# 经验记录

> 本文档为项目统一知识库，记录开发过程中遇到的关键问题及解决方案。每次执行修改前必须阅读，防止重复踩坑。
> 记录格式：A. 具体问题 → B. 产生问题原因 → C. 解决问题方案 → D. 后续如何避免问题

---

## 记录 1：report-editor 新建报告时显示空白模板

**A. 具体问题**
超级管理员进入 `/report-editor`（新建报告）时，编辑区域为纯白色空白，顶部模板选择器显示"无"，但 system-settings 中已配置了默认模板。

**B. 产生问题原因**
1. `ReportEditor.tsx` 在组件卸载时会自动将当前编辑器内容保存为草稿。即使用户未输入任何内容，保存的 `content` 也是空字符串 `""`。
2. 初始化 effect 中判断草稿是否有效的条件仅使用了 `typeof draft.content === 'string'`，空字符串满足该条件，导致编辑器被填充为空白 HTML，并将 `contentLoadedRef.current` 设为 `true`。
3. 由于 `contentLoadedRef.current` 已被置为 `true`，后续加载 `settings.defaultTemplate` 的默认模板分支被完全跳过。

**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)')` 查找第一个空置占位符。
3. 在关键帧卡片底部新增 "插入" 按钮，使用 `opacity-0 group-hover:opacity-100 transition-opacity`，并通过 `e.stopPropagation()` 避免触发卡片的视频跳转 `onClick`。

**D. 后续如何避免问题**
- 当同一交互效果需要支持多种触发方式时，应将核心逻辑抽离为独立函数，避免重复代码。
- 在可点击子元素上务必注意事件冒泡控制，防止触发父级不必要的副作用。

---

## 记录 3：路由切换后视频分析图片丢失

**A. 具体问题**
在 `/report-editor` 中上传视频、自动摘取关键帧后，切换到 `/report-manage` 再返回 `/report-editor`，右侧「视频分析」面板中的所有截图和关键帧全部消失。

**B. 产生问题原因**
1. `ReportEditor.tsx` 在组件卸载时通过 `stateRef.current` 保存草稿到 `localStorage`。
2. 初始化 `useEffect` 从 draft 恢复数据时，仅通过 `setState` 更新了 React state，但 **没有同步更新 `stateRef.current`**。
3. 离开页面时，`stateRef.current` 仍保存着初始值（空数组），导致 `saveDraftToStorage()` 用空数组覆盖了 localStorage 中的 draft。

**C. 解决问题方案**
在 `ReportEditor.tsx` 的所有数据恢复入口中，恢复 `reportData`、`videos`、`capturedFrames` 后立即同步赋值给 `stateRef.current`。

**D. 后续如何避免问题**
- 当使用 `useRef` 作为「自动保存」的数据快照时，**任何从持久化存储恢复数据到 React state 的操作，必须同步更新对应的 ref**。
- 在涉及草稿/自动保存的功能中，应定期审查所有数据恢复路径，确保 ref 与 state 的一致性。

---

## 记录 4：路由切换后报告内容、基本信息、视频分析全部丢失 + 自动帧插入 UI 延迟刷新

**A. 具体问题**
1. 在 `/report-editor` 中编辑报告后，切换到 `/report-manage` 再返回，**报告内容变空、基本信息清空、视频分析数据全部丢失**。
2. 开启「自动帧插入」后，自动关键帧摘取过程中右侧关键帧列表和 placeholder 中的图片**不会逐张实时更新**。

**B. 产生问题原因**
1. **数据丢失**：在初始化 `useEffect` 中，将 `stateRef.current` 的同步赋值放在了 `if (editorRef.current && draft.content.trim().length > 0)` 条件块内部。当 `editorRef` 尚未挂载或 `draft.content` 为空时，`stateRef.current` 就得不到同步。
2. **UI 延迟**：`autoCaptureFrames` 是 async 函数，内部循环中连续调用 `setCapturedFrames`。React 18 的自动批处理机制在异步函数中会合并状态更新，DOM 重渲染被推迟到整个循环结束后。

**C. 解决问题方案**
1. 将 `stateRef.current` 的同步赋值**移到 `editorRef.current/content` 判断条件的外部**。
2. 在 `autoCaptureFrames` 的 for 循环中，将 `setCapturedFrames` 包裹在 `flushSync(() => { ... })` 中，强制每一帧被摘取后立即触发 DOM 更新。

**D. 后续如何避免问题**
- ref 的同步赋值绝对不能依赖于任何与 UI 渲染相关的条件判断。
- 在异步函数中需要让用户看到实时状态更新时，应使用 `flushSync` 强制同步渲染。

---

## 记录 5：路由切换后所有内容仍然丢失——彻底重构自动保存机制

**A. 具体问题**
在 `/report-editor` 中编辑报告后，切换到 `/report-manage` 再返回，报告编辑器内容、基本信息、视频列表、关键帧截图**全部丢失**。

**B. 产生问题原因**
1. 自动保存机制过度依赖 `stateRef` 和 `contentRef` 作为"数据快照"。
2. **React 18 `StrictMode`** 在开发/预览环境下会执行"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时，`stateRef.current` 仍然是组件创建时的初始空值。
3. 组件卸载（cleanup）时调用保存，用这个空值**覆盖了 localStorage 中已有的正确 draft**。

**C. 解决问题方案**
1. **彻底重构 `saveDraftToStorage`**：不再读取 `contentRef.current` 和 `stateRef.current`，而是直接从最新的 React state 和 `editorRef.current?.innerHTML` 获取数据。`useCallback` 的 dependency 数组包含所有相关 state，确保闭包永远绑定当前渲染周期的最新 state。
2. 将 `beforeunload` 和 `visibilitychange` 事件处理器直接绑定到 `saveDraftToStorage`，effect 的 dependency 改为 `[saveDraftToStorage]`。

**D. 后续如何避免问题**
- **永远不要将 `useRef` 作为自动保存的唯一数据源**。ref 在 React 18 `StrictMode` 的模拟卸载阶段仍然保持初始值，会导致用空数据覆盖有效持久化数据。
- 自动保存函数应直接从最新的 React state 和 DOM 读取数据，通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。

---

## 记录 6：编辑器内容和关键帧在路由切换后仍然丢失——从 Ref 读取避免闭包陷阱和 DOM 失效

**A. 具体问题**
在 `/report-editor` 中编辑报告后，切换到 `/report-manage` 再返回：报告内容全部丢失；视频分析面板中的自动关键帧和手动截图全部丢失。

**B. 产生问题原因**
1. **闭包陷阱**：`saveDraftToStorage` 直接从 React state 读取，但代码中存在 `setCapturedFrames(nextFrames); saveDraftToStorage();` 的写法。由于 `setState` 是异步的，`saveDraftToStorage` 闭包中读到的 `capturedFrames` 仍然是旧值。
2. **卸载时 DOM 失效**：组件卸载时 React 开始销毁 DOM 树，`editorRef.current` 可能已经变为 `null`，`content: editorRef.current?.innerHTML || ''` 会把空字符串保存到 draft 中。
3. **`contentRef` 更新遗漏**：在 `handleEditorClick` 中删除 placeholder 后，直接调用了 `saveDraftToStorage()`，但没有先更新 `contentRef.current`。

**C. 解决问题方案**
1. **重构 `saveDraftToStorage` 从 Ref 读取**：`content` 优先读取 `contentRef.current`（内存引用，卸载时仍稳定存在）；`reportData`、`videos`、`capturedFrames` 全部从 `stateRef.current` 读取。
2. **补齐 `contentRef` 遗漏**：在 `handleEditorClick` 的 `document.execCommand('delete')` 分支后，增加 `if (editorRef.current) contentRef.current = editorRef.current.innerHTML;`。

