新增后台错误报告查看器
增加错误报告列表、详情和状态筛选页面。 支持管理员受控下载诊断包并接入错误报告 Tab 权限。 补充前端 API 类型、客户端请求和详情弹窗样式。
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -38,6 +38,7 @@ const routeIcons = {
|
||||
tables: Database,
|
||||
debug: Bug,
|
||||
tracking: Table2,
|
||||
'error-reports': Bug,
|
||||
'gray-release': GitBranch,
|
||||
redeem: TicketPercent,
|
||||
invite: TicketCheck,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user