diff --git a/clinical-web/README.md b/clinical-web/README.md index 94d0f6c..97f89ee 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -129,11 +129,14 @@ ClinicalLayout 科室在文档库的 Word 编辑器中维护文书,签署时需要交互化的内容按下面的写法即可,系统在签署和「签署预览」时自动转换: -- 占位符:`{患者姓名}`、`{性别}`、`{年龄}`、`{就诊号}`、`{签署日期}`、`{患者签名}`、`{医师签名}` 等,由签署任务的患者与就诊快照填充; +- 占位符分两类,写法都是 `{…}`: + - **系统预填项**:`{患者姓名}`、`{性别}`、`{年龄}`、`{就诊号}`、`{签署日期}`、`{患者签名}`、`{医师签名}` 等,字段键与取值来源固定,由签署任务的患者与就诊快照填充,目录见 `utils/signing-fields.ts` 的 `SIGNING_FIELD_PRESETS`; + - **通用文本项**:现场手填的输入框,默认写 `{文本框}`,签署时渲染成一个空文本框。**每个占位符各自是一个填写项**,字段键按出现位置编号(`custom:名称`、第 2 个起是 `custom:名称#2`……),所以同一份文书里放几个文本框就是几份取值,不需要为了区分而改名字;名称只决定文书里显示的文字,直接把正文里的「文本框」改成「与患者关系」「受托人姓名」即可(仅为可读性,不影响取值归属)。 + - 只有上面那类系统预填项按字段键去重、同名共用一份取值——页眉的患者姓名、每页的签名位反复出现,取值本来就该是同一个。 - 勾选表:做成表格,第一行表头写「勾选」,另设一列写「项目」作为勾选条目名,签署时「勾选」列每个单元格变成复选框; - 行内选项:正文里直接写方框符号,如 `□否 □是`、`□同意 □不同意`。同一段落或同一单元格内出现两个及以上方框时按二选一(互斥单选)处理,只有一个方框时保持独立复选框语义。 -编辑器弹窗顶部提供「插入勾选框」「二选一」按钮,光标停在表格单元格内时插入内容会落进该单元格,不需要科室自己找符号面板。导入(.docx / 粘贴)阶段只做格式转换,不做勾选识别;识别统一在签署渲染时进行。 +编辑器弹窗顶部提供「插入勾选框」「二选一」按钮,以及插入通用文本项的「文本框」按钮(虚线圈出的那一个,点一下插入 `{文本框}`,把「文本框」三个字改成实际名称即可)。光标停在表格单元格内时插入内容会落进该单元格,不需要科室自己找符号面板。导入(.docx / 粘贴)阶段只做格式转换,不做勾选识别;识别统一在签署渲染时进行。 识别规则集中在 `utils/signing-document.ts`,调整后签署端与「签署预览」会同步生效。 diff --git a/clinical-web/src/utils/signing-document.ts b/clinical-web/src/utils/signing-document.ts index 8c75674..08139ae 100644 --- a/clinical-web/src/utils/signing-document.ts +++ b/clinical-web/src/utils/signing-document.ts @@ -18,11 +18,10 @@ import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/workbench/types' import { - createFieldTokenPattern, FIELD_TOKEN_OPEN, PRIMARY_SIGNATURE_FIELD_ID, resolveAnswersFromSystem, - resolveFieldByLabel, + scanFieldTokens, SIGNING_DOCUMENT_HEADER, SIGNING_OPTION_MARKERS, type SigningSystemSnapshot, @@ -260,6 +259,11 @@ function buildFieldControl( * 占位符渲染:把 {患者姓名} 就地替换成填写项控件,保留同一文本节点内的前后文字。 * 模板未收录的占位符按自定义文本项处理,仍可在文书上填写, * 避免出现"写了占位符但签署时既填不上也看不见"的情况。 + * + * 解析统一走 scanFieldTokens,且计数器跨文本节点共用一个: + * 自定义文本项靠"同名占位符第几次出现"编号,这里数出的序号必须和 + * 填写项统计(collectFieldDefs)数出来的完全一致,否则同一处内容 + * 在面板里是一个字段键、落到 DOM 上是另一个,取值就悄悄对不上了。 */ function renderTokenFields( root: Element, @@ -270,6 +274,7 @@ function renderTokenFields( const doc = root.ownerDocument const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT) const targets: Text[] = [] + const counters = new Map() let node = walker.nextNode() while (node) { @@ -282,28 +287,18 @@ function renderTokenFields( targets.forEach((textNode) => { const text = textNode.textContent ?? '' - const pattern = createFieldTokenPattern() const fragment = doc.createDocumentFragment() let cursor = 0 - let match: RegExpExecArray | null - while ((match = pattern.exec(text)) !== null) { - if (match.index > cursor) { - fragment.appendChild(doc.createTextNode(text.slice(cursor, match.index))) + scanFieldTokens(text, counters).forEach(({ start, end, field }) => { + if (start > cursor) { + fragment.appendChild(doc.createTextNode(text.slice(cursor, start))) } - fragment.appendChild( - buildFieldWidget( - doc, - resolveFieldByLabel(match[1] ?? ''), - answers, - interactive, - signatureActionable, - ), - ) + fragment.appendChild(buildFieldWidget(doc, field, answers, interactive, signatureActionable)) - cursor = pattern.lastIndex - } + cursor = end + }) if (cursor < text.length) { fragment.appendChild(doc.createTextNode(text.slice(cursor))) diff --git a/clinical-web/src/utils/signing-editor-marks.ts b/clinical-web/src/utils/signing-editor-marks.ts index 6ea41bf..2923426 100644 --- a/clinical-web/src/utils/signing-editor-marks.ts +++ b/clinical-web/src/utils/signing-editor-marks.ts @@ -30,6 +30,7 @@ import type { IEditorConfig } from '@wangeditor/editor' import { createFieldTokenPattern, + isCustomFieldId, resolveFieldByLabel, SIGNING_FIELD_KIND_LABELS, SIGNING_OPTION_MARKERS, @@ -57,6 +58,8 @@ interface SigningLeafMark { /** 控件类型的中文名,渲染在胶囊右侧 */ signTag?: string signRequired?: boolean + /** 是否目录外的自定义文本项(现场手填),决定悬浮提示里说"自动填充"还是"现场填写" */ + signCustom?: boolean /** 原文,如 {患者姓名},用于悬浮提示 */ signRaw?: string } @@ -97,6 +100,7 @@ export function decorateSigningMarkers([node, path]: DecoratedEntry): DecoratedR signKind: field.kind, signTag: SIGNING_FIELD_KIND_LABELS[field.kind] ?? '文本', signRequired: field.required, + signCustom: isCustomFieldId(field.id), signRaw: text.slice(start, end), } @@ -177,8 +181,11 @@ function renderSigningMarker(node: StyledNode, vnode: StyledVNode): StyledVNode if (mark.signPart === 'label') { const kind = mark.signTag ?? '文本' + // 提示必须分开说:目录里的字段签署时自动填充,自定义文本项是留给现场手填的空框。 + // 一律写成"自动填充",科室会以为写了占位符就有人替他填。 + const source = mark.signCustom ? '签署时由患者/家属现场填写' : '签署时按患者与就诊信息自动填充' props.className = 'ms-sign-chip' - props.title = `待填写项:${mark.signRaw ?? ''}(${kind}${mark.signRequired ? ',必填' : ''}),签署时按患者与就诊信息自动填充` + props.title = `待填写项:${mark.signRaw ?? ''}(${kind}${mark.signRequired ? ',必填' : ''}),${source}` if (mark.signTag) { dataset.signTag = mark.signTag diff --git a/clinical-web/src/utils/signing-fields.ts b/clinical-web/src/utils/signing-fields.ts index 89dcd78..41a0fd6 100644 --- a/clinical-web/src/utils/signing-fields.ts +++ b/clinical-web/src/utils/signing-fields.ts @@ -2,7 +2,8 @@ * 文书填写项(占位符)模型。 * * 相比「把占位符当普通文字做字符串替换」,这里给每个占位符补上语义: - * ① 稳定字段键(id):与 HIS / 患者快照的取值一一对应,不再依赖中文标签匹配; + * ① 稳定字段键(id):目录里的字段与 HIS / 患者快照的取值一一对应,不依赖中文标签匹配; + * 目录外的自定义文本项按出现位置编号(custom:名称#第几次),两处文本框就是两份取值; * ② 控件类型(kind):签名是与文本/日期/单选/勾选并列的一种填写项, * 签完把取值写回同一字段即可在文书原位置回显; * ③ 必填与选项:模板保存时可做去重校验,签署时可校验必填项是否填完; @@ -64,6 +65,12 @@ 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: [] }, @@ -73,7 +80,6 @@ export const SIGNING_FIELD_PRESETS: SigningFieldDef[] = [ { 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: [] }, ] @@ -136,7 +142,10 @@ const FIELD_LABEL_ALIASES: Record = { 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 不在目录里时按文本填写项处理 */ +/** + * 自定义占位符的字段键前缀:label 不在目录里时按文本填写项处理。 + * 键 = `custom:名称`,同名占位符第 2 个起再接 `#出现次序`——见 resolveFieldByLabel。 + */ const CUSTOM_FIELD_PREFIX = 'custom:' export function getPresetField(id: string): SigningFieldDef | null { @@ -148,9 +157,11 @@ export function createCustomField( label: string, kind: SigningFieldKind = 'text', options: string[] = [], + /** 同名占位符在文书里的出现次序,从 1 开始;见 resolveFieldByLabel */ + sequence = 1, ): SigningFieldDef { return { - id: `${CUSTOM_FIELD_PREFIX}${label}`, + id: `${CUSTOM_FIELD_PREFIX}${label}${sequence > 1 ? `#${sequence}` : ''}`, label, kind, required: false, @@ -158,11 +169,54 @@ export function createCustomField( } } +/** 判断字段键是否来自目录之外的自定义文本项(现场手填的输入框) */ +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): SigningFieldDef { +export function resolveFieldByLabel(label: string, sequence = 1): SigningFieldDef { const trimmed = label.trim() const preset = PRESET_BY_LABEL.get(trimmed) if (preset) { @@ -177,7 +231,50 @@ export function resolveFieldByLabel(label: string): SigningFieldDef { } } - return createCustomField(trimmed) + return createCustomField(trimmed, 'text', [], sequence) +} + +/** 扫描结果:占位符在文本里的位置 + 它对应的字段定义 */ +export interface ScannedFieldToken { + start: number + end: number + field: SigningFieldDef +} + +/** + * 扫描一段文本里的占位符并解析成字段定义。 + * + * counters 必须**跨段传递**(同一份文书的每个文本节点/整段 HTML 共用一个): + * 自定义文本项靠"同名占位符第几次出现"编号,分段扫描时如果各自从 1 数起, + * 编辑器面板统计出的字段键就会和签署渲染时写进 DOM 的键对不上, + * 表现是填写项统计里有这一项、签署时却收集不到值。 + * + * 之所以要有个统一的扫描函数,就是因为这条编号规则被三处用到 + * (填写项统计 collectFieldDefs、重复检查 findDuplicateFields、签署渲染 renderTokenFields), + * 三处各写一份计时器迟早会漂移。 + */ +export function scanFieldTokens(text: string, counters: Map): 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) + tokens.push({ + start: match.index, + end: pattern.lastIndex, + field: resolveFieldByLabel(label, sequence), + }) + } + + return tokens } /** @@ -194,7 +291,10 @@ export function buildFieldToken(field: SigningFieldDef): string { /** * 解析文书 HTML 里的全部占位符,按出现顺序去重。 - * 同一个字段在文书里出现多次(如签名与日期在每页都出现)只算一个填写项。 + * + * 「去重」只对目录里的字段生效:同一个字段在文书里出现多次(如签名与日期在每页都出现) + * 只算一个填写项,它们共用一份取值。自定义文本项按出现位置各自成项(见 resolveFieldByLabel), + * 所以两份 {文本框} 会得到两个填写项,不会被合成一个。 */ export function collectFieldDefs(contentHtml: string): SigningFieldDef[] { if (!contentHtml) { @@ -203,37 +303,37 @@ export function collectFieldDefs(contentHtml: string): SigningFieldDef[] { 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] ?? '') + scanFieldTokens(contentHtml, new Map()).forEach(({ field }) => { if (!field.label || seen.has(field.id)) { - continue + return } seen.add(field.id) fields.push(field) - } + }) return fields } -/** 同一占位符在文书里写了多次的字段(保存模板时提示,通常是误重复) */ +/** + * 同一占位符在文书里写了多次的字段(保存模板时提示,通常是误重复)。 + * + * 这里**只会命中目录里的字段**:自定义文本项按出现位置编号,每次出现的字段键都不同, + * 计数永远到不了 2。这是对的——两处 {文本框} 是两处要填的内容, + * 不是"同一个字段写重复了",不该提示科室去合并。 + */ 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] ?? '') + scanFieldTokens(contentHtml, new Map()).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) } @@ -267,9 +367,10 @@ export function resolveAnswersFromSystem(snapshot: SigningSystemSnapshot): Signi // 真正确认的日期以签署完成时固化的取值(signedAt)为准 signedDate: (snapshot.signedAt ?? new Date().toISOString()).slice(0, 10), signature: snapshot.signatureDataUrl ?? '', - // 医师签名与代理人关系必须由现场填写,不能由系统代填; - // 上一版把「与患者关系」恒填「本人」,等于把代理人签署这条路堵死了。 - relationship: '', + // 医师签名必须由现场采集,不能由系统代填。 + // 同理,与患者关系、受托人姓名这类现场手填的文本框是自定义文本项 + // (见 createCustomField),没有系统初值——把「与患者关系」恒填「本人」 + // 等于把代理人签署这条路堵死了,所以这一项连空值都不在这里预置。 doctor: '', } } diff --git a/clinical-web/src/views/management/documents/components/WordEditorDialog.vue b/clinical-web/src/views/management/documents/components/WordEditorDialog.vue index dfe68cb..1a20fa5 100644 --- a/clinical-web/src/views/management/documents/components/WordEditorDialog.vue +++ b/clinical-web/src/views/management/documents/components/WordEditorDialog.vue @@ -5,8 +5,10 @@ import { ElMessage } from 'element-plus' import type { CampusRecord, DepartmentRecord } from '@/api/management/types' import { buildFieldToken, + buildGenericTextToken, collectFieldDefs, findDuplicateFields, + GENERIC_TEXT_FIELD_LABEL, SIGNING_FIELD_KIND_LABELS, SIGNING_FIELD_PRESETS, } from '@/utils/signing-fields' @@ -43,9 +45,12 @@ const importing = ref(false) const importMessage = ref('') const paneRef = ref(null) -// 占位符在签署时按字段键取 HIS 与患者就诊信息自动填充,其中签名是独立控件类型: -// 签字后签名图作为该字段取值落回文书原本的签名位,不需要再替换文字。 -// 目录见 utils/signing-fields 的 SIGNING_FIELD_PRESETS,与签署端渲染共用同一份。 +// 占位符分两类: +// ① 目录里的字段(utils/signing-fields 的 SIGNING_FIELD_PRESETS):取值有固定来源, +// 签署时按字段键取 HIS / 患者就诊快照自动填充,其中签名是独立控件类型—— +// 签字后签名图作为该字段取值落回文书原本的签名位,不需要再替换文字; +// ② 通用文本项(现场手填的输入框,如与患者关系、受托人姓名):没有系统取值来源, +// 默认写 {文本框},签署时渲染成一个空文本框,名称在正文里改即可,见 insertGenericTextField。 const form = reactive({ templateCode: '', @@ -113,6 +118,17 @@ function insertField(field: (typeof SIGNING_FIELD_PRESETS)[number]) { insertContent(buildFieldToken(field)) } +/** + * 插入通用文本项:现场手填的输入框,默认 {文本框}。 + * + * 每插一个就是一个独立填写项(字段键按出现位置编号,见 signing-fields 的 resolveFieldByLabel), + * 所以两处文本框天然是两份取值,不需要为了区分而改名字。 + * 名称只决定文书里显示的文字:改成「与患者关系」是为了读起来像文书,不影响取值归属。 + */ +function insertGenericTextField() { + insertContent(buildGenericTextToken()) +} + /** 已插入的填写项:让科室在保存前确认签名位、日期位有没有漏 */ const detectedFields = computed(() => collectFieldDefs(valueHtml.value)) @@ -284,6 +300,14 @@ function save() { > {{ field.label }}{{ field.required ? ' *' : '' }} +
@@ -344,7 +368,8 @@ function save() { {{ importMessage }} 建议从 Word/WPS - 中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载。 + 中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载; + 需要患者现场填写的内容(与患者关系、受托人姓名等)点「文本框」插入输入框占位符,插几个就是几处独立填写、各填各的;把名称改成实际叫法只是为了读起来像文书。 占位符与勾选框在编辑区会显示成带类型标签的胶囊,便于和正文区分。
@@ -514,6 +539,12 @@ function save() { background: #fff4e2; } +/* 通用文本项:虚线圈住,和左边一排系统预填字段区分开—— + 它不是某个固定字段,插到哪就是一个现场手填的输入框,名称可以在正文里随便改 */ +.placeholder-chip.is-generic { + border: 1px dashed var(--brand); +} + .placeholder-check { padding: 10px 18px 0; }