feat(usage): add usage stats export/import actions

This commit is contained in:
Supra4E8C
2025-12-27 00:49:41 +08:00
parent 2dd3f233d3
commit 66c6073bbc
5 changed files with 155 additions and 10 deletions

View File

@@ -534,6 +534,11 @@
"by_hour": "By Hour",
"by_day": "By Day",
"refresh": "Refresh",
"export": "Export",
"import": "Import",
"export_success": "Usage export downloaded",
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
"import_invalid": "Invalid usage export file",
"chart_line_label_1": "Line 1",
"chart_line_label_2": "Line 2",
"chart_line_label_3": "Line 3",

View File

@@ -534,6 +534,11 @@
"by_hour": "按小时",
"by_day": "按天",
"refresh": "刷新",
"export": "导出数据",
"import": "导入数据",
"export_success": "使用统计已导出",
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
"import_invalid": "导入文件格式不正确",
"chart_line_label_1": "曲线 1",
"chart_line_label_2": "曲线 2",
"chart_line_label_3": "曲线 3",

View File

@@ -18,6 +18,13 @@
gap: 10px;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pageTitle {
font-size: 28px;
font-weight: 700;

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo, type CSSProperties } from 'react';
import { useEffect, useState, useCallback, useMemo, useRef, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
@@ -19,7 +19,7 @@ import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useThemeStore } from '@/stores';
import { useNotificationStore, useThemeStore } from '@/stores';
import { usageApi } from '@/services/api/usage';
import {
formatTokensInMillions,
@@ -63,6 +63,7 @@ interface UsagePayload {
export function UsagePage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const isMobile = useMediaQuery('(max-width: 768px)');
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
@@ -71,6 +72,9 @@ export function UsagePage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const importInputRef = useRef<HTMLInputElement | null>(null);
// Model price form state
const [selectedModel, setSelectedModel] = useState('');
@@ -107,6 +111,77 @@ export function UsagePage() {
setModelPrices(loadModelPrices());
}, [loadUsage]);
const handleExport = async () => {
setExporting(true);
try {
const data = await usageApi.exportUsage();
const exportedAt =
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
const safeTimestamp = Number.isNaN(exportedAt.getTime())
? new Date().toISOString()
: exportedAt.toISOString();
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
showNotification(t('usage_stats.export_success'), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setExporting(false);
}
};
const handleImportClick = () => {
importInputRef.current?.click();
};
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setImporting(true);
try {
const text = await file.text();
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
showNotification(t('usage_stats.import_invalid'), 'error');
return;
}
const result = await usageApi.importUsage(payload);
showNotification(
t('usage_stats.import_success', {
added: result?.added ?? 0,
skipped: result?.skipped ?? 0,
total: result?.total_requests ?? 0,
failed: result?.failed_requests ?? 0
}),
'success'
);
await loadUsage();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setImporting(false);
}
};
// Calculate derived data
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
const rateStats = usage
@@ -527,14 +602,41 @@ export function UsagePage() {
)}
<div className={styles.header}>
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={handleExport}
loading={exporting}
disabled={loading || importing}
>
{t('usage_stats.export')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleImportClick}
loading={importing}
disabled={loading || exporting}
>
{t('usage_stats.import')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={loadUsage}
disabled={loading}
disabled={loading || exporting || importing}
>
{loading ? t('common.loading') : t('usage_stats.refresh')}
</Button>
<input
ref={importInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleImportChange}
/>
</div>
</div>
{error && <div className={styles.errorBox}>{error}</div>}

View File

@@ -7,12 +7,38 @@ import { computeKeyStats, KeyStats } from '@/utils/usage';
const USAGE_TIMEOUT_MS = 60 * 1000;
export interface UsageExportPayload {
version?: number;
exported_at?: string;
usage?: Record<string, unknown>;
[key: string]: unknown;
}
export interface UsageImportResponse {
added?: number;
skipped?: number;
total_requests?: number;
failed_requests?: number;
[key: string]: unknown;
}
export const usageApi = {
/**
* 获取使用统计原始数据
*/
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
/**
* 导出使用统计快照
*/
exportUsage: () => apiClient.get<UsageExportPayload>('/usage/export', { timeout: USAGE_TIMEOUT_MS }),
/**
* 导入使用统计快照
*/
importUsage: (payload: unknown) =>
apiClient.post<UsageImportResponse>('/usage/import', payload, { timeout: USAGE_TIMEOUT_MS }),
/**
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
*/