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

1269 lines
38 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 { createPadSigningSession } from '@/api/workbench/deliveries'
import {
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,
SignDeliveryResponseDto,
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 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 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 canDeliveryWrite = computed(() => canWrite('sign:delivery:write'))
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'))
const canViewArtifacts = computed(() => canRead('sign:artifact:query'))
const canDownloadArtifacts = computed(() => canRead('sign:artifact:download'))
/**
* 「投递类」动作:签署本身、方式切换、签署消息的发送与重发。
*
* 它们动的是对外可见的东西(患者会收到短信 / 推送,或任务状态直接变),
* 与「创建签署会话」同级 —— 都要 `sign:delivery:write`。
* 归到一处是因为原来逐个 `action === 'x' && ...` 地列,加一个方式就漏一个
* (漏掉的表现是「切到 Pad 就提示没有权限」)。
*/
const DELIVERY_ACTIONS: SigningTaskAction[] = [
'pad-sign',
'online-sign',
'switch-method',
'switch-to-ipad',
'send-sms',
'send-wx-push',
'simulate-patient-sign',
'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
// 换任务时先清空勾选与填写项,避免把上一个任务的取值带到新任务上;
// 文书组件挂载后会用任务自身固化的取值重新上报
signingSelection.value = []
signingAnswers.value = {}
signingProgress.value = null
fieldSignatureInjections.value = {}
pendingSignatureField.value = null
fieldSignatureDialogVisible.value = false
// 预览弹窗跟着任务走:换任务时留在上一份文书的预览上会让人以为看的是当前任务
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
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
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
}
function handleFieldChange(answers: SigningAnswers, progress: SigningFieldProgress) {
signingAnswers.value = answers
signingProgress.value = progress
}
/**
* 采集主签名前的检查。
*
* ① 必填项没填完 → 直接挡住:签一份内容不完整的文书没有意义;
* ② 文书里还有没采集的签名位 → 让操作人员确认后再继续。
* 这一条不能硬挡:医师确实可能签在纸质件上,替业务做决定是越界;
* 但也不能不提醒,否则文书会带着空签名位被固化。
*/
async function assertSigningReady() {
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
}
signatureDialogVisible.value = true
}
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 ||
(action === 'create-pad-session' && canDeliveryWrite.value) ||
(DELIVERY_ACTIONS.includes(action) && canDeliveryWrite.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') {
if (!isSigningMockEnabled) {
ElMessage.info(`${readSigningMethod(task.method).short}设备签署接口待接入`)
return
}
/**
* 原型把两种现场方式分得很清楚:
* - 手写板 → `openPad()`,直接开签名板;
* - Pad → `openIpad()`,**不弹窗**,只把文书滚到第一个签名位并高亮,
* 提示「点击签名框弹出签名板」,由操作人自己点那个框。
* 差别就在这里:一个直接签,一个是「在文书上找到该签的地方」再签。
*/
if (task.method === 'ipad') {
if (taskDetailRef.value?.locateSignatureSlot()) {
ElMessage.info('已下拉到签名位置,点击签名框弹出签名板完成签署')
} else {
ElMessage.error('当前文书未找到签名位置')
}
return
}
if (!(await assertSigningReady())) {
return
}
signatureDialogVisible.value = true
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}下载失败,请稍后重试`)
}
}
async function confirmPadSignature(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))
signatureDialogVisible.value = false
} catch {
ElMessage.error('手写签署失败,请稍后重试')
}
}
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"
:artifacts="artifacts"
:artifacts-loading="artifactsLoading"
:artifacts-error="artifactsError"
:pad-session-status="padSessionStatus"
:pad-session-loading="padSessionLoading"
:can-delivery-write="canDeliveryWrite"
: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"
@close="signatureDialogVisible = false"
@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>