**D. 后续如何避免问题**
- 对于需要在异步操作或组件卸载时读取的"最新状态"，**应优先使用 `useRef` 作为稳定的数据快照**，而不是依赖 React state 的闭包。
- 任何直接操作 DOM 修改编辑器内容的代码，都必须**紧跟一行 `contentRef.current = editorRef.current.innerHTML`**。

---

## 记录 7：自动帧插入阻塞关键帧摘取——改为 setTimeout 非阻塞异步插入

**A. 具体问题**
开启「自动帧插入」后，点击「自动关键帧摘取」时，系统不是快速完成所有关键帧的摘取，而是每摘取一张就停下来等待插入延迟，整体过程非常缓慢。

**B. 产生问题原因**
`autoCaptureFrames` 的 `for` 循环内部，自动插入逻辑使用了 `await new Promise<void>(r => setTimeout(...))`，`await` 会暂停整个 `for` 循环的执行。

**C. 解决问题方案**
1. 将 `await new Promise(...)` 替换为 `setTimeout(...)`，把插入操作推入事件队列异步执行。
2. 实现延迟叠加（顺序插入）：通过 `settings.autoInsertFrameIndices.indexOf(i)` 计算当前帧是第几个需要插入的，延迟时间为 `baseDelay * (insertOrderIndex + 1)`。
3. `setTimeout` 回调中实时查询 `.image-placeholder:not(.has-image)`，找到则插入，并同步更新 `contentRef.current` 和调用 `saveDraftToStorage()`。

**D. 后续如何避免问题**
- 在异步循环中，如果某个操作不需要依赖前一步的完成结果，**绝对不要使用 `await` 阻塞主循环**，应改用 `setTimeout` 或 `Promise.all` 实现并行/异步解耦。
- 在 `setTimeout` 等异步回调中操作 DOM 时，应在回调触发时"实时查询"目标元素，而不是在循环中提前捕获元素引用。

---

## 记录 8：关键帧在路由切换后丢失——压缩 Canvas 分辨率并增加存储错误日志

**A. 具体问题**
报告编辑器内容和视频列表在路由切换后能正常保留，但视频分析面板中的自动摘取关键帧和手动截图全部丢失。

**B. 产生问题原因**
1. **LocalStorage 5MB 容量限制**：当前抽帧逻辑使用视频原始分辨率 + JPEG 质量 0.9，对于 1080p/4K 视频，单张 Base64 图片可达 300KB~1MB，十几张关键帧即可超过 5MB。
2. **静默失败**：`storage.ts` 中的 `set` 方法捕获了 `QuotaExceededError` 但没有任何日志，导致用户和开发者都感知不到错误。

**C. 解决问题方案**
1. **压缩关键帧分辨率与质量**：Canvas 等比缩放至最大 800px 宽，JPEG 导出质量从 `0.9` 降到 `0.6`。单张图片体积可从 500KB 降至 30KB~80KB。
2. **增加存储错误可见性**：将静默 `catch` 改为输出 `console.error`。

**D. 后续如何避免问题**
- 任何将 Base64 图片持久化到 `localStorage` 的场景，都必须**预估数据体积**并对图片进行适当的分辨率/质量压缩。
- 存储层的异常捕获**绝不应静默吞掉**，至少要输出日志，必要时还应弹出用户提示。

---

## 记录 9：contentEditable 中实现标签锁定与输入方格的双向绑定

**A. 具体问题**
需要在富文本编辑器中插入"标签锁定、内容可调"的智能占位控件，使"姓名："等固定文本不会被用户误删，同时方格内的输入能与右侧表单双向联动。

**B. 产生问题原因**
原生 `contentEditable` 区域内所有文本节点对用户都是可编辑的，无法直接保护某一段固定标签不被单独删除或篡改。

**C. 解决问题方案**
采用三层嵌套 HTML 结构：
1. **外层** `<span class="smart-field-wrapper" contenteditable="false">`
2. **标签层** `<span class="field-label">`
3. **输入层** `<span class="field-value" contenteditable="true" data-bind="patientName">`

双向绑定逻辑：富文本 → 表单通过 `handleEditorInput` 中 `e.target.hasAttribute('data-bind')` 判断；表单 → 富文本通过 `useEffect` 监听 `reportData` 变化，仅当 `el.innerText !== newValue` 时才重写 DOM。

**D. 后续如何避免问题**
- 对于需要在富文本中保护的固定文本，优先采用 `contenteditable="false"` 的包装器。
- 在 `State -> DOM` 的同步中务必加入差异判断，避免不必要的 DOM 重写导致输入焦点异常。

---

## 记录 10：智能字段插入间距修复与 Backspace 防误删

**A. 具体问题**
1. 插入智能字段后，字段后方会出现一个可见的空格（由 `&nbsp;` 和多行模板字符串中的换行/缩进空白引起）。
2. 光标位于 `<p>` 行首且后紧跟 `.smart-field-wrapper` 时按 Backspace，WebKit 内核会直接删除整段 `<p>` 而不是仅删除字段节点。

**B. 产生问题原因**
1. `insertSmartField` 的 HTML 字符串使用反引号多行模板，缩进和换行被浏览器解析为额外的文本节点；末尾显式拼接了 `&nbsp;`。
2. `contenteditable="false"` 的 inline 元素处于行边界时，WebKit 的默认编辑行为会将整个包含该元素的块级父节点一并删除。

**C. 解决问题方案**
1. **压缩 HTML 字符串**：将 `insertSmartField` 和 `defaultContent.ts` 的 `smartField` 输出改为单行 HTML，移除所有无意义的换行和缩进，并去掉尾部的 `&nbsp;`。
2. **防止内部折行**：给 `.smart-field-wrapper` 增加 `white-space: nowrap;`。
3. **拦截 Backspace/Delete**：在编辑器上增加 `keydown` 事件监听（capture 阶段）。当光标位于文本节点起始位置且前一个兄弟节点是 `.smart-field-wrapper` 时按 Backspace，主动 `preventDefault()` 并手动移除该字段节点。

**D. 后续如何避免问题**
- 在 `contentEditable` 中使用 `document.execCommand('insertHTML', ...)` 插入 HTML 时，**传入的字符串必须是无多余空白的紧凑单行**。
- 对于 `contenteditable="false"` 的内联控件，若放置在块级边界，务必增加键盘事件拦截。

---

## 记录 11：撤销栈修复、字段删除交互优化与签名字段闭环

**A. 具体问题**
1. 删除智能字段后，浏览器撤销栈（Undo）失效，点击"撤销"按钮无法恢复。
2. 插入字段后，字段框有时会跳到下一行。
3. Backspace 键无法删除字段；Delete 键会误删字段前面的大段文本。

**B. 产生问题原因**
1. 删除字段时使用了 `target.remove()` 直接操作 DOM，绕过了浏览器的原生撤销栈。
2. 插入的 `smart-field-wrapper` 是 `inline-block` 元素，但其后缺少行内锚点文本节点，浏览器容易将其挤到新行。
3. `keydown` 拦截逻辑中 `target.remove()` 同样会误删父级块节点。

