mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
feat(usage): add usage stats export/import actions
This commit is contained in:
@@ -534,6 +534,11 @@
|
|||||||
"by_hour": "By Hour",
|
"by_hour": "By Hour",
|
||||||
"by_day": "By Day",
|
"by_day": "By Day",
|
||||||
"refresh": "Refresh",
|
"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_1": "Line 1",
|
||||||
"chart_line_label_2": "Line 2",
|
"chart_line_label_2": "Line 2",
|
||||||
"chart_line_label_3": "Line 3",
|
"chart_line_label_3": "Line 3",
|
||||||
|
|||||||
@@ -534,6 +534,11 @@
|
|||||||
"by_hour": "按小时",
|
"by_hour": "按小时",
|
||||||
"by_day": "按天",
|
"by_day": "按天",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
|
"export": "导出数据",
|
||||||
|
"import": "导入数据",
|
||||||
|
"export_success": "使用统计已导出",
|
||||||
|
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
|
||||||
|
"import_invalid": "导入文件格式不正确",
|
||||||
"chart_line_label_1": "曲线 1",
|
"chart_line_label_1": "曲线 1",
|
||||||
"chart_line_label_2": "曲线 2",
|
"chart_line_label_2": "曲线 2",
|
||||||
"chart_line_label_3": "曲线 3",
|
"chart_line_label_3": "曲线 3",
|
||||||
|
|||||||
@@ -18,6 +18,13 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -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 { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
@@ -19,7 +19,7 @@ import { Input } from '@/components/ui/Input';
|
|||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||||
import { usageApi } from '@/services/api/usage';
|
import { usageApi } from '@/services/api/usage';
|
||||||
import {
|
import {
|
||||||
formatTokensInMillions,
|
formatTokensInMillions,
|
||||||
@@ -63,6 +63,7 @@ interface UsagePayload {
|
|||||||
|
|
||||||
export function UsagePage() {
|
export function UsagePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const isDark = resolvedTheme === 'dark';
|
const isDark = resolvedTheme === 'dark';
|
||||||
@@ -71,6 +72,9 @@ export function UsagePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
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
|
// Model price form state
|
||||||
const [selectedModel, setSelectedModel] = useState('');
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
@@ -107,6 +111,77 @@ export function UsagePage() {
|
|||||||
setModelPrices(loadModelPrices());
|
setModelPrices(loadModelPrices());
|
||||||
}, [loadUsage]);
|
}, [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
|
// Calculate derived data
|
||||||
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||||
const rateStats = usage
|
const rateStats = usage
|
||||||
@@ -527,14 +602,41 @@ export function UsagePage() {
|
|||||||
)}
|
)}
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
||||||
<Button
|
<div className={styles.headerActions}>
|
||||||
variant="secondary"
|
<Button
|
||||||
size="sm"
|
variant="secondary"
|
||||||
onClick={loadUsage}
|
size="sm"
|
||||||
disabled={loading}
|
onClick={handleExport}
|
||||||
>
|
loading={exporting}
|
||||||
{loading ? t('common.loading') : t('usage_stats.refresh')}
|
disabled={loading || importing}
|
||||||
</Button>
|
>
|
||||||
|
{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 || 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>
|
</div>
|
||||||
|
|
||||||
{error && <div className={styles.errorBox}>{error}</div>}
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
|||||||
@@ -7,12 +7,38 @@ import { computeKeyStats, KeyStats } from '@/utils/usage';
|
|||||||
|
|
||||||
const USAGE_TIMEOUT_MS = 60 * 1000;
|
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 = {
|
export const usageApi = {
|
||||||
/**
|
/**
|
||||||
* 获取使用统计原始数据
|
* 获取使用统计原始数据
|
||||||
*/
|
*/
|
||||||
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
|
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 数据
|
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user