&& 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 @@