8.7 KiB
实现方案 — 2026-04-16-22-23-02
根因分析
当前 TemplateManage.tsx 和 ReportEditor.tsx 均使用原生 contentEditable 实现富文本编辑,但模板中的占位符是纯 HTML(如 姓名:<span style="color: #ff0000;">*姓名*</span>),存在以下问题:
- 固定文本无保护:"姓名:" 等标签与普通文本无异,用户可随意删除或篡改。
- 无双向绑定:模板中的占位符与右侧表单之间没有数据通道,模板内容不会随表单变化,表单也不会随模板输入自动填充。
- 打印样式混乱:现有的红色占位文本在打印报告中显得不专业。
修改文件清单
| 文件 | 修改类型 | 说明 |
|---|---|---|
src/pages/TemplateManage.tsx |
修改 | 新增右侧"字段库"面板,支持点击插入智能占位控件 |
src/pages/ReportEditor.tsx |
修改 | 新增 data-bind DOM 的双向监听与同步逻辑 |
src/utils/print.ts |
修改 | 打印样式中增加 .field-value 的打印适配 |
src/index.css |
修改 | 新增 .smart-field-wrapper 系列样式 |
src/types.ts |
修改(可选) | 定义字段映射常量数组,供两端复用 |
具体代码变更
变更 1:src/types.ts — 定义字段库常量
新增内容(在文件末尾追加):
export interface BindableField {
key: string;
label: string;
}
export const BINDABLE_FIELDS: BindableField[] = [
{ key: 'patientName', label: '姓名' },
{ key: 'gender', label: '性别' },
{ key: 'age', label: '年龄' },
{ key: 'hospitalId', label: '住院号' },
{ key: 'bedNumber', label: '床号' },
{ key: 'surgeryDate', label: '手术日期' },
{ key: 'surgeryType', label: '手术类型' },
{ key: 'surgeon', label: '手术者' },
{ key: 'assistant', label: '助手' },
{ key: 'anesthesiaType', label: '麻醉方式' },
{ key: 'preoperativeDiagnosis', label: '术前诊断' },
{ key: 'intraoperativeDiagnosis', label: '术中诊断' },
{ key: 'surgicalProcedure', label: '手术经过' },
];
变更 2:src/index.css — 智能占位控件样式
在 @layer components 或文件末尾新增:
.smart-field-wrapper {
display: inline-flex;
align-items: center;
margin: 0 4px;
vertical-align: middle;
}
.smart-field-wrapper .field-label {
color: #64748b;
user-select: none;
}
.smart-field-wrapper .field-value {
min-width: 60px;
padding: 0 4px;
border: 1px solid #cbd5e1;
border-radius: 4px;
display: inline-block;
background: #fff;
color: #0f172a;
outline: none;
}
.smart-field-wrapper .field-value:empty::before {
content: '\200b'; /* zero-width space to keep min-height */
}
@media print {
.smart-field-wrapper .field-value {
border: none !important;
border-bottom: 1px solid #000 !important;
border-radius: 0 !important;
background: transparent !important;
}
}
变更 3:src/pages/TemplateManage.tsx — 字段库面板与插入逻辑
当前结构: TemplateManage.tsx 右侧通常为操作按钮区(如保存、预览)。
新增字段库面板(放在保存按钮下方或单独区域):
import { BINDABLE_FIELDS } from '../types';
// 在组件内新增辅助函数
const insertSmartField = (field: typeof BINDABLE_FIELDS[0]) => {
const html = `
<span class="smart-field-wrapper" contenteditable="false">
<span class="field-label">${field.label}:</span>
<span class="field-value"
data-bind="${field.key}"
contenteditable="true"
style="min-width: 60px; padding: 0 4px; border: 1px solid #cbd5e1; border-radius: 4px; display: inline-block; background: #fff; color: #0f172a;">
</span>
</span>
`;
document.execCommand('insertHTML', false, html);
};
UI 位置(在保存按钮下方新增卡片):
<div className="card-minimal mt-4">
<h3 className="text-sm font-semibold text-primary mb-2">表单字段库</h3>
<div className="flex flex-wrap gap-2">
{BINDABLE_FIELDS.map((field) => (
<button
key={field.key}
type="button"
onClick={() => insertSmartField(field)}
className="px-2 py-1 text-xs bg-slate-100 hover:bg-slate-200 text-slate-700 rounded border border-slate-300 transition-colors"
title={`插入 ${field.label}`}
>
{field.label}
</button>
))}
</div>
<p className="text-[10px] text-slate-400 mt-2">点击字段插入智能占位方格,Label 锁定,Value 可输入。</p>
</div>
注意:
TemplateManage.tsx的具体行号需以实际文件为准,但插入逻辑与 UI 结构如上。
变更 4:src/pages/ReportEditor.tsx — 双向绑定逻辑
4.1 富文本 → 表单(handleEditorInput 或 onInput 事件)
当前代码中 ReportEditor.tsx 的编辑器通常已有 onInput 处理(保存草稿)。
在原有 onInput 处理器中追加:
const handleEditorInput = (e: React.FormEvent<HTMLDivElement>) => {
// 1. 原有逻辑:同步 contentRef 并保存草稿
if (editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
}
saveDraftToStorage();
// 2. 新增:双向绑定 — 方格内容变更时更新表单 State
const target = e.target as HTMLElement;
if (target && target.hasAttribute('data-bind')) {
const fieldKey = target.getAttribute('data-bind')!;
const newValue = target.innerText;
setReportData((prev) => {
const next = { ...prev, [fieldKey]: newValue };
// 同步 stateRef
stateRef.current.reportData = next;
return next;
});
}
};
4.2 表单 → 富文本(useEffect 监听 reportData)
在 ReportEditor.tsx 中新增一个 useEffect:
useEffect(() => {
if (!editorRef.current) return;
const bindNodes = editorRef.current.querySelectorAll('[data-bind]');
bindNodes.forEach((node) => {
const el = node as HTMLElement;
const fieldKey = el.getAttribute('data-bind')!;
const rawValue = (reportData as any)[fieldKey];
// 处理数组类型(如 surgeon / assistant)
let newValue = '';
if (Array.isArray(rawValue)) {
newValue = rawValue.join(', ');
} else if (rawValue !== undefined && rawValue !== null) {
newValue = String(rawValue);
}
// 仅在差异时更新 DOM,防止光标跳动
if (el.innerText !== newValue) {
el.innerText = newValue;
}
});
}, [reportData]);
4.3 光标/焦点保护(边界处理)
为避免 useEffect 在 reportData 变化时与用户的输入冲突,上述逻辑已通过 if (el.innerText !== newValue) 做短路保护。若用户当前正在该方格内输入(此时 reportData 已由 handleEditorInput 同步更新),innerText 通常等于 newValue,不会触发 DOM 重写,因此光标不会跳动。
变更 5:src/utils/print.ts — 打印样式适配
当前 print.ts 会将 HTML 内容包裹后打印。
在注入的 <style> 中追加:
@media print {
/* 现有打印样式保留 ... */
.smart-field-wrapper .field-value {
border: none !important;
border-bottom: 1px solid #000 !important;
border-radius: 0 !important;
background: transparent !important;
padding: 0 2px !important;
}
}
若
print.ts本身会读取index.css中的打印样式,可确认是否重复。保险起见,两边同时维护或仅在index.css维护即可。此处优先在index.css中维护,print.ts若已内联样式则同步追加。
风险点
| 风险 | 级别 | 应对措施 |
|---|---|---|
| 光标跳动/输入中断 | 中 | useEffect 同步时严格判断 innerText !== newValue,仅在差异时重写 DOM |
contenteditable="false" 外层导致整个控件无法删除 |
低 | 这是预期行为,用户可通过选中整个控件后按 Delete 删除;若需要允许删除,可在外层增加 tabindex 或删除按钮 |
| 数组字段(surgeon)同步时格式异常 | 低 | 在 useEffect 中加入 Array.isArray(rawValue) 分支,统一用 join(', ') |
| 老模板未自动升级,用户反馈"联动不生效" | 低 | 在 TemplateManage 页面增加提示文案:"请重新编辑模板并插入字段库控件以激活联动" |
回滚策略
本次改动仅涉及前端 UI 和 DOM 事件处理,不修改数据结构和存储接口。如出现异常,可直接执行以下任一方式:
git revert撤销相关提交;- 手动注释掉
ReportEditor.tsx中的useEffect双向绑定逻辑和handleEditorInput的新增分支,保留原有草稿保存逻辑即可恢复。
⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。