167 lines
6.7 KiB
TypeScript
167 lines
6.7 KiB
TypeScript
import { useState, type FormEvent } from 'react'
|
|
import { api, type Channel, type Key, type Stats } from '../api'
|
|
import { channelName, num, type Action } from '../common'
|
|
import { formatTime, keyExpiry } from '../time'
|
|
import { IconPlus } from '../components/icons'
|
|
import { Badge, Dialog, Feedback, Table } from '../components/ui'
|
|
|
|
interface KeyForm {
|
|
name: string
|
|
all_channels: boolean
|
|
channels: string[]
|
|
expires_at: string
|
|
}
|
|
|
|
const EMPTY_FORM: KeyForm = { name: '', all_channels: false, channels: [], expires_at: '' }
|
|
|
|
interface KeysProps {
|
|
keys: Key[]
|
|
stats: Stats | null
|
|
channels: Channel[]
|
|
timezone: string
|
|
busy: boolean
|
|
error: string
|
|
notice: string
|
|
action: Action
|
|
refresh: () => void
|
|
notify: (msg: string) => void
|
|
clearError: () => void
|
|
/** 跳转到概览页并按该 Key 筛选统计。 */
|
|
onViewStats: (k: Key) => void
|
|
}
|
|
|
|
export function KeysPage({ keys, stats, channels, timezone, busy, error, notice, action, refresh, notify, clearError, onViewStats }: KeysProps) {
|
|
const [creating, setCreating] = useState(false)
|
|
const [keyForm, setKeyForm] = useState<KeyForm>(EMPTY_FORM)
|
|
const [newKey, setNewKey] = useState('')
|
|
|
|
const time = formatTime
|
|
|
|
function openCreate() {
|
|
setCreating(true)
|
|
setKeyForm(EMPTY_FORM)
|
|
}
|
|
|
|
function closeCreate() {
|
|
setCreating(false)
|
|
clearError()
|
|
}
|
|
|
|
function submit(e: FormEvent) {
|
|
e.preventDefault()
|
|
action(async () => {
|
|
if (!keyForm.all_channels && !keyForm.channels.length) throw new Error('请选择至少一个渠道,或授权全部渠道。')
|
|
const expires_at = keyExpiry(keyForm.expires_at)
|
|
const r = await api<{ key: string }>('/keys', 'POST', { ...keyForm, channels: keyForm.all_channels ? [] : keyForm.channels, expires_at })
|
|
setNewKey(r.key)
|
|
setCreating(false)
|
|
refresh()
|
|
})
|
|
}
|
|
|
|
function toggle(k: Key) {
|
|
action(async () => {
|
|
await api('/keys/' + encodeURIComponent(k.id), 'PATCH', { enabled: !k.enabled })
|
|
refresh()
|
|
})
|
|
}
|
|
|
|
function revoke(k: Key) {
|
|
if (!confirm(`确定撤销 Key“${k.name}”?历史记录将保留。`)) return
|
|
action(async () => {
|
|
await api('/keys/' + encodeURIComponent(k.id), 'DELETE')
|
|
refresh()
|
|
notify('Key 已撤销,历史记录已保留。')
|
|
})
|
|
}
|
|
|
|
function copyKey() {
|
|
action(async () => {
|
|
if (!navigator.clipboard) throw new Error('无法自动复制,请选中密钥手动复制。')
|
|
await navigator.clipboard.writeText(newKey)
|
|
notify('密钥已复制。')
|
|
})
|
|
}
|
|
|
|
function toggleChannel(id: string, checked: boolean) {
|
|
setKeyForm({
|
|
...keyForm,
|
|
channels: checked ? [...keyForm.channels, id] : keyForm.channels.filter(c => c !== id),
|
|
})
|
|
}
|
|
|
|
return (
|
|
<section className="panel">
|
|
<div className="section-head">
|
|
<h2>访问密钥 <small>{keys.length}</small></h2>
|
|
<button className="primary" onClick={openCreate}><IconPlus />创建 Key</button>
|
|
</div>
|
|
<Table
|
|
heads={['名称 / 前缀', '渠道权限', '状态', '请求 / tokens', '有效期 / 创建时间', '操作']}
|
|
rows={keys.map(k => {
|
|
const u = stats?.by_key?.find(u => u.key_id === k.id)
|
|
return [
|
|
<><button className="link" onClick={() => onViewStats(k)}>{k.name}</button><code className="sub">{k.prefix}…</code></>,
|
|
k.all_channels ? '全部渠道' : k.channels?.map(id => channelName(channels, id)).join('、') || '无渠道权限',
|
|
<Badge variant={k.revoked ? 'danger' : k.enabled ? 'good' : 'neutral'}>{k.revoked ? '已撤销' : k.enabled ? '启用' : '停用'}</Badge>,
|
|
<>{num(u?.requests ?? 0)} 次<small className="sub">输入 {num(u?.input_tokens ?? 0)} / 输出 {num(u?.output_tokens ?? 0)}</small></>,
|
|
<>{time(k.expires_at) === '—' ? '永不过期' : time(k.expires_at)}<small className="sub">创建于 {time(k.created_at)}</small></>,
|
|
<div className="actions">
|
|
<button disabled={busy || k.revoked} onClick={() => toggle(k)}>{k.enabled ? '停用' : '启用'}</button>
|
|
<button className="danger" disabled={busy} onClick={() => revoke(k)}>撤销</button>
|
|
</div>,
|
|
]
|
|
})}
|
|
/>
|
|
{creating && (
|
|
<Dialog id="key-title" title="创建访问密钥" onClose={closeCreate}>
|
|
<Feedback error={error} notice={notice} />
|
|
<form onSubmit={submit}>
|
|
<fieldset disabled={busy}>
|
|
<label>名称
|
|
<input autoFocus required value={keyForm.name} onChange={e => setKeyForm({ ...keyForm, name: e.target.value })} />
|
|
</label>
|
|
<label>有效期({timezone},可选)
|
|
<input type="datetime-local" value={keyForm.expires_at} onChange={e => setKeyForm({ ...keyForm, expires_at: e.target.value })} />
|
|
<small>留空表示永不过期。</small>
|
|
</label>
|
|
<label className="check">
|
|
<input type="checkbox" checked={keyForm.all_channels} onChange={e => setKeyForm({ ...keyForm, all_channels: e.target.checked })} />允许全部渠道(包含未来新增渠道)
|
|
</label>
|
|
{!keyForm.all_channels && (
|
|
<div className="channel-options">
|
|
<p>选择允许访问的渠道</p>
|
|
{!channels.length && <p className="muted">暂无渠道,请先创建渠道。</p>}
|
|
{channels.map(c => (
|
|
<label className="check" key={c.id}>
|
|
<input type="checkbox" checked={keyForm.channels.includes(c.id)} onChange={e => toggleChannel(c.id, e.target.checked)} />
|
|
{c.name}{!c.enabled && '(停用)'}
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="dialog-actions">
|
|
<button type="button" onClick={closeCreate}>取消</button>
|
|
<button className="primary">{busy ? '创建中…' : '创建 Key'}</button>
|
|
</div>
|
|
</fieldset>
|
|
</form>
|
|
</Dialog>
|
|
)}
|
|
{newKey && (
|
|
<Dialog id="secret-title" title="Key 已创建">
|
|
<div className="alert warning">完整密钥仅展示这一次。请立即复制并妥善保存,关闭后无法再次查看。</div>
|
|
<label>完整密钥
|
|
<textarea autoFocus readOnly rows={3} value={newKey} onFocus={e => e.target.select()} />
|
|
</label>
|
|
<Feedback error={error} notice={notice} />
|
|
<div className="dialog-actions">
|
|
<button onClick={() => { setNewKey(''); notify('') }}>我已保存,关闭</button>
|
|
<button className="primary" onClick={copyKey}>复制密钥</button>
|
|
</div>
|
|
</Dialog>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|