From a8f956275835ee1f0eb652107e1359859918b488 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 8 Dec 2025 17:08:55 +0800 Subject: [PATCH] refactor(usage): restructure usage dashboard components Comprehensive usage statistics panel refactoring: UsageDashboard: - Reorganize layout with improved section headers - Add better loading states and empty state handling ModelStatsTable & ProviderStatsTable: - Minor styling updates for consistency ModelTestConfigPanel & PricingConfigPanel: - Simplify component structure - Remove redundant Card wrappers - Improve form field organization RequestLogTable: - Enhance table layout with better column sizing - Improve pagination controls UsageSummaryCards: - Update card styling with semantic tokens - Better responsive grid layout UsageTrendChart: - Refine chart container styling - Improve legend and tooltip display --- src/components/usage/ModelStatsTable.tsx | 2 +- src/components/usage/ModelTestConfigPanel.tsx | 270 +++++++----------- src/components/usage/PricingConfigPanel.tsx | 237 +++++++-------- src/components/usage/ProviderStatsTable.tsx | 2 +- src/components/usage/RequestLogTable.tsx | 256 +++++++++-------- src/components/usage/UsageDashboard.tsx | 113 +++++--- src/components/usage/UsageSummaryCards.tsx | 218 ++++++++------ src/components/usage/UsageTrendChart.tsx | 214 ++++++++------ 8 files changed, 691 insertions(+), 621 deletions(-) diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx index 331ae8ef0..171fffd04 100644 --- a/src/components/usage/ModelStatsTable.tsx +++ b/src/components/usage/ModelStatsTable.tsx @@ -18,7 +18,7 @@ export function ModelStatsTable() { } return ( -
+
diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx index 1d880cdb0..4c0677e6b 100644 --- a/src/components/usage/ModelTestConfigPanel.tsx +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -1,17 +1,10 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { ChevronDown, ChevronRight, Save, Loader2 } from "lucide-react"; +import { Save, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { getModelTestConfig, @@ -21,7 +14,6 @@ import { export function ModelTestConfigPanel() { const { t } = useTranslation(); - const [isExpanded, setIsExpanded] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); @@ -66,160 +58,120 @@ export function ModelTestConfigPanel() { if (isLoading) { return ( - - setIsExpanded(!isExpanded)} - > -
- - - {t("modelTest.configTitle", "模型测试配置")} - -
-
-
+
+ +
); } return ( - - setIsExpanded(!isExpanded)} - > -
- {isExpanded ? ( - - ) : ( - - )} -
- - {t("modelTest.configTitle", "模型测试配置")} - - {!isExpanded && ( - - {t( - "modelTest.configDesc", - "配置模型测试使用的默认模型和提示词", - )} - - )} -
-
-
- - {isExpanded && ( - - {error && ( - - {error} - - )} - -
-
- - - setConfig({ ...config, claudeModel: e.target.value }) - } - placeholder="claude-haiku-4-5-20251001" - /> -
- -
- - - setConfig({ ...config, codexModel: e.target.value }) - } - placeholder="gpt-5.1-low" - /> -
- -
- - - setConfig({ ...config, geminiModel: e.target.value }) - } - placeholder="gemini-3-pro-low" - /> -
-
- -
-
- - - setConfig({ ...config, testPrompt: e.target.value }) - } - placeholder="ping" - /> -

- {t( - "modelTest.testPromptHint", - "发送给模型的测试消息,建议使用简短内容以减少 token 消耗", - )} -

-
- -
- - - setConfig({ - ...config, - timeoutSecs: parseInt(e.target.value) || 15, - }) - } - /> -
-
- -
- -
-
+
+ {error && ( + + {error} + )} - + +
+
+ + + setConfig({ ...config, claudeModel: e.target.value }) + } + placeholder="claude-haiku-4-5-20251001" + /> +
+ +
+ + + setConfig({ ...config, codexModel: e.target.value }) + } + placeholder="gpt-5.1-low" + /> +
+ +
+ + + setConfig({ ...config, geminiModel: e.target.value }) + } + placeholder="gemini-3-pro-low" + /> +
+
+ +
+
+ + + setConfig({ ...config, testPrompt: e.target.value }) + } + placeholder="ping" + /> +

+ {t( + "modelTest.testPromptHint", + "发送给模型的测试消息,建议使用简短内容以减少 token 消耗", + )} +

