2026-04-16-18-51-06 - 修复路由切换后视频分析图片丢失问题

This commit is contained in:
2026-04-16 18:58:35 +08:00
parent 11278d0bcd
commit 396a8cab0b
6 changed files with 452 additions and 16 deletions

View File

@@ -67,9 +67,7 @@ export default function ReportEditor() {
const videoInputRef = useRef<HTMLInputElement>(null);
const contentLoadedRef = useRef(false);
const contentRef = useRef('');
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab });
stateRef.current = { reportData, videos, capturedFrames, activeTab };
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId });
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
@@ -88,12 +86,11 @@ export default function ReportEditor() {
if (key) {
storage.set(key, {
content: contentRef.current,
loadedTemplateId,
draftReportId: reportId || null,
...stateRef.current
});
}
}, [reportId, loadedTemplateId]);
}, [reportId]);
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
@@ -128,6 +125,13 @@ export default function ReportEditor() {
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
setTimeout(() => updatePageHeight(), 0);
}
} else {
@@ -146,6 +150,12 @@ export default function ReportEditor() {
contentRef.current = found.content;
}
contentLoadedRef.current = true;
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
setTimeout(() => updatePageHeight(), 0);
}
if (found.capturedFrames) {
@@ -176,6 +186,13 @@ export default function ReportEditor() {
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
setTimeout(() => updatePageHeight(), 0);
}
}
@@ -185,6 +202,7 @@ export default function ReportEditor() {
const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate);
if (tpl) {
setLoadedTemplateId(tpl.id);
stateRef.current = { ...stateRef.current, loadedTemplateId: tpl.id };
editorRef.current.innerHTML = tpl.content;
contentRef.current = tpl.content;
} else {
@@ -398,6 +416,7 @@ export default function ReportEditor() {
}));
const combined = [...videos, ...newVideos];
setVideos(combined);
stateRef.current = { ...stateRef.current, videos: combined };
setCurrentVideoIndex(videos.length); // select first newly uploaded video
if (videoInputRef.current) videoInputRef.current.value = '';
saveDraftToStorage();
@@ -407,15 +426,18 @@ export default function ReportEditor() {
const idx = videos.findIndex(v => v.id === id);
const updated = videos.filter(v => v.id !== id);
setVideos(updated);
stateRef.current = { ...stateRef.current, videos: updated };
if (currentVideoIndex >= updated.length) {
setCurrentVideoIndex(updated.length > 0 ? 0 : -1);
} else if (currentVideoIndex === idx && updated.length > 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 };
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();
};
@@ -449,7 +471,9 @@ export default function ReportEditor() {
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
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();
};
@@ -468,8 +492,9 @@ export default function ReportEditor() {
const wasPlaying = !video.paused;
if (wasPlaying) video.pause();
const newFrames: CapturedFrame[] = [];
for (const pos of positions) {
let accumulatedFrames = [...capturedFrames];
for (let i = 0; i < positions.length; i++) {
const pos = positions[i];
const time = (pos / 100) * dur;
video.currentTime = time;
await new Promise<void>(resolve => {
@@ -482,7 +507,7 @@ export default function ReportEditor() {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
newFrames.push({
const newFrame: CapturedFrame = {
id: Date.now() + Math.random(),
videoIndex: currentVideoIndex,
videoName: videos[currentVideoIndex].name,
@@ -490,9 +515,27 @@ export default function ReportEditor() {
timeFormatted: formatTime(time),
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
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();
saveDraftToStorage();
};
@@ -596,8 +639,7 @@ export default function ReportEditor() {
if (tpl) {
editorRef.current.innerHTML = tpl.content;
contentRef.current = tpl.content;
setLoadedTemplateId(tpl.id);
setReportData({
const nextReportData = {
title: tpl.name || '腹腔镜胆囊切除术报告',
patientName: '',
hospitalId: '',
@@ -615,11 +657,21 @@ export default function ReportEditor() {
anesthesiologist: [],
anesthesiaType: '',
status: 'draft'
});
};
setLoadedTemplateId(tpl.id);
setReportData(nextReportData);
setVideos([]);
setCapturedFrames([]);
setCurrentVideoIndex(-1);
prevVideoCountRef.current = 0;
stateRef.current = {
...stateRef.current,
loadedTemplateId: tpl.id,
reportData: nextReportData,
videos: [],
capturedFrames: [],
activeTab: stateRef.current.activeTab
};
updatePageHeight();
saveDraftToStorage();
}
@@ -641,6 +693,13 @@ export default function ReportEditor() {
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
setTimeout(() => updatePageHeight(), 0);
return;
}
@@ -655,6 +714,12 @@ export default function ReportEditor() {
editorRef.current.innerHTML = found.content;
}
contentLoadedRef.current = true;
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
setTimeout(() => updatePageHeight(), 0);
return;
}
@@ -664,6 +729,13 @@ export default function ReportEditor() {
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
setTimeout(() => updatePageHeight(), 0);
return;
}
@@ -678,6 +750,7 @@ export default function ReportEditor() {
const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate);
if (tpl) {
setLoadedTemplateId(tpl.id);
stateRef.current = { ...stateRef.current, loadedTemplateId: tpl.id };
editorRef.current.innerHTML = tpl.content;
} else {
editorRef.current.innerHTML = defaultReportContent;

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

@@ -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,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

@@ -83,3 +83,25 @@
- 对于图片/截图类卡片上的操作按钮,应优先考虑不遮挡核心图片内容的区域(如底部、角落),避免影响预览。
- 在 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 同步,减少遗漏点。

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` 数据恢复正确性影响 |
## 待确认问题
无。问题现象明确,根因已定位。