2026-04-16-18-51-06 - 修复路由切换后视频分析图片丢失问题
This commit is contained in:
@@ -67,9 +67,7 @@ export default function ReportEditor() {
|
|||||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||||
const contentLoadedRef = useRef(false);
|
const contentLoadedRef = useRef(false);
|
||||||
const contentRef = useRef('');
|
const contentRef = useRef('');
|
||||||
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab });
|
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId });
|
||||||
|
|
||||||
stateRef.current = { reportData, videos, capturedFrames, activeTab };
|
|
||||||
|
|
||||||
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
|
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
|
||||||
|
|
||||||
@@ -88,12 +86,11 @@ export default function ReportEditor() {
|
|||||||
if (key) {
|
if (key) {
|
||||||
storage.set(key, {
|
storage.set(key, {
|
||||||
content: contentRef.current,
|
content: contentRef.current,
|
||||||
loadedTemplateId,
|
|
||||||
draftReportId: reportId || null,
|
draftReportId: reportId || null,
|
||||||
...stateRef.current
|
...stateRef.current
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [reportId, loadedTemplateId]);
|
}, [reportId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const user = storage.get<User | null>('currentUser', null);
|
const user = storage.get<User | null>('currentUser', null);
|
||||||
@@ -128,6 +125,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);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -146,6 +150,12 @@ export default function ReportEditor() {
|
|||||||
contentRef.current = found.content;
|
contentRef.current = 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);
|
||||||
}
|
}
|
||||||
if (found.capturedFrames) {
|
if (found.capturedFrames) {
|
||||||
@@ -176,6 +186,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,6 +202,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 {
|
||||||
@@ -398,6 +416,7 @@ export default function ReportEditor() {
|
|||||||
}));
|
}));
|
||||||
const combined = [...videos, ...newVideos];
|
const combined = [...videos, ...newVideos];
|
||||||
setVideos(combined);
|
setVideos(combined);
|
||||||
|
stateRef.current = { ...stateRef.current, videos: combined };
|
||||||
setCurrentVideoIndex(videos.length); // select first newly uploaded video
|
setCurrentVideoIndex(videos.length); // select first newly uploaded video
|
||||||
if (videoInputRef.current) videoInputRef.current.value = '';
|
if (videoInputRef.current) videoInputRef.current.value = '';
|
||||||
saveDraftToStorage();
|
saveDraftToStorage();
|
||||||
@@ -407,15 +426,18 @@ export default function ReportEditor() {
|
|||||||
const idx = videos.findIndex(v => v.id === id);
|
const idx = videos.findIndex(v => v.id === id);
|
||||||
const updated = videos.filter(v => v.id !== id);
|
const updated = videos.filter(v => v.id !== id);
|
||||||
setVideos(updated);
|
setVideos(updated);
|
||||||
|
stateRef.current = { ...stateRef.current, videos: updated };
|
||||||
if (currentVideoIndex >= updated.length) {
|
if (currentVideoIndex >= updated.length) {
|
||||||
setCurrentVideoIndex(updated.length > 0 ? 0 : -1);
|
setCurrentVideoIndex(updated.length > 0 ? 0 : -1);
|
||||||
} else if (currentVideoIndex === idx && updated.length > 0) {
|
} else if (currentVideoIndex === idx && updated.length > 0) {
|
||||||
setCurrentVideoIndex(0);
|
setCurrentVideoIndex(0);
|
||||||
}
|
}
|
||||||
setCapturedFrames(prev => prev.filter(f => f.videoIndex !== idx).map(f => {
|
const nextFrames = capturedFrames.filter(f => f.videoIndex !== idx).map(f => {
|
||||||
if (f.videoIndex > idx) return { ...f, videoIndex: f.videoIndex - 1 };
|
if (f.videoIndex > idx) return { ...f, videoIndex: f.videoIndex - 1 };
|
||||||
return f;
|
return f;
|
||||||
}).sort((a, b) => a.time - b.time));
|
}).sort((a, b) => a.time - b.time);
|
||||||
|
setCapturedFrames(nextFrames);
|
||||||
|
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
|
||||||
saveDraftToStorage();
|
saveDraftToStorage();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -449,7 +471,9 @@ export default function ReportEditor() {
|
|||||||
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
|
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
|
||||||
isManual: true
|
isManual: true
|
||||||
};
|
};
|
||||||
setCapturedFrames(prev => [...prev, newFrame].sort((a, b) => a.time - b.time));
|
const nextFrames = [...capturedFrames, newFrame].sort((a, b) => a.time - b.time);
|
||||||
|
setCapturedFrames(nextFrames);
|
||||||
|
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
|
||||||
saveDraftToStorage();
|
saveDraftToStorage();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -468,8 +492,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 => {
|
||||||
@@ -482,7 +507,7 @@ export default function ReportEditor() {
|
|||||||
canvas.width = video.videoWidth;
|
canvas.width = video.videoWidth;
|
||||||
canvas.height = video.videoHeight;
|
canvas.height = video.videoHeight;
|
||||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
newFrames.push({
|
const newFrame: CapturedFrame = {
|
||||||
id: Date.now() + Math.random(),
|
id: Date.now() + Math.random(),
|
||||||
videoIndex: currentVideoIndex,
|
videoIndex: currentVideoIndex,
|
||||||
videoName: videos[currentVideoIndex].name,
|
videoName: videos[currentVideoIndex].name,
|
||||||
@@ -490,9 +515,27 @@ export default function ReportEditor() {
|
|||||||
timeFormatted: formatTime(time),
|
timeFormatted: formatTime(time),
|
||||||
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
|
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
|
||||||
isManual: false
|
isManual: false
|
||||||
});
|
};
|
||||||
|
accumulatedFrames = [...accumulatedFrames, newFrame].sort((a, b) => a.time - b.time);
|
||||||
|
setCapturedFrames(accumulatedFrames);
|
||||||
|
stateRef.current = { ...stateRef.current, capturedFrames: accumulatedFrames };
|
||||||
|
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 = `
|
||||||
|
<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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (settings.autoInsertFrames && editorRef.current) {
|
||||||
|
contentRef.current = editorRef.current.innerHTML;
|
||||||
}
|
}
|
||||||
setCapturedFrames(prev => [...prev, ...newFrames].sort((a, b) => a.time - b.time));
|
|
||||||
if (wasPlaying) video.play();
|
if (wasPlaying) video.play();
|
||||||
saveDraftToStorage();
|
saveDraftToStorage();
|
||||||
};
|
};
|
||||||
@@ -596,8 +639,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: '',
|
||||||
@@ -615,11 +657,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();
|
||||||
}
|
}
|
||||||
@@ -641,6 +693,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;
|
||||||
}
|
}
|
||||||
@@ -655,6 +714,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;
|
||||||
}
|
}
|
||||||
@@ -664,6 +729,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;
|
||||||
}
|
}
|
||||||
@@ -678,6 +750,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;
|
||||||
|
|||||||
97
工程分析/代码编纂工作流.md
Normal file
97
工程分析/代码编纂工作流.md
Normal 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] 经验记录初始文档创建
|
||||||
118
工程分析/实现方案-2026-04-16-18-51-06.md
Normal file
118
工程分析/实现方案-2026-04-16-18-51-06.md
Normal 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 || ''
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改点 4:useLayoutEffect 安全网 — 从 draft 恢复已有报告(约第 677 行后)
|
||||||
|
|
||||||
|
在 `setLoadedTemplateId(draft.loadedTemplateId || '');` 之后,**追加同步**:
|
||||||
|
```tsx
|
||||||
|
stateRef.current = {
|
||||||
|
...stateRef.current,
|
||||||
|
reportData: draft.reportData,
|
||||||
|
videos: draft.videos,
|
||||||
|
capturedFrames: draft.capturedFrames,
|
||||||
|
loadedTemplateId: draft.loadedTemplateId || ''
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改点 5:useLayoutEffect 安全网 — 从已保存报告(found)恢复(约第 692 行后)
|
||||||
|
|
||||||
|
在 `contentLoadedRef.current = true;` 之后,**追加同步**:
|
||||||
|
```tsx
|
||||||
|
stateRef.current = {
|
||||||
|
...stateRef.current,
|
||||||
|
reportData: found,
|
||||||
|
videos: found.videos || [],
|
||||||
|
capturedFrames: found.capturedFrames || []
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改点 6:useLayoutEffect 安全网 — 从 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` 回滚。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**
|
||||||
90
工程分析/测试方案-2026-04-16-18-51-06.md
Normal file
90
工程分析/测试方案-2026-04-16-18-51-06.md
Normal 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 备份)。**
|
||||||
22
工程分析/经验记录.md
22
工程分析/经验记录.md
@@ -83,3 +83,25 @@
|
|||||||
- 对于图片/截图类卡片上的操作按钮,应优先考虑不遮挡核心图片内容的区域(如底部、角落),避免影响预览。
|
- 对于图片/截图类卡片上的操作按钮,应优先考虑不遮挡核心图片内容的区域(如底部、角落),避免影响预览。
|
||||||
- 在 UI 微调过程中,可以通过小步迭代快速验证用户意图,减少一次性大改导致的方向偏差。
|
- 在 UI 微调过程中,可以通过小步迭代快速验证用户意图,减少一次性大改导致的方向偏差。
|
||||||
- 实体按钮比纯文字链接具有更高的可点击性和辨识度,在微小空间中也能提供良好的交互体验。
|
- 实体按钮比纯文字链接具有更高的可点击性和辨识度,在微小空间中也能提供良好的交互体验。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 5:路由切换后视频分析图片丢失
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
在 `/report-editor` 中上传视频、自动摘取关键帧、手动截图或拖拽截图到 `image-placeholder` 后,切换到 `/report-manage` 等其他页面再返回 `/report-editor`,右侧「视频分析」面板中的所有截图和关键帧全部消失;编辑器中已拖拽到 placeholder 的图片也不可见。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
1. `ReportEditor.tsx` 在组件卸载时通过 `stateRef.current` 保存草稿到 `localStorage`。
|
||||||
|
2. 初始化 `useEffect` 和 `useLayoutEffect` 从 draft 或已保存报告恢复数据时,仅通过 `setState` 更新了 React state(`videos`、`capturedFrames`),但 **没有同步更新 `stateRef.current`**。
|
||||||
|
3. 用户首次进入页面时数据正确显示;离开页面时,`stateRef.current` 仍保存着初始值(空数组),导致 `saveDraftToStorage()` 用空数组覆盖了 localStorage 中的 draft。
|
||||||
|
4. 再次返回页面时,系统优先读取被污染后的 draft,从而丢失了所有视频分析数据。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
在 `ReportEditor.tsx` 的 6 个数据恢复入口(初始化 `useEffect` 的 3 个分支 + `useLayoutEffect` 安全网的 3 个分支)中,恢复 `reportData`、`videos`、`capturedFrames` 后立即同步赋值给 `stateRef.current`,确保后续草稿保存时数据完整。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 当使用 `useRef` 作为「自动保存」的数据快照时,**任何从持久化存储恢复数据到 React state 的操作,必须同步更新对应的 ref**,否则 ref 将始终保存陈旧值。
|
||||||
|
- 在涉及草稿/自动保存的功能中,应定期审查所有数据恢复路径(初始化 effect、安全网 effect、手动导入等),确保 ref 与 state 的一致性。
|
||||||
|
- 对于复杂单文件组件,可考虑将「持久化 ↔ 状态同步」逻辑抽离为统一的数据恢复函数,集中处理 ref 同步,减少遗漏点。
|
||||||
|
|||||||
36
工程分析/需求分析-2026-04-16-18-51-06.md
Normal file
36
工程分析/需求分析-2026-04-16-18-51-06.md
Normal 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` 数据恢复正确性影响 |
|
||||||
|
|
||||||
|
## 待确认问题
|
||||||
|
|
||||||
|
无。问题现象明确,根因已定位。
|
||||||
Reference in New Issue
Block a user