257 lines
12 KiB
Markdown
257 lines
12 KiB
Markdown
# 实现方案 — 2026-04-18-00-02-08
|
||
|
||
## 根因分析
|
||
|
||
### 问题1:拖拽插入后边框不消失
|
||
- `fillPlaceholderSrc`(点击上传路径)设置了 `border='none'` 和 `background='transparent'`。
|
||
- `fillPlaceholder`(拖拽路径)遗漏了这两行样式清除,导致拖拽后虚线框和灰色背景仍然可见。
|
||
- 同时 `fillPlaceholder` 中图片 style 缺少 `max-height:100%;object-fit:contain;`,图片可能溢出占位符。
|
||
|
||
### 问题2:prompt 弹窗体验差 + 自动帧插入无区分
|
||
- `insertImage` 使用浏览器原生 `prompt` 询问宽高,交互体验不佳。
|
||
- 所有 `.image-placeholder` 一视同仁,`autoCaptureFrames` 会自动填入任意空占位符。Logo、签名等位置不应被手术关键帧污染。
|
||
- 没有机制区分"接受关键帧"和"不接受关键帧"的占位符。
|
||
|
||
### 问题3:insertTable 使用 prompt
|
||
- 与 insertImage 同理,原生 `prompt` 弹窗用户体验差,应替换为与项目风格一致的自定义 Modal。
|
||
|
||
## 修改文件清单
|
||
|
||
| 文件 | 修改内容 |
|
||
|------|---------|
|
||
| `src/pages/ReportEditor.tsx` | ① fillPlaceholder 补齐样式清除和图片约束;② insertImage 改为 placeholderModal;③ insertTable 改为 tableModal;④ autoCaptureFrames/insertFrameToPlaceholder 选择器增加 `:not([data-mode="manual"])`;⑤ handleDrop 拦截 manual 模式;⑥ JSX 底部新增 2 个 Modal |
|
||
| `src/pages/TemplateManage.tsx` | ① insertImage 改为 placeholderModal;② insertTable 改为 tableModal;③ JSX 底部新增 2 个 Modal |
|
||
|
||
## 具体代码变更
|
||
|
||
### 1. ReportEditor.tsx
|
||
|
||
#### 1.1 fillPlaceholder 修复
|
||
|
||
```ts
|
||
const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => {
|
||
placeholder.innerHTML = `
|
||
<span class="delete-btn" contenteditable="false">×</span>
|
||
<img src="${frame.dataUrl}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
|
||
`;
|
||
placeholder.classList.add('has-image');
|
||
placeholder.style.border = 'none';
|
||
placeholder.style.background = 'transparent';
|
||
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||
saveDraftToStorage();
|
||
};
|
||
```
|
||
|
||
#### 1.2 新增状态
|
||
|
||
```ts
|
||
const [placeholderModal, setPlaceholderModal] = useState({
|
||
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
|
||
});
|
||
const [tableModal, setTableModal] = useState({
|
||
isOpen: false, rows: '2', cols: '3'
|
||
});
|
||
```
|
||
|
||
#### 1.3 insertImage 改为打开 Modal
|
||
|
||
```ts
|
||
const insertImage = () => {
|
||
editorRef.current?.focus();
|
||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||
};
|
||
```
|
||
|
||
#### 1.4 insertTable 改为打开 Modal
|
||
|
||
```ts
|
||
const insertTable = () => {
|
||
editorRef.current?.focus();
|
||
setTableModal({ isOpen: true, rows: '2', cols: '3' });
|
||
};
|
||
```
|
||
|
||
#### 1.5 autoCaptureFrames 中选择器修改
|
||
|
||
将 `setTimeout` 回调内的:
|
||
```ts
|
||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||
```
|
||
改为:
|
||
```ts
|
||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
|
||
```
|
||
|
||
#### 1.6 insertFrameToPlaceholder 选择器修改
|
||
|
||
```ts
|
||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
|
||
```
|
||
|
||
#### 1.7 handleDrop 拦截 manual 模式
|
||
|
||
```ts
|
||
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
|
||
e.preventDefault();
|
||
if (placeholder.getAttribute('data-mode') === 'manual') {
|
||
alert('此处为静态图片占位符,仅支持点击插入(如Logo/签名),不支持拖入关键帧');
|
||
return;
|
||
}
|
||
const frameId = e.dataTransfer.getData('frameId');
|
||
const frame = capturedFrames.find(f => f.id.toString() === frameId);
|
||
if (frame) {
|
||
fillPlaceholder(placeholder, frame);
|
||
}
|
||
};
|
||
```
|
||
|
||
#### 1.8 JSX 底部新增 Modal
|
||
|
||
**Placeholder Insert Modal**(在 `</div>` 关闭之前,与现有 `imagePickerOpen` Modal 并列):
|
||
|
||
```tsx
|
||
{placeholderModal.isOpen && (
|
||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||
<h3 className="text-lg font-bold text-text-main mb-4">插入图片占位符</h3>
|
||
<div className="space-y-3">
|
||
<div className="flex gap-2">
|
||
<div className="flex-1">
|
||
<label className="block text-xs mb-1">宽度(px)</label>
|
||
<input type="number" value={placeholderModal.width} onChange={e => setPlaceholderModal({...placeholderModal, width: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<label className="block text-xs mb-1">高度(px)</label>
|
||
<input type="number" value={placeholderModal.height} onChange={e => setPlaceholderModal({...placeholderModal, height: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs mb-1">占位符类型</label>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'frame'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'frame' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}>手术影像占位<br/><span className="text-[10px] opacity-80">(支持自动/拖拽插入)</span></button>
|
||
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'manual'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'manual' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}>静态图片占位<br/><span className="text-[10px] opacity-80">(仅支持点击插入)</span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-5 flex justify-end gap-2">
|
||
<button onClick={() => setPlaceholderModal({...placeholderModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||
<button onClick={() => {
|
||
const w = parseInt(placeholderModal.width) || 200;
|
||
const h = parseInt(placeholderModal.height) || 200;
|
||
const modeAttr = placeholderModal.mode === 'manual' ? ' data-mode="manual"' : '';
|
||
const showShortText = w > 0 && w < 80;
|
||
const text = showShortText ? '插图' : '插入/点击放置图片';
|
||
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;';
|
||
styleStr += `width:${w}px;height:${h}px;`;
|
||
const id = 'ph_' + Date.now();
|
||
const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} 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>​`;
|
||
execCmd('insertHTML', html);
|
||
setPlaceholderModal({...placeholderModal, isOpen: false});
|
||
}} className="px-4 py-2 bg-accent text-white rounded text-sm">确认插入</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
**Table Insert Modal**:
|
||
|
||
```tsx
|
||
{tableModal.isOpen && (
|
||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||
<h3 className="text-lg font-bold text-text-main mb-4">插入表格</h3>
|
||
<div className="space-y-3">
|
||
<div className="flex gap-2">
|
||
<div className="flex-1">
|
||
<label className="block text-xs mb-1">行数</label>
|
||
<input type="number" min="1" value={tableModal.rows} onChange={e => setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<label className="block text-xs mb-1">列数</label>
|
||
<input type="number" min="1" value={tableModal.cols} onChange={e => setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-5 flex justify-end gap-2">
|
||
<button onClick={() => setTableModal({...tableModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||
<button onClick={() => {
|
||
const rows = parseInt(tableModal.rows);
|
||
const cols = parseInt(tableModal.cols);
|
||
if (isNaN(rows) || isNaN(cols) || rows < 1 || cols < 1) {
|
||
setTableModal({...tableModal, isOpen: false});
|
||
return;
|
||
}
|
||
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
|
||
for (let i = 0; i < rows; i++) {
|
||
table += '<tr>';
|
||
for (let j = 0; j < cols; j++) {
|
||
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
|
||
}
|
||
table += '</tr>';
|
||
}
|
||
table += '</table><p></p>';
|
||
execCmd('insertHTML', table);
|
||
setTableModal({...tableModal, isOpen: false});
|
||
}} className="px-4 py-2 bg-accent text-white rounded text-sm">确认插入</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
### 2. TemplateManage.tsx
|
||
|
||
结构与 ReportEditor.tsx 类似,但 `insertImage` 的 Modal 中也需要表格检测逻辑(已在上一轮修改中实现)。
|
||
|
||
#### 2.1 新增状态
|
||
|
||
```ts
|
||
const [placeholderModal, setPlaceholderModal] = useState({
|
||
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
|
||
});
|
||
const [tableModal, setTableModal] = useState({
|
||
isOpen: false, rows: '2', cols: '3'
|
||
});
|
||
```
|
||
|
||
#### 2.2 insertImage 改为打开 Modal
|
||
|
||
```ts
|
||
const insertImage = () => {
|
||
editorRef.current?.focus();
|
||
restoreSelection();
|
||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||
};
|
||
```
|
||
|
||
#### 2.3 insertTable 改为打开 Modal
|
||
|
||
```ts
|
||
const insertTable = () => {
|
||
editorRef.current?.focus();
|
||
restoreSelection();
|
||
pushHistory();
|
||
setTableModal({ isOpen: true, rows: '2', cols: '3' });
|
||
};
|
||
```
|
||
|
||
#### 2.4 JSX 底部新增 Modal
|
||
|
||
与 ReportEditor.tsx 的 Modal 结构一致。TemplateManage.tsx 的 `insertImage` Modal 中,确认按钮需要执行表格检测(沿用上一轮修改的逻辑),然后调用 `execCmd('insertHTML', html)` 和 `pushHistory()`。
|
||
|
||
## 风险点与应对措施
|
||
|
||
| 风险 | 应对措施 |
|
||
|------|---------|
|
||
| `data-mode="manual"` 的选择器 `:not([data-mode="manual"])` 可能不兼容旧浏览器 | 项目使用 Chrome/Edge,完全支持属性选择器 |
|
||
| 新增 Modal 与现有 `imagePickerOpen` Modal 的 z-index 冲突 | 两者都使用 `z-50`,在同一时刻不会同时打开 |
|
||
| TemplateManage.tsx 的 insertImage 中 pushHistory() 调用位置 | 确认按钮中在 `execCmd` 之前调用 `pushHistory()` |
|
||
| 表格内的 insertImage(上一轮修改)与本次 Modal 的冲突 | 确认按钮中保留表格检测逻辑,在表格内时不使用 Modal 中的宽高值 |
|
||
|
||
## 回滚策略
|
||
|
||
- 删除新增的状态和 Modal JSX,恢复 `insertImage` 和 `insertTable` 中的 `prompt` 弹窗逻辑。
|
||
- 恢复 `fillPlaceholder` 到修改前状态。
|
||
- 恢复 `autoCaptureFrames`、`insertFrameToPlaceholder`、`handleDrop` 中的选择器和拦截逻辑。
|