diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index f99665e..3b3a410 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -23,6 +23,7 @@ import type { SignTaskEventResponseDto, SignTaskResponseDto, SignTaskSendPreparationResponseDto, + SigningAnswers, SigningApiOptions, SigningMethod, SigningTaskEvent, @@ -351,6 +352,8 @@ function cloneTask(task: SigningTaskRecord): SigningTaskRecord { ...task, // 勾选结果是数组,浅拷贝会让调用方和内存数据共享同一个引用 selectedItems: task.selectedItems ? [...task.selectedItems] : task.selectedItems, + // 填写项取值同理,调用方会就地改动它 + fieldAnswers: task.fieldAnswers ? { ...task.fieldAnswers } : task.fieldAnswers, } } @@ -1008,6 +1011,7 @@ export function completeSigningTask( id: string, signatureDataUrl: string, selectedItems: string[] = [], + fieldAnswers: SigningAnswers = {}, ): Promise { if (!useMockData) { throw new Error('真实签署需要通过签署投递与文件上传接口完成') @@ -1025,6 +1029,8 @@ export function completeSigningTask( task.updatedAt = task.signedAt // 勾选结果随签署一起固化,否则重新打开文书会丢掉患者勾了哪些项 task.selectedItems = [...selectedItems] + // 填写项取值(含签名图)随签署一起固化:重新打开文书时按原位置回显 + task.fieldAnswers = { ...fieldAnswers, signature: signatureDataUrl } persistMockRecords() return Promise.resolve(cloneTask(task)) } @@ -1050,8 +1056,9 @@ export async function reopenSigningTask( task.status = 'pending' task.signatureDataUrl = undefined task.signedAt = undefined - // 重新发起等于重新签,上一次的勾选不再沿用 + // 重新发起等于重新签,上一次的勾选与填写项取值都不再沿用 task.selectedItems = [] + task.fieldAnswers = undefined task.updatedAt = formatMockDate() persistMockRecords() return Promise.resolve(cloneTask(task)) diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index 0a6e740..b30fe25 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -167,6 +167,31 @@ export interface SigningTaskQuery extends PageQuery { export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all' +/** + * 文书填写项的控件类型。 + * signature 与其他类型并列:签名是文书上的一个填写项,不是特殊的文本替换。 + */ +export type SigningFieldKind = 'text' | 'date' | 'choice' | 'checkbox' | 'signature' + +/** 文书里的一个填写项定义,由模板内容中的占位符解析得到。 */ +export interface SigningFieldDef { + /** 稳定字段键:与 HIS / 患者快照取值一一对应,不随占位符显示文字变化 */ + id: string + /** 文书中显示的文字 */ + label: string + kind: SigningFieldKind + required: boolean + /** choice / checkbox 的可选项 */ + options: string[] + /** 取值等于该字符串时视为异常项(如风险筛查选了"是") */ + risk?: string +} + +export type SigningFieldValue = string | boolean + +/** 填写项取值,key 为 SigningFieldDef.id。 */ +export type SigningAnswers = Record + export interface SigningTaskRecord extends WorkbenchTask { templateVersionId: string campus: WorkbenchCampus @@ -192,6 +217,11 @@ export interface SigningTaskRecord extends WorkbenchTask { documentContentHtml?: string /** 原型:患者勾选结果,随签署一起留存(对应真实实现里随签署证据提交的勾选摘要)。 */ selectedItems?: string[] + /** + * 文书填写项取值(含签名图),签署时固化。 + * 重新打开文书时用它回显,签名因此落在文书原本的签名位上。 + */ + fieldAnswers?: SigningAnswers } export type SigningTaskListResponse = PageResult diff --git a/clinical-web/src/utils/signing-document.ts b/clinical-web/src/utils/signing-document.ts index d4a36d2..182c625 100644 --- a/clinical-web/src/utils/signing-document.ts +++ b/clinical-web/src/utils/signing-document.ts @@ -1,31 +1,47 @@ /** - * 签署文书 HTML 渲染管线(原型)。 + * 文书渲染管线(原型)。 * - * 把「文书定稿 HTML」按签署时的效果做一次变换: - * ① 占位符填充({患者姓名}等 → 患者与就诊快照数据); - * ② 签名域:已有签名图时替换 {患者签名},否则给出待签署提示; - * ③ 勾选表识别:表头含「勾选」列的表格,逐行转为复选框; - * ④ 行内选项识别:正文手打的 □ 方框(□否 □是)转为单选/多选框。 + * 把「模板定稿 HTML」渲染成签署态: + * ① 填写项渲染:占位符按控件类型渲染成文本/日期/单选/勾选/签名控件, + * 初值来自 HIS 与患者就诊快照,签署时可在文书上直接确认或补录; + * ② 签名回显:签名是 kind=signature 的填写项,签名图作为该字段的取值, + * 签完后落在文书原本的签名位上,不需要"替换文字"这一步; + * ③ 勾选表识别(存量兼容):表头含「勾选」列的表格逐行转为复选框; + * ④ 行内选项识别(存量兼容):正文手打的 □ 方框转成单选/多选框。 * - * 签署任务详情(SigningDocumentInteractive)与文书库的「签署预览」共用本模块, - * 避免两处各自演化后渲染结果不一致。真实实现中填充在后端完成、勾选结果随签署证据 - * 提交,此处仅用于前端闭环验证。 + * ③④ 是为已经存在的存量文书保留的启发式识别;用占位符写的新文书不再依赖它们。 + * + * 渲染所需的取值模型见 signing-fields.ts;真实实现中填充与固化在后端完成, + * 此处仅用于前端闭环验证。 */ -/** 渲染所需的患者/就诊快照字段;SigningTaskRecord 与预览用示例数据都满足此形状。 */ -export interface SigningDocumentData { - patientName: string - sex: string - age: number | string - visitNo: string - visitDate: string - signedAt?: string - signatureDataUrl?: string - /** 已勾选的条目名:签署完成后重新打开文书时用来还原勾选状态 */ +import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/workbench/types' + +import { + createFieldTokenPattern, + FIELD_TOKEN_OPEN, + resolveAnswersFromSystem, + resolveFieldByLabel, + type SigningSystemSnapshot, +} from './signing-fields' + +/** 渲染所需的患者/就诊快照 + 已填写的填写项取值。 */ +export interface SigningDocumentData extends SigningSystemSnapshot { + /** 已签署的存量文书里,由「勾选表」收集的条目 */ selectedItems?: string[] + /** + * 填写项取值:已填过的值优先于系统快照(用于签署后回显)。 + * 属性名与 SigningTaskRecord.fieldAnswers 一致,避免任务对象透传时对不上而静默丢值。 + */ + fieldAnswers?: SigningAnswers } -const SIGNATURE_PLACEHOLDER = '{患者签名}' +/** 填写项在文书上的宿主节点属性,签署端靠它收集取值 */ +const FIELD_ATTR = 'data-sign-field' +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' /** * 科室手打的复选框符号:几何图形方框,以及 Wingdings 私有区码位。 @@ -41,69 +57,224 @@ const OPTION_TEST_PATTERN = new RegExp(`[${OPTION_MARKERS}]`) /** 判定「同一组选项」的块级容器:同一段、同一单元格里的方框视为一组 */ const OPTION_BLOCK_SELECTOR = 'p, li, td, th, div, h1, h2, h3, h4, h5, h6' -/** 收集所有包含指定关键字(占位符前缀等)的文本节点 */ -function collectTextNodes(root: Element, keyword: string): Text[] { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) +/** 一个填写项在文书上的取值状态,用于签署端提示必填与异常 */ +export interface SigningFieldProgress { + total: number + filled: number + /** 未填写的必填项标签 */ + missingRequired: string[] + /** 取值命中风险的项标签 */ + abnormal: string[] +} + +function buildFieldWidget( + doc: Document, + field: SigningFieldDef, + answers: SigningAnswers, + interactive: boolean, +): HTMLElement { + const value = answers[field.id] + const abnormal = + Boolean(field.risk) && typeof value === 'string' && value === (field.risk as string) + + const host = doc.createElement('span') + host.className = `sign-field sign-field--${field.kind}${abnormal ? ' sign-field--abnormal' : ''}` + host.setAttribute(FIELD_ATTR, field.id) + host.setAttribute(FIELD_KIND_ATTR, field.kind) + host.setAttribute(FIELD_LABEL_ATTR, field.label) + host.setAttribute(FIELD_REQUIRED_ATTR, field.required ? 'true' : 'false') + + if (field.risk) { + host.setAttribute(FIELD_RISK_ATTR, field.risk) + } + + const label = doc.createElement('span') + label.className = 'sign-field__label' + label.textContent = field.label + + if (field.required) { + const mark = doc.createElement('span') + mark.className = 'sign-field__required' + mark.textContent = '*' + label.appendChild(mark) + } + + host.appendChild(label) + + const control = doc.createElement('span') + control.className = 'sign-field__control' + control.appendChild(buildFieldControl(doc, field, value, interactive)) + host.appendChild(control) + + if (abnormal) { + const badge = doc.createElement('span') + badge.className = 'sign-field__badge' + badge.textContent = '异常项' + host.appendChild(badge) + } + + return host +} + +function buildFieldControl( + doc: Document, + field: SigningFieldDef, + value: SigningAnswers[string], + interactive: boolean, +): Node { + const textValue = typeof value === 'string' ? value : value ? '是' : '' + + if (field.kind === 'signature') { + if (textValue) { + const image = doc.createElement('img') + image.className = 'sign-doc-signature' + image.src = textValue + image.alt = field.label + return image + } + + const empty = doc.createElement('span') + empty.className = 'sign-field__signature-empty' + empty.textContent = `待${field.label}` + return empty + } + + if (field.kind === 'choice') { + 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 + } + + if (field.kind === 'checkbox') { + const wrapper = doc.createElement('label') + wrapper.className = 'sign-doc-option' + + const input = doc.createElement('input') + input.className = 'sign-field__checkbox' + input.type = 'checkbox' + input.disabled = !interactive + + if (value === true) { + input.checked = true + input.setAttribute('checked', '') + } + + wrapper.appendChild(input) + wrapper.appendChild(doc.createTextNode(field.label)) + return wrapper + } + + const input = doc.createElement('input') + input.className = 'sign-field__input' + input.type = field.kind === 'date' ? 'date' : 'text' + input.value = textValue + // value 属性同理:只设 property 的话序列化出来是空值 + input.setAttribute('value', textValue) + input.readOnly = !interactive + input.disabled = !interactive + if (interactive) { + input.placeholder = '请填写' + } + + return input +} + +/** + * 占位符渲染:把 {患者姓名} 就地替换成填写项控件,保留同一文本节点内的前后文字。 + * 模板未收录的占位符按自定义文本项处理,仍可在文书上填写, + * 避免出现"写了占位符但签署时既填不上也看不见"的情况。 + */ +function renderTokenFields(root: Element, answers: SigningAnswers, interactive: boolean) { + const doc = root.ownerDocument + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT) const targets: Text[] = [] let node = walker.nextNode() while (node) { - if ((node.textContent ?? '').includes(keyword)) { + if ((node.textContent ?? '').includes(FIELD_TOKEN_OPEN)) { targets.push(node as Text) } node = walker.nextNode() } - return targets -} + targets.forEach((textNode) => { + const text = textNode.textContent ?? '' + const pattern = createFieldTokenPattern() + const fragment = doc.createDocumentFragment() + let cursor = 0 + let match: RegExpExecArray | null -function replaceTextPlaceholder(root: Element, placeholder: string, replacement: string) { - collectTextNodes(root, placeholder).forEach((textNode) => { - textNode.textContent = (textNode.textContent ?? '').split(placeholder).join(replacement) - }) -} + while ((match = pattern.exec(text)) !== null) { + if (match.index > cursor) { + fragment.appendChild(doc.createTextNode(text.slice(cursor, match.index))) + } -/** 把 {患者签名} 就地换成签名图片,保留同一文本节点内的前后文字 */ -function replaceSignaturePlaceholder(root: Element, dataUrl: string) { - collectTextNodes(root, SIGNATURE_PLACEHOLDER).forEach((textNode) => { - const parts = (textNode.textContent ?? '').split(SIGNATURE_PLACEHOLDER) - const fragment = document.createDocumentFragment() + fragment.appendChild( + buildFieldWidget(doc, resolveFieldByLabel(match[1] ?? ''), answers, interactive), + ) - if (parts[0]) { - fragment.appendChild(document.createTextNode(parts[0])) + cursor = pattern.lastIndex } - const image = document.createElement('img') - image.src = dataUrl - image.alt = '患者签名' - image.className = 'sign-doc-signature' - fragment.appendChild(image) - - if (parts[1]) { - fragment.appendChild(document.createTextNode(parts[1])) + if (cursor < text.length) { + fragment.appendChild(doc.createTextNode(text.slice(cursor))) } textNode.replaceWith(fragment) }) } -function fillPatientPlaceholders(root: Element, data: SigningDocumentData) { - const replacements: Record = { - '{患者姓名}': data.patientName, - '{性别}': data.sex, - '{年龄}': `${data.age} 岁`, - '{就诊号}': data.visitNo, - '{联系电话}': '—', - '{预约日期}': data.visitDate, - '{与患者关系}': '本人', - '{签署日期}': data.signedAt || data.visitDate || '  年  月  日', - } +/** + * 结构化占位符渲染:兼容 写法。 + * 该写法是目标格式(字段语义显式、不依赖中文标签), + * 供后端模板或后续换编辑器后生产的内容使用。 + */ +function renderStructuredFields(root: Element, answers: SigningAnswers, interactive: boolean) { + const doc = root.ownerDocument - collectTextNodes(root, '{').forEach((textNode) => { - Object.entries(replacements).forEach(([placeholder, value]) => { - textNode.textContent = (textNode.textContent ?? '').split(placeholder).join(value) - }) + root.querySelectorAll('span[data-field]').forEach((element) => { + const id = element.getAttribute('data-field') ?? '' + const label = element.getAttribute('data-label') ?? id + + if (!id) { + 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)) }) } @@ -165,7 +336,7 @@ function convertCheckboxes(root: Element, interactive: boolean, selected: Set, ) { const parts = (textNode.textContent ?? '').split(OPTION_SPLIT_PATTERN) - const fragment = document.createDocumentFragment() + const fragment = textNode.ownerDocument.createDocumentFragment() for (let index = 0; index < parts.length; index += 1) { const part = parts[index] ?? '' if (!OPTION_TEST_PATTERN.test(part)) { if (part) { - fragment.appendChild(document.createTextNode(part)) + fragment.appendChild(textNode.ownerDocument.createTextNode(part)) } continue @@ -210,7 +381,7 @@ function replaceOptionMarkers( const label = labelMatch?.[1] ?? '' const tail = labelMatch?.[2] ?? '' - const input = document.createElement('input') + const input = textNode.ownerDocument.createElement('input') input.type = type input.className = 'sign-doc-checkbox' input.disabled = !interactive @@ -227,15 +398,15 @@ function replaceOptionMarkers( input.value = label } - const wrapper = document.createElement('label') + const wrapper = textNode.ownerDocument.createElement('label') wrapper.className = 'sign-doc-option' wrapper.appendChild(input) - wrapper.appendChild(document.createTextNode(label)) + wrapper.appendChild(textNode.ownerDocument.createTextNode(label)) fragment.appendChild(wrapper) if (tail) { - fragment.appendChild(document.createTextNode(tail)) + fragment.appendChild(textNode.ownerDocument.createTextNode(tail)) } // 选项名已随方框一起消费,跳过它对应的片段 @@ -246,7 +417,7 @@ function replaceOptionMarkers( } /** - * 行内选项识别:正文里手打的方框(□否 □是、□同意 □不同意)。 + * 行内选项识别(存量兼容):正文里手打的方框(□否 □是、□同意 □不同意)。 * 同一段/同一单元格里出现两个及以上方框时按「二选一」处理成互斥单选; * 只有一个方框则保持多选语义,避免把「□高血压」这类单项确认误判成互斥组。 */ @@ -286,7 +457,7 @@ function convertInlineOptions(root: Element, interactive: boolean, selected: Set /** * 生成签署态文书 HTML。 * contentHtml 为空时返回空串;解析失败时原样返回,不会抛错。 - * interactive 为 false(预览场景)时复选框只读展示。 + * interactive 为 false(预览 / 已签署查看)时填写项只读展示取值。 */ export function buildSigningDocumentHtml( contentHtml: string, @@ -307,19 +478,104 @@ export function buildSigningDocumentHtml( return contentHtml } - fillPatientPlaceholders(root, data) - - if (data.signatureDataUrl) { - replaceSignaturePlaceholder(root, data.signatureDataUrl) - } else { - replaceTextPlaceholder(root, SIGNATURE_PLACEHOLDER, '(待患者签署)') + // 已填写的取值优先于系统快照:签署后被固化的填写项要保持原样回显 + const answers: SigningAnswers = { + ...resolveAnswersFromSystem(data), + ...(data.fieldAnswers ?? {}), } - // 已签署的文书再次打开时,把上次记录的勾选状态还原出来 - const selected = new Set(data.selectedItems ?? []) + if (data.signatureDataUrl && !answers.signature) { + answers.signature = data.signatureDataUrl + } + renderStructuredFields(root, answers, interactive) + renderTokenFields(root, answers, interactive) + + const selected = new Set(data.selectedItems ?? []) convertCheckboxes(root, interactive, selected) convertInlineOptions(root, interactive, selected) return root.innerHTML } + +/** + * 从渲染后的文书 DOM 里读回填写项取值。 + * 签名读的是签名图,其余按控件类型读,返回结果直接可作为签署证据的一部分固化。 + */ +export function readAnswersFromRoot(root: HTMLElement): SigningAnswers { + const answers: SigningAnswers = {} + + root.querySelectorAll(`[${FIELD_ATTR}]`).forEach((host) => { + const id = host.getAttribute(FIELD_ATTR) + + if (!id) { + return + } + + const kind = (host.getAttribute(FIELD_KIND_ATTR) ?? 'text') as SigningFieldKind + + if (kind === 'signature') { + answers[id] = host.querySelector('img')?.getAttribute('src') ?? '' + return + } + + if (kind === 'choice') { + const checked = host.querySelector('input[type="radio"]:checked') + answers[id] = checked?.value ?? '' + return + } + + if (kind === 'checkbox') { + const box = host.querySelector('input[type="checkbox"]') + answers[id] = Boolean(box?.checked) + return + } + + answers[id] = host.querySelector('input')?.value ?? '' + }) + + return answers +} + +/** 统计填写项进度:已填数量、未填的必填项、命中的异常项 */ +export function describeFieldProgress( + root: HTMLElement, + answers: SigningAnswers, +): SigningFieldProgress { + const progress: SigningFieldProgress = { + total: 0, + filled: 0, + missingRequired: [], + abnormal: [], + } + + root.querySelectorAll(`[${FIELD_ATTR}]`).forEach((host) => { + const id = host.getAttribute(FIELD_ATTR) + const label = host.getAttribute(FIELD_LABEL_ATTR) ?? id ?? '' + + if (!id) { + return + } + + const value = answers[id] + const empty = value === undefined || value === '' || value === false + const risk = host.getAttribute(FIELD_RISK_ATTR) + const isSignature = host.getAttribute(FIELD_KIND_ATTR) === 'signature' + + progress.total += 1 + + if (!empty) { + progress.filled += 1 + } else if (host.getAttribute(FIELD_REQUIRED_ATTR) === 'true' && !isSignature) { + // 签名必填但尚未采集,正是"点击签署"要完成的事,不能算作待补的必填项, + // 否则签名入口会把自己挡住。 + progress.missingRequired.push(label) + } + + if (risk && value === risk) { + progress.abnormal.push(label) + } + }) + + return progress +} diff --git a/clinical-web/src/utils/signing-fields.ts b/clinical-web/src/utils/signing-fields.ts new file mode 100644 index 0000000..3fde3e0 --- /dev/null +++ b/clinical-web/src/utils/signing-fields.ts @@ -0,0 +1,207 @@ +/** + * 文书填写项(占位符)模型。 + * + * 相比「把占位符当普通文字做字符串替换」,这里给每个占位符补上语义: + * ① 稳定字段键(id):与 HIS / 患者快照的取值一一对应,不再依赖中文标签匹配; + * ② 控件类型(kind):签名是与文本/日期/单选/勾选并列的一种填写项, + * 签完把取值写回同一字段即可在文书原位置回显; + * ③ 必填与选项:模板保存时可做去重校验,签署时可校验必填项是否填完; + * ④ 风险值(risk):签署取值命中即标记为异常项。 + * + * 载体仍沿用既有文书里的 {患者姓名} 写法,原因见 buildFieldToken 的注释: + * 当前编辑器(WangEditor / Slate)无法无损往返带 data-* 属性的结构化节点, + * 而纯文本占位符可以,且存量模板零迁移。渲染管线同时支持 span[data-field] 写法, + * 后续换成支持结构化节点的编辑器时可以平滑升级。 + */ + +import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/workbench/types' + +/** 占位符书写格式:{患者姓名} */ +export const FIELD_TOKEN_OPEN = '{' +export const FIELD_TOKEN_CLOSE = '}' + +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') +} + +/** + * 填写项目录:key 为字段键,label 是文书里显示的文字,kind 决定签署时的控件。 + * id 与 label 的对应关系由这里统一维护,渲染端与编辑器共用同一份,避免两处各自演化。 + */ +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: 'relationship', label: '与患者关系', kind: 'text', required: false, 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 不在目录里时按文本填写项处理 */ +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[] = [], +): SigningFieldDef { + return { + id: `${CUSTOM_FIELD_PREFIX}${label}`, + label, + kind, + required: false, + options, + } +} + +/** + * 按占位符文字定位字段定义:先查目录,再查别名,都不是就当自定义文本项。 + * 这样存量文书里写的是「患者(或家属)签名」也能命中签名控件。 + */ +export function resolveFieldByLabel(label: string): 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) +} + +/** + * 生成占位符文本。 + * + * 用纯文本而不是 :WangEditor 是 Slate 模型,插入的 HTML 会被 + * 重新解析,未注册的自定义元素会退化成纯文本、data-* 属性全部丢失, + * 导致「保存后再打开,占位符已经不是占位符」。纯文本可以无损往返, + * 且不需要为编辑器注册自定义节点,存量模板也不需要迁移。 + */ +export function buildFieldToken(field: SigningFieldDef): string { + return `${FIELD_TOKEN_OPEN}${field.label}${FIELD_TOKEN_CLOSE}` +} + +/** + * 解析文书 HTML 里的全部占位符,按出现顺序去重。 + * 同一个字段在文书里出现多次(如签名与日期在每页都出现)只算一个填写项。 + */ +export function collectFieldDefs(contentHtml: string): SigningFieldDef[] { + if (!contentHtml) { + return [] + } + + const seen = new Set() + const fields: SigningFieldDef[] = [] + const pattern = createFieldTokenPattern() + let match: RegExpExecArray | null + + while ((match = pattern.exec(contentHtml)) !== null) { + const field = resolveFieldByLabel(match[1] ?? '') + if (!field.label || seen.has(field.id)) { + continue + } + + seen.add(field.id) + fields.push(field) + } + + return fields +} + +/** 同一占位符在文书里写了多次的字段(保存模板时提示,通常是误重复) */ +export function findDuplicateFields(contentHtml: string): SigningFieldDef[] { + if (!contentHtml) { + return [] + } + + const counts = new Map() + const pattern = createFieldTokenPattern() + let match: RegExpExecArray | null + + while ((match = pattern.exec(contentHtml)) !== null) { + const field = resolveFieldByLabel(match[1] ?? '') + 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) +} + +/** + * 系统侧取值来源(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 ?? '', + // 医师签名与代理人关系必须由现场填写,不能由系统代填; + // 上一版把「与患者关系」恒填「本人」,等于把代理人签署这条路堵死了。 + relationship: '', + doctor: '', + } +} diff --git a/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue b/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue index a15e19f..fc02183 100644 --- a/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue +++ b/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue @@ -1,7 +1,12 @@ @@ -179,14 +199,22 @@ function handleSigningSelectionChange(event: Event) {

- 已勾选 {{ signingSelection.length }} 项{{ - signingSelection.length ? `:${signingSelection.join('、')}` : '' - }} + 填写项 {{ signingProgress.filled }} / + {{ signingProgress.total }} 项已填