4 Commits
Author SHA1 Message Date
LoveChengke 580f5bed16 fix(dist): 交叉构建 Linux 包时 tar 的盘符冒号坑(转正斜杠 + --force-local)
在 Windows 上跑 pnpm dist:server:linux 时,packLinuxTarball 传给 tar 的归档路径带盘符,GNU tar 会把 F: 当成主机名去解析远程归档,报 resolve failed 并以退出码 2 失败。现象很误导:包已经整套铺好,只差最后一步打 tar。反斜杠还会被当转义符。

改成路径统一转正斜杠并加 --force-local。该选项在 Linux 上无害(本地路径本来就该走本地分支),因此无条件加上、不做平台分支 —— Linux 上路径没有盘符,这个坑在 CI 与真机上永远复现不了,只在交叉构建时现形。已记入 AGENTS.md 第 5 节第 59 条与 README 的交付验收表。
2026-10-06 15:52:05 +08:00
LoveChengke ce9bed73f4 feat(shared,web-admin,desktop-client,build): beUI 动效层与两端深色主题,全项目图标改用设计导出的新图标
动效层:把 beUI 的动效语言移植到两端,令牌放在 packages/shared/src/motion.ts(纯常量,后端也引用得到)。弹簧用数值积分生成 CSS linear(),纯 CSS 即可驱动,不必每组件一个 rAF 循环;两端各一份 composables/motion.ts 负责 DOM 部分,在 app.mount() 之前注入 :root。含 reduced-motion 全局降级与 hover 能力探测;灵动岛只移植单调曲线。

深色主题:Web 管理端补齐,机制与桌面客户端一致;语义色令牌只覆盖颜色,不动圆角与动效令牌。默认跟随系统,用户手动点过之后以用户为准。

图标:build/classhelper.{png,ico} 成为全项目唯一图形源,pnpm icons 从它派生各端图标与界面内品牌标。版本号统一到 1.1.2。
2026-10-06 15:32:01 +08:00
LoveChengke 82c85b59c1 feat(dist,deploy,website): 交付产物统一归集到 releases/<组件>/<版本>,补齐官网与 Linux 验收
三个打包脚本原先各写各的输出目录,现在一律直接落在仓库根 releases/<组件>/<版本>/,分层固定为安装包/免安装/构建中间;dist-win 用 --config.directories.output 覆盖 electron-builder 的目录后再整理,显式传入时不覆盖。插件侧顺手修掉一个既有 bug:原先 rmSync 整个组件目录,重打一次就把上一版的留档抹掉,现在只清当前版本目录。verify-packaged 的 --exe 默认值跟着改到新落点。

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

website/ 是产品官网(纯静态、无构建步骤,不进 pnpm workspace),含 beUI 动效 token 与实机留档图;客户端冒烟相应加了采集留档图用的外观开关。
2026-10-06 15:31:38 +08:00
LoveChengke 7b1ae29774 feat(desktop-client,build): 换用设计导出的新图标(客户端 / 服务端安装程序 / 托盘)
build/classhelper.{ico,png} 是新的图标来源(设计导出、手工放入),不再走 `pnpm icons` 那条
SVG 渲染流水线(那条只会重写 icon.*,两套并存容易搞混):

- packages/desktop-client/build/classhelper.{ico,png}:客户端安装包与应用图标;
- packages/desktop-client/public/tray.png:托盘图标(由 classhelper.png 覆盖);
- 服务端安装程序与客户端的"接线"分别在 scripts/dist-server.mjs 的 @ICON@ 与
  packages/desktop-client/electron-builder.yml 的 win.icon —— 这两个文件当前另有未提交改动
  (另一处工作在进行),那两行会随那批改动一起入库。

说明:Web 管理端(PWA)的 favicon / icon-192 / icon-512 未动,仍是原图标。
2026-10-05 21:13:52 +08:00
103 changed files with 7966 additions and 415 deletions

No files matched your search

