/** * 签署页「点击文中签名位采集签名」的渲染与交互验证。 * * 验证目标(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() } })()