feat(saas): Phase 2 Admin Web 管理后台 — 完整 CRUD + Dashboard 统计
后端: - 添加 GET /api/v1/stats/dashboard 聚合统计端点 (账号数/活跃服务商/今日请求/今日Token用量等7项指标) - 需要 account:admin 权限 Admin 前端 (Next.js 14 + shadcn/ui + Tailwind + Recharts): - 设计系统: Dark Mode OLED (#020617 背景, #22C55E CTA) - 登录页: 双栏布局, 品牌区 + 表单 - Dashboard 布局: Sidebar 导航 + Header + 主内容区 - 仪表盘: 4 统计卡片 + AreaChart 请求趋势 + BarChart Token用量 - 8 个 CRUD 页面: - 账号管理 (搜索/角色/状态筛选, 编辑/启用禁用) - 服务商 (CRUD + API Key masked) - 模型管理 (Provider筛选, CRUD) - API 密钥 (创建/撤销, 一次性显示token) - 用量统计 (LineChart + BarChart) - 中转任务 (状态筛选, 展开详情) - 系统配置 (分类Tab, 编辑) - 操作日志 (Action筛选, 展开详情) - 14 个 shadcn 风格 UI 组件 (手写实现) - 类型化 API 客户端 (SaaSClient, 20+ 方法, 401 自动跳转) - AuthGuard 路由保护 + useAuth() hook 验证: tsc --noEmit 零 error, pnpm build 13 页面成功, cargo test 21 通过
This commit is contained in:
2
admin/.gitignore
vendored
Normal file
2
admin/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.next/
|
||||||
|
node_modules/
|
||||||
5
admin/next-env.d.ts
vendored
Normal file
5
admin/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
4
admin/next.config.js
Normal file
4
admin/next.config.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
37
admin/package.json
Normal file
37
admin/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "zclaw-admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.484.0",
|
||||||
|
"next": "14.2.29",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^2.15.3",
|
||||||
|
"tailwind-merge": "^3.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.17.19",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.30.2"
|
||||||
|
}
|
||||||
2171
admin/pnpm-lock.yaml
generated
Normal file
2171
admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
admin/postcss.config.js
Normal file
6
admin/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
393
admin/src/app/(dashboard)/accounts/page.tsx
Normal file
393
admin/src/app/(dashboard)/accounts/page.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Ban,
|
||||||
|
CheckCircle2,
|
||||||
|
} 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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
import type { AccountPublic } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
super_admin: '超级管理员',
|
||||||
|
admin: '管理员',
|
||||||
|
user: '普通用户',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, 'success' | 'destructive' | 'warning'> = {
|
||||||
|
active: 'success',
|
||||||
|
disabled: 'destructive',
|
||||||
|
suspended: 'warning',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: '正常',
|
||||||
|
disabled: '已禁用',
|
||||||
|
suspended: '已暂停',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccountsPage() {
|
||||||
|
const [accounts, setAccounts] = useState<AccountPublic[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 编辑 Dialog
|
||||||
|
const [editTarget, setEditTarget] = useState<AccountPublic | null>(null)
|
||||||
|
const [editForm, setEditForm] = useState({ display_name: '', email: '', role: 'user' })
|
||||||
|
const [editSaving, setEditSaving] = useState(false)
|
||||||
|
|
||||||
|
// 确认 Dialog
|
||||||
|
const [confirmTarget, setConfirmTarget] = useState<{ id: string; action: string; status: string } | null>(null)
|
||||||
|
const [confirmSaving, setConfirmSaving] = useState(false)
|
||||||
|
|
||||||
|
const fetchAccounts = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||||
|
if (search.trim()) params.search = search.trim()
|
||||||
|
if (roleFilter !== 'all') params.role = roleFilter
|
||||||
|
if (statusFilter !== 'all') params.status = statusFilter
|
||||||
|
|
||||||
|
const res = await api.accounts.list(params)
|
||||||
|
setAccounts(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message)
|
||||||
|
} else {
|
||||||
|
setError('加载失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, search, roleFilter, statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccounts()
|
||||||
|
}, [fetchAccounts])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
function openEditDialog(account: AccountPublic) {
|
||||||
|
setEditTarget(account)
|
||||||
|
setEditForm({
|
||||||
|
display_name: account.display_name,
|
||||||
|
email: account.email,
|
||||||
|
role: account.role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave() {
|
||||||
|
if (!editTarget) return
|
||||||
|
setEditSaving(true)
|
||||||
|
try {
|
||||||
|
await api.accounts.update(editTarget.id, {
|
||||||
|
display_name: editForm.display_name,
|
||||||
|
email: editForm.email,
|
||||||
|
role: editForm.role as AccountPublic['role'],
|
||||||
|
})
|
||||||
|
setEditTarget(null)
|
||||||
|
fetchAccounts()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setEditSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConfirmDialog(account: AccountPublic) {
|
||||||
|
const newStatus = account.status === 'active' ? 'disabled' : 'active'
|
||||||
|
setConfirmTarget({
|
||||||
|
id: account.id,
|
||||||
|
action: newStatus === 'disabled' ? '禁用' : '启用',
|
||||||
|
status: newStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmSave() {
|
||||||
|
if (!confirmTarget) return
|
||||||
|
setConfirmSaving(true)
|
||||||
|
try {
|
||||||
|
await api.accounts.updateStatus(confirmTarget.id, {
|
||||||
|
status: confirmTarget.status as AccountPublic['status'],
|
||||||
|
})
|
||||||
|
setConfirmTarget(null)
|
||||||
|
fetchAccounts()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setConfirmSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 搜索和筛选 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户名 / 邮箱 / 显示名..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="角色筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部角色</SelectItem>
|
||||||
|
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||||||
|
<SelectItem value="admin">管理员</SelectItem>
|
||||||
|
<SelectItem value="user">普通用户</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="状态筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="active">正常</SelectItem>
|
||||||
|
<SelectItem value="disabled">已禁用</SelectItem>
|
||||||
|
<SelectItem value="suspended">已暂停</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : accounts.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>用户名</TableHead>
|
||||||
|
<TableHead>邮箱</TableHead>
|
||||||
|
<TableHead>显示名</TableHead>
|
||||||
|
<TableHead>角色</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<TableRow key={account.id}>
|
||||||
|
<TableCell className="font-medium">{account.username}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{account.email}</TableCell>
|
||||||
|
<TableCell>{account.display_name || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={account.role === 'super_admin' ? 'default' : account.role === 'admin' ? 'info' : 'secondary'}>
|
||||||
|
{roleLabels[account.role] || account.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusColors[account.status] || 'secondary'}>
|
||||||
|
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current" />
|
||||||
|
{statusLabels[account.status] || account.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(account.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openEditDialog(account)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openConfirmDialog(account)}
|
||||||
|
title={account.status === 'active' ? '禁用' : '启用'}
|
||||||
|
>
|
||||||
|
{account.status === 'active' ? (
|
||||||
|
<Ban className="h-4 w-4 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑账号</DialogTitle>
|
||||||
|
<DialogDescription>修改账号信息</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>显示名</Label>
|
||||||
|
<Input
|
||||||
|
value={editForm.display_name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, display_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>邮箱</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={editForm.email}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>角色</Label>
|
||||||
|
<Select value={editForm.role} onValueChange={(v) => setEditForm({ ...editForm, role: v })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">普通用户</SelectItem>
|
||||||
|
<SelectItem value="admin">管理员</SelectItem>
|
||||||
|
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEditSave} disabled={editSaving}>
|
||||||
|
{editSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 确认 Dialog */}
|
||||||
|
<Dialog open={!!confirmTarget} onOpenChange={() => setConfirmTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认{confirmTarget?.action}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要{confirmTarget?.action}该账号吗?此操作将立即生效。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setConfirmTarget(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={confirmTarget?.status === 'disabled' ? 'destructive' : 'default'}
|
||||||
|
onClick={handleConfirmSave}
|
||||||
|
disabled={confirmSaving}
|
||||||
|
>
|
||||||
|
{confirmSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
确认{confirmTarget?.action}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
351
admin/src/app/(dashboard)/api-keys/page.tsx
Normal file
351
admin/src/app/(dashboard)/api-keys/page.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
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 { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
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 [tokens, setTokens] = useState<TokenInfo[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 创建 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 fetchTokens = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.tokens.list({ page, page_size: PAGE_SIZE })
|
||||||
|
setTokens(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTokens()
|
||||||
|
}, [fetchTokens])
|
||||||
|
|
||||||
|
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'] })
|
||||||
|
fetchTokens()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke() {
|
||||||
|
if (!revokeTarget) return
|
||||||
|
setRevoking(true)
|
||||||
|
try {
|
||||||
|
await api.tokens.revoke(revokeTarget.id)
|
||||||
|
setRevokeTarget(null)
|
||||||
|
fetchTokens()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(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 && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
270
admin/src/app/(dashboard)/config/page.tsx
Normal file
270
admin/src/app/(dashboard)/config/page.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
RotateCcw,
|
||||||
|
} 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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import type { ConfigItem } from '@/lib/types'
|
||||||
|
|
||||||
|
const sourceLabels: Record<string, string> = {
|
||||||
|
default: '默认值',
|
||||||
|
env: '环境变量',
|
||||||
|
db: '数据库',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceVariants: Record<string, 'secondary' | 'info' | 'default'> = {
|
||||||
|
default: 'secondary',
|
||||||
|
env: 'info',
|
||||||
|
db: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfigPage() {
|
||||||
|
const [configs, setConfigs] = useState<ConfigItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
|
|
||||||
|
// 编辑 Dialog
|
||||||
|
const [editTarget, setEditTarget] = useState<ConfigItem | null>(null)
|
||||||
|
const [editValue, setEditValue] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const fetchConfigs = useCallback(async (category?: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = {}
|
||||||
|
if (category && category !== 'all') params.category = category
|
||||||
|
const res = await api.config.list(params)
|
||||||
|
setConfigs(res)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfigs(activeTab)
|
||||||
|
}, [fetchConfigs, activeTab])
|
||||||
|
|
||||||
|
function openEditDialog(config: ConfigItem) {
|
||||||
|
setEditTarget(config)
|
||||||
|
setEditValue(config.current_value !== undefined ? String(config.current_value) : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!editTarget) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
let parsedValue: string | number | boolean = editValue
|
||||||
|
if (editTarget.value_type === 'number') {
|
||||||
|
parsedValue = parseFloat(editValue) || 0
|
||||||
|
} else if (editTarget.value_type === 'boolean') {
|
||||||
|
parsedValue = editValue === 'true'
|
||||||
|
}
|
||||||
|
await api.config.update(editTarget.id, { value: parsedValue })
|
||||||
|
setEditTarget(null)
|
||||||
|
fetchConfigs(activeTab)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value === undefined || value === null) return '-'
|
||||||
|
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = ['all', 'auth', 'relay', 'model', 'system']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 分类 Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<TabsTrigger key={cat} value={cat}>
|
||||||
|
{cat === 'all' ? '全部' : cat}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : configs.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无配置项
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>分类</TableHead>
|
||||||
|
<TableHead>Key</TableHead>
|
||||||
|
<TableHead>当前值</TableHead>
|
||||||
|
<TableHead>默认值</TableHead>
|
||||||
|
<TableHead>来源</TableHead>
|
||||||
|
<TableHead>需重启</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{configs.map((config) => (
|
||||||
|
<TableRow key={config.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{config.category}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{config.key_path}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm max-w-[200px] truncate">
|
||||||
|
{formatValue(config.current_value)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
||||||
|
{formatValue(config.default_value)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={sourceVariants[config.source] || 'secondary'}>
|
||||||
|
{sourceLabels[config.source] || config.source}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{config.requires_restart ? (
|
||||||
|
<Badge variant="warning">是</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">否</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-[250px] truncate">
|
||||||
|
{config.description || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEditDialog(config)} title="编辑">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 编辑 Dialog */}
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑配置</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
修改 {editTarget?.key_path} 的值
|
||||||
|
{editTarget?.requires_restart && (
|
||||||
|
<span className="block mt-1 text-yellow-400 text-xs">
|
||||||
|
注意: 修改此配置需要重启服务才能生效
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Key</Label>
|
||||||
|
<Input value={editTarget?.key_path || ''} disabled />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>类型</Label>
|
||||||
|
<Input value={editTarget?.value_type || ''} disabled />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
新值 {editTarget?.default_value !== undefined && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
(默认: {formatValue(editTarget.default_value)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{editTarget?.value_type === 'boolean' ? (
|
||||||
|
<Select value={editValue} onValueChange={setEditValue}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">true</SelectItem>
|
||||||
|
<SelectItem value="false">false</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type={editTarget?.value_type === 'number' ? 'number' : 'text'}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (editTarget?.default_value !== undefined) {
|
||||||
|
setEditValue(String(editTarget.default_value))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
恢复默认
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setEditTarget(null)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
218
admin/src/app/(dashboard)/layout.tsx
Normal file
218
admin/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
Server,
|
||||||
|
Cpu,
|
||||||
|
Key,
|
||||||
|
BarChart3,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Settings,
|
||||||
|
FileText,
|
||||||
|
LogOut,
|
||||||
|
ChevronLeft,
|
||||||
|
Menu,
|
||||||
|
Bell,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { AuthGuard, useAuth } from '@/components/auth-guard'
|
||||||
|
import { logout } from '@/lib/auth'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', label: '仪表盘', icon: LayoutDashboard },
|
||||||
|
{ href: '/accounts', label: '账号管理', icon: Users },
|
||||||
|
{ href: '/providers', label: '服务商', icon: Server },
|
||||||
|
{ href: '/models', label: '模型管理', icon: Cpu },
|
||||||
|
{ href: '/api-keys', label: 'API 密钥', icon: Key },
|
||||||
|
{ href: '/usage', label: '用量统计', icon: BarChart3 },
|
||||||
|
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight },
|
||||||
|
{ href: '/config', label: '系统配置', icon: Settings },
|
||||||
|
{ href: '/logs', label: '操作日志', icon: FileText },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
collapsed: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const { account } = useAuth()
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout()
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-border bg-card transition-all duration-300',
|
||||||
|
collapsed ? 'w-16' : 'w-64',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex h-14 items-center border-b border-border px-4">
|
||||||
|
<Link href="/" className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground font-bold text-sm">
|
||||||
|
Z
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-bold text-foreground">ZCLAW</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">Admin</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 导航 */}
|
||||||
|
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2 px-2">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === '/'
|
||||||
|
: pathname.startsWith(item.href)
|
||||||
|
const Icon = item.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-200 cursor-pointer',
|
||||||
|
isActive
|
||||||
|
? 'bg-muted text-green-400'
|
||||||
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||||
|
collapsed && 'justify-center px-2',
|
||||||
|
)}
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
{!collapsed && <span>{item.label}</span>}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 底部折叠按钮 */}
|
||||||
|
<div className="border-t border-border p-2">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex w-full items-center justify-center rounded-md px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ChevronLeft
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 transition-transform duration-200',
|
||||||
|
collapsed && 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用户信息 */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="border-t border-border p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground">
|
||||||
|
{account?.display_name?.[0] || account?.username?.[0] || 'A'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{account?.display_name || account?.username || 'Admin'}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{account?.role || 'admin'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-destructive transition-colors duration-200 cursor-pointer"
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const currentNav = navItems.find(
|
||||||
|
(item) =>
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === '/'
|
||||||
|
: pathname.startsWith(item.href),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-30 flex h-14 items-center border-b border-border bg-background/80 backdrop-blur-sm px-6">
|
||||||
|
{/* 移动端菜单按钮 */}
|
||||||
|
<MobileMenuButton />
|
||||||
|
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">
|
||||||
|
{currentNav?.label || '仪表盘'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{/* 通知 */}
|
||||||
|
<button
|
||||||
|
className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||||
|
title="通知"
|
||||||
|
>
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileMenuButton() {
|
||||||
|
// Placeholder for mobile menu toggle
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="mr-3 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 lg:hidden cursor-pointer"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 flex-col transition-all duration-300',
|
||||||
|
sidebarCollapsed ? 'ml-16' : 'ml-64',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 overflow-auto p-6 scrollbar-thin">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthGuard>
|
||||||
|
)
|
||||||
|
}
|
||||||
436
admin/src/app/(dashboard)/models/page.tsx
Normal file
436
admin/src/app/(dashboard)/models/page.tsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
} 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 { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatNumber } from '@/lib/utils'
|
||||||
|
import type { Model, Provider } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
interface ModelForm {
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
alias: string
|
||||||
|
context_window: string
|
||||||
|
max_output_tokens: string
|
||||||
|
supports_streaming: boolean
|
||||||
|
supports_vision: boolean
|
||||||
|
enabled: boolean
|
||||||
|
pricing_input: string
|
||||||
|
pricing_output: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: ModelForm = {
|
||||||
|
provider_id: '',
|
||||||
|
model_id: '',
|
||||||
|
alias: '',
|
||||||
|
context_window: '4096',
|
||||||
|
max_output_tokens: '4096',
|
||||||
|
supports_streaming: true,
|
||||||
|
supports_vision: false,
|
||||||
|
enabled: true,
|
||||||
|
pricing_input: '',
|
||||||
|
pricing_output: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModelsPage() {
|
||||||
|
const [models, setModels] = useState<Model[]>([])
|
||||||
|
const [providers, setProviders] = useState<Provider[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [providerFilter, setProviderFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Dialog
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<Model | null>(null)
|
||||||
|
const [form, setForm] = useState<ModelForm>(emptyForm)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Model | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const fetchModels = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||||
|
if (providerFilter !== 'all') params.provider_id = providerFilter
|
||||||
|
const res = await api.models.list(params)
|
||||||
|
setModels(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, providerFilter])
|
||||||
|
|
||||||
|
const fetchProviders = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.providers.list({ page: 1, page_size: 100 })
|
||||||
|
setProviders(res.items)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchModels()
|
||||||
|
fetchProviders()
|
||||||
|
}, [fetchModels, fetchProviders])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
const providerMap = new Map(providers.map((p) => [p.id, p.display_name || p.name]))
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditTarget(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(model: Model) {
|
||||||
|
setEditTarget(model)
|
||||||
|
setForm({
|
||||||
|
provider_id: model.provider_id,
|
||||||
|
model_id: model.model_id,
|
||||||
|
alias: model.alias,
|
||||||
|
context_window: model.context_window.toString(),
|
||||||
|
max_output_tokens: model.max_output_tokens.toString(),
|
||||||
|
supports_streaming: model.supports_streaming,
|
||||||
|
supports_vision: model.supports_vision,
|
||||||
|
enabled: model.enabled,
|
||||||
|
pricing_input: model.pricing_input.toString(),
|
||||||
|
pricing_output: model.pricing_output.toString(),
|
||||||
|
})
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.model_id.trim() || !form.provider_id) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
provider_id: form.provider_id,
|
||||||
|
model_id: form.model_id.trim(),
|
||||||
|
alias: form.alias.trim(),
|
||||||
|
context_window: parseInt(form.context_window, 10) || 4096,
|
||||||
|
max_output_tokens: parseInt(form.max_output_tokens, 10) || 4096,
|
||||||
|
supports_streaming: form.supports_streaming,
|
||||||
|
supports_vision: form.supports_vision,
|
||||||
|
enabled: form.enabled,
|
||||||
|
pricing_input: parseFloat(form.pricing_input) || 0,
|
||||||
|
pricing_output: parseFloat(form.pricing_output) || 0,
|
||||||
|
}
|
||||||
|
if (editTarget) {
|
||||||
|
await api.models.update(editTarget.id, payload)
|
||||||
|
} else {
|
||||||
|
await api.models.create(payload)
|
||||||
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
|
fetchModels()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await api.models.delete(deleteTarget.id)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchModels()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Select value={providerFilter} onValueChange={(v) => { setProviderFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="按服务商筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部服务商</SelectItem>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.display_name || p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建模型
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>模型 ID</TableHead>
|
||||||
|
<TableHead>别名</TableHead>
|
||||||
|
<TableHead>服务商</TableHead>
|
||||||
|
<TableHead>上下文窗口</TableHead>
|
||||||
|
<TableHead>最大输出</TableHead>
|
||||||
|
<TableHead>流式</TableHead>
|
||||||
|
<TableHead>视觉</TableHead>
|
||||||
|
<TableHead>启用</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{models.map((m) => (
|
||||||
|
<TableRow key={m.id}>
|
||||||
|
<TableCell className="font-mono text-sm">{m.model_id}</TableCell>
|
||||||
|
<TableCell>{m.alias || '-'}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{providerMap.get(m.provider_id) || m.provider_id.slice(0, 8)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(m.context_window)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(m.max_output_tokens)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={m.supports_streaming ? 'success' : 'secondary'}>
|
||||||
|
{m.supports_streaming ? '是' : '否'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={m.supports_vision ? 'success' : 'secondary'}>
|
||||||
|
{m.supports_vision ? '是' : '否'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={m.enabled ? 'success' : 'destructive'}>
|
||||||
|
{m.enabled ? '启用' : '禁用'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEditDialog(m)} title="编辑">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(m)} title="删除">
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editTarget ? '编辑模型' : '新建模型'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editTarget ? '修改模型配置' : '添加新的 AI 模型'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>服务商 *</Label>
|
||||||
|
<Select value={form.provider_id} onValueChange={(v) => setForm({ ...form, provider_id: v })} disabled={!!editTarget}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择服务商" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.display_name || p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>模型 ID *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.model_id}
|
||||||
|
onChange={(e) => setForm({ ...form, model_id: e.target.value })}
|
||||||
|
placeholder="gpt-4o"
|
||||||
|
disabled={!!editTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>别名</Label>
|
||||||
|
<Input
|
||||||
|
value={form.alias}
|
||||||
|
onChange={(e) => setForm({ ...form, alias: e.target.value })}
|
||||||
|
placeholder="GPT-4o"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>上下文窗口</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.context_window}
|
||||||
|
onChange={(e) => setForm({ ...form, context_window: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>最大输出 Tokens</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.max_output_tokens}
|
||||||
|
onChange={(e) => setForm({ ...form, max_output_tokens: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Input 定价 ($/1M tokens)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.pricing_input}
|
||||||
|
onChange={(e) => setForm({ ...form, pricing_input: e.target.value })}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Output 定价 ($/1M tokens)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.pricing_output}
|
||||||
|
onChange={(e) => setForm({ ...form, pricing_output: e.target.value })}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={form.supports_streaming} onCheckedChange={(v) => setForm({ ...form, supports_streaming: v })} />
|
||||||
|
<Label>流式</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={form.supports_vision} onCheckedChange={(v) => setForm({ ...form, supports_vision: v })} />
|
||||||
|
<Label>视觉</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={form.enabled} onCheckedChange={(v) => setForm({ ...form, enabled: v })} />
|
||||||
|
<Label>启用</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !form.model_id.trim() || !form.provider_id}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 删除确认 */}
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要删除模型 "{deleteTarget?.alias || deleteTarget?.model_id}" 吗?此操作不可撤销。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||||
|
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
336
admin/src/app/(dashboard)/page.tsx
Normal file
336
admin/src/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Server,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Zap,
|
||||||
|
Loader2,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { formatNumber, formatDate } from '@/lib/utils'
|
||||||
|
import type {
|
||||||
|
DashboardStats,
|
||||||
|
UsageRecord,
|
||||||
|
OperationLog,
|
||||||
|
} from '@/lib/types'
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
icon: React.ReactNode
|
||||||
|
color: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, icon, color, subtitle }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{title}</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-foreground">{value}</p>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-lg ${color}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const variantMap: Record<string, 'success' | 'destructive' | 'warning' | 'info' | 'secondary'> = {
|
||||||
|
active: 'success',
|
||||||
|
completed: 'success',
|
||||||
|
disabled: 'destructive',
|
||||||
|
failed: 'destructive',
|
||||||
|
processing: 'info',
|
||||||
|
queued: 'warning',
|
||||||
|
suspended: 'destructive',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant={variantMap[status] || 'secondary'}>{status}</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
|
const [usageData, setUsageData] = useState<UsageRecord[]>([])
|
||||||
|
const [recentLogs, setRecentLogs] = useState<OperationLog[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const [statsRes, usageRes, logsRes] = await Promise.allSettled([
|
||||||
|
api.stats.dashboard(),
|
||||||
|
api.usage.daily({ days: 30 }),
|
||||||
|
api.logs.list({ page: 1, page_size: 5 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
||||||
|
if (usageRes.status === 'fulfilled') setUsageData(usageRes.value)
|
||||||
|
if (logsRes.status === 'fulfilled') setRecentLogs(logsRes.value.items)
|
||||||
|
} catch (err) {
|
||||||
|
setError('加载数据失败,请检查后端服务是否启动')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 text-sm text-primary hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = usageData.map((r) => ({
|
||||||
|
day: r.day.slice(5), // MM-DD
|
||||||
|
请求量: r.count,
|
||||||
|
Input: r.input_tokens,
|
||||||
|
Output: r.output_tokens,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title="总账号数"
|
||||||
|
value={stats?.total_accounts ?? '-'}
|
||||||
|
icon={<Users className="h-5 w-5 text-blue-400" />}
|
||||||
|
color="bg-blue-500/10"
|
||||||
|
subtitle={`活跃 ${stats?.active_accounts ?? 0}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="活跃服务商"
|
||||||
|
value={stats?.active_providers ?? '-'}
|
||||||
|
icon={<Server className="h-5 w-5 text-green-400" />}
|
||||||
|
color="bg-green-500/10"
|
||||||
|
subtitle={`模型 ${stats?.active_models ?? 0}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="今日请求"
|
||||||
|
value={stats?.tasks_today ?? '-'}
|
||||||
|
icon={<ArrowLeftRight className="h-5 w-5 text-purple-400" />}
|
||||||
|
color="bg-purple-500/10"
|
||||||
|
subtitle="中转任务"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="今日 Token"
|
||||||
|
value={formatNumber((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0))}
|
||||||
|
icon={<Zap className="h-5 w-5 text-orange-400" />}
|
||||||
|
color="bg-orange-500/10"
|
||||||
|
subtitle={`In: ${formatNumber(stats?.tokens_today_input ?? 0)} / Out: ${formatNumber(stats?.tokens_today_output ?? 0)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图表 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{/* 请求趋势 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
|
请求趋势 (30 天)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorRequests" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#22C55E" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#22C55E" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="请求量"
|
||||||
|
stroke="#22C55E"
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorRequests)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Token 用量 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Zap className="h-4 w-4 text-orange-400" />
|
||||||
|
Token 用量 (30 天)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="Input" fill="#3B82F6" radius={[2, 2, 0, 0]} />
|
||||||
|
<Bar dataKey="Output" fill="#F97316" radius={[2, 2, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 最近操作日志 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">最近操作</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recentLogs.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>时间</TableHead>
|
||||||
|
<TableHead>账号 ID</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
<TableHead>目标类型</TableHead>
|
||||||
|
<TableHead>目标 ID</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{recentLogs.map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{log.account_id.slice(0, 8)}...
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{log.action}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{log.target_type}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{log.target_id.slice(0, 8)}...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无操作日志
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
386
admin/src/app/(dashboard)/providers/page.tsx
Normal file
386
admin/src/app/(dashboard)/providers/page.tsx
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
} 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 { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate, maskApiKey } from '@/lib/utils'
|
||||||
|
import type { Provider } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
interface ProviderForm {
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
base_url: string
|
||||||
|
api_protocol: 'openai' | 'anthropic'
|
||||||
|
api_key: string
|
||||||
|
enabled: boolean
|
||||||
|
rate_limit_rpm: string
|
||||||
|
rate_limit_tpm: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: ProviderForm = {
|
||||||
|
name: '',
|
||||||
|
display_name: '',
|
||||||
|
base_url: '',
|
||||||
|
api_protocol: 'openai',
|
||||||
|
api_key: '',
|
||||||
|
enabled: true,
|
||||||
|
rate_limit_rpm: '',
|
||||||
|
rate_limit_tpm: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProvidersPage() {
|
||||||
|
const [providers, setProviders] = useState<Provider[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 创建/编辑 Dialog
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<Provider | null>(null)
|
||||||
|
const [form, setForm] = useState<ProviderForm>(emptyForm)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// 删除确认 Dialog
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Provider | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const fetchProviders = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.providers.list({ page, page_size: PAGE_SIZE })
|
||||||
|
setProviders(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProviders()
|
||||||
|
}, [fetchProviders])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditTarget(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(provider: Provider) {
|
||||||
|
setEditTarget(provider)
|
||||||
|
setForm({
|
||||||
|
name: provider.name,
|
||||||
|
display_name: provider.display_name,
|
||||||
|
base_url: provider.base_url,
|
||||||
|
api_protocol: provider.api_protocol,
|
||||||
|
api_key: provider.api_key || '',
|
||||||
|
enabled: provider.enabled,
|
||||||
|
rate_limit_rpm: provider.rate_limit_rpm?.toString() || '',
|
||||||
|
rate_limit_tpm: provider.rate_limit_tpm?.toString() || '',
|
||||||
|
})
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.name.trim() || !form.base_url.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
display_name: form.display_name.trim(),
|
||||||
|
base_url: form.base_url.trim(),
|
||||||
|
api_protocol: form.api_protocol,
|
||||||
|
api_key: form.api_key.trim() || undefined,
|
||||||
|
enabled: form.enabled,
|
||||||
|
rate_limit_rpm: form.rate_limit_rpm ? parseInt(form.rate_limit_rpm, 10) : undefined,
|
||||||
|
rate_limit_tpm: form.rate_limit_tpm ? parseInt(form.rate_limit_tpm, 10) : undefined,
|
||||||
|
}
|
||||||
|
if (editTarget) {
|
||||||
|
await api.providers.update(editTarget.id, payload)
|
||||||
|
} else {
|
||||||
|
await api.providers.create(payload)
|
||||||
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
|
fetchProviders()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await api.providers.delete(deleteTarget.id)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchProviders()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div />
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建服务商
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>显示名</TableHead>
|
||||||
|
<TableHead>Base URL</TableHead>
|
||||||
|
<TableHead>协议</TableHead>
|
||||||
|
<TableHead>API Key</TableHead>
|
||||||
|
<TableHead>启用</TableHead>
|
||||||
|
<TableHead>RPM 限制</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="font-medium">{p.name}</TableCell>
|
||||||
|
<TableCell>{p.display_name || '-'}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
||||||
|
{p.base_url}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={p.api_protocol === 'openai' ? 'default' : 'info'}>
|
||||||
|
{p.api_protocol}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{maskApiKey(p.api_key)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={p.enabled ? 'success' : 'secondary'}>
|
||||||
|
{p.enabled ? '是' : '否'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{p.rate_limit_rpm ?? '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(p.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEditDialog(p)} title="编辑">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(p)} title="删除">
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editTarget ? '编辑服务商' : '新建服务商'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editTarget ? '修改服务商配置' : '添加新的 AI 服务商'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>名称 *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="例如: openai"
|
||||||
|
disabled={!!editTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>显示名</Label>
|
||||||
|
<Input
|
||||||
|
value={form.display_name}
|
||||||
|
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
||||||
|
placeholder="例如: OpenAI"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Base URL *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.base_url}
|
||||||
|
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>API 协议</Label>
|
||||||
|
<Select value={form.api_protocol} onValueChange={(v) => setForm({ ...form, api_protocol: v as 'openai' | 'anthropic' })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="openai">OpenAI</SelectItem>
|
||||||
|
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>API Key</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.api_key}
|
||||||
|
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||||
|
placeholder={editTarget ? '留空则不修改' : 'sk-...'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
checked={form.enabled}
|
||||||
|
onCheckedChange={(v) => setForm({ ...form, enabled: v })}
|
||||||
|
/>
|
||||||
|
<Label>启用</Label>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>RPM 限制</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.rate_limit_rpm}
|
||||||
|
onChange={(e) => setForm({ ...form, rate_limit_rpm: e.target.value })}
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>TPM 限制</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.rate_limit_tpm}
|
||||||
|
onChange={(e) => setForm({ ...form, rate_limit_tpm: e.target.value })}
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !form.name.trim() || !form.base_url.trim()}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 删除确认 Dialog */}
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要删除服务商 "{deleteTarget?.display_name || deleteTarget?.name}" 吗?此操作不可撤销。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||||
|
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
245
admin/src/app/(dashboard)/relay/page.tsx
Normal file
245
admin/src/app/(dashboard)/relay/page.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate, formatNumber } from '@/lib/utils'
|
||||||
|
import type { RelayTask } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const statusVariants: Record<string, 'success' | 'info' | 'warning' | 'destructive' | 'secondary'> = {
|
||||||
|
queued: 'warning',
|
||||||
|
processing: 'info',
|
||||||
|
completed: 'success',
|
||||||
|
failed: 'destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
queued: '排队中',
|
||||||
|
processing: '处理中',
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RelayPage() {
|
||||||
|
const [tasks, setTasks] = useState<RelayTask[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||||
|
if (statusFilter !== 'all') params.status = statusFilter
|
||||||
|
const res = await api.relay.list(params)
|
||||||
|
setTasks(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks()
|
||||||
|
}, [fetchTasks])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
function toggleExpand(id: string) {
|
||||||
|
setExpandedId((prev) => (prev === id ? null : id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 筛选 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="状态筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="queued">排队中</SelectItem>
|
||||||
|
<SelectItem value="processing">处理中</SelectItem>
|
||||||
|
<SelectItem value="completed">已完成</SelectItem>
|
||||||
|
<SelectItem value="failed">失败</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-8" />
|
||||||
|
<TableHead>任务 ID</TableHead>
|
||||||
|
<TableHead>模型</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>优先级</TableHead>
|
||||||
|
<TableHead>重试次数</TableHead>
|
||||||
|
<TableHead>Input Tokens</TableHead>
|
||||||
|
<TableHead>Output Tokens</TableHead>
|
||||||
|
<TableHead>错误信息</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<>
|
||||||
|
<TableRow key={task.id} className="cursor-pointer" onClick={() => toggleExpand(task.id)}>
|
||||||
|
<TableCell>
|
||||||
|
{expandedId === task.id ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{task.id.slice(0, 8)}...
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{task.model_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariants[task.status] || 'secondary'}>
|
||||||
|
{statusLabels[task.status] || task.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{task.priority}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{task.attempt_count}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(task.input_tokens)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(task.output_tokens)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate text-xs text-destructive">
|
||||||
|
{task.error_message || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(task.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{expandedId === task.id && (
|
||||||
|
<TableRow key={`${task.id}-detail`}>
|
||||||
|
<TableCell colSpan={10} className="bg-muted/20 px-8 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">任务 ID</p>
|
||||||
|
<p className="font-mono text-xs">{task.id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">账号 ID</p>
|
||||||
|
<p className="font-mono text-xs">{task.account_id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">服务商 ID</p>
|
||||||
|
<p className="font-mono text-xs">{task.provider_id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">模型 ID</p>
|
||||||
|
<p className="font-mono text-xs">{task.model_id}</p>
|
||||||
|
</div>
|
||||||
|
{task.queued_at && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">排队时间</p>
|
||||||
|
<p className="font-mono text-xs">{formatDate(task.queued_at)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.started_at && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">开始时间</p>
|
||||||
|
<p className="font-mono text-xs">{formatDate(task.started_at)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.completed_at && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">完成时间</p>
|
||||||
|
<p className="font-mono text-xs">{formatDate(task.completed_at)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.error_message && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-muted-foreground">错误信息</p>
|
||||||
|
<p className="text-xs text-destructive mt-1">{task.error_message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
235
admin/src/app/(dashboard)/usage/page.tsx
Normal file
235
admin/src/app/(dashboard)/usage/page.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Loader2, Zap } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatNumber } from '@/lib/utils'
|
||||||
|
import type { UsageRecord, UsageByModel } from '@/lib/types'
|
||||||
|
|
||||||
|
export default function UsagePage() {
|
||||||
|
const [days, setDays] = useState(7)
|
||||||
|
const [dailyData, setDailyData] = useState<UsageRecord[]>([])
|
||||||
|
const [modelData, setModelData] = useState<UsageByModel[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const [dailyRes, modelRes] = await Promise.allSettled([
|
||||||
|
api.usage.daily({ days }),
|
||||||
|
api.usage.byModel({ days }),
|
||||||
|
])
|
||||||
|
if (dailyRes.status === 'fulfilled') setDailyData(dailyRes.value)
|
||||||
|
else throw new Error('Failed to fetch daily usage')
|
||||||
|
if (modelRes.status === 'fulfilled') setModelData(modelRes.value)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [days])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const lineChartData = dailyData.map((r) => ({
|
||||||
|
day: r.day.slice(5),
|
||||||
|
Input: r.input_tokens,
|
||||||
|
Output: r.output_tokens,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const barChartData = modelData.map((r) => ({
|
||||||
|
model: r.model_id,
|
||||||
|
请求量: r.count,
|
||||||
|
Input: r.input_tokens,
|
||||||
|
Output: r.output_tokens,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const totalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0)
|
||||||
|
const totalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0)
|
||||||
|
const totalRequests = dailyData.reduce((s, r) => s + r.count, 0)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
<button onClick={() => fetchData()} className="mt-4 text-sm text-primary hover:underline cursor-pointer">
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 时间范围 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">时间范围:</span>
|
||||||
|
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">最近 7 天</SelectItem>
|
||||||
|
<SelectItem value="30">最近 30 天</SelectItem>
|
||||||
|
<SelectItem value="90">最近 90 天</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 汇总统计 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground">总请求数</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-foreground">
|
||||||
|
{formatNumber(totalRequests)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Input Tokens</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-blue-400">
|
||||||
|
{formatNumber(totalInput)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Output Tokens</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-orange-400">
|
||||||
|
{formatNumber(totalOutput)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token 用量趋势 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Zap className="h-4 w-4 text-primary" />
|
||||||
|
Token 用量趋势
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{lineChartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart data={lineChartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
||||||
|
<Line type="monotone" dataKey="Input" stroke="#3B82F6" strokeWidth={2} dot={false} />
|
||||||
|
<Line type="monotone" dataKey="Output" stroke="#F97316" strokeWidth={2} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 按模型分布 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">按模型分布</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{barChartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<BarChart data={barChartData} layout="vertical">
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="model"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
width={120}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
||||||
|
<Bar dataKey="Input" fill="#3B82F6" radius={[0, 2, 2, 0]} />
|
||||||
|
<Bar dataKey="Output" fill="#F97316" radius={[0, 2, 2, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
admin/src/app/globals.css
Normal file
66
admin/src/app/globals.css
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 222 47% 5%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222 47% 8%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--primary: 142 71% 45%;
|
||||||
|
--primary-foreground: 222 47% 5%;
|
||||||
|
--muted: 217 33% 17%;
|
||||||
|
--muted-foreground: 215 20% 65%;
|
||||||
|
--accent: 215 28% 23%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217 33% 17%;
|
||||||
|
--input: 217 33% 17%;
|
||||||
|
--ring: 142 71% 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.scrollbar-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--muted)) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: hsl(var(--accent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.glass-card {
|
||||||
|
@apply bg-card/80 backdrop-blur-sm border border-border rounded-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
admin/src/app/layout.tsx
Normal file
27
admin/src/app/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'ZCLAW Admin',
|
||||||
|
description: 'ZCLAW AI Agent 管理平台',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN" className="dark">
|
||||||
|
<head>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
199
admin/src/app/login/page.tsx
Normal file
199
admin/src/app/login/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Lock, User, Loader2, Eye, EyeOff } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { login } from '@/lib/auth'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [remember, setRemember] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (!username.trim()) {
|
||||||
|
setError('请输入用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password.trim()) {
|
||||||
|
setError('请输入密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.auth.login({ username: username.trim(), password })
|
||||||
|
login(res.token, res.account)
|
||||||
|
router.replace('/')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message || '登录失败,请检查用户名和密码')
|
||||||
|
} else {
|
||||||
|
setError('网络错误,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* 左侧品牌区域 */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||||
|
{/* 装饰性背景 */}
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-green-500/5 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-green-500/8 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] border border-green-500/10 rounded-full" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] border border-green-500/10 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 品牌内容 */}
|
||||||
|
<div className="relative z-10 flex flex-col items-center justify-center w-full p-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-6xl font-bold tracking-tight text-foreground mb-4">
|
||||||
|
ZCLAW
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-muted-foreground font-light">
|
||||||
|
AI Agent 管理平台
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex items-center justify-center gap-2">
|
||||||
|
<div className="h-px w-12 bg-green-500/50" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
|
<div className="h-px w-12 bg-green-500/50" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 text-sm text-muted-foreground/60 max-w-sm">
|
||||||
|
统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧登录表单 */}
|
||||||
|
<div className="flex w-full lg:w-1/2 items-center justify-center p-8">
|
||||||
|
<div className="w-full max-w-sm space-y-8">
|
||||||
|
{/* 移动端 Logo */}
|
||||||
|
<div className="lg:hidden text-center">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-foreground mb-2">
|
||||||
|
ZCLAW
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">AI Agent 管理平台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-foreground">登录</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
输入您的账号信息以继续
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* 用户名 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-3 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 密码 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-10 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 记住我 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="remember"
|
||||||
|
type="checkbox"
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="remember"
|
||||||
|
className="text-sm text-muted-foreground cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
记住我
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 登录按钮 */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex h-10 w-full items-center justify-center rounded-md bg-primary text-primary-foreground font-medium text-sm shadow-sm transition-colors duration-200 hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
登录中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'登录'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
admin/src/components/auth-guard.tsx
Normal file
48
admin/src/components/auth-guard.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { isAuthenticated, getAccount } from '@/lib/auth'
|
||||||
|
import type { AccountPublic } from '@/lib/types'
|
||||||
|
|
||||||
|
interface AuthGuardProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: AuthGuardProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [authorized, setAuthorized] = useState(false)
|
||||||
|
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAccount(getAccount())
|
||||||
|
setAuthorized(true)
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
if (!authorized) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-background">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const acc = getAccount()
|
||||||
|
setAccount(acc)
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { account, loading, isAuthenticated: isAuthenticated() }
|
||||||
|
}
|
||||||
42
admin/src/components/ui/badge.tsx
Normal file
42
admin/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary/15 text-primary',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-muted text-muted-foreground',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive/15 text-destructive',
|
||||||
|
outline:
|
||||||
|
'text-foreground border-border',
|
||||||
|
success:
|
||||||
|
'border-transparent bg-green-500/15 text-green-400',
|
||||||
|
warning:
|
||||||
|
'border-transparent bg-yellow-500/15 text-yellow-400',
|
||||||
|
info:
|
||||||
|
'border-transparent bg-blue-500/15 text-blue-400',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
56
admin/src/components/ui/button.tsx
Normal file
56
admin/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm',
|
||||||
|
secondary:
|
||||||
|
'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-red-600 shadow-sm',
|
||||||
|
outline:
|
||||||
|
'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||||
|
ghost:
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link:
|
||||||
|
'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
75
admin/src/components/ui/card.tsx
Normal file
75
admin/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = 'CardContent'
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = 'CardFooter'
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
118
admin/src/components/ui/dialog.tsx
Normal file
118
admin/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
|
||||||
|
'gap-4 border border-border bg-card p-6 shadow-lg duration-200',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||||
|
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||||
|
'rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = 'DialogHeader'
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = 'DialogFooter'
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
28
admin/src/components/ui/input.tsx
Normal file
28
admin/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors duration-200',
|
||||||
|
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export { Input }
|
||||||
23
admin/src/components/ui/label.tsx
Normal file
23
admin/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface LabelProps
|
||||||
|
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||||
|
|
||||||
|
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Label.displayName = 'Label'
|
||||||
|
|
||||||
|
export { Label }
|
||||||
100
admin/src/components/ui/select.tsx
Normal file
100
admin/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||||
|
import { Check, ChevronDown } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus:outline-none focus:ring-1 focus:ring-ring',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'[&>span]:line-clamp-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card text-foreground shadow-md',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
|
||||||
|
'focus:bg-accent focus:text-accent-foreground',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
}
|
||||||
30
admin/src/components/ui/separator.tsx
Normal file
30
admin/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
32
admin/src/components/ui/switch.tsx
Normal file
32
admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-200',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200',
|
||||||
|
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
119
admin/src/components/ui/table.tsx
Normal file
119
admin/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto scrollbar-thin">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn('w-full caption-bottom text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = 'Table'
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = 'TableHeader'
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn('[&_tr:last-child]:border-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = 'TableBody'
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = 'TableFooter'
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border transition-colors duration-200 hover:bg-muted/50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = 'TableRow'
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = 'TableHead'
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = 'TableCell'
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = 'TableCaption'
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
57
admin/src/components/ui/tabs.tsx
Normal file
57
admin/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-200',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
'data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
31
admin/src/components/ui/tooltip.tsx
Normal file
31
admin/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 overflow-hidden rounded-md bg-card border border-border px-3 py-1.5 text-sm text-foreground shadow-md',
|
||||||
|
'animate-in fade-in-0 zoom-in-95',
|
||||||
|
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||||
|
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
284
admin/src/lib/api-client.ts
Normal file
284
admin/src/lib/api-client.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { getToken, logout } from './auth'
|
||||||
|
import type {
|
||||||
|
AccountPublic,
|
||||||
|
ApiError,
|
||||||
|
ConfigItem,
|
||||||
|
CreateTokenRequest,
|
||||||
|
DashboardStats,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
Model,
|
||||||
|
OperationLog,
|
||||||
|
PaginatedResponse,
|
||||||
|
Provider,
|
||||||
|
RelayTask,
|
||||||
|
TokenInfo,
|
||||||
|
UsageByModel,
|
||||||
|
UsageRecord,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// ── 错误类 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public body: ApiError,
|
||||||
|
) {
|
||||||
|
super(body.message || `Request failed with status ${status}`)
|
||||||
|
this.name = 'ApiRequestError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 基础请求 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080'
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const token = getToken()
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
logout()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
throw new ApiRequestError(401, { error: 'unauthorized', message: '登录已过期,请重新登录' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let errorBody: ApiError
|
||||||
|
try {
|
||||||
|
errorBody = await res.json()
|
||||||
|
} catch {
|
||||||
|
errorBody = { error: 'unknown', message: `请求失败 (${res.status})` }
|
||||||
|
}
|
||||||
|
throw new ApiRequestError(res.status, errorBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content
|
||||||
|
if (res.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API 客户端 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// ── 认证 ──────────────────────────────────────────────
|
||||||
|
auth: {
|
||||||
|
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||||
|
return request<LoginResponse>('POST', '/api/auth/login', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(data: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
email: string
|
||||||
|
display_name?: string
|
||||||
|
}): Promise<LoginResponse> {
|
||||||
|
return request<LoginResponse>('POST', '/api/auth/register', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async me(): Promise<AccountPublic> {
|
||||||
|
return request<AccountPublic>('GET', '/api/auth/me')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 账号管理 ──────────────────────────────────────────
|
||||||
|
accounts: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
role?: string
|
||||||
|
status?: string
|
||||||
|
}): Promise<PaginatedResponse<AccountPublic>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<AccountPublic>>('GET', `/api/accounts${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<AccountPublic> {
|
||||||
|
return request<AccountPublic>('GET', `/api/accounts/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
|
||||||
|
): Promise<AccountPublic> {
|
||||||
|
return request<AccountPublic>('PATCH', `/api/accounts/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
data: { status: AccountPublic['status'] },
|
||||||
|
): Promise<void> {
|
||||||
|
return request<void>('PATCH', `/api/accounts/${id}/status`, data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 服务商管理 ────────────────────────────────────────
|
||||||
|
providers: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}): Promise<PaginatedResponse<Provider>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<Provider>>('GET', `/api/providers${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
|
||||||
|
return request<Provider>('POST', '/api/providers', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
|
||||||
|
): Promise<Provider> {
|
||||||
|
return request<Provider>('PATCH', `/api/providers/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
return request<void>('DELETE', `/api/providers/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 模型管理 ──────────────────────────────────────────
|
||||||
|
models: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
provider_id?: string
|
||||||
|
}): Promise<PaginatedResponse<Model>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<Model>>('GET', `/api/models${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||||
|
return request<Model>('POST', '/api/models', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||||
|
return request<Model>('PATCH', `/api/models/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
return request<void>('DELETE', `/api/models/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── API 密钥 ──────────────────────────────────────────
|
||||||
|
tokens: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}): Promise<PaginatedResponse<TokenInfo>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<TokenInfo>>('GET', `/api/tokens${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: CreateTokenRequest): Promise<TokenInfo> {
|
||||||
|
return request<TokenInfo>('POST', '/api/tokens', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async revoke(id: string): Promise<void> {
|
||||||
|
return request<void>('DELETE', `/api/tokens/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 用量统计 ──────────────────────────────────────────
|
||||||
|
usage: {
|
||||||
|
async daily(params?: { days?: number }): Promise<UsageRecord[]> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<UsageRecord[]>('GET', `/api/usage/daily${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async byModel(params?: { days?: number }): Promise<UsageByModel[]> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<UsageByModel[]>('GET', `/api/usage/by-model${qs}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 中转任务 ──────────────────────────────────────────
|
||||||
|
relay: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
status?: string
|
||||||
|
}): Promise<PaginatedResponse<RelayTask>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<RelayTask>>('GET', `/api/relay/tasks${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<RelayTask> {
|
||||||
|
return request<RelayTask>('GET', `/api/relay/tasks/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 系统配置 ──────────────────────────────────────────
|
||||||
|
config: {
|
||||||
|
async list(params?: {
|
||||||
|
category?: string
|
||||||
|
}): Promise<ConfigItem[]> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<ConfigItem[]>('GET', `/api/config${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> {
|
||||||
|
return request<ConfigItem>('PATCH', `/api/config/${id}`, data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 操作日志 ──────────────────────────────────────────
|
||||||
|
logs: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
action?: string
|
||||||
|
}): Promise<PaginatedResponse<OperationLog>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<OperationLog>>('GET', `/api/logs${qs}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 仪表盘 ────────────────────────────────────────────
|
||||||
|
stats: {
|
||||||
|
async dashboard(): Promise<DashboardStats> {
|
||||||
|
return request<DashboardStats>('GET', '/api/stats/dashboard')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 工具函数 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildQueryString(params?: Record<string, unknown>): string {
|
||||||
|
if (!params) return ''
|
||||||
|
const entries = Object.entries(params).filter(
|
||||||
|
([, v]) => v !== undefined && v !== null && v !== '',
|
||||||
|
)
|
||||||
|
if (entries.length === 0) return ''
|
||||||
|
const qs = entries
|
||||||
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||||
|
.join('&')
|
||||||
|
return `?${qs}`
|
||||||
|
}
|
||||||
45
admin/src/lib/auth.ts
Normal file
45
admin/src/lib/auth.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW SaaS Admin — JWT Token 管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { AccountPublic } from './types'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'zclaw_admin_token'
|
||||||
|
const ACCOUNT_KEY = 'zclaw_admin_account'
|
||||||
|
|
||||||
|
/** 保存登录凭证 */
|
||||||
|
export function login(token: string, account: AccountPublic): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除登录凭证 */
|
||||||
|
export function logout(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(ACCOUNT_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 JWT token */
|
||||||
|
export function getToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前登录用户信息 */
|
||||||
|
export function getAccount(): AccountPublic | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const raw = localStorage.getItem(ACCOUNT_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as AccountPublic
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已认证 */
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return !!getToken()
|
||||||
|
}
|
||||||
169
admin/src/lib/types.ts
Normal file
169
admin/src/lib/types.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW SaaS Admin — 全局类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 公共账号信息 */
|
||||||
|
export interface AccountPublic {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
display_name: string
|
||||||
|
role: 'super_admin' | 'admin' | 'user'
|
||||||
|
status: 'active' | 'disabled' | 'suspended'
|
||||||
|
totp_enabled: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录请求 */
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录响应 */
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
account: AccountPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册请求 */
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
email: string
|
||||||
|
display_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页响应 */
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务商 (Provider) */
|
||||||
|
export interface Provider {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
api_key?: string
|
||||||
|
base_url: string
|
||||||
|
api_protocol: 'openai' | 'anthropic'
|
||||||
|
enabled: boolean
|
||||||
|
rate_limit_rpm?: number
|
||||||
|
rate_limit_tpm?: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型 */
|
||||||
|
export interface Model {
|
||||||
|
id: string
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
alias: string
|
||||||
|
context_window: number
|
||||||
|
max_output_tokens: number
|
||||||
|
supports_streaming: boolean
|
||||||
|
supports_vision: boolean
|
||||||
|
enabled: boolean
|
||||||
|
pricing_input: number
|
||||||
|
pricing_output: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 密钥信息 */
|
||||||
|
export interface TokenInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
token_prefix: string
|
||||||
|
permissions: string[]
|
||||||
|
last_used_at?: string
|
||||||
|
expires_at?: string
|
||||||
|
created_at: string
|
||||||
|
token?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建 Token 请求 */
|
||||||
|
export interface CreateTokenRequest {
|
||||||
|
name: string
|
||||||
|
expires_days?: number
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中转任务 */
|
||||||
|
export interface RelayTask {
|
||||||
|
id: string
|
||||||
|
account_id: string
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
status: 'queued' | 'processing' | 'completed' | 'failed'
|
||||||
|
priority: number
|
||||||
|
attempt_count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
error_message?: string
|
||||||
|
queued_at?: string
|
||||||
|
started_at?: string
|
||||||
|
completed_at?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用量记录 */
|
||||||
|
export interface UsageRecord {
|
||||||
|
day: string
|
||||||
|
count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按模型用量 */
|
||||||
|
export interface UsageByModel {
|
||||||
|
model_id: string
|
||||||
|
count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统配置项 */
|
||||||
|
export interface ConfigItem {
|
||||||
|
id: string
|
||||||
|
category: string
|
||||||
|
key_path: string
|
||||||
|
value_type: 'string' | 'number' | 'boolean'
|
||||||
|
current_value?: string | number | boolean
|
||||||
|
default_value?: string | number | boolean
|
||||||
|
source: 'default' | 'env' | 'db'
|
||||||
|
description?: string
|
||||||
|
requires_restart: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 操作日志 */
|
||||||
|
export interface OperationLog {
|
||||||
|
id: string
|
||||||
|
account_id: string
|
||||||
|
action: string
|
||||||
|
target_type: string
|
||||||
|
target_id: string
|
||||||
|
details?: string
|
||||||
|
ip_address?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仪表盘统计 */
|
||||||
|
export interface DashboardStats {
|
||||||
|
total_accounts: number
|
||||||
|
active_accounts: number
|
||||||
|
tasks_today: number
|
||||||
|
active_providers: number
|
||||||
|
active_models: number
|
||||||
|
tokens_today_input: number
|
||||||
|
tokens_today_output: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 错误响应 */
|
||||||
|
export interface ApiError {
|
||||||
|
error: string
|
||||||
|
message: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
34
admin/src/lib/utils.ts
Normal file
34
admin/src/lib/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date): string {
|
||||||
|
const d = new Date(date)
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||||
|
return n.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskApiKey(key?: string): string {
|
||||||
|
if (!key) return '-'
|
||||||
|
if (key.length <= 8) return '****'
|
||||||
|
return `${key.slice(0, 4)}${'*'.repeat(key.length - 8)}${key.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
62
admin/tailwind.config.ts
Normal file
62
admin/tailwind.config.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: '#020617',
|
||||||
|
foreground: '#F8FAFC',
|
||||||
|
card: {
|
||||||
|
DEFAULT: '#0F172A',
|
||||||
|
foreground: '#F8FAFC',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#22C55E',
|
||||||
|
foreground: '#020617',
|
||||||
|
hover: '#16A34A',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: '#1E293B',
|
||||||
|
foreground: '#94A3B8',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: '#334155',
|
||||||
|
foreground: '#F8FAFC',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: '#EF4444',
|
||||||
|
foreground: '#F8FAFC',
|
||||||
|
},
|
||||||
|
border: '#1E293B',
|
||||||
|
input: '#1E293B',
|
||||||
|
ring: '#22C55E',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'fade-in': {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
'slide-in': {
|
||||||
|
'0%': { opacity: '0', transform: 'translateX(-8px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fade-in 0.2s ease-out',
|
||||||
|
'slide-in': 'slide-in 0.2s ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
export default config
|
||||||
21
admin/tsconfig.json
Normal file
21
admin/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -130,3 +130,39 @@ pub async fn list_operation_logs(
|
|||||||
|
|
||||||
Ok(Json(items))
|
Ok(Json(items))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/stats/dashboard — 仪表盘聚合统计 (需要 admin 权限)
|
||||||
|
pub async fn dashboard_stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(ctx): Extension<AuthContext>,
|
||||||
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
require_admin(&ctx)?;
|
||||||
|
|
||||||
|
let total_accounts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM accounts")
|
||||||
|
.fetch_one(&state.db).await?;
|
||||||
|
let active_accounts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM accounts WHERE status = 'active'")
|
||||||
|
.fetch_one(&state.db).await?;
|
||||||
|
let tasks_today: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM relay_tasks WHERE date(created_at) = date('now')"
|
||||||
|
).fetch_one(&state.db).await?;
|
||||||
|
let active_providers: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM providers WHERE enabled = 1")
|
||||||
|
.fetch_one(&state.db).await?;
|
||||||
|
let active_models: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM models WHERE enabled = 1")
|
||||||
|
.fetch_one(&state.db).await?;
|
||||||
|
let tokens_today_input: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COALESCE(SUM(input_tokens), 0) FROM usage_records WHERE date(created_at) = date('now')"
|
||||||
|
).fetch_one(&state.db).await?;
|
||||||
|
let tokens_today_output: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COALESCE(SUM(output_tokens), 0) FROM usage_records WHERE date(created_at) = date('now')"
|
||||||
|
).fetch_one(&state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"total_accounts": total_accounts.0,
|
||||||
|
"active_accounts": active_accounts.0,
|
||||||
|
"tasks_today": tasks_today.0,
|
||||||
|
"active_providers": active_providers.0,
|
||||||
|
"active_models": active_models.0,
|
||||||
|
"tokens_today_input": tokens_today_input.0,
|
||||||
|
"tokens_today_output": tokens_today_output.0,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
|
|||||||
.route("/api/v1/tokens", post(handlers::create_token))
|
.route("/api/v1/tokens", post(handlers::create_token))
|
||||||
.route("/api/v1/tokens/{id}", delete(handlers::revoke_token))
|
.route("/api/v1/tokens/{id}", delete(handlers::revoke_token))
|
||||||
.route("/api/v1/logs/operations", get(handlers::list_operation_logs))
|
.route("/api/v1/logs/operations", get(handlers::list_operation_logs))
|
||||||
|
.route("/api/v1/stats/dashboard", get(handlers::dashboard_stats))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user