release: v1.2.0 手术图文病历报告系统
This commit is contained in:
116
过往经验/实现方案-2026-04-16-20-24-11.md
Normal file
116
过往经验/实现方案-2026-04-16-20-24-11.md
Normal file
@@ -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<User | null>('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` 回滚。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**
|
||||
90
过往经验/实现方案-2026-04-16-20-33-12.md
Normal file
90
过往经验/实现方案-2026-04-16-20-33-12.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 实现方案 — 2026-04-16-20-33-12
|
||||
|
||||
## 根因分析
|
||||
|
||||
`autoCaptureFrames` 的 `for` 循环内部,自动插入逻辑使用了:
|
||||
```tsx
|
||||
if ((settings.autoInsertDelay || 0) > 0) {
|
||||
await new Promise<void>(r => setTimeout(r, (settings.autoInsertDelay || 0) * 1000));
|
||||
}
|
||||
```
|
||||
|
||||
`await` 会暂停整个 `for` 循环的执行,导致:
|
||||
1. 关键帧摘取被强制暂停,等待延迟结束;
|
||||
2. 所有帧必须一张一张串行处理,整体耗时 = 摘取时间 + 插入延迟 × 插入帧数;
|
||||
3. 用户体验上感觉"卡顿"或"慢"。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 修改 | `autoCaptureFrames` 中自动插入逻辑改为 `setTimeout` 非阻塞执行 |
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 变更:`autoCaptureFrames` 中的插入逻辑(约第 523-535 行)
|
||||
|
||||
**当前代码:**
|
||||
```tsx
|
||||
if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.includes(i) && editorRef.current) {
|
||||
if ((settings.autoInsertDelay || 0) > 0) {
|
||||
await new Promise<void>(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');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**修改为:**
|
||||
```tsx
|
||||
if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.includes(i)) {
|
||||
const baseDelay = (settings.autoInsertDelay || 0) * 1000;
|
||||
const insertOrderIndex = settings.autoInsertFrameIndices.indexOf(i);
|
||||
const actualDelay = baseDelay > 0 ? baseDelay * (insertOrderIndex + 1) : 0;
|
||||
|
||||
setTimeout(() => {
|
||||
if (!editorRef.current) return;
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||||
if (emptyPlaceholder) {
|
||||
emptyPlaceholder.innerHTML = `
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
||||
`;
|
||||
emptyPlaceholder.classList.add('has-image');
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
}
|
||||
}, actualDelay);
|
||||
}
|
||||
```
|
||||
|
||||
**效果:**
|
||||
- `for` 循环全速运行,不再被插入延迟阻塞;
|
||||
- 每张需要插入的帧按顺序延迟(第 1 张 delay,第 2 张 2×delay...),避免同时插入;
|
||||
- `setTimeout` 回调中实时查询 DOM 获取最新的空 placeholder;
|
||||
- 插入后同步 `contentRef.current` 并保存草稿。
|
||||
|
||||
### 附加变更:移除循环后的批量 `contentRef` 更新
|
||||
|
||||
当前代码在循环结束后:
|
||||
```tsx
|
||||
if (settings.autoInsertFrames && editorRef.current) {
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
}
|
||||
```
|
||||
|
||||
由于每个 `setTimeout` 回调内部已经单独更新 `contentRef` 和保存草稿,且循环结束时可能 `setTimeout` 尚未执行,这句批量更新既不及时也可能遗漏。建议**移除或保留不影响功能**。为简化逻辑,选择保留但无实质影响,因为非阻塞的 `setTimeout` 回调会各自负责自己的保存。
|
||||
|
||||
## 风险点
|
||||
|
||||
| 风险 | 级别 | 应对措施 |
|
||||
|------|------|---------|
|
||||
| `setTimeout` 回调执行时 placeholder 已被用户手动填充 | 低 | 回调中实时查询 `.image-placeholder:not(.has-image)`,找不到则跳过,不会覆盖用户内容 |
|
||||
| 多张图片按顺序延迟插入时,用户快速离开页面 | 低 | 每次插入后都调用 `saveDraftToStorage()`,已插入的图片会被保存;未执行的 `setTimeout` 自然丢弃 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
修改范围极小,仅涉及 `autoCaptureFrames` 中的几行代码。如有异常可直接 revert。
|
||||
105
过往经验/实现方案-2026-04-16-20-46-50.md
Normal file
105
过往经验/实现方案-2026-04-16-20-46-50.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 实现方案 — 2026-04-16-20-46-50
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 1. LocalStorage 容量超限(QuotaExceededError)
|
||||
|
||||
浏览器对单个域名的 `localStorage` 通常有 **5MB** 的严格容量限制。
|
||||
|
||||
当前代码在抽帧时使用了视频的原始分辨率:
|
||||
```tsx
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
```
|
||||
|
||||
如果上传的是 1080p 甚至 4K 视频:
|
||||
- 单张 0.9 质量 JPEG Base64 图片可能达到 **300KB ~ 1MB**;
|
||||
- 自动提取 12 张关键帧 + 手动截图若干张,总数据量可能达到 **5MB ~ 10MB**;
|
||||
- 直接超过 `localStorage` 的 5MB 上限。
|
||||
|
||||
### 2. 静默失败
|
||||
|
||||
`src/utils/storage.ts` 中的 `set` 方法:
|
||||
```typescript
|
||||
set<T>(key: string, value: T): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// ignore quota exceeded
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当数据量超过 5MB 时,`localStorage.setItem` 抛出 `QuotaExceededError`,但被 `catch` 静默吞掉。
|
||||
|
||||
**实际发生的过程:**
|
||||
1. 用户上传视频 → 此时 `videos` 数组中的 `url` 是 `blob:http://...`(短字符串),数据量很小,**保存成功**;
|
||||
2. 系统开始自动抽帧,生成巨大的 Base64 `dataUrl` 数组;
|
||||
3. 调用 `saveDraftToStorage()` 尝试保存时,`localStorage.setItem` 触发超限报错;
|
||||
4. 异常被 `catch` 忽略,**draft 没有被更新**(或更新失败);
|
||||
5. 当用户离开页面再返回时,localStorage 中读到的 draft 仍然停留在"仅有视频、没有关键帧"的状态。
|
||||
|
||||
这就是为什么:编辑器内容保留了,视频保留了,但**关键帧全部消失**。
|
||||
|
||||
## 修改方向
|
||||
|
||||
### 方向一:压缩关键帧分辨率与质量(快速修复,推荐优先执行)
|
||||
|
||||
关键帧只是用于插入报告的缩略图,通常不需要 4K 原画质。可以:
|
||||
1. 设定最大宽度(如 800px),等比缩放 Canvas;
|
||||
2. 将 JPEG 导出质量从 `0.9` 降到 `0.5 ~ 0.6`;
|
||||
3. 这样单张图片体积可从 500KB 压缩到 30KB~80KB,十几张关键帧总计不到 1MB,远低于 5MB 限制。
|
||||
|
||||
**修改点:**
|
||||
- `captureFrame()`(手动截图)
|
||||
- `autoCaptureFrames()`(自动抽帧)
|
||||
|
||||
### 方向二:增加存储超限的可见性
|
||||
|
||||
在 `storage.ts` 中不再静默吞掉异常,而是至少输出 `console.error`,甚至可以在 UI 层捕获后提示用户:
|
||||
"报告数据过大,请降低视频截图质量或删除部分图片。"
|
||||
|
||||
### 方向三:迁移到 IndexedDB(长期根治)
|
||||
|
||||
`localStorage` 的 5MB 上限对于包含大量 Base64 图片的医疗报告系统来说迟早会不够用。长期方案是:
|
||||
- 引入 `localforage` 或 `idb-keyval` 等轻量库;
|
||||
- 将 `storage.ts` 改造为基于 IndexedDB 的异步存储方案(容量可达数百 MB)。
|
||||
|
||||
**注意:** 方向三涉及全项目的 `storage.get/set` 调用点从同步改为异步,改动面较大,适合作为后续迭代项目。
|
||||
|
||||
## 建议的实施方案
|
||||
|
||||
**本次优先执行方向一 + 方向二**,以最快速度解决关键帧丢失问题,并让用户感知到存储异常:
|
||||
|
||||
1. 在 `captureFrame` 和 `autoCaptureFrames` 中增加 Canvas 等比缩放逻辑:
|
||||
```tsx
|
||||
const MAX_WIDTH = 800;
|
||||
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
|
||||
canvas.width = video.videoWidth * scale;
|
||||
canvas.height = video.videoHeight * scale;
|
||||
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
|
||||
```
|
||||
|
||||
2. 在 `storage.ts` 中增加超限日志:
|
||||
```tsx
|
||||
} catch (e) {
|
||||
console.error('Storage save failed (possibly quota exceeded):', e);
|
||||
}
|
||||
```
|
||||
|
||||
## 风险点
|
||||
|
||||
| 风险 | 级别 | 应对措施 |
|
||||
|------|------|---------|
|
||||
| 压缩后图片清晰度下降 | 低 | 800px 宽度 + 0.6 质量对于报告插入足够清晰 |
|
||||
| 仍有个别超长视频压缩后接近 5MB | 极低 | 配合方向二的日志提示,便于后续继续优化 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
仅调整 Canvas 缩放参数和 JPEG 质量,不涉及数据结构和接口变更。如有异常可直接 revert。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**
|
||||
72
过往经验/测试方案-2026-04-16-20-24-11.md
Normal file
72
过往经验/测试方案-2026-04-16-20-24-11.md
Normal file
@@ -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% 保留
|
||||
- [ ] 多次快速切换后数据不丢失
|
||||
- [ ] 编辑已有报告保存后重新编辑数据完整
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工浏览器验证。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||
37
过往经验/测试方案-2026-04-16-20-33-12.md
Normal file
37
过往经验/测试方案-2026-04-16-20-33-12.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 测试方案 — 2026-04-16-20-33-12
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证自动帧插入改为非阻塞后:
|
||||
1. 关键帧摘取过程不再被插入延迟阻塞;
|
||||
2. 图片按顺序、按延迟时间依次插入 placeholder;
|
||||
3. 插入后草稿正常保存。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### 用例 1:非阻塞摘取
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1.1 | 系统设置开启自动插入,延迟 2 秒,勾选多个帧索引 | — |
|
||||
| 1.2 | 上传视频,点击「自动关键帧摘取」 | 右侧关键帧列表在 1-2 秒内迅速全部出现,不再卡顿等待 |
|
||||
|
||||
### 用例 2:顺序延迟插入
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 2.1 | 确保编辑器中有足够空 placeholder | — |
|
||||
| 2.2 | 点击「自动关键帧摘取」 | 摘取完成后,placeholder 按顺序每隔约 2 秒插入一张,不是同时插入 |
|
||||
|
||||
### 用例 3:插入后内容保存
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 3.1 | 等待自动插入全部完成 | — |
|
||||
| 3.2 | 切换到 `/report-manage` 再返回 | 已插入 placeholder 的图片保留在编辑器中 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 关键帧摘取不受插入延迟阻塞,快速完成
|
||||
- [ ] 图片按顺序依次插入,不堆积
|
||||
- [ ] 插入后的内容能正常保存
|
||||
60
过往经验/测试方案-2026-04-16-20-46-50.md
Normal file
60
过往经验/测试方案-2026-04-16-20-46-50.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 测试方案 — 2026-04-16-20-46-50
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证压缩关键帧分辨率/质量后,LocalStorage 不再超限,路由切换后自动/手动关键帧能够正常保留;同时验证存储超限时能在控制台看到错误日志。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 浏览器:Chrome / Edge(推荐开启 DevTools 观察 Console 和 Application > Local Storage)
|
||||
- 测试文件:准备一个 1080p 或更高分辨率的 MP4 视频文件(时长 60 秒以上,确保能提取多张关键帧)
|
||||
|
||||
## 测试用例设计
|
||||
|
||||
### 用例 1:自动关键帧摘取后切换路由
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1.1 | 进入 `/report-editor`,上传高清视频 | 视频正常加载 |
|
||||
| 1.2 | 点击「自动关键帧摘取」 | 右侧迅速生成 10+ 张关键帧缩略图 |
|
||||
| 1.3 | 打开 DevTools > Console | 无 `QuotaExceededError` 报错 |
|
||||
| 1.4 | 打开 DevTools > Application > Local Storage | `reportEditorDraft_{username}` 存在且体积明显小于 5MB |
|
||||
| 1.5 | 跳转到 `/report-manage`,再返回 `/report-editor` | **所有自动关键帧缩略图完整保留** |
|
||||
|
||||
### 用例 2:手动截图后切换路由
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 2.1 | 播放视频,在多个时间点点击「手动截图」 | 生成 5 张以上手动截图 |
|
||||
| 2.2 | 切换路由后再返回 | **所有手动截图完整保留** |
|
||||
|
||||
### 用例 3:自动+手动混合场景
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 3.1 | 上传视频,自动摘取 12 张关键帧 | 自动帧正常显示 |
|
||||
| 3.2 | 再手动截取 5 张 | 手动帧正常显示 |
|
||||
| 3.3 | 切换路由后再返回 | **自动帧和手动帧全部保留** |
|
||||
|
||||
### 用例 4:图片清晰度验证
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 4.1 | 拖拽一张压缩后的关键帧到 `image-placeholder` | placeholder 中图片清晰可见,无严重马赛克 |
|
||||
| 4.2 | 打印预览或放大查看 | 图片质量满足报告使用需求 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 高清视频自动摘取 10+ 张关键帧后,LocalStorage 不超限;
|
||||
- [ ] 路由切换后,自动关键帧 100% 保留;
|
||||
- [ ] 路由切换后,手动截图 100% 保留;
|
||||
- [ ] 压缩后的图片清晰度仍满足报告使用;
|
||||
- [ ] `storage.ts` 中存储失败时能在 Console 看到错误日志。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工浏览器验证,结合 DevTools 观察 LocalStorage 容量和 Console 日志。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||
286
过往经验/经验记录.md
Normal file
286
过往经验/经验记录.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# 经验记录
|
||||
|
||||
---
|
||||
|
||||
## 记录 1:report-editor 新建报告时显示空白模板
|
||||
|
||||
**A. 具体问题**
|
||||
超级管理员进入 `/report-editor`(新建报告)时,编辑区域为纯白色空白,顶部模板选择器显示"无",但 system-settings 中已配置了默认模板。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `ReportEditor.tsx` 在组件卸载(如页面切换)时会自动将当前编辑器内容保存为草稿(draft)。即使用户未输入任何内容,保存的 `content` 也是空字符串 `""`。
|
||||
2. 初始化 effect 中判断草稿是否有效的条件仅使用了 `typeof draft.content === 'string'`,空字符串满足该条件,导致编辑器被填充为空白 HTML,并将 `contentLoadedRef.current` 设为 `true`。
|
||||
3. 由于 `contentLoadedRef.current` 已被置为 `true`,后续加载 `settings.defaultTemplate` 的默认模板分支被完全跳过,从而永远显示空白。
|
||||
4. 此外,草稿中未保存 `loadedTemplateId`,即使内容非空时恢复草稿,模板选择器也会因缺少状态而显示"无"。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. 在 `saveDraftToStorage` 中将当前 `loadedTemplateId` 一并存入 draft。
|
||||
2. 将四处草稿恢复的判断条件从 `typeof draft.content === 'string'` 收紧为 `typeof draft.content === 'string' && draft.content.trim().length > 0`,使空白草稿不再拦截默认模板加载。
|
||||
3. 恢复草稿时同步执行 `setLoadedTemplateId(draft.loadedTemplateId || '')`,确保模板选择器名称正确。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 在前端使用 contentEditable 的自动保存机制时,保存和恢复草稿都应增加对空/仅空白内容的过滤。
|
||||
- 若草稿与某个业务状态(如当前模板 ID)强关联,应确保两者一并持久化和恢复,避免状态不一致。
|
||||
- 对兜底初始化逻辑(如默认模板加载)增加更严格的防护,防止被无效中间状态提前截断。
|
||||
|
||||
---
|
||||
|
||||
## 记录 2:关键帧一键插入占位符功能实现
|
||||
|
||||
**A. 具体问题**
|
||||
用户希望视频分析面板中的关键帧截图除了拖拽插入外,还能通过点击 "插入" 按钮一键自动填充到编辑器中第一个空置的 `image-placeholder`。
|
||||
|
||||
**B. 产生问题原因**
|
||||
原先仅支持拖拽方式将关键帧放入占位符。当关键帧数量多或占位符位置较远时,操作不便。且 `handleDrop` 中的填充逻辑未抽离,无法被其他交互方式复用。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. 将 `handleDrop` 中的 HTML 填充逻辑抽离为 `fillPlaceholder(placeholder, frame)` 公共函数。
|
||||
2. 新增 `insertFrameToPlaceholder(frame)` 函数:通过 `editorRef.current.querySelector('.image-placeholder:not(.has-image)')` 查找第一个空置占位符,找到则调用 `fillPlaceholder`,未找到则 `alert('没有可插入图片的空位')`。
|
||||
3. 在关键帧卡片底部的 `timeFormatted` 与 "可拖拽" 之间新增 "插入" 按钮,使用 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 保持一致的显隐行为,并通过 `e.stopPropagation()` 避免触发卡片的视频跳转 `onClick`。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当同一交互效果(如填充占位符)需要支持多种触发方式(拖拽、按钮点击、快捷键等)时,应将核心逻辑抽离为独立函数,避免重复代码。
|
||||
- 在可点击子元素上务必注意事件冒泡控制,防止触发父级不必要的副作用(如此处的视频跳转)。
|
||||
- UI 提示文字(如 "插入"、"可拖拽")的显隐样式应尽量保持一致,减少用户认知成本。
|
||||
|
||||
---
|
||||
|
||||
## 记录 3:关键帧 "插入" 按钮位置与样式优化
|
||||
|
||||
**A. 具体问题**
|
||||
用户对已实现的 "插入" 按钮位置和样式提出优化:希望按钮位于图片中央、做成实体按钮样式、颜色与 "可拖拽" 的蓝色有明显区分。
|
||||
|
||||
**B. 产生问题原因**
|
||||
初次实现时将 "插入" 按钮放在了卡片底部文字区域,采用纯文字链接样式(`text-accent`),视觉上不够醒目,且与 "可拖拽" 提示颜色重叠,辨识度低。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. 将 "插入" 按钮从底部文字行移到图片层的 `<div className="relative">` 容器内,使用 `absolute inset-0 m-auto w-fit h-fit` 实现水平和垂直居中。
|
||||
2. 将按钮样式改为实体胶囊按钮:`px-3 py-1.5 bg-emerald-500 text-white rounded-full shadow-md`,hover 时加深为 `bg-emerald-600`。
|
||||
3. 底部文字区域只保留 `timeFormatted` 和 "可拖拽" 提示,"插入" 按钮不再与它们并列。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 对于图片卡片上的核心操作按钮,优先考虑覆盖在图片中央或显著位置,比在底部小字中放置链接更符合用户直觉。
|
||||
- 同一卡片上的多个 hover 提示元素应保持显隐动画一致(`opacity-0 group-hover:opacity-100 transition-opacity`),但颜色上要有区分,避免用户混淆不同功能。
|
||||
- 使用 `absolute inset-0 m-auto w-fit h-fit` 是一种在 Tailwind 中不依赖 flex/grid 的居中技巧,适合在 `relative` 容器内居中不定宽高的元素。
|
||||
|
||||
---
|
||||
|
||||
## 记录 4:关键帧 "插入" 按钮位置微调(从图片中央移回底部)
|
||||
|
||||
**A. 具体问题**
|
||||
用户反馈将 "插入" 按钮放在图片正中央会遮挡图片内容,希望移回卡片底部,但仍保留实体按钮样式和蓝色。
|
||||
|
||||
**B. 产生问题原因**
|
||||
按钮以 `absolute` 层覆盖在图片中央时,确实会遮挡部分图片内容,对于医学影像类截图可能影响用户预览。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. 将 "插入" 按钮从图片层的 absolute 覆盖层移回卡片底部的文字行,放置在 `timeFormatted` 与 "可拖拽" 之间。
|
||||
2. 按钮颜色恢复为蓝色(`bg-accent text-white`),与 "可拖拽" 蓝色保持一致,视觉上统一。
|
||||
3. 保留实体胶囊按钮样式:`px-2 py-0.5 rounded-full shadow-sm`,不再是纯文字链接。
|
||||
4. 显隐行为仍通过 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 同步。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 对于图片/截图类卡片上的操作按钮,应优先考虑不遮挡核心图片内容的区域(如底部、角落),避免影响预览。
|
||||
- 在 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 同步,减少遗漏点。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 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` 同步集中在一个统一的恢复函数中处理,减少遗漏点和条件嵌套。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 7:重新部署应用(Vite 生产构建 + Vite Preview)
|
||||
|
||||
**A. 具体问题**
|
||||
用户要求将最新代码重新部署到生产环境,但当前运行环境中未安装 Docker,无法使用项目自带的 `docker-compose.yaml` 进行容器化部署。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. 当前 Windows 环境缺少 Docker 和 docker-compose CLI;
|
||||
2. 项目本身是基于 Vite 的前端应用,可通过 `npm run build` 生成静态文件后,使用 `vite preview` 或任意静态文件服务器进行部署;
|
||||
3. 系统中已存在旧版本的 `vite preview` 进程在运行,需要先停止旧服务再启动新服务。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. 使用 PowerShell 查询并强制停止所有属于当前项目目录的旧 `vite preview` 进程;
|
||||
2. 执行 `npm run build` 重新构建生产包;
|
||||
3. 使用 `cmd /c "start /B npm run preview"` 在后台启动新的 Vite 预览服务器;
|
||||
4. 通过 `Invoke-WebRequest` 访问 `http://localhost:4173/` 验证服务返回 HTTP 200,确认部署成功。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 在无法使用 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 的最新状态闭包来持久化。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 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 同步问题。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 10:自动帧插入阻塞关键帧摘取——改为 setTimeout 非阻塞异步插入
|
||||
|
||||
**A. 具体问题**
|
||||
开启「自动帧插入」后,点击「自动关键帧摘取」时,系统不是快速完成所有关键帧的摘取,而是每摘取一张就停下来等待插入延迟(如 2 秒),插入完成后才继续摘取下一张。整体过程非常缓慢,用户体验卡顿。
|
||||
|
||||
**B. 产生问题原因**
|
||||
`autoCaptureFrames` 的 `for` 循环内部,自动插入逻辑使用了 `await new Promise<void>(r => setTimeout(...))`:
|
||||
```tsx
|
||||
if ((settings.autoInsertDelay || 0) > 0) {
|
||||
await new Promise<void>(r => setTimeout(r, (settings.autoInsertDelay || 0) * 1000));
|
||||
}
|
||||
```
|
||||
|
||||
`await` 会暂停整个 `for` 循环的执行,导致关键帧摘取和插入变成了串行阻塞流程:必须等插入完成才能摘取下一张。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. 将 `await new Promise(...)` 替换为 `setTimeout(...)`,把插入操作推入事件队列异步执行,`for` 循环不再被阻塞,可以全速完成所有关键帧的摘取。
|
||||
2. 实现延迟叠加(顺序插入):通过 `settings.autoInsertFrameIndices.indexOf(i)` 计算当前帧是第几个需要插入的,延迟时间为 `baseDelay * (insertOrderIndex + 1)`,避免所有图片在同一时刻同时插入。
|
||||
3. `setTimeout` 回调中实时查询 `.image-placeholder:not(.has-image)`,找到则插入,并同步更新 `contentRef.current` 和调用 `saveDraftToStorage()`。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 在异步循环中,如果某个操作不需要依赖前一步的完成结果,**绝对不要使用 `await` 阻塞主循环**,应改用 `setTimeout` 或 `Promise.all` 实现并行/异步解耦。
|
||||
- 当多个定时任务需要按顺序执行时,可以通过索引计算累积延迟(`delay * (index + 1)`),实现简单的"队列式"顺序触发,而不需要阻塞主流程。
|
||||
- 在 `setTimeout` 等异步回调中操作 DOM 时,应在回调触发时"实时查询"目标元素,而不是在循环中提前捕获元素引用,以防 DOM 在延迟期间已被用户修改。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 11:关键帧在路由切换后丢失——压缩 Canvas 分辨率并增加存储错误日志
|
||||
|
||||
**A. 具体问题**
|
||||
报告编辑器内容和视频列表在路由切换后能正常保留,但视频分析面板中的自动摘取关键帧和手动截图全部丢失。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. **LocalStorage 5MB 容量限制**:当前抽帧逻辑使用视频原始分辨率 + JPEG 质量 0.9:
|
||||
```tsx
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
```
|
||||
对于 1080p/4K 视频,单张 Base64 图片可达 300KB~1MB,十几张关键帧即可超过 5MB。
|
||||
2. **静默失败**:`storage.ts` 中的 `set` 方法捕获了 `QuotaExceededError` 但没有任何日志:
|
||||
```typescript
|
||||
} catch {
|
||||
// ignore quota exceeded
|
||||
}
|
||||
```
|
||||
当 `saveDraftToStorage()` 尝试保存大量关键帧时,`localStorage.setItem` 抛出异常,draft 无法更新,但用户和开发者都感知不到错误。最终返回 `/report-editor` 时,只能读取到"有视频、无关键帧"的旧 draft。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **压缩关键帧分辨率与质量**:
|
||||
- 在 `captureFrame()`(手动截图)和 `autoCaptureFrames()`(自动抽帧)中,增加 Canvas 等比缩放:
|
||||
```tsx
|
||||
const MAX_WIDTH = 800;
|
||||
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
|
||||
canvas.width = video.videoWidth * scale;
|
||||
canvas.height = video.videoHeight * scale;
|
||||
```
|
||||
- 将 JPEG 导出质量从 `0.9` 降到 `0.6`。
|
||||
- 这样单张图片体积可从 500KB 降至 30KB~80KB,有效避免 LocalStorage 超限。
|
||||
|
||||
2. **增加存储错误可见性**:
|
||||
- 在 `storage.ts` 的 `set` 和 `setSession` 中,将静默 `catch` 改为输出 `console.error`:
|
||||
```typescript
|
||||
} catch (e) {
|
||||
console.error('Storage save failed (possibly quota exceeded):', e);
|
||||
}
|
||||
```
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 任何将 Base64 图片持久化到 `localStorage` 的场景,都必须**预估数据体积**并对图片进行适当的分辨率/质量压缩。
|
||||
- 存储层的异常捕获**绝不应静默吞掉**,至少要输出日志,必要时还应弹出用户提示。
|
||||
- 对于需要存储大量图片的医疗/图文报告系统,应将 `localStorage` 逐步迁移到 `IndexedDB`,从根本上解除 5MB 容量瓶颈。
|
||||
- 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。
|
||||
34
过往经验/需求分析-2026-04-16-20-24-11.md
Normal file
34
过往经验/需求分析-2026-04-16-20-24-11.md
Normal file
@@ -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` 遗漏点补齐 |
|
||||
| 其他文件 | 无 | 不涉及修改 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。问题现象和分析已明确。
|
||||
26
过往经验/需求分析-2026-04-16-20-33-12.md
Normal file
26
过往经验/需求分析-2026-04-16-20-33-12.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 需求分析 — 2026-04-16-20-33-12
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
`autoCaptureFrames` 中自动插入关键帧到 placeholder 的逻辑使用了 `await new Promise(setTimeout(...))`,这会**阻塞 `for` 循环**,导致必须等待插入延迟结束后才会开始摘取下一帧。期望将其改为**异步非阻塞**,使关键帧摘取全速运行,插入操作在延迟后独立执行,两者互不影响。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
- 移除 `autoCaptureFrames` 中自动插入逻辑的 `await` 阻塞;
|
||||
- 使用 `setTimeout` 将插入操作推入事件队列异步执行;
|
||||
- 实现延迟叠加(顺序插入),避免多张图片在同一时刻同时插入。
|
||||
|
||||
### 非功能点
|
||||
- 保持现有 `flushSync` 实时显示关键帧的效果;
|
||||
- 不破坏现有的 `contentRef` 同步和草稿保存机制。
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 中 | 仅修改 `autoCaptureFrames` 中的自动插入逻辑 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。修改方向明确。
|
||||
29
过往经验/需求分析-2026-04-16-20-46-50.md
Normal file
29
过往经验/需求分析-2026-04-16-20-46-50.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 需求分析 — 2026-04-16-20-46-50
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
在 `/report-editor` 页面操作后,切换到 `/report-manage` 再返回 `/report-editor` 时:
|
||||
- 报告编辑器内容(`class="editor-content-wrapper print-wrapper"`)已能正常保留 ✅;
|
||||
- 视频列表也能正常保留 ✅;
|
||||
- **但视频分析中的自动摘取关键帧和手动截图全部丢失** ❌。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
- 定位关键帧在路由切换后丢失的根因;
|
||||
- 给出可行的修改方向,确保关键帧数据能够持久化并恢复。
|
||||
|
||||
### 非功能点
|
||||
- 保持现有 UI 和交互不变;
|
||||
- 尽量减少对存储架构的侵入。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 中 | 抽帧时的 Canvas 尺寸/质量调整 |
|
||||
| `src/utils/storage.ts` | 低 | 增加超限日志或错误提示 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。根因已高度明确,等待用户确认修改方向。
|
||||
Reference in New Issue
Block a user