Files
ClassHelper/website/tools/capture-shots.mjs
T
LoveChengke 82c85b59c1 feat(dist,deploy,website): 交付产物统一归集到 releases/<组件>/<版本>,补齐官网与 Linux 验收
三个打包脚本原先各写各的输出目录,现在一律直接落在仓库根 releases/<组件>/<版本>/,分层固定为安装包/免安装/构建中间;dist-win 用 --config.directories.output 覆盖 electron-builder 的目录后再整理,显式传入时不覆盖。插件侧顺手修掉一个既有 bug:原先 rmSync 整个组件目录,重打一次就把上一版的留档抹掉,现在只清当前版本目录。verify-packaged 的 --exe 默认值跟着改到新落点。

deploy/verify-linux.sh 修掉管道在 pipefail 下的 SIGPIPE 误判(命中被算成失败);CI workflow 的出包与上传路径同步;electron-builder.yml 显式指定安装包图标。

website/ 是产品官网(纯静态、无构建步骤,不进 pnpm workspace),含 beUI 动效 token 与实机留档图;客户端冒烟相应加了采集留档图用的外观开关。
2026-10-06 15:31:38 +08:00

280 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 给官网采集 **真实运行** 的实机截图。
*
* 复用仓库里已安装的 Electron(不需要额外依赖),真的打开 Web 管理端、真的登录、
* 逐页截屏落到 website/assets/shots/。不是手绘的示意,也不是旧版本的残留图。
*
* 用法:
* 1) 先把后端跑起来(它同时托管 Web 管理端):pnpm dev:server
* 2) node website/tools/capture-shots.mjs
*
* 环境变量:
* CAPTURE_URL 默认 http://127.0.0.1:4000
* CAPTURE_USER / CAPTURE_PASS 默认 admin / admin123
* CAPTURE_ONLY 只截某几页(逗号分隔的 name)
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveElectronEnv, resolveElectronExecutable } from '../../scripts/lib/electron-env.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..', '..');
const outDir = path.join(root, 'website', 'assets', 'shots');
const BASE = process.env.CAPTURE_URL ?? 'http://127.0.0.1:4000';
const USER = process.env.CAPTURE_USER ?? 'admin';
const PASS = process.env.CAPTURE_PASS ?? 'admin123';
const ONLY = (process.env.CAPTURE_ONLY ?? '').split(',').filter(Boolean);
/**
* 渲染倍率。**保持 1**:`force-device-scale-factor` 会让 BrowserWindow 的
* width/height 按物理像素算,想要 1500 CSS px 的视口就得开 3000 px 的窗口,
* 本机屏幕(1600×900)放不下,窗口会被夹回来反而更小。
* 1 倍截出来的 1500 px 已经比站点上的显示尺寸大两倍,够清晰了。
*/
const SCALE = 1;
// ── 不在 Electron 里跑就先用仓库里的 Electron 重新拉起自己(见 scripts/lib/electron-env.mjs)
if (!process.versions.electron) {
const electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron');
const executable = resolveElectronExecutable(electronDir);
if (!fs.existsSync(executable)) {
console.error(`[capture] 找不到 Electron:${executable}`);
console.error(
' 先执行 pnpm install,再执行 node packages/desktop-client/node_modules/electron/install.js',
);
process.exit(2);
}
const child = spawn(executable, [fileURLToPath(import.meta.url)], {
stdio: 'inherit',
env: resolveElectronEnv(),
});
child.on('exit', (code) => process.exit(code ?? 1));
} else {
const { app, BrowserWindow } = await import('electron');
// 这两条必须在 whenReady 之前设置,晚一步就不生效了
app.commandLine.appendSwitch('force-device-scale-factor', String(SCALE));
// 干净的配置目录:上次留下的 localStorage 会让 /login 直接跳走,
// 「登录页」那一张就截成别的页了
const profileDir = path.join(root, '.cache', 'capture-shots-profile');
fs.rmSync(profileDir, { recursive: true, force: true });
app.setPath('userData', profileDir);
// 关掉桌面窗口时默认会把整个 app 带退出(Windows),手机那一轮就跑不到了
app.on('window-all-closed', () => {});
// whenReady 的回调在本模块求值完之后才跑,所以上面那些 const 已经初始化好了
app.whenReady().then(() => main(app, BrowserWindow));
}
/**
* 桌面端页面:路由路径 → 文件名。
* `pickClass` 会在截图前把工具栏的班级下拉切过去 —— 演示班里 高一(1)班 被冒烟跑了几十次,
* 列表里全是「联动页也不该下发」这类测试残条;高二(3)班 还是干净的种子数据。
*/
const DESKTOP_PAGES = [
{ name: 'web-dashboard', route: '/dashboard', title: '仪表盘' },
{ name: 'web-classes', route: '/classes', title: '班级管理' },
{ name: 'web-students', route: '/students', title: '学生管理' },
{ name: 'web-teachers', route: '/teachers', title: '教师管理' },
{ name: 'web-schedules', route: '/schedules', title: '课表管理' },
{ name: 'web-homeworks', route: '/homeworks', title: '作业发布' },
{ name: 'web-notifications', route: '/notifications', title: '通知发布', pickClass: '高二(3)班' },
{ name: 'web-grades', route: '/grades', title: '成绩录入' },
{ name: 'web-integrations', route: '/integrations', title: 'ClassIsland 联动' },
{ name: 'web-database', route: '/database', title: '数据库管理' },
];
/** 手机小屏:同一套界面在 <=768px 下的样子(抽屉 + 卡片内滚动) */
const MOBILE_PAGES = [
{ name: 'mobile-notifications', route: '/notifications', title: '手机 · 通知发布', openDrawer: true },
{ name: 'mobile-dashboard', route: '/dashboard', title: '手机 · 仪表盘' },
];
// 本机屏幕工作区只有 1600×852,窗口再高会被 Electron 夹回来,所以取能放下的高度
const DESKTOP = { width: 1500, height: 820 };
const MOBILE = { width: 414, height: 800 };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function main(app, BrowserWindow) {
async function login() {
const response = await fetch(`${BASE}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: USER, password: PASS }),
});
if (!response.ok) throw new Error(`登录失败 ${response.status}:${await response.text()}`);
return (await response.json()).data;
}
async function makeWindow(size) {
const win = new BrowserWindow({
...size,
useContentSize: true,
show: true,
autoHideMenuBar: true,
backgroundColor: '#f2f4f8',
webPreferences: { contextIsolation: true, nodeIntegration: false },
});
// 连着开关窗口时偶发 ERR_FAILED,重试一次就好
for (let attempt = 1; ; attempt += 1) {
try {
await win.loadURL(`${BASE}/login`);
return win;
} catch (error) {
if (attempt >= 3) throw error;
await sleep(600);
}
}
}
/** 把令牌塞进 localStorage,省掉每次跑一遍登录表单;顺便压掉新手引导 */
async function seedSession(win, session) {
await win.webContents.executeJavaScript(`
localStorage.setItem('classhelper.token', ${JSON.stringify(session.token)});
localStorage.setItem('classhelper.user', ${JSON.stringify(JSON.stringify(session.user))});
localStorage.setItem('classhelper.onboarding', '99');
`);
}
/** 页面上可能飘着 ElMessage 提示,截之前先收掉,免得挡住内容 */
async function dismissToasts(win) {
await win.webContents.executeJavaScript(`
document.querySelectorAll('.el-message, .el-notification').forEach((node) => node.remove());
const tour = document.querySelector('.el-tour__closebtn');
if (tour) tour.click();
`);
}
async function shoot(win, name, width) {
// capturePage 偶发 UnknownVizError(合成器还没交出那一帧),重试几次就好
let image = null;
for (let attempt = 1; attempt <= 4; attempt += 1) {
try {
const frame = await win.webContents.capturePage();
if (!frame.isEmpty()) {
image = frame;
break;
}
} catch (error) {
if (attempt === 4) throw error;
console.warn(` ${name} 截图失败(第 ${attempt} 次):${error.message}`);
}
await sleep(450);
}
if (!image) throw new Error(`${name} 连续 4 次没截到内容`);
const [contentW, contentH] = win.getContentSize();
// 2 倍渲染 → 缩到目标宽度:文字边缘比直接 1 倍清晰一档
const scaled = image.getSize().width > width ? image.resize({ width, quality: 'best' }) : image;
fs.mkdirSync(outDir, { recursive: true });
const file = path.join(outDir, `${name}.jpg`);
fs.writeFileSync(file, scaled.toJPEG(90));
const kb = (fs.statSync(file).size / 1024).toFixed(0);
console.log(
` ${name.padEnd(22)} ${scaled.getSize().width}×${scaled.getSize().height} ${kb}KB ` +
`(窗口内容 ${contentW}×${contentH}, 截图 ${image.getSize().width}×${image.getSize().height})`,
);
}
/**
* 一个「用完可能已经被关掉」的窗口。截屏过程要弹好几个真窗口在用户桌面上,
* 用户顺手关掉一个是完全正常的,所以每一步之前都确认它还在。
*/
function opener(size, session) {
let win = null;
return {
async ensure() {
if (win && !win.isDestroyed()) return win;
win = await makeWindow(size);
await seedSession(win, session);
return win;
},
close() {
if (win && !win.isDestroyed()) win.destroy();
win = null;
},
};
}
/** 把工具栏第一个下拉(班级)切到指定班级:Element Plus 的 select 要点 wrapper 才会展开 */
async function pickClass(win, className) {
await win.webContents.executeJavaScript(`
(() => {
const wrapper = document.querySelector('.el-select__wrapper') ?? document.querySelector('.el-select');
if (!wrapper) return false;
wrapper.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
wrapper.click();
return true;
})()
`);
await sleep(600);
const picked = await win.webContents.executeJavaScript(`
(() => {
const items = [...document.querySelectorAll('.el-select-dropdown__item')];
const target = items.find((li) => li.textContent.trim() === ${JSON.stringify(className)});
if (!target) return '没找到,候选:' + items.map((li) => li.textContent.trim()).join('/');
target.click();
return 'ok';
})()
`);
await sleep(1100);
return picked;
}
async function run() {
const session = await login();
console.log(`[capture] ${session.user.name}(${session.user.role})→ ${BASE}`);
// 登录页要在写入令牌之前截:这时候还不能 seed
const loginWindow = await makeWindow(DESKTOP);
await sleep(1300);
await shoot(loginWindow, 'web-login', DESKTOP.width);
loginWindow.destroy();
const desktop = opener(DESKTOP, session);
for (const page of DESKTOP_PAGES) {
if (ONLY.length && !ONLY.includes(page.name)) continue;
const win = await desktop.ensure();
await win.loadURL(`${BASE}${page.route}`);
await sleep(1900);
if (page.pickClass) {
const result = await pickClass(win, page.pickClass);
if (result !== 'ok') console.warn(` ${page.name} 切换班级失败:${result}`);
}
await dismissToasts(win);
await sleep(300);
await shoot(win, page.name, DESKTOP.width);
}
desktop.close();
const mobile = opener(MOBILE, session);
for (const page of MOBILE_PAGES) {
if (ONLY.length && !ONLY.includes(page.name)) continue;
const win = await mobile.ensure();
await win.loadURL(`${BASE}${page.route}`);
await sleep(2000);
await dismissToasts(win);
if (page.openDrawer) {
await win.webContents.executeJavaScript(`
const burger = document.querySelector('.header-left button, header button');
if (burger) burger.click();
`);
await sleep(800);
}
await sleep(300);
await shoot(win, page.name, MOBILE.width);
}
mobile.close();
}
try {
await run();
console.log('[capture] 完成');
app.exit(0);
} catch (error) {
console.error('[capture] 失败:', error);
app.exit(1);
}
}