diff --git a/src/index.css b/src/index.css index 540702c..0f76ca8 100644 --- a/src/index.css +++ b/src/index.css @@ -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 { diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 8c010ec..2d453aa 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -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('formFieldsConfig', []); + if (fieldsConfig.length === 0) { + storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS); + } + const settingsRaw = storage.get('systemSettings', {} as SystemSettings); if (!settingsRaw.frameCount) { const round1 = (n: number) => Math.round(n * 10) / 10; diff --git a/src/pages/ReportEditor.tsx b/src/pages/ReportEditor.tsx index ec375ef..e3b7c1d 100644 --- a/src/pages/ReportEditor.tsx +++ b/src/pages/ReportEditor.tsx @@ -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(['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉']); const [openDropdown, setOpenDropdown] = useState(null); const [touched, setTouched] = useState>({}); + const [formFields, setFormFields] = useState([]); const editorRef = useRef(null); const videoRef = useRef(null); @@ -108,6 +109,14 @@ export default function ReportEditor() { const savedAnesthesia = storage.get('anesthesiaOptions', null); if (savedAnesthesia) setAnesthesiaOptions(savedAnesthesia); + const savedFields = storage.get('formFieldsConfig', []); + if (savedFields.length > 0) { + setFormFields(savedFields); + } else { + setFormFields(DEFAULT_FORM_FIELDS); + storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS); + } + const allTemplates = storage.get('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,11 +908,27 @@ export default function ReportEditor() { const fieldKey = target.getAttribute('data-bind')!; const newValue = target.innerText; - setReportData((prev) => { - const next = { ...prev, [fieldKey]: newValue }; - stateRef.current = { ...stateRef.current, reportData: next }; - return next; - }); + 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; + }); + } } }; @@ -900,13 +939,21 @@ 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 (Array.isArray(rawValue)) { - newValue = rawValue.join(', '); - } else if (rawValue !== undefined && rawValue !== null) { - newValue = String(rawValue); + 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) { @@ -1075,256 +1122,190 @@ export default function ReportEditor() {
{activeTab === 'info' && (
-
-
- - { 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="患者姓名" - /> -
-
- - { 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="住院号" - /> -
-
+ {formFields.filter(f => f.visibleInForm).map(field => { + const isRequired = field.isSystemLocked; + const hasError = isRequired && touched[field.key] && !(reportData as any)[field.key]; -
- - { const next = {...reportData, title: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }} - className="input-minimal" - placeholder="请输入手术名称" - /> -
- -
-
- - -
-
- - { const next = {...reportData, patientAge: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }} - className="input-minimal" - placeholder="年龄" - /> -
-
- -
-
- - { const next = {...reportData, department: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }} - className="input-minimal" - placeholder="科室" - /> -
-
- - { const next = {...reportData, bedNumber: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }} - className="input-minimal" - placeholder="床号" - /> -
-
- -
- - { const next = {...reportData, surgeryDate: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }} - className="input-minimal" - /> -
- -
-
- -
- - : - -
-
-
- -
- - : - -
-
-
- - {(['surgeon', 'assistant', 'anesthesiologist'] as const).map((field) => { - const labels = { surgeon: '手术者', assistant: '助手', anesthesiologist: '麻醉师' }; - const isOpen = openDropdown === field; - return ( -
- -
setOpenDropdown(field)} - > - {(reportData[field] || []).map(tag => ( - - {tag} - { e.stopPropagation(); removeTag(field, tag); }}>× - - ))} + if (field.type === 'text' || field.type === 'date') { + const inputType = field.type === 'date' ? 'date' : 'text'; + return ( +
f2.visibleInForm && f2.type === 'text' && f2.isSystemLocked).length > 1 && (field.key === 'patientName' || field.key === 'hospitalId') ? 'flex-1 space-y-1' : 'space-y-1'}> + 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} />
- {isOpen && ( -
- {(multiSelectOptions[field] || []).map(opt => ( -
{ addTag(field, opt); }} - > - {opt} - { e.stopPropagation(); removeMultiOption(field, opt); }} - >× -
- ))} - {(multiSelectOptions[field] || []).length === 0 && ( -
暂无选项
- )} -
- )} -
- ); - })} + ); + } -
- -
setOpenDropdown('anesthesia')} - > - { const next = {...reportData, anesthesiaType: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }} - onFocus={() => setOpenDropdown('anesthesia')} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - const val = (e.target as HTMLInputElement).value.trim(); - if (val) { - const next = {...reportData, anesthesiaType: val}; - setReportData(next); - stateRef.current = { ...stateRef.current, reportData: next }; - saveDraftToStorage(); - if (!anesthesiaOptions.includes(val)) { - const next = [...anesthesiaOptions, val]; - setAnesthesiaOptions(next); - storage.set('anesthesiaOptions', next); - } - setOpenDropdown(null); - } - } - }} - /> -
- {openDropdown === 'anesthesia' && ( -
- {anesthesiaOptions.map(opt => ( -
{ const next = {...reportData, anesthesiaType: opt}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); setOpenDropdown(null); }} + if (field.type === 'single_select') { + const isOpen = openDropdown === field.key; + const opts = field.options || (field.key === 'anesthesiaType' ? anesthesiaOptions : []); + return ( +
+ +
setOpenDropdown(field.key)} > - {opt} - { e.stopPropagation(); removeAnesthesiaOption(opt); }} - >× + { 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, [field.key]: val }; + setReportData(next); + stateRef.current = { ...stateRef.current, reportData: next }; + saveDraftToStorage(); + 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); + } + } + }} + />
- ))} - {anesthesiaOptions.length === 0 && ( -
暂无选项
- )} -
- )} -
+ {isOpen && ( +
+ {opts.map(opt => ( +
{ const next = { ...reportData, [field.key]: opt }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); setOpenDropdown(null); }} + > + {opt} + { + 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); + }} + >× +
+ ))} + {opts.length === 0 && ( +
暂无选项
+ )} +
+ )} +
+ ); + } + if (field.type === 'multi_select') { + const isOpen = openDropdown === field.key; + const opts = field.options || multiSelectOptions[field.key] || []; + return ( +
+ +
setOpenDropdown(field.key)} + > + {((reportData as any)[field.key] || []).map((tag: string) => ( + + {tag} + { e.stopPropagation(); removeTag(field.key, tag); }}>× + + ))} + 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 = ''; } + } + }} + /> +
+ {isOpen && ( +
+ {opts.map(opt => ( +
{ addTag(field.key, opt); }} + > + {opt} + { e.stopPropagation(); removeMultiOption(field.key, opt); }} + >× +
+ ))} + {opts.length === 0 && ( +
暂无选项
+ )} +
+ )} +
+ ); + } + + if (field.type === 'time') { + const hourKey = field.key === 'startTime' ? 'startHour' : 'endHour'; + const minuteKey = field.key === 'startTime' ? 'startMinute' : 'endMinute'; + return ( +
+ +
+ + : + +
+
+ ); + } + + return null; + })}
)} diff --git a/src/pages/TemplateManage.tsx b/src/pages/TemplateManage.tsx index 4299ad7..0e41cce 100644 --- a/src/pages/TemplateManage.tsx +++ b/src/pages/TemplateManage.tsx @@ -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(null); const savedRangeRef = useRef(null); + const [fieldLibTab, setFieldLibTab] = useState<'insert' | 'manage'>('insert'); + const [formFields, setFormFields] = useState([]); + 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('formFieldsConfig', []); + if (savedFields.length > 0) { + setFormFields(savedFields); + } else { + setFormFields(DEFAULT_FORM_FIELDS); + storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS); + } + const savedTemplates = storage.get('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 = ` - ${field.label}: + 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;">   `; @@ -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,28 +513,126 @@ export default function TemplateManage() {
{/* Right: Field Library */} -