486 lines
16 KiB
TypeScript
486 lines
16 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,
|
||
AlertCircle,
|
||
} 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 ENGINE_LABELS: Record<string, string> = {
|
||
deepseek: "DeepSeek",
|
||
chatgpt: "ChatGPT",
|
||
qwen: "通义千问",
|
||
gemini: "Google Gemini",
|
||
kimi: "Kimi",
|
||
wenxin: "文心一言",
|
||
doubao: "豆包",
|
||
yuanbao: "腾讯元宝",
|
||
perplexity: "Perplexity",
|
||
};
|
||
|
||
const PIE_COLORS = [
|
||
"#3b82f6",
|
||
"#8b5cf6",
|
||
"#ec4899",
|
||
"#f59e0b",
|
||
"#10b981",
|
||
"#6366f1",
|
||
"#ef4444",
|
||
"#14b8a6",
|
||
"#f97316",
|
||
];
|
||
|
||
interface QuotaAPIResponse {
|
||
used: number;
|
||
limit: number;
|
||
usage_percentage: number;
|
||
status: "ok" | "warning" | "exceeded";
|
||
}
|
||
|
||
interface SummaryAPIResponse {
|
||
period: string;
|
||
start_date: string;
|
||
end_date: string;
|
||
total_queries: number;
|
||
total_input_tokens: number;
|
||
total_output_tokens: number;
|
||
total_cost: number;
|
||
by_engine: Record<string, {
|
||
queries: number;
|
||
input_tokens: number;
|
||
output_tokens: number;
|
||
cost: number;
|
||
}>;
|
||
}
|
||
|
||
interface ByEngineAPIResponse {
|
||
engines: Array<{
|
||
type: string;
|
||
queries: number;
|
||
cost: number;
|
||
}>;
|
||
}
|
||
|
||
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[];
|
||
}
|
||
|
||
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: quotaData, isLoading: isQuotaLoading, error: quotaError } = useApi<QuotaAPIResponse>("/api/v1/usage/quota");
|
||
const { data: summaryData, isLoading: isSummaryLoading, error: summaryError, refresh: refreshSummary } = useApi<SummaryAPIResponse>(
|
||
`/api/v1/usage/summary?period=${timeRange === "7d" ? "week" : timeRange === "30d" ? "month" : "month"}`
|
||
);
|
||
const { data: byEngineData, isLoading: isByEngineLoading, error: byEngineError } = useApi<ByEngineAPIResponse>("/api/v1/usage/by-engine");
|
||
|
||
const isLoading = isQuotaLoading || isSummaryLoading || isByEngineLoading;
|
||
const error = quotaError || summaryError || byEngineError;
|
||
const refresh = refreshSummary;
|
||
|
||
const usageData = useMemo((): UsageData | null => {
|
||
if (!quotaData || !summaryData || !byEngineData) {
|
||
return null;
|
||
}
|
||
|
||
const quota: QuotaData = {
|
||
used: quotaData.used,
|
||
limit: quotaData.limit,
|
||
percentage: quotaData.usage_percentage,
|
||
status: quotaData.status === "ok" ? "normal" : quotaData.status,
|
||
};
|
||
|
||
const trends: TrendItem[] = [];
|
||
if (summaryData.start_date && summaryData.end_date) {
|
||
const startDate = new Date(summaryData.start_date);
|
||
const endDate = new Date(summaryData.end_date);
|
||
const daysDiff = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||
const avgDailyCost = daysDiff > 0 ? summaryData.total_cost / daysDiff : 0;
|
||
|
||
for (let i = 0; i < Math.min(daysDiff, 7); i++) {
|
||
const date = new Date(startDate);
|
||
date.setDate(date.getDate() + i);
|
||
const monthDay = `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||
trends.push({ date: monthDay, cost: avgDailyCost });
|
||
}
|
||
}
|
||
|
||
const engineDistribution = byEngineData.engines?.map((e) => ({
|
||
engine: e.type,
|
||
label: ENGINE_LABELS[e.type] || e.type,
|
||
cost: e.cost,
|
||
})) || [];
|
||
|
||
const engineUsage: EngineUsageItem[] = Object.entries(summaryData.by_engine || {}).map(([engine, data]) => ({
|
||
engine,
|
||
label: ENGINE_LABELS[engine] || engine,
|
||
queries: data.queries,
|
||
inputTokens: data.input_tokens,
|
||
outputTokens: data.output_tokens,
|
||
cost: data.cost,
|
||
}));
|
||
|
||
return {
|
||
quota,
|
||
trends,
|
||
engineDistribution,
|
||
engineUsage,
|
||
};
|
||
}, [quotaData, summaryData, byEngineData]);
|
||
|
||
const data = usageData;
|
||
|
||
const totalCost = useMemo(() => {
|
||
if (!data) return 0;
|
||
return data.engineUsage.reduce((sum, item) => sum + item.cost, 0);
|
||
}, [data]);
|
||
|
||
const totalQueries = useMemo(() => {
|
||
if (!data) return 0;
|
||
return data.engineUsage.reduce((sum, item) => sum + item.queries, 0);
|
||
}, [data]);
|
||
|
||
const hasData = data && data.engineUsage.length > 0;
|
||
|
||
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>
|
||
) : error ? (
|
||
<div className="flex flex-col items-center justify-center py-12">
|
||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||
<p className="mt-4 text-sm text-muted-foreground">加载失败: {error.message}</p>
|
||
<Button variant="outline" size="sm" onClick={refresh} className="mt-4">
|
||
<RefreshCw className="mr-1 h-3 w-3" />
|
||
重试
|
||
</Button>
|
||
</div>
|
||
) : !hasData ? (
|
||
<div className="flex flex-col items-center justify-center py-12">
|
||
<div className="rounded-lg bg-muted p-4">
|
||
<DollarSign className="h-8 w-8 text-muted-foreground" />
|
||
</div>
|
||
<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>
|
||
);
|
||
}
|