mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 19:20:49 +08:00
feat(usage): add cost trend chart with hourly/daily toggle
This commit is contained in:
145
src/components/usage/CostTrendChart.tsx
Normal file
145
src/components/usage/CostTrendChart.tsx
Normal file
@@ -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<string, ModelPrice>;
|
||||||
|
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 (
|
||||||
|
<Card
|
||||||
|
title={t('usage_stats.cost_trend')}
|
||||||
|
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>
|
||||||
|
) : !hasPrices ? (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.cost_need_price')}</div>
|
||||||
|
) : !hasData ? (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.cost_no_data')}</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.chartWrapper}>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,3 +32,6 @@ export type { CredentialStatsCardProps } from './CredentialStatsCard';
|
|||||||
|
|
||||||
export { TokenBreakdownChart } from './TokenBreakdownChart';
|
export { TokenBreakdownChart } from './TokenBreakdownChart';
|
||||||
export type { TokenBreakdownChartProps } from './TokenBreakdownChart';
|
export type { TokenBreakdownChartProps } from './TokenBreakdownChart';
|
||||||
|
|
||||||
|
export { CostTrendChart } from './CostTrendChart';
|
||||||
|
export type { CostTrendChartProps } from './CostTrendChart';
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
PriceSettingsCard,
|
PriceSettingsCard,
|
||||||
CredentialStatsCard,
|
CredentialStatsCard,
|
||||||
TokenBreakdownChart,
|
TokenBreakdownChart,
|
||||||
|
CostTrendChart,
|
||||||
useUsageData,
|
useUsageData,
|
||||||
useSparklines,
|
useSparklines,
|
||||||
useChartData
|
useChartData
|
||||||
@@ -367,6 +368,16 @@ export function UsagePage() {
|
|||||||
hourWindowHours={hourWindowHours}
|
hourWindowHours={hourWindowHours}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Cost Trend Chart */}
|
||||||
|
<CostTrendChart
|
||||||
|
usage={filteredUsage}
|
||||||
|
loading={loading}
|
||||||
|
isDark={isDark}
|
||||||
|
isMobile={isMobile}
|
||||||
|
modelPrices={modelPrices}
|
||||||
|
hourWindowHours={hourWindowHours}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Details Grid */}
|
{/* Details Grid */}
|
||||||
<div className={styles.detailsGrid}>
|
<div className={styles.detailsGrid}>
|
||||||
<ApiDetailsCard apiStats={apiStats} loading={loading} hasPrices={hasPrices} />
|
<ApiDetailsCard apiStats={apiStats} loading={loading} hasPrices={hasPrices} />
|
||||||
|
|||||||
@@ -1395,3 +1395,90 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
|
|||||||
|
|
||||||
return { labels, dataByCategory, hasData };
|
return { labels, dataByCategory, hasData };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CostSeries {
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
hasData: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按小时构建费用时间序列
|
||||||
|
*/
|
||||||
|
export function buildHourlyCostSeries(
|
||||||
|
usageData: unknown,
|
||||||
|
modelPrices: Record<string, ModelPrice>,
|
||||||
|
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<string, ModelPrice>
|
||||||
|
): CostSeries {
|
||||||
|
const details = collectUsageDetails(usageData);
|
||||||
|
const dayMap: Record<string, 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;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user