Files
Genarrative/apps/admin-web/src/pages/AdminErrorReportsPage.tsx
T
kdletters df39d5a16f
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
修复客户端渠道与错误报告展示
客户端 debug 保留服务器选择,正式包按发布渠道连接服务

修复错误报告时间解析与超时路由脱敏

重做后台错误详情面板并补齐定向测试

同步渠道、诊断与后台展示文档
2026-09-21 01:52:35 +08:00

475 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 { 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 (
<section className="admin-panel">
<div className="admin-panel-header">
<div>
<h1>错误报告</h1>
<p>查看用户提交的脱敏诊断包。</p>
</div>
<label>
状态{' '}
<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>
</label>
</div>
{status ? (
<p className="admin-alert" role="status">
{status}
</p>
) : null}
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>报告</th>
<th>事件</th>
<th>来源</th>
<th>状态</th>
<th>用户</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{reports.map((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>
))}
</tbody>
</table>
</div>
<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>
{selected ? (
<div
className="admin-detail-modal"
role="dialog"
aria-label="错误报告详情"
aria-modal="true"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setSelected(null);
}}
>
<div className="admin-detail-modal__panel admin-error-report-detail">
<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>
</div>
</div>
) : null}
</section>
);
}