1551 lines
46 KiB
Vue
1551 lines
46 KiB
Vue
<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>
|