2026-04-16-19-28-04 - 彻底重构自动保存机制,修复路由切换后所有内容丢失问题

This commit is contained in:
2026-04-16 19:56:23 +08:00
parent 22d3ce0e35
commit 39ecdf2b71
5 changed files with 298 additions and 20 deletions

View File

@@ -86,12 +86,16 @@ export default function ReportEditor() {
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: contentRef.current,
content: editorRef.current?.innerHTML || '',
draftReportId: reportId || null,
...stateRef.current
reportData,
videos,
capturedFrames,
activeTab,
loadedTemplateId
});
}
}, [reportId]);
}, [reportData, videos, capturedFrames, activeTab, loadedTemplateId, reportId]);
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
@@ -220,25 +224,18 @@ export default function ReportEditor() {
}, [reportId, navigate, draftKey, restoreFlag]);
useEffect(() => {
const save = () => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: contentRef.current,
draftReportId: reportId || null,
...stateRef.current
});
}
const handleBeforeUnload = () => saveDraftToStorage();
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') saveDraftToStorage();
};
window.addEventListener('beforeunload', save);
document.addEventListener('visibilitychange', save);
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('beforeunload', save);
document.removeEventListener('visibilitychange', save);
save();
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
saveDraftToStorage();
};
}, [reportId]);
}, [saveDraftToStorage]);
useEffect(() => {
if (!editorRef.current) return;
@@ -762,7 +759,7 @@ export default function ReportEditor() {
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
});
}, []);
const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
const minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));

View File

