# 经验记录

---

## 记录 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 在延迟期间已被用户修改。


---

## 记录 12：5 项交互与默认值优化（占位符尺寸、签名状态、素材预加载）

**A. 具体问题**
用户提出 5 个 UI/UX 改进需求：
1. 插入图片占位符时两次 `prompt` 弹窗合并为一次，用英文逗号分隔宽高；
2. 占位符未指定尺寸时默认显示为 `200×200px`，且样式直接使用 `width`/`height` 而非 `max-width`/`max-height`；
3. 系统重置后的默认设置中增加 `autoInsertFrames: true`、`autoInsertDelay: 1`、`autoInsertFrameIndices: [0,1,2,3,4,5]`；
4. 用户管理表格在「部门」与「状态」之间新增「签名状态」列，根据 `user.signature` 显示「已上传」/「未上传」；
5. 修复系统重置后 `ReportEditor` 的素材库为空的问题，将 logo 预加载逻辑从 `TemplateManage.tsx` 前置到 `Login.tsx` 的 `initData()` 中。

**B. 产生问题原因**
1. `insertImage()` 在两个编辑器（`TemplateManage`、`ReportEditor`）中均使用两次独立的 `prompt()`，操作冗余且中断感强。
2. 旧占位符样式使用 `max-width`/`max-height`，当内容区域大于占位符时，边框和背景不会收缩到指定尺寸，视觉尺寸不可控；且未指定时的 `padding:8px 16px` 导致占位符尺寸随文字变化，不统一。
3. `Login.tsx` 初始化 `systemSettings` 时遗漏了自动帧插入相关的 3 个字段，导致新系统首次进入 `/system-settings` 时相关开关为空。
4. `UserManage.tsx` 表格缺少签名可视化列，管理员无法一眼辨别哪些医生已上传电子签名。
5. `imageAssets` 的预加载仅在 `TemplateManage.tsx` 的 `useEffect` 中执行。若用户首次登录后直接进入 `ReportEditor`，素材库为空，图片选择器无法使用系统默认 logo。

**C. 解决问题方案**
1. **合并 prompt**：
   ```ts
   const input = prompt('请输入占位符的最大宽度和高度(px)，用英文逗号分隔（如: 100,50）。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
   const parts = input.split(',').map(s => s.trim());
   ```
   按逗号分割，第一部分为宽度，第二部分为高度。留空或单侧留空时，另一侧自动回退到 `200`。
2. **固定尺寸样式**：
   - 移除 `max-width`/`max-height`，改用 `width:${width}px;` / `height:${height}px;`。
   - 默认值逻辑：`!widthStr && !heightStr` → `200×200`；`widthStr && !heightStr` → 宽自定义、高 `200`；`!widthStr && heightStr` → 宽 `200`、高自定义。
3. **默认设置补全**：在 `Login.tsx` 的 `defaultSettings` 中显式加入：
   ```ts
   autoInsertFrames: true,
   autoInsertDelay: 1,
   autoInsertFrameIndices: [0, 1, 2, 3, 4, 5]
   ```
4. **签名状态列**：在 `UserManage.tsx` 表格的 `<th>` 和 `<td>` 中，于「部门」之后、「状态」之前插入：
   ```tsx
   <span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${
     user.signature ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-500'
   }`}>
     {user.signature ? '已上传' : '未上传'}
   </span>
   ```
5. **素材预加载前置**：将 `fetch('/logo_square.png') → FileReader → storage.set('imageAssets', [...])` 的逻辑从 `TemplateManage.tsx` 迁移到 `Login.tsx` 的 `initData()` 中，并增加 `savedAssets.length === 0` 的判空保护，避免覆盖用户后续上传的素材。

**D. 后续如何避免问题**
- 对于成对的数值输入（如宽高、行列），优先考虑单输入框 + 分隔符，减少弹窗次数；同时做好格式解析和容错（空值、单侧空值、非数字）。
- 使用 `width`/`height` 代替 `max-width`/`max-height` 能确保占位符尺寸严格可控，避免 `inline-flex` 内容撑大容器。
- 任何需要在多个页面共享的初始化数据（如素材库、默认配置），应放在全局初始化入口（如登录页的 `initData`），而不是分散在各个页面的 `useEffect` 中。
- 表格字段变更时，注意保持 `<thead>` 与 `<tbody>` 的列顺序严格一致，避免列错位。

---

## 记录 13：6 项交互优化（placeholder 虚线框、删除按钮、签名尺寸、多选重构）

**A. 具体问题**
用户提出 6 个 UI/UX 改进需求：
1. 图片插入占位符后虚线框残留——内联 `border:1px dashed #cbd5e1` 优先级高于 `.has-image` CSS class；
2. `insertImage` 生成的 placeholder 中 `overflow:hidden` 裁切了绝对定位的删除按钮（`×`）；
3. 占位符尺寸输入从逗号分隔改为星号（`*`）分隔，格式错误时提示重新输入；
4. 默认模板中「手术者签名」占位符固定为 `200×40px`；
5. 删除「手术者签名确认」字段及相关的弱阻断确认弹窗；
6. 多选组件从 tag 形态重构为纯文本拼接形态，支持多种标点符号拆分并自动保存新选项。

