import { computed, reactive, ref, toValue, type MaybeRefOrGetter } from 'vue' import { ElMessage } from 'element-plus' import { sendSigningSms } from '@/api/workbench/deliveries' import { buildLinkedPrescription, type LinkedPrescription } from '@/api/workbench/prescriptions' 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 campusId: 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 的 rxOfVisit)。 * * 演示数据与生成口径已挪到 api/workbench/prescriptions,那里同时供 * 签署工作台详情的「关联处方」明细表使用——两处口径不同但同一份药品池, * 各写一份必然漂移。这里只做转出,保持既有导入路径不变。 */ export type { LinkedPrescription } export function useSigningTaskForm(options: UseSigningTaskFormOptions) { const newIdempotencyKey = () => `clinical-web-${ typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}` }` let createTaskIdempotencyKey = newIdempotencyKey() let sendSmsIdempotencyKey = newIdempotencyKey() 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('') let loadedTemplateCampusId = '' 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 ? buildLinkedPrescription(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 ( isSigningMockEnabled && 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() createTaskIdempotencyKey = newIdempotencyKey() sendSmsIdempotencyKey = newIdempotencyKey() } function resetForNextTask() { const initialTemplate = getInitialTemplate() documentSearch.value = '' selectedTemplateId.value = initialTemplate?.id ?? '' method.value = initialTemplate?.supportedMethods[0] ?? method.value smsDestination.value = '' errors.document = '' errors.sms = '' createTaskIdempotencyKey = newIdempotencyKey() sendSmsIdempotencyKey = newIdempotencyKey() initDocumentTree() } async function loadTemplates() { const campusId = toValue(options.campusId) if (!campusId) { templates.value = [] loadedTemplateCampusId = '' ElMessage.error('请先选择院区,再加载文书模板') return } if (loadedTemplateCampusId && loadedTemplateCampusId !== campusId) { selectedTemplateId.value = '' selectedDepartment.value = '' selectedCategory.value = '' } loadingTemplates.value = true try { // Publishing replaces the available version even within the same campus. const available = await getSigningTemplates({ campusId }) if (toValue(options.campusId) !== campusId) return templates.value = available loadedTemplateCampusId = campusId } catch { templates.value = [] 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.kind === '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) { if (!isSigningMockEnabled && (nextMethod === 'wx' || nextMethod === 'ipad')) { return false } 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 { if (!isSigningMockEnabled) { const selected = selectedTemplate.value const campusId = toValue(options.campusId) if (!campusId) throw new Error('请先选择院区') const available = await getSigningTemplates({ campusId }) if ( toValue(options.campusId) !== campusId || selectedTemplate.value?.versionId !== selected.versionId ) throw new Error('院区或文书选择已变化,请核对后重新提交') const current = available.find((template) => template.id === selected.id) if (!current || current.versionId !== selected.versionId) { templates.value = available createTaskIdempotencyKey = newIdempotencyKey() errors.document = current ? `文书已更新为 ${current.version},请核对新版正文后再发起签署。` : '该文书已停用或暂无可用版本,请重新选择文书。' ElMessage.warning(errors.document) return null } } 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, // 需科室确认由文书模板标记带出(原型 V6.9 起弹窗里不再让人选) needConfirm: selectedTemplate.value.needConfirm ?? false, // 签署人固定为患者本人:发起弹窗不再采集签署人 / 代签信息, // 后端仍按这三个字段接收(接口中均为可选)。 signerType: 'SELF', signerName: patient.value.name, signerRelation: '本人', idempotencyKey: createTaskIdempotencyKey, }) if (!isSigningMockEnabled && method.value === 'sms') { try { await sendSigningSms( task.id, { destination: smsDestination.value.trim().replace(/\s+/g, ''), templateCode: 'SIGN_LINK', }, sendSmsIdempotencyKey, ) } catch { throw new Error( `签署任务 ${task.id} 已创建,但短信投递失败。请在工作台任务详情中重试发送。`, ) } sendSmsIdempotencyKey = newIdempotencyKey() } 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, } }