From 16f3442a117bbcf199982492709c08ccdb94509d Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Wed, 17 Dec 2025 00:35:02 +0800 Subject: [PATCH] fix: refactor usage --- src/pages/UsagePage.module.scss | 265 +++++++++------- src/pages/UsagePage.tsx | 547 ++++++++++++++++++-------------- src/utils/usage.ts | 51 ++- 3 files changed, 509 insertions(+), 354 deletions(-) diff --git a/src/pages/UsagePage.module.scss b/src/pages/UsagePage.module.scss index 348e21b..90ee585 100644 --- a/src/pages/UsagePage.module.scss +++ b/src/pages/UsagePage.module.scss @@ -5,21 +5,7 @@ width: 100%; display: flex; flex-direction: column; - gap: 16px; - - // 覆盖Card组件样式 (80%比例) - :global(.card) { - padding: 12px; - border-radius: $radius-md; - } - - :global(.card-header) { - margin-bottom: 10px; - - .title { - font-size: 14px; - } - } + gap: 20px; } .header { @@ -40,9 +26,9 @@ .errorBox { padding: 10px; background-color: rgba(239, 68, 68, 0.1); - border: 1px solid var(--danger-color); + border: 1px solid var(--error-color); border-radius: $radius-sm; - color: var(--danger-color); + color: var(--error-color); font-size: 12px; } @@ -53,15 +39,11 @@ padding: 16px; } -// Stats Grid - 五个卡片并排显示 (88%比例,放大10%) +// Stats Grid .statsGrid { display: grid; - gap: 8px; - grid-template-columns: repeat(5, minmax(0, 1fr)); - - @include tablet { - grid-template-columns: repeat(3, 1fr); - } + gap: 14px; + grid-template-columns: repeat(12, minmax(0, 1fr)); @include mobile { grid-template-columns: 1fr; @@ -69,22 +51,69 @@ } .statCard { - padding: 13px; - background-color: var(--bg-primary); - border-radius: $radius-md; + --accent: #3b82f6; + --accent-soft: rgba(59, 130, 246, 0.18); + --accent-border: rgba(59, 130, 246, 0.35); + + grid-column: span 4; + position: relative; + padding: 18px; + background: + radial-gradient(120% 140% at 12% 0%, var(--accent-soft) 0%, rgba(0, 0, 0, 0) 62%), + linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)), + var(--bg-primary); + border-radius: $radius-lg; border: 1px solid var(--border-color); display: flex; flex-direction: column; - gap: 5px; - min-height: 143px; - box-shadow: $shadow-sm; + gap: 10px; + min-height: 176px; + box-shadow: var(--shadow-lg); transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast; overflow: hidden; + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 3px; + width: 100%; + background: linear-gradient(90deg, var(--accent), rgba(0, 0, 0, 0)); + opacity: 0.95; + } + &:hover { transform: translateY(-2px); - box-shadow: $shadow-md; - border-color: rgba(37, 99, 235, 0.2); + border-color: var(--accent-border); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.22); + } + + @include tablet { + grid-column: span 6; + } + + @include mobile { + grid-column: auto; + min-height: 168px; + } +} + +.statCard:nth-child(-n + 2) { + grid-column: span 6; + + .statValue { + font-size: 32px; + } +} + +@include mobile { + .statCard:nth-child(-n + 2) { + grid-column: auto; + + .statValue { + font-size: 28px; + } } } @@ -92,7 +121,7 @@ display: flex; justify-content: space-between; align-items: flex-start; - gap: 5px; + gap: 12px; } .statLabelGroup { @@ -102,14 +131,16 @@ } .statIconBadge { - width: 29px; - height: 29px; - border-radius: $radius-sm; + width: 34px; + height: 34px; + border-radius: $radius-md; display: grid; place-items: center; color: #fff; font-size: 13px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08); + background: var(--accent); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 10px 22px rgba(0, 0, 0, 0.25); flex-shrink: 0; svg { @@ -128,18 +159,18 @@ } .statLabel { - font-size: 11px; - color: var(--text-secondary); - font-weight: 600; - letter-spacing: 0.01em; - text-transform: uppercase; + font-size: 12px; + color: var(--text-tertiary); + font-weight: 700; + letter-spacing: 0.02em; } .statValue { - font-size: 20px; - font-weight: 700; + font-size: 28px; + font-weight: 800; color: var(--text-primary); line-height: 1.2; + font-variant-numeric: tabular-nums; } .statValueRow { @@ -186,8 +217,8 @@ .statMetaRow { display: flex; flex-wrap: wrap; - gap: 4px; - font-size: 10px; + gap: 8px 10px; + font-size: 12px; color: var(--text-secondary); } @@ -198,8 +229,8 @@ } .statMetaDot { - width: 6px; - height: 6px; + width: 7px; + height: 7px; border-radius: 50%; background-color: var(--text-secondary); } @@ -210,18 +241,18 @@ .statTrend { margin-top: auto; - background: var(--bg-secondary, #f6f8fb); - border-radius: $radius-sm; - padding: 4px; - height: 44px; + background: var(--bg-tertiary); + border-radius: $radius-md; + padding: 8px; + height: 58px; border: 1px solid var(--border-color); } .statTrendPlaceholder { width: 100%; height: 100%; - background: var(--bg-tertiary, #eef1f6); - border-radius: $radius-sm; + background: var(--bg-secondary); + border-radius: $radius-md; } .sparkline { @@ -257,7 +288,7 @@ transition: background-color 0.15s ease; &:hover { - background-color: var(--bg-hover); + background-color: var(--bg-tertiary); } } @@ -272,7 +303,7 @@ .apiEndpoint { font-weight: 600; color: var(--text-primary); - font-size: 12px; + font-size: 13px; word-break: break-all; } @@ -283,16 +314,17 @@ } .apiBadge { - font-size: 10px; + font-size: 11px; color: var(--text-secondary); - background-color: var(--bg-tertiary); - padding: 1px 6px; - border-radius: $radius-sm; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + padding: 2px 8px; + border-radius: $radius-full; } .expandIcon { color: var(--text-secondary); - font-size: 10px; + font-size: 12px; margin-left: 6px; } @@ -311,10 +343,11 @@ display: grid; grid-template-columns: 1fr auto auto; gap: 10px; - padding: 3px 6px; + padding: 8px 10px; background-color: var(--bg-primary); - border-radius: $radius-sm; - font-size: 11px; + border: 1px solid var(--border-color); + border-radius: $radius-md; + font-size: 12px; @include mobile { grid-template-columns: 1fr; @@ -345,17 +378,17 @@ .table { width: 100%; border-collapse: collapse; - font-size: 11px; + font-size: 12px; th, td { - padding: 6px 10px; + padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border-color); } th { font-weight: 600; - color: var(--text-secondary); + color: var(--text-tertiary); background-color: var(--bg-secondary); white-space: nowrap; } @@ -365,7 +398,7 @@ } tbody tr:hover { - background-color: var(--bg-hover); + background-color: var(--bg-tertiary); } } @@ -535,13 +568,13 @@ } .chartWrapper { - padding: 12px; - background-color: var(--bg-primary); - border-radius: $radius-md; + padding: 14px; + background-color: var(--bg-secondary); + border-radius: $radius-lg; border: 1px solid var(--border-color); display: flex; flex-direction: column; - gap: 10px; + gap: 12px; } .chartLegend { @@ -566,7 +599,11 @@ gap: 6px; min-width: 0; max-width: 240px; - font-size: 11px; + padding: 4px 10px; + border-radius: $radius-full; + border: 1px solid var(--border-color); + background: var(--bg-primary); + font-size: 12px; color: var(--text-secondary); @include mobile { @@ -588,10 +625,10 @@ } .chartArea { - height: 240px; + height: 280px; @include mobile { - height: 280px; + height: 320px; } } @@ -616,63 +653,77 @@ .periodButtons { display: flex; - gap: 3px; + gap: 6px; } -// Chart Line Controls (80%比例) -.chartLineControls { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 16px; - flex-wrap: wrap; +.chartsGrid { + display: grid; + gap: 20px; + grid-template-columns: 1fr; - @include mobile { - flex-direction: column; + @include desktop { + grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.detailsGrid { + display: grid; + gap: 20px; + grid-template-columns: 1fr; + + @include desktop { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.chartLineHeader { + display: inline-flex; + align-items: center; + gap: 10px; +} + .chartLineList { - display: flex; - flex-direction: column; - gap: 6px; - flex: 1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + + @include mobile { + grid-template-columns: 1fr; + } } .chartLineItem { - display: flex; + display: grid; + grid-template-columns: auto 1fr auto; align-items: center; - gap: 6px; - flex-wrap: wrap; + gap: 10px; + padding: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: $radius-md; @include mobile { - flex-direction: column; - align-items: flex-start; + grid-template-columns: 1fr; + align-items: stretch; + gap: 8px; } } .chartLineLabel { - font-size: 11px; + font-size: 12px; color: var(--text-secondary); - min-width: 48px; -} - -.chartLineActions { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; + font-weight: 600; + min-width: 64px; } .chartLineCount { - font-size: 11px; + font-size: 12px; color: var(--text-secondary); font-weight: 500; } .chartLineHint { - font-size: 10px; + font-size: 12px; color: var(--text-tertiary); - margin: 6px 0 0 0; - font-style: italic; + margin: 10px 0 0 0; } diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index ace30c8..30a4d0f 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useEffect, useState, useCallback, useMemo, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { Chart as ChartJS, @@ -18,6 +18,7 @@ import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons'; import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useThemeStore } from '@/stores'; import { usageApi } from '@/services/api/usage'; import { formatTokensInMillions, @@ -55,13 +56,15 @@ interface UsagePayload { success_count?: number; failure_count?: number; total_tokens?: number; - apis?: Record; - [key: string]: any; + apis?: Record; + [key: string]: unknown; } export function UsagePage() { const { t } = useTranslation(); const isMobile = useMediaQuery('(max-width: 768px)'); + const theme = useThemeStore((state) => state.theme); + const isDark = theme === 'dark'; const [usage, setUsage] = useState(null); const [loading, setLoading] = useState(true); @@ -90,8 +93,9 @@ export function UsagePage() { const data = await usageApi.getUsage(); const payload = data?.usage ?? data; setUsage(payload); - } catch (err: any) { - setError(err?.message || t('usage_stats.loading_error')); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('usage_stats.loading_error'); + setError(message); } finally { setLoading(false); } @@ -200,23 +204,23 @@ export function UsagePage() { ); const requestsSparkline = useMemo( - () => buildSparkline(buildLastHourSeries('requests'), '#2563eb', 'rgba(37, 99, 235, 0.12)'), + () => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'), [buildLastHourSeries, buildSparkline] ); const tokensSparkline = useMemo( - () => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.12)'), + () => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.18)'), [buildLastHourSeries, buildSparkline] ); const rpmSparkline = useMemo( - () => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.12)'), + () => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.18)'), [buildLastHourSeries, buildSparkline] ); const tpmSparkline = useMemo( - () => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.12)'), + () => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.18)'), [buildLastHourSeries, buildSparkline] ); const costSparkline = useMemo( - () => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.12)'), + () => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.18)'), [buildLastHourSeries, buildSparkline] ); @@ -225,6 +229,13 @@ export function UsagePage() { const pointRadius = isMobile && period === 'hour' ? 0 : isMobile ? 2 : 4; const tickFontSize = isMobile ? 10 : 12; const maxTickLabelCount = isMobile ? (period === 'hour' ? 8 : 6) : period === 'hour' ? 12 : 10; + const gridColor = isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(17, 24, 39, 0.06)'; + const axisBorderColor = isDark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(17, 24, 39, 0.10)'; + const tickColor = isDark ? 'rgba(255, 255, 255, 0.72)' : 'rgba(17, 24, 39, 0.72)'; + const tooltipBg = isDark ? 'rgba(17, 24, 39, 0.92)' : 'rgba(255, 255, 255, 0.98)'; + const tooltipTitle = isDark ? '#ffffff' : '#111827'; + const tooltipBody = isDark ? 'rgba(255, 255, 255, 0.86)' : '#374151'; + const tooltipBorder = isDark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(17, 24, 39, 0.10)'; return { responsive: true, @@ -234,11 +245,29 @@ export function UsagePage() { intersect: false }, plugins: { - legend: { display: false } + legend: { display: false }, + tooltip: { + backgroundColor: tooltipBg, + titleColor: tooltipTitle, + bodyColor: tooltipBody, + borderColor: tooltipBorder, + borderWidth: 1, + padding: 10, + displayColors: true, + usePointStyle: true + } }, scales: { x: { + grid: { + color: gridColor, + drawTicks: false + }, + border: { + color: axisBorderColor + }, ticks: { + color: tickColor, font: { size: tickFontSize }, maxRotation: isMobile ? 0 : 45, minRotation: isMobile ? 0 : 0, @@ -270,7 +299,14 @@ export function UsagePage() { }, y: { beginAtZero: true, + grid: { + color: gridColor + }, + border: { + color: axisBorderColor + }, ticks: { + color: tickColor, font: { size: tickFontSize } } } @@ -288,7 +324,7 @@ export function UsagePage() { } }; }, - [isMobile] + [isDark, isMobile] ); const requestsChartOptions = useMemo( @@ -386,7 +422,9 @@ export function UsagePage() { key: 'requests', label: t('usage_stats.total_requests'), icon: , - accent: '#2563eb', + accent: '#3b82f6', + accentSoft: 'rgba(59, 130, 246, 0.18)', + accentBorder: 'rgba(59, 130, 246, 0.35)', value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(), meta: ( <> @@ -407,6 +445,8 @@ export function UsagePage() { label: t('usage_stats.total_tokens'), icon: , accent: '#8b5cf6', + accentSoft: 'rgba(139, 92, 246, 0.18)', + accentBorder: 'rgba(139, 92, 246, 0.35)', value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0), meta: ( <> @@ -425,6 +465,8 @@ export function UsagePage() { label: t('usage_stats.rpm_30m'), icon: , accent: '#22c55e', + accentSoft: 'rgba(34, 197, 94, 0.18)', + accentBorder: 'rgba(34, 197, 94, 0.32)', value: loading ? '-' : formatPerMinuteValue(rateStats.rpm), meta: ( @@ -438,6 +480,8 @@ export function UsagePage() { label: t('usage_stats.tpm_30m'), icon: , accent: '#f97316', + accentSoft: 'rgba(249, 115, 22, 0.18)', + accentBorder: 'rgba(249, 115, 22, 0.32)', value: loading ? '-' : formatPerMinuteValue(rateStats.tpm), meta: ( @@ -451,6 +495,8 @@ export function UsagePage() { label: t('usage_stats.total_cost'), icon: , accent: '#f59e0b', + accentSoft: 'rgba(245, 158, 11, 0.18)', + accentBorder: 'rgba(245, 158, 11, 0.32)', value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--', meta: ( <> @@ -487,15 +533,22 @@ export function UsagePage() { {/* Stats Overview Cards */}
{statsCards.map(card => ( -
+
{card.label}
- + {card.icon}
@@ -513,37 +566,10 @@ export function UsagePage() {
{/* Chart Line Selection */} - -
-
- {chartLines.map((line, index) => ( -
- - {t(`usage_stats.chart_line_label_${index + 1}`)}: - - - {chartLines.length > 1 && ( - - )} -
- ))} -
-
+ {chartLines.length}/{MAX_CHART_LINES} @@ -556,208 +582,241 @@ export function UsagePage() { {t('usage_stats.chart_line_add')}
+ } + > +
+ {chartLines.map((line, index) => ( +
+ + {t(`usage_stats.chart_line_label_${index + 1}`)} + + + {chartLines.length > 1 && ( + + )} +
+ ))}

{t('usage_stats.chart_line_hint')}

- {/* Requests Chart */} - - - -
- } - > - {loading ? ( -
{t('common.loading')}
- ) : requestsChartData.labels.length > 0 ? ( -
-
- {requestsChartData.datasets.map((dataset, index) => ( -
- - {dataset.label} -
- ))} +
+ {/* Requests Chart */} + + +
-
-
-
- -
-
-
-
- ) : ( -
{t('usage_stats.no_data')}
- )} - - - {/* Tokens Chart */} - - - -
- } - > - {loading ? ( -
{t('common.loading')}
- ) : tokensChartData.labels.length > 0 ? ( -
-
- {tokensChartData.datasets.map((dataset, index) => ( -
- - {dataset.label} -
- ))} -
-
-
-
- -
-
-
-
- ) : ( -
{t('usage_stats.no_data')}
- )} -
- - {/* API Key Statistics */} - - {loading ? ( -
{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)} - - )} -
+ } + > + {loading ? ( +
{t('common.loading')}
+ ) : requestsChartData.labels.length > 0 ? ( +
+
+ {requestsChartData.datasets.map((dataset, index) => ( +
+ + {dataset.label}
- - {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')}
- )} - - - {/* 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')}
- )} -
+
+
+
+
+ +
+
+
+
+ ) : ( +
{t('usage_stats.no_data')}
+ )} + + + {/* Tokens Chart */} + + + +
+ } + > + {loading ? ( +
{t('common.loading')}
+ ) : tokensChartData.labels.length > 0 ? ( +
+
+ {tokensChartData.datasets.map((dataset, index) => ( +
+ + {dataset.label} +
+ ))} +
+
+
+
+ +
+
+
+
+ ) : ( +
{t('usage_stats.no_data')}
+ )} +
+
+ +
+ {/* API Key Statistics */} + + {loading ? ( +
{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')}
+ )} +
+ + {/* 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 */} diff --git a/src/utils/usage.ts b/src/utils/usage.ts index b38f7c1..e33d2cc 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -3,6 +3,7 @@ * 迁移自基线 modules/usage.js 的纯逻辑部分 */ +import type { ScriptableContext } from 'chart.js'; import { maskApiKey } from './format'; export interface KeyStatBucket { @@ -636,7 +637,7 @@ export interface ChartDataset { label: string; data: number[]; borderColor: string; - backgroundColor: string; + backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient); fill: boolean; tension: number; } @@ -658,6 +659,47 @@ const CHART_COLORS = [ { borderColor: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.15)' }, ]; +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => { + const normalized = hex.trim().replace('#', ''); + if (normalized.length !== 6) { + return null; + } + const r = Number.parseInt(normalized.slice(0, 2), 16); + const g = Number.parseInt(normalized.slice(2, 4), 16); + const b = Number.parseInt(normalized.slice(4, 6), 16); + if (![r, g, b].every((channel) => Number.isFinite(channel))) { + return null; + } + return { r, g, b }; +}; + +const withAlpha = (hex: string, alpha: number) => { + const rgb = hexToRgb(hex); + if (!rgb) { + return hex; + } + const clamped = clamp(alpha, 0, 1); + return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${clamped})`; +}; + +const buildAreaGradient = (context: ScriptableContext<'line'>, baseHex: string, fallback: string) => { + const chart = context.chart; + const ctx = chart.ctx; + const area = chart.chartArea; + + if (!area) { + return fallback; + } + + const gradient = ctx.createLinearGradient(0, area.top, 0, area.bottom); + gradient.addColorStop(0, withAlpha(baseHex, 0.28)); + gradient.addColorStop(0.6, withAlpha(baseHex, 0.12)); + gradient.addColorStop(1, withAlpha(baseHex, 0.02)); + return gradient; +}; + /** * 构建图表数据 */ @@ -692,13 +734,16 @@ export function buildChartData( const data = isAll ? getAllSeries() : (dataByModel.get(model) || new Array(labels.length).fill(0)); const colorIndex = index % CHART_COLORS.length; const style = CHART_COLORS[colorIndex]; + const shouldFill = modelsToShow.length === 1 || (isAll && modelsToShow.length > 1); return { label: isAll ? 'All Models' : model, data, borderColor: style.borderColor, - backgroundColor: style.backgroundColor, - fill: false, + backgroundColor: shouldFill + ? (ctx) => buildAreaGradient(ctx, style.borderColor, style.backgroundColor) + : style.backgroundColor, + fill: shouldFill, tension: 0.35 }; });