**B. 产生问题原因**
1. `fillPlaceholderSrc` 仅添加了 `has-image` class，但内联 `style="border:..."` 的优先级永远高于外部 CSS，导致虚线框无法消除。
2. `insertImage` 的 `styleStr` 中硬编码了 `overflow:hidden;`，而删除按钮使用 `position:absolute; top:-8px; right:-8px` 之类的定位，必然被父级裁切。
3. 英文逗号分隔容易与用户输入的千位分隔符或中文逗号混淆。
4. 默认模板中签名占位符使用 `min-width:80px;min-height:24px`，尺寸过小且不一致。
5. `isSigned` 字段与签名图片是两个独立的状态，造成医生需要多点一次确认，流程冗余。
6. 原多选使用 tag 胶囊形式，每个 tag 带背景色和删除按钮，占用空间大，且无法直接复制粘贴整段文本。

**C. 解决问题方案**
1. **清除内联样式**：在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `fillPlaceholderSrc` 中增加：
   ```ts
   placeholder.style.border = 'none';
   placeholder.style.background = 'transparent';
   ```
   同时统一 `defaultContent.ts` 中所有 8 个 placeholder 为 `<span style="display:inline-flex;...">` 格式，表格中的 6 个也统一使用 `width:100%;height:150px;`。
2. **移除 overflow:hidden**：从两个 `insertImage` 的 `styleStr` 中删除 `overflow:hidden;`，保留在 `placeholder-text` 子元素上（文字截断仍可用）。
3. **星号分隔 + 校验循环**：
   ```ts
   while (true) {
     const input = prompt('...用 * 分隔...', '');
     if (input === null) return;
     const trimmed = input.trim();
     if (trimmed === '') break;
     const parts = trimmed.split('*').map(s => s.trim());
     if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
       width = parseInt(parts[0]); height = parseInt(parts[1]); break;
     }
     alert('格式错误...');
   }
   ```
4. **签名占位符尺寸**：`defaultContent.ts` 中改为 `width:200px;height:40px;`。
5. **移除 `isSigned`**：
   - `types.ts` 的 `DEFAULT_FORM_FIELDS` 中删除；
   - `ReportEditor.tsx` 的初始 `reportData` 中删除；
   - `saveReport` 的完成确认逻辑中删除 `isSigned` 判断；
   - smart field 同步逻辑中删除 `isSigned` 判断，只要有 `signatureData` 就直接显示签名图。
6. **多选重构为文本拼接**：
   - `displayText = currentValues.join(', ')`；
   - input 使用 `value={displayText}` 受控组件；
   - `onChange` 实时解析并更新 `reportData`：`parseMultiInput(text)` 用 `/[,，;；、]/` 正则拆分、去重；
   - `onBlur` / `Enter` 时调用 `handleMultiCommit`，将拆分出的新选项保存到 `multiSelectOptions` 和 `formFieldsConfig`；
   - 下拉选择时追加 `, opt` 到现有文本。

