feat(usage): add token type breakdown stacked chart

This commit is contained in:
Supra4E8C
2026-02-13 13:29:21 +08:00
parent 7cdede6de8
commit 78512f8039
7 changed files with 289 additions and 3 deletions

View File

@@ -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<TokenCategory, { border: string; bg: string }> = {
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<TokenCategory, string> = {
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 (
<Card
title={t('usage_stats.token_breakdown')}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : chartData.labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{chartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}

View File

@@ -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';

View File

@@ -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",

View File

@@ -817,7 +817,10 @@
"cost_need_usage": "Нет данных использования для расчёта стоимости",
"cost_no_data": "Данных о стоимости ещё нет",
"credential_stats": "Статистика учётных данных",
"credential_name": "Учётные данные"
"credential_name": "Учётные данные",
"token_breakdown": "Распределение типов токенов",
"input_tokens": "Входные токены",
"output_tokens": "Выходные токены"
},
"stats": {
"success": "Успех",

View File

@@ -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": "成功",

View File

@@ -25,6 +25,7 @@ import {
ModelStatsCard,
PriceSettingsCard,
CredentialStatsCard,
TokenBreakdownChart,
useUsageData,
useSparklines,
useChartData
@@ -357,6 +358,15 @@ export function UsagePage() {
/>
</div>
{/* Token Breakdown Chart */}
<TokenBreakdownChart
usage={filteredUsage}
loading={loading}
isDark={isDark}
isMobile={isMobile}
hourWindowHours={hourWindowHours}
/>
{/* Details Grid */}
<div className={styles.detailsGrid}>
<ApiDetailsCard apiStats={apiStats} loading={loading} hasPrices={hasPrices} />

View File

@@ -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<TokenCategory, number[]>;
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<TokenCategory, number[]> = {
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<string, Record<TokenCategory, number>> = {};
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<TokenCategory, number[]> = {
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 };
}