fix(desktop-client): 灵动岛反馈修正——红晕改为卡片内部 inset(窗口边缘零外溢)、紧急通知补展开动画、普通通知去掉上岛入场动画、点空白处/右上角按钮回缩为胶囊、上课时段严格不显示;冒烟用独立 userData 以免与运行中的客户端抢单实例锁

This commit is contained in:
LoveChengke committed 2026-09-12 09:39:33 +08:00
1 parent 3ab3cb1eb1
commit 38c7660dd9
10 files changed
+353 -119

No files matched your search

+77 -62
View File
@@ -1,4 +1,4 @@
# 班级小助手(Class Helper) # 班级小助手(Class Helper)
班级信息管理系统,采用 pnpm monorepo,包含**后端服务**、**Web 管理端**(教师/管理员)与 班级信息管理系统,采用 pnpm monorepo,包含**后端服务**、**Web 管理端**(教师/管理员)与
**桌面客户端**(学生)三端。核心链路: **桌面客户端**(学生)三端。核心链路:
@@ -20,7 +20,7 @@
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 19/19) | | 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 26/26) |
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) | | 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
@@ -173,13 +173,13 @@ pnpm verify:desktop
# 另开一个终端先启动后端:pnpm dev:server # 另开一个终端先启动后端:pnpm dev:server
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截:62 项 pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截:62 项
pnpm verify:web # Web 管理端真实点击(含上课时段紧急通知全屏警告):8 项 pnpm verify:web # Web 管理端真实点击(含上课时段紧急通知全屏警告):8 项
pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成):19 项 pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成):26 项
``` ```
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项, 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项,
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 客户端脚本验证 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` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | | `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | | `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 | | `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) | | `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) | | `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) |
| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) | | **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) |
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | | `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | | `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | | `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
| `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) | | `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) |
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,8 项) | | `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,8 项) |
| `pnpm verify:desktop` | EXE 客户端冒烟验证(19 项,含灵动岛像素级截图校验、侧边栏点击与离线回退) | | `pnpm verify:desktop` | EXE 客户端冒烟验证(26 项,含灵动岛行为/像素级截图校验、侧边栏点击与离线回退) |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | | `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | | `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | | `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
| `pnpm db:migrate` | 创建并应用迁移 | | `pnpm db:migrate` | 创建并应用迁移 |
| `pnpm db:seed` | 写入种子数据 | | `pnpm db:seed` | 写入种子数据 |
| `pnpm db:reset` | 重置数据库并重新执行 seed | | `pnpm db:reset` | 重置数据库并重新执行 seed |
| `pnpm db:studio` | 打开 Prisma Studio | | `pnpm db:studio` | 打开 Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | | `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
## EXE 客户端(学生端) ## EXE 客户端(学生端)
@@ -258,19 +258,26 @@ pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成)
客户端有一个独立于主窗口的**灵动岛**:一个无边框、透明、置顶、不占任务栏的窄条窗口, 客户端有一个独立于主窗口的**灵动岛**:一个无边框、透明、置顶、不占任务栏的窄条窗口,
常驻桌面**中上方**(屏幕工作区顶部居中,`y = workArea.y + 8`)。它是"消息到达"的第一现场: 常驻桌面**中上方**(屏幕工作区顶部居中,`y = workArea.y + 8`)。它是"消息到达"的第一现场:
| 场景 | 灵动岛行为 | | 场景 | 灵动岛行为 |
| -------------------------- | ------------------------------------------------------------------------------------------------------ | | -------------------------- | ---------------------------------------------------------------------------------------------------------- |
| 收到通知(非上课时段) | 从隐藏态淡入为**胶囊**:`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 | | 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 |
| 点击胶囊 | 展开为**详情卡**:标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 | | 点击胶囊 | 展开为**详情卡**(弹簧形变 + 内容弹入):标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 |
| 上课时间段收到普通通知 | **自动隐藏**(不打扰课堂),进入待发队列;下课后自动在桌面中上方弹出详情(14 秒)→ 收起为胶囊(25 秒) | | 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) |
| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 | | 点击右上角收起按钮 | 同上:回缩为胶囊 |
| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" | | 上课时间段收到普通通知 | **完全不显示**(窗口直接隐藏,不打扰课堂)并进入待发队列;下课后自动弹出详情(14 秒)→ 收起为胶囊(25 秒) |
| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) | | 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带内部红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 |
| 上课时间段内的任何点击 | 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上 |
| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" |
| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) |
- 动画:窗口尺寸用 `easeOutCubic` 260ms 逐帧缓动(胶囊 ↔ 详情 形变),透明度单独淡入淡出; - 动画:窗口尺寸用逐帧缓动实现"形变"(胶囊 → 详情/紧急为 340ms `easeOutBack` **弹簧回弹**,
卡片内部再用 `cubic-bezier(0.34, 1.56, 0.64, 1)`(弹簧回弹)做入场与点击反馈, 收起为 260ms `easeOutCubic`),胶囊出现**不做动画**直接显示;
紧急形态的红色光晕由独立的 `.glow` 层以 `urgent-breath` 无限呼吸,未读点用 `dot-pulse`。 卡片内部用 `cubic-bezier(0.34, 1.56, 0.64, 1)` 弹簧缩放入场,未读点用 `dot-pulse`。
两套窗口动画使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。 尺寸与淡入使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。
- 光晕:紧急形态的红色呼吸光晕**只用 inset 阴影**画在卡片内部。
窗口只比卡片大 2~4px,任何向外的 box-shadow/光晕都会被窗口边界裁切,
在屏幕上表现为"卡片周围一圈奇怪的光晕硬边"——冒烟测试里有一条像素断言守着这一点
(截图最外圈偏红像素必须为 0)。
- 事件流:`notification:new` → `renderer/stores/realtime.ts` → `pushNotificationToIsland()` → - 事件流:`notification:new` → `renderer/stores/realtime.ts` → `pushNotificationToIsland()` →
主进程 `IslandController`(决定隐藏/胶囊/详情)→ 灵动岛渲染进程 `src/island/IslandApp.vue`。 主进程 `IslandController`(决定隐藏/胶囊/详情)→ 灵动岛渲染进程 `src/island/IslandApp.vue`。
- 相关文件:`src/main/island.ts`(控制器 + IPC)、`src/preload/island.ts`、`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-2-after-class.png) | 404×316 / 808×632 | 上课期间暂存的通知,下课后自动弹出 |
| 上课期间紧急通知 | ![紧急通知立即展开](docs/screenshots/island/island-3-urgent.png) | 424×344 / 848×688 | 红色光晕 + 紧急角标,无需点击 | | 上课期间紧急通知 | ![紧急通知立即展开](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-4-pill.png) | 268×44 / 536×88 | 直接出现(无入场动画),点击后展开 |
| 点击胶囊后的详情 | ![点击后展开](docs/screenshots/island/island-5-clicked.png) | 404×316 / 808×632 | 标题/内容/时间 + 打开应用/标为已读/知道了 | | 点击胶囊后的详情 | ![点击后展开](docs/screenshots/island/island-5-clicked.png) | 404×316 / 808×632 | 标题/内容/时间 + 打开应用/标为已读/知道了 |
> 截图路径可用 `ISLAND_SHOTS_DIR` 覆盖(默认 `.cache/island-shots/`); > 截图路径可用 `ISLAND_SHOTS_DIR` 覆盖(默认 `.cache/island-shots/`);
@@ -327,27 +334,35 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
`pnpm verify:desktop` 以 `ELECTRON_SMOKE_TEST=1` 启动应用(不弹窗),主进程依次校验后自动退出: `pnpm verify:desktop` 以 `ELECTRON_SMOKE_TEST=1` 启动应用(不弹窗),主进程依次校验后自动退出:
| 校验项 | 实测结果 | | 校验项 | 实测结果 |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` | | 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` |
| 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 | | 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 |
| preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) | | preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) |
| IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 | | IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 |
| 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` | | 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` |
| IndexedDB 缓存读写 | ✅ 读写/统计/删除 + 6 个 store | | IndexedDB 缓存读写 | ✅ 读写/统计/删除 + 6 个 store |
| 断网时回退本地缓存 | ✅ `fromCache=true`(请求不可达端口) | | 断网时回退本地缓存 | ✅ `fromCache=true`(请求不可达端口) |
| 灵动岛窗口创建(置顶/透明/不占任务栏) | ✅ `ready=true` | | 灵动岛窗口创建(置顶/透明/不占任务栏) | ✅ `ready=true` |
| 上课期间普通通知自动隐藏(暂存) | ✅ `mode=hidden queued=1` | | 上课期间普通通知自动隐藏 | ✅ `mode=hidden queued=1 visible=false`(窗口真的隐藏,不是只改状态) |
| 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` | | 上课期间点击不展开(严格不显示) | ✅ `mode=hidden visible=false` |
| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` | | 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` |
| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) | | 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` |
| **灵动岛截图留档 + 像素级校验** | ✅ 4 张(808×632 / 848×688 / 536×88 / 808×632);宽高比与状态机配置偏差 **0.000**;胶囊态偏红 205 px、紧急态偏红 **20473 px**(红色光晕);形态尺寸递增 pill < expanded < urgent | | **紧急通知带展开动画** | ✅ 16ms 采样 34 帧,出现 7 个中间尺寸,最终 `424x344`(窗口从胶囊尺寸弹簧展开) |
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 6 作业 / 9 通知 / 8 成绩 / 第1周30节课 / Socket.IO 已连接 | | 上课期间收起不回缩成胶囊 | ✅ `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=<file>` 写出 JSON 结果, 该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
已实测 `packaged: true`、16/16 通过、退出码 0)。灵动岛各状态的窗口截图会写到 已实测 `packaged: true`、23/23 通过、退出码 0)。灵动岛各状态的窗口截图会写到
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), `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** | | 服务端安装程序(内置 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-portable.exe`(单文件) | ~107MB | ✅(含联网集成:登录王小明 / Socket.IO 已连接 / 四类数据) |
| `release/…-x64-setup.exe`(客户端) | ~107MB | ✅ 构建成功,已嵌入自定义图标;包含灵动岛 | | `release/…-x64-setup.exe`(客户端) | ~107MB | ✅ 构建成功,已嵌入自定义图标;包含灵动岛 |
@@ -530,7 +545,7 @@ pnpm db:migrate && pnpm db:seed
| ---------------------------------------------------------------------------- | ----------------------------------- | | ---------------------------------------------------------------------------- | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **62/62** ✅ | | `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **62/62** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 上课时段紧急通知 + PWA) | **8/8** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 上课时段紧急通知 + PWA) | **8/8** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛四态 + 像素级截图校验 + 离线回退) | **19/19** ✅ | | `pnpm verify:desktop`(客户端冒烟 + 灵动岛行为 + 像素级截图校验 + 离线回退) | **26/26** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | | 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | | Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 117 KiB

+11 -1
View File
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url';
import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs'; import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); 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 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 required = [path.join(root, 'dist/main/index.js'), path.join(root, 'dist/renderer/index.html')];
const missing = required.filter((file) => !fs.existsSync(file)); const missing = required.filter((file) => !fs.existsSync(file));
@@ -25,6 +26,11 @@ if (missing.length > 0) {
console.log('[smoke] 启动 Electron 冒烟验证...\n'); 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; let online = process.env.ELECTRON_SMOKE_ONLINE;
if (online === undefined) { if (online === undefined) {
@@ -48,7 +54,11 @@ if (online === undefined) {
const child = spawn(electronPath, ['.'], { const child = spawn(electronPath, ['.'], {
cwd: root, cwd: root,
stdio: 'inherit', 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) => { child.on('exit', (code) => {
@@ -40,6 +40,17 @@ const subtitle = computed(() => {
/** 下课补发时提示"其实上课期间就到了" */ /** 下课补发时提示"其实上课期间就到了" */
const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass); 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 { function expand(): void {
bridge?.sendAction('expand'); bridge?.sendAction('expand');
} }
@@ -48,6 +59,14 @@ function collapse(): void {
bridge?.sendAction('collapse'); 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 { function dismiss(): void {
bridge?.sendAction('dismiss'); bridge?.sendAction('dismiss');
} }
@@ -70,8 +89,8 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="island-root"> <div class="island-root" @click.self="collapse">
<Transition name="island-pop" :duration="{ enter: 240, leave: 150 }"> <Transition :name="transitionName" :duration="transitionDuration">
<!-- 胶囊态:收到通知后先变成"新消息" --> <!-- 胶囊态:收到通知后先变成"新消息" -->
<button <button
v-if="mode === 'pill' && notification" v-if="mode === 'pill' && notification"
@@ -111,12 +130,13 @@ onMounted(async () => {
</span> </span>
</button> </button>
<!-- 展开态:显示通知详情 --> <!-- 展开态:显示通知详情(点击空白处回缩为胶囊) -->
<section <section
v-else-if="mode === 'expanded' && notification" v-else-if="mode === 'expanded' && notification"
key="expanded" key="expanded"
class="island-card expanded" class="island-card expanded"
:class="{ urgent: isUrgent }" :class="{ urgent: isUrgent }"
@click="onCardClick"
> >
<span v-if="isUrgent" class="glow" aria-hidden="true"></span> <span v-if="isUrgent" class="glow" aria-hidden="true"></span>
<header class="head"> <header class="head">
@@ -194,7 +214,10 @@ body,
box-sizing: border-box; box-sizing: border-box;
} }
/* 三态卡片绝对定位,使交叉淡入淡出(并行 Transition)不会互相挤压 */ /* 三态卡片绝对定位,使交叉淡入淡出(并行 Transition)不会互相挤压。
注意:窗口只比卡片大 2~4px,任何向外的 box-shadow/光晕都会被窗口边界裁切,
在屏幕上表现为"卡片周围一圈奇怪的光晕/硬边"。因此这里**只用 inset 阴影**,
所有装饰效果都画在卡片内部。 */
.island-card { .island-card {
position: absolute; position: absolute;
top: 2px; top: 2px;
@@ -209,28 +232,24 @@ body,
radial-gradient(120% 140% at 50% -20%, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 55%), radial-gradient(120% 140% at 50% -20%, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 55%),
linear-gradient(180deg, #23242a 0%, #0c0d10 100%); linear-gradient(180deg, #23242a 0%, #0c0d10 100%);
box-shadow: box-shadow:
0 12px 28px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.08); inset 0 -1px 0 rgba(0, 0, 0, 0.35);
transition: transition:
border-color 0.28s ease, border-color 0.28s ease,
box-shadow 0.28s ease,
background 0.28s ease; background 0.28s ease;
} }
/* 紧急通知:红色描边 + 独立光晕层呼吸 /* 紧急通知:红色描边 + 内部光晕呼吸(不外扩,避免窗口边界裁切出光晕硬边) */
注意:无限动画必须放在内部 .glow 上,否则会让 Vue <Transition> 的
transitionend/animationend 判定失效,导致DOM 卡在离场状态。 */
.island-card.urgent { .island-card.urgent {
border-color: rgba(255, 92, 92, 0.55); border-color: rgba(255, 92, 92, 0.6);
box-shadow: box-shadow:
0 12px 30px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 0 0 1px rgba(255, 92, 92, 0.25), inset 0 -1px 0 rgba(0, 0, 0, 0.35);
inset 0 1px 0 rgba(255, 255, 255, 0.1);
} }
.glow { .glow {
position: absolute; position: absolute;
inset: -1px; inset: 0;
border-radius: inherit; border-radius: inherit;
pointer-events: none; pointer-events: none;
animation: urgent-breath 2.2s ease-in-out infinite; animation: urgent-breath 2.2s ease-in-out infinite;
@@ -240,13 +259,13 @@ body,
0%, 0%,
100% { 100% {
box-shadow: box-shadow:
0 0 0 1px rgba(255, 92, 92, 0.22), inset 0 0 14px rgba(255, 72, 72, 0.22),
0 0 18px rgba(255, 72, 72, 0.28); inset 0 0 0 1px rgba(255, 92, 92, 0.3);
} }
50% { 50% {
box-shadow: box-shadow:
0 0 0 1px rgba(255, 92, 92, 0.38), inset 0 0 26px rgba(255, 72, 72, 0.45),
0 0 34px rgba(255, 72, 72, 0.5); inset 0 0 0 1px rgba(255, 92, 92, 0.55);
} }
} }
@@ -576,25 +595,44 @@ body,
/* ---------------------------------------------------------------- 形变动画 */ /* ---------------------------------------------------------------- 形变动画 */
/* 胶囊态:没有入场动画,直接出现 */
.island-instant-enter-active {
transition: none;
}
.island-instant-enter-from {
opacity: 1;
transform: none;
}
.island-instant-leave-active {
transition: opacity 0.12s ease;
}
.island-instant-leave-to {
opacity: 0;
}
/* 展开态:弹簧缩放入场(配合窗口尺寸缓动) */
.island-pop-enter-active { .island-pop-enter-active {
transition: transition:
opacity 0.22s ease, opacity 0.2s ease,
transform 0.34s cubic-bezier(0.34, 1.56, 0.64, 1); transform 0.34s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
.island-pop-leave-active { .island-pop-leave-active {
transition: transition:
opacity 0.16s ease, opacity 0.14s ease,
transform 0.2s ease; transform 0.2s ease;
} }
.island-pop-enter-from { .island-pop-enter-from {
opacity: 0; opacity: 0;
transform: scale(0.86) translateY(-8px); transform: scale(0.88) translateY(-6px);
} }
.island-pop-leave-to { .island-pop-leave-to {
opacity: 0; opacity: 0;
transform: scale(0.9); transform: scale(0.92);
} }
</style> </style>
@@ -12,6 +12,11 @@ const currentDir = __dirname;
const devServerUrl = process.env.VITE_DEV_SERVER_URL; const devServerUrl = process.env.VITE_DEV_SERVER_URL;
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1'; const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
// 自动化验证使用独立的 userData:否则用户正在运行的客户端会占着单实例锁,
// 冒烟进程会"静默退出(退出码 0、无输出)",看起来像构建坏了。
const smokeProfile = process.env.ELECTRON_SMOKE_PROFILE;
if (smokeProfile) app.setPath('userData', smokeProfile);
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
+70 -12
View File
@@ -246,6 +246,8 @@ class IslandController {
handleAction(payload: IslandActionPayload): void { handleAction(payload: IslandActionPayload): void {
switch (payload.action) { switch (payload.action) {
case 'expand': case 'expand':
// 上课时段一律不显示灵动岛(只有紧急通知能自动展开)
if (this.state.inClass) break;
if (this.state.active) { if (this.state.active) {
this.setState({ mode: 'expanded' }); this.setState({ mode: 'expanded' });
this.scheduleCollapse( this.scheduleCollapse(
@@ -253,16 +255,19 @@ class IslandController {
); );
} }
break; break;
case 'collapse': { case 'collapse':
const next = this.state.queued[this.state.queued.length - 1]; // 点击卡片空白处 / 右上角收起按钮:回缩到灵动岛(胶囊),不直接消失
if (next) { if (this.state.inClass) {
this.hideImmediately();
break;
}
if (this.state.active) {
this.setState({ mode: 'pill', reason: this.state.reason }); this.setState({ mode: 'pill', reason: this.state.reason });
this.scheduleCollapse(TIMEOUTS.pill); this.scheduleCollapse(TIMEOUTS.pill);
} else { } else {
this.hide(); this.hide();
} }
break; break;
}
case 'dismiss': case 'dismiss':
this.dismissActive(); this.dismissActive();
break; break;
@@ -290,6 +295,23 @@ class IslandController {
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true }); this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
} }
/**
* 立刻隐藏(不做淡出):
* 上课时间段必须"完全不显示灵动岛",任何残留的淡出都会被投影/屏幕录制看到。
*/
hideImmediately(): void {
this.clearTimers();
this.fadeToken += 1;
const changed = this.state.mode !== 'hidden' || this.state.reason !== null;
if (changed) {
this.state = { ...this.state, mode: 'hidden', reason: null, updatedAt: Date.now() };
this.emit();
}
if (!this.win || this.win.isDestroyed()) return;
if (this.win.isVisible()) this.win.hide();
this.win.setOpacity(1);
}
destroy(): void { destroy(): void {
this.clearTimers(); this.clearTimers();
if (this.win && !this.win.isDestroyed()) this.win.destroy(); if (this.win && !this.win.isDestroyed()) this.win.destroy();
@@ -397,6 +419,13 @@ class IslandController {
private syncWindow(): void { private syncWindow(): void {
if (!this.win || this.win.isDestroyed()) return; if (!this.win || this.win.isDestroyed()) return;
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知"允许出现在屏幕上)
const urgentShowing = this.state.mode === 'expanded' && this.state.active?.priority === 'URGENT';
if (this.state.inClass && !urgentShowing) {
this.hideImmediately();
return;
}
if (this.state.mode === 'hidden') { if (this.state.mode === 'hidden') {
this.fadeOut(); this.fadeOut();
return; return;
@@ -410,13 +439,30 @@ class IslandController {
: SIZES.pill; : SIZES.pill;
if (!this.win.isVisible()) { if (!this.win.isVisible()) {
const pillBounds = this.computeBounds(SIZES.pill);
// 普通通知:直接出现在屏幕上,不做"上岛"入场动画
if (size === SIZES.pill) {
this.cancelFade();
this.win.setOpacity(1);
this.win.setBounds(pillBounds);
this.win.showInactive();
return;
}
// 展开 / 紧急:一定从胶囊尺寸开始缓动,保证"展开动画"看得见
this.cancelFade();
this.win.setOpacity(0); this.win.setOpacity(0);
this.win.setBounds(pillBounds);
this.win.showInactive(); this.win.showInactive();
this.animateBounds(size); this.animateBounds(size, { spring: true });
this.fadeIn(); this.fadeIn();
return; return;
} }
this.animateBounds(size); this.animateBounds(size, { spring: size !== SIZES.pill });
}
/** 作废进行中的淡入淡出动画 */
private cancelFade(): void {
this.fadeToken += 1;
} }
private fadeIn(): void { private fadeIn(): void {
@@ -450,26 +496,30 @@ class IslandController {
step(); step();
} }
/** 尺寸/位置缓动:Windows 下没有原生窗口动画,这里按帧插值实现"形变"效果 */ /**
private animateBounds(size: { width: number; height: number }): void { * 尺寸/位置缓动:Windows 下没有原生窗口动画,这里按帧插值实现"形变"效果。
* `spring` 使用回弹缓动(轻微过冲后回落),用于"展开成卡片"的弹簧手感。
*/
private animateBounds(size: { width: number; height: number }, options: { spring?: boolean } = {}): void {
if (!this.win || this.win.isDestroyed()) return; if (!this.win || this.win.isDestroyed()) return;
const from = this.win.getBounds(); const from = this.win.getBounds();
const to = this.computeBounds(size); const to = this.computeBounds(size);
if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return; if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return;
const token = ++this.boundsToken; const token = ++this.boundsToken;
const duration = 260; const spring = options.spring === true;
const duration = spring ? 340 : 260;
const startedAt = Date.now(); const startedAt = Date.now();
const step = (): void => { const step = (): void => {
if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return; if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return;
const progress = Math.min(1, (Date.now() - startedAt) / duration); const progress = Math.min(1, (Date.now() - startedAt) / duration);
const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic const eased = spring ? easeOutBack(progress) : 1 - Math.pow(1 - progress, 3); // easeOutCubic
const bounds = { const bounds = {
x: Math.round(from.x + (to.x - from.x) * eased), x: Math.round(from.x + (to.x - from.x) * eased),
y: Math.round(from.y + (to.y - from.y) * eased), y: Math.round(from.y + (to.y - from.y) * eased),
width: Math.round(from.width + (to.width - from.width) * eased), width: Math.max(1, Math.round(from.width + (to.width - from.width) * eased)),
height: Math.round(from.height + (to.height - from.height) * eased), height: Math.max(1, Math.round(from.height + (to.height - from.height) * eased)),
}; };
this.win.setBounds(bounds); this.win.setBounds(bounds);
if (progress < 1) setTimeout(step, 12); if (progress < 1) setTimeout(step, 12);
@@ -478,6 +528,14 @@ class IslandController {
} }
} }
/** 回弹缓动(easeOutBack):进度超过 1 形成轻微过冲,营造弹簧感 */
function easeOutBack(progress: number): number {
const c1 = 1.15;
const c3 = c1 + 1;
const p = progress - 1;
return 1 + c3 * p * p * p + c1 * p * p;
}
export const island = new IslandController(); export const island = new IslandController();
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */ /** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
+129 -21
View File
@@ -29,8 +29,10 @@ interface IslandShotStats {
opaque: number; opaque: number;
transparent: number; transparent: number;
uniqueColors: number; uniqueColors: number;
/** 偏红像素数(紧急形态的红色光晕/角标) */ /** 偏红像素数(紧急形态的红色描边/角标/内部光晕) */
reddish: number; reddish: number;
/** 图像最外圈(2px)的偏红像素数:必须为 0,用于守住"卡片外不允许有光晕外溢" */
edgeReddish: number;
filePath: string; filePath: string;
} }
@@ -42,12 +44,15 @@ function analyzeBitmap(image: NativeImage): {
transparent: number; transparent: number;
uniqueColors: number; uniqueColors: number;
reddish: number; reddish: number;
edgeReddish: number;
} { } {
const bitmap = image.toBitmap(); const bitmap = image.toBitmap();
const { width, height } = image.getSize();
const total = Math.floor(bitmap.length / 4); const total = Math.floor(bitmap.length / 4);
let opaque = 0; let opaque = 0;
let transparent = 0; let transparent = 0;
let reddish = 0; let reddish = 0;
let edgeReddish = 0;
const colors = new Set<number>(); const colors = new Set<number>();
for (let index = 0; index < total; index += 1) { for (let index = 0; index < total; index += 1) {
@@ -63,11 +68,18 @@ function analyzeBitmap(image: NativeImage): {
} }
opaque += 1; opaque += 1;
if (colors.size < 4096) colors.add((red << 16) | (green << 8) | blue); if (colors.size < 4096) colors.add((red << 16) | (green << 8) | blue);
// 红色占优:紧急态的光晕、角标、深色卡片上的暖色高光
if (red > 110 && red > green + 40 && red > blue + 40) reddish += 1; // 红色占优:紧急态的描边、角标、内部光晕
if (red > 110 && red > green + 40 && red > blue + 40) {
reddish += 1;
// 最外圈 2px 是窗口留白(卡片之外),不允许出现红色外溢光晕
const x = index % width;
const y = Math.floor(index / width);
if (x < 2 || y < 2 || x >= width - 2 || y >= height - 2) edgeReddish += 1;
}
} }
return { opaque, transparent, uniqueColors: colors.size, reddish }; return { opaque, transparent, uniqueColors: colors.size, reddish, edgeReddish };
} }
/** 灵动岛状态截图留档 + 像素统计(便于人工复核外观与自动化断言) */ /** 灵动岛状态截图留档 + 像素统计(便于人工复核外观与自动化断言) */
@@ -104,7 +116,7 @@ async function captureIsland(
islandShots.push(stats); islandShots.push(stats);
console.log( console.log(
`[SMOKE] 灵动岛截图:${filePath}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` + `[SMOKE] 灵动岛截图:${filePath}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` +
`颜色=${stats.uniqueColors} 偏红=${stats.reddish})`, `颜色=${stats.uniqueColors} 偏红=${stats.reddish} 外圈偏红=${stats.edgeReddish})`,
); );
return stats; return stats;
} catch (error) { } catch (error) {
@@ -153,10 +165,12 @@ function makeNotification(
/** /**
* 灵动岛行为验证(对应产品需求): * 灵动岛行为验证(对应产品需求):
* 1. 上课时间段收到普通通知 → 自动隐藏并暂存 * 1. 上课时间段收到普通通知 → 不显示灵动岛(窗口真正隐藏)并暂存
* 2. 下课后 → 自动在桌面中上方弹出详情 * 2. 下课后 → 自动在桌面中上方弹出详情
* 3. 上课时间段收到紧急通知 → 立即展开显示详情(无需点击) * 3. 上课时间段收到紧急通知 → 立即展开显示详情(无需点击),且带展开动画
* 4. 非上课时段收到普通通知 → 变成"新消息"胶囊,点击后展开 * 4. 非上课时段收到普通通知 → 直接显示"新消息"胶囊(无入场动画),点击后展开
* 5. 点击卡片空白处 / 右上角收起按钮 → 回缩为胶囊
* 6. 上课时间段内点击胶囊不会展开(上课不显示灵动岛)
*/ */
async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promise<void> { async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promise<void> {
record('灵动岛窗口已创建(置顶/透明/不占任务栏)', island.isReady(), `ready=${island.isReady()}`); record('灵动岛窗口已创建(置顶/透明/不占任务栏)', island.isReady(), `ready=${island.isReady()}`);
@@ -164,20 +178,31 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
const islandWindow = island.getWindow(); const islandWindow = island.getWindow();
// 1) 上课期间:普通通知必须保持隐藏并进入队列 // 1) 上课期间:普通通知必须完全不显示(窗口隐藏)并进入队列
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 }); island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
island.pushNotification(makeNotification('smoke-normal-in-class', 'NORMAL', '上课期间的普通通知'), { island.pushNotification(makeNotification('smoke-normal-in-class', 'NORMAL', '上课期间的普通通知'), {
inClass: true, inClass: true,
}); });
await sleep(500); await sleep(500);
const hiddenState = island.getState(); const hiddenState = island.getState();
const hiddenVisible = islandWindow?.isVisible() ?? true;
record( record(
'上课期间普通通知自动隐藏(暂存待下课弹出)', '上课期间普通通知自动隐藏(窗口隐藏 + 暂存待下课弹出)',
hiddenState.mode === 'hidden' && hiddenState.queued.length === 1, hiddenState.mode === 'hidden' && hiddenState.queued.length === 1 && hiddenVisible === false,
`mode=${hiddenState.mode} queued=${hiddenState.queued.length}`, `mode=${hiddenState.mode} queued=${hiddenState.queued.length} visible=${hiddenVisible}`,
); );
console.log(`[SMOKE] 隐藏态 DOM:${await dumpIslandDom('hidden')}`); console.log(`[SMOKE] 隐藏态 DOM:${await dumpIslandDom('hidden')}`);
// 1.1) 上课期间请求展开也不应显示(上课不显示灵动岛)
island.handleAction({ action: 'expand' });
await sleep(400);
const expandBlocked = island.getState().mode === 'hidden' && !(islandWindow?.isVisible() ?? true);
record(
'上课期间点击不会展开灵动岛(严格不显示)',
expandBlocked,
`mode=${island.getState().mode} visible=${islandWindow?.isVisible() ?? true}`,
);
// 2) 下课:自动弹出暂存通知的详情 // 2) 下课:自动弹出暂存通知的详情
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 }); island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(900); await sleep(900);
@@ -189,13 +214,19 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
); );
await captureIsland('island-2-after-class', ISLAND_SIZES.expanded); await captureIsland('island-2-after-class', ISLAND_SIZES.expanded);
// 3) 上课期间紧急通知:立即展开,无需点击 // 3) 上课期间紧急通知:立即展开,无需点击;并采样窗口尺寸证明有"展开动画"
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 }); island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
await sleep(300); await sleep(300);
const sampledWidths: number[] = [];
const sampler = setInterval(() => {
const bounds = islandWindow?.getBounds();
if (bounds) sampledWidths.push(bounds.width);
}, 16);
island.pushNotification(makeNotification('smoke-urgent-in-class', 'URGENT', '上课期间的紧急通知'), { island.pushNotification(makeNotification('smoke-urgent-in-class', 'URGENT', '上课期间的紧急通知'), {
inClass: true, inClass: true,
}); });
await sleep(900); await sleep(900);
clearInterval(sampler);
const urgentState = island.getState(); const urgentState = island.getState();
record( record(
'上课期间紧急通知立即展开显示详情(无需点击)', '上课期间紧急通知立即展开显示详情(无需点击)',
@@ -204,17 +235,49 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
urgentState.reason === 'urgent', urgentState.reason === 'urgent',
`mode=${urgentState.mode} active=${urgentState.active?.id ?? '-'} reason=${urgentState.reason ?? '-'}`, `mode=${urgentState.mode} active=${urgentState.active?.id ?? '-'} reason=${urgentState.reason ?? '-'}`,
); );
const urgentBounds = islandWindow?.getBounds();
const rampWidths = [
...new Set(sampledWidths.filter((width) => width > 0 && width < ISLAND_URGENT_SIZE.width)),
];
record(
'紧急通知带展开动画(窗口从胶囊尺寸缓动到紧急尺寸)',
rampWidths.length >= 3 && urgentBounds?.width === ISLAND_URGENT_SIZE.width,
`采样=${sampledWidths.length} 中间尺寸=${rampWidths.length} 最终=${urgentBounds?.width ?? '-'}x${urgentBounds?.height ?? '-'}`,
);
await captureIsland('island-3-urgent', ISLAND_URGENT_SIZE); await captureIsland('island-3-urgent', ISLAND_URGENT_SIZE);
// 4) 非上课时段普通通知 → 胶囊态;点击后展开 // 3.1) 上课期间请求"收起"也不应显示胶囊(上课严格不显示灵动岛)
island.handleAction({ action: 'dismiss' }); island.handleAction({ action: 'collapse' });
await sleep(400);
record(
'上课期间收起不会回缩成胶囊(严格不显示)',
island.getState().mode === 'hidden' && !(islandWindow?.isVisible() ?? true),
`mode=${island.getState().mode} visible=${islandWindow?.isVisible() ?? true}`,
);
// 4) 非上课时段普通通知 → 直接显示胶囊(无入场动画);点击后展开
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 }); island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(300); await sleep(800);
island.hide();
await sleep(400);
const hiddenBeforePush = !(islandWindow?.isVisible() ?? true);
island.pushNotification(makeNotification('smoke-normal-free', 'NORMAL', '课间收到的普通通知'), { island.pushNotification(makeNotification('smoke-normal-free', 'NORMAL', '课间收到的普通通知'), {
inClass: false, inClass: false,
}); });
await sleep(700); await sleep(60);
const immediateBounds = islandWindow?.getBounds();
await sleep(640);
const pillState = island.getState(); const pillState = island.getState();
record(
'普通通知直接显示胶囊(无"上岛"入场动画)',
hiddenBeforePush &&
pillState.mode === 'pill' &&
immediateBounds?.width === ISLAND_SIZES.pill.width &&
immediateBounds?.height === ISLAND_SIZES.pill.height,
`推送前隐藏=${hiddenBeforePush} 60ms 内窗口=${immediateBounds?.width ?? '-'}x${immediateBounds?.height ?? '-'}` +
`(期望 ${ISLAND_SIZES.pill.width}x${ISLAND_SIZES.pill.height})mode=${pillState.mode}`,
);
await captureIsland('island-4-pill', ISLAND_SIZES.pill); await captureIsland('island-4-pill', ISLAND_SIZES.pill);
console.log(`[SMOKE] 胶囊态 DOM:${await dumpIslandDom('pill')}`); console.log(`[SMOKE] 胶囊态 DOM:${await dumpIslandDom('pill')}`);
@@ -235,7 +298,7 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
); );
await captureIsland('island-5-clicked', ISLAND_SIZES.expanded); await captureIsland('island-5-clicked', ISLAND_SIZES.expanded);
// 5) 灵动岛 DOM 结构自检(确保渲染进程真的画出卡片而不是空白窗口) // 4.1) 展开态的 DOM 结构自检(卡片/标题/操作按钮都要真的渲染出来)
const domInfo = await islandWindow?.webContents.executeJavaScript( const domInfo = await islandWindow?.webContents.executeJavaScript(
`(() => ({ `(() => ({
hasCard: Boolean(document.querySelector('.island-card')), hasCard: Boolean(document.querySelector('.island-card')),
@@ -249,8 +312,44 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
`title=${domInfo?.title ?? '-'} actions=${domInfo?.hasActions ?? false}`, `title=${domInfo?.title ?? '-'} actions=${domInfo?.hasActions ?? false}`,
); );
// 6) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与 // 4.2) 点击卡片空白处 → 回缩为胶囊
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色光晕像素。 const blankClicked = await islandWindow?.webContents.executeJavaScript(
`(() => {
const blank = document.querySelector('.island-card.expanded .body');
if (!blank) return false;
blank.click();
return true;
})()`,
);
await sleep(600);
const afterBlank = island.getState();
record(
'点击卡片空白处回缩为胶囊',
blankClicked === true && afterBlank.mode === 'pill',
`blankClicked=${blankClicked} mode=${afterBlank.mode}`,
);
// 4.3) 点击右上角收起按钮 → 回缩为胶囊
island.handleAction({ action: 'expand' });
await sleep(600);
const chevronClicked = await islandWindow?.webContents.executeJavaScript(
`(() => {
const button = document.querySelector('.island-card.expanded .icon-btn');
if (!button) return false;
button.click();
return true;
})()`,
);
await sleep(600);
const afterChevron = island.getState();
record(
'点击右上角收起按钮回缩为胶囊',
chevronClicked === true && afterChevron.mode === 'pill',
`chevronClicked=${chevronClicked} mode=${afterChevron.mode}`,
);
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
const shotDetails = islandShots.map((shot) => { const shotDetails = islandShots.map((shot) => {
const expectedRatio = shot.expected.width / shot.expected.height; const expectedRatio = shot.expected.width / shot.expected.height;
const actualRatio = shot.width / shot.height; const actualRatio = shot.width / shot.height;
@@ -260,7 +359,8 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
ratioDelta, ratioDelta,
text: text:
`${shot.name} ${shot.width}x${shot.height}(期望 ${shot.expected.width}x${shot.expected.height}, ` + `${shot.name} ${shot.width}x${shot.height}(期望 ${shot.expected.width}x${shot.expected.height}, ` +
`比例偏差 ${ratioDelta.toFixed(3)}) 不透明=${shot.opaque} 颜色=${shot.uniqueColors} 偏红=${shot.reddish}`, `比例偏差 ${ratioDelta.toFixed(3)}) 不透明=${shot.opaque} 颜色=${shot.uniqueColors} ` +
`偏红=${shot.reddish} 外圈偏红=${shot.edgeReddish}`,
}; };
}); });
@@ -268,6 +368,7 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
const wrongRatio = shotDetails.filter((shot) => shot.ratioDelta > 0.05); const wrongRatio = shotDetails.filter((shot) => shot.ratioDelta > 0.05);
const urgentShot = shotDetails.find((shot) => shot.name.includes('urgent')); const urgentShot = shotDetails.find((shot) => shot.name.includes('urgent'));
const urgentReddish = urgentShot?.reddish ?? 0; const urgentReddish = urgentShot?.reddish ?? 0;
const edgeReddish = urgentShot?.edgeReddish ?? 0;
const pillShot = shotDetails.find((shot) => shot.name.includes('pill')); const pillShot = shotDetails.find((shot) => shot.name.includes('pill'));
const sizesOrdered = Boolean( const sizesOrdered = Boolean(
pillShot && pillShot &&
@@ -289,6 +390,13 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
` 形态尺寸递增=${sizesOrdered}`, ` 形态尺寸递增=${sizesOrdered}`,
); );
// 7) 光晕只在卡片内部:截图最外圈(窗口留白)不允许出现红色外溢像素
record(
'紧急红晕不外溢(窗口边缘无红色光晕像素)',
urgentShot !== undefined && edgeReddish === 0 && urgentReddish > 200,
`紧急态偏红=${urgentReddish} 最外圈偏红=${edgeReddish}(要求 0)`,
);
// 收尾:隐藏灵动岛,避免影响后续用例 // 收尾:隐藏灵动岛,避免影响后续用例
island.handleAction({ action: 'dismiss' }); island.handleAction({ action: 'dismiss' });
await sleep(300); await sleep(300);