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
+574 -63
View File
@@ -1,8 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
cancelUserSubscription,
createSubscriptionPlan,
deleteSubscriptionPlan,
getAdminUsageStats,
getAdminUsageSummary,
grantUserSubscription,
listAdminLedger,
listAdminSubscriptions,
@@ -13,8 +16,19 @@ import {
updateSubscriptionPlan,
} from '../../api/client';
import type { PagedResult } from '../../api/client';
import type { AdminSubscription, AdminUserRow, LedgerEntry, PlanDefinition, UsageRecord } from '../../types';
import type { AdminSubscription, AdminUserRow, LedgerEntry, PlanDefinition, UsageRecord, UsageStatsResult, UsageSummaryResult, UsageTotals } from '../../types';
import { Pagination } from '../../components/Pagination';
import {
dateRangeToUnix,
DEFAULT_CHART_METRICS,
METRIC_CONFIG,
METRIC_OPTIONS,
presetDateRange,
toggleChartMetric,
UsageTrendChart,
type UsageChartMetric,
type UsageChartType,
} from '../../components/UsageTrendChart';
import { useAdminUsers } from '../hooks/useAdminUsers';
import { formatTime, formatYuan } from '../utils/format';
@@ -111,6 +125,22 @@ const TABS: { key: TabKey; label: string }[] = [
{ key: 'ledger', label: '资金流水' },
];
const TAB_PATHS: Record<TabKey, string> = {
subscriptions: '/billing',
recharge: '/billing/recharge',
usage: '/billing/usage',
ledger: '/billing/ledger',
};
function tabFromPath(pathname: string): TabKey {
const suffix = pathname.replace(/^\/billing\/?/, '');
if (suffix === 'usage' || suffix.startsWith('usage/')) return 'usage';
if (suffix === 'recharge') return 'recharge';
if (suffix === 'ledger') return 'ledger';
if (suffix === 'subscriptions') return 'subscriptions';
return 'subscriptions';
}
const PAGE_SIZE = 20;
function RechargeTab() {
@@ -161,21 +191,416 @@ function RechargeTab() {
);
}
function UsageTab() {
const { users } = useAdminUsers();
function UsageRecordsTable({
result,
onPage,
}: {
result: PagedResult<UsageRecord>;
onPage: (page: number) => void;
}) {
return (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}></th>
<th style={{ textAlign: 'right' }}></th>
</tr>
</thead>
<tbody>
{result.items.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>
<span>{row.displayName || row.username}</span>
<span className="muted"> @{row.username}</span>
</td>
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={result.page}
totalPages={result.totalPages}
total={result.total}
pageSize={result.pageSize}
onChange={onPage}
/>
</>
);
}
function UsageTotalsGrid({ totals }: { totals: UsageTotals }) {
const totalTokens = totals.inputTokens + totals.outputTokens;
return (
<div className="admin-stat-grid usage-summary-grid">
<div className="admin-stat-card">
<div className="admin-stat-label"></div>
<div className="admin-stat-value">{totals.count.toLocaleString()}</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">Token </div>
<div className="admin-stat-value">{totalTokens.toLocaleString()}</div>
<div className="muted usage-summary-sub">
{totals.inputTokens.toLocaleString()} / {totals.outputTokens.toLocaleString()}
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label"></div>
<div className="admin-stat-value">¥{formatYuan(totals.costCents)}</div>
</div>
</div>
);
}
function UsageSummaryPanel({
summary,
rangeLabel,
allTimeLabel = '累计(不限区间)',
loading,
}: {
summary: UsageSummaryResult | null;
rangeLabel: string;
allTimeLabel?: string;
loading?: boolean;
}) {
if (loading && !summary) {
return <p className="muted usage-summary-loading"></p>;
}
if (!summary) return null;
return (
<div className="usage-summary-panel">
{summary.range && (
<section className="usage-summary-block">
<h4 className="usage-summary-title">{rangeLabel}</h4>
<UsageTotalsGrid totals={summary.range} />
</section>
)}
<section className="usage-summary-block">
<h4 className="usage-summary-title">{allTimeLabel}</h4>
<UsageTotalsGrid totals={summary.allTime} />
</section>
</div>
);
}
function UsageChartPanel({
stats,
chartMetrics,
chartType,
onMetricsChange,
onChartTypeChange,
title = '用量趋势',
hint,
}: {
stats: UsageStatsResult;
chartMetrics: UsageChartMetric[];
chartType: UsageChartType;
onMetricsChange: (metrics: UsageChartMetric[]) => void;
onChartTypeChange: (type: UsageChartType) => void;
title?: string;
hint?: string;
}) {
return (
<div className="usage-chart-panel">
<div className="usage-chart-head">
<div>
<h3>{title}</h3>
{hint && <p className="muted usage-chart-hint">{hint}</p>}
</div>
<div className="usage-chart-controls">
<div className="usage-metric-checks" role="group" aria-label="图表指标">
{METRIC_OPTIONS.map(({ key, label }) => {
const checked = chartMetrics.includes(key);
const color = METRIC_CONFIG[key].color;
return (
<label key={key} className={`usage-metric-check${checked ? ' checked' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => onMetricsChange(toggleChartMetric(chartMetrics, key))}
/>
<span className="usage-metric-check-dot" style={{ background: color }} />
{label}
</label>
);
})}
</div>
<div className="usage-chart-type-toggle" role="group" aria-label="图表类型">
<button
type="button"
className={`usage-chart-type-btn${chartType === 'bar' ? ' active' : ''}`}
onClick={() => onChartTypeChange('bar')}
>
</button>
<button
type="button"
className={`usage-chart-type-btn${chartType === 'line' ? ' active' : ''}`}
onClick={() => onChartTypeChange('line')}
>
线
</button>
</div>
</div>
</div>
<UsageTrendChart
buckets={stats.buckets}
startAt={stats.startAt}
endAt={stats.endAt}
metrics={chartMetrics}
chartType={chartType}
/>
</div>
);
}
type UsageView = 'overview' | 'query';
function UsageOverviewPanel() {
const [result, setResult] = useState<PagedResult<UsageRecord> | null>(null);
const [stats, setStats] = useState<UsageStatsResult | null>(null);
const [summary, setSummary] = useState<UsageSummaryResult | null>(null);
const [loadingTable, setLoadingTable] = useState(false);
const [loadingChart, setLoadingChart] = useState(false);
const [loadingSummary, setLoadingSummary] = useState(false);
const [error, setError] = useState<string | null>(null);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [chartMetrics, setChartMetrics] = useState<UsageChartMetric[]>(DEFAULT_CHART_METRICS);
const [chartType, setChartType] = useState<UsageChartType>('bar');
const hasDateFilter = Boolean(startDate && endDate && startDate <= endDate);
const loadTable = useCallback(async (page: number, start: string, end: string) => {
if (start && end && start > end) {
setError('请选择有效的时间范围');
return;
}
setLoadingTable(true);
setError(null);
try {
const params: { page: number; pageSize: number; startAt?: number; endAt?: number } = {
page,
pageSize: PAGE_SIZE,
};
if (start && end) {
const range = dateRangeToUnix(start, end);
params.startAt = range.startAt;
params.endAt = range.endAt;
}
const data = await listAdminUsage(params);
setResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoadingTable(false);
}
}, []);
const loadChart = useCallback(async (start: string, end: string) => {
if (start && end && start > end) return;
setLoadingChart(true);
setError(null);
try {
const chartRange = start && end
? { startDate: start, endDate: end }
: presetDateRange(30);
const { startAt, endAt } = dateRangeToUnix(chartRange.startDate, chartRange.endDate);
const statsData = await getAdminUsageStats({ startAt, endAt });
setStats(statsData);
} catch (err) {
setError(err instanceof Error ? err.message : '图表加载失败');
} finally {
setLoadingChart(false);
}
}, []);
const loadSummary = useCallback(async (start: string, end: string) => {
if (start && end && start > end) return;
setLoadingSummary(true);
setError(null);
try {
const chartRange = start && end
? { startDate: start, endDate: end }
: presetDateRange(30);
const { startAt, endAt } = dateRangeToUnix(chartRange.startDate, chartRange.endDate);
const data = await getAdminUsageSummary({ startAt, endAt });
setSummary(data);
} catch (err) {
setError(err instanceof Error ? err.message : '汇总加载失败');
} finally {
setLoadingSummary(false);
}
}, []);
useEffect(() => {
void loadTable(1, startDate, endDate);
}, [startDate, endDate, loadTable]);
useEffect(() => {
void loadChart(startDate, endDate);
}, [startDate, endDate, loadChart]);
useEffect(() => {
void loadSummary(startDate, endDate);
}, [startDate, endDate, loadSummary]);
const applyPreset = (days: number) => {
const range = presetDateRange(days);
setStartDate(range.startDate);
setEndDate(range.endDate);
};
const clearDateFilter = () => {
setStartDate('');
setEndDate('');
};
const handlePage = (page: number) => void loadTable(page, startDate, endDate);
const chartHint = hasDateFilter
? `${startDate}${endDate} 全平台用量(列表与图表同步筛选)`
: '近 30 天全平台用量(列表展示全部记录;设置日期后列表与图表同步筛选)';
const rangeSummaryLabel = hasDateFilter
? `区间汇总(${startDate}${endDate}`
: '区间汇总(近 30 天)';
return (
<>
{error && <p className="banner banner-error">{error}</p>}
<div className="usage-overview-filter">
<span className="muted usage-overview-chart-label"></span>
<div className="usage-date-range">
<input
type="date"
className="billing-filter-select usage-date-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<span className="muted usage-date-sep"></span>
<input
type="date"
className="billing-filter-select usage-date-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<div className="usage-preset-btns">
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(7)}>7</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(30)}>30</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(90)}>90</button>
<button
type="button"
className={`admin-btn-secondary usage-preset-btn${!hasDateFilter ? ' active' : ''}`}
onClick={clearDateFilter}
>
</button>
</div>
</div>
<UsageSummaryPanel
summary={summary}
rangeLabel={rangeSummaryLabel}
allTimeLabel="累计汇总(不限区间 · 全平台)"
loading={loadingSummary}
/>
{loadingChart && !stats && <p className="muted"></p>}
{!loadingChart && stats && (
<UsageChartPanel
stats={stats}
chartMetrics={chartMetrics}
chartType={chartType}
onMetricsChange={setChartMetrics}
onChartTypeChange={setChartType}
title="用量统计"
hint={chartHint}
/>
)}
{loadingTable && !result && <p className="muted"></p>}
{!loadingTable && result && (
result.items.length === 0 ? (
<p className="muted billing-empty"></p>
) : (
<UsageRecordsTable result={result} onPage={handlePage} />
)
)}
</>
);
}
function UsageQueryPanel() {
const { users } = useAdminUsers();
const defaultRange = presetDateRange(30);
const [result, setResult] = useState<PagedResult<UsageRecord> | null>(null);
const [stats, setStats] = useState<UsageStatsResult | null>(null);
const [summary, setSummary] = useState<UsageSummaryResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filterUserId, setFilterUserId] = useState('');
const [pendingUserId, setPendingUserId] = useState('');
const [userId, setUserId] = useState('');
const [startDate, setStartDate] = useState(defaultRange.startDate);
const [endDate, setEndDate] = useState(defaultRange.endDate);
const [chartMetrics, setChartMetrics] = useState<UsageChartMetric[]>(DEFAULT_CHART_METRICS);
const [chartType, setChartType] = useState<UsageChartType>('bar');
const load = useCallback(async (p: number, userId: string) => {
const hasDateFilter = Boolean(startDate && endDate && startDate <= endDate);
const load = useCallback(async (p: number, uid: string, start: string, end: string, withChart: boolean) => {
if (!uid) return;
if (start && end && start > end) {
setError('请选择有效的时间范围');
return;
}
if ((start && !end) || (!start && end)) {
setError('请完整选择起止日期');
return;
}
const filtered = Boolean(start && end);
setLoading(true);
setError(null);
try {
const data = await listAdminUsage({ userId: userId || undefined, page: p, pageSize: PAGE_SIZE });
const listParams: { userId: string; page: number; pageSize: number; startAt?: number; endAt?: number } = {
userId: uid,
page: p,
pageSize: PAGE_SIZE,
};
let statsPromise: Promise<UsageStatsResult | null> = Promise.resolve(null);
let summaryPromise: Promise<UsageSummaryResult | null> = Promise.resolve(null);
if (filtered) {
const { startAt, endAt } = dateRangeToUnix(start, end);
listParams.startAt = startAt;
listParams.endAt = endAt;
if (withChart) {
statsPromise = getAdminUsageStats({ userId: uid, startAt, endAt });
summaryPromise = getAdminUsageSummary({ userId: uid, startAt, endAt });
}
} else if (withChart) {
const endAt = Math.floor(Date.now() / 1000);
statsPromise = getAdminUsageStats({ userId: uid, startAt: 1, endAt });
summaryPromise = getAdminUsageSummary({ userId: uid });
}
const [data, statsData, summaryData] = await Promise.all([
listAdminUsage(listParams),
statsPromise,
summaryPromise,
]);
setResult(data);
setFilterUserId(userId);
if (statsData) setStats(statsData);
if (summaryData) setSummary(summaryData);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
@@ -183,71 +608,155 @@ function UsageTab() {
}
}, []);
useEffect(() => { void load(1, ''); }, [load]);
useEffect(() => {
if (!userId) {
setResult(null);
setStats(null);
setSummary(null);
setError(null);
return;
}
void load(1, userId, startDate, endDate, true);
}, [userId, startDate, endDate, load]);
const handleQuery = () => void load(1, pendingUserId);
const handlePage = (p: number) => void load(p, filterUserId);
const applyPreset = (days: number) => {
const range = presetDateRange(days);
setStartDate(range.startDate);
setEndDate(range.endDate);
};
const clearDateFilter = () => {
setStartDate('');
setEndDate('');
};
const handlePage = (p: number) => void load(p, userId, startDate, endDate, false);
const selectedUser = users.find((u) => u.id === userId);
const chartHint = hasDateFilter
? `${startDate}${endDate}${selectedUser ? ` · ${selectedUser.displayName}` : ''}`
: `全部日期${selectedUser ? ` · ${selectedUser.displayName}` : ''}`;
const rangeSummaryLabel = hasDateFilter
? `区间汇总(${startDate}${endDate}${selectedUser ? ` · ${selectedUser.displayName}` : ''}`
: '';
const allTimeSummaryLabel = selectedUser
? `累计汇总(${selectedUser.displayName} · 全部日期)`
: '累计汇总(全部日期)';
return (
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<div className="billing-toolbar">
<UserCombobox
users={users}
value={pendingUserId}
onChange={setPendingUserId}
placeholder="全部用户"
<>
<div className="billing-toolbar usage-toolbar usage-query-toolbar">
<UserCombobox
users={users}
value={userId}
onChange={setUserId}
placeholder="选择用户"
/>
<div className="usage-date-range">
<input
type="date"
className="billing-filter-select usage-date-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
disabled={!userId}
/>
<button type="button" className="send-btn billing-query-btn" onClick={handleQuery} disabled={loading}>
<span className="muted usage-date-sep"></span>
<input
type="date"
className="billing-filter-select usage-date-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
disabled={!userId}
/>
</div>
<div className="usage-preset-btns">
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(7)}>7</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(30)}>30</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(90)}>90</button>
<button
type="button"
className={`admin-btn-secondary usage-preset-btn${userId && !hasDateFilter ? ' active' : ''}`}
disabled={!userId}
onClick={clearDateFilter}
>
</button>
</div>
</div>
{error && <p className="banner banner-error">{error}</p>}
{!result && !loading && (
<p className="muted billing-empty"></p>
{!userId && !loading && (
<p className="muted billing-empty"></p>
)}
{loading && <p className="muted"></p>}
{!loading && result && (
{userId && loading && !result && !stats && <p className="muted"></p>}
{userId && (
<UsageSummaryPanel
summary={summary}
rangeLabel={rangeSummaryLabel}
allTimeLabel={allTimeSummaryLabel}
loading={loading && !summary}
/>
)}
{userId && !loading && stats && (
<UsageChartPanel
stats={stats}
chartMetrics={chartMetrics}
chartType={chartType}
onMetricsChange={setChartMetrics}
onChartTypeChange={setChartType}
title="用户用量趋势"
hint={chartHint}
/>
)}
{userId && !loading && result && (
result.items.length === 0 ? (
<p className="muted billing-empty"></p>
<p className="muted billing-empty">{hasDateFilter ? '该时间范围内暂无记录' : '该用户暂无用量记录'}</p>
) : (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}></th>
<th style={{ textAlign: 'right' }}></th>
</tr>
</thead>
<tbody>
{result.items.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>
<span>{row.displayName || row.username}</span>
<span className="muted"> @{row.username}</span>
</td>
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={result.page} totalPages={result.totalPages} total={result.total}
pageSize={result.pageSize} onChange={handlePage} />
</>
<UsageRecordsTable result={result} onPage={handlePage} />
)
)}
</>
);
}
function UsageTab() {
const location = useLocation();
const navigate = useNavigate();
const view: UsageView = location.pathname.endsWith('/query') ? 'query' : 'overview';
const setView = (next: UsageView) => {
navigate(next === 'query' ? '/billing/usage/query' : '/billing/usage');
};
return (
<section className="admin-card">
<div className="admin-card-head usage-tab-head">
<h2></h2>
<div className="usage-view-switch" role="tablist" aria-label="用量记录视图">
<button
type="button"
role="tab"
aria-selected={view === 'overview'}
className={`usage-view-btn${view === 'overview' ? ' active' : ''}`}
onClick={() => setView('overview')}
>
</button>
<button
type="button"
role="tab"
aria-selected={view === 'query'}
className={`usage-view-btn${view === 'query' ? ' active' : ''}`}
onClick={() => setView('query')}
>
</button>
</div>
</div>
<div className="usage-tab-body">
{view === 'overview' ? <UsageOverviewPanel /> : <UsageQueryPanel />}
</div>
</section>
);
}
@@ -299,7 +808,7 @@ function LedgerTab() {
</div>
{error && <p className="banner banner-error">{error}</p>}
{!result && !loading && (
<p className="muted billing-empty"></p>
<p className="muted billing-empty"></p>
)}
{loading && <p className="muted"></p>}
{!loading && result && (
@@ -925,7 +1434,9 @@ function SubscriptionsTab() {
}
export function BillingPage() {
const [activeTab, setActiveTab] = useState<TabKey>('subscriptions');
const location = useLocation();
const navigate = useNavigate();
const activeTab = tabFromPath(location.pathname);
return (
<div className="admin-page">
@@ -937,7 +1448,7 @@ export function BillingPage() {
{TABS.map((tab) => (
<button key={tab.key} type="button" role="tab" aria-selected={activeTab === tab.key}
className={`admin-tab${activeTab === tab.key ? ' active' : ''}`}
onClick={() => setActiveTab(tab.key)}>
onClick={() => navigate(TAB_PATHS[tab.key])}>
{tab.label}
</button>
))}