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。
This commit is contained in:
LoveChengke committed 2026-10-06 15:32:01 +08:00
1 parent 82c85b59c1
commit ce9bed73f4
59 files changed
+2974 -336

No files matched your search

+149 -10
View File
@@ -1,7 +1,7 @@
# 班级小助手(Class Helper)
班级信息管理系统。一个 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/shared` | 三端共用的类型 / 常量 / 权限矩阵 / 工具函数 | 上面三个 + 插件 |
| 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:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8) |
| `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:dir` | 打包客户端免安装目录 `release/win-unpacked`(最快) |
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
@@ -822,11 +823,11 @@ class-helper/
├── eslint.config.mjs # ESLint 扁平配置(TS + Vue)
├── .prettierrc.json # 单引号 / 110 列 / LF / 尾逗号
├── .dockerignore # 容器构建上下文排除项
├── build/icon.{ico,png} # 应用图标(由 pnpm icons 生成)
├── build/classhelper.{png,ico} # **图标源**(设计导出、手工放入);pnpm icons 从它派生其余全部图标
├── scripts/
│ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手(改写 schema.prisma 的 provider)
│ ├── generate-icons.mjs # 用 Electron 渲染 SVG 生成 PNG/ICO 图标
│ ├── icons/render.cjs # 图标渲染脚本(CommonJS)
│ ├── generate-icons.mjs # 启动 Electron 跑 icons/render.cjs(= pnpm icons)
│ ├── icons/render.cjs # 把 classhelper.png 重采样成各端图标(CommonJS)
│ ├── dist-server.mjs # 服务端 + Web 管理端 打包(免安装目录 + NSIS 安装程序)
│ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟
│ ├── lib/electron-env.mjs # 启动 Electron 的公共处理(清理 RUN_AS_NODE + 定位可执行文件)
@@ -850,6 +851,9 @@ class-helper/
│ ├── mysql.md # MySQL 切换指南
│ ├── winisland-design-tokens.md # 灵动岛照 WinIsland 提取的设计 token
│ └── 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/
├── shared/ # 三端共享:类型契约、常量、权限矩阵、工具函数
│ └── src/{types,constants,permissions,utils,index}.ts
@@ -888,11 +892,11 @@ class-helper/
│ ├── src/Interop/ClassHelperClient.cs # 后端 HTTP 客户端 + 对齐服务端 zod 的 DTO
│ └── scripts/{build,verify}.mjs # 构建打包(.cipx)与静态契约校验
├── 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}
└── desktop-client/ # 桌面客户端(Electron + Vue 3)
├── electron-builder.yml # 打包配置(nsis 安装包 + portable 单文件)
├── build/icon.ico # 应用图标(electron-builder 自动使用)
├── build/classhelper.ico # 应用 / 安装包图标(electron-builder 的 win.icon 显式指向它)
├── vite.config.mts # 渲染进程构建(base: './',hash 路由)
├── scripts/{build-main,dev,smoke,dist-win,check-bundle-secrets}.mjs
└── src/
@@ -1123,6 +1127,62 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
因此新增表无需用户手动执行 `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.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:desktop`(客户端冒烟,不弹窗) | **105/105** ✅(在线轮)+ **89/89** ✅(二次启动轮),0 失败(2026-10-05 实测) |
| `pnpm verify:web`(Web 管理端真实点击回归) | **32/33**(2026-10-05 实测;未过的 1 项是既有脆弱用例「课表科目为全校统一目录」,见下) |
| `pnpm verify:desktop`(客户端冒烟,不弹窗) | **107/107** ✅ + **89/89** ✅(2026-10-06 实测,0 失败) |
| `pnpm verify:web`(Web 管理端真实点击回归) | **32/33**(2026-10-06 实测;未过的 1 项是既有脆弱用例「课表科目为全校统一目录」,见下) |
| `pnpm verify:classisland`(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约) | **42/42** ✅(2026-10-05 实测) |
| `pnpm verify:classisland-plugin`(插件静态契约) | **68/68** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm check:icons` | 全过 ✅(lint 0 error **0 warning**) |
@@ -1199,7 +1259,8 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
- **`verify:desktop` 的「真实通知链路」「灵动岛标为已读」等 3 项**:要求**当前不在上课时段**。
若种子课表恰有课正在进行,客户端会**正确地**把普通通知暂存到下课(见 [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`
当第 1 周 —— `.env` 的 `TERM_START_DATE` 对齐到 `2026-09-07` 即全绿。
- **`verify:web` 的「课表科目为全校统一目录」**:种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组。
@@ -1288,6 +1349,84 @@ pnpm verify:classisland-plugin # 静态契约校验(不需要 .NET,不需
别为了消警告加无参构造函数。部署到教室机器(先退出 ClassIsland 再覆盖 DLL,别覆盖 `Settings.json`)的步骤
见 [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. 常见问题
**开发环境的坑请直接看 [AGENTS.md](AGENTS.md) §7「本机环境已知坑」** —— 那里是本仓库唯一维护这些内容的