feat(文档库): 打通模板正文真实模式并新增渲染验证

模板列表接口不返回正文,展示、编辑与发起签署前需再取版本详情获取 contentHtml,
为此新增 getTemplateContent、createTemplateWithContent、updateTemplateContent 与
deleteTemplate(归档),并在签署端按 contentSha256 缓存正文快照。

同时新增 scripts/login.cjs 与 scripts/verify-pages.cjs,通过真实登录与 CDP
渲染截图、断言关键内容并收集运行时错误,并补充 .gitignore、eslint 忽略与文档说明。
This commit is contained in:
yelan
2026-09-17 14:10:45 +08:00
parent 0d271d90be
commit 81922d774f
20 changed files with 1640 additions and 187 deletions
+107
View File
@@ -0,0 +1,107 @@
# scripts
只用于本地开发与验证,不参与打包。
## verify-pages.cjs — 浏览器真实渲染验证
**为什么需要**:`lint` / `vue-tsc` / `build` 通过 ≠ 页面能跑。数据加载失败导致列表空白、
解析逻辑只处理部分数据、组件渲染时抛错,这些问题在类型检查里完全看不出来,
只有真实打开浏览器渲染才发现。
**用法**:
```bash
# 1. 起 dev server(另开一个终端)
npm run dev
# 2. 抓验证码图片,然后自己看图认出数字
node scripts/login.cjs
# 图片在 scripts/.captcha.png,例如显示 8944
# 3. 带验证码登录,会话写入 scripts/.session.json
node scripts/login.cjs 8944
# 4. 跑渲染验证:打开页面、截图、断言关键内容、收集运行时错误
node scripts/verify-pages.cjs
```
截图输出到仓库根目录的 `screenshots/`。
**环境变量**:
| 变量 | 默认值 | 说明 |
| ------------------------------------- | ------------------------------------------ | ---------------------- |
| `VERIFY_URL` | `http://127.0.0.1:5173/` | 目标地址 |
| `VERIFY_ROUTES` | `/management/documents,/workbench/signing` | 要验证的路由,逗号分隔 |
| `VERIFY_OUT` | `<repo>/screenshots` | 截图输出目录 |
| `CHROME_PATH` | Chrome 默认安装路径 | Chrome 可执行文件 |
| `VERIFY_USERNAME` / `VERIFY_PASSWORD` | `admin` / `admin` | 登录账号 |
| `VERIFY_BASE_URL` | `http://127.0.0.1:5173` | 登录接口所在地址 |
**依赖**:需要 `ws`(CDP 通信)。装到隔离 workspace,不要装进项目:
```bash
cd C:/Users/<you>/.workbuddy-ai/binaries/node/workspace
<node>/npm.cmd install ws
```
运行脚本时指定 `NODE_PATH` 指向该目录:
```bash
NODE_PATH=C:/Users/<you>/.workbuddy-ai/binaries/node/workspace/node_modules \
node scripts/verify-pages.cjs
```
**验证真实模式**(关掉演示数据)另起一个 server:
```bash
VITE_MOCK_DOCUMENTS=false VITE_MOCK_SIGNING=false npx vite --port 5199
VERIFY_URL=http://127.0.0.1:5199/ node scripts/verify-pages.cjs
```
## 踩过的坑
改脚本前先读这段,都是实际踩出来的。
### 1. 登录不要 stub,走真实登录
MEDISIGN 后端对无效 token 返回 **401**,而 `utils/request.ts` 的响应拦截器收到 401 会
`clearAuthStorage()` + 跳登录页。伪造会话很容易被踢出去。
如果确实要 stub XHR(比如只想验证某个组件),**必须实现完整响应**:
`getAllResponseHeaders` / `getResponseHeader` / `onloadend` 一个都不能少。
只设 `responseText` 会让 axios 在解析响应头时抛错、**Promise 永不 settle**,
路由守卫的 `await` 一直挂着 → `#app` 只剩 `<!---->`、**整页空白**。
这个现象极易被误判成「页面渲染挂了」,实际是验证脚本自己的 bug。
### 2. 验证码是强制的
不传 `captchaId` / `captchaCode` 会报「验证码错误或已过期」。
验证码有时效,抓图和登录要连着做。
### 3. CDP 连续导航在 SPA 里不可靠
`Page.navigate` 连续调用时:
- `pushState` 跳转**不触发** `Page.loadEventFired`
- 轮询 `document.readyState` 会立刻通过,但读到的是**旧文档**
结果就是断言拿到上一个页面的内容,看起来像「导航了但没渲染」。
正确做法(脚本已采用):只做**一次**整页 `Page.navigate`,后续切页取
```js
document.getElementById('app').__vue_app__.config.globalProperties.$router
```
调 `router.push()`。注意 `history.pushState` + 手动派发 `popstate` 对 Vue Router **无效**。
### 4. 文件用 .cjs 后缀
`package.json` 里有 `"type": "module"`,`.js` 会被当成 ES 模块,
而这里用 `require` 更简单,所以显式声明成 CommonJS。
## 安全提醒
`scripts/.session.json` 里是**真实 token**,已在 `.gitignore` 中忽略,**不要提交**。
`.captcha.json` / `.captcha.png` 是临时文件,同样已忽略。
+105
View File
@@ -0,0 +1,105 @@
/**
* 登录并保存会话,供页面渲染验证脚本复用。
*
* 为什么不 stub 接口:MEDISIGN 后端对无效 token 返回 401,
* utils/request.ts 的响应拦截器收到 401 会 clearAuthStorage() + 跳登录页,
* 伪造会话很容易被踢出去。走真实登录最省事。
*
* 验证码是强制的(不传会报「验证码错误或已过期」)。用法:
*
* node scripts/login.cjs # 第一步:抓验证码图片
* (用看图工具认出图片里的数字)
* node scripts/login.cjs 5274 # 第二步:带验证码登录,写 .session.json
*
* 结果写到 scripts/.session.json(已在 .gitignore 里,不会提交 token)。
*
* 文件用 .cjs 后缀:package.json 里的 "type": "module" 会把 .js 当 ES 模块,
* 而这里用 require 更简单,所以显式声明成 CommonJS。
*
* 依赖:Node 18+(内置 fetch)。无需额外安装依赖。
*/
const fs = require('fs')
const path = require('path')
const BASE_URL = process.env.VERIFY_BASE_URL || 'http://127.0.0.1:5173'
const USERNAME = process.env.VERIFY_USERNAME || 'admin'
const PASSWORD = process.env.VERIFY_PASSWORD || 'admin'
const OUT_DIR = __dirname
const CAPTCHA_JSON = path.join(OUT_DIR, '.captcha.json')
const CAPTCHA_PNG = path.join(OUT_DIR, '.captcha.png')
const SESSION_FILE = path.join(OUT_DIR, '.session.json')
async function fetchCaptcha() {
const res = await fetch(`${BASE_URL}/api/v1/auth/captcha`)
const json = await res.json()
if (json.code !== '0' && json.code !== 0) {
throw new Error('获取验证码失败: ' + json.message)
}
const { captchaId, imageBase64 } = json.data
fs.writeFileSync(CAPTCHA_JSON, JSON.stringify(json))
fs.writeFileSync(
CAPTCHA_PNG,
Buffer.from(String(imageBase64).replace(/^data:image\/\w+;base64,/, ''), 'base64'),
)
console.log('验证码已保存,请打开图片认出里面的数字:')
console.log(' ' + CAPTCHA_PNG)
console.log('captchaId = ' + captchaId)
console.log('')
console.log('然后执行: node scripts/login.cjs <识别出的数字>')
}
async function login(captchaCode) {
if (!fs.existsSync(CAPTCHA_JSON)) {
throw new Error('没有找到验证码,请先执行 node scripts/login.cjs 抓取验证码')
}
const { data } = JSON.parse(fs.readFileSync(CAPTCHA_JSON, 'utf8'))
const res = await fetch(`${BASE_URL}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: USERNAME,
password: PASSWORD,
captchaId: data.captchaId,
captchaCode,
}),
})
const json = await res.json()
if (json.code !== '0' && json.code !== 0) {
throw new Error(
'登录失败: ' + json.message + '(验证码可能已过期,重新执行 node scripts/login.cjs)',
)
}
fs.writeFileSync(SESSION_FILE, JSON.stringify(json.data, null, 2))
console.log('登录成功')
console.log(
' 用户 = ' + json.data.user.displayName + ' (' + json.data.user.username + ')',
)
console.log(' campusId = ' + json.data.user.campusId)
console.log(' dataScope = ' + json.data.user.dataScope)
console.log(' permissions = ' + json.data.user.permissions.length + ' 项')
console.log(' 会话已写入 ' + SESSION_FILE)
}
async function main() {
const captchaCode = process.argv[2]
if (captchaCode) {
await login(captchaCode.trim())
} else {
await fetchCaptcha()
}
}
main().catch((err) => {
console.error(err.message)
process.exit(1)
})
+263
View File
@@ -0,0 +1,263 @@
/**
* 浏览器真实渲染验证:打开页面截图 + 断言关键内容 + 收集运行时错误。
*
* 为什么需要它:编译通过(lint / vue-tsc / build)不等于页面能跑。
* 数据加载失败导致列表空白、解析逻辑只处理部分数据、组件渲染时抛错,
* 这些问题在类型检查里完全看不出来,只有真实渲染才发现。
*
* 用法:
* 1. 起 dev server(默认 http://127.0.0.1:5173)
* 2. node scripts/login.cjs # 抓验证码图片并认数字
* node scripts/login.cjs 5274 # 登录,写 .session.json
* 3. node scripts/verify-pages.cjs
*
* 环境变量:
* VERIFY_URL 目标地址,默认 http://127.0.0.1:5173/
* VERIFY_ROUTES 要验证的路由,逗号分隔,默认 /management/documents,/workbench/signing
* VERIFY_OUT 截图输出目录,默认 <repo>/screenshots
* CHROME_PATH Chrome 可执行文件路径
*
* 依赖:ws(CDP 需要)。装到隔离 workspace:
* cd C:/Users/<you>/.workbuddy-ai/binaries/node/workspace
* <node>/npm.cmd install ws
* 运行时指定 NODE_PATH 指向该 workspace 的 node_modules。
*
* ─────────────────────────────────────────────────────────────
* 踩过的坑(改这个脚本前先读):
*
* 1. 登录不要 stub。后端对无效 token 返回 401 → utils/request.ts 会
* clearAuthStorage() + 跳登录页。伪造会话很容易被踢。走真实登录。
* 若一定要 stub XHR,必须实现 getAllResponseHeaders / getResponseHeader /
* onloadend——只给 responseText 会让 axios 解析失败、Promise 永不 settle,
* 路由守卫的 await 一直挂着 → #app 只剩 <!---->、整页空白,
* 极易误判成"页面渲染挂了"。
*
* 2. CDP 连续 Page.navigate 在 SPA 里不可靠:pushState 不触发
* Page.loadEventFired,轮询 readyState 又读到旧文档,断言会拿到上一页内容。
* 这里只做一次整页 navigate,后续切页用 router.push()。
* history.pushState + 手动派发 popstate 对 Vue Router 无效。
*
* 3. Chrome 可执行路径写死容易失效,用 CHROME_PATH 覆盖。
*/
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')
console.error('然后设置 NODE_PATH 指向该目录的 node_modules 再运行本脚本。')
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 || 9448)
const TARGET_URL = process.env.VERIFY_URL || 'http://127.0.0.1:5173/'
const ROUTES = (process.env.VERIFY_ROUTES || '/management/documents,/workbench/signing')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
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-verify-' + process.pid,
)
fs.mkdirSync(OUT, { recursive: true })
fs.rmSync(PROFILE, { recursive: true, force: true })
if (!fs.existsSync(SESSION_FILE)) {
console.error('缺少会话文件 ' + SESSION_FILE)
console.error('请先执行: 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))
// 先于应用代码执行,保证 store 初始化时就能读到会话
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) {}
`
async function assertReachable(url) {
try {
const res = await fetch(url)
if (!res.ok) throw new Error('HTTP ' + res.status)
} catch (err) {
console.error(`\n[致命] 无法访问 ${url} —— ${err.message}`)
console.error('请先启动 dev server,然后用 VERIFY_URL 指定正确地址。\n')
process.exit(1)
}
}
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) {
const { data } = await send(ws, 'Page.captureScreenshot', { format: 'png' })
const file = path.join(OUT, name)
fs.writeFileSync(file, Buffer.from(data, 'base64'))
console.log(' 截图 -> ' + file)
}
/** 页面内跳转:取 app 上的 router 实例 push,避免 CDP 连续 navigate 的坑 */
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)
}
/** 页面通用断言:布局是否挂载、有无空态/错误提示、正文长度 */
const PAGE_PROBE = `(() => {
const t = document.body.innerText
const app = document.getElementById('app')
return {
path: location.pathname,
appHtmlLen: app ? app.innerHTML.length : -1,
vueMounted: !!(app && app.__vue_app__),
hasLayout: !!document.querySelector('.clinical-layout'),
bodyTextLen: t.length,
hasEmptyHint: /暂无|没有|为空|加载失败|请从左侧列表选择/.test(t),
hasErrorHint: /失败|错误|异常/.test(t),
snippet: t.slice(0, 900),
}
})()`
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 pageErrors = []
const apiCalls = []
;(async () => {
await assertReachable(TARGET_URL)
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))
}
if (msg.method === 'Network.responseReceived') {
const r = msg.params.response
if (r.url.includes('/api/v1/')) {
apiCalls.push(r.status + ' ' + r.url.replace(/^https?:\/\/[^/]+/, ''))
}
}
})
await send(ws, 'Page.enable')
await send(ws, 'Runtime.enable')
await send(ws, 'Network.enable')
await send(ws, 'Page.addScriptToEvaluateOnNewDocument', { source: BOOT_SCRIPT })
// 只做一次整页导航,后续切页都在页面内完成
await send(ws, 'Page.navigate', { url: TARGET_URL })
await sleep(7000)
const boot = await evaluate(ws, PAGE_PROBE)
console.log('\n[启动态] ' + JSON.stringify(boot))
for (const [index, route] of ROUTES.entries()) {
console.log(`\n=== ${route} ===`)
await gotoInPage(ws, route, 6000)
console.log(JSON.stringify(await evaluate(ws, PAGE_PROBE), null, 2))
const safe = route.replace(/^\//, '').replace(/\//g, '-')
await shot(ws, String(index + 1).padStart(2, '0') + '-' + safe + '.png')
}
console.log('\n=== 后端接口调用 ===')
const uniq = [...new Set(apiCalls)]
console.log(uniq.length ? uniq.slice(0, 40).join('\n') : '(无)')
console.log('\n=== 页面运行时错误 ===')
console.log(pageErrors.length ? [...new Set(pageErrors)].slice(0, 20).join('\n') : '无(干净)')
} catch (err) {
console.error('验证失败:', err.message)
process.exitCode = 1
} finally {
if (ws) ws.close()
chrome.kill()
}
})()