chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user