feat(signing): 将签署占位符改为原子节点并支持多选选项组
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 编辑器里的「签署占位符」元素节点。
|
||||
*
|
||||
* 为什么要有这个模块:占位符原本是纯文本 {患者姓名},在编辑器里就是一段文字——
|
||||
* 删掉一个要按五六次退格,退格一次只吃掉一个字符,稍微快点就会把相邻正文也带走;
|
||||
* 而且纯文本没法承载「有哪些选项」「能不能多选」这类结构化信息。
|
||||
*
|
||||
* 现在把它做成一个**原子节点**:点一下即整块选中,一次退格整体删除,
|
||||
* 内部不落任何可编辑的文字。
|
||||
*
|
||||
* 关键实现点(都是照着 wangEditor 内置的 image 模块抄的,那是它自己的 void 元素):
|
||||
*
|
||||
* ① **isVoid + isInline 都要覆写,且必须走 editorPlugin**。
|
||||
* 只有 isVoid 会变成块级 <div>,把整行撑断;两个都覆写才得到内联的 <span>。
|
||||
* 覆写方式与 image 模块一致:保留原方法再或上自己的类型判断。
|
||||
*
|
||||
* ② **renderElem 拿到的 children 是 null**。void 元素渲染时 wangEditor 不传子节点
|
||||
* (见其内部 renderElement:`children = isVoid ? null : node.children.map(...)`),
|
||||
* 所以标签要自己渲染,不能指望 children。
|
||||
*
|
||||
* ③ **标签文字走 CSS ::before,不落成 DOM 文本节点**。
|
||||
* void 元素的 DOM 结构是 `wrapper > [renderElem 结果, data-slate-spacer]`,
|
||||
* spacer 里才有 wangEditor 用来算光标位置的 data-slate-zero-width 叶子。
|
||||
* 如果我们的胶囊里再放一段真实文字,浏览器原生光标有可能落进那段文字里,
|
||||
* 一次退格就只删掉一个字符——正好破坏「一次删掉」这个诉求。
|
||||
* 用 ::before { content: attr(data-sign-label) } 展示,DOM 里就没有可落点文字。
|
||||
*
|
||||
* ④ **存下来的正文仍然是干净的 `<span data-field>`**,与 utils/signing-fields 的
|
||||
* 结构化契约、以及签署渲染管线(utils/signing-document 的 renderStructuredFields)
|
||||
* 完全一致,签署侧一行都不用改。编辑器专属的标记只活在解析期,见 ⑤。
|
||||
*
|
||||
* ⑤ **`<span>` 必须带 `data-w-e-type` 才会被当成元素解析**。
|
||||
* wangEditor 的解析分派里有一段特判:
|
||||
* if (tagName === 'span') {
|
||||
* if ($elem.attr('data-w-e-type')) return parseCommonElemHtml(...)
|
||||
* else return parseTextElemHtml(...) // 退化成行内文本
|
||||
* }
|
||||
* 只有 `data-w-e-type` 存在时才会去查 PARSE_ELEM_HTML_CONF 里的选择器。
|
||||
* 所以「让 span 被认成 sign-field 元素」这一步必须补。补在 preParseHtml 而不是
|
||||
* 直接写进正文,是为了让 contentHtml 保持干净:preParseHtml 只在解析前跑一遍,
|
||||
* 给它加上 `data-w-e-type` / `data-w-e-is-void` 两个 wangEditor 内部标记,
|
||||
* 导出时(elemToHtml)不带上它们。若把这两个标记写进正文,
|
||||
* 编辑器的内部实现细节就渗进了模板内容,还会连带改变模板哈希。
|
||||
* preParseHtml 是解析入口 parseElemHtml 的第一步(`PRE_PARSE_HTML_CONF_LIST.forEach`),
|
||||
* setHtml 与粘贴走的都是它,所以两条路径都能正确还原。
|
||||
*
|
||||
* ⑥ 显式引入 snabbdom 的 h:registerRenderElem 必须返回 vnode,而 vnode 只能由
|
||||
* snabbdom 构造(wangEditor 未导出 h)。这条依赖已写进 package.json,
|
||||
* 版本与其内部使用的保持一致,避免打包出两份 snabbdom 实例。
|
||||
*/
|
||||
|
||||
import { Boot, DomEditor, SlateEditor, SlateTransforms } from '@wangeditor/editor'
|
||||
import type { IDomEditor } from '@wangeditor/editor'
|
||||
import { h } from 'snabbdom'
|
||||
import type { VNode } from 'snabbdom'
|
||||
|
||||
import type { SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
|
||||
|
||||
import {
|
||||
buildFieldSpanHtml,
|
||||
FIELD_ELEMENT_SELECTOR,
|
||||
readFieldFromElement,
|
||||
SIGNING_FIELD_KIND_LABELS,
|
||||
} from './signing-fields'
|
||||
|
||||
/** Slate 元素类型名。改这个值要同步下面两处选择器。 */
|
||||
export const SIGN_FIELD_TYPE = 'sign-field'
|
||||
|
||||
/**
|
||||
* 解析 `<span data-field>` 时用的选择器。
|
||||
* 注意不是 FIELD_ELEMENT_SELECTOR —— 解析阶段这个 span 已经被 preParseHtml
|
||||
* 打上了 data-w-e-type,见文件头 ⑤。
|
||||
*/
|
||||
export const SIGN_FIELD_PARSE_SELECTOR = `span[data-w-e-type="${SIGN_FIELD_TYPE}"]`
|
||||
|
||||
/** wangEditor 用来判断「这个 span 是元素而不是行内文本」的标记属性。 */
|
||||
const WANGEDITOR_TYPE_ATTR = 'data-w-e-type'
|
||||
/** wangEditor 用来跳过「从内部文字生成 children」的标记属性。 */
|
||||
const WANGEDITOR_VOID_ATTR = 'data-w-e-is-void'
|
||||
|
||||
/** 编辑器里胶囊的类名,样式见 EditorWangPane。 */
|
||||
export const SIGN_FIELD_CHIP_CLASS = 'ms-sign-chip'
|
||||
/** 原子占位符额外的类名:与 decorate 出来的旧式胶囊共用底色,但只有它有 ::before 标签与选中态。 */
|
||||
export const SIGN_FIELD_NODE_CLASS = 'ms-sign-node'
|
||||
|
||||
/**
|
||||
* 占位符在 Slate 模型里的样子。
|
||||
*
|
||||
* 字段信息全部挂在元素自己身上(而不是像纯文本那样靠「第几次出现」推断位置),
|
||||
* 所以改名字、调顺序、删除都不会让取值归属漂移。
|
||||
*/
|
||||
export interface SignFieldElement {
|
||||
type: typeof SIGN_FIELD_TYPE
|
||||
fieldId: string
|
||||
fieldKind: SigningFieldKind
|
||||
fieldLabel: string
|
||||
fieldRequired: boolean
|
||||
fieldOptions: string[]
|
||||
fieldMulti?: boolean
|
||||
fieldRisk?: string
|
||||
/** void 元素也要有一个空文本子节点,否则 Slate 会认为结构非法 */
|
||||
children: [{ text: '' }]
|
||||
}
|
||||
|
||||
/**
|
||||
* 把字段定义摊成 Slate 元素的属性。
|
||||
*
|
||||
* `fieldMulti` / `fieldRisk` **显式写成 undefined 而不是省略**:Transforms.setNodes 是合并语义,
|
||||
* 省略这两个键会让节点上原有的值残留下来(比如把「可多选」关掉之后模型里还是 true)。
|
||||
* createSignFieldNode 也走这里,两边属性名不会漂移。
|
||||
*/
|
||||
function buildSignFieldProps(field: SigningFieldDef): Omit<SignFieldElement, 'type' | 'children'> {
|
||||
return {
|
||||
fieldId: field.id,
|
||||
fieldKind: field.kind,
|
||||
fieldLabel: field.label,
|
||||
fieldRequired: field.required,
|
||||
fieldOptions: [...field.options],
|
||||
fieldMulti: field.multi,
|
||||
fieldRisk: field.risk,
|
||||
}
|
||||
}
|
||||
|
||||
export function createSignFieldNode(field: SigningFieldDef): SignFieldElement {
|
||||
return {
|
||||
type: SIGN_FIELD_TYPE,
|
||||
...buildSignFieldProps(field),
|
||||
children: [{ text: '' }],
|
||||
}
|
||||
}
|
||||
|
||||
export function isSignFieldElement(value: unknown): value is SignFieldElement {
|
||||
return Boolean(value) && (value as { type?: unknown }).type === SIGN_FIELD_TYPE
|
||||
}
|
||||
|
||||
/** 收集编辑器里某个字段的全部占位符节点与路径(先收集再改,避免边遍历边改结构) */
|
||||
function findSignFieldEntries(
|
||||
editor: IDomEditor,
|
||||
fieldId: string,
|
||||
): Array<{ node: SignFieldElement; path: number[] }> {
|
||||
const entries: Array<{ node: SignFieldElement; path: number[] }> = []
|
||||
|
||||
for (const [node, path] of SlateEditor.nodes(editor, {
|
||||
at: [],
|
||||
match: (candidate) => isSignFieldElement(candidate) && candidate.fieldId === fieldId,
|
||||
})) {
|
||||
entries.push({ node: node as SignFieldElement, path })
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* 就地改写编辑器里某个字段的每一处占位符。返回命中的节点数。
|
||||
*
|
||||
* 为什么不走「getHtml() → 字符串改写 → setHtml()」的往返(面板改字段原本是这么写的):
|
||||
* ① setHtml 会重建整篇文档,光标位置随之失效;
|
||||
* ② 更糟的是,改写时用户的光标通常还停在正文里,setHtml 会让 wangEditor 拿旧选区去
|
||||
* 新 DOM 上找位置,偏移一超界就抛 `Cannot resolve a DOM point from Slate point`
|
||||
* (实测必现:面板里改字段名 → 正文里那段文字被拆成新节点 → 旧偏移落空)。
|
||||
* 直接改模型既保住光标,也彻底没有这个竞态。
|
||||
*
|
||||
* 字段 id 刻意不变:改名只是改给人看的标签,改了 id 会让历史 fieldAnswers 对不上。
|
||||
*/
|
||||
export function updateSignFieldNodes(
|
||||
editor: IDomEditor,
|
||||
fieldId: string,
|
||||
patch: Partial<Omit<SigningFieldDef, 'id'>>,
|
||||
): number {
|
||||
const entries = findSignFieldEntries(editor, fieldId)
|
||||
|
||||
entries.forEach(({ node, path }) => {
|
||||
const merged = { ...readFieldFromSignFieldNode(node), ...patch, id: fieldId }
|
||||
// 类型放宽一次:Slate 的 Element 是 BaseElement 与自定义类型的交叉类型、没有属性索引签名,
|
||||
// Partial<Element> 不接纳我们的 field* 扩展属性(内置 image 模块走 insertNodes,
|
||||
// 参数是 Node,所以没遇到这个问题)。先落到 Partial<SignFieldElement> 再传,
|
||||
// 非字面量的额外属性就不再触发多余属性检查。
|
||||
const props: Partial<SignFieldElement> = buildSignFieldProps(merged)
|
||||
|
||||
SlateTransforms.setNodes(editor, props, { at: path })
|
||||
})
|
||||
|
||||
return entries.length
|
||||
}
|
||||
|
||||
/** 就地删掉编辑器里某个字段的每一处占位符。返回命中的节点数。 */
|
||||
export function removeSignFieldNodes(editor: IDomEditor, fieldId: string): number {
|
||||
const entries = findSignFieldEntries(editor, fieldId)
|
||||
|
||||
SlateTransforms.removeNodes(editor, {
|
||||
at: [],
|
||||
match: (candidate) => isSignFieldElement(candidate) && candidate.fieldId === fieldId,
|
||||
})
|
||||
|
||||
return entries.length
|
||||
}
|
||||
|
||||
/** 从 Slate 元素还原字段定义,交给编辑器面板 / 签署侧使用。 */
|
||||
export function readFieldFromSignFieldNode(elem: SignFieldElement): SigningFieldDef {
|
||||
return {
|
||||
id: elem.fieldId,
|
||||
label: elem.fieldLabel,
|
||||
kind: elem.fieldKind,
|
||||
required: elem.fieldRequired,
|
||||
options: [...elem.fieldOptions],
|
||||
...(elem.fieldMulti === undefined ? {} : { multi: elem.fieldMulti }),
|
||||
...(elem.fieldRisk ? { risk: elem.fieldRisk } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个占位符当前是否处于选中态。
|
||||
*
|
||||
* 必须包一层 try/catch:DomEditor.isNodeSelected 内部会拿 editor.selection 去跑 nodes(),
|
||||
* 而 selection 有可能指向一个已经不在文档里的路径——整篇替换正文时 setHtml **不会**清掉旧选区,
|
||||
* 于是渲染那一刻的选区是脏的,nodes() 抛 `Cannot find a descendant at path`。
|
||||
* 渲染函数不能因为选区脏了就整篇渲染失败(一个占位符渲染不出来,编辑器看起来就像丢了内容),
|
||||
* 取不到就当作未选中。
|
||||
*/
|
||||
function isSelected(editor: IDomEditor, elemNode: unknown): boolean {
|
||||
try {
|
||||
return DomEditor.isNodeSelected(
|
||||
editor,
|
||||
elemNode as Parameters<typeof DomEditor.isNodeSelected>[1],
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器内渲染:一个只靠属性展示的胶囊。
|
||||
* 不渲染 children,也不放文本节点,理由见文件头的 ③。
|
||||
*/
|
||||
function renderSignField(
|
||||
elemNode: unknown,
|
||||
_children: VNode[] | null,
|
||||
editor: IDomEditor,
|
||||
): VNode {
|
||||
const elem = elemNode as SignFieldElement
|
||||
const kindLabel = SIGNING_FIELD_KIND_LABELS[elem.fieldKind] ?? '文本'
|
||||
const selected = isSelected(editor, elemNode)
|
||||
const source = elem.fieldId.startsWith('custom:')
|
||||
? '签署时由患者/家属现场填写'
|
||||
: '签署时按患者与就诊信息自动填充'
|
||||
const extra = [
|
||||
elem.fieldRequired ? '必填' : '选填',
|
||||
elem.fieldOptions.length ? `选项:${elem.fieldOptions.join(' / ')}` : '',
|
||||
elem.fieldMulti === undefined ? '' : elem.fieldMulti ? '可多选' : '单选',
|
||||
elem.fieldRisk ? `命中「${elem.fieldRisk}」记为异常项` : '',
|
||||
].filter(Boolean)
|
||||
|
||||
return h('span', {
|
||||
attrs: {
|
||||
class: `${SIGN_FIELD_CHIP_CLASS} ${SIGN_FIELD_NODE_CLASS}${selected ? ' is-selected' : ''}`,
|
||||
// 供 CSS ::before / ::after 展示,避免在 DOM 里留下可落光标的文字
|
||||
'data-sign-label': elem.fieldLabel,
|
||||
'data-sign-tag': kindLabel,
|
||||
'data-sign-kind': elem.fieldKind,
|
||||
title: `待填写项:${elem.fieldLabel}(${kindLabel}),${source}。${extra.join(',')}。点击选中后可整块删除`,
|
||||
contenteditable: 'false',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 导出正文时还原成结构化占位符,与签署渲染端共用同一份属性口径。 */
|
||||
function signFieldToHtml(elemNode: unknown): string {
|
||||
return buildFieldSpanHtml(readFieldFromSignFieldNode(elemNode as SignFieldElement))
|
||||
}
|
||||
|
||||
/** 解析 `<span data-field>`:字段信息原样搬进模型,不再从文字反推。 */
|
||||
function parseSignField($elem: Element): SignFieldElement {
|
||||
const field = readFieldFromElement($elem)
|
||||
|
||||
if (!field) {
|
||||
return createSignFieldNode({
|
||||
id: 'field',
|
||||
label: '填写项',
|
||||
kind: 'text',
|
||||
required: false,
|
||||
options: [],
|
||||
})
|
||||
}
|
||||
|
||||
return createSignFieldNode(field)
|
||||
}
|
||||
|
||||
/**
|
||||
* 让占位符成为内联的原子节点。
|
||||
* 必须同时覆写 isInline 与 isVoid:只覆写 isVoid 会渲染成块级 div 并撑断行。
|
||||
*
|
||||
* 参数 element 的声明类型是 Slate 的 BaseElement,没有 type 字段,
|
||||
* 所以这里按「可能带 type 的任意对象」读,读不到就不是我们的节点(与内置 image 模块同构)。
|
||||
*/
|
||||
function withSignField<T extends IDomEditor>(editor: T): T {
|
||||
const { isInline, isVoid } = editor
|
||||
|
||||
editor.isInline = (element) =>
|
||||
(element as { type?: unknown }).type === SIGN_FIELD_TYPE || isInline(element)
|
||||
editor.isVoid = (element) =>
|
||||
(element as { type?: unknown }).type === SIGN_FIELD_TYPE || isVoid(element)
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
let registered = false
|
||||
|
||||
/**
|
||||
* 注册占位符元素节点。
|
||||
*
|
||||
* 与 signing-editor-marks 一样是 wangEditor 的全局注册表,重复注册会让同一个元素
|
||||
* 被处理多次,用模块级开关挡住(HMR 重新求值时开关会重置,但注册本身是幂等的)。
|
||||
* 必须在编辑器创建前调用,否则首帧渲染出来的还是裸文字。
|
||||
*/
|
||||
export function registerSigningFieldNode(): void {
|
||||
if (registered) {
|
||||
return
|
||||
}
|
||||
|
||||
registered = true
|
||||
|
||||
// 解析前补上 wangEditor 认得的元素标记(详见文件头 ⑤)
|
||||
Boot.registerPreParseHtml({
|
||||
selector: FIELD_ELEMENT_SELECTOR,
|
||||
preParseHtml: ($node: Element) => {
|
||||
$node.setAttribute(WANGEDITOR_TYPE_ATTR, SIGN_FIELD_TYPE)
|
||||
$node.setAttribute(WANGEDITOR_VOID_ATTR, 'true')
|
||||
return $node
|
||||
},
|
||||
})
|
||||
|
||||
Boot.registerRenderElem({
|
||||
type: SIGN_FIELD_TYPE,
|
||||
renderElem: renderSignField,
|
||||
})
|
||||
|
||||
Boot.registerElemToHtml({
|
||||
type: SIGN_FIELD_TYPE,
|
||||
elemToHtml: signFieldToHtml,
|
||||
})
|
||||
|
||||
Boot.registerParseElemHtml({
|
||||
selector: SIGN_FIELD_PARSE_SELECTOR,
|
||||
parseElemHtml: ($elem: Element) => parseSignField($elem),
|
||||
})
|
||||
|
||||
Boot.registerPlugin(withSignField)
|
||||
}
|
||||
@@ -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 对应,是「自动填充」的唯一入口。
|
||||
|
||||
Reference in New Issue
Block a user