实现自动错误通知与报告面板

新增五秒聚合的非阻塞错误通知

支持事件选择并在日志读取完成前禁止提交

扩大移动端关闭按钮触控区域
This commit is contained in:
2026-09-01 19:04:51 +08:00
parent 5315a3e338
commit ba74c1af30
2 changed files with 162 additions and 23 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
type ClientErrorEvent,
@@ -13,43 +13,61 @@ import { ThemedModal } from '../modal/ThemedModal';
type ErrorReportDialogProps = {
open: boolean;
events: ClientErrorEvent[];
onClose: () => void;
};
export function ErrorReportDialog({ open, onClose }: ErrorReportDialogProps) {
const [events, setEvents] = useState<ClientErrorEvent[]>([]);
export function ErrorReportDialog({
open,
events,
onClose,
}: ErrorReportDialogProps) {
const [selectedFingerprints, setSelectedFingerprints] = useState<Set<string>>(
new Set(),
);
const [logs, setLogs] = useState<DiagnosticLogFile[]>([]);
const [includeLogs, setIncludeLogs] = useState(true);
const [description, setDescription] = useState('');
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState('');
useEffect(
() =>
subscribeClientErrorEvents(() =>
setEvents(getPendingClientErrorEvents()),
),
[],
);
const [logsReady, setLogsReady] = useState(false);
const [logsError, setLogsError] = useState(false);
useEffect(() => {
if (!open) return;
setEvents(getPendingClientErrorEvents());
setSelectedFingerprints(
new Set(events.slice(0, 1).map((event) => event.fingerprint)),
);
setStatus('');
void readApplicationDiagnosticLogs().then(setLogs);
}, [open]);
setLogsReady(false);
setLogsError(false);
void readApplicationDiagnosticLogs()
.then((nextLogs) => {
setLogs(nextLogs);
setLogsReady(true);
})
.catch(() => {
setLogs([]);
setLogsError(true);
setLogsReady(true);
});
}, [events, open]);
const submit = useCallback(async () => {
if (!events.length || busy) return;
const selectedEvents = events.filter((event) =>
selectedFingerprints.has(event.fingerprint),
);
if (!selectedEvents.length || busy || !logsReady) return;
if (includeLogs && logsError) return;
setBusy(true);
setStatus('正在提交…');
try {
await submitErrorReportBatch({
events,
events: selectedEvents,
logs: includeLogs ? logs : [],
userDescription: description,
});
markClientErrorEventsSubmitted(events);
markClientErrorEventsSubmitted(selectedEvents);
setStatus('已提交,感谢你的反馈');
window.setTimeout(onClose, 700);
} catch (error) {
@@ -59,7 +77,17 @@ export function ErrorReportDialog({ open, onClose }: ErrorReportDialogProps) {
} finally {
setBusy(false);
}
}, [busy, description, events, includeLogs, logs, onClose]);
}, [
busy,
description,
events,
includeLogs,
logs,
logsError,
logsReady,
onClose,
selectedFingerprints,
]);
return (
<ThemedModal
@@ -83,7 +111,22 @@ export function ErrorReportDialog({ open, onClose }: ErrorReportDialogProps) {
<ul className="error-report-dialog__events">
{events.map((event) => (
<li key={event.fingerprint}>
<span>{event.message}</span>
<label className="error-report-dialog__event-select">
<input
type="checkbox"
checked={selectedFingerprints.has(event.fingerprint)}
onChange={() =>
setSelectedFingerprints((current) => {
const next = new Set(current);
if (next.has(event.fingerprint))
next.delete(event.fingerprint);
else next.add(event.fingerprint);
return next;
})
}
/>
<span>{event.message}</span>
</label>
<small>
{event.source} · {event.count}
</small>
@@ -109,10 +152,16 @@ export function ErrorReportDialog({ open, onClose }: ErrorReportDialogProps) {
checked={includeLogs}
onChange={(event) => setIncludeLogs(event.target.checked)}
/>
<span>{logs.length} </span>
<span>
{logsReady
? logsError
? '应用诊断日志暂不可用(可取消后提交)'
: `附加应用诊断日志(${logs.length} 个文件)`
: '正在读取应用诊断日志…'}
</span>
</label>
<p className="error-report-dialog__consent">
</p>
{status ? <p role="status">{status}</p> : null}
</div>
@@ -123,7 +172,12 @@ export function ErrorReportDialog({ open, onClose }: ErrorReportDialogProps) {
<button
type="button"
onClick={() => void submit()}
disabled={busy || !events.length}
disabled={
busy ||
!selectedFingerprints.size ||
!logsReady ||
(includeLogs && logsError)
}
>
{busy ? '提交中…' : '提交报告'}
</button>
@@ -131,3 +185,78 @@ export function ErrorReportDialog({ open, onClose }: ErrorReportDialogProps) {
</ThemedModal>
);
}
export function ErrorReportCenter() {
const [pendingEvents, setPendingEvents] = useState<ClientErrorEvent[]>([]);
const [notificationEvents, setNotificationEvents] = useState<
ClientErrorEvent[]
>([]);
const [reportEvents, setReportEvents] = useState<ClientErrorEvent[]>([]);
const notifiedFingerprints = useRef(new Set<string>());
const aggregationTimer = useRef<number | null>(null);
const sync = useCallback(() => {
setPendingEvents(getPendingClientErrorEvents());
}, []);
useEffect(() => {
sync();
return subscribeClientErrorEvents(sync);
}, [sync]);
useEffect(() => {
const candidates = pendingEvents.filter(
(event) => !notifiedFingerprints.current.has(event.fingerprint),
);
if (
!candidates.length ||
notificationEvents.length ||
aggregationTimer.current !== null
)
return;
aggregationTimer.current = window.setTimeout(() => {
aggregationTimer.current = null;
const next = getPendingClientErrorEvents();
const newlyNotified = next.filter(
(event) => !notifiedFingerprints.current.has(event.fingerprint),
);
newlyNotified.forEach((event) =>
notifiedFingerprints.current.add(event.fingerprint),
);
if (newlyNotified.length) setNotificationEvents(next);
}, 5000);
return () => {
if (aggregationTimer.current !== null) {
window.clearTimeout(aggregationTimer.current);
aggregationTimer.current = null;
}
};
}, [notificationEvents.length, pendingEvents]);
const ignore = () => setNotificationEvents([]);
const openReport = () => {
setReportEvents(notificationEvents);
setNotificationEvents([]);
};
return (
<>
{notificationEvents.length ? (
<aside className="error-report-notice" role="status" aria-live="polite">
<span> {notificationEvents.length} </span>
<button type="button" onClick={openReport}>
</button>
<button type="button" onClick={ignore} aria-label="忽略">
×
</button>
</aside>
) : null}
<ErrorReportDialog
open={reportEvents.length > 0}
events={reportEvents}
onClose={() => setReportEvents([])}
/>
</>
);
}
+11 -1
View File
@@ -690,7 +690,7 @@ textarea {
.error-report-dialog__header h2 { margin: 0; }
.error-report-dialog__consent { margin: 4px 0 0; color: var(--platform-text-soft); font-size: 13px; }
.error-report-dialog__header > button { border: 0; background: transparent; font-size: 24px; cursor: pointer; }
.error-report-dialog__header > button { width: 40px; min-height: 40px; border: 0; border-radius: 10px; background: transparent; font-size: 24px; cursor: pointer; }
.error-report-dialog__body { display: grid; gap: 16px; margin: 20px 0; }
.error-report-dialog__events { display: grid; gap: 8px; padding: 0; margin: 8px 0 0; list-style: none; }
.error-report-dialog__events li { display: grid; gap: 3px; border: 1px solid var(--platform-border-subtle); border-radius: 10px; padding: 10px; }
@@ -701,6 +701,16 @@ textarea {
.error-report-dialog__actions { justify-content: flex-end; }
.error-report-dialog__actions button { border: 0; border-radius: 9px; padding: 9px 14px; cursor: pointer; }
.error-report-dialog__actions button:last-child { background: var(--platform-button-primary-fill); color: var(--platform-button-primary-text); }
.error-report-dialog__event-select { display: flex !important; grid-template-columns: none !important; align-items: flex-start; gap: 8px; }
.error-report-dialog__event-select input { margin-top: 3px; }
.error-report-notice { position: fixed; top: calc(var(--window-chrome-height) + 14px); right: 16px; z-index: 40; display: flex; align-items: center; gap: 10px; max-width: min(420px, calc(100vw - 2rem)); padding: 12px 14px; border: 1px solid var(--platform-surface-border); border-radius: 12px; background: var(--platform-subpanel-fill); color: var(--platform-text-strong); box-shadow: 0 12px 30px rgb(36 20 12 / 18%); }
.error-report-notice span { flex: 1 1 auto; }
.error-report-notice button { min-height: 36px; border: 0; border-radius: 8px; padding: 0 10px; background: var(--platform-button-primary-fill); color: var(--platform-button-primary-text); }
.error-report-notice button:last-child { width: 36px; padding: 0; background: transparent; color: var(--platform-text-soft); font-size: 20px; }
@media (max-width: 560px) {
.error-report-notice { top: calc(var(--window-chrome-height) + 8px); right: 8px; left: 8px; max-width: none; }
}
@media (max-width: 480px) {
.client-auth-shell {