refactor(文书预览): 抽出统一的文书纸与预览弹窗,消除五处重复渲染

预览原先在 5 个地方各写一遍(文书库预览、编辑器签署预览、发起签署弹窗预览、
工作台在线预览、工作台文书区),基础排版已经分叉:表格边框 #52646e 与 #c8d7dd
两套色、单元格内边距 5px 7px 与 5px 9px 两个值、只有两处带宋体。
原型本身是「一处渲染(.paper + fillDocHtml)、两种外框」,这次按那个形状收口。

新增
- utils/document-paper.ts:基础排版的**唯一一份** CSS(p / h1-h4 / 表格 / 宋体)。
  运行期幂等注入 head —— 因为同一份纸有两条渲染通道:v-html(吃全局样式)与
  iframe srcdoc(隔离模板自带 style,只能吃字符串),各写一份必然漂移。
- components/document/DocumentPaper.vue:唯一的纸渲染器。
  signing / raw 两模式、interactive、取值还原、☑☐ 就地切换、签名位请求采集、
  填写项与勾选上报、getDocumentElement()(供导出 PDF)。
- components/document/DocumentPreviewDialog.vue:唯一的预览外框。
  标题/副标题/徽标、可选页签、加载与错误态、纸舞台(浅色卡片 / 深灰 A4)、
  #footer 与 #empty 插槽。
- styles/document-preview.css:页脚按钮等**插槽内容**要用的类。
  插槽节点带的是父组件 scope id,弹窗的 scoped 样式命中不了 —— 与
  signing-document.css 同一类问题,同一套解法。

迁移
- 文书库预览、编辑器签署预览、发起签署弹窗预览、工作台在线预览全部改走统一组件;
  SigningDocumentInteractive 的纸换成 DocumentPaper,只留任务外壳
  (横幅 / 作废章 / 填写项进度)。
- 删除四处基础排版副本;SimSun 与 :deep(table) 现在各只剩一处。

行为保持
- 文书库预览仍是两个页签(文书内容走 iframe 隔离、签署预览走 v-html),
  没有正文时仍给占位版式(占位符写成 {患者姓名} 原始形态,不预填示例值)。
- 工作台预览的深灰 A4 舞台、页脚说明与两个出口按钮不变。
- 作废章原先是纸的兄弟节点、DOM 顺序在前,纸改成自带白底后会被盖住 ——
  给章补 z-index,并在探针里加命中测试锁死。

验证
- 新增 probe-doc-paper.cjs:31/31 PASS。核心是**计算样式**断言
  (边框色 / 内边距 / 宋体 / border-collapse),并断言文书库预览与编辑器预览
  两份纸的样式**逐项一致** —— 只看 DOM 存在证明不了"只有一份排版"。
- 回归:probe-new-task 104/104、probe-workbench-lib 46/46(新增作废章命中测试)、
  probe-dept-confirm 36/36、probe-method-domain 14/14、probe-report-analytics 92/92。
- vue-tsc 与 eslint 0 错。

净减 888 行(309 增 / 1197 删)。
This commit is contained in:
yelan
2026-09-26 20:56:02 +08:00
parent 8c70f01e2e
commit 3cb174ce0c
10 changed files with 1294 additions and 1202 deletions
@@ -1,20 +1,20 @@
<script setup lang="ts">
/**
* 已签署文书的在线预览(原型 openPdfView 的 mdPdfView)。
* 已签署 / 待科室确认文书的在线预览(原型 openPdfView 的 mdPdfView)。
*
* 用的是**同一个**文书渲染组件(SigningDocumentInteractive,只读态),
* 而不是另写一套纸样:预览看到的版式必须与工作台里、与导出 PDF 的完全一致,
* 三处各写一份必然漂移。
*
* 底部两个出口对应原型结果卡上的「下载」「只读副本」:
* - 下载 PDF 原件:优先取服务端产物,没有产物时(演示环境)由前端现场生成;
* - 只读副本:前端加带审计编号的水印后导出,对外提供时用这份。
* 它只是**统一预览弹窗**在签署工作台这一处的配置:
* 深灰 A4 舞台 + 「只读副本 / 下载 PDF 原件」两个出口。
* 纸的渲染在 components/document/DocumentPaper.vue、弹窗外壳在 DocumentPreviewDialog.vue,
* 这里不重复实现任何渲染逻辑 —— 预览看到的版式必须与工作台里、与导出的 PDF 完全一致。
*/
import type { SigningTaskRecord } from '@/api/workbench/types'
import SigningDocumentInteractive from '@/components/signing/SigningDocumentInteractive.vue'
import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue'
import { computed } from 'vue'
defineProps<{
import type { SigningTaskRecord } from '@/api/workbench/types'
import DocumentPreviewDialog from '@/components/document/DocumentPreviewDialog.vue'
import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue'
import type { SigningDocumentData } from '@/utils/signing-document'
const props = defineProps<{
visible: boolean
task: SigningTaskRecord | null
/** 导出中:两个下载按钮一起禁用,避免重复点出多份 PDF */
@@ -26,196 +26,59 @@ const emit = defineEmits<{
download: []
'download-readonly-copy': []
}>()
const subtitle = computed(() =>
props.task ? `${props.task.documentName} · ${props.task.id} · ${props.task.patientName}` : '',
)
/** 只挑渲染真正需要的字段显式映射,不直接透传整个任务对象 */
const documentData = computed<SigningDocumentData>(() => ({
patientName: props.task?.patientName ?? '',
sex: props.task?.sex ?? '',
age: props.task?.age ?? '',
visitNo: props.task?.visitNo ?? '',
visitDate: props.task?.visitDate ?? '',
signedAt: props.task?.signedAt ?? '',
signatureDataUrl: props.task?.signatureDataUrl,
selectedItems: props.task?.selectedItems,
fieldAnswers: props.task?.fieldAnswers,
}))
</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"
<DocumentPreviewDialog
:visible="visible && Boolean(task)"
title="在线预览(PDF)"
:subtitle="subtitle"
:content-html="task?.documentContentHtml ?? ''"
:data="documentData"
stage="stage"
:width="900"
@close="emit('close')"
>
<!-- 正文取不到时用带任务信息的回退纸样,而不是一句「暂无正文」:
那会把「后端没给正文」和「这份文书本来就是空的」混为一谈 -->
<template #empty>
<SigningDocumentPreview v-if="task" :task="task" />
</template>
<template #footer>
<span class="preview-note-footer">
正式文书以服务端生成的 PDF 原件为准;此处预览按任务固化的文书版本渲染。
</span>
<span class="preview-spacer" />
<button
type="button"
class="preview-button preview-button--ghost"
:disabled="exporting"
title="生成带水印与审计编号的副本,对外提供时使用"
@click="emit('download-readonly-copy')"
>
<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>
只读副本
</button>
<button type="button" class="preview-button" :disabled="exporting" @click="emit('download')">
{{ exporting ? '生成中…' : '下载 PDF 原件' }}
</button>
</template>
</DocumentPreviewDialog>
</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>