2026-04-16-19-06-18 - 修复路由切换后报告全部丢失及自动帧插入实时刷新问题

This commit is contained in:
2026-04-16 19:13:26 +08:00
parent 396a8cab0b
commit a4d494f4f8
5 changed files with 303 additions and 23 deletions

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState, useRef } from 'react';
import { flushSync } from 'react-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import {
@@ -120,18 +121,18 @@ export default function ReportEditor() {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
}
if (draft.activeTab) setActiveTab(draft.activeTab);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
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 {
@@ -139,6 +140,12 @@ export default function ReportEditor() {
const found = reports.find(r => r.id === reportId);
if (found) {
setReportData(found);
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
if (editorRef.current) {
const restoreContent = storage.getSession<string | null>(`restore_${reportId}`, null);
if (restoreFlag && restoreContent) {
@@ -146,16 +153,9 @@ export default function ReportEditor() {
storage.removeSession(`restore_${reportId}`);
} else {
editorRef.current.innerHTML = found.content;
contentRef.current = found.content;
contentRef.current = found.content;
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) {
@@ -181,18 +181,18 @@ export default function ReportEditor() {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
}
if (draft.activeTab) setActiveTab(draft.activeTab);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
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);
}
}
@@ -517,7 +517,9 @@ export default function ReportEditor() {
isManual: false
};
accumulatedFrames = [...accumulatedFrames, newFrame].sort((a, b) => a.time - b.time);
setCapturedFrames(accumulatedFrames);
flushSync(() => {
setCapturedFrames(accumulatedFrames);
});
stateRef.current = { ...stateRef.current, capturedFrames: accumulatedFrames };
if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.includes(i) && editorRef.current) {
if ((settings.autoInsertDelay || 0) > 0) {

View File

@@ -0,0 +1,132 @@
# 实现方案 — 2026-04-16-19-06-18
## 根因分析
### 问题 1路由切换后所有内容丢失
`ReportEditor.tsx` 的初始化 `useEffect` 在从 draft 恢复数据时,将 `stateRef.current` 的同步代码放在了 `if (editorRef.current && draft.content.trim().length > 0)` 条件块的**内部**。
这导致当以下任一情况发生时,`stateRef.current` 不会被同步:
- `editorRef.current` 在 useEffect 执行时尚未挂载(组件首次渲染时常见);
- `draft.content` 为空字符串或仅包含空白(新建报告时常见)。
`stateRef.current` 保持为初始值(空的 `reportData`、空的 `videos`、空的 `capturedFrames`)时,用户离开页面触发的 `saveDraftToStorage()` 会**用这些空值覆盖 localStorage 中的 draft**。再次返回 `/report-editor` 时,系统读取了这个被清空的 draft导致所有内容全部丢失。
### 问题 2自动帧插入 UI 批量刷新
`autoCaptureFrames` 是一个 `async` 函数,内部通过 `for` 循环逐帧处理。循环中每次摘取到新帧后都会调用 `setCapturedFrames(accumulatedFrames)`,但由于 React 18 的**自动批处理**机制在异步函数中连续调用的状态更新会被合并DOM 重渲染被推迟到整个循环结束后才执行一次。因此用户看不到逐帧实时摘取的过程,只有等全部帧处理完后,关键帧列表和 placeholder 中的图片才会一次性批量出现。
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 移动 stateRef 同步位置 + 引入 `flushSync` |
## 具体代码变更
### 变更 1useEffect — draft 恢复已有报告(约第 123 行区域)
**当前代码(有问题的结构):**
```tsx
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
// ...
stateRef.current = { ...stateRef.current, reportData: draft.reportData, ... };
}
```
**修改为:**
`stateRef.current` 的赋值提取到 `if (editorRef.current && ...)` 的**外部**,紧接在 `setCapturedFrames` 之后:
```tsx
if (draft.activeTab) setActiveTab(draft.activeTab);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
}
```
### 变更 2useEffect — found 恢复已有报告(约第 141 行区域)
**修改为:**
`stateRef.current` 的赋值提取到 `if (editorRef.current)` 的**外部**,紧接在 `setReportData(found)` 之后:
```tsx
setReportData(found);
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
if (editorRef.current) {
// ... 恢复 editor content ...
}
```
同时顺手清理重复赋值 `contentRef.current = found.content;`(第 149-150 行重复)。
### 变更 3useEffect — draft 恢复新建报告(约第 184 行区域)
**修改为:**
与变更 1 类似,将 `stateRef.current` 的赋值提取到 `if (editorRef.current && ...)` 的**外部**
```tsx
if (draft.activeTab) setActiveTab(draft.activeTab);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
// ... 恢复 editor content ...
}
```
### 变更 4autoCaptureFrames 引入 flushSync约第 519-521 行区域)
在文件顶部增加导入:
```tsx
import { flushSync } from 'react-dom';
```
`autoCaptureFrames` 的 for 循环中,每次更新 `capturedFrames` 时使用 `flushSync` 强制同步渲染:
**当前代码:**
```tsx
setCapturedFrames(accumulatedFrames);
stateRef.current = { ...stateRef.current, capturedFrames: accumulatedFrames };
```
**修改为:**
```tsx
flushSync(() => {
setCapturedFrames(accumulatedFrames);
});
stateRef.current = { ...stateRef.current, capturedFrames: accumulatedFrames };
```
这样每一帧被摘取后都会立即触发 DOM 更新,用户可以在右侧「视频分析」面板中实时看到关键帧逐张出现,同时自动插入逻辑也能按配置延迟后逐张填充 placeholder。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| `flushSync` 在循环中可能导致轻微渲染卡顿 | 低 | 关键帧数量通常仅 10~20 张,现代浏览器完全可以承受;已在 async 函数中使用,不会阻塞视频 seek 事件 |
| 移动 `stateRef` 同步位置可能与其他逻辑产生时序影响 | 低 | 仅将同步从 `if` 内部移到外部,逻辑语义完全一致,只是确保在所有路径下都被执行 |
| `found` 分支中 `contentRef.current` 重复赋值 | 极低 | 顺手清理,不影响行为 |
## 回滚策略
本次修改仅调整代码执行顺序并引入一个 React 标准 API`flushSync`),未改变数据结构或接口。如出现异常,可直接 `git revert` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**

View File

@@ -0,0 +1,89 @@
# 测试方案 — 2026-04-16-19-06-18
## 测试目标
1. 验证路由切换后,报告编辑器内容、基本信息、视频分析数据(自动关键帧、手动截图、拖拽图片)均**完整保留不丢失**。
2. 验证开启「自动帧插入」后,关键帧在自动摘取过程中能够**实时逐张显示**,并在达到延迟时间后**逐张插入**到 `image-placeholder` 中,而不是全部处理完成后一次性批量出现。
## 测试环境
- 浏览器Chrome / Edge推荐
- 前置条件已登录系统localStorage 中有当前用户信息
- 测试文件:准备一个时长超过 60 秒的 MP4 视频文件
- 系统设置:在「系统设置」中开启「自动帧插入」,配置插入延迟为 1~2 秒,并勾选若干自动插入的帧索引(如第 0、3、5 帧)
## 测试用例设计
### 用例 1新建报告 — 路由切换后数据完整保留
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor`(不带 `?id` | 页面正常加载默认模板 |
| 1.2 | 填写患者姓名、住院号、科室等基本信息 | 输入内容正常保留在表单中 |
| 1.3 | 在编辑器中输入一段自定义文字 | 编辑器内容正常显示 |
| 1.4 | 切换到「视频分析」页签,上传测试视频 | 视频出现在右侧列表 |
| 1.5 | 点击「自动关键帧摘取」 | 右侧出现多张自动关键帧缩略图 |
| 1.6 | 手动截取 2 张截图 | 手动截图出现在右侧列表 |
| 1.7 | 拖拽 1 张自动关键帧到编辑器的 `image-placeholder` | placeholder 正确显示图片 |
| 1.8 | 在地址栏手动跳转到 `/report-manage` | 页面正常跳转 |
| 1.9 | 再次输入 `/report-editor` 返回新建报告页面 | **编辑器内容、基本信息、自动关键帧、手动截图、placeholder 中的图片均完整保留** |
| 1.10 | 点击「保存草稿」,再次离开并返回 | 所有数据仍然完整 |
### 用例 2编辑已有报告 — 保存后重新编辑数据完整
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 进入 `/report-manage`,对已有报告点击「编辑」 | `/report-editor?id=xxx` 正常加载 |
| 2.2 | 修改患者姓名,上传视频,自动摘取关键帧 | 修改和视频分析数据正常显示 |
| 2.3 | 点击「保存草稿」 | 提示保存成功 |
| 2.4 | 跳转离开后再返回 `/report-editor?id=xxx` | **修改后的基本信息、报告内容、视频分析数据完整恢复** |
### 用例 3边界场景 — 多次快速路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 在 `/report-editor` 中完成用例 1 的全部操作 | 数据正常 |
| 3.2 | 快速连续切换:/report-editor → /report-manage → /report-editor → /report-manage → /report-editor | 最终返回时,**没有任何数据丢失或变空** |
| 3.3 | 检查 localStorage 中 `reportEditorDraft_{username}` | draft 中 `reportData``videos``capturedFrames` 均非空 |
### 用例 4自动帧插入 — 关键帧实时逐张显示
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 进入系统设置,开启「自动帧插入」,设置延迟 1 秒,勾选索引 0、2、4 为自动插入 | 设置保存成功 |
| 4.2 | 进入 `/report-editor`,上传视频,确保编辑器中有至少 3 个空的 `image-placeholder` | placeholder 就位 |
| 4.3 | 点击「自动关键帧摘取」 | **右侧关键帧列表中,每隔一小段时间(视频 seek 时间)就有新的一张缩略图出现**,而不是等全部结束后一次性全部出现 |
| 4.4 | 观察 placeholder | 当第 0 帧被摘取后,等待约 1 秒,第一个 placeholder 被填充;第 2 帧摘取后约 1 秒,第二个 placeholder 被填充;以此类推,**逐张插入** |
### 用例 5自动帧插入 — 无延迟配置的即时插入
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 5.1 | 在系统设置中将自动插入延迟设为 0 秒 | 设置保存成功 |
| 5.2 | 进入 `/report-editor`,上传视频,点击「自动关键帧摘取」 | 每摘取到一张指定索引的帧,**几乎立即**填充到下一个空的 placeholder 中 |
| 5.3 | 观察右侧关键帧列表 | 同样能看到帧逐张实时出现 |
### 用例 6回归测试 — 模板切换行为不变
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 6.1 | 在 `/report-editor` 中上传视频、截取若干帧 | 数据正常 |
| 6.2 | 切换模板 | 编辑器内容按模板重置,基本信息清空,视频分析根据现有逻辑可能被清空 |
| 6.3 | 离开并返回 | 模板切换后的状态保持一致,无异常崩溃 |
## 验收标准
- [ ] 用例 1新建报告路由切换后编辑器内容、基本信息、视频分析数据 100% 保留;
- [ ] 用例 2编辑已有报告保存后再次编辑数据完整无丢失
- [ ] 用例 3多次快速切换路由后数据不丢失、不异常变空
- [ ] 用例 4开启自动帧插入且有延迟时关键帧实时逐张显示、逐张插入 placeholder
- [ ] 用例 5延迟为 0 时,指定帧摘取后立即插入 placeholder
- [ ] 用例 6模板切换的现有行为未被意外改变页面无崩溃。
## 测试方式
由于本项目目前无自动化测试框架,所有测试用例均通过 **手工浏览器验证** 执行。测试人员按上表逐步操作,观察实际结果是否与预期一致。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段(修改代码 + 更新经验记录 + Gitea 备份)。**

View File

@@ -105,3 +105,26 @@
- 当使用 `useRef` 作为「自动保存」的数据快照时,**任何从持久化存储恢复数据到 React state 的操作,必须同步更新对应的 ref**,否则 ref 将始终保存陈旧值。
- 在涉及草稿/自动保存的功能中,应定期审查所有数据恢复路径(初始化 effect、安全网 effect、手动导入等确保 ref 与 state 的一致性。
- 对于复杂单文件组件,可考虑将「持久化 ↔ 状态同步」逻辑抽离为统一的数据恢复函数,集中处理 ref 同步,减少遗漏点。
---
## 记录 6路由切换后报告内容、基本信息、视频分析全部丢失 + 自动帧插入 UI 延迟刷新
**A. 具体问题**
1.`/report-editor` 中编辑报告后,切换到 `/report-manage` 再返回 `/report-editor`**报告内容变空、基本信息清空、视频分析数据全部丢失**。
2. 开启「自动帧插入」后,自动关键帧摘取过程中右侧关键帧列表和 placeholder 中的图片**不会逐张实时更新**,而是等所有帧全部处理完后一次性批量出现。
**B. 产生问题原因**
1. **数据丢失原因**:在初始化 `useEffect` 中,将 `stateRef.current` 的同步赋值放在了 `if (editorRef.current && draft.content.trim().length > 0)` 条件块的内部。当组件首次渲染时 `editorRef` 尚未挂载,或 `draft.content` 为空(新建报告常见场景),`stateRef.current` 就得不到同步,始终保存着初始空值。组件卸载时,空值被保存为 draft覆盖了用户已有的数据。
2. **UI 延迟原因**`autoCaptureFrames` 是一个 async 函数,内部循环中连续调用 `setCapturedFrames`。由于 React 18 的自动批处理机制在异步函数中连续的状态更新会被合并DOM 重渲染被推迟到整个循环结束后才执行一次,导致用户看不到逐帧实时更新的效果。
**C. 解决问题方案**
1. **修复数据丢失**:在 `ReportEditor.tsx` 初始化 `useEffect` 的 3 个数据恢复分支draft 恢复已有报告、found 恢复已有报告、draft 恢复新建报告)中,将 `stateRef.current` 的同步赋值**移到 `editorRef.current/content` 判断条件的外部**,确保无论编辑器 DOM 是否已挂载、`content` 是否为空,`reportData``videos``capturedFrames` 都会立即写入 `stateRef.current`
2. **清理重复代码**:顺带移除了 `found` 恢复分支中 `contentRef.current = found.content;` 的重复赋值。
3. **修复 UI 延迟**:在 `autoCaptureFrames` 的 for 循环中,将 `setCapturedFrames` 包裹在 `flushSync(() => { ... })` 中,强制每一帧被摘取后立即触发 DOM 更新,实现逐张实时显示和逐张插入 placeholder。
**D. 后续如何避免问题**
- 当使用 `useRef` 作为自动保存的数据快照时,**ref 的同步赋值绝对不能依赖于任何与 UI 渲染相关的条件判断**(如 `editorRef.current` 是否存在、`content` 是否非空),否则在组件挂载前或内容为空时会导致数据丢失。
- 在异步函数中需要让用户看到实时状态更新时,应使用 `flushSync` 强制同步渲染,避免被 React 自动批处理延迟。
- 对于复杂单文件组件中的「恢复数据」逻辑,建议将所有 `setState` 和对应的 `ref` 同步集中在一个统一的恢复函数中处理,减少遗漏点和条件嵌套。

View File

@@ -0,0 +1,34 @@
# 需求分析 — 2026-04-16-19-06-18
## 原始需求摘要
### 问题 1路由切换后所有内容丢失
`/report-editor` 页面操作后,切换到 `/report-manage` 等其他页面,再返回 `/report-editor` 时,**所有内容全部变空**
- 报告编辑器内容丢失;
- 基本信息(患者姓名、住院号等)丢失;
- 视频分析中的自动关键帧、手动截图、拖拽到 `image-placeholder` 的图片全部丢失。
### 问题 2自动帧插入的 UI 刷新时序异常
开启「自动帧插入」后,系统应在**每摘取到一张特定关键帧后、经过设定的延迟时间,立即将该帧插入到报告中**。但目前的实际表现是:用户需要等待所有关键帧全部摘取完成后,右侧关键帧列表和报告中的插入图片才会**一次性批量蹦出**,无法看到逐帧实时更新的效果。
## 需求拆解
### 功能点
- **问题 1**:修复路由切换后报告内容、基本信息、视频分析数据全部丢失的根因;
- **问题 2**:修复 `autoCaptureFrames` 中 React 状态更新被批处理导致的 UI 延迟刷新问题,使关键帧在摘取过程中实时可见、并按延迟配置逐帧插入 placeholder。
### 非功能点
- 保持现有 localStorage 草稿机制不变;
- 最小化改动范围,不引入新的状态管理库;
- 不破坏模板切换、保存草稿、打印等现有功能。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 高 | 初始化数据恢复逻辑 + autoCaptureFrames 的渲染同步 |
| 其他页面 | 低 | 仅涉及 `/report-editor` 的数据持久化和恢复 |
## 待确认问题
无。问题现象明确,根因已定位。