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

393 lines
11 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 { ref } from 'vue'
import type {
SigningAnswers,
SigningArtifact,
SigningMethod,
SigningTaskEvent,
SigningTaskRecord,
} from '@/api/workbench/types'
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
import { formatSigningMethodTitle, readSigningMethod } from '@/utils/signing-method'
import SigningDocumentInteractive from '@/components/signing/SigningDocumentInteractive.vue'
import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue'
import TaskAuditTimeline from '@/components/signing/TaskAuditTimeline.vue'
import SigningArtifactList from './SigningArtifactList.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, targetMethod?: SigningMethod]
downloadArtifact: [artifact: SigningArtifact]
selectionChange: [items: string[]]
fieldChange: [answers: SigningAnswers, progress: SigningFieldProgress]
signatureRequest: [request: SigningSignatureRequest]
}>()
const documentRef = ref<InstanceType<typeof SigningDocumentInteractive> | null>(null)
/**
* 原型任务详情:主按钮 = 当前方式的签署入口,另外三种方式各给一个「转为 X」的次按钮。
* 原型的四种方式都走这一套(`待签署` 下四个 if/else 分支,主按钮与其余三个各不相同),
* 所以这里按**具体取值**分支,不做「现场 / 线上」归并。
*/
const ALTERNATE_METHODS: readonly SigningMethod[] = ['pad', 'sms', 'ipad', 'wx']
function alternateMethods(current: SigningMethod): SigningMethod[] {
return ALTERNATE_METHODS.filter((item) => item !== current)
}
/**
* 现场设备签署(手写板 / Pad)走本地采集入口;线上(短信 / 公众号)走患者端页面。
* 这只是**入口形态**的分组,不是取值归并 —— 按钮文案、状态标签仍按具体取值区分。
*/
function isOnsiteMethod(method: SigningMethod): boolean {
return method === 'pad' || method === 'ipad'
}
/** 供父级「导出 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'">
<!-- 主按钮 = 当前方式的签署入口(原型:手写板/Pad 走现场设备,短信/公众号走线上页面) -->
<template v-if="isOnsiteMethod(task.method)">
<template v-if="allowLocalSigning">
<button type="button" class="action-button" @click="emit('action', 'pad-sign')">
{{ formatSigningMethodTitle(task.method) }}
</button>
</template>
<template v-else-if="canDeliveryWrite">
<button
type="button"
class="action-button"
:disabled="padSessionLoading"
@click="emit('action', 'create-pad-session')"
>
{{
padSessionLoading ? '创建会话中…' : `创建${readSigningMethod(task.method).short}会话`
}}
</button>
<span v-if="padSessionStatus" class="action-hint">
会话状态:{{ padSessionStatus }};等待设备适配器读取 challenge
</span>
<span v-else class="action-hint">创建会话后仍需接入厂商设备适配器完成签名采集</span>
</template>
</template>
<template v-else>
<button
v-if="allowLocalSigning"
type="button"
class="action-button"
@click="emit('action', 'online-sign')"
>
查看{{ readSigningMethod(task.method).short }}签署
</button>
<span v-else class="action-hint">线上签署页面接口待接入</span>
</template>
<!-- 其余三种方式:一键转换(原型四种方式互为切换入口) -->
<button
v-for="target in alternateMethods(task.method)"
:key="target"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'switch-method', target)"
>
{{ readSigningMethod(target).short }}签署
</button>
<button
v-if="canDeliveryWrite && task.method === 'sms'"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'resend-sms')"
>
重发短信
</button>
<span v-else-if="task.method === 'wx'" class="action-hint">公众号推送接口待接入</span>
</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>
<!-- 操作按钮常驻顶部,文书在自己的滚动区里滚:
长文书不再把整页顶下去,左栏与按钮也不会滚出视野 -->
<div class="detail-body">
<!-- 任务接口只返回模板版本哈希,正文由 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)"
/>
</div>
</template>
<div v-else class="detail-empty">
<span aria-hidden="true">📋</span>
<p>请从左侧列表选择一个签署任务</p>
</div>
</section>
</template>
<style scoped>
.detail-panel {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
}
/* 文书 + 留痕 + 产物的滚动区:flex 子项必须 min-height: 0 才肯被压到内容高度以下,
否则它会撑到内容高度,overflow 永远触发不了(与用户权限面板同一处理) */
.detail-body {
flex: 1;
min-height: 0;
padding-right: 3px;
overflow-y: auto;
}
.task-actions {
display: flex;
flex-shrink: 0;
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;
flex: 1;
min-height: 0;
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;
flex: 1;
min-height: 0;
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: 0;
}
@keyframes skeleton-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (max-width: 1200px) {
/* 窄屏堆叠后不再锁高度,恢复自然流式布局,由外层滚动 */
.detail-body {
flex: none;
overflow: visible;
}
.detail-empty,
.detail-loading {
min-height: 560px;
}
}
@media (max-width: 700px) {
.task-action-meta {
width: 100%;
}
}
</style>