diff --git a/src/components/usage/CostTrendChart.tsx b/src/components/usage/CostTrendChart.tsx new file mode 100644 index 0000000..d77f04e --- /dev/null +++ b/src/components/usage/CostTrendChart.tsx @@ -0,0 +1,145 @@ +import { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ScriptableContext } from 'chart.js'; +import { Line } from 'react-chartjs-2'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { + buildHourlyCostSeries, + buildDailyCostSeries, + formatUsd, + type ModelPrice, +} from '@/utils/usage'; +import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig'; +import type { UsagePayload } from './hooks/useUsageData'; +import styles from '@/pages/UsagePage.module.scss'; + +export interface CostTrendChartProps { + usage: UsagePayload | null; + loading: boolean; + isDark: boolean; + isMobile: boolean; + modelPrices: Record; + hourWindowHours?: number; +} + +const COST_COLOR = '#f59e0b'; +const COST_BG = 'rgba(245, 158, 11, 0.15)'; + +function buildGradient(ctx: ScriptableContext<'line'>) { + const chart = ctx.chart; + const area = chart.chartArea; + if (!area) return COST_BG; + const gradient = chart.ctx.createLinearGradient(0, area.top, 0, area.bottom); + gradient.addColorStop(0, 'rgba(245, 158, 11, 0.28)'); + gradient.addColorStop(0.6, 'rgba(245, 158, 11, 0.12)'); + gradient.addColorStop(1, 'rgba(245, 158, 11, 0.02)'); + return gradient; +} + +export function CostTrendChart({ + usage, + loading, + isDark, + isMobile, + modelPrices, + hourWindowHours, +}: CostTrendChartProps) { + const { t } = useTranslation(); + const [period, setPeriod] = useState<'hour' | 'day'>('hour'); + const hasPrices = Object.keys(modelPrices).length > 0; + + const { chartData, chartOptions, hasData } = useMemo(() => { + if (!hasPrices || !usage) { + return { chartData: { labels: [], datasets: [] }, chartOptions: {}, hasData: false }; + } + + const series = + period === 'hour' + ? buildHourlyCostSeries(usage, modelPrices, hourWindowHours) + : buildDailyCostSeries(usage, modelPrices); + + const data = { + labels: series.labels, + datasets: [ + { + label: t('usage_stats.total_cost'), + data: series.data, + borderColor: COST_COLOR, + backgroundColor: buildGradient, + pointBackgroundColor: COST_COLOR, + pointBorderColor: COST_COLOR, + fill: true, + tension: 0.35, + }, + ], + }; + + const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile }); + const options = { + ...baseOptions, + scales: { + ...baseOptions.scales, + y: { + ...baseOptions.scales?.y, + ticks: { + ...(baseOptions.scales?.y && 'ticks' in baseOptions.scales.y ? baseOptions.scales.y.ticks : {}), + callback: (value: string | number) => formatUsd(Number(value)), + }, + }, + }, + }; + + return { chartData: data, chartOptions: options, hasData: series.hasData }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [usage, period, isDark, isMobile, modelPrices, hasPrices, hourWindowHours]); + + return ( + + + + + } + > + {loading ? ( +
{t('common.loading')}
+ ) : !hasPrices ? ( +
{t('usage_stats.cost_need_price')}
+ ) : !hasData ? ( +
{t('usage_stats.cost_no_data')}
+ ) : ( +
+
+
+
+ +
+
+
+
+ )} +
+ ); +} diff --git a/src/components/usage/index.ts b/src/components/usage/index.ts index da43a30..e07bc8b 100644 --- a/src/components/usage/index.ts +++ b/src/components/usage/index.ts @@ -32,3 +32,6 @@ export type { CredentialStatsCardProps } from './CredentialStatsCard'; export { TokenBreakdownChart } from './TokenBreakdownChart'; export type { TokenBreakdownChartProps } from './TokenBreakdownChart'; + +export { CostTrendChart } from './CostTrendChart'; +export type { CostTrendChartProps } from './CostTrendChart'; diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index df341ae..6814ce3 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -26,6 +26,7 @@ import { PriceSettingsCard, CredentialStatsCard, TokenBreakdownChart, + CostTrendChart, useUsageData, useSparklines, useChartData @@ -367,6 +368,16 @@ export function UsagePage() { hourWindowHours={hourWindowHours} /> + {/* Cost Trend Chart */} + + {/* Details Grid */}
diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 45cd9e1..0a4bf14 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -1395,3 +1395,90 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri return { labels, dataByCategory, hasData }; } + +export interface CostSeries { + labels: string[]; + data: number[]; + hasData: boolean; +} + +/** + * 按小时构建费用时间序列 + */ +export function buildHourlyCostSeries( + usageData: unknown, + modelPrices: Record, + hourWindow: number = 24 +): CostSeries { + 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 data = 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 cost = calculateCost(detail, modelPrices); + if (cost > 0) { + data[bucketIndex] += cost; + hasData = true; + } + }); + + return { labels, data, hasData }; +} + +/** + * 按天构建费用时间序列 + */ +export function buildDailyCostSeries( + usageData: unknown, + modelPrices: Record +): CostSeries { + 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; + + const cost = calculateCost(detail, modelPrices); + if (cost > 0) { + dayMap[dayLabel] = (dayMap[dayLabel] || 0) + cost; + hasData = true; + } + }); + + const labels = Object.keys(dayMap).sort(); + const data = labels.map((l) => dayMap[l]); + + return { labels, data, hasData }; +}