feat(signing): 将签署占位符改为原子节点并支持多选选项组

This commit is contained in:
yelan
2026-09-24 12:13:24 +08:00
parent ec24e85829
commit 9a6ac93e21
13 changed files with 2141 additions and 121 deletions
+215 -56
View File
@@ -15,11 +15,19 @@
* 此处仅用于前端闭环验证。
*/
import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
import type {
SigningAnswers,
SigningFieldDef,
SigningFieldKind,
SigningFieldValue,
} from '@/api/workbench/types'
import {
collectStructuredFieldIds,
FIELD_ELEMENT_SELECTOR,
FIELD_TOKEN_OPEN,
PRIMARY_SIGNATURE_FIELD_ID,
readFieldFromElement,
resolveAnswersFromSystem,
scanFieldTokens,
SIGNING_DOCUMENT_HEADER,
@@ -44,6 +52,11 @@ 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'
/**
* 多选组的标记。渲染时由这里打上,回读取值时靠它区分
* 「一组多选 checkbox」与「单个勾选框」——两者 DOM 里都是 input[type=checkbox]。
*/
const FIELD_MULTI_ATTR = 'data-sign-multi'
/** 需要宿主接管交互的填写项(目前只有「点击采集签名」),值是动作名 */
const FIELD_ACTION_ATTR = 'data-sign-action'
/** 签名位的采集动作名 */
@@ -101,6 +114,71 @@ export interface SigningSignatureRequest {
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')
}
function buildFieldWidget(
doc: Document,
field: SigningFieldDef,
@@ -109,8 +187,8 @@ function buildFieldWidget(
signatureActionable: boolean,
): HTMLElement {
const value = answers[field.id]
const abnormal =
Boolean(field.risk) && typeof value === 'string' && value === (field.risk as string)
const abnormal = hitsRisk(value, field.risk)
const multi = isMultiOptionGroup(field)
const host = doc.createElement('span')
host.className = `sign-field sign-field--${field.kind}${abnormal ? ' sign-field--abnormal' : ''}`
@@ -119,6 +197,11 @@ function buildFieldWidget(
host.setAttribute(FIELD_LABEL_ATTR, field.label)
host.setAttribute(FIELD_REQUIRED_ATTR, field.required ? 'true' : 'false')
// 回读取值时靠它区分「一组多选」与「单个勾选框」:两者 DOM 里都是 checkbox
if (multi) {
host.setAttribute(FIELD_MULTI_ATTR, 'true')
}
if (field.risk) {
host.setAttribute(FIELD_RISK_ATTR, field.risk)
}
@@ -150,7 +233,7 @@ function buildFieldWidget(
const control = doc.createElement('span')
control.className = 'sign-field__control'
control.appendChild(buildFieldControl(doc, field, value, interactive, signaturePending))
control.appendChild(buildFieldControl(doc, field, value, interactive, signaturePending, multi))
host.appendChild(control)
if (abnormal) {
@@ -163,14 +246,56 @@ function buildFieldWidget(
return host
}
/**
* 一组选项:单选渲染成一组 radio(互斥),多选渲染成一组 checkbox(各勾各的)。
*/
function buildOptionGroup(
doc: Document,
field: SigningFieldDef,
value: SigningFieldValue | undefined,
interactive: boolean,
multi: boolean,
): HTMLElement {
const group = doc.createElement('span')
group.className = `sign-field__choices${multi ? ' sign-field__choices--multi' : ''}`
const selected = toSelectedValues(value)
field.options.forEach((option) => {
const wrapper = doc.createElement('label')
wrapper.className = 'sign-doc-option'
const input = doc.createElement('input')
input.className = multi ? 'sign-field__checkbox' : 'sign-field__radio'
input.type = multi ? 'checkbox' : 'radio'
// 同名:单选靠它互斥,多选靠它被 readAnswersFromRoot 一起取回
input.name = `sign-field-${field.id}`
input.value = option
input.disabled = !interactive
// 必须同时落成 attribute:innerHTML 只序列化 HTML 属性,
// 只设 .checked 这个 property 会在生成 HTML 时被丢掉
if (selected.includes(option)) {
input.checked = true
input.setAttribute('checked', '')
}
wrapper.appendChild(input)
wrapper.appendChild(doc.createTextNode(option))
group.appendChild(wrapper)
})
return group
}
function buildFieldControl(
doc: Document,
field: SigningFieldDef,
value: SigningAnswers[string],
value: SigningFieldValue | undefined,
interactive: boolean,
signaturePending: boolean,
multi: boolean,
): Node {
const textValue = typeof value === 'string' ? value : value ? '是' : ''
const textValue = toTextValue(value)
if (field.kind === 'signature') {
if (textValue) {
@@ -189,36 +314,10 @@ function buildFieldControl(
return empty
}
// 有选项才渲染单选组;没有选项(结构化占位符只声明了 kind=choice 却没给 options)
// 就退化成文本框,否则科室拿到的是一个点不动的空控件
if (field.kind === 'choice' && field.options.length) {
const group = doc.createElement('span')
group.className = 'sign-field__choices'
field.options.forEach((option) => {
const wrapper = doc.createElement('label')
wrapper.className = 'sign-doc-option'
const input = doc.createElement('input')
input.className = 'sign-field__radio'
input.type = 'radio'
input.name = `sign-field-${field.id}`
input.value = option
input.disabled = !interactive
// 必须同时落成 attribute:innerHTML 只序列化 HTML 属性,
// 只设 .checked 这个 property 会在生成 HTML 时被丢掉
if (textValue === option) {
input.checked = true
input.setAttribute('checked', '')
}
wrapper.appendChild(input)
wrapper.appendChild(doc.createTextNode(option))
group.appendChild(wrapper)
})
return group
// 声明了选项就渲染成一组控件;没给选项的 choice 会落到下面的文本框分支,
// 否则科室拿到的是一个点不动的空控件
if (isOptionGroupField(field)) {
return buildOptionGroup(doc, field, value, interactive, multi)
}
if (field.kind === 'checkbox') {
@@ -264,12 +363,17 @@ function buildFieldControl(
* 自定义文本项靠"同名占位符第几次出现"编号,这里数出的序号必须和
* 填写项统计(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)
@@ -290,7 +394,7 @@ function renderTokenFields(
const fragment = doc.createDocumentFragment()
let cursor = 0
scanFieldTokens(text, counters).forEach(({ start, end, field }) => {
scanFieldTokens(text, counters, claimed).forEach(({ start, end, field }) => {
if (start > cursor) {
fragment.appendChild(doc.createTextNode(text.slice(cursor, start)))
}
@@ -309,9 +413,12 @@ function renderTokenFields(
}
/**
* 结构化占位符渲染:兼容 <span data-field="..."> 写法。
* 该写法是目标格式(字段语义显式、不依赖中文标签),
* 供后端模板或后续换编辑器后生产的内容使用。
* 结构化占位符渲染:把 `<span data-field="...">` 就地换成填写项控件。
*
* 属性解析交给 utils/signing-fields 的 readFieldFromElement,
* 不在这里另写一份:编辑器写入、填写项统计、签署渲染三处必须共用同一套
* 属性名与缺省口径(尤其 data-multi 这种"缺失即按类型取缺省"的字段),
* 各自解析迟早会出现"编辑器里是多选、签署时变成单选"的静默失配。
*/
function renderStructuredFields(
root: Element,
@@ -321,23 +428,13 @@ function renderStructuredFields(
) {
const doc = root.ownerDocument
root.querySelectorAll('span[data-field]').forEach((element) => {
const id = element.getAttribute('data-field') ?? ''
const label = element.getAttribute('data-label') ?? id
root.querySelectorAll(FIELD_ELEMENT_SELECTOR).forEach((element) => {
const field = readFieldFromElement(element)
if (!id) {
if (!field) {
return
}
const field: SigningFieldDef = {
id,
label,
kind: (element.getAttribute('data-kind') ?? 'text') as SigningFieldKind,
required: element.getAttribute('data-required') === 'true',
options: (element.getAttribute('data-options') ?? '').split('|').filter(Boolean),
risk: element.getAttribute('data-risk') ?? undefined,
}
element.replaceWith(buildFieldWidget(doc, field, answers, interactive, signatureActionable))
})
}
@@ -484,6 +581,9 @@ function replaceOptionMarkers(
* 行内选项识别(存量兼容):正文里手打的方框(□否 □是、□同意 □不同意)。
* 同一段/同一单元格里出现两个及以上方框时按「二选一」处理成互斥单选;
* 只有一个方框则保持多选语义,避免把「□高血压」这类单项确认误判成互斥组。
*
* 占位符写法不走这里:`<span data-field data-options data-multi>` 的单/多选是
* 模板里显式声明的(见 isMultiOptionGroup),不需要靠数方框个数去猜。
*/
function convertInlineOptions(root: Element, interactive: boolean, selected: Set<string>) {
const groups = new Map<Element, Text[]>()
@@ -600,8 +700,12 @@ export function buildSigningDocumentHtml(
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)
renderTokenFields(root, answers, interactive, signatureActionable, claimed)
const selected = new Set(data.selectedItems ?? [])
convertCheckboxes(root, interactive, selected)
@@ -634,6 +738,16 @@ export function readAnswersFromRoot(root: HTMLElement): SigningAnswers {
return
}
// 多选组:一组 checkbox,取值是字符串数组(勾了几个就是几个)。
// 必须先于下面的 checkbox 分支判断——两者 DOM 里都是 input[type=checkbox],
// 只有渲染时打上的 data-sign-multi 能区分「一组多选」与「单个勾选框」
if (host.getAttribute(FIELD_MULTI_ATTR) === 'true') {
answers[id] = Array.from(
host.querySelectorAll<HTMLInputElement>('input[type="checkbox"]:checked'),
).map((input) => input.value)
return
}
if (kind === 'choice') {
const checked = host.querySelector<HTMLInputElement>('input[type="radio"]:checked')
answers[id] = checked?.value ?? ''
@@ -652,6 +766,48 @@ export function readAnswersFromRoot(root: HTMLElement): SigningAnswers {
return answers
}
/**
* 把一个已固化的取值写回填写项的控件(签署端重新打开文书时还原状态)。
*
* 与 readAnswersFromRoot 是一对:那边按控件类型读、这边按同一套判据写,
* 两者都放在本文件里,属性名与判据只有一份——签署端不该自己写 data-sign-* 选择器。
*/
export function applyAnswerToFieldHost(host: HTMLElement, value: SigningFieldValue): void {
// 多选组:一组 checkbox,取值是字符串数组。
// 必须先判它,否则会落到下面的布尔分支,把数组当成「勾上了」全部勾选
if (host.getAttribute(FIELD_MULTI_ATTR) === 'true') {
const picked = new Set(Array.isArray(value) ? value : [])
host.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((box) => {
box.checked = picked.has(box.value)
})
return
}
if (typeof value === 'boolean') {
const checkbox = host.querySelector<HTMLInputElement>('input[type="checkbox"]')
if (checkbox) {
checkbox.checked = value
}
return
}
const text = Array.isArray(value) ? '' : value
const input = host.querySelector<HTMLInputElement>(
'input:not([type="radio"]):not([type="checkbox"])',
)
if (input) {
input.value = text
}
host.querySelectorAll<HTMLInputElement>('input[type="radio"]').forEach((radio) => {
radio.checked = radio.value === text
})
}
/**
* 把签名图写进指定签名位,并摘掉它的采集入口。返回是否真的发生了改动。
*
@@ -700,6 +856,9 @@ export function applySignatureToRoot(root: HTMLElement, fieldId: string, dataUrl
*
* 按字段键去重:同一个字段在文书里可能出现多次(页眉的患者姓名/就诊号与正文重复是常态),
* 它们共用一份取值,只能算一个填写项,否则分母会被重复计数撑大。
*
* 「已填」的判据见 isEmptyFieldValue:多选取值只有空数组才算没填——
* 用 `value === ''` 之类的朴素判据会把空数组当成已填,必填校验就漏了。
*/
export function describeFieldProgress(
root: HTMLElement,
@@ -725,7 +884,7 @@ export function describeFieldProgress(
seen.add(id)
const value = answers[id]
const empty = value === undefined || value === '' || value === false
const empty = isEmptyFieldValue(value)
const risk = host.getAttribute(FIELD_RISK_ATTR)
const isSignature = host.getAttribute(FIELD_KIND_ATTR) === 'signature'
@@ -750,7 +909,7 @@ export function describeFieldProgress(
progress.pendingSignatures.push(label)
}
if (risk && value === risk) {
if (hitsRisk(value, risk ?? undefined)) {
progress.abnormal.push(label)
}
})