import { computed, reactive, ref, toValue, type MaybeRefOrGetter } from 'vue' import { ElMessage } from 'element-plus' import { sendSigningSms } from '@/api/workbench/deliveries' import { collectFieldDefs } from '@/utils/signing-fields' import { createSigningTask, getMockPatients, getPatientProfile, getSigningTemplates, isSigningMockEnabled, } from '@/api/workbench/signing' import type { PatientProfile, PatientVisit, SigningMethod, SigningTaskRecord, SigningTemplate, WorkbenchCampus, } from '@/api/workbench/types' export interface UseSigningTaskFormOptions { campus: MaybeRefOrGetter initialTemplate?: MaybeRefOrGetter } /** 左侧科室行(原型 #ntTree 左列) */ export interface DocumentTreeDepartmentRow { name: string /** 该科室命中文书总数(含分类内) */ count: number open: boolean active: boolean categories: Array<{ name: string; count: number; active: boolean }> } /** 右侧文档行;搜索态下带出「科室 · 分类」小标题(与原型一致) */ export interface DocumentListRow { template: SigningTemplate breadcrumb: string } /** 关联处方(原型 V6.4:按就诊号确定性生成,仅演示数据) */ export interface LinkedPrescription { no: string description: string } /** * 演示处方池。原型 RX_POOL 逐条对齐。 * 真实模式没有处方接口,因此只在 Mock 模式下生成,绝不凭空造一条看起来真实的处方。 */ const PRESCRIPTION_POOL = [ { item: '碘佛醇注射液 100ml', usage: '静脉注射 · 检查用' }, { item: '阿莫西林胶囊 0.25g×24', usage: '口服 每日3次' }, { item: '布洛芬缓释胶囊 0.3g×20', usage: '口服 每日2次' }, { item: '奥美拉唑肠溶片 20mg×14', usage: '口服 每日1次 · 晨服' }, { item: '0.9%氯化钠注射液 250ml', usage: '静脉滴注 · 检查前水化' }, ] /** * 按就诊号确定性生成演示处方。 * * 用哈希而不是随机:同一就诊号每次打开弹窗必须带出同一条处方, * 否则「关联处方」会随刷新变来变去,演示时没法解释。 */ function buildPrescription(visit: PatientVisit | null): LinkedPrescription | null { if (!visit) { return null } let seed = 0 for (let index = 0; index < visit.visitNo.length; index += 1) { seed = (seed * 31 + visit.visitNo.charCodeAt(index)) % 9973 } const first = PRESCRIPTION_POOL[seed % PRESCRIPTION_POOL.length] const second = PRESCRIPTION_POOL[(seed + 2) % PRESCRIPTION_POOL.length] const extra = seed % 3 === 0 ? `;${second.item}(${second.usage})` : '' return { no: `Rx${visit.visitNo.replace(/\D/g, '').slice(-8)}`, description: `${first.item}(${first.usage})${extra}`, } } export function useSigningTaskForm(options: UseSigningTaskFormOptions) { const patientId = ref('') const patient = ref(null) const visits = ref([]) const templates = ref([]) const documentSearch = ref('') const selectedTemplateId = ref('') const selectedVisitId = ref('') const method = ref('pad') const smsDestination = ref('') const loadingTemplates = ref(false) const locating = ref(false) const submitting = ref(false) /** 手机号是否明文展示(原型 togglePh);明文查看会记审计日志,因此每次开弹窗都要复位 */ const phonePlain = ref(false) /** 候选患者下拉是否展开(原型 #ntSug 的 .on) */ const suggestionOpen = ref(false) /** 左列展开的科室(原型 ntOpen 的 'd:' 键) */ const expandedDepartments = ref([]) /** 左列选中的科室与分类(原型 ntDeptSel / ntCatSel) */ const selectedDepartment = ref('') const selectedCategory = ref('') const errors = reactive({ patient: '', visit: '', document: '', sms: '', }) /** 演示候选患者(真实模式为空数组,候选下拉自然不出现) */ const patientCandidates = getMockPatients() const selectedVisit = computed( () => visits.value.find((visit) => visit.id === selectedVisitId.value) ?? null, ) const selectedTemplate = computed( () => templates.value.find((template) => template.id === selectedTemplateId.value) ?? null, ) /** 关联处方:随选中就诊带出(仅演示数据) */ const prescription = computed(() => isSigningMockEnabled ? buildPrescription(selectedVisit.value) : null, ) /** * 候选患者:原型 patMatches 的匹配口径——患者ID / 姓名 / 证件号 / 就诊号, * 任一命中即算匹配;关键字为空时列出全部候选。 */ const patientSuggestions = computed(() => { const keyword = patientId.value.trim() if (!keyword) { return patientCandidates } const lower = keyword.toLowerCase() return patientCandidates.filter( (item) => item.id.toLowerCase().includes(lower) || item.name.includes(keyword) || item.idCard.includes(keyword) || item.visits.some((visit) => visit.visitNo.toLowerCase().includes(lower)), ) }) /** 命中关键字的科室 → 分类 → 文书(原型 renderNtTree 的组装段) */ const matchedDepartments = computed(() => { const keyword = documentSearch.value.trim().toLowerCase() const grouped = new Map>() templates.value.forEach((template) => { const matches = !keyword || [template.name, template.code, template.department, template.category] .join(' ') .toLowerCase() .includes(keyword) if (!matches) { return } const categories = grouped.get(template.department) ?? new Map() const categoryTemplates = categories.get(template.category) ?? [] categoryTemplates.push(template) categories.set(template.category, categoryTemplates) grouped.set(template.department, categories) }) return [...grouped.entries()].map(([name, categories]) => { const categoryRows = [...categories.entries()].map(([categoryName, items]) => ({ name: categoryName, templates: items, })) return { name, categories: categoryRows, templates: categoryRows.flatMap((category) => category.templates), } }) }) /** 搜索态:所有科室展开、不区分选中;否则按用户展开/选中状态渲染 */ const searching = computed(() => documentSearch.value.trim().length > 0) const departmentRows = computed(() => matchedDepartments.value.map((department) => { const open = searching.value || expandedDepartments.value.includes(department.name) return { name: department.name, count: department.templates.length, open, active: !searching.value && selectedDepartment.value === department.name && !selectedCategory.value, categories: department.categories.map((category) => ({ name: category.name, count: category.templates.length, active: !searching.value && selectedDepartment.value === department.name && selectedCategory.value === category.name, })), } }), ) /** 右列数据源与标题(原型 renderNtTree 的 rightDocs / rightLbl) */ const documentList = computed<{ label: string; rows: DocumentListRow[] }>(() => { if (searching.value) { return { label: '搜索结果', rows: matchedDepartments.value.flatMap((department) => department.categories.flatMap((category) => category.templates.map((template) => ({ template, breadcrumb: `${department.name} · ${category.name}`, })), ), ), } } const current = matchedDepartments.value.find( (department) => department.name === selectedDepartment.value, ) if (!current) { return { label: '', rows: [] } } if (selectedCategory.value) { const category = current.categories.find((item) => item.name === selectedCategory.value) if (category) { return { label: `${current.name} · ${category.name}`, rows: category.templates.map((template) => ({ template, breadcrumb: '' })), } } } return { label: current.name, rows: current.templates.map((template) => ({ template, breadcrumb: '' })), } }) function getInitialTemplate() { return options.initialTemplate ? (toValue(options.initialTemplate) ?? null) : null } function includeInitialTemplate() { const initialTemplate = getInitialTemplate() if ( initialTemplate && !templates.value.some((template) => template.id === initialTemplate.id) ) { templates.value = [initialTemplate, ...templates.value] } } /** * 首次进入默认选中第一个有文书的科室并展开(原型 renderNtTree 的 _init 分支), * 这样右列一进来就有内容,不会是一片空白等人去点。 */ function initDocumentTree() { const first = matchedDepartments.value.find((department) => department.templates.length) if (!first) { return } selectedDepartment.value = first.name selectedCategory.value = '' expandedDepartments.value = [first.name] } /** 从文档库带文书进来时:展开并选中该文书所属科室,右列直接展示 */ function focusTemplateDepartment(template: SigningTemplate) { selectedDepartment.value = template.department selectedCategory.value = '' if (!expandedDepartments.value.includes(template.department)) { expandedDepartments.value = [...expandedDepartments.value, template.department] } } function toggleDepartment(name: string) { const expanded = expandedDepartments.value.includes(name) // 已选中且已展开的科室再点一次 = 收起;否则展开并选中,同时清掉分类选中 if (selectedDepartment.value === name && !selectedCategory.value && expanded) { expandedDepartments.value = expandedDepartments.value.filter((item) => item !== name) return } if (!expanded) { expandedDepartments.value = [...expandedDepartments.value, name] } selectedDepartment.value = name selectedCategory.value = '' } function toggleCategory(departmentName: string, categoryName: string) { if (!expandedDepartments.value.includes(departmentName)) { expandedDepartments.value = [...expandedDepartments.value, departmentName] } const sameCategory = selectedDepartment.value === departmentName && selectedCategory.value === categoryName selectedDepartment.value = departmentName selectedCategory.value = sameCategory ? '' : categoryName } function clearErrors() { errors.patient = '' errors.visit = '' errors.document = '' errors.sms = '' } function resetForm() { const initialTemplate = getInitialTemplate() patientId.value = '' patient.value = null visits.value = [] documentSearch.value = '' selectedTemplateId.value = initialTemplate?.id ?? '' selectedVisitId.value = '' method.value = initialTemplate?.supportedMethods[0] ?? 'pad' smsDestination.value = '' phonePlain.value = false suggestionOpen.value = false selectedDepartment.value = '' selectedCategory.value = '' expandedDepartments.value = [] clearErrors() } function resetForNextTask() { const initialTemplate = getInitialTemplate() documentSearch.value = '' selectedTemplateId.value = initialTemplate?.id ?? '' method.value = initialTemplate?.supportedMethods[0] ?? method.value smsDestination.value = '' errors.document = '' errors.sms = '' initDocumentTree() } async function loadTemplates() { if (!templates.value.length) { loadingTemplates.value = true try { templates.value = await getSigningTemplates() } catch { ElMessage.error('文档模板加载失败,请稍后重试') } finally { loadingTemplates.value = false } } includeInitialTemplate() const initialTemplate = getInitialTemplate() if (initialTemplate) { focusTemplateDepartment(initialTemplate) } else { initDocumentTree() } } function openSuggestions() { suggestionOpen.value = true } function closeSuggestions() { suggestionOpen.value = false } /** 点候选行直接定位(原型 pickPat) */ async function pickPatient(candidate: PatientProfile) { patientId.value = candidate.id closeSuggestions() await locatePatient() } async function locatePatient() { const keyword = patientId.value.trim() errors.patient = '' errors.visit = '' patient.value = null visits.value = [] selectedVisitId.value = '' selectedTemplateId.value = getInitialTemplate()?.id ?? '' phonePlain.value = false if (!keyword) { openSuggestions() errors.patient = '请输入患者 ID / 姓名 / 就诊号' return } locating.value = true try { let profile = await getPatientProfile(keyword) if (!profile) { const matches = patientSuggestions.value // 原型口径:模糊命中唯一一个就直接定位,命中多个让人从候选列表里挑 if (matches.length === 1) { profile = matches[0] } else if (matches.length > 1) { openSuggestions() errors.patient = `匹配到 ${matches.length} 位患者,请从候选列表中选择` return } } if (!profile) { closeSuggestions() errors.patient = '未找到该患者,请核对输入内容' return } closeSuggestions() patientId.value = profile.id patient.value = profile visits.value = profile.visits selectedVisitId.value = profile.visits.find((visit) => visit.isCurrent)?.id ?? profile.visits[0]?.id ?? '' // 已经选了短信方式才定位患者时,把投递号码一并带出(原型 locatePatient 同款) if (method.value === 'sms' && !smsDestination.value.trim()) { smsDestination.value = profile.phoneFull ?? '' } } catch { errors.patient = '患者信息查询失败,请稍后重试' } finally { locating.value = false } } function selectVisit(visit: PatientVisit) { selectedVisitId.value = visit.id errors.visit = '' } /** 原型 togglePh:明文查看会留审计痕迹,因此每次切换都提示一次 */ function togglePhonePlain() { if (!patient.value?.phoneFull) { return } phonePlain.value = !phonePlain.value ElMessage.info(phonePlain.value ? '明文查看已记录审计日志' : '已恢复脱敏显示') } /** * 文书必须带签名位才能发起签署:否则签名会随证据固化,但文书上没有落位的地方, * 签出来的文书看不出患者签过。 * * 真实模式下后端按 templateVersionId 取版本快照、前端拿不到正文, * 此时不做判断(返回 true),交由后端校验。 */ function hasSignatureSlot(template: SigningTemplate) { if (!template.contentHtml) { return true } return collectFieldDefs(template.contentHtml).some((field) => field.id === 'signature') } const signatureMissingMessage = '该文书没有签名占位符,请先在文档库中补上签名位再发起' /** 原型 toggleNtDoc:单选,再点一次取消选中 */ function selectTemplate(template: SigningTemplate) { const nextId = selectedTemplateId.value === template.id ? '' : template.id selectedTemplateId.value = nextId errors.document = nextId && !hasSignatureSlot(template) ? signatureMissingMessage : '' if (nextId && !template.supportedMethods.includes(method.value)) { method.value = template.supportedMethods[0] ?? 'pad' } } /** * 文书模板声明的可签署方式,按**具体取值**精确判断(四个平铺值,没有通道归并)。 * 演示模板一律声明全部四种;真实模式由文档库配置决定。 */ function isMethodAllowed(nextMethod: SigningMethod) { const template = selectedTemplate.value if (!template) { return true } return template.supportedMethods.includes(nextMethod) } function selectMethod(nextMethod: SigningMethod) { if (!isMethodAllowed(nextMethod)) { return } method.value = nextMethod // 短信方式要确认投递号码:有完整号码就预填,没有则留空由操作人填写 if (nextMethod === 'sms' && !smsDestination.value.trim()) { smsDestination.value = patient.value?.phoneFull ?? '' } } function maskPhone(value: string) { if (value.length < 7) { return value } return `${value.slice(0, 3)}****${value.slice(-4)}` } function validate() { clearErrors() let valid = true if (!patient.value) { errors.patient = '请先定位患者' valid = false } if (!selectedVisit.value) { errors.visit = '请选择关联就诊' valid = false } if (!selectedTemplate.value) { errors.document = '请至少选择一份文档' valid = false } else if (!hasSignatureSlot(selectedTemplate.value)) { errors.document = signatureMissingMessage valid = false } if (method.value === 'sms') { const destination = smsDestination.value.trim().replace(/\s+/g, '') if (!/^\+?[1-9]\d{6,14}$/.test(destination)) { errors.sms = '请输入完整的手机号或 E.164 地址,才能发送短信' valid = false } } return valid } async function submit(): Promise { if (!validate() || !patient.value || !selectedVisit.value || !selectedTemplate.value) { return null } submitting.value = true try { const task = await createSigningTask({ campus: toValue(options.campus), patientId: patient.value.id, visitId: selectedVisit.value.id, documentId: selectedTemplate.value.id, documentName: selectedTemplate.value.name, templateVersionId: selectedTemplate.value.versionId, method: method.value, visitType: selectedVisit.value.type, visitNo: selectedVisit.value.visitNo, department: selectedVisit.value.department, documentContentHtml: selectedTemplate.value.contentHtml, }) if (!isSigningMockEnabled && method.value === 'sms') { await sendSigningSms(task.id, { destination: smsDestination.value.trim().replace(/\s+/g, ''), templateCode: 'SIGN_LINK', }) } return task } finally { submitting.value = false } } return { closeSuggestions, departmentRows, documentList, documentSearch, errors, isMethodAllowed, loadingTemplates, loadTemplates, locatePatient, locating, maskPhone, method, openSuggestions, patient, patientId, patientSuggestions, phonePlain, pickPatient, prescription, resetForNextTask, resetForm, selectMethod, selectTemplate, selectVisit, selectedTemplate, selectedTemplateId, selectedVisit, selectedVisitId, smsDestination, submit, submitting, suggestionOpen, templates, toggleCategory, toggleDepartment, togglePhonePlain, visits, } }