feat(documents): 抽取签署文书渲染管线并新增签署预览
This commit is contained in:
+14
-1
@@ -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 与类型约定
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { documentStatusLabels } from '../mock'
|
||||
import { buildSigningDocumentHtml } from '@/utils/signing-document'
|
||||
|
||||
import { documentStatusLabels, signingPreviewPatient } from '../mock'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -16,6 +18,22 @@ const emit = defineEmits<{
|
||||
use: [template: DocumentTemplate]
|
||||
}>()
|
||||
|
||||
/** 预览页签:「文书内容」看原始定稿,「签署预览」看占位符填充与勾选交互化后的样子 */
|
||||
const activeTab = ref<'content' | 'signing'>('content')
|
||||
/** 签署预览里科室试勾选的结果,仅用于预览反馈,不作为签署证据 */
|
||||
const signingSelection = ref<string[]>([])
|
||||
const signingBodyRef = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
activeTab.value = 'content'
|
||||
signingSelection.value = []
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (!props.template) {
|
||||
return ''
|
||||
@@ -72,6 +90,29 @@ const previewSrcDoc = computed(() => {
|
||||
|
||||
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8" /><style>${DOCUMENT_BASE_STYLE}</style></head><body>${contentHtml}</body></html>`
|
||||
})
|
||||
|
||||
// 签署预览:复用签署端同一套渲染管线(占位符填充 → 签名域 → 勾选表交互化),
|
||||
// 患者/就诊数据用示例快照。interactive 传 true,科室可以在预览里试勾选、确认交互效果。
|
||||
// 这里不用 iframe:需要读取勾选结果做汇总,iframe 的 sandbox 也不便于表单交互。
|
||||
const signingPreviewHtml = computed(() =>
|
||||
buildSigningDocumentHtml(props.template?.contentHtml ?? '', signingPreviewPatient, true),
|
||||
)
|
||||
|
||||
// 勾选汇总只在预览内展示(切页签、重开弹窗即重置),不作为签署证据。
|
||||
function handleSigningSelectionChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
|
||||
if (!target.classList.contains('sign-doc-checkbox')) {
|
||||
return
|
||||
}
|
||||
|
||||
const body = signingBodyRef.value
|
||||
const checked = body
|
||||
? Array.from(body.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'))
|
||||
: []
|
||||
|
||||
signingSelection.value = checked.map((input) => input.getAttribute('data-sign-item') ?? '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -99,11 +140,55 @@ const previewSrcDoc = computed(() => {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="preview-tabs" role="tablist" aria-label="预览方式">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="preview-tab"
|
||||
:class="{ 'is-active': activeTab === 'content' }"
|
||||
:aria-selected="activeTab === 'content'"
|
||||
@click="activeTab = 'content'"
|
||||
>
|
||||
文书内容
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="preview-tab"
|
||||
:class="{ 'is-active': activeTab === 'signing' }"
|
||||
:aria-selected="activeTab === 'signing'"
|
||||
:disabled="!template.contentHtml"
|
||||
title="按签署时的效果渲染:占位符填充、勾选表交互化、签名域"
|
||||
@click="activeTab = 'signing'"
|
||||
>
|
||||
签署预览
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<div v-if="loading" class="preview-state">正在加载模板内容…</div>
|
||||
<div v-else-if="error" class="preview-state preview-state--error">
|
||||
模板内容加载失败,请关闭后重试。
|
||||
</div>
|
||||
<template v-else-if="template.contentHtml && activeTab === 'signing'">
|
||||
<p class="preview-note">
|
||||
按签署时效果渲染:占位符已用示例患者「{{ signingPreviewPatient.patientName }}」填充,
|
||||
表头含「勾选」的表格已转为勾选框,可直接勾选确认交互效果(预览不保存勾选结果)。
|
||||
</p>
|
||||
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
||||
<div
|
||||
ref="signingBodyRef"
|
||||
class="signing-preview"
|
||||
@change="handleSigningSelectionChange"
|
||||
v-html="signingPreviewHtml"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<p class="signing-selection">
|
||||
已勾选 <strong>{{ signingSelection.length }}</strong> 项{{
|
||||
signingSelection.length ? `:${signingSelection.join('、')}` : ''
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<iframe
|
||||
v-else-if="template.contentHtml"
|
||||
class="preview-frame"
|
||||
@@ -146,9 +231,7 @@ const previewSrcDoc = computed(() => {
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<p>
|
||||
文书版式取自科室真实知情同意书原件,{占位符}与勾选项在发起签署时由系统自动填充。
|
||||
</p>
|
||||
<p>文书版式取自科室真实知情同意书原件,{占位符}与勾选项在发起签署时由系统自动填充。</p>
|
||||
<button type="button" class="footer-button secondary" @click="emit('close')">关闭</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -258,6 +341,47 @@ const previewSrcDoc = computed(() => {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.preview-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.preview-tab {
|
||||
padding: 5px 12px;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.preview-tab:hover:not(:disabled) {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.preview-tab.is-active {
|
||||
color: var(--brand);
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.preview-tab:disabled {
|
||||
color: #b6c3c9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.preview-note {
|
||||
padding: 8px 12px;
|
||||
margin: 0 0 10px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
background: var(--brand-l);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
min-height: 0;
|
||||
padding: 12px 18px;
|
||||
@@ -284,6 +408,85 @@ const previewSrcDoc = computed(() => {
|
||||
border: 1px solid #eef4f6;
|
||||
}
|
||||
|
||||
/* 签署预览用 v-html 渲染(需读取勾选结果,因此不用 sandbox iframe)。
|
||||
下列基础文书样式需与 previewSrcDoc 使用的 DOCUMENT_BASE_STYLE 保持一致;
|
||||
v-html 注入的节点不携带 scoped 属性,统一用 :deep 覆盖。 */
|
||||
.signing-preview {
|
||||
min-height: 560px;
|
||||
padding: 24px 28px 36px;
|
||||
color: #1c2b33;
|
||||
font-family: 'SimSun', 'Songti SC', 'STSong', serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.85;
|
||||
background: #fff;
|
||||
border: 1px solid #eef4f6;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.signing-preview :deep(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.signing-preview :deep(table) {
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.signing-preview :deep(th),
|
||||
.signing-preview :deep(td) {
|
||||
padding: 5px 7px;
|
||||
border: 1px solid #52646e;
|
||||
vertical-align: middle;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.signing-preview :deep(th p),
|
||||
.signing-preview :deep(td p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-doc-checkbox) {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 正文行内选项(□否 □是):用 label 包住方框与选项文字,点文字也能选中 */
|
||||
.signing-preview :deep(.sign-doc-option) {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
margin-right: 14px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-doc-signature) {
|
||||
max-width: 150px;
|
||||
max-height: 52px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.signing-selection {
|
||||
padding: 8px 12px;
|
||||
margin: 10px 0 0;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
background: #f8fbfc;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.signing-selection strong {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.preview-paper {
|
||||
min-height: 420px;
|
||||
padding: 26px 34px;
|
||||
|
||||
@@ -255,7 +255,7 @@ function save() {
|
||||
/>
|
||||
<span v-if="importMessage" class="docx-message">{{ importMessage }}</span>
|
||||
<span v-else class="docx-hint">
|
||||
建议从 Word/WPS 中全选复制文书后直接粘贴到下方编辑区;勾选列表请保持"勾选"列为第一列,签署时会自动转为可交互勾选。
|
||||
建议从 Word/WPS 中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的条目请做成表格——表头第一行写「勾选」,旁边一列写「项目」,签署时该列会自动转为勾选框。
|
||||
</span>
|
||||
</div>
|
||||
<div class="editor-area">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SigningDocumentData } from '@/utils/signing-document'
|
||||
|
||||
import type { DocumentDepartment, DocumentStatus } from './types'
|
||||
|
||||
const documentStatusLabels: Record<DocumentStatus, string> = {
|
||||
@@ -16,3 +18,17 @@ export { documentStatusLabels }
|
||||
|
||||
// Word 自维护方案:文书库初始为空,所有文书由科室通过 Word 编辑器(粘贴 / 导入 .docx)创建维护。
|
||||
export const documentLibrary: DocumentDepartment[] = []
|
||||
|
||||
/**
|
||||
* 签署预览使用的示例患者快照(脱敏演示数据)。
|
||||
* 创建文书时还没有真实签署任务,用固定示例值让科室看到占位符被填充后的版式;
|
||||
* 真实签署仍以任务创建时的患者/就诊快照为准。
|
||||
*/
|
||||
export const signingPreviewPatient: SigningDocumentData = {
|
||||
patientName: '李某某',
|
||||
sex: '男',
|
||||
age: 46,
|
||||
visitNo: 'V20260914001',
|
||||
visitDate: '2026-09-14',
|
||||
signedAt: '',
|
||||
}
|
||||
|
||||
+23
-145
@@ -2,12 +2,11 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||
import { buildSigningDocumentHtml } from '@/utils/signing-document'
|
||||
|
||||
// 签署文书交互化渲染(原型管线):
|
||||
// ① 占位符填充({患者姓名}等 → 任务患者快照数据)
|
||||
// ② 勾选表识别:表头含"勾选"列的表格,逐行转为可交互复选框
|
||||
// ③ 签名域:任务已有签名图(signatureDataUrl)时替换 {患者签名}
|
||||
// 真实实现中填充在后端完成,勾选结果随签署证据提交;本组件用于前端闭环验证。
|
||||
// 签署文书交互化渲染:变换管线(占位符填充 / 签名域 / 勾选表转复选框)见
|
||||
// utils/signing-document,与文书库的「签署预览」共用同一实现。
|
||||
// 本组件只负责挂载 HTML、收集勾选结果并渲染签署态外壳。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
task: SigningTaskRecord
|
||||
@@ -23,144 +22,9 @@ const emit = defineEmits<{
|
||||
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
|
||||
})
|
||||
const processedHtml = computed(() =>
|
||||
buildSigningDocumentHtml(props.task.documentContentHtml ?? '', props.task, props.interactive),
|
||||
)
|
||||
|
||||
function handleSelectionChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
@@ -184,7 +48,9 @@ function handleSelectionChange(event: Event) {
|
||||
<div class="sign-doc-banner">
|
||||
<span>已按任务固化的文书版本渲染(占位符已填充、"勾选"表已交互化)</span>
|
||||
<span class="sign-doc-state">
|
||||
{{ task.signatureDataUrl ? '已采集签名' : task.status === 'signed' ? '已签署' : '待患者签署' }}
|
||||
{{
|
||||
task.signatureDataUrl ? '已采集签名' : task.status === 'signed' ? '已签署' : '待患者签署'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +65,9 @@ function handleSelectionChange(event: Event) {
|
||||
|
||||
<footer v-if="interactive" class="sign-doc-footer">
|
||||
<span>
|
||||
已勾选 <strong>{{ selectedItems.length }}</strong> 项{{ selectedItems.length ? `:${selectedItems.join('、')}` : '' }}
|
||||
已勾选 <strong>{{ selectedItems.length }}</strong> 项{{
|
||||
selectedItems.length ? `:${selectedItems.join('、')}` : ''
|
||||
}}
|
||||
</span>
|
||||
<span>完成勾选后通过「手写板签署 / 线上签署」采集签名,勾选结果将随签署证据一并提交。</span>
|
||||
</footer>
|
||||
@@ -306,6 +174,16 @@ function handleSelectionChange(event: Event) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* 正文行内选项(□否 □是):用 label 包住方框与选项文字,点文字也能选中 */
|
||||
.sign-doc-body :deep(.sign-doc-option) {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
margin-right: 14px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sign-doc-body :deep(.sign-doc-signature) {
|
||||
max-width: 150px;
|
||||
max-height: 52px;
|
||||
|
||||
Reference in New Issue
Block a user