**D. 后续如何避免问题**
- 当使用内联样式设置边框/背景时，如需在特定状态下移除，**必须在内联层面重置**（`style.border = 'none'`），不能仅依赖 CSS class 覆盖。
- `overflow:hidden` 与绝对定位子元素互斥，若需要裁切文字但保留溢出按钮，应将 `overflow:hidden` 限制在文字子元素上，而非父容器。
- 用户输入的格式校验应使用 `while` 循环 + `alert` 重试，避免静默容错导致不可预期的行为。
- 删除字段时务必全局搜索（`grep -r 'isSigned'`），确保初始化状态、表单验证、模板绑定等所有引用点都被清理。
- 将「标签胶囊」改为「纯文本拼接」时，注意保持 `reportData` 的数据结构仍为数组，UI 层只做 `join/split` 转换。

---

## 记录 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 容量瓶颈。
- 在开发测试阶段，应使用高分辨率视频和大批量关键帧进行压力测试，提前暴露存储容量问题。


---

## 记录 14：5 项交互修复（虚线框恢复、prompt 文案、删除按钮、多选输入、label 提示）

**A. 具体问题**
用户提出 5 个修复需求：
1. 删除 `image-placeholder` 中的图片后，虚线框消失——`fillPlaceholderSrc` 中设置了 `border='none'`，但删除图片的代码没有恢复；
2. `ReportEditor.tsx` 的 `insertImage` prompt 文案仍显示旧版 "用英文逗号分隔"，未同步修改；
3. 新生成的 `image-placeholder` 右上角红色 `×` 显示不完全——`ReportEditor.tsx` 的 `insertImage` 中 `overflow:hidden` 未移除；
4. 多选框无法输入 `,`、`;`、`，`、`、` 等分隔符——`onChange` 实时调用 `parseMultiInput` + `filter(Boolean)`，末尾的分隔符被瞬间吃掉；
5. 多选框 label 缺少 "（可多选）" 提示。

**B. 产生问题原因**
1. 上批次修改时，`ReportEditor.tsx` 的 `insertImage` 替换未成功匹配（旧字符串与文件实际内容有微小差异），导致该函数保留了旧代码。删除图片逻辑同样缺少 border/background 恢复。
2. `overflow:hidden` 仅在新版 `TemplateManage.tsx` 中被移除，`ReportEditor.tsx` 中仍保留。
3. 多选框使用受控 `value={displayText}` + `onChange={handleMultiChange}`，每次输入都会触发 `split(/[,，;；、]/)` 和 `filter(Boolean)`。当用户输入一个逗号时，split 产生空字符串，filter 将其过滤，输入框值立即回退到无逗号状态。
4. label 渲染时直接使用 `{field.label}`，未追加多选提示。

**C. 解决问题方案**
1. **恢复虚线框**：在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 的删除图片分支中，增加：
   ```ts
   placeholder.style.border = '1px dashed #cbd5e1';
   placeholder.style.background = '#f8fafc';
   ```
2. **修正 prompt 文案**：将 `ReportEditor.tsx` 的 `insertImage` 重写为与 `TemplateManage.tsx` 一致的新版逻辑（`*` 分隔 + while 循环校验）。
3. **移除 overflow:hidden**：从 `ReportEditor.tsx` 的 `styleStr` 中删除 `overflow:hidden;`。
4. **多选输入解耦**：引入本地状态 `multiInputText: Record<string, string>`，
   - `onChange` 仅更新 `multiInputText`，不触发拆分；
   - `onBlur` 和 `Enter` 时才调用 `handleMultiCommit` 执行 `split` + `filter(Boolean)` + 保存新选项；
   - 输入框 `value` 优先读取 `multiInputText[field.key]`，无本地缓存时回退到 `displayText`。
5. **label 追加提示**：`{field.label}（可多选）`。

**D. 后续如何避免问题**
- 同类型函数在多个文件中存在时，务必逐个文件 grep 确认修改结果，不能假设一次替换就能覆盖所有实例。
- 任何 "实时解析输入" 的逻辑都必须警惕 `filter(Boolean)` 对空字符串的过滤效应——如果允许用户输入分隔符，应使用独立状态缓存原始输入，仅在确认时（blur/enter）执行解析。
- `StrReplaceFile` 的批量替换若返回 "Applied N edit(s) with M total replacement(s)" 且 M < N，应立即检查未匹配的文件，避免遗漏。



---

## 记录 15：时间/日期字段格式配置与撰写时间动态字段

