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
@@ -0,0 +1,343 @@
<script setup lang="ts">
/**
* 文书纸:把一份文书正文按「签署态」渲染出来 —— **全项目唯一一处**。
*
* 为什么必须只有一个:同一份纸会出现在签署工作台、文书库预览、编辑器签署预览、
* 发起签署弹窗的文档预览、记录详情、以及导出 PDF 的截图源里。改版前每处各写一遍
* 基础排版,实测已经分叉(表格边框两套色、单元格内边距两套值、宋体只有两处有)。
* 版式一旦不一致,"预览看到的"和"签署时看到的"就不再是同一份东西,
* 而这类偏差不会报错,只会让人对不上。
*
* 组件只负责「纸」本身(排版 + 取值 + 交互 + 上报),不负责外框:
* 弹窗壳见 components/document/DocumentPreviewDialog.vue,
* 任务态外壳(横幅 / 页脚进度 / 作废章)见 components/signing/SigningDocumentInteractive.vue。
*/
import { computed, onMounted, ref, watch } from 'vue'
import type { SigningAnswers } from '@/api/workbench/types'
import {
applyAnswerToFieldHost,
applySelectedItemsToRoot,
applySignatureToRoot,
buildSigningDocumentHtml,
collectSelectedItemsFromRoot,
describeFieldProgress,
OPTION_TICK_SELECTOR,
readAnswersFromRoot,
SIGNATURE_ACTION_SELECTOR,
toggleOptionAt,
type SigningDocumentData,
type SigningFieldProgress,
type SigningSignatureRequest,
} from '@/utils/signing-document'
import { buildPaperSrcDoc, ensureDocumentPaperStyle } from '@/utils/document-paper'
const props = withDefaults(
defineProps<{
/** 文书正文(模板定稿 / 任务固化快照) */
contentHtml: string
/**
* 渲染模式。
* - `signing`(默认):按签署态渲染(占位符回填、勾选项交互化、签名域)
* - `raw`:原样展示定稿(iframe 隔离模板自带的 `<style>`),仅文书库的「文书内容」页签用
*/
mode?: 'signing' | 'raw'
/** 回填上下文:患者信息 / 签名 / 勾选 / 已填取值 */
data?: SigningDocumentData
/** 填写项可否改动、签名位可否点击采集 */
interactive?: boolean
/**
* 签名位是否渲染成可点入口。
* 默认跟随 `interactive`;单独一个开关是因为「静态预览」也是 interactive 的
* (科室能试勾选),但那里没有事件宿主,渲染成可点按钮就是骗人。
*/
signatureActionable?: boolean
/** 已固化的勾选结果(存量勾选表) */
selectedItems?: string[]
/** 已固化的填写项取值 */
fieldAnswers?: SigningAnswers
/** 已固化的主签名图 */
signatureDataUrl?: string
/** 本次会话刚采到、还没提交的签名图(key 是字段键) */
signatureInjections?: SigningAnswers
/**
* 纸的盒子形态:
* - `plain`(默认):只有排版与白底,内边距/圆角/阴影由调用方给(工作台的外壳自己带)
* - `card`:白纸 + 内边距 + 圆角 + 阴影,预览弹窗用
*/
variant?: 'plain' | 'card'
}>(),
{
mode: 'signing',
data: () => ({}),
interactive: false,
signatureActionable: undefined,
selectedItems: () => [],
fieldAnswers: () => ({}),
signatureDataUrl: undefined,
signatureInjections: () => ({}),
variant: 'plain',
},
)
const emit = defineEmits<{
selectionChange: [items: string[]]
fieldChange: [answers: SigningAnswers, progress: SigningFieldProgress]
/** 请求采集某个签名位,由调用方打开签字板并把结果回灌到 signatureInjections */
signatureRequest: [request: SigningSignatureRequest]
}>()
const containerRef = ref<HTMLElement | null>(null)
/**
* 基础排版注入放在 setup 期而不是 onMounted:
* onMounted 时首次 DOM 已经 patch 完,会有一帧"纸没有排版"的闪烁。
* 注入是幂等的,早调无害。
*/
ensureDocumentPaperStyle()
/** 默认跟随 interactive:单独传才算覆盖 */
const actionable = computed(() => props.signatureActionable ?? props.interactive)
/**
* 签署态 HTML。
* 只挑渲染真正需要的字段显式映射,不直接透传整个对象:
* 渲染数据契约与业务对象是两回事,哪天字段改名也不会静默丢取值。
*/
const processedHtml = computed(() =>
buildSigningDocumentHtml(props.contentHtml, props.data, props.interactive, {
signatureActionable: actionable.value,
}),
)
/** raw 模式的 iframe 文档(自带完整版式的正文原样返回) */
const rawSrcDoc = computed(() => buildPaperSrcDoc(props.contentHtml))
/**
* 从渲染后的 DOM 读回填写项取值与勾选清单并上报。
* 生成 HTML 时已把取值写成属性/文本,这里再按取值同步一次 DOM 状态,
* 保证「签完再打开时填写项/勾选还在」这件事不依赖序列化细节。
*/
function syncFromDom() {
const container = containerRef.value
if (!container) {
return
}
const answers = readAnswersFromRoot(container)
const progress = describeFieldProgress(container, answers)
const selected = collectSelectedItemsFromRoot(container)
emit('fieldChange', { ...answers }, { ...progress })
emit('selectionChange', [...selected])
}
/** 按已固化的取值还原 DOM 状态(存量勾选表 + 填写项都覆盖) */
function applyStoredValues() {
const container = containerRef.value
if (!container) {
return
}
applySelectedItemsToRoot(container, props.selectedItems ?? [])
const answers: SigningAnswers = { ...(props.fieldAnswers ?? {}) }
if (props.signatureDataUrl && !answers.signature) {
answers.signature = props.signatureDataUrl
}
container.querySelectorAll<HTMLElement>('[data-sign-field]').forEach((host) => {
const id = host.getAttribute('data-sign-field')
const value = id ? answers[id] : undefined
if (id === undefined || value === undefined) {
return
}
// 读写的判据都在 signing-document 里(readAnswersFromRoot 的镜像),
// 这里不自己写 data-sign-* 选择器,否则选项组这类「同 DOM 不同语义」的控件会两处漂移
applyAnswerToFieldHost(host, value)
})
}
/**
* 从事件目标往上找「可点击采集的签名位」。
* 用 closest 而不是直接比对 target:签名位胶囊里还有类型标签、虚线框等子节点,
* 点在这些子节点上时 target 并不是宿主节点本身。
*/
function resolveSignatureHost(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof Element)) {
return null
}
const host = target.closest<HTMLElement>(SIGNATURE_ACTION_SELECTOR)
// closest 会一路向上穿过容器,事件也可能来自容器外,必须确认落在本文书里
return host && containerRef.value?.contains(host) ? host : null
}
function requestSignature(host: HTMLElement) {
const fieldId = host.getAttribute('data-sign-field') ?? ''
if (!fieldId) {
return
}
emit('signatureRequest', {
fieldId,
label: host.getAttribute('data-sign-label') ?? fieldId,
})
}
/**
* 点击文书里的 ☑ / ☐ 选项。
*
* 与签名位分开处理:签名位是「请求采集」(要弹签字板),选项是就地切换。
* 切换逻辑本身在 utils/signing-document 的 toggleOptionAt 里,
* 组件只负责把点击翻译成一次调用 —— 单选互斥的判据不该在组件里再推一遍。
*/
function handleDocumentClick(event: MouseEvent) {
if (!props.interactive) {
return
}
const target = event.target
if (target instanceof Element) {
const option = target.closest<HTMLElement>(OPTION_TICK_SELECTOR)
const container = containerRef.value
if (option && container && toggleOptionAt(container, option)) {
syncFromDom()
return
}
}
const host = resolveSignatureHost(event.target)
if (host) {
requestSignature(host)
}
}
/** 签名位挂了 role=button 与 tabindex,键盘必须同样可用,否则只有鼠标点得动 */
function handleDocumentKeydown(event: KeyboardEvent) {
if (!props.interactive || (event.key !== 'Enter' && event.key !== ' ')) {
return
}
const host = resolveSignatureHost(event.target)
if (host) {
// 空格默认会滚动容器,必须挡掉
event.preventDefault()
requestSignature(host)
}
}
/**
* 把外部采集到的签名图就地写进签名位,写完立刻回读 DOM 上报取值。
*
* 不重算 processedHtml:整篇重建会换掉 v-html 的 DOM,
* 操作人员正在输入的填写项会连同光标一起丢。
*/
function patchSignatures() {
const container = containerRef.value
if (!container) {
return
}
const patched = Object.entries(props.signatureInjections ?? {}).some(
([fieldId, value]) =>
typeof value === 'string' && applySignatureToRoot(container, fieldId, value),
)
if (patched) {
syncFromDom()
}
}
watch(
processedHtml,
() => {
applyStoredValues()
// 重渲染会丢掉就地写入的签名图,得重新贴一遍
patchSignatures()
},
{ flush: 'post' },
)
watch(() => props.signatureInjections, patchSignatures, { deep: true, flush: 'post' })
onMounted(() => {
applyStoredValues()
patchSignatures()
syncFromDom()
})
/** 供「导出 PDF」使用:拿到已渲染的文书容器(含固定页眉与签署态取值) */
function getDocumentElement() {
return containerRef.value
}
defineExpose({ getDocumentElement })
</script>
<template>
<!-- raw 模式:模板定稿可能自带 <style>,必须隔离,否则会污染整个应用 -->
<iframe
v-if="mode === 'raw'"
class="doc-paper-frame"
:srcdoc="rawSrcDoc"
sandbox=""
title="文书原始版式预览"
/>
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
<article v-else class="doc-paper" :class="{ 'doc-paper--card': variant === 'card' }">
<div
ref="containerRef"
class="doc-paper__body"
@change="syncFromDom"
@input="syncFromDom"
@click="handleDocumentClick"
@keydown="handleDocumentKeydown"
v-html="processedHtml"
/>
</article>
<!-- eslint-enable vue/no-v-html -->
</template>
<style scoped>
/* 基础排版(p / h2 / h3 / table / th / td / img)在 utils/document-paper.ts 的
DOCUMENT_PAPER_CSS 里,运行期注入 head —— 那里是唯一一份,v-html 与 iframe 共用。
这里只保留容器自己的盒子。 */
.doc-paper {
min-width: 0;
}
/* card:预览弹窗里那张白纸 */
.doc-paper--card {
padding: 26px 32px 30px;
border-radius: var(--r);
box-shadow: var(--sh);
}
.doc-paper__body {
min-width: 0;
}
.doc-paper-frame {
display: block;
width: 100%;
height: 100%;
min-height: 560px;
background: #fff;
border: 0;
}
</style>
@@ -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>