diff --git a/apps/web-antd/src/components/loading/PrescriptionLoading.vue b/apps/web-antd/src/components/loading/PrescriptionLoading.vue new file mode 100644 index 00000000..4996776f --- /dev/null +++ b/apps/web-antd/src/components/loading/PrescriptionLoading.vue @@ -0,0 +1,714 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/doctor/doctor-reception/api/index.ts b/apps/web-antd/src/views/doctor/doctor-reception/api/index.ts index 97edd82c..54d0785f 100644 --- a/apps/web-antd/src/views/doctor/doctor-reception/api/index.ts +++ b/apps/web-antd/src/views/doctor/doctor-reception/api/index.ts @@ -428,3 +428,41 @@ export async function saveGranularCommonPrescriptionApi(data: { data, ); } + +/** AI 给处方 */ +export async function aiGeneratePrescriptionApi(data: { + register_id: number; + store_id?: number; + prescription_type: number; + /** 前端确认过的主诉(可编辑) */ + chief_complaint?: string; + /** 当前病历表单快照(可未保存),供 AI 综合参考 */ + medical_record?: Record; +}) { + return requestClient.post(`${prefix}ai-generate-prescription`, data); +} + +/** 本挂号 AI 生成历史(轻量列表) */ +export async function aiListGenerationsApi(data: { + register_id: number; + scene?: string; + limit?: number; +}) { + return requestClient.get(`${prefix}ai-list-generations`, { params: data }); +} + +/** AI 生成详情(点选历史) */ +export async function aiGenerationDetailApi(data: { id: number }) { + return requestClient.get(`${prefix}ai-generation-detail`, { params: data }); +} + +/** AI 处方药名模糊对照 */ +export async function aiMatchPrescriptionDrugsApi(data: { + generation_id?: number; + register_id?: number; + store_id?: number; + prescription_type?: number; + items?: Array>; +}) { + return requestClient.post(`${prefix}ai-match-prescription-drugs`, data); +} diff --git a/apps/web-antd/src/views/doctor/doctor-reception/components/AiPrescriptionDrawer.vue b/apps/web-antd/src/views/doctor/doctor-reception/components/AiPrescriptionDrawer.vue new file mode 100644 index 00000000..4c122128 --- /dev/null +++ b/apps/web-antd/src/views/doctor/doctor-reception/components/AiPrescriptionDrawer.vue @@ -0,0 +1,710 @@ + + + + + + + + {{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁 · + {{ typeLabel }} + + + 配伍禁忌提示 + + · {{ line }} + + + + + 历史记录 + 加载中… + + 暂无记录,可点击下方「生成处方」 + + + + {{ row.name || row.prescription_name || `方案 #${row.id}` }} + + + {{ row.prescription_type_label || typeLabelOf(row.prescription_type) }} + · {{ row.item_count || 0 }}味 · {{ formatTime(row.created_at) }} + + + + + + + + + + 请选择左侧历史,或点击「生成处方」 + + + + 已对照({{ matchedList.length }}) + + + + + + + {{ m.ai_name }} + + {{ m.dose }}{{ m.unit }}{{ m.usage ? `·${m.usage}` : '' }} + + + + + + + {{ c.drug_name }} + + + + + + + + {{ m.ai_name }} + + {{ m.dose }}{{ m.unit }}{{ m.usage ? `·${m.usage}` : '' }} + + + + + + {{ c.drug_name }} ¥{{ Number(c.price || 0).toFixed(2) }} + + + + + + 未对照({{ unmatchedList.length }}) + + + {{ u.ai_name }} + + 去手动搜索 + + + + + + {{ rxPrescriptionName || '未命名药方' }} + + + {{ rxDosage || '—' }}剂 + + + {{ rxDayDosage || '—' }}次 + + + + {{ rxReason }} + + + + + {{ rxBasis }} + + + + + + + + + 关闭 + 生成处方 + + 确认导入 + + + + + + + + + + {{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁 · + {{ typeLabel }} · 点击文字可编辑,将同步到病历 + + + + + onFieldInput(row.key, String(v ?? ''))" + @blur="endEdit" + /> + + {{ fieldValue(row.key) || '(空,点击填写)' }} + + + + + 仅有主诉。建议先在病历区补充现病史、舌脉等,再生成。 + + + + + + + diff --git a/apps/web-antd/src/views/doctor/doctor-reception/index.vue b/apps/web-antd/src/views/doctor/doctor-reception/index.vue index 85b7bc96..d4efeee5 100644 --- a/apps/web-antd/src/views/doctor/doctor-reception/index.vue +++ b/apps/web-antd/src/views/doctor/doctor-reception/index.vue @@ -14,6 +14,7 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, PlusOutlined, + RobotOutlined, SaveOutlined, StopOutlined, SwapOutlined, @@ -85,6 +86,7 @@ import RefusalOfTreatmentModal from "#/views/doctor/doctor-reception/components/RefusalOfTreatmentModal.vue"; // 常用方选择弹窗组件 import CommonPrescriptionModal from './components/CommonPrescriptionModal.vue'; +import AiPrescriptionDrawer from './components/AiPrescriptionDrawer.vue'; // 确认模态框组件 import ConfirmModal from './components/ConfirmModal.vue'; // 信息提示模态框组件 @@ -1264,6 +1266,177 @@ function openCommonPrescriptionModal() { CommonPrescriptionModalApi.open(); } +const aiPrescriptionDrawerRef = ref | null>(null); + +/** 打开 AI 给处方抽屉:先确认患者与主诉,再生成预览 */ +function openAiPrescriptionDrawer() { + if (guardSpecialPrescriptionCartEdit()) return; + const registerId = Number.parseInt( + localStorage.getItem(`doctorReception-id`) || '0', + ); + if (!registerId) { + message.warning('请先选择患者'); + return; + } + if (!activeCategory.value) { + message.warning('请先选择处方类型'); + return; + } + const payload = medicalRecordPanelRef.value?.getPayload?.() || {}; + aiPrescriptionDrawerRef.value?.open({ + registerId, + storeId: Number(myStoreId.value || 0) || undefined, + prescriptionType: Number(activeCategory.value), + patientName: String(activePatient.value?.name || ''), + patientSex: Number(activePatient.value?.sex || 0), + patientAge: Number(activePatient.value?.age || 0), + chiefComplaint: String(payload.chief_complaint || ''), + // 带入当前病历表单(含未保存内容),出方时综合参考而非只看主诉 + medicalRecord: { ...payload }, + }); +} + +/** + * AI 出方预览里改病历字段时,回写到当前病历面板(不强制保存) + */ +function handleSyncAiMedicalRecord(partial: Record) { + medicalRecordPanelRef.value?.patchFields?.(partial || {}); +} + +/** + * 将 AI 对照结果覆盖导入当前处方(非追加) + * 中药会同步剂数/每日次数,并按 usage 匹配先煎后下 way_id + */ +function handleImportAiPrescription(payload: { + rows?: any[]; + dosage?: number; + day_dosage?: number; +}) { + if (guardSpecialPrescriptionCartEdit()) return; + const list = Array.isArray(payload?.rows) ? payload.rows : []; + if (!list.length) { + message.warning('没有可导入的药品'); + return; + } + const resolveWayId = (usage: string) => { + const u = String(usage || '').trim(); + if (!u) return 0; + const found = (drugUseWay.value || []).find( + (w: any) => + String(w.name || '') === u || String(w.name || '').includes(u), + ); + return found ? Number(found.id || 0) : 0; + }; + const doOverwrite = () => { + clearAppliedSpecialPrescription(); + const next: any[] = []; + for (const row of list) { + const c = row?.candidate || {}; + const drugId = Number(c.drug_id || 0); + if (!drugId) continue; + if (next.find((item: any) => Number(item.id) === drugId)) continue; + const doseNum = parseFloat(row.dose); + const qty = !Number.isNaN(doseNum) && doseNum > 0 ? doseNum : 1; + const drug = c.drug || {}; + if (activeCategory.value === 1) { + const wayId = resolveWayId(row.usage || ''); + next.push({ + index_id: c.id || drugId, + id: drugId, + drug_name: c.drug_name || row.ai_name, + number: qty, + price: parseFloat(c.price) || 0, + way_id: wayId, + use_ways: drugUseWay.value.find((item: any) => item.id === wayId), + select_number: 1, + unit: c.unit || { id: 0, name: row.unit || 'g' }, + }); + continue; + } + if (activeCategory.value === 2) { + next.push({ + index_id: c.id || drugId, + id: drugId, + drug_name: c.drug_name || drug.drug_name || row.ai_name, + number: qty, + select_number: qty, + price: parseFloat(c.price) || 0, + image: c.image || drug.image || '', + instruction: drug.instruction || '', + time_id: c.time_id || drug.time_id || 0, + type_id: c.type_id || drug.type_id || 0, + frequency_id: c.frequency_id || drug.frequency_id || 0, + unit_id: c.unit_id || drug.unit_id || 0, + use_num: drugTime.value.find( + (item) => item.id === (c.time_id || drug.time_id || 0), + ), + use_type: drugUseType.value.find( + (item) => item.id === (c.type_id || drug.type_id || 0), + ), + use_frequency: drugUseFrequency.value.find( + (item) => item.id === (c.frequency_id || drug.frequency_id || 0), + ), + unit: + c.unit || + drugUnit.value.find( + (item) => item.id === (c.unit_id || drug.unit_id || 0), + ), + type: 2, + }); + continue; + } + next.push({ + index_id: c.id || drugId, + id: drugId, + drug_name: c.drug_name || row.ai_name, + number: 1, + select_number: 1, + price: parseFloat(c.price) || 0, + image: c.image || '', + type: activeCategory.value, + }); + } + if (!next.length) { + message.warning('没有可导入的药品'); + return; + } + currentDrugs.value = next; + if (activeCategory.value === 1) { + if (payload.dosage != null && Number(payload.dosage) > 0) { + dosage.value = Number(payload.dosage); + } + if (payload.day_dosage != null && Number(payload.day_dosage) > 0) { + dayDosage.value = Number(payload.day_dosage); + } + } + updateLocalStorage(); + message.success(`已覆盖导入 ${next.length} 个药品`); + }; + if (currentDrugs.value.length > 0) { + AntModal.confirm({ + title: '覆盖当前处方', + content: '导入将清空并覆盖现有处方药品,是否继续?', + okText: '继续覆盖', + cancelText: '取消', + onOk: () => doOverwrite(), + }); + return; + } + doOverwrite(); +} + +/** 未对照药名:提示手动搜索 */ +function handleAiSearchUnmatchedDrug(keyword: string) { + const key = String(keyword || ''); + if (activeCategory.value === 2) { + openWesternModal(); + return; + } + if (key) { + message.info(`请手动搜索:${key}`); + } +} + /** * 处理选择常用方 * @param data 常用方数据,包含prescription和recipes @@ -2947,6 +3120,15 @@ function onStoreSelectOpenChange(open: boolean) { 选择常用方 + + + AI辅助出方 + 引用历史病历 + + + AI写病历 + @@ -3767,6 +3958,12 @@ function onStoreSelectOpenChange(open: boolean) { + diff --git a/apps/web-antd/src/views/doctor/medical-record/MedicalRecordPanel.vue b/apps/web-antd/src/views/doctor/medical-record/MedicalRecordPanel.vue index c0521f1a..e91b8d60 100644 --- a/apps/web-antd/src/views/doctor/medical-record/MedicalRecordPanel.vue +++ b/apps/web-antd/src/views/doctor/medical-record/MedicalRecordPanel.vue @@ -19,6 +19,7 @@ import { import { ClearOutlined, HistoryOutlined, + RobotOutlined, SaveOutlined, } from '@ant-design/icons-vue'; @@ -35,6 +36,7 @@ import EntryKeywordBubble, { import CommonDxOrderChips from './components/CommonDxOrderChips.vue'; import PatientMedicalRecordDrawer from './components/PatientMedicalRecordDrawer.vue'; import TextareaExpandModal from './components/TextareaExpandModal.vue'; +import AiMedicalRecordModal from './components/AiMedicalRecordModal.vue'; import { emptyMedicalRecord, isHistoryFieldVisible } from './config/constants'; import { clearMedicalRecordDraft, @@ -62,6 +64,8 @@ const props = withDefaults( patientSex?: number; /** 就诊人年龄,用于婚育史显隐 */ patientAge?: number; + /** 就诊人姓名(AI 写病历确认用) */ + patientName?: string; /** * 是否在面板内展示操作工具栏 * 接诊台/弹窗已把按钮提到一级 Tab 下吸顶工具栏时传 false @@ -75,6 +79,7 @@ const props = withDefaults( storagePrefix: '', patientSex: 0, patientAge: 0, + patientName: '', showToolbar: true, }, ); @@ -579,6 +584,54 @@ async function handleSave() { } } +/** + * 打开 AI 写病历抽屉:先进历史;无历史自动打开生成框 + */ +function handleAiGenerate() { + if (!props.registerId) { + message.warning('无挂号信息'); + return; + } + aiMedicalRecordModalRef.value?.open({ + registerId: props.registerId, + storeId: props.storeId || undefined, + patientName: props.patientName || '', + patientSex: Number(props.patientSex || 0), + patientAge: Number(props.patientAge || 0), + chiefComplaint: String(form.chief_complaint || ''), + }); +} + +/** + * 将 AI 预览结果导入病历表单(不自动保存) + */ +function handleImportAiMedicalRecord(payload: { + chief_complaint: string; + fields: Record; +}) { + const fields = payload?.fields || {}; + Object.keys(fields).forEach((k) => { + if (k === 'chief_complaint' || k === 'name') return; + // 诊断与处方同源,走双向绑定而不是仅写 form + if (k === 'diagnosis') { + const dx = fields[k] == null ? '' : String(fields[k]); + form.diagnosis = dx; + emit('update:diagnosis', dx); + return; + } + if (Object.prototype.hasOwnProperty.call(form, k)) { + (form as any)[k] = fields[k] == null ? '' : String(fields[k]); + } + }); + if (payload?.chief_complaint) { + form.chief_complaint = payload.chief_complaint; + } + persistLocalDraft(); + message.success('已导入,请检查后保存'); +} + +const aiMedicalRecordModalRef = ref | null>(null); + async function handleClear() { if (!props.registerId) return; await clearMedicalRecord({ @@ -637,6 +690,7 @@ defineExpose({ loadRecord, applyCitedRecord, openCiteHistory: handleCiteHistory, + aiGenerate: handleAiGenerate, loading, getPayload: () => ({ ...form, @@ -644,6 +698,30 @@ defineExpose({ diagnosis: diagnosisModel.value, doctor_order: medicalAdviceModel.value, }), + /** + * 外部局部回写病历字段(如 AI 出方预览里点改),同步表单与本地草稿,不自动落库 + */ + patchFields: (partial: Record) => { + if (!partial || typeof partial !== 'object') return; + Object.keys(partial).forEach((k) => { + const raw = partial[k]; + const val = raw == null ? '' : String(raw); + if (k === 'diagnosis') { + form.diagnosis = val; + emit('update:diagnosis', val); + return; + } + if (k === 'doctor_order' || k === 'medicalAdvice') { + form.doctor_order = val; + emit('update:medicalAdvice', val); + return; + } + if (Object.prototype.hasOwnProperty.call(form, k)) { + (form as any)[k] = val; + } + }); + persistLocalDraft(); + }, }); /** source:走中医三典气泡;默认 entry 走病历词条 */ @@ -692,6 +770,8 @@ const allNormalFields = [ fieldBlock('personal_history', '个人史'), fieldBlock('allergy_history', '过敏史'), fieldBlock('treatment_advice', '治疗意见'), + // 诊断/医嘱与处方 Tab 同源双向同步(此前网格漏渲染导致病历侧「诊断」消失) + fieldBlock('diagnosis', '临床诊断'), fieldBlock('doctor_order', '医嘱'), fieldBlock('physical_exam', '体征检查'), fieldBlock('auxiliary_exam', '辅助检查'), @@ -724,6 +804,10 @@ const visibleNormalFields = computed(() => 引用历史病历 + + + AI写病历 + > {{ f.label }} + + onEntryPick('diagnosis', item)" + @common-changed="commonChipsTick += 1" + /> + + /> - + + + + + + onEntryPick('diagnosis', { + id: text, + title: text, + content: text, + remark: '', + }) + " + /> + + + diff --git a/apps/web-antd/src/views/doctor/medical-record/components/EntryListPage.vue b/apps/web-antd/src/views/doctor/medical-record/components/EntryListPage.vue index aac59397..3f7cbd1e 100644 --- a/apps/web-antd/src/views/doctor/medical-record/components/EntryListPage.vue +++ b/apps/web-antd/src/views/doctor/medical-record/components/EntryListPage.vue @@ -175,7 +175,10 @@ async function handleImportFile(file: File) { importing.value = true; try { const buffer = await file.arrayBuffer(); - const parsed = await parseMedicalRecordEntryExcelBuffer(buffer); + // 按字段拆分页传入 defaultFieldCode:允许 Excel 字段编码留空 / 填中文名 + const parsed = await parseMedicalRecordEntryExcelBuffer(buffer, { + defaultFieldCode: props.fixedFieldCode || '', + }); let rows = parsed.items; if (props.fixedFieldCode) { rows = rows @@ -183,10 +186,13 @@ async function handleImportFile(file: File) { .filter((r) => r.title); } if (!rows.length) { + const detail = parsed.firstInvalidReason + ? `:${parsed.firstInvalidReason}` + : ''; message.warning( parsed.invalidCount - ? `没有可导入的有效行(无效 ${parsed.invalidCount} 行)` - : '文件无有效数据', + ? `没有可导入的有效行(无效 ${parsed.invalidCount} 行)${detail}` + : '文件无有效数据,请确认填写的是「词条导入」工作表', ); return false; } diff --git a/apps/web-antd/src/views/doctor/medical-record/components/PatientMedicalRecordDrawer.vue b/apps/web-antd/src/views/doctor/medical-record/components/PatientMedicalRecordDrawer.vue index 36308862..953f65ed 100644 --- a/apps/web-antd/src/views/doctor/medical-record/components/PatientMedicalRecordDrawer.vue +++ b/apps/web-antd/src/views/doctor/medical-record/components/PatientMedicalRecordDrawer.vue @@ -3,12 +3,12 @@ * 患者历史病历抽屉 * - view:只读查看 * - cite:选择一条后点「引用此病历」回填到当前接诊病历 - * (修复:原 latest 接口会排除当前挂号,仅有本次病历时会误报「暂无」) + * 右侧预览用 Descriptions 双列,跳过空字段 */ import { computed, ref } from 'vue'; import { useVbenDrawer } from '@vben/common-ui'; -import { Empty, Spin, Tag, message } from 'ant-design-vue'; +import { Descriptions, Empty, Spin, Tag, message } from 'ant-design-vue'; import { getMedicalRecordListByPatient } from '../api'; import { MEDICAL_RECORD_VIEW_FIELDS } from '../config/constants'; @@ -19,14 +19,22 @@ type DrawerData = { userPatientId?: number; storeId?: number; patientName?: string; - /** view=查看;cite=引用到当前病历 */ mode?: 'view' | 'cite'; - /** 引用模式下排除当前挂号(仍展示,但默认选中其他挂号) */ excludeRegisterId?: number; - /** 引用确认回调 */ onCite?: (record: Record) => void; }; +/** Descriptions 通栏字段 */ +const FULL_SPAN_KEYS = new Set([ + 'chief_complaint', + 'present_illness', + 'tcm_case', + 'treatment_advice', + 'doctor_order', + 'auxiliary_exam', + 'physical_exam', +]); + const loading = ref(false); const list = ref[]>([]); const activeId = ref(0); @@ -38,7 +46,6 @@ const activeRecord = computed(() => { return list.value.find((r) => Number(r.id) === activeId.value) || null; }); -/** 体征摘要文案 */ function vitalsText(row: Record) { const parts: string[] = []; if (row.temperature) parts.push(`体温 ${row.temperature}℃`); @@ -56,13 +63,25 @@ function fieldDisplay(row: Record, key: string) { const body = String(row.physical_exam || '').trim(); const vitals = vitalsText(row); if (body && vitals) return `${vitals}\n${body}`; - return body || vitals || '—'; + return body || vitals || ''; } const v = row[key]; - if (v === null || v === undefined || v === '' || v === 0) return '—'; - return String(v); + if (v === null || v === undefined || v === '' || v === 0) return ''; + return String(v).trim(); } +/** 仅有内容的字段,供 Descriptions 渲染 */ +const filledViewFields = computed(() => { + const row = activeRecord.value; + if (!row) return []; + return MEDICAL_RECORD_VIEW_FIELDS.map((f) => ({ + label: f.label, + value: f.value, + text: fieldDisplay(row, f.value), + span: FULL_SPAN_KEYS.has(f.value) ? 2 : 1, + })).filter((f) => !!f.text); +}); + function listTitle(row: Record) { const dx = String(row.diagnosis || '').trim(); if (dx) return dx.length > 28 ? `${dx.slice(0, 28)}…` : dx; @@ -74,9 +93,6 @@ function isCurrentRegister(row: Record) { return exclude > 0 && Number(row.register_id) === exclude; } -/** - * 默认选中:优先「非当前挂号」的最近一条,否则选第一条 - */ function pickDefaultActiveId(rows: Record[]) { if (!rows.length) return 0; const exclude = Number(meta.value.excludeRegisterId || 0); @@ -113,7 +129,7 @@ async function loadList() { } const [Drawer, drawerApi] = useVbenDrawer({ - class: 'w-[860px]', + class: 'w-[900px]', title: '查看患者病历', cancelText: '关闭', confirmText: '引用此病历', @@ -177,27 +193,50 @@ const [Drawer, drawerApi] = useVbenDrawer({ - - 挂号 ID:{{ activeRecord.register_id || '-' }} - 创建:{{ activeRecord.created_at || '—' }} - 更新:{{ activeRecord.updated_at || '—' }} - + + + {{ activeRecord.register_id || '-' }} + + + {{ activeRecord.created_at || '—' }} + + + {{ activeRecord.updated_at || '—' }} + + 当前选中的是本次挂号病历,引用后会覆盖编辑区内容(仍可继续改)。 - + - {{ f.label }} - - {{ fieldDisplay(activeRecord, f.value) }} - - + + {{ f.text }} + + @@ -208,22 +247,22 @@ const [Drawer, drawerApi] = useVbenDrawer({ diff --git a/apps/web-antd/src/views/doctor/medical-record/utils/exportMedicalRecordEntryExcel.ts b/apps/web-antd/src/views/doctor/medical-record/utils/exportMedicalRecordEntryExcel.ts index a9f78d97..eb8a364a 100644 --- a/apps/web-antd/src/views/doctor/medical-record/utils/exportMedicalRecordEntryExcel.ts +++ b/apps/web-antd/src/views/doctor/medical-record/utils/exportMedicalRecordEntryExcel.ts @@ -78,8 +78,28 @@ export async function exportMedicalRecordEntryTemplate(): Promise { }); }); tip.addRow([]); + tip.addRow([ + '填写说明', + '「字段编码」可填右侧英文编码,也可直接填左侧中文名(如:主诉)', + ]); tip.addRow(['状态说明', '1=启用,0=禁用;空默认启用']); tip.addRow(['拼音首拼', '由系统根据词条名称自动生成,无需填写']); + tip.addRow([ + '按字段菜单导入', + '在「主诉/既往史」等拆分页导入时,字段编码列可留空,系统自动归属当前字段', + ]); + + // 给「字段编码」列加下拉,减少填错中文/编码混用导致整表无效 + const codeList = MEDICAL_RECORD_FIELD_OPTIONS.map((o) => o.value).join(','); + // Excel 数据验证公式长度有限;字段不多时可直接列枚举 + sheet.dataValidations.add('A2:A2001', { + type: 'list', + allowBlank: true, + formulae: [`"${codeList}"`], + showErrorMessage: true, + errorTitle: '字段编码', + error: '请从下拉选择英文编码,或手动填写中文名(如:主诉)', + }); return workbook.xlsx.writeBuffer() as Promise; } diff --git a/apps/web-antd/src/views/doctor/medical-record/utils/parseMedicalRecordEntryExcel.ts b/apps/web-antd/src/views/doctor/medical-record/utils/parseMedicalRecordEntryExcel.ts index be6cab8c..e7dedfad 100644 --- a/apps/web-antd/src/views/doctor/medical-record/utils/parseMedicalRecordEntryExcel.ts +++ b/apps/web-antd/src/views/doctor/medical-record/utils/parseMedicalRecordEntryExcel.ts @@ -17,18 +17,45 @@ export type ParseMedicalRecordEntryResult = { items: ParsedMedicalRecordEntryRow[]; invalidCount: number; rowCount: number; + /** 首条无效原因,便于前端提示(如填了中文名未识别等) */ + firstInvalidReason?: string; +}; + +export type ParseMedicalRecordEntryOptions = { + /** + * 按字段拆分菜单导入时传入:字段编码列可留空,自动落到当前字段 + * 为什么需要:运营在「主诉词条」页导入时常不填字段编码,旧逻辑会整行判无效 + */ + defaultFieldCode?: string; }; function normalizeCell(value: ExcelJS.CellValue): string { if (value === null || value === undefined) { return ''; } - if (typeof value === 'object' && 'text' in value) { - return String((value as any).text ?? '').trim(); + if (typeof value === 'object') { + // 富文本:Excel/WPS 局部加粗后常见 { richText: [...] } + if ('richText' in value && Array.isArray((value as any).richText)) { + return (value as any).richText + .map((p: { text?: string }) => p?.text ?? '') + .join('') + .trim(); + } + // 超链接 / 带 text 的复合值 + if ('text' in value) { + return String((value as any).text ?? '').trim(); + } + // 公式结果 + if ('result' in value) { + return normalizeCell((value as any).result as ExcelJS.CellValue); + } } if (typeof value === 'number') { return Number.isInteger(value) ? String(value) : String(value).trim(); } + if (typeof value === 'boolean') { + return value ? '1' : '0'; + } return String(value).trim(); } @@ -45,6 +72,23 @@ const STATUS_ALIASES = ['状态', 'status']; const VALID_FIELDS = new Set(MEDICAL_RECORD_FIELD_OPTIONS.map((o) => o.value)); +/** 中文名 / 英文编码 → 标准 field_code(运营常按「字段编码说明」里的中文名填写) */ +const FIELD_LABEL_TO_CODE = new Map( + MEDICAL_RECORD_FIELD_OPTIONS.flatMap((o) => [ + [normalizeHeader(o.value), o.value], + [normalizeHeader(o.label), o.value], + ]), +); + +/** + * 把单元格里的字段编码或中文名解析成标准 code;无法识别则返回空串 + */ +function resolveFieldCode(raw: string): string { + const key = normalizeHeader(raw); + if (!key) return ''; + return FIELD_LABEL_TO_CODE.get(key) || (VALID_FIELDS.has(raw) ? raw : ''); +} + function buildHeaderIndexMap(headerRow: ExcelJS.Row): Record { const map: Record = {}; headerRow.eachCell({ includeEmpty: true }, (cell, colNumber) => { @@ -81,25 +125,38 @@ function getCellInt(row: ExcelJS.Row, col?: number, fallback = 0): number { } function rowIsEmpty(row: ExcelJS.Row): boolean { - let empty = true; - row.eachCell({ includeEmpty: false }, () => { - empty = false; + let hasValue = false; + row.eachCell({ includeEmpty: false }, (cell) => { + if (normalizeCell(cell.value) !== '') { + hasValue = true; + } }); - return empty; + return !hasValue; +} + +/** + * 优先取「词条导入」sheet;避免用户改完后激活了「字段编码说明」导致读错表 + */ +function pickImportSheet(workbook: ExcelJS.Workbook): ExcelJS.Worksheet | undefined { + const byName = workbook.worksheets.find((s) => s.name === '词条导入'); + return byName || workbook.worksheets[0]; } /** * 解析词条导入 Excel(首行为表头) + * 字段编码列支持英文 code 或中文名;按字段页可传 defaultFieldCode 允许留空 */ export async function parseMedicalRecordEntryExcelBuffer( buffer: ArrayBuffer, + options: ParseMedicalRecordEntryOptions = {}, ): Promise { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(buffer); - const sheet = workbook.worksheets[0]; + const sheet = pickImportSheet(workbook); if (!sheet) { return { items: [], invalidCount: 0, rowCount: 0 }; } + const defaultFieldCode = resolveFieldCode(options.defaultFieldCode || ''); const headerRow = sheet.getRow(1); const headerMap = buildHeaderIndexMap(headerRow); const fieldCol = pickColumn(headerMap, FIELD_ALIASES); @@ -108,21 +165,44 @@ export async function parseMedicalRecordEntryExcelBuffer( const remarkCol = pickColumn(headerMap, REMARK_ALIASES); const sortCol = pickColumn(headerMap, SORT_ALIASES); const statusCol = pickColumn(headerMap, STATUS_ALIASES); - if (!fieldCol || !titleCol) { - throw new Error('模板表头缺少「字段编码」或「词条名称」列'); + // 无默认字段时必须有「字段编码」列;有默认字段时仅要求「词条名称」 + if (!titleCol) { + throw new Error('模板表头缺少「词条名称」列,请重新下载模板'); + } + if (!fieldCol && !defaultFieldCode) { + throw new Error('模板表头缺少「字段编码」列,请重新下载模板'); } const items: ParsedMedicalRecordEntryRow[] = []; let invalidCount = 0; let rowCount = 0; - sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { - if (rowNumber === 1 || rowIsEmpty(row)) return; + let firstInvalidReason: string | undefined; + const maxRow = Math.min(sheet.rowCount || 0, MAX_ROWS + 1); + for (let rowNumber = 2; rowNumber <= maxRow; rowNumber++) { + const row = sheet.getRow(rowNumber); + if (rowIsEmpty(row)) continue; rowCount++; - if (items.length >= MAX_ROWS) return; - const field_code = getCellText(row, fieldCol); + if (items.length >= MAX_ROWS) break; + const rawField = getCellText(row, fieldCol); const title = getCellText(row, titleCol); - if (!field_code || !title || !VALID_FIELDS.has(field_code)) { + let field_code = resolveFieldCode(rawField); + if (!field_code && defaultFieldCode) { + field_code = defaultFieldCode; + } + if (!title) { invalidCount++; - return; + if (!firstInvalidReason) { + firstInvalidReason = `第${rowNumber}行:词条名称为空`; + } + continue; + } + if (!field_code) { + invalidCount++; + if (!firstInvalidReason) { + firstInvalidReason = rawField + ? `第${rowNumber}行:字段「${rawField}」无法识别(请填英文编码如 chief_complaint,或中文名如「主诉」)` + : `第${rowNumber}行:字段编码为空(请对照「字段编码说明」填写,或从对应字段菜单页导入)`; + } + continue; } const statusRaw = getCellInt(row, statusCol, 1); items.push({ @@ -133,6 +213,6 @@ export async function parseMedicalRecordEntryExcelBuffer( sort: getCellInt(row, sortCol, 0), status: statusRaw === 0 ? 0 : 1, }); - }); - return { items, invalidCount, rowCount }; + } + return { items, invalidCount, rowCount, firstInvalidReason }; }