feat(签署工作台): 补代签标签、关联处方卡、线上等待态与只读副本

按原型 V10.2 详情页补齐四块:

- 任务列表「代签」标签(原型 .vtag.chk 橙底):SigningTaskRecord 增加
  signerType / signerRelation,仅签署人非「本人」时显示。
  有意偏离:关系为空时不输出空括号(原型会渲染成「家属代签()」)。
- 「关联处方」可折叠卡(原型 rxCard):新建 api/workbench/prescriptions.ts
  统一处方来源,把弹窗里那份搬过来消除重复,并新增详情用的 7 列明细。
  原型两处处方口径本就不同(弹窗按就诊号哈希、详情按任务号派生),分开维护不合并。
  真实模式返回空 → 整卡不渲染,不显示空表(会被读成「系统查过了,确实没处方」)。
  顺带按原型调了详情卡片顺序:文书 → 签署文件 → 关联处方 → 操作留痕。
- 短信 / 公众号待签署的等待态(原型 .tg-wait):号码取后端 smsMobileMasked,
  没有值时不拼号码,不拿患者档案里的号码冒充发送目标。
- 已签署的「在线预览(PDF)」与「只读副本」:
  预览弹窗复用 SigningDocumentInteractive(只读态),保证预览 / 工作台 / 导出 PDF
  三处版式一致;只读副本走 signing-pdf 新增的 watermark 选项,
  在 html2canvas 克隆体上平铺斜排水印,审计编号直接用任务号。
  水印行数按克隆体 scrollHeight 算,写死行数会让长文书下半部分没水印。
