feat(签署方式): 扩展为手写板/短信/Pad/公众号四种并集中口径

This commit is contained in:
yelan
2026-09-25 12:09:58 +08:00
parent bd89a4d31a
commit 8ec55e48c9
26 changed files with 1864 additions and 688 deletions
@@ -5,6 +5,7 @@ import { sendSigningSms } from '@/api/workbench/deliveries'
import { collectFieldDefs } from '@/utils/signing-fields'
import {
createSigningTask,
getMockPatients,
getPatientProfile,
getSigningTemplates,
isSigningMockEnabled,
@@ -23,14 +24,65 @@ export interface UseSigningTaskFormOptions {
initialTemplate?: MaybeRefOrGetter<SigningTemplate | null | undefined>
}
interface TemplateCategoryGroup {
/** 左侧科室行(原型 #ntTree 左列) */
export interface DocumentTreeDepartmentRow {
name: string
templates: SigningTemplate[]
/** 该科室命中文书总数(含分类内) */
count: number
open: boolean
active: boolean
categories: Array<{ name: string; count: number; active: boolean }>
}
interface TemplateDepartmentGroup {
name: string
categories: TemplateCategoryGroup[]
/** 右侧文档行;搜索态下带出「科室 · 分类」小标题(与原型一致) */
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) {
@@ -46,6 +98,15 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
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: '',
@@ -54,6 +115,9 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
sms: '',
})
/** 演示候选患者(真实模式为空数组,候选下拉自然不出现) */
const patientCandidates = getMockPatients()
const selectedVisit = computed(
() => visits.value.find((visit) => visit.id === selectedVisitId.value) ?? null,
)
@@ -62,7 +126,35 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
() => templates.value.find((template) => template.id === selectedTemplateId.value) ?? null,
)
const filteredTemplateGroups = computed<TemplateDepartmentGroup[]>(() => {
/** 关联处方:随选中就诊带出(仅演示数据) */
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[]>>()
@@ -85,13 +177,86 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
grouped.set(template.department, categories)
})
return [...grouped.entries()].map(([department, categories]) => ({
name: department,
categories: [...categories.entries()].map(([category, categoryTemplates]) => ({
name: category,
templates: categoryTemplates,
})),
}))
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() {
@@ -109,6 +274,61 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
}
}
/**
* 首次进入默认选中第一个有文书的科室并展开(原型 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 = ''
@@ -127,6 +347,11 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
selectedVisitId.value = ''
method.value = initialTemplate?.supportedMethods[0] ?? 'pad'
smsDestination.value = ''
phonePlain.value = false
suggestionOpen.value = false
selectedDepartment.value = ''
selectedCategory.value = ''
expandedDepartments.value = []
clearErrors()
}
@@ -139,6 +364,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
smsDestination.value = ''
errors.document = ''
errors.sms = ''
initDocumentTree()
}
async function loadTemplates() {
@@ -155,6 +381,29 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
}
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() {
@@ -165,26 +414,49 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
visits.value = []
selectedVisitId.value = ''
selectedTemplateId.value = getInitialTemplate()?.id ?? ''
phonePlain.value = false
if (!keyword) {
errors.patient = '请输入患者 ID、门诊号或住院号'
openSuggestions()
errors.patient = '请输入患者 ID / 姓名 / 就诊号'
return
}
locating.value = true
try {
const profile = await getPatientProfile(keyword)
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 {
@@ -197,6 +469,16 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
errors.visit = ''
}
/** 原型 togglePh:明文查看会留审计痕迹,因此每次切换都提示一次 */
function togglePhonePlain() {
if (!patient.value?.phoneFull) {
return
}
phonePlain.value = !phonePlain.value
ElMessage.info(phonePlain.value ? '明文查看已记录审计日志' : '已恢复脱敏显示')
}
/**
* 文书必须带签名位才能发起签署:否则签名会随证据固化,但文书上没有落位的地方,
* 签出来的文书看不出患者签过。
@@ -214,21 +496,43 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
const signatureMissingMessage = '该文书没有签名占位符,请先在文档库中补上签名位再发起'
/** 原型 toggleNtDoc:单选,再点一次取消选中 */
function selectTemplate(template: SigningTemplate) {
selectedTemplateId.value = template.id
errors.document = hasSignatureSlot(template) ? '' : signatureMissingMessage
const nextId = selectedTemplateId.value === template.id ? '' : template.id
if (!template.supportedMethods.includes(method.value)) {
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 (selectedTemplate.value && !selectedTemplate.value.supportedMethods.includes(nextMethod)) {
if (!isMethodAllowed(nextMethod)) {
return
}
method.value = nextMethod
// 短信方式要确认投递号码:有完整号码就预填,没有则留空由操作人填写
if (nextMethod === 'sms' && !smsDestination.value.trim()) {
smsDestination.value = patient.value?.phoneFull ?? ''
}
}
function maskPhone(value: string) {
@@ -261,7 +565,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
valid = false
}
if (!isSigningMockEnabled && method.value === 'sms') {
if (method.value === 'sms') {
const destination = smsDestination.value.trim().replace(/\s+/g, '')
if (!/^\+?[1-9]\d{6,14}$/.test(destination)) {
@@ -309,17 +613,25 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
}
return {
closeSuggestions,
departmentRows,
documentList,
documentSearch,
errors,
filteredTemplateGroups,
isMethodAllowed,
loadingTemplates,
loadTemplates,
locatePatient,
locating,
maskPhone,
method,
openSuggestions,
patient,
patientId,
patientSuggestions,
phonePlain,
pickPatient,
prescription,
resetForNextTask,
resetForm,
selectMethod,
@@ -332,7 +644,11 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
smsDestination,
submit,
submitting,
suggestionOpen,
templates,
toggleCategory,
toggleDepartment,
togglePhonePlain,
visits,
}
}