**C. 解决问题方案**
1. **撤销栈修复**：将点击红 × 删除和键盘 Backspace/Delete 删除全部改为 `Range.selectNode(target)` + `document.execCommand('delete')`。
2. **防换行**：在 `insertSmartField` 和 `defaultContent.ts` 的 `smartField()` 生成的 HTML 末尾增加 `&#8203;`（零宽空格），作为稳定的行内锚点。
3. **精准键盘删除**：配合 `Range.selectNode` + `execCommand('delete')`，不再直接 `remove()` DOM 节点。

**D. 后续如何避免问题**
- 在 `contentEditable` 中删除元素时，**优先使用 `Range.selectNode` + `execCommand('delete')`** 而非直接 `remove()`，以确保撤销/重做等原生编辑行为正常工作。
- 插入 `inline-block` 或 `inline-flex` 控件时，可在其后追加 `&#8203;` 零宽空格，为浏览器提供稳定的行内文本锚点。

---

## 记录 12：TemplateManage 自定义 Undo/Redo 与插入字段光标定位修复

**A. 具体问题**
1. 删除智能字段后，点击工具栏的"撤销"按钮无法恢复字段，"重做"也失效。
2. 点击右侧字段库按钮插入字段时，字段经常跳到下一行或文档末尾。

**B. 产生问题原因**
1. 即使将删除逻辑改为 `execCommand('delete')`，浏览器原生的 undo stack 在 `contentEditable` 中结合 React 状态更新时仍然非常脆弱，容易被清空。
2. 点击侧边栏按钮会导致编辑器 `blur`，浏览器内部的光标位置（Selection/Range）丢失；再次 `focus()` 后光标被重置，导致 `insertHTML` 插入位置错误。

**C. 解决问题方案**
1. **自定义 Undo/Redo 栈**：引入 `undoStack` 和 `redoStack` 两个 `useRef<string[]>([])`。实现 `pushHistory()`，在执行任何结构性变更前将当前 `editorRef.current.innerHTML` 推入 undo 栈。
2. **阻止焦点流失**：在所有工具栏按钮和字段库插入按钮上增加 `onMouseDown={(e) => e.preventDefault()}`，阻止 mousedown 默认行为导致编辑器失去焦点。
3. **光标位置记忆与恢复**：利用 `savedRangeRef`，实现 `saveSelection()` 和 `restoreSelection()`。在编辑器 `<div>` 上绑定 `onBlur={saveSelection}`、`onMouseUp={saveSelection}`、`onKeyUp={saveSelection}`。

**D. 后续如何避免问题**
- 对于 `contentEditable` 编辑器中的结构性变更，如果原生 undo 不可靠，应尽早实现自定义历史栈（基于 HTML 字符串快照），完全接管撤销/重做逻辑。
- 侧边栏/工具栏按钮与编辑器共存时，**必须**通过 `onMouseDown={e => e.preventDefault()}` 阻止焦点流失。

---

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

**A. 具体问题**
1. 时间/日期字段缺少配置：date 可选显示格式；time 可选 24h / 12h 显示格式；两者均可选「当前时间」或「固定时间」作为默认值策略。
2. 默认模板底部写死的「年 月 日」改为动态「撰写时间」智能字段，自动取当前日期。

**B. 产生问题原因**
1. `FormField` 数据结构缺少格式和默认值配置字段。
2. `ReportEditor` 中 time 字段的表单渲染仅支持 `startTime/endTime` 且固定为 24 小时制；smart field 同步时直接显示原始值，不做任何格式转换。

**C. 解决问题方案**
1. **扩展数据结构**：`FormField` 增加 `timeFormat?: string` 和 `timeDefault?: 'current' | 'specific'`。
2. **ReportEditor 表单渲染重构**：`startTime/endTime` 根据 `timeFormat` 选择 hour select 的选项范围；12h 时额外增加 AM/PM select。
3. **smart field 同步格式化**：同步 useEffect 中，根据字段定义调用 `formatDateDisplay`/`formatTimeDisplay`。
4. **编辑器反向编辑解析**：`handleEditorInput` 中，通过正则解析格式化文本，转回原始值后存入 `reportData`。

**D. 后续如何避免问题**
- 显示格式与存储格式分离时，**必须同时实现「正向格式化」（存储→显示）和「反向解析」（显示→存储）**，否则用户在编辑器中直接编辑格式化后的值会导致数据格式混乱。
- 自动填充当前时间必须增加「仅当值为空时触发」的保护，防止编辑已有报告时覆盖用户数据。

---

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

**A. 具体问题**
1. 新建日期字段时默认格式为 `YYYY-MM-DD`，缺少中文格式；新建时间字段时默认格式为不可解析的 `'24h'`。
2. 时间字段设为「固定时间」后，进入报告编辑器新建报告时，该固定值未自动填充到表单中。
3. `startTime` 格式改为 `hh:mm A`（12小时制），报告编辑器中的表单仍显示为 24 小时制下拉框。

**B. 产生问题原因**
1. **默认格式错误**：`TemplateManage.tsx` 中 `newFieldForm.type` 的 `onChange` 将时间字段默认值硬编码为 `'24h'`，而实际通用格式化函数使用的是 `HH`、`hh`、`mm`、`A` 等 token。
2. **固定时间未注入**：`ReportEditor.tsx` 初始 `reportData` 中 `surgeryDate` 被强制赋值为 `new Date().toISOString().split('T')[0]`，导致后续「仅当值为空时才填充固定时间」的判断被跳过。
3. **12h 判断写死**：`const is12h = field.timeFormat === '12h';` 仅匹配精确的 `'12h'` 字符串。

**C. 解决问题方案**
1. 默认格式改为：`t === 'date' ? 'YYYY年MM月DD日' : 'HH:mm'`。
2. `surgeryDate` 初始值从 `new Date()` 改为空字符串 `''`；切换模板时显式遍历 `formFields` 注入固定值/当前值。
3. 12h 判断改为包含性判断：`field.timeFormat.includes('hh') || field.timeFormat.includes('A')`。

**D. 后续如何避免问题**
- 时间/日期格式的默认值必须与通用格式化函数的 token 体系保持一致，不能使用简写别名（如 `'24h'`、`'12h'`）作为存储值。
- 当字段配置了「固定默认值」或「自动填充当前值」时，必须在所有「创建新数据」的入口中显式遍历字段配置并注入。
- 对于「格式→UI 形态」的联动判断，应使用**包含性判断**（`includes`）而非**精确匹配**。

---

## 记录 15：打印分页边距失效

**A. 具体问题**
`report-editor` / `report-view` 打印多页报告时，第二页及后续页面的上下边距几乎为 0，内容紧贴纸张边缘。

**B. 产生问题原因**
`@page { margin: 0 }` 将物理纸张边距设为 0，`body { padding: 10mm }` 只在整个 HTML 文档的顶部和底部各生效一次。当内容跨页时，浏览器在分页切断处不会保留 `body` 的 padding。

**C. 解决问题方案**
`print.ts` 中：
- `@page { margin: 15mm 10mm; }` 让打印引擎为每一页物理纸张独立分配边距
- `body { padding: 0; }` 清除 body padding
- `.content { width: 100%; }` 让内容自然撑满可用区域

**D. 后续如何避免问题**
- 打印样式的边距控制**必须使用 `@page { margin: ... }` 而非 `body { padding: ... }`**，前者会让打印引擎为每一页物理纸张独立分配边距，后者只在文档首尾生效一次。

---

## 记录 16：表格内 execCommand 插入破坏结构

**A. 具体问题**
在 `template-manage` 编辑器表格中点击"插入图片占位符"后，HTML 结构被破坏——外层 `<span class="image-placeholder">` 丢失，仅剩内部子元素散落为 `<td>` 的直接子元素。

