/** * 文书渲染管线(原型)。 * * 把「模板定稿 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 }