Files
xh-medical-sign-web/clinical-web/src/composables/useSigningTaskForm.ts
T

655 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<WorkbenchCampus>
initialTemplate?: MaybeRefOrGetter<SigningTemplate | null | undefined>
}
/** 左侧科室行(原型 #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<PatientProfile | null>(null)
const visits = ref<PatientVisit[]>([])
const templates = ref<SigningTemplate[]>([])
const documentSearch = ref('')
const selectedTemplateId = ref('')
const selectedVisitId = ref('')
const method = ref<SigningMethod>('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<string[]>([])
/** 左列选中的科室与分类(原型 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<LinkedPrescription | null>(() =>
isSigningMockEnabled ? buildPrescription(selectedVisit.value) : null,
)
/**
* 候选患者:原型 patMatches 的匹配口径——患者ID / 姓名 / 证件号 / 就诊号,
* 任一命中即算匹配;关键字为空时列出全部候选。
*/
const patientSuggestions = computed<PatientProfile[]>(() => {
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<string, Map<string, SigningTemplate[]>>()
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<string, SigningTemplate[]>()
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<DocumentTreeDepartmentRow[]>(() =>
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<SigningTaskRecord | null> {
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,
}
}