[第五批] 手术图文病历报告系统 - 时间字段增强:自定义格式、固定时间默认值、系统锁定标签

This commit is contained in:
2026-04-17 22:20:33 +08:00
parent 8beb534abb
commit ac6b619549
4 changed files with 191 additions and 111 deletions

View File

@@ -260,34 +260,63 @@ export default function ReportEditor() {
let hasChange = false;
const updates: any = {};
formFields.forEach(field => {
if (field.timeDefault !== 'current') return;
if (field.type === 'date') {
const current = new Date().toISOString().split('T')[0];
if (!(reportData as any)[field.key]) {
updates[field.key] = current;
hasChange = true;
}
} else if (field.type === 'time') {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
if (field.key === 'startTime') {
if (!reportData.startHour) {
updates.startHour = hh;
updates.startMinute = mm;
hasChange = true;
}
} else if (field.key === 'endTime') {
if (!reportData.endHour) {
updates.endHour = hh;
updates.endMinute = mm;
hasChange = true;
}
} else {
if (field.timeDefault === 'current') {
if (field.type === 'date') {
const current = new Date().toISOString().split('T')[0];
if (!(reportData as any)[field.key]) {
updates[field.key] = `${hh}:${mm}`;
updates[field.key] = current;
hasChange = true;
}
} else if (field.type === 'time') {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
if (field.key === 'startTime') {
if (!reportData.startHour) {
updates.startHour = hh;
updates.startMinute = mm;
hasChange = true;
}
} else if (field.key === 'endTime') {
if (!reportData.endHour) {
updates.endHour = hh;
updates.endMinute = mm;
hasChange = true;
}
} else {
if (!(reportData as any)[field.key]) {
updates[field.key] = `${hh}:${mm}`;
hasChange = true;
}
}
}
} else if (field.timeDefault === 'specific' && field.fixedTimeValue) {
if (field.type === 'date') {
if (!(reportData as any)[field.key]) {
updates[field.key] = field.fixedTimeValue;
hasChange = true;
}
} else if (field.type === 'time') {
if (field.key === 'startTime') {
if (!reportData.startHour) {
const [hh, mm] = field.fixedTimeValue.split(':');
updates.startHour = hh || '';
updates.startMinute = mm || '';
hasChange = true;
}
} else if (field.key === 'endTime') {
if (!reportData.endHour) {
const [hh, mm] = field.fixedTimeValue.split(':');
updates.endHour = hh || '';
updates.endMinute = mm || '';
hasChange = true;
}
} else {
if (!(reportData as any)[field.key]) {
updates[field.key] = field.fixedTimeValue;
hasChange = true;
}
}
}
}
});
@@ -873,25 +902,50 @@ export default function ReportEditor() {
const minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
const formatDateDisplay = (isoDate: string, fmt?: string): string => {
if (!isoDate) return '';
if (fmt === 'YYYY年MM月DD日') {
const [y, m, d] = isoDate.split('-');
if (y && m && d) return `${y}${m}${d}`;
}
return isoDate;
if (!isoDate || !fmt) return isoDate || '';
const [y, m, d] = isoDate.split('-');
return fmt
.replace(/YYYY/g, y || '')
.replace(/MM/g, m || '')
.replace(/DD/g, d || '');
};
const formatTimeDisplay = (timeStr: string, fmt?: string): string => {
if (!timeStr) return '';
if (fmt === '12h') {
const [hStr, mStr] = timeStr.split(':');
let h = parseInt(hStr);
const ampm = h >= 12 ? '下午' : '上午';
h = h % 12;
if (h === 0) h = 12;
return `${String(h).padStart(2, '0')}:${mStr} ${ampm}`;
if (!timeStr || !fmt) return timeStr || '';
const [h24str, mstr] = timeStr.split(':');
const h24 = parseInt(h24str) || 0;
const isPM = h24 >= 12;
let h12 = h24 % 12;
if (h12 === 0) h12 = 12;
return fmt
.replace(/HH/g, String(h24).padStart(2, '0'))
.replace(/mm/g, mstr || '00')
.replace(/hh/g, String(h12).padStart(2, '0'))
.replace(/A/g, isPM ? '下午' : '上午');
};
const parseDateFromFormat = (text: string, fmt?: string): string => {
if (!text || !fmt) return text;
const nums = text.match(/\d+/g);
if (!nums) return text;
let y = '', m = '', d = '';
if (nums.length >= 3) { y = nums[0].padStart(4, '0'); m = nums[1].padStart(2, '0'); d = nums[2].padStart(2, '0'); }
else if (nums.length === 2) { m = nums[0].padStart(2, '0'); d = nums[1].padStart(2, '0'); y = new Date().getFullYear().toString(); }
return `${y}-${m}-${d}`;
};
const parseTimeFromFormat = (text: string, fmt?: string): string => {
if (!text || !fmt) return text;
const nums = text.match(/\d+/g);
const ampm = text.match(/上午|下午/);
if (!nums || nums.length < 2) return text;
let h = parseInt(nums[0]);
if (ampm) {
const isPM = ampm[0] === '下午';
if (isPM && h !== 12) h += 12;
if (!isPM && h === 12) h = 0;
}
return timeStr;
return `${String(h).padStart(2, '0')}:${nums[1].padStart(2, '0')}`;
};
const to24h = (h12: number, isPM: boolean): number => {
@@ -1044,15 +1098,8 @@ export default function ReportEditor() {
const fieldDef = formFields.find(f => f.key === fieldKey);
if (fieldKey === 'startTime') {
let raw = newValue;
if (fieldDef?.timeFormat === '12h') {
const m = newValue.match(/(\d{2}):(\d{2})\s*(上午|下午)/);
if (m) {
let h = parseInt(m[1]);
const isPM = m[3] === '下午';
if (isPM && h !== 12) h += 12;
if (!isPM && h === 12) h = 0;
raw = `${String(h).padStart(2, '0')}:${m[2]}`;
}
if (fieldDef?.timeFormat && (fieldDef.timeFormat.includes('hh') || fieldDef.timeFormat.includes('A'))) {
raw = parseTimeFromFormat(newValue, fieldDef.timeFormat);
}
const parts = raw.split(':');
setReportData((prev) => {
@@ -1062,15 +1109,8 @@ export default function ReportEditor() {
});
} else if (fieldKey === 'endTime') {
let raw = newValue;
if (fieldDef?.timeFormat === '12h') {
const m = newValue.match(/(\d{2}):(\d{2})\s*(上午|下午)/);
if (m) {
let h = parseInt(m[1]);
const isPM = m[3] === '下午';
if (isPM && h !== 12) h += 12;
if (!isPM && h === 12) h = 0;
raw = `${String(h).padStart(2, '0')}:${m[2]}`;
}
if (fieldDef?.timeFormat && (fieldDef.timeFormat.includes('hh') || fieldDef.timeFormat.includes('A'))) {
raw = parseTimeFromFormat(newValue, fieldDef.timeFormat);
}
const parts = raw.split(':');
setReportData((prev) => {
@@ -1080,18 +1120,10 @@ export default function ReportEditor() {
});
} else {
let raw = newValue;
if (fieldDef?.type === 'date' && fieldDef.timeFormat === 'YYYY年MM月DD日') {
const m = newValue.match(/(\d{4})年(\d{2})月(\d{2})日/);
if (m) raw = `${m[1]}-${m[2]}-${m[3]}`;
} else if (fieldDef?.type === 'time' && fieldDef.timeFormat === '12h') {
const m = newValue.match(/(\d{2}):(\d{2})\s*(上午|下午)/);
if (m) {
let h = parseInt(m[1]);
const isPM = m[3] === '下午';
if (isPM && h !== 12) h += 12;
if (!isPM && h === 12) h = 0;
raw = `${String(h).padStart(2, '0')}:${m[2]}`;
}
if (fieldDef?.type === 'date') {
raw = parseDateFromFormat(newValue, fieldDef.timeFormat);
} else if (fieldDef?.type === 'time') {
raw = parseTimeFromFormat(newValue, fieldDef.timeFormat);
}
setReportData((prev) => {
const next = { ...prev, [fieldKey]: raw };

View File

@@ -33,8 +33,11 @@ export default function TemplateManage() {
const [editFieldOptions, setEditFieldOptions] = useState('');
const [editFieldTimeFormat, setEditFieldTimeFormat] = useState('');
const [editFieldTimeDefault, setEditFieldTimeDefault] = useState<'current' | 'specific'>('specific');
const [editFieldFixedTimeValue, setEditFieldFixedTimeValue] = useState('');
const [newFieldTimeFormat, setNewFieldTimeFormat] = useState('YYYY-MM-DD');
const [newFieldTimeDefault, setNewFieldTimeDefault] = useState<'current' | 'specific'>('specific');
const [newFieldFixedTimeValue, setNewFieldFixedTimeValue] = useState('');
const [customTimeFormats, setCustomTimeFormats] = useState<string[]>([]);
const [imageAssets, setImageAssets] = useState<{ id: string; name: string; dataUrl: string }[]>([]);
const updatePageHeight = () => {
@@ -81,6 +84,10 @@ export default function TemplateManage() {
.catch(() => setImageAssets([]));
}
const savedFormats = storage.get<string[]>('customTimeFormats', []);
const defaultFormats = ['YYYY-MM-DD', 'YYYY年MM月DD日', 'MM-DD', 'MM月DD日', 'HH:mm', 'hh:mm A'];
setCustomTimeFormats(Array.from(new Set([...defaultFormats, ...savedFormats])));
const savedTemplates = storage.get<Template[]>('templates', []);
if (savedTemplates.length === 0) {
const initial: Template = {
@@ -429,6 +436,7 @@ export default function TemplateManage() {
if (f.category === '时间') {
next.timeFormat = editFieldTimeFormat;
next.timeDefault = editFieldTimeDefault;
next.fixedTimeValue = editFieldFixedTimeValue;
}
return next;
});
@@ -454,6 +462,7 @@ export default function TemplateManage() {
if (newFieldForm.category === '时间') {
newField.timeFormat = newFieldTimeFormat;
newField.timeDefault = newFieldTimeDefault;
newField.fixedTimeValue = newFieldFixedTimeValue;
}
const updated = [...formFields, newField];
setFormFields(updated);
@@ -462,6 +471,7 @@ export default function TemplateManage() {
setNewFieldOptions('');
setNewFieldTimeFormat('YYYY-MM-DD');
setNewFieldTimeDefault('specific');
setNewFieldFixedTimeValue('');
};
const handleAssetUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -874,6 +884,7 @@ export default function TemplateManage() {
setEditFieldOptions((field.options || []).join(', '));
setEditFieldTimeFormat(field.timeFormat || '');
setEditFieldTimeDefault(field.timeDefault || 'specific');
setEditFieldFixedTimeValue(field.fixedTimeValue || '');
}}
className={`cursor-pointer rounded border p-2 transition-all ${activeFieldKey === field.key ? 'border-accent bg-blue-50 ring-1 ring-accent' : 'border-slate-100 bg-slate-50 hover:border-slate-200'}`}
>
@@ -906,27 +917,45 @@ export default function TemplateManage() {
onChange={(e) => setEditFieldTimeDefault(e.target.value as 'current' | 'specific')}
className="w-full px-1.5 py-1 text-xs border border-border rounded bg-white"
>
<option value="specific"></option>
<option value="specific"></option>
<option value="current"></option>
</select>
<select
{editFieldTimeDefault === 'specific' && (
<input
type={field.type === 'date' ? 'date' : 'time'}
value={editFieldFixedTimeValue}
onChange={(e) => setEditFieldFixedTimeValue(e.target.value)}
className="w-full px-1.5 py-1 text-xs border border-border rounded"
/>
)}
<input
list={`edit-format-list-${field.key}`}
value={editFieldTimeFormat}
onChange={(e) => setEditFieldTimeFormat(e.target.value)}
className="w-full px-1.5 py-1 text-xs border border-border rounded bg-white"
>
{field.type === 'date' && (
<>
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
<option value="YYYY年MM月DD日">YYYY年MM月DD日</option>
</>
)}
{field.type === 'time' && (
<>
<option value="24h">24</option>
<option value="12h">12</option>
</>
)}
</select>
onBlur={(e) => {
const val = e.target.value.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}
}}
className="w-full px-1.5 py-1 text-xs border border-border rounded"
placeholder="输入格式,如 YYYY-MM-DD"
/>
<datalist id={`edit-format-list-${field.key}`}>
{customTimeFormats.map(fmt => <option key={fmt} value={fmt} />)}
</datalist>
</div>
)}
<div className="flex gap-2">
@@ -1048,27 +1077,45 @@ export default function TemplateManage() {
onChange={(e) => setNewFieldTimeDefault(e.target.value as 'current' | 'specific')}
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-white"
>
<option value="specific"></option>
<option value="specific"></option>
<option value="current"></option>
</select>
<select
{newFieldTimeDefault === 'specific' && (
<input
type={newFieldForm.type === 'date' ? 'date' : 'time'}
value={newFieldFixedTimeValue}
onChange={(e) => setNewFieldFixedTimeValue(e.target.value)}
className="w-full px-2 py-1.5 text-xs border border-border rounded"
/>
)}
<input
list="new-format-list"
value={newFieldTimeFormat}
onChange={(e) => setNewFieldTimeFormat(e.target.value)}
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-white"
>
{newFieldForm.type === 'date' && (
<>
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
<option value="YYYY年MM月DD日">YYYY年MM月DD日</option>
</>
)}
{newFieldForm.type === 'time' && (
<>
<option value="24h">24</option>
<option value="12h">12</option>
</>
)}
</select>
onBlur={(e) => {
const val = e.target.value.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}
}}
className="w-full px-2 py-1.5 text-xs border border-border rounded"
placeholder="输入格式,如 YYYY-MM-DD"
/>
<datalist id="new-format-list">
{customTimeFormats.map(fmt => <option key={fmt} value={fmt} />)}
</datalist>
</div>
)}
{['单选', '多选'].includes(newFieldForm.category) && (

View File

@@ -115,6 +115,7 @@ export interface FormField {
options?: string[];
timeFormat?: string;
timeDefault?: 'current' | 'specific';
fixedTimeValue?: string;
}
export const DEFAULT_FORM_FIELDS: FormField[] = [
@@ -125,13 +126,13 @@ export const DEFAULT_FORM_FIELDS: FormField[] = [
{ 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, timeFormat: 'YYYY-MM-DD', timeDefault: 'specific' },
{ key: 'startTime', label: '手术开始时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: false, timeFormat: '24h', timeDefault: 'specific' },
{ key: 'endTime', label: '手术终止时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: false, timeFormat: '24h', timeDefault: 'specific' },
{ key: 'surgeryDate', label: '手术日期', category: '时间', type: 'date', visibleInForm: true, isSystemLocked: true, timeFormat: 'YYYY-MM-DD', timeDefault: 'specific' },
{ key: 'startTime', label: '手术开始时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: true, timeFormat: '24h', timeDefault: 'specific' },
{ key: 'endTime', label: '手术终止时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: true, timeFormat: '24h', timeDefault: 'specific' },
{ key: 'reportDate', label: '撰写时间', category: '时间', type: 'date', visibleInForm: true, isSystemLocked: true, timeFormat: 'YYYY年MM月DD日', timeDefault: 'current' },
{ 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: 'surgeon', label: '手术者', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: true, options: ['张医生', '李医生', '王医生'] },
{ key: 'assistant', label: '助手', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: true, options: ['赵医生', '钱医生', '孙医生'] },
{ key: 'anesthesiologist', label: '麻醉师', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: true, options: ['周医生', '吴医生', '郑医生'] },
{ key: 'anesthesiaType', label: '麻醉方式', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
{ key: 'preoperativeDiagnosis', label: '术前诊断', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊结石伴慢性胆囊炎', '急性胆囊炎'] },
{ key: 'postoperativeDiagnosis', label: '术后诊断', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊结石伴慢性胆囊炎', '急性胆囊炎'] },

View File

@@ -155,7 +155,7 @@ export const defaultReportContent = `
</p>
<p style="text-align: right; font-family: SimSun;">
撰写时间:${smartField('reportDate')}
${smartField('reportDate')}
</p>
</div>
`;