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

18 KiB
Raw Blame History

实现方案 — 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 修改 新增 FieldTypeFormFieldFormFieldsConfig 类型
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

具体代码变更

变更 1src/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: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
];

变更 2src/utils/defaultContent.ts — 手术时间方框化

替换手术时间相关段落:

    <p style="font-family: SimSun;">
        手术开始时间:${smartField('startTime')} 
        手术终止时间:${smartField('endTime')}
    </p>

注意:同时需要把 smartField 函数的样式字符串更新为紧凑版本(见变更 4

变更 3src/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>
`;

变更 4src/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;
  }
}

变更 5src/utils/print.ts — 同步打印样式

在 iframe 内联 <style> 中,将 .smart-field-wrapper .field-value 的默认样式更新为紧凑版本,并保留 @media print 下划线样式。

变更 6src/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.keyfield.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('');
};

变更 7src/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] || '' };
};

表单 → 方格的时间同步(在 reportDatauseEffect 中):

// 对时间字段做特殊拼接
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 读取。

变更 8src/pages/Login.tsx — 首次登录初始化字段配置

在 Login 页面初始化默认数据时(与其他 storage.set 一起),增加:

if (!storage.get<FormField[]>('formFieldsConfig', null)) {
  storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
}

风险点

风险 级别 应对措施
老用户的 localStorage 中没有 formFieldsConfig,首次进入可能显示空白表单 ReportEditorTemplateManage 中都做 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') 恢复默认字段配置。

⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。