/** * 建档模块 接口适配层(2026-10-11 新版接口) * ------------------------------------------------------------------ * 新版后端把「新增 / 修改」合并成一个 insertOrUpdate 接口,请求体是 * 主表字段 + 详情表字段的扁平混合,一次调用同时维护两张表,并返回主表 ID。 * 因此这里不再做「主表一次请求 + 详情表一次请求」的拆分,只负责: * 1. 前端单一档案对象 <-> 后端扁平字段的转换 * 2. 量表 / 可编辑表格数据的 JSON 打包与解包 * * 对外函数签名与旧版保持一致,页面无需改动。 */ import { ARCHIVE_TYPE_CODE, resolveTarget, resolveKey, toApiFields, fromApiFields, toApiParent, packJson, unpackJson, splitLearningForm, normalizeDates, FORM_FIELD_BY_TAB, archiveApi, nowDateTime, today, toDateTime, calcAge, } from "@/api/patient/archive"; import { getTypeByKey } from "./types"; /* ==================== 内部工具 ==================== */ function pick(obj, keys) { const out = {}; keys.forEach((k) => { const v = obj && obj[k]; if (v !== undefined && v !== null && v !== "") out[k] = v; }); return out; } /** 后端字段 -> 前端字段(列表行、详情记录共用) * 注意:appointmentSituation 不在这里,它存的是 JSON 串,由 parseAppointmentStatus 单独解析 */ const API_TO_FRONT = { highRiskChildType: "highriskCategory", highRiskChildName: "highriskName", nutritionIcd10Code: "nutritionCode", nutritionDiagName: "nutritionName", plannedFollowTime: "followupDate", birthWeight: "birthWeight", gestationalWeek: "gestationalWeek", pregnancySituation: "pregnancyStatus", followupCycleTimes: "followupCycles", }; /** * 解析「预约情况」 * 后端这个字段存的是 JSON 串:{"appointmentStatus":"无预约"}, * 直接铺到列表上会把整串 JSON 显示出来,这里统一取里面的 appointmentStatus。 * 已经是纯文本时原样返回,解析不出内容则返回空。 */ function parseAppointmentStatus(val) { if (val === undefined || val === null || val === "") return ""; if (typeof val !== "string") return val.appointmentStatus || ""; const s = val.trim(); if (s.charAt(0) !== "{") return s; try { const o = JSON.parse(s); return (o && o.appointmentStatus) || ""; } catch (e) { return ""; } } /* ------------------------------------------------------------------ * 模块名口径不一致的统一入口 * 页面(types.js)用 maternal|child,接口层(ARCHIVE_TYPE_CODE)用 maternity|child。 * 两边不统一会导致 resolveKey 匹配不上,列表页所有专案都识别不出来。 */ function innerModule(module) { return module === "child" ? "child" : "maternity"; } /** 接口层模块名 -> 页面口径 */ function frontModule(module) { return module === "child" ? "child" : "maternal"; } /** 详情表冗余的患者基本信息 -> 前端对象 */ function applyCommonFields(target, row) { const front = fromApiFields(row); Object.keys(front).forEach((k) => { // id / detailId / subType 属于骨架自身的字段,不能被原始行数据覆盖 if (k === "id" || k === "detailId" || k === "subType") return; const v = front[k]; if (v !== undefined && v !== null && v !== "") target[k] = v; }); Object.keys(API_TO_FRONT).forEach((apiKey) => { const v = row[apiKey]; if (v !== undefined && v !== null && v !== "") target[API_TO_FRONT[apiKey]] = v; }); if (row.appointmentSituation) { target.appointmentStatus = parseAppointmentStatus(row.appointmentSituation); } if (row.birthdate) target.age = calcAge(row.birthdate); if (row.parentname || row.parentPhone || row.parentRelation) { target.parentInfo = Object.assign({}, target.parentInfo, { parentName: row.parentname || "", relation: row.parentRelation || "", parentPhone: row.parentPhone || "", parentIdCard: row.parentIdcardno || "", address: row.parentAddress || "", }); } if (row.sourceType === "1" || row.sourceType === 1) target.sourcePage = "outpatient"; return target; } /** * 后端行 -> 前端档案对象骨架 * * ⚠️ id 与 archiveid 是两个独立字段,不能互相赋值,必须由调用方显式声明这一行来自哪张表: * from='main' 主表行(列表接口返回):row.id 是主表主键,没有 archiveid * from='detail' 详情行(详情接口返回):row.id 是详情表主键,row.archiveid 是主表 ID * * 前端对象上: * archive.id 永远 = 主表主键 * archive.detailId 永远 = 详情表主键(只有详情行才有) * * @param {Object} row 后端行 * @param {String} typeKey 专案 key * @param {String} module maternity|child * @param {String} from main|detail 这一行的来源表 */ function skeletonFrom(row, typeKey, module, from) { const t = ARCHIVE_TYPE_CODE[typeKey] || {}; const typeCfg = getTypeByKey(typeKey) || {}; const isDetail = from === "detail"; const archive = { // 主表主键:主表行直接取 row.id;详情行取外键 row.archiveid id: isDetail ? row.archiveid : row.id, // 详情表主键:只有详情行才有,主表行(列表)拿不到,保持为空 detailId: isDetail ? row.id : "", subType: typeKey, subTypeLabel: typeCfg.subTypeLabel || t.label || "", mainCategory: frontModule(module), mainCategoryLabel: frontModule(module) === "child" ? "儿保科建档" : "妇保科建档", sourcePage: "", parentInfo: {}, archiveForm: {}, }; return applyCommonFields(archive, row); } /** 列表行(主表行) -> 前端档案对象 */ function normalizeRow(row, module) { const inner = innerModule(module); const key = resolveKey(inner, row.archiveType) || ""; return skeletonFrom(row, key, inner, "main"); } /* ------------------------------------------------------------------ * 主表行缓存 * 列表接口只返回主表行(不含详情主键),详情接口按主表 ID 查,两者用主表 id 就能串起来。 * 把列表行按主表 id 暂存到 sessionStorage,getArchive 取不到详情时用它兜底, * 至少能把姓名、性别、诊断、联系方式这些主表字段显示出来,而不是一片空白。 */ const ROW_PREFIX = "archiveRow:"; function cacheRows(rows = []) { try { rows.forEach((r) => { // 列表行是主表行,主键只认 row.id;不要用 archiveid 兜底,主表行根本没有这个字段 const id = r.id; if (id) sessionStorage.setItem(ROW_PREFIX + id, JSON.stringify(r)); }); } catch (e) { /* 忽略隐私模式等写入异常 */ } } function readRow(id) { if (!id) return null; try { return JSON.parse(sessionStorage.getItem(ROW_PREFIX + id) || "null"); } catch (e) { return null; } } /* ------------------------------------------------------------------ * 新建草稿缓存 * 详情接口按【主表 ID】查询,但 insertOrUpdate 刚返回主表 ID 时后端详情行可能还没落好, * 新建完立刻跳详情页有概率取不到详情记录。这里把新建时的档案对象暂存到 * sessionStorage,getArchive 取不到详情时用它兜底,避免详情页一片空白。 * 保存成功后清除。 */ const DRAFT_PREFIX = "archiveDraft:"; function saveDraft(id, archive) { if (!id) return; try { sessionStorage.setItem(DRAFT_PREFIX + id, JSON.stringify(archive)); } catch (e) { /* 忽略隐私模式等写入异常 */ } } function readDraft(id) { if (!id) return null; try { return JSON.parse(sessionStorage.getItem(DRAFT_PREFIX + id) || "null"); } catch (e) { return null; } } function clearDraft(id) { if (!id) return; try { sessionStorage.removeItem(DRAFT_PREFIX + id); } catch (e) { /* ignore */ } } /* ==================== 前端对象 -> 扁平 payload ==================== */ /** * 构造 insertOrUpdate 的请求体(主表 + 详情表字段扁平混合) * @param {Object} archive 前端档案对象 * @param {Object} t resolveTarget 结果 * @param {Boolean} forUpdate 是否更新(带 archiveid / id) */ export function buildPayload(archive, t, forUpdate) { const key = archive.subType; const F = FORM_FIELD_BY_TAB[key] || {}; const payload = toApiFields(archive); payload.archiveType = t.archiveType; if (forUpdate) { // 扁平请求体里这两个字段各自代表一张表的主键,值来源不同,不能互相顶替: // archiveid = 主表 ID(来自 archive.id) // id = 详情表主键(来自 archive.detailId,列表接口不返回,取不到就不传,后端按新增处理) if (archive.id) payload.archiveid = archive.id; if (archive.detailId) payload.id = archive.detailId; } if (archive.sourcePage === "outpatient") payload.sourceType = "1"; // 冗余的患者基本信息:详情表也存一份 const common = pick(archive, [ "outhospno", "inhospno", "serialnum", "patid", "patname", "sex", "deptcode", "deptname", "wardcode", "wardname", "icd10code", "diagname", "idcardno", ]); Object.assign(payload, common, { birthdate: toDateTime(archive.birthday), diagTime: toDateTime(archive.diagnosisDate), archiveTime: toDateTime(archive.createTime), doctorName: archive.attendingDoctor || "", managementDoctorName: archive.managingDoctor || "", registerUser: archive.registrant || "", closeStatus: archive.closeStatus == null ? "0" : String(archive.closeStatus), }); if (key === "pregnancy") { const p = archive.pregFormData || {}; const reg = p.registration || {}; const preg = p.pregnancy || {}; const fv = p.firstVisit || {}; Object.assign(payload, { lastMenstrualTime: toDateTime(fv.lmp), firstDoctorName: fv.firstDoctor || "", contactPhone: fv.contactPhone || fv.selfPhone || "", address: fv.address || "", pregnancySituation: archive.pregnancyStatus || "", specificSituation: packJson(preg.details || []), handleSuggestion: packJson(preg.hint || []), isRecordEstablished: reg.isRegistered === "是" ? "1" : reg.isRegistered === "否" ? "0" : "", dueDate: toDateTime(reg.edd), followupPeriod: packJson(p.followupCycle || []), [F.followup]: packJson(p), }); } else if (key === "menopause") { const f = archive.formData || {}; Object.assign(payload, { [F.sysQ]: packJson(f.menoSysQ || {}), [F.mhtFirst]: packJson(f.mhtFirst || {}), [F.mhtFollow]: packJson(f.mhtFollow || {}), [F.kupperman]: packJson(f.kupperman || {}), [F.mrs]: packJson(f.mrs || {}), [F.psqi]: packJson(f.psqi || {}), }); } else if (key === "highrisk") { Object.assign(payload, toApiParent(archive.parentInfo || {}), { highRiskChildType: archive.highriskCategory || "", highRiskChildName: archive.highriskName || "", plannedFollowTime: toDateTime(archive.followupDate), [F.growth]: packJson(archive.growthData || []), [F.neuro]: packJson(archive.neuroData || []), [F.intervention]: packJson(archive.interventionData || []), }); } else if (key === "nutrition") { Object.assign(payload, toApiParent(archive.parentInfo || {}), { nutritionDiagName: archive.nutritionName || "", nutritionIcd10Code: archive.nutritionCode || "", [F.measure]: packJson(archive.measureData || []), [F.diet]: packJson(archive.dietData || []), [F.plan]: packJson(archive.planData || []), }); } else if (key === "learning") { const ld = (archive.archiveForm && archive.archiveForm.learningData) || archive.archiveForm || {}; Object.assign( payload, toApiParent(archive.parentInfo || {}), splitLearningForm(ld, archive.appointmentStatus || "") ); } delete payload.age; return normalizeDates(payload); } /* ==================== 详情记录 -> 页面表单数据 ==================== */ /** 把详情表 TEXT 字段还原成页面表单数据 */ function applyDetailToForms(archive, detail) { const key = archive.subType; const F = FORM_FIELD_BY_TAB[key] || {}; if (key === "pregnancy") { const early = unpackJson(detail.epEarlyPregnancyFollowupForm, null); archive.pregFormData = early || {}; if (!archive.pregFormData.followupCycle) { archive.pregFormData.followupCycle = unpackJson(detail.followupPeriod, []); } if (!archive.pregFormData.pregnancy) { archive.pregFormData.pregnancy = { status: "", details: unpackJson(detail.specificSituation, []), hint: unpackJson(detail.handleSuggestion, []), }; } if (!archive.pregFormData.registration) archive.pregFormData.registration = {}; if (detail.isRecordEstablished != null && detail.isRecordEstablished !== "") { archive.pregFormData.registration.isRegistered = detail.isRecordEstablished === "1" ? "是" : "否"; } if (detail.dueDate && !archive.pregFormData.registration.edd) { archive.pregFormData.registration.edd = String(detail.dueDate).substring(0, 10); } if (detail.lastMenstrualTime && !archive.pregFormData.firstVisit) { archive.pregFormData.firstVisit = { lmp: String(detail.lastMenstrualTime).substring(0, 10), }; } } else if (key === "menopause") { archive.formData = { menoSysQ: unpackJson(detail.mhiMenopauseFollowupSystemIssuesForm, {}), mhtFirst: unpackJson(detail.mhiMhtInitialVisitMedicalRecordForm, {}), mhtFollow: unpackJson(detail.mhiMhtFollowUpMedicalRecordForm, {}), kupperman: unpackJson(detail.mhiModifiedKuppermanScoringScaleForm, {}), mrs: unpackJson(detail.mhiMenopauseRatingScaleForm, {}), psqi: unpackJson(detail.mhiPittsburghSleepQualityIndexForm, {}), }; } else if (key === "highrisk") { archive.growthData = unpackJson(detail.hrcGrowthDevelopmentMonitoring, []); archive.neuroData = unpackJson(detail.hrcNeurologicalEvaluation, []); archive.interventionData = unpackJson(detail.hrcInterventionRecords, []); } else if (key === "nutrition") { archive.measureData = unpackJson(detail.ndPhysicalMeasurements, []); archive.dietData = unpackJson(detail.ndDietaryAssessment, []); archive.planData = unpackJson(detail.ndInterventionProgram, []); } else if (key === "learning") { const appt = unpackJson(detail.appointmentSituation, {}); const ability = unpackJson(detail.ldLearningAbilityAssessment, {}); const behavior = unpackJson(detail.ldBehavioralAssessment, {}); const train = unpackJson(detail.ldInterventionTrainingRecord, {}); archive.appointmentStatus = appt.appointmentStatus || ""; archive.archiveForm = { learningData: Object.assign({}, ability, behavior, train), }; } } /* ==================== 查询 ==================== */ /** * 分页查询某专案档案(自动带上 archiveType) * @param {String} typeKey 专案 key * @param {Object} params 查询条件,支持 params.beginTime / params.endTime * @returns Promise<{ rows, total }> */ export function listArchives(typeKey, params = {}) { const t = resolveTarget(typeKey); if (!t) return Promise.resolve({ rows: [], total: 0 }); const api = archiveApi(typeKey); const query = Object.assign({}, params, { archiveType: t.archiveType }); return api.list(query).then((res) => { const rows = res.rows || []; cacheRows(rows); return { rows: rows.map((r) => normalizeRow(r, t.module)), total: res.total || 0, }; }); } /** * 按科室查询全部专案(妇保 / 儿保总列表页用,不带 archiveType) * @param {String} module maternal|child */ export function listArchivesByModule(module, params = {}) { const isChild = innerModule(module) === "child"; // 两个模块共用一个控制器,取任一专案 key 只是为了拿到接口 const api = archiveApi(isChild ? "highrisk" : "pregnancy"); return api.list(Object.assign({}, params)).then((res) => { const rows = res.rows || []; cacheRows(rows); return { rows: rows.map((r) => normalizeRow(r, module)), total: res.total || 0, }; }); } /** * 查询单个档案详情,返回前端完整对象 * @param {String} typeKey 专案 key * @param {String|Number} id 主表 ID(详情接口 getInfo/{archiveid} 就是按它查的) * @param {String|Number} [detailId] 已废弃,保留只为兼容旧调用,不再参与请求 * @returns Promise<前端档案对象 | null> */ export function getArchive(typeKey, id, detailId) { const t = resolveTarget(typeKey); if (!t || !id) return Promise.resolve(null); const api = archiveApi(typeKey); return api .getDetail(id) .then((res) => { const detail = res.data || res; if (!detail || !detail.id) return fallbackArchive(typeKey, id, t); // 详情记录里的 archiveid 必须等于当前主表 id,否则说明数据串了,宁可不要 if (detail.archiveid != null && String(detail.archiveid) !== String(id)) { console.warn( "[archive] 详情记录与主表 id 不匹配,已忽略详情数据:", { archiveid: detail.archiveid, expectId: id } ); return fallbackArchive(typeKey, id, t); } // 详情行:row.id 是详情主键 -> detailId,row.archiveid 是主表 ID -> id const archive = skeletonFrom(detail, typeKey, t.module, "detail"); applyDetailToForms(archive, detail); return archive; }) .catch(() => fallbackArchive(typeKey, id, t)); } /** 详情取不到时的兜底:新建草稿 > 主表行缓存 > 仅带主表 id 的空档案 */ function fallbackArchive(typeKey, id, t) { const cached = readDraft(id); if (cached && cached.subType === typeKey) { return Object.assign(emptyArchive(typeKey, id, t), cached, { id }); } const row = readRow(id); if (row) return skeletonFrom(row, typeKey, t.module, "main"); return emptyArchive(typeKey, id, t); } /** 详情取不到时的兜底:只带主表 id 的空档案,页面仍可填写并保存 */ function emptyArchive(typeKey, id, t) { const typeCfg = getTypeByKey(typeKey) || {}; return { id, detailId: "", subType: typeKey, subTypeLabel: typeCfg.subTypeLabel || t.label || "", mainCategory: frontModule(t.module), mainCategoryLabel: frontModule(t.module) === "child" ? "儿保科建档" : "妇保科建档", sourcePage: "", parentInfo: {}, archiveForm: {}, }; } /* ==================== 新增 ==================== */ /** * 由一条患者记录(门诊 / 住院 / 独立录入)建档 * @param {Object} patient 患者记录 * @param {String} typeKey 专案 key * @param {String} sourcePage outpatient|inpatient|standalone * @returns Promise<前端档案对象> */ export function createArchive(patient, typeKey, sourcePage) { const t = resolveTarget(typeKey); if (!t) return Promise.reject(new Error("未知的档案专案类型:" + typeKey)); const api = archiveApi(typeKey); const now = nowDateTime(); const draft = { subType: typeKey, patid: patient.patid || patient.id || "", patname: patient.patname || patient.name || "", sex: patient.sex, telcode: patient.telcode || patient.phone || "", idcardno: patient.idcardno || "", birthday: patient.birthday || patient.birthdate || patient.goday || "", // 门诊/住院记录里诊断日期字段名不统一(admitdate / diagTime / diagnosisDate),且可能只有日期 diagnosisDate: patient.diagnosisDate || patient.diagTime || patient.admitdate || "", createTime: now, outhospno: patient.outhospno || "", inhospno: patient.inhospno || "", serialnum: patient.serialnum || "", deptcode: patient.deptcode || "", deptname: patient.deptname || "", wardcode: patient.wardcode || "", wardname: patient.wardname || "", icd10code: patient.icd10code || "", diagname: patient.diagname || "", attendingDoctor: patient.drname || "", registrant: localStorage.getItem("YongHuXM") || "", closeStatus: "0", sourcePage: sourcePage || "outpatient", parentInfo: {}, archiveForm: {}, }; const payload = buildPayload(draft, t, false); return api.save(payload).then((res) => { const newId = res && res.data != null ? res.data : ""; const typeCfg = getTypeByKey(typeKey) || {}; const archive = Object.assign({}, draft, { id: newId, detailId: "", subTypeLabel: typeCfg.subTypeLabel || t.label, mainCategory: frontModule(t.module), mainCategoryLabel: frontModule(t.module) === "child" ? "儿保科建档" : "妇保科建档", age: calcAge(draft.birthday), }); saveDraft(newId, archive); return archive; }); } /* ==================== 保存 / 结案 / 删除 ==================== */ /** * 保存档案:一次 insertOrUpdate 同时更新主表与详情表 * @param {Object} archive 前端档案对象 * @param {Boolean} onlyCloseStatus 仅更新结案状态 */ export function saveArchive(archive, onlyCloseStatus) { const key = archive.subType; const t = resolveTarget(key); if (!t || !archive.id) return Promise.reject(new Error("档案缺少 subType 或 id")); const api = archiveApi(key); if (onlyCloseStatus) { return api.save({ archiveid: archive.id, archiveType: t.archiveType, closeStatus: "1" }); } return api.save(buildPayload(archive, t, true)).then((res) => { clearDraft(archive.id); return res; }); } /** 结案 */ export function closeArchive(archive) { return saveArchive(archive, true); } /** * 删除档案(主表逻辑删除,详情表在同一事务里一起删) * @param {String} typeKey 专案 key * @param {Array} ids 主表 id 数组 */ export function removeArchives(typeKey, ids) { const t = resolveTarget(typeKey); if (!t || !ids || !ids.length) return Promise.resolve(); return archiveApi(typeKey).batchDelete(ids); } /** 删除档案(按行数组,自动按专案分组) */ export function removeArchiveList(rows = []) { const groups = {}; rows.forEach((r) => { const key = r.subType || ""; if (!key) return; (groups[key] = groups[key] || []).push(r.id); }); return Promise.all( Object.keys(groups).map((key) => removeArchives(key, groups[key])) ); } export { calcAge, nowDateTime, today, toDateTime };