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

694 lines
21 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 { 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<WorkbenchCampus>
campusId: MaybeRefOrGetter<string>
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 的 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<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('')
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<LinkedPrescription | null>(() =>
isSigningMockEnabled ? buildLinkedPrescription(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 (
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<SigningTaskRecord | null> {
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,
}
}