# 经验记录 --- ## 记录 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. 将 "插入" 按钮从底部文字行移到图片层的 `
` 容器内,使用 `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 恢复已有报告、draf t 恢复新建报告)中,将 `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(r => setTimeout(...))`: ```tsx if ((settings.autoInsertDelay || 0) > 0) { await new Promise(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 容量瓶颈。 - 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。 --- ## 记录 12:contentEditable 中实现标签锁定与输入方格的双向绑定 **A. 具体问题** 需要在 `ReportEditor` 和 `TemplateManage` 的富文本编辑器中插入"标签锁定、内容可调"的智能占位控件,使"姓名:"等固定文本不会被用户误删,同时方格内的输入能与右侧【基本信息】表单双向联动。 **B. 产生问题原因** 原生 `contentEditable` 区域内所有文本节点对用户都是可编辑的,无法直接保护某一段固定标签不被单独删除或篡改。若仅用样式区分的普通 ``,用户仍可通过退格键将"姓名:"删掉一半或改乱。 **C. 解决问题方案** 采用三层嵌套 HTML 结构: 1. **外层** ``:作为不可编辑的框架,确保整个控件不会被内部逐字删除。 2. **标签层** ``:显示固定文本如"姓名:",受外层保护。 3. **输入层** ``:允许用户输入,并通过 `data-bind` 属性建立与 `reportData` 的映射关系。 双向绑定逻辑: - **富文本 → 表单**:在 `handleEditorInput` 中通过 `e.target.hasAttribute('data-bind')` 判断输入源,实时更新 `reportData`。 - **表单 → 富文本**:在 `useEffect` 中监听 `reportData` 变化,仅当 `el.innerText !== newValue` 时才重写 DOM,防止光标跳动。 **D. 后续如何避免问题** - 对于需要在富文本中保护的固定文本,优先采用 `contenteditable="false"` 的包装器,而不是仅靠样式区分的普通 ``。 - 在 `State -> DOM` 的同步中务必加入差异判断,避免不必要的 DOM 重写导致输入焦点异常。 - 数组类型字段(如 `surgeon`)在同步到方格前应先 `join(', ')` 转换为字符串,保持显示一致性。 --- ## 记录 13:手术时间方框化、动态字段分类体系与 UI 紧凑化 **A. 具体问题** 1. 手术开始/终止时间在模板中是纯文本"时 分",无法与右侧表单联动。 2. `TemplateManage` 的字段库是静态列表,无法按医院需求自定义字段;`ReportEditor` 的右侧表单全部硬编码,每新增一个字段就要改代码。 3. `field-value` 方格使用了 `min-width: 60px` 和上下 `padding`,导致行间距被撑大,排版松散。 **B. 产生问题原因** 1. 时间字段在 `defaultContent.ts` 中没有使用 `data-bind` 智能控件,且右侧表单将时间拆分为 `startHour`/`startMinute` 两个独立字段,缺少与方格的双向转换层。 2. 早期设计采用了"硬编码表单"思路,字段名、类型、选项全部写死在 `ReportEditor.tsx` 的 JSX 中,不具备扩展性。 3. `inline-block` 元素自带上下 `padding` 和 `border`,超出了默认行高,浏览器不得不增大整行高度以容纳它。 **C. 解决问题方案** 1. **时间方框联动**: - 在 `defaultContent.ts` 中替换为 `data-bind="startTime"` 和 `data-bind="endTime"` 的方格。 - 在 `ReportEditor.tsx` 的 `handleEditorInput` 中,对 `startTime`/`endTime` 使用 `split(':')` 解析,反向更新 `startHour`/`startMinute`;在 `useEffect(reportData)` 中拼接 `HH:mm` 同步回方格。 2. **动态字段体系**: - 在 `types.ts` 中新增 `FieldType`、`FormField`、`DEFAULT_FORM_FIELDS`,定义字段的 key/label/分类/类型/显隐/锁定状态/选项。 - 使用 `localStorage` 的 `formFieldsConfig` 持久化字段配置。 - `TemplateManage.tsx` 右侧字段库重构为 Tab 结构:【插入字段】按"填空/单选/多选/时间"分组;【字段管理】支持新增、删除(非锁定字段)、显隐开关。 - `ReportEditor.tsx` 右侧基本信息表单改为遍历 `formFieldsConfig`、按 `type` switch-case 动态渲染(文本框/下拉框/多选标签/时间拆分下拉框)。 3. **UI 紧凑化**: - 将 `min-width` 从 `60px` 缩至 `32px`。 - 去除上下 `padding`,使用 `line-height: 1.2`、`font-size: inherit`、`vertical-align: text-bottom`。 - 背景色改为 `#f8fafc`(编辑态更明显),打印时恢复透明并只保留下划线。 **D. 后续如何避免问题** - 对于需要将多个子字段映射到单一 UI 控件的场景,应在事件处理器和 `useEffect` 中各维护一层"拼接/解析"转换逻辑,保持底层数据结构不变。 - 当表单字段超过 5 个且存在频繁变更需求时,应尽早从硬编码 JSX 转向"配置驱动渲染"(Config-Driven UI),降低后续维护成本。 - 在 `contentEditable` 中插入 `inline-block` 元素时,务必通过 `line-height`、`vertical-align` 和最小化 `padding` 控制其对行高的影响,避免破坏段落排版的紧凑性。 --- ## 记录 15:默认模板新增术后 4 个字段并自动补全老用户配置 **A. 具体问题** 需要在默认手术报告模板中新增 4 个字段(手术后情况、切除标本描述、是否送检病理、冰冻病理结果),使其与右侧【基本信息】动态表单双向联动。同时,老用户浏览器 localStorage 中已存在的 `formFieldsConfig` 不包含这 4 个字段,直接发版会导致老用户看不到新控件。 **B. 产生问题原因** 1. `DEFAULT_FORM_FIELDS` 和 `defaultContent.ts` 是代码层面的默认值,但 `ReportEditor.tsx` 和 `TemplateManage.tsx` 的初始化逻辑是"若 localStorage 中已存在 `formFieldsConfig`,则直接使用,不再回退到代码默认值"。 2. 因此,新增字段只对新用户(localStorage 为空)生效,老用户的字段列表会永远停留在旧版本。 **C. 解决问题方案** 1. 在 `src/types.ts` 的 `Report` 接口中追加 4 个新字段;在 `DEFAULT_FORM_FIELDS` 末尾追加 4 条配置(含 `options`)。 2. 在 `src/utils/defaultContent.ts` 中,将模板底部对应 4 处静态文本/占位色 `` 替换为 `${smartField('key')}`。 3. 在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 的初始化 `useEffect` 中,读取 `formFieldsConfig` 后增加**缺失字段自动补全逻辑**: ```ts const missing = DEFAULT_FORM_FIELDS.filter(def => !savedFields.some(f => f.key === def.key)); if (missing.length > 0) { mergedFields = [...savedFields, ...missing]; storage.set('formFieldsConfig', mergedFields); } ``` 这样老用户首次进入页面时,缺失字段会自动追加到列表末尾,同时保留用户已有的自定义字段和显隐设置。 **D. 后续如何避免问题** - 任何依赖 `localStorage` 持久化配置的"字段/菜单/权限"列表,在后续版本新增默认值时,都应提供**自动合并/升级逻辑**,而不是简单依赖"有值则跳过"。 - 合并策略推荐:以代码层 `DEFAULT_xxx` 为基准,检测缺失 key 并追加,保留用户已有的自定义项和状态,实现无痛升级。 - 对于模板 HTML 的变更,若旧模板已存入 `templates` localStorage,则默认模板只影响新建报告;若需要更新已有模板,需额外提供模板升级脚本或管理员手动维护机制。 --- ## 记录 14:智能字段插入间距修复与 Backspace 防误删 **A. 具体问题** 1. `TemplateManage.tsx` 中使用 `insertSmartField` 插入智能字段后,字段后方会出现一个可见的空格(由 ` ` 和多行模板字符串中的换行/缩进空白引起)。 2. 在 `contenteditable` 中,光标位于 `

