diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 9a524a5..dd8dcd1 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -1,4 +1,4 @@ -import { ReactNode, SVGProps, useEffect, useMemo, useState } from 'react'; +import { ReactNode, SVGProps, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { NavLink, Outlet } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; @@ -142,9 +142,40 @@ export function MainLayout() { const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [checkingVersion, setCheckingVersion] = useState(false); + const [brandExpanded, setBrandExpanded] = useState(true); + const brandCollapseTimer = useRef | null>(null); const isLocal = useMemo(() => isLocalhost(window.location.hostname), []); + const fullBrandName = 'CLI Proxy API Management Center'; + const abbrBrandName = t('title.abbr'); + + // 5秒后自动收起品牌名称 + useEffect(() => { + brandCollapseTimer.current = setTimeout(() => { + setBrandExpanded(false); + }, 5000); + + return () => { + if (brandCollapseTimer.current) { + clearTimeout(brandCollapseTimer.current); + } + }; + }, []); + + const handleBrandClick = useCallback(() => { + if (!brandExpanded) { + setBrandExpanded(true); + // 点击展开后,5秒后再次收起 + if (brandCollapseTimer.current) { + clearTimeout(brandCollapseTimer.current); + } + brandCollapseTimer.current = setTimeout(() => { + setBrandExpanded(false); + }, 5000); + } + }, [brandExpanded]); + useEffect(() => { fetchConfig().catch(() => { // ignore initial failure; login flow会提示 @@ -213,38 +244,27 @@ export function MainLayout() { return (
- - -
-
-
- -
+
+
+ +
+ {fullBrandName} + {abbrBrandName} +
+ +
{t( connectionStatus === 'connected' @@ -258,7 +278,7 @@ export function MainLayout() {
-
+
@@ -274,19 +294,39 @@ export function MainLayout() {
-
- -
+
+ -
- - {t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')} - - - {t('footer.build_date')}:{' '} - {serverBuildDate ? new Date(serverBuildDate).toLocaleString(i18n.language) : t('system_info.version_unknown')} - -
+
+
+ +
+ +
+ + {t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')} + + + {t('footer.build_date')}:{' '} + {serverBuildDate ? new Date(serverBuildDate).toLocaleString(i18n.language) : t('system_info.version_unknown')} + +
+
); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 00ae33b..593777f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -441,8 +441,12 @@ "total_tokens": "Total Tokens", "cached_tokens": "Cached Tokens", "reasoning_tokens": "Reasoning Tokens", - "rpm_30m": "RPM (last 30 min)", - "tpm_30m": "TPM (last 30 min)", + "rpm_30m": "RPM", + "tpm_30m": "TPM", + "rate_30m": "Rate (last 30 min)", + "model_name": "Model Name", + "model_price_settings": "Model Pricing Settings", + "saved_prices": "Saved Prices", "requests_trend": "Request Trends", "tokens_trend": "Token Usage Trends", "api_details": "API Details", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 1108cac..da0c137 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -439,10 +439,14 @@ "success_requests": "成功请求", "failed_requests": "失败请求", "total_tokens": "总Token数", - "cached_tokens": "缓存 Token 数", - "reasoning_tokens": "思考 Token 数", - "rpm_30m": "RPM(近30分钟)", - "tpm_30m": "TPM(近30分钟)", + "cached_tokens": "缓存 Tokens", + "reasoning_tokens": "思考 Tokens", + "rpm_30m": "RPM", + "tpm_30m": "TPM", + "rate_30m": "近30分钟速率", + "model_name": "模型名称", + "model_price_settings": "模型价格设置", + "saved_prices": "已保存的价格", "requests_trend": "请求趋势", "tokens_trend": "Token 使用趋势", "api_details": "API 详细统计", diff --git a/src/pages/UsagePage.module.scss b/src/pages/UsagePage.module.scss index 64203ab..072dc9a 100644 --- a/src/pages/UsagePage.module.scss +++ b/src/pages/UsagePage.module.scss @@ -1,24 +1,53 @@ +@use '../styles/variables' as *; +@use '../styles/mixins' as *; + .container { width: 100%; + display: flex; + flex-direction: column; + gap: $spacing-lg; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: $spacing-md; } .pageTitle { font-size: 28px; font-weight: 700; color: var(--text-primary); - margin: 0 0 $spacing-xl 0; + margin: 0; } -.content { - display: flex; - flex-direction: column; - gap: $spacing-xl; +.errorBox { + padding: $spacing-md; + background-color: rgba(239, 68, 68, 0.1); + border: 1px solid var(--danger-color); + border-radius: $radius-md; + color: var(--danger-color); + font-size: 14px; } +.hint { + color: var(--text-secondary); + font-size: 14px; + text-align: center; + padding: $spacing-lg; +} + +// Stats Grid .statsGrid { display: grid; gap: $spacing-md; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + grid-template-columns: repeat(4, 1fr); + + @include tablet { + grid-template-columns: repeat(2, 1fr); + } @include mobile { grid-template-columns: 1fr; @@ -30,20 +59,345 @@ background-color: var(--bg-primary); border-radius: $radius-lg; border: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: $spacing-sm; +} - .label { - font-size: 14px; - color: var(--text-secondary); - margin-bottom: $spacing-sm; - } +.statHeader { + display: flex; + align-items: center; + gap: $spacing-sm; +} - .value { - font-size: 24px; - font-weight: 700; - color: var(--text-primary); +.statIcon { + font-size: 18px; +} + +.statLabel { + font-size: 14px; + color: var(--text-secondary); + font-weight: 500; +} + +.statValue { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.2; +} + +.statValueRow { + display: flex; + gap: $spacing-lg; +} + +.statValueSmall { + display: flex; + flex-direction: column; + gap: 2px; +} + +.statValueLabel { + font-size: 12px; + color: var(--text-secondary); +} + +.statValueNum { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); +} + +.statMeta { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; +} + +.statSuccess { + color: var(--success-color, #22c55e); +} + +.statFailure { + color: var(--danger-color, #ef4444); +} + +.statNeutral { + color: var(--text-secondary); +} + +.statHint { + color: var(--text-tertiary); + font-style: italic; +} + +// API List +.apiList { + display: flex; + flex-direction: column; + gap: $spacing-sm; +} + +.apiItem { + background-color: var(--bg-secondary); + border-radius: $radius-md; + border: 1px solid var(--border-color); + overflow: hidden; +} + +.apiHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: $spacing-md; + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--bg-hover); } } +.apiInfo { + display: flex; + flex-direction: column; + gap: $spacing-xs; + min-width: 0; + flex: 1; +} + +.apiEndpoint { + font-weight: 600; + color: var(--text-primary); + font-size: 14px; + word-break: break-all; +} + +.apiStats { + display: flex; + flex-wrap: wrap; + gap: $spacing-xs; +} + +.apiBadge { + font-size: 12px; + color: var(--text-secondary); + background-color: var(--bg-tertiary); + padding: 2px 8px; + border-radius: $radius-sm; +} + +.expandIcon { + color: var(--text-secondary); + font-size: 12px; + margin-left: $spacing-sm; +} + +.apiModels { + padding: $spacing-md; + padding-top: 0; + display: flex; + flex-direction: column; + gap: $spacing-xs; + border-top: 1px solid var(--border-color); + margin-top: 0; + padding-top: $spacing-md; +} + +.modelRow { + display: grid; + grid-template-columns: 1fr auto auto; + gap: $spacing-md; + padding: $spacing-xs $spacing-sm; + background-color: var(--bg-primary); + border-radius: $radius-sm; + font-size: 13px; + + @include mobile { + grid-template-columns: 1fr; + gap: $spacing-xs; + } +} + +.modelName { + color: var(--text-primary); + font-weight: 500; + word-break: break-all; +} + +.modelStat { + color: var(--text-secondary); + text-align: right; + + @include mobile { + text-align: left; + } +} + +// Table +.tableWrapper { + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + + th, td { + padding: $spacing-sm $spacing-md; + text-align: left; + border-bottom: 1px solid var(--border-color); + } + + th { + font-weight: 600; + color: var(--text-secondary); + background-color: var(--bg-secondary); + white-space: nowrap; + } + + td { + color: var(--text-primary); + } + + tbody tr:hover { + background-color: var(--bg-hover); + } +} + +.modelCell { + font-weight: 500; + max-width: 300px; + word-break: break-all; +} + +// Pricing Section +.pricingSection { + display: flex; + flex-direction: column; + gap: $spacing-lg; +} + +.priceForm { + padding: $spacing-md; + background-color: var(--bg-secondary); + border-radius: $radius-md; + border: 1px solid var(--border-color); +} + +.formRow { + display: flex; + gap: $spacing-md; + align-items: flex-end; + flex-wrap: wrap; + + @include mobile { + flex-direction: column; + align-items: stretch; + } +} + +.formField { + display: flex; + flex-direction: column; + gap: $spacing-xs; + flex: 1; + min-width: 150px; + + label { + font-size: 12px; + color: var(--text-secondary); + font-weight: 500; + } +} + +.select { + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background-color: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; + cursor: pointer; + + &:focus { + outline: none; + border-color: var(--primary-color); + } +} + +.pricesList { + display: flex; + flex-direction: column; + gap: $spacing-md; +} + +.pricesTitle { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.pricesGrid { + display: flex; + flex-direction: column; + gap: $spacing-sm; +} + +.priceItem { + display: flex; + justify-content: space-between; + align-items: center; + padding: $spacing-md; + background-color: var(--bg-secondary); + border-radius: $radius-md; + border: 1px solid var(--border-color); + gap: $spacing-md; + + @include mobile { + flex-direction: column; + align-items: flex-start; + } +} + +.priceInfo { + display: flex; + flex-direction: column; + gap: $spacing-xs; + min-width: 0; + flex: 1; +} + +.priceModel { + font-weight: 600; + color: var(--text-primary); + font-size: 14px; + word-break: break-all; +} + +.priceMeta { + display: flex; + gap: $spacing-md; + font-size: 12px; + color: var(--text-secondary); + + @include mobile { + flex-direction: column; + gap: $spacing-xs; + } +} + +.priceActions { + display: flex; + gap: $spacing-xs; + flex-shrink: 0; +} + +// Chart Section (for future use) .chartSection { display: flex; flex-direction: column; @@ -68,4 +422,68 @@ border-radius: $radius-lg; border: 1px solid var(--border-color); min-height: 300px; + height: 300px; +} + +.periodButtons { + display: flex; + gap: $spacing-xs; +} + +// Chart Line Controls +.chartLineControls { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: $spacing-lg; + flex-wrap: wrap; + + @include mobile { + flex-direction: column; + } +} + +.chartLineList { + display: flex; + flex-direction: column; + gap: $spacing-sm; + flex: 1; +} + +.chartLineItem { + display: flex; + align-items: center; + gap: $spacing-sm; + flex-wrap: wrap; + + @include mobile { + flex-direction: column; + align-items: flex-start; + } +} + +.chartLineLabel { + font-size: 14px; + color: var(--text-secondary); + min-width: 60px; +} + +.chartLineActions { + display: flex; + align-items: center; + gap: $spacing-sm; + flex-shrink: 0; +} + +.chartLineCount { + font-size: 14px; + color: var(--text-secondary); + font-weight: 500; +} + +.chartLineHint { + font-size: 12px; + color: var(--text-tertiary); + margin: $spacing-sm 0 0 0; + font-style: italic; } diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index 2c57c68..db47176 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -1,19 +1,56 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler +} from 'chart.js'; +import { Line } from 'react-chartjs-2'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; import { usageApi } from '@/services/api/usage'; -import type { KeyStats } from '@/utils/usage'; +import { + formatTokensInMillions, + formatPerMinuteValue, + formatUsd, + calculateTokenBreakdown, + calculateRecentPerMinuteRates, + calculateTotalCost, + getModelNamesFromUsage, + getApiStats, + getModelStats, + loadModelPrices, + saveModelPrices, + buildChartData, + type ModelPrice +} from '@/utils/usage'; +import styles from './UsagePage.module.scss'; + +// Register Chart.js components +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler +); interface UsagePayload { total_requests?: number; - success_requests?: number; - failed_requests?: number; + success_count?: number; + failure_count?: number; total_tokens?: number; - cached_tokens?: number; - reasoning_tokens?: number; - rpm_30m?: number; - tpm_30m?: number; + apis?: Record; [key: string]: any; } @@ -23,86 +60,547 @@ export function UsagePage() { const [usage, setUsage] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const [keyStats, setKeyStats] = useState(null); + const [modelPrices, setModelPrices] = useState>({}); - const loadUsage = async () => { + // Model price form state + const [selectedModel, setSelectedModel] = useState(''); + const [promptPrice, setPromptPrice] = useState(''); + const [completionPrice, setCompletionPrice] = useState(''); + + // Expanded sections + const [expandedApis, setExpandedApis] = useState>(new Set()); + + // Chart state + const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day'); + const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day'); + const [chartLines, setChartLines] = useState(['all']); + const MAX_CHART_LINES = 9; + + const loadUsage = useCallback(async () => { setLoading(true); setError(''); try { const data = await usageApi.getUsage(); const payload = data?.usage ?? data; setUsage(payload); - const stats = await usageApi.getKeyStats(payload); - setKeyStats(stats); } catch (err: any) { setError(err?.message || t('usage_stats.loading_error')); } finally { setLoading(false); } - }; + }, [t]); useEffect(() => { loadUsage(); - }, []); + setModelPrices(loadModelPrices()); + }, [loadUsage]); - const overviewItems = [ - { label: t('usage_stats.total_requests'), value: usage?.total_requests }, - { label: t('usage_stats.success_requests'), value: usage?.success_requests }, - { label: t('usage_stats.failed_requests'), value: usage?.failed_requests }, - { label: t('usage_stats.total_tokens'), value: usage?.total_tokens }, - { label: t('usage_stats.cached_tokens'), value: usage?.cached_tokens }, - { label: t('usage_stats.reasoning_tokens'), value: usage?.reasoning_tokens }, - { label: t('usage_stats.rpm_30m'), value: usage?.rpm_30m }, - { label: t('usage_stats.tpm_30m'), value: usage?.tpm_30m } - ]; + // Calculate derived data + const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 }; + const rateStats = usage ? calculateRecentPerMinuteRates(30, usage) : { rpm: 0, tpm: 0 }; + const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0; + const modelNames = usage ? getModelNamesFromUsage(usage) : []; + const apiStats = usage ? getApiStats(usage, modelPrices) : []; + const modelStats = usage ? getModelStats(usage, modelPrices) : []; + const hasPrices = Object.keys(modelPrices).length > 0; + + // Build chart data + const requestsChartData = useMemo(() => { + if (!usage) return { labels: [], datasets: [] }; + return buildChartData(usage, requestsPeriod, 'requests', chartLines); + }, [usage, requestsPeriod, chartLines]); + + const tokensChartData = useMemo(() => { + if (!usage) return { labels: [], datasets: [] }; + return buildChartData(usage, tokensPeriod, 'tokens', chartLines); + }, [usage, tokensPeriod, chartLines]); + + const chartOptions = { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index' as const, + intersect: false + }, + plugins: { + legend: { + display: true, + position: 'top' as const, + align: 'start' as const, + labels: { + usePointStyle: true + } + } + }, + scales: { + y: { + beginAtZero: true + } + }, + elements: { + line: { + tension: 0.35, + borderWidth: 2 + }, + point: { + borderWidth: 2, + radius: 4 + } + } + }; + + // Chart line management + const handleAddChartLine = () => { + if (chartLines.length >= MAX_CHART_LINES) return; + const unusedModel = modelNames.find(m => !chartLines.includes(m)); + if (unusedModel) { + setChartLines([...chartLines, unusedModel]); + } else { + setChartLines([...chartLines, 'all']); + } + }; + + const handleRemoveChartLine = (index: number) => { + if (chartLines.length <= 1) return; + const newLines = [...chartLines]; + newLines.splice(index, 1); + setChartLines(newLines); + }; + + const handleChartLineChange = (index: number, value: string) => { + const newLines = [...chartLines]; + newLines[index] = value; + setChartLines(newLines); + }; + + // Handle model price save + const handleSavePrice = () => { + if (!selectedModel) return; + const prompt = parseFloat(promptPrice) || 0; + const completion = parseFloat(completionPrice) || 0; + const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion } }; + setModelPrices(newPrices); + saveModelPrices(newPrices); + setSelectedModel(''); + setPromptPrice(''); + setCompletionPrice(''); + }; + + // Handle model price delete + const handleDeletePrice = (model: string) => { + const newPrices = { ...modelPrices }; + delete newPrices[model]; + setModelPrices(newPrices); + saveModelPrices(newPrices); + }; + + // Handle edit price + const handleEditPrice = (model: string) => { + const price = modelPrices[model]; + setSelectedModel(model); + setPromptPrice(price?.prompt?.toString() || ''); + setCompletionPrice(price?.completion?.toString() || ''); + }; + + // Toggle API expansion + const toggleApiExpand = (endpoint: string) => { + setExpandedApis(prev => { + const newSet = new Set(prev); + if (newSet.has(endpoint)) { + newSet.delete(endpoint); + } else { + newSet.add(endpoint); + } + return newSet; + }); + }; return ( -
- - {t('usage_stats.refresh')} - - } - > - {error &&
{error}
} - {loading ? ( -
{t('common.loading')}
- ) : ( -
- {overviewItems.map((item) => ( -
-
{item.label}
-
{item.value ?? '-'}
+
+
+

{t('usage_stats.title')}

+ +
+ + {error &&
{error}
} + + {/* Stats Overview Cards */} +
+ {/* Total Requests Card */} +
+
+ 📊 + {t('usage_stats.total_requests')} +
+
+ {loading ? '-' : (usage?.total_requests ?? 0).toLocaleString()} +
+
+ + ✓ {t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)} + + + ✗ {t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)} + +
+
+ + {/* Total Tokens Card */} +
+
+ 🔤 + {t('usage_stats.total_tokens')} +
+
+ {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)} +
+
+ + 💾 {t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)} + + + 🧠 {t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)} + +
+
+ + {/* RPM/TPM Card */} +
+
+ + {t('usage_stats.rate_30m')} +
+
+
+ {t('usage_stats.rpm_30m')} + {loading ? '-' : formatPerMinuteValue(rateStats.rpm)} +
+
+ {t('usage_stats.tpm_30m')} + {loading ? '-' : formatPerMinuteValue(rateStats.tpm)} +
+
+
+ + {/* Total Cost Card */} +
+
+ 💰 + {t('usage_stats.total_cost')} +
+
+ {loading ? '-' : hasPrices ? formatUsd(totalCost) : '--'} +
+ {!hasPrices && ( +
+ {t('usage_stats.cost_need_price')} +
+ )} +
+
+ + {/* Chart Line Selection */} + +
+
+ {chartLines.map((line, index) => ( +
+ + {t(`usage_stats.chart_line_label_${index + 1}`)}: + + + {chartLines.length > 1 && ( + + )}
))}
+
+ + {chartLines.length}/{MAX_CHART_LINES} + + +
+
+

{t('usage_stats.chart_line_hint')}

+
+ + {/* Requests Chart */} + + + +
+ } + > + {loading ? ( +
{t('common.loading')}
+ ) : requestsChartData.labels.length > 0 ? ( +
+ +
+ ) : ( +
{t('usage_stats.no_data')}
)} + {/* Tokens Chart */} + + + +
+ } + > + {loading ? ( +
{t('common.loading')}
+ ) : tokensChartData.labels.length > 0 ? ( +
+ +
+ ) : ( +
{t('usage_stats.no_data')}
+ )} + + + {/* API Key Statistics */} {loading ? ( -
{t('common.loading')}
- ) : keyStats && Object.keys(keyStats.bySource || {}).length ? ( -
-
-
{t('usage_stats.api_endpoint')}
-
{t('stats.success')}
-
{t('stats.failure')}
-
- {Object.entries(keyStats.bySource || {}).map(([source, bucket]) => ( -
-
{source}
-
{bucket.success}
-
{bucket.failure}
+
{t('common.loading')}
+ ) : apiStats.length > 0 ? ( +
+ {apiStats.map((api) => ( +
+
toggleApiExpand(api.endpoint)} + > +
+ {api.endpoint} +
+ + {t('usage_stats.requests_count')}: {api.totalRequests} + + + Tokens: {formatTokensInMillions(api.totalTokens)} + + {hasPrices && api.totalCost > 0 && ( + + {t('usage_stats.total_cost')}: {formatUsd(api.totalCost)} + + )} +
+
+ + {expandedApis.has(api.endpoint) ? '▼' : '▶'} + +
+ {expandedApis.has(api.endpoint) && ( +
+ {Object.entries(api.models).map(([model, stats]) => ( +
+ {model} + {stats.requests} {t('usage_stats.requests_count')} + {formatTokensInMillions(stats.tokens)} +
+ ))} +
+ )}
))}
) : ( -
{t('usage_stats.no_data')}
+
{t('usage_stats.no_data')}
)} + + {/* Model Statistics */} + + {loading ? ( +
{t('common.loading')}
+ ) : modelStats.length > 0 ? ( +
+ + + + + + + {hasPrices && } + + + + {modelStats.map((stat) => ( + + + + + {hasPrices && } + + ))} + +
{t('usage_stats.model_name')}{t('usage_stats.requests_count')}{t('usage_stats.tokens_count')}{t('usage_stats.total_cost')}
{stat.model}{stat.requests.toLocaleString()}{formatTokensInMillions(stat.tokens)}{stat.cost > 0 ? formatUsd(stat.cost) : '--'}
+
+ ) : ( +
{t('usage_stats.no_data')}
+ )} +
+ + {/* Model Pricing Configuration */} + +
+ {/* Price Form */} +
+
+
+ + +
+
+ + setPromptPrice(e.target.value)} + placeholder="0.00" + step="0.0001" + /> +
+
+ + setCompletionPrice(e.target.value)} + placeholder="0.00" + step="0.0001" + /> +
+ +
+
+ + {/* Saved Prices List */} +
+

{t('usage_stats.saved_prices')}

+ {Object.keys(modelPrices).length > 0 ? ( +
+ {Object.entries(modelPrices).map(([model, price]) => ( +
+
+ {model} +
+ {t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M + {t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M +
+
+
+ + +
+
+ ))} +
+ ) : ( +
{t('usage_stats.model_price_empty')}
+ )} +
+
+
); } diff --git a/src/styles/layout.scss b/src/styles/layout.scss index 0f32694..db705c8 100644 --- a/src/styles/layout.scss +++ b/src/styles/layout.scss @@ -2,32 +2,208 @@ .app-shell { display: flex; + flex-direction: column; min-height: 100vh; background: var(--bg-secondary); color: var(--text-primary); } +.main-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-md; + padding: $spacing-md $spacing-lg; + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 10; + width: 100%; + + @media (max-width: $breakpoint-mobile) { + padding: $spacing-sm $spacing-md; + gap: $spacing-sm; + } + + .left { + display: flex; + align-items: center; + gap: $spacing-sm; + min-width: 0; + } + + .sidebar-toggle-header { + padding: $spacing-xs $spacing-sm; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: $radius-md; + color: var(--text-secondary); + cursor: pointer; + font-size: 16px; + font-weight: bold; + transition: background $transition-fast, color $transition-fast; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex-shrink: 0; + + &:hover { + background: var(--bg-tertiary, var(--border-color)); + color: var(--text-primary); + } + + @media (max-width: $breakpoint-mobile) { + display: none; + } + } + + .brand-header { + font-weight: 800; + font-size: 18px; + color: var(--text-primary); + margin-right: $spacing-md; + position: relative; + cursor: pointer; + overflow: hidden; + white-space: nowrap; + flex-shrink: 0; + + .brand-full, + .brand-abbr { + display: inline-block; + transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); + } + + .brand-full { + max-width: 320px; + opacity: 1; + transform: translateX(0); + } + + .brand-abbr { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%) translateX(20px); + opacity: 0; + pointer-events: none; + } + + &.collapsed { + .brand-full { + max-width: 0; + opacity: 0; + transform: translateX(-20px); + } + + .brand-abbr { + position: relative; + transform: translateY(0) translateX(0); + opacity: 1; + pointer-events: auto; + } + } + + &:hover { + color: var(--primary-color); + } + + // 移动端:禁用动画,只显示缩写 + @media (max-width: $breakpoint-mobile) { + margin-right: $spacing-sm; + cursor: default; + + .brand-full, + .brand-abbr { + transition: none; + } + + .brand-full { + display: none; + } + + .brand-abbr { + position: relative; + transform: none; + opacity: 1; + pointer-events: auto; + } + + &:hover { + color: var(--text-primary); + } + } + } + + .mobile-menu-btn { + display: none; + flex-shrink: 0; + + @media (max-width: $breakpoint-mobile) { + display: flex; + } + } + + .header-actions { + display: flex; + align-items: center; + gap: $spacing-xs; + flex-shrink: 0; + + @media (max-width: $breakpoint-mobile) { + gap: 2px; + } + } + + .connection { + display: flex; + align-items: center; + gap: $spacing-sm; + color: var(--text-secondary); + min-width: 0; + + .base { + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + @media (max-width: $breakpoint-mobile) { + display: none; + } + } +} + +.main-body { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + .sidebar { width: 240px; background: var(--bg-primary); border-right: 1px solid var(--border-color); - padding: $spacing-lg $spacing-md; + padding: $spacing-md; display: flex; flex-direction: column; gap: $spacing-lg; transition: width $transition-normal, transform $transition-normal; + overflow-y: auto; + flex-shrink: 0; position: sticky; top: 0; - height: 100vh; - overflow-y: auto; + height: calc(100vh - 57px); // 减去顶栏高度 &.collapsed { width: 60px; - padding: $spacing-lg $spacing-sm; - - .brand { - text-align: center; - } + padding: $spacing-md $spacing-sm; .nav-item { justify-content: center; @@ -35,12 +211,6 @@ } } - .brand { - font-weight: 800; - font-size: 18px; - color: var(--text-primary); - } - .nav-section { display: flex; flex-direction: column; @@ -92,29 +262,11 @@ } } - .sidebar-toggle { - margin-top: auto; - padding: $spacing-sm; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: $radius-md; - color: var(--text-secondary); - cursor: pointer; - font-size: 16px; - font-weight: bold; - transition: background $transition-fast, color $transition-fast; - - &:hover { - background: var(--bg-tertiary, var(--border-color)); - color: var(--text-primary); - } - } - @media (max-width: $breakpoint-mobile) { position: fixed; z-index: $z-dropdown; left: 0; - top: 0; + top: 56px; bottom: 0; transform: translateX(-100%); box-shadow: $shadow-lg; @@ -130,37 +282,8 @@ display: flex; flex-direction: column; min-width: 0; -} - -.main-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: $spacing-md; - padding: $spacing-md $spacing-lg; - background: var(--bg-primary); - border-bottom: 1px solid var(--border-color); - position: sticky; - top: 0; - z-index: 5; - - .left { - display: flex; - align-items: center; - gap: $spacing-sm; - } - - .connection { - display: flex; - align-items: center; - gap: $spacing-sm; - color: var(--text-secondary); - - .base { - font-weight: 600; - color: var(--text-primary); - } - } + overflow-y: auto; + height: calc(100vh - 57px); // 减去顶栏高度 } .main-content { @@ -169,6 +292,10 @@ display: flex; flex-direction: column; gap: $spacing-lg; + + @media (max-width: $breakpoint-mobile) { + padding: $spacing-md; + } } .footer { diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 35aa1b5..2b08643 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -15,6 +15,50 @@ export interface KeyStats { byAuthIndex: Record; } +export interface TokenBreakdown { + cachedTokens: number; + reasoningTokens: number; +} + +export interface RateStats { + rpm: number; + tpm: number; + windowMinutes: number; + requestCount: number; + tokenCount: number; +} + +export interface ModelPrice { + prompt: number; + completion: number; +} + +export interface UsageDetail { + timestamp: string; + source: string; + auth_index: number; + tokens: { + input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + cached_tokens: number; + total_tokens: number; + }; + failed: boolean; + __modelName?: string; +} + +export interface ApiStats { + endpoint: string; + totalRequests: number; + totalTokens: number; + totalCost: number; + models: Record; +} + +const TOKENS_PER_PRICE_UNIT = 1_000_000; +const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2'; + const normalizeAuthIndex = (value: any) => { if (typeof value === 'number' && Number.isFinite(value)) { return value.toString(); @@ -70,6 +114,580 @@ export function maskUsageSensitiveValue(value: unknown, masker: (val: string) => return masked; } +/** + * 格式化 tokens 为百万单位 + */ +export function formatTokensInMillions(value: number): string { + const num = Number(value); + if (!Number.isFinite(num)) { + return '0.00M'; + } + return `${(num / 1_000_000).toFixed(2)}M`; +} + +/** + * 格式化每分钟数值 + */ +export function formatPerMinuteValue(value: number): string { + const num = Number(value); + if (!Number.isFinite(num)) { + return '0.00'; + } + const abs = Math.abs(num); + if (abs >= 1000) { + return Math.round(num).toLocaleString(); + } + if (abs >= 100) { + return num.toFixed(0); + } + if (abs >= 10) { + return num.toFixed(1); + } + return num.toFixed(2); +} + +/** + * 格式化紧凑数字 + */ +export function formatCompactNumber(value: number): string { + const num = Number(value); + if (!Number.isFinite(num)) { + return '0'; + } + const abs = Math.abs(num); + if (abs >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M`; + } + if (abs >= 1_000) { + return `${(num / 1_000).toFixed(1)}K`; + } + return abs >= 1 ? num.toFixed(0) : num.toFixed(2); +} + +/** + * 格式化美元 + */ +export function formatUsd(value: number): string { + const num = Number(value); + if (!Number.isFinite(num)) { + return '$0.00'; + } + const fixed = num.toFixed(2); + const parts = Number(fixed).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + return `$${parts}`; +} + +/** + * 从使用数据中收集所有请求明细 + */ +export function collectUsageDetails(usageData: any): UsageDetail[] { + if (!usageData) { + return []; + } + const apis = usageData.apis || {}; + const details: UsageDetail[] = []; + Object.values(apis as Record).forEach((apiEntry) => { + const models = apiEntry?.models || {}; + Object.entries(models as Record).forEach(([modelName, modelEntry]) => { + const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : []; + modelDetails.forEach((detail: any) => { + if (detail && detail.timestamp) { + details.push({ + ...detail, + __modelName: modelName + }); + } + }); + }); + }); + return details; +} + +/** + * 从单条明细提取总 tokens + */ +export function extractTotalTokens(detail: any): number { + const tokens = detail?.tokens || {}; + if (typeof tokens.total_tokens === 'number') { + return tokens.total_tokens; + } + const tokenKeys = ['input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_tokens']; + return tokenKeys.reduce((sum, key) => { + const value = tokens[key]; + return sum + (typeof value === 'number' ? value : 0); + }, 0); +} + +/** + * 计算 token 分类统计 + */ +export function calculateTokenBreakdown(usageData: any): TokenBreakdown { + const details = collectUsageDetails(usageData); + if (!details.length) { + return { cachedTokens: 0, reasoningTokens: 0 }; + } + + let cachedTokens = 0; + let reasoningTokens = 0; + + details.forEach(detail => { + const tokens = detail?.tokens || {}; + if (typeof tokens.cached_tokens === 'number') { + cachedTokens += tokens.cached_tokens; + } + if (typeof tokens.reasoning_tokens === 'number') { + reasoningTokens += tokens.reasoning_tokens; + } + }); + + return { cachedTokens, reasoningTokens }; +} + +/** + * 计算最近 N 分钟的 RPM/TPM + */ +export function calculateRecentPerMinuteRates(windowMinutes: number = 30, usageData: any): RateStats { + const details = collectUsageDetails(usageData); + const effectiveWindow = Number.isFinite(windowMinutes) && windowMinutes > 0 ? windowMinutes : 30; + + if (!details.length) { + return { rpm: 0, tpm: 0, windowMinutes: effectiveWindow, requestCount: 0, tokenCount: 0 }; + } + + const now = Date.now(); + const windowStart = now - effectiveWindow * 60 * 1000; + let requestCount = 0; + let tokenCount = 0; + + details.forEach(detail => { + const timestamp = Date.parse(detail.timestamp); + if (Number.isNaN(timestamp) || timestamp < windowStart) { + return; + } + requestCount += 1; + tokenCount += extractTotalTokens(detail); + }); + + const denominator = effectiveWindow > 0 ? effectiveWindow : 1; + return { + rpm: requestCount / denominator, + tpm: tokenCount / denominator, + windowMinutes: effectiveWindow, + requestCount, + tokenCount + }; +} + +/** + * 从使用数据获取模型名称列表 + */ +export function getModelNamesFromUsage(usageData: any): string[] { + if (!usageData) { + return []; + } + const apis = usageData.apis || {}; + const names = new Set(); + Object.values(apis as Record).forEach(apiEntry => { + const models = apiEntry?.models || {}; + Object.keys(models).forEach(modelName => { + if (modelName) { + names.add(modelName); + } + }); + }); + return Array.from(names).sort((a, b) => a.localeCompare(b)); +} + +/** + * 计算成本数据 + */ +export function calculateCost(detail: any, modelPrices: Record): number { + const modelName = detail.__modelName || ''; + const price = modelPrices[modelName]; + if (!price) { + return 0; + } + const tokens = detail?.tokens || {}; + const promptTokens = Number(tokens.input_tokens) || 0; + const completionTokens = Number(tokens.output_tokens) || 0; + const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0); + const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0); + const total = promptCost + completionCost; + return Number.isFinite(total) && total > 0 ? total : 0; +} + +/** + * 计算总成本 + */ +export function calculateTotalCost(usageData: any, modelPrices: Record): number { + const details = collectUsageDetails(usageData); + if (!details.length || !Object.keys(modelPrices).length) { + return 0; + } + return details.reduce((sum, detail) => sum + calculateCost(detail, modelPrices), 0); +} + +/** + * 从 localStorage 加载模型价格 + */ +export function loadModelPrices(): Record { + try { + if (typeof localStorage === 'undefined') { + return {}; + } + const raw = localStorage.getItem(MODEL_PRICE_STORAGE_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return {}; + } + const normalized: Record = {}; + Object.entries(parsed).forEach(([model, price]: [string, any]) => { + if (!model) return; + const prompt = Number(price?.prompt); + const completion = Number(price?.completion); + if (!Number.isFinite(prompt) && !Number.isFinite(completion)) { + return; + } + normalized[model] = { + prompt: Number.isFinite(prompt) && prompt >= 0 ? prompt : 0, + completion: Number.isFinite(completion) && completion >= 0 ? completion : 0 + }; + }); + return normalized; + } catch { + return {}; + } +} + +/** + * 保存模型价格到 localStorage + */ +export function saveModelPrices(prices: Record): void { + try { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.setItem(MODEL_PRICE_STORAGE_KEY, JSON.stringify(prices)); + } catch { + console.warn('保存模型价格失败'); + } +} + +/** + * 获取 API 统计数据 + */ +export function getApiStats(usageData: any, modelPrices: Record): ApiStats[] { + if (!usageData?.apis) { + return []; + } + const apis = usageData.apis; + const result: ApiStats[] = []; + + Object.entries(apis as Record).forEach(([endpoint, apiData]) => { + const models: Record = {}; + let totalCost = 0; + + const modelsData = apiData?.models || {}; + Object.entries(modelsData as Record).forEach(([modelName, modelData]) => { + models[modelName] = { + requests: modelData.total_requests || 0, + tokens: modelData.total_tokens || 0 + }; + + const price = modelPrices[modelName]; + if (price) { + const details = Array.isArray(modelData.details) ? modelData.details : []; + details.forEach((detail: any) => { + const tokens = detail?.tokens || {}; + const promptTokens = Number(tokens.input_tokens) || 0; + const completionTokens = Number(tokens.output_tokens) || 0; + const cost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0) + + (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0); + if (Number.isFinite(cost) && cost > 0) { + totalCost += cost; + } + }); + } + }); + + result.push({ + endpoint: maskUsageSensitiveValue(endpoint) || endpoint, + totalRequests: apiData.total_requests || 0, + totalTokens: apiData.total_tokens || 0, + totalCost, + models + }); + }); + + return result; +} + +/** + * 获取模型统计数据 + */ +export function getModelStats(usageData: any, modelPrices: Record): Array<{ + model: string; + requests: number; + tokens: number; + cost: number; +}> { + if (!usageData?.apis) { + return []; + } + + const modelMap = new Map(); + + Object.values(usageData.apis as Record).forEach(apiData => { + const models = apiData?.models || {}; + Object.entries(models as Record).forEach(([modelName, modelData]) => { + const existing = modelMap.get(modelName) || { requests: 0, tokens: 0, cost: 0 }; + existing.requests += modelData.total_requests || 0; + existing.tokens += modelData.total_tokens || 0; + + const price = modelPrices[modelName]; + if (price) { + const details = Array.isArray(modelData.details) ? modelData.details : []; + details.forEach((detail: any) => { + const tokens = detail?.tokens || {}; + const promptTokens = Number(tokens.input_tokens) || 0; + const completionTokens = Number(tokens.output_tokens) || 0; + const cost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0) + + (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0); + if (Number.isFinite(cost) && cost > 0) { + existing.cost += cost; + } + }); + } + modelMap.set(modelName, existing); + }); + }); + + return Array.from(modelMap.entries()) + .map(([model, stats]) => ({ model, ...stats })) + .sort((a, b) => b.requests - a.requests); +} + +/** + * 格式化小时标签 + */ +export function formatHourLabel(date: Date): string { + if (!(date instanceof Date)) { + return ''; + } + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + const hour = date.getHours().toString().padStart(2, '0'); + return `${month}-${day} ${hour}:00`; +} + +/** + * 格式化日期标签 + */ +export function formatDayLabel(date: Date): string { + if (!(date instanceof Date)) { + return ''; + } + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * 构建小时级别的数据序列 + */ +export function buildHourlySeriesByModel(usageData: any, metric: 'requests' | 'tokens' = 'requests'): { + labels: string[]; + dataByModel: Map; + hasData: boolean; +} { + const hourMs = 60 * 60 * 1000; + const now = new Date(); + const currentHour = new Date(now); + currentHour.setMinutes(0, 0, 0); + + const earliestBucket = new Date(currentHour); + earliestBucket.setHours(earliestBucket.getHours() - 23); + const earliestTime = earliestBucket.getTime(); + + const labels: string[] = []; + for (let i = 0; i < 24; i++) { + const bucketStart = earliestTime + i * hourMs; + labels.push(formatHourLabel(new Date(bucketStart))); + } + + const details = collectUsageDetails(usageData); + const dataByModel = new Map(); + let hasData = false; + + if (!details.length) { + return { labels, dataByModel, hasData }; + } + + details.forEach(detail => { + const timestamp = Date.parse(detail.timestamp); + if (Number.isNaN(timestamp)) { + return; + } + + const normalized = new Date(timestamp); + normalized.setMinutes(0, 0, 0); + const bucketStart = normalized.getTime(); + const lastBucketTime = earliestTime + (labels.length - 1) * hourMs; + if (bucketStart < earliestTime || bucketStart > lastBucketTime) { + return; + } + + const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs); + if (bucketIndex < 0 || bucketIndex >= labels.length) { + return; + } + + const modelName = detail.__modelName || 'Unknown'; + if (!dataByModel.has(modelName)) { + dataByModel.set(modelName, new Array(labels.length).fill(0)); + } + + const bucketValues = dataByModel.get(modelName)!; + if (metric === 'tokens') { + bucketValues[bucketIndex] += extractTotalTokens(detail); + } else { + bucketValues[bucketIndex] += 1; + } + hasData = true; + }); + + return { labels, dataByModel, hasData }; +} + +/** + * 构建日级别的数据序列 + */ +export function buildDailySeriesByModel(usageData: any, metric: 'requests' | 'tokens' = 'requests'): { + labels: string[]; + dataByModel: Map; + hasData: boolean; +} { + const details = collectUsageDetails(usageData); + const valuesByModel = new Map>(); + const labelsSet = new Set(); + let hasData = false; + + if (!details.length) { + return { labels: [], dataByModel: new Map(), hasData }; + } + + details.forEach(detail => { + const timestamp = Date.parse(detail.timestamp); + if (Number.isNaN(timestamp)) { + return; + } + const dayLabel = formatDayLabel(new Date(timestamp)); + if (!dayLabel) { + return; + } + + const modelName = detail.__modelName || 'Unknown'; + if (!valuesByModel.has(modelName)) { + valuesByModel.set(modelName, new Map()); + } + const modelDayMap = valuesByModel.get(modelName)!; + const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1; + modelDayMap.set(dayLabel, (modelDayMap.get(dayLabel) || 0) + increment); + labelsSet.add(dayLabel); + hasData = true; + }); + + const labels = Array.from(labelsSet).sort(); + const dataByModel = new Map(); + valuesByModel.forEach((dayMap, modelName) => { + const series = labels.map(label => dayMap.get(label) || 0); + dataByModel.set(modelName, series); + }); + + return { labels, dataByModel, hasData }; +} + +export interface ChartDataset { + label: string; + data: number[]; + borderColor: string; + backgroundColor: string; + fill: boolean; + tension: number; +} + +export interface ChartData { + labels: string[]; + datasets: ChartDataset[]; +} + +const CHART_COLORS = [ + { borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.15)' }, + { borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.15)' }, + { borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.15)' }, + { borderColor: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.15)' }, + { borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.15)' }, + { borderColor: '#06b6d4', backgroundColor: 'rgba(6, 182, 212, 0.15)' }, + { borderColor: '#ec4899', backgroundColor: 'rgba(236, 72, 153, 0.15)' }, + { borderColor: '#84cc16', backgroundColor: 'rgba(132, 204, 22, 0.15)' }, + { borderColor: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.15)' }, +]; + +/** + * 构建图表数据 + */ +export function buildChartData( + usageData: any, + period: 'hour' | 'day' = 'day', + metric: 'requests' | 'tokens' = 'requests', + selectedModels: string[] = [] +): ChartData { + const baseSeries = period === 'hour' + ? buildHourlySeriesByModel(usageData, metric) + : buildDailySeriesByModel(usageData, metric); + + const { labels, dataByModel } = baseSeries; + + // Build "All" series as sum of all models + const getAllSeries = (): number[] => { + const summed = new Array(labels.length).fill(0); + dataByModel.forEach(values => { + values.forEach((value, idx) => { + summed[idx] = (summed[idx] || 0) + value; + }); + }); + return summed; + }; + + // Determine which models to show + const modelsToShow = selectedModels.length > 0 ? selectedModels : ['all']; + + const datasets: ChartDataset[] = modelsToShow.map((model, index) => { + const isAll = model === 'all'; + const data = isAll ? getAllSeries() : (dataByModel.get(model) || new Array(labels.length).fill(0)); + const colorIndex = index % CHART_COLORS.length; + const style = CHART_COLORS[colorIndex]; + + return { + label: isAll ? 'All Models' : model, + data, + borderColor: style.borderColor, + backgroundColor: style.backgroundColor, + fill: false, + tension: 0.35 + }; + }); + + return { labels, datasets }; +} + /** * 依据 usage 数据计算密钥使用统计 */