Files
Mdeical_Sur_Report/工程分析/经验记录.md

98 KiB
Raw Blame History

经验记录

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


记录 1report-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-mdhover 时加深为 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. 初始化 useEffectuseLayoutEffect 从 draft 或已保存报告恢复数据时,仅通过 setState 更新了 React statevideoscapturedFrames),但 没有同步更新 stateRef.current
  3. 用户首次进入页面时数据正确显示;离开页面时,stateRef.current 仍保存着初始值(空数组),导致 saveDraftToStorage() 用空数组覆盖了 localStorage 中的 draft。
  4. 再次返回页面时,系统优先读取被污染后的 draft从而丢失了所有视频分析数据。

C. 解决问题方案ReportEditor.tsx 的 6 个数据恢复入口(初始化 useEffect 的 3 个分支 + useLayoutEffect 安全网的 3 个分支)中,恢复 reportDatavideoscapturedFrames 后立即同步赋值给 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 是否为空,reportDatavideoscapturedFrames 都会立即写入 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.jsonpreview 脚本中增加 --port 参数(如 vite preview --port 8080)。

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

A. 具体问题/report-editor 中编辑报告(填写基本信息、上传视频、自动/手动截取关键帧、拖拽图片到 placeholder切换到 /report-manage 再返回 /report-editor,报告编辑器内容、基本信息、视频列表、关键帧截图全部丢失

B. 产生问题原因

  1. 自动保存机制过度依赖 stateRefcontentRef 作为"数据快照"。
  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.currentstateRef.current,而是直接从最新的 React state 和 editorRef.current?.innerHTML 获取数据。useCallback 的 dependency 数组包含 reportDatavideoscapturedFramesactiveTabloadedTemplateIdreportId,确保闭包永远绑定当前渲染周期的最新 state。
  2. 重构自动保存 effect:将 beforeunloadvisibilitychange 事件处理器直接绑定到 saveDraftToStorageeffect 的 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 statecapturedFramesvideos)读取。但代码中大量存在 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
    • reportDatavideoscapturedFramesactiveTabloadedTemplateId 全部从 stateRef.current 读取,彻底避开 React state 的闭包陷阱。
    • useCallback 的 dependency 仅保留 [reportId],避免因 state 变化产生陈旧闭包。
  2. 补齐 contentRef 遗漏:在 handleEditorClickdocument.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. 产生问题原因 autoCaptureFramesfor 循环内部,自动插入逻辑使用了 await new Promise<void>(r => setTimeout(...))

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 阻塞主循环,应改用 setTimeoutPromise.all 实现并行/异步解耦。
  • 当多个定时任务需要按顺序执行时,可以通过索引计算累积延迟(delay * (index + 1)),实现简单的"队列式"顺序触发,而不需要阻塞主流程。
  • setTimeout 等异步回调中操作 DOM 时,应在回调触发时"实时查询"目标元素,而不是在循环中提前捕获元素引用,以防 DOM 在延迟期间已被用户修改。

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

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

B. 产生问题原因

  1. LocalStorage 5MB 容量限制:当前抽帧逻辑使用视频原始分辨率 + JPEG 质量 0.9
    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 但没有任何日志:
    } catch {
      // ignore quota exceeded
    }
    
    saveDraftToStorage() 尝试保存大量关键帧时,localStorage.setItem 抛出异常draft 无法更新,但用户和开发者都感知不到错误。最终返回 /report-editor 时,只能读取到"有视频、无关键帧"的旧 draft。

C. 解决问题方案

  1. 压缩关键帧分辨率与质量

    • captureFrame()(手动截图)和 autoCaptureFrames()(自动抽帧)中,增加 Canvas 等比缩放:
      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.tssetsetSession 中,将静默 catch 改为输出 console.error
      } catch (e) {
        console.error('Storage save failed (possibly quota exceeded):', e);
      }
      

D. 后续如何避免问题

  • 任何将 Base64 图片持久化到 localStorage 的场景,都必须预估数据体积并对图片进行适当的分辨率/质量压缩。
  • 存储层的异常捕获绝不应静默吞掉,至少要输出日志,必要时还应弹出用户提示。
  • 对于需要存储大量图片的医疗/图文报告系统,应将 localStorage 逐步迁移到 IndexedDB,从根本上解除 5MB 容量瓶颈。
  • 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。

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

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

B. 产生问题原因 原生 contentEditable 区域内所有文本节点对用户都是可编辑的,无法直接保护某一段固定标签不被单独删除或篡改。若仅用样式区分的普通 <span>,用户仍可通过退格键将"姓名:"删掉一半或改乱。

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">:允许用户输入,并通过 data-bind 属性建立与 reportData 的映射关系。

双向绑定逻辑:

  • 富文本 → 表单:在 handleEditorInput 中通过 e.target.hasAttribute('data-bind') 判断输入源,实时更新 reportData
  • 表单 → 富文本:在 useEffect 中监听 reportData 变化,仅当 el.innerText !== newValue 时才重写 DOM防止光标跳动。

D. 后续如何避免问题

  • 对于需要在富文本中保护的固定文本,优先采用 contenteditable="false" 的包装器,而不是仅靠样式区分的普通 <span>
  • 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 元素自带上下 paddingborder,超出了默认行高,浏览器不得不增大整行高度以容纳它。

C. 解决问题方案

  1. 时间方框联动
    • defaultContent.ts 中替换为 data-bind="startTime"data-bind="endTime" 的方格。
    • ReportEditor.tsxhandleEditorInput 中,对 startTime/endTime 使用 split(':') 解析,反向更新 startHour/startMinute;在 useEffect(reportData) 中拼接 HH:mm 同步回方格。
  2. 动态字段体系
    • types.ts 中新增 FieldTypeFormFieldDEFAULT_FORM_FIELDS,定义字段的 key/label/分类/类型/显隐/锁定状态/选项。
    • 使用 localStorageformFieldsConfig 持久化字段配置。
    • TemplateManage.tsx 右侧字段库重构为 Tab 结构:【插入字段】按"填空/单选/多选/时间"分组;【字段管理】支持新增、删除(非锁定字段)、显隐开关。
    • ReportEditor.tsx 右侧基本信息表单改为遍历 formFieldsConfig、按 type switch-case 动态渲染(文本框/下拉框/多选标签/时间拆分下拉框)。
  3. UI 紧凑化
    • min-width60px 缩至 32px
    • 去除上下 padding,使用 line-height: 1.2font-size: inheritvertical-align: text-bottom
    • 背景色改为 #f8fafc(编辑态更明显),打印时恢复透明并只保留下划线。

D. 后续如何避免问题

  • 对于需要将多个子字段映射到单一 UI 控件的场景,应在事件处理器和 useEffect 中各维护一层"拼接/解析"转换逻辑,保持底层数据结构不变。
  • 当表单字段超过 5 个且存在频繁变更需求时,应尽早从硬编码 JSX 转向"配置驱动渲染"Config-Driven UI降低后续维护成本。
  • contentEditable 中插入 inline-block 元素时,务必通过 line-heightvertical-align 和最小化 padding 控制其对行高的影响,避免破坏段落排版的紧凑性。

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

A. 具体问题

  1. TemplateManage.tsx 中使用 insertSmartField 插入智能字段后,字段后方会出现一个可见的空格(由 &nbsp; 和多行模板字符串中的换行/缩进空白引起)。
  2. contenteditable 中,光标位于 <p> 行首且后紧跟 .smart-field-wrapper 时按 BackspaceWebKit 内核会直接删除整段 <p> 而不是仅删除字段节点。
  3. defaultContent.ts 中的 smartField 辅助函数同样存在多行缩进导致的模板 HTML 中夹杂空白文本节点问题。

B. 产生问题原因

  1. insertSmartField 的 HTML 字符串使用反引号多行模板,缩进和换行被浏览器解析为额外的文本节点;末尾显式拼接了 &nbsp;,导致插入后字段与后续文字之间总有一个不必要的空格。
  2. contenteditable="false" 的 inline 元素处于行边界时WebKit 的默认编辑行为会将整个包含该元素的块级父节点一并删除,而不是只删除该不可编辑元素。
  3. defaultContent.ts 中的 smartField 为了可读性也使用了多行缩进模板字面量,导致默认模板里每个 smartField 调用前后都引入了额外的空白文本节点。

