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
+475 -22
View File
@@ -9,10 +9,15 @@
* ③ 必填与选项:模板保存时可做去重校验,签署时可校验必填项是否填完;
* ④ 风险值(risk):签署取值命中即标记为异常项。
*
* 载体仍沿用既有文书里的 {患者姓名} 写法,原因见 buildFieldToken 的注释:
* 当前编辑器(WangEditor / Slate)无法无损往返带 data-* 属性的结构化节点,
* 而纯文本占位符可以,且存量模板零迁移。渲染管线同时支持 span[data-field] 写法,
* 后续换成支持结构化节点的编辑器时可以平滑升级。
* 载体有两种,由本文件同时维护读写:
* ① 纯文本 {患者姓名} —— 存量文书的写法。正则解析、零迁移,但只是一段文字,
* 在编辑器里要按五六次退格才能删干净,也承载不了「选项 / 多选 / 必填」;
* ② `<span data-field>` 结构化占位符 —— 新写入的目标格式。在编辑器里是一个原子节点
* (见 utils/signing-field-node),点一下退格即可整体删除,字段语义全部显式声明。
*
* 两种载体的渲染、统计、签署取值都走同一套解析(collectFieldOccurrences),
* 因此可以并存,不需要一次性迁移存量模板;需要时用 rewriteFieldOccurrences
* 把某个字段就地升级成结构化写法。
*/
import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
@@ -21,6 +26,126 @@ import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/wo
export const FIELD_TOKEN_OPEN = '{'
export const FIELD_TOKEN_CLOSE = '}'
/**
* 结构化占位符的元素与属性契约。
*
* 两种载体并存,各有分工:
* - **纯文本 {患者姓名}**:存量模板的写法。正则解析、零迁移风险,但只是一段文字,
* 在编辑器里要按五六次退格才能删干净,也没法承载"选项""多选"这类结构化信息。
* - **`<span data-field>`**:新写入的目标格式。在编辑器里是一个原子节点(见
* utils/signing-field-node),点一下退格即可整体删除;字段键、类型、选项、多选
* 全部显式声明,不依赖中文标签匹配。
*
* 两个方向都由本文件维护:写入走 buildFieldSpanHtml,读取走 readFieldFromElement,
* 渲染端(utils/signing-document)与编辑器端(utils/signing-field-node)共用这一份,
* 避免属性名在两处各自演化。
*/
export const FIELD_ELEMENT_TAG = 'span'
export const FIELD_ID_ATTR = 'data-field'
export const FIELD_KIND_ATTR = 'data-kind'
export const FIELD_LABEL_ATTR = 'data-label'
export const FIELD_REQUIRED_ATTR = 'data-required'
export const FIELD_OPTIONS_ATTR = 'data-options'
export const FIELD_MULTI_ATTR = 'data-multi'
export const FIELD_RISK_ATTR = 'data-risk'
/** 结构化占位符的选择器:渲染端与解析端必须用同一个。 */
export const FIELD_ELEMENT_SELECTOR = `${FIELD_ELEMENT_TAG}[${FIELD_ID_ATTR}]`
/** 多选项在 data-options 里的分隔符,与原型 V9.9 的写法一致。 */
export const FIELD_OPTIONS_SEPARATOR = '|'
/**
* 由字段定义产出结构化占位符元素。
* 属性值一律走 setAttribute,标签文字走 textContent,避免手写字符串时漏转义。
*
* 标签文字仍然保留在元素内部:即便后端清洗掉了 data-*,元素至少还剩可读的文字,
* 不会变成一个空白占位。渲染端优先读 data-label,读不到才回落到文字。
*/
export function createFieldElement(doc: Document, field: SigningFieldDef): HTMLElement {
const element = doc.createElement(FIELD_ELEMENT_TAG)
element.setAttribute(FIELD_ID_ATTR, field.id)
element.setAttribute(FIELD_KIND_ATTR, field.kind)
element.setAttribute(FIELD_LABEL_ATTR, field.label)
element.setAttribute(FIELD_REQUIRED_ATTR, field.required ? 'true' : 'false')
if (field.options.length) {
element.setAttribute(FIELD_OPTIONS_ATTR, field.options.join(FIELD_OPTIONS_SEPARATOR))
}
if (field.multi !== undefined) {
element.setAttribute(FIELD_MULTI_ATTR, field.multi ? 'true' : 'false')
}
if (field.risk) {
element.setAttribute(FIELD_RISK_ATTR, field.risk)
}
element.textContent = field.label
return element
}
/** 由字段定义产出结构化占位符的 HTML 字符串(编辑器导出正文时用)。 */
export function buildFieldSpanHtml(field: SigningFieldDef): string {
return createFieldElement(document, field).outerHTML
}
/**
* 从结构化占位符元素读回字段定义。
*
* 容错口径:data-* 是主依据,缺失时按可读信息降级,而不是直接丢弃这一项——
* 模板正文要经过后端存储,属性一旦被清洗,整篇文书的填写项会全部消失,
* 那种失败方式比"类型退化成文本"严重得多。
*/
export function readFieldFromElement(element: Element): SigningFieldDef | null {
const id = element.getAttribute(FIELD_ID_ATTR)?.trim() ?? ''
const label = (element.getAttribute(FIELD_LABEL_ATTR) ?? element.textContent ?? '').trim()
if (!id && !label) {
return null
}
// 没有 data-field 时用标签反查目录,签名位这类字段至少还能还原成正确的控件
const resolved = id ? null : resolveFieldByLabel(label)
return {
id: id || (resolved as SigningFieldDef).id,
label: label || id,
kind: ((element.getAttribute(FIELD_KIND_ATTR) ?? resolved?.kind ?? 'text') as SigningFieldKind),
required:
element.getAttribute(FIELD_REQUIRED_ATTR) === 'true' ||
(!element.hasAttribute(FIELD_REQUIRED_ATTR) && resolved?.required === true),
options: (element.getAttribute(FIELD_OPTIONS_ATTR) ?? '')
.split(FIELD_OPTIONS_SEPARATOR)
.map((option) => option.trim())
.filter(Boolean),
multi: readMultiAttribute(element, resolved),
risk: element.getAttribute(FIELD_RISK_ATTR)?.trim() || resolved?.risk || undefined,
}
}
/** data-multi 缺失时按类型给缺省:choice 单选,checkbox 带选项则多选。 */
function readMultiAttribute(
element: Element,
resolved: SigningFieldDef | null,
): boolean | undefined {
const raw = element.getAttribute(FIELD_MULTI_ATTR)
if (raw === 'true' || raw === 'false') {
return raw === 'true'
}
if (resolved?.multi !== undefined) {
return resolved.multi
}
const kind = element.getAttribute(FIELD_KIND_ATTR) ?? resolved?.kind ?? 'text'
const hasOptions = (element.getAttribute(FIELD_OPTIONS_ATTR) ?? '').length > 0
return kind === 'checkbox' && hasOptions ? true : undefined
}
/**
* 科室手打的复选框符号:几何图形方框,以及 Wingdings 私有区码位。
* Word「插入 → 符号」里的方框多为私有区字符(U+F0A8 等),粘贴后字体信息丢失、
@@ -159,6 +284,8 @@ export function createCustomField(
options: string[] = [],
/** 同名占位符在文书里的出现次序,从 1 开始;见 resolveFieldByLabel */
sequence = 1,
/** 有选项时是否多选;不传表示按类型取缺省(见 SigningFieldDef.multi) */
multi?: boolean,
): SigningFieldDef {
return {
id: `${CUSTOM_FIELD_PREFIX}${label}${sequence > 1 ? `#${sequence}` : ''}`,
@@ -166,6 +293,7 @@ export function createCustomField(
kind,
required: false,
options,
...(multi === undefined ? {} : { multi }),
}
}
@@ -241,6 +369,26 @@ export interface ScannedFieldToken {
field: SigningFieldDef
}
/**
* 从「同名第几次出现」往后找第一个没被占用的编号。
*
* 只对自定义文本项有意义(调用方保证),目录里的字段同名共用一份取值是刻意的。
* claimed 是有限集合、next 严格递增,所以循环一定收敛。
*/
function nextFreeCustomSequence(
label: string,
sequence: number,
claimed: Set<string>,
): number {
let next = sequence
while (claimed.has(createCustomField(label, 'text', [], next).id)) {
next += 1
}
return next
}
/**
* 扫描一段文本里的占位符并解析成字段定义。
*
@@ -249,11 +397,22 @@ export interface ScannedFieldToken {
* 编辑器面板统计出的字段键就会和签署渲染时写进 DOM 的键对不上,
* 表现是填写项统计里有这一项、签署时却收集不到值。
*
* claimed 是**结构化占位符已占用的字段键**,同样要跨段传递。
* 为什么光有计数器不够:结构化占位符的键写在属性里,纯文本占位符的键是现算的,
* 两套编号互不知情。一份文书里两种载体混用时(改过配置的那处已结构化、
* 同名的另一处还是纯文本),纯文本这一遍会重新数出 `custom:文本框` 这种
* 已被结构化元素占用的键——两个控件挂同一个键,填哪一处都写进同一份取值。
* 所以纯文本除了按同名声数,还要让开已被占用的键。
*
* 之所以要有个统一的扫描函数,就是因为这条编号规则被三处用到
* (填写项统计 collectFieldDefs、重复检查 findDuplicateFields、签署渲染 renderTokenFields),
* 三处各写一份计时器迟早会漂移。
*/
export function scanFieldTokens(text: string, counters: Map<string, number>): ScannedFieldToken[] {
export function scanFieldTokens(
text: string,
counters: Map<string, number>,
claimed?: Set<string>,
): ScannedFieldToken[] {
const tokens: ScannedFieldToken[] = []
const pattern = createFieldTokenPattern()
let match: RegExpExecArray | null
@@ -267,10 +426,23 @@ export function scanFieldTokens(text: string, counters: Map<string, number>): Sc
const sequence = (counters.get(label) ?? 0) + 1
counters.set(label, sequence)
let field = resolveFieldByLabel(label, sequence)
// 目录里的字段(患者姓名、签名位…)反复出现只能是同一个字段,不参与让位
if (claimed && field.id.startsWith(CUSTOM_FIELD_PREFIX)) {
if (claimed.has(field.id)) {
field = resolveFieldByLabel(label, nextFreeCustomSequence(label, sequence, claimed))
}
// 边扫边占:同一段文字里连着的两个 {文本框} 不能都跳到同一个空号上
claimed.add(field.id)
}
tokens.push({
start: match.index,
end: pattern.lastIndex,
field: resolveFieldByLabel(label, sequence),
field,
})
}
@@ -278,33 +450,154 @@ export function scanFieldTokens(text: string, counters: Map<string, number>): Sc
}
/**
* 生成占位符文本。
* 生成纯文本占位符 {患者姓名}。
*
* 用纯文本而不是 <span data-field>:WangEditor 是 Slate 模型,插入的 HTML 会被
* 重新解析,未注册的自定义元素会退化成纯文本、data-* 属性全部丢失,
* 导致「保存后再打开,占位符已经不是占位符」。纯文本可以无损往返,
* 且不需要为编辑器注册自定义节点,存量模板也不需要迁移。
* 新写入的占位符已改走结构化写法(编辑器里插入的是 sign-field 元素节点,
* 见 utils/signing-field-node),本函数只保留两处用途:
* ① 在编辑器之外(如后端拼装、测试)生成占位符文本;
* ② 作为结构化元素不可用时的降级写法——纯文本可以无损往返,
* 即便某个环节把 data-* 洗掉了,正文里至少还剩一个能被识别的 {…}。
*/
export function buildFieldToken(field: SigningFieldDef): string {
return `${FIELD_TOKEN_OPEN}${field.label}${FIELD_TOKEN_CLOSE}`
}
/** 占位符在文书里的一次出现;element 只在结构化写法下存在。 */
interface FieldOccurrence {
field: SigningFieldDef
element: Element | null
}
const SCAN_ROOT_ID = 'sign-field-scan-root'
/** 收集某个根节点下结构化占位符已占用的字段键(属性解析失败的不算,反正也认不出来) */
function collectStructuredIdsFrom(root: Element): Set<string> {
const ids = new Set<string>()
root.querySelectorAll(FIELD_ELEMENT_SELECTOR).forEach((element) => {
const field = readFieldFromElement(element)
if (field) {
ids.add(field.id)
}
})
return ids
}
/**
* 解析文书 HTML 里的全部占位符,按出现顺序去重。
* 从正文 HTML 里取出结构化占位符已占用的字段键。
*
* 给「要自己扫纯文本占位符」的调用方用(目前是签署渲染的 renderTokenFields):
* 它必须在渲染前先拿到这个集合,否则纯文本那一遍会数出与结构化元素相同的键。
* 不含 `data-field` 的正文直接跳过 DOM 往返。
*/
export function collectStructuredFieldIds(contentHtml: string): Set<string> {
if (!contentHtml || !contentHtml.includes('data-field')) {
return new Set<string>()
}
const parsed = new DOMParser().parseFromString(
`<div id="${SCAN_ROOT_ID}">${contentHtml}</div>`,
'text/html',
)
const root = parsed.getElementById(SCAN_ROOT_ID)
return root ? collectStructuredIdsFrom(root) : new Set<string>()
}
/**
* 按文档顺序扫描文书里的全部占位符,两种载体合并。
*
* 走 DOM 树而不是对 HTML 字符串跑正则:字符串正则会把属性值里的 {…}、
* 以及跨标签断开的 token 也算成占位符,而渲染端(renderTokenFields)是走文本节点的,
* 两边口径一旦不一致,就会出现"面板里统计得到这一项、签署时却渲染不出来"。
* 统一按文本节点扫描后,编辑器统计与签署渲染严格同源。
*/
function collectFieldOccurrences(contentHtml: string): FieldOccurrence[] {
if (!contentHtml) {
return []
}
const parsed = new DOMParser().parseFromString(
`<div id="${SCAN_ROOT_ID}">${contentHtml}</div>`,
'text/html',
)
const root = parsed.getElementById(SCAN_ROOT_ID)
if (!root) {
return []
}
const collected: Array<{ node: Node; field: SigningFieldDef }> = []
// 结构化占位符先扫出来,它们的字段键要作为"已占用"传给纯文本那一遍
const claimed = new Set<string>()
root.querySelectorAll(FIELD_ELEMENT_SELECTOR).forEach((element) => {
const field = readFieldFromElement(element)
if (field) {
collected.push({ node: element, field })
claimed.add(field.id)
}
})
const counters = new Map<string, number>()
const walker = parsed.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let textNode = walker.nextNode()
while (textNode) {
// 收窄后的引用单独存一份:闭包里拿不到 while 条件带来的收窄,类型会退回 Node | null
const currentNode = textNode
const text = currentNode.textContent ?? ''
// 结构化占位符内部的文字是它自己的标签,不再当 token 扫一遍
if (
text.includes(FIELD_TOKEN_OPEN) &&
!currentNode.parentElement?.closest(FIELD_ELEMENT_SELECTOR)
) {
scanFieldTokens(text, counters, claimed).forEach(({ field }) => {
collected.push({ node: currentNode, field })
})
}
textNode = walker.nextNode()
}
// 两种载体分别扫出来后按文档顺序合并;同一个文本节点里的多个 token
// 靠 sort 的稳定性保持扫描顺序(现代 JS 的 Array#sort 是稳定排序)
collected.sort((a, b) => {
const relation = a.node.compareDocumentPosition(b.node)
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) {
return -1
}
if (relation & Node.DOCUMENT_POSITION_PRECEDING) {
return 1
}
return 0
})
return collected.map(({ node, field }) => ({
field,
element: node.nodeType === Node.ELEMENT_NODE ? (node as Element) : null,
}))
}
/**
* 解析文书 HTML 里的全部占位符,按出现顺序去重。两种载体都认。
*
* 「去重」只对目录里的字段生效:同一个字段在文书里出现多次(如签名与日期在每页都出现)
* 只算一个填写项,它们共用一份取值。自定义文本项按出现位置各自成项(见 resolveFieldByLabel),
* 所以两份 {文本框} 会得到两个填写项,不会被合成一个。
*/
export function collectFieldDefs(contentHtml: string): SigningFieldDef[] {
if (!contentHtml) {
return []
}
const seen = new Set<string>()
const fields: SigningFieldDef[] = []
scanFieldTokens(contentHtml, new Map()).forEach(({ field }) => {
collectFieldOccurrences(contentHtml).forEach(({ field }) => {
if (!field.label || seen.has(field.id)) {
return
}
@@ -324,13 +617,9 @@ export function collectFieldDefs(contentHtml: string): SigningFieldDef[] {
* 不是"同一个字段写重复了",不该提示科室去合并。
*/
export function findDuplicateFields(contentHtml: string): SigningFieldDef[] {
if (!contentHtml) {
return []
}
const counts = new Map<string, { field: SigningFieldDef; count: number }>()
scanFieldTokens(contentHtml, new Map()).forEach(({ field }) => {
collectFieldOccurrences(contentHtml).forEach(({ field }) => {
const entry = counts.get(field.id)
counts.set(field.id, { field, count: (entry?.count ?? 0) + 1 })
})
@@ -338,6 +627,170 @@ export function findDuplicateFields(contentHtml: string): SigningFieldDef[] {
return [...counts.values()].filter((item) => item.count > 1).map((item) => item.field)
}
/**
* 一次出现的改写结果:
* - 返回字段定义:替换成(或升级为)结构化占位符;
* - 返回 `'remove'`:删掉这一次出现;
* - 返回 `null`:原样保留。
*/
export type FieldRewriteResult = SigningFieldDef | 'remove' | null
/**
* 按文档顺序重写正文里的占位符,是「升级 / 改配置 / 删除」三件事的共同底座。
*
* 三件事的难点完全相同,所以必须共用一个实现:
* ① **必须按文档顺序**走一遍——自定义文本项的编号取决于同名占位符的出现次序;
* ② **计数器要跨文本节点共享**——分段各数各的会让字段键漂移;
* ③ 两种载体都要覆盖——纯文本 token 升级成结构化,已是结构化的就地改属性。
*
* 只有确实含 { 的正文才做 DOM 往返;否则只跑结构化那一遍(甚至直接原样返回)。
*/
export function rewriteFieldOccurrences(
contentHtml: string,
resolve: (field: SigningFieldDef, isStructured: boolean) => FieldRewriteResult,
): string {
if (!contentHtml) {
return contentHtml
}
// 走 DOM 而不是对字符串跑正则:正则会把属性值里的 {…}、以及跨标签断开的
// token 也算成占位符,与渲染端(按文本节点扫描)口径不一致
const parsed = new DOMParser().parseFromString(
`<div id="${SCAN_ROOT_ID}">${contentHtml}</div>`,
'text/html',
)
const root = parsed.getElementById(SCAN_ROOT_ID)
if (!root) {
return contentHtml
}
// 纯文本 token 的字段键要避开结构化占位符已占用的键,所以在 ① 改写之前先把
// 占用集合固定下来:这样"纯文本键怎么算"只取决于输入文档,与改写的先后无关,
// 也与 collectFieldDefs 对同一份输入算出的结果一致。
const claimed = collectStructuredIdsFrom(root)
// ① 结构化占位符:字段定义就在属性里,直接就地改写,不参与文本扫描
root.querySelectorAll(FIELD_ELEMENT_SELECTOR).forEach((element) => {
const field = readFieldFromElement(element)
if (!field) {
return
}
const next = resolve(field, true)
if (next === null) {
return
}
if (next === 'remove') {
element.remove()
return
}
element.replaceWith(createFieldElement(parsed, next))
})
// ② 纯文本 {…}:逐文本节点扫描。计数器与占用集合都跨节点共享,
// 与 collectFieldOccurrences 同源
const counters = new Map<string, number>()
const targets: Text[] = []
const walker = parsed.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()
while (node) {
const text = node.textContent ?? ''
// 结构化占位符内部的文字是它自己的标签,不再当 token 扫一遍
if (text.includes(FIELD_TOKEN_OPEN) && !node.parentElement?.closest(FIELD_ELEMENT_SELECTOR)) {
targets.push(node as Text)
}
node = walker.nextNode()
}
targets.forEach((textNode) => {
const text = textNode.textContent ?? ''
const fragment = parsed.createDocumentFragment()
let cursor = 0
let changed = false
scanFieldTokens(text, counters, claimed).forEach(({ start, end, field }) => {
const next = resolve(field, false)
// 原样保留:不推进 cursor,这段原文会随下一次 slice 或收尾一并写回
if (next === null) {
return
}
if (start > cursor) {
fragment.appendChild(parsed.createTextNode(text.slice(cursor, start)))
}
if (next !== 'remove') {
fragment.appendChild(createFieldElement(parsed, next))
}
cursor = end
changed = true
})
// 整段都没有要改的,就别动这个文本节点(少一次 DOM 替换,也避免动到无关文本)
if (!changed) {
return
}
if (cursor < text.length) {
fragment.appendChild(parsed.createTextNode(text.slice(cursor)))
}
textNode.replaceWith(fragment)
})
return root.innerHTML
}
/**
* 把存量正文里的 {患者姓名} 就地升级成结构化占位符。
*
* 升级后它变成一个原子节点,一次退格整体删除,也能承载选项与多选。
* 字段键沿用 scanFieldTokens 的解析结果,与升级前的口径完全一致——
* 自定义文本项仍按「同名占位符第几次出现」编号,所以升级前后
* 已保存的 fieldAnswers 能继续对上,不会出现「模板一改、历史取值全丢」。
*/
export function upgradeFieldTokensToStructured(contentHtml: string): string {
return rewriteFieldOccurrences(contentHtml, (_field, isStructured) =>
isStructured ? null : _field,
)
}
/**
* 给新插入的自定义文本项分配一个在本文书里唯一的字段键。
*
* 自定义文本项的字段键是 `custom:名称#出现次序`(见 resolveFieldByLabel),
* 次序就是它的身份。**不能只数「同名第几次出现」**:科室改过名之后,
* 一个叫「与患者关系」的输入框其键仍停留在 custom:文本框,
* 此时再插一个「文本框」按同名声数会得到 custom:文本框,与既有项撞键,
* 两个不同的输入框共用一份取值、互相覆盖。
* 所以这里直接以「正文里已占用的键」为准,取第一个没被占用的编号。
*/
export function allocateCustomFieldId(contentHtml: string, label: string): string {
const trimmed = label.trim()
const used = new Set(collectFieldOccurrences(contentHtml).map(({ field }) => field.id))
// 从 1 号开始往后找第一个没被占用的编号;同名项通常只有一两个,循环最多跑几次
let sequence = 1
let id = createCustomField(trimmed, 'text', [], sequence).id
while (used.has(id)) {
sequence += 1
id = createCustomField(trimmed, 'text', [], sequence).id
}
return id
}
/**
* 系统侧取值来源(HIS / 患者与就诊快照)。
* 字段键与目录里的 id 对应,是「自动填充」的唯一入口。