diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 452ad30f3..1a1c6f668 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -144,6 +144,8 @@ export interface AdminErrorReportListResponse { } export interface AdminErrorReportDetail extends AdminErrorReportEntry { + firstFingerprint?: string; + firstSource?: string; note?: string; events: Array>; logNames: string[]; diff --git a/apps/admin-web/src/pages/AdminErrorReportsPage.test.tsx b/apps/admin-web/src/pages/AdminErrorReportsPage.test.tsx new file mode 100644 index 000000000..2c945abbe --- /dev/null +++ b/apps/admin-web/src/pages/AdminErrorReportsPage.test.tsx @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatAdminTimestamp, + parseAdminTimestamp, +} from './AdminErrorReportsPage'; + +describe('错误报告时间格式化', () => { + it('解析 SpacetimeDB seconds.microsZ 时间', () => { + expect(parseAdminTimestamp('1778207451.731746Z')).toBe(1778207451731); + }); + + it('解析微秒字符串并拒绝无效时间', () => { + expect(parseAdminTimestamp('1778207451731746')).toBe(1778207451731); + expect(formatAdminTimestamp('not-a-date')).toBe('-'); + expect(formatAdminTimestamp('999999999999999999999999')).toBe('-'); + }); +}); diff --git a/apps/admin-web/src/pages/AdminErrorReportsPage.tsx b/apps/admin-web/src/pages/AdminErrorReportsPage.tsx index fbb5f8650..da2f7ebba 100644 --- a/apps/admin-web/src/pages/AdminErrorReportsPage.tsx +++ b/apps/admin-web/src/pages/AdminErrorReportsPage.tsx @@ -17,6 +17,57 @@ 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([]); @@ -29,6 +80,24 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { 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; @@ -184,9 +253,15 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { {report.batchId} {report.eventCount} {report.source ?? '-'} - {report.status} + + + {ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status} + + {report.userId} - {new Date(report.createdAt).toLocaleString()} + {formatAdminTimestamp(report.createdAt)} ))} @@ -224,66 +299,173 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { className="admin-detail-modal" role="dialog" aria-label="错误报告详情" + aria-modal="true" + onMouseDown={(event) => { + if (event.target === event.currentTarget) setSelected(null); + }} > -
-
-

{selected.batchId}

-
-

- 用户:{selected.userId} · 事件:{selected.eventCount} · 日志: - {selected.logCount} -

-
{JSON.stringify(selected.events.slice(0, 20), null, 2)}
- {selected.userDescription ? ( -

用户描述:{selected.userDescription}

- ) : null} -