From ce9bed73f4d37a105beb8991f1c57abae56e9b5a Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Tue, 6 Oct 2026 15:32:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(shared,web-admin,desktop-client,build):=20?= =?UTF-8?q?beUI=20=E5=8A=A8=E6=95=88=E5=B1=82=E4=B8=8E=E4=B8=A4=E7=AB=AF?= =?UTF-8?q?=E6=B7=B1=E8=89=B2=E4=B8=BB=E9=A2=98=EF=BC=8C=E5=85=A8=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=9B=BE=E6=A0=87=E6=94=B9=E7=94=A8=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E7=9A=84=E6=96=B0=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 动效层:把 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。 --- AGENTS.md | 201 +++++++- README.md | 159 +++++- build/icon.ico | Bin 111173 -> 0 bytes build/icon.png | Bin 270653 -> 0 bytes deploy/.env.example | 2 +- deploy/.env.sqlite.example | 2 +- deploy/docker-compose.sqlite.yml | 2 +- deploy/docker-compose.yml | 2 +- deploy/install.sh | 4 +- deploy/package.runtime.json | 2 +- docs/production.md | 6 +- package.json | 2 +- packages/classisland-plugin/icon.png | Bin 3263 -> 85233 bytes packages/classisland-plugin/manifest.yml | 2 +- .../src/Services/BridgeService.cs | 2 +- packages/desktop-client/build/icon.ico | Bin 111173 -> 432254 bytes packages/desktop-client/build/icon.png | Bin 270653 -> 256073 bytes packages/desktop-client/package.json | 2 +- packages/desktop-client/public/logo.png | Bin 0 -> 25724 bytes packages/desktop-client/public/tray.png | Bin 49814 -> 2567 bytes .../desktop-client/src/island/IslandApp.vue | 94 ++-- packages/desktop-client/src/island/main.ts | 14 + packages/desktop-client/src/renderer/App.vue | 55 ++- .../src/renderer/cache/self-test.ts | 10 +- .../src/renderer/composables/motion.ts | 239 +++++++++ .../desktop-client/src/renderer/config.ts | 13 + .../src/renderer/layouts/ClientLayout.vue | 197 +++++++- packages/desktop-client/src/renderer/main.ts | 13 + .../src/renderer/styles/index.css | 442 ++++++++++++++++- .../src/renderer/views/LoginView.vue | 14 +- packages/server/package.json | 2 +- .../server/scripts/verify-classisland.mjs | 2 +- packages/shared/package.json | 2 +- packages/shared/src/index.ts | 1 + packages/shared/src/motion.ts | 242 ++++++++++ packages/web-admin/package.json | 2 +- .../web-admin/public/apple-touch-icon.png | Bin 38283 -> 45829 bytes packages/web-admin/public/favicon.png | Bin 7153 -> 7912 bytes packages/web-admin/public/favicon.svg | 18 +- packages/web-admin/public/icon-192.png | Bin 42273 -> 52378 bytes packages/web-admin/public/icon-512.png | Bin 270653 -> 256073 bytes packages/web-admin/public/logo.png | Bin 0 -> 25724 bytes .../src/components/ClassPlanImportDialog.vue | 2 +- .../web-admin/src/components/StatCard.vue | 31 ++ .../src/components/TableImportDialog.vue | 6 +- .../src/components/TimeLayoutImportDialog.vue | 2 +- .../src/components/UrgentClassWarning.vue | 93 +++- packages/web-admin/src/composables/motion.ts | 230 +++++++++ packages/web-admin/src/icons.ts | 4 + .../web-admin/src/layouts/AdminLayout.vue | 245 +++++++++- packages/web-admin/src/main.ts | 20 +- packages/web-admin/src/stores/ui.ts | 96 ++++ packages/web-admin/src/styles/index.css | 454 +++++++++++++++++- .../web-admin/src/views/DashboardView.vue | 20 +- .../web-admin/src/views/HomeworksView.vue | 6 +- packages/web-admin/src/views/LoginView.vue | 17 +- packages/web-admin/src/views/StudentsView.vue | 2 +- scripts/generate-icons.mjs | 27 +- scripts/icons/render.cjs | 307 +++++++----- 59 files changed, 2974 insertions(+), 336 deletions(-) delete mode 100644 build/icon.ico delete mode 100644 build/icon.png create mode 100644 packages/desktop-client/public/logo.png create mode 100644 packages/desktop-client/src/renderer/composables/motion.ts create mode 100644 packages/shared/src/motion.ts create mode 100644 packages/web-admin/public/logo.png create mode 100644 packages/web-admin/src/components/StatCard.vue create mode 100644 packages/web-admin/src/composables/motion.ts create mode 100644 packages/web-admin/src/stores/ui.ts diff --git a/AGENTS.md b/AGENTS.md index 5265429..4f6ce20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,12 +47,12 @@ pnpm-workspace.yaml workspace + allowBuilds(pnpm 11 依赖构建白 tsconfig.base.json 共享 TS 基础配置(ESM + NodeNext + strict) eslint.config.mjs ESLint 扁平配置(TS + Vue);ignores 含 releases/ / release-server/ / src/generated/ .prettierrc.json 单引号 / printWidth 110 / trailingComma all / LF / 2 空格 -build/icon.{ico,png} 应用图标(由 pnpm icons 生成) +build/classhelper.{png,ico} 图标源(**设计导出、手工放入**;`pnpm icons` 从它派生其余全部图标,见 §5 第 62 条) scripts/ use-database.mjs SQLite ⇄ MySQL 切换(**改写 schema.prisma 里的 provider**,没有第二份 schema) - generate-icons.mjs Electron 渲染 SVG → PNG/ICO - icons/render.cjs 图标渲染(CommonJS) - dist-server.mjs 服务端 + Web 管理端打包(产物落在 release-server/) + generate-icons.mjs 启动 Electron 跑 icons/render.cjs(= `pnpm icons`) + icons/render.cjs 把 classhelper.png 重采样成各端图标(CommonJS,见 §5 第 62 条) + dist-server.mjs 服务端 + Web 管理端打包(产物落在 releases/server/<版本>/) nsis/server-installer.nsi 服务端 NSIS 安装脚本 verify-packaged.mjs 对**打包后/已安装**的客户端 EXE 复跑冒烟 check-icons.mjs 图标注册表静态门禁(漏注册会静默渲染空白,见 §5 第 53 条) @@ -68,6 +68,15 @@ deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.c docs/ production.md(生产部署)/ linux-deploy.md(Linux 部署与运维) mysql.md(MySQL 切换)/ winisland-design-tokens.md screenshots/{island,client,web-mobile,classisland}/ +website/ 产品官网(**纯静态、无构建步骤**,不进 pnpm workspace) + index.html / assets/{styles.css,motion.js,main.js,icon.png,shots/} + serve.mjs(本地预览)/ tools/capture-shots.mjs(实机图采集) + ⚠ assets/motion.js 是动效层:曲线与弹簧 token 逐条取自 beUI + (beui.dev/components/motion,见 README §7「官网」)。 + 改官网动效请改 token 表,不要在组件里另写 duration / cubic-bezier。 + ⚠ assets/shots/ 里全是**跑起来截的**实机图,重采集步骤见 README + §7「采集实机图」;客户端那两张要配合冒烟的 + ELECTRON_CLIENT_SHOTS_DIR / ELECTRON_SMOKE_ISLAND_* 开关。 packages/shared/src/ types.ts / constants.ts / permissions.ts / utils.ts / index.ts packages/server/ prisma/schema.prisma 数据模型(14 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts) @@ -96,7 +105,7 @@ packages/desktop-client/ src/types/desktop.d.ts 主进程 ↔ 渲染进程契约 scripts/ build-main / dev / smoke / dist-win packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install) - manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.1.0.0 + manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.1.2.0 ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0) src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService src/Models/PluginSettings.cs @@ -178,7 +187,7 @@ pnpm build:classisland-plugin | `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) | | `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) | | `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8;缓存/临时目录自动指到 `.cache/`) | -| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/` | +| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/<版本>/` | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm lint` / `pnpm lint:fix` | ESLint | | `pnpm check:icons` | 图标注册表门禁(两端 build 已内联,这里可单独跑;见 §5 第 53 条) | @@ -197,7 +206,7 @@ pnpm build:classisland-plugin | `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 68 项) | | `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 | | `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 | -| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) | +| `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 界面内品牌标) | ### 端口 @@ -209,27 +218,44 @@ pnpm build:classisland-plugin ### 交付产物归集 -打包脚本各自输出到固定目录,**归集到仓库根 `releases/` 是本仓库的人工约定**(该目录 gitignore + eslint ignore): +**三个打包脚本都直接输出到仓库根 `releases/<组件>/<版本>/`,没有中间输出目录** +(该目录 gitignore + eslint ignore),人工归集这一步已经取消: ``` -release-server/ pnpm dist:server 的输出(免安装目录 + NSIS 安装程序) - pnpm dist:server:linux 的输出(classhelper-server-linux-x64-<版本>.tar.gz + .sha256) -packages/desktop-client/release/ pnpm dist:win / dist:dir 的输出(win-unpacked + setup.exe + portable.exe) -releases/ 本仓库约定的归集处(把上面的产物拷进 client/ server/ classisland-plugin/) +releases/ + client/<版本>/ + 安装包/ 班级小助手-<版本>-x64-setup.exe / -portable.exe(+ .blockmap) + 免安装/ electron-builder 的 win-unpacked 内容 + 构建中间/ builder-*.yml、*.nsis.7z + server/<版本>/ + 安装包/ 班级小助手服务端-<版本>-x64-setup.exe + 免安装/ classhelper-server 免安装目录(含 .env、node.exe、data/、logs/) + linux-x64/ classhelper-server-linux-x64-<版本>.tar.gz + .sha256 + 同内容解包目录 + 构建中间/ server-installer.generated.nsi + classisland-plugin/<版本>/ + 安装包/ ClassHelper.ClassIslandPlugin.cipx + 免安装/ ClassHelper.ClassIslandPlugin/(解压即用,丢进 Plugins 目录即可加载) + checksums.md + SHA256SUMS-<版本>.txt 跨组件哈希清单(人读,由发布流程维护) ``` +版本号取自根 `package.json`(插件用 `manifest.yml` 的前三段,与产品版本对齐); +重新打包只清**当前版本目录**里的产物,历史版本原样保留 —— 所以同版本多次构建留下的 +不同产物要自己改名归档(例如 `1.0.0-重构建20261005/`),别把上一次的覆盖掉。 + ```bash -# 客户端:electron-builder 支持指定输出目录(dist-win.mjs 会把额外参数透传) -node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对路径>\releases\client - -# 服务端:输出路径写死在 release-server/,打完自行移进 releases/server/ -pnpm dist:server # 首次会跑一次 npm install;重试可加 --reuse-deps 跳过 +pnpm dist:server # 服务端 Windows(首次会跑一次 npm install;重试可加 --reuse-deps 跳过) pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④) - -# ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/ +pnpm dist:win # 客户端:安装包 + 免安装目录;dist:dir 只出免安装目录 pnpm dist:classisland-plugin ``` +`dist-win.mjs` 用 `--config.directories.output` 覆盖 electron-builder 自己的输出目录,打包成功后 +再把原始布局整理成上面的分层(`win-unpacked/` → `免安装/`、`*.exe` → `安装包/`、`*.yml` → `构建中间/`); +显式传 `-c.directories.output=<路径>` 时不再覆盖,整理以传入路径为准。 +**交付物的完整性标签修正(§7 第 7 条)也挂在这条收尾链路上,别把 `tidyArtifacts` 之后的 +`normalizeIntegrityLabel(destDir)` 拆掉。** + --- ## 5. 架构硬约定(改代码前必须知道) @@ -528,6 +554,17 @@ database / update`。 主题状态在 `stores/ui.ts`(持久化到主进程配置 `theme`),主窗口 `backgroundColor` 也按主题设置 (深色下若仍用浅色底会"先白后黑"闪一下)。 + **两端都有深色主题,机制一致**(Web 管理端 2026-10-06 补齐):`html.dark` + element-plus 的 + `dark/css-vars.css` + 两端各自的 `stores/ui.ts`。语义色令牌是 + `--ch-surface` / `--ch-surface-alt` / `--ch-surface-sunken` / `--ch-surface-hover` 与 + `--ch-text` / `-secondary` / `-muted` / `-placeholder`;**`html.dark` 只覆盖颜色, + 绝不覆盖圆角与动效令牌** —— 两套主题的几何必须完全一致,否则切换时会看到布局跳一下。 + Web 端还要一并同步 `` 与 `color-scheme`(后者管原生控件: + 滚动条、日期选择器弹层 —— 只改 CSS 变量的话它们仍是浅色)。 + 默认**跟随系统**(`prefers-color-scheme`,系统改了就跟着变);用户手动点过之后以用户为准。 + 切换入口两端都在顶栏,都走 `useThemeReveal()`(见第 61 条)。 + 改令牌时只改值 —— **不要对样式表跑批量 sed**,理由见第 61 条末尾。 + 47. **数据库管理模块(`/api/database/*`,仅 ADMIN)——改它之前必须知道的四件事**: ① **一切都建立在 `lib/snapshot.ts` 的 JSON 快照上**(备份 / 导入导出 / 跨库迁移共用一种格式, 14 张表按拓扑序导出、恢复时临时关外键检查、分批 `createMany`)。恢复时**必须**先关外键检查: @@ -766,6 +803,118 @@ database / update`。 `STAGE_DIR` 而不是 `echo` 返回路径)这两类是实打实的 bug;`die` 要先 `trap - ERR` 再退出, 免得 ERR 钩子再补一句误导的"第 N 行执行失败"。 +60. **安全响应头里的 `upgrade-insecure-requests` 与 HSTS 只能在 HTTPS 请求下下发**(`middleware/security.ts` + 的 `buildHelmet(httpsRequest)`,按 `req.secure` 二选一;TRUST_PROXY 开启时 `req.secure` 已含 + `x-forwarded-proto` 判断)。helmet 的默认 CSP **自带 `upgrade-insecure-requests`**,而安装器的 + 默认形态是 `http://IP:端口` 直连(无 TLS):这条指令会把页面**子资源**请求全部改写成 https 发出去, + 服务器没有 TLS 监听,全部连接失败 —— 表现为「管理端一片空白、标签页标题正常」 + (2026-10-05 用户在 98.142.241.144 实测踩到;`/healthz`、登录 API 都正常,纯浏览器侧的资产全灭)。 + HSTS 在明文响应里按规范会被浏览器忽略,但 CSP 指令没有这层豁免,所以两者都要跟着 https 走。 + 开发时一直用 localhost(浏览器视为安全上下文、不做改写),所以这个坑在本地永远不复现, + 只有真机 http 直连部署才炸 —— 别因为"本地好好的"就把条件判断摘掉。 + +61. **动效层:令牌在 `packages/shared/src/motion.ts`,两端各一份组合式函数。** + 移植自 [beUI](https://beui.dev) 的 `lib/ease.ts`(它的组件是 React,用不了;动效语言与框架无关)。 + 三组缓动 + 六组具名弹簧(`SPRING_PRESS/SWAP/PANEL/LAYOUT/MOUSE/GLIDE`)。 + + - **令牌必须是纯常量**:`packages/shared` 被后端一起消费,它的 tsconfig 是 `lib: ["ES2023"]` + (不含 DOM),所以 `motion.ts` 里不碰 `document` / `window` / vue。DOM 注入与 Vue 组合式函数 + 在两端各自的 `composables/motion.ts`(两端各一份,与 `ScoreBarChart.vue` 同一做法)。 + - **CSS 变量在 `app.mount()` 之前由 JS 注入 `:root`**(两端 `main.ts` 调 `applyMotionTokens()`)。 + CSS 里只引用 `var(--ch-spring-*)`,**不要抄数值** —— 抄了必漂。 + - **弹簧靠数值积分生成 CSS `linear()`**,于是弹簧可以纯 CSS 驱动,不需要每组件一个 rAF 循环。 + 算落定时间**不能用包络公式 `6/(ζ·ω0)`**:它只对欠阻尼成立,过阻尼要用较慢的实极点 + `ω0(ζ−√(ζ²−1))`,否则曲线会被截断在 0.87/0.95 上、元素永远差一截没到位(实测踩过)。 + - **`--ch-spring-X` 与 `--ch-spring-X-dur` 必须成对用**:`linear()` 是把 [0,1] 进度重映射, + 时长给短了等于把整条曲线等比压缩 —— 形状还在,但已经不是那组参数的手感了。 + - `linear()` 的进度值可以 >1(过冲):只能用在不越界无害的属性上。**别用在 `opacity`**。 + + **四条硬约束(都对应实测过的坑)**: + + - **路由层不要包 ``,尤其不要 `mode="out-in"`。** 它把"旧组件卸载、新组件挂载"推迟到 + 退场动画结束(90ms 起),后果有两个:① 点菜单后有一小段时间页面上还是上一页,`ui-smoke` 的多条 + 用例("pathname 一变就找新页面的元素")会整片判红 —— 实测一次掉 9 项;② 退场判定依赖 + `animationend`,**窗口被遮挡时 Chromium 冻结 CSS 动画**(实测 `document.timeline` 1.2 秒推进 0ms), + 事件永不触发 ⇒ 新页面永远不挂载,整个应用卡死在第一页。页面切换的动效改用 `.page > *` 错峰入场, + 那是随组件挂载**同步**触发的。 + - **Vue 的 `` 一律显式给 `:duration`。** 同上,靠 `animationend` 判断的写法在窗口不可见时 + 会永远停住(`UrgentClassWarning` 那块铺满全屏的遮罩尤其致命:不卸载就再也点不动)。 + - **`position: fixed` 的整屏层、以及"自己量几何再据此设尺寸"的元素,不要叠位移类动画。** + `.page > *` 错峰入场排除了 `.el-overlay`(Element Plus 的 `el-dialog` 默认 `append-to-body=false`, + 声明在视图里的弹窗其遮罩就是 `.page` 的直接子元素)、`.el-loading-mask`、`.board-host` + (客户端的作业看板按"刚好铺满"算 `transform: scale()`,下移 6px 就溢出屏幕)。 + 判定方法是 `getBoundingClientRect()` **包含** transform,而 `clientHeight` / `offsetHeight` 不含。 + - **弹窗面板的动画要挂在 `.el-dialog` 上,不要挂在 `.el-overlay-dialog`(居中容器)上。** + Element Plus 的 `@opened` 按**根元素**(`.el-overlay`,只有 200ms 淡入)的动画结束触发, + 而外层容器上的弹簧要跑 463ms —— 任何在 `@opened` 里量容器尺寸做自适应的弹窗都会量到 + "还带着 `scale(0.97)` 的缩小版",被 `minScale` 兜住后反而超出屏幕(作业看板实测 +7px)。 + 挂 `.el-dialog` 还有个好处:全屏弹窗能用 `is-fullscreen` 精确豁免(全屏表面本来也不该缩放入场)。 + + **两条容易踩的实现细节**: + - 客户端的 `--nav-ease` **刻意保留** Material 的 `cubic-bezier(.4, 0, .2, 1)`,没有统一成 EASE_OUT。 + EASE_OUT 起步极快,宽度掉到 80px 以下时动画才走了约 35%,而 `layoutChromeSelfTest` 一到读宽度 + 判据就往下走、立刻量图标中心,此刻内边距还在动 ⇒ 6 个分组子项偏 2.3px 判红。想换曲线得连那条断言 + 一起重新商量。(该断言的宽度判据已从 `≤80` 收紧到 `≤65`,即"真正收拢到稳态"再量 —— 折叠目标宽度是 64px。) + - **Vue 的 scoped ` diff --git a/packages/desktop-client/src/island/main.ts b/packages/desktop-client/src/island/main.ts index 6f68988..7726a71 100644 --- a/packages/desktop-client/src/island/main.ts +++ b/packages/desktop-client/src/island/main.ts @@ -1,5 +1,6 @@ import { createApp } from 'vue'; import IslandApp from './IslandApp.vue'; +import { applyMotionTokens } from '../renderer/composables/motion.js'; /** * 灵动岛渲染进程入口。 @@ -8,6 +9,19 @@ import IslandApp from './IslandApp.vue'; */ const app = createApp(IslandApp); +/** + * 动效令牌(beUI 的缓动曲线,见 packages/shared/src/motion.ts)写进 `:root`。 + * + * **必须在这里也注入一次**:灵动岛是**另一个渲染进程**,不共享主窗口的 `:root`。 + * 而 `IslandApp.vue` 的悬停微交互引用了 `var(--ch-dur-fast)` / `var(--ch-ease-out)`—— + * 变量缺席会让整条 `transition` 简写失效(自定义属性解析不出来 → 声明作废), + * 结果是**完全没有过渡**,比改造前还差。 + * + * 成本可控:多出来的是共享模块里的令牌常量与弹簧积分(约 2KB), + * 相比岛内已经打包的 Vue 可以忽略;换来的是"两端一岛同一套曲线、不各抄一份"。 + */ +applyMotionTokens(); + app.config.errorHandler = (error) => { console.error('[island] 渲染异常', error); }; diff --git a/packages/desktop-client/src/renderer/App.vue b/packages/desktop-client/src/renderer/App.vue index 464f291..cebf06b 100644 --- a/packages/desktop-client/src/renderer/App.vue +++ b/packages/desktop-client/src/renderer/App.vue @@ -137,7 +137,17 @@ onUnmounted(() => {

