2026-04-17-00-13-09 - 手术时间方框联动、动态字段分类管理体系、字段显隐控制、UI紧凑化优化

This commit is contained in:
2026-04-17 00:30:11 +08:00
parent 952856e8c6
commit 2a4934e7c4
11 changed files with 1116 additions and 293 deletions

View File

@@ -100,21 +100,27 @@
.smart-field-wrapper {
display: inline-flex;
align-items: center;
margin: 0 4px;
vertical-align: middle;
margin: 0 2px;
vertical-align: text-bottom;
}
.smart-field-wrapper .field-label {
color: #64748b;
user-select: none;
}
.smart-field-wrapper .field-value {
min-width: 60px;
min-width: 32px;
padding: 0 4px;
margin: 0 2px;
border: 1px solid #cbd5e1;
border-radius: 4px;
border-radius: 2px;
display: inline-block;
background: #fff;
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;
}
.smart-field-wrapper .field-value:empty::before {

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { User, Template, SystemSettings } from '../types';
import { User, Template, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { storage } from '../utils/storage';
import { User as UserIcon, Lock } from 'lucide-react';
@@ -41,6 +41,11 @@ export default function Login() {
console.log('Default users initialized');
}
const fieldsConfig = storage.get<FormField[]>('formFieldsConfig', []);
if (fieldsConfig.length === 0) {
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
}
const settingsRaw = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
if (!settingsRaw.frameCount) {
const round1 = (n: number) => Math.round(n * 10) / 10;

View File

@@ -7,7 +7,7 @@ import {
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
Video, Play, Pause, Plus, X, ChevronLeft
} from 'lucide-react';
import { User, Report, Template, CapturedFrame, SystemSettings } from '../types';
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
import { storage } from '../utils/storage';
@@ -61,6 +61,7 @@ export default function ReportEditor() {
const [anesthesiaOptions, setAnesthesiaOptions] = useState<string[]>(['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉']);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [formFields, setFormFields] = useState<FormField[]>([]);
const editorRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
@@ -108,6 +109,14 @@ export default function ReportEditor() {
const savedAnesthesia = storage.get<string[] | null>('anesthesiaOptions', null);
if (savedAnesthesia) setAnesthesiaOptions(savedAnesthesia);
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
if (savedFields.length > 0) {
setFormFields(savedFields);
} else {
setFormFields(DEFAULT_FORM_FIELDS);
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
}
const allTemplates = storage.get<Template[]>('templates', []);
const visibleTplIds = Array.isArray(user.visibleTemplates) ? user.visibleTemplates : allTemplates.map(t => t.id);
const filteredTemplates = allTemplates.filter(t => visibleTplIds.includes(t.id));
@@ -776,8 +785,8 @@ export default function ReportEditor() {
const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
const minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
const addTag = (field: 'surgeon' | 'assistant' | 'anesthesiologist', value: string) => {
const current = reportData[field] || [];
const addTag = (field: string, value: string) => {
const current = (reportData as any)[field] || [];
if (!current.includes(value)) {
const next = { ...reportData, [field]: [...current, value] };
setReportData(next);
@@ -791,21 +800,35 @@ export default function ReportEditor() {
setMultiSelectOptions(next);
storage.set('multiSelectOptions', next);
}
// Sync to formFieldsConfig
const fieldDef = formFields.find(f => f.key === field);
if (fieldDef && fieldDef.options && !fieldDef.options.includes(value)) {
const updatedFields = formFields.map(f => f.key === field ? { ...f, options: [...(f.options || []), value] } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}
};
const removeTag = (field: 'surgeon' | 'assistant' | 'anesthesiologist', value: string) => {
const current = reportData[field] || [];
const next = { ...reportData, [field]: current.filter(v => v !== value) };
const removeTag = (field: string, value: string) => {
const current = (reportData as any)[field] || [];
const next = { ...reportData, [field]: current.filter((v: string) => v !== value) };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
};
const removeMultiOption = (field: 'surgeon' | 'assistant' | 'anesthesiologist', value: string) => {
const removeMultiOption = (field: string, value: string) => {
const current = multiSelectOptions[field] || [];
const next = { ...multiSelectOptions, [field]: current.filter(v => v !== value) };
setMultiSelectOptions(next);
storage.set('multiSelectOptions', next);
// Sync to formFieldsConfig
const fieldDef = formFields.find(f => f.key === field);
if (fieldDef && fieldDef.options) {
const updatedFields = formFields.map(f => f.key === field ? { ...f, options: (f.options || []).filter(v => v !== value) } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}
};
const removeAnesthesiaOption = (value: string) => {
@@ -885,12 +908,28 @@ export default function ReportEditor() {
const fieldKey = target.getAttribute('data-bind')!;
const newValue = target.innerText;
if (fieldKey === 'startTime') {
const parts = newValue.split(':');
setReportData((prev) => {
const next = { ...prev, startHour: parts[0] || '', startMinute: parts[1] || '' };
stateRef.current = { ...stateRef.current, reportData: next };
return next;
});
} else if (fieldKey === 'endTime') {
const parts = newValue.split(':');
setReportData((prev) => {
const next = { ...prev, endHour: parts[0] || '', endMinute: parts[1] || '' };
stateRef.current = { ...stateRef.current, reportData: next };
return next;
});
} else {
setReportData((prev) => {
const next = { ...prev, [fieldKey]: newValue };
stateRef.current = { ...stateRef.current, reportData: next };
return next;
});
}
}
};
// Sync form state -> rich text field values
@@ -900,14 +939,22 @@ export default function ReportEditor() {
bindNodes.forEach((node) => {
const el = node as HTMLElement;
const fieldKey = el.getAttribute('data-bind')!;
const rawValue = (reportData as any)[fieldKey];
let newValue = '';
if (fieldKey === 'startTime') {
newValue = `${reportData.startHour || ''}:${reportData.startMinute || ''}`;
if (newValue === ':') newValue = '';
} else if (fieldKey === 'endTime') {
newValue = `${reportData.endHour || ''}:${reportData.endMinute || ''}`;
if (newValue === ':') newValue = '';
} else {
const rawValue = (reportData as any)[fieldKey];
if (Array.isArray(rawValue)) {
newValue = rawValue.join(', ');
} else if (rawValue !== undefined && rawValue !== null) {
newValue = String(rawValue);
}
}
if (el.innerText !== newValue) {
el.innerText = newValue;
@@ -1075,227 +1122,64 @@ export default function ReportEditor() {
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{activeTab === 'info' && (
<div className="report-info-form space-y-4">
<div className="flex gap-4">
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"> <span className="text-red-500">*</span></label>
<input
type="text"
value={reportData.patientName}
onChange={(e) => { const next = {...reportData, patientName: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
onBlur={() => setTouched(t => ({ ...t, patientName: true }))}
className={`input-minimal ${touched.patientName && !reportData.patientName ? 'border-red-500' : ''}`}
placeholder="患者姓名"
/>
</div>
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"> <span className="text-red-500">*</span></label>
<input
type="text"
value={reportData.hospitalId}
onChange={(e) => { const next = {...reportData, hospitalId: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
onBlur={() => setTouched(t => ({ ...t, hospitalId: true }))}
className={`input-minimal ${touched.hospitalId && !reportData.hospitalId ? 'border-red-500' : ''}`}
placeholder="住院号"
/>
</div>
</div>
{formFields.filter(f => f.visibleInForm).map(field => {
const isRequired = field.isSystemLocked;
const hasError = isRequired && touched[field.key] && !(reportData as any)[field.key];
<div className="space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<input
type="text"
value={reportData.title}
onChange={(e) => { const next = {...reportData, title: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal"
placeholder="请输入手术名称"
/>
</div>
<div className="flex gap-4">
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<select
value={reportData.patientGender}
onChange={(e) => { const next = {...reportData, patientGender: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal bg-white"
>
<option value=""></option>
<option value="男"></option>
<option value="女"></option>
</select>
</div>
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<input
type="text"
value={reportData.patientAge}
onChange={(e) => { const next = {...reportData, patientAge: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal"
placeholder="年龄"
/>
</div>
</div>
<div className="flex gap-4">
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<input
type="text"
value={reportData.department}
onChange={(e) => { const next = {...reportData, department: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal"
placeholder="科室"
/>
</div>
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<input
type="text"
value={reportData.bedNumber}
onChange={(e) => { const next = {...reportData, bedNumber: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal"
placeholder="床号"
/>
</div>
</div>
<div className="space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<input
type="date"
value={reportData.surgeryDate}
onChange={(e) => { const next = {...reportData, surgeryDate: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal"
/>
</div>
<div className="flex gap-4">
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<div className="flex items-center gap-2">
<select
value={reportData.startHour}
onChange={(e) => { const next = {...reportData, startHour: 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.startMinute}
onChange={(e) => { const next = {...reportData, startMinute: 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>
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<div className="flex items-center gap-2">
<select
value={reportData.endHour}
onChange={(e) => { const next = {...reportData, endHour: 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.endMinute}
onChange={(e) => { const next = {...reportData, endMinute: 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>
</div>
{(['surgeon', 'assistant', 'anesthesiologist'] as const).map((field) => {
const labels = { surgeon: '手术者', assistant: '助手', anesthesiologist: '麻醉师' };
const isOpen = openDropdown === field;
if (field.type === 'text' || field.type === 'date') {
const inputType = field.type === 'date' ? 'date' : 'text';
return (
<div key={field} className="space-y-1 select-dropdown-root relative">
<label className="block text-xs font-bold text-text-main">{labels[field]}</label>
<div
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex flex-wrap gap-1 items-center min-h-[42px] cursor-text"
onClick={() => setOpenDropdown(field)}
>
{(reportData[field] || []).map(tag => (
<span key={tag} className="px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 flex items-center gap-1">
{tag}
<span className="cursor-pointer hover:text-amber-900" onClick={(e) => { e.stopPropagation(); removeTag(field, tag); }}>×</span>
</span>
))}
<div key={field.key} className={field.category === '填空' && formFields.filter(f2 => f2.visibleInForm && f2.type === 'text' && f2.isSystemLocked).length > 1 && (field.key === 'patientName' || field.key === 'hospitalId') ? 'flex-1 space-y-1' : 'space-y-1'}>
<label className="block text-xs font-bold text-text-main">
{field.label} {isRequired && <span className="text-red-500">*</span>}
</label>
<input
type="text"
className="outline-none text-sm min-w-[60px] flex-1 bg-transparent"
placeholder="输入或选择"
onFocus={() => setOpenDropdown(field)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = (e.target as HTMLInputElement).value.trim();
if (val) { addTag(field, val); (e.target as HTMLInputElement).value = ''; }
}
}}
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(); }}
onBlur={() => setTouched(t => ({ ...t, [field.key]: true }))}
className={`input-minimal ${hasError ? 'border-red-500' : ''}`}
placeholder={field.label}
/>
</div>
{isOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded-lg shadow-lg max-h-[150px] overflow-y-auto">
{(multiSelectOptions[field] || []).map(opt => (
<div
key={opt}
className="px-3 py-2 text-sm hover:bg-slate-50 cursor-pointer flex justify-between items-center"
onClick={() => { addTag(field, opt); }}
>
<span>{opt}</span>
<span
className="text-red-500 text-xs hover:bg-red-50 px-1 rounded"
onClick={(e) => { e.stopPropagation(); removeMultiOption(field, opt); }}
>×</span>
</div>
))}
{(multiSelectOptions[field] || []).length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted"></div>
)}
</div>
)}
</div>
);
})}
}
<div className="space-y-1 select-dropdown-root relative">
<label className="block text-xs font-bold text-text-main"></label>
if (field.type === 'single_select') {
const isOpen = openDropdown === field.key;
const opts = field.options || (field.key === 'anesthesiaType' ? anesthesiaOptions : []);
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="w-full px-3 py-2 border border-border rounded-lg bg-white flex items-center min-h-[42px] cursor-text"
onClick={() => setOpenDropdown('anesthesia')}
onClick={() => setOpenDropdown(field.key)}
>
<input
type="text"
className="outline-none text-sm flex-1 bg-transparent"
placeholder="输入或选择麻醉方式"
value={reportData.anesthesiaType || ''}
onChange={(e) => { const next = {...reportData, anesthesiaType: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
onFocus={() => setOpenDropdown('anesthesia')}
placeholder={`输入或选择${field.label}`}
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(); }}
onFocus={() => setOpenDropdown(field.key)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = (e.target as HTMLInputElement).value.trim();
if (val) {
const next = {...reportData, anesthesiaType: val};
const next = { ...reportData, [field.key]: val };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
if (!anesthesiaOptions.includes(val)) {
const next = [...anesthesiaOptions, val];
setAnesthesiaOptions(next);
storage.set('anesthesiaOptions', next);
if (!opts.includes(val)) {
const updatedOpts = [...opts, val];
if (field.key === 'anesthesiaType') {
setAnesthesiaOptions(updatedOpts);
storage.set('anesthesiaOptions', updatedOpts);
}
const updatedFields = formFields.map(f => f.key === field.key ? { ...f, options: updatedOpts } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}
setOpenDropdown(null);
}
@@ -1303,28 +1187,125 @@ export default function ReportEditor() {
}}
/>
</div>
{openDropdown === 'anesthesia' && (
{isOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded-lg shadow-lg max-h-[150px] overflow-y-auto">
{anesthesiaOptions.map(opt => (
{opts.map(opt => (
<div
key={opt}
className="px-3 py-2 text-sm hover:bg-slate-50 cursor-pointer flex justify-between items-center"
onClick={() => { const next = {...reportData, anesthesiaType: opt}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); setOpenDropdown(null); }}
onClick={() => { const next = { ...reportData, [field.key]: opt }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); setOpenDropdown(null); }}
>
<span>{opt}</span>
<span
className="text-red-500 text-xs hover:bg-red-50 px-1 rounded"
onClick={(e) => { e.stopPropagation(); removeAnesthesiaOption(opt); }}
onClick={(e) => {
e.stopPropagation();
const updatedOpts = opts.filter(v => v !== opt);
if (field.key === 'anesthesiaType') {
setAnesthesiaOptions(updatedOpts);
storage.set('anesthesiaOptions', updatedOpts);
}
const updatedFields = formFields.map(f => f.key === field.key ? { ...f, options: updatedOpts } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}}
>×</span>
</div>
))}
{anesthesiaOptions.length === 0 && (
{opts.length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted"></div>
)}
</div>
)}
</div>
);
}
if (field.type === 'multi_select') {
const isOpen = openDropdown === field.key;
const opts = field.options || multiSelectOptions[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="w-full px-3 py-2 border border-border rounded-lg bg-white flex flex-wrap gap-1 items-center min-h-[42px] cursor-text"
onClick={() => setOpenDropdown(field.key)}
>
{((reportData as any)[field.key] || []).map((tag: string) => (
<span key={tag} className="px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 flex items-center gap-1">
{tag}
<span className="cursor-pointer hover:text-amber-900" onClick={(e) => { e.stopPropagation(); removeTag(field.key, tag); }}>×</span>
</span>
))}
<input
type="text"
className="outline-none text-sm min-w-[60px] flex-1 bg-transparent"
placeholder="输入或选择"
onFocus={() => setOpenDropdown(field.key)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = (e.target as HTMLInputElement).value.trim();
if (val) { addTag(field.key, val); (e.target as HTMLInputElement).value = ''; }
}
}}
/>
</div>
{isOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded-lg shadow-lg max-h-[150px] overflow-y-auto">
{opts.map(opt => (
<div
key={opt}
className="px-3 py-2 text-sm hover:bg-slate-50 cursor-pointer flex justify-between items-center"
onClick={() => { addTag(field.key, opt); }}
>
<span>{opt}</span>
<span
className="text-red-500 text-xs hover:bg-red-50 px-1 rounded"
onClick={(e) => { e.stopPropagation(); removeMultiOption(field.key, opt); }}
>×</span>
</div>
))}
{opts.length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted"></div>
)}
</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>
)}

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check } from 'lucide-react';
import { User, Template, BINDABLE_FIELDS } from '../types';
import { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
import { storage } from '../utils/storage';
@@ -18,6 +18,10 @@ export default function TemplateManage() {
const [isSaved, setIsSaved] = useState(false);
const editorRef = useRef<HTMLDivElement>(null);
const savedRangeRef = useRef<Range | null>(null);
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('');
const updatePageHeight = () => {
if (!editorRef.current) return;
@@ -36,6 +40,14 @@ export default function TemplateManage() {
}
setCurrentUser(user);
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
if (savedFields.length > 0) {
setFormFields(savedFields);
} else {
setFormFields(DEFAULT_FORM_FIELDS);
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
}
const savedTemplates = storage.get<Template[]>('templates', []);
if (savedTemplates.length === 0) {
const initial: Template = {
@@ -156,15 +168,14 @@ export default function TemplateManage() {
editorRef.current?.focus();
};
const insertSmartField = (field: typeof BINDABLE_FIELDS[0]) => {
const insertSmartField = (field: FormField) => {
editorRef.current?.focus();
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;">
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>&nbsp;
`;
@@ -172,6 +183,39 @@ export default function TemplateManage() {
editorRef.current?.focus();
};
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('');
};
const insertTable = () => {
const rowsStr = prompt('请输入行数:', '2');
const colsStr = prompt('请输入列数:', '3');
@@ -469,15 +513,31 @@ export default function TemplateManage() {
</div>
{/* Right: Field Library */}
<aside className="w-[220px] bg-sidebar-bg border-l border-border flex flex-col shrink-0 overflow-hidden">
<div className="p-4 border-b border-border">
<span className="text-sm font-bold text-text-main uppercase tracking-wider"></span>
<aside className="w-[240px] bg-sidebar-bg border-l border-border flex flex-col shrink-0 overflow-hidden">
<div className="flex border-b border-border">
{(['insert', 'manage'] as const).map(tab => (
<button
key={tab}
onClick={() => setFieldLibTab(tab)}
className={`flex-1 py-3 text-xs font-bold transition-all border-b-2 uppercase tracking-wider ${
fieldLibTab === tab ? 'text-accent border-accent' : 'text-text-muted border-transparent hover:text-text-main'
}`}
>
{tab === 'insert' ? '插入字段' : '字段管理'}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div className="card-minimal p-3">
<h3 className="text-xs font-semibold text-primary mb-2"></h3>
{fieldLibTab === 'insert' && (
<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.5 font-medium">{cat}</div>
<div className="flex flex-wrap gap-1.5">
{BINDABLE_FIELDS.map((field) => (
{catFields.map(field => (
<button
key={field.key}
type="button"
@@ -489,8 +549,90 @@ export default function TemplateManage() {
</button>
))}
</div>
<p className="text-[10px] text-slate-400 mt-2 leading-tight">Label Value </p>
</div>
);
})}
</div>
)}
{fieldLibTab === 'manage' && (
<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 cursor-pointer">
<input
type="checkbox"
checked={field.visibleInForm}
onChange={() => toggleFieldVisible(field.key)}
/>
</label>
<button onClick={() => deleteField(field.key)} className="text-red-500 text-[10px] hover:underline"></button>
</div>
</div>
))}
<div className="pt-3 border-t border-slate-200 space-y-2">
<div className="text-xs font-semibold text-text-main"></div>
<input
type="text"
value={newFieldForm.label}
onChange={(e) => setNewFieldForm({ ...newFieldForm, label: e.target.value })}
placeholder="字段名称"
className="w-full px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent"
/>
<div className="flex gap-2">
<select
value={newFieldForm.category}
onChange={(e) => {
const cat = e.target.value;
let t: FieldType = 'text';
if (cat === '单选') t = 'single_select';
else if (cat === '多选') t = 'multi_select';
else if (cat === '时间') t = 'date';
setNewFieldForm({ ...newFieldForm, category: cat, type: t });
}}
className="flex-1 px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent bg-white"
>
<option value="填空"></option>
<option value="单选"></option>
<option value="多选"></option>
<option value="时间"></option>
</select>
<select
value={newFieldForm.type}
onChange={(e) => setNewFieldForm({ ...newFieldForm, type: e.target.value as FieldType })}
className="flex-1 px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent bg-white"
>
<option value="text"></option>
{newFieldForm.category === '单选' && <option value="single_select"></option>}
{newFieldForm.category === '多选' && <option value="multi_select"></option>}
{newFieldForm.category === '时间' && <><option value="date"></option><option value="time"></option></>}
</select>
</div>
{['单选', '多选'].includes(newFieldForm.category) && (
<input
type="text"
value={newFieldOptions}
onChange={(e) => setNewFieldOptions(e.target.value)}
placeholder="选项,用逗号分隔"
className="w-full px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent"
/>
)}
<button
onClick={addField}
className="w-full py-1.5 bg-accent text-white text-xs font-semibold rounded hover:opacity-90 transition-colors"
>
</button>
</div>
</div>
)}
</div>
</aside>
</div>

View File

@@ -94,8 +94,39 @@ export const BINDABLE_FIELDS: BindableField[] = [
{ key: 'hospitalId', label: '住院号' },
{ key: 'surgeryDate', label: '手术日期' },
{ key: 'title', label: '手术名称' },
{ key: 'startTime', label: '手术开始时间' },
{ key: 'endTime', label: '手术终止时间' },
{ key: 'surgeon', label: '手术者' },
{ key: 'assistant', label: '助手' },
{ key: 'anesthesiologist', label: '麻醉师' },
{ key: 'anesthesiaType', label: '麻醉方式' },
];
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: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
];

View File

@@ -3,7 +3,7 @@ const smartField = (key: string) => `
<span class="field-value"
data-bind="${key}"
contenteditable="true"
style="min-width: 60px; padding: 0 4px; border: 1px solid #cbd5e1; border-radius: 4px; display: inline-block; background: #fff; color: #0f172a;">
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>
`;
@@ -49,8 +49,8 @@ export const defaultReportContent = `
</p>
<p style="font-family: SimSun;">
手术开始时间:<span style="color: #bdbdbd;">时 分</span>
手术终止时间:<span style="color: #bdbdbd;">时 分</span>
手术开始时间:${smartField('startTime')}
手术终止时间:${smartField('endTime')}
</p>
<p style="font-family: SimSun;">

View File

@@ -34,9 +34,9 @@ export const printDocument = (htmlContent: string) => {
.image-placeholder .delete-btn { display: none !important; }
.image-placeholder:not(.has-image) { display: none !important; }
.template-info-section { position: relative; margin-bottom: 16px; }
.smart-field-wrapper { display: inline-flex; align-items: center; margin: 0 4px; vertical-align: middle; }
.smart-field-wrapper { display: inline-flex; align-items: center; margin: 0 2px; vertical-align: text-bottom; }
.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 { 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; }
}

View File

@@ -0,0 +1,446 @@
# 实现方案 — 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')` 恢复默认字段配置。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**

View File

@@ -0,0 +1,121 @@
# 测试方案 — 2026-04-17-00-13-09
## 测试目标
验证手术时间方框联动、动态字段分类管理体系、字段显隐控制、自定义字段新增删除、以及 UI 紧凑化优化的正确性与稳定性。
## 测试环境
- 浏览器Chrome / Edge
- 前置条件:已登录系统(建议使用 `admin` 超级管理员账号)
- 测试页面:`/template-manage``/report-editor`
---
## 测试用例设计
### 用例 1手术时间方框联动
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor`(新建报告) | 默认模板中"手术开始时间:"和"手术终止时间:"后各有一个可填方格 |
| 1.2 | 检查方格的 `data-bind` 属性 | 分别为 `startTime``endTime` |
| 1.3 | 在右侧【基本信息】选择手术开始时间 "09" 时 "30" 分 | 编辑器内"手术开始时间"方格自动显示 "09:30" |
| 1.4 | 在编辑器"手术终止时间"方格内输入 "14:45" | 右侧表单"手术终止时间"下拉框自动变为 "14" 时 "45" 分 |
| 1.5 | 删除方格内内容 | 右侧时/分下拉框恢复为空("--" |
### 用例 2字段库分类展示与插入
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 进入 `/template-manage` | 右侧字段库面板显示两个 Tab"插入字段"和"字段管理",默认在"插入字段" |
| 2.2 | 观察"插入字段"Tab | 字段按"填空"、"单选"、"多选"、"时间"四组分类展示 |
| 2.3 | 点击"时间"分类下的"手术开始时间" | 编辑器光标处插入带有 `data-bind="startTime"` 的方格 |
| 2.4 | 点击"多选"分类下的"手术者" | 编辑器中插入 `data-bind="surgeon"` 的方格 |
### 用例 3字段显隐控制
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 在 `/template-manage` 切换到"字段管理"Tab | 显示所有非系统锁定字段列表 |
| 3.2 | 找到"患者性别"字段,取消其"显示"勾选 | 该字段右侧出现未勾选状态 |
| 3.3 | 进入 `/report-editor` | 右侧【基本信息】中**不再出现**"患者性别"输入项 |
| 3.4 | 返回 `/template-manage`,重新勾选"患者性别"的"显示" | — |
| 3.5 | 再次进入 `/report-editor` | "患者性别"重新出现在右侧表单中 |
### 用例 4系统锁定字段保护
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 在 `/template-manage` 的"字段管理"中查看"患者姓名"和"住院号" | 这两个字段**不在列表中**(或显示为灰色不可操作状态) |
| 4.2 | 尝试在 ReportEditor 中隐藏"患者姓名" | 无法操作,因为 TemplateManage 中没有提供隐藏开关 |
### 用例 5自定义字段新增与使用
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 5.1 | 在 `/template-manage` "字段管理"Tab 中,输入新字段名"术中失血量",选择分类"填空",类型"text",点击添加 | 列表中出现"术中失血量"字段,且"显示"已默认勾选 |
| 5.2 | 切换到"插入字段"Tab | "填空"分类下出现"术中失血量"按钮 |
| 5.3 | 点击"术中失血量"按钮插入编辑器 | 编辑器中出现 `data-bind="custom_xxxx"` 的方格 |
| 5.4 | 进入 `/report-editor` | 右侧【基本信息】中出现"术中失血量"文本输入框 |
| 5.5 | 在编辑器方格中输入"200ml" | 右侧表单同步显示"200ml" |
### 用例 6自定义字段删除
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 6.1 | 在 `/template-manage` "字段管理"中,点击"术中失血量"的"删除"按钮 | 该字段从列表中消失 |
| 6.2 | 切换到"插入字段"Tab | "术中失血量"按钮已不在"填空"分类中 |
| 6.3 | 进入 `/report-editor` | 右侧表单中不再显示"术中失血量" |
### 用例 7自定义单选/多选字段
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 7.1 | 在字段管理中新增字段"手术体位",分类"单选",类型"single_select",选项填入"仰卧位, 侧卧位, 俯卧位" | 新增成功 |
| 7.2 | 在 `/report-editor` 中查看右侧表单 | "手术体位"显示为下拉选择框,包含三个选项 |
| 7.3 | 选择"侧卧位" | 编辑器内对应方格显示"侧卧位" |
### 用例 8UI 紧凑化验证
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 8.1 | 在 `/report-editor` 中观察姓名方格 | 方格宽度明显变小(约 32px 起),不再撑大行间距 |
| 8.2 | 对比方格与周围普通文字的行高 | 行高基本一致,段落排版紧凑自然 |
| 8.3 | 进入打印预览 | 方格边框消失,变为细下划线,不破坏打印版面 |
### 用例 9老数据兼容
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 9.1 | 编辑一份之前保存的老报告(不含新自定义字段) | 页面正常加载,老字段正常显示和编辑 |
| 9.2 | 检查 `localStorage``formFieldsConfig` | 首次访问后自动生成默认配置 |
### 用例 10类型检查
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 10.1 | 执行 `npm run lint` | 无 TypeScript 编译错误 |
---
## 验收标准
- [ ] 手术开始/终止时间在模板中以方格呈现,且与右侧时/分下拉框双向联动。
- [ ] `TemplateManage` 字段库支持按"填空/单选/多选/时间"分类展示。
- [ ] `TemplateManage` 支持新增自定义字段,新增后可在 ReportEditor 表单和字段库中正常使用。
- [ ] `TemplateManage` 支持删除自定义字段,删除后 ReportEditor 表单和字段库同步移除。
- [ ] 字段显隐开关可控制 ReportEditor 右侧表单是否显示该字段。
- [ ] "患者姓名"和"住院号"为系统锁定字段,不可删除、不可隐藏。
- [ ] `field-value` 方格宽度缩小、行间距恢复正常、排版紧凑。
- [ ] 打印时方格变为下划线风格。
- [ ] 老报告和新用户首次登录均能正常加载,无报错。
- [ ] `npm run lint` 通过。
## 测试方式
手工浏览器验证为主,结合 DevTools 观察 DOM 结构和 `localStorage` 配置。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View File

@@ -309,3 +309,36 @@ if ((settings.autoInsertDelay || 0) > 0) {
- 对于需要在富文本中保护的固定文本,优先采用 `contenteditable="false"` 的包装器,而不是仅靠样式区分的普通 `<span>`。
- 在 `State -> DOM` 的同步中务必加入差异判断,避免不必要的 DOM 重写导致输入焦点异常。
- 数组类型字段(如 `surgeon`)在同步到方格前应先 `join(', ')` 转换为字符串,保持显示一致性。
---
## 记录 13手术时间方框化、动态字段分类体系与 UI 紧凑化
**A. 具体问题**
1. 手术开始/终止时间在模板中是纯文本"时 分",无法与右侧表单联动。
2. `TemplateManage` 的字段库是静态列表,无法按医院需求自定义字段;`ReportEditor` 的右侧表单全部硬编码,每新增一个字段就要改代码。
3. `field-value` 方格使用了 `min-width: 60px` 和上下 `padding`,导致行间距被撑大,排版松散。
**B. 产生问题原因**
1. 时间字段在 `defaultContent.ts` 中没有使用 `data-bind` 智能控件,且右侧表单将时间拆分为 `startHour`/`startMinute` 两个独立字段,缺少与方格的双向转换层。
2. 早期设计采用了"硬编码表单"思路,字段名、类型、选项全部写死在 `ReportEditor.tsx` 的 JSX 中,不具备扩展性。
3. `inline-block` 元素自带上下 `padding` 和 `border`,超出了默认行高,浏览器不得不增大整行高度以容纳它。
**C. 解决问题方案**
1. **时间方框联动**
- 在 `defaultContent.ts` 中替换为 `data-bind="startTime"` 和 `data-bind="endTime"` 的方格。
- 在 `ReportEditor.tsx` 的 `handleEditorInput` 中,对 `startTime`/`endTime` 使用 `split(':')` 解析,反向更新 `startHour`/`startMinute`;在 `useEffect(reportData)` 中拼接 `HH:mm` 同步回方格。
2. **动态字段体系**
- 在 `types.ts` 中新增 `FieldType`、`FormField`、`DEFAULT_FORM_FIELDS`,定义字段的 key/label/分类/类型/显隐/锁定状态/选项。
- 使用 `localStorage` 的 `formFieldsConfig` 持久化字段配置。
- `TemplateManage.tsx` 右侧字段库重构为 Tab 结构:【插入字段】按"填空/单选/多选/时间"分组;【字段管理】支持新增、删除(非锁定字段)、显隐开关。
- `ReportEditor.tsx` 右侧基本信息表单改为遍历 `formFieldsConfig`、按 `type` switch-case 动态渲染(文本框/下拉框/多选标签/时间拆分下拉框)。
3. **UI 紧凑化**
- 将 `min-width` 从 `60px` 缩至 `32px`。
- 去除上下 `padding`,使用 `line-height: 1.2`、`font-size: inherit`、`vertical-align: text-bottom`。
- 背景色改为 `#f8fafc`(编辑态更明显),打印时恢复透明并只保留下划线。
**D. 后续如何避免问题**
- 对于需要将多个子字段映射到单一 UI 控件的场景,应在事件处理器和 `useEffect` 中各维护一层"拼接/解析"转换逻辑,保持底层数据结构不变。
- 当表单字段超过 5 个且存在频繁变更需求时,应尽早从硬编码 JSX 转向"配置驱动渲染"Config-Driven UI降低后续维护成本。
- 在 `contentEditable` 中插入 `inline-block` 元素时,务必通过 `line-height`、`vertical-align` 和最小化 `padding` 控制其对行高的影响,避免破坏段落排版的紧凑性。

View File

@@ -0,0 +1,58 @@
# 需求分析 — 2026-04-17-00-13-09
## 原始需求摘要
1. **手术时间方框化**:将报告模板中的"手术开始时间"、"手术终止时间"从静态文本改为可联动的智能方格。
2. **动态字段体系**:在 `TemplateManage` 中将字段库升级为支持分类(填空、多选、单选、时间)、允许自定义新增字段,并可控制字段是否在 `ReportEditor` 右侧【基本信息】中显示。除"姓名"、"住院号"为系统锁定字段外,其余字段均可调整显隐和删除。
3. **UI 紧凑化**:缩小 `field-value` 方格的默认宽度和高度,降低其对行间距的影响,使排版更紧凑自然。
## 需求拆解
### 功能点
#### 需求 1手术开始/终止时间方框联动
-`defaultContent.ts` 中将"手术开始时间:时 分"替换为 `data-bind="startTime"` 的智能方格,同理替换"手术终止时间"。
-`ReportEditor.tsx` 右侧基本信息中,保留现有的 `startHour`/`startMinute``endHour`/`endMinute` 下拉框(或合并为时间输入)。
- 建立双向转换:
- 表单 → 方格:下拉框变化时拼接为 `HH:mm` 同步到方格。
- 方格 → 表单:用户在方格内输入时间文本(如"09:30")时,解析并反向更新 `startHour`/`startMinute`
#### 需求 2动态字段配置体系
- **数据结构**:在 `types.ts` 中新增 `FieldType``FormField` 接口,描述字段的 key、label、分类、类型、显隐状态、是否系统锁定、选项列表等。
- **全局配置存储**:在 `localStorage` 中新增 `formFieldsConfig` key保存字段配置数组。
- **TemplateManage 字段库改造**
- 右侧面板增加【插入字段】和【字段管理】两个 Tab。
- 【插入字段】按分类(填空、单选、多选、时间)分组展示字段按钮,点击插入对应 `data-bind` 方格。
- 【字段管理】展示所有非系统锁定字段,支持新增字段(输入名称、选择类型、选择分类)、编辑选项、删除字段、控制 `visibleInForm` 开关。
- **ReportEditor 动态渲染**:右侧【基本信息】表单不再硬编码,而是读取 `formFieldsConfig`,过滤出 `visibleInForm === true` 的字段,根据 `type` 动态渲染对应输入组件(文本框/下拉框/多选标签/时间拆分下拉框)。
- **初始化默认配置**:首次加载时若 `formFieldsConfig` 不存在,自动生成一套与当前硬编码表单一致的默认配置。
#### 需求 3UI 紧凑化优化
- 调整 `field-value` 的内联样式:
- `min-width``60px` 降至 `32px`
- 去除上下 `padding`(或仅保留极小的上下间距)
- 增加 `line-height: 1.2``font-size: inherit``vertical-align: text-bottom`
- 背景色微调为 `#f8fafc`(编辑态更明显)
- 同步修改 `index.css``print.ts` 中的对应样式。
### 非功能点
- 系统锁定字段(`patientName``hospitalId`)不可删除、不可隐藏。
- 老用户已有的 `localStorage` 数据不因字段配置变化而丢失;新增配置与现有 `Report`/`Template` 数据解耦。
- 保持 `npm run lint` 类型检查通过。
- 不引入新的第三方依赖。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/types.ts` | 高 | 新增 `FieldType``FormField``FormFieldsConfig` 类型定义 |
| `src/utils/defaultContent.ts` | 中 | 手术时间占位符替换为智能方格 |
| `src/index.css` | 中 | 优化 `.field-value` 紧凑样式 |
| `src/utils/print.ts` | 低 | 同步打印样式 |
| `src/pages/TemplateManage.tsx` | 高 | 字段库 UI 重构为 Tab + 分类分组 + 字段管理 |
| `src/pages/ReportEditor.tsx` | 高 | 右侧基本信息表单从硬编码改为动态渲染,新增时间解析/拼接逻辑 |
## 待确认问题
无。用户已提供详细需求和初步技术思路,可直接进入实现方案设计。