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」说明渲染通道的选择。
This commit is contained in:
+100
-11
@@ -62,12 +62,13 @@ VERIFY_URL=http://127.0.0.1:5199/ node scripts/verify-pages.cjs
|
||||
## verify-editor-marks.cjs — 占位符 / 勾选框胶囊验证
|
||||
|
||||
**为什么需要**:占位符胶囊(`utils/signing-editor-marks`)是**只改渲染、不改数据**的实现——
|
||||
它靠 wangEditor 的 `decorate` 把文本切成叶子再挂样式。这种写法有两类问题编译期完全看不出来:
|
||||
它靠 wangEditor 的 `decorate` 把文本切成叶子再挂样式。这种写法有三类问题编译期完全看不出来:
|
||||
|
||||
- 叶子切分 / 样式挂载没生效 → 胶囊根本没渲染出来(看起来"功能没做");
|
||||
- 装饰破坏了 Slate 的光标偏移计算 → 点击胶囊后光标落点错位、编辑时字符插到别处。
|
||||
- 装饰破坏了 Slate 的光标偏移计算 → 点击胶囊后光标落点错位、编辑时字符插到别处;
|
||||
- 装饰挂上去容易、**摘下来难** → 内容删了,DOM 上的类名还赖着不走(见下方「踩过的坑 · 5」)。
|
||||
|
||||
所以必须真实打开编辑器,一边看渲染一边点进去打字。
|
||||
所以必须真实打开编辑器,一边看渲染一边点进去打字、删字符。
|
||||
|
||||
**用法**(登录步骤同 `verify-pages.cjs`,注意端口要和实际 dev server 一致):
|
||||
|
||||
@@ -77,18 +78,25 @@ node scripts/login.cjs 8944 # 登录
|
||||
VERIFY_URL=http://127.0.0.1:5174/ node scripts/verify-editor-marks.cjs
|
||||
```
|
||||
|
||||
脚本会:打开文档库 → 新增文档(弹出 Word 编辑器)→ 依次插入 3 个占位符与 2 个勾选框 →
|
||||
输出编辑区探针(胶囊数量/文案/配色/类型角标)与「占位符检查」面板文案 →
|
||||
点进胶囊中部插入一个 `X`,验证字符确实落在占位符内部。
|
||||
脚本会:打开文档库 → 新增文档(弹出 Word 编辑器)→ 依次插入 5 个占位符与勾选框 →
|
||||
输出编辑区探针与「占位符检查」面板文案 → 点进胶囊中部插入一个 `X` → 按退格删掉最后插入的
|
||||
勾选框 → 再 dump 一遍叶子结构 → 打印 PASS/FAIL 台账并以退出码反映结果。
|
||||
|
||||
**两个关键断言**:
|
||||
**断言清单**(全通过才算过,失败会列出未通过项并 `exit 1`):
|
||||
|
||||
- `panelText` 显示「N 个填写项」——面板读的就是 `editor.getHtml()` 的序列化结果,
|
||||
能数出填写项即证明**底层仍是 `{…}` 纯文本**,保存 / 签署链路的口径没被改动;
|
||||
- 点进胶囊后插入 `X` 得到 `{患者X签名}`——证明装饰没有破坏光标偏移计算。
|
||||
| 断言 | 它防的是什么 |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- |
|
||||
| 占位符 / 勾选框都渲染成胶囊 | 装饰完全没生效,功能像没做 |
|
||||
| 每个胶囊都有类型角标(`::after` 有文案) | 角标挂错通道(`content: attr(...)` 拿不到值) |
|
||||
| 所有胶囊配色 / 边框 / 角标色**完全一致** | 又冒出「必填红」「签名位橙」这类分色分支 |
|
||||
| 胶囊与插入按钮上都没有 `*`、面板里没有「必填」 | 必填标记从别的地方漏回来 |
|
||||
| 面板能数出「N 个填写项」(N≠0) | 装饰把序列化结果改脏了,保存 / 签署链路口径被改 |
|
||||
| 点进胶囊后插入 `X` 得到 `{…X…}` | 装饰破坏了 Slate 的光标偏移计算 |
|
||||
| 退格后无 `.ms-sign-box`、无叶子残留 `ms-sign-*` 类名 | **本轮回归重点**:内容删了类名还挂着 |
|
||||
| 退格后正文不再有 `□` | 数据层确实删掉了(区别于「只有 DOM 脏」) |
|
||||
|
||||
截图:`04-editor-marks.png`(整窗)、`04b-editor-marks-zoom.png`(编辑区 4 倍放大,用来核对胶囊细节)、
|
||||
`05-editor-marks-caret.png`(插入字符后)。
|
||||
`05-editor-marks-caret.png`(插入字符后)、`05b-editor-marks-delete.png`(退格后)。
|
||||
|
||||
## verify-signature-click.cjs — 点击文中签名位采集签名
|
||||
|
||||
@@ -167,6 +175,87 @@ document.getElementById('app').__vue_app__.config.globalProperties.$router
|
||||
`package.json` 里有 `"type": "module"`,`.js` 会被当成 ES 模块,
|
||||
而这里用 `require` 更简单,所以显式声明成 CommonJS。
|
||||
|
||||
### 5. 自定义渲染器里改类名 / 属性,只能走 `data.attrs`
|
||||
|
||||
这条是排查「插入勾选框后删不干净」时挖出来的,两个坑叠在一起,值得单独记一笔。
|
||||
|
||||
#### 坑一:`data.class` 会被规范化函数搬走,根本到不了 class 模块
|
||||
|
||||
wangEditor 在渲染前会用一个内部函数(压缩后叫 `RT`)**递归重写整棵 vnode 树的 data**:
|
||||
|
||||
```js
|
||||
var LT = ["props", "attrs", "style", "dataset", "on", "hook"] // 保留键白名单
|
||||
|
||||
function RT(vnode) {
|
||||
var data = vnode.data || {}
|
||||
Object.keys(data).forEach(function (key) {
|
||||
if (key === 'key') { vnode.key = data[key]; return }
|
||||
if (LT.includes(key)) return // 白名单里的原样透传
|
||||
if (key.startsWith('data-')) {
|
||||
Object.assign(vnode.data.dataset, …) // data-* 归入 dataset
|
||||
} else {
|
||||
Object.assign(vnode.data.props, …) // 其余一律塞进 props
|
||||
}
|
||||
delete vnode.data[key] // 原键被删掉
|
||||
})
|
||||
vnode.children.forEach(RT) // 递归
|
||||
}
|
||||
```
|
||||
|
||||
`class` **不在白名单里**,所以 `data.class = { 'ms-sign-chip': true }` 会被搬成
|
||||
`data.props.class` 并 `delete data.class` —— snabbdom 的 class 模块读的是 `data.class`,
|
||||
读到 `undefined` 就什么都不做。**结果是类名压根不出现在 DOM 上,胶囊整个不渲染。**
|
||||
|
||||
(`attrs` 与 `dataset` 都在白名单里,能原样透传。)
|
||||
|
||||
#### 坑二:`data.props` 只单向写入,旧值摘不掉
|
||||
|
||||
wangEditor 内嵌的 snabbdom 用 `fg([yg, Ag, Tg, wg, xg, gg])` 初始化,六个模块依次是
|
||||
**class / style / dataset / eventlisteners / props / attrs**。其中 `props` 模块:
|
||||
|
||||
```js
|
||||
function propsModule(oldVnode, vnode) {
|
||||
for (key in newProps) {
|
||||
old === cur || (elm[key] = cur)
|
||||
} // 只遍历「新」props
|
||||
}
|
||||
```
|
||||
|
||||
它从不清理已经消失的旧 prop;而 `className` / `title` 在 DOM 元素上是原型访问器,
|
||||
`delete elm.className` 是个空操作。所以类名一旦通过 `props` 挂上去,就**再也摘不掉**:
|
||||
勾选框被退格删掉后,那个叶子已经空了、`decorate` 也早已不再返回它,
|
||||
DOM 里却还留着 `<span class="ms-sign-box" data-slate-zero-width="n">` ——
|
||||
编辑器里表现为一个擦不掉的浅蓝小方块。
|
||||
|
||||
注意这两条是**先后被踩**的:最早用 `props.className`(渲染出来了,但删不掉),
|
||||
改成 `data.class`(删得掉,但压根渲染不出来),最后才落到 `data.attrs`。
|
||||
|
||||
| 通道 | 挂得上吗 | 摘得掉吗 | 结论 |
|
||||
| ---------------------- | ------------------ | -------- | -------------------------- |
|
||||
| `data.props.className` | ✅ | ❌ | 会留下擦不掉的残留 |
|
||||
| `data.class` | ❌(被 `RT` 搬走) | — | 完全不渲染 |
|
||||
| `data.dataset` | ✅ | ✅ | 可用,适合放 `data-*` 标记 |
|
||||
| **`data.attrs`** | ✅ | ✅ | **类名与 title 走这里** |
|
||||
|
||||
`attrs` 模块两头都正常:
|
||||
|
||||
```js
|
||||
for (n in newAttrs) oldAttrs[n] !== v ? setAttribute(n, v) : …
|
||||
for (n in oldAttrs) n in newAttrs || removeAttribute(n) // ← 摘得掉
|
||||
```
|
||||
|
||||
#### 现象为什么长成「编辑器有问题、签署没问题」
|
||||
|
||||
`decorate` 侧其实一直是对的(数据层干净、签署侧按文本解析也一直正常),
|
||||
出问题的只有渲染通道。用户的原话「在编辑中显示侧,签署的时候没问题」,
|
||||
正是这个机制决定的:**问题不在数据,在渲染通道。**
|
||||
|
||||
排查手法:反编译 `node_modules/@wangeditor/editor/dist/index.esm.js`,
|
||||
搜 `registerRenderStyle` / `fg([` / `removeAttribute` / `classList.remove`,
|
||||
把 snabbdom 的六个模块和 `RT` 的白名单逐个打印出来看。
|
||||
|
||||
对应的回归断言在 `verify-editor-marks.cjs` 的「删掉勾选框后 DOM 不残留」一段。
|
||||
|
||||
## 安全提醒
|
||||
|
||||
`scripts/.session.json` 里是**真实 token**,已在 `.gitignore` 中忽略,**不要提交**。
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* 临时复现脚本:插入勾选框后删除,看编辑器 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()
|
||||
}
|
||||
})()
|
||||
@@ -1,12 +1,22 @@
|
||||
/**
|
||||
* 编辑器占位符 / 勾选框胶囊的渲染验证。
|
||||
*
|
||||
* 验证目标(utils/signing-editor-marks + EditorWangPane):
|
||||
* ① {患者姓名} 在编辑区渲染成带类型角标的胶囊,而不是和正文一样的裸文字;
|
||||
* ② □ 渲染成勾选胶囊;
|
||||
* 验证目标(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
|
||||
@@ -181,13 +191,14 @@ const EDITOR_PROBE = `(() => {
|
||||
text: c.textContent,
|
||||
tag: c.dataset.signTag || null,
|
||||
kind: c.dataset.signKind || null,
|
||||
required: c.dataset.signRequired || 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,
|
||||
title: c.title,
|
||||
afterColor: style(c, '::after').color,
|
||||
title: c.getAttribute('title'),
|
||||
cls: c.className,
|
||||
})),
|
||||
braceCount: braces.length,
|
||||
braceTexts: braces.map((b) => b.textContent),
|
||||
@@ -200,10 +211,40 @@ const EDITOR_PROBE = `(() => {
|
||||
text: b.textContent,
|
||||
bg: style(b).backgroundColor,
|
||||
border: style(b).borderTopWidth,
|
||||
title: b.title,
|
||||
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,
|
||||
}
|
||||
}),
|
||||
})),
|
||||
}
|
||||
})()`
|
||||
|
||||
@@ -230,8 +271,27 @@ async function mouse(ws, type, x, y) {
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -295,7 +355,10 @@ const pageErrors = []
|
||||
if (!dialog.hasEditor) throw new Error('Word 编辑器没有打开')
|
||||
|
||||
console.log('\n=== 插入占位符与勾选框 ===')
|
||||
for (const label of ['患者签名', '签署日期', '患者姓名', '勾选框 □', '二选一']) {
|
||||
// 勾选框必须最后插入:退格是逐字符删,只有让 □ 成为最后一个字符,
|
||||
// 才会走到「叶子变空」那条路径——残留 bug 只在那条路径上出现。
|
||||
const INSERT_LABELS = ['患者签名', '签署日期', '患者姓名', '二选一 □是 □否', '勾选框 □']
|
||||
for (const label of INSERT_LABELS) {
|
||||
console.log(
|
||||
' ' + label + ' -> ' + (await evaluate(ws, clickByText(label, '.placeholder-chip'))),
|
||||
)
|
||||
@@ -309,6 +372,107 @@ const pageErrors = []
|
||||
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,
|
||||
@@ -326,7 +490,7 @@ const pageErrors = []
|
||||
await mouse(ws, 'mousePressed', cx, cy)
|
||||
await mouse(ws, 'mouseReleased', cx, cy)
|
||||
await sleep(500)
|
||||
console.log(' 点击前选中态: ' + JSON.stringify(await evaluate(ws, SELECTION_PROBE)))
|
||||
console.log(' 点击后选中态: ' + JSON.stringify(await evaluate(ws, SELECTION_PROBE)))
|
||||
|
||||
await send(ws, 'Input.insertText', { text: 'X' })
|
||||
await sleep(600)
|
||||
@@ -334,10 +498,26 @@ const pageErrors = []
|
||||
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
|
||||
|
||||
@@ -57,7 +57,6 @@ interface SigningLeafMark {
|
||||
signKind?: string
|
||||
/** 控件类型的中文名,渲染在胶囊右侧 */
|
||||
signTag?: string
|
||||
signRequired?: boolean
|
||||
/** 是否目录外的自定义文本项(现场手填),决定悬浮提示里说"自动填充"还是"现场填写" */
|
||||
signCustom?: boolean
|
||||
/** 原文,如 {患者姓名},用于悬浮提示 */
|
||||
@@ -99,7 +98,6 @@ export function decorateSigningMarkers([node, path]: DecoratedEntry): DecoratedR
|
||||
signMarker: 'field',
|
||||
signKind: field.kind,
|
||||
signTag: SIGNING_FIELD_KIND_LABELS[field.kind] ?? '文本',
|
||||
signRequired: field.required,
|
||||
signCustom: isCustomFieldId(field.id),
|
||||
signRaw: text.slice(start, end),
|
||||
}
|
||||
@@ -150,12 +148,44 @@ export function decorateSigningMarkers([node, path]: DecoratedEntry): DecoratedR
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染钩子:给带标记的叶子挂上类名与 data-*,样式见 EditorWangPane。
|
||||
* 渲染钩子:给带标记的叶子挂上类名与属性,样式见 EditorWangPane。
|
||||
*
|
||||
* 直接改传入的 vnode.data 而不新建 vnode:新建需要 snabbdom 的 h,
|
||||
* 而 snabbdom 是 wangEditor 的内部依赖,不该由业务代码直接引用。
|
||||
* data.props / data.dataset 是 vnode 的规范字段(见 core 的 normalizeVnodeData),
|
||||
* 改它们不会被后续的归一化覆盖。
|
||||
*
|
||||
* **类名与 title 必须走 data.attrs。** 这一条是踩出来的,两个坑叠在一起:
|
||||
*
|
||||
* ① `data.class` 根本到不了 snabbdom 的 class 模块。
|
||||
* wangEditor 在渲染前会用一个规范化函数(`RT`)递归重写 vnode.data:
|
||||
*
|
||||
* var LT = ["props","attrs","style","dataset","on","hook"] // 白名单
|
||||
* Object.keys(data).forEach(key => {
|
||||
* if (LT.includes(key)) return
|
||||
* if (key.startsWith("data-")) { 移到 data.dataset; delete data[key] }
|
||||
* else { 合并进 data.props; delete data[key] }
|
||||
* })
|
||||
*
|
||||
* `class` 不在白名单里,于是 `data.class` 被搬成 `data.props.class` 并删掉原键,
|
||||
* class 模块永远读不到它 —— 类名压根不会出现在 DOM 上(胶囊整个不渲染)。
|
||||
* 注意 `data.dataset` 与 `data.attrs` 都在白名单里,能原样透传。
|
||||
*
|
||||
* ② `data.props` 只单向写入,摘不掉旧值。
|
||||
*
|
||||
* function propsModule(oldVnode, vnode) {
|
||||
* for (key in newProps) { old === cur || (elm[key] = cur) }
|
||||
* }
|
||||
*
|
||||
* 它只遍历**新** props,从不清理已经消失的旧 prop;而 className / title 都是
|
||||
* 原型上的访问器,`delete elm.className` 是个空操作。于是 props 里的类名一旦
|
||||
* 挂上就再也摘不掉:勾选框被退格删掉后,那个叶子已经空了、decorate 也早已不再
|
||||
* 返回它,DOM 里却还留着 ms-sign-box —— 编辑器里表现为一个擦不掉的浅蓝小方块
|
||||
* (签署侧按文本解析,文本确实空了,所以那边一直是对的)。
|
||||
*
|
||||
* `attrs` 模块两头都正常,所以统一走它:
|
||||
*
|
||||
* create/update 都执行 setAttribute,并且有 `for (key in oldAttrs) key in newAttrs || removeAttribute(key)`
|
||||
*
|
||||
* 于是叶子从"有标记"变成"无标记"时,类名会被真正摘掉。
|
||||
*/
|
||||
function renderSigningMarker(node: StyledNode, vnode: StyledVNode): StyledVNode {
|
||||
const mark = node as unknown as SigningLeafMark
|
||||
@@ -165,14 +195,16 @@ function renderSigningMarker(node: StyledNode, vnode: StyledVNode): StyledVNode
|
||||
}
|
||||
|
||||
const data = (vnode.data ??= {})
|
||||
const props = (data.props ??= {})
|
||||
const dataset = (data.dataset ??= {})
|
||||
|
||||
dataset.signMarker = mark.signMarker
|
||||
|
||||
if (mark.signMarker === 'box') {
|
||||
props.className = 'ms-sign-box'
|
||||
props.title = '勾选项:签署时转为可勾选控件'
|
||||
setAttrs(data, {
|
||||
class: 'ms-sign-box',
|
||||
title: '勾选项:签署时转为可勾选控件',
|
||||
})
|
||||
|
||||
return vnode
|
||||
}
|
||||
|
||||
@@ -184,27 +216,35 @@ function renderSigningMarker(node: StyledNode, vnode: StyledVNode): StyledVNode
|
||||
// 提示必须分开说:目录里的字段签署时自动填充,自定义文本项是留给现场手填的空框。
|
||||
// 一律写成"自动填充",科室会以为写了占位符就有人替他填。
|
||||
const source = mark.signCustom ? '签署时由患者/家属现场填写' : '签署时按患者与就诊信息自动填充'
|
||||
props.className = 'ms-sign-chip'
|
||||
props.title = `待填写项:${mark.signRaw ?? ''}(${kind}${mark.signRequired ? ',必填' : ''}),${source}`
|
||||
|
||||
setAttrs(data, {
|
||||
class: 'ms-sign-chip',
|
||||
title: `待填写项:${mark.signRaw ?? ''}(${kind}),${source}`,
|
||||
})
|
||||
|
||||
if (mark.signTag) {
|
||||
dataset.signTag = mark.signTag
|
||||
}
|
||||
|
||||
if (mark.signRequired) {
|
||||
dataset.signRequired = 'true'
|
||||
}
|
||||
|
||||
return vnode
|
||||
}
|
||||
|
||||
// 大括号只缩小调淡,不移除:见文件头的"硬约束"。
|
||||
// 左右分开给类名,样式里才能各收各的边距(全角括号两侧留白很宽)
|
||||
props.className = `ms-sign-brace ms-sign-brace--${mark.signPart ?? 'open'}`
|
||||
setAttrs(data, { class: `ms-sign-brace ms-sign-brace--${mark.signPart ?? 'open'}` })
|
||||
|
||||
return vnode
|
||||
}
|
||||
|
||||
/**
|
||||
* 写 data.attrs(attrs 模块)。
|
||||
* 不要写 data.class —— 会被规范化函数搬进 props 且到不了 class 模块;
|
||||
* 也不要写 data.props.className —— props 模块只增不删,类名摘不掉。理由见上。
|
||||
*/
|
||||
function setAttrs(data: NonNullable<StyledVNode['data']>, attrs: Record<string, string>) {
|
||||
Object.assign((data.attrs ??= {}), attrs)
|
||||
}
|
||||
|
||||
let registered = false
|
||||
|
||||
/**
|
||||
|
||||
@@ -295,10 +295,10 @@ function save() {
|
||||
type="button"
|
||||
class="placeholder-chip"
|
||||
:class="{ 'is-signature': field.kind === 'signature' }"
|
||||
:title="`${kindLabels[field.kind]}字段;签署时按患者与就诊信息自动填充${field.required ? ',必填' : ''}`"
|
||||
:title="`${kindLabels[field.kind]}字段;签署时按患者与就诊信息自动填充`"
|
||||
@click="insertField(field)"
|
||||
>
|
||||
{{ field.label }}{{ field.required ? ' *' : '' }}
|
||||
{{ field.label }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -336,7 +336,6 @@ function save() {
|
||||
<span v-for="field in detectedFields" :key="field.id" class="placeholder-check__item">
|
||||
{{ field.label }}
|
||||
<em>{{ kindLabels[field.kind] }}</em>
|
||||
<i v-if="field.required">必填</i>
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="placeholder-check__empty">
|
||||
@@ -590,12 +589,6 @@ function save() {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.placeholder-check__item i {
|
||||
color: var(--err);
|
||||
font-size: 10.5px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.placeholder-check__empty {
|
||||
margin: 0;
|
||||
color: var(--mut);
|
||||
|
||||
@@ -238,7 +238,8 @@ onBeforeUnmount(() => {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 类型角标:签名/日期/文本……让科室不点开也知道这一项要填什么 */
|
||||
/* 类型角标:签名/日期/文本……让科室不点开也知道这一项要填什么。
|
||||
所有占位符共用一个样式,靠这个角标区分类型,不再按必填/签名位分色 */
|
||||
.pane-content :deep(.ms-sign-chip)::after {
|
||||
padding-left: 5px;
|
||||
margin-left: 5px;
|
||||
@@ -250,25 +251,6 @@ onBeforeUnmount(() => {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 必填项:角标转红并补一个星号,和下方「占位符检查」面板的口径一致 */
|
||||
.pane-content :deep(.ms-sign-chip[data-sign-required='true'])::after {
|
||||
color: var(--err);
|
||||
content: attr(data-sign-tag) ' *';
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 签名位是最不能漏的一项:与「插入签署占位符」按钮里的橙色区分保持一致 */
|
||||
.pane-content :deep(.ms-sign-chip[data-sign-kind='signature']) {
|
||||
color: #a96900;
|
||||
background: #fff4e2;
|
||||
border-color: #f0d5a8;
|
||||
}
|
||||
|
||||
.pane-content :deep(.ms-sign-brace[data-sign-kind='signature']) {
|
||||
color: #c08a3e;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 勾选框:签署时会转成可勾选控件,这里给它一个"框"的观感。
|
||||
只上底色不加边框——□ 本身就是个方框,外面再套一圈边框会变成"框中框" */
|
||||
.pane-content :deep(.ms-sign-box) {
|
||||
|
||||
Reference in New Issue
Block a user