diff --git a/README.md b/README.md index c118494..23e331f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 班级小助手(Class Helper) +# 班级小助手(Class Helper) 班级信息管理系统,采用 pnpm monorepo,包含**后端服务**、**Web 管理端**(教师/管理员)与 **桌面客户端**(学生)三端。核心链路: @@ -20,7 +20,7 @@ | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | -| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 19/19) | +| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 26/26) | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) | @@ -173,13 +173,13 @@ pnpm verify:desktop # 另开一个终端先启动后端:pnpm dev:server pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截:62 项 pnpm verify:web # Web 管理端真实点击(含上课时段紧急通知全屏警告):8 项 -pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成):19 项 +pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成):26 项 ``` 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项, 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 -(登录 + 四类数据 + Socket.IO),实测 19/19 通过。 +(登录 + 四类数据 + Socket.IO),实测 26/26 通过。 ## 默认账号与种子数据 @@ -202,30 +202,30 @@ pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成) ## 常用命令 -| 命令 | 说明 | -| ------------------------------------------- | ------------------------------------------------------------------------- | -| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | -| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | -| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | -| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 | -| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) | -| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) | -| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) | -| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | -| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | -| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | -| `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) | -| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,8 项) | -| `pnpm verify:desktop` | EXE 客户端冒烟验证(19 项,含灵动岛像素级截图校验、侧边栏点击与离线回退) | -| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | -| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | -| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | -| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | -| `pnpm db:migrate` | 创建并应用迁移 | -| `pnpm db:seed` | 写入种子数据 | -| `pnpm db:reset` | 重置数据库并重新执行 seed | -| `pnpm db:studio` | 打开 Prisma Studio | -| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | +| 命令 | 说明 | +| ------------------------------------------- | ------------------------------------------------------------------------------ | +| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | +| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | +| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | +| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 | +| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) | +| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) | +| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) | +| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | +| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | +| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | +| `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) | +| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,8 项) | +| `pnpm verify:desktop` | EXE 客户端冒烟验证(26 项,含灵动岛行为/像素级截图校验、侧边栏点击与离线回退) | +| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | +| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | +| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | +| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | +| `pnpm db:migrate` | 创建并应用迁移 | +| `pnpm db:seed` | 写入种子数据 | +| `pnpm db:reset` | 重置数据库并重新执行 seed | +| `pnpm db:studio` | 打开 Prisma Studio | +| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | ## EXE 客户端(学生端) @@ -258,19 +258,26 @@ pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成) 客户端有一个独立于主窗口的**灵动岛**:一个无边框、透明、置顶、不占任务栏的窄条窗口, 常驻桌面**中上方**(屏幕工作区顶部居中,`y = workArea.y + 8`)。它是"消息到达"的第一现场: -| 场景 | 灵动岛行为 | -| -------------------------- | ------------------------------------------------------------------------------------------------------ | -| 收到通知(非上课时段) | 从隐藏态淡入为**胶囊**:`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 | -| 点击胶囊 | 展开为**详情卡**:标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 | -| 上课时间段收到普通通知 | **自动隐藏**(不打扰课堂),进入待发队列;下课后自动在桌面中上方弹出详情(14 秒)→ 收起为胶囊(25 秒) | -| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 | -| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" | -| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) | +| 场景 | 灵动岛行为 | +| -------------------------- | ---------------------------------------------------------------------------------------------------------- | +| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 | +| 点击胶囊 | 展开为**详情卡**(弹簧形变 + 内容弹入):标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 | +| 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) | +| 点击右上角收起按钮 | 同上:回缩为胶囊 | +| 上课时间段收到普通通知 | **完全不显示**(窗口直接隐藏,不打扰课堂)并进入待发队列;下课后自动弹出详情(14 秒)→ 收起为胶囊(25 秒) | +| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带内部红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 | +| 上课时间段内的任何点击 | 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上 | +| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" | +| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) | -- 动画:窗口尺寸用 `easeOutCubic` 260ms 逐帧缓动(胶囊 ↔ 详情 形变),透明度单独淡入淡出; - 卡片内部再用 `cubic-bezier(0.34, 1.56, 0.64, 1)`(弹簧回弹)做入场与点击反馈, - 紧急形态的红色光晕由独立的 `.glow` 层以 `urgent-breath` 无限呼吸,未读点用 `dot-pulse`。 - 两套窗口动画使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。 +- 动画:窗口尺寸用逐帧缓动实现"形变"(胶囊 → 详情/紧急为 340ms `easeOutBack` **弹簧回弹**, + 收起为 260ms `easeOutCubic`),胶囊出现**不做动画**直接显示; + 卡片内部用 `cubic-bezier(0.34, 1.56, 0.64, 1)` 弹簧缩放入场,未读点用 `dot-pulse`。 + 尺寸与淡入使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。 +- 光晕:紧急形态的红色呼吸光晕**只用 inset 阴影**画在卡片内部。 + 窗口只比卡片大 2~4px,任何向外的 box-shadow/光晕都会被窗口边界裁切, + 在屏幕上表现为"卡片周围一圈奇怪的光晕硬边"——冒烟测试里有一条像素断言守着这一点 + (截图最外圈偏红像素必须为 0)。 - 事件流:`notification:new` → `renderer/stores/realtime.ts` → `pushNotificationToIsland()` → 主进程 `IslandController`(决定隐藏/胶囊/详情)→ 灵动岛渲染进程 `src/island/IslandApp.vue`。 - 相关文件:`src/main/island.ts`(控制器 + IPC)、`src/preload/island.ts`、`src/island/IslandApp.vue`、 @@ -281,8 +288,8 @@ pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成) | 形态 | 截图 | 尺寸(逻辑 / 截图) | 说明 | | ---------------------- | ------------------------------------------------------------------- | ------------------- | ----------------------------------------- | | 下课后自动弹出详情 | ![下课后自动弹出](docs/screenshots/island/island-2-after-class.png) | 404×316 / 808×632 | 上课期间暂存的通知,下课后自动弹出 | -| 上课期间紧急通知 | ![紧急通知立即展开](docs/screenshots/island/island-3-urgent.png) | 424×344 / 848×688 | 红色光晕 + 紧急角标,无需点击 | -| 非上课时段"新消息"胶囊 | ![新消息胶囊](docs/screenshots/island/island-4-pill.png) | 268×44 / 536×88 | 点击后展开详情 | +| 上课期间紧急通知 | ![紧急通知立即展开](docs/screenshots/island/island-3-urgent.png) | 424×344 / 848×688 | 卡片**内部**红色光晕 + 紧急角标,无需点击 | +| 非上课时段"新消息"胶囊 | ![新消息胶囊](docs/screenshots/island/island-4-pill.png) | 268×44 / 536×88 | 直接出现(无入场动画),点击后展开 | | 点击胶囊后的详情 | ![点击后展开](docs/screenshots/island/island-5-clicked.png) | 404×316 / 808×632 | 标题/内容/时间 + 打开应用/标为已读/知道了 | > 截图路径可用 `ISLAND_SHOTS_DIR` 覆盖(默认 `.cache/island-shots/`); @@ -327,27 +334,35 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS `pnpm verify:desktop` 以 `ELECTRON_SMOKE_TEST=1` 启动应用(不弹窗),主进程依次校验后自动退出: -| 校验项 | 实测结果 | -| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` | -| 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 | -| preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) | -| IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 | -| 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` | -| IndexedDB 缓存读写 | ✅ 读写/统计/删除 + 6 个 store | -| 断网时回退本地缓存 | ✅ `fromCache=true`(请求不可达端口) | -| 灵动岛窗口创建(置顶/透明/不占任务栏) | ✅ `ready=true` | -| 上课期间普通通知自动隐藏(暂存) | ✅ `mode=hidden queued=1` | -| 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` | -| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` | -| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) | -| **灵动岛截图留档 + 像素级校验** | ✅ 4 张(808×632 / 848×688 / 536×88 / 808×632);宽高比与状态机配置偏差 **0.000**;胶囊态偏红 205 px、紧急态偏红 **20473 px**(红色光晕);形态尺寸递增 pill < expanded < urgent | -| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 6 作业 / 9 通知 / 8 成绩 / 第1周30节课 / Socket.IO 已连接 | +| 校验项 | 实测结果 | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` | +| 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 | +| preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) | +| IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 | +| 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` | +| IndexedDB 缓存读写 | ✅ 读写/统计/删除 + 6 个 store | +| 断网时回退本地缓存 | ✅ `fromCache=true`(请求不可达端口) | +| 灵动岛窗口创建(置顶/透明/不占任务栏) | ✅ `ready=true` | +| 上课期间普通通知自动隐藏 | ✅ `mode=hidden queued=1 visible=false`(窗口真的隐藏,不是只改状态) | +| 上课期间点击不展开(严格不显示) | ✅ `mode=hidden visible=false` | +| 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` | +| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` | +| **紧急通知带展开动画** | ✅ 16ms 采样 34 帧,出现 7 个中间尺寸,最终 `424x344`(窗口从胶囊尺寸弹簧展开) | +| 上课期间收起不回缩成胶囊 | ✅ `mode=hidden visible=false` | +| **普通通知直接显示胶囊(无入场动画)** | ✅ 推送前已隐藏,60ms 内窗口即为 `268x44`、`mode=pill`(无"上岛"动画) | +| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) | +| **点击卡片空白处回缩为胶囊** | ✅ 真实点击 `.body` 空白区域 → `mode=pill` | +| **点击右上角收起按钮回缩为胶囊** | ✅ 真实点击 `.icon-btn` → `mode=pill` | +| **灵动岛截图留档 + 像素级校验** | ✅ 4 张(808×632 / 848×688 / 536×88 / 808×632);宽高比与状态机配置偏差 **0.000**;形态尺寸递增 pill < expanded < urgent;紧急态偏红 **20584 px**(卡片内部光晕) | +| **紧急红晕不外溢(窗口边缘无红光)** | ✅ 截图最外圈(2px)偏红像素 = **0**(守住"卡片周围不出现光晕硬边") | +| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 7 作业 / 20 通知 / 8 成绩 / 第1周30节课 / Socket.IO 已连接 | 该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=` 写出 JSON 结果, -已实测 `packaged: true`、16/16 通过、退出码 0)。灵动岛各状态的窗口截图会写到 +已实测 `packaged: true`、23/23 通过、退出码 0)。灵动岛各状态的窗口截图会写到 `ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), -并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)与紧急态红色像素做断言。 +并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。 +冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。 ## 上课时段策略(紧急通知二次确认) @@ -520,7 +535,7 @@ pnpm db:migrate && pnpm db:seed | 产物 | 大小 | 冒烟结果 | | -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------ | | 服务端安装程序(内置 Node + Web 端) | 34MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 → **对安装实例跑 62/62 e2e + 8/8 UI** | -| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 16/16,`packaged: true`,退出码 0 | +| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 23/23,`packaged: true`,退出码 0 | | `release/…-x64-portable.exe`(单文件) | ~107MB | ✅(含联网集成:登录王小明 / Socket.IO 已连接 / 四类数据) | | `release/…-x64-setup.exe`(客户端) | ~107MB | ✅ 构建成功,已嵌入自定义图标;包含灵动岛 | @@ -530,7 +545,7 @@ pnpm db:migrate && pnpm db:seed | ---------------------------------------------------------------------------- | ----------------------------------- | | `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **62/62** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 上课时段紧急通知 + PWA) | **8/8** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + 灵动岛四态 + 像素级截图校验 + 离线回退) | **19/19** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + 灵动岛行为 + 像素级截图校验 + 离线回退) | **26/26** ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | | 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | | Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | diff --git a/docs/screenshots/island/island-2-after-class.png b/docs/screenshots/island/island-2-after-class.png index 9338e08..9e81d3b 100644 Binary files a/docs/screenshots/island/island-2-after-class.png and b/docs/screenshots/island/island-2-after-class.png differ diff --git a/docs/screenshots/island/island-3-urgent.png b/docs/screenshots/island/island-3-urgent.png index 69e9631..ba61169 100644 Binary files a/docs/screenshots/island/island-3-urgent.png and b/docs/screenshots/island/island-3-urgent.png differ diff --git a/docs/screenshots/island/island-4-pill.png b/docs/screenshots/island/island-4-pill.png index 8c49f6b..e6f2c6e 100644 Binary files a/docs/screenshots/island/island-4-pill.png and b/docs/screenshots/island/island-4-pill.png differ diff --git a/docs/screenshots/island/island-5-clicked.png b/docs/screenshots/island/island-5-clicked.png index 7584d4a..fca2747 100644 Binary files a/docs/screenshots/island/island-5-clicked.png and b/docs/screenshots/island/island-5-clicked.png differ diff --git a/packages/desktop-client/scripts/smoke.mjs b/packages/desktop-client/scripts/smoke.mjs index 457ba76..44230c4 100644 --- a/packages/desktop-client/scripts/smoke.mjs +++ b/packages/desktop-client/scripts/smoke.mjs @@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'; import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(root, '..', '..'); const electronPath = resolveElectronExecutable(path.join(root, 'node_modules', 'electron')); const required = [path.join(root, 'dist/main/index.js'), path.join(root, 'dist/renderer/index.html')]; const missing = required.filter((file) => !fs.existsSync(file)); @@ -25,6 +26,11 @@ if (missing.length > 0) { console.log('[smoke] 启动 Electron 冒烟验证...\n'); +// 独立的 userData:避免与用户正在运行的客户端抢单实例锁(否则新进程会静默退出) +const profileDir = path.join(repoRoot, '.cache', 'desktop-smoke-profile'); +fs.rmSync(profileDir, { recursive: true, force: true }); +console.log(`[smoke] 独立配置目录:${profileDir}`); + // 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试 let online = process.env.ELECTRON_SMOKE_ONLINE; if (online === undefined) { @@ -48,7 +54,11 @@ if (online === undefined) { const child = spawn(electronPath, ['.'], { cwd: root, stdio: 'inherit', - env: resolveElectronEnv({ ELECTRON_SMOKE_TEST: '1', ELECTRON_SMOKE_ONLINE: online }), + env: resolveElectronEnv({ + ELECTRON_SMOKE_TEST: '1', + ELECTRON_SMOKE_ONLINE: online, + ELECTRON_SMOKE_PROFILE: profileDir, + }), }); child.on('exit', (code) => { diff --git a/packages/desktop-client/src/island/IslandApp.vue b/packages/desktop-client/src/island/IslandApp.vue index cf1a366..37845f3 100644 --- a/packages/desktop-client/src/island/IslandApp.vue +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -40,6 +40,17 @@ const subtitle = computed(() => { /** 下课补发时提示"其实上课期间就到了" */ const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass); +/** + * 入场动画: + * - 胶囊(普通通知):**没有入场动画**,直接出现在屏幕上 + * - 展开 / 紧急:弹簧缩放入场,配合窗口尺寸缓动形成"展开"效果 + */ +const transitionName = computed(() => (mode.value === 'pill' ? 'island-instant' : 'island-pop')); +const transitionDuration = computed(() => ({ + enter: mode.value === 'pill' ? 0 : 260, + leave: 140, +})); + function expand(): void { bridge?.sendAction('expand'); } @@ -48,6 +59,14 @@ function collapse(): void { bridge?.sendAction('collapse'); } +/** 点击卡片空白处(非按钮、非选中文字)→ 回缩为灵动岛胶囊 */ +function onCardClick(event: MouseEvent): void { + const target = event.target as HTMLElement | null; + if (target?.closest('button')) return; + if (window.getSelection()?.toString()) return; + collapse(); +} + function dismiss(): void { bridge?.sendAction('dismiss'); } @@ -70,8 +89,8 @@ onMounted(async () => {