@@ -0,0 +1,141 @@
# 实现方案 — 2026-04-16-19-28-04
## 根因分析
当前 `ReportEditor.tsx` 的自动保存机制过度依赖两个 `useRef``stateRef``contentRef`)作为"数据快照"
1. **用户操作时**:各事件处理器先更新 React state再手动同步 `stateRef.current`,然后调用 `saveDraftToStorage()` 写入 localStorage。
2. **组件卸载时**`useEffect` 的 cleanup 调用 `save()`,同样读取 `stateRef.current``contentRef.current` 来保存 draft。
这个机制存在致命缺陷:
- **React 18 `StrictMode`** 在开发/预览环境下会"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,`stateRef.current` 还是组件创建时的初始空值(`videos: []``capturedFrames: []``reportData: 默认值`。cleanup 中的 `save()` 会用这个空值 **覆盖** localStorage 里已经存在的正确 draft。
- 即使不在 StrictMode 下,只要任何恢复路径或用户操作路径遗漏了 `stateRef.current` 的同步,卸载保存时就会丢失数据。
此前两次修复只把 `stateRef.current` 同步移到了更多恢复分支中,但**没有从根本上消除对 `stateRef` 的依赖**,因此问题依旧。
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 重构 `saveDraftToStorage` + 自动保存 effect + `useLayoutEffect` 依赖 |
## 具体代码变更
### 变更 1重构 `saveDraftToStorage`
**当前实现(依赖 ref**
```tsx
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: contentRef.current,
draftReportId: reportId || null,
...stateRef.current
});
}
}, [reportId]);
```
**修改为(直接从最新 state 和 DOM 读取):**
```tsx
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: editorRef.current?.innerHTML || '',
draftReportId: reportId || null,
reportData,
videos,
capturedFrames,
activeTab,
loadedTemplateId
});
}
}, [reportData, videos, capturedFrames, activeTab, loadedTemplateId, reportId]);
```
**效果**
- `saveDraftToStorage` 的闭包永远绑定当前渲染周期的最新 state不再需要 `stateRef` 作为中转。
- 编辑器内容直接从 `editorRef.current?.innerHTML` 读取,不再依赖可能滞后的 `contentRef`
- 用户操作中所有调用 `saveDraftToStorage()` 的地方(表单 onChange、上传视频、截图等会自动生效。
### 变更 2重构自动保存 effect
**当前实现:**
```tsx
useEffect(() => {
const save = () => { ... };
window.addEventListener('beforeunload', save);
document.addEventListener('visibilitychange', save);
return () => {
window.removeEventListener('beforeunload', save);
document.removeEventListener('visibilitychange', save);
save();
};
}, [reportId]);
```
**修改为:**
```tsx
useEffect(() => {
const handleBeforeUnload = () => saveDraftToStorage();
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') saveDraftToStorage();
};
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
saveDraftToStorage();
};
}, [saveDraftToStorage]);
```
**效果**
- 由于 `saveDraftToStorage` 的 dependency 包含了所有关键 state每次 state 变化后 effect 都会重新注册,但最重要的是 **cleanup 中调用的 `saveDraftToStorage` 永远指向最新的闭包**,不会因为 ref 滞后而用空值覆盖 draft。
### 变更 3给 `useLayoutEffect` 安全网添加 `[]` 依赖
**当前:**
```tsx
React.useLayoutEffect(() => {
if (contentLoadedRef.current || !editorRef.current) return;
// ... 恢复逻辑 ...
});
```
**修改为:**
```tsx
React.useLayoutEffect(() => {
if (contentLoadedRef.current || !editorRef.current) return;
// ... 恢复逻辑 ...
}, []);
```
**效果**
- 避免在每次渲染后重复执行安全网,防止潜在的意外覆盖或性能损耗。
- 保留其原有功能:仅在组件挂载时,如果 `useEffect` 初始化因 ref 未 ready 未能恢复内容,则作为兜底恢复编辑器 DOM。
### 变更 4可选但建议移除对 `contentRef` 的强依赖
`contentRef` 在旧代码中用于 draft 保存。修改 `saveDraftToStorage` 后直接读取 `editorRef.current?.innerHTML``contentRef` 仍可保留供其他旧代码使用,不影响功能。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| `saveDraftToStorage` dependency 数组较长,可能导致 effect 频繁重新注册 | 低 | 重新注册事件监听器的开销极小,远小于 localStorage 写入本身 |
| `editorRef.current?.innerHTML` 在卸载时读取可能拿到不完整 DOM | 极低 | `editorRef` 指向的 DOM 在 cleanup 执行时尚未被 React 移除,内容完整 |
| `useLayoutEffect` 添加 `[]` 后闭包值陈旧 | 低 | `useLayoutEffect` 内部仅读取 `reportId`(来自 URL在生命周期内不变和 localStorage不受影响 |
## 回滚策略
本次修改仅重构保存函数的实现方式,不改变数据结构或存储 key。如出现异常可直接 `git revert` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**

View File

@@ -0,0 +1,80 @@
# 测试方案 — 2026-04-16-19-28-04
## 测试目标
彻底验证路由切换后,报告编辑器内容、基本信息、视频列表、关键帧截图在任何场景下均不再丢失。
## 测试环境
- 浏览器Chrome / Edge
- 前置条件已登录系统localStorage 中有当前用户信息
- 测试文件:准备一个时长超过 30 秒的 MP4 视频文件
## 测试用例设计
### 用例 1新建报告 — 填写基本信息后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor`(不带 `?id` | 页面正常加载默认模板 |
| 1.2 | 填写患者姓名、住院号、科室 | 输入内容保留在表单中 |
| 1.3 | 在编辑器中输入文字 | 编辑器内容正常显示 |
| 1.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **基本信息和编辑器内容均完整保留** |
### 用例 2新建报告 — 上传视频 + 自动关键帧后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 在 `/report-editor` 中上传视频 | 视频出现在右侧列表 |
| 2.2 | 点击「自动关键帧摘取」 | 右侧出现多张关键帧缩略图 |
| 2.3 | 手动截取 2 张截图 | 手动截图出现在右侧 |
| 2.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **视频列表、自动关键帧、手动截图全部保留** |
### 用例 3新建报告 — 拖拽图片到 placeholder 后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 插入 `image-placeholder`,拖拽一张关键帧到其中 | placeholder 显示图片 |
| 3.2 | 跳转到 `/report-manage`,再返回 `/report-editor` | **placeholder 中的图片保留,右侧关键帧列表也保留** |
### 用例 4编辑已有报告 — 修改后保存并重新编辑
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 从 `/report-manage` 编辑已有报告 | `/report-editor?id=xxx` 正常加载 |
| 4.2 | 修改患者姓名,上传视频,自动摘取关键帧 | 所有数据正常显示 |
| 4.3 | 点击「保存草稿」 | 提示保存成功 |
| 4.4 | 离开并重新进入 `/report-editor?id=xxx` | **修改后的所有数据完整恢复** |
### 用例 5边界 — 多次快速路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 5.1 | 完成用例 2 和 3 的所有操作 | 数据正常 |
| 5.2 | 连续快速切换 /report-editor ↔ /report-manage 3 次以上 | **最终返回时没有任何数据丢失或变空** |
| 5.3 | 检查 localStorage `reportEditorDraft_{username}` | draft 中 `reportData``videos``capturedFrames``content` 均非空 |
### 用例 6回归 — 模板切换后行为正常
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 6.1 | 在 `/report-editor` 中上传视频并截取若干帧 | 数据正常 |
| 6.2 | 切换模板 | 编辑器内容重置,基本信息清空(现有预期行为) |
| 6.3 | 离开并返回 | 页面无崩溃,状态与模板切换后一致 |
## 验收标准
- [ ] 用例 1基本信息和编辑器内容在路由切换后 100% 保留;
- [ ] 用例 2视频列表和关键帧在路由切换后 100% 保留;
- [ ] 用例 3拖拽到 placeholder 的图片在路由切换后 100% 保留;
- [ ] 用例 4编辑已有报告保存后重新编辑数据完整无丢失
- [ ] 用例 5多次快速切换路由后数据不丢失、不异常变空
- [ ] 用例 6模板切换行为未被意外改变页面无崩溃。
## 测试方式
手工浏览器验证。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View File

@@ -152,3 +152,29 @@
- 在无法使用 Docker 的环境中,可将 `npm run build && npm run preview` 作为标准部署脚本;
- 重新部署前务必先清理旧的同类型进程,避免端口冲突或多版本服务同时运行导致访问混乱;
- 如需固定端口,可在 `package.json``preview` 脚本中增加 `--port` 参数(如 `vite preview --port 8080`)。
---
## 记录 8路由切换后所有内容仍然丢失——彻底重构自动保存机制
**A. 具体问题**
`/report-editor` 中编辑报告(填写基本信息、上传视频、自动/手动截取关键帧、拖拽图片到 placeholder切换到 `/report-manage` 再返回 `/report-editor`,报告编辑器内容、基本信息、视频列表、关键帧截图**全部丢失**。
**B. 产生问题原因**
1. 自动保存机制过度依赖 `stateRef``contentRef` 作为"数据快照"。
2. **React 18 `StrictMode`** 在开发/预览环境下会执行"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,`stateRef.current` 仍然是组件创建时的初始空值(`videos: []``capturedFrames: []`、默认 `reportData`)。
3. 组件卸载cleanup时调用保存用这个空值**覆盖了 localStorage 中已有的正确 draft**。
4. 重新挂载后,系统读取了被清空的 draft导致所有数据全部丢失。
5. 此前两次修复仅把 `stateRef.current` 同步移到了更多恢复分支中,但**没有从根本上消除对 ref 的依赖**,因此 `StrictMode` 下的首次卸载仍会覆盖有效 draft。
**C. 解决问题方案**
1. **彻底重构 `saveDraftToStorage`**:不再读取 `contentRef.current``stateRef.current`,而是直接从最新的 React state 和 `editorRef.current?.innerHTML` 获取数据。`useCallback` 的 dependency 数组包含 `reportData``videos``capturedFrames``activeTab``loadedTemplateId``reportId`,确保闭包永远绑定当前渲染周期的最新 state。
2. **重构自动保存 effect**:将 `beforeunload``visibilitychange` 事件处理器直接绑定到 `saveDraftToStorage`effect 的 dependency 改为 `[saveDraftToStorage]`。这样即使 `StrictMode` 导致组件在首次挂载后立即卸载cleanup 中调用的 `saveDraftToStorage` 也指向最新数据的闭包,不会用空值覆盖已有 draft。
3. **给 `useLayoutEffect` 安全网添加 `[]` 依赖**:防止每次渲染后重复执行,避免潜在的意外覆盖。
**D. 后续如何避免问题**
- **永远不要将 `useRef` 作为自动保存的唯一数据源**。ref 在 React 18 `StrictMode` 的模拟卸载阶段仍然保持初始值,会导致用空数据覆盖有效持久化数据。
- 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。
- 在开发阶段应始终开启 `StrictMode` 测试,因为它能暴露 ref-based 状态同步在卸载/重挂载时的隐藏 bug。
- 对于大型表单/编辑器组件,应将自动保存逻辑与业务状态彻底解耦,统一通过 hook 的最新状态闭包来持久化。

View File

@@ -0,0 +1,34 @@
# 需求分析 — 2026-04-16-19-28-04
## 原始需求摘要
`/report-editor` 页面进行操作(填写基本信息、上传视频、自动/手动截取关键帧、拖拽图片到 placeholder离开该页面进入 `/report-manage` 等其他界面后,再次返回 `/report-editor` 时,**所有内容全部丢失**
- 报告编辑器中的文本和图片丢失;
- 基本信息(患者姓名、住院号等)丢失;
- 视频分析面板中的视频列表和关键帧截图全部丢失。
此前两次修复尝试未能解决该问题。
## 需求拆解
### 功能点
- 彻底修复路由切换后报告内容、基本信息、视频分析数据全部丢失的问题;
- 确保自动保存机制(草稿保存)在任何情况下都不会用空值覆盖已有的有效 draft
- 确保组件卸载时保存的 draft 100% 反映用户最新的操作状态。
### 非功能点
- 最小化对现有 UI 和交互逻辑的侵入;
- 保持现有 localStorage 存储机制不变;
- 同时兼顾 React 18 `StrictMode` 在开发/预览环境下的双重挂载行为。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 高 | 自动保存逻辑 `saveDraftToStorage` 和自动保存 effect 需要重构 |
| `useLayoutEffect` 安全网 | 中 | 需要添加依赖数组,避免重复执行 |
| 其他组件 | 无 | 不涉及修改 |
## 待确认问题
无。根因已定位,修复方案明确。