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:
@@ -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}`,
|
||||
}))
|
||||
}
|
||||
@@ -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: '工作台发起',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user