班级小助手

-

{{ bootText }}

+ + +

{{ bootText }}

+
@@ -160,6 +170,19 @@ onUnmounted(() => { margin: 6px 0 0; font-size: 20px; font-weight: 600; + /* 启动页是"整个应用的第一帧",给标题一点入场分量是值得的 */ + animation: boot-title-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both; +} + +@keyframes boot-title-in { + from { + opacity: 0; + transform: translateY(6px) scale(0.98); + } + to { + opacity: 1; + transform: none; + } } .boot-text { @@ -167,4 +190,34 @@ onUnmounted(() => { font-size: 13px; color: rgba(255, 255, 255, 0.75); } + +@keyframes boot-text-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes boot-text-out { + from { + opacity: 1; + transform: none; + } + to { + opacity: 0; + transform: translateY(-4px); + } +} + +.boot-text-enter-active { + animation: boot-text-in var(--ch-dur-base) var(--ch-ease-out) both; +} + +.boot-text-leave-active { + animation: boot-text-out var(--ch-dur-instant) var(--ch-ease-out) both; +} diff --git a/packages/desktop-client/src/renderer/cache/self-test.ts b/packages/desktop-client/src/renderer/cache/self-test.ts index 5692e60..b92e234 100644 --- a/packages/desktop-client/src/renderer/cache/self-test.ts +++ b/packages/desktop-client/src/renderer/cache/self-test.ts @@ -993,9 +993,17 @@ export async function layoutChromeSelfTest(): Promise { Number.isFinite(asideTransitionSeconds) && asideTransitionSeconds > 0.05; if (!clickToggle('[data-test="sidebar-toggle"]')) return { ok: false, detail: '汉堡按钮点击失败' }; + /* + * 判据是「宽度收拢到**稳态**」而不是「宽度小于 80」。 + * + * 折叠的目标宽度是 64px(ClientLayout 的 `:width="ui.sidebarCollapsed ? '64px' : '200px'"`), + * 而宽度与菜单项内边距是同一条过渡在驱动 —— 只等「≤80」会在动画还剩几十毫秒时就往下走, + * 下面量图标中心时内边距仍在变化,量到的是中间帧而非稳态(实测出现过 0.5px 与 2.3px 两个值, + * 全看当时机器有多忙)。等它真正到位之后再量,这条断言才是它想表达的"折叠后同列"。 + */ const collapsed = await waitUntil(() => { const aside = document.querySelector('.aside.ch-nav'); - return Boolean(aside?.classList.contains('is-collapsed')) && (aside?.offsetWidth ?? 999) <= 80; + return Boolean(aside?.classList.contains('is-collapsed')) && (aside?.offsetWidth ?? 999) <= 65; }, 4000); const asideAfter = document.querySelector('.aside.ch-nav'); const widthAfter = asideAfter?.offsetWidth ?? 0; diff --git a/packages/desktop-client/src/renderer/composables/motion.ts b/packages/desktop-client/src/renderer/composables/motion.ts new file mode 100644 index 0000000..27297a4 --- /dev/null +++ b/packages/desktop-client/src/renderer/composables/motion.ts @@ -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 { + 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 { + 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/desktop-client/src/renderer/config.ts b/packages/desktop-client/src/renderer/config.ts index 517453c..59ccf59 100644 --- a/packages/desktop-client/src/renderer/config.ts +++ b/packages/desktop-client/src/renderer/config.ts @@ -3,6 +3,19 @@ import { DEFAULT_SERVER_URL } from '@classhelper/shared'; /** 默认后端地址(首次启动的预填值) */ export const DEFAULT_SERVER = DEFAULT_SERVER_URL; +/** + * 品牌标地址(侧栏与登录页的 `` 用它)。 + * + * 模板里既不能写 `src="logo.png"`、也不能写 `src="/logo.png"`: + * ① 字面量的**相对** `src` 会被 Vite 当成模块导入去解析 —— 构建直接失败 + * (`Rolldown failed to resolve import "logo.png" from "LoginView.vue"`); + * ② 根绝对路径在打包形态下是坏的:Electron 用 `file://` 加载 + * `dist/renderer/index.html`,`/logo.png` 会解析到**盘符根目录**。 + * 所以只能「绑定属性 + 跟着 vite base 走」:base 为 `'./'`,结果是 `./logo.png`, + * 开发态(5174 静态服务)与打包态(`dist/renderer/logo.png`)都指向 public/ 拷过去的那份。 + */ +export const BRAND_LOGO_URL = `${import.meta.env.BASE_URL}logo.png`; + /** 由服务器地址推导 API 基地址,例如 http://127.0.0.1:4000 -> http://127.0.0.1:4000/api */ export function apiBaseOf(serverUrl: string): string { return `${serverUrl.trim().replace(/\/+$/, '')}/api`; diff --git a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue index 89166aa..9cf9820 100644 --- a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue +++ b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue @@ -4,6 +4,8 @@ import { useRoute, useRouter } from 'vue-router'; import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus'; import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; import { startIslandBridge, stopIslandBridge } from '../island/bridge.js'; +import { useThemeReveal } from '../composables/motion.js'; +import { BRAND_LOGO_URL } from '../config.js'; import { useAppStore } from '../stores/app.js'; import { useAuthStore } from '../stores/auth.js'; import { useNotificationStore } from '../stores/notifications.js'; @@ -51,6 +53,115 @@ const menuGroups = [ }, ]; +/* -------------------------------------------------------------- 导航指示器 */ + +/** + * 侧栏激活项的**滑动指示器**(beUI 的 `shared-layout-bg` / tabs 的 layoutId 手法)。 + * + * 做法是**一个**绝对定位的底片跟着激活项平移,而不是让每个菜单项各自画一块底色 —— + * 后者在切换时是"旧的瞬间消失、新的瞬间出现",看不出两者之间的关系。 + * + * 位置靠**量 DOM**:菜单项的位置由 Element Plus 的 padding 与行高决定, + * 写死会在组件库升级或文案变长时错位;折叠成图标导轨时宽度还会变,量出来才跟得上。 + * + * ⚠️ 这里用 `document.querySelector` 而不是模板 ref:`.aside` 是 `el-aside` **组件**, + * 模板 ref 拿到的是组件实例(`.querySelector` 不是函数,实测踩过); + * 客户端只有一个主侧栏,直接查文档反而更直白。 + */ +const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false }); + +function syncIndicator(): void { + const aside = document.querySelector('.aside.ch-nav'); + const active = aside?.querySelector('.el-menu-item.is-active'); + if (!aside || !active) { + indicator.value = { ...indicator.value, visible: false }; + return; + } + const asideRect = aside.getBoundingClientRect(); + const rect = active.getBoundingClientRect(); + indicator.value = { + x: rect.left - asideRect.left, + y: rect.top - asideRect.top, + width: rect.width, + height: rect.height, + visible: rect.width > 0 && rect.height > 0, + }; +} + +const indicatorStyle = computed(() => ({ + width: `${indicator.value.width}px`, + height: `${indicator.value.height}px`, + transform: `translate(${indicator.value.x}px, ${indicator.value.y}px)`, + opacity: indicator.value.visible ? '1' : '0', +})); + +let asideObserver: ResizeObserver | null = null; +let indicatorFrame = 0; + +/** + * 把测量推迟到下一帧再跑。 + * + * 侧栏折叠时 `.aside` 的宽度有一整段过渡,`ResizeObserver` 会逐帧回调;若在回调里**同步** + * 读 `getBoundingClientRect()` 再写样式,就是在每一帧中间强行插入一次同步布局 + * (Chromium 还会因此报 "ResizeObserver loop completed with undelivered notifications")。 + * 放进 rAF 就变成"本帧布局完成后统一量一次",代价是跟随慢一帧,肉眼无感。 + */ +function scheduleIndicatorSync(): void { + if (indicatorFrame) return; + indicatorFrame = requestAnimationFrame(() => { + indicatorFrame = 0; + syncIndicator(); + }); +} + +/** + * 首次测量放在 `onMounted`:子组件先于父组件挂载完成,此刻 `.el-menu-item.is-active` + * 已在文档里,量完再让浏览器首帧绘制 —— 指示器第一次出现就是正确位置,不会从左上角滑过去。 + * + * `ResizeObserver` 不是为了窗口缩放,而是为了**侧栏折叠**:折叠时 `.aside` 的宽度 + * 有一整段过渡(`--nav-dur`),观察它会逐帧回调,指示器于是跟着栏宽一起收 —— + * 否则图标导轨形成后,底片还停在展开态的宽度上。 + */ +onMounted(() => { + syncIndicator(); + const aside = document.querySelector('.aside.ch-nav'); + if (aside && typeof ResizeObserver !== 'undefined') { + asideObserver = new ResizeObserver(scheduleIndicatorSync); + asideObserver.observe(aside); + } +}); + +onUnmounted(() => { + if (indicatorFrame) cancelAnimationFrame(indicatorFrame); + indicatorFrame = 0; + asideObserver?.disconnect(); + asideObserver = null; +}); + +// 路由变化后菜单的 active 类由 Element Plus 更新,等一拍再量 +watch( + () => route.path, + () => { + void nextTick(syncIndicator); + }, +); + +/* ---------------------------------------------------------- 主题切换动画 */ + +/** + * 顶栏的日夜快捷切换。 + * + * "从点击位置扩散的圆"那套整页揭示由 `useThemeReveal()` 提供 —— + * **与 Web 管理端共用同一份实现**(两端 `composables/motion.ts` 里的同名函数), + * 这里只负责把"点击事件"和"切换主题"接起来。那三条守卫(不支持 View Transition / + * 减少动态效果 / 文档不可见)都在组合式函数里,别在这里再判一遍。 + */ +const revealThemeChange = useThemeReveal(); + +function handleThemeToggle(event: MouseEvent): void { + revealThemeChange(event, () => ui.toggleTheme()); +} + /** 未读红点挂在「通知」项的图标右上角 */ function showBadge(path: string): boolean { return path === '/notifications' && notifications.unreadCount > 0; @@ -186,11 +297,12 @@ onUnmounted(() => { - + 班级小助手 + +