feat(signing): 接入工作台任务操作与审计

This commit is contained in:
yelan
2026-08-31 16:08:47 +08:00
parent 40b0a5d040
commit 9c1b2f53a6
4 changed files with 187 additions and 64 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SigningTaskRecord } from '@/api/workbench/types' import type { SigningTaskEvent, SigningTaskRecord } from '@/api/workbench/types'
import SigningDocumentPreview from './SigningDocumentPreview.vue' import SigningDocumentPreview from './SigningDocumentPreview.vue'
import TaskAuditTimeline from './TaskAuditTimeline.vue' import TaskAuditTimeline from './TaskAuditTimeline.vue'
@@ -8,6 +8,10 @@ import type { SigningTaskAction } from '../types'
defineProps<{ defineProps<{
task: SigningTaskRecord | null task: SigningTaskRecord | null
loading: boolean loading: boolean
auditEvents: SigningTaskEvent[]
auditLoading: boolean
auditError: boolean
allowLocalSigning: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -27,6 +31,7 @@ const emit = defineEmits<{
<span class="task-action-meta">任务 {{ task.id }} · {{ task.patientName }}</span> <span class="task-action-meta">任务 {{ task.id }} · {{ task.patientName }}</span>
<template v-if="task.status === 'pending' && task.method === 'pad'"> <template v-if="task.status === 'pending' && task.method === 'pad'">
<template v-if="allowLocalSigning">
<button type="button" class="action-button" @click="emit('action', 'pad-sign')"> <button type="button" class="action-button" @click="emit('action', 'pad-sign')">
手写板签署 手写板签署
</button> </button>
@@ -38,12 +43,21 @@ const emit = defineEmits<{
转为短信发送 转为短信发送
</button> </button>
</template> </template>
<span v-else class="action-hint">手写板设备签署接口待接入</span>
</template>
<template v-else-if="task.status === 'pending' && task.method === 'sms'"> <template v-else-if="task.status === 'pending' && task.method === 'sms'">
<button type="button" class="action-button" @click="emit('action', 'online-sign')"> <button
v-if="allowLocalSigning"
type="button"
class="action-button"
@click="emit('action', 'online-sign')"
>
📱 查看线上签署 📱 查看线上签署
</button> </button>
<span v-else class="action-hint">线上签署页面接口待接入</span>
<button <button
v-if="allowLocalSigning"
type="button" type="button"
class="action-button action-button--ghost" class="action-button action-button--ghost"
@click="emit('action', 'switch-method')" @click="emit('action', 'switch-method')"
@@ -59,7 +73,7 @@ const emit = defineEmits<{
</button> </button>
</template> </template>
<template v-else-if="task.status === 'expired'"> <template v-else-if="task.status === 'expired' || task.status === 'failed'">
<button type="button" class="action-button" @click="emit('action', 'reopen')"> <button type="button" class="action-button" @click="emit('action', 'reopen')">
重新发起 重新发起
</button> </button>
@@ -90,7 +104,12 @@ const emit = defineEmits<{
</template> </template>
<button <button
v-if="task.status === 'pending' || task.status === 'expired' || task.status === 'signing'" v-if="
task.status === 'pending' ||
task.status === 'expired' ||
task.status === 'signing' ||
task.status === 'failed'
"
type="button" type="button"
class="action-button action-button--danger" class="action-button action-button--danger"
@click="emit('action', 'void')" @click="emit('action', 'void')"
@@ -100,7 +119,7 @@ const emit = defineEmits<{
</div> </div>
<SigningDocumentPreview :task="task" /> <SigningDocumentPreview :task="task" />
<TaskAuditTimeline :task="task" /> <TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" />
</template> </template>
<div v-else class="detail-empty"> <div v-else class="detail-empty">
@@ -131,6 +150,15 @@ const emit = defineEmits<{
font-size: 12px; font-size: 12px;
} }
.action-hint {
padding: 8px 12px;
color: var(--mut);
font-size: 12px;
background: #f7fafb;
border: 1px dashed var(--line);
border-radius: 7px;
}
.action-button { .action-button {
flex-shrink: 0; flex-shrink: 0;
padding: 8px 16px; padding: 8px 16px;

View File

@@ -19,6 +19,7 @@ const statusLabels: Record<SigningTaskRecord['status'], string> = {
rejected: '已拒签', rejected: '已拒签',
expired: '已超时', expired: '已超时',
void: '已作废', void: '已作废',
failed: '处理失败',
} }
function getStatusClass(task: SigningTaskRecord) { function getStatusClass(task: SigningTaskRecord) {
@@ -217,6 +218,11 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
background: var(--err-l); background: var(--err-l);
} }
.task-status--failed {
color: #a15c19;
background: #fff3df;
}
.task-status--void { .task-status--void {
color: #8a9aa3; color: #8a9aa3;
background: #eceef0; background: #eceef0;

View File

@@ -1,49 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import type { SigningTaskEvent } from '@/api/workbench/types'
import type { SigningTaskRecord } from '@/api/workbench/types' defineProps<{
events: SigningTaskEvent[]
const props = defineProps<{ loading: boolean
task: SigningTaskRecord error: boolean
}>() }>()
const events = computed(() => {
const result = [
{
time: props.task.updatedAt,
text: `张文静发起签署任务(${props.task.method === 'pad' ? '手写板' : '线上短信'}`,
},
]
if (props.task.status === 'signing') {
result.push({ time: props.task.updatedAt, text: '已打开签名采集,等待患者完成签名' })
}
if (props.task.signedAt) {
result.push({
time: props.task.signedAt,
text: `患者 ${props.task.patientName} 完成签名,系统自动合成并双留存`,
})
}
if (props.task.status === 'void') {
result.push({
time: props.task.updatedAt,
text: `任务已作废${props.task.voidReason ? `,原因:${props.task.voidReason}` : ''},全程可审计`,
})
}
return result
})
</script> </script>
<template> <template>
<section class="audit-card"> <section class="audit-card">
<strong>操作留痕</strong> <strong>操作留痕</strong>
<ol class="audit-list">
<li v-for="event in events" :key="`${event.time}-${event.text}`"> <div v-if="loading" class="audit-state">正在加载操作留痕</div>
<div v-else-if="error" class="audit-state audit-state--error">
操作留痕加载失败请稍后刷新重试
</div>
<div v-else-if="!events.length" class="audit-state">暂无操作留痕</div>
<ol v-else class="audit-list">
<li v-for="event in events" :key="event.id">
<time>{{ event.time }}</time> <time>{{ event.time }}</time>
<span class="audit-event-content">
<span>{{ event.text }}</span> <span>{{ event.text }}</span>
<small v-if="event.attemptNo || event.reason">
<template v-if="event.attemptNo"> {{ event.attemptNo }} </template>
<template v-if="event.attemptNo && event.reason"> · </template>
<template v-if="event.reason">{{ event.reason }}</template>
</small>
</span>
</li> </li>
</ol> </ol>
</section> </section>
@@ -62,6 +47,16 @@ const events = computed(() => {
font-size: 12px; font-size: 12px;
} }
.audit-state {
padding: 8px 0 1px;
color: var(--mut);
font-size: 12px;
}
.audit-state--error {
color: var(--err);
}
.audit-list { .audit-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -83,4 +78,15 @@ const events = computed(() => {
flex-shrink: 0; flex-shrink: 0;
color: var(--mut); color: var(--mut);
} }
.audit-event-content {
display: inline-flex;
gap: 4px;
flex-wrap: wrap;
}
.audit-event-content small {
color: var(--mut);
font-size: 11px;
}
</style> </style>

View File

@@ -6,8 +6,10 @@ import { useRoute } from 'vue-router'
import { import {
completeSigningTask, completeSigningTask,
getSigningTaskDetail, getSigningTaskDetail,
getSigningTaskEvents,
getSigningTasks, getSigningTasks,
getSigningTemplates, getSigningTemplates,
isSigningMockEnabled,
reopenSigningTask, reopenSigningTask,
resendSigningSms, resendSigningSms,
updateSigningTaskMethod, updateSigningTaskMethod,
@@ -16,6 +18,7 @@ import {
import type { import type {
SigningDateRange, SigningDateRange,
SigningMethod, SigningMethod,
SigningTaskEvent,
SigningTaskRecord, SigningTaskRecord,
SigningTaskStatus, SigningTaskStatus,
SigningTemplate, SigningTemplate,
@@ -43,6 +46,7 @@ const STATUS_VALUES: SigningTaskStatus[] = [
'rejected', 'rejected',
'expired', 'expired',
'void', 'void',
'failed',
] ]
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all'] const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
@@ -53,6 +57,7 @@ const statusLabels: Record<SigningTaskStatus, string> = {
rejected: '已拒签', rejected: '已拒签',
expired: '已超时', expired: '已超时',
void: '已作废', void: '已作废',
failed: '处理失败',
} }
const filter = reactive<SigningFilterForm>({ const filter = reactive<SigningFilterForm>({
@@ -70,9 +75,12 @@ const tasks = ref<SigningTaskRecord[]>([])
const total = ref(0) const total = ref(0)
const selectedTaskId = ref<string | null>(null) const selectedTaskId = ref<string | null>(null)
const selectedTask = ref<SigningTaskRecord | null>(null) const selectedTask = ref<SigningTaskRecord | null>(null)
const taskEvents = ref<SigningTaskEvent[]>([])
const templates = ref<SigningTemplate[]>([]) const templates = ref<SigningTemplate[]>([])
const loading = ref(true) const loading = ref(true)
const detailLoading = ref(false) const detailLoading = ref(false)
const eventsLoading = ref(false)
const eventsError = ref(false)
const newDialogVisible = ref(false) const newDialogVisible = ref(false)
const signatureDialogVisible = ref(false) const signatureDialogVisible = ref(false)
const onlineDialogVisible = ref(false) const onlineDialogVisible = ref(false)
@@ -121,6 +129,11 @@ const documentOptions = computed<SigningFilterOption[]>(() => [
...templates.value.map((template) => ({ label: template.name, value: template.id })), ...templates.value.map((template) => ({ label: template.name, value: template.id })),
]) ])
const signingApiOptions = computed(() => ({
campus: appStore.selectedCampus,
templates: templates.value,
}))
function getQueryString(value: unknown) { function getQueryString(value: unknown) {
return typeof value === 'string' ? value : '' return typeof value === 'string' ? value : ''
} }
@@ -154,26 +167,47 @@ async function loadTaskDetail(id: string | null) {
if (!id) { if (!id) {
selectedTask.value = null selectedTask.value = null
taskEvents.value = []
detailLoading.value = false detailLoading.value = false
eventsLoading.value = false
eventsError.value = false
return return
} }
detailLoading.value = true detailLoading.value = true
eventsLoading.value = true
eventsError.value = false
taskEvents.value = []
try { try {
const detail = await getSigningTaskDetail(id) const [detailResult, eventsResult] = await Promise.allSettled([
getSigningTaskDetail(id, signingApiOptions.value),
getSigningTaskEvents(id),
])
if (requestId === detailRequestId) { if (requestId === detailRequestId) {
selectedTask.value = detail if (detailResult.status === 'rejected') {
throw detailResult.reason
}
selectedTask.value = detailResult.value
if (eventsResult.status === 'fulfilled') {
taskEvents.value = eventsResult.value
} else {
eventsError.value = true
}
} }
} catch { } catch {
if (requestId === detailRequestId) { if (requestId === detailRequestId) {
selectedTask.value = null selectedTask.value = null
taskEvents.value = []
ElMessage.error('签署任务详情加载失败,请稍后重试') ElMessage.error('签署任务详情加载失败,请稍后重试')
} }
} finally { } finally {
if (requestId === detailRequestId) { if (requestId === detailRequestId) {
detailLoading.value = false detailLoading.value = false
eventsLoading.value = false
} }
} }
} }
@@ -183,11 +217,14 @@ async function loadTasks(preferredTaskId?: string) {
loading.value = true loading.value = true
try { try {
const response = await getSigningTasks({ const response = await getSigningTasks(
{
...filter, ...filter,
page: 1, page: 1,
pageSize: 50, pageSize: 50,
}) },
signingApiOptions.value,
)
if (requestId !== listRequestId) { if (requestId !== listRequestId) {
return return
@@ -249,11 +286,21 @@ async function handleTaskAction(action: SigningTaskAction) {
} }
if (action === 'pad-sign') { if (action === 'pad-sign') {
if (!isSigningMockEnabled) {
ElMessage.info('手写板设备签署接口待接入')
return
}
signatureDialogVisible.value = true signatureDialogVisible.value = true
return return
} }
if (action === 'online-sign') { if (action === 'online-sign') {
if (!isSigningMockEnabled) {
ElMessage.info('线上签署页面接口待接入')
return
}
onlineDialogVisible.value = true onlineDialogVisible.value = true
return return
} }
@@ -280,6 +327,11 @@ async function handleTaskAction(action: SigningTaskAction) {
try { try {
if (action === 'switch-method') { if (action === 'switch-method') {
if (!isSigningMockEnabled) {
ElMessage.info('当前后端未提供切换签署方式接口,请作废后重新创建任务')
return
}
const method: SigningMethod = task.method === 'pad' ? 'sms' : 'pad' const method: SigningMethod = task.method === 'pad' ? 'sms' : 'pad'
await refreshTask( await refreshTask(
await updateSigningTaskMethod(task.id, method), await updateSigningTaskMethod(task.id, method),
@@ -289,12 +341,24 @@ async function handleTaskAction(action: SigningTaskAction) {
} }
if (action === 'resend-sms') { if (action === 'resend-sms') {
await refreshTask(await resendSigningSms(task.id), '短信已重新发送') await refreshTask(
await resendSigningSms(task.id, {
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'短信已重新发送',
)
return return
} }
if (action === 'reopen') { if (action === 'reopen') {
await refreshTask(await reopenSigningTask(task.id), '签署任务已重新发起') await refreshTask(
await reopenSigningTask(task.id, {
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'签署任务已重新发起',
)
} }
} catch { } catch {
ElMessage.error('任务操作失败,请稍后重试') ElMessage.error('任务操作失败,请稍后重试')
@@ -339,7 +403,13 @@ async function confirmVoidTask(reason: string) {
} }
try { try {
await refreshTask(await voidSigningTask(task.id, reason), '签署任务已作废') await refreshTask(
await voidSigningTask(task.id, reason, {
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'签署任务已作废',
)
voidDialogVisible.value = false voidDialogVisible.value = false
} catch { } catch {
ElMessage.error('任务作废失败,请稍后重试') ElMessage.error('任务作废失败,请稍后重试')
@@ -350,6 +420,11 @@ async function handleOnlineResend() {
await handleTaskAction('resend-sms') await handleTaskAction('resend-sms')
} }
async function initializePage() {
await loadTemplates()
await loadTasks()
}
async function handleTaskCreated(task: SigningTaskRecord) { async function handleTaskCreated(task: SigningTaskRecord) {
await loadTasks(task.id) await loadTasks(task.id)
} }
@@ -375,7 +450,7 @@ watch(
onMounted(() => { onMounted(() => {
syncFilterFromRoute() syncFilterFromRoute()
void Promise.all([loadTemplates(), loadTasks()]) void initializePage()
}) })
</script> </script>
@@ -412,7 +487,15 @@ onMounted(() => {
:loading="loading" :loading="loading"
@select="selectTask" @select="selectTask"
/> />
<SigningTaskDetail :task="selectedTask" :loading="detailLoading" @action="handleTaskAction" /> <SigningTaskDetail
:task="selectedTask"
:loading="detailLoading"
:audit-events="taskEvents"
:audit-loading="eventsLoading"
:audit-error="eventsError"
:allow-local-signing="isSigningMockEnabled"
@action="handleTaskAction"
/>
</div> </div>
<NewSigningTaskDialog <NewSigningTaskDialog