diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 9912b9cf7..65bb5d4e5 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -23,6 +23,9 @@ import type { AdminEditorShowcaseListQuery, AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest, + AdminErrorReportDetail, + AdminErrorReportEntry, + AdminErrorReportListResponse, AdminFeatureGateConfigResponse, AdminLoginResponse, AdminMeResponse, @@ -259,7 +262,16 @@ export function listAdminTrackingEvents( query: AdminTrackingEventListQuery = {}, ) { return request( - `/admin/api/tracking/events${buildQueryString(query)}`, + `/admin/api/tracking/events${buildQueryString((params) => { + appendQueryParam(params, 'eventKey', query.eventKey); + appendQueryParam(params, 'userId', query.userId); + appendQueryParam(params, 'scopeKind', query.scopeKind); + appendQueryParam(params, 'scopeId', query.scopeId); + appendQueryParam(params, 'startDate', query.startDate); + appendQueryParam(params, 'endDate', query.endDate); + appendNumericQueryParam(params, 'limit', query.limit); + if (query.exportAll) params.set('exportAll', 'true'); + })}`, { token }, ); } @@ -271,6 +283,60 @@ export function listAdminTrackingEventKeys(token: string) { ); } +export function listAdminErrorReports( + token: string, + query: { + status?: string; + fingerprint?: string; + source?: string; + limit?: number; + offset?: number; + } = {}, +) { + return request( + `/admin/api/error-reports${buildErrorReportQueryString(query)}`, + { token }, + ); +} + +export function getAdminErrorReport(token: string, batchId: string) { + return request( + `/admin/api/error-reports/${encodeURIComponent(batchId)}`, + { token }, + ); +} + +export function updateAdminErrorReport( + token: string, + batchId: string, + payload: { status: string; note?: string }, +) { + return request( + `/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('/admin/api/feature-gates', { token, @@ -788,43 +854,44 @@ async function postAdminDirectUploadFile( } } -function buildQueryString(query: AdminTrackingEventListQuery) { +function buildQueryString(append: (params: URLSearchParams) => void) { const params = new URLSearchParams(); - appendQueryParam(params, 'eventKey', query.eventKey); - appendQueryParam(params, 'userId', query.userId); - appendQueryParam(params, 'scopeKind', query.scopeKind); - appendQueryParam(params, 'scopeId', query.scopeId); - appendQueryParam(params, 'startDate', query.startDate); - appendQueryParam(params, 'endDate', query.endDate); - if (typeof query.limit === 'number' && Number.isFinite(query.limit)) { - params.set('limit', String(query.limit)); - } - if (query.exportAll) { - params.set('exportAll', 'true'); - } - const queryString = params.toString(); - return queryString ? `?${queryString}` : ''; + append(params); + return formatQueryString(params); +} + +function buildErrorReportQueryString(query: { + status?: string; + fingerprint?: string; + source?: string; + limit?: number; + offset?: number; +}) { + return buildQueryString((params) => { + appendQueryParam(params, 'status', query.status); + appendQueryParam(params, 'fingerprint', query.fingerprint); + appendQueryParam(params, 'source', query.source); + appendNumericQueryParam(params, 'limit', query.limit); + appendNumericQueryParam(params, 'offset', query.offset); + }); } function buildAdminRechargeOrderListQuery(query: AdminRechargeOrderListQuery) { - const params = new URLSearchParams(); - appendQueryParam(params, 'orderId', query.orderId); - appendQueryParam( - params, - 'providerTransactionId', - query.providerTransactionId, - ); - appendQueryParam(params, 'userId', query.userId); - appendQueryParam(params, 'publicUserCode', query.publicUserCode); - appendQueryParam(params, 'paymentChannel', query.paymentChannel); - appendQueryParam(params, 'status', query.status); - appendQueryParam(params, 'createdAfter', query.createdAfter); - appendQueryParam(params, 'createdBefore', query.createdBefore); - if (typeof query.limit === 'number' && Number.isFinite(query.limit)) { - params.set('limit', String(query.limit)); - } - const queryString = params.toString(); - return queryString ? `?${queryString}` : ''; + return buildQueryString((params) => { + appendQueryParam(params, 'orderId', query.orderId); + appendQueryParam( + params, + 'providerTransactionId', + query.providerTransactionId, + ); + appendQueryParam(params, 'userId', query.userId); + appendQueryParam(params, 'publicUserCode', query.publicUserCode); + appendQueryParam(params, 'paymentChannel', query.paymentChannel); + appendQueryParam(params, 'status', query.status); + appendQueryParam(params, 'createdAfter', query.createdAfter); + appendQueryParam(params, 'createdBefore', query.createdBefore); + appendNumericQueryParam(params, 'limit', query.limit); + }); } function buildAdminUserDetailQuery(query: AdminUserDetailQuery) { @@ -900,6 +967,21 @@ function appendQueryParam( } } +function appendNumericQueryParam( + params: URLSearchParams, + key: string, + value: number | null | undefined, +) { + if (typeof value === 'number' && Number.isFinite(value)) { + params.set(key, String(value)); + } +} + +function formatQueryString(params: URLSearchParams) { + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} + function parseJsonResponse(responseText: string): unknown { if (!responseText.trim()) { return null; diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index bcd2ee1db..b911a6e8b 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -96,6 +96,38 @@ 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'; +} + +export interface AdminErrorReportListResponse { + reports: AdminErrorReportEntry[]; + total: number; + offset: number; + limit: number; + hasMore: boolean; +} + +export interface AdminErrorReportDetail extends AdminErrorReportEntry { + note?: string; + events: Array>; + logNames: string[]; +} + export interface AdminOverviewResponse { service: AdminServiceOverviewPayload; database: AdminDatabaseOverviewPayload; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index de33c8761..a717f146b 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -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' ? ( + + ) : null} {activeRouteId === 'gray-release' ? ( 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([]); + const [selected, setSelected] = useState(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 ( +
+
+
+

错误报告

+

查看用户提交的脱敏诊断包。

+
+ +
+ {status ? ( +

+ {status} +

+ ) : null} +
+ + + + + + + + + + + + + {reports.map((report) => ( + void openReport(report.batchId)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + void openReport(report.batchId); + } + }} + > + + + + + + + + ))} + +
报告事件来源状态用户时间
{report.batchId}{report.eventCount}{report.source ?? '-'}{report.status}{report.userId}{new Date(report.createdAt).toLocaleString()}
+
+
+ + {pageInfo.total === 0 + ? '暂无报告' + : `第 ${pageOffset + 1}-${Math.min(pageOffset + reports.length, pageInfo.total)} 条,共 ${pageInfo.total} 条`} + + + +
+ {selected ? ( +
+
+
+

{selected.batchId}

+ +
+

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

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

用户描述:{selected.userDescription}

+ ) : null} +