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:
@@ -0,0 +1,440 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文书预览弹窗:全项目**唯一**的预览外框。
|
||||
*
|
||||
* 原型里预览也是「一处渲染、两种外框」——`#mdTpl`(720px,类 Word 版式,页脚「用此模板发起签署」)
|
||||
* 与 `#mdPdfView`(820px,深灰 A4 舞台,页眉「下载 PDF」)。两者共用同一份 `.paper`
|
||||
* 与 `fillDocHtml`,只有外壳不同。本组件把这两种外壳收成一个:
|
||||
* 标题栏 / 可选页签 / 可选提示 / 加载与错误态 / 纸舞台(浅色卡片或深灰 A4)/ 页脚插槽。
|
||||
*
|
||||
* 纸本身(排版、占位符回填、勾选交互)在 components/document/DocumentPaper.vue,
|
||||
* 本组件不重复实现任何渲染逻辑。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { SigningAnswers } from '@/api/workbench/types'
|
||||
import type {
|
||||
SigningDocumentData,
|
||||
SigningFieldProgress,
|
||||
SigningSignatureRequest,
|
||||
} from '@/utils/signing-document'
|
||||
|
||||
import DocumentPaper from './DocumentPaper.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
/** 标题栏主标题(模板名 / 「在线预览(PDF)」这类固定文案) */
|
||||
title: string
|
||||
/** 标题右侧的次要信息(编号 · 患者等) */
|
||||
subtitle?: string
|
||||
/** 标题行的徽标(版式标记、文书状态等) */
|
||||
badges?: { text: string; tone?: 'brand' | 'ok' | 'warn' | 'mut' }[]
|
||||
/** 可选页签;不传即单页 */
|
||||
tabs?: { key: string; label: string; disabled?: boolean; hint?: string }[]
|
||||
activeTab?: string
|
||||
/** 正文(模板定稿 / 任务固化快照) */
|
||||
contentHtml: string
|
||||
/** 渲染模式,透传给 DocumentPaper */
|
||||
mode?: 'signing' | 'raw'
|
||||
data?: SigningDocumentData
|
||||
interactive?: boolean
|
||||
signatureActionable?: boolean
|
||||
selectedItems?: string[]
|
||||
fieldAnswers?: SigningAnswers
|
||||
signatureDataUrl?: string
|
||||
signatureInjections?: SigningAnswers
|
||||
/** 正文取回中 */
|
||||
loading?: boolean
|
||||
/** 正文取回失败的提示;给了就整块显示错误态 */
|
||||
error?: string
|
||||
/** 纸上方的一条提示(例如「已用示例患者信息回填」) */
|
||||
note?: string
|
||||
/** 正文为空时的提示 */
|
||||
emptyText?: string
|
||||
/**
|
||||
* 纸的舞台:
|
||||
* - `plain`(默认):浅色底、白纸带内边距,用于看版式
|
||||
* - `stage`:深灰底、居中 A4,用于「在线预览(PDF)」(原型 #mdPdfView)
|
||||
*/
|
||||
stage?: 'plain' | 'stage'
|
||||
/** 弹窗宽度(px) */
|
||||
width?: number
|
||||
/**
|
||||
* 是否叠在另一个弹窗之上(发起签署弹窗里的文档预览)。
|
||||
* 叠层时遮罩加深一档,否则两层弹窗看着像同一层。
|
||||
*/
|
||||
stacked?: boolean
|
||||
}>(),
|
||||
{
|
||||
subtitle: '',
|
||||
badges: () => [],
|
||||
tabs: undefined,
|
||||
activeTab: '',
|
||||
mode: 'signing',
|
||||
data: () => ({}),
|
||||
interactive: false,
|
||||
signatureActionable: undefined,
|
||||
selectedItems: () => [],
|
||||
fieldAnswers: () => ({}),
|
||||
signatureDataUrl: undefined,
|
||||
signatureInjections: () => ({}),
|
||||
loading: false,
|
||||
error: '',
|
||||
note: '',
|
||||
emptyText: '该文书暂无正文内容。',
|
||||
stage: 'plain',
|
||||
width: 720,
|
||||
stacked: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'update:activeTab': [key: string]
|
||||
selectionChange: [items: string[]]
|
||||
fieldChange: [answers: SigningAnswers, progress: SigningFieldProgress]
|
||||
signatureRequest: [request: SigningSignatureRequest]
|
||||
}>()
|
||||
|
||||
const paperRef = ref<InstanceType<typeof DocumentPaper> | null>(null)
|
||||
|
||||
/** 有正文、且没在加载 / 报错时才渲染纸 */
|
||||
const hasPaper = computed(() => Boolean(props.contentHtml) && !props.loading && !props.error)
|
||||
|
||||
/** 供父级「导出 PDF / 生成只读副本」使用:拿到实际渲染文书的那一层 */
|
||||
function getDocumentElement() {
|
||||
return paperRef.value?.getDocumentElement() ?? null
|
||||
}
|
||||
|
||||
defineExpose({ getDocumentElement })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="preview-mask"
|
||||
:class="{ 'preview-mask--stacked': stacked }"
|
||||
role="presentation"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<section
|
||||
class="preview-card"
|
||||
:style="{ width: `${width}px` }"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="document-preview-title"
|
||||
>
|
||||
<header class="preview-header">
|
||||
<strong id="document-preview-title" class="preview-title">{{ title }}</strong>
|
||||
<span v-if="subtitle" class="preview-subtitle">{{ subtitle }}</span>
|
||||
<span
|
||||
v-for="badge in badges"
|
||||
:key="badge.text"
|
||||
class="preview-badge"
|
||||
:class="`tone-${badge.tone ?? 'brand'}`"
|
||||
>
|
||||
{{ badge.text }}
|
||||
</span>
|
||||
<button type="button" class="preview-close" aria-label="关闭预览" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="tabs?.length" class="preview-tabs" role="tablist" aria-label="预览方式">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="preview-tab"
|
||||
:class="{ 'is-active': tab.key === activeTab }"
|
||||
:aria-selected="tab.key === activeTab"
|
||||
:disabled="tab.disabled"
|
||||
:title="tab.hint"
|
||||
@click="emit('update:activeTab', tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="preview-body" :class="{ 'preview-body--stage': stage === 'stage' }">
|
||||
<p v-if="note && !loading && !error" class="preview-note">{{ note }}</p>
|
||||
<div v-if="loading" class="preview-state">正在加载文书内容…</div>
|
||||
<div v-else-if="error" class="preview-state preview-state--error">{{ error }}</div>
|
||||
<slot v-else>
|
||||
<DocumentPaper
|
||||
v-if="hasPaper"
|
||||
ref="paperRef"
|
||||
:content-html="contentHtml"
|
||||
:mode="mode"
|
||||
:data="data"
|
||||
:interactive="interactive"
|
||||
:signature-actionable="signatureActionable"
|
||||
:selected-items="selectedItems"
|
||||
:field-answers="fieldAnswers"
|
||||
:signature-data-url="signatureDataUrl"
|
||||
:signature-injections="signatureInjections"
|
||||
:variant="stage === 'stage' ? 'plain' : 'card'"
|
||||
@selection-change="emit('selectionChange', $event)"
|
||||
@field-change="(answers, progress) => emit('fieldChange', answers, progress)"
|
||||
@signature-request="emit('signatureRequest', $event)"
|
||||
/>
|
||||
<!-- 正文取不到时的兜底版式由调用方给:工作台要用带任务信息的回退纸样,
|
||||
而不是一句「暂无正文」—— 那会把「后端没给正文」和「这份文书本来就是空的」混为一谈 -->
|
||||
<slot v-else name="empty">
|
||||
<p class="preview-state preview-state--empty">{{ emptyText }}</p>
|
||||
</slot>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<footer class="preview-footer">
|
||||
<slot name="footer">
|
||||
<span class="preview-spacer" />
|
||||
<button
|
||||
type="button"
|
||||
class="preview-button preview-button--ghost"
|
||||
@click="emit('close')"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</slot>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preview-mask {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: rgb(9 40 52 / 45%);
|
||||
}
|
||||
|
||||
/* 叠在另一个弹窗之上时遮罩再深一档,否则两层弹窗看着像同一层 */
|
||||
.preview-mask--stacked {
|
||||
z-index: 50;
|
||||
background: rgb(9 40 52 / 55%);
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
display: flex;
|
||||
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: preview-pop 0.18s ease;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-subtitle {
|
||||
overflow: hidden;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 3px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.preview-badge.tone-brand {
|
||||
color: var(--brand);
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.preview-badge.tone-ok {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.preview-badge.tone-warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-l);
|
||||
}
|
||||
|
||||
.preview-badge.tone-mut {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
}
|
||||
|
||||
.preview-close {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
color: var(--mut);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.preview-close:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.preview-tabs {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
padding: 8px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.preview-tab {
|
||||
padding: 5px 12px;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.preview-tab:hover:not(:disabled) {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.preview-tab.is-active {
|
||||
color: var(--brand);
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.preview-tab:disabled {
|
||||
color: #b6c3c9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 唯一滚动区:纸在自己的框里滚,头尾常驻 */
|
||||
.preview-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 深灰 A4 舞台(原型 #mdPdfView):纸居中、带阴影,像在看 PDF */
|
||||
.preview-body--stage {
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
background: #525659;
|
||||
}
|
||||
|
||||
.preview-body--stage :deep(.doc-paper) {
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
min-height: 905px;
|
||||
flex: none;
|
||||
padding: 34px 40px;
|
||||
box-shadow: 0 6px 22px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
/* 深灰底上纸要自己撑满高度,否则短文书会缩成一条 */
|
||||
.preview-body--stage :deep(.doc-paper-frame) {
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
height: 905px;
|
||||
box-shadow: 0 6px 22px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.preview-state {
|
||||
display: grid;
|
||||
min-height: 420px;
|
||||
color: var(--mut);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.preview-state--error {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.preview-state--empty {
|
||||
color: var(--mut);
|
||||
}
|
||||
|
||||
.preview-note {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
background: var(--brand-l);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.preview-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 页脚按钮与说明(.preview-button / .preview-button--ghost / .preview-spacer /
|
||||
.preview-note-footer)在 styles/document-preview.css(全局)。
|
||||
为什么不做成 scoped:调用方通过 `#footer` 插槽塞进来的节点带的是**父组件**的
|
||||
scope id,本组件的 scoped 选择器命中不了它们 —— 与 signing-document.css 同一类问题,
|
||||
同一套解法。这样四处预览的按钮样式仍然只有一份定义。 */
|
||||
|
||||
@keyframes preview-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.preview-note-footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user