文档站(`docs/` 就是 docfx 项目,发布在 https://lovechengke.github.io/ClassHelper/): - 四章 + 常见问题 + 完整参考,共 44 页:快速上手(8 页)/ 应用帮助(10 页)/ 管理运维(6 页)/ 开发文档(11 页)。信息架构照 ClassIsland 文档站来: 顶栏分章、左侧章节导航、右侧本页目录、上一页 / 下一页、站内搜索、深色主题跟随系统; - 自定义模板覆盖(`templates/classhelper/`):把 docfx modern 模板那套英文 UI 文案 (In this article / Improve this doc / Next…)换成中文,补 GitHub 入口与版本徽标, 并加一层只调排版与配色的 `public/main.css`(不动 DOM 结构); - 站点顶栏的版本徽标取自根 `package.json`(`docs/build.mjs` 用 `--metadata` 注入), 不在文档里写第二份 —— 否则一定漂; - 404 页单独生成:GitHub Pages 的项目页挂在 `/<仓库名>/` 子路径下,而浏览器是按**被请求的路径** 解析页面里的相对链接的,所以 `<base>` 必须钉到站点根,由 `build.mjs`(本地 `./`)与 workflow(CI 传 `/<仓库名>/`)分别注入; - 构建前清空 `_site`:docfx 只覆盖不清理,删掉或改名的页面会以旧文件留在目录里, 而 Pages 上传的是整个目录。 版本迭代(**每一次 releases,不是每一次提交**): - 新增 `scripts/version.mjs`。这个仓库里同一个版本号散在 **10 个文件**里(5 份 `package.json`、 `deploy/package.runtime.json`、两个 compose 的镜像 tag、插件 `manifest.yml`、以及插件 C# 里的 `PluginVersion` 常量),手工改必然漏一处,而漏掉的那处**不会报错**,只在某个不常走的分支上 表现为"版本号对不上"。现在收口成两条命令:`pnpm version:check`(逐处比对,不一致 exit 1) 与 `pnpm version:bump patch|minor|major`(一次改写全部 10 处,任何一处匹配不到或匹配到多处 就直接失败退出,不做尽力而为的部分改写); - `AGENTS.md` 新增 §4.4「版本迭代(每一次 releases,不是每一次提交)」:写清版本号落在哪 10 处、 完整发布命令序列(体检 → 验收 → `dist:all` → 哈希清单 → 推 master → `gh release create` → Linux 包由 CI 合并哈希行),以及"已发布的版本号不要复用"的理由。 其它: - `.github/workflows/docs.yml`:推 master(`docs/**` / `package.json` / workflow 自身有变)即构建并 部署到 Pages;构建带 `--check`,把 docfx 的警告当错误 —— 最常见的警告是"某个 `.md` 没被任何 `toc.yml` 引用",那种页面有内容但没有侧边栏,属于必须修掉的疏漏; - docs 目录重组后的引用同步:`production` / `mysql` / `linux-deploy` / `winisland-design-tokens` 移到新位置后,AGENTS.md、`deploy/`、`scripts/`、客户端源码注释里的路径引用一并更新; - 文档站只发布 `docs/` 这一棵树,页面里指向树外的相对链接(`../AGENTS.md` 等)改成 GitHub 绝对 URL, 否则上线全是 404; - 图标派生清单补上文档站模板里的 `logo.png` 与 `favicon.png`(`pnpm icons` 一并生成, 仍然只有一个图形源 `build/classhelper.{png,ico}`); - `docs/_site` 进 `.gitignore` / `.prettierignore` 与 ESLint ignores; - 根脚本补 `version:check` / `version:bump` / `docs:build` / `docs:serve` / `docs:check`。
141 lines
10 KiB
Markdown
141 lines
10 KiB
Markdown
---
|
||
title: 动效层与主题
|
||
description: beUI 动效令牌、CSS linear() 弹簧、运行时注入,以及深色主题的实现边界。
|
||
---
|
||
|
||
# 动效层与主题
|
||
|
||
## 令牌的单一来源
|
||
|
||
两端的动画与过渡统一走一套自研的动效语言,**移植自 [beUI](https://beui.dev) 的 `lib/ease.ts`**。
|
||
beUI 的组件本身是 React(`motion/react` + Tailwind),用不到这里,**但它的动效语言与框架无关**。
|
||
|
||
令牌全部在 `packages/shared/src/motion.ts`:
|
||
|
||
| 常量 | 取值 | 用在哪 |
|
||
| --------------- | --------------------- | ------------------------------ |
|
||
| `EASE_OUT` | `[0.16, 1, 0.3, 1]` | 绝大多数进出场 |
|
||
| `EASE_IN_OUT` | `[0.77, 0, 0.175, 1]` | 对称的来回动(抽屉、内容互换) |
|
||
| `EASE_DRAWER` | `[0.32, 0.72, 0, 1]` | 侧向抽屉 |
|
||
| `SPRING_PRESS` | 500 / 30 / 0.6 | 按钮、可点表面 |
|
||
| `SPRING_SWAP` | 460 / 30 / 0.55 | 控件内的内容互换 |
|
||
| `SPRING_PANEL` | 420 / 40 / 0.5 | 弹窗、抽屉、气泡进场 |
|
||
| `SPRING_LAYOUT` | 360 / 32 / 0.6 | 共享布局滑动(指示器) |
|
||
| `SPRING_MOUSE` | 200 / 15 / 0.3 | 光标跟随类装饰动效 |
|
||
| `SPRING_GLIDE` | 700 / 50 / 0.5 | 拖拽滑块与填充(临界阻尼) |
|
||
|
||
弹簧语义与 `motion/react` 一致:`加速度 = (stiffness × 剩余距离 − damping × 当前速度) / mass`。
|
||
这六组里 PRESS / SWAP / MOUSE 是欠阻尼(有过冲),PANEL / LAYOUT / GLIDE 是过阻尼。
|
||
|
||
### 为什么这个文件必须"纯常量 + 纯函数"
|
||
|
||
`packages/shared` 会被后端(Express)一起消费,它的 tsconfig 是 `lib: ["ES2023"]`(**不含 DOM**)。
|
||
所以这里不碰 `document`、不碰 `window`、也不依赖 vue。
|
||
|
||
DOM 注入与 Vue 组合式函数在两端各自的 `composables/motion.ts` 里
|
||
(`useReducedMotion` / `useHoverCapable` / `useCountUp` / `useStaggerIn`)——
|
||
与 `ScoreBarChart.vue` 一样,两端各一份是这个仓库的既有做法。
|
||
|
||
## 弹簧怎么变成 CSS
|
||
|
||
不必给每个组件挂一个 rAF 循环:**用数值积分求出落定时间,再生成 CSS `linear()` 缓动字面量**,
|
||
弹簧就能纯 CSS 驱动。
|
||
|
||
几条实现约束:
|
||
|
||
| 约束 | 原因 |
|
||
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||
| 积分步长 1ms、`linear()` 取 60 个采样点 | 60 段肉眼已看不出折线,再密只是徒增样式表体积 |
|
||
| **落定时间不能用包络公式 `6/(ζ·ω0)`** | 它只对欠阻尼成立;过阻尼要用较慢的实极点 `ω0(ζ−√(ζ²−1))`,否则曲线会被截断在 0.87/0.95 上、元素永远差一截 |
|
||
| **`--ch-spring-X` 与 `--ch-spring-X-dur` 必须成对用** | `linear()` 是把 [0,1] 进度重映射,时长给短了等于把整条曲线等比压缩 —— 形状还在,但已经不是那组参数的手感了 |
|
||
| `linear()` 的进度值可以 >1(过冲) | 只能用在不越界无害的属性上。**别用在 `opacity`** |
|
||
|
||
### 运行时注入
|
||
|
||
两端的 `main.ts` 在 `app.mount()` **之前**把令牌写进 `:root` 的自定义属性
|
||
(`--ch-ease-*` / `--ch-spring-*` / `--ch-dur-*`)。CSS 里只引用变量,**不抄数值 —— 抄了必漂**。
|
||
|
||
> **灵动岛是另一个渲染进程**,不共享主窗口的 `:root`:令牌要在 `src/island/main.ts` 里**再注入一次**。
|
||
> 漏了的话 `var(--ch-ease-out)` 解析为空、**整条 `transition` 简写作废**,比不改还差(完全没有过渡)。
|
||
|
||
## 落地到界面上的效果
|
||
|
||
| 位置 | 动效 |
|
||
| --------------------- | ---------------------------------------------------------------------------------------------- |
|
||
| 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:从点击位置扩散的圆整页换肤 |
|
||
| 灵动岛 | **刻意只有单调缓动,不用弹簧** |
|
||
|
||
### 灵动岛为什么不用弹簧
|
||
|
||
岛的形变由**主进程**缓动窗口尺寸,而冒烟断言「展开过程不震动」「收回过程不抖动」
|
||
是**逐帧采样岛体几何**并要求单调的 —— 任何带过冲的弹簧都会直接判红。
|
||
|
||
所以岛用 `--ch-ease-out` 这类单调曲线,壳的物理仍归它自己的 `src/island/spring.ts`(WinIsland 移植)。
|
||
**不要拿 beUI 的 `SPRING_*` 去换**。
|
||
|
||
另外岛的 `:hover` 规则必须关在 `@media (hover: hover) and (pointer: fine)` 里 ——
|
||
教室触摸屏的"幽灵 hover"会让点过「知道了」的按钮永远高亮。
|
||
|
||
## 四条硬约束
|
||
|
||
| 约束 | 后果 |
|
||
| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| **路由层不要包 `<transition>`**,尤其不要 `mode="out-in"` | ① 点菜单后一小段时间页面上还是上一页;② 退场判定依赖 `animationend`,而**窗口被遮挡时 Chromium 冻结 CSS 动画**,事件永不触发 ⇒ 新页面永远不挂载,整个应用卡死在第一页 |
|
||
| **Vue 的 `<transition>` 一律显式给 `:duration`** | 同上,靠 `animationend` 判断的写法在窗口不可见时会永远停住 |
|
||
| **`position: fixed` 的整屏层、以及"自己量几何再据此设尺寸"的元素,不要叠位移类动画** | `getBoundingClientRect()` 包含 transform,而 `clientHeight` / `offsetHeight` 不含 —— 下移 6px 就可能溢出屏幕 |
|
||
| **弹窗面板的动画挂在 `.el-dialog` 上,不要挂在 `.el-overlay-dialog`** | Element Plus 的 `@opened` 按根元素(只有 200ms 淡入)的动画结束触发,而外层容器的弹簧要跑 463ms —— 在 `@opened` 里量尺寸会量到"还带着 `scale(0.97)` 的缩小版" |
|
||
|
||
页面切换的动效因此改成 `.page > *` 错峰入场 —— 那是随组件挂载**同步**触发的,不依赖动画事件。
|
||
|
||
## 降低动态效果
|
||
|
||
两端各有一个 `@media (prefers-reduced-motion: reduce)` 全局块,把动画压到 **1ms**。
|
||
|
||
**是压短而不是 `transition: none`** —— 后者会让依赖 `transitionend` 的逻辑失灵。
|
||
唯一的例外是**加载指示旋**:它改为放慢而不是停,停掉会让界面看起来像卡死。
|
||
|
||
## 主题
|
||
|
||
**两端机制完全一致**:在 `<html>` 上挂 `.dark` 类,由各自的样式表覆盖**语义色令牌**,
|
||
Element Plus 的 `theme-chalk/dark/css-vars.css` 提供 `--el-*` 的深色默认。
|
||
两端的 `main.ts` 都在挂载前落主题,深色用户首帧才不会看到"先白后黑"。
|
||
|
||
### 语义色令牌
|
||
|
||
| 令牌 | 用途 |
|
||
| ------------------------------------------------------ | -------- |
|
||
| `--ch-surface` / `-alt` / `-sunken` / `-hover` | 表面 |
|
||
| `--ch-text` / `-secondary` / `-muted` / `-placeholder` | 文字层级 |
|
||
|
||
组件里**一律引用这些,不要写死颜色**。自查命令:
|
||
|
||
```bash
|
||
grep -rn "rgba(255, 255, 255\|rgba(0, 0, 0\|#fff" packages/desktop-client/src/renderer
|
||
```
|
||
|
||
除了**明确固定深色**的区域(全屏作业看板、登录页渐变、启动过渡页、灵动岛自身、
|
||
以及 accent 渐变底上的白字)之外,都应该是变量。
|
||
|
||
### 两条纪律
|
||
|
||
**`html.dark` 只覆盖颜色,绝不覆盖圆角与动效令牌。** 两套主题的几何必须完全一致,
|
||
否则切换时会看到布局跳一下。
|
||
|
||
**改令牌时只改值,不要对样式表跑批量替换。** "把 `#303133` 全量替换成 `var(--ch-text)`"
|
||
会把令牌**定义本身**也换成 `--ch-text: var(--ch-text)` —— 自定义属性一旦自引用就整条失效、
|
||
`getPropertyValue` 返回空串,全站文字颜色静默回落成继承色(浅色下看着"还行",深色下才露馅)。
|
||
|
||
Web 端还要一并同步 `<meta name="theme-color">` 与 `color-scheme` —— 后者管**原生控件**
|
||
(滚动条、日期选择器弹层、自动填充底色),只改 CSS 变量的话它们仍是浅色。
|
||
|
||
### 刻意固定深色的区域
|
||
|
||
登录页的深色渐变底、桌面端的启动过渡页与全屏作业看板、灵动岛自身、Web 管理端的侧边栏
|
||
(本来就是深色)、以及深色底上的白色文字。**改它们会被当成回归。**
|