C. 解决问题方案

  1. 压缩 HTML 字符串:将 insertSmartFielddefaultContent.tssmartField 输出改为单行 HTML移除所有无意义的换行和缩进并去掉尾部的 &nbsp;
  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" 的内联控件,若放置在块级边界(如 <p> 开头/结尾),务必增加键盘事件拦截,防止浏览器默认行为误删父级块。
  • 默认模板或任何通过代码生成的 HTML应避免为了代码可读性而牺牲运行时 DOM 的纯净性;必要时在生成后对字符串进行 .replace(/\s+/g, ' ').trim() 处理。

记录 155 项交互与默认值优化(占位符尺寸、签名状态、素材预加载)

A. 具体问题 用户提出 5 个 UI/UX 改进需求:

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

B. 产生问题原因

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

C. 解决问题方案

  1. 合并 prompt
    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 && !heightStr200×200widthStr && !heightStr → 宽自定义、高 200!widthStr && heightStr → 宽 200、高自定义。
  3. 默认设置补全:在 Login.tsxdefaultSettings 中显式加入:
    autoInsertFrames: true,
    autoInsertDelay: 1,
    autoInsertFrameIndices: [0, 1, 2, 3, 4, 5]
    
  4. 签名状态列:在 UserManage.tsx 表格的 <th><td> 中,于「部门」之后、「状态」之前插入签名状态标签。
  5. 素材预加载前置:将 fetch('/logo_square.png') → FileReader → storage.set('imageAssets', [...]) 的逻辑从 TemplateManage.tsx 迁移到 Login.tsxinitData() 中,并增加 savedAssets.length === 0 的判空保护,避免覆盖用户后续上传的素材。

D. 后续如何避免问题

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

记录 16模板字段唯一性、删除按钮与报告批量导出

A. 具体问题

  1. TemplateManage 中智能字段可以重复插入多次,导致模板混乱。
  2. 智能字段在某些边界位置(如段落开头/结尾)无法通过 Backspace/Delete 删除。
  3. ReportManage 缺少报告导出功能和批量操作能力。

B. 产生问题原因

  1. insertSmartField 没有检测 DOM 中是否已存在相同 data-bind 的字段节点。
  2. 之前的 keydown 拦截逻辑只处理了光标在文本节点内的情况,没有处理光标直接在块级父节点边界(startContainer<p> 等块元素)的场景。
  3. ReportManage 的设计只支持单条查看/编辑/删除,没有设计多选状态和导出逻辑。

C. 解决问题方案

  1. 唯一性校验:在 insertSmartField 中通过 editorRef.current?.querySelector([data-bind="..."]) 预检查,若已存在则 alert 并终止插入。
  2. 删除按钮:给 .smart-field-wrapper 内部增加一个红色圆形的 <span class="delete-btn">×</span>,点击即可删除整个字段节点。同时在 index.cssprint 媒体查询中分别定义显示/隐藏样式。
  3. 键盘删除增强:重写 keydown 处理器,同时处理 startContainerTEXT_NODEELEMENT_NODE 两种情况。当光标位于块级父节点的子节点边界时,通过 el.childNodes[offset - 1]el.childNodes[offset] 定位字段节点并安全删除。
  4. 报告批量操作
    • ReportManage.tsx 中引入 selectedIds 状态,表格每行增加 Checkbox表头支持全选/反选。
    • 增加浮动批量操作栏,支持"批量删除"、"批量导出 PDF"、"批量导出 JSON"、"取消选择"。
    • 单报告操作列增加"导出"按钮,点击弹出模态框选择 PDF 或 JSON。
    • PDF 导出复用现有的 printDocument(content)JSON 导出通过 Blob + URL.createObjectURL 实现下载,数据结构包含 meta(报告元信息)和 fields(所有 DEFAULT_FORM_FIELDS 对应值)。
    • 批量 PDF 将多份报告的 HTML 用 <div style="page-break-after: always;"></div> 拼接后统一打印。
    • 批量 JSON 将多份报告导出为数组形式的单个 .json 文件。

D. 后续如何避免问题

  • contentEditable 中插入的任何可复用控件,都应考虑增加唯一性校验和明确的删除入口(可视化按钮 + 键盘事件拦截)。
  • 键盘事件处理不能假设 startContainer 一定是文本节点,必须覆盖块级元素边界的情况。
  • 当列表页需要增加批量操作时,建议将"选择状态"和"批量动作"封装为独立逻辑,保持单条操作按钮的可维护性。
  • 导出功能应尽量复用现有的 printDocument 等工具函数,减少新依赖引入。

记录 17字段聚焦高亮、删除按钮显隐隔离与 multi_select 脏数据崩溃修复

A. 具体问题

  1. TemplateManage 中编辑智能字段时缺少视觉焦点反馈,用户体验不够直观。
  2. 红色 × 删除按钮始终显示在字段内部左侧,且在任何包含 smart-field-wrapper 的页面(包括 ReportEditor)都会显示。
  3. ReportEditor 加载某些历史报告时崩溃,报错 (y[x.key] || []).map is not a function

B. 产生问题原因

  1. 之前没有为 .field-value 定义 :focus 状态的 CSS 样式。
  2. delete-btn 使用 display: inline-flex 默认常驻显示,且没有针对页面做显隐隔离。
  3. multi_select 字段(如 surgeonassistant)的渲染直接对值调用 .map(),但旧数据或异常存储可能将其保存为字符串(如 "张医生" 而非 ["张医生"]),导致 .map 在字符串上调用时抛出 TypeError

C. 解决问题方案

  1. 聚焦高亮:在 index.css 中为 .smart-field-wrapper .field-value:focus 增加背景色加深(#e2e8f0)、边框变深(#94a3b8)和蓝色外发光(box-shadow: 0 0 0 2px rgba(59,130,246,0.25))的样式,配合 transition 实现平滑反馈。
  2. 删除按钮定位与显隐隔离
    • delete-btn 从字段内部移到 .field-value 之后,并给 .smart-field-wrapper 增加 position:relative,使 delete-btn 可绝对定位到右上角(top: -8px; right: -8px)。
    • 默认 display: none;在 TemplateManage 的编辑器容器上增加 template-editor-mode class通过 .template-editor-mode .smart-field-wrapper:hover .delete-btn:focus-within .delete-btn 控制仅在 TemplateManage 中悬浮/聚焦时显示。
    • ReportEditor 的编辑器容器没有 template-editor-mode,因此删除按钮不会显示。
  3. 类型安全修复:在 ReportEditor.tsxmulti_select 渲染分支中,增加 Array.isArray 检查:
    const rawValue = (reportData as any)[field.key];
    const tags = Array.isArray(rawValue) ? rawValue : (rawValue ? [String(rawValue)] : []);
    
    确保无论旧数据是数组、字符串还是空值,都能安全渲染为标签列表。

D. 后续如何避免问题

  • 任何需要在不同页面显隐不同的 UI 元素,应通过容器级 class 做样式隔离,而不是依赖全局显示/隐藏。
  • contentEditable 控件的焦点状态必须有明确的视觉反馈(背景/边框/阴影变化),否则用户难以感知当前编辑位置。
  • 对从持久化存储读取的数组类型数据,在 React 渲染前务必做 Array.isArray 校验,防止历史脏数据导致整页崩溃。

记录 18字段悬浮高亮、电子签上传与手术者签名联动

A. 具体问题

  1. TemplateManage 中右侧字段库按钮与编辑器中的字段缺乏视觉关联,用户难以快速定位字段位置。
  2. UserManage 缺少电子签名上传功能,无法为医生绑定个人签名图。
  3. 模板中缺少"手术者签名"字段,报告编辑时无法自动带入医生签名。
  4. 签名图片若直接放入 .field-value 中,容易撑大行高,影响排版和打印效果。

B. 产生问题原因

  1. 字段库按钮没有任何与编辑器 DOM 联动的交互反馈机制。
  2. 早期设计未考虑医疗文书中的电子签需求,User 模型和 DEFAULT_FORM_FIELDS 均缺少签名相关定义。
  3. 没有针对签名图片设计专门的 CSS 尺寸约束,导致浏览器按原图尺寸渲染,破坏行高。

C. 解决问题方案

  1. 悬浮高亮:在 TemplateManage.tsx 的字段库按钮上增加 onMouseEnter / onMouseLeave,直接操作编辑器中对应 data-bind.field-valuestyle.boxShadowstyle.backgroundColor,实现蓝色外发光/背景变浅蓝色的即时高亮反馈。
  2. 电子签上传与压缩
    • UserManage.tsx 中增加 compressImage(file, maxSize=500) 工具函数,利用 Canvas 等比例缩放并填充白色背景,输出 JPEG base64质量 0.8)。
    • 在用户编辑/新增弹窗中增加"电子签名"区块:预览图、上传按钮、清除按钮。
    • 编辑当前登录用户时同步更新 storage.set('currentUser', ...),确保 ReportEditor 能读取最新签名。
  3. 手术者签名字段
    • types.tsUser 增加 signature?: stringFieldType 增加 'signature'DEFAULT_FORM_FIELDS 追加 surgeonSignature(分类"图片",系统锁定)。
    • TemplateManage 插入字段分类增加"图片"surgeonSignature 自动出现在该分类下。
    • ReportEditor 的"表单 → 编辑器"同步 useEffect 中,对 fieldKey === 'surgeonSignature' 做特殊分支:有签名则填充 <img class="report-signature-img" src="..." />,无签名则填充文本"【请上传电子签】"。
  4. 签名排版优化
    • index.cssprint.ts 中定义 .report-signature-img
      .report-signature-img {
        height: 2.4em;
        width: auto;
        vertical-align: middle;
        display: inline-block;
        margin: -0.3em 0;
      }
      
    • 打印媒体查询中同步使用 !important 确保打印输出也保持同样尺寸。

