315 lines
8.3 KiB
Vue
315 lines
8.3 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
|
||
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||
|
||
// 签署文书交互化渲染(原型管线):
|
||
// ① 占位符填充({患者姓名}等 → 任务患者快照数据)
|
||
// ② 勾选表识别:表头含"勾选"列的表格,逐行转为可交互复选框
|
||
// ③ 签名域:任务已有签名图(signatureDataUrl)时替换 {患者签名}
|
||
// 真实实现中填充在后端完成,勾选结果随签署证据提交;本组件用于前端闭环验证。
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
task: SigningTaskRecord
|
||
interactive?: boolean
|
||
}>(),
|
||
{ interactive: false },
|
||
)
|
||
|
||
const emit = defineEmits<{
|
||
'selection-change': [items: string[]]
|
||
}>()
|
||
|
||
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
|
||
})
|
||
|
||
function handleSelectionChange(event: Event) {
|
||
const target = event.target as HTMLInputElement
|
||
|
||
if (!target.classList.contains('sign-doc-checkbox')) {
|
||
return
|
||
}
|
||
|
||
const container = containerRef.value
|
||
const checked = container
|
||
? Array.from(container.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'))
|
||
: []
|
||
|
||
selectedItems.value = checked.map((item) => item.getAttribute('data-sign-item') ?? '')
|
||
emit('selection-change', [...selectedItems.value])
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<article class="sign-doc">
|
||
<div class="sign-doc-banner">
|
||
<span>已按任务固化的文书版本渲染(占位符已填充、"勾选"表已交互化)</span>
|
||
<span class="sign-doc-state">
|
||
{{ task.signatureDataUrl ? '已采集签名' : task.status === 'signed' ? '已签署' : '待患者签署' }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
||
<div
|
||
ref="containerRef"
|
||
class="sign-doc-body"
|
||
@change="handleSelectionChange"
|
||
v-html="processedHtml"
|
||
/>
|
||
<!-- eslint-enable vue/no-v-html -->
|
||
|
||
<footer v-if="interactive" class="sign-doc-footer">
|
||
<span>
|
||
已勾选 <strong>{{ selectedItems.length }}</strong> 项{{ selectedItems.length ? `:${selectedItems.join('、')}` : '' }}
|
||
</span>
|
||
<span>完成勾选后通过「手写板签署 / 线上签署」采集签名,勾选结果将随签署证据一并提交。</span>
|
||
</footer>
|
||
</article>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.sign-doc {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 480px;
|
||
background: #fff;
|
||
border-radius: var(--r);
|
||
box-shadow: var(--sh);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sign-doc-banner {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 9px 16px;
|
||
color: var(--brand);
|
||
font-size: 11.5px;
|
||
background: var(--brand-l);
|
||
}
|
||
|
||
.sign-doc-state {
|
||
padding: 2px 9px;
|
||
color: #fff;
|
||
font-weight: 700;
|
||
background: var(--brand);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.sign-doc-body {
|
||
flex: 1;
|
||
padding: 26px 32px 30px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.sign-doc-footer {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
padding: 10px 16px;
|
||
font-size: 12px;
|
||
color: var(--mut);
|
||
background: #f8fbfc;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
|
||
.sign-doc-footer strong {
|
||
color: var(--brand);
|
||
}
|
||
|
||
/* 注入的文书 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%;
|
||
}
|
||
|
||
.sign-doc-body :deep(.sign-doc-checkbox) {
|
||
width: 15px;
|
||
height: 15px;
|
||
accent-color: var(--brand);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.sign-doc-body :deep(.sign-doc-checkbox:disabled) {
|
||
cursor: not-allowed;
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.sign-doc-body :deep(.sign-doc-signature) {
|
||
max-width: 150px;
|
||
max-height: 52px;
|
||
vertical-align: middle;
|
||
}
|
||
</style>
|