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

895 lines
24 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, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute } from 'vue-router'
import { getCampuses, getDepartments } from '@/api/management/organization'
import { downloadSigningArtifact, getSigningArtifacts } from '@/api/workbench/artifacts'
import { createPadSigningSession } from '@/api/workbench/deliveries'
import {
completeSigningTask,
getSigningTaskDetail,
getSigningTaskEvents,
getSigningTasks,
getSigningTemplates,
isSigningMockEnabled,
reopenSigningTask,
resendSigningSms,
updateSigningTaskMethod,
voidSigningTask,
} from '@/api/workbench/signing'
import type {
SigningAnswers,
SigningDateRange,
SigningMethod,
SigningArtifact,
SignDeliveryResponseDto,
SigningTaskEvent,
SigningTaskRecord,
SigningTaskStatus,
SigningTemplate,
VisitType,
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 } from '@/utils/signing-document'
import { downloadSigningPdf } from '@/utils/signing-pdf'
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
import SignaturePadDialog from './components/SignaturePadDialog.vue'
import SigningFilterBar from './components/SigningFilterBar.vue'
import SigningTaskDetail from './components/SigningTaskDetail.vue'
import SigningTaskList from './components/SigningTaskList.vue'
import VoidTaskDialog from './components/VoidTaskDialog.vue'
import type { SigningFilterForm, SigningFilterOption, SigningTaskAction } from './types'
const route = useRoute()
const appStore = useAppStore()
const loginStore = useLoginStore()
const STATUS_VALUES: SigningTaskStatus[] = [
'pending',
'signing',
'signed',
'expired',
'void',
'failed',
]
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
expired: '已超时',
void: '已作废',
failed: '处理失败',
}
const filter = reactive<SigningFilterForm>({
keyword: '',
status: 'all',
dateRange: 'all',
campus: 'all',
method: 'all',
department: '',
documentId: '',
visitType: '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)
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)
const voidDialogVisible = 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 canViewEvents = computed(() => canRead('sign:task:event:query'))
const canViewArtifacts = computed(() => canRead('sign:artifact:query'))
const canDownloadArtifacts = computed(() => canRead('sign:artifact:download'))
const campusOptions = computed<SigningFilterOption<WorkbenchCampus | 'all'>[]>(() => [
{ label: '全部院区', value: 'all' },
...appStore.campuses.map((campus) => ({ label: campus, value: campus })),
])
const statusOptions: SigningFilterOption<SigningFilterForm['status']>[] = [
{ label: '全部状态', value: 'all' },
...STATUS_VALUES.map((status) => ({ label: statusLabels[status], value: status })),
]
const methodOptions: SigningFilterOption<SigningFilterForm['method']>[] = [
{ label: '全部方式', value: 'all' },
{ label: '手写板', value: 'pad' },
{ label: '短信线上', value: 'sms' },
]
const visitTypeOptions: SigningFilterOption<VisitType | 'all'>[] = [
{ label: '全部类型', value: 'all' },
{ label: '门诊', value: '门诊' },
{ label: '住院', value: '住院' },
{ label: '体检', value: '体检' },
]
const departmentOptions = computed<SigningFilterOption[]>(() => {
const departments = new Set([
...templates.value.map((template) => template.department),
...tasks.value.map((task) => task.department),
])
return [
{ label: '全部科室', value: '' },
...[...departments].sort().map((department) => ({ label: department, value: department })),
]
})
const documentOptions = computed<SigningFilterOption[]>(() => [
{ label: '全部文档', value: '' },
...templates.value.map((template) => ({ label: template.name, value: template.id })),
])
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_VALUES.includes(value as SigningTaskStatus)
}
function isSigningDateRange(value: string): value is SigningDateRange {
return DATE_RANGE_VALUES.includes(value as SigningDateRange)
}
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
return appStore.campuses.includes(value)
}
function syncFilterFromRoute() {
const status = getQueryString(route.query.status)
const range = getQueryString(route.query.range)
const campus = getQueryString(route.query.campus)
filter.status = status === 'all' || isSigningStatus(status) ? status : 'all'
filter.dateRange = isSigningDateRange(range) ? range : 'all'
filter.campus = isWorkbenchCampus(campus) ? campus : '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
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
}
}
} 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
}
/**
* 采集签名前的填写项校验。
* 必填项没填完就采集签名,等于签了一份内容不完整的文书,因此在入口处挡住。
*/
function assertFieldsReady() {
const missing = signingProgress.value?.missingRequired ?? []
if (missing.length) {
ElMessage.warning(`请先完成必填项:${missing.join('、')}`)
return false
}
return true
}
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) {
const task = selectedTask.value
if (!task) {
return
}
const allowed =
isSigningMockEnabled ||
(action === 'create-pad-session' && canDeliveryWrite.value) ||
(action === 'resend-sms' && canDeliveryWrite.value) ||
(action === 'reopen' && canReopenTask.value) ||
(action === 'void' && canVoidTask.value) ||
((action === 'download-pdf' || action === 'download-signature' || action === 'print') &&
canDownloadArtifacts.value)
if (!allowed) {
ElMessage.warning('当前账号没有执行此操作的权限')
return
}
if (action === 'pad-sign') {
if (!isSigningMockEnabled) {
ElMessage.info('手写板设备签署接口待接入')
return
}
if (!assertFieldsReady()) {
return
}
signatureDialogVisible.value = true
return
}
if (action === 'create-pad-session') {
padSessionLoading.value = true
try {
const session: SignDeliveryResponseDto = await createPadSigningSession(task.id)
padSessionStatus.value = session.status
ElMessage.success('手写板签署会话已创建,请由设备适配器继续采集签名')
} catch {
ElMessage.error('手写板签署会话创建失败,请稍后重试')
} finally {
padSessionLoading.value = false
}
return
}
if (action === 'online-sign') {
if (!isSigningMockEnabled) {
ElMessage.info('线上签署页面接口待接入')
return
}
if (!assertFieldsReady()) {
return
}
onlineDialogVisible.value = true
return
}
if (action === 'void') {
voidDialogVisible.value = true
return
}
if (action === 'reopen' && task.status !== 'expired') {
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 === 'print') {
ElMessage.info('原型演示:打印服务待接入')
return
}
try {
if (action === 'switch-method') {
if (!isSigningMockEnabled) {
ElMessage.info('当前后端未提供切换签署方式接口,请作废后重新创建任务')
return
}
const method: SigningMethod = task.method === 'pad' ? 'sms' : 'pad'
await refreshTask(
await updateSigningTaskMethod(task.id, method),
method === 'sms' ? '已切换为短信线上签署' : '已切换为手写板签署',
)
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 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 {
await refreshTask(
await completeSigningTask(
task.id,
signatureDataUrl,
signingSelection.value,
signingAnswers.value,
),
'手写签署已完成',
)
signatureDialogVisible.value = false
} catch {
ElMessage.error('手写签署失败,请稍后重试')
}
}
async function completeOnlineSignature(signatureDataUrl: string) {
const task = selectedTask.value
if (!task) {
return
}
try {
await refreshTask(
await completeSigningTask(
task.id,
signatureDataUrl,
signingSelection.value,
signingAnswers.value,
),
'线上签署已完成',
)
onlineDialogVisible.value = false
} catch {
ElMessage.error('线上签署失败,请稍后重试')
}
}
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')
}
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.campus, route.query.taskId],
() => {
syncFilterFromRoute()
page.value = 1
selectedTaskId.value = null
void loadTasks()
},
)
watch(
() => appStore.selectedCampus,
(campus) => {
if (filter.campus !== 'all') {
filter.campus = campus
}
page.value = 1
void loadTasks()
},
)
onMounted(() => {
void initializePage()
})
</script>
<template>
<section class="signing-page">
<div class="page-intro">
<div>
<h1>签署任务</h1>
<p>集中处理患者知情同意文书的发起、签署与全过程留痕。</p>
</div>
<div class="page-intro__meta">
<span>当前院区:{{ appStore.selectedCampus }}</span>
<span>共 {{ total }} 条任务</span>
</div>
</div>
<SigningFilterBar
:filter="filter"
:campuses="campusOptions"
:statuses="statusOptions"
:methods="methodOptions"
:departments="departmentOptions"
:documents="documentOptions"
:visit-types="visitTypeOptions"
: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-view-audit="canViewEvents"
:can-view-artifacts="canViewArtifacts"
:can-download-artifact="canDownloadArtifacts"
@action="handleTaskAction"
@download-artifact="downloadArtifact"
@selection-change="handleSelectionChange"
@field-change="handleFieldChange"
/>
</div>
<NewSigningTaskDialog
:visible="newDialogVisible"
@close="newDialogVisible = false"
@created="handleTaskCreated"
/>
<SignaturePadDialog
:visible="signatureDialogVisible"
:task="selectedTask"
@close="signatureDialogVisible = false"
@confirm="confirmPadSignature"
/>
<OnlineSigningDialog
:visible="onlineDialogVisible"
:task="selectedTask"
@close="onlineDialogVisible = false"
@complete="completeOnlineSignature"
@resend="handleOnlineResend"
/>
<VoidTaskDialog
:visible="voidDialogVisible"
:task="selectedTask"
@close="voidDialogVisible = false"
@confirm="confirmVoidTask"
/>
</section>
</template>
<style scoped>
.signing-page {
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.page-intro {
display: flex;
gap: 20px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 14px;
}
.page-intro h1 {
margin: 0;
color: var(--ink);
font-size: 19px;
letter-spacing: 0.5px;
}
.page-intro p {
margin: 4px 0 0;
color: var(--mut);
font-size: 12.5px;
}
.page-intro__meta {
display: flex;
gap: 8px;
align-items: center;
color: var(--mut);
font-size: 12px;
white-space: nowrap;
}
.page-intro__meta span {
padding: 5px 9px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 6px;
}
.task-workspace {
display: flex;
gap: 16px;
align-items: flex-start;
}
.task-workspace > * {
min-width: 0;
}
@media (max-width: 1200px) {
.task-workspace {
flex-direction: column;
}
.task-workspace > * {
width: 100%;
}
}
@media (max-width: 680px) {
.page-intro {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.page-intro__meta {
white-space: normal;
}
}
</style>