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
@@ -4,9 +4,10 @@ import { ElMessage } from 'element-plus'
import { isSigningMockEnabled } from '@/api/workbench/signing'
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import DocumentPreviewDialog from '@/components/document/DocumentPreviewDialog.vue'
import { useSigningTaskForm } from '@/composables/useSigningTaskForm'
import { useAppStore } from '@/stores/app'
import { buildSigningDocumentHtml, type SigningDocumentData } from '@/utils/signing-document'
import type { SigningDocumentData } from '@/utils/signing-document'
import { SIGNING_METHODS } from '@/utils/signing-method'
const props = defineProps<{
@@ -107,12 +108,6 @@ const PREVIEW_SNAPSHOT: SigningDocumentData = {
const previewTemplate = ref<SigningTemplate | null>(null)
const previewHtml = computed(() => {
const contentHtml = previewTemplate.value?.contentHtml
return contentHtml ? buildSigningDocumentHtml(contentHtml, PREVIEW_SNAPSHOT, false) : ''
})
function openPreview(template: SigningTemplate) {
previewTemplate.value = template
}
@@ -480,29 +475,17 @@ watch(
</section>
</div>
<!-- 文档签署预览(原型 ntPreviewDoc → mdPdfView,只读) -->
<div v-if="previewTemplate" class="dialog-mask dialog-mask--stacked" @click.self="closePreview">
<section class="preview-card" role="dialog" aria-modal="true">
<header class="dialog-header">
<strong>{{ previewTemplate.name }}.pdf · 签署预览</strong>
<button type="button" class="dialog-close" aria-label="关闭预览" @click="closePreview">
×
</button>
</header>
<div class="preview-body">
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
<div v-if="previewHtml" class="preview-paper" v-html="previewHtml" />
<!-- eslint-enable vue/no-v-html -->
<p v-else class="preview-empty">该文档暂无结构化模板正文,发起签署后将按标准文书生成。</p>
</div>
<footer class="dialog-footer">
<span class="footer-spacer" />
<button type="button" class="action-button action-button--ghost" @click="closePreview">
关闭
</button>
</footer>
</section>
</div>
<!-- 文档签署预览(原型 ntPreviewDoc → mdPdfView,只读):
渲染与外壳都走统一的文书预览组件,别在这里再写一份纸样 -->
<DocumentPreviewDialog
:visible="Boolean(previewTemplate)"
:title="`${previewTemplate?.name ?? ''}.pdf · 签署预览`"
:content-html="previewTemplate?.contentHtml ?? ''"
:data="PREVIEW_SNAPSHOT"
:stacked="true"
empty-text="该文档暂无结构化模板正文,发起签署后将按标准文书生成。"
@close="closePreview"
/>
</Teleport>
</template>
@@ -518,11 +501,6 @@ watch(
}
/* 预览叠在发起弹窗之上,遮罩再深一档,避免两层弹窗看起来是同一层 */
.dialog-mask--stacked {
z-index: 60;
background: rgb(9 40 52 / 55%);
}
.dialog-card {
width: 680px;
max-width: 96vw;
@@ -1298,92 +1276,8 @@ watch(
border-radius: 5px;
}
/* 预览弹窗 */
.preview-card {
display: flex;
width: 900px;
max-width: 96vw;
max-height: calc(100vh - 32px);
flex-direction: column;
margin: auto;
overflow: hidden;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
animation: dialog-pop 0.18s ease;
}
.preview-body {
min-height: 0;
padding: 16px 18px;
overflow-y: auto;
background: #f1f5f7;
}
.preview-paper {
padding: 26px 32px 30px;
background: #fff;
border-radius: var(--r);
box-shadow: var(--sh);
}
.preview-empty {
padding: 40px 16px;
margin: 0;
color: var(--mut);
font-size: 12.5px;
text-align: center;
background: #fff;
border-radius: var(--r);
}
/* 注入的文书 HTML 不携带 scoped 属性,统一用 :deep 覆盖基础文书样式 */
.preview-paper :deep(p) {
margin: 0 0 6px;
color: #1c2b33;
font-size: 13.5px;
line-height: 1.85;
}
.preview-paper :deep(h2) {
margin: 0 0 10px;
font-size: 18px;
text-align: center;
}
.preview-paper :deep(h3) {
margin: 14px 0 6px;
font-size: 14px;
}
.preview-paper :deep(table) {
width: 100%;
margin: 8px 0 12px;
border-collapse: collapse;
table-layout: auto;
font-size: 12.5px;
line-height: 1.6;
}
.preview-paper :deep(th),
.preview-paper :deep(td) {
padding: 5px 7px;
border: 1px solid #52646e;
vertical-align: middle;
overflow-wrap: anywhere;
word-break: break-word;
}
.preview-paper :deep(th) {
font-weight: 700;
background: #f2f6f8;
}
.preview-paper :deep(img) {
max-width: 100%;
}
/* 预览弹窗的样式已全部收进 components/document/DocumentPreviewDialog.vue ——
纸的排版、外框、页脚按钮都在那里,这里不再保留任何预览样式。 */
@keyframes dialog-pop {
from {