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:
+574
-63
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -35,12 +35,17 @@ import type {
|
||||
MemoryV2AdminConfigResponse,
|
||||
MemoryV2ModelApiType,
|
||||
MemoryV2RuntimeStatusResponse,
|
||||
MemoryV2ConfigRuntimeState,
|
||||
SkillRuntimeAdminConfig,
|
||||
SkillRuntimeAdminConfigResponse,
|
||||
SkillRuntimeCatalogItem,
|
||||
SkillDefinition,
|
||||
SkillMap,
|
||||
UsageRecord,
|
||||
UsageStatsBucket,
|
||||
UsageStatsResult,
|
||||
UsageSummaryResult,
|
||||
UsageTotals,
|
||||
WechatAdminSummary,
|
||||
WechatBinding,
|
||||
WechatDeliveryLog,
|
||||
@@ -229,11 +234,15 @@ export async function listAdminUsage(params?: {
|
||||
userId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
startAt?: number;
|
||||
endAt?: number;
|
||||
}): Promise<PagedResult<UsageRecord>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.userId) q.set('userId', params.userId);
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.pageSize) q.set('pageSize', String(params.pageSize));
|
||||
if (params?.startAt) q.set('startAt', String(params.startAt));
|
||||
if (params?.endAt) q.set('endAt', String(params.endAt));
|
||||
const result = await portalFetch<{ records: UsageRecord[]; total: number; page: number; pageSize: number }>(
|
||||
`/admin-api/usage${q.toString() ? `?${q}` : ''}`,
|
||||
);
|
||||
@@ -242,6 +251,146 @@ export async function listAdminUsage(params?: {
|
||||
return { items: result.records ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) };
|
||||
}
|
||||
|
||||
function toMillis(ts: number) {
|
||||
return ts >= 1_000_000_000_000 ? ts : ts * 1000;
|
||||
}
|
||||
|
||||
function usageDayKey(createdAt: number) {
|
||||
const d = new Date(toMillis(createdAt));
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
async function aggregateUsageStatsFromRecords(params: {
|
||||
userId?: string;
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
}): Promise<UsageStatsResult> {
|
||||
const bucketMap = new Map<string, UsageStatsBucket>();
|
||||
let page = 1;
|
||||
const pageSize = 200;
|
||||
const maxPages = 50;
|
||||
|
||||
const startMs = params.startAt * 1000;
|
||||
const endMs = params.endAt * 1000 + 999;
|
||||
|
||||
while (page <= maxPages) {
|
||||
const result = await listAdminUsage({
|
||||
userId: params.userId,
|
||||
page,
|
||||
pageSize,
|
||||
startAt: params.startAt,
|
||||
endAt: params.endAt,
|
||||
});
|
||||
for (const record of result.items) {
|
||||
const createdMs = toMillis(record.createdAt);
|
||||
if (createdMs < startMs || createdMs > endMs) continue;
|
||||
const day = usageDayKey(record.createdAt);
|
||||
const bucket = bucketMap.get(day) ?? {
|
||||
day,
|
||||
count: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
costCents: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.inputTokens += record.inputTokens;
|
||||
bucket.outputTokens += record.outputTokens;
|
||||
bucket.costCents += record.costCents;
|
||||
bucketMap.set(day, bucket);
|
||||
}
|
||||
if (page >= result.totalPages || result.items.length === 0) break;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
buckets: [...bucketMap.values()].sort((a, b) => a.day.localeCompare(b.day)),
|
||||
startAt: params.startAt,
|
||||
endAt: params.endAt,
|
||||
};
|
||||
}
|
||||
|
||||
function sumUsageBuckets(buckets: UsageStatsBucket[]): UsageTotals {
|
||||
return buckets.reduce(
|
||||
(acc, b) => ({
|
||||
count: acc.count + b.count,
|
||||
inputTokens: acc.inputTokens + b.inputTokens,
|
||||
outputTokens: acc.outputTokens + b.outputTokens,
|
||||
costCents: acc.costCents + b.costCents,
|
||||
}),
|
||||
{ count: 0, inputTokens: 0, outputTokens: 0, costCents: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAdminUsageSummary(params?: {
|
||||
userId?: string;
|
||||
startAt?: number;
|
||||
endAt?: number;
|
||||
}): Promise<UsageSummaryResult> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.userId) q.set('userId', params.userId);
|
||||
if (params?.startAt) q.set('startAt', String(params.startAt));
|
||||
if (params?.endAt) q.set('endAt', String(params.endAt));
|
||||
const suffix = q.toString() ? `?${q}` : '';
|
||||
try {
|
||||
return await portalFetch<UsageSummaryResult>(`/admin-api/usage/summary${suffix}`);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError) || err.status !== 404) throw err;
|
||||
}
|
||||
const allTime = sumUsageBuckets(
|
||||
(await aggregateUsageStatsFromRecords({
|
||||
userId: params?.userId,
|
||||
startAt: 0,
|
||||
endAt: Math.floor(Date.now() / 1000),
|
||||
})).buckets,
|
||||
);
|
||||
if (!params?.startAt || !params?.endAt) {
|
||||
return { range: null, allTime };
|
||||
}
|
||||
const rangeRows = await getAdminUsageStats({
|
||||
userId: params.userId,
|
||||
startAt: params.startAt,
|
||||
endAt: params.endAt,
|
||||
});
|
||||
return {
|
||||
range: {
|
||||
...sumUsageBuckets(rangeRows.buckets),
|
||||
startAt: params.startAt,
|
||||
endAt: params.endAt,
|
||||
},
|
||||
allTime,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAdminUsageStats(params: {
|
||||
userId?: string;
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
}): Promise<UsageStatsResult> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.userId) q.set('userId', params.userId);
|
||||
q.set('startAt', String(params.startAt));
|
||||
q.set('endAt', String(params.endAt));
|
||||
|
||||
const attempts = [
|
||||
`/admin-api/usage/stats?${q}`,
|
||||
`/admin-api/usage?${q}&stats=1`,
|
||||
];
|
||||
|
||||
for (const path of attempts) {
|
||||
try {
|
||||
return await portalFetch<UsageStatsResult>(path);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return aggregateUsageStatsFromRecords(params);
|
||||
}
|
||||
|
||||
export async function listAdminLedger(params?: {
|
||||
userId?: string;
|
||||
page?: number;
|
||||
@@ -359,6 +508,10 @@ export async function getMemoryV2RuntimeStatus(): Promise<MemoryV2RuntimeStatusR
|
||||
return portalFetch('/admin-api/memory-v2/status');
|
||||
}
|
||||
|
||||
export async function getMemoryV2ConfigRuntime(): Promise<MemoryV2ConfigRuntimeState> {
|
||||
return portalFetch('/admin-api/memory-v2/runtime');
|
||||
}
|
||||
|
||||
export async function listPersonalMemoryCandidates(
|
||||
status = 'candidate',
|
||||
): Promise<PersonalMemoryCandidateListResponse> {
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
+268
@@ -2097,6 +2097,274 @@ body,
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.usage-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.usage-tab-head {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.usage-view-switch {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.usage-view-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.usage-view-btn.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.usage-tab-body {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.usage-query-toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.usage-summary-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.usage-summary-block {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.usage-summary-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.usage-summary-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.usage-summary-grid .admin-stat-card {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.usage-summary-grid .admin-stat-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.usage-summary-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.usage-summary-loading {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.usage-overview-filter,
|
||||
.usage-overview-chart-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.usage-overview-chart-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.usage-chart-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.usage-preset-btn.active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.usage-date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.usage-date-input {
|
||||
width: 138px;
|
||||
}
|
||||
|
||||
.usage-date-sep {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.usage-preset-btns {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.usage-preset-btn {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.usage-chart-panel {
|
||||
margin: 16px 0 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.usage-chart-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.usage-chart-head h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.usage-chart-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.usage-metric-checks {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.usage-metric-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.usage-metric-check.checked {
|
||||
border-color: var(--color-border-subtle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.usage-metric-check input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.usage-metric-check-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.usage-chart-tooltip-metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.usage-chart-tooltip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.usage-chart-tooltip-detail {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.usage-chart-select {
|
||||
min-width: 120px;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.usage-chart-type-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.usage-chart-type-btn {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.usage-chart-type-btn.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.usage-chart-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.usage-chart-empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.usage-chart-tooltip {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.usage-chart-tooltip-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.text-income {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
@@ -247,6 +247,37 @@ export type UsageRecord = {
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type UsageStatsBucket = {
|
||||
day: string;
|
||||
count: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costCents: number;
|
||||
};
|
||||
|
||||
export type UsageTotals = {
|
||||
count: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costCents: number;
|
||||
};
|
||||
|
||||
export type UsageRangeTotals = UsageTotals & {
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
};
|
||||
|
||||
export type UsageSummaryResult = {
|
||||
range: UsageRangeTotals | null;
|
||||
allTime: UsageTotals;
|
||||
};
|
||||
|
||||
export type UsageStatsResult = {
|
||||
buckets: UsageStatsBucket[];
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
};
|
||||
|
||||
export type LedgerEntry = {
|
||||
id: number;
|
||||
userId: string;
|
||||
@@ -343,6 +374,14 @@ export type MemoryV2AdminConfigResponse = {
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type MemoryV2ConfigRuntimeState = {
|
||||
source: string;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
fingerprint?: string;
|
||||
overrides: Record<string, string>;
|
||||
};
|
||||
|
||||
export type MemoryV2RuntimeStatusResponse = {
|
||||
ok: boolean;
|
||||
checkedAt?: number;
|
||||
|
||||
Reference in New Issue
Block a user