From 396a8cab0bd3894c1ea8502a39798380015dfc18 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Thu, 16 Apr 2026 18:58:35 +0800 Subject: [PATCH] =?UTF-8?q?2026-04-16-18-51-06=20-=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=88=87=E6=8D=A2=E5=90=8E=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=9B=BE=E7=89=87=E4=B8=A2=E5=A4=B1=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ReportEditor.tsx | 105 +++++++++++++++++--- 工程分析/代码编纂工作流.md | 97 +++++++++++++++++++ 工程分析/实现方案-2026-04-16-18-51-06.md | 118 +++++++++++++++++++++++ 工程分析/测试方案-2026-04-16-18-51-06.md | 90 +++++++++++++++++ 工程分析/经验记录.md | 22 +++++ 工程分析/需求分析-2026-04-16-18-51-06.md | 36 +++++++ 6 files changed, 452 insertions(+), 16 deletions(-) create mode 100644 工程分析/代码编纂工作流.md create mode 100644 工程分析/实现方案-2026-04-16-18-51-06.md create mode 100644 工程分析/测试方案-2026-04-16-18-51-06.md create mode 100644 工程分析/需求分析-2026-04-16-18-51-06.md diff --git a/src/pages/ReportEditor.tsx b/src/pages/ReportEditor.tsx index f1c7c9e..2112134 100644 --- a/src/pages/ReportEditor.tsx +++ b/src/pages/ReportEditor.tsx @@ -67,9 +67,7 @@ export default function ReportEditor() { const videoInputRef = useRef(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('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(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(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'); + } + } + } + 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; diff --git a/工程分析/代码编纂工作流.md b/工程分析/代码编纂工作流.md new file mode 100644 index 0000000..26c793c --- /dev/null +++ b/工程分析/代码编纂工作流.md @@ -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] 经验记录初始文档创建 diff --git a/工程分析/实现方案-2026-04-16-18-51-06.md b/工程分析/实现方案-2026-04-16-18-51-06.md new file mode 100644 index 0000000..5338ec3 --- /dev/null +++ b/工程分析/实现方案-2026-04-16-18-51-06.md @@ -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` 回滚。 + +--- + +**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。** diff --git a/工程分析/测试方案-2026-04-16-18-51-06.md b/工程分析/测试方案-2026-04-16-18-51-06.md new file mode 100644 index 0000000..09762f1 --- /dev/null +++ b/工程分析/测试方案-2026-04-16-18-51-06.md @@ -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 备份)。** diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index aec893a..e55235d 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -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 同步,减少遗漏点。 diff --git a/工程分析/需求分析-2026-04-16-18-51-06.md b/工程分析/需求分析-2026-04-16-18-51-06.md new file mode 100644 index 0000000..4fda2ab --- /dev/null +++ b/工程分析/需求分析-2026-04-16-18-51-06.md @@ -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` 数据恢复正确性影响 | + +## 待确认问题 + +无。问题现象明确,根因已定位。