chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View File

@@ -1,7 +1,8 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { Loader2, Zap } from 'lucide-react'
import { useState } from 'react'
import useSWR from 'swr'
import { Zap, Monitor, Smartphone } from 'lucide-react'
import {
LineChart,
Line,
@@ -15,6 +16,8 @@ import {
Legend,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { TableSkeleton, ChartSkeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
@@ -22,84 +25,87 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client'
import { formatNumber } from '@/lib/utils'
import type { UsageRecord, UsageByModel } from '@/lib/types'
import type { UsageRecord, UsageByModel, ModelUsageStat, DailyUsageStat } 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 [activeTab, setActiveTab] = useState('relay')
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])
// 4 parallel SWR calls — each loads independently
const { data: dailyData = [], isLoading: dailyLoading } = useSWR(
['usage.daily', days],
() => api.usage.daily({ days })
)
const { data: modelData = [], isLoading: modelLoading } = useSWR(
['usage.byModel', days],
() => api.usage.byModel({ days })
)
const { data: telemetryModels = [] } = useSWR(
['telemetry.modelStats'],
() => api.telemetry.modelStats()
)
const { data: telemetryDaily = [] } = useSWR(
['telemetry.dailyStats', days],
() => api.telemetry.dailyStats({ days })
)
useEffect(() => {
fetchData()
}, [fetchData])
const relayLoading = dailyLoading || modelLoading
const telemetryLoading = !telemetryModels.length && !telemetryDaily.length && (dailyLoading || modelLoading)
const lineChartData = dailyData.map((r) => ({
// === Relay 用量图表数据 ===
const relayLineData = dailyData.map((r) => ({
day: r.day.slice(5),
Input: r.input_tokens,
Output: r.output_tokens,
}))
const barChartData = modelData.map((r) => ({
const relayBarData = 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)
const relayTotalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0)
const relayTotalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0)
const relayTotalRequests = 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>
)
}
const telemetryLineData = telemetryDaily.map((r) => ({
day: r.day.slice(5),
Input: r.input_tokens,
Output: r.output_tokens,
设备数: r.unique_devices,
}))
const telemetryTotalInput = telemetryDaily.reduce((s, r) => s + r.input_tokens, 0)
const telemetryTotalOutput = telemetryDaily.reduce((s, r) => s + r.output_tokens, 0)
const telemetryTotalRequests = telemetryDaily.reduce((s, r) => s + r.request_count, 0)
// === 合计 ===
const totalInput = relayTotalInput + telemetryTotalInput
const totalOutput = relayTotalOutput + telemetryTotalOutput
const totalRequests = relayTotalRequests + telemetryTotalRequests
return (
<div className="space-y-6">
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
{/* 时间范围 */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">:</span>
@@ -115,8 +121,8 @@ export default function UsagePage() {
</Select>
</div>
{/* 汇总统计 */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{/* 汇总统计 — render immediately, use 0 while loading */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground"></p>
@@ -127,7 +133,7 @@ export default function UsagePage() {
</Card>
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground">Input Tokens</p>
<p className="text-sm text-muted-foreground"> Input Tokens</p>
<p className="mt-1 text-2xl font-bold text-blue-400">
{formatNumber(totalInput)}
</p>
@@ -135,101 +141,190 @@ export default function UsagePage() {
</Card>
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground">Output Tokens</p>
<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>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-green-400" />
<p className="text-sm text-muted-foreground"></p>
</div>
<p className="mt-1 text-2xl font-bold text-green-400">
{formatNumber(relayTotalRequests)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-2">
<Smartphone className="h-4 w-4 text-purple-400" />
<p className="text-sm text-muted-foreground"></p>
</div>
<p className="mt-1 text-2xl font-bold text-purple-400">
{formatNumber(telemetryTotalRequests)}
</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>
{/* Tab 切换 */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="relay">
<Monitor className="h-4 w-4 mr-1" />
</TabsTrigger>
<TabsTrigger value="telemetry">
<Smartphone className="h-4 w-4 mr-1" />
</TabsTrigger>
</TabsList>
{/* 按模型分布 */}
<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>
{/* Relay 用量 Tab */}
<TabsContent value="relay" className="space-y-6">
{relayLoading ? (
<>
<ChartSkeleton height={320} />
<ChartSkeleton height={280} />
</>
) : (
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
</div>
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-4 w-4 text-primary" />
Token
</CardTitle>
</CardHeader>
<CardContent>
{relayLineData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={relayLineData}>
<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>
) : (
<EmptyState message="暂无中转数据" />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{relayBarData.length > 0 ? (
<ResponsiveContainer width="100%" height={Math.max(200, relayBarData.length * 40)}>
<BarChart data={relayBarData} 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>
) : (
<EmptyState />
)}
</CardContent>
</Card>
</>
)}
</CardContent>
</Card>
</TabsContent>
{/* 遥测 Tab */}
<TabsContent value="telemetry" className="space-y-6">
{telemetryLoading ? (
<>
<ChartSkeleton height={320} />
<TableSkeleton rows={5} cols={6} hasToolbar={false} />
</>
) : (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Smartphone className="h-4 w-4 text-purple-400" />
Token
</CardTitle>
</CardHeader>
<CardContent>
{telemetryLineData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={telemetryLineData}>
<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>
) : (
<EmptyState message="暂无桌面端遥测数据(需要桌面端上报)" />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{telemetryModels.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">Input Tokens</TableHead>
<TableHead className="text-right">Output Tokens</TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{telemetryModels.map((stat) => (
<TableRow key={stat.model_id}>
<TableCell className="font-mono text-sm">{stat.model_id}</TableCell>
<TableCell className="text-right">{formatNumber(stat.request_count)}</TableCell>
<TableCell className="text-right text-blue-400">{formatNumber(stat.input_tokens)}</TableCell>
<TableCell className="text-right text-orange-400">{formatNumber(stat.output_tokens)}</TableCell>
<TableCell className="text-right">
{stat.avg_latency_ms !== null ? `${Math.round(stat.avg_latency_ms)}ms` : '-'}
</TableCell>
<TableCell className="text-right">
<Badge variant={stat.success_rate >= 0.95 ? 'default' : 'destructive'}>
{(stat.success_rate * 100).toFixed(1)}%
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<EmptyState />
)}
</CardContent>
</Card>
</>
)}
</TabsContent>
</Tabs>
</div>
)
}