1144 lines
44 KiB
TypeScript
1144 lines
44 KiB
TypeScript
/**
|
||
* 文书渲染管线(原型)。
|
||
*
|
||
* 把「模板定稿 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 = ' '
|
||
|
||
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)里是 ` `,直接取 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
|
||
}
|