**B. 产生问题原因**
`document.execCommand('insertHTML')` 在 `<td>` 内处理复杂的 `inline-flex` 嵌套 `<span>` 时，WebKit/Blink 会将其自动"拍平"或重新排列。外层 `contenteditable="false"` 的 inline 容器被浏览器移除。

**C. 解决问题方案**
在 `insertImage` 中通过 `window.getSelection().anchorNode` 向上遍历检测是否在 `<td>` / `<th>` 内：
- 若在表格内：不弹出 prompt，使用 `<div>` 块级容器 + `width:100%;height:100%;`
- 若不在表格内：保持现有 `<span>` 行内容器

**D. 后续如何避免问题**
- `document.execCommand('insertHTML')` 对块级元素边界（尤其是 `<td>` 内）的自动修正行为不可控；在表格等复杂容器内插入 HTML 时，应优先使用块级标签（如 `<div>`）作为外层容器。

---

## 记录 17：图片占位符体系重构与双端统一

**A. 具体问题**
1. `template-manage` 的"插入字段"中仍存在"图片"分类（手术者签名、医院Logo）。
2. 插入图片占位符时无法自定义默认宽高，且使用 `<div>` 导致强制换行。
3. 占位符框太小时"插入/点击放置图片"文字显示不全。

**B. 产生问题原因**
1. `DEFAULT_FORM_FIELDS` 仍包含 `surgeonSignature` 和 `hospitalLogo`。
2. 两端编辑器的 `insertImage()` 使用块级 `<div>` 插入，未提供尺寸 prompt。
3. 占位符提示文本固定为长文本，未根据容器宽度做缩写适配。

**C. 解决问题方案**
1. 从 `DEFAULT_FORM_FIELDS` 和 `types.ts` 中移除 `surgeonSignature` 和 `hospitalLogo`；在 `TemplateManage.tsx` 中彻底移除"图片"分类。
2. 改造 `insertImage()`：插入前通过 `prompt` 获取最大宽度/高度（px），生成带 `max-width/max-height` 的 `<span>` 行内占位符。
3. 根据 prompt 输入的宽度决定提示文字：宽度 < 80px 时显示"插入图片"，否则显示"插入/点击放置图片"。

**D. 后续如何避免问题**
- 当从字段体系中彻底移除某一分类时，需要同时清理：`DEFAULT_FORM_FIELDS`、UI 渲染数组、新增表单 options、以及可能残留的分类判断逻辑。
- 在 `contentEditable` 中实现"同行插入"必须使用行内元素（`<span>`）并显式设置 `display:inline-flex` + `vertical-align:middle`。

---

## 记录 18：默认模板中 image-placeholder 缺少 data-mode 导致来源隔离失效

**A. 具体问题**
默认模板 `defaultContent.ts` 中的 8 个 `.image-placeholder` 使用的是旧版 HTML 结构，缺少 `data-mode="frame|manual"` 属性。新建报告加载默认模板后，签名和 Logo 区域可被关键帧拖拽误填充。

**B. 产生问题原因**
此前对「插入图片占位符」进行弹窗改造时，仅在运行时插入逻辑中新增了 `data-mode` 属性，但未同步回刷默认模板 `defaultContent.ts`。

**C. 解决问题方案**
在 `defaultContent.ts` 中对 8 个占位符做最小化修补：
1. 医院 Logo（65×65）和手术者签名（200×40）添加 `data-mode="manual"`。
2. 表格内 6 个术中影像占位符（100%×150）添加 `data-mode="frame"`。
3. 所有占位符的 `width/height/margin/display` 等布局属性绝对保持不变。

**D. 后续如何避免问题**
- 当为 `image-placeholder` 引入新的核心属性（如 `data-mode`、`data-allow-source`）时，**必须同步检索 `defaultContent.ts` 和任何预置模板文件**，确保静态模板中的占位符结构与运行时插入逻辑保持一致。

---

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

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

**B. 产生问题原因**
1. `fillPlaceholderSrc` 设置了 `border='none'`，但删除图片的代码没有恢复。
2. `overflow:hidden` 仅在新版 `TemplateManage.tsx` 中被移除，`ReportEditor.tsx` 中仍保留。
3. 多选框使用受控 `value={displayText}` + `onChange={handleMultiChange}`，每次输入都会触发 `split(/[,，;；、]/)` 和 `filter(Boolean)`。当用户输入一个逗号时，split 产生空字符串，filter 将其过滤，输入框值立即回退。

**C. 解决问题方案**
1. 在删除图片分支中增加：`placeholder.style.border = '1px dashed #cbd5e1'; placeholder.style.background = '#f8fafc';`
2. 将 `ReportEditor.tsx` 的 `insertImage` 重写为与 `TemplateManage.tsx` 一致的新版逻辑（`*` 分隔 + while 循环校验）。
3. 从 `ReportEditor.tsx` 的 `styleStr` 中删除 `overflow:hidden;`。
4. **多选输入解耦**：引入本地状态 `multiInputText: Record<string, string>`，`onChange` 仅更新 `multiInputText`，不触发拆分；`onBlur` 和 `Enter` 时才调用 `handleMultiCommit` 执行拆分。
5. label 追加 `（可多选）` 提示。

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

---

## 记录 20：图片占位符填充后高度自适应

**A. 具体问题**
图片占位符填充后仍保留固定高度（如 200px），导致图片下方出现大片空白。

**B. 产生问题原因**
此前仅将 `height` 改为 `auto`，未同步处理 `width`，也未利用 `max-width`/`max-height` 作为硬限制来实现等比例缩放。

**C. 解决问题方案**
1. **插入时**：为 inline-block 占位符追加 `max-width:${w}px;max-height:${h}px;`。
2. **填充时**：统一执行以下步骤：
   - 读取 `placeholder.style.maxWidth || placeholder.style.width` 和 `placeholder.style.maxHeight || placeholder.style.height` 作为硬限制值 `mw` / `mh`
   - 将 `<img>` 的 style 设为 `max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;`
   - 将占位符外壳设为 `width:auto;height:auto;line-height:normal;max-width:${mw};max-height:${mh};`

**D. 后续如何避免问题**
- `image-placeholder` 的尺寸逻辑涉及「创建时预设」和「填充后自适应」两个阶段，修改时必须同时考虑：创建时是否写入了 `max-width`/`max-height`；填充时是否同步清除了固定宽高并保留了硬限制。

---

## 记录 21：重新部署应用（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. 使用 `Start-Process` 以独立 Windows 进程启动 `npm run preview -- --host`（避免后台任务超时杀死服务）。
4. 通过 `Invoke-WebRequest` 访问 `http://localhost:4173/` 验证服务返回 HTTP 200。

**D. 后续如何避免问题**
- 在无法使用 Docker 的环境中，可将 `npm run build && npm run preview -- --host` 作为标准部署脚本。
- 重新部署前务必先清理旧的同类型进程，避免端口冲突或多版本服务同时运行导致访问混乱。
- **切勿使用 Shell 后台任务（`run_in_background=true`）长时间运行 `npm run preview`**，因为任务超时机制（默认 60s）会强制终止 preview 进程，导致服务中断。


---

## 记录 22：参考信息文件夹导致 tsc 编译失败

**A. 具体问题**
执行 `npm run lint`（`tsc --noEmit`）时，报错 `参考信息/参考-ReportEditor.tsx` 中找不到模块 `'../components/Sidebar'` 等。该文件只是用户提供的参考代码，不应参与编译。

