2026-04-17-00-13-09 - 手术时间方框联动、动态字段分类管理体系、字段显隐控制、UI紧凑化优化
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
|
||||
31
src/types.ts
31
src/types.ts
@@ -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: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
|
||||
];
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
446
工程分析/实现方案-2026-04-17-00-13-09.md
Normal file
446
工程分析/实现方案-2026-04-17-00-13-09.md
Normal 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')` 恢复默认字段配置。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**
|
||||
121
工程分析/测试方案-2026-04-17-00-13-09.md
Normal file
121
工程分析/测试方案-2026-04-17-00-13-09.md
Normal 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 | 选择"侧卧位" | 编辑器内对应方格显示"侧卧位" |
|
||||
|
||||
### 用例 8:UI 紧凑化验证
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|--------- |
|
||||
| 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` 配置。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||
33
工程分析/经验记录.md
33
工程分析/经验记录.md
@@ -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` 控制其对行高的影响,避免破坏段落排版的紧凑性。
|
||||
|
||||
58
工程分析/需求分析-2026-04-17-00-13-09.md
Normal file
58
工程分析/需求分析-2026-04-17-00-13-09.md
Normal 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` 不存在,自动生成一套与当前硬编码表单一致的默认配置。
|
||||
|
||||
#### 需求 3:UI 紧凑化优化
|
||||
- 调整 `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` | 高 | 右侧基本信息表单从硬编码改为动态渲染,新增时间解析/拼接逻辑 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。用户已提供详细需求和初步技术思路,可直接进入实现方案设计。
|
||||
Reference in New Issue
Block a user