mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
feat(ui): add usage dashboard components
Add UsageDashboard with summary cards, trend chart, and data tables. Implement model pricing configuration panel. Add request log viewer with filtering and detail panel.
This commit is contained in:
@@ -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 (
|
||||
<div className="rounded-md border">
|
||||
<div className="rounded-md bg-card/60 shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('usage.model', '模型')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.requests', '请求数')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.tokens', 'Tokens')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.totalCost', '总成本')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.avgCost', '平均成本')}</TableHead>
|
||||
<TableHead>{t("usage.model", "模型")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.requests", "请求数")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.tokens", "Tokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.totalCost", "总成本")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.avgCost", "平均成本")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
{t('usage.noData', '暂无数据')}
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("usage.noData", "暂无数据")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
stats?.map((stat) => (
|
||||
<TableRow key={stat.model}>
|
||||
<TableCell className="font-mono text-sm">{stat.model}</TableCell>
|
||||
<TableCell className="text-right">{stat.requestCount.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{stat.totalTokens.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">${parseFloat(stat.totalCost).toFixed(4)}</TableCell>
|
||||
<TableCell className="text-right">${parseFloat(stat.avgCostPerRequest).toFixed(6)}</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{stat.model}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat.requestCount.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat.totalTokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${parseFloat(stat.totalCost).toFixed(4)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${parseFloat(stat.avgCostPerRequest).toFixed(6)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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<ModelPricing | null>(null);
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(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 <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
||||
return (
|
||||
<Card className="border-none bg-transparent p-0 shadow-none">
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<CardTitle className="text-base">
|
||||
{t("usage.modelPricing", "模型定价")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="border-none bg-transparent p-0 shadow-none">
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<CardTitle className="text-base">
|
||||
{t("usage.modelPricing", "模型定价")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("usage.loadPricingError", "加载定价数据失败")}:{" "}
|
||||
{String(error)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t('usage.modelPricing', '模型定价')}</h2>
|
||||
</div>
|
||||
<Card className="border-none bg-transparent shadow-none">
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
{t("usage.modelPricing", "模型定价")}
|
||||
{pricing && pricing.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
({pricing.length})
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
{!isExpanded && (
|
||||
<CardDescription className="mt-1">
|
||||
{t(
|
||||
"usage.modelPricingDesc",
|
||||
"配置各模型的 Token 成本(每百万 tokens 的 USD 价格,支持 * 与 ? 通配)",
|
||||
)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddNew();
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("common.add", "新增")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('usage.model', '模型')}</TableHead>
|
||||
<TableHead>{t('usage.displayName', '显示名称')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.inputCost', '输入成本')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.outputCost', '输出成本')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.cacheReadCost', '缓存读取')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.cacheWriteCost', '缓存写入')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions', '操作')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pricing?.map((model) => (
|
||||
<TableRow key={model.modelId}>
|
||||
<TableCell className="font-mono text-sm">{model.modelId}</TableCell>
|
||||
<TableCell>{model.displayName}</TableCell>
|
||||
<TableCell className="text-right">${model.inputCostPerMillion}</TableCell>
|
||||
<TableCell className="text-right">${model.outputCostPerMillion}</TableCell>
|
||||
<TableCell className="text-right">${model.cacheReadCostPerMillion}</TableCell>
|
||||
<TableCell className="text-right">${model.cacheCreationCostPerMillion}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingModel(model)}
|
||||
>
|
||||
{t('common.edit', '编辑')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
{!pricing || pricing.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"usage.noPricingData",
|
||||
'暂无定价数据。点击"新增"添加模型定价配置。',
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="rounded-md bg-card/60 shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("usage.model", "模型")}</TableHead>
|
||||
<TableHead>{t("usage.displayName", "显示名称")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.inputCost", "输入成本")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.outputCost", "输出成本")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheReadCost", "缓存读取")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheWriteCost", "缓存写入")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions", "操作")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pricing.map((model) => (
|
||||
<TableRow key={model.modelId}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{model.modelId}
|
||||
</TableCell>
|
||||
<TableCell>{model.displayName}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.inputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.outputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheReadCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheCreationCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setIsAddingNew(false);
|
||||
setEditingModel(model);
|
||||
}}
|
||||
title={t("common.edit", "编辑")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteConfirm(model.modelId)}
|
||||
title={t("common.delete", "删除")}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{editingModel && (
|
||||
<PricingEditModal
|
||||
model={editingModel}
|
||||
onClose={() => setEditingModel(null)}
|
||||
isNew={isAddingNew}
|
||||
onClose={() => {
|
||||
setEditingModel(null);
|
||||
setIsAddingNew(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={!!deleteConfirm}
|
||||
onOpenChange={() => setDeleteConfirm(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("usage.deleteConfirmTitle", "确认删除")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"usage.deleteConfirmDesc",
|
||||
"确定要删除此模型定价配置吗?此操作无法撤销。",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
{t("common.cancel", "取消")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteConfirm && handleDelete(deleteConfirm)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending
|
||||
? t("common.deleting", "删除中...")
|
||||
: t("common.delete", "删除")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('usage.editPricing', '编辑定价')} - {model.modelId}
|
||||
{isNew
|
||||
? t("usage.addPricing", "新增定价")
|
||||
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{isNew && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
|
||||
<Input
|
||||
id="modelId"
|
||||
value={formData.modelId}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, modelId: e.target.value })
|
||||
}
|
||||
placeholder="例如: claude-3-5-sonnet-20241022"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">{t('usage.displayName', '显示名称')}</Label>
|
||||
<Label htmlFor="displayName">
|
||||
{t("usage.displayName", "显示名称")}
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={formData.displayName}
|
||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, displayName: e.target.value })
|
||||
}
|
||||
placeholder="例如: Claude 3.5 Sonnet"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inputCost">
|
||||
{t('usage.inputCostPerMillion', '输入成本 (每百万 tokens, USD)')}
|
||||
{t("usage.inputCostPerMillion", "输入成本 (每百万 tokens, USD)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="inputCost"
|
||||
@@ -110,14 +136,16 @@ export function PricingEditModal({ model, onClose }: PricingEditModalProps) {
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.inputCost}
|
||||
onChange={(e) => setFormData({ ...formData, inputCost: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, inputCost: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="outputCost">
|
||||
{t('usage.outputCostPerMillion', '输出成本 (每百万 tokens, USD)')}
|
||||
{t("usage.outputCostPerMillion", "输出成本 (每百万 tokens, USD)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="outputCost"
|
||||
@@ -125,14 +153,19 @@ export function PricingEditModal({ model, onClose }: PricingEditModalProps) {
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.outputCost}
|
||||
onChange={(e) => setFormData({ ...formData, outputCost: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, outputCost: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cacheReadCost">
|
||||
{t('usage.cacheReadCostPerMillion', '缓存读取成本 (每百万 tokens, USD)')}
|
||||
{t(
|
||||
"usage.cacheReadCostPerMillion",
|
||||
"缓存读取成本 (每百万 tokens, USD)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="cacheReadCost"
|
||||
@@ -140,14 +173,19 @@ export function PricingEditModal({ model, onClose }: PricingEditModalProps) {
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.cacheReadCost}
|
||||
onChange={(e) => setFormData({ ...formData, cacheReadCost: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cacheReadCost: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cacheCreationCost">
|
||||
{t('usage.cacheCreationCostPerMillion', '缓存写入成本 (每百万 tokens, USD)')}
|
||||
{t(
|
||||
"usage.cacheCreationCostPerMillion",
|
||||
"缓存写入成本 (每百万 tokens, USD)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="cacheCreationCost"
|
||||
@@ -155,17 +193,23 @@ export function PricingEditModal({ model, onClose }: PricingEditModalProps) {
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.cacheCreationCost}
|
||||
onChange={(e) => setFormData({ ...formData, cacheCreationCost: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cacheCreationCost: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel', '取消')}
|
||||
{t("common.cancel", "取消")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={updatePricing.isPending}>
|
||||
{updatePricing.isPending ? t('common.saving', '保存中...') : t('common.save', '保存')}
|
||||
{updatePricing.isPending
|
||||
? t("common.saving", "保存中...")
|
||||
: isNew
|
||||
? t("common.add", "新增")
|
||||
: t("common.save", "保存")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-md border">
|
||||
<div className="rounded-md bg-card/60 shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('usage.provider', 'Provider')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.requests', '请求数')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.tokens', 'Tokens')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.cost', '成本')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.successRate', '成功率')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.avgLatency', '平均延迟')}</TableHead>
|
||||
<TableHead>{t("usage.provider", "Provider")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.requests", "请求数")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.tokens", "Tokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cost", "成本")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.successRate", "成功率")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.avgLatency", "平均延迟")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
{t('usage.noData', '暂无数据')}
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("usage.noData", "暂无数据")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
stats?.map((stat) => (
|
||||
<TableRow key={stat.providerId}>
|
||||
<TableCell className="font-medium">{stat.providerName}</TableCell>
|
||||
<TableCell className="text-right">{stat.requestCount.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{stat.totalTokens.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">${parseFloat(stat.totalCost).toFixed(4)}</TableCell>
|
||||
<TableCell className="text-right">{stat.successRate.toFixed(1)}%</TableCell>
|
||||
<TableCell className="text-right">{stat.avgLatencyMs}ms</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{stat.providerName}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat.requestCount.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat.totalTokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${parseFloat(stat.totalCost).toFixed(4)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat.successRate.toFixed(1)}%
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat.avgLatencyMs}ms
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('usage.requestDetail', '请求详情')}</DialogTitle>
|
||||
<DialogTitle>{t("usage.requestDetail", "请求详情")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-center text-muted-foreground">
|
||||
{t('usage.requestNotFound', '请求未找到')}
|
||||
{t("usage.requestNotFound", "请求未找到")}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -45,47 +48,65 @@ export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelPro
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('usage.requestDetail', '请求详情')}</DialogTitle>
|
||||
<DialogTitle>{t("usage.requestDetail", "请求详情")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 基本信息 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 font-semibold">{t('usage.basicInfo', '基本信息')}</h3>
|
||||
<h3 className="mb-3 font-semibold">
|
||||
{t("usage.basicInfo", "基本信息")}
|
||||
</h3>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.requestId', '请求ID')}</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.requestId", "请求ID")}
|
||||
</dt>
|
||||
<dd className="font-mono">{request.requestId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.time', '时间')}</dt>
|
||||
<dd>{new Date(request.createdAt * 1000).toLocaleString('zh-CN')}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.time", "时间")}
|
||||
</dt>
|
||||
<dd>
|
||||
{new Date(request.createdAt * 1000).toLocaleString("zh-CN")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.provider', '供应商')}</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.provider", "供应商")}
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
<span className="font-medium">{request.providerName || t('usage.unknownProvider', '未知')}</span>
|
||||
<span className="font-medium">
|
||||
{request.providerName || t("usage.unknownProvider", "未知")}
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs text-muted-foreground">
|
||||
{request.providerId}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.appType', '应用类型')}</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.appType", "应用类型")}
|
||||
</dt>
|
||||
<dd>{request.appType}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.model', '模型')}</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.model", "模型")}
|
||||
</dt>
|
||||
<dd className="font-mono">{request.model}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.status', '状态')}</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.status", "状态")}
|
||||
</dt>
|
||||
<dd>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs ${
|
||||
request.statusCode >= 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 使用量 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 font-semibold">{t('usage.tokenUsage', 'Token 使用量')}</h3>
|
||||
<h3 className="mb-3 font-semibold">
|
||||
{t("usage.tokenUsage", "Token 使用量")}
|
||||
</h3>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.inputTokens', '输入 Tokens')}</dt>
|
||||
<dd className="font-mono">{request.inputTokens.toLocaleString()}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.inputTokens", "输入 Tokens")}
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
{request.inputTokens.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.outputTokens', '输出 Tokens')}</dt>
|
||||
<dd className="font-mono">{request.outputTokens.toLocaleString()}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.outputTokens", "输出 Tokens")}
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
{request.outputTokens.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.cacheReadTokens', '缓存读取')}</dt>
|
||||
<dd className="font-mono">{request.cacheReadTokens.toLocaleString()}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.cacheReadTokens", "缓存读取")}
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
{request.cacheReadTokens.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.cacheCreationTokens', '缓存写入')}</dt>
|
||||
<dd className="font-mono">{request.cacheCreationTokens.toLocaleString()}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.cacheCreationTokens", "缓存写入")}
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
{request.cacheCreationTokens.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">{t('usage.totalTokens', '总计')}</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.totalTokens", "总计")}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold">
|
||||
{(request.inputTokens + request.outputTokens).toLocaleString()}
|
||||
{(
|
||||
request.inputTokens + request.outputTokens
|
||||
).toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -126,26 +169,46 @@ export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelPro
|
||||
|
||||
{/* 成本明细 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 font-semibold">{t('usage.costBreakdown', '成本明细')}</h3>
|
||||
<h3 className="mb-3 font-semibold">
|
||||
{t("usage.costBreakdown", "成本明细")}
|
||||
</h3>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.inputCost', '输入成本')}</dt>
|
||||
<dd className="font-mono">${parseFloat(request.inputCostUsd).toFixed(6)}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.inputCost", "输入成本")}
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
${parseFloat(request.inputCostUsd).toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.outputCost', '输出成本')}</dt>
|
||||
<dd className="font-mono">${parseFloat(request.outputCostUsd).toFixed(6)}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.outputCost", "输出成本")}
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
${parseFloat(request.outputCostUsd).toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.cacheReadCost', '缓存读取成本')}</dt>
|
||||
<dd className="font-mono">${parseFloat(request.cacheReadCostUsd).toFixed(6)}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.cacheReadCost", "缓存读取成本")}
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
${parseFloat(request.cacheReadCostUsd).toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.cacheCreationCost', '缓存写入成本')}</dt>
|
||||
<dd className="font-mono">${parseFloat(request.cacheCreationCostUsd).toFixed(6)}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.cacheCreationCost", "缓存写入成本")}
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
${parseFloat(request.cacheCreationCostUsd).toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2 border-t pt-3">
|
||||
<dt className="text-muted-foreground">{t('usage.totalCost', '总成本')}</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.totalCost", "总成本")}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-primary">
|
||||
${parseFloat(request.totalCostUsd).toFixed(6)}
|
||||
</dd>
|
||||
@@ -155,10 +218,14 @@ export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelPro
|
||||
|
||||
{/* 性能信息 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 font-semibold">{t('usage.performance', '性能信息')}</h3>
|
||||
<h3 className="mb-3 font-semibold">
|
||||
{t("usage.performance", "性能信息")}
|
||||
</h3>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.latency', '延迟')}</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.latency", "延迟")}
|
||||
</dt>
|
||||
<dd className="font-mono">{request.latencyMs}ms</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -167,7 +234,9 @@ export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelPro
|
||||
{/* 错误信息 */}
|
||||
{request.errorMessage && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<h3 className="mb-2 font-semibold text-red-800">{t('usage.errorMessage', '错误信息')}</h3>
|
||||
<h3 className="mb-2 font-semibold text-red-800">
|
||||
{t("usage.errorMessage", "错误信息")}
|
||||
</h3>
|
||||
<p className="text-sm text-red-700">{request.errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<LogFilters>({});
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectedRequestId, setSelectedRequestId] = useState<string | null>(
|
||||
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 <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
||||
@@ -18,47 +33,92 @@ export function RequestLogTable() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<div className="rounded-md bg-card/60 shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('usage.time', '时间')}</TableHead>
|
||||
<TableHead>{t('usage.provider', 'Provider')}</TableHead>
|
||||
<TableHead>{t('usage.model', '模型')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.tokens', 'Tokens')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.cost', '成本')}</TableHead>
|
||||
<TableHead className="text-right">{t('usage.latency', '延迟')}</TableHead>
|
||||
<TableHead>{t('usage.status', '状态')}</TableHead>
|
||||
<TableHead>{t("usage.time", "时间")}</TableHead>
|
||||
<TableHead>{t("usage.provider", "供应商")}</TableHead>
|
||||
<TableHead>{t("usage.billingModel", "计费模型")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.inputTokens", "输入")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.outputTokens", "输出")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheCreationTokens", "缓存写入")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheReadTokens", "缓存读取")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.totalCost", "成本")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.latency", "耗时")}
|
||||
</TableHead>
|
||||
<TableHead>{t("usage.status", "状态")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
{t('usage.noData', '暂无数据')}
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("usage.noData", "暂无数据")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs?.map((log) => (
|
||||
<TableRow key={log.requestId}>
|
||||
<TableRow
|
||||
key={log.requestId}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => setSelectedRequestId(log.requestId)}
|
||||
>
|
||||
<TableCell>
|
||||
{new Date(log.createdAt * 1000).toLocaleString('zh-CN')}
|
||||
{new Date(log.createdAt * 1000).toLocaleString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col text-sm leading-tight">
|
||||
<span className="font-medium">
|
||||
{log.providerName ||
|
||||
t("usage.unknownProvider", "未知供应商")}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{log.providerId}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{log.model}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{log.providerId}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{log.model}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{(log.inputTokens + log.outputTokens).toLocaleString()}
|
||||
{log.inputTokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{log.outputTokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{log.cacheCreationTokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{log.cacheReadTokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${parseFloat(log.totalCostUsd).toFixed(6)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{log.latencyMs}ms</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{log.latencyMs}ms
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs ${
|
||||
log.statusCode >= 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", "上一页")}
|
||||
</button>
|
||||
<span className="px-3 py-1">
|
||||
{t('common.page', '第')} {page + 1} {t('common.pageUnit', '页')}
|
||||
{t("common.page", "第")} {page + 1} {t("common.pageUnit", "页")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={logs.length < limit}
|
||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
||||
>
|
||||
{t('common.next', '下一页')}
|
||||
{t("common.next", "下一页")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRequestId && (
|
||||
<RequestDetailPanel
|
||||
requestId={selectedRequestId}
|
||||
onClose={() => setSelectedRequestId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TimeRange>('7d');
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||
|
||||
const days = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90;
|
||||
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">{t('usage.title', '使用统计')}</h1>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-end">
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
|
||||
className="rounded-md border px-3 py-1.5"
|
||||
className="rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="7d">{t('usage.last7days', '最近 7 天')}</option>
|
||||
<option value="30d">{t('usage.last30days', '最近 30 天')}</option>
|
||||
<option value="90d">{t('usage.last90days', '最近 90 天')}</option>
|
||||
<option value="1d">{t("usage.today", "今天")}</option>
|
||||
<option value="7d">{t("usage.last7days", "过去 7 天")}</option>
|
||||
<option value="30d">{t("usage.last30days", "过去 30 天")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<UsageSummaryCards days={days} />
|
||||
|
||||
<Card className="p-6">
|
||||
<Card className="border-none bg-transparent p-0 shadow-none">
|
||||
<UsageTrendChart days={days} />
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="logs" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="logs">{t('usage.requestLogs', '请求日志')}</TabsTrigger>
|
||||
<TabsTrigger value="providers">{t('usage.providerStats', 'Provider 统计')}</TabsTrigger>
|
||||
<TabsTrigger value="models">{t('usage.modelStats', '模型统计')}</TabsTrigger>
|
||||
<TabsTrigger value="logs">
|
||||
{t("usage.requestLogs", "请求日志")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="providers">
|
||||
{t("usage.providerStats", "Provider 统计")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models">
|
||||
{t("usage.modelStats", "模型统计")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="logs" className="mt-4">
|
||||
|
||||
@@ -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) {
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('usage.totalRequests', '总请求数')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{summary?.totalRequests || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('usage.totalCost', '总成本')}
|
||||
{t("usage.totalRequests", "总请求数")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
${parseFloat(summary?.totalCost || '0').toFixed(4)}
|
||||
{totalRequests.toLocaleString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -59,12 +56,33 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('usage.totalTokens', '总 Token 数')}
|
||||
{t("usage.totalCost", "总成本")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">${totalCost}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("usage.totalTokens", "总 Token 数")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{((summary?.totalInputTokens || 0) + (summary?.totalOutputTokens || 0)).toLocaleString()}
|
||||
{totalTokens.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
<div>
|
||||
{t("usage.inputTokens", "输入")}:{" "}
|
||||
{totalInputTokens.toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
{t("usage.outputTokens", "输出")}:{" "}
|
||||
{totalOutputTokens.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -72,12 +90,21 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('usage.successRate', '成功率')}
|
||||
{t("usage.cacheTokens", "缓存 Token")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{summary?.successRate.toFixed(1) || 0}%
|
||||
{totalCacheTokens.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
<div>
|
||||
{t("usage.cacheWrite", "写入")}:{" "}
|
||||
{cacheWriteTokens.toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
{t("usage.cacheRead", "读取")}: {cacheReadTokens.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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<MetricType>('requests');
|
||||
const { data: trends, isLoading } = useUsageTrends(days);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-[300px] animate-pulse rounded bg-gray-100" />;
|
||||
return <div className="h-[320px] animate-pulse rounded bg-gray-100" />;
|
||||
}
|
||||
|
||||
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<number, (typeof chartData)[number]>();
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{t('usage.trends', '使用趋势')}</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setMetric('requests')}
|
||||
className={`rounded px-3 py-1 text-sm ${
|
||||
metric === 'requests' ? 'bg-primary text-primary-foreground' : 'bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('usage.requests', '请求数')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMetric('cost')}
|
||||
className={`rounded px-3 py-1 text-sm ${
|
||||
metric === 'cost' ? 'bg-primary text-primary-foreground' : 'bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('usage.cost', '成本')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMetric('tokens')}
|
||||
className={`rounded px-3 py-1 text-sm ${
|
||||
metric === 'tokens' ? 'bg-primary text-primary-foreground' : 'bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('usage.tokens', 'Tokens')}
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("usage.trends", "使用趋势")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{rangeLabel}</p>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={displayData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis label={{ value: getYAxisLabel(), angle: -90, position: 'insideLeft' }} />
|
||||
<XAxis dataKey="label" />
|
||||
<YAxis
|
||||
yAxisId="tokens"
|
||||
label={{
|
||||
value: t("usage.tokensAxis", "Tokens"),
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="cost"
|
||||
orientation="right"
|
||||
label={{
|
||||
value: t("usage.costAxis", "成本 (USD)"),
|
||||
angle: 90,
|
||||
position: "insideRight",
|
||||
}}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey={metric} stroke="#8884d8" strokeWidth={2} />
|
||||
<Legend />
|
||||
<Line
|
||||
yAxisId="tokens"
|
||||
type="monotone"
|
||||
dataKey="inputTokens"
|
||||
name={t("usage.inputTokens", "输入 Tokens")}
|
||||
stroke="#2563eb"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive
|
||||
/>
|
||||
<Line
|
||||
yAxisId="tokens"
|
||||
type="monotone"
|
||||
dataKey="outputTokens"
|
||||
name={t("usage.outputTokens", "输出 Tokens")}
|
||||
stroke="#16a34a"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive
|
||||
/>
|
||||
<Line
|
||||
yAxisId="tokens"
|
||||
type="monotone"
|
||||
dataKey="cacheCreationTokens"
|
||||
name={t("usage.cacheCreationTokens", "缓存写入")}
|
||||
stroke="#f97316"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive
|
||||
/>
|
||||
<Line
|
||||
yAxisId="tokens"
|
||||
type="monotone"
|
||||
dataKey="cacheReadTokens"
|
||||
name={t("usage.cacheReadTokens", "缓存读取")}
|
||||
stroke="#a855f7"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive
|
||||
/>
|
||||
<Line
|
||||
yAxisId="cost"
|
||||
type="monotone"
|
||||
dataKey="cost"
|
||||
name={t("usage.cost", "成本")}
|
||||
stroke="#dc2626"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user