Files
xh-medical-sign-web/clinical-web/scripts/verify-editor-marks.cjs
T
yelan b967077873 fix(signing-editor-marks): 统一占位符样式并修复删除后 DOM 残留
自定义渲染器渲染钩子改走 data.attrs 挂类名与 title:data.class 会被
wangEditor 的规范化函数搬进 props 且到不了 class 模块(胶囊不渲染),
data.props.className 则因 props 模块只单向写入而摘不掉旧值,导致勾选框
退格删除后 DOM 仍残留 ms-sign-box 空壳。

同时移除必填标记相关的红角标、签名位专属橙色、插入按钮与「占位符检查」
面板里的 `*` / 「必填」文案,所有占位符共用一个样式。

verify-editor-marks.cjs 增加断言台账(PASS/FAIL 并反映退出码)、叶子结构
探针与退格删除回归,README 补充「踩过的坑 · 5」说明渲染通道的选择。
2026-09-18 16:40:08 +08:00

529 lines
20 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-editor-marks + EditorWangPane + WordEditorDialog):
* ① {患者姓名} 在编辑区渲染成带类型角标的胶囊,□ 渲染成勾选胶囊,
* 而不是和正文一样的裸文字;
* ② 所有占位符**共用一个样式**:胶囊之间配色/边框完全一致,没有「必填」红角标,
* 没有签名位专属的橙色,插入按钮与「占位符检查」面板也不再出现 `*` / 「必填」;
* ③ getHtml() 输出不变——用页面自己的「占位符检查」面板做探针
* (它读的就是序列化后的 HTML 字符串),面板能数出填写项即证明底层仍是 {…} 纯文本;
* ④ 光标落点没被装饰破坏:点进胶囊中部后插入字符,字符应落在占位符内部;
* ⑤ **删干净**:插入勾选框后按退格,编辑区 DOM 里不能残留任何 `ms-sign-*` 元素。
* 这一条是回归重点,也是最容易反复踩的地方——渲染通道选错会以两种方式坏掉:
* · 走 `data.props.className`:挂得上,但 props 模块只单向写入、从不清理旧 prop,
* 内容删了类名还挂在空叶子上(表现为擦不掉的浅蓝小方块);
* · 走 `data.class`:wangEditor 的规范化函数(`RT`)白名单里没有 `class`,
* 会被搬进 `data.props` 并删掉原键,class 模块读不到 → 类名压根不渲染。
* 正确通道是 `data.attrs`(在白名单里,且模块自带 removeAttribute)。
* 详见 signing-editor-marks.ts 的函数注释与 scripts/README.md「踩过的坑 · 5」。
*
* 用法:
* 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-editor-marks.cjs
*
* 依赖 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 || 9451)
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-marks-' + 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 zoomShot(ws, selector, name, scale = 3) {
const rect = await evaluate(
ws,
`(() => {
const el = document.querySelector(${JSON.stringify(selector)})
if (!el) return null
const r = el.getBoundingClientRect()
return { x: r.left, y: r.top, width: r.width, height: r.height }
})()`,
)
if (!rect) {
console.log(' 放大截图跳过:找不到 ' + selector)
return
}
await shot(ws, name, { ...rect, scale })
}
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 EDITOR_PROBE = `(() => {
const root = document.querySelector('.pane-content')
if (!root) return { error: 'NO_EDITOR' }
const style = (el, pseudo) => getComputedStyle(el, pseudo)
const chips = [...root.querySelectorAll('.ms-sign-chip')]
const braces = [...root.querySelectorAll('.ms-sign-brace')]
const boxes = [...root.querySelectorAll('.ms-sign-box')]
const panel = document.querySelector('.placeholder-check')
return {
editorText: root.innerText.replace(/\\s+/g, ' ').trim(),
chips: chips.map((c) => ({
text: c.textContent,
tag: c.dataset.signTag || null,
kind: c.dataset.signKind || null,
display: style(c).display,
bg: style(c).backgroundColor,
color: style(c).color,
border: style(c).borderTopWidth + ' ' + style(c).borderTopColor,
afterContent: style(c, '::after').content,
afterColor: style(c, '::after').color,
title: c.getAttribute('title'),
cls: c.className,
})),
braceCount: braces.length,
braceTexts: braces.map((b) => b.textContent),
braceSample: braces[0] ? {
fontSize: style(braces[0]).fontSize,
opacity: style(braces[0]).opacity,
color: style(braces[0]).color,
} : null,
boxes: boxes.map((b) => ({
text: b.textContent,
bg: style(b).backgroundColor,
border: style(b).borderTopWidth,
title: b.getAttribute('title'),
cls: b.className,
})),
panelText: panel ? panel.innerText.replace(/\\s+/g, ' ').trim() : null,
panelItems: panel ? [...panel.querySelectorAll('.placeholder-check__item')].map((e) => e.textContent.replace(/\\s+/g, ' ').trim()) : [],
insertButtons: [...document.querySelectorAll('.placeholder-chip')].map((b) => b.textContent.trim()),
}
})()`
/**
* 叶子结构探针:把编辑区每个段落的叶子原样 dump 出来。
* 用来抓「数据已删、DOM 里类名还挂着」的残留——只看 querySelectorAll('.ms-sign-box')
* 会漏掉那些类名被挂在 zero-width 空叶子上的情况。
*/
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 {
blocks: blocks.map((el) => ({
tag: el.tagName,
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,
title: span ? span.getAttribute('title') : null,
outer: span ? span.outerHTML : null,
}
}),
})),
}
})()`
const SELECTION_PROBE = `(() => {
const sel = window.getSelection()
return {
collapsed: sel.isCollapsed,
anchorNodeText: sel.anchorNode ? sel.anchorNode.textContent : null,
anchorOffset: sel.anchorOffset,
focusNodeText: sel.focusNode ? sel.focusNode.textContent : null,
focusOffset: sel.focusOffset,
}
})()`
async function mouse(ws, type, x, y) {
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',
})
}
async function key(ws, k, code, vk) {
for (const type of ['keyDown', 'keyUp']) {
await send(ws, 'Input.dispatchKeyEvent', {
type,
key: k,
code,
windowsVirtualKeyCode: vk,
nativeVirtualKeyCode: vk,
})
}
}
const pageErrors = []
/** 断言台账:脚本不做「只打印不判断」,结论必须一眼可读 */
const checks = []
function check(name, ok, detail) {
checks.push({ name, ok: !!ok })
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`)
}
;(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, '/management/documents', 6000)
console.log('\n=== 打开 Word 编辑器 ===')
console.log(' ' + (await evaluate(ws, clickByText('新增文档'))))
await sleep(1500)
const dialog = await evaluate(
ws,
`({
mask: document.querySelectorAll('.dialog-mask').length,
hasEditor: !!document.querySelector('.pane-content [data-slate-editor]'),
})`,
)
console.log(' ' + JSON.stringify(dialog))
if (!dialog.hasEditor) throw new Error('Word 编辑器没有打开')
console.log('\n=== 插入占位符与勾选框 ===')
// 勾选框必须最后插入:退格是逐字符删,只有让 □ 成为最后一个字符,
// 才会走到「叶子变空」那条路径——残留 bug 只在那条路径上出现。
const INSERT_LABELS = ['患者签名', '签署日期', '患者姓名', '二选一 □是 □否', '勾选框 □']
for (const label of INSERT_LABELS) {
console.log(
' ' + label + ' -> ' + (await evaluate(ws, clickByText(label, '.placeholder-chip'))),
)
await sleep(400)
}
await sleep(1200)
console.log('\n=== 编辑区渲染探针 ===')
const probe = await evaluate(ws, EDITOR_PROBE)
console.log(JSON.stringify(probe, null, 2))
await shot(ws, '04-editor-marks.png')
await zoomShot(ws, '.pane-content [data-slate-editor] p', '04b-editor-marks-zoom.png', 4)
console.log('\n=== 断言:胶囊渲染与样式统一 ===')
check(
'占位符渲染成胶囊(.ms-sign-chip ≥ 1)',
probe.chips.length >= 1,
`${probe.chips.length} 个`,
)
check(
'勾选框渲染成胶囊(.ms-sign-box ≥ 1)',
probe.boxes.length >= 1,
`${probe.boxes.length} 个`,
)
check(
'每个胶囊都有类型角标(::after 有文案)',
probe.chips.length > 0 &&
probe.chips.every(
(c) => c.afterContent && c.afterContent !== 'none' && c.afterContent !== '""',
),
probe.chips.map((c) => `${c.text}=${c.afterContent}`).join(' | '),
)
const skin = (c) => `${c.bg}/${c.color}/${c.border}/${c.afterColor}`
const skins = [...new Set(probe.chips.map(skin))]
check(
'所有占位符共用一个样式(配色/边框/角标色完全一致)',
probe.chips.length >= 2 && skins.length === 1,
skins.length === 1 ? skins[0] : `出现 ${skins.length} 种:${skins.join(' ;; ')}`,
)
check(
'胶囊上不再有必填标记(无 data-sign-required、无 `*` 角标)',
probe.chips.every(
(c) => !c.cls.includes('required') && !String(c.afterContent).includes('*'),
),
probe.chips.map((c) => c.cls).join(' | '),
)
check(
'插入按钮文字不再带 `*`',
probe.insertButtons.every((t) => !t.includes('*')),
probe.insertButtons.join(' / '),
)
check(
'「占位符检查」面板不再出现「必填」',
probe.panelItems.every((t) => !t.includes('必填')),
probe.panelItems.join(' / '),
)
check(
'底层仍是 {…} 纯文本(面板能数出填写项)',
/个填写项/.test(probe.panelText || '') && !/0 个填写项/.test(probe.panelText || ''),
probe.panelText,
)
// 删除测试必须紧跟插入、且在光标测试之前:
// 插入后光标就在文末,退格才删得到最后那个 □;
// 光标测试会往胶囊里插字符,之后光标不在文末,退格删的就是别的字符了。
console.log('\n=== 断言:删掉勾选框后 DOM 不残留 ===')
console.log(' 按一次退格(删掉最后插入的 □)')
await key(ws, 'Backspace', 'Backspace', 8)
await sleep(1000)
const leaves = await evaluate(ws, LEAF_PROBE)
const afterDelete = await evaluate(ws, EDITOR_PROBE)
const allLeaves = leaves.blocks.flatMap((b) => b.leaves)
// 残留的确切签名:叶子已经空了(zero-width 或空文本),类名却还挂着
const residue = allLeaves.filter(
(l) => l.cls && l.cls.includes('ms-sign-') && (l.zeroWidth || !l.text),
)
const countBoxes = (text) => ((text || '').match(/□/g) || []).length
console.log(
' [退格后胶囊] ' +
JSON.stringify({
chips: afterDelete.chips.map((c) => c.text),
braces: afterDelete.braceTexts,
boxes: afterDelete.boxes.map((b) => b.text),
}),
)
console.log(
' [退格后叶子] ' + JSON.stringify(allLeaves.map((l) => ({ cls: l.cls, codes: l.codes }))),
)
await shot(ws, '05b-editor-marks-delete.png')
check(
'勾选框已被删除(.ms-sign-box 比插入时少一个)',
afterDelete.boxes.length === probe.boxes.length - 1,
`${probe.boxes.length} -> ${afterDelete.boxes.length}`,
)
check(
'勾选框字符已被删除(正文 □ 比插入时少一个)',
countBoxes(afterDelete.editorText) === countBoxes(probe.editorText) - 1,
`${countBoxes(probe.editorText)} -> ${countBoxes(afterDelete.editorText)}`,
)
check(
'DOM 里的勾选胶囊数与正文 □ 数一致(没有多出来的空壳)',
afterDelete.boxes.length === countBoxes(afterDelete.editorText),
`boxes=${afterDelete.boxes.length} / □=${countBoxes(afterDelete.editorText)}`,
)
check(
'空叶子上没有残留 ms-sign-* 类名',
residue.length === 0,
residue.length ? residue.map((l) => `${l.cls}:${l.codes}`).join(' | ') : '干净',
)
console.log('\n=== 光标落点(点进胶囊中部后插入 X) ===')
const chipBox = await evaluate(
ws,
`(() => {
const chip = document.querySelector('.ms-sign-chip')
if (!chip) return null
const r = chip.getBoundingClientRect()
return { x: r.left, y: r.top, w: r.width, h: r.height }
})()`,
)
if (chipBox) {
const cx = chipBox.x + chipBox.w * 0.3
const cy = chipBox.y + chipBox.h / 2
await mouse(ws, 'mousePressed', cx, cy)
await mouse(ws, 'mouseReleased', cx, cy)
await sleep(500)
console.log(' 点击后选中态: ' + JSON.stringify(await evaluate(ws, SELECTION_PROBE)))
await send(ws, 'Input.insertText', { text: 'X' })
await sleep(600)
const after = await evaluate(ws, EDITOR_PROBE)
console.log(' 插入 X 后正文: ' + after.editorText)
console.log(' 插入 X 后填写项面板: ' + after.panelText)
await shot(ws, '05-editor-marks-caret.png')
console.log('\n=== 断言:装饰没有破坏光标偏移 ===')
check(
'插入的字符落在占位符内部',
/{[^}]*X[^}]*}/.test(after.editorText),
after.editorText,
)
} else {
check('插入的字符落在占位符内部', false, '找不到可点的胶囊')
}
console.log('\n=== 页面运行时错误 ===')
console.log(pageErrors.length ? [...new Set(pageErrors)].slice(0, 20).join('\n') : '无(干净)')
const failed = checks.filter((c) => !c.ok)
console.log(`\n=== 结论:${checks.length - failed.length}/${checks.length} 通过 ===`)
if (failed.length) {
failed.forEach((c) => console.log(' 未通过:' + c.name))
process.exitCode = 1
}
} catch (err) {
console.error('验证失败:', err.message)
process.exitCode = 1
} finally {
if (ws) ws.close()
chrome.kill()
}
})()