197 lines
8.4 KiB
Markdown
197 lines
8.4 KiB
Markdown
# 实现方案 — 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 新增状态(组件顶部)
|
||
|
||
```tsx
|
||
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
|
||
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
|
||
```
|
||
|
||
#### 1.2 编辑字段格式输入替换为自定义下拉
|
||
|
||
将原 `input[list]` + `datalist` 替换为:
|
||
|
||
```tsx
|
||
<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 增加表格检测
|
||
|
||
```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 = `<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
|
||
|
||
```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` 样式。
|