397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo } from "react";
|
||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
DollarSign,
|
||
TrendingUp,
|
||
AlertTriangle,
|
||
CheckCircle,
|
||
Loader2,
|
||
RefreshCw,
|
||
} from "lucide-react";
|
||
import {
|
||
LineChart,
|
||
Line,
|
||
XAxis,
|
||
YAxis,
|
||
CartesianGrid,
|
||
Tooltip,
|
||
ResponsiveContainer,
|
||
PieChart,
|
||
Pie,
|
||
Cell,
|
||
Legend,
|
||
} from "recharts";
|
||
import { useApi } from "@/lib/hooks/use-api";
|
||
|
||
type TimeRange = "7d" | "30d" | "month";
|
||
|
||
const TIME_RANGE_LABELS: Record<TimeRange, string> = {
|
||
"7d": "最近7天",
|
||
"30d": "最近30天",
|
||
month: "本月",
|
||
};
|
||
|
||
const PIE_COLORS = [
|
||
"#3b82f6",
|
||
"#8b5cf6",
|
||
"#ec4899",
|
||
"#f59e0b",
|
||
"#10b981",
|
||
"#6366f1",
|
||
"#ef4444",
|
||
"#14b8a6",
|
||
"#f97316",
|
||
];
|
||
|
||
interface QuotaData {
|
||
used: number;
|
||
limit: number;
|
||
percentage: number;
|
||
status: "normal" | "warning" | "exceeded";
|
||
}
|
||
|
||
interface TrendItem {
|
||
date: string;
|
||
cost: number;
|
||
}
|
||
|
||
interface EngineUsageItem {
|
||
engine: string;
|
||
label: string;
|
||
queries: number;
|
||
inputTokens: number;
|
||
outputTokens: number;
|
||
cost: number;
|
||
}
|
||
|
||
interface UsageData {
|
||
quota: QuotaData;
|
||
trends: TrendItem[];
|
||
engineDistribution: { engine: string; label: string; cost: number }[];
|
||
engineUsage: EngineUsageItem[];
|
||
}
|
||
|
||
const MOCK_USAGE_DATA: UsageData = {
|
||
quota: { used: 45.6, limit: 100, percentage: 45.6, status: "normal" },
|
||
trends: [
|
||
{ date: "05-19", cost: 5.2 },
|
||
{ date: "05-20", cost: 6.8 },
|
||
{ date: "05-21", cost: 4.5 },
|
||
{ date: "05-22", cost: 7.3 },
|
||
{ date: "05-23", cost: 8.1 },
|
||
{ date: "05-24", cost: 6.9 },
|
||
{ date: "05-25", cost: 7.0 },
|
||
],
|
||
engineDistribution: [
|
||
{ engine: "deepseek", label: "DeepSeek", cost: 15.2 },
|
||
{ engine: "chatgpt", label: "ChatGPT", cost: 12.8 },
|
||
{ engine: "qwen", label: "通义千问", cost: 8.5 },
|
||
{ engine: "gemini", label: "Google Gemini", cost: 5.3 },
|
||
{ engine: "kimi", label: "Kimi", cost: 3.8 },
|
||
],
|
||
engineUsage: [
|
||
{ engine: "deepseek", label: "DeepSeek", queries: 1250, inputTokens: 3200000, outputTokens: 1800000, cost: 15.2 },
|
||
{ engine: "chatgpt", label: "ChatGPT", queries: 890, inputTokens: 2100000, outputTokens: 1500000, cost: 12.8 },
|
||
{ engine: "qwen", label: "通义千问", queries: 720, inputTokens: 1800000, outputTokens: 900000, cost: 8.5 },
|
||
{ engine: "gemini", label: "Google Gemini", queries: 450, inputTokens: 1100000, outputTokens: 600000, cost: 5.3 },
|
||
{ engine: "kimi", label: "Kimi", queries: 320, inputTokens: 800000, outputTokens: 400000, cost: 3.8 },
|
||
{ engine: "wenxin", label: "文心一言", queries: 280, inputTokens: 700000, outputTokens: 350000, cost: 0.02 },
|
||
{ engine: "doubao", label: "豆包", queries: 210, inputTokens: 520000, outputTokens: 280000, cost: 0.5 },
|
||
{ engine: "yuanbao", label: "腾讯元宝", queries: 150, inputTokens: 380000, outputTokens: 200000, cost: 0.7 },
|
||
{ engine: "perplexity", label: "Perplexity", queries: 30, inputTokens: 75000, outputTokens: 40000, cost: 2.6 },
|
||
],
|
||
};
|
||
|
||
function formatTokenCount(count: number): string {
|
||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||
if (count >= 1000) return `${(count / 1000).toFixed(0)}K`;
|
||
return String(count);
|
||
}
|
||
|
||
function formatCurrency(value: number): string {
|
||
return `¥${value.toFixed(2)}`;
|
||
}
|
||
|
||
function CircularProgress({ percentage, size = 120, strokeWidth = 10 }: { percentage: number; size?: number; strokeWidth?: number }) {
|
||
const radius = (size - strokeWidth) / 2;
|
||
const circumference = 2 * Math.PI * radius;
|
||
const offset = circumference - (percentage / 100) * circumference;
|
||
|
||
const color =
|
||
percentage >= 90 ? "#ef4444" : percentage >= 70 ? "#f59e0b" : "#10b981";
|
||
|
||
return (
|
||
<div className="relative inline-flex items-center justify-center">
|
||
<svg width={size} height={size} className="-rotate-90">
|
||
<circle
|
||
cx={size / 2}
|
||
cy={size / 2}
|
||
r={radius}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth={strokeWidth}
|
||
className="text-muted/30"
|
||
/>
|
||
<circle
|
||
cx={size / 2}
|
||
cy={size / 2}
|
||
r={radius}
|
||
fill="none"
|
||
stroke={color}
|
||
strokeWidth={strokeWidth}
|
||
strokeDasharray={circumference}
|
||
strokeDashoffset={offset}
|
||
strokeLinecap="round"
|
||
className="transition-all duration-500"
|
||
/>
|
||
</svg>
|
||
<div className="absolute flex flex-col items-center">
|
||
<span className="text-2xl font-bold" style={{ color }}>
|
||
{percentage.toFixed(1)}%
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">已使用</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function QuotaStatusBadge({ status }: { status: "normal" | "warning" | "exceeded" }) {
|
||
const config = {
|
||
normal: { label: "正常", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100", icon: CheckCircle },
|
||
warning: { label: "预警", className: "bg-amber-100 text-amber-700 hover:bg-amber-100", icon: AlertTriangle },
|
||
exceeded: { label: "超限", className: "bg-red-100 text-red-700 hover:bg-red-100", icon: AlertTriangle },
|
||
};
|
||
const c = config[status];
|
||
const Icon = c.icon;
|
||
return (
|
||
<Badge className={c.className}>
|
||
<Icon className="mr-1 h-3 w-3" />
|
||
{c.label}
|
||
</Badge>
|
||
);
|
||
}
|
||
|
||
export default function UsagePage() {
|
||
const [timeRange, setTimeRange] = useState<TimeRange>("7d");
|
||
|
||
const { data: usageData, isLoading, refresh } = useApi<UsageData>(
|
||
`/api/v1/usage/summary?period=${timeRange === "7d" ? "week" : timeRange === "30d" ? "month" : "month"}`
|
||
);
|
||
|
||
const data = usageData || MOCK_USAGE_DATA;
|
||
|
||
const totalCost = useMemo(() => {
|
||
return data.engineUsage.reduce((sum, item) => sum + item.cost, 0);
|
||
}, [data.engineUsage]);
|
||
|
||
const totalQueries = useMemo(() => {
|
||
return data.engineUsage.reduce((sum, item) => sum + item.queries, 0);
|
||
}, [data.engineUsage]);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">用量统计</h1>
|
||
<p className="text-muted-foreground">监控AI引擎调用成本和配额使用情况</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{(["7d", "30d", "month"] as TimeRange[]).map((range) => (
|
||
<Button
|
||
key={range}
|
||
variant={timeRange === range ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => setTimeRange(range)}
|
||
>
|
||
{TIME_RANGE_LABELS[range]}
|
||
</Button>
|
||
))}
|
||
<Button variant="outline" size="sm" onClick={refresh} disabled={isLoading}>
|
||
<RefreshCw className={`mr-1 h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="flex flex-col items-center justify-center py-12">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
<p className="mt-4 text-sm text-muted-foreground">正在加载用量数据...</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="rounded-lg bg-blue-100 p-2">
|
||
<DollarSign className="h-5 w-5 text-blue-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">本月已用金额</p>
|
||
<p className="text-2xl font-bold">{formatCurrency(data.quota.used)}</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="rounded-lg bg-purple-100 p-2">
|
||
<TrendingUp className="h-5 w-5 text-purple-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">配额上限</p>
|
||
<p className="text-2xl font-bold">{formatCurrency(data.quota.limit)}</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="rounded-lg bg-emerald-100 p-2">
|
||
<CheckCircle className="h-5 w-5 text-emerald-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">配额状态</p>
|
||
<QuotaStatusBadge status={data.quota.status} />
|
||
</div>
|
||
</div>
|
||
<CircularProgress percentage={data.quota.percentage} size={80} strokeWidth={8} />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<div className="grid gap-6 lg:grid-cols-3">
|
||
<Card className="lg:col-span-2">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">用量趋势</CardTitle>
|
||
<CardDescription>每日成本变化趋势</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="h-[300px]">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<LineChart data={data.trends}>
|
||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||
<XAxis
|
||
dataKey="date"
|
||
tick={{ fontSize: 12 }}
|
||
className="text-muted-foreground"
|
||
/>
|
||
<YAxis
|
||
tick={{ fontSize: 12 }}
|
||
className="text-muted-foreground"
|
||
tickFormatter={(v) => `¥${v}`}
|
||
/>
|
||
<Tooltip
|
||
formatter={(value: number) => [formatCurrency(value), "成本"]}
|
||
labelFormatter={(label) => `日期: ${label}`}
|
||
/>
|
||
<Line
|
||
type="monotone"
|
||
dataKey="cost"
|
||
stroke="#3b82f6"
|
||
strokeWidth={2}
|
||
dot={{ r: 4, fill: "#3b82f6" }}
|
||
activeDot={{ r: 6 }}
|
||
/>
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">引擎用量分布</CardTitle>
|
||
<CardDescription>各引擎成本占比</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="h-[300px]">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<PieChart>
|
||
<Pie
|
||
data={data.engineDistribution}
|
||
cx="50%"
|
||
cy="45%"
|
||
innerRadius={50}
|
||
outerRadius={80}
|
||
paddingAngle={2}
|
||
dataKey="cost"
|
||
nameKey="label"
|
||
>
|
||
{data.engineDistribution.map((_, index) => (
|
||
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||
))}
|
||
</Pie>
|
||
<Tooltip
|
||
formatter={(value: number) => [formatCurrency(value), "成本"]}
|
||
/>
|
||
<Legend
|
||
verticalAlign="bottom"
|
||
iconType="circle"
|
||
iconSize={8}
|
||
formatter={(value: string) => (
|
||
<span className="text-xs">{value}</span>
|
||
)}
|
||
/>
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">引擎用量明细</CardTitle>
|
||
<CardDescription>
|
||
共 {totalQueries} 次查询,总成本 {formatCurrency(totalCost)}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>引擎名称</TableHead>
|
||
<TableHead className="text-right">查询次数</TableHead>
|
||
<TableHead className="text-right">输入Token</TableHead>
|
||
<TableHead className="text-right">输出Token</TableHead>
|
||
<TableHead className="text-right">成本</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.engineUsage.map((item) => (
|
||
<TableRow key={item.engine}>
|
||
<TableCell className="font-medium">{item.label}</TableCell>
|
||
<TableCell className="text-right">{item.queries.toLocaleString()}</TableCell>
|
||
<TableCell className="text-right">{formatTokenCount(item.inputTokens)}</TableCell>
|
||
<TableCell className="text-right">{formatTokenCount(item.outputTokens)}</TableCell>
|
||
<TableCell className="text-right font-medium">{formatCurrency(item.cost)}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|