2026-04-16-19-28-04 - 彻底重构自动保存机制,修复路由切换后所有内容丢失问题
This commit is contained in:
@@ -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'));
|
||||
|
||||
141
工程分析/实现方案-2026-04-16-19-28-04.md
Normal file
141
工程分析/实现方案-2026-04-16-19-28-04.md
Normal 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` 回滚。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**
|
||||
80
工程分析/测试方案-2026-04-16-19-28-04.md
Normal file
80
工程分析/测试方案-2026-04-16-19-28-04.md
Normal 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:模板切换行为未被意外改变,页面无崩溃。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工浏览器验证。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||
26
工程分析/经验记录.md
26
工程分析/经验记录.md
@@ -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 的最新状态闭包来持久化。
|
||||
|
||||
34
工程分析/需求分析-2026-04-16-19-28-04.md
Normal file
34
工程分析/需求分析-2026-04-16-19-28-04.md
Normal 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` 安全网 | 中 | 需要添加依赖数组,避免重复执行 |
|
||||
| 其他组件 | 无 | 不涉及修改 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。根因已定位,修复方案明确。
|
||||
Reference in New Issue
Block a user