**A. 具体问题**
用户提出 2 个需求：
1. TemplateManage 字段管理中，时间/日期字段增加配置：date 可选 `YYYY-MM-DD` / `YYYY年MM月DD日` 显示格式；time 可选 24h / 12h 显示格式；两者均可选「当前时间」或「手动选择」作为默认值策略。
2. 默认模板底部写死的「年 月 日」改为动态「撰写时间」智能字段，自动取当前日期。

**B. 产生问题原因**
1. `FormField` 数据结构缺少格式和默认值配置字段。
2. `ReportEditor` 中 time 字段的表单渲染仅支持 `startTime/endTime` 且固定为 24 小时制；smart field 同步时直接显示原始值，不做任何格式转换。
3. 模板底部「年 月 日」是纯静态 HTML 文本，没有数据绑定能力。

**C. 解决问题方案**
1. **扩展数据结构**：`FormField` 增加 `timeFormat?: string` 和 `timeDefault?: 'current' | 'specific'`。现有字段补充默认值（`surgeryDate` → `YYYY-MM-DD`+`specific`，`startTime/endTime` → `24h`+`specific`）；新增系统字段 `reportDate`（`YYYY年MM月DD日`+`current`）。
2. **TemplateManage UI 增强**：
   - 新增字段表单：category 为「时间」时显示「默认值」select（手动选择/当前时间）和「显示格式」select（date 提供两种日期格式，time 提供 24h/12h）。
   - 字段编辑面板：点击已有时间字段进入编辑模式时，可修改上述两项配置。
3. **ReportEditor 自动填充**：新增 `useEffect` 监听 `formFields`，对 `timeDefault === 'current'` 且值为空的字段，自动填充系统当前日期/时间。
4. **ReportEditor 表单渲染重构**：
   - `startTime/endTime`：根据 `timeFormat` 选择 hour select 的选项范围（24h: 00-23，12h: 01-12），12h 时额外增加 AM/PM select。存储仍保持 24h（`startHour/startMinute`），转换函数 `to24h`/`from24h` 处理 12h↔24h。
   - 通用 time 字段（非 startTime/endTime）：新增 hour+minute select 渲染，值统一存储为 `HH:MM` 字符串。
5. **smart field 同步格式化**：同步 useEffect 中，根据字段定义调用 `formatDateDisplay`/`formatTimeDisplay`，将原始值转换为配置格式后写入编辑器。
6. **编辑器反向编辑解析**：`handleEditorInput` 中，当用户直接在编辑器内修改 date/time smart field 时，通过正则解析格式化文本（如 `2026年04月17日` → `2026-04-17`、`02:30 下午` → `14:30`），转回原始值后存入 `reportData`。
7. **默认模板更新**：`defaultContent.ts` 底部静态「年 月 日」替换为 `${smartField('reportDate')}`。

**D. 后续如何避免问题**
- 当为字段增加新的配置属性时，务必在 `DEFAULT_FORM_FIELDS` 中为所有已有字段提供合理的默认值，保证向后兼容。
- 显示格式与存储格式分离时，必须同时实现「正向格式化」（存储→显示）和「反向解析」（显示→存储），否则用户在编辑器中直接编辑格式化后的值会导致数据格式混乱。
- 12h/24h 转换要覆盖所有边界情况：12AM→00、12PM→12、1PM→13，建议用独立纯函数（`to24h`/`from24h`）集中处理，避免在 JSX 中内联复杂计算。
- 自动填充当前时间必须增加「仅当值为空时触发」的保护，防止编辑已有报告时覆盖用户数据。


---

## 记录 16：时间字段增强——自定义格式、固定时间默认值、系统锁定标签

**A. 具体问题**
用户提出 4 个改进需求：
1. 默认模板底部「撰写时间」文字前缀与 smartField 占位符重复，需删除前缀仅保留占位符；
2. 多选类和时间类字段在 TemplateManage 字段管理中仍可修改名称，应锁定为系统字段；
3. 「手动选择」文案歧义，应改为「固定时间」；
4. 时间格式应从固定下拉选项改为支持自定义格式输入（类似单选新增选项策略），并支持为「固定时间」设置默认值。