D. 后续如何避免问题

  • 当需要在 React 之外直接操作 DOM 样式实现即时反馈时,优先使用原生事件 + inline style避免触发组件重渲染导致光标丢失
  • 任何新增的持久化字段应在类型定义TypeScript interface、默认值DEFAULT_xxx、以及所有相关读写逻辑中同步补齐防止类型不一致。
  • contentEditable 中插入图片时,务必通过 CSS 对 height/width/vertical-align 做严格约束,避免原图尺寸破坏文本流。
  • 涉及打印的样式必须在 iframe 打印模板和 @media print 中双端同步,防止打印效果与屏幕预览不一致。

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

A. 具体问题

  1. TemplateManage 中通过红色 × 或键盘删除智能字段后浏览器撤销栈Undo失效点击"撤销"按钮无法恢复。
  2. 插入"手术日期"、"手术者签名"等字段后,字段框有时会跳到下一行。
  3. Backspace 键无法删除字段Delete 键会误删字段前面的大段文本(如"手术步骤、术中出现的情况及处理:")。
  4. 签名图片没有最大尺寸限制;"手术者签名"字段不在 ReportEditor 表单中显示,无法受控管理签字状态。
  5. 点击"完成报告"时缺少对签名状态的确认提示。

B. 产生问题原因

  1. 删除字段时使用了 target.remove() 直接操作 DOM绕过了浏览器的原生撤销栈undo stack)。
  2. 插入的 smart-field-wrapperinline-block 元素,但其后缺少行内锚点文本节点,浏览器在特定光标位置插入时容易将其挤到新行。
  3. keydown 拦截逻辑中 target.remove() 同样会误删父级块节点WebKit 在边界处对 contenteditable="false" inline 元素的处理缺陷)。
  4. surgeonSignature 字段原先 visibleInForm: false,且签名图片样式仅用 height: 2.4em 约束,没有 max-width/max-height 的硬限制。
  5. 完成报告逻辑中缺少针对签名字段的业务校验。

C. 解决问题方案

  1. 撤销栈修复:将点击红 × 删除和键盘 Backspace/Delete 删除全部改为 Range.selectNode(target) + document.execCommand('delete')。这样浏览器会将删除操作记录到撤销栈中,execCommand('undo') 可以正确恢复。
  2. 防换行:在 insertSmartFielddefaultContent.tssmartField() 生成的 HTML 末尾增加 &#8203;(零宽空格),作为稳定的行内锚点,防止字段被浏览器排到新行。
  3. 精准键盘删除:配合 Range.selectNode + execCommand('delete'),不再直接 remove() DOM 节点,彻底避免误删父级 <p> 的问题。
  4. 签名尺寸与字段管理
    • types.ts 中将 surgeonSignature 改为 visibleInForm: true, isSystemLocked: false,使其出现在字段管理和右侧表单中。
    • 新增 isSigned 字段(单选:已签字 / 未签字,默认"未签字")。
    • 签名图片样式改为 max-width: 120px; max-height: 40px; object-fit: contain;,并在打印样式和 print.ts 中同步。
  5. 签名同步逻辑重构ReportEditorsurgeonSignature 的渲染由 isSigned 控制:
    • 已签字currentUser.signature 存在 → 显示签名图片。
    • 已签字 但无签名图 → 显示 "【请上传电子签】"。
    • 未签字 → 显示 "【未签字】"。
  6. 完成报告签名校验saveReport('completed') 中,若模板包含 surgeonSignature
    • 未选择"已签字" → confirm 弱阻断提示。
    • 已选择"已签字"但无签名图 → confirm 弱阻断提示。
    • 用户点击"取消"则中断保存,点击"确定"仍可继续保存。

D. 后续如何避免问题

  • contentEditable 中删除元素时,优先使用 Range.selectNode + execCommand('delete') 而非直接 remove(),以确保撤销/重做等原生编辑行为正常工作。
  • 插入 inline-blockinline-flex 控件时,可在其后追加 &#8203; 零宽空格,为浏览器提供稳定的行内文本锚点,减少排版异常。
  • 任何需要从"不可见"改为"可见/可配置"的字段,应在 DEFAULT_FORM_FIELDSReport 类型reportData 初始值 三处同步更新,防止表单渲染遗漏。
  • 对于图片类嵌入内容,应使用 max-width/max-height + object-fit: contain 做硬约束,避免不同来源图片破坏页面布局。

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

