Files
xh-medical-sign-web/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue
T
2026-09-29 15:50:21 +08:00

680 lines
21 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 { computed, ref } from 'vue'
import type {
SigningAnswers,
SigningArtifact,
SigningMethod,
SigningTaskEvent,
SigningTaskRecord,
} from '@/api/workbench/types'
import {
SIGN_FIELD_LOCATE_CLASS,
SIGN_FIELD_LOCATE_MS,
type SigningFieldProgress,
type 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 LinkedPrescriptionCard from './LinkedPrescriptionCard.vue'
import SigningArtifactList from './SigningArtifactList.vue'
import type { SigningTaskAction } from '../types'
const props = defineProps<{
task: SigningTaskRecord | null
loading: boolean
auditEvents: SigningTaskEvent[]
auditLoading: boolean
auditError: boolean
allowLocalSigning: boolean
/** 真实模式下允许有签署权限的用户通过浏览器鼠标采集现场手写签名。 */
allowMouseSigning: boolean
artifacts: SigningArtifact[]
artifactsLoading: boolean
artifactsError: boolean
padSessionStatus: string | null
padSessionLoading: boolean
canSignTask: boolean
canReopen: boolean
canVoid: boolean
/** 科室复核签章(sign:task:confirm):只有「待科室确认」的任务用得上 */
canConfirm: boolean
/** 催办(sign:task:remind) */
canRemind: 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)
/**
* 科室确认节点的三种形态(原型文书签名区那一格的三分支):
* 待患者签署后 / 待科室确认 / ✓ 已确认。
*
* 只看任务状态,不另存一份「科室签没签」的布尔量 —— 两个来源迟早会打架。
*/
const confirmNodeState = computed<'idle' | 'waiting' | 'done'>(() => {
const status = props.task?.status
if (status === 'signed') {
return 'done'
}
return status === 'confirming' ? 'waiting' : 'idle'
})
/**
* 原型任务详情:主按钮 = 当前方式的签署入口,另外三种方式各给一个「转为 X」的次按钮。
* 原型的四种方式都走这一套(`待签署` 下四个 if/else 分支,主按钮与其余三个各不相同),
* 所以这里按**具体取值**分支,不做「现场 / 线上」归并。
*/
const ALTERNATE_METHODS: readonly SigningMethod[] = ['pad', 'sms', 'ipad', 'wx']
function alternateMethods(current: SigningMethod): SigningMethod[] {
return ALTERNATE_METHODS.filter((item) => item !== current)
}
/**
* 「其余三种方式」按钮各自打开哪个流程(原型 wbActs 里四个按钮的 onclick)。
*
* 原型这四个按钮**互为入口**:点「短信签署」是弹「发送签署短信」、
* 点「公众号签署」是弹「消息推送」、点「Pad 签署」是切换方式并把文书滚到签名位 ——
* 都不是「只把方式改一下」。
*/
function entryActionFor(target: SigningMethod): SigningTaskAction {
if (target === 'pad') {
return 'pad-sign'
}
if (target === 'ipad') {
return 'switch-to-ipad'
}
return target === 'wx' ? 'send-wx-push' : 'send-sms'
}
/** 「查看患者签署页」的按钮文案:原型两处写法不同(短信写 H5、公众号写患者签署页),照抄 */
function viewPatientPageLabel(method: SigningMethod): string {
return method === 'wx' ? '查看患者签署页(公众号)' : '查看线上签署(H5)'
}
/**
* 现场设备签署(手写板 / Pad)走本地采集入口;线上(短信 / 公众号)走患者端页面。
* 这只是**入口形态**的分组,不是取值归并 —— 按钮文案、状态标签仍按具体取值区分。
*/
function isOnsiteMethod(method: SigningMethod): boolean {
return method === 'pad' || method === 'ipad'
}
/**
* 线上方式(短信 / 公众号)的等待态提示(原型 renderPreview 里那条 `.tg-wait`)。
*
* 说的是「任务正等患者签」,这是任务状态本身能确认的事;
* 短信再把发送目标带出来 —— 号码取后端返回的**脱敏值**,
* 后端没给就只写「已发送」,不拿患者档案里的号码冒充发送目标。
*/
function waitingHint(task: SigningTaskRecord): string {
const { icon, short } = readSigningMethod(task.method)
if (task.method === 'wx') {
return `${icon} ${short}签署消息已推送 · 等待患者签署…`
}
const mobile = task.smsMobileMasked?.trim()
return `${icon} ${short}${mobile ? `已发送至 ${mobile}` : '已发送'} · 等待患者签署…`
}
/** 供父级「导出 PDF」使用:转发到实际渲染文书的那一层 */
function getDocumentElement() {
return documentRef.value?.getDocumentElement() ?? null
}
/**
* 「📲 Pad 签署」:把文书滚到第一个签名位并高亮(原型 `openIpad`)。
*
* 原型这一步**不弹签名板** —— 它只把签名位滚到眼前、闪一下、提示「点击签名框弹出签名板」,
* 由操作人自己在文书上点那个签名位。这样「Pad 签署」与「手写板签署」的差别才看得出来:
* 前者是在文书上定位后现场签,后者是直接开签名板。
*
* 返回是否找到了签名位(没找到由父级提示,原型也是 toast 报错)。
*/
function locateSignatureSlot(): boolean {
const root = documentRef.value?.getDocumentElement()
const slot = root?.querySelector<HTMLElement>('[data-sign-kind="signature"]')
if (!slot) {
return false
}
slot.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 重放动画:先摘类、强制 reflow、再加回去(原型用同一手法)
slot.classList.remove(SIGN_FIELD_LOCATE_CLASS)
void slot.offsetWidth
slot.classList.add(SIGN_FIELD_LOCATE_CLASS)
window.setTimeout(() => slot.classList.remove(SIGN_FIELD_LOCATE_CLASS), SIGN_FIELD_LOCATE_MS)
return true
}
defineExpose({ getDocumentElement, locateSignatureSlot })
</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'">
<!-- 主按钮 = 当前方式的签署入口(原型 wbActs 的四个分支):
手写板 → 直接开签名板;Pad → 把文书滚到签名位等高亮;短信 → 开「发送签署短信」;
公众号 → 开「消息推送」 -->
<template v-if="isOnsiteMethod(task.method)">
<template v-if="allowLocalSigning || allowMouseSigning">
<button
type="button"
class="action-button"
:disabled="padSessionLoading"
@click="emit('action', 'pad-sign')"
>
{{
padSessionLoading ? '正在创建签署会话…' : formatSigningMethodTitle(task.method)
}}
</button>
</template>
<template v-else-if="canSignTask">
<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>
<!-- 线上方式:消息已经发出去了,此刻等的是患者(原型 `.tg-wait`)。
所以主按钮不是「再发一次」,而是「查看患者那一页」;
再加一个演示用的「模拟患者完成签署」——
演示环境里没人真的去点短信链接,不给这个入口整条链路就走不完 -->
<template v-else>
<span class="action-wait">{{ waitingHint(task) }}</span>
<button
v-if="allowLocalSigning"
type="button"
class="action-button"
@click="emit('action', 'online-sign')"
>
{{ viewPatientPageLabel(task.method) }}
</button>
<span v-else class="action-hint">线上签署页面接口待接入</span>
<button
v-if="allowLocalSigning"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'simulate-patient-sign')"
>
✓ 模拟患者完成签署(演示)
</button>
<button
v-if="canRemind"
type="button"
class="action-button action-button--ghost"
@click="emit('action', task.method === 'wx' ? 'resend-wx-push' : 'resend-sms')"
>
{{ task.method === 'wx' ? '重发推送' : '重发短信' }}
</button>
</template>
<!-- 其余三种方式:**直接打开各自的流程**(原型四个按钮互为入口,不是「只切方式」)。
短信按钮先弹「发送签署短信」、公众号按钮先弹「消息推送」,
发送/推送成功后才把任务切成那个方式 —— 与原型 openSmsFlow / openWxPush 一致;
Pad 按钮 = 切换方式 + 把文书滚到签名位(原型 switchToIpad) -->
<button
v-for="target in alternateMethods(task.method)"
:key="target"
type="button"
class="action-button action-button--ghost"
@click="emit('action', entryActionFor(target))"
>
{{ readSigningMethod(target).buttonLabel }}
</button>
</template>
<!-- 待科室确认:患者已签完,此刻要动的是科室那一道章(原型 confirmDept 的动作组)。
预览与下载放在这里,是因为科室复核时要先看患者签了什么 -->
<template v-else-if="task.status === 'confirming'">
<button
v-if="canConfirm"
type="button"
class="action-button"
@click="emit('action', 'confirm-dept')"
>
🏛 科室复核签章(确认完成)
</button>
<button
v-if="canRemind"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'remind')"
>
催办
</button>
<button
v-if="canViewArtifacts"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'preview-signed')"
>
在线预览
</button>
<button
v-if="canDownloadArtifact"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'download-pdf')"
>
下载已签 PDF
</button>
</template>
<template v-else-if="task.status === 'expired' && canReopen">
<button type="button" class="action-button" @click="emit('action', 'reopen')">
↻ 重新发起
</button>
</template>
<!-- 已签署:预览是主入口(原型主按钮就是「在线预览(PDF)」),
下载类动作在后;只读副本带水印与审计编号,对外提供时用那一份 -->
<template v-else-if="task.status === 'signed'">
<button
v-if="canViewArtifacts"
type="button"
class="action-button"
@click="emit('action', 'preview-signed')"
>
在线预览(PDF)
</button>
<template v-if="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"
title="生成带水印与审计编号的副本,对外提供时使用"
@click="emit('action', 'download-readonly-copy')"
>
只读副本
</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>
</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' &&
task.status !== 'confirming'
"
: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" />
<!-- 科室确认节点(原型:文书签名区里的「科室确认(签章)」那一格)。
它跟着文书走,不跟产物走 —— 科室复核的是患者签了什么,不是下载了什么 -->
<section
v-if="task.needConfirm"
class="dept-confirm"
:class="`is-${confirmNodeState}`"
aria-label="科室确认节点"
>
<span class="dept-confirm__label">科室确认(签章)</span>
<span v-if="confirmNodeState === 'done'" class="dept-confirm__tag is-done">
✓ {{ task.department }}已确认
</span>
<span v-else-if="confirmNodeState === 'waiting'" class="dept-confirm__tag is-waiting">
待科室确认
</span>
<span v-else class="dept-confirm__hint">待患者签署后</span>
<span class="dept-confirm__note">
{{
confirmNodeState === 'done'
? '科室复核签章完成,文档已归档至本次诊疗记录'
: confirmNodeState === 'waiting'
? '患者已签署,等待科室复核签章后归档'
: '本文书需科室复核签章,患者签署后自动流转'
}}
</span>
</section>
<!-- 顺序照原型:文书 → 签署文件(归档结果)→ 关联处方 → 操作留痕 -->
<SigningArtifactList
v-if="canViewArtifacts"
:artifacts="artifacts"
:loading="artifactsLoading"
:error="artifactsError"
:can-download="canDownloadArtifact"
@download="emit('downloadArtifact', $event)"
/>
<LinkedPrescriptionCard :task="task" />
<TaskAuditTimeline
v-if="canViewAudit"
:events="auditEvents"
:loading="auditLoading"
:error="auditError"
/>
</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;
}
/* 等待态:原型 .tag.tg-wait(信息蓝),与旁边的主按钮区分开 */
.action-wait {
align-self: center;
padding: 5px 11px;
color: var(--info);
font-size: 12px;
font-weight: 700;
background: var(--info-l);
border-radius: 14px;
}
.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);
}
/* 科室确认节点:原型文书签名区里「科室确认(签章)」那一格的独立呈现。
三种形态用左边框颜色区分:待患者签署(灰)/ 待科室确认(橙)/ 已确认(绿) */
.dept-confirm {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 10px 14px;
margin-top: 14px;
background: var(--card);
border: 1px solid var(--line);
border-left: 3px solid #c0c8cd;
border-radius: var(--r);
box-shadow: var(--sh);
}
.dept-confirm.is-waiting {
border-left-color: #c07f00;
}
.dept-confirm.is-done {
border-left-color: var(--ok);
}
.dept-confirm__label {
color: var(--ink);
font-size: 13px;
font-weight: 700;
}
.dept-confirm__tag {
padding: 2px 9px;
font-size: 11px;
font-weight: 700;
border-radius: 12px;
}
/* 原型 .tag.tg-cfm */
.dept-confirm__tag.is-waiting {
color: #c07f00;
background: #fff3e0;
}
/* 原型 .tag.tg-done */
.dept-confirm__tag.is-done {
color: var(--ok);
background: var(--ok-l);
}
.dept-confirm__hint {
color: var(--mut);
font-size: 12px;
}
.dept-confirm__note {
flex: 1;
min-width: 180px;
color: var(--mut);
font-size: 11.5px;
text-align: right;
}
.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>