7 Commits

30 changed files with 1801 additions and 461 deletions

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { flushSync } from 'react-dom';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import Sidebar from '../components/Sidebar'; import Sidebar from '../components/Sidebar';
import { import {
@@ -69,8 +70,6 @@ export default function ReportEditor() {
const contentRef = useRef(''); const contentRef = useRef('');
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId }); const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId });
stateRef.current = { reportData, videos, capturedFrames, activeTab, loadedTemplateId };
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : ''; const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
const updatePageHeight = () => { const updatePageHeight = () => {
@@ -83,17 +82,21 @@ export default function ReportEditor() {
}; };
const saveDraftToStorage = React.useCallback(() => { const saveDraftToStorage = React.useCallback(() => {
stateRef.current = { reportData, videos, capturedFrames, activeTab, loadedTemplateId };
const user = storage.get<User | null>('currentUser', null); const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : ''; const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) { if (key) {
const currentContent = contentRef.current || editorRef.current?.innerHTML || '';
storage.set(key, { storage.set(key, {
content: contentRef.current, content: currentContent,
draftReportId: reportId || null, draftReportId: reportId || null,
...stateRef.current reportData: stateRef.current.reportData,
videos: stateRef.current.videos,
capturedFrames: stateRef.current.capturedFrames,
activeTab: stateRef.current.activeTab,
loadedTemplateId: stateRef.current.loadedTemplateId
}); });
} }
}, [reportId, loadedTemplateId]); }, [reportId]);
useEffect(() => { useEffect(() => {
const user = storage.get<User | null>('currentUser', null); const user = storage.get<User | null>('currentUser', null);
@@ -123,6 +126,13 @@ export default function ReportEditor() {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time)); setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
} }
if (draft.activeTab) setActiveTab(draft.activeTab); if (draft.activeTab) setActiveTab(draft.activeTab);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) { if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content; editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content; contentRef.current = draft.content;
@@ -135,6 +145,12 @@ export default function ReportEditor() {
const found = reports.find(r => r.id === reportId); const found = reports.find(r => r.id === reportId);
if (found) { if (found) {
setReportData(found); setReportData(found);
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
if (editorRef.current) { if (editorRef.current) {
const restoreContent = storage.getSession<string | null>(`restore_${reportId}`, null); const restoreContent = storage.getSession<string | null>(`restore_${reportId}`, null);
if (restoreFlag && restoreContent) { if (restoreFlag && restoreContent) {
@@ -142,8 +158,7 @@ export default function ReportEditor() {
storage.removeSession(`restore_${reportId}`); storage.removeSession(`restore_${reportId}`);
} else { } else {
editorRef.current.innerHTML = found.content; editorRef.current.innerHTML = found.content;
contentRef.current = found.content; contentRef.current = found.content;
contentRef.current = found.content;
} }
contentLoadedRef.current = true; contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0); setTimeout(() => updatePageHeight(), 0);
@@ -171,6 +186,13 @@ export default function ReportEditor() {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time)); setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
} }
if (draft.activeTab) setActiveTab(draft.activeTab); if (draft.activeTab) setActiveTab(draft.activeTab);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) { if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content; editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content; contentRef.current = draft.content;
@@ -185,6 +207,7 @@ export default function ReportEditor() {
const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate); const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate);
if (tpl) { if (tpl) {
setLoadedTemplateId(tpl.id); setLoadedTemplateId(tpl.id);
stateRef.current = { ...stateRef.current, loadedTemplateId: tpl.id };
editorRef.current.innerHTML = tpl.content; editorRef.current.innerHTML = tpl.content;
contentRef.current = tpl.content; contentRef.current = tpl.content;
} else { } else {
@@ -202,25 +225,18 @@ export default function ReportEditor() {
}, [reportId, navigate, draftKey, restoreFlag]); }, [reportId, navigate, draftKey, restoreFlag]);
useEffect(() => { useEffect(() => {
const save = () => { const handleBeforeUnload = () => saveDraftToStorage();
const user = storage.get<User | null>('currentUser', null); const handleVisibilityChange = () => {
const key = user ? `reportEditorDraft_${user.username}` : ''; if (document.visibilityState === 'hidden') saveDraftToStorage();
if (key) {
storage.set(key, {
content: contentRef.current,
draftReportId: reportId || null,
...stateRef.current
});
}
}; };
window.addEventListener('beforeunload', save); window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('visibilitychange', save); document.addEventListener('visibilitychange', handleVisibilityChange);
return () => { return () => {
window.removeEventListener('beforeunload', save); window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', save); document.removeEventListener('visibilitychange', handleVisibilityChange);
save(); saveDraftToStorage();
}; };
}, [reportId]); }, [saveDraftToStorage]);
useEffect(() => { useEffect(() => {
if (!editorRef.current) return; if (!editorRef.current) return;
@@ -285,6 +301,7 @@ export default function ReportEditor() {
sel?.removeAllRanges(); sel?.removeAllRanges();
sel?.addRange(range); sel?.addRange(range);
document.execCommand('delete'); document.execCommand('delete');
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage(); saveDraftToStorage();
} }
return; return;
@@ -439,8 +456,10 @@ export default function ReportEditor() {
if (!videoRef.current || !canvasRef.current || currentVideoIndex === -1) return; if (!videoRef.current || !canvasRef.current || currentVideoIndex === -1) return;
const video = videoRef.current; const video = videoRef.current;
const canvas = canvasRef.current; const canvas = canvasRef.current;
canvas.width = video.videoWidth; const MAX_WIDTH = 800;
canvas.height = video.videoHeight; const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height); ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
@@ -450,7 +469,7 @@ export default function ReportEditor() {
videoName: videos[currentVideoIndex].name, videoName: videos[currentVideoIndex].name,
time: video.currentTime, time: video.currentTime,
timeFormatted: formatTime(video.currentTime), timeFormatted: formatTime(video.currentTime),
dataUrl: canvas.toDataURL('image/jpeg', 0.9), dataUrl: canvas.toDataURL('image/jpeg', 0.6),
isManual: true isManual: true
}; };
const nextFrames = [...capturedFrames, newFrame].sort((a, b) => a.time - b.time); const nextFrames = [...capturedFrames, newFrame].sort((a, b) => a.time - b.time);
@@ -474,8 +493,9 @@ export default function ReportEditor() {
const wasPlaying = !video.paused; const wasPlaying = !video.paused;
if (wasPlaying) video.pause(); if (wasPlaying) video.pause();
const newFrames: CapturedFrame[] = []; let accumulatedFrames = [...capturedFrames];
for (const pos of positions) { for (let i = 0; i < positions.length; i++) {
const pos = positions[i];
const time = (pos / 100) * dur; const time = (pos / 100) * dur;
video.currentTime = time; video.currentTime = time;
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
@@ -485,8 +505,10 @@ export default function ReportEditor() {
}; };
video.addEventListener('seeked', onSeeked); video.addEventListener('seeked', onSeeked);
}); });
canvas.width = video.videoWidth; const MAX_WIDTH = 800;
canvas.height = video.videoHeight; const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const newFrame: CapturedFrame = { const newFrame: CapturedFrame = {
id: Date.now() + Math.random(), id: Date.now() + Math.random(),
@@ -494,27 +516,37 @@ export default function ReportEditor() {
videoName: videos[currentVideoIndex].name, videoName: videos[currentVideoIndex].name,
time, time,
timeFormatted: formatTime(time), timeFormatted: formatTime(time),
dataUrl: canvas.toDataURL('image/jpeg', 0.9), dataUrl: canvas.toDataURL('image/jpeg', 0.6),
isManual: false isManual: false
}; };
newFrames.push(newFrame); accumulatedFrames = [...accumulatedFrames, newFrame].sort((a, b) => a.time - b.time);
if (settings.autoInsertFrames && editorRef.current) { flushSync(() => {
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null; setCapturedFrames(accumulatedFrames);
if (emptyPlaceholder) { });
emptyPlaceholder.innerHTML = ` stateRef.current = { ...stateRef.current, capturedFrames: accumulatedFrames };
<span class="delete-btn" contenteditable="false">×</span> if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.includes(i)) {
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false"> const baseDelay = (settings.autoInsertDelay || 0) * 1000;
`; const insertOrderIndex = settings.autoInsertFrameIndices.indexOf(i);
emptyPlaceholder.classList.add('has-image'); const actualDelay = baseDelay > 0 ? baseDelay * (insertOrderIndex + 1) : 0;
}
setTimeout(() => {
if (!editorRef.current) return;
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
if (emptyPlaceholder) {
emptyPlaceholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
`;
emptyPlaceholder.classList.add('has-image');
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
}, actualDelay);
} }
} }
if (settings.autoInsertFrames && editorRef.current) { if (settings.autoInsertFrames && editorRef.current) {
contentRef.current = editorRef.current.innerHTML; contentRef.current = editorRef.current.innerHTML;
} }
const nextFrames = [...capturedFrames, ...newFrames].sort((a, b) => a.time - b.time);
setCapturedFrames(nextFrames);
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
if (wasPlaying) video.play(); if (wasPlaying) video.play();
saveDraftToStorage(); saveDraftToStorage();
}; };
@@ -618,8 +650,7 @@ export default function ReportEditor() {
if (tpl) { if (tpl) {
editorRef.current.innerHTML = tpl.content; editorRef.current.innerHTML = tpl.content;
contentRef.current = tpl.content; contentRef.current = tpl.content;
setLoadedTemplateId(tpl.id); const nextReportData = {
setReportData({
title: tpl.name || '腹腔镜胆囊切除术报告', title: tpl.name || '腹腔镜胆囊切除术报告',
patientName: '', patientName: '',
hospitalId: '', hospitalId: '',
@@ -637,11 +668,21 @@ export default function ReportEditor() {
anesthesiologist: [], anesthesiologist: [],
anesthesiaType: '', anesthesiaType: '',
status: 'draft' status: 'draft'
}); };
setLoadedTemplateId(tpl.id);
setReportData(nextReportData);
setVideos([]); setVideos([]);
setCapturedFrames([]); setCapturedFrames([]);
setCurrentVideoIndex(-1); setCurrentVideoIndex(-1);
prevVideoCountRef.current = 0; prevVideoCountRef.current = 0;
stateRef.current = {
...stateRef.current,
loadedTemplateId: tpl.id,
reportData: nextReportData,
videos: [],
capturedFrames: [],
activeTab: stateRef.current.activeTab
};
updatePageHeight(); updatePageHeight();
saveDraftToStorage(); saveDraftToStorage();
} }
@@ -663,6 +704,13 @@ export default function ReportEditor() {
contentRef.current = draft.content; contentRef.current = draft.content;
contentLoadedRef.current = true; contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || ''); setLoadedTemplateId(draft.loadedTemplateId || '');
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
setTimeout(() => updatePageHeight(), 0); setTimeout(() => updatePageHeight(), 0);
return; return;
} }
@@ -677,6 +725,12 @@ export default function ReportEditor() {
editorRef.current.innerHTML = found.content; editorRef.current.innerHTML = found.content;
} }
contentLoadedRef.current = true; contentLoadedRef.current = true;
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
setTimeout(() => updatePageHeight(), 0); setTimeout(() => updatePageHeight(), 0);
return; return;
} }
@@ -686,6 +740,13 @@ export default function ReportEditor() {
contentRef.current = draft.content; contentRef.current = draft.content;
contentLoadedRef.current = true; contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || ''); setLoadedTemplateId(draft.loadedTemplateId || '');
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
setTimeout(() => updatePageHeight(), 0); setTimeout(() => updatePageHeight(), 0);
return; return;
} }
@@ -700,6 +761,7 @@ export default function ReportEditor() {
const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate); const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate);
if (tpl) { if (tpl) {
setLoadedTemplateId(tpl.id); setLoadedTemplateId(tpl.id);
stateRef.current = { ...stateRef.current, loadedTemplateId: tpl.id };
editorRef.current.innerHTML = tpl.content; editorRef.current.innerHTML = tpl.content;
} else { } else {
editorRef.current.innerHTML = defaultReportContent; editorRef.current.innerHTML = defaultReportContent;
@@ -709,7 +771,7 @@ export default function ReportEditor() {
} }
contentLoadedRef.current = true; contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0); setTimeout(() => updatePageHeight(), 0);
}); }, []);
const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0')); const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
const minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0')); const minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));

View File

@@ -14,8 +14,7 @@ export default function SystemSettings() {
apiEndpoint: '', apiEndpoint: '',
apiKey: '', apiKey: '',
defaultTemplate: '', defaultTemplate: '',
frameMode: 'uniform', frameMode: 'uniform'
autoInsertFrames: false
}); });
const [templates, setTemplates] = useState<Template[]>([]); const [templates, setTemplates] = useState<Template[]>([]);
const [isSaved, setIsSaved] = useState(false); const [isSaved, setIsSaved] = useState(false);
@@ -173,18 +172,6 @@ export default function SystemSettings() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoInsertFrames"
checked={settings.autoInsertFrames || false}
onChange={(e) => setSettings({ ...settings, autoInsertFrames: e.target.checked })}
className="w-4 h-4 accent-accent cursor-pointer"
/>
<label htmlFor="autoInsertFrames" className="text-sm text-text-main cursor-pointer"></label>
</div>
<p className="text-[11px] text-text-muted"></p>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (%)</label> <label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (%)</label>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-3">
@@ -204,11 +191,6 @@ export default function SystemSettings() {
className="input-minimal w-full pr-6 text-center" className="input-minimal w-full pr-6 text-center"
/> />
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-text-muted">%</span> <span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-text-muted">%</span>
{settings.autoInsertFrames && (
<span className="absolute bottom-0.5 right-1 text-green-500 pointer-events-none">
<Check size={10} />
</span>
)}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {

View File

@@ -75,5 +75,4 @@ export interface SystemSettings {
apiKey: string; apiKey: string;
defaultTemplate?: string; defaultTemplate?: string;
frameMode?: 'uniform' | 'keep'; frameMode?: 'uniform' | 'keep';
autoInsertFrames?: boolean;
} }

View File

@@ -11,8 +11,8 @@ export const storage = {
set<T>(key: string, value: T): void { set<T>(key: string, value: T): void {
try { try {
localStorage.setItem(key, JSON.stringify(value)); localStorage.setItem(key, JSON.stringify(value));
} catch { } catch (e) {
// ignore quota exceeded console.error('Storage save failed (possibly quota exceeded):', e);
} }
}, },
@@ -32,8 +32,8 @@ export const storage = {
setSession<T>(key: string, value: T): void { setSession<T>(key: string, value: T): void {
try { try {
sessionStorage.setItem(key, JSON.stringify(value)); sessionStorage.setItem(key, JSON.stringify(value));
} catch { } catch (e) {
// ignore console.error('Session storage save failed:', e);
} }
}, },

View File

@@ -0,0 +1,97 @@
# 代码编纂工作流
> 本工作流为项目修改类需求的标准执行流程。后续所有项目修改相关需求,均需严格按以下步骤执行。
---
## 前置约定
- 时间戳格式:`{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`
- 示例:`2026-04-16-18-35-00`
- 所有方案文档均存放于 `.\工程分析\` 目录下。
---
## 执行步骤
### Step 0. 记录开始时间
每次执行前,以当前时间生成时间戳,作为本次需求的唯一标识。
### Step 1. 创建/确认工程分析目录
确保 `.\工程分析\` 文件夹存在。如不存在,则自动创建。
### Step 2. 需求分析
将用户提出的需求整理、拆解、澄清后,写入文档:
```
.\工程分析\需求分析-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md
```
内容要求:
- 原始需求摘要
- 需求拆解(功能点 / 非功能点)
- 待确认问题(如有)
- 影响范围预估
### Step 3. 实现方案(需人工确认)
基于需求分析,撰写详细的实现方案,写入文档:
```
.\工程分析\实现方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md
```
内容要求:
- 实现思路与架构决策
- 涉及修改的文件清单
- 具体的代码变更说明
- 风险点与回滚策略
**⚠️ 此文档写完后,必须提交给用户进行二次人工审核确认,得到明确批准后方可进入下一步。**
### Step 4. 测试方案(需人工确认)
基于实现方案,撰写测试方案,写入文档:
```
.\工程分析\测试方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md
```
内容要求:
- 测试目标
- 测试用例设计
- 测试环境准备
- 验收标准
**⚠️ 此文档写完后,必须提交给用户进行二次人工审核确认,得到明确批准后方可进入下一步。**
### Step 5. 执行修改前准备
1. **阅读 `.\工程分析\经验记录.md`**,回顾历史问题,避免重复犯错。
2. 确认实现方案和测试方案均已获得用户批准。
### Step 6. 执行修改
按照已批准的实现方案和测试方案,执行具体的代码修改与测试验证。
### Step 7. 更新经验记录
修改完成后,将本次执行过程中遇到的关键问题及解决方案,以 **四段式** 追加写入 `.\工程分析\经验记录.md`
- **A. 具体问题**
- **B. 产生问题原因**
- **C. 解决问题方案**
- **D. 后续如何避免问题**
### Step 8. Gitea 备份 Commit
`.\工程分析\` 目录下的所有文档使用 Gitea 进行备份,提交 Commit。
Commit Message 格式:
```
{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec} - {本次修改的简要描述}
```
Commit 完成后,提醒用户备份已完成。
---
## 当前状态
- [x] 工作流文档建立
- [x] 工程分析目录创建
- [x] 经验记录初始文档创建

View File

@@ -1,191 +0,0 @@
# 实现方案 — 2026-04-16-17-40-20
## 技术思路
本次需求包含三个相互关联的修复/优化点:
1. **自动抽帧图片在页面切换后丢失**:根因在于 `saveDraftToStorage` 依赖的 `stateRef.current``setCapturedFrames` 异步更新后未及时同步,导致组件卸载前保存的 draft 中缺失自动帧。
2. **编辑后切换界面模板选择器显示"无"**:根因是 `loadedTemplateId` 未纳入 `stateRef.current`,且 `saveDraftToStorage` 因 stale closure 捕获了过时的 `loadedTemplateId` 值(空字符串)。
3. **自动帧插入开关**:在 `SystemSettings` 中新增 `autoInsertFrames` 布尔开关,并在 `autoCaptureFrames` 中支持自动将新生成的帧填充到第一个空置占位符。
---
## 修改文件清单
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `src/types.ts` | 修改 | `SystemSettings` 增加 `autoInsertFrames?: boolean` |
| `src/pages/SystemSettings.tsx` | 修改 | 新增"开启自动帧插入"开关及抽帧位置对勾标记 |
| `src/pages/ReportEditor.tsx` | 修改 | 修复 `stateRef` 同步逻辑、自动帧插入实现 |
---
## 关键代码变更说明
### 1. `src/types.ts` — 扩展类型
```tsx
export interface SystemSettings {
frameCount: number;
framePositions: number[];
apiEndpoint: string;
apiKey: string;
defaultTemplate?: string;
frameMode?: 'uniform' | 'keep';
autoInsertFrames?: boolean; // 新增
}
```
### 2. `src/pages/SystemSettings.tsx` — 新增开关与 UI 标记
在"视频抽帧配置"区域(`framePositions` 列表上方)增加开关:
```tsx
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoInsertFrames"
checked={settings.autoInsertFrames || false}
onChange={(e) => setSettings({ ...settings, autoInsertFrames: e.target.checked })}
className="w-4 h-4 accent-accent cursor-pointer"
/>
<label htmlFor="autoInsertFrames" className="text-sm text-text-main cursor-pointer"></label>
</div>
<p className="text-[11px] text-text-muted"></p>
```
`settings.framePositions` 的每个 input 框中,当 `autoInsertFrames` 开启时显示绿色对勾(`Check` 图标):
```tsx
{settings.autoInsertFrames && (
<span className="absolute bottom-0.5 right-1 text-green-500 pointer-events-none">
<Check size={10} />
</span>
)}
```
注意:需确保 `Check` 图标已从 `lucide-react` 导入(当前页面已导入)。
### 3. `src/pages/ReportEditor.tsx` — 修复状态同步与自动插入
#### 3.1 将 `loadedTemplateId` 纳入 `stateRef`
修改第 72 行附近的 ref 定义:
```tsx
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId });
// ...
stateRef.current = { reportData, videos, capturedFrames, activeTab, loadedTemplateId };
```
#### 3.2 精简 `saveDraftToStorage`
移除独立的 `loadedTemplateId` 字段(已由 `stateRef.current` 包含),并加入 `syncStateToRef`
```tsx
const saveDraftToStorage = React.useCallback(() => {
stateRef.current = { reportData, videos, capturedFrames, activeTab, loadedTemplateId };
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: contentRef.current,
draftReportId: reportId || null,
...stateRef.current
});
}
}, [reportId, loadedTemplateId]);
```
> 保留 `loadedTemplateId` 在依赖数组中无实际副作用,但可确保 lint 不报警。
#### 3.3 修复 `autoCaptureFrames` 中 `capturedFrames` 不同步问题
`autoCaptureFrames` 函数中,生成所有帧后、调用 `saveDraftToStorage` 前,手动将最新帧列表同步到 `stateRef.current`
```tsx
const nextFrames = [...capturedFrames, ...newFrames].sort((a, b) => a.time - b.time);
setCapturedFrames(nextFrames);
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
if (wasPlaying) video.play();
saveDraftToStorage();
```
同时,若开启了 `autoInsertFrames`,在循环中每生成一帧立即尝试插入第一个空置占位符(不触发 save
```tsx
for (const pos of positions) {
... // 生成 frame
newFrames.push(newFrame);
if (settings.autoInsertFrames && editorRef.current) {
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
if (emptyPlaceholder) {
emptyPlaceholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
`;
emptyPlaceholder.classList.add('has-image');
}
}
}
// 循环结束后统一更新 contentRef 与 save
if (settings.autoInsertFrames && editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
}
const nextFrames = [...capturedFrames, ...newFrames].sort((a, b) => a.time - b.time);
setCapturedFrames(nextFrames);
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
if (wasPlaying) video.play();
saveDraftToStorage();
```
#### 3.4 修复 `captureFrame` 中状态同步
```tsx
const nextFrames = [...capturedFrames, newFrame].sort((a, b) => a.time - b.time);
setCapturedFrames(nextFrames);
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
saveDraftToStorage();
```
#### 3.5 修复 `removeVideo` 中状态同步
```tsx
setVideos(updated);
stateRef.current = { ...stateRef.current, videos: updated };
// ... 调整 currentVideoIndex 后
const nextCaptured = prev => prev.filter(...).map(...).sort(...);
setCapturedFrames(nextCaptured);
stateRef.current = { ...stateRef.current, capturedFrames: nextCaptured };
saveDraftToStorage();
```
#### 3.6 修复 `handleVideoUpload` 中状态同步
```tsx
setVideos(combined);
stateRef.current = { ...stateRef.current, videos: combined };
setCurrentVideoIndex(videos.length);
if (videoInputRef.current) videoInputRef.current.value = '';
saveDraftToStorage();
```
#### 3.7 恢复 draft 时 `loadedTemplateId` 读取调整
由于 `loadedTemplateId` 现在已通过 `stateRef.current` 保存到 draft 中(键名为 `loadedTemplateId`),恢复逻辑中的 `setLoadedTemplateId(draft.loadedTemplateId || '')` 保持不变即可。但因为 `saveDraftToStorage` 每次调用前都会同步 `stateRef.current`draft 中的 `loadedTemplateId` 将始终是最新的。
---
## 风险点及应对策略
| 风险 | 影响 | 应对策略 |
|------|------|----------|
| `autoInsertFrames` 开启时循环内频繁写 DOM | 性能轻微下降 | 循环内只修改 DOM不调用 `saveDraftToStorage`,统一在循环结束后保存 |
| `stateRef.current` 手动同步遗漏其他 setState 点 | 其他状态仍可能丢失 | 本次重点修复 `capturedFrames``videos``loadedTemplateId` 三个关键字段;`reportData``activeTab` 在现有代码中已在事件处理内手动同步到 `stateRef` |
| `Check` 图标与现有 % 符号/删除按钮重叠 | 布局错乱 | 将 Check 放在 input 框内右下角 (`bottom-0.5 right-1`),不与居中 % 符号重叠 |
| 旧 `systemSettings` 没有 `autoInsertFrames` 字段 | 读取 undefined | 所有访问点使用 `settings.autoInsertFrames || false` 兜底 |
---
## 改动范围总结
- 三个文件修改,不引入新依赖。
- 自动帧插入默认关闭,不影响现有用户体验。

View File

@@ -0,0 +1,118 @@
# 实现方案 — 2026-04-16-18-51-06
## 根因分析
`ReportEditor.tsx` 中使用了 `stateRef.current` 作为「草稿自动保存」的数据来源。组件卸载时,`saveDraftToStorage()` 会将 `stateRef.current` 写入 `localStorage`
但在页面初始化(`useEffect``useLayoutEffect`)从已保存报告或 draft 恢复数据时,仅通过 `setState` 更新了 React state**没有同步更新 `stateRef.current` 中的 `videos``capturedFrames` 字段**。
这导致:
1. 用户首次进入 `/report-editor` 时,数据从 localStorage 正确恢复;
2. 用户离开页面时,`stateRef.current` 仍保存着初始的空数组;
3. 组件卸载触发的 `saveDraftToStorage()` 用空数组覆盖了 draft
4. 用户再次返回 `/report-editor` 时,系统优先读取被覆盖后的 draft导致视频分析数据全部丢失。
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 在 4 处数据恢复逻辑后追加 `stateRef.current` 同步赋值 |
## 具体代码变更
### 修改点 1初始化 useEffect — 从 draft 恢复已有报告(约第 128 行后)
在已有代码:
```tsx
setLoadedTemplateId(draft.loadedTemplateId || '');
stateRef.current = { ...stateRef.current, loadedTemplateId: draft.loadedTemplateId || '' };
```
**追加同步**
```tsx
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
```
### 修改点 2初始化 useEffect — 从已保存报告found恢复约第 146 行后)
在设置完 `contentLoadedRef.current = true;` 之后,**追加同步**
```tsx
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
```
### 修改点 3初始化 useEffect — 从 draft 恢复新建报告(约第 176 行后)
与修改点 1 类似,在 `setLoadedTemplateId(draft.loadedTemplateId || '');` 之后,**追加同步**
```tsx
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
```
### 修改点 4useLayoutEffect 安全网 — 从 draft 恢复已有报告(约第 677 行后)
`setLoadedTemplateId(draft.loadedTemplateId || '');` 之后,**追加同步**
```tsx
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
```
### 修改点 5useLayoutEffect 安全网 — 从已保存报告found恢复约第 692 行后)
`contentLoadedRef.current = true;` 之后,**追加同步**
```tsx
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
```
### 修改点 6useLayoutEffect 安全网 — 从 draft 恢复新建报告(约第 701 行后)
`setLoadedTemplateId(draft.loadedTemplateId || '');` 之后,**追加同步**
```tsx
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
```
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| `stateRef` 仍可能在其他未覆盖路径中不同步 | 低 | 已检查所有数据恢复入口init effect + layout effect后续若新增恢复逻辑需保持同步习惯 |
| `found.videos` / `found.capturedFrames` 为 undefined | 低 | 代码中使用 `|| []` 做防御性处理 |
## 回滚策略
本次修改仅增加 `stateRef.current` 的同步赋值语句,不涉及删除或重构现有逻辑。如出现异常,可直接 `git revert` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**

View File

@@ -0,0 +1,132 @@
# 实现方案 — 2026-04-16-19-06-18
## 根因分析
### 问题 1路由切换后所有内容丢失
`ReportEditor.tsx` 的初始化 `useEffect` 在从 draft 恢复数据时,将 `stateRef.current` 的同步代码放在了 `if (editorRef.current && draft.content.trim().length > 0)` 条件块的**内部**。
这导致当以下任一情况发生时,`stateRef.current` 不会被同步:
- `editorRef.current` 在 useEffect 执行时尚未挂载(组件首次渲染时常见);
- `draft.content` 为空字符串或仅包含空白(新建报告时常见)。
`stateRef.current` 保持为初始值(空的 `reportData`、空的 `videos`、空的 `capturedFrames`)时,用户离开页面触发的 `saveDraftToStorage()` 会**用这些空值覆盖 localStorage 中的 draft**。再次返回 `/report-editor` 时,系统读取了这个被清空的 draft导致所有内容全部丢失。
### 问题 2自动帧插入 UI 批量刷新
`autoCaptureFrames` 是一个 `async` 函数,内部通过 `for` 循环逐帧处理。循环中每次摘取到新帧后都会调用 `setCapturedFrames(accumulatedFrames)`,但由于 React 18 的**自动批处理**机制在异步函数中连续调用的状态更新会被合并DOM 重渲染被推迟到整个循环结束后才执行一次。因此用户看不到逐帧实时摘取的过程,只有等全部帧处理完后,关键帧列表和 placeholder 中的图片才会一次性批量出现。
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 移动 stateRef 同步位置 + 引入 `flushSync` |
## 具体代码变更
### 变更 1useEffect — draft 恢复已有报告(约第 123 行区域)
**当前代码(有问题的结构):**
```tsx
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
// ...
stateRef.current = { ...stateRef.current, reportData: draft.reportData, ... };
}
```
**修改为:**
`stateRef.current` 的赋值提取到 `if (editorRef.current && ...)` 的**外部**,紧接在 `setCapturedFrames` 之后:
```tsx
if (draft.activeTab) setActiveTab(draft.activeTab);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
}
```
### 变更 2useEffect — found 恢复已有报告(约第 141 行区域)
**修改为:**
`stateRef.current` 的赋值提取到 `if (editorRef.current)` 的**外部**,紧接在 `setReportData(found)` 之后:
```tsx
setReportData(found);
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
if (editorRef.current) {
// ... 恢复 editor content ...
}
```
同时顺手清理重复赋值 `contentRef.current = found.content;`(第 149-150 行重复)。
### 变更 3useEffect — draft 恢复新建报告(约第 184 行区域)
**修改为:**
与变更 1 类似,将 `stateRef.current` 的赋值提取到 `if (editorRef.current && ...)` 的**外部**
```tsx
if (draft.activeTab) setActiveTab(draft.activeTab);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
// ... 恢复 editor content ...
}
```
### 变更 4autoCaptureFrames 引入 flushSync约第 519-521 行区域)
在文件顶部增加导入:
```tsx
import { flushSync } from 'react-dom';
```
`autoCaptureFrames` 的 for 循环中,每次更新 `capturedFrames` 时使用 `flushSync` 强制同步渲染:
**当前代码:**
```tsx
setCapturedFrames(accumulatedFrames);
stateRef.current = { ...stateRef.current, capturedFrames: accumulatedFrames };
```
**修改为:**
```tsx
flushSync(() => {
setCapturedFrames(accumulatedFrames);
});
stateRef.current = { ...stateRef.current, capturedFrames: accumulatedFrames };
```
这样每一帧被摘取后都会立即触发 DOM 更新,用户可以在右侧「视频分析」面板中实时看到关键帧逐张出现,同时自动插入逻辑也能按配置延迟后逐张填充 placeholder。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| `flushSync` 在循环中可能导致轻微渲染卡顿 | 低 | 关键帧数量通常仅 10~20 张,现代浏览器完全可以承受;已在 async 函数中使用,不会阻塞视频 seek 事件 |
| 移动 `stateRef` 同步位置可能与其他逻辑产生时序影响 | 低 | 仅将同步从 `if` 内部移到外部,逻辑语义完全一致,只是确保在所有路径下都被执行 |
| `found` 分支中 `contentRef.current` 重复赋值 | 极低 | 顺手清理,不影响行为 |
## 回滚策略
本次修改仅调整代码执行顺序并引入一个 React 标准 API`flushSync`),未改变数据结构或接口。如出现异常,可直接 `git revert` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**

View File

@@ -0,0 +1,25 @@
# 实现方案 — 2026-04-16-19-18-14
## 部署步骤
1. **构建生产包**:运行 `npm run build` 先本地验证构建是否通过(可选但推荐)。
2. **停止旧容器**`docker-compose down` 停止并移除当前运行的 `medical-report-app` 容器。
3. **重新构建镜像**`docker-compose build --no-cache` 基于最新代码重新构建 Docker 镜像。
4. **启动新容器**`docker-compose up -d` 后台启动新容器。
5. **验证部署**:检查容器状态 `docker ps`,并尝试访问 `http://localhost:8080` 确认应用正常。
## 修改文件清单
无需修改源代码,仅执行构建和容器操作。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| 构建失败 | 低 | 本地已执行 `tsc --noEmit` 通过,构建风险低 |
| 端口 8080 被占用 | 低 | `docker-compose down` 会先释放旧容器占用的端口 |
| Docker 未安装/未启动 | 中 | 如遇报错,根据错误信息处理 |
## 回滚策略
若部署后异常,可执行 `docker-compose down` 后回退到上一个可用的 Git commit 再重新构建。

View File

@@ -0,0 +1,141 @@
# 实现方案 — 2026-04-16-19-28-04
## 根因分析
当前 `ReportEditor.tsx` 的自动保存机制过度依赖两个 `useRef``stateRef``contentRef`)作为"数据快照"
1. **用户操作时**:各事件处理器先更新 React state再手动同步 `stateRef.current`,然后调用 `saveDraftToStorage()` 写入 localStorage。
2. **组件卸载时**`useEffect` 的 cleanup 调用 `save()`,同样读取 `stateRef.current``contentRef.current` 来保存 draft。
这个机制存在致命缺陷:
- **React 18 `StrictMode`** 在开发/预览环境下会"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,`stateRef.current` 还是组件创建时的初始空值(`videos: []``capturedFrames: []``reportData: 默认值`。cleanup 中的 `save()` 会用这个空值 **覆盖** localStorage 里已经存在的正确 draft。
- 即使不在 StrictMode 下,只要任何恢复路径或用户操作路径遗漏了 `stateRef.current` 的同步,卸载保存时就会丢失数据。
此前两次修复只把 `stateRef.current` 同步移到了更多恢复分支中,但**没有从根本上消除对 `stateRef` 的依赖**,因此问题依旧。
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 重构 `saveDraftToStorage` + 自动保存 effect + `useLayoutEffect` 依赖 |
## 具体代码变更
### 变更 1重构 `saveDraftToStorage`
**当前实现(依赖 ref**
```tsx
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: contentRef.current,
draftReportId: reportId || null,
...stateRef.current
});
}
}, [reportId]);
```
**修改为(直接从最新 state 和 DOM 读取):**
```tsx
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: editorRef.current?.innerHTML || '',
draftReportId: reportId || null,
reportData,
videos,
capturedFrames,
activeTab,
loadedTemplateId
});
}
}, [reportData, videos, capturedFrames, activeTab, loadedTemplateId, reportId]);
```
**效果**
- `saveDraftToStorage` 的闭包永远绑定当前渲染周期的最新 state不再需要 `stateRef` 作为中转。
- 编辑器内容直接从 `editorRef.current?.innerHTML` 读取,不再依赖可能滞后的 `contentRef`
- 用户操作中所有调用 `saveDraftToStorage()` 的地方(表单 onChange、上传视频、截图等会自动生效。
### 变更 2重构自动保存 effect
**当前实现:**
```tsx
useEffect(() => {
const save = () => { ... };
window.addEventListener('beforeunload', save);
document.addEventListener('visibilitychange', save);
return () => {
window.removeEventListener('beforeunload', save);
document.removeEventListener('visibilitychange', save);
save();
};
}, [reportId]);
```
**修改为:**
```tsx
useEffect(() => {
const handleBeforeUnload = () => saveDraftToStorage();
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') saveDraftToStorage();
};
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
saveDraftToStorage();
};
}, [saveDraftToStorage]);
```
**效果**
- 由于 `saveDraftToStorage` 的 dependency 包含了所有关键 state每次 state 变化后 effect 都会重新注册,但最重要的是 **cleanup 中调用的 `saveDraftToStorage` 永远指向最新的闭包**,不会因为 ref 滞后而用空值覆盖 draft。
### 变更 3给 `useLayoutEffect` 安全网添加 `[]` 依赖
**当前:**
```tsx
React.useLayoutEffect(() => {
if (contentLoadedRef.current || !editorRef.current) return;
// ... 恢复逻辑 ...
});
```
**修改为:**
```tsx
React.useLayoutEffect(() => {
if (contentLoadedRef.current || !editorRef.current) return;
// ... 恢复逻辑 ...
}, []);
```
**效果**
- 避免在每次渲染后重复执行安全网,防止潜在的意外覆盖或性能损耗。
- 保留其原有功能:仅在组件挂载时,如果 `useEffect` 初始化因 ref 未 ready 未能恢复内容,则作为兜底恢复编辑器 DOM。
### 变更 4可选但建议移除对 `contentRef` 的强依赖
`contentRef` 在旧代码中用于 draft 保存。修改 `saveDraftToStorage` 后直接读取 `editorRef.current?.innerHTML``contentRef` 仍可保留供其他旧代码使用,不影响功能。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| `saveDraftToStorage` dependency 数组较长,可能导致 effect 频繁重新注册 | 低 | 重新注册事件监听器的开销极小,远小于 localStorage 写入本身 |
| `editorRef.current?.innerHTML` 在卸载时读取可能拿到不完整 DOM | 极低 | `editorRef` 指向的 DOM 在 cleanup 执行时尚未被 React 移除,内容完整 |
| `useLayoutEffect` 添加 `[]` 后闭包值陈旧 | 低 | `useLayoutEffect` 内部仅读取 `reportId`(来自 URL在生命周期内不变和 localStorage不受影响 |
## 回滚策略
本次修改仅重构保存函数的实现方式,不改变数据结构或存储 key。如出现异常可直接 `git revert` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**

View File

@@ -0,0 +1,116 @@
# 实现方案 — 2026-04-16-20-24-11
## 根因分析
当前问题由两个核心缺陷共同导致:
### 1. `saveDraftToStorage` 的闭包陷阱 + DOM 引用失效
此前为修复 `stateRef` 不同步的问题,将 `saveDraftToStorage` 重构为直接从 React state 读取:
```tsx
const saveDraftToStorage = React.useCallback(() => {
storage.set(key, {
content: editorRef.current?.innerHTML || '',
reportData,
videos,
capturedFrames,
...
});
}, [reportData, videos, capturedFrames, ...]);
```
这引入了新的问题:
- **闭包陷阱**:用户操作后常见写法是 `setCapturedFrames(nextFrames); saveDraftToStorage();`。由于 `setState` 异步,`saveDraftToStorage` 闭包中读取到的 `capturedFrames` 仍是旧值(空数组),导致旧值覆盖 localStorage。
- **卸载时 DOM 失效**:组件卸载时 React 开始销毁 DOM`editorRef.current` 可能已经变为 `null` 或其 `innerHTML` 已为空,导致 `content: editorRef.current?.innerHTML || ''` 保存了空字符串,覆盖了已有的报告内容。
### 2. `contentRef` 更新遗漏
代码中部分修改编辑器 DOM 的路径没有同步更新 `contentRef.current`。例如 `handleEditorClick` 中通过 `document.execCommand('delete')` 删除 placeholder 后,直接调用了 `saveDraftToStorage()`,但没有先更新 `contentRef`
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 重构 `saveDraftToStorage` + 补齐 `contentRef` 遗漏点 |
## 具体代码变更
### 变更 1重构 `saveDraftToStorage` 从 Ref 读取
**修改为:**
```tsx
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
const currentContent = contentRef.current || editorRef.current?.innerHTML || '';
storage.set(key, {
content: currentContent,
draftReportId: reportId || null,
reportData: stateRef.current.reportData,
videos: stateRef.current.videos,
capturedFrames: stateRef.current.capturedFrames,
activeTab: stateRef.current.activeTab,
loadedTemplateId: stateRef.current.loadedTemplateId
});
}
}, [reportId]);
```
**效果**
- 数据源回归 `stateRef``contentRef`,彻底摆脱 React state 的闭包陷阱。
- `content` 优先读取 `contentRef.current`(在内存中稳定存在),回退到 `editorRef.current?.innerHTML`(兼容某些未更新 contentRef 的旧路径),兜底为空字符串。即使组件卸载时 DOM 已被销毁,`contentRef.current` 仍保存着最新的编辑器 HTML。
### 变更 2补齐 `contentRef` 更新遗漏
`handleEditorClick``document.execCommand('delete')` 分支中,增加 `contentRef.current` 的同步:
**当前代码(约第 296-304 行):**
```tsx
} else {
const range = document.createRange();
range.selectNode(placeholder);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('delete');
saveDraftToStorage();
}
```
**修改为:**
```tsx
} else {
const range = document.createRange();
range.selectNode(placeholder);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('delete');
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
```
### 变更 3确保自动保存 effect 绑定最新 `saveDraftToStorage`
当前自动保存 effect 已经绑定 `[saveDraftToStorage]`,由于 `saveDraftToStorage` 的 dependency 现在只有 `[reportId]`effect 不会因为 state 变化而频繁重新注册,但 cleanup 中仍然指向最新的保存函数。
### 变更 4初始化恢复时 `contentRef` 同步(已有,确认无误)
`useEffect``useLayoutEffect` 的各恢复分支中,设置 `editorRef.current.innerHTML = draft.content`(或 `found.content`)时,代码已经同步设置了 `contentRef.current = ...`,这部分无需修改。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| 仍有未被发现的 `contentRef` 更新遗漏点 | 低 | 已全面搜索 `innerHTML` 修改点和 `saveDraftToStorage` 调用点,仅发现 1 处遗漏 |
| `stateRef` 在某些新功能中再次不同步 | 低 | `saveDraftToStorage``stateRef` 读取后续新增功能只要保持「setState 后立即同步 stateRef」的习惯即可 |
## 回滚策略
本次修改仅调整 `saveDraftToStorage` 的数据源和补齐一处 `contentRef` 更新,不改变数据结构和接口。如出现异常,可直接 `git revert` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**

View File

@@ -0,0 +1,90 @@
# 实现方案 — 2026-04-16-20-33-12
## 根因分析
`autoCaptureFrames``for` 循环内部,自动插入逻辑使用了:
```tsx
if ((settings.autoInsertDelay || 0) > 0) {
await new Promise<void>(r => setTimeout(r, (settings.autoInsertDelay || 0) * 1000));
}
```
`await` 会暂停整个 `for` 循环的执行,导致:
1. 关键帧摘取被强制暂停,等待延迟结束;
2. 所有帧必须一张一张串行处理,整体耗时 = 摘取时间 + 插入延迟 × 插入帧数;
3. 用户体验上感觉"卡顿"或"慢"。
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 修改 | `autoCaptureFrames` 中自动插入逻辑改为 `setTimeout` 非阻塞执行 |
## 具体代码变更
### 变更:`autoCaptureFrames` 中的插入逻辑(约第 523-535 行)
**当前代码:**
```tsx
if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.includes(i) && editorRef.current) {
if ((settings.autoInsertDelay || 0) > 0) {
await new Promise<void>(r => setTimeout(r, (settings.autoInsertDelay || 0) * 1000));
}
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
if (emptyPlaceholder) {
emptyPlaceholder.innerHTML = `...`;
emptyPlaceholder.classList.add('has-image');
}
}
```
**修改为:**
```tsx
if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.includes(i)) {
const baseDelay = (settings.autoInsertDelay || 0) * 1000;
const insertOrderIndex = settings.autoInsertFrameIndices.indexOf(i);
const actualDelay = baseDelay > 0 ? baseDelay * (insertOrderIndex + 1) : 0;
setTimeout(() => {
if (!editorRef.current) return;
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
if (emptyPlaceholder) {
emptyPlaceholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
`;
emptyPlaceholder.classList.add('has-image');
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
}, actualDelay);
}
```
**效果:**
- `for` 循环全速运行,不再被插入延迟阻塞;
- 每张需要插入的帧按顺序延迟(第 1 张 delay第 2 张 2×delay...),避免同时插入;
- `setTimeout` 回调中实时查询 DOM 获取最新的空 placeholder
- 插入后同步 `contentRef.current` 并保存草稿。
### 附加变更:移除循环后的批量 `contentRef` 更新
当前代码在循环结束后:
```tsx
if (settings.autoInsertFrames && editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
}
```
由于每个 `setTimeout` 回调内部已经单独更新 `contentRef` 和保存草稿,且循环结束时可能 `setTimeout` 尚未执行,这句批量更新既不及时也可能遗漏。建议**移除或保留不影响功能**。为简化逻辑,选择保留但无实质影响,因为非阻塞的 `setTimeout` 回调会各自负责自己的保存。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| `setTimeout` 回调执行时 placeholder 已被用户手动填充 | 低 | 回调中实时查询 `.image-placeholder:not(.has-image)`,找不到则跳过,不会覆盖用户内容 |
| 多张图片按顺序延迟插入时,用户快速离开页面 | 低 | 每次插入后都调用 `saveDraftToStorage()`,已插入的图片会被保存;未执行的 `setTimeout` 自然丢弃 |
## 回滚策略
修改范围极小,仅涉及 `autoCaptureFrames` 中的几行代码。如有异常可直接 revert。

View File

@@ -0,0 +1,105 @@
# 实现方案 — 2026-04-16-20-46-50
## 根因分析
### 1. LocalStorage 容量超限QuotaExceededError
浏览器对单个域名的 `localStorage` 通常有 **5MB** 的严格容量限制。
当前代码在抽帧时使用了视频的原始分辨率:
```tsx
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
```
如果上传的是 1080p 甚至 4K 视频:
- 单张 0.9 质量 JPEG Base64 图片可能达到 **300KB ~ 1MB**
- 自动提取 12 张关键帧 + 手动截图若干张,总数据量可能达到 **5MB ~ 10MB**
- 直接超过 `localStorage` 的 5MB 上限。
### 2. 静默失败
`src/utils/storage.ts` 中的 `set` 方法:
```typescript
set<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// ignore quota exceeded
}
}
```
当数据量超过 5MB 时,`localStorage.setItem` 抛出 `QuotaExceededError`,但被 `catch` 静默吞掉。
**实际发生的过程:**
1. 用户上传视频 → 此时 `videos` 数组中的 `url``blob:http://...`(短字符串),数据量很小,**保存成功**
2. 系统开始自动抽帧,生成巨大的 Base64 `dataUrl` 数组;
3. 调用 `saveDraftToStorage()` 尝试保存时,`localStorage.setItem` 触发超限报错;
4. 异常被 `catch` 忽略,**draft 没有被更新**(或更新失败);
5. 当用户离开页面再返回时localStorage 中读到的 draft 仍然停留在"仅有视频、没有关键帧"的状态。
这就是为什么:编辑器内容保留了,视频保留了,但**关键帧全部消失**。
## 修改方向
### 方向一:压缩关键帧分辨率与质量(快速修复,推荐优先执行)
关键帧只是用于插入报告的缩略图,通常不需要 4K 原画质。可以:
1. 设定最大宽度(如 800px等比缩放 Canvas
2. 将 JPEG 导出质量从 `0.9` 降到 `0.5 ~ 0.6`
3. 这样单张图片体积可从 500KB 压缩到 30KB~80KB十几张关键帧总计不到 1MB远低于 5MB 限制。
**修改点:**
- `captureFrame()`(手动截图)
- `autoCaptureFrames()`(自动抽帧)
### 方向二:增加存储超限的可见性
`storage.ts` 中不再静默吞掉异常,而是至少输出 `console.error`,甚至可以在 UI 层捕获后提示用户:
"报告数据过大,请降低视频截图质量或删除部分图片。"
### 方向三:迁移到 IndexedDB长期根治
`localStorage` 的 5MB 上限对于包含大量 Base64 图片的医疗报告系统来说迟早会不够用。长期方案是:
- 引入 `localforage``idb-keyval` 等轻量库;
-`storage.ts` 改造为基于 IndexedDB 的异步存储方案(容量可达数百 MB
**注意:** 方向三涉及全项目的 `storage.get/set` 调用点从同步改为异步,改动面较大,适合作为后续迭代项目。
## 建议的实施方案
**本次优先执行方向一 + 方向二**,以最快速度解决关键帧丢失问题,并让用户感知到存储异常:
1.`captureFrame``autoCaptureFrames` 中增加 Canvas 等比缩放逻辑:
```tsx
const MAX_WIDTH = 800;
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
```
2. 在 `storage.ts` 中增加超限日志:
```tsx
} catch (e) {
console.error('Storage save failed (possibly quota exceeded):', e);
}
```
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| 压缩后图片清晰度下降 | 低 | 800px 宽度 + 0.6 质量对于报告插入足够清晰 |
| 仍有个别超长视频压缩后接近 5MB | 极低 | 配合方向二的日志提示,便于后续继续优化 |
## 回滚策略
仅调整 Canvas 缩放参数和 JPEG 质量,不涉及数据结构和接口变更。如有异常可直接 revert。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**

View File

@@ -1,130 +0,0 @@
# 测试方案 — 2026-04-16-17-40-20
## 测试环境准备
1. 项目依赖已安装,构建通过。
2. 使用测试账号 `admin / 123456`(超级管理员)登录系统。
3. 确保系统设置中已配置默认模板,且已存在至少一个报告模板。
---
## 测试项清单
### 测试项 A自动抽帧图片在切换页面后不丢失
**测试步骤**
1. 进入 **图文报告生成** 页面,上传一个手术视频。
2. 等待系统自动抽帧完成(关键帧列表中出现多个自动帧)。
3. 切换到 **报告管理**`/report-manage`)页面。
4. 通过左侧菜单再次进入 **图文报告生成** 页面。
**预期结果**
- 视频分析面板中,刚才自动抽取的所有关键帧图片**全部保留**,没有丢失。
- 手动截取的帧(如果有)也一并保留。
---
### 测试项 B手动抽帧图片在切换页面后保留
**测试步骤**
1. 在视频分析面板中点击 **"截取当前帧"** 按钮,手动截取一帧。
2. 切换到其他页面(如 `/dashboard`)。
3. 返回 **图文报告生成** 页面。
**预期结果**
- 手动截取的帧**仍然存在于关键帧列表中**。
---
### 测试项 C编辑报告后切换界面模板选择器不显示"无"
**测试步骤**
1. 进入 **图文报告生成** 页面(新建报告)。
2. 确认顶部模板选择器显示了默认模板名称(如 "腹腔镜胆囊切除术报告")。
3. 在编辑器中随意输入几个字或修改基本信息。
4. 切换到 **报告管理** 页面。
5. 再次进入 **图文报告生成** 页面。
**预期结果**
- 模板选择器**仍然显示默认模板名称****不显示"无"**。
- 编辑器内容恢复为刚才编辑的状态。
---
### 测试项 D编辑已有报告后切换界面模板选择器状态正确
**测试步骤**
1.**报告管理** 中打开一份已有报告进行编辑。
2. 在编辑器中修改内容。
3. 切换到其他页面后再返回该报告编辑页。
**预期结果**
- 报告内容正确恢复。
- 若该报告是从模板创建的,"当前模板"应显示对应模板名称;若不是从模板创建,显示"无"也属正常。
---
### 测试项 Esystem-settings 新增"开启自动帧插入"开关
**测试步骤**
1. 进入 **系统设置** 页面。
2. 观察 **视频抽帧配置** 区域。
**预期结果**
- 存在一个 **"开启自动帧插入"** 的复选框开关,默认未勾选。
- 勾选后,每个 **抽帧位置百分比** 输入框的右下角(或旁边)显示一个 **绿色对勾(✓)** 标记。
- 取消勾选后,绿色对勾标记消失。
- 点击 **保存全局配置** 后刷新页面,开关状态正确保持。
---
### 测试项 F开启自动帧插入后自动抽帧会按顺序填充占位符
**测试步骤**
1.**系统设置** 中勾选 **"开启自动帧插入"**,保存。
2. 进入 **图文报告生成** 页面(新建报告),确认编辑器中有多个空置 `image-placeholder`
3. 在视频分析面板中上传一个手术视频,等待自动抽帧完成。
**预期结果**
- 自动抽取的每一帧会**按从前到后的顺序**,依次填充到编辑器中的空置占位符里。
- 如果占位符数量多于自动帧数量,剩余占位符保持空置。
- 自动插入过程中**没有任何弹窗提示**。
---
### 测试项 G自动帧插入满后静默处理无弹窗
**测试步骤**
1. 确保 **开启自动帧插入** 已打开。
2. 新建报告,编辑器中只有 1 个或少量空置占位符。
3. 上传视频,等待自动抽帧(通常会产生 12 帧或更多)。
**预期结果**
- 前几个空置占位符被自动填充。
- 当所有占位符都填满后,剩余的自动帧**仅在关键帧列表中显示**,不再尝试插入。
- **全程不出现** "没有可插入图片的空位" 的 alert 提示。
---
### 测试项 H关闭自动帧插入后行为回退
**测试步骤**
1.**系统设置** 中**取消勾选** "开启自动帧插入",保存。
2. 进入 **图文报告生成** 页面,上传视频等待自动抽帧。
**预期结果**
- 自动抽帧完成后,编辑器中的占位符**全部保持空置**。
- 用户需要通过手动拖拽或点击 "插入" 按钮来填充占位符。
---
### 测试项 I构建与类型检查回归
**测试步骤**
1. 在项目根目录执行:
```bash
npm run lint
npm run build
```
**预期结果**
- `npm run lint` 无 TypeScript 编译错误。
- `npm run build` 构建成功。
---
## 回归验证范围
- [ ] 拖拽插入、手动截取、删除关键帧功能正常。
- [ ] 报告保存(草稿/完成)功能正常。
- [ ] 编辑器打印、模板加载功能正常。
- [ ] 其他系统设置项API 地址、默认模板等)保存和读取正常。

View File

@@ -0,0 +1,90 @@
# 测试方案 — 2026-04-16-18-51-06
## 测试目标
验证在 `/report-editor` 页面离开并重新返回后,**视频分析相关的所有图片数据(自动关键帧、手动截图、拖拽到 placeholder 的截图)能够正确恢复**,且不影响报告基本信息和其他页面功能。
## 测试环境
- 浏览器Chrome / Edge推荐
- 前置条件已登录系统localStorage 中有当前用户信息
- 测试文件:准备一个时长超过 30 秒的 MP4 视频文件(用于自动关键帧摘取)
## 测试用例设计
### 用例 1新建报告 — 自动关键帧摘取后路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor`(不带 `?id` | 页面正常加载,显示默认模板内容 |
| 1.2 | 填写患者姓名、住院号等基本信息 | 基本信息输入正常 |
| 1.3 | 切换到「视频分析」页签,上传测试视频 | 视频上传成功,视频列表中显示文件名 |
| 1.4 | 点击「自动关键帧摘取」 | 右侧生成多张自动关键帧缩略图 |
| 1.5 | 点击浏览器地址栏,手动跳转至 `/report-manage` | 页面跳转成功 |
| 1.6 | 再次在地址栏输入 `/report-editor` 返回 | **右侧「视频分析」中自动关键帧缩略图全部保留** |
| 1.7 | 点击「保存草稿」,再跳转离开并返回 | 自动关键帧缩略图仍然保留 |
### 用例 2新建报告 — 手动截图后路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 在新建报告页面上传视频并播放 | 视频正常播放 |
| 2.2 | 拖动进度条到某一时刻,点击「手动截图」 | 右侧生成一张手动截图缩略图 |
| 2.3 | 再次截取 2-3 张不同时间点的截图 | 所有手动截图均显示在右侧列表 |
| 2.4 | 跳转至 `/report-manage`,再返回 `/report-editor` | **所有手动截图缩略图全部保留** |
| 2.5 | 点击「保存草稿」后再次离开并返回 | 手动截图仍然保留 |
### 用例 3新建/编辑报告 — 拖拽截图到 image-placeholder 后路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 在编辑器中插入一个 `image-placeholder` | placeholder 正常显示在编辑器中 |
| 3.2 | 从右侧「视频分析」中拖拽一张自动关键帧到 placeholder | placeholder 中显示该图片,且带有删除按钮 |
| 3.3 | 再插入一个 placeholder拖拽一张手动截图到其中 | 第二张 placeholder 也正确显示图片 |
| 3.4 | 跳转至 `/report-manage`,再返回 `/report-editor` | **编辑器中两个 placeholder 内的图片均保留可见** |
| 3.5 | 查看右侧「视频分析」面板 | 被拖拽的原始帧/截图缩略图也仍然保留在列表中 |
| 3.6 | 点击「保存草稿」后再次离开并返回 | 编辑器和右侧面板的图片均保留 |
### 用例 4编辑已有报告 — 保存后数据完整恢复
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 对任意一份已有报告点击「编辑」,进入 `/report-editor?id=xxx` | 报告内容和基本信息正常加载 |
| 4.2 | 上传视频、自动摘取关键帧、手动截图、拖拽一张到 placeholder | 所有操作正常生效 |
| 4.3 | 点击「保存草稿」 | 提示保存成功 |
| 4.4 | 跳转至 `/report-manage`,找到该报告,再次点击「编辑」 | 进入 `/report-editor?id=xxx` |
| 4.5 | 检查编辑器、基本信息、视频分析面板 | **所有数据和图片完整恢复,无丢失** |
### 用例 5边界场景 — 多次快速路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 5.1 | 在 `/report-editor` 中完成视频上传、截图、拖拽 | 数据正常 |
| 5.2 | 快速连续切换:/report-editor → /report-manage → /report-editor → /report-manage → /report-editor | **最终返回时,所有视频分析数据仍然完整保留** |
| 5.3 | 检查 localStorage 中 `reportEditorDraft_{username}` | draft 中 `videos``capturedFrames` 均非空数组 |
### 用例 6回归测试 — 模板切换不污染数据
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 6.1 | 在 `/report-editor` 中上传视频并截取若干帧 | 数据正常 |
| 6.2 | 切换模板(顶部模板选择下拉框) | 编辑器内容按模板重置,报告基本信息清空 |
| 6.3 | 检查视频分析面板 | 根据现有逻辑,模板切换会清空 videos 和 capturedFrames此行为保持不变 |
| 6.4 | 若切换模板后不希望丢失视频数据,可后续作为优化项提出 | — |
## 验收标准
- [ ] 用例 1自动关键帧在路由切换后 100% 保留;
- [ ] 用例 2手动截图在路由切换后 100% 保留;
- [ ] 用例 3拖拽到 placeholder 的图片在路由切换后 100% 保留;
- [ ] 用例 4编辑已有报告保存后再次编辑数据完整无丢失
- [ ] 用例 5多次快速切换路由后数据不丢失、不异常
- [ ] 用例 6模板切换的现有行为未被意外改变。
## 测试方式
由于本项目目前无自动化测试框架,所有测试用例均通过 **手工浏览器验证** 执行。测试人员按上表逐步操作,观察实际结果是否与预期一致。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段(修改代码 + 更新经验记录 + Gitea 备份)。**

View File

@@ -0,0 +1,89 @@
# 测试方案 — 2026-04-16-19-06-18
## 测试目标
1. 验证路由切换后,报告编辑器内容、基本信息、视频分析数据(自动关键帧、手动截图、拖拽图片)均**完整保留不丢失**。
2. 验证开启「自动帧插入」后,关键帧在自动摘取过程中能够**实时逐张显示**,并在达到延迟时间后**逐张插入**到 `image-placeholder` 中,而不是全部处理完成后一次性批量出现。
## 测试环境
- 浏览器Chrome / Edge推荐
- 前置条件已登录系统localStorage 中有当前用户信息
- 测试文件:准备一个时长超过 60 秒的 MP4 视频文件
- 系统设置:在「系统设置」中开启「自动帧插入」,配置插入延迟为 1~2 秒,并勾选若干自动插入的帧索引(如第 0、3、5 帧)
## 测试用例设计
### 用例 1新建报告 — 路由切换后数据完整保留
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor`(不带 `?id` | 页面正常加载默认模板 |
| 1.2 | 填写患者姓名、住院号、科室等基本信息 | 输入内容正常保留在表单中 |
| 1.3 | 在编辑器中输入一段自定义文字 | 编辑器内容正常显示 |
| 1.4 | 切换到「视频分析」页签,上传测试视频 | 视频出现在右侧列表 |
| 1.5 | 点击「自动关键帧摘取」 | 右侧出现多张自动关键帧缩略图 |
| 1.6 | 手动截取 2 张截图 | 手动截图出现在右侧列表 |
| 1.7 | 拖拽 1 张自动关键帧到编辑器的 `image-placeholder` | placeholder 正确显示图片 |
| 1.8 | 在地址栏手动跳转到 `/report-manage` | 页面正常跳转 |
| 1.9 | 再次输入 `/report-editor` 返回新建报告页面 | **编辑器内容、基本信息、自动关键帧、手动截图、placeholder 中的图片均完整保留** |
| 1.10 | 点击「保存草稿」,再次离开并返回 | 所有数据仍然完整 |
### 用例 2编辑已有报告 — 保存后重新编辑数据完整
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 进入 `/report-manage`,对已有报告点击「编辑」 | `/report-editor?id=xxx` 正常加载 |
| 2.2 | 修改患者姓名,上传视频,自动摘取关键帧 | 修改和视频分析数据正常显示 |
| 2.3 | 点击「保存草稿」 | 提示保存成功 |
| 2.4 | 跳转离开后再返回 `/report-editor?id=xxx` | **修改后的基本信息、报告内容、视频分析数据完整恢复** |
### 用例 3边界场景 — 多次快速路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 在 `/report-editor` 中完成用例 1 的全部操作 | 数据正常 |
| 3.2 | 快速连续切换:/report-editor → /report-manage → /report-editor → /report-manage → /report-editor | 最终返回时,**没有任何数据丢失或变空** |
| 3.3 | 检查 localStorage 中 `reportEditorDraft_{username}` | draft 中 `reportData``videos``capturedFrames` 均非空 |
### 用例 4自动帧插入 — 关键帧实时逐张显示
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 进入系统设置,开启「自动帧插入」,设置延迟 1 秒,勾选索引 0、2、4 为自动插入 | 设置保存成功 |
| 4.2 | 进入 `/report-editor`,上传视频,确保编辑器中有至少 3 个空的 `image-placeholder` | placeholder 就位 |
| 4.3 | 点击「自动关键帧摘取」 | **右侧关键帧列表中,每隔一小段时间(视频 seek 时间)就有新的一张缩略图出现**,而不是等全部结束后一次性全部出现 |
| 4.4 | 观察 placeholder | 当第 0 帧被摘取后,等待约 1 秒,第一个 placeholder 被填充;第 2 帧摘取后约 1 秒,第二个 placeholder 被填充;以此类推,**逐张插入** |
### 用例 5自动帧插入 — 无延迟配置的即时插入
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 5.1 | 在系统设置中将自动插入延迟设为 0 秒 | 设置保存成功 |
| 5.2 | 进入 `/report-editor`,上传视频,点击「自动关键帧摘取」 | 每摘取到一张指定索引的帧,**几乎立即**填充到下一个空的 placeholder 中 |
| 5.3 | 观察右侧关键帧列表 | 同样能看到帧逐张实时出现 |
### 用例 6回归测试 — 模板切换行为不变
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 6.1 | 在 `/report-editor` 中上传视频、截取若干帧 | 数据正常 |
| 6.2 | 切换模板 | 编辑器内容按模板重置,基本信息清空,视频分析根据现有逻辑可能被清空 |
| 6.3 | 离开并返回 | 模板切换后的状态保持一致,无异常崩溃 |
## 验收标准
- [ ] 用例 1新建报告路由切换后编辑器内容、基本信息、视频分析数据 100% 保留;
- [ ] 用例 2编辑已有报告保存后再次编辑数据完整无丢失
- [ ] 用例 3多次快速切换路由后数据不丢失、不异常变空
- [ ] 用例 4开启自动帧插入且有延迟时关键帧实时逐张显示、逐张插入 placeholder
- [ ] 用例 5延迟为 0 时,指定帧摘取后立即插入 placeholder
- [ ] 用例 6模板切换的现有行为未被意外改变页面无崩溃。
## 测试方式
由于本项目目前无自动化测试框架,所有测试用例均通过 **手工浏览器验证** 执行。测试人员按上表逐步操作,观察实际结果是否与预期一致。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段(修改代码 + 更新经验记录 + Gitea 备份)。**

View File

@@ -0,0 +1,19 @@
# 测试方案 — 2026-04-16-19-18-14
## 测试目标
验证应用重新部署后,服务正常启动且可通过端口访问。
## 测试用例
| 用例 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 执行 `docker ps` | 看到 `medical-report-app` 容器在运行,端口映射为 `8080:80` |
| 2 | 浏览器访问 `http://localhost:8080` | 页面正常加载,显示登录或报告管理界面 |
| 3 | 登录后进入 `/report-editor` | 页面正常加载,无白屏或报错 |
## 验收标准
- [ ] 容器运行状态正常
- [ ] 浏览器可正常访问应用首页
- [ ] 核心页面 `/report-editor` 可正常加载

View File

@@ -0,0 +1,80 @@
# 测试方案 — 2026-04-16-19-28-04
## 测试目标
彻底验证路由切换后,报告编辑器内容、基本信息、视频列表、关键帧截图在任何场景下均不再丢失。
## 测试环境
- 浏览器Chrome / Edge
- 前置条件已登录系统localStorage 中有当前用户信息
- 测试文件:准备一个时长超过 30 秒的 MP4 视频文件
## 测试用例设计
### 用例 1新建报告 — 填写基本信息后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor`(不带 `?id` | 页面正常加载默认模板 |
| 1.2 | 填写患者姓名、住院号、科室 | 输入内容保留在表单中 |
| 1.3 | 在编辑器中输入文字 | 编辑器内容正常显示 |
| 1.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **基本信息和编辑器内容均完整保留** |
### 用例 2新建报告 — 上传视频 + 自动关键帧后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 在 `/report-editor` 中上传视频 | 视频出现在右侧列表 |
| 2.2 | 点击「自动关键帧摘取」 | 右侧出现多张关键帧缩略图 |
| 2.3 | 手动截取 2 张截图 | 手动截图出现在右侧 |
| 2.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **视频列表、自动关键帧、手动截图全部保留** |
### 用例 3新建报告 — 拖拽图片到 placeholder 后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 插入 `image-placeholder`,拖拽一张关键帧到其中 | placeholder 显示图片 |
| 3.2 | 跳转到 `/report-manage`,再返回 `/report-editor` | **placeholder 中的图片保留,右侧关键帧列表也保留** |
### 用例 4编辑已有报告 — 修改后保存并重新编辑
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 从 `/report-manage` 编辑已有报告 | `/report-editor?id=xxx` 正常加载 |
| 4.2 | 修改患者姓名,上传视频,自动摘取关键帧 | 所有数据正常显示 |
| 4.3 | 点击「保存草稿」 | 提示保存成功 |
| 4.4 | 离开并重新进入 `/report-editor?id=xxx` | **修改后的所有数据完整恢复** |
### 用例 5边界 — 多次快速路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 5.1 | 完成用例 2 和 3 的所有操作 | 数据正常 |
| 5.2 | 连续快速切换 /report-editor ↔ /report-manage 3 次以上 | **最终返回时没有任何数据丢失或变空** |
| 5.3 | 检查 localStorage `reportEditorDraft_{username}` | draft 中 `reportData``videos``capturedFrames``content` 均非空 |
### 用例 6回归 — 模板切换后行为正常
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 6.1 | 在 `/report-editor` 中上传视频并截取若干帧 | 数据正常 |
| 6.2 | 切换模板 | 编辑器内容重置,基本信息清空(现有预期行为) |
| 6.3 | 离开并返回 | 页面无崩溃,状态与模板切换后一致 |
## 验收标准
- [ ] 用例 1基本信息和编辑器内容在路由切换后 100% 保留;
- [ ] 用例 2视频列表和关键帧在路由切换后 100% 保留;
- [ ] 用例 3拖拽到 placeholder 的图片在路由切换后 100% 保留;
- [ ] 用例 4编辑已有报告保存后重新编辑数据完整无丢失
- [ ] 用例 5多次快速切换路由后数据不丢失、不异常变空
- [ ] 用例 6模板切换行为未被意外改变页面无崩溃。
## 测试方式
手工浏览器验证。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View File

@@ -0,0 +1,72 @@
# 测试方案 — 2026-04-16-20-24-11
## 测试目标
验证路由切换后,报告编辑器内容(文本、图片、表格等)和视频分析关键帧(自动/手动摘取)均不再丢失。
## 测试环境
- 浏览器Chrome / Edge
- 前置条件:已登录系统
- 测试文件:准备一个时长超过 30 秒的 MP4 视频文件
## 测试用例设计
### 用例 1新建报告 — 编辑器内容 + 基本信息切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor` | 页面正常加载默认模板 |
| 1.2 | 填写患者姓名、住院号 | 输入内容保留 |
| 1.3 | 在编辑器中输入文字、插入表格 | 内容正常显示 |
| 1.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **编辑器内容和基本信息完整保留** |
### 用例 2新建报告 — 视频 + 自动/手动关键帧切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 上传视频 | 视频出现在右侧列表 |
| 2.2 | 点击「自动关键帧摘取」 | 右侧出现多张关键帧 |
| 2.3 | 手动截取 2 张截图 | 手动截图出现在右侧 |
| 2.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **视频列表、自动关键帧、手动截图全部保留** |
### 用例 3新建报告 — placeholder 图片 + 删除 placeholder 后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 拖拽一张关键帧到 `image-placeholder` | placeholder 显示图片 |
| 3.2 | 点击 placeholder 的 × 删除图片(保留空 placeholder | placeholder 恢复为空 |
| 3.3 | 再次拖拽一张手动截图到 placeholder | 再次显示图片 |
| 3.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **placeholder 中的图片保留,右侧关键帧列表也保留** |
### 用例 4编辑已有报告 — 修改后保存并重新编辑
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 编辑已有报告 | 数据正常加载 |
| 4.2 | 修改内容并保存草稿 | 提示保存成功 |
| 4.3 | 离开并重新进入编辑 | **所有修改完整恢复** |
### 用例 5边界 — 多次快速切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 5.1 | 完成用例 1+2 的操作 | 数据正常 |
| 5.2 | 连续快速切换路由 3 次以上 | **没有任何数据丢失** |
| 5.3 | 检查 localStorage draft | `content``videos``capturedFrames` 均非空 |
## 验收标准
- [ ] 编辑器内容在路由切换后 100% 保留
- [ ] 基本信息在路由切换后 100% 保留
- [ ] 视频和关键帧在路由切换后 100% 保留
- [ ] 多次快速切换后数据不丢失
- [ ] 编辑已有报告保存后重新编辑数据完整
## 测试方式
手工浏览器验证。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View File

@@ -0,0 +1,37 @@
# 测试方案 — 2026-04-16-20-33-12
## 测试目标
验证自动帧插入改为非阻塞后:
1. 关键帧摘取过程不再被插入延迟阻塞;
2. 图片按顺序、按延迟时间依次插入 placeholder
3. 插入后草稿正常保存。
## 测试用例
### 用例 1非阻塞摘取
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 系统设置开启自动插入,延迟 2 秒,勾选多个帧索引 | — |
| 1.2 | 上传视频,点击「自动关键帧摘取」 | 右侧关键帧列表在 1-2 秒内迅速全部出现,不再卡顿等待 |
### 用例 2顺序延迟插入
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 确保编辑器中有足够空 placeholder | — |
| 2.2 | 点击「自动关键帧摘取」 | 摘取完成后placeholder 按顺序每隔约 2 秒插入一张,不是同时插入 |
### 用例 3插入后内容保存
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 等待自动插入全部完成 | — |
| 3.2 | 切换到 `/report-manage` 再返回 | 已插入 placeholder 的图片保留在编辑器中 |
## 验收标准
- [ ] 关键帧摘取不受插入延迟阻塞,快速完成
- [ ] 图片按顺序依次插入,不堆积
- [ ] 插入后的内容能正常保存

View File

@@ -0,0 +1,60 @@
# 测试方案 — 2026-04-16-20-46-50
## 测试目标
验证压缩关键帧分辨率/质量后LocalStorage 不再超限,路由切换后自动/手动关键帧能够正常保留;同时验证存储超限时能在控制台看到错误日志。
## 测试环境
- 浏览器Chrome / Edge推荐开启 DevTools 观察 Console 和 Application > Local Storage
- 测试文件:准备一个 1080p 或更高分辨率的 MP4 视频文件(时长 60 秒以上,确保能提取多张关键帧)
## 测试用例设计
### 用例 1自动关键帧摘取后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor`,上传高清视频 | 视频正常加载 |
| 1.2 | 点击「自动关键帧摘取」 | 右侧迅速生成 10+ 张关键帧缩略图 |
| 1.3 | 打开 DevTools > Console | 无 `QuotaExceededError` 报错 |
| 1.4 | 打开 DevTools > Application > Local Storage | `reportEditorDraft_{username}` 存在且体积明显小于 5MB |
| 1.5 | 跳转到 `/report-manage`,再返回 `/report-editor` | **所有自动关键帧缩略图完整保留** |
### 用例 2手动截图后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 播放视频,在多个时间点点击「手动截图」 | 生成 5 张以上手动截图 |
| 2.2 | 切换路由后再返回 | **所有手动截图完整保留** |
### 用例 3自动+手动混合场景
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 上传视频,自动摘取 12 张关键帧 | 自动帧正常显示 |
| 3.2 | 再手动截取 5 张 | 手动帧正常显示 |
| 3.3 | 切换路由后再返回 | **自动帧和手动帧全部保留** |
### 用例 4图片清晰度验证
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 拖拽一张压缩后的关键帧到 `image-placeholder` | placeholder 中图片清晰可见,无严重马赛克 |
| 4.2 | 打印预览或放大查看 | 图片质量满足报告使用需求 |
## 验收标准
- [ ] 高清视频自动摘取 10+ 张关键帧后LocalStorage 不超限;
- [ ] 路由切换后,自动关键帧 100% 保留;
- [ ] 路由切换后,手动截图 100% 保留;
- [ ] 压缩后的图片清晰度仍满足报告使用;
- [ ] `storage.ts` 中存储失败时能在 Console 看到错误日志。
## 测试方式
手工浏览器验证,结合 DevTools 观察 LocalStorage 容量和 Console 日志。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View File

@@ -84,29 +84,203 @@
- 在 UI 微调过程中,可以通过小步迭代快速验证用户意图,减少一次性大改导致的方向偏差。 - 在 UI 微调过程中,可以通过小步迭代快速验证用户意图,减少一次性大改导致的方向偏差。
- 实体按钮比纯文字链接具有更高的可点击性和辨识度,在微小空间中也能提供良好的交互体验。 - 实体按钮比纯文字链接具有更高的可点击性和辨识度,在微小空间中也能提供良好的交互体验。
--- ---
## 记录 5自动抽帧丢失、模板选择器变"无"与自动帧插入开关 ## 记录 5路由切换后视频分析图片丢失
**A. 具体问题** **A. 具体问题**
用户反馈三个关联问题: `/report-editor` 中上传视频、自动摘取关键帧、手动截图或拖拽截图到 `image-placeholder` 后,切换到 `/report-manage` 等其他页面再返回 `/report-editor`,右侧「视频分析」面板中的所有截图和关键帧全部消失;编辑器中已拖拽到 placeholder 的图片也不可见。
1. 从其他页面返回 `/report-editor` 后,自动抽帧的图片全部丢失。
2. 编辑报告后切换界面再返回,"当前模板"显示为"无"。
3. 希望 system-settings 增加"开启自动帧插入"开关,让自动抽帧直接填充占位符。
**B. 产生问题原因** **B. 产生问题原因**
1. **自动抽帧丢失**`autoCaptureFrames``captureFrame``removeVideo``handleVideoUpload` 中调用 `setCapturedFrames`/`setVideos` 后立即调用 `saveDraftToStorage`,但 `stateRef.current` 中的 `capturedFrames``videos` 仍是旧值React 的 setState 是异步的)。因此组件卸载前保存的 draft 里缺少新生成的自动帧 1. `ReportEditor.tsx` 在组件卸载时通过 `stateRef.current` 保存草稿到 `localStorage`
2. **模板选择器变"无"**`saveDraftToStorage` 使用 `useCallback` 捕获了旧的 `loadedTemplateId`stale closure。编辑报告时 `loadedTemplateId` 未通过 `stateRef.current` 同步,导致 draft 中的 `loadedTemplateId` 为空字符串,恢复后模板选择器显示"无" 2. 初始化 `useEffect` `useLayoutEffect` 从 draft 或已保存报告恢复数据时,仅通过 `setState` 更新了 React state`videos``capturedFrames`),但 **没有同步更新 `stateRef.current`**
3. **自动帧插入需求**:用户希望减少手动拖拽操作,提升效率 3. 用户首次进入页面时数据正确显示;离开页面时,`stateRef.current` 仍保存着初始值(空数组),导致 `saveDraftToStorage()` 用空数组覆盖了 localStorage 中的 draft
4. 再次返回页面时,系统优先读取被污染后的 draft从而丢失了所有视频分析数据。
**C. 解决问题方案** **C. 解决问题方案**
1. **修复状态同步**:将 `loadedTemplateId` 纳入 `stateRef.current`,并在 `saveDraftToStorage` 调用前先强制同步 `stateRef.current = { reportData, videos, capturedFrames, activeTab, loadedTemplateId }`。同时,在所有修改 `videos``capturedFrames` 的函数(`handleVideoUpload``removeVideo``captureFrame``autoCaptureFrames`)中,手动将最新状态同步到 `stateRef.current` 后再 `saveDraftToStorage` `ReportEditor.tsx` 的 6 个数据恢复入口(初始化 `useEffect` 的 3 个分支 + `useLayoutEffect` 安全网的 3 个分支)中,恢复 `reportData``videos``capturedFrames` 后立即同步赋值给 `stateRef.current`,确保后续草稿保存时数据完整
2. **新增自动帧插入开关**
- `SystemSettings` 类型增加 `autoInsertFrames?: boolean`
- `SystemSettings.tsx` 增加 checkbox 开关,并在每个 `framePositions` 百分比旁显示绿色 `Check` 对勾。
- `ReportEditor.tsx``autoCaptureFrames` 中,每生成一帧后若开关开启,则查找第一个 `.image-placeholder:not(.has-image)` 并填充图片;若找不到则静默跳过,不弹窗。循环结束后统一更新 `contentRef.current` 并保存草稿。
**D. 后续如何避免问题** **D. 后续如何避免问题**
-多个状态需要同时持久化到 localStorage如通过 draft 机制),应避免依赖异步 setState 后的自动同步。在保存前手动将最新计算值同步到 ref 或直接在保存函数中传入最新值,是更可靠的做法 -使用 `useRef` 作为「自动保存」的数据快照时,**任何从持久化存储恢复数据到 React state 的操作,必须同步更新对应的 ref**,否则 ref 将始终保存陈旧值
- `useCallback` 的依赖数组虽然能触发重新创建函数,但函数体内部若同时读取 state 和 ref仍可能出现 stale closure。将可变状态统一收敛到 ref 中,并在保存前强制刷新 ref可有效避免此类问题 - 在涉及草稿/自动保存的功能中,应定期审查所有数据恢复路径(初始化 effect、安全网 effect、手动导入等确保 ref 与 state 的一致性
- 新增业务开关时,类型定义、设置页面、消费逻辑三个点必须同步修改,并在 UI 上给予用户明确的视觉反馈(如此次的绿色对勾标记)。 - 对于复杂单文件组件,可考虑将「持久化 ↔ 状态同步」逻辑抽离为统一的数据恢复函数,集中处理 ref 同步,减少遗漏点。
---
## 记录 6路由切换后报告内容、基本信息、视频分析全部丢失 + 自动帧插入 UI 延迟刷新
**A. 具体问题**
1.`/report-editor` 中编辑报告后,切换到 `/report-manage` 再返回 `/report-editor`**报告内容变空、基本信息清空、视频分析数据全部丢失**。
2. 开启「自动帧插入」后,自动关键帧摘取过程中右侧关键帧列表和 placeholder 中的图片**不会逐张实时更新**,而是等所有帧全部处理完后一次性批量出现。
**B. 产生问题原因**
1. **数据丢失原因**:在初始化 `useEffect` 中,将 `stateRef.current` 的同步赋值放在了 `if (editorRef.current && draft.content.trim().length > 0)` 条件块的内部。当组件首次渲染时 `editorRef` 尚未挂载,或 `draft.content` 为空(新建报告常见场景),`stateRef.current` 就得不到同步,始终保存着初始空值。组件卸载时,空值被保存为 draft覆盖了用户已有的数据。
2. **UI 延迟原因**`autoCaptureFrames` 是一个 async 函数,内部循环中连续调用 `setCapturedFrames`。由于 React 18 的自动批处理机制在异步函数中连续的状态更新会被合并DOM 重渲染被推迟到整个循环结束后才执行一次,导致用户看不到逐帧实时更新的效果。
**C. 解决问题方案**
1. **修复数据丢失**:在 `ReportEditor.tsx` 初始化 `useEffect` 的 3 个数据恢复分支draft 恢复已有报告、found 恢复已有报告、draft 恢复新建报告)中,将 `stateRef.current` 的同步赋值**移到 `editorRef.current/content` 判断条件的外部**,确保无论编辑器 DOM 是否已挂载、`content` 是否为空,`reportData``videos``capturedFrames` 都会立即写入 `stateRef.current`
2. **清理重复代码**:顺带移除了 `found` 恢复分支中 `contentRef.current = found.content;` 的重复赋值。
3. **修复 UI 延迟**:在 `autoCaptureFrames` 的 for 循环中,将 `setCapturedFrames` 包裹在 `flushSync(() => { ... })` 中,强制每一帧被摘取后立即触发 DOM 更新,实现逐张实时显示和逐张插入 placeholder。
**D. 后续如何避免问题**
- 当使用 `useRef` 作为自动保存的数据快照时,**ref 的同步赋值绝对不能依赖于任何与 UI 渲染相关的条件判断**(如 `editorRef.current` 是否存在、`content` 是否非空),否则在组件挂载前或内容为空时会导致数据丢失。
- 在异步函数中需要让用户看到实时状态更新时,应使用 `flushSync` 强制同步渲染,避免被 React 自动批处理延迟。
- 对于复杂单文件组件中的「恢复数据」逻辑,建议将所有 `setState` 和对应的 `ref` 同步集中在一个统一的恢复函数中处理,减少遗漏点和条件嵌套。
---
## 记录 7重新部署应用Vite 生产构建 + Vite Preview
**A. 具体问题**
用户要求将最新代码重新部署到生产环境,但当前运行环境中未安装 Docker无法使用项目自带的 `docker-compose.yaml` 进行容器化部署。
**B. 产生问题原因**
1. 当前 Windows 环境缺少 Docker 和 docker-compose CLI
2. 项目本身是基于 Vite 的前端应用,可通过 `npm run build` 生成静态文件后,使用 `vite preview` 或任意静态文件服务器进行部署;
3. 系统中已存在旧版本的 `vite preview` 进程在运行,需要先停止旧服务再启动新服务。
**C. 解决问题方案**
1. 使用 PowerShell 查询并强制停止所有属于当前项目目录的旧 `vite preview` 进程;
2. 执行 `npm run build` 重新构建生产包;
3. 使用 `cmd /c "start /B npm run preview"` 在后台启动新的 Vite 预览服务器;
4. 通过 `Invoke-WebRequest` 访问 `http://localhost:4173/` 验证服务返回 HTTP 200确认部署成功。
**D. 后续如何避免问题**
- 在无法使用 Docker 的环境中,可将 `npm run build && npm run preview` 作为标准部署脚本;
- 重新部署前务必先清理旧的同类型进程,避免端口冲突或多版本服务同时运行导致访问混乱;
- 如需固定端口,可在 `package.json``preview` 脚本中增加 `--port` 参数(如 `vite preview --port 8080`)。
---
## 记录 8路由切换后所有内容仍然丢失——彻底重构自动保存机制
**A. 具体问题**
`/report-editor` 中编辑报告(填写基本信息、上传视频、自动/手动截取关键帧、拖拽图片到 placeholder切换到 `/report-manage` 再返回 `/report-editor`,报告编辑器内容、基本信息、视频列表、关键帧截图**全部丢失**。
**B. 产生问题原因**
1. 自动保存机制过度依赖 `stateRef``contentRef` 作为"数据快照"。
2. **React 18 `StrictMode`** 在开发/预览环境下会执行"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,`stateRef.current` 仍然是组件创建时的初始空值(`videos: []``capturedFrames: []`、默认 `reportData`)。
3. 组件卸载cleanup时调用保存用这个空值**覆盖了 localStorage 中已有的正确 draft**。
4. 重新挂载后,系统读取了被清空的 draft导致所有数据全部丢失。
5. 此前两次修复仅把 `stateRef.current` 同步移到了更多恢复分支中,但**没有从根本上消除对 ref 的依赖**,因此 `StrictMode` 下的首次卸载仍会覆盖有效 draft。
**C. 解决问题方案**
1. **彻底重构 `saveDraftToStorage`**:不再读取 `contentRef.current``stateRef.current`,而是直接从最新的 React state 和 `editorRef.current?.innerHTML` 获取数据。`useCallback` 的 dependency 数组包含 `reportData``videos``capturedFrames``activeTab``loadedTemplateId``reportId`,确保闭包永远绑定当前渲染周期的最新 state。
2. **重构自动保存 effect**:将 `beforeunload``visibilitychange` 事件处理器直接绑定到 `saveDraftToStorage`effect 的 dependency 改为 `[saveDraftToStorage]`。这样即使 `StrictMode` 导致组件在首次挂载后立即卸载cleanup 中调用的 `saveDraftToStorage` 也指向最新数据的闭包,不会用空值覆盖已有 draft。
3. **给 `useLayoutEffect` 安全网添加 `[]` 依赖**:防止每次渲染后重复执行,避免潜在的意外覆盖。
**D. 后续如何避免问题**
- **永远不要将 `useRef` 作为自动保存的唯一数据源**。ref 在 React 18 `StrictMode` 的模拟卸载阶段仍然保持初始值,会导致用空数据覆盖有效持久化数据。
- 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。
- 在开发阶段应始终开启 `StrictMode` 测试,因为它能暴露 ref-based 状态同步在卸载/重挂载时的隐藏 bug。
- 对于大型表单/编辑器组件,应将自动保存逻辑与业务状态彻底解耦,统一通过 hook 的最新状态闭包来持久化。
---
## 记录 9编辑器内容和关键帧在路由切换后仍然丢失——从 Ref 读取避免闭包陷阱和 DOM 失效
**A. 具体问题**
`/report-editor` 中编辑报告(输入文字、上传视频、自动/手动摘取关键帧、拖拽图片到 placeholder切换到 `/report-manage` 再返回 `/report-editor`
- `class="editor-content-wrapper print-wrapper"` 中的报告内容全部丢失;
- 视频分析面板中的自动关键帧和手动截图全部丢失。
**B. 产生问题原因**
1. **闭包陷阱**:之前为修复 `stateRef` 不同步的问题,将 `saveDraftToStorage` 改为直接从 React state`capturedFrames``videos`)读取。但代码中大量存在 `setCapturedFrames(nextFrames); saveDraftToStorage();` 的写法。由于 `setState` 是异步的,`saveDraftToStorage` 闭包中读到的 `capturedFrames` 仍然是旧值(空数组),导致旧值覆盖了 localStorage 中的有效 draft。
2. **卸载时 DOM 失效**:组件卸载时 React 开始销毁 DOM 树,`editorRef.current` 可能已经变为 `null` 或其 `innerHTML` 已为空。`content: editorRef.current?.innerHTML || ''` 会把空字符串保存到 draft 中,导致报告内容丢失。
3. **`contentRef` 更新遗漏**:在 `handleEditorClick` 中通过 `document.execCommand('delete')` 删除 placeholder 后,直接调用了 `saveDraftToStorage()`,但没有先更新 `contentRef.current`,进一步加剧了内容不一致。
**C. 解决问题方案**
1. **重构 `saveDraftToStorage` 从 Ref 读取**
- `content` 优先读取 `contentRef.current`(内存引用,卸载时仍稳定存在),回退到 `editorRef.current?.innerHTML`
- `reportData``videos``capturedFrames``activeTab``loadedTemplateId` 全部从 `stateRef.current` 读取,彻底避开 React state 的闭包陷阱。
- `useCallback` 的 dependency 仅保留 `[reportId]`,避免因 state 变化产生陈旧闭包。
2. **补齐 `contentRef` 遗漏**:在 `handleEditorClick``document.execCommand('delete')` 分支后,增加 `if (editorRef.current) contentRef.current = editorRef.current.innerHTML;`,确保 DOM 修改后 `contentRef` 及时同步。
**D. 后续如何避免问题**
- 对于需要在异步操作或组件卸载时读取的"最新状态"**应优先使用 `useRef` 作为稳定的数据快照**,而不是依赖 React state 的闭包。
- 自动保存函数的 `useCallback` dependency 应尽量精简(如只保留 `reportId`),避免因 state 变化导致闭包更新不同步。
- 任何直接操作 DOM 修改编辑器内容的代码,都必须**紧跟一行 `contentRef.current = editorRef.current.innerHTML`**,确保内存中的内容快照与 DOM 保持一致。
- 在开发阶段应定期测试「组件卸载 → 重新挂载」的场景React 18 `StrictMode` 会自动模拟),提前暴露闭包和 ref 同步问题。
---
## 记录 10自动帧插入阻塞关键帧摘取——改为 setTimeout 非阻塞异步插入
**A. 具体问题**
开启「自动帧插入」后,点击「自动关键帧摘取」时,系统不是快速完成所有关键帧的摘取,而是每摘取一张就停下来等待插入延迟(如 2 秒),插入完成后才继续摘取下一张。整体过程非常缓慢,用户体验卡顿。
**B. 产生问题原因**
`autoCaptureFrames``for` 循环内部,自动插入逻辑使用了 `await new Promise<void>(r => setTimeout(...))`
```tsx
if ((settings.autoInsertDelay || 0) > 0) {
await new Promise<void>(r => setTimeout(r, (settings.autoInsertDelay || 0) * 1000));
}
```
`await` 会暂停整个 `for` 循环的执行,导致关键帧摘取和插入变成了串行阻塞流程:必须等插入完成才能摘取下一张。
**C. 解决问题方案**
1.`await new Promise(...)` 替换为 `setTimeout(...)`,把插入操作推入事件队列异步执行,`for` 循环不再被阻塞,可以全速完成所有关键帧的摘取。
2. 实现延迟叠加(顺序插入):通过 `settings.autoInsertFrameIndices.indexOf(i)` 计算当前帧是第几个需要插入的,延迟时间为 `baseDelay * (insertOrderIndex + 1)`,避免所有图片在同一时刻同时插入。
3. `setTimeout` 回调中实时查询 `.image-placeholder:not(.has-image)`,找到则插入,并同步更新 `contentRef.current` 和调用 `saveDraftToStorage()`
**D. 后续如何避免问题**
- 在异步循环中,如果某个操作不需要依赖前一步的完成结果,**绝对不要使用 `await` 阻塞主循环**,应改用 `setTimeout``Promise.all` 实现并行/异步解耦。
- 当多个定时任务需要按顺序执行时,可以通过索引计算累积延迟(`delay * (index + 1)`),实现简单的"队列式"顺序触发,而不需要阻塞主流程。
-`setTimeout` 等异步回调中操作 DOM 时,应在回调触发时"实时查询"目标元素,而不是在循环中提前捕获元素引用,以防 DOM 在延迟期间已被用户修改。
---
## 记录 11关键帧在路由切换后丢失——压缩 Canvas 分辨率并增加存储错误日志
**A. 具体问题**
报告编辑器内容和视频列表在路由切换后能正常保留,但视频分析面板中的自动摘取关键帧和手动截图全部丢失。
**B. 产生问题原因**
1. **LocalStorage 5MB 容量限制**:当前抽帧逻辑使用视频原始分辨率 + JPEG 质量 0.9
```tsx
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
```
对于 1080p/4K 视频,单张 Base64 图片可达 300KB~1MB十几张关键帧即可超过 5MB。
2. **静默失败**`storage.ts` 中的 `set` 方法捕获了 `QuotaExceededError` 但没有任何日志:
```typescript
} catch {
// ignore quota exceeded
}
```
当 `saveDraftToStorage()` 尝试保存大量关键帧时,`localStorage.setItem` 抛出异常draft 无法更新,但用户和开发者都感知不到错误。最终返回 `/report-editor` 时,只能读取到"有视频、无关键帧"的旧 draft。
**C. 解决问题方案**
1. **压缩关键帧分辨率与质量**
- 在 `captureFrame()`(手动截图)和 `autoCaptureFrames()`(自动抽帧)中,增加 Canvas 等比缩放:
```tsx
const MAX_WIDTH = 800;
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
```
- 将 JPEG 导出质量从 `0.9` 降到 `0.6`。
- 这样单张图片体积可从 500KB 降至 30KB~80KB有效避免 LocalStorage 超限。
2. **增加存储错误可见性**
- 在 `storage.ts` 的 `set` 和 `setSession` 中,将静默 `catch` 改为输出 `console.error`
```typescript
} catch (e) {
console.error('Storage save failed (possibly quota exceeded):', e);
}
```
**D. 后续如何避免问题**
- 任何将 Base64 图片持久化到 `localStorage` 的场景,都必须**预估数据体积**并对图片进行适当的分辨率/质量压缩。
- 存储层的异常捕获**绝不应静默吞掉**,至少要输出日志,必要时还应弹出用户提示。
- 对于需要存储大量图片的医疗/图文报告系统,应将 `localStorage` 逐步迁移到 `IndexedDB`,从根本上解除 5MB 容量瓶颈。
- 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。

