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,142 @@
/**
* 关联处方(演示数据)。
*
* 原型 V10.2 里「关联处方」出现在两处,**两处口径本来就不一样,不要合并**:
*
* ① 新增签署任务弹窗 B 区(原型 `rxOfVisit(v)`):一行摘要,按**就诊号**哈希
* 从 `RX_POOL` 里取药,同一就诊号每次打开必须带出同一条;
* ② 签署工作台详情(原型 `taskRxs(t)`):一张 7 列的明细表,按**任务号**派生处方号,
* 三条固定药品(麻精类,与麻精知情同意书配套)。
*
* 后端没有处方接口(处方由 HIS 带出),因此两份都只在演示模式下生成;
* 真实模式一律返回空,**绝不凭空造一条看起来真实的处方**——
* 那会让医务人员以为系统真的从 HIS 拿到了这张处方。
*/
import type { PatientVisit, SigningTaskRecord } from './types'
import { isSigningMockEnabled } from './signing'
/**
* 演示处方池。原型 RX_POOL 逐条对齐。
* 只在弹窗摘要里用;工作台明细表用的是下面的 `TASK_PRESCRIPTION_ROWS`。
*/
const PRESCRIPTION_POOL = [
{ item: '碘佛醇注射液 100ml', usage: '静脉注射 · 检查用' },
{ item: '阿莫西林胶囊 0.25g×24', usage: '口服 每日3次' },
{ item: '布洛芬缓释胶囊 0.3g×20', usage: '口服 每日2次' },
{ item: '奥美拉唑肠溶片 20mg×14', usage: '口服 每日1次 · 晨服' },
{ item: '0.9%氯化钠注射液 250ml', usage: '静脉滴注 · 检查前水化' },
]
/** 弹窗摘要形态的关联处方(原型 rxOfVisit 的 `{no, desc}`) */
export interface LinkedPrescription {
no: string
description: string
}
/** 工作台明细表形态的处方行(原型 rxCard 表格的 7 列) */
export interface TaskPrescription {
/** 处方号 */
no: string
/** 处方类别,如「门诊处方」「麻醉处方」 */
category: string
/** 药品名称 */
drug: string
/** 规格 */
spec: string
/** 数量 */
quantity: string
/** 用法用量 */
usage: string
/** 状态,如「已开立」 */
status: string
}
/** 就诊号 → 稳定哈希(原型 rxOfVisit 的 seed 算法,改了口径两处会对不上) */
function hashVisitNo(visitNo: string): number {
let seed = 0
for (let index = 0; index < visitNo.length; index += 1) {
seed = (seed * 31 + visitNo.charCodeAt(index)) % 9973
}
return seed
}
/**
* 按就诊号确定性生成演示处方摘要(原型 rxOfVisit)。
*
* 用哈希而不是随机:同一就诊号每次打开弹窗必须带出同一条处方,
* 否则「关联处方」会随刷新变来变去,演示时没法解释。
*/
export function buildLinkedPrescription(visit: PatientVisit | null): LinkedPrescription | null {
if (!visit) {
return null
}
const seed = hashVisitNo(visit.visitNo)
const first = PRESCRIPTION_POOL[seed % PRESCRIPTION_POOL.length]
const second = PRESCRIPTION_POOL[(seed + 2) % PRESCRIPTION_POOL.length]
const extra = seed % 3 === 0 ? `;${second.item}(${second.usage})` : ''
return {
no: `Rx${visit.visitNo.replace(/\D/g, '').slice(-8)}`,
description: `${first.item}(${first.usage})${extra}`,
}
}
/**
* 演示处方明细(原型 taskRxs 的三条麻精类药品)。
*
* 处方号 = `Rx` + 任务号里的数字后 6 位,再拼 `-1/-2/-3`;
* 任务号里没有数字时拼出来会是 `Rx-1` 这种残缺编号,
* 这时退回用任务号本身做尾缀,保证每条处方的编号可读且唯一。
*/
const TASK_PRESCRIPTION_ROWS: ReadonlyArray<Omit<TaskPrescription, 'no'>> = [
{
category: '门诊处方',
drug: '盐酸羟考酮缓释片(精一)',
spec: '5mg×10片/盒',
quantity: '1盒',
usage: '口服,每12小时一次,整片吞服,遵医嘱',
status: '已开立',
},
{
category: '门诊处方',
drug: '地佐辛注射液',
spec: '5mg:1ml×5支/盒',
quantity: '2支',
usage: '肌内注射,必要时,单次不超过10mg',
status: '已开立',
},
{
category: '麻醉处方',
drug: '磷酸可待因片(精二)',
spec: '30mg×20片/瓶',
quantity: '1瓶',
usage: '口服,每次1片,每日3次(精神药品不得自开自用)',
status: '已开立',
},
]
/**
* 读取某个签署任务的关联处方。
*
* 真实模式返回空数组:处方由 HIS 带出,前端没有接口就拿不到,
* 详情页据此整卡不渲染(而不是显示一张空表)。
*/
export function readTaskPrescriptions(
task: Pick<SigningTaskRecord, 'id' | 'visitNo'> | null,
): TaskPrescription[] {
if (!task || !isSigningMockEnabled) {
return []
}
const digits = task.id.replace(/\D/g, '').slice(-6)
const suffix = digits || task.id.replace(/\W/g, '').slice(-6) || '0'
return TASK_PRESCRIPTION_ROWS.map((row, index) => ({
...row,
no: `Rx${suffix}-${index + 1}`,
}))
}
+17
View File
@@ -188,7 +188,12 @@ const defaultMockRecords: SigningTaskRecord[] = [
department: '放射科',
status: 'signed',
signerName: '王某某家属',
// 代签样本:列表上要能看到「家属代签(配偶)」标签,
// 否则这条原型口径在演示环境里没有任何样本可验
signerType: '家属',
signerRelation: '配偶',
method: 'sms',
smsMobileMasked: '139****3321',
source: '工作台发起',
expiresAt: '2026-08-29 18:00',
updatedAt: '2026-08-27 09:12',
@@ -210,7 +215,10 @@ const defaultMockRecords: SigningTaskRecord[] = [
department: '消化内科',
status: 'expired',
signerName: '赵某某家属',
signerType: '家属',
signerRelation: '子女',
method: 'sms',
smsMobileMasked: '137****8845',
source: '电子病历',
expiresAt: '2026-08-27 16:00',
updatedAt: '2026-08-27 08:45',
@@ -321,6 +329,9 @@ const defaultMockRecords: SigningTaskRecord[] = [
status: 'pending',
signerName: '王某某',
method: 'sms',
// 待签署的短信任务:详情里要显示「已发送至 139****3321 · 等待患者签署…」,
// 号码取后端返回的脱敏值,没有就不拼号码
smsMobileMasked: '139****3321',
source: '工作台发起',
expiresAt: '2026-08-31 18:00',
updatedAt: '2026-08-27 09:50',
@@ -712,7 +723,11 @@ function mapSigningTask(
department: dto.visitSnapshot.departmentName || '未指定科室',
departmentId: dto.departmentId,
signerName: dto.patientSnapshot.name,
// 签署人类型 / 关系:后端补齐后自动带出,取不到即 undefined,列表不显示代签标签
signerType: dto.signerType ?? undefined,
signerRelation: dto.signerRelation ?? undefined,
method: normalizeSigningMethod(dto.signMethod),
smsMobileMasked: dto.smsMobileMasked ?? undefined,
status: normalizeTaskStatus(dto.status),
source: dto.visitSnapshot.sourceSystem || dto.patientSnapshot.sourceSystem || '—',
expiresAt: formatApiDateTime(dto.expiredAt),
@@ -1170,6 +1185,8 @@ export async function createSigningTask(input: CreateSigningTaskInput): Promise<
documentName: input.documentName,
department: input.department,
signerName: patient?.name ?? '演示患者',
// 原型新建任务固定落「本人」(发起弹窗没有代签人选择,代签是签署现场才知道的事)
signerType: '本人',
method: input.method,
status: 'pending',
source: '工作台发起',
+25
View File
@@ -246,7 +246,26 @@ export interface SigningTaskRecord extends WorkbenchTask {
documentId: string
departmentId?: string | null
signerName: string
/**
* 签署人类型与「与患者的关系」(原型 V10.2 的 signerType / relation)。
*
* `'本人'` 表示患者自签;`'监护人'` / `'家属'` / `'代办人'` 等表示代签,
* 任务列表要把它标出来——代签与自签在合规上是两件事,
* 事后追责时第一个要看的就是「谁签的、凭什么签」。
* 关系文案(如「母亲」)单独存,不拼进类型里。
*
* 当前后端任务接口不返回,取不到就是 undefined,列表不渲染代签标签。
*/
signerType?: string | null
signerRelation?: string | null
method: SigningMethod
/**
* 短信发送到的号码,后端只返回脱敏值(如 `138****5678`)。
*
* 只用于「已发送至 xxx · 等待患者签署…」这类提示;**没有值时不拼号码**,
* 不拿患者档案里的号码冒充发送目标——那会让人以为短信确实发到了那个号。
*/
smsMobileMasked?: string | null
source: string
expiresAt: string
createdAt?: string
@@ -449,6 +468,12 @@ export interface SignTaskResponseDto {
templateContentSha256: string
templateFileSha256: string
smsMobileMasked: string | null
/**
* 签署人类型与关系(原型 signerType / relation)。
* 后端补齐后前端无需改动即可显示;当前取不到时为 undefined。
*/
signerType?: string | null
signerRelation?: string | null
signedAt: string | null
expiredAt: string | null
voidedAt: string | null
@@ -2,6 +2,10 @@ import { computed, reactive, ref, toValue, type MaybeRefOrGetter } from 'vue'
import { ElMessage } from 'element-plus'
import { sendSigningSms } from '@/api/workbench/deliveries'
import {
buildLinkedPrescription,
type LinkedPrescription,
} from '@/api/workbench/prescriptions'
import { collectFieldDefs } from '@/utils/signing-fields'
import {
createSigningTask,
@@ -40,50 +44,14 @@ export interface DocumentListRow {
breadcrumb: string
}
/** 关联处方(原型 V6.4:按就诊号确定性生成,仅演示数据) */
export interface LinkedPrescription {
no: string
description: string
}
/**
* 演示处方池。原型 RX_POOL 逐条对齐。
* 真实模式没有处方接口,因此只在 Mock 模式下生成,绝不凭空造一条看起来真实的处方。
*/
const PRESCRIPTION_POOL = [
{ item: '碘佛醇注射液 100ml', usage: '静脉注射 · 检查用' },
{ item: '阿莫西林胶囊 0.25g×24', usage: '口服 每日3次' },
{ item: '布洛芬缓释胶囊 0.3g×20', usage: '口服 每日2次' },
{ item: '奥美拉唑肠溶片 20mg×14', usage: '口服 每日1次 · 晨服' },
{ item: '0.9%氯化钠注射液 250ml', usage: '静脉滴注 · 检查前水化' },
]
/**
* 按就诊号确定性生成演示处方。
* 关联处方摘要(原型 V6.4 的 rxOfVisit)。
*
* 用哈希而不是随机:同一就诊号每次打开弹窗必须带出同一条处方,
* 否则「关联处方」会随刷新变来变去,演示时没法解释。
* 演示数据与生成口径已挪到 api/workbench/prescriptions,那里同时供
* 签署工作台详情的「关联处方」明细表使用——两处口径不同但同一份药品池,
* 各写一份必然漂移。这里只做转出,保持既有导入路径不变。
*/
function buildPrescription(visit: PatientVisit | null): LinkedPrescription | null {
if (!visit) {
return null
}
let seed = 0
for (let index = 0; index < visit.visitNo.length; index += 1) {
seed = (seed * 31 + visit.visitNo.charCodeAt(index)) % 9973
}
const first = PRESCRIPTION_POOL[seed % PRESCRIPTION_POOL.length]
const second = PRESCRIPTION_POOL[(seed + 2) % PRESCRIPTION_POOL.length]
const extra = seed % 3 === 0 ? `;${second.item}(${second.usage})` : ''
return {
no: `Rx${visit.visitNo.replace(/\D/g, '').slice(-8)}`,
description: `${first.item}(${first.usage})${extra}`,
}
}
export type { LinkedPrescription }
export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
const patientId = ref('')
@@ -128,7 +96,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
/** 关联处方:随选中就诊带出(仅演示数据) */
const prescription = computed<LinkedPrescription | null>(() =>
isSigningMockEnabled ? buildPrescription(selectedVisit.value) : null,
isSigningMockEnabled ? buildLinkedPrescription(selectedVisit.value) : null,
)
/**
+71
View File
@@ -10,6 +10,8 @@
* 画中文页码需要内嵌字体(体积会明显变大),这一步留给服务端用真字体实现;
* ④ 导出前对 flex 容器做一次兼容降级:html2canvas 不实现 flex 布局,
* 填写项胶囊里的文字、输入框取值、签名图会被整块丢掉,见 applyExportFlexFallback。
* ⑤ 「只读副本」的水印在克隆体上平铺(injectWatermark):屏幕上那份文书不出现水印,
* 只有导出的 PDF 带;水印烙进截图,每一页都有,且改不掉文本层。
*
* 输出为位图 PDF:文本不可选中,仅供原型演示;正式文书以服务端生成的 PDF 为准。
*/
@@ -36,6 +38,70 @@ export interface SigningPdfInput {
element: HTMLElement
/** 文件名,不带扩展名 */
fileName: string
/**
* 只读副本水印文案。
*
* 只有「只读副本」这类对外提供、不可作为正式凭证的副本才传;
* 正式文书(PDF 原件)不加水印,传空即原样导出。
* 水印直接烙进截图,因此每一页都会带上,且无法通过编辑 PDF 文本层去掉。
*/
watermark?: string
}
/** 水印平铺的列数:5 列按 20% 步进,再左移 10% 让斜排文字盖住左右两侧 */
const WATERMARK_COLUMNS = 5
/** 水印行距(px):比文字高度大,斜排后不重叠 */
const WATERMARK_ROW_GAP_PX = 90
/**
* 往克隆体里平铺斜向水印。
*
* 必须在 `onclone` 里做:屏幕上那份文书不能出现水印,
* 而 html2canvas 只截克隆体,所以改克隆体既不影响界面也能进 PDF。
*
* 行数按克隆体的实际高度算(`scrollHeight` 会强制一次重排,这里正是需要的时机),
* 写死行数会让长文书的下半部分没有水印。
*/
function injectWatermark(root: HTMLElement, text: string) {
const height = Math.max(root.scrollHeight, root.clientHeight, 1)
const rows = Math.ceil(height / WATERMARK_ROW_GAP_PX) + 2
// 绝对定位的水印层要有定位祖先;容器自身多半是 static,
// 这里只改克隆体,屏幕上的版式不受影响
root.style.position = 'relative'
const overlay = document.createElement('div')
overlay.style.cssText = [
'position:absolute',
'inset:0',
'z-index:9',
'overflow:hidden',
'pointer-events:none',
].join(';')
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < WATERMARK_COLUMNS; column += 1) {
const item = document.createElement('span')
item.textContent = text
item.style.cssText = [
'position:absolute',
'display:block',
'width:280px',
'color:rgba(14,110,140,0.13)',
'font-size:14px',
'font-weight:700',
'letter-spacing:2px',
'white-space:nowrap',
`left:${column * 20 - 10}%`,
`top:${row * WATERMARK_ROW_GAP_PX + 30}px`,
'transform:rotate(-28deg)',
'transform-origin:left top',
].join(';')
overlay.appendChild(item)
}
}
root.appendChild(overlay)
}
/**
@@ -112,6 +178,11 @@ export async function createSigningPdf(input: SigningPdfInput): Promise<Blob> {
clonedElement.style.margin = '0'
clonedElement.style.background = '#ffffff'
// 水印在量页眉之前注入:它是绝对定位层,不参与布局,不会影响页眉高度
if (input.watermark) {
injectWatermark(clonedElement, input.watermark)
}
// 文书外壳(.sign-doc)在页面里是定高滚动卡片:overflow: hidden 会把超出一屏的内容裁掉,
// 纸张阴影与圆角也不该出现在 PDF 页面上,这里一并抹平
const paper = clonedElement.closest<HTMLElement>('.sign-doc')
@@ -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