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:
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user