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

12 KiB
Raw Blame History

实现方案 — 2026-04-18-00-02-08

根因分析

问题1拖拽插入后边框不消失

  • fillPlaceholderSrc(点击上传路径)设置了 border='none'background='transparent'
  • fillPlaceholder(拖拽路径)遗漏了这两行样式清除,导致拖拽后虚线框和灰色背景仍然可见。
  • 同时 fillPlaceholder 中图片 style 缺少 max-height:100%;object-fit:contain;,图片可能溢出占位符。

问题2prompt 弹窗体验差 + 自动帧插入无区分

  • insertImage 使用浏览器原生 prompt 询问宽高,交互体验不佳。
  • 所有 .image-placeholder 一视同仁,autoCaptureFrames 会自动填入任意空占位符。Logo、签名等位置不应被手术关键帧污染。
  • 没有机制区分"接受关键帧"和"不接受关键帧"的占位符。

问题3insertTable 使用 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 修复

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 新增状态

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

const insertImage = () => {
  editorRef.current?.focus();
  setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};

1.4 insertTable 改为打开 Modal

const insertTable = () => {
  editorRef.current?.focus();
  setTableModal({ isOpen: true, rows: '2', cols: '3' });
};

1.5 autoCaptureFrames 中选择器修改

setTimeout 回调内的:

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;

1.6 insertFrameToPlaceholder 选择器修改

const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;

1.7 handleDrop 拦截 manual 模式

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 并列):

{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>&#8203;`;
          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

{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 新增状态

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

const insertImage = () => {
  editorRef.current?.focus();
  restoreSelection();
  setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};

2.3 insertTable 改为打开 Modal

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恢复 insertImageinsertTable 中的 prompt 弹窗逻辑。
  • 恢复 fillPlaceholder 到修改前状态。
  • 恢复 autoCaptureFramesinsertFrameToPlaceholderhandleDrop 中的选择器和拦截逻辑。