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

@@ -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);
};
}