+11 -10
View File
@@ -9,7 +9,7 @@ name: 发布 Linux 服务端安装包
# #
# 用法: # 用法:
# 1. 先在本地按惯例打出 Windows 包并发 Release(vX.Y.Z); # 1. 先在本地按惯例打出 Windows 包并发 Release(vX.Y.Z);
# 2. 到 Actions 页面手动运行本 workflow,填 tag(例如 v1.1.0); # 2. 到 Actions 页面手动运行本 workflow,填 tag(例如 v1.1.2);
# 3. 跑完 Release 里会多出 Linux 包与更新后的 SHA256SUMS-<版本>.txt。 # 3. 跑完 Release 里会多出 Linux 包与更新后的 SHA256SUMS-<版本>.txt。
# #
# 也可以只跑构建不挂 Release(attach=false),产物在 workflow 的 Artifacts 里。 # 也可以只跑构建不挂 Release(attach=false),产物在 workflow 的 Artifacts 里。
@@ -18,7 +18,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: '要构建并挂载的 Release tag(例如 v1.1.0)' description: '要构建并挂载的 Release tag(例如 v1.1.2)'
required: true required: true
type: string type: string
attach: attach:
@@ -81,15 +81,15 @@ jobs:
run: pnpm dist:server:linux run: pnpm dist:server:linux
- name: 展示产物 - name: 展示产物
run: ls -la release-server/ run: ls -la releases/server/*/linux-x64/
- name: 上传构建产物(Artifacts) - name: 上传构建产物(Artifacts)
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: classhelper-server-linux-x64 name: classhelper-server-linux-x64
path: | path: |
release-server/classhelper-server-linux-x64-*.tar.gz releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz
release-server/classhelper-server-linux-x64-*.tar.gz.sha256 releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz.sha256
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -101,9 +101,10 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
VERSION="${TAG#v}" VERSION="${TAG#v}"
TARBALL="release-server/classhelper-server-linux-x64-${VERSION}.tar.gz" ARTIFACT_DIR="releases/server/${VERSION}/linux-x64"
TARBALL="${ARTIFACT_DIR}/classhelper-server-linux-x64-${VERSION}.tar.gz"
SHAFILE="${TARBALL}.sha256" SHAFILE="${TARBALL}.sha256"
SUMS="release-server/SHA256SUMS-${VERSION}.txt" SUMS="releases/SHA256SUMS-${VERSION}.txt"
if [ ! -f "$TARBALL" ]; then if [ ! -f "$TARBALL" ]; then
echo "::error::没有找到 $TARBALL(tag 与 package.json 的版本号对得上吗?)" echo "::error::没有找到 $TARBALL(tag 与 package.json 的版本号对得上吗?)"
@@ -112,13 +113,13 @@ jobs:
# .sha256 必须跟着一起传:它是独立发布的资产,只更新 tar.gz 会让这个文件指向旧哈希, # .sha256 必须跟着一起传:它是独立发布的资产,只更新 tar.gz 会让这个文件指向旧哈希,
# 拿它校验的人会得到 mismatch(classhelper upgrade 用的是 --sha256/这个文件,不是 SHA256SUMS)。 # 拿它校验的人会得到 mismatch(classhelper upgrade 用的是 --sha256/这个文件,不是 SHA256SUMS)。
if [ ! -f "$SHAFILE" ]; then if [ ! -f "$SHAFILE" ]; then
sha256sum "$TARBALL" | sed "s#release-server/##" > "$SHAFILE" sha256sum "$TARBALL" | sed "s#${ARTIFACT_DIR}/##" > "$SHAFILE"
fi fi
# 取回已有的校验清单(Windows 包那几行),只替换/追加本包这一行,其余内容原样保留。 # 取回已有的校验清单(Windows 包那几行),只替换/追加本包这一行,其余内容原样保留。
# 注意**不要**用 `sort -u` 去重:那份文件是人读的(表头 + 体积 + 说明),排序会把它们打散。 # 注意**不要**用 `sort -u` 去重:那份文件是人读的(表头 + 体积 + 说明),排序会把它们打散。
gh release download "$TAG" --pattern "SHA256SUMS-*.txt" --dir release-server --clobber || true gh release download "$TAG" --pattern "SHA256SUMS-*.txt" --dir releases --clobber || true
DOWNLOADED="$(ls release-server/SHA256SUMS-*.txt 2>/dev/null | head -n 1 || true)" DOWNLOADED="$(ls releases/SHA256SUMS-*.txt 2>/dev/null | head -n 1 || true)"
if [ -n "$DOWNLOADED" ] && [ "$DOWNLOADED" != "$SUMS" ]; then mv "$DOWNLOADED" "$SUMS"; fi if [ -n "$DOWNLOADED" ] && [ "$DOWNLOADED" != "$SUMS" ]; then mv "$DOWNLOADED" "$SUMS"; fi
touch "$SUMS" touch "$SUMS"
grep -v "^[0-9a-f]\{64\} classhelper-server-linux-" "$SUMS" > "${SUMS}.new" || true grep -v "^[0-9a-f]\{64\} classhelper-server-linux-" "$SUMS" > "${SUMS}.new" || true
+183 -25
View File
@@ -47,12 +47,12 @@ pnpm-workspace.yaml workspace + allowBuilds(pnpm 11 依赖构建白
tsconfig.base.json 共享 TS 基础配置(ESM + NodeNext + strict) tsconfig.base.json 共享 TS 基础配置(ESM + NodeNext + strict)
eslint.config.mjs ESLint 扁平配置(TS + Vue);ignores 含 releases/ / release-server/ / src/generated/ eslint.config.mjs ESLint 扁平配置(TS + Vue);ignores 含 releases/ / release-server/ / src/generated/
.prettierrc.json 单引号 / printWidth 110 / trailingComma all / LF / 2 空格 .prettierrc.json 单引号 / printWidth 110 / trailingComma all / LF / 2 空格
build/icon.{ico,png} 应用图标(由 pnpm icons 生成) build/classhelper.{png,ico} 图标源(**设计导出、手工放入**;`pnpm icons` 从它派生其余全部图标,见 §5 第 62 条)
scripts/ scripts/
use-database.mjs SQLite ⇄ MySQL 切换(**改写 schema.prisma 里的 provider**,没有第二份 schema) use-database.mjs SQLite ⇄ MySQL 切换(**改写 schema.prisma 里的 provider**,没有第二份 schema)
generate-icons.mjs Electron 渲染 SVG → PNG/ICO generate-icons.mjs 启动 Electron 跑 icons/render.cjs(= `pnpm icons`)
icons/render.cjs 图标渲染(CommonJS) icons/render.cjs 把 classhelper.png 重采样成各端图标(CommonJS,见 §5 第 62 条)
dist-server.mjs 服务端 + Web 管理端打包(产物落在 release-server/) dist-server.mjs 服务端 + Web 管理端打包(产物落在 releases/server/<版本>/)
nsis/server-installer.nsi 服务端 NSIS 安装脚本 nsis/server-installer.nsi 服务端 NSIS 安装脚本
verify-packaged.mjs 对**打包后/已安装**的客户端 EXE 复跑冒烟 verify-packaged.mjs 对**打包后/已安装**的客户端 EXE 复跑冒烟
check-icons.mjs 图标注册表静态门禁(漏注册会静默渲染空白,见 §5 第 53 条) check-icons.mjs 图标注册表静态门禁(漏注册会静默渲染空白,见 §5 第 53 条)
@@ -68,6 +68,15 @@ deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.c
docs/ production.md(生产部署)/ linux-deploy.md(Linux 部署与运维) docs/ production.md(生产部署)/ linux-deploy.md(Linux 部署与运维)
mysql.md(MySQL 切换)/ winisland-design-tokens.md mysql.md(MySQL 切换)/ winisland-design-tokens.md
screenshots/{island,client,web-mobile,classisland}/ screenshots/{island,client,web-mobile,classisland}/
website/ 产品官网(**纯静态、无构建步骤**,不进 pnpm workspace)
index.html / assets/{styles.css,motion.js,main.js,icon.png,shots/}
serve.mjs(本地预览)/ tools/capture-shots.mjs(实机图采集)
⚠ assets/motion.js 是动效层:曲线与弹簧 token 逐条取自 beUI
(beui.dev/components/motion,见 README §7「官网」)。
改官网动效请改 token 表,不要在组件里另写 duration / cubic-bezier。
⚠ assets/shots/ 里全是**跑起来截的**实机图,重采集步骤见 README
§7「采集实机图」;客户端那两张要配合冒烟的
ELECTRON_CLIENT_SHOTS_DIR / ELECTRON_SMOKE_ISLAND_* 开关。
packages/shared/src/ types.ts / constants.ts / permissions.ts / utils.ts / index.ts packages/shared/src/ types.ts / constants.ts / permissions.ts / utils.ts / index.ts
packages/server/ packages/server/
prisma/schema.prisma 数据模型(14 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts) prisma/schema.prisma 数据模型(14 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
@@ -96,7 +105,7 @@ packages/desktop-client/
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约 src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
scripts/ build-main / dev / smoke / dist-win scripts/ build-main / dev / smoke / dist-win
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install) packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.1.0.0 manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.1.2.0
ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0) ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
src/Models/PluginSettings.cs src/Models/PluginSettings.cs
@@ -178,7 +187,7 @@ pnpm build:classisland-plugin
| `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) | | `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) |
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) | | `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) |
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8;缓存/临时目录自动指到 `.cache/`) | | `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8;缓存/临时目录自动指到 `.cache/`) |
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/` | | `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/<版本>/` |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint | | `pnpm lint` / `pnpm lint:fix` | ESLint |
| `pnpm check:icons` | 图标注册表门禁(两端 build 已内联,这里可单独跑;见 §5 第 53 条) | | `pnpm check:icons` | 图标注册表门禁(两端 build 已内联,这里可单独跑;见 §5 第 53 条) |
@@ -197,7 +206,7 @@ pnpm build:classisland-plugin
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 68 项) | | `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 68 项) |
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 | | `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 |
| `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 | | `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 |
| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) | | `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 界面内品牌标) |
### 端口 ### 端口
@@ -209,27 +218,44 @@ pnpm build:classisland-plugin
### 交付产物归集 ### 交付产物归集
打包脚本各自输出到固定目录,**归集到仓库根 `releases/` 是本仓库的人工约定**(该目录 gitignore + eslint ignore): **三个打包脚本都直接输出到仓库根 `releases/<组件>/<版本>/`,没有中间输出目录**
(该目录 gitignore + eslint ignore),人工归集这一步已经取消:
``` ```
release-server/ pnpm dist:server 的输出(免安装目录 + NSIS 安装程序) releases/
pnpm dist:server:linux 的输出(classhelper-server-linux-x64-<版本>.tar.gz + .sha256) client/<版本>/
packages/desktop-client/release/ pnpm dist:win / dist:dir 的输出(win-unpacked + setup.exe + portable.exe) 安装包/ 班级小助手-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
releases/ 本仓库约定的归集处(把上面的产物拷进 client/ server/ classisland-plugin/) 免安装/ electron-builder 的 win-unpacked 内容
构建中间/ builder-*.yml、*.nsis.7z
server/<版本>/
安装包/ 班级小助手服务端-<版本>-x64-setup.exe
免安装/ classhelper-server 免安装目录(含 .env、node.exe、data/、logs/)
linux-x64/ classhelper-server-linux-x64-<版本>.tar.gz + .sha256 + 同内容解包目录
构建中间/ server-installer.generated.nsi
classisland-plugin/<版本>/
安装包/ ClassHelper.ClassIslandPlugin.cipx
免安装/ ClassHelper.ClassIslandPlugin/(解压即用,丢进 Plugins 目录即可加载)
checksums.md
SHA256SUMS-<版本>.txt 跨组件哈希清单(人读,由发布流程维护)
``` ```
版本号取自根 `package.json`(插件用 `manifest.yml` 的前三段,与产品版本对齐);
重新打包只清**当前版本目录**里的产物,历史版本原样保留 —— 所以同版本多次构建留下的
不同产物要自己改名归档(例如 `1.0.0-重构建20261005/`),别把上一次的覆盖掉。
```bash ```bash
# 客户端:electron-builder 支持指定输出目录(dist-win.mjs 会把额外参数透传) pnpm dist:server # 服务端 Windows(首次会跑一次 npm install;重试可加 --reuse-deps 跳过)
node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对路径>\releases\client
# 服务端:输出路径写死在 release-server/,打完自行移进 releases/server/
pnpm dist:server # 首次会跑一次 npm install;重试可加 --reuse-deps 跳过
pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④) pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④)
pnpm dist:win # 客户端:安装包 + 免安装目录;dist:dir 只出免安装目录
# ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/
pnpm dist:classisland-plugin pnpm dist:classisland-plugin
``` ```
`dist-win.mjs` 用 `--config.directories.output` 覆盖 electron-builder 自己的输出目录,打包成功后
再把原始布局整理成上面的分层(`win-unpacked/` → `免安装/`、`*.exe` → `安装包/`、`*.yml` → `构建中间/`);
显式传 `-c.directories.output=<路径>` 时不再覆盖,整理以传入路径为准。
**交付物的完整性标签修正(§7 第 7 条)也挂在这条收尾链路上,别把 `tidyArtifacts` 之后的
`normalizeIntegrityLabel(destDir)` 拆掉。**
--- ---
## 5. 架构硬约定(改代码前必须知道) ## 5. 架构硬约定(改代码前必须知道)
@@ -528,6 +554,17 @@ database / update`。
主题状态在 `stores/ui.ts`(持久化到主进程配置 `theme`),主窗口 `backgroundColor` 也按主题设置 主题状态在 `stores/ui.ts`(持久化到主进程配置 `theme`),主窗口 `backgroundColor` 也按主题设置
(深色下若仍用浅色底会"先白后黑"闪一下)。 (深色下若仍用浅色底会"先白后黑"闪一下)。
**两端都有深色主题,机制一致**(Web 管理端 2026-10-06 补齐):`html.dark` + element-plus 的
`dark/css-vars.css` + 两端各自的 `stores/ui.ts`。语义色令牌是
`--ch-surface` / `--ch-surface-alt` / `--ch-surface-sunken` / `--ch-surface-hover` 与
`--ch-text` / `-secondary` / `-muted` / `-placeholder`;**`html.dark` 只覆盖颜色,
绝不覆盖圆角与动效令牌** —— 两套主题的几何必须完全一致,否则切换时会看到布局跳一下。
Web 端还要一并同步 `<meta name="theme-color">` 与 `color-scheme`(后者管原生控件:
滚动条、日期选择器弹层 —— 只改 CSS 变量的话它们仍是浅色)。
默认**跟随系统**(`prefers-color-scheme`,系统改了就跟着变);用户手动点过之后以用户为准。
切换入口两端都在顶栏,都走 `useThemeReveal()`(见第 61 条)。
改令牌时只改值 —— **不要对样式表跑批量 sed**,理由见第 61 条末尾。
47. **数据库管理模块(`/api/database/*`,仅 ADMIN)——改它之前必须知道的四件事**: 47. **数据库管理模块(`/api/database/*`,仅 ADMIN)——改它之前必须知道的四件事**:
① **一切都建立在 `lib/snapshot.ts` 的 JSON 快照上**(备份 / 导入导出 / 跨库迁移共用一种格式, ① **一切都建立在 `lib/snapshot.ts` 的 JSON 快照上**(备份 / 导入导出 / 跨库迁移共用一种格式,
14 张表按拓扑序导出、恢复时临时关外键检查、分批 `createMany`)。恢复时**必须**先关外键检查: 14 张表按拓扑序导出、恢复时临时关外键检查、分批 `createMany`)。恢复时**必须**先关外键检查:
@@ -742,7 +779,12 @@ database / update`。
schema engine,CLI 运行期按平台扫 `@prisma/engines/schema-engine-debian-openssl-3.0.x` 这类路径, schema engine,CLI 运行期按平台扫 `@prisma/engines/schema-engine-debian-openssl-3.0.x` 这类路径,
而交叉包里只有 `schema-engine-windows.exe`。交叉构建会写入 `.cross-built` 标记, 而交叉包里只有 `schema-engine-windows.exe`。交叉构建会写入 `.cross-built` 标记,
`classhelper doctor` 据此提示(别改成静默),`upgrade` 替换程序文件时会按新包有无该标记来增删。 `classhelper doctor` 据此提示(别改成静默),`upgrade` 替换程序文件时会按新包有无该标记来增删。
正式产物仍走 CI。 正式产物仍走 CI。**交叉构建时 `tar` 还有两个 Windows 专有坑**(`packLinuxTarball()`):
`-f F:\...tar.gz` 里的**盘符冒号**会被 GNU tar 当成 `主机:路径` 的远程归档写法,报
`tar (child): Cannot connect to F: resolve failed`、退出码 2 —— 现象很误导:包已经整套铺好、
就差最后打 tar 那一步失败。反斜杠还会被当成转义符。所以那一步把路径统一转成**正斜杠**
并加 `--force-local`。该选项在 Linux 上无害(本地路径本来就该走本地分支),因此无条件加上、
不做平台分支 —— **Linux 上路径没有盘符,这个坑在 CI 与真机上永远复现不了**,只在交叉构建时现形。
⑤ **密码永远走 stdin,命令行一律不接受密码;调用服务端模块的工具必须压掉它的 stdout 噪声。** ⑤ **密码永远走 stdin,命令行一律不接受密码;调用服务端模块的工具必须压掉它的 stdout 噪声。**
`classhelper password` 用 `read -s` 优先读 `/dev/tty`、无 tty 回退 stdin(这样既能交互, `classhelper password` 用 `read -s` 优先读 `/dev/tty`、无 tty 回退 stdin(这样既能交互,
@@ -766,6 +808,118 @@ database / update`。
`STAGE_DIR` 而不是 `echo` 返回路径)这两类是实打实的 bug;`die` 要先 `trap - ERR` 再退出, `STAGE_DIR` 而不是 `echo` 返回路径)这两类是实打实的 bug;`die` 要先 `trap - ERR` 再退出,
免得 ERR 钩子再补一句误导的"第 N 行执行失败"。 免得 ERR 钩子再补一句误导的"第 N 行执行失败"。
60. **安全响应头里的 `upgrade-insecure-requests` 与 HSTS 只能在 HTTPS 请求下下发**(`middleware/security.ts`
的 `buildHelmet(httpsRequest)`,按 `req.secure` 二选一;TRUST_PROXY 开启时 `req.secure` 已含
`x-forwarded-proto` 判断)。helmet 的默认 CSP **自带 `upgrade-insecure-requests`**,而安装器的
默认形态是 `http://IP:端口` 直连(无 TLS):这条指令会把页面**子资源**请求全部改写成 https 发出去,
服务器没有 TLS 监听,全部连接失败 —— 表现为「管理端一片空白、标签页标题正常」
(2026-10-05 用户在 98.142.241.144 实测踩到;`/healthz`、登录 API 都正常,纯浏览器侧的资产全灭)。
HSTS 在明文响应里按规范会被浏览器忽略,但 CSP 指令没有这层豁免,所以两者都要跟着 https 走。
开发时一直用 localhost(浏览器视为安全上下文、不做改写),所以这个坑在本地永远不复现,
只有真机 http 直连部署才炸 —— 别因为"本地好好的"就把条件判断摘掉。
61. **动效层:令牌在 `packages/shared/src/motion.ts`,两端各一份组合式函数。**
移植自 [beUI](https://beui.dev) 的 `lib/ease.ts`(它的组件是 React,用不了;动效语言与框架无关)。
三组缓动 + 六组具名弹簧(`SPRING_PRESS/SWAP/PANEL/LAYOUT/MOUSE/GLIDE`)。
- **令牌必须是纯常量**:`packages/shared` 被后端一起消费,它的 tsconfig 是 `lib: ["ES2023"]`
(不含 DOM),所以 `motion.ts` 里不碰 `document` / `window` / vue。DOM 注入与 Vue 组合式函数
在两端各自的 `composables/motion.ts`(两端各一份,与 `ScoreBarChart.vue` 同一做法)。
- **CSS 变量在 `app.mount()` 之前由 JS 注入 `:root`**(两端 `main.ts` 调 `applyMotionTokens()`)。
CSS 里只引用 `var(--ch-spring-*)`,**不要抄数值** —— 抄了必漂。
- **弹簧靠数值积分生成 CSS `linear()`**,于是弹簧可以纯 CSS 驱动,不需要每组件一个 rAF 循环。
算落定时间**不能用包络公式 `6/(ζ·ω0)`**:它只对欠阻尼成立,过阻尼要用较慢的实极点
`ω0(ζ−√(ζ²−1))`,否则曲线会被截断在 0.87/0.95 上、元素永远差一截没到位(实测踩过)。
- **`--ch-spring-X` 与 `--ch-spring-X-dur` 必须成对用**:`linear()` 是把 [0,1] 进度重映射,
时长给短了等于把整条曲线等比压缩 —— 形状还在,但已经不是那组参数的手感了。
- `linear()` 的进度值可以 >1(过冲):只能用在不越界无害的属性上。**别用在 `opacity`**。
**四条硬约束(都对应实测过的坑)**:
- **路由层不要包 `<transition>`,尤其不要 `mode="out-in"`。** 它把"旧组件卸载、新组件挂载"推迟到
退场动画结束(90ms 起),后果有两个:① 点菜单后有一小段时间页面上还是上一页,`ui-smoke` 的多条
用例("pathname 一变就找新页面的元素")会整片判红 —— 实测一次掉 9 项;② 退场判定依赖
`animationend`,**窗口被遮挡时 Chromium 冻结 CSS 动画**(实测 `document.timeline` 1.2 秒推进 0ms),
事件永不触发 ⇒ 新页面永远不挂载,整个应用卡死在第一页。页面切换的动效改用 `.page > *` 错峰入场,
那是随组件挂载**同步**触发的。
- **Vue 的 `<transition>` 一律显式给 `:duration`。** 同上,靠 `animationend` 判断的写法在窗口不可见时
会永远停住(`UrgentClassWarning` 那块铺满全屏的遮罩尤其致命:不卸载就再也点不动)。
- **`position: fixed` 的整屏层、以及"自己量几何再据此设尺寸"的元素,不要叠位移类动画。**
`.page > *` 错峰入场排除了 `.el-overlay`(Element Plus 的 `el-dialog` 默认 `append-to-body=false`,
声明在视图里的弹窗其遮罩就是 `.page` 的直接子元素)、`.el-loading-mask`、`.board-host`
(客户端的作业看板按"刚好铺满"算 `transform: scale()`,下移 6px 就溢出屏幕)。
判定方法是 `getBoundingClientRect()` **包含** transform,而 `clientHeight` / `offsetHeight` 不含。
- **弹窗面板的动画要挂在 `.el-dialog` 上,不要挂在 `.el-overlay-dialog`(居中容器)上。**
Element Plus 的 `@opened` 按**根元素**(`.el-overlay`,只有 200ms 淡入)的动画结束触发,
而外层容器上的弹簧要跑 463ms —— 任何在 `@opened` 里量容器尺寸做自适应的弹窗都会量到
"还带着 `scale(0.97)` 的缩小版",被 `minScale` 兜住后反而超出屏幕(作业看板实测 +7px)。
挂 `.el-dialog` 还有个好处:全屏弹窗能用 `is-fullscreen` 精确豁免(全屏表面本来也不该缩放入场)。
**两条容易踩的实现细节**:
- 客户端的 `--nav-ease` **刻意保留** Material 的 `cubic-bezier(.4, 0, .2, 1)`,没有统一成 EASE_OUT。
EASE_OUT 起步极快,宽度掉到 80px 以下时动画才走了约 35%,而 `layoutChromeSelfTest` 一到读宽度
判据就往下走、立刻量图标中心,此刻内边距还在动 ⇒ 6 个分组子项偏 2.3px 判红。想换曲线得连那条断言
一起重新商量。(该断言的宽度判据已从 `≤80` 收紧到 `≤65`,即"真正收拢到稳态"再量 —— 折叠目标宽度是 64px。)
- **Vue 的 scoped `<style>` 可以引用全局 `@keyframes`**(只有同一个 scoped 块里**定义**的才会被改名)。
实测:`.login-card` 的 `animation: ch-panel-in …` 原样输出且生效。但 `mode="out-in"` 之类的
transition 类名仍在 scoped 块里写、元素也带 `data-v`,能正常命中。
- `el-aside` / `el-menu` 这些是**组件**不是原生标签:模板 ref 拿到的是组件实例,
`.querySelector` 不是函数(会被 `app.config.errorHandler` 吞成一行中文异常,界面看着毫无异样)。
用 `$el`,或直接 `document.querySelector`(客户端的主侧栏只有一个,后者更直白)。
- 降低动态效果:两端 `styles/index.css` 各有一个 `@media (prefers-reduced-motion: reduce)` 全局块,
把动画压到 1ms(不是删掉,`transition: none` 会让依赖 transitionend 的逻辑失灵)。
**加载指示旋要豁免**(改为放慢而非停)—— 停掉会让界面看起来像卡死。
**灵动岛:只移植曲线,不移植弹簧。**
岛的形变由主进程缓动窗口尺寸(`src/main/island.ts`),而冒烟断言「展开过程不震动」
「收回过程不抖动」**逐帧采样岛体几何**并要求单调 —— 任何带过冲的弹簧都会直接判红。
所以岛用 `--ch-ease-out` 这类单调曲线,壳的物理仍归它自己的 `island/spring.ts`(WinIsland 移植),
不要拿 beUI 的 `SPRING_*` 去换。
`IslandApp.vue` 是**另一个渲染进程**,不共享主窗口的 `:root`:令牌要在
`src/island/main.ts` 里**再注入一次**(`applyMotionTokens()`)。漏了的话 `var(--ch-ease-out)`
解析为空、**整条 `transition` 简写作废**,反而比改造前更差(完全没有过渡)。
另外岛的 `:hover` 规则必须关在 `@media (hover: hover) and (pointer: fine)` 里 ——
教室触摸屏的"幽灵 hover"会让点过「知道了」的按钮永远高亮。
**深色主题的语义色令牌:只改值,不要对样式表跑批量 sed。**
`--ch-text: #303133` 被"把 #303133 换成 var(--ch-text)"的批量替换一过,就变成
`--ch-text: var(--ch-text)` —— **自引用**会让整条自定义属性失效,`getPropertyValue` 返回空串,
全站文字颜色静默回落成继承色(浅色下看着"还行",深色下就露馅)。实测踩过。
62. **图标:全项目只有一个图形源 `build/classhelper.{png,ico}`,其余一律由 `pnpm icons` 派生。**
这两个文件是**设计导出、手工放入**的,`pnpm icons` 只读不写 —— 要换图标就换这两个文件,
别去改脚本。派生清单写在 `scripts/icons/render.cjs` 顶部的注释里,覆盖:Web 端 PWA 三件套
(`icon-192` / `icon-512` / `apple-touch-icon`)、`favicon`(png + svg 各一份)、
ClassIsland 插件 `icon.png`、客户端 `build/{classhelper,icon}.{png,ico}`、
托盘 `public/tray.png`、官网 `assets/icon.png`,以及**两端界面内的品牌标** `public/logo.png`。
**四条硬约束(都对应实测踩过的坑)**:
- **ICO 原样透传,不要重新生成。** `classhelper.ico` 里 9 个尺寸
(16/24/32/48/64/72/96/128/256)是设计工具**逐尺寸栅格化**的,比把那张 345×339 的位图
缩下去干净得多;脚本只解析它的目录项做日志与校验,然后把整份复制给客户端与 electron-builder。
- **`packages/desktop-client/build/icon.{png,ico}` 别删。** 它们是与 `classhelper.*` 同步的副本,
而名字是 **electron-builder 的兜底约定**:`convertIcon()` 的候选表是
`['<win.icon>', 'icon.ico', 'icons', 'icon', 'icon.png', 'icon.svg', 'icon.icns']`,
依次在 `[buildResources, projectDir]` 两个根下 stat,**第一个命中的就赢**。
`win.icon` 现在显式写着 `build/classhelper.ico`(实测 `isFallback = false`,
解析到 `packages/desktop-client/build/classhelper.ico`),但那条配置一旦被拿掉,
就会**静默**回落到 `icon.ico` —— 副本同步着,回落也还是新图标。
- **界面内的品牌标用 `<img>`,且两端 URL 的写法必须不同。** Web 端写根绝对路径 `/logo.png`
(永远从源站根提供服务;前端是 history 路由,相对路径在 `/classes/1` 这类嵌套路由下会解析错)。
客户端**必须经 `BRAND_LOGO_URL`(`src/renderer/config.ts`)绑定给 `:src`** ——
字面量的相对 `src` 会被 Vite 当成模块导入去解析
(`Rolldown failed to resolve import "logo.png" from "LoginView.vue"`,构建直接失败),
而根绝对路径在 `file://` 加载的打包形态下会解析到**盘符根目录**。
- **`School` 字形在两端 `icons.ts` 里的注册不要删**:它仍是「班级管理」菜单项与
「班级码」输入框前缀的字段图标,只是不再是品牌标。品牌标换成真图标后,
`.brand-logo` 的 `background` / `color` / `border-radius` / `overflow` 都要撤掉 ——
圆角与透明边角本来就在图里,再叠一层会出现双重圆角。
跑完 `pnpm icons` 记得把**生成出来的那些图**一起提交(脚本会比对内容,没变动的不会重写)。
源图 345×339 不是正方形,脚本按等比缩放居中补透明(补边约 1.7%,肉眼不可见);
512 那两档是**放大**,会略软 —— 那是源图分辨率的上限,要更清晰只能换更大的设计导出。
--- ---
## 6. 代码风格 ## 6. 代码风格
@@ -885,10 +1039,12 @@ database / update`。
(`packages/classisland-plugin/.gitignore` 是本仓库里唯一能正常编辑的 .gitignore。) (`packages/classisland-plugin/.gitignore` 是本仓库里唯一能正常编辑的 .gitignore。)
11. **`releases/`(复数)必须在 ESLint 忽略列表里**:本地跑过 `pnpm dist:*` 之后, 11. **`releases/`(复数)必须在 ESLint 忽略列表里**:本地跑过 `pnpm dist:*` 之后,
这个目录里是构建后的压缩 JS,不忽略的话 `pnpm lint` 会报出几千条与源码无关的 error 这个目录里是构建后的压缩 JS,不忽略的话 `pnpm lint` 会报出几千条与源码无关的 error
(`eslint.config.mjs` 里已补 `**/releases/**`,别再删掉;`release-server/` 同理)。 (`eslint.config.mjs` 里已补 `**/releases/**`,别再删掉;旧的 `release-server/` 条目留作兜底)。
**它一度只在 ESLint 里被忽略、`.gitignore` 里漏了**(后者只有单数的 `release/` 和 `release-server/`): **它一度只在 ESLint 里被忽略、`.gitignore` 里漏了**(后者只有单数的 `release/` 和 `release-server/`):
归集一次客户端产物就有 225MB 的 EXE 变成未跟踪文件,`git add -A` 会直接把安装包提交入库。 归集一次客户端产物就有 225MB 的 EXE 变成未跟踪文件,`git add -A` 会直接把安装包提交入库。
2026-09-30 已在 `.gitignore` 末尾补上 `releases/`。 2026-09-30 已在 `.gitignore` 末尾补上 `releases/`。
(2026-10-05 起 `release-server/`、`packages/desktop-client/release/` 这两个中间输出目录已取消,
三个打包脚本都直接输出到 `releases/`,见 §4;那两条 ignore 保留作兜底。)
12. **ClassIsland 的数据根是 `D:\Classisland\data`**(**不是**旧文档写的 `F:\data`,F 盘已不存在): 12. **ClassIsland 的数据根是 `D:\Classisland\data`**(**不是**旧文档写的 `F:\data`,F 盘已不存在):
- 插件 DLL 覆盖到 `D:\Classisland\data\Plugins\classhelper.classisland.bridge\`(**ClassIsland 运行时会锁住 - 插件 DLL 覆盖到 `D:\Classisland\data\Plugins\classhelper.classisland.bridge\`(**ClassIsland 运行时会锁住
DLL,必须先退出它再覆盖**:`Get-Process ClassIsland.Desktop | Stop-Process -Force`); DLL,必须先退出它再覆盖**:`Get-Process ClassIsland.Desktop | Stop-Process -Force`);
@@ -913,7 +1069,8 @@ database / update`。
- `pnpm dist:win` 报 `!tempfile: Unable to create temporary file!` / `Error in macro _Switch`:**不是空间不足**, - `pnpm dist:win` 报 `!tempfile: Unable to create temporary file!` / `Error in macro _Switch`:**不是空间不足**,
而是 Git Bash 里 `TEMP=TMP=/tmp` —— makensis 是原生程序,把 `/tmp` 当成「当前盘根下的 tmp」解析成 而是 Git Bash 里 `TEMP=TMP=/tmp` —— makensis 是原生程序,把 `/tmp` 当成「当前盘根下的 tmp」解析成
`D:\tmp`,该目录不存在。`/c` 有 79G、`/d` 有 124G 也会照样失败(2026-09-30 实测)。 `D:\tmp`,该目录不存在。`/c` 有 79G、`/d` 有 124G 也会照样失败(2026-09-30 实测)。
`win-unpacked` 与 `release/*.nsis.7z` 此时都已生成,**只需重跑 electron-builder,不必重建前端**: `win-unpacked` 与 `*.nsis.7z` 此时都已生成(整理进 `安装包/`、`免安装/` 的那一步在打包
成功之后才跑,失败时还没执行),**只需重跑,不必重建前端**:
```bash ```bash
mkdir -p .cache/tmp mkdir -p .cache/tmp
@@ -966,6 +1123,7 @@ database / update`。
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把「周一第 1 节」限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`TERM_START_DATE` 对齐到 `2026-09-07` 即全绿(本机 `.env` 已这样配;`.env.example` 仍是 `2026-02-23`,重置 `.env` 时记得改回来) | | `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把「周一第 1 节」限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`TERM_START_DATE` 对齐到 `2026-09-07` 即全绿(本机 `.env` 已这样配;`.env.example` 仍是 `2026-02-23`,重置 `.env` 时记得改回来) |
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤,`已结束=false` 判定失败 | | `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤,`已结束=false` 判定失败 |
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 | | `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 |
| `verify:desktop`「作业看板全屏自适应」 | 演示数据里某天有 6 条长作业、而冒烟窗口只有 ~543px 高时,`computeScale` 算出的缩放低于下限 0.45 被 `Math.max(minScale, …)` **钳住**,于是必然溢出(实测 565 > 543)。判据用的是 `host.clientHeight` / `inner.offsetHeight`,**这两个是布局属性、transform 不影响**,所以与动效层无关;数据在冒烟里建了又删,每次跑出来的高度还会差几十像素 |
| 岛交互 / 岛动画 / 岛截图留档这几个用例 | **AI 会话里会间歇性失败**:同一份产物连跑两次,失败项会在「点击展开 / 紧急展开动画 / 各状态截图留档」之间跳(2026-09-30 实测三轮结果各不相同)。根因是置顶透明小窗的 rAF 被限流、形变采样抓不到帧,属时序敏感;**先原样重跑一次再动手改代码**,别把偶发当成回归 | | 岛交互 / 岛动画 / 岛截图留档这几个用例 | **AI 会话里会间歇性失败**:同一份产物连跑两次,失败项会在「点击展开 / 紧急展开动画 / 各状态截图留档」之间跳(2026-09-30 实测三轮结果各不相同)。根因是置顶透明小窗的 rAF 被限流、形变采样抓不到帧,属时序敏感;**先原样重跑一次再动手改代码**,别把偶发当成回归 |
--- ---
@@ -995,7 +1153,7 @@ bash -n deploy/install.sh deploy/classhelper deploy/verify-linux.sh deploy/insta
pnpm dist:server:linux # 只能在 Linux 上跑;开发机是 Windows 时到服务器/CI 上验证 pnpm dist:server:linux # 只能在 Linux 上跑;开发机是 Windows 时到服务器/CI 上验证
# 真机(任意一台能 SSH 的 Linux,容器也行但 systemd 用例会跳过) # 真机(任意一台能 SSH 的 Linux,容器也行但 systemd 用例会跳过)
scp release-server/classhelper-server-linux-x64-*.tar.gz deploy/{install.sh,verify-linux.sh} root@<主机>:/tmp/ scp releases/server/<版本>/linux-x64/classhelper-server-linux-x64-*.tar.gz deploy/{install.sh,verify-linux.sh} root@<主机>:/tmp/
ssh root@<主机> 'bash /tmp/install.sh --check' ssh root@<主机> 'bash /tmp/install.sh --check'
ssh root@<主机> "printf '%s\n' '<密码>' | bash /tmp/install.sh --yes --admin-password-stdin --package /tmp/classhelper-server-linux-x64-*.tar.gz" ssh root@<主机> "printf '%s\n' '<密码>' | bash /tmp/install.sh --yes --admin-password-stdin --package /tmp/classhelper-server-linux-x64-*.tar.gz"
ssh root@<主机> "bash /tmp/verify-linux.sh --admin-password-stdin" < <(printf '%s\n' '<密码>') ssh root@<主机> "bash /tmp/verify-linux.sh --admin-password-stdin" < <(printf '%s\n' '<密码>')
@@ -1026,11 +1184,11 @@ Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
开发产物通过不等于用户机器上的副本通过。 开发产物通过不等于用户机器上的副本通过。
**改过 `pruneRuntime()`(§5 第 55 条)时,还要对免安装目录本身跑一遍**(开发形态跑通不代表裁剪后跑通)。 **改过 `pruneRuntime()`(§5 第 55 条)时,还要对免安装目录本身跑一遍**(开发形态跑通不代表裁剪后跑通)。
从 `release-server/classhelper-server/` 起,用**自带的 node.exe**: 从 `releases/server/<版本>/免安装/` 起,用**自带的 node.exe**:
```bash ```bash
# ① 起服务:探针通 + 能真实查表(AUTO_MIGRATE 会建库;PORT 用环境变量避开开发端口) # ① 起服务:探针通 + 能真实查表(AUTO_MIGRATE 会建库;PORT 用环境变量避开开发端口)
cd release-server/classhelper-server && PORT=4100 ./node.exe server/dist/index.js cd releases/server/<版本>/免安装 && PORT=4100 ./node.exe server/dist/index.js
curl.exe -s --noproxy '*' http://127.0.0.1:4100/healthz curl.exe -s --noproxy '*' http://127.0.0.1:4100/healthz
curl.exe -s --noproxy '*' -X POST http://127.0.0.1:4100/api/auth/login \ curl.exe -s --noproxy '*' -X POST http://127.0.0.1:4100/api/auth/login \
-H "content-type: application/json" -d '{"username":"admin","password":"admin123"}' -H "content-type: application/json" -d '{"username":"admin","password":"admin123"}'
+150 -11
View File
@@ -1,7 +1,7 @@
# 班级小助手(Class Helper) # 班级小助手(Class Helper)
班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:**后端服务**、**Web 管理端**(教师 / 管理员)、 班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:**后端服务**、**Web 管理端**(教师 / 管理员)、
**桌面客户端**(学生端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.1.1**。 **桌面客户端**(学生端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.1.2**。
核心链路: 核心链路:
@@ -63,6 +63,7 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3,含「灵动岛」浮窗、托盘、离线缓存 | 学生端 / 教室机器 | | 桌面客户端 | `packages/desktop-client` | Electron + Vue 3,含「灵动岛」浮窗、托盘、离线缓存 | 学生端 / 教室机器 |
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限矩阵 / 工具函数 | 上面三个 + 插件 | | 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限矩阵 / 工具函数 | 上面三个 + 插件 |
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#,装在教室的 ClassIsland 上(上报课表与上课状态、弹提醒) | 教室机器(独立于 pnpm workspace) | | ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#,装在教室的 ClassIsland 上(上报课表与上课状态、弹提醒) | 教室机器(独立于 pnpm workspace) |
| 官网 | `website/` | 纯 HTML / CSS / JS 的静态页面,**没有构建步骤** | 展示与说明(`node website/serve.mjs` 本地预览) |
**设计取舍(与原始提示词的两处偏差,已与用户确认)**: **设计取舍(与原始提示词的两处偏差,已与用户确认)**:
@@ -202,7 +203,7 @@ pnpm verify:desktop
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程 / preload + Vite 渲染进程,末尾跑产物凭据门禁) | | `pnpm build:desktop` | 只构建客户端(esbuild 主进程 / preload + Vite 渲染进程,末尾跑产物凭据门禁) |
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8) | | `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8) |
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/<版本>/` | | `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/<版本>/` |
| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) | | `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 界面内品牌标) |
| `pnpm dist:server` | 打包服务端 + Web 管理端(免安装目录 + NSIS 安装程序,内置 Node) | | `pnpm dist:server` | 打包服务端 + Web 管理端(免安装目录 + NSIS 安装程序,内置 Node) |
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(最快) | | `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(最快) |
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | | `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
@@ -822,11 +823,11 @@ class-helper/
├── eslint.config.mjs # ESLint 扁平配置(TS + Vue) ├── eslint.config.mjs # ESLint 扁平配置(TS + Vue)
├── .prettierrc.json # 单引号 / 110 列 / LF / 尾逗号 ├── .prettierrc.json # 单引号 / 110 列 / LF / 尾逗号
├── .dockerignore # 容器构建上下文排除项 ├── .dockerignore # 容器构建上下文排除项
├── build/icon.{ico,png} # 应用图标(由 pnpm icons 生成) ├── build/classhelper.{png,ico} # **图标源**(设计导出、手工放入);pnpm icons 从它派生其余全部图标
├── scripts/ ├── scripts/
│ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手(改写 schema.prisma 的 provider) │ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手(改写 schema.prisma 的 provider)
│ ├── generate-icons.mjs # 用 Electron 渲染 SVG 生成 PNG/ICO 图标 │ ├── generate-icons.mjs # 启动 Electron 跑 icons/render.cjs(= pnpm icons)
│ ├── icons/render.cjs # 图标渲染脚本(CommonJS) │ ├── icons/render.cjs # 把 classhelper.png 重采样成各端图标(CommonJS)
│ ├── dist-server.mjs # 服务端 + Web 管理端 打包(免安装目录 + NSIS 安装程序) │ ├── dist-server.mjs # 服务端 + Web 管理端 打包(免安装目录 + NSIS 安装程序)
│ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟 │ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟
│ ├── lib/electron-env.mjs # 启动 Electron 的公共处理(清理 RUN_AS_NODE + 定位可执行文件) │ ├── lib/electron-env.mjs # 启动 Electron 的公共处理(清理 RUN_AS_NODE + 定位可执行文件)
@@ -850,6 +851,9 @@ class-helper/
│ ├── mysql.md # MySQL 切换指南 │ ├── mysql.md # MySQL 切换指南
│ ├── winisland-design-tokens.md # 灵动岛照 WinIsland 提取的设计 token │ ├── winisland-design-tokens.md # 灵动岛照 WinIsland 提取的设计 token
│ └── screenshots/ # 回归测试自动留档的截图(island / client / web-mobile / classisland) │ └── screenshots/ # 回归测试自动留档的截图(island / client / web-mobile / classisland)
├── website/ # 产品官网(纯静态,无构建;node serve.mjs 本地预览)
│ ├── index.html
│ └── assets/{styles.css,motion.js,main.js,icon.png,shots/} # motion.js = beUI 动效 token 与弹簧引擎
└── packages/ └── packages/
├── shared/ # 三端共享:类型契约、常量、权限矩阵、工具函数 ├── shared/ # 三端共享:类型契约、常量、权限矩阵、工具函数
│ └── src/{types,constants,permissions,utils,index}.ts │ └── src/{types,constants,permissions,utils,index}.ts
@@ -888,11 +892,11 @@ class-helper/
│ ├── src/Interop/ClassHelperClient.cs # 后端 HTTP 客户端 + 对齐服务端 zod 的 DTO │ ├── src/Interop/ClassHelperClient.cs # 后端 HTTP 客户端 + 对齐服务端 zod 的 DTO
│ └── scripts/{build,verify}.mjs # 构建打包(.cipx)与静态契约校验 │ └── scripts/{build,verify}.mjs # 构建打包(.cipx)与静态契约校验
├── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus + PWA) ├── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus + PWA)
│ ├── public/ # manifest.webmanifest / sw.js / 图标(由 pnpm icons 生成) │ ├── public/ # manifest.webmanifest / sw.js / 图标与界面品牌标(由 pnpm icons 生成)
│ └── src/{api,stores,router,layouts,views,components,composables,styles} │ └── src/{api,stores,router,layouts,views,components,composables,styles}
└── desktop-client/ # 桌面客户端(Electron + Vue 3) └── desktop-client/ # 桌面客户端(Electron + Vue 3)
├── electron-builder.yml # 打包配置(nsis 安装包 + portable 单文件) ├── electron-builder.yml # 打包配置(nsis 安装包 + portable 单文件)
├── build/icon.ico # 应用图标(electron-builder 自动使用) ├── build/classhelper.ico # 应用 / 安装包图标(electron-builder 的 win.icon 显式指向它)
├── vite.config.mts # 渲染进程构建(base: './',hash 路由) ├── vite.config.mts # 渲染进程构建(base: './',hash 路由)
├── scripts/{build-main,dev,smoke,dist-win,check-bundle-secrets}.mjs ├── scripts/{build-main,dev,smoke,dist-win,check-bundle-secrets}.mjs
└── src/ └── src/
@@ -1123,6 +1127,62 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
因此新增表无需用户手动执行 `prisma migrate deploy`;已是最新则不做任何写操作。日志会明确写出 因此新增表无需用户手动执行 `prisma migrate deploy`;已是最新则不做任何写操作。日志会明确写出
「全新安装 / 升级安装」、应用了几个迁移、跳过了几个已存在对象。 「全新安装 / 升级安装」、应用了几个迁移、跳过了几个已存在对象。
### 5.8 动效层
两端的动画与过渡统一走一套自研的动效语言,**移植自 [beUI](https://beui.dev) 的 `lib/ease.ts`**
(beUI 的组件本身是 React,无法直接用于本项目的 Vue 两端,但它的动效语言与框架无关)。
- **令牌单一来源**:`packages/shared/src/motion.ts`。三组缓动曲线(`EASE_OUT` / `EASE_IN_OUT` /
`EASE_DRAWER`)与六组具名弹簧(`SPRING_PRESS` / `SWAP` / `PANEL` / `LAYOUT` / `MOUSE` / `GLIDE`)。
弹簧由**数值积分**求出落定时间,再生成 CSS `linear()` 缓动字面量 —— 于是弹簧可以纯 CSS 驱动,
不必给每个组件挂一个 rAF 循环去逐帧改样式。
- **运行时注入**:两端的 `main.ts` 在 `app.mount()` **之前**把令牌写进 `:root` 的自定义属性
(`--ch-ease-*` / `--ch-spring-*` / `--ch-dur-*`)。CSS 里只引用变量,不抄数值 —— 抄了必漂。
- **两端各一份组合式函数**:`packages/{web-admin/src,desktop-client/src/renderer}/composables/motion.ts`
(`useReducedMotion` / `useHoverCapable` / `useCountUp` / `useStaggerIn`)。与 `ScoreBarChart.vue`
一样,两端各一份是仓库既有做法。
落地到界面上的动效:
| 位置 | 动效 |
| ---- | ---- |
| Element Plus 全站过渡 | 时长 300/200ms → 200/140ms,缓动换成 `EASE_OUT`(改写 `--el-transition-*` 变量,不动组件源码) |
| 弹窗 / 抽屉 / 浮层 / 消息条 | 面板走 `SPRING_PANEL` / `SPRING_SWAP` 弹簧;**退出一律比入场快**(用户已经决定关掉它了) |
| 按钮 | 按下 90ms 直落(跟手)、抬起走 `SPRING_PRESS` 弹回 |
| 侧栏激活项 | 单个"底片"在菜单项之间**滑过去**(`SPRING_LAYOUT`),取代"每项各画一块底色" |
| 页面内容 | `.page > *` 按块错峰浮现(30ms 一档,封顶 180ms) |
| 统计数字 | 0 → 实际值滚动(`useCountUp`) |
| 主题切换(两端) | View Transition API:从点击位置扩散的圆整页换肤 |
| 灵动岛 | **刻意只有单调缓动**(曲线取自同一套令牌,但不用弹簧):岛的形变由主进程缓动窗口尺寸,任何过冲都会让"开合"看起来在震动(冒烟逐帧断言)。悬停微交互关在 `@media (hover: hover)` 里,避免触摸屏上"幽灵 hover"粘住按钮 |
| 降低动态效果 | 全站 `prefers-reduced-motion` 降级(**加载指示旋除外** —— 停掉会让界面看着像卡死) |
三条**别踩**的约定(都对应实测过的坑,详见 [AGENTS.md](AGENTS.md) §5 第 61 条):
路由层**不要**包 `<transition mode="out-in">`;Vue 的 `<transition>` 一律显式给 `:duration`;
`position: fixed` 的整屏层与"自己量尺寸"的元素不要叠位移类动画。
### 5.9 主题(浅色 / 深色)
**两端都有深色主题,机制完全一致**:在 `<html>` 上挂 `.dark` 类,由各自 `styles/index.css` 的
`html.dark` 块覆盖**语义色令牌**,Element Plus 的 `theme-chalk/dark/css-vars.css` 提供 `--el-*` 的深色默认。
两端的 `main.ts` 都在 `app.mount()` **之前**落主题,深色用户首帧才不会看到"先白后黑"闪一下。
- **语义色令牌**:`--ch-surface` / `--ch-surface-alt` / `--ch-surface-sunken` / `--ch-surface-hover` /
`--ch-text` / `--ch-text-secondary` / `--ch-text-muted` / `--ch-text-placeholder`。
组件里**一律引用这些,不要写死颜色**;`html.dark` 只覆盖颜色,**不动圆角与动效令牌** ——
两套主题的几何必须完全一致,否则切换时会看到布局跳一下。
- **默认跟随系统**(`prefers-color-scheme`,且系统改了就跟着变);用户一旦手动点过切换按钮,
就以用户的选择为准(系统的偏好是默认值,不是命令)。
- **切换入口**:客户端在顶栏(`[data-test="theme-toggle"]`),Web 管理端同样在顶栏。
两边都走 `useThemeReveal()`(见 [5.8](#58-动效层))——**从点击位置扩散的圆整页换肤**。
- **刻意不随主题的区域**:登录页的深色渐变底、桌面端的启动过渡页与全屏作业看板、
灵动岛自身、Web 管理端的侧栏(本来就是深色)、以及深色底上的白色文字。
- Web 端还会同步 `<meta name="theme-color">`,并设 `color-scheme` 让**原生控件**
(滚动条、日期选择器弹层、自动填充底色)也跟着走 —— 只改 CSS 变量的话它们会仍是刺眼的浅色。
> ⚠️ 改令牌时**只改值**。别对样式表跑"把 `#303133` 全量替换成 `var(--ch-text)`"这类批量替换 ——
> 它会把令牌**定义本身**也换掉(`--ch-text: #303133` → `--ch-text: var(--ch-text)`),
> 自定义属性一旦自引用就整条失效、解析成空串,全站文字颜色静默回落成继承色(实测踩过)。
## 6. 验收 ## 6. 验收
### 6.1 验收标准对照 ### 6.1 验收标准对照
@@ -1177,8 +1237,8 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
| 验证 | 结果 | | 验证 | 结果 |
| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `pnpm verify:e2e`(后端 + 实时 + RBAC + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查) | **190/190** ✅(2026-10-05 实测) | | `pnpm verify:e2e`(后端 + 实时 + RBAC + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查) | **190/190** ✅(2026-10-05 实测) |
| `pnpm verify:desktop`(客户端冒烟,不弹窗) | **105/105** ✅(在线轮)+ **89/89** ✅(二次启动轮),0 失败(2026-10-05 实测) | | `pnpm verify:desktop`(客户端冒烟,不弹窗) | **107/107** ✅ + **89/89** ✅(2026-10-06 实测,0 失败) |
| `pnpm verify:web`(Web 管理端真实点击回归) | **32/33**(2026-10-05 实测;未过的 1 项是既有脆弱用例「课表科目为全校统一目录」,见下) | | `pnpm verify:web`(Web 管理端真实点击回归) | **32/33**(2026-10-06 实测;未过的 1 项是既有脆弱用例「课表科目为全校统一目录」,见下) |
| `pnpm verify:classisland`(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约) | **42/42** ✅(2026-10-05 实测) | | `pnpm verify:classisland`(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约) | **42/42** ✅(2026-10-05 实测) |
| `pnpm verify:classisland-plugin`(插件静态契约) | **68/68** ✅ | | `pnpm verify:classisland-plugin`(插件静态契约) | **68/68** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm check:icons` | 全过 ✅(lint 0 error **0 warning**) | | `pnpm typecheck` / `pnpm lint` / `pnpm check:icons` | 全过 ✅(lint 0 error **0 warning**) |
@@ -1191,7 +1251,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
| 安装程序完整生命周期(静默安装 → 启动 → 卸载) | 通过 ✅(安装到 `%LOCALAPPDATA%\Programs`,卸载干净) | | 安装程序完整生命周期(静默安装 → 启动 → 卸载) | 通过 ✅(安装到 `%LOCALAPPDATA%\Programs`,卸载干净) |
| 便捷版(单文件)解包启动 | 通过 ✅(wrapper + 多个进程,主窗口正常) | | 便捷版(单文件)解包启动 | 通过 ✅(wrapper + 多个进程,主窗口正常) |
| Docker / Nginx 部署样例 | 文件已提供;本机无 Docker 未实测 ⚠️ | | Docker / Nginx 部署样例 | 文件已提供;本机无 Docker 未实测 ⚠️ |
| Linux 一键安装 + `classhelper` 运维命令(`deploy/install.sh` / `deploy/classhelper` / `pnpm dist:server:linux`) | 脚本与文档已交付,本地静态检查通过(`bash -n`、ESLint 0 error);**Linux 实机验收待做** ⚠️(清单见 [linux-deploy.md](docs/linux-deploy.md) §10,`deploy/verify-linux.sh` 可自动跑) | | Linux 一键安装 + `classhelper` 运维命令(`deploy/install.sh` / `deploy/classhelper` / `pnpm dist:server:linux`) | 脚本与文档已交付,本地静态检查通过(`bash -n`、ESLint 0 error);**Linux 实机验收待做** ⚠️(清单见 [linux-deploy.md](docs/linux-deploy.md) §10,`deploy/verify-linux.sh` 可自动跑)。2026-10-06 用交叉构建出了 1.1.2 的包并核对过内容(sha256 自洽、顶层目录与 `--strip-components=1` 对得上、`.cross-built` 标记在位、包内是新图标),但**没在 Linux 上跑过** —— 正式发布仍走 CI |
### 6.3 已知的时序 / 环境相关用例 ### 6.3 已知的时序 / 环境相关用例
@@ -1199,7 +1259,8 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
- **`verify:desktop` 的「真实通知链路」「灵动岛标为已读」等 3 项**:要求**当前不在上课时段**。 - **`verify:desktop` 的「真实通知链路」「灵动岛标为已读」等 3 项**:要求**当前不在上课时段**。
若种子课表恰有课正在进行,客户端会**正确地**把普通通知暂存到下课(见 [4.6](#46-上课时段策略)), 若种子课表恰有课正在进行,客户端会**正确地**把普通通知暂存到下课(见 [4.6](#46-上课时段策略)),
这三项就会红(日志里会写 `客户端判定上课中=true` / `正在上 XXX`)。2026-10-02 那次 99/102 即为此因。 这三项就会红(日志里会写 `客户端判定上课中=true` / `正在上 XXX`)。2026-10-02 那次 99/102 即为此因,
2026-10-06 09:27(种子里的「政治 08:55–09:40」正在上)那次 104/107 同因。
- **`verify:e2e` 的「上课状态接口(at=周一 08:10)」**:种子把周一第 1 节限制在第 1 周,且用例把 `2026-09-07` - **`verify:e2e` 的「上课状态接口(at=周一 08:10)」**:种子把周一第 1 节限制在第 1 周,且用例把 `2026-09-07`
当第 1 周 —— `.env` 的 `TERM_START_DATE` 对齐到 `2026-09-07` 即全绿。 当第 1 周 —— `.env` 的 `TERM_START_DATE` 对齐到 `2026-09-07` 即全绿。
- **`verify:web` 的「课表科目为全校统一目录」**:种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组。 - **`verify:web` 的「课表科目为全校统一目录」**:种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组。
@@ -1288,6 +1349,84 @@ pnpm verify:classisland-plugin # 静态契约校验(不需要 .NET,不需
别为了消警告加无参构造函数。部署到教室机器(先退出 ClassIsland 再覆盖 DLL,别覆盖 `Settings.json`)的步骤 别为了消警告加无参构造函数。部署到教室机器(先退出 ClassIsland 再覆盖 DLL,别覆盖 `Settings.json`)的步骤
见 [AGENTS.md](AGENTS.md) §7 第 12 条与 §8。 见 [AGENTS.md](AGENTS.md) §7 第 12 条与 §8。
### 官网(`website/`)
产品介绍页,纯静态、**没有构建步骤**,也不进 pnpm workspace:
```bash
node website/serve.mjs # http://127.0.0.1:5180,只是本地预览用
```
发布时把 `website/` 整个目录丢给任意静态托管 / Nginx 即可(`index.html` 是唯一入口,无路由)。
**动效层(`assets/motion.js`)的参数逐条取自 [beUI](https://beui.dev/components/motion) 的开源 token**——
曲线(`EASE_OUT` = `cubic-bezier(0.16, 1, 0.3, 1)` 等)、弹簧(`SPRING_PRESS` 500/30/0.6、`SPRING_LAYOUT` 360/32/0.6、
`SPRING_GLIDE` 700/50/0.5 ……)、时长口径,以及各个组件源码里的具体写法。没有引 React 那套依赖,
是自己写的一个小引擎(`Spring` 类 + 一个自续 rAF 循环 + `duration/bounce` 反解刚度的求解器)。
| 页面上的动效 | 对着 beUI 的哪个组件写的 |
| --- | --- |
| 标题逐字上浮(位移用弹簧、透明度 0.7s、去模糊 0.9s、逐字 0.03s 错开) | `motion/text-animation` 的 `TextReveal` |
| 首屏那个**灵动岛** | `blocks/dynamic-island` |
| 胶囊文案交换(scale 0.94 + 模糊 8px,0.2s) | `motion/action-swap` 的 blur 变体 |
| 铃铛从铰链上摆一下 | motion guide「Semantic icon motion」 |
| 顶栏悬停胶囊滑移 | `motion/shared-layout-bg` |
| 顶部 2px 阅读进度条 | `motion/scroll-animation` 的 `ScrollProgressBar` |
| 按钮按压 0.93 / 悬停 1.02 | `motion/button` |
| 验收数字滚动(0–9 竖排滚轮) | `motion/number` 的 `NumberTicker` |
| 上课时段滑块的把手 | `motion/range-slider` |
| 复制按钮文案滚上来 | `motion/action-swap` 的 roll 变体 |
| 实机图库的分段控件(指示器滑移) | `motion/tabs` 的 `layoutId` 指示器 |
| 切分组换图(旧的走得比新的快,±4px) | motion guide「Content swap」 |
| 点缩略图放大(从原位置长成大图) | `motion/image-viewer` 的 `morphing-lightbox` |
灵动岛保持**产品的几何**(连续圆角 squircle、真实尺寸 胶囊 268×44 / 展开 424×230 / 叫人 456×262),
换成 beUI 的**动效语言**:壳用 Apple 的 `duration 0.8s + bounce 0.2`、内容层 `bounce 0.35`,
圆角是常数 24(由 `min(圆角, w/2, h/2)` 钳制,胶囊态自动成为满圆角),内容进出带 scale + 位移 + 模糊。
少动效下只保留透明度:位移、缩放、模糊、回弹全部去掉。
**版式照 [ClassIsland 官网](https://www.classisland.tech/) 写**(它是教室大屏那套东西,读者是同一批人,
两边看起来像一套更省事):
| 版式 | 来自 ClassIsland 的哪一处 |
| --- | --- |
| 分幕标题(居中大标题 + 中间一段渐变填色) | `.headline-feature` + `.gradient-base` |
| 特性单元:`#彩色标签` → 粗体短标题 → 一句说明 | `#主界面` / `#提醒` … 那一组 |
| 首屏左文右块、文字收成一条窄列 | `.introduction-col`(`flex-basis: 480px`) |
| 首屏逐项错峰入场(`.72s` EASE_OUT、延迟 150/200/250ms) | `.intro-seq` + `--intro-delay` |
| 末尾一整块收尾 CTA | 「更多功能留给您自行探索!」那一屏 |
> 有意思的是 ClassIsland 用的曲线 `cubic-bezier(.16, 1, .3, 1)` 和 beUI 的 `EASE_OUT` **是同一条**,
> 所以两套参考并不打架。
> 标题与引文用的是楷体(优先加载霞鹜文楷 Screen,失败回落到系统 `KaiTi` / `STKaiti`),
> 正文用产品自己那套 UI 字体。字体是 CDN 引入的,**断网时会自动回落,不影响阅读**。
#### 采集实机图(`website/assets/shots/`)
官网上那一栏实机截图**不是画的示意**,是脚本真的把程序跑起来截的。要重新采集:
```bash
pnpm dev:server # 后端(同时托管 Web 管理端)
node website/tools/capture-shots.mjs # → web-*.jpg / mobile-*.jpg
# 客户端页面 + 灵动岛各形态(跑一遍客户端冒烟,顺便留档)
ISLAND_SHOTS_DIR=F:/ClassHelper/.cache/island-shots \
ELECTRON_CLIENT_SHOTS_DIR=F:/ClassHelper/.cache/client-shots \
ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \
ELECTRON_SMOKE_USER=teacher1 ELECTRON_SMOKE_PASSWORD=123456 \
node packages/desktop-client/scripts/smoke.mjs
```
两个环境变量在 `smoke.ts` 里是**默认值为洋红**的开关:冒烟平时故意用洋红主题色把验证实例的岛
和用户自己的岛区分开(见 AGENTS.md §5 第 38 条),采集留档图时才换成产品默认外观。
指向 `website/assets/shots/` 的那两个目录变量不在 `website/`,要自己拷过去。
> 采集脚本会把窗口**真的弹在屏幕上**(和 `verify:web` / `verify:desktop` 一样),跑的时候别动键鼠。
> 客户端留档会**临时停掉 CSS 动画**再截图:窗口被遮挡时 Chromium 冻结动画,页面入场动画的起始帧是
> `opacity: 0`,不停掉的话截出来是**整片空白**(DOM 里文本齐全,只有图是空的)。
> `capture-shots.mjs` 自带 `UnknownVizError` 重试与空内容跳过,偶发失败不会毁掉整轮。
## 8. 常见问题 ## 8. 常见问题
**开发环境的坑请直接看 [AGENTS.md](AGENTS.md) §7「本机环境已知坑」** —— 那里是本仓库唯一维护这些内容的 **开发环境的坑请直接看 [AGENTS.md](AGENTS.md) §7「本机环境已知坑」** —— 那里是本仓库唯一维护这些内容的
Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

+1 -1
View File
@@ -7,7 +7,7 @@
SERVER_PORT=4000 SERVER_PORT=4000
# 应用版本(镜像标签) # 应用版本(镜像标签)
APP_VERSION=1.1.1 APP_VERSION=1.1.2
# MySQL 配置 # MySQL 配置
MYSQL_ROOT_PASSWORD=请改成强密码_root MYSQL_ROOT_PASSWORD=请改成强密码_root
+1 -1
View File
@@ -8,7 +8,7 @@
SERVER_PORT=4000 SERVER_PORT=4000
# 应用版本(镜像标签) # 应用版本(镜像标签)
APP_VERSION=1.1.1 APP_VERSION=1.1.2
# JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词) # JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词)
# 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" # 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
+1 -1
View File
@@ -23,7 +23,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-1.1.1} image: classhelper-server:${APP_VERSION:-1.1.2}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+1 -1
View File
@@ -60,7 +60,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-1.1.1} image: classhelper-server:${APP_VERSION:-1.1.2}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+2 -2
View File
@@ -17,7 +17,7 @@
# 脚本会自动从 GitHub Release 取**最新版**服务端安装包,并取发布页上同名的 .sha256 做校验, # 脚本会自动从 GitHub Release 取**最新版**服务端安装包,并取发布页上同名的 .sha256 做校验,
# 所以整条链路只需要这一行。要加参数就接在最后:`sudo bash /tmp/classhelper-install.sh --check` # 所以整条链路只需要这一行。要加参数就接在最后:`sudo bash /tmp/classhelper-install.sh --check`
# --check 只体检、不安装(不需要 root) --yes 全部默认、不交互 # --check 只体检、不安装(不需要 root) --yes 全部默认、不交互
# --version 1.1.1 装指定版本 --port 8080 自定义端口 # --version 1.1.2 装指定版本 --port 8080 自定义端口
# --admin-password-stdin 从 stdin 读初始管理员密码 # --admin-password-stdin 从 stdin 读初始管理员密码
# #
# ⚠ 别写成 `bash -c "$(curl …)" --check`:`bash -c '代码' 第一个参数` 里那个参数会变成 **$0**、 # ⚠ 别写成 `bash -c "$(curl …)" --check`:`bash -c '代码' 第一个参数` 里那个参数会变成 **$0**、
@@ -529,7 +529,7 @@ resolve_package_url() {
tag="$(github_latest_tag)" || true tag="$(github_latest_tag)" || true
if [ -z "$tag" ]; then if [ -z "$tag" ]; then
die "取不到 GitHub 最新版本(本机可能没有外网)。请用 --package 指定安装包,例如: die "取不到 GitHub 最新版本(本机可能没有外网)。请用 --package 指定安装包,例如:
sudo bash install.sh --package /tmp/classhelper-server-linux-x64-1.1.1.tar.gz" sudo bash install.sh --package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz"
fi fi
fi fi
ver="${tag#v}" ver="${tag#v}"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "classhelper-server-runtime", "name": "classhelper-server-runtime",
"version": "1.1.1", "version": "1.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "班级小助手服务端运行时(安装包 / 容器内置)", "description": "班级小助手服务端运行时(安装包 / 容器内置)",
+12 -7
View File
@@ -181,12 +181,14 @@ if [ -n "$TOKEN" ]; then
check "审计日志里没有新密码明文" "$(grep -q -- "$GENERATED" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)" check "审计日志里没有新密码明文" "$(grep -q -- "$GENERATED" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)"
check "审计日志里没有原密码明文" "$(grep -q -- "$ADMIN_PASSWORD" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)" check "审计日志里没有原密码明文" "$(grep -q -- "$ADMIN_PASSWORD" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)"
check "弱密码被拒绝(长度/种数不足)" \ # 先把输出收进变量再 grep:`外部命令 | grep -q` 在 `set -o pipefail` 下,grep 命中即退出会让
"$(printf 'abc\nabc\n' | "$CLASSHELPER" password "$ADMIN_USER" 2>&1 | grep -q '不合规' && echo 0 || echo 1)" # 写端收到 SIGPIPE(退出码 141),于是"匹配到了"被算成失败
weak_out="$(printf 'abc\nabc\n' | "$CLASSHELPER" password "$ADMIN_USER" 2>&1 || true)"
check "弱密码被拒绝(长度/种数不足)" "$(printf '%s' "$weak_out" | grep -q '不合规' && echo 0 || echo 1)"
check "密码不合规时不会卡死(stdin 结束即退出)" \ check "密码不合规时不会卡死(stdin 结束即退出)" \
"$(printf 'abc\n' | timeout 20 "$CLASSHELPER" password "$ADMIN_USER" >/dev/null 2>&1; [ $? -ne 124 ] && echo 0 || echo 1)" "$(printf 'abc\n' | timeout 20 "$CLASSHELPER" password "$ADMIN_USER" >/dev/null 2>&1; [ $? -ne 124 ] && echo 0 || echo 1)"
check "password --list 能列出账号" \ list_out="$("$CLASSHELPER" password --list 2>&1 || true)"
"$("$CLASSHELPER" password --list 2>&1 | grep -q "$ADMIN_USER" && echo 0 || echo 1)" check "password --list 能列出账号" "$(printf '%s' "$list_out" | grep -q "$ADMIN_USER" && echo 0 || echo 1)"
# 用改密前保存的哈希把账号恢复原样:比再改一次密码更可靠 # 用改密前保存的哈希把账号恢复原样:比再改一次密码更可靠
# (原密码若是 admin123 这类弱口令,改回去会被强度校验挡下 —— 那是产品的正确行为) # (原密码若是 admin123 这类弱口令,改回去会被强度校验挡下 —— 那是产品的正确行为)
@@ -223,7 +225,8 @@ if [ -n "$latest" ]; then
fi fi
check "备份目录权限 700" "$([ "$(stat -c '%a' "${latest%/}" 2>/dev/null)" = "700" ] && echo 0 || echo 1)" check "备份目录权限 700" "$([ "$(stat -c '%a' "${latest%/}" 2>/dev/null)" = "700" ] && echo 0 || echo 1)"
fi fi
check "backup list 能列出备份" "$("$CLASSHELPER" backup list 2>&1 | grep -qE '^ [0-9]{8}-' && echo 0 || echo 1)" backup_list_out="$("$CLASSHELPER" backup list 2>&1 || true)"
check "backup list 能列出备份" "$(printf '%s' "$backup_list_out" | grep -qE '^ [0-9]{8}-' && echo 0 || echo 1)"
# ---------------------------------------------------------------- 6. 配置 # ---------------------------------------------------------------- 6. 配置
@@ -248,8 +251,10 @@ if [ -n "$TOKEN" ]; then
note "按环境变量跳过" note "按环境变量跳过"
else else
old_secret="$(read_cfg JWT_SECRET)" old_secret="$(read_cfg JWT_SECRET)"
# key rotate 会问一次确认;无 tty 时从 stdin 读,因此用管道喂 yes # key rotate 无 tty 时会问两次(确认轮换、是否现在重启);只喂一个 yes 的话第二次读到 EOF,
printf 'yes\n' | CLASSHELPER_NO_TTY=1 "$CLASSHELPER" key rotate >/dev/null 2>&1 || true # 重启被跳过 —— 新密钥不生效,下面「旧 token 已失效」必然失败。
# 用 CLASSHELPER_ASSUME_YES=1 跳过问答并强制重启,语义才是"轮换后旧会话立即失效"。
CLASSHELPER_NO_TTY=1 CLASSHELPER_ASSUME_YES=1 "$CLASSHELPER" key rotate >/dev/null 2>&1 || true
new_secret="$(read_cfg JWT_SECRET)" new_secret="$(read_cfg JWT_SECRET)"
check "key rotate 改变了 JWT_SECRET" "$([ -n "$new_secret" ] && [ "$new_secret" != "$old_secret" ] && echo 0 || echo 1)" check "key rotate 改变了 JWT_SECRET" "$([ -n "$new_secret" ] && [ "$new_secret" != "$old_secret" ] && echo 0 || echo 1)"
check "新密钥长度 ≥32" "$([ "${#new_secret}" -ge 32 ] && echo 0 || echo 1)" check "新密钥长度 ≥32" "$([ "${#new_secret}" -ge 32 ] && echo 0 || echo 1)"
+33 -9
View File
@@ -3,10 +3,33 @@
本文覆盖服务端在 Linux 上的**一键安装**与日常运维(`classhelper` 命令)。 本文覆盖服务端在 Linux 上的**一键安装**与日常运维(`classhelper` 命令)。
适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+(自动识别 apt / yum / dnf)。 适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+(自动识别 apt / yum / dnf)。
不想看长文的话,最短路径是这两条命令: 不想看长文的话,最短路径就是这**一行**(在服务器上执行):
```bash ```bash
sudo bash install.sh --package /path/to/classhelper-server-linux-x64-1.1.0.tar.gz curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh && sudo bash /tmp/classhelper-install.sh
```
它自己会把整套流程走完:识别发行版 → 补依赖 → 建系统用户 → **从 GitHub Release 取最新版服务端包
并校验发布页上的 `.sha256`** → 下载内置 Node 运行时 → 建库建号 → 装成 systemd 服务。装完直接:
```bash
sudo classhelper status
```
参数接在最后即可,例如先只体检(不需要 root、不改动系统):
```bash
curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh && bash /tmp/classhelper-install.sh --check
```
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`,
> 不是 `$1` —— 脚本收不到它(实测踩到:本意是体检,结果走进了交互式安装)。
> 先落盘再执行没这个坑,也不会因为下载中断而执行到半个脚本。
以下是"已有安装包 / 内网离线"的用法:
```bash
sudo bash install.sh --package /path/to/classhelper-server-linux-x64-1.1.2.tar.gz
sudo classhelper status sudo classhelper status
``` ```
@@ -41,7 +64,7 @@ Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不
仓库里的 [`.github/workflows/release-linux-server.yml`](../.github/workflows/release-linux-server.yml) 仓库里的 [`.github/workflows/release-linux-server.yml`](../.github/workflows/release-linux-server.yml)
就是干这个的(拿 ubuntu runner 跑 `pnpm dist:server:linux`,再把产物挂到对应 Release 上): 就是干这个的(拿 ubuntu runner 跑 `pnpm dist:server:linux`,再把产物挂到对应 Release 上):
1. 本地照惯例发 Windows 包的 Release(tag 形如 `v1.1.0`); 1. 本地照惯例发 Windows 包的 Release(tag 形如 `v1.1.2`);
2. GitHub → Actions → 「发布 Linux 服务端安装包」→ Run workflow → 填 tag → 运行; 2. GitHub → Actions → 「发布 Linux 服务端安装包」→ Run workflow → 填 tag → 运行;
3. 跑完 Release 里会多出 Linux 包与更新后的 `SHA256SUMS-<版本>.txt`。 3. 跑完 Release 里会多出 Linux 包与更新后的 `SHA256SUMS-<版本>.txt`。
@@ -67,7 +90,7 @@ Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不
```bash ```bash
# 开发机(Windows 也可以) # 开发机(Windows 也可以)
pnpm dist:server:linux pnpm dist:server:linux
# → release-server/classhelper-server-linux-x64-<版本>.tar.gz(+ .sha256) # → releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz(+ .sha256)
# 重跑很快:node scripts/dist-server.mjs --platform linux --reuse-deps(复用已装的 node_modules) # 重跑很快:node scripts/dist-server.mjs --platform linux --reuse-deps(复用已装的 node_modules)
``` ```
@@ -88,17 +111,17 @@ TUI 会依次问:形态 → 安装目录 → 端口 → 数据库 → 初始
```bash ```bash
# 全部默认:/opt/classhelper、4000 端口、SQLite、内置 Node # 全部默认:/opt/classhelper、4000 端口、SQLite、内置 Node
sudo bash install.sh --yes --package ./classhelper-server-linux-x64-1.1.0.tar.gz sudo bash install.sh --yes --package ./classhelper-server-linux-x64-1.1.2.tar.gz
# 指定目录与端口,从 stdin 传初始管理员密码(不进 argv / shell history) # 指定目录与端口,从 stdin 传初始管理员密码(不进 argv / shell history)
printf '%s\n' 'MyStrongPass!2026' | sudo bash install.sh --yes \ printf '%s\n' 'MyStrongPass!2026' | sudo bash install.sh --yes \
--dir /srv/classhelper --port 8080 --admin-password-stdin \ --dir /srv/classhelper --port 8080 --admin-password-stdin \
--package ./classhelper-server-linux-x64-1.1.0.tar.gz --package ./classhelper-server-linux-x64-1.1.2.tar.gz
# MySQL 形态 # MySQL 形态
sudo bash install.sh --yes --database mysql --mysql-host 10.0.0.5 --mysql-db classhelper \ sudo bash install.sh --yes --database mysql --mysql-host 10.0.0.5 --mysql-db classhelper \
--mysql-user classhelper --mysql-password-stdin \ --mysql-user classhelper --mysql-password-stdin \
--package ./classhelper-server-linux-x64-1.1.0.tar.gz <<< 'MySQL密码' --package ./classhelper-server-linux-x64-1.1.2.tar.gz <<< 'MySQL密码'
``` ```
完整参数见 `bash install.sh --help`。要点: 完整参数见 `bash install.sh --help`。要点:
@@ -403,6 +426,7 @@ sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
| --- | --- | | --- | --- |
| 服务起不来 | `classhelper doctor` → `classhelper logs -n 100`。常见:端口被占用、MySQL 连接串错、`data/` 权限 | | 服务起不来 | `classhelper doctor` → `classhelper logs -n 100`。常见:端口被占用、MySQL 连接串错、`data/` 权限 |
| 装完打不开页面 | `classhelper status` 看探针与监听地址;云服务器还要在**安全组**放行端口 | | 装完打不开页面 | `classhelper status` 看探针与监听地址;云服务器还要在**安全组**放行端口 |
| 页面能打开但是**空白** | 1.1.0 的服务端在 HTTP 直连访问时会下发 CSP `upgrade-insecure-requests`,浏览器把所有子资源改写成 https 加载而服务器没有 TLS,于是整页空白(HTML/接口都正常)。已修复的版本按请求协议条件下发;若暂不能升级,唯一正解是上 HTTPS 反代 |
| 改了配置没生效 | 是否重启过(`classhelper config set` 会问);`EnvironmentFile` 的值优先于文件里的同项 | | 改了配置没生效 | 是否重启过(`classhelper config set` 会问);`EnvironmentFile` 的值优先于文件里的同项 |
| Web 端切库后还是连旧库 | `classhelper doctor` 看 `.env` 软链是否被破坏(`ln -sfn /etc/classhelper/config.env <安装目录>/.env`) | | Web 端切库后还是连旧库 | `classhelper doctor` 看 `.env` 软链是否被破坏(`ln -sfn /etc/classhelper/config.env <安装目录>/.env`) |
| 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB | | 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB |
@@ -447,7 +471,7 @@ Node 官方的 linux-x64 二进制要求 **glibc ≥ 2.28**,而 CentOS 7 是 2
```bash ```bash
# 在开发机:做出 Linux 包(或在 Actions 里下载) # 在开发机:做出 Linux 包(或在 Actions 里下载)
pnpm dist:server:linux pnpm dist:server:linux
scp release-server/classhelper-server-linux-x64-*.tar.gz root@<服务器>:/tmp/ scp releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz root@<服务器>:/tmp/
scp deploy/install.sh deploy/verify-linux.sh root@<服务器>:/tmp/ scp deploy/install.sh deploy/verify-linux.sh root@<服务器>:/tmp/
``` ```
@@ -464,7 +488,7 @@ bash /tmp/install.sh --check
```bash ```bash
sudo bash /tmp/install.sh --yes \ sudo bash /tmp/install.sh --yes \
--package /tmp/classhelper-server-linux-x64-1.1.0.tar.gz \ --package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz \
--admin-password-stdin <<< 'Verify-Pass-2026!' --admin-password-stdin <<< 'Verify-Pass-2026!'
``` ```
+3 -3
View File
@@ -35,7 +35,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
双击安装程序即可,**无需管理员权限**(当前用户级安装): 双击安装程序即可,**无需管理员权限**(当前用户级安装):
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer` - 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
- 静默安装:`"班级小助手服务端-1.1.1-x64-setup.exe" /S` - 静默安装:`"班级小助手服务端-1.1.2-x64-setup.exe" /S`
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载 - 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
- 桌面:`班级小助手服务端.lnk`(打开管理端) - 桌面:`班级小助手服务端.lnk`(打开管理端)
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`) - 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
@@ -176,7 +176,7 @@ sudo bash /tmp/install.sh # 交互式:选形态 → 选目录
```bash ```bash
printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \ printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \
--dir /srv/classhelper --port 8080 --admin-password-stdin \ --dir /srv/classhelper --port 8080 --admin-password-stdin \
--package /tmp/classhelper-server-linux-x64-1.1.1.tar.gz --package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz
``` ```
安装器做的事:识别发行版与包管理器补依赖 → 建系统用户 `classhelper` → 下载包并校验 sha256 → 安装器做的事:识别发行版与包管理器补依赖 → 建系统用户 `classhelper` → 下载包并校验 sha256 →
@@ -221,7 +221,7 @@ printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \
没有 systemd 的容器里可以用安装器的"只铺文件"模式,再自己托管进程: 没有 systemd 的容器里可以用安装器的"只铺文件"模式,再自己托管进程:
```bash ```bash
sudo bash /tmp/install.sh --yes --force --package /tmp/classhelper-server-linux-x64-1.1.1.tar.gz sudo bash /tmp/install.sh --yes --force --package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz
sudo -u classhelper /opt/classhelper/runtime/node/bin/node /opt/classhelper/server/dist/index.js sudo -u classhelper /opt/classhelper/runtime/node/bin/node /opt/classhelper/server/dist/index.js
``` ```
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "class-helper", "name": "class-helper",
"version": "1.1.1", "version": "1.1.2",
"private": true, "private": true,
"description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@11.8.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 83 KiB

+1 -1
View File
@@ -14,6 +14,6 @@ description: |-
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。 4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll' entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper url: https://github.com/LoveChengke/ClassHelper
version: 1.1.1.0 version: 1.1.2.0
apiVersion: 2.0.0.0 apiVersion: 2.0.0.0
author: 班级小助手 author: 班级小助手
+19 -13
View File
@@ -3,7 +3,7 @@
* *
* 用法: * 用法:
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release * node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
* node scripts/build.mjs --cipx # 追加打包 .cipx(ClassIsland 插件包)并归集到 releases/classisland-plugin/ * node scripts/build.mjs --cipx # 追加打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`) * 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
* *
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍): * 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'node:url';
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(pluginDir, '..', '..'); const repoRoot = path.resolve(pluginDir, '..', '..');
const cacheDir = path.join(repoRoot, '.cache'); const cacheDir = path.join(repoRoot, '.cache');
const releasesDir = path.join(repoRoot, 'releases', 'classisland-plugin'); const releasesRoot = path.join(repoRoot, 'releases', 'classisland-plugin');
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const withCipx = argv.includes('--cipx'); const withCipx = argv.includes('--cipx');
@@ -146,14 +146,19 @@ if (!fs.existsSync(cipxFile)) {
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`); throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
} }
// 归集到 releases/classisland-plugin/(与客户端、服务端交付产物同级;releases/ 不入库) // 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。
fs.rmSync(releasesDir, { recursive: true, force: true }); // 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把
fs.mkdirSync(releasesDir, { recursive: true }); // 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。
fs.copyFileSync(cipxFile, path.join(releasesDir, path.basename(cipxFile))); const pluginVersion = manifestVersion.split('.').slice(0, 3).join('.');
const versionDir = path.join(releasesRoot, pluginVersion);
const installerDir = path.join(versionDir, '安装包');
const portableDir = path.join(versionDir, '免安装', 'ClassHelper.ClassIslandPlugin');
fs.rmSync(installerDir, { recursive: true, force: true });
fs.rmSync(path.dirname(portableDir), { recursive: true, force: true });
fs.mkdirSync(installerDir, { recursive: true });
fs.mkdirSync(portableDir, { recursive: true });
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载 // 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
const portableDir = path.join(releasesDir, 'ClassHelper.ClassIslandPlugin');
fs.mkdirSync(portableDir, { recursive: true });
for (const name of fs.readdirSync(outputDir)) { for (const name of fs.readdirSync(outputDir)) {
const from = path.join(outputDir, name); const from = path.join(outputDir, name);
if (!fs.statSync(from).isFile()) continue; if (!fs.statSync(from).isFile()) continue;
@@ -165,10 +170,11 @@ for (const name of fs.readdirSync(outputDir)) {
} }
for (const file of fs.readdirSync(cipxDir)) { for (const file of fs.readdirSync(cipxDir)) {
fs.copyFileSync(path.join(cipxDir, file), path.join(releasesDir, file)); // .cipx 是插件包本体,校验清单跟着版本目录走
const to = file.toLowerCase().endsWith('.cipx') ? installerDir : versionDir;
fs.copyFileSync(path.join(cipxDir, file), path.join(to, file));
} }
log(`插件包已归集到 ${path.relative(repoRoot, releasesDir)}`); log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`);
for (const file of fs.readdirSync(releasesDir)) { for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`);
log(` ${file}`); for (const file of fs.readdirSync(portableDir)) log(` 免安装/ClassHelper.ClassIslandPlugin/${file}`);
}
@@ -495,7 +495,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/* ---------------------------------------------------------------- 内部实现 */ /* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary> /// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "1.1.1.0"; private const string PluginVersion = "1.1.2.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary> /// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion() private static string SafeAppVersion()
Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 250 KiB

+6 -1
View File
@@ -15,6 +15,8 @@ electronLanguages:
- zh-CN - zh-CN
directories: directories:
# 这里的 output 只是 electron-builder 的兜底值:scripts/dist-win.mjs 会把它覆盖成
# releases/client/<版本>/(与服务端、插件同一约定)。直接手敲 electron-builder 才会落到这里。
output: release output: release
buildResources: build buildResources: build
@@ -43,7 +45,10 @@ win:
arch: [x64] arch: [x64]
- target: portable - target: portable
arch: [x64] arch: [x64]
# 未提供 build/icon.ico 时使用 Electron 默认图标 # 应用/安装包图标。**显式指定**而不是靠 electron-builder 的默认约定(默认找 build/icon.ico):
# 现在生效的是仓库根 build/classhelper.{ico,png}(设计导出、手工放入),
# 而 `pnpm icons` 那条 SVG 流水线只会重写 icon.*,两者不打架。
icon: build/classhelper.ico
artifactName: ${productName}-${version}-${arch}.${ext} artifactName: ${productName}-${version}-${arch}.${ext}
nsis: nsis:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/desktop-client", "name": "@classhelper/desktop-client",
"version": "1.1.1", "version": "1.1.2",
"private": true, "private": true,
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)", "description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper", "author": "Class Helper",
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+70 -18
View File
@@ -10,7 +10,14 @@
* *
* 用法: * 用法:
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件 * node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
* node scripts/dist-win.mjs --dir # 仅生成免安装目录 release/win-unpacked * node scripts/dist-win.mjs --dir # 仅生成免安装目录
*
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
* 安装包/ 班级小助手-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
* 免安装/ electron-builder 的 win-unpacked 内容
* 构建中间/ builder-*.yml、*.nsis.7z
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
* 显式传入 `-c.directories.output=...` 时不覆盖,整理(tidyArtifacts)以传入的目录为准。
*/ */
import { execFileSync, spawn } from 'node:child_process'; import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
@@ -59,6 +66,14 @@ if (!fs.existsSync(cli)) {
process.exit(2); process.exit(2);
} }
// 交付产物落到 releases/client/<版本>/(与服务端、插件同一约定;releases/ 不入库)
const clientVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
const explicitOutput = passthrough.find((item) => item.includes('directories.output'));
const destDir = explicitOutput
? path.resolve(root, explicitOutput.slice(explicitOutput.indexOf('=') + 1))
: path.join(repoRoot, 'releases', 'client', clientVersion);
if (!explicitOutput) overrides.push(`--config.directories.output=${destDir}`);
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough]; const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough];
console.log('[dist] electron-builder', cliArgs.slice(1).join(' ')); console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
@@ -72,18 +87,18 @@ console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`);
// 清理上一轮产物:本仓库目录树带沙箱 ACL(含 Everyone 的 DENY(delete child)), // 清理上一轮产物:本仓库目录树带沙箱 ACL(含 Everyone 的 DENY(delete child)),
// 已存在的产物会被工具(makensis / 7za)判为"Can't open output file / 拒绝访问"而失败, // 已存在的产物会被工具(makensis / 7za)判为"Can't open output file / 拒绝访问"而失败,
// 手动删掉再打包即可 —— 顺手也保证产物集合确定,不会残留上一轮的旧安装包。 // 手动删掉再打包即可 —— 顺手也保证产物集合确定,不会残留上一轮的旧安装包。
const outputDir = path.join(root, 'release'); let cleaned = false;
if (fs.existsSync(outputDir)) { for (const name of ['win-unpacked', '免安装', '安装包', '构建中间']) {
for (const name of fs.readdirSync(outputDir)) { const target = path.join(destDir, name);
if (!/\.(exe|7z|blockmap|yml)$/i.test(name)) continue; if (!fs.existsSync(target)) continue;
try { try {
fs.rmSync(path.join(outputDir, name), { force: true }); fs.rmSync(target, { recursive: true, force: true });
} catch (error) { cleaned = true;
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`); } catch (error) {
} console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
} }
console.log('[dist] 已清理上一轮的安装包 / 归档');
} }
if (cleaned) console.log('[dist] 已清理上一轮的免安装目录 / 安装包 / 构建中间');
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env }); const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
@@ -105,21 +120,58 @@ const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env })
function normalizeIntegrityLabel(dir) { function normalizeIntegrityLabel(dir) {
if (process.platform !== 'win32') return; if (process.platform !== 'win32') return;
const run = (args) => execFileSync('icacls', args, { stdio: 'pipe', windowsHide: true }); const run = (args) => execFileSync('icacls', args, { stdio: 'pipe', windowsHide: true });
// 这一行才是"交付物能不能用"的关键,失败必须喊出来
try { try {
run([dir, '/setintegritylevel', 'Medium', '/T', '/C']); run([dir, '/setintegritylevel', 'Medium', '/T', '/C']);
// 目录自身恢复为"继承父目录的 Low",保证下次构建时 Low 的 7za 仍能写入归档 console.log('[dist] 已把交付物完整性标签修正为 Medium:', dir);
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
console.log('[dist] 已修正交付物完整性标签为 Medium,目录恢复继承:', dir);
} catch (error) { } catch (error) {
console.warn('[dist] 完整性标签修正跳过:', error.message); console.warn('[dist] 完整性标签修正失败(交付物可能装不上,见 AGENTS.md §7 第 7 条):', error.message);
}
// 目录自身恢复为"继承父目录的 Low",保证下次构建时带 Low 标签的 7za 仍能写进这个目录。
// 宿主目录树本来就没有 Low 标签体系时,这一步会被系统拒绝(保持继承即可),属正常,不提示。
try {
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
} catch {
/* 忽略 */
}
}
/**
* 把 electron-builder 的原始输出整理成 releases 的固定分层:
* win-unpacked/ → 免安装/
* *.exe / *.blockmap → 安装包/
* *.yml / *.nsis.7z → 构建中间/
* electron-builder 每次都会按自己的布局重写这些路径,因此整理只放在打包成功之后做;
* 同名文件用 renameSync 同盘移动,不会产生大文件的额外拷贝。
*/
function tidyArtifacts(dir) {
if (!fs.existsSync(dir)) return;
const moveInto = (from, sub) => {
const to = path.join(dir, sub);
fs.mkdirSync(to, { recursive: true });
fs.renameSync(from, path.join(to, path.basename(from)));
};
const unpacked = path.join(dir, 'win-unpacked');
const portableDir = path.join(dir, '免安装');
if (fs.existsSync(unpacked)) {
fs.rmSync(portableDir, { recursive: true, force: true });
fs.renameSync(unpacked, portableDir);
}
for (const name of fs.readdirSync(dir)) {
const from = path.join(dir, name);
if (!fs.statSync(from).isFile()) continue;
if (/\.(exe|blockmap)$/i.test(name)) moveInto(from, '安装包');
else if (/\.(yml|7z)$/i.test(name)) moveInto(from, '构建中间');
} }
} }
child.on('exit', (code) => { child.on('exit', (code) => {
if (code === 0) { if (code === 0) {
const output = path.join(root, 'release'); tidyArtifacts(destDir);
normalizeIntegrityLabel(output); normalizeIntegrityLabel(destDir);
console.log('\n[dist] 打包完成,产物目录:', output); console.log('\n[dist] 打包完成,产物目录:', destDir);
} }
process.exit(code ?? 1); process.exit(code ?? 1);
}); });
@@ -31,7 +31,9 @@ import { squirclePath } from './squircle.js';
* - 阴影:只在展开态,`0 2px 3px rgba(0,0,0,.11)`(WinIsland `draw_expanded_shadow`); * - 阴影:只在展开态,`0 2px 3px rgba(0,0,0,.11)`(WinIsland `draw_expanded_shadow`);
* - 排版:白字 + alpha 分级(.92/.72/.58),**所有文本 = 基础字号 × 排版系数**, * - 排版:白字 + alpha 分级(.92/.72/.58),**所有文本 = 基础字号 × 排版系数**,
* 因此「字号」设置一改,岛内文字整体等比缩放(WinIsland `font_size` 模型); * 因此「字号」设置一改,岛内文字整体等比缩放(WinIsland `font_size` 模型);
* - 动效:单调缓动(无过冲、无回弹),只有淡入淡出与窗口形变,符合"开合不震动"。 * - 动效:**单调缓动(无过冲、无回弹)**,只有淡入淡出与窗口形变,符合"开合不震动"。
* 曲线取自共享令牌(`--ch-ease-out`,见 packages/shared/src/motion.ts);
* 刻意**不用** beUI 那几组带过冲的弹簧 —— 理由写在文件末尾"悬停微交互"那段。
* *
* 窗口尺寸仍由主进程按状态缓动(见 src/main/island.ts),渲染进程负责形状、排版与交互回传。 * 窗口尺寸仍由主进程按状态缓动(见 src/main/island.ts),渲染进程负责形状、排版与交互回传。
*/ */
@@ -1201,11 +1203,7 @@ body {
color: var(--wn-text-1); color: var(--wn-text-1);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: background 0.15s ease; transition: background var(--ch-dur-fast) var(--ch-ease-out);
}
.list-row:hover {
background: var(--wn-surface);
} }
.row-icon { .row-icon {
@@ -1292,11 +1290,7 @@ body {
font-size: calc(var(--island-font, 13px) * 0.76); font-size: calc(var(--island-font, 13px) * 0.76);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.15s ease; transition: background var(--ch-dur-fast) var(--ch-ease-out);
}
.more-btn:hover {
background: var(--wn-surface-hover);
} }
.more-app { .more-app {
@@ -1430,13 +1424,8 @@ body {
cursor: pointer; cursor: pointer;
flex: 0 0 auto; flex: 0 0 auto;
transition: transition:
background 0.15s ease, background var(--ch-dur-fast) var(--ch-ease-out),
color 0.15s ease; color var(--ch-dur-fast) var(--ch-ease-out);
}
.icon-btn:hover {
background: var(--wn-surface-hover);
color: #fff;
} }
.icon-btn svg { .icon-btn svg {
@@ -1521,8 +1510,8 @@ body {
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
transition: transition:
background 0.15s ease, background var(--ch-dur-fast) var(--ch-ease-out),
transform 0.12s ease; transform var(--ch-dur-instant) var(--ch-ease-out);
} }
.ghost-btn { .ghost-btn {
@@ -1530,10 +1519,6 @@ body {
color: var(--wn-text-1); color: var(--wn-text-1);
} }
.ghost-btn:hover {
background: var(--wn-surface-hover);
}
.solid-btn { .solid-btn {
background: #ffffff; background: #ffffff;
color: #0b0b0f; color: #0b0b0f;
@@ -1554,35 +1539,40 @@ body {
transform: scale(0.97); transform: scale(0.97);
} }
/* ---------------------------------------------------------------- 过渡(无缩放、无回弹) */ /* --------------------------------------------------------------- 悬停微交互
*
* **必须关在 `@media (hover: hover) and (pointer: fine)` 里**:教室机器上的希沃触摸屏
* 点一下会产生"幽灵 hover"并**一直粘着**,直到点别处才消失 —— 不关的话,点过「知道了」
* 的按钮会永远保持高亮(同 AGENTS.md §5 第 30 条那类问题)。
* 这是 beUI `useHoverCapable` 的 CSS 版:纯样式效果用媒体查询就够,不必挂 composable。
*/
@media (hover: hover) and (pointer: fine) {
.list-row:hover {
background: var(--wn-surface);
}
.island-instant-enter-active { .more-btn:hover,
transition: none; .ghost-btn:hover {
background: var(--wn-surface-hover);
}
.icon-btn:hover {
background: var(--wn-surface-hover);
color: #ffffff;
}
} }
.island-instant-enter-from { /*
opacity: 1; * 关于本岛的动效:**只有单调缓动(无过冲、无回弹)**,曲线取自共享令牌
} * (`--ch-ease-out`,见 packages/shared/src/motion.ts)。
*
.island-instant-leave-active { * 这里刻意**不用** beUI 那几组弹簧:弹簧带过冲,而本岛的形变由主进程按状态缓动窗口尺寸
transition: opacity calc(var(--island-dur, 320ms) * 0.28) ease-out; * (`src/main/island.ts`),任何回弹都会让"开合"看起来在震动 ——
} * 冒烟断言「展开过程不震动」「收回过程不抖动」逐帧采样岛体几何,正是为这条立的规矩。
* 岛的物理是自己的一套(`./spring.ts`,从 WinIsland 逐行移植),不该被换掉。
.island-instant-leave-to { *
opacity: 0; * (这里原本还有 `.island-instant-*` / `.island-fade-*` 两组过渡类 —— 它们**是死规则**:
} * 全包只有 `<style>` 里出现,没有任何地方挂类名,是 §5 第 34 条删掉"收回快照"动画后的残留。
* 已删除,免得后人以为岛上存在一层 Vue `<Transition>`。)
/* 展开 / 收回:只做淡入淡出,形变交给窗口尺寸(避免任何缩放回弹"震动") */ */
.island-fade-enter-active {
transition: opacity calc(var(--island-dur, 320ms) * 0.45) ease-out;
}
.island-fade-leave-active {
transition: opacity calc(var(--island-dur, 320ms) * 0.3) ease-out;
}
.island-fade-enter-from,
.island-fade-leave-to {
opacity: 0;
}
</style> </style>
@@ -1,5 +1,6 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import IslandApp from './IslandApp.vue'; import IslandApp from './IslandApp.vue';
import { applyMotionTokens } from '../renderer/composables/motion.js';
/** /**
* 灵动岛渲染进程入口。 * 灵动岛渲染进程入口。
@@ -8,6 +9,19 @@ import IslandApp from './IslandApp.vue';
*/ */
const app = createApp(IslandApp); const app = createApp(IslandApp);
/**
* 动效令牌(beUI 的缓动曲线,见 packages/shared/src/motion.ts)写进 `:root`。
*
* **必须在这里也注入一次**:灵动岛是**另一个渲染进程**,不共享主窗口的 `:root`。
* 而 `IslandApp.vue` 的悬停微交互引用了 `var(--ch-dur-fast)` / `var(--ch-ease-out)`——
* 变量缺席会让整条 `transition` 简写失效(自定义属性解析不出来 → 声明作废),
* 结果是**完全没有过渡**,比改造前还差。
*
* 成本可控:多出来的是共享模块里的令牌常量与弹簧积分(约 2KB),
* 相比岛内已经打包的 Vue 可以忽略;换来的是"两端一岛同一套曲线、不各抄一份"。
*/
applyMotionTokens();
app.config.errorHandler = (error) => { app.config.errorHandler = (error) => {
console.error('[island] 渲染异常', error); console.error('[island] 渲染异常', error);
}; };
+112 -1
View File
@@ -1142,8 +1142,20 @@ async function runIslandChecks(
* (他常是 top-center + 较大 marginY,验证实例是 top-center + marginY=8)—— 两个岛一高一低叠在一起, * (他常是 top-center + 较大 marginY,验证实例是 top-center + marginY=8)—— 两个岛一高一低叠在一起,
* 用户会以为"屏幕上出现了第二个灵动岛",点它又"反应不对"(它归验证脚本控制,会自己展开/收起)。 * 用户会以为"屏幕上出现了第二个灵动岛",点它又"反应不对"(它归验证脚本控制,会自己展开/收起)。
* 只改**颜色**(不动停靠位置):命中/几何断言全部按默认位置写过,挪位置会把它们弄红(实测踩过)。 * 只改**颜色**(不动停靠位置):命中/几何断言全部按默认位置写过,挪位置会把它们弄红(实测踩过)。
*
* 例外:采集产品留档图时这两个值可以临时换成产品默认外观 ——
* `ELECTRON_SMOKE_ISLAND_ACCENT` / `ELECTRON_SMOKE_ISLAND_STYLE`(见 website/tools/capture-shots.mjs)。
* 留档图要的是"产品长什么样",不是"验证实例长什么样"。**日常跑验证不要设它们。**
*/ */
island.setAppearance({ accent: '#e91e8c', style: 'tinted' }); const shotAppearance: Parameters<typeof island.setAppearance>[0] = {
accent: process.env.ELECTRON_SMOKE_ISLAND_ACCENT ?? '#e91e8c',
};
if (process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'black' || process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'glass') {
shotAppearance.style = process.env.ELECTRON_SMOKE_ISLAND_STYLE;
} else if (!process.env.ELECTRON_SMOKE_ISLAND_STYLE) {
shotAppearance.style = 'tinted';
}
island.setAppearance(shotAppearance);
/** /**
* 强制关掉触摸模式:本节的命中/穿透/窗口几何断言全部是按"鼠标模式"写的(用 * 强制关掉触摸模式:本节的命中/穿透/窗口几何断言全部是按"鼠标模式"写的(用
* `setHitTestCursor` 注入光标位置代替挪动真实鼠标)。若跑在带触摸屏的机器上, * `setHitTestCursor` 注入光标位置代替挪动真实鼠标)。若跑在带触摸屏的机器上,
@@ -3526,6 +3538,94 @@ async function runIslandChecks(
* "HTTP 发布 → Socket.IO 广播 → 渲染进程 realtime store → bridge → IPC → 灵动岛"全链路, * "HTTP 发布 → Socket.IO 广播 → 渲染进程 realtime store → bridge → IPC → 灵动岛"全链路,
* 并在灵动岛里真实点击"标为已读",确认通知中心的未读状态同步更新。 * 并在灵动岛里真实点击"标为已读",确认通知中心的未读状态同步更新。
*/ */
/**
* 客户端逐页留档(官网素材用)。只有设了 `ELECTRON_CLIENT_SHOTS_DIR` 才会调用。
*
* 页面顺序就是侧边栏的顺序,方便照着做图注;走的是 hash 路由,与真实点击菜单等价。
* 作业页带 `?date=` 深链 —— 当天通常没有作业,空看板当素材没意义(日期取自
* `GET /homeworks/days`,种子数据里只有 2026-09-27 有作业)。
*/
const CLIENT_PAGE_SHOTS: Array<{ name: string; hash: string }> = [
{ name: 'client-schedule', hash: '#/schedule' },
{ name: 'client-homeworks', hash: '#/homeworks?date=2026-09-27' },
{ name: 'client-notifications', hash: '#/notifications' },
{ name: 'client-grades', hash: '#/grades' },
{ name: 'client-settings-island', hash: '#/settings/island' },
{ name: 'client-settings-appearance', hash: '#/settings/appearance' },
];
/** 页面里飘着的 ElMessage 提示会挡内容,截之前先收掉 */
const STRIP_TOASTS = `document.querySelectorAll('.el-message, .el-notification').forEach((node) => node.remove()); true;`;
/** 主内容区的纯文本长度(侧栏不算):用来判断页面到底渲染出来没有 */
const MAIN_TEXT_LENGTH = `((document.querySelector('.el-main.main') ?? document.body).innerText ?? '').trim().length`;
/**
* 截图前把 CSS 动画与过渡停掉。
*
* 为什么必须:客户端每个页面有「错峰入场」动画(styles/index.css 的 `.page > *`),
* 起始帧是 `opacity: 0`。窗口被别的窗口盖住时 Chromium 会冻结 CSS 动画,
* 于是 DOM 里文本齐全(实测 3000 多字)、截出来却是**整片空白** —— 排查了半天。
* `animation: none` 之后元素回落到自己的基础样式(可见),截图也顺带变成确定性的一帧。
*/
const FREEZE_ANIMATIONS = `(() => {
const id = 'smoke-shot-freeze';
if (!document.getElementById(id)) {
const style = document.createElement('style');
style.id = id;
style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; }';
document.head.append(style);
}
return true;
})()`;
const UNFREEZE_ANIMATIONS = `(() => {
document.getElementById('smoke-shot-freeze')?.remove();
return true;
})()`;
async function captureClientPages(win: BrowserWindow, dir: string): Promise<void> {
fs.mkdirSync(dir, { recursive: true });
await win.webContents.executeJavaScript(FREEZE_ANIMATIONS);
for (const page of CLIENT_PAGE_SHOTS) {
try {
for (let attempt = 1; attempt <= 3; attempt += 1) {
// 连点两次同样的 hash 不会触发路由,先回课表再过去,保证每次都真的重新渲染
await win.webContents.executeJavaScript(`location.hash = '#/schedule'; true;`);
await sleep(350);
await win.webContents.executeJavaScript(`location.hash = ${JSON.stringify(page.hash)}; true;`);
await sleep(1200);
await win.webContents.executeJavaScript(STRIP_TOASTS);
await sleep(200);
// 懒加载的视图偶尔会来不及挂上,量到空内容就重来一次
const length = await win.webContents.executeJavaScript(MAIN_TEXT_LENGTH);
if (typeof length === 'number' && length >= 60) break;
if (attempt === 3) {
console.warn(`[SHOTS] ${page.name} 内容区一直是空的(最后一次 ${length} 字),跳过`);
break;
}
await sleep(900);
}
const length = await win.webContents.executeJavaScript(MAIN_TEXT_LENGTH);
if (typeof length !== 'number' || length < 60) continue;
const image = await win.webContents.capturePage();
if (image.isEmpty()) {
console.warn(`[SHOTS] ${page.name} 截到空图,跳过`);
continue;
}
const file = path.join(dir, `${page.name}.png`);
fs.writeFileSync(file, image.toPNG());
console.log(`[SHOTS] ${page.name} → ${file}(内容 ${length} 字)`);
} catch (error) {
console.warn(`[SHOTS] ${page.name} 截图失败`, error);
}
}
await win.webContents.executeJavaScript(UNFREEZE_ANIMATIONS);
}
async function runIslandRealtimeCheck( async function runIslandRealtimeCheck(
win: BrowserWindow, win: BrowserWindow,
): Promise<{ delivered: boolean; deliveryDetail: string; markRead: boolean; markReadDetail: string }> { ): Promise<{ delivered: boolean; deliveryDetail: string; markRead: boolean; markReadDetail: string }> {
@@ -4454,6 +4554,17 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
); );
if (injectedId) await deleteSmokeNotification(injectedId); if (injectedId) await deleteSmokeNotification(injectedId);
/**
* 官网素材:逐页留档。只有设了 ELECTRON_CLIENT_SHOTS_DIR 才跑 —— 日常验证不受影响。
*
* 位置两头的约束把这里夹死了:
* - 必须在**收尾清理登录态之前**,否则截出来的是登录页 + 缓存数据;
* - 必须在**所有 UI 断言之后**:翻页会改变侧栏分组展开态,紧接着测「折叠后图标与汉堡同列」
* 就会量到过渡中间值(实测:宽度 200→77、中心差 3.2px,把那条用例弄红了)。
*/
const clientShotsDir = process.env.ELECTRON_CLIENT_SHOTS_DIR ?? '';
if (clientShotsDir) await captureClientPages(win, clientShotsDir);
const cleanup = await win.webContents.executeJavaScript( const cleanup = await win.webContents.executeJavaScript(
`(async () => { `(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' }; if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
+54 -1
View File
@@ -137,7 +137,17 @@ onUnmounted(() => {
<div v-if="booting" class="boot-screen"> <div v-if="booting" class="boot-screen">
<el-icon :size="30" class="is-loading" color="#409eff"><Loading /></el-icon> <el-icon :size="30" class="is-loading" color="#409eff"><Loading /></el-icon>
<p class="boot-title">班级小助手</p> <p class="boot-title">班级小助手</p>
<p class="boot-text">{{ bootText }}</p> <!--
启动文案会依次经过「读取本地配置 → 恢复登录状态」,直接换字是一下"啪"的跳变。
交叉淡入(旧的上移淡出、新的从下方淡入)把两次状态读成**同一行字在更新**,
而不是"屏幕闪了一下"。
`:duration` 是必需的(理由同 ClientLayout 的页面过渡):靠 `animationend` 判断的话,
窗口不可见时动画被冻结、事件不触发,文案会一直停在第一句。
-->
<transition name="boot-text" mode="out-in" :duration="{ enter: 200, leave: 90 }">
<p class="boot-text" :key="bootText">{{ bootText }}</p>
</transition>
</div> </div>
<router-view v-else /> <router-view v-else />
<!-- 初次启动引导:常驻挂载,由 onboarding store 控制显隐(登录页/主界面/设置页都能唤起) --> <!-- 初次启动引导:常驻挂载,由 onboarding store 控制显隐(登录页/主界面/设置页都能唤起) -->
@@ -160,6 +170,19 @@ onUnmounted(() => {
margin: 6px 0 0; margin: 6px 0 0;
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
/* 启动页是"整个应用的第一帧",给标题一点入场分量是值得的 */
animation: boot-title-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
}
@keyframes boot-title-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
} }
.boot-text { .boot-text {
@@ -167,4 +190,34 @@ onUnmounted(() => {
font-size: 13px; font-size: 13px;
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
} }
@keyframes boot-text-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes boot-text-out {
from {
opacity: 1;
transform: none;
}
to {
opacity: 0;
transform: translateY(-4px);
}
}
.boot-text-enter-active {
animation: boot-text-in var(--ch-dur-base) var(--ch-ease-out) both;
}
.boot-text-leave-active {
animation: boot-text-out var(--ch-dur-instant) var(--ch-ease-out) both;
}
</style> </style>
+9 -1
View File
@@ -993,9 +993,17 @@ export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
Number.isFinite(asideTransitionSeconds) && Number.isFinite(asideTransitionSeconds) &&
asideTransitionSeconds > 0.05; asideTransitionSeconds > 0.05;
if (!clickToggle('[data-test="sidebar-toggle"]')) return { ok: false, detail: '汉堡按钮点击失败' }; if (!clickToggle('[data-test="sidebar-toggle"]')) return { ok: false, detail: '汉堡按钮点击失败' };
/*
* 判据是「宽度收拢到**稳态**」而不是「宽度小于 80」。
*
* 折叠的目标宽度是 64px(ClientLayout 的 `:width="ui.sidebarCollapsed ? '64px' : '200px'"`),
* 而宽度与菜单项内边距是同一条过渡在驱动 —— 只等「≤80」会在动画还剩几十毫秒时就往下走,
* 下面量图标中心时内边距仍在变化,量到的是中间帧而非稳态(实测出现过 0.5px 与 2.3px 两个值,
* 全看当时机器有多忙)。等它真正到位之后再量,这条断言才是它想表达的"折叠后同列"。
*/
const collapsed = await waitUntil(() => { const collapsed = await waitUntil(() => {
const aside = document.querySelector<HTMLElement>('.aside.ch-nav'); const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
return Boolean(aside?.classList.contains('is-collapsed')) && (aside?.offsetWidth ?? 999) <= 80; return Boolean(aside?.classList.contains('is-collapsed')) && (aside?.offsetWidth ?? 999) <= 65;
}, 4000); }, 4000);
const asideAfter = document.querySelector<HTMLElement>('.aside.ch-nav'); const asideAfter = document.querySelector<HTMLElement>('.aside.ch-nav');
const widthAfter = asideAfter?.offsetWidth ?? 0; const widthAfter = asideAfter?.offsetWidth ?? 0;
@@ -0,0 +1,239 @@
/**
* 动效组合式函数 —— beUI 那套动效语言在客户端的落地层。
*
* 令牌的**数值**在 `@classhelper/shared` 的 `motion.ts`(单一来源);
* 这里只放需要 DOM / Vue 的部分。内容与 Web 管理端的
* `packages/web-admin/src/composables/motion.ts` 一致 —— 与 `ScoreBarChart.vue`
* 一样,两端各一份是仓库既有做法。
*
* 客户端相较 Web 端多一层现实约束:教室机器多为**希沃触摸屏**,
* 触摸点按会产生粘住的幽灵 hover,因此 `useHoverCapable()` 在客户端是**必需**的,
* 不是锦上添花(见 AGENTS.md §5 第 30 条)。
*/
import { computed, onUnmounted, ref, watch, type CSSProperties, type Ref } from 'vue';
import {
EASE_OUT,
HOVER_CAPABLE_QUERY,
MOTION_DURATION,
REDUCED_MOTION_QUERY,
cubicBezierCss,
motionCssVars,
} from '@classhelper/shared';
/**
* 把动效令牌写进 `:root` 的自定义属性。
*
* **必须在 `app.mount()` 之前调用**:那样 CSS 里可以放心写 `var(--ch-spring-panel)`,
* 首帧之前就已就位,既没有 FOUC,也不需要到处写兜底值。
*
* ⚠️ 只给**主渲染进程**注入。灵动岛是另一个渲染进程(`src/island/main.ts`),
* 它有自己那套从 WinIsland 移植的弹簧物理,刻意不参与这里的动效体系。
*
* 弹簧用 CSS `linear()` 表达;Electron 44(Chromium 13x)原生支持,
* 仍保留 `cubic-bezier` 回落分支以防将来降版本。
*/
export function applyMotionTokens(): void {
if (typeof document === 'undefined') return;
const linear =
typeof CSS !== 'undefined' &&
typeof CSS.supports === 'function' &&
CSS.supports('transition-timing-function', 'linear(0, 1)');
for (const [name, value] of Object.entries(motionCssVars({ linear }))) {
document.documentElement.style.setProperty(name, value);
}
}
/**
* 监听 `prefers-reduced-motion`。
*
* 三元表达式而非直接返回 `matchMedia(...)` 的结果:这条偏好可能在运行中被改动,
* 必须跟着变;JS 驱动的动画(数字滚动、位置测量)拿不到 CSS 那层自动降级,得自己看这个值。
*/
export function useReducedMotion(): Ref<boolean> {
const matches = ref(false);
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return matches;
const query = window.matchMedia(REDUCED_MOTION_QUERY);
matches.value = query.matches;
const update = (): void => {
matches.value = query.matches;
};
// Electron 的 Chromium 支持 addEventListener;保留旧接口兜底
if (typeof query.addEventListener === 'function') {
query.addEventListener('change', update);
onUnmounted(() => query.removeEventListener('change', update));
} else if (typeof query.addListener === 'function') {
query.addListener(update);
onUnmounted(() => query.removeListener(update));
}
return matches;
}
/**
* 是否有真正的悬停能力(鼠标 / 触控板)。
*
* 触摸设备在点按时会触发**幽灵 hover** 并一直粘着,直到点别处才消失 ——
* 教室里的希沃触摸屏正属于这种,所有 hover-only 效果(抬升、磁吸、光标跟随)
* 都必须先过这一关,否则触摸屏上会出现"点一下,卡片就永远浮着"。
*/
export function useHoverCapable(): Ref<boolean> {
const capable = ref(false);
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return capable;
const query = window.matchMedia(HOVER_CAPABLE_QUERY);
capable.value = query.matches;
const update = (): void => {
capable.value = query.matches;
};
if (typeof query.addEventListener === 'function') {
query.addEventListener('change', update);
onUnmounted(() => query.removeEventListener('change', update));
} else if (typeof query.addListener === 'function') {
query.addListener(update);
onUnmounted(() => query.removeListener(update));
}
return capable;
}
/** 缓动函数:给定进度 t∈[0,1],返回缓动后的进度。即 EASE_OUT 那条贝塞尔的求值。 */
function easeOutProgress(t: number): number {
const [x1, y1, x2, y2] = EASE_OUT;
// 牛顿迭代反解 x(u) = t,再用同一个 u 求 y —— 三次贝塞尔求值的标准做法
let u = t;
for (let i = 0; i < 6; i += 1) {
const x = 3 * (1 - u) ** 2 * u * x1 + 3 * (1 - u) * u ** 2 * x2 + u ** 3;
const dx = 3 * (1 - u) ** 2 * x1 + 6 * (1 - u) * u * (x2 - x1) + 3 * u ** 2 * (1 - x2);
if (Math.abs(dx) < 1e-6) break;
u = Math.min(1, Math.max(0, u - (x - t) / dx));
}
return 3 * (1 - u) ** 2 * u * y1 + 3 * (1 - u) * u ** 2 * y2 + u ** 3;
}
export interface CountUpOptions {
/** 时长(毫秒)。默认 `MOTION_DURATION.slow`。 */
duration?: number;
}
/**
* 数字滚动:目标值变化时从当前显示值滚过去,而不是直接跳。
*
* `reduced-motion` 下**直接落位**(不启动 rAF)。
*
* 刻意**不**给"首次变化"开豁免:数据是异步加载的,挂载那一刻目标值还是 0,
* 真正想看到滚动的恰恰是"0 → 实际值"这第一跳;而如果数据在挂载时就已就位
* (离线缓存命中),`displayed` 初始化即等于目标值、根本不会触发 watch。
*/
export function useCountUp(source: () => number, options: CountUpOptions = {}): Ref<number> {
const reduce = useReducedMotion();
const duration = options.duration ?? MOTION_DURATION.slow;
const displayed = ref(source());
let frame = 0;
function stop(): void {
if (frame) {
cancelAnimationFrame(frame);
frame = 0;
}
}
watch(source, (target) => {
stop();
if (reduce.value || duration <= 0) {
displayed.value = target;
return;
}
const from = displayed.value;
const delta = target - from;
if (delta === 0) return;
const start = performance.now();
const tick = (now: number): void => {
const progress = Math.min(1, (now - start) / duration);
displayed.value = from + delta * easeOutProgress(progress);
if (progress < 1) frame = requestAnimationFrame(tick);
else {
displayed.value = target;
frame = 0;
}
};
frame = requestAnimationFrame(tick);
});
onUnmounted(stop);
return displayed;
}
/**
* 列表错峰入场:返回一个"给第 index 项算内联样式"的函数,
* 配合 CSS 类 `.ch-reveal`(见 `styles/index.css`)使用。
*
* 延迟**封顶**(默认第 8 项之后不再累加):一屏几十行时,最后一行不该等一秒才出现。
*/
export function useStaggerIn(step = 35, maxIndex = 8): (index: number) => CSSProperties {
const reduce = useReducedMotion();
return (index: number): CSSProperties =>
reduce.value ? {} : ({ '--ch-reveal-delay': `${Math.min(index, maxIndex) * step}ms` } as CSSProperties);
}
/** 把 reduced-motion 折成一个布尔,供模板里判断"要不要挂动画类"。 */
export function useMotionEnabled(): Ref<boolean> {
const reduce = useReducedMotion();
return computed(() => !reduce.value);
}
/**
* 整页换肤的揭示动画(beUI 的 `theme-toggle`):新旧两帧各存一张快照,
* 让新帧从一个**从点击位置扩散的圆**里长出来 —— 这是"整个界面换了张皮"这件事
* 唯一能让人**看见**的表达方式,比整页硬切一下好懂得多。
*
* 返回的函数直接挂 `@click`:`@click="reveal($event, () => ui.setTheme(next))"`。
*
* 三条守卫缺一不可:
* ① 不认识 View Transition API 的浏览器直接走同步切换;
* ② 系统开了「减少动态效果」时不放全屏动画;
* ③ **文档不可见时不放**(最小化、被遮挡、无头浏览器)—— 那种情况下浏览器不产生帧,
* 而 View Transition 的更新回调要等到下一个渲染时机才执行,**主题会一直不切**。
* 教室机器上窗口被别的窗口盖住是常态,这条尤其要守。
*/
export function useThemeReveal(): (event: MouseEvent, apply: () => void) => void {
const reduce = useReducedMotion();
return (event, apply) => {
const doc = document as Document & {
startViewTransition?: (callback: () => void) => { ready: Promise<void> };
};
if (
typeof doc.startViewTransition !== 'function' ||
reduce.value ||
document.visibilityState !== 'visible'
) {
apply();
return;
}
const { clientX: x, clientY: y } = event;
void doc
.startViewTransition(apply)
.ready.then(() => {
// 半径取到最远的那个角,圆才能把整屏吃完
const radius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
{
// 比常规过渡长一点 —— 它是一次**整屏**的变化,太快会读成"闪了一下"而不是"换了一层皮"
duration: MOTION_DURATION.slow * 1.5,
// 缓动取自共享令牌,别在这里手写曲线字面量(那是漂移的起点)
easing: cubicBezierCss(EASE_OUT),
pseudoElement: '::view-transition-new(root)',
},
);
})
.catch(() => undefined);
};
}
@@ -3,6 +3,19 @@ import { DEFAULT_SERVER_URL } from '@classhelper/shared';
/** 默认后端地址(首次启动的预填值) */ /** 默认后端地址(首次启动的预填值) */
export const DEFAULT_SERVER = DEFAULT_SERVER_URL; export const DEFAULT_SERVER = DEFAULT_SERVER_URL;
/**
* 品牌标地址(侧栏与登录页的 `<img>` 用它)。
*
* 模板里既不能写 `src="logo.png"`、也不能写 `src="/logo.png"`:
* ① 字面量的**相对** `src` 会被 Vite 当成模块导入去解析 —— 构建直接失败
* (`Rolldown failed to resolve import "logo.png" from "LoginView.vue"`);
* ② 根绝对路径在打包形态下是坏的:Electron 用 `file://` 加载
* `dist/renderer/index.html`,`/logo.png` 会解析到**盘符根目录**。
* 所以只能「绑定属性 + 跟着 vite base 走」:base 为 `'./'`,结果是 `./logo.png`,
* 开发态(5174 静态服务)与打包态(`dist/renderer/logo.png`)都指向 public/ 拷过去的那份。
*/
export const BRAND_LOGO_URL = `${import.meta.env.BASE_URL}logo.png`;
/** 由服务器地址推导 API 基地址,例如 http://127.0.0.1:4000 -> http://127.0.0.1:4000/api */ /** 由服务器地址推导 API 基地址,例如 http://127.0.0.1:4000 -> http://127.0.0.1:4000/api */
export function apiBaseOf(serverUrl: string): string { export function apiBaseOf(serverUrl: string): string {
return `${serverUrl.trim().replace(/\/+$/, '')}/api`; return `${serverUrl.trim().replace(/\/+$/, '')}/api`;
@@ -4,6 +4,8 @@ import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus'; import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js'; import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
import { useThemeReveal } from '../composables/motion.js';
import { BRAND_LOGO_URL } from '../config.js';
import { useAppStore } from '../stores/app.js'; import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js'; import { useNotificationStore } from '../stores/notifications.js';
@@ -51,6 +53,115 @@ const menuGroups = [
}, },
]; ];
/* -------------------------------------------------------------- 导航指示器 */
/**
* 侧栏激活项的**滑动指示器**(beUI 的 `shared-layout-bg` / tabs 的 layoutId 手法)。
*
* 做法是**一个**绝对定位的底片跟着激活项平移,而不是让每个菜单项各自画一块底色 ——
* 后者在切换时是"旧的瞬间消失、新的瞬间出现",看不出两者之间的关系。
*
* 位置靠**量 DOM**:菜单项的位置由 Element Plus 的 padding 与行高决定,
* 写死会在组件库升级或文案变长时错位;折叠成图标导轨时宽度还会变,量出来才跟得上。
*
* ⚠️ 这里用 `document.querySelector` 而不是模板 ref:`.aside` 是 `el-aside` **组件**,
* 模板 ref 拿到的是组件实例(`.querySelector` 不是函数,实测踩过);
* 客户端只有一个主侧栏,直接查文档反而更直白。
*/
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
function syncIndicator(): void {
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
const active = aside?.querySelector<HTMLElement>('.el-menu-item.is-active');
if (!aside || !active) {
indicator.value = { ...indicator.value, visible: false };
return;
}
const asideRect = aside.getBoundingClientRect();
const rect = active.getBoundingClientRect();
indicator.value = {
x: rect.left - asideRect.left,
y: rect.top - asideRect.top,
width: rect.width,
height: rect.height,
visible: rect.width > 0 && rect.height > 0,
};
}
const indicatorStyle = computed(() => ({
width: `${indicator.value.width}px`,
height: `${indicator.value.height}px`,
transform: `translate(${indicator.value.x}px, ${indicator.value.y}px)`,
opacity: indicator.value.visible ? '1' : '0',
}));
let asideObserver: ResizeObserver | null = null;
let indicatorFrame = 0;
/**
* 把测量推迟到下一帧再跑。
*
* 侧栏折叠时 `.aside` 的宽度有一整段过渡,`ResizeObserver` 会逐帧回调;若在回调里**同步**
* 读 `getBoundingClientRect()` 再写样式,就是在每一帧中间强行插入一次同步布局
* (Chromium 还会因此报 "ResizeObserver loop completed with undelivered notifications")。
* 放进 rAF 就变成"本帧布局完成后统一量一次",代价是跟随慢一帧,肉眼无感。
*/
function scheduleIndicatorSync(): void {
if (indicatorFrame) return;
indicatorFrame = requestAnimationFrame(() => {
indicatorFrame = 0;
syncIndicator();
});
}
/**
* 首次测量放在 `onMounted`:子组件先于父组件挂载完成,此刻 `.el-menu-item.is-active`
* 已在文档里,量完再让浏览器首帧绘制 —— 指示器第一次出现就是正确位置,不会从左上角滑过去。
*
* `ResizeObserver` 不是为了窗口缩放,而是为了**侧栏折叠**:折叠时 `.aside` 的宽度
* 有一整段过渡(`--nav-dur`),观察它会逐帧回调,指示器于是跟着栏宽一起收 ——
* 否则图标导轨形成后,底片还停在展开态的宽度上。
*/
onMounted(() => {
syncIndicator();
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
if (aside && typeof ResizeObserver !== 'undefined') {
asideObserver = new ResizeObserver(scheduleIndicatorSync);
asideObserver.observe(aside);
}
});
onUnmounted(() => {
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
indicatorFrame = 0;
asideObserver?.disconnect();
asideObserver = null;
});
// 路由变化后菜单的 active 类由 Element Plus 更新,等一拍再量
watch(
() => route.path,
() => {
void nextTick(syncIndicator);
},
);
/* ---------------------------------------------------------- 主题切换动画 */
/**
* 顶栏的日夜快捷切换。
*
* "从点击位置扩散的圆"那套整页揭示由 `useThemeReveal()` 提供 ——
* **与 Web 管理端共用同一份实现**(两端 `composables/motion.ts` 里的同名函数),
* 这里只负责把"点击事件"和"切换主题"接起来。那三条守卫(不支持 View Transition /
* 减少动态效果 / 文档不可见)都在组合式函数里,别在这里再判一遍。
*/
const revealThemeChange = useThemeReveal();
function handleThemeToggle(event: MouseEvent): void {
revealThemeChange(event, () => ui.toggleTheme());
}
/** 未读红点挂在「通知」项的图标右上角 */ /** 未读红点挂在「通知」项的图标右上角 */
function showBadge(path: string): boolean { function showBadge(path: string): boolean {
return path === '/notifications' && notifications.unreadCount > 0; return path === '/notifications' && notifications.unreadCount > 0;
@@ -186,11 +297,12 @@ onUnmounted(() => {
</button> </button>
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出: <!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 --> v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
<span class="brand-logo"> <img class="brand-logo" :src="BRAND_LOGO_URL" alt="" width="26" height="26" />
<el-icon :size="16"><School /></el-icon>
</span>
<span class="brand-text">班级小助手</span> <span class="brand-text">班级小助手</span>
</div> </div>
<!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator" :style="indicatorStyle" aria-hidden="true" />
<el-menu <el-menu
ref="menuRef" ref="menuRef"
:default-active="activeMenu" :default-active="activeMenu"
@@ -256,7 +368,7 @@ onUnmounted(() => {
circle circle
data-test="theme-toggle" data-test="theme-toggle"
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" :aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
@click="ui.toggleTheme()" @click="handleThemeToggle"
> >
<el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon> <el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon>
</el-button> </el-button>
@@ -293,6 +405,19 @@ onUnmounted(() => {
title="当前处于离线状态" title="当前处于离线状态"
:description="`服务器不可达,正在显示本地缓存数据(最近同步:${appStore.lastSyncText})。恢复网络后会自动同步。`" :description="`服务器不可达,正在显示本地缓存数据(最近同步:${appStore.lastSyncText})。恢复网络后会自动同步。`"
/> />
<!--
这里**刻意不用** `<transition>` 包 router-view。
页面切换的动效由 `.page > *` 的错峰入场承担(styles/index.css)——
那是随组件挂载同步触发的,不推迟任何东西。
包一层过渡(尤其 `mode="out-in"`)会把"旧组件卸载、新组件挂载"推迟到退场动画结束
(90ms 起)。代价有两个,都不值得:
① 点菜单后有一小段时间页面上还是**上一页**,用户和自动化看到的都是旧内容;
② 退场判定依赖 `animationend`,窗口被遮挡时 Chromium 冻结 CSS 动画、
事件永不触发,新页面就永远不挂载 —— 整个客户端卡死在当前页。
教室机器上窗口被别的东西盖住是常态,这条尤其要守。
真要加回来,必须先确认这两条都有人兜住。
-->
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<component :is="Component" /> <component :is="Component" />
</router-view> </router-view>
@@ -315,6 +440,49 @@ onUnmounted(() => {
菜单自己的滚动由内部的 .menu(flex:1 + min-height:0 + overflow-y:auto)负责。 */ 菜单自己的滚动由内部的 .menu(flex:1 + min-height:0 + overflow-y:auto)负责。 */
overflow: hidden; overflow: hidden;
transition: width var(--nav-dur) var(--nav-ease); transition: width var(--nav-dur) var(--nav-ease);
/* 滑动指示器(.nav-indicator)的定位父级 */
position: relative;
}
/*
* 导航指示器:一个跟着激活项平移的底片,承载了原先"激活项底色 + 左侧强调条"两件事。
*
* 位置与尺寸都由 syncIndicator() 量出来后内联下发,这里只负责外观与过渡。
* 缓动用 LAYOUT 弹簧(397ms)—— 它是"共享布局滑动"的专用参数:比 SWAP(263ms)更有分量,
* 因为这一跳可能跨越好几个菜单项,太快会显得"闪"而不是"滑"。
*
* 折叠成图标导轨时,栏宽有一整段 260ms 的过渡,ResizeObserver 会逐帧把新宽度送进来,
* 底片于是跟着栏宽一起收窄 —— 两个时长不一致是**故意的**:底片比栏慢一点,
* 读起来像"被栏宽带着走",同步反而显得僵硬。
*/
.nav-indicator {
position: absolute;
top: 0;
left: 0;
z-index: 0;
border-radius: var(--ch-radius-control);
background: var(--ch-accent-soft);
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
pointer-events: none;
will-change: transform;
transition:
transform var(--ch-spring-layout-dur) var(--ch-spring-layout),
width var(--ch-spring-layout-dur) var(--ch-spring-layout),
height var(--ch-spring-layout-dur) var(--ch-spring-layout),
opacity var(--ch-dur-fast) var(--ch-ease-out);
}
/* 左侧强调条(原有观感,现在跟着底片一起滑) */
.nav-indicator::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: var(--ch-accent);
} }
.brand { .brand {
@@ -361,18 +529,23 @@ onUnmounted(() => {
padding-left: 18px; padding-left: 18px;
} }
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */ /*
* 品牌标:与应用图标同一份图(`public/logo.png`,由 `pnpm icons` 从 `build/classhelper.png` 生成)。
* 上一版这里是"强调色渐变方块 + 白色 School 字形"手搓的占位标,换成真图标之后
* `background` / `color` / `border-radius` / `overflow` 全部撤掉 —— 圆角与透明边角本来就在图里,
* 再叠一层只会出现双重圆角。
*
* `src` 走 `BRAND_LOGO_URL`(见 `config.ts`):模板里既不能写字面量的相对 `src`(Vite 会把它
* 当模块导入去解析、构建失败),也不能写根绝对路径(Electron 生产环境走 file://,会解析到盘符根目录)。
*
* 折叠态的 `.is-collapsed .brand-logo { width: 0 }` 照旧生效:img 的 width 一样可过渡,
* 而且它本来就带 opacity 淡出,收放观感与改造前完全一致。
*/
.brand-logo { .brand-logo {
display: inline-flex; display: block;
align-items: center;
justify-content: center;
width: 26px; width: 26px;
height: 26px; height: 26px;
flex: 0 0 auto; flex: 0 0 auto;
border-radius: 6px;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
overflow: hidden;
transition: transition:
width var(--nav-dur) var(--nav-ease), width var(--nav-dur) var(--nav-ease),
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease); opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
@@ -9,6 +9,7 @@ import 'element-plus/theme-chalk/dark/css-vars.css';
import App from './App.vue'; import App from './App.vue';
import router from './router/index.js'; import router from './router/index.js';
import { registerAppIcons } from './icons.js'; import { registerAppIcons } from './icons.js';
import { applyMotionTokens } from './composables/motion.js';
import './styles/index.css'; import './styles/index.css';
const app = createApp(App); const app = createApp(App);
@@ -37,5 +38,17 @@ if (window.desktop?.smokeTest) {
registerSmokeHooks(); registerSmokeHooks();
} }
/**
* 动效令牌(beUI 那套缓动与弹簧,见 packages/shared/src/motion.ts)写进 :root。
*
* 必须在 mount 之前:CSS 里引用的 `var(--ch-spring-*)` 要在首帧之前就已就位,
* 否则第一帧会按"变量不存在"渲染一遍(表现为入场动画闪一下)。
* 也刻意排在主题恢复(stores/ui.ts 的 init)之前 —— 令牌与主题互不相干,
* 但先落令牌能让首屏的入场动画从第一帧起就是对的节奏。
*
* ⚠️ 灵动岛是**另一个渲染进程**(src/island/main.ts),不经过这里。
*/
applyMotionTokens();
await router.isReady(); await router.isReady();
app.mount('#app'); app.mount('#app');
@@ -57,6 +57,17 @@
* 侧栏收放动画。组件(ClientLayout 的 .aside/.brand/.brand-logo/.aside-footer) * 侧栏收放动画。组件(ClientLayout 的 .aside/.brand/.brand-logo/.aside-footer)
* 与全局的菜单导轨规则(下面的 .ch-nav.is-collapsed …)共用这一份参数, * 与全局的菜单导轨规则(下面的 .ch-nav.is-collapsed …)共用这一份参数,
* 改时长/缓动只改这里,两边不会漂。 * 改时长/缓动只改这里,两边不会漂。
*
* ⚠️ 缓动**刻意保留 Material 的 cubic-bezier(.4, 0, .2, 1)**,没有换成全站统一的
* beUI EASE_OUT。试过的后果:EASE_OUT 起步极快,宽度掉到 80px 以下时动画才走了约 35%,
* 而冒烟自检 layoutChromeSelfTest 一旦读到"宽度 ≤ 80"就立刻量图标中心 ——
* 此刻内边距还在动,量出来 6 个分组子项偏 2.3px(要求 ≤2px),直接判红。
* 原曲线的 0.882 进度点落在 74% 处,剩下的时间足够动画跑完,量到的是稳态。
* 换句话说:**这条断言是按"收完之后的几何"写的,而它采样的时机取决于缓动的形状**,
* 想换曲线就得连那条断言一起重新商量。
*
* ⚠️ 时长**不要**改到 0.05s 以下:同一条自检断言"折叠必须真的是动画"
* (读 getComputedStyle 的 transitionDuration,要求 > 0.05)。
*/ */
--nav-dur: 260ms; --nav-dur: 260ms;
--nav-ease: cubic-bezier(0.4, 0, 0.2, 1); --nav-ease: cubic-bezier(0.4, 0, 0.2, 1);
@@ -381,22 +392,20 @@ textarea {
background: transparent; background: transparent;
} }
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */ /*
.ch-nav .el-menu-item.is-active { * 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式)。
background: var(--ch-accent-soft); *
color: var(--ch-text); * 底色与强调条**都改由滑动指示器承担**(见 ClientLayout.vue 的 `.nav-indicator`)——
* 原先每个激活项各画一块,切换时是"旧的瞬间消失、新的瞬间出现",看不出两者之间的关系;
* 现在由同一个底片滑过去。指示器是 z-index:0 的绝对定位元素,菜单项抬到 1 层,
* 文字才不会被它盖住(`.ch-nav .el-menu-item` 上面已有 `position: relative`)。
*/
.ch-nav .el-menu-item {
z-index: 1;
} }
.ch-nav .el-menu-item.is-active::before { .ch-nav .el-menu-item.is-active {
content: ''; color: var(--ch-text);
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: var(--ch-accent);
} }
/* ---------------------------------------------------------------- 折叠态:图标导轨 /* ---------------------------------------------------------------- 折叠态:图标导轨
@@ -532,3 +541,408 @@ textarea {
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
/* ============================================================================
* 动效层 —— beUI 的动效语言
*
* 令牌(缓动曲线、六组弹簧、时长)在 `packages/shared/src/motion.ts`,
* 由 `main.ts` 在 mount 之前写进 `:root`;这里只负责**用**它们。
* 与 Web 管理端(packages/web-admin/src/styles/index.css)是同一套词汇表。
*
* ⚠️ 灵动岛**不在**这套体系里:它是另一个渲染进程,用自研的 WinIsland 弹簧物理
* (src/island/spring.ts),刻意保持独立,不要往那边接这里的变量。
*
* 分三层,越往下越"重":
* ① 变量调音 —— 改写 Element Plus 自己的过渡变量,一次性改掉全站组件库过渡;
* ② 弹簧覆盖 —— 弹窗 / 抽屉 / 浮层换成真正的物理曲线;
* ③ 自有动效 —— 页面过渡、列表错峰入场、按钮按压、卡片抬升。
* ========================================================================== */
/* ------------------------------------------------- ① 变量调音(覆盖组件库)
*
* Element Plus 的每个过渡都由这几个变量拼出来(`--el-transition-all` / `-fade` /
* `-md-fade`),调这里等于一次性调完全站,比逐个覆盖类名安全。
*
* Fluent/ClassIsland 观感本来就偏"轻快",原来 300/200ms 的默认值在这套界面里更显拖沓,
* 收到 200/140ms;缓动换成 beUI 的 EASE_OUT。
*
* 只改时长与缓动、不改颜色,所以不需要在 html.dark 里再写一份。
* 侧栏的 `--nav-ease` 也在文件开头的 :root 里改成了同一条曲线(与折叠几何放在一起,
* 免得"改侧栏动画要翻两个地方")。
*/
:root {
--el-transition-duration: 200ms;
--el-transition-duration-fast: 140ms;
--el-transition-function-ease-in-out-bezier: cubic-bezier(0.16, 1, 0.3, 1);
--el-transition-function-fast-bezier: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ------------------------------------------------------------ ② 弹簧覆盖 */
/*
* 弹窗(el-dialog)。Element Plus 的入场做在 `.el-overlay-dialog`(居中容器)上,
* 遮罩淡入在 `.dialog-fade-enter-active` 自己身上,两条都要换 ——
* 否则遮罩先到位、弹窗还在动,看起来是"背景已经变暗了但框还没出来"。
*
* 弹簧 PANEL 总时长 463ms 看着偏长,但它 95% 的行程在 262ms 就走完了,
* 剩下的是极慢的收尾 —— 观感上就是"200ms 到位、再轻轻落定一下"。
*/
@keyframes ch-panel-in {
from {
opacity: 0;
transform: translateY(-12px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes ch-panel-out {
from {
opacity: 1;
transform: none;
}
to {
opacity: 0;
transform: translateY(-6px) scale(0.985);
}
}
/*
* 位移与缩放挂在 `.el-dialog` **自己**身上,不挂在 `.el-overlay-dialog`(居中容器)上。
*
* 挂外层容器有个隐蔽的坑:Element Plus 的「打开完成」回调(@opened)按**根元素**
* (`.el-overlay`,只有 200ms 的淡入)的动画结束触发,而外层容器上的弹簧要跑 463ms ——
* 于是任何在 @opened 里量容器尺寸做自适应的弹窗,都会量到"还带着 scale(0.97) 的缩小版"。
* **本客户端的作业看板正是这么算 scale 的**:测小了会被最小缩放兜住,反而超出屏幕 7px
* (冒烟断言「作业看板全屏自适应(铺满屏幕、内容不溢出)」实测逮到,两次跑出来都是 550 > 543)。
* 挂到 `.el-dialog` 上还有个好处:全屏弹窗能靠 `is-fullscreen` 精确豁免(见下)。
*/
.dialog-fade-enter-active .el-overlay-dialog,
.dialog-fade-leave-active .el-overlay-dialog {
/* 抵消 Element Plus 自带的那两条(它们同样带 translateY,会与下面这两条叠起来) */
animation: none;
}
.dialog-fade-enter-active .el-dialog {
animation: ch-panel-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
}
/*
* 退出**刻意比入场快得多**:用户已经决定关掉它了,这时候再让他等一段"有分量"的动画
* 只会显得卡。而且冒烟里多处断言"窗口/弹窗已消失",退出越快越稳。
*/
.dialog-fade-leave-active .el-dialog {
animation: ch-panel-out var(--ch-dur-fast) var(--ch-ease-out) both;
}
/*
* 全屏弹窗(`is-fullscreen`,就是作业看板)**不做位移缩放**。
* 它本来就是铺满整屏的表面,"从略小处落下来"既看不出效果,又会让内部的
* fitBoard / fitFullscreen 在 @opened 那一刻量到被 scale 过的容器 —— 测小了会被
* 最小缩放兜住,结果反而超出屏幕。只留淡入。
*/
.dialog-fade-enter-active .el-dialog.is-fullscreen {
animation: ch-fade-in var(--ch-dur-base) var(--ch-ease-out) both;
}
.dialog-fade-leave-active .el-dialog.is-fullscreen {
animation: ch-fade-out var(--ch-dur-fast) var(--ch-ease-out) both;
}
/*
* 确认框(ElMessageBox)。这条沿用了"动画挂在遮罩层上"的做法:
* 它内部没有任何按容器尺寸做自适应的内容,不存在上面那个 @opened 测量的坑。
*/
.el-overlay-message-box {
animation: ch-panel-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
}
.dialog-fade-enter-active {
animation: ch-fade-in var(--ch-dur-base) var(--ch-ease-out) both;
}
.dialog-fade-leave-active {
animation: ch-fade-out var(--ch-dur-fast) var(--ch-ease-out) both;
}
@keyframes ch-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes ch-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/*
* 抽屉。位移由 `.el-drawer` **自身**的 `transition: all` 承载
* (`.el-drawer-fade-*` 那组类只管遮罩与 overflow),所以弹簧挂在 `.el-drawer` 上,
* 且显式列出属性 —— 不想让背景色的变化也走弹簧曲线。
*/
.el-drawer {
transition:
transform var(--ch-spring-panel-dur) var(--ch-spring-panel),
opacity var(--ch-dur-base) var(--ch-ease-out);
}
/*
* 浮层(下拉菜单 / 选择器 / 气泡)。Element Plus 用 `el-zoom-in-*` 系列,
* transform-origin 已按 placement 设好。换成 SWAP 弹簧(263ms,比 PANEL 短):
* 这类浮层是高频出现的,每次点下拉都等半秒会烦。
*/
.el-zoom-in-top-enter-active,
.el-zoom-in-top-leave-active,
.el-zoom-in-bottom-enter-active,
.el-zoom-in-bottom-leave-active,
.el-zoom-in-center-enter-active,
.el-zoom-in-center-leave-active,
.el-zoom-in-left-enter-active,
.el-zoom-in-left-leave-active {
transition:
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
opacity var(--ch-dur-fast) var(--ch-ease-out);
}
.el-zoom-in-top-enter-from,
.el-zoom-in-bottom-enter-from,
.el-zoom-in-center-enter-from,
.el-zoom-in-left-enter-from {
opacity: 0;
transform: scale(0.95);
}
.el-zoom-in-top-leave-active,
.el-zoom-in-bottom-leave-active,
.el-zoom-in-center-leave-active,
.el-zoom-in-left-leave-active {
opacity: 0;
transform: scale(0.97);
}
/* 消息条与通知:原生的位移是 0.4s,连弹三条时明显拖沓 */
.el-message {
transition:
opacity var(--ch-dur-fast) var(--ch-ease-out),
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
top var(--ch-spring-swap-dur) var(--ch-spring-swap),
bottom var(--ch-spring-swap-dur) var(--ch-spring-swap);
}
.el-notification {
transition:
opacity var(--ch-dur-fast) var(--ch-ease-out),
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
left var(--ch-spring-swap-dur) var(--ch-spring-swap),
right var(--ch-spring-swap-dur) var(--ch-spring-swap),
top var(--ch-spring-swap-dur) var(--ch-spring-swap),
bottom var(--ch-spring-swap-dur) var(--ch-spring-swap);
}
/* ------------------------------------------------------------ ③ 自有动效 */
/*
* 按钮按压。Fluent 的按钮只有颜色过渡,没有"按下去"的物理反馈。
*
* 上行/下行用**不同的缓动**,这是这里唯一需要技巧的地方:
* - 按下(`:active` 生效时):90ms 直落,要"跟手",慢了就是延迟;
* - 抬起(`:active` 撤销后回到基础规则):走 PRESS 弹簧,轻轻弹回原位。
* CSS 的规则是"进入某状态用该状态的 transition、离开用目标状态的 transition",
* 所以把弹簧写在基础规则、把快落写在 `:active` 里,正好得到想要的一对。
*/
.el-button {
transition:
transform var(--ch-spring-press-dur) var(--ch-spring-press),
color var(--el-transition-duration-fast) var(--ch-ease-out),
border-color var(--el-transition-duration-fast) var(--ch-ease-out),
background-color var(--el-transition-duration-fast) var(--ch-ease-out),
box-shadow var(--el-transition-duration-fast) var(--ch-ease-out);
}
.el-button:not(.is-disabled):active {
transform: scale(var(--ch-press-scale));
transition-duration: var(--ch-dur-instant);
transition-timing-function: var(--ch-ease-out);
}
/*
* 卡片抬升。**必须关在 `@media (hover: hover) and (pointer: fine)` 里**:
* 教室机器的希沃触摸屏点一下会产生"幽灵 hover"并一直粘着,
* 不加这层的话卡片被点过之后就永远浮着(见 AGENTS.md §5 第 30 条)。
* 这也正是 `composables/motion.ts` 里 `useHoverCapable()` 的 CSS 版本 ——
* 纯 CSS 效果用媒体查询就够了,不必为它挂一个 composable。
*/
@media (hover: hover) and (pointer: fine) {
.el-card {
transition:
border-color var(--el-transition-duration-fast) var(--ch-ease-out),
box-shadow var(--el-transition-duration) var(--ch-ease-out),
transform var(--ch-spring-swap-dur) var(--ch-spring-swap);
}
.el-card:hover {
transform: translateY(-1px);
}
}
/* 表格行:hover 底色快速跟上,别用 Element Plus 的 0.3s */
.el-table td.el-table__cell {
transition: background-color var(--el-transition-duration-fast) var(--ch-ease-out);
}
/*
* 列表错峰入场。配合 `composables/motion.ts` 的 `useStaggerIn()` 使用 ——
* 那个函数按索引下发 `--ch-reveal-delay`,延迟在 CSS 里生效。
* `both` 是必需的:否则带延迟的元素会在延迟期间先按终态渲染一下(闪一帧)。
*/
@keyframes ch-reveal-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.ch-reveal {
animation: ch-reveal-in var(--ch-dur-slow) var(--ch-ease-out) both;
animation-delay: var(--ch-reveal-delay, 0ms);
}
/*
* 页面切换**没有**容器级的过渡 —— 这里曾经有一对 `.ch-page-enter-active` /
* `-leave-active`(配 `<transition mode="out-in">`),已经去掉,原因写在
* ClientLayout.vue 的 router-view 注释里(延期换页 + 窗口被遮挡时卡死)。
* 页面切换的动效全部由下面的 `.page > *` 错峰入场承担。
*/
/*
* 页面内容的错峰入场。
*
* 挂在 `.page > *` 上而不是逐个视图去写类名:所有视图(含六个设置子页)的根容器
* 都叫 `.page`,一条规则覆盖全部,新增视图自动享有,也不会有人忘记加。
*
* 延迟按**块**递进(30ms 一档、封顶 180ms):一屏通常是"页头 + 两三块内容",
* 这个节奏读起来是依次浮现;给更多块继续累加就变成排队了,所以第 7 块起不再递增。
*/
@keyframes ch-page-block-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.page > * {
animation: ch-page-block-in var(--ch-dur-slow) var(--ch-ease-out) both;
}
.page > *:nth-child(2) {
animation-delay: 30ms;
}
.page > *:nth-child(3) {
animation-delay: 60ms;
}
.page > *:nth-child(4) {
animation-delay: 90ms;
}
.page > *:nth-child(5) {
animation-delay: 120ms;
}
.page > *:nth-child(6) {
animation-delay: 150ms;
}
.page > *:nth-child(n + 7) {
animation-delay: 180ms;
}
/*
* 三个例外,都是"动了就会破坏别人的测量":
* ① `v-loading` 的遮罩是插在 `.page` 里的直接子元素,让它跟着淡入会闪一下;
* ② **弹窗遮罩**(`.el-overlay`)。Element Plus 的 `el-dialog` 默认 `append-to-body=false`,
* 所以声明在视图里的弹窗,它的遮罩是 `.page` 的**直接子元素**。而 `.el-overlay` 是
* `position: fixed; inset: 0` 的全屏层,整体下移 6px 就会把"刚好铺满"的内容顶出屏幕 ——
* 作业看板正是这样被顶出去的(冒烟断言「作业看板全屏自适应」读到可视底 550 > 视口高 543)。
* 全屏层本来也不该参与"内容块依次浮现",它一出现就该盖住整屏。
* ③ **看板容器**(`.board-host`):它的尺寸是按"刚好铺满屏幕"用 `transform: scale()`
* 精确算出来的(见 HomeworkView 的 computeScale),算完就是贴边的,同样经不起位移。
* 一句话:**凡是 `position: fixed` 的整屏层、或自己量几何再据此设尺寸的元素,
* 都不要叠位移类动画。**
*/
.page > .el-loading-mask,
.page > .el-overlay,
.page > .board-host {
animation: none;
}
/* ------------------------------------------------------- 降低动态效果偏好
*
* 系统开了「减少动态效果」时,把全站的动画压到 1ms —— 不是删掉,
* 而是让它在同一帧内完成(`transition: none` 会让依赖 transitionend 的逻辑失灵)。
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
/*
* 例外:加载指示旋的是"状态"不是"装饰"。把它一起停掉会让界面看起来像卡死了
* (用户会去重复点击),所以这里改为**放慢**而不是停。
*/
.el-icon.is-loading,
.el-loading-spinner .circular,
.el-loading-spinner svg,
.is-loading {
animation-duration: 1.6s !important;
animation-iteration-count: infinite !important;
}
}
/* ------------------------------------------------- 整页换肤(View Transition)
*
* `handleThemeToggle`(ClientLayout.vue)用 View Transition API 做"从点击位置扩散的圆"。
* 浏览器默认会给新旧两帧做一次交叉淡入 —— 这里把它关掉,改由 JS 用 WAAPI 驱动
* `::view-transition-new(root)` 的 clip-path。不关掉的话两套动画会叠在一起,
* 观感是"圆在扩散的同时整页又在闪"。
*
* 层级:老帧在下(z-index 1)、新帧在上(z-index 2),新帧的圆从无到有地盖住老帧。
* 不支持这个 API 的浏览器不会渲染这些伪元素,规则自然失效(JS 侧也有守卫)。
*/
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2;
}
@@ -7,7 +7,7 @@ import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useOnboardingStore } from '../stores/onboarding.js'; import { useOnboardingStore } from '../stores/onboarding.js';
import { useRealtimeStore } from '../stores/realtime.js'; import { useRealtimeStore } from '../stores/realtime.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js'; import { DEFAULT_SERVER, BRAND_LOGO_URL, normalizeServerUrl } from '../config.js';
const router = useRouter(); const router = useRouter();
const appStore = useAppStore(); const appStore = useAppStore();
@@ -93,7 +93,7 @@ async function enterOffline(): Promise<void> {
<div class="login-page"> <div class="login-page">
<div class="login-card"> <div class="login-card">
<div class="login-head"> <div class="login-head">
<el-icon :size="30" color="#409eff"><School /></el-icon> <img class="login-logo" :src="BRAND_LOGO_URL" alt="" width="44" height="44" />
<div> <div>
<h1 class="login-title">班级小助手</h1> <h1 class="login-title">班级小助手</h1>
<p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p> <p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
@@ -200,6 +200,16 @@ async function enterOffline(): Promise<void> {
margin-bottom: 18px; margin-bottom: 18px;
} }
/*
* 品牌标:与应用图标同一份图(`public/logo.png`)。
* 图自带圆角与透明边角,不要再加 border-radius;`src` 走 `BRAND_LOGO_URL` 的理由同 ClientLayout。
* (表单里「班级码」输入框的前缀仍用 School 字形 —— 那是字段语义图标,不是品牌标。)
*/
.login-logo {
flex: none;
display: block;
}
.login-title { .login-title {
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/server", "name": "@classhelper/server",
"version": "1.1.1", "version": "1.1.2",
"private": true, "private": true,
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问", "description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module", "type": "module",
@@ -210,7 +210,7 @@ async function main() {
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次) // ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
const reportBody = { const reportBody = {
pluginVersion: '1.1.1.0', pluginVersion: '1.1.2.0',
classIslandVersion: '2.1.0.0', classIslandVersion: '2.1.0.0',
state: { state: {
inClass: true, inClass: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/shared", "name": "@classhelper/shared",
"version": "1.1.1", "version": "1.1.2",
"private": true, "private": true,
"description": "班级小助手 - 三端共享的类型、常量与工具函数", "description": "班级小助手 - 三端共享的类型、常量与工具函数",
"type": "module", "type": "module",
+1
View File
@@ -3,6 +3,7 @@
* 后端、Web 管理端、桌面客户端统一从这里导入类型与工具函数。 * 后端、Web 管理端、桌面客户端统一从这里导入类型与工具函数。
*/ */
export * from './constants.js'; export * from './constants.js';
export * from './motion.js';
export * from './permissions.js'; export * from './permissions.js';
export * from './utils.js'; export * from './utils.js';
export type * from './types.js'; export type * from './types.js';
+242
View File
@@ -0,0 +1,242 @@
/**
* 动效令牌 —— 移植自 beUI(https://beui.dev)的 `lib/ease.ts`。
*
* 移植范围与理由:
* - beUI 的组件本身是 React(`motion/react` + Tailwind),无法在本项目的 Vue 两端直接使用;
* **但它的动效语言与框架无关**,这部分完整搬过来。
* - 原文注释一并保留("默认的 ease-in / ease-out 太弱,需要更强的自定义曲线")——
* 这条判断是本文件所有取值的出发点。
*
* **本文件必须保持"纯常量 + 纯函数"**:`packages/shared` 会被后端(Express)一起消费,
* 它的 tsconfig 是 `lib: ["ES2023"]`(不含 DOM),所以这里不碰 `document`、不碰 `window`、
* 也不依赖 vue。DOM 注入与 Vue 组合式函数在两端各自的 `composables/motion.ts` 里。
*
* 用法见 `motionCssVars()`:动效的**数值**只写在这里一份,两端在 `app.mount()` 之前
* 把它写进 `:root` 的自定义属性,CSS 只引用 `var(--ch-…)` —— 避免"两端各抄一份、日久必漂"。
*/
/* ------------------------------------------------------------------ 缓动曲线 */
/** 四段式贝塞尔的两个控制点(与 CSS `cubic-bezier()` 同参)。 */
export type EasingCurve = readonly [number, number, number, number];
/** 主缓动:绝大多数进出场用它。收得干脆、尾巴长,是"贵"的那种手感。 */
export const EASE_OUT: EasingCurve = [0.16, 1, 0.3, 1];
/** 对称的来回动(抽屉推拉、内容互换)。 */
export const EASE_IN_OUT: EasingCurve = [0.77, 0, 0.175, 1];
/** 侧向抽屉专用:起步快、落位缓,从屏幕边缘滑入时比 EASE_OUT 更有"质量"。 */
export const EASE_DRAWER: EasingCurve = [0.32, 0.72, 0, 1];
/** 把曲线元组转成 CSS 字面量。 */
export function cubicBezierCss(curve: EasingCurve): string {
return `cubic-bezier(${curve[0]}, ${curve[1]}, ${curve[2]}, ${curve[3]})`;
}
/* -------------------------------------------------------------------- 弹簧 */
/**
* 弹簧参数,取自 beUI。语义与 `motion/react` 的 spring 一致:
* 加速度 = (stiffness × 剩余距离 − damping × 当前速度) / mass。
*/
export interface SpringConfig {
stiffness: number;
damping: number;
mass: number;
}
/** 按压反馈:按钮、可点表面。beUI 的 `whileTap: { scale: 0.93 }` 配它。 */
export const SPRING_PRESS: SpringConfig = { stiffness: 500, damping: 30, mass: 0.6 };
/** 控件内的内容互换(文字/图标换位)——比 PRESS 稍柔,避免小面积元素显得"抖"。 */
export const SPRING_SWAP: SpringConfig = { stiffness: 460, damping: 30, mass: 0.55 };
/** 浮层进场:弹窗、抽屉、气泡。刻意偏阻尼,落位不晃。 */
export const SPRING_PANEL: SpringConfig = { stiffness: 420, damping: 40, mass: 0.5 };
/** 共享布局滑动(药丸指示器、标签指示条)。 */
export const SPRING_LAYOUT: SpringConfig = { stiffness: 360, damping: 32, mass: 0.6 };
/** 光标跟随类装饰动效(磁吸、倾斜)。 */
export const SPRING_MOUSE: SpringConfig = { stiffness: 200, damping: 15, mass: 0.3 };
/** 拖拽的滑块与填充:临界阻尼,跟手但绝不回弹。 */
export const SPRING_GLIDE: SpringConfig = { stiffness: 700, damping: 50, mass: 0.5 };
/**
* 由弹簧参数推出的固有角频率与阻尼比(`ζ ≥ 1` 表示不过冲)。
* beUI 这六组里 PRESS / SWAP / MOUSE 欠阻尼,PANEL / LAYOUT / GLIDE 过阻尼。
*/
export function springDynamics(spring: SpringConfig): { omega0: number; zeta: number } {
const omega0 = Math.sqrt(spring.stiffness / spring.mass);
const zeta = spring.damping / (2 * Math.sqrt(spring.stiffness * spring.mass));
return { omega0, zeta };
}
/* ---------------------------------------------------------------- 弹簧积分 */
/** 数值积分步长(1ms)——比任何刷新率都细,采样出来足够平滑。 */
const SIMULATION_STEP = 0.001;
/** `linear()` 的采样点数。60 段肉眼已看不出折线,再密只是徒增样式表体积。 */
const LINEAR_SAMPLES = 60;
/**
* 落定判据(归一化空间,位移全程记作 1):
* 位置误差 ≤1%、速度 ≤5%/秒。1% 的位移误差在任何真实尺寸下都不到 1px;
* 5%/秒 意味着单帧(16ms)只移动 0.08% 的行程 —— 比显示器能分辨的还小。
*/
const SETTLE_DISTANCE = 0.01;
const SETTLE_SPEED = 0.05;
/** 模拟上限,任何弹簧都远达不到。 */
const MAX_SIMULATION_MS = 1500;
interface SpringCurve {
/** 从静止到落定所需的毫秒数。 */
durationMs: number;
/** 每个积分步的位移(0 → 1,可能短暂越过 1)。 */
positions: number[];
}
/** 同一组参数会被反复查询(每次注入 CSS 变量都会走一遍),算一次存下来。 */
const curveCache = new Map<string, SpringCurve>();
/**
* 按物理积分弹簧,直到落定。
*
* ⚠️ **不要用"包络衰减到某个比例"的解析近似来算时长**:那个公式(`t = 6 / (ζ·ω0)`)
* 只对欠阻尼成立。过阻尼系统的衰减由**较慢的那个实极点** `ω0(ζ − √(ζ²−1))` 决定,
* 拿 `ζ·ω0` 去估会**严重偏短** —— 实测 `SPRING_PANEL` 会被截在 0.869、
* `SPRING_LAYOUT` 截在 0.952,缓动曲线根本走不到终点,元素永远差一截没就位。
* 按落定判据积分则对两种阻尼都成立。
*/
function simulateSpring(spring: SpringConfig): SpringCurve {
const key = `${spring.stiffness}/${spring.damping}/${spring.mass}`;
const cached = curveCache.get(key);
if (cached) return cached;
const steps = Math.round(MAX_SIMULATION_MS / 1000 / SIMULATION_STEP);
const positions: number[] = [0];
let value = 0;
let velocity = 0;
for (let step = 1; step <= steps; step += 1) {
const acceleration = (spring.stiffness * (1 - value) - spring.damping * velocity) / spring.mass;
velocity += acceleration * SIMULATION_STEP;
value += velocity * SIMULATION_STEP;
positions.push(value);
if (Math.abs(1 - value) <= SETTLE_DISTANCE && Math.abs(velocity) <= SETTLE_SPEED) {
// 吸附到 1:否则曲线尾巴停在 0.995 上,元素永远差那 0.5% 没到位
positions[positions.length - 1] = 1;
const curve = { durationMs: step * SIMULATION_STEP * 1000, positions };
curveCache.set(key, curve);
return curve;
}
}
positions[positions.length - 1] = 1;
const curve = { durationMs: MAX_SIMULATION_MS, positions };
curveCache.set(key, curve);
return curve;
}
/** 该弹簧从静止到落定所需的毫秒数。 */
export function springDurationMs(spring: SpringConfig): number {
return Math.round(simulateSpring(spring).durationMs);
}
/**
* 把弹簧积分成 CSS `linear()` 缓动字面量。
*
* 为什么值得这么做:CSS 的 `cubic-bezier()` 表达不了"过冲"与"按物理收敛",
* 而 `linear()` 允许逐点给定进度曲线,于是弹簧可以**纯 CSS 驱动**,
* 不必给每个组件挂一个 rAF 循环去逐帧改样式(本项目刻意不引 rAF 弹簧引擎,
* 唯一的例外是灵动岛,它有自己那套从 WinIsland 移植的物理)。
*
* ⚠️ 过冲的弹簧会产生 **大于 1 的进度值**:只能用在 `transform` / `clip-path` 这类
* 越界无害的属性上,**不要用在 `opacity`**(会被裁剪在 [0,1],观感变成"到顶就停")。
*/
export function springLinearEasing(spring: SpringConfig): string {
const { positions } = simulateSpring(spring);
const lastStep = positions.length - 1;
const points: string[] = ['0'];
for (let sample = 1; sample <= LINEAR_SAMPLES; sample += 1) {
const index = Math.min(lastStep, Math.round((sample * lastStep) / LINEAR_SAMPLES));
const value = (positions[index] ?? 1).toFixed(4).replace(/\.?0+$/, '');
const percent = ((sample / LINEAR_SAMPLES) * 100).toFixed(2).replace(/\.?0+$/, '');
points.push(`${value} ${percent}%`);
}
return `linear(${points.join(', ')})`;
}
/* -------------------------------------------------------- 时长 / 比例 / 查询 */
/** 时长(毫秒)。`instant` 用于悬停这类高频反馈,`page` 用于路由切换。 */
export const MOTION_DURATION = {
instant: 90,
fast: 140,
base: 200,
slow: 320,
page: 180,
} as const;
/** 按压时的缩放比例。beUI 用 0.93;管理端控件更密集,收到 0.97 免得整片表格跟着抖。 */
export const PRESS_SCALE = 0.97;
/** 降低动态效果的系统偏好。两端都必须响应(原先一处都没处理)。 */
export const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
/**
* 是否有真正的悬停能力(鼠标 / 触控板)。
* 触摸设备会在点按时触发"幽灵 hover"并**粘住**直到点别处 —— 教室里的希沃触摸屏正是这种,
* 所有 hover-only 效果(抬升、磁吸、光标跟随)都必须先过这一关。
*/
export const HOVER_CAPABLE_QUERY = '(hover: hover) and (pointer: fine)';
/* ---------------------------------------------------------------- CSS 变量 */
export interface MotionCssVarOptions {
/**
* 运行环境是否支持 CSS `linear()`(Chrome 113+)。
* 为 `false` 时弹簧变量回落到 `EASE_OUT` 的 cubic-bezier —— 手感略钝,但不会失效。
* 调用方用 `CSS.supports('transition-timing-function', 'linear(0, 1)')` 探测后传入。
*/
linear?: boolean;
}
/**
* 令牌 → CSS 自定义属性表。两端在 `app.mount()` **之前**写入 `:root`,
* 于是 CSS 里可以放心写 `var(--ch-spring-panel)`,首帧之前就已就位(无 FOUC,也不需要兜底值)。
*/
export function motionCssVars(options: MotionCssVarOptions = {}): Record<string, string> {
const { linear = true } = options;
const spring = (config: SpringConfig): string =>
linear ? springLinearEasing(config) : cubicBezierCss(EASE_OUT);
return {
'--ch-ease-out': cubicBezierCss(EASE_OUT),
'--ch-ease-in-out': cubicBezierCss(EASE_IN_OUT),
'--ch-ease-drawer': cubicBezierCss(EASE_DRAWER),
'--ch-spring-press': spring(SPRING_PRESS),
'--ch-spring-swap': spring(SPRING_SWAP),
'--ch-spring-panel': spring(SPRING_PANEL),
'--ch-spring-layout': spring(SPRING_LAYOUT),
'--ch-spring-glide': spring(SPRING_GLIDE),
/*
* 弹簧的**自然落定时间**,与上面那条曲线成对使用。
*
* ⚠️ 这两个值必须配成一对:CSS 的 `linear()` 是"把 [0,1] 的进度重映射",
* 若 `transition-duration` 短于弹簧的自然落定时间,整条曲线会被等比例压缩 ——
* 形状还在,但已经不是这组 stiffness/damping 对应的手感了(等于偷偷换了一组参数)。
*/
'--ch-spring-press-dur': `${springDurationMs(SPRING_PRESS)}ms`,
'--ch-spring-swap-dur': `${springDurationMs(SPRING_SWAP)}ms`,
'--ch-spring-panel-dur': `${springDurationMs(SPRING_PANEL)}ms`,
'--ch-spring-layout-dur': `${springDurationMs(SPRING_LAYOUT)}ms`,
'--ch-spring-glide-dur': `${springDurationMs(SPRING_GLIDE)}ms`,
'--ch-dur-instant': `${MOTION_DURATION.instant}ms`,
'--ch-dur-fast': `${MOTION_DURATION.fast}ms`,
'--ch-dur-base': `${MOTION_DURATION.base}ms`,
'--ch-dur-slow': `${MOTION_DURATION.slow}ms`,
'--ch-dur-page': `${MOTION_DURATION.page}ms`,
'--ch-press-scale': String(PRESS_SCALE),
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/web-admin", "name": "@classhelper/web-admin",
"version": "1.1.1", "version": "1.1.2",
"private": true, "private": true,
"description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)", "description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module", "type": "module",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

+3 -15
View File
@@ -1,15 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs> <image href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAQAElEQVR4Aez9d6BtV1nvD3/HXPuU5Jz0kISQkBA6SAdBOqKAKCj1IoJiA7wgKCLqVawoiKIg6EW5CNJFFFSwAIKIF+kBlBJCJySQ3k5yyt5rvJ/PM+dce5+TgOX63/ubjGc8vYwyx5xrrZ3DkP/v+v/rGfhv3wDnn3/+7ouvvvr6F1/Rv+mSPRu/culVy9dftnd5MbhfctWyX3r1sl8GXC7sXfYrtsI+eGAPcOUhcG2yK/HVTt1V+5f9GkCMWbcHvfSMr0J3bfbKKyb2s14/ZYL+4q3gGC6nFjFjXY1PWrkg7bgF52ArOC8X7Vn2C69c9guu2OgXXbHsF+9Zfv7CPRu/f+EV6w+96Ip+yyuu6Cf03tf+u3frf8sGoLAFRd6Cgn9h++7j/jDLHX/Xhv7RtPaLPXlE7zkmLWlAuCYEdXBrGKsDrRTy5Qch3grD0DIgoKUubALQIg6ja0C4tCmYaeVliIBWOnlgAGhIpwZDK4axFLZTNtc8kFB+QGEsUNTFwQDSoGLVSRhLkDensMCZFvSnZ9n+Z0/78976mXvT33DRnuVvf/Xy/l2XXNKP1ue/A6z3/ynOZXvXv/Oiq/qZact3Uvwv97RHM4CbMx8DOAOzYpLC0oAJ1QnYRSwEnViY7aUbCmM0HAdpiNIzS4MgLy59os+QZAUtRVNfyq8lrfeUHlpZuMoPfsbKYbMwNkL5rRCUDUEzEHTx4GLpcImyGRCNOZOo2wrhKh6jwsQRD3SQ21DfY9nbU1v6q/e0/udfvbTfF9n/cyPdfz5G733x1Yv3fdP5V2y8aP/G8Iah5VbcjScQyTneHBwCiqcfmzTjWenxqwlRXjRE65lkbWXHoJNWLQ0DyAjVQdDSWIWWFq8GFlZ0kxJ6zC8l0dKKtJOadU5Km3QlozOvNUKmILMFMQ3AJilk14J2hHDBFl/dxBjDPEKJ6JRhnrJLIi+Eaws+cvsi39rb8u3nXrp8lxvhgt6PwOS/1Mz/n3K88KqrrnfRlctfGbZv++vF0J601rKT2lNAx/qMNFFhI2PxcyJlA4yymZZHFCd4BhddfQGden3C1QjawLaSFdPDbR15F0swlnHSW8nTMEky2iQr+6AIp64hpGG1Gf2jJPM1wDlGrEuEacoWbhBgaFFuVzFgaLIHQTH42IxZICPgQJMqM2njiwXmPosh91y25ev2Xdb/+II9/fZl/J/sjPkfdjn77L5j//6drx6G9rNrQ053cLOzg94aTL4AgwY4Cu2lhYNs0bemVGKaWlhaFBcuVUsDl8VIFFcdPKd6kaxjOosu6OBmQF06sWB+N4e0emsTyqh8oQikHgQTQsklELGuOUYwaG3S9R4p2VFf5rW5lQkDCrF24RIXDzFjxNUQZZZZn4+84tGKFy3HsxYPv3Jf//Nz/guPBUoh0r/TOPLXfPk44sT+xR3bci+KIm8OKgxZvCxKWgidvLiKhxeDEPkM3pwsF1XbURf0qcu7wjuVNYk4XANC7Yw19JTtzIsFbRZtGa8xbi+7sVMayAaIe+bLzTCD+czbUAo92JkbhpbRuaXiI3dhzduSkgVi1CXS1ouIzZm61JVM4QylyWrDFNuie7Rvra10iKFH/vBtOX1tWL79/Ms2nnLRRf3I/DvXrB5m4hvhcy/NdzIZL9m2yInlQGbqCCjFz84ISo4QsvTiBULx1kUOsoLJd2Z5lJe4eOIEx0YnX2zToSFJwdYCtGnhYp24EXmJhqNJ99F6nHxlmnUrEhDAl/9IlnWjn2WFJxs9UKUWD1k1kjQJDGmx1llfcnI1nBo26kFjQyBfgESsr3sN89R8IJeeAZc0uooPlgZFfjm0F1y92Hguj4STdfv3oHJ9I6MvX7zv1ovF8g07FjkpZgHmhJDlKl7JYCoomJaSc3eMGIkTgQFUFVwBkB2Ei0m00S9e2DDHNfeQ8c7UQJl2M63OSZPXTZ0xKGHy7ZkOhoQ6ttpIz2B8IT0p/6SwfFAoQxVzeWKMygYCwlUJAz8D58dUbEsqdWsJLV7ig3hqc0Fn3WyonbLKDUNbxQpGuGVtGB5/4MDGr+Y/cGl/rWYc+4uvXL7+oGGx7cwda62+gNDYhEKsAE+LBlWbaVXS2gmldPAQrfHk1QDa5iARSRas7Iujw3Ylg9C2ALk1SGNViztjVbNcWjvmJsIiXNPilFx2IixRQKSpKEWUsBepacXuo0peitLiRmB0skmU0I8orbUcNFbkWx9fmS/kmM5cYGNXOFwSwAKgIRibdAEdeRrXD5932cavnHtuP3y0uPbeNb1WzXkX7b/x9mF4Ns+WlQ2BU8WQZNz5SfFYNGVJCkMPaSk60wVDS5j8EY9r1nLw5Vy31sp31jDXcWLbFoGyAjoa+gA9uEa7itOzSqI/XLoGWBRSAJQMB/2UC7CU2rE0hN5oe1hE7+SG19ikhDIcRau+5HDGA0UbZc6jOCogRMrCJZ55VGWirGBI8ZkvDLQtQCa2bsg0HFprz2y7lj/75d4PU3ZtQMhrin/5l/uw3LbtpeyyW27VdhhyOiMRXwMQDGi8y1qWafABGl3R+IcgDVwATYs6AXGUVwKYou0w6mjKRl4ovdKgYVGStNaAbF4tLNjIUk0RNUGtyIgEuYoEQ6uFn2VlL4OiA0WyicVCpyNtxZLWXjNBYWEGXacDTNmC1QmsExHGJq1KbmVXTDSdp6XolX5ykA+XMayjxE3T9sztV2w8ANW1tuFQKUf/zh/5ieUL+Xx/V4MK2oibhNHFMoAJSwddmMGaVhMBcWrwMNKzPWzkBWlhK20YalEc4/rxpyaX/NopE8NGA/WZLu0k/bZvfOuTS7Rx7UY5sgbYcDCeOYuVmXTal7wSxW2tSZS1sSt+dvFTQAnoDFFutXPgisELDBcoymvgFudlg3ifujr5/L5kA5twKQdVa/S1YBLQlI2vBECzBtAokwCUrff2nK/t7TeEvUareFul5166cb/F0H5oh7cxCpOAKqjBVoBQWr31SCNKGeaQaxqMdjVz8GVvdoXyq0DZDFFGGS/oxgR5ry8n+14y1PqKJtDOxYaNNsxyZt7NqLzqgCAU/dSoZcVPREe2WVBCytSlHEJU6Wf7IJlo1HIpH2WozNvSVI3ADnNMVy9bfuIzyQ9P8HOf79mnD1ZaC+UGMU9bxVWPTJ0INsqltQvE0NpNlvs2Xn1578ep3wplMws+dUE/orfhsdvXsjNcBgJFXLXQSRNTsWPJADPL6s4qTcNnAFKQLVefjGvSkDeAsJt31sSDom7EWEwOyubFnHXKPOInk4QcPUjB4YJKB9vMX7bqFRS0Lcc+AgKVa4P+Ok19Jwam+NZUwGUFOeQyFGvtXqQWPd2mkxHK/3Nuz19emnyBu/9Te5NXXdjyVxf1yQCEDS3mhSssLQQFTaQqHUY5qGSuUdJuf/Vly0dwqirOfA0zIT525/rtdq7lXiWczKQlxdoUIKCloidMwFhoa0OU19BaLzpcFgOqpl5CGydPUKbN5gYKvj1e6rpKGLGQiS97aC3dFMZ0hpUjjnL51YIrJI62ggaKhIqLbs5nXbBzKsmCWV4MnfahM7f5VnoZ9KuGjXYuhvncMkxR2Pk5lwX/m0u445dGSfx0oN/F64m2QubLuA1GDEjCVYMN721ZQCjXT6xyGNo2viN4xNeuzHXkZxhmQtwy/BgFloGOBVgUphukwbQIsIVjpkyXCmFiRdQj+rpmujsZPa02kws4L0jXs7oWF2yEEqhhfTdpLBKC9UCxEjR8mhzgQZu6jF0gh1EHN2CMHWoAktT40K/kDeGWRqoV18lQdkqwo0XR1MVrq/1Ms+b558uTD1/R3Avx8cYaZDvB7n10i7SxDoVsueZY2sziuWxlgnLs7so9elfpGRxj0Rfu23cziEetjGGoocaAo9wIfUSlKHIUlB+rqG3Ro7gsqkPI8VOkcTMZiopHU7SdoP8ErQx6ChsHvQsY8FZYBose+3hhGp/5SAPBZkhdNTnYyfRoBYWwQ5btlI/dlV450KOjj6xY6HTCHFgTQb5LlHLLxoMnTVThGgntnv2lnv0roeKeHzopufVuaOS0mFeITMZLXpZpj3RkUEkXHEqHR/syT2cd1lBVW22AA3vXfm7B+TE7qpUORRtXWnzojsycNRkp7MOlPahk5ScD5wRospSHkXYSCoqhQz4vxIiZUgMKoxrBSHhaGEowjxDtEMy+8vMkqff4n0FeXdfHkOIoJQUxbIoE6UNByz7Z19GBwYqHrtaJBUgbR5+Zful5yVfWWzxdw+Xb/xnbW37q1MaJ0MbILYVXcy8vZLwqJvERpdPR6uTQCXEZlQ0Ui3+3y/bkWyGr1Qa4fG+/KcEfoKPSMp4YaQNNbIpGSBtpIhSd8boGjaNFsKa49qgn1+o55wKuQFsNtkCXNjQBKF4q+rfWGOwICrsd/qIC9IXt8BVdO+jZo6su1sJyJTBK5YVwGUYgq4dDmSGOPObRvhNIXnlBT9QJma7SY3fu/uTFbIA1PnE5porNnfG4E5OjmVeTlAw//QXF4q2QlsiHyzjZws9yilMc+QPpz8K0mvFy2VUbN1wb2up3/RBArcYzzThSvITKLVDm3kZbZN5h8wC2iFO2xKClNbgJOljZbDvThu3ZtJOsuBgiLVYMW60mtyg6YmYF8KumB4CutRGrMpf2rSHrCX1SfRtv7pa6RIIMZiJLKjyaNxBHv8o2iu2rNmSscX3Of/0FySU+9FUCLvYt+eL23vyWt47REujdAC1jSS0ZW7w6seSlxY3VrDEgUAUbQV9BG1RZ3+g3ufTK/u3Swzt7Xzts5+IeHEG7NShDEquUd+dIG0i6NSqQQDiTJmtlzE0hg25ZPNIGMzcdquqUNlsv/YB5AIXhaWXrXahMKJndCnrZGM68SyZt2VMvVOIO3ydeDKtpPFGWCDaWyQZvY0sUG9Line truncated
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1"> </svg>
<stop offset="0%" stop-color="#4facfe"/>
<stop offset="55%" stop-color="#409eff"/>
<stop offset="100%" stop-color="#1f6feb"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="64" height="64" rx="14" ry="14" fill="url(#g)"/>
<text x="50%" y="52%" text-anchor="middle" dominant-baseline="central"
font-family="'Microsoft YaHei','PingFang SC',sans-serif" font-weight="700"
font-size="33" fill="#ffffff">班</text>
<rect x="16" y="47" width="32" height="3"
rx="1" fill="#ffffff" opacity="0.9"/>
</svg>

Before

Width:  |  Height:  |  Size: 702 B

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -316,7 +316,7 @@ function parityLabel(item: ClassPlanEntryDto): string {
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 16px;
margin-top: 10px; margin-top: 10px;
color: #606266; color: var(--ch-text-secondary);
font-size: 13px; font-size: 13px;
} }
.mb-8 { .mb-8 {
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { useCountUp } from '@/composables/motion';
/**
* 统计卡片。抽成组件而不是写在 DashboardView 的 v-for 里,是因为数字滚动要用到
* `useCountUp()` —— 它必须在 setup 阶段调用(内部有 `watch` 与 `onUnmounted`),
* 而 v-for 里没法给每一项单独跑一遍 setup。
*
* 外观类名走全局样式(`styles/index.css` 的 `.stat-card` / `.stat-value`…),
* 与仪表盘原先的写法完全一致,这里只负责"数字滚起来"。
*/
const props = defineProps<{
label: string;
value: number;
hint: string;
}>();
/**
* 数字滚动(beUI 的 number 原语)。0 → 实际值的那一跳就是它存在的意义:
* 数据是异步来的,直接跳到终值会让人错过"这一栏有内容"这件事。
*/
const displayed = useCountUp(() => props.value);
</script>
<template>
<div class="stat-card">
<div class="stat-label">{{ label }}</div>
<div class="stat-value">{{ Math.round(displayed) }}</div>
<div class="stat-hint">{{ hint }}</div>
</div>
</template>
@@ -388,7 +388,7 @@ async function submit(): Promise<void> {
flex-wrap: wrap; flex-wrap: wrap;
} }
.file-name { .file-name {
color: #606266; color: var(--ch-text-secondary);
font-size: 13px; font-size: 13px;
} }
.hidden-input { .hidden-input {
@@ -399,7 +399,7 @@ async function submit(): Promise<void> {
gap: 16px; gap: 16px;
flex-wrap: wrap; flex-wrap: wrap;
margin: 12px 0 4px; margin: 12px 0 4px;
color: #606266; color: var(--ch-text-secondary);
font-size: 13px; font-size: 13px;
} }
.required-hint { .required-hint {
@@ -409,7 +409,7 @@ async function submit(): Promise<void> {
} }
.mode-hint { .mode-hint {
width: 100%; width: 100%;
color: #909399; color: var(--ch-text-muted);
font-size: 12px; font-size: 12px;
} }
.result-stats { .result-stats {
@@ -293,7 +293,7 @@ function fillSample(): void {
display: flex; display: flex;
gap: 16px; gap: 16px;
margin-top: 10px; margin-top: 10px;
color: #606266; color: var(--ch-text-secondary);
font-size: 13px; font-size: 13px;
} }
.mb-8 { .mb-8 {
@@ -73,7 +73,13 @@ function handleConfirm(): void {
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="urgent-fade"> <!--
`:duration` 是必需的:这块遮罩是 `position: fixed; inset: 0`,铺满整屏、吃掉所有点击。
靠 `animationend` 判断退场结束的话,窗口被遮挡时 Chromium 会冻结 CSS 动画、
事件永不触发 ⇒ **遮罩永远不卸载,整个管理端再也点不动**。
给的数值取「卡片弹簧的落定时间」与「遮罩淡出的时长」里较大的那个,保证不会被提前截断。
-->
<Transition name="urgent-fade" :duration="{ enter: 480, leave: 160 }">
<div v-if="visible" class="urgent-mask" role="alertdialog" aria-modal="true"> <div v-if="visible" class="urgent-mask" role="alertdialog" aria-modal="true">
<div class="urgent-card"> <div class="urgent-card">
<div class="urgent-icon"> <div class="urgent-icon">
@@ -116,7 +122,14 @@ function handleConfirm(): void {
:show-text="false" :show-text="false"
color="#f56c6c" color="#f56c6c"
/> />
<span class="ring-text">{{ remaining }}</span> <!--
倒计时数字按秒滚动(beUI 的 number 原语)。这里刻意**不用** `mode="out-in"`:
出与进同时进行、共用一个绝对定位的中心,才读得出"数字往下滚";
串行执行会变成"先空一拍、再出现",那是闪一下而不是滚动。
-->
<Transition name="ring-roll">
<span class="ring-text" :key="remaining">{{ remaining }}</span>
</Transition>
</div> </div>
<span class="progress-hint"> <span class="progress-hint">
{{ {{
@@ -149,7 +162,7 @@ function handleConfirm(): void {
max-width: calc(100vw - 48px); max-width: calc(100vw - 48px);
padding: 28px 30px 22px; padding: 28px 30px 22px;
border-radius: 16px; border-radius: 16px;
background: #fff; background: var(--ch-surface);
border-top: 4px solid #f56c6c; border-top: 4px solid #f56c6c;
box-shadow: 0 24px 60px rgba(120, 20, 20, 0.35); box-shadow: 0 24px 60px rgba(120, 20, 20, 0.35);
text-align: center; text-align: center;
@@ -164,7 +177,7 @@ function handleConfirm(): void {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #f56c6c; color: #f56c6c;
background: #fef0f0; background: rgba(245, 108, 108, 0.1);
animation: urgent-pulse 1.4s ease-in-out infinite; animation: urgent-pulse 1.4s ease-in-out infinite;
} }
@@ -183,14 +196,14 @@ function handleConfirm(): void {
.urgent-title { .urgent-title {
margin: 0 0 8px; margin: 0 0 8px;
font-size: 22px; font-size: 22px;
color: #303133; color: var(--ch-text);
} }
.urgent-subtitle { .urgent-subtitle {
margin: 0 0 18px; margin: 0 0 18px;
font-size: 14px; font-size: 14px;
line-height: 1.7; line-height: 1.7;
color: #606266; color: var(--ch-text-secondary);
} }
.urgent-subtitle strong { .urgent-subtitle strong {
@@ -210,17 +223,17 @@ function handleConfirm(): void {
} }
.urgent-period { .urgent-period {
background: #fef0f0; background: rgba(245, 108, 108, 0.1);
} }
.urgent-preview { .urgent-preview {
background: #f5f7fa; background: var(--ch-surface-alt);
} }
.period-label, .period-label,
.preview-label { .preview-label {
flex: 0 0 64px; flex: 0 0 64px;
color: #909399; color: var(--ch-text-muted);
} }
.period-value { .period-value {
@@ -229,7 +242,7 @@ function handleConfirm(): void {
} }
.preview-title { .preview-title {
color: #303133; color: var(--ch-text);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -266,17 +279,31 @@ function handleConfirm(): void {
.progress-hint { .progress-hint {
font-size: 12px; font-size: 12px;
color: #909399; color: var(--ch-text-muted);
} }
/*
* 进出的曲线统一走共享令牌(`packages/shared/src/motion.ts`)——
* 原先这里是手写的 `cubic-bezier(0.34, 1.56, 0.64, 1)`,与全站其它浮层不是一套手感。
* PANEL 弹簧同样是"落位时轻微过冲",但它和弹窗、抽屉用的是同一条曲线,
* 连续操作时不会觉得这个警告框是"另一个世界的弹窗"。
*/
.urgent-fade-enter-active, .urgent-fade-enter-active,
.urgent-fade-leave-active { .urgent-fade-leave-active {
transition: opacity 0.22s ease; transition: opacity var(--ch-dur-base) var(--ch-ease-out);
} }
.urgent-fade-enter-active .urgent-card, .urgent-fade-enter-active .urgent-card {
transition:
transform var(--ch-spring-panel-dur) var(--ch-spring-panel),
opacity var(--ch-dur-base) var(--ch-ease-out);
}
/* 退出比入场快:用户已经决定关掉它了,再让他等一段"有分量"的动画只会显得卡 */
.urgent-fade-leave-active .urgent-card { .urgent-fade-leave-active .urgent-card {
transition: transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1); transition:
transform var(--ch-dur-fast) var(--ch-ease-out),
opacity var(--ch-dur-fast) var(--ch-ease-out);
} }
.urgent-fade-enter-from, .urgent-fade-enter-from,
@@ -284,8 +311,42 @@ function handleConfirm(): void {
opacity: 0; opacity: 0;
} }
.urgent-fade-enter-from .urgent-card, .urgent-fade-enter-from .urgent-card {
transform: scale(0.94) translateY(10px);
}
.urgent-fade-leave-to .urgent-card { .urgent-fade-leave-to .urgent-card {
transform: scale(0.92) translateY(10px); transform: scale(0.97) translateY(-6px);
}
/* 倒计时数字的滚动:旧的向上淡出、新的从下方升上来,两者同时进行 */
@keyframes ring-roll-in {
from {
opacity: 0;
transform: translateY(0.4em);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes ring-roll-out {
from {
opacity: 1;
transform: none;
}
to {
opacity: 0;
transform: translateY(-0.4em);
}
}
.ring-roll-enter-active {
animation: ring-roll-in var(--ch-dur-fast) var(--ch-ease-out) both;
}
.ring-roll-leave-active {
animation: ring-roll-out var(--ch-dur-instant) var(--ch-ease-out) both;
} }
</style> </style>
@@ -0,0 +1,230 @@
/**
* 动效组合式函数 —— beUI 那套动效语言在本端的落地层。
*
* 令牌的**数值**在 `@classhelper/shared` 的 `motion.ts`(单一来源,后端也引用得到);
* 这里只放需要 DOM / Vue 的部分。桌面客户端有一份对应的副本
* (`packages/desktop-client/src/renderer/composables/motion.ts`)——
* 与 `ScoreBarChart.vue` 一样,两端各一份是仓库既有做法,为一个百来行的文件
* 新建 workspace 包不划算。
*/
import { computed, onUnmounted, ref, watch, type CSSProperties, type Ref } from 'vue';
import {
EASE_OUT,
HOVER_CAPABLE_QUERY,
MOTION_DURATION,
REDUCED_MOTION_QUERY,
cubicBezierCss,
motionCssVars,
} from '@classhelper/shared';
/**
* 把动效令牌写进 `:root` 的自定义属性。
*
* **必须在 `app.mount()` 之前调用**:那样 CSS 里可以放心写 `var(--ch-spring-panel)`,
* 首帧之前就已就位,既没有 FOUC,也不需要到处写兜底值。
*
* 弹簧用 CSS `linear()` 表达(见 shared 里 `springLinearEasing` 的说明);
* Chrome 113 以下不支持,此时回落到 `EASE_OUT` 的 cubic-bezier —— 手感略钝但不会失效。
*/
export function applyMotionTokens(): void {
if (typeof document === 'undefined') return;
const linear =
typeof CSS !== 'undefined' &&
typeof CSS.supports === 'function' &&
CSS.supports('transition-timing-function', 'linear(0, 1)');
for (const [name, value] of Object.entries(motionCssVars({ linear }))) {
document.documentElement.style.setProperty(name, value);
}
}
/**
* 监听 `prefers-reduced-motion`。
*
* 三元表达式而非直接返回 `matchMedia(...)` 的结果:这条偏好可能在运行中被改动
* (系统设置里一开关就生效),必须跟着变;JS 驱动的动画(数字滚动、位置测量)
* 拿不到 CSS 那层自动降级,得自己看这个值。
*/
export function useReducedMotion(): Ref<boolean> {
const matches = ref(false);
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return matches;
const query = window.matchMedia(REDUCED_MOTION_QUERY);
matches.value = query.matches;
const update = (): void => {
matches.value = query.matches;
};
// Safari 14 以下只有已废弃的 addListener
if (typeof query.addEventListener === 'function') {
query.addEventListener('change', update);
onUnmounted(() => query.removeEventListener('change', update));
} else if (typeof query.addListener === 'function') {
query.addListener(update);
onUnmounted(() => query.removeListener(update));
}
return matches;
}
/**
* 是否有真正的悬停能力(鼠标 / 触控板)。
*
* 触摸设备在点按时会触发**幽灵 hover** 并一直粘着,直到点别处才消失 ——
* 教室里的希沃触摸屏正属于这种,所有 hover-only 效果(抬升、磁吸、光标跟随)
* 都必须先过这一关,否则触摸屏上会出现"点一下,卡片就永远浮着"。
*/
export function useHoverCapable(): Ref<boolean> {
const capable = ref(false);
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return capable;
const query = window.matchMedia(HOVER_CAPABLE_QUERY);
capable.value = query.matches;
const update = (): void => {
capable.value = query.matches;
};
if (typeof query.addEventListener === 'function') {
query.addEventListener('change', update);
onUnmounted(() => query.removeEventListener('change', update));
} else if (typeof query.addListener === 'function') {
query.addListener(update);
onUnmounted(() => query.removeListener(update));
}
return capable;
}
/** 缓动函数:给定进度 t∈[0,1],返回缓动后的进度。即 EASE_OUT 那条贝塞尔的求值。 */
function easeOutProgress(t: number): number {
const [x1, y1, x2, y2] = EASE_OUT;
// 牛顿迭代反解 x(u) = t,再用同一个 u 求 y —— 三次贝塞尔求值的标准做法
let u = t;
for (let i = 0; i < 6; i += 1) {
const x = 3 * (1 - u) ** 2 * u * x1 + 3 * (1 - u) * u ** 2 * x2 + u ** 3;
const dx = 3 * (1 - u) ** 2 * x1 + 6 * (1 - u) * u * (x2 - x1) + 3 * u ** 2 * (1 - x2);
if (Math.abs(dx) < 1e-6) break;
u = Math.min(1, Math.max(0, u - (x - t) / dx));
}
return 3 * (1 - u) ** 2 * u * y1 + 3 * (1 - u) * u ** 2 * y2 + u ** 3;
}
export interface CountUpOptions {
/** 时长(毫秒)。默认 `MOTION_DURATION.slow`。 */
duration?: number;
}
/**
* 数字滚动:目标值变化时从当前显示值滚过去,而不是直接跳。
*
* 用于仪表盘的统计卡片。`reduced-motion` 下**直接落位**(不启动 rAF)。
*
* 刻意**不**给"首次变化"开豁免:统计页的数据是异步加载的,挂载那一刻目标值还是 0,
* 真正想看到滚动的恰恰是"0 → 实际值"这第一跳;而如果数据在挂载时就已就位
* (缓存命中),`displayed` 初始化即等于目标值、根本不会触发 watch,本来就不会滚。
*/
export function useCountUp(source: () => number, options: CountUpOptions = {}): Ref<number> {
const reduce = useReducedMotion();
const duration = options.duration ?? MOTION_DURATION.slow;
const displayed = ref(source());
let frame = 0;
function stop(): void {
if (frame) {
cancelAnimationFrame(frame);
frame = 0;
}
}
watch(source, (target) => {
stop();
if (reduce.value || duration <= 0) {
displayed.value = target;
return;
}
const from = displayed.value;
const delta = target - from;
if (delta === 0) return;
const start = performance.now();
const tick = (now: number): void => {
const progress = Math.min(1, (now - start) / duration);
displayed.value = from + delta * easeOutProgress(progress);
if (progress < 1) frame = requestAnimationFrame(tick);
else {
displayed.value = target;
frame = 0;
}
};
frame = requestAnimationFrame(tick);
});
onUnmounted(stop);
return displayed;
}
/**
* 列表错峰入场:返回一个"给第 index 项算内联样式"的函数,
* 配合 CSS 类 `.ch-reveal`(见 `styles/index.css`)使用。
*
* 延迟**封顶**(默认第 8 项之后不再累加):一屏几十行时,最后一行不该等一秒才出现。
*/
export function useStaggerIn(step = 35, maxIndex = 8): (index: number) => CSSProperties {
const reduce = useReducedMotion();
return (index: number): CSSProperties =>
reduce.value ? {} : ({ '--ch-reveal-delay': `${Math.min(index, maxIndex) * step}ms` } as CSSProperties);
}
/** 把 reduced-motion 折成一个布尔,供模板里判断"要不要挂动画类"。 */
export function useMotionEnabled(): Ref<boolean> {
const reduce = useReducedMotion();
return computed(() => !reduce.value);
}
/**
* 整页换肤的揭示动画(beUI 的 `theme-toggle`):新旧两帧各存一张快照,
* 让新帧从一个**从点击位置扩散的圆**里长出来 —— 这是"整个界面换了张皮"这件事
* 唯一能让人**看见**的表达方式,比整页硬切一下好懂得多。
*
* 返回的函数直接挂 `@click`:`@click="reveal($event, () => ui.setTheme(next))"`。
*
* 三条守卫缺一不可:
* ① 不认识 View Transition API 的浏览器直接走同步切换;
* ② 系统开了「减少动态效果」时不放全屏动画;
* ③ **文档不可见时不放**(最小化、被遮挡、无头浏览器)—— 那种情况下浏览器不产生帧,
* 而 View Transition 的更新回调要等到下一个渲染时机才执行,**主题会一直不切**。
*/
export function useThemeReveal(): (event: MouseEvent, apply: () => void) => void {
const reduce = useReducedMotion();
return (event, apply) => {
const doc = document as Document & {
startViewTransition?: (callback: () => void) => { ready: Promise<void> };
};
if (
typeof doc.startViewTransition !== 'function' ||
reduce.value ||
document.visibilityState !== 'visible'
) {
apply();
return;
}
const { clientX: x, clientY: y } = event;
void doc
.startViewTransition(apply)
.ready.then(() => {
// 半径取到最远的那个角,圆才能把整屏吃完
const radius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
{
// 比常规过渡长一点 —— 它是一次**整屏**的变化,太快会读成"闪了一下"而不是"换了一层皮"
duration: MOTION_DURATION.slow * 1.5,
// 缓动取自共享令牌,别在这里手写曲线字面量(那是漂移的起点)
easing: cubicBezierCss(EASE_OUT),
pseudoElement: '::view-transition-new(root)',
},
);
})
.catch(() => undefined);
};
}
+4
View File
@@ -10,12 +10,14 @@ import {
Guide, Guide,
Lock, Lock,
Menu, Menu,
Moon,
Notebook, Notebook,
Odometer, Odometer,
Plus, Plus,
Refresh, Refresh,
School, School,
Search, Search,
Sunny,
SwitchButton, SwitchButton,
Trophy, Trophy,
Upload, Upload,
@@ -50,12 +52,14 @@ const APP_ICONS = {
Guide, Guide,
Lock, Lock,
Menu, Menu,
Moon,
Notebook, Notebook,
Odometer, Odometer,
Plus, Plus,
Refresh, Refresh,
School, School,
Search, Search,
Sunny,
SwitchButton, SwitchButton,
Trophy, Trophy,
Upload, Upload,
+231 -14
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { STORAGE_KEYS } from '@classhelper/shared'; import { STORAGE_KEYS } from '@classhelper/shared';
@@ -7,15 +7,31 @@ import { authApi } from '@/api';
import UpdateCheckDialog from '@/components/UpdateCheckDialog.vue'; import UpdateCheckDialog from '@/components/UpdateCheckDialog.vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime'; import { useRealtimeStore } from '@/stores/realtime';
import { useUiStore } from '@/stores/ui';
import { useResponsive } from '@/composables/useResponsive'; import { useResponsive } from '@/composables/useResponsive';
import { useThemeReveal } from '@/composables/motion';
import { APP_TITLE } from '@/config'; import { APP_TITLE } from '@/config';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const ui = useUiStore();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
/**
* 顶栏的主题切换。整页"从点击位置扩散的圆"由 `useThemeReveal()` 提供
* (与桌面客户端共用同一份实现,见 composables/motion.ts)。
*
* 入口只放在**登录后的主布局**里:登录页是刻意固定的深色渐变底(AGENTS.md §5 第 46 条),
* 它不随主题走,在那里放一个切换按钮只会让人以为按钮坏了。
*/
const revealThemeChange = useThemeReveal();
function handleThemeToggle(event: MouseEvent): void {
revealThemeChange(event, () => ui.toggleTheme());
}
/** /**
* 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致): * 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致):
* - 班级管理 / 学生管理 / 教师管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限) * - 班级管理 / 学生管理 / 教师管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
@@ -50,6 +66,110 @@ const activeMenu = computed(() => route.path);
/** 顶栏显示的当前页面标题(小屏下替代侧边栏的"我在哪"提示,1Panel 同款做法) */ /** 顶栏显示的当前页面标题(小屏下替代侧边栏的"我在哪"提示,1Panel 同款做法) */
const currentTitle = computed(() => menuItems.find((item) => item.path === route.path)?.title ?? APP_TITLE); const currentTitle = computed(() => menuItems.find((item) => item.path === route.path)?.title ?? APP_TITLE);
/* -------------------------------------------------------------- 导航指示器 */
/**
* 侧栏激活项的**滑动指示器**(beUI 的 `shared-layout-bg` / tabs 的 layoutId 手法)。
*
* 做法是**一个**绝对定位的药丸跟着激活项平移,而不是让每个菜单项各自画一块底色。
* 后者在切换时是"旧的瞬间消失、新的瞬间出现",看不出两者之间的关系;
* 前者的滑动把「我从这一项去了那一项」这个关系画了出来 —— 这是整个动效层里
* 唯一一处"传达信息"而非"装饰"的动画。
*
* 位置靠**量 DOM**而不是写死数值:菜单项的位置由 Element Plus 的 padding 与行高决定,
* 写死会在组件库升级、字号变化或菜单文案变长时错位。
* 用 ResizeObserver 而不是 window.resize:侧栏宽度/菜单重排都可能由非窗口因素触发。
*/
/**
* `el-aside` 是**组件**而不是原生标签:模板 ref 拿到的是组件实例,
* 直接 `.querySelector()` 会 `TypeError: not a function`(实测踩过,而且被
* `app.config.errorHandler` 吞成了控制台里一行中文异常,界面看着毫无异样)。
* 组件实例的 `$el` 才是它真正渲染出来的那个 `<aside>` 元素。
*/
type AsideRef = { $el?: HTMLElement } | HTMLElement | null;
const asideRef = ref<AsideRef>(null);
function asideElement(): HTMLElement | null {
const value = asideRef.value;
if (!value) return null;
return value instanceof HTMLElement ? value : (value.$el ?? null);
}
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
function syncIndicator(): void {
const aside = asideElement();
const active = aside?.querySelector<HTMLElement>('.el-menu-item.is-active');
if (!aside || !active) {
indicator.value = { ...indicator.value, visible: false };
return;
}
const asideRect = aside.getBoundingClientRect();
const rect = active.getBoundingClientRect();
indicator.value = {
x: rect.left - asideRect.left,
y: rect.top - asideRect.top,
width: rect.width,
height: rect.height,
visible: rect.width > 0 && rect.height > 0,
};
}
const indicatorStyle = computed(() => ({
width: `${indicator.value.width}px`,
height: `${indicator.value.height}px`,
transform: `translate(${indicator.value.x}px, ${indicator.value.y}px)`,
opacity: indicator.value.visible ? '1' : '0',
}));
let asideObserver: ResizeObserver | null = null;
let indicatorFrame = 0;
/**
* 把测量推迟到下一帧再跑。
*
* 侧栏折叠时 `.aside` 的宽度有一整段过渡,`ResizeObserver` 会逐帧回调;若在回调里**同步**
* 读 `getBoundingClientRect()` 再写样式,就是在每一帧中间强行插入一次同步布局
* (Chromium 还会因此报 "ResizeObserver loop completed with undelivered notifications")。
* 放进 rAF 就变成"本帧布局完成后统一量一次",代价是跟随慢一帧,肉眼无感。
*/
function scheduleIndicatorSync(): void {
if (indicatorFrame) return;
indicatorFrame = requestAnimationFrame(() => {
indicatorFrame = 0;
syncIndicator();
});
}
/**
* 首次测量放在 onMounted(而不是 nextTick):子组件先于父组件挂载完成,
* 此刻 `.el-menu-item.is-active` 已在文档里,量完再让浏览器首帧绘制 ——
* 于是指示器**第一次出现就是正确位置**,不会从左上角滑过去。
*/
onMounted(() => {
syncIndicator();
const aside = asideElement();
if (aside && typeof ResizeObserver !== 'undefined') {
asideObserver = new ResizeObserver(scheduleIndicatorSync);
asideObserver.observe(aside);
}
});
onUnmounted(() => {
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
indicatorFrame = 0;
asideObserver?.disconnect();
asideObserver = null;
});
// 路由变化后菜单的 active 类由 Element Plus 更新,等一拍再量
watch(
() => route.path,
() => {
void nextTick(syncIndicator);
},
);
/** 小屏:侧边栏收进抽屉,由顶栏汉堡按钮唤出 */ /** 小屏:侧边栏收进抽屉,由顶栏汉堡按钮唤出 */
const drawerVisible = ref(false); const drawerVisible = ref(false);
@@ -232,11 +352,20 @@ const tourSteps = computed(() => [
<template> <template>
<el-container class="layout"> <el-container class="layout">
<!-- 桌面/平板:常驻深色侧边栏 --> <!-- 桌面/平板:常驻深色侧边栏 -->
<el-aside v-if="!isMobile" width="210px" class="layout-aside ch-sidebar" data-tour="side-nav"> <el-aside
v-if="!isMobile"
ref="asideRef"
width="210px"
class="layout-aside ch-sidebar"
data-tour="side-nav"
>
<div class="brand"> <div class="brand">
<el-icon :size="22"><School /></el-icon> <img class="brand-logo" src="/logo.png" alt="" width="24" height="24" />
<span class="brand-text">班级小助手</span> <span class="brand-text">班级小助手</span>
</div> </div>
<!-- 激活项的滑动指示器:单个药丸在菜单项之间平移,位置由 syncIndicator() 量出来。
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator" :style="indicatorStyle" aria-hidden="true" />
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect"> <el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
<el-menu-item v-for="item in visibleMenuItems" :key="item.path" :index="item.path"> <el-menu-item v-for="item in visibleMenuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon> <el-icon><component :is="item.icon" /></el-icon>
@@ -280,6 +409,28 @@ const tourSteps = computed(() => [
</div> </div>
<div class="header-right"> <div class="header-right">
<!--
主题切换。与桌面客户端同一套(圆形揭示由 useThemeReveal 提供)。
注意 `.header-right` 必须是 flex 容器:Element Plus 给 `.el-button` 的是
`vertical-align: middle`、给 `.el-dropdown` 的是 `vertical-align: top`,
放在普通 block 里两个 inline-level 子元素会按各自的规则落位、错开几像素
(AGENTS.md §5 第 58 条,客户端当初就是这么翻的车)。
-->
<el-tooltip :content="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" placement="bottom">
<el-button
class="theme-toggle"
text
circle
data-test="theme-toggle"
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
@click="handleThemeToggle"
>
<el-icon>
<Sunny v-if="ui.theme === 'dark'" />
<Moon v-else />
</el-icon>
</el-button>
</el-tooltip>
<el-dropdown trigger="click"> <el-dropdown trigger="click">
<span class="user-chip" data-tour="user-chip"> <span class="user-chip" data-tour="user-chip">
<el-icon><UserFilled /></el-icon> <el-icon><UserFilled /></el-icon>
@@ -315,6 +466,18 @@ const tourSteps = computed(() => [
</el-header> </el-header>
<el-main class="layout-main"> <el-main class="layout-main">
<!--
这里**刻意不用** `<transition>` 包 router-view。
页面切换的动效由 `.page > *` 的错峰入场承担(styles/index.css)——
那是随组件挂载同步触发的,不推迟任何东西。
包一层过渡(尤其 `mode="out-in"`)会把"旧组件卸载、新组件挂载"推迟到退场动画结束
(90ms 起)。代价有两个,都不值得:
① 点菜单后有一小段时间页面上还是**上一页**,用户和自动化看到的都是旧内容;
② 退场判定依赖 `animationend`,窗口被遮挡时 Chromium 冻结 CSS 动画、
事件永不触发,新页面就永远不挂载 —— 整个应用卡死在第一页。
真要加回来,必须先确认这两条都有人兜住。
-->
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<component :is="Component" /> <component :is="Component" />
</router-view> </router-view>
@@ -331,7 +494,7 @@ const tourSteps = computed(() => [
> >
<div class="ch-sidebar ch-sidebar-drawer"> <div class="ch-sidebar ch-sidebar-drawer">
<div class="brand"> <div class="brand">
<el-icon :size="22"><School /></el-icon> <img class="brand-logo" src="/logo.png" alt="" width="24" height="24" />
<span class="brand-text">班级小助手</span> <span class="brand-text">班级小助手</span>
</div> </div>
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect"> <el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
@@ -394,6 +557,8 @@ const tourSteps = computed(() => [
.layout-aside { .layout-aside {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 导航指示器(.nav-indicator)的定位父级 */
position: relative;
} }
.brand { .brand {
@@ -411,6 +576,16 @@ const tourSteps = computed(() => [
font-size: 16px; font-size: 16px;
} }
/*
* 品牌标:与应用图标同一份图(`public/logo.png`,由 `pnpm icons` 从 `build/classhelper.png` 生成)。
* 图本身自带圆角与透明边角,所以这里**不要**再加 border-radius / 背景 —— 会叠出第二层圆角。
* `flex: none` 是必需的:`.brand` 是 flex 容器,不加的话侧栏收窄时这张图会被压扁。
*/
.brand-logo {
flex: none;
display: block;
}
.layout-menu { .layout-menu {
border-right: none; border-right: none;
background: transparent; background: transparent;
@@ -420,22 +595,54 @@ const tourSteps = computed(() => [
--el-menu-active-color: #ffffff; --el-menu-active-color: #ffffff;
} }
.layout-menu :deep(.el-menu-item.is-active) { /*
background: #409eff; * 激活项的底色改由导航指示器承担(见 .nav-indicator)。
border-radius: var(--ch-radius-sm); *
margin: 0 8px; * 这里顺手修掉一个既有小毛病:原先激活项自带 `margin: 0 8px`,把普通项的 `2px 8px`
} * 覆盖掉了,于是**每次切换菜单,激活项上下各少 2px、它下面的所有项都跟着跳 4px**。
* 现在激活项不再单独改外边距,切换时整列菜单纹丝不动。
*/
.layout-menu :deep(.el-menu-item) { .layout-menu :deep(.el-menu-item) {
margin: 2px 8px; margin: 2px 8px;
border-radius: var(--ch-radius-sm); border-radius: var(--ch-radius-sm);
/* 压在指示器之上,文字才不会被盖住 */
position: relative;
z-index: 1;
}
.layout-menu :deep(.el-menu-item.is-active) {
color: #ffffff;
}
/*
* 导航指示器:一个跟着激活项平移的药丸。宽高与位移由 syncIndicator() 量出来后内联下发,
* 这里只负责外观与过渡。
*
* 缓动用 LAYOUT 弹簧(397ms)—— 它是"共享布局滑动"的专用参数:比 SWAP(263ms)更有分量,
* 因为这一跳可能跨越好几个菜单项,太快会显得"闪"而不是"滑"。
*/
.nav-indicator {
position: absolute;
top: 0;
left: 0;
z-index: 0;
border-radius: var(--ch-radius-sm);
background: #409eff;
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
pointer-events: none;
will-change: transform;
transition:
transform var(--ch-spring-layout-dur) var(--ch-spring-layout),
width var(--ch-spring-layout-dur) var(--ch-spring-layout),
height var(--ch-spring-layout-dur) var(--ch-spring-layout),
opacity var(--ch-dur-fast) var(--ch-ease-out);
} }
.layout-header { .layout-header {
/* 顶栏跟随圆角设计:底部圆角 + 轻投影,与内容区一体化 */ /* 顶栏跟随圆角设计:底部圆角 + 轻投影,与内容区一体化 */
border-radius: 0 0 var(--ch-radius-lg) var(--ch-radius-lg); border-radius: 0 0 var(--ch-radius-lg) var(--ch-radius-lg);
box-shadow: var(--ch-shadow-sm); box-shadow: var(--ch-shadow-sm);
background: #fff; background: var(--ch-surface);
border-bottom: 1px solid var(--ch-border); border-bottom: 1px solid var(--ch-border);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -453,14 +660,14 @@ const tourSteps = computed(() => [
margin-right: 2px; margin-right: 2px;
border: none; border: none;
border-radius: var(--ch-radius-sm); border-radius: var(--ch-radius-sm);
background: #f2f3f5; background: var(--ch-surface-sunken);
color: #303133; color: var(--ch-text);
cursor: pointer; cursor: pointer;
transition: background 0.18s ease; transition: background 0.18s ease;
} }
.nav-toggle:hover { .nav-toggle:hover {
background: #e6e8eb; background: var(--ch-surface-hover);
} }
.header-left { .header-left {
@@ -484,9 +691,19 @@ const tourSteps = computed(() => [
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
/* 主题按钮与用户名牌之间留一点气口(两者原本紧贴) */
gap: 4px;
flex: 0 0 auto; flex: 0 0 auto;
} }
.theme-toggle {
color: var(--ch-text-muted);
}
.theme-toggle:hover {
color: var(--ch-text);
}
.user-chip { .user-chip {
display: flex; display: flex;
align-items: center; align-items: center;
+19 -1
View File
@@ -3,18 +3,23 @@ import { createPinia } from 'pinia';
import ElementPlus from 'element-plus'; import ElementPlus from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn'; import zhCn from 'element-plus/es/locale/lang/zh-cn';
import 'element-plus/dist/index.css'; import 'element-plus/dist/index.css';
// 深色主题:html.dark 下的 --el-* 变量(品牌侧的语义色覆盖在 styles/index.css 的 html.dark 块)
import 'element-plus/theme-chalk/dark/css-vars.css';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
import { registerAppIcons } from './icons'; import { registerAppIcons } from './icons';
import { applyMotionTokens } from './composables/motion';
import { useUiStore } from './stores/ui';
import '@/styles/index.css'; import '@/styles/index.css';
const app = createApp(App); const app = createApp(App);
const pinia = createPinia();
// 按需注册图标(见 icons.ts:只注册用到的那些,而不是全量 293 个) // 按需注册图标(见 icons.ts:只注册用到的那些,而不是全量 293 个)
registerAppIcons(app); registerAppIcons(app);
app.use(createPinia()); app.use(pinia);
app.use(router); app.use(router);
app.use(ElementPlus, { locale: zhCn }); app.use(ElementPlus, { locale: zhCn });
@@ -31,6 +36,19 @@ app.config.errorHandler = (error, _instance, info) => {
console.error(`[web-admin] 未处理的异常(${info})`, error); console.error(`[web-admin] 未处理的异常(${info})`, error);
}; };
/**
* 动效令牌(beUI 那套缓动与弹簧,见 packages/shared/src/motion.ts)写进 :root。
* 必须在 mount 之前:CSS 里引用的 `var(--ch-spring-*)` 要在首帧之前就已就位,
* 否则第一帧会按"变量不存在"渲染一遍(表现为入场动画闪一下)。
*/
applyMotionTokens();
/**
* 主题也要在 mount **之前**落好:深色用户首帧才不会看到"先白后黑"闪一下。
* 顺序放在动效令牌之后没有讲究,两者互不相干 —— 但都要早于 mount。
*/
useUiStore(pinia).init();
app.mount('#app'); app.mount('#app');
/** /**
+96
View File
@@ -0,0 +1,96 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
/**
* 界面偏好(当前只有主题)。
*
* 机制与桌面客户端(`desktop-client/src/renderer/stores/ui.ts`)保持一套:
* 深色主题 = 在 `<html>` 上挂 `.dark` 类,由 `styles/index.css` 的 `html.dark` 块
* 覆盖语义色令牌,Element Plus 的 `dark/css-vars.css`(main.ts 引入)提供 `--el-*` 的深色默认。
*
* **没有选过时跟随系统**,且系统切换时会实时跟上;一旦用户手动点过切换按钮,
* 就以用户的选择为准,不再被系统改回去(这是主题开关的通行语义:
* 系统的偏好是默认值,不是命令)。
*/
export type AdminTheme = 'light' | 'dark';
const THEME_KEY = 'classhelper.admin.theme';
const DARK_QUERY = '(prefers-color-scheme: dark)';
function systemPrefersDark(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia(DARK_QUERY).matches;
}
/**
* 把主题落到 DOM 上。三件事一件都不能少:
* 1. `html.dark` —— 主题令牌的开关;
* 2. `color-scheme` —— 让**原生控件**(滚动条、`<input type=date>` 的弹层、自动填充底色)
* 也跟着变。只改 CSS 变量的话,滚动条和日期选择器在深色下仍是刺眼的浅色;
* 3. PWA 的 `theme-color` —— 手机端地址栏 / 状态栏的配色,装成应用后尤其明显。
*/
function applyTheme(theme: AdminTheme): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.style.colorScheme = theme;
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', theme === 'dark' ? '#14161a' : '#409eff');
}
function readStored(): AdminTheme | null {
try {
const raw = localStorage.getItem(THEME_KEY);
return raw === 'dark' || raw === 'light' ? raw : null;
} catch {
return null;
}
}
export const useUiStore = defineStore('ui', () => {
const theme = ref<AdminTheme>('light');
/** 用户是否显式选过。没选过时系统偏好说了算,且会实时跟随 */
let explicit = false;
/**
* 在 `app.mount()` **之前**调用:主题类要在首帧之前挂好,
* 否则深色用户会看到"先白后黑"闪一下(与客户端同一条要求)。
*/
function init(): void {
const stored = readStored();
explicit = stored !== null;
theme.value = stored ?? (systemPrefersDark() ? 'dark' : 'light');
applyTheme(theme.value);
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
const query = window.matchMedia(DARK_QUERY);
const onChange = (): void => {
if (explicit) return;
theme.value = query.matches ? 'dark' : 'light';
applyTheme(theme.value);
};
if (typeof query.addEventListener === 'function') query.addEventListener('change', onChange);
else if (typeof query.addListener === 'function') query.addListener(onChange);
}
}
function setTheme(next: AdminTheme): void {
explicit = true;
theme.value = next;
applyTheme(next);
try {
localStorage.setItem(THEME_KEY, next);
} catch {
// 隐私模式下写不进去:本次会话内仍然生效
}
}
function toggleTheme(): void {
setTheme(theme.value === 'dark' ? 'light' : 'dark');
}
return { theme, init, setTheme, toggleTheme };
});
+441 -13
View File
@@ -14,6 +14,25 @@
--ch-header-height: 56px; --ch-header-height: 56px;
--ch-aside-width: 210px; --ch-aside-width: 210px;
/*
* 语义色 —— 组件里**一律引用这些,不要写死颜色**(深色主题靠 html.dark 覆盖这里)。
* 侧栏是刻意固定的深色区(见文件末尾 .ch-sidebar),它自己那套白字不参与主题切换。
*
* ⚠️ 改这些**只改值**。别对这个文件跑"把 #303133 全量替换成 var(--ch-text)"这类批量替换 ——
* 它会把下面几行**定义本身**也换掉(`--ch-text: #303133` → `--ch-text: var(--ch-text)`),
* 自定义属性一旦自引用就整条失效,`--ch-text` 会解析成空串,全站文字颜色静默回落成继承色。
* 实测踩过:批量替换之后 `getPropertyValue('--ch-text')` 返回空,页面看着"还行",
* 但深色主题下文字全变成不合规的继承色。
*/
--ch-surface: #ffffff; /* 卡片 / 弹窗 / 顶栏底 */
--ch-surface-alt: #f7f9fc; /* 表头 / 次级底 */
--ch-surface-sunken: #f2f3f5; /* 内嵌块(代码段、只读预览) */
--ch-surface-hover: #e6e8eb; /* 悬停态底(比 sunken 再深一档) */
--ch-text: #303133;
--ch-text-secondary: #606266;
--ch-text-muted: #909399;
--ch-text-placeholder: #c0c4cc;
/* Element Plus 圆角令牌对齐(组件库内部都引用这些变量,改这里即可全局生效) */ /* Element Plus 圆角令牌对齐(组件库内部都引用这些变量,改这里即可全局生效) */
--el-border-radius-base: var(--ch-radius-sm); --el-border-radius-base: var(--ch-radius-sm);
--el-border-radius-small: 8px; --el-border-radius-small: 8px;
@@ -21,6 +40,35 @@
--el-box-shadow-light: var(--ch-shadow-sm); --el-box-shadow-light: var(--ch-shadow-sm);
} }
/*
* ---------------------------------------------------------------- 深色主题
*
* 与客户端同一套机制:`<html>` 挂 `.dark` 类(见 stores/ui.ts),
* Element Plus 的 `dark/css-vars.css`(main.ts 引入)提供 --el-* 的深色默认,
* 这里再覆盖品牌侧的中性色与圆角令牌。
*
* 只覆盖**语义色**,不动圆角/间距/动效令牌 —— 两套主题的几何必须完全一致,
* 否则切换主题会看到布局跳一下。
*
* 刻意**不随主题**的区域(见 AGENTS.md §5 第 46 条):登录页的深色渐变底、
* 侧栏(本来就是深色)、以及深色底上的白色文字。
*/
html.dark {
--ch-bg: #14161a;
--ch-surface: #1c1f26;
--ch-surface-alt: #23262e;
--ch-surface-sunken: #191c22;
--ch-surface-hover: #2a2e37;
--ch-border: #2e323c;
--ch-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.32);
--ch-shadow-md: 0 6px 24px rgba(0, 0, 0, 0.5);
--ch-text: #e5e7eb;
--ch-text-secondary: #b6bcc8;
--ch-text-muted: #8b93a3;
--ch-text-placeholder: #6b7280;
--el-box-shadow-light: var(--ch-shadow-sm);
}
/* ---------------------------------------------------------------- 圆角设计: /* ---------------------------------------------------------------- 圆角设计:
统一"大圆角 + 轻阴影 + 无硬边框"的观感: 统一"大圆角 + 轻阴影 + 无硬边框"的观感:
@@ -200,7 +248,7 @@
} }
.el-table th.el-table__cell { .el-table th.el-table__cell {
background: #f7f9fc; background: var(--ch-surface-alt);
} }
.el-table__inner-wrapper::before { .el-table__inner-wrapper::before {
@@ -251,7 +299,7 @@ body,
body { body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background: var(--ch-bg); background: var(--ch-bg);
color: #303133; color: var(--ch-text);
/* 小屏上禁止整页横向滚动(内容用卡片内滚动承载) */ /* 小屏上禁止整页横向滚动(内容用卡片内滚动承载) */
overflow-x: hidden; overflow-x: hidden;
} }
@@ -276,7 +324,7 @@ body {
} }
.page-subtitle { .page-subtitle {
color: #909399; color: var(--ch-text-muted);
font-size: 13px; font-size: 13px;
margin: 4px 0 0; margin: 4px 0 0;
} }
@@ -295,23 +343,29 @@ body {
} }
.stat-card { .stat-card {
background: #fff; background: var(--ch-surface);
border-radius: var(--ch-radius-lg); border-radius: var(--ch-radius-lg);
padding: 18px; padding: 18px;
border: none; border: none;
box-shadow: var(--ch-shadow-sm); box-shadow: var(--ch-shadow-sm);
transition: transition:
transform 0.18s ease, transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
box-shadow 0.18s ease; box-shadow var(--ch-dur-base) var(--ch-ease-out);
} }
.stat-card:hover { /*
transform: translateY(-2px); * 抬升效果关在 `@media (hover: hover)` 里:老师会用平板(以及教室的希沃触摸屏)打开
box-shadow: var(--ch-shadow-md); * 管理端,触摸点按会产生"幽灵 hover"并一直粘着,不加这层的话点过的卡片就永远浮着。
*/
@media (hover: hover) and (pointer: fine) {
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--ch-shadow-md);
}
} }
.stat-label { .stat-label {
color: #909399; color: var(--ch-text-muted);
font-size: 13px; font-size: 13px;
} }
@@ -322,13 +376,13 @@ body {
} }
.stat-hint { .stat-hint {
color: #c0c4cc; color: var(--ch-text-placeholder);
font-size: 12px; font-size: 12px;
margin-top: 4px; margin-top: 4px;
} }
.text-muted { .text-muted {
color: #909399; color: var(--ch-text-muted);
} }
.mt-12 { .mt-12 {
@@ -349,7 +403,7 @@ body {
.timetable-meta { .timetable-meta {
font-size: 12px; font-size: 12px;
color: #909399; color: var(--ch-text-muted);
} }
.unread-dot { .unread-dot {
@@ -509,3 +563,377 @@ body {
width: calc(100vw - 32px); width: calc(100vw - 32px);
} }
} }
/* ============================================================================
* 动效层 —— beUI 的动效语言
*
* 令牌(缓动曲线、六组弹簧、时长)在 `packages/shared/src/motion.ts`,
* 由 `main.ts` 在 mount 之前写进 `:root`;这里只负责**用**它们。
* 两端的动效词汇表完全一致(桌面客户端有一份对应的 styles/index.css 段落)。
*
* 分三层,越往下越"重":
* ① 变量调音 —— 改写 Element Plus 自己的过渡变量,一次性改掉全站所有组件库过渡;
* ② 弹簧覆盖 —— 弹窗 / 抽屉 / 浮层这几个"看得见"的地方换成真正的物理曲线;
* ③ 自有动效 —— 页面过渡、列表错峰入场、按钮按压,这些组件库不管的部分。
* ========================================================================== */
/* ------------------------------------------------- ① 变量调音(覆盖组件库)
*
* Element Plus 的每一个过渡都引用这几个变量(`--el-transition-all` / `-fade` /
* `-md-fade` 都是由它们拼出来的),所以调这里等于一次性调完全站 ——
* 比逐个覆盖 `.dialog-fade-*` 之类的类名安全得多(那些类名随组件库版本会变)。
*
* 时长从 300/200ms 收到 200/140ms:原值在密集操作的管理端偏拖沓。
* 缓动换成 beUI 的 EASE_OUT —— 原值是 `cubic-bezier(.23, 1, .32, 1)`,
* 同为"快出慢收"但尾巴更短,收得干脆。
*/
:root {
--el-transition-duration: 200ms;
--el-transition-duration-fast: 140ms;
--el-transition-function-ease-in-out-bezier: cubic-bezier(0.16, 1, 0.3, 1);
--el-transition-function-fast-bezier: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ------------------------------------------------------------ ② 弹簧覆盖 */
/*
* 弹窗(el-dialog)。Element Plus 把入场做在 `.el-overlay-dialog`(承载 .el-dialog 的
* 居中容器)上的两条 keyframes 动画里,遮罩淡入则在 `.dialog-fade-enter-active` 自己身上。
* 两条都要换,否则遮罩先到位、弹窗还在动,看起来是"背景已经变暗了但框还没出来"。
*
* 弹簧 PARAM 的观感说明:总时长 463ms 看着偏长,但它 95% 的行程在 262ms 就走完了,
* 剩下的是极慢的收尾 —— 观感上就是"200ms 到位、再轻轻落定一下",不是"等了半秒"。
*/
@keyframes ch-panel-in {
from {
opacity: 0;
transform: translateY(-14px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes ch-panel-out {
from {
opacity: 1;
transform: none;
}
to {
opacity: 0;
transform: translateY(-8px) scale(0.985);
}
}
/*
* 位移与缩放挂在 `.el-dialog` **自己**身上,不挂在 `.el-overlay-dialog`(居中容器)上。
*
* 挂外层容器有个隐蔽的坑:Element Plus 的「打开完成」回调(@opened)按**根元素**
* (`.el-overlay`,只有 200ms 的淡入)的动画结束触发,而外层容器上的弹簧要跑 463ms ——
* 于是任何在 @opened 里量容器尺寸做自适应的弹窗,都会量到"还带着 scale(0.97) 的缩小版"。
* 客户端作业看板正是这么算 scale 的,实测因此超出屏幕 7px(冒烟断言逮到)。
* 挂到 `.el-dialog` 上还有个好处:全屏弹窗能靠 `is-fullscreen` 精确豁免(见下)。
*/
.dialog-fade-enter-active .el-overlay-dialog,
.dialog-fade-leave-active .el-overlay-dialog {
/* 抵消 Element Plus 自带的那两条(它们同样带 translateY,会与下面这两条叠起来) */
animation: none;
}
.dialog-fade-enter-active .el-dialog {
animation: ch-panel-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
}
.dialog-fade-leave-active .el-dialog {
animation: ch-panel-out var(--ch-dur-fast) var(--ch-ease-out) both;
}
/*
* 全屏弹窗(`is-fullscreen`,如客户端的作业看板)**不做位移缩放**。
* 它本来就是铺满整屏的表面,"从略小处落下来"既看不出效果,又会让内部的自适应布局
* 在 @opened 那一刻量到被 scale 过的容器 —— 测小了会被最小缩放兜住,结果反而超出屏幕。
* 只留淡入。
*/
.dialog-fade-enter-active .el-dialog.is-fullscreen {
animation: ch-fade-in var(--ch-dur-base) var(--ch-ease-out) both;
}
.dialog-fade-leave-active .el-dialog.is-fullscreen {
animation: ch-fade-out var(--ch-dur-fast) var(--ch-ease-out) both;
}
/*
* 确认框(ElMessageBox)。这条沿用了"动画挂在遮罩层上"的做法:
* 它内部没有任何按容器尺寸做自适应的内容,不存在上面那个 @opened 测量的坑,
* 而且 Element Plus 本来就是这么写的(`.el-overlay-message-box` 自带一条 animation)。
*/
.el-overlay-message-box {
animation: ch-panel-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
}
.dialog-fade-enter-active {
animation: ch-fade-in var(--ch-dur-base) var(--ch-ease-out) both;
}
.dialog-fade-leave-active {
animation: ch-fade-out var(--ch-dur-fast) var(--ch-ease-out) both;
}
@keyframes ch-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes ch-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/*
* 抽屉(el-drawer)。和弹窗不同,抽屉的位移由 `.el-drawer` **自身**的
* `transition: all var(--el-transition-duration)` 承载(`.el-drawer-fade-*` 那组类
* 只管遮罩与 overflow)。所以弹簧要挂在 `.el-drawer` 上,且显式列出属性 ——
* 跟它的 `all` 不同,我们不想让背景色的变化也走弹簧曲线。
*/
.el-drawer {
transition:
transform var(--ch-spring-panel-dur) var(--ch-spring-panel),
opacity var(--ch-dur-base) var(--ch-ease-out);
}
/*
* 浮层(下拉菜单 / 选择器 / 气泡提示)。Element Plus 用 `el-zoom-in-*` 系列,
* 从触发点那一侧展开(`transform-origin` 已经由组件库按 placement 设好)。
* 这里换成 SWAP 弹簧:它比 PANEL 短(263ms vs 463ms),因为这类浮层是**高频**出现的,
* 每次点下拉都等半秒会烦。原生的 `scaleY(0)` 起手太"扁",改成轻微缩放 + 位移。
*/
.el-zoom-in-top-enter-active,
.el-zoom-in-top-leave-active,
.el-zoom-in-bottom-enter-active,
.el-zoom-in-bottom-leave-active,
.el-zoom-in-center-enter-active,
.el-zoom-in-center-leave-active,
.el-zoom-in-left-enter-active,
.el-zoom-in-left-leave-active {
transition:
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
opacity var(--ch-dur-fast) var(--ch-ease-out);
}
.el-zoom-in-top-enter-from,
.el-zoom-in-bottom-enter-from,
.el-zoom-in-center-enter-from,
.el-zoom-in-left-enter-from {
opacity: 0;
transform: scale(0.95);
}
.el-zoom-in-top-leave-active,
.el-zoom-in-bottom-leave-active,
.el-zoom-in-center-leave-active,
.el-zoom-in-left-leave-active {
opacity: 0;
transform: scale(0.97);
}
/*
* 消息条与通知。Element Plus 给的是 `opacity .3s, transform .4s` ——
* 0.4s 的位移在"连续弹三条提示"时明显拖沓。换成 240ms 的弹簧位移 + 140ms 淡出。
*/
.el-message {
transition:
opacity var(--ch-dur-fast) var(--ch-ease-out),
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
top var(--ch-spring-swap-dur) var(--ch-spring-swap),
bottom var(--ch-spring-swap-dur) var(--ch-spring-swap);
}
.el-notification {
transition:
opacity var(--ch-dur-fast) var(--ch-ease-out),
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
left var(--ch-spring-swap-dur) var(--ch-spring-swap),
right var(--ch-spring-swap-dur) var(--ch-spring-swap),
top var(--ch-spring-swap-dur) var(--ch-spring-swap),
bottom var(--ch-spring-swap-dur) var(--ch-spring-swap);
}
/* ------------------------------------------------------------ ③ 自有动效 */
/*
* 按钮按压。Element Plus 的按钮只有颜色过渡,没有"按下去"的物理反馈。
*
* 上行/下行用**不同的缓动**,这是这里唯一需要技巧的地方:
* - 按下(`:active` 生效时):140ms 直落,要"跟手",慢了就是延迟;
* - 抬起(`:active` 撤销后回到基础规则):走 PRESS 弹簧,轻轻弹回原位。
* CSS 的规则是"进入某状态用该状态的 transition、离开用目标状态的 transition",
* 所以把弹簧写在基础规则、把快落写在 `:active` 里,正好得到想要的一对。
*/
.el-button {
transition:
transform var(--ch-spring-press-dur) var(--ch-spring-press),
color var(--el-transition-duration-fast) var(--ch-ease-out),
border-color var(--el-transition-duration-fast) var(--ch-ease-out),
background-color var(--el-transition-duration-fast) var(--ch-ease-out),
box-shadow var(--el-transition-duration-fast) var(--ch-ease-out);
}
.el-button:not(.is-disabled):active {
transform: scale(var(--ch-press-scale));
transition-duration: var(--ch-dur-instant);
transition-timing-function: var(--ch-ease-out);
}
/*
* 列表错峰入场。配合 `composables/motion.ts` 的 `useStaggerIn()` 使用 ——
* 那个函数按索引下发 `--ch-reveal-delay`,延迟在 CSS 里生效。
* `both` 是必需的:否则带延迟的元素会在延迟期间先按终态渲染一下(闪一帧)。
*/
@keyframes ch-reveal-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.ch-reveal {
animation: ch-reveal-in var(--ch-dur-slow) var(--ch-ease-out) both;
animation-delay: var(--ch-reveal-delay, 0ms);
}
/*
* 页面切换**没有**容器级的过渡 —— 这里曾经有一对 `.ch-page-enter-active` /
* `-leave-active`(配 `<transition mode="out-in">`),已经去掉,原因写在
* AdminLayout.vue 的 router-view 注释里(延期换页 + 窗口被遮挡时卡死)。
* 页面切换的动效全部由下面的 `.page > *` 错峰入场承担。
*/
/*
* 页面内容的错峰入场。
*
* 挂在 `.page > *` 上而不是逐个视图去写类名:所有视图的根容器都叫 `.page`
* (这条约定由 `.page-header` / `.page-title` / `.toolbar` 等共享类名一起维持),
* 于是一条规则覆盖全部 12 个页面,新增视图自动享有,也不会有人忘记加。
*
* 延迟按**块**递进(30ms 一档、封顶 180ms):一屏通常是"页头 + 两三块内容",
* 这个节奏读起来是依次浮现;给更多块继续累加就变成排队了,所以第 7 块起不再递增。
*/
@keyframes ch-page-block-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.page > * {
animation: ch-page-block-in var(--ch-dur-slow) var(--ch-ease-out) both;
}
.page > *:nth-child(2) {
animation-delay: 30ms;
}
.page > *:nth-child(3) {
animation-delay: 60ms;
}
.page > *:nth-child(4) {
animation-delay: 90ms;
}
.page > *:nth-child(5) {
animation-delay: 120ms;
}
.page > *:nth-child(6) {
animation-delay: 150ms;
}
.page > *:nth-child(n + 7) {
animation-delay: 180ms;
}
/*
* 三处例外,都是"再动一遍就成了两层动画"或"动了会破坏别人的测量":
* ① `v-loading` 的遮罩是插在 `.page` 里的直接子元素,让它跟着淡入会闪;
* ② 仪表盘的统计网格内部每张卡片自带 `.ch-reveal`(错峰由卡片承担,
* 才能看到七张卡依次浮现),网格本身再动一遍就叠了;
* ③ **弹窗遮罩**(`.el-overlay`)。Element Plus 的 `el-dialog` 默认 `append-to-body=false`,
* 声明在视图里的弹窗,其遮罩是 `.page` 的直接子元素;而它是 `position: fixed; inset: 0`
* 的全屏层,下移 6px 会把刚好贴边的自适应内容顶出屏幕。全屏层本来也不该参与
* "内容块依次浮现" —— 它一出现就该盖住整屏。
*/
.page > .el-loading-mask,
.page > .el-overlay,
.page > .stat-grid {
animation: none;
}
/* ------------------------------------------------------- 降低动态效果偏好
*
* 系统开了「减少动态效果」时,把全站的动画压到 1ms —— 不是删掉,
* 而是让它在同一帧内完成(`transition: none` 会让依赖 transitionend 的逻辑失灵)。
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
/*
* 例外:加载指示旋的是"状态"不是"装饰"。把它一起停掉会让界面看起来像卡死了
* (用户会去重复点击),所以这里改为**放慢**而不是停。
*/
.el-icon.is-loading,
.el-loading-spinner .circular,
.el-loading-spinner svg,
.is-loading {
animation-duration: 1.6s !important;
animation-iteration-count: infinite !important;
}
}
/* ------------------------------------------------- 整页换肤(View Transition)
*
* `useThemeReveal()`(composables/motion.ts,与桌面客户端共用同一份实现)用
* View Transition API 做"从点击位置扩散的圆"。浏览器默认会给新旧两帧做一次交叉淡入 ——
* 这里把它关掉,改由 JS 用 WAAPI 驱动 `::view-transition-new(root)` 的 clip-path。
* 不关掉的话两套动画会叠在一起,观感是"圆在扩散的同时整页又在闪"。
*
* 层级:老帧在下(z-index 1)、新帧在上(z-index 2),新帧的圆从无到有地盖住老帧。
* 不支持这个 API 的浏览器不会渲染这些伪元素,规则自然失效(JS 侧也有守卫)。
*/
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2;
}
+15 -5
View File
@@ -9,6 +9,8 @@ import {
type DashboardSummary, type DashboardSummary,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { dashboardApi, notificationApi } from '@/api'; import { dashboardApi, notificationApi } from '@/api';
import StatCard from '@/components/StatCard.vue';
import { useStaggerIn } from '@/composables/motion';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime'; import { useRealtimeStore } from '@/stores/realtime';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
@@ -38,6 +40,12 @@ const statCards = computed(() => {
]; ];
}); });
/**
* 统计卡片的错峰入场(beUI 的 stagger reveal):按索引下发 `--ch-reveal-delay`,
* 让七张卡片依次浮现而不是"啪"地一起出现。延迟在 `.ch-reveal` 里生效。
*/
const revealStyle = useStaggerIn();
async function load(): Promise<void> { async function load(): Promise<void> {
loading.value = true; loading.value = true;
try { try {
@@ -93,11 +101,13 @@ function openNotification(item: NotificationDto): void {
</div> </div>
<div class="stat-grid"> <div class="stat-grid">
<div v-for="card in statCards" :key="card.label" class="stat-card"> <StatCard
<div class="stat-label">{{ card.label }}</div> v-for="(card, index) in statCards"
<div class="stat-value">{{ card.value }}</div> :key="card.label"
<div class="stat-hint">{{ card.hint }}</div> v-bind="card"
</div> class="ch-reveal"
:style="revealStyle(index)"
/>
</div> </div>
<el-row :gutter="12" class="mt-16"> <el-row :gutter="12" class="mt-16">
@@ -623,14 +623,14 @@ onUnmounted(() => {
width: 100%; width: 100%;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
color: #909399; color: var(--ch-text-muted);
margin-top: 4px; margin-top: 4px;
} }
.content-block { .content-block {
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.7; line-height: 1.7;
background: #fafafa; background: var(--ch-surface-sunken);
border-radius: 12px; border-radius: 12px;
padding: 12px; padding: 12px;
margin: 0; margin: 0;
@@ -659,7 +659,7 @@ onUnmounted(() => {
.submission-list { .submission-list {
max-height: 46vh; max-height: 46vh;
overflow-y: auto; overflow-y: auto;
border: 1px solid #eef0f5; border: 1px solid var(--ch-border);
border-radius: 12px; border-radius: 12px;
padding: 12px; padding: 12px;
} }
+15 -2
View File
@@ -34,7 +34,7 @@ async function submit(): Promise<void> {
<div class="login-page"> <div class="login-page">
<div class="login-card"> <div class="login-card">
<div class="login-brand"> <div class="login-brand">
<el-icon :size="30" color="#409eff"><School /></el-icon> <img class="login-logo" src="/logo.png" alt="" width="44" height="44" />
<div> <div>
<h1 class="login-title">班级小助手</h1> <h1 class="login-title">班级小助手</h1>
<p class="login-subtitle">Web 管理端 · 教师 / 管理员登录</p> <p class="login-subtitle">Web 管理端 · 教师 / 管理员登录</p>
@@ -81,6 +81,13 @@ async function submit(): Promise<void> {
border-radius: 14px; border-radius: 14px;
padding: 30px 28px 22px; padding: 30px 28px 22px;
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28); box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
/*
* 入场用 PANEL 弹簧(beUI 的 center-morph-modal 观感:从略小、略高处落下来)。
* 登录页是整个应用里最"一次性"、最值得给一点分量的一屏 ——
* 它不像弹窗那样会被反复开关,慢一点不会变成等待。
* keyframes 定义在 styles/index.css 的动效层里(全局,scoped 样式也能引用)。
*/
animation: ch-panel-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -105,6 +112,12 @@ async function submit(): Promise<void> {
margin-bottom: 22px; margin-bottom: 22px;
} }
/* 品牌标:与应用图标同一份图(`public/logo.png`)。图自带圆角与透明边角,不要再加 border-radius。 */
.login-logo {
flex: none;
display: block;
}
.login-title { .login-title {
font-size: 20px; font-size: 20px;
margin: 0; margin: 0;
@@ -113,7 +126,7 @@ async function submit(): Promise<void> {
.login-subtitle { .login-subtitle {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 13px; font-size: 13px;
color: #909399; color: var(--ch-text-muted);
} }
.login-button { .login-button {
@@ -352,7 +352,7 @@ async function submitCall(): Promise<void> {
.call-label { .call-label {
font-size: 13px; font-size: 13px;
color: #606266; color: var(--ch-text-secondary);
margin-bottom: 8px; margin-bottom: 8px;
} }
+45 -15
View File
@@ -1,13 +1,17 @@
/** /**
* 服务端 + Web 管理端 生产打包脚本:pnpm dist:server * 服务端 + Web 管理端 生产打包脚本:pnpm dist:server
* *
* 交付产物统一落到 releases/server/<版本>/(releases/ 不入库,与客户端/插件同级):
*
* 产出(Windows): * 产出(Windows):
* release-server/classhelper-server/ 可直接运行的免安装目录 * releases/server/<版本>/免安装/ 可直接运行的免安装目录
* release-server/班级小助手服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS) * releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS)
* releases/server/<版本>/构建中间/server-installer.generated.nsi NSIS 生成脚本(可重新生成)
* *
* 产出(Linux,加 --platform linux,即 pnpm dist:server:linux): * 产出(Linux,加 --platform linux,即 pnpm dist:server:linux):
* release-server/classhelper-server-linux-x64-<版本>.tar.gz Linux 一键安装器的输入 * releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz Linux 一键安装器的输入
* release-server/classhelper-server-linux-x64-<版本>.tar.gz.sha256 * releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz.sha256
* releases/server/<版本>/linux-x64/classhelper-server-linux-x64/ tar 的解包目录(同内容留档)
* *
* 为什么 Linux 包要在 Linux 上构建:`@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` 都是 * 为什么 Linux 包要在 Linux 上构建:`@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` 都是
* 平台相关依赖,在 Windows 上 npm install 出来的 node_modules 装到 Linux 跑不起来。 * 平台相关依赖,在 Windows 上 npm install 出来的 node_modules 装到 Linux 跑不起来。
@@ -32,7 +36,16 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const serverDir = path.join(root, 'packages', 'server'); const serverDir = path.join(root, 'packages', 'server');
const webDir = path.join(root, 'packages', 'web-admin'); const webDir = path.join(root, 'packages', 'web-admin');
const deployDir = path.join(root, 'deploy'); const deployDir = path.join(root, 'deploy');
const outRoot = path.join(root, 'release-server');
const VERSION = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
const PRODUCT = '班级小助手服务端';
const PORT = '4000';
const LINUX_ARCH = 'x64';
// 产物根:releases/server/<版本>/(releases/ 不入库,见 AGENTS.md §4)
const outRoot = path.join(root, 'releases', 'server', VERSION);
const INSTALLER_DIR = path.join(outRoot, '安装包');
const GENERATED_DIR = path.join(outRoot, '构建中间');
/** win = 免安装目录 + NSIS 安装程序;linux = 带运维工具的 tar.gz(给 deploy/install.sh 用) */ /** win = 免安装目录 + NSIS 安装程序;linux = 带运维工具的 tar.gz(给 deploy/install.sh 用) */
const PLATFORM = (() => { const PLATFORM = (() => {
@@ -43,13 +56,17 @@ const PLATFORM = (() => {
})(); })();
const isLinux = PLATFORM === 'linux'; const isLinux = PLATFORM === 'linux';
const staging = path.join(outRoot, isLinux ? 'classhelper-server-linux-x64' : 'classhelper-server'); // Linux 侧 staging 的目录名会原样成为 tar 里的顶层目录(deploy/install.sh 用 --strip-components=1
// 展开),保持 ASCII 不动;Windows 侧目录名只影响本地留档,直接用中文分类名。
const staging = isLinux
? path.join(outRoot, 'linux-x64', 'classhelper-server-linux-x64')
: path.join(outRoot, '免安装');
const VERSION = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; const LINUX_TARBALL = path.join(
const PRODUCT = '班级小助手服务端'; outRoot,
const PORT = '4000'; 'linux-x64',
const LINUX_ARCH = 'x64'; `classhelper-server-linux-${LINUX_ARCH}-${VERSION}.tar.gz`,
const LINUX_TARBALL = path.join(outRoot, `classhelper-server-linux-${LINUX_ARCH}-${VERSION}.tar.gz`); );
const log = (message) => console.log(`[dist:server] ${message}`); const log = (message) => console.log(`[dist:server] ${message}`);
@@ -770,7 +787,15 @@ function packLinuxTarball() {
fs.rmSync(LINUX_TARBALL, { force: true }); fs.rmSync(LINUX_TARBALL, { force: true });
const topDir = path.basename(staging); // tar 里套一层目录,安装器用 --strip-components=1 展开 const topDir = path.basename(staging); // tar 里套一层目录,安装器用 --strip-components=1 展开
log(`打包 ${path.relative(root, LINUX_TARBALL)} ...`); log(`打包 ${path.relative(root, LINUX_TARBALL)} ...`);
run('tar', ['-czf', LINUX_TARBALL, '-C', path.dirname(staging), topDir]); // GNU tar 在 Windows 路径上有两个坑(Linux 上路径没有盘符、没有反斜杠,永远踩不到,
// 只有交叉构建会碰到):
// ① `-f F:\...tar.gz` 里的**盘符冒号**会被当成 `主机:路径` 的远程归档写法,
// 报 `tar (child): Cannot connect to F: resolve failed` 并以退出码 2 失败;
// ② 反斜杠会被当成转义符。
// 因此统一转成正斜杠,并加 `--force-local` 明确"这是本地文件"。
// 该选项在 Linux 上无害(本地路径本来就该走本地分支),所以无条件加上,不做平台分支。
const toPosix = (target) => target.replace(/\\/g, '/');
run('tar', ['--force-local', '-czf', toPosix(LINUX_TARBALL), '-C', toPosix(path.dirname(staging)), topDir]);
const digest = crypto.createHash('sha256').update(fs.readFileSync(LINUX_TARBALL)).digest('hex'); const digest = crypto.createHash('sha256').update(fs.readFileSync(LINUX_TARBALL)).digest('hex');
const shaFile = `${LINUX_TARBALL}.sha256`; const shaFile = `${LINUX_TARBALL}.sha256`;
@@ -802,7 +827,10 @@ function buildInstaller() {
return null; return null;
} }
const outFile = path.join(outRoot, `${PRODUCT}-${VERSION}-x64-setup.exe`); fs.mkdirSync(INSTALLER_DIR, { recursive: true });
fs.mkdirSync(GENERATED_DIR, { recursive: true });
const outFile = path.join(INSTALLER_DIR, `${PRODUCT}-${VERSION}-x64-setup.exe`);
const templatePath = path.join(root, 'scripts', 'nsis', 'server-installer.nsi'); const templatePath = path.join(root, 'scripts', 'nsis', 'server-installer.nsi');
const script = fs const script = fs
.readFileSync(templatePath, 'utf8') .readFileSync(templatePath, 'utf8')
@@ -810,10 +838,12 @@ function buildInstaller() {
.replaceAll('@VERSION@', VERSION) .replaceAll('@VERSION@', VERSION)
.replaceAll('@STAGING@', staging) .replaceAll('@STAGING@', staging)
.replaceAll('@OUTFILE@', outFile) .replaceAll('@OUTFILE@', outFile)
.replaceAll('@ICON@', path.join(root, 'build', 'icon.ico')) // 安装程序图标:用设计导出的 build/classhelper.ico(与客户端同一份);
// 旧的 build/icon.ico 是 `pnpm icons` 从 SVG 渲染出来的那一套,已不参与交付。
.replaceAll('@ICON@', path.join(root, 'build', 'classhelper.ico'))
.replaceAll('@PORT@', PORT); .replaceAll('@PORT@', PORT);
const generatedScript = path.join(outRoot, 'server-installer.generated.nsi'); const generatedScript = path.join(GENERATED_DIR, 'server-installer.generated.nsi');
// NSIS 通过 BOM 识别 UTF-8,脚本含中文必须写入 BOM,否则报 "Bad text encoding" // NSIS 通过 BOM 识别 UTF-8,脚本含中文必须写入 BOM,否则报 "Bad text encoding"
fs.writeFileSync(generatedScript, `\uFEFF${script}`, 'utf8'); fs.writeFileSync(generatedScript, `\uFEFF${script}`, 'utf8');
+14 -13
View File
@@ -1,6 +1,11 @@
/** /**
* 图标生成启动器:pnpm icons * 图标生成启动器:pnpm icons
* 复用 desktop-client 的 Electron 运行时渲染 SVG 并导出 PNG/ICO。 *
* 真正的活儿在 `scripts/icons/render.cjs`(跑在 Electron 里,用 Chromium 的 canvas
* 把 `build/classhelper.png` 重采样成各端要的尺寸)。这里只负责把 Electron 拉起来。
*
* 图形源是**手工放入的设计导出**,不从任何 SVG 渲染 —— 改图标请换
* `build/classhelper.{png,ico}` 这两个文件,别改这个脚本。
*/ */
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
@@ -16,6 +21,14 @@ if (!fs.existsSync(electronDir)) {
process.exit(2); process.exit(2);
} }
for (const source of ['classhelper.png', 'classhelper.ico']) {
const file = path.join(root, 'build', source);
if (!fs.existsSync(file)) {
console.error(`[icons] 缺少图形源 build/${source}(设计导出,需手工放入)`);
process.exit(2);
}
}
const executable = resolveElectronExecutable(electronDir); const executable = resolveElectronExecutable(electronDir);
const child = spawn(executable, [path.join(root, 'scripts', 'icons', 'render.cjs')], { const child = spawn(executable, [path.join(root, 'scripts', 'icons', 'render.cjs')], {
@@ -25,17 +38,5 @@ const child = spawn(executable, [path.join(root, 'scripts', 'icons', 'render.cjs
}); });
child.on('exit', (code) => { child.on('exit', (code) => {
if (code === 0) {
// 托盘图标需要一张 PNG 随 renderer 一起打包(Vite 会把 public/ 复制到 dist/renderer)
try {
const source = path.join(root, 'packages', 'desktop-client', 'build', 'icon.png');
const targetDir = path.join(root, 'packages', 'desktop-client', 'public');
fs.mkdirSync(targetDir, { recursive: true });
fs.copyFileSync(source, path.join(targetDir, 'tray.png'));
console.log('[icons] 托盘图标已同步:packages/desktop-client/public/tray.png');
} catch (error) {
console.warn('[icons] 同步托盘图标失败(不影响构建)', error);
}
}
process.exit(code ?? 1); process.exit(code ?? 1);
}); });
+193 -114
View File
@@ -1,157 +1,236 @@
/** /**
* 用 Electron 渲染 SVG 并导出应用图标(PNG + ICO)。 * 应用图标导出器(`pnpm icons`)—— **全项目所有图标都从这里出**。
* *
* 为什么用 Electron:可以复用系统字体渲染中文标识,且无需任何图形库/ImageMagick。 * ## 图形源(唯一)
* *
* 产物: * 仓库根 `build/` 下那两份是**手工放入的设计导出**,不要用 `pnpm icons` 去覆盖它们:
* packages/web-admin/public/icon-{192,512}.png PWA 图标 *
* packages/web-admin/public/apple-touch-icon.png iOS 图标 * build/classhelper.png 345×339 PNG(调色板 + tRNS,带透明)—— 所有 PNG 尺寸由它重采样;
* packages/web-admin/public/favicon.svg 浏览器标签页图标 * build/classhelper.ico 9 个尺寸(16/24/32/48/64/72/96/128/256,BMP 载荷)—— 由设计工具
* packages/desktop-client/build/icon.png 512×512(electron-builder 用) * **逐尺寸栅格化**,比把位图缩下去干净,因此**原样透传**,不重新生成。
* packages/desktop-client/build/icon.ico 多尺寸 ICO(安装包/EXE 图标) *
* build/icon.ico 服务端安装程序图标 * 历史上这里是用 Electron 渲染一段内联 SVG("蓝色圆角块 + 白色『班』字")生成全套图标;
* 换成设计导出的位图之后,那套 SVG 已删除,本脚本只做重采样与分发。
*
* ## 产物
*
* packages/web-admin/public/icon-192.png PWA 图标
* packages/web-admin/public/icon-512.png PWA 图标(manifest 里也当 maskable 用)
* packages/web-admin/public/apple-touch-icon.png iOS 主屏图标(180)
* packages/web-admin/public/favicon.png 标签页图标(64)
* packages/web-admin/public/favicon.svg 标签页图标(内嵌 128 位图的 SVG 壳)
* packages/web-admin/public/logo.png **界面内**的品牌标(侧栏 / 登录页,128)
* packages/classisland-plugin/icon.png ClassIsland 插件图标(256)
* packages/desktop-client/build/classhelper.{png,ico} 图标源在客户端的镜像
* packages/desktop-client/build/icon.{png,ico} electron-builder 的**兜底约定名**
* (`win.icon` 显式指向 classhelper.ico,
* 这两个是与它同步的副本,别再删)
* packages/desktop-client/public/tray.png 系统托盘图标(32)
* packages/desktop-client/public/logo.png **界面内**的品牌标(侧栏 / 登录页,128)
* website/assets/icon.png 官网(favicon / apple-touch / 页面内 brand)
*
* ## 为什么还是 Electron
*
* 要把一张位图重采样到 8 个尺寸(其中 512 那档是**放大**),仓库里没有任何图形库;
* 而 Electron 自带 Chromium 的 canvas,重采样质量比 `nativeImage.resize()` 好得多
* (后者一步到位、没有逐级折半,345→16 会走样)。为这一个脚本引 sharp/jimp 进
* devDependencies 更不划算 —— 这条流水线从有图标那天起就是跑在 Electron 上的。
*
* ## 三条实现要点
*
* ① **非正方形源**:345×339 直接 `drawImage` 到正方形画布会拉变形,必须等比缩放后居中、
* 四周补透明(补边只有 1.7%,肉眼不可见);
* ② **逐级折半**:canvas 的降采样是一次双线性,345 → 16 一步到位会糊成一团,
* 先折到目标的两倍以内再出最后一步;
* ③ 图片以 `data:` URL 进页面:`data:` 图**不会污染 canvas**,`toDataURL()` 拿得到数据;
* 换成 `file://` 就会因跨源被拒(Chromium 里 file:// 页面是 opaque origin)。
*/ */
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { app, BrowserWindow } = require('electron'); const { app, BrowserWindow } = require('electron');
const ROOT = path.resolve(__dirname, '..', '..'); const ROOT = path.resolve(__dirname, '..', '..');
const SIZES = [16, 24, 32, 48, 64, 128, 180, 192, 256, 512]; const BUILD_DIR = path.join(ROOT, 'build');
const SOURCE_PNG = path.join(BUILD_DIR, 'classhelper.png');
const SOURCE_ICO = path.join(BUILD_DIR, 'classhelper.ico');
/** 品牌图形:蓝色渐变圆角方块 + 白色"班"字 + 书本底纹 */ /** 需要从源 PNG 重采样出来的全部尺寸(去重后每个尺寸只渲染一次) */
function logoSvg(size) { const PNG_SIZES = [32, 64, 128, 180, 192, 256, 512];
const radius = Math.round(size * 0.22);
const fontSize = Math.round(size * 0.52); /** 客户端产物目录(electron-builder 的 buildResources) */
const bookY = Math.round(size * 0.74); const DESKTOP_BUILD = path.join(ROOT, 'packages', 'desktop-client', 'build');
const bookWidth = Math.round(size * 0.5);
const bookX = Math.round((size - bookWidth) / 2); function readSourceDataUrl() {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"> if (!fs.existsSync(SOURCE_PNG)) {
<defs> throw new Error(`缺少图形源 ${path.relative(ROOT, SOURCE_PNG)}(设计导出,需手工放入)`);
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1"> }
<stop offset="0%" stop-color="#4facfe"/> return `data:image/png;base64,${fs.readFileSync(SOURCE_PNG).toString('base64')}`;
<stop offset="55%" stop-color="#409eff"/>
<stop offset="100%" stop-color="#1f6feb"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="${size}" height="${size}" rx="${radius}" ry="${radius}" fill="url(#g)"/>
<text x="50%" y="52%" text-anchor="middle" dominant-baseline="central"
font-family="'Microsoft YaHei','PingFang SC',sans-serif" font-weight="700"
font-size="${fontSize}" fill="#ffffff">班</text>
<rect x="${bookX}" y="${bookY}" width="${bookWidth}" height="${Math.max(2, Math.round(size * 0.045))}"
rx="${Math.round(size * 0.02)}" fill="#ffffff" opacity="0.9"/>
</svg>`;
} }
function htmlFor(size) { /**
return `<!doctype html><html><head><meta charset="utf-8" /><style> * 页面侧的重采样脚本。返回一张 `size×size`、内容等比居中、四周透明的 PNG(data URL)。
html,body{margin:0;padding:0;background:transparent;width:${size}px;height:${size}px;overflow:hidden} *
svg{display:block} * 以字符串形式注入页面主世界执行(`executeJavaScript` 默认就在主世界,
</style></head><body>${logoSvg(size)}</body></html>`; * 不受 `contextIsolation` 影响),因此这里不能用反引号。
*/
function resampleScript(size, sourceUrl) {
return `(async () => {
const SIZE = ${size};
const img = new Image();
img.src = ${JSON.stringify(sourceUrl)};
await img.decode();
// 等比放进 SIZE×SIZE 的目标框
const fit = Math.min(SIZE / img.naturalWidth, SIZE / img.naturalHeight);
const boxW = Math.max(1, Math.round(img.naturalWidth * fit));
const boxH = Math.max(1, Math.round(img.naturalHeight * fit));
// 逐级折半:一步到位降采样会走样,先折到目标的两倍以内
let current = img;
while (Math.round(current.width / 2) >= boxW && Math.round(current.height / 2) >= boxH) {
const half = document.createElement('canvas');
half.width = Math.max(1, Math.round(current.width / 2));
half.height = Math.max(1, Math.round(current.height / 2));
const halfCtx = half.getContext('2d');
halfCtx.imageSmoothingEnabled = true;
halfCtx.imageSmoothingQuality = 'high';
halfCtx.drawImage(current, 0, 0, half.width, half.height);
current = half;
}
// 最后一次:按 current 的实际比例重算一次落位尺寸,避免折半取整带来的亚像素拉伸
const finalFit = Math.min(SIZE / current.width, SIZE / current.height);
const drawW = Math.max(1, Math.round(current.width * finalFit));
const drawH = Math.max(1, Math.round(current.height * finalFit));
const canvas = document.createElement('canvas');
canvas.width = SIZE;
canvas.height = SIZE;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(current, Math.floor((SIZE - drawW) / 2), Math.floor((SIZE - drawH) / 2), drawW, drawH);
return canvas.toDataURL('image/png');
})()`;
} }
/** 把若干 PNG 打包成 ICO 容器(ICO 允许直接内嵌 PNG 数据) */ async function renderSizes(window, sourceUrl) {
function buildIco(entries) {
const count = entries.length;
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0); // reserved
header.writeUInt16LE(1, 2); // type: icon
header.writeUInt16LE(count, 4);
const directory = Buffer.alloc(16 * count);
let offset = 6 + 16 * count;
const payloads = [];
entries.forEach((entry, index) => {
const base = index * 16;
directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, base + 0); // width
directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, base + 1); // height
directory.writeUInt8(0, base + 2); // palette
directory.writeUInt8(0, base + 3); // reserved
directory.writeUInt16LE(1, base + 4); // color planes
directory.writeUInt16LE(32, base + 6); // bits per pixel
directory.writeUInt32LE(entry.data.length, base + 8);
directory.writeUInt32LE(offset, base + 12);
offset += entry.data.length;
payloads.push(entry.data);
});
return Buffer.concat([header, directory, ...payloads]);
}
async function renderSizes() {
const window = new BrowserWindow({
width: 512,
height: 512,
show: false,
transparent: true,
frame: false,
webPreferences: { offscreen: false, backgroundThrottling: false },
});
const tempDir = path.join(ROOT, '.cache', 'icons');
fs.mkdirSync(tempDir, { recursive: true });
const pngBySize = new Map(); const pngBySize = new Map();
for (const size of PNG_SIZES) {
for (const size of SIZES) { const dataUrl = await window.webContents.executeJavaScript(resampleScript(size, sourceUrl));
const file = path.join(tempDir, `logo-${size}.html`); if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
fs.writeFileSync(file, htmlFor(size), 'utf8'); throw new Error(`渲染 ${size}×${size} 失败:页面没有返回 PNG data URL`);
window.setContentSize(size, size);
await window.loadFile(file);
await new Promise((resolve) => setTimeout(resolve, 150));
let buffer = (await window.webContents.capturePage({ x: 0, y: 0, width: size, height: size })).toPNG();
// 隐藏窗口在部分环境下会截到空图,短暂显示后重试
if (!buffer || buffer.length === 0) {
window.showInactive();
await new Promise((resolve) => setTimeout(resolve, 250));
buffer = (await window.webContents.capturePage({ x: 0, y: 0, width: size, height: size })).toPNG();
window.hide();
}
if (!buffer || buffer.length === 0) {
throw new Error(`渲染 ${size}×${size} 图标失败:截图为空`);
} }
const buffer = Buffer.from(dataUrl.slice('data:image/png;base64,'.length), 'base64');
if (buffer.length === 0) throw new Error(`渲染 ${size}×${size} 失败:数据为空`);
console.log(`[icons] ${size}×${size} -> ${buffer.length} 字节`); console.log(`[icons] ${size}×${size} -> ${buffer.length} 字节`);
pngBySize.set(size, buffer); pngBySize.set(size, buffer);
} }
window.destroy();
return pngBySize; return pngBySize;
} }
/** ICO 容器头解析,只用来核对设计导出里到底带了哪几个尺寸 */
function readIcoSizes(buffer) {
if (buffer.length < 6 || buffer.readUInt16LE(0) !== 0 || buffer.readUInt16LE(2) !== 1) {
throw new Error(`${path.relative(ROOT, SOURCE_ICO)} 不是合法的 ICO 容器`);
}
const count = buffer.readUInt16LE(4);
const sizes = [];
for (let index = 0; index < count; index += 1) {
const base = 6 + index * 16;
sizes.push(`${buffer[base] || 256}×${buffer[base + 1] || 256}`);
}
return sizes;
}
/**
* 标签页图标的 SVG 壳。
*
* 设计只给了位图,没有矢量源 —— 所以这里不做"假矢量",而是把一张 128 的 PNG 原样嵌进去。
* 保留 `favicon.svg` 这个文件名是为了不动 `index.html` 里那条 `<link rel="icon" type="image/svg+xml">`:
* 现代浏览器优先取 SVG 那条,删掉它反而要改 HTML、改文档。
* 128 足够覆盖任何真实标签页尺寸(32px @4x = 128 物理像素)。
*/
function buildFaviconSvg(png128) {
const base64 = png128.toString('base64');
return `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<image href="data:image/png;base64,${base64}" width="128" height="128" />
</svg>
`;
}
/** 内容一致就不写盘:反复跑 `pnpm icons` 不该在 git 里刷出无意义的改动 */
function writeIfChanged(file, data) { function writeIfChanged(file, data) {
if (!data || (Buffer.isBuffer(data) && data.length === 0)) { if (!data || data.length === 0) {
throw new Error(`拒绝写入空内容:${path.relative(ROOT, file)}`); throw new Error(`拒绝写入空内容:${path.relative(ROOT, file)}`);
} }
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.mkdirSync(path.dirname(file), { recursive: true });
const existing = fs.existsSync(file) ? fs.readFileSync(file) : null;
if (existing && existing.equals(data)) {
console.log(`[icons] 未变化 ${path.relative(ROOT, file)}`);
return;
}
fs.writeFileSync(file, data); fs.writeFileSync(file, data);
console.log(`[icons] 写入 ${path.relative(ROOT, file)} (${data.length} 字节)`); console.log(`[icons] 写入 ${path.relative(ROOT, file)} (${data.length} 字节)`);
} }
app.whenReady().then(async () => { app.whenReady().then(async () => {
try { try {
const pngBySize = await renderSizes(); const sourceUrl = readSourceDataUrl();
const icoBytes = fs.readFileSync(SOURCE_ICO);
const icoSizes = readIcoSizes(icoBytes);
console.log(`[icons] 图形源 ${path.relative(ROOT, SOURCE_PNG)}`);
console.log(`[icons] ICO 尺寸 ${icoSizes.join(' / ')}`);
// Web 管理端(PWA) // 尺寸由页面里的 canvas 决定,窗口本身多大都行 —— 用 64×64 就够,且必须隐藏
const window = new BrowserWindow({
width: 64,
height: 64,
show: false,
webPreferences: { offscreen: false, backgroundThrottling: false },
});
await window.loadURL(
`data:text/html;charset=utf-8,${encodeURIComponent('<!doctype html><meta charset="utf-8"><title>icons</title>')}`,
);
const pngBySize = await renderSizes(window, sourceUrl);
window.destroy();
// ── Web 管理端(PWA + 标签页) ───────────────────────────────────────────
const webPublic = path.join(ROOT, 'packages', 'web-admin', 'public'); const webPublic = path.join(ROOT, 'packages', 'web-admin', 'public');
writeIfChanged(path.join(webPublic, 'icon-192.png'), pngBySize.get(192)); writeIfChanged(path.join(webPublic, 'icon-192.png'), pngBySize.get(192));
writeIfChanged(path.join(webPublic, 'icon-512.png'), pngBySize.get(512)); writeIfChanged(path.join(webPublic, 'icon-512.png'), pngBySize.get(512));
writeIfChanged(path.join(webPublic, 'apple-touch-icon.png'), pngBySize.get(180)); writeIfChanged(path.join(webPublic, 'apple-touch-icon.png'), pngBySize.get(180));
writeIfChanged(path.join(webPublic, 'favicon.svg'), Buffer.from(logoSvg(64), 'utf8'));
writeIfChanged(path.join(webPublic, 'favicon.png'), pngBySize.get(64)); writeIfChanged(path.join(webPublic, 'favicon.png'), pngBySize.get(64));
writeIfChanged(path.join(webPublic, 'favicon.svg'), Buffer.from(buildFaviconSvg(pngBySize.get(128)), 'utf8'));
// 界面内的品牌标(侧栏 / 登录页)。与 favicon 分开一份的理由:favicon 是给浏览器标签页用的,
// 而这张是**渲染进页面**的(`<img src="/logo.png">`),尺寸与缓存策略都不该混在一起。
writeIfChanged(path.join(webPublic, 'logo.png'), pngBySize.get(128));
// 桌面客户端(electron-builder) // ── ClassIsland 插件(清单同目录的 icon.png,ClassIsland 按约定读取) ──────
const desktopBuild = path.join(ROOT, 'packages', 'desktop-client', 'build'); writeIfChanged(path.join(ROOT, 'packages', 'classisland-plugin', 'icon.png'), pngBySize.get(256));
writeIfChanged(path.join(desktopBuild, 'icon.png'), pngBySize.get(512));
// ICO(安装包 / EXE 图标):包含常用尺寸 // ── 桌面客户端 ──────────────────────────────────────────────────────────
const icoSizes = [16, 24, 32, 48, 64, 128, 256]; // classhelper.* 是源的镜像;icon.* 是 electron-builder 的兜底约定名(默认找 build/icon.ico),
const ico = buildIco(icoSizes.map((size) => ({ size, data: pngBySize.get(size) }))); // 两套都留在客户端产物目录里,免得哪天显式配置被拿掉就悄悄退回旧图标。
writeIfChanged(path.join(desktopBuild, 'icon.ico'), ico); writeIfChanged(path.join(DESKTOP_BUILD, 'classhelper.png'), fs.readFileSync(SOURCE_PNG));
writeIfChanged(path.join(ROOT, 'build', 'icon.ico'), ico); writeIfChanged(path.join(DESKTOP_BUILD, 'classhelper.ico'), icoBytes);
writeIfChanged(path.join(ROOT, 'build', 'icon.png'), pngBySize.get(512)); writeIfChanged(path.join(DESKTOP_BUILD, 'icon.png'), pngBySize.get(512));
writeIfChanged(path.join(DESKTOP_BUILD, 'icon.ico'), icoBytes);
// 托盘:Windows 通知区域在 100% DPI 下就是 16×16,给 32×32 让它在
// 150% / 200% DPI 上也有原生像素可用(原先直接丢了一张 345×339 的应用图标进去,
// 非正方形会被系统拉伸)。
writeIfChanged(path.join(ROOT, 'packages', 'desktop-client', 'public', 'tray.png'), pngBySize.get(32));
// 界面内的品牌标(侧栏 / 登录页)。
// **客户端必须经 `BRAND_LOGO_URL`(src/renderer/config.ts)绑定给 `:src`**:
// 生产环境是 file:// 加载 dist/renderer/index.html,根绝对路径会解析到盘符根目录;
// 而字面量的相对 `src` 又会被 Vite 当成模块导入去解析、直接构建失败。
writeIfChanged(path.join(ROOT, 'packages', 'desktop-client', 'public', 'logo.png'), pngBySize.get(128));
// ── 官网(纯静态站,图标同样是页面里的 brand 标记) ──────────────────────
writeIfChanged(path.join(ROOT, 'website', 'assets', 'icon.png'), pngBySize.get(512));
console.log('[icons] 全部图标生成完成'); console.log('[icons] 全部图标生成完成');
app.exit(0); app.exit(0);
+5 -3
View File
@@ -26,9 +26,11 @@ const argOf = (name) => {
return index >= 0 ? argv[index + 1] : undefined; return index >= 0 ? argv[index + 1] : undefined;
}; };
const exe = path.resolve( // 默认取 releases/client/<版本>/免安装/(dist-win 的产物落点,见 AGENTS.md §4)
argOf('--exe') ?? path.join(repoRoot, 'packages/desktop-client/release/win-unpacked/班级小助手.exe'), const clientVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
); const defaultExe = path.join(repoRoot, 'releases', 'client', clientVersion, '免安装', '班级小助手.exe');
const exe = path.resolve(argOf('--exe') ?? defaultExe);
const classCode = argOf('--code') ?? process.env.ELECTRON_SMOKE_CLASS_CODE ?? 'G101'; const classCode = argOf('--code') ?? process.env.ELECTRON_SMOKE_CLASS_CODE ?? 'G101';
const classPassword = argOf('--password') ?? process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '123456'; const classPassword = argOf('--password') ?? process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '123456';
const forceOffline = argv.includes('--offline'); const forceOffline = argv.includes('--offline');
Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

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

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

File diff suppressed because it is too large. Load diff
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more