View File

@@ -1,50 +0,0 @@
# 需求分析 — 2026-04-16-17-40-20
## 需求背景
用户在持续使用 report-editor 过程中发现了三个关联问题/优化点,涉及草稿恢复、模板状态显示以及视频自动抽帧后的自动插入体验。
## 功能目标
### 需求 A自动抽帧图片在切换页面后丢失
**现状**:从 `/report-manage` 等其他界面进入 `/report-editor` 后,**视频分析 - 关键帧摘取** 中自动摘取的图片全部丢失,但手动摘取的可能保留。
**期望**:无论是自动抽帧还是手动截取的图片,在用户切换界面后重新进入 report-editor 时都应正确保留。
**初步分析**
- `saveDraftToStorage` 在组件卸载时会保存 `capturedFrames`,但 `autoCaptureFrames` 中生成的帧 `isManual``false`
- 在恢复草稿时,条件可能过度过滤了 `isManual === false` 的帧,或者在某个 effect 中清除了自动帧。
- 需要审阅 `ReportEditor.tsx` 中所有涉及 `capturedFrames` 赋值/过滤的代码路径,尤其是 `isManual` 字段的处理。
### 需求 B编辑报告后切换界面模板选择器显示"无"
**现状**:如果用户在 report-editor 中**编辑了报告内容**(如输入文字、插入图片),然后切换到其他界面再返回,顶部 **"当前模板(及重置模板):"** 会显示为 **"无"**。但如果不编辑直接进去,能正确显示原模板名称。
**期望**:无论从何种状态返回 report-editor模板选择器都应正确反映当前加载的模板或默认模板不应因编辑过内容而变为"无"。
**初步分析**
- 之前修复空白模板时,在 `saveDraftToStorage` 中追加了 `loadedTemplateId`,但可能在**编辑已有报告**`reportId` 存在)的场景下,`loadedTemplateId` 未被正确写入草稿,或恢复时未正确读取。
- 编辑已有报告时,报告的 `content` 来自已有报告而非模板,`loadedTemplateId` 可能为空,导致恢复草稿后模板选择器显示"无"。
- 另一种可能是:编辑已有报告后,`loadedTemplateId` 在 saveDraft 时被覆盖了,但恢复时该字段未同步。
### 需求 Csystem-settings 增加"开启自动帧插入"选项
**现状**:视频上传后系统按百分比自动抽帧,但用户需要手动(拖拽或点击"插入")将每个关键帧放入报告占位符中。
**期望**
1.**系统设置 - 视频抽帧配置** 中增加一个 **"开启自动帧插入"** 的开关(如 Checkbox 或 Toggle
2. 开启后,在 **抽帧位置百分比 (%)** 列表中,每个百分比数字的下方或旁边显示一个 **对勾(✓)** 或其他标记,表示该位置抽取的帧将**自动按顺序插入**到报告的空置 `image-placeholder` 中。
3. 自动插入的行为:当视频上传后触发自动抽帧时,每抽取一帧,立即尝试插入到报告中第一个空置的 `image-placeholder`。如果插满了,后续帧**静默丢弃****不再弹出** "没有可插入图片的空位" 提示。
4. 该开关状态保存在 `systemSettings` 中。
**涉及页面/模块**
- `src/pages/ReportEditor.tsx` —— 草稿恢复逻辑(需求 A、B、自动抽帧后插入逻辑需求 C
- `src/pages/SystemSettings.tsx` —— 新增"开启自动帧插入"开关及 UI 标记(需求 C
- `src/types.ts` —— `SystemSettings` 类型扩展(需求 C
- `src/utils/storage.ts` —— 无需修改,使用现有 `storage` API
## 验收标准
- [ ] 自动抽帧图片在切换页面后重新进入 report-editor 时不丢失。
- [ ] 手动抽帧图片在切换页面后依然保留。
- [ ] 编辑已有报告后切换界面再返回,模板选择器正确显示(不显示"无")。
- [ ] system-settings 中出现"开启自动帧插入"开关,状态可保存。
- [ ] 开关开启后,自动抽帧的每帧会按顺序自动插入空置占位符,插满后无弹窗提示。
- [ ] 开关关闭后,自动抽帧行为与现在一致(不自动插入)。
- [ ] `npm run lint` 零错误,`npm run build` 构建通过。

View File

@@ -0,0 +1,36 @@
# 需求分析 — 2026-04-16-18-51-06
## 原始需求摘要
`/report-editor` 页面中进行操作后,离开该页面(例如进入 `/report-manage`),再返回 `/report-editor` 时,**视频分析相关数据丢失**,具体表现为:
1. 自动关键帧摘取的图片消失;
2. 自动/手动拖拽到报告 `image-placeholder` 上的视频截图消失;
3. 手动截取的视频截图消失。
报告的基本信息(患者姓名、住院号等)保存正常。
## 需求拆解
### 功能点
- 修复路由切换后 `capturedFrames`(关键帧/截图)数据丢失的问题;
- 修复路由切换后 `videos`(已上传视频列表)数据丢失的问题;
- 确保 `stateRef.current` 与 React state 在数据恢复后保持同步;
- 确保组件卸载时保存的 draft 包含完整的视频分析数据。
### 非功能点
- 保持现有 localStorage 存储机制不变;
- 最小化代码改动,避免引入新的状态管理库;
- 不破坏现有报告保存/打印/模板切换等功能。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 高 | 初始化逻辑、`useLayoutEffect` 安全网、`stateRef` 同步 |
| `src/utils/storage.ts` | 无 | 不涉及修改 |
| 其他页面 | 低 | 仅受 `/report-editor` 数据恢复正确性影响 |
## 待确认问题
无。问题现象明确,根因已定位。

View File

@@ -0,0 +1,34 @@
# 需求分析 — 2026-04-16-19-06-18
## 原始需求摘要
### 问题 1路由切换后所有内容丢失
`/report-editor` 页面操作后,切换到 `/report-manage` 等其他页面,再返回 `/report-editor` 时,**所有内容全部变空**
- 报告编辑器内容丢失;
- 基本信息(患者姓名、住院号等)丢失;
- 视频分析中的自动关键帧、手动截图、拖拽到 `image-placeholder` 的图片全部丢失。
### 问题 2自动帧插入的 UI 刷新时序异常
开启「自动帧插入」后,系统应在**每摘取到一张特定关键帧后、经过设定的延迟时间,立即将该帧插入到报告中**。但目前的实际表现是:用户需要等待所有关键帧全部摘取完成后,右侧关键帧列表和报告中的插入图片才会**一次性批量蹦出**,无法看到逐帧实时更新的效果。
## 需求拆解
### 功能点
- **问题 1**:修复路由切换后报告内容、基本信息、视频分析数据全部丢失的根因;
- **问题 2**:修复 `autoCaptureFrames` 中 React 状态更新被批处理导致的 UI 延迟刷新问题,使关键帧在摘取过程中实时可见、并按延迟配置逐帧插入 placeholder。
### 非功能点
- 保持现有 localStorage 草稿机制不变;
- 最小化改动范围,不引入新的状态管理库;
- 不破坏模板切换、保存草稿、打印等现有功能。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 高 | 初始化数据恢复逻辑 + autoCaptureFrames 的渲染同步 |
| 其他页面 | 低 | 仅涉及 `/report-editor` 的数据持久化和恢复 |
## 待确认问题
无。问题现象明确,根因已定位。

View File

@@ -0,0 +1,30 @@
# 需求分析 — 2026-04-16-19-18-14
## 原始需求摘要
用户要求**重新部署当前应用**,使最新的代码修改(已修复的路由切换数据丢失、自动帧插入实时刷新等问题)在生产环境中生效。
## 需求拆解
### 功能点
- 基于最新代码重新构建 Docker 镜像;
- 停止并移除旧运行的容器;
- 启动新容器并暴露服务;
- 验证部署后的应用可正常访问。
### 非功能点
- 尽量缩短服务中断时间;
- 保留旧镜像以便快速回滚(可选);
- 部署失败时需要有明确的错误信息。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| Docker 容器 `medical-report-app` | 高 | 需要重建并重启 |
| 本地端口 `8080` | 中 | 重新绑定到新容器 |
| 源代码 / Git 仓库 | 无 | 仅读取最新代码进行构建 |
## 待确认问题
无。部署流程明确。

View File

@@ -0,0 +1,34 @@
# 需求分析 — 2026-04-16-19-28-04
## 原始需求摘要
`/report-editor` 页面进行操作(填写基本信息、上传视频、自动/手动截取关键帧、拖拽图片到 placeholder离开该页面进入 `/report-manage` 等其他界面后,再次返回 `/report-editor` 时,**所有内容全部丢失**
- 报告编辑器中的文本和图片丢失;
- 基本信息(患者姓名、住院号等)丢失;
- 视频分析面板中的视频列表和关键帧截图全部丢失。
此前两次修复尝试未能解决该问题。
## 需求拆解
### 功能点
- 彻底修复路由切换后报告内容、基本信息、视频分析数据全部丢失的问题;
- 确保自动保存机制(草稿保存)在任何情况下都不会用空值覆盖已有的有效 draft
- 确保组件卸载时保存的 draft 100% 反映用户最新的操作状态。
### 非功能点
- 最小化对现有 UI 和交互逻辑的侵入;
- 保持现有 localStorage 存储机制不变;
- 同时兼顾 React 18 `StrictMode` 在开发/预览环境下的双重挂载行为。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 高 | 自动保存逻辑 `saveDraftToStorage` 和自动保存 effect 需要重构 |
| `useLayoutEffect` 安全网 | 中 | 需要添加依赖数组,避免重复执行 |
| 其他组件 | 无 | 不涉及修改 |
## 待确认问题
无。根因已定位,修复方案明确。

View File

@@ -0,0 +1,34 @@
# 需求分析 — 2026-04-16-20-24-11
## 原始需求摘要
`/report-editor` 页面操作后,切换到 `/report-manage` 等其他页面,再次返回 `/report-editor` 时:
- `class="editor-content-wrapper print-wrapper"` 中的内容(报告文本、图片、表格等)全部丢失;
- 视频分析面板中自动摘取的关键帧、手动摘取的关键帧全部丢失。
此前三次修复尝试(同步 stateRef 到更多恢复分支、彻底重构 saveDraftToStorage 依赖 React state未能根治问题。
## 需求拆解
### 功能点
- 修复路由切换后报告编辑器内容丢失的问题;
- 修复路由切换后自动/手动关键帧丢失的问题;
- 修复 `saveDraftToStorage` 中的闭包陷阱问题;
- 修复组件卸载时 `editorRef` 失效导致的 content 丢失问题;
- 确保所有修改编辑器 DOM 的操作后都及时更新 `contentRef`
### 非功能点
- 最小化改动范围,不引入新的状态管理库;
- 保持现有 localStorage 草稿机制不变;
- 保持用户现有的操作习惯(上传视频、自动摘帧、拖拽插入等)。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 高 | `saveDraftToStorage` 函数重构 + `contentRef` 遗漏点补齐 |
| 其他文件 | 无 | 不涉及修改 |
## 待确认问题
无。问题现象和分析已明确。

View File

@@ -0,0 +1,26 @@
# 需求分析 — 2026-04-16-20-33-12
## 原始需求摘要
`autoCaptureFrames` 中自动插入关键帧到 placeholder 的逻辑使用了 `await new Promise(setTimeout(...))`,这会**阻塞 `for` 循环**,导致必须等待插入延迟结束后才会开始摘取下一帧。期望将其改为**异步非阻塞**,使关键帧摘取全速运行,插入操作在延迟后独立执行,两者互不影响。
## 需求拆解
### 功能点
- 移除 `autoCaptureFrames` 中自动插入逻辑的 `await` 阻塞;
- 使用 `setTimeout` 将插入操作推入事件队列异步执行;
- 实现延迟叠加(顺序插入),避免多张图片在同一时刻同时插入。
### 非功能点
- 保持现有 `flushSync` 实时显示关键帧的效果;
- 不破坏现有的 `contentRef` 同步和草稿保存机制。
## 影响范围
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 中 | 仅修改 `autoCaptureFrames` 中的自动插入逻辑 |
## 待确认问题
无。修改方向明确。

View File

@@ -0,0 +1,29 @@
# 需求分析 — 2026-04-16-20-46-50
## 原始需求摘要
`/report-editor` 页面操作后,切换到 `/report-manage` 再返回 `/report-editor` 时:
- 报告编辑器内容(`class="editor-content-wrapper print-wrapper"`)已能正常保留 ✅;
- 视频列表也能正常保留 ✅;
- **但视频分析中的自动摘取关键帧和手动截图全部丢失** ❌。
## 需求拆解
### 功能点
- 定位关键帧在路由切换后丢失的根因;
- 给出可行的修改方向,确保关键帧数据能够持久化并恢复。
### 非功能点
- 保持现有 UI 和交互不变;
- 尽量减少对存储架构的侵入。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 中 | 抽帧时的 Canvas 尺寸/质量调整 |
| `src/utils/storage.ts` | 低 | 增加超限日志或错误提示 |
## 待确认问题
无。根因已高度明确,等待用户确认修改方向。