Files
Mdeical_Sur_Report/工程分析/实现方案-2026-04-18-00-23-14.md

279 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 实现方案 — 2026-04-18-00-23-14
## 根因分析
1. **拖拽后边框残留**`ReportEditor.tsx` 中存在两个填充函数:
- `fillPlaceholderSrc`(弹窗选择图片后填充)会执行 `placeholder.style.border = 'none'``placeholder.style.background = 'transparent'`
- `fillPlaceholder`(拖拽关键帧后填充)仅添加 `has-image` class未清除内联样式导致 `style="border:1px dashed #cbd5e1;background:#f8fafc"` 仍然生效,覆盖 CSS class 的 `border-none`/`bg-transparent`
- 同理,`autoCaptureFrames``setTimeout` 回调中也直接操作 DOM未清除内联样式。
2. **原生 `prompt()` 体验差**`insertTable``insertImage` 在两端编辑器中均连续调用 `prompt()`,阻塞主线程且无法定制样式,与系统现代化 UI 脱节。
3. **占位符无来源隔离**:当前所有 `.image-placeholder` 生成时没有任何类型标识,导致关键帧、本地上传、签名等图片可以无差别填入任何位置。
## 修改文件清单
- `src/pages/ReportEditor.tsx`
- `src/pages/TemplateManage.tsx`
## 具体代码变更
### 一、ReportEditor.tsx
#### 1. 新增弹窗状态
```typescript
const [tableModalOpen, setTableModalOpen] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false);
const [savedRange, setSavedRange] = useState<Range | null>(null);
const [imageModalInTable, setImageModalInTable] = useState(false);
const [imageModalWidth, setImageModalWidth] = useState('200');
const [imageModalHeight, setImageModalHeight] = useState('200');
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
const [tableRows, setTableRows] = useState('2');
const [tableCols, setTableCols] = useState('3');
```
#### 2. 修复 `fillPlaceholder`F1
`fillPlaceholder` 函数中,添加 `has-image` class 后,同步清除内联样式:
```typescript
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
```
#### 3. 修复 `autoCaptureFrames` 中的自动插入F2
`setTimeout` 回调内,`classList.add('has-image')` 之后增加:
```typescript
emptyPlaceholder.style.border = 'none';
emptyPlaceholder.style.background = 'transparent';
```
#### 4. 替换 `insertTable` 为弹窗驱动F3
- **打开弹窗**
```typescript
const openTableModal = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
setTableRows('2');
setTableCols('3');
setTableModalOpen(true);
};
```
- **确认插入**
```typescript
const confirmInsertTable = () => {
const rows = parseInt(tableRows);
const cols = parseInt(tableCols);
if (isNaN(rows) || isNaN(cols) || rows <= 0 || cols <= 0) {
setTableModalOpen(false);
return;
}
if (savedRange) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRange);
}
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);
setTableModalOpen(false);
setSavedRange(null);
};
```
- 工具栏按钮 `onClick` 从 `insertTable` 改为 `openTableModal`。
#### 5. 替换 `insertImage` 为弹窗驱动F4 / F5
- **打开弹窗**
```typescript
const openImageModal = () => {
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;
}
if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
setImageModalInTable(inTable);
setImageModalWidth('200');
setImageModalHeight('200');
setImageModalAllowSource('all');
setImageModalOpen(true);
};
```
- **确认插入**
```typescript
const confirmInsertImage = () => {
if (savedRange) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRange);
}
const width = imageModalInTable ? 0 : (parseInt(imageModalWidth) || 200);
const height = imageModalInTable ? 0 : (parseInt(imageModalHeight) || 200);
const allowSource = imageModalAllowSource;
const hintText = '插入/点击放置图片';
const id = 'ph_' + Date.now();
const allowAttr = allowSource !== 'all' ? ` data-allow-source="${allowSource}"` : '';
let html: string;
if (imageModalInTable) {
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"${allowAttr} 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 text = showShortText ? '插入图片' : hintText;
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} 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;`;
}
execCmd('insertHTML', html);
setImageModalOpen(false);
setSavedRange(null);
};
```
- 工具栏按钮 `onClick` 从 `insertImage` 改为 `openImageModal`。
#### 6. 拦截拖拽F6
修改 `handleDrop`
```typescript
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
e.preventDefault();
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
if (allowSource === 'upload') {
alert('此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。');
return;
}
const frameId = e.dataTransfer.getData('frameId');
const frame = capturedFrames.find(f => f.id.toString() === frameId);
if (frame) fillPlaceholder(placeholder, frame);
};
```
#### 7. 拦截点击空占位符F7
修改 `handleEditorClick` 中点击空占位符的分支:
```typescript
if (!placeholder.classList.contains('has-image')) {
e.preventDefault();
e.stopPropagation();
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
if (allowSource === 'frame') {
alert('此区域仅限插入关键帧图片,请从右侧视频分析面板拖拽或点击插入。');
return;
}
setImagePickerTarget(placeholder);
setImagePickerOpen(true);
}
```
#### 8. 拦截一键插入关键帧F8
修改 `insertFrameToPlaceholder`
```typescript
const insertFrameToPlaceholder = (frame: CapturedFrame) => {
if (!editorRef.current) {
alert('编辑器未准备好');
return;
}
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
if (!emptyPlaceholder) {
alert('没有可插入图片的空位');
return;
}
const allowSource = emptyPlaceholder.getAttribute('data-allow-source') || 'all';
if (allowSource === 'upload') {
alert('此区域仅限插入本地上传/签名/素材图片,不可通过关键帧插入。');
return;
}
fillPlaceholder(emptyPlaceholder, frame);
};
```
#### 9. 拦截自动帧插入F9
在 `autoCaptureFrames` 的 `setTimeout` 回调中,读取 `data-allow-source`,若值为 `upload` 则直接 `return` 跳过该帧。
#### 10. 新增 JSX 弹窗(位于组件底部)
**Table Modal**
- 遮罩层:`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm`
- 卡片:`bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl`
- 输入:行数、列数(`<input type="number">`
- 按钮:确认(`btn-accent` 样式)、取消
**Image Placeholder Modal**
- 同样遮罩层和卡片布局
- 若在表格内:显示提示文本「表格内占位符将自动填满单元格,无需设置尺寸」。
- 若不在表格内:宽度输入、高度输入(`type="number"`,默认 200提示文字「正文一行文字高度约为 20 像素左右」。
- 下拉选择:「允许图片来源」——所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材。
- 按钮:确认、取消
### 二、TemplateManage.tsx
#### 1. 新增弹窗状态
与 ReportEditor 类似,但使用已有的 `savedRangeRef` 恢复光标:
```typescript
const [tableModalOpen, setTableModalOpen] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false);
const [imageModalInTable, setImageModalInTable] = useState(false);
const [imageModalWidth, setImageModalWidth] = useState('200');
const [imageModalHeight, setImageModalHeight] = useState('200');
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
const [tableRows, setTableRows] = useState('2');
const [tableCols, setTableCols] = useState('3');
```
#### 2. 替换 `insertTable`
- `openTableModal`:保存 `savedRangeRef.current`,打开弹窗。
- `confirmInsertTable`:先 `restoreSelection()`,再执行 `pushHistory()` + `execCmd('insertHTML', table)`。
#### 3. 替换 `insertImage`
- `openImageModal`:检测是否在表格内,保存 `savedRangeRef.current`,打开弹窗。
- `confirmInsertImage`:先 `restoreSelection()`,再执行 `pushHistory()` + `execCmd('insertHTML', html)`。
- HTML 中增加 `data-allow-source` 属性。
#### 4. 新增 JSX 弹窗
结构与 ReportEditor 完全一致,放置在组件底部 `imagePickerOpen` 弹窗之前或之后。
## 风险点与应对措施
| 风险 | 应对措施 |
|------|---------|
| 弹窗打开后编辑器失去焦点,插入位置错误 | 打开弹窗前保存 `Range.cloneRange()`,确认后恢复 `Selection` 再执行 `insertHTML`。 |
| `autoCaptureFrames` 的 `setTimeout` 异步回调中 DOM 引用失效 | 回调内部重新查询 `editorRef.current`,并做空值保护;`contentRef.current` 同步更新。 |
| 旧报告/模板中的占位符没有 `data-allow-source` 属性 | 所有读取逻辑使用 `getAttribute('data-allow-source') || 'all'` 兜底,向后兼容。 |
| TemplateManage 工具栏按钮 `onMouseDown={e=>e.preventDefault()}` 已存在ReportEditor 缺少 | 给 ReportEditor 的工具栏按钮也增加 `onMouseDown={e=>e.preventDefault()}`,减少焦点流失概率。 |
## 回滚策略
- 所有修改集中在两个文件(`ReportEditor.tsx`、`TemplateManage.tsx`),未改动 `types.ts`、`storage.ts` 等底层模块。
- 回滚时直接 `git checkout` 还原两个文件即可恢复原有 `prompt()` 行为和占位符逻辑。