refactor(文书预览): 抽出统一的文书纸与预览弹窗,消除五处重复渲染

预览原先在 5 个地方各写一遍(文书库预览、编辑器签署预览、发起签署弹窗预览、
工作台在线预览、工作台文书区),基础排版已经分叉:表格边框 #52646e 与 #c8d7dd
两套色、单元格内边距 5px 7px 与 5px 9px 两个值、只有两处带宋体。
原型本身是「一处渲染(.paper + fillDocHtml)、两种外框」,这次按那个形状收口。

新增
- utils/document-paper.ts:基础排版的**唯一一份** CSS(p / h1-h4 / 表格 / 宋体)。
  运行期幂等注入 head —— 因为同一份纸有两条渲染通道:v-html(吃全局样式)与
  iframe srcdoc(隔离模板自带 style,只能吃字符串),各写一份必然漂移。
- components/document/DocumentPaper.vue:唯一的纸渲染器。
  signing / raw 两模式、interactive、取值还原、☑☐ 就地切换、签名位请求采集、
  填写项与勾选上报、getDocumentElement()(供导出 PDF)。
- components/document/DocumentPreviewDialog.vue:唯一的预览外框。
  标题/副标题/徽标、可选页签、加载与错误态、纸舞台(浅色卡片 / 深灰 A4)、
  #footer 与 #empty 插槽。
- styles/document-preview.css:页脚按钮等**插槽内容**要用的类。
  插槽节点带的是父组件 scope id,弹窗的 scoped 样式命中不了 —— 与
  signing-document.css 同一类问题,同一套解法。

迁移
- 文书库预览、编辑器签署预览、发起签署弹窗预览、工作台在线预览全部改走统一组件;
  SigningDocumentInteractive 的纸换成 DocumentPaper,只留任务外壳
  (横幅 / 作废章 / 填写项进度)。
- 删除四处基础排版副本;SimSun 与 :deep(table) 现在各只剩一处。

行为保持
- 文书库预览仍是两个页签(文书内容走 iframe 隔离、签署预览走 v-html),
  没有正文时仍给占位版式(占位符写成 {患者姓名} 原始形态,不预填示例值)。
- 工作台预览的深灰 A4 舞台、页脚说明与两个出口按钮不变。
- 作废章原先是纸的兄弟节点、DOM 顺序在前,纸改成自带白底后会被盖住 ——
  给章补 z-index,并在探针里加命中测试锁死。

验证
- 新增 probe-doc-paper.cjs:31/31 PASS。核心是**计算样式**断言
  (边框色 / 内边距 / 宋体 / border-collapse),并断言文书库预览与编辑器预览
  两份纸的样式**逐项一致** —— 只看 DOM 存在证明不了"只有一份排版"。
- 回归:probe-new-task 104/104、probe-workbench-lib 46/46(新增作废章命中测试)、
  probe-dept-confirm 36/36、probe-method-domain 14/14、probe-report-analytics 92/92。
- vue-tsc 与 eslint 0 错。