A. 具体问题

  1. TemplateManage 中删除智能字段(通过红 × 或 Backspace/Delete点击工具栏的"撤销"按钮无法恢复字段,"重做"也失效。
  2. 点击右侧字段库按钮插入字段时,字段经常跳到下一行或文档末尾。

B. 产生问题原因

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

C. 解决问题方案

  1. 自定义 Undo/Redo 栈
    • TemplateManage.tsx 中引入 undoStackredoStack 两个 useRef<string[]>([])
    • 实现 pushHistory(),在执行任何结构性变更(删除字段、插入字段、插入表格/图片、格式化命令)前将当前 editorRef.current.innerHTML 推入 undo 栈并清空 redo 栈。
    • 实现 handleUndo() / handleRedo(),直接替换工具栏按钮的 execCmd('undo') / execCmd('redo') 调用。从栈中取出历史 HTML 字符串并赋值给 editorRef.current.innerHTML,再调用 saveTemplateContent() 同步到 React state 和 localStorage
  2. 阻止焦点流失
    • 在所有工具栏按钮和字段库插入按钮上增加 onMouseDown={(e) => e.preventDefault()},阻止 mousedown 默认行为导致编辑器失去焦点。
  3. 光标位置记忆与恢复
    • 利用已有的 savedRangeRef,实现 saveSelection()restoreSelection()
    • 在编辑器 <div> 上绑定 onBlur={saveSelection}onMouseUp={saveSelection}onKeyUp={saveSelection},持续记录光标位置。
    • insertSmartFieldinsertImage 中,执行 insertHTML 前先调用 restoreSelection() 恢复光标,确保字段插入到正确的位置。

D. 后续如何避免问题

  • 对于 contentEditable 编辑器中的结构性变更(插入/删除特殊节点),如果原生 undo 不可靠,应尽早实现自定义历史栈(基于 HTML 字符串快照),完全接管撤销/重做逻辑。
  • 侧边栏/工具栏按钮与编辑器共存时,必须通过 onMouseDown={e => e.preventDefault()} 或等价手段阻止焦点流失,这是保证光标位置不丢失的最简单有效方案。
  • 插入操作前恢复 savedRangeRef 可以作为焦点流失后的兜底保险,两者结合使用效果最佳。

记录 21TemplateManage 快捷键 Undo/Redo 与字段插入排版修复

A. 具体问题

  1. TemplateManage 中删除 smart-field-wrapper 后按键盘 Ctrl+Z 无法撤销,但点击工具栏撤销按钮可以恢复。
  2. 当目标段落以 <br> 结尾时,从字段库插入 smart-field-wrapper 会被拆到下一行(<span> 跑到了 <p> 外部)。

B. 问题产生原因

  1. keydown 事件监听器只拦截了 Backspace/Delete未拦截 Ctrl+Z/Ctrl+Y导致浏览器原生 undo 与自定义 undoStack/redoStack 完全脱节。
  2. insertSmartField 使用 document.execCommand('insertHTML')WebKit/Blink 在块级元素末尾存在 <br> 时,会自动将插入的 inline <span> 修正到块级元素外部,造成排版错位。

C. 解决问题方法

  1. 快捷键拦截:在 keydown 监听的最开头增加 Ctrl+Z / Cmd+Z / Ctrl+Shift+Z / Ctrl+Y 的拦截,调用 e.preventDefault() 后路由到 handleUndo() 或 handleRedo()。
  2. 精确 Range 插入:将 insertSmartField 的插入方式从 execCommand('insertHTML') 替换为手动 Range.insertNode()
    • restoreSelection() 恢复光标;
    • Range.deleteContents() 清空当前选区;
    • 将 HTML 字符串转为 DocumentFragment
    • Range.insertNode(fragment) 精确插入到 Range 位置;
    • setStartAfter(lastNode) 把光标移动到插入内容末尾。

D. 经验与教训总结

  • 在 contentEditable 中实现自定义撤销栈时,必须同时拦截界面按钮和键盘快捷键的 undo/redo否则两套历史机制会互相冲突。
  • document.execCommand('insertHTML') 对块级元素边界(尤其是 <br> 结尾)的自动修正行为不可控;需要精确插入时,应优先使用 Range.insertNode() 手动操作 DOM。
  • 任何对 contentEditable 的 DOM 修改后都应同步保存内容saveTemplateContent确保 localStorage 中的模板数据与编辑器状态一致。

记录 22TemplateManage 字段体系升级与双向交互联动

A. 具体问题

  1. 新增字段时单选/多选分类仍显示"文本"选项,联动逻辑错误。
  2. 默认模板中存在大量静态灰色占位文本(术前诊断、术后诊断等),无法与右侧表单双向绑定。
  3. 字段管理列表平铺展示,无分组折叠,系统字段选项不可修改。
  4. 图片占位符只能通过本地上传填充,无法使用签名图或系统素材。
  5. 编辑器中的智能字段与右侧侧边栏完全无联动。

B. 问题产生原因

  1. newFieldForm.category onChange 时未正确过滤 type select 的 options。
  2. DEFAULT_FORM_FIELDS 缺少术前/术后诊断等临床字段,导致 defaultContent.ts 只能写死占位文本。
  3. 字段管理 UI 未按 category 分组,也未提供编辑系统字段选项的入口。
  4. ReportEditor.tsx 中图片占位符点击后直接调用 input.click(),缺少多渠道选择机制。
  5. TemplateManage.tsxhandleEditorClick 仅处理了删除逻辑,未处理点击高亮/导航。

C. 解决问题方法

  1. 类型联动修复category onChange 时强制设置对应 type单选→single_select、多选→multi_select、图片→imagetype select 使用条件渲染,只显示当前 category 支持的选项。
  2. 扩展默认字段:在 types.ts 追加 preoperativeDiagnosispostoperativeDiagnosispostOpConditionspecimenDescriptionpathologyCheckfrozenPathologyhospitalLogo 等系统字段,全部 isSystemLocked: true
  3. 替换模板占位文本:在 defaultContent.ts 中将所有灰色占位文本替换为 smartField(...)Logo 替换为带 data-bind="hospitalLogo"image-placeholder
  4. 字段管理折叠与编辑:新增 expandedCategories 状态实现折叠面板;新增 editingFieldKey 等状态实现点击编辑(系统字段 label 只读、选项可编辑)。
  5. 素材库与图片字段FieldType 扩展 'image';初始化时自动将 Logo 转 Base64 存入 imageAssetsinsertSmartField 对图片类型插入 image-placeholder
  6. 图片来源选择弹窗ReportEditor.tsx 点击图片占位符弹出 Modal支持本地上传、我的签名、系统素材三选一。
  7. 编辑器-侧边栏双向联动:点击 smart-field-wrapper 时读取 data-bind,高亮并滚动定位到右侧对应字段,自动展开分组。

D. 经验与教训总结

  • category→type 的联动应在 state 变更层强制收敛,而不是仅依赖 JSX 条件渲染。
  • 升级静态占位文本为字段时,必须同步修改 DEFAULT_FORM_FIELDSdefaultContent.tsformFieldsConfig
  • 图片字段与普通文本字段的 DOM 结构差异大,插入逻辑需要按 type 分支。
  • 编辑器与侧边栏联动建议使用 scrollIntoView + 临时 CSS 类,避免复杂的状态同步。
  • 新增 localStorage key 时应提供合理的默认值或降级处理。

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

A. 具体问题

  1. template-manage 的"插入字段"中仍存在"图片"分类手术者签名、医院Logo用户认为不再需要。
  2. 插入图片占位符时无法自定义默认宽高,且使用 <div> 导致强制换行。
  3. 占位符框太小时"插入/点击放置图片"文字显示不全。
  4. 默认模板中签名和 Logo 的结构不统一(一个是 smartField,一个是 div.image-placeholder)。
  5. template-manage 点击图片占位符直接调起本地文件选择器,与 report-editor 的三选一弹窗行为不一致。

B. 问题产生原因

  1. DEFAULT_FORM_FIELDS 仍包含 surgeonSignaturehospitalLogo
  2. 两端编辑器的 insertImage() 使用块级 <div> 插入,未提供尺寸 prompt。
  3. 占位符提示文本固定为长文本,未根据容器宽度做缩写适配。
  4. TemplateManage 的 placeholder 点击事件直接调用 triggerPlaceholderUpload(),缺少与 ReportEditor 一致的弹窗组件。

C. 解决问题方法

  1. 清理图片字段:从 DEFAULT_FORM_FIELDStypes.ts 中移除 surgeonSignaturehospitalLogo;在 TemplateManage.tsx 的插入字段/字段管理/新增字段表单中彻底移除"图片"分类。
  2. 统一默认模板:在 defaultContent.ts 中将 Logo 和签名均替换为 <span class="image-placeholder" style="display:inline-flex;...">
  3. 改造 insertImage():在 TemplateManage.tsxReportEditor.tsx 中,插入前通过 prompt 获取最大宽度/高度px生成带 max-width/max-height<span> 行内占位符;提示文字中附加"正文一行文字高度约为 20 像素左右"。
  4. 文本自适应:根据 prompt 输入的宽度决定提示文字:宽度 < 80px 时显示"插入图片",否则显示"插入/点击放置图片"。
  5. 统一弹窗行为:将 ReportEditorimagePickerOpen / imagePickerTarget / fillPlaceholderSrc 逻辑完整移植到 TemplateManage;删除旧的 triggerPlaceholderUpload 直接上传逻辑;两端点击图片占位符均弹出"本地上传 / 我的签名 / 系统素材"三选一弹窗。
  6. 优化填充样式fillPlaceholderSrc 中给 <img> 增加 max-width:100%; max-height:100%; object-fit:contain;,避免撑破设置了固定尺寸的占位符。

D. 经验与教训总结

  • 当从字段体系中彻底移除某一分类时,需要同时清理:DEFAULT_FORM_FIELDS、UI 渲染数组、新增表单 options、以及可能残留的分类判断逻辑如编辑字段时显示 options 输入框的条件)。
  • contentEditable 中实现"同行插入"必须使用行内元素(<span>)并显式设置 display:inline-flex + vertical-align:middle;块级 <div> 即使通过 CSS 改 display 也可能因浏览器 execCommand 修正导致换行。
  • 跨页面/跨编辑器的一致交互(如图片选择弹窗)应抽取为可复用逻辑或至少保持代码结构一致,避免用户在不同页面产生认知割裂。
  • prompt 虽不是最优雅的用户交互,但在工具栏快捷操作中是一种零依赖、快速落地的方案;若后续需要更复杂交互,可再替换为 Modal 组件。

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

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?: stringtimeDefault?: 'current' | 'specific'。现有字段补充默认值(surgeryDateYYYY-MM-DD+specificstartTime/endTime24h+specific);新增系统字段 reportDateYYYY年MM月DD日+current)。
  2. TemplateManage UI 增强
    • 新增字段表单category 为「时间」时显示「默认值」select手动选择/当前时间和「显示格式」selectdate 提供两种日期格式time 提供 24h/12h
    • 字段编辑面板:点击已有时间字段进入编辑模式时,可修改上述两项配置。
  3. ReportEditor 自动填充:新增 useEffect 监听 formFields,对 timeDefault === 'current' 且值为空的字段,自动填充系统当前日期/时间。
  4. ReportEditor 表单渲染重构
    • startTime/endTime:根据 timeFormat 选择 hour select 的选项范围24h: 00-2312h: 01-1212h 时额外增加 AM/PM select。存储仍保持 24hstartHour/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-1702:30 下午14:30),转回原始值后存入 reportData
  7. 默认模板更新defaultContent.ts 底部静态「年 月 日」替换为 ${smartField('reportDate')}

