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 全部通过
479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|