**B. 产生问题原因**
1. `defaultContent.ts` 中底部 HTML 写死了 `撰写时间：${smartField('reportDate')}`，导致编辑器中显示重复文字。
2. `DEFAULT_FORM_FIELDS` 中 `surgeryDate`、`startTime`、`endTime`、`surgeon` 等字段的 `isSystemLocked` 为 `false`，字段库允许修改 label。
3. 早期实现时默认将时间默认值策略命名为「手动选择」，语义不够精确。
4. 日期/时间格式仅通过固定 `<select>` 提供预设选项（如 `YYYY-MM-DD`、`24h`），无法覆盖用户自定义需求（如 `YYYY/MM/DD`、`hh:mm A` 等）。
5. 当默认值策略为「固定时间」时，系统无法自动填充用户指定的固定值到报告表单中。

**C. 解决问题方案**
1. **删除前缀**：`defaultContent.ts` 中将底部 HTML 从 `撰写时间：${smartField('reportDate')}` 改为仅 `${smartField('reportDate')}`。
2. **系统锁定**：`types.ts` 中 `DEFAULT_FORM_FIELDS` 的 `surgeryDate`、`startTime`、`endTime`、`reportDate`、`surgeon`、`assistant`、`anesthesiologist` 全部改为 `isSystemLocked: true`。
3. **文案修改**：`TemplateManage.tsx` 中所有「手动选择」改为「固定时间」。
4. **自定义格式输入**：
   - `types.ts` 的 `FormField` 增加 `fixedTimeValue?: string`。
   - `TemplateManage.tsx` 的时间格式 UI 改为「下拉 + 自定义输入」双模式：
     - `formatInputMode: 'select' | 'custom'`，默认 `select`。
     - 选择「自定义」时显示 `<input>`，用户可自由输入格式字符串；回车后将输入值加入候选列表并设为当前值。
     - 预设候选包含常用格式：`YYYY-MM-DD`、`YYYY年MM月DD日`、`YYYY/MM/DD`、`24h`、`12h`、`hh:mm A`、`HH:mm`。
   - 通用化显示函数：
     ```ts
     const formatDateDisplay = (isoDate: string, fmt?: string): string => {
       if (!isoDate || !fmt) return isoDate || '';
       const [y, m, d] = isoDate.split('-');
       return fmt.replace(/YYYY/g, y || '').replace(/MM/g, m || '').replace(/DD/g, d || '');
     };
     const formatTimeDisplay = (timeStr: string, fmt?: string): string => {
       if (!timeStr || !fmt) return timeStr || '';
       const [h24str, mstr] = timeStr.split(':');
       const h24 = parseInt(h24str) || 0;
       const isPM = h24 >= 12;
       let h12 = h24 % 12; if (h12 === 0) h12 = 12;
       return fmt.replace(/HH/g, String(h24).padStart(2, '0'))
         .replace(/mm/g, mstr || '00')
         .replace(/hh/g, String(h12).padStart(2, '0'))
         .replace(/A/g, isPM ? '下午' : '上午');
     };
     ```
5. **通用化反向解析**：新增 `parseDateFromFormat` / `parseTimeFromFormat`，从格式化文本中通过数字正则提取原始值，确保用户在编辑器中直接编辑格式化后的 smart field 后能正确回存。
6. **固定时间默认值自动填充**：`ReportEditor.tsx` 的自动填充 `useEffect` 中增加 `timeDefault === 'specific'` 分支，若字段配置了 `fixedTimeValue` 且当前值为空，则自动填入固定值。

**D. 后续如何避免问题**
- 自定义格式输入必须同时提供「正向格式化」和「反向解析」函数，否则编辑器双向同步会断裂。
- 使用占位符替换（如 `fmt.replace(/YYYY/g, y)`）实现通用格式化时，要确保所有可能的 token 都覆盖到，且替换顺序不会相互干扰。
- 当某个字段被标记为 `isSystemLocked: true` 后，需在 UI 层面同时禁用 label 输入框，否则用户会困惑「为何修改无效」。
- 时间/日期字段的默认值策略文案应直接体现业务含义（如「固定时间」「当前时间」），避免使用技术词汇（如「手动选择」）。
- 对于 `startTime`/`endTime` 这类拆分存储（`startHour`+`startMinute`）的遗留字段，在通用化处理时需保留特殊分支，避免破坏现有数据结构。

---

## 记录 17：时间字段联动修复——默认格式、固定时间自动填充、12/24h 动态切换

