From 5c5a8fc90bc21341506d71ea246fe3d485d07bbd Mon Sep 17 00:00:00 2001 From: yelan Date: Mon, 14 Sep 2026 11:06:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(documents):=20=E6=8A=BD=E5=8F=96=E7=AD=BE?= =?UTF-8?q?=E7=BD=B2=E6=96=87=E4=B9=A6=E6=B8=B2=E6=9F=93=E7=AE=A1=E7=BA=BF?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=E7=AD=BE=E7=BD=B2=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clinical-web/README.md | 15 +- clinical-web/src/utils/signing-document.ts | 304 ++++++++++++++++++ .../components/DocumentPreviewDialog.vue | 213 +++++++++++- .../documents/components/WordEditorDialog.vue | 2 +- .../src/views/management/documents/mock.ts | 16 + .../components/SigningDocumentInteractive.vue | 168 ++-------- 6 files changed, 566 insertions(+), 152 deletions(-) create mode 100644 clinical-web/src/utils/signing-document.ts diff --git a/clinical-web/README.md b/clinical-web/README.md index d3c11a6..954f740 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -111,7 +111,20 @@ ClinicalLayout 跨页面复用的业务组件和 composable 不放在具体 View 目录中: - `components/signing/NewSigningTaskDialog.vue`:新增签署任务公共弹窗,供签署工作台和文档库管理复用; -- `composables/useSigningTaskForm.ts`:封装患者定位、就诊选择、模板选择、签署方式、校验和任务提交状态。 +- `composables/useSigningTaskForm.ts`:封装患者定位、就诊选择、模板选择、签署方式、校验和任务提交状态; +- `utils/signing-document.ts`:把文书定稿 HTML 变换为签署态 HTML 的渲染管线(占位符填充、签名域、勾选与行内选项识别),签署工作台的任务详情和文档库「签署预览」共用同一份实现。 + +## 文书编写约定 + +科室在文档库的 Word 编辑器中维护文书,签署时需要交互化的内容按下面的写法即可,系统在签署和「签署预览」时自动转换: + +- 占位符:`{患者姓名}`、`{性别}`、`{年龄}`、`{就诊号}`、`{签署日期}`、`{患者签名}` 等,由签署任务的患者与就诊快照填充; +- 勾选表:做成表格,第一行表头写「勾选」,另设一列写「项目」作为勾选条目名,签署时「勾选」列每个单元格变成复选框; +- 行内选项:正文里直接写方框符号,如 `□否 □是`、`□同意 □不同意`。同一段落或同一单元格内出现两个及以上方框时按二选一(互斥单选)处理,只有一个方框时保持独立复选框语义。 + +编辑器弹窗顶部提供「插入勾选框」「二选一」按钮,光标停在表格单元格内时插入内容会落进该单元格,不需要科室自己找符号面板。导入(.docx / 粘贴)阶段只做格式转换,不做勾选识别;识别统一在签署渲染时进行。 + +识别规则集中在 `utils/signing-document.ts`,调整后签署端与「签署预览」会同步生效。 ## API 与类型约定 diff --git a/clinical-web/src/utils/signing-document.ts b/clinical-web/src/utils/signing-document.ts new file mode 100644 index 0000000..c9d3792 --- /dev/null +++ b/clinical-web/src/utils/signing-document.ts @@ -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 = { + '{患者姓名}': 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) => { + // 不能只认 :WangEditor 的表格节点是 ,它导出时不会生成 + // 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() + + 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( + `
${contentHtml}
`, + '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 +} diff --git a/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue b/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue index aac3f7b..a15e19f 100644 --- a/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue +++ b/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue @@ -1,7 +1,9 @@