返回 DeepSeek-Reasonix
FrontendDiagnosticsControl.tsx
根目录 / desktop / frontend / src / components / FrontendDiagnosticsControl.tsx
1 import { useCallback, useEffect, useRef, useState } from "react";
2 import { Activity, Download, Flag, X } from "lucide-react";
3 import { app } from "../lib/bridge";
4 import { useToast } from "../lib/toast";
5 import { useI18n, type Locale } from "../lib/i18n";
6 import {
7 frontendDiagnosticSample,
8 frontendDiagnostics,
9 type FrontendDiagnosticSnapshot,
10 } from "../lib/frontendDiagnostics";
11
12 const defaultFilename = (reportId: string) => `reasonix-frontend-diagnostics-${reportId.slice(0, 8) || "trace"}.json`;
13 const COPY: Record<Locale, {
14 start: string;
15 startHint: string;
16 recordingLabel: string;
17 readyLabel: string;
18 recording: (seconds: number, events: number) => string;
19 ready: (events: number) => string;
20 export: string;
21 stopExport: string;
22 mark: string;
23 cancel: string;
24 }> = {
25 en: {
26 start: "Frontend diagnostics",
27 startHint: "Start recording a local frontend interaction timeline",
28 recordingLabel: "Frontend diagnostics · on",
29 readyLabel: "Frontend diagnostics · ready",
30 recording: (seconds, events) => `Recording - ${seconds}s - ${events} events`,
31 ready: (events) => `Trace ready - ${events} events`,
32 export: "Export",
33 stopExport: "Stop & export",
34 mark: "Mark current issue",
35 cancel: "Cancel recording",
36 },
37 zh: {
38 start: "前端诊断",
39 startHint: "开始记录一段仅保存在本地的前端交互时间线",
40 recordingLabel: "前端诊断 · 已开启",
41 readyLabel: "前端诊断 · 待导出",
42 recording: (seconds, events) => `记录中 - ${seconds} 秒 - ${events} 条事件`,
43 ready: (events) => `记录完成 - ${events} 条事件`,
44 export: "导出",
45 stopExport: "停止并导出",
46 mark: "标记当前问题",
47 cancel: "取消记录",
48 },
49 "zh-TW": {
50 start: "前端診斷",
51 startHint: "開始記錄一段僅儲存在本機的前端互動時間線",
52 recordingLabel: "前端診斷 · 已開啟",
53 readyLabel: "前端診斷 · 待匯出",
54 recording: (seconds, events) => `記錄中 - ${seconds} 秒 - ${events} 筆事件`,
55 ready: (events) => `記錄完成 - ${events} 筆事件`,
56 export: "匯出",
57 stopExport: "停止並匯出",
58 mark: "標記目前問題",
59 cancel: "取消記錄",
60 },
61 };
62
63 export type FrontendDiagnosticsControlProps = {
64 scrollElement?: HTMLElement | null;
65 totalRows?: number;
66 embedded?: boolean;
67 };
68
69 export function FrontendDiagnosticsControl({
70 scrollElement,
71 totalRows = 0,
72 embedded = false,
73 }: FrontendDiagnosticsControlProps) {
74 const { locale } = useI18n();
75 const copy = COPY[locale];
76 const scrollElementRef = useRef<HTMLElement | null>(scrollElement ?? null);
77 const totalRowsRef = useRef(totalRows);
78 scrollElementRef.current = scrollElement ?? null;
79 totalRowsRef.current = totalRows;
80 const { showToast } = useToast();
81 const [snapshot, setSnapshot] = useState<FrontendDiagnosticSnapshot>(() => frontendDiagnostics.getSnapshot());
82 const [exporting, setExporting] = useState(false);
83
84 const refresh = useCallback(() => setSnapshot(frontendDiagnostics.getSnapshot()), []);
85 useEffect(() => frontendDiagnostics.subscribe(refresh), [refresh]);
86 useEffect(() => {
87 if (snapshot.status !== "recording") return;
88 const timer = window.setInterval(refresh, 500);
89 return () => window.clearInterval(timer);
90 }, [refresh, snapshot.status]);
91
92 const start = useCallback(() => {
93 frontendDiagnostics.start(() => frontendDiagnosticSample(scrollElementRef.current, totalRowsRef.current));
94 }, []);
95
96 const exportTrace = useCallback(async () => {
97 setExporting(true);
98 try {
99 const payload = frontendDiagnostics.stop();
100 const path = await app.PickExportFile(defaultFilename(payload.manifest.reportId), "application/json");
101 if (path) {
102 await app.SaveExportFile(path, JSON.stringify(payload, null, 2), false);
103 frontendDiagnostics.reset();
104 }
105 } catch (error) {
106 showToast(error instanceof Error ? error.message : String(error), "error");
107 } finally {
108 setExporting(false);
109 }
110 }, [showToast]);
111
112 const toggle = useCallback(() => {
113 if (snapshot.status === "idle") {
114 start();
115 return;
116 }
117 void exportTrace();
118 }, [exportTrace, snapshot.status, start]);
119 const rootClass = `scroll-diagnostics frontend-diagnostics${embedded ? " frontend-diagnostics--embedded" : ""}`;
120
121 if (snapshot.status === "idle") {
122 return (
123 <div className={rootClass} data-testid="frontend-diagnostics">
124 <button
125 type="button"
126 className="frontend-diagnostics__toggle"
127 role="switch"
128 aria-checked="false"
129 onClick={start}
130 title={copy.startHint}
131 >
132 <Activity size={15} aria-hidden="true" />
133 <span>{copy.start}</span>
134 </button>
135 </div>
136 );
137 }
138
139 const elapsedSeconds = Math.min(120, Math.floor(snapshot.durationMs / 1_000));
140 const recording = snapshot.status === "recording";
141 return (
142 <div className={`${rootClass} scroll-diagnostics--${snapshot.status}`} data-testid="frontend-diagnostics">
143 <button
144 type="button"
145 className={`frontend-diagnostics__toggle${recording ? " frontend-diagnostics__toggle--on" : ""}`}
146 role="switch"
147 aria-checked={recording}
148 onClick={toggle}
149 disabled={exporting}
150 title={recording ? copy.stopExport : copy.export}
151 >
152 <Activity size={15} aria-hidden="true" />
153 <span>{recording ? copy.recordingLabel : copy.readyLabel}</span>
154 </button>
155 <span className="scroll-diagnostics__status" role="status">
156 <span className="scroll-diagnostics__dot" aria-hidden="true" />
157 {recording ? copy.recording(elapsedSeconds, snapshot.eventCount) : copy.ready(snapshot.eventCount)}
158 </span>
159 {recording && (
160 <button
161 type="button"
162 className="scroll-diagnostics__icon-button"
163 onClick={() => frontendDiagnostics.mark()}
164 aria-label={copy.mark}
165 title={copy.mark}
166 >
167 <Flag size={15} aria-hidden="true" />
168 </button>
169 )}
170 <button
171 type="button"
172 className="scroll-diagnostics__export"
173 onClick={() => void exportTrace()}
174 disabled={exporting}
175 title={recording ? copy.stopExport : copy.export}
176 >
177 <Download size={15} aria-hidden="true" />
178 <span>{copy.export}</span>
179 </button>
180 <button
181 type="button"
182 className="scroll-diagnostics__icon-button"
183 onClick={() => frontendDiagnostics.reset()}
184 aria-label={copy.cancel}
185 title={copy.cancel}
186 >
187 <X size={15} aria-hidden="true" />
188 </button>
189 </div>
190 );
191 }
192
192 lines Plain Text