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

11 KiB
Raw Blame History

实现方案 — 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
    • 同理,autoCaptureFramessetTimeout 回调中也直接操作 DOM未清除内联样式。
  2. 原生 prompt() 体验差insertTableinsertImage 在两端编辑器中均连续调用 prompt(),阻塞主线程且无法定制样式,与系统现代化 UI 脱节。

  3. 占位符无来源隔离:当前所有 .image-placeholder 生成时没有任何类型标识,导致关键帧、本地上传、签名等图片可以无差别填入任何位置。

修改文件清单

  • src/pages/ReportEditor.tsx
  • src/pages/TemplateManage.tsx

具体代码变更

一、ReportEditor.tsx

1. 新增弹窗状态

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. 修复 fillPlaceholderF1

fillPlaceholder 函数中,添加 has-image class 后,同步清除内联样式:

placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';

3. 修复 autoCaptureFrames 中的自动插入F2

setTimeout 回调内,classList.add('has-image') 之后增加:

emptyPlaceholder.style.border = 'none';
emptyPlaceholder.style.background = 'transparent';

4. 替换 insertTable 为弹窗驱动F3

  • 打开弹窗
    const openTableModal = () => {
      const sel = window.getSelection();
      if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
      setTableRows('2');
      setTableCols('3');
      setTableModalOpen(true);
    };
    
  • 确认插入
    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);
    };
    
  • 工具栏按钮 onClickinsertTable 改为 openTableModal

5. 替换 insertImage 为弹窗驱动F4 / F5

  • 打开弹窗
    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);
    };
    
  • 确认插入
    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);
    };
    
  • 工具栏按钮 onClickinsertImage 改为 openImageModal

6. 拦截拖拽F6

修改 handleDrop

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 中点击空占位符的分支:

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

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

autoCaptureFramessetTimeout 回调中,读取 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 恢复光标:

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
autoCaptureFramessetTimeout 异步回调中 DOM 引用失效 回调内部重新查询 editorRef.current,并做空值保护;contentRef.current 同步更新。
旧报告/模板中的占位符没有 data-allow-source 属性 所有读取逻辑使用 `getAttribute('data-allow-source')
TemplateManage 工具栏按钮 onMouseDown={e=>e.preventDefault()} 已存在ReportEditor 缺少 给 ReportEditor 的工具栏按钮也增加 onMouseDown={e=>e.preventDefault()},减少焦点流失概率。

回滚策略

  • 所有修改集中在两个文件(ReportEditor.tsxTemplateManage.tsx),未改动 types.tsstorage.ts 等底层模块。
  • 回滚时直接 git checkout 还原两个文件即可恢复原有 prompt() 行为和占位符逻辑。