后端: - 添加 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 通过
337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
'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>
|
|
)
|
|
}
|