**B. 产生问题原因**
`tsconfig.json` 中没有配置 `exclude` 字段，TypeScript 默认会递归编译项目根目录下所有 `.ts`/`.tsx` 文件，包括非源码的参考文件。

**C. 解决问题方案**
在 `tsconfig.json` 中增加 `exclude` 字段：
```json
"exclude": ["参考信息", "dist", "node_modules"]
```

**D. 后续如何避免问题**
- 任何非源码的参考文件、文档、备份代码必须放在被 `tsconfig.exclude` 或 `.gitignore` 排除的目录中。
- 修改 `tsconfig.json` 后应立即运行 `npm run lint` 验证排除是否生效。

---

## 记录 23：大文件（2200+行）增量修改的定位策略

**A. 具体问题**
`ReportEditor.tsx` 有 2224 行，需要在多处插入代码（imports、state、函数、工具栏 JSX、tab 切换 JSX、AI 面板 JSX、Diff 弹窗 JSX）。直接全文搜索效率低，且容易定位错误。

**B. 产生问题原因**
单文件组件承载了过多功能（编辑器、视频分析、表单、AI 面板），导致任何新增功能都需要在文件的多个离散位置插入代码。

**C. 解决问题方案**
采用 **"Grep 定位 + 精确读取 + StrReplaceFile"** 的三段式策略：
1. 先用 `Grep` 找到目标代码的精确行号
2. 用 `ReadFile` 读取该行号前后 10-20 行，获取精确文本
3. 用 `StrReplaceFile` 进行最小化字符串替换，确保只改目标区域

**D. 后续如何避免问题**
- 对于超过 1500 行的单文件组件，新增功能时应优先使用 `Grep` 定位关键锚点（如 `const [activeTab`、`<div className="flex items-center gap-1 p-3` 等），避免盲目滚动阅读。
- 若需在同一文件的 5 个以上位置插入代码，建议先用 Agent 生成修改草案，再人工审核关键锚点。
- 考虑在未来重构中将超大组件按功能拆分为子组件（如 `ReportEditorToolbar`、`ReportEditorAiPanel`），降低后续修改成本。


---

## 记录 24：数据结构重构时的旧数据迁移策略

**A. 具体问题**
重构 `SystemSettings` 时，将 `apiEndpoint`/`apiKey`/`kimiApiKey`/`kimiApiEndpoint` 四个散装字段替换为 `activeAiProvider` + `aiProviders` 字典结构。如果直接删除旧字段，已配置 API Key 的老用户会丢失配置。

**B. 产生问题原因**
TypeScript 接口变更后，从 `localStorage` 读出的旧数据对象缺少新字段，直接赋值给新类型的 state 会导致类型错误（缺少 `activeAiProvider`、`aiProviders`）或运行时逻辑断裂。

**C. 解决问题方案**
1. 在 `SystemSettings.tsx` 的初始化 `useEffect` 中增加数据迁移逻辑：
   ```ts
   if (!savedSettings.aiProviders) {
     const providers = { ...DEFAULT_AI_PROVIDERS };
     if ((savedSettings as any).kimiApiKey || (savedSettings as any).kimiApiEndpoint) {
       providers.kimi = {
         endpoint: (savedSettings as any).kimiApiApiEndpoint || providers.kimi.endpoint,
         apiKey: (savedSettings as any).kimiApiKey || '',
         modelName: 'kimi-k2-5'
       };
     }
     savedSettings.aiProviders = providers;
     savedSettings.activeAiProvider = 'kimi';
     storage.set('systemSettings', savedSettings);
   }
   ```
2. 使用 `(savedSettings as any)` 临时绕过旧字段的类型检查，避免在 `types.ts` 中保留废弃字段。
3. 迁移后旧字段仍保留在 `localStorage` 中（不主动删除），但代码不再读取。

**D. 后续如何避免问题**
- 任何涉及 `localStorage` 数据结构变更的重构，都必须在初始化入口提供**自动迁移逻辑**，否则用户数据会静默丢失。
- 迁移逻辑应使用 `try/catch` 包裹，防止脏数据导致页面白屏。
- 旧字段可通过 `(obj as any).oldField` 安全访问，无需在类型定义中长期保留废弃字段。

---

## 记录 25：多服务商配置字典的 UI 绑定模式

**A. 具体问题**
SystemSettings 需要支持 4 个服务商（Kimi/DeepSeek/OpenAI/自定义），每个服务商有 3 个配置项（endpoint/apiKey/modelName）。若用 12 个独立 state 或输入框，代码会极其臃肿。

**B. 产生问题原因**
早期设计采用平铺字段（`kimiApiKey`、`deepseekApiKey`...），导致每新增一个服务商就要改 types + UI + 调用逻辑三处。

**C. 解决问题方案**
采用 **"字典 + 动态下标"** 模式：
1. `types.ts` 中统一定义 `Record<string, AiProviderConfig>`
2. UI 中只有一个 `activeAiProvider` select，下方 3 个输入框统一绑定到 `aiProviders[activeAiProvider].xxx`
3. `onChange` 时创建浅拷贝更新：
   ```ts
   const next = { ...settings.aiProviders };
   next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], endpoint: e.target.value };
   setSettings({ ...settings, aiProviders: next });
   ```

**D. 后续如何避免问题**
- 当一组配置具有"同构多实例"特征时（多个服务商、多个环境、多个账号），优先使用 `Record<string, Config>` 而非平铺字段。
- 动态表单的 `onChange` 必须注意不可变更新：先浅拷贝外层字典，再浅拷贝当前项，最后修改目标字段。直接 `settings.aiProviders[k].endpoint = x` 会触发 React 引用比较优化导致不刷新。


---

## 记录 26：API Endpoint 尾部斜杠导致 404

**A. 具体问题**
SystemSettings 中测试连接成功（`/models` 返回 200），但 ReportEditor 中调用 `/chat/completions` 报 404。用户输入的 Base URL 末尾带有 `/`，导致拼接后路径为 `https://api.xxx.com/v1//chat/completions`。

**B. 产生问题原因**
用户从文档复制 Base URL 时，末尾可能带斜杠；代码中直接做字符串拼接 `${apiEndpoint}/chat/completions`，未做净化处理。

**C. 解决问题方案**
在 `handleAIGenerate` 和 `testApi` 中统一对 endpoint 做尾部斜杠移除：
```ts
const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, '');
```

**D. 后续如何避免问题**
- 任何从用户输入拼接 URL 的场景，都必须先对基础路径做 `.replace(/\/+$/, '')` 或 `new URL(path, base)` 标准化处理。
- 测试连通性（`/models`）和实际业务调用（`/chat/completions`）应使用同一套 endpoint 净化逻辑，避免"测试通过、调用失败"的认知落差。

---

## 记录 27：State 未纳入 Ref 导致自动保存遗漏

**A. 具体问题**
AI 撰写面板的 `chatMessages` 在路由切换后全部丢失。因为 `saveDraftToStorage` 从 `stateRef.current` 读取数据快照，而 `chatMessages` 从未被同步到 `stateRef`。

**B. 产生问题原因**
ReportEditor 采用 `useRef` 作为自动保存的数据快照机制（避免 React state 闭包陷阱）。新增 `chatMessages` state 时，只关注了 UI 渲染，遗漏了与 `stateRef` 的同步。

**C. 解决问题方案**
1. `stateRef` 初始化时包含 `chatMessages`
2. `saveDraftToStorage` 保存对象中增加 `chatMessages: stateRef.current.chatMessages`
3. 增加 `useEffect` 监听 `chatMessages` 变化，实时同步到 `stateRef.current.chatMessages`
4. 所有草稿恢复分支（初始化 useEffect 的 2 处 + useLayoutEffect 的 2 处）均增加 `chatMessages` 的恢复和 ref 同步

