18 KiB
实现方案 — 2026-04-17-00-13-09
根因分析
当前系统存在三个核心问题:
- 时间字段未联动:
defaultContent.ts中手术开始/终止时间是纯文本占位符,无data-bind,导致右侧表单与正文内容无法同步。 - 表单硬编码不可扩展:
ReportEditor.tsx右侧的基本信息表单是写死的 JSX,每新增一个字段都需要改代码;TemplateManage.tsx的字段库也是静态数组,无法按医院实际需求自定义。 - 方格 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 之后追加:
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 — 手术时间方框化
替换手术时间相关段落:
<p style="font-family: SimSun;">
手术开始时间:${smartField('startTime')}
手术终止时间:${smartField('endTime')}
</p>
注意:同时需要把
smartField函数的样式字符串更新为紧凑版本(见变更 4)。
变更 3:src/utils/defaultContent.ts — 更新 smartField 紧凑样式
替换现有的 smartField 函数:
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:
.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;
}
打印样式同步更新:
@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 — 字段库重构
新增状态:
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 中读取/初始化配置):
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:
<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:
<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>
关键操作函数:
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 中):
const [formFields, setFormFields] = useState<FormField[]>([]);
// ...
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
if (savedFields.length > 0) {
setFormFields(savedFields);
} else {
setFormFields(DEFAULT_FORM_FIELDS);
}
时间解析/拼接辅助函数:
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 中):
// 对时间字段做特殊拼接
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 中):
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">... 整体替换为:
{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 一起),增加:
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 渲染。如出现异常,可:
git revert回滚代码;- 手动在浏览器控制台执行
localStorage.removeItem('formFieldsConfig')恢复默认字段配置。
⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。