Files
xh-medical-sign-web/clinical-web/src/views/workbench/signing/components/SigningDocumentInteractive.vue
T

315 lines
8.3 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">
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>