feat(documents): 抽取签署文书渲染管线并新增签署预览
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user