622 lines
15 KiB
Vue
622 lines
15 KiB
Vue
<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>
|