diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx index 5a8e2594b..331ae8ef0 100644 --- a/src/components/usage/ModelStatsTable.tsx +++ b/src/components/usage/ModelStatsTable.tsx @@ -1,6 +1,13 @@ -import { useTranslation } from 'react-i18next'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { useModelStats } from '@/lib/query/usage'; +import { useTranslation } from "react-i18next"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useModelStats } from "@/lib/query/usage"; export function ModelStatsTable() { const { t } = useTranslation(); @@ -11,32 +18,53 @@ export function ModelStatsTable() { } return ( -
+
- {t('usage.model', '模型')} - {t('usage.requests', '请求数')} - {t('usage.tokens', 'Tokens')} - {t('usage.totalCost', '总成本')} - {t('usage.avgCost', '平均成本')} + {t("usage.model", "模型")} + + {t("usage.requests", "请求数")} + + + {t("usage.tokens", "Tokens")} + + + {t("usage.totalCost", "总成本")} + + + {t("usage.avgCost", "平均成本")} + {stats?.length === 0 ? ( - - {t('usage.noData', '暂无数据')} + + {t("usage.noData", "暂无数据")} ) : ( stats?.map((stat) => ( - {stat.model} - {stat.requestCount.toLocaleString()} - {stat.totalTokens.toLocaleString()} - ${parseFloat(stat.totalCost).toFixed(4)} - ${parseFloat(stat.avgCostPerRequest).toFixed(6)} + + {stat.model} + + + {stat.requestCount.toLocaleString()} + + + {stat.totalTokens.toLocaleString()} + + + ${parseFloat(stat.totalCost).toFixed(4)} + + + ${parseFloat(stat.avgCostPerRequest).toFixed(6)} + )) )} diff --git a/src/components/usage/PricingConfigPanel.tsx b/src/components/usage/PricingConfigPanel.tsx index 342c4c57b..f39111c4a 100644 --- a/src/components/usage/PricingConfigPanel.tsx +++ b/src/components/usage/PricingConfigPanel.tsx @@ -1,69 +1,289 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Button } from '@/components/ui/button'; -import { useModelPricing } from '@/lib/query/usage'; -import { PricingEditModal } from './PricingEditModal'; -import type { ModelPricing } from '@/types/usage'; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage"; +import { PricingEditModal } from "./PricingEditModal"; +import type { ModelPricing } from "@/types/usage"; +import { Plus, Pencil, Trash2, ChevronDown, ChevronRight } from "lucide-react"; export function PricingConfigPanel() { const { t } = useTranslation(); - const { data: pricing, isLoading } = useModelPricing(); + const { data: pricing, isLoading, error } = useModelPricing(); + const deleteMutation = useDeleteModelPricing(); const [editingModel, setEditingModel] = useState(null); + const [isAddingNew, setIsAddingNew] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + + const handleDelete = (modelId: string) => { + deleteMutation.mutate(modelId, { + onSuccess: () => { + setDeleteConfirm(null); + }, + }); + }; + + const handleAddNew = () => { + setIsAddingNew(true); + setEditingModel({ + modelId: "", + displayName: "", + inputCostPerMillion: "0", + outputCostPerMillion: "0", + cacheReadCostPerMillion: "0", + cacheCreationCostPerMillion: "0", + }); + }; if (isLoading) { - return
; + return ( + + setIsExpanded(!isExpanded)} + > +
+ + + {t("usage.modelPricing", "模型定价")} + +
+
+
+ ); + } + + if (error) { + return ( + + setIsExpanded(!isExpanded)} + > +
+ {isExpanded ? ( + + ) : ( + + )} + + {t("usage.modelPricing", "模型定价")} + +
+
+ {isExpanded && ( + + + + {t("usage.loadPricingError", "加载定价数据失败")}:{" "} + {String(error)} + + + + )} +
+ ); } return ( -
-
-

{t('usage.modelPricing', '模型定价')}

-
+ + setIsExpanded(!isExpanded)} + > +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+ + {t("usage.modelPricing", "模型定价")} + {pricing && pricing.length > 0 && ( + + ({pricing.length}) + + )} + + {!isExpanded && ( + + {t( + "usage.modelPricingDesc", + "配置各模型的 Token 成本(每百万 tokens 的 USD 价格,支持 * 与 ? 通配)", + )} + + )} +
+
+ +
+
-
-
- - - {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} - - - - - ))} - -
-
+ {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.map((model) => ( + + + {model.modelId} + + {model.displayName} + + ${model.inputCostPerMillion} + + + ${model.outputCostPerMillion} + + + ${model.cacheReadCostPerMillion} + + + ${model.cacheCreationCostPerMillion} + + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+ )} {editingModel && ( setEditingModel(null)} + isNew={isAddingNew} + onClose={() => { + setEditingModel(null); + setIsAddingNew(false); + }} /> )} -
+ + setDeleteConfirm(null)} + > + + + + {t("usage.deleteConfirmTitle", "确认删除")} + + + {t( + "usage.deleteConfirmDesc", + "确定要删除此模型定价配置吗?此操作无法撤销。", + )} + + + + + + + + + ); } diff --git a/src/components/usage/PricingEditModal.tsx b/src/components/usage/PricingEditModal.tsx index 42384ea16..c759ac01d 100644 --- a/src/components/usage/PricingEditModal.tsx +++ b/src/components/usage/PricingEditModal.tsx @@ -1,30 +1,35 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { useUpdateModelPricing } from '@/lib/query/usage'; -import { useToast } from '@/hooks/use-toast'; -import type { ModelPricing } from '@/types/usage'; +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useUpdateModelPricing } from "@/lib/query/usage"; +import type { ModelPricing } from "@/types/usage"; interface PricingEditModalProps { model: ModelPricing; + isNew?: boolean; onClose: () => void; } -export function PricingEditModal({ model, onClose }: PricingEditModalProps) { +export function PricingEditModal({ + model, + isNew = false, + onClose, +}: PricingEditModalProps) { const { t } = useTranslation(); - const { toast } = useToast(); const updatePricing = useUpdateModelPricing(); const [formData, setFormData] = useState({ + modelId: model.modelId, displayName: model.displayName, inputCost: model.inputCostPerMillion, outputCost: model.outputCostPerMillion, @@ -35,6 +40,12 @@ export function PricingEditModal({ model, onClose }: PricingEditModalProps) { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + // 验证模型 ID + if (isNew && !formData.modelId.trim()) { + toast.error(t("usage.modelIdRequired", "模型 ID 不能为空")); + return; + } + // 验证非负数 const values = [ formData.inputCost, @@ -46,18 +57,14 @@ export function PricingEditModal({ model, onClose }: PricingEditModalProps) { for (const value of values) { const num = parseFloat(value); if (isNaN(num) || num < 0) { - toast({ - title: t('common.error', '错误'), - description: t('usage.invalidPrice', '价格必须为非负数'), - variant: 'destructive', - }); + toast.error(t("usage.invalidPrice", "价格必须为非负数")); return; } } try { await updatePricing.mutateAsync({ - modelId: model.modelId, + modelId: isNew ? formData.modelId : model.modelId, displayName: formData.displayName, inputCost: formData.inputCost, outputCost: formData.outputCost, @@ -65,18 +72,15 @@ export function PricingEditModal({ model, onClose }: PricingEditModalProps) { cacheCreationCost: formData.cacheCreationCost, }); - toast({ - title: t('common.success', '成功'), - description: t('usage.pricingUpdated', '定价已更新'), - }); + toast.success( + isNew + ? t("usage.pricingAdded", "定价已添加") + : t("usage.pricingUpdated", "定价已更新"), + ); onClose(); } catch (error) { - toast({ - title: t('common.error', '错误'), - description: String(error), - variant: 'destructive', - }); + toast.error(String(error)); } }; @@ -85,24 +89,46 @@ export function PricingEditModal({ model, onClose }: PricingEditModalProps) { - {t('usage.editPricing', '编辑定价')} - {model.modelId} + {isNew + ? t("usage.addPricing", "新增定价") + : `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`}
+ {isNew && ( +
+ + + setFormData({ ...formData, modelId: e.target.value }) + } + placeholder="例如: claude-3-5-sonnet-20241022" + required + /> +
+ )} +
- + setFormData({ ...formData, displayName: e.target.value })} + onChange={(e) => + setFormData({ ...formData, displayName: e.target.value }) + } + placeholder="例如: Claude 3.5 Sonnet" required />
setFormData({ ...formData, inputCost: e.target.value })} + onChange={(e) => + setFormData({ ...formData, inputCost: e.target.value }) + } required />
setFormData({ ...formData, outputCost: e.target.value })} + onChange={(e) => + setFormData({ ...formData, outputCost: e.target.value }) + } required />
setFormData({ ...formData, cacheReadCost: e.target.value })} + onChange={(e) => + setFormData({ ...formData, cacheReadCost: e.target.value }) + } required />
setFormData({ ...formData, cacheCreationCost: e.target.value })} + onChange={(e) => + setFormData({ ...formData, cacheCreationCost: e.target.value }) + } required />
diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx index 03e57b53d..e7233ec60 100644 --- a/src/components/usage/ProviderStatsTable.tsx +++ b/src/components/usage/ProviderStatsTable.tsx @@ -1,6 +1,13 @@ -import { useTranslation } from 'react-i18next'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { useProviderStats } from '@/lib/query/usage'; +import { useTranslation } from "react-i18next"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useProviderStats } from "@/lib/query/usage"; export function ProviderStatsTable() { const { t } = useTranslation(); @@ -11,34 +18,59 @@ export function ProviderStatsTable() { } return ( -
+
- {t('usage.provider', 'Provider')} - {t('usage.requests', '请求数')} - {t('usage.tokens', 'Tokens')} - {t('usage.cost', '成本')} - {t('usage.successRate', '成功率')} - {t('usage.avgLatency', '平均延迟')} + {t("usage.provider", "Provider")} + + {t("usage.requests", "请求数")} + + + {t("usage.tokens", "Tokens")} + + + {t("usage.cost", "成本")} + + + {t("usage.successRate", "成功率")} + + + {t("usage.avgLatency", "平均延迟")} + {stats?.length === 0 ? ( - - {t('usage.noData', '暂无数据')} + + {t("usage.noData", "暂无数据")} ) : ( stats?.map((stat) => ( - {stat.providerName} - {stat.requestCount.toLocaleString()} - {stat.totalTokens.toLocaleString()} - ${parseFloat(stat.totalCost).toFixed(4)} - {stat.successRate.toFixed(1)}% - {stat.avgLatencyMs}ms + + {stat.providerName} + + + {stat.requestCount.toLocaleString()} + + + {stat.totalTokens.toLocaleString()} + + + ${parseFloat(stat.totalCost).toFixed(4)} + + + {stat.successRate.toFixed(1)}% + + + {stat.avgLatencyMs}ms + )) )} diff --git a/src/components/usage/RequestDetailPanel.tsx b/src/components/usage/RequestDetailPanel.tsx index 207273ed7..aa91ee510 100644 --- a/src/components/usage/RequestDetailPanel.tsx +++ b/src/components/usage/RequestDetailPanel.tsx @@ -1,18 +1,21 @@ -import { useTranslation } from 'react-i18next'; +import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, DialogHeader, DialogTitle, -} from '@/components/ui/dialog'; -import { useRequestDetail } from '@/lib/query/usage'; +} from "@/components/ui/dialog"; +import { useRequestDetail } from "@/lib/query/usage"; interface RequestDetailPanelProps { requestId: string; onClose: () => void; } -export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelProps) { +export function RequestDetailPanel({ + requestId, + onClose, +}: RequestDetailPanelProps) { const { t } = useTranslation(); const { data: request, isLoading } = useRequestDetail(requestId); @@ -31,10 +34,10 @@ export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelPro - {t('usage.requestDetail', '请求详情')} + {t("usage.requestDetail", "请求详情")}
- {t('usage.requestNotFound', '请求未找到')} + {t("usage.requestNotFound", "请求未找到")}
@@ -45,47 +48,65 @@ export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelPro - {t('usage.requestDetail', '请求详情')} + {t("usage.requestDetail", "请求详情")}
{/* 基本信息 */}
-

{t('usage.basicInfo', '基本信息')}

+

+ {t("usage.basicInfo", "基本信息")} +

-
{t('usage.requestId', '请求ID')}
+
+ {t("usage.requestId", "请求ID")} +
{request.requestId}
-
{t('usage.time', '时间')}
-
{new Date(request.createdAt * 1000).toLocaleString('zh-CN')}
+
+ {t("usage.time", "时间")} +
+
+ {new Date(request.createdAt * 1000).toLocaleString("zh-CN")} +
-
{t('usage.provider', '供应商')}
+
+ {t("usage.provider", "供应商")} +
- {request.providerName || t('usage.unknownProvider', '未知')} + + {request.providerName || t("usage.unknownProvider", "未知")} + {request.providerId}
-
{t('usage.appType', '应用类型')}
+
+ {t("usage.appType", "应用类型")} +
{request.appType}
-
{t('usage.model', '模型')}
+
+ {t("usage.model", "模型")} +
{request.model}
-
{t('usage.status', '状态')}
+
+ {t("usage.status", "状态")} +
= 200 && request.statusCode < 300 - ? 'bg-green-100 text-green-800' - : 'bg-red-100 text-red-800' + ? "bg-green-100 text-green-800" + : "bg-red-100 text-red-800" }`} > {request.statusCode} @@ -97,28 +118,50 @@ export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelPro {/* Token 使用量 */}
-

{t('usage.tokenUsage', 'Token 使用量')}

+

+ {t("usage.tokenUsage", "Token 使用量")} +

-
{t('usage.inputTokens', '输入 Tokens')}
-
{request.inputTokens.toLocaleString()}
+
+ {t("usage.inputTokens", "输入 Tokens")} +
+
+ {request.inputTokens.toLocaleString()} +
-
{t('usage.outputTokens', '输出 Tokens')}
-
{request.outputTokens.toLocaleString()}
+
+ {t("usage.outputTokens", "输出 Tokens")} +
+
+ {request.outputTokens.toLocaleString()} +
-
{t('usage.cacheReadTokens', '缓存读取')}
-
{request.cacheReadTokens.toLocaleString()}
+
+ {t("usage.cacheReadTokens", "缓存读取")} +
+
+ {request.cacheReadTokens.toLocaleString()} +
-
{t('usage.cacheCreationTokens', '缓存写入')}
-
{request.cacheCreationTokens.toLocaleString()}
+
+ {t("usage.cacheCreationTokens", "缓存写入")} +
+
+ {request.cacheCreationTokens.toLocaleString()} +
-
{t('usage.totalTokens', '总计')}
+
+ {t("usage.totalTokens", "总计")} +
- {(request.inputTokens + request.outputTokens).toLocaleString()} + {( + request.inputTokens + request.outputTokens + ).toLocaleString()}
@@ -126,26 +169,46 @@ export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelPro {/* 成本明细 */}
-

{t('usage.costBreakdown', '成本明细')}

+

+ {t("usage.costBreakdown", "成本明细")} +

-
{t('usage.inputCost', '输入成本')}
-
${parseFloat(request.inputCostUsd).toFixed(6)}
+
+ {t("usage.inputCost", "输入成本")} +
+
+ ${parseFloat(request.inputCostUsd).toFixed(6)} +
-
{t('usage.outputCost', '输出成本')}
-
${parseFloat(request.outputCostUsd).toFixed(6)}
+
+ {t("usage.outputCost", "输出成本")} +
+
+ ${parseFloat(request.outputCostUsd).toFixed(6)} +
-
{t('usage.cacheReadCost', '缓存读取成本')}
-
${parseFloat(request.cacheReadCostUsd).toFixed(6)}
+
+ {t("usage.cacheReadCost", "缓存读取成本")} +
+
+ ${parseFloat(request.cacheReadCostUsd).toFixed(6)} +
-
{t('usage.cacheCreationCost', '缓存写入成本')}
-
${parseFloat(request.cacheCreationCostUsd).toFixed(6)}
+
+ {t("usage.cacheCreationCost", "缓存写入成本")} +
+
+ ${parseFloat(request.cacheCreationCostUsd).toFixed(6)} +
-
{t('usage.totalCost', '总成本')}
+
+ {t("usage.totalCost", "总成本")} +
${parseFloat(request.totalCostUsd).toFixed(6)}
@@ -155,10 +218,14 @@ export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelPro {/* 性能信息 */}
-

{t('usage.performance', '性能信息')}

+

+ {t("usage.performance", "性能信息")} +

-
{t('usage.latency', '延迟')}
+
+ {t("usage.latency", "延迟")} +
{request.latencyMs}ms
@@ -167,7 +234,9 @@ export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelPro {/* 错误信息 */} {request.errorMessage && (
-

{t('usage.errorMessage', '错误信息')}

+

+ {t("usage.errorMessage", "错误信息")} +

{request.errorMessage}

)} diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index f8b4fa28d..1a667bb82 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -1,16 +1,31 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { useRequestLogs } from '@/lib/query/usage'; -import type { LogFilters } from '@/types/usage'; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useRequestLogs } from "@/lib/query/usage"; +import { RequestDetailPanel } from "./RequestDetailPanel"; +import type { LogFilters } from "@/types/usage"; export function RequestLogTable() { const { t } = useTranslation(); const [filters] = useState({}); const [page, setPage] = useState(0); + const [selectedRequestId, setSelectedRequestId] = useState( + null, + ); const limit = 20; - const { data: logs, isLoading } = useRequestLogs(filters, limit, page * limit); + const { data: logs, isLoading } = useRequestLogs( + filters, + limit, + page * limit, + ); if (isLoading) { return
; @@ -18,47 +33,92 @@ export function RequestLogTable() { return (
-
+
- {t('usage.time', '时间')} - {t('usage.provider', 'Provider')} - {t('usage.model', '模型')} - {t('usage.tokens', 'Tokens')} - {t('usage.cost', '成本')} - {t('usage.latency', '延迟')} - {t('usage.status', '状态')} + {t("usage.time", "时间")} + {t("usage.provider", "供应商")} + {t("usage.billingModel", "计费模型")} + + {t("usage.inputTokens", "输入")} + + + {t("usage.outputTokens", "输出")} + + + {t("usage.cacheCreationTokens", "缓存写入")} + + + {t("usage.cacheReadTokens", "缓存读取")} + + + {t("usage.totalCost", "成本")} + + + {t("usage.latency", "耗时")} + + {t("usage.status", "状态")} {logs?.length === 0 ? ( - - {t('usage.noData', '暂无数据')} + + {t("usage.noData", "暂无数据")} ) : ( logs?.map((log) => ( - + setSelectedRequestId(log.requestId)} + > - {new Date(log.createdAt * 1000).toLocaleString('zh-CN')} + {new Date(log.createdAt * 1000).toLocaleString("zh-CN")} + + +
+ + {log.providerName || + t("usage.unknownProvider", "未知供应商")} + + + {log.providerId} + +
+
+ + {log.model} - {log.providerId} - {log.model} - {(log.inputTokens + log.outputTokens).toLocaleString()} + {log.inputTokens.toLocaleString()} + + + {log.outputTokens.toLocaleString()} + + + {log.cacheCreationTokens.toLocaleString()} + + + {log.cacheReadTokens.toLocaleString()} ${parseFloat(log.totalCostUsd).toFixed(6)} - {log.latencyMs}ms + + {log.latencyMs}ms + = 200 && log.statusCode < 300 - ? 'bg-green-100 text-green-800' - : 'bg-red-100 text-red-800' + ? "bg-green-100 text-green-800" + : "bg-red-100 text-red-800" }`} > {log.statusCode} @@ -78,20 +138,27 @@ export function RequestLogTable() { disabled={page === 0} className="rounded border px-3 py-1 disabled:opacity-50" > - {t('common.previous', '上一页')} + {t("common.previous", "上一页")} - {t('common.page', '第')} {page + 1} {t('common.pageUnit', '页')} + {t("common.page", "第")} {page + 1} {t("common.pageUnit", "页")} )} + + {selectedRequestId && ( + setSelectedRequestId(null)} + /> + )} ); } diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index cdab41e36..ef63ff0d6 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -1,46 +1,51 @@ -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 { 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"; export function UsageDashboard() { const { t } = useTranslation(); - const [timeRange, setTimeRange] = useState('7d'); + const [timeRange, setTimeRange] = useState("1d"); - const days = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90; + const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30; return ( -
-
-

{t('usage.title', '使用统计')}

+
+
- + - {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 0b6f9f83e..12dc93d63 100644 --- a/src/components/usage/UsageSummaryCards.tsx +++ b/src/components/usage/UsageSummaryCards.tsx @@ -1,6 +1,6 @@ -import { useTranslation } from 'react-i18next'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { useUsageSummary } from '@/lib/query/usage'; +import { useTranslation } from "react-i18next"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useUsageSummary } from "@/lib/query/usage"; interface UsageSummaryCardsProps { days: number; @@ -10,8 +10,16 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { const { t } = useTranslation(); const endDate = Math.floor(Date.now() / 1000); const startDate = endDate - days * 24 * 60 * 60; - + 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; if (isLoading) { return ( @@ -35,23 +43,12 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { - {t('usage.totalRequests', '总请求数')} - - - -
{summary?.totalRequests || 0}
-
-
- - - - - {t('usage.totalCost', '总成本')} + {t("usage.totalRequests", "总请求数")}
- ${parseFloat(summary?.totalCost || '0').toFixed(4)} + {totalRequests.toLocaleString()}
@@ -59,12 +56,33 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { - {t('usage.totalTokens', '总 Token 数')} + {t("usage.totalCost", "总成本")} + + + +
${totalCost}
+
+
+ + + + + {t("usage.totalTokens", "总 Token 数")}
- {((summary?.totalInputTokens || 0) + (summary?.totalOutputTokens || 0)).toLocaleString()} + {totalTokens.toLocaleString()} +
+
+
+ {t("usage.inputTokens", "输入")}:{" "} + {totalInputTokens.toLocaleString()} +
+
+ {t("usage.outputTokens", "输出")}:{" "} + {totalOutputTokens.toLocaleString()} +
@@ -72,12 +90,21 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { - {t('usage.successRate', '成功率')} + {t("usage.cacheTokens", "缓存 Token")}
- {summary?.successRate.toFixed(1) || 0}% + {totalCacheTokens.toLocaleString()} +
+
+
+ {t("usage.cacheWrite", "写入")}:{" "} + {cacheWriteTokens.toLocaleString()} +
+
+ {t("usage.cacheRead", "读取")}: {cacheReadTokens.toLocaleString()} +
diff --git a/src/components/usage/UsageTrendChart.tsx b/src/components/usage/UsageTrendChart.tsx index 7898de6dd..2fa2f8363 100644 --- a/src/components/usage/UsageTrendChart.tsx +++ b/src/components/usage/UsageTrendChart.tsx @@ -1,77 +1,158 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; -import { useUsageTrends } from '@/lib/query/usage'; +import { useTranslation } from "react-i18next"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { useUsageTrends } from "@/lib/query/usage"; interface UsageTrendChartProps { days: number; } -type MetricType = 'requests' | 'cost' | 'tokens'; - export function UsageTrendChart({ days }: UsageTrendChartProps) { const { t } = useTranslation(); - const [metric, setMetric] = useState('requests'); const { data: trends, isLoading } = useUsageTrends(days); if (isLoading) { - return
; + return
; } - const chartData = trends?.map((stat) => ({ - date: new Date(stat.date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }), - requests: stat.requestCount, - cost: parseFloat(stat.totalCost), - tokens: stat.totalTokens, - })) || []; + const isToday = days === 1; + const chartData = + trends?.map((stat) => { + const pointDate = new Date(stat.date); + return { + rawDate: stat.date, + label: isToday + ? pointDate.toLocaleTimeString("zh-CN", { hour: "2-digit" }) + : pointDate.toLocaleDateString("zh-CN", { + month: "2-digit", + day: "2-digit", + }), + hour: pointDate.getHours(), + inputTokens: stat.totalInputTokens, + outputTokens: stat.totalOutputTokens, + cacheCreationTokens: stat.totalCacheCreationTokens, + cacheReadTokens: stat.totalCacheReadTokens, + cost: parseFloat(stat.totalCost), + }; + }) || []; - const getYAxisLabel = () => { - switch (metric) { - case 'requests': return t('usage.requests', '请求数'); - case 'cost': return t('usage.cost', '成本 (USD)'); - case 'tokens': return t('usage.tokens', 'Tokens'); - } - }; + const hourlyData = (() => { + if (!isToday) return chartData; + const map = new Map(); + chartData.forEach((point) => { + map.set(point.hour ?? 0, point); + }); + return Array.from({ length: 24 }, (_, hour) => { + const bucket = map.get(hour); + return { + 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, + }; + }); + })(); + + const displayData = isToday ? hourlyData : chartData; + + const rangeLabel = isToday + ? t("usage.rangeToday", "今天 (按小时)") + : days === 7 + ? t("usage.rangeLast7Days", "过去 7 天") + : t("usage.rangeLast30Days", "过去 30 天"); return (
-

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

-
- - - -
+

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

+

{rangeLabel}

- - + + - - + + + - + + + + + +