338 lines
8.9 KiB
Vue
338 lines
8.9 KiB
Vue
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
|
||
import type {
|
||
SigningAnswers,
|
||
SigningArtifact,
|
||
SigningTaskEvent,
|
||
SigningTaskRecord,
|
||
} from '@/api/workbench/types'
|
||
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
|
||
|
||
import SigningDocumentInteractive from './SigningDocumentInteractive.vue'
|
||
import SigningDocumentPreview from './SigningDocumentPreview.vue'
|
||
import SigningArtifactList from './SigningArtifactList.vue'
|
||
import TaskAuditTimeline from './TaskAuditTimeline.vue'
|
||
import type { SigningTaskAction } from '../types'
|
||
|
||
defineProps<{
|
||
task: SigningTaskRecord | null
|
||
loading: boolean
|
||
auditEvents: SigningTaskEvent[]
|
||
auditLoading: boolean
|
||
auditError: boolean
|
||
allowLocalSigning: boolean
|
||
artifacts: SigningArtifact[]
|
||
artifactsLoading: boolean
|
||
artifactsError: boolean
|
||
padSessionStatus: string | null
|
||
padSessionLoading: boolean
|
||
canDeliveryWrite: boolean
|
||
canReopen: boolean
|
||
canVoid: boolean
|
||
canViewAudit: boolean
|
||
canViewArtifacts: boolean
|
||
canDownloadArtifact: boolean
|
||
/** 本次会话在文书里点击采集到的签名图,由签署页持有并回灌 */
|
||
signatureInjections: SigningAnswers
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
action: [action: SigningTaskAction]
|
||
downloadArtifact: [artifact: SigningArtifact]
|
||
selectionChange: [items: string[]]
|
||
fieldChange: [answers: SigningAnswers, progress: SigningFieldProgress]
|
||
signatureRequest: [request: SigningSignatureRequest]
|
||
}>()
|
||
|
||
const documentRef = ref<InstanceType<typeof SigningDocumentInteractive> | null>(null)
|
||
|
||
/** 供父级「导出 PDF」使用:转发到实际渲染文书的那一层 */
|
||
function getDocumentElement() {
|
||
return documentRef.value?.getDocumentElement() ?? null
|
||
}
|
||
|
||
defineExpose({ getDocumentElement })
|
||
</script>
|
||
|
||
<template>
|
||
<section class="detail-panel">
|
||
<div v-if="loading" class="detail-loading" aria-label="正在加载任务详情">
|
||
<span class="detail-loading__actions" />
|
||
<span class="detail-loading__paper" />
|
||
</div>
|
||
|
||
<template v-else-if="task">
|
||
<div class="task-actions">
|
||
<span class="task-action-meta">任务 {{ task.id }} · {{ task.patientName }}</span>
|
||
|
||
<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>
|
||
<button
|
||
type="button"
|
||
class="action-button action-button--ghost"
|
||
@click="emit('action', 'switch-method')"
|
||
>
|
||
转为短信发送
|
||
</button>
|
||
</template>
|
||
<template v-else-if="canDeliveryWrite">
|
||
<button
|
||
type="button"
|
||
class="action-button"
|
||
:disabled="padSessionLoading"
|
||
@click="emit('action', 'create-pad-session')"
|
||
>
|
||
{{ padSessionLoading ? '创建会话中…' : '创建手写板会话' }}
|
||
</button>
|
||
<span v-if="padSessionStatus" class="action-hint">
|
||
会话状态:{{ padSessionStatus }};等待设备适配器读取 challenge
|
||
</span>
|
||
<span v-else class="action-hint">创建会话后仍需接入厂商设备适配器完成签名采集</span>
|
||
</template>
|
||
</template>
|
||
|
||
<template v-else-if="task.status === 'pending' && task.method === 'sms'">
|
||
<button
|
||
v-if="allowLocalSigning"
|
||
type="button"
|
||
class="action-button"
|
||
@click="emit('action', 'online-sign')"
|
||
>
|
||
📱 查看线上签署
|
||
</button>
|
||
<span v-else class="action-hint">线上签署页面接口待接入</span>
|
||
<button
|
||
v-if="allowLocalSigning"
|
||
type="button"
|
||
class="action-button action-button--ghost"
|
||
@click="emit('action', 'switch-method')"
|
||
>
|
||
转手写板签署
|
||
</button>
|
||
<button
|
||
v-if="canDeliveryWrite"
|
||
type="button"
|
||
class="action-button action-button--ghost"
|
||
@click="emit('action', 'resend-sms')"
|
||
>
|
||
重发短信
|
||
</button>
|
||
</template>
|
||
|
||
<template v-else-if="task.status === 'expired' && canReopen">
|
||
<button type="button" class="action-button" @click="emit('action', 'reopen')">
|
||
↻ 重新发起
|
||
</button>
|
||
</template>
|
||
|
||
<template v-else-if="task.status === 'signed' && canDownloadArtifact">
|
||
<button
|
||
type="button"
|
||
class="action-button action-button--ghost"
|
||
@click="emit('action', 'download-pdf')"
|
||
>
|
||
下载签署后 PDF
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="action-button action-button--ghost"
|
||
@click="emit('action', 'download-signature')"
|
||
>
|
||
签名原图
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="action-button action-button--ghost"
|
||
@click="emit('action', 'print')"
|
||
>
|
||
打印
|
||
</button>
|
||
</template>
|
||
|
||
<button
|
||
v-if="
|
||
canVoid &&
|
||
(task.status === 'pending' ||
|
||
task.status === 'expired' ||
|
||
task.status === 'signing' ||
|
||
task.status === 'failed')
|
||
"
|
||
type="button"
|
||
class="action-button action-button--danger"
|
||
@click="emit('action', 'void')"
|
||
>
|
||
作废
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 任务接口只返回模板版本哈希,正文由 API 层按哈希补回;
|
||
补不到时如实提示并给出占位版式,不退化成"看起来像真文书"的假纸样 -->
|
||
<SigningDocumentInteractive
|
||
v-if="task.documentContentHtml"
|
||
ref="documentRef"
|
||
:task="task"
|
||
:interactive="allowLocalSigning && task.status !== 'signed' && task.status !== 'void'"
|
||
:signature-injections="signatureInjections"
|
||
@selection-change="emit('selectionChange', $event)"
|
||
@field-change="(answers, progress) => emit('fieldChange', answers, progress)"
|
||
@signature-request="emit('signatureRequest', $event)"
|
||
/>
|
||
<SigningDocumentPreview v-else :task="task" />
|
||
<TaskAuditTimeline
|
||
v-if="canViewAudit"
|
||
:events="auditEvents"
|
||
:loading="auditLoading"
|
||
:error="auditError"
|
||
/>
|
||
<SigningArtifactList
|
||
v-if="canViewArtifacts"
|
||
:artifacts="artifacts"
|
||
:loading="artifactsLoading"
|
||
:error="artifactsError"
|
||
:can-download="canDownloadArtifact"
|
||
@download="emit('downloadArtifact', $event)"
|
||
/>
|
||
</template>
|
||
|
||
<div v-else class="detail-empty">
|
||
<span aria-hidden="true">📋</span>
|
||
<p>请从左侧列表选择一个签署任务</p>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.detail-panel {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.task-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.task-action-meta {
|
||
align-self: center;
|
||
margin-right: auto;
|
||
color: var(--mut);
|
||
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 {
|
||
flex-shrink: 0;
|
||
padding: 8px 16px;
|
||
color: #fff;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
background: var(--brand);
|
||
border-radius: 7px;
|
||
transition: background 0.15s ease;
|
||
}
|
||
|
||
.action-button:hover {
|
||
background: var(--brand-d);
|
||
}
|
||
|
||
.action-button--ghost {
|
||
color: var(--brand);
|
||
background: #fff;
|
||
border: 1px solid var(--brand);
|
||
}
|
||
|
||
.action-button--ghost:hover {
|
||
background: var(--brand-l);
|
||
}
|
||
|
||
.action-button--danger {
|
||
color: var(--err);
|
||
background: #fff;
|
||
border: 1px solid var(--err);
|
||
}
|
||
|
||
.action-button--danger:hover {
|
||
background: var(--err-l);
|
||
}
|
||
|
||
.detail-empty {
|
||
display: grid;
|
||
min-height: 560px;
|
||
padding: 40px 24px;
|
||
color: var(--mut);
|
||
text-align: center;
|
||
background: var(--card);
|
||
border-radius: var(--r);
|
||
box-shadow: var(--sh);
|
||
place-items: center;
|
||
align-content: center;
|
||
}
|
||
|
||
.detail-empty span {
|
||
margin-bottom: 8px;
|
||
font-size: 34px;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.detail-empty p {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.detail-loading {
|
||
display: flex;
|
||
min-height: 680px;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.detail-loading span {
|
||
display: block;
|
||
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
|
||
background-size: 400% 100%;
|
||
border-radius: var(--r);
|
||
box-shadow: var(--sh);
|
||
animation: skeleton-shimmer 1.4s ease infinite;
|
||
}
|
||
|
||
.detail-loading__actions {
|
||
height: 38px;
|
||
}
|
||
|
||
.detail-loading__paper {
|
||
flex: 1;
|
||
min-height: 560px;
|
||
}
|
||
|
||
@keyframes skeleton-shimmer {
|
||
0% {
|
||
background-position: 100% 0;
|
||
}
|
||
|
||
100% {
|
||
background-position: -100% 0;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 700px) {
|
||
.task-action-meta {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|