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 与实机留档图;客户端冒烟相应加了采集留档图用的外观开关。
No files matched your search
@@ -9,7 +9,7 @@ name: 发布 Linux 服务端安装包
|
||||
#
|
||||
# 用法:
|
||||
# 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。
|
||||
#
|
||||
# 也可以只跑构建不挂 Release(attach=false),产物在 workflow 的 Artifacts 里。
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: '要构建并挂载的 Release tag(例如 v1.1.0)'
|
||||
description: '要构建并挂载的 Release tag(例如 v1.1.2)'
|
||||
required: true
|
||||
type: string
|
||||
attach:
|
||||
@@ -81,15 +81,15 @@ jobs:
|
||||
run: pnpm dist:server:linux
|
||||
|
||||
- name: 展示产物
|
||||
run: ls -la release-server/
|
||||
run: ls -la releases/server/*/linux-x64/
|
||||
|
||||
- name: 上传构建产物(Artifacts)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: classhelper-server-linux-x64
|
||||
path: |
|
||||
release-server/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
|
||||
releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
@@ -101,9 +101,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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"
|
||||
SUMS="release-server/SHA256SUMS-${VERSION}.txt"
|
||||
SUMS="releases/SHA256SUMS-${VERSION}.txt"
|
||||
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
echo "::error::没有找到 $TARBALL(tag 与 package.json 的版本号对得上吗?)"
|
||||
@@ -112,13 +113,13 @@ jobs:
|
||||
# .sha256 必须跟着一起传:它是独立发布的资产,只更新 tar.gz 会让这个文件指向旧哈希,
|
||||
# 拿它校验的人会得到 mismatch(classhelper upgrade 用的是 --sha256/这个文件,不是 SHA256SUMS)。
|
||||
if [ ! -f "$SHAFILE" ]; then
|
||||
sha256sum "$TARBALL" | sed "s#release-server/##" > "$SHAFILE"
|
||||
sha256sum "$TARBALL" | sed "s#${ARTIFACT_DIR}/##" > "$SHAFILE"
|
||||
fi
|
||||
|
||||
# 取回已有的校验清单(Windows 包那几行),只替换/追加本包这一行,其余内容原样保留。
|
||||
# 注意**不要**用 `sort -u` 去重:那份文件是人读的(表头 + 体积 + 说明),排序会把它们打散。
|
||||
gh release download "$TAG" --pattern "SHA256SUMS-*.txt" --dir release-server --clobber || true
|
||||
DOWNLOADED="$(ls release-server/SHA256SUMS-*.txt 2>/dev/null | head -n 1 || true)"
|
||||
gh release download "$TAG" --pattern "SHA256SUMS-*.txt" --dir releases --clobber || true
|
||||
DOWNLOADED="$(ls releases/SHA256SUMS-*.txt 2>/dev/null | head -n 1 || true)"
|
||||
if [ -n "$DOWNLOADED" ] && [ "$DOWNLOADED" != "$SUMS" ]; then mv "$DOWNLOADED" "$SUMS"; fi
|
||||
touch "$SUMS"
|
||||
grep -v "^[0-9a-f]\{64\} classhelper-server-linux-" "$SUMS" > "${SUMS}.new" || true
|
||||
|
||||
@@ -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 -- "$ADMIN_PASSWORD" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)"
|
||||
|
||||
check "弱密码被拒绝(长度/种数不足)" \
|
||||
"$(printf 'abc\nabc\n' | "$CLASSHELPER" password "$ADMIN_USER" 2>&1 | grep -q '不合规' && echo 0 || echo 1)"
|
||||
# 先把输出收进变量再 grep:`外部命令 | grep -q` 在 `set -o pipefail` 下,grep 命中即退出会让
|
||||
# 写端收到 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 结束即退出)" \
|
||||
"$(printf 'abc\n' | timeout 20 "$CLASSHELPER" password "$ADMIN_USER" >/dev/null 2>&1; [ $? -ne 124 ] && echo 0 || echo 1)"
|
||||
check "password --list 能列出账号" \
|
||||
"$("$CLASSHELPER" password --list 2>&1 | grep -q "$ADMIN_USER" && echo 0 || echo 1)"
|
||||
list_out="$("$CLASSHELPER" password --list 2>&1 || true)"
|
||||
check "password --list 能列出账号" "$(printf '%s' "$list_out" | grep -q "$ADMIN_USER" && echo 0 || echo 1)"
|
||||
|
||||
# 用改密前保存的哈希把账号恢复原样:比再改一次密码更可靠
|
||||
# (原密码若是 admin123 这类弱口令,改回去会被强度校验挡下 —— 那是产品的正确行为)
|
||||
@@ -223,7 +225,8 @@ if [ -n "$latest" ]; then
|
||||
fi
|
||||
check "备份目录权限 700" "$([ "$(stat -c '%a' "${latest%/}" 2>/dev/null)" = "700" ] && echo 0 || echo 1)"
|
||||
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. 配置
|
||||
|
||||
@@ -248,8 +251,10 @@ if [ -n "$TOKEN" ]; then
|
||||
note "按环境变量跳过"
|
||||
else
|
||||
old_secret="$(read_cfg JWT_SECRET)"
|
||||
# key rotate 会问一次确认;无 tty 时从 stdin 读,因此用管道喂 yes
|
||||
printf 'yes\n' | CLASSHELPER_NO_TTY=1 "$CLASSHELPER" key rotate >/dev/null 2>&1 || true
|
||||
# key rotate 无 tty 时会问两次(确认轮换、是否现在重启);只喂一个 yes 的话第二次读到 EOF,
|
||||
# 重启被跳过 —— 新密钥不生效,下面「旧 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)"
|
||||
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)"
|
||||
|
||||
@@ -3,10 +3,33 @@
|
||||
本文覆盖服务端在 Linux 上的**一键安装**与日常运维(`classhelper` 命令)。
|
||||
适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+(自动识别 apt / yum / dnf)。
|
||||
|
||||
不想看长文的话,最短路径是这两条命令:
|
||||
不想看长文的话,最短路径就是这**一行**(在服务器上执行):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -41,7 +64,7 @@ Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不
|
||||
仓库里的 [`.github/workflows/release-linux-server.yml`](../.github/workflows/release-linux-server.yml)
|
||||
就是干这个的(拿 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 → 运行;
|
||||
3. 跑完 Release 里会多出 Linux 包与更新后的 `SHA256SUMS-<版本>.txt`。
|
||||
|
||||
@@ -67,7 +90,7 @@ Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不
|
||||
```bash
|
||||
# 开发机(Windows 也可以)
|
||||
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)
|
||||
```
|
||||
|
||||
@@ -88,17 +111,17 @@ TUI 会依次问:形态 → 安装目录 → 端口 → 数据库 → 初始
|
||||
|
||||
```bash
|
||||
# 全部默认:/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)
|
||||
printf '%s\n' 'MyStrongPass!2026' | sudo bash install.sh --yes \
|
||||
--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 形态
|
||||
sudo bash install.sh --yes --database mysql --mysql-host 10.0.0.5 --mysql-db classhelper \
|
||||
--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`。要点:
|
||||
@@ -403,6 +426,7 @@ sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
|
||||
| --- | --- |
|
||||
| 服务起不来 | `classhelper doctor` → `classhelper logs -n 100`。常见:端口被占用、MySQL 连接串错、`data/` 权限 |
|
||||
| 装完打不开页面 | `classhelper status` 看探针与监听地址;云服务器还要在**安全组**放行端口 |
|
||||
| 页面能打开但是**空白** | 1.1.0 的服务端在 HTTP 直连访问时会下发 CSP `upgrade-insecure-requests`,浏览器把所有子资源改写成 https 加载而服务器没有 TLS,于是整页空白(HTML/接口都正常)。已修复的版本按请求协议条件下发;若暂不能升级,唯一正解是上 HTTPS 反代 |
|
||||
| 改了配置没生效 | 是否重启过(`classhelper config set` 会问);`EnvironmentFile` 的值优先于文件里的同项 |
|
||||
| Web 端切库后还是连旧库 | `classhelper doctor` 看 `.env` 软链是否被破坏(`ln -sfn /etc/classhelper/config.env <安装目录>/.env`) |
|
||||
| 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB |
|
||||
@@ -447,7 +471,7 @@ Node 官方的 linux-x64 二进制要求 **glibc ≥ 2.28**,而 CentOS 7 是 2
|
||||
```bash
|
||||
# 在开发机:做出 Linux 包(或在 Actions 里下载)
|
||||
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/
|
||||
```
|
||||
|
||||
@@ -464,7 +488,7 @@ bash /tmp/install.sh --check
|
||||
|
||||
```bash
|
||||
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!'
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 用法:
|
||||
* 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`)
|
||||
*
|
||||
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
|
||||
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'node:url';
|
||||
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repoRoot = path.resolve(pluginDir, '..', '..');
|
||||
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 withCipx = argv.includes('--cipx');
|
||||
@@ -146,14 +146,19 @@ if (!fs.existsSync(cipxFile)) {
|
||||
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
|
||||
}
|
||||
|
||||
// 归集到 releases/classisland-plugin/(与客户端、服务端交付产物同级;releases/ 不入库)
|
||||
fs.rmSync(releasesDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(releasesDir, { recursive: true });
|
||||
fs.copyFileSync(cipxFile, path.join(releasesDir, path.basename(cipxFile)));
|
||||
// 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。
|
||||
// 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把
|
||||
// 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。
|
||||
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 目录也能加载
|
||||
const portableDir = path.join(releasesDir, 'ClassHelper.ClassIslandPlugin');
|
||||
fs.mkdirSync(portableDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(outputDir)) {
|
||||
const from = path.join(outputDir, name);
|
||||
if (!fs.statSync(from).isFile()) continue;
|
||||
@@ -165,10 +170,11 @@ for (const name of fs.readdirSync(outputDir)) {
|
||||
}
|
||||
|
||||
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)}`);
|
||||
for (const file of fs.readdirSync(releasesDir)) {
|
||||
log(` ${file}`);
|
||||
}
|
||||
log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`);
|
||||
for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`);
|
||||
for (const file of fs.readdirSync(portableDir)) log(` 免安装/ClassHelper.ClassIslandPlugin/${file}`);
|
||||
@@ -15,6 +15,8 @@ electronLanguages:
|
||||
- zh-CN
|
||||
|
||||
directories:
|
||||
# 这里的 output 只是 electron-builder 的兜底值:scripts/dist-win.mjs 会把它覆盖成
|
||||
# releases/client/<版本>/(与服务端、插件同一约定)。直接手敲 electron-builder 才会落到这里。
|
||||
output: release
|
||||
buildResources: build
|
||||
|
||||
@@ -43,7 +45,10 @@ win:
|
||||
arch: [x64]
|
||||
- target: portable
|
||||
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}
|
||||
|
||||
nsis:
|
||||
|
||||
@@ -10,7 +10,14 @@
|
||||
*
|
||||
* 用法:
|
||||
* 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 fs from 'node:fs';
|
||||
@@ -59,6 +66,14 @@ if (!fs.existsSync(cli)) {
|
||||
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];
|
||||
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)),
|
||||
// 已存在的产物会被工具(makensis / 7za)判为"Can't open output file / 拒绝访问"而失败,
|
||||
// 手动删掉再打包即可 —— 顺手也保证产物集合确定,不会残留上一轮的旧安装包。
|
||||
const outputDir = path.join(root, 'release');
|
||||
if (fs.existsSync(outputDir)) {
|
||||
for (const name of fs.readdirSync(outputDir)) {
|
||||
if (!/\.(exe|7z|blockmap|yml)$/i.test(name)) continue;
|
||||
try {
|
||||
fs.rmSync(path.join(outputDir, name), { force: true });
|
||||
} catch (error) {
|
||||
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
|
||||
}
|
||||
let cleaned = false;
|
||||
for (const name of ['win-unpacked', '免安装', '安装包', '构建中间']) {
|
||||
const target = path.join(destDir, name);
|
||||
if (!fs.existsSync(target)) continue;
|
||||
try {
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
cleaned = true;
|
||||
} 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 });
|
||||
|
||||
@@ -105,21 +120,58 @@ const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env })
|
||||
function normalizeIntegrityLabel(dir) {
|
||||
if (process.platform !== 'win32') return;
|
||||
const run = (args) => execFileSync('icacls', args, { stdio: 'pipe', windowsHide: true });
|
||||
// 这一行才是"交付物能不能用"的关键,失败必须喊出来
|
||||
try {
|
||||
run([dir, '/setintegritylevel', 'Medium', '/T', '/C']);
|
||||
// 目录自身恢复为"继承父目录的 Low",保证下次构建时 Low 的 7za 仍能写入归档
|
||||
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
|
||||
console.log('[dist] 已修正交付物完整性标签为 Medium,目录恢复继承:', dir);
|
||||
console.log('[dist] 已把交付物完整性标签修正为 Medium:', dir);
|
||||
} 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) => {
|
||||
if (code === 0) {
|
||||
const output = path.join(root, 'release');
|
||||
normalizeIntegrityLabel(output);
|
||||
console.log('\n[dist] 打包完成,产物目录:', output);
|
||||
tidyArtifacts(destDir);
|
||||
normalizeIntegrityLabel(destDir);
|
||||
console.log('\n[dist] 打包完成,产物目录:', destDir);
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
@@ -1142,8 +1142,20 @@ async function runIslandChecks(
|
||||
* (他常是 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` 注入光标位置代替挪动真实鼠标)。若跑在带触摸屏的机器上,
|
||||
@@ -3526,6 +3538,94 @@ async function runIslandChecks(
|
||||
* "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(
|
||||
win: BrowserWindow,
|
||||
): 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);
|
||||
|
||||
/**
|
||||
* 官网素材:逐页留档。只有设了 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(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* 服务端 + Web 管理端 生产打包脚本:pnpm dist:server
|
||||
*
|
||||
* 交付产物统一落到 releases/server/<版本>/(releases/ 不入库,与客户端/插件同级):
|
||||
*
|
||||
* 产出(Windows):
|
||||
* release-server/classhelper-server/ 可直接运行的免安装目录
|
||||
* release-server/班级小助手服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS)
|
||||
* releases/server/<版本>/免安装/ 可直接运行的免安装目录
|
||||
* releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS)
|
||||
* releases/server/<版本>/构建中间/server-installer.generated.nsi NSIS 生成脚本(可重新生成)
|
||||
*
|
||||
* 产出(Linux,加 --platform linux,即 pnpm dist:server:linux):
|
||||
* release-server/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 Linux 一键安装器的输入
|
||||
* 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` 都是
|
||||
* 平台相关依赖,在 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 webDir = path.join(root, 'packages', 'web-admin');
|
||||
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 用) */
|
||||
const PLATFORM = (() => {
|
||||
@@ -43,13 +56,17 @@ const PLATFORM = (() => {
|
||||
})();
|
||||
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 PRODUCT = '班级小助手服务端';
|
||||
const PORT = '4000';
|
||||
const LINUX_ARCH = 'x64';
|
||||
const LINUX_TARBALL = path.join(outRoot, `classhelper-server-linux-${LINUX_ARCH}-${VERSION}.tar.gz`);
|
||||
const LINUX_TARBALL = path.join(
|
||||
outRoot,
|
||||
'linux-x64',
|
||||
`classhelper-server-linux-${LINUX_ARCH}-${VERSION}.tar.gz`,
|
||||
);
|
||||
|
||||
const log = (message) => console.log(`[dist:server] ${message}`);
|
||||
|
||||
@@ -802,7 +819,10 @@ function buildInstaller() {
|
||||
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 script = fs
|
||||
.readFileSync(templatePath, 'utf8')
|
||||
@@ -810,10 +830,12 @@ function buildInstaller() {
|
||||
.replaceAll('@VERSION@', VERSION)
|
||||
.replaceAll('@STAGING@', staging)
|
||||
.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);
|
||||
|
||||
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"
|
||||
fs.writeFileSync(generatedScript, `\uFEFF${script}`, 'utf8');
|
||||
|
||||
|
||||
@@ -26,9 +26,11 @@ const argOf = (name) => {
|
||||
return index >= 0 ? argv[index + 1] : undefined;
|
||||
};
|
||||
|
||||
const exe = path.resolve(
|
||||
argOf('--exe') ?? path.join(repoRoot, 'packages/desktop-client/release/win-unpacked/班级小助手.exe'),
|
||||
);
|
||||
// 默认取 releases/client/<版本>/免安装/(dist-win 的产物落点,见 AGENTS.md §4)
|
||||
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 classPassword = argOf('--password') ?? process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '123456';
|
||||
const forceOffline = argv.includes('--offline');
|
||||
|
||||
|
After Width: | Height: | Size: 250 KiB |
@@ -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,
|
||||
};
|
||||
})();
|
||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -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 && 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>
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||