From 9a6ac93e21b7c95c838554066bcab0c4a002d3fa Mon Sep 17 00:00:00 2001 From: yelan Date: Thu, 24 Sep 2026 12:13:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(signing):=20=E5=B0=86=E7=AD=BE=E7=BD=B2?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E7=AC=A6=E6=94=B9=E4=B8=BA=E5=8E=9F=E5=AD=90?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=B9=B6=E6=94=AF=E6=8C=81=E5=A4=9A=E9=80=89?= =?UTF-8?q?=E9=80=89=E9=A1=B9=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clinical-web/.gitignore | 4 + clinical-web/.probe-field-node/index.html | 61 +++ clinical-web/.probe-field-node/main.ts | 443 ++++++++++++++++ .../.probe-field-node/vite.probe.config.ts | 26 + clinical-web/package.json | 1 + clinical-web/src/api/management/documents.ts | 3 + clinical-web/src/api/workbench/types.ts | 20 +- clinical-web/src/utils/signing-document.ts | 271 ++++++++-- clinical-web/src/utils/signing-field-node.ts | 348 ++++++++++++ clinical-web/src/utils/signing-fields.ts | 497 +++++++++++++++++- .../documents/components/WordEditorDialog.vue | 422 ++++++++++++++- .../components/editors/EditorWangPane.vue | 144 ++++- .../components/SigningDocumentInteractive.vue | 22 +- 13 files changed, 2141 insertions(+), 121 deletions(-) create mode 100644 clinical-web/.probe-field-node/index.html create mode 100644 clinical-web/.probe-field-node/main.ts create mode 100644 clinical-web/.probe-field-node/vite.probe.config.ts create mode 100644 clinical-web/src/utils/signing-field-node.ts diff --git a/clinical-web/.gitignore b/clinical-web/.gitignore index d6f55d5..de7a357 100644 --- a/clinical-web/.gitignore +++ b/clinical-web/.gitignore @@ -16,6 +16,10 @@ dist-ssr scripts/.session.json scripts/.captcha.json scripts/.captcha.png + +# 浏览器行为探针(验证编辑器原子占位符用),构建产物与本地产物都不进仓库 +.probe-field-node/dist/ +.probe-field-node/node_modules/ # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/clinical-web/.probe-field-node/index.html b/clinical-web/.probe-field-node/index.html new file mode 100644 index 0000000..171f808 --- /dev/null +++ b/clinical-web/.probe-field-node/index.html @@ -0,0 +1,61 @@ + + + + + sign-field 原子占位符探针 + + + +
+
PENDING
+ + + diff --git a/clinical-web/.probe-field-node/main.ts b/clinical-web/.probe-field-node/main.ts new file mode 100644 index 0000000..554ac41 --- /dev/null +++ b/clinical-web/.probe-field-node/main.ts @@ -0,0 +1,443 @@ +/** + * 「签署占位符 = 原子节点」的浏览器行为探针。 + * + * 为什么需要它:类型检查、构建、纯逻辑断言都只能证明"代码写得对", + * 证明不了 wangEditor/Slate 在真实浏览器里**真的**把占位符当成了原子节点。 + * 用户的核心诉求是「一次退格就能整块删掉」,这件事只有真跑一遍才算验证过。 + * + * 探针只 import 生产源码(utils/signing-field-node),不复制任何逻辑。 + * 通过 window.__probe 暴露一组原语,由 CDP 脚本编排场景。 + */ +import '@wangeditor/editor/dist/css/style.css' + +import { createEditor, DomEditor, SlateEditor, SlateTransforms } from '@wangeditor/editor' +import type { IDomEditor } from '@wangeditor/editor' + +import type { SigningFieldDef } from '../src/api/workbench/types' +import { + createSignFieldNode, + isSignFieldElement, + readFieldFromSignFieldNode, + registerSigningFieldNode, + removeSignFieldNodes, + SIGN_FIELD_CHIP_CLASS, + SIGN_FIELD_NODE_CLASS, + SIGN_FIELD_TYPE, + updateSignFieldNodes, +} from '../src/utils/signing-field-node' + +// 与 EditorWangPane.vue 一样,在创建编辑器之前注册 +registerSigningFieldNode() + +const FIELD: SigningFieldDef = { + id: 'patient', + label: '患者姓名', + kind: 'text', + required: true, + options: [], +} + +const INITIAL_HTML = '

甲乙丙

