班级小助手
班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩
@@ -200,6 +200,16 @@ async function enterOffline(): Promise
{
margin-bottom: 18px;
}
+/*
+ * 品牌标:与应用图标同一份图(`public/logo.png`)。
+ * 图自带圆角与透明边角,不要再加 border-radius;`src` 走 `BRAND_LOGO_URL` 的理由同 ClientLayout。
+ * (表单里「班级码」输入框的前缀仍用 School 字形 —— 那是字段语义图标,不是品牌标。)
+ */
+.login-logo {
+ flex: none;
+ display: block;
+}
+
.login-title {
margin: 0;
font-size: 20px;
diff --git a/packages/server/package.json b/packages/server/package.json
index 7f319e9..518ed4c 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
- "version": "1.1.1",
+ "version": "1.1.2",
"private": true,
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
diff --git a/packages/server/scripts/verify-classisland.mjs b/packages/server/scripts/verify-classisland.mjs
index 35a743d..3d0e5d7 100644
--- a/packages/server/scripts/verify-classisland.mjs
+++ b/packages/server/scripts/verify-classisland.mjs
@@ -210,7 +210,7 @@ async function main() {
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
const reportBody = {
- pluginVersion: '1.1.1.0',
+ pluginVersion: '1.1.2.0',
classIslandVersion: '2.1.0.0',
state: {
inClass: true,
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 127a025..5f9491b 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -1,6 +1,6 @@
{
"name": "@classhelper/shared",
- "version": "1.1.1",
+ "version": "1.1.2",
"private": true,
"description": "班级小助手 - 三端共享的类型、常量与工具函数",
"type": "module",
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index e3afab6..d9bd6c3 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -3,6 +3,7 @@
* 后端、Web 管理端、桌面客户端统一从这里导入类型与工具函数。
*/
export * from './constants.js';
+export * from './motion.js';
export * from './permissions.js';
export * from './utils.js';
export type * from './types.js';
diff --git a/packages/shared/src/motion.ts b/packages/shared/src/motion.ts
new file mode 100644
index 0000000..eabdbef
--- /dev/null
+++ b/packages/shared/src/motion.ts
@@ -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();
+
+/**
+ * 按物理积分弹簧,直到落定。
+ *
+ * ⚠️ **不要用"包络衰减到某个比例"的解析近似来算时长**:那个公式(`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 {
+ 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),
+ };
+}
diff --git a/packages/web-admin/package.json b/packages/web-admin/package.json
index 0ebb0f4..1e120bf 100644
--- a/packages/web-admin/package.json
+++ b/packages/web-admin/package.json
@@ -1,6 +1,6 @@
{
"name": "@classhelper/web-admin",
- "version": "1.1.1",
+ "version": "1.1.2",
"private": true,
"description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module",
diff --git a/packages/web-admin/public/apple-touch-icon.png b/packages/web-admin/public/apple-touch-icon.png
index 2beeee2..b7ae6c5 100644
Binary files a/packages/web-admin/public/apple-touch-icon.png and b/packages/web-admin/public/apple-touch-icon.png differ
diff --git a/packages/web-admin/public/favicon.png b/packages/web-admin/public/favicon.png
index 90c9a65..2c8a7ec 100644
Binary files a/packages/web-admin/public/favicon.png and b/packages/web-admin/public/favicon.png differ
diff --git a/packages/web-admin/public/favicon.svg b/packages/web-admin/public/favicon.svg
index 8e4ef24..563bdd4 100644
--- a/packages/web-admin/public/favicon.svg
+++ b/packages/web-admin/public/favicon.svg
@@ -1,15 +1,3 @@
-
\ No newline at end of file
+
diff --git a/packages/web-admin/public/icon-192.png b/packages/web-admin/public/icon-192.png
index 8ae8744..9f08edc 100644
Binary files a/packages/web-admin/public/icon-192.png and b/packages/web-admin/public/icon-192.png differ
diff --git a/packages/web-admin/public/icon-512.png b/packages/web-admin/public/icon-512.png
index 3e256e5..47ad8c3 100644
Binary files a/packages/web-admin/public/icon-512.png and b/packages/web-admin/public/icon-512.png differ
diff --git a/packages/web-admin/public/logo.png b/packages/web-admin/public/logo.png
new file mode 100644
index 0000000..0eae00c
Binary files /dev/null and b/packages/web-admin/public/logo.png differ
diff --git a/packages/web-admin/src/components/ClassPlanImportDialog.vue b/packages/web-admin/src/components/ClassPlanImportDialog.vue
index f65b416..d03f3f0 100644
--- a/packages/web-admin/src/components/ClassPlanImportDialog.vue
+++ b/packages/web-admin/src/components/ClassPlanImportDialog.vue
@@ -316,7 +316,7 @@ function parityLabel(item: ClassPlanEntryDto): string {
flex-wrap: wrap;
gap: 16px;
margin-top: 10px;
- color: #606266;
+ color: var(--ch-text-secondary);
font-size: 13px;
}
.mb-8 {
diff --git a/packages/web-admin/src/components/StatCard.vue b/packages/web-admin/src/components/StatCard.vue
new file mode 100644
index 0000000..8375917
--- /dev/null
+++ b/packages/web-admin/src/components/StatCard.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
{{ label }}
+
{{ Math.round(displayed) }}
+
{{ hint }}
+
+
diff --git a/packages/web-admin/src/components/TableImportDialog.vue b/packages/web-admin/src/components/TableImportDialog.vue
index d962475..7923f17 100644
--- a/packages/web-admin/src/components/TableImportDialog.vue
+++ b/packages/web-admin/src/components/TableImportDialog.vue
@@ -388,7 +388,7 @@ async function submit(): Promise {
flex-wrap: wrap;
}
.file-name {
- color: #606266;
+ color: var(--ch-text-secondary);
font-size: 13px;
}
.hidden-input {
@@ -399,7 +399,7 @@ async function submit(): Promise {
gap: 16px;
flex-wrap: wrap;
margin: 12px 0 4px;
- color: #606266;
+ color: var(--ch-text-secondary);
font-size: 13px;
}
.required-hint {
@@ -409,7 +409,7 @@ async function submit(): Promise {
}
.mode-hint {
width: 100%;
- color: #909399;
+ color: var(--ch-text-muted);
font-size: 12px;
}
.result-stats {
diff --git a/packages/web-admin/src/components/TimeLayoutImportDialog.vue b/packages/web-admin/src/components/TimeLayoutImportDialog.vue
index 923cc3f..36156a8 100644
--- a/packages/web-admin/src/components/TimeLayoutImportDialog.vue
+++ b/packages/web-admin/src/components/TimeLayoutImportDialog.vue
@@ -293,7 +293,7 @@ function fillSample(): void {
display: flex;
gap: 16px;
margin-top: 10px;
- color: #606266;
+ color: var(--ch-text-secondary);
font-size: 13px;
}
.mb-8 {
diff --git a/packages/web-admin/src/components/UrgentClassWarning.vue b/packages/web-admin/src/components/UrgentClassWarning.vue
index c36f442..ba5e0a7 100644
--- a/packages/web-admin/src/components/UrgentClassWarning.vue
+++ b/packages/web-admin/src/components/UrgentClassWarning.vue
@@ -73,7 +73,13 @@ function handleConfirm(): void {
-
+
+
@@ -116,7 +122,14 @@ function handleConfirm(): void {
:show-text="false"
color="#f56c6c"
/>
- {{ remaining }}
+
+
+ {{ remaining }}
+
{{
@@ -149,7 +162,7 @@ function handleConfirm(): void {
max-width: calc(100vw - 48px);
padding: 28px 30px 22px;
border-radius: 16px;
- background: #fff;
+ background: var(--ch-surface);
border-top: 4px solid #f56c6c;
box-shadow: 0 24px 60px rgba(120, 20, 20, 0.35);
text-align: center;
@@ -164,7 +177,7 @@ function handleConfirm(): void {
align-items: center;
justify-content: center;
color: #f56c6c;
- background: #fef0f0;
+ background: rgba(245, 108, 108, 0.1);
animation: urgent-pulse 1.4s ease-in-out infinite;
}
@@ -183,14 +196,14 @@ function handleConfirm(): void {
.urgent-title {
margin: 0 0 8px;
font-size: 22px;
- color: #303133;
+ color: var(--ch-text);
}
.urgent-subtitle {
margin: 0 0 18px;
font-size: 14px;
line-height: 1.7;
- color: #606266;
+ color: var(--ch-text-secondary);
}
.urgent-subtitle strong {
@@ -210,17 +223,17 @@ function handleConfirm(): void {
}
.urgent-period {
- background: #fef0f0;
+ background: rgba(245, 108, 108, 0.1);
}
.urgent-preview {
- background: #f5f7fa;
+ background: var(--ch-surface-alt);
}
.period-label,
.preview-label {
flex: 0 0 64px;
- color: #909399;
+ color: var(--ch-text-muted);
}
.period-value {
@@ -229,7 +242,7 @@ function handleConfirm(): void {
}
.preview-title {
- color: #303133;
+ color: var(--ch-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -266,17 +279,31 @@ function handleConfirm(): void {
.progress-hint {
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-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 {
- 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,
@@ -284,8 +311,42 @@ function handleConfirm(): void {
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 {
- 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;
}
diff --git a/packages/web-admin/src/composables/motion.ts b/packages/web-admin/src/composables/motion.ts
new file mode 100644
index 0000000..a96db19
--- /dev/null
+++ b/packages/web-admin/src/composables/motion.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 };
+ };
+ 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);
+ };
+}
diff --git a/packages/web-admin/src/icons.ts b/packages/web-admin/src/icons.ts
index 348b197..47aeea2 100644
--- a/packages/web-admin/src/icons.ts
+++ b/packages/web-admin/src/icons.ts
@@ -10,12 +10,14 @@ import {
Guide,
Lock,
Menu,
+ Moon,
Notebook,
Odometer,
Plus,
Refresh,
School,
Search,
+ Sunny,
SwitchButton,
Trophy,
Upload,
@@ -50,12 +52,14 @@ const APP_ICONS = {
Guide,
Lock,
Menu,
+ Moon,
Notebook,
Odometer,
Plus,
Refresh,
School,
Search,
+ Sunny,
SwitchButton,
Trophy,
Upload,
diff --git a/packages/web-admin/src/layouts/AdminLayout.vue b/packages/web-admin/src/layouts/AdminLayout.vue
index 1b2b8b5..d448e9e 100644
--- a/packages/web-admin/src/layouts/AdminLayout.vue
+++ b/packages/web-admin/src/layouts/AdminLayout.vue
@@ -1,5 +1,5 @@