250 lines
8.7 KiB
Markdown
250 lines
8.7 KiB
Markdown
# 实现方案 — 2026-04-16-22-23-02
|
||
|
||
## 根因分析
|
||
|
||
当前 `TemplateManage.tsx` 和 `ReportEditor.tsx` 均使用原生 `contentEditable` 实现富文本编辑,但模板中的占位符是纯 HTML(如 `姓名:<span style="color: #ff0000;">*姓名*</span>`),存在以下问题:
|
||
1. **固定文本无保护**:"姓名:" 等标签与普通文本无异,用户可随意删除或篡改。
|
||
2. **无双向绑定**:模板中的占位符与右侧表单之间没有数据通道,模板内容不会随表单变化,表单也不会随模板输入自动填充。
|
||
3. **打印样式混乱**:现有的红色占位文本在打印报告中显得不专业。
|
||
|
||
## 修改文件清单
|
||
|
||
| 文件 | 修改类型 | 说明 |
|
||
|------|---------|------|
|
||
| `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` — 定义字段库常量
|
||
|
||
**新增内容(在文件末尾追加):**
|
||
|
||
```typescript
|
||
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` 或文件末尾新增:**
|
||
|
||
```css
|
||
.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` 右侧通常为操作按钮区(如保存、预览)。
|
||
|
||
**新增字段库面板(放在保存按钮下方或单独区域):**
|
||
|
||
```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 位置(在保存按钮下方新增卡片):**
|
||
|
||
```tsx
|
||
<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` 处理器中追加:**
|
||
|
||
```tsx
|
||
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`:**
|
||
|
||
```tsx
|
||
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>` 中追加:**
|
||
|
||
```css
|
||
@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 事件处理,不修改数据结构和存储接口。如出现异常,可直接执行以下任一方式:
|
||
1. `git revert` 撤销相关提交;
|
||
2. 手动注释掉 `ReportEditor.tsx` 中的 `useEffect` 双向绑定逻辑和 `handleEditorInput` 的新增分支,保留原有草稿保存逻辑即可恢复。
|
||
|
||
---
|
||
|
||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**
|