diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 91fab85..510e454 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 65c088b..e8b449b 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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", diff --git a/src/pages/UsagePage.module.scss b/src/pages/UsagePage.module.scss index 1901c7e..962913a 100644 --- a/src/pages/UsagePage.module.scss +++ b/src/pages/UsagePage.module.scss @@ -18,6 +18,13 @@ gap: 10px; } +.headerActions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .pageTitle { font-size: 28px; font-weight: 700; diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index 2c0c729..83148af 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -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>({}); + const [exporting, setExporting] = useState(false); + const [importing, setImporting] = useState(false); + const importInputRef = useRef(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) => { + 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() { )}

{t('usage_stats.title')}

- +
+ + + + +
{error &&
{error}
} diff --git a/src/services/api/usage.ts b/src/services/api/usage.ts index ba0ebd8..029ce09 100644 --- a/src/services/api/usage.ts +++ b/src/services/api/usage.ts @@ -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; + [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('/usage/export', { timeout: USAGE_TIMEOUT_MS }), + + /** + * 导入使用统计快照 + */ + importUsage: (payload: unknown) => + apiClient.post('/usage/import', payload, { timeout: USAGE_TIMEOUT_MS }), + /** * 计算密钥成功/失败统计,必要时会先获取 usage 数据 */