feat(documents): 抽取签署文书渲染管线并新增签署预览

This commit is contained in:
yelan
2026-09-14 11:06:58 +08:00
parent 7478bf04f1
commit 5c5a8fc90b
6 changed files with 566 additions and 152 deletions
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { documentStatusLabels } from '../mock'
import { buildSigningDocumentHtml } from '@/utils/signing-document'
import { documentStatusLabels, signingPreviewPatient } from '../mock'
import type { DocumentTemplate } from '../types'
const props = defineProps<{
@@ -16,6 +18,22 @@ const emit = defineEmits<{
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 ''
@@ -72,6 +90,29 @@ const previewSrcDoc = computed(() => {
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>
@@ -99,11 +140,55 @@ const previewSrcDoc = computed(() => {
</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"
@@ -146,9 +231,7 @@ const previewSrcDoc = computed(() => {
</div>
<footer class="dialog-footer">
<p>
文书版式取自科室真实知情同意书原件,{占位符}与勾选项在发起签署时由系统自动填充。
</p>
<p>文书版式取自科室真实知情同意书原件,{占位符}与勾选项在发起签署时由系统自动填充。</p>
<button type="button" class="footer-button secondary" @click="emit('close')">关闭</button>
<button
type="button"
@@ -258,6 +341,47 @@ const previewSrcDoc = computed(() => {
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;
@@ -284,6 +408,85 @@ const previewSrcDoc = computed(() => {
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;
@@ -255,7 +255,7 @@ function save() {
/>
<span v-if="importMessage" class="docx-message">{{ importMessage }}</span>
<span v-else class="docx-hint">
建议从 Word/WPS 中全选复制文书后直接粘贴到下方编辑区;勾选列表请保持"勾选"列为第一列,签署时会自动转为可交互勾选。
建议从 Word/WPS 中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的条目请做成表格——表头第一行写「勾选」,旁边一列写「项目」,签署时该列会自动转为勾选框。
</span>
</div>
<div class="editor-area">
@@ -1,3 +1,5 @@
import type { SigningDocumentData } from '@/utils/signing-document'
import type { DocumentDepartment, DocumentStatus } from './types'
const documentStatusLabels: Record<DocumentStatus, string> = {
@@ -16,3 +18,17 @@ export { documentStatusLabels }
// Word 自维护方案:文书库初始为空,所有文书由科室通过 Word 编辑器(粘贴 / 导入 .docx)创建维护。
export const documentLibrary: DocumentDepartment[] = []
/**
* 签署预览使用的示例患者快照(脱敏演示数据)。
* 创建文书时还没有真实签署任务,用固定示例值让科室看到占位符被填充后的版式;
* 真实签署仍以任务创建时的患者/就诊快照为准。
*/
export const signingPreviewPatient: SigningDocumentData = {
patientName: '李某某',
sex: '男',
age: 46,
visitNo: 'V20260914001',
visitDate: '2026-09-14',
signedAt: '',
}
@@ -2,12 +2,11 @@
import { computed, ref } from 'vue'
import type { SigningTaskRecord } from '@/api/workbench/types'
import { buildSigningDocumentHtml } from '@/utils/signing-document'
// 签署文书交互化渲染(原型管线):
// ① 占位符填充({患者姓名}等 → 任务患者快照数据)
// ② 勾选表识别:表头含"勾选"列的表格,逐行转为可交互复选框
// ③ 签名域:任务已有签名图(signatureDataUrl)时替换 {患者签名}
// 真实实现中填充在后端完成,勾选结果随签署证据提交;本组件用于前端闭环验证。
// 签署文书交互化渲染:变换管线(占位符填充 / 签名域 / 勾选表转复选框)见
// utils/signing-document,与文书库的「签署预览」共用同一实现。
// 本组件只负责挂载 HTML、收集勾选结果并渲染签署态外壳。
const props = withDefaults(
defineProps<{
task: SigningTaskRecord
@@ -23,144 +22,9 @@ const emit = defineEmits<{
const containerRef = ref<HTMLElement | null>(null)
const selectedItems = ref<string[]>([])
function replaceTextPlaceholder(root: Element, placeholder: string, replacement: string) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const targets: Text[] = []
let node = walker.nextNode()
while (node) {
if ((node.textContent ?? '').includes(placeholder)) {
targets.push(node as Text)
}
node = walker.nextNode()
}
targets.forEach((textNode) => {
textNode.textContent = textNode.textContent?.split(placeholder).join(replacement) ?? ''
})
}
function replaceSignaturePlaceholder(root: Element, dataUrl: string) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const targets: Text[] = []
let node = walker.nextNode()
while (node) {
if ((node.textContent ?? '').includes('{患者签名}')) {
targets.push(node as Text)
}
node = walker.nextNode()
}
targets.forEach((textNode) => {
const parts = (textNode.textContent ?? '').split('{患者签名}')
const fragment = document.createDocumentFragment()
if (parts[0]) {
fragment.appendChild(document.createTextNode(parts[0]))
}
const image = document.createElement('img')
image.src = dataUrl
image.alt = '患者签名'
image.className = 'sign-doc-signature'
fragment.appendChild(image)
if (parts[1]) {
fragment.appendChild(document.createTextNode(parts[1]))
}
textNode.replaceWith(fragment)
})
}
function fillPatientPlaceholders(root: Element) {
const task = props.task
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const targets: Text[] = []
let node = walker.nextNode()
while (node) {
if ((node.textContent ?? '').includes('{')) {
targets.push(node as Text)
}
node = walker.nextNode()
}
targets.forEach((textNode) => {
textNode.textContent = (textNode.textContent ?? '')
.replace(/{患者姓名}/g, task.patientName)
.replace(/{性别}/g, task.sex)
.replace(/{年龄}/g, `${task.age} 岁`)
.replace(/{就诊号}/g, task.visitNo)
.replace(/{联系电话}/g, '—')
.replace(/{预约日期}/g, task.visitDate)
.replace(/{与患者关系}/g, '本人')
.replace(/{签署日期}/g, task.signedAt || task.visitDate || '  年  月  日')
})
}
function convertCheckboxes(root: Element) {
root.querySelectorAll('table').forEach((table) => {
const headerCells = Array.from(table.querySelectorAll('thead th'))
const checkIndex = headerCells.findIndex((cell) => cell.textContent?.includes('勾选'))
if (checkIndex === -1) {
return
}
const nameIndex = headerCells.findIndex((cell) => cell.textContent?.includes('项目'))
table.classList.add('sign-doc-table')
table.querySelectorAll('tbody tr').forEach((row) => {
const cells = Array.from(row.querySelectorAll('td'))
const checkCell = cells[checkIndex]
if (!checkCell) {
return
}
const nameCell = cells[nameIndex > -1 ? nameIndex : 1]
const itemName = (nameCell?.textContent ?? '').trim()
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'sign-doc-checkbox'
checkbox.setAttribute('data-sign-item', itemName)
checkbox.disabled = !props.interactive
checkCell.textContent = ''
checkCell.appendChild(checkbox)
})
})
}
const processedHtml = computed(() => {
const source = props.task.documentContentHtml
if (!source) {
return ''
}
const doc = new DOMParser().parseFromString(`<div id="sign-doc-root">${source}</div>`, 'text/html')
const root = doc.getElementById('sign-doc-root')
if (!root) {
return source
}
fillPatientPlaceholders(root)
if (props.task.signatureDataUrl) {
replaceSignaturePlaceholder(root, props.task.signatureDataUrl)
} else {
replaceTextPlaceholder(root, '{患者签名}', '(待患者签署)')
}
convertCheckboxes(root)
return root.innerHTML
})
const processedHtml = computed(() =>
buildSigningDocumentHtml(props.task.documentContentHtml ?? '', props.task, props.interactive),
)
function handleSelectionChange(event: Event) {
const target = event.target as HTMLInputElement
@@ -184,7 +48,9 @@ function handleSelectionChange(event: Event) {
<div class="sign-doc-banner">
<span>已按任务固化的文书版本渲染(占位符已填充、"勾选"表已交互化)</span>
<span class="sign-doc-state">
{{ task.signatureDataUrl ? '已采集签名' : task.status === 'signed' ? '已签署' : '待患者签署' }}
{{
task.signatureDataUrl ? '已采集签名' : task.status === 'signed' ? '已签署' : '待患者签署'
}}
</span>
</div>
@@ -199,7 +65,9 @@ function handleSelectionChange(event: Event) {
<footer v-if="interactive" class="sign-doc-footer">
<span>
已勾选 <strong>{{ selectedItems.length }}</strong> 项{{ selectedItems.length ? `:${selectedItems.join('、')}` : '' }}
已勾选 <strong>{{ selectedItems.length }}</strong> 项{{
selectedItems.length ? `:${selectedItems.join('、')}` : ''
}}
</span>
<span>完成勾选后通过「手写板签署 / 线上签署」采集签名,勾选结果将随签署证据一并提交。</span>
</footer>
@@ -306,6 +174,16 @@ function handleSelectionChange(event: Event) {
opacity: 0.55;
}
/* 正文行内选项(□否 □是):用 label 包住方框与选项文字,点文字也能选中 */
.sign-doc-body :deep(.sign-doc-option) {
display: inline-flex;
gap: 3px;
align-items: center;
margin-right: 14px;
white-space: nowrap;
cursor: pointer;
}
.sign-doc-body :deep(.sign-doc-signature) {
max-width: 150px;
max-height: 52px;