/* 真实浏览器视觉验证:用本机 Edge 无头模式给每个页面截图。 做法是把 index.html 复制成变体(放回 static 目录,保证相对路径能打到后端), 在 前注入「自动登录 + 等渲染」脚本,跳页交给应用自己的 hash 路由 (enterApp 会读 location.hash)。 为什么用 hash 路由而不是登录后再调一次 goto(): enterApp 自己已经按 hash 跳了一次,自己再调一次就是两次导航并发。应用侧现在 有导航序号保护(慢的那次会自行作废),但让浏览器直接带着目标 hash 打开页面 只有一次导航,最干净,也顺便验证了 hash 深链是通的。 哈希去重是必须的:只要跳页静默失效,所有截图会变成同一张(历史上真出现过 「11 张全是概览」),文件大小和分辨率都正常,光看清单看不出问题。 用法:node verify/shots.js [base] [outDir] */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { execFileSync } = require('child_process'); const BASE = process.argv[2] || 'http://127.0.0.1:8010'; const ROOT = path.resolve(__dirname, '..'); const STATIC = path.join(ROOT, 'app', 'static'); const OUT = process.argv[3] || path.join(ROOT, 'verify', 'shots'); const EDGE = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'; const PROFILE = path.join(ROOT, 'verify', '_edge_profile'); const ACCOUNTS = { admin: { user: 'admin', pass: 'admin123' }, viewer: { user: 'viewer', pass: 'viewer123' }, }; // page 为 null 表示登录页原样截图;account 省略时用 admin const JOBS = [ { name: 'login', page: null, label: '登录页' }, { name: 'overview', page: 'overview', label: '概览' }, { name: 'students', page: 'students', label: '学生管理' }, { name: 'scores', page: 'scores', label: '成绩管理' }, { name: 'employments', page: 'employments', label: '就业管理' }, { name: 'classes', page: 'classes', label: '班级管理' }, { name: 'teachers', page: 'teachers', label: '老师管理' }, { name: 'advisors', page: 'advisors', label: '顾问管理' }, { name: 'statistics', page: 'statistics', label: '统计分析' }, { name: 'advanced', page: 'advanced', label: '高级筛选' }, { name: 'students-modal', page: 'students', label: '新增学生弹窗', after: "document.querySelector('#btn-add').click();" }, // 只读账号:写入口应当整片消失,只留「详情」这类读操作 { name: 'viewer-students', page: 'students', label: '学生管理(只读)', account: 'viewer' }, { name: 'viewer-scores', page: 'scores', label: '成绩管理(只读)', account: 'viewer' }, ]; function inject(html, job) { if (job.page === null) return html; // 登录页:原样 const acc = ACCOUNTS[job.account || 'admin']; const after = job.after || ''; const auto = ``; return html.replace('', auto + '\n'); } function pngInfo(file) { const buf = fs.readFileSync(file); return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20), bytes: buf.length, md5: crypto.createHash('md5').update(buf).digest('hex'), }; } (function main() { fs.mkdirSync(OUT, { recursive: true }); try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) {} const src = fs.readFileSync(path.join(STATIC, 'index.html'), 'utf8'); const results = []; const variants = []; try { for (const job of JOBS) { const file = path.join(STATIC, `_shot_${job.name}.html`); fs.writeFileSync(file, inject(src, job), 'utf8'); variants.push(file); const out = path.join(OUT, `${job.name}.png`); const url = `${BASE}/static/_shot_${job.name}.html` + (job.page ? `#${job.page}` : ''); try { execFileSync(EDGE, [ '--headless=new', '--disable-gpu', '--hide-scrollbars', '--no-first-run', '--force-device-scale-factor=1', '--user-data-dir=' + PROFILE, '--window-size=1680,1120', '--virtual-time-budget=25000', '--screenshot=' + out, url, ], { stdio: 'pipe', timeout: 150000 }); const info = pngInfo(out); results.push({ name: job.name, label: job.label, ok: true, ...info }); } catch (e) { results.push({ name: job.name, label: job.label, ok: false, err: String(e.message || e).slice(0, 160) }); } } } finally { variants.forEach((f) => { try { fs.unlinkSync(f); } catch (e) {} }); try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) {} } // 逐张报告,并把「哈希重复」当作失败 —— 重复说明跳页没生效 const seen = new Map(); for (const r of results) { if (!r.ok) { console.log(` [FAIL] ${r.label.padEnd(14)} ${r.err}`); continue; } const dup = seen.get(r.md5); seen.set(r.md5, r.label); if (dup) { r.ok = false; console.log(` [FAIL] ${r.label.padEnd(14)} 与「${dup}」截图完全相同(hash ${r.md5.slice(0, 8)})→ 跳页未生效`); } else { console.log(` [OK] ${r.label.padEnd(14)} ${r.w}x${r.h} ${(r.bytes / 1024).toFixed(0)}KB hash=${r.md5.slice(0, 8)}`); } } const bad = results.filter((r) => !r.ok); console.log(`\n截图:成功 ${results.length - bad.length}/${results.length},输出目录 ${OUT}`); process.exit(bad.length ? 1 : 0); })();