diff --git a/.github/workflows/release-linux-server.yml b/.github/workflows/release-linux-server.yml index 7848fc9..2015bd6 100644 --- a/.github/workflows/release-linux-server.yml +++ b/.github/workflows/release-linux-server.yml @@ -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 diff --git a/deploy/verify-linux.sh b/deploy/verify-linux.sh index 0dc9d76..f56fd3a 100644 --- a/deploy/verify-linux.sh +++ b/deploy/verify-linux.sh @@ -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)" diff --git a/docs/linux-deploy.md b/docs/linux-deploy.md index befa935..4ade99c 100644 --- a/docs/linux-deploy.md +++ b/docs/linux-deploy.md @@ -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!' ``` diff --git a/packages/classisland-plugin/scripts/build.mjs b/packages/classisland-plugin/scripts/build.mjs index 21dc1a7..927514e 100644 --- a/packages/classisland-plugin/scripts/build.mjs +++ b/packages/classisland-plugin/scripts/build.mjs @@ -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}`); diff --git a/packages/desktop-client/electron-builder.yml b/packages/desktop-client/electron-builder.yml index 9fbf419..c4d4207 100644 --- a/packages/desktop-client/electron-builder.yml +++ b/packages/desktop-client/electron-builder.yml @@ -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: diff --git a/packages/desktop-client/scripts/dist-win.mjs b/packages/desktop-client/scripts/dist-win.mjs index e0bc42c..e400565 100644 --- a/packages/desktop-client/scripts/dist-win.mjs +++ b/packages/desktop-client/scripts/dist-win.mjs @@ -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); }); diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index aeb4036..3ffc74e 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -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[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 { + 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 { ); 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: '渲染进程未注册冒烟钩子' }; diff --git a/scripts/dist-server.mjs b/scripts/dist-server.mjs index 3194a85..4515843 100644 --- a/scripts/dist-server.mjs +++ b/scripts/dist-server.mjs @@ -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'); diff --git a/scripts/verify-packaged.mjs b/scripts/verify-packaged.mjs index a43497e..563e236 100644 --- a/scripts/verify-packaged.mjs +++ b/scripts/verify-packaged.mjs @@ -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'); diff --git a/website/assets/icon.png b/website/assets/icon.png new file mode 100644 index 0000000..47ad8c3 Binary files /dev/null and b/website/assets/icon.png differ diff --git a/website/assets/main.js b/website/assets/main.js new file mode 100644 index 0000000..1b497d4 --- /dev/null +++ b/website/assets/main.js @@ -0,0 +1,1351 @@ +/** + * 班级小助手 · 官网交互 + * + * 动效全部走 assets/motion.js 里那套 beUI token(曲线、弹簧、时长口径)。 + * 这里只做页面逻辑,逐块对着 beUI 的组件写: + * + * 标题逐字上浮 components/motion/text-animation TextReveal + * 灵动岛 components/blocks/dynamic-island 壳 + 内容两层弹簧 + * 胶囊文案交换 components/motion/action-swap blur 变体 + * 铃铛晃动 motion guide「Semantic icon motion」 铰链摆动 + * 顶栏悬停胶囊 components/motion/shared-layout-bg 共享布局滑移 + * 阅读进度条 components/motion/scroll-animation ScrollProgressBar + * 按钮按压 components/motion/button whileTap 0.93 + * 验收数字滚动 components/motion/number NumberTicker + * 上课时段滑块 components/motion/range-slider GLIDE + BOUNCY + * 复制按钮文案 components/motion/action-swap roll 变体 + */ + +(() => { + 'use strict'; + + const M = window.CHMotion; + const { + EASE_OUT, + SPRING_PRESS, + SPRING_SWAP, + SPRING_LAYOUT, + SPRING_GLIDE, + SPRING_BOUNCY, + SPRING_PROGRESS, + SPRING_TEXT, + DURATION, + Spring, + reduceMotion, + hoverCapable, + round2, + clamp, + } = M; + /** duration+bounce 形式的壳弹簧(与 beUI 的 SHELL_SPRING / CONTENT_SPRING 同值) */ + const ISLAND_SHELL_SPRING = { duration: DURATION.islandShell, bounce: 0.2 }; + const ISLAND_CONTENT_SPRING = { duration: DURATION.islandContent, bounce: 0.35 }; + + const $ = (selector, root = document) => root.querySelector(selector); + const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; + + /** 延迟执行,并在页面不可见时不再堆积 */ + const after = (seconds, callback) => window.setTimeout(callback, Math.max(0, seconds * 1000)); + + /* ══════════════════════════════════════════════════════════════════════ + 一、标题逐字上浮 —— beUI TextReveal + 中文没有空格,所以按「字」切;每个字是一个 inline-block,空白保留。 + ══════════════════════════════════════════════════════════════════════ */ + + function setupHeadlineReveal() { + const headline = $('[data-reveal]'); + if (!headline) return; + + const lines = $$('.reveal-line', headline); + if (!lines.length) return; + + const units = []; + lines.forEach((line) => { + const text = line.textContent.trim(); + line.textContent = ''; + for (const char of Array.from(text)) { + const span = document.createElement('span'); + span.className = 'reveal-unit'; + span.textContent = char; + line.append(span); + units.push({ element: span }); + } + }); + + const reduce = reduceMotion(); + + units.forEach((unit, index) => { + // reduce 时只留透明度:位移、缩放、模糊全部去掉 + if (reduce) { + unit.element.style.opacity = '0'; + unit.element.style.transition = `opacity 0.25s ${EASE_OUT} ${round2(index * DURATION.textStagger * 0.3)}s`; + return; + } + unit.element.style.opacity = '0'; + unit.element.style.filter = 'blur(12px)'; + unit.element.style.transform = 'translateY(40%)'; + unit.element.style.transition = + `opacity 0.7s ${EASE_OUT} ${round2(index * DURATION.textStagger)}s,` + + `filter 0.9s ${EASE_OUT} ${round2(index * DURATION.textStagger)}s`; + unit.element.style.willChange = 'transform, opacity, filter'; + }); + + // 位移用弹簧(TextReveal 的 y 就是 spring),透明度与模糊用 EASE_OUT 过渡 + document.documentElement.classList.remove('reveal-pending'); + + let started = false; + function startReveal() { + if (started) return; + started = true; + units.forEach((unit, index) => { + unit.element.style.opacity = '1'; + if (reduce) return; + unit.element.style.filter = 'blur(0px)'; + + const spring = new Spring( + 40, + SPRING_TEXT, + (value) => { + unit.element.style.transform = `translateY(${round2(value)}%)`; + }, + { restFloor: 0.01 }, + ); + after(index * DURATION.textStagger, () => spring.set(0)); + }); + } + + // 下一帧起跑;rAF 被限流时(后台标签页、被遮挡的窗口)用定时器兜底, + // 否则单位会一直停在透明态,标题就永远是空的。 + M.nextFrame(startReveal); + after(0.25, startReveal); + } + + /* ══════════════════════════════════════════════════════════════════════ + 二、灵动岛 —— beUI DynamicIsland + ══════════════════════════════════════════════════════════════════════ */ + + /** 与 DEFAULT_ISLAND_APPEARANCE 一致 */ + const ISLAND = { width: 268, height: 44, fontSize: 13 }; + /** 与 ISLAND_SIZE_DELTA 一致:展开卡的尺寸 = 胶囊 + 增量 */ + const SIZE_DELTA = { + notification: { width: 156, height: 186 }, + urgent: { width: 172, height: 202 }, + call: { width: 188, height: 218 }, + }; + /** + * 圆角是常数,不参与动画 —— beUI 的做法,浏览器会把它钳到 min(圆角, w/2, h/2), + * 于是「胶囊 → 圆角矩形」的形变是尺寸变化的副产品,不存在圆角插值出错的可能。 + * 值取产品的展开态半径 24,这样胶囊态会被钳成 h/2(满圆角)。 + */ + const ISLAND_RADIUS = 24; + + /** 超椭圆指数:2 = 标准圆弧圆角,越大曲率越连续(与产品的 squircle.ts 同值) */ + const SUPERELLIPSE_EXPONENT = 4.2; + const CORNER_SEGMENTS = 14; + + function squirclePath(width, height, radius) { + const w = Math.max(1, round2(width)); + const h = Math.max(1, round2(height)); + const r = Math.max(0, Math.min(radius, w / 2, h / 2)); + if (r <= 0.5) return `M0,0 H${w} V${h} H0 Z`; + + const p = 2 / SUPERELLIPSE_EXPONENT; + const centers = [ + { cx: w - r, cy: r }, + { cx: w - r, cy: h - r }, + { cx: r, cy: h - r }, + { cx: r, cy: r }, + ]; + const startAngles = [-Math.PI / 2, 0, Math.PI / 2, Math.PI]; + + const parts = []; + for (let corner = 0; corner < 4; corner += 1) { + const center = centers[corner]; + for (let index = 0; index <= CORNER_SEGMENTS; index += 1) { + const angle = startAngles[corner] + (index / CORNER_SEGMENTS) * (Math.PI / 2); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const px = center.cx + r * Math.sign(cos) * Math.pow(Math.abs(cos), p); + const py = center.cy + r * Math.sign(sin) * Math.pow(Math.abs(sin), p); + parts.push(`${index === 0 && corner === 0 ? 'M' : 'L'}${round2(px)},${round2(py)}`); + } + } + parts.push('Z'); + return parts.join(' '); + } + + const ICONS = { + bell: '', + book: '', + megaphone: + '', + chevron: + '', + }; + + /** 一条消息的全部内容。文案取自产品自己的演示数据,不是现编的占位符。 */ + const MESSAGES = { + notification: { + icon: 'bell', + badge: '通知', + meta: '张老师 · 09-12 18:38', + title: '秋季运动会报名', + body: '各班请于本周五前把参赛名单交到体育组,每人限报两项,报名后不再更改。', + more: '还有 1 条通知', + buttons: ['打开应用', '标为已读', '知道了'], + primary: '知道了', + }, + urgent: { + icon: 'bell', + badge: '紧急', + meta: '需立即查看 · 张老师', + title: '上课期间的紧急通知', + body: '明天下午的数学课改在实验楼 B205 上课,请同学们提前 10 分钟到达,并携带上次发的练习册。', + more: '还有 1 条通知', + buttons: ['打开应用', '标为已读', '知道了'], + primary: '知道了', + /** 紧急时铃铛从铰链上摆一下 —— motion guide 的「语义化图标动效」 */ + swing: true, + }, + call: { + icon: 'megaphone', + badge: '叫人', + meta: '张老师 · 09-12 18:40', + title: '请 王小明 同学找 张老师', + body: '带上昨天的数学作业到办公室。', + more: '', + buttons: ['收到'], + primary: '收到', + }, + }; + + const PILL_HINT = { + notification: { title: '新消息', sub: '张老师 · 点击查看', icon: 'bell' }, + urgent: { title: '紧急通知', sub: '点击查看', icon: 'bell', swing: true }, + call: { title: '老师叫你', sub: '点击查看', icon: 'megaphone' }, + homework: { title: '新作业', sub: '数学 · 点击查看', icon: 'book' }, + }; + + /** 首屏演示顺序:三条消息依次到达,最后完全隐藏一会儿 */ + const SCRIPT = [ + { kind: 'pill', hint: 'notification', hold: 1500 }, + { kind: 'card', message: 'notification', hold: 5200 }, + { kind: 'pill', hint: 'urgent', hold: 1400 }, + { kind: 'card', message: 'urgent', hold: 5600 }, + { kind: 'pill', hint: 'call', hold: 1400 }, + { kind: 'card', message: 'call', hold: 5000 }, + { kind: 'pill', hint: 'homework', hold: 1600 }, + { kind: 'hidden', hold: 2200 }, + ]; + + function cardMarkup(message) { + const buttons = message.buttons + .map( + (label) => + `${label}`, + ) + .join(''); + + return ` +
+ ${ICONS[message.icon]} + ${message.badge} + ${message.meta} + ${ICONS.chevron} +
+

${message.title}

+

${message.body}

