6bea5bb2b1
合入 origin/master 最新提交 a465e481e
保留后台错误报告、AGC 受控搜索与 Router 计费安全修复
同步 SpacetimeDB migration、技能文档和配置契约
293 lines
9.2 KiB
TypeScript
293 lines
9.2 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;
|
|
|
|
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 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>{report.status}</td>
|
|
<td>{report.userId}</td>
|
|
<td>{new Date(report.createdAt).toLocaleString()}</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="错误报告详情"
|
|
>
|
|
<div className="admin-detail-modal__panel">
|
|
<header>
|
|
<h2>{selected.batchId}</h2>
|
|
<button type="button" onClick={() => setSelected(null)}>
|
|
关闭
|
|
</button>
|
|
</header>
|
|
<p>
|
|
用户:{selected.userId} · 事件:{selected.eventCount} · 日志:
|
|
{selected.logCount}
|
|
</p>
|
|
<pre>{JSON.stringify(selected.events.slice(0, 20), null, 2)}</pre>
|
|
{selected.userDescription ? (
|
|
<p>用户描述:{selected.userDescription}</p>
|
|
) : null}
|
|
<label className="admin-detail-modal__note">
|
|
处理备注
|
|
<textarea
|
|
value={selected.note ?? ''}
|
|
onChange={(event) =>
|
|
setSelected((current) =>
|
|
current
|
|
? { ...current, note: event.target.value }
|
|
: current,
|
|
)
|
|
}
|
|
maxLength={2000}
|
|
rows={4}
|
|
placeholder="记录处理结论或后续跟进事项"
|
|
disabled={busy}
|
|
/>
|
|
</label>
|
|
<div className="admin-detail-modal__actions">
|
|
<select
|
|
value={selected.status}
|
|
onChange={(event) => void saveStatus(event.target.value)}
|
|
disabled={busy}
|
|
>
|
|
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
|
<option key={value} value={value}>
|
|
{value}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={() => void saveStatus(selected.status)}
|
|
disabled={busy}
|
|
>
|
|
保存备注
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void download(selected.batchId)}
|
|
disabled={downloading}
|
|
>
|
|
{downloading ? '下载中…' : '下载诊断包'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|