净减 888 行(309 增 / 1197 删)。
This commit is contained in:
yelan
2026-09-26 20:56:02 +08:00
parent 8c70f01e2e
commit 3cb174ce0c
10 changed files with 1294 additions and 1202 deletions
@@ -1,26 +1,22 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
/**
* 签署工作台的任务态文书外壳。
*
* 它只负责**任务专属的那一圈**:顶部横幅(按哪个版本渲染 / 当前签署状态)、
* 作废章、底部填写项进度。纸本身(排版、占位符回填、勾选交互、签名位采集)
* 在 components/document/DocumentPaper.vue —— 那是全项目唯一一处,
* 工作台看到的版式必须与预览、与导出 PDF 完全一致。
*/
import { computed, ref } from 'vue'
import type { SigningAnswers, SigningTaskRecord } 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,
import DocumentPaper from '@/components/document/DocumentPaper.vue'
import type {
SigningDocumentData,
SigningFieldProgress,
SigningSignatureRequest,
} from '@/utils/signing-document'
// 签署文书交互化渲染:填写项渲染(HIS 预填 + 现场确认)、签名回显、勾选收集见
// utils/signing-document 与 utils/signing-fields,与文书库的「签署预览」共用同一实现。
// 本组件只负责挂载 HTML、收集填写项取值并渲染签署态外壳。
const props = withDefaults(
defineProps<{
task: SigningTaskRecord
@@ -28,7 +24,7 @@ const props = withDefaults(
/**
* 在文书里点击签名位采集到的签名图(key 是字段键,如 doctor)。
*
* 不走 task:task 是服务端快照,这里装的是"本次会话刚采到、还没提交"的取值。
* 不走 task:task 是服务端快照,这里装的是「本次会话刚采到、还没提交」的取值。
* 单独一份也让「整篇重渲染」不会把刚采的签名冲掉。
*/
signatureInjections?: SigningAnswers
@@ -43,9 +39,8 @@ const emit = defineEmits<{
'signature-request': [request: SigningSignatureRequest]
}>()
const containerRef = ref<HTMLElement | null>(null)
const selectedItems = ref<string[]>([])
const fieldAnswers = ref<SigningAnswers>({})
const paperRef = ref<InstanceType<typeof DocumentPaper> | null>(null)
const progress = ref<SigningFieldProgress>({
total: 0,
filled: 0,
@@ -54,8 +49,10 @@ const progress = ref<SigningFieldProgress>({
pendingSignatures: [],
})
// 只挑渲染真正需要的字段显式映射,不直接透传整个任务对象:
// 渲染数据契约与任务记录是两回事,哪天字段改名也不会静默丢取值。
/**
* 只挑渲染真正需要的字段显式映射,不直接透传整个任务对象:
* 渲染数据契约与任务记录是两回事,哪天字段改名也不会静默丢取值。
*/
const documentData = computed<SigningDocumentData>(() => ({
patientName: props.task.patientName,
sex: props.task.sex,
@@ -68,21 +65,10 @@ const documentData = computed<SigningDocumentData>(() => ({
fieldAnswers: props.task.fieldAnswers,
}))
const processedHtml = computed(() =>
buildSigningDocumentHtml(
props.task.documentContentHtml ?? '',
documentData.value,
props.interactive,
// 本组件就是签名位的点击宿主(见 handleDocumentClick),
// 所以可采集与否只取决于文书当前是否可交互
{ signatureActionable: props.interactive },
),
)
/** 文书里的填写项数量(含只读展示),用于提示"这份文书有几个填写项" */
/** 文书里的填写项数量(含只读展示),用于提示「这份文书有几个填写项」 */
const fieldTotal = computed(() => progress.value.total)
/** 签名/状态文案:作废要单独说清楚,不能被当成"待患者签署" */
/** 签名/状态文案:作废要单独说清楚,不能被当成「待患者签署」 */
const signatureStateLabel = computed(() => {
if (props.task.status === 'void') {
return '已作废'
@@ -92,189 +78,20 @@ const signatureStateLabel = computed(() => {
return '已采集签名'
}
return props.task.status === 'signed' ? '已签署' : '待患者签署'
return props.task.status === 'signed' || props.task.status === 'confirming'
? '已签署'
: '待患者签署'
})
/**
* 从渲染后的 DOM 读回填写项取值与勾选清单。
* 生成 HTML 时已把取值写成属性/文本,这里再按取值同步一次 DOM 状态,
* 保证「签完再打开时填写项/勾选还在」这件事不依赖序列化细节。
*/
function syncFromDom() {
const container = containerRef.value
if (!container) {
return
}
const answers = readAnswersFromRoot(container)
fieldAnswers.value = answers
progress.value = describeFieldProgress(container, answers)
selectedItems.value = collectSelectedItemsFromRoot(container)
emit('field-change', { ...answers }, { ...progress.value })
emit('selection-change', [...selectedItems.value])
/** 纸把填写项与勾选结果上报上来,这里转给签署页,同时留一份给页脚用 */
function handleFieldChange(answers: SigningAnswers, next: SigningFieldProgress) {
progress.value = next
emit('field-change', answers, next)
}
/** 按已固化的取值还原 DOM 状态(存量勾选表 + 新填写项都覆盖) */
function applyStoredValues() {
const container = containerRef.value
if (!container) {
return
}
applySelectedItemsToRoot(container, props.task.selectedItems ?? [])
const answers: SigningAnswers = { ...(props.task.fieldAnswers ?? {}) }
if (props.task.signatureDataUrl && !answers.signature) {
answers.signature = props.task.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)
})
}
watch(
() => props.task,
() => {
selectedItems.value = [...(props.task.selectedItems ?? [])]
fieldAnswers.value = { ...(props.task.fieldAnswers ?? {}) }
emit('selection-change', [...selectedItems.value])
},
{ immediate: true },
)
/**
* 从事件目标往上找「可点击采集的签名位」。
* 用 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('signature-request', {
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)) {
syncFromDom()
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 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() {
return containerRef.value
return paperRef.value?.getDocumentElement() ?? null
}
defineExpose({ getDocumentElement })
@@ -291,17 +108,20 @@ defineExpose({ getDocumentElement })
<div v-if="task.status === 'void'" class="void-stamp">已 作 废</div>
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
<div
ref="containerRef"
<DocumentPaper
ref="paperRef"
class="sign-doc-body"
@change="syncFromDom"
@input="syncFromDom"
@click="handleDocumentClick"
@keydown="handleDocumentKeydown"
v-html="processedHtml"
:content-html="task.documentContentHtml ?? ''"
:data="documentData"
:interactive="interactive"
:selected-items="task.selectedItems"
:field-answers="task.fieldAnswers"
:signature-data-url="task.signatureDataUrl"
:signature-injections="signatureInjections"
@selection-change="emit('selection-change', $event)"
@field-change="handleFieldChange"
@signature-request="emit('signature-request', $event)"
/>
<!-- eslint-enable vue/no-v-html -->
<footer v-if="interactive" class="sign-doc-footer">
<span>
@@ -344,6 +164,7 @@ defineExpose({ getDocumentElement })
/* 作废章压在文书上,位置与存量回退纸样(SigningDocumentPreview)保持一致 */
.void-stamp {
position: absolute;
z-index: 2;
top: 52px;
right: 32px;
padding: 4px 12px;
@@ -381,8 +202,11 @@ defineExpose({ getDocumentElement })
background: #8a9aa3;
}
/* 纸的盒子:DocumentPaper 只给排版与白底(variant=plain),
内边距与滚动由这里给。类名通过 fallthrough 落到纸的根元素上。 */
.sign-doc-body {
flex: 1;
min-height: 0;
padding: 26px 32px 30px;
overflow: auto;
}
@@ -392,8 +216,8 @@ defineExpose({ getDocumentElement })
flex-direction: column;
gap: 4px;
padding: 10px 16px;
font-size: 12px;
color: var(--mut);
font-size: 12px;
background: #f8fbfc;
border-top: 1px solid var(--line);
}
@@ -418,43 +242,7 @@ defineExpose({ getDocumentElement })
color: #a96900;
}
/* 注入的文书 HTML(v-html 内容不携带 scoped 属性,统一用 :deep 覆盖基础文书样式) */
.sign-doc-body :deep(p) {
margin: 0 0 6px;
font-size: 13.5px;
line-height: 1.85;
color: #1c2b33;
}
/* 文书表格按内容自适应列宽并允许换行,避免绝对列宽在窄容器里把整表裁掉 */
.sign-doc-body :deep(table) {
width: 100%;
margin: 8px 0 12px;
border-collapse: collapse;
table-layout: auto;
font-size: 12.5px;
line-height: 1.6;
}
.sign-doc-body :deep(th),
.sign-doc-body :deep(td) {
padding: 5px 7px;
border: 1px solid #52646e;
vertical-align: middle;
overflow-wrap: anywhere;
word-break: break-word;
}
.sign-doc-body :deep(th) {
font-weight: 700;
background: #f2f6f8;
}
.sign-doc-body :deep(img) {
max-width: 100%;
}
/* 填写项胶囊、选项 ☑ / ☐、系统页眉的样式在 styles/signing-document.css(全局):
它们同时出现在文书库预览、签署工作台与编辑器里,各写一份必然漂移。
这里只保留"这份文书外壳"自己的版式(正文字号、表格边框等)。 */
/* 正文字号、表格边框、宋体等基础排版在 components/document/DocumentPaper.vue 的
DOCUMENT_PAPER_CSS 里(全项目唯一一份)。填写项胶囊、选项 ☑☐、系统页眉在
styles/signing-document.css。这里只保留"这份文书外壳"自己的东西。 */
</style>