` 行首且后紧跟 `.smart-field-wrapper` 时按 Backspace,WebKit 内核会直接删除整段 `

` 而不是仅删除字段节点。 3. `defaultContent.ts` 中的 `smartField` 辅助函数同样存在多行缩进导致的模板 HTML 中夹杂空白文本节点问题。 **B. 产生问题原因** 1. `insertSmartField` 的 HTML 字符串使用反引号多行模板,缩进和换行被浏览器解析为额外的文本节点;末尾显式拼接了 ` `,导致插入后字段与后续文字之间总有一个不必要的空格。 2. `contenteditable="false"` 的 inline 元素处于行边界时,WebKit 的默认编辑行为会将整个包含该元素的块级父节点一并删除,而不是只删除该不可编辑元素。 3. `defaultContent.ts` 中的 `smartField` 为了可读性也使用了多行缩进模板字面量,导致默认模板里每个 `smartField` 调用前后都引入了额外的空白文本节点。 **C. 解决问题方案** 1. **压缩 HTML 字符串**:将 `insertSmartField` 和 `defaultContent.ts` 的 `smartField` 输出改为单行 HTML,移除所有无意义的换行和缩进,并去掉尾部的 ` `。 2. **防止内部折行**:给 `.smart-field-wrapper` 增加 `white-space: nowrap;`(内联样式 + CSS 类双保险),确保标签和输入框不会在行中间被拆开。 3. **拦截 Backspace/Delete**:在 `TemplateManage.tsx` 的编辑器上增加 `keydown` 事件监听(capture 阶段)。当光标位于文本节点起始位置且前一个兄弟节点是 `.smart-field-wrapper` 时按 Backspace,或光标在文本节点末尾且后一个兄弟节点是 `.smart-field-wrapper` 时按 Delete,主动 `preventDefault()` 并手动移除该字段节点,随后同步更新 `localStorage` 中的模板内容。 **D. 后续如何避免问题** - 在 `contentEditable` 中使用 `document.execCommand('insertHTML', ...)` 插入 HTML 时,**传入的字符串必须是无多余空白的紧凑单行**,否则浏览器会将其中的换行符解析为额外的文本节点,破坏排版和光标行为。 - 对于 `contenteditable="false"` 的内联控件,若放置在块级边界(如 `

` 开头/结尾),务必增加键盘事件拦截,防止浏览器默认行为误删父级块。 - 默认模板或任何通过代码生成的 HTML,应避免为了代码可读性而牺牲运行时 DOM 的纯净性;必要时在生成后对字符串进行 `.replace(/\s+/g, ' ').trim()` 处理。