/** * 文书填写项(占位符)模型。 * * 相比「把占位符当普通文字做字符串替换」,这里给每个占位符补上语义: * ① 稳定字段键(id):目录里的字段与 HIS / 患者快照的取值一一对应,不依赖中文标签匹配; * 目录外的自定义文本项按出现位置编号(custom:名称#第几次),两处文本框就是两份取值; * ② 控件类型(kind):签名是与文本/日期/单选/勾选并列的一种填写项, * 签完把取值写回同一字段即可在文书原位置回显; * ③ 必填与选项:模板保存时可做去重校验,签署时可校验必填项是否填完; * ④ 风险值(risk):签署取值命中即标记为异常项。 * * 载体有两种,由本文件同时维护读写: * ① 纯文本 {患者姓名} —— 存量文书的写法。正则解析、零迁移,但只是一段文字, * 在编辑器里要按五六次退格才能删干净,也承载不了「选项 / 多选 / 必填」; * ② `` 结构化占位符 —— 新写入的目标格式。在编辑器里是一个原子节点 * (见 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 = '}' /** * 结构化占位符的元素与属性契约。 * * 两种载体并存,各有分工: * - **纯文本 {患者姓名}**:存量模板的写法。正则解析、零迁移风险,但只是一段文字, * 在编辑器里要按五六次退格才能删干净,也没法承载"选项""多选"这类结构化信息。 * - **``**:新写入的目标格式。在编辑器里是一个原子节点(见 * 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 = { 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 = { '患者(或家属)签名': '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): 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, claimed?: Set, ): 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 { const ids = new Set() 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 { if (!contentHtml || !contentHtml.includes('data-field')) { return new Set() } const parsed = new DOMParser().parseFromString( `
${contentHtml}
`, 'text/html', ) const root = parsed.getElementById(SCAN_ROOT_ID) return root ? collectStructuredIdsFrom(root) : new Set() } /** * 按文档顺序扫描文书里的全部占位符,两种载体合并。 * * 走 DOM 树而不是对 HTML 字符串跑正则:字符串正则会把属性值里的 {…}、 * 以及跨标签断开的 token 也算成占位符,而渲染端(renderTokenFields)是走文本节点的, * 两边口径一旦不一致,就会出现"面板里统计得到这一项、签署时却渲染不出来"。 * 统一按文本节点扫描后,编辑器统计与签署渲染严格同源。 */ function collectFieldOccurrences(contentHtml: string): FieldOccurrence[] { if (!contentHtml) { return [] } const parsed = new DOMParser().parseFromString( `
${contentHtml}
`, 'text/html', ) const root = parsed.getElementById(SCAN_ROOT_ID) if (!root) { return [] } const collected: Array<{ node: Node; field: SigningFieldDef }> = [] // 结构化占位符先扫出来,它们的字段键要作为"已占用"传给纯文本那一遍 const claimed = new Set() 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() 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() 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() 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( `
${contentHtml}
`, '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() 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('${contentHtml}`, '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 } /** 匹配单个方框符号(SIGNING_OPTION_MARKERS 里的任意一个) */ const BOX_TEST_PATTERN = new RegExp(`[${SIGNING_OPTION_MARKERS}]`) /** 方框转出来的勾选项取不到上下文名称时的兜底名称(与工具栏「勾选项」按钮同名) */ const DEFAULT_BOX_LABEL = '勾选项' /** 选项名最长取多少字:方框后面若是一整句话,别把整句当成字段名 */ const BOX_LABEL_MAX_LENGTH = 20 /** * 方框后第一段文字(到标点 / 空白为止)就是选项名。 * * 与签署渲染期的 replaceOptionMarkers 用**同一条规则** —— 两边认出来的选项名必须一致, * 否则「导入时按一套切分成组、签署渲染时又按另一套切分」会得到对不上的选项。 */ function readOptionAfter(text: string, from: number): { label: string; end: number } { const match = /^(\s*)([^\s。,、;:,.;:]*)/.exec(text.slice(from)) const leading = match?.[1]?.length ?? 0 const raw = match?.[2] ?? '' return { label: raw.slice(0, BOX_LABEL_MAX_LENGTH), end: from + leading + raw.length } } /** * 取一个在本文书里没被占用的勾选项字段键。 * 与 allocateCustomFieldId 同款规则:同名的第二处往后编号,两处方框不会共用一份取值。 */ function nextBoxField( claimed: Set, label: string, options: string[], multi?: boolean, ): SigningFieldDef { let sequence = 1 while (claimed.has(createCustomField(label, 'checkbox', options, sequence).id)) { sequence += 1 } const field = createCustomField(label, 'checkbox', options, sequence, multi) claimed.add(field.id) return field } /** 把一个文本节点里的方框换成结构化占位符;没有方框时返回 null,调用方据此跳过替换 */ function rewriteBoxesInText( doc: Document, text: string, claimed: Set, ): DocumentFragment | null { const pattern = new RegExp(`[${SIGNING_OPTION_MARKERS}]`, 'g') const boxes: Array<{ index: number; end: number; label: string }> = [] let match: RegExpExecArray | null while ((match = pattern.exec(text)) !== null) { const option = readOptionAfter(text, pattern.lastIndex) boxes.push({ index: match.index, end: option.end, label: option.label }) } if (!boxes.length) { return null } const fragment = doc.createDocumentFragment() const first = boxes[0] const last = boxes[boxes.length - 1] const options = boxes.map((box) => box.label).filter(Boolean) // 同一处出现多个方框时合成一个勾选组:签署渲染期对这种情况本来就是按「互斥单选」处理的 // (convertInlineOptions 数方框个数 > 1 → radio),拆成两个独立勾选框会让患者把 // 「是」「否」同时勾上。 if (first && last && boxes.length > 1 && options.length > 1) { fragment.appendChild(doc.createTextNode(text.slice(0, first.index))) fragment.appendChild( createFieldElement(doc, nextBoxField(claimed, DEFAULT_BOX_LABEL, options, false)), ) fragment.appendChild(doc.createTextNode(text.slice(last.end))) return fragment } // 单个方框:只换掉方框本身,后面的文字原样留在正文里(「□ 已阅读」→「[勾选框] 已阅读」) let cursor = 0 boxes.forEach((box) => { fragment.appendChild(doc.createTextNode(text.slice(cursor, box.index))) fragment.appendChild( createFieldElement(doc, nextBoxField(claimed, box.label || DEFAULT_BOX_LABEL, [])), ) cursor = box.index + 1 }) fragment.appendChild(doc.createTextNode(text.slice(cursor))) return fragment } /** * 把正文里科室手打的方框(□ / ☐ / Wingdings 私有区码位,见 SIGNING_OPTION_MARKERS) * 替换成**结构化勾选占位符** —— 与工具栏「勾选项」按钮插出来的是同一种东西。 * * 为什么要在导入 / 粘贴时转,而不是留到签署渲染再转(见 signing-document 的 convertInlineOptions): * ① 渲染期那一步只改签署态的外观,编辑层里它始终是一段普通文字 —— 科室看到的是 * 「浅蓝小方块」,与插入的绿色胶囊两副长相,也点不开填写项配置; * ② 转成占位符后它就是一个正常的填写项,可以改名称、类型、选项,与手工插入的没有区别。 * * 取值规则照抄签署渲染期:方框后第一段文字(到标点为止)就是选项名。于是 * - 一块里只有一个方框 → 一个无选项的勾选框,方框被替换、后面的文字留在正文里; * - 一块里有多个方框 → 合成一个勾选组(选项名进 data-options,显式 multi=false)。 * * 只处理**同一个文本节点内**的方框:跨节点的方框(如 `□是 □否`)罕见, * 要重建多个文本节点才能合并,留给签署渲染期的启发式兜底,不做半吊子的合并。 * * 已经结构化的占位符内部不扫:它的标签文字里可能带方框字符(如 {□} 那种旧写法)。 */ export function convertInlineBoxesToFields(contentHtml: string): string { if (!contentHtml || !BOX_TEST_PATTERN.test(contentHtml)) { return contentHtml } const parsed = new DOMParser().parseFromString( `
${contentHtml}
`, 'text/html', ) const root = parsed.getElementById(SCAN_ROOT_ID) if (!root) { return contentHtml } const claimed = collectStructuredIdsFrom(root) const walker = parsed.createTreeWalker(root, NodeFilter.SHOW_TEXT) const targets: Text[] = [] let node = walker.nextNode() while (node) { const text = node.textContent ?? '' // 结构化占位符内部的文字是它自己的标签,不再当方框扫一遍 if (BOX_TEST_PATTERN.test(text) && !node.parentElement?.closest(FIELD_ELEMENT_SELECTOR)) { targets.push(node as Text) } node = walker.nextNode() } let changed = false targets.forEach((textNode) => { const replacement = rewriteBoxesInText(parsed, textNode.textContent ?? '', claimed) if (!replacement) { return } textNode.replaceWith(replacement) 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: '', } }