[第五批] 手术图文病历报告系统 - 时间字段增强:自定义格式、固定时间默认值、系统锁定标签
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
13
src/types.ts
13
src/types.ts
@@ -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: ['胆囊结石伴慢性胆囊炎', '急性胆囊炎'] },
|
||||
|
||||
@@ -155,7 +155,7 @@ export const defaultReportContent = `
|
||||
</p>
|
||||
|
||||
<p style="text-align: right; font-family: SimSun;">
|
||||
撰写时间:${smartField('reportDate')}
|
||||
${smartField('reportDate')}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user