代码提交

This commit is contained in:
xwt
2026-09-21 19:03:31 +08:00
parent 34d656492c
commit 97986d9a1a
217 changed files with 12042 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
/* 真实浏览器视觉验证:用本机 Edge 无头模式给每个页面截图。
做法是把 index.html 复制成变体(放回 static 目录,保证相对路径能打到后端),
在 </body> 前注入「自动登录 + 等渲染」脚本,跳页交给应用自己的 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 = `<script>
(async function () {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const waitFor = async (fn, n) => { for (let i = 0; i < n; i++) { if (fn()) return true; await sleep(20); } return false; };
await waitFor(() => typeof doLogin === 'function', 300);
document.querySelector('#login-user').value = ${JSON.stringify(acc.user)};
document.querySelector('#login-pass').value = ${JSON.stringify(acc.pass)};
document.querySelector('#login-btn').click();
await waitFor(() => document.querySelector('#app').style.display === 'flex', 300);
// 等应用自己按 location.hash 的那次跳转渲染完,不要插手
await waitFor(() => !document.querySelector('#view').textContent.trim().startsWith('加载中'), 400);
await sleep(500);
${after}
await sleep(700);
document.body.setAttribute('data-shot-ready', '1');
})();
</script>`;
return html.replace('</body>', auto + '\n</body>');
}
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);
})();