**D. 后续如何避免问题**
- 在 `ReportEditor.tsx` 中新增任何 `useState` 时，必须同时问自己三个问题：
  1. 这个 state 是否需要持久化到 draft？
  2. 若需要，是否已加入 `stateRef` 初始化？
  3. 若需要，是否已在 `saveDraftToStorage`、所有恢复分支、以及 state→ref 同步 effect 中补齐？
- 建议维护一份 "Draft 持久化字段清单" 注释在 `stateRef` 定义附近，作为新增 state 时的检查单。


---

## 记录 28：chatInput 草稿恢复遗漏 + AI 请求 content 格式条件判断

**A. 具体问题**
1. AI 撰写面板的聊天输入框内容在路由切换后丢失——虽然 `chatMessages` 已修复，但 `chatInput`（用户正在输入但未发送的文本）未纳入 draft 恢复。
2. Kimi 等纯文本模型在没有任何图片时，若将 `content` 以 vision 数组格式发送，会返回 `400 Bad Request`。

**B. 产生问题原因**
1. **遗漏模式**：此前修复 `chatMessages` 持久化时，只关注了已发送消息的恢复，忽略了输入框中未提交的 `chatInput` state。该 state 同样参与了 `stateRef` 同步和 `saveDraftToStorage` 保存，但所有 3 处草稿恢复分支均未恢复它。
2. **模型格式差异**：OpenAI 兼容 API 中，vision 模型支持 `content: [{type:'image_url'...}, {type:'text'...}]`，但纯文本模型（如 Kimi 默认的 `kimi-k2-5`）要求 `content` 必须是 `string`。即使数组中只有 text 元素，也会触发 400。

**C. 解决问题方案**
1. **chatInput 恢复**：
   - 在 `stateRef` 初始化和 `saveDraftToStorage` 中确认 `chatInput` 已存在（此前修改已完成）
   - 在 3 处草稿恢复分支中增加：
     ```ts
     if (typeof draft.chatInput === 'string') setChatInput(draft.chatInput);
     // 以及 stateRef.current 中增加 chatInput: draft.chatInput || ''
     ```
2. **content 条件格式**：
   ```ts
   let messageContent: any;
   if (allImages.length > 0) {
     messageContent = [];
     allImages.forEach(url => messageContent.push({ type: 'image_url', image_url: { url } }));
     messageContent.push({ type: 'text', text: promptText });
   } else {
     messageContent = promptText;
   }
   ```

**D. 后续如何避免问题**
- 新增任何 `useState` 时，除了问自己「是否已加入 stateRef / saveDraftToStorage / state→ref effect」，还必须**逐个审查所有 draft 恢复分支**，确认恢复逻辑完整。
- 调用多模型兼容的 OpenAI 格式 API 时，必须根据「是否有图片附件」动态决定 `content` 的类型（`string` vs `array`），不能无条件发送数组。


---

## 记录 29：Checkbox 在复杂 React 组件树中点击失效 + AI 上下文缺失

**A. 具体问题**
1. AI 面板底部的「允许修改正文」复选框无法点击切换。
2. AI 无法回答编辑器中已有的报告内容（如「气腹压力是多少」），表现得像「瞎子」。

**B. 产生问题原因**
1. **Checkbox 失效**：使用了独立的 `<input id="x">` + `<label htmlFor="x">` 组合。在复杂的 contentEditable 编辑器 + React 重渲染环境中，`id`/`htmlFor` 的绑定可能因事件冒泡、DOM 结构覆盖或 React 的 reconciliation 导致点击事件无法正确路由到 input。
2. **AI 上下文缺失**：`handleAIGenerate` 只向大模型发送了「目标 AI 区域的 HTML 源码」。当该区域为空或信息在其他区域时，大模型收到的上下文只有用户指令，自然无法回答。

**C. 解决问题方案**
1. **Checkbox 修复**：将 `div > input + label` 改为 `label > input + span`，让 label 直接包裹 input，天然扩大点击区域并避免 `id`/`htmlFor` 绑定冲突；`onChange` 中增加 `e.stopPropagation()` 防止事件冒泡被外层拦截。
2. **AI 上下文增强**：
   - 新增 `globalContextText = editorRef.current?.innerText || ''`，将编辑器完整纯文本作为全局背景知识注入 prompt
   - `currentHtml` 增加 `.replace(/&#8203;/g, '').trim()` 过滤零宽字符
   - 重构 prompt 结构：先放「全局参考内容」，再放「目标区域源码」，最后放「医生指令」
   - 同步优化 systemPrompt，明确告知大模型有两个信息源

**D. 后续如何避免问题**
- 在复杂 React 组件（尤其是与 contentEditable 共存）中使用 Checkbox 时，**优先使用 `<label>` 直接包裹 `<input>`** 的写法，避免依赖 `id`/`htmlFor`。
- 向大模型发送局部修改请求时，**必须同时提供全局上下文**，否则 AI 无法基于文档其他部分的信息进行推理和修改。


---

## 记录 30：AI「只聊天不干活」——System Prompt 过度依赖目标区域 + 缺少降级插入

**A. 具体问题**
用户在 report-editor 中输入「请随机填充文本内容」，AI 聊天面板有输出，但编辑器中的 AI 可编辑区域没有任何更新。

**B. 产生问题原因**
1. **System Prompt 条件过于严苛**：`systemPrompt` 的构建条件是 `aiModifyEnabled && targetRegionEl`。由于 `aiTargetRegion` 默认值为 `'none'`，如果用户未在下拉框中明确选中区域，`targetRegionEl` 为 `null`，systemPrompt 降级为纯聊天模式，大模型根本不会返回 `updatedHtml` 字段。
2. **接收逻辑缺少降级**：`responseJson.updatedHtml` 的接收条件是 `aiModifyEnabled && targetRegionEl`，同样因为 `targetRegionEl` 为 `null` 而被跳过。即使大模型返回了 HTML，也会被丢弃。
3. **缺少光标插入降级**：参考代码 `injectAIText` 中，当找不到目标区域时，会调用 `execCmd('insertHTML', htmlContent)` 将内容直接插入当前光标位置。当前代码完全没有这种降级机制。

**C. 解决问题方案**
1. **解绑 System Prompt**：将条件从 `aiModifyEnabled && targetRegionEl` 改为 `aiModifyEnabled`，让大模型在「允许修改正文」开启时始终返回 `updatedHtml`。
2. **增加降级插入逻辑**：
   ```ts
   if (responseJson.updatedHtml && aiModifyEnabled) {
     if (targetRegionEl) {
       setDiffModal({...}); // 原有流程：目标区域存在时走 diff 弹窗
     } else {
       execCmd('insertHTML', responseJson.updatedHtml); // 降级：插入光标位置
     }
   }
   ```
3. 复用当前代码已存在的 `execCmd` 辅助函数，自动处理 focus、contentRef 更新和草稿保存。

**D. 后续如何避免问题**
- 设计「修改/生成」类 AI 功能时，**systemPrompt 的条件应尽量只依赖用户意图开关**（如 `aiModifyEnabled`），而非依赖具体 UI 状态（如某个下拉框是否选中）。UI 状态应只影响「如何注入结果」，不应影响「是否要求模型生成结果」。
- 任何「目标区域注入」逻辑都必须配备**降级方案**（如光标处插入），防止因用户未选中区域而导致功能完全失效。


---