D. 后续如何避免问题

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

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

A. 具体问题 用户提出 4 个改进需求:

  1. 默认模板底部「撰写时间」文字前缀与 smartField 占位符重复,需删除前缀仅保留占位符;
  2. 多选类和时间类字段在 TemplateManage 字段管理中仍可修改名称,应锁定为系统字段;
  3. 「手动选择」文案歧义,应改为「固定时间」;
  4. 时间格式应从固定下拉选项改为支持自定义格式输入(类似单选新增选项策略),并支持为「固定时间」设置默认值。

B. 产生问题原因

  1. defaultContent.ts 中底部 HTML 写死了 撰写时间:${smartField('reportDate')},导致编辑器中显示重复文字。
  2. DEFAULT_FORM_FIELDSsurgeryDatestartTimeendTimesurgeon 等字段的 isSystemLockedfalse,字段库允许修改 label。
  3. 早期实现时默认将时间默认值策略命名为「手动选择」,语义不够精确。
  4. 日期/时间格式仅通过固定 <select> 提供预设选项(如 YYYY-MM-DD24h),无法覆盖用户自定义需求(如 YYYY/MM/DDhh:mm A 等)。
  5. 当默认值策略为「固定时间」时,系统无法自动填充用户指定的固定值到报告表单中。

C. 解决问题方案

  1. 删除前缀defaultContent.ts 中将底部 HTML 从 撰写时间:${smartField('reportDate')} 改为仅 ${smartField('reportDate')}
  2. 系统锁定types.tsDEFAULT_FORM_FIELDSsurgeryDatestartTimeendTimereportDatesurgeonassistantanesthesiologist 全部改为 isSystemLocked: true
  3. 文案修改TemplateManage.tsx 中所有「手动选择」改为「固定时间」。
  4. 自定义格式输入
    • types.tsFormField 增加 fixedTimeValue?: string
    • TemplateManage.tsx 的时间格式 UI 改为「下拉 + 自定义输入」双模式:
      • formatInputMode: 'select' | 'custom',默认 select
      • 选择「自定义」时显示 <input>,用户可自由输入格式字符串;回车后将输入值加入候选列表并设为当前值。
      • 预设候选包含常用格式:YYYY-MM-DDYYYY年MM月DD日YYYY/MM/DD24h12hhh:mm AHH:mm
    • 通用化显示函数:
      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)的遗留字段,在通用化处理时需保留特殊分支,避免破坏现有数据结构。

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

A. 具体问题 用户发现 3 个时间字段配置与报告编辑器的联动断层:

  1. 模板管理中新建日期字段时默认格式为 YYYY-MM-DD,缺少中文格式 YYYY年MM月DD日;新建时间字段时默认格式为不可解析的 '24h'
  2. 在模板管理中将时间字段设为「固定时间」并填写固定值后,进入报告编辑器新建报告时,该固定值未自动填充到表单中。
  3. 在模板管理中将 startTime 格式改为 hh:mm A12小时制报告编辑器中的手术开始时间表单仍显示为 24 小时制下拉框,未联动切换。

B. 产生问题原因

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

C. 解决问题方案

  1. 修正默认格式
    • TemplateManage.tsx 中新建字段的默认格式改为:
      setNewFieldTimeFormat(t === 'date' ? 'YYYY年MM月DD日' : 'HH:mm');
      
    • 重置表单时的默认值同步修正。
  2. 注入固定时间默认值
    • ReportEditor.tsx 初始 reportDatasurgeryDatenew Date() 改为空字符串 ''
    • 切换模板的 useEffect 中,在构建 nextReportData 后增加遍历 formFields 的逻辑:
      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 中:
      const is12h = field.timeFormat ? (field.timeFormat.includes('hh') || field.timeFormat.includes('A')) : false;
      
    • 这样无论格式是 12hhh:mm Ahh:mm 还是用户自定义的 hh时mm分 A,只要包含 hhA 就自动切换为 12 小时制表单。

D. 后续如何避免问题

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

记录 27DEFAULT_FORM_FIELDS 遗留 '24h' 默认值导致报告显示异常 + 格式选项未分类 + 编辑面板点击失效

A. 具体问题

  1. template-manage 字段管理中,时间字段的格式 datalist 只显示 YYYY-MM-DD24h,缺少 YYYY年MM月DD日HH:mm/hh:mm A
  2. report-editor 中手术终止时间 smart field 显示为 "24h" 字样,而非正常时间值。
  3. template-manage 字段管理中,点击底部字段进入编辑模式后,部分输入框/下拉框点击无响应,需手动滚动后才能获取焦点。

B. 产生问题原因

  1. DEFAULT_FORM_FIELDS 遗留旧值types.tsstartTimeendTimetimeFormat 仍被硬编码为 '24h'(历史遗留简写别名)。当新用户登录或重置系统时,该值被加载到 formFieldsConfig 中。ReportEditor.tsxformatTimeDisplay 函数用 '24h' 作为格式模板进行 token 替换,但 '24h' 中不含 HH/hh/mm/A 等任何可替换 token函数直接原样返回 '24h',导致编辑器中显示 "24h"。
  2. customTimeFormats 未按类型过滤TemplateManage.tsx 的 datalist 直接渲染了 customTimeFormats 数组中的所有格式(日期和时间混在一起)。当用户编辑 time 字段时,会看到 YYYY-MM-DD 等日期格式;编辑 date 字段时,会看到 HH:mm 等时间格式,选项混乱。
  3. 布局突变导致点击穿透失效:字段管理列表位于 overflow-y-auto 滚动容器内。点击字段卡片后,内部编辑表单展开,高度瞬间增加。若卡片原本位于可视区域底部边缘,新出现的输入框可能刚好处于容器裁剪区域之外,浏览器 hit-testing 无法将点击事件正确路由到输入框上。

