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
+304
View File
@@ -0,0 +1,304 @@
/**
* 签署文书 HTML 渲染管线(原型)。
*
* 把「文书定稿 HTML」按签署时的效果做一次变换:
* ① 占位符填充({患者姓名}等 → 患者与就诊快照数据);
* ② 签名域:已有签名图时替换 {患者签名},否则给出待签署提示;
* ③ 勾选表识别:表头含「勾选」列的表格,逐行转为复选框;
* ④ 行内选项识别:正文手打的 □ 方框(□否 □是)转为单选/多选框。
*
* 签署任务详情(SigningDocumentInteractive)与文书库的「签署预览」共用本模块,
* 避免两处各自演化后渲染结果不一致。真实实现中填充在后端完成、勾选结果随签署证据
* 提交,此处仅用于前端闭环验证。
*/
/** 渲染所需的患者/就诊快照字段;SigningTaskRecord 与预览用示例数据都满足此形状。 */
export interface SigningDocumentData {
patientName: string
sex: string
age: number | string
visitNo: string
visitDate: string
signedAt?: string
signatureDataUrl?: string
}
const SIGNATURE_PLACEHOLDER = '{患者签名}'
/**
* 科室手打的复选框符号:几何图形方框,以及 Wingdings 私有区码位。
* Word「插入 → 符号」里的方框多为私有区字符(U+F0A8 等),粘贴后字体信息丢失、
* 只剩下码位,所以这些也要一并识别。
*/
const OPTION_MARKERS = '□☐▢◻❑❒\uF0A8\uF0FE'
/** split 用:捕获组保证分隔符本身保留在结果里 */
const OPTION_SPLIT_PATTERN = new RegExp(`([${OPTION_MARKERS}])`)
/** match 用(带 g 只用于 String.match,不参与 RegExp.test,避免 lastIndex 副作用) */
const OPTION_ALL_PATTERN = new RegExp(`[${OPTION_MARKERS}]`, 'g')
const OPTION_TEST_PATTERN = new RegExp(`[${OPTION_MARKERS}]`)
/** 判定「同一组选项」的块级容器:同一段、同一单元格里的方框视为一组 */
const OPTION_BLOCK_SELECTOR = 'p, li, td, th, div, h1, h2, h3, h4, h5, h6'
/** 收集所有包含指定关键字(占位符前缀等)的文本节点 */
function collectTextNodes(root: Element, keyword: string): Text[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const targets: Text[] = []
let node = walker.nextNode()
while (node) {
if ((node.textContent ?? '').includes(keyword)) {
targets.push(node as Text)
}
node = walker.nextNode()
}
return targets
}
function replaceTextPlaceholder(root: Element, placeholder: string, replacement: string) {
collectTextNodes(root, placeholder).forEach((textNode) => {
textNode.textContent = (textNode.textContent ?? '').split(placeholder).join(replacement)
})
}
/** 把 {患者签名} 就地换成签名图片,保留同一文本节点内的前后文字 */
function replaceSignaturePlaceholder(root: Element, dataUrl: string) {
collectTextNodes(root, SIGNATURE_PLACEHOLDER).forEach((textNode) => {
const parts = (textNode.textContent ?? '').split(SIGNATURE_PLACEHOLDER)
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, data: SigningDocumentData) {
const replacements: Record<string, string> = {
'{患者姓名}': data.patientName,
'{性别}': data.sex,
'{年龄}': `${data.age} 岁`,
'{就诊号}': data.visitNo,
'{联系电话}': '—',
'{预约日期}': data.visitDate,
'{与患者关系}': '本人',
'{签署日期}': data.signedAt || data.visitDate || '  年  月  日',
}
collectTextNodes(root, '{').forEach((textNode) => {
Object.entries(replacements).forEach(([placeholder, value]) => {
textNode.textContent = (textNode.textContent ?? '').split(placeholder).join(value)
})
})
}
function convertCheckboxes(root: Element, interactive: boolean) {
root.querySelectorAll('table').forEach((table) => {
// 不能只认 <thead>:WangEditor 的表格节点是 <table><tbody><tr>,它导出时不会生成
// thead,粘贴带 thead 的表格时其 preParseHtml 也把 thead 拆掉;.docx 经 mammoth
// 转换才带 thead。两种结构都要能定位表头行与数据行。
const rows = Array.from(table.querySelectorAll('tr'))
const headerRow = table.querySelector('thead tr') ?? rows[0]
if (!headerRow) {
return
}
const headerCells = Array.from(headerRow.querySelectorAll('th, td'))
const checkIndex = headerCells.findIndex((cell) => cell.textContent?.includes('勾选'))
if (checkIndex === -1) {
return
}
const nameIndex = headerCells.findIndex((cell) => cell.textContent?.includes('项目'))
// 没有「项目」列时退化为「除勾选列外的第一列」,避免固定取第 2 列取错
const fallbackIndex = headerCells.findIndex((_, index) => index !== checkIndex)
table.classList.add('sign-doc-table')
rows
.filter((row) => row !== headerRow)
.forEach((row) => {
const cells = Array.from(row.querySelectorAll('td, th'))
const checkCell = cells[checkIndex]
if (!checkCell) {
return
}
const nameCell = cells[nameIndex > -1 ? nameIndex : fallbackIndex]
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 = !interactive
checkCell.textContent = ''
checkCell.appendChild(checkbox)
})
})
}
/** 收集所有含方框符号的文本节点 */
function collectOptionTextNodes(root: Element): Text[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const targets: Text[] = []
let node = walker.nextNode()
while (node) {
if (OPTION_TEST_PATTERN.test(node.textContent ?? '')) {
targets.push(node as Text)
}
node = walker.nextNode()
}
return targets
}
/**
* 把一个文本节点里的方框换成可交互控件:方框 + 紧随其后的文字算一个选项,
* 例如「□否 □是」→ 两个 radio,选项名分别是「否」「是」。
*/
function replaceOptionMarkers(
textNode: Text,
type: 'radio' | 'checkbox',
groupName: string,
interactive: boolean,
) {
const parts = (textNode.textContent ?? '').split(OPTION_SPLIT_PATTERN)
const fragment = document.createDocumentFragment()
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index] ?? ''
if (!OPTION_TEST_PATTERN.test(part)) {
if (part) {
fragment.appendChild(document.createTextNode(part))
}
continue
}
// 选项名取方框后的第一段文字,尾随的标点/空格原样留在选项外
const labelMatch = /^\s*([^\s。,、;:,.;:]*)([\s\S]*)$/.exec(parts[index + 1] ?? '')
const label = labelMatch?.[1] ?? ''
const tail = labelMatch?.[2] ?? ''
const input = document.createElement('input')
input.type = type
input.className = 'sign-doc-checkbox'
input.disabled = !interactive
input.setAttribute('data-sign-item', label)
if (type === 'radio') {
input.name = groupName
input.value = label
}
const wrapper = document.createElement('label')
wrapper.className = 'sign-doc-option'
wrapper.appendChild(input)
wrapper.appendChild(document.createTextNode(label))
fragment.appendChild(wrapper)
if (tail) {
fragment.appendChild(document.createTextNode(tail))
}
// 选项名已随方框一起消费,跳过它对应的片段
index += 1
}
textNode.replaceWith(fragment)
}
/**
* 行内选项识别:正文里手打的方框(□否 □是、□同意 □不同意)。
* 同一段/同一单元格里出现两个及以上方框时按「二选一」处理成互斥单选;
* 只有一个方框则保持多选语义,避免把「□高血压」这类单项确认误判成互斥组。
*/
function convertInlineOptions(root: Element, interactive: boolean) {
const groups = new Map<Element, Text[]>()
collectOptionTextNodes(root).forEach((textNode) => {
const block = textNode.parentElement?.closest(OPTION_BLOCK_SELECTOR) ?? root
const bucket = groups.get(block)
if (bucket) {
bucket.push(textNode)
} else {
groups.set(block, [textNode])
}
})
let groupSeq = 0
groups.forEach((textNodes, block) => {
const markerCount = block.textContent?.match(OPTION_ALL_PATTERN)?.length ?? 0
if (markerCount === 0) {
return
}
groupSeq += 1
const type = markerCount > 1 ? 'radio' : 'checkbox'
const groupName = `sign-option-${groupSeq}`
textNodes.forEach((textNode) => replaceOptionMarkers(textNode, type, groupName, interactive))
})
}
/**
* 生成签署态文书 HTML。
* contentHtml 为空时返回空串;解析失败时原样返回,不会抛错。
* interactive 为 false(预览场景)时复选框只读展示。
*/
export function buildSigningDocumentHtml(
contentHtml: string,
data: SigningDocumentData,
interactive = false,
): string {
if (!contentHtml) {
return ''
}
const doc = new DOMParser().parseFromString(
`<div id="sign-doc-root">${contentHtml}</div>`,
'text/html',
)
const root = doc.getElementById('sign-doc-root')
if (!root) {
return contentHtml
}
fillPatientPlaceholders(root, data)
if (data.signatureDataUrl) {
replaceSignaturePlaceholder(root, data.signatureDataUrl)
} else {
replaceTextPlaceholder(root, SIGNATURE_PLACEHOLDER, '(待患者签署)')
}
convertCheckboxes(root, interactive)
convertInlineOptions(root, interactive)
return root.innerHTML
}