447 lines
18 KiB
Markdown
447 lines
18 KiB
Markdown
# 实现方案 — 2026-04-17-00-13-09
|
||
|
||
## 根因分析
|
||
|
||
当前系统存在三个核心问题:
|
||
1. **时间字段未联动**:`defaultContent.ts` 中手术开始/终止时间是纯文本占位符,无 `data-bind`,导致右侧表单与正文内容无法同步。
|
||
2. **表单硬编码不可扩展**:`ReportEditor.tsx` 右侧的基本信息表单是写死的 JSX,每新增一个字段都需要改代码;`TemplateManage.tsx` 的字段库也是静态数组,无法按医院实际需求自定义。
|
||
3. **方格 UI 破坏排版**:`field-value` 使用了较大的 `min-width` 和上下 `padding`,在 `inline-block` 布局下撑大了行高,导致段落行间距明显变大。
|
||
|
||
## 修改文件清单
|
||
|
||
| 文件 | 修改类型 | 说明 |
|
||
|------|---------|------|
|
||
| `src/types.ts` | 修改 | 新增 `FieldType`、`FormField`、`FormFieldsConfig` 类型 |
|
||
| `src/utils/defaultContent.ts` | 修改 | 手术时间替换为 `startTime`/`endTime` 智能方格 |
|
||
| `src/index.css` | 修改 | 优化 `.field-value` 紧凑样式 |
|
||
| `src/utils/print.ts` | 修改 | 同步打印样式 |
|
||
| `src/pages/TemplateManage.tsx` | 修改 | 字段库重构为 Tab 结构,支持分类、新增、显隐控制 |
|
||
| `src/pages/ReportEditor.tsx` | 修改 | 右侧表单动态渲染 + 时间解析拼接双向转换 |
|
||
| `src/pages/Login.tsx` | 修改 | 首次登录时初始化默认字段配置到 localStorage |
|
||
|
||
---
|
||
|
||
## 具体代码变更
|
||
|
||
### 变更 1:`src/types.ts` — 动态字段类型定义
|
||
|
||
**在 `BINDABLE_FIELDS` 之后追加:**
|
||
|
||
```typescript
|
||
export type FieldType = 'text' | 'single_select' | 'multi_select' | 'time' | 'date';
|
||
|
||
export interface FormField {
|
||
key: string;
|
||
label: string;
|
||
category: string; // 如 '填空'、'单选'、'多选'、'时间'
|
||
type: FieldType;
|
||
visibleInForm: boolean;
|
||
isSystemLocked: boolean;
|
||
options?: string[];
|
||
}
|
||
|
||
export const DEFAULT_FORM_FIELDS: FormField[] = [
|
||
{ key: 'patientName', label: '患者姓名', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
|
||
{ key: 'hospitalId', label: '住院号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
|
||
{ key: 'title', label: '手术名称', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||
{ key: 'patientGender', label: '患者性别', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['男', '女'] },
|
||
{ key: 'patientAge', label: '患者年龄', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||
{ key: 'department', label: '科别', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||
{ key: 'bedNumber', label: '床号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||
{ key: 'surgeryDate', label: '手术日期', category: '时间', type: 'date', visibleInForm: true, isSystemLocked: false },
|
||
{ key: 'startTime', label: '手术开始时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: false },
|
||
{ key: 'endTime', label: '手术终止时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: false },
|
||
{ key: 'surgeon', label: '手术者', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: false, options: ['张医生', '李医生', '王医生'] },
|
||
{ key: 'assistant', label: '助手', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: false, options: ['赵医生', '钱医生', '孙医生'] },
|
||
{ key: 'anesthesiologist', label: '麻醉师', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: false, options: ['周医生', '吴医生', '郑医生'] },
|
||
{ key: 'anesthesiaType', label: '麻醉方式', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
|
||
];
|
||
```
|
||
|
||
### 变更 2:`src/utils/defaultContent.ts` — 手术时间方框化
|
||
|
||
**替换手术时间相关段落:**
|
||
|
||
```typescript
|
||
<p style="font-family: SimSun;">
|
||
手术开始时间:${smartField('startTime')}
|
||
手术终止时间:${smartField('endTime')}
|
||
</p>
|
||
```
|
||
|
||
> 注意:同时需要把 `smartField` 函数的样式字符串更新为紧凑版本(见变更 4)。
|
||
|
||
### 变更 3:`src/utils/defaultContent.ts` — 更新 `smartField` 紧凑样式
|
||
|
||
**替换现有的 `smartField` 函数:**
|
||
|
||
```typescript
|
||
const smartField = (key: string) => `
|
||
<span class="smart-field-wrapper" contenteditable="false">
|
||
<span class="field-value"
|
||
data-bind="${key}"
|
||
contenteditable="true"
|
||
style="min-width: 32px; padding: 0 4px; margin: 0 2px; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: 1.2; font-size: inherit; vertical-align: text-bottom; box-sizing: border-box; min-height: 1.2em;">
|
||
</span>
|
||
</span>
|
||
`;
|
||
```
|
||
|
||
### 变更 4:`src/index.css` — 同步优化 `.field-value` 样式
|
||
|
||
**在 `.smart-field-wrapper` 相关样式区块中更新 `.field-value`:**
|
||
|
||
```css
|
||
.smart-field-wrapper .field-value {
|
||
min-width: 32px;
|
||
padding: 0 4px;
|
||
margin: 0 2px;
|
||
border: 1px solid #cbd5e1;
|
||
border-radius: 2px;
|
||
display: inline-block;
|
||
background: #f8fafc;
|
||
color: #0f172a;
|
||
line-height: 1.2;
|
||
font-size: inherit;
|
||
vertical-align: text-bottom;
|
||
box-sizing: border-box;
|
||
min-height: 1.2em;
|
||
outline: none;
|
||
}
|
||
```
|
||
|
||
**打印样式同步更新:**
|
||
|
||
```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;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 变更 5:`src/utils/print.ts` — 同步打印样式
|
||
|
||
在 iframe 内联 `<style>` 中,将 `.smart-field-wrapper .field-value` 的默认样式更新为紧凑版本,并保留 `@media print` 下划线样式。
|
||
|
||
### 变更 6:`src/pages/TemplateManage.tsx` — 字段库重构
|
||
|
||
**新增状态:**
|
||
|
||
```tsx
|
||
const [fieldLibTab, setFieldLibTab] = useState<'insert' | 'manage'>('insert');
|
||
const [formFields, setFormFields] = useState<FormField[]>([]);
|
||
const [newFieldForm, setNewFieldForm] = useState({ label: '', category: '填空', type: 'text' as FieldType });
|
||
const [newFieldOptions, setNewFieldOptions] = useState('');
|
||
```
|
||
|
||
**初始化(在 `useEffect` 中读取/初始化配置):**
|
||
|
||
```tsx
|
||
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
|
||
if (savedFields.length > 0) {
|
||
setFormFields(savedFields);
|
||
} else {
|
||
setFormFields(DEFAULT_FORM_FIELDS);
|
||
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
|
||
}
|
||
```
|
||
|
||
**插入字段 Tab UI:**
|
||
|
||
```tsx
|
||
<div className="space-y-4">
|
||
{['填空', '单选', '多选', '时间'].map(cat => {
|
||
const catFields = formFields.filter(f => f.category === cat);
|
||
if (catFields.length === 0) return null;
|
||
return (
|
||
<div key={cat}>
|
||
<div className="text-[10px] text-slate-400 mb-1">{cat}</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{catFields.map(field => (
|
||
<button
|
||
key={field.key}
|
||
onClick={() => insertSmartField(field)}
|
||
className="..."
|
||
>
|
||
{field.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
```
|
||
|
||
> `insertSmartField` 函数的参数改为 `FormField`,使用 `field.key` 和 `field.label` 生成 HTML。
|
||
|
||
**字段管理 Tab UI:**
|
||
|
||
```tsx
|
||
<div className="space-y-3">
|
||
{formFields.filter(f => !f.isSystemLocked).map(field => (
|
||
<div key={field.key} className="flex items-center justify-between p-2 bg-slate-50 rounded border border-slate-200">
|
||
<div className="text-xs">
|
||
<div className="font-medium text-text-main">{field.label}</div>
|
||
<div className="text-[10px] text-slate-400">{field.category} · {field.type}</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<label className="flex items-center gap-1 text-[10px] text-slate-600">
|
||
<input
|
||
type="checkbox"
|
||
checked={field.visibleInForm}
|
||
onChange={() => toggleFieldVisible(field.key)}
|
||
/>
|
||
显示
|
||
</label>
|
||
<button onClick={() => deleteField(field.key)} className="text-red-500 text-[10px]">删除</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<div className="pt-2 border-t border-slate-200">
|
||
<div className="text-xs font-semibold mb-2">新增字段</div>
|
||
<input ... />
|
||
<select ... />
|
||
<button onClick={addField}>添加</button>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
**关键操作函数:**
|
||
|
||
```tsx
|
||
const toggleFieldVisible = (key: string) => {
|
||
const updated = formFields.map(f => f.key === key ? { ...f, visibleInForm: !f.visibleInForm } : f);
|
||
setFormFields(updated);
|
||
storage.set('formFieldsConfig', updated);
|
||
};
|
||
|
||
const deleteField = (key: string) => {
|
||
const updated = formFields.filter(f => f.key !== key);
|
||
setFormFields(updated);
|
||
storage.set('formFieldsConfig', updated);
|
||
};
|
||
|
||
const addField = () => {
|
||
if (!newFieldForm.label.trim()) return;
|
||
const key = 'custom_' + Date.now();
|
||
const newField: FormField = {
|
||
key,
|
||
label: newFieldForm.label.trim(),
|
||
category: newFieldForm.category,
|
||
type: newFieldForm.type,
|
||
visibleInForm: true,
|
||
isSystemLocked: false,
|
||
options: ['单选', '多选'].includes(newFieldForm.category) && newFieldOptions.trim()
|
||
? newFieldOptions.split(/[,,]/).map(s => s.trim()).filter(Boolean)
|
||
: undefined
|
||
};
|
||
const updated = [...formFields, newField];
|
||
setFormFields(updated);
|
||
storage.set('formFieldsConfig', updated);
|
||
setNewFieldForm({ label: '', category: '填空', type: 'text' });
|
||
setNewFieldOptions('');
|
||
};
|
||
```
|
||
|
||
### 变更 7:`src/pages/ReportEditor.tsx` — 动态渲染右侧表单 + 时间联动
|
||
|
||
**初始化字段配置(在 `useEffect` 中):**
|
||
|
||
```tsx
|
||
const [formFields, setFormFields] = useState<FormField[]>([]);
|
||
// ...
|
||
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
|
||
if (savedFields.length > 0) {
|
||
setFormFields(savedFields);
|
||
} else {
|
||
setFormFields(DEFAULT_FORM_FIELDS);
|
||
}
|
||
```
|
||
|
||
**时间解析/拼接辅助函数:**
|
||
|
||
```tsx
|
||
const formatTimeValue = (hour?: string, minute?: string) => {
|
||
if (!hour && !minute) return '';
|
||
return `${hour || ''}:${minute || ''}`;
|
||
};
|
||
|
||
const parseTimeValue = (value: string) => {
|
||
const parts = value.split(':');
|
||
return { hour: parts[0] || '', minute: parts[1] || '' };
|
||
};
|
||
```
|
||
|
||
**表单 → 方格的时间同步(在 `reportData` 的 `useEffect` 中):**
|
||
|
||
```tsx
|
||
// 对时间字段做特殊拼接
|
||
let newValue = '';
|
||
if (fieldKey === 'startTime') {
|
||
newValue = formatTimeValue(reportData.startHour, reportData.startMinute);
|
||
} else if (fieldKey === 'endTime') {
|
||
newValue = formatTimeValue(reportData.endHour, reportData.endMinute);
|
||
} else {
|
||
const rawValue = (reportData as any)[fieldKey];
|
||
if (Array.isArray(rawValue)) newValue = rawValue.join(', ');
|
||
else if (rawValue !== undefined && rawValue !== null) newValue = String(rawValue);
|
||
}
|
||
```
|
||
|
||
**方格 → 表单的时间同步(在 `handleEditorInput` 中):**
|
||
|
||
```tsx
|
||
if (target && target.hasAttribute('data-bind')) {
|
||
const fieldKey = target.getAttribute('data-bind')!;
|
||
const newValue = target.innerText;
|
||
|
||
if (fieldKey === 'startTime') {
|
||
const { hour, minute } = parseTimeValue(newValue);
|
||
setReportData(prev => {
|
||
const next = { ...prev, startHour: hour, startMinute: minute };
|
||
stateRef.current = { ...stateRef.current, reportData: next };
|
||
return next;
|
||
});
|
||
} else if (fieldKey === 'endTime') {
|
||
const { hour, minute } = parseTimeValue(newValue);
|
||
setReportData(prev => {
|
||
const next = { ...prev, endHour: hour, endMinute: minute };
|
||
stateRef.current = { ...stateRef.current, reportData: next };
|
||
return next;
|
||
});
|
||
} else {
|
||
setReportData(prev => {
|
||
const next = { ...prev, [fieldKey]: newValue };
|
||
stateRef.current = { ...stateRef.current, reportData: next };
|
||
return next;
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
**动态渲染右侧表单(替换现有的硬编码表单区域):**
|
||
|
||
将现有的 `activeTab === 'info'` 下的 `<div className="report-info-form space-y-4">...` 整体替换为:
|
||
|
||
```tsx
|
||
{activeTab === 'info' && (
|
||
<div className="report-info-form space-y-4">
|
||
{formFields.filter(f => f.visibleInForm).map(field => {
|
||
if (field.type === 'text' || field.type === 'date') {
|
||
const inputType = field.type === 'date' ? 'date' : 'text';
|
||
return (
|
||
<div key={field.key} className="space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">{field.label}</label>
|
||
<input
|
||
type={inputType}
|
||
value={(reportData as any)[field.key] || ''}
|
||
onChange={(e) => { const next = { ...reportData, [field.key]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal"
|
||
placeholder={field.label}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (field.type === 'single_select') {
|
||
return (
|
||
<div key={field.key} className="space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">{field.label}</label>
|
||
<select
|
||
value={(reportData as any)[field.key] || ''}
|
||
onChange={(e) => { const next = { ...reportData, [field.key]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal bg-white"
|
||
>
|
||
<option value="">请选择</option>
|
||
{(field.options || []).map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||
</select>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (field.type === 'multi_select') {
|
||
const isOpen = openDropdown === field.key;
|
||
return (
|
||
<div key={field.key} className="space-y-1 select-dropdown-root relative">
|
||
<label className="block text-xs font-bold text-text-main">{field.label}</label>
|
||
<div className="..." onClick={() => setOpenDropdown(field.key)}>
|
||
{/* 复用现有的多选标签渲染逻辑,字段名用 field.key */}
|
||
</div>
|
||
{/* 下拉选项弹窗 ... */}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (field.type === 'time') {
|
||
const hourKey = field.key === 'startTime' ? 'startHour' : 'endHour';
|
||
const minuteKey = field.key === 'startTime' ? 'startMinute' : 'endMinute';
|
||
return (
|
||
<div key={field.key} className="space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">{field.label}</label>
|
||
<div className="flex items-center gap-2">
|
||
<select
|
||
value={(reportData as any)[hourKey] || ''}
|
||
onChange={(e) => { const next = { ...reportData, [hourKey]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal bg-white flex-1"
|
||
>
|
||
<option value="">--</option>
|
||
{hourOptions.map(h => <option key={h} value={h}>{h}</option>)}
|
||
</select>
|
||
<span className="text-text-muted">:</span>
|
||
<select
|
||
value={(reportData as any)[minuteKey] || ''}
|
||
onChange={(e) => { const next = { ...reportData, [minuteKey]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal bg-white flex-1"
|
||
>
|
||
<option value="">--</option>
|
||
{minuteOptions.map(m => <option key={m} value={m}>{m}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
})}
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
> 对于 `multi_select`,可以完全复用现有的 `surgeon`/`assistant`/`anesthesiologist` 的多选组件逻辑,只需将硬编码的字段名替换为 `field.key`,并将 `multiSelectOptions` 的读取逻辑泛化为从 `field.options` 读取。
|
||
|
||
### 变更 8:`src/pages/Login.tsx` — 首次登录初始化字段配置
|
||
|
||
在 Login 页面初始化默认数据时(与其他 `storage.set` 一起),增加:
|
||
|
||
```tsx
|
||
if (!storage.get<FormField[]>('formFieldsConfig', null)) {
|
||
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
|
||
}
|
||
```
|
||
|
||
## 风险点
|
||
|
||
| 风险 | 级别 | 应对措施 |
|
||
|------|------|---------|
|
||
| 老用户的 `localStorage` 中没有 `formFieldsConfig`,首次进入可能显示空白表单 | 中 | `ReportEditor` 和 `TemplateManage` 中都做 fallback:若不存在则使用 `DEFAULT_FORM_FIELDS` 并自动写入 localStorage |
|
||
| `ReportEditor` 动态渲染多选字段时,现有 `multiSelectOptions` 状态与新字段体系冲突 | 中 | 多选字段的选项统一从 `field.options` 读取,不再依赖独立的 `multiSelectOptions` 状态(或做兼容映射) |
|
||
| 时间方格输入非标准格式(如"930"而非"09:30")导致解析失败 | 低 | `parseTimeValue` 使用简单 `split(':')`,若格式不对则 `hour`/`minute` 保持原样或空字符串,不影响系统稳定性 |
|
||
| 删除自定义字段后,老报告中仍包含该 `data-bind` 节点 | 低 | 老报告中的 orphan 节点只是普通可编辑方格,右侧表单不显示对应输入项,属于预期行为 |
|
||
|
||
## 回滚策略
|
||
|
||
本次改动涉及数据结构和多处 UI 渲染。如出现异常,可:
|
||
1. `git revert` 回滚代码;
|
||
2. 手动在浏览器控制台执行 `localStorage.removeItem('formFieldsConfig')` 恢复默认字段配置。
|
||
|
||
---
|
||
|
||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**
|