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

+11 -10
View File
@@ -9,7 +9,7 @@ name: 发布 Linux 服务端安装包
# #
# 用法: # 用法:
# 1. 先在本地按惯例打出 Windows 包并发 Release(vX.Y.Z); # 1. 先在本地按惯例打出 Windows 包并发 Release(vX.Y.Z);
# 2. 到 Actions 页面手动运行本 workflow,填 tag(例如 v1.1.0); # 2. 到 Actions 页面手动运行本 workflow,填 tag(例如 v1.1.2);
# 3. 跑完 Release 里会多出 Linux 包与更新后的 SHA256SUMS-<版本>.txt。 # 3. 跑完 Release 里会多出 Linux 包与更新后的 SHA256SUMS-<版本>.txt。
# #
# 也可以只跑构建不挂 Release(attach=false),产物在 workflow 的 Artifacts 里。 # 也可以只跑构建不挂 Release(attach=false),产物在 workflow 的 Artifacts 里。
@@ -18,7 +18,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: '要构建并挂载的 Release tag(例如 v1.1.0)' description: '要构建并挂载的 Release tag(例如 v1.1.2)'
required: true required: true
type: string type: string
attach: attach:
@@ -81,15 +81,15 @@ jobs:
run: pnpm dist:server:linux run: pnpm dist:server:linux
- name: 展示产物 - name: 展示产物
run: ls -la release-server/ run: ls -la releases/server/*/linux-x64/
- name: 上传构建产物(Artifacts) - name: 上传构建产物(Artifacts)
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: classhelper-server-linux-x64 name: classhelper-server-linux-x64
path: | path: |
release-server/classhelper-server-linux-x64-*.tar.gz releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz
release-server/classhelper-server-linux-x64-*.tar.gz.sha256 releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz.sha256
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -101,9 +101,10 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
VERSION="${TAG#v}" VERSION="${TAG#v}"
TARBALL="release-server/classhelper-server-linux-x64-${VERSION}.tar.gz" ARTIFACT_DIR="releases/server/${VERSION}/linux-x64"
TARBALL="${ARTIFACT_DIR}/classhelper-server-linux-x64-${VERSION}.tar.gz"
SHAFILE="${TARBALL}.sha256" SHAFILE="${TARBALL}.sha256"
SUMS="release-server/SHA256SUMS-${VERSION}.txt" SUMS="releases/SHA256SUMS-${VERSION}.txt"
if [ ! -f "$TARBALL" ]; then if [ ! -f "$TARBALL" ]; then
echo "::error::没有找到 $TARBALL(tag 与 package.json 的版本号对得上吗?)" echo "::error::没有找到 $TARBALL(tag 与 package.json 的版本号对得上吗?)"
@@ -112,13 +113,13 @@ jobs:
# .sha256 必须跟着一起传:它是独立发布的资产,只更新 tar.gz 会让这个文件指向旧哈希, # .sha256 必须跟着一起传:它是独立发布的资产,只更新 tar.gz 会让这个文件指向旧哈希,
# 拿它校验的人会得到 mismatch(classhelper upgrade 用的是 --sha256/这个文件,不是 SHA256SUMS)。 # 拿它校验的人会得到 mismatch(classhelper upgrade 用的是 --sha256/这个文件,不是 SHA256SUMS)。
if [ ! -f "$SHAFILE" ]; then if [ ! -f "$SHAFILE" ]; then
sha256sum "$TARBALL" | sed "s#release-server/##" > "$SHAFILE" sha256sum "$TARBALL" | sed "s#${ARTIFACT_DIR}/##" > "$SHAFILE"
fi fi
# 取回已有的校验清单(Windows 包那几行),只替换/追加本包这一行,其余内容原样保留。 # 取回已有的校验清单(Windows 包那几行),只替换/追加本包这一行,其余内容原样保留。
# 注意**不要**用 `sort -u` 去重:那份文件是人读的(表头 + 体积 + 说明),排序会把它们打散。 # 注意**不要**用 `sort -u` 去重:那份文件是人读的(表头 + 体积 + 说明),排序会把它们打散。
gh release download "$TAG" --pattern "SHA256SUMS-*.txt" --dir release-server --clobber || true gh release download "$TAG" --pattern "SHA256SUMS-*.txt" --dir releases --clobber || true
DOWNLOADED="$(ls release-server/SHA256SUMS-*.txt 2>/dev/null | head -n 1 || true)" DOWNLOADED="$(ls releases/SHA256SUMS-*.txt 2>/dev/null | head -n 1 || true)"
if [ -n "$DOWNLOADED" ] && [ "$DOWNLOADED" != "$SUMS" ]; then mv "$DOWNLOADED" "$SUMS"; fi if [ -n "$DOWNLOADED" ] && [ "$DOWNLOADED" != "$SUMS" ]; then mv "$DOWNLOADED" "$SUMS"; fi
touch "$SUMS" touch "$SUMS"
grep -v "^[0-9a-f]\{64\} classhelper-server-linux-" "$SUMS" > "${SUMS}.new" || true grep -v "^[0-9a-f]\{64\} classhelper-server-linux-" "$SUMS" > "${SUMS}.new" || true
+12 -7
View File
@@ -181,12 +181,14 @@ if [ -n "$TOKEN" ]; then
check "审计日志里没有新密码明文" "$(grep -q -- "$GENERATED" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)" check "审计日志里没有新密码明文" "$(grep -q -- "$GENERATED" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)"
check "审计日志里没有原密码明文" "$(grep -q -- "$ADMIN_PASSWORD" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)" check "审计日志里没有原密码明文" "$(grep -q -- "$ADMIN_PASSWORD" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)"
check "弱密码被拒绝(长度/种数不足)" \ # 先把输出收进变量再 grep:`外部命令 | grep -q` 在 `set -o pipefail` 下,grep 命中即退出会让
"$(printf 'abc\nabc\n' | "$CLASSHELPER" password "$ADMIN_USER" 2>&1 | grep -q '不合规' && echo 0 || echo 1)" # 写端收到 SIGPIPE(退出码 141),于是"匹配到了"被算成失败
weak_out="$(printf 'abc\nabc\n' | "$CLASSHELPER" password "$ADMIN_USER" 2>&1 || true)"
check "弱密码被拒绝(长度/种数不足)" "$(printf '%s' "$weak_out" | grep -q '不合规' && echo 0 || echo 1)"
check "密码不合规时不会卡死(stdin 结束即退出)" \ check "密码不合规时不会卡死(stdin 结束即退出)" \
"$(printf 'abc\n' | timeout 20 "$CLASSHELPER" password "$ADMIN_USER" >/dev/null 2>&1; [ $? -ne 124 ] && echo 0 || echo 1)" "$(printf 'abc\n' | timeout 20 "$CLASSHELPER" password "$ADMIN_USER" >/dev/null 2>&1; [ $? -ne 124 ] && echo 0 || echo 1)"
check "password --list 能列出账号" \ list_out="$("$CLASSHELPER" password --list 2>&1 || true)"
"$("$CLASSHELPER" password --list 2>&1 | grep -q "$ADMIN_USER" && echo 0 || echo 1)" check "password --list 能列出账号" "$(printf '%s' "$list_out" | grep -q "$ADMIN_USER" && echo 0 || echo 1)"
# 用改密前保存的哈希把账号恢复原样:比再改一次密码更可靠 # 用改密前保存的哈希把账号恢复原样:比再改一次密码更可靠
# (原密码若是 admin123 这类弱口令,改回去会被强度校验挡下 —— 那是产品的正确行为) # (原密码若是 admin123 这类弱口令,改回去会被强度校验挡下 —— 那是产品的正确行为)
@@ -223,7 +225,8 @@ if [ -n "$latest" ]; then
fi fi
check "备份目录权限 700" "$([ "$(stat -c '%a' "${latest%/}" 2>/dev/null)" = "700" ] && echo 0 || echo 1)" check "备份目录权限 700" "$([ "$(stat -c '%a' "${latest%/}" 2>/dev/null)" = "700" ] && echo 0 || echo 1)"
fi fi
check "backup list 能列出备份" "$("$CLASSHELPER" backup list 2>&1 | grep -qE '^ [0-9]{8}-' && echo 0 || echo 1)" backup_list_out="$("$CLASSHELPER" backup list 2>&1 || true)"
check "backup list 能列出备份" "$(printf '%s' "$backup_list_out" | grep -qE '^ [0-9]{8}-' && echo 0 || echo 1)"
# ---------------------------------------------------------------- 6. 配置 # ---------------------------------------------------------------- 6. 配置
@@ -248,8 +251,10 @@ if [ -n "$TOKEN" ]; then
note "按环境变量跳过" note "按环境变量跳过"
else else
old_secret="$(read_cfg JWT_SECRET)" old_secret="$(read_cfg JWT_SECRET)"
# key rotate 会问一次确认;无 tty 时从 stdin 读,因此用管道喂 yes # key rotate 无 tty 时会问两次(确认轮换、是否现在重启);只喂一个 yes 的话第二次读到 EOF,
printf 'yes\n' | CLASSHELPER_NO_TTY=1 "$CLASSHELPER" key rotate >/dev/null 2>&1 || true # 重启被跳过 —— 新密钥不生效,下面「旧 token 已失效」必然失败。
# 用 CLASSHELPER_ASSUME_YES=1 跳过问答并强制重启,语义才是"轮换后旧会话立即失效"。
CLASSHELPER_NO_TTY=1 CLASSHELPER_ASSUME_YES=1 "$CLASSHELPER" key rotate >/dev/null 2>&1 || true
new_secret="$(read_cfg JWT_SECRET)" new_secret="$(read_cfg JWT_SECRET)"
check "key rotate 改变了 JWT_SECRET" "$([ -n "$new_secret" ] && [ "$new_secret" != "$old_secret" ] && echo 0 || echo 1)" check "key rotate 改变了 JWT_SECRET" "$([ -n "$new_secret" ] && [ "$new_secret" != "$old_secret" ] && echo 0 || echo 1)"
check "新密钥长度 ≥32" "$([ "${#new_secret}" -ge 32 ] && echo 0 || echo 1)" check "新密钥长度 ≥32" "$([ "${#new_secret}" -ge 32 ] && echo 0 || echo 1)"
+33 -9
View File
@@ -3,10 +3,33 @@
本文覆盖服务端在 Linux 上的**一键安装**与日常运维(`classhelper` 命令)。 本文覆盖服务端在 Linux 上的**一键安装**与日常运维(`classhelper` 命令)。
适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+(自动识别 apt / yum / dnf)。 适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+(自动识别 apt / yum / dnf)。
不想看长文的话,最短路径是这两条命令: 不想看长文的话,最短路径就是这**一行**(在服务器上执行):
```bash ```bash
sudo bash install.sh --package /path/to/classhelper-server-linux-x64-1.1.0.tar.gz curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh && sudo bash /tmp/classhelper-install.sh
```
它自己会把整套流程走完:识别发行版 → 补依赖 → 建系统用户 → **从 GitHub Release 取最新版服务端包
并校验发布页上的 `.sha256`** → 下载内置 Node 运行时 → 建库建号 → 装成 systemd 服务。装完直接:
```bash
sudo classhelper status
```
参数接在最后即可,例如先只体检(不需要 root、不改动系统):
```bash
curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh && bash /tmp/classhelper-install.sh --check
```
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`,
> 不是 `$1` —— 脚本收不到它(实测踩到:本意是体检,结果走进了交互式安装)。
> 先落盘再执行没这个坑,也不会因为下载中断而执行到半个脚本。
以下是"已有安装包 / 内网离线"的用法:
```bash
sudo bash install.sh --package /path/to/classhelper-server-linux-x64-1.1.2.tar.gz
sudo classhelper status sudo classhelper status
``` ```
@@ -41,7 +64,7 @@ Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不
仓库里的 [`.github/workflows/release-linux-server.yml`](../.github/workflows/release-linux-server.yml) 仓库里的 [`.github/workflows/release-linux-server.yml`](../.github/workflows/release-linux-server.yml)
就是干这个的(拿 ubuntu runner 跑 `pnpm dist:server:linux`,再把产物挂到对应 Release 上): 就是干这个的(拿 ubuntu runner 跑 `pnpm dist:server:linux`,再把产物挂到对应 Release 上):
1. 本地照惯例发 Windows 包的 Release(tag 形如 `v1.1.0`); 1. 本地照惯例发 Windows 包的 Release(tag 形如 `v1.1.2`);
2. GitHub → Actions → 「发布 Linux 服务端安装包」→ Run workflow → 填 tag → 运行; 2. GitHub → Actions → 「发布 Linux 服务端安装包」→ Run workflow → 填 tag → 运行;
3. 跑完 Release 里会多出 Linux 包与更新后的 `SHA256SUMS-<版本>.txt`。 3. 跑完 Release 里会多出 Linux 包与更新后的 `SHA256SUMS-<版本>.txt`。
@@ -67,7 +90,7 @@ Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不
```bash ```bash
# 开发机(Windows 也可以) # 开发机(Windows 也可以)
pnpm dist:server:linux pnpm dist:server:linux
# → release-server/classhelper-server-linux-x64-<版本>.tar.gz(+ .sha256) # → releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz(+ .sha256)
# 重跑很快:node scripts/dist-server.mjs --platform linux --reuse-deps(复用已装的 node_modules) # 重跑很快:node scripts/dist-server.mjs --platform linux --reuse-deps(复用已装的 node_modules)
``` ```
@@ -88,17 +111,17 @@ TUI 会依次问:形态 → 安装目录 → 端口 → 数据库 → 初始
```bash ```bash
# 全部默认:/opt/classhelper、4000 端口、SQLite、内置 Node # 全部默认:/opt/classhelper、4000 端口、SQLite、内置 Node
sudo bash install.sh --yes --package ./classhelper-server-linux-x64-1.1.0.tar.gz sudo bash install.sh --yes --package ./classhelper-server-linux-x64-1.1.2.tar.gz
# 指定目录与端口,从 stdin 传初始管理员密码(不进 argv / shell history) # 指定目录与端口,从 stdin 传初始管理员密码(不进 argv / shell history)
printf '%s\n' 'MyStrongPass!2026' | sudo bash install.sh --yes \ printf '%s\n' 'MyStrongPass!2026' | sudo bash install.sh --yes \
--dir /srv/classhelper --port 8080 --admin-password-stdin \ --dir /srv/classhelper --port 8080 --admin-password-stdin \
--package ./classhelper-server-linux-x64-1.1.0.tar.gz --package ./classhelper-server-linux-x64-1.1.2.tar.gz
# MySQL 形态 # MySQL 形态
sudo bash install.sh --yes --database mysql --mysql-host 10.0.0.5 --mysql-db classhelper \ sudo bash install.sh --yes --database mysql --mysql-host 10.0.0.5 --mysql-db classhelper \
--mysql-user classhelper --mysql-password-stdin \ --mysql-user classhelper --mysql-password-stdin \
--package ./classhelper-server-linux-x64-1.1.0.tar.gz <<< 'MySQL密码' --package ./classhelper-server-linux-x64-1.1.2.tar.gz <<< 'MySQL密码'
``` ```
完整参数见 `bash install.sh --help`。要点: 完整参数见 `bash install.sh --help`。要点:
@@ -403,6 +426,7 @@ sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
| --- | --- | | --- | --- |
| 服务起不来 | `classhelper doctor` → `classhelper logs -n 100`。常见:端口被占用、MySQL 连接串错、`data/` 权限 | | 服务起不来 | `classhelper doctor` → `classhelper logs -n 100`。常见:端口被占用、MySQL 连接串错、`data/` 权限 |
| 装完打不开页面 | `classhelper status` 看探针与监听地址;云服务器还要在**安全组**放行端口 | | 装完打不开页面 | `classhelper status` 看探针与监听地址;云服务器还要在**安全组**放行端口 |
| 页面能打开但是**空白** | 1.1.0 的服务端在 HTTP 直连访问时会下发 CSP `upgrade-insecure-requests`,浏览器把所有子资源改写成 https 加载而服务器没有 TLS,于是整页空白(HTML/接口都正常)。已修复的版本按请求协议条件下发;若暂不能升级,唯一正解是上 HTTPS 反代 |
| 改了配置没生效 | 是否重启过(`classhelper config set` 会问);`EnvironmentFile` 的值优先于文件里的同项 | | 改了配置没生效 | 是否重启过(`classhelper config set` 会问);`EnvironmentFile` 的值优先于文件里的同项 |
| Web 端切库后还是连旧库 | `classhelper doctor` 看 `.env` 软链是否被破坏(`ln -sfn /etc/classhelper/config.env <安装目录>/.env`) | | Web 端切库后还是连旧库 | `classhelper doctor` 看 `.env` 软链是否被破坏(`ln -sfn /etc/classhelper/config.env <安装目录>/.env`) |
| 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB | | 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB |
@@ -447,7 +471,7 @@ Node 官方的 linux-x64 二进制要求 **glibc ≥ 2.28**,而 CentOS 7 是 2
```bash ```bash
# 在开发机:做出 Linux 包(或在 Actions 里下载) # 在开发机:做出 Linux 包(或在 Actions 里下载)
pnpm dist:server:linux pnpm dist:server:linux
scp release-server/classhelper-server-linux-x64-*.tar.gz root@<服务器>:/tmp/ scp releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz root@<服务器>:/tmp/
scp deploy/install.sh deploy/verify-linux.sh root@<服务器>:/tmp/ scp deploy/install.sh deploy/verify-linux.sh root@<服务器>:/tmp/
``` ```
@@ -464,7 +488,7 @@ bash /tmp/install.sh --check
```bash ```bash
sudo bash /tmp/install.sh --yes \ sudo bash /tmp/install.sh --yes \
--package /tmp/classhelper-server-linux-x64-1.1.0.tar.gz \ --package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz \
--admin-password-stdin <<< 'Verify-Pass-2026!' --admin-password-stdin <<< 'Verify-Pass-2026!'
``` ```
+19 -13
View File
@@ -3,7 +3,7 @@
* *
* 用法: * 用法:
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release * node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
* node scripts/build.mjs --cipx # 追加打包 .cipx(ClassIsland 插件包)并归集到 releases/classisland-plugin/ * node scripts/build.mjs --cipx # 追加打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`) * 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
* *
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍): * 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'node:url';
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(pluginDir, '..', '..'); const repoRoot = path.resolve(pluginDir, '..', '..');
const cacheDir = path.join(repoRoot, '.cache'); const cacheDir = path.join(repoRoot, '.cache');
const releasesDir = path.join(repoRoot, 'releases', 'classisland-plugin'); const releasesRoot = path.join(repoRoot, 'releases', 'classisland-plugin');
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const withCipx = argv.includes('--cipx'); const withCipx = argv.includes('--cipx');
@@ -146,14 +146,19 @@ if (!fs.existsSync(cipxFile)) {
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`); throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
} }
// 归集到 releases/classisland-plugin/(与客户端、服务端交付产物同级;releases/ 不入库) // 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。
fs.rmSync(releasesDir, { recursive: true, force: true }); // 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把
fs.mkdirSync(releasesDir, { recursive: true }); // 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。
fs.copyFileSync(cipxFile, path.join(releasesDir, path.basename(cipxFile))); const pluginVersion = manifestVersion.split('.').slice(0, 3).join('.');
const versionDir = path.join(releasesRoot, pluginVersion);
const installerDir = path.join(versionDir, '安装包');
const portableDir = path.join(versionDir, '免安装', 'ClassHelper.ClassIslandPlugin');
fs.rmSync(installerDir, { recursive: true, force: true });
fs.rmSync(path.dirname(portableDir), { recursive: true, force: true });
fs.mkdirSync(installerDir, { recursive: true });
fs.mkdirSync(portableDir, { recursive: true });
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载 // 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
const portableDir = path.join(releasesDir, 'ClassHelper.ClassIslandPlugin');
fs.mkdirSync(portableDir, { recursive: true });
for (const name of fs.readdirSync(outputDir)) { for (const name of fs.readdirSync(outputDir)) {
const from = path.join(outputDir, name); const from = path.join(outputDir, name);
if (!fs.statSync(from).isFile()) continue; if (!fs.statSync(from).isFile()) continue;
@@ -165,10 +170,11 @@ for (const name of fs.readdirSync(outputDir)) {
} }
for (const file of fs.readdirSync(cipxDir)) { for (const file of fs.readdirSync(cipxDir)) {
fs.copyFileSync(path.join(cipxDir, file), path.join(releasesDir, file)); // .cipx 是插件包本体,校验清单跟着版本目录走
const to = file.toLowerCase().endsWith('.cipx') ? installerDir : versionDir;
fs.copyFileSync(path.join(cipxDir, file), path.join(to, file));
} }
log(`插件包已归集到 ${path.relative(repoRoot, releasesDir)}`); log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`);
for (const file of fs.readdirSync(releasesDir)) { for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`);
log(` ${file}`); for (const file of fs.readdirSync(portableDir)) log(` 免安装/ClassHelper.ClassIslandPlugin/${file}`);
}
+6 -1
View File
@@ -15,6 +15,8 @@ electronLanguages:
- zh-CN - zh-CN
directories: directories:
# 这里的 output 只是 electron-builder 的兜底值:scripts/dist-win.mjs 会把它覆盖成
# releases/client/<版本>/(与服务端、插件同一约定)。直接手敲 electron-builder 才会落到这里。
output: release output: release
buildResources: build buildResources: build
@@ -43,7 +45,10 @@ win:
arch: [x64] arch: [x64]
- target: portable - target: portable
arch: [x64] arch: [x64]
# 未提供 build/icon.ico 时使用 Electron 默认图标 # 应用/安装包图标。**显式指定**而不是靠 electron-builder 的默认约定(默认找 build/icon.ico):
# 现在生效的是仓库根 build/classhelper.{ico,png}(设计导出、手工放入),
# 而 `pnpm icons` 那条 SVG 流水线只会重写 icon.*,两者不打架。
icon: build/classhelper.ico
artifactName: ${productName}-${version}-${arch}.${ext} artifactName: ${productName}-${version}-${arch}.${ext}
nsis: nsis:
+70 -18
View File
@@ -10,7 +10,14 @@
* *
* 用法: * 用法:
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件 * node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
* node scripts/dist-win.mjs --dir # 仅生成免安装目录 release/win-unpacked * node scripts/dist-win.mjs --dir # 仅生成免安装目录
*
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
* 安装包/ 班级小助手-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
* 免安装/ electron-builder 的 win-unpacked 内容
* 构建中间/ builder-*.yml、*.nsis.7z
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
* 显式传入 `-c.directories.output=...` 时不覆盖,整理(tidyArtifacts)以传入的目录为准。
*/ */
import { execFileSync, spawn } from 'node:child_process'; import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
@@ -59,6 +66,14 @@ if (!fs.existsSync(cli)) {
process.exit(2); process.exit(2);
} }
// 交付产物落到 releases/client/<版本>/(与服务端、插件同一约定;releases/ 不入库)
const clientVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
const explicitOutput = passthrough.find((item) => item.includes('directories.output'));
const destDir = explicitOutput
? path.resolve(root, explicitOutput.slice(explicitOutput.indexOf('=') + 1))
: path.join(repoRoot, 'releases', 'client', clientVersion);
if (!explicitOutput) overrides.push(`--config.directories.output=${destDir}`);
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough]; const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough];
console.log('[dist] electron-builder', cliArgs.slice(1).join(' ')); console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
@@ -72,18 +87,18 @@ console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`);
// 清理上一轮产物:本仓库目录树带沙箱 ACL(含 Everyone 的 DENY(delete child)), // 清理上一轮产物:本仓库目录树带沙箱 ACL(含 Everyone 的 DENY(delete child)),
// 已存在的产物会被工具(makensis / 7za)判为"Can't open output file / 拒绝访问"而失败, // 已存在的产物会被工具(makensis / 7za)判为"Can't open output file / 拒绝访问"而失败,
// 手动删掉再打包即可 —— 顺手也保证产物集合确定,不会残留上一轮的旧安装包。 // 手动删掉再打包即可 —— 顺手也保证产物集合确定,不会残留上一轮的旧安装包。
const outputDir = path.join(root, 'release'); let cleaned = false;
if (fs.existsSync(outputDir)) { for (const name of ['win-unpacked', '免安装', '安装包', '构建中间']) {
for (const name of fs.readdirSync(outputDir)) { const target = path.join(destDir, name);
if (!/\.(exe|7z|blockmap|yml)$/i.test(name)) continue; if (!fs.existsSync(target)) continue;
try { try {
fs.rmSync(path.join(outputDir, name), { force: true }); fs.rmSync(target, { recursive: true, force: true });
} catch (error) { cleaned = true;
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`); } catch (error) {
} console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
} }
console.log('[dist] 已清理上一轮的安装包 / 归档');
} }
if (cleaned) console.log('[dist] 已清理上一轮的免安装目录 / 安装包 / 构建中间');
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env }); const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
@@ -105,21 +120,58 @@ const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env })
function normalizeIntegrityLabel(dir) { function normalizeIntegrityLabel(dir) {
if (process.platform !== 'win32') return; if (process.platform !== 'win32') return;
const run = (args) => execFileSync('icacls', args, { stdio: 'pipe', windowsHide: true }); const run = (args) => execFileSync('icacls', args, { stdio: 'pipe', windowsHide: true });
// 这一行才是"交付物能不能用"的关键,失败必须喊出来
try { try {
run([dir, '/setintegritylevel', 'Medium', '/T', '/C']); run([dir, '/setintegritylevel', 'Medium', '/T', '/C']);
// 目录自身恢复为"继承父目录的 Low",保证下次构建时 Low 的 7za 仍能写入归档 console.log('[dist] 已把交付物完整性标签修正为 Medium:', dir);
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
console.log('[dist] 已修正交付物完整性标签为 Medium,目录恢复继承:', dir);
} catch (error) { } catch (error) {
console.warn('[dist] 完整性标签修正跳过:', error.message); console.warn('[dist] 完整性标签修正失败(交付物可能装不上,见 AGENTS.md §7 第 7 条):', error.message);
}
// 目录自身恢复为"继承父目录的 Low",保证下次构建时带 Low 标签的 7za 仍能写进这个目录。
// 宿主目录树本来就没有 Low 标签体系时,这一步会被系统拒绝(保持继承即可),属正常,不提示。
try {
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
} catch {
/* 忽略 */
}
}
/**
* 把 electron-builder 的原始输出整理成 releases 的固定分层:
* win-unpacked/ → 免安装/
* *.exe / *.blockmap → 安装包/
* *.yml / *.nsis.7z → 构建中间/
* electron-builder 每次都会按自己的布局重写这些路径,因此整理只放在打包成功之后做;
* 同名文件用 renameSync 同盘移动,不会产生大文件的额外拷贝。
*/
function tidyArtifacts(dir) {
if (!fs.existsSync(dir)) return;
const moveInto = (from, sub) => {
const to = path.join(dir, sub);
fs.mkdirSync(to, { recursive: true });
fs.renameSync(from, path.join(to, path.basename(from)));
};
const unpacked = path.join(dir, 'win-unpacked');
const portableDir = path.join(dir, '免安装');
if (fs.existsSync(unpacked)) {
fs.rmSync(portableDir, { recursive: true, force: true });
fs.renameSync(unpacked, portableDir);
}
for (const name of fs.readdirSync(dir)) {
const from = path.join(dir, name);
if (!fs.statSync(from).isFile()) continue;
if (/\.(exe|blockmap)$/i.test(name)) moveInto(from, '安装包');
else if (/\.(yml|7z)$/i.test(name)) moveInto(from, '构建中间');
} }
} }
child.on('exit', (code) => { child.on('exit', (code) => {
if (code === 0) { if (code === 0) {
const output = path.join(root, 'release'); tidyArtifacts(destDir);
normalizeIntegrityLabel(output); normalizeIntegrityLabel(destDir);
console.log('\n[dist] 打包完成,产物目录:', output); console.log('\n[dist] 打包完成,产物目录:', destDir);
} }
process.exit(code ?? 1); process.exit(code ?? 1);
}); });
+112 -1
View File
@@ -1142,8 +1142,20 @@ async function runIslandChecks(
* (他常是 top-center + 较大 marginY,验证实例是 top-center + marginY=8)—— 两个岛一高一低叠在一起, * (他常是 top-center + 较大 marginY,验证实例是 top-center + marginY=8)—— 两个岛一高一低叠在一起,
* 用户会以为"屏幕上出现了第二个灵动岛",点它又"反应不对"(它归验证脚本控制,会自己展开/收起)。 * 用户会以为"屏幕上出现了第二个灵动岛",点它又"反应不对"(它归验证脚本控制,会自己展开/收起)。
* 只改**颜色**(不动停靠位置):命中/几何断言全部按默认位置写过,挪位置会把它们弄红(实测踩过)。 * 只改**颜色**(不动停靠位置):命中/几何断言全部按默认位置写过,挪位置会把它们弄红(实测踩过)。
*
* 例外:采集产品留档图时这两个值可以临时换成产品默认外观 ——
* `ELECTRON_SMOKE_ISLAND_ACCENT` / `ELECTRON_SMOKE_ISLAND_STYLE`(见 website/tools/capture-shots.mjs)。
* 留档图要的是"产品长什么样",不是"验证实例长什么样"。**日常跑验证不要设它们。**
*/ */
island.setAppearance({ accent: '#e91e8c', style: 'tinted' }); const shotAppearance: Parameters<typeof island.setAppearance>[0] = {
accent: process.env.ELECTRON_SMOKE_ISLAND_ACCENT ?? '#e91e8c',
};
if (process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'black' || process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'glass') {
shotAppearance.style = process.env.ELECTRON_SMOKE_ISLAND_STYLE;
} else if (!process.env.ELECTRON_SMOKE_ISLAND_STYLE) {
shotAppearance.style = 'tinted';
}
island.setAppearance(shotAppearance);
/** /**
* 强制关掉触摸模式:本节的命中/穿透/窗口几何断言全部是按"鼠标模式"写的(用 * 强制关掉触摸模式:本节的命中/穿透/窗口几何断言全部是按"鼠标模式"写的(用
* `setHitTestCursor` 注入光标位置代替挪动真实鼠标)。若跑在带触摸屏的机器上, * `setHitTestCursor` 注入光标位置代替挪动真实鼠标)。若跑在带触摸屏的机器上,
@@ -3526,6 +3538,94 @@ async function runIslandChecks(
* "HTTP 发布 → Socket.IO 广播 → 渲染进程 realtime store → bridge → IPC → 灵动岛"全链路, * "HTTP 发布 → Socket.IO 广播 → 渲染进程 realtime store → bridge → IPC → 灵动岛"全链路,
* 并在灵动岛里真实点击"标为已读",确认通知中心的未读状态同步更新。 * 并在灵动岛里真实点击"标为已读",确认通知中心的未读状态同步更新。
*/ */
/**
* 客户端逐页留档(官网素材用)。只有设了 `ELECTRON_CLIENT_SHOTS_DIR` 才会调用。
*
* 页面顺序就是侧边栏的顺序,方便照着做图注;走的是 hash 路由,与真实点击菜单等价。
* 作业页带 `?date=` 深链 —— 当天通常没有作业,空看板当素材没意义(日期取自
* `GET /homeworks/days`,种子数据里只有 2026-09-27 有作业)。
*/
const CLIENT_PAGE_SHOTS: Array<{ name: string; hash: string }> = [
{ name: 'client-schedule', hash: '#/schedule' },
{ name: 'client-homeworks', hash: '#/homeworks?date=2026-09-27' },
{ name: 'client-notifications', hash: '#/notifications' },
{ name: 'client-grades', hash: '#/grades' },
{ name: 'client-settings-island', hash: '#/settings/island' },
{ name: 'client-settings-appearance', hash: '#/settings/appearance' },
];
/** 页面里飘着的 ElMessage 提示会挡内容,截之前先收掉 */
const STRIP_TOASTS = `document.querySelectorAll('.el-message, .el-notification').forEach((node) => node.remove()); true;`;
/** 主内容区的纯文本长度(侧栏不算):用来判断页面到底渲染出来没有 */
const MAIN_TEXT_LENGTH = `((document.querySelector('.el-main.main') ?? document.body).innerText ?? '').trim().length`;
/**
* 截图前把 CSS 动画与过渡停掉。
*
* 为什么必须:客户端每个页面有「错峰入场」动画(styles/index.css 的 `.page > *`),
* 起始帧是 `opacity: 0`。窗口被别的窗口盖住时 Chromium 会冻结 CSS 动画,
* 于是 DOM 里文本齐全(实测 3000 多字)、截出来却是**整片空白** —— 排查了半天。
* `animation: none` 之后元素回落到自己的基础样式(可见),截图也顺带变成确定性的一帧。
*/
const FREEZE_ANIMATIONS = `(() => {
const id = 'smoke-shot-freeze';
if (!document.getElementById(id)) {
const style = document.createElement('style');
style.id = id;
style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; }';
document.head.append(style);
}
return true;
})()`;
const UNFREEZE_ANIMATIONS = `(() => {
document.getElementById('smoke-shot-freeze')?.remove();
return true;
})()`;
async function captureClientPages(win: BrowserWindow, dir: string): Promise<void> {
fs.mkdirSync(dir, { recursive: true });
await win.webContents.executeJavaScript(FREEZE_ANIMATIONS);
for (const page of CLIENT_PAGE_SHOTS) {
try {
for (let attempt = 1; attempt <= 3; attempt += 1) {
// 连点两次同样的 hash 不会触发路由,先回课表再过去,保证每次都真的重新渲染
await win.webContents.executeJavaScript(`location.hash = '#/schedule'; true;`);
await sleep(350);
await win.webContents.executeJavaScript(`location.hash = ${JSON.stringify(page.hash)}; true;`);
await sleep(1200);
await win.webContents.executeJavaScript(STRIP_TOASTS);
await sleep(200);
// 懒加载的视图偶尔会来不及挂上,量到空内容就重来一次
const length = await win.webContents.executeJavaScript(MAIN_TEXT_LENGTH);
if (typeof length === 'number' && length >= 60) break;
if (attempt === 3) {
console.warn(`[SHOTS] ${page.name} 内容区一直是空的(最后一次 ${length} 字),跳过`);
break;
}
await sleep(900);
}
const length = await win.webContents.executeJavaScript(MAIN_TEXT_LENGTH);
if (typeof length !== 'number' || length < 60) continue;
const image = await win.webContents.capturePage();
if (image.isEmpty()) {
console.warn(`[SHOTS] ${page.name} 截到空图,跳过`);
continue;
}
const file = path.join(dir, `${page.name}.png`);
fs.writeFileSync(file, image.toPNG());
console.log(`[SHOTS] ${page.name} → ${file}(内容 ${length} 字)`);
} catch (error) {
console.warn(`[SHOTS] ${page.name} 截图失败`, error);
}
}
await win.webContents.executeJavaScript(UNFREEZE_ANIMATIONS);
}
async function runIslandRealtimeCheck( async function runIslandRealtimeCheck(
win: BrowserWindow, win: BrowserWindow,
): Promise<{ delivered: boolean; deliveryDetail: string; markRead: boolean; markReadDetail: string }> { ): Promise<{ delivered: boolean; deliveryDetail: string; markRead: boolean; markReadDetail: string }> {
@@ -4454,6 +4554,17 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
); );
if (injectedId) await deleteSmokeNotification(injectedId); if (injectedId) await deleteSmokeNotification(injectedId);
/**
* 官网素材:逐页留档。只有设了 ELECTRON_CLIENT_SHOTS_DIR 才跑 —— 日常验证不受影响。
*
* 位置两头的约束把这里夹死了:
* - 必须在**收尾清理登录态之前**,否则截出来的是登录页 + 缓存数据;
* - 必须在**所有 UI 断言之后**:翻页会改变侧栏分组展开态,紧接着测「折叠后图标与汉堡同列」
* 就会量到过渡中间值(实测:宽度 200→77、中心差 3.2px,把那条用例弄红了)。
*/
const clientShotsDir = process.env.ELECTRON_CLIENT_SHOTS_DIR ?? '';
if (clientShotsDir) await captureClientPages(win, clientShotsDir);
const cleanup = await win.webContents.executeJavaScript( const cleanup = await win.webContents.executeJavaScript(
`(async () => { `(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' }; if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
+36 -14
View File
@@ -1,13 +1,17 @@
/** /**
* 服务端 + Web 管理端 生产打包脚本:pnpm dist:server * 服务端 + Web 管理端 生产打包脚本:pnpm dist:server
* *
* 交付产物统一落到 releases/server/<版本>/(releases/ 不入库,与客户端/插件同级):
*
* 产出(Windows): * 产出(Windows):
* release-server/classhelper-server/ 可直接运行的免安装目录 * releases/server/<版本>/免安装/ 可直接运行的免安装目录
* release-server/班级小助手服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS) * releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS)
* releases/server/<版本>/构建中间/server-installer.generated.nsi NSIS 生成脚本(可重新生成)
* *
* 产出(Linux,加 --platform linux,即 pnpm dist:server:linux): * 产出(Linux,加 --platform linux,即 pnpm dist:server:linux):
* release-server/classhelper-server-linux-x64-<版本>.tar.gz Linux 一键安装器的输入 * releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz Linux 一键安装器的输入
* release-server/classhelper-server-linux-x64-<版本>.tar.gz.sha256 * releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz.sha256
* releases/server/<版本>/linux-x64/classhelper-server-linux-x64/ tar 的解包目录(同内容留档)
* *
* 为什么 Linux 包要在 Linux 上构建:`@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` 都是 * 为什么 Linux 包要在 Linux 上构建:`@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` 都是
* 平台相关依赖,在 Windows 上 npm install 出来的 node_modules 装到 Linux 跑不起来。 * 平台相关依赖,在 Windows 上 npm install 出来的 node_modules 装到 Linux 跑不起来。
@@ -32,7 +36,16 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const serverDir = path.join(root, 'packages', 'server'); const serverDir = path.join(root, 'packages', 'server');
const webDir = path.join(root, 'packages', 'web-admin'); const webDir = path.join(root, 'packages', 'web-admin');
const deployDir = path.join(root, 'deploy'); const deployDir = path.join(root, 'deploy');
const outRoot = path.join(root, 'release-server');
const VERSION = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
const PRODUCT = '班级小助手服务端';
const PORT = '4000';
const LINUX_ARCH = 'x64';
// 产物根:releases/server/<版本>/(releases/ 不入库,见 AGENTS.md §4)
const outRoot = path.join(root, 'releases', 'server', VERSION);
const INSTALLER_DIR = path.join(outRoot, '安装包');
const GENERATED_DIR = path.join(outRoot, '构建中间');
/** win = 免安装目录 + NSIS 安装程序;linux = 带运维工具的 tar.gz(给 deploy/install.sh 用) */ /** win = 免安装目录 + NSIS 安装程序;linux = 带运维工具的 tar.gz(给 deploy/install.sh 用) */
const PLATFORM = (() => { const PLATFORM = (() => {
@@ -43,13 +56,17 @@ const PLATFORM = (() => {
})(); })();
const isLinux = PLATFORM === 'linux'; const isLinux = PLATFORM === 'linux';
const staging = path.join(outRoot, isLinux ? 'classhelper-server-linux-x64' : 'classhelper-server'); // Linux 侧 staging 的目录名会原样成为 tar 里的顶层目录(deploy/install.sh 用 --strip-components=1
// 展开),保持 ASCII 不动;Windows 侧目录名只影响本地留档,直接用中文分类名。
const staging = isLinux
? path.join(outRoot, 'linux-x64', 'classhelper-server-linux-x64')
: path.join(outRoot, '免安装');
const VERSION = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; const LINUX_TARBALL = path.join(
const PRODUCT = '班级小助手服务端'; outRoot,
const PORT = '4000'; 'linux-x64',
const LINUX_ARCH = 'x64'; `classhelper-server-linux-${LINUX_ARCH}-${VERSION}.tar.gz`,
const LINUX_TARBALL = path.join(outRoot, `classhelper-server-linux-${LINUX_ARCH}-${VERSION}.tar.gz`); );
const log = (message) => console.log(`[dist:server] ${message}`); const log = (message) => console.log(`[dist:server] ${message}`);
@@ -802,7 +819,10 @@ function buildInstaller() {
return null; return null;
} }
const outFile = path.join(outRoot, `${PRODUCT}-${VERSION}-x64-setup.exe`); fs.mkdirSync(INSTALLER_DIR, { recursive: true });
fs.mkdirSync(GENERATED_DIR, { recursive: true });
const outFile = path.join(INSTALLER_DIR, `${PRODUCT}-${VERSION}-x64-setup.exe`);
const templatePath = path.join(root, 'scripts', 'nsis', 'server-installer.nsi'); const templatePath = path.join(root, 'scripts', 'nsis', 'server-installer.nsi');
const script = fs const script = fs
.readFileSync(templatePath, 'utf8') .readFileSync(templatePath, 'utf8')
@@ -810,10 +830,12 @@ function buildInstaller() {
.replaceAll('@VERSION@', VERSION) .replaceAll('@VERSION@', VERSION)
.replaceAll('@STAGING@', staging) .replaceAll('@STAGING@', staging)
.replaceAll('@OUTFILE@', outFile) .replaceAll('@OUTFILE@', outFile)
.replaceAll('@ICON@', path.join(root, 'build', 'icon.ico')) // 安装程序图标:用设计导出的 build/classhelper.ico(与客户端同一份);
// 旧的 build/icon.ico 是 `pnpm icons` 从 SVG 渲染出来的那一套,已不参与交付。
.replaceAll('@ICON@', path.join(root, 'build', 'classhelper.ico'))
.replaceAll('@PORT@', PORT); .replaceAll('@PORT@', PORT);
const generatedScript = path.join(outRoot, 'server-installer.generated.nsi'); const generatedScript = path.join(GENERATED_DIR, 'server-installer.generated.nsi');
// NSIS 通过 BOM 识别 UTF-8,脚本含中文必须写入 BOM,否则报 "Bad text encoding" // NSIS 通过 BOM 识别 UTF-8,脚本含中文必须写入 BOM,否则报 "Bad text encoding"
fs.writeFileSync(generatedScript, `\uFEFF${script}`, 'utf8'); fs.writeFileSync(generatedScript, `\uFEFF${script}`, 'utf8');
+5 -3
View File
@@ -26,9 +26,11 @@ const argOf = (name) => {
return index >= 0 ? argv[index + 1] : undefined; return index >= 0 ? argv[index + 1] : undefined;
}; };
const exe = path.resolve( // 默认取 releases/client/<版本>/免安装/(dist-win 的产物落点,见 AGENTS.md §4)
argOf('--exe') ?? path.join(repoRoot, 'packages/desktop-client/release/win-unpacked/班级小助手.exe'), const clientVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
); const defaultExe = path.join(repoRoot, 'releases', 'client', clientVersion, '免安装', '班级小助手.exe');
const exe = path.resolve(argOf('--exe') ?? defaultExe);
const classCode = argOf('--code') ?? process.env.ELECTRON_SMOKE_CLASS_CODE ?? 'G101'; const classCode = argOf('--code') ?? process.env.ELECTRON_SMOKE_CLASS_CODE ?? 'G101';
const classPassword = argOf('--password') ?? process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '123456'; const classPassword = argOf('--password') ?? process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '123456';
const forceOffline = argv.includes('--offline'); const forceOffline = argv.includes('--offline');
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);
}
}