feat: 客户端灵动岛 + 上课时段紧急通知二次确认,并修复打包/校验脚本在 Electron 宿主下的多个缺陷
## 客户端灵动岛(动态岛)
- 新增独立透明置顶窗口:桌面中上方常驻、不占任务栏、不抢焦点(268x44 胶囊 / 404x316 详情 / 424x344 紧急)
- 三种形态与动画:隐藏 ↔ 胶囊 ↔ 详情,尺寸用 easeOutCubic 260ms 逐帧缓动、透明度单独淡入淡出
(尺寸与淡入使用各自动画令牌,避免互相打断导致窗口卡在胶囊尺寸)
- 行为:普通通知变「新消息 · 共 N 条」胶囊,点击展开详情;上课时段自动隐藏并进入待发队列,
下课后自动弹出;紧急通知无论是否上课立即展开(红色呼吸光晕 + 紧急角标,无需点击)
- 上课状态来自 GET /api/schedules/current(10s 心跳 + 下课时长定时器),
新增 src/main/island.ts(控制器/IPC)、src/preload/island.ts、src/island/IslandApp.vue、
src/renderer/island/bridge.ts、island.html 与多页 Vite 构建
## 上课时段发布紧急通知的二次确认
- 服务端:priority=URGENT 且当前处于上课时间段且未带 confirmDuringClass 时返回
409 URGENT_DURING_CLASS(携带 current/next/week/serverTime),绕过界面调接口同样被拦截
- 新增 GET /api/schedules/current(inClass/current/next,at 参数用于诊断与测试)
- Web 管理端:全屏警告遮罩(UrgentClassWarning.vue),显示正在上的课时段与待发布标题,
确认按钮带 3 秒倒计时(圆环进度,倒计时结束前禁用),取消即不发;并发命中 409 同样兜底弹窗
## 打包与验证脚本修复(Electron 宿主环境)
- scripts/lib/node-runtime.mjs:定位真正的 node.exe。此前经 pnpm 运行时 process.execPath 指向
Electron,dist:server 会把 Electron 复制成安装包内的 node.exe,dist:win 会让 electron-builder 报
Unknown argument: .../cli.js(改为显式使用真实 Node)
- scripts/lib/electron-env.mjs:启动 Electron 前清理继承的 ELECTRON_RUN_AS_NODE(否则 Electron 退化为
纯 Node,require('electron') 直接 MODULE_NOT_FOUND),并从 path.txt 定位 electron.exe
(process.versions.electron 存在时 import 'electron' 返回的是 API 对象而非路径)
- scripts/dist-server.mjs:新增构建产物新鲜度校验(曾出现 server 已重建但打进安装包的 shared/dist 是旧的,
安装后服务启动即报 does not provide an export named MAX_TERM_WEEK)
- scripts/ui-smoke:新增 live-probe.mjs 真实制造/清理上课时段,Electron 驱动真实点击断言全屏警告文案、
按钮禁用与 3 秒倒计时时长(实测 2966ms),测试后自动删除探针课表与测试通知
## 验证结果
- pnpm verify:e2e 62/62(新增上课时段拦截、二次确认后发布、普通通知不受限等 8 项)
- pnpm verify:web 8/8(新增上课时段紧急通知全屏警告 + 倒计时)
- pnpm verify:desktop 18/18(新增灵动岛窗口/上课隐藏/下课弹出/紧急立即展开/胶囊点击展开 + 截图)
- 打包后 EXE 冒烟 15/15(packaged: true,退出码 0)
- 服务端安装程序升级安装:.env 与数据库保持不变,/api/schedules/current 正常
- pnpm lint / format:check / typecheck 全部通过
This commit is contained in:
1 parent
5545ef6f55
commit
3a9f8812fc
40 files changed
+3062
-149
No files matched your search
@@ -20,10 +20,11 @@
|
|||||||
| 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 离线缓存 | ✅ 已完成(冒烟验证 11/11) |
|
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 18/18) |
|
||||||
| 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)) |
|
||||||
|
| 8 | 客户端灵动岛(通知浮窗,上课隐藏 / 下课弹出 / 紧急立即展开)与上课时段紧急通知二次确认 | ✅ 已完成(见"上课时段策略") |
|
||||||
|
|
||||||
> **与原始提示词的两处偏差(已与你确认)**
|
> **与原始提示词的两处偏差(已与你确认)**
|
||||||
>
|
>
|
||||||
@@ -59,8 +60,9 @@ class-helper/
|
|||||||
│ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手
|
│ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手
|
||||||
│ ├── generate-icons.mjs # 用 Electron 渲染 SVG 生成 PNG/ICO 图标
|
│ ├── generate-icons.mjs # 用 Electron 渲染 SVG 生成 PNG/ICO 图标
|
||||||
│ ├── dist-server.mjs # 服务端 + Web 管理端 打包(免安装目录 + NSIS 安装程序)
|
│ ├── dist-server.mjs # 服务端 + Web 管理端 打包(免安装目录 + NSIS 安装程序)
|
||||||
|
│ ├── lib/electron-env.mjs # 启动 Electron 的公共处理(清理 RUN_AS_NODE + 定位可执行文件)
|
||||||
│ ├── nsis/server-installer.nsi# 安装程序脚本模板
|
│ ├── nsis/server-installer.nsi# 安装程序脚本模板
|
||||||
│ └── ui-smoke/ # Web 管理端 UI 真实点击回归测试(Electron 驱动)
|
│ └── ui-smoke/ # Web 管理端 UI 真实点击回归测试(Electron 驱动 + 上课时段探针)
|
||||||
├── deploy/ # 生产部署:Dockerfile / docker-compose.yml / nginx.conf
|
├── deploy/ # 生产部署:Dockerfile / docker-compose.yml / nginx.conf
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── production.md # 生产部署指南(三种形态 + 运维 + 安全清单)
|
│ ├── production.md # 生产部署指南(三种形态 + 运维 + 安全清单)
|
||||||
@@ -73,7 +75,7 @@ class-helper/
|
|||||||
│ ├── prisma/migrations/ # 迁移历史(已生成并应用)
|
│ ├── prisma/migrations/ # 迁移历史(已生成并应用)
|
||||||
│ ├── prisma/seed.ts # 种子数据
|
│ ├── prisma/seed.ts # 种子数据
|
||||||
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
|
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
|
||||||
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(54 项)
|
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(62 项)
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载)
|
│ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载)
|
||||||
│ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出)
|
│ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出)
|
||||||
@@ -91,8 +93,9 @@ class-helper/
|
|||||||
├── vite.config.mts # 渲染进程构建(base: './',hash 路由)
|
├── vite.config.mts # 渲染进程构建(base: './',hash 路由)
|
||||||
├── scripts/{build-main,dev,smoke,dist-win}.mjs
|
├── scripts/{build-main,dev,smoke,dist-win}.mjs
|
||||||
└── src/
|
└── src/
|
||||||
├── main/ # 主进程:窗口/单实例/配置持久化(IPC)/冒烟验证
|
├── main/ # 主进程:窗口/单实例/配置持久化(IPC)/冒烟验证/灵动岛
|
||||||
├── preload/ # contextBridge 安全桥(不暴露 ipcRenderer 本体)
|
├── preload/ # contextBridge 安全桥(不暴露 ipcRenderer 本体)
|
||||||
|
├── island/ # 灵动岛渲染进程(独立透明置顶窗口)
|
||||||
├── types/desktop.d.ts # 主进程 <-> 渲染进程契约
|
├── types/desktop.d.ts # 主进程 <-> 渲染进程契约
|
||||||
└── renderer/ # 渲染进程:api / stores / cache / router / layouts / views
|
└── renderer/ # 渲染进程:api / stores / cache / router / layouts / views
|
||||||
```
|
```
|
||||||
@@ -168,13 +171,15 @@ pnpm verify:desktop
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 另开一个终端先启动后端:pnpm dev:server
|
# 另开一个终端先启动后端:pnpm dev:server
|
||||||
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC:54 项
|
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截:62 项
|
||||||
pnpm verify:desktop # Electron 客户端(含联网集成):9 项
|
pnpm verify:web # Web 管理端真实点击(含上课时段紧急通知全屏警告):8 项
|
||||||
|
pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成):18 项
|
||||||
```
|
```
|
||||||
|
|
||||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离**等 54 项,实测通知 37ms、作业 26ms、
|
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项,
|
||||||
成绩 25ms 到达(要求 < 5 秒);客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、
|
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
||||||
断网回退与联网集成(登录 + 四类数据 + Socket.IO),实测 9/9 通过。
|
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
||||||
|
(登录 + 四类数据 + Socket.IO),实测 18/18 通过。
|
||||||
|
|
||||||
## 默认账号与种子数据
|
## 默认账号与种子数据
|
||||||
|
|
||||||
@@ -209,9 +214,9 @@ pnpm verify:desktop # Electron 客户端(含联网集成):9 项
|
|||||||
| `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` | 后端端到端验收(54 项,需服务端已启动) |
|
| `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) |
|
||||||
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,7 项) |
|
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,8 项) |
|
||||||
| `pnpm verify:desktop` | EXE 客户端冒烟验证(11 项,含侧边栏点击与离线回退) |
|
| `pnpm verify:desktop` | EXE 客户端冒烟验证(18 项,含灵动岛、侧边栏点击与离线回退) |
|
||||||
| `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 格式化 / 检查 |
|
||||||
@@ -246,6 +251,29 @@ pnpm verify:desktop # Electron 客户端(含联网集成):9 项
|
|||||||
| 成绩 | 表格(分数/得分率进度条/等级)+ ECharts 柱状图(各次考试平均得分率)+ 等级分布 + 最近更新 |
|
| 成绩 | 表格(分数/得分率进度条/等级)+ 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` 启动应用(不弹窗),主进程依次校验后自动退出:
|
`pnpm verify:desktop` 以 `ELECTRON_SMOKE_TEST=1` 启动应用(不弹窗),主进程依次校验后自动退出:
|
||||||
|
|
||||||
| 校验项 | 实测结果 |
|
| 校验项 | 实测结果 |
|
||||||
| ------------------------------------- | ----------------------------------------------------------------------------------- |
|
| -------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||||
| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` |
|
| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` |
|
||||||
| 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 |
|
| 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 |
|
||||||
| preload contextBridge 注入 | ✅ 6 个方法 |
|
| 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`(请求不可达端口) |
|
||||||
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 5 作业 / 4 通知 / 6 成绩 / 第29周29节课 / Socket.IO 已连接 |
|
| 灵动岛窗口创建(置顶/透明/不占任务栏) | ✅ `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=<file>` 写出 JSON 结果,
|
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 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 一览
|
## REST API 一览
|
||||||
|
|
||||||
统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。
|
统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。
|
||||||
所有接口前缀 `/api`,除登录外均需 `Authorization: Bearer <token>`。
|
所有接口前缀 `/api`,除登录外均需 `Authorization: Bearer <token>`。
|
||||||
|
|
||||||
| 方法 | 路径 | 权限 | 说明 |
|
| 方法 | 路径 | 权限 | 说明 |
|
||||||
| --------------------------- | -------------------------------------------------------- | --------------- | ---------------------------------------------------------- |
|
| --------------------------- | -------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------- |
|
||||||
| POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 |
|
| POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 |
|
||||||
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) |
|
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) |
|
||||||
| PATCH | `/auth/password` | 登录 | 修改自己的密码 |
|
| PATCH | `/auth/password` | 登录 | 修改自己的密码 |
|
||||||
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
|
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
|
||||||
| GET | `/classes` | 登录 | 班级列表(按权限收敛) |
|
| GET | `/classes` | 登录 | 班级列表(按权限收敛) |
|
||||||
| GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) |
|
| GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) |
|
||||||
| POST / PATCH / DELETE | `/classes` `/classes/:id` | 教师/管理员 | 班级增删改 |
|
| POST / PATCH / DELETE | `/classes` `/classes/:id` | 教师/管理员 | 班级增删改 |
|
||||||
| GET / POST | `/classes/:id/students` | 班级可见 / 可写 | 学生名单 / 添加学生(已存在账号直接转入) |
|
| GET / POST | `/classes/:id/students` | 班级可见 / 可写 | 学生名单 / 添加学生(已存在账号直接转入) |
|
||||||
| DELETE | `/classes/:id/students/:userId` | 教师/管理员 | 移出学生 |
|
| DELETE | `/classes/:id/students/:userId` | 教师/管理员 | 移出学生 |
|
||||||
| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | 教师/管理员 | 分配 / 取消协作教师 |
|
| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | 教师/管理员 | 分配 / 取消协作教师 |
|
||||||
| GET / POST / PATCH / DELETE | `/courses` | 登录 / 教师 | 课程管理 |
|
| GET / POST / PATCH / DELETE | `/courses` | 登录 / 教师 | 课程管理 |
|
||||||
| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(week 过滤周次范围) |
|
| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(week 过滤周次范围) |
|
||||||
| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) |
|
| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) |
|
||||||
| POST / PATCH / DELETE | `/schedules` | 教师/管理员 | 课表增删改(广播 `schedule:updated`) |
|
| GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) |
|
||||||
| GET | `/homeworks?classId=&courseId=&pendingOnly=&keyword=` | 登录 | 作业列表(学生带完成状态,教师带完成人数) |
|
| POST / PATCH / DELETE | `/schedules` | 教师/管理员 | 课表增删改(广播 `schedule:updated`) |
|
||||||
| GET | `/homeworks/:id` | 班级可见 | 作业详情 |
|
| GET | `/homeworks?classId=&courseId=&pendingOnly=&keyword=` | 登录 | 作业列表(学生带完成状态,教师带完成人数) |
|
||||||
| POST / PATCH / DELETE | `/homeworks` | 教师/管理员 | 发布/修改/删除(广播 `homework:new` / `homework:updated`) |
|
| GET | `/homeworks/:id` | 班级可见 | 作业详情 |
|
||||||
| PATCH | `/homeworks/:id/status` | 登录 | 标记完成/取消(广播 `homework:status`) |
|
| POST / PATCH / DELETE | `/homeworks` | 教师/管理员 | 发布/修改/删除(广播 `homework:new` / `homework:updated`) |
|
||||||
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) |
|
| PATCH | `/homeworks/:id/status` | 登录 | 标记完成/取消(广播 `homework:status`) |
|
||||||
| GET | `/notifications/unread-count` | 登录 | 未读数(红点) |
|
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) |
|
||||||
| POST | `/notifications` | 教师/管理员 | 发布通知(广播 `notification:new`) |
|
| GET | `/notifications/unread-count` | 登录 | 未读数(红点) |
|
||||||
| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读 |
|
| POST | `/notifications` | 教师/管理员 | 发布通知(广播 `notification:new`);上课时段发布紧急通知需 `confirmDuringClass` |
|
||||||
| DELETE | `/notifications/:id` | 教师/管理员 | 删除通知 |
|
| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读 |
|
||||||
| GET | `/grades/my` | 登录 | 个人成绩 |
|
| DELETE | `/notifications/:id` | 教师/管理员 | 删除通知 |
|
||||||
| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师/管理员 | 班级成绩 |
|
| GET | `/grades/my` | 登录 | 个人成绩 |
|
||||||
| GET | `/grades/stats?classId=&courseId=&examName=` | 教师/管理员 | 等级分布 + 各课程平均得分率 |
|
| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师/管理员 | 班级成绩 |
|
||||||
| POST | `/grades`、`/grades/bulk` | 教师/管理员 | 单条 / 批量录入(广播 `grade:updated`) |
|
| GET | `/grades/stats?classId=&courseId=&examName=` | 教师/管理员 | 等级分布 + 各课程平均得分率 |
|
||||||
| PATCH / DELETE | `/grades/:id` | 教师/管理员 | 修改 / 删除成绩 |
|
| POST | `/grades`、`/grades/bulk` | 教师/管理员 | 单条 / 批量录入(广播 `grade:updated`) |
|
||||||
| GET | `/students?classId=&keyword=` | 教师/管理员 | 学生名单 |
|
| PATCH / DELETE | `/grades/:id` | 教师/管理员 | 修改 / 删除成绩 |
|
||||||
| POST / PATCH / DELETE | `/students` | 教师/管理员 | 学生账号增删改 |
|
| GET | `/students?classId=&keyword=` | 教师/管理员 | 学生名单 |
|
||||||
| POST | `/students/:id/reset-password` | 教师/管理员 | 重置密码 |
|
| POST / PATCH / DELETE | `/students` | 教师/管理员 | 学生账号增删改 |
|
||||||
| GET | `/teachers?keyword=` | 教师/管理员 | 教师列表(分配协作教师用) |
|
| POST | `/students/:id/reset-password` | 教师/管理员 | 重置密码 |
|
||||||
| POST | `/teachers` | 管理员 | 新建教师账号 |
|
| GET | `/teachers?keyword=` | 教师/管理员 | 教师列表(分配协作教师用) |
|
||||||
| GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 |
|
| POST | `/teachers` | 管理员 | 新建教师账号 |
|
||||||
| GET | `/health` | 公开 | 健康检查(含已挂载模块列表) |
|
| GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 |
|
||||||
|
| GET | `/health` | 公开 | 健康检查(含已挂载模块列表) |
|
||||||
|
|
||||||
## WebSocket 事件
|
## WebSocket 事件
|
||||||
|
|
||||||
@@ -422,36 +486,38 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
|
|
||||||
## 验收标准对照
|
## 验收标准对照
|
||||||
|
|
||||||
| 验收项 | 结果 | 证据 |
|
| 验收项 | 结果 | 证据 |
|
||||||
| --------------------------------------------- | ---- | ------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------ | ---- | -------------------------------------------------------------------------------------------- |
|
||||||
| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** |
|
| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** |
|
||||||
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
|
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
|
||||||
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 |
|
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 |
|
||||||
| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` |
|
| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` |
|
||||||
| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 |
|
| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 |
|
||||||
| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) |
|
| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) |
|
||||||
| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe`(106.9MB) / `-portable.exe`(106.7MB) / `win-unpacked/*.exe` |
|
| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 |
|
||||||
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 |
|
| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 四种状态断言 + 截图(`.cache/island-shots/`) |
|
||||||
|
| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) |
|
||||||
|
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 |
|
||||||
|
|
||||||
打包产物验证(对最终 EXE 实测,非仅开发产物):
|
打包产物验证(对最终 EXE 实测,非仅开发产物):
|
||||||
|
|
||||||
| 产物 | 大小 | 冒烟结果 |
|
| 产物 | 大小 | 冒烟结果 |
|
||||||
| -------------------------------------- | ------- | ------------------------------------------------------------------------------------ |
|
| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
| 服务端安装程序(内置 Node + Web 端) | 33.9MB | ✅ 静默安装 10s → 自动建库建号 → 服务 34s 就绪 → **对安装实例跑 54/54 e2e + 7/7 UI** |
|
| 服务端安装程序(内置 Node + Web 端) | 34MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 → **对安装实例跑 62/62 e2e + 8/8 UI** |
|
||||||
| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 11/11,`packaged: true`,退出码 0 |
|
| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 18/18,`packaged: true`,退出码 0 |
|
||||||
| `release/…-x64-portable.exe`(单文件) | 106.9MB | ✅ 9/9(含联网集成:登录王小明 / Socket.IO 已连接 / 四类数据) |
|
| `release/…-x64-portable.exe`(单文件) | ~107MB | ✅(含联网集成:登录王小明 / Socket.IO 已连接 / 四类数据) |
|
||||||
| `release/…-x64-setup.exe`(客户端) | 107.2MB | ✅ 构建成功,已嵌入自定义图标 |
|
| `release/…-x64-setup.exe`(客户端) | ~107MB | ✅ 构建成功,已嵌入自定义图标;包含灵动岛 |
|
||||||
|
|
||||||
当前实测:
|
当前实测:
|
||||||
|
|
||||||
| 验证 | 结果 |
|
| 验证 | 结果 |
|
||||||
| ----------------------------------------------------------- | ----------------------------------- |
|
| ------------------------------------------------------------------------ | ----------------------------------- |
|
||||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **54/54** ✅ |
|
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **62/62** ✅ |
|
||||||
| `pnpm verify:web`(Web 管理端真实点击 + PWA) | **7/7** ✅ |
|
| `pnpm verify:web`(Web 管理端真实点击 + 上课时段紧急通知 + PWA) | **8/8** ✅ |
|
||||||
| `pnpm verify:desktop`(客户端冒烟 + 侧边栏点击 + 离线回退) | **11/11** ✅ |
|
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛四态 + 侧边栏点击 + 离线回退) | **18/18** ✅ |
|
||||||
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||||
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||||
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||||
|
|
||||||
## 常见问题(本机环境已知坑)
|
## 常见问题(本机环境已知坑)
|
||||||
|
|
||||||
@@ -464,6 +530,21 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
`node packages/web-admin/node_modules/vite/bin/vite.js build`。
|
`node packages/web-admin/node_modules/vite/bin/vite.js build`。
|
||||||
在普通终端(非 Electron 宿主)中 `pnpm <script>` 一切正常。
|
在普通终端(非 Electron 宿主)中 `pnpm <script>` 一切正常。
|
||||||
|
|
||||||
|
1.5 **脚本里启动 Electron 时必须清掉 `ELECTRON_RUN_AS_NODE`,并且不要 `import 'electron'` 取路径**
|
||||||
|
现象(两种,都只在本机这种"宿主终端本身是 Electron"的环境里出现):
|
||||||
|
① `node scripts/smoke.mjs` 正常,但 `pnpm verify:desktop` 报
|
||||||
|
`Cannot find module 'electron'`(Electron 退化成纯 Node 运行);
|
||||||
|
② `pnpm dist:win` 报 `Unknown argument: .../electron-builder/out/cli/cli.js`。
|
||||||
|
原因:本机 `pnpm` 运行在 Electron 内置 Node 上,会给子进程带上 `ELECTRON_RUN_AS_NODE=1`;
|
||||||
|
同时 `process.versions.electron` 存在时,`require('electron')` 返回的是 **API 对象而非可执行文件路径**,
|
||||||
|
`spawn(process.execPath, [cli.js, …])` 会被 Electron 当成"启动应用"而多出一个位置参数。
|
||||||
|
规避(已内置到代码里):
|
||||||
|
|
||||||
|
- `scripts/lib/electron-env.mjs`:`resolveElectronEnv()` 清理变量、`resolveElectronExecutable()` 从
|
||||||
|
`node_modules/electron/path.txt` 定位 electron.exe;
|
||||||
|
- `scripts/lib/node-runtime.mjs`:`resolveNodeRuntime()` 明确找一个**真正的 node.exe**,
|
||||||
|
供 electron-builder CLI 与"内置 Node 运行时"打包使用。
|
||||||
|
|
||||||
2. **不要用 PowerShell 管道调用 `start.cmd`**
|
2. **不要用 PowerShell 管道调用 `start.cmd`**
|
||||||
现象:`& "$install\start.cmd" | Select-Object -First 20` 会一直不返回。
|
现象:`& "$install\start.cmd" | Select-Object -First 20` 会一直不返回。
|
||||||
原因:脚本内启动的 node 进程在该上下文里继承了管道句柄。
|
原因:脚本内启动的 node 进程在该上下文里继承了管道句柄。
|
||||||
@@ -521,9 +602,22 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
| `deploy/{Dockerfile,docker-compose.yml,nginx.conf}` | 云部署与 HTTPS 反代样例 |
|
| `deploy/{Dockerfile,docker-compose.yml,nginx.conf}` | 云部署与 HTTPS 反代样例 |
|
||||||
| `docs/production.md` | 生产部署指南(三种形态 + 运维 + 安全清单 + 故障排查) |
|
| `docs/production.md` | 生产部署指南(三种形态 + 运维 + 安全清单 + 故障排查) |
|
||||||
|
|
||||||
|
阶段 8(灵动岛 + 上课时段策略)新增文件:
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
| ------------------------------------------------------------ | -------------------------------------------------------------------- |
|
||||||
|
| `packages/desktop-client/src/main/island.ts` | 灵动岛控制器(窗口/状态机/动画/超时/IPC) |
|
||||||
|
| `packages/desktop-client/src/preload/island.ts` | 灵动岛 preload 桥(`islandGetState/islandPush/islandSetClassState`) |
|
||||||
|
| `packages/desktop-client/src/island/IslandApp.vue` | 灵动岛界面(胶囊 / 详情 / 紧急三种形态 + 动画) |
|
||||||
|
| `packages/desktop-client/src/renderer/island/bridge.ts` | 上课状态轮询(10s)与通知转发 |
|
||||||
|
| `packages/server/src/modules/schedules/schedules.service.ts` | `computeClassStatus` / `getClassStatus`(上课时段判定) |
|
||||||
|
| `packages/web-admin/src/components/UrgentClassWarning.vue` | 上课时段发布紧急通知的全屏二次确认(3 秒倒计时) |
|
||||||
|
| `scripts/ui-smoke/live-probe.mjs` | UI 回归测试的"真实上课时段"探针(自建课表 + 用后清理) |
|
||||||
|
| `scripts/lib/{electron-env,node-runtime}.mjs` | Electron/Node 运行时定位与宿主环境兼容处理 |
|
||||||
|
|
||||||
## 后续可选增强
|
## 后续可选增强
|
||||||
|
|
||||||
1. 代码签名证书(消除 SmartScreen 提示)。
|
1. 代码签名证书(消除 SmartScreen 提示)。
|
||||||
2. 客户端"提交类操作离线队列"(当前离线为只读,联网后自动同步读取的数据)。
|
2. 客户端"提交类操作离线队列"(当前离线为只读,联网后自动同步读取的数据)。
|
||||||
3. Socket.IO 多实例广播(引入 `@socket.io/redis-adapter`)与 CI 流水线。
|
3. Socket.IO 多实例广播(引入 `@socket.io/redis-adapter`)与 CI 流水线。
|
||||||
4. 课表按当前时间高亮"正在上的课"与上课提醒(可参考 ClassIsland 的课程提醒设计)。
|
4. 灵动岛位置/尺寸可配置(多显示器时跟随当前活动屏幕,而非固定主屏)。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>班级小助手 · 灵动岛</title>
|
||||||
|
<style>
|
||||||
|
/* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/island/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -34,4 +34,11 @@ await build({
|
|||||||
outfile: path.join(root, 'dist/preload/index.js'),
|
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 构建完成');
|
||||||
@@ -8,9 +8,10 @@ import { spawn } from 'node:child_process';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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 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 HOST = '127.0.0.1';
|
||||||
const PORT = 5174;
|
const PORT = 5174;
|
||||||
const DEV_URL = `http://${HOST}:${PORT}`;
|
const DEV_URL = `http://${HOST}:${PORT}`;
|
||||||
@@ -53,7 +54,7 @@ function startElectron() {
|
|||||||
electron = spawn(electronPath, ['.'], {
|
electron = spawn(electronPath, ['.'], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: 'inherit',
|
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));
|
electron.on('exit', (code) => cleanup(code ?? 0));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { spawn } from 'node:child_process';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { resolveNodeRuntime } from '../../../scripts/lib/node-runtime.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 repoRoot = path.resolve(root, '..', '..');
|
||||||
@@ -52,7 +53,14 @@ if (!fs.existsSync(cli)) {
|
|||||||
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough];
|
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough];
|
||||||
console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
|
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) => {
|
child.on('exit', (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { spawn } from 'node:child_process';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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 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 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));
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ if (online === undefined) {
|
|||||||
const child = spawn(electronPath, ['.'], {
|
const child = spawn(electronPath, ['.'], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: 'inherit',
|
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) => {
|
child.on('exit', (code) => {
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { PRIORITY_LABELS, formatDate, type IslandNotification, type IslandState } from '@classhelper/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 灵动岛渲染进程。
|
||||||
|
*
|
||||||
|
* 窗口尺寸由主进程按状态缓动(见 src/main/island.ts),这里只负责:
|
||||||
|
* - 按状态渲染 胶囊 / 展开卡片
|
||||||
|
* - 内容交叉淡入与弹簧缩放(形变动画的"内容层")
|
||||||
|
* - 把用户点击(展开 / 收起 / 标为已读 / 打开应用)回传主进程
|
||||||
|
*/
|
||||||
|
const state = ref<IslandState>({
|
||||||
|
mode: 'hidden',
|
||||||
|
active: null,
|
||||||
|
queued: [],
|
||||||
|
inClass: false,
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
reason: null,
|
||||||
|
updatedAt: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bridge = window.island;
|
||||||
|
|
||||||
|
const notification = computed<IslandNotification | null>(() => state.value.active);
|
||||||
|
const mode = computed(() => state.value.mode);
|
||||||
|
const isUrgent = computed(() => notification.value?.priority === 'URGENT');
|
||||||
|
const queueCount = computed(() => state.value.queued.length);
|
||||||
|
|
||||||
|
const priorityLabel = computed(() =>
|
||||||
|
notification.value ? (PRIORITY_LABELS[notification.value.priority] ?? notification.value.priority) : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitle = computed(() => {
|
||||||
|
if (!notification.value) return '';
|
||||||
|
const teacher = notification.value.teacherName ? `${notification.value.teacherName} · ` : '';
|
||||||
|
return `${teacher}${formatDate(notification.value.createdAt, true)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 下课补发时提示"其实上课期间就到了" */
|
||||||
|
const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass);
|
||||||
|
|
||||||
|
function expand(): void {
|
||||||
|
bridge?.sendAction('expand');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapse(): void {
|
||||||
|
bridge?.sendAction('collapse');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(): void {
|
||||||
|
bridge?.sendAction('dismiss');
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRead(): void {
|
||||||
|
if (notification.value) bridge?.sendAction('mark-read', notification.value.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openApp(): void {
|
||||||
|
bridge?.sendAction('open-app');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
bridge?.onState((next) => {
|
||||||
|
state.value = next;
|
||||||
|
});
|
||||||
|
const initial = await bridge?.getState();
|
||||||
|
if (initial) state.value = initial;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="island-root">
|
||||||
|
<Transition name="island-pop" :duration="{ enter: 240, leave: 150 }">
|
||||||
|
<!-- 胶囊态:收到通知后先变成"新消息" -->
|
||||||
|
<button
|
||||||
|
v-if="mode === 'pill' && notification"
|
||||||
|
key="pill"
|
||||||
|
type="button"
|
||||||
|
class="island-card pill"
|
||||||
|
:class="{ urgent: isUrgent, 'after-class': showAfterClassHint }"
|
||||||
|
@click="expand"
|
||||||
|
>
|
||||||
|
<span v-if="isUrgent" class="glow" aria-hidden="true"></span>
|
||||||
|
<span class="pulse-dot" :class="{ urgent: isUrgent }"></span>
|
||||||
|
<span class="pill-icon">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 2a6 6 0 0 0-6 6v3.1L4.6 14a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5L18 11.1V8a6 6 0 0 0-6-6Zm0 20a3 3 0 0 0 3-2.6H9A3 3 0 0 0 12 22Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="pill-text">
|
||||||
|
<span class="pill-title">
|
||||||
|
{{ isUrgent ? '紧急通知' : '新消息' }}
|
||||||
|
<template v-if="queueCount > 0">· 共 {{ queueCount + 1 }} 条</template>
|
||||||
|
</span>
|
||||||
|
<span class="pill-sub">{{ notification.teacherName || '老师' }} · 点击查看</span>
|
||||||
|
</span>
|
||||||
|
<span class="pill-chevron" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14">
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="m9 6 6 6-6 6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 展开态:显示通知详情 -->
|
||||||
|
<section
|
||||||
|
v-else-if="mode === 'expanded' && notification"
|
||||||
|
key="expanded"
|
||||||
|
class="island-card expanded"
|
||||||
|
:class="{ urgent: isUrgent }"
|
||||||
|
>
|
||||||
|
<span v-if="isUrgent" class="glow" aria-hidden="true"></span>
|
||||||
|
<header class="head">
|
||||||
|
<span class="head-icon" :class="{ urgent: isUrgent }">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 2a6 6 0 0 0-6 6v3.1L4.6 14a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5L18 11.1V8a6 6 0 0 0-6-6Zm0 20a3 3 0 0 0 3-2.6H9A3 3 0 0 0 12 22Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="head-meta">
|
||||||
|
<span class="badge" :class="`badge-${notification.priority.toLowerCase()}`">
|
||||||
|
{{ priorityLabel }}
|
||||||
|
</span>
|
||||||
|
<span v-if="isUrgent" class="live-dot">需立即查看</span>
|
||||||
|
<span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span>
|
||||||
|
<span class="head-sub">{{ subtitle }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="icon-btn" title="收起" @click="collapse">
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14">
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="m6 15 6-6 6 6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h3 class="title">{{ notification.title }}</h3>
|
||||||
|
<div class="body">
|
||||||
|
<p class="content">{{ notification.content }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
<span v-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
|
||||||
|
<span v-else class="more muted">来自班级小助手</span>
|
||||||
|
<span class="actions">
|
||||||
|
<button type="button" class="ghost-btn" @click="openApp">打开应用</button>
|
||||||
|
<button type="button" class="ghost-btn" @click="markRead">标为已读</button>
|
||||||
|
<button type="button" class="solid-btn" @click="dismiss">知道了</button>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 隐藏态占位(窗口不可见,仅保证 DOM 结构稳定) -->
|
||||||
|
<div v-else key="hidden" class="island-card hidden-placeholder"></div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-root {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 2px 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 三态卡片绝对定位,使交叉淡入淡出(并行 Transition)不会互相挤压 */
|
||||||
|
.island-card {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 24px;
|
||||||
|
color: #f7f8fa;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background:
|
||||||
|
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%);
|
||||||
|
box-shadow:
|
||||||
|
0 12px 28px rgba(0, 0, 0, 0.45),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
transition:
|
||||||
|
border-color 0.28s ease,
|
||||||
|
box-shadow 0.28s ease,
|
||||||
|
background 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 紧急通知:红色描边 + 独立光晕层呼吸
|
||||||
|
注意:无限动画必须放在内部 .glow 上,否则会让 Vue <Transition> 的
|
||||||
|
transitionend/animationend 判定失效,导致DOM 卡在离场状态。 */
|
||||||
|
.island-card.urgent {
|
||||||
|
border-color: rgba(255, 92, 92, 0.55);
|
||||||
|
box-shadow:
|
||||||
|
0 12px 30px rgba(0, 0, 0, 0.5),
|
||||||
|
0 0 0 1px rgba(255, 92, 92, 0.25),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: urgent-breath 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes urgent-breath {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255, 92, 92, 0.22),
|
||||||
|
0 0 18px rgba(255, 72, 72, 0.28);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255, 92, 92, 0.38),
|
||||||
|
0 0 34px rgba(255, 72, 72, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 胶囊态 */
|
||||||
|
|
||||||
|
.island-card.pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||||
|
background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-card.pill:hover {
|
||||||
|
transform: translateY(-1px) scale(1.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-card.pill:active {
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-placeholder {
|
||||||
|
opacity: 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 9px;
|
||||||
|
left: 14px;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4facfe;
|
||||||
|
box-shadow: 0 0 0 0 rgba(79, 172, 254, 0.6);
|
||||||
|
animation: dot-pulse 1.8s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-dot.urgent {
|
||||||
|
background: #ff5c5c;
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 92, 92, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dot-pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(79, 172, 254, 0.55);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 9px rgba(79, 172, 254, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(79, 172, 254, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #9fd0ff;
|
||||||
|
background: rgba(79, 172, 254, 0.14);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-card.pill.urgent .pill-icon {
|
||||||
|
color: #ffb3b3;
|
||||||
|
background: rgba(255, 92, 92, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-chevron {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 展开态 */
|
||||||
|
|
||||||
|
.island-card.expanded {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px 14px 12px;
|
||||||
|
border-radius: 24px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #9fd0ff;
|
||||||
|
background: rgba(79, 172, 254, 0.16);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-icon.urgent {
|
||||||
|
color: #ffc0c0;
|
||||||
|
background: rgba(255, 92, 92, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-urgent {
|
||||||
|
color: #fff;
|
||||||
|
background: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-high {
|
||||||
|
color: #3a2a00;
|
||||||
|
background: #ffc53d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-normal {
|
||||||
|
color: #06263f;
|
||||||
|
background: #6cc4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-low {
|
||||||
|
color: #23262b;
|
||||||
|
background: #c9ced6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #ffb3b3;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot::before {
|
||||||
|
content: '';
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
animation: dot-pulse 1.6s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot.muted {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-sub {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.42);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition:
|
||||||
|
background 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 680;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.72;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9fd0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more.muted {
|
||||||
|
color: rgba(255, 255, 255, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-btn,
|
||||||
|
.solid-btn {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11.5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition:
|
||||||
|
background 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-btn {
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.solid-btn {
|
||||||
|
color: #06263f;
|
||||||
|
background: #6cc4ff;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-card.expanded.urgent .solid-btn {
|
||||||
|
color: #fff;
|
||||||
|
background: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.solid-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 形变动画 */
|
||||||
|
|
||||||
|
.island-pop-enter-active {
|
||||||
|
transition:
|
||||||
|
opacity 0.22s ease,
|
||||||
|
transform 0.34s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-pop-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 0.16s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-pop-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.86) translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-pop-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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');
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { BrowserWindow, app, shell } from 'electron';
|
import { BrowserWindow, app, shell } from 'electron';
|
||||||
import { registerIpcHandlers } from './ipc.js';
|
import { registerIpcHandlers } from './ipc.js';
|
||||||
|
import { island, registerIslandIpc } from './island.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
import { runSmokeTest } from './smoke.js';
|
import { runSmokeTest } from './smoke.js';
|
||||||
|
|
||||||
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
|
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
|
||||||
@@ -75,10 +77,25 @@ if (!gotLock) {
|
|||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
registerIpcHandlers();
|
registerIpcHandlers();
|
||||||
|
registerIslandIpc();
|
||||||
|
|
||||||
mainWindow = createWindow();
|
mainWindow = createWindow();
|
||||||
await loadRenderer(mainWindow);
|
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) {
|
if (isSmokeTest) {
|
||||||
try {
|
try {
|
||||||
await runSmokeTest(mainWindow);
|
await runSmokeTest(mainWindow);
|
||||||
@@ -90,9 +107,14 @@ if (!gotLock) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
|
island.destroy();
|
||||||
if (process.platform !== 'darwin') app.quit();
|
if (process.platform !== 'darwin') app.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
island.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
app.on('activate', async () => {
|
app.on('activate', async () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
mainWindow = createWindow();
|
mainWindow = createWindow();
|
||||||
|
|||||||
@@ -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<IslandMode, { width: number; height: number }> = {
|
||||||
|
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<typeof setTimeout> | 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<void> {
|
||||||
|
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<IslandState>, 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 };
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,7 +2,9 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import type { IslandNotification } from '@classhelper/shared';
|
||||||
import { getDiagnostics } from './ipc.js';
|
import { getDiagnostics } from './ipc.js';
|
||||||
|
import { island } from './island.js';
|
||||||
|
|
||||||
interface SmokeResult {
|
interface SmokeResult {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -10,6 +12,177 @@ interface SmokeResult {
|
|||||||
detail: string;
|
detail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Recorder = (name: string, ok: boolean, detail?: string) => void;
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 灵动岛状态截图留档(便于人工复核外观与动画) */
|
||||||
|
async function captureIsland(name: string): Promise<void> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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 时触发,跑完自动退出)。
|
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
|
||||||
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
|
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
|
||||||
@@ -135,6 +308,9 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
|||||||
);
|
);
|
||||||
record('断网时回退到本地缓存', Boolean(offline?.ok), String(offline?.detail ?? ''));
|
record('断网时回退到本地缓存', Boolean(offline?.ok), String(offline?.detail ?? ''));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 灵动岛
|
||||||
|
await runIslandChecks(record, results);
|
||||||
|
|
||||||
// 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用)
|
// 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用)
|
||||||
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
|
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
|
||||||
const online = await win.webContents.executeJavaScript(
|
const online = await win.webContents.executeJavaScript(
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
import { IPC_CHANNELS } from '../main/ipc.js';
|
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 的前提下,
|
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
|
||||||
@@ -14,6 +21,14 @@ const bridge: DesktopBridge = {
|
|||||||
clearConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.clearConfig),
|
clearConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.clearConfig),
|
||||||
getAppInfo: (): Promise<DesktopAppInfo> => ipcRenderer.invoke(IPC_CHANNELS.appInfo),
|
getAppInfo: (): Promise<DesktopAppInfo> => ipcRenderer.invoke(IPC_CHANNELS.appInfo),
|
||||||
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openExternal, url),
|
openExternal: (url: string): Promise<boolean> => 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);
|
contextBridge.exposeInMainWorld('desktop', bridge);
|
||||||
@@ -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<IslandState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import { startIslandBridge, stopIslandBridge } from './island/bridge.js';
|
||||||
import { useAppStore } from './stores/app.js';
|
import { useAppStore } from './stores/app.js';
|
||||||
import { useAuthStore } from './stores/auth.js';
|
import { useAuthStore } from './stores/auth.js';
|
||||||
import { useRealtimeStore } from './stores/realtime.js';
|
import { useRealtimeStore } from './stores/realtime.js';
|
||||||
@@ -29,12 +30,19 @@ onMounted(async () => {
|
|||||||
|
|
||||||
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
|
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
|
||||||
|
|
||||||
|
// 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出)
|
||||||
|
if (auth.token) startIslandBridge();
|
||||||
|
|
||||||
if (auth.isAuthenticated) await router.replace('/schedule');
|
if (auth.isAuthenticated) await router.replace('/schedule');
|
||||||
else await router.replace('/login');
|
else await router.replace('/login');
|
||||||
} finally {
|
} finally {
|
||||||
booting.value = false;
|
booting.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopIslandBridge();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
SOCKET_EVENTS,
|
||||||
|
resolveClassStatus,
|
||||||
|
type ClassPeriod,
|
||||||
|
type NotificationDto,
|
||||||
|
type ScheduleDto,
|
||||||
|
} from '@classhelper/shared';
|
||||||
|
import { scheduleApi } from '../api/index.js';
|
||||||
|
import { fetchWithCache } from '../cache/index.js';
|
||||||
|
import { useAppStore } from '../stores/app.js';
|
||||||
|
import { useAuthStore } from '../stores/auth.js';
|
||||||
|
import { useRealtimeStore } from '../stores/realtime.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 灵动岛桥接(渲染进程侧)。
|
||||||
|
*
|
||||||
|
* 职责:把"当前是否在上课时间段"这件事算出来并同步给主进程 ——
|
||||||
|
* 主进程据此决定通知是"立即弹出"还是"上课期间隐藏、下课后自动弹出"。
|
||||||
|
*
|
||||||
|
* 判定完全基于本地缓存的课表(离线也能算),并每 10 秒复算一次,
|
||||||
|
* 因此下课后最多 10 秒内会自动弹出暂存的通知。
|
||||||
|
*/
|
||||||
|
const TICK_INTERVAL_MS = 10_000;
|
||||||
|
const REFRESH_INTERVAL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
let schedules: ScheduleDto[] = [];
|
||||||
|
let currentPeriod: ClassPeriod | null = null;
|
||||||
|
let inClass = false;
|
||||||
|
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** 供通知投递时读取:当前是否上课 + 本节课结束时间 */
|
||||||
|
export function getIslandClassContext(): { inClass: boolean; currentPeriodEnd: string | null } {
|
||||||
|
return { inClass, currentPeriodEnd: currentPeriod?.endTime ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIslandSchedules(): ScheduleDto[] {
|
||||||
|
return schedules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主动刷新课表(课表变更事件触发) */
|
||||||
|
export async function refreshIslandSchedules(): Promise<void> {
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
if (!auth.token) return;
|
||||||
|
|
||||||
|
const week = appStore.currentWeek;
|
||||||
|
const result = await fetchWithCache<ScheduleDto[]>(
|
||||||
|
'schedules',
|
||||||
|
`week:${week}`,
|
||||||
|
() => scheduleApi.list({ classId: auth.classId ?? undefined, week }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
schedules = result.data;
|
||||||
|
recomputeClassState(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeClassState(force = false): void {
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const status = resolveClassStatus(schedules, new Date(), appStore.currentWeek);
|
||||||
|
const nextInClass = status.inClass;
|
||||||
|
currentPeriod = status.current;
|
||||||
|
|
||||||
|
if (!force && nextInClass === inClass) return;
|
||||||
|
inClass = nextInClass;
|
||||||
|
|
||||||
|
window.desktop?.islandSetClassState({
|
||||||
|
inClass,
|
||||||
|
currentPeriodEnd: currentPeriod?.endTime ?? null,
|
||||||
|
week: appStore.currentWeek,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把校园通知投递到灵动岛(由实时通道收到 notification:new 时调用) */
|
||||||
|
export function pushNotificationToIsland(notification: NotificationDto): void {
|
||||||
|
window.desktop?.islandPush({
|
||||||
|
notification: {
|
||||||
|
id: notification.id,
|
||||||
|
title: notification.title,
|
||||||
|
content: notification.content,
|
||||||
|
priority: notification.priority,
|
||||||
|
createdAt: notification.createdAt,
|
||||||
|
courseName: currentPeriod?.courseName ?? null,
|
||||||
|
teacherName: notification.creator?.name ?? null,
|
||||||
|
},
|
||||||
|
context: getIslandClassContext(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startIslandBridge(): void {
|
||||||
|
if (tickTimer) return;
|
||||||
|
|
||||||
|
void refreshIslandSchedules().catch(() => undefined);
|
||||||
|
|
||||||
|
// 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏"
|
||||||
|
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, () => {
|
||||||
|
void refreshIslandSchedules().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS);
|
||||||
|
refreshTimer = setInterval(() => {
|
||||||
|
void refreshIslandSchedules().catch(() => undefined);
|
||||||
|
}, REFRESH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopIslandBridge(): void {
|
||||||
|
if (tickTimer) clearInterval(tickTimer);
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
|
tickTimer = null;
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type ServerToClientEvents,
|
type ServerToClientEvents,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { socketUrlOf } from '../config.js';
|
import { socketUrlOf } from '../config.js';
|
||||||
|
import { pushNotificationToIsland } from '../island/bridge.js';
|
||||||
|
|
||||||
type RealtimeEventName = keyof ServerToClientEvents;
|
type RealtimeEventName = keyof ServerToClientEvents;
|
||||||
type EventHandler = (payload: unknown) => void;
|
type EventHandler = (payload: unknown) => void;
|
||||||
@@ -70,6 +71,8 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
showClose: true,
|
showClose: true,
|
||||||
});
|
});
|
||||||
|
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
|
||||||
|
pushNotificationToIsland(payload);
|
||||||
dispatch(SOCKET_EVENTS.notificationNew, payload);
|
dispatch(SOCKET_EVENTS.notificationNew, payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+34
-1
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
|
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
|
||||||
* 渲染进程只依赖这个最小 API,不接触 Node.js。
|
* 渲染进程只依赖这些最小 API,不接触 Node.js。
|
||||||
*/
|
*/
|
||||||
|
import type { IslandNotification, IslandState } from '@classhelper/shared';
|
||||||
|
|
||||||
export interface DesktopStoredConfig {
|
export interface DesktopStoredConfig {
|
||||||
/** 后端服务地址,例如 http://127.0.0.1:4000 */
|
/** 后端服务地址,例如 http://127.0.0.1:4000 */
|
||||||
@@ -23,6 +24,21 @@ export interface DesktopAppInfo {
|
|||||||
smokeTest: boolean;
|
smokeTest: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 投递通知到灵动岛时附带的上下文 */
|
||||||
|
export interface IslandPushContext {
|
||||||
|
/** 当前是否处于上课时间段(上课期间非紧急通知会被暂存,下课后自动弹出) */
|
||||||
|
inClass?: boolean;
|
||||||
|
/** 当前这节课的结束时间(HH:mm) */
|
||||||
|
currentPeriodEnd?: string | null;
|
||||||
|
week?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IslandClassStatePayload {
|
||||||
|
inClass: boolean;
|
||||||
|
currentPeriodEnd?: string | null;
|
||||||
|
week?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopBridge {
|
export interface DesktopBridge {
|
||||||
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
||||||
smokeTest: boolean;
|
smokeTest: boolean;
|
||||||
@@ -31,12 +47,29 @@ export interface DesktopBridge {
|
|||||||
clearConfig(): Promise<DesktopStoredConfig>;
|
clearConfig(): Promise<DesktopStoredConfig>;
|
||||||
getAppInfo(): Promise<DesktopAppInfo>;
|
getAppInfo(): Promise<DesktopAppInfo>;
|
||||||
openExternal(url: string): Promise<boolean>;
|
openExternal(url: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/* 灵动岛 */
|
||||||
|
/** 把一条通知投递到灵动岛 */
|
||||||
|
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
|
||||||
|
/** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */
|
||||||
|
islandSetClassState(payload: IslandClassStatePayload): void;
|
||||||
|
/** 读取灵动岛当前状态(设置页/冒烟验证用) */
|
||||||
|
islandGetState(): Promise<IslandState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
|
||||||
|
export interface IslandRendererBridge {
|
||||||
|
onState(handler: (state: IslandState) => void): void;
|
||||||
|
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
|
||||||
|
getState(): Promise<IslandState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
/** 仅在 Electron 中注入;浏览器里为 undefined(渲染进程做了降级处理) */
|
/** 仅在 Electron 中注入;浏览器里为 undefined(渲染进程做了降级处理) */
|
||||||
desktop?: DesktopBridge;
|
desktop?: DesktopBridge;
|
||||||
|
/** 灵动岛窗口专用桥接 */
|
||||||
|
island?: IslandRendererBridge;
|
||||||
/** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */
|
/** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */
|
||||||
__classhelperSmoke__?: {
|
__classhelperSmoke__?: {
|
||||||
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
"src/renderer/**/*.ts",
|
"src/renderer/**/*.ts",
|
||||||
"src/renderer/**/*.d.ts",
|
"src/renderer/**/*.d.ts",
|
||||||
"src/renderer/**/*.vue",
|
"src/renderer/**/*.vue",
|
||||||
|
"src/island/**/*.ts",
|
||||||
|
"src/island/**/*.vue",
|
||||||
"src/types/**/*.d.ts"
|
"src/types/**/*.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,14 @@ export default defineConfig({
|
|||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
chunkSizeWarningLimit: 2000,
|
chunkSizeWarningLimit: 2000,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
// 主窗口
|
||||||
|
index: fileURLToPath(new URL('./index.html', import.meta.url)),
|
||||||
|
// 灵动岛(独立的置顶透明小窗口)
|
||||||
|
island: fileURLToPath(new URL('./island.html', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
|
|||||||
@@ -440,6 +440,114 @@ async function main() {
|
|||||||
`可见班级 ${dashboardForStudent.payload?.data?.classCount ?? 0} 个`,
|
`可见班级 ${dashboardForStudent.payload?.data?.classCount ?? 0} 个`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 6.2 上课时段与紧急通知二次确认
|
||||||
|
// 诊断参数 at:2026-09-07 是周一,种子数据里周一 08:00-08:45 有课
|
||||||
|
const mondayInClass = await api(`/schedules/current?classId=${classId}&at=2026-09-07T08:10:00`, {
|
||||||
|
token: teacherToken,
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'上课状态接口(at=周一 08:10 判定为上课)',
|
||||||
|
mondayInClass.status === 200 && mondayInClass.payload?.data?.inClass === true,
|
||||||
|
`course=${mondayInClass.payload?.data?.current?.courseName ?? '-'} week=${mondayInClass.payload?.data?.week ?? '-'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sundayFree = await api(`/schedules/current?classId=${classId}&at=2026-09-06T03:00:00`, {
|
||||||
|
token: teacherToken,
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'上课状态接口(at=周日 03:00 判定为不在上课)',
|
||||||
|
sundayFree.status === 200 && sundayFree.payload?.data?.inClass === false,
|
||||||
|
`inClass=${sundayFree.payload?.data?.inClass}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 造一节覆盖"此刻"的课,让紧急通知拦截用例与运行时刻无关
|
||||||
|
const nowDate = new Date();
|
||||||
|
const toHHmm = (minutes) => {
|
||||||
|
const clamped = Math.min(23 * 60 + 59, Math.max(0, minutes));
|
||||||
|
return `${String(Math.floor(clamped / 60)).padStart(2, '0')}:${String(clamped % 60).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
const nowMinutes = nowDate.getHours() * 60 + nowDate.getMinutes();
|
||||||
|
const probeStart = toHHmm(nowMinutes - 30);
|
||||||
|
const probeEnd = toHHmm(nowMinutes + 30);
|
||||||
|
const todayWeekday = nowDate.getDay() === 0 ? 7 : nowDate.getDay();
|
||||||
|
|
||||||
|
const probeSchedule = await api('/schedules', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: {
|
||||||
|
classId,
|
||||||
|
courseId,
|
||||||
|
dayOfWeek: todayWeekday,
|
||||||
|
startTime: probeStart,
|
||||||
|
endTime: probeEnd,
|
||||||
|
location: '联调验证教室',
|
||||||
|
weekStart: 1,
|
||||||
|
weekEnd: 30,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const probeScheduleId = probeSchedule.payload?.data?.id;
|
||||||
|
record('创建覆盖当前时刻的课表(构造上课场景)', probeSchedule.status === 201, `${probeStart}-${probeEnd}`);
|
||||||
|
|
||||||
|
const realTimeStatus = await api(`/schedules/current?classId=${classId}`, { token: teacherToken });
|
||||||
|
record(
|
||||||
|
'当前时刻被判定为上课时间段',
|
||||||
|
realTimeStatus.status === 200 && realTimeStatus.payload?.data?.inClass === true,
|
||||||
|
`course=${realTimeStatus.payload?.data?.current?.courseName ?? '-'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const blockedUrgent = await api('/notifications', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: {
|
||||||
|
classId,
|
||||||
|
title: '上课时段紧急通知(未确认)',
|
||||||
|
content: '这条应当被服务端拦截',
|
||||||
|
priority: 'URGENT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'上课时段发布紧急通知被拦截(409 URGENT_DURING_CLASS)',
|
||||||
|
blockedUrgent.status === 409 && blockedUrgent.payload?.code === 'URGENT_DURING_CLASS',
|
||||||
|
`status=${blockedUrgent.status} code=${blockedUrgent.payload?.code ?? '-'} 当前课程=${blockedUrgent.payload?.details?.current?.courseName ?? '-'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmedUrgent = await api('/notifications', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: {
|
||||||
|
classId,
|
||||||
|
title: `上课时段紧急通知(已二次确认)${Date.now()}`,
|
||||||
|
content: '教师已在上课警告中确认,允许发布',
|
||||||
|
priority: 'URGENT',
|
||||||
|
confirmDuringClass: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
record('二次确认后允许发布紧急通知', confirmedUrgent.status === 201, `status=${confirmedUrgent.status}`);
|
||||||
|
|
||||||
|
const normalDuringClass = await api('/notifications', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: {
|
||||||
|
classId,
|
||||||
|
title: `上课时段普通通知${Date.now()}`,
|
||||||
|
content: '普通通知不受上课时段限制',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'上课时段发布普通通知不受限制',
|
||||||
|
normalDuringClass.status === 201,
|
||||||
|
`status=${normalDuringClass.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (probeScheduleId) {
|
||||||
|
const removedProbe = await api(`/schedules/${probeScheduleId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
token: teacherToken,
|
||||||
|
});
|
||||||
|
record('清理探测课表', removedProbe.status === 200, `status=${removedProbe.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 7. 权限隔离
|
// ---------------------------------------------------------------- 7. 权限隔离
|
||||||
if (foreignClass) {
|
if (foreignClass) {
|
||||||
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken });
|
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken });
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export type ApiErrorCode =
|
|||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
| 'NOT_FOUND'
|
| 'NOT_FOUND'
|
||||||
| 'CONFLICT'
|
| 'CONFLICT'
|
||||||
|
| 'TOO_MANY_REQUESTS'
|
||||||
|
/** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */
|
||||||
|
| 'URGENT_DURING_CLASS'
|
||||||
| 'INTERNAL_ERROR';
|
| 'INTERNAL_ERROR';
|
||||||
|
|
||||||
/** 业务异常:中间件会把它转换成统一错误响应体 */
|
/** 业务异常:中间件会把它转换成统一错误响应体 */
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
|
import { MAX_TERM_WEEK, resolveCurrentWeek as resolveWeek } from '@classhelper/shared';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
const MS_PER_DAY = 86_400_000;
|
export { MAX_TERM_WEEK };
|
||||||
export const MAX_TERM_WEEK = 30;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 依据 TERM_START_DATE 计算当前教学周(第 1 周从该日期所在周一开始)。
|
* 当前教学周。
|
||||||
* 学生端与 Web 端"当前周次"默认值都走这里,保证三端口径一致。
|
* 具体计算逻辑在 @classhelper/shared(三端共用),这里只负责注入服务端的
|
||||||
|
* TERM_START_DATE 与周次上限,避免"周次"口径在服务端/客户端出现分叉。
|
||||||
*/
|
*/
|
||||||
export function resolveCurrentWeek(now: Date = new Date()): number {
|
export function resolveCurrentWeek(now: Date = new Date()): number {
|
||||||
if (!env.termStartDate) return 1;
|
return resolveWeek(env.termStartDate, now, MAX_TERM_WEEK);
|
||||||
const start = new Date(env.termStartDate);
|
|
||||||
if (Number.isNaN(start.getTime())) return 1;
|
|
||||||
|
|
||||||
// 归一到该日期所在周的周一
|
|
||||||
const weekday = start.getDay() === 0 ? 7 : start.getDay();
|
|
||||||
const monday = new Date(start.getTime() - (weekday - 1) * MS_PER_DAY);
|
|
||||||
monday.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const diffDays = Math.floor((now.getTime() - monday.getTime()) / MS_PER_DAY);
|
|
||||||
const week = Math.floor(diffDays / 7) + 1;
|
|
||||||
return Math.min(Math.max(week, 1), MAX_TERM_WEEK);
|
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,11 @@ export const createNotificationSchema = z.object({
|
|||||||
title: z.string().trim().min(1, '请输入通知标题').max(120),
|
title: z.string().trim().min(1, '请输入通知标题').max(120),
|
||||||
content: z.string().min(1, '请输入通知内容').max(5000),
|
content: z.string().min(1, '请输入通知内容').max(5000),
|
||||||
priority: notificationPrioritySchema.optional(),
|
priority: notificationPrioritySchema.optional(),
|
||||||
|
/**
|
||||||
|
* 上课时间段内发布"紧急"通知时必须显式确认(教师已在全屏警告中二次确认)。
|
||||||
|
* 未确认时服务端返回 409 URGENT_DURING_CLASS 及当前课程信息。
|
||||||
|
*/
|
||||||
|
confirmDuringClass: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const notificationScopeQuerySchema = z.object({
|
export const notificationScopeQuerySchema = z.object({
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
|
import { logger } from '../../lib/logger.js';
|
||||||
import { toNotificationDto } from '../../lib/mappers.js';
|
import { toNotificationDto } from '../../lib/mappers.js';
|
||||||
import { emitToClass } from '../../realtime/bus.js';
|
import { emitToClass } from '../../realtime/bus.js';
|
||||||
|
import { computeClassStatus } from '../schedules/schedules.service.js';
|
||||||
import type { CreateNotificationInput } from './notifications.schemas.js';
|
import type { CreateNotificationInput } from './notifications.schemas.js';
|
||||||
|
|
||||||
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
|
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
|
||||||
@@ -48,19 +50,42 @@ export async function listNotifications(
|
|||||||
return notifications.map((item) => toNotificationDto(item, { userId: user.sub, withStatus: isStaff }));
|
return notifications.map((item) => toNotificationDto(item, { userId: user.sub, withStatus: isStaff }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发布通知并实时广播到 class:{classId} 房间 */
|
/**
|
||||||
|
* 发布通知并实时广播到 class:{classId} 房间。
|
||||||
|
*
|
||||||
|
* 上课时段保护:若目标班级当前正在上课且优先级为"紧急",必须由前端二次确认
|
||||||
|
* (`confirmDuringClass: true`)才允许发布,否则返回 409 URGENT_DURING_CLASS,
|
||||||
|
* 由 Web 端弹出全屏警告并倒计时 3 秒。
|
||||||
|
*/
|
||||||
export async function createNotification(
|
export async function createNotification(
|
||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
input: CreateNotificationInput,
|
input: CreateNotificationInput,
|
||||||
): Promise<NotificationDto> {
|
): Promise<NotificationDto> {
|
||||||
await assertClassWritable(user, input.classId);
|
await assertClassWritable(user, input.classId);
|
||||||
|
|
||||||
|
const priority = input.priority ?? 'NORMAL';
|
||||||
|
if (priority === 'URGENT' && input.confirmDuringClass !== true) {
|
||||||
|
const status = await computeClassStatus(input.classId);
|
||||||
|
if (status.inClass) {
|
||||||
|
logger.warn(
|
||||||
|
`上课时段发布紧急通知被拦截:班级=${input.classId} 课程=${status.current?.courseName ?? '-'}(等待教师二次确认)`,
|
||||||
|
);
|
||||||
|
throw new ApiError(409, 'URGENT_DURING_CLASS', '现在为上课时间段,发布紧急通知会干扰上课,请再次确认', {
|
||||||
|
classId: input.classId,
|
||||||
|
current: status.current,
|
||||||
|
next: status.next,
|
||||||
|
week: status.week,
|
||||||
|
serverTime: status.serverTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const created = await prisma.notification.create({
|
const created = await prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
classId: input.classId,
|
classId: input.classId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
content: input.content,
|
content: input.content,
|
||||||
priority: input.priority ?? 'NORMAL',
|
priority,
|
||||||
createdBy: user.sub,
|
createdBy: user.sub,
|
||||||
},
|
},
|
||||||
include: { creator: creatorSelect, reads: true },
|
include: { creator: creatorSelect, reads: true },
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { resolveCurrentWeek } from '@classhelper/shared';
|
||||||
|
import { env } from '../../config/env.js';
|
||||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||||
import { idParamSchema } from '../../lib/schemas.js';
|
import { idParamSchema } from '../../lib/schemas.js';
|
||||||
import { resolveCurrentWeek } from '../../lib/term.js';
|
|
||||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import {
|
import {
|
||||||
|
classStatusQuerySchema,
|
||||||
createScheduleSchema,
|
createScheduleSchema,
|
||||||
gridQuerySchema,
|
gridQuerySchema,
|
||||||
listSchedulesQuerySchema,
|
listSchedulesQuerySchema,
|
||||||
@@ -31,11 +33,29 @@ router.get('/grid', validate({ query: gridQuerySchema }), async (req, res) => {
|
|||||||
const { classId, week } = validatedQuery<{ classId?: string; week?: number }>(req);
|
const { classId, week } = validatedQuery<{ classId?: string; week?: number }>(req);
|
||||||
sendOk(
|
sendOk(
|
||||||
res,
|
res,
|
||||||
await scheduleService.getScheduleGrid(user, { classId, week: week ?? resolveCurrentWeek() }),
|
await scheduleService.getScheduleGrid(user, {
|
||||||
|
classId,
|
||||||
|
week: week ?? resolveCurrentWeek(env.termStartDate),
|
||||||
|
}),
|
||||||
'获取周视图成功',
|
'获取周视图成功',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/schedules/current?classId=&at= - 班级当前上课状态。
|
||||||
|
* 客户端灵动岛(上课期间隐藏、下课后弹出)与 Web 端"上课时段发布紧急通知"
|
||||||
|
* 的全屏二次确认都基于它;`at` 为诊断/联调用时间覆盖。
|
||||||
|
*/
|
||||||
|
router.get('/current', validate({ query: classStatusQuerySchema }), async (req, res) => {
|
||||||
|
const user = getAuthUser(req);
|
||||||
|
const { classId, at } = validatedQuery<{ classId?: string; at?: string }>(req);
|
||||||
|
const status = await scheduleService.getClassStatus(user, {
|
||||||
|
classId,
|
||||||
|
at: at ? new Date(at) : undefined,
|
||||||
|
});
|
||||||
|
sendOk(res, status, status.inClass ? '当前处于上课时间段' : '当前不在上课时间段');
|
||||||
|
});
|
||||||
|
|
||||||
/** POST /api/schedules - 新增课表 */
|
/** POST /api/schedules - 新增课表 */
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ export const gridQuerySchema = z.object({
|
|||||||
week: weekNumberSchema.optional(),
|
week: weekNumberSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上课状态查询。
|
||||||
|
* `at` 为诊断/联调用的时间覆盖(例如排查"为什么现在不认为是上课"),
|
||||||
|
* 不传则按服务器当前时间判定。
|
||||||
|
*/
|
||||||
|
export const classStatusQuerySchema = z.object({
|
||||||
|
classId: z.string().min(1).optional(),
|
||||||
|
at: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.refine((value) => value === undefined || !Number.isNaN(new Date(value).getTime()), 'at 需为合法时间'),
|
||||||
|
});
|
||||||
|
|
||||||
export const createScheduleSchema = z
|
export const createScheduleSchema = z
|
||||||
.object({
|
.object({
|
||||||
classId: z.string().min(1, '请选择班级'),
|
classId: z.string().min(1, '请选择班级'),
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import {
|
import {
|
||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
buildScheduleWeekView,
|
buildScheduleWeekView,
|
||||||
|
resolveClassStatus,
|
||||||
|
resolveCurrentWeek,
|
||||||
|
type ClassStatusDto,
|
||||||
type ScheduleDto,
|
type ScheduleDto,
|
||||||
type ScheduleWeekView,
|
type ScheduleWeekView,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { assertClassWritable, classScopeWhere, resolveClassScope } from '../../lib/access.js';
|
import {
|
||||||
|
assertClassAccess,
|
||||||
|
assertClassWritable,
|
||||||
|
classScopeWhere,
|
||||||
|
isStudent,
|
||||||
|
requireStudentClassId,
|
||||||
|
resolveClassScope,
|
||||||
|
} from '../../lib/access.js';
|
||||||
|
import { env } from '../../config/env.js';
|
||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { toScheduleDto } from '../../lib/mappers.js';
|
import { toScheduleDto } from '../../lib/mappers.js';
|
||||||
import { resolveCurrentWeek } from '../../lib/term.js';
|
|
||||||
import { emitToClass } from '../../realtime/bus.js';
|
import { emitToClass } from '../../realtime/bus.js';
|
||||||
import type { CreateScheduleInput, UpdateScheduleInput } from './schedules.schemas.js';
|
import type { CreateScheduleInput, UpdateScheduleInput } from './schedules.schemas.js';
|
||||||
|
|
||||||
@@ -47,11 +57,50 @@ export async function getScheduleGrid(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
options: { classId?: string; week?: number },
|
options: { classId?: string; week?: number },
|
||||||
): Promise<ScheduleWeekView> {
|
): Promise<ScheduleWeekView> {
|
||||||
const week = options.week ?? resolveCurrentWeek();
|
const week = options.week ?? resolveCurrentWeek(env.termStartDate);
|
||||||
const items = await listSchedules(user, { classId: options.classId, week });
|
const items = await listSchedules(user, { classId: options.classId, week });
|
||||||
return buildScheduleWeekView(items, week);
|
return buildScheduleWeekView(items, week);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算指定班级在某时刻的上课状态(不做权限校验,供服务端内部复用)。
|
||||||
|
* @param at 判定时刻,默认当前时间(接口层可传入用于诊断/测试)
|
||||||
|
*/
|
||||||
|
export async function computeClassStatus(classId: string, at?: Date): Promise<ClassStatusDto> {
|
||||||
|
const now = at ?? new Date();
|
||||||
|
const week = resolveCurrentWeek(env.termStartDate, now);
|
||||||
|
const schedules = await prisma.schedule.findMany({
|
||||||
|
where: { classId },
|
||||||
|
include: { course: courseSelect },
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
});
|
||||||
|
const status = resolveClassStatus(schedules.map(toScheduleDto), now, week);
|
||||||
|
|
||||||
|
return {
|
||||||
|
classId,
|
||||||
|
inClass: status.inClass,
|
||||||
|
current: status.current,
|
||||||
|
next: status.next,
|
||||||
|
serverTime: now.toISOString(),
|
||||||
|
week,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级上课状态(带权限校验)。
|
||||||
|
* - 学生:固定为自己所在班级
|
||||||
|
* - 教师/管理员:需要显式 classId(前端发布通知时使用)
|
||||||
|
*/
|
||||||
|
export async function getClassStatus(
|
||||||
|
user: TokenPayload,
|
||||||
|
options: { classId?: string; at?: Date } = {},
|
||||||
|
): Promise<ClassStatusDto> {
|
||||||
|
const classId = isStudent(user) ? requireStudentClassId(user) : options.classId;
|
||||||
|
if (!classId) throw ApiError.badRequest('请指定班级(classId)');
|
||||||
|
await assertClassAccess(user, classId);
|
||||||
|
return computeClassStatus(classId, options.at);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise<ScheduleDto> {
|
export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise<ScheduleDto> {
|
||||||
await assertClassWritable(user, input.classId);
|
await assertClassWritable(user, input.classId);
|
||||||
await assertCourseInClass(input.courseId, input.classId);
|
await assertCourseInClass(input.courseId, input.classId);
|
||||||
|
|||||||
@@ -247,6 +247,11 @@ export interface CreateNotificationRequest {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
priority?: NotificationPriority;
|
priority?: NotificationPriority;
|
||||||
|
/**
|
||||||
|
* 上课时间段内发布"紧急"通知时必须显式置为 true(表示教师已确认会干扰上课)。
|
||||||
|
* 否则服务端返回 409 URGENT_DURING_CLASS,由前端弹出全屏二次确认。
|
||||||
|
*/
|
||||||
|
confirmDuringClass?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationQueryParams {
|
export interface NotificationQueryParams {
|
||||||
@@ -300,6 +305,69 @@ export interface GradeStats {
|
|||||||
distribution: Array<{ level: GradeLevel; count: number }>;
|
distribution: Array<{ level: GradeLevel; count: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ 上课时段(灵动岛 / 紧急通知确认共用) */
|
||||||
|
|
||||||
|
/** 一节课的时段信息 */
|
||||||
|
export interface ClassPeriod {
|
||||||
|
scheduleId: string;
|
||||||
|
courseId: string;
|
||||||
|
courseName: string;
|
||||||
|
/** HH:mm */
|
||||||
|
startTime: string;
|
||||||
|
/** HH:mm */
|
||||||
|
endTime: string;
|
||||||
|
location: string | null;
|
||||||
|
/** 1=周一 ... 7=周日 */
|
||||||
|
dayOfWeek: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班级当前上课状态 */
|
||||||
|
export interface ClassStatusDto {
|
||||||
|
classId: string;
|
||||||
|
/** 当前是否处于上课时间段 */
|
||||||
|
inClass: boolean;
|
||||||
|
/** 正在上的这节课(inClass=true 时非空) */
|
||||||
|
current: ClassPeriod | null;
|
||||||
|
/** 下一节课(用于"下课后自动弹出"的提示与倒计时) */
|
||||||
|
next: ClassPeriod | null;
|
||||||
|
/** 服务器判定所用的时间(客户端可据此校正本地时钟偏差) */
|
||||||
|
serverTime: string;
|
||||||
|
/** 判定所用的教学周 */
|
||||||
|
week: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ 灵动岛 */
|
||||||
|
|
||||||
|
/** 灵动岛显示状态 */
|
||||||
|
export type IslandMode = 'hidden' | 'pill' | 'expanded';
|
||||||
|
|
||||||
|
/** 投递到灵动岛的通知载荷(服务端 NotificationDto 的精简版) */
|
||||||
|
export interface IslandNotification {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
priority: NotificationPriority;
|
||||||
|
createdAt: string;
|
||||||
|
courseName?: string | null;
|
||||||
|
teacherName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 灵动岛当前状态(主进程持有,渲染进程与冒烟测试读取) */
|
||||||
|
export interface IslandState {
|
||||||
|
mode: IslandMode;
|
||||||
|
/** 正在展示的通知(展开态或胶囊态) */
|
||||||
|
active: IslandNotification | null;
|
||||||
|
/** 上课期间被暂存、待下课后弹出的通知 */
|
||||||
|
queued: IslandNotification[];
|
||||||
|
/** 当前是否处于上课时间段 */
|
||||||
|
inClass: boolean;
|
||||||
|
/** 当前课的结束时间(HH:mm),用于"下课后自动弹出" */
|
||||||
|
currentPeriodEnd: string | null;
|
||||||
|
/** 本轮展示原因:新消息 / 下课后补发 / 紧急插播 */
|
||||||
|
reason: 'new' | 'after-class' | 'urgent' | null;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ 仪表盘 */
|
/* ------------------------------------------------------------------ 仪表盘 */
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { WEEKDAY_LABELS, WEEKDAYS } from './constants.js';
|
import { WEEKDAY_LABELS, WEEKDAYS } from './constants.js';
|
||||||
import type { GradeLevel, ScheduleDto, ScheduleWeekView } from './types.js';
|
import type { ClassPeriod, GradeLevel, ScheduleDto, ScheduleWeekView } from './types.js';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ 日期时间 */
|
/* ------------------------------------------------------------------ 日期时间 */
|
||||||
|
|
||||||
@@ -207,3 +207,102 @@ export function truncate(text: string | null | undefined, max = 60): string {
|
|||||||
export function isValidTimeString(value: string): boolean {
|
export function isValidTimeString(value: string): boolean {
|
||||||
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
|
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ 上课时段判定 */
|
||||||
|
|
||||||
|
/** HH:mm -> 分钟数(非法输入返回 -1) */
|
||||||
|
export function timeToMinutes(value: string): number {
|
||||||
|
const [hour, minute] = value.split(':').map(Number);
|
||||||
|
if (hour === undefined || minute === undefined || Number.isNaN(hour) || Number.isNaN(minute)) return -1;
|
||||||
|
return hour * 60 + minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
export const MAX_TERM_WEEK = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依据学期起始日(第 1 教学周的周一)计算当前教学周。
|
||||||
|
* 服务端与三端共用同一份实现,避免"周次"口径不一致。
|
||||||
|
*/
|
||||||
|
export function resolveCurrentWeek(
|
||||||
|
termStartDate: string,
|
||||||
|
now: Date = new Date(),
|
||||||
|
maxWeek: number = MAX_TERM_WEEK,
|
||||||
|
): number {
|
||||||
|
if (!termStartDate) return 1;
|
||||||
|
const start = toDate(termStartDate);
|
||||||
|
if (Number.isNaN(start.getTime())) return 1;
|
||||||
|
|
||||||
|
const weekday = start.getDay() === 0 ? 7 : start.getDay();
|
||||||
|
const monday = new Date(start.getTime() - (weekday - 1) * MS_PER_DAY);
|
||||||
|
monday.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const diffDays = Math.floor((now.getTime() - monday.getTime()) / MS_PER_DAY);
|
||||||
|
const week = Math.floor(diffDays / 7) + 1;
|
||||||
|
return clamp(week, 1, maxWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把课表条目转换成时段信息 */
|
||||||
|
export function toClassPeriod(schedule: ScheduleDto): ClassPeriod {
|
||||||
|
return {
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
courseId: schedule.courseId,
|
||||||
|
courseName: schedule.course?.name ?? '课程',
|
||||||
|
startTime: schedule.startTime,
|
||||||
|
endTime: schedule.endTime,
|
||||||
|
location: schedule.location,
|
||||||
|
dayOfWeek: schedule.dayOfWeek,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断某一时刻是否处于上课时间段,并给出正在上的课与下一节课。
|
||||||
|
*
|
||||||
|
* @param schedules 该班级的课表(可含全部周次)
|
||||||
|
* @param now 判定时刻
|
||||||
|
* @param week 当前教学周;传 0 / 省略表示不按周次过滤(仅按星期与时间)
|
||||||
|
*/
|
||||||
|
export function resolveClassStatus(
|
||||||
|
schedules: ScheduleDto[],
|
||||||
|
now: Date = new Date(),
|
||||||
|
week?: number,
|
||||||
|
): { inClass: boolean; current: ClassPeriod | null; next: ClassPeriod | null } {
|
||||||
|
const weekday = weekdayOf(now);
|
||||||
|
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||||
|
|
||||||
|
const weekFiltered =
|
||||||
|
week && week > 0 ? schedules.filter((item) => isScheduleActiveInWeek(item, week)) : schedules;
|
||||||
|
const today = weekFiltered.filter((item) => item.dayOfWeek === weekday);
|
||||||
|
|
||||||
|
const current = today
|
||||||
|
.filter((item) => {
|
||||||
|
const start = timeToMinutes(item.startTime);
|
||||||
|
const end = timeToMinutes(item.endTime);
|
||||||
|
return start >= 0 && end > start && nowMinutes >= start && nowMinutes < end;
|
||||||
|
})
|
||||||
|
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime))[0];
|
||||||
|
|
||||||
|
const next = today
|
||||||
|
.filter((item) => timeToMinutes(item.startTime) > nowMinutes)
|
||||||
|
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime))[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
inClass: Boolean(current),
|
||||||
|
current: current ? toClassPeriod(current) : null,
|
||||||
|
next: next ? toClassPeriod(next) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时段的可读文案,例如 "08:00-08:45 · 数学 · 教学楼 A301" */
|
||||||
|
export function formatClassPeriod(period: ClassPeriod | null): string {
|
||||||
|
if (!period) return '';
|
||||||
|
const location = period.location ? ` · ${period.location}` : '';
|
||||||
|
return `${period.startTime}-${period.endTime} · ${period.courseName}${location}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 距离某个 HH:mm 还有多少分钟(已过则为负) */
|
||||||
|
export function minutesUntil(time: string, now: Date = new Date()): number {
|
||||||
|
const target = timeToMinutes(time);
|
||||||
|
if (target < 0) return 0;
|
||||||
|
return target - (now.getHours() * 60 + now.getMinutes());
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
API_PATHS,
|
API_PATHS,
|
||||||
type ClassDetailDto,
|
type ClassDetailDto,
|
||||||
type ClassDto,
|
type ClassDto,
|
||||||
|
type ClassStatusDto,
|
||||||
type CourseDto,
|
type CourseDto,
|
||||||
type CreateClassRequest,
|
type CreateClassRequest,
|
||||||
type CreateCourseRequest,
|
type CreateCourseRequest,
|
||||||
@@ -76,6 +77,9 @@ export const scheduleApi = {
|
|||||||
api.get(API_PATHS.schedules, params),
|
api.get(API_PATHS.schedules, params),
|
||||||
grid: (params: { classId?: string; week?: number }): Promise<ScheduleWeekView> =>
|
grid: (params: { classId?: string; week?: number }): Promise<ScheduleWeekView> =>
|
||||||
api.get(`${API_PATHS.schedules}/grid`, params),
|
api.get(`${API_PATHS.schedules}/grid`, params),
|
||||||
|
/** 班级当前上课状态:上课时段发布紧急通知时必须先二次确认 */
|
||||||
|
classStatus: (classId: string): Promise<ClassStatusDto> =>
|
||||||
|
api.get(`${API_PATHS.schedules}/current`, { classId }),
|
||||||
create: (payload: CreateScheduleRequest): Promise<ScheduleDto> => api.post(API_PATHS.schedules, payload),
|
create: (payload: CreateScheduleRequest): Promise<ScheduleDto> => api.post(API_PATHS.schedules, payload),
|
||||||
update: (id: string, payload: Partial<CreateScheduleRequest>): Promise<ScheduleDto> =>
|
update: (id: string, payload: Partial<CreateScheduleRequest>): Promise<ScheduleDto> =>
|
||||||
api.patch(`${API_PATHS.schedules}/${id}`, payload),
|
api.patch(`${API_PATHS.schedules}/${id}`, payload),
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { formatClassPeriod, type ClassPeriod } from '@classhelper/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上课时段发布紧急通知的全屏二次确认。
|
||||||
|
*
|
||||||
|
* 设计要点:
|
||||||
|
* - 全屏遮罩 + 强警示配色,明确告知"会干扰上课"
|
||||||
|
* - 确认按钮有 3 秒倒计时保护,倒计时结束前不可点击(避免误触连点)
|
||||||
|
* - 倒计时以圆环进度可视化,并给出每秒反馈
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
period: ClassPeriod | null;
|
||||||
|
pendingTitle: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'cancel'): void;
|
||||||
|
(event: 'confirm'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const COUNTDOWN_SECONDS = 3;
|
||||||
|
|
||||||
|
const remaining = ref(COUNTDOWN_SECONDS);
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const periodText = computed(() => formatClassPeriod(props.period));
|
||||||
|
const canConfirm = computed(() => remaining.value <= 0);
|
||||||
|
const progress = computed(() => ((COUNTDOWN_SECONDS - remaining.value) / COUNTDOWN_SECONDS) * 100);
|
||||||
|
|
||||||
|
function stopTimer(): void {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown(): void {
|
||||||
|
stopTimer();
|
||||||
|
remaining.value = COUNTDOWN_SECONDS;
|
||||||
|
timer = setInterval(() => {
|
||||||
|
remaining.value -= 1;
|
||||||
|
if (remaining.value <= 0) {
|
||||||
|
remaining.value = 0;
|
||||||
|
stopTimer();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) startCountdown();
|
||||||
|
else stopTimer();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(stopTimer);
|
||||||
|
|
||||||
|
function handleCancel(): void {
|
||||||
|
stopTimer();
|
||||||
|
emit('cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm(): void {
|
||||||
|
if (!canConfirm.value) return;
|
||||||
|
stopTimer();
|
||||||
|
emit('confirm');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="urgent-fade">
|
||||||
|
<div v-if="visible" class="urgent-mask" role="alertdialog" aria-modal="true">
|
||||||
|
<div class="urgent-card">
|
||||||
|
<div class="urgent-icon">
|
||||||
|
<el-icon :size="34"><WarningFilled /></el-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="urgent-title">现在为上课时间段</h2>
|
||||||
|
<p class="urgent-subtitle">
|
||||||
|
如果发布
|
||||||
|
<strong>紧急</strong>
|
||||||
|
通知,会干扰正在进行的课堂,请再次确认。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="period" class="urgent-period">
|
||||||
|
<span class="period-label">正在上课</span>
|
||||||
|
<span class="period-value">{{ periodText }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="urgent-preview">
|
||||||
|
<span class="preview-label">即将发布</span>
|
||||||
|
<span class="preview-title">{{ pendingTitle || '(无标题)' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="urgent-actions">
|
||||||
|
<el-button size="large" @click="handleCancel">取消发布</el-button>
|
||||||
|
|
||||||
|
<el-button size="large" type="danger" :disabled="!canConfirm" @click="handleConfirm">
|
||||||
|
<template v-if="canConfirm">确认发布紧急通知</template>
|
||||||
|
<template v-else>请等待 {{ remaining }} 秒…</template>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="urgent-progress">
|
||||||
|
<div v-if="!canConfirm" class="progress-ring">
|
||||||
|
<el-progress
|
||||||
|
type="circle"
|
||||||
|
:percentage="progress"
|
||||||
|
:width="46"
|
||||||
|
:stroke-width="4"
|
||||||
|
:show-text="false"
|
||||||
|
color="#f56c6c"
|
||||||
|
/>
|
||||||
|
<span class="ring-text">{{ remaining }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="progress-hint">
|
||||||
|
{{
|
||||||
|
canConfirm
|
||||||
|
? '倒计时结束,请谨慎确认是否真的需要立即打扰课堂'
|
||||||
|
: '正在为你预留 3 秒冷静时间,避免误触打扰课堂'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.urgent-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(20, 12, 12, 0.62);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-card {
|
||||||
|
width: 520px;
|
||||||
|
max-width: calc(100vw - 48px);
|
||||||
|
padding: 28px 30px 22px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 4px solid #f56c6c;
|
||||||
|
box-shadow: 0 24px 60px rgba(120, 20, 20, 0.35);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #f56c6c;
|
||||||
|
background: #fef0f0;
|
||||||
|
animation: urgent-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes urgent-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.45);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 14px rgba(245, 108, 108, 0);
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-subtitle {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-subtitle strong {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-period,
|
||||||
|
.urgent-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-period {
|
||||||
|
background: #fef0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-preview {
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-label,
|
||||||
|
.preview-label {
|
||||||
|
flex: 0 0 64px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-value {
|
||||||
|
color: #f56c6c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
color: #303133;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 18px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-text {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-fade-enter-active,
|
||||||
|
.urgent-fade-leave-active {
|
||||||
|
transition: opacity 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-fade-enter-active .urgent-card,
|
||||||
|
.urgent-fade-leave-active .urgent-card {
|
||||||
|
transition: transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-fade-enter-from,
|
||||||
|
.urgent-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-fade-enter-from .urgent-card,
|
||||||
|
.urgent-fade-leave-to .urgent-card {
|
||||||
|
transform: scale(0.92) translateY(10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,11 +10,13 @@ import {
|
|||||||
relativeTime,
|
relativeTime,
|
||||||
truncate,
|
truncate,
|
||||||
type ClassDto,
|
type ClassDto,
|
||||||
|
type ClassPeriod,
|
||||||
type NotificationDto,
|
type NotificationDto,
|
||||||
type NotificationPriority,
|
type NotificationPriority,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { classApi, notificationApi } from '@/api';
|
import { classApi, notificationApi, scheduleApi } from '@/api';
|
||||||
import { useRealtimeStore } from '@/stores/realtime';
|
import { useRealtimeStore } from '@/stores/realtime';
|
||||||
|
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
|
||||||
|
|
||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
@@ -64,6 +66,13 @@ const rules: FormRules = {
|
|||||||
content: [{ required: true, message: '请输入通知内容', trigger: 'blur' }],
|
content: [{ required: true, message: '请输入通知内容', trigger: 'blur' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 上课时段发布紧急通知的全屏二次确认状态 */
|
||||||
|
const urgentWarning = reactive({
|
||||||
|
visible: false,
|
||||||
|
period: null as ClassPeriod | null,
|
||||||
|
title: '',
|
||||||
|
});
|
||||||
|
|
||||||
function openCreate(): void {
|
function openCreate(): void {
|
||||||
form.classId = filter.classId || classes.value[0]?.id || '';
|
form.classId = filter.classId || classes.value[0]?.id || '';
|
||||||
form.title = '';
|
form.title = '';
|
||||||
@@ -76,15 +85,56 @@ async function submitForm(): Promise<void> {
|
|||||||
const valid = await formRef.value?.validate().catch(() => false);
|
const valid = await formRef.value?.validate().catch(() => false);
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
const created = await notificationApi.create({
|
// 上课时段发布紧急通知:先查询班级上课状态,命中则弹全屏二次确认(3 秒倒计时)
|
||||||
classId: form.classId,
|
if (form.priority === 'URGENT') {
|
||||||
title: form.title.trim(),
|
const status = await scheduleApi.classStatus(form.classId).catch(() => null);
|
||||||
content: form.content,
|
if (status?.inClass) {
|
||||||
priority: form.priority,
|
urgentWarning.period = status.current;
|
||||||
});
|
urgentWarning.title = form.title.trim();
|
||||||
ElMessage.success(`通知已发布(${created.title}),已推送给学生客户端`);
|
urgentWarning.visible = true;
|
||||||
formVisible.value = false;
|
return;
|
||||||
await loadNotifications();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await doPublish(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 真正提交;confirmDuringClass 为 true 表示教师已在上课警告中确认 */
|
||||||
|
async function doPublish(confirmDuringClass: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
const created = await notificationApi.create({
|
||||||
|
classId: form.classId,
|
||||||
|
title: form.title.trim(),
|
||||||
|
content: form.content,
|
||||||
|
priority: form.priority,
|
||||||
|
...(confirmDuringClass ? { confirmDuringClass: true } : {}),
|
||||||
|
});
|
||||||
|
ElMessage.success(`通知已发布(${created.title}),已推送给学生客户端`);
|
||||||
|
formVisible.value = false;
|
||||||
|
await loadNotifications();
|
||||||
|
} catch (error) {
|
||||||
|
// 兜底:并发场景下服务端仍可能拦截(例如刚好上课铃响),此时同样弹出警告
|
||||||
|
const response = (
|
||||||
|
error as {
|
||||||
|
response?: { status?: number; data?: { code?: string; details?: { current?: ClassPeriod | null } } };
|
||||||
|
}
|
||||||
|
).response;
|
||||||
|
if (response?.status === 409 && response.data?.code === 'URGENT_DURING_CLASS') {
|
||||||
|
urgentWarning.period = response.data.details?.current ?? null;
|
||||||
|
urgentWarning.title = form.title.trim();
|
||||||
|
urgentWarning.visible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUrgentConfirmed(): void {
|
||||||
|
urgentWarning.visible = false;
|
||||||
|
void doPublish(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUrgentCancelled(): void {
|
||||||
|
urgentWarning.visible = false;
|
||||||
|
ElMessage.info('已取消发布,紧急通知未发出');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeNotification(row: NotificationDto): Promise<void> {
|
async function removeNotification(row: NotificationDto): Promise<void> {
|
||||||
@@ -222,5 +272,14 @@ onUnmounted(() => {
|
|||||||
<el-button type="primary" @click="submitForm">立即发布</el-button>
|
<el-button type="primary" @click="submitForm">立即发布</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 上课时段发布紧急通知的全屏二次确认(3 秒倒计时) -->
|
||||||
|
<UrgentClassWarning
|
||||||
|
:visible="urgentWarning.visible"
|
||||||
|
:period="urgentWarning.period"
|
||||||
|
:pending-title="urgentWarning.title"
|
||||||
|
@cancel="onUrgentCancelled"
|
||||||
|
@confirm="onUrgentConfirmed"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
+42
-5
@@ -17,6 +17,7 @@ import crypto from 'node:crypto';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { resolveNodeRuntime } from './lib/node-runtime.mjs';
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const serverDir = path.join(root, 'packages', 'server');
|
const serverDir = path.join(root, 'packages', 'server');
|
||||||
@@ -47,13 +48,49 @@ const run = (command, args, options = {}) => {
|
|||||||
function ensureBuilds() {
|
function ensureBuilds() {
|
||||||
const serverEntry = path.join(serverDir, 'dist', 'index.js');
|
const serverEntry = path.join(serverDir, 'dist', 'index.js');
|
||||||
const webEntry = path.join(webDir, 'dist', 'index.html');
|
const webEntry = path.join(webDir, 'dist', 'index.html');
|
||||||
|
const sharedEntry = path.join(root, 'packages', 'shared', 'dist', 'index.js');
|
||||||
const missing = [];
|
const missing = [];
|
||||||
|
if (!fs.existsSync(sharedEntry)) missing.push('packages/shared/dist(请先 pnpm build:shared)');
|
||||||
if (!fs.existsSync(serverEntry))
|
if (!fs.existsSync(serverEntry))
|
||||||
missing.push('packages/server/dist(请先 pnpm --filter @classhelper/server build)');
|
missing.push('packages/server/dist(请先 pnpm --filter @classhelper/server build)');
|
||||||
if (!fs.existsSync(webEntry))
|
if (!fs.existsSync(webEntry))
|
||||||
missing.push('packages/web-admin/dist(请先 pnpm --filter @classhelper/web-admin build)');
|
missing.push('packages/web-admin/dist(请先 pnpm --filter @classhelper/web-admin build)');
|
||||||
if (missing.length > 0) {
|
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 管理端
|
// Web 管理端
|
||||||
copyRecursive(path.join(webDir, 'dist'), path.join(staging, 'web'));
|
copyRecursive(path.join(webDir, 'dist'), path.join(staging, 'web'));
|
||||||
|
|
||||||
// Node 运行时
|
// Node 运行时(必须是真正的 node.exe,不能用宿主 Electron 可执行文件)
|
||||||
const nodeExe = process.execPath;
|
const nodeRuntime = resolveNodeRuntime();
|
||||||
copyRecursive(nodeExe, path.join(staging, 'node.exe'));
|
copyRecursive(nodeRuntime.path, path.join(staging, 'node.exe'));
|
||||||
log(`内置 Node 运行时:${nodeExe}`);
|
log(`内置 Node 运行时:${nodeRuntime.path}(${nodeRuntime.version})`);
|
||||||
|
|
||||||
// 数据目录:强制清空,避免把打包机上测试用的数据库带进安装包
|
// 数据目录:强制清空,避免把打包机上测试用的数据库带进安装包
|
||||||
fs.rmSync(path.join(staging, 'data'), { recursive: true, force: true });
|
fs.rmSync(path.join(staging, 'data'), { recursive: true, force: true });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { spawn } from 'node:child_process';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { resolveElectronEnv, resolveElectronExecutable } from './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 electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron');
|
const electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron');
|
||||||
@@ -15,15 +16,12 @@ if (!fs.existsSync(electronDir)) {
|
|||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathFile = path.join(electronDir, 'path.txt');
|
const executable = resolveElectronExecutable(electronDir);
|
||||||
const executable = fs.existsSync(pathFile)
|
|
||||||
? path.join(electronDir, 'dist', fs.readFileSync(pathFile, 'utf8').trim())
|
|
||||||
: path.join(electronDir, 'dist', 'electron.exe');
|
|
||||||
|
|
||||||
const child = spawn(executable, [path.join(root, 'scripts', 'icons', 'render.cjs')], {
|
const child = spawn(executable, [path.join(root, 'scripts', 'icons', 'render.cjs')], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: process.env,
|
env: resolveElectronEnv(),
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('exit', (code) => process.exit(code ?? 1));
|
child.on('exit', (code) => process.exit(code ?? 1));
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 的完整路径。',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+132
-2
@@ -7,6 +7,10 @@
|
|||||||
* 3. 进入主布局
|
* 3. 进入主布局
|
||||||
* 4. **侧边栏 7 个菜单逐一点击**:断言路由路径变化 + 页面标题渲染
|
* 4. **侧边栏 7 个菜单逐一点击**:断言路由路径变化 + 页面标题渲染
|
||||||
* —— 这是"点击左侧边栏没反应"的回归测试
|
* —— 这是"点击左侧边栏没反应"的回归测试
|
||||||
|
* 5. **上课时段发布紧急通知**:全屏警告 + 3 秒倒计时 + 确认后才真正发布
|
||||||
|
* —— 上课时段由 run.mjs 的探针(今天 00:00-23:59 的课表)真实制造
|
||||||
|
* 6. 前端路由深链直接访问
|
||||||
|
* 7. PWA manifest + Service Worker(浏览器可安装为应用)
|
||||||
*
|
*
|
||||||
* 用法(需要后端已启动,且 Web 管理端由其托管):
|
* 用法(需要后端已启动,且 Web 管理端由其托管):
|
||||||
* UI_SMOKE_URL=http://127.0.0.1:4000/ node scripts/ui-smoke/run.mjs
|
* 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(';')}` : ''}`,
|
`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`);
|
await win.loadURL(`${TARGET_URL.replace(/\/$/, '')}/grades`);
|
||||||
const deepLinkOk = await waitFor(
|
const deepLinkOk = await waitFor(
|
||||||
win,
|
win,
|
||||||
@@ -182,7 +312,7 @@ async function main() {
|
|||||||
`path=${await win.webContents.executeJavaScript('location.pathname')}`,
|
`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 manifestInfo = await win.webContents.executeJavaScript(`(async () => {
|
||||||
const link = document.querySelector('link[rel="manifest"]');
|
const link = document.querySelector('link[rel="manifest"]');
|
||||||
|
|||||||
@@ -13,11 +13,14 @@
|
|||||||
* UI_SMOKE_USER 默认 teacher1
|
* UI_SMOKE_USER 默认 teacher1
|
||||||
* UI_SMOKE_PASS 默认 teacher123
|
* UI_SMOKE_PASS 默认 teacher123
|
||||||
* UI_SMOKE_RESULT 结果 JSON 输出路径(可选)
|
* UI_SMOKE_RESULT 结果 JSON 输出路径(可选)
|
||||||
|
* UI_SMOKE_SKIP_PROBE=1 跳过"上课时段探针"(只跑基础 UI 校验)
|
||||||
*/
|
*/
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
const electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron');
|
const electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron');
|
||||||
@@ -28,11 +31,7 @@ if (!fs.existsSync(electronDir)) {
|
|||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathFile = path.join(electronDir, 'path.txt');
|
const executable = resolveElectronExecutable(electronDir);
|
||||||
const executable = fs.existsSync(pathFile)
|
|
||||||
? path.join(electronDir, 'dist', fs.readFileSync(pathFile, 'utf8').trim())
|
|
||||||
: path.join(electronDir, 'dist', 'electron.exe');
|
|
||||||
|
|
||||||
if (!fs.existsSync(executable)) {
|
if (!fs.existsSync(executable)) {
|
||||||
console.error(`[ui-smoke] Electron 可执行文件不存在:${executable}`);
|
console.error(`[ui-smoke] Electron 可执行文件不存在:${executable}`);
|
||||||
console.error(' 请执行:node packages/desktop-client/node_modules/electron/install.js');
|
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] 目标:${target}`);
|
||||||
console.log(`[ui-smoke] Electron:${executable}`);
|
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], {
|
const child = spawn(executable, [entry], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: {
|
env: resolveElectronEnv({
|
||||||
...process.env,
|
|
||||||
UI_SMOKE_URL: target,
|
UI_SMOKE_URL: target,
|
||||||
UI_SMOKE_RESULT: resultFile,
|
UI_SMOKE_RESULT: resultFile,
|
||||||
UI_SMOKE_PROFILE: profileDir,
|
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) {
|
if (code === 0) {
|
||||||
console.log(`[ui-smoke] 全部通过,结果文件:${resultFile}`);
|
console.log(`[ui-smoke] 全部通过,结果文件:${resultFile}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user