This commit is contained in:
yelan
2026-09-26 19:45:40 +08:00
parent f871613abe
commit 82acc03442
11 changed files with 882 additions and 67 deletions
@@ -0,0 +1,215 @@
<script setup lang="ts">
/**
* 关联处方(原型 V10.2 的 rxCard)。
*
* 处方由 HIS 带出、按就诊号自动匹配,签署工作台只做展示:
* 默认收起,点表头展开 7 列明细(处方号 / 类别 / 药品名称 / 规格 / 数量 / 用法用量 / 状态)。
*
* **真实模式整卡不渲染**:后端没有处方接口,`readTaskPrescriptions` 会返回空数组。
* 这种情况下显示一张空表或一条"暂无数据"都比不显示更糟 ——
* 医务人员会以为系统查过了、确实没有处方,而实际是压根没查。
*/
import { computed, ref } from 'vue'
import { readTaskPrescriptions } from '@/api/workbench/prescriptions'
import type { SigningTaskRecord } from '@/api/workbench/types'
const props = defineProps<{
task: SigningTaskRecord
}>()
const expanded = ref(false)
const prescriptions = computed(() => readTaskPrescriptions(props.task))
</script>
<template>
<section v-if="prescriptions.length" class="rx-card" aria-labelledby="rx-title">
<button
type="button"
class="rx-head"
:class="{ 'is-open': expanded }"
:aria-expanded="expanded"
@click="expanded = !expanded"
>
<span class="rx-arrow" aria-hidden="true">▸</span>
<strong id="rx-title">关联处方</strong>
<span class="rx-count">{{ prescriptions.length }} 条处方</span>
<span class="rx-hint">本次就诊({{ task.visitNo }})开立 · 点击展开 / 收起</span>
<span class="rx-spacer" />
<span class="rx-note">处方保存 3 年 · 与知情同意签署记录相互关联</span>
</button>
<div v-show="expanded" class="rx-body">
<table class="rx-table">
<thead>
<tr>
<th>处方号</th>
<th>类别</th>
<th>药品名称</th>
<th>规格</th>
<th>数量</th>
<th>用法用量</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="item in prescriptions" :key="item.no">
<td class="rx-mono">{{ item.no }}</td>
<td>{{ item.category }}</td>
<td>{{ item.drug }}</td>
<td>{{ item.spec }}</td>
<td>{{ item.quantity }}</td>
<td class="rx-usage">{{ item.usage }}</td>
<td>
<span class="rx-status">{{ item.status }}</span>
</td>
</tr>
</tbody>
</table>
<p class="rx-footnote">
处方由 HIS 带出并按就诊号自动匹配;知情同意书签署完成后方可计费 / 取药,全过程留痕可审计。
</p>
</div>
</section>
</template>
<style scoped>
.rx-card {
padding: 12px 16px;
margin-top: 14px;
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--r);
box-shadow: var(--sh);
}
.rx-head {
display: flex;
width: 100%;
gap: 8px;
align-items: center;
color: var(--ink);
text-align: left;
user-select: none;
}
.rx-head strong {
font-size: 13px;
}
/* 展开时三角转 90°,与原型 .rx-head.open .ar 一致 */
.rx-arrow {
display: inline-block;
width: 14px;
flex-shrink: 0;
color: var(--mut);
font-size: 13px;
line-height: 1;
text-align: center;
transition: transform 0.15s ease;
}
.rx-head.is-open .rx-arrow {
transform: rotate(90deg);
}
.rx-count {
flex-shrink: 0;
padding: 2px 9px;
color: var(--brand);
font-size: 11px;
font-weight: 700;
background: var(--brand-l);
border-radius: 12px;
}
.rx-hint,
.rx-note {
color: var(--mut);
font-size: 11.5px;
}
.rx-note {
flex-shrink: 0;
}
.rx-spacer {
flex: 1;
}
.rx-body {
margin-top: 10px;
}
.rx-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.rx-table th {
padding: 8px 10px;
color: var(--mut);
font-size: 11.5px;
font-weight: 700;
text-align: left;
white-space: nowrap;
background: #f5f9fa;
border-bottom: 1px solid var(--line);
}
.rx-table td {
padding: 8px 10px;
color: var(--ink);
vertical-align: middle;
border-bottom: 1px solid #eef4f6;
}
.rx-table tbody tr:hover td {
background: #f7fbfc;
}
.rx-mono {
font-family: Consolas, monospace;
font-size: 11.5px;
white-space: nowrap;
}
.rx-usage {
color: var(--mut);
font-size: 12px;
}
.rx-status {
display: inline-block;
padding: 1px 7px;
color: var(--ok);
font-size: 11px;
font-weight: 700;
background: var(--ok-l);
border-radius: 10px;
}
.rx-footnote {
margin: 6px 0 0;
color: var(--mut);
font-size: 11.5px;
}
@media (max-width: 900px) {
.rx-note,
.rx-hint {
display: none;
}
/* 窄屏表格自己横向滚,不把整页撑宽 */
.rx-body {
overflow-x: auto;
}
.rx-table {
min-width: 640px;
}
}
</style>
@@ -0,0 +1,221 @@
<script setup lang="ts">
/**
* 已签署文书的在线预览(原型 openPdfView 的 mdPdfView)。
*
* 用的是**同一个**文书渲染组件(SigningDocumentInteractive,只读态),
* 而不是另写一套纸样:预览看到的版式必须与工作台里、与导出 PDF 的完全一致,
* 三处各写一份必然漂移。
*
* 底部两个出口对应原型结果卡上的「下载」「只读副本」:
* - 下载 PDF 原件:优先取服务端产物,没有产物时(演示环境)由前端现场生成;
* - 只读副本:前端加带审计编号的水印后导出,对外提供时用这份。
*/
import type { SigningTaskRecord } from '@/api/workbench/types'
import SigningDocumentInteractive from '@/components/signing/SigningDocumentInteractive.vue'
import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue'
defineProps<{
visible: boolean
task: SigningTaskRecord | null
/** 导出中:两个下载按钮一起禁用,避免重复点出多份 PDF */
exporting: boolean
}>()
const emit = defineEmits<{
close: []
download: []
'download-readonly-copy': []
}>()
</script>
<template>
<Teleport to="body">
<div v-if="visible && task" class="preview-mask" @click.self="emit('close')">
<section
class="preview-card"
role="dialog"
aria-modal="true"
aria-labelledby="signed-preview-title"
>
<header class="preview-header">
<strong id="signed-preview-title">在线预览(PDF)</strong>
<span class="preview-subtitle">
{{ task.documentName }} · {{ task.id }} · {{ task.patientName }}
</span>
<button type="button" class="preview-close" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<div class="preview-body">
<SigningDocumentInteractive
v-if="task.documentContentHtml"
:task="task"
:interactive="false"
/>
<SigningDocumentPreview v-else :task="task" />
</div>
<footer class="preview-footer">
<span class="preview-note">
正式文书以服务端生成的 PDF 原件为准;此处预览按任务固化的文书版本渲染。
</span>
<span class="preview-spacer" />
<button
type="button"
class="preview-button preview-button--ghost"
:disabled="exporting"
title="生成带水印与审计编号的副本,对外提供时使用"
@click="emit('download-readonly-copy')"
>
只读副本
</button>
<button
type="button"
class="preview-button"
:disabled="exporting"
@click="emit('download')"
>
{{ exporting ? '生成中…' : '下载 PDF 原件' }}
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.preview-mask {
position: fixed;
z-index: 60;
inset: 0;
display: flex;
padding: 18px;
background: rgb(9 40 52 / 45%);
}
.preview-card {
display: flex;
width: 900px;
max-width: 96vw;
height: calc(100vh - 36px);
min-height: 0;
flex-direction: column;
margin: auto;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
animation: preview-pop 0.18s ease;
}
.preview-header {
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: baseline;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.preview-header strong {
font-size: 15px;
}
.preview-subtitle {
overflow: hidden;
color: var(--mut);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-close {
flex-shrink: 0;
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
/* 唯一滚动区:文书在自己的框里滚,头尾常驻 */
.preview-body {
flex: 1;
min-height: 0;
padding: 14px;
background: #f2f6f8;
}
/* 让复用的文书外壳吃满弹窗高度,由它内部的 .sign-doc-body 滚动 */
.preview-body :deep(.sign-doc) {
height: 100%;
min-height: 0;
}
.preview-footer {
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
padding: 10px 18px;
color: var(--mut);
font-size: 12px;
background: #f8fbfc;
border-top: 1px solid var(--line);
border-radius: 0 0 14px 14px;
}
.preview-note {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-spacer {
flex: 1;
}
.preview-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.preview-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.preview-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.preview-button--ghost:hover:not(:disabled) {
background: var(--brand-l);
}
@keyframes preview-pop {
from {
opacity: 0;
transform: scale(0.97) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
@media (max-width: 680px) {
.preview-note {
display: none;
}
}
</style>
@@ -14,6 +14,7 @@ import { formatSigningMethodTitle, readSigningMethod } from '@/utils/signing-met
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'
@@ -68,6 +69,25 @@ 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
@@ -113,6 +133,8 @@ defineExpose({ getDocumentElement })
</template>
</template>
<template v-else>
<!-- 线上方式先给等待态:这条任务此刻等的是患者,不是操作人 -->
<span class="action-wait">{{ waitingHint(task) }}</span>
<button
v-if="allowLocalSigning"
type="button"
@@ -152,28 +174,48 @@ defineExpose({ getDocumentElement })
</button>
</template>
<template v-else-if="task.status === 'signed' && canDownloadArtifact">
<!-- 已签署:预览是主入口(原型主按钮就是「在线预览(PDF)」),
下载类动作在后;只读副本带水印与审计编号,对外提供时用那一份 -->
<template v-else-if="task.status === 'signed'">
<button
v-if="canViewArtifacts"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'download-pdf')"
class="action-button"
@click="emit('action', 'preview-signed')"
>
下载签署后 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')"
>
打印
在线预览(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
@@ -208,12 +250,7 @@ defineExpose({ getDocumentElement })
@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"
@@ -222,6 +259,13 @@ defineExpose({ getDocumentElement })
:can-download="canDownloadArtifact"
@download="emit('downloadArtifact', $event)"
/>
<LinkedPrescriptionCard :task="task" />
<TaskAuditTimeline
v-if="canViewAudit"
:events="auditEvents"
:loading="auditLoading"
:error="auditError"
/>
</div>
</template>
@@ -275,6 +319,17 @@ defineExpose({ getDocumentElement })
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;
@@ -47,6 +47,24 @@ function getPendingLabel(task: SigningTaskRecord) {
return readPendingTag(task.method).text
}
/**
* 代签标签(原型 `.vtag.chk`):只有签署人不是「本人」时才显示。
*
* 与原型的一处差别:关系为空时**不输出空括号**(原型会渲染成「家属代签()」),
* 空括号看着像渲染出错,也会让人以为关系字段丢了。
*/
function getSignerTag(task: SigningTaskRecord) {
const type = task.signerType?.trim()
if (!type || type === '本人') {
return ''
}
const relation = task.signerRelation?.trim()
return relation ? `${type}代签(${relation})` : `${type}代签`
}
function getVisitClass(type: SigningTaskRecord['visitType']) {
return type === '住院'
? 'visit-tag--hospital'
@@ -94,6 +112,7 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
<span>{{ task.patientId }}</span>
<span>{{ task.updatedAt }}</span>
<span>{{ getMethodLabel(task) }}</span>
<span v-if="getSignerTag(task)" class="signer-tag">{{ getSignerTag(task) }}</span>
</span>
<span class="task-row task-row--visit">
<span class="visit-tag" :class="getVisitClass(task.visitType)">{{ task.visitType }}</span>
@@ -289,6 +308,20 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
border-radius: 4px;
}
/* 代签标签:原型 .titem .vtag.chk 的橙底(#fff3e0 / #d9821f)。
它与方式标签并排出现,因此不能继承 .task-row--meta 的灰字色 */
.signer-tag {
display: inline-block;
padding: 1px 6px;
color: #d9821f;
font-size: 10px;
font-weight: 700;
line-height: 1.5;
white-space: nowrap;
background: #fff3e0;
border-radius: 4px;
}
.visit-tag--outpatient {
color: var(--ok);
background: var(--ok-l);
@@ -49,6 +49,7 @@ import { downloadSigningPdf } from '@/utils/signing-pdf'
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
import SignaturePadDialog from './components/SignaturePadDialog.vue'
import SignedDocumentDialog from './components/SignedDocumentDialog.vue'
import SigningFilterBar from './components/SigningFilterBar.vue'
import SigningTaskDetail from './components/SigningTaskDetail.vue'
import SigningTaskList from './components/SigningTaskList.vue'
@@ -128,6 +129,8 @@ const newDialogVisible = ref(false)
const signatureDialogVisible = ref(false)
const onlineDialogVisible = ref(false)
const voidDialogVisible = ref(false)
/** 已签署文书的在线预览弹窗(原型 openPdfView) */
const signedPreviewVisible = ref(false)
const taskDetailRef = ref<InstanceType<typeof SigningTaskDetail> | null>(null)
const pdfExporting = ref(false)
@@ -271,6 +274,8 @@ async function loadTaskDetail(id: string | null) {
fieldSignatureInjections.value = {}
pendingSignatureField.value = null
fieldSignatureDialogVisible.value = false
// 预览弹窗跟着任务走:换任务时留在上一份文书的预览上会让人以为看的是当前任务
signedPreviewVisible.value = false
if (!id) {
selectedTask.value = null
@@ -531,7 +536,11 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
(action === 'resend-sms' && canDeliveryWrite.value) ||
(action === 'reopen' && canReopenTask.value) ||
(action === 'void' && canVoidTask.value) ||
((action === 'download-pdf' || action === 'download-signature' || action === 'print') &&
(action === 'preview-signed' && canViewArtifacts.value) ||
((action === 'download-pdf' ||
action === 'download-signature' ||
action === 'download-readonly-copy' ||
action === 'print') &&
canDownloadArtifacts.value)
if (!allowed) {
@@ -619,6 +628,16 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
return
}
if (action === 'preview-signed') {
signedPreviewVisible.value = true
return
}
if (action === 'download-readonly-copy') {
await exportReadonlyCopy()
return
}
if (action === 'print') {
ElMessage.info('原型演示:打印服务待接入')
return
@@ -695,6 +714,43 @@ async function exportSignedPdf() {
}
}
/**
* 只读副本:在按任务固化版式渲染的结果上加「水印 + 审计编号」再导出。
*
* 与原件的区别是用途:原件是对内留存的正式凭证,只读副本是对外提供的那一份,
* 因此必须能一眼看出不是原件。水印直接烙进截图,改不掉文本层。
* 审计编号直接用任务号 —— 它是全链路可追溯的真实标识,
* 另外编一个前端生成的编号只会让追溯断在这里。
*/
async function exportReadonlyCopy() {
const task = selectedTask.value
const element = taskDetailRef.value?.getDocumentElement()
if (!task || !element) {
ElMessage.info('该任务没有固化的文书正文,无法生成只读副本')
return
}
if (pdfExporting.value) {
return
}
pdfExporting.value = true
try {
await downloadSigningPdf({
element,
fileName: `${task.documentName}-${task.id}-只读副本`,
watermark: `只读副本 · 审计编号 ${task.id}`,
})
ElMessage.success('只读副本已生成(含水印与审计编号)')
} catch {
ElMessage.error('只读副本生成失败,请稍后重试')
} finally {
pdfExporting.value = false
}
}
async function downloadArtifact(artifact: SigningArtifact) {
if (!canDownloadArtifacts.value) {
ElMessage.warning('当前账号没有下载签署文件的权限')
@@ -917,6 +973,14 @@ onMounted(() => {
@complete="completeOnlineSignature"
@resend="handleOnlineResend"
/>
<SignedDocumentDialog
:visible="signedPreviewVisible"
:task="selectedTask"
:exporting="pdfExporting"
@close="signedPreviewVisible = false"
@download="handleTaskAction('download-pdf')"
@download-readonly-copy="exportReadonlyCopy"
/>
<VoidTaskDialog
:visible="voidDialogVisible"
:task="selectedTask"
@@ -49,6 +49,10 @@ export type SigningTaskAction =
| 'void'
| 'download-pdf'
| 'download-signature'
/** 在线预览已签署文书(原型 openPdfView 的弹窗预览) */
| 'preview-signed'
/** 生成带水印与审计编号的只读副本(原型「只读副本」) */
| 'download-readonly-copy'
| 'print'
export type SigningPadSessionStatus = SignDeliveryStatus | null