+
+ +
+ + + setConfig({ + ...config, + timeoutSecs: parseInt(e.target.value) || 15, + }) + } + /> +
+
+ +
+ +
+
); } diff --git a/src/components/usage/PricingConfigPanel.tsx b/src/components/usage/PricingConfigPanel.tsx index fb49bf1c9..85182221e 100644 --- a/src/components/usage/PricingConfigPanel.tsx +++ b/src/components/usage/PricingConfigPanel.tsx @@ -1,12 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, @@ -110,136 +104,107 @@ export function PricingConfigPanel() { } return ( - - setIsExpanded(!isExpanded)} - > -
-
- {isExpanded ? ( - - ) : ( - - )} -
- - {t("usage.modelPricing", "模型定价")} - {pricing && pricing.length > 0 && ( - - ({pricing.length}) - - )} - - {!isExpanded && ( - - {t( - "usage.modelPricingDesc", - "配置各模型的 Token 成本(每百万 tokens 的 USD 价格,支持 * 与 ? 通配)", - )} - - )} -
-
- -
-
+
+
+

+ {t("usage.modelPricingDesc", "配置各模型的 Token 成本")} (每百万) +

+ +
- {isExpanded && ( - - {!pricing || pricing.length === 0 ? ( - - - {t( - "usage.noPricingData", - '暂无定价数据。点击"新增"添加模型定价配置。', - )} - - - ) : ( -
-
- - - {t("usage.model", "模型")} - {t("usage.displayName", "显示名称")} - - {t("usage.inputCost", "输入成本")} - - - {t("usage.outputCost", "输出成本")} - - - {t("usage.cacheReadCost", "缓存读取")} - - - {t("usage.cacheWriteCost", "缓存写入")} - - - {t("common.actions", "操作")} - +
+ {!pricing || pricing.length === 0 ? ( + + + {t( + "usage.noPricingData", + '暂无定价数据。点击"新增"添加模型定价配置。', + )} + + + ) : ( +
+
+ + + {t("usage.model", "模型")} + {t("usage.displayName", "显示名称")} + + {t("usage.inputCost", "输入成本")} + + + {t("usage.outputCost", "输出成本")} + + + {t("usage.cacheReadCost", "缓存读取")} + + + {t("usage.cacheWriteCost", "缓存写入")} + + + {t("common.actions", "操作")} + + + + + {pricing.map((model) => ( + + + {model.modelId} + + {model.displayName} + + ${model.inputCostPerMillion} + + + ${model.outputCostPerMillion} + + + ${model.cacheReadCostPerMillion} + + + ${model.cacheCreationCostPerMillion} + + +
+ + +
+
- - - {pricing.map((model) => ( - - - {model.modelId} - - {model.displayName} - - ${model.inputCostPerMillion} - - - ${model.outputCostPerMillion} - - - ${model.cacheReadCostPerMillion} - - - ${model.cacheCreationCostPerMillion} - - -
- - -
-
-
- ))} -
-
-
- )} - - )} + ))} + + +
+ )} + {editingModel && ( - + ); } diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx index e7233ec60..be6e72f51 100644 --- a/src/components/usage/ProviderStatsTable.tsx +++ b/src/components/usage/ProviderStatsTable.tsx @@ -18,7 +18,7 @@ export function ProviderStatsTable() { } return ( -
+
diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 26dbc66b9..c644b210c 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -65,123 +65,151 @@ export function RequestLogTable() { return (
{/* 筛选栏 */} -
- +
+
+ - + - - setTempFilters({ - ...tempFilters, - providerName: e.target.value || undefined, - }) - } - /> +
+
+ + + setTempFilters({ + ...tempFilters, + providerName: e.target.value || undefined, + }) + } + /> +
+ + setTempFilters({ + ...tempFilters, + model: e.target.value || undefined, + }) + } + /> +
+
- - setTempFilters({ - ...tempFilters, - model: e.target.value || undefined, - }) - } - /> +
+
+ 时间范围: + + setTempFilters({ + ...tempFilters, + startDate: e.target.value + ? Math.floor(new Date(e.target.value).getTime() / 1000) + : undefined, + }) + } + /> + - + + setTempFilters({ + ...tempFilters, + endDate: e.target.value + ? Math.floor(new Date(e.target.value).getTime() / 1000) + : undefined, + }) + } + /> +
- - setTempFilters({ - ...tempFilters, - startDate: e.target.value - ? Math.floor(new Date(e.target.value).getTime() / 1000) - : undefined, - }) - } - /> - - - setTempFilters({ - ...tempFilters, - endDate: e.target.value - ? Math.floor(new Date(e.target.value).getTime() / 1000) - : undefined, - }) - } - /> - -
- - - +
+ + + +
@@ -189,7 +217,7 @@ export function RequestLogTable() {
) : ( <> -
+
diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index ef63ff0d6..d9af2b0c1 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -1,13 +1,14 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Card } from "@/components/ui/card"; import { UsageSummaryCards } from "./UsageSummaryCards"; import { UsageTrendChart } from "./UsageTrendChart"; import { RequestLogTable } from "./RequestLogTable"; import { ProviderStatsTable } from "./ProviderStatsTable"; import { ModelStatsTable } from "./ModelStatsTable"; import type { TimeRange } from "@/types/usage"; +import { motion } from "framer-motion"; +import { BarChart3, ListFilter, Activity } from "lucide-react"; export function UsageDashboard() { const { t } = useTranslation(); @@ -16,50 +17,90 @@ export function UsageDashboard() { const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30; return ( -
-
- + + + {t("usage.today", "24小时")} + + + {t("usage.last7days", "7天")} + + + {t("usage.last30days", "30天")} + + +
- - - + - - - - {t("usage.requestLogs", "请求日志")} - - - {t("usage.providerStats", "Provider 统计")} - - - {t("usage.modelStats", "模型统计")} - - +
+ +
+ + + + {t("usage.requestLogs", "请求日志")} + + + + {t("usage.providerStats", "Provider 统计")} + + + + {t("usage.modelStats", "模型统计")} + + +
- - - + + + + - - - + + + - - - -
-
+ + + + +
+
+ ); } diff --git a/src/components/usage/UsageSummaryCards.tsx b/src/components/usage/UsageSummaryCards.tsx index 5256eadd4..01b6dd7bd 100644 --- a/src/components/usage/UsageSummaryCards.tsx +++ b/src/components/usage/UsageSummaryCards.tsx @@ -1,7 +1,9 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { useUsageSummary } from "@/lib/query/usage"; +import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react"; +import { motion } from "framer-motion"; interface UsageSummaryCardsProps { days: number; @@ -10,7 +12,6 @@ interface UsageSummaryCardsProps { export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { const { t } = useTranslation(); - // 使用 useMemo 稳定时间戳,只在 days 变化时重新计算 const { startDate, endDate } = useMemo(() => { const end = Math.floor(Date.now() / 1000); const start = end - days * 24 * 60 * 60; @@ -18,25 +19,110 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { }, [days]); const { data: summary, isLoading } = useUsageSummary(startDate, endDate); - const totalRequests = summary?.totalRequests ?? 0; - const totalCost = parseFloat(summary?.totalCost || "0").toFixed(4); - const totalInputTokens = summary?.totalInputTokens ?? 0; - const totalOutputTokens = summary?.totalOutputTokens ?? 0; - const totalTokens = totalInputTokens + totalOutputTokens; - const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0; - const cacheReadTokens = summary?.totalCacheReadTokens ?? 0; - const totalCacheTokens = cacheWriteTokens + cacheReadTokens; + + const stats = useMemo(() => { + const totalRequests = summary?.totalRequests ?? 0; + const totalCost = parseFloat(summary?.totalCost || "0"); + + const inputTokens = summary?.totalInputTokens ?? 0; + const outputTokens = summary?.totalOutputTokens ?? 0; + const totalTokens = inputTokens + outputTokens; + + const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0; + const cacheReadTokens = summary?.totalCacheReadTokens ?? 0; + const totalCacheTokens = cacheWriteTokens + cacheReadTokens; + + return [ + { + title: t("usage.totalRequests", "总请求数"), + value: totalRequests.toLocaleString(), + icon: Activity, + color: "text-blue-500", + bg: "bg-blue-500/10", + subValue: null, + }, + { + title: t("usage.totalCost", "总成本"), + value: `$${totalCost.toFixed(4)}`, + icon: DollarSign, + color: "text-green-500", + bg: "bg-green-500/10", + subValue: null, + }, + { + title: t("usage.totalTokens", "总 Token 数"), + value: totalTokens.toLocaleString(), + icon: Layers, + color: "text-purple-500", + bg: "bg-purple-500/10", + subValue: ( +
+
+ Input + + {(inputTokens / 1000).toFixed(1)}k + +
+
+ Output + + {(outputTokens / 1000).toFixed(1)}k + +
+
+ ), + }, + { + title: t("usage.cacheTokens", "缓存 Token"), + value: totalCacheTokens.toLocaleString(), + icon: Database, + color: "text-orange-500", + bg: "bg-orange-500/10", + subValue: ( +
+
+ Write + + {(cacheWriteTokens / 1000).toFixed(1)}k + +
+
+ Read + + {(cacheReadTokens / 1000).toFixed(1)}k + +
+
+ ), + }, + ]; + }, [summary, t]); + + const container = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + staggerChildren: 0.1, + }, + }, + }; + + const item = { + hidden: { opacity: 0, y: 20 }, + show: { opacity: 1, y: 0 }, + }; if (isLoading) { return (
{[...Array(4)].map((_, i) => ( - - -
- - -
+ + + ))} @@ -45,75 +131,39 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { } return ( -
- - - - {t("usage.totalRequests", "总请求数")} - - - -
- {totalRequests.toLocaleString()} -
-
-
+ + {stats.map((stat, i) => ( + + + +
+

+ {stat.title} +

+
+ +
+
- - - - {t("usage.totalCost", "总成本")} - - - -
${totalCost}
-
-
+
+

+ {stat.value} +

+
- - - - {t("usage.totalTokens", "总 Token 数")} - - - -
- {totalTokens.toLocaleString()} -
-
-
- {t("usage.inputTokens", "输入")}:{" "} - {totalInputTokens.toLocaleString()} -
-
- {t("usage.outputTokens", "输出")}:{" "} - {totalOutputTokens.toLocaleString()} -
-
-
-
- - - - - {t("usage.cacheTokens", "缓存 Token")} - - - -
- {totalCacheTokens.toLocaleString()} -
-
-
- {t("usage.cacheWrite", "写入")}:{" "} - {cacheWriteTokens.toLocaleString()} -
-
- {t("usage.cacheRead", "读取")}: {cacheReadTokens.toLocaleString()} -
-
-
-
-
+ {stat.subValue || ( + /* Placeholder to properly align cards if no subvalue (first 2 cards) - effectively adding empty space or using flex-1 equivalent */ +
+ )} + + + + ))} + ); } diff --git a/src/components/usage/UsageTrendChart.tsx b/src/components/usage/UsageTrendChart.tsx index 2fa2f8363..7d82a6282 100644 --- a/src/components/usage/UsageTrendChart.tsx +++ b/src/components/usage/UsageTrendChart.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { - LineChart, - Line, + AreaChart, + Area, XAxis, YAxis, CartesianGrid, @@ -10,6 +10,7 @@ import { Legend, } from "recharts"; import { useUsageTrends } from "@/lib/query/usage"; +import { Loader2 } from "lucide-react"; interface UsageTrendChartProps { days: number; @@ -20,7 +21,11 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { const { data: trends, isLoading } = useUsageTrends(days); if (isLoading) { - return
; + return ( +
+ +
+ ); } const isToday = days === 1; @@ -38,8 +43,6 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { hour: pointDate.getHours(), inputTokens: stat.totalInputTokens, outputTokens: stat.totalOutputTokens, - cacheCreationTokens: stat.totalCacheCreationTokens, - cacheReadTokens: stat.totalCacheReadTokens, cost: parseFloat(stat.totalCost), }; }) || []; @@ -56,8 +59,6 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { label: `${hour.toString().padStart(2, "0")}:00`, inputTokens: bucket?.inputTokens ?? 0, outputTokens: bucket?.outputTokens ?? 0, - cacheCreationTokens: bucket?.cacheCreationTokens ?? 0, - cacheReadTokens: bucket?.cacheReadTokens ?? 0, cost: bucket?.cost ?? 0, }; }); @@ -65,96 +66,129 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { const displayData = isToday ? hourlyData : chartData; - const rangeLabel = isToday - ? t("usage.rangeToday", "今天 (按小时)") - : days === 7 - ? t("usage.rangeLast7Days", "过去 7 天") - : t("usage.rangeLast30Days", "过去 30 天"); + const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+ {payload.map((entry: any, index: number) => ( +
+
+ {entry.name}: + + {entry.name.includes(t("usage.cost", "成本")) + ? `$${typeof entry.value === "number" ? entry.value.toFixed(6) : entry.value}` + : entry.value.toLocaleString()} + +
+ ))} +
+ ); + } + return null; + }; return ( -
-
+
+

{t("usage.trends", "使用趋势")}

-

{rangeLabel}

+

+ {isToday + ? t("usage.rangeToday", "今天 (按小时)") + : days === 7 + ? t("usage.rangeLast7Days", "过去 7 天") + : t("usage.rangeLast30Days", "过去 30 天")} +

- - - - - - - - - - - - - - - +
+ + + + + + + + + + + + + + + `${(value / 1000).toFixed(0)}k`} + /> + `$${value}`} + /> + } /> + + + + + + +
); }