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:
iven
2026-03-27 14:06:50 +08:00
parent d760b9ca10
commit a66b675675
39 changed files with 6798 additions and 0 deletions

View 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>
)
}