diff --git a/clinical-web/README.md b/clinical-web/README.md index c2911ab..94d0f6c 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -122,13 +122,14 @@ ClinicalLayout - `components/signing/NewSigningTaskDialog.vue`:新增签署任务公共弹窗,供签署工作台和文档库管理复用; - `composables/useSigningTaskForm.ts`:封装患者定位、就诊选择、模板选择、签署方式、校验和任务提交状态; -- `utils/signing-document.ts`:把文书定稿 HTML 变换为签署态 HTML 的渲染管线(占位符填充、签名域、勾选与行内选项识别),签署工作台的任务详情和文档库「签署预览」共用同一份实现。 +- `utils/signing-document.ts`:把文书定稿 HTML 变换为签署态 HTML 的渲染管线(占位符填充、签名域、勾选与行内选项识别),签署工作台的任务详情和文档库「签署预览」共用同一份实现; +- `utils/signing-editor-marks.ts`:编辑区里把占位符与勾选框渲染成带类型角标的胶囊,只改渲染不改数据(见下「文书编写约定」)。 ## 文书编写约定 科室在文档库的 Word 编辑器中维护文书,签署时需要交互化的内容按下面的写法即可,系统在签署和「签署预览」时自动转换: -- 占位符:`{患者姓名}`、`{性别}`、`{年龄}`、`{就诊号}`、`{签署日期}`、`{患者签名}` 等,由签署任务的患者与就诊快照填充; +- 占位符:`{患者姓名}`、`{性别}`、`{年龄}`、`{就诊号}`、`{签署日期}`、`{患者签名}`、`{医师签名}` 等,由签署任务的患者与就诊快照填充; - 勾选表:做成表格,第一行表头写「勾选」,另设一列写「项目」作为勾选条目名,签署时「勾选」列每个单元格变成复选框; - 行内选项:正文里直接写方框符号,如 `□否 □是`、`□同意 □不同意`。同一段落或同一单元格内出现两个及以上方框时按二选一(互斥单选)处理,只有一个方框时保持独立复选框语义。 @@ -136,6 +137,16 @@ ClinicalLayout 识别规则集中在 `utils/signing-document.ts`,调整后签署端与「签署预览」会同步生效。 +### 编辑区与签署态的呈现差异 + +**编辑区**(`utils/signing-editor-marks.ts`)把 `{…}` 与 `□` 渲染成带类型角标的胶囊,让科室一眼分出「正文」和「待签署的内容」。这是**只读投影**:靠 wangEditor 的 `decorate` 把文本切成叶子再挂样式,`editor.getHtml()` 输出一字未变,保存与签署链路的口径不受影响。 + +**签署态**(`utils/signing-document.ts`)把占位符就地换成可交互控件。其中**签名位**(`kind=signature`)在任务处于可签署状态时渲染成「✍ 点击签名」的可点击入口:点一下就打开签字板,签完的图就地回显到该签名位,只回填这一个字段,**不会结束签署任务**——任务级的「签署完成」仍由主签名位(`{患者签名}`)通过「手写板签署 / 线上签署」触发。 + +签名位之间互不覆盖:主签名位存在任务的 `signatureDataUrl`(提交时由接口固定落到 `signature` 字段),其余签名位(医师签名等)各自存在 `fieldAnswers` 里。 + +签名图只作为渲染结果里的 `` 存在,**不写进模板正文**:它是 base64 大字符串,进正文会让模板哈希随签名变化、缓存键跟着失效。 + ## API 与类型约定 API 模块与页面按业务域对应。工作台页面使用 `api/workbench` 下的页面文件,管理页面使用 `api/management` 下的直接文件: diff --git a/clinical-web/scripts/README.md b/clinical-web/scripts/README.md index 5fc6c83..4b6b7dc 100644 --- a/clinical-web/scripts/README.md +++ b/clinical-web/scripts/README.md @@ -90,6 +90,41 @@ VERIFY_URL=http://127.0.0.1:5174/ node scripts/verify-editor-marks.cjs 截图:`04-editor-marks.png`(整窗)、`04b-editor-marks-zoom.png`(编辑区 4 倍放大,用来核对胶囊细节)、 `05-editor-marks-caret.png`(插入字符后)。 +## verify-signature-click.cjs — 点击文中签名位采集签名 + +**为什么需要**:签名位的点击采集横跨三层(`utils/signing-document` 渲染 → `SigningDocumentInteractive` +事件委托 → 签署页弹窗与回灌),每一层都可能在编译期看不出问题: + +- 渲染层挂了 `data-sign-action` 但没人监听 → 看着像按钮,点了没反应; +- 事件委托的 `closest()` 没做容器校验 → 容器外的事件也会误触发; +- 回灌走了「整篇重渲染」而不是就地改写 → 正在输入的填写项连同光标一起丢。 + +所以必须真的用鼠标点、真的在签字板上画一笔、再看签名图有没有回到那一个签名位上。 + +**用法**(登录步骤同上,端口要和实际 dev server 一致): + +```bash +node scripts/login.cjs # 抓验证码,看图认数字 +node scripts/login.cjs 8944 # 登录 +VERIFY_URL=http://127.0.0.1:5173/ node scripts/verify-signature-click.cjs +``` + +脚本会:打开签署工作台 → 选一个「待签署」任务 → 输出签名位探针 → 真实点击「医师签名」→ +在签字板上画一笔并确认 → 复检回显与页脚提示 → 用键盘 `Enter` 触发「患者签名」的采集入口。 + +**关键断言**: + +- 空签名位必须同时有 `data-sign-action=signature`、`role=button`、`tabindex=0`、 + `cursor: pointer` 与 `✍ 点击签名` 文案——少一样就是「看着能点其实不能点」; +- 采集后该签名位出现 `img.sign-doc-signature` 且 `src` 是 `data:image/png`, + 同时采集入口被摘掉(`data-sign-action` 与 `sign-field--actionable` 都消失); +- **患者签名位的采集入口不能被误伤**——医师签名采完,患者签名还得能点; +- 页脚不再把已采集的签名位列进「还有签名位待采集」。 + +截图:`06-signature-actions.png`(初始:两个签名位都是可点入口)、 +`07-signature-dialog.png`(弹窗标题指向被点的签名位)、`08-signature-applied.png`(回显 + 页脚更新)、 +`09-signature-keyboard.png`(键盘触发)。 + ## 踩过的坑 改脚本前先读这段,都是实际踩出来的。 diff --git a/clinical-web/scripts/repro-checkbox-delete.cjs b/clinical-web/scripts/repro-checkbox-delete.cjs new file mode 100644 index 0000000..7951e8f --- /dev/null +++ b/clinical-web/scripts/repro-checkbox-delete.cjs @@ -0,0 +1,247 @@ +/** + * 临时复现脚本:插入勾选框后删除,看编辑器 DOM / 数据里有没有残留。 + * 用法: + * node scripts/login.cjs && node scripts/repro-checkbox-delete.cjs + */ +const { spawn } = require('child_process') +const fs = require('fs') +const path = require('path') + +const WebSocket = require('ws') + +const CHROME = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe' +const PORT = Number(process.env.VERIFY_CDP_PORT || 9461) +const TARGET_URL = process.env.VERIFY_URL || 'http://127.0.0.1:5173/' +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 || '/tmp', 'cdp-prof-repro-' + process.pid) + +fs.mkdirSync(OUT, { recursive: true }) +fs.rmSync(PROFILE, { recursive: true, force: true }) + +const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')) +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' }, + ) + fs.writeFileSync(path.join(OUT, name), Buffer.from(data, 'base64')) + console.log(' 截图 -> ' + path.join(OUT, name)) +} + +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 key(ws, k, code, vk) { + await send(ws, 'Input.dispatchKeyEvent', { + type: 'keyDown', + key: k, + code, + windowsVirtualKeyCode: vk, + nativeVirtualKeyCode: vk, + }) + await send(ws, 'Input.dispatchKeyEvent', { + type: 'keyUp', + key: k, + code, + windowsVirtualKeyCode: vk, + nativeVirtualKeyCode: vk, + }) +} + +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('未找到页面') +} + +/** 把编辑区每个段落的叶子结构原样 dump 出来:类名 + 文本(含不可见字符的码位) */ +const LEAF_PROBE = `(() => { + const root = document.querySelector('.pane-content [data-slate-editor]') + if (!root) return { error: 'NO_EDITOR' } + const codes = (s) => [...s].map((c) => (c.codePointAt(0) > 126 ? 'U+' + c.codePointAt(0).toString(16).toUpperCase() : c)).join('') + const blocks = [...root.querySelectorAll('[data-slate-node="element"]')] + return { + editableHtml: root.innerHTML, + blocks: blocks.map((el) => ({ + tag: el.tagName, + elementHtml: el.innerHTML, + leaves: [...el.querySelectorAll('[data-slate-leaf]')].map((leaf) => { + const span = leaf.firstElementChild + return { + cls: span ? span.className : null, + text: span ? span.textContent : null, + codes: span ? codes(span.textContent || '') : null, + zeroWidth: span ? span.getAttribute('data-slate-zero-width') : null, + outer: span ? span.outerHTML : null, + } + }), + textNodes: [...el.querySelectorAll('[data-slate-node="text"]')].length, + })), + } +})()` + +const pageErrors = [] + +;(async () => { + 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('会话已失效') + + console.log('\n=== 打开文档库 → 新增文档 ===') + await gotoInPage(ws, '/management/documents', 6000) + console.log(' ' + (await evaluate(ws, clickByText('新增文档')))) + await sleep(2500) + + const hasEditor = await evaluate( + ws, + `!!document.querySelector('.pane-content [data-slate-editor]')`, + ) + if (!hasEditor) throw new Error('编辑器没打开') + + console.log('\n=== 在空段落里插入勾选框 □ ===') + console.log(' ' + (await evaluate(ws, clickByText('勾选框 □', '.placeholder-chip')))) + await sleep(1200) + console.log('\n[插入后]') + console.log(JSON.stringify(await evaluate(ws, LEAF_PROBE), null, 2)) + await shot(ws, 'tmp-repro-01-inserted.png') + + console.log('\n=== 按一次退格 ===') + await key(ws, 'Backspace', 'Backspace', 8) + await sleep(1000) + console.log('\n[退格后]') + console.log(JSON.stringify(await evaluate(ws, LEAF_PROBE), null, 2)) + await shot(ws, 'tmp-repro-02-backspace.png') + + console.log('\n=== 再插一次 □ 并插入 二选一,然后用 Ctrl+A + Delete 全删 ===') + console.log(' ' + (await evaluate(ws, clickByText('勾选框 □', '.placeholder-chip')))) + await sleep(600) + console.log(' ' + (await evaluate(ws, clickByText('二选一 □是 □否', '.placeholder-chip')))) + await sleep(1000) + console.log(JSON.stringify(await evaluate(ws, LEAF_PROBE), null, 2)) + + await key(ws, 'a', 'KeyA', 65) + await sleep(300) + await key(ws, 'Delete', 'Delete', 46) + await sleep(1000) + console.log('\n[全选删除后]') + console.log(JSON.stringify(await evaluate(ws, LEAF_PROBE), null, 2)) + await shot(ws, 'tmp-repro-03-selectall-delete.png') + + console.log('\n=== 页面运行时错误 ===') + console.log(pageErrors.length ? [...new Set(pageErrors)].join('\n') : '无(干净)') + } catch (err) { + console.error('复现失败:', err.message) + process.exitCode = 1 + } finally { + if (ws) ws.close() + chrome.kill() + } +})() diff --git a/clinical-web/scripts/verify-signature-click.cjs b/clinical-web/scripts/verify-signature-click.cjs new file mode 100644 index 0000000..d2cc856 --- /dev/null +++ b/clinical-web/scripts/verify-signature-click.cjs @@ -0,0 +1,420 @@ +/** + * 签署页「点击文中签名位采集签名」的渲染与交互验证。 + * + * 验证目标(utils/signing-document + SigningDocumentInteractive + 签署页): + * ① 签署态的文书里,空签名位渲染成可点击的采集入口 + * (data-sign-action=signature、role=button、tabindex、✍ 点击签名 文案、手型光标); + * ② 患者签名与医师签名是两个独立签名位,都挂着采集入口; + * ③ 真实点击医师签名 → 打开签字板弹窗,标题是「采集「医师签名」」; + * ④ 在签字板上画一笔 → 确认 → 签名图回显到该签名位(img.sign-doc-signature), + * 且该位的采集入口被摘掉、页脚「还有签名位待采集」不再点名它; + * ⑤ 患者签名位不受影响,仍然可点; + * ⑥ 键盘 Enter 也能触发采集入口(签名位挂了 role=button,不能只有鼠标能用)。 + * + * 用法: + * 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-signature-click.cjs + * + * 注意:后端会话只活约 10 分钟,login 与 verify 要串在同一条命令里跑。 + * 依赖 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 || 9453) +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-signclick-' + 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 mouse(ws, type, x, y, extra = {}) { + 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', + ...extra, + }) +} + +async function clickAt(ws, x, y) { + await mouse(ws, 'mousePressed', x, y) + await sleep(40) + await mouse(ws, 'mouseReleased', x, y) +} + +/** 元素中心点(视口坐标),元素不存在返回 null */ +async function centerOf(ws, selector) { + return evaluate( + ws, + `(() => { + const el = document.querySelector(${JSON.stringify(selector)}) + if (!el) return null + const r = el.getBoundingClientRect() + if (!r.width || !r.height) return null + return { x: r.left + r.width / 2, y: r.top + r.height / 2, w: r.width, h: r.height } + })()`, + ) +} + +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 DOC_PROBE = `(() => { + const body = document.querySelector('.sign-doc-body') + if (!body) return { error: 'NO_DOC_BODY' } + const hosts = [...body.querySelectorAll('[data-sign-field]')] + const sigHosts = hosts.filter((h) => h.dataset.signKind === 'signature') + const footer = document.querySelector('.sign-doc-footer') + const card = document.querySelector('.signature-dialog') + return { + fieldIds: hosts.map((h) => h.dataset.signField), + signatureHosts: sigHosts.map((h) => { + const img = h.querySelector('img.sign-doc-signature') + const cs = getComputedStyle(h) + return { + id: h.dataset.signField, + label: h.dataset.signLabel, + action: h.dataset.signAction || null, + role: h.getAttribute('role'), + tabindex: h.getAttribute('tabindex'), + title: h.title || null, + actionableClass: h.classList.contains('sign-field--actionable'), + controlText: (h.querySelector('.sign-field__control') || {}).textContent?.trim() ?? null, + cursor: cs.cursor, + background: cs.backgroundColor, + borderColor: cs.borderTopColor, + imageSrcPrefix: img ? img.src.slice(0, 22) : null, + imageSrcLength: img ? img.src.length : 0, + } + }), + footer: footer ? footer.innerText.replace(/\\s+/g, ' ').trim() : null, + dialog: card + ? { + title: card.querySelector('.dialog-header strong')?.textContent.trim() ?? null, + hint: card.querySelector('.dialog-hint')?.textContent.trim() ?? null, + buttons: [...card.querySelectorAll('button')].map((b) => b.textContent.trim()), + canvasInk: (() => { + const c = card.querySelector('canvas') + if (!c) return null + const ctx = c.getContext('2d') + const data = ctx.getImageData(0, 0, c.width, c.height).data + let painted = 0 + for (let i = 3; i < data.length; i += 4) if (data[i] > 0) painted++ + return painted + })(), + } + : null, + } +})()` + +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, '/workbench/signing', 6000) + + console.log('\n=== 选一个待签署任务 ===') + const picked = await evaluate( + ws, + `(() => { + const items = [...document.querySelectorAll('.task-item')] + if (!items.length) return 'NO_TASK_ITEM' + const pending = items.find((el) => el.textContent.includes('待签署')) + if (!pending) return 'NO_PENDING_TASK:' + items.length + pending.click() + return 'PICKED: ' + pending.textContent.replace(/\\s+/g, ' ').trim().slice(0, 60) + })()`, + ) + console.log(' ' + picked) + if (String(picked).startsWith('NO_')) throw new Error('没有可用的待签署任务:' + picked) + await sleep(4000) + + console.log('\n=== ① 签名位初始渲染探针 ===') + const before = await evaluate(ws, DOC_PROBE) + console.log(JSON.stringify(before, null, 2)) + await shot(ws, '06-signature-actions.png') + + if (before.error) throw new Error('文书没有渲染:' + before.error) + + const actionable = before.signatureHosts.filter((h) => h.action === 'signature') + console.log(`\n 可点击签名位 ${actionable.length} 个:${actionable.map((h) => h.label).join('、')}`) + + const doctor = before.signatureHosts.find((h) => h.label === '医师签名') + if (!doctor) throw new Error('文书里没有「医师签名」签名位,无法验证点击采集') + if (doctor.action !== 'signature') throw new Error('「医师签名」没有渲染成可点击采集入口') + if (doctor.cursor !== 'pointer') throw new Error('「医师签名」没有手型光标,观感上看不出能点') + if (!doctor.actionableClass) throw new Error('「医师签名」缺少 sign-field--actionable 类') + + console.log('\n=== ② 真实点击「医师签名」 ===') + const doctorBox = await centerOf(ws, '[data-sign-field="doctor"]') + if (!doctorBox) throw new Error('找不到「医师签名」宿主节点的可点区域') + await clickAt(ws, doctorBox.x, doctorBox.y) + await sleep(1200) + + const dialog = await evaluate(ws, DOC_PROBE) + console.log(' 弹窗:' + JSON.stringify(dialog.dialog)) + if (!dialog.dialog) throw new Error('点击「医师签名」没有打开签字板弹窗') + if (!String(dialog.dialog.title).includes('医师签名')) { + throw new Error('弹窗标题没有指向被点的签名位:' + dialog.dialog.title) + } + await shot(ws, '07-signature-dialog.png') + + console.log('\n=== ③ 在签字板上画一笔并确认 ===') + const canvasBox = await centerOf(ws, '.signature-dialog canvas') + if (!canvasBox) throw new Error('签字板 canvas 不可见') + const x0 = canvasBox.x - canvasBox.w * 0.3 + const y0 = canvasBox.y + await mouse(ws, 'mousePressed', x0, y0) + for (let i = 1; i <= 12; i++) { + await mouse(ws, 'mouseMoved', x0 + i * (canvasBox.w * 0.05), y0 + (i % 2 ? 18 : -18)) + await sleep(16) + } + await mouse(ws, 'mouseReleased', x0 + canvasBox.w * 0.6, y0) + await sleep(600) + + const drawn = await evaluate(ws, DOC_PROBE) + console.log(' 画布着墨像素:' + drawn.dialog.canvasInk) + if (!drawn.dialog.canvasInk) throw new Error('签字板上没有画出内容,无法验证回显') + + console.log(' ' + (await evaluate(ws, clickByText('确认采集')))) + await sleep(1500) + + console.log('\n=== ④ 采集结果探针 ===') + const after = await evaluate(ws, DOC_PROBE) + console.log(JSON.stringify(after, null, 2)) + await shot(ws, '08-signature-applied.png') + + const doctorAfter = after.signatureHosts.find((h) => h.id === 'doctor') + if (!doctorAfter) throw new Error('采集后找不到「医师签名」签名位') + if (!doctorAfter.imageSrcPrefix?.startsWith('data:image/png')) { + throw new Error('「医师签名」没有回显签名图:' + doctorAfter.imageSrcPrefix) + } + if (doctorAfter.action) throw new Error('「医师签名」已采集,却还留着采集入口') + if (doctorAfter.actionableClass) throw new Error('「医师签名」已采集,却还留着可点击样式') + + const patientAfter = after.signatureHosts.find((h) => h.id === 'signature') + if (!patientAfter) throw new Error('找不到「患者签名」签名位') + if (patientAfter.action !== 'signature') { + throw new Error('采集医师签名后,患者签名位的采集入口被误伤') + } + + if (after.footer && after.footer.includes('医师签名')) { + throw new Error('页脚仍在提示「医师签名」待采集:' + after.footer) + } + + console.log('\n=== ⑤ 键盘 Enter 触发「患者签名」 ===') + const focused = await evaluate( + ws, + `(() => { + const host = document.querySelector('[data-sign-field="signature"]') + if (!host) return 'NO_HOST' + host.focus() + return document.activeElement === host ? 'FOCUSED' : 'FOCUS_FAILED' + })()`, + ) + console.log(' ' + focused) + await send(ws, 'Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'Enter', + code: 'Enter', + windowsVirtualKeyCode: 13, + nativeVirtualKeyCode: 13, + }) + await send(ws, 'Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'Enter', + code: 'Enter', + windowsVirtualKeyCode: 13, + nativeVirtualKeyCode: 13, + }) + await sleep(1200) + + const keyboard = await evaluate(ws, DOC_PROBE) + console.log(' 弹窗:' + JSON.stringify(keyboard.dialog && keyboard.dialog.title)) + if (!keyboard.dialog || !String(keyboard.dialog.title).includes('患者签名')) { + throw new Error('键盘 Enter 没有触发「患者签名」的采集入口') + } + await shot(ws, '09-signature-keyboard.png') + + console.log('\n=== 页面运行时错误 ===') + console.log(pageErrors.length ? [...new Set(pageErrors)].slice(0, 20).join('\n') : '无(干净)') + if (pageErrors.length) process.exitCode = 1 + } catch (err) { + console.error('验证失败:', err.message) + process.exitCode = 1 + } finally { + if (ws) ws.close() + chrome.kill() + } +})() diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index 1286539..186b8d0 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -275,16 +275,20 @@ function hydrateTaskDocuments(records: SigningTaskRecord[]): SigningTaskRecord[] next = { ...next, signatureDataUrl: DEMO_SIGNATURE_DATA_URL } } - if (next.documentContentHtml) { - return next - } - const template = templates.get(next.documentId) if (!template?.contentHtml) { return next } + // 已经进入签署流程的任务保留当时固化的正文——那是留痕的一部分,不能事后被换掉。 + // 尚未开始签署的任务则跟着演示种子走:演示数据是持久化在 localStorage 里的, + // 改了 mock-document-library 的种子却还渲染上一轮的正文,表现为「新加的内容看不见」, + // 排查成本很高(本地文档库演示入口已随 documents 开关一起关掉了)。 + if (next.documentContentHtml && !isTaskContentRefreshable(next.status)) { + return next + } + return { ...next, templateVersionId: next.templateVersionId || template.versionId, @@ -293,6 +297,11 @@ function hydrateTaskDocuments(records: SigningTaskRecord[]): SigningTaskRecord[] }) } +/** 正文可跟随演示种子刷新的状态:还没真正开始签署,正文只是待签的快照 */ +function isTaskContentRefreshable(status: SigningTaskStatus): boolean { + return status === 'pending' || status === 'expired' +} + /** * 演示任务记录:优先用本地保存的,刷新后仍保留(任务固化的文书 HTML、签名图都在里面)。 * 所有写操作都要调用 persistMockRecords 落盘,否则刷新会回到切换前的状态。 diff --git a/clinical-web/src/utils/mock-document-library.ts b/clinical-web/src/utils/mock-document-library.ts index 9d3611b..81fb481 100644 --- a/clinical-web/src/utils/mock-document-library.ts +++ b/clinical-web/src/utils/mock-document-library.ts @@ -86,7 +86,10 @@ interface DemoDocumentSpec { /** * 生成演示文书正文。 * 标题、说明段、勾选表、签名位、签署日期都用真实文书的写法, - * 其中 {患者签名}{签署日期} 是签署端识别签名位与日期的依据。 + * 其中 {患者签名}{医师签名}{签署日期} 是签署端识别签名位与日期的依据。 + * + * 患者签名与医师签名是两栏:前者是主签名位(手写板/线上签署采集), + * 后者是文书上的独立签名位,由医师在签署页点击就地采集,两者互不覆盖。 */ function buildDemoDocumentHtml(spec: DemoDocumentSpec): string { const paragraphs = [ @@ -109,6 +112,7 @@ function buildDemoDocumentHtml(spec: DemoDocumentSpec): string { checkboxTable, ...extraFields, '