**A. 具体问题**
用户发现 3 个时间字段配置与报告编辑器的联动断层：
1. 模板管理中新建日期字段时默认格式为 `YYYY-MM-DD`，缺少中文格式 `YYYY年MM月DD日`；新建时间字段时默认格式为不可解析的 `'24h'`。
2. 在模板管理中将时间字段设为「固定时间」并填写固定值后，进入报告编辑器新建报告时，该固定值未自动填充到表单中。
3. 在模板管理中将 `startTime` 格式改为 `hh:mm A`（12小时制），报告编辑器中的手术开始时间表单仍显示为 24 小时制下拉框，未联动切换。

**B. 产生问题原因**
1. **默认格式错误**：`TemplateManage.tsx` 中 `newFieldForm.type` 的 `onChange` 将时间字段默认值硬编码为 `'24h'`，而实际通用格式化函数 `formatTimeDisplay` 使用的是 `HH`、`hh`、`mm`、`A` 等 token， `'24h'` 无法被正确解析。
2. **固定时间未注入**：`ReportEditor.tsx` 初始 `reportData` 和切换模板时的 `nextReportData` 中，`surgeryDate` 被强制赋值为 `new Date().toISOString().split('T')[0]`，导致后续「仅当值为空时才填充固定时间」的判断被跳过（因为已有值了）。切换模板时也未遍历 `formFields` 读取字段的 `timeDefault`/`fixedTimeValue` 配置来注入默认值。
3. **12h 判断写死**：`ReportEditor.tsx` 中 `const is12h = field.timeFormat === '12h';` 仅匹配精确的 `'12h'` 字符串。当用户在模板管理中选择了 `hh:mm A` 或自定义了其他包含 `hh`/`A` 的格式时，判断失败，表单始终渲染为 24 小时制。

**C. 解决问题方案**
1. **修正默认格式**：
   - `TemplateManage.tsx` 中新建字段的默认格式改为：
     ```ts
     setNewFieldTimeFormat(t === 'date' ? 'YYYY年MM月DD日' : 'HH:mm');
     ```
   - 重置表单时的默认值同步修正。
2. **注入固定时间默认值**：
   - `ReportEditor.tsx` 初始 `reportData` 中 `surgeryDate` 从 `new Date()` 改为空字符串 `''`。
   - 切换模板的 `useEffect` 中，在构建 `nextReportData` 后增加遍历 `formFields` 的逻辑：
     ```ts
     formFields.forEach(field => {
       if (field.category === '时间') {
         if (field.timeDefault === 'specific' && field.fixedTimeValue) {
           // 按 field.type 和 field.key 注入固定值
         } else if (field.timeDefault === 'current') {
           // 注入当前系统时间
         }
       }
     });
     if (!nextReportData.surgeryDate) {
       nextReportData.surgeryDate = new Date().toISOString().split('T')[0];
     }
     ```
3. **通用化 12h 判断**：
   - `ReportEditor.tsx` 中：
     ```ts
     const is12h = field.timeFormat ? (field.timeFormat.includes('hh') || field.timeFormat.includes('A')) : false;
     ```
   - 这样无论格式是 `12h`、`hh:mm A`、`hh:mm` 还是用户自定义的 `hh时mm分 A`，只要包含 `hh` 或 `A` 就自动切换为 12 小时制表单。

**D. 后续如何避免问题**
- 时间/日期格式的默认值必须与通用格式化函数的 token 体系保持一致，不能使用简写别名（如 `'24h'`、`'12h'`）作为存储值，除非格式化函数也能识别这些别名。
- 当字段配置了「固定默认值」或「自动填充当前值」时，必须在所有「创建新数据」的入口（初始 state、切换模板、重置表单等）中显式遍历字段配置并注入，不能依赖单个 `useEffect` 来兜底——因为 `useEffect` 的触发条件可能与数据创建时机不一致。
- 对于「格式→UI 形态」的联动判断，应使用**包含性判断**（`includes`）而非**精确匹配**，以兼容用户自定义格式。如果判断逻辑较为复杂，建议抽离为独立工具函数（如 `is12HourFormat(fmt: string): boolean`）。
- 当某个字段在初始化时被赋予了「看似合理的默认值」（如 `surgeryDate: new Date()`），必须评估这是否会拦截后续基于字段配置的自动填充逻辑。若会拦截，应改为空值并在最后做兜底赋值。
