Files
Genarrative/apps/admin-web/src/pages/AdminErrorReportsPage.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

479 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AdminActionRow,
AdminAlert,
AdminDialog,
AdminField,
AdminListPanel,
AdminPage,
AdminPageHeading,
} from '@genarrative/shared/components';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
downloadAdminErrorReport,
formatAdminApiError,
getAdminErrorReport,
isAdminApiError,
listAdminErrorReports,
updateAdminErrorReport,
} from '../api/adminApiClient';
import type {
AdminErrorReportDetail,
AdminErrorReportEntry,
} from '../api/adminApiTypes';
type Props = { token: string; onUnauthorized: (message?: string) => void };
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
const ERROR_REPORT_PAGE_SIZE = 50;
const ERROR_REPORT_STATUS_LABELS: Record<string, string> = {
new: '待处理',
'in-progress': '处理中',
resolved: '已解决',
};
export function parseAdminTimestamp(value: string | null | undefined) {
const normalized = value?.trim() ?? '';
if (/^-?\d+\.\d{6}Z$/u.test(normalized)) {
const [secondsText, microsText] = normalized.slice(0, -1).split('.');
const seconds = Number(secondsText);
const micros = Number(microsText);
return Number.isFinite(seconds) && Number.isFinite(micros)
? seconds * 1000 + Math.floor(micros / 1000)
: Number.NaN;
}
if (/^-?\d+$/u.test(normalized)) {
const numeric = Number(normalized);
if (!Number.isFinite(numeric)) return Number.NaN;
if (Math.abs(numeric) >= 1e14) return Math.floor(numeric / 1000);
if (Math.abs(numeric) >= 1e11) return numeric;
return numeric * 1000;
}
return Date.parse(normalized);
}
export function formatAdminTimestamp(value: string | null | undefined) {
const timestamp = parseAdminTimestamp(value);
if (!Number.isFinite(timestamp)) return '-';
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return '-';
try {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date);
} catch {
return '-';
}
}
function eventText(event: Record<string, unknown>, key: string) {
const value = event[key];
return typeof value === 'string' || typeof value === 'number'
? String(value)
: '';
}
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
const [selected, setSelected] = useState<AdminErrorReportDetail | null>(null);
const [status, setStatus] = useState('');
const [downloading, setDownloading] = useState(false);
const [busy, setBusy] = useState(false);
const [filterStatus, setFilterStatus] = useState('');
const [pageOffset, setPageOffset] = useState(0);
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
const openReportRequestId = useRef(0);
const loadRequestId = useRef(0);
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!selected) return;
const previousFocus = document.activeElement as HTMLElement | null;
const focusFrame = window.requestAnimationFrame(() => {
detailCloseButtonRef.current?.focus();
});
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setSelected(null);
};
window.addEventListener('keydown', handleEscape);
return () => {
window.cancelAnimationFrame(focusFrame);
window.removeEventListener('keydown', handleEscape);
previousFocus?.focus?.();
};
}, [selected]);
const load = useCallback(async () => {
const requestId = ++loadRequestId.current;
setStatus('');
try {
const response = await listAdminErrorReports(token, {
status: filterStatus || undefined,
limit: ERROR_REPORT_PAGE_SIZE,
offset: pageOffset,
});
if (requestId === loadRequestId.current) {
const lastValidOffset = response.total
? Math.floor((response.total - 1) / ERROR_REPORT_PAGE_SIZE) *
ERROR_REPORT_PAGE_SIZE
: 0;
if (pageOffset > lastValidOffset) {
setPageOffset(lastValidOffset);
return;
}
setReports(response.reports);
setPageInfo({ total: response.total, hasMore: response.hasMore });
}
} catch (error) {
if (requestId !== loadRequestId.current) return;
if (isAdminApiError(error) && error.status === 401)
return onUnauthorized();
setStatus(formatAdminApiError(error));
}
}, [filterStatus, onUnauthorized, pageOffset, token]);
const loadRef = useRef(load);
useEffect(() => {
loadRef.current = load;
}, [load]);
useEffect(() => {
void load();
}, [load]);
async function openReport(batchId: string) {
const requestId = ++openReportRequestId.current;
setStatus('');
try {
const detail = await getAdminErrorReport(token, batchId);
if (requestId === openReportRequestId.current) setSelected(detail);
} catch (error) {
if (requestId !== openReportRequestId.current) return;
if (isAdminApiError(error) && error.status === 401)
return onUnauthorized();
setStatus(formatAdminApiError(error));
}
}
async function saveStatus(nextStatus: string) {
if (!selected || busy) return;
setBusy(true);
setStatus('');
try {
const updated = await updateAdminErrorReport(token, selected.batchId, {
status: nextStatus,
note: selected.note,
});
setSelected((current) =>
current ? { ...current, ...updated } : current,
);
await loadRef.current();
} catch (error) {
if (isAdminApiError(error) && error.status === 401) onUnauthorized();
else setStatus(formatAdminApiError(error));
} finally {
setBusy(false);
}
}
async function download(batchId: string) {
if (downloading) return;
setDownloading(true);
setStatus('');
try {
const blob = await downloadAdminErrorReport(token, batchId);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${batchId}.zip`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
} catch (error) {
if (isAdminApiError(error) && error.status === 401) onUnauthorized();
else setStatus(formatAdminApiError(error));
} finally {
setDownloading(false);
}
}
return (
<AdminPage wide>
<AdminPageHeading
title="错误报告"
description="用户提交的脱敏诊断包"
actions={
<AdminActionRow>
<AdminField label="状态" compact>
<select
value={filterStatus}
onChange={(event) => {
setFilterStatus(event.target.value);
setPageOffset(0);
}}
>
<option value="">全部</option>
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</AdminField>
</AdminActionRow>
}
/>
<AdminListPanel
pagination={
<div
className="admin-detail-modal__actions"
aria-label="错误报告分页"
>
<span>
{pageInfo.total === 0
? '暂无报告'
: `第 ${pageOffset + 1}-${Math.min(pageOffset + reports.length, pageInfo.total)} 条,共 ${pageInfo.total} 条`}
</span>
<button
type="button"
onClick={() =>
setPageOffset((offset) =>
Math.max(0, offset - ERROR_REPORT_PAGE_SIZE),
)
}
disabled={pageOffset === 0}
>
上一页
</button>
<button
type="button"
onClick={() =>
setPageOffset((offset) => offset + ERROR_REPORT_PAGE_SIZE)
}
disabled={!pageInfo.hasMore}
>
下一页
</button>
</div>
}
columns={[
{ key: 'batchId', label: '报告' },
{ key: 'eventCount', label: '事件' },
{ key: 'source', label: '来源' },
{ key: 'status', label: '状态' },
{ key: 'userId', label: '用户' },
{ key: 'createdAt', label: '时间' },
]}
renderRow={(report) => (
<tr
key={report.batchId}
onClick={() => void openReport(report.batchId)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
void openReport(report.batchId);
}
}}
>
<td>{report.batchId}</td>
<td>{report.eventCount}</td>
<td>{report.source ?? '-'}</td>
<td>
<span className={`admin-error-report-status is-${report.status}`}>
{ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status}
</span>
</td>
<td>{report.userId}</td>
<td>{formatAdminTimestamp(report.createdAt)}</td>
</tr>
)}
rows={reports}
showEmpty={false}
toolbar={status ? <AdminAlert>{status}</AdminAlert> : null}
/>
{selected ? (
<AdminDialog
ariaLabel="错误报告详情"
panelClassName="admin-detail-modal__panel admin-error-report-detail"
showClose={false}
onClose={() => setSelected(null)}
>
<header className="admin-error-report-detail__header">
<div className="admin-error-report-detail__heading">
<span className="admin-error-report-detail__eyebrow">
错误报告详情
</span>
<h2>{selected.batchId}</h2>
<div className="admin-error-report-detail__badges">
<span
className={`admin-error-report-status is-${selected.status}`}
>
{ERROR_REPORT_STATUS_LABELS[selected.status] ??
selected.status}
</span>
<span className="admin-error-report-detail__badge">
{selected.firstSource ?? selected.source ?? '未知来源'}
</span>
</div>
</div>
<button
type="button"
className="admin-error-report-detail__close"
ref={detailCloseButtonRef}
onClick={() => setSelected(null)}
aria-label="关闭错误报告详情"
>
×
</button>
</header>
<div className="admin-error-report-detail__summary">
<div>
<span>用户</span>
<strong>{selected.userId}</strong>
</div>
<div>
<span>发生时间</span>
<strong>{formatAdminTimestamp(selected.createdAt)}</strong>
</div>
<div>
<span>错误事件</span>
<strong>{selected.eventCount}</strong>
</div>
<div>
<span>应用日志</span>
<strong>{selected.logCount}</strong>
</div>
</div>
<div className="admin-error-report-detail__body">
<section className="admin-error-report-detail__section">
<div className="admin-error-report-detail__section-heading">
<div>
<h3>错误事件</h3>
<span>展示最近 20 条,完整内容请下载诊断包。</span>
</div>
<span className="admin-error-report-detail__count">
{selected.events.length} 条
</span>
</div>
<div className="admin-error-report-event-list">
{selected.events.length === 0 ? (
<p className="admin-error-report-detail__empty">
归档中没有可展示的错误事件。
</p>
) : null}
{selected.events.slice(0, 20).map((event, index) => {
const message = eventText(event, 'message') || '未知错误';
const stack = eventText(event, 'stack');
const occurredAt = eventText(event, 'occurredAt');
return (
<article
className="admin-error-report-event"
key={`${index}-${message}`}
>
<div className="admin-error-report-event__topline">
<span className="admin-error-report-event__index">
#{index + 1}
</span>
<span>{eventText(event, 'source') || 'client'}</span>
<span>{eventText(event, 'count') || '1'} 次</span>
<time>{formatAdminTimestamp(occurredAt)}</time>
</div>
<p className="admin-error-report-event__message">
{message}
</p>
{eventText(event, 'fingerprint') ? (
<code>{eventText(event, 'fingerprint')}</code>
) : null}
{stack ? (
<details>
<summary>查看调用栈</summary>
<pre>{stack}</pre>
</details>
) : null}
</article>
);
})}
</div>
</section>
{selected.userDescription ? (
<section className="admin-error-report-detail__section">
<div className="admin-error-report-detail__section-heading">
<div>
<h3>用户描述</h3>
</div>
</div>
<p className="admin-error-report-detail__description">
{selected.userDescription}
</p>
</section>
) : null}
<section className="admin-error-report-detail__section admin-error-report-detail__section--note">
<label className="admin-detail-modal__note">
<span>处理备注</span>
<textarea
value={selected.note ?? ''}
onChange={(event) =>
setSelected((current) =>
current
? {
...current,
note: event.target.value,
}
: current,
)
}
maxLength={2000}
rows={4}
placeholder="记录处理结论或后续跟进事项"
disabled={busy}
/>
</label>
</section>
</div>
<footer className="admin-error-report-detail__footer">
<label className="admin-error-report-detail__status-field">
状态
<select
value={selected.status}
onChange={(event) => void saveStatus(event.target.value)}
disabled={busy}
>
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
<option key={value} value={value}>
{ERROR_REPORT_STATUS_LABELS[value]}
</option>
))}
</select>
</label>
<div className="admin-error-report-detail__footer-actions">
<button
type="button"
onClick={() => void saveStatus(selected.status)}
disabled={busy}
>
{busy ? '保存中…' : '保存备注'}
</button>
<button
type="button"
onClick={() => void download(selected.batchId)}
disabled={downloading}
>
{downloading ? '下载中…' : '下载诊断包'}
</button>
</div>
</footer>
</AdminDialog>
) : null}
</AdminPage>
);
}