## 记录 31：AI 修改模式自动锁定目标区域 + System Prompt 模式语义强化

**A. 具体问题**
用户希望实现两个明确场景：
1. **修改模式**：勾选「允许修改正文」→ AI 修改目标区域 → 弹出 diff 对比弹窗
2. **对话模式**：取消勾选「允许修改正文」→ AI 只聊天不修改

实际使用时，用户勾选了修改模式但未在下拉框中选择具体区域（`aiTargetRegion` 仍为 `'none'`），导致 AI 虽然返回了 `updatedHtml`，但 prompt 中缺少目标区域源码，diff 弹窗中的「原稿」一侧为空。

**B. 产生问题原因**
1. **目标区域未自动锁定**：`aiTargetRegion` 默认 `'none'`，修改模式开启后，如果用户未手动选择区域，`targetRegionEl` 为 `null`，prompt 中不会注入目标区域源码。
2. **System Prompt 模式语义不够强烈**：大模型对「修改模式」vs「对话模式」的区分不够清晰，可能即使在对话模式下也返回 HTML。

**C. 解决问题方案**
1. **自动修正目标区域**：在 `handleAIGenerate` 开头增加：
   ```ts
   let actualTargetId = aiTargetRegion;
   if (aiModifyEnabled && actualTargetId === 'none') {
     const availableRegions = checkAiRegions();
     if (availableRegions.length > 0) {
       actualTargetId = availableRegions[0].id;
       setAiTargetRegion(actualTargetId);
     }
   }
   ```
   后续 querySelector 和 diffModal 的 `targetId` 均使用 `actualTargetId`。
2. **强化 System Prompt 模式语义**：
   - 修改模式明确标注「当前处于【修改模式】」，并要求必须包含 `reply` + `updatedHtml`
   - 对话模式明确标注「当前处于【对话模式】」，并要求仅包含 `reply`，不要返回 HTML

**D. 后续如何避免问题**
- 当功能存在「全局开关 + 局部选择器」两层控制时，**全局开关开启后应自动兜底局部选择器**，避免因用户遗漏局部配置而导致功能降级。
- System Prompt 中应显式标注当前模式名称（如「修改模式」「对话模式」），大模型对显式标签的遵循度远高于隐式条件推断。


---

## 记录 32：AI diff 弹窗内容不完整 + 右侧多余空行

**A. 具体问题**
1. AI 修改确认弹窗左侧原始版本只显示一段内容，用户希望 AI 能一次性生成完整的多段落内容。
2. diff 弹窗右侧 AI 提议版本的段落间出现额外空行，与左侧结构不一致。

**B. 产生问题原因**
1. **内容不完整**：大模型被给予的目标区域源码（`currentHtml`）可能只有一段，且 systemPrompt 没有明确要求「生成完整、结构化的多段落内容」，导致 AI 只做局部改写。
2. **多余空行**：大模型返回的 HTML 中常包含 `<br>` 标签或 `\n` 换行符。`</p>\n<p>` 中的换行符会被浏览器解析为文本节点，产生额外空白。

**C. 解决问题方案**
1. **输入端控制（System Prompt + Prompt）**：
   - systemPrompt 增加明确要求：`updatedHtml 必须生成完整、结构化的多段落内容，不要只改写现有段落`
   - systemPrompt 增加 HTML 格式约束：`段落必须使用 <p> 标签包裹，段落之间绝对不要使用 <br> 标签，也不要使用任何换行符`
   - promptText 末尾追加「格式要求」段落，再次强调完整多段落、`<p>` 标签、禁止 `<br>`、紧凑 HTML
2. **输出端兜底（正则清洗）**：
   ```ts
   let cleanHtml = responseJson.updatedHtml;
   cleanHtml = cleanHtml.replace(/<br\s*\/?>/gi, '');
   cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
   cleanHtml = cleanHtml.trim();
   ```
   在 `setDiffModal` 和 `execCmd` 之前统一清洗，确保右侧渲染结构与左侧一致。

**D. 后续如何避免问题**
- 当大模型返回的 HTML 需要在前端渲染时，**必须同时在输入端（prompt）和输出端（后处理）进行格式约束**，单靠一端无法完全控制不同 LLM 的输出随机性。
- 对于「生成完整性」类需求，必须在 prompt 中明确使用「必须生成完整...」「不要只改写...」等强制性措辞，否则大模型倾向于做最小化修改。


---

## 记录 33：AI 越界生成——Prompt 中"补充完善"导致大模型过度联想

**A. 具体问题**
AI 修改确认弹窗右侧出现了不属于目标区域的内容：术后情况、切除标本描述、是否送病理检查、冰冻病理结果、手术者签名等。这些模块本应在报告的其他位置，却被 AI 混入了"手术步骤"区域的 updatedHtml 中。

**B. 产生问题原因**
1. **全局上下文暴露过多**：`globalContextText` 包含了整个编辑器的纯文本，AI 看到了报告中所有模块的内容。
2. **Prompt 措辞诱导过度联想**：systemPrompt 中写着 `要基于全局信息补充完善`，大模型非常"听话"地把它在全局上下文中看到的所有内容都"补充"进了输出。
3. **缺少内容边界约束**：Prompt 中没有明确告知 AI"只能输出目标区域本身的内容，严禁混入其他模块"。

**C. 解决问题方案**
1. **System Prompt 去掉诱导性措辞**：
   - 将 `请根据全局内容和用户的【医生指令】` 改为 `请根据用户的【医生指令】`
   - 将 `updatedHtml 必须生成完整...要基于全局信息补充完善` 改为明确的【内容边界】警告：
     > "全局参考内容仅供你理解上下文。你的 updatedHtml 只能包含目标区域本身的内容。严禁输出签名、落款、术后总结等属于报告其他部分的结构！"
2. **User Prompt 增加防越界指令**：
   - 增加第 2 点：用 ⚠️ 警告符号明确列出禁止混入的模块类型（基本信息、术后情况、标本描述、病理结果、医生签名、日期等）

**D. 后续如何避免问题**
- 在向大模型发送局部修改请求时，**必须设置严格的内容边界（Fencing）**。全局上下文可以提供给 AI 作为背景理解，但必须在 Prompt 中明确声明"仅供理解，严禁输出"。
- 避免使用"补充完善""基于全局信息扩展"等容易被大模型过度解读的措辞。大模型会尽其所能地"满足"用户的指令，即使这意味着越界生成。


---

## 记录 34：contentEditable 回车导致段落溢出 .ai-content

**A. 具体问题**
AI 修改确认弹窗的「原始版本」左侧只显示了 AI 可编辑区域中的一段内容，但编辑器中该区域实际上有 2-5 段。从 DOM 源码可以看到：
```html
<div class="ai-content"><p>第2段</p></div>
<p>第3段</p>
<p>第4段</p>
<p>第5段</p>
```
第 3-5 段变成了 `.ai-content` 的兄弟节点，不在 `.ai-content` 内部。

**B. 产生问题原因**
浏览器原生 `contentEditable` 机制在用户按回车换行时，会截断当前的块级容器（`.ai-content` div），在同级生成新的 `<p>` 标签。这导致后续段落脱离了 `.ai-content` 父容器，变成了 `.ai-region` 的直接子节点。

