388 lines
13 KiB
Vue
388 lines
13 KiB
Vue
<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>
|