feat(workbench): 扩展签署任务管理功能

This commit is contained in:
yelan
2026-08-28 10:02:51 +08:00
parent b533ab2a58
commit 2de75ed993
14 changed files with 4287 additions and 37 deletions
@@ -1,12 +1,520 @@
<script setup lang="ts">
import PagePlaceholder from '@/components/PagePlaceholder.vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute } from 'vue-router'
import {
completeSigningTask,
getSigningTaskDetail,
getSigningTasks,
getSigningTemplates,
reopenSigningTask,
resendSigningSms,
updateSigningTaskMethod,
voidSigningTask,
} from '@/api/workbench/signing'
import type {
SigningDateRange,
SigningMethod,
SigningTaskRecord,
SigningTaskStatus,
SigningTemplate,
VisitType,
WorkbenchCampus,
} from '@/api/workbench/types'
import { useAppStore } from '@/stores/app'
import NewSigningTaskDialog from './components/NewSigningTaskDialog.vue'
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',
]
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
rejected: '已拒签',
expired: '已超时',
void: '已作废',
}
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 templates = ref<SigningTemplate[]>([])
const loading = ref(true)
const detailLoading = ref(false)
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 })),
])
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 loadTaskDetail(id: string | null) {
const requestId = ++detailRequestId
if (!id) {
selectedTask.value = null
detailLoading.value = false
return
}
detailLoading.value = true
try {
const detail = await getSigningTaskDetail(id)
if (requestId === detailRequestId) {
selectedTask.value = detail
}
} catch {
if (requestId === detailRequestId) {
selectedTask.value = null
ElMessage.error('签署任务详情加载失败,请稍后重试')
}
} finally {
if (requestId === detailRequestId) {
detailLoading.value = false
}
}
}
async function loadTasks(preferredTaskId?: string) {
const requestId = ++listRequestId
loading.value = true
try {
const response = await getSigningTasks({
...filter,
page: 1,
pageSize: 50,
})
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') {
signatureDialogVisible.value = true
return
}
if (action === 'online-sign') {
onlineDialogVisible.value = true
return
}
if (action === 'void') {
voidDialogVisible.value = true
return
}
if (action === 'download-pdf') {
ElMessage.info('原型演示:PDF 原件下载接口待接入')
return
}
if (action === 'download-signature') {
ElMessage.info('原型演示:签名原图下载接口待接入')
return
}
if (action === 'print') {
ElMessage.info('原型演示:打印服务待接入')
return
}
try {
if (action === 'switch-method') {
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), '短信已重新发送')
return
}
if (action === 'reopen') {
await refreshTask(await reopenSigningTask(task.id), '签署任务已重新发起')
}
} catch {
ElMessage.error('任务操作失败,请稍后重试')
}
}
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), '签署任务已作废')
voidDialogVisible.value = false
} catch {
ElMessage.error('任务作废失败,请稍后重试')
}
}
async function handleOnlineResend() {
await handleTaskAction('resend-sms')
}
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 Promise.all([loadTemplates(), loadTasks()])
})
</script>
<template>
<PagePlaceholder
group="工作台"
title="签署工作台"
icon="✍"
description="签署工作台页面占位,待根据 Web 端需求实现。"
/>
<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" @action="handleTaskAction" />
</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>