# 实现方案 — 2026-04-17-23-38-34 ## 根因分析 ### 问题1:原生 datalist 交互体验差 - 原生 `` + `` 在不同浏览器中表现不一致,部分浏览器不会自动展开全部选项,且不支持样式自定义。 - 用户已习惯 `ReportEditor.tsx` 中单选下拉框的交互模式,期望统一体验。 ### 问题2:execCommand('insertHTML') 在表格中破坏结构 - 当 `insertImage` 在 `` 内执行 `execCommand('insertHTML', ...)` 时,WebKit/Blink 会将复杂的 `inline-flex` 嵌套 `` 结构自动"拍平"或重新排列。 - 外层 `` 被浏览器移除,仅剩内部的 `.delete-btn` 和 `.placeholder-text` 散落为 `` 的直接子元素。 - 表格单元格本身就是块级上下文,使用块级 `
` 作为占位符容器更符合浏览器预期,不会被强制修正。 ### 问题3:@page margin 与 body padding 的分页失效 - `@page { margin: 0 }` 将物理纸张边距设为 0。 - `body { padding: 10mm }` 只在整个 HTML 文档的顶部和底部各生效一次。 - 当内容跨页时,浏览器在分页切断处不会保留 `body` 的 padding,导致第二页顶部和底部紧贴纸张边缘。 - 正确做法是将边距交给 `@page` 规则,让打印引擎为每一张物理纸张独立留出边距。 ## 修改文件清单 | 文件 | 修改内容 | |------|---------| | `src/pages/TemplateManage.tsx` | ① 引入 `formatDropdownOpen` / `newFormatDropdownOpen` 状态;② 将编辑/新增字段的格式 `input[list]+datalist` 替换为自定义下拉组件;③ `insertImage` 增加表格检测,表格内使用 `
` 块级容器+自适应尺寸 | | `src/pages/ReportEditor.tsx` | `insertImage` 增加表格检测,表格内使用 `
` 块级容器+自适应尺寸 | | `src/utils/print.ts` | `@page margin` 与 `body padding` 调整,`.content` width 改为 `100%` | ## 具体代码变更 ### 1. TemplateManage.tsx #### 1.1 新增状态(组件顶部) ```tsx const [formatDropdownOpen, setFormatDropdownOpen] = useState(false); const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false); ``` #### 1.2 编辑字段格式输入替换为自定义下拉 将原 `input[list]` + `datalist` 替换为: ```tsx
setEditFieldTimeFormat(e.target.value)} onFocus={() => setFormatDropdownOpen(true)} onBlur={() => { setTimeout(() => setFormatDropdownOpen(false), 200); const val = editFieldTimeFormat.trim(); if (val && !customTimeFormats.includes(val)) { const next = [...customTimeFormats, val]; setCustomTimeFormats(next); storage.set('customTimeFormats', next); } }} onKeyDown={(e) => { if (e.key === 'Enter') { const val = editFieldTimeFormat.trim(); if (val && !customTimeFormats.includes(val)) { const next = [...customTimeFormats, val]; setCustomTimeFormats(next); storage.set('customTimeFormats', next); } setFormatDropdownOpen(false); } }} className="w-full px-1.5 py-1 text-xs border border-border rounded" placeholder="输入格式或下拉选择" /> {formatDropdownOpen && (
{customTimeFormats .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; }) .map(fmt => (
{ e.preventDefault(); setEditFieldTimeFormat(fmt); setFormatDropdownOpen(false); }} > {fmt}
))}
)}
``` #### 1.3 新增字段格式输入同理替换 使用 `newFormatDropdownOpen` 状态,结构同上,过滤条件改为 `newFieldForm.type`。 #### 1.4 insertImage 增加表格检测 ```tsx const insertImage = () => { editorRef.current?.focus(); restoreSelection(); // 检测是否在表格单元格内 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; } let width = 200; let height = 200; if (!inTable) { while (true) { const input = prompt('请输入占位符的最大宽度和高度(px),用 * 分隔(如: 100*50)。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', ''); if (input === null) return; const trimmed = input.trim(); if (trimmed === '') break; const parts = trimmed.split('*').map(s => s.trim()); if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) { width = parseInt(parts[0]) || 0; height = parseInt(parts[1]) || 0; break; } alert('格式错误,请确保使用 * 分隔两个数字,例如 100*50'); } } const id = 'ph_' + Date.now(); const hintText = '插入/点击放置图片'; let html: string; if (inTable) { // 表格内使用 div 块级容器,自适应单元格 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 { // 普通文本中保持行内 span 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}​`; } pushHistory(); execCmd('insertHTML', html); }; ``` ### 2. ReportEditor.tsx `insertImage` 同理增加表格检测分支,与 TemplateManage 保持一致(去除 `restoreSelection()` 和 `pushHistory()` 调用)。 ### 3. print.ts ```css /* 修改前 */ @page { size: A4; margin: 0; } body { margin: 0; padding: 10mm; ... } .content { width: 190mm; min-height: 277mm; margin: 0 auto; } /* 修改后 */ @page { size: A4; margin: 15mm 10mm; } body { margin: 0; padding: 0; ... } .content { width: 100%; min-height: 277mm; margin: 0 auto; } ``` ## 风险点与应对措施 | 风险 | 应对措施 | |------|---------| | 自定义下拉组件在滚动容器内可能被裁切 | 父容器设置 `relative`,下拉层设置 `absolute z-10`,并确保外层有适当的 overflow-visible 或足够空间 | | 表格检测 `while (node)` 循环在编辑器外部可能遍历到 body/html | 以 `node.nodeName === 'TD' \|\| node.nodeName === 'TH'` 为终止条件,安全 | | 表格内使用 div 后,fillPlaceholderSrc 需要兼容 | fillPlaceholderSrc 通过 `querySelector('.image-placeholder')` 匹配 class,不受标签名影响,已验证兼容 | | @page margin 增加后 .content width 190mm 会溢出 | 改为 `width: 100%`,让内容自然撑满可用区域 | ## 回滚策略 - TemplateManage.tsx 的修改:删除新增状态和替换的 JSX 条件块,恢复原有的 `input[list]` + `datalist`。 - ReportEditor.tsx 的修改:删除 insertImage 中的表格检测分支。 - print.ts 的修改:恢复原始的 `@page`、`body`、`content` 样式。