feat(billing): add usage analytics charts and summary totals

Add daily usage trend charts with multi-metric toggles, overview vs user
analysis views, date-range filtering, and usage summary API for interval
and all-time totals.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-31 11:28:47 +08:00
parent 2a1c221bb3
commit 2550b3b323
9 changed files with 1843 additions and 68 deletions
+251
View File
@@ -0,0 +1,251 @@
import { useMemo } from 'react';
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { UsageStatsBucket } from '../types';
import { formatYuan } from '../admin/utils/format';
export type UsageChartMetric = 'cost' | 'tokens' | 'count';
export type UsageChartType = 'bar' | 'line';
export const METRIC_OPTIONS: { key: UsageChartMetric; label: string }[] = [
{ key: 'tokens', label: 'Token 总量' },
{ key: 'cost', label: '扣费金额' },
{ key: 'count', label: '请求次数' },
];
export const METRIC_CONFIG: Record<
UsageChartMetric,
{ label: string; color: string; yAxisId: 'left' | 'right' }
> = {
tokens: { label: 'Token 总量', color: '#3d8bfd', yAxisId: 'left' },
cost: { label: '扣费金额', color: '#22c55e', yAxisId: 'right' },
count: { label: '请求次数', color: '#f59e0b', yAxisId: 'right' },
};
export const DEFAULT_CHART_METRICS: UsageChartMetric[] = ['tokens'];
function formatDayLabel(day: string) {
const [, month, date] = day.split('-');
return `${Number(month)}/${Number(date)}`;
}
function fillDailyBuckets(buckets: UsageStatsBucket[], startAt: number, endAt: number) {
const map = new Map(buckets.map((b) => [b.day, b]));
const start = new Date(startAt * 1000);
start.setHours(0, 0, 0, 0);
const end = new Date(endAt * 1000);
end.setHours(0, 0, 0, 0);
const filled: UsageStatsBucket[] = [];
for (let cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) {
const y = cursor.getFullYear();
const m = String(cursor.getMonth() + 1).padStart(2, '0');
const d = String(cursor.getDate()).padStart(2, '0');
const day = `${y}-${m}-${d}`;
filled.push(
map.get(day) ?? {
day,
count: 0,
inputTokens: 0,
outputTokens: 0,
costCents: 0,
},
);
}
return filled;
}
function formatMetricValue(value: number, metric: UsageChartMetric) {
if (metric === 'cost') return `¥${formatYuan(Math.round(value * 100))}`;
return value.toLocaleString();
}
type ChartPoint = {
day: string;
label: string;
tokens: number;
cost: number;
count: number;
inputTokens: number;
outputTokens: number;
costCents: number;
};
function UsageTooltip({
active,
payload,
metrics,
}: {
active?: boolean;
payload?: Array<{ payload: ChartPoint; dataKey?: string; color?: string }>;
metrics: UsageChartMetric[];
}) {
if (!active || !payload?.length) return null;
const row = payload[0].payload;
return (
<div className="usage-chart-tooltip">
<div className="usage-chart-tooltip-title">{row.day}</div>
{metrics.map((metric) => (
<div key={metric} className="usage-chart-tooltip-metric">
<span className="usage-chart-tooltip-dot" style={{ background: METRIC_CONFIG[metric].color }} />
{METRIC_CONFIG[metric].label}{formatMetricValue(row[metric], metric)}
</div>
))}
<div className="muted usage-chart-tooltip-detail">
{row.inputTokens.toLocaleString()} / {row.outputTokens.toLocaleString()} Token
</div>
</div>
);
}
export function UsageTrendChart({
buckets,
startAt,
endAt,
metrics,
chartType,
}: {
buckets: UsageStatsBucket[];
startAt: number;
endAt: number;
metrics: UsageChartMetric[];
chartType: UsageChartType;
}) {
const activeMetrics = metrics.length > 0 ? metrics : DEFAULT_CHART_METRICS;
const data = useMemo(() => {
const filled = fillDailyBuckets(buckets, startAt, endAt);
return filled.map((bucket) => ({
day: bucket.day,
label: formatDayLabel(bucket.day),
tokens: bucket.inputTokens + bucket.outputTokens,
cost: bucket.costCents / 100,
count: bucket.count,
inputTokens: bucket.inputTokens,
outputTokens: bucket.outputTokens,
costCents: bucket.costCents,
}));
}, [buckets, startAt, endAt]);
const hasData = data.some((row) => activeMetrics.some((metric) => row[metric] > 0));
const showLeftAxis = activeMetrics.includes('tokens');
const showRightAxis = activeMetrics.some((m) => m === 'cost' || m === 'count');
const resolveYAxisId = (metric: UsageChartMetric): 'left' | 'right' => {
if (showLeftAxis && showRightAxis) return METRIC_CONFIG[metric].yAxisId;
if (showLeftAxis) return 'left';
return 'right';
};
if (!hasData) {
return <p className="muted usage-chart-empty"></p>;
}
return (
<div className="usage-chart-wrap">
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={data} margin={{ top: 8, right: showRightAxis ? 16 : 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border-subtle)" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--color-text-muted)' }}
interval={data.length > 14 ? Math.floor(data.length / 7) : 0}
/>
{showLeftAxis && (
<YAxis
yAxisId="left"
tick={{ fontSize: 11, fill: METRIC_CONFIG.tokens.color }}
tickFormatter={(v) => (v >= 10000 ? `${(v / 10000).toFixed(0)}` : String(v))}
width={52}
/>
)}
{showRightAxis && (
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 11, fill: 'var(--color-text-muted)' }}
tickFormatter={(v) => (Number.isInteger(v) ? String(v) : v.toFixed(2))}
width={48}
/>
)}
<Tooltip content={<UsageTooltip metrics={activeMetrics} />} />
<Legend
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
formatter={(value) => <span style={{ color: 'var(--color-text-muted)' }}>{value}</span>}
/>
{activeMetrics.map((metric) => {
const cfg = METRIC_CONFIG[metric];
const axisId = resolveYAxisId(metric);
if (chartType === 'bar') {
return (
<Bar
key={metric}
yAxisId={axisId}
dataKey={metric}
name={cfg.label}
fill={cfg.color}
radius={[4, 4, 0, 0]}
maxBarSize={activeMetrics.length > 1 ? 24 : 40}
/>
);
}
return (
<Line
key={metric}
yAxisId={axisId}
type="monotone"
dataKey={metric}
name={cfg.label}
stroke={cfg.color}
strokeWidth={2}
dot={{ r: 3, fill: cfg.color }}
activeDot={{ r: 5 }}
/>
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
export function toggleChartMetric(current: UsageChartMetric[], metric: UsageChartMetric): UsageChartMetric[] {
if (current.includes(metric)) {
if (current.length === 1) return current;
return current.filter((m) => m !== metric);
}
return [...current, metric];
}
export function dateRangeToUnix(startDate: string, endDate: string) {
const [sy, sm, sd] = startDate.split('-').map(Number);
const [ey, em, ed] = endDate.split('-').map(Number);
const startAt = Math.floor(new Date(sy, sm - 1, sd, 0, 0, 0, 0).getTime() / 1000);
const endAt = Math.floor(new Date(ey, em - 1, ed, 23, 59, 59, 999).getTime() / 1000);
return { startAt, endAt };
}
export function formatDateInput(date: Date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function presetDateRange(days: number) {
const end = new Date();
end.setHours(0, 0, 0, 0);
const start = new Date(end);
start.setDate(start.getDate() - (days - 1));
return { startDate: formatDateInput(start), endDate: formatDateInput(end) };
}