Files
memind_adm/src/components/UsageTrendChart.tsx
T
john 2550b3b323 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>
2026-07-31 11:28:47 +08:00

252 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) };
}