diff --git a/clinical-web/docs/documents-api-verification-2026-09-17.md b/clinical-web/docs/documents-api-verification-2026-09-17.md new file mode 100644 index 0000000..b33ca8c --- /dev/null +++ b/clinical-web/docs/documents-api-verification-2026-09-17.md @@ -0,0 +1,132 @@ +# 文档库管理 —— 真实接口验证报告 + +- 验证日期:2026-09-17 +- 验证对象:`clinical-web` 文档库管理(`/management/documents`) +- 数据来源:MEDISIGN 后端 `https://ipad.shenynet.com`(真实接口,`VITE_MOCK_DOCUMENTS=false`) +- 验证方式:真实浏览器(Chrome headless + CDP)驱动真实页面,非静态审查 + +## 1. 结论 + +**文档库管理在真实接口下功能完整、闭环可用。** + +从「新增文书」到「发布」再到「发起签署」的完整链路全部走通, +每一步后端均返回 200,页面运行时错误 0 条。 + +此前记录的「后端模板写接口作废(`POST /v1/templates` 稳定 500、列表恒 0 条)」**已不成立**, +写接口已恢复正常。 + +## 2. 只读功能验证 + +| 功能 | 结果 | 说明 | +|---|---|---| +| 列表加载 | 通过 | 真实模板正确渲染,科室三级分组(科室 → 分类 → 文书)正常 | +| 科室页签 | 通过 | 按实际科室归类,无科室的模板归入「未指定科室」 | +| 状态页签 | 通过 | 按后端原始状态过滤,`PENDING_REVIEW` 与 `REJECTED` 未合并 | +| 搜索 | 通过 | 按名称/编号/科室过滤正确 | +| 预览弹窗 | 通过 | **正文真实取回**(列表接口只给哈希,已正确补穿透版本详情) | +| 预览 - 签署预览页签 | 通过 | 占位符按演示患者填充,渲染正常 | +| 编辑弹窗 | 通过 | 名称/编号/说明/正文全部正确带出 | +| 新增弹窗 | 通过 | 空表单;院区取真实 UUID;版本号显示「由后端按 vN 自动生成」 | +| 加载态/错误态 | 通过 | 有独立加载态与错误态,未把失败降级成空态 | + +## 3. 写操作与版本工作流验证 + +新建测试文书「【自动化测试】签署链路验证」(编号 `AUTO-TEST-800004`)后逐步执行: + +| 步骤 | 卡片动作 | 后端接口 | 状态变化 | +|---|---|---|---| +| 1 | 保存文书 | `POST /v1/templates` + `POST /v1/templates/{id}/versions` | (新建)→ 草稿 | +| 2 | 提交审核 | `POST .../versions/{vid}/submit-review` | 草稿 → 待审核 | +| 3 | 驳回 | `POST .../versions/{vid}/reject` | 待审核 → 已驳回 | +| 4 | 重新提交 | `POST .../versions/{vid}/submit-review` | 已驳回 → 待审核 | +| 5 | 审核通过 | `POST .../versions/{vid}/approve` | 待审核 → 审核通过 | +| 6 | 发布 | `POST .../versions/{vid}/publish` | 审核通过 → 已发布 | +| 7 | 发起 | `GET .../versions/{vid}`(取真实正文) | 打开「新增签署任务」,模板已自动选中 | + +驳回意见经 `reject` 的 `comment` 参数提交,用于留痕。 + +### 3.1 动作按钮文案随状态变化 + +前端按后端状态计算卡片主动作,文案**不是固定值**: + +| 后端状态 | 卡片动作 | +|---|---| +| `DRAFT` | 提交审核 | +| `PENDING_REVIEW` | 审核通过 / 驳回 | +| `REJECTED` | 重新提交 | +| `APPROVED` | 发布 | +| `PUBLISHED` | 发起 | +| `DISABLED` | 重新启用 | + +写自动化脚本或写测试用例时若写死「提交审核」,在后续步骤会全部点空。 + +### 3.2 工作流确认框的确认按钮文案 + +确认框的确认按钮文案**等于动作名本身**(「提交审核」「发布」「确认驳回」), +不是「确定」。按 `/确定|确认/` 匹配文字会点不到,应按 `.el-button--primary` 定位。 + +## 4. 接口调用清单(本次验证实际打到) + +``` +GET /api/v1/auth/me +GET /api/v1/campuses +GET /api/v1/departments +GET /api/v1/templates 列表(分页拉全量) +GET /api/v1/templates/{id} 模板详情 +GET /api/v1/templates/{id}/versions 版本列表 +GET /api/v1/templates/{id}/versions/{vid} 版本详情(正文来源) +POST /api/v1/templates 新建模板 +POST /api/v1/templates/{id}/versions 新建版本 +POST /api/v1/templates/{id}/versions/{vid}/submit-review +POST /api/v1/templates/{id}/versions/{vid}/reject +POST /api/v1/templates/{id}/versions/{vid}/approve +POST /api/v1/templates/{id}/versions/{vid}/publish +``` + +全部 200。 + +## 5. 已知限制与未覆盖项 + +### 5.1 入口未开放(既有设计,非本次回归) + +- **版本历史**:`getTemplateVersions` 与 `DocumentVersionDialog` 均已实现, + 但卡片上没有「版本」按钮(`DocumentTemplateCard` 中 `showVersionAction = false`), + 用户无法从 UI 进入。 +- **下架 / 归档**:同样没有按钮(`showRemoveAction = false`)。 + 因此本次测试创建的文书**无法通过 UI 清理**,只能走接口归档。 + +### 5.2 混合模式下的模板树 + +当前本地配置为「文档库真实 + 签署工作台演示数据」。此配置下点「发起」, +「新增签署任务」弹窗左侧的模板树**仍为演示数据**(含后端不存在的文书), +仅从文档库带入的那一份是真实的。 + +原因:该弹窗调用 `getSigningTemplates()`,它读的是 `mockFlags.signing`。 +将签署端也切到真实接口后,该模板树才会只显示后端真实模板。 + +### 5.3 未验证项 + +- 手写板设备桥接、线上签署回调、打印服务(后端尚未提供,前端为占位) +- 模板包导出(`showExportMessage` 为占位提示,后端无对应接口) + +## 6. 验证环境注意事项 + +供后续复现参考: + +1. **请求头是 `X-Token`,不是 `Authorization: Bearer`**(见 `utils/request.ts`)。 + 用 curl 直连后端调试时写错会得到 `40100 请先登录`,容易误判成 token 失效。 +2. **后端会话实际存活约 10 分钟**,而登录响应里的 `expiresAt` 声明 2 小时。 + 登录与验证必须串在同一条命令里执行(`node scripts/login.cjs && node .cjs`), + 否则必然被踢回 `/login`,表现为「页面上找不到按钮」。 +3. 文档库的弹窗是自定义 `.dialog-mask`,**不是 Element Plus 的 `.el-dialog`**。 + 关闭要点 `.dialog-mask .close-button`(预览 / Word 编辑器)或 `.dialog-close`(版本弹窗)。 + 关不掉会导致下一个弹窗因 `visible` 未变化而不触发重置,造出「状态没重置」的假象。 + +## 7. 遗留测试数据 + +测试文书「【自动化测试】签署链路验证」(编号 `AUTO-TEST-800004`)当前为**已发布**状态, +保留在联调环境以便继续验证发起签署。不需要时归档即可: + +``` +POST /api/v1/templates/{templateId}/versions/{versionId}/archive +``` diff --git a/clinical-web/scripts/README.md b/clinical-web/scripts/README.md index 261507e..5fc6c83 100644 --- a/clinical-web/scripts/README.md +++ b/clinical-web/scripts/README.md @@ -59,6 +59,37 @@ VITE_MOCK_DOCUMENTS=false VITE_MOCK_SIGNING=false npx vite --port 5199 VERIFY_URL=http://127.0.0.1:5199/ node scripts/verify-pages.cjs ``` +## verify-editor-marks.cjs — 占位符 / 勾选框胶囊验证 + +**为什么需要**:占位符胶囊(`utils/signing-editor-marks`)是**只改渲染、不改数据**的实现—— +它靠 wangEditor 的 `decorate` 把文本切成叶子再挂样式。这种写法有两类问题编译期完全看不出来: + +- 叶子切分 / 样式挂载没生效 → 胶囊根本没渲染出来(看起来"功能没做"); +- 装饰破坏了 Slate 的光标偏移计算 → 点击胶囊后光标落点错位、编辑时字符插到别处。 + +所以必须真实打开编辑器,一边看渲染一边点进去打字。 + +**用法**(登录步骤同 `verify-pages.cjs`,注意端口要和实际 dev server 一致): + +```bash +node scripts/login.cjs # 抓验证码,看图认数字 +node scripts/login.cjs 8944 # 登录 +VERIFY_URL=http://127.0.0.1:5174/ node scripts/verify-editor-marks.cjs +``` + +脚本会:打开文档库 → 新增文档(弹出 Word 编辑器)→ 依次插入 3 个占位符与 2 个勾选框 → +输出编辑区探针(胶囊数量/文案/配色/类型角标)与「占位符检查」面板文案 → +点进胶囊中部插入一个 `X`,验证字符确实落在占位符内部。 + +**两个关键断言**: + +- `panelText` 显示「N 个填写项」——面板读的就是 `editor.getHtml()` 的序列化结果, + 能数出填写项即证明**底层仍是 `{…}` 纯文本**,保存 / 签署链路的口径没被改动; +- 点进胶囊后插入 `X` 得到 `{患者X签名}`——证明装饰没有破坏光标偏移计算。 + +截图:`04-editor-marks.png`(整窗)、`04b-editor-marks-zoom.png`(编辑区 4 倍放大,用来核对胶囊细节)、 +`05-editor-marks-caret.png`(插入字符后)。 + ## 踩过的坑 改脚本前先读这段,都是实际踩出来的。 diff --git a/clinical-web/scripts/verify-editor-marks.cjs b/clinical-web/scripts/verify-editor-marks.cjs new file mode 100644 index 0000000..1302448 --- /dev/null +++ b/clinical-web/scripts/verify-editor-marks.cjs @@ -0,0 +1,348 @@ +/** + * 编辑器占位符 / 勾选框胶囊的渲染验证。 + * + * 验证目标(utils/signing-editor-marks + EditorWangPane): + * ① {患者姓名} 在编辑区渲染成带类型角标的胶囊,而不是和正文一样的裸文字; + * ② □ 渲染成勾选胶囊; + * ③ getHtml() 输出不变——用页面自己的「占位符检查」面板做探针 + * (它读的就是序列化后的 HTML 字符串),面板能数出填写项即证明底层仍是 {…} 纯文本; + * ④ 光标落点没被装饰破坏:点进胶囊中部后插入字符,字符应落在占位符内部。 + * + * 用法: + * 1. 起 dev server + * 2. VERIFY_BASE_URL=http://127.0.0.1: node scripts/login.cjs # 抓验证码 + * VERIFY_BASE_URL=http://127.0.0.1: node scripts/login.cjs 1234 # 登录 + * 3. VERIFY_URL=http://127.0.0.1:/ node scripts/verify-editor-marks.cjs + * + * 依赖 ws,与 verify-pages.cjs 相同(NODE_PATH 指向隔离 workspace)。 + */ +const { spawn } = require('child_process') +const fs = require('fs') +const path = require('path') + +let WebSocket +try { + WebSocket = require('ws') +} catch { + console.error('缺少依赖 ws。请先安装:') + console.error(' cd C:/Users//.workbuddy-ai/binaries/node/workspace') + console.error(' /npm.cmd install ws') + process.exit(1) +} + +const CHROME = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe' +const PORT = Number(process.env.VERIFY_CDP_PORT || 9451) +const TARGET_URL = process.env.VERIFY_URL || 'http://127.0.0.1:5174/' +const OUT = process.env.VERIFY_OUT || path.resolve(__dirname, '../../screenshots') +const SESSION_FILE = path.join(__dirname, '.session.json') +const PROFILE = path.join( + process.env.TEMP || process.env.TMPDIR || '/tmp', + 'cdp-prof-marks-' + process.pid, +) + +fs.mkdirSync(OUT, { recursive: true }) +fs.rmSync(PROFILE, { recursive: true, force: true }) + +if (!fs.existsSync(SESSION_FILE)) { + console.error('缺少会话文件 ' + SESSION_FILE + ',请先执行 node scripts/login.cjs') + process.exit(1) +} +const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')) +console.log(`[会话] ${session.user.displayName} / token=${String(session.token).slice(0, 8)}…`) + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +const BOOT_SCRIPT = ` +try { + localStorage.setItem('token', ${JSON.stringify(session.token)}) + localStorage.setItem('tokenExpiresAt', ${JSON.stringify(session.expiresAt)}) + localStorage.setItem('userInfo', ${JSON.stringify(JSON.stringify(session.user))}) +} catch (e) {} +` + +const chrome = spawn(CHROME, [ + '--headless=new', + '--disable-gpu', + '--no-sandbox', + '--hide-scrollbars', + `--remote-debugging-port=${PORT}`, + `--user-data-dir=${PROFILE}`, + '--window-size=1680,1300', + 'about:blank', +]) + +let msgId = 0 +function send(ws, method, params = {}) { + return new Promise((resolve, reject) => { + const id = ++msgId + const onMessage = (raw) => { + const msg = JSON.parse(raw) + if (msg.id !== id) return + ws.off('message', onMessage) + msg.error ? reject(new Error(method + ': ' + msg.error.message)) : resolve(msg.result) + } + ws.on('message', onMessage) + ws.send(JSON.stringify({ id, method, params })) + setTimeout(() => reject(new Error(method + ' 超时')), 25000) + }) +} + +async function evaluate(ws, expression) { + const res = await send(ws, 'Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + }) + if (res.exceptionDetails) { + const d = res.exceptionDetails + throw new Error('[evaluate] ' + (d.exception?.description || d.text || '').slice(0, 400)) + } + return res.result.value +} + +async function shot(ws, name, clip) { + const { data } = await send( + ws, + 'Page.captureScreenshot', + clip ? { format: 'png', clip } : { format: 'png' }, + ) + const file = path.join(OUT, name) + fs.writeFileSync(file, Buffer.from(data, 'base64')) + console.log(' 截图 -> ' + file) +} + +/** 放大截取编辑区里的一行内容,用于人眼核对胶囊细节 */ +async function zoomShot(ws, selector, name, scale = 3) { + const rect = await evaluate( + ws, + `(() => { + const el = document.querySelector(${JSON.stringify(selector)}) + if (!el) return null + const r = el.getBoundingClientRect() + return { x: r.left, y: r.top, width: r.width, height: r.height } + })()`, + ) + if (!rect) { + console.log(' 放大截图跳过:找不到 ' + selector) + return + } + await shot(ws, name, { ...rect, scale }) +} + +const clickByText = (text, tag = 'button') => ` +(() => { + const el = [...document.querySelectorAll('${tag}')] + .find(e => e.textContent.trim().includes(${JSON.stringify(text)})); + if (!el) return 'NOT_FOUND: ${text}'; + el.click(); + return 'CLICKED: ' + el.textContent.trim().slice(0, 30); +})()` + +async function gotoInPage(ws, routePath, settleMs = 5000) { + const result = await evaluate( + ws, + `(async () => { + const app = document.getElementById('app') + const router = app && app.__vue_app__ && app.__vue_app__.config.globalProperties.$router + if (!router) return 'NO_ROUTER' + await router.push(${JSON.stringify(routePath)}) + await new Promise(r => setTimeout(r, 200)) + return 'PUSHED:' + router.currentRoute.value.path + })()`, + ) + console.log(' ' + result) + await sleep(settleMs) +} + +async function getTarget() { + for (let i = 0; i < 40; i++) { + try { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json() + const page = list.find((t) => t.type === 'page') + if (page) return page + } catch {} + await sleep(300) + } + throw new Error('未找到页面') +} + +/** 编辑区探针:胶囊/大括号/勾选框的数量与样式,以及填写项统计面板的文案 */ +const EDITOR_PROBE = `(() => { + const root = document.querySelector('.pane-content') + if (!root) return { error: 'NO_EDITOR' } + const style = (el, pseudo) => getComputedStyle(el, pseudo) + const chips = [...root.querySelectorAll('.ms-sign-chip')] + const braces = [...root.querySelectorAll('.ms-sign-brace')] + const boxes = [...root.querySelectorAll('.ms-sign-box')] + const panel = document.querySelector('.placeholder-check') + return { + editorText: root.innerText.replace(/\\s+/g, ' ').trim(), + chips: chips.map((c) => ({ + text: c.textContent, + tag: c.dataset.signTag || null, + kind: c.dataset.signKind || null, + required: c.dataset.signRequired || null, + display: style(c).display, + bg: style(c).backgroundColor, + color: style(c).color, + border: style(c).borderTopWidth + ' ' + style(c).borderTopColor, + afterContent: style(c, '::after').content, + title: c.title, + })), + braceCount: braces.length, + braceTexts: braces.map((b) => b.textContent), + braceSample: braces[0] ? { + fontSize: style(braces[0]).fontSize, + opacity: style(braces[0]).opacity, + color: style(braces[0]).color, + } : null, + boxes: boxes.map((b) => ({ + text: b.textContent, + bg: style(b).backgroundColor, + border: style(b).borderTopWidth, + title: b.title, + })), + panelText: panel ? panel.innerText.replace(/\\s+/g, ' ').trim() : null, + panelItems: panel ? [...panel.querySelectorAll('.placeholder-check__item')].map((e) => e.textContent.replace(/\\s+/g, ' ').trim()) : [], + } +})()` + +const SELECTION_PROBE = `(() => { + const sel = window.getSelection() + return { + collapsed: sel.isCollapsed, + anchorNodeText: sel.anchorNode ? sel.anchorNode.textContent : null, + anchorOffset: sel.anchorOffset, + focusNodeText: sel.focusNode ? sel.focusNode.textContent : null, + focusOffset: sel.focusOffset, + } +})()` + +async function mouse(ws, type, x, y) { + await send(ws, 'Input.dispatchMouseEvent', { + type, + x: Math.round(x), + y: Math.round(y), + button: 'left', + buttons: type === 'mouseReleased' ? 0 : 1, + clickCount: 1, + pointerType: 'mouse', + }) +} + +const pageErrors = [] + +;(async () => { + try { + const res = await fetch(TARGET_URL) + if (!res.ok) throw new Error('HTTP ' + res.status) + } catch (err) { + console.error(`\n[致命] 无法访问 ${TARGET_URL} —— ${err.message}`) + console.error('请先启动 dev server,并用 VERIFY_URL 指定正确地址。\n') + process.exit(1) + } + + let ws + try { + const target = await getTarget() + ws = new WebSocket(target.webSocketDebuggerUrl, { perMessageDeflate: false }) + ws.setMaxListeners(0) + await new Promise((res, rej) => { + ws.on('open', res) + ws.on('error', rej) + }) + + ws.on('message', (raw) => { + const msg = JSON.parse(raw) + if (msg.method === 'Runtime.exceptionThrown') { + const d = msg.params.exceptionDetails + pageErrors.push('[exception] ' + (d.exception?.description || d.text || '').slice(0, 300)) + } + if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') { + const text = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(' ') + pageErrors.push('[console.error] ' + text.slice(0, 300)) + } + }) + + await send(ws, 'Page.enable') + await send(ws, 'Runtime.enable') + await send(ws, 'Page.addScriptToEvaluateOnNewDocument', { source: BOOT_SCRIPT }) + + await send(ws, 'Page.navigate', { url: TARGET_URL }) + await sleep(7000) + + const bootPath = await evaluate(ws, 'location.pathname') + console.log('\n[启动] pathname = ' + bootPath) + if (String(bootPath).includes('login')) { + throw new Error('会话已失效,落到了登录页。重新执行 node scripts/login.cjs') + } + + console.log('\n=== 打开文档库 ===') + await gotoInPage(ws, '/management/documents', 6000) + + console.log('\n=== 打开 Word 编辑器 ===') + console.log(' ' + (await evaluate(ws, clickByText('新增文档')))) + await sleep(1500) + + const dialog = await evaluate( + ws, + `({ + mask: document.querySelectorAll('.dialog-mask').length, + hasEditor: !!document.querySelector('.pane-content [data-slate-editor]'), + })`, + ) + console.log(' ' + JSON.stringify(dialog)) + if (!dialog.hasEditor) throw new Error('Word 编辑器没有打开') + + console.log('\n=== 插入占位符与勾选框 ===') + for (const label of ['患者签名', '签署日期', '患者姓名', '勾选框 □', '二选一']) { + console.log( + ' ' + label + ' -> ' + (await evaluate(ws, clickByText(label, '.placeholder-chip'))), + ) + await sleep(400) + } + await sleep(1200) + + console.log('\n=== 编辑区渲染探针 ===') + const probe = await evaluate(ws, EDITOR_PROBE) + console.log(JSON.stringify(probe, null, 2)) + await shot(ws, '04-editor-marks.png') + await zoomShot(ws, '.pane-content [data-slate-editor] p', '04b-editor-marks-zoom.png', 4) + + console.log('\n=== 光标落点(点进胶囊中部后插入 X) ===') + const chipBox = await evaluate( + ws, + `(() => { + const chip = document.querySelector('.ms-sign-chip') + if (!chip) return null + const r = chip.getBoundingClientRect() + return { x: r.left, y: r.top, w: r.width, h: r.height } + })()`, + ) + + if (chipBox) { + const cx = chipBox.x + chipBox.w * 0.3 + const cy = chipBox.y + chipBox.h / 2 + await mouse(ws, 'mousePressed', cx, cy) + await mouse(ws, 'mouseReleased', cx, cy) + await sleep(500) + console.log(' 点击前选中态: ' + JSON.stringify(await evaluate(ws, SELECTION_PROBE))) + + await send(ws, 'Input.insertText', { text: 'X' }) + await sleep(600) + const after = await evaluate(ws, EDITOR_PROBE) + console.log(' 插入 X 后正文: ' + after.editorText) + console.log(' 插入 X 后填写项面板: ' + after.panelText) + await shot(ws, '05-editor-marks-caret.png') + } + + console.log('\n=== 页面运行时错误 ===') + console.log(pageErrors.length ? [...new Set(pageErrors)].slice(0, 20).join('\n') : '无(干净)') + } catch (err) { + console.error('验证失败:', err.message) + process.exitCode = 1 + } finally { + if (ws) ws.close() + chrome.kill() + } +})() diff --git a/clinical-web/src/utils/signing-document.ts b/clinical-web/src/utils/signing-document.ts index 1e522e7..9e2e48e 100644 --- a/clinical-web/src/utils/signing-document.ts +++ b/clinical-web/src/utils/signing-document.ts @@ -23,6 +23,7 @@ import { resolveAnswersFromSystem, resolveFieldByLabel, SIGNING_DOCUMENT_HEADER, + SIGNING_OPTION_MARKERS, type SigningSystemSnapshot, } from './signing-fields' @@ -45,11 +46,10 @@ const FIELD_REQUIRED_ATTR = 'data-sign-required' const FIELD_RISK_ATTR = 'data-sign-risk' /** - * 科室手打的复选框符号:几何图形方框,以及 Wingdings 私有区码位。 - * Word「插入 → 符号」里的方框多为私有区字符(U+F0A8 等),粘贴后字体信息丢失、 - * 只剩下码位,所以这些也要一并识别。 + * 科室手打的复选框符号见 SIGNING_OPTION_MARKERS(utils/signing-fields): + * 编辑器标记与这里识别必须用同一份字符集,故不再本地声明。 */ -const OPTION_MARKERS = '□☐▢◻❑❒\uF0A8\uF0FE' +const OPTION_MARKERS = SIGNING_OPTION_MARKERS /** split 用:捕获组保证分隔符本身保留在结果里 */ const OPTION_SPLIT_PATTERN = new RegExp(`([${OPTION_MARKERS}])`) /** match 用(带 g 只用于 String.match,不参与 RegExp.test,避免 lastIndex 副作用) */ diff --git a/clinical-web/src/utils/signing-editor-marks.ts b/clinical-web/src/utils/signing-editor-marks.ts new file mode 100644 index 0000000..6ea41bf --- /dev/null +++ b/clinical-web/src/utils/signing-editor-marks.ts @@ -0,0 +1,216 @@ +/** + * 编辑器内的「占位符 / 勾选框」可视化标记。 + * + * 问题:占位符({患者姓名})与勾选框(□)在富文本编辑器里就是一段普通文字, + * 和正文长得一模一样。科室在长文书里看不出哪些内容是要在签署时采集的, + * 漏一个签名位往往要等到发起签署、甚至签署完才发现。 + * + * 方案:**不改数据模型,只改渲染**。 + * 用 wangEditor(Slate)的 decorate 把文本节点里的占位符 / 勾选框切成独立的 + * 叶子节点(leaf),再用 registerRenderStyle 给这些叶子挂上类名与 data-*, + * 由 EditorWangPane 的样式渲染成带类型角标的「字段胶囊」。 + * + * 为什么不用自定义 Slate 元素()承载: + * ① 纯文本占位符可以无损往返,存量模板零迁移;换成结构化元素就得在载入时 + * 把已有正文里的占位符批量改写一遍,是对既有文书内容的变换,风险与收益不成比例; + * ② 渲染管线(utils/signing-document)与填写项统计(collectFieldDefs)都按 + * {…} 文本解析,改模型要同步改签署侧与统计侧,回归面被放大; + * ③ decorate 是只读投影:它不改变 editor.getHtml() 的输出, + * 保存 / 回填 / 签署全链路一行都不用动。最坏情况只是"显示得不好看", + * 不会丢内容、不会写出编辑器读不回来的 HTML。 + * + * 一条硬约束:叶子里的原文必须原样留在 DOM 中(大括号不能 display:none / font-size:0)。 + * Slate 用 Range.cloneContents().textContent.length 反算光标偏移(见 slate 的 toSlatePoint), + * 把字符藏起来会让光标落点整体算错;而且「在胶囊左边缘按退格」会静默删掉看不见的字符。 + * 所以大括号只是被缩小、调淡,仍然可见可选——顺带也让科室能看见并手敲这个书写格式。 + */ + +import { Boot } from '@wangeditor/editor' +import type { IEditorConfig } from '@wangeditor/editor' + +import { + createFieldTokenPattern, + resolveFieldByLabel, + SIGNING_FIELD_KIND_LABELS, + SIGNING_OPTION_MARKERS, +} from './signing-fields' + +type DecorateConfig = NonNullable +type DecoratedEntry = Parameters[0] +type DecoratedRange = ReturnType[number] + +type RenderStyleConfig = Parameters[0] +type StyledNode = Parameters[0] +type StyledVNode = Parameters[1] + +/** + * 挂在文本叶子上、供渲染钩子读取的标记。 + * 统一加 sign 前缀,避免与 wangEditor / Slate 自身的 leaf 字段撞名 + * (叶子是原文本节点展开出来的,标点、bold 等既有属性都会一并带过来)。 + */ +interface SigningLeafMark { + /** field = 占位符;box = 勾选框 */ + signMarker?: 'field' | 'box' + /** 占位符被切成的三段:{ / 标签 / } */ + signPart?: 'open' | 'label' | 'close' + signKind?: string + /** 控件类型的中文名,渲染在胶囊右侧 */ + signTag?: string + signRequired?: boolean + /** 原文,如 {患者姓名},用于悬浮提示 */ + signRaw?: string +} + +type SigningRange = DecoratedRange & SigningLeafMark + +/** + * decorate 回调:把一段文本里的占位符与勾选框切成独立叶子。 + * + * 占位符切成三段而不是一段,是为了让大括号能单独缩小、调淡—— + * 一段文本节点没法只给其中两个字符换样式。 + */ +export function decorateSigningMarkers([node, path]: DecoratedEntry): DecoratedRange[] { + const { text } = node as { text?: string } + + if (typeof text !== 'string' || !text) { + return [] + } + + const ranges: SigningRange[] = [] + const tokenSpans: Array<{ start: number; end: number }> = [] + const pattern = createFieldTokenPattern() + let match: RegExpExecArray | null + + while ((match = pattern.exec(text)) !== null) { + const label = (match[1] ?? '').trim() + const start = match.index + const end = pattern.lastIndex + + // {x} 至少三个字符,否则中间切不出标签叶子,反而会造出零长度区间 + if (!label || end - start < 3) { + continue + } + + const field = resolveFieldByLabel(label) + const mark: SigningLeafMark = { + signMarker: 'field', + signKind: field.kind, + signTag: SIGNING_FIELD_KIND_LABELS[field.kind] ?? '文本', + signRequired: field.required, + signRaw: text.slice(start, end), + } + + tokenSpans.push({ start, end }) + ranges.push( + { + anchor: { path, offset: start }, + focus: { path, offset: start + 1 }, + ...mark, + signPart: 'open', + }, + { + anchor: { path, offset: start + 1 }, + focus: { path, offset: end - 1 }, + ...mark, + signPart: 'label', + }, + { + anchor: { path, offset: end - 1 }, + focus: { path, offset: end }, + ...mark, + signPart: 'close', + }, + ) + } + + const boxPattern = new RegExp(`[${SIGNING_OPTION_MARKERS}]`, 'g') + + while ((match = boxPattern.exec(text)) !== null) { + const index = match.index + + // 占位符标签里如果写了方框(如 {□}),它是标签文字的一部分,不能再标成勾选胶囊 + if (tokenSpans.some((span) => index >= span.start && index < span.end)) { + continue + } + + ranges.push({ + anchor: { path, offset: index }, + focus: { path, offset: index + 1 }, + signMarker: 'box', + }) + } + + // Slate 的 Text.decorations 是"按数组顺序逐个切分叶子",区间乱序会让切分结果错位。 + // 占位符区间与勾选框区间在本函数里是两轮扫描产出的,必须显式排序。 + return ranges.sort((a, b) => a.anchor.offset - b.anchor.offset || a.focus.offset - b.focus.offset) +} + +/** + * 渲染钩子:给带标记的叶子挂上类名与 data-*,样式见 EditorWangPane。 + * + * 直接改传入的 vnode.data 而不新建 vnode:新建需要 snabbdom 的 h, + * 而 snabbdom 是 wangEditor 的内部依赖,不该由业务代码直接引用。 + * data.props / data.dataset 是 vnode 的规范字段(见 core 的 normalizeVnodeData), + * 改它们不会被后续的归一化覆盖。 + */ +function renderSigningMarker(node: StyledNode, vnode: StyledVNode): StyledVNode { + const mark = node as unknown as SigningLeafMark + + if (!mark.signMarker) { + return vnode + } + + const data = (vnode.data ??= {}) + const props = (data.props ??= {}) + const dataset = (data.dataset ??= {}) + + dataset.signMarker = mark.signMarker + + if (mark.signMarker === 'box') { + props.className = 'ms-sign-box' + props.title = '勾选项:签署时转为可勾选控件' + return vnode + } + + dataset.signKind = mark.signKind ?? 'text' + dataset.signPart = mark.signPart ?? 'label' + + if (mark.signPart === 'label') { + const kind = mark.signTag ?? '文本' + props.className = 'ms-sign-chip' + props.title = `待填写项:${mark.signRaw ?? ''}(${kind}${mark.signRequired ? ',必填' : ''}),签署时按患者与就诊信息自动填充` + + if (mark.signTag) { + dataset.signTag = mark.signTag + } + + if (mark.signRequired) { + dataset.signRequired = 'true' + } + + return vnode + } + + // 大括号只缩小调淡,不移除:见文件头的"硬约束"。 + // 左右分开给类名,样式里才能各收各的边距(全角括号两侧留白很宽) + props.className = `ms-sign-brace ms-sign-brace--${mark.signPart ?? 'open'}` + + return vnode +} + +let registered = false + +/** + * 注册编辑器内的占位符 / 勾选框渲染钩子。 + * + * wangEditor 的渲染钩子是全局注册表,重复注册会让同一个叶子被处理多次; + * 用模块级开关挡住。HMR 重新求值该模块时开关会重置,但重复处理是幂等的,不会出错。 + */ +export function registerSigningEditorMarks(): void { + if (registered) { + return + } + + registered = true + Boot.registerRenderStyle(renderSigningMarker) +} diff --git a/clinical-web/src/utils/signing-fields.ts b/clinical-web/src/utils/signing-fields.ts index 1b7c5bd..65e67df 100644 --- a/clinical-web/src/utils/signing-fields.ts +++ b/clinical-web/src/utils/signing-fields.ts @@ -20,6 +20,26 @@ import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/wo export const FIELD_TOKEN_OPEN = '{' export const FIELD_TOKEN_CLOSE = '}' +/** + * 科室手打的复选框符号:几何图形方框,以及 Wingdings 私有区码位。 + * Word「插入 → 符号」里的方框多为私有区字符(U+F0A8 等),粘贴后字体信息丢失、 + * 只剩下码位,所以这些也要一并识别。 + * + * 放在这里而不是各端各写一份:签署端(utils/signing-document)靠它把方框转成 + * 可勾选控件,编辑器(utils/signing-editor-marks)靠它把方框标成勾选胶囊, + * 两边识别的字符集必须完全一致,否则会出现"编辑器标了、签署时没转"的静默失配。 + */ +export const SIGNING_OPTION_MARKERS = '□☐▢◻❑❒\uF0A8\uF0FE' + +/** 填写项控件类型的中文名,编辑器、占位符检查面板、签署端提示共用一份口径。 */ +export const SIGNING_FIELD_KIND_LABELS: Record = { + text: '文本', + date: '日期', + choice: '单选', + checkbox: '勾选', + signature: '签名', +} + const FIELD_TOKEN_SOURCE = `${FIELD_TOKEN_OPEN}([^${FIELD_TOKEN_OPEN}${FIELD_TOKEN_CLOSE}]{1,40})${FIELD_TOKEN_CLOSE}` /** /g 正则有 lastIndex 副作用,每次解析都新建一个,避免跨调用互相干扰。 */ diff --git a/clinical-web/src/views/management/documents/components/WordEditorDialog.vue b/clinical-web/src/views/management/documents/components/WordEditorDialog.vue index 60b3e98..dfe68cb 100644 --- a/clinical-web/src/views/management/documents/components/WordEditorDialog.vue +++ b/clinical-web/src/views/management/documents/components/WordEditorDialog.vue @@ -7,6 +7,7 @@ import { buildFieldToken, collectFieldDefs, findDuplicateFields, + SIGNING_FIELD_KIND_LABELS, SIGNING_FIELD_PRESETS, } from '@/utils/signing-fields' @@ -138,13 +139,8 @@ const placeholderHints = computed(() => { return hints }) -const kindLabels: Record = { - text: '文本', - date: '日期', - choice: '单选', - checkbox: '勾选', - signature: '签名', -} +// 类型中文名与编辑器胶囊、签署端提示共用一份(utils/signing-fields) +const kindLabels = SIGNING_FIELD_KIND_LABELS async function handleDocxChange(event: Event) { const input = event.target as HTMLInputElement @@ -349,6 +345,7 @@ function save() { 建议从 Word/WPS 中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载。 + 占位符与勾选框在编辑区会显示成带类型标签的胶囊,便于和正文区分。

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 c058e8f..535f055 100644 --- a/clinical-web/src/views/management/documents/components/editors/EditorWangPane.vue +++ b/clinical-web/src/views/management/documents/components/editors/EditorWangPane.vue @@ -6,6 +6,11 @@ import { Editor, Toolbar } from '@wangeditor/editor-for-vue' import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor' import { normalizePastedHtml } from '@/utils/rich-text' +import { decorateSigningMarkers, registerSigningEditorMarks } from '@/utils/signing-editor-marks' + +// 渲染钩子是 wangEditor 的全局注册表,必须在编辑器创建前挂上, +// 否则首帧渲染出来的占位符/勾选框还是普通文字(要等下一次重渲染才变胶囊)。 +registerSigningEditorMarks() const props = defineProps<{ modelValue: string @@ -58,6 +63,10 @@ const toolbarConfig: Partial = { const editorConfig: Partial = { placeholder: '将 Word / WPS 中的文书内容直接粘贴到此处(保留表格与基本格式)。', + // 把 {患者姓名} 这类占位符与 □ 勾选框在编辑器里渲染成可辨识的胶囊, + // 而不是和正文一样的裸文字。只影响显示,不改 getHtml() 的输出, + // 保存 / 签署链路口径不变,细节见 utils/signing-editor-marks。 + decorate: decorateSigningMarkers, // 接管粘贴:优先使用剪贴板中的富 HTML(保留居中、加粗、表格等结构), // 但先做归一化——Word 表格的绝对列宽与 nowrap 会让整表在窄容器里被裁切; // 纯文本粘贴(无富 HTML)时返回 true 走默认处理。 @@ -194,4 +203,81 @@ onBeforeUnmount(() => { .pane-content :deep(td p) { margin: 0; } + +/* ---- 占位符 / 勾选框胶囊 ---- + utils/signing-editor-marks 把 {患者姓名} 切成「{ / 患者姓名 / }」三段叶子、 + 把 □ 切成一 段叶子,这里负责把它们渲染成一眼能认出是"待签署内容"的形态。 */ + +/* 大括号:只缩小调淡,不移除。这两个字符必须留在 DOM 里参与光标偏移计算, + 隐藏它们会让点击胶囊时的落点整体算错,也会让退格静默删掉看不见的字符 */ +.pane-content :deep(.ms-sign-brace) { + color: var(--brand-m); + font-size: 0.78em; + opacity: 0.55; +} + +/* 全角大括号自带很宽的边距,负外边距把它收紧到贴着胶囊,读起来才像一对括号 */ +.pane-content :deep(.ms-sign-brace--open) { + margin-right: -0.18em; +} + +.pane-content :deep(.ms-sign-brace--close) { + margin-left: -0.18em; +} + +/* 占位符主体:带类型角标的胶囊 */ +.pane-content :deep(.ms-sign-chip) { + display: inline-block; + padding: 0 6px; + color: var(--brand-d); + font-size: 0.92em; + font-weight: 700; + line-height: 1.65; + background: var(--brand-l); + border: 1px solid #bcd9e3; + border-radius: 4px; +} + +/* 类型角标:签名/日期/文本……让科室不点开也知道这一项要填什么 */ +.pane-content :deep(.ms-sign-chip)::after { + padding-left: 5px; + margin-left: 5px; + color: inherit; + font-size: 0.8em; + font-weight: 400; + content: attr(data-sign-tag); + border-left: 1px solid rgb(0 0 0 / 10%); + opacity: 0.7; +} + +/* 必填项:角标转红并补一个星号,和下方「占位符检查」面板的口径一致 */ +.pane-content :deep(.ms-sign-chip[data-sign-required='true'])::after { + color: var(--err); + content: attr(data-sign-tag) ' *'; + opacity: 1; +} + +/* 签名位是最不能漏的一项:与「插入签署占位符」按钮里的橙色区分保持一致 */ +.pane-content :deep(.ms-sign-chip[data-sign-kind='signature']) { + color: #a96900; + background: #fff4e2; + border-color: #f0d5a8; +} + +.pane-content :deep(.ms-sign-brace[data-sign-kind='signature']) { + color: #c08a3e; + opacity: 0.7; +} + +/* 勾选框:签署时会转成可勾选控件,这里给它一个"框"的观感。 + 只上底色不加边框——□ 本身就是个方框,外面再套一圈边框会变成"框中框" */ +.pane-content :deep(.ms-sign-box) { + padding: 0 4px; + color: var(--brand); + font-size: 1.05em; + font-weight: 700; + line-height: 1.45; + background: var(--brand-l); + border-radius: 3px; +}