From 78512f8039bc30f9d0aab72d778a809ec69a11b7 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 13 Feb 2026 13:29:21 +0800 Subject: [PATCH] feat(usage): add token type breakdown stacked chart --- src/components/usage/TokenBreakdownChart.tsx | 146 +++++++++++++++++++ src/components/usage/index.ts | 3 + src/i18n/locales/en.json | 5 +- src/i18n/locales/ru.json | 5 +- src/i18n/locales/zh-CN.json | 5 +- src/pages/UsagePage.tsx | 10 ++ src/utils/usage.ts | 118 +++++++++++++++ 7 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 src/components/usage/TokenBreakdownChart.tsx diff --git a/src/components/usage/TokenBreakdownChart.tsx b/src/components/usage/TokenBreakdownChart.tsx new file mode 100644 index 0000000..57b701f --- /dev/null +++ b/src/components/usage/TokenBreakdownChart.tsx @@ -0,0 +1,146 @@ +import { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Line } from 'react-chartjs-2'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { + buildHourlyTokenBreakdown, + buildDailyTokenBreakdown, + type TokenCategory, +} from '@/utils/usage'; +import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig'; +import type { UsagePayload } from './hooks/useUsageData'; +import styles from '@/pages/UsagePage.module.scss'; + +const TOKEN_COLORS: Record = { + input: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.25)' }, + output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' }, + cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' }, + reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' }, +}; + +const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning']; + +export interface TokenBreakdownChartProps { + usage: UsagePayload | null; + loading: boolean; + isDark: boolean; + isMobile: boolean; + hourWindowHours?: number; +} + +export function TokenBreakdownChart({ + usage, + loading, + isDark, + isMobile, + hourWindowHours, +}: TokenBreakdownChartProps) { + const { t } = useTranslation(); + const [period, setPeriod] = useState<'hour' | 'day'>('hour'); + + const CATEGORY_LABELS: Record = { + input: t('usage_stats.input_tokens'), + output: t('usage_stats.output_tokens'), + cached: t('usage_stats.cached_tokens'), + reasoning: t('usage_stats.reasoning_tokens'), + }; + + const { chartData, chartOptions } = useMemo(() => { + const series = + period === 'hour' + ? buildHourlyTokenBreakdown(usage, hourWindowHours) + : buildDailyTokenBreakdown(usage); + + const data = { + labels: series.labels, + datasets: CATEGORIES.map((cat) => ({ + label: CATEGORY_LABELS[cat], + data: series.dataByCategory[cat], + borderColor: TOKEN_COLORS[cat].border, + backgroundColor: TOKEN_COLORS[cat].bg, + pointBackgroundColor: TOKEN_COLORS[cat].border, + pointBorderColor: TOKEN_COLORS[cat].border, + fill: true, + tension: 0.35, + })), + }; + + const options = { + ...buildChartOptions({ period, labels: series.labels, isDark, isMobile }), + scales: { + ...buildChartOptions({ period, labels: series.labels, isDark, isMobile }).scales, + y: { + ...buildChartOptions({ period, labels: series.labels, isDark, isMobile }).scales?.y, + stacked: true, + }, + x: { + ...buildChartOptions({ period, labels: series.labels, isDark, isMobile }).scales?.x, + stacked: true, + }, + }, + }; + + return { chartData: data, chartOptions: options, hasData: series.hasData }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [usage, period, isDark, isMobile, hourWindowHours]); + + return ( + + + + + } + > + {loading ? ( +
{t('common.loading')}
+ ) : chartData.labels.length > 0 ? ( +
+
+ {chartData.datasets.map((dataset, index) => ( +
+ + {dataset.label} +
+ ))} +
+
+
+
+ +
+
+
+
+ ) : ( +
{t('usage_stats.no_data')}
+ )} +
+ ); +} diff --git a/src/components/usage/index.ts b/src/components/usage/index.ts index 08f571c..da43a30 100644 --- a/src/components/usage/index.ts +++ b/src/components/usage/index.ts @@ -29,3 +29,6 @@ export type { PriceSettingsCardProps } from './PriceSettingsCard'; export { CredentialStatsCard } from './CredentialStatsCard'; export type { CredentialStatsCardProps } from './CredentialStatsCard'; + +export { TokenBreakdownChart } from './TokenBreakdownChart'; +export type { TokenBreakdownChartProps } from './TokenBreakdownChart'; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4dc10d1..8f7f1a2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -814,7 +814,10 @@ "cost_need_usage": "No usage data available to calculate cost", "cost_no_data": "No cost data yet", "credential_stats": "Credential Statistics", - "credential_name": "Credential" + "credential_name": "Credential", + "token_breakdown": "Token Type Breakdown", + "input_tokens": "Input Tokens", + "output_tokens": "Output Tokens" }, "stats": { "success": "Success", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 389549a..9bd041e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -817,7 +817,10 @@ "cost_need_usage": "Нет данных использования для расчёта стоимости", "cost_no_data": "Данных о стоимости ещё нет", "credential_stats": "Статистика учётных данных", - "credential_name": "Учётные данные" + "credential_name": "Учётные данные", + "token_breakdown": "Распределение типов токенов", + "input_tokens": "Входные токены", + "output_tokens": "Выходные токены" }, "stats": { "success": "Успех", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index bc2d4bb..d7eafe3 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -814,7 +814,10 @@ "cost_need_usage": "暂无使用数据,无法计算花费", "cost_no_data": "没有可计算的花费数据", "credential_stats": "凭证统计", - "credential_name": "凭证" + "credential_name": "凭证", + "token_breakdown": "Token 类型分布", + "input_tokens": "输入 Tokens", + "output_tokens": "输出 Tokens" }, "stats": { "success": "成功", diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index 9c1d779..df341ae 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -25,6 +25,7 @@ import { ModelStatsCard, PriceSettingsCard, CredentialStatsCard, + TokenBreakdownChart, useUsageData, useSparklines, useChartData @@ -357,6 +358,15 @@ export function UsagePage() { /> + {/* Token Breakdown Chart */} + + {/* Details Grid */}
diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 0f523b3..45cd9e1 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -1277,3 +1277,121 @@ export function computeKeyStats(usageData: unknown, masker: (val: string) => str byAuthIndex: authIndexStats }; } + +export type TokenCategory = 'input' | 'output' | 'cached' | 'reasoning'; + +export interface TokenBreakdownSeries { + labels: string[]; + dataByCategory: Record; + hasData: boolean; +} + +/** + * 按 token 类别构建小时级别的堆叠序列 + */ +export function buildHourlyTokenBreakdown( + usageData: unknown, + hourWindow: number = 24 +): TokenBreakdownSeries { + const hourMs = 60 * 60 * 1000; + const resolvedHourWindow = + Number.isFinite(hourWindow) && hourWindow > 0 + ? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31) + : 24; + const now = new Date(); + const currentHour = new Date(now); + currentHour.setMinutes(0, 0, 0); + + const earliestBucket = new Date(currentHour); + earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1)); + const earliestTime = earliestBucket.getTime(); + + const labels: string[] = []; + for (let i = 0; i < resolvedHourWindow; i++) { + labels.push(formatHourLabel(new Date(earliestTime + i * hourMs))); + } + + const dataByCategory: Record = { + input: new Array(labels.length).fill(0), + output: new Array(labels.length).fill(0), + cached: new Array(labels.length).fill(0), + reasoning: new Array(labels.length).fill(0), + }; + + const details = collectUsageDetails(usageData); + let hasData = false; + + 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 tokens = detail.tokens; + const input = typeof tokens.input_tokens === 'number' ? Math.max(tokens.input_tokens, 0) : 0; + const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0; + const cached = Math.max( + typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0, + typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0, + ); + const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0; + + dataByCategory.input[bucketIndex] += input; + dataByCategory.output[bucketIndex] += output; + dataByCategory.cached[bucketIndex] += cached; + dataByCategory.reasoning[bucketIndex] += reasoning; + hasData = true; + }); + + return { labels, dataByCategory, hasData }; +} + +/** + * 按 token 类别构建日级别的堆叠序列 + */ +export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeries { + const details = collectUsageDetails(usageData); + const dayMap: Record> = {}; + let hasData = false; + + details.forEach((detail) => { + const timestamp = Date.parse(detail.timestamp); + if (Number.isNaN(timestamp)) return; + const dayLabel = formatDayLabel(new Date(timestamp)); + if (!dayLabel) return; + + if (!dayMap[dayLabel]) { + dayMap[dayLabel] = { input: 0, output: 0, cached: 0, reasoning: 0 }; + } + + const tokens = detail.tokens; + const input = typeof tokens.input_tokens === 'number' ? Math.max(tokens.input_tokens, 0) : 0; + const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0; + const cached = Math.max( + typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0, + typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0, + ); + const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0; + + dayMap[dayLabel].input += input; + dayMap[dayLabel].output += output; + dayMap[dayLabel].cached += cached; + dayMap[dayLabel].reasoning += reasoning; + hasData = true; + }); + + const labels = Object.keys(dayMap).sort(); + const dataByCategory: Record = { + input: labels.map((l) => dayMap[l].input), + output: labels.map((l) => dayMap[l].output), + cached: labels.map((l) => dayMap[l].cached), + reasoning: labels.map((l) => dayMap[l].reasoning), + }; + + return { labels, dataByCategory, hasData }; +}