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 与实机留档图;客户端冒烟相应加了采集留档图用的外观开关。
This commit is contained in:
LoveChengke committed 2026-10-06 15:31:38 +08:00
1 parent 7b1ae29774
commit 82c85b59c1
40 files changed
+4976 -76

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

File diff suppressed because it is too large. Load diff
+303
View File
@@ -0,0 +1,303 @@
/**
* 动效层 —— 每一条曲线、每一个弹簧参数都取自 beUI(beui.dev)的源码:
*
* lib/ease.ts 共享 token(EASE_OUT / SPRING_PRESS / …)
* components/motion/dynamic-island 壳 spring 用 Apple 的 duration+bounce 形式
* components/motion/text-animation TextReveal:逐字上浮 + 去模糊
* components/motion/number NumberTicker:数字竖排滚轮
* components/motion/shared-layout-bg 悬停胶囊在条目之间滑移
* components/motion/range-slider 位置用 SPRING_GLIDE,拖拽把手 scaleY 用 SPRING_BOUNCY
* components/motion/scroll-animation 阅读进度条,PROGRESS_SPRING 缓跟
* components/motion/button whileTap 0.93 / whileHover 1.02
*
* 这里只有引擎,没有页面逻辑;页面逻辑在 main.js 里。
*/
(() => {
'use strict';
/* ═══════════════════════════════ 曲线 ═══════════════════════════════════ */
/** 进场与退场:立刻响应,然后安静地落位 */
const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';
/** 已经在屏幕上的东西开始移动 */
const EASE_IN_OUT = 'cubic-bezier(0.77, 0, 0.175, 1)';
/** 抽屉 */
const EASE_DRAWER = 'cubic-bezier(0.32, 0.72, 0, 1)';
/* ═══════════════════════════════ 弹簧 ═══════════════════════════════════ */
/** 按钮等可按压表面:快、有分量 */
const SPRING_PRESS = { stiffness: 500, damping: 30, mass: 0.6 };
/** 控件内部文字 / 图标交换位置 */
const SPRING_SWAP = { stiffness: 460, damping: 30, mass: 0.55 };
/** 由指针唤出的浮层 */
const SPRING_PANEL = { stiffness: 420, damping: 40, mass: 0.5 };
/** 共享布局滑移:胶囊、指示器、面板换位置 */
const SPRING_LAYOUT = { stiffness: 360, damping: 32, mass: 0.6 };
/** 装饰性的鼠标跟随(磁吸、倾斜、Dock) */
const SPRING_MOUSE = { stiffness: 200, damping: 15, mass: 0.3 };
/** 拖拽把手与填充:临界阻尼,跟手且不会在两端回弹 */
const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5 };
/** 范围滑块把手的抓取反馈(只做 scaleY) */
const SPRING_BOUNCY = { stiffness: 500, damping: 14, mass: 0.7 };
/** 阅读进度条:故意比 UI 弹簧松,让它跟着滚动慢慢追上来 */
const SPRING_PROGRESS = { stiffness: 120, damping: 30, mass: 0.6 };
/** 标题逐字上浮(TextReveal 的 DEFAULT_SPRING) */
const SPRING_TEXT = { stiffness: 140, damping: 26, mass: 1.2 };
/* ═════════════════════════ 时长口径(beUI motion guide) ═════════════════════════
按压反馈 100–160ms · tooltip 125–200 · 下拉 150–250 · 弹窗抽屉 200–500
界面动效默认 < 300ms;更长的动效只属于讲解型演示。 */
const DURATION = {
press: 0.14,
swap: 0.2,
reveal: 0.22,
islandShell: 0.8,
islandContent: 0.8,
islandExit: 0.08,
number: 0.9,
numberStagger: 0.04,
textStagger: 0.03,
};
/* ═══════════════════════════ duration + bounce ═══════════════════════════
motion 的 Apple 式弹簧写法:给一个「感知时长」和一个回弹量,反解出刚度。
bounce = 0 无回弹(临界阻尼),bounce = 1 最弹;阻尼比 ζ = 1 - bounce。
做法是拿单位阶跃响应去二分刚度,让它在 duration 秒内落到静止 —— 与
motion 的 springFromDuration 同一套语义(因此感知时长与位移距离无关)。 */
const REST_DELTA = 0.01;
const REST_SPEED = 0.01;
const SOLVE_STEP = 1 / 480;
/** 运行时积分用的固定子步,与求解器同口径 */
const INTERNAL_STEP_MS = 1000 / 480;
/** 单帧最多补多少个子步,防止切回标签页时一次算几千步 */
const MAX_SUBSTEPS = 96;
function settleTime(stiffness, damping, mass) {
let x = 0;
let velocity = 0;
for (let t = 0; t < 6; t += SOLVE_STEP) {
const acceleration = (-stiffness * (x - 1) - damping * velocity) / mass;
velocity += acceleration * SOLVE_STEP;
x += velocity * SOLVE_STEP;
if (Math.abs(x - 1) < REST_DELTA && Math.abs(velocity) < REST_SPEED) return t;
}
return 6;
}
const durationCache = new Map();
function springFromDuration(duration, bounce, mass = 1) {
const key = `${duration}|${bounce}|${mass}`;
const cached = durationCache.get(key);
if (cached) return cached;
const zeta = Math.max(0.05, Math.min(1, 1 - bounce));
// 刚度越大落位越快,settleTime 对它单调递减 —— 二分找出刚好在 duration 落位的那个
let low = 1;
let high = 4000;
for (let i = 0; i < 40; i += 1) {
const stiffness = (low + high) / 2;
const damping = 2 * zeta * Math.sqrt(stiffness * mass);
if (settleTime(stiffness, damping, mass) > duration) low = stiffness;
else high = stiffness;
}
const stiffness = (low + high) / 2;
const result = { stiffness, damping: 2 * zeta * Math.sqrt(stiffness * mass), mass };
durationCache.set(key, result);
return result;
}
/** 把 {stiffness,damping,mass} 或 {duration,bounce} 统一成系数 */
function resolveConfig(config) {
if (config.duration !== undefined) {
return springFromDuration(config.duration, config.bounce ?? 0, config.mass ?? 1);
}
return { stiffness: config.stiffness, damping: config.damping, mass: config.mass ?? 1 };
}
/* ═══════════════════════════ 共享帧循环 ═══════════════════════════
全站一个 rAF:所有弹簧都挂在这上面,谁在动就推进谁,都停了就整条循环歇着。
可见性每帧现读,不缓存 —— 缓存过的标记一旦翻不回来,动画就永久停住。 */
const running = new Set();
let frameId = 0;
let lastTime = 0;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const reduceMotion = () => reduceQuery.matches;
function frame(time) {
const delta = lastTime ? Math.min(64, time - lastTime) : 16;
lastTime = time;
for (const spring of [...running]) {
if (!spring.step(delta)) running.delete(spring);
}
if (running.size > 0) {
frameId = requestAnimationFrame(frame);
} else {
frameId = 0;
lastTime = 0;
}
}
function wake() {
if (!frameId) frameId = requestAnimationFrame(frame);
}
/* ═══════════════════════════════ 弹簧 ═══════════════════════════════ */
class Spring {
/**
* @param {number} initial 初值
* @param {object} config {stiffness,damping,mass} 或 {duration,bounce}
* @param {(value:number)=>void} onUpdate 每帧回调
* @param {{restRatio?:number, restFloor?:number}} [rest] 静止阈值
*/
constructor(initial, config, onUpdate, rest = {}) {
const resolved = resolveConfig(config);
this.stiffness = resolved.stiffness;
this.damping = resolved.damping;
this.mass = resolved.mass;
// duration+bounce 那套是按「本次位移的 1%」判定静止的,这里跟着同口径缩放 ——
// 用绝对阈值的话,同一组参数走 20px 和走 200px 的观感时长会不一样。
this.restRatio = rest.restRatio ?? REST_DELTA;
this.restFloor = rest.restFloor ?? 1e-4;
this.restDelta = this.restFloor;
this.restSpeed = this.restFloor;
this.value = initial;
this.target = initial;
this.velocity = 0;
this.onUpdate = onUpdate;
}
get settled() {
return this.value === this.target && this.velocity === 0;
}
/** 换目标。保留当前速度 —— 真实弹簧就是这样,中途改向不会顿一下。 */
set(target, options = {}) {
if (options.velocity !== undefined) this.velocity = options.velocity;
if (target === this.target && this.settled) return;
this.target = target;
if (reduceMotion() || options.instant) {
this.jump(target);
return;
}
const travel = Math.abs(target - this.value);
this.restDelta = Math.max(this.restFloor, travel * this.restRatio);
this.restSpeed = this.restDelta;
running.add(this);
wake();
}
/** 直接落位,不做动画 */
jump(target) {
this.target = target;
this.value = target;
this.velocity = 0;
running.delete(this);
this.onUpdate?.(this.value, this);
}
/** 把当前值刷一次到 DOM。构造之后、或 set() 因为目标没变而早退时,靠它把首帧对上。 */
paint() {
this.onUpdate?.(this.value, this);
}
step(deltaMs) {
// 定步长积分:显式欧拉在 60/120Hz 这种大步长下会明显耗散,同一组参数的
// 观感时长会随屏幕刷新率变(0.8s 的壳在 120Hz 下会 0.48s 就停)。
// 这里把它拆成 1/480s 的固定子步,几时落位只由参数决定,与刷新率无关。
const sub = INTERNAL_STEP_MS;
let remaining = Math.min(deltaMs, sub * MAX_SUBSTEPS);
let moving = false;
while (remaining > 0) {
const dt = Math.min(sub, remaining) / 1000;
remaining -= sub;
moving = this.#integrate(dt) || moving;
}
return moving;
}
#integrate(dt) {
const distance = this.target - this.value;
if (distance === 0 && Math.abs(this.velocity) < this.restSpeed) {
this.value = this.target;
this.velocity = 0;
this.onUpdate?.(this.value, this);
return false;
}
// 胡克定律 + 阻尼:a = (k·(target − value) − c·v) / m
const acceleration = (this.stiffness * distance - this.damping * this.velocity) / this.mass;
this.velocity += acceleration * dt;
this.value += this.velocity * dt;
if (Math.abs(this.target - this.value) < this.restDelta && Math.abs(this.velocity) < this.restSpeed) {
this.value = this.target;
this.velocity = 0;
this.onUpdate?.(this.value, this);
return false;
}
if (!Number.isFinite(this.value) || !Number.isFinite(this.velocity)) {
this.value = this.target;
this.velocity = 0;
this.onUpdate?.(this.value, this);
return false;
}
this.onUpdate?.(this.value, this);
return true;
}
}
/* ═══════════════════════════ 通用小工具 ═══════════════════════════ */
const round2 = (value) => Math.round(value * 100) / 100;
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
/** 指针能悬停吗 —— 触摸设备上不要挂 hover 态 */
const hoverCapable = () => window.matchMedia('(hover: hover) and (pointer: fine)').matches;
function setTransition(el, properties, duration, ease = EASE_OUT, delay = 0) {
el.style.transition = properties
.map((property) => `${property} ${duration}s ${ease} ${delay ? `${delay}s` : '0s'}`)
.join(', ');
}
/** 动画重定向:先落 initial,下一帧再说 —— 保证浏览器真的重绘一次 */
function nextFrame(callback) {
requestAnimationFrame(() => requestAnimationFrame(callback));
}
window.CHMotion = {
EASE_OUT,
EASE_IN_OUT,
EASE_DRAWER,
SPRING_PRESS,
SPRING_SWAP,
SPRING_PANEL,
SPRING_LAYOUT,
SPRING_MOUSE,
SPRING_GLIDE,
SPRING_BOUNCY,
SPRING_PROGRESS,
SPRING_TEXT,
DURATION,
Spring,
springFromDuration,
reduceMotion,
hoverCapable,
round2,
clamp,
setTransition,
nextFrame,
};
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