' + +function createProbeEditor(html: string): IDomEditor { + const container = document.getElementById('editor') + + // 复用同一个容器重建,先把上一次留下的 DOM 清掉 + if (container) { + container.innerHTML = '' + } + + return createEditor({ + selector: '#editor', + html, + mode: 'default', + config: { placeholder: '正文' }, + }) +} + +let editor: IDomEditor = createProbeEditor(INITIAL_HTML) + +function plain(value: T): T { + return JSON.parse(JSON.stringify(value ?? null)) as T +} + +/** + * 等 DOM 追上模型。 + * + * wangEditor 的 setHtml / insertNode / select 只改 Slate 模型,DOM 由 snabbdom 在下一帧 patch。 + * 采样太快会读到上一版 DOM(表现是"模型里有元素、DOM 里没有"), + * 会把正常的渲染误判成"胶囊没渲染出来"。 + */ +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 80)) +} + +/** 深度优先找第一个占位符节点的路径(正文里可能嵌在段落/列表里) */ +function findFieldPath(): number[] | null { + let hit: number[] | null = null + + const visit = (nodes: unknown[], prefix: number[]) => { + nodes.forEach((node, index) => { + if (!node || typeof node !== 'object') { + return + } + + if (isSignFieldElement(node)) { + if (!hit) { + hit = [...prefix, index] + } + return + } + + const children = (node as { children?: unknown }).children + + if (Array.isArray(children)) { + visit(children, [...prefix, index]) + } + }) + } + + visit(editor.children as unknown[], []) + + return hit +} + +function countFields(): number { + let count = 0 + + const visit = (nodes: unknown[]) => { + nodes.forEach((node) => { + if (!node || typeof node !== 'object') { + return + } + + if (isSignFieldElement(node)) { + count += 1 + return + } + + const children = (node as { children?: unknown }).children + + if (Array.isArray(children)) { + visit(children) + } + }) + } + + visit(editor.children as unknown[]) + + return count +} + +/** 把 Slate 模型压成一眼能看懂的形状,方便在 CDP 输出里核对 */ +function outline(): unknown[] { + const walk = (nodes: unknown[]): unknown[] => + nodes.map((node) => { + const record = node as { type?: string; text?: string; children?: unknown[] } + + if (typeof record.text === 'string') { + return { text: record.text } + } + + if (isSignFieldElement(node)) { + return { element: SIGN_FIELD_TYPE, fieldId: record.fieldId, label: record.fieldLabel } + } + + return { type: record.type, children: walk(record.children ?? []) } + }) + + return walk(editor.children as unknown[]) +} + +/** 胶囊在 DOM 里的样子:标签必须靠 CSS 展示,DOM 里不能有可落光标的文字 */ +function chipInfo() { + const chip = document.querySelector(`.${SIGN_FIELD_NODE_CLASS}`) + + if (!chip) { + return null + } + + return { + tagName: chip.tagName, + className: chip.className, + hasChipClass: chip.classList.contains(SIGN_FIELD_CHIP_CLASS), + dataSignLabel: chip.getAttribute('data-sign-label'), + dataSignTag: chip.getAttribute('data-sign-tag'), + contentEditableAttr: chip.getAttribute('contenteditable'), + // 文本节点数量是这条诉求的关键:有文字节点,原生光标就可能落进去 + textNodeCount: Array.from(chip.childNodes).filter((n) => n.nodeType === Node.TEXT_NODE).length, + textContent: chip.textContent, + childElementCount: chip.childElementCount, + beforeContent: getComputedStyle(chip, '::before').content, + afterContent: getComputedStyle(chip, '::after').content, + backgroundColor: getComputedStyle(chip).backgroundColor, + rect: plain(chip.getBoundingClientRect()), + } +} + +const probe = { + /** + * 只灌正文,不碰光标。 + * 用来区分"setHtml 本身有没有问题"与"setHtml 之后立刻 focus 有没有问题"。 + */ + async setHtmlOnly(html: string) { + editor.setHtml(html) + + return probe.snapshot() + }, + + /** 变体:先清选区再灌正文(用来证明"清选区"并不能规避 setHtml 的竞态) */ + async deselectThenSetHtml(html: string) { + SlateTransforms.deselect(editor) + editor.setHtml(html) + + return probe.snapshot() + }, + + /** + * 复位成指定正文:**销毁重建编辑器**。 + * + * ⚠️ 不能用 setHtml 复位。实测(见 [0] 节的对照实验)wangEditor 的 setHtml 在 + * "编辑器产生过选区"之后是不可靠的: + * ① 异步抛 `Cannot resolve a DOM node from Slate node`; + * ② 有时直接同步抛 `Cannot find a descendant at path`。 + * 读源码可知原因:with-content.ts 的 setHtml 内部会 `e.focus()`, + * 而 with-dom.ts 的 `e.focus()` 会把**缓存的上次选区**(EDITOR_TO_SELECTION)装回去, + * 于是 `e.clear()` 之后、`Transforms.insertFragment` 之前,选区已经指向不存在的路径。 + * 事先 `editor.selection = null` 或 `Transforms.deselect` 都没用——focus() 会把它装回来。 + * + * 生产里只有「导入 docx」会走到这条路径,那是一个既有的独立问题(已在 WordEditorDialog + * 对应位置留了说明)。探针需要频繁复位正文,所以改成重建实例——这也更贴近生产的真实形态 + * (WordEditorDialog 整块内容在 v-if="visible" 里,弹窗每次打开都是新编辑器)。 + */ + async reset(html: string) { + editor.destroy() + editor = createProbeEditor(html) + await settle() + + return probe.snapshot() + }, + + async snapshot() { + await settle() + + return { + html: editor.getHtml(), + text: editor.getText(), + selection: plain(editor.selection), + fieldPath: findFieldPath(), + fieldCount: countFields(), + outline: outline(), + chip: chipInfo(), + // 排查用:渲染失败时只有编辑器自己的 DOM 能说明它到底渲染成了什么 + editorDom: document.getElementById('editor')?.innerHTML ?? null, + result: document.getElementById('result')?.textContent, + } + }, + + /** 与 EditorWangPane.insertField 同一套顺序:focus → 若已选中占位符则右移 → insertNode */ + async insertField(patch?: Partial) { + const def: SigningFieldDef = { ...FIELD, ...(patch ?? {}) } + + editor.focus(true) + + if (DomEditor.getSelectedNodeByType(editor, SIGN_FIELD_TYPE)) { + editor.move(1) + } + + editor.insertNode(createSignFieldNode(def) as never) + + return probe.snapshot() + }, + + /** 光标放到占位符之后(退格即应删掉整个占位符) */ + async caretAfterField() { + const path = findFieldPath() + + if (!path) { + return { ok: false, reason: 'NO_FIELD' } + } + + const next = SlateEditor.next(editor, { at: path }) + + if (next) { + editor.select({ path: next[1], offset: 0 }) + await settle() + return { ok: true, how: 'next', selection: plain(editor.selection) } + } + + // 占位符是最后一个子节点:光标落到父节点末尾 + const parentPath = path.slice(0, -1) + const parent = SlateEditor.node(editor, parentPath)[0] as { children: unknown[] } + editor.select({ path: parentPath, offset: parent.children.length }) + await settle() + + return { ok: true, how: 'parentEnd', selection: plain(editor.selection) } + }, + + /** 光标放到占位符之前(用于对照:退格应删掉前一个字符,不动占位符) */ + async caretBeforeField() { + const path = findFieldPath() + + if (!path) { + return { ok: false, reason: 'NO_FIELD' } + } + + const prev = SlateEditor.previous(editor, { at: path }) + + if (prev) { + const text = (prev[0] as { text?: string }).text ?? '' + editor.select({ path: prev[1], offset: text.length }) + await settle() + return { ok: true, how: 'previous', selection: plain(editor.selection) } + } + + editor.select({ path: path.slice(0, -1), offset: 0 }) + await settle() + + return { ok: true, how: 'parentStart', selection: plain(editor.selection) } + }, + + /** + * 光标放到文末(纯文本对照用)。 + * 直接算文末的点再 select,不依赖 focus(true) 在"已有选区"时的行为—— + * 上一节点过胶囊之后选区还停在那个 void 节点上,focus 不一定会把它挪走。 + */ + async caretToEnd() { + editor.select(SlateEditor.end(editor, [])) + await settle() + + return { ok: true, selection: plain(editor.selection) } + }, + + /** 当前 DOM 里是否已渲染出胶囊 */ + chipExists() { + return Boolean(document.querySelector(`.${SIGN_FIELD_NODE_CLASS}`)) + }, + + /** + * 复刻 EditorWangPane.handleFieldClick 的定位路径: + * 点胶囊 → closest('.ms-sign-chip') → DomEditor.toSlateNode → 判类型 → 取路径。 + * 这一步的风险是 toSlateNode 能不能从 ::before 展示的胶囊解回 Slate 节点。 + */ + clickChip() { + const chip = document.querySelector(`.${SIGN_FIELD_CHIP_CLASS}`) + + if (!chip) { + return { ok: false, reason: 'NO_CHIP' } + } + + chip.click() + + return probe.resolveChip() + }, + + /** 不走点击,直接按 handleFieldClick 的逻辑解一遍(排除 click 事件本身的干扰) */ + async resolveChip() { + await settle() + + const chip = document.querySelector(`.${SIGN_FIELD_CHIP_CLASS}`) + + if (!chip) { + return { ok: false, reason: 'NO_CHIP' } + } + + try { + const node = DomEditor.toSlateNode(editor, chip) as unknown + + if (!isSignFieldElement(node)) { + return { ok: false, reason: 'NOT_SIGN_FIELD', node: plain(node) } + } + + const path = DomEditor.findPath(editor, node as never) + + return { + ok: true, + field: readFieldFromSignFieldNode(node), + path: plain(path), + isSelectedBefore: DomEditor.isNodeSelected(editor, node as never), + } + } catch (error) { + return { ok: false, reason: 'THREW', message: String(error) } + } + }, + + /** 选中占位符节点,供"选中态样式"断言使用 */ + async selectField() { + const path = findFieldPath() + + if (!path) { + return { ok: false, reason: 'NO_FIELD' } + } + + editor.focus() + editor.select(path) + await settle() + + return { ok: true, selection: plain(editor.selection), chip: chipInfo() } + }, + + /** 未选中时的胶囊样式,作为选中态的对照基线 */ + async unselectedChip() { + await settle() + + return chipInfo() + }, + + /** 当前编辑区是否持有焦点(CDP 发真实按键前必须为 true) */ + focusState() { + const editable = document.querySelector('[contenteditable="true"]') + + return { + activeElementIsEditable: document.activeElement === editable, + editableFound: Boolean(editable), + } + }, + + /** 供 CDP 读回的原始编辑器引用(排查用)。getter:reset 会重建实例 */ + get editor() { + return editor + }, + DomEditor, + SlateEditor, + /** 面板改字段:就地改模型(EditorWangPane.updateField 的底层) */ + async updateField(fieldId: string, patch: Record) { + const count = updateSignFieldNodes(editor, fieldId, patch as never) + + return { count, snapshot: await probe.snapshot() } + }, + + /** 面板删除字段:就地改模型(EditorWangPane.removeField 的底层) */ + async removeField(fieldId: string) { + const count = removeSignFieldNodes(editor, fieldId) + + return { count, snapshot: await probe.snapshot() } + }, + + /** + * 人为把选区弄脏(指向文档里不存在的路径)后强制重渲染。 + * + * 复现的是这条链路:整篇替换正文时 setHtml 不清旧选区 → 选区越界 → + * renderElem 里 DomEditor.isNodeSelected 拿脏选区跑 nodes() 抛 + * `Cannot find a descendant at path` → 整个占位符渲染失败。 + * 渲染函数不该因为选区脏了就崩,所以 isSelected 包了 try/catch。 + */ + async renderWithDirtySelection() { + editor.selection = { + anchor: { path: [0, 99, 0], offset: 0 }, + focus: { path: [0, 99, 0], offset: 0 }, + } as never + + // 改一个属性强制重渲染(渲染钩子会去读 editor.selection) + updateSignFieldNodes(editor, 'patient', { required: false }) + await settle() + + return { chip: chipInfo(), selection: plain(editor.selection), fieldCount: countFields() } + }, +} + +Object.assign(window, { __probe: probe }) + +document.getElementById('result')!.textContent = 'READY' + +export {} diff --git a/clinical-web/.probe-field-node/vite.probe.config.ts b/clinical-web/.probe-field-node/vite.probe.config.ts new file mode 100644 index 0000000..453a03b --- /dev/null +++ b/clinical-web/.probe-field-node/vite.probe.config.ts @@ -0,0 +1,26 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' + +/** + * 只给 .probe-field-node 用的最小构建配置。 + * 不引 vue 插件(探针是纯 TS),但要复用应用的 @ 别名, + * 否则 signing-field-node.ts 里的 `@/api/workbench/types` 解析不了。 + */ +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + base: './', + build: { + outDir: 'dist', + // 不自动清空:清空会触发沙箱对删除操作的拦截,而且产物本来就带 hash、覆盖即可 + emptyOutDir: false, + rollupOptions: { + input: fileURLToPath(new URL('./index.html', import.meta.url)), + }, + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('../src', import.meta.url)), + }, + }, +}) diff --git a/clinical-web/package.json b/clinical-web/package.json index 8d3adcd..133b195 100644 --- a/clinical-web/package.json +++ b/clinical-web/package.json @@ -21,6 +21,7 @@ "jspdf": "^4.2.1", "mammoth": "^1.12.2", "pinia": "^4.0.3", + "snabbdom": "^3.6.4", "vue": "^3.5.41", "vue-router": "^5.2.0" }, diff --git a/clinical-web/src/api/management/documents.ts b/clinical-web/src/api/management/documents.ts index 44d2514..0cd099b 100644 --- a/clinical-web/src/api/management/documents.ts +++ b/clinical-web/src/api/management/documents.ts @@ -140,6 +140,9 @@ export function buildSignatureFieldsPayload(contentHtml: string): unknown[] { kind: field.kind, required: field.required, options: field.options, + // 单/多选必须一起上报:后端靠它渲染签署控件与做取值校验, + // 缺了这个字段就只能按 kind 猜,多选组会被当成单个勾选框 + ...(field.multi === undefined ? {} : { multi: field.multi }), ...(field.risk ? { risk: field.risk } : {}), })) } diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index c260c01..3bab7a6 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -189,11 +189,29 @@ export interface SigningFieldDef { required: boolean /** choice / checkbox 的可选项 */ options: string[] + /** + * 有选项时是否允许多选(渲染成一组 checkbox 而不是互斥单选)。 + * + * 必须显式声明,不能靠"这一块里有几个方框"去猜: + * 「□高血压 □糖尿病 □冠心病」是三项可同时成立的多选, + * 猜成互斥单选会让科室只能勾一个,且当场看不出错。 + * + * 缺省口径:choice 默认单选;checkbox 带选项时默认多选(勾选本来就是"可勾多个"), + * 不带选项时是单个布尔勾选(options 为空,multi 无意义)。 + */ + multi?: boolean /** 取值等于该字符串时视为异常项(如风险筛查选了"是") */ risk?: string } -export type SigningFieldValue = string | boolean +/** + * 填写项取值。 + * - text / date:字符串 + * - choice:字符串(选中的那一项) + * - checkbox:布尔(单个勾选);带选项且多选时是字符串数组 + * - signature:签名图的 dataURL + */ +export type SigningFieldValue = string | boolean | string[] /** 填写项取值,key 为 SigningFieldDef.id。 */ export type SigningAnswers = Record diff --git a/clinical-web/src/utils/signing-document.ts b/clinical-web/src/utils/signing-document.ts index 08139ae..a86385f 100644 --- a/clinical-web/src/utils/signing-document.ts +++ b/clinical-web/src/utils/signing-document.ts @@ -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, ) { 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( } /** - * 结构化占位符渲染:兼容 写法。 - * 该写法是目标格式(字段语义显式、不依赖中文标签), - * 供后端模板或后续换编辑器后生产的内容使用。 + * 结构化占位符渲染:把 `` 就地换成填写项控件。 + * + * 属性解析交给 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( * 行内选项识别(存量兼容):正文里手打的方框(□否 □是、□同意 □不同意)。 * 同一段/同一单元格里出现两个及以上方框时按「二选一」处理成互斥单选; * 只有一个方框则保持多选语义,避免把「□高血压」这类单项确认误判成互斥组。 + * + * 占位符写法不走这里:`` 的单/多选是 + * 模板里显式声明的(见 isMultiOptionGroup),不需要靠数方框个数去猜。 */ function convertInlineOptions(root: Element, interactive: boolean, selected: Set) { const groups = new Map() @@ -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('input[type="checkbox"]:checked'), + ).map((input) => input.value) + return + } + if (kind === 'choice') { const checked = host.querySelector('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('input[type="checkbox"]').forEach((box) => { + box.checked = picked.has(box.value) + }) + return + } + + if (typeof value === 'boolean') { + const checkbox = host.querySelector('input[type="checkbox"]') + + if (checkbox) { + checkbox.checked = value + } + + return + } + + const text = Array.isArray(value) ? '' : value + const input = host.querySelector( + 'input:not([type="radio"]):not([type="checkbox"])', + ) + + if (input) { + input.value = text + } + + host.querySelectorAll('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) } }) diff --git a/clinical-web/src/utils/signing-field-node.ts b/clinical-web/src/utils/signing-field-node.ts new file mode 100644 index 0000000..92e10b3 --- /dev/null +++ b/clinical-web/src/utils/signing-field-node.ts @@ -0,0 +1,348 @@ +/** + * 编辑器里的「签署占位符」元素节点。 + * + * 为什么要有这个模块:占位符原本是纯文本 {患者姓名},在编辑器里就是一段文字—— + * 删掉一个要按五六次退格,退格一次只吃掉一个字符,稍微快点就会把相邻正文也带走; + * 而且纯文本没法承载「有哪些选项」「能不能多选」这类结构化信息。 + * + * 现在把它做成一个**原子节点**:点一下即整块选中,一次退格整体删除, + * 内部不落任何可编辑的文字。 + * + * 关键实现点(都是照着 wangEditor 内置的 image 模块抄的,那是它自己的 void 元素): + * + * ① **isVoid + isInline 都要覆写,且必须走 editorPlugin**。 + * 只有 isVoid 会变成块级
,把整行撑断;两个都覆写才得到内联的 。 + * 覆写方式与 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 里就没有可落点文字。 + * + * ④ **存下来的正文仍然是干净的 ``**,与 utils/signing-fields 的 + * 结构化契约、以及签署渲染管线(utils/signing-document 的 renderStructuredFields) + * 完全一致,签署侧一行都不用改。编辑器专属的标记只活在解析期,见 ⑤。 + * + * ⑤ **`` 必须带 `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' + +/** + * 解析 `` 时用的选择器。 + * 注意不是 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 { + 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>, +): number { + const entries = findSignFieldEntries(editor, fieldId) + + entries.forEach(({ node, path }) => { + const merged = { ...readFieldFromSignFieldNode(node), ...patch, id: fieldId } + // 类型放宽一次:Slate 的 Element 是 BaseElement 与自定义类型的交叉类型、没有属性索引签名, + // Partial 不接纳我们的 field* 扩展属性(内置 image 模块走 insertNodes, + // 参数是 Node,所以没遇到这个问题)。先落到 Partial 再传, + // 非字面量的额外属性就不再触发多余属性检查。 + const props: Partial = 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[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)) +} + +/** 解析 ``:字段信息原样搬进模型,不再从文字反推。 */ +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(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) +} diff --git a/clinical-web/src/utils/signing-fields.ts b/clinical-web/src/utils/signing-fields.ts index 41a0fd6..b3299cb 100644 --- a/clinical-web/src/utils/signing-fields.ts +++ b/clinical-web/src/utils/signing-fields.ts @@ -9,10 +9,15 @@ * ③ 必填与选项:模板保存时可做去重校验,签署时可校验必填项是否填完; * ④ 风险值(risk):签署取值命中即标记为异常项。 * - * 载体仍沿用既有文书里的 {患者姓名} 写法,原因见 buildFieldToken 的注释: - * 当前编辑器(WangEditor / Slate)无法无损往返带 data-* 属性的结构化节点, - * 而纯文本占位符可以,且存量模板零迁移。渲染管线同时支持 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 = '}' +/** + * 结构化占位符的元素与属性契约。 + * + * 两种载体并存,各有分工: + * - **纯文本 {患者姓名}**:存量模板的写法。正则解析、零迁移风险,但只是一段文字, + * 在编辑器里要按五六次退格才能删干净,也没法承载"选项""多选"这类结构化信息。 + * - **``**:新写入的目标格式。在编辑器里是一个原子节点(见 + * 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, +): 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): ScannedFieldToken[] { +export function scanFieldTokens( + text: string, + counters: Map, + claimed?: Set, +): ScannedFieldToken[] { const tokens: ScannedFieldToken[] = [] const pattern = createFieldTokenPattern() let match: RegExpExecArray | null @@ -267,10 +426,23 @@ export function scanFieldTokens(text: string, counters: Map): 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): Sc } /** - * 生成占位符文本。 + * 生成纯文本占位符 {患者姓名}。 * - * 用纯文本而不是 :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 { + const ids = new Set() + + 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 { + 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[] { - if (!contentHtml) { - return [] - } - const seen = new Set() 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() - 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( + `
${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, + ) +} + +/** + * 给新插入的自定义文本项分配一个在本文书里唯一的字段键。 + * + * 自定义文本项的字段键是 `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 对应,是「自动填充」的唯一入口。 diff --git a/clinical-web/src/views/management/documents/components/WordEditorDialog.vue b/clinical-web/src/views/management/documents/components/WordEditorDialog.vue index c0af7cb..3135650 100644 --- a/clinical-web/src/views/management/documents/components/WordEditorDialog.vue +++ b/clinical-web/src/views/management/documents/components/WordEditorDialog.vue @@ -3,14 +3,15 @@ import { computed, reactive, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import type { CampusRecord, DepartmentRecord } from '@/api/management/types' +import type { SigningFieldDef, SigningFieldKind } from '@/api/workbench/types' import { - buildFieldToken, - buildGenericTextToken, + allocateCustomFieldId, collectFieldDefs, findDuplicateFields, GENERIC_TEXT_FIELD_LABEL, SIGNING_FIELD_KIND_LABELS, SIGNING_FIELD_PRESETS, + upgradeFieldTokensToStructured, } from '@/utils/signing-fields' import EditorWangPane from './editors/EditorWangPane.vue' @@ -39,10 +40,16 @@ const savePermission = computed(() => // Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式), // 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。 // 编辑引擎固定为 WangEditor(MIT)。更换引擎只需新增一个实现 EditorPaneHandle 的面板组件, -// 注意把 Word 粘贴的归一化处理重新挂到新引擎的粘贴钩子上(WangEditor 见 EditorWangPane 的 customPaste)。 +// 注意把 Word 粘贴的归一化处理重新挂到新引擎的粘贴钩子上(WangEditor 见 EditorWangPane 的 customPaste), +// 并把占位符的元素节点与渲染钩子重新注册一遍(见 utils/signing-field-node)。 interface EditorPaneHandle { getText: () => string insertText: (text: string) => void + insertField: (field: SigningFieldDef) => void + /** 就地改写某个字段的占位符,返回命中的节点数;0 表示正文里已经没有它 */ + updateField: (fieldId: string, patch: Partial>) => number + /** 就地删掉某个字段的占位符,返回命中的节点数 */ + removeField: (fieldId: string) => number } const valueHtml = ref('


') @@ -50,13 +57,20 @@ const docxInput = ref(null) const importing = ref(false) const importMessage = ref('') const paneRef = ref(null) +/** 打开时若把旧式 {…} 占位符升级成了原子占位符,这里放一句说明(不静默改内容) */ +const upgradeNotice = ref('') +/** 正在配置的填写项字段键;空串表示没有选中 */ +const selectedFieldId = ref('') // 占位符分两类: // ① 目录里的字段(utils/signing-fields 的 SIGNING_FIELD_PRESETS):取值有固定来源, // 签署时按字段键取 HIS / 患者就诊快照自动填充,其中签名是独立控件类型—— // 签字后签名图作为该字段取值落回文书原本的签名位,不需要再替换文字; // ② 通用文本项(现场手填的输入框,如与患者关系、受托人姓名):没有系统取值来源, -// 默认写 {文本框},签署时渲染成一个空文本框,名称在正文里改即可,见 insertGenericTextField。 +// 默认写「文本框」,签署时渲染成一个空文本框,名称在配置面板里改即可。 +// +// 两类插入的都是**结构化占位符元素节点**(正文里存成 ): +// 点一下即整块选中、一次退格整块删除,字段键 / 类型 / 必填 / 选项 / 多选都在元素上。 const form = reactive({ templateCode: '', @@ -96,9 +110,24 @@ watch( versionNo: template?.version ?? 'v1', contentHtml: template?.contentHtml ?? '', }) - valueHtml.value = template?.contentHtml || '


' + + // 存量正文里的占位符是纯文本 {患者姓名},删一个要按五六次退格。 + // 打开时统一升级成结构化占位符(原子节点、一次退格整块删除)。 + // 升级沿用同一套解析口径,字段键前后完全一致,历史 fieldAnswers 不受影响; + // 正文里没有 { 时这一步原样返回,不会白白做一次 DOM 往返。 + const rawContent = template?.contentHtml || '


' + const upgradedContent = upgradeFieldTokensToStructured(rawContent) + + upgradeNotice.value = + upgradedContent === rawContent + ? '' + : '已把正文里的旧式 {…} 占位符升级为可整块删除的占位符(字段口径不变)。' + + form.contentHtml = upgradedContent + valueHtml.value = upgradedContent syncDepartmentOptions() importMessage.value = '' + selectedFieldId.value = '' }, { immediate: true }, ) @@ -120,24 +149,159 @@ function insertContent(text: string) { paneRef.value?.insertText(text) } -function insertField(field: (typeof SIGNING_FIELD_PRESETS)[number]) { - insertContent(buildFieldToken(field)) +/** + * 插入目录里的字段(患者姓名、签名、日期……)。 + * + * 进的是**结构化占位符元素节点**,不是一段文字:点一下即整块选中, + * 一次退格整块删除;字段键与类型随节点走,不靠"第几次出现"推断。 + */ +function insertField(field: SigningFieldDef) { + paneRef.value?.insertField(field) } /** - * 插入通用文本项:现场手填的输入框,默认 {文本框}。 + * 插入通用文本项:现场手填的输入框,默认叫「文本框」。 * - * 每插一个就是一个独立填写项(字段键按出现位置编号,见 signing-fields 的 resolveFieldByLabel), - * 所以两处文本框天然是两份取值,不需要为了区分而改名字。 - * 名称只决定文书里显示的文字:改成「与患者关系」是为了读起来像文书,不影响取值归属。 + * 每插一个就是一个独立填写项,所以字段键必须**当场取一个没被占用的** + * (见 allocateCustomFieldId)——两个文本框是两处要填的内容,共用一个键才是错的。 + * 名称只决定文书里显示的文字:改成「与患者关系」只是为了读起来像文书, + * 不影响它是哪个字段,也不影响历史取值。 */ function insertGenericTextField() { - insertContent(buildGenericTextToken()) + const label = GENERIC_TEXT_FIELD_LABEL + + paneRef.value?.insertField({ + id: allocateCustomFieldId(valueHtml.value, label), + label, + kind: 'text', + required: false, + options: [], + }) } -/** 已插入的填写项:让科室在保存前确认签名位、日期位有没有漏 */ +/** 已插入的填写项:让科室在保存前确认签名位、日期位有没有漏,并点进去改配置 */ const detectedFields = computed(() => collectFieldDefs(valueHtml.value)) +/** 正在配置的那一项(从 detectedFields 里现取,保证面板显示的就是正文里的口径) */ +const selectedField = computed( + () => detectedFields.value.find((field) => field.id === selectedFieldId.value) ?? null, +) + +/** + * 配置草稿。 + * + * 与 selectedField 分开保存:面板上改到一半、还没点「应用修改」时, + * 正文(valueHtml)不能跟着变——那会让编辑区每次敲键盘都重排一次。 + */ +const fieldDraft = reactive({ + label: '', + kind: 'text' as SigningFieldKind, + required: false, + optionsText: '', + multi: false, + risk: '', +}) + +function syncDraft(field: SigningFieldDef | null) { + fieldDraft.label = field?.label ?? '' + fieldDraft.kind = field?.kind ?? 'text' + fieldDraft.required = field?.required ?? false + // 选项用顿号展示:科室更习惯「同意、不同意」这种写法,逗号也能识别 + fieldDraft.optionsText = (field?.options ?? []).join('、') + // 缺省口径与 signing-fields 的 SigningFieldDef.multi 一致:勾选类默认可多选 + fieldDraft.multi = field?.multi ?? field?.kind === 'checkbox' + fieldDraft.risk = field?.risk ?? '' +} + +// 只在"选中项变了"时对齐草稿。不要 watch selectedField —— 它是 computed, +// 正文每敲一个字都会重算并换掉对象引用,那样会把面板上没提交的编辑冲掉。 +watch( + () => selectedFieldId.value, + (fieldId) => { + syncDraft(detectedFields.value.find((field) => field.id === fieldId) ?? null) + }, + { immediate: true }, +) + +/** 点正文里的胶囊:直接选中并刷新草稿(同一个胶囊连点两次也应把草稿复原) */ +function selectFieldFromEditor(field: SigningFieldDef) { + selectedFieldId.value = field.id + syncDraft(field) +} + +/** 点列表项:再点一次收起,也顺带把草稿复原 */ +function toggleField(fieldId: string) { + selectedFieldId.value = selectedFieldId.value === fieldId ? '' : fieldId +} + +/** 选项文本 → 选项数组。顿号、中英文逗号、竖线都当分隔符(竖线与 data-options 口径一致) */ +function parseOptions(text: string): string[] { + return text + .split(/[、,,|]/) + .map((option) => option.trim()) + .filter(Boolean) +} + +function applyFieldDraft() { + const field = selectedField.value + + if (!field) { + return + } + + const label = fieldDraft.label.trim() + + if (!label) { + ElMessage.error('填写项名称不能为空') + return + } + + const needsOptions = fieldDraft.kind === 'choice' || fieldDraft.kind === 'checkbox' + const options = needsOptions ? parseOptions(fieldDraft.optionsText) : [] + + if (needsOptions && !options.length) { + ElMessage.error('单选 / 勾选至少要有一个选项') + return + } + + // 字段键(field.id)刻意保持不变:id 是取值的归属,改叫法不该让历史取值对不上。 + // 目录外的自定义文本项也一样——它的键里虽然带着旧名字,但那只是个内部标识。 + // + // 改的是**编辑器模型**而不是 valueHtml 字符串:字符串往返要经过 setHtml, + // 会重建整篇文档——用户此刻的光标还停在正文里,旧选区在新 DOM 上找不到位置就抛异常, + // 而且光标也会丢。改模型两个问题都没有。 + const changed = paneRef.value?.updateField(field.id, { + label, + kind: fieldDraft.kind, + required: fieldDraft.required, + options, + multi: options.length ? fieldDraft.multi : undefined, + risk: fieldDraft.risk.trim() || undefined, + }) + + if (!changed) { + ElMessage.warning(`正文里已经没有「${field.label}」了,请重新选择要修改的填写项`) + selectedFieldId.value = '' + return + } + + ElMessage.success(`已更新「${label}」(正文里 ${changed} 处)`) +} + +function removeField() { + const field = selectedField.value + + if (!field) { + return + } + + // 整项作废:正文里它的每一处出现都删掉(签名位在每页都出现是常态) + const changed = paneRef.value?.removeField(field.id) ?? 0 + + selectedFieldId.value = '' + ElMessage.success(`已删除「${field.label}」(正文里 ${changed} 处)`) +} + const placeholderHints = computed(() => { const hints: string[] = [] const ids = new Set(detectedFields.value.map((field) => field.id)) @@ -179,6 +343,10 @@ async function handleDocxChange(event: Event) { const arrayBuffer = await file.arrayBuffer() const mammoth = await import('mammoth/mammoth.browser') const result = await mammoth.convertToHtml({ arrayBuffer }) + // 已知问题(既有,与本次占位符改动无关):编辑器已有光标时这一句会让 wangEditor 抛一条 + // 未捕获的 promise 异常——setHtml 内部会 e.focus(),把缓存的上次选区装回去, + // 新正文的节点还没绑上 DOM 就先做了一次 DOM 同步。 + // 正文仍然会正常导入(异常发生在同步选区那一步),要彻底修需要重建编辑器实例,另行处理。 valueHtml.value = result.value || '


' importMessage.value = result.messages.length ? `已导入,${result.messages.length} 条转换提示(部分 Word 特性可能未还原)` @@ -335,18 +503,81 @@ function save() {
- 占位符检查 - {{ detectedFields.length }} 个填写项 + 填写项 + {{ detectedFields.length }} 个 + {{ upgradeNotice }}
- +

文书中还没有任何填写项,签署时不会采集患者填写内容。

+ +
+
+ 配置「{{ selectedField.label }}」 + 字段键 {{ selectedField.id }}(改名称不影响取值归属) +
+
+ + + + +
+
+ + +
+
+ + + 改动会作用到正文里该项的每一处出现,保存文书后生效 +
+
+
  • {{ hint }}
@@ -374,8 +605,8 @@ function save() { 建议从 Word/WPS 中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载; - 需要患者现场填写的内容(与患者关系、受托人姓名等)点「文本框」插入输入框占位符,插几个就是几处独立填写、各填各的;把名称改成实际叫法只是为了读起来像文书。 - 占位符与勾选框在编辑区会显示成带类型标签的胶囊,便于和正文区分。 + 需要患者现场填写的内容(与患者关系、受托人姓名等)点「文本框」插入输入框占位符,插几个就是几处独立填写、各填各的。 + 占位符在编辑区显示成带类型标签的胶囊:点一下即整块选中,按一次退格就整块删除;名称、类型、必填、选项在下方「填写项」里点开改。

@@ -388,6 +619,7 @@ function save() { ref="paneRef" :model-value="valueHtml" @update:model-value="valueHtml = $event" + @select-field="selectFieldFromEditor" />

@@ -578,12 +810,22 @@ function save() { font-size: 11px; } +/* 打开文书时若升级了旧式占位符,在这里说一声——不静默改内容 */ +.placeholder-check__notice { + padding: 2px 8px; + color: var(--brand); + font-size: 11px; + background: var(--brand-l); + border-radius: 10px; +} + .placeholder-check__list { display: flex; flex-wrap: wrap; gap: 5px; } +/* 每一项都是按钮:点开即可改名称 / 类型 / 必填 / 选项,再点一次收起 */ .placeholder-check__item { display: inline-flex; gap: 5px; @@ -594,6 +836,17 @@ function save() { background: #f4f8fa; border: 1px solid var(--line); border-radius: 12px; + cursor: pointer; +} + +.placeholder-check__item:hover { + border-color: var(--brand); +} + +.placeholder-check__item.is-active { + color: #fff; + background: var(--brand); + border-color: var(--brand); } .placeholder-check__item em { @@ -602,6 +855,137 @@ function save() { font-style: normal; } +.placeholder-check__item i { + padding: 0 5px; + color: var(--mut); + font-size: 10px; + font-style: normal; + background: rgb(0 0 0 / 5%); + border-radius: 8px; +} + +.placeholder-check__item.is-active em, +.placeholder-check__item.is-active i { + color: #fff; + background: rgb(255 255 255 / 22%); +} + +/* ---- 填写项配置 ---- */ + +.field-config { + padding: 9px 10px; + margin-top: 8px; + background: #f8fbfc; + border: 1px solid var(--line); + border-radius: 8px; +} + +.field-config__head { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: baseline; + margin-bottom: 8px; +} + +.field-config__head strong { + color: var(--ink); + font-size: 11.5px; +} + +.field-config__head span { + color: var(--mut); + font-size: 10.5px; +} + +.field-config__row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: flex-end; + margin-bottom: 8px; +} + +.field-config__item { + display: block; + min-width: 0; +} + +.field-config__item.grow { + flex: 1; + min-width: 180px; +} + +.field-config__item > span { + display: block; + margin-bottom: 3px; + color: var(--ink); + font-size: 11px; + font-weight: 700; +} + +.field-config__item input, +.field-config__item select { + width: 100%; + padding: 5px 7px; + color: var(--ink); + font-size: 12px; + border: 1px solid var(--line); + border-radius: 6px; + box-sizing: border-box; +} + +.field-config__item input:focus, +.field-config__item select:focus { + border-color: var(--brand); + outline: none; +} + +.field-config__check { + display: inline-flex; + gap: 5px; + align-items: center; + padding-bottom: 6px; + color: var(--ink); + font-size: 11.5px; + white-space: nowrap; +} + +.field-config__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.field-config__actions > span { + color: var(--mut); + font-size: 10.5px; +} + +.field-config__button { + padding: 4px 11px; + font-size: 11.5px; + font-weight: 700; + border-radius: 6px; +} + +.field-config__button.primary { + color: #fff; + background: var(--brand); + border: 1px solid var(--brand); +} + +.field-config__button.danger { + color: var(--danger, #c0392b); + background: #fff; + border: 1px solid currentcolor; +} + +.field-config__button:hover { + filter: brightness(0.96); +} + .placeholder-check__empty { margin: 0; color: var(--mut); diff --git a/clinical-web/src/views/management/documents/components/editors/EditorWangPane.vue b/clinical-web/src/views/management/documents/components/editors/EditorWangPane.vue index adac046..f88dda4 100644 --- a/clinical-web/src/views/management/documents/components/editors/EditorWangPane.vue +++ b/clinical-web/src/views/management/documents/components/editors/EditorWangPane.vue @@ -3,14 +3,28 @@ import '@wangeditor/editor/dist/css/style.css' import { onBeforeUnmount, shallowRef } from 'vue' import { Editor, Toolbar } from '@wangeditor/editor-for-vue' +import { DomEditor } from '@wangeditor/editor' import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor' +import type { SigningFieldDef } from '@/api/workbench/types' import { normalizePastedHtml } from '@/utils/rich-text' import { decorateSigningMarkers, registerSigningEditorMarks } from '@/utils/signing-editor-marks' +import { + createSignFieldNode, + isSignFieldElement, + readFieldFromSignFieldNode, + registerSigningFieldNode, + removeSignFieldNodes, + SIGN_FIELD_CHIP_CLASS, + SIGN_FIELD_TYPE, + updateSignFieldNodes, +} from '@/utils/signing-field-node' -// 渲染钩子是 wangEditor 的全局注册表,必须在编辑器创建前挂上, -// 否则首帧渲染出来的占位符/勾选框还是普通文字(要等下一次重渲染才变胶囊)。 +// 这两个都是 wangEditor 的全局注册表,必须在编辑器创建前挂上: +// - 渲染钩子决定旧式 {…} 占位符与 □ 勾选框的显示(要等下一次重渲染才生效就晚了); +// - 元素节点决定结构化占位符能不能被还原成原子胶囊(首帧就要是胶囊,否则看起来像丢内容)。 registerSigningEditorMarks() +registerSigningFieldNode() const props = defineProps<{ modelValue: string @@ -18,6 +32,8 @@ const props = defineProps<{ const emit = defineEmits<{ 'update:modelValue': [value: string] + /** 点中了正文里的结构化占位符,交给上层打开「填写项配置」 */ + 'select-field': [field: SigningFieldDef] }>() const editorRef = shallowRef() @@ -96,6 +112,44 @@ function handleCreated(editor: IDomEditor) { editableRef.value = editable editable.addEventListener('pointerdown', markUserCaret) editable.addEventListener('keydown', markUserCaret) + editable.addEventListener('click', handleFieldClick) +} + +/** + * 点中正文里的占位符胶囊 → 整块选中并通知上层打开配置面板。 + * + * 必须再判一次 toSlateNode 的结果:旧式 {…} 占位符被 decorate 渲染成了 + * 同名的 .ms-sign-chip,它只是个叶子、不是原子节点, + * 点它时什么都不该发生(它的配置从上方「填写项」列表里改)。 + */ +function handleFieldClick(event: Event) { + const editor = editorRef.value + const target = event.target + + if (!editor || !(target instanceof Element)) { + return + } + + const chip = target.closest(`.${SIGN_FIELD_CHIP_CLASS}`) + + if (!chip) { + return + } + + try { + const node = DomEditor.toSlateNode(editor, chip) + + if (!isSignFieldElement(node)) { + return + } + + editor.focus() + editor.select(DomEditor.findPath(editor, node)) + emit('select-field', readFieldFromSignFieldNode(node)) + } catch { + // 解不出节点(点到装饰件、或节点已不在模型里)就静默放过: + // 点击本身不该报错,保持浏览器默认行为即可 + } } function getText(): string { @@ -116,11 +170,68 @@ function insertText(text: string) { editor.insertNode({ text }) } -defineExpose({ getText, insertText }) +/** + * 在光标处插入一个**结构化占位符**(Slate 元素,不是文本)。 + * + * 与 insertText 的区别就是这个模块存在的理由:它进的是原子节点, + * 点一下即整块选中、一次退格整块删除;字段键、类型、必填、选项、多选 + * 都挂在元素上,导出正文时还原成 ``, + * 与签署渲染端(utils/signing-document)共用同一份契约。 + */ +function insertField(field: SigningFieldDef) { + const editor = editorRef.value + + if (!editor) { + return + } + + editor.focus(!userPlacedCaret.value) + + // 当前选中的就是占位符时先右移一格:否则新节点会插进上一个占位符内部, + // 连插两个签名位会互相嵌套(内置图片节点也是这么处理的) + if (DomEditor.getSelectedNodeByType(editor, SIGN_FIELD_TYPE)) { + editor.move(1) + } + + editor.insertNode(createSignFieldNode(field)) +} + +/** + * 就地改写某个字段的每一处占位符,返回命中的节点数。 + * + * 面板改字段走这里而不是「改 HTML 字符串再塞回编辑器」: + * 字符串往返要经过 setHtml,而 setHtml 内部会 `e.focus()`——它会把缓存的上次选区装回去, + * 于是新正文的节点还没绑上 DOM 就先做了一次 DOM 同步,抛 + * `Cannot resolve a DOM node from Slate node`(实测必现,且与光标在哪儿无关)。 + * 改模型既保住光标,也彻底绕开这条路径。字段 id 不变,历史取值不受影响。 + */ +function updateField(fieldId: string, patch: Partial>): number { + const editor = editorRef.value + + if (!editor) { + return 0 + } + + return updateSignFieldNodes(editor, fieldId, patch) +} + +/** 就地删掉某个字段的每一处占位符,返回命中的节点数 */ +function removeField(fieldId: string): number { + const editor = editorRef.value + + if (!editor) { + return 0 + } + + return removeSignFieldNodes(editor, fieldId) +} + +defineExpose({ getText, insertText, insertField, updateField, removeField }) onBeforeUnmount(() => { editableRef.value?.removeEventListener('pointerdown', markUserCaret) editableRef.value?.removeEventListener('keydown', markUserCaret) + editableRef.value?.removeEventListener('click', handleFieldClick) editorRef.value?.destroy() }) @@ -205,8 +316,12 @@ onBeforeUnmount(() => { } /* ---- 占位符 / 勾选框胶囊 ---- - utils/signing-editor-marks 把 {患者姓名} 切成「{ / 患者姓名 / }」三段叶子、 - 把 □ 切成一 段叶子,这里负责把它们渲染成一眼能认出是"待签署内容"的形态。 */ + 现在有两种占位符胶囊,样式刻意保持一致(都代表"待签署内容",不该靠外观区分重要性): + + ① 结构化占位符(.ms-sign-node):Slate 元素节点,正文里存成 。 + 标签文字走下面的 ::before,DOM 里没有可落光标的文字,一次退格整块删除。 + ② 旧式 {患者姓名}(.ms-sign-brace + .ms-sign-chip):decorate 出来的叶子, + 只是显示成胶囊,本质还是文字,要按多次退格。存量模板打开后会被升级成 ①。 */ /* 大括号:只缩小调淡,不移除。这两个字符必须留在 DOM 里参与光标偏移计算, 隐藏它们会让点击胶囊时的落点整体算错,也会让退格静默删掉看不见的字符 */ @@ -238,6 +353,25 @@ onBeforeUnmount(() => { border-radius: 4px; } +/* 结构化占位符的标签文字:走 CSS 内容而不是 DOM 文本节点。 + 元素是 void,DOM 里只有它自己与一个绝对定位的零宽占位叶子; + 若在这里放真实文字,浏览器原生光标会落进文字里, + 一次退格就只删掉一个字符 —— 正好破坏"一次退格整块删除"这个设计目标 */ +.pane-content :deep(.ms-sign-node)::before { + content: attr(data-sign-label); +} + +/* 选中态:点一下即整块选中,给个明确的反色,让人知道接下来的退格会删掉整块 */ +.pane-content :deep(.ms-sign-node.is-selected) { + color: #fff; + background: var(--brand); + border-color: var(--brand); +} + +.pane-content :deep(.ms-sign-node.is-selected)::after { + border-left-color: rgb(255 255 255 / 35%); +} + /* 类型角标:签名/日期/文本……让科室不点开也知道这一项要填什么。 所有占位符共用一个样式,靠这个角标区分类型,不再按必填/签名位分色 */ .pane-content :deep(.ms-sign-chip)::after { diff --git a/clinical-web/src/views/workbench/signing/components/SigningDocumentInteractive.vue b/clinical-web/src/views/workbench/signing/components/SigningDocumentInteractive.vue index 6b3cbb4..445fbf8 100644 --- a/clinical-web/src/views/workbench/signing/components/SigningDocumentInteractive.vue +++ b/clinical-web/src/views/workbench/signing/components/SigningDocumentInteractive.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue' import type { SigningAnswers, SigningTaskRecord } from '@/api/workbench/types' import { + applyAnswerToFieldHost, applySignatureToRoot, buildSigningDocumentHtml, describeFieldProgress, @@ -143,24 +144,9 @@ function applyStoredValues() { return } - if (typeof value === 'boolean') { - const checkbox = host.querySelector('input[type="checkbox"]') - if (checkbox) { - checkbox.checked = value - } - return - } - - const input = host.querySelector( - 'input:not([type="radio"]):not([type="checkbox"])', - ) - if (input) { - input.value = value - } - - host.querySelectorAll('input[type="radio"]').forEach((radio) => { - radio.checked = radio.value === value - }) + // 读写的判据都在 signing-document 里(readAnswersFromRoot 的镜像), + // 这里不自己写 data-sign-* 选择器,否则多选组这类"同 DOM 不同语义"的控件会两处漂移 + applyAnswerToFieldHost(host, value) }) }