+
+ ${message.more} + ${buttons} +
`; + } + + function setupIsland() { + const stage = $('#stage'); + const island = $('#island'); + const svg = $('#island-shape'); + const path = $('#island-path'); + const tint = $('#island-tint'); + const layers = $('.island-layers'); + const pill = $('#island-pill'); + const pillIcon = $('#pill-icon'); + const pillTitle = $('#pill-title'); + const pillSub = $('#pill-sub'); + if (!stage || !island || !path || !layers) return; + + // ── 壳:真实 width / height,两条弹簧(不是 transform,避免缩放拉伸) + const shell = { + width: new Spring(ISLAND.width, ISLAND_SHELL_SPRING, paint), + height: new Spring(ISLAND.height, ISLAND_SHELL_SPRING, paint), + }; + + function paint() { + const w = shell.width.value; + const h = shell.height.value; + island.style.width = `${round2(w)}px`; + island.style.height = `${round2(h)}px`; + svg.setAttribute('width', round2(w)); + svg.setAttribute('height', round2(h)); + const d = squirclePath(w, h, ISLAND_RADIUS); + path.setAttribute('d', d); + if (tint) tint.setAttribute('d', d); + } + paint(); + + // ── 内容槽:进出都带位移 + 缩放 + 模糊(beUI 的 Slot) + let slot = null; + let exitTimers = []; + + function mountSlot(build) { + exitTimers.forEach(window.clearTimeout); + exitTimers = []; + + const previous = slot; + if (previous) { + // 退场走得快、不带模糊,趁壳还没缩到裁掉它之前先走 + previous.element.style.transition = `opacity ${DURATION.islandExit}s ${EASE_OUT}, transform ${DURATION.islandExit}s ${EASE_OUT}, filter ${DURATION.islandExit}s ${EASE_OUT}`; + previous.element.style.opacity = '0'; + previous.element.style.filter = 'blur(0px)'; + previous.element.style.transform = 'translateY(-6px) scale(0.9)'; + previous.springs.forEach((spring) => spring.jump(spring.value)); + exitTimers.push(after(DURATION.islandExit, () => previous.element.remove())); + } + + const element = document.createElement('div'); + element.className = 'island-slot-content'; + element.innerHTML = build(); + layers.append(element); + + const reduce = reduceMotion(); + const from = reduce + ? { opacity: 0, scale: 1, y: 0, blur: 0 } + : { opacity: 0, scale: 0.9, y: -8, blur: 5 }; + const to = { opacity: 1, scale: 1, y: 0, blur: 0 }; + + const style = { ...from }; + element.style.transition = 'none'; + element.style.transformOrigin = 'top center'; + element.style.opacity = String(style.opacity); + element.style.filter = `blur(${style.blur}px)`; + element.style.transform = `translateY(${style.y}px) scale(${style.scale})`; + + const apply = () => { + element.style.opacity = String(style.opacity); + element.style.filter = `blur(${round2(style.blur)}px)`; + element.style.transform = `translateY(${round2(style.y)}px) scale(${round2(style.scale)})`; + }; + + // 一条弹簧同时驱动 transform / opacity / blur —— 不做分属性补间,内容跟着壳一起走 + const movement = new Spring(from.y, ISLAND_CONTENT_SPRING, (v) => { + style.y = v; + apply(); + }); + const scale = new Spring(from.scale, ISLAND_CONTENT_SPRING, (v) => { + style.scale = v; + apply(); + }); + const opacity = new Spring(from.opacity, ISLAND_CONTENT_SPRING, (v) => { + style.opacity = v; + apply(); + }); + const blur = new Spring(from.blur, ISLAND_CONTENT_SPRING, (v) => { + style.blur = v; + apply(); + }); + const springs = [movement, scale, opacity, blur]; + + if (reduce) { + // 只保留透明度:不位移、不缩放、不去模糊之外加动效 + element.style.transition = `opacity 0.15s ${EASE_OUT}`; + element.style.filter = 'blur(0px)'; + element.style.transform = 'none'; + movement.jump(to.y); + scale.jump(to.scale); + blur.jump(to.blur); + after(0.01, () => { + element.style.opacity = '1'; + }); + } else { + movement.set(to.y); + scale.set(to.scale); + opacity.set(to.opacity); + blur.set(to.blur); + } + + slot = { element, springs }; + } + + // ── 胶囊标题用 action-swap 的 blur 变体换文案 + let pillState = { title: null, sub: null }; + function setPillHint(hint) { + pillIcon.innerHTML = ICONS[hint.icon]; + pillIcon.dataset.swing = hint.swing ? 'true' : 'false'; + if (pillState.title === hint.title) return; + pillState = { title: hint.title, sub: hint.sub }; + + const swap = (element, text) => { + element.textContent = text; + element.classList.remove('is-swapping'); + void element.offsetWidth; + element.classList.add('is-swapping'); + }; + swap(pillTitle, hint.title); + swap(pillSub, hint.sub); + } + + // ── 壳与内容一起换到某个形态 + function setSize(kind) { + const next = + kind === 'pill' || kind === 'hidden' + ? { width: ISLAND.width, height: ISLAND.height } + : { width: ISLAND.width + SIZE_DELTA[kind].width, height: ISLAND.height + SIZE_DELTA[kind].height }; + shell.width.set(next.width); + shell.height.set(next.height); + } + + let step = 0; + let elapsed = 0; + let lastFrame = 0; + let onScreen = true; + + function renderStep() { + const item = SCRIPT[step % SCRIPT.length]; + + if (item.kind === 'card') { + const message = MESSAGES[item.message]; + island.dataset.kind = item.message; + island.dataset.shape = 'card'; + setSize(item.message); + mountSlot(() => cardMarkup(message)); + pill.setAttribute('aria-hidden', 'true'); + } else { + const hint = item.kind === 'pill' ? PILL_HINT[item.hint] : null; + if (hint) { + island.dataset.kind = item.hint; + setPillHint(hint); + } + island.dataset.shape = 'pill'; + setSize('pill'); + mountSlot(() => ''); + pill.setAttribute('aria-hidden', 'false'); + } + + island.style.opacity = item.kind === 'hidden' ? '0' : '1'; + } + + /** + * 一个自续的 rAF 循环同时推进故事线与可见性判断。 + * 可见性每帧现读,不缓存 —— 缓存过的标记一旦没被翻回来,动画就永久停住。 + */ + function loop(time) { + const delta = lastFrame ? Math.min(64, time - lastFrame) : 16; + lastFrame = time; + + if (onScreen && !document.hidden) { + elapsed += delta; + const item = SCRIPT[step % SCRIPT.length]; + if (elapsed >= item.hold) { + elapsed = 0; + step += 1; + renderStep(); + } + } + requestAnimationFrame(loop); + } + + function fit() { + const available = stage.clientWidth - 32; + // 卡片最高的是叫人卡(456 宽),按它算缩放,保证任何屏幕宽度都装得下 + const scale = Math.max(0.5, Math.min(1.18, available / (ISLAND.width + SIZE_DELTA.call.width + 20))); + island.style.transform = `scale(${scale})`; + stage.style.setProperty( + '--headroom', + `${Math.round((ISLAND.height + SIZE_DELTA.call.height) * scale) + 30}px`, + ); + } + + fit(); + window.addEventListener('resize', fit, { passive: true }); + + if ('IntersectionObserver' in window) { + const observer = new IntersectionObserver( + (entries) => { + onScreen = entries[0].isIntersecting; + }, + { threshold: 0.15 }, + ); + observer.observe(stage); + } + + renderStep(); + requestAnimationFrame(loop); + } + + /* ══════════════════════════════════════════════════════════════════════ + 三、顶栏悬停胶囊 —— beUI SharedLayoutBg + ══════════════════════════════════════════════════════════════════════ */ + + function setupNavPill() { + const nav = $('.topnav'); + const pill = $('.nav-pill'); + const links = $$('a', nav); + if (!nav || !pill || !links.length) return; + + const edges = new Spring(0, SPRING_LAYOUT, (value) => { + pill.style.left = `${round2(value)}px`; + }); + const span = new Spring(0, SPRING_LAYOUT, (value) => { + pill.style.width = `${round2(value)}px`; + }); + + let visible = false; + + function show() { + if (visible) return; + visible = true; + pill.classList.add('is-visible'); + } + + function hide() { + visible = false; + pill.classList.remove('is-visible'); + } + + function moveTo(link) { + const navBox = nav.getBoundingClientRect(); + const box = link.getBoundingClientRect(); + const left = box.left - navBox.left - 10; + const next = box.width + 20; + if (!visible) { + edges.jump(left); + span.jump(next); + show(); + return; + } + edges.set(left); + span.set(next); + show(); + } + + links.forEach((link) => { + link.addEventListener('pointerenter', () => { + if (!hoverCapable()) return; + moveTo(link); + }); + link.addEventListener('focus', () => moveTo(link)); + link.addEventListener('blur', hide); + }); + nav.addEventListener('pointerleave', hide); + } + + /* ══════════════════════════════════════════════════════════════════════ + 四、阅读进度条 —— beUI ScrollProgressBar + ══════════════════════════════════════════════════════════════════════ */ + + function setupScrollProgress() { + const bar = $('.scroll-progress'); + if (!bar) return; + + const progress = new Spring(0, SPRING_PROGRESS, (value) => { + bar.style.transform = `scaleX(${round2(clamp(value, 0, 1))})`; + }); + + function read() { + const scrollable = document.documentElement.scrollHeight - window.innerHeight; + const value = scrollable > 0 ? window.scrollY / scrollable : 0; + if (reduceMotion()) progress.jump(clamp(value, 0, 1)); + else progress.set(clamp(value, 0, 1)); + } + + read(); + window.addEventListener('scroll', read, { passive: true }); + window.addEventListener('resize', read, { passive: true }); + } + + /* ══════════════════════════════════════════════════════════════════════ + 五、按钮按压 —— beUI Button 的 whileTap / whileHover + ══════════════════════════════════════════════════════════════════════ */ + + function setupPressable() { + $$('[data-press]').forEach(bindPressable); + } + + /** 给单个元素挂上按压反馈;动态插入的卡片要在插入之后再调一次 */ + function bindPressable(element) { + if (element.dataset.pressBound === '1') return; + element.dataset.pressBound = '1'; + + const canHover = hoverCapable(); + const pressScale = Number(element.dataset.press) || 0.93; + const spring = new Spring( + 1, + SPRING_PRESS, + (value) => { + element.style.transform = `scale(${round2(value)})`; + }, + { restFloor: 0.0005 }, + ); + element.style.transformOrigin = 'center'; + + // whileTap 压过 whileHover:松开手指后回到「悬停中」的状态而不是 1 + let hovering = false; + const resting = () => (hovering && canHover ? 1.02 : 1); + + element.addEventListener('pointerdown', () => { + if (!reduceMotion()) spring.set(pressScale); + }); + const release = () => { + if (reduceMotion()) { + spring.jump(1); + return; + } + spring.set(resting()); + }; + element.addEventListener('pointerup', release); + element.addEventListener('pointercancel', release); + + if (canHover) { + element.addEventListener('pointerenter', () => { + hovering = true; + if (!reduceMotion()) spring.set(1.02); + }); + element.addEventListener('pointerleave', () => { + hovering = false; + release(); + }); + } + } + + /* ══════════════════════════════════════════════════════════════════════ + 六、上课时段:时间轴 + 滑块 + 滑块的物理取自 beUI RangeSlider:位置用 SPRING_GLIDE(临界阻尼、跟手不回弹), + 把手拖拽时 scaleY 1.35 用 SPRING_BOUNCY。 + ══════════════════════════════════════════════════════════════════════ */ + + const DAY_START = 7 * 60; + const DAY_END = 17 * 60 + 10; + + const PERIODS = [ + { start: 7 * 60 + 10, end: 7 * 60 + 50, name: '早读', inClass: true }, + { start: 7 * 60 + 50, end: 8 * 60, name: '课间', inClass: false }, + { start: 8 * 60, end: 8 * 60 + 45, name: '第一节', subject: '数学', inClass: true }, + { start: 8 * 60 + 45, end: 8 * 60 + 55, name: '课间', inClass: false }, + { start: 8 * 60 + 55, end: 9 * 60 + 40, name: '第二节', subject: '语文', inClass: true }, + { start: 9 * 60 + 40, end: 10 * 60 + 10, name: '大课间', inClass: false }, + { start: 10 * 60 + 10, end: 10 * 60 + 55, name: '第三节', subject: '英语', inClass: true }, + { start: 10 * 60 + 55, end: 11 * 60 + 5, name: '课间', inClass: false }, + { start: 11 * 60 + 5, end: 11 * 60 + 50, name: '第四节', subject: '物理', inClass: true }, + { start: 11 * 60 + 50, end: 14 * 60, name: '午休', inClass: false }, + { start: 14 * 60, end: 14 * 60 + 45, name: '第五节', subject: '化学', inClass: true }, + { start: 14 * 60 + 45, end: 14 * 60 + 55, name: '课间', inClass: false }, + { start: 14 * 60 + 55, end: 15 * 60 + 40, name: '第六节', subject: '生物', inClass: true }, + { start: 15 * 60 + 40, end: 16 * 60 + 10, name: '课间', inClass: false }, + { start: 16 * 60 + 10, end: 16 * 60 + 55, name: '第七节', subject: '自习', inClass: true }, + { start: 16 * 60 + 55, end: DAY_END, name: '放学', inClass: false }, + ]; + + const pad = (value) => String(value).padStart(2, '0'); + const hhmm = (minutes) => `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; + const percent = (minutes) => ((minutes - DAY_START) / (DAY_END - DAY_START)) * 100; + + function setupPolicy() { + const root = $('#policy-widget'); + const track = $('#day-track'); + const marker = $('#day-marker'); + const input = $('#day-input'); + const timeEl = $('#policy-time'); + const stateEl = $('#policy-state'); + if (!root || !track || !marker || !input) return; + + const blocks = PERIODS.map( + (period) => ` +
+ ${period.name} +
`, + ).join(''); + // 每个节次的边界放一个刻度点 —— beUI RangeSlider 的 showTicks + const ticks = PERIODS.map( + (period) => ``, + ).join(''); + track.innerHTML = `${blocks}${ticks}`; + const fill = $('.day-fill', track); + + const nodes = $$('.day-block', track); + function fitLabels() { + const trackWidth = track.clientWidth; + nodes.forEach((node) => { + const label = $('.day-block-label', node); + if (!label) return; + label.style.visibility = Number(node.dataset.share) * trackWidth < 56 ? 'hidden' : 'visible'; + }); + } + fitLabels(); + window.addEventListener('resize', fitLabels, { passive: true }); + + const say = { + normalClient: $('#o-normal-client'), + normalScreen: $('#o-normal-screen'), + urgentScreen: $('#o-urgent-screen'), + }; + + function periodAt(minutes) { + return ( + PERIODS.find((period) => minutes >= period.start && minutes < period.end) ?? + PERIODS[PERIODS.length - 1] + ); + } + + // ── 把手:位置走弹簧,拖拽时纵向拉伸 + const position = new Spring(percent(Number(input.value)), SPRING_GLIDE, (value) => { + marker.style.left = `${round2(value)}%`; + if (fill) fill.style.width = `${round2(value)}%`; + }); + // 初值就等于目标时 set() 会早退,得手动把首帧刷上去,否则把手停在默认的 left: auto + position.paint(); + const stretch = new Spring( + 1, + SPRING_BOUNCY, + (value) => { + marker.style.setProperty('--thumb-stretch', round2(value)); + }, + { restFloor: 0.002 }, + ); + + function readOut() { + const minutes = Number(input.value); + const period = periodAt(minutes); + const left = percent(minutes); + if (reduceMotion()) { + position.jump(left); + if (fill) fill.style.width = `${round2(left)}%`; + } else { + position.set(left); + } + + if (timeEl) timeEl.textContent = hhmm(minutes); + + if (stateEl) { + if (period.inClass) { + stateEl.textContent = period.subject + ? `正在上 ${period.name} · ${period.subject}` + : `${period.name}(算上课时段)`; + } else { + const next = PERIODS.find((item) => item.start >= minutes && item.inClass); + stateEl.textContent = next + ? `${period.name} · 下一节 ${hhmm(next.start)} 开始${next.subject ? `(${next.subject})` : ''}` + : `${period.name} · 今天的课都上完了`; + } + } + + root.dataset.inclass = period.inClass ? 'true' : 'false'; + + if (say.normalClient) { + say.normalClient.textContent = period.inClass + ? '先收着:窗口直接隐藏,打下课铃再把详情弹出来。' + : '直接弹出来:先是一个胶囊,点开才是详情卡。'; + } + if (say.normalScreen) { + say.normalScreen.textContent = period.inClass + ? '暂存起来,打下课铃补弹(插件订阅课程事件)。' + : '直接全屏弹出。'; + } + if (say.urgentScreen) { + say.urgentScreen.textContent = period.inClass + ? '立刻全屏弹出,可以语音朗读 —— 老师正在等学生。' + : '立刻全屏弹出,可以语音朗读。'; + } + } + + input.addEventListener('input', readOut); + if (!reduceMotion()) { + input.addEventListener('pointerdown', () => stretch.set(1.35)); + const relax = () => stretch.set(1); + input.addEventListener('pointerup', relax); + input.addEventListener('pointercancel', relax); + input.addEventListener('pointerleave', relax); + } + readOut(); + } + + /* ══════════════════════════════════════════════════════════════════════ + 七、验收数字滚动 —— beUI NumberTicker + 每位数字是一列 0–9 的竖排,靠 translateY 滚到目标位;进场时逐位错开。 + ══════════════════════════════════════════════════════════════════════ */ + + function setupNumberTickers() { + const tickers = $$('[data-ticker]'); + if (!tickers.length) return; + + tickers.forEach((host) => { + const value = host.dataset.ticker; + host.innerHTML = `${value}`; + const visual = document.createElement('span'); + visual.className = 'ticker'; + visual.setAttribute('aria-hidden', 'true'); + + Array.from(value).forEach((char, index) => { + if (!/\d/.test(char)) { + const separator = document.createElement('span'); + separator.className = 'ticker-sep'; + separator.textContent = char; + visual.append(separator); + return; + } + const column = document.createElement('span'); + column.className = 'ticker-digit'; + // 静息位置一开始就写好:没轮到它滚动时也是正确的数字,不会是 0 + column.style.setProperty('--roll-to', `-${round2(Number(char) * 1.1)}em`); + column.style.setProperty('--roll-delay', `${round2(index * DURATION.numberStagger)}s`); + column.innerHTML = `${Array.from({ length: 10 }, (_, n) => `${n}`).join('')}`; + visual.append(column); + }); + + host.append(visual); + host.dataset.state = 'idle'; + }); + + /** 先落到 0、再去掉这个类 —— 浏览器才会把「滚上去」的过渡跑起来 */ + function run(host) { + if (host.dataset.state === 'done') return; + host.dataset.state = 'done'; + if (reduceMotion()) return; // 静息位置本来就是对的,不需要动 + $$('.ticker-digit', host).forEach((column) => { + column.classList.add('is-arming'); + void column.offsetWidth; + after(Number.parseFloat(column.style.getPropertyValue('--roll-delay')) || 0, () => + column.classList.remove('is-arming'), + ); + }); + } + + if (!('IntersectionObserver' in window)) { + tickers.forEach(run); + return; + } + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + run(entry.target); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.6 }, + ); + tickers.forEach((host) => observer.observe(host)); + } + + /* ══════════════════════════════════════════════════════════════════════ + 八、复制按钮 —— beUI ActionSwap 的 roll 变体 + ══════════════════════════════════════════════════════════════════════ */ + + function setupCopyButtons() { + $$('[data-copy]').forEach((button) => { + const label = $('.copy-label', button) ?? button; + const idle = label.textContent.trim(); + const swapped = button.dataset.copyDone ?? '已复制'; + + // action-swap 的 roll 变体:新文案从下方 90% 滚上来,带一点模糊 + label.style.transition = `opacity 0.25s ${EASE_OUT}, filter 0.25s ${EASE_OUT}`; + const roll = new Spring( + 90, + SPRING_SWAP, + (value) => { + label.style.transform = `translateY(${round2(value)}%)`; + }, + { restFloor: 0.05 }, + ); + + function swapTo(text) { + label.textContent = text; + if (reduceMotion()) { + label.style.opacity = '1'; + label.style.filter = 'blur(0px)'; + roll.jump(0); + return; + } + label.style.transition = 'none'; + label.style.opacity = '0'; + label.style.filter = 'blur(3px)'; + roll.jump(90); + void label.offsetWidth; + label.style.transition = `opacity 0.25s ${EASE_OUT}, filter 0.25s ${EASE_OUT}`; + label.style.opacity = '1'; + label.style.filter = 'blur(0px)'; + roll.set(0); + } + + button.addEventListener('click', async () => { + const source = $(button.dataset.copy); + if (!source) return; + const text = source.textContent.trim(); + try { + await navigator.clipboard.writeText(text); + } catch { + const range = document.createRange(); + range.selectNodeContents(source); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + document.execCommand('copy'); + selection.removeAllRanges(); + } + + swapTo(swapped); + button.dataset.done = '1'; + after(2, () => { + swapTo(idle); + button.dataset.done = '0'; + }); + }); + }); + } + + /* ══════════════════════════════════════════════════════════════════════ + 十、实机截图 —— beUI Tabs(分段控件)+ Content swap(换组)+ Morphing lightbox + ══════════════════════════════════════════════════════════════════════ */ + + /** + * 图都是 `website/tools/capture-shots.mjs` 与客户端冒烟脚本真的把程序跑起来截的 + * (见 README §7「官网」的采集步骤),不是画的示意。 + */ + const SHOT_GROUPS = [ + { + id: 'web', + label: 'Web 管理端', + items: [ + { + file: 'web-dashboard.jpg', + kind: 'screen', + cap: '仪表盘 —— 统计卡、最新通知与最近作业,范围按账号权限自动收窄', + }, + { + file: 'web-schedules.jpg', + kind: 'screen', + cap: '课表管理 —— 按周排课、单双周标签,也能直接导入 ClassIsland 的档案', + }, + { + file: 'web-homeworks.jpg', + kind: 'screen', + cap: '作业发布 —— 按天查看、快捷短语,教室机器上也能录', + }, + { + file: 'web-notifications.jpg', + kind: 'screen', + cap: '通知发布 —— 优先级、已读人数、上课时段发紧急通知要先过二次确认', + }, + { + file: 'web-grades.jpg', + kind: 'screen', + cap: '成绩录入 —— 单条 / 批量 / 表格导入,重复判定按「同班同学生同考试同课程」', + }, + { + file: 'web-integrations.jpg', + kind: 'screen', + cap: 'ClassIsland 联动 —— 设备在线状态、当前节次、回传与镜像开关', + }, + { + file: 'web-classes.jpg', + kind: 'screen', + cap: '班级管理 —— 建班、换班主任、改班级码与班级密码(仅管理员)', + }, + { file: 'web-students.jpg', kind: 'screen', cap: '学生管理 —— 名单、表格导入,以及发「叫人」' }, + { + file: 'web-teachers.jpg', + kind: 'screen', + cap: '教师管理 —— 单个录入 / 导入名单 / 重置密码(仅管理员)', + }, + { + file: 'web-database.jpg', + kind: 'screen', + cap: '数据库管理 —— 连接状态、备份、导入导出、SQLite ⇄ MySQL 一键切换', + }, + { file: 'web-login.jpg', kind: 'screen', cap: '登录页 —— 页面上没有任何示例账号或演示数据' }, + ], + }, + { + id: 'client', + label: '教室机器', + items: [ + { + file: 'client-schedule.png', + kind: 'screen', + cap: '「今天」时间轴 —— 大时钟、正在上的课、距下课倒计时、下一节还有多久', + }, + { + file: 'client-homeworks.png', + kind: 'screen', + cap: '作业看板 —— 按科目分卡,整屏缩放到刚好放下,不用上下翻页', + }, + { + file: 'client-grades.png', + kind: 'screen', + cap: '本班成绩总览 —— 分数 / 得分率 / 等级,配一张柱状图', + }, + { + file: 'client-settings-island.png', + kind: 'screen', + cap: '灵动岛个性化 —— 高宽圆角字号、主题色、动画速度都能调', + }, + { + file: 'client-settings-appearance.png', + kind: 'screen', + cap: '外观 —— 浅色 / 深色两套主题,切换即时生效', + }, + ], + }, + { + id: 'island', + label: '灵动岛', + items: [ + { file: 'island-4-pill.png', kind: 'island', cap: '胶囊 —— 消息到达时先出这个,点开才是详情' }, + { file: 'island-5-clicked.png', kind: 'island', cap: '展开的详情卡 —— 标题、正文、时间与三个按钮' }, + { + file: 'island-3-urgent.png', + kind: 'island', + cap: '紧急通知 —— 卡片内部红色呼吸光晕,上课时段也立刻展开', + }, + { + file: 'island-8-call.png', + kind: 'island', + cap: '叫人 ——「请 XXX 同学找 XXX 老师」,按钮是「收到」', + }, + { file: 'island-7-homework.png', kind: 'island', cap: '新作业 —— 青蓝描边加书本图标' }, + { + file: 'island-2-after-class.png', + kind: 'island', + cap: '上课期间收到、先收着的通知,打下课铃自动补弹', + }, + ], + }, + { + id: 'mobile', + label: '手机', + items: [ + { + file: 'mobile-notifications.jpg', + kind: 'phone', + cap: '手机上的抽屉导航 —— 侧边栏收进抽屉,由顶栏汉堡按钮唤出', + }, + { + file: 'mobile-dashboard.jpg', + kind: 'phone', + cap: '手机仪表盘 —— 统计卡两列,表格在卡片内横向滚动,页面不横滚', + }, + ], + }, + ]; + + function setupShots() { + const widget = $('#shots-widget'); + const tabsHost = $('.shots-tabs', widget ?? document); + const indicator = $('.shots-indicator', widget ?? document); + const grid = $('#shots-grid'); + if (!widget || !tabsHost || !indicator || !grid) return; + + // ── 分段控件:指示器在选项之间滑移(beUI Tabs 的 layoutId,这里靠 SPRING_LAYOUT 驱动) + const indicatorX = new Spring(0, SPRING_LAYOUT, (value) => { + indicator.style.transform = `translateX(${round2(value)}px)`; + }); + const indicatorW = new Spring(0, SPRING_LAYOUT, (value) => { + indicator.style.width = `${round2(value)}px`; + }); + + const tabs = SHOT_GROUPS.map((group) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'shots-tab'; + button.setAttribute('role', 'tab'); + button.dataset.shotsTab = group.id; + button.textContent = group.label; + button.setAttribute('aria-selected', 'false'); + button.addEventListener('click', () => select(group.id)); + return button; + }); + tabsHost.append(...tabs); + + function moveIndicator(button, animate) { + const hostBox = tabsHost.getBoundingClientRect(); + const box = button.getBoundingClientRect(); + const borderLeft = Number.parseFloat(getComputedStyle(tabsHost).borderLeftWidth) || 0; + const x = box.left - hostBox.left - borderLeft; + if (!animate || reduceMotion()) { + indicatorX.jump(x); + indicatorW.jump(box.width); + return; + } + if (indicatorW.value === 0) indicatorW.jump(box.width); + indicatorX.set(x); + indicatorW.set(box.width); + } + + // ── 卡片 + function makeCard(item) { + const card = document.createElement('button'); + card.type = 'button'; + card.className = 'shot-card'; + card.dataset.kind = item.kind; + card.dataset.press = '0.98'; + card.innerHTML = ` + + ${item.cap} + + ${item.cap}`; + bindPressable(card); + card.addEventListener('click', () => openLightbox(card, item)); + return card; + } + + /** beUI 的 Content reveal:透明度 + 8px 上浮 + 去模糊,0.22s EASE_OUT */ + const GRID_HIDDEN = { opacity: '0', transform: 'translateY(8px)', filter: 'blur(4px)' }; + + function hideGrid() { + grid.style.transition = 'none'; + grid.style.opacity = GRID_HIDDEN.opacity; + grid.style.transform = GRID_HIDDEN.transform; + grid.style.filter = GRID_HIDDEN.filter; + } + + function revealGrid() { + grid.style.transition = `opacity ${DURATION.reveal}s ${EASE_OUT}, transform ${DURATION.reveal}s ${EASE_OUT}, filter ${DURATION.reveal}s ${EASE_OUT}`; + grid.style.opacity = '1'; + grid.style.transform = 'translateY(0)'; + grid.style.filter = 'blur(0px)'; + } + + /** beUI 的 Content swap:旧的走得比新的快,位移只有几个像素 */ + function renderGrid(id, animate) { + const group = SHOT_GROUPS.find((item) => item.id === id); + if (!group) return; + + const build = () => { + grid.innerHTML = ''; + for (const item of group.items) grid.append(makeCard(item)); + }; + + if (!animate || reduceMotion()) { + build(); + return; + } + + grid.style.transition = `opacity 0.12s ${EASE_OUT}, transform 0.12s ${EASE_OUT}, filter 0.12s ${EASE_OUT}`; + grid.style.opacity = '0'; + grid.style.transform = 'translateY(-4px)'; + grid.style.filter = 'blur(4px)'; + after(0.12, () => { + build(); + grid.style.transition = 'none'; + grid.style.transform = 'translateY(4px)'; + void grid.offsetHeight; + grid.style.transition = `opacity 0.18s ${EASE_OUT}, transform 0.18s ${EASE_OUT}, filter 0.18s ${EASE_OUT}`; + grid.style.opacity = '1'; + grid.style.transform = 'translateY(0)'; + grid.style.filter = 'blur(0px)'; + }); + } + + let current = null; + function select(id) { + if (current === id) return; + const first = current === null; + current = id; + tabs.forEach((button) => { + button.setAttribute('aria-selected', String(button.dataset.shotsTab === id)); + }); + moveIndicator(tabs[SHOT_GROUPS.findIndex((group) => group.id === id)], !first); + + // 首次进入:先落到隐藏态再建卡片,否则会先闪一下可见的样子。 + // 起跑用「下一帧 + 定时器兜底」:rAF 被限流时(后台标签页、被遮挡的窗口)也不能把整块图库留在透明态。 + if (first && !reduceMotion()) { + hideGrid(); + renderGrid(id, false); + M.nextFrame(revealGrid); + after(0.3, revealGrid); + return; + } + renderGrid(id, !first); + } + + // ── 大图:从缩略图原地长到大图(FLIP) + const lightbox = $('#lightbox'); + const backdrop = $('#lightbox-backdrop'); + const panel = $('#lightbox-panel'); + const lightboxImg = $('#lightbox-img'); + const caption = $('#lightbox-cap'); + const closeButton = $('#lightbox-close'); + + let openCard = null; + let targetSize = { w: 0, h: 0 }; + const box = { + x: new Spring(0, SPRING_LAYOUT, (v) => { + panel.style.transform = `translate3d(${round2(v)}px, ${round2(box.y.value)}px, 0)`; + }), + y: new Spring(0, SPRING_LAYOUT, () => { + panel.style.transform = `translate3d(${round2(box.x.value)}px, ${round2(box.y.value)}px, 0)`; + }), + w: new Spring(0, SPRING_LAYOUT, (v) => { + panel.style.width = `${round2(v)}px`; + }), + h: new Spring(0, SPRING_LAYOUT, (v) => { + panel.style.height = `${round2(v)}px`; + }), + }; + + /** 目标矩形:屏幕类按视口收着放,灵动岛的图**不放大**(原图就只有几百像素) */ + function targetRect(kind, image) { + const natural = { + w: image.naturalWidth || 16, + h: image.naturalHeight || 9, + }; + const isIsland = kind === 'island'; + const maxWidth = window.innerWidth * (isIsland ? 0.92 : 0.94); + const maxHeight = window.innerHeight * (isIsland ? 0.6 : 0.84); + let scale = Math.min(maxWidth / natural.w, maxHeight / natural.h); + if (isIsland) scale = Math.min(scale, 1); + const w = Math.round(natural.w * scale); + const h = Math.round(natural.h * scale); + return { + w, + h, + x: Math.round((window.innerWidth - w) / 2), + y: Math.round((window.innerHeight - h) / 2), + }; + } + + function openLightbox(card, item) { + const thumb = $('img', card); + if (!thumb) return; + openCard = card; + lightboxImg.src = `assets/shots/${item.file}`; + lightboxImg.alt = item.cap; + caption.textContent = item.cap; + + const start = thumb.getBoundingClientRect(); + const target = targetRect(item.kind, thumb); + targetSize = target; + + lightbox.hidden = false; + panel.style.width = `${round2(start.width)}px`; + panel.style.height = `${round2(start.height)}px`; + box.x.jump(start.left); + box.y.jump(start.top); + box.w.jump(start.width); + box.h.jump(start.height); + + const fade = `opacity 0.18s ${EASE_OUT}`; + backdrop.style.transition = fade; + closeButton.style.transition = fade; + caption.style.transition = fade; + backdrop.style.opacity = '1'; + closeButton.style.opacity = '1'; + caption.style.opacity = '1'; + + if (!reduceMotion()) { + box.x.set(target.x); + box.y.set(target.y); + box.w.set(target.w); + box.h.set(target.h); + // 兜底:帧循环被限流时(后台标签页、被遮挡的窗口)弹簧推不动, + // 面板会一直卡在缩略图那么大。半秒后还没动就直接落到终点。 + after(0.5, () => { + if (lightbox.hidden) return; + if (Math.abs(box.w.value - start.width) > 1) return; + box.x.jump(target.x); + box.y.jump(target.y); + box.w.jump(target.w); + box.h.jump(target.h); + }); + } else { + box.x.jump(target.x); + box.y.jump(target.y); + box.w.jump(target.w); + box.h.jump(target.h); + } + + document.body.style.overflow = 'hidden'; + closeButton.focus({ preventScroll: true }); + } + + function closeLightbox() { + if (lightbox.hidden) return; + const card = openCard; + const thumb = card ? $('img', card) : null; + const end = thumb ? thumb.getBoundingClientRect() : null; + + const fade = `opacity 0.14s ${EASE_OUT}`; + backdrop.style.transition = fade; + closeButton.style.transition = fade; + caption.style.transition = fade; + backdrop.style.opacity = '0'; + closeButton.style.opacity = '0'; + caption.style.opacity = '0'; + + if (end && !reduceMotion()) { + box.x.set(end.left); + box.y.set(end.top); + box.w.set(end.width); + box.h.set(end.height); + } + after(reduceMotion() ? 0 : 0.22, () => { + lightbox.hidden = true; + lightboxImg.removeAttribute('src'); + document.body.style.overflow = ''; + if (card) card.focus({ preventScroll: true }); + openCard = null; + }); + } + + backdrop.addEventListener('click', closeLightbox); + closeButton.addEventListener('click', closeLightbox); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !lightbox.hidden) closeLightbox(); + }); + window.addEventListener( + 'resize', + () => { + if (lightbox.hidden) return; + const thumb = openCard ? $('img', openCard) : null; + if (!thumb) return; + const next = targetRect(openCard.dataset.kind, thumb); + if (next.w === targetSize.w && next.h === targetSize.h) return; + targetSize = next; + box.x.set(next.x); + box.y.set(next.y); + box.w.set(next.w); + box.h.set(next.h); + }, + { passive: true }, + ); + + select('web'); + // 首帧之后量一次指示器:字体加载完宽度会变 + M.nextFrame(() => moveIndicator(tabs[0], false)); + window.addEventListener( + 'resize', + () => + moveIndicator( + tabs.find((b) => b.dataset.shotsTab === current), + false, + ), + { + passive: true, + }, + ); + } + + /* ══════════════════════════════════════════════════════════════════════ + 十一、启动 + ══════════════════════════════════════════════════════════════════════ */ + + setupHeadlineReveal(); + setupIsland(); + setupNavPill(); + setupScrollProgress(); + setupPressable(); + setupPolicy(); + setupNumberTickers(); + setupCopyButtons(); + setupShots(); +})(); diff --git a/website/assets/motion.js b/website/assets/motion.js new file mode 100644 index 0000000..60db6c6 --- /dev/null +++ b/website/assets/motion.js @@ -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, + }; +})(); diff --git a/website/assets/shots/client-grades.png b/website/assets/shots/client-grades.png new file mode 100644 index 0000000..ee14292 Binary files /dev/null and b/website/assets/shots/client-grades.png differ diff --git a/website/assets/shots/client-homeworks.png b/website/assets/shots/client-homeworks.png new file mode 100644 index 0000000..935d873 Binary files /dev/null and b/website/assets/shots/client-homeworks.png differ diff --git a/website/assets/shots/client-schedule.png b/website/assets/shots/client-schedule.png new file mode 100644 index 0000000..ed394f6 Binary files /dev/null and b/website/assets/shots/client-schedule.png differ diff --git a/website/assets/shots/client-settings-appearance.png b/website/assets/shots/client-settings-appearance.png new file mode 100644 index 0000000..dce0698 Binary files /dev/null and b/website/assets/shots/client-settings-appearance.png differ diff --git a/website/assets/shots/client-settings-island.png b/website/assets/shots/client-settings-island.png new file mode 100644 index 0000000..ad293f6 Binary files /dev/null and b/website/assets/shots/client-settings-island.png differ diff --git a/website/assets/shots/island-2-after-class.png b/website/assets/shots/island-2-after-class.png new file mode 100644 index 0000000..8ba1084 Binary files /dev/null and b/website/assets/shots/island-2-after-class.png differ diff --git a/website/assets/shots/island-3-urgent.png b/website/assets/shots/island-3-urgent.png new file mode 100644 index 0000000..e2b98f5 Binary files /dev/null and b/website/assets/shots/island-3-urgent.png differ diff --git a/website/assets/shots/island-4-pill.png b/website/assets/shots/island-4-pill.png new file mode 100644 index 0000000..1c91354 Binary files /dev/null and b/website/assets/shots/island-4-pill.png differ diff --git a/website/assets/shots/island-5-clicked.png b/website/assets/shots/island-5-clicked.png new file mode 100644 index 0000000..5f4ea4f Binary files /dev/null and b/website/assets/shots/island-5-clicked.png differ diff --git a/website/assets/shots/island-7-homework.png b/website/assets/shots/island-7-homework.png new file mode 100644 index 0000000..6a8faf0 Binary files /dev/null and b/website/assets/shots/island-7-homework.png differ diff --git a/website/assets/shots/island-8-call.png b/website/assets/shots/island-8-call.png new file mode 100644 index 0000000..77b6f29 Binary files /dev/null and b/website/assets/shots/island-8-call.png differ diff --git a/website/assets/shots/mobile-dashboard.jpg b/website/assets/shots/mobile-dashboard.jpg new file mode 100644 index 0000000..6198d85 Binary files /dev/null and b/website/assets/shots/mobile-dashboard.jpg differ diff --git a/website/assets/shots/mobile-notifications.jpg b/website/assets/shots/mobile-notifications.jpg new file mode 100644 index 0000000..1df6a4d Binary files /dev/null and b/website/assets/shots/mobile-notifications.jpg differ diff --git a/website/assets/shots/web-classes.jpg b/website/assets/shots/web-classes.jpg new file mode 100644 index 0000000..5b3a53c Binary files /dev/null and b/website/assets/shots/web-classes.jpg differ diff --git a/website/assets/shots/web-dashboard.jpg b/website/assets/shots/web-dashboard.jpg new file mode 100644 index 0000000..2a13ce0 Binary files /dev/null and b/website/assets/shots/web-dashboard.jpg differ diff --git a/website/assets/shots/web-database.jpg b/website/assets/shots/web-database.jpg new file mode 100644 index 0000000..236dce4 Binary files /dev/null and b/website/assets/shots/web-database.jpg differ diff --git a/website/assets/shots/web-grades.jpg b/website/assets/shots/web-grades.jpg new file mode 100644 index 0000000..fbdf39b Binary files /dev/null and b/website/assets/shots/web-grades.jpg differ diff --git a/website/assets/shots/web-homeworks.jpg b/website/assets/shots/web-homeworks.jpg new file mode 100644 index 0000000..0082f22 Binary files /dev/null and b/website/assets/shots/web-homeworks.jpg differ diff --git a/website/assets/shots/web-integrations.jpg b/website/assets/shots/web-integrations.jpg new file mode 100644 index 0000000..858fc39 Binary files /dev/null and b/website/assets/shots/web-integrations.jpg differ diff --git a/website/assets/shots/web-login.jpg b/website/assets/shots/web-login.jpg new file mode 100644 index 0000000..8cdc6ea Binary files /dev/null and b/website/assets/shots/web-login.jpg differ diff --git a/website/assets/shots/web-notifications.jpg b/website/assets/shots/web-notifications.jpg new file mode 100644 index 0000000..8737826 Binary files /dev/null and b/website/assets/shots/web-notifications.jpg differ diff --git a/website/assets/shots/web-schedules.jpg b/website/assets/shots/web-schedules.jpg new file mode 100644 index 0000000..183e601 Binary files /dev/null and b/website/assets/shots/web-schedules.jpg differ diff --git a/website/assets/shots/web-students.jpg b/website/assets/shots/web-students.jpg new file mode 100644 index 0000000..61b8008 Binary files /dev/null and b/website/assets/shots/web-students.jpg differ diff --git a/website/assets/shots/web-teachers.jpg b/website/assets/shots/web-teachers.jpg new file mode 100644 index 0000000..2e03e1d Binary files /dev/null and b/website/assets/shots/web-teachers.jpg differ diff --git a/website/assets/styles.css b/website/assets/styles.css new file mode 100644 index 0000000..ea25b36 --- /dev/null +++ b/website/assets/styles.css @@ -0,0 +1,2002 @@ +/* ============================================================================ + 班级小助手 · 官网样式 + ---------------------------------------------------------------------------- + 两套表面,对应教室里的两样东西: + + 黑板(.band-board) 教室前面那块板 —— 屏幕上的事都在这里发生 + 纸张(.band-paper) 老师手里的东西 —— 名单、课表、账目都在这里读 + + 颜色只有两个强调色,各有各的活儿: + 蓝(--blue / --blue-deep) 产品自己的强调色。表示"这是一条真的数据" + 红(--red) 只用在"上课被打断"这一件事上 + ========================================================================= */ + +:root { + /* 黑板 */ + --board: #16231e; + --board-deep: #0c1311; + --board-raise: #1d2e27; + --board-line: rgba(232, 238, 233, 0.13); + --board-line-soft: rgba(232, 238, 233, 0.07); + --chalk: #e8eee9; + --chalk-2: #97aca1; + --chalk-3: #6d8279; + + /* 屏幕(黑板里那块亮起来的屏) */ + --screen-line: rgba(232, 238, 233, 0.12); + + /* 纸张 */ + --paper: #f1f4f7; + --paper-raise: #ffffff; + --paper-rule: #d9e0e7; + --paper-rule-soft: #e9eef3; + --ink: #17231f; + --ink-2: #56655f; + --ink-3: #8a9893; + + /* 强调色: + 蓝 = 产品自己的强调色,表示"这是一条真的数据"(链接、进度、实时状态) + 红 = 只用在"上课被打断"这一件事上,整页只出现一次 */ + --blue: #2f8fe6; + --blue-deep: #0f6cbd; + --chalk-red: #f0a5a1; + + /* 灵动岛用的产品原色(只在这一处出现,是复刻,不参与整站配色) */ + --island-bg: #000000; + --island-border: rgba(255, 255, 255, 0.118); + --island-accent: #0a84ff; + --island-urgent: #ff453a; + --island-call: #ff9f0a; + + /* 曲线 token —— 与 assets/motion.js 的 EASE_* 同值(beUI lib/ease.ts) */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); + --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); + + /* 字体:标题用楷体(课本、板书、作文本的那个声音),正文用产品自己那套 UI 字体 */ + --display: + 'LXGW WenKai Screen', 'LXGW WenKai', '霞鹜文楷', KaiTi, '楷体', STKaiti, 'Kaiti SC', 'AR PL UKai CN', + serif; + --ui: + 'Segoe UI Variable Text', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'PingFang SC', + 'Microsoft YaHei UI', 'Microsoft YaHei', 'Noto Sans SC', system-ui, sans-serif; + --mono: ui-monospace, 'Cascadia Mono', 'SF Mono', Consolas, 'Microsoft YaHei UI', monospace; + + --fs-display: clamp(2.1rem, 5vw, 3.6rem); + --fs-h2: clamp(1.7rem, 3.4vw, 2.5rem); + --fs-h3: clamp(1.05rem, 1.5vw, 1.2rem); + --fs-lead: clamp(1rem, 1.5vw, 1.12rem); + --fs-sm: 0.875rem; + --fs-xs: 0.8125rem; + + --wrap: 1120px; + --gutter: 24px; + --radius: 4px; +} + +/* ------------------------------------------------------------------ 基础 */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + scroll-padding-top: 72px; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + background: var(--board); + color: var(--chalk); + font-family: var(--ui); + font-size: 1rem; + line-height: 1.9; + font-feature-settings: 'tnum' 1; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +img { + max-width: 100%; + height: auto; + display: block; +} + +a { + color: inherit; +} + +code { + font-family: var(--mono); + font-size: 0.875em; + font-variant-numeric: tabular-nums; +} + +p, +ul, +ol, +dl, +figure, +table { + margin: 0; +} + +:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 3px; + border-radius: 2px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.skip { + position: absolute; + left: -9999px; + top: 0; + z-index: 40; + background: var(--paper-raise); + color: var(--ink); + padding: 10px 16px; +} + +.skip:focus { + left: 12px; + top: 12px; +} + +.wrap { + width: 100%; + max-width: var(--wrap); + margin-inline: auto; + padding-inline: var(--gutter); +} + +/* ------------------------------------------------------------------ 顶栏 */ + +.topbar { + position: sticky; + top: 0; + z-index: 30; + background: color-mix(in srgb, var(--board-deep) 88%, transparent); + backdrop-filter: blur(14px); + border-bottom: 1px solid var(--board-line-soft); +} + +.topbar-inner { + display: flex; + align-items: center; + gap: 20px; + min-height: 60px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} + +.brand-mark { + border-radius: 8px; +} + +.brand-name { + font-family: var(--display); + font-size: 1.28rem; + letter-spacing: 0.02em; +} + +.brand-ver { + font-size: var(--fs-xs); + color: var(--chalk-3); + font-variant-numeric: tabular-nums; + padding-left: 10px; + border-left: 1px solid var(--board-line); + line-height: 1; +} + +.topnav { + display: flex; + gap: 22px; + margin-left: auto; + font-size: var(--fs-sm); + position: relative; +} + +/* 悬停胶囊:beUI SharedLayoutBg —— 一个 layoutId 在条目之间滑移, + 显隐走 blur(6px) 的淡入淡出,位置由 SPRING_LAYOUT 驱动(见 main.js) */ +.nav-pill { + position: absolute; + top: 50%; + left: 0; + width: 0; + height: 34px; + margin-top: -17px; + border-radius: 10px; + background: var(--board-raise); + opacity: 0; + filter: blur(6px); + transition: + opacity 0.2s var(--ease-out), + filter 0.2s var(--ease-out); + pointer-events: none; +} + +.nav-pill.is-visible { + opacity: 1; + filter: blur(0); +} + +.topnav a { + position: relative; + color: var(--chalk-2); + text-decoration: none; + padding: 6px 0; + transition: color 0.16s var(--ease-out); +} + +.topnav a:hover, +.topnav a:focus-visible { + color: var(--chalk); +} + +/* 阅读进度 —— beUI ScrollProgressBar:2px、origin-left、scaleX 跟着滚动走, + 值由 SPRING_PROGRESS 缓跟(故意比 UI 弹簧松,让它追着滚动而不是贴着) */ +.scroll-progress { + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 2px; + background: var(--blue); + transform: scaleX(0); + transform-origin: left center; + will-change: transform; +} + +/* ------------------------------------------------------------------ 按钮 */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: var(--fs-sm); + font-weight: 500; + line-height: 1; + padding: 13px 22px; + border-radius: var(--radius); + text-decoration: none; + border: 1px solid transparent; + transition: + background-color 0.16s var(--ease-out), + border-color 0.16s var(--ease-out), + color 0.16s var(--ease-out); + white-space: nowrap; +} + +.btn-solid { + background: var(--blue-deep); + color: #fff; +} + +.btn-solid:hover { + background: #1280dd; +} + +.btn-line { + border-color: var(--board-line); + color: var(--chalk); +} + +.btn-line:hover { + border-color: var(--chalk-3); + background: var(--board-raise); +} + +.btn-quiet { + margin-left: auto; + border-color: var(--board-line); + color: var(--chalk); + padding: 9px 16px; +} + +.btn-quiet:hover { + background: var(--board-raise); +} + +/* ------------------------------------------------------------------ 色带 */ + +.band { + padding-block: clamp(64px, 9vw, 116px); + border-bottom: 1px solid var(--board-line-soft); +} + +.band-board { + background: var(--board); + color: var(--chalk); +} + +.band-paper { + background: var(--paper); + color: var(--ink); + border-bottom-color: var(--paper-rule); +} + +/* ── 分幕标题 ──────────────────────────────────────────────────────────────── + 照 ClassIsland 官网的分节写法:居中的一句大标题,中间一小段用渐变填色。 + 它是「这一幕讲什么」的路标,所以比下面的小节标题大一档、也更空。 */ + +.act { + background: var(--board); + padding-block: clamp(64px, 8vw, 104px); + border-bottom: 1px solid var(--board-line-soft); +} + +.act-title { + font-family: var(--display); + font-weight: 400; + font-size: clamp(1.75rem, 3.6vw, 2.6rem); + line-height: 1.35; + letter-spacing: 0.02em; + text-align: center; + color: var(--chalk); +} + +/* 渐变高亮词组。深色底上从粉笔白走到品牌蓝,读起来还是「同一块黑板上的字」 */ +.act-hl { + background-image: linear-gradient(135deg, var(--chalk) 15%, var(--blue) 85%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; +} + +/* ── 收尾 CTA ───────────────────────────────────────────────────────────── */ + +.cta { + background: var(--board); + padding-block: clamp(72px, 9vw, 120px); + border-bottom: 0; +} + +.cta-title { + font-family: var(--display); + font-weight: 400; + font-size: clamp(1.75rem, 3.6vw, 2.6rem); + line-height: 1.35; + text-align: center; + color: var(--chalk); +} + +.cta-lead { + margin: 18px auto 0; + max-width: 34em; + text-align: center; + font-size: var(--fs-lead); + line-height: 1.95; + color: var(--chalk-2); +} + +.cta-actions { + margin-top: 30px; + display: flex; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +} + +.band-paper code { + background: rgba(23, 35, 31, 0.06); + padding: 1px 5px; + border-radius: 3px; +} + +/* 纸张上的分隔线换色 */ +.band-paper .lead strong { + color: var(--ink); +} + +/* ------------------------------------------------------------------ 排版 */ + +.display { + font-family: var(--display); + font-weight: 400; + font-size: var(--fs-display); + line-height: 1.32; + letter-spacing: 0.01em; + max-width: 20em; + margin-bottom: 30px; +} + +/* 标题逐字上浮 —— beUI TextReveal。 + 脚本没跑起来时标题就是普通标题;跑起来了才按字拆开并藏起来等揭示。 + `reveal-pending` 由一个 4 秒的兜底定时器摘掉,脚本坏在半路也不会把标题永久藏住。 */ +.reveal-pending .display[data-reveal] { + visibility: hidden; +} + +/* 每行一个块级容器:换行交给浏览器,窄屏上标题才会自己折行。 + 字本身是 inline-block(beUI 的做法),所以不会在字中间断开。 */ +.reveal-line { + display: block; +} + +.reveal-unit { + display: inline-block; + white-space: pre; + will-change: transform, opacity, filter; +} + +.h2 { + font-family: var(--display); + font-weight: 400; + font-size: var(--fs-h2); + line-height: 1.4; + margin-bottom: 20px; + max-width: 22em; +} + +.h3 { + font-family: var(--display); + font-weight: 400; + font-size: var(--fs-h3); + line-height: 1.5; + margin-bottom: 6px; + padding-bottom: 12px; + border-bottom: 1px solid var(--paper-rule); +} + +.lead { + font-size: var(--fs-lead); + line-height: 1.95; + color: var(--chalk-2); + max-width: 34em; +} + +.narrow { + max-width: 34em; +} + +.band-paper .lead { + color: var(--ink-2); +} + +.lead + .lead { + margin-top: 18px; +} + +strong { + font-weight: 600; + color: var(--chalk); +} + +.band-paper strong { + color: var(--ink); +} + +/* ════════════════════════════════ 教室那块屏幕 ════════════════════════════ */ + +.hero { + padding-top: clamp(44px, 6vw, 76px); +} + +.screen { + margin-top: clamp(40px, 5vw, 60px); + border: 1px solid var(--board-line); + border-radius: 10px; + /* 亮着的屏:比黑板亮一档的冷灰蓝,灵动岛那块纯黑才映得出来 */ + background: linear-gradient(180deg, #22303f 0%, #161e28 100%); + overflow: hidden; + box-shadow: 0 24px 60px -30px rgba(0, 0, 0, 0.9); +} + +.screen-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 0 16px; + height: 38px; + border-bottom: 1px solid var(--screen-line); + font-size: var(--fs-xs); + color: var(--chalk-3); +} + +.screen-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #4ac07a; + box-shadow: 0 0 0 3px rgba(74, 192, 122, 0.16); + flex-shrink: 0; +} + +.screen-title { + color: var(--chalk-2); +} + +.screen-live { + margin-left: auto; + font-variant-numeric: tabular-nums; +} + +.screen-stage { + position: relative; + padding: 0 var(--gutter) 30px; + --headroom: 300px; +} + +.screen-body { + display: flex; + align-items: center; + gap: clamp(18px, 4vw, 52px); + flex-wrap: wrap; + padding-top: var(--headroom); +} + +.clock-hm { + font-size: clamp(2.6rem, 6vw, 4rem); + font-weight: 600; + line-height: 1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + display: flex; + align-items: baseline; + gap: 6px; +} + +.clock-s { + font-size: 0.32em; + font-weight: 500; + color: var(--chalk-3); +} + +.clock-week { + margin-top: 10px; + font-size: var(--fs-sm); + color: var(--chalk-3); + font-variant-numeric: tabular-nums; +} + +.clock-now { + flex: 1 1 300px; + min-width: 260px; + max-width: 460px; + border-left: 2px solid var(--blue-deep); + padding-left: 18px; +} + +.clock-current { + font-size: 1.06rem; +} + +.clock-current b { + font-weight: 600; +} + +.clock-sub { + margin-top: 4px; + font-size: var(--fs-xs); + color: var(--chalk-3); + font-variant-numeric: tabular-nums; +} + +.clock-bar { + margin-top: 12px; + height: 4px; + border-radius: 2px; + background: rgba(232, 238, 233, 0.12); + overflow: hidden; +} + +.clock-bar i { + display: block; + height: 100%; + background: var(--blue); + border-radius: 2px; +} + +.clock-next { + margin-left: auto; + text-align: right; + padding-left: 18px; + border-left: 1px solid var(--screen-line); +} + +.next-head { + font-size: var(--fs-xs); + color: var(--chalk-3); +} + +.next-name { + font-size: 1.06rem; + margin-top: 4px; +} + +.next-name span { + font-size: var(--fs-xs); + color: var(--chalk-3); + font-variant-numeric: tabular-nums; + margin-left: 6px; +} + +.next-sub { + margin-top: 4px; + font-size: var(--fs-xs); + color: var(--chalk-3); +} + +.hero-cap { + margin-top: 16px; + font-size: var(--fs-xs); + line-height: 1.85; + color: var(--chalk-3); + max-width: 40em; +} + +/* 首屏文字收成左列(ClassIsland 的 .introduction-col 也是这么窄的一条)。 + 47em ≈ 750px:再窄一点,「它知道现在是第几节课,」的逗号就会掉到第二行去。 */ +.hero-intro { + max-width: 47em; +} + +/* 宽屏上把三句事实挪到文字右边,填住那条空 —— ClassIsland 的首屏也是左文右块。 + 屏幕照旧整幅铺在下面:灵动岛是「屏幕顶部居中」的,不能只给它半幅。 */ +@media (min-width: 1080px) { + .hero .wrap { + display: grid; + grid-template-columns: minmax(0, 47em) 1fr; + grid-template-areas: + 'intro facts' + 'screen screen' + 'cap cap'; + align-items: start; + gap: 34px clamp(36px, 5vw, 72px); + } + + .hero-intro { + grid-area: intro; + } + + .facts { + grid-area: facts; + margin-top: 10px; + grid-template-columns: 1fr; + gap: 18px; + padding-top: 24px; + } + + .screen { + grid-area: screen; + } + + .hero-cap { + grid-area: cap; + } +} + +.hero-sub { + font-family: var(--display); + font-size: clamp(1.35rem, 2.6vw, 1.9rem); + line-height: 1.5; + color: var(--chalk); + margin-bottom: 14px; +} + +.hero-intro .lead { + max-width: 32em; + margin-bottom: 26px; +} + +/* ── 首屏入场:照着 ClassIsland 的 .intro-seq 写(逐项 0.72s EASE_OUT + 错峰延迟)。 + 用 backwards 而不是 both:延迟期间先落在起始帧,动画结束后就回到底样式, + 不在元素上留一个永久的 transform(那会多出一层包含块)。 */ +@media (prefers-reduced-motion: no-preference) { + .intro-seq { + animation: intro-rise 0.72s var(--ease-out) backwards; + animation-delay: var(--intro-delay, 0s); + } + + .screen { + animation: intro-fly 0.75s var(--ease-out) 60ms backwards; + } +} + +@keyframes intro-rise { + from { + opacity: 0; + transform: translateX(-42px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes intro-fly { + from { + opacity: 0; + transform: translateX(96px) scale(0.86); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +.actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.facts { + list-style: none; + padding: 28px 0 0; + margin-top: 44px; + border-top: 1px solid var(--board-line); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 20px 34px; +} + +.facts li { + font-size: var(--fs-sm); + line-height: 1.85; + color: var(--chalk-2); + padding-left: 18px; + position: relative; +} + +.facts li::before { + content: ''; + position: absolute; + left: 0; + top: 0.72em; + width: 7px; + height: 1px; + background: var(--chalk-3); +} + +/* ════════════════════════════════ 灵动岛 ═════════════════════════════════ + 下面这些数值整块来自产品本身(DEFAULT_ISLAND_APPEARANCE / + ISLAND_SIZE_DELTA / ISLAND_TYPE_SCALE):底色 #000、白字 alpha 分级 + .92/.72/.58、强调色 #0a84ff、紧急 #ff453a、叫人 #ff9f0a。 + ====================================================================== */ + +.island-slot { + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + z-index: 2; + pointer-events: none; +} + +.island { + --island-font: 13px; + --state-color: var(--island-accent); + position: relative; + width: 268px; + height: 44px; + transform-origin: top center; + transition: opacity 0.3s var(--ease-out); + color: rgba(255, 255, 255, 0.92); + font-size: var(--island-font); + line-height: 1.5; +} + +.island[data-kind='urgent'] { + --state-color: var(--island-urgent); +} + +.island[data-kind='call'] { + --state-color: var(--island-call); +} + +.island-shape { + position: absolute; + inset: 0; + overflow: visible; + display: block; +} + +.island-base { + fill: var(--island-bg); + stroke: var(--island-border); + stroke-width: 1; + /* 投影常驻:给元素增删 CSS filter 会让 Chromium 重建渲染表面,开合瞬间底座闪一帧。 + beUI 的岛也是一直挂阴影,不做展开态才有的那套。 */ + filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.28)); +} + +/* 紧急 / 叫人:描边跟着状态色走,卡片顶部有一层同色光晕 */ +.island[data-kind='urgent'] .island-base { + stroke: color-mix(in srgb, var(--island-urgent) 62%, rgba(255, 255, 255, 0.2)); +} + +.island[data-kind='call'] .island-base { + stroke: color-mix(in srgb, var(--island-call) 62%, rgba(255, 255, 255, 0.2)); +} + +.island-tint { + fill: none; + stroke: none; +} + +.island[data-kind='urgent'] .island-tint { + fill: url(#island-tint-urgent); +} + +.island[data-kind='call'] .island-tint { + fill: url(#island-tint-call); +} + +.island-layers { + position: absolute; + inset: 0; + /* 壳在缩小时裁掉溢出 —— beUI 的 DynamicIsland 也是 overflow-hidden */ + overflow: hidden; + border-radius: inherit; +} + +/* 胶囊层与内容槽都铺满整个壳,靠 data-shape 切换谁在场 */ +.island-pill, +.island-slot-content { + position: absolute; + inset: 0; + display: flex; +} + +.island-pill { + align-items: center; + gap: calc(var(--island-font) * 0.62); + padding: 0 calc(var(--island-font) * 0.78); + transform-origin: top center; + /* 退场与 beUI 的 Slot 同参数:0.08s、ease-out、先走且不带模糊 */ + transition: + opacity 0.08s var(--ease-out), + transform 0.08s var(--ease-out); +} + +.island[data-shape='card'] .island-pill { + opacity: 0; + transform: translateY(-6px) scale(0.9); + pointer-events: none; +} + +.pill-icon { + width: calc(var(--island-font) * 1.24); + height: calc(var(--island-font) * 1.24); + flex-shrink: 0; + color: var(--state-color); +} + +.pill-icon svg { + width: 100%; + height: 100%; + display: block; +} + +/* 语义化图标动效:铃铛从铰链(上边中点)上摆一下,0.28s */ +.pill-icon[data-swing='true'] svg, +.card-icon-swing svg { + transform-origin: top center; + animation: bell-swing 0.28s var(--ease-out); +} + +@keyframes bell-swing { + 0% { + transform: rotate(0deg); + } + 28% { + transform: rotate(12deg); + } + 55% { + transform: rotate(-8deg); + } + 78% { + transform: rotate(4deg); + } + 100% { + transform: rotate(0deg); + } +} + +.pill-title, +.pill-sub { + /* action-swap 的 blur 变体:0.2s、ease-in-out、scale 0.94 + 模糊 8px */ + transition: + opacity 0.2s ease-in-out, + transform 0.2s ease-in-out, + filter 0.2s ease-in-out; +} + +.pill-title { + font-size: calc(var(--island-font) * 0.92); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pill-sub { + margin-left: auto; + font-size: calc(var(--island-font) * 0.74); + color: rgba(255, 255, 255, 0.58); + white-space: nowrap; +} + +/* 先摆到起点、再在下一帧去掉这个类 —— 浏览器才会把过渡跑起来 */ +.pill-title.is-swapping, +.pill-sub.is-swapping { + animation: swap-in 0.2s ease-in-out; +} + +@keyframes swap-in { + from { + opacity: 0; + transform: scale(0.94); + filter: blur(8px); + } + to { + opacity: 1; + transform: scale(1); + filter: blur(0); + } +} + +.island-slot-content { + flex-direction: column; + padding: calc(var(--island-font) * 1.4) calc(var(--island-font) * 1.6); + gap: calc(var(--island-font) * 0.7); + /* 内容从胶囊那条线向下展开,被收回时也往那里去 */ + transform-origin: top center; + will-change: transform, opacity, filter; +} + +.card-head { + display: flex; + align-items: center; + gap: calc(var(--island-font) * 0.55); +} + +.card-icon { + width: calc(var(--island-font) * 1.62); + height: calc(var(--island-font) * 1.62); + border-radius: calc(var(--island-font) * 0.5); + display: grid; + place-items: center; + flex-shrink: 0; + background: color-mix(in srgb, var(--state-color) 20%, transparent); + color: var(--state-color); +} + +.card-icon svg { + width: calc(var(--island-font) * 1.05); + height: calc(var(--island-font) * 1.05); +} + +.card-badge { + height: calc(var(--island-font) * 1.25); + display: inline-flex; + align-items: center; + padding: 0 calc(var(--island-font) * 0.5); + border-radius: calc(var(--island-font) * 0.36); + font-size: calc(var(--island-font) * 0.68); + font-weight: 500; + background: color-mix(in srgb, var(--state-color) 22%, transparent); + color: var(--state-color); + white-space: nowrap; +} + +.card-meta { + font-size: calc(var(--island-font) * 0.68); + color: rgba(255, 255, 255, 0.58); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-collapse { + margin-left: auto; + width: calc(var(--island-font) * 1.5); + height: calc(var(--island-font) * 1.5); + border-radius: 50%; + display: grid; + place-items: center; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.72); + flex-shrink: 0; +} + +.card-collapse svg { + width: calc(var(--island-font) * 0.76); + height: calc(var(--island-font) * 0.76); +} + +.card-title { + font-size: calc(var(--island-font) * 1.08); + font-weight: 600; + line-height: 1.5; +} + +.card-body { + font-size: calc(var(--island-font) * 0.95); + color: rgba(255, 255, 255, 0.72); + line-height: 1.62; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.card-foot { + display: flex; + align-items: center; + gap: calc(var(--island-font) * 0.5); + padding-top: calc(var(--island-font) * 0.9); + border-top: 1px solid rgba(255, 255, 255, 0.09); +} + +.card-more { + font-size: calc(var(--island-font) * 0.76); + color: rgba(255, 255, 255, 0.58); + margin-right: auto; +} + +.card-btn { + height: calc(var(--island-font) * 2.1); + display: inline-flex; + align-items: center; + padding: 0 calc(var(--island-font) * 0.86); + border-radius: calc(var(--island-font) * 0.5); + font-size: calc(var(--island-font) * 0.76); + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.92); + white-space: nowrap; +} + +.card-btn-primary { + background: color-mix(in srgb, var(--state-color) 78%, transparent); + color: #fff; +} + +/* ════════════════════════════════ 链路 ═════════════════════════════════ */ + +.pipeline { + list-style: none; + padding: 0; + margin-top: 48px; + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 0; +} + +.pipeline li { + position: relative; + padding: 26px 18px 0 0; + border-top: 2px solid var(--board-line); +} + +.pipeline li + li { + padding-left: 20px; +} + +/* 头上的圆点,表示这是一条真的有先后顺序的流程 */ +.pipeline li::before { + content: ''; + position: absolute; + top: -5px; + left: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--board); + border: 2px solid var(--board-line); +} + +.pipeline li:first-child::before { + border-color: var(--blue); + background: var(--blue); +} + +.pipe-step { + font-family: var(--display); + font-size: 1.12rem; + margin-bottom: 8px; +} + +.pipe-body { + font-size: var(--fs-sm); + line-height: 1.85; + color: var(--chalk-2); +} + +.pipe-body code { + background: rgba(232, 238, 233, 0.08); + padding: 1px 5px; + border-radius: 3px; +} + +/* ════════════════════════════════ 时间轴 ═══════════════════════════════ */ + +.policy { + margin-top: 44px; + border: 1px solid var(--board-line); + border-radius: 8px; + padding: clamp(20px, 3vw, 34px); + background: var(--board-deep); +} + +.policy-clock { + display: flex; + align-items: baseline; + gap: 18px; + flex-wrap: wrap; + margin-bottom: 26px; +} + +.policy-time { + font-size: clamp(2rem, 4.4vw, 3rem); + font-weight: 600; + line-height: 1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.policy-state { + font-size: var(--fs-sm); + color: var(--chalk); + font-weight: 500; +} + +.day { + position: relative; + padding-bottom: 26px; +} + +.day-track { + position: relative; + height: 40px; + border-radius: 4px; + background: rgba(232, 238, 233, 0.05); + overflow: hidden; +} + +/* 已经过去的那一段(beUI RangeSlider 的进度填充:内缩、圆角、低对比) */ +.day-fill { + position: absolute; + top: 2px; + bottom: 2px; + left: 2px; + width: 0; + border-radius: 3px; + background: rgba(232, 238, 233, 0.07); + pointer-events: none; +} + +.day-block { + position: absolute; + top: 0; + bottom: 0; + background: rgba(232, 238, 233, 0.04); + border-left: 1px solid var(--board-deep); + border-right: 1px solid var(--board-deep); +} + +/* 上课的节次填实、课间留白 —— 一眼看出这段在不在课上 */ +.day-block.is-class { + background: rgba(232, 238, 233, 0.2); +} + +.day-block-label { + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-size: var(--fs-xs); + color: var(--chalk-3); + overflow: hidden; + white-space: nowrap; +} + +.day-block.is-class .day-block-label { + color: rgba(232, 238, 233, 0.78); +} + +/* 把手 —— beUI RangeSlider 的 thumb:一根竖圆条,拖动时纵向拉伸 1.35 倍。 + 位置由 SPRING_GLIDE 驱动(跟手、临界阻尼、不会在两端回弹),拉伸由 SPRING_BOUNCY 驱动。 */ +.day-marker { + position: absolute; + top: 50%; + margin-top: -13px; + margin-left: -2px; + width: 4px; + height: 26px; + border-radius: 999px; + background: var(--chalk); + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.45); + transform: scaleY(var(--thumb-stretch, 1)); + transform-origin: center; + pointer-events: none; + will-change: transform, left; +} + +/* 每个节次的边界一个刻度点 */ +.day-tick { + position: absolute; + bottom: 4px; + width: 3px; + height: 3px; + margin-left: -1.5px; + border-radius: 50%; + background: rgba(232, 238, 233, 0.34); + pointer-events: none; +} + +.day-input { + position: absolute; + left: 0; + right: 0; + top: 0; + width: 100%; + height: 40px; + margin: 0; + opacity: 0; + cursor: ew-resize; + -webkit-appearance: none; + appearance: none; + background: transparent; +} + +.day-input::-webkit-slider-thumb { + -webkit-appearance: none; + width: 28px; + height: 40px; + cursor: ew-resize; +} + +.day-input::-moz-range-thumb { + width: 28px; + height: 40px; + border: 0; + cursor: ew-resize; +} + +/* 拖动条本身是透明的(视觉全交给轨道),所以键盘焦点要画在轨道上,否则看不见 */ +.day:focus-within .day-track { + outline: 2px solid var(--blue); + outline-offset: 3px; +} + +.day-scale { + position: absolute; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: space-between; + font-size: var(--fs-xs); + color: var(--chalk-3); + font-variant-numeric: tabular-nums; +} + +.outcomes { + margin-top: 30px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1px; + background: var(--board-line-soft); + border: 1px solid var(--board-line-soft); + border-radius: 6px; + overflow: hidden; +} + +.outcome { + background: var(--board-deep); + padding: 18px; +} + +.outcome-what { + font-size: var(--fs-xs); + color: var(--chalk-3); +} + +.outcome-where { + font-family: var(--display); + font-size: 1.05rem; + margin: 4px 0 8px; +} + +.outcome-say { + font-size: var(--fs-sm); + line-height: 1.8; + color: var(--chalk-2); +} + +.outcome-alert .outcome-say { + color: var(--chalk-red); +} + +.policy-note { + margin-top: 26px; + padding-top: 22px; + border-top: 1px solid var(--board-line-soft); + font-size: var(--fs-sm); + line-height: 1.9; + color: var(--chalk-2); + max-width: 62em; +} + +/* ════════════════════════════════ 表格 ═════════════════════════════════ */ + +.table-scroll { + margin-top: 36px; + overflow-x: auto; +} + +.sheet { + width: 100%; + min-width: 620px; + border-collapse: collapse; + font-size: var(--fs-sm); + background: var(--paper-raise); + border: 1px solid var(--paper-rule); + border-radius: 4px; +} + +.sheet th, +.sheet td { + text-align: left; + padding: 15px 18px; + border-bottom: 1px solid var(--paper-rule-soft); + vertical-align: top; + line-height: 1.75; +} + +.sheet thead th { + font-weight: 600; + color: var(--ink-2); + border-bottom: 1px solid var(--paper-rule); + white-space: nowrap; +} + +.sheet tbody th { + font-weight: 600; + white-space: nowrap; +} + +.sheet tbody tr:last-child th, +.sheet tbody tr:last-child td { + border-bottom: 0; +} + +.sheet td:first-of-type { + color: var(--ink-2); +} + +/* ════════════════════════════════ 实机截图 ═════════════════════════════ */ + +.shots { + margin-top: 40px; +} + +/* 分段控件 —— beUI Tabs:一个 layoutId 指示器在选项之间滑移(位置由 SPRING_LAYOUT 驱动) */ +.shots-tabs { + position: relative; + display: inline-flex; + gap: 4px; + padding: 4px; + border: 1px solid var(--board-line); + border-radius: 12px; + background: var(--board-deep); + max-width: 100%; + overflow-x: auto; + scrollbar-width: none; +} + +.shots-tabs::-webkit-scrollbar { + display: none; +} + +.shots-tab { + position: relative; + z-index: 1; + font: inherit; + font-size: var(--fs-sm); + color: var(--chalk-2); + background: transparent; + border: 0; + border-radius: 9px; + padding: 8px 16px; + cursor: pointer; + white-space: nowrap; + transition: color 0.18s var(--ease-out); +} + +.shots-tab:hover { + color: var(--chalk); +} + +.shots-tab[aria-selected='true'] { + color: var(--chalk); +} + +.shots-indicator { + position: absolute; + top: 4px; + left: 0; + height: calc(100% - 8px); + width: 0; + border-radius: 9px; + background: var(--board-raise); + pointer-events: none; +} + +.shots-grid { + margin-top: 26px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 22px; +} + +.shots-grid:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 6px; +} + +.shot-card { + display: flex; + flex-direction: column; + gap: 10px; + font: inherit; + text-align: left; + color: inherit; + background: transparent; + border: 0; + padding: 0; + cursor: zoom-in; + transform-origin: center; +} + +.shot-frame { + display: grid; + place-items: center; + aspect-ratio: 16 / 9; + padding: 0; + border: 1px solid var(--board-line); + border-radius: 8px; + background: #0d1512; + overflow: hidden; + transition: border-color 0.18s var(--ease-out); +} + +.shot-card:hover .shot-frame { + border-color: var(--chalk-3); +} + +.shot-frame img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: top center; + display: block; +} + +/* 灵动岛的图是贴着岛体裁下来的小图,原尺寸居中摆,不放大也不裁 */ +.shot-card[data-kind='island'] .shot-frame { + aspect-ratio: 16 / 9; + padding: 16px; +} + +.shot-card[data-kind='island'] .shot-frame img { + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +/* 手机端是竖图,给个竖版的框 */ +.shot-card[data-kind='phone'] .shot-frame { + aspect-ratio: 4 / 5; + padding: 12px; +} + +.shot-card[data-kind='phone'] .shot-frame img { + width: auto; + height: 100%; + object-fit: contain; + border-radius: 6px; +} + +.shot-cap { + font-size: var(--fs-xs); + line-height: 1.75; + color: var(--chalk-2); +} + +.shots-hint { + margin-top: 22px; + font-size: var(--fs-xs); + color: var(--chalk-3); +} + +/* ════════════════════════════ 大图浮层 ════════════════════════════ + 从被点的那张缩略图原地长到大图(beUI 的 morphing lightbox 是共享 layoutId, + 这里用 FLIP 做等价的事:面板先摆到缩略图矩形,再用弹簧走到目标矩形)。 */ + +.lightbox { + position: fixed; + inset: 0; + z-index: 60; +} + +.lightbox[hidden] { + display: none; +} + +.lightbox-backdrop { + position: absolute; + inset: 0; + background: rgba(6, 10, 9, 0.88); + backdrop-filter: blur(6px); + opacity: 0; +} + +.lightbox-panel { + position: fixed; + left: 0; + top: 0; + overflow: hidden; + border-radius: 8px; + background: #0d1512; + box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.8); + will-change: transform, width, height; +} + +.lightbox-img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} + +.lightbox-cap { + position: fixed; + left: 50%; + bottom: 26px; + transform: translateX(-50%); + max-width: min(720px, 88vw); + text-align: center; + font-size: var(--fs-xs); + line-height: 1.7; + color: var(--chalk-2); + opacity: 0; +} + +.lightbox-close { + position: fixed; + top: 20px; + right: 20px; + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 999px; + border: 1px solid var(--board-line); + background: rgba(232, 238, 233, 0.06); + color: var(--chalk); + cursor: pointer; + opacity: 0; + transition: background-color 0.18s var(--ease-out); +} + +.lightbox-close:hover { + background: rgba(232, 238, 233, 0.14); +} + +.lightbox-close svg { + width: 18px; + height: 18px; +} + +/* ════════════════════════════════ 功能清单 ═════════════════════════════ */ + +.feature-groups { + margin-top: 40px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: clamp(32px, 5vw, 64px); +} + +/* ── 特性块 ────────────────────────────────────────────────────────────────── + 照 ClassIsland 官网的特性写法:#彩色标签 → 粗体短标题 → 一句说明。 + 「#」那行是它的招牌,先用蓝色承担(这一页的蓝只表示「这是产品里真有的东西」)。 */ + +.feature-blocks { + display: flex; + flex-direction: column; + gap: 26px; + margin-top: 4px; +} + +.feature-tag { + font-size: var(--fs-xs); + font-weight: 600; + letter-spacing: 0.02em; + color: var(--blue-deep); + margin-bottom: 2px; +} + +.feature-title { + font-size: clamp(1.1rem, 1.9vw, 1.4rem); + font-weight: 700; + line-height: 1.45; + color: var(--ink); + margin-bottom: 6px; +} + +.feature-body { + font-size: var(--fs-sm); + line-height: 1.85; + color: var(--ink-2); + opacity: 0.85; + max-width: 34em; +} + +/* ════════════════════════════════ 命令块 ═══════════════════════════════ */ + +.snippet { + margin-top: 40px; + border: 1px solid rgba(232, 238, 233, 0.12); + border-radius: 6px; + background: var(--board-deep); + overflow: hidden; +} + +.snippet-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 16px; + border-bottom: 1px solid rgba(232, 238, 233, 0.08); +} + +.snippet-label { + font-size: var(--fs-xs); + color: var(--chalk-2); +} + +.copy { + font: inherit; + font-size: var(--fs-xs); + color: var(--chalk-2); + background: transparent; + border: 1px solid var(--board-line); + border-radius: 3px; + padding: 5px 12px; + cursor: pointer; + overflow: hidden; + transition: + color 0.16s var(--ease-out), + border-color 0.16s var(--ease-out); +} + +/* action-swap 的 roll 变体:文案整块从下方滚上来,容器裁掉溢出 */ +.copy-label { + display: inline-block; + white-space: nowrap; + will-change: transform, opacity, filter; +} + +.copy:hover { + color: var(--chalk); + border-color: var(--chalk-3); +} + +.copy[data-done='1'] { + color: #4ac07a; + border-color: rgba(74, 192, 122, 0.4); +} + +.snippet pre { + margin: 0; + padding: 18px 16px; + font-family: var(--mono); + font-size: 0.82rem; + line-height: 1.85; + color: var(--chalk); + tab-size: 2; + /* 折行而不是横向滚动:这条命令是要被抄走的,看全比保持一行更重要 */ + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.snippet code { + font-size: inherit; + background: none; + padding: 0; +} + +.band-paper .snippet { + border-color: #1d2e27; +} + +/* ════════════════════════════════ 验收账目 ═════════════════════════════ */ + +.ledger { + margin-top: 44px; + border-top: 1px solid var(--board-line); +} + +.ledger-row { + display: grid; + grid-template-columns: minmax(240px, 300px) 1fr auto; + align-items: baseline; + gap: 20px; + padding: 16px 0; + border-bottom: 1px solid var(--board-line-soft); +} + +.ledger-cmd { + font-size: 0.85rem; + color: var(--chalk); + background: rgba(232, 238, 233, 0.06); + padding: 3px 8px; + border-radius: 3px; + justify-self: start; + white-space: nowrap; +} + +.ledger-what { + font-size: var(--fs-sm); + line-height: 1.8; + color: var(--chalk-2); +} + +.ledger-num { + font-size: 1.25rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--chalk); +} + +.ledger-num::after { + content: ' 项'; + font-size: 0.72rem; + font-weight: 400; + color: var(--chalk-3); +} + +/* 数字滚动 —— beUI NumberTicker:每一位是一列 0–9 的竖排,靠 translateY 滚到目标位, + 进场时逐位错开(0.04s),滚动本身 0.9s EASE_OUT,滚动期间带一点模糊。 + 静息位置 = 目标位,所以没轮到滚动时显示的也是正确的数字。 */ +.ticker { + display: inline-flex; + align-items: center; +} + +.ticker-digit { + position: relative; + display: inline-block; + width: 1ch; + height: 1.1em; + overflow: hidden; +} + +.ticker-roll { + position: absolute; + left: 0; + right: 0; + top: 0; + display: flex; + flex-direction: column; + align-items: center; + filter: blur(0); + transform: translateY(var(--roll-to, 0em)); + transition: + transform 0.9s var(--ease-out) var(--roll-delay, 0s), + filter 0.32s var(--ease-out) var(--roll-delay, 0s); + will-change: transform, filter; +} + +.ticker-roll > span { + display: flex; + height: 1.1em; + align-items: center; + justify-content: center; + line-height: 1; +} + +.ticker-digit.is-arming .ticker-roll { + transform: translateY(0); + filter: blur(10px); + transition: none; +} + +.ledger-foot { + margin-top: 20px; + font-size: var(--fs-xs); + line-height: 1.85; + color: var(--chalk-3); + max-width: 44em; +} + +/* ════════════════════════════════ 页脚 ═════════════════════════════════ */ + +.foot { + background: var(--board-deep); + padding-block: 46px; +} + +.foot-inner { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 32px; + flex-wrap: wrap; +} + +.foot-brand { + display: flex; + align-items: center; + gap: 14px; +} + +.foot-brand img { + border-radius: 9px; +} + +.foot-name { + font-family: var(--display); + font-size: 1.2rem; +} + +.foot-name span { + font-size: 0.8rem; + color: var(--chalk-3); + margin-left: 6px; +} + +.foot-ver { + font-size: var(--fs-xs); + color: var(--chalk-3); + margin-top: 2px; +} + +.foot-nav { + display: flex; + gap: 26px; + flex-wrap: wrap; + font-size: var(--fs-sm); +} + +.foot-nav a { + color: var(--chalk-2); + text-decoration: none; + border-bottom: 1px solid transparent; + padding-bottom: 2px; +} + +.foot-nav a:hover { + color: var(--chalk); + border-bottom-color: var(--chalk-3); +} + +/* ════════════════════════════════ 窄屏 ═════════════════════════════════ */ + +@media (max-width: 1000px) { + .pipeline { + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 26px 0; + } + + .pipeline li { + padding-right: 24px; + } +} + +@media (max-width: 860px) { + .topnav { + display: none; + } + + .btn-quiet { + margin-left: auto; + } + + .hero-foot { + align-items: flex-start; + } + + .ledger-row { + grid-template-columns: 1fr auto; + gap: 8px 16px; + } + + .ledger-cmd { + grid-column: 1 / -1; + } +} + +@media (max-width: 560px) { + :root { + --gutter: 18px; + } + + /* 窄屏上让标题自己折行,硬换行会留下一个孤零零的字 */ + .display br { + display: none; + } + + .brand-ver { + display: none; + } + + .screen-stage { + padding-bottom: 22px; + } + + .screen-body { + gap: 18px; + } + + .clock-now { + border-left-width: 2px; + padding-left: 14px; + } + + .clock-next { + margin-left: 0; + text-align: left; + padding-left: 14px; + } + + .actions .btn { + flex: 1 1 auto; + } + + .policy { + padding: 18px 14px; + } + + .outcomes { + grid-template-columns: 1fr; + } +} + +/* ════════════════════════════════ 少动效 ═══════════════════════════════ + beUI motion guide 的口径:少动效是一个设计过的状态,不是把动效全删掉 —— + 保留透明度与颜色反馈(有信息量),去掉位移、缩放、模糊与回弹。 */ + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-property: opacity, color, background-color, border-color !important; + transition-duration: 0.2s !important; + transition-delay: 0s !important; + } +} diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..44effb3 --- /dev/null +++ b/website/index.html @@ -0,0 +1,681 @@ + + + + + + 班级小助手 Class Helper — 知道现在是第几节课的班级信息系统 + + + + + + + + + + + + + + + + + + + +
+ + + +
+ +
+ +
+
+
+

+ 它知道现在是第几节课, + 所以不会在课上打扰学生。 +

+ +

课表、作业、通知、成绩、叫人

+ +

+ 一个后端全管,老师在电脑或手机上发布,教室大屏和每台学生机同时收到; + 断网时用本地缓存接着看,网络恢复自动补齐。 +

+ + +
+ + + +

+ 上面这个灵动岛不是截图 —— 圆角路径与尺寸都是产品的(胶囊 268×44、展开 424×230、连续圆角 4.2 + 次方), 形变用的是 beUI 的 Dynamic Island 那套弹簧:壳 0.8 秒、回弹 0.2,内容层回弹 0.35。 +

+ +
    +
  • 装着 SQLite 就能跑,需要时一键换到 MySQL —— 数据也跟着搬过去。
  • +
  • 课表由教室的 ClassIsland 上报,不用在小助手这边再排一遍。
  • +
  • 教室把通知交给哪个端,由教室那台机器自己选:客户端、大屏,或者两边都弹。
  • +
+
+
+ + +
+
+ +

它是怎么工作的

+
+
+ +
+
+

从按下「发布」到教室屏幕上弹出来

+

+ 中间只隔着一条 WebSocket 广播。实测从老师提交到教室机器收到,是 30 到 50 毫秒 —— 这个数字由 + pnpm verify:e2e + 每次跑的时候重新量,不是写在文档里的估计值。 +

+ +
    +
  1. +

    老师发布

    +

    Web 管理端或手机浏览器。通知、作业、叫人、成绩都在这里录。

    +
  2. +
  3. +

    后端落库

    +

    Express 5 + Prisma 7 写进库,再按班级房间广播一条事件。

    +
  4. +
  5. +

    教室收到

    +

    + Socket.IO 推到 + class:班级 + 房间 —— 教室机器、学生机、大屏同时收到。 +

    +
  6. +
  7. +

    判一次时间

    +

    课表服务回答「现在上不上课」,当场弹还是先收着,这一步就定下来了。

    +
  8. +
  9. +

    两端弹出

    +

    灵动岛展开成卡片,ClassIsland 同时全屏提醒,可以语音朗读。

    +
  10. +
+
+
+ + +
+
+

上课的时候,通知会先排队

+

+ 判定来自课表本身,不是按时钟拍脑袋。把下面这条时间轴拨一下,看看同样的通知在课上和课间分别会怎样。 +

+ +
+
+

09:20

+

正在上 第三节 · 数学

+
+ +
+ + + + +
+ +
+
+

普通通知

+

学生机 · 灵动岛

+

先收着,打下课铃自动弹出来

+
+
+

普通通知

+

教室大屏 · ClassIsland

+

先收着,下课补弹

+
+
+

紧急通知 / 叫人

+

教室大屏 · ClassIsland

+

立刻全屏弹出,不打断也拦不住

+
+
+

不管什么时候

+

通知中心

+

照常记一条,未读红点随时可查,不会被吞掉

+
+
+ +

+ 普通叫人(默认级别)上课时只进队列,下课后自动弹出详情;标成紧急的才当场打断课堂 —— + 那是老师正在等学生。老师在 Web 端发紧急通知时会先撞上全屏二次确认,确认按钮带 3 秒倒计时, + 防止手滑。这一层拦在服务端,绕过界面直接调接口也一样会被拒。 +

+
+
+
+ + +
+
+ +

它是怎么构成的

+
+
+ +
+
+

一份代码,四个交付物

+

+ 一个 pnpm monorepo。三端共用同一份类型与权限矩阵,所以不会出现「Web 端说有、客户端说没有」。 +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
四个交付物、它们的形态与使用者
交付物形态谁在用
后端服务 + Express 5 + Prisma 7 + Socket.IO,接口前缀 + /api + 其它三端
Web 管理端Vue 3 + Element Plus,手机可用,可在浏览器里装成应用老师、管理员
桌面客户端Electron + Vue 3,带灵动岛与系统托盘,断网可用教室机器、学生机
ClassIsland 联动插件.NET 8 插件,装进教室已有的 ClassIsland 里教室大屏
+
+
+
+ + +
+
+

每个界面都是跑起来截的

+

+ 下面没有一张是画的示意。这些图是脚本真的把后端、Web 管理端、教室客户端和灵动岛跑起来之后 + 一屏一屏截下来的 —— 数据也是库里真实的数据。 +

+ +
+
+ +
+
+
+ +

点任意一张看大图。

+
+
+ + + + + +
+
+

老师那边,教室这边

+ +
+
+

老师与管理员

+
+
+

#班级人员

+

建班、换班主任、录学生

+

+ 名单可以直接导入表格。教师录入与重置密码只对管理员开放,班主任也不行。 +

+
+
+

#课表

+

按周排课,支持单双周

+

+ 也可以直接导入 ClassIsland 的档案 JSON —— 节次时间跟着课表一起进来,不用排两遍。 +

+
+
+

#作业

+

按「所属日期」归类

+

+ 晚上八点留的作业算今天。教室机器也能录;未交名单勾一下,其余同学自动标成已完成。 +

+
+
+

#通知与叫人

+

分优先级发布,点名分两档

+

+ 普通叫人上课时先收着,紧急的才当场打断课堂 —— 那是老师正在等学生。 +

+
+
+

#成绩

+

单条、批量,或者整张表导进来

+

+ 重复判定按「同班 + 同学生 + 同考试 + 同课程」,导入报错精确到 Excel 行号,改完再导。 +

+
+
+

#数据库

+

备份、导入导出、一键换库

+

+ SQLite 与 MySQL 互相搬数据,中途失败会自动回滚 —— 现有的库不会被动到。 +

+
+
+
+ +
+

教室机器与学生机

+
+
+

#课表时间轴

+

大时钟、正在上的课、距下课倒计时

+

已结束的置灰,正在上的带进度条,下一节还有多久开始 —— 一眼看完。

+
+
+

#作业看板

+

按科目分卡,整屏缩放到刚好放下

+

+ 标题和要求全文不截断,也不用上下翻页;全屏看板会自己算最合适的列数。 +

+
+
+

#灵动岛

+

消息到达的第一现场

+

+ 无边框透明置顶的小窗,通知、作业、叫人在里面展开成卡片;鼠标穿透,不占任务栏。 +

+
+
+

#离线缓存

+

断网的时候接着看

+

+ 课表、作业、通知、成绩先落本地,断网顶部提示离线、照旧显示,网络恢复自己刷新。 +

+
+
+

#外观

+

深浅两套,岛也能整

+

+ 高度、宽度、圆角、字号、停靠位置、视觉风格都能调;越界的值在主进程统一夹紧。 +

+
+
+
+
+
+
+ + +
+
+ +

它是怎么到你手里的

+
+
+ +
+
+

四种装法,按场景挑

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
四种部署形态与各自入口
场景形态入口
教师电脑、学校机房单机Windows 服务端安装程序,内置 Node 运行时双击安装,自动建库建号、开机自启
云服务器、多终端共享Docker Compose,MySQL 与 SQLite 两种编排都在仓库里deploy/docker-compose.yml
已有 Linux 服务器 + 一行命令装成 systemd 服务,附带 + classhelper + 运维命令 + 见下面的命令
已有 Node 环境免安装目录,自带 node.exe + pnpm dist:server + 的产物 +
+
+ +
+
+ Linux 服务器上跑这一条 + +
+
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 + ,再装成 systemd 服务。 想先不改动系统,把末尾换成 + --check + 就只体检。装完之后, + classhelper upgrade + 、 + backup + 、 + doctor + 、 + password + 这些命令都在 + /usr/local/bin + 里等着。 +

+ +

+ 学生机这边是安装包和单文件版两种:安装包走 NSIS,单文件版不用装、放 U 盘里就能分发。 + 两个都不写死服务器地址 —— 第一次打开时填教室的地址和班级码就行。 +

+
+
+ + +
+
+

每个功能都配了一条能复跑的用例

+

+ 仓库里带着这几套脚本,跑到哪儿、过了几条由脚本自己数,不是文档里写死的数字。 交付前它们必须全绿 —— + 包括对 + 已经打包装好 + 的那份客户端再跑一遍。 +

+ +
+
+ pnpm verify:e2e + + 接口、实时推送、权限越权、上课时段、表格导入、班级账号、数据库管理、更新检查 + + 190 +
+
+ pnpm verify:desktop + 客户端冒烟:登录、离线回退、灵动岛开合与命中、设置页真实点击 + 105 +
+
+ pnpm verify:packaged + 对打包后 / 已安装的那份 EXE 复跑同一套冒烟 + 105 +
+
+ pnpm verify:classisland + 设备令牌、课表上报、提醒下发与回执、镜像契约、幽灵行清理 + 42 +
+
+ pnpm verify:classisland-plugin + 插件清单、注册完整性、C# DTO 与服务端校验逐字段对齐 + 68 +
+
+ +

+ 上面是 2026-10-05 那一次的结果。这几套脚本每天都在跑,数字会变;跑到多少条,以脚本自己打印的为准。 +

+
+
+ + +
+
+ +

剩下的,留给你自己试

+

+ 一台机器装完就能跑起来,学生机和教室大屏接上同一个地址就行。装好之后还有什么, 都在界面里 —— + 不用先读一遍文档。 +

+ +
+
+
+ + + + + + + + diff --git a/website/serve.mjs b/website/serve.mjs new file mode 100644 index 0000000..bfbe9d7 --- /dev/null +++ b/website/serve.mjs @@ -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}`); +}); diff --git a/website/tools/capture-shots.mjs b/website/tools/capture-shots.mjs new file mode 100644 index 0000000..a8b8f80 --- /dev/null +++ b/website/tools/capture-shots.mjs @@ -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); + } +}