/**
* 文书渲染管线(原型)。
*
* 把「模板定稿 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
}
/**
* 一个选项。
*
* 刻意用文本而不是 ``:原型的签署态文书里没有任何表单控件,
* 选项就是 ☑ / ☐ 两个字符,点击切换类名与文字。这样导出的 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 = ' '
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,
) {
const doc = root.ownerDocument
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const targets: Text[] = []
const counters = new Map()
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)
})
}
/**
* 结构化占位符渲染:把 `` 就地换成填写项控件。
*
* 属性解析交给 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)
})
}
/**
* 存量兼容①:表头含「勾选」列的表格,逐行把勾选列转成可点击的 ☑ / ☐。
*
* 为什么不是 ``:原型的口径是"勾选列 → 占位符 → ☑ 文本"
* (见其 docCheckboxize + fillDocHtml),签署态文书里没有表单控件。
* 两种外观混在一篇文书里,会让人以为有一处没生效。
*
* 项目名仍写在 data-sign-item 上:它是这项勾选的**取值标识**,
* 格子里只显示一个 ☐——项目名在左边那一列已经写着,重复一遍反而挤。
*/
function convertCheckboxTables(root: Element, interactive: boolean, selected: Set) {
const doc = root.ownerDocument
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 || 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,
) {
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)
}
/**
* 行内选项识别(存量兼容):正文里手打的方框(□否 □是、□同意 □不同意)。
* 同一段/同一单元格里出现两个及以上方框时按「二选一」处理成互斥单选;
* 只有一个方框则保持多选语义,避免把「□高血压」这类单项确认误判成互斥组。
*
* 占位符写法不走这里:`` 的单/多选是
* 模板里显式声明的(见 isMultiOptionGroup),不需要靠数方框个数去猜。
*/
function convertInlineOptions(root: Element, interactive: boolean, selected: Set) {
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, 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(
`${contentHtml}
`,
'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(
`[${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(`[${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(`.${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(`.${OPTION_CLASS}.${OPTION_ON_CLASS}`)).map(
(item) => item.getAttribute(OPTION_VALUE_ATTR) ?? '',
)
}
/**
* 读填写项的文本取值。
*
* 两种载体都要认:签署页是输入框(取 value),预览与已签署查看是正文文字。
* 只读态的空值占位(.docfld-blank)里是 ` `,直接取 textContent 会得到一个
* 不换行空格——那会让「已填 / 未填」的判据全部失效(空值看起来像有值),
* 所以把占位节点先摘掉再读。
*/
function readHostText(host: HTMLElement): string {
const input = host.querySelector(`.${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,必须写成
。
// 少了这一分支它会掉到下面的文本分支,把整串 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(`.${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(`.${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(`[${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(`[${GROUP_ATTR}="${groupName}"]`))
: Array.from(host?.querySelectorAll(`.${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(`.${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(`.${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 始终是取值的唯一来源。
*
* 签名图只作为
写进渲染结果,不进模板正文:
* 它是 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(`[${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()
root.querySelectorAll(`[${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
}