**C. 解决问题方案**
在 `handleAIGenerate` 获取 `currentHtml` 之前，增加溢出段落合并逻辑：
```ts
const aiRegion = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"]`);
if (aiRegion && targetRegionEl) {
  let nextSibling = targetRegionEl.nextElementSibling;
  while (nextSibling) {
    const toMove = nextSibling;
    nextSibling = nextSibling.nextElementSibling;
    if (toMove.tagName === 'P') {
      targetRegionEl.appendChild(toMove);
    }
  }
  if (editorRef.current) {
    contentRef.current = editorRef.current.innerHTML;
    saveDraftToStorage();
  }
}
```
遍历 `.ai-content` 之后的所有兄弟节点，把 `<p>` 标签移回 `.ai-content` 内，然后同步更新 contentRef 和草稿。

**D. 后续如何避免问题**
- `contentEditable` 中的嵌套容器（如 `.ai-content`）在用户输入时极易被浏览器原生编辑行为破坏结构。任何依赖特定 DOM 层级关系的功能，都必须在读取数据前做**结构完整性检查和修复**。
- 对于 AI 区域这类核心功能，应考虑在编辑器层面增加 `keydown`/`paste` 事件拦截，或改用更可控的编辑方案（如 ProseMirror/Slate）来替代原生 `contentEditable`。


---

## 记录 35：打印时隐藏 AI 区域蓝框 + diff 弹窗字体统一

**A. 具体问题**
1. 点击下载/打印时，AI 可编辑区域的蓝色虚线框和右上角标签仍然显示在输出中。
2. AI 修改确认弹窗中，右侧「AI 提议版本」的字体与左侧「原始版本」不一致（左侧宋体 12pt，右侧默认无衬线体）。

**B. 产生问题原因**
1. **打印样式缺失**：`src/utils/print.ts` 使用 iframe 生成打印文档，其 `<style>` 中没有针对 `.ai-region` 的隐藏样式。虽然 `src/index.css` 中有 `.print-content .ai-region` 规则，但 `print.ts` 中实际使用的是 `.content` 类，CSS 选择器不匹配。
2. **字体继承缺失**：AI 返回的 HTML 是纯净的 `<p>` 标签，没有内联样式。diff 弹窗右侧容器没有设置默认字体，导致浏览器使用默认无衬线字体。

**C. 解决问题方案**
1. **打印样式**：在 `print.ts` 的 iframe `<style>` 中增加：
   ```css
   .ai-region { border: none !important; background: transparent !important; padding: 0 !important; margin: 0 !important; }
   .ai-region > [contenteditable="false"] { display: none !important; }
   ```
2. **diff 弹窗字体**：在右侧容器的 `style` 属性中指定：
   ```tsx
   style={{ fontFamily: 'SimSun, "Microsoft YaHei", serif', fontSize: '12pt', lineHeight: '1.5' }}
   ```

**D. 后续如何避免问题**
- 任何通过 iframe 或独立文档实现的打印/导出功能，都必须在 iframe 的 `<style>` 中独立维护打印样式，不能依赖外部 CSS 文件（因为外部样式不会自动注入 iframe）。
- 对于 diff 对比类 UI，左右两侧容器应显式设置相同的默认字体样式，避免依赖内容自带的内联样式造成视觉不一致。


---

## 记录 36：AI 注入后 Ctrl+Z 失效 + 字体格式丢失

**A. 具体问题**
1. 点击「确认并写入报告」后，Ctrl+Z 无法撤销 AI 的修改。
2. AI 替换后的文字丢失了原有内联样式（宋体 12pt），显示为浏览器默认字体。

**B. 产生问题原因**
1. **撤销失效**：`confirmAiInjection` 使用 `targetContent.innerHTML = newHtml;` 直接修改 DOM 属性。这种方式完全绕过了浏览器 `contentEditable` 的原生撤销/重做历史栈。
2. **字体丢失**：大模型返回的是纯净的 `<p>` 标签（如 `<p>内容</p>`），没有内联样式。替换后浏览器使用默认字体渲染，与原有 `<p style="font-family: SimSun; font-size: 12pt;">` 不一致。

**C. 解决问题方案**
1. **保留撤销栈**：将 `innerHTML = newHtml` 替换为：
   ```ts
   targetContent.focus();
   const sel = window.getSelection();
   const range = document.createRange();
   range.selectNodeContents(targetContent);
   sel?.removeAllRanges();
   sel?.addRange(range);
   document.execCommand('insertHTML', false, newHtml);
   ```
   `Range.selectNodeContents` 选中区域内所有旧内容，`execCommand('insertHTML')` 让浏览器原生撤销栈记录这次替换。
2. **注入内联样式**：在 `handleAIGenerate` 的 `cleanHtml` 清洗后增加：
   ```ts
   cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="padding: 0px; font-family: SimSun; font-size: 12pt; line-height: 1.5;">');
   ```
   给所有 `<p>` 标签注入标准内联样式，确保替换后字体与原有文字一致。

**D. 后续如何避免问题**
- 在 `contentEditable` 环境中修改内容时，**优先使用 `Range.selectNodeContents` + `execCommand('insertHTML')` 而非直接 `innerHTML` 赋值**，前者能让浏览器原生撤销/重做栈正常工作。
- 当大模型返回的 HTML 缺少必要的内联样式时，应在**前端后处理阶段**统一注入样式，而不是依赖大模型生成完整的样式代码（大模型对样式生成的稳定性较差）。


---

## 记录 37：AI 二次修改未弹窗 + diff 弹窗增加文档对比高亮

**A. 具体问题**
1. 第一次 AI 修改正常弹出 diff 弹窗，第二次输入微调指令（如"把 5x3x2 变成 5x3x10"）后没有弹窗。
2. diff 弹窗左侧和右侧只是简单渲染两段 HTML，无法直观看到 AI 具体修改了哪些字词。

**B. 产生问题原因**
1. **未弹窗**：大模型在微小修改指令时可能"偷懒"，只返回 `reply` 而不返回 `updatedHtml`。当前逻辑 `if (responseJson.updatedHtml && aiModifyEnabled)` 会跳过弹窗，用户没有任何反馈。
2. **无对比**：没有使用差异比对算法来标记变更，用户只能通过肉眼对比左右两侧发现差异。

**C. 解决问题方案**
1. **强化 systemPrompt**：增加第 8 条：「⚠️ 绝对强制：无论用户的修改指令多么微小，你都必须返回 updatedHtml。绝对不允许只返回 reply 而不返回 updatedHtml！」
2. **前端校验兜底**：在 `updatedHtml` 处理分支前增加：
   ```ts
   if (aiModifyEnabled && !responseJson.updatedHtml) {
     setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】AI 未能生成修改内容，请尝试重新描述您的需求。' }]);
   }
   ```
3. **引入 diff 库**：`npm install diff`，使用 `diffChars` 进行字符级差异比对。
4. **左右两侧 diff 高亮**：
   - 左侧（原始版本）：删除的内容标红（`background-color:#fee2e2; color:#dc2626; text-decoration:line-through;`）
   - 右侧（AI 版本）：新增的内容标绿（`background-color:#dcfce7; color:#16a34a;`）
5. **注入前清理**：`confirmAiInjection` 中去掉 diff 高亮 span：
   ```ts
   const cleanHtml = newHtml.replace(/<span class="diff-(added|removed)"[^>]*>(.*?)<\/span>/gi, '$2');
   ```

**D. 后续如何避免问题**
- 大模型对「必须返回某字段」的遵循度与 prompt 中该字段的强调程度正相关。对于关键输出字段，应在 systemPrompt 中使用「绝对强制」「绝对不允许」等最强措辞，并在前端增加缺失校验兜底。
- 在 diff 对比场景中，**纯文本层面的差异比对**比 HTML 层面的比对更可靠。应先将 HTML strip 为纯文本，再做 diff，最后把结果渲染为 HTML。
