Files
Genarrative/apps/admin-web/src/pages/AdminDashboardPage.tsx
T
kdletters 8315d5660a
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
后台公共组件收口到共享组件库并统一列表实现
- packages/shared/src/components/admin/ 新增 16 个后台共享组件(AdminPage/AdminPanel/AdminButton/AdminAlert/AdminTable/AdminField/AdminInfoList/AdminTagList/AdminPagination/AdminListPanel/AdminDialog 等)与随组件的 admin.css,经 @genarrative/shared/components 导出
- AdminListPanel 统一列表外壳:columns + renderRow、加载两口径(empty/line)、空态三态(state/text/row)、toolbar 与 pagination 槽位、showEmpty 判据、surface=plain(弹窗内列表)
- AdminListPanel 新增列头排序(sortable/sortValue/sortDescription):正序→倒序→不排序循环、同步 th[aria-sort]、稳定排序
- AdminDialog 统一弹窗外壳(遮罩/标题行/关闭动作/role=dialog/aria-modal/Esc/点遮罩关闭/初始聚焦/as=form),12 处各页自写弹窗全部改用它
- AdminFormat 新增 formatAdminDateTime/formatAdminDate,删除 7 份重复的本地时间格式化实现,修掉列表里裸 ISO 时间
- 21 个文件里 26 处列表全部迁到 AdminListPanel(24 处 columns 形态、2 处弹窗内 plain),页内已无手写表头或 AdminTable 当 children 的写法;累计 45 个可排序列
- 页面壳归一:错误报告补 AdminPage wide + AdminPageHeading,详情弹窗移出列表面板;模板管理列表从共享 ui Table 换成 AdminTable
- 样式收口:公共组件规则随组件进入共享 styles,清理 admin.css 重复按钮/面板规则,.admin-tag 与 .admin-table th 改 nowrap,埋点用户列参考按钮紧跟本行并留行距,错误报告修版心
- 新增 scripts/admin-web-fake-api.mjs 本地假 API(含 overview/dashboard/tracking/error-reports/feature-gates/邀请码/兑换码/任务/充值/项目工程/AGC 模型与模板/游戏/账号/用户详情夹具),用于本地核对渲染
- docs 同步:共享基础组件库与展示页(后台组件层/列表排序/收敛范围/本地核对)、decision-log 与 pitfalls 记录迁移口径、排序边界与踩坑
- 验证:npm run admin-web:typecheck、npx vitest run apps/admin-web packages/shared/src/components/admin(26 files / 244 tests)、npm run check:encoding、git diff --check、prettier、eslint 全部通过
2026-09-29 22:39:41 +08:00

692 lines
19 KiB
TypeScript

import {
AdminAlert,
AdminButton,
AdminEmptyState,
AdminField,
AdminPage,
AdminPageHeading,
AdminPanel,
} from '@genarrative/shared/components';
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 (
<AdminPage wide>
<AdminPageHeading
title="Dashboard"
description={dashboard?.range.periodLabel ?? '运营指标'}
actions={
<div className="admin-dashboard-actions">
<div className="admin-dashboard-period-fields">
<AdminField
label="起始日期"
className="admin-dashboard-date-field"
>
<input
type="date"
max={today}
value={dateRange.startDate}
onChange={(event) =>
handleDateRangeChange('startDate', event.target.value)
}
/>
</AdminField>
<AdminField
label="终止日期"
className="admin-dashboard-date-field"
>
<input
type="date"
max={today}
value={dateRange.endDate}
onChange={(event) =>
handleDateRangeChange('endDate', event.target.value)
}
/>
</AdminField>
</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>
<AdminButton
variant="secondary"
disabled={isLoading}
type="button"
onClick={() => void loadDashboard()}
>
<RefreshCw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</AdminButton>
</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 ? <AdminAlert>{errorMessage}</AdminAlert> : null}
{dashboard?.warnings.length ? (
<AdminPanel
tone="warning"
title="读取告警"
count={dashboard.warnings.length}
>
<ul className="admin-error-list">
{dashboard.warnings.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
</AdminPanel>
) : null}
{activeTab === 'metrics' ? (
<>
<AdminPanel title="总计数据" count="全站累计">
<div className="admin-dashboard-metric-grid">
{totalMetricCards.map((metric) => (
<MetricCard
key={metric.id}
label={metric.label}
value={metric.value}
unit={metric.unit}
compact
/>
))}
</div>
</AdminPanel>
<AdminPanel
title="时段数据"
count={dashboard?.range.periodLabel ?? '-'}
>
<div className="admin-dashboard-metric-grid">
{periodMetricCards.map((metric) => (
<MetricCard
key={metric.id}
label={metric.label}
value={metric.value}
unit={metric.unit}
compact
/>
))}
</div>
</AdminPanel>
<AdminPanel
title="新增用户转化与留存"
count={dashboard?.range.periodLabel ?? '-'}
>
<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>
</AdminPanel>
<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 ? (
<AdminEmptyState>暂无图表</AdminEmptyState>
) : null}
</div>
</>
) : (
<div className="admin-dashboard-operations">
<AdminPanel
title="运营指标"
count={dashboard?.range.periodLabel ?? '-'}
>
<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>
</AdminPanel>
<BreakdownPanel
title="素材类型分布"
rows={dashboard?.operations.assetKindBreakdown ?? []}
unit="个"
/>
<BreakdownPanel
title="访问模块分布"
rows={dashboard?.operations.moduleVisitBreakdown ?? []}
unit="次"
/>
</div>
)}
</AdminPage>
);
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 (
<AdminPanel data-compact={compact} className="admin-dashboard-metric-card">
<span>{label}</span>
<strong>{formatNumber(value)}</strong>
<small>{unit}</small>
</AdminPanel>
);
}
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 (
<AdminPanel
title={chart.title}
count={
<>
{chart.id === 'visit-users' ? '时段去重 ' : ''}
{formatNumber(chart.total)} {chart.unit}
</>
}
className="admin-dashboard-chart-card"
>
<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>
</AdminPanel>
);
}
function BreakdownPanel({
title,
rows,
unit,
}: {
title: string;
rows: AdminDashboardBreakdownRowPayload[];
unit: string;
}) {
const maxValue = Math.max(1, ...rows.map((row) => row.value));
return (
<AdminPanel
title={title}
count={rows.length}
className="admin-dashboard-breakdown-panel"
>
<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 ? <AdminEmptyState>暂无数据</AdminEmptyState> : null}
</div>
</AdminPanel>
);
}
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}`;
}