File diff suppressed because it is too large. Load diff
+681
View File
@@ -0,0 +1,681 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>班级小助手 Class Helper — 知道现在是第几节课的班级信息系统</title>
<meta
name="description"
content="班级小助手是一套自托管的班级信息管理系统:课表、作业、通知、成绩、叫人合在一个后端,教室大屏与每台学生机实时收到,上课时段自动不打扰课堂。支持 Windows 单机、Linux 服务器与 Docker 部署。"
/>
<meta name="theme-color" content="#16231e" />
<meta property="og:title" content="班级小助手 Class Helper" />
<meta
property="og:description"
content="课表、作业、通知、成绩、叫人合在一个后端,教室大屏与每台学生机实时收到,上课时段自动不打扰课堂。"
/>
<meta property="og:type" content="website" />
<link rel="icon" href="assets/icon.png" />
<link rel="apple-touch-icon" href="assets/icon.png" />
<!-- 标题与引文用的楷体(霞鹜文楷 Screen)。加载失败时自动回落到系统的楷体 / 衬线 -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-screen-webfont@1.7.0/style.css"
media="print"
onload="this.media = 'all'"
/>
<link rel="stylesheet" href="assets/styles.css" />
<!-- 脚本在,先把标题藏起来准备逐字上浮;4 秒兜底,脚本坏在半路也不会把标题永久藏住 -->
<script>
document.documentElement.classList.add('reveal-pending');
setTimeout(function () {
document.documentElement.classList.remove('reveal-pending');
}, 4000);
</script>
</head>
<body>
<a class="skip" href="#main">跳到正文</a>
<header class="topbar">
<div class="wrap topbar-inner">
<a class="brand" href="#top" data-press="0.96">
<img class="brand-mark" src="assets/icon.png" alt="" width="32" height="32" />
<span class="brand-name">班级小助手</span>
<span class="brand-ver">1.1.2</span>
</a>
<nav class="topnav" aria-label="页内导航">
<span class="nav-pill" aria-hidden="true"></span>
<a href="#journey">一条通知的旅程</a>
<a href="#policy">上课时段策略</a>
<a href="#parts">组成部分</a>
<a href="#shots">实机</a>
<a href="#deploy">部署</a>
<a href="#verify">验收</a>
</nav>
<a class="btn btn-quiet" href="#deploy" data-press="0.95">取安装包</a>
</div>
<!-- 阅读进度:beUI ScrollProgressBar(2px,origin-left,弹簧缓跟) -->
<div class="scroll-progress" aria-hidden="true"></div>
</header>
<main id="main">
<!-- ═══════════════════════ 黑板:教室那台机器 ═══════════════════════ -->
<section class="band band-board hero" id="top">
<div class="wrap">
<div class="hero-intro">
<h1 class="display" data-reveal>
<span class="reveal-line">它知道现在是第几节课,</span>
<span class="reveal-line">所以不会在课上打扰学生。</span>
</h1>
<p class="hero-sub intro-seq" style="--intro-delay: 150ms">课表、作业、通知、成绩、叫人</p>
<p class="lead intro-seq" style="--intro-delay: 200ms">
一个后端全管,老师在电脑或手机上发布,教室大屏和每台学生机同时收到;
断网时用本地缓存接着看,网络恢复自动补齐。
</p>
<div class="actions intro-seq" style="--intro-delay: 250ms">
<a class="btn btn-solid" href="#deploy" data-press="0.93">下载与部署</a>
<a class="btn btn-line" href="#journey" data-press="0.93">先看它怎么工作</a>
</div>
</div>
<div
class="screen"
role="img"
aria-label="教室机器上的界面示意:屏幕顶部中央浮着一个黑色灵动岛,正在展开老师发来的通知;下面是大时钟、正在上的数学课与下一节语文。"
>
<div class="screen-bar">
<span class="screen-dot" aria-hidden="true"></span>
<span class="screen-title">教室机器 · 高一(1)班</span>
<span class="screen-live">实时连接正常</span>
</div>
<div class="screen-stage" id="stage">
<div class="island-slot">
<div class="island" id="island" data-kind="pill">
<svg class="island-shape" id="island-shape" width="268" height="44" aria-hidden="true">
<defs>
<!-- 紧急/叫人卡片顶部那层呼吸光晕。真实产品用的是卡片内部的 inset 阴影,
这里用同一个 path 做一层渐变,效果一致且不会被窗口裁掉 -->
<radialGradient id="island-tint-urgent" cx="50%" cy="0" r="120%">
<stop offset="0" stop-color="#ff453a" stop-opacity="0.28" />
<stop offset="0.68" stop-color="#ff453a" stop-opacity="0" />
</radialGradient>
<radialGradient id="island-tint-call" cx="50%" cy="0" r="120%">
<stop offset="0" stop-color="#ff9f0a" stop-opacity="0.26" />
<stop offset="0.68" stop-color="#ff9f0a" stop-opacity="0" />
</radialGradient>
</defs>
<path class="island-base" id="island-path" d="" />
<path class="island-tint" id="island-tint" d="" />
</svg>
<div class="island-layers">
<div class="island-pill" id="island-pill">
<span class="pill-icon" id="pill-icon"></span>
<span class="pill-title" id="pill-title">新消息</span>
<span class="pill-sub" id="pill-sub">张老师 · 点击查看</span>
</div>
<!-- 卡片内容由 main.js 按 beUI DynamicIsland 的 Slot 机制挂进来 -->
</div>
</div>
</div>
<div class="screen-body">
<div class="clock">
<p class="clock-hm">
08:42
<span class="clock-s">07</span>
</p>
<p class="clock-week">第 6 教学周 · 单周 · 周二</p>
</div>
<div class="clock-now">
<p class="clock-current">
正在上
<b>数学</b>
</p>
<p class="clock-sub">08:00–08:45 · 教学楼 A301 · 距下课 3 分钟</p>
<div class="clock-bar"><i style="width: 88%"></i></div>
</div>
<div class="clock-next">
<p class="next-head">下一节</p>
<p class="next-name">
语文
<span>08:55–09:40</span>
</p>
<p class="next-sub">教学楼 A302 · 还有 13 分钟</p>
</div>
</div>
</div>
</div>
<p class="hero-cap">
上面这个灵动岛不是截图 —— 圆角路径与尺寸都是产品的(胶囊 268×44、展开 424×230、连续圆角 4.2
次方), 形变用的是 beUI 的 Dynamic Island 那套弹簧:壳 0.8 秒、回弹 0.2,内容层回弹 0.35。
</p>
<ul class="facts">
<li>装着 SQLite 就能跑,需要时一键换到 MySQL —— 数据也跟着搬过去。</li>
<li>课表由教室的 ClassIsland 上报,不用在小助手这边再排一遍。</li>
<li>教室把通知交给哪个端,由教室那台机器自己选:客户端、大屏,或者两边都弹。</li>
</ul>
</div>
</section>
<!-- 分幕标题:居中大标题 + 中间一段渐变高亮(照 ClassIsland 官网的分节写法) -->
<section class="band act" aria-label="第一部分:它是怎么工作的">
<div class="wrap">
<!-- prettier-ignore -->
<h2 class="act-title">它是<span class="act-hl">怎么工作</span>的</h2>
</div>
</section>
<section class="band band-board" id="journey">
<div class="wrap">
<h2 class="h2">从按下「发布」到教室屏幕上弹出来</h2>
<p class="lead narrow">
中间只隔着一条 WebSocket 广播。实测从老师提交到教室机器收到,是 30 到 50 毫秒 —— 这个数字由
<code>pnpm verify:e2e</code>
每次跑的时候重新量,不是写在文档里的估计值。
</p>
<ol class="pipeline">
<li>
<p class="pipe-step">老师发布</p>
<p class="pipe-body">Web 管理端或手机浏览器。通知、作业、叫人、成绩都在这里录。</p>
</li>
<li>
<p class="pipe-step">后端落库</p>
<p class="pipe-body">Express 5 + Prisma 7 写进库,再按班级房间广播一条事件。</p>
</li>
<li>
<p class="pipe-step">教室收到</p>
<p class="pipe-body">
Socket.IO 推到
<code>class:班级</code>
房间 —— 教室机器、学生机、大屏同时收到。
</p>
</li>
<li>
<p class="pipe-step">判一次时间</p>
<p class="pipe-body">课表服务回答「现在上不上课」,当场弹还是先收着,这一步就定下来了。</p>
</li>
<li>
<p class="pipe-step">两端弹出</p>
<p class="pipe-body">灵动岛展开成卡片,ClassIsland 同时全屏提醒,可以语音朗读。</p>
</li>
</ol>
</div>
</section>
<!-- ═══════════════════════ 上课时段策略 ═══════════════════════ -->
<section class="band band-board" id="policy">
<div class="wrap">
<h2 class="h2">上课的时候,通知会先排队</h2>
<p class="lead narrow">
判定来自课表本身,不是按时钟拍脑袋。把下面这条时间轴拨一下,看看同样的通知在课上和课间分别会怎样。
</p>
<div class="policy" id="policy-widget">
<div class="policy-clock">
<p class="policy-time" id="policy-time">09:20</p>
<p class="policy-state" id="policy-state">正在上 第三节 · 数学</p>
</div>
<div class="day">
<div class="day-track" id="day-track" aria-hidden="true"></div>
<div class="day-marker" id="day-marker" aria-hidden="true"></div>
<input
class="day-input"
id="day-input"
type="range"
min="420"
max="1030"
step="1"
value="560"
aria-label="拖动选择一天中的时刻,查看通知在两个端上的表现"
/>
<div class="day-scale" aria-hidden="true">
<span>07:00</span>
<span>10:00</span>
<span>13:00</span>
<span>16:00</span>
<span>17:10</span>
</div>
</div>
<div class="outcomes">
<div class="outcome">
<p class="outcome-what">普通通知</p>
<p class="outcome-where">学生机 · 灵动岛</p>
<p class="outcome-say" id="o-normal-client">先收着,打下课铃自动弹出来</p>
</div>
<div class="outcome">
<p class="outcome-what">普通通知</p>
<p class="outcome-where">教室大屏 · ClassIsland</p>
<p class="outcome-say" id="o-normal-screen">先收着,下课补弹</p>
</div>
<div class="outcome outcome-alert">
<p class="outcome-what">紧急通知 / 叫人</p>
<p class="outcome-where">教室大屏 · ClassIsland</p>
<p class="outcome-say" id="o-urgent-screen">立刻全屏弹出,不打断也拦不住</p>
</div>
<div class="outcome">
<p class="outcome-what">不管什么时候</p>
<p class="outcome-where">通知中心</p>
<p class="outcome-say">照常记一条,未读红点随时可查,不会被吞掉</p>
</div>
</div>
<p class="policy-note">
普通叫人(默认级别)上课时只进队列,下课后自动弹出详情;标成紧急的才当场打断课堂 ——
那是老师正在等学生。老师在 Web 端发紧急通知时会先撞上全屏二次确认,确认按钮带 3 秒倒计时,
防止手滑。这一层拦在服务端,绕过界面直接调接口也一样会被拒。
</p>
</div>
</div>
</section>
<!-- 分幕标题:居中大标题 + 中间一段渐变高亮(照 ClassIsland 官网的分节写法) -->
<section class="band act" aria-label="第二部分:它是怎么构成的">
<div class="wrap">
<!-- prettier-ignore -->
<h2 class="act-title">它是<span class="act-hl">怎么构成</span>的</h2>
</div>
</section>
<section class="band band-paper" id="parts">
<div class="wrap">
<h2 class="h2">一份代码,四个交付物</h2>
<p class="lead narrow">
一个 pnpm monorepo。三端共用同一份类型与权限矩阵,所以不会出现「Web 端说有、客户端说没有」。
</p>
<div class="table-scroll">
<table class="sheet">
<caption class="sr-only">四个交付物、它们的形态与使用者</caption>
<thead>
<tr>
<th scope="col">交付物</th>
<th scope="col">形态</th>
<th scope="col">谁在用</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">后端服务</th>
<td>
Express 5 + Prisma 7 + Socket.IO,接口前缀
<code>/api</code>
</td>
<td>其它三端</td>
</tr>
<tr>
<th scope="row">Web 管理端</th>
<td>Vue 3 + Element Plus,手机可用,可在浏览器里装成应用</td>
<td>老师、管理员</td>
</tr>
<tr>
<th scope="row">桌面客户端</th>
<td>Electron + Vue 3,带灵动岛与系统托盘,断网可用</td>
<td>教室机器、学生机</td>
</tr>
<tr>
<th scope="row">ClassIsland 联动插件</th>
<td>.NET 8 插件,装进教室已有的 ClassIsland 里</td>
<td>教室大屏</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ═══════════════════════ 实机截图 ═══════════════════════ -->
<section class="band band-board" id="shots">
<div class="wrap">
<h2 class="h2">每个界面都是跑起来截的</h2>
<p class="lead narrow">
下面没有一张是画的示意。这些图是脚本真的把后端、Web 管理端、教室客户端和灵动岛跑起来之后
一屏一屏截下来的 —— 数据也是库里真实的数据。
</p>
<div class="shots" id="shots-widget">
<div class="shots-tabs" role="tablist" aria-label="按端筛选截图">
<span class="shots-indicator" data-tabs-indicator aria-hidden="true"></span>
</div>
<div class="shots-grid" id="shots-grid" role="tabpanel" tabindex="0"></div>
</div>
<p class="shots-hint">点任意一张看大图。</p>
</div>
</section>
<!-- 大图浮层:从被点的那张缩略图原地展开(beUI 的 morphing lightbox 用共享 layoutId,这里用 FLIP 等价实现) -->
<div class="lightbox" id="lightbox" hidden>
<div class="lightbox-backdrop" id="lightbox-backdrop"></div>
<div class="lightbox-panel" id="lightbox-panel">
<img class="lightbox-img" id="lightbox-img" alt="" />
</div>
<p class="lightbox-cap" id="lightbox-cap"></p>
<button class="lightbox-close" id="lightbox-close" type="button" aria-label="关闭大图">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<!-- ═══════════════════════ 纸张:功能 ═══════════════════════ -->
<section class="band band-paper" id="features">
<div class="wrap">
<h2 class="h2">老师那边,教室这边</h2>
<div class="feature-groups">
<div class="feature-group">
<h3 class="h3">老师与管理员</h3>
<div class="feature-blocks">
<div class="feature-block">
<p class="feature-tag">#班级人员</p>
<h4 class="feature-title">建班、换班主任、录学生</h4>
<p class="feature-body">
名单可以直接导入表格。教师录入与重置密码只对管理员开放,班主任也不行。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#课表</p>
<h4 class="feature-title">按周排课,支持单双周</h4>
<p class="feature-body">
也可以直接导入 ClassIsland 的档案 JSON —— 节次时间跟着课表一起进来,不用排两遍。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#作业</p>
<h4 class="feature-title">按「所属日期」归类</h4>
<p class="feature-body">
晚上八点留的作业算今天。教室机器也能录;未交名单勾一下,其余同学自动标成已完成。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#通知与叫人</p>
<h4 class="feature-title">分优先级发布,点名分两档</h4>
<p class="feature-body">
普通叫人上课时先收着,紧急的才当场打断课堂 —— 那是老师正在等学生。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#成绩</p>
<h4 class="feature-title">单条、批量,或者整张表导进来</h4>
<p class="feature-body">
重复判定按「同班 + 同学生 + 同考试 + 同课程」,导入报错精确到 Excel 行号,改完再导。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#数据库</p>
<h4 class="feature-title">备份、导入导出、一键换库</h4>
<p class="feature-body">
SQLite 与 MySQL 互相搬数据,中途失败会自动回滚 —— 现有的库不会被动到。
</p>
</div>
</div>
</div>
<div class="feature-group">
<h3 class="h3">教室机器与学生机</h3>
<div class="feature-blocks">
<div class="feature-block">
<p class="feature-tag">#课表时间轴</p>
<h4 class="feature-title">大时钟、正在上的课、距下课倒计时</h4>
<p class="feature-body">已结束的置灰,正在上的带进度条,下一节还有多久开始 —— 一眼看完。</p>
</div>
<div class="feature-block">
<p class="feature-tag">#作业看板</p>
<h4 class="feature-title">按科目分卡,整屏缩放到刚好放下</h4>
<p class="feature-body">
标题和要求全文不截断,也不用上下翻页;全屏看板会自己算最合适的列数。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#灵动岛</p>
<h4 class="feature-title">消息到达的第一现场</h4>
<p class="feature-body">
无边框透明置顶的小窗,通知、作业、叫人在里面展开成卡片;鼠标穿透,不占任务栏。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#离线缓存</p>
<h4 class="feature-title">断网的时候接着看</h4>
<p class="feature-body">
课表、作业、通知、成绩先落本地,断网顶部提示离线、照旧显示,网络恢复自己刷新。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#外观</p>
<h4 class="feature-title">深浅两套,岛也能整</h4>
<p class="feature-body">
高度、宽度、圆角、字号、停靠位置、视觉风格都能调;越界的值在主进程统一夹紧。
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 分幕标题:居中大标题 + 中间一段渐变高亮(照 ClassIsland 官网的分节写法) -->
<section class="band act" aria-label="第三部分:它是怎么到你手里的">
<div class="wrap">
<!-- prettier-ignore -->
<h2 class="act-title">它是<span class="act-hl">怎么到你手里</span>的</h2>
</div>
</section>
<section class="band band-paper" id="deploy">
<div class="wrap">
<h2 class="h2">四种装法,按场景挑</h2>
<div class="table-scroll">
<table class="sheet">
<caption class="sr-only">四种部署形态与各自入口</caption>
<thead>
<tr>
<th scope="col">场景</th>
<th scope="col">形态</th>
<th scope="col">入口</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">教师电脑、学校机房单机</th>
<td>Windows 服务端安装程序,内置 Node 运行时</td>
<td>双击安装,自动建库建号、开机自启</td>
</tr>
<tr>
<th scope="row">云服务器、多终端共享</th>
<td>Docker Compose,MySQL 与 SQLite 两种编排都在仓库里</td>
<td><code>deploy/docker-compose.yml</code></td>
</tr>
<tr>
<th scope="row">已有 Linux 服务器</th>
<td>
一行命令装成 systemd 服务,附带
<code>classhelper</code>
运维命令
</td>
<td>见下面的命令</td>
</tr>
<tr>
<th scope="row">已有 Node 环境</th>
<td>免安装目录,自带 node.exe</td>
<td>
<code>pnpm dist:server</code>
的产物
</td>
</tr>
</tbody>
</table>
</div>
<div class="snippet">
<div class="snippet-head">
<span class="snippet-label">Linux 服务器上跑这一条</span>
<button
class="copy"
type="button"
data-press="0.97"
data-copy="#cmd-linux"
data-copy-done="已复制"
>
<span class="copy-label">复制</span>
</button>
</div>
<pre
id="cmd-linux"
><code>curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh &amp;&amp; sudo bash /tmp/classhelper-install.sh</code></pre>
</div>
<p class="lead narrow">
脚本自己从 GitHub Release 取最新版、核对发布页上的
<code>.sha256</code>
,再装成 systemd 服务。 想先不改动系统,把末尾换成
<code>--check</code>
就只体检。装完之后,
<code>classhelper upgrade</code>
、
<code>backup</code>
、
<code>doctor</code>
、
<code>password</code>
这些命令都在
<code>/usr/local/bin</code>
里等着。
</p>
<p class="lead narrow">
学生机这边是安装包和单文件版两种:安装包走 NSIS,单文件版不用装、放 U 盘里就能分发。
两个都不写死服务器地址 —— 第一次打开时填教室的地址和班级码就行。
</p>
</div>
</section>
<!-- ═══════════════════════ 验收 ═══════════════════════ -->
<section class="band band-board" id="verify">
<div class="wrap">
<h2 class="h2">每个功能都配了一条能复跑的用例</h2>
<p class="lead narrow">
仓库里带着这几套脚本,跑到哪儿、过了几条由脚本自己数,不是文档里写死的数字。 交付前它们必须全绿 ——
包括对
<strong>已经打包装好</strong>
的那份客户端再跑一遍。
</p>
<div class="ledger">
<div class="ledger-row">
<code class="ledger-cmd">pnpm verify:e2e</code>
<span class="ledger-what">
接口、实时推送、权限越权、上课时段、表格导入、班级账号、数据库管理、更新检查
</span>
<span class="ledger-num"><span data-ticker="190">190</span></span>
</div>
<div class="ledger-row">
<code class="ledger-cmd">pnpm verify:desktop</code>
<span class="ledger-what">客户端冒烟:登录、离线回退、灵动岛开合与命中、设置页真实点击</span>
<span class="ledger-num"><span data-ticker="105">105</span></span>
</div>
<div class="ledger-row">
<code class="ledger-cmd">pnpm verify:packaged</code>
<span class="ledger-what">对打包后 / 已安装的那份 EXE 复跑同一套冒烟</span>
<span class="ledger-num"><span data-ticker="105">105</span></span>
</div>
<div class="ledger-row">
<code class="ledger-cmd">pnpm verify:classisland</code>
<span class="ledger-what">设备令牌、课表上报、提醒下发与回执、镜像契约、幽灵行清理</span>
<span class="ledger-num"><span data-ticker="42">42</span></span>
</div>
<div class="ledger-row">
<code class="ledger-cmd">pnpm verify:classisland-plugin</code>
<span class="ledger-what">插件清单、注册完整性、C# DTO 与服务端校验逐字段对齐</span>
<span class="ledger-num"><span data-ticker="68">68</span></span>
</div>
</div>
<p class="ledger-foot">
上面是 2026-10-05 那一次的结果。这几套脚本每天都在跑,数字会变;跑到多少条,以脚本自己打印的为准。
</p>
</div>
</section>
<!-- 收尾 CTA:和 ClassIsland 官网一样,末尾放一整块「下一步做什么」 -->
<section class="band cta" aria-label="开始使用">
<div class="wrap">
<!-- prettier-ignore -->
<h2 class="cta-title">剩下的,<span class="act-hl">留给你自己试</span></h2>
<p class="cta-lead">
一台机器装完就能跑起来,学生机和教室大屏接上同一个地址就行。装好之后还有什么, 都在界面里 ——
不用先读一遍文档。
</p>
<div class="cta-actions">
<a class="btn btn-solid" href="#deploy" data-press="0.93">取安装包</a>
<a
class="btn btn-line"
href="https://github.com/LoveChengke/ClassHelper"
target="_blank"
rel="noreferrer"
data-press="0.93"
>
看源码
</a>
</div>
</div>
</section>
</main>
<!-- ═══════════════════════ 页脚 ═══════════════════════ -->
<footer class="foot">
<div class="wrap foot-inner">
<div class="foot-brand">
<img src="assets/icon.png" alt="" width="36" height="36" />
<div>
<p class="foot-name">
班级小助手
<span>Class Helper</span>
</p>
<p class="foot-ver">版本 1.1.2 · 源码公开 · 装在你自己的机器上</p>
</div>
</div>
<nav class="foot-nav" aria-label="相关链接">
<a href="https://github.com/LoveChengke/ClassHelper" target="_blank" rel="noreferrer">源码仓库</a>
<a
href="https://github.com/LoveChengke/ClassHelper/blob/master/docs/production.md"
target="_blank"
rel="noreferrer"
>
生产部署
</a>
<a
href="https://github.com/LoveChengke/ClassHelper/blob/master/docs/linux-deploy.md"
target="_blank"
rel="noreferrer"
>
Linux 运维
</a>
<a
href="https://github.com/LoveChengke/ClassHelper/blob/master/docs/mysql.md"
target="_blank"
rel="noreferrer"
>
切换 MySQL
</a>
<a href="https://classisland.tech" target="_blank" rel="noreferrer">ClassIsland</a>
</nav>
</div>
</footer>
<script src="assets/motion.js" defer></script>
<script src="assets/main.js" defer></script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
/**
* 官网本地预览用的最小静态服务器 —— 只是为了 `node serve.mjs` 能直接看页面。
* 正式发布时把 website/ 整个目录丢给任意静态托管 / Nginx 即可,不需要这个文件。
*
* node website/serve.mjs → http://127.0.0.1:5180
* PORT=8080 node website/serve.mjs
*/
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)));
const PORT = Number(process.env.PORT ?? 5180);
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json',
'.woff2': 'font/woff2',
};
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? '/', 'http://localhost');
const requested = decodeURIComponent(url.pathname);
const target = normalize(join(ROOT, requested === '/' ? 'index.html' : requested));
// 目录穿越防护:解析结果必须仍在 ROOT 里
if (target !== ROOT && !target.startsWith(ROOT + sep)) {
response.writeHead(403).end('Forbidden');
return;
}
try {
const info = await stat(target);
const file = info.isDirectory() ? join(target, 'index.html') : target;
const body = await readFile(file);
response.writeHead(200, {
'content-type': MIME[extname(file)] ?? 'application/octet-stream',
'cache-control': 'no-store',
});
response.end(body);
} catch {
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('404');
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`官网预览:http://127.0.0.1:${PORT}`);
});
+279
View File
@@ -0,0 +1,279 @@
/**
* 给官网采集 **真实运行** 的实机截图。
*
* 复用仓库里已安装的 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);
}
}