实现自动错误通知与报告面板
新增五秒聚合的非阻塞错误通知 支持事件选择并在日志读取完成前禁止提交 扩大移动端关闭按钮触控区域
This commit is contained in:
@@ -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([])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user