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

692 lines
17 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, nextTick, ref, watch } from 'vue'
import {
buildSigningDocumentHtml,
collectSelectedItemsFromRoot,
describeFieldProgress,
OPTION_TICK_SELECTOR,
readAnswersFromRoot,
toggleOptionAt,
type SigningFieldProgress,
} from '@/utils/signing-document'
import { canInitiateSigning, readLibraryStatus, readStatusTone } from '../library'
import { PREVIEW_PATIENT as previewPatient } from '../sample-patient'
import type { DocumentLibraryStatus, DocumentTemplate } from '../types'
const props = defineProps<{
visible: boolean
template: DocumentTemplate | null
loading: boolean
error: boolean
}>()
const emit = defineEmits<{
close: []
use: [template: DocumentTemplate]
}>()
/**
* 签署预览用的示例患者快照见 ../sample-patient(编辑器弹窗的「签署预览」共用同一份)。
*/
/** 预览页签:「文书内容」看原始定稿,「签署预览」看占位符填充与勾选交互化后的样子 */
const activeTab = ref<'content' | 'signing'>('content')
/** 签署预览里科室试勾选的结果,仅用于预览反馈,不作为签署证据 */
const signingSelection = ref<string[]>([])
/** 签署预览里填写项的完成情况,同样只用于预览反馈 */
const signingProgress = ref<SigningFieldProgress>({
total: 0,
filled: 0,
missingRequired: [],
abnormal: [],
pendingSignatures: [],
})
const signingBodyRef = ref<HTMLElement | null>(null)
// 切到签署预览时才挂载文书 DOM,挂载后再统计填写项,否则进页面会显示「0 / 0 项」
watch(activeTab, async (tab) => {
if (tab === 'signing') {
await nextTick()
syncPreviewFields()
}
})
watch(
() => props.visible,
(visible) => {
if (visible) {
activeTab.value = 'content'
signingSelection.value = []
signingProgress.value = {
total: 0,
filled: 0,
missingRequired: [],
abnormal: [],
pendingSignatures: [],
}
}
},
)
/** 状态口径统一走 library.ts:卡片、页签、预览三处必须是同一档 */
const libraryStatus = computed<DocumentLibraryStatus>(() =>
props.template ? readLibraryStatus(props.template) : '草稿',
)
const statusLabel = computed(() => libraryStatus.value)
/** 徽标配色与文书卡片同口径(见 library.ts 的 STATUS_TONES) */
const statusTone = computed(() => readStatusTone(libraryStatus.value))
/**
* 与文书卡片保持同一口径:只有「启用中」的文书能发起签署。
* 「需复审」不放行——它同时承载待审核与已驳回,两者都未定稿。
*/
const canInitiate = computed(
() => Boolean(props.template?.contentHtml) && canInitiateSigning(libraryStatus.value),
)
// 预置文书的 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 ?? '', previewPatient, true),
)
// 填写项与勾选汇总只在预览内展示(切页签、重开弹窗即重置),不作为签署证据。
function syncPreviewFields() {
const body = signingBodyRef.value
if (!body) {
return
}
const answers = readAnswersFromRoot(body)
signingProgress.value = describeFieldProgress(body, answers)
signingSelection.value = collectSelectedItemsFromRoot(body)
}
/**
* 预览里的 ☑ / ☐ 就地切换。
* 选项渲染成文本(原型口径),不会触发 change 事件,必须自己接 click;
* 切换判据在 utils/signing-document 的 toggleOptionAt 里,与签署端共用一份。
*/
function handlePreviewClick(event: MouseEvent) {
const body = signingBodyRef.value
const target = event.target
if (!body || !(target instanceof Element)) {
return
}
const option = target.closest<HTMLElement>(OPTION_TICK_SELECTOR)
if (option && toggleOptionAt(body, option)) {
syncPreviewFields()
}
}
</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" :class="'tone-' + statusTone">{{ 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">
按签署时效果渲染:占位符已用示例患者「{{ previewPatient.patientName }}」填充,
空白处是待补录的下划线、☑ / ☐ 可直接点击确认交互效果(预览不保存结果)。
</p>
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
<div
ref="signingBodyRef"
class="signing-preview"
@change="syncPreviewFields"
@input="syncPreviewFields"
@click="handlePreviewClick"
v-html="signingPreviewHtml"
/>
<!-- eslint-enable vue/no-v-html -->
<p class="signing-selection">
填写项 <strong>{{ signingProgress.filled }}</strong> /
{{ signingProgress.total }} 项已填<template v-if="signingSelection.length">
・已勾选 <strong>{{ signingSelection.length }}</strong> 项:{{
signingSelection.join('、')
}}</template
><template v-if="signingProgress.abnormal.length">
・异常项:<strong class="is-abnormal">{{
signingProgress.abnormal.join('、')
}}</strong></template
>
</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="!canInitiate"
:title="canInitiate ? '用此模板发起签署' : '仅已发布(审核通过)的模板可发起签署'"
@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);
}
/* 状态徽标配色:与文书卡片同一套色调(见 library.ts 的 STATUS_TONES) */
.status-badge.tone-draft,
.status-badge.tone-archived,
.status-badge.tone-stopped {
color: #8a9aa3;
background: #eceef0;
}
.status-badge.tone-review {
color: var(--warn);
background: var(--warn-l);
}
.status-badge.tone-enabled {
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;
}
/* 填写项胶囊、选项 ☑ / ☐、系统页眉的样式在 styles/signing-document.css(全局):
同一份签署态文书会在文书库预览、签署工作台与编辑器三处出现,各写一份必然漂移。
这里只保留预览容器自己的版式。 */
.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);
}
.signing-selection .is-abnormal {
color: var(--err);
}
.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>