1222 lines
36 KiB
Vue
1222 lines
36 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 { 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 { 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() {
|
||
try {
|
||
templates.value = await getSigningTemplates()
|
||
} 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
|
||
}
|
||
}
|
||
|
||
/** 点击文中签名位:先记下采的是哪一个,再由弹窗把签名图回灌到该位置 */
|
||
function handleSignatureRequest(request: SigningSignatureRequest) {
|
||
if (!selectedTask.value) {
|
||
return
|
||
}
|
||
|
||
pendingSignatureField.value = request
|
||
fieldSignatureDialogVisible.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('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.selectedCampus,
|
||
() => {
|
||
page.value = 1
|
||
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"
|
||
@close="onlineDialogVisible = false"
|
||
@complete="completeOnlineSignature"
|
||
@resend="handleOnlineResend"
|
||
/>
|
||
<!-- 短信 / 公众号链路的**第一步**:先把消息发出去(原型 #mdSmsFlow / #mdWxPush) -->
|
||
<SmsSendDialog
|
||
:visible="smsSendVisible"
|
||
:task="selectedTask"
|
||
@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>
|