C. 解决问题方案

  1. 修正默认值types.tsstartTime/endTimetimeFormat'24h' 改为 'HH:mm'
  2. 兼容兜底ReportEditor.tsxformatTimeDisplay 开头增加 if (fmt === '24h') fmt = 'HH:mm';,防止已有用户的 formFieldsConfig 中仍残留 '24h' 导致显示异常。
  3. 清理旧缓存TemplateManage.tsx 初始化 customTimeFormats 时,对 savedFormats 增加 .filter(f => f !== '24h' && f !== '12h'),自动清理历史遗留的无效旧格式。
  4. 按类型过滤 datalist:编辑字段和新增字段的 format <datalist> 渲染时,增加 .filter
    .filter(fmt => {
      const isDateFormat = /YYYY|MM|DD/.test(fmt);
      const isTimeFormat = /HH|hh|mm|A/.test(fmt);
      if (field.type === 'date') return isDateFormat;
      if (field.type === 'time') return isTimeFormat;
      return true;
    })
    
  5. 自动滚动对齐:字段卡片 onClick 中,在设置完编辑状态后增加 setTimeout(() => { e.currentTarget.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, 50);,确保编辑面板展开后卡片位于可视区域内。

D. 后续如何避免问题

  • 当将格式简写别名(如 '24h')迁移为标准 token 格式(如 'HH:mm')时,必须全局搜索所有硬编码默认值(DEFAULT_FORM_FIELDS、测试数据、mock 数据等),确保源头不再产生脏数据。
  • customTimeFormats 这类用户可扩展的缓存数组,在初始化时应建立无效值清理机制,防止历史版本残留的数据污染后续 UI。
  • datalist / select 的选项如果存在明显的类型分组(日期 vs 时间),应在渲染层做过滤,而不是将所有选项平铺展示。
  • 任何在滚动容器内通过点击展开/折叠的交互组件,都应考虑增加 scrollIntoView 兜底,防止布局突变导致的点击失效问题。

记录 28原生 datalist 交互体验差、表格内 execCommand 插入破坏结构、打印分页边距失效

A. 具体问题

  1. template-manage 字段管理中,时间字段的格式输入使用原生 <input list> + <datalist>,浏览器下拉体验差,部分浏览器不会自动展示全部选项。
  2. template-manage 编辑器表格中点击"插入图片占位符"后HTML 结构被破坏——外层 <span class="image-placeholder"> 丢失,仅剩内部子元素散落为 <td> 的直接子元素。
  3. report-editor / report-view 打印多页报告时,第二页及后续页面的上下边距几乎为 0内容紧贴纸张边缘。

B. 产生问题原因

  1. 原生 datalist 局限性:不同浏览器对 <datalist> 的展示逻辑不一致Edge/Chrome 中聚焦时不会自动展开全部选项,且不支持样式自定义,无法提供一致的下拉选择体验。
  2. execCommand 在表格中的自动修正document.execCommand('insertHTML')<td> 内处理复杂的 inline-flex 嵌套 <span>WebKit/Blink 会将其自动"拍平"或重新排列。外层 contenteditable="false" 的 inline 容器被浏览器移除,仅剩内部子元素散落。
  3. @page margin 与 body padding 的分页陷阱@page { margin: 0 } 将物理纸张边距设为 0body { padding: 10mm } 只在整个 HTML 文档的顶部和底部各生效一次。当内容跨页时,浏览器在分页切断处不会保留 body 的 padding导致第二页顶部和底部紧贴纸张边缘。@page 的 margin 才是为每一张物理纸张独立分配边距的正确方式。

C. 解决问题方案

  1. 自定义下拉组件:放弃原生 input[list] + datalist,改为手写 input + 绝对定位 div 列表组件:
    • onFocussetDropdownOpen(true) 展开列表
    • onMouseDown + e.preventDefault() 阻止失焦,实现点击选项填充
    • onBlur(延迟 200ms时保存手写的新格式到 customTimeFormats
    • 列表项通过 .filterdate/time 类型过滤显示
  2. 表格检测 + 块级容器:在 insertImage 中通过 window.getSelection().anchorNode 向上遍历检测是否在 <td> / <th> 内:
    • 若在表格内:不弹出 prompt使用 <div> 块级容器 + width:100%;height:100%;max-width:200px;max-height:200px;
    • 若不在表格内:保持现有 <span> 行内容器 + prompt 输入自定义宽高
  3. 打印边距修正print.ts 中:
    • @page { margin: 15mm 10mm; } 让打印引擎为每一页纸张独立分配上下 15mm / 左右 10mm 边距
    • body { padding: 0; } 清除 body padding
    • .content { width: 100%; } 让内容自然撑满可用区域

D. 后续如何避免问题

  • <input list> + <datalist> 的交互体验无法满足需求时,应尽早替换为自定义下拉组件,避免在不同浏览器中产生不一致的行为。
  • document.execCommand('insertHTML') 对块级元素边界(尤其是 <td> 内)的自动修正行为不可控;在表格等复杂容器内插入 HTML 时,应优先使用块级标签(如 <div>)作为外层容器,减少被浏览器重新排列的风险。
  • 打印样式的边距控制必须使用 @page { margin: ... } 而非 body { padding: ... },前者会让打印引擎为每一页物理纸张独立分配边距,后者只在文档首尾生效一次。

记录 29拖拽关键帧样式遗漏、占位符分类隔离与 Modal 弹窗改造

A. 具体问题

  1. 拖拽关键帧到 .image-placeholder 后,虚线边框和灰色背景未消失,且图片可能溢出占位符。
  2. insertImageinsertTable 使用浏览器原生 prompt 弹窗,交互体验差。
  3. 所有占位符一视同仁,自动帧插入和一键插入会把手术关键帧填入 Logo、签名等静态图片位置。

B. 产生问题原因

  1. fillPlaceholder 遗漏样式清除fillPlaceholderSrc(点击上传路径)设置了 border='none'background='transparent',但 fillPlaceholder(拖拽路径)遗漏了这两行,且图片 style 缺少 max-height:100%;object-fit:contain;
  2. 原生 prompt 的限制prompt 弹窗无法自定义样式,且在不同浏览器中表现不一致,用户体验差。
  3. 占位符无分类机制:所有 .image-placeholder 都接受关键帧填充,没有区分"接受自动插入"和"不接受自动插入"的占位符。

C. 解决问题方案

  1. 补齐 fillPlaceholder:增加 placeholder.style.border = 'none'placeholder.style.background = 'transparent',图片 style 改为 max-width:100%;max-height:100%;object-fit:contain;
  2. 自定义 Modal 替代 prompt
    • 新增 placeholderModal 状态isOpen, width, height, modetableModal 状态isOpen, rows, cols
    • insertImageinsertTable 改为打开 Modal。
    • Modal 使用项目统一的 bg-black/50 backdrop-blur-sm + bg-white rounded-2xl 风格。
  3. 占位符分类隔离
    • Modal 中增加模式选择「手术影像占位frame」和「静态图片占位manual」。
    • manual 模式生成的 placeholder 带有 data-mode="manual" 属性。
    • autoCaptureFramesinsertFrameToPlaceholder 的选择器增加 :not([data-mode="manual"])
    • handleDrop 中拦截 manual 占位符的拖拽,弹出提示。

D. 后续如何避免问题

  • 当同一填充逻辑存在多个入口(点击上传、拖拽、自动插入)时,务必确保所有入口的后续处理完全一致,避免某一路径遗漏样式清除。
  • 原生 prompt/confirm/alert 在现代 Web 应用中应尽量避免使用,优先采用自定义 Modal 组件,以获得一致的视觉体验和更灵活的控制能力。
  • 当系统中存在"自动填充"机制时,应考虑为被填充的容器增加分类标记(如 data-mode),并在自动填充逻辑中通过选择器过滤,防止无关区域被污染。

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

A. 具体问题 默认模板 defaultContent.ts 中的 8 个 .image-placeholder(医院 Logo、6 个表格内术中影像、手术者签名)使用的是旧版 HTML 结构,缺少 data-mode="frame|manual" 属性。新建报告加载默认模板后,签名和 Logo 区域可被关键帧拖拽误填充;自动帧插入时也会将术中截图插入签名位置。

B. 产生问题原因 此前对「插入图片占位符」进行弹窗改造时,仅在运行时插入逻辑中新增了 data-mode 属性,但未同步回刷默认模板 defaultContent.ts。导致默认模板产出的占位符与新插入的占位符结构不一致,图片来源隔离机制在默认模板场景下完全失效。

C. 解决问题方案defaultContent.ts 中对 8 个占位符做最小化修补:

  1. 医院 Logo65×65和手术者签名200×40添加 data-mode="manual",标记为静态图片占位。
  2. 表格内 6 个术中影像占位符100%×150添加 data-mode="frame",标记为手术影像占位。
  3. 签名占位符宽度 200px ≥ 80px按新弹窗规则将提示文本从「插入图片」更新为「插入/点击放置图片」。
  4. 所有占位符的 width/height/margin/display 等布局属性绝对保持不变。

D. 后续如何避免问题

  • 当为 image-placeholder 引入新的核心属性(如 data-modedata-allow-source)时,必须同步检索 defaultContent.ts 和任何预置模板文件,确保静态模板中的占位符结构与运行时插入逻辑保持一致。
  • 默认模板修改后,应通过「新建报告 → 检查 DOM」快速验证所有占位符是否携带了最新属性。

记录 31六项 UI/UX 优化集中实施

A. 具体问题 用户提出六项体验优化需求:基础信息字段打印无下划线、编辑器字段联动高亮、视频上传按钮整合、视频面板间距紧凑化、签名与日期之间空行、图片占位符填充后高度自适应。

B. 产生问题原因 均为长期使用中积累的交互和排版细节问题:

  1. 默认模板的基础字段(姓名/性别/年龄/科别/床号/住院号)打印时默认带下划线,但临床场景中这些字段通常不需要下划线。
  2. 编辑器中点击正文 field-value 后右侧没有视觉反馈,用户不知道对应哪个输入框。
  3. 视频上传按钮独立占一行,浪费垂直空间。
  4. 视频面板各区域间距过大,挤压了关键帧列表的展示空间。
  5. 签名和日期之间缺少空行,排版拥挤。
  6. 图片占位符填充后仍保留固定高度(如 200px导致图片下方出现大片空白。

C. 解决问题方案

  1. 基础字段无下划线:在 defaultContent.tssmartField() 中硬编码 6 个 keypatientName, patientGender, patientAge, department, bedNumber, hospitalId),自动注入 .no-underline 类;同时保留 hasUnderline 配置机制供 TemplateManage 自定义。
  2. 字段联动高亮:新增 activeFieldKey 状态;点击 field-value 时设置该状态并滚动到对应 id={input-${bindKey}} 元素为右侧所有字段类型text/date/single_select/multi_select/time的容器统一添加 p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}
  3. 视频按钮整合:删除独立的大按钮,在缩略图滚动容器的首位插入缩小版按钮(shrink-0 w-24 h-[68px]),样式与视频卡片一致。
  4. 视频间距紧凑:将 space-y-4 逐层改为 space-y-2;关键帧摘取标题区域改为 pt-1 border-t border-border
  5. 签名空行:在签名 <p> 和日期 <p> 之间插入 <p style="margin:0;padding:0;line-height:1.5;">&nbsp;</p>
  6. 占位符高度自适应:在 fillPlaceholderSrcfillPlaceholderautoCaptureFramesReportEditor以及 fillPlaceholderSrcTemplateManage填充图片后统一设置 placeholder.style.height = 'auto'; placeholder.style.width = 'auto'; placeholder.style.lineHeight = 'normal';,并将图片 style 中的 max-height:100%;object-fit:contain 改为 height:auto

D. 后续如何避免问题

  • 当为 image-placeholder 修改填充后的样式行为时,必须同步检索所有填充入口(fillPlaceholderSrcfillPlaceholder、自动帧插入、拖拽填充等),并同步到 TemplateManage.tsx
  • 右侧表单字段容器样式如果统一(如高亮背景),应在所有字段类型的渲染分支中同步添加,避免某些类型遗漏。
  • 默认模板修改后应通过「新建报告 → 检查 DOM 结构」快速验证。

记录 32视频分析模块空白修复与图片占位符自适应逻辑重构

A. 具体问题

  1. 上一轮优化中将「上传视频」按钮移入了 videos.length > 0 条件渲染内部,导致无视频时整个「视频分析」面板空白,用户无法上传第一个视频。
  2. 图片占位符填充后仅将 height 设为 auto,但宽度仍保持预设值(如 200px导致图片周围有大量空白用户希望占位符能紧缩包围图片。

B. 产生问题原因

  1. 视频按钮位置错误:重构视频面板时,将上传按钮和缩略图列表全部包裹在 {videos.length > 0 && (...)} 中,未意识到上传按钮必须始终可见。
  2. 占位符尺寸逻辑不完整:此前仅将 height 改为 auto,未同步处理 width,也未利用 max-width/max-height 作为硬限制来实现等比例缩放。

C. 解决问题方案

  1. 修复视频面板:将上传按钮和缩略图列表移出 videos.length > 0 条件,使其始终渲染;仅保留视频播放器和关键帧网格在 {currentVideoIndex !== -1 && videos.length > 0 && (...)} 中条件渲染。注意:移出后需同步删除对应的 </div>)} 关闭标签,否则会导致 JSX 结构不匹配esbuild 报错「Unexpected closing tag」
  2. 重构占位符尺寸逻辑
    • 插入时:在 placeholderModal 确认插入的 styleStr 中,为 inline-block 占位符追加 max-width:${w}px;max-height:${h}px;(表格内占位符原本就有)。
    • 填充时:在 fillPlaceholderSrcfillPlaceholderautoCaptureFramesReportEditorfillPlaceholderSrcTemplateManage中统一执行以下步骤
      • 读取 placeholder.style.maxWidth || placeholder.style.widthplaceholder.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};text-align:left;vertical-align:top;justify-content:flex-start;align-items:flex-start;
    • 这样,小图片会 shrink-wrap 到实际尺寸,大图片会等比例缩小到限制范围内,且靠左上方放置。

