mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
feat(db): add usage tracking schema and types
Add database tables for proxy request logs and model pricing. Extend Provider and error types to support usage statistics.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
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();
|
||||
const { data: stats, isLoading } = useModelStats();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats?.length === 0 ? (
|
||||
<TableRow>
|
||||
<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>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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';
|
||||
|
||||
export function PricingConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { data: pricing, isLoading } = useModelPricing();
|
||||
const [editingModel, setEditingModel] = useState<ModelPricing | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t('usage.modelPricing', '模型定价')}</h2>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{editingModel && (
|
||||
<PricingEditModal
|
||||
model={editingModel}
|
||||
onClose={() => setEditingModel(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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';
|
||||
|
||||
interface PricingEditModalProps {
|
||||
model: ModelPricing;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PricingEditModal({ model, onClose }: PricingEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const updatePricing = useUpdateModelPricing();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
displayName: model.displayName,
|
||||
inputCost: model.inputCostPerMillion,
|
||||
outputCost: model.outputCostPerMillion,
|
||||
cacheReadCost: model.cacheReadCostPerMillion,
|
||||
cacheCreationCost: model.cacheCreationCostPerMillion,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 验证非负数
|
||||
const values = [
|
||||
formData.inputCost,
|
||||
formData.outputCost,
|
||||
formData.cacheReadCost,
|
||||
formData.cacheCreationCost,
|
||||
];
|
||||
|
||||
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',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updatePricing.mutateAsync({
|
||||
modelId: model.modelId,
|
||||
displayName: formData.displayName,
|
||||
inputCost: formData.inputCost,
|
||||
outputCost: formData.outputCost,
|
||||
cacheReadCost: formData.cacheReadCost,
|
||||
cacheCreationCost: formData.cacheCreationCost,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t('common.success', '成功'),
|
||||
description: t('usage.pricingUpdated', '定价已更新'),
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('common.error', '错误'),
|
||||
description: String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('usage.editPricing', '编辑定价')} - {model.modelId}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">{t('usage.displayName', '显示名称')}</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={formData.displayName}
|
||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inputCost">
|
||||
{t('usage.inputCostPerMillion', '输入成本 (每百万 tokens, USD)')}
|
||||
</Label>
|
||||
<Input
|
||||
id="inputCost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.inputCost}
|
||||
onChange={(e) => setFormData({ ...formData, inputCost: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="outputCost">
|
||||
{t('usage.outputCostPerMillion', '输出成本 (每百万 tokens, USD)')}
|
||||
</Label>
|
||||
<Input
|
||||
id="outputCost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.outputCost}
|
||||
onChange={(e) => setFormData({ ...formData, outputCost: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cacheReadCost">
|
||||
{t('usage.cacheReadCostPerMillion', '缓存读取成本 (每百万 tokens, USD)')}
|
||||
</Label>
|
||||
<Input
|
||||
id="cacheReadCost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.cacheReadCost}
|
||||
onChange={(e) => setFormData({ ...formData, cacheReadCost: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cacheCreationCost">
|
||||
{t('usage.cacheCreationCostPerMillion', '缓存写入成本 (每百万 tokens, USD)')}
|
||||
</Label>
|
||||
<Input
|
||||
id="cacheCreationCost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.cacheCreationCost}
|
||||
onChange={(e) => setFormData({ ...formData, cacheCreationCost: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={updatePricing.isPending}>
|
||||
{updatePricing.isPending ? t('common.saving', '保存中...') : t('common.save', '保存')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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();
|
||||
const { data: stats, isLoading } = useProviderStats();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats?.length === 0 ? (
|
||||
<TableRow>
|
||||
<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>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useRequestDetail } from '@/lib/query/usage';
|
||||
|
||||
interface RequestDetailPanelProps {
|
||||
requestId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: request, isLoading } = useRequestDetail(requestId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<div className="h-[400px] animate-pulse rounded bg-gray-100" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('usage.requestDetail', '请求详情')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-center text-muted-foreground">
|
||||
{t('usage.requestNotFound', '请求未找到')}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<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>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<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="ml-2 font-mono text-xs text-muted-foreground">
|
||||
{request.providerId}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('usage.appType', '应用类型')}</dt>
|
||||
<dd>{request.appType}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
{request.statusCode}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Token 使用量 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<dd className="text-lg font-semibold">
|
||||
{(request.inputTokens + request.outputTokens).toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* 成本明细 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<dd className="text-lg font-semibold text-primary">
|
||||
${parseFloat(request.totalCostUsd).toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* 性能信息 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<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>
|
||||
<dd className="font-mono">{request.latencyMs}ms</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{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>
|
||||
<p className="text-sm text-red-700">{request.errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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';
|
||||
|
||||
export function RequestLogTable() {
|
||||
const { t } = useTranslation();
|
||||
const [filters] = useState<LogFilters>({});
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 20;
|
||||
|
||||
const { data: logs, isLoading } = useRequestLogs(filters, limit, page * limit);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
{t('usage.noData', '暂无数据')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs?.map((log) => (
|
||||
<TableRow key={log.requestId}>
|
||||
<TableCell>
|
||||
{new Date(log.createdAt * 1000).toLocaleString('zh-CN')}
|
||||
</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()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${parseFloat(log.totalCostUsd).toFixed(6)}
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
{log.statusCode}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{logs && logs.length >= limit && (
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
||||
>
|
||||
{t('common.previous', '上一页')}
|
||||
</button>
|
||||
<span className="px-3 py-1">
|
||||
{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', '下一页')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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 days = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90;
|
||||
|
||||
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>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
|
||||
className="rounded-md border px-3 py-1.5"
|
||||
>
|
||||
<option value="7d">{t('usage.last7days', '最近 7 天')}</option>
|
||||
<option value="30d">{t('usage.last30days', '最近 30 天')}</option>
|
||||
<option value="90d">{t('usage.last90days', '最近 90 天')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<UsageSummaryCards days={days} />
|
||||
|
||||
<Card className="p-6">
|
||||
<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>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="logs" className="mt-4">
|
||||
<RequestLogTable />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="providers" className="mt-4">
|
||||
<ProviderStatsTable />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models" className="mt-4">
|
||||
<ModelStatsTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useUsageSummary } from '@/lib/query/usage';
|
||||
|
||||
interface UsageSummaryCardsProps {
|
||||
days: number;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-gray-200" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-8 w-32 animate-pulse rounded bg-gray-200" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<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', '总成本')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
${parseFloat(summary?.totalCost || '0').toFixed(4)}
|
||||
</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()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('usage.successRate', '成功率')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{summary?.successRate.toFixed(1) || 0}%
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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';
|
||||
|
||||
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" />;
|
||||
}
|
||||
|
||||
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 getYAxisLabel = () => {
|
||||
switch (metric) {
|
||||
case 'requests': return t('usage.requests', '请求数');
|
||||
case 'cost': return t('usage.cost', '成本 (USD)');
|
||||
case 'tokens': return t('usage.tokens', 'Tokens');
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis label={{ value: getYAxisLabel(), angle: -90, position: 'insideLeft' }} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey={metric} stroke="#8884d8" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { usageApi } from '@/lib/api/usage';
|
||||
import type { LogFilters } from '@/types/usage';
|
||||
|
||||
// Query keys
|
||||
export const usageKeys = {
|
||||
all: ['usage'] as const,
|
||||
summary: (startDate?: number, endDate?: number) =>
|
||||
[...usageKeys.all, 'summary', startDate, endDate] as const,
|
||||
trends: (days: number) => [...usageKeys.all, 'trends', days] as const,
|
||||
providerStats: () => [...usageKeys.all, 'provider-stats'] as const,
|
||||
modelStats: () => [...usageKeys.all, 'model-stats'] as const,
|
||||
logs: (filters: LogFilters, limit: number, offset: number) =>
|
||||
[...usageKeys.all, 'logs', filters, limit, offset] as const,
|
||||
detail: (requestId: string) =>
|
||||
[...usageKeys.all, 'detail', requestId] as const,
|
||||
pricing: () => [...usageKeys.all, 'pricing'] as const,
|
||||
limits: (providerId: string, appType: string) =>
|
||||
[...usageKeys.all, 'limits', providerId, appType] as const,
|
||||
};
|
||||
|
||||
// Hooks
|
||||
export function useUsageSummary(startDate?: number, endDate?: number) {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.summary(startDate, endDate),
|
||||
queryFn: () => usageApi.getUsageSummary(startDate, endDate),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsageTrends(days: number) {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.trends(days),
|
||||
queryFn: () => usageApi.getUsageTrends(days),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProviderStats() {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.providerStats(),
|
||||
queryFn: usageApi.getProviderStats,
|
||||
});
|
||||
}
|
||||
|
||||
export function useModelStats() {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.modelStats(),
|
||||
queryFn: usageApi.getModelStats,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestLogs(
|
||||
filters: LogFilters,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.logs(filters, limit, offset),
|
||||
queryFn: () => usageApi.getRequestLogs(filters, limit, offset),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestDetail(requestId: string) {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.detail(requestId),
|
||||
queryFn: () => usageApi.getRequestDetail(requestId),
|
||||
enabled: !!requestId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useModelPricing() {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.pricing(),
|
||||
queryFn: usageApi.getModelPricing,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProviderLimits(providerId: string, appType: string) {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.limits(providerId, appType),
|
||||
queryFn: () => usageApi.checkProviderLimits(providerId, appType),
|
||||
enabled: !!providerId && !!appType,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateModelPricing() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: {
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
inputCost: string;
|
||||
outputCost: string;
|
||||
cacheReadCost: string;
|
||||
cacheCreationCost: string;
|
||||
}) =>
|
||||
usageApi.updateModelPricing(
|
||||
params.modelId,
|
||||
params.displayName,
|
||||
params.inputCost,
|
||||
params.outputCost,
|
||||
params.cacheReadCost,
|
||||
params.cacheCreationCost
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.pricing() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// 使用统计相关类型定义
|
||||
|
||||
export interface TokenUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
}
|
||||
|
||||
export interface RequestLog {
|
||||
requestId: string;
|
||||
providerId: string;
|
||||
appType: string;
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
inputCostUsd: string;
|
||||
outputCostUsd: string;
|
||||
cacheReadCostUsd: string;
|
||||
cacheCreationCostUsd: string;
|
||||
totalCostUsd: string;
|
||||
latencyMs: number;
|
||||
statusCode: number;
|
||||
errorMessage?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ModelPricing {
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
inputCostPerMillion: string;
|
||||
outputCostPerMillion: string;
|
||||
cacheReadCostPerMillion: string;
|
||||
cacheCreationCostPerMillion: string;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
totalRequests: number;
|
||||
totalCost: string;
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export interface DailyStats {
|
||||
date: string;
|
||||
requestCount: number;
|
||||
totalCost: string;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface ProviderStats {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
requestCount: number;
|
||||
totalTokens: number;
|
||||
totalCost: string;
|
||||
successRate: number;
|
||||
avgLatencyMs: number;
|
||||
}
|
||||
|
||||
export interface ModelStats {
|
||||
model: string;
|
||||
requestCount: number;
|
||||
totalTokens: number;
|
||||
totalCost: string;
|
||||
avgCostPerRequest: string;
|
||||
}
|
||||
|
||||
export interface LogFilters {
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
statusCode?: number;
|
||||
startDate?: number;
|
||||
endDate?: number;
|
||||
}
|
||||
|
||||
export interface ProviderLimitStatus {
|
||||
providerId: string;
|
||||
dailyUsage: string;
|
||||
dailyLimit?: string;
|
||||
dailyExceeded: boolean;
|
||||
monthlyUsage: string;
|
||||
monthlyLimit?: string;
|
||||
monthlyExceeded: boolean;
|
||||
}
|
||||
|
||||
export type TimeRange = '7d' | '30d' | '90d';
|
||||
|
||||
export interface StatsFilters {
|
||||
timeRange: TimeRange;
|
||||
providerId?: string;
|
||||
appType?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user