feat(signing): 支持点击文中签名位就地采集签名

This commit is contained in:
yelan
2026-09-18 13:03:25 +08:00
parent 9062d0b3c5
commit 0d3eb2b9ad
13 changed files with 1173 additions and 41 deletions
+35
View File
@@ -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`(键盘触发)。
## 踩过的坑
改脚本前先读这段,都是实际踩出来的。
@@ -0,0 +1,247 @@
/**
* 临时复现脚本:插入勾选框后删除,看编辑器 DOM / 数据里有没有残留。
* 用法:
* node scripts/login.cjs <code> && 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()
}
})()
@@ -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:<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()
}
})()