719 lines
18 KiB
Vue
719 lines
18 KiB
Vue
<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 {
|
||
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 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 STATUS_VALUES: SigningTaskStatus[] = [
|
||
'pending',
|
||
'signing',
|
||
'signed',
|
||
'rejected',
|
||
'expired',
|
||
'void',
|
||
'failed',
|
||
]
|
||
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
|
||
|
||
const statusLabels: Record<SigningTaskStatus, string> = {
|
||
pending: '待签署',
|
||
signing: '签署中',
|
||
signed: '已签署',
|
||
rejected: '已拒签',
|
||
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 selectedTaskId = ref<string | null>(null)
|
||
const selectedTask = ref<SigningTaskRecord | 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)
|
||
|
||
let listRequestId = 0
|
||
let detailRequestId = 0
|
||
|
||
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],
|
||
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 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
|
||
.filter(
|
||
(campus): campus is typeof campus & { name: WorkbenchCampus } =>
|
||
campus.name === '本部院区' || campus.name === '东院区' || campus.name === '西院区',
|
||
)
|
||
.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
|
||
|
||
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 = true
|
||
eventsError.value = false
|
||
artifactsLoading.value = true
|
||
artifactsError.value = false
|
||
taskEvents.value = []
|
||
artifacts.value = []
|
||
padSessionStatus.value = null
|
||
|
||
try {
|
||
const [detailResult, eventsResult, artifactsResult] = await Promise.allSettled([
|
||
getSigningTaskDetail(id, signingApiOptions.value),
|
||
getSigningTaskEvents(id),
|
||
getSigningArtifacts(id),
|
||
])
|
||
|
||
if (requestId === detailRequestId) {
|
||
if (detailResult.status === 'rejected') {
|
||
throw detailResult.reason
|
||
}
|
||
|
||
selectedTask.value = detailResult.value
|
||
|
||
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) {
|
||
const requestId = ++listRequestId
|
||
loading.value = true
|
||
|
||
try {
|
||
const response = await getSigningTasks(
|
||
{
|
||
...filter,
|
||
page: 1,
|
||
pageSize: 50,
|
||
},
|
||
signingApiOptions.value,
|
||
)
|
||
|
||
if (requestId !== listRequestId) {
|
||
return
|
||
}
|
||
|
||
tasks.value = response.records
|
||
total.value = response.total
|
||
|
||
const taskIds = new Set(response.records.map((task) => task.id))
|
||
const routeTaskId = getQueryString(route.query.taskId)
|
||
const nextTaskId =
|
||
[preferredTaskId, selectedTaskId.value, routeTaskId].find((id) => id && taskIds.has(id)) ??
|
||
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()
|
||
}
|
||
|
||
function selectTask(id: string) {
|
||
selectedTaskId.value = id
|
||
void loadTaskDetail(id)
|
||
}
|
||
|
||
async function refreshTask(updatedTask: SigningTaskRecord | null, successMessage: string) {
|
||
if (!updatedTask) {
|
||
ElMessage.error('签署任务不存在或已被删除')
|
||
return
|
||
}
|
||
|
||
await loadTasks(updatedTask.id)
|
||
ElMessage.success(successMessage)
|
||
}
|
||
|
||
async function handleTaskAction(action: SigningTaskAction) {
|
||
const task = selectedTask.value
|
||
|
||
if (!task) {
|
||
return
|
||
}
|
||
|
||
if (action === 'pad-sign') {
|
||
if (!isSigningMockEnabled) {
|
||
ElMessage.info('手写板设备签署接口待接入')
|
||
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
|
||
}
|
||
|
||
onlineDialogVisible.value = true
|
||
return
|
||
}
|
||
|
||
if (action === 'void') {
|
||
voidDialogVisible.value = true
|
||
return
|
||
}
|
||
|
||
if (action === 'download-pdf') {
|
||
const artifact =
|
||
artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF') ??
|
||
artifacts.value.find((item) => item.artifactType === 'ORIGINAL_PDF')
|
||
|
||
if (artifact) {
|
||
await downloadArtifact(artifact)
|
||
} 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('任务操作失败,请稍后重试')
|
||
}
|
||
}
|
||
|
||
async function downloadArtifact(artifact: SigningArtifact) {
|
||
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), '手写签署已完成')
|
||
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), '线上签署已完成')
|
||
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 loadOrganizationOptions()
|
||
await loadTemplates()
|
||
await loadTasks()
|
||
}
|
||
|
||
async function handleTaskCreated(task: SigningTaskRecord) {
|
||
await loadTasks(task.id)
|
||
}
|
||
|
||
watch(
|
||
() => [route.query.status, route.query.range, route.query.taskId],
|
||
() => {
|
||
syncFilterFromRoute()
|
||
selectedTaskId.value = null
|
||
void loadTasks()
|
||
},
|
||
)
|
||
|
||
watch(
|
||
() => appStore.selectedCampus,
|
||
(campus) => {
|
||
if (filter.campus !== 'all') {
|
||
filter.campus = campus
|
||
}
|
||
void loadTasks()
|
||
},
|
||
)
|
||
|
||
onMounted(() => {
|
||
syncFilterFromRoute()
|
||
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"
|
||
@add="newDialogVisible = true"
|
||
@search="handleSearch"
|
||
/>
|
||
|
||
<div class="task-workspace">
|
||
<SigningTaskList
|
||
:tasks="tasks"
|
||
:selected-id="selectedTaskId"
|
||
:total="total"
|
||
:loading="loading"
|
||
@select="selectTask"
|
||
/>
|
||
<SigningTaskDetail
|
||
: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"
|
||
@action="handleTaskAction"
|
||
@download-artifact="downloadArtifact"
|
||
/>
|
||
</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>
|