新增后台错误报告查看器

增加错误报告列表、详情和状态筛选页面。

支持管理员受控下载诊断包并接入错误报告 Tab 权限。

补充前端 API 类型、客户端请求和详情弹窗样式。
This commit is contained in:
2026-09-01 10:11:43 +08:00
parent 5a4ac48575
commit 4671c5fb52
7 changed files with 308 additions and 0 deletions
+70
View File
@@ -271,6 +271,59 @@ export function listAdminTrackingEventKeys(token: string) {
);
}
export function listAdminErrorReports(
token: string,
query: {
status?: string;
fingerprint?: string;
source?: string;
limit?: number;
} = {},
) {
return request<import('./adminApiTypes').AdminErrorReportListResponse>(
`/admin/api/error-reports${buildErrorReportQueryString(query)}`,
{ token },
);
}
export function getAdminErrorReport(token: string, batchId: string) {
return request<import('./adminApiTypes').AdminErrorReportDetail>(
`/admin/api/error-reports/${encodeURIComponent(batchId)}`,
{ token },
);
}
export function updateAdminErrorReport(
token: string,
batchId: string,
payload: { status: string; note?: string },
) {
return request<import('./adminApiTypes').AdminErrorReportDetail>(
`/admin/api/error-reports/${encodeURIComponent(batchId)}`,
{ method: 'PATCH', token, body: payload },
);
}
export function getAdminErrorReportDownloadUrl(batchId: string) {
return `/admin/api/error-reports/${encodeURIComponent(batchId)}/download`;
}
export async function downloadAdminErrorReport(token: string, batchId: string) {
const response = await fetch(
buildRequestUrl(getAdminErrorReportDownloadUrl(batchId)),
{
headers: { Authorization: `Bearer ${token}` },
},
);
if (!response.ok) {
throw new AdminApiError({
message: `下载失败(${response.status}`,
status: response.status,
});
}
return response.blob();
}
export function getAdminFeatureGateConfig(token: string) {
return request<AdminFeatureGateConfigResponse>('/admin/api/feature-gates', {
token,
@@ -806,6 +859,23 @@ function buildQueryString(query: AdminTrackingEventListQuery) {
return queryString ? `?${queryString}` : '';
}
function buildErrorReportQueryString(query: {
status?: string;
fingerprint?: string;
source?: string;
limit?: number;
}) {
const params = new URLSearchParams();
appendQueryParam(params, 'status', query.status);
appendQueryParam(params, 'fingerprint', query.fingerprint);
appendQueryParam(params, 'source', query.source);
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(query.limit));
}
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
function buildAdminRechargeOrderListQuery(query: AdminRechargeOrderListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'orderId', query.orderId);
+29
View File
@@ -96,6 +96,35 @@ export interface AdminMeResponse {
admin: AdminSessionPayload;
}
export interface AdminErrorReportEntry {
batchId: string;
eventCount: number;
logCount: number;
fingerprint?: string;
source?: string;
status: 'new' | 'in-progress' | 'resolved';
userId: string;
createdAt: string;
updatedAt: string;
userDescription?: string;
attachmentSizeBytes: number;
submissionId?: string;
ossObjectKey?: string;
archiveSha256?: string;
uploadStatus?: 'uploading' | 'ready' | 'failed';
reviewStatus?: 'new' | 'in-progress' | 'resolved';
}
export interface AdminErrorReportListResponse {
reports: AdminErrorReportEntry[];
}
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
note?: string;
events: Array<Record<string, unknown>>;
logNames: string[];
}
export interface AdminOverviewResponse {
service: AdminServiceOverviewPayload;
database: AdminDatabaseOverviewPayload;
+7
View File
@@ -24,6 +24,7 @@ import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -228,6 +229,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'error-reports' ? (
<AdminErrorReportsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'gray-release' ? (
<AdminGrayReleaseConfigPage
token={token}
+1
View File
@@ -38,6 +38,7 @@ const routeIcons = {
tables: Database,
debug: Bug,
tracking: Table2,
'error-reports': Bug,
'gray-release': GitBranch,
redeem: TicketPercent,
invite: TicketCheck,
+2
View File
@@ -5,6 +5,7 @@ export type AdminRouteId =
| 'tables'
| 'debug'
| 'tracking'
| 'error-reports'
| 'gray-release'
| 'redeem'
| 'invite'
@@ -33,6 +34,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'tables', label: '表查询', hash: '#tables' },
{ id: 'debug', label: 'API 调试', hash: '#debug' },
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' },
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
{ id: 'invite', label: '邀请码', hash: '#invite' },
@@ -0,0 +1,179 @@
import { useCallback, useEffect, 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 };
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
const [selected, setSelected] = useState<AdminErrorReportDetail | null>(null);
const [status, setStatus] = useState('');
const [busy, setBusy] = useState(false);
const [filterStatus, setFilterStatus] = useState('');
const load = useCallback(async () => {
try {
const response = await listAdminErrorReports(token, {
status: filterStatus || undefined,
});
setReports(response.reports);
} catch (error) {
if (isAdminApiError(error) && error.status === 401)
return onUnauthorized();
setStatus(formatAdminApiError(error));
}
}, [filterStatus, onUnauthorized, token]);
useEffect(() => {
void load();
}, [load]);
async function openReport(batchId: string) {
try {
setSelected(await getAdminErrorReport(token, batchId));
} catch (error) {
if (isAdminApiError(error) && error.status === 401)
return onUnauthorized();
setStatus(formatAdminApiError(error));
}
}
async function saveStatus(nextStatus: string) {
if (!selected || busy) return;
setBusy(true);
try {
setSelected(
await updateAdminErrorReport(token, selected.batchId, {
status: nextStatus,
note: selected.note,
}),
);
await load();
} catch (error) {
if (isAdminApiError(error) && error.status === 401) onUnauthorized();
else setStatus(formatAdminApiError(error));
} finally {
setBusy(false);
}
}
async function download(batchId: string) {
try {
const blob = await downloadAdminErrorReport(token, batchId);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${batchId}.zip`;
anchor.click();
URL.revokeObjectURL(url);
} catch (error) {
if (isAdminApiError(error) && error.status === 401) onUnauthorized();
else setStatus(formatAdminApiError(error));
}
}
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)}
>
<option value=""></option>
<option value="new">new</option>
<option value="in-progress">in-progress</option>
<option value="resolved">resolved</option>
</select>
</label>
</div>
{status ? <p className="admin-error-message">{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)}
>
<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>
{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, null, 2)}</pre>
{selected.userDescription ? (
<p>{selected.userDescription}</p>
) : null}
<div className="admin-detail-modal__actions">
<select
value={selected.status}
onChange={(event) => void saveStatus(event.target.value)}
disabled={busy}
>
<option value="new">new</option>
<option value="in-progress">in-progress</option>
<option value="resolved">resolved</option>
</select>
<button
type="button"
onClick={() => void download(selected.batchId)}
>
</button>
</div>
</div>
</div>
) : null}
</section>
);
}
+20
View File
@@ -2449,3 +2449,23 @@ button:disabled {
white-space: nowrap;
}
}
.admin-detail-modal {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 20px;
background: rgb(15 23 42 / 35%);
}
.admin-detail-modal__panel {
width: min(860px, 100%);
max-height: 90vh;
overflow: auto;
border-radius: 14px;
padding: 20px;
background: var(--admin-surface, #fff);
}
.admin-detail-modal__panel header,
.admin-detail-modal__actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.admin-detail-modal__panel pre { max-height: 360px; overflow: auto; white-space: pre-wrap; background: #f8fafc; padding: 12px; border-radius: 8px; }