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 保留服务器选择,正式包按发布渠道连接服务 修复错误报告时间解析与超时路由脱敏 重做后台错误详情面板并补齐定向测试 同步渠道、诊断与后台展示文档
475 lines
16 KiB
TypeScript
475 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|