Files
LoveChengke c7f215a09c feat(courseware,docs): 完善课件传输与岛编辑并统一全仓文档
- 增加课件上传、班级可见范围、设备补收与传输进度
- 整合自定义灵动岛编辑器和相关客户端交互
- 同步三端契约、数据库迁移、部署配置与隔离回归
- 按源码统一文档与官网说明,移除过时任务书和审查记录
- 保持产品版本为 1.4.0
2026-10-18 11:53:37 +08:00

139 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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:从点击位置扩散的圆整页换肤 |
| 灵动岛 | 独立 SpringValue 帧积分与过冲钳制,见设计令牌 |
### 灵动岛使用独立弹簧
经典与自定义岛的形变使用 `src/island/spring.ts` 的 SpringValue,渲染层驱动岛体几何,主进程管理原生窗口与命中。
它与 shared/motion.ts 的 CSS 弹簧是不同实现,不能互相替换参数。默认阻尼、换向与过冲钳制见[设计令牌](winisland-design-tokens.md)。
岛动画由应用开关及可见性控制,主窗口 prefers-reduced-motion 规则不直接替代岛自身设置。
另外岛的 `: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 管理端的侧边栏
(本来就是深色)、以及深色底上的白色文字。**改它们会被当成回归。**