Phase 0: 知识库
- docs/knowledge-base/loco-rs-patterns.md — loco-rs 10 个可借鉴模式研究
Phase 1: 数据层重构
- crates/zclaw-saas/src/models/ — 15 个 FromRow 类型化模型
- Login 3 次查询合并为 1 次 AccountLoginRow 查询
- 所有 service 文件从元组解构迁移到 FromRow 结构体
Phase 2: Worker + Scheduler 系统
- crates/zclaw-saas/src/workers/ — Worker trait + 5 个具体实现
- crates/zclaw-saas/src/scheduler.rs — TOML 声明式调度器
- crates/zclaw-saas/src/tasks/ — CLI 任务系统
Phase 3: 性能修复
- Relay N+1 查询 → 精准 SQL (relay/handlers.rs)
- Config RwLock → AtomicU32 无锁 rate limit (state.rs, middleware.rs)
- SSE std::sync::Mutex → tokio::sync::Mutex (relay/service.rs)
- /auth/refresh 阻塞清理 → Scheduler 定期执行
Phase 4: 多环境配置
- config/saas-{development,production,test}.toml
- ZCLAW_ENV 环境选择 + ZCLAW_SAAS_CONFIG 精确覆盖
- scheduler 配置集成到 TOML
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import useSWR from 'swr'
|
||
import {
|
||
Plus,
|
||
Loader2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Trash2,
|
||
Copy,
|
||
Check,
|
||
AlertTriangle,
|
||
} from 'lucide-react'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
DialogDescription,
|
||
} from '@/components/ui/dialog'
|
||
import { api } from '@/lib/api-client'
|
||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
||
import { ApiRequestError } from '@/lib/api-client'
|
||
import { formatDate, getSwrErrorMessage } from '@/lib/utils'
|
||
import { TableSkeleton } from '@/components/ui/skeleton'
|
||
import type { TokenInfo } from '@/lib/types'
|
||
|
||
const PAGE_SIZE = 20
|
||
|
||
const allPermissions = [
|
||
{ key: 'chat', label: '对话' },
|
||
{ key: 'relay', label: '中转' },
|
||
{ key: 'admin', label: '管理' },
|
||
]
|
||
|
||
export default function ApiKeysPage() {
|
||
const [page, setPage] = useState(1)
|
||
const [mutationError, setMutationError] = useState('')
|
||
|
||
const { data, error: swrError, isLoading, mutate } = useSWR(
|
||
['tokens', page],
|
||
() => api.tokens.list({ page, page_size: PAGE_SIZE }),
|
||
)
|
||
|
||
const tokens = data?.items ?? []
|
||
const total = data?.total ?? 0
|
||
const error = getSwrErrorMessage(swrError) || mutationError
|
||
|
||
// 创建 Dialog
|
||
const [createOpen, setCreateOpen] = useState(false)
|
||
const [createForm, setCreateForm] = useState({ name: '', expires_days: '', permissions: ['chat'] as string[] })
|
||
const [creating, setCreating] = useState(false)
|
||
|
||
// 创建成功显示 token
|
||
const [createdToken, setCreatedToken] = useState<TokenInfo | null>(null)
|
||
const [copied, setCopied] = useState(false)
|
||
|
||
// 撤销确认
|
||
const [revokeTarget, setRevokeTarget] = useState<TokenInfo | null>(null)
|
||
const [revoking, setRevoking] = useState(false)
|
||
|
||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||
|
||
function togglePermission(perm: string) {
|
||
setCreateForm((prev) => ({
|
||
...prev,
|
||
permissions: prev.permissions.includes(perm)
|
||
? prev.permissions.filter((p) => p !== perm)
|
||
: [...prev.permissions, perm],
|
||
}))
|
||
}
|
||
|
||
async function handleCreate() {
|
||
if (!createForm.name.trim() || createForm.permissions.length === 0) return
|
||
setCreating(true)
|
||
try {
|
||
const payload = {
|
||
name: createForm.name.trim(),
|
||
expires_days: createForm.expires_days ? parseInt(createForm.expires_days, 10) : undefined,
|
||
permissions: createForm.permissions,
|
||
}
|
||
const res = await api.tokens.create(payload)
|
||
setCreateOpen(false)
|
||
setCreatedToken(res)
|
||
setCreateForm({ name: '', expires_days: '', permissions: ['chat'] })
|
||
mutate()
|
||
} catch (err) {
|
||
if (err instanceof ApiRequestError) setMutationError(err.body.message)
|
||
} finally {
|
||
setCreating(false)
|
||
}
|
||
}
|
||
|
||
async function handleRevoke() {
|
||
if (!revokeTarget) return
|
||
setRevoking(true)
|
||
try {
|
||
await api.tokens.revoke(revokeTarget.id)
|
||
setRevokeTarget(null)
|
||
mutate()
|
||
} catch (err) {
|
||
if (err instanceof ApiRequestError) setMutationError(err.body.message)
|
||
} finally {
|
||
setRevoking(false)
|
||
}
|
||
}
|
||
|
||
async function copyToken() {
|
||
if (!createdToken?.token) return
|
||
try {
|
||
await navigator.clipboard.writeText(createdToken.token)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
} catch {
|
||
// Fallback
|
||
const textarea = document.createElement('textarea')
|
||
textarea.value = createdToken.token
|
||
document.body.appendChild(textarea)
|
||
textarea.select()
|
||
document.execCommand('copy')
|
||
document.body.removeChild(textarea)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div />
|
||
<Button onClick={() => setCreateOpen(true)}>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
新建密钥
|
||
</Button>
|
||
</div>
|
||
|
||
{error && <ErrorBanner message={error} onDismiss={() => setMutationError('')} />}
|
||
|
||
{isLoading ? (
|
||
<TableSkeleton rows={6} cols={7} />
|
||
) : error ? null : tokens.length === 0 ? (
|
||
<EmptyState />
|
||
) : (
|
||
<>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>名称</TableHead>
|
||
<TableHead>前缀</TableHead>
|
||
<TableHead>权限</TableHead>
|
||
<TableHead>最后使用</TableHead>
|
||
<TableHead>过期时间</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{tokens.map((t) => (
|
||
<TableRow key={t.id}>
|
||
<TableCell className="font-medium">{t.name}</TableCell>
|
||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||
{t.token_prefix}...
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex gap-1">
|
||
{t.permissions.map((p) => (
|
||
<Badge key={p} variant="outline" className="text-xs">
|
||
{p}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||
{t.last_used_at ? formatDate(t.last_used_at) : '未使用'}
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||
{t.expires_at ? formatDate(t.expires_at) : '永不过期'}
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||
{formatDate(t.created_at)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<Button variant="ghost" size="icon" onClick={() => setRevokeTarget(t)} title="撤销">
|
||
<Trash2 className="h-4 w-4 text-destructive" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
|
||
<div className="flex items-center justify-between text-sm">
|
||
<p className="text-muted-foreground">
|
||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||
上一页
|
||
</Button>
|
||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||
下一页
|
||
<ChevronRight className="h-4 w-4 ml-1" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 创建 Dialog */}
|
||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>新建 API 密钥</DialogTitle>
|
||
<DialogDescription>创建新的 API 密钥用于接口调用</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>名称 *</Label>
|
||
<Input
|
||
value={createForm.name}
|
||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||
placeholder="例如: 生产环境"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>过期天数 (留空则永不过期)</Label>
|
||
<Input
|
||
type="number"
|
||
value={createForm.expires_days}
|
||
onChange={(e) => setCreateForm({ ...createForm, expires_days: e.target.value })}
|
||
placeholder="365"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>权限 *</Label>
|
||
<div className="flex flex-wrap gap-3 mt-1">
|
||
{allPermissions.map((perm) => (
|
||
<label
|
||
key={perm.key}
|
||
className="flex items-center gap-2 cursor-pointer"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={createForm.permissions.includes(perm.key)}
|
||
onChange={() => togglePermission(perm.key)}
|
||
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
|
||
/>
|
||
<span className="text-sm text-foreground">{perm.label}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||
<Button onClick={handleCreate} disabled={creating || !createForm.name.trim() || createForm.permissions.length === 0}>
|
||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
创建
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 创建成功 Dialog */}
|
||
<Dialog open={!!createdToken} onOpenChange={() => setCreatedToken(null)}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<AlertTriangle className="h-5 w-5 text-yellow-400" />
|
||
密钥已创建
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
请立即复制并安全保存此密钥,关闭后将无法再次查看完整密钥。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="rounded-md bg-muted p-4">
|
||
<p className="text-xs text-muted-foreground mb-2">完整密钥</p>
|
||
<p className="font-mono text-sm break-all text-foreground">
|
||
{createdToken?.token}
|
||
</p>
|
||
</div>
|
||
<div className="rounded-md bg-yellow-500/10 border border-yellow-500/20 p-3 text-sm text-yellow-400">
|
||
此密钥仅显示一次。请确保已保存到安全的位置。
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button onClick={copyToken} variant="outline">
|
||
{copied ? <Check className="h-4 w-4 mr-2" /> : <Copy className="h-4 w-4 mr-2" />}
|
||
{copied ? '已复制' : '复制密钥'}
|
||
</Button>
|
||
<Button onClick={() => setCreatedToken(null)}>我已保存</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 撤销确认 */}
|
||
<Dialog open={!!revokeTarget} onOpenChange={() => setRevokeTarget(null)}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>确认撤销</DialogTitle>
|
||
<DialogDescription>
|
||
确定要撤销密钥 "{revokeTarget?.name}" 吗?使用此密钥的应用将立即失去访问权限。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setRevokeTarget(null)}>取消</Button>
|
||
<Button variant="destructive" onClick={handleRevoke} disabled={revoking}>
|
||
{revoking && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
撤销
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|