diff --git a/README.md b/README.md index cc9ff9f..faa2c87 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,11 @@ | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | -| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 11/11) | +| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 18/18) | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) | +| 8 | 客户端灵动岛(通知浮窗,上课隐藏 / 下课弹出 / 紧急立即展开)与上课时段紧急通知二次确认 | ✅ 已完成(见"上课时段策略") | > **与原始提示词的两处偏差(已与你确认)** > @@ -59,8 +60,9 @@ class-helper/ │ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手 │ ├── generate-icons.mjs # 用 Electron 渲染 SVG 生成 PNG/ICO 图标 │ ├── dist-server.mjs # 服务端 + Web 管理端 打包(免安装目录 + NSIS 安装程序) +│ ├── lib/electron-env.mjs # 启动 Electron 的公共处理(清理 RUN_AS_NODE + 定位可执行文件) │ ├── nsis/server-installer.nsi# 安装程序脚本模板 -│ └── ui-smoke/ # Web 管理端 UI 真实点击回归测试(Electron 驱动) +│ └── ui-smoke/ # Web 管理端 UI 真实点击回归测试(Electron 驱动 + 上课时段探针) ├── deploy/ # 生产部署:Dockerfile / docker-compose.yml / nginx.conf ├── docs/ │ ├── production.md # 生产部署指南(三种形态 + 运维 + 安全清单) @@ -73,7 +75,7 @@ class-helper/ │ ├── prisma/migrations/ # 迁移历史(已生成并应用) │ ├── prisma/seed.ts # 种子数据 │ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串) - │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(54 项) + │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(62 项) │ └── src/ │ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载) │ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出) @@ -91,8 +93,9 @@ class-helper/ ├── vite.config.mts # 渲染进程构建(base: './',hash 路由) ├── scripts/{build-main,dev,smoke,dist-win}.mjs └── src/ - ├── main/ # 主进程:窗口/单实例/配置持久化(IPC)/冒烟验证 + ├── main/ # 主进程:窗口/单实例/配置持久化(IPC)/冒烟验证/灵动岛 ├── preload/ # contextBridge 安全桥(不暴露 ipcRenderer 本体) + ├── island/ # 灵动岛渲染进程(独立透明置顶窗口) ├── types/desktop.d.ts # 主进程 <-> 渲染进程契约 └── renderer/ # 渲染进程:api / stores / cache / router / layouts / views ``` @@ -168,13 +171,15 @@ pnpm verify:desktop ```bash # 另开一个终端先启动后端:pnpm dev:server -pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC:54 项 -pnpm verify:desktop # Electron 客户端(含联网集成):9 项 +pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截:62 项 +pnpm verify:web # Web 管理端真实点击(含上课时段紧急通知全屏警告):8 项 +pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成):18 项 ``` -后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离**等 54 项,实测通知 37ms、作业 26ms、 -成绩 25ms 到达(要求 < 5 秒);客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、 -断网回退与联网集成(登录 + 四类数据 + Socket.IO),实测 9/9 通过。 +后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项, +实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; +客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 +(登录 + 四类数据 + Socket.IO),实测 18/18 通过。 ## 默认账号与种子数据 @@ -209,9 +214,9 @@ pnpm verify:desktop # Electron 客户端(含联网集成):9 项 | `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | | `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | | `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | -| `pnpm verify:e2e` | 后端端到端验收(54 项,需服务端已启动) | -| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,7 项) | -| `pnpm verify:desktop` | EXE 客户端冒烟验证(11 项,含侧边栏点击与离线回退) | +| `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) | +| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,8 项) | +| `pnpm verify:desktop` | EXE 客户端冒烟验证(18 项,含灵动岛、侧边栏点击与离线回退) | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | | `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | @@ -246,6 +251,29 @@ pnpm verify:desktop # Electron 客户端(含联网集成):9 项 | 成绩 | 表格(分数/得分率进度条/等级)+ ECharts 柱状图(各次考试平均得分率)+ 等级分布 + 最近更新 | | 设置 | 服务器地址(保存并测试)、账号信息、离线缓存条目统计与清空、客户端版本信息、退出登录 | +> 通知到达时还会由**灵动岛**在桌面中上方浮出提醒(见上一节),上课时段自动隐藏、紧急通知立即展开。 + +### 灵动岛(桌面通知浮窗) + +客户端有一个独立于主窗口的**灵动岛**:一个无边框、透明、置顶、不占任务栏的窄条窗口, +常驻桌面**中上方**(屏幕工作区顶部居中,`y = workArea.y + 8`)。它是"消息到达"的第一现场: + +| 场景 | 灵动岛行为 | +| -------------------------- | ------------------------------------------------------------------------------------------------------ | +| 收到通知(非上课时段) | 从隐藏态淡入为**胶囊**:`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 | +| 点击胶囊 | 展开为**详情卡**:标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 | +| 上课时间段收到普通通知 | **自动隐藏**(不打扰课堂),进入待发队列;下课后自动在桌面中上方弹出详情(14 秒)→ 收起为胶囊(25 秒) | +| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 | +| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" | +| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) | + +- 动画:窗口尺寸用 `easeOutCubic` 260ms 逐帧缓动(胶囊 ↔ 详情 形变),透明度单独淡入淡出; + 两套动画使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。 +- 事件流:`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`、 + `src/renderer/island/bridge.ts`(上课状态轮询与通知转发)。 + ### 离线缓存与自动同步 ``` @@ -285,62 +313,98 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS `pnpm verify:desktop` 以 `ELECTRON_SMOKE_TEST=1` 启动应用(不弹窗),主进程依次校验后自动退出: -| 校验项 | 实测结果 | -| ------------------------------------- | ----------------------------------------------------------------------------------- | -| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` | -| 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 | -| preload contextBridge 注入 | ✅ 6 个方法 | -| IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 | -| 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` | -| IndexedDB 缓存读写 | ✅ 读写/统计/删除 + 6 个 store | -| 断网时回退本地缓存 | ✅ `fromCache=true`(请求不可达端口) | -| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 5 作业 / 4 通知 / 6 成绩 / 第29周29节课 / 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` | +| 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` | +| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` | +| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) | +| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 6 作业 / 9 通知 / 8 成绩 / 第1周30节课 / Socket.IO 已连接 | 该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=` 写出 JSON 结果, -已实测 `packaged: true`、9/9 通过、退出码 0)。 +已实测 `packaged: true`、18/18 通过、退出码 0)。灵动岛各状态的窗口截图会写到 +`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`),便于人工确认视觉效果。 + +## 上课时段策略(紧急通知二次确认) + +"上课时段"由课表实时判定:`GET /api/schedules/current?classId=` 返回 +`{ inClass, current, next, week, serverTime }`(`at` 参数可覆盖判定时刻,便于联调与测试)。 + +**服务端强制拦截**(不是只靠前端提醒): + +``` +POST /api/notifications { classId, title, content, priority: "URGENT" } → 409 URGENT_DURING_CLASS +POST /api/notifications { …, priority: "URGENT", confirmDuringClass: true } → 201 +POST /api/notifications { …, priority: "NORMAL" }(上课时段) → 201(不受限) +``` + +409 响应体携带 `details: { classId, current, next, week, serverTime }`,前端据此渲染"正在上的课"。 +这样即使有人绕过界面直接调接口,也一定会被拦下来。 + +**Web 管理端**:教师在通知发布弹窗选择"紧急"并提交时,先查询班级上课状态,命中则弹出**全屏二次确认** +(`packages/web-admin/src/components/UrgentClassWarning.vue`): + +- 全屏遮罩 + 强警示配色,文案"现在为上课时间段……" + 正在上的课时段 + 待发布标题; +- 确认按钮带 **3 秒倒计时**(圆环进度 + 每秒钟数提示),倒计时结束前不可点击,防止误触; +- 取消则不发;确认后带 `confirmDuringClass: true` 重新提交并成功发布; +- 兜底:并发场景(提交瞬间刚好打铃)仍会收到 409,此时同样弹出该警告。 + +**EXE 客户端**:上课时段非紧急通知自动隐藏、下课后自动弹出;紧急通知无论是否上课都立即展开(见"灵动岛"一节)。 + +回归测试:`verify:e2e` 覆盖 409 拦截 / 二次确认后 201 / 普通通知不受限(用 `at` 固定时刻,结果可复现); +`verify:web` 用真实点击构造"正在上课"场景并断言全屏警告文案、按钮禁用与倒计时时长(实测 ~2.96s)。 ## REST API 一览 统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。 所有接口前缀 `/api`,除登录外均需 `Authorization: Bearer `。 -| 方法 | 路径 | 权限 | 说明 | -| --------------------------- | -------------------------------------------------------- | --------------- | ---------------------------------------------------------- | -| POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 | -| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) | -| PATCH | `/auth/password` | 登录 | 修改自己的密码 | -| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | -| GET | `/classes` | 登录 | 班级列表(按权限收敛) | -| GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) | -| POST / PATCH / DELETE | `/classes` `/classes/:id` | 教师/管理员 | 班级增删改 | -| GET / POST | `/classes/:id/students` | 班级可见 / 可写 | 学生名单 / 添加学生(已存在账号直接转入) | -| DELETE | `/classes/:id/students/:userId` | 教师/管理员 | 移出学生 | -| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | 教师/管理员 | 分配 / 取消协作教师 | -| GET / POST / PATCH / DELETE | `/courses` | 登录 / 教师 | 课程管理 | -| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(week 过滤周次范围) | -| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) | -| POST / PATCH / DELETE | `/schedules` | 教师/管理员 | 课表增删改(广播 `schedule:updated`) | -| GET | `/homeworks?classId=&courseId=&pendingOnly=&keyword=` | 登录 | 作业列表(学生带完成状态,教师带完成人数) | -| GET | `/homeworks/:id` | 班级可见 | 作业详情 | -| POST / PATCH / DELETE | `/homeworks` | 教师/管理员 | 发布/修改/删除(广播 `homework:new` / `homework:updated`) | -| PATCH | `/homeworks/:id/status` | 登录 | 标记完成/取消(广播 `homework:status`) | -| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) | -| GET | `/notifications/unread-count` | 登录 | 未读数(红点) | -| POST | `/notifications` | 教师/管理员 | 发布通知(广播 `notification:new`) | -| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读 | -| DELETE | `/notifications/:id` | 教师/管理员 | 删除通知 | -| GET | `/grades/my` | 登录 | 个人成绩 | -| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师/管理员 | 班级成绩 | -| GET | `/grades/stats?classId=&courseId=&examName=` | 教师/管理员 | 等级分布 + 各课程平均得分率 | -| POST | `/grades`、`/grades/bulk` | 教师/管理员 | 单条 / 批量录入(广播 `grade:updated`) | -| PATCH / DELETE | `/grades/:id` | 教师/管理员 | 修改 / 删除成绩 | -| GET | `/students?classId=&keyword=` | 教师/管理员 | 学生名单 | -| POST / PATCH / DELETE | `/students` | 教师/管理员 | 学生账号增删改 | -| POST | `/students/:id/reset-password` | 教师/管理员 | 重置密码 | -| GET | `/teachers?keyword=` | 教师/管理员 | 教师列表(分配协作教师用) | -| POST | `/teachers` | 管理员 | 新建教师账号 | -| GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 | -| GET | `/health` | 公开 | 健康检查(含已挂载模块列表) | +| 方法 | 路径 | 权限 | 说明 | +| --------------------------- | -------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------- | +| POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 | +| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) | +| PATCH | `/auth/password` | 登录 | 修改自己的密码 | +| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | +| GET | `/classes` | 登录 | 班级列表(按权限收敛) | +| GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) | +| POST / PATCH / DELETE | `/classes` `/classes/:id` | 教师/管理员 | 班级增删改 | +| GET / POST | `/classes/:id/students` | 班级可见 / 可写 | 学生名单 / 添加学生(已存在账号直接转入) | +| DELETE | `/classes/:id/students/:userId` | 教师/管理员 | 移出学生 | +| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | 教师/管理员 | 分配 / 取消协作教师 | +| GET / POST / PATCH / DELETE | `/courses` | 登录 / 教师 | 课程管理 | +| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(week 过滤周次范围) | +| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) | +| GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) | +| POST / PATCH / DELETE | `/schedules` | 教师/管理员 | 课表增删改(广播 `schedule:updated`) | +| GET | `/homeworks?classId=&courseId=&pendingOnly=&keyword=` | 登录 | 作业列表(学生带完成状态,教师带完成人数) | +| GET | `/homeworks/:id` | 班级可见 | 作业详情 | +| POST / PATCH / DELETE | `/homeworks` | 教师/管理员 | 发布/修改/删除(广播 `homework:new` / `homework:updated`) | +| PATCH | `/homeworks/:id/status` | 登录 | 标记完成/取消(广播 `homework:status`) | +| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) | +| GET | `/notifications/unread-count` | 登录 | 未读数(红点) | +| POST | `/notifications` | 教师/管理员 | 发布通知(广播 `notification:new`);上课时段发布紧急通知需 `confirmDuringClass` | +| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读 | +| DELETE | `/notifications/:id` | 教师/管理员 | 删除通知 | +| GET | `/grades/my` | 登录 | 个人成绩 | +| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师/管理员 | 班级成绩 | +| GET | `/grades/stats?classId=&courseId=&examName=` | 教师/管理员 | 等级分布 + 各课程平均得分率 | +| POST | `/grades`、`/grades/bulk` | 教师/管理员 | 单条 / 批量录入(广播 `grade:updated`) | +| PATCH / DELETE | `/grades/:id` | 教师/管理员 | 修改 / 删除成绩 | +| GET | `/students?classId=&keyword=` | 教师/管理员 | 学生名单 | +| POST / PATCH / DELETE | `/students` | 教师/管理员 | 学生账号增删改 | +| POST | `/students/:id/reset-password` | 教师/管理员 | 重置密码 | +| GET | `/teachers?keyword=` | 教师/管理员 | 教师列表(分配协作教师用) | +| POST | `/teachers` | 管理员 | 新建教师账号 | +| GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 | +| GET | `/health` | 公开 | 健康检查(含已挂载模块列表) | ## WebSocket 事件 @@ -422,36 +486,38 @@ pnpm db:migrate && pnpm db:seed ## 验收标准对照 -| 验收项 | 结果 | 证据 | -| --------------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | -| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** | -| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | -| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 | -| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` | -| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 | -| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | -| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe`(106.9MB) / `-portable.exe`(106.7MB) / `win-unpacked/*.exe` | -| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | +| 验收项 | 结果 | 证据 | +| ------------------------------------------------ | ---- | -------------------------------------------------------------------------------------------- | +| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** | +| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | +| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 | +| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` | +| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 | +| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | +| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 | +| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 四种状态断言 + 截图(`.cache/island-shots/`) | +| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) | +| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | 打包产物验证(对最终 EXE 实测,非仅开发产物): -| 产物 | 大小 | 冒烟结果 | -| -------------------------------------- | ------- | ------------------------------------------------------------------------------------ | -| 服务端安装程序(内置 Node + Web 端) | 33.9MB | ✅ 静默安装 10s → 自动建库建号 → 服务 34s 就绪 → **对安装实例跑 54/54 e2e + 7/7 UI** | -| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 11/11,`packaged: true`,退出码 0 | -| `release/…-x64-portable.exe`(单文件) | 106.9MB | ✅ 9/9(含联网集成:登录王小明 / Socket.IO 已连接 / 四类数据) | -| `release/…-x64-setup.exe`(客户端) | 107.2MB | ✅ 构建成功,已嵌入自定义图标 | +| 产物 | 大小 | 冒烟结果 | +| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------ | +| 服务端安装程序(内置 Node + Web 端) | 34MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 → **对安装实例跑 62/62 e2e + 8/8 UI** | +| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 18/18,`packaged: true`,退出码 0 | +| `release/…-x64-portable.exe`(单文件) | ~107MB | ✅(含联网集成:登录王小明 / Socket.IO 已连接 / 四类数据) | +| `release/…-x64-setup.exe`(客户端) | ~107MB | ✅ 构建成功,已嵌入自定义图标;包含灵动岛 | 当前实测: -| 验证 | 结果 | -| ----------------------------------------------------------- | ----------------------------------- | -| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **54/54** ✅ | -| `pnpm verify:web`(Web 管理端真实点击 + PWA) | **7/7** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + 侧边栏点击 + 离线回退) | **11/11** ✅ | -| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | -| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | -| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | +| 验证 | 结果 | +| ------------------------------------------------------------------------ | ----------------------------------- | +| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **62/62** ✅ | +| `pnpm verify:web`(Web 管理端真实点击 + 上课时段紧急通知 + PWA) | **8/8** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + 灵动岛四态 + 侧边栏点击 + 离线回退) | **18/18** ✅ | +| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | +| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | +| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | ## 常见问题(本机环境已知坑) @@ -464,6 +530,21 @@ pnpm db:migrate && pnpm db:seed `node packages/web-admin/node_modules/vite/bin/vite.js build`。 在普通终端(非 Electron 宿主)中 `pnpm + + diff --git a/packages/desktop-client/scripts/build-main.mjs b/packages/desktop-client/scripts/build-main.mjs index e18e857..b40df21 100644 --- a/packages/desktop-client/scripts/build-main.mjs +++ b/packages/desktop-client/scripts/build-main.mjs @@ -34,4 +34,11 @@ await build({ outfile: path.join(root, 'dist/preload/index.js'), }); -console.log('[build:main] dist/main/index.js 与 dist/preload/index.js 构建完成'); +// 灵动岛窗口的独立 preload(只暴露状态订阅与操作转发) +await build({ + ...shared, + entryPoints: [path.join(root, 'src/preload/island.ts')], + outfile: path.join(root, 'dist/preload/island.js'), +}); + +console.log('[build:main] dist/main/index.js、dist/preload/index.js、dist/preload/island.js 构建完成'); diff --git a/packages/desktop-client/scripts/dev.mjs b/packages/desktop-client/scripts/dev.mjs index c6b92f4..cfb3c97 100644 --- a/packages/desktop-client/scripts/dev.mjs +++ b/packages/desktop-client/scripts/dev.mjs @@ -8,9 +8,10 @@ import { spawn } from 'node:child_process'; import net from 'node:net'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import electronPath from 'electron'; +import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const electronPath = resolveElectronExecutable(path.join(root, 'node_modules', 'electron')); const HOST = '127.0.0.1'; const PORT = 5174; const DEV_URL = `http://${HOST}:${PORT}`; @@ -53,7 +54,7 @@ function startElectron() { electron = spawn(electronPath, ['.'], { cwd: root, stdio: 'inherit', - env: { ...process.env, VITE_DEV_SERVER_URL: DEV_URL }, + env: resolveElectronEnv({ VITE_DEV_SERVER_URL: DEV_URL }), }); electron.on('exit', (code) => cleanup(code ?? 0)); } diff --git a/packages/desktop-client/scripts/dist-win.mjs b/packages/desktop-client/scripts/dist-win.mjs index 7bb31db..f0ae29c 100644 --- a/packages/desktop-client/scripts/dist-win.mjs +++ b/packages/desktop-client/scripts/dist-win.mjs @@ -16,6 +16,7 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveNodeRuntime } from '../../../scripts/lib/node-runtime.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = path.resolve(root, '..', '..'); @@ -52,7 +53,14 @@ if (!fs.existsSync(cli)) { const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough]; console.log('[dist] electron-builder', cliArgs.slice(1).join(' ')); -const child = spawn(process.execPath, cliArgs, { cwd: root, stdio: 'inherit', env }); +// 必须用真正的 node 执行 electron-builder CLI: +// 若宿主终端是 Electron(本机 pnpm 即如此),process.execPath 会指向 Electron, +// 直接 spawn 会让 Electron 把 cli.js 当成应用入口,多出一个位置参数, +// electron-builder 报 `Unknown argument: .../cli.js`。 +const runtime = resolveNodeRuntime(); +console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`); + +const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env }); child.on('exit', (code) => { if (code === 0) { diff --git a/packages/desktop-client/scripts/smoke.mjs b/packages/desktop-client/scripts/smoke.mjs index d8525b6..457ba76 100644 --- a/packages/desktop-client/scripts/smoke.mjs +++ b/packages/desktop-client/scripts/smoke.mjs @@ -9,9 +9,10 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import electronPath from 'electron'; +import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +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)); @@ -47,7 +48,7 @@ if (online === undefined) { const child = spawn(electronPath, ['.'], { cwd: root, stdio: 'inherit', - env: { ...process.env, ELECTRON_SMOKE_TEST: '1', ELECTRON_SMOKE_ONLINE: online }, + env: resolveElectronEnv({ ELECTRON_SMOKE_TEST: '1', ELECTRON_SMOKE_ONLINE: online }), }); child.on('exit', (code) => { diff --git a/packages/desktop-client/src/island/IslandApp.vue b/packages/desktop-client/src/island/IslandApp.vue new file mode 100644 index 0000000..cf1a366 --- /dev/null +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -0,0 +1,600 @@ + + + + + diff --git a/packages/desktop-client/src/island/main.ts b/packages/desktop-client/src/island/main.ts new file mode 100644 index 0000000..6f68988 --- /dev/null +++ b/packages/desktop-client/src/island/main.ts @@ -0,0 +1,15 @@ +import { createApp } from 'vue'; +import IslandApp from './IslandApp.vue'; + +/** + * 灵动岛渲染进程入口。 + * 刻意不引入 Element Plus 等 UI 库:这个窗口极小、常驻桌面, + * 保持零依赖可以让它在低配机器上也瞬间渲染。 + */ +const app = createApp(IslandApp); + +app.config.errorHandler = (error) => { + console.error('[island] 渲染异常', error); +}; + +app.mount('#app'); diff --git a/packages/desktop-client/src/main/index.ts b/packages/desktop-client/src/main/index.ts index 90a66c2..395be70 100644 --- a/packages/desktop-client/src/main/index.ts +++ b/packages/desktop-client/src/main/index.ts @@ -1,6 +1,8 @@ import path from 'node:path'; import { BrowserWindow, app, shell } from 'electron'; import { registerIpcHandlers } from './ipc.js'; +import { island, registerIslandIpc } from './island.js'; +import { logger } from './logger.js'; import { runSmokeTest } from './smoke.js'; // 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname @@ -75,10 +77,25 @@ if (!gotLock) { app.whenReady().then(async () => { registerIpcHandlers(); + registerIslandIpc(); mainWindow = createWindow(); await loadRenderer(mainWindow); + // 灵动岛:独立的置顶透明小窗口(桌面中上方) + try { + await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null); + // 点击灵动岛里的"打开应用"时把主窗口拉到前台 + island.setOpenAppHandler(() => { + if (!mainWindow || mainWindow.isDestroyed()) return; + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + }); + } catch (error) { + logger.error('灵动岛初始化失败(不影响主功能)', error); + } + if (isSmokeTest) { try { await runSmokeTest(mainWindow); @@ -90,9 +107,14 @@ if (!gotLock) { }); app.on('window-all-closed', () => { + island.destroy(); if (process.platform !== 'darwin') app.quit(); }); + app.on('before-quit', () => { + island.destroy(); + }); + app.on('activate', async () => { if (BrowserWindow.getAllWindows().length === 0) { mainWindow = createWindow(); diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts new file mode 100644 index 0000000..85752b8 --- /dev/null +++ b/packages/desktop-client/src/main/island.ts @@ -0,0 +1,506 @@ +import path from 'node:path'; +import { BrowserWindow, ipcMain, screen } from 'electron'; +import type { IslandMode, IslandNotification, IslandState } from '@classhelper/shared'; +import { logger } from './logger.js'; + +/** + * 灵动岛(Dynamic Island)主进程控制器。 + * + * 形态:一个独立的、无边框、透明、置顶、不占任务栏、不抢焦点的小窗口, + * 固定在主显示器工作区顶部居中("桌面中上方")。 + * + * 行为(对应产品需求): + * - 收到教师通知:变形为"新消息"胶囊;点击展开显示详情 + * - 上课时间段:非紧急通知不弹出(自动隐藏),进入待发队列 + * - 下课后:自动在桌面中上方弹出队列中的通知详情 + * - 紧急通知:无论是否在上课,立刻展开显示详情,无需点击 + */ + +const SIZES: Record = { + hidden: { width: 268, height: 44 }, + pill: { width: 268, height: 44 }, + expanded: { width: 404, height: 316 }, +}; + +const URGENT_SIZE = { width: 424, height: 344 }; + +/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */ +const TIMEOUTS = { + pill: 15_000, + afterClassExpanded: 14_000, + afterClassPill: 25_000, + expanded: 20_000, + urgent: 45_000, +}; + +export interface IslandPushContext { + inClass?: boolean; + currentPeriodEnd?: string | null; + week?: number; +} + +export interface IslandClassStatePayload { + inClass: boolean; + currentPeriodEnd?: string | null; + week?: number; +} + +type IslandActionName = 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app'; + +interface IslandActionPayload { + action: IslandActionName; + id?: string; +} + +class IslandController { + private win: BrowserWindow | null = null; + private rendererUrl: string | null = null; + private ready = false; + private pendingState: IslandState | null = null; + private collapseTimer: ReturnType | null = null; + /** 尺寸(形变)动画令牌:新动画会作废上一轮,避免互相打架 */ + private boundsToken = 0; + /** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */ + private fadeToken = 0; + + private state: IslandState = { + mode: 'hidden', + active: null, + queued: [], + inClass: false, + currentPeriodEnd: null, + reason: null, + updatedAt: Date.now(), + }; + + /** 灵动岛展开时点击"打开应用"交给外部处理 */ + private openAppHandler: (() => void) | null = null; + + setOpenAppHandler(handler: () => void): void { + this.openAppHandler = handler; + } + + async init(rendererUrl: string | null): Promise { + this.rendererUrl = rendererUrl; + if (this.win && !this.win.isDestroyed()) return; + + const win = new BrowserWindow({ + ...this.computeBounds(SIZES.hidden), + show: false, + frame: false, + transparent: true, + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + hasShadow: false, + // 不抢焦点:通知不应该打断学生正在做的事(键盘焦点仍留在原应用) + focusable: false, + alwaysOnTop: true, + type: 'toolbar', + webPreferences: { + preload: path.join(__dirname, '../preload/island.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + backgroundThrottling: false, + }, + }); + + win.setAlwaysOnTop(true, 'screen-saver'); + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + win.setIgnoreMouseEvents(false); + win.on('closed', () => { + this.win = null; + this.ready = false; + }); + + this.win = win; + + // 开发/冒烟时把灵动岛渲染进程的日志转发到主进程,便于排查"窗口空白"之类问题 + win.webContents.on('console-message', (...args) => { + const first = args[0]; + const isEventObject = first && typeof first === 'object' && 'message' in first; + const level = isEventObject ? first.level : args[1]; + const message = isEventObject ? first.message : args[2]; + const isProblem = level === 'error' || level === 'warning' || level === 2 || level === 3; + if (isProblem || process.env.ELECTRON_SMOKE_TEST === '1') { + logger.info(`[island-renderer] ${String(message)}`); + } + }); + + if (rendererUrl) { + await win.loadURL(rendererUrl); + } else { + await win.loadFile(path.join(__dirname, '../renderer/island.html')); + } + this.ready = true; + if (this.pendingState) { + this.emit(this.pendingState); + this.pendingState = null; + } + // 首次进入时同步一次当前状态,保证渲染进程不会停在初始默认值 + this.emit(); + logger.info('灵动岛已就绪(置顶透明窗口,顶部居中)'); + } + + getWindow(): BrowserWindow | null { + return this.win; + } + + getState(): IslandState { + return { ...this.state, queued: [...this.state.queued] }; + } + + isReady(): boolean { + return this.ready && Boolean(this.win) && !this.win?.isDestroyed(); + } + + /* ------------------------------------------------------------ 对外行为 */ + + /** + * 收到一条通知。 + * - 紧急:立刻展开(无视上课时段) + * - 上课中:进队列并保持隐藏(下课后由 setClassState 触发弹出) + * - 其它:显示"新消息"胶囊,等待点击展开 + */ + pushNotification(notification: IslandNotification, context: IslandPushContext = {}): void { + const inClass = context.inClass ?? this.state.inClass; + this.state.inClass = inClass; + if (context.currentPeriodEnd !== undefined) + this.state.currentPeriodEnd = context.currentPeriodEnd ?? null; + + if (notification.priority === 'URGENT') { + // 紧急插播:如果正在展示别的通知,把它放回队列 + if (this.state.active && this.state.active.id !== notification.id) { + this.state.queued.push(this.state.active); + } + this.state.queued = this.state.queued.filter((item) => item.id !== notification.id); + this.activate(notification, 'urgent', 'expanded'); + logger.info(`灵动岛紧急插播:${notification.title}`); + return; + } + + if (inClass) { + // 上课时段:自动隐藏,暂存待下课后弹出 + this.state.queued = [...this.state.queued.filter((item) => item.id !== notification.id), notification]; + if (this.state.mode !== 'hidden') { + this.setState({ mode: 'hidden', reason: null }); + } + this.state.updatedAt = Date.now(); + this.emit(); + logger.info(`灵动岛暂存通知(上课中,下课后弹出):${notification.title}`); + return; + } + + if (this.state.mode === 'expanded' && this.state.active) { + // 已经展开时,把当前这条挤入队列并显示新的一条 + this.state.queued.push(this.state.active); + } + this.state.queued = [...this.state.queued.filter((item) => item.id !== notification.id), notification]; + this.activate(notification, 'new', 'pill'); + } + + /** 上课状态变化:进入上课隐藏;下课则自动弹出队列 */ + setClassState(payload: IslandClassStatePayload): void { + const wasInClass = this.state.inClass; + this.state.inClass = payload.inClass; + this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null; + + if (payload.inClass) { + if (!wasInClass) logger.info('灵动岛:进入上课时间段,自动隐藏'); + if (this.state.mode !== 'hidden') { + // 上课期间自动隐藏(紧急通知也遵循,避免持续遮挡课堂投影) + this.setState({ mode: 'hidden', reason: null }); + } else { + this.state.updatedAt = Date.now(); + this.emit(); + } + return; + } + + if (wasInClass && !payload.inClass) { + logger.info('灵动岛:下课,弹出上课期间暂存的通知'); + this.flushQueueAfterClass(); + return; + } + + this.state.updatedAt = Date.now(); + this.emit(); + } + + /** 下课:弹出队列中最新的一条(展开显示详情),其余保留为"还有 N 条" */ + flushQueueAfterClass(): void { + const next = this.state.queued[this.state.queued.length - 1]; + if (!next) { + this.setState({ mode: 'hidden', reason: null }); + return; + } + const urgent = this.state.queued.find((item) => item.priority === 'URGENT'); + const target = urgent ?? next; + this.activate(target, 'after-class', 'expanded'); + } + + handleAction(payload: IslandActionPayload): void { + switch (payload.action) { + case 'expand': + if (this.state.active) { + this.setState({ mode: 'expanded' }); + this.scheduleCollapse( + this.state.active.priority === 'URGENT' ? TIMEOUTS.urgent : TIMEOUTS.expanded, + ); + } + break; + case 'collapse': { + const next = this.state.queued[this.state.queued.length - 1]; + if (next) { + this.setState({ mode: 'pill', reason: this.state.reason }); + this.scheduleCollapse(TIMEOUTS.pill); + } else { + this.hide(); + } + break; + } + case 'dismiss': + this.dismissActive(); + break; + case 'mark-read': { + const queue = this.state.queued.filter((item) => item.id !== payload.id); + this.state.queued = queue; + if (this.state.active?.id === payload.id) this.state.active = null; + this.state.updatedAt = Date.now(); + if (this.state.mode === 'hidden') this.emit(); + else this.dismissActive(); + break; + } + case 'open-app': + this.openAppHandler?.(); + break; + default: + break; + } + } + + hide(): void { + this.clearTimers(); + if (!this.win || this.win.isDestroyed()) return; + this.fadeOut(); + this.setState({ mode: 'hidden', reason: null }, { skipWindow: true }); + } + + destroy(): void { + this.clearTimers(); + if (this.win && !this.win.isDestroyed()) this.win.destroy(); + this.win = null; + } + + /* ------------------------------------------------------------ 内部实现 */ + + private dismissActive(): void { + this.clearTimers(); + if (this.state.active) { + this.state.queued = this.state.queued.filter((item) => item.id !== this.state.active?.id); + } + this.state.active = null; + + const next = this.state.queued[this.state.queued.length - 1]; + if (next) { + // 还有未看的通知:退成胶囊态提示"还有 N 条" + this.state.active = next; + this.setState({ mode: 'pill', reason: 'after-class' }); + this.scheduleCollapse(TIMEOUTS.afterClassPill); + return; + } + this.hide(); + } + + private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void { + this.clearTimers(); + // 正在展示的通知从队列中移出,queue 只保留"尚未看过"的 + this.state.queued = this.state.queued.filter((item) => item.id !== notification.id); + this.state.active = notification; + this.state.updatedAt = Date.now(); + this.setState({ mode, reason }); + if (notification.priority === 'URGENT') { + this.scheduleCollapse(TIMEOUTS.urgent); + } else if (reason === 'after-class') { + this.scheduleCollapse(TIMEOUTS.afterClassExpanded); + } else if (mode === 'pill') { + this.scheduleCollapse(TIMEOUTS.pill); + } else { + this.scheduleCollapse(TIMEOUTS.expanded); + } + } + + private scheduleCollapse(delayMs: number): void { + this.clearTimers(); + this.collapseTimer = setTimeout(() => { + this.collapseTimer = null; + if (this.state.mode === 'expanded') { + const hasMore = this.state.queued.length > 1; + if (hasMore) { + this.setState({ mode: 'pill' }); + this.scheduleCollapse(TIMEOUTS.afterClassPill); + } else { + this.hide(); + } + return; + } + this.hide(); + }, delayMs); + } + + private clearTimers(): void { + if (this.collapseTimer) { + clearTimeout(this.collapseTimer); + this.collapseTimer = null; + } + } + + private computeBounds(size: { width: number; height: number }): { + x: number; + y: number; + width: number; + height: number; + } { + const area = screen.getPrimaryDisplay().workArea; + return { + x: Math.round(area.x + (area.width - size.width) / 2), + y: area.y + 8, + width: size.width, + height: size.height, + }; + } + + /** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */ + private setState(patch: Partial, options: { skipWindow?: boolean } = {}): void { + this.state = { ...this.state, ...patch, updatedAt: Date.now() }; + this.emit(); + if (!options.skipWindow) this.syncWindow(); + } + + private emit(state?: IslandState): void { + const payload = state ?? this.getState(); + if (!this.win || this.win.isDestroyed()) { + this.pendingState = payload; + return; + } + if (!this.ready) { + this.pendingState = payload; + return; + } + this.win.webContents.send('island:state', payload); + } + + private syncWindow(): void { + if (!this.win || this.win.isDestroyed()) return; + + if (this.state.mode === 'hidden') { + this.fadeOut(); + return; + } + + const size = + this.state.mode === 'expanded' + ? this.state.active?.priority === 'URGENT' + ? URGENT_SIZE + : SIZES.expanded + : SIZES.pill; + + if (!this.win.isVisible()) { + this.win.setOpacity(0); + this.win.showInactive(); + this.animateBounds(size); + this.fadeIn(); + return; + } + this.animateBounds(size); + } + + private fadeIn(): void { + if (!this.win || this.win.isDestroyed()) return; + const token = ++this.fadeToken; + let opacity = this.win.getOpacity(); + const step = (): void => { + if (!this.win || this.win.isDestroyed() || token !== this.fadeToken) return; + opacity = Math.min(1, opacity + 0.16); + this.win.setOpacity(opacity); + if (opacity < 1) setTimeout(step, 16); + }; + step(); + } + + private fadeOut(): void { + if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) return; + const token = ++this.fadeToken; + let opacity = this.win.getOpacity(); + const step = (): void => { + if (!this.win || this.win.isDestroyed() || token !== this.fadeToken) return; + opacity = Math.max(0, opacity - 0.2); + this.win.setOpacity(opacity); + if (opacity > 0.02) { + setTimeout(step, 16); + return; + } + this.win.hide(); + this.win.setOpacity(1); + }; + step(); + } + + /** 尺寸/位置缓动:Windows 下没有原生窗口动画,这里按帧插值实现"形变"效果 */ + private animateBounds(size: { width: number; height: number }): void { + if (!this.win || this.win.isDestroyed()) return; + const from = this.win.getBounds(); + const to = this.computeBounds(size); + if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return; + + const token = ++this.boundsToken; + const duration = 260; + const startedAt = Date.now(); + + const step = (): void => { + if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return; + const progress = Math.min(1, (Date.now() - startedAt) / duration); + const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic + const bounds = { + x: Math.round(from.x + (to.x - from.x) * eased), + y: Math.round(from.y + (to.y - from.y) * eased), + width: Math.round(from.width + (to.width - from.width) * eased), + height: Math.round(from.height + (to.height - from.height) * eased), + }; + this.win.setBounds(bounds); + if (progress < 1) setTimeout(step, 12); + }; + step(); + } +} + +export const island = new IslandController(); + +/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */ +export function registerIslandIpc(): void { + ipcMain.on( + 'island:push', + (_event, payload: { notification: IslandNotification; context?: IslandPushContext }) => { + if (!payload?.notification) return; + island.pushNotification(payload.notification, payload.context ?? {}); + }, + ); + + ipcMain.on('island:class-state', (_event, payload: IslandClassStatePayload) => { + if (!payload) return; + island.setClassState(payload); + }); + + ipcMain.handle('island:get-state', () => island.getState()); + + ipcMain.on('island:action', (_event, payload: IslandActionPayload) => { + if (!payload?.action) return; + island.handleAction(payload); + }); +} + +export { SIZES as ISLAND_SIZES, URGENT_SIZE as ISLAND_URGENT_SIZE }; diff --git a/packages/desktop-client/src/main/logger.ts b/packages/desktop-client/src/main/logger.ts new file mode 100644 index 0000000..8882604 --- /dev/null +++ b/packages/desktop-client/src/main/logger.ts @@ -0,0 +1,25 @@ +/** + * 主进程日志(控制台输出)。 + * 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过: + * - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件 + * - 主窗口右上角的连接状态 / 灵动岛状态 + * 对外可见,避免"出了问题却看不到日志"。 + */ +function timestamp(): string { + return new Date().toISOString(); +} + +export const logger = { + debug(message: string, ...args: unknown[]): void { + console.log(`[${timestamp()}] DEBUG ${message}`, ...args); + }, + info(message: string, ...args: unknown[]): void { + console.log(`[${timestamp()}] INFO ${message}`, ...args); + }, + warn(message: string, ...args: unknown[]): void { + console.warn(`[${timestamp()}] WARN ${message}`, ...args); + }, + error(message: string, ...args: unknown[]): void { + console.error(`[${timestamp()}] ERROR ${message}`, ...args); + }, +}; diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index 82bcaf2..9119fff 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -2,7 +2,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { app } from 'electron'; import type { BrowserWindow } from 'electron'; +import type { IslandNotification } from '@classhelper/shared'; import { getDiagnostics } from './ipc.js'; +import { island } from './island.js'; interface SmokeResult { name: string; @@ -10,6 +12,177 @@ interface SmokeResult { detail: string; } +type Recorder = (name: string, ok: boolean, detail?: string) => void; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** 灵动岛状态截图留档(便于人工复核外观与动画) */ +async function captureIsland(name: string): Promise { + const dir = process.env.ISLAND_SHOTS_DIR; + const win = island.getWindow(); + if (!dir || !win || win.isDestroyed() || !win.isVisible()) return; + try { + // 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图 + await win.webContents.executeJavaScript( + `document.documentElement.style.background = '#1f2733'; document.body.style.background = '#1f2733'; true`, + ); + await sleep(120); + const image = await win.webContents.capturePage(); + await win.webContents.executeJavaScript( + `document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`, + ); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${name}.png`), image.toPNG()); + console.log(`[SMOKE] 灵动岛截图:${path.join(dir, `${name}.png`)}`); + } catch (error) { + console.warn('[SMOKE] 灵动岛截图失败', error); + } +} + +/** 打印灵动岛渲染进程的真实 DOM 结构(排查空白窗口) */ +async function dumpIslandDom(_label: string): Promise { + const win = island.getWindow(); + if (!win || win.isDestroyed()) return 'no-window'; + try { + return await win.webContents.executeJavaScript( + `(() => { + const app = document.querySelector('#app'); + const card = document.querySelector('.island-card'); + return JSON.stringify({ + appChildren: app ? app.children.length : -1, + cardClass: card ? card.className : null, + text: (document.body.innerText || '').replace(/\\s+/g, ' ').slice(0, 60), + }); + })()`, + ); + } catch (error) { + return `dump-error: ${(error as Error).message}`; + } +} + +function makeNotification( + id: string, + priority: IslandNotification['priority'], + title: string, +): IslandNotification { + return { + id, + title, + content: + '明天下午的数学课改为在实验楼 B205 上课,请同学们提前 10 分钟到达,并携带上次发的练习册。如有疑问请联系课代表。', + priority, + createdAt: new Date().toISOString(), + courseName: '数学', + teacherName: '张老师', + }; +} + +/** + * 灵动岛行为验证(对应产品需求): + * 1. 上课时间段收到普通通知 → 自动隐藏并暂存 + * 2. 下课后 → 自动在桌面中上方弹出详情 + * 3. 上课时间段收到紧急通知 → 立即展开显示详情(无需点击) + * 4. 非上课时段收到普通通知 → 变成"新消息"胶囊,点击后展开 + */ +async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promise { + record('灵动岛窗口已创建(置顶/透明/不占任务栏)', island.isReady(), `ready=${island.isReady()}`); + if (!island.isReady()) return; + + const islandWindow = island.getWindow(); + + // 1) 上课期间:普通通知必须保持隐藏并进入队列 + island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 }); + island.pushNotification(makeNotification('smoke-normal-in-class', 'NORMAL', '上课期间的普通通知'), { + inClass: true, + }); + await sleep(500); + const hiddenState = island.getState(); + record( + '上课期间普通通知自动隐藏(暂存待下课弹出)', + hiddenState.mode === 'hidden' && hiddenState.queued.length === 1, + `mode=${hiddenState.mode} queued=${hiddenState.queued.length}`, + ); + console.log(`[SMOKE] 隐藏态 DOM:${await dumpIslandDom('hidden')}`); + + // 2) 下课:自动弹出暂存通知的详情 + island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 }); + await sleep(900); + const afterClassState = island.getState(); + record( + '下课后自动弹出通知详情', + afterClassState.mode === 'expanded' && afterClassState.active?.id === 'smoke-normal-in-class', + `mode=${afterClassState.mode} active=${afterClassState.active?.id ?? '-'} reason=${afterClassState.reason ?? '-'}`, + ); + await captureIsland('island-2-after-class'); + + // 3) 上课期间紧急通知:立即展开,无需点击 + island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 }); + await sleep(300); + island.pushNotification(makeNotification('smoke-urgent-in-class', 'URGENT', '上课期间的紧急通知'), { + inClass: true, + }); + await sleep(900); + const urgentState = island.getState(); + record( + '上课期间紧急通知立即展开显示详情(无需点击)', + urgentState.mode === 'expanded' && + urgentState.active?.id === 'smoke-urgent-in-class' && + urgentState.reason === 'urgent', + `mode=${urgentState.mode} active=${urgentState.active?.id ?? '-'} reason=${urgentState.reason ?? '-'}`, + ); + await captureIsland('island-3-urgent'); + + // 4) 非上课时段普通通知 → 胶囊态;点击后展开 + island.handleAction({ action: 'dismiss' }); + island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 }); + await sleep(300); + island.pushNotification(makeNotification('smoke-normal-free', 'NORMAL', '课间收到的普通通知'), { + inClass: false, + }); + await sleep(700); + const pillState = island.getState(); + await captureIsland('island-4-pill'); + console.log(`[SMOKE] 胶囊态 DOM:${await dumpIslandDom('pill')}`); + + const clicked = await islandWindow?.webContents.executeJavaScript( + `(() => { + const pill = document.querySelector('.island-card.pill:not(.hidden-placeholder)'); + if (!pill) return false; + pill.click(); + return true; + })()`, + ); + await sleep(800); + const expandedState = island.getState(); + record( + '普通通知先变"新消息"胶囊,点击后展开详情', + pillState.mode === 'pill' && clicked === true && expandedState.mode === 'expanded', + `pillMode=${pillState.mode} clicked=${clicked} expandedMode=${expandedState.mode}`, + ); + await captureIsland('island-5-clicked'); + + // 5) 灵动岛 DOM 结构自检(确保渲染进程真的画出卡片而不是空白窗口) + const domInfo = await islandWindow?.webContents.executeJavaScript( + `(() => ({ + hasCard: Boolean(document.querySelector('.island-card')), + title: document.querySelector('.title')?.textContent ?? '', + hasActions: Boolean(document.querySelector('.solid-btn')), + }))()`, + ); + record( + '灵动岛渲染进程正常绘制(卡片/标题/操作按钮)', + Boolean(domInfo?.hasCard && domInfo?.title && domInfo?.hasActions), + `title=${domInfo?.title ?? '-'} actions=${domInfo?.hasActions ?? false}`, + ); + + // 收尾:隐藏灵动岛,避免影响后续用例 + island.handleAction({ action: 'dismiss' }); + await sleep(300); + results.push({ name: '灵动岛用例收尾', ok: true, detail: '已隐藏' }); +} + /** * Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。 * 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退, @@ -135,6 +308,9 @@ export async function runSmokeTest(win: BrowserWindow): Promise { ); record('断网时回退到本地缓存', Boolean(offline?.ok), String(offline?.detail ?? '')); + // ---------------------------------------------------------------- 灵动岛 + await runIslandChecks(record, results); + // 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用) if (process.env.ELECTRON_SMOKE_ONLINE === '1') { const online = await win.webContents.executeJavaScript( diff --git a/packages/desktop-client/src/preload/index.ts b/packages/desktop-client/src/preload/index.ts index f4f3c27..4fd581c 100644 --- a/packages/desktop-client/src/preload/index.ts +++ b/packages/desktop-client/src/preload/index.ts @@ -1,6 +1,13 @@ import { contextBridge, ipcRenderer } from 'electron'; import { IPC_CHANNELS } from '../main/ipc.js'; -import type { DesktopAppInfo, DesktopBridge, DesktopStoredConfig } from '../types/desktop.js'; +import type { + DesktopAppInfo, + DesktopBridge, + DesktopStoredConfig, + IslandClassStatePayload, + IslandPushContext, +} from '../types/desktop.js'; +import type { IslandNotification } from '@classhelper/shared'; /** * 预加载脚本:在开启了 contextIsolation + sandbox 的前提下, @@ -14,6 +21,14 @@ const bridge: DesktopBridge = { clearConfig: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.clearConfig), getAppInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.appInfo), openExternal: (url: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.openExternal, url), + + islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => { + ipcRenderer.send('island:push', payload); + }, + islandSetClassState: (payload: IslandClassStatePayload): void => { + ipcRenderer.send('island:class-state', payload); + }, + islandGetState: () => ipcRenderer.invoke('island:get-state'), }; contextBridge.exposeInMainWorld('desktop', bridge); diff --git a/packages/desktop-client/src/preload/island.ts b/packages/desktop-client/src/preload/island.ts new file mode 100644 index 0000000..92f7604 --- /dev/null +++ b/packages/desktop-client/src/preload/island.ts @@ -0,0 +1,24 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import type { IslandState } from '@classhelper/shared'; + +/** + * 灵动岛窗口的预加载脚本。 + * 只暴露「订阅状态」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。 + */ +export interface IslandBridge { + onState(handler: (state: IslandState) => void): void; + sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void; + getState(): Promise; +} + +const bridge: IslandBridge = { + onState: (handler) => { + ipcRenderer.on('island:state', (_event, state: IslandState) => handler(state)); + }, + sendAction: (action, id) => { + ipcRenderer.send('island:action', { action, id }); + }, + getState: () => ipcRenderer.invoke('island:get-state'), +}; + +contextBridge.exposeInMainWorld('island', bridge); diff --git a/packages/desktop-client/src/renderer/App.vue b/packages/desktop-client/src/renderer/App.vue index 2efe053..dbd4cb4 100644 --- a/packages/desktop-client/src/renderer/App.vue +++ b/packages/desktop-client/src/renderer/App.vue @@ -1,6 +1,7 @@ + + + diff --git a/scripts/dist-server.mjs b/scripts/dist-server.mjs index e2621a7..ad76f71 100644 --- a/scripts/dist-server.mjs +++ b/scripts/dist-server.mjs @@ -17,6 +17,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveNodeRuntime } from './lib/node-runtime.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const serverDir = path.join(root, 'packages', 'server'); @@ -47,13 +48,49 @@ const run = (command, args, options = {}) => { function ensureBuilds() { const serverEntry = path.join(serverDir, 'dist', 'index.js'); const webEntry = path.join(webDir, 'dist', 'index.html'); + const sharedEntry = path.join(root, 'packages', 'shared', 'dist', 'index.js'); const missing = []; + if (!fs.existsSync(sharedEntry)) missing.push('packages/shared/dist(请先 pnpm build:shared)'); if (!fs.existsSync(serverEntry)) missing.push('packages/server/dist(请先 pnpm --filter @classhelper/server build)'); if (!fs.existsSync(webEntry)) missing.push('packages/web-admin/dist(请先 pnpm --filter @classhelper/web-admin build)'); if (missing.length > 0) { - throw new Error(`缺少构建产物:\n - ${missing.join('\n - ')}`); + throw new Error(`缺少构建产物:\n - ${missing.join('\n - ')}\n建议直接执行:pnpm dist:server`); + } + + // 新鲜度校验:曾出现过"改动只重新构建了 server,却把旧的 shared/dist 打进安装包", + // 结果安装后服务启动即报 "does not provide an export named ..."。这里直接拦住。 + const newestSource = (dir) => { + let newest = 0; + const walk = (current) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) walk(full); + else newest = Math.max(newest, fs.statSync(full).mtimeMs); + } + }; + if (fs.existsSync(dir)) walk(dir); + return newest; + }; + + const stale = []; + const sharedSourceTime = newestSource(path.join(root, 'packages', 'shared', 'src')); + const sharedBuildTime = fs.statSync(path.join(root, 'packages', 'shared', 'dist', 'index.js')).mtimeMs; + if (sharedSourceTime > sharedBuildTime) stale.push('packages/shared(src 比 dist 新)'); + + const serverSourceTime = newestSource(path.join(serverDir, 'src')); + const serverBuildTime = fs.statSync(serverEntry).mtimeMs; + if (serverSourceTime > serverBuildTime) stale.push('packages/server(src 比 dist 新)'); + + const webSourceTime = newestSource(path.join(webDir, 'src')); + const webBuildTime = fs.statSync(webEntry).mtimeMs; + if (webSourceTime > webBuildTime) stale.push('packages/web-admin(src 比 dist 新)'); + + if (stale.length > 0) { + throw new Error( + `构建产物已过期:\n - ${stale.join('\n - ')}\n请重新构建(推荐直接执行:pnpm dist:server)`, + ); } } @@ -144,10 +181,10 @@ function buildRuntime() { // Web 管理端 copyRecursive(path.join(webDir, 'dist'), path.join(staging, 'web')); - // Node 运行时 - const nodeExe = process.execPath; - copyRecursive(nodeExe, path.join(staging, 'node.exe')); - log(`内置 Node 运行时:${nodeExe}`); + // Node 运行时(必须是真正的 node.exe,不能用宿主 Electron 可执行文件) + const nodeRuntime = resolveNodeRuntime(); + copyRecursive(nodeRuntime.path, path.join(staging, 'node.exe')); + log(`内置 Node 运行时:${nodeRuntime.path}(${nodeRuntime.version})`); // 数据目录:强制清空,避免把打包机上测试用的数据库带进安装包 fs.rmSync(path.join(staging, 'data'), { recursive: true, force: true }); diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index d32c469..967ea8f 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -6,6 +6,7 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveElectronEnv, resolveElectronExecutable } from './lib/electron-env.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron'); @@ -15,15 +16,12 @@ if (!fs.existsSync(electronDir)) { process.exit(2); } -const pathFile = path.join(electronDir, 'path.txt'); -const executable = fs.existsSync(pathFile) - ? path.join(electronDir, 'dist', fs.readFileSync(pathFile, 'utf8').trim()) - : path.join(electronDir, 'dist', 'electron.exe'); +const executable = resolveElectronExecutable(electronDir); const child = spawn(executable, [path.join(root, 'scripts', 'icons', 'render.cjs')], { cwd: root, stdio: 'inherit', - env: process.env, + env: resolveElectronEnv(), }); child.on('exit', (code) => process.exit(code ?? 1)); diff --git a/scripts/lib/electron-env.mjs b/scripts/lib/electron-env.mjs new file mode 100644 index 0000000..f23b4db --- /dev/null +++ b/scripts/lib/electron-env.mjs @@ -0,0 +1,42 @@ +/** + * 启动 Electron 应用时的公共辅助:环境变量清理 + 可执行文件定位。 + * + * 背景(本机真实踩过的两个坑,都发生在"宿主终端本身就是 Electron"的场景, + * 例如 DSH 桌面端内置终端 / VS Code 扩展宿主): + * + * 1. 宿主会带着 `ELECTRON_RUN_AS_NODE=1` 运行子进程(本机的 `pnpm` 运行在 + * Electron 内置 Node 上,因此经过 `pnpm run` 的脚本都会带着它)。 + * 该变量会一路继承到我们用 `spawn` 拉起的 Electron 上,使其**退化成纯 Node**: + * `process.versions.electron` 仍在,但 `require('electron')` 抛 MODULE_NOT_FOUND, + * 表现为"冒烟测试/开发模式一启动就报 Cannot find module 'electron'"。 + * 2. 同样因为 `process.versions.electron` 存在,`require('electron')` / `import electronPath + * from 'electron'` 返回的是 **API 对象而不是可执行文件路径**,直接 spawn 会报 + * `ERR_INVALID_ARG_TYPE: The "file" argument must be of type string`。 + * + * 因此:启动 Electron 一律用 `resolveElectronEnv()` + `resolveElectronExecutable()`, + * 不要直接 import 'electron'。 + */ +import fs from 'node:fs'; +import path from 'node:path'; + +/** 清掉会让 Electron 退化成纯 Node 的继承变量,可附加自定义变量 */ +export function resolveElectronEnv(extra = {}) { + const env = { ...process.env, ...extra }; + delete env.ELECTRON_RUN_AS_NODE; + delete env.ELECTRON_NO_ATTACH_CONSOLE; + return env; +} + +/** + * 由 `node_modules/electron` 目录定位真正的 Electron 可执行文件。 + * @param {string} electronDir `node_modules/electron` 的绝对路径 + */ +export function resolveElectronExecutable(electronDir) { + const pathFile = path.join(electronDir, 'path.txt'); + const binaryName = fs.existsSync(pathFile) + ? fs.readFileSync(pathFile, 'utf8').trim() + : process.platform === 'win32' + ? 'electron.exe' + : 'electron'; + return path.join(electronDir, 'dist', binaryName); +} diff --git a/scripts/lib/node-runtime.mjs b/scripts/lib/node-runtime.mjs new file mode 100644 index 0000000..76d9a8d --- /dev/null +++ b/scripts/lib/node-runtime.mjs @@ -0,0 +1,98 @@ +/** + * 定位"真正的 Node 运行时"。 + * + * 为什么需要它:本机的 `pnpm` 运行在 Electron 内置的 Node 上(宿主终端本身是 Electron 应用, + * 例如 DSH 桌面端内置终端)。此时 `process.execPath` 指向 **Electron 可执行文件**, + * 用它去跑 Node CLI 会得到两种典型故障: + * + * 1. 没有 `ELECTRON_RUN_AS_NODE=1` 时:Electron 把脚本当成"应用入口"启动, + * `process.argv` 里多出一个位置参数 —— 例如 electron-builder 直接报 + * `Unknown argument: .../electron-builder/out/cli/cli.js`。 + * 2. 有该变量时虽然能跑,但会把 Electron 当成 Node 复制进安装包, + * 目标机上 `node.exe` 其实是 Electron,服务根本起不来。 + * + * 因此凡是"需要纯 Node 去执行脚本"的地方(打包 CLI、内置运行时分发), + * 都用 `resolveNodeRuntime()` 拿一个真实的 node 可执行文件。 + */ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** 判断候选可执行文件能否以"纯 Node 身份"运行 */ +function isNodeRuntime(candidate) { + const probe = spawnSync( + candidate, + ['-e', "process.stdout.write(process.versions.electron ? 'electron' : 'node')"], + { + encoding: 'utf8', + timeout: 20_000, + // 若候选其实是 Electron,则让它以 Node 模式运行,避免真的弹出应用窗口 + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + }, + ); + const kind = String(probe.stdout ?? '').trim(); + return { ok: probe.status === 0 && kind === 'node', kind: kind || probe.error?.message || '未知' }; +} + +/** + * 返回 `{ path, version }`;找不到时抛错(附带尝试过的候选,便于排查)。 + * @param {{ extraCandidates?: string[] }} [options] + */ +export function resolveNodeRuntime(options = {}) { + const candidates = []; + const push = (value) => { + if (value && !candidates.includes(value)) candidates.push(value); + }; + + // 1) 显式覆盖(打包机/CI 容易配置) + push(process.env.CLASSHELPER_NODE_EXE); + push(process.env.NODE_EXE); + + // 2) 当前进程本身就是 node.exe 时直接复用 + if (path.basename(process.execPath).toLowerCase() === 'node.exe') push(process.execPath); + + for (const candidate of options.extraCandidates ?? []) push(candidate); + + // 3) PATH 中的 node(where node / which node) + try { + const finder = process.platform === 'win32' ? 'where' : 'which'; + const found = spawnSync(finder, ['node'], { encoding: 'utf8' }); + if (found.status === 0) { + for (const line of String(found.stdout ?? '').split(/\r?\n/)) push(line.trim()); + } + } catch { + /* 忽略:继续尝试常见安装路径 */ + } + + // 4) 常见安装路径 + push(path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'nodejs', 'node.exe')); + push(path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'nodejs', 'node.exe')); + push(path.join(process.env.LOCALAPPDATA ?? '', 'Programs', 'nodejs', 'node.exe')); + push(path.join(process.env.NVM_SYMLINK ?? '', 'node.exe')); + push(path.join(process.env.APPDATA ?? '', 'nvm', 'current', 'node.exe')); + push('D:\\node.js\\node.exe'); + push('/usr/local/bin/node'); + push('/usr/bin/node'); + + const rejected = []; + for (const candidate of candidates) { + try { + if (!fs.statSync(candidate).isFile()) continue; + } catch { + continue; + } + + const verdict = isNodeRuntime(candidate); + if (verdict.ok) { + const version = spawnSync(candidate, ['--version'], { encoding: 'utf8' }).stdout?.trim() ?? ''; + return { path: candidate, version }; + } + rejected.push(`${candidate}(${verdict.kind})`); + } + + throw new Error( + '未找到可用的 Node 运行时。\n' + + ` 已尝试:\n - ${rejected.join('\n - ') || '(无候选)'}\n` + + ' 请安装 Node.js,或用环境变量 CLASSHELPER_NODE_EXE 指定 node.exe 的完整路径。', + ); +} diff --git a/scripts/ui-smoke/live-probe.mjs b/scripts/ui-smoke/live-probe.mjs new file mode 100644 index 0000000..baa18d6 --- /dev/null +++ b/scripts/ui-smoke/live-probe.mjs @@ -0,0 +1,149 @@ +/** + * UI 冒烟测试用「真实上课时段探针」。 + * + * 为了验证「上课时段发布紧急通知 → 全屏警告 + 3 秒倒计时」这条链路, + * 测试必须先让服务端真实认定"现在正在上课"。本模块直接用 HTTP 调后端接口: + * + * 1. 登录(拿到 token) + * 2. 取该教师第一个班级 + 该班级第一门课程 + * 3. 新建一条"今天 00:00-23:59、第 1-30 教学周"的课表 —— 覆盖当前时刻 + * 4. 调 /api/schedules/current 确认服务端确实返回 inClass = true + * + * 测试结束后 cleanup 会删除这条探针课表,并删除本次测试发布的通知, + * 保证不污染演示数据。 + * + * 注意:这里只使用 Node 内置 fetch(Node 20+),不引入额外依赖。 + */ + +const DEFAULT_TIMEOUT_MS = 10_000; + +async function call(baseUrl, path, { method = 'GET', token, body } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); + try { + const response = await fetch(`${baseUrl}${path}`, { + method, + signal: controller.signal, + headers: { + ...(body ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = { raw: text.slice(0, 200) }; + } + return { status: response.status, payload }; + } finally { + clearTimeout(timer); + } +} + +/** 今天对应的课表星期(1=周一 … 7=周日) */ +function todayDayOfWeek(now = new Date()) { + const weekday = now.getDay(); + return weekday === 0 ? 7 : weekday; +} + +/** + * 制造一次真实的上课时段。 + * @returns {Promise<{token: string, classId: string, className: string, courseId: string, + * scheduleId: string, inClass: boolean, period: object|null}>} + */ +export async function setupInClassProbe({ baseUrl, username = 'teacher1', password = 'teacher123' } = {}) { + const base = (baseUrl ?? 'http://127.0.0.1:4000').replace(/\/$/, ''); + + const login = await call(base, '/api/auth/login', { + method: 'POST', + body: { username, password }, + }); + const token = login.payload?.data?.token; + if (login.status !== 200 || !token) { + throw new Error(`探针登录失败:HTTP ${login.status} ${JSON.stringify(login.payload)?.slice(0, 160)}`); + } + + const classes = await call(base, '/api/classes', { token }); + const targetClass = Array.isArray(classes.payload?.data) ? classes.payload.data[0] : null; + if (!targetClass) throw new Error('探针失败:该教师名下没有班级,无法制造上课时段'); + + const courses = await call(base, `/api/courses?classId=${encodeURIComponent(targetClass.id)}`, { + token, + }); + const targetCourse = Array.isArray(courses.payload?.data) ? courses.payload.data[0] : null; + if (!targetCourse) throw new Error(`探针失败:班级「${targetClass.name}」没有课程,无法制造上课时段`); + + const schedule = await call(base, '/api/schedules', { + method: 'POST', + token, + body: { + classId: targetClass.id, + courseId: targetCourse.id, + dayOfWeek: todayDayOfWeek(), + startTime: '00:00', + endTime: '23:59', + weekStart: 1, + weekEnd: 30, + location: 'UI 冒烟探针', + }, + }); + const scheduleId = schedule.payload?.data?.id; + if (schedule.status !== 201 || !scheduleId) { + throw new Error( + `探针失败:创建课表返回 HTTP ${schedule.status} ${JSON.stringify(schedule.payload)?.slice(0, 200)}`, + ); + } + + const status = await call(base, `/api/schedules/current?classId=${encodeURIComponent(targetClass.id)}`, { + token, + }); + + return { + baseUrl: base, + token, + classId: targetClass.id, + className: targetClass.name, + courseId: targetCourse.id, + scheduleId, + inClass: Boolean(status.payload?.data?.inClass), + period: status.payload?.data?.current ?? null, + statusCode: status.status, + }; +} + +/** 删除探针课表 + 本次测试发布的通知,恢复原始数据 */ +export async function cleanupInClassProbe(probe, { notificationTitle } = {}) { + const removed = []; + if (!probe?.baseUrl || !probe?.token) return removed; + + if (notificationTitle) { + const list = await call( + probe.baseUrl, + `/api/notifications?classId=${encodeURIComponent(probe.classId)}&keyword=${encodeURIComponent(notificationTitle)}`, + { token: probe.token }, + ); + const matched = Array.isArray(list.payload?.data) + ? list.payload.data.filter((item) => item.title === notificationTitle) + : []; + for (const item of matched) { + const result = await call(probe.baseUrl, `/api/notifications/${item.id}`, { + method: 'DELETE', + token: probe.token, + }); + if (result.status === 200) removed.push(`notification:${item.id}`); + } + } + + if (probe.scheduleId) { + const result = await call(probe.baseUrl, `/api/schedules/${probe.scheduleId}`, { + method: 'DELETE', + token: probe.token, + }); + if (result.status === 200) removed.push(`schedule:${probe.scheduleId}`); + } + + return removed; +} diff --git a/scripts/ui-smoke/main.cjs b/scripts/ui-smoke/main.cjs index 6b8246b..65b7e1b 100644 --- a/scripts/ui-smoke/main.cjs +++ b/scripts/ui-smoke/main.cjs @@ -7,6 +7,10 @@ * 3. 进入主布局 * 4. **侧边栏 7 个菜单逐一点击**:断言路由路径变化 + 页面标题渲染 * —— 这是"点击左侧边栏没反应"的回归测试 + * 5. **上课时段发布紧急通知**:全屏警告 + 3 秒倒计时 + 确认后才真正发布 + * —— 上课时段由 run.mjs 的探针(今天 00:00-23:59 的课表)真实制造 + * 6. 前端路由深链直接访问 + * 7. PWA manifest + Service Worker(浏览器可安装为应用) * * 用法(需要后端已启动,且 Web 管理端由其托管): * UI_SMOKE_URL=http://127.0.0.1:4000/ node scripts/ui-smoke/run.mjs @@ -169,7 +173,133 @@ async function main() { `visited=[${visited.join(' ')}]${failures.length > 0 ? ` failures=${failures.join(';')}` : ''}`, ); - // 5. SPA 深链刷新(生产环境静态托管必须支持) + // 5. 上课时段发布紧急通知:全屏警告 + 3 秒倒计时 + 教师确认后才真正发布 + // "上课时段"由 run.mjs 的探针提前用 HTTP 建好(今天 00:00-23:59 的课表) + const urgentTitle = process.env.UI_SMOKE_URGENT_TITLE ?? 'UI 冒烟紧急通知'; + const inClass = process.env.UI_SMOKE_INCLASS === '1'; + if (!inClass) { + record( + '上课时段发布紧急通知(全屏警告 + 3 秒倒计时)', + false, + `未能制造上课时段:${process.env.UI_SMOKE_PROBE_DETAIL || '未知原因'}`, + ); + } else { + const urgent = await win.webContents.executeJavaScript(`(async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const waitFor = async (check, timeout = 8000) => { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const value = check(); + if (value) return value; + await sleep(80); + } + return null; + }; + const byText = (nodes, text) => + Array.from(nodes).find((node) => (node.textContent ?? '').trim().includes(text)); + const setValue = (element, value) => { + const proto = element.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + Object.getOwnPropertyDescriptor(proto, 'value').set.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + }; + const formItem = (dialog, label) => + Array.from(dialog.querySelectorAll('.el-form-item')).find((item) => + (item.querySelector('.el-form-item__label')?.textContent ?? '').trim().startsWith(label), + ); + + const menu = byText(document.querySelectorAll('.el-menu-item'), '通知发布'); + if (!menu) return { ok: false, detail: '侧边栏未找到"通知发布"菜单' }; + menu.click(); + if (!(await waitFor(() => location.pathname === '/notifications'))) + return { ok: false, detail: '未跳转到 /notifications' }; + + const openButton = await waitFor(() => byText(document.querySelectorAll('button'), '发布通知')); + if (!openButton) return { ok: false, detail: '未找到"发布通知"按钮' }; + openButton.click(); + + const dialog = await waitFor(() => document.querySelector('.el-dialog')); + if (!dialog) return { ok: false, detail: '发布通知弹窗未打开' }; + + const titleItem = formItem(dialog, '标题'); + const contentItem = formItem(dialog, '内容'); + if (!titleItem || !contentItem) return { ok: false, detail: '弹窗表单缺少标题/内容字段' }; + setValue(titleItem.querySelector('input'), ${JSON.stringify(urgentTitle)}); + setValue(contentItem.querySelector('textarea'), '自动化校验:本节课紧急通知二次确认流程'); + + const urgentRadio = byText(dialog.querySelectorAll('.el-radio-button'), '紧急'); + if (!urgentRadio) return { ok: false, detail: '未找到"紧急"优先级选项' }; + (urgentRadio.querySelector('.el-radio-button__inner') ?? urgentRadio).click(); + const radioActive = await waitFor(() => { + const node = byText(dialog.querySelectorAll('.el-radio-button'), '紧急'); + return Boolean(node && node.classList.contains('is-active')); + }); + if (!radioActive) return { ok: false, detail: '"紧急"优先级未选中' }; + + const publish = byText(dialog.querySelectorAll('.el-dialog__footer button'), '立即发布'); + if (!publish) return { ok: false, detail: '未找到"立即发布"按钮' }; + publish.click(); + + const mask = await waitFor(() => document.querySelector('.urgent-mask'), 10000); + if (!mask) return { ok: false, detail: '上课时段发布紧急通知未弹出全屏警告' }; + + const maskText = (mask.textContent ?? '').replace(/\\s+/g, ' ').trim(); + const periodText = (mask.querySelector('.urgent-period .period-value')?.textContent ?? '').trim(); + const dangerButton = mask.querySelector('.urgent-actions button.el-button--danger'); + if (!dangerButton) return { ok: false, detail: '全屏警告缺少确认按钮' }; + const disabledAtStart = dangerButton.disabled === true; + const startText = (dangerButton.textContent ?? '').trim(); + + const startedAt = Date.now(); + const enabled = await waitFor(() => { + const node = document.querySelector('.urgent-mask .urgent-actions button.el-button--danger'); + return Boolean(node && node.disabled !== true); + }, 9000); + const countdownMs = Date.now() - startedAt; + if (!enabled) return { ok: false, detail: '倒计时结束后确认按钮仍未可用' }; + + document.querySelector('.urgent-mask .urgent-actions button.el-button--danger').click(); + + const closed = await waitFor(() => !document.querySelector('.urgent-mask'), 6000); + const listed = await waitFor( + () => + Array.from(document.querySelectorAll('.el-table__body td')).some((cell) => + (cell.textContent ?? '').includes(${JSON.stringify(urgentTitle)}), + ), + 10000, + ); + + return { + ok: Boolean(closed && listed), + detail: closed + ? listed + ? '' + : '确认后警告已关闭,但通知列表未出现该通知' + : '点击确认后全屏警告未关闭', + maskText, + periodText, + disabledAtStart, + startText, + countdownMs, + }; + })()`); + + record( + '上课时段发布紧急通知(全屏警告 + 3 秒倒计时)', + Boolean(urgent?.ok) && + Boolean(urgent?.maskText?.includes('现在为上课时间段')) && + Boolean(urgent?.periodText) && + urgent?.disabledAtStart === true && + Number(urgent?.countdownMs) >= 1500, + urgent + ? `警告文案="${urgent.maskText?.slice(0, 60)}…" 上课时段=${urgent.periodText} ` + + `初始按钮="${urgent.startText}"(禁用=${urgent.disabledAtStart}) ` + + `倒计时=${urgent.countdownMs}ms${urgent.detail ? ` 问题=${urgent.detail}` : ''}` + : '未执行', + ); + } + + // 6. SPA 深链刷新(生产环境静态托管必须支持) await win.loadURL(`${TARGET_URL.replace(/\/$/, '')}/grades`); const deepLinkOk = await waitFor( win, @@ -182,7 +312,7 @@ async function main() { `path=${await win.webContents.executeJavaScript('location.pathname')}`, ); - // 6. PWA 可安装性:manifest 可获取 + Service Worker 已注册 + // 7. PWA 可安装性:manifest 可获取 + Service Worker 已注册 // 注意:注入代码里不要使用 ${} 插值,避免与宿主文件的模板字符串冲突 const manifestInfo = await win.webContents.executeJavaScript(`(async () => { const link = document.querySelector('link[rel="manifest"]'); diff --git a/scripts/ui-smoke/run.mjs b/scripts/ui-smoke/run.mjs index d36dbfd..8cbe4eb 100644 --- a/scripts/ui-smoke/run.mjs +++ b/scripts/ui-smoke/run.mjs @@ -13,11 +13,14 @@ * UI_SMOKE_USER 默认 teacher1 * UI_SMOKE_PASS 默认 teacher123 * UI_SMOKE_RESULT 结果 JSON 输出路径(可选) + * UI_SMOKE_SKIP_PROBE=1 跳过"上课时段探针"(只跑基础 UI 校验) */ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveElectronEnv, resolveElectronExecutable } from '../lib/electron-env.mjs'; +import { cleanupInClassProbe, setupInClassProbe } from './live-probe.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); const electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron'); @@ -28,11 +31,7 @@ if (!fs.existsSync(electronDir)) { process.exit(2); } -const pathFile = path.join(electronDir, 'path.txt'); -const executable = fs.existsSync(pathFile) - ? path.join(electronDir, 'dist', fs.readFileSync(pathFile, 'utf8').trim()) - : path.join(electronDir, 'dist', 'electron.exe'); - +const executable = resolveElectronExecutable(electronDir); if (!fs.existsSync(executable)) { console.error(`[ui-smoke] Electron 可执行文件不存在:${executable}`); console.error(' 请执行:node packages/desktop-client/node_modules/electron/install.js'); @@ -50,18 +49,63 @@ fs.rmSync(profileDir, { recursive: true, force: true }); console.log(`[ui-smoke] 目标:${target}`); console.log(`[ui-smoke] Electron:${executable}`); +/** + * 上课时段探针:让服务端真实认定"现在正在上课", + * 以便验证「上课时段发布紧急通知 → 全屏警告 + 3 秒倒计时」。 + */ +const urgentTitle = `UI 冒烟紧急通知 ${Date.now()}`; +let probe = null; +let probeError = ''; + +if (process.env.UI_SMOKE_SKIP_PROBE === '1') { + probeError = '已通过 UI_SMOKE_SKIP_PROBE=1 跳过探针'; + console.log(`[ui-smoke] ${probeError}`); +} else { + try { + probe = await setupInClassProbe({ + baseUrl: new URL(target).origin, + username: process.env.UI_SMOKE_USER ?? 'teacher1', + password: process.env.UI_SMOKE_PASS ?? 'teacher123', + }); + if (!probe.inClass) probeError = '探针课表已创建,但服务端仍未判定为上课时段'; + console.log( + `[ui-smoke] 上课时段探针:班级=${probe.className} 上课中=${probe.inClass} ` + + `时段=${probe.period ? `${probe.period.startTime}-${probe.period.endTime} · ${probe.period.courseName}` : '-'}`, + ); + } catch (error) { + probeError = String(error?.message ?? error); + console.error(`[ui-smoke] 上课时段探针准备失败:${probeError}`); + console.error(' "上课时段紧急通知二次确认"校验项将被记为失败。'); + } +} + const child = spawn(executable, [entry], { cwd: root, stdio: 'inherit', - env: { - ...process.env, + env: resolveElectronEnv({ UI_SMOKE_URL: target, UI_SMOKE_RESULT: resultFile, UI_SMOKE_PROFILE: profileDir, - }, + UI_SMOKE_URGENT_TITLE: urgentTitle, + UI_SMOKE_INCLASS: probe?.inClass ? '1' : '0', + UI_SMOKE_PROBE_DETAIL: probeError, + }), }); -child.on('exit', (code) => { +child.on('exit', async (code) => { + if (probe) { + try { + const removed = await cleanupInClassProbe(probe, { notificationTitle: urgentTitle }); + console.log( + removed.length > 0 + ? `[ui-smoke] 探针清理完成:${removed.join(', ')}` + : '[ui-smoke] 探针清理完成(无残留数据)', + ); + } catch (error) { + console.error(`[ui-smoke] 探针清理失败(请手动检查演示数据):${error?.message ?? error}`); + } + } + if (code === 0) { console.log(`[ui-smoke] 全部通过,结果文件:${resultFile}`); }