Files
xh-medical-sign-web/clinical-web/src/components/document/DocumentPreviewDialog.vue
T

429 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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
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: '',
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"
: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-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>