- handleAIGenerate开头增加自动修正目标区域逻辑:修改模式开启且未选区域时,自动选择文档中第一个AI区域 - systemPrompt明确标注'当前处于【修改模式】/【对话模式】',并细化字段要求 - diffModal的targetId改为使用actualTargetId,确保确认注入时使用实际修正后的区域ID
47 KiB
经验记录
本文档为项目统一知识库,记录开发过程中遇到的关键问题及解决方案。每次执行修改前必须阅读,防止重复踩坑。 记录格式:A. 具体问题 → B. 产生问题原因 → C. 解决问题方案 → D. 后续如何避免问题
记录 1:report-editor 新建报告时显示空白模板
A. 具体问题
超级管理员进入 /report-editor(新建报告)时,编辑区域为纯白色空白,顶部模板选择器显示"无",但 system-settings 中已配置了默认模板。
B. 产生问题原因
ReportEditor.tsx在组件卸载时会自动将当前编辑器内容保存为草稿。即使用户未输入任何内容,保存的content也是空字符串""。- 初始化 effect 中判断草稿是否有效的条件仅使用了
typeof draft.content === 'string',空字符串满足该条件,导致编辑器被填充为空白 HTML,并将contentLoadedRef.current设为true。 - 由于
contentLoadedRef.current已被置为true,后续加载settings.defaultTemplate的默认模板分支被完全跳过。
C. 解决问题方案
- 在
saveDraftToStorage中将当前loadedTemplateId一并存入 draft。 - 将四处草稿恢复的判断条件从
typeof draft.content === 'string'收紧为typeof draft.content === 'string' && draft.content.trim().length > 0。 - 恢复草稿时同步执行
setLoadedTemplateId(draft.loadedTemplateId || '')。
D. 后续如何避免问题
- 在前端使用 contentEditable 的自动保存机制时,保存和恢复草稿都应增加对空/仅空白内容的过滤。
- 若草稿与某个业务状态(如当前模板 ID)强关联,应确保两者一并持久化和恢复,避免状态不一致。
记录 2:关键帧一键插入占位符功能实现
A. 具体问题
用户希望视频分析面板中的关键帧截图除了拖拽插入外,还能通过点击 "插入" 按钮一键自动填充到编辑器中第一个空置的 image-placeholder。
B. 产生问题原因
原先仅支持拖拽方式将关键帧放入占位符。当关键帧数量多或占位符位置较远时,操作不便。且 handleDrop 中的填充逻辑未抽离,无法被其他交互方式复用。
C. 解决问题方案
- 将
handleDrop中的 HTML 填充逻辑抽离为fillPlaceholder(placeholder, frame)公共函数。 - 新增
insertFrameToPlaceholder(frame)函数:通过editorRef.current.querySelector('.image-placeholder:not(.has-image)')查找第一个空置占位符。 - 在关键帧卡片底部新增 "插入" 按钮,使用
opacity-0 group-hover:opacity-100 transition-opacity,并通过e.stopPropagation()避免触发卡片的视频跳转onClick。
D. 后续如何避免问题
- 当同一交互效果需要支持多种触发方式时,应将核心逻辑抽离为独立函数,避免重复代码。
- 在可点击子元素上务必注意事件冒泡控制,防止触发父级不必要的副作用。
记录 3:路由切换后视频分析图片丢失
A. 具体问题
在 /report-editor 中上传视频、自动摘取关键帧后,切换到 /report-manage 再返回 /report-editor,右侧「视频分析」面板中的所有截图和关键帧全部消失。
B. 产生问题原因
ReportEditor.tsx在组件卸载时通过stateRef.current保存草稿到localStorage。- 初始化
useEffect从 draft 恢复数据时,仅通过setState更新了 React state,但 没有同步更新stateRef.current。 - 离开页面时,
stateRef.current仍保存着初始值(空数组),导致saveDraftToStorage()用空数组覆盖了 localStorage 中的 draft。
C. 解决问题方案
在 ReportEditor.tsx 的所有数据恢复入口中,恢复 reportData、videos、capturedFrames 后立即同步赋值给 stateRef.current。
D. 后续如何避免问题
- 当使用
useRef作为「自动保存」的数据快照时,任何从持久化存储恢复数据到 React state 的操作,必须同步更新对应的 ref。 - 在涉及草稿/自动保存的功能中,应定期审查所有数据恢复路径,确保 ref 与 state 的一致性。
记录 4:路由切换后报告内容、基本信息、视频分析全部丢失 + 自动帧插入 UI 延迟刷新
A. 具体问题
- 在
/report-editor中编辑报告后,切换到/report-manage再返回,报告内容变空、基本信息清空、视频分析数据全部丢失。 - 开启「自动帧插入」后,自动关键帧摘取过程中右侧关键帧列表和 placeholder 中的图片不会逐张实时更新。
B. 产生问题原因
- 数据丢失:在初始化
useEffect中,将stateRef.current的同步赋值放在了if (editorRef.current && draft.content.trim().length > 0)条件块内部。当editorRef尚未挂载或draft.content为空时,stateRef.current就得不到同步。 - UI 延迟:
autoCaptureFrames是 async 函数,内部循环中连续调用setCapturedFrames。React 18 的自动批处理机制在异步函数中会合并状态更新,DOM 重渲染被推迟到整个循环结束后。
C. 解决问题方案
- 将
stateRef.current的同步赋值移到editorRef.current/content判断条件的外部。 - 在
autoCaptureFrames的 for 循环中,将setCapturedFrames包裹在flushSync(() => { ... })中,强制每一帧被摘取后立即触发 DOM 更新。
D. 后续如何避免问题
- ref 的同步赋值绝对不能依赖于任何与 UI 渲染相关的条件判断。
- 在异步函数中需要让用户看到实时状态更新时,应使用
flushSync强制同步渲染。
记录 5:路由切换后所有内容仍然丢失——彻底重构自动保存机制
A. 具体问题
在 /report-editor 中编辑报告后,切换到 /report-manage 再返回,报告编辑器内容、基本信息、视频列表、关键帧截图全部丢失。
B. 产生问题原因
- 自动保存机制过度依赖
stateRef和contentRef作为"数据快照"。 - React 18
StrictMode在开发/预览环境下会执行"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,stateRef.current仍然是组件创建时的初始空值。 - 组件卸载(cleanup)时调用保存,用这个空值覆盖了 localStorage 中已有的正确 draft。
C. 解决问题方案
- 彻底重构
saveDraftToStorage:不再读取contentRef.current和stateRef.current,而是直接从最新的 React state 和editorRef.current?.innerHTML获取数据。useCallback的 dependency 数组包含所有相关 state,确保闭包永远绑定当前渲染周期的最新 state。 - 将
beforeunload和visibilitychange事件处理器直接绑定到saveDraftToStorage,effect 的 dependency 改为[saveDraftToStorage]。
D. 后续如何避免问题
- 永远不要将
useRef作为自动保存的唯一数据源。ref 在 React 18StrictMode的模拟卸载阶段仍然保持初始值,会导致用空数据覆盖有效持久化数据。 - 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过
useCallback+ 完整的 dependency 数组保证闭包始终新鲜。
记录 6:编辑器内容和关键帧在路由切换后仍然丢失——从 Ref 读取避免闭包陷阱和 DOM 失效
A. 具体问题
在 /report-editor 中编辑报告后,切换到 /report-manage 再返回:报告内容全部丢失;视频分析面板中的自动关键帧和手动截图全部丢失。
B. 产生问题原因
- 闭包陷阱:
saveDraftToStorage直接从 React state 读取,但代码中存在setCapturedFrames(nextFrames); saveDraftToStorage();的写法。由于setState是异步的,saveDraftToStorage闭包中读到的capturedFrames仍然是旧值。 - 卸载时 DOM 失效:组件卸载时 React 开始销毁 DOM 树,
editorRef.current可能已经变为null,content: editorRef.current?.innerHTML || ''会把空字符串保存到 draft 中。 contentRef更新遗漏:在handleEditorClick中删除 placeholder 后,直接调用了saveDraftToStorage(),但没有先更新contentRef.current。
C. 解决问题方案
- 重构
saveDraftToStorage从 Ref 读取:content优先读取contentRef.current(内存引用,卸载时仍稳定存在);reportData、videos、capturedFrames全部从stateRef.current读取。 - 补齐
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. 解决问题方案
- 将
await new Promise(...)替换为setTimeout(...),把插入操作推入事件队列异步执行。 - 实现延迟叠加(顺序插入):通过
settings.autoInsertFrameIndices.indexOf(i)计算当前帧是第几个需要插入的,延迟时间为baseDelay * (insertOrderIndex + 1)。 setTimeout回调中实时查询.image-placeholder:not(.has-image),找到则插入,并同步更新contentRef.current和调用saveDraftToStorage()。
D. 后续如何避免问题
- 在异步循环中,如果某个操作不需要依赖前一步的完成结果,绝对不要使用
await阻塞主循环,应改用setTimeout或Promise.all实现并行/异步解耦。 - 在
setTimeout等异步回调中操作 DOM 时,应在回调触发时"实时查询"目标元素,而不是在循环中提前捕获元素引用。
记录 8:关键帧在路由切换后丢失——压缩 Canvas 分辨率并增加存储错误日志
A. 具体问题 报告编辑器内容和视频列表在路由切换后能正常保留,但视频分析面板中的自动摘取关键帧和手动截图全部丢失。
B. 产生问题原因
- LocalStorage 5MB 容量限制:当前抽帧逻辑使用视频原始分辨率 + JPEG 质量 0.9,对于 1080p/4K 视频,单张 Base64 图片可达 300KB~1MB,十几张关键帧即可超过 5MB。
- 静默失败:
storage.ts中的set方法捕获了QuotaExceededError但没有任何日志,导致用户和开发者都感知不到错误。
C. 解决问题方案
- 压缩关键帧分辨率与质量:Canvas 等比缩放至最大 800px 宽,JPEG 导出质量从
0.9降到0.6。单张图片体积可从 500KB 降至 30KB~80KB。 - 增加存储错误可见性:将静默
catch改为输出console.error。
D. 后续如何避免问题
- 任何将 Base64 图片持久化到
localStorage的场景,都必须预估数据体积并对图片进行适当的分辨率/质量压缩。 - 存储层的异常捕获绝不应静默吞掉,至少要输出日志,必要时还应弹出用户提示。
记录 9:contentEditable 中实现标签锁定与输入方格的双向绑定
A. 具体问题 需要在富文本编辑器中插入"标签锁定、内容可调"的智能占位控件,使"姓名:"等固定文本不会被用户误删,同时方格内的输入能与右侧表单双向联动。
B. 产生问题原因
原生 contentEditable 区域内所有文本节点对用户都是可编辑的,无法直接保护某一段固定标签不被单独删除或篡改。
C. 解决问题方案 采用三层嵌套 HTML 结构:
- 外层
<span class="smart-field-wrapper" contenteditable="false"> - 标签层
<span class="field-label"> - 输入层
<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. 具体问题
- 插入智能字段后,字段后方会出现一个可见的空格(由
和多行模板字符串中的换行/缩进空白引起)。 - 光标位于
<p>行首且后紧跟.smart-field-wrapper时按 Backspace,WebKit 内核会直接删除整段<p>而不是仅删除字段节点。
B. 产生问题原因
insertSmartField的 HTML 字符串使用反引号多行模板,缩进和换行被浏览器解析为额外的文本节点;末尾显式拼接了 。contenteditable="false"的 inline 元素处于行边界时,WebKit 的默认编辑行为会将整个包含该元素的块级父节点一并删除。
C. 解决问题方案
- 压缩 HTML 字符串:将
insertSmartField和defaultContent.ts的smartField输出改为单行 HTML,移除所有无意义的换行和缩进,并去掉尾部的 。 - 防止内部折行:给
.smart-field-wrapper增加white-space: nowrap;。 - 拦截 Backspace/Delete:在编辑器上增加
keydown事件监听(capture 阶段)。当光标位于文本节点起始位置且前一个兄弟节点是.smart-field-wrapper时按 Backspace,主动preventDefault()并手动移除该字段节点。
D. 后续如何避免问题
- 在
contentEditable中使用document.execCommand('insertHTML', ...)插入 HTML 时,传入的字符串必须是无多余空白的紧凑单行。 - 对于
contenteditable="false"的内联控件,若放置在块级边界,务必增加键盘事件拦截。
记录 11:撤销栈修复、字段删除交互优化与签名字段闭环
A. 具体问题
- 删除智能字段后,浏览器撤销栈(Undo)失效,点击"撤销"按钮无法恢复。
- 插入字段后,字段框有时会跳到下一行。
- Backspace 键无法删除字段;Delete 键会误删字段前面的大段文本。
B. 产生问题原因
- 删除字段时使用了
target.remove()直接操作 DOM,绕过了浏览器的原生撤销栈。 - 插入的
smart-field-wrapper是inline-block元素,但其后缺少行内锚点文本节点,浏览器容易将其挤到新行。 keydown拦截逻辑中target.remove()同样会误删父级块节点。
C. 解决问题方案
- 撤销栈修复:将点击红 × 删除和键盘 Backspace/Delete 删除全部改为
Range.selectNode(target)+document.execCommand('delete')。 - 防换行:在
insertSmartField和defaultContent.ts的smartField()生成的 HTML 末尾增加​(零宽空格),作为稳定的行内锚点。 - 精准键盘删除:配合
Range.selectNode+execCommand('delete'),不再直接remove()DOM 节点。
D. 后续如何避免问题
- 在
contentEditable中删除元素时,优先使用Range.selectNode+execCommand('delete')而非直接remove(),以确保撤销/重做等原生编辑行为正常工作。 - 插入
inline-block或inline-flex控件时,可在其后追加​零宽空格,为浏览器提供稳定的行内文本锚点。
记录 12:TemplateManage 自定义 Undo/Redo 与插入字段光标定位修复
A. 具体问题
- 删除智能字段后,点击工具栏的"撤销"按钮无法恢复字段,"重做"也失效。
- 点击右侧字段库按钮插入字段时,字段经常跳到下一行或文档末尾。
B. 产生问题原因
- 即使将删除逻辑改为
execCommand('delete'),浏览器原生的 undo stack 在contentEditable中结合 React 状态更新时仍然非常脆弱,容易被清空。 - 点击侧边栏按钮会导致编辑器
blur,浏览器内部的光标位置(Selection/Range)丢失;再次focus()后光标被重置,导致insertHTML插入位置错误。
C. 解决问题方案
- 自定义 Undo/Redo 栈:引入
undoStack和redoStack两个useRef<string[]>([])。实现pushHistory(),在执行任何结构性变更前将当前editorRef.current.innerHTML推入 undo 栈。 - 阻止焦点流失:在所有工具栏按钮和字段库插入按钮上增加
onMouseDown={(e) => e.preventDefault()},阻止 mousedown 默认行为导致编辑器失去焦点。 - 光标位置记忆与恢复:利用
savedRangeRef,实现saveSelection()和restoreSelection()。在编辑器<div>上绑定onBlur={saveSelection}、onMouseUp={saveSelection}、onKeyUp={saveSelection}。
D. 后续如何避免问题
- 对于
contentEditable编辑器中的结构性变更,如果原生 undo 不可靠,应尽早实现自定义历史栈(基于 HTML 字符串快照),完全接管撤销/重做逻辑。 - 侧边栏/工具栏按钮与编辑器共存时,必须通过
onMouseDown={e => e.preventDefault()}阻止焦点流失。
记录 13:时间/日期字段格式配置与撰写时间动态字段
A. 具体问题
- 时间/日期字段缺少配置:date 可选显示格式;time 可选 24h / 12h 显示格式;两者均可选「当前时间」或「固定时间」作为默认值策略。
- 默认模板底部写死的「年 月 日」改为动态「撰写时间」智能字段,自动取当前日期。
B. 产生问题原因
FormField数据结构缺少格式和默认值配置字段。ReportEditor中 time 字段的表单渲染仅支持startTime/endTime且固定为 24 小时制;smart field 同步时直接显示原始值,不做任何格式转换。
C. 解决问题方案
- 扩展数据结构:
FormField增加timeFormat?: string和timeDefault?: 'current' | 'specific'。 - ReportEditor 表单渲染重构:
startTime/endTime根据timeFormat选择 hour select 的选项范围;12h 时额外增加 AM/PM select。 - smart field 同步格式化:同步 useEffect 中,根据字段定义调用
formatDateDisplay/formatTimeDisplay。 - 编辑器反向编辑解析:
handleEditorInput中,通过正则解析格式化文本,转回原始值后存入reportData。
D. 后续如何避免问题
- 显示格式与存储格式分离时,必须同时实现「正向格式化」(存储→显示)和「反向解析」(显示→存储),否则用户在编辑器中直接编辑格式化后的值会导致数据格式混乱。
- 自动填充当前时间必须增加「仅当值为空时触发」的保护,防止编辑已有报告时覆盖用户数据。
记录 14:时间字段联动修复——默认格式、固定时间自动填充、12/24h 动态切换
A. 具体问题
- 新建日期字段时默认格式为
YYYY-MM-DD,缺少中文格式;新建时间字段时默认格式为不可解析的'24h'。 - 时间字段设为「固定时间」后,进入报告编辑器新建报告时,该固定值未自动填充到表单中。
startTime格式改为hh:mm A(12小时制),报告编辑器中的表单仍显示为 24 小时制下拉框。
B. 产生问题原因
- 默认格式错误:
TemplateManage.tsx中newFieldForm.type的onChange将时间字段默认值硬编码为'24h',而实际通用格式化函数使用的是HH、hh、mm、A等 token。 - 固定时间未注入:
ReportEditor.tsx初始reportData中surgeryDate被强制赋值为new Date().toISOString().split('T')[0],导致后续「仅当值为空时才填充固定时间」的判断被跳过。 - 12h 判断写死:
const is12h = field.timeFormat === '12h';仅匹配精确的'12h'字符串。
C. 解决问题方案
- 默认格式改为:
t === 'date' ? 'YYYY年MM月DD日' : 'HH:mm'。 surgeryDate初始值从new Date()改为空字符串'';切换模板时显式遍历formFields注入固定值/当前值。- 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. 具体问题
template-manage的"插入字段"中仍存在"图片"分类(手术者签名、医院Logo)。- 插入图片占位符时无法自定义默认宽高,且使用
<div>导致强制换行。 - 占位符框太小时"插入/点击放置图片"文字显示不全。
B. 产生问题原因
DEFAULT_FORM_FIELDS仍包含surgeonSignature和hospitalLogo。- 两端编辑器的
insertImage()使用块级<div>插入,未提供尺寸 prompt。 - 占位符提示文本固定为长文本,未根据容器宽度做缩写适配。
C. 解决问题方案
- 从
DEFAULT_FORM_FIELDS和types.ts中移除surgeonSignature和hospitalLogo;在TemplateManage.tsx中彻底移除"图片"分类。 - 改造
insertImage():插入前通过prompt获取最大宽度/高度(px),生成带max-width/max-height的<span>行内占位符。 - 根据 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 个占位符做最小化修补:
- 医院 Logo(65×65)和手术者签名(200×40)添加
data-mode="manual"。 - 表格内 6 个术中影像占位符(100%×150)添加
data-mode="frame"。 - 所有占位符的
width/height/margin/display等布局属性绝对保持不变。
D. 后续如何避免问题
- 当为
image-placeholder引入新的核心属性(如data-mode、data-allow-source)时,必须同步检索defaultContent.ts和任何预置模板文件,确保静态模板中的占位符结构与运行时插入逻辑保持一致。
记录 19:5 项交互修复(虚线框恢复、prompt 文案、删除按钮、多选输入、label 提示)
A. 具体问题
- 删除
image-placeholder中的图片后,虚线框消失。 ReportEditor.tsx的insertImageprompt 文案仍显示旧版 "用英文逗号分隔",未同步修改。- 新生成的
image-placeholder右上角红色×显示不完全——overflow:hidden未移除。 - 多选框无法输入
,、;、,、、等分隔符——onChange实时调用parseMultiInput+filter(Boolean),末尾的分隔符被瞬间吃掉。 - 多选框 label 缺少 "(可多选)" 提示。
B. 产生问题原因
fillPlaceholderSrc设置了border='none',但删除图片的代码没有恢复。overflow:hidden仅在新版TemplateManage.tsx中被移除,ReportEditor.tsx中仍保留。- 多选框使用受控
value={displayText}+onChange={handleMultiChange},每次输入都会触发split(/[,,;;、]/)和filter(Boolean)。当用户输入一个逗号时,split 产生空字符串,filter 将其过滤,输入框值立即回退。
C. 解决问题方案
- 在删除图片分支中增加:
placeholder.style.border = '1px dashed #cbd5e1'; placeholder.style.background = '#f8fafc'; - 将
ReportEditor.tsx的insertImage重写为与TemplateManage.tsx一致的新版逻辑(*分隔 + while 循环校验)。 - 从
ReportEditor.tsx的styleStr中删除overflow:hidden;。 - 多选输入解耦:引入本地状态
multiInputText: Record<string, string>,onChange仅更新multiInputText,不触发拆分;onBlur和Enter时才调用handleMultiCommit执行拆分。 - label 追加
(可多选)提示。
D. 后续如何避免问题
- 同类型函数在多个文件中存在时,务必逐个文件 grep 确认修改结果,不能假设一次替换就能覆盖所有实例。
- 任何 "实时解析输入" 的逻辑都必须警惕
filter(Boolean)对空字符串的过滤效应——如果允许用户输入分隔符,应使用独立状态缓存原始输入,仅在确认时(blur/enter)执行解析。
记录 20:图片占位符填充后高度自适应
A. 具体问题 图片占位符填充后仍保留固定高度(如 200px),导致图片下方出现大片空白。
B. 产生问题原因
此前仅将 height 改为 auto,未同步处理 width,也未利用 max-width/max-height 作为硬限制来实现等比例缩放。
C. 解决问题方案
- 插入时:为 inline-block 占位符追加
max-width:${w}px;max-height:${h}px;。 - 填充时:统一执行以下步骤:
- 读取
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. 产生问题原因
- 当前 Windows 环境缺少 Docker 和 docker-compose CLI。
- 项目本身是基于 Vite 的前端应用,可通过
npm run build生成静态文件后,使用vite preview或任意静态文件服务器进行部署。 - 系统中已存在旧版本的
vite preview进程在运行,需要先停止旧服务再启动新服务。
C. 解决问题方案
- 使用 PowerShell 查询并强制停止所有属于当前项目目录的旧
vite preview进程。 - 执行
npm run build重新构建生产包。 - 使用
Start-Process以独立 Windows 进程启动npm run preview -- --host(避免后台任务超时杀死服务)。 - 通过
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 字段:
"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" 的三段式策略:
- 先用
Grep找到目标代码的精确行号 - 用
ReadFile读取该行号前后 10-20 行,获取精确文本 - 用
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. 解决问题方案
- 在
SystemSettings.tsx的初始化useEffect中增加数据迁移逻辑: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); } - 使用
(savedSettings as any)临时绕过旧字段的类型检查,避免在types.ts中保留废弃字段。 - 迁移后旧字段仍保留在
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. 解决问题方案 采用 "字典 + 动态下标" 模式:
types.ts中统一定义Record<string, AiProviderConfig>- UI 中只有一个
activeAiProviderselect,下方 3 个输入框统一绑定到aiProviders[activeAiProvider].xxx onChange时创建浅拷贝更新: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 做尾部斜杠移除:
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. 解决问题方案
stateRef初始化时包含chatMessagessaveDraftToStorage保存对象中增加chatMessages: stateRef.current.chatMessages- 增加
useEffect监听chatMessages变化,实时同步到stateRef.current.chatMessages - 所有草稿恢复分支(初始化 useEffect 的 2 处 + useLayoutEffect 的 2 处)均增加
chatMessages的恢复和 ref 同步
D. 后续如何避免问题
- 在
ReportEditor.tsx中新增任何useState时,必须同时问自己三个问题:- 这个 state 是否需要持久化到 draft?
- 若需要,是否已加入
stateRef初始化? - 若需要,是否已在
saveDraftToStorage、所有恢复分支、以及 state→ref 同步 effect 中补齐?
- 建议维护一份 "Draft 持久化字段清单" 注释在
stateRef定义附近,作为新增 state 时的检查单。
记录 28:chatInput 草稿恢复遗漏 + AI 请求 content 格式条件判断
A. 具体问题
- AI 撰写面板的聊天输入框内容在路由切换后丢失——虽然
chatMessages已修复,但chatInput(用户正在输入但未发送的文本)未纳入 draft 恢复。 - Kimi 等纯文本模型在没有任何图片时,若将
content以 vision 数组格式发送,会返回400 Bad Request。
B. 产生问题原因
- 遗漏模式:此前修复
chatMessages持久化时,只关注了已发送消息的恢复,忽略了输入框中未提交的chatInputstate。该 state 同样参与了stateRef同步和saveDraftToStorage保存,但所有 3 处草稿恢复分支均未恢复它。 - 模型格式差异:OpenAI 兼容 API 中,vision 模型支持
content: [{type:'image_url'...}, {type:'text'...}],但纯文本模型(如 Kimi 默认的kimi-k2-5)要求content必须是string。即使数组中只有 text 元素,也会触发 400。
C. 解决问题方案
- chatInput 恢复:
- 在
stateRef初始化和saveDraftToStorage中确认chatInput已存在(此前修改已完成) - 在 3 处草稿恢复分支中增加:
if (typeof draft.chatInput === 'string') setChatInput(draft.chatInput); // 以及 stateRef.current 中增加 chatInput: draft.chatInput || ''
- 在
- content 条件格式:
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的类型(stringvsarray),不能无条件发送数组。
记录 29:Checkbox 在复杂 React 组件树中点击失效 + AI 上下文缺失
A. 具体问题
- AI 面板底部的「允许修改正文」复选框无法点击切换。
- AI 无法回答编辑器中已有的报告内容(如「气腹压力是多少」),表现得像「瞎子」。
B. 产生问题原因
- Checkbox 失效:使用了独立的
<input id="x">+<label htmlFor="x">组合。在复杂的 contentEditable 编辑器 + React 重渲染环境中,id/htmlFor的绑定可能因事件冒泡、DOM 结构覆盖或 React 的 reconciliation 导致点击事件无法正确路由到 input。 - AI 上下文缺失:
handleAIGenerate只向大模型发送了「目标 AI 区域的 HTML 源码」。当该区域为空或信息在其他区域时,大模型收到的上下文只有用户指令,自然无法回答。
C. 解决问题方案
- Checkbox 修复:将
div > input + label改为label > input + span,让 label 直接包裹 input,天然扩大点击区域并避免id/htmlFor绑定冲突;onChange中增加e.stopPropagation()防止事件冒泡被外层拦截。 - AI 上下文增强:
- 新增
globalContextText = editorRef.current?.innerText || '',将编辑器完整纯文本作为全局背景知识注入 prompt currentHtml增加.replace(/​/g, '').trim()过滤零宽字符- 重构 prompt 结构:先放「全局参考内容」,再放「目标区域源码」,最后放「医生指令」
- 同步优化 systemPrompt,明确告知大模型有两个信息源
- 新增
D. 后续如何避免问题
- 在复杂 React 组件(尤其是与 contentEditable 共存)中使用 Checkbox 时,优先使用
<label>直接包裹<input>的写法,避免依赖id/htmlFor。 - 向大模型发送局部修改请求时,必须同时提供全局上下文,否则 AI 无法基于文档其他部分的信息进行推理和修改。
记录 30:AI「只聊天不干活」——System Prompt 过度依赖目标区域 + 缺少降级插入
A. 具体问题 用户在 report-editor 中输入「请随机填充文本内容」,AI 聊天面板有输出,但编辑器中的 AI 可编辑区域没有任何更新。
B. 产生问题原因
- System Prompt 条件过于严苛:
systemPrompt的构建条件是aiModifyEnabled && targetRegionEl。由于aiTargetRegion默认值为'none',如果用户未在下拉框中明确选中区域,targetRegionEl为null,systemPrompt 降级为纯聊天模式,大模型根本不会返回updatedHtml字段。 - 接收逻辑缺少降级:
responseJson.updatedHtml的接收条件是aiModifyEnabled && targetRegionEl,同样因为targetRegionEl为null而被跳过。即使大模型返回了 HTML,也会被丢弃。 - 缺少光标插入降级:参考代码
injectAIText中,当找不到目标区域时,会调用execCmd('insertHTML', htmlContent)将内容直接插入当前光标位置。当前代码完全没有这种降级机制。
C. 解决问题方案
- 解绑 System Prompt:将条件从
aiModifyEnabled && targetRegionEl改为aiModifyEnabled,让大模型在「允许修改正文」开启时始终返回updatedHtml。 - 增加降级插入逻辑:
if (responseJson.updatedHtml && aiModifyEnabled) { if (targetRegionEl) { setDiffModal({...}); // 原有流程:目标区域存在时走 diff 弹窗 } else { execCmd('insertHTML', responseJson.updatedHtml); // 降级:插入光标位置 } } - 复用当前代码已存在的
execCmd辅助函数,自动处理 focus、contentRef 更新和草稿保存。
D. 后续如何避免问题
- 设计「修改/生成」类 AI 功能时,systemPrompt 的条件应尽量只依赖用户意图开关(如
aiModifyEnabled),而非依赖具体 UI 状态(如某个下拉框是否选中)。UI 状态应只影响「如何注入结果」,不应影响「是否要求模型生成结果」。 - 任何「目标区域注入」逻辑都必须配备降级方案(如光标处插入),防止因用户未选中区域而导致功能完全失效。
记录 31:AI 修改模式自动锁定目标区域 + System Prompt 模式语义强化
A. 具体问题 用户希望实现两个明确场景:
- 修改模式:勾选「允许修改正文」→ AI 修改目标区域 → 弹出 diff 对比弹窗
- 对话模式:取消勾选「允许修改正文」→ AI 只聊天不修改
实际使用时,用户勾选了修改模式但未在下拉框中选择具体区域(aiTargetRegion 仍为 'none'),导致 AI 虽然返回了 updatedHtml,但 prompt 中缺少目标区域源码,diff 弹窗中的「原稿」一侧为空。
B. 产生问题原因
- 目标区域未自动锁定:
aiTargetRegion默认'none',修改模式开启后,如果用户未手动选择区域,targetRegionEl为null,prompt 中不会注入目标区域源码。 - System Prompt 模式语义不够强烈:大模型对「修改模式」vs「对话模式」的区分不够清晰,可能即使在对话模式下也返回 HTML。
C. 解决问题方案
- 自动修正目标区域:在
handleAIGenerate开头增加:后续 querySelector 和 diffModal 的let actualTargetId = aiTargetRegion; if (aiModifyEnabled && actualTargetId === 'none') { const availableRegions = checkAiRegions(); if (availableRegions.length > 0) { actualTargetId = availableRegions[0].id; setAiTargetRegion(actualTargetId); } }targetId均使用actualTargetId。 - 强化 System Prompt 模式语义:
- 修改模式明确标注「当前处于【修改模式】」,并要求必须包含
reply+updatedHtml - 对话模式明确标注「当前处于【对话模式】」,并要求仅包含
reply,不要返回 HTML
- 修改模式明确标注「当前处于【修改模式】」,并要求必须包含
D. 后续如何避免问题
- 当功能存在「全局开关 + 局部选择器」两层控制时,全局开关开启后应自动兜底局部选择器,避免因用户遗漏局部配置而导致功能降级。
- System Prompt 中应显式标注当前模式名称(如「修改模式」「对话模式」),大模型对显式标签的遵循度远高于隐式条件推断。