Files
Mdeical_Sur_Report/工程分析/实现方案-2026-04-17-00-13-09.md

447 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 实现方案 — 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')` 恢复默认字段配置。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**