2026-04-17-23-38-34 - 时间格式自定义下拉组件、表格内图片占位符自适应、打印多页页边距修复

This commit is contained in:
2026-04-17 23:44:03 +08:00
parent d45e973255
commit d05029838f
7 changed files with 536 additions and 103 deletions

View File

@@ -501,8 +501,21 @@ export default function ReportEditor() {
const insertImage = () => {
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;
}
let width = 200;
let height = 200;
if (!inTable) {
while (true) {
const input = prompt('请输入占位符的最大宽度和高度(px),用*号分隔(如: 100*50。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
if (input === null) return;
@@ -516,16 +529,24 @@ export default function ReportEditor() {
}
alert('格式错误,请确保使用 * 分隔两个数字,例如 100*50');
}
}
const hintText = '插入/点击放置图片';
const id = 'ph_' + Date.now();
let html: string;
if (inTable) {
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 {
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 hintText = showShortText ? '插入图片' : '插入/点击放置图片';
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>&#8203;`;
}
const id = 'ph_' + Date.now();
const 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;">${hintText}</span></span>&#8203;`;
execCmd('insertHTML', html);
};

View File

@@ -38,6 +38,8 @@ export default function TemplateManage() {
const [newFieldTimeDefault, setNewFieldTimeDefault] = useState<'current' | 'specific'>('specific');
const [newFieldFixedTimeValue, setNewFieldFixedTimeValue] = useState('');
const [customTimeFormats, setCustomTimeFormats] = useState<string[]>([]);
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
const [imageAssets, setImageAssets] = useState<{ id: string; name: string; dataUrl: string }[]>([]);
const updatePageHeight = () => {
@@ -515,8 +517,21 @@ export default function TemplateManage() {
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;
@@ -530,16 +545,24 @@ export default function TemplateManage() {
}
alert('格式错误,请确保使用 * 分隔两个数字,例如 100*50');
}
}
const hintText = '插入/点击放置图片';
const id = 'ph_' + Date.now();
let html: string;
if (inTable) {
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 {
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 hintText = showShortText ? '插图' : '插入/点击放置图片';
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>&#8203;`;
}
const id = 'ph_' + Date.now();
const 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;">${hintText}</span></span>&#8203;`;
pushHistory();
execCmd('insertHTML', html);
};
@@ -933,12 +956,15 @@ export default function TemplateManage() {
className="w-full px-1.5 py-1 text-xs border border-border rounded"
/>
)}
<div className="relative">
<input
list={`edit-format-list-${field.key}`}
type="text"
value={editFieldTimeFormat}
onChange={(e) => setEditFieldTimeFormat(e.target.value)}
onBlur={(e) => {
const val = e.target.value.trim();
onFocus={() => setFormatDropdownOpen(true)}
onBlur={() => {
setTimeout(() => setFormatDropdownOpen(false), 200);
const val = editFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
@@ -947,18 +973,20 @@ export default function TemplateManage() {
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
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="输入格式,如 YYYY-MM-DD"
placeholder="输入格式或下拉选择"
/>
<datalist id={`edit-format-list-${field.key}`}>
{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);
@@ -967,8 +995,22 @@ export default function TemplateManage() {
if (field.type === 'time') return isTimeFormat;
return true;
})
.map(fmt => <option key={fmt} value={fmt} />)}
</datalist>
.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>
</div>
)}
<div className="flex gap-2">
@@ -1101,12 +1143,15 @@ export default function TemplateManage() {
className="w-full px-2 py-1.5 text-xs border border-border rounded"
/>
)}
<div className="relative">
<input
list="new-format-list"
type="text"
value={newFieldTimeFormat}
onChange={(e) => setNewFieldTimeFormat(e.target.value)}
onBlur={(e) => {
const val = e.target.value.trim();
onFocus={() => setNewFormatDropdownOpen(true)}
onBlur={() => {
setTimeout(() => setNewFormatDropdownOpen(false), 200);
const val = newFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
@@ -1115,18 +1160,20 @@ export default function TemplateManage() {
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
const val = newFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
setNewFormatDropdownOpen(false);
}
}}
className="w-full px-2 py-1.5 text-xs border border-border rounded"
placeholder="输入格式,如 YYYY-MM-DD"
placeholder="输入格式或下拉选择"
/>
<datalist id="new-format-list">
{newFormatDropdownOpen && (
<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);
@@ -1135,8 +1182,22 @@ export default function TemplateManage() {
if (newFieldForm.type === 'time') return isTimeFormat;
return true;
})
.map(fmt => <option key={fmt} value={fmt} />)}
</datalist>
.map(fmt => (
<div
key={fmt}
className="px-2 py-1 text-xs hover:bg-slate-100 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
setNewFieldTimeFormat(fmt);
setNewFormatDropdownOpen(false);
}}
>
{fmt}
</div>
))}
</div>
)}
</div>
</div>
)}
{['单选', '多选'].includes(newFieldForm.category) && (

View File

@@ -18,10 +18,10 @@ export const printDocument = (htmlContent: string) => {
<head>
<meta charset="utf-8">
<style>
@page { size: A4; margin: 0; }
@page { size: A4; margin: 15mm 10mm; }
* { box-sizing: border-box; }
body { margin: 0; padding: 10mm; font-family: SimSun, "Microsoft YaHei", serif; color: #1E293B; background: white; }
.content { width: 190mm; min-height: 277mm; margin: 0 auto; }
body { margin: 0; padding: 0; font-family: SimSun, "Microsoft YaHei", serif; color: #1E293B; background: white; }
.content { width: 100%; min-height: 277mm; margin: 0 auto; }
img { max-width: 100%; height: auto; display: block; margin: 8px auto; }
p { margin: 0; padding: 4px 0; line-height: 1.6; }
h1 { font-size: 20px; margin: 16px 0 12px; font-weight: 600; text-align: center; }

View File

@@ -0,0 +1,196 @@
# 实现方案 — 2026-04-17-23-38-34
## 根因分析
### 问题1原生 datalist 交互体验差
- 原生 `<input list>` + `<datalist>` 在不同浏览器中表现不一致,部分浏览器不会自动展开全部选项,且不支持样式自定义。
- 用户已习惯 `ReportEditor.tsx` 中单选下拉框的交互模式,期望统一体验。
### 问题2execCommand('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>&#8203;`;
}
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` 样式。

View File

@@ -0,0 +1,86 @@
# 测试方案 — 2026-04-17-23-38-34
## 测试目标
验证时间格式自定义下拉组件、表格内图片占位符插入、以及打印多页页边距三项修复。
## 测试环境
- 本地开发服务器:`npm run dev`(端口 3000
- 浏览器Chrome/Edge
- 测试账号admin / 123456超级管理员
## 测试用例
### TC-1时间格式自定义下拉编辑字段
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 登录 admin进入「模板管理」→「字段管理」 | — |
| 2 | 点击「手术日期」进入编辑模式,聚焦格式输入框 | 输入框下方弹出下拉列表,显示 `YYYY-MM-DD``YYYY年MM月DD日` 等日期格式 |
| 3 | 点击列表中 `YYYY年MM月DD日` | 输入框被填充为 `YYYY年MM月DD日`,下拉列表关闭 |
| 4 | 点击「手术开始时间」进入编辑模式,聚焦格式输入框 | 下拉列表只显示时间格式(`HH:mm``hh:mm A`**不出现**日期格式 |
| 5 | 在格式输入框中手写输入 `MM-DD HH:mm`,按 Enter | 新格式被保存,下拉列表中新增 `MM-DD HH:mm`;重新聚焦后可在列表中看到 |
### TC-2时间格式自定义下拉新增字段
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 在字段管理底部点击「新增字段」 | — |
| 2 | category 选「时间」type 选「日期」,聚焦格式输入框 | 下拉列表只显示日期格式 |
| 3 | type 切换为「时分」,聚焦格式输入框 | 下拉列表只显示时间格式 |
| 4 | 手写输入新格式 `hh:mm:ss`,按 Enter | 新格式被保存到 `customTimeFormats` 并出现在列表中 |
### TC-3表格内插入图片占位符TemplateManage
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 进入「模板管理」,编辑器中确保存在表格 | — |
| 2 | 将光标放入表格的某个 `<td>` 单元格内 | — |
| 3 | 点击工具栏「插入图片占位符」 | **不弹出 prompt**,占位符直接插入到单元格内 |
| 4 | 用 DevTools 检查插入的元素 | 外层标签为 `<div class="image-placeholder">`style 包含 `width:100%;height:100%;max-width:200px;max-height:200px;`内部结构完整delete-btn + placeholder-text |
| 5 | 点击图片占位符填充一张图片 | 图片正常显示,占位符添加 `has-image` class |
### TC-4普通文本中插入图片占位符TemplateManage
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 将光标放在表格外的普通文本段落中 | — |
| 2 | 点击「插入图片占位符」 | 弹出 prompt 要求输入宽高 |
| 3 | 输入 `100*80` | 插入行内 `<span class="image-placeholder">`style 包含 `width:100px;height:80px;`,与前后文字保持在同一行 |
### TC-5表格内插入图片占位符ReportEditor
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 进入「报告编辑器」,确保报告内容中有表格 | — |
| 2 | 将光标放入表格单元格内,插入图片占位符 | 不弹出 prompt插入自适应 div 占位符 |
| 3 | 从视频分析面板拖拽关键帧到占位符中 | 图片正常填充,结构完整 |
### TC-6打印多页页边距
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 在「报告编辑器」中创建一份内容较多的报告或复制粘贴大量文本使内容超过1页A4 | — |
| 2 | 点击「预览/打印」 | 弹出打印对话框 |
| 3 | 在浏览器打印预览中查看第2页 | 第二页顶部和底部均有约 15mm 的留白,不紧贴纸张边缘;左右留白约 10mm |
| 4 | 检查第一页 | 第一页同样有 15mm 上下 / 10mm 左右的均匀留白 |
### TC-7类型检查
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 项目根目录执行 `npm run lint` | `tsc --noEmit` 通过0 errors |
## 验收标准
- [ ] `npm run lint` 无 TypeScript 编译错误
- [ ] 时间格式输入框支持点击展开下拉列表、选择选项、手写输入并记忆新格式
- [ ] date 字段下拉只显示日期格式time 字段只显示时间格式
- [ ] 表格内插入图片占位符不弹 prompt结构完整使用 div 块级容器
- [ ] 普通文本中插入图片占位符仍弹 prompt使用 span 行内容器
- [ ] 打印多页时每一页上下均有约 15mm 留白,左右约 10mm
## 测试方式
全部使用手工功能验证(项目无单元测试框架)。

View File

@@ -854,3 +854,37 @@ if ((settings.autoInsertDelay || 0) > 0) {
- `customTimeFormats` 这类用户可扩展的缓存数组,在初始化时应建立**无效值清理机制**,防止历史版本残留的数据污染后续 UI。
- `datalist` / `select` 的选项如果存在明显的类型分组(日期 vs 时间),应在渲染层做过滤,而不是将所有选项平铺展示。
- 任何在滚动容器内通过点击展开/折叠的交互组件,都应考虑增加 `scrollIntoView` 兜底,防止布局突变导致的点击失效问题。
---
## 记录 28原生 datalist 交互体验差、表格内 execCommand 插入破坏结构、打印分页边距失效
**A. 具体问题**
1. `template-manage` 字段管理中,时间字段的格式输入使用原生 `<input list>` + `<datalist>`,浏览器下拉体验差,部分浏览器不会自动展示全部选项。
2. 在 `template-manage` 编辑器表格中点击"插入图片占位符"后HTML 结构被破坏——外层 `<span class="image-placeholder">` 丢失,仅剩内部子元素散落为 `<td>` 的直接子元素。
3. `report-editor` / `report-view` 打印多页报告时,第二页及后续页面的上下边距几乎为 0内容紧贴纸张边缘。
**B. 产生问题原因**
1. **原生 datalist 局限性**:不同浏览器对 `<datalist>` 的展示逻辑不一致Edge/Chrome 中聚焦时不会自动展开全部选项,且不支持样式自定义,无法提供一致的下拉选择体验。
2. **execCommand 在表格中的自动修正**`document.execCommand('insertHTML')` 在 `<td>` 内处理复杂的 `inline-flex` 嵌套 `<span>` 时WebKit/Blink 会将其自动"拍平"或重新排列。外层 `contenteditable="false"` 的 inline 容器被浏览器移除,仅剩内部子元素散落。
3. **@page margin 与 body padding 的分页陷阱**`@page { margin: 0 }` 将物理纸张边距设为 0`body { padding: 10mm }` 只在整个 HTML 文档的顶部和底部各生效一次。当内容跨页时,浏览器在分页切断处不会保留 `body` 的 padding导致第二页顶部和底部紧贴纸张边缘。`@page` 的 margin 才是为每一张物理纸张独立分配边距的正确方式。
**C. 解决问题方案**
1. **自定义下拉组件**:放弃原生 `input[list]` + `datalist`,改为手写 input + 绝对定位 div 列表组件:
- `onFocus` 时 `setDropdownOpen(true)` 展开列表
- `onMouseDown` + `e.preventDefault()` 阻止失焦,实现点击选项填充
- `onBlur`(延迟 200ms时保存手写的新格式到 `customTimeFormats`
- 列表项通过 `.filter` 按 `date`/`time` 类型过滤显示
2. **表格检测 + 块级容器**:在 `insertImage` 中通过 `window.getSelection().anchorNode` 向上遍历检测是否在 `<td>` / `<th>` 内:
- 若在表格内:不弹出 prompt使用 `<div>` 块级容器 + `width:100%;height:100%;max-width:200px;max-height:200px;`
- 若不在表格内:保持现有 `<span>` 行内容器 + prompt 输入自定义宽高
3. **打印边距修正**`print.ts` 中:
- `@page { margin: 15mm 10mm; }` 让打印引擎为每一页纸张独立分配上下 15mm / 左右 10mm 边距
- `body { padding: 0; }` 清除 body padding
- `.content { width: 100%; }` 让内容自然撑满可用区域
**D. 后续如何避免问题**
- 当 `<input list>` + `<datalist>` 的交互体验无法满足需求时,应尽早替换为自定义下拉组件,避免在不同浏览器中产生不一致的行为。
- `document.execCommand('insertHTML')` 对块级元素边界(尤其是 `<td>` 内)的自动修正行为不可控;在表格等复杂容器内插入 HTML 时,应优先使用块级标签(如 `<div>`)作为外层容器,减少被浏览器重新排列的风险。
- 打印样式的边距控制必须使用 `@page { margin: ... }` 而非 `body { padding: ... }`,前者会让打印引擎为每一页物理纸张独立分配边距,后者只在文档首尾生效一次。

View File

@@ -0,0 +1,35 @@
# 需求分析 — 2026-04-17-23-38-34
## 原始需求摘要
1. **时间格式输入框改造**`template-manage` 字段管理中,时间字段的格式输入当前使用原生 `<input list="...">` + `<datalist>`,浏览器兼容性和交互体验不佳。希望改造为类似单选下拉框的自定义组件,既能下拉选择已有格式,又能手写输入并自动记忆新格式。
2. **表格内插入图片占位符修复**:在 `template-manage` 编辑器表格中点击"插入图片占位符"后HTML 结构被破坏——外层 `<span class="image-placeholder">` 丢失,仅剩内部子元素被分散到 `<td>` 中。且表格内占位符应默认自适应单元格大小(最大不超过 200×200px
3. **打印第二页页边距太小**`report-editor` / `report-view` 点击打印时,第二页及后续页面的上下边距几乎为 0内容紧贴纸张边缘。当前 `@page { margin: 0 }` + `body { padding: 10mm }` 的组合仅在文档首尾生效一次分页后无padding。
## 需求拆解
### 功能点
- **F1**`TemplateManage.tsx` 中编辑字段和新增字段的时间格式输入,从原生 `input[list]` + `datalist` 改造为自定义下拉组件input + 绝对定位 ul 列表。支持点击展开下拉、点击选项填充、手写输入、blur/Enter 时自动保存新格式到 `customTimeFormats`
- **F2**`TemplateManage.tsx``ReportEditor.tsx``insertImage` 函数,在插入前检测当前光标是否位于 `<td>` / `<th>` 内。若在表格内,使用块级 `<div>` 作为外层容器(避免浏览器 execCommand 修正破坏结构),并设置 `width:100%;height:100%;max-width:200px;max-height:200px;` 实现自适应。不在表格内时保持现有 `<span>` 行内结构。
- **F3**`src/utils/print.ts` 中,将 `@page { margin: 0 }` + `body { padding: 10mm }` 改为 `@page { margin: 15mm 10mm }` + `body { padding: 0 }`,使每一页物理纸张都有独立的上下 15mm / 左右 10mm 留白。同步调整 `.content` 的 width 为 `100%`
### 非功能点
- 向后兼容:已保存的模板/报告中已有的 `image-placeholder` 结构不受影响。
- 下拉组件的 `z-index` 需确保覆盖在滚动容器之上。
- 打印样式调整应同时兼顾 `.image-placeholder` 在打印时的隐藏逻辑。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/TemplateManage.tsx` | 高 | 时间格式自定义下拉组件(编辑+新增两处insertImage 表格检测与分支逻辑 |
| `src/pages/ReportEditor.tsx` | 中 | insertImage 表格检测与分支逻辑 |
| `src/utils/print.ts` | 低 | @page margin 与 body padding 调整 |
## 待确认问题
无(用户已明确需求,且本次无需人工确认)。