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 = { 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, 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([]); const [selected, setSelected] = useState(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(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 (

错误报告

查看用户提交的脱敏诊断包。

{status ? (

{status}

) : null}
{reports.map((report) => ( void openReport(report.batchId)} role="button" tabIndex={0} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); void openReport(report.batchId); } }} > ))}
报告 事件 来源 状态 用户 时间
{report.batchId} {report.eventCount} {report.source ?? '-'} {ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status} {report.userId} {formatAdminTimestamp(report.createdAt)}
{pageInfo.total === 0 ? '暂无报告' : `第 ${pageOffset + 1}-${Math.min(pageOffset + reports.length, pageInfo.total)} 条,共 ${pageInfo.total} 条`}
{selected ? (
{ if (event.target === event.currentTarget) setSelected(null); }} >
错误报告详情

{selected.batchId}

{ERROR_REPORT_STATUS_LABELS[selected.status] ?? selected.status} {selected.firstSource ?? selected.source ?? '未知来源'}
用户 {selected.userId}
发生时间 {formatAdminTimestamp(selected.createdAt)}
错误事件 {selected.eventCount}
应用日志 {selected.logCount}

错误事件

展示最近 20 条,完整内容请下载诊断包。
{selected.events.length} 条
{selected.events.length === 0 ? (

归档中没有可展示的错误事件。

) : 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 (
#{index + 1} {eventText(event, 'source') || 'client'} {eventText(event, 'count') || '1'} 次

{message}

{eventText(event, 'fingerprint') ? ( {eventText(event, 'fingerprint')} ) : null} {stack ? (
查看调用栈
{stack}
) : null}
); })}
{selected.userDescription ? (

用户描述

{selected.userDescription}

) : null}