8.4 KiB
8.4 KiB
实现方案 — 2026-04-17-23-38-34
根因分析
问题1:原生 datalist 交互体验差
- 原生
<input list>+<datalist>在不同浏览器中表现不一致,部分浏览器不会自动展开全部选项,且不支持样式自定义。 - 用户已习惯
ReportEditor.tsx中单选下拉框的交互模式,期望统一体验。
问题2:execCommand('insertHTML') 在表格中破坏结构
- 当
insertImage在<td>内执行execCommand('insertHTML', ...)时,WebKit/Blink 会将复杂的inline-flex嵌套<span>结构自动"拍平"或重新排列。 - 外层
<span class="image-placeholder">被浏览器移除,仅剩内部的.delete-btn和.placeholder-text散落为<td>的直接子元素。 - 表格单元格本身就是块级上下文,使用块级
<div>作为占位符容器更符合浏览器预期,不会被强制修正。
问题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 增加表格检测,表格内使用 <div> 块级容器+自适应尺寸 |
src/pages/ReportEditor.tsx |
insertImage 增加表格检测,表格内使用 <div> 块级容器+自适应尺寸 |
src/utils/print.ts |
@page margin 与 body padding 调整,.content width 改为 100% |
具体代码变更
1. TemplateManage.tsx
1.1 新增状态(组件顶部)
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
1.2 编辑字段格式输入替换为自定义下拉
将原 input[list] + datalist 替换为:
<div className="relative">
<input
type="text"
value={editFieldTimeFormat}
onChange={(e) => 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 && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded shadow-lg max-h-32 overflow-y-auto">
{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 => (
<div
key={fmt}
className="px-2 py-1 text-xs hover:bg-slate-100 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
setEditFieldTimeFormat(fmt);
setFormatDropdownOpen(false);
}}
>
{fmt}
</div>
))}
</div>
)}
</div>
1.3 新增字段格式输入同理替换
使用 newFormatDropdownOpen 状态,结构同上,过滤条件改为 newFieldForm.type。
1.4 insertImage 增加表格检测
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 = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
} 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 = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
}
pushHistory();
execCmd('insertHTML', html);
};
2. ReportEditor.tsx
insertImage 同理增加表格检测分支,与 TemplateManage 保持一致(去除 restoreSelection() 和 pushHistory() 调用)。
3. print.ts
/* 修改前 */
@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样式。