From 85080e5630ffd785f140032d184269bbc1a78ae8 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Thu, 16 Apr 2026 20:29:06 +0800 Subject: [PATCH] =?UTF-8?q?2026-04-16-20-24-11=20-=20=E4=BB=8E=20Ref=20?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E4=BF=AE=E5=A4=8D=20saveDraftToStorage=20?= =?UTF-8?q?=E9=97=AD=E5=8C=85=E9=99=B7=E9=98=B1=E5=92=8C=20DOM=20=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E5=AF=BC=E8=87=B4=E7=9A=84=E5=86=85=E5=AE=B9=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ReportEditor.tsx | 16 ++-- 工程分析/实现方案-2026-04-16-20-24-11.md | 116 +++++++++++++++++++++++ 工程分析/测试方案-2026-04-16-20-24-11.md | 72 ++++++++++++++ 工程分析/经验记录.md | 28 ++++++ 工程分析/需求分析-2026-04-16-20-24-11.md | 34 +++++++ 5 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 工程分析/实现方案-2026-04-16-20-24-11.md create mode 100644 工程分析/测试方案-2026-04-16-20-24-11.md create mode 100644 工程分析/需求分析-2026-04-16-20-24-11.md diff --git a/src/pages/ReportEditor.tsx b/src/pages/ReportEditor.tsx index 3e12cd8..5aa02f0 100644 --- a/src/pages/ReportEditor.tsx +++ b/src/pages/ReportEditor.tsx @@ -85,17 +85,18 @@ export default function ReportEditor() { const user = storage.get('currentUser', null); const key = user ? `reportEditorDraft_${user.username}` : ''; if (key) { + const currentContent = contentRef.current || editorRef.current?.innerHTML || ''; storage.set(key, { - content: editorRef.current?.innerHTML || '', + content: currentContent, draftReportId: reportId || null, - reportData, - videos, - capturedFrames, - activeTab, - loadedTemplateId + reportData: stateRef.current.reportData, + videos: stateRef.current.videos, + capturedFrames: stateRef.current.capturedFrames, + activeTab: stateRef.current.activeTab, + loadedTemplateId: stateRef.current.loadedTemplateId }); } - }, [reportData, videos, capturedFrames, activeTab, loadedTemplateId, reportId]); + }, [reportId]); useEffect(() => { const user = storage.get('currentUser', null); @@ -300,6 +301,7 @@ export default function ReportEditor() { sel?.removeAllRanges(); sel?.addRange(range); document.execCommand('delete'); + if (editorRef.current) contentRef.current = editorRef.current.innerHTML; saveDraftToStorage(); } return; diff --git a/工程分析/实现方案-2026-04-16-20-24-11.md b/工程分析/实现方案-2026-04-16-20-24-11.md new file mode 100644 index 0000000..0aac254 --- /dev/null +++ b/工程分析/实现方案-2026-04-16-20-24-11.md @@ -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('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` 回滚。 + +--- + +**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。** diff --git a/工程分析/测试方案-2026-04-16-20-24-11.md b/工程分析/测试方案-2026-04-16-20-24-11.md new file mode 100644 index 0000000..4569a38 --- /dev/null +++ b/工程分析/测试方案-2026-04-16-20-24-11.md @@ -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% 保留 +- [ ] 多次快速切换后数据不丢失 +- [ ] 编辑已有报告保存后重新编辑数据完整 + +## 测试方式 + +手工浏览器验证。 + +--- + +**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。** diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 05c7f2d..1f2aba5 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -178,3 +178,31 @@ - 自动保存函数应直接从最新的 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 同步问题。 diff --git a/工程分析/需求分析-2026-04-16-20-24-11.md b/工程分析/需求分析-2026-04-16-20-24-11.md new file mode 100644 index 0000000..7850006 --- /dev/null +++ b/工程分析/需求分析-2026-04-16-20-24-11.md @@ -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` 遗漏点补齐 | +| 其他文件 | 无 | 不涉及修改 | + +## 待确认问题 + +无。问题现象和分析已明确。