Files
xh-medical-sign-web/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue
T

622 lines
15 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">
import { computed, ref, watch } from 'vue'
import { buildSigningDocumentHtml } from '@/utils/signing-document'
import { documentStatusLabels, signingPreviewPatient } from '../mock'
import type { DocumentTemplate } from '../types'
const props = defineProps<{
visible: boolean
template: DocumentTemplate | null
loading: boolean
error: boolean
}>()
const emit = defineEmits<{
close: []
use: [template: DocumentTemplate]
}>()
/** 预览页签:「文书内容」看原始定稿,「签署预览」看占位符填充与勾选交互化后的样子 */
const activeTab = ref<'content' | 'signing'>('content')
/** 签署预览里科室试勾选的结果,仅用于预览反馈,不作为签署证据 */
const signingSelection = ref<string[]>([])
const signingBodyRef = ref<HTMLElement | null>(null)
watch(
() => props.visible,
(visible) => {
if (visible) {
activeTab.value = 'content'
signingSelection.value = []
}
},
)
const statusLabel = computed(() => {
if (!props.template) {
return ''
}
return documentStatusLabels[props.template.status]
})
// 预置文书的 contentHtml 是完整 HTML 文档(自带版式样式);
// Word 自维护文书存的是编辑器产出的 HTML 片段,需要叠加文书基础渲染样式
// (表格边框、正文字体等),使导入路径的"裸表格"等语义结构也有合理的文书外观。
const DOCUMENT_BASE_STYLE = `
body {
margin: 0;
padding: 24px 28px 36px;
color: #1c2b33;
font-family: 'SimSun', 'Songti SC', 'STSong', serif;
font-size: 14px;
line-height: 1.85;
background: #fff;
overflow-x: auto;
}
img {
max-width: 100%;
}
table {
width: 100%;
margin: 8px 0;
border-collapse: collapse;
table-layout: auto;
font-size: 12.5px;
line-height: 1.6;
}
th,
td {
padding: 5px 7px;
border: 1px solid #52646e;
vertical-align: middle;
overflow-wrap: anywhere;
word-break: break-word;
}
th p,
td p {
margin: 0;
}
`
const previewSrcDoc = computed(() => {
const contentHtml = props.template?.contentHtml ?? ''
if (/^\s*<!doctype html|^\s*<html[\s>]/i.test(contentHtml)) {
return contentHtml
}
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8" /><style>${DOCUMENT_BASE_STYLE}</style></head><body>${contentHtml}</body></html>`
})
// 签署预览:复用签署端同一套渲染管线(占位符填充 → 签名域 → 勾选表交互化),
// 患者/就诊数据用示例快照。interactive 传 true,科室可以在预览里试勾选、确认交互效果。
// 这里不用 iframe:需要读取勾选结果做汇总,iframe 的 sandbox 也不便于表单交互。
const signingPreviewHtml = computed(() =>
buildSigningDocumentHtml(props.template?.contentHtml ?? '', signingPreviewPatient, true),
)
// 勾选汇总只在预览内展示(切页签、重开弹窗即重置),不作为签署证据。
function handleSigningSelectionChange(event: Event) {
const target = event.target as HTMLInputElement
if (!target.classList.contains('sign-doc-checkbox')) {
return
}
const body = signingBodyRef.value
const checked = body
? Array.from(body.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'))
: []
signingSelection.value = checked.map((input) => input.getAttribute('data-sign-item') ?? '')
}
</script>
<template>
<Teleport to="body">
<div
v-if="visible && template"
class="dialog-mask"
role="presentation"
@click.self="emit('close')"
>
<section
class="preview-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="document-preview-title"
>
<header class="dialog-header">
<div class="dialog-title">
<h2 id="document-preview-title">{{ template.name }}</h2>
<span class="word-badge">类 Word 版式</span>
<span class="status-badge">{{ statusLabel }}</span>
</div>
<button type="button" class="close-button" aria-label="关闭预览" @click="emit('close')">
×
</button>
</header>
<div class="preview-tabs" role="tablist" aria-label="预览方式">
<button
type="button"
role="tab"
class="preview-tab"
:class="{ 'is-active': activeTab === 'content' }"
:aria-selected="activeTab === 'content'"
@click="activeTab = 'content'"
>
文书内容
</button>
<button
type="button"
role="tab"
class="preview-tab"
:class="{ 'is-active': activeTab === 'signing' }"
:aria-selected="activeTab === 'signing'"
:disabled="!template.contentHtml"
title="按签署时的效果渲染:占位符填充、勾选表交互化、签名域"
@click="activeTab = 'signing'"
>
签署预览
</button>
</div>
<div class="dialog-body">
<div v-if="loading" class="preview-state">正在加载模板内容…</div>
<div v-else-if="error" class="preview-state preview-state--error">
模板内容加载失败,请关闭后重试。
</div>
<template v-else-if="template.contentHtml && activeTab === 'signing'">
<p class="preview-note">
按签署时效果渲染:占位符已用示例患者「{{ signingPreviewPatient.patientName }}」填充,
表头含「勾选」的表格已转为勾选框,可直接勾选确认交互效果(预览不保存勾选结果)。
</p>
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
<div
ref="signingBodyRef"
class="signing-preview"
@change="handleSigningSelectionChange"
v-html="signingPreviewHtml"
/>
<!-- eslint-enable vue/no-v-html -->
<p class="signing-selection">
已勾选 <strong>{{ signingSelection.length }}</strong> 项{{
signingSelection.length ? `:${signingSelection.join('、')}` : ''
}}
</p>
</template>
<iframe
v-else-if="template.contentHtml"
class="preview-frame"
:srcdoc="previewSrcDoc"
sandbox=""
title="文档模板内容预览"
/>
<article v-else class="preview-paper">
<h3>{{ template.name }}</h3>
<p class="paper-number">模板编号:{{ template.code }} · 版本 {{ template.version }}</p>
<p>
患者:<strong>{患者姓名}</strong> · 性别:<strong>{性别}</strong> · 年龄:<strong
>{年龄}</strong
>
</p>
<p>
门诊/住院号:<strong>{就诊号}</strong> · 科室:<strong>{{
template.department
}}</strong>
· 日期:<strong>{签署日期}</strong>
</p>
<p>
医务人员已就本项检查、治疗的适应证、禁忌证、医疗风险、替代方案及可能出现的不良后果向本人(或授权委托人)充分说明,本人已完全理解上述内容,自愿接受并签署确认。
</p>
<p>
本文档采用电子方式签署,患者手写签名图片将合成至下方签名域,文档原件与签名原图双留存。
</p>
<div class="signature-area">
<div>
<span>患者 / 授权委托人签名:</span>
<div class="signature-line">签名域(自动合成)</div>
</div>
<div>
<span>日期:</span>
<div class="signature-line date-line">自动填充</div>
</div>
</div>
</article>
</div>
<footer class="dialog-footer">
<p>文书版式取自科室真实知情同意书原件,{占位符}与勾选项在发起签署时由系统自动填充。</p>
<button type="button" class="footer-button secondary" @click="emit('close')">关闭</button>
<button
type="button"
class="footer-button primary"
:disabled="template.status !== 'enabled'"
:title="template.status === 'enabled' ? '用此模板发起签署' : '仅已启用模板可发起签署'"
@click="emit('use', template)"
>
用此模板发起签署
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 20;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.preview-dialog {
display: flex;
flex-direction: column;
width: 720px;
max-width: 96vw;
max-height: calc(100vh - 32px);
margin: auto;
overflow: hidden;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
animation: dialog-pop 0.18s ease;
}
@keyframes dialog-pop {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
.dialog-header {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.dialog-title {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.dialog-title h2 {
margin: 0;
overflow: hidden;
color: var(--ink);
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.word-badge,
.status-badge {
padding: 3px 9px;
font-size: 11px;
font-weight: 700;
border-radius: 12px;
}
.word-badge {
color: var(--brand);
background: var(--brand-l);
}
.status-badge {
color: var(--ok);
background: var(--ok-l);
}
.close-button {
flex-shrink: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.close-button:hover {
color: var(--ink);
}
.preview-tabs {
display: flex;
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-note {
padding: 8px 12px;
margin: 0 0 10px;
color: var(--brand);
font-size: 12px;
line-height: 1.6;
background: var(--brand-l);
border-radius: 7px;
}
.dialog-body {
min-height: 0;
padding: 12px 18px;
overflow-y: auto;
}
.preview-state {
display: grid;
min-height: 420px;
color: var(--mut);
font-size: 13px;
place-items: center;
}
.preview-state--error {
color: var(--err);
}
.preview-frame {
display: block;
width: 100%;
min-height: 560px;
background: #fff;
border: 1px solid #eef4f6;
}
/* 签署预览用 v-html 渲染(需读取勾选结果,因此不用 sandbox iframe)。
下列基础文书样式需与 previewSrcDoc 使用的 DOCUMENT_BASE_STYLE 保持一致;
v-html 注入的节点不携带 scoped 属性,统一用 :deep 覆盖。 */
.signing-preview {
min-height: 560px;
padding: 24px 28px 36px;
color: #1c2b33;
font-family: 'SimSun', 'Songti SC', 'STSong', serif;
font-size: 14px;
line-height: 1.85;
background: #fff;
border: 1px solid #eef4f6;
overflow-x: auto;
}
.signing-preview :deep(img) {
max-width: 100%;
}
.signing-preview :deep(table) {
width: 100%;
margin: 8px 0;
border-collapse: collapse;
table-layout: auto;
font-size: 12.5px;
line-height: 1.6;
}
.signing-preview :deep(th),
.signing-preview :deep(td) {
padding: 5px 7px;
border: 1px solid #52646e;
vertical-align: middle;
overflow-wrap: anywhere;
word-break: break-word;
}
.signing-preview :deep(th p),
.signing-preview :deep(td p) {
margin: 0;
}
.signing-preview :deep(.sign-doc-checkbox) {
width: 15px;
height: 15px;
accent-color: var(--brand);
cursor: pointer;
}
/* 正文行内选项(□否 □是):用 label 包住方框与选项文字,点文字也能选中 */
.signing-preview :deep(.sign-doc-option) {
display: inline-flex;
gap: 3px;
align-items: center;
margin-right: 14px;
white-space: nowrap;
cursor: pointer;
}
.signing-preview :deep(.sign-doc-signature) {
max-width: 150px;
max-height: 52px;
vertical-align: middle;
}
.signing-selection {
padding: 8px 12px;
margin: 10px 0 0;
color: var(--mut);
font-size: 12px;
background: #f8fbfc;
border: 1px solid var(--line);
border-radius: 7px;
}
.signing-selection strong {
color: var(--brand);
}
.preview-paper {
min-height: 420px;
padding: 26px 34px;
color: var(--ink);
font-size: 13px;
line-height: 1.9;
background: #fff;
border: 1px solid #eef4f6;
box-shadow: 0 1px 5px rgb(13 62 81 / 5%);
}
.preview-paper h3 {
margin: 0 0 6px;
color: var(--ink);
font-size: 17px;
text-align: center;
letter-spacing: 2px;
}
.preview-paper p {
margin: 0 0 7px;
text-indent: 2em;
text-align: justify;
}
.preview-paper .paper-number {
margin-bottom: 18px;
color: var(--mut);
font-size: 12px;
text-align: center;
text-indent: 0;
}
.signature-area {
display: flex;
flex-wrap: wrap;
gap: 40px;
align-items: flex-end;
margin-top: 22px;
}
.signature-area > div > span {
display: block;
margin-bottom: 6px;
color: var(--mut);
font-size: 12.5px;
}
.signature-line {
display: flex;
align-items: center;
justify-content: center;
min-width: 200px;
min-height: 64px;
color: var(--mut);
border-bottom: 2px dashed var(--line);
}
.signature-line.date-line {
min-width: 130px;
}
.dialog-footer {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 18px;
background: #f8fbfc;
border-top: 1px solid var(--line);
}
.dialog-footer p {
max-width: 46%;
margin: 0;
overflow: hidden;
color: var(--mut);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer-button {
padding: 7px 13px;
font-size: 12px;
font-weight: 700;
border-radius: 7px;
}
.footer-button.secondary {
margin-left: auto;
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.footer-button.primary {
color: #fff;
background: var(--brand);
border: 1px solid var(--brand);
}
.footer-button:hover {
filter: brightness(0.96);
}
.footer-button:disabled {
color: #aebbc0;
background: #f1f5f6;
border-color: #dce5e8;
cursor: not-allowed;
filter: none;
}
@media (max-width: 680px) {
.preview-paper {
padding: 22px 18px;
}
.dialog-footer {
flex-wrap: wrap;
}
.dialog-footer p {
width: 100%;
max-width: none;
}
.footer-button.secondary {
margin-left: 0;
}
}
</style>