655 lines
19 KiB
TypeScript
655 lines
19 KiB
TypeScript
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,
|
||
}
|
||
}
|