chore: initial commit
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user