D. 后续如何避免问题

  • 重构条件渲染的 JSX 结构时,必须仔细核对打开和关闭标签的数量和层级。建议使用编辑器格式化或 build 工具(如 esbuild立即验证。
  • image-placeholder 的尺寸逻辑涉及「创建时预设」和「填充后自适应」两个阶段,修改时必须同时考虑:
    • 创建时是否写入了 max-width/max-height
    • 填充时是否正确读取并应用这些限制值
    • 所有填充入口(本地上传、签名插入、系统素材、自动帧插入、拖拽填充)是否同步更新
  • 默认模板中的占位符如果没有 max-width/max-height,回退逻辑 || placeholder.style.width 仍能正确获取限制值,但后续修改默认模板时应注意统一添加 max-width/max-height 以显式声明意图。

记录 33四项编辑器体验优化集中实施

A. 具体问题

  1. 视频分析面板中「上传视频」按钮位于视频缩略图列表首位,不符合「先列出现有项,最后提供添加操作」的操作直觉。
  2. 图片占位符内的提示文字未在框中绝对居中,当占位符高度较大时文字明显偏上。
  3. 删除占位符内已插入的图片后,占位符保持收缩后的 width:auto; height:auto 尺寸,未恢复为原始预设大小。
  4. 点击「左对齐/居中/右对齐」按钮时,浏览器原生 execCommand('justifyLeft') 会用 <div align="left"> 包裹选区,导致包含 .field-value.image-placeholder 的段落被肢解,文字与输入框/图片强制换行分离。

B. 产生问题原因

  1. 上一轮重构视频面板时,将上传按钮移入了缩略图列表,但放在了首位而非末尾。
  2. 占位符提示文字使用默认的行内流布局居中,依赖于 line-height 和父容器的 align-items: center,在填充后 line-height 被改为 normal,导致文字不再居中。
  3. 删除恢复逻辑仅重置了 borderbackground,未恢复 widthheightlineHeight 等尺寸属性。
  4. execCommand 的对齐命令实现过于粗暴,会直接修改 DOM 树结构以创建对齐容器,无法安全地处理混合排版(文字 + 交互元素)。

C. 解决问题方案

  1. 视频按钮位置:将上传按钮从 videos.map() 之前移至之后,保持所有样式和点击逻辑不变。
  2. 占位符文字绝对居中
    • .placeholder-text 的样式统一改为 position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); display:block; width:100%;
    • 给所有表格内的 .image-placeholder 父容器添加 position:relative;inline-block 和签名占位符原本已有)
    • 修改范围覆盖 defaultContent.ts8 个占位符)、ReportEditor.tsxModal 插入 + 删除恢复)、TemplateManage.tsxModal 插入 + 删除恢复)
  3. 删除后恢复尺寸:在删除恢复逻辑中增加:
    const mw = placeholder.style.maxWidth;
    const mh = placeholder.style.maxHeight;
    if (mw) placeholder.style.width = mw;
    if (mh) { placeholder.style.height = mh; placeholder.style.lineHeight = mh; }
    placeholder.style.textAlign = 'center';
    placeholder.style.verticalAlign = 'middle';
    placeholder.style.justifyContent = 'center';
    placeholder.style.alignItems = 'center';
    
    同时根据占位符原始宽度(maxWidth || width)判断显示「插图」(<80px或「插入/点击放置图片」。
  4. 安全对齐:弃用 execCommand('justifyLeft'/'justifyCenter'/'justifyRight'),新增 changeAlignment(align) 方法:
    • 通过 window.getSelection() 获取选区
    • 使用 closest('p, div, td, h1, h2, h3, li') 找到最近的块级祖先
    • 直接设置 (block as HTMLElement).style.textAlign = align
    • 同步保存内容快照
    • 对齐按钮增加 onMouseDown={(e) => e.preventDefault()} 防止编辑器失焦

D. 后续如何避免问题

  • 当修改 image-placeholder 的创建或恢复逻辑时,必须在所有入口同步更新:defaultContent.ts(静态模板)、ReportEditor.tsx(运行时插入/填充/删除恢复)、TemplateManage.tsx(模板管理)。
  • 任何涉及 execCommand 的富文本操作都应评估其安全性,优先使用直接 DOM 样式操作(如 style.textAlignstyle.lineHeight)替代,避免浏览器原生命令对复杂 DOM 结构的不可控修改。
  • 绝对定位的居中方案(transform: translate(-50%, -50%))虽然效果稳定,但要求父容器必须带有 position: relative,修改时需同步检查所有父容器的样式。

记录 34模板导入导出迁移与 Logo 占位符替换

A. 具体问题

  1. 模板管理模块缺乏数据迁移能力:用户无法将配置好的模板(含字段管理配置)导出为文件,也无法在新建模板时通过文件导入已有配置。
  2. 默认模板顶部 Logo 虽然已是 image-placeholder,但使用的是 display:inline-flex 布局,与运行时插入的占位符(display:inline-block)样式不一致,导致交互体验不统一。

B. 产生问题原因

  1. 系统设计初期未考虑模板迁移场景Template 类型缺少 fields 属性,字段配置仅保存在全局 formFieldsConfig 中。
  2. Logo 占位符在默认模板中独立硬编码,未与运行时插入逻辑保持一致的标准结构。

C. 解决问题方案

  1. Template 类型扩展:在 src/types.tsTemplate 接口中新增 fields?: FormField[]
  2. 模板导出功能:在 TemplateManage.tsx 中新增 handleExportTemplate 函数,导出 JSON 结构包含 versiontypetitledescriptioncontentfields
  3. 模板导入功能
    • 新增 importedContent 状态({content: string; fields: FormField[]})和 fileInputRef
    • 新增 handleImportFile 函数:解析 JSON验证 type === 'surclaw_template_package',自动填充名称和描述,暂存内容和字段
    • 在新增模板 Modal 中增加导入 UI使用用户指定的 w-8 h-8 bg-accent... 样式类名)
    • 修改 handleModalSubmit:新建模板时优先使用 importedContent.contentimportedContent.fields,并同步保存到全局 formFieldsConfig
    • 切换模板时(currentTemplateId 变化),如果模板有 fields 则加载到编辑器并同步保存到全局配置
  4. Logo 占位符标准化:将 defaultContent.ts 中 Logo 的 display:inline-flex 改为 display:inline-block,统一使用 text-align:center + line-height:65px 的垂直居中方式提示文字改为「LOGO」。

D. 后续如何避免问题

  • 当扩展数据类型(如 Template 接口)时,应评估是否需要同步修改所有使用该类型的持久化/序列化逻辑(如 storage 读写、导入/导出)。
  • 默认模板中的占位符结构必须与运行时插入逻辑保持完全一致(display、居中方式、data-mode 等),任何差异都可能导致交互体验不一致。
  • 新增文件上传/导入功能时,必须在 onChange 事件末尾清空 e.target.value = '',否则同一文件无法重复选择。

记录 35字段默认不下划线与占位符文字居中修复

A. 具体问题

  1. 模板管理中新增字段时,「打印时显示下划线」复选框默认勾选,用户希望改为默认不勾选。
  2. 删除图片占位符中的图片后,提示文字(如「插入/点击放置图片」)在虚线框内偏左,未真正居中。

B. 产生问题原因

  1. newFieldHasUnderlineeditFieldHasUnderlineuseState 默认值为 trueinsertSmartField 中的判断逻辑是 field.hasUnderline === false ? ' no-underline' : '',导致只有显式关闭时才无下划线。
  2. 虽然给 .placeholder-text 使用了 position:absolute + transform:translate(-50%, -50%) 实现居中,但元素本身设置了 display:block; width:100%,其内部文本流默认 text-align:left,导致文字靠左。
  3. 上一轮对 TemplateManage.tsxhandleEditorClick 删除恢复逻辑的修改未完全生效,该文件中的删除恢复逻辑仍使用旧代码(无 absolute 定位、无尺寸恢复)。

C. 解决问题方案

  1. 字段默认不下划线
    • src/pages/TemplateManage.tsxnewFieldHasUnderlineeditFieldHasUnderline 默认值从 true 改为 false
    • src/pages/TemplateManage.tsxinsertSmartField 中判断改为 field.hasUnderline !== true ? ' no-underline' : ''
    • src/pages/TemplateManage.tsx:编辑字段回显改为 field.hasUnderline ?? false
    • src/utils/defaultContent.ts:移除 noUnderlineKeys 数组,smartField() 直接给所有字段加 .no-underline
  2. 占位符文字居中
    • 在所有 .placeholder-text 的 style 中追加 text-align:center;
    • 修改范围覆盖 src/utils/defaultContent.ts8 个占位符)、src/pages/ReportEditor.tsx3 处)、src/pages/TemplateManage.tsx3 处)
    • 补全 TemplateManage.tsxhandleEditorClick 删除恢复逻辑的旧代码,添加 absolute 居中、尺寸恢复、text-align:center

D. 后续如何避免问题

  • 当修改默认值(如 useState(true)useState(false))时,应同时检查所有回显/回退逻辑(如 field.hasUnderline !== falsefield.hasUnderline ?? false),确保数据兼容性。
  • 使用 display:block; width:100% 的绝对居中元素,必须显式设置 text-align:center; 以控制内部文本流的对齐方向。
  • 批量替换字符串时,应通过 grep 验证所有匹配位置是否都已更新,避免遗漏(如此次 TemplateManage.tsx 中 handleEditorClick 的旧代码)。

记录 36七项排版与功能优化集中实施

A. 具体问题

  1. .field-value 输入框中的文字与正文不在同一基线上,视觉上向上偏移。
  2. 「姓名:」下方横线与文字之间距离过大。
  3. 「手术记录」标题与上方医院名称横线之间距离过大。
  4. Logo 占位符相对于医院名称文字整体偏右下。
  5. 导出 PDF 时浏览器默认文件名为「My Google AI Studio App.pdf」而非自定义名称。
  6. 导出 JSON 文件名中的时间戳使用 UTC 时间,不符合国内用户习惯。
  7. 模板管理模块缺乏批量操作能力,只能逐个删除/导出。

B. 产生问题原因

  1. smartField() 中使用了 vertical-align:text-bottomline-height:1.2;min-height:1.2em,导致内联块元素基线计算偏移。
  2. 姓名栏 <p>padding-bottom:1px 叠加 line-height:1.2,导致 border-bottom 距文字约 2-3px。
  3. 医院名称的 margin-bottom:8px 过大。
  4. Logo 位于 flex 容器中,使用默认的 gap:12pxalign-items:center,位置不够精确。
  5. printDocument() 虽接受 docTitle 参数并写入 iframe 的 <title>,但浏览器打印时优先使用父窗口的 document.title
  6. new Date().toISOString() 返回 UTC 时间字符串。
  7. 模板列表 UI 仅设计了单条操作按钮,未设计复选框和批量操作状态。

C. 解决问题方案

  1. 基线对齐修复
    • defaultContent.tsvertical-align:text-bottomvertical-align:baselineline-height:1.2;min-height:1.2emline-height:inherit;
    • print.ts:同步修改 .smart-field-wrapper.field-valuevertical-align:baselineline-height:inherit
  2. 姓名栏间距<p>padding:0 0 1px 0padding:0line-height:1.2line-height:1,使 border-bottom 紧贴文字
  3. 手术记录间距:医院名称 margin-bottom:8pxmargin-bottom:2pxpadding-bottom:0padding-bottom:1px
  4. Logo 微调:给 Logo 的 <span> 添加 transform:translate(-5px,-5px)
  5. PDF 文件名:在 printDocument() 中保存并临时设置 document.title = docTitle,打印完成后恢复
  6. 北京时间:统一替换所有 new Date().toISOString()new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().slice(0, 16),并保留原有的 replace(/[:.]/g, '-')
  7. 模板批量操作
    • 新增 selectedIds 状态
    • 新增 handleBatchDeletehandleBatchExport
    • 模板卡片内增加复选框(阻止冒泡避免触发选中)
    • 选中时显示批量操作浮动工具栏
    • 移除 templates.length <= 1 的单条删除限制,允许列表为空
    • 删除后自动同步 currentTemplateIdselectedIds

D. 后续如何避免问题

  • 排版微调时,应同时检查编辑器显示、打印预览两处的表现,因为 print.ts 中有独立的样式覆盖。
  • vertical-align 属性对内联块元素的基线影响显著,混合使用 text-bottommiddlebaseline 时需谨慎测试。
  • 浏览器打印的文件名行为不一致(有的用 iframe title有的用父窗口 title最稳妥的方案是在打印前后动态修改 document.title
  • 批量操作 UI 中,复选框的点击事件必须 stopPropagation(),否则会触发卡片点击导致状态混乱。
  • 批量删除后必须同步清理 selectedIdscurrentTemplateId,避免出现「选中已删除项」或「当前模板不存在」的异常状态。