geo/frontend/app/(dashboard)/dashboard/usage/page.tsx

486 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}