From 82acc03442c60ee506f5e5dfbee6b37e90e914d3 Mon Sep 17 00:00:00 2001 From: yelan Date: Sat, 26 Sep 2026 19:45:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=AD=BE=E7=BD=B2=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0):=20=E8=A1=A5=E4=BB=A3=E7=AD=BE=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E3=80=81=E5=85=B3=E8=81=94=E5=A4=84=E6=96=B9=E5=8D=A1=E3=80=81?= =?UTF-8?q?=E7=BA=BF=E4=B8=8A=E7=AD=89=E5=BE=85=E6=80=81=E4=B8=8E=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E5=89=AF=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按原型 V10.2 详情页补齐四块: - 任务列表「代签」标签(原型 .vtag.chk 橙底):SigningTaskRecord 增加 signerType / signerRelation,仅签署人非「本人」时显示。 有意偏离:关系为空时不输出空括号(原型会渲染成「家属代签()」)。 - 「关联处方」可折叠卡(原型 rxCard):新建 api/workbench/prescriptions.ts 统一处方来源,把弹窗里那份搬过来消除重复,并新增详情用的 7 列明细。 原型两处处方口径本就不同(弹窗按就诊号哈希、详情按任务号派生),分开维护不合并。 真实模式返回空 → 整卡不渲染,不显示空表(会被读成「系统查过了,确实没处方」)。 顺带按原型调了详情卡片顺序:文书 → 签署文件 → 关联处方 → 操作留痕。 - 短信 / 公众号待签署的等待态(原型 .tg-wait):号码取后端 smsMobileMasked, 没有值时不拼号码,不拿患者档案里的号码冒充发送目标。 - 已签署的「在线预览(PDF)」与「只读副本」: 预览弹窗复用 SigningDocumentInteractive(只读态),保证预览 / 工作台 / 导出 PDF 三处版式一致;只读副本走 signing-pdf 新增的 watermark 选项, 在 html2canvas 克隆体上平铺斜排水印,审计编号直接用任务号。 水印行数按克隆体 scrollHeight 算,写死行数会让长文书下半部分没水印。 --- .../src/api/workbench/prescriptions.ts | 142 +++++++++++ clinical-web/src/api/workbench/signing.ts | 17 ++ clinical-web/src/api/workbench/types.ts | 25 ++ .../src/composables/useSigningTaskForm.ts | 52 +---- clinical-web/src/utils/signing-pdf.ts | 71 ++++++ .../components/LinkedPrescriptionCard.vue | 215 +++++++++++++++++ .../components/SignedDocumentDialog.vue | 221 ++++++++++++++++++ .../signing/components/SigningTaskDetail.vue | 103 ++++++-- .../signing/components/SigningTaskList.vue | 33 +++ .../src/views/workbench/signing/index.vue | 66 +++++- .../src/views/workbench/signing/types.ts | 4 + 11 files changed, 882 insertions(+), 67 deletions(-) create mode 100644 clinical-web/src/api/workbench/prescriptions.ts create mode 100644 clinical-web/src/views/workbench/signing/components/LinkedPrescriptionCard.vue create mode 100644 clinical-web/src/views/workbench/signing/components/SignedDocumentDialog.vue diff --git a/clinical-web/src/api/workbench/prescriptions.ts b/clinical-web/src/api/workbench/prescriptions.ts new file mode 100644 index 0000000..99c6c31 --- /dev/null +++ b/clinical-web/src/api/workbench/prescriptions.ts @@ -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> = [ + { + 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 | 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}`, + })) +} diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index 9eb35e8..f421d71 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -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: '工作台发起', diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index 1b8ed60..39b5bf2 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -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 diff --git a/clinical-web/src/composables/useSigningTaskForm.ts b/clinical-web/src/composables/useSigningTaskForm.ts index a4212f1..c3d92d4 100644 --- a/clinical-web/src/composables/useSigningTaskForm.ts +++ b/clinical-web/src/composables/useSigningTaskForm.ts @@ -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(() => - isSigningMockEnabled ? buildPrescription(selectedVisit.value) : null, + isSigningMockEnabled ? buildLinkedPrescription(selectedVisit.value) : null, ) /** diff --git a/clinical-web/src/utils/signing-pdf.ts b/clinical-web/src/utils/signing-pdf.ts index 1d04873..8497a86 100644 --- a/clinical-web/src/utils/signing-pdf.ts +++ b/clinical-web/src/utils/signing-pdf.ts @@ -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 { clonedElement.style.margin = '0' clonedElement.style.background = '#ffffff' + // 水印在量页眉之前注入:它是绝对定位层,不参与布局,不会影响页眉高度 + if (input.watermark) { + injectWatermark(clonedElement, input.watermark) + } + // 文书外壳(.sign-doc)在页面里是定高滚动卡片:overflow: hidden 会把超出一屏的内容裁掉, // 纸张阴影与圆角也不该出现在 PDF 页面上,这里一并抹平 const paper = clonedElement.closest('.sign-doc') diff --git a/clinical-web/src/views/workbench/signing/components/LinkedPrescriptionCard.vue b/clinical-web/src/views/workbench/signing/components/LinkedPrescriptionCard.vue new file mode 100644 index 0000000..e91a7de --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/LinkedPrescriptionCard.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/components/SignedDocumentDialog.vue b/clinical-web/src/views/workbench/signing/components/SignedDocumentDialog.vue new file mode 100644 index 0000000..ed5b846 --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/SignedDocumentDialog.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue index 8466fa6..8e4f3f6 100644 --- a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue +++ b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue @@ -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 }) - @@ -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; diff --git a/clinical-web/src/views/workbench/signing/components/SigningTaskList.vue b/clinical-web/src/views/workbench/signing/components/SigningTaskList.vue index 2070f0a..33bd78d 100644 --- a/clinical-web/src/views/workbench/signing/components/SigningTaskList.vue +++ b/clinical-web/src/views/workbench/signing/components/SigningTaskList.vue @@ -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']) { {{ task.patientId }} {{ task.updatedAt }} {{ getMethodLabel(task) }} + {{ getSignerTag(task) }} {{ task.visitType }} @@ -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); diff --git a/clinical-web/src/views/workbench/signing/index.vue b/clinical-web/src/views/workbench/signing/index.vue index 2c1fdf1..2c21af7 100644 --- a/clinical-web/src/views/workbench/signing/index.vue +++ b/clinical-web/src/views/workbench/signing/index.vue @@ -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 | 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" /> +