患者(或家属)签名:{患者签名}

', + '

医师签名:{医师签名}

', '

签署日期:{签署日期}

', ].join('') } diff --git a/clinical-web/src/utils/signing-document.ts b/clinical-web/src/utils/signing-document.ts index 9e2e48e..8c75674 100644 --- a/clinical-web/src/utils/signing-document.ts +++ b/clinical-web/src/utils/signing-document.ts @@ -20,6 +20,7 @@ import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/wo import { createFieldTokenPattern, FIELD_TOKEN_OPEN, + PRIMARY_SIGNATURE_FIELD_ID, resolveAnswersFromSystem, resolveFieldByLabel, SIGNING_DOCUMENT_HEADER, @@ -44,6 +45,20 @@ 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' +/** 需要宿主接管交互的填写项(目前只有「点击采集签名」),值是动作名 */ +const FIELD_ACTION_ATTR = 'data-sign-action' +/** 签名位的采集动作名 */ +const SIGNATURE_ACTION = 'signature' +/** 已采集签名图的类名,readAnswersFromRoot 与写入端都按它找 */ +const SIGNATURE_IMAGE_CLASS = 'sign-doc-signature' +/** 可点击采集的签名位类名,样式与摘除入口时都要用同一个 */ +const ACTIONABLE_CLASS = 'sign-field--actionable' + +/** + * 可点击采集签名的填写项宿主节点选择器。 + * 导出给签署端做事件委托用 —— 渲染与识别必须指向同一个属性,不能两边各写一份字符串。 + */ +export const SIGNATURE_ACTION_SELECTOR = `[${FIELD_ACTION_ATTR}="${SIGNATURE_ACTION}"]` /** * 科室手打的复选框符号见 SIGNING_OPTION_MARKERS(utils/signing-fields): @@ -66,6 +81,25 @@ export interface SigningFieldProgress { missingRequired: string[] /** 取值命中风险的项标签 */ abnormal: string[] + /** + * 仍未采集的签名位标签(不含主签名位「患者签名」)。 + * + * 签名位在文书里可以逐个点击采集(医师签名、家属签名……), + * 主签名位则是手写板 / 线上签署要采集的那一个,把它列进来等于让签名入口挡住自己。 + * 签署端用这份清单在采集前提醒「还有签名位没签」,避免文书带着空签名位被固化。 + */ + pendingSignatures: string[] +} + +/** + * 签署端点击文中签名位后发出的采集请求。 + * + * label 一并带出去,是为了让签字板弹窗能说清「现在采的是谁的名字」—— + * 只传 fieldId 的话弹窗只能显示一个内部键,操作人员看不出这笔签名要落在哪。 + */ +export interface SigningSignatureRequest { + fieldId: string + label: string } function buildFieldWidget( @@ -73,6 +107,7 @@ function buildFieldWidget( field: SigningFieldDef, answers: SigningAnswers, interactive: boolean, + signatureActionable: boolean, ): HTMLElement { const value = answers[field.id] const abnormal = @@ -89,6 +124,18 @@ function buildFieldWidget( host.setAttribute(FIELD_RISK_ATTR, field.risk) } + // 已签名的签名位不再挂动作:留着会让"还能再点一次"的观感落空 + const signaturePending = field.kind === 'signature' && !value + + if (signatureActionable && signaturePending) { + host.classList.add(ACTIONABLE_CLASS) + host.setAttribute(FIELD_ACTION_ATTR, SIGNATURE_ACTION) + // 可点就得可键盘触发,否则只在鼠标下可用 + host.setAttribute('role', 'button') + host.setAttribute('tabindex', '0') + host.setAttribute('title', `点击采集「${field.label}」`) + } + const label = doc.createElement('span') label.className = 'sign-field__label' label.textContent = field.label @@ -104,7 +151,7 @@ function buildFieldWidget( const control = doc.createElement('span') control.className = 'sign-field__control' - control.appendChild(buildFieldControl(doc, field, value, interactive)) + control.appendChild(buildFieldControl(doc, field, value, interactive, signaturePending)) host.appendChild(control) if (abnormal) { @@ -122,25 +169,30 @@ function buildFieldControl( field: SigningFieldDef, value: SigningAnswers[string], interactive: boolean, + signaturePending: boolean, ): Node { const textValue = typeof value === 'string' ? value : value ? '是' : '' if (field.kind === 'signature') { if (textValue) { const image = doc.createElement('img') - image.className = 'sign-doc-signature' + image.className = SIGNATURE_IMAGE_CLASS image.src = textValue image.alt = field.label return image } + // 签名位只写「待签名」:宿主胶囊上已经写着「医师签名」了, + // 这里再写一遍「待医师签名」是重复信息 const empty = doc.createElement('span') empty.className = 'sign-field__signature-empty' - empty.textContent = `待${field.label}` + empty.textContent = signaturePending ? '✍ 点击签名' : '待签名' return empty } - if (field.kind === 'choice') { + // 有选项才渲染单选组;没有选项(结构化占位符只声明了 kind=choice 却没给 options) + // 就退化成文本框,否则科室拿到的是一个点不动的空控件 + if (field.kind === 'choice' && field.options.length) { const group = doc.createElement('span') group.className = 'sign-field__choices' @@ -209,7 +261,12 @@ function buildFieldControl( * 模板未收录的占位符按自定义文本项处理,仍可在文书上填写, * 避免出现"写了占位符但签署时既填不上也看不见"的情况。 */ -function renderTokenFields(root: Element, answers: SigningAnswers, interactive: boolean) { +function renderTokenFields( + root: Element, + answers: SigningAnswers, + interactive: boolean, + signatureActionable: boolean, +) { const doc = root.ownerDocument const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT) const targets: Text[] = [] @@ -236,7 +293,13 @@ function renderTokenFields(root: Element, answers: SigningAnswers, interactive: } fragment.appendChild( - buildFieldWidget(doc, resolveFieldByLabel(match[1] ?? ''), answers, interactive), + buildFieldWidget( + doc, + resolveFieldByLabel(match[1] ?? ''), + answers, + interactive, + signatureActionable, + ), ) cursor = pattern.lastIndex @@ -255,7 +318,12 @@ function renderTokenFields(root: Element, answers: SigningAnswers, interactive: * 该写法是目标格式(字段语义显式、不依赖中文标签), * 供后端模板或后续换编辑器后生产的内容使用。 */ -function renderStructuredFields(root: Element, answers: SigningAnswers, interactive: boolean) { +function renderStructuredFields( + root: Element, + answers: SigningAnswers, + interactive: boolean, + signatureActionable: boolean, +) { const doc = root.ownerDocument root.querySelectorAll('span[data-field]').forEach((element) => { @@ -275,7 +343,7 @@ function renderStructuredFields(root: Element, answers: SigningAnswers, interact risk: element.getAttribute('data-risk') ?? undefined, } - element.replaceWith(buildFieldWidget(doc, field, answers, interactive)) + element.replaceWith(buildFieldWidget(doc, field, answers, interactive, signatureActionable)) }) } @@ -488,6 +556,18 @@ function buildDocumentHeader(doc: Document, answers: SigningAnswers): HTMLElemen return header } +/** 渲染开关。默认全部关闭,只读展示是"安全侧"缺省。 */ +export interface SigningDocumentRenderOptions { + /** + * 签名位是否渲染成「✍ 点击签名」的可点按钮。 + * + * 与 interactive 分开是必须的:文书库的静态预览也是 interactive 的 + * (科室能在预览里试勾选、确认交互效果),但那里没有事件委托宿主, + * 签名位点了不会有任何反应 —— 渲染成可点按钮就是骗人。 + */ + signatureActionable?: boolean +} + /** * 生成签署态文书 HTML。 * contentHtml 为空时返回空串;解析失败时原样返回,不会抛错。 @@ -497,6 +577,7 @@ export function buildSigningDocumentHtml( contentHtml: string, data: SigningDocumentData, interactive = false, + options: SigningDocumentRenderOptions = {}, ): string { if (!contentHtml) { return '' @@ -522,8 +603,10 @@ export function buildSigningDocumentHtml( answers.signature = data.signatureDataUrl } - renderStructuredFields(root, answers, interactive) - renderTokenFields(root, answers, interactive) + const signatureActionable = interactive && Boolean(options.signatureActionable) + + renderStructuredFields(root, answers, interactive, signatureActionable) + renderTokenFields(root, answers, interactive, signatureActionable) const selected = new Set(data.selectedItems ?? []) convertCheckboxes(root, interactive, selected) @@ -574,6 +657,49 @@ export function readAnswersFromRoot(root: HTMLElement): SigningAnswers { return answers } +/** + * 把签名图写进指定签名位,并摘掉它的采集入口。返回是否真的发生了改动。 + * + * 只改这一个宿主节点,不重建整篇文书 —— 整篇重建会换掉 v-html 的 DOM, + * 操作人员正在输入的填写项会连同光标一起丢。写完后由调用方回读 DOM + * (readAnswersFromRoot),文书 DOM 始终是取值的唯一来源。 + * + * 签名图只作为 写进渲染结果,不进模板正文: + * 它是 base64 大字符串,进正文会让模板哈希随签名变化,缓存键跟着失效。 + */ +export function applySignatureToRoot(root: HTMLElement, fieldId: string, dataUrl: string): boolean { + if (!fieldId || !dataUrl) { + return false + } + + const host = root.querySelector(`[${FIELD_ATTR}="${fieldId}"]`) + const control = host?.querySelector('.sign-field__control') + + if (!host || !control) { + return false + } + + // 同一张图重复写入没有意义,返回 false 也让调用方少跑一轮回读 + if (control.querySelector(`img.${SIGNATURE_IMAGE_CLASS}`)?.getAttribute('src') === dataUrl) { + return false + } + + const image = root.ownerDocument.createElement('img') + image.className = SIGNATURE_IMAGE_CLASS + image.src = dataUrl + image.alt = host.getAttribute(FIELD_LABEL_ATTR) ?? fieldId + control.replaceChildren(image) + + // 采集入口用完即摘:留着会让「还能再点一次」的观感落空 + host.classList.remove(ACTIONABLE_CLASS) + host.removeAttribute(FIELD_ACTION_ATTR) + host.removeAttribute('role') + host.removeAttribute('tabindex') + host.removeAttribute('title') + + return true +} + /** * 统计填写项进度:已填数量、未填的必填项、命中的异常项。 * @@ -589,6 +715,7 @@ export function describeFieldProgress( filled: 0, missingRequired: [], abnormal: [], + pendingSignatures: [], } const seen = new Set() @@ -617,6 +744,17 @@ export function describeFieldProgress( progress.missingRequired.push(label) } + // 待采集的签名位 = 挂上了采集动作、却还没采集的那一个(见 buildFieldWidget)。 + // 直接认渲染时打的动作标记,而不是自己再推一遍「kind=signature 且无值」: + // 动作标记本身已经排除了已签名的位,两边判据一致才不会出现 + // 「界面显示不可点、清单却催你点」的矛盾。 + if ( + host.getAttribute(FIELD_ACTION_ATTR) === SIGNATURE_ACTION && + id !== PRIMARY_SIGNATURE_FIELD_ID + ) { + progress.pendingSignatures.push(label) + } + if (risk && value === risk) { progress.abnormal.push(label) } diff --git a/clinical-web/src/utils/signing-fields.ts b/clinical-web/src/utils/signing-fields.ts index 65e67df..89dcd78 100644 --- a/clinical-web/src/utils/signing-fields.ts +++ b/clinical-web/src/utils/signing-fields.ts @@ -47,6 +47,20 @@ export function createFieldTokenPattern() { return new RegExp(FIELD_TOKEN_SOURCE, 'g') } +/** + * 主签名位字段键(患者签名)。 + * + * 一个签署任务只有一份「签名图」产物,接口与任务记录都把它固定落在 signature 上 + * (见 api/workbench/signing 的 completeSigningTask:`{ ...fieldAnswers, signature }`)。 + * 其余签名位(如医师签名)各自存在 fieldAnswers 里,互不覆盖。 + * + * 这条约定有两个用处: + * ① 手写板 / 线上签署采集的就是主签名位,因此它不能算「待补的签名位」, + * 否则签名入口会把自己挡住; + * ② 判断"还有别的签名位没签"时要排除它。 + */ +export const PRIMARY_SIGNATURE_FIELD_ID = 'signature' + /** * 填写项目录:key 为字段键,label 是文书里显示的文字,kind 决定签署时的控件。 * id 与 label 的对应关系由这里统一维护,渲染端与编辑器共用同一份,避免两处各自演化。 diff --git a/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue b/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue index 041f23f..ae5463e 100644 --- a/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue +++ b/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue @@ -52,6 +52,7 @@ const signingProgress = ref({ filled: 0, missingRequired: [], abnormal: [], + pendingSignatures: [], }) const signingBodyRef = ref(null) @@ -69,7 +70,13 @@ watch( if (visible) { activeTab.value = 'content' signingSelection.value = [] - signingProgress.value = { total: 0, filled: 0, missingRequired: [], abnormal: [] } + signingProgress.value = { + total: 0, + filled: 0, + missingRequired: [], + abnormal: [], + pendingSignatures: [], + } } }, ) diff --git a/clinical-web/src/views/workbench/signing/components/SignaturePadDialog.vue b/clinical-web/src/views/workbench/signing/components/SignaturePadDialog.vue index e376663..22c5694 100644 --- a/clinical-web/src/views/workbench/signing/components/SignaturePadDialog.vue +++ b/clinical-web/src/views/workbench/signing/components/SignaturePadDialog.vue @@ -1,21 +1,40 @@