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

388 lines
13 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">
/**
* 文书纸:把一份文书正文按「签署态」渲染出来 —— **全项目唯一一处**。
*
* 为什么必须只有一个:同一份纸会出现在签署工作台、文书库预览、编辑器签署预览、
* 发起签署弹窗的文档预览、记录详情、以及导出 PDF 的截图源里。改版前每处各写一遍
* 基础排版,实测已经分叉(表格边框两套色、单元格内边距两套值、字体声明只有两处有)。
* 版式一旦不一致,"预览看到的"和"签署时看到的"就不再是同一份东西,
* 而这类偏差不会报错,只会让人对不上。
*
* 组件只负责「纸」本身(排版 + 取值 + 交互 + 上报),不负责外框:
* 弹窗壳见 components/document/DocumentPreviewDialog.vue,
* 任务态外壳(横幅 / 页脚进度 / 作废章)见 components/signing/SigningDocumentInteractive.vue。
*/
import { computed, onMounted, ref, watch } from 'vue'
import { loadDocumentFont, type DocumentFont } from '../../../../packages/document-fonts'
import { getDefaultDocumentFont } from '@/api/management/document-fonts'
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 { ensureDocumentPaperStyle } from '@/utils/document-paper'
const props = withDefaults(
defineProps<{
/** 文书正文(模板定稿 / 任务固化快照) */
contentHtml: string
/** undefined: template preview uses default; null: historical task retains legacy font. */
documentFont?: DocumentFont | null
/** 回填上下文:患者信息 / 签名 / 勾选 / 已填取值 */
data?: SigningDocumentData
/** 填写项可否改动、签名位可否点击采集 */
interactive?: boolean
/**
* 签名位是否渲染成可点入口。
* 默认跟随 `interactive`;单独一个开关是因为「静态预览」也是 interactive 的
* (科室能试勾选),但那里没有事件宿主,渲染成可点按钮就是骗人。
*/
signatureActionable?: boolean
/** 已固化的勾选结果(存量勾选表) */
selectedItems?: string[]
/** 已固化的填写项取值 */
fieldAnswers?: SigningAnswers
/** 已固化的主签名图 */
signatureDataUrl?: string
/** 本次会话刚采到、还没提交的签名图(key 是字段键) */
signatureInjections?: SigningAnswers
/**
* 纸的盒子形态:
* - `plain`(默认):只有排版与白底,内边距/圆角/阴影由调用方给(工作台的外壳自己带)
* - `card`:白纸 + 内边距 + 圆角 + 阴影,预览弹窗用
*/
variant?: 'plain' | 'card'
}>(),
{
documentFont: undefined,
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 fontReady = ref(false)
const fontError = ref('')
const fontFamily = ref('')
let fontLoadVersion = 0
async function prepareFont() {
const version = ++fontLoadVersion
fontReady.value = false
fontError.value = ''
try {
const font =
props.documentFont === undefined ? await getDefaultDocumentFont() : props.documentFont
const family = await loadDocumentFont(font, import.meta.env.VITE_API_BASE_URL || '/api')
if (version !== fontLoadVersion) return
fontFamily.value = family
fontReady.value = true
} catch {
if (version === fontLoadVersion) fontError.value = '文书字体加载失败,请重试。'
}
}
watch(() => props.documentFont, prepareFont, { immediate: true })
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,
}),
)
/**
* 从渲染后的 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])
}
/** Repeated occurrences of one field must share the same answer. */
function syncEditedField(target: EventTarget | null) {
const root = containerRef.value
const host = target instanceof Element ? target.closest<HTMLElement>('[data-sign-field]') : null
const id = host?.getAttribute('data-sign-field')
if (root && host && id) {
const wrapper = document.createElement('div')
wrapper.append(host.cloneNode(true))
const value = readAnswersFromRoot(wrapper)[id]
if (value !== undefined)
root.querySelectorAll<HTMLElement>('[data-sign-field]').forEach((other) => {
if (other !== host && other.getAttribute('data-sign-field') === id)
applyAnswerToFieldHost(other, value)
})
}
syncFromDom()
}
function handleInput(event: Event) {
if (props.interactive) syncEditedField(event.target)
}
/** 按已固化的取值还原 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)) {
syncEditedField(option)
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 option =
event.target instanceof Element ? event.target.closest<HTMLElement>(OPTION_TICK_SELECTOR) : null
if (option && containerRef.value && toggleOptionAt(containerRef.value, option)) {
event.preventDefault()
syncEditedField(option)
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() {
if (!fontReady.value) return null
return containerRef.value
}
defineExpose({ getDocumentElement })
</script>
<template>
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
<!-- doc-paper--a4:无论哪种 variant 都是「一页 A4」(比例 + 一般文书页边距),
variant 只决定盒子装饰 —— card 自带圆角阴影,plain 交给调用方 -->
<p v-if="!fontReady" role="status">
{{ fontError || '正在加载文书字体…' }}
<button v-if="fontError" type="button" @click="prepareFont">重试</button>
</p>
<article
class="doc-paper doc-paper--a4"
:class="{ 'doc-paper--card': variant === 'card', 'managed-font': Boolean(fontFamily) }"
:style="{ '--document-font': fontFamily, visibility: fontReady ? 'visible' : 'hidden' }"
:inert="!fontReady"
>
<div
ref="containerRef"
class="doc-paper__body"
@change="handleInput"
@input="handleInput"
@click="handleDocumentClick"
@keydown="handleDocumentKeydown"
v-html="processedHtml"
/>
</article>
<!-- eslint-enable vue/no-v-html -->
</template>
<style scoped>
/* 文书字体(.managed-font / --document-font)在 utils/document-paper.ts 的
DOCUMENT_PAPER_CSS 里 —— 编辑器编辑面也要换上同一份字体,所以它是全局规则,
不是这张纸私有的。 */
/* 基础排版(p / h2 / h3 / table / th / td / img)在 utils/document-paper.ts 的
DOCUMENT_PAPER_CSS 里,运行期注入 head —— 那里是唯一一份。
这里只保留容器自己的盒子。 */
.doc-paper {
min-width: 0;
}
/* card:预览弹窗里那张白纸。
比例与页边距由 .doc-paper--a4 给(见 utils/document-paper.ts 的 A4 规格),
这里只补「纸」的观感 —— 圆角与投影。 */
.doc-paper--card {
border-radius: var(--r);
box-shadow: var(--sh);
}
.doc-paper__body {
min-width: 0;
}
</style>