feat(文档库): 打通模板正文真实模式并新增渲染验证
模板列表接口不返回正文,展示、编辑与发起签署前需再取版本详情获取 contentHtml, 为此新增 getTemplateContent、createTemplateWithContent、updateTemplateContent 与 deleteTemplate(归档),并在签署端按 contentSha256 缓存正文快照。 同时新增 scripts/login.cjs 与 scripts/verify-pages.cjs,通过真实登录与 CDP 渲染截图、断言关键内容并收集运行时错误,并补充 .gitignore、eslint 忽略与文档说明。
This commit is contained in:
@@ -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` 是临时文件,同样已忽略。
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})()
|
||||
Reference in New Issue
Block a user