From b7a1ea457e7009ad7432ad17a91125e5fccd7235 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sat, 18 Apr 2026 00:09:33 +0800 Subject: [PATCH] =?UTF-8?q?2026-04-18-00-02-08=20-=20=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E5=B8=A7=E6=A0=B7=E5=BC=8F=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E3=80=81=E5=8D=A0=E4=BD=8D=E7=AC=A6=E5=88=86=E7=B1=BB=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E4=B8=8EModal=E5=BC=B9=E7=AA=97=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E3=80=81=E8=A1=A8=E6=A0=BC=E6=8F=92=E5=85=A5Modal=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ReportEditor.tsx | 188 +++++++++++------ src/pages/TemplateManage.tsx | 179 ++++++++++------ 工程分析/实现方案-2026-04-18-00-02-08.md | 256 +++++++++++++++++++++++ 工程分析/测试方案-2026-04-18-00-02-08.md | 104 +++++++++ 工程分析/经验记录.md | 32 +++ 工程分析/需求分析-2026-04-18-00-02-08.md | 45 ++++ 6 files changed, 670 insertions(+), 134 deletions(-) create mode 100644 工程分析/实现方案-2026-04-18-00-02-08.md create mode 100644 工程分析/测试方案-2026-04-18-00-02-08.md create mode 100644 工程分析/需求分析-2026-04-18-00-02-08.md diff --git a/src/pages/ReportEditor.tsx b/src/pages/ReportEditor.tsx index 6c12365..88f1273 100644 --- a/src/pages/ReportEditor.tsx +++ b/src/pages/ReportEditor.tsx @@ -66,6 +66,12 @@ export default function ReportEditor() { const [imagePickerOpen, setImagePickerOpen] = useState(false); const [imagePickerTarget, setImagePickerTarget] = useState(null); const [imageAssets, setImageAssets] = useState<{id: string; name: string; dataUrl: string}[]>([]); + 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' + }); const editorRef = useRef(null); const videoRef = useRef(null); @@ -479,75 +485,13 @@ export default function ReportEditor() { }; const insertTable = () => { - const rowsStr = prompt('请输入行数:', '2'); - const colsStr = prompt('请输入列数:', '3'); - if (rowsStr && colsStr) { - const rows = parseInt(rowsStr); - const cols = parseInt(colsStr); - if (isNaN(rows) || isNaN(cols)) return; - - let table = ''; - for (let i = 0; i < rows; i++) { - table += ''; - for (let j = 0; j < cols; j++) { - table += ''; - } - table += ''; - } - table += '
单元格

'; - execCmd('insertHTML', table); - } + editorRef.current?.focus(); + setTableModal({ isOpen: true, rows: '2', cols: '3' }); }; 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; - 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 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 = `
×${hintText}
`; - } 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 = `×${text}​`; - } - - execCmd('insertHTML', html); + setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' }); }; const handleVideoUpload = (e: React.ChangeEvent) => { @@ -676,7 +620,7 @@ export default function ReportEditor() { setTimeout(() => { if (!editorRef.current) return; - const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null; + const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null; if (emptyPlaceholder) { emptyPlaceholder.innerHTML = ` × @@ -709,15 +653,21 @@ export default function ReportEditor() { const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => { placeholder.innerHTML = ` × - + `; placeholder.classList.add('has-image'); + placeholder.style.border = 'none'; + placeholder.style.background = 'transparent'; if (editorRef.current) contentRef.current = editorRef.current.innerHTML; saveDraftToStorage(); }; 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) { @@ -730,7 +680,7 @@ export default function ReportEditor() { alert('编辑器未准备好'); return; } - const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null; + const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null; if (!emptyPlaceholder) { alert('没有可插入图片的空位'); return; @@ -1895,6 +1845,108 @@ export default function ReportEditor() { + {placeholderModal.isOpen && ( +
+
+

插入图片占位符

+
+
+
+ + setPlaceholderModal({...placeholderModal, width: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" /> +
+
+ + setPlaceholderModal({...placeholderModal, height: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" /> +
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+ )} + + {tableModal.isOpen && ( +
+
+

插入表格

+
+
+
+ + setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" /> +
+
+ + setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" /> +
+
+
+
+ + +
+
+
+ )} + {imagePickerOpen && imagePickerTarget && (
diff --git a/src/pages/TemplateManage.tsx b/src/pages/TemplateManage.tsx index ee9a17d..ea6d657 100644 --- a/src/pages/TemplateManage.tsx +++ b/src/pages/TemplateManage.tsx @@ -40,6 +40,12 @@ export default function TemplateManage() { const [customTimeFormats, setCustomTimeFormats] = useState([]); const [formatDropdownOpen, setFormatDropdownOpen] = useState(false); const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false); + 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' + }); const [imageAssets, setImageAssets] = useState<{ id: string; name: string; dataUrl: string }[]>([]); const updatePageHeight = () => { @@ -493,78 +499,17 @@ export default function TemplateManage() { }; const insertTable = () => { - const rowsStr = prompt('请输入行数:', '2'); - const colsStr = prompt('请输入列数:', '3'); - if (rowsStr && colsStr) { - const rows = parseInt(rowsStr); - const cols = parseInt(colsStr); - if (isNaN(rows) || isNaN(cols)) return; - - let table = ''; - for (let i = 0; i < rows; i++) { - table += ''; - for (let j = 0; j < cols; j++) { - table += ''; - } - table += ''; - } - table += '
单元格

'; - pushHistory(); - execCmd('insertHTML', table); - } + editorRef.current?.focus(); + restoreSelection(); + pushHistory(); + setTableModal({ isOpen: true, rows: '2', cols: '3' }); }; 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 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 = `
×${hintText}
`; - } 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 = `×${text}​`; - } - pushHistory(); - execCmd('insertHTML', html); + setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' }); }; const saveCurrentTemplate = () => { @@ -1269,6 +1214,108 @@ export default function TemplateManage() {
)} + {placeholderModal.isOpen && ( +
+
+

插入图片占位符

+
+
+
+ + setPlaceholderModal({...placeholderModal, width: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" /> +
+
+ + setPlaceholderModal({...placeholderModal, height: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" /> +
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+ )} + + {tableModal.isOpen && ( +
+
+

插入表格

+
+
+
+ + setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" /> +
+
+ + setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" /> +
+
+
+
+ + +
+
+
+ )} + {imagePickerOpen && imagePickerTarget && (
diff --git a/工程分析/实现方案-2026-04-18-00-02-08.md b/工程分析/实现方案-2026-04-18-00-02-08.md new file mode 100644 index 0000000..79f2418 --- /dev/null +++ b/工程分析/实现方案-2026-04-18-00-02-08.md @@ -0,0 +1,256 @@ +# 实现方案 — 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 = ` + × + + `; + 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**(在 `
` 关闭之前,与现有 `imagePickerOpen` Modal 并列): + +```tsx +{placeholderModal.isOpen && ( +
+
+

插入图片占位符

+
+
+
+ + setPlaceholderModal({...placeholderModal, width: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" /> +
+
+ + setPlaceholderModal({...placeholderModal, height: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" /> +
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+)} +``` + +**Table Insert Modal**: + +```tsx +{tableModal.isOpen && ( +
+
+

插入表格

+
+
+
+ + setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" /> +
+
+ + setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" /> +
+
+
+
+ + +
+
+
+)} +``` + +### 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` 中的选择器和拦截逻辑。 diff --git a/工程分析/测试方案-2026-04-18-00-02-08.md b/工程分析/测试方案-2026-04-18-00-02-08.md new file mode 100644 index 0000000..4a6f9f1 --- /dev/null +++ b/工程分析/测试方案-2026-04-18-00-02-08.md @@ -0,0 +1,104 @@ +# 测试方案 — 2026-04-18-00-02-08 + +## 测试目标 + +验证拖拽关键帧插入样式修复、图片占位符自定义弹窗与分类隔离、表格插入自定义弹窗三项修复。 + +## 测试环境 + +- 本地开发服务器:`npm run dev`(端口 3000) +- 浏览器:Chrome/Edge +- 测试账号:admin / 123456(超级管理员) + +## 测试用例 + +### TC-1:拖拽关键帧后边框消失 + 图片约束 + +| 步骤 | 操作 | 预期结果 | +|------|------|---------| +| 1 | 进入「报告编辑器」,上传视频并自动摘取关键帧 | 右侧视频分析面板显示关键帧缩略图 | +| 2 | 编辑器中插入一个图片占位符 | 显示虚线框占位符 | +| 3 | 从右侧拖拽关键帧到占位符中 | 图片正常显示,**虚线边框和灰色背景消失**;图片不溢出占位符边界 | +| 4 | 用 DevTools 检查 `` 元素 | style 包含 `max-width:100%;max-height:100%;object-fit:contain;` | + +### TC-2:图片占位符插入弹窗(ReportEditor) + +| 步骤 | 操作 | 预期结果 | +|------|------|---------| +| 1 | 进入「报告编辑器」 | — | +| 2 | 点击工具栏「插入图片占位符」 | **不弹出 prompt**,而是弹出居中的自定义 Modal | +| 3 | Modal 中显示默认宽度 200、高度 200 | — | +| 4 | 修改宽度为 120,高度为 80 | 输入框值正常变化 | +| 5 | 选择「静态图片占位」模式 | 模式按钮高亮切换 | +| 6 | 点击「确认插入」 | Modal 关闭,编辑器中插入行内 `` 占位符,带有 `data-mode="manual"` 属性 | +| 7 | 用 DevTools 检查插入的元素 | `data-mode="manual"` 存在,style 包含 `width:120px;height:80px;` | + +### TC-3:图片占位符插入弹窗(TemplateManage) + +| 步骤 | 操作 | 预期结果 | +|------|------|---------| +| 1 | 进入「模板管理」 | — | +| 2 | 点击工具栏「插入图片占位符」 | 弹出自定义 Modal | +| 3 | 确认插入 | 占位符正常插入,结构完整 | + +### TC-4:自动帧插入跳过 manual 占位符 + +| 步骤 | 操作 | 预期结果 | +|------|------|---------| +| 1 | 在编辑器中插入两个占位符:第一个是 frame 模式,第二个是 manual 模式 | — | +| 2 | 上传视频,开启「自动帧插入」,点击「自动关键帧摘取」 | — | +| 3 | 观察占位符填充情况 | 只有**第一个 frame 模式**的占位符被自动填入关键帧;第二个 manual 占位符**保持空白** | + +### TC-5:一键插入跳过 manual 占位符 + +| 步骤 | 操作 | 预期结果 | +|------|------|---------| +| 1 | 编辑器中先插入一个 manual 占位符,再插入一个 frame 占位符 | — | +| 2 | 右侧关键帧卡片点击「插入」按钮 | 关键帧填入**第二个 frame 占位符**;manual 占位符不受影响 | + +### TC-6:拖拽拦截 manual 占位符 + +| 步骤 | 操作 | 预期结果 | +|------|------|---------| +| 1 | 编辑器中插入一个 manual 占位符 | — | +| 2 | 从右侧拖拽关键帧到该 manual 占位符上 | 弹出提示「此处为静态图片占位符,仅支持点击插入...」;占位符**不被填充** | + +### TC-7:表格插入弹窗(ReportEditor) + +| 步骤 | 操作 | 预期结果 | +|------|------|---------| +| 1 | 进入「报告编辑器」 | — | +| 2 | 点击工具栏「插入表格」 | **不弹出 prompt**,弹出自定义 Modal | +| 3 | Modal 中显示默认行数 2、列数 3 | — | +| 4 | 修改行数为 4,列数为 2 | 输入框值正常变化 | +| 5 | 点击「确认插入」 | Modal 关闭,编辑器中插入 4 行 2 列的表格 | + +### TC-8:表格插入弹窗(TemplateManage) + +| 步骤 | 操作 | 预期结果 | +|------|------|---------| +| 1 | 进入「模板管理」 | — | +| 2 | 点击工具栏「插入表格」 | 弹出自定义 Modal | +| 3 | 设置行数 3,列数 3,确认插入 | 表格正常插入 | + +### TC-9:类型检查 + +| 步骤 | 操作 | 预期结果 | +|------|------|---------| +| 1 | 项目根目录执行 `npm run lint` | `tsc --noEmit` 通过,0 errors | + +## 验收标准 + +- [ ] `npm run lint` 无 TypeScript 编译错误 +- [ ] 拖拽关键帧后占位符边框和背景消失,图片不溢出 +- [ ] 点击「插入图片占位符」弹出自定义 Modal(非 prompt) +- [ ] Modal 支持选择占位符模式(frame/manual) +- [ ] manual 占位符带有 `data-mode="manual"` 属性 +- [ ] 自动帧插入和一键插入跳过 manual 占位符 +- [ ] 拖拽到 manual 占位符被拦截并提示 +- [ ] 点击「插入表格」弹出自定义 Modal(非 prompt) +- [ ] Modal 支持输入行数/列数并正常插入表格 + +## 测试方式 + +全部使用手工功能验证(项目无单元测试框架)。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index b150a30..b446eaa 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -888,3 +888,35 @@ if ((settings.autoInsertDelay || 0) > 0) { - 当 `` + `` 的交互体验无法满足需求时,应尽早替换为自定义下拉组件,避免在不同浏览器中产生不一致的行为。 - `document.execCommand('insertHTML')` 对块级元素边界(尤其是 `` 内)的自动修正行为不可控;在表格等复杂容器内插入 HTML 时,应优先使用块级标签(如 `
`)作为外层容器,减少被浏览器重新排列的风险。 - 打印样式的边距控制必须使用 `@page { margin: ... }` 而非 `body { padding: ... }`,前者会让打印引擎为每一页物理纸张独立分配边距,后者只在文档首尾生效一次。 + + +--- + +## 记录 29:拖拽关键帧样式遗漏、占位符分类隔离与 Modal 弹窗改造 + +**A. 具体问题** +1. 拖拽关键帧到 `.image-placeholder` 后,虚线边框和灰色背景未消失,且图片可能溢出占位符。 +2. `insertImage` 和 `insertTable` 使用浏览器原生 `prompt` 弹窗,交互体验差。 +3. 所有占位符一视同仁,自动帧插入和一键插入会把手术关键帧填入 Logo、签名等静态图片位置。 + +**B. 产生问题原因** +1. **`fillPlaceholder` 遗漏样式清除**:`fillPlaceholderSrc`(点击上传路径)设置了 `border='none'` 和 `background='transparent'`,但 `fillPlaceholder`(拖拽路径)遗漏了这两行,且图片 style 缺少 `max-height:100%;object-fit:contain;`。 +2. **原生 prompt 的限制**:`prompt` 弹窗无法自定义样式,且在不同浏览器中表现不一致,用户体验差。 +3. **占位符无分类机制**:所有 `.image-placeholder` 都接受关键帧填充,没有区分"接受自动插入"和"不接受自动插入"的占位符。 + +**C. 解决问题方案** +1. **补齐 `fillPlaceholder`**:增加 `placeholder.style.border = 'none'`、`placeholder.style.background = 'transparent'`,图片 style 改为 `max-width:100%;max-height:100%;object-fit:contain;`。 +2. **自定义 Modal 替代 prompt**: + - 新增 `placeholderModal` 状态(isOpen, width, height, mode)和 `tableModal` 状态(isOpen, rows, cols)。 + - `insertImage` 和 `insertTable` 改为打开 Modal。 + - Modal 使用项目统一的 `bg-black/50 backdrop-blur-sm` + `bg-white rounded-2xl` 风格。 +3. **占位符分类隔离**: + - Modal 中增加模式选择:「手术影像占位(frame)」和「静态图片占位(manual)」。 + - manual 模式生成的 placeholder 带有 `data-mode="manual"` 属性。 + - `autoCaptureFrames` 和 `insertFrameToPlaceholder` 的选择器增加 `:not([data-mode="manual"])`。 + - `handleDrop` 中拦截 manual 占位符的拖拽,弹出提示。 + +**D. 后续如何避免问题** +- 当同一填充逻辑存在多个入口(点击上传、拖拽、自动插入)时,务必确保所有入口的后续处理完全一致,避免某一路径遗漏样式清除。 +- 原生 `prompt`/`confirm`/`alert` 在现代 Web 应用中应尽量避免使用,优先采用自定义 Modal 组件,以获得一致的视觉体验和更灵活的控制能力。 +- 当系统中存在"自动填充"机制时,应考虑为被填充的容器增加分类标记(如 `data-mode`),并在自动填充逻辑中通过选择器过滤,防止无关区域被污染。 diff --git a/工程分析/需求分析-2026-04-18-00-02-08.md b/工程分析/需求分析-2026-04-18-00-02-08.md new file mode 100644 index 0000000..7912e00 --- /dev/null +++ b/工程分析/需求分析-2026-04-18-00-02-08.md @@ -0,0 +1,45 @@ +# 需求分析 — 2026-04-18-00-02-08 + +## 原始需求摘要 + +1. **修复拖拽关键帧插入兼容性**:拖拽关键帧到 `.image-placeholder` 后,虚线边框和背景色未消失,且图片缺少 `max-height:100%;object-fit:contain;` 约束,可能溢出占位符。 + +2. **图片占位符插入改为自定义弹窗 + 分类隔离**: + - 替换 `insertImage` 中的 `prompt` 弹窗为自定义 React Modal。 + - 占位符分为两类: + - **手术影像占位(frame 模式)**:支持自动帧插入、一键插入、拖拽插入。 + - **静态图片占位(manual 模式)**:仅支持点击后从弹窗选择图片来源(本地上传/签名/素材),防止系统自动将手术关键帧填入 Logo 或签名位置。 + - 自动帧插入和一键插入逻辑需跳过 `data-mode="manual"` 的占位符。 + - 拖拽到 manual 占位符时需拦截并提示。 + +3. **表格插入改为自定义弹窗**:替换 `insertTable` 中的 `prompt` 弹窗为自定义 React Modal,中间弹出子窗口选择行数和列数。 + +## 需求拆解 + +### 功能点 + +- **F1**:`ReportEditor.tsx` 的 `fillPlaceholder` 函数补齐 `border='none'`、`background='transparent'`,图片 style 增加 `max-height:100%;object-fit:contain;`。 +- **F2**:`ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `insertImage` 改为打开自定义 Modal(替代 `prompt`)。Modal 包含: + - 宽高输入框(默认 200*200) + - 模式选择:手术影像占位(frame)/ 静态图片占位(manual) + - 确认/取消按钮 +- **F3**:生成 placeholder HTML 时,manual 模式添加 `data-mode="manual"` 属性。 +- **F4**:`ReportEditor.tsx` 的 `autoCaptureFrames`(setTimeout 回调内)、`insertFrameToPlaceholder` 的空占位符选择器,从 `.image-placeholder:not(.has-image)` 改为 `.image-placeholder:not(.has-image):not([data-mode="manual"])`。 +- **F5**:`ReportEditor.tsx` 的 `handleDrop` 增加拦截:若目标 placeholder 的 `data-mode === 'manual'`,弹出提示并阻止填充。 +- **F6**:`ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `insertTable` 改为打开自定义 Modal(替代 `prompt`),包含行数/列数输入和确认/取消按钮。 + +### 非功能点 + +- 向后兼容:已有报告中已有的 placeholder 结构不受影响(没有 `data-mode` 属性的占位符默认为 frame 模式)。 +- Modal 样式复用现有的 `bg-black/50 backdrop-blur-sm` + `bg-white rounded-2xl` 风格。 + +## 影响范围预估 + +| 模块 | 影响程度 | 说明 | +|------|---------|------| +| `src/pages/ReportEditor.tsx` | 高 | fillPlaceholder 修复;insertImage 改为 Modal;insertTable 改为 Modal;autoCaptureFrames 选择器;insertFrameToPlaceholder 选择器;handleDrop 拦截;新增 3 个 Modal 的 JSX | +| `src/pages/TemplateManage.tsx` | 高 | insertImage 改为 Modal;insertTable 改为 Modal;新增 2 个 Modal 的 JSX | + +## 待确认问题 + +无(用户已明确需求,且本次无需人工确认)。