2026-04-16-20-24-11 - 从 Ref 读取修复 saveDraftToStorage 闭包陷阱和 DOM 失效导致的内容丢失
This commit is contained in:
@@ -85,17 +85,18 @@ export default function ReportEditor() {
|
|||||||
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: editorRef.current?.innerHTML || '',
|
content: currentContent,
|
||||||
draftReportId: reportId || null,
|
draftReportId: reportId || null,
|
||||||
reportData,
|
reportData: stateRef.current.reportData,
|
||||||
videos,
|
videos: stateRef.current.videos,
|
||||||
capturedFrames,
|
capturedFrames: stateRef.current.capturedFrames,
|
||||||
activeTab,
|
activeTab: stateRef.current.activeTab,
|
||||||
loadedTemplateId
|
loadedTemplateId: stateRef.current.loadedTemplateId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [reportData, videos, capturedFrames, activeTab, loadedTemplateId, reportId]);
|
}, [reportId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const user = storage.get<User | null>('currentUser', null);
|
const user = storage.get<User | null>('currentUser', null);
|
||||||
@@ -300,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;
|
||||||
|
|||||||
116
工程分析/实现方案-2026-04-16-20-24-11.md
Normal file
116
工程分析/实现方案-2026-04-16-20-24-11.md
Normal 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` 回滚。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**
|
||||||
72
工程分析/测试方案-2026-04-16-20-24-11.md
Normal file
72
工程分析/测试方案-2026-04-16-20-24-11.md
Normal 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% 保留
|
||||||
|
- [ ] 多次快速切换后数据不丢失
|
||||||
|
- [ ] 编辑已有报告保存后重新编辑数据完整
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
手工浏览器验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||||
28
工程分析/经验记录.md
28
工程分析/经验记录.md
@@ -178,3 +178,31 @@
|
|||||||
- 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。
|
- 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。
|
||||||
- 在开发阶段应始终开启 `StrictMode` 测试,因为它能暴露 ref-based 状态同步在卸载/重挂载时的隐藏 bug。
|
- 在开发阶段应始终开启 `StrictMode` 测试,因为它能暴露 ref-based 状态同步在卸载/重挂载时的隐藏 bug。
|
||||||
- 对于大型表单/编辑器组件,应将自动保存逻辑与业务状态彻底解耦,统一通过 hook 的最新状态闭包来持久化。
|
- 对于大型表单/编辑器组件,应将自动保存逻辑与业务状态彻底解耦,统一通过 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 同步问题。
|
||||||
|
|||||||
34
工程分析/需求分析-2026-04-16-20-24-11.md
Normal file
34
工程分析/需求分析-2026-04-16-20-24-11.md
Normal 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` 遗漏点补齐 |
|
||||||
|
| 其他文件 | 无 | 不涉及修改 |
|
||||||
|
|
||||||
|
## 待确认问题
|
||||||
|
|
||||||
|
无。问题现象和分析已明确。
|
||||||
Reference in New Issue
Block a user