Files
xh-medical-sign-web/clinical-web/scripts/verify-signature-click.cjs
T

421 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 签署页「点击文中签名位采集签名」的渲染与交互验证。
*
* 验证目标(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:<port> node scripts/login.cjs # 抓验证码
* VERIFY_BASE_URL=http://127.0.0.1:<port> node scripts/login.cjs 1234 # 登录
* 3. VERIFY_URL=http://127.0.0.1:<port>/ 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/<you>/.workbuddy-ai/binaries/node/workspace')
console.error(' <node>/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()
}
})()