Files
xh-medical-sign-web/clinical-web/src/views/workbench/signing/index.vue
T

1551 lines
46 KiB
Vue
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.
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute } from 'vue-router'
import { getCampuses, getDepartments } from '@/api/management/organization'
import {
downloadSigningArtifact,
fetchSignatureDataUrl,
getSigningArtifacts,
} from '@/api/workbench/artifacts'
import {
consumeSigningToken,
createPadSigningSession,
getSigningContext,
uploadSigningSignature,
} from '@/api/workbench/deliveries'
import {
saveSigningForm,
completeSigningTask,
confirmDepartmentSigning,
getSigningTaskDetail,
getSigningTaskEvents,
getSigningTasks,
getSigningTemplates,
isSigningMockEnabled,
pushSigningWxMessage,
reopenSigningTask,
resendSigningSms,
resendSigningWxMessage,
sendSigningSms,
simulatePatientSigning,
updateSigningTaskMethod,
voidSigningTask,
} from '@/api/workbench/signing'
import type {
SigningAnswers,
SigningDateRange,
SigningMethod,
SigningArtifact,
PatientSignerAssertionInput,
SignDeliveryResponseDto,
SigningContextSignatureFieldDto,
SigningTaskEvent,
SigningTaskRecord,
SigningTaskStatus,
SigningTemplate,
WorkbenchCampus,
} from '@/api/workbench/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
import { PRIMARY_SIGNATURE_FIELD_ID } from '@/utils/signing-fields'
import {
formatSigningMethodTitle,
readSigningMethod,
toggleSigningMethod,
} from '@/utils/signing-method'
import { downloadSigningPdf } from '@/utils/signing-pdf'
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
import SignaturePadDialog from './components/SignaturePadDialog.vue'
import SignedDocumentDialog from './components/SignedDocumentDialog.vue'
import SigningFilterBar from './components/SigningFilterBar.vue'
import SigningTaskDetail from './components/SigningTaskDetail.vue'
import SigningTaskList from './components/SigningTaskList.vue'
import SmsSendDialog from './components/SmsSendDialog.vue'
import VoidTaskDialog from './components/VoidTaskDialog.vue'
import WxPushDialog from './components/WxPushDialog.vue'
import type { SigningFilterForm, SigningFilterOption, SigningTaskAction } from './types'
const route = useRoute()
const appStore = useAppStore()
const loginStore = useLoginStore()
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
confirming: '待科室确认',
signed: '已签署',
expired: '已超时',
void: '已作废',
failed: '处理失败',
}
const filter = reactive<SigningFilterForm>({
keyword: '',
status: 'all',
dateRange: 'all',
})
const tasks = ref<SigningTaskRecord[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const selectedTaskId = ref<string | null>(null)
const selectedTask = ref<SigningTaskRecord | null>(null)
/** 当前任务里患者已勾选的条目,签署完成时随签名一起固化到任务上 */
const signingSelection = ref<string[]>([])
/** 当前任务文书的填写项取值(含签名图),签署完成时随签名一起固化 */
const signingAnswers = ref<SigningAnswers>({})
/** 文书填写项完成情况:签署前用它做必填校验、提示异常项 */
const signingProgress = ref<SigningFieldProgress | null>(null)
/**
* 在文书里点击签名位采集到的签名图(key 是字段键,如 doctor)。
* 与 signingAnswers 分开:signingAnswers 每次输入都会变,把它当渲染输入会让
* 整篇文书重渲染、光标丢失;这份只在真的采到签名时变化。
*/
const fieldSignatureInjections = ref<SigningAnswers>({})
/** 当前正在采集的签名位,null 表示弹窗不是由「点击文中签名位」打开的 */
const pendingSignatureField = ref<SigningSignatureRequest | null>(null)
const fieldSignatureDialogVisible = ref(false)
const taskEvents = ref<SigningTaskEvent[]>([])
const artifacts = ref<SigningArtifact[]>([])
const templates = ref<SigningTemplate[]>([])
const loading = ref(true)
const detailLoading = ref(false)
const eventsLoading = ref(false)
const eventsError = ref(false)
const artifactsLoading = ref(false)
const artifactsError = ref(false)
const padSessionStatus = ref<string | null>(null)
const padSessionLoading = ref(false)
const mousePadSession = ref<{ deliveryId: string; token: string } | null>(null)
const campusIds = ref<Record<string, string>>({})
const campusNames = ref<Record<string, WorkbenchCampus>>({})
const departmentIds = ref<Record<string, string>>({})
const newDialogVisible = ref(false)
const signatureDialogVisible = ref(false)
const signatureSubmitting = ref(false)
const onlineDialogVisible = ref(false)
/** 「发送签署短信」弹窗(原型 #mdSmsFlow) */
const smsSendVisible = ref(false)
/** 「公众号消息推送」弹窗(原型 #mdWxPush) */
const wxPushVisible = ref(false)
const voidDialogVisible = ref(false)
/** 已签署文书的在线预览弹窗(原型 openPdfView) */
const signedPreviewVisible = ref(false)
const taskDetailRef = ref<InstanceType<typeof SigningTaskDetail> | null>(null)
const pdfExporting = ref(false)
let listRequestId = 0
let detailRequestId = 0
function canWrite(permission: string) {
return (
!loginStore.isReadOnly && (isSigningMockEnabled || loginStore.hasWritePermission(permission))
)
}
function canRead(permission: string) {
return isSigningMockEnabled || loginStore.hasPermission(permission)
}
const canCreateTask = computed(() => canWrite('sign:task:create'))
const canSignTask = computed(() => canWrite('sign:task:sign'))
const canReopenTask = computed(() => canWrite('sign:task:reopen'))
const canVoidTask = computed(() => canWrite('sign:task:void'))
/** 科室复核签章:把「待科室确认」推到「已签署」,是正式留痕动作,与作废同级 */
const canConfirmTask = computed(() => canWrite('sign:task:confirm'))
/** 催办:提醒科室签署员尽快复核 */
const canRemindTask = computed(() => canWrite('sign:task:remind'))
const canViewEvents = computed(() => canRead('sign:task:event:query'))
// Keep these permission codes aligned with SignArtifactController: artifact:view
// lists the index, while task:download authorizes the PNG/PDF download.
const canViewArtifacts = computed(() => canRead('sign:artifact:view'))
const canDownloadArtifacts = computed(() => canRead('sign:task:download'))
/** 需要执行签署权限的任务动作,对应后端 `sign:task:sign`。 */
const SIGNING_ACTIONS: SigningTaskAction[] = [
'pad-sign',
'create-pad-session',
'online-sign',
'switch-method',
'switch-to-ipad',
'simulate-patient-sign',
]
/** 短信/公众号发送与重发对应后端 `sign:task:remind`。 */
const DELIVERY_ACTIONS: SigningTaskAction[] = [
'send-sms',
'send-wx-push',
'resend-sms',
'resend-wx-push',
]
/**
* 状态筛选的取值:原型 `#fStat` 只有 6 项
* (全部 / 待签署 / 待科室确认 / 已签署 / 已超时 / 已作废)。
*
* 仓库另有 `signing`(签署中)与 `failed`(处理失败)两个状态 —— 它们仍会正常渲染徽标,
* 只是原型没有对应筛选项,所以不进下拉;路由 query 里出现这两个值也按「全部」处理,
* 否则下拉会选中一个不存在的 option、显示成空白。
*/
const STATUS_FILTER_VALUES: SigningTaskStatus[] = [
'pending',
'confirming',
'signed',
'expired',
'void',
]
const statusOptions: SigningFilterOption<SigningFilterForm['status']>[] = [
{ label: '全部', value: 'all' },
...STATUS_FILTER_VALUES.map((status) => ({ label: statusLabels[status], value: status })),
]
const signingApiOptions = computed(() => ({
campus: appStore.selectedCampus,
campusId: campusIds.value[appStore.selectedCampus],
campusIds: campusIds.value,
campusNames: campusNames.value,
departmentIds: departmentIds.value,
templates: templates.value,
}))
function getQueryString(value: unknown) {
return typeof value === 'string' ? value : ''
}
function isSigningStatus(value: string): value is SigningTaskStatus {
return STATUS_FILTER_VALUES.includes(value as SigningTaskStatus)
}
function isSigningDateRange(value: string): value is SigningDateRange {
return DATE_RANGE_VALUES.includes(value as SigningDateRange)
}
/**
* 路由 query → 搜索条件。
*
* 只认 `status` 与 `range`:原型工作台顶部就这两个条件,
* `campus` 由右上角的全局院区选择器统一约束(见 `signingApiOptions`),不再从 query 取。
*/
function syncFilterFromRoute() {
const status = getQueryString(route.query.status)
const range = getQueryString(route.query.range)
filter.status = status === 'all' || isSigningStatus(status) ? status : 'all'
filter.dateRange = isSigningDateRange(range) ? range : 'all'
}
async function loadTemplates() {
const campusId = appStore.selectedCampusId
if (!campusId) {
templates.value = []
return
}
try {
templates.value = await getSigningTemplates({ campusId })
} catch {
ElMessage.error('文档模板加载失败,请稍后重试')
}
}
async function loadOrganizationOptions() {
if (isSigningMockEnabled) {
return
}
const [campusesResult, departmentsResult] = await Promise.allSettled([
getCampuses(),
getDepartments(),
])
if (campusesResult.status === 'fulfilled') {
campusIds.value = Object.fromEntries(
campusesResult.value.map((campus) => [campus.name, campus.id]),
)
campusNames.value = Object.fromEntries(
campusesResult.value.map((campus) => [campus.id, campus.name]),
)
}
if (departmentsResult.status === 'fulfilled') {
departmentIds.value = Object.fromEntries(
departmentsResult.value.map((department) => [department.name, department.id]),
)
}
}
async function loadTaskDetail(id: string | null) {
const requestId = ++detailRequestId
detailLoading.value = true
await formSaveQueue
if (requestId !== detailRequestId) return
formDraft = null
formSaveError = null
formSaveMessage.value = ''
// 换任务时先清空勾选与填写项,避免把上一个任务的取值带到新任务上;
// 文书组件挂载后会用任务自身固化的取值重新上报
signingSelection.value = []
signingAnswers.value = {}
signingProgress.value = null
fieldSignatureInjections.value = {}
pendingSignatureField.value = null
fieldSignatureDialogVisible.value = false
signatureDialogVisible.value = false
mousePadSession.value = null
// 预览弹窗跟着任务走:换任务时留在上一份文书的预览上会让人以为看的是当前任务
signedPreviewVisible.value = false
if (!id) {
selectedTask.value = null
taskEvents.value = []
artifacts.value = []
detailLoading.value = false
eventsLoading.value = false
eventsError.value = false
artifactsLoading.value = false
artifactsError.value = false
padSessionStatus.value = null
mousePadSession.value = null
return
}
detailLoading.value = true
eventsLoading.value = canViewEvents.value
eventsError.value = false
artifactsLoading.value = canViewArtifacts.value
artifactsError.value = false
taskEvents.value = []
artifacts.value = []
padSessionStatus.value = null
mousePadSession.value = null
try {
const [detailResult, eventsResult, artifactsResult] = await Promise.allSettled([
getSigningTaskDetail(id, signingApiOptions.value),
canViewEvents.value ? getSigningTaskEvents(id) : Promise.resolve([]),
canViewArtifacts.value ? getSigningArtifacts(id) : Promise.resolve([]),
])
if (requestId === detailRequestId) {
if (detailResult.status === 'rejected') {
throw detailResult.reason
}
const detail = detailResult.value
selectedTask.value = detail
signingAnswers.value = { ...(detail?.fieldAnswers ?? {}) }
if (eventsResult.status === 'fulfilled') {
taskEvents.value = eventsResult.value
} else {
eventsError.value = true
}
if (artifactsResult.status === 'fulfilled') {
artifacts.value = artifactsResult.value
} else {
artifactsError.value = true
}
// 任务接口不返回签名,签名图是独立的 SIGNATURE_IMAGE 产物;
// 这里把它取回来转成 dataURL 补到任务上,文书里的签名位才有内容。
// 取不到就保持原样(渲染成「待患者签名」),不伪造一个签名糊上去。
if (detail && !detail.signatureDataUrl) {
const signatureArtifact = artifacts.value.find(
(item) => item.artifactType === 'SIGNATURE_IMAGE',
)
if (signatureArtifact) {
const signatureDataUrl = await fetchSignatureDataUrl(signatureArtifact)
if (requestId === detailRequestId && signatureDataUrl) {
selectedTask.value = { ...detail, signatureDataUrl }
}
}
}
}
} catch {
if (requestId === detailRequestId) {
selectedTask.value = null
taskEvents.value = []
ElMessage.error('签署任务详情加载失败,请稍后重试')
}
} finally {
if (requestId === detailRequestId) {
detailLoading.value = false
eventsLoading.value = false
artifactsLoading.value = false
}
}
}
async function loadTasks(preferredTaskId?: string, resetPage = false) {
if (resetPage) {
page.value = 1
}
const requestId = ++listRequestId
loading.value = true
try {
const response = await getSigningTasks(
{
...filter,
page: page.value,
pageSize: pageSize.value,
},
signingApiOptions.value,
)
if (requestId !== listRequestId) {
return
}
tasks.value = response.records
total.value = response.total
page.value = response.page
pageSize.value = response.pageSize
const taskIds = new Set(response.records.map((task) => task.id))
const routeTaskId = getQueryString(route.query.taskId)
const matchedTaskId = [preferredTaskId, selectedTaskId.value, routeTaskId].find(
(id) => id && taskIds.has(id),
)
const nextTaskId = matchedTaskId ?? (routeTaskId || response.records[0]?.id || null)
selectedTaskId.value = nextTaskId
await loadTaskDetail(nextTaskId)
} catch {
if (requestId === listRequestId) {
tasks.value = []
total.value = 0
selectedTaskId.value = null
selectedTask.value = null
ElMessage.error('签署任务加载失败,请稍后重试')
}
} finally {
if (requestId === listRequestId) {
loading.value = false
}
}
}
function handleSearch(nextFilter: SigningFilterForm) {
Object.assign(filter, nextFilter)
selectedTaskId.value = null
void loadTasks(undefined, true)
}
function selectTask(id: string) {
selectedTaskId.value = id
void loadTaskDetail(id)
}
function handleSelectionChange(items: string[]) {
signingSelection.value = items
}
let formSaveQueue: Promise<void> = Promise.resolve()
const formSaving = ref(false)
const formSaveMessage = ref('')
let formSaveError: unknown = null
let formDraft: {
taskId: string
revision: number
fingerprint: string
fields: Set<string>
} | null = null
function persistFormAnswers(answers: SigningAnswers) {
const task = selectedTask.value
if (
detailLoading.value ||
signatureSubmitting.value ||
isSigningMockEnabled ||
!task?.formState?.editable ||
task.method !== 'pad' ||
!canSignTask.value
)
return
if (!formDraft || formDraft.taskId !== task.id) {
formDraft = {
taskId: task.id,
revision: task.formState.revision,
fingerprint: JSON.stringify(task.formState.answers),
fields: new Set(task.formState.fields.filter((f) => f.editable).map((f) => f.id)),
}
formSaveError = null
}
const draft = formDraft
const filtered = Object.fromEntries(
Object.entries(answers).filter(([key]) => draft.fields.has(key)),
) as SigningAnswers
const fingerprint = JSON.stringify(filtered)
if (fingerprint === draft.fingerprint) return
draft.fingerprint = fingerprint
formSaving.value = true
formSaveMessage.value = '正在保存填写内容…'
formSaveQueue = formSaveQueue.then(async () => {
if (formSaveError) return
try {
const saved = await saveSigningForm(draft.taskId, draft.revision, filtered)
draft.revision = saved.revision
formSaveMessage.value = '填写内容已保存'
} catch (error) {
formSaveError = error
formSaveMessage.value = '保存失败,请刷新核对后重试'
ElMessage.error('填写内容保存失败,请刷新核对后重试;本次签署已阻止')
}
})
const current = formSaveQueue
void current.finally(() => {
if (current === formSaveQueue) formSaving.value = false
})
}
function handleFieldChange(answers: SigningAnswers, progress: SigningFieldProgress) {
signingAnswers.value = answers
persistFormAnswers(answers)
signingProgress.value = progress
}
/**
* 采集主签名前的检查。
*
* ① 必填项没填完 → 直接挡住:签一份内容不完整的文书没有意义;
* ② 文书里还有没采集的签名位 → 让操作人员确认后再继续。
* 这一条不能硬挡:医师确实可能签在纸质件上,替业务做决定是越界;
* 但也不能不提醒,否则文书会带着空签名位被固化。
*/
async function assertSigningReady() {
await formSaveQueue
if (formSaveError) {
ElMessage.error('填写内容未保存,请刷新后重试')
return false
}
const missing = signingProgress.value?.missingRequired ?? []
if (missing.length) {
ElMessage.warning(`请先完成必填项:${missing.join('、')}`)
return false
}
const pending = signingProgress.value?.pendingSignatures ?? []
if (!pending.length) {
return true
}
try {
await ElMessageBox.confirm(
`文书中还有签名位未采集:${pending.join('、')}。可直接点击文中签名位完成,也可以继续采集主签名。`,
'还有签名位待采集',
{ confirmButtonText: '继续签署', cancelButtonText: '返回采集', type: 'warning' },
)
return true
} catch {
return false
}
}
/**
* 点击文中签名位。
*
* 分两条路 —— 原型 renderPreview 给每个签名位挂的 onclick 是
* `if(curTask.method==='sms')openPhone();else openPad();`,且只挂在「待签署」任务上。
*
* ① **主签名位**(患者签名,`PRIMARY_SIGNATURE_FIELD_ID`)正是任务级签署要采的那一笔,
* 与「手写板签署 / Pad 签署」按钮走的是同一件事,所以必须开**任务级**弹窗。
* 这条不能少:Pad 方式的按钮只负责「把文书滚到签名位」(原型 openIpad 不弹窗),
* 真正落笔就是靠点这个签名位 —— 若这里只回灌一张图而不结束任务,
* Pad 任务就会永远停在「待签署」。
* 短信任务照原型走患者端 H5 页(患者在自己手机上签,不是在这台电脑上画)。
* ② 其余签名位(医师签名……)是本仓库在原型之外的扩展:它们只是文书上的填写项,
* 采完只回填该位置、不结束任务,所以走签名位弹窗。
*/
async function handleSignatureRequest(request: SigningSignatureRequest) {
const task = selectedTask.value
if (!task) {
return
}
if (request.fieldId !== PRIMARY_SIGNATURE_FIELD_ID || task.status !== 'pending') {
pendingSignatureField.value = request
fieldSignatureDialogVisible.value = true
return
}
if (task.method === 'sms') {
await handleTaskAction('online-sign')
return
}
if (!(await assertSigningReady())) {
return
}
await openTaskPadSignature(task)
}
async function openTaskPadSignature(task: SigningTaskRecord) {
mousePadSession.value = null
if (!isSigningMockEnabled) {
padSessionLoading.value = true
try {
const session = await createPadSigningSession(task.id, { deviceId: 'browser-mouse-test' })
if (!session.token) {
throw new Error('后端未返回手写板会话令牌')
}
mousePadSession.value = { deliveryId: session.deliveryId, token: session.token }
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '鼠标测试会话创建失败,请稍后重试')
return
} finally {
padSessionLoading.value = false
}
}
signatureDialogVisible.value = true
}
function closeTaskPadSignature() {
signatureDialogVisible.value = false
mousePadSession.value = null
}
function closeFieldSignatureDialog() {
fieldSignatureDialogVisible.value = false
pendingSignatureField.value = null
}
/**
* 单个签名位采集完成。
*
* 这里不调 completeSigningTask:医师签名只是文书上的一个填写项,
* 任务级的「签署完成」仍要由主签名位(患者签名)触发,
* 否则点一下医师签名就把整个任务标记成已签署了。
*/
function confirmFieldSignature(signatureDataUrl: string) {
const request = pendingSignatureField.value
if (!request) {
return
}
fieldSignatureInjections.value = {
...fieldSignatureInjections.value,
[request.fieldId]: signatureDataUrl,
}
closeFieldSignatureDialog()
ElMessage.success(`「${request.label}」签名已采集,将随本次签署一并固化`)
}
function handlePageChange(nextPage: number) {
const pageCount = Math.max(Math.ceil(total.value / pageSize.value), 1)
if (nextPage < 1 || nextPage > pageCount || nextPage === page.value) {
return
}
page.value = nextPage
selectedTaskId.value = null
void loadTasks()
}
async function refreshTask(updatedTask: SigningTaskRecord | null, successMessage: string) {
if (!updatedTask) {
ElMessage.error('签署任务不存在或已被删除')
return
}
await loadTasks(updatedTask.id, true)
ElMessage.success(successMessage)
}
async function handleTaskAction(action: SigningTaskAction, targetMethod?: SigningMethod) {
const task = selectedTask.value
if (!task) {
return
}
const allowed =
isSigningMockEnabled ||
(SIGNING_ACTIONS.includes(action) && canSignTask.value) ||
(DELIVERY_ACTIONS.includes(action) && canRemindTask.value) ||
(action === 'reopen' && canReopenTask.value) ||
(action === 'void' && canVoidTask.value) ||
(action === 'confirm-dept' && canConfirmTask.value) ||
(action === 'remind' && canRemindTask.value) ||
(action === 'preview-signed' && canViewArtifacts.value) ||
((action === 'download-pdf' ||
action === 'download-signature' ||
action === 'download-readonly-copy' ||
action === 'print') &&
canDownloadArtifacts.value)
if (!allowed) {
ElMessage.warning('当前账号没有执行此操作的权限')
return
}
if (action === 'pad-sign') {
/**
* 原型把两种现场方式分得很清楚:
* - 手写板 → `openPad()`,直接开签名板;
* - Pad → `openIpad()`,**不弹窗**,只把文书滚到第一个签名位并高亮,
* 提示「点击签名框弹出签名板」,由操作人自己点那个框。
* 差别就在这里:一个直接签,一个是「在文书上找到该签的地方」再签。
*/
if (task.method === 'ipad') {
if (taskDetailRef.value?.locateSignatureSlot()) {
ElMessage.info('已下拉到签名位置,点击签名框弹出签名板完成签署')
} else {
ElMessage.error('当前文书未找到签名位置')
}
return
}
if (!(await assertSigningReady())) {
return
}
await openTaskPadSignature(task)
return
}
// 短信 / 公众号:先开「发送」弹窗(原型 openSmsFlow / openWxPush)。
// 发送成功才算把任务切成那个方式 —— 见 handleSmsSent / handleWxPushed
if (action === 'send-sms') {
if (!isSigningMockEnabled) {
ElMessage.info('发送签署短信接口待接入')
return
}
smsSendVisible.value = true
return
}
if (action === 'send-wx-push') {
if (!isSigningMockEnabled) {
ElMessage.info('公众号签署推送接口待接入')
return
}
wxPushVisible.value = true
return
}
// 演示入口:没人真的去点短信链接 / 打开公众号消息,不给它整条链路走不完
if (action === 'simulate-patient-sign') {
if (!isSigningMockEnabled) {
ElMessage.info('演示入口,真实模式请由患者在签署页完成')
return
}
try {
const updated = await simulatePatientSigning(task.id)
await refreshTask(updated, signingCompletedMessage(updated))
} catch {
ElMessage.error('模拟患者签署失败,请稍后重试')
}
return
}
if (action === 'resend-wx-push') {
if (!isSigningMockEnabled) {
ElMessage.info('公众号签署推送接口待接入')
return
}
try {
await refreshTask(await resendSigningWxMessage(task.id), '公众号签署消息已重新推送')
} catch {
ElMessage.error('重发推送失败,请稍后重试')
}
return
}
// 原型 switchToIpad:先切成 Pad 方式,再定位到签名位(两步是一件事)
if (action === 'switch-to-ipad') {
if (!isSigningMockEnabled) {
ElMessage.info('当前后端未提供切换签署方式接口,请作废后重新创建任务')
return
}
try {
await refreshTask(await updateSigningTaskMethod(task.id, 'ipad'), '已切换为 📲 Pad 签署')
await nextTick()
if (taskDetailRef.value?.locateSignatureSlot()) {
ElMessage.info('已下拉到签名位置,点击签名框弹出签名板完成签署')
}
} catch {
ElMessage.error('任务操作失败,请稍后重试')
}
return
}
if (action === 'create-pad-session') {
padSessionLoading.value = true
try {
const session: SignDeliveryResponseDto = await createPadSigningSession(task.id)
padSessionStatus.value = session.status
ElMessage.success(
`${readSigningMethod(task.method).short}签署会话已创建,请由设备适配器继续采集签名`,
)
} catch {
ElMessage.error(`${readSigningMethod(task.method).short}签署会话创建失败,请稍后重试`)
} finally {
padSessionLoading.value = false
}
return
}
if (action === 'online-sign') {
if (!isSigningMockEnabled) {
ElMessage.info('线上签署页面接口待接入')
return
}
if (!(await assertSigningReady())) {
return
}
onlineDialogVisible.value = true
return
}
if (action === 'void') {
voidDialogVisible.value = true
return
}
/**
* 催办:提醒科室签署员尽快复核。
*
* 真实模式后端没有催办接口(要重做),按项目既有口径如实提示"待接入",
* 不前端假装发了一条提醒 —— 催办是要留痕的对外动作。
*/
if (action === 'remind') {
if (!isSigningMockEnabled) {
ElMessage.info('催办提醒接口待接入')
return
}
ElMessage.success(`已向${task.department}签署员发送催办提醒`)
return
}
if (action === 'confirm-dept') {
if (!isSigningMockEnabled) {
ElMessage.info('科室复核签章接口待接入')
return
}
const confirmed = await ElMessageBox.confirm(
`确认「${task.department}」已完成复核签章?确认后任务转为「已签署」,文档归档至本次诊疗记录。`,
'科室复核签章',
{ type: 'warning', confirmButtonText: '确认签章', cancelButtonText: '取消' },
)
.then(() => true)
.catch(() => false)
if (!confirmed) {
return
}
try {
await refreshTask(
await confirmDepartmentSigning(task.id),
'科室复核签章完成,文档已归档至本次诊疗记录',
)
} catch {
ElMessage.error('科室复核签章失败,请稍后重试')
}
return
}
if (action === 'reopen' && task.status !== 'expired' && task.status !== 'failed') {
ElMessage.info('只有已超时或处理失败的任务可以重新发起')
return
}
if (action === 'download-pdf') {
const artifact = artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF')
if (artifact) {
await downloadArtifact(artifact)
} else if (isSigningMockEnabled) {
await exportSignedPdf()
} else {
ElMessage.info('当前任务暂无签署后 PDF')
}
return
}
if (action === 'download-signature') {
const artifact = artifacts.value.find((item) => item.artifactType === 'SIGNATURE_IMAGE')
if (artifact) {
await downloadArtifact(artifact)
} else {
ElMessage.info('当前任务暂无签名原图')
}
return
}
if (action === 'preview-signed') {
signedPreviewVisible.value = true
return
}
if (action === 'download-readonly-copy') {
await exportReadonlyCopy()
return
}
if (action === 'print') {
ElMessage.info('原型演示:打印服务待接入')
return
}
try {
if (action === 'switch-method') {
if (!isSigningMockEnabled) {
ElMessage.info('当前后端未提供切换签署方式接口,请作废后重新创建任务')
return
}
// 详情页的次按钮会带上目标方式;没有目标时退回原型 switchMethod 的二选一
const method: SigningMethod = targetMethod ?? toggleSigningMethod(task.method)
await refreshTask(
await updateSigningTaskMethod(task.id, method),
`已切换为${formatSigningMethodTitle(method)}`,
)
return
}
if (action === 'resend-sms') {
await refreshTask(
await resendSigningSms(task.id, {
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'短信投递已重新排队',
)
return
}
if (action === 'reopen') {
await refreshTask(
await reopenSigningTask(task.id, {
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'签署任务已重新发起',
)
}
} catch {
ElMessage.error('任务操作失败,请稍后重试')
}
}
/**
* 原型阶段:后端没有签署后 PDF 时,前端按当前渲染结果现场生成一份。
* 真实实现里 PDF 由服务端生成(正式文书以服务端产物为准),
* 这里只是让前端能先看到成品效果,因此仅在演示模式下兜底。
*/
async function exportSignedPdf() {
const task = selectedTask.value
const element = taskDetailRef.value?.getDocumentElement()
if (!task || !element) {
ElMessage.info('该任务没有固化的文书正文,无法导出 PDF')
return
}
if (pdfExporting.value) {
return
}
pdfExporting.value = true
try {
await downloadSigningPdf({ element, fileName: `${task.documentName}-${task.id}` })
ElMessage.success('签署后 PDF 已生成')
} catch {
ElMessage.error('PDF 生成失败,请稍后重试')
} finally {
pdfExporting.value = false
}
}
/**
* 只读副本:在按任务固化版式渲染的结果上加「水印 + 审计编号」再导出。
*
* 与原件的区别是用途:原件是对内留存的正式凭证,只读副本是对外提供的那一份,
* 因此必须能一眼看出不是原件。水印直接烙进截图,改不掉文本层。
* 审计编号直接用任务号 —— 它是全链路可追溯的真实标识,
* 另外编一个前端生成的编号只会让追溯断在这里。
*/
async function exportReadonlyCopy() {
const task = selectedTask.value
const element = taskDetailRef.value?.getDocumentElement()
if (!task || !element) {
ElMessage.info('该任务没有固化的文书正文,无法生成只读副本')
return
}
if (pdfExporting.value) {
return
}
pdfExporting.value = true
try {
await downloadSigningPdf({
element,
fileName: `${task.documentName}-${task.id}-只读副本`,
watermark: `只读副本 · 审计编号 ${task.id}`,
})
ElMessage.success('只读副本已生成(含水印与审计编号)')
} catch {
ElMessage.error('只读副本生成失败,请稍后重试')
} finally {
pdfExporting.value = false
}
}
async function downloadArtifact(artifact: SigningArtifact) {
if (!canDownloadArtifacts.value) {
ElMessage.warning('当前账号没有下载签署文件的权限')
return
}
try {
const blob = await downloadSigningArtifact(artifact.id)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = artifact.fileName
link.click()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
ElMessage.success(`${artifact.label}下载已开始`)
} catch {
ElMessage.error(`${artifact.label}下载失败,请稍后重试`)
}
}
function decodeSignaturePng(dataUrl: string) {
const match = /^data:image\/png;base64,([\s\S]+)$/.exec(dataUrl)
if (!match) {
throw new Error('签名画布没有生成有效的 PNG 图片')
}
const binary = atob(match[1])
const buffer = new ArrayBuffer(binary.length)
const bytes = new Uint8Array(buffer)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10]
if (pngSignature.some((value, index) => bytes[index] !== value) || bytes.length < 24) {
throw new Error('签名 PNG 格式无效')
}
const view = new DataView(buffer)
const width = view.getUint32(16, false)
const height = view.getUint32(20, false)
if (!width || !height) {
throw new Error('签名图片尺寸无效')
}
return {
blob: new Blob([buffer], { type: 'image/png' }),
width,
height,
}
}
async function signatureSha256(blob: Blob) {
if (!globalThis.crypto?.subtle) {
throw new Error('当前浏览器环境不支持签名校验,请通过 HTTPS 或 localhost 访问')
}
const digest = await globalThis.crypto.subtle.digest('SHA-256', await blob.arrayBuffer())
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, '0')).join('')
}
function createPatientSignerAssertion(signer: {
type: string | null
name: string | null
relation: string | null
}): PatientSignerAssertionInput {
const type = signer.type?.trim().toUpperCase()
if (type !== 'SELF' && type !== 'FAMILY') {
throw new Error('任务中没有有效的签署人类型,请刷新任务后重试')
}
const name = signer.name?.trim() ?? ''
const relation = signer.relation?.trim() ?? ''
if (!name || !relation) {
throw new Error('任务中缺少签署人姓名或关系,请检查任务信息')
}
return {
type,
name,
relation,
consentAccepted: true,
observedSignedAt: new Date().toISOString(),
}
}
function buildSignatureMetadata(
field: SigningContextSignatureFieldDto,
image: { blob: Blob; width: number; height: number },
sha256: string,
) {
return JSON.stringify({
fieldId: field.id,
page: field.page,
x: field.x,
y: field.y,
width: field.width,
height: field.height,
coordinateSystem: field.coordinateSystem,
imageWidth: image.width,
imageHeight: image.height,
sha256,
contentType: 'image/png',
sizeBytes: image.blob.size,
})
}
async function submitMousePadSignature(task: SigningTaskRecord, signatureDataUrl: string) {
const session = mousePadSession.value
if (!session?.token) {
throw new Error('手写板会话已失效,请重新打开鼠标签署')
}
await formSaveQueue
if (formSaveError) throw new Error('填写内容未保存,请刷新后重试')
const answerRevision =
formDraft?.taskId === task.id ? formDraft.revision : task.formState?.revision
const image = decodeSignaturePng(signatureDataUrl)
const consumed = await consumeSigningToken({
token: session.token,
channel: 'PAD',
taskId: task.id,
})
if (!consumed.uploadToken) {
throw new Error('签署会话未返回一次性上传授权')
}
const context = await getSigningContext({
taskId: task.id,
deliveryId: consumed.deliveryId,
uploadToken: consumed.uploadToken,
})
const field = context.signatureFields.find((item) => item.id === PRIMARY_SIGNATURE_FIELD_ID)
if (!field) {
throw new Error('模板没有患者签名位置,无法提交鼠标签名')
}
const sha256 = await signatureSha256(image.blob)
await uploadSigningSignature({
taskId: task.id,
deliveryId: consumed.deliveryId,
uploadToken: consumed.uploadToken,
file: image.blob,
metadata: buildSignatureMetadata(field, image, sha256),
signer: { ...createPatientSignerAssertion(context.signer), answerRevision },
})
signatureDialogVisible.value = false
mousePadSession.value = null
await loadTasks(task.id, true)
ElMessage.success(
task.needConfirm
? '现场鼠标签署完成,任务已流转至科室确认节点'
: '鼠标签署完成,签署文件已生成',
)
}
async function confirmPadSignature(signatureDataUrl: string) {
const task = selectedTask.value
if (!task) {
return
}
if (!isSigningMockEnabled) {
signatureSubmitting.value = true
try {
await submitMousePadSignature(task, signatureDataUrl)
} catch (error) {
signatureDialogVisible.value = false
mousePadSession.value = null
ElMessage.error(
error instanceof Error ? `鼠标签署失败:${error.message}` : '鼠标签署失败,请稍后重试',
)
} finally {
signatureSubmitting.value = false
}
return
}
signatureSubmitting.value = true
try {
const updated = await completeSigningTask(
task.id,
signatureDataUrl,
signingSelection.value,
signingAnswers.value,
)
await refreshTask(updated, signingCompletedMessage(updated))
signatureDialogVisible.value = false
} catch {
ElMessage.error('手写签署失败,请稍后重试')
} finally {
signatureSubmitting.value = false
}
}
async function completeOnlineSignature(signatureDataUrl: string) {
const task = selectedTask.value
if (!task) {
return
}
try {
const updated = await completeSigningTask(
task.id,
signatureDataUrl,
signingSelection.value,
signingAnswers.value,
)
await refreshTask(updated, signingCompletedMessage(updated))
onlineDialogVisible.value = false
} catch {
ElMessage.error('线上签署失败,请稍后重试')
}
}
/**
* 签署完成后的提示语。
*
* 需科室确认的任务签完**并没有结束**(原型同样区分这两种说法):
* 如果这里一律说「签署已完成」,操作人会以为可以关页面了,
* 而实际上文档还停在科室确认队列里没归档。
*/
function signingCompletedMessage(updated: SigningTaskRecord | null) {
return updated?.status === 'confirming'
? '患者已签署,已流转至科室确认节点,等待科室复核签章'
: '签署完成,文档已归档'
}
async function confirmVoidTask(reason: string) {
const task = selectedTask.value
if (!task) {
return
}
try {
await refreshTask(
await voidSigningTask(task.id, reason, {
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'签署任务已作废',
)
voidDialogVisible.value = false
} catch {
ElMessage.error('任务作废失败,请稍后重试')
}
}
/** 患者页里的「重发」:短信重发短信、公众号重发推送(两个动作后端也不同) */
async function handleOnlineResend() {
await handleTaskAction(selectedTask.value?.method === 'wx' ? 'resend-wx-push' : 'resend-sms')
}
/**
* 发送签署短信成功(原型 `sendSmsFlow`):方式切换在这里完成 ——
* 发送前那条任务可能还是手写板 / Pad,发送后它才是一条「等待患者签署」的短信任务。
*/
async function handleSmsSent(mobile: string | null) {
const task = selectedTask.value
if (!task) {
return
}
try {
const updated = await sendSigningSms(task.id, mobile)
smsSendVisible.value = false
await refreshTask(
updated,
`签署短信投递已排队${updated?.smsMobileMasked ? `至 ${updated.smsMobileMasked}` : ''},等待患者签署`,
)
} catch {
ElMessage.error('发送签署短信失败,请稍后重试')
}
}
/** 确认推送公众号签署消息(原型 `sendWxPush`):方式同样在这一步切成 wx */
async function handleWxPushed() {
const task = selectedTask.value
if (!task) {
return
}
try {
const updated = await pushSigningWxMessage(task.id)
wxPushVisible.value = false
await refreshTask(updated, '公众号签署消息已推送给患者,等待患者签署')
} catch {
ElMessage.error('公众号推送失败,请稍后重试')
}
}
async function initializePage() {
await appStore.loadCampuses()
syncFilterFromRoute()
await loadOrganizationOptions()
await loadTemplates()
await loadTasks()
}
async function handleTaskCreated(task: SigningTaskRecord) {
await loadTasks(task.id, true)
}
watch(
() => [route.query.status, route.query.range, route.query.taskId],
() => {
syncFilterFromRoute()
page.value = 1
selectedTaskId.value = null
void loadTasks()
},
)
/** 切换全局院区:列表跟着换(院区不再是筛选条里的一个下拉,见 syncFilterFromRoute) */
watch(
() => appStore.selectedCampusId,
(campusId, previousCampusId) => {
page.value = 1
if (campusId && previousCampusId) {
void loadTemplates()
}
void loadTasks()
},
)
onMounted(() => {
void initializePage()
})
</script>
<template>
<section class="signing-page">
<SigningFilterBar
:filter="filter"
:statuses="statusOptions"
:can-create="canCreateTask"
@add="newDialogVisible = true"
@search="handleSearch"
/>
<div class="task-workspace">
<SigningTaskList
:tasks="tasks"
:selected-id="selectedTaskId"
:total="total"
:page="page"
:page-size="pageSize"
:loading="loading"
@select="selectTask"
@page-change="handlePageChange"
/>
<SigningTaskDetail
ref="taskDetailRef"
:task="selectedTask"
:loading="detailLoading"
:audit-events="taskEvents"
:audit-loading="eventsLoading"
:audit-error="eventsError"
:allow-local-signing="isSigningMockEnabled"
:form-save-message="formSaveMessage"
:form-saving="formSaving"
:allow-mouse-signing="canSignTask && selectedTask?.method === 'pad'"
:artifacts="artifacts"
:artifacts-loading="artifactsLoading"
:artifacts-error="artifactsError"
:pad-session-status="padSessionStatus"
:pad-session-loading="padSessionLoading"
:can-sign-task="canSignTask"
:can-reopen="canReopenTask"
:can-void="canVoidTask"
:can-confirm="canConfirmTask"
:can-remind="canRemindTask"
:can-view-audit="canViewEvents"
:can-view-artifacts="canViewArtifacts"
:can-download-artifact="canDownloadArtifacts"
:signature-injections="fieldSignatureInjections"
@action="handleTaskAction"
@download-artifact="downloadArtifact"
@selection-change="handleSelectionChange"
@field-change="handleFieldChange"
@signature-request="handleSignatureRequest"
/>
</div>
<NewSigningTaskDialog
:visible="newDialogVisible"
@close="newDialogVisible = false"
@created="handleTaskCreated"
/>
<SignaturePadDialog
:visible="signatureDialogVisible"
:task="selectedTask"
:mouse-test-required="!isSigningMockEnabled && selectedTask?.method === 'pad'"
:patient-consent-required="!isSigningMockEnabled && selectedTask?.method === 'pad'"
:submitting="signatureSubmitting"
:confirm-label="
!isSigningMockEnabled && selectedTask?.method === 'pad' ? '提交鼠标签署' : '确认签署'
"
@close="closeTaskPadSignature"
@confirm="confirmPadSignature"
/>
<!-- 文书里单个签名位(医师签名等)的采集入口:签完只回填该位置,不结束任务 -->
<SignaturePadDialog
:visible="fieldSignatureDialogVisible"
:task="selectedTask"
:title="`采集「${pendingSignatureField?.label ?? '签名'}」`"
:hint="`请让签署人在下框内签名,签名图将回显到文书中的「${pendingSignatureField?.label ?? '签名'}」位置;本次采集不会结束签署任务:`"
confirm-label="确认采集"
@close="closeFieldSignatureDialog"
@confirm="confirmFieldSignature"
/>
<OnlineSigningDialog
:visible="onlineDialogVisible"
:task="selectedTask"
:variant="selectedTask?.method === 'wx' ? 'wx' : 'sms'"
@close="onlineDialogVisible = false"
@complete="completeOnlineSignature"
@resend="handleOnlineResend"
/>
<!-- 短信 / 公众号链路的**第一步**:先把消息发出去(原型 #mdSmsFlow / #mdWxPush) -->
<SmsSendDialog
:visible="smsSendVisible"
:task="selectedTask"
:is-mock="isSigningMockEnabled"
@close="smsSendVisible = false"
@send="handleSmsSent"
/>
<WxPushDialog
:visible="wxPushVisible"
:task="selectedTask"
@close="wxPushVisible = false"
@push="handleWxPushed"
/>
<SignedDocumentDialog
:visible="signedPreviewVisible"
:task="selectedTask"
:exporting="pdfExporting"
@close="signedPreviewVisible = false"
@download="handleTaskAction('download-pdf')"
@download-readonly-copy="exportReadonlyCopy"
/>
<VoidTaskDialog
:visible="voidDialogVisible"
:task="selectedTask"
@close="voidDialogVisible = false"
@confirm="confirmVoidTask"
/>
</section>
</template>
<style scoped>
/* 工作区吃满可视高度:文书在自己的滚动区里滚,页面本身不再滚。
外层 Container 仍是滚动容器,这里只是不去撑高它 */
.signing-page {
display: flex;
width: 100%;
max-width: 1600px;
height: 100%;
min-height: 0;
flex-direction: column;
margin: 0 auto;
}
.signing-page > * {
flex-shrink: 0;
}
.task-workspace {
display: flex;
flex: 1;
gap: 16px;
align-items: stretch;
/* 视口过矮时保底:宁可让外层滚动,也不把工作区压到看不见文书 */
min-height: 400px;
}
.task-workspace > * {
min-width: 0;
}
@media (max-width: 1200px) {
/* 窄屏两栏改上下堆叠,此时不再锁死高度,交回外层自然滚动 */
.signing-page {
height: auto;
}
.task-workspace {
flex-direction: column;
min-height: 0;
}
.task-workspace > * {
width: 100%;
}
}
</style>