Files
xh-medical-sign-web/clinical-web/src/utils/signing-document.ts
T

1144 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
/**
* 文书渲染管线(原型)。
*
* 把「模板定稿 HTML」渲染成签署态:
* ① 填写项渲染:占位符按控件类型渲染成文本/日期/单选/勾选/签名控件,
* 初值来自 HIS 与患者就诊快照,签署时可在文书上直接确认或补录;
* ② 签名回显:签名是 kind=signature 的填写项,签名图作为该字段的取值,
* 签完后落在文书原本的签名位上,不需要"替换文字"这一步;
* ③ 勾选表识别(存量兼容):表头含「勾选」列的表格逐行转为复选框;
* ④ 行内选项识别(存量兼容):正文手打的 □ 方框转成单选/多选框。
*
* ③④ 是为已经存在的存量文书保留的启发式识别;用占位符写的新文书不再依赖它们。
*
* 渲染所需的取值模型见 signing-fields.ts;真实实现中填充与固化在后端完成,
* 此处仅用于前端闭环验证。
*/
import type {
SigningAnswers,
SigningFieldDef,
SigningFieldKind,
SigningFieldValue,
} from '@/api/workbench/types'
import { unlockTableLayout } from './rich-text'
import {
collectStructuredFieldIds,
FIELD_ELEMENT_SELECTOR,
FIELD_TOKEN_OPEN,
PRIMARY_SIGNATURE_FIELD_ID,
readFieldFromElement,
resolveAnswersFromSystem,
scanFieldTokens,
SIGNING_OPTION_MARKERS,
type SigningSystemSnapshot,
} from './signing-fields'
/** 渲染所需的患者/就诊快照 + 已填写的填写项取值。 */
export interface SigningDocumentData extends SigningSystemSnapshot {
/** 已签署的存量文书里,由「勾选表」收集的条目 */
selectedItems?: string[]
/**
* 填写项取值:已填过的值优先于系统快照(用于签署后回显)。
* 属性名与 SigningTaskRecord.fieldAnswers 一致,避免任务对象透传时对不上而静默丢值。
*/
fieldAnswers?: SigningAnswers
}
/** 填写项在文书上的宿主节点属性,签署端靠它收集取值 */
const FIELD_ATTR = 'data-sign-field'
const FIELD_KIND_ATTR = 'data-sign-kind'
const FIELD_LABEL_ATTR = 'data-sign-label'
const FIELD_REQUIRED_ATTR = 'data-sign-required'
const FIELD_RISK_ATTR = 'data-sign-risk'
/**
* 选项组的标记,渲染时由这里**总是**打上(true / false)。
*
* 为什么要显式写 false 而不是「有才写」:回读取值时要区分三种形态——
* 一组多选、一组单选、以及没有选项的单个勾选框;后两者都可能是
* 「没有这个属性 + kind=checkbox」,光看属性在不在分不出来。
* 显式落成 true / false 后,「有没有这个属性」就等于「是不是一组选项」。
*/
const FIELD_MULTI_ATTR = 'data-sign-multi'
/** 需要宿主接管交互的填写项(目前只有「点击采集签名」),值是动作名 */
const FIELD_ACTION_ATTR = 'data-sign-action'
/** 签名位的采集动作名 */
const SIGNATURE_ACTION = 'signature'
/** 可点击采集的签名位类名,样式与摘除入口时都要用同一个 */
const ACTIONABLE_CLASS = 'sign-field--actionable'
/** 填写项宿主节点类名(原型 .docfld:编辑态虚线胶囊 → 渲染态保留实线细框) */
const FIELD_HOST_CLASS = 'docfld'
/** 渲染态标记:去掉"待填写"的虚线胶囊,改为保留一圈实线细框(原型 .docfld.filled) */
const FIELD_FILLED_CLASS = 'filled'
/** 空值下划线 */
const BLANK_CLASS = 'docfld-blank'
/**
* 文本 / 日期填写项的可输入控件类名。
*
* 原型里这类项是**只读**的:值全部来自 HIS 与患者就诊快照,取不到就留一条下划线。
* 但本仓库还有「与患者关系」「受托人姓名」这类没有系统取值来源、必须现场手填的
* 自定义文本项(见 utils/signing-fields 的 createCustomField),做成只读等于把
* 代理人签署这条路堵死。折中办法是保留输入框、但把外观压成原型那条下划线:
* 空着是一条线、填好了是正文,视觉上与原型一致,又没丢能力。
*/
const INPUT_CLASS = 'docfld-input'
/** 签名图类名,readAnswersFromRoot 与写入端都按它找 */
const SIGNATURE_IMAGE_CLASS = 'docfld-signature'
/** 单个选项的类名(原型 .docopt) */
const OPTION_CLASS = 'docopt'
/** 可点击选项的附加类名(原型 .docopt.tick) */
const OPTION_TICK_CLASS = 'tick'
/** 已勾选标记(原型 .docopt.on) */
const OPTION_ON_CLASS = 'on'
/**
* 无名称的单个勾选框的附加类名。
*
* 去掉「待患者勾选」那句说明文字后,这一格只剩一个 ☐,点击区域从上百像素缩到二十来像素,
* 签署时患者不好点。样式里靠这个类把内边距补回来(见 styles/signing-document.css)。
*/
const OPTION_BARE_CLASS = 'bare'
/** 选项取值的载体属性;表格勾选列与行内方框也用同一个,回读时只认它 */
const OPTION_VALUE_ATTR = 'data-sign-item'
/**
* 行内方框转换出来的选项所属的组名。
* 这些选项散落在正文的文本节点里、没有共同的宿主节点,
* 互斥只能靠这个属性在整篇文书里找同组兄弟(见 toggleOptionAt)。
*/
const GROUP_ATTR = 'data-sign-group'
/**
* 选项的显示名。与取值标识(data-sign-item)分开存:
* 表格勾选列的取值标识是项目名,但那一格只显示一个 ☐。
* 切换勾选状态时要按显示名重建文案,所以它必须落成属性,不能从取值推。
*/
const OPTION_TEXT_ATTR = 'data-sign-text'
/**
* 可点击采集签名的填写项宿主节点选择器。
* 导出给签署端做事件委托用 —— 渲染与识别必须指向同一个属性,不能两边各写一份字符串。
*/
export const SIGNATURE_ACTION_SELECTOR = `[${FIELD_ACTION_ATTR}="${SIGNATURE_ACTION}"]`
/**
* 可点击切换的选项选择器。
* 导出给签署端做事件委托:渲染端决定"哪些选项可点"(.tick),
* 签署端只负责把点击转成 toggleOptionAt,两边不能各写一份选择器。
*/
export const OPTION_TICK_SELECTOR = `.${OPTION_CLASS}.${OPTION_TICK_CLASS}`
/**
* 「定位到签名位」的高亮类名(原型 `.sig-locate`)与持续时间。
*
* 原型 `openIpad()` 用 4200ms:滚动到签名位后闪一下,提示操作人「点这个签名框就能签」。
* 类名与时长都放这里,样式在 styles/signing-document.css —— 组件里别再各写一份。
*/
export const SIGN_FIELD_LOCATE_CLASS = 'sign-field--locate'
export const SIGN_FIELD_LOCATE_MS = 4200
/**
* 科室手打的复选框符号见 SIGNING_OPTION_MARKERS(utils/signing-fields):
* 编辑器标记与这里识别必须用同一份字符集,故不再本地声明。
*/
const OPTION_MARKERS = SIGNING_OPTION_MARKERS
/** 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'
/** 一个填写项在文书上的取值状态,用于签署端提示必填与异常 */
export interface SigningFieldProgress {
total: number
filled: number
/** 未填写的必填项标签 */
missingRequired: string[]
/** 取值命中风险的项标签 */
abnormal: string[]
/**
* 仍未采集的签名位标签(不含主签名位「患者签名」)。
*
* 签名位在文书里可以逐个点击采集(医师签名、家属签名……),
* 主签名位则是手写板 / 线上签署要采集的那一个,把它列进来等于让签名入口挡住自己。
* 签署端用这份清单在采集前提醒「还有签名位没签」,避免文书带着空签名位被固化。
*/
pendingSignatures: string[]
}
/**
* 签署端点击文中签名位后发出的采集请求。
*
* label 一并带出去,是为了让签字板弹窗能说清「现在采的是谁的名字」——
* 只传 fieldId 的话弹窗只能显示一个内部键,操作人员看不出这笔签名要落在哪。
*/
export interface SigningSignatureRequest {
fieldId: string
label: string
}
/** 取值统一成「已选中的选项」数组:多选是数组,单选/文本是单个字符串。 */
function toSelectedValues(value: SigningFieldValue | undefined): string[] {
if (Array.isArray(value)) {
return value
}
return typeof value === 'string' && value ? [value] : []
}
/** 取值统一成可放进输入框的文本。数组(多选)没有单一文本表示,返回空串。 */
function toTextValue(value: SigningFieldValue | undefined): string {
if (typeof value === 'string') {
return value
}
return value === true ? '是' : ''
}
/**
* 取值是否命中高危值。多选取值里勾中了高危项也算命中。
*
* 渲染侧的「异常项」角标与进度统计的 abnormal 清单共用这一条判据——
* 两边各写一份迟早会漂移,出现「界面标了异常、清单里没有」的矛盾。
*/
function hitsRisk(value: SigningFieldValue | undefined, risk: string | undefined): boolean {
if (!risk || value === undefined) {
return false
}
return Array.isArray(value) ? value.includes(risk) : value === risk
}
/** 取值是否为空。多选取值只有空数组才算没填。 */
function isEmptyFieldValue(value: SigningFieldValue | undefined): boolean {
if (value === undefined || value === '' || value === false) {
return true
}
return Array.isArray(value) && value.length === 0
}
/**
* 这个填写项是否渲染成「一组选项」。
*
* 声明了选项的 choice / checkbox 都是一组控件;没给选项的(结构化占位符只写了
* kind=choice 却没写 data-options)会退化成文本框,否则科室拿到的是一个点不动的空控件。
*/
function isOptionGroupField(field: SigningFieldDef): boolean {
return field.options.length > 0 && (field.kind === 'choice' || field.kind === 'checkbox')
}
/**
* 一组选项是单选还是多选。
*
* 由模板显式声明(data-multi),缺省口径与 SigningFieldDef.multi 一致:
* choice 默认单选,checkbox 带选项时默认多选。
*
* 这条声明替代了原先的启发式(convertInlineOptions 里按"这一段有几个方框"猜)——
* 那个启发式让"同一段里两个方框"只能互斥,科室没法声明多选组。
* 手打方框的存量文书仍走启发式,见 convertInlineOptions。
*/
function isMultiOptionGroup(field: SigningFieldDef): boolean {
return isOptionGroupField(field) && (field.multi ?? field.kind === 'checkbox')
}
/**
* 选项的显示文案:勾选态用 ☑、未勾选用 ☐,空一格后跟选项名。
*
* **没有选项名的单个勾选框只落一个符号**,不再补说明文字。
*
* 这是**有意偏离原型 V10.2**(2026-10-06 定):原型给无选项名的勾选框补
* 「☐ 待患者勾选」/「☑ 已勾选(结果固化归档)」,等于把方框当正文写。
* 而方框本身就是"这里要勾",这一处在勾什么由旁边的文字(表格的其它列、
* 正文的上一句)交代,补一句说明只会把版面挤开;编辑层同一处也只显示一个 ☑,
* 签署层补一句中文会让同一处控件两副长相。
* 字段名仍留在宿主 title 里,悬停可见。**别照原型改回去。**
*/
function optionDisplay(value: string, on: boolean): string {
const mark = on ? '☑' : '☐'
return value ? `${mark} ${value}` : mark
}
/**
* 一个选项。
*
* 刻意用文本而不是 `<input>`:原型的签署态文书里没有任何表单控件,
* 选项就是 ☑ / ☐ 两个字符,点击切换类名与文字。这样导出的 HTML
* 与纸质文书的观感一致,也不会因为表单控件的默认样式在各浏览器下跑版。
* 交互由签署端的事件委托完成(见 toggleOptionAt),所以这里只落一个 .tick 标记。
*
* displayValue 与 itemValue 分开:表格勾选列的项目名要写进 data-sign-item 当取值标识,
* 但那一格只显示一个 ☐——项目名在左边那一列已经写过了,重复一遍反而挤。
*/
function buildOptionItem(
doc: Document,
itemValue: string,
on: boolean,
interactive: boolean,
displayValue: string = itemValue,
): HTMLElement {
const item = doc.createElement('span')
item.className = [
OPTION_CLASS,
on ? OPTION_ON_CLASS : '',
interactive ? OPTION_TICK_CLASS : '',
displayValue ? '' : OPTION_BARE_CLASS,
]
.filter(Boolean)
.join(' ')
if (interactive) {
item.setAttribute('role', 'checkbox')
item.setAttribute('tabindex', '0')
item.setAttribute('aria-checked', String(on))
item.setAttribute('aria-label', displayValue || '勾选此项')
}
item.setAttribute(OPTION_VALUE_ATTR, itemValue)
item.setAttribute(OPTION_TEXT_ATTR, displayValue)
item.textContent = optionDisplay(displayValue, on)
return item
}
/** 切换一个选项的勾选态(改类名 + 重建 ☑ / ☐ 文案),返回切换后的状态 */
function setOptionState(item: HTMLElement, on: boolean): boolean {
item.classList.toggle(OPTION_ON_CLASS, on)
if (item.hasAttribute('aria-checked')) item.setAttribute('aria-checked', String(on))
item.textContent = optionDisplay(item.getAttribute(OPTION_TEXT_ATTR) ?? '', on)
return on
}
/**
* 空值占位:一条下划线。
*
* 原型只给下划线、不给标签,因为它的填写项几乎都由 HIS 预填,
* 空值属于"系统没取到值"的少数情况。这里补一个 title,
* 让鼠标停在下划线上能看出这一处该填什么——外观一行没动。
*/
function buildBlank(doc: Document, label: string): HTMLElement {
const blank = doc.createElement('span')
blank.className = BLANK_CLASS
blank.innerHTML = '&nbsp;'
if (label) {
blank.title = `待填写:${label}`
}
return blank
}
function buildFieldWidget(
doc: Document,
field: SigningFieldDef,
answers: SigningAnswers,
interactive: boolean,
signatureActionable: boolean,
): HTMLElement {
const value = answers[field.id]
const abnormal = hitsRisk(value, field.risk)
const multi = isMultiOptionGroup(field)
const empty = isEmptyFieldValue(value)
const host = doc.createElement('span')
// 原型口径:渲染出来的填写项**一律**带 .filled —— 签署态文书不再有"待填写"的
// 虚线胶囊,填空处是下划线、填好的地方就是正文;.filled 表达的是"已渲染为签署态",
// 不是"已填值"。未填的必填项靠下面的 sign-field--missing 补一条提示色。
host.className = [
FIELD_HOST_CLASS,
`k-${field.kind}`,
FIELD_FILLED_CLASS,
abnormal ? 'sign-field--abnormal' : '',
interactive && empty ? 'sign-field--missing' : '',
]
.filter(Boolean)
.join(' ')
host.setAttribute(FIELD_ATTR, field.id)
host.setAttribute(FIELD_KIND_ATTR, field.kind)
host.setAttribute(FIELD_LABEL_ATTR, field.label)
host.setAttribute(FIELD_REQUIRED_ATTR, field.required ? 'true' : 'false')
// 选项组显式落成 true / false:回读取值时靠"有没有这个属性"区分
// 「一组选项」与「没有选项的单个勾选框」,两者 DOM 里都是一串 .docopt
if (isOptionGroupField(field)) {
host.setAttribute(FIELD_MULTI_ATTR, multi ? 'true' : 'false')
}
if (field.risk) {
host.setAttribute(FIELD_RISK_ATTR, field.risk)
}
// 已签名的签名位不再挂动作:留着会让"还能再点一次"的观感落空
const signaturePending = field.kind === 'signature' && !value
if (signatureActionable && signaturePending) {
host.classList.add(ACTIONABLE_CLASS)
host.setAttribute(FIELD_ACTION_ATTR, SIGNATURE_ACTION)
// 可点就得可键盘触发,否则只在鼠标下可用
host.setAttribute('role', 'button')
host.setAttribute('tabindex', '0')
host.setAttribute('title', `点击采集「${field.label}」`)
} else if (field.label) {
host.title = field.required ? `${field.label}(必填)` : field.label
}
if (field.kind === 'signature') {
const text = toTextValue(value)
if (text) {
const image = doc.createElement('img')
image.className = SIGNATURE_IMAGE_CLASS
image.src = text
image.alt = field.label
host.appendChild(image)
} else {
const pending = doc.createElement('span')
pending.className = 'docfld-empty'
pending.textContent = '未签署'
host.appendChild(pending)
}
return host
}
// 声明了选项就渲染成一组 ☑ / ☐;没给选项的 choice 会落到下面的文本分支,
// 否则科室拿到的是一个点不动的空控件
if (isOptionGroupField(field)) {
const selected = toSelectedValues(value)
field.options.forEach((option) => {
host.appendChild(buildOptionItem(doc, option, selected.includes(option), interactive))
})
return host
}
// 没有选项的单个勾选框:一个 ☑ / ☐,取值是布尔
if (field.kind === 'checkbox') {
host.appendChild(buildOptionItem(doc, '', value === true, interactive))
return host
}
const text = toTextValue(value)
// 可交互(签署页):给一个压成下划线外观的输入框,现场可补录
if (interactive) {
const input = doc.createElement('input')
input.className = INPUT_CLASS
input.type = field.kind === 'date' ? 'date' : 'text'
input.maxLength = 4000
input.value = text
// value 属性同理:只设 property 的话序列化出来是空值
input.setAttribute('value', text)
host.appendChild(input)
return host
}
// 只读(预览 / 已签署查看):原型口径——有值就是正文,没值留一条下划线
if (text) {
host.textContent = text
} else {
host.appendChild(buildBlank(doc, field.label))
}
return host
}
/**
* 占位符渲染:把 {患者姓名} 就地替换成填写项控件,保留同一文本节点内的前后文字。
* 模板未收录的占位符按自定义文本项处理,仍可在文书上填写,
* 避免出现"写了占位符但签署时既填不上也看不见"的情况。
*
* 解析统一走 scanFieldTokens,且计数器跨文本节点共用一个:
* 自定义文本项靠"同名占位符第几次出现"编号,这里数出的序号必须和
* 填写项统计(collectFieldDefs)数出来的完全一致,否则同一处内容
* 在面板里是一个字段键、落到 DOM 上是另一个,取值就悄悄对不上了。
*
* claimed 是结构化占位符已占用的键,由调用方在**渲染结构化占位符之前**取好传进来:
* 结构化占位符渲染完就变成 `data-sign-field` 控件,那时已经认不出它的字段键了。
* 少了这一步,混用两种载体的正文里,纯文本那一遍会数出与结构化元素相同的键。
*/
function renderTokenFields(
root: Element,
answers: SigningAnswers,
interactive: boolean,
signatureActionable: boolean,
claimed: Set<string>,
) {
const doc = root.ownerDocument
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const targets: Text[] = []
const counters = new Map<string, number>()
let node = walker.nextNode()
while (node) {
if ((node.textContent ?? '').includes(FIELD_TOKEN_OPEN)) {
targets.push(node as Text)
}
node = walker.nextNode()
}
targets.forEach((textNode) => {
const text = textNode.textContent ?? ''
const fragment = doc.createDocumentFragment()
let cursor = 0
scanFieldTokens(text, counters, claimed).forEach(({ start, end, field }) => {
if (start > cursor) {
fragment.appendChild(doc.createTextNode(text.slice(cursor, start)))
}
fragment.appendChild(buildFieldWidget(doc, field, answers, interactive, signatureActionable))
cursor = end
})
if (cursor < text.length) {
fragment.appendChild(doc.createTextNode(text.slice(cursor)))
}
textNode.replaceWith(fragment)
})
}
/**
* 结构化占位符渲染:把 `<span data-field="...">` 就地换成填写项控件。
*
* 属性解析交给 utils/signing-fields 的 readFieldFromElement,
* 不在这里另写一份:编辑器写入、填写项统计、签署渲染三处必须共用同一套
* 属性名与缺省口径(尤其 data-multi 这种"缺失即按类型取缺省"的字段),
* 各自解析迟早会出现"编辑器里是多选、签署时变成单选"的静默失配。
*/
function renderStructuredFields(
root: Element,
answers: SigningAnswers,
interactive: boolean,
signatureActionable: boolean,
) {
const doc = root.ownerDocument
root.querySelectorAll(FIELD_ELEMENT_SELECTOR).forEach((element) => {
const field = readFieldFromElement(element)
if (!field) {
return
}
const readonly = element.getAttribute('data-readonly') === 'true'
const widget = buildFieldWidget(
doc,
readonly ? { ...field, required: false } : field,
answers,
interactive && !readonly,
signatureActionable && (!readonly || field.kind === 'signature'),
)
element.replaceWith(widget)
})
}
/**
* 存量兼容①:表头含「勾选」列的表格,逐行把勾选列转成可点击的 ☑ / ☐。
*
* 为什么不是 `<input type=checkbox>`:原型的口径是"勾选列 → 占位符 → ☑ 文本"
* (见其 docCheckboxize + fillDocHtml),签署态文书里没有表单控件。
* 两种外观混在一篇文书里,会让人以为有一处没生效。
*
* 项目名仍写在 data-sign-item 上:它是这项勾选的**取值标识**,
* 格子里只显示一个 ☐——项目名在左边那一列已经写着,重复一遍反而挤。
*/
function convertCheckboxTables(root: Element, interactive: boolean, selected: Set<string>) {
const doc = root.ownerDocument
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 || checkCell.querySelector('[data-sign-field], [data-field]')) {
return
}
const nameCell = cells[nameIndex > -1 ? nameIndex : fallbackIndex]
const itemName = (nameCell?.textContent ?? '').trim()
checkCell.replaceChildren(
buildOptionItem(doc, itemName, selected.has(itemName), interactive, ''),
)
})
})
}
/**
* 已经渲染成控件的区域:结构化占位符宿主(含它内部的选项)与已经生成的选项本身。
* 这两个区域里的方框符号是**我们自己的输出**(☑/☐ 文本),不能再当"科室手打的方框"识别。
*/
const RENDERED_OPTION_SELECTOR = `.${OPTION_CLASS}, [${FIELD_ATTR}]`
/**
* 收集所有含方框符号的文本节点。
*
* 必须排除已渲染区域,否则会自我套娃:选项的文本就是 `☐ 选项`,而 `☐` 恰好是
* SIGNING_OPTION_MARKERS 里的字符,扫描器会把它当成科室手打的方框再转一次,
* 于是在 `.docopt` 里再嵌一个 `.docopt` —— 患者看到的是「☐ 患者本人 ☐ 患者本人」,
* 且内层用「选项名到标点为止」的规则截断,长选项名会被切掉后半截。
*/
function collectOptionTextNodes(root: Element): Text[] {
const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const targets: Text[] = []
let node = walker.nextNode()
while (node) {
const text = node.textContent ?? ''
if (OPTION_TEST_PATTERN.test(text) && !isInsideRenderedOption(node as Text)) {
targets.push(node as Text)
}
node = walker.nextNode()
}
return targets
}
function isInsideRenderedOption(node: Text): boolean {
return Boolean(node.parentElement?.closest(RENDERED_OPTION_SELECTOR))
}
/**
* 把一个文本节点里的方框换成可点击的 ☑ / ☐ 选项:方框 + 紧随其后的文字算一个选项,
* 例如「□否 □是」→ 两个选项,取值分别是「否」「是」。
*
* 同组标记(data-sign-group)落在选项自己身上,而不是包一层容器:
* 这些方框散落在正文的文本节点里,跨节点包容器会改掉文书结构。
* 单选组还会带上 data-sign-multi="false",签署端据此做互斥。
*/
function replaceOptionMarkers(
textNode: Text,
type: 'radio' | 'checkbox',
groupName: string,
interactive: boolean,
selected: Set<string>,
) {
const doc = textNode.ownerDocument
const parts = (textNode.textContent ?? '').split(OPTION_SPLIT_PATTERN)
const fragment = doc.createDocumentFragment()
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index] ?? ''
if (!OPTION_TEST_PATTERN.test(part)) {
if (part) {
fragment.appendChild(doc.createTextNode(part))
}
continue
}
// 选项名取方框后的第一段文字,尾随的标点/空格原样留在选项外
const labelMatch = /^\s*([^\s。,、;:,.;:]*)([\s\S]*)$/.exec(parts[index + 1] ?? '')
const label = labelMatch?.[1] ?? ''
const tail = labelMatch?.[2] ?? ''
const item = buildOptionItem(doc, label, label !== '' && selected.has(label), interactive)
item.setAttribute(GROUP_ATTR, groupName)
if (type === 'radio') {
item.setAttribute(FIELD_MULTI_ATTR, 'false')
}
fragment.appendChild(item)
if (tail) {
fragment.appendChild(doc.createTextNode(tail))
}
// 选项名已随方框一起消费,跳过它对应的片段
index += 1
}
textNode.replaceWith(fragment)
}
/**
* 行内选项识别(存量兼容):正文里手打的方框(□否 □是、□同意 □不同意)。
* 同一段/同一单元格里出现两个及以上方框时按「二选一」处理成互斥单选;
* 只有一个方框则保持多选语义,避免把「□高血压」这类单项确认误判成互斥组。
*
* 占位符写法不走这里:`<span data-field data-options data-multi>` 的单/多选是
* 模板里显式声明的(见 isMultiOptionGroup),不需要靠数方框个数去猜。
*/
function convertInlineOptions(root: Element, interactive: boolean, selected: Set<string>) {
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, selected),
)
})
}
/** 渲染开关。默认全部关闭,只读展示是"安全侧"缺省。 */
export interface SigningDocumentRenderOptions {
/**
* 签名位是否渲染成「✍ 点击签名」的可点按钮。
*
* 与 interactive 分开是必须的:文书库的静态预览也是 interactive 的
* (科室能在预览里试勾选、确认交互效果),但那里没有事件委托宿主,
* 签名位点了不会有任何反应 —— 渲染成可点按钮就是骗人。
*/
signatureActionable?: boolean
}
/**
* 生成签署态文书 HTML。
* contentHtml 为空时返回空串;解析失败时原样返回,不会抛错。
* interactive 为 false(预览 / 已签署查看)时填写项只读展示取值。
*/
export function buildSigningDocumentHtml(
contentHtml: string,
data: SigningDocumentData,
interactive = false,
options: SigningDocumentRenderOptions = {},
): 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
}
// 表格的「宽度锁」在渲染这一侧也要解掉,且必须在渲染填写项之前:
// 编辑器导出的每张表都带 style="width: auto"(WangEditor 的序列化口径),
// 它是**内联样式**,会盖掉 `.doc-paper table { width: 100% }` —— 结果就是
// 「编辑器里撑满版心、预览/签署里按内容收缩」。详见 utils/rich-text 的 unlockTableLayout。
unlockTableLayout(root)
// 已填写的取值优先于系统快照:签署后被固化的填写项要保持原样回显
const answers: SigningAnswers = {
...resolveAnswersFromSystem(data),
...(data.fieldAnswers ?? {}),
}
if (data.signatureDataUrl && !answers.signature) {
answers.signature = data.signatureDataUrl
}
const signatureActionable = interactive && Boolean(options.signatureActionable)
// 占用集合必须在渲染之前取:renderStructuredFields 一跑,[data-field] 就变成
// data-sign-field 控件了,之后再也认不出它们占用了哪些字段键
const claimed = collectStructuredFieldIds(contentHtml)
renderStructuredFields(root, answers, interactive, signatureActionable)
renderTokenFields(root, answers, interactive, signatureActionable, claimed)
// 已签任务但图片产物暂时取不到时,不要把权威状态渲染成“未签署”。
// 签名图本身仍以 SIGNATURE_IMAGE 产物为准;这里仅修正文案,避免误导查看者。
if (data.signedAt && !answers[PRIMARY_SIGNATURE_FIELD_ID]) {
const pendingLabel = root.querySelector<HTMLElement>(
`[${FIELD_ATTR}="${PRIMARY_SIGNATURE_FIELD_ID}"] .docfld-empty`,
)
if (pendingLabel) {
pendingLabel.textContent = '签名图片暂不可显示'
}
}
const selected = new Set(data.selectedItems ?? [])
convertCheckboxTables(root, interactive, selected)
convertInlineOptions(root, interactive, selected)
// 渲染只**填值**,不往正文里添任何内容:文书长什么样就是编辑器里那一份。
// 这里曾经最后插入一条系统页眉(患者姓名 / 性别 / 年龄 / 住院号),2026-09-30 去掉:
// 编辑器里没有它,编辑时看不到、签出来和下载的 PDF 上却多一条。
// 患者信息要出现在文书上,就在正文里插对应占位符(编辑器「HIS 自动填充」里有这几个按钮)。
return root.innerHTML
}
/**
* 从渲染后的文书 DOM 里读回填写项取值。
* 签名读的是签名图,其余按控件形态读,返回结果直接可作为签署证据的一部分固化。
*
* 三条读取路径与 buildFieldWidget 的三种形态一一对应,判据只看宿主上的属性,
* 不再猜 DOM:带 data-sign-multi 的是一组选项,kind=checkbox 的是单个勾选框,
* 其余按文本读。
*/
export function readAnswersFromRoot(root: HTMLElement): SigningAnswers {
const answers: SigningAnswers = {}
root.querySelectorAll<HTMLElement>(`[${FIELD_ATTR}]`).forEach((host) => {
const id = host.getAttribute(FIELD_ATTR)
if (!id) {
return
}
const kind = (host.getAttribute(FIELD_KIND_ATTR) ?? 'text') as SigningFieldKind
if (kind === 'signature') {
answers[id] = host.querySelector('img')?.getAttribute('src') ?? ''
return
}
const multiAttr = host.getAttribute(FIELD_MULTI_ATTR)
// 一组选项:勾中的项取 data-sign-item。
// 单选返回字符串、多选返回数组——与 SigningFieldValue 的约定一致
if (multiAttr !== null) {
const picked = collectCheckedItems(host)
answers[id] = multiAttr === 'true' ? picked : (picked[0] ?? '')
return
}
// 没有选项的单个勾选框:取值是布尔。
// 找不到勾选节点时按「未勾选」处理——`?.classList.contains()` 会返回 undefined,
// 直接落库会在证据里留下一个 undefined 而不是 false
if (kind === 'checkbox') {
answers[id] =
host.querySelector<HTMLElement>(`.${OPTION_CLASS}`)?.classList.contains(OPTION_ON_CLASS) ??
false
return
}
answers[id] = readHostText(host)
})
return answers
}
/** 某个宿主下已勾选的选项取值(按文档顺序) */
function collectCheckedItems(host: HTMLElement): string[] {
return Array.from(host.querySelectorAll<HTMLElement>(`.${OPTION_CLASS}.${OPTION_ON_CLASS}`)).map(
(item) => item.getAttribute(OPTION_VALUE_ATTR) ?? '',
)
}
/**
* 读填写项的文本取值。
*
* 两种载体都要认:签署页是输入框(取 value),预览与已签署查看是正文文字。
* 只读态的空值占位(.docfld-blank)里是 `&nbsp;`,直接取 textContent 会得到一个
* 不换行空格——那会让「已填 / 未填」的判据全部失效(空值看起来像有值),
* 所以把占位节点先摘掉再读。
*/
function readHostText(host: HTMLElement): string {
const input = host.querySelector<HTMLInputElement>(`.${INPUT_CLASS}`)
if (input) {
return input.value.trim()
}
const clone = host.cloneNode(true) as HTMLElement
clone.querySelectorAll(`.${BLANK_CLASS}`).forEach((blank) => blank.remove())
return (clone.textContent ?? '').trim()
}
/**
* 把一个已固化的取值写回填写项(签署端重新打开文书时还原状态)。
*
* 与 readAnswersFromRoot 是一对:那边按宿主属性读、这边按同一套判据写,
* 两者都放在本文件里,属性名与判据只有一份——签署端不该自己写 data-sign-* 选择器。
*/
export function applyAnswerToFieldHost(host: HTMLElement, value: SigningFieldValue): void {
// 签名位的取值是图片 dataURL,必须写成 <img>。
// 少了这一分支它会掉到下面的文本分支,把整串 base64 当正文塞进宿主,
// 页面上就会出现一长串 data:image/png;base64,… 而不是签名图 ——
// 记录页「查看」与签署工作台详情读的都是这条路径,两边会一起中招。
if (host.getAttribute(FIELD_KIND_ATTR) === 'signature') {
// 空值不用动:渲染阶段已经落了「未签署」提示,这里没有可写的东西
if (typeof value === 'string' && value) {
writeSignatureToHost(host, value, host.getAttribute(FIELD_ATTR) ?? '')
}
return
}
const items = Array.from(host.querySelectorAll<HTMLElement>(`.${OPTION_CLASS}`))
const multiAttr = host.getAttribute(FIELD_MULTI_ATTR)
// 一组选项:多选取数组、单选取字符串。两种都先把整组清干净再按取值点亮,
// 否则「从「是」改成「否」」会留下两个勾
if (multiAttr !== null && items.length) {
const picked = new Set(
multiAttr === 'true' ? toSelectedValues(value) : [toTextValue(value)].filter(Boolean),
)
items.forEach((item) => {
setOptionState(item, picked.has(item.getAttribute(OPTION_VALUE_ATTR) ?? ''))
})
return
}
// 没有选项的单个勾选框:取值是布尔
if (items.length === 1) {
setOptionState(items[0] as HTMLElement, value === true)
return
}
// 文本项只认字符串:数组(选项组)与布尔(勾选框)都在这条分支之前被处理掉了,
// 真落到这里说明取值类型与控件形态对不上,按空值处理而不是硬塞一个值进去
const text = typeof value === 'string' ? value : ''
const input = host.querySelector<HTMLInputElement>(`.${INPUT_CLASS}`)
if (input) {
input.value = text
input.setAttribute('value', text)
return
}
if (text) {
host.textContent = text
return
}
// 清空要还原成下划线占位,不能留一个空壳(空壳看起来像"这一项被删了")
host.replaceChildren(buildBlank(host.ownerDocument, host.getAttribute(FIELD_LABEL_ATTR) ?? ''))
}
/**
* 切换点击到的那个选项,返回是否真的发生了变化。
*
* 三种形态在这里统一处理:
* - 结构化占位符与存量勾选表的选项:宿主带 data-sign-multi,互斥范围就是这个宿主;
* - 行内方框转出来的选项:散在正文里、没有共同宿主,靠 data-sign-group 找同组兄弟。
*
* root 是整篇文书的容器:只有它才能把"同组兄弟"找全(见上)。
* 这一层逻辑放在本文件而不是签署端组件里,是为了和渲染端共用
* 「什么算同组」这一条判据——组件里再推一遍迟早和渲染漂移。
*/
export function toggleOptionAt(root: HTMLElement, item: HTMLElement): boolean {
if (!item.classList.contains(OPTION_TICK_CLASS) || !root.contains(item)) {
return false
}
const host = item.closest<HTMLElement>(`[${FIELD_ATTR}]`)
const groupName = item.getAttribute(GROUP_ATTR)
const multiAttr = host?.getAttribute(FIELD_MULTI_ATTR) ?? item.getAttribute(FIELD_MULTI_ATTR)
// 缺省按多选:只有明确声明过互斥的组才做单选
const multi = multiAttr !== 'false'
const on = item.classList.contains(OPTION_ON_CLASS)
if (multi) {
setOptionState(item, !on)
return true
}
const siblings = groupName
? Array.from(root.querySelectorAll<HTMLElement>(`[${GROUP_ATTR}="${groupName}"]`))
: Array.from(host?.querySelectorAll<HTMLElement>(`.${OPTION_CLASS}`) ?? [item])
// 单选:再点一次已选中的项等于取消,与原型 tickDocOpt 的口径一致
siblings.forEach((sibling) => setOptionState(sibling, !on && sibling === item))
return true
}
/**
* 收集"散装"选项里已勾选的项(存量勾选表 + 行内方框),即 SigningTaskRecord.selectedItems。
*
* 刻意排除落在 `[data-sign-field]` 宿主里的选项:结构化填写项的取值走 fieldAnswers,
* 两边都收会让同一份勾选在任务上存两份、且口径可能不一致。
*/
export function collectSelectedItemsFromRoot(root: HTMLElement): string[] {
return Array.from(root.querySelectorAll<HTMLElement>(`.${OPTION_CLASS}.${OPTION_ON_CLASS}`))
.filter((item) => !item.closest(`[${FIELD_ATTR}]`))
.map((item) => item.getAttribute(OPTION_VALUE_ATTR) ?? '')
.filter(Boolean)
}
/** 按已固化的勾选清单还原"散装"选项的状态(与 collectSelectedItemsFromRoot 对称) */
export function applySelectedItemsToRoot(root: HTMLElement, items: string[]): void {
const selected = new Set(items)
root.querySelectorAll<HTMLElement>(`.${OPTION_CLASS}`).forEach((item) => {
if (item.closest(`[${FIELD_ATTR}]`)) {
return
}
setOptionState(item, selected.has(item.getAttribute(OPTION_VALUE_ATTR) ?? ''))
})
}
/**
* 把签名图写进**已经拿到手的**签名位宿主,并摘掉它的采集入口。返回是否真的发生了写入。
*
* 只改这一个宿主节点,不重建整篇文书 —— 整篇重建会换掉 v-html 的 DOM,
* 操作人员正在输入的填写项会连同光标一起丢。写完后由调用方回读 DOM
* (readAnswersFromRoot),文书 DOM 始终是取值的唯一来源。
*
* 签名图只作为 <img src> 写进渲染结果,不进模板正文:
* 它是 base64 大字符串,进正文会让模板哈希随签名变化,缓存键跟着失效。
*
* 宿主级实现,供两条路径共用:applySignatureToRoot(只拿到根,按字段键找宿主)
* 与 applyAnswerToFieldHost(宿主就在手上)。签名图的 DOM 形态只能有一份定义,
* 否则「点出来的签名」与「读回来的签名」会长得不一样。
*/
function writeSignatureToHost(host: HTMLElement, dataUrl: string, altFallback = ''): boolean {
// 同一张图重复写入没有意义,返回 false 也让调用方少跑一轮回读
if (host.querySelector(`img.${SIGNATURE_IMAGE_CLASS}`)?.getAttribute('src') === dataUrl) {
return false
}
const label = host.getAttribute(FIELD_LABEL_ATTR) ?? ''
const image = host.ownerDocument.createElement('img')
image.className = SIGNATURE_IMAGE_CLASS
image.src = dataUrl
image.alt = label || altFallback
// 签名位填充后只剩签名图:原型不保留「未签署」提示,也不再有点击提示
host.replaceChildren(image)
host.title = label
// 采集入口用完即摘:留着会让「还能再点一次」的观感落空
host.classList.remove(ACTIONABLE_CLASS, 'sign-field--missing')
host.removeAttribute(FIELD_ACTION_ATTR)
host.removeAttribute('role')
host.removeAttribute('tabindex')
return true
}
/**
* 按字段键在根节点里找到签名位,把签名图写进去。返回是否真的发生了改动。
*
* 与 applyAnswerToFieldHost 共用 writeSignatureToHost:签名图的 DOM 形态只有一份定义。
*/
export function applySignatureToRoot(root: HTMLElement, fieldId: string, dataUrl: string): boolean {
if (!fieldId || !dataUrl) {
return false
}
const host = root.querySelector<HTMLElement>(`[${FIELD_ATTR}="${fieldId}"]`)
if (!host) {
return false
}
return writeSignatureToHost(host, dataUrl, fieldId)
}
/**
* 统计填写项进度:已填数量、未填的必填项、命中的异常项。
*
* 按字段键去重:同一个字段在文书里可能出现多次(签名位每页都有、患者姓名在正文里反复提到),
* 它们共用一份取值,只能算一个填写项,否则分母会被重复计数撑大。
*
* 「已填」的判据见 isEmptyFieldValue:多选取值只有空数组才算没填——
* 用 `value === ''` 之类的朴素判据会把空数组当成已填,必填校验就漏了。
*/
export function describeFieldProgress(
root: HTMLElement,
answers: SigningAnswers,
): SigningFieldProgress {
const progress: SigningFieldProgress = {
total: 0,
filled: 0,
missingRequired: [],
abnormal: [],
pendingSignatures: [],
}
const seen = new Set<string>()
root.querySelectorAll<HTMLElement>(`[${FIELD_ATTR}]`).forEach((host) => {
const id = host.getAttribute(FIELD_ATTR)
const label = host.getAttribute(FIELD_LABEL_ATTR) ?? id ?? ''
if (!id || seen.has(id)) {
return
}
seen.add(id)
const value = answers[id]
const empty = isEmptyFieldValue(value)
const risk = host.getAttribute(FIELD_RISK_ATTR)
const isSignature = host.getAttribute(FIELD_KIND_ATTR) === 'signature'
progress.total += 1
if (!empty) {
progress.filled += 1
} else if (host.getAttribute(FIELD_REQUIRED_ATTR) === 'true' && !isSignature) {
// 签名必填但尚未采集,正是"点击签署"要完成的事,不能算作待补的必填项,
// 否则签名入口会把自己挡住。
progress.missingRequired.push(label)
}
// 待采集的签名位 = 挂上了采集动作、却还没采集的那一个(见 buildFieldWidget)。
// 直接认渲染时打的动作标记,而不是自己再推一遍「kind=signature 且无值」:
// 动作标记本身已经排除了已签名的位,两边判据一致才不会出现
// 「界面显示不可点、清单却催你点」的矛盾。
if (
host.getAttribute(FIELD_ACTION_ATTR) === SIGNATURE_ACTION &&
id !== PRIMARY_SIGNATURE_FIELD_ID
) {
progress.pendingSignatures.push(label)
}
if (hitsRisk(value, risk ?? undefined)) {
progress.abnormal.push(label)
}
})
return progress
}