# 实现方案 — 2026-04-18-00-23-14 ## 根因分析 1. **拖拽后边框残留**:`ReportEditor.tsx` 中存在两个填充函数: - `fillPlaceholderSrc`(弹窗选择图片后填充)会执行 `placeholder.style.border = 'none'` 和 `placeholder.style.background = 'transparent'`。 - `fillPlaceholder`(拖拽关键帧后填充)仅添加 `has-image` class,未清除内联样式,导致 `style="border:1px dashed #cbd5e1;background:#f8fafc"` 仍然生效,覆盖 CSS class 的 `border-none`/`bg-transparent`。 - 同理,`autoCaptureFrames` 的 `setTimeout` 回调中也直接操作 DOM,未清除内联样式。 2. **原生 `prompt()` 体验差**:`insertTable` 和 `insertImage` 在两端编辑器中均连续调用 `prompt()`,阻塞主线程且无法定制样式,与系统现代化 UI 脱节。 3. **占位符无来源隔离**:当前所有 `.image-placeholder` 生成时没有任何类型标识,导致关键帧、本地上传、签名等图片可以无差别填入任何位置。 ## 修改文件清单 - `src/pages/ReportEditor.tsx` - `src/pages/TemplateManage.tsx` ## 具体代码变更 ### 一、ReportEditor.tsx #### 1. 新增弹窗状态 ```typescript const [tableModalOpen, setTableModalOpen] = useState(false); const [imageModalOpen, setImageModalOpen] = useState(false); const [savedRange, setSavedRange] = useState(null); const [imageModalInTable, setImageModalInTable] = useState(false); const [imageModalWidth, setImageModalWidth] = useState('200'); const [imageModalHeight, setImageModalHeight] = useState('200'); const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all'); const [tableRows, setTableRows] = useState('2'); const [tableCols, setTableCols] = useState('3'); ``` #### 2. 修复 `fillPlaceholder`(F1) 在 `fillPlaceholder` 函数中,添加 `has-image` class 后,同步清除内联样式: ```typescript placeholder.classList.add('has-image'); placeholder.style.border = 'none'; placeholder.style.background = 'transparent'; ``` #### 3. 修复 `autoCaptureFrames` 中的自动插入(F2) 在 `setTimeout` 回调内,`classList.add('has-image')` 之后增加: ```typescript emptyPlaceholder.style.border = 'none'; emptyPlaceholder.style.background = 'transparent'; ``` #### 4. 替换 `insertTable` 为弹窗驱动(F3) - **打开弹窗**: ```typescript const openTableModal = () => { const sel = window.getSelection(); if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange()); setTableRows('2'); setTableCols('3'); setTableModalOpen(true); }; ``` - **确认插入**: ```typescript const confirmInsertTable = () => { const rows = parseInt(tableRows); const cols = parseInt(tableCols); if (isNaN(rows) || isNaN(cols) || rows <= 0 || cols <= 0) { setTableModalOpen(false); return; } if (savedRange) { const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(savedRange); } let table = ''; for (let i = 0; i < rows; i++) { table += ''; for (let j = 0; j < cols; j++) { table += ''; } table += ''; } table += '
单元格

'; execCmd('insertHTML', table); setTableModalOpen(false); setSavedRange(null); }; ``` - 工具栏按钮 `onClick` 从 `insertTable` 改为 `openTableModal`。 #### 5. 替换 `insertImage` 为弹窗驱动(F4 / F5) - **打开弹窗**: ```typescript const openImageModal = () => { editorRef.current?.focus(); const sel = window.getSelection(); let node: Node | null = sel?.anchorNode ?? null; let inTable = false; while (node) { if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') { inTable = true; break; } node = node.parentNode; } if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange()); setImageModalInTable(inTable); setImageModalWidth('200'); setImageModalHeight('200'); setImageModalAllowSource('all'); setImageModalOpen(true); }; ``` - **确认插入**: ```typescript const confirmInsertImage = () => { if (savedRange) { const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(savedRange); } const width = imageModalInTable ? 0 : (parseInt(imageModalWidth) || 200); const height = imageModalInTable ? 0 : (parseInt(imageModalHeight) || 200); const allowSource = imageModalAllowSource; const hintText = '插入/点击放置图片'; const id = 'ph_' + Date.now(); const allowAttr = allowSource !== 'all' ? ` data-allow-source="${allowSource}"` : ''; let html: string; if (imageModalInTable) { const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;'; html = `
×${hintText}
`; } else { let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;'; if (width > 0) styleStr += `width:${width}px;`; if (height > 0) styleStr += `height:${height}px;`; const showShortText = width > 0 && width < 80; const text = showShortText ? '插入图片' : hintText; html = `×${text}​`; } execCmd('insertHTML', html); setImageModalOpen(false); setSavedRange(null); }; ``` - 工具栏按钮 `onClick` 从 `insertImage` 改为 `openImageModal`。 #### 6. 拦截拖拽(F6) 修改 `handleDrop`: ```typescript const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => { e.preventDefault(); const allowSource = placeholder.getAttribute('data-allow-source') || 'all'; if (allowSource === 'upload') { alert('此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。'); return; } const frameId = e.dataTransfer.getData('frameId'); const frame = capturedFrames.find(f => f.id.toString() === frameId); if (frame) fillPlaceholder(placeholder, frame); }; ``` #### 7. 拦截点击空占位符(F7) 修改 `handleEditorClick` 中点击空占位符的分支: ```typescript if (!placeholder.classList.contains('has-image')) { e.preventDefault(); e.stopPropagation(); const allowSource = placeholder.getAttribute('data-allow-source') || 'all'; if (allowSource === 'frame') { alert('此区域仅限插入关键帧图片,请从右侧视频分析面板拖拽或点击插入。'); return; } setImagePickerTarget(placeholder); setImagePickerOpen(true); } ``` #### 8. 拦截一键插入关键帧(F8) 修改 `insertFrameToPlaceholder`: ```typescript const insertFrameToPlaceholder = (frame: CapturedFrame) => { if (!editorRef.current) { alert('编辑器未准备好'); return; } const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null; if (!emptyPlaceholder) { alert('没有可插入图片的空位'); return; } const allowSource = emptyPlaceholder.getAttribute('data-allow-source') || 'all'; if (allowSource === 'upload') { alert('此区域仅限插入本地上传/签名/素材图片,不可通过关键帧插入。'); return; } fillPlaceholder(emptyPlaceholder, frame); }; ``` #### 9. 拦截自动帧插入(F9) 在 `autoCaptureFrames` 的 `setTimeout` 回调中,读取 `data-allow-source`,若值为 `upload` 则直接 `return` 跳过该帧。 #### 10. 新增 JSX 弹窗(位于组件底部) **Table Modal**: - 遮罩层:`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm` - 卡片:`bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl` - 输入:行数、列数(``) - 按钮:确认(`btn-accent` 样式)、取消 **Image Placeholder Modal**: - 同样遮罩层和卡片布局 - 若在表格内:显示提示文本「表格内占位符将自动填满单元格,无需设置尺寸」。 - 若不在表格内:宽度输入、高度输入(`type="number"`,默认 200),提示文字「正文一行文字高度约为 20 像素左右」。 - 下拉选择:「允许图片来源」——所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材。 - 按钮:确认、取消 ### 二、TemplateManage.tsx #### 1. 新增弹窗状态 与 ReportEditor 类似,但使用已有的 `savedRangeRef` 恢复光标: ```typescript const [tableModalOpen, setTableModalOpen] = useState(false); const [imageModalOpen, setImageModalOpen] = useState(false); const [imageModalInTable, setImageModalInTable] = useState(false); const [imageModalWidth, setImageModalWidth] = useState('200'); const [imageModalHeight, setImageModalHeight] = useState('200'); const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all'); const [tableRows, setTableRows] = useState('2'); const [tableCols, setTableCols] = useState('3'); ``` #### 2. 替换 `insertTable` - `openTableModal`:保存 `savedRangeRef.current`,打开弹窗。 - `confirmInsertTable`:先 `restoreSelection()`,再执行 `pushHistory()` + `execCmd('insertHTML', table)`。 #### 3. 替换 `insertImage` - `openImageModal`:检测是否在表格内,保存 `savedRangeRef.current`,打开弹窗。 - `confirmInsertImage`:先 `restoreSelection()`,再执行 `pushHistory()` + `execCmd('insertHTML', html)`。 - HTML 中增加 `data-allow-source` 属性。 #### 4. 新增 JSX 弹窗 结构与 ReportEditor 完全一致,放置在组件底部 `imagePickerOpen` 弹窗之前或之后。 ## 风险点与应对措施 | 风险 | 应对措施 | |------|---------| | 弹窗打开后编辑器失去焦点,插入位置错误 | 打开弹窗前保存 `Range.cloneRange()`,确认后恢复 `Selection` 再执行 `insertHTML`。 | | `autoCaptureFrames` 的 `setTimeout` 异步回调中 DOM 引用失效 | 回调内部重新查询 `editorRef.current`,并做空值保护;`contentRef.current` 同步更新。 | | 旧报告/模板中的占位符没有 `data-allow-source` 属性 | 所有读取逻辑使用 `getAttribute('data-allow-source') || 'all'` 兜底,向后兼容。 | | TemplateManage 工具栏按钮 `onMouseDown={e=>e.preventDefault()}` 已存在,ReportEditor 缺少 | 给 ReportEditor 的工具栏按钮也增加 `onMouseDown={e=>e.preventDefault()}`,减少焦点流失概率。 | ## 回滚策略 - 所有修改集中在两个文件(`ReportEditor.tsx`、`TemplateManage.tsx`),未改动 `types.ts`、`storage.ts` 等底层模块。 - 回滚时直接 `git checkout` 还原两个文件即可恢复原有 `prompt()` 行为和占位符逻辑。