Files
xh-medical-sign-web/clinical-web/src/utils/signing-fields.ts
T

914 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 文书填写项(占位符)模型。
*
* 相比「把占位符当普通文字做字符串替换」,这里给每个占位符补上语义:
* ① 稳定字段键(id):目录里的字段与 HIS / 患者快照的取值一一对应,不依赖中文标签匹配;
* 目录外的自定义文本项按出现位置编号(custom:名称#第几次),两处文本框就是两份取值;
* ② 控件类型(kind):签名是与文本/日期/单选/勾选并列的一种填写项,
* 签完把取值写回同一字段即可在文书原位置回显;
* ③ 必填与选项:模板保存时可做去重校验,签署时可校验必填项是否填完;
* ④ 风险值(risk):签署取值命中即标记为异常项。
*
* 载体有两种,由本文件同时维护读写:
* ① 纯文本 {患者姓名} —— 存量文书的写法。正则解析、零迁移,但只是一段文字,
* 在编辑器里要按五六次退格才能删干净,也承载不了「选项 / 多选 / 必填」;
* ② `<span data-field>` 结构化占位符 —— 新写入的目标格式。在编辑器里是一个原子节点
* (见 utils/signing-field-node),点一下退格即可整体删除,字段语义全部显式声明。
*
* 两种载体的渲染、统计、签署取值都走同一套解析(collectFieldOccurrences),
* 因此可以并存,不需要一次性迁移存量模板;需要时用 rewriteFieldOccurrences
* 把某个字段就地升级成结构化写法。
*/
import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
/** 占位符书写格式:{患者姓名} */
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_PDF_PAGE_ATTR = 'data-pdf-page'
export const FIELD_PDF_X_ATTR = 'data-pdf-x'
export const FIELD_PDF_Y_ATTR = 'data-pdf-y'
export const FIELD_PDF_WIDTH_ATTR = 'data-pdf-width'
export const FIELD_PDF_HEIGHT_ATTR = 'data-pdf-height'
export const FIELD_PDF_COORDINATE_SYSTEM_ATTR = 'data-pdf-coordinate-system'
/** 结构化占位符的选择器:渲染端与解析端必须用同一个。 */
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)
}
if (field.pdfPosition) {
element.setAttribute(FIELD_PDF_PAGE_ATTR, String(field.pdfPosition.page))
element.setAttribute(FIELD_PDF_X_ATTR, String(field.pdfPosition.x))
element.setAttribute(FIELD_PDF_Y_ATTR, String(field.pdfPosition.y))
element.setAttribute(FIELD_PDF_WIDTH_ATTR, String(field.pdfPosition.width))
element.setAttribute(FIELD_PDF_HEIGHT_ATTR, String(field.pdfPosition.height))
element.setAttribute(FIELD_PDF_COORDINATE_SYSTEM_ATTR, field.pdfPosition.coordinateSystem)
}
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)
const pdfPositionValues = [
element.getAttribute(FIELD_PDF_PAGE_ATTR),
element.getAttribute(FIELD_PDF_X_ATTR),
element.getAttribute(FIELD_PDF_Y_ATTR),
element.getAttribute(FIELD_PDF_WIDTH_ATTR),
element.getAttribute(FIELD_PDF_HEIGHT_ATTR),
]
const pdfNumbers = pdfPositionValues.map((value) => Number(value))
const hasPdfPosition = pdfPositionValues.every((value) => value !== null && value.trim() !== '')
const pdfCoordinateSystem = element.getAttribute(FIELD_PDF_COORDINATE_SYSTEM_ATTR)
const pdfPosition =
hasPdfPosition && pdfNumbers.every(Number.isFinite)
? {
page: pdfNumbers[0],
x: pdfNumbers[1],
y: pdfNumbers[2],
width: pdfNumbers[3],
height: pdfNumbers[4],
coordinateSystem:
pdfCoordinateSystem === 'PDF_BOTTOM_LEFT'
? ('PDF_BOTTOM_LEFT' as const)
: ('TOP_LEFT' as const),
}
: undefined
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,
pdfPosition,
}
}
/** 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 等),粘贴后字体信息丢失、
* 只剩下码位,所以这些也要一并识别。
*
* 放在这里而不是各端各写一份:签署端(utils/signing-document)靠它把方框转成
* 可勾选控件,编辑器(utils/signing-editor-marks)靠它把方框标成勾选胶囊,
* 两边识别的字符集必须完全一致,否则会出现"编辑器标了、签署时没转"的静默失配。
*/
export const SIGNING_OPTION_MARKERS = '□☐▢◻❑❒\uF0A8\uF0FE'
/** 填写项控件类型的中文名,编辑器、占位符检查面板、签署端提示共用一份口径。 */
export const SIGNING_FIELD_KIND_LABELS: Record<SigningFieldKind, string> = {
text: '文本',
date: '日期',
choice: '单选',
checkbox: '勾选',
signature: '签名',
}
const FIELD_TOKEN_SOURCE = `${FIELD_TOKEN_OPEN}([^${FIELD_TOKEN_OPEN}${FIELD_TOKEN_CLOSE}]{1,40})${FIELD_TOKEN_CLOSE}`
/** /g 正则有 lastIndex 副作用,每次解析都新建一个,避免跨调用互相干扰。 */
export function createFieldTokenPattern() {
return new RegExp(FIELD_TOKEN_SOURCE, 'g')
}
/**
* 主签名位字段键(患者签名)。
*
* 一个签署任务只有一份「签名图」产物,接口与任务记录都把它固定落在 signature 上
* (见 api/workbench/signing 的 completeSigningTask:`{ ...fieldAnswers, signature }`)。
* 其余签名位(如医师签名)各自存在 fieldAnswers 里,互不覆盖。
*
* 这条约定有两个用处:
* ① 手写板 / 线上签署采集的就是主签名位,因此它不能算「待补的签名位」,
* 否则签名入口会把自己挡住;
* ② 判断"还有别的签名位没签"时要排除它。
*/
export const PRIMARY_SIGNATURE_FIELD_ID = 'signature'
/**
* 填写项目录:key 为字段键,label 是文书里显示的文字,kind 决定签署时的控件。
* id 与 label 的对应关系由这里统一维护,渲染端与编辑器共用同一份,避免两处各自演化。
*
* 收录标准只有一条:**取值有固定来源**。这里的每一项都能按 id 从 HIS / 患者就诊快照
* 或签署动作里取到值(见 resolveAnswersFromSystem),所以字段键必须稳定、不能随叫法变。
* 现场手填的文本框没有系统取值来源,不需要固定字段键,一律走自定义文本项
* (createCustomField + buildGenericTextToken),不再为「与患者关系」这类写法逐条登记——
* 逐条登记既列不全科室的叫法,也会让同一个输入框因写法不同而落到两个字段键上。
*/
export const SIGNING_FIELD_PRESETS: SigningFieldDef[] = [
{ id: 'patient', label: '患者姓名', kind: 'text', required: true, options: [] },
{ id: 'sex', label: '性别', kind: 'text', required: false, options: [] },
{ id: 'age', label: '年龄', kind: 'text', required: false, options: [] },
{ id: 'visit', label: '就诊号', kind: 'text', required: false, options: [] },
{ id: 'phone', label: '联系电话', kind: 'text', required: false, options: [] },
{ id: 'appointment', label: '预约日期', kind: 'date', required: false, options: [] },
{ id: 'signedDate', label: '签署日期', kind: 'date', required: true, options: [] },
{ id: 'signature', label: '患者签名', kind: 'signature', required: true, options: [] },
{ id: 'doctor', label: '医师签名', kind: 'signature', required: false, options: [] },
]
/**
* 同一字段在不同文书里的常见写法。
* 各科室历史文书用词不统一(患者签名 / 患方签名 / 患者(或家属)签名),
* 归一到同一个字段键,取值才不会因为换了叫法就填不上。
*/
const FIELD_LABEL_ALIASES: Record<string, string> = {
'患者(或家属)签名': 'signature',
'患者/家属签名': 'signature',
患方签名: 'signature',
'患者/监护人签字': 'signature',
签字: 'signature',
'住院号/门诊号': 'visit',
'门诊/住院号': 'visit',
门诊号: 'visit',
住院号: 'visit',
手机号: 'phone',
预约日期: 'appointment',
日期: 'signedDate',
签名日期: 'signedDate',
}
const PRESET_BY_ID = new Map(SIGNING_FIELD_PRESETS.map((field) => [field.id, field]))
const PRESET_BY_LABEL = new Map(SIGNING_FIELD_PRESETS.map((field) => [field.label, field]))
/**
* 自定义占位符的字段键前缀:label 不在目录里时按文本填写项处理。
* 键 = `custom:名称`,同名占位符第 2 个起再接 `#出现次序`——见 resolveFieldByLabel。
*/
const CUSTOM_FIELD_PREFIX = 'custom:'
export function getPresetField(id: string): SigningFieldDef | null {
const preset = PRESET_BY_ID.get(id)
return preset ? { ...preset } : null
}
export function createCustomField(
label: string,
kind: SigningFieldKind = 'text',
options: string[] = [],
/** 同名占位符在文书里的出现次序,从 1 开始;见 resolveFieldByLabel */
sequence = 1,
/** 有选项时是否多选;不传表示按类型取缺省(见 SigningFieldDef.multi) */
multi?: boolean,
): SigningFieldDef {
return {
id: `${CUSTOM_FIELD_PREFIX}${label}${sequence > 1 ? `#${sequence}` : ''}`,
label,
kind,
required: false,
options,
...(multi === undefined ? {} : { multi }),
}
}
/** 判断字段键是否来自目录之外的自定义文本项(现场手填的输入框) */
export function isCustomFieldId(id: string): boolean {
return id.startsWith(CUSTOM_FIELD_PREFIX)
}
/**
* 通用文本项的默认名称(占位符 {文本框})。名称只决定文书里显示的文字,不决定它是哪个字段。
*
* 为什么留一个通用入口,而不是逐个登记「与患者关系」「受托人姓名」:
* 这类内容是现场手填的输入框,没有系统取值来源,叫法又千变万化(代理文书里
* 还有「受托人姓名」「代理人身份证号」……),在目录里逐条登记既列不全,
* 也会让同一个输入框因叫法不同落到两个字段键上。
*
* 名称也不负责区分"是不是同一个填写项":文本框按出现位置各自成项(见 resolveFieldByLabel),
* 所以一份文书里的两个文本框天然就是两份取值,不需要为了区分而改名字——
* 改名只是让文书写得像文书(「文本框」改成「与患者关系」)。
*
* 默认名不叫「文本」是为了在编辑器里能认出来:胶囊的角标已经写着「文本」,
* 名称再叫「文本」会显示成「文本 文本」,分不清哪个是名字哪个是类型。
*/
export const GENERIC_TEXT_FIELD_LABEL = '文本框'
/**
* 生成通用文本项的占位符,默认 {文本框}。
*
* 走的是目录外的自定义文本项:resolveFieldByLabel 查不到目录就按文本控件处理,
* 签署时渲染成一个输入框,取值落在 fieldAnswers 里。
*/
export function buildGenericTextToken(label: string = GENERIC_TEXT_FIELD_LABEL): string {
return buildFieldToken(createCustomField(label.trim()))
}
/**
* 按占位符文字定位字段定义:先查目录,再查别名,都不是就当自定义文本项。
* 这样存量文书里写的是「患者(或家属)签名」也能命中签名控件。
*
* sequence 是**同名占位符在本文书里的第几次出现**(从 1 开始),只有自定义文本项用得上:
*
* - 目录里的字段按 id 去重,同名共用一份取值是刻意的——每页都出现的签名位、
* 正文里反复提到的患者姓名,取值只能是同一个;
* - 自定义文本项没有取值来源,两个 {文本框} 是两处要填的内容,共用一个值才是错的。
* 而占位符是纯文本,正文里只有文字可依据,所以用"第几次出现"给它们各自编号:
* 第 1 个 custom:文本框,第 2 个 custom:文本框#2……位置就是它们的身份。
*
* 由此得到一条对科室更省心的规则:**改名字不影响取值归属**,名字纯粹是给人看的。
* 前提是模板内容定稿后就冻结(发起签署时会快照正文),位置编号才不会漂移。
*/
export function resolveFieldByLabel(label: string, sequence = 1): SigningFieldDef {
const trimmed = label.trim()
const preset = PRESET_BY_LABEL.get(trimmed)
if (preset) {
return { ...preset }
}
const aliasId = FIELD_LABEL_ALIASES[trimmed]
if (aliasId) {
const aliased = PRESET_BY_ID.get(aliasId)
if (aliased) {
return { ...aliased }
}
}
return createCustomField(trimmed, 'text', [], sequence)
}
/** 扫描结果:占位符在文本里的位置 + 它对应的字段定义 */
export interface ScannedFieldToken {
start: number
end: number
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
}
/**
* 扫描一段文本里的占位符并解析成字段定义。
*
* counters 必须**跨段传递**(同一份文书的每个文本节点/整段 HTML 共用一个):
* 自定义文本项靠"同名占位符第几次出现"编号,分段扫描时如果各自从 1 数起,
* 编辑器面板统计出的字段键就会和签署渲染时写进 DOM 的键对不上,
* 表现是填写项统计里有这一项、签署时却收集不到值。
*
* claimed 是**结构化占位符已占用的字段键**,同样要跨段传递。
* 为什么光有计数器不够:结构化占位符的键写在属性里,纯文本占位符的键是现算的,
* 两套编号互不知情。一份文书里两种载体混用时(改过配置的那处已结构化、
* 同名的另一处还是纯文本),纯文本这一遍会重新数出 `custom:文本框` 这种
* 已被结构化元素占用的键——两个控件挂同一个键,填哪一处都写进同一份取值。
* 所以纯文本除了按同名声数,还要让开已被占用的键。
*
* 之所以要有个统一的扫描函数,就是因为这条编号规则被三处用到
* (填写项统计 collectFieldDefs、重复检查 findDuplicateFields、签署渲染 renderTokenFields),
* 三处各写一份计时器迟早会漂移。
*/
export function scanFieldTokens(
text: string,
counters: Map<string, number>,
claimed?: Set<string>,
): ScannedFieldToken[] {
const tokens: ScannedFieldToken[] = []
const pattern = createFieldTokenPattern()
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
const label = (match[1] ?? '').trim()
if (!label) {
continue
}
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,
})
}
return tokens
}
/**
* 生成纯文本占位符 {患者姓名}。
*
* 新写入的占位符已改走结构化写法(编辑器里插入的是 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 里取出结构化占位符已占用的字段键。
*
* 给「要自己扫纯文本占位符」的调用方用(目前是签署渲染的 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[] {
const seen = new Set<string>()
const fields: SigningFieldDef[] = []
collectFieldOccurrences(contentHtml).forEach(({ field }) => {
if (!field.label || seen.has(field.id)) {
return
}
seen.add(field.id)
fields.push(field)
})
return fields
}
/**
* 同一占位符在文书里写了多次的字段(保存模板时提示,通常是误重复)。
*
* 这里**只会命中目录里的字段**:自定义文本项按出现位置编号,每次出现的字段键都不同,
* 计数永远到不了 2。这是对的——两处 {文本框} 是两处要填的内容,
* 不是"同一个字段写重复了",不该提示科室去合并。
*/
export function findDuplicateFields(contentHtml: string): SigningFieldDef[] {
const counts = new Map<string, { field: SigningFieldDef; count: number }>()
collectFieldOccurrences(contentHtml).forEach(({ field }) => {
const entry = counts.get(field.id)
counts.set(field.id, { field, count: (entry?.count ?? 0) + 1 })
})
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,
)
}
/**
* 把导入的 Word 表格里的「勾选」列转成结构化勾选占位符(原型 docCheckboxize 的口径)。
*
* 为什么在**导入时**转,而不是留到签署渲染再转:
* ① 转完它就是一个正常的填写项,科室可以点开改名称、类型、选项,
* 与手工插入的占位符没有任何区别;
* ② 留在渲染期转的话,这份文书在编辑器里始终是一张"看不出哪里要勾"的空表格,
* 科室没法在保存前核对。
*
* 取值列的选择顺序照原型:勾选列右边第二列 → 左边第一列 → 右边第一列 → 「选择项目」。
* 原型会给转出来的勾选项写 data-risk="是",但那个值在渲染侧从未被读取
* (勾选的取值是布尔,命中判据比不出来),属于原型里没落地的残留,这里不抄。
*
* 字段键必须逐格现取:同一张表里两行都叫「已阅读」时,共用 `custom:已阅读`
* 会让两处勾选互相覆盖,所以用 allocateCustomFieldId 的同款规则按占用情况往后编号。
*/
export function convertCheckboxColumnsToFields(contentHtml: string): string {
if (!contentHtml || !contentHtml.includes('<table')) {
return contentHtml
}
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
}
const claimed = collectStructuredIdsFrom(root)
let changed = false
root.querySelectorAll('table').forEach((table) => {
const rows = Array.from(table.querySelectorAll('tr'))
if (rows.length < 2) {
return
}
const headerRow = rows[0] as HTMLTableRowElement
const headerCells = Array.from(headerRow.cells)
const checkIndex = headerCells.findIndex((cell) => cell.textContent?.trim() === '勾选')
if (checkIndex === -1) {
return
}
rows.slice(1).forEach((row) => {
const cells = Array.from((row as HTMLTableRowElement).cells)
const target = cells[checkIndex]
// 只转空单元格:已经有内容的说明科室自己填过,别覆盖
if (!target || (target.textContent ?? '').trim()) {
return
}
const label =
(cells[checkIndex + 2]?.textContent ?? '').trim() ||
(cells[checkIndex - 1]?.textContent ?? '').trim() ||
(cells[checkIndex + 1]?.textContent ?? '').trim() ||
'选择项目'
let sequence = 1
while (claimed.has(createCustomField(label, 'checkbox', [], sequence).id)) {
sequence += 1
}
const field = createCustomField(label, 'checkbox', [], sequence)
claimed.add(field.id)
target.replaceChildren(createFieldElement(parsed, { ...field, required: true }))
changed = true
})
})
return changed ? root.innerHTML : contentHtml
}
/**
* 给新插入的自定义文本项分配一个在本文书里唯一的字段键。
*
* 自定义文本项的字段键是 `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 对应,是「自动填充」的唯一入口。
*/
export interface SigningSystemSnapshot {
patientName?: string
sex?: string
age?: number | string
visitNo?: string
visitDate?: string
phone?: string
doctorName?: string
signedAt?: string
signatureDataUrl?: string
}
/** 把患者/就诊快照映射成填写项初值,字段键即 SIGNING_FIELD_PRESETS 的 id。 */
export function resolveAnswersFromSystem(snapshot: SigningSystemSnapshot): SigningAnswers {
return {
patient: snapshot.patientName ?? '',
sex: snapshot.sex ?? '',
age: snapshot.age === undefined || snapshot.age === null ? '' : String(snapshot.age),
visit: snapshot.visitNo ?? '',
phone: snapshot.phone ?? '',
appointment: snapshot.visitDate ?? '',
// 签署日期是必填项,未签署前先给当天日期,符合"系统预填 + 现场确认"的业务预期;
// 真正确认的日期以签署完成时固化的取值(signedAt)为准
signedDate: (snapshot.signedAt ?? new Date().toISOString()).slice(0, 10),
signature: snapshot.signatureDataUrl ?? '',
// 医师签名必须由现场采集,不能由系统代填。
// 同理,与患者关系、受托人姓名这类现场手填的文本框是自定义文本项
// (见 createCustomField),没有系统初值——把「与患者关系」恒填「本人」
// 等于把代理人签署这条路堵死了,所以这一项连空值都不在这里预置。
doctor: '',
}
}