Files
Genarrative/apps/admin-web/src/pages/AdminDashboardPage.tsx
T
kdletters 0dbd279dfc
Project CI / AI game creator shell Rust crates (push) Successful in 1m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / Backend tests (push) Successful in 5m16s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m42s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m21s
Project CI / Native shell tests (push) Successful in 6m43s
Project CI / Frontend tests (push) Successful in 2m22s
Project CI / Repository checks (push) Successful in 2m45s
Project CI / AI game creator shell web tests (push) Successful in 2m7s
后台 Dashboard 消耗泥点改为对冲退还后的净消耗
- consumedMudPoints 改为净额:按北京时间业务日先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的丢弃,每日净额非负且区间合计严格等于「消耗 − 退还」
- 退还口径并入 asset_operation_refund 正向流水(生成失败退还、精选审核返还)与 llm_router_consume 正向冲正流水;充值退款追回、余额重置、赠送和 hold 仍不计入
- 契约新增 refundedMudPoints,后端新增「退还泥点」趋势图,后台时段数据并列「退还泥点数」卡,毛消耗可由「消耗 + 退还」核出
- 新增 4 条净额单测(同日对冲 / 回溯抵扣 / 超额归零 / 无退还保原值)与 Dashboard 用例断言
- 同步 Dashboard 运营看板方案指标口径与决策记录
- 验证:cargo test -p api-server --bin api-server admin(136 passed / 0 failed / 1 ignored)、npm run admin-web:typecheck、npx vitest run apps/admin-web/src(218 passed)、cargo fmt --all --check、npm run check:encoding、npm run check:doc-index、git diff --check 全部通过
2026-09-23 19:56:24 +08:00

689 lines
19 KiB
TypeScript

import { RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAdminDashboard } from '../api/adminApiClient';
import type {
AdminDashboardBreakdownRowPayload,
AdminDashboardChartPayload,
AdminDashboardGranularity,
AdminDashboardResponse,
} from '../api/adminApiTypes';
import { handlePageError } from './pageUtils';
interface AdminDashboardPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
type AdminDashboardTab = 'metrics' | 'operations';
const granularityOptions: Array<{
id: Exclude<AdminDashboardGranularity, 'period'>;
label: string;
}> = [
{ id: 'day', label: '本日' },
{ id: 'week', label: '本周' },
{ id: 'month', label: '本月' },
];
export function AdminDashboardPage({
token,
onUnauthorized,
}: AdminDashboardPageProps) {
const [dashboard, setDashboard] = useState<AdminDashboardResponse | null>(
null,
);
const [granularity, setGranularity] =
useState<AdminDashboardGranularity>('day');
const [dateRange, setDateRange] = useState(() => {
const today = formatBeijingDateInput(new Date());
return { startDate: today, endDate: today };
});
const [activeTab, setActiveTab] = useState<AdminDashboardTab>('metrics');
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [chartScrollLeft, setChartScrollLeft] = useState(0);
const today = formatBeijingDateInput(new Date());
const loadDashboard = useCallback(
async (range = dateRange) => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminDashboard(token, {
granularity: 'period',
anchor: undefined,
startDate: range.startDate,
endDate: range.endDate,
});
setDashboard(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
},
[dateRange, onUnauthorized, token],
);
useEffect(() => {
void loadDashboard();
}, [loadDashboard]);
useEffect(() => {
const timer = window.setInterval(
() => {
void loadDashboard();
},
5 * 60 * 1000,
);
return () => window.clearInterval(timer);
}, [loadDashboard]);
useEffect(() => {
setChartScrollLeft(0);
}, [dashboard?.range.periodEndDate, dashboard?.range.periodStartDate]);
const metrics = dashboard?.metrics;
const totalMetricCards = useMemo(
() => [
{
id: 'total-registered-users',
label: '总注册用户',
value: metrics?.totalRegisteredUsers ?? 0,
unit: '人',
},
{
id: 'total-visit-users',
label: '总访问人数',
value: metrics?.totalVisitUsers ?? 0,
unit: '人',
},
{
id: 'total-visit-count',
label: '总访问次数',
value: metrics?.totalVisitCount ?? 0,
unit: '次',
},
{
id: 'current-users',
label: '近 5 分钟活跃用户',
value: metrics?.currentUsers ?? 0,
unit: '人',
},
],
[metrics],
);
const periodMetricCards = useMemo(
() => [
{
id: 'generated-assets',
label: `${rangePrefix(granularity)}生产素材数`,
value: metrics?.generatedAssets ?? 0,
unit: '个',
},
{
id: 'consumed-mud-points',
label: `${rangePrefix(granularity)}消耗泥点数`,
value: metrics?.consumedMudPoints ?? 0,
unit: '泥点',
},
{
id: 'refunded-mud-points',
label: `${rangePrefix(granularity)}退还泥点数`,
value: metrics?.refundedMudPoints ?? 0,
unit: '泥点',
},
{
id: 'new-registered-users',
label: `${rangePrefix(granularity)}新增用户数`,
value: metrics?.newRegisteredUsers ?? 0,
unit: '人',
},
{
id: 'visit-users',
label: `${rangePrefix(granularity)}访问人数`,
value: metrics?.visitUsers ?? 0,
unit: '人',
},
{
id: 'visit-count',
label: `${rangePrefix(granularity)}访问次数`,
value: metrics?.visitCount ?? 0,
unit: '次',
},
],
[granularity, metrics],
);
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading admin-dashboard-heading">
<div>
<h2>Dashboard</h2>
<p>{dashboard?.range.periodLabel ?? '运营指标'}</p>
</div>
<div className="admin-dashboard-actions">
<div className="admin-dashboard-period-fields">
<label className="admin-field admin-dashboard-date-field">
<span>起始日期</span>
<input
type="date"
max={today}
value={dateRange.startDate}
onChange={(event) =>
handleDateRangeChange('startDate', event.target.value)
}
/>
</label>
<label className="admin-field admin-dashboard-date-field">
<span>终止日期</span>
<input
type="date"
max={today}
value={dateRange.endDate}
onChange={(event) =>
handleDateRangeChange('endDate', event.target.value)
}
/>
</label>
</div>
<div
className="admin-segmented-control admin-dashboard-granularity"
role="tablist"
aria-label="统计周期"
>
{granularityOptions.map((option) => (
<button
key={option.id}
type="button"
data-active={option.id === granularity}
onClick={() => handleGranularityChange(option.id)}
>
{option.label}
</button>
))}
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={() => void loadDashboard()}
>
<RefreshCw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
</div>
<div
className="admin-dashboard-tabs"
role="tablist"
aria-label="Dashboard"
>
<button
type="button"
data-active={activeTab === 'metrics'}
onClick={() => setActiveTab('metrics')}
>
数据看板
</button>
<button
type="button"
data-active={activeTab === 'operations'}
onClick={() => setActiveTab('operations')}
>
运营汇总
</button>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
{dashboard?.warnings.length ? (
<section className="admin-panel admin-panel-warning">
<div className="admin-panel-heading">
<h3>读取告警</h3>
<span>{dashboard.warnings.length}</span>
</div>
<ul className="admin-error-list">
{dashboard.warnings.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
</section>
) : null}
{activeTab === 'metrics' ? (
<>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>总计数据</h3>
<span>全站累计</span>
</div>
<div className="admin-dashboard-metric-grid">
{totalMetricCards.map((metric) => (
<MetricCard
key={metric.id}
label={metric.label}
value={metric.value}
unit={metric.unit}
compact
/>
))}
</div>
</section>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>时段数据</h3>
<span>{dashboard?.range.periodLabel ?? '-'}</span>
</div>
<div className="admin-dashboard-metric-grid">
{periodMetricCards.map((metric) => (
<MetricCard
key={metric.id}
label={metric.label}
value={metric.value}
unit={metric.unit}
compact
/>
))}
</div>
</section>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>新增用户转化与留存</h3>
<span>{dashboard?.range.periodLabel ?? '-'}</span>
</div>
<div className="admin-dashboard-retention-grid">
<RateCard
label={`${rangePrefix(granularity)}新增用户付费率`}
numeratorLabel="付费人数"
denominatorLabel="新增人数"
numerator={metrics?.newUserPaymentConversion?.paidUsers}
denominator={
metrics?.newUserPaymentConversion?.newRegisteredUsers
}
rateBasisPoints={
metrics?.newUserPaymentConversion?.rateBasisPoints
}
/>
<RateCard
label="次日留存"
numeratorLabel="留存人数"
denominatorLabel="可观察新增人数"
numerator={metrics?.day1Retention.retainedUsers}
denominator={metrics?.day1Retention.eligibleUsers}
rateBasisPoints={metrics?.day1Retention.rateBasisPoints}
/>
<RateCard
label="七日留存"
numeratorLabel="留存人数"
denominatorLabel="可观察新增人数"
numerator={metrics?.day7Retention.retainedUsers}
denominator={metrics?.day7Retention.eligibleUsers}
rateBasisPoints={metrics?.day7Retention.rateBasisPoints}
/>
</div>
</section>
<div className="admin-dashboard-chart-grid">
{(dashboard?.charts ?? []).map((chart) => (
<ChartPanel
key={chart.id}
chart={chart}
scrollLeft={chartScrollLeft}
onScrollLeftChange={setChartScrollLeft}
/>
))}
{dashboard && dashboard.charts.length === 0 ? (
<div className="admin-empty-state">暂无图表</div>
) : null}
</div>
</>
) : (
<div className="admin-dashboard-operations">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>运营指标</h3>
<span>{dashboard?.range.periodLabel ?? '-'}</span>
</div>
<div className="admin-dashboard-operation-grid">
{(dashboard?.operations.cards ?? []).map((card) => (
<MetricCard
key={card.id}
label={card.label}
value={card.value}
unit={card.unit}
compact
/>
))}
</div>
</section>
<BreakdownPanel
title="素材类型分布"
rows={dashboard?.operations.assetKindBreakdown ?? []}
unit="个"
/>
<BreakdownPanel
title="访问模块分布"
rows={dashboard?.operations.moduleVisitBreakdown ?? []}
unit="次"
/>
</div>
)}
</section>
);
function handleGranularityChange(
nextGranularity: Exclude<AdminDashboardGranularity, 'period'>,
) {
const nextRange = buildPresetDateRange(
nextGranularity,
formatBeijingDateInput(new Date()),
);
const rangeChanged =
nextRange.startDate !== dateRange.startDate ||
nextRange.endDate !== dateRange.endDate;
setGranularity(nextGranularity);
if (rangeChanged) {
setDateRange(nextRange);
} else {
void loadDashboard(nextRange);
}
}
function handleDateRangeChange(
field: 'startDate' | 'endDate',
value: string,
) {
if (!parseDateValueAsUtc(value)) {
return;
}
const clampedValue = value > today ? today : value;
setGranularity('period');
setDateRange((current) =>
normalizeDateRange({ ...current, [field]: clampedValue }),
);
}
}
function MetricCard({
label,
value,
unit,
compact = false,
}: {
label: string;
value: number;
unit: string;
compact?: boolean;
}) {
return (
<section
className="admin-panel admin-dashboard-metric-card"
data-compact={compact}
>
<span>{label}</span>
<strong>{formatNumber(value)}</strong>
<small>{unit}</small>
</section>
);
}
function RateCard({
label,
numeratorLabel,
denominatorLabel,
numerator,
denominator,
rateBasisPoints,
}: {
label: string;
numeratorLabel: string;
denominatorLabel: string;
numerator?: number;
denominator?: number;
rateBasisPoints?: number;
}) {
const hasDenominator = Boolean(denominator);
return (
<article className="admin-dashboard-retention-card">
<span>{label}</span>
<strong>
{hasDenominator && rateBasisPoints !== undefined
? formatRateBasisPoints(rateBasisPoints)
: '-'}
</strong>
<div>
<small>
{numeratorLabel} / {denominatorLabel}
</small>
<b>
{numerator !== undefined && denominator !== undefined
? `${formatNumber(numerator)} / ${formatNumber(denominator)} 人`
: '-'}
</b>
</div>
</article>
);
}
function ChartPanel({
chart,
scrollLeft,
onScrollLeftChange,
}: {
chart: AdminDashboardChartPayload;
scrollLeft: number;
onScrollLeftChange: (scrollLeft: number) => void;
}) {
const barsRef = useRef<HTMLDivElement>(null);
const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value));
useEffect(() => {
const element = barsRef.current;
if (!element) {
return;
}
if (Math.abs(element.scrollLeft - scrollLeft) > 1) {
element.scrollLeft = scrollLeft;
}
}, [chart.buckets.length, scrollLeft]);
return (
<section className="admin-panel admin-dashboard-chart-card">
<div className="admin-panel-heading">
<h3>{chart.title}</h3>
<span>
{chart.id === 'visit-users' ? '时段去重 ' : ''}
{formatNumber(chart.total)} {chart.unit}
</span>
</div>
<div
ref={barsRef}
className="admin-dashboard-bars"
role="region"
aria-label={`${chart.title}趋势图`}
tabIndex={0}
onScroll={(event) => {
onScrollLeftChange(event.currentTarget.scrollLeft);
}}
>
{chart.buckets.map((bucket) => (
<div className="admin-dashboard-bar-item" key={bucket.key}>
<div
className="admin-dashboard-bar-track"
title={`${bucket.key}: ${formatNumber(bucket.value)} ${chart.unit}`}
>
<span
style={{
height:
bucket.value === 0
? '0%'
: `${Math.max(4, (bucket.value / maxValue) * 100)}%`,
}}
/>
</div>
<small>{bucket.label}</small>
</div>
))}
</div>
</section>
);
}
function BreakdownPanel({
title,
rows,
unit,
}: {
title: string;
rows: AdminDashboardBreakdownRowPayload[];
unit: string;
}) {
const maxValue = Math.max(1, ...rows.map((row) => row.value));
return (
<section className="admin-panel admin-dashboard-breakdown-panel">
<div className="admin-panel-heading">
<h3>{title}</h3>
<span>{rows.length}</span>
</div>
<div className="admin-dashboard-breakdown-list">
{rows.map((row) => (
<div className="admin-dashboard-breakdown-row" key={row.key}>
<div>
<strong>{row.label}</strong>
<span>
{formatNumber(row.value)} {unit}
</span>
</div>
<div className="admin-dashboard-breakdown-track">
<span style={{ width: `${(row.value / maxValue) * 100}%` }} />
</div>
</div>
))}
{rows.length === 0 ? (
<div className="admin-empty-state">暂无数据</div>
) : null}
</div>
</section>
);
}
function formatNumber(value: number) {
return new Intl.NumberFormat('zh-CN').format(value);
}
function formatRateBasisPoints(value: number) {
return new Intl.NumberFormat('zh-CN', {
style: 'percent',
maximumFractionDigits: 2,
}).format(value / 10_000);
}
function formatBeijingDateInput(date: Date) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
function rangePrefix(granularity: AdminDashboardGranularity) {
if (granularity === 'week') {
return '本周';
}
if (granularity === 'month') {
return '本月';
}
if (granularity === 'period') {
return '本时段';
}
return '本日';
}
function buildPresetDateRange(
granularity: AdminDashboardGranularity,
selectedDate: string,
) {
const fallbackDate = formatBeijingDateInput(new Date());
const anchor = parseDateValueAsUtc(selectedDate)
? selectedDate
: fallbackDate;
if (granularity === 'week') {
return buildWeekDateRange(anchor);
}
if (granularity === 'month') {
return buildMonthDateRange(anchor);
}
return { startDate: anchor, endDate: anchor };
}
function buildWeekDateRange(dateValue: string) {
const date = parseDateValueAsUtc(dateValue);
if (!date) {
return { startDate: dateValue, endDate: dateValue };
}
const weekday = date.getUTCDay() || 7;
const monday = new Date(date);
monday.setUTCDate(date.getUTCDate() - weekday + 1);
return { startDate: formatUtcDate(monday), endDate: dateValue };
}
function buildMonthDateRange(dateValue: string) {
const date = parseDateValueAsUtc(dateValue);
if (!date) {
return { startDate: dateValue, endDate: dateValue };
}
const firstDate = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1),
);
return {
startDate: formatUtcDate(firstDate),
endDate: dateValue,
};
}
function normalizeDateRange(range: { startDate: string; endDate: string }) {
return range.startDate > range.endDate
? { startDate: range.endDate, endDate: range.startDate }
: range;
}
function parseDateValueAsUtc(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() + 1 !== month ||
date.getUTCDate() !== day
) {
return null;
}
return date;
}
function formatUtcDate(date: Date) {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}