19 Commits
Author SHA1 Message Date
LoveChengke 2612a912ef chore(release): 重打包并提交 1.4.0 交付物 2026-10-18 13:46:09 +08:00
LoveChengke c7f215a09c feat(courseware,docs): 完善课件传输与岛编辑并统一全仓文档
- 增加课件上传、班级可见范围、设备补收与传输进度
- 整合自定义灵动岛编辑器和相关客户端交互
- 同步三端契约、数据库迁移、部署配置与隔离回归
- 按源码统一文档与官网说明,移除过时任务书和审查记录
- 保持产品版本为 1.4.0
2026-10-18 11:53:37 +08:00
LoveChengke 536379036a feat(ui): 补齐课程提醒进度并内嵌编辑器与重构后台导航 2026-10-17 21:21:28 +08:00
LoveChengke 3379eb975c feat(island): 对齐 ClassIsland 课表提醒并恢复开合动画 2026-10-17 16:43:36 +08:00
LoveChengke e84e43544a fix(island): 合并常驻与自定义岛并统一经典通知和即时课表同步 2026-10-17 15:56:44 +08:00
LoveChengke 05848570d1 fix(island): 重写常驻岛自适应界面与 ClassIsland 动画 2026-10-17 15:12:02 +08:00
LoveChengke 7f7d3925b2 feat(island): 增加常驻内容并沿用 ClassIsland 尺寸与避让设置 2026-10-17 14:29:25 +08:00
LoveChengke c83b03d8e6 fix(island): 将组件与提醒合入岛体并重写布局设置 2026-10-17 13:45:26 +08:00
LoveChengke ead66f0b93 test(docs): 补齐隔离验收与功能实施记录 2026-10-17 13:04:22 +08:00
LoveChengke 57069a9a75 fix(grades,term): 封闭实时成绩泄露并完善并发升级审计 2026-10-17 12:55:40 +08:00
LoveChengke b990701cde feat(client): 实现可编辑岛组件方案与连接更新状态反馈 2026-10-17 12:55:39 +08:00
LoveChengke 35b252249d feat(web): 增加日课表编辑与学期设备管理界面 2026-10-17 09:38:29 +08:00
LoveChengke 4dc824c283 feat(server): 统一课表解析并完善权限范围与年度升级 2026-10-17 09:34:23 +08:00
LoveChengke 66ef878a71 feat(shared,prisma): 建立日课表与岛组件契约及数据范围模型 2026-10-17 09:33:32 +08:00
LoveChengke c0ebf88138 chore(release): 1.3.3 发布并退役旧联动 2026-10-17 03:06:46 +08:00
LoveChengke b7cdc7c9ea fix(clients,client,web): 远控指令的两个修复 —— 执行失败不再谎报成功 + 「上次指令」让"点了没反应"可见(1.3.2)
背景:用户报「下发锁屏等等指令不生效」。先把服务端那条链路单独验了一遍
(模拟客户端:登记 → 下发 → 心跳带回 → 设备列表可见 → 回执 → 清除),**整条是通的**;
真正的问题在两处。

客户端:执行失败会谎报成功
- `spawn` 找不到可执行文件 / 没权限时**不抛异常**,而是在下一个 tick 发一个异步 `error` 事件 ——
  只包 `try/catch` 抓不到,代码一路走到 `return { ok: true }`,回执写着「已下达系统命令」,
  Web 端看起来一切正常而机器纹丝不动
- 现在等一个 200ms 的窗口确认没有立刻报错,失败如实回执(`client-agent.ts` 的 `executeCommand`)

Web 端:点了没生效,界面上什么都不说
- 一条命令被**取消 / 过期**之后就从 `pendingCommand` 里消失,页面留白 ——
  而最常见的两种"没反应"恰恰是「本机没人确认(客户端 60 秒自动按取消)」与「下达时机器离线」
- `GET /clients/devices` 增加 `lastCommand`(最近一条,不论状态);
  「客户端」页的设备表多一列「上次指令」,由 `commandStateText()` 翻成一句人话
  (已执行 / 本机用户取消 / 已过期 —— 下达时机器不在线 / 待执行)
- 下发成功的提示补上「60 秒内没人点确认就按取消处理」

文档
- AGENTS.md §5 第 69 条记下这两条,并写明排查同类反馈的顺序:**先看那台机器的客户端版本是否 ≥ 1.3.0**
  (远控命令的处理 `client-agent.ts` 是 1.3.0 才有的,旧客户端连心跳都不发)
- docs/reference.md 4.15 的表格补上「二次确认」的准确口径与新增的「上次指令」列

验收:`lint` / `typecheck` / `check:icons` / `docs:check` 全绿;
后端端到端 **247/247**、联动链路 **44/44**、插件静态契约 **76/76**、
Web 真实点击 **34/34**、客户端冒烟 **90/90**;
远控链路另用模拟客户端跑通,并确认新增的「上次指令」在待执行与已回执两种状态下都显示正确。
2026-10-11 17:00:11 +08:00
LoveChengke 227117ccb3 fix(integrations,grades,clients,web,client,plugin): 课表同步改为按需(修「课表被反复删改」)+ 客户端页合并 + 成绩排名(1.3.1)
线上问题的根因
- ClassIsland 插件默认每 15 秒上报一次,每次都带全量课表,上报完还顺手把课表镜像回教室
  (本机 `UploadSchedule` / `MirrorSchedule` 两个开关默认都是开)。于是教室的课表每 15 秒被重写、
  服务端也每 15 秒 merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"。
- 现在两个方向都只由 Web 端的一次性待办触发,插件侧那两个开关整体删除;
  定时上报只报**运行状态**(当前上什么课),不带课表。

课表同步(#3)
- 新增 `IntegrationDevice.requestMirrorSchedule` + `POST /integrations/devices/:id/mirror-schedule`
  (「下发课表」),与既有的 `request-schedule`(「获取课表」)对称;拉取成功即清标记
- 插件 `ClassPlanWriter.Apply(..., force: true)`:停用同一天同一单双周相位下冲突的旧课表
  —— **只停用不删除**,内容原样留在档案里,老师随时能重新启用
- 插件设置页删掉「自动上报课表」「镜像回 ClassIsland」两个开关,改为一句说明

「客户端」页(#4)
- 把「ClassIsland 联动」与「客户端设备」合成一页 `ClientHubView.vue`:一个班一张卡片,
  两条链路状态放一起,勾选可批量下发课表 / 更新,点卡片进详情做逐台操作
- 「下发提醒」也搬了过来;入口权限用 `classisland:notify`(默认 ADMIN + TEACHER),
  页内按钮再各自按 device:manage / remote:control / client:update-push 显示或禁用
- 两个旧页面 `meta.hidden: true` **保留路由**(安装包缓存管理、联动步骤说明仍在),
  从详情抽屉里给链接 —— 不删功能

成绩(#5/#6/#7)
- 新增 `Exam`(班级 + 考试名 + `countInTotal`)与迁移;`GET/PUT /api/grades/exams`、
  `GET /api/grades/ranking`(两条都**不挂权限键**,班级端的有效权限是空集,边界在 service 里)
- 排名口径写在服务端:**同一学生同一科目只取最高分**(补录 / 重考会多行,不去重等于"总分多一科")、
  并列同名次(1、2、2、4)
- 「成绩录入」页加「考试口径」开关(按班级 + 考试名记住);客户端「成绩」页新增总分榜 + 单科榜
- `updateStudentGradeQuery` 改成只有教师 / 管理员能改 —— 原先班级端自己也能改
- 客户端的成绩查询开关改为只读(只显示服务端下发的状态)

其他(#2/#5/#8/#9)
- 全校学期开始日期改成 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改、立刻重算周次
  (原先这一级只能来自 `.env`)
- 大模型助手发紧急通知补上「上课时段 3 秒全屏警告」:判据抽成 `composables/useClassInSession.ts`,
  与手动发布页共用
- 客户端去掉「关怀模式」「自动更新」两个开关(教室机器是统一投放的终端)
- 修掉「选中设置二级菜单后收起分组、高亮块仍留在原地」:底片只在路由 / 滚动 / 缩放时重测,
   而收起分组动不了 `.aside` 的盒子 —— 补上"子项被 overflow:hidden 裁掉"的判据 + 观察分组 `<ul>` 高度
- `src/types/express.d.ts` 里 `req.device` 改成引用 `DeviceContext`:原先手抄了一份结构,
  往接口加字段时两边就漂了,而报错会落在 `getDevice()` 的 `return device;` 上,看不出真正原因

验收:`lint` / `typecheck` / `check:icons` / `docs:check` 全绿;
后端端到端 **247/247**、联动链路 **44/44**、插件静态契约 **76/76**、
Web 真实点击 **34/34**(含新增的「客户端」页用例)、客户端冒烟 **90/90**。
2026-10-11 16:21:38 +08:00
LoveChengke 3236d3ad83 fix(deploy): install.sh --version 1.3.0 下载 404 —— 拼地址时 tag 少了 v 前缀
Release 的 tag 是 `v1.3.0`,而 `--version` 的用法一向不带 v(用法提示与文档都写 `--version 1.1.2`),
GitHub 的 `/releases/download/<tag>/` 又是**按 tag 精确匹配**的(`releases/tags/1.3.0` 直接 404),
于是 install.sh 直接把 `VERSION` 拿去拼出来的地址必然 404:

    [classhelper] 下载:.../releases/download/1.3.0/classhelper-server-linux-x64-1.3.0.tar.gz
    curl: (22) The requested URL returned error: 404

同一个功能上,`classhelper` 的 `resolve_download()` 一直是 `tag="v${want#v}"`,install.sh 漏了这句 ——
所以 `classhelper upgrade --version 1.3.0` 正常、`bash install.sh --version 1.3.0` 却 404,
两处只在 `deploy/` 内部对不齐。现在两边都归一化。

`VERSION` 本身**保持不带 v**:`tag` 是另一个局部变量,装完写进安装目录的 VERSION 文件用的仍是不带 v
的裸版本号,别把它一起改了。

实测(把 install.sh 里的 `resolve_package_url()` 原样抽出来单独跑,不手抄):

| VERSION 传参  | 修复前                            | 修复后                               |
| ------------- | --------------------------------- | ------------------------------------ |
| `1.3.0`       | `.../download/1.3.0/...` → 404    | `.../download/v1.3.0/...`            |
| `v1.3.0`      | `.../download/v1.3.0/...`         | `.../download/v1.3.0/...`            |
| 留空(latest) | `.../download/v1.3.0/...`         | `.../download/v1.3.0/...`            |

`bash -n` 四个脚本(install.sh / classhelper / verify-linux.sh / install-linux.sh)全过。
本机 curl 到 github.com 超时(见 §7 第 1 条),所以**真正下载要走服务器实测**。

AGENTS.md §5 第 59 条补 ⑦ 记下这条约定。
2026-10-11 14:28:07 +08:00
LoveChengke 0b9a321bc9 docs(release,website,readme): 1.3.0 的更新日志、官网功能块与 README 清单,并把「每次发版四件套」写进 AGENTS.md
- `releases/ReleaseNotes-1.3.0.md`:1.3.0 的 Release 正文草稿(一句话定位 / 七个分节 /
  ⚠️ 升级须知 / 验收 / 产物表)。验收表只写本轮真跑过的静态门禁,运行时验收没跑就直说没跑
- `website/index.html`:老师与管理员加 #权限 / #教室机器 / #大模型助手 / #批量发布 四块,
  教室机器与学生机加 #自愈 一块(11 → 16 块,实测渲染正常)
- `README.md`:功能清单补上权限组、客户端设备、大模型助手、批量发布、系统设置、关怀模式、
  崩溃自愈与离线补弹,以及「客户端更新不依赖外网」
- `AGENTS.md` §4.4 新增「每次发版必须一起交付的四样东西」(更新日志 / Linux 包 / 文档 / 官网与 README),
  并记下「CI 按 tag 的 ref 检出源码」这个坑 —— 发完版再改构建脚本时**光推 master 不够**,
  必须把 tag 挪到修复后的提交;第 ⑦ 步的示例也改成用 `--notes-file`
- `docs/dev/release.md`:同一份四件套清单(面向人)+ 第 7 步改用 `--notes-file` + tag 那个坑

`pnpm docs:check` 0 warning 0 error。
2026-10-11 14:23:29 +08:00
300 changed files with 20497 additions and 17890 deletions

No files matched your search

+3 -1
View File
@@ -39,7 +39,7 @@ eslint-report.json
.DS_Store
Thumbs.db
# 参考用的第三方源码包(设计 token 已提取到 docs/winisland-design-tokens.md)
# 设计参考压缩包(令牌见 docs/dev/winisland-design-tokens.md)
WinIsland-master.zip
releases/
@@ -51,3 +51,5 @@ packages/server/prisma/database-settings.json
# 文档站构建产物(pnpm docs:build 生成,由 GitHub Pages 发布;见 docs/)
docs/_site/
packages/data/courseware/
+121 -1641
View File
File diff suppressed because it is too large. Load diff
+77 -74
View File
@@ -17,26 +17,24 @@
[![仓库大小](https://img.shields.io/github/repo-size/LoveChengke/ClassHelper?style=flat-square&color=3cb371)](https://github.com/LoveChengke/ClassHelper)
ClassHelper是给中小学班级用的一套信息管理系统。<br/>
**老师发一条通知,教室那台机器上的灵动岛和 [ClassIsland](https://classisland.tech) 会同时弹出来**;<br/>
**老师发一条通知,教室客户端与灵动岛会实时提醒**;<br/>
学生打开客户端就能看到当天的课表和作业。
#### [🚀 软件下载](https://github.com/LoveChengke/ClassHelper/releases/latest) | [📚 使用文档](docs/index.md) | [🧭 完整参考](docs/reference.md) | [💬 反馈问题](https://github.com/LoveChengke/ClassHelper/issues)
#### [🚀 软件下载](https://github.com/LoveChengke/ClassHelper/releases/latest) | [📚 使用文档](docs/index.md) | [🧭 功能与源码索引](docs/reference.md) | [💬 反馈问题](https://github.com/LoveChengke/ClassHelper/issues)
</div>
## 它由什么组成
一份代码,四个交付物:
一份代码,三个交付物:
| 装在哪 | 是什么 | 谁在用 |
| -------------------------- | ---------------------------------------------------------------------------------- | ---------------------- |
| 教师的电脑 / 学校服务器 | **服务端**(Express + Prisma + Socket.IO),同时托管 Web 管理端 | 其它三端 |
| 浏览器(手机也行) | **Web 管理端**(Vue 3 + Element Plus,PWA 可安装) | 老师、班主任、管理员 |
| 教室那台机器 / 学生电脑 | **桌面客户端**(Electron + Vue 3),含「灵动岛」浮窗、托盘、离线缓存 | 教室大屏、学生 |
| 教室机器上的 ClassIsland | **联动插件**(.NET 8 / C#),上报课表与上课状态、把老师的提醒弹到大屏上 | 教室机器 |
| 装在哪 | 是什么 | 谁在用 |
| ----------------------- | -------------------------------------------------------------------- | -------------------- |
| 教师的电脑 / 学校服务器 | **服务端**(Express + Prisma + Socket.IO),同时托管 Web 管理端 | 管理端与客户端 |
| 浏览器(手机也行) | **Web 管理端**(Vue 3 + Element Plus,PWA 可安装) | 老师、班主任、管理员 |
| 教室那台机器 / 学生电脑 | **桌面客户端**(Electron + Vue 3),含「灵动岛」浮窗、托盘、离线缓存 | 教室大屏、学生 |
老师在 Web 端发布内容 → 服务端落库并按班级房间广播 → 客户端与灵动岛实时更新;
落到 ClassIsland 上的那部分走设备令牌的独立通道,教室网络不好也不影响。
老师在 Web 端发布内容 → 服务端落库并按班级房间广播 → 客户端与灵动岛实时更新。
## 功能
@@ -45,13 +43,18 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
- [x] 仪表盘:本班人数、课程、课表、作业、通知、成绩一屏概览
- [x] 班级管理:建班自动生成班级码与班级密码,随时更换班主任、按科目配置各科任课老师
- [x] 学生 / 教师管理:单个录入或导入名单(xlsx / xls / csv / tsv)
- [x] 课表管理:按周排课、单双周,可直接从 ClassIsland 档案导入课表与节次时间
- [x] 课表管理:日课表、共享作息、1–9 周轮换、群组与临时换课;支持完整 ClassIsland 档案离线导入
- [x] 作业发布:按「所属日期」归类,支持附件与未交名单
- [x] 通知发布:三档优先级;在**上课时段**发紧急通知会被拦下来并要求二次确认
- [x] 通知发布:四档优先级;在**上课时段**发紧急通知会被拦下来并要求二次确认
- [x] 叫人:点名某位同学到办公室,分「普通」与「紧急」两级
- [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计
- [x] ClassIsland 联动:签发设备令牌、下发提醒、把课表镜像回教室大屏
- [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计;单科排名自动出,可选「各科相加排总分榜」
- [x] 客户端管理:查看设备在线状态与版本,远控关机、重启、注销与锁屏;推送更新可选立即、放学后或空闲时安装
- [x] 数据库管理:连接体检、备份与恢复、SQLite ⇄ MySQL 一键切换
- [x] 权限组:按功能键建组分配账号,支持岗位与年级范围 —— 默认组可自定义,没分组的按角色默认组生效,分组后只按组的并集算
- [x] 大模型助手:一句话解析成待确认的作业 / 通知 / 叫人,点确认才落库
- [x] 通知与作业批量发布:勾多个班一次发完,个别班失败不影响其余班级
- [x] 系统设置:开发者模式、运行诊断、开机自启开关(仅安装形态)
- [x] 关怀模式:字号与控件整体放大
- [x] 手机小屏适配,可「添加到主屏幕」当应用用
### 教室里的桌面客户端
@@ -60,26 +63,21 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
- [x] 课表:「今天」时间轴(大时钟 + 正在上 / 下一节 + 距下课倒计时)与周视图
- [x] 作业看板:按科目分卡、可全屏铺满、字号可调,也能切回列表
- [x] 通知中心:未读红点、优先级标签、点击自动标为已读
- [x] 成绩总览:全班成绩表格 + 柱状图 + 等级分布
- [x] 成绩总览:开启个人查询时显示本班成绩与排名;关闭后仅展示服务端集体分析,保留隐私限制
- [x] 教室机器可直接录入作业、勾选未交名单
- [x] 离线优先:断网回退 IndexedDB 缓存,恢复连接后自动同步
- [x] 断网期间错过的通知,重新上线时补弹一次;渲染进程崩了会安静地自己重建窗口
- [x] 浅色 / 深色主题,跟随系统
### 灵动岛(桌面通知浮窗)
- [x] 通知、新作业、叫人都会上岛:先是胶囊,点开是详情卡
- [x] 通知、新作业、叫人都会上岛:经典模式使用胶囊与详情卡,自定义模式使用常驻内容与铃铛
- [x] 多条消息展开为竖排列表,按重要程度排序,放不下时提示去应用内看
- [x] **上课时段不打扰**:普通通知暂存到下课再弹;紧急通知与叫人立刻弹
- [x] **经典模式上课时段不打扰**:普通通知暂存,普通叫人可点开胶囊,紧急消息立即展开;自定义模式点击铃铛查看
- [x] 尺寸、圆角、字号、主题色、停靠位置(顶部 / 底部 × 左 / 中 / 右)都可以调
- [x] 纯黑 / 毛玻璃 / 主题色渐变三种底
- [x] 触摸屏(希沃白板这类一体机)也能点开
### ClassIsland 联动
- [x] 教室机器上报课表与上课状态,Web 端能看到「现在在上什么」
- [x] 老师发的提醒在教室大屏上全屏弹出,支持语音朗读,播完回执不重复弹
- [x] 可以把 ClassHelper排好的课表镜像回 ClassIsland,不影响老师原有的课表
- [x] 提醒弹在客户端还是 ClassIsland,由教室那台机器自己选
- [x] 自定义常驻岛:分别编辑常态与无通知展开内容,收到消息显示铃铛,点击查看通知
### 部署与运维
@@ -87,6 +85,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
- [x] Linux 一键安装 + `classhelper` 运维命令(systemd 托管,升级 / 回滚 / 备份)
- [x] Docker Compose(MySQL 版与 SQLite 版各一份)
- [x] SQLite 与 MySQL 一键切换,备份与跨库迁移共用同一份 JSON 快照
- [x] 客户端批量更新不依赖外网:安装包由服务端下载缓存,教室机从服务端取,校验 sha256 后空闲时自动装
## 软件截图
@@ -99,18 +98,17 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
<details>
<summary>展开其余页面……</summary>
| 页面 | 截图 |
| --- | --- |
| 仪表盘 | <img src="docs/images/web-dashboard.jpg" alt="仪表盘" /> |
| 班级管理 | <img src="docs/images/web-classes.jpg" alt="班级管理" /> |
| 学生管理 | <img src="docs/images/web-students.jpg" alt="学生管理" /> |
| 教师管理 | <img src="docs/images/web-teachers.jpg" alt="教师管理" /> |
| 课表管理 | <img src="docs/images/web-schedules.jpg" alt="课表管理" /> |
| 作业发布 | <img src="docs/images/web-homeworks.jpg" alt="作业发布" /> |
| 通知发布 | <img src="docs/images/web-notifications.jpg" alt="通知发布" /> |
| 成绩录入 | <img src="docs/images/web-grades.jpg" alt="成绩录入" /> |
| ClassIsland 联动 | <img src="docs/images/web-integrations.jpg" alt="ClassIsland 联动" /> |
| 数据库管理 | <img src="docs/images/web-database.jpg" alt="数据库管理" /> |
| 页面 | 截图 |
| ---------- | -------------------------------------------------------------- |
| 仪表盘 | <img src="docs/images/web-dashboard.jpg" alt="仪表盘" /> |
| 班级管理 | <img src="docs/images/web-classes.jpg" alt="班级管理" /> |
| 学生管理 | <img src="docs/images/web-students.jpg" alt="学生管理" /> |
| 教师管理 | <img src="docs/images/web-teachers.jpg" alt="教师管理" /> |
| 课表管理 | <img src="docs/images/web-schedules.jpg" alt="课表管理" /> |
| 作业发布 | <img src="docs/images/web-homeworks.jpg" alt="作业发布" /> |
| 通知发布 | <img src="docs/images/web-notifications.jpg" alt="通知发布" /> |
| 成绩录入 | <img src="docs/images/web-grades.jpg" alt="成绩录入" /> |
| 数据库管理 | <img src="docs/images/web-database.jpg" alt="数据库管理" /> |
</details>
@@ -121,47 +119,46 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
<details>
<summary>展开其余页面……</summary>
| 页面 | 截图 |
| --- | --- |
| 作业看板 | <img src="docs/images/client-homeworks.png" alt="作业看板" /> |
| 通知 | <img src="docs/images/client-notifications.png" alt="通知" /> |
| 成绩 | <img src="docs/images/client-grades.png" alt="成绩" /> |
| 设置 · 灵动岛 | <img src="docs/images/client-settings-island.png" alt="设置 · 灵动岛" /> |
| 页面 | 截图 |
| ----------------------- | -------------------------------------------------------------------------- |
| 作业看板 | <img src="docs/images/client-homeworks.png" alt="作业看板" /> |
| 通知 | <img src="docs/images/client-notifications.png" alt="通知" /> |
| 成绩 | <img src="docs/images/client-grades.png" alt="成绩" /> |
| 设置 · 灵动岛 | <img src="docs/images/client-settings-island.png" alt="设置 · 灵动岛" /> |
| 设置 · 外观(深色主题) | <img src="docs/images/client-settings-appearance.png" alt="设置 · 外观" /> |
</details>
### 灵动岛
| 形态 | 截图 | 说明 |
| --- | --- | --- |
| 新消息胶囊 | <img src="docs/images/island-4-pill.png" alt="新消息胶囊" /> | 非上课时段直接出现,点一下展开 |
| 点开后的详情 | <img src="docs/images/island-5-clicked.png" alt="详情卡" /> | 标题、内容、时间 + 打开应用 / 标为已读 / 知道了 |
| 多条消息列表 | <img src="docs/images/island-9-list.png" alt="多条消息列表" /> | 按重要程度排序,默认显示三条 |
| 上课期间的紧急通知 | <img src="docs/images/island-3-urgent.png" alt="紧急通知" /> | 卡片内部红色呼吸光晕,无需点击 |
| 下课后补弹 | <img src="docs/images/island-2-after-class.png" alt="下课后自动弹出" /> | 上课期间暂存的通知,下课后自动弹出来 |
| 老师叫人 | <img src="docs/images/island-8-call.png" alt="叫人卡片" /> | 「请 XXX 同学找 XXX 老师」 |
| 新作业 | <img src="docs/images/island-7-homework.png" alt="新作业" /> | 教室机器自己录的那条不会弹给自己 |
| 形态 | 截图 | 说明 |
| ------------------ | ----------------------------------------------------------------------- | ----------------------------------------------- |
| 新消息胶囊 | <img src="docs/images/island-4-pill.png" alt="新消息胶囊" /> | 非上课时段直接出现,点一下展开 |
| 点开后的详情 | <img src="docs/images/island-5-clicked.png" alt="详情卡" /> | 标题、内容、时间 + 打开应用 / 标为已读 / 知道了 |
| 多条消息列表 | <img src="docs/images/island-9-list.png" alt="多条消息列表" /> | 按重要程度排序,默认显示三条 |
| 上课期间的紧急通知 | <img src="docs/images/island-3-urgent.png" alt="紧急通知" /> | 卡片内部红色呼吸光晕,无需点击 |
| 下课后补弹 | <img src="docs/images/island-2-after-class.png" alt="下课后自动弹出" /> | 上课期间暂存的通知,下课后自动弹出来 |
| 老师叫人 | <img src="docs/images/island-8-call.png" alt="叫人卡片" /> | 「请 XXX 同学找 XXX 老师」 |
| 新作业 | <img src="docs/images/island-7-homework.png" alt="新作业" /> | 教室机器自己录的那条不会弹给自己 |
### 手机上
<details>
<summary>展开……</summary>
| 场景 | 截图 |
| --- | --- |
| 仪表盘 | <img src="docs/images/mobile-dashboard.jpg" alt="手机 · 仪表盘" /> |
| 场景 | 截图 |
| -------- | ------------------------------------------------------------------------ |
| 仪表盘 | <img src="docs/images/mobile-dashboard.jpg" alt="手机 · 仪表盘" /> |
| 通知发布 | <img src="docs/images/mobile-notifications.jpg" alt="手机 · 通知发布" /> |
</details>
## 开始使用
| 装在哪 | 装什么 | 从 Releases 里拿 |
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- |
| 教师的电脑 / 学校服务器 | **服务端**(内含 Web 管理端与 Node 运行时) | `ClassHelper-<版本>-x64-server-setup.exe` |
| 教室机器 / 学生电脑 | **学生客户端** | `ClassHelper-<版本>-x64-client-setup.exe`,或 `-client-portable.exe` 免安装 |
| 教室机器的 ClassIsland | **联动插件** | `ClassHelper.ClassIslandPlugin.cipx` |
| 装在哪 | 装什么 | 从 Releases 里拿 |
| ----------------------- | ------------------------------------------- | --------------------------------------------------------------------------- |
| 教师的电脑 / 学校服务器 | **服务端**(内含 Web 管理端与 Node 运行时) | `ClassHelper-<版本>-x64-server-setup.exe` |
| 教室机器 / 学生电脑 | **学生客户端** | `ClassHelper-<版本>-x64-client-setup.exe`,或 `-client-portable.exe` 免安装 |
也可以在官网的[下载页](https://lovechengke.github.io/ClassHelper/download.html)上挑:
选一个版本,四个包排成四张卡,文件名、体积与 SHA256 清单都在那儿 ——
@@ -188,13 +185,12 @@ curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/Love
## 开发
| 层 | 选型 |
| -------------- | ------------------------------------------------------------------------ |
| 后端 | Node.js ≥ 20.19 + Express 5 + Prisma 7(SQLite / MySQL)+ Socket.IO |
| Web 管理端 | Vue 3 + Vite + Element Plus + Pinia |
| 桌面客户端 | Electron 44 + Vue 3 + IndexedDB 离线缓存(含独立的灵动岛渲染进程) |
| ClassIsland 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) |
| 共享契约 | `packages/shared`:类型、常量、权限矩阵、动效令牌 |
| 层 | 选型 |
| ---------- | ------------------------------------------------------------------- |
| 后端 | Node.js ≥ 20.19 + Express 5 + Prisma 7(SQLite / MySQL)+ Socket.IO |
| Web 管理端 | Vue 3 + Vite + Element Plus + Pinia |
| 桌面客户端 | Electron 44 + Vue 3 + IndexedDB 离线缓存(含独立的灵动岛渲染进程) |
| 共享契约 | `packages/shared`:类型、常量、权限矩阵、动效令牌 |
```bash
pnpm install
@@ -208,14 +204,14 @@ pnpm dev # 后端 4000 + Web 端 5
`pnpm db:seed` 会写一套演示数据(1 管理员 / 2 教师 / 3 个班 / 15 名学生 / 课表 / 作业 / 通知 / 成绩):
| 角色 | 账号 | 密码 |
| -------------- | --------------- | ------------ |
| 管理员 | `admin` | `admin123` |
| 教师 | `teacher1` | `teacher123` |
| ClassHelper 班级端(班级) | 班级码 `G101` | `123456` |
| 角色 | 账号 | 密码 |
| -------------------------- | ------------- | ------------ |
| 管理员 | `admin` | `admin123` |
| 教师 | `teacher1` | `teacher123` |
| ClassHelper 班级端(班级) | 班级码 `G101` | `123456` |
- [AGENTS.md](AGENTS.md) —— 工程约定与本机踩坑,**改代码前先读它**
- [docs/reference.md](docs/reference.md) —— 完整参考(功能行为、接口、验收记录)
- [AGENTS.md](AGENTS.md) —— 工程约定与开发环境注意事项,**改代码前先读它**
- [docs/reference.md](docs/reference.md) —— 功能章节与源码入口索引
- [docs/dev/architecture.md](docs/dev/architecture.md) —— 架构总览
## 致谢
@@ -229,3 +225,10 @@ pnpm dev # 后端 4000 + Web 端 5
如果这个项目对你有帮助,欢迎点亮 Star ⭐
</div>
### 客户端维护
支持远程关机、重启、注销与锁屏(管理端确认下发,客户端执行并回执),可查看最近一次执行结果。
统一更新支持立即安装、放学后安装或空闲时安装,支持取消下发。
管理员与教师的默认权限组均可自定义;管理员保留权限管理和系统管理入口。
课件共享:教师可上传教学文件到服务端,按日期与类型归档,仅向本人授课班级开放或推送;班级客户端支持课件目录、离线补收,以及灵动岛传输进度。使用说明见 [课件上传与传输](docs/app/courseware.md)。
+1 -1
View File
@@ -284,7 +284,7 @@ svc_restart() {
fi
}
# 就绪探针:本地请求**必须**绕开系统代理(否则拿到的是代理返回的 404,见 AGENTS.md §7 第 1 条)
# 就绪探针:本地请求**必须**绕开系统代理(否则拿到的是代理返回的 404,见 AGENTS.md 的现行工程约定)
probe_ready() {
command -v curl >/dev/null 2>&1 || return 1
curl -fsS --noproxy '*' --max-time 3 -o /dev/null "http://127.0.0.1:$(current_port)/healthz" >/dev/null 2>&1
+1 -1
View File
@@ -23,7 +23,7 @@ services:
context: ..
dockerfile: deploy/Dockerfile
target: runtime
image: classhelper-server:${APP_VERSION:-1.3.0}
image: classhelper-server:${APP_VERSION:-1.4.0}
restart: unless-stopped
environment:
NODE_ENV: production
+4 -1
View File
@@ -60,7 +60,7 @@ services:
context: ..
dockerfile: deploy/Dockerfile
target: runtime
image: classhelper-server:${APP_VERSION:-1.3.0}
image: classhelper-server:${APP_VERSION:-1.4.0}
restart: unless-stopped
environment:
NODE_ENV: production
@@ -83,6 +83,8 @@ services:
DEFAULT_TEACHER_PASSWORD: ${DEFAULT_TEACHER_PASSWORD:-123456}
ports:
- '${SERVER_PORT:-4000}:4000'
volumes:
- server-data:/app/data
depends_on:
db: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
@@ -93,4 +95,5 @@ networks:
driver: bridge
volumes:
server-data:
db-data:
+6
View File
@@ -540,6 +540,12 @@ resolve_package_url() {
sudo bash install.sh --package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz"
fi
fi
# tag 一律补上 v 前缀:Release 的 tag 是 v1.3.0,而 --version 的用法是 `--version 1.3.0`(不带 v)——
# 直接拿 VERSION 去拼就成了 .../download/1.3.0/...,GitHub 按 tag 精确匹配,必然 404
# (2026-10-11 实测:`install.sh --version 1.3.0` 下载 404,而 `classhelper upgrade --version 1.3.0` 正常
# —— 因为 classhelper 的 resolve_download 一直有这句,install.sh 漏了)。
# 传 `v1.3.0`、`1.3.0`、或者不传(走 latest)三种写法都落在这里。
tag="v${tag#v}"
ver="${tag#v}"
# 注意这里用 GITHUB_REPO_URL 而不是 GITHUB_RELEASES_PAGE:后者本身已经以 /releases 结尾,
# 再拼一次就变成 .../releases/releases/download/... (必然 404,实测踩到)。
+14
View File
@@ -92,6 +92,20 @@ server {
}
# ---------------------------------------------------------------- 接口与页面
# 课件流式上传(其余接口继续沿用 8 MB 限制)
location /api/courseware/ {
client_max_body_size 512m;
proxy_pass http://classhelper_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 1800s;
proxy_send_timeout 1800s;
}
location / {
proxy_pass http://classhelper_backend;
proxy_http_version 1.1;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "classhelper-server-runtime",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"type": "module",
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
+1 -1
View File
@@ -328,7 +328,7 @@ if (command === 'help' || command === '--help' || command === '-h') {
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
} finally {
// 不用 process.exit():在顶层 await 里硬退会踩 libuv 断言(见 AGENTS.md §7)
// 不用 process.exit():在顶层 await 里硬退会踩 libuv 断言(见 AGENTS.md 的现行工程约定)
await modules.disconnectPrisma();
}
}
+5 -3
View File
@@ -79,18 +79,20 @@ beUI 的组件本身是 React,用不到这里,但它的动效语言与框架
两端各有一个 `prefers-reduced-motion` 的全局降级块,把动画压到 1ms ——
**是压短而不是删掉**(`transition: none` 会让依赖 `transitionend` 的逻辑失灵)。
唯一的例外是**加载指示旋**:它是放慢而不是停,停掉会让界面看起来像卡死。
主窗口中的例外是**加载指示旋**:它是放慢而不是停,停掉会让界面看起来像卡死。
灵动岛使用独立动画控制,应用内开关优先,具体见[灵动岛](island.md)。
## 客户端设置页
「设置」是一个可展开的分组,六个子页各有自己的路由:
「设置」是可展开分组,常用子页如下;开发者模式还提供开发者页,自定义岛编辑为灵动岛子路由:
| 子页 | 内容 |
| ------ | ----------------------------------------------------------------------------- |
| 通用 | 服务器连接、离线缓存、作业录入短语、使用引导 |
| 外观 | 浅色 / 深色 |
| 灵动岛 | 尺寸、字号、主题色、视觉风格、停靠、边距、空闲细缝(详见[灵动岛](island.md)) |
| 提醒 | 通知显示在哪一端 |
| 提醒 | 本机课程等提醒开关与行为 |
| 账号 | 当前班级信息、退出登录 |
| 关于 | 版本 / Electron / Chromium / Node、诊断信息、检查更新、鸣谢 |
+13 -48
View File
@@ -1,57 +1,22 @@
---
title: 叫人
description: 老师点名让学生过来:普通与紧急两档、快捷短语、谁能叫谁,以及它在教室里的表现。
description: 点名、优先级、可见范围和客户端展示。
---
# 叫人
「叫人」是老师点名让某位同学来一趟 —— 它**不是**一条普通通知,因为它有一个明确的收件人和一个明确的动作
(学生会点「收到」)。
在 Web“通知发布 → 叫人”选学生,填写事由或选择快捷短语,需 call:create 和班级范围权限。
可调用的学生必须属于相应在读班级且未转出;岗位范围、任课或班主任关系由服务端检查。
学生没有账号,无法以个人身份调用接口。
## 在哪发
| 级别 | 落库优先级 | 经典 fixed 展示 |
| -------- | ---------- | ------------------------------------------------------------------ |
| 普通叫人 | HIGH | 胶囊可点击,上课也允许主动查看;不自动展开打断课堂,下课可自动展开 |
| 紧急叫人 | URGENT | 上课也立即展开,收起后仍可主动再打开 |
| 入口 | 谁能用 |
| --------------- | ------------ |
| 通知发布 → 叫人 | 教师、管理员 |
| 学生管理 → 叫人 | 管理员 |
自定义 custom 是常驻模式,两种叫人都先提示铃铛,由用户主动查看;详见[通知与提醒](notifications.md)。
卡片内容包括被叫学生、教师和事由,“收到”复用通知已读机制。
自定义消息优先于快捷短语,消息长度以接口 schema 校验为准。
学生管理页是管理员专属,但班主任与科任老师同样需要叫人,所以给老师留的入口在「通知发布 → 叫人」。
## 两档
| 级别 | 落库级别 | ClassHelper 班级端行为 |
| ------------ | -------- | ----------------------------------------------------------------------------------------------------- |
| **普通叫人** | `HIGH` | 课间先显示「叫人」胶囊,点击展开;**上课时段只进队列、不打断课堂**,下课后自动弹出详情(90 秒后收起) |
| **紧急叫人** | `URGENT` | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 |
两档的卡片都是「请 XXX 同学找 XXX 老师」+ 具体事由,徽标是「叫人」,按钮是「收到」
(复用通知的已读机制)。
> **是否立即展开只由 `priority` 决定**,与普通通知共用同一条排队 / 隐藏 / 下课弹出路径。
## 怎么说
两种输入方式,**自定义消息优先于快捷短语**:
- **快捷短语**(8 条,点击选中、再点取消):请到办公室找我 / 请到讲台找我 / 请到实验室找我 /
请到门卫处找我 / 请带上作业本找我 / 请带上试卷找我 / 请到教室门口等我 / 请马上来一趟;
- **自定义消息**:最长 200 字,例如「带上昨天的数学作业到办公室」。
## 谁能叫谁
| 角色 | 限制 |
| ------ | ---------------------------------------------------------- |
| 学生 | 调用被拒(403) |
| 教师 | 只能叫**自己班级**的学生;跨班、或学生不在本班 → 400 / 403 |
| 管理员 | 任意班级 |
## 服务端做了什么
`POST /api/calls`(教师 / 管理员)→ `urgent: true` 落库成 `URGENT`,否则 `HIGH`,
然后广播两个事件:
- `notification:new` 到班级房间(通知中心与灵动岛都靠它);
- `call:new` 定向到被叫学生 / 班级设备。
如果教室的「通知显示位置」不是"只在 ClassHelper",这条叫人也会推给 ClassIsland,
教室大屏一并播报。
`POST /api/calls` 保存为 Notification,带本地所属日期;随后发送 notification:new 班级广播和 call:new 班级会话定向事件。
定向主体是班级会话,学生本身没有 Socket 房间;双播按消息 id 合并去重。
+28 -83
View File
@@ -1,98 +1,43 @@
---
title: 班级、学生与教师
description: 班级账号、学生名单、班主任与各科任课老师,以及"谁能看什么、谁能改什么"。
description: 名单、班级会话、任课关系与年度升级。
---
# 班级、学生与教师
## 班级账号:ClassHelper 班级端的身份
## 班级账号
学生**没有个人账号**。一台教室机器用一个**班级账号**登录:
教室客户端使用班级码和班级密码登录,形成 CLASS_DEVICE 会话,主体是班级。
班级码保存时统一大写;管理员在“班级管理”维护班级码、密码与班主任。
班级端的已读、作业完成等操作代表全班,学生没有个人登录账号。
客户端没有修改密码入口;员工本人改密码在 Web 顶栏菜单。
```
班级码(例如 G203) + 班级密码 → 这台机器代表"高二(3)班"
```
班级名由入学年份和班号组成,例如“2026级1班”;年级独立保存,学年升级时班名不随之改写。
班主任可以暂空,新学年自动创建的高一班级仅有骨架,需要管理员后续分配教师。
登录之后,这台机器上的「已读 / 完成 / 未交」都是**代全班**操作的;它看到的是本班的数据。
## 学生名单
班级码是管理员在「班级管理」里设的(新建时自动生成,可以改成好记的)。
校验时会统一按大写比对(码存大写)。
Student 存学号、姓名、班级、性别、家长电话和状态,没有密码或角色字段。
学号为必填唯一标识,格式为 1–32 位字母、数字或 . _ -;不会自动生成登录名。
管理员或本班班主任在具备名单管理权限后管理学生,教师也可按授权范围只读名单。
名单表格导入仍要求管理员及相应导入权限;学号查重在 Student 表完成,不当成 User 用户名。
调班保留历史内容原归属;转出独立记账并从现行业务名单过滤,学号不可复用。
> 班级密码在新建班级时由 `.env` 的 `DEFAULT_CLASS_PASSWORD` 决定初始值,
> 之后由管理员在「班级管理 → 修改班级账号」维护。
>
> **客户端没有改密码入口**,这是刻意移除的:教室机器上误改班级密码会让别的机器登录不了。
> 教师 / 管理员本人改密码在 Web 端顶栏头像菜单里。
## 教师与科目
## 学生
教师使用工号和密码登录,管理入口受 teacher:read/teacher:manage 等功能权限控制。
教师名单导入还要求管理员身份和相应导入权限。班主任关系在 Class.teacherId;
科任关系唯一存于 Course(classId, name, teacherId),分配任课关系由班级管理权限及服务端断言限制。
班主任不任教某科就不能修改该科作业/成绩;科任老师有相应权限时可以管理自己任教科目。
**学生管理**(仅管理员)→ 新建 / 导入名单。
删除教师前,服务端检查其负责的班级、任课课程、发布过的作业/通知与所属课件;仍有业务内容则返回冲突,避免级联丢失。
教师本人改密码、管理员修改教师密码、班级账号密码是不同入口;学生没有密码接口。
学生记录**只是名单**:成绩、未交名单、叫人、已读记录都按名单记录,所以名单不全这几项就不准。
## 权限、学年与归档
| 项目 | 说明 |
| -------- | ------------------------------------------------------------------------------------ |
| 用户名 | `^[A-Za-z0-9_.-]{3,32}$`;单个新建时自动生成 |
| 密码 | **没有密码概念**。学生的 `passwordHash` 是空串占位,不能登录,也没有任何重置密码接口 |
| 所属班级 | 一个学生属于一个主班级 |
> 学生**不是账号**(`Student` 表里没有密码、没有角色),拿学号去登录只会得到 401 ——
> 否则"密码错(401)"和"密码对但学生账号已停用(403)"的差异会把学生账号密码变成可离线验证的预言机。
## 教师
| 能力 | 在哪 | 谁能做 |
| -------------------- | ------------------------------------------------ | ------------- |
| 新建 / 编辑 / 删除 | 「教师管理」 | **仅管理员** |
| 导入名单(xlsx/csv) | 「教师管理 → 导入名单」 | **仅管理员** |
| 重置密码 | 「教师管理」(可指定新密码,留空用默认初始密码) | **仅管理员** |
| 加进某个班 | 班级详情 → 任课老师(班级 + 科目 + 教师) | 管理员 |
| 换班主任 | 班级详情顶部「更换班主任」 | **仅管理员** |
> **教师录入只有管理员能做**:`/api/teachers` 整组接口都是 `requireRole('ADMIN')`,教师调用返回 403。
> 前端也只在管理员登录时显示这个菜单项。
### 删除教师有护栏
下面四种情况一律拒绝(409),因为删账号会级联删掉他在各班发过的内容:
1. 他还是某个班的**班主任**;
2. 名下有**课程**;
3. 有**任课关系**(`Course.teacherId === 他`);
4. **发布过作业或通知**。
(后两项曾经漏检,会静默删掉该教师发过的作业与通知。)
## 角色与可见范围
班级内的角色由「账号角色 + 与该班级的关系」共同决定:
| 角色 | 怎么判定 | 可见范围 |
| ------------------ | ------------------------------ | -------------------------- |
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
| 科任老师 `SUBJECT` | 该班 `Course.teacherId` 有当前用户 | 被任课的班级 |
| 学生 `STUDENT` | `User.classId` / 班级账号 | 自己的班级(只读业务数据) |
### 权限矩阵(摘要)
| 操作 | 管理员 | 班主任 | 科任老师 |
| ------------------------------------ | :----: | :----------: | :------: |
| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ |
| 分配班主任与科任老师 | ✅ | ❌ | ❌ |
| 学生名单管理(增删 / 导入) | ✅ | ❌ | ❌ |
| 教师录入(新建 / 导入 / 改密码) | ✅ | ❌ | ❌ |
| 数据库管理(备份 / 导入导出 / 切换) | ✅ | ❌ | ❌ |
| 课表管理(增删改 / 导入) | ✅ | ✅(仅本班) | ❌ |
| 布置作业 | ✅ | ✅ | ✅ |
| 叫人 | ✅ | ✅ | ✅ |
| 发布通知 | ✅ | ✅ | ✅ |
| 成绩录入 / 修改 / 导入(仅本班) | ✅ | ✅(仅本班) | ❌ |
| 维护班级密码 | ✅ | ❌ | ❌ |
完整矩阵与实现见[角色与权限](../dev/permissions.md)。
## 学期周数
每个班有自己的学期周数(`Class.termWeeks`,默认 20,上限 30),**只有管理员**能改。
它决定"现在是第几教学周",见[课表](schedules.md)。
角色给默认权限模板,实际入口由可编辑权限组决定;岗位组还能配置年级或部范围。
范围不代替科目写权限,详见[角色与权限](../dev/permissions.md)。
学期周数受全校可配置上限约束,默认上限 30、允许 1–60,见[课表](schedules.md)。
学年升级按计划时间运行,执行前保存快照,以年份幂等;启动发现漏跑需管理员预览补跑。
高三归档、高一/高二升一级,新高一不复制任课关系。归档页查看届别历史与转出记录,
不再通过手动创建归档来代替年度升级。撤回转出等操作仍受归档与管理员业务检查约束。
+37
View File
@@ -0,0 +1,37 @@
# 课件上传与传输
教师可在 Web 管理端的「教学事务 → 课件」上传演示文稿、文档、表格、图片、音视频、压缩包等文件,单文件上限为 **512 MB**。
## 上传与可见范围
1. 选择本地文件。
2. 选择可以查看课件的班级。候选项只包含本人担任任课教师的在读班级;仅担任班主任、级长或部长不会额外获得上传到这些班级的权限。
3. 选择「仅保存云端」或「保存后推送所选班级」,点击上传。
不选择班级时,课件仅自己可见。仅保存云端并指定可见班级后,这些班级可在客户端自主下载。推送模式会先把文件完整保存到服务端,再给所选班级创建下载任务;不会从教师电脑直接传到教室电脑。
教师可在列表中按文件名、教师、归档日期和文件类型筛选,修改自己课件的可见范围,或再次推送。再次推送时只能选择「本人授课班级」与「该文件可见范围」的交集。管理员可查看归档列表,但上传和推送自己的文件同样遵守任课关系。
「查看与下载课件」和「上传、设置可见范围与推送自己的课件」为独立权限,可在权限组中分配。默认教师与管理员、内置教师与班主任岗位包含这两项权限;升级时自定义权限组需管理员按需授权。
## 班级客户端
客户端侧栏新增「课件」,仅列出本班可见的文件,显示文件名、类型、上传教师、日期与大小。点击「下载」将课件保存到本机,点击「打开文件夹」定位已下载文件。客户端不会自动运行文件。
收到教师推送后,客户端自动下载;离线时任务保留在服务端,上线后的心跳会补拉,每台设备独立回执。实时通道正常时会立即触发检查;断线时通过心跳继续补收。传输失败会显示原因,推送任务在后续心跳中重试,不会影响远控和在线状态上报。
下载期间,固定岛和自定义常驻岛都会显示文件名、文件类型、传输教师、接收字节数和进度条。下载完成后校验 SHA256,再保存正式文件并回执。断网时可以查看已缓存的课件目录与已经下载的本地文件;重新联网后同步当前可见范围。
修改可见范围后,被移除班级无法再获取文件,尚未完成的推送任务同步取消。已经下载到教室电脑的文件无法远程撤回,需要在本机自行删除。
## 服务端归档与备份
默认文件实体目录为 `<服务端根目录的上一级>/data/courseware/YYYY-MM-DD/<类型>/<随机编号>`,展示名称与扩展名单独保存在数据库中。可通过环境变量 `COURSEWARE_DIR` 指定文件目录。上传使用流式写入,未完成文件使用 `.part` 临时文件;失败或上传中断时清理。只有完整收到文件并完成摘要计算后才记录元数据和创建推送任务。
数据库迁移 `20261018000000_add_courseware` 新增课件、可见班级、推送和设备回执四张表。SQLite 安装部署按现有自动迁移流程更新;源码开发部署执行 `pnpm --filter @classhelper/server db:deploy`。MySQL 按现有换库流程基于更新后的 schema 建模。
数据库 JSON 备份包含这四张表,但**不包含二进制文件本身**。完整备份必须同时备份 `data/courseware`(自定义目录时备份对应目录)。SQLite Docker 的现有数据卷覆盖此目录;MySQL Docker 配置增加 `server-data:/app/data` 数据卷。复制到新机器时需同时恢复数据库和文件目录。
使用反向代理时,需允许课件上传大小和足够的读写超时。仓库的 Nginx 样例单独为 `/api/courseware/` 配置 512 MB 和 1800 秒超时,其他接口维持原有大小限制。
开发验收:`pnpm verify:courseware` 自动使用隔离数据库和临时文件目录,不读取或向真实班级发送课件。
+33 -41
View File
@@ -1,56 +1,48 @@
---
title: 成绩
description: 成绩录入与导入、等级与得分率、ClassHelper 班级端看到的全班总览。
description: 录入、按学号查询、排名与关闭个人查询后的集体分析。
---
# 成绩
## 三种录入方式
Web 管理端支持单条、批量和表格导入成绩;记录通过 Grade.studentId 关联学生名单。
科目来自班级课程与共享科目目录,分数、总分和等级按录入规则校验。
成绩写入需 grade:write 或相应导入权限,并通过班级和科目检查:
管理员可管理,教师只能编辑自己任课科目,无科目内容按班主任规则;班级端不能写成绩。
表格导入另有管理员/本班班主任门槛,班主任也只能导入符合科目权限的行;它与单条/批量录入的准入不同。
Web 管理端「成绩录入」页:
## 查看、排名与隐私
| 方式 | 适合 | 说明 |
| -------- | --------------------------------- | ---------------------------------------------------------------- |
| 单条录入 | 个别缺漏补录 | 选学生、科目、考试名称、分数、总分 |
| 批量录入 | 一次录一个班的某一科 | 一屏列出全班学生,逐个填分 |
| 导入表格 | 已经有 Excel 的(期中期末最常见) | 支持列名同义词映射、先预览再提交,见[表格与课表导入](imports.md) |
班级客户端可显示本班考试、成绩总览、等级分布,以及按学号查询明细。
是否允许读取个人数据由 Class.studentGradeQueryEnabled 控制,班级端只读该设置。
**科目**下拉是全校统一的 18 科目录;选中一个该班还没有的科目时,前端会自动为该班建同名课程,
不用先去把科目建一遍。
| 状态 | 班级端可读取内容 |
| ------------ | -------------------------------------------------------------------- |
| 个人查询开启 | 本班原始成绩、个人明细、考试及排名,仍检查班级归属 |
| 个人查询关闭 | 页面可打开,只返回服务端集体聚合;个人明细、原始全班成绩和排名被拒绝 |
## 谁能录
聚合对少于 5 名学生的样本、稀疏分段及补集抑制,不提供最高/最低分。
客户端不会先拿个人成绩再做隐藏或聚合;教师/管理员按自身权限读取,不受班级端个人查询开关限制。
| 角色 | 权限 |
| --------------- | -------------------------------------- |
| 管理员 | ✅ 全部班级 |
| **本班班主任** | ✅ 仅本班(录入 / 修改 / 删除 / 导入) |
| 科任老师 | ❌ 403 |
| 学生 / 班级账号 | ❌ 只能看 |
考试“是否计入总分”属于 Exam,教师在管理端设置,客户端只展示。
同一学生同一科多条成绩取最高分,排名并列用 1、2、2、4;总分只在开启时显示。
图表使用本地 HTML/CSS/SVG 组件。
权限在**服务层**兜底,前端只是按同一份矩阵隐藏按钮。
## 接口入口
## 学生看到的是什么
以下省略 /api 前缀,详情与参数以 grades.module.ts、grades.schemas.ts 为准。
ClassHelper 班级端(教室机器)用的是**班级账号**,所以它看到的不是"我的成绩",而是**全班成绩总览**:
| 方法与路径 | 用途 |
| ----------------------------------------------------- | -------------------------------------------------- |
| GET /grades/overview | 班级端按开关返回原始总览或集体聚合 |
| GET /grades/my | 班级端原始成绩,关闭个人查询时拒绝;员工返回空数组 |
| GET /grades/collective?classId= | 服务端集体分析 |
| GET /grades/student/:studentNo | 按学号查询,班级端受班级开关控制 |
| GET /grades?classId=&courseId=&studentId=&examName= | 员工列表,功能键与范围检查 |
| GET /grades/stats | 员工统计 |
| GET/PUT /grades/exams | 考试列表 / 设置总分口径 |
| GET /grades/ranking | 考试排名,班级端受个人查询开关控制 |
| POST /grades、/grades/bulk | 单条或批量录入 |
| PATCH /grades/levels、/grades/:id;DELETE /grades/:id | 等级与成绩维护 |
- 表格:分数 / **得分率** / **等级**;
- 柱状图;
- 等级分布;
- 最近更新。
图表是自研的轻量组件(柱状图是纯 HTML/CSS,折线图是 SVG),项目里**不引图表库**。
> 学生没有个人账号,所以系统里不存在"某个学生登录后看自己的成绩"这条路径。
> 成绩记录本身是按学生名单存的(`Grade.userId`),只是入口统一在班级账号上。
## 接口
| 方法 | 路径 | 权限 |
| -------------- | ---------------------------------------------- | --------------------------- |
| GET | `/grades/my` | 登录(班级会话 → 全班总览) |
| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 |
| GET | `/grades/stats?classId=&courseId=&examName=` | 教师 / 管理员 |
| POST | `/grades`、`/grades/bulk` | 班主任 / 管理员 |
| PATCH / DELETE | `/grades/:id` | 班主任 / 管理员 |
写入后会广播 `grade:updated`,客户端立刻刷新。
变更发送 grade:updated,事件载荷同样须遵守个人数据保护,客户端按需重新读取。
+5 -5
View File
@@ -83,10 +83,10 @@ Web 端作业详情抽屉里同样可看可改。
发布 / 修改 / 删除作业会分别广播:
| 事件 | 客户端反应 |
| ------------------ | ------------------------------------- |
| `homework:new` | 作业列表刷新 + 灵动岛弹「新作业」胶囊 |
| `homework:updated` | 刷新;带 `deleted: true` 表示删除 |
| `homework:status` | 完成状态变化时刷新统计 |
| 事件 | 客户端反应 |
| ------------------ | --------------------------------------------------- |
| `homework:new` | 列表刷新;经典岛显示胶囊/课堂暂存,自定义岛提示铃铛 |
| `homework:updated` | 刷新;带 `deleted: true` 表示删除 |
| `homework:status` | 完成状态变化时刷新统计 |
课表、作业、通知、成绩都是同一个套路:写库 → 按班级房间广播 → 客户端更新内存状态并写回 IndexedDB。
+35 -98
View File
@@ -1,115 +1,52 @@
---
title: 表格与课表导入
description: 成绩、学生名单、教师名单的表格导入,以及从 ClassIsland 档案导入节次时间与课表。
description: 表格预览、名单与成绩校验,以及完整离线档案导入。
---
# 表格与课表导入
五条导入链路**都在服务端完成解析、校验与去重**,前端只负责收集文件与展示结果。
所以 Web 端、客户端与直接调接口拿到的结果是同一套规则、同一份统计。
解析与写入均在服务端完成,Web 收集文件、展示预览和逐行结果。
表格支持成绩、学生、教师与班级任课关系等 kind,类型和模板以 shared/imports 模块为准。
## 通用流程
## 表格流程
表格类导入都是三段式(成绩 / 学生 / 教师都一样):
下载模板 → 选择 xlsx/xls/csv/tsv → 预览列名与映射 → 确认模式并提交。
文件上限 8 MB,JSON 请求体 12 MB,超限 413 IMPORT_TOO_LARGE;结果含新增/更新/跳过/失败、errors/warnings。
错误行号包含表头,便于在 Excel 定位。
1. **下载模板** —— `GET /imports/template?kind=grades&format=xlsx|csv`。
CSV 带 BOM,Excel 直接双击打开不乱码。
2. **上传预览** —— 服务端返回列名、前 20 行、总行数与**建议的字段映射**,以及校验问题。
列名按同义词自动映射(「用户名→学号」「得分→分数」「满分→总分」「课程→科目」……),也可以手动改。
**缺必填列不会静默通过**,会直接给出「缺少必填列「…」」。
3. **确认导入** —— 按映射与写入模式提交:
- 学生成绩按学号匹配,姓名兜底要求同班唯一;转出学生不参与匹配,重复记录和写入模式由服务校验。
- 学生模板使用学号、姓名、班级、性别、家长电话,无密码;学号 1–32 位字母/数字/._-,在 Student 表查重。
- 教师模板使用工号、姓名、手机号、初始密码、角色;学生/教师名单导入仍要求管理员和导入权限。
- 成绩表格导入当前先要求管理员或本班班主任,再校验每行科目任课关系;科任老师的单条/批量成绩写入权限不代表能走表格导入。
- 任课关系导入用于既有班级、科目和教师的对应关系,不把档案中的 TeacherName 当账号授权。
| 模式 | 已存在的记录 |
| -------- | ------------ |
| `upsert` | 更新(默认) |
| `append` | 跳过 |
## 完整 ClassIsland 档案
返回 `{ total, inserted, updated, skipped, failed, errors[], warnings[] }`。
`errors[].row` 是 **Excel 视角的行号(含表头)**,方便老师改完再导。
在“课表管理 → 导入 ClassIsland 课程表”选择或粘贴 JSON,先预览再导入。
完整输入包含 TimeLayouts、ClassPlans、Subjects 字典,可包含 ClassPlanGroups 和 OrderedSchedules 等配置。
JSON 只作为离线文件导入,不依赖在线联动插件。
**体积限制**:文件上限 **8MB**,请求体上限 12MB(文件以 base64 放进 JSON,约 4/3 倍)。
超限返回 413 `IMPORT_TOO_LARGE` 与中文提示 —— 如果你看到一句英文的「request entity too large」,
那说明这个异常没被映射,属于 bug。
现行写入由 class-plans/profile-import.service.ts 完成:
## 1. 成绩表格导入
| 项目 | 写入规则 |
| --------- | ----------------------------------------------------------------------- |
| 时间类型 | 0/1/2/3 对应 LESSON/BREAK/DIVIDER/ACTION,保存为关系化时间点 |
| 课程对齐 | Classes[i] 引用第 i 个上课时间点,课间/分割线不占课位,数据库用显式外键 |
| 星期 | 完整导入 0=不按天、1–7=周一至周日 |
| 轮换 | 原样保留 WeekCountDiv/WeekCountDivTotal,支持 1–9 周,不降级成每周 |
| 状态 | 保留空课、停用、临时换课标记和关联层 |
| 群组/预定 | 保存群组及可识别的预定/临时选择 |
| 缺失科目 | 复用该班课程;新建需该班已有班主任,教师名不授予权限 |
| merge | 基于班级与来源 id 生成稳定 id,重复导入更新同一套对象 |
| replace | 事务内删除该班预定和日课表后写入,保留的对象以实现为准 |
- 入口:成绩录入 → 「导入表格」
- 模板列:`学生学号 / 学生姓名 / 考试名称 / 分数 / 总分 / 科目 / 等级`
- 支持 `.xlsx / .xls / .csv / .tsv`(自动识别分隔符)
- **重复判定**:同班级 + 同学生 + 同考试 + 同课程
- 学生按「用户名」优先、「姓名」兜底匹配
日课表导入需本班班主任/管理员检查。旧 /imports/class-plan/preview 是扁平预览,
不完整展示群组/空课/停用等结构;提交直接调用完整导入,不能把预览的单双周列当作实际数据模型。
完整导入失败时事务回滚;部分缺失引用可能返回 warnings,须检查导入结果和日课表编辑器。
## 2. 学生名单导入
## 时间配置兼容入口
- 入口:学生管理 → 「导入名单」(仅管理员)
- 模板列:`学号 / 姓名 / 班级 / 性别 / 家长手机号` —— 学生**不是账号**,没有密码列
- 用户名必须匹配 `^[A-Za-z0-9_.-]{3,32}$`
- 用户名已经被某个教师 / 管理员账号占用时,**这一行进错误列表**,不会覆盖他人账号
旧 /imports/time-layout 接口保留,写入和删除需 timetable:manage,现行写入同步关系化时间点。
常规使用时间表/日课表编辑器或整套档案导入;旧预览解析兼容性不代表完整导入接受所有旧输入形态。
## 3. 教师名单导入
- 入口:教师管理 → 「导入名单」(仅管理员)
- 模板列:`工号 / 姓名 / 手机号 / 初始密码 / 角色`
- 角色取 `ADMIN` / `TEACHER`,留空按 `TEACHER`
- 与新建教师同一套校验,导入完可以直接登录
## 4. 从 ClassIsland 导入课程表
**这是最省事的一条路**:如果教室里已经在用 ClassIsland,直接导入它的档案 JSON,
课表、节次时间、单双周会一起进来,不用排两遍。
- 入口:课表管理 → 「导入 ClassIsland 课程表」
- 输入:同一份档案 JSON(`ClassPlans` / `TimeLayouts` / `Subjects` 三个字典),
粘贴或选择 `.json` 文件
### 真实档案的形态
ClassIsland 2.x 的档案在 `Profiles/<档案名>.json`,字段是 PascalCase:
```jsonc
{
"TimeLayouts": {
"<guid>": { "Name": "…", "Layouts": [{ "StartTime": "08:00:00", "EndTime": "08:45:00", "TimeType": 0 }] },
},
"ClassPlans": { "<guid>": { "Name": "…", "Classes": [/* 与上课时间点逐位对齐 */] } },
"Subjects": { "<guid>": { "Name": "语文" } },
}
```
解析器同时兼容顶层数组、`{ TimeLayouts: [...] }`、`{ items | Items | Layouts | TimeLayout: [...] }`
与单个时间表对象。
| 细节 | 说明 |
| ------------ | ----------------------------------------------------------------------------------------------------------------------- |
| 时间字段 | `StartTime` / `EndTime`(`"08:00:00"`);旧版 `StartSecond` / `EndSecond`(ISO 字符串或秒数);`"8:0"` / `"08:00"` 都认 |
| `TimeType` | `0=上课`、`1=课间`、`2=分割线`、`3=行动`,与 ClassIsland 一致 |
| **节次对齐** | `ClassPlan.Classes[i]` 对应 `Layouts` 里**第 i 个 `TimeType === 0` 的时间点**(课间、分割线不占位) |
| 单双周 | `TimeRule.WeekCountDiv`(`0=每周`、`1=单周`、`2=双周`)+ `WeekCountDivTotal`(默认 2) |
| 星期换算 | ClassIsland 是 `0=周日 … 6=周六`,导入时换算成 `1=周一 … 7=周日` |
| 科目映射 | `Subjects[<guid>].Name`;该班还没有的科目**自动补建课程**(任课老师取该班班主任),预览里会提前列出 |
| 写入模式 | `replace` 清空该班课表后写入(事务内完成)/ `merge` 按「星期 + 开始时间 + 单双周」去重 |
**解析出 `errors` 时返回 400 且数据库完全不写入**(`IMPORT_INVALID` / `IMPORT_EMPTY`)——
先保证不写坏,再谈导入。
时间重叠、分割线没有时间这类问题只作 `warnings`,不阻断导入。
> **3 周及以上的轮换**没法用单双周表达,会给出 warning 并按"每周"处理。
## 5. 只导入节次时间(旧入口)
「导入时间配置」按钮**已经下线** —— 因为同一份档案 JSON 里的 `TimeLayouts` 已经跟着课表一起进来了。
组件与接口(`/imports/time-layout/*`)仍然保留给旧脚本调用。
它的写入模式也是 `replace`(整体替换该班同名配置)与 `merge`(以**开始时间**为槽位标识合并,
同槽位被导入项覆盖,其它旧节次保留,不静默丢数据)。
## 相关接口
| 路径 | 说明 |
| ------------------------------------------------------ | ------------------------------------------ |
| `/imports/template?kind=&format=` | 下载模板(`csv` 走 JSON,`xlsx` 走二进制) |
| `/imports/table/preview` | 上传解析预览(base64) |
| `/imports/table/commit` | 按映射与模式导入 |
| `/imports/class-plan/preview`、`/imports/class-plan` | ClassIsland 课程表预览 / 导入 |
| `/imports/time-layout/preview`、`/imports/time-layout` | 时间配置预览 / 导入(旧入口) |
接口索引见[REST API](../dev/api.md),迁移边界见[当前实现约定](../dev/implementation.md)。
+18 -17
View File
@@ -8,30 +8,31 @@ description: ClassHelper每一项功能怎么用:课表、作业、通知、
这一章按功能讲,重点是**行为上的约定**——什么时段会弹、谁看得见、什么情况下不动。
很多"看起来像故障"的现象其实是设计行为,下面每一页都会点出来。
| 页面 | 讲什么 |
| --------------------------------- | ------------------------------------------------------ |
| [课表](schedules.md) | 周视图与「今天」时间轴、单双周、学期周数、课表来源 |
| [作业](homeworks.md) | 按所属日期归类、看板与全屏看板、教室机器录入、未交名单 |
| [通知与提醒](notifications.md) | 三档优先级、上课时段策略、二次确认、已读与红点 |
| [叫人](calls.md) | 普通叫人与紧急叫人的差别、快捷短语、谁能叫谁 |
| [成绩](grades.md) | 录入方式、导入、等级分布、学生看到的是什么 |
| [班级、学生与教师](classes.md) | 班级账号、名单、班主任与各科任课老师、谁看得见什么 |
| [灵动岛](island.md) | 胶囊与卡片、多条通知的竖排列表、停靠与外观、触摸屏 |
| [外观、主题与动效](appearance.md) | 深浅色、设计令牌、动效语言、降低动态效果 |
| [表格与课表导入](imports.md) | 成绩 / 名单 / 教师表格导入与 ClassIsland 档案导入 |
| 页面 | 讲什么 |
| --------------------------------- | ---------------------------------------------------------- |
| [课表](schedules.md) | 周视图与「今天」时间轴、单双周、学期周数、课表来源 |
| [课件](courseware.md) | 上传、班级可见范围、推送与逐设备接收 |
| [作业](homeworks.md) | 按所属日期归类、看板与全屏看板、教室机器录入、未交名单 |
| [通知与提醒](notifications.md) | 四档优先级与两种岛模式、上课时段策略、二次确认、已读与红点 |
| [叫人](calls.md) | 普通叫人与紧急叫人的差别、快捷短语、谁能叫谁 |
| [成绩](grades.md) | 录入方式、导入、等级分布、学生看到的是什么 |
| [班级、学生与教师](classes.md) | 班级账号、名单、班主任与各科任课老师、谁看得见什么 |
| [灵动岛](island.md) | 胶囊与卡片、多条通知的竖排列表、停靠与外观、触摸屏 |
| [外观、主题与动效](appearance.md) | 深浅色、设计令牌、动效语言、降低动态效果 |
| [表格与课表导入](imports.md) | 成绩 / 名单 / 教师表格导入与 ClassIsland 档案导入 |
## 贯穿所有功能的三条规则
**一、角色决定可见范围。** 同一个页面,管理员、班主任、科任老师看到的数据与能点的按钮都不一样。
**一、权限与范围共同决定可见内容。** 同一个页面,管理员、班主任、科任老师看到的数据与能点的按钮都不一样。
前端会按权限隐藏入口,但**真正拦人的是服务端**——直接调接口一样会被拒。完整矩阵在
[角色与权限](../dev/permissions.md)。
**二、上课时段不打扰课堂。** 「上课时段」由课表实时判定,普通通知与普通叫人只进队列、下课补弹;
紧急通知与紧急叫人才会立刻打断。规则只有一份,客户端与 ClassIsland 都遵守它。
**二、课堂提醒遵循本机布局规则。** 经典模式普通通知暂存到下课,普通叫人保留可点胶囊;
紧急通知与紧急叫人立即展开;自定义常驻模式先显示铃铛,由用户主动查看。
细节见[通知与提醒](notifications.md)。
**三、实时推送只走班级房间。** 老师一发内容,服务端写库后按班级房间广播,客户端立刻更新,
不靠轮询。教室机器断线重连后会自动补上漏掉的数据,灵动岛也会把上课期间暂存的通知补弹出来。
**三、实时广播配合恢复读取。** 老师发布后服务端按班级等房间广播,客户端刷新;断线恢复重新拉取。
设备心跳和课件补领另有轮询机制,普通通知课堂暂存由客户端处理。
## 两端的差异
@@ -40,7 +41,7 @@ description: ClassHelper每一项功能怎么用:课表、作业、通知、
| 事情 | Web 管理端(老师) | 桌面客户端(教室机器) |
| ---------- | ------------------------------- | --------------------------------------------------------- |
| 作业 | 发布 / 编辑 / 删除 / 看未交名单 | 看作业 + **录入作业** + 勾未交名单,不显示"完成 / 待完成" |
| 成绩 | 录入 / 导入 / 改 / 删,带统计 | 只看全班总览 |
| 成绩 | 录入 / 导入 / 改 / 删,带统计 | 按班级开关看个人数据或集体分析 |
| 课表 | 排课、导入、改 | 只看(周视图 + 今天时间轴) |
| 通知 | 发布 / 删除 / 看已读 | 看、标为已读 |
| 班级与人员 | 管理员可管 | 只能看本班 |
+46 -101
View File
@@ -1,121 +1,66 @@
---
title: 灵动岛
description: 桌面客户端的通知浮窗:胶囊与卡片、多条通知的竖排列表、停靠与外观、触摸屏下的行为。
description: 经典提醒、自定义常驻方案、课程与课件传输。
---
# 灵动岛
灵动岛是桌面客户端上那个独立于主窗口的浮窗:无边框、透明、置顶、不占任务栏。
它是「消息到达的第一现场」—— 老师发的内容先在岛上出现,学生点开才进主界面。
灵动岛是客户端的独立透明窗口,不占任务栏。设置中可选择经典 fixed 或自定义 custom,默认 fixed。
自定义当前即常驻模式;旧 residentEnabled 字段只作兼容,不是另一个独立开关。
两种布局共用一个原生窗口,切布局不会新开第二个岛。
视觉参照 [WinIsland](https://github.com/WinIslandProject/WinIsland):
连续圆角(超椭圆)而不是 `border-radius`、纯黑 / 毛玻璃 / 主题色三种底、Apple 系统色强调色、
6 个停靠位、可选的「空闲细缝」。逐项设计令牌见[灵动岛设计令牌](../dev/winisland-design-tokens.md)。
## 两种消息展示
## 各形态长什么样
| 场景 | 经典 fixed | 自定义 custom |
| ----------------- | ------------------------------ | -------------------------- |
| 空闲 | 默认隐藏,可选细缝 | 显示常态组件行 |
| 课间普通通知/作业 | 胶囊,点击详情 | 铃铛和未处理数量,点击查看 |
| 课堂普通通知/作业 | 暂存,下课补弹 | 铃铛提示,允许主动查看 |
| 普通叫人 | 课堂也保留可点胶囊,不自动展开 | 铃铛提示,主动查看 |
| 紧急通知/紧急叫人 | 无论课堂状态立即展开 | 铃铛提示,由用户主动展开 |
| 无消息时点击 | 按经典空闲状态处理 | 展示方案的无通知展开内容 |
| 处理完成 | 队列清空后隐藏或细缝 | 回到常态组件行 |
| 形态 | 截图 | 说明 |
| ---------------------- | ----------------------------------------------------- | ------------------------------------------------- |
| 非上课时段的「新消息」 | ![新消息胶囊](../images/island-4-pill.png) | 268×44,直接出现(无入场动画),点击后展开 |
| 点开后的详情卡 | ![点击后展开](../images/island-5-clicked.png) | 标题 / 内容 / 时间 + 打开应用 / 标为已读 / 知道了 |
| 下课后自动弹出 | ![下课后自动弹出](../images/island-2-after-class.png) | 上课期间暂存的通知,下课后自动弹出 |
| 上课期间的紧急通知 | ![紧急通知立即展开](../images/island-3-urgent.png) | 卡片**内部**红色光晕 + 紧急角标,无需点击 |
| 新作业 | ![新作业](../images/island-7-homework.png) | 青蓝描边 + 书本图标 |
| 叫人 | ![叫人](../images/island-8-call.png) | 琥珀金卡片 + 「叫人」徽标 + 「收到」按钮 |
多条消息按 id 去重,按优先级和时间排序;详情显示列表与“展开更多”,超出工作区提示到应用中操作。
“知道了/标为已读”按当前列表语义处理;本机刚录的作业通过指纹避免重复提醒自己。
课堂发布紧急通知的二次确认是服务端规则,见[通知](notifications.md),不因本机布局改变。
## 什么时候弹、弹成什么样
经典布局的历史展示图(供外观参考,具体行为以上表和当前设置为准):
| 场景 | 灵动岛行为 |
| --------------------------------- | --------------------------------------------------------------------------------------------- |
| 收到通知(非上课时段) | 直接出现胶囊:`新消息 · 共 N 条` + 发送人 + `点击查看` |
| 多条未处理消息 | 胶囊标题汇总类型:`新消息:叫人/作业/通知(共 3 条)`,右侧带类型小标签 |
| 点击胶囊(只有一条) | 展开为**详情卡**:标题、内容、时间 + 三个按钮 |
| **点击胶囊(多条)** | 展开为**竖排列表**,按重要程度排序(紧急 > 重要 > 普通 > 低,同级按时间新的在前) |
| **点「展开更多」** | 把放得下的都铺出来;铺到工作区下沿还放不下时改成「更多请前往应用内操作」 |
| **多条时的「知道了 / 标为已读」** | 都是**整批**操作 |
| 上课时段的**普通叫人** | **不自动展开**(不打断课堂),但保留胶囊并**允许学生主动点开**;下课自动展开 |
| **紧急叫人** | 上课时段也立即展开,卡片是琥珀金 + 「叫人」徽标,按钮为「收到」 |
| 上课时段的普通通知 | **完全不显示**(窗口隐藏)并进队列;下课自动弹出详情 → 收起为胶囊 |
| 上课时段的紧急通知 | **无论是否上课立刻展开**,45 秒后收起 |
| 点击卡片空白处 / 屏幕任意位置 | 回缩为**胶囊**(不直接消失,仍可再次点开) |
| **收起后胶囊常驻** | 只要还有未处理通知,胶囊就不会消失;彻底消失只在「知道了 / 标为已读」、队列清空或进入上课时段 |
| 场景 | 展示图 |
| -------- | --------------------------------------- |
| 经典胶囊 | ![胶囊](../images/island-4-pill.png) |
| 展开详情 | ![详情](../images/island-5-clicked.png) |
| 紧急通知 | ![紧急](../images/island-3-urgent.png) |
> **本机刚录入的作业不会上岛** —— 自己通知自己没意义。
## 编辑自定义方案
## 多条通知时的排版
“设置 → 灵动岛 → 自定义 → 编辑岛内组件”进入主窗口设置页。
方案支持复制、删除、JSON 导入导出;组件支持日期、时钟、倒计时、课程、文本、分隔符、通知/作业/叫人及组合容器。
分别编辑常态 rows 与无消息展开 expandedRows,拖动调整结构,设置内容、外观、尺寸与隐藏条件。
方案保存到本机 config.json.islandLayout,不增加服务端同步。
编辑预览不创建额外桌面窗口;打开编辑页不自动切布局,应用后启用自定义方案。
保存按序进行,失败可重试,主题与关怀模式沿用客户端设置。
这是最容易出问题的地方:卡片要长高,还得刚好放得下。规则是**主进程与渲染进程各算一次同一个数**
(`islandListLayout()`,在 `@classhelper/shared` 里):
## 外观、位置与输入
- 默认只显示前 `defaultVisibleRows`(3)条,下面是「展开更多(还有 N 条)」;
- 点开后铺到 `maxCardHeight` 为止,仍放不下的改成「更多请前往应用内操作」;
- `maxCardHeight` 是主进程按工作区 / 任务栏算出的**可用高度**,随状态下发。
经典模式可调宽高、字号、主题色、黑色/半透明/渐变、停靠与边距;自定义内容根据实际测量适配工作区。
自定义方案可配置缩放、圆角、颜色和行间距,默认值与范围在 shared/island-layout.ts。
左右边距只在左/右停靠时生效,居中锚点仍在屏幕中线。
触摸屏自动进入触摸模式,保留触摸命中和失焦宽限;鼠标模式按光标控制输入穿透。
自定义常驻内容支持鼠标避让淡化及反转,交互入口、展开/编辑和触摸操作按实现暂停避让。
应用内动画开关决定岛动画,不能用主窗口的减少动态效果说明替代岛自身规则。
窗口的包围盒会**在卡片形变之前**先长高(这一步用户看不到),否则底部的按钮会被裁掉。
经典几何与配色参考见[设计令牌](../dev/winisland-design-tokens.md),实现入口见[当前实现约定](../dev/implementation.md)。
## 外观设置
## 课程与课件
客户端「设置 → 灵动岛」:
准备、上课、下课、下课前倒数和课中进度等提醒在本机配置独立开关,课程依据服务端解析后的课表。
课件接收时显示文件名、类型、教师、字节和进度,覆盖当前固定/自定义内容;完成后校验 SHA256 并回执。
客户端不会自动执行收到的文件,见[课件](courseware.md)。
| 设置项 | 区间 / 取值 | 默认 |
| --------------- | -------------------------------------- | ------------------------- |
| 高度 | 36–72px | 44 |
| 宽度 | 220–420px | 268 |
| 圆角 | 8–32px | 20 |
| 不透明度 | 40%–100% | 100% |
| **字号** | 11–20px | 13 |
| 动画速度 | 0.5×–2× | 1× |
| 主题色 | 取色器 | `#0a84ff`(Apple 系统蓝) |
| **视觉风格** | 纯黑 / 毛玻璃 / 主题色渐变 | 纯黑 |
| **显示位置** | 顶部 / 底部 × 左 / 中 / 右(6 个锚点) | 顶部居中 |
| 左右 / 上下边距 | 0–200px / 0–160px | 都是 8px |
| 跟随鼠标屏幕 | 开 / 关 | 关 |
| 动画开关 | 开 / 关 | 开 |
| 始终置顶 | 开 / 关 | 开 |
| **空闲细缝** | 开 / 关 | 关 |
## 验证与排查
几个容易困惑的点:
- **字号是"基础字号"**:岛内所有文本 = 基础字号 × 各自的排版系数(标题 1.08 / 正文 0.95 / 次要 0.78 /
徽标 0.76 / 胶囊标题 0.92 ……),所以调一个滑块就能整体放大,比例不会乱。
- **左右边距只在「左 / 右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线,
这时调左右边距不会有任何变化 —— 设置页会把那个滑块置灰并写明原因。
- **「毛玻璃」不是窗口材质**,是 CSS 半透明深色卡。用系统级背景材质(`setBackgroundMaterial`)
会在卡片外那一圈垫出一块浅色"白底面板",而且**调用过一次就再也回不去逐像素透明**。
- **「预览效果」**会本地推一条通知,立刻看到当前尺寸 / 配色 / 动画。预览是专用通道:
直接展开、失焦不收起、30 秒后自行消失。
越界的值在**主进程**统一夹紧,配置填坏了也不会让布局错乱。
## 触摸屏(希沃白板等)
触摸屏上灵动岛走的是**另一条命中链路**。
原因:常规的命中判定有两条路径 ——渲染进程转发鼠标移动、主进程读系统光标位置 ——
**两条都以"鼠标指针移动"为前提**。而手指触摸既不产生 `mousemove`、也不移动系统光标,
窗口就会永远停在"穿透"状态,**手指点不开岛**。
修法是:客户端检测到 `navigator.maxTouchPoints > 0` 后进入触摸模式,
把窗口**贴合岛体**并**始终接收输入**(Windows 上 Electron 没有窗口区域能力,这是唯一可行的办法)。
触摸模式下有三条额外的护栏,都是为了不闪、不裁:
- **窗口可见期间只缩不放**(需要变小一律等窗口隐藏之后再做);
- 命中矩形按**窗口实际尺寸**算偏移;
- 失焦宽限期放宽到 1200ms(触摸没有光标,"假失焦"会让刚展开的岛被缩回胶囊)。
非触摸屏的机器**完全不走这条路**。
## 鼠标穿透
窗口是按最大形态创建的固定包围盒,比卡片大一圈。默认整块穿透,渲染层做命中测试,
只在指针进入岛体时打开命中 —— 所以这个"大窗口"不会吞掉桌面上的点击。
另有一层保险:主进程每 120ms 用系统光标位置校正一次命中。
## 排查"闪一下"
- **开合时底座闪一下**:卡片投影是常驻的 CSS `filter`,形态切换只改参数。
给元素增删 `filter` 会让 Chromium 重建渲染表面,首帧可能被当成空内容画出去。
- **收回一瞬间闪一下**:多半是**自动化验证实例的叠影**,不是产品的问题 ——
冒烟会真的在屏幕上放一个灵动岛,如果它和你自己的客户端叠在一起,你看到的是两个窗口。
(验证实例的岛是洋红主题色,一眼可辨。)
默认 verify:desktop/verify:packaged 是后台快速检查,不显示岛或托盘。
只有显式 --visual 或 verify:desktop:visual 才进行桌面像素、透明度、动画和截图验收。
若出现多岛,先确认是否存在用户实例与显式视觉验证实例;不要把默认后台冒烟解释成可见验证岛。
+32 -73
View File
@@ -1,91 +1,50 @@
---
title: 通知与提醒
description: 通知的三档优先级、上课时段策略、紧急通知的二次确认、提醒弹在哪个端、已读与红点。
description: 优先级、课堂确认与经典/自定义常驻模式的提醒区别。
---
# 通知与提醒
通知是这套系统里规则最多的一项 —— 因为它要在"老师想说"和"别打断上课"之间做取舍。
这一页把规则一次讲清楚。
Web 发布通知需 notification:publish 和班级范围权限。通知按本地 assignDate 归档,支持按日期、发布人查看与批量发布。
服务端保存后立即广播;上课暂存与是否自动展开是客户端本机展示规则。
## 三档优先级
## 优先级与本机展示
| 优先级 | ClassHelper 班级端表现 |
| ------ | -------------------------------------------------------------------------------------------- |
| 普通 | 课间弹「新消息」胶囊,点击展开详情;**上课时段完全不显示**(窗口隐藏)并进队列,下课自动补弹 |
| 重要 | 同上,卡片上有优先级标签 |
| 紧急 | **无论是否上课都立即展开**,卡片内部红色呼吸光晕 + 「紧急」角标,无需点击,45 秒后收起 |
共享优先级为 LOW(低)、NORMAL(普通)、HIGH(重要)、URGENT(紧急)。
经典固定模式和自定义常驻模式有不同展示:
紧急通知的光晕画在**卡片内部**(`inset` 阴影),因为窗口比卡片只大 2~4px,向外的阴影会被裁成硬边。
| 场景 | 经典 fixed | 自定义 custom |
| ---------------------- | ------------------------------------------ | -------------------------------------- |
| 普通/重要/低通知、作业 | 课间显示胶囊;上课暂存,下课补弹 | 常驻内容旁提示铃铛与去重数量,点击查看 |
| 紧急通知/紧急叫人 | 无论是否上课立即展开 | 提示铃铛,由用户主动展开 |
| 普通叫人 | 上课保留可点击胶囊,不自动展开;课间可展开 | 提示铃铛,可主动查看 |
| 主动查看 | 普通通知受课堂状态限制,叫人和紧急可点开 | 上课也可主动展开 |
## 上课时段策略
经典紧急通知默认约 45 秒收起;列表与消息处理见[灵动岛](island.md)。课件传输另显示专用进度。
「上课时段」由课表实时判定(见[课表](schedules.md))。判定之后:
## 课堂发布紧急通知
| ClassHelper 班级端行为 | 上课时段 | 课间 / 放学 |
| ---------- | ---------------------------------------- | ----------- |
| 普通通知 | 收着不弹(窗口隐藏),进待发队列 | 直接弹 |
| 紧急通知 | **立刻弹** | 立刻弹 |
| 任何点击 | 一律不响应(不会展开、也不会回缩出胶囊) | 正常 |
上课时发布 URGENT,服务端先返回 409 URGENT_DURING_CLASS。
Web 显示全屏确认和倒计时,再次请求带 confirmDuringClass:true 才放行。
这是服务端发布校验,不受客户端选择 fixed/custom 影响;普通叫人和紧急叫人走[叫人](calls.md)的接口规则。
ClassIsland 那一端遵守同一套规则,否则会出现"学生在客户端看不到、却在教室大屏上被打断"。
## 已读与撤回
## 紧急通知的二次确认
客户端通知页有未读数,打开详情可标已读;班级会话的已读操作代表全班在读学生。
删除需相应员工权限和范围,实时撤回会清理客户端列表、缓存与岛队列。
同 id 的通知与叫人双播合并,不重复计数。
上课时段发紧急通知,**服务端会拦一次**(`409 URGENT_DURING_CLASS`):
## 接口入口
```
POST /api/notifications { classId, title, content, priority: "URGENT" }
→ 409 URGENT_DURING_CLASS(响应体里带着正在上的那节课)
以下省略 /api,均先鉴权;具体功能键及范围见[REST API](../dev/api.md)。
POST /api/notifications { …, priority: "URGENT", confirmDuringClass: true }
→ 201 发出去
```
| 方法与路径 | 用途 |
| ----------------------------------------------------- | --------------------------------------------------------------------- |
| GET /notifications | 列表;classId、priority、unreadOnly、keyword、date/from/to、createdBy |
| GET /notifications/days、/notifications/creators | 日期与发布人归档 |
| GET /notifications/unread-count | 未读数 |
| POST /notifications、/notifications/bulk | 单班/批量发布,批量逐班返回结果 |
| POST /notifications/:id/read、/notifications/read-all | 标记已读 |
| DELETE /notifications/:id | 撤回 |
Web 端在提交前会先查一次上课状态,命中就弹一个**全屏二次确认**:全屏遮罩 + 强警示配色,
文案是"现在为上课时间段……" + 正在上的课时段 + 待发布的标题。确认按钮带 **3 秒倒计时**
(圆环进度),倒计时结束前点不动,防误触。
**这个拦截在服务端,不是前端提醒。** 绕过界面直接调接口一样会被拦 —— 这是产品行为,不是可以关掉的提示。
## 提醒弹在哪个端
教室机器上的客户端「设置 → 提醒」里选,选择会同步到班级记录:
| 选择 | ClassHelper 客户端 | ClassIsland |
| ----------------------------------------- | ------------------------------ | ----------- |
| `ClassHelper 与 ClassIsland 都弹`(默认) | 弹窗 + 灵动岛 | 全屏提醒 |
| `只在 ClassHelper 客户端弹` | 弹窗 + 灵动岛 | 不推送 |
| `只在 ClassIsland 上弹` | 只进通知中心(不弹窗、不上岛) | 全屏提醒 |
这条规则**任何入口都遵守**,包括 ClassIsland 联动页的「下发提醒」——
那种情况下会回一句"该班教室已设置为只在 ClassHelper 客户端显示"。
管理员与教师也可以用 `PATCH /api/classes/:id/notification-channel` 改,但默认由教室机器自己定。
## 已读、红点与删除
| 能力 | 说明 |
| -------- | ------------------------------------------------------------------------------ |
| 未读红点 | 客户端侧边栏「通知」项右上角的徽标;数字来自 `GET /notifications/unread-count` |
| 标为已读 | 点开通知自动标为已读;也有「全部已读」 |
| 班级设备 | 教室机器的"已读"是**代全班**操作的(它用的是班级账号) |
| 删除 | 只有教师 / 管理员能删 |
## 发布入口只有一个实现
「通知发布」页、叫人、ClassIsland 联动页的「下发提醒」三个入口,
**共用同一份 ClassIsland 推送实现**。所以不存在"某个页面发的通知不联动"这种事 ——
以前有过,现在是硬约定:新增发通知的入口必须复用同一份代码。
## 接口
| 方法 | 路径 | 说明 |
| ------ | -------------------------------------------------------- | ------------------------------- |
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 列表(带已读状态) |
| GET | `/notifications/unread-count` | 未读数 |
| POST | `/notifications` | 发布(广播 `notification:new`) |
| POST | `/notifications/:id/read`、`/notifications/read-all` | 标记已读 |
| DELETE | `/notifications/:id` | 删除 |
完整清单见 [REST API](../dev/api.md)。
ClassIsland 在线提醒渠道已移除;本机提醒设置不会写回旧渠道字段。
+24 -67
View File
@@ -1,82 +1,39 @@
---
title: 课表
description: 周课表与「今天」时间轴、单双周、学期周数、课表来源,以及上课时段是怎么判定的。
description: 日课表、共享作息、轮换、临时换课和教学周。
---
# 课表
课表不只是"给学生们看看"的表格 —— **「上课时段」的判定完全基于它**。
课表排错,提醒就会在该弹的时候不弹。所以这一页值得读完。
Web 管理端可查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。
上课状态、灵动岛课程提醒和紧急通知确认共用服务端的课表判定。
## 两个视图
## 日课表与时间表
| 视图 | 在哪 | 内容 |
| -------------- | ----------------------------------------- | -------------------------------------------------------- |
| **周视图** | Web 端「课表管理」、客户端「课表 → 本周」 | 节次 × 星期 的 7 列表格 |
| **今天时间轴** | 客户端「课表 → 今天」 | 大时钟 + 当前状态卡 + 时间轴(已结束 / 正在上 / 下一节) |
时间表保存时间点:上课、课间、分割线、行动;日课表引用时间表,每节课程通过外键关联时间点。
可以维护多套班级作息或共享作息、复制日课表、调整课程、配置启用规则和群组。
时间点排序不会改变课程关联。行动配置当前保存数据,不执行外部动作。
「今天」时间轴是客户端默认打开的那一屏:
日课表按星期、周次范围和轮换规则启用,支持 1–9 周轮换;单双周是 2 周轮换的特例,第 1 教学周为单周。
可以预定某天使用某张表,临时换课/叠加层保留原表。相同日期的最终启用选择由服务端唯一解析器完成。
- **大时钟**:时:分(大字)+ 秒(小字),下面一行是日期 / 星期 / 第 N 教学周。
- **当前状态卡**:「正在上 XXX」+ 时段地点 + **距下课 X 分钟**;没课时显示"今天的课都上完了"或"今天没有课程安排"。
- **时间轴**:左侧时间栏(开始 / 结束)+ 竖向轨道与节点 + 右侧课卡。已结束的置灰、
正在上的用强调色实底 + 进度条 + 距下课倒计时、下一节用橙色标记 + "还有多久开始"。
## 教学周
## 单双周
班级有自己的学期周数;全校上限由“学期周次”设置,默认 30、可设 1–60,班级不能超过该上限。
默认班级周数为 20。管理员可在界面修改全校开学日和逐周起止区间,班级可有独立设置。
计算优先使用班级逐周区间、再全校区间;无适用区间时按开学日线性计算。
开学日解析还会参考第 1 周起点,环境变量 TERM_START_DATE 是兜底,具体顺序见 `lib/term.ts`。
一门课可以设成**每周**(默认)、**单周**或**双周**。判定口径是 **第 1 周 = 单周**。
## 上课状态与提醒
从 ClassIsland 导入时,`WeekCountDiv`(`0=每周 / 1=单周 / 2=双周`)会换算成这套 `ALL / ODD / EVEN`。
**3 周及以上的轮换没法用单双周表达**,导入时会给出 warning 并按"每周"处理 ——
如果学校里真的是三周轮换,目前只能用两份课表 + 手工切换的方式绕过。
`GET /api/schedules/current?classId=<id>&at=<ISO时间>` 返回 inClass/current/next/week/serverTime 等状态;at 用于诊断。
`/schedules` 是新模型的兼容门面,旧 Schedule 只保留迁移审计记录。
经典岛普通通知上课暂存、下课补弹;普通叫人保留可点击胶囊,紧急消息立即展开。
自定义常驻岛先显示铃铛,由用户主动查看。紧急通知在课堂发布仍需服务端二次确认。
## 学期周数
## 权限与导入
每个班有自己的**学期周数**(`Class.termWeeks`,默认 20,**上限 30**),只有管理员能改。
它决定三件事:课表的周次下拉能选到第几周、`weekEnd` 的默认值、以及"现在是第几教学周"。
周次换算的起点是服务端 `.env` 里的 `TERM_START_DATE`(当学期第 1 教学周的周一)。
> **`TERM_START_DATE` 设错,整套周次都错。** 它不在界面上改,要改 `.env` 后重启服务端。
> 上限三处必须一致(`MAX_TERM_WEEK`、周次校验、班级设置),曾经出现过"班级能设到 40 周、
> 而课表只到 30 周"的错位。
## 上课时段是怎么判定的
服务端有一个接口专门回答"这个班现在是不是在上课":
```
GET /api/schedules/current?classId=<班级 id>[&at=<ISO 时间>]
```
返回 `{ inClass, current, next, week, serverTime }`。`at` 参数可以覆盖判定时刻,用来联调与测试。
判定结果被用在四处,规则完全一致:
1. 客户端灵动岛:上课时段普通通知**不显示**(窗口直接隐藏)并进队列,下课自动补弹;
2. ClassIsland 插件:同样暂存,下课后补弹;
3. 服务端发布接口:上课时段发紧急通知会被拦(`409 URGENT_DURING_CLASS`),确认后放行;
4. Web 端:提交紧急通知前先查一次状态,命中就弹全屏二次确认。
## 课表的"来源"字段
每一行课表都记着它是从哪来的:
| 来源 | 什么时候产生 | 会被自动清理吗 |
| ------------- | ---------------------------------------------- | ---------------------------- |
| `manual` | Web 端手工录入、从 ClassIsland JSON 一次性导入 | **不会**,永远保留 |
| `classisland` | 教室插件上报同步过来的 | 会——本次没再上报的行会被删掉 |
这条规则解决了"老师在 ClassIsland 里删掉一节,小助手这边却永远留着"的幽灵课问题,
同时保证老师在小助手这边手排的课不会被插件覆盖掉。
> 去重键是「星期 + 开始时间 + 单双周」。如果老师手排的某一节恰好和上报的同一时间槽重合,
> 那一行会被上报"收养"(保留原本的 `manual` 来源),下一次清理不会删它。
## 排课 / 改课 / 导入
- 手工排课在 **课表管理**(管理员 / 本班班主任)。科目下拉是全校统一的 18 科目录,
选中一个该班还没有的科目会自动建课。
- 从 ClassIsland 档案导入最省事,课表、节次时间、单双周会一起进来,
见[表格与课表导入](imports.md)。
- 课表变更后会广播 `schedule:updated`,客户端与灵动岛立刻刷新。
日课表写入需 schedule:write,并通过管理员或本班班主任检查;时间表写入需 timetable:manage。
离线导入完整 ClassIsland 档案可创建班级自有作息,不依赖在线联动插件。
完整导入保留 1–9 周轮换、空课、停用、群组和预定;旧预览界面的单双周展示不代表新模型只能表达双周。
见[表格与课表导入](imports.md)。修改后广播 schedule:updated,客户端刷新。
+2
View File
@@ -4,6 +4,8 @@
href: schedules.md
- name: 作业
href: homeworks.md
- name: 课件上传与传输
href: courseware.md
- name: 通知与提醒
href: notifications.md
- name: 叫人
+61 -125
View File
@@ -1,138 +1,74 @@
---
title: REST API
description: ClassHelper后端全部接口一览:方法、路径、权限与说明。
description: 现行接口分组、关键路径和权限边界。
---
# REST API
## 通用约定
API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib/http.ts` 为准,
校验错误映射 422 VALIDATION_ERROR,表格体积超限为 413 IMPORT_TOO_LARGE。
全部挂载入口以 `modules/registry.ts` 为准;完整参数、返回类型、权限和枚举以模块的 module/schemas/service 与 shared 契约为准。
本页列常用入口,不重复复制每条路由实现。
- 所有接口前缀 `/api`(设备接口也一样);
- 统一响应体:`{ "success": true, "data": {}, "message": "" }`,错误为 `success: false` + `code`;
- 除登录、健康检查与设备接口外,都需要 `Authorization: Bearer <token>`;
- **ClassIsland 设备接口用 `X-ClassIsland-Token`**,不走 JWT(见[ClassIsland 插件](classisland-plugin.md));
- 请求体校验走 zod(`middleware/validate.ts`),错误由 `middleware/error.ts` 统一映射。
## 身份与班级
## 接口一览
| 方法与路径(省略 /api) | 行为及边界 |
| ----------------------------------------------------------------------- | -------------------------------------------------------------- |
| POST /auth/login、/auth/class-login | 员工/班级登录,共享登录限流;学生无账号 |
| GET /auth/me;POST /auth/logout;PATCH /auth/password | 当前身份、退出、员工本人改密码 |
| GET/POST /classes;GET/PATCH/DELETE /classes/:id | 班级查询/管理,写入需 class:manage 与管理员业务断言 |
| PATCH /classes/:id/class-account、/classes/:id/head-teacher | 班级凭据/班主任维护 |
| PUT /classes/:id/subject-teachers | 任课关系设置,Course 为唯一来源 |
| GET/PATCH /classes/:id/student-grade-query | 查看/修改班级个人成绩查询开关,班级端不能修改 |
| GET/POST /classes/:id/students;DELETE /classes/:id/students/:studentId | 本班名单及维护,受名单功能权限和班级关系约束 |
| /students、/teachers | 名单与账号 CRUD;读/写使用不同权限键,具体角色限制读取对应模块 |
| POST /teachers/:id/reset-password | 指定新密码或默认重置;学生无对应接口 |
| /permissions/me、/catalog、/groups、/users | 当前有效权限、目录、组与账号分组 |
| 方法 | 路径 | 权限 | 说明 |
| --------------------------- | ------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------ |
| POST | `/auth/login` | 公开 | 登录(教师 / 管理员的**工号** + 密码);学生不是账号,拿学号登录只会得到 401 |
| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → 班级会话 |
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) |
| PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) |
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
| GET | `/permissions/me` | 登录 | 当前账号的生效权限(**无权限键**;班级端会话返回空集) |
| GET | `/permissions/catalog` | `permission:manage` | 权限键目录(权限页整棵勾选树的唯一来源) |
| GET / POST | `/permissions/groups` | `permission:manage` | 权限组列表 / 新建 |
| PATCH / DELETE | `/permissions/groups/:id` | `permission:manage` | 改名 / 改描述 / 整体替换权限键;内置组不可删 |
| GET | `/permissions/users` | `permission:manage` | 成员列表(所属权限组 + 生效权限数量) |
| PUT | `/permissions/users/:id/groups` | `permission:manage` | 整体替换某账号的权限组(空数组 = 回落角色默认模板) |
| GET | `/classes` | 登录 | 班级列表(按权限收敛) |
| GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 班主任 / 各科任课老师) |
| POST | `/classes` | 管理员 | 创建班级(自动生成班级码,可指定班主任) |
| PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 |
| PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) |
| PATCH | `/classes/:id/head-teacher` | 管理员 | 设置 / 更换班主任 |
| GET | `/classes/:id/classisland-status` | 班级可见 | 本班 ClassIsland 联动状态(教室客户端设置页用) |
| GET | `/classes/:id/notification-channel` | 班级可见 | 读取「通知显示到哪个端」 |
| PATCH | `/classes/:id/notification-channel` | 班级账号 / 教师 / 管理员 | 设置「通知显示到哪个端」 |
| GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生 |
| DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 |
| PUT | `/classes/:id/subject-teachers` | ADMIN | 设置各科任课老师(班级 + 科目 + 教师) |
| GET / POST / PATCH / DELETE | `/courses` | 登录(写:班主任 / 管理员) | 课程管理 |
| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(`week` 过滤周次范围) |
| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) |
| GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) |
| POST / PATCH / DELETE | `/schedules[/:id]` | 班主任 / 管理员 | 课表增删改(广播 `schedule:updated`) |
| GET | `/homeworks?classId=&courseId=&date=&pendingOnly=&keyword=` | 登录 | 作业列表(支持按所属日期;学生带完成状态,教师带完成人数) |
| GET | `/homeworks/days?classId=&from=&to=` | 登录 | 哪些天有作业(日期高亮) |
| GET | `/homeworks/:id` | 班级可见 | 作业详情 |
| POST | `/homeworks` | 教师 / 管理员 / **班级账号** | 发布作业(附 `assignDate`;班级账号用于教室机器录入,归属班主任) |
| PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) |
| PATCH | `/homeworks/:id/status` | 登录 | 标记完成 / 取消(广播 `homework:status`) |
| GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 |
| POST | `/homeworks/bulk` | `homework:write` | 一次给多个班级布置同一份作业(`subjectName` 跨班按名匹配科目;逐班结果) |
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态;另支持 `date` / `from` / `to` / `createdBy`) |
| GET | `/notifications/unread-count` | 登录 | 未读数(红点) |
| POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发紧急通知需 `confirmDuringClass` |
| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) |
| DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 |
| GET | `/notifications/days?classId=&from=&to=&createdBy=` | 登录 | 通知按天归档(日期 + 条数,日期取 `assignDate`) |
| GET | `/notifications/creators?classId=&from=&to=&date=` | 登录 | 通知按发布人归档 |
| POST | `/notifications/bulk` | `notification:publish` | 一次给多个班级发同一条通知(逐班结果,**单班失败不影响整批**) |
| POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) |
| GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) |
| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 |
| GET | `/grades/stats?classId=&courseId=&examName=` | 教师 / 管理员 | 等级分布 + 各课程平均得分率 |
| POST | `/grades`、`/grades/bulk` | 班主任 / 管理员 | 单条 / 批量录入(广播 `grade:updated`) |
| PATCH / DELETE | `/grades/:id` | 班主任 / 管理员 | 修改 / 删除成绩 |
| GET | `/students?classId=&keyword=` | **管理员** | 学生名单 |
| POST / PATCH / DELETE | `/students[/:id]` | **管理员** | 学生名单增删改(无密码概念;重置密码接口已下线 → 404) |
| GET | `/teachers?keyword=` | **管理员** | 教师列表 |
| POST / PATCH / DELETE | `/teachers[/:id]` | **管理员** | 新建 / 编辑 / 删除教师账号 |
| POST | `/teachers/:id/reset-password` | **管理员** | 修改 / 重置教师密码(`newPassword` 可选,留空 = 默认初始密码) |
| GET | `/dashboard/summary`、`/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 |
| GET | `/imports/template?kind=&format=` | 管理员 / 班主任 | 导入模板下载(`csv` 走 JSON,`xlsx` 走二进制) |
| POST | `/imports/table/preview` | 管理员 / 班主任 | 上传表格(base64)解析预览:列名 + 前 20 行 + 校验问题 + 建议映射 |
| POST | `/imports/table/commit` | 管理员 / 班主任 | 按字段映射与写入模式导入(成绩 / 学生名单 / 教师名单) |
| POST | `/imports/time-layout/preview` | 管理员 / 本班班主任 | 解析 ClassIsland 时间配置 JSON(只解析不落库) |
| GET / POST / DELETE | `/imports/time-layout[/:id]` | 管理员 / 本班班主任 | 时间配置列表 / 导入(`replace` 覆盖、`merge` 合并)/ 删除 |
| POST | `/imports/class-plan/preview`、`/imports/class-plan` | 管理员 / 本班班主任 | ClassIsland 课程表解析预览 / 导入(支持单双周) |
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) |
| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) |
| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) |
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 23 张表行数 / 备份列表 / 定时配置) |
| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) |
| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
| GET | `/database/export`、`/database/sqlite-file` | **管理员** | 下载 JSON 快照 / 下载数据库文件(后者仅 SQLite) |
| POST | `/database/import` | **管理员** | 导入快照(base64,整库覆盖;可在 SQLite / MySQL 之间互迁) |
| GET / PUT | `/database/backup-schedule` | **管理员** | 定时备份配置(enabled / intervalHours / keepCount) |
| POST / GET | `/database/switch`、`/database/switch/jobs/:id` | **管理员** | **一键切换数据库**(异步任务)/ 轮询进度 |
| POST | `/integrations/classisland/report` | **设备令牌** | 插件上报状态 + 课表 + 节次时间 |
| GET | `/integrations/classisland/pending` | **设备令牌** | 插件拉取尚未确认的提醒(离线期间老师发的通知,重连后补齐) |
| POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) |
| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland |
| GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) |
| POST | `/clients/heartbeat` | 登录 | 客户端每 30 秒上报(**无权限键**);返回更新目标 + 安装包信息 + 一条待执行命令 |
| POST | `/clients/commands/:id/ack` | 登录 | 命令执行回执(**无权限键**,靠 body 的 `deviceKey` 校验归属,幂等) |
| POST | `/clients/commands` | `remote:control` | 下发远控命令(关机 / 重启 / 注销 / 锁屏;自动撤销该机更早的未回执命令) |
| GET | `/clients/devices?classId=` | `client:manage` | 客户端设备列表(版本 / 在线 / 待执行命令) |
| POST | `/clients/updates` | `client:update-push` | 统一下发客户端更新(按设备 / 班级 / 全部) |
| DELETE | `/clients/devices/:id` | `client:manage` | 移除设备记录(换机 / 旧机器清理) |
| GET | `/update/client/releases` | `client:manage` | 服务端已缓存了哪些客户端安装包 |
| POST | `/update/client/fetch` | `client:update-push` | 服务端代教室机器从 GitHub 下载安装包并登记 sha256 + size |
| POST | `/update/client/releases` | `client:update-push` | 手工登记已放进磁盘的安装包(内网 / U 盘分发兜底) |
| DELETE | `/update/client/releases/:version` | `client:update-push` | 删除缓存的安装包(元数据 + 磁盘文件) |
| GET | `/update/client/download/:version` | 登录 | 下载安装包(**故意不挂权限键**,班级端要能下;`res.download`) |
| GET / PUT | `/ai/settings` | `ai:manage` | 大模型设置(API Key 加密存储、**只进不出**,只回 `hasApiKey` / `keyHint`) |
| POST | `/ai/interpret` | `ai:use` | 一句话解析(**只解析不落库**);回 `action` / `params` / `preview` / `confidence` |
| GET | `/ai/audit?limit=` | `ai:manage` | 助手使用记录(每次解析写一条 `AiAudit`) |
| GET / PUT | `/system/settings` | `system:manage` | 系统设置(`developerMode`:开启后 500 响应带 `error.stack`) |
| GET / PUT | `/system/autostart` | `system:manage` | 开机自启状态 / 开关(Windows 注册表 Run 项,Linux systemd 单元) |
| GET | `/system/diagnostics` | `system:manage` | 运行诊断(版本 / Node / 运行时长 / 数据目录 / 在线客户端数 / 已加载模块) |
| GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块) |
## 教学与文件
另有三个探针(不带 `/api` 前缀):`/healthz`(存活)、`/readyz`(就绪)、以及 `/api/health`(详情)。
| 路径/动作 | 行为及边界 |
| ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| /courses | 班级科目与任课关系 |
| GET /schedules、/grid、/current、/active-week | 新关系模型的兼容投影,按班级范围读取 |
| POST/PATCH/DELETE /schedules[/:id] | 兼容 CRUD,写新模型,不双写旧 Schedule |
| POST /schedules/swap、/overlay、/scheduled;GET /schedules/scheduled;DELETE /schedules/scheduled/:id | 换课、临时层、预定 |
| /time-layouts;POST /time-layouts/:id/clone、/:id/import | 时间表 CRUD/克隆/导入,写入 timetable:manage |
| /class-plans;PUT /class-plans/:id/entries;PATCH /class-plans/:id/enabled | 日课表 CRUD、整表保存、启用,写入 schedule:write + 班主任/管理员 |
| POST /class-plans/:id/clone、/class-plans/import、/class-plans/import/profile;GET /class-plans/:id/export | 复制、导入导出及完整档案导入 |
| /class-plan-groups;GET /class-plan-groups/selection;POST /class-plan-groups/select | 群组及当前/临时选择 |
| /homeworks、/homeworks/bulk、/:id/status、/:id/submissions | 作业、批量、完成与未交;员工功能键+科目检查,班级端可录作业 |
| /notifications、/notifications/bulk、/days、/creators、/unread-count、/:id/read、/read-all | 通知、归档和已读;发布需 notification:publish,课堂紧急须确认 |
| POST /calls | 叫人,call:create + 范围检查 |
| /grades、/grades/bulk、/stats、/levels、/my、/overview、/collective、/student/:studentNo、/exams、/ranking | [成绩与隐私](../app/grades.md),查询列表参数用 studentId,写入仍按科目 |
| /imports/template、/table/preview、/table/commit | 表格导入,按 kind 再检查角色、导入权限、班级和科目 |
| /imports/class-plan/preview、/imports/class-plan | 旧预览与完整档案提交;提交走关系化导入 |
| /imports/time-layout[/:id]、/time-layout/preview | 时间配置兼容入口,写入/删除需 timetable:manage |
| GET /courseware、/courseware/:id、/:id/download | 课件目录/详情/下载;员工需 courseware:read,班级端按可见范围 |
| POST /courseware/upload、/:id/push;PATCH /courseware/:id/visibility;DELETE /courseware/:id | 写入 courseware:write + 员工身份;本人文件、任课班级与可见范围限制 |
| GET /courseware/transfers;POST /courseware/transfers/:id/ack | 班级端补领、逐设备回执 |
## 几条容易踩的接口约定
## 设备、系统与运维
| 约定 | 说明 |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| **`POST /homeworks` 故意不加 `requireRole`** | 权限判在 `homeworks.service.createHomework` 里(staff 或本班班级账号),因为教室机器也要能录作业 |
| **班级账号的 `sub` 是班级 id** | 写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键 |
| **登录限流挂在两个入口上** | `/auth/login` 与 `/auth/class-login` 都挂 `loginRateLimiter()` |
| **角色判定在密码校验之前** | 否则"密码错 / 密码对但账号停用"的差异会把学生账号密码变成可离线验证的预言机 |
| **会渲染成链接的字段要校验 scheme** | `Homework.attachmentUrl` 只允许 `http(s)://` 或站内相对路径 |
| **表格导入 8MB ↔ 请求体 12MB** | 文件以 base64 放进 JSON(约 4/3 倍),超限映射成 413 `IMPORT_TOO_LARGE` |
| **三个接口故意不挂权限键** | `/permissions/me`、`/clients/heartbeat`、`/update/client/download/:version` —— 前两者由班级端(`CLASS_DEVICE`,权限集为空)调用,下载接口要能装包;保护只有"已登录 + 限流" |
| **查询参数校验失败是 422** | `middleware/validate.ts` 的 zod 解析错误由错误中间件映射成 422 `VALIDATION_ERROR`(不是 400) |
| **批量发布逐班判定、不整批回滚** | `/notifications/bulk` 与 `/homeworks/bulk` 的单班失败(403 越权 / `URGENT_DURING_CLASS`)不影响其它班,成功班照常落库并实时推送 |
| **`/ai/interpret` 只解析不落库** | 助手只回结构化参数与中文预览;落库由前端确认后调既有端点 —— 权限与上课时段保护都在那条路径上 |
| 路径/动作 | 行为及边界 |
| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| POST /clients/heartbeat、/clients/commands/:id/ack | 登录设备心跳/回执,校验 deviceKey 和归属 |
| GET /clients/devices;DELETE /clients/devices/:id | 设备读取/维护,分别检查功能键和范围 |
| POST /clients/commands | remote:control + 对应范围,白名单动作 |
| POST /clients/updates | client:update,安装策略 immediate/after-school/idle |
| GET /update/check | 员工需 update:check;强制刷新另检查 update:force |
| GET /update/client/releases | 缓存版本读取,按路由允许的读取权限 |
| POST /update/client/fetch、/update/client/releases;DELETE /update/client/releases/:version | 安装包写入口统一 client:update |
| GET /update/client/download/:version | 已登录下载,不挂员工权限键,供班级端更新 |
| /term;POST /term/auto;GET /term/holidays | 学期读取/编辑/假日建议,term:read/term:write |
| GET/PUT /term/promotion;POST /term/promotion/run | 升级计划、预览和执行,system:manage,执行还做业务校验 |
| /archives、/archives/transferred、/archives/transferred/:id/restore | 历史归档与撤回转出,archive:manage;不提供创建归档代替年度升级的入口 |
| /database/status、/test-connection、/backups、/export、/import、/sqlite-file、/backup-schedule、/switch | database:manage;目标空库、快照及换库子进程保护 |
| /system/settings、/autostart、/diagnostics | system:manage,非安装形态自启动返回不可用原因 |
| /ai/settings、/interpret、/audit | ai:manage/ai:use;只解析待确认动作,密钥只进不出 |
| /dashboard/summary、/dashboard/term | 按范围读取看板与周次 |
## 相关
- 实时事件见[实时事件(Socket.IO)](realtime.md);
- 权限矩阵见[角色与权限](permissions.md);
- 数据模型见[数据模型](data-model.md)。
探针 /healthz 和 /readyz 不带 API 前缀;/api/health 提供版本与模块详情。
课件上传是原始文件流,512 MB 限制独立于表格 JSON 的 12 MB 限制;metadata 走上传查询 schema。
心跳 agentProtocol:2 使用统一响应 data,旧协议兼容字段只用于升级过渡。
更多说明见[权限](permissions.md)、[实时事件](realtime.md)、[数据模型](data-model.md)。
+46 -148
View File
@@ -1,168 +1,66 @@
---
title: 架构总览
description: 目录结构、技术栈、模块化设计与几条贯穿全局的工程约定。
description: 工作区结构、模块注册、共享契约与数据流。
---
# 架构总览
## 技术栈
ClassHelper 是 pnpm workspace:Express/Prisma/Socket.IO 服务端、Vue Web 管理端、Electron 桌面客户端与共享包。
依赖版本以根和各包的 `package.json`、`pnpm-lock.yaml` 为准。文档站和纯静态官网不在 workspace 内。
| 层 | 选型 | 版本(`package.json` 实际声明) |
| ------ | -------------------------------------------------------------------- | ---------------------------------------------------------- |
| 包管理 | pnpm workspace | 11.8.0 |
| 语言 | TypeScript | 5.9.3(`typescript-eslint` 要求 < 6.1,因此未用 7.x) |
| 后端 | Node.js + Express | ≥ 20.19 / 5.2 |
| ORM | Prisma + driver adapter | 7.10(+ `@prisma/adapter-libsql`,切 MySQL 用 `-mariadb`) |
| 实时 | Socket.IO | 4.8 |
| 认证 | jsonwebtoken + bcryptjs | 9.0 / 3.0 |
| 校验 | zod | 4.6 |
| Web 端 | Vue + Vite + Element Plus + Pinia + Vue Router + Axios | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 |
| 客户端 | Electron + Vue + Element Plus + Pinia + Socket.IO Client + IndexedDB | 44.3 / 3.5 / 2.14 / 4.0 / 4.8 |
| 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) | net8.0 |
| 打包 | electron-builder(nsis / portable)+ NSIS | 26.15 |
| 文档站 | docfx | 2.81 |
## 目录与入口
## 目录结构
| 路径 | 职责 |
| --------------------------------------------- | ------------------------------------------- |
| `packages/shared/src/` | 类型、权限目录、动效、课表/岛/课件契约 |
| `packages/server/src/app.ts` | 探针、限流、中间件、模块挂载、静态 Web 托管 |
| `packages/server/src/index.ts` | 启动、自检、HTTP、Socket.IO 与退出清理 |
| `packages/server/src/modules/registry.ts` | 全部启用模块的唯一注册表 |
| `packages/server/src/lib/` | 鉴权范围、日课表解析、周次、快照与系统设置 |
| `packages/server/prisma/` | 单一 schema、SQLite 迁移、演示 seed |
| `packages/web-admin/src/router/` | Web 路由、标题、图标、权限 |
| `packages/web-admin/src/config/navigation.ts` | 菜单分组,页面属性读取路由 |
| `packages/desktop-client/src/main/` | 窗口、配置、岛、设备代理、更新、课件下载 |
| `packages/desktop-client/src/preload/` | contextBridge 白名单 |
| `packages/desktop-client/src/island/` | 经典岛、自定义运行时、课程与课件提醒 |
| `packages/desktop-client/src/renderer/` | 主窗口页面、状态、IndexedDB 与岛桥接 |
| `scripts/` / `deploy/` | 验证、版本、打包、部署与运维 |
| `docs/` / `website/` | 文档站与官网,Pages 构建时拼接 |
```
class-helper/
├── package.json # 根脚本(dev / build / db:* / verify:* / dist:* / docs:* / icons)
├── pnpm-workspace.yaml # workspace 定义 + allowBuilds(pnpm 11 依赖构建白名单)
├── tsconfig.base.json # 共享 TS 基础配置(ESM + NodeNext + strict)
├── eslint.config.mjs # ESLint 扁平配置(TS + Vue)
├── .prettierrc.json # 单引号 / 110 列 / LF / 尾逗号
├── build/classhelper.{png,ico} # **图标源**(设计导出、手工放入);pnpm icons 从它派生其余全部图标
├── scripts/
│ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手
│ ├── generate-icons.mjs # 启动 Electron 跑 icons/render.cjs(= pnpm icons)
│ ├── icons/render.cjs # 把 classhelper.png 重采样成各端图标(CommonJS)
│ ├── version.mjs # 版本号单一来源的读写与一致性校验(= pnpm version:*)
│ ├── dist-server.mjs # 服务端 + Web 管理端打包(组装运行时 → NSIS 安装程序)
│ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟
│ ├── lib/ # electron-env / node-runtime / smoke-class
│ ├── nsis/server-installer.nsi# 安装程序脚本模板(版本号由 dist-server 注入)
│ └── ui-smoke/ # Web 管理端 UI 真实点击回归(Electron 驱动)
├── deploy/ # 生产部署:Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf
│ ├── install.sh # Linux 一键安装器(TUI;native/docker 双形态;自带 --check 体检)
│ ├── classhelper # 运维命令本体(软链 /usr/local/bin/classhelper)
│ ├── verify-linux.sh # Linux 安装/改密/备份/升级/回滚验收(自统计 N/N 项)
│ ├── tools/admin-cli.mjs # 离线账号工具(改密、列账号、SQLite 一致性快照)
│ ├── classhelper.service # systemd 单元模板
│ └── package.runtime.json # 运行时依赖清单(安装包 / 容器 / Linux 部署共用)
├── docs/ # **文档站**(docfx 项目,发布到 GitHub Pages)
│ ├── docfx.json # docfx 配置
│ ├── toc.yml # 顶栏导航;各子目录各有一份 toc.yml 作为侧栏
│ ├── index.md # 首页
│ ├── get-started/ app/ management/ dev/ # 四个章节
│ ├── templates/classhelper/ # docfx 模板覆盖(_master.tmpl + public/main.css + 图标)
│ ├── images/ # 展示图:文档站 / README / 官网共用的同一份(1× 逻辑像素)
│ ├── screenshots/ # 回归测试留档的截图(island / client / web-mobile / classisland),页面不展示
│ ├── build.mjs # 构建入口(注入版本号与 404 的 base)
│ ├── serve.mjs # 本地预览(:5181)
│ └── _site/ # 构建产物(gitignore)
├── website/ # 产品官网(纯静态,无构建;node website/serve.mjs 本地预览)
│ └── assets/{styles.css,motion.js,main.js,icon.png,shots/}
└── packages/
├── shared/ # 三端共享:类型契约、常量、权限矩阵、动效令牌、工具函数
│ └── src/{types,constants,permissions,motion,island-layout,utils,index}.ts
├── server/ # 后端服务
│ ├── prisma/schema.prisma # 数据模型(24 个 model,只有一份,切库改 provider)
│ ├── prisma/migrations/ # 迁移历史(10 个)
│ ├── prisma/seed.ts # 种子数据(会清空业务表后重建演示数据)
│ ├── scripts/ # verify-e2e.mjs / verify-classisland.mjs / apply-column-migrations.cjs
│ └── src/
│ ├── app.ts # Express 装配(探针 + 限流 + 模块挂载 + 静态托管 + 兜底)
│ ├── index.ts # 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
│ ├── config/env.ts # 环境变量校验(zod)+ 生产配置自检
│ ├── lib/ # access(RBAC) / snapshot(全库 JSON 快照) / db / db-bootstrap /
│ │ # session / web-static / http / jwt / logger / mappers / version …
│ ├── tools/apply-snapshot.ts # 跨库迁移的子进程入口
│ ├── middleware/ # auth / error / validate / security(helmet + 限流 + 耗时日志)
│ ├── realtime/ # socket.ts(房间)+ bus.ts(事件总线)
│ └── modules/ # 15 个功能模块 + registry.ts(模块注册表)
├── classisland-plugin/ # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace)
│ ├── manifest.yml
│ ├── src/Plugin.cs
│ ├── src/Services/ # BridgeService / ScheduleMapper / ClassPlanWriter /
│ │ # ClassHelperNotificationProvider
│ ├── src/Views/ # BridgeSettingsPage.axaml(Avalonia 设置页)
│ └── src/Interop/ClassHelperClient.cs # HTTP 客户端 + 逐字段对齐服务端 zod 的 DTO
├── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus + PWA)
│ └── src/{api,stores,router,layouts,views,components,composables,styles}
└── desktop-client/ # 桌面客户端(Electron + Vue 3)
├── electron-builder.yml
├── scripts/{build-main,dev,smoke,dist-win,check-bundle-secrets}.mjs
└── src/
├── main/ # 主进程:窗口 / 单实例 / 配置持久化 / 托盘 / 灵动岛 / 冒烟
├── preload/ # contextBridge 安全桥(index.ts + island.ts)
├── island/ # 灵动岛渲染进程(独立透明置顶窗口)
├── types/desktop.d.ts # 主进程 ↔ 渲染进程契约
└── renderer/ # 渲染进程:api / stores / cache / router / views / island
```
## 模块与数据流
## 模块化设计
功能模块使用 `*.module.ts` 路由、`*.schemas.ts` 校验、`*.service.ts` 业务;新增/下线改 `registry.ts`。
注册表包含教学业务、课件、日课表/时间表/群组、权限、客户端、系统、AI、升级和数据管理模块。
路由与模块总数直接读取注册表,不在文档复制另一份数组。
后端每个功能是一个独立目录,统一契约:
教师在 Web 提交 → 服务端校验权限、班级和科目范围 → 写数据库 → 事件总线按房间广播 → Web/客户端刷新。
客户端先维护 IndexedDB 缓存,断网读取缓存;主窗口通过 preload 与主进程通信,主进程管理唯一原生岛窗口。
课件先流式上传到服务端文件目录,再由客户端下载、验摘要并独立回执。
```ts
// packages/server/src/modules/registry.ts(当前 21 个模块)
export const apiModules: ApiModule[] = [
authModule,
aiModule, // 大模型助手(只解析不落库,见 docs/dev/api.md)
classesModule,
coursesModule,
schedulesModule,
homeworksModule,
notificationsModule,
callsModule,
importsModule,
integrationsModule,
gradesModule,
studentsModule,
teachersModule,
archivesModule,
termModule,
dashboardModule,
databaseModule, // 数据库管理(仅管理员)
updateModule, // 更新检查 + 客户端安装包缓存与统一下发
permissionsModule, // 权限组管理
clientsModule, // 客户端设备心跳 / 远控命令
systemModule, // 开发者模式 / 开机自启 / 运行诊断
].filter((module) => module.enabled !== false);
```
## 工程边界
- **新增功能**:新建 `modules/<name>/`(`*.schemas.ts` 校验、`*.service.ts` 业务、`*.module.ts` 路由),
在 registry 里加一行即可,`app.ts` 不用改;
- **下线功能**:删掉那一行,或把 `enabled` 置为 `false`,接口立即从路由表消失;
- **实时推送解耦**:业务模块只调 `realtime/bus.ts` 的 `emitToClass/emitToUser`,不直接依赖 Socket.IO 实例;
- **共享契约**:所有 DTO 与类型集中在 `@classhelper/shared`(改完要 `pnpm build:shared`),三端共用。
- 三端契约只放 shared,改后先 `pnpm build:shared`。
- 学生不是账号;班级会话读取 `Class`,教师/管理员读取 `User`。
- 功能权限在路由判定,范围与科目权限在 service 判定;前端同规则隐藏入口。
- 课表以关系模型为真值,所有在线启用选择走 `lib/class-plan.ts`;周次走 `lib/term.ts`。
- 作业/通知使用本地 `assignDate`;实时使用 `SOCKET_EVENTS/SOCKET_ROOMS` 和 `emitToClass/emitToSession`。
- 数据库备份清单随模型维护,课件文件另行备份。
- Electron 保留 CSP 与导航限制,桥接只传纯数据;打包凭据门禁不能取消。
- ClassIsland 在线联动已退役,仅保留离线 JSON 导入与设计参考。
## 几条贯穿全局的硬约定
完整规则见 [AGENTS.md](https://github.com/LoveChengke/ClassHelper/blob/master/AGENTS.md)。
这些都是踩过坑之后固化下来的,改代码前值得先看(完整版在 `AGENTS.md`):
| 约定 | 为什么 |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
| **统一响应与校验** | 用 `lib/http.ts` 的 `sendOk/sendCreated`,用 `middleware/validate.ts` 挂 zod,错误交给 `middleware/error.ts` |
| **功能级准入看权限键** | 路由挂 `requirePermission('…')` / `requireAnyPermission(…)`,角色只是默认模板(`ROLE_DEFAULT_PERMISSIONS`)—— 见 `docs/dev/permissions.md` |
| **权限判在服务层** | 权限组只管功能级;班级维度与科目维度仍用 `lib/access.ts` 的断言(`assertCanManageSubjectContent` 等),**权限组不可覆盖** —— 前端隐藏 ≠ 权限 |
| **周一为新一周的第一天** | 周次口径唯一:`resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)` |
| **作业按 `assignDate` 归类** | 用 `createdAt`(UTC)会在晚上把作业算到第二天 |
| **新增实时事件要改三处** | 服务端 bus/socket、Web 端订阅、客户端订阅(灵动岛也依赖) |
| **跨桥接只传纯数据** | 传 Vue 的 `ref.value` 会在进入 preload 之前就抛 `could not be cloned` |
| **桌面端产物里不得有凭据** | asar 可直接解包;构建有 `check-bundle-secrets` 门禁 |
| **图标只有一个图形源** | 别改 `scripts/icons/` 的派生逻辑,换图标就换 `build/classhelper.{png,ico}` |
| **前端不写死颜色** | 深色主题靠语义令牌,写死的颜色在深色下会露馅 |
## 本地开发
## 开发启动
```bash
pnpm install
cp packages/server/.env.example packages/server/.env # 至少改 JWT_SECRET
pnpm db:generate && pnpm --filter @classhelper/server db:deploy
pnpm build:shared # 必须先构建 shared,否则后续全部 ERR_MODULE_NOT_FOUND
pnpm db:seed # 演示数据(会清空业务表)
pnpm dev # shared watch + 后端 4000 + Web 端 5173
# 复制 packages/server/.env.example 为 .env,并配置独立 JWT_SECRET 与数据库
pnpm db:generate
pnpm --filter @classhelper/server db:deploy
pnpm build:shared
# 只有明确需要重建演示数据时才运行 pnpm db:seed(会清空业务表)
pnpm dev
```
常用命令与端口见[安装服务端](../get-started/server.md);验收脚本见[验收与测试](testing.md)。
后端 4000、Web dev 5173、桌面 dev 5174、官网 5180、文档预览 5181。
生产服务端托管已构建的 Web 管理端;详见[安装服务端](../get-started/server.md)和[验收](testing.md)。
-146
View File
@@ -1,146 +0,0 @@
---
title: ClassIsland 插件
description: 联动插件的契约、镜像为什么要带节次时间、上课时段暂存,以及部署与调试。
---
# ClassIsland 插件
源码在 `packages/classisland-plugin/`,是一个 **.NET 8 / C#** 的 ClassIsland 插件
(ClassIsland 插件 API 2.0)。它**独立于 pnpm workspace**,不参与 `pnpm install`。
## 三条链路
```
ClassIsland(教室机器)
│ ① 上报:状态 + 课表 + 节次时间(每次上报顺带取回一条待弹提醒)
▼
/api/integrations/classisland/report ← X-ClassIsland-Token(设备令牌,非 JWT)
│
│ ② 下发:老师在 Web 端发提醒 → 落库 → 插件下次上报时取回 → ClassIsland 全屏弹出 → ack
▼
/api/integrations/classisland/pending、/ack
│ ③ 镜像(可选):服务端把本班课表下发,插件在 ClassIsland 里新建一份档案课表
▼
/api/integrations/classisland/class-plan
```
## 为什么走 HTTP 而不是 IPC
ClassIsland 的跨进程 IPC 数据质量不足以还原课表。插件内部**直接读**
`ILessonsService`(课程状态)与 `IProfileService`(档案课表)最准。
与后端之间用「**上报即拉取**」:每次上报的返回值里顺带带一条待弹出的提醒 ——
插件因此**不需要 WebSocket 依赖**,故障面更小,而且**上报间隔本身就是心跳**。
提醒是**轮询取回**的(默认最多 10 秒延迟,设置页「提醒轮询间隔」可调到 5 秒)。
## 设备令牌
- 令牌是**设备级**的,不是用户级;
- 插件在本机保存明文,服务端只存 **sha256** 与一个前缀提示(`chci_xxxxxxxx`)用于人眼识别;
- 同一班级下按 `deviceKey`(机器码)唯一 —— 插件重装后带同一 `deviceKey` 会**自动重绑**到原记录,
不会在设备列表里堆重复项;
- 关闭设备的「启用」开关后,该设备的上报与下发一律 403。
## 提醒的排队规则
| 提醒类型 | 上课时段 | 下课 / 放学 |
| ------------------------------------------------------ | ---------------------------- | ----------- |
| 通知类(`kind=notification`:通知发布页 / 联动页下发) | **暂存不弹** | 自动补弹 |
| 主动通知:紧急提醒、**叫人**(`kind=call`) | **立刻弹**(老师正在等学生) | 立刻弹 |
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件);
客户端侧的实现在 `renderer/island/bridge.ts`。**改这条规则要两端一起改。**
暂存期间**不回执**,所以服务端会一直保留着待提醒;`ClassIslandPush.expiresAt` 到期后不再补发
(避免学生几天后突然弹出旧提醒)。
## 镜像:为什么必须带 `startTime` / `endTime`
ClassIsland 的 `ClassPlan.Classes[i]` **强制与时间表里第 i 个 `TimeType == 0`(上课)的时间点对齐**。
所以镜像下发时,每一条课目都要带上自己的 `startTime` / `endTime`。
> **少一个时间点,整份课表就会错位** —— 而且表现为"课表看着有内容、每节课都对不上",
> 极难排查。这是插件契约里最容易踩的坑。
另外镜像进 ClassIsland 的课表还要**带课间**:每两节之间补一段(上一节下课 → 下一节上课),
**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算),
服务端合成节次时间表用的是同一口径。
## 上报课表:单向还是双向
**双向对齐**。插件每次全量上报时,服务端只清理「`source='classisland'` 且本次未再上报」的行:
- 老师在 ClassIsland 里删掉 / 挪动一节 → 小助手这边跟着同步;
- 老师在小助手这边手排的课 → **一律不删**。
merge 的去重键是「星期 + 开始时间 + 单双周」。导入时**不要覆盖 `weekStart` / `weekEnd`** ——
插件上报契约里没有周次范围,硬写"整学期"会把老师设的「第 1~10 周」抹掉。
## 单双周的映射
ClassIsland 的 `WeekCountDiv` / `WeekCountDivTotal` ⇄ 本项目的 `ALL / ODD / EVEN`,
走 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` ——
**服务端与插件共用同一份语义**,两边不会各算各的。
## 代理:本地地址强制直连
`ClassHelperClient` 里有一个 `LocalAwareProxy`:对本地 / 内网地址**强制直连**,
不走系统代理。
原因:教室机器上如果开了系统代理,填好令牌后表现为"立刻 HTTP 404"(其实是代理返回的错误页)。
`pnpm verify:classisland-plugin` 有 4 项断言盯着这段逻辑,别删。
## 构建、打包与验收
```bash
pnpm build:classisland-plugin # 编译(.NET 8;缓存与临时目录自动指到仓库内 .cache/)
pnpm dist:classisland-plugin # 打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
pnpm verify:classisland-plugin # 静态契约校验(不需要 .NET,不需要服务端)
```
`verify:classisland-plugin` 会逐项校验:清单一致性、注册完整性、
**C# DTO ↔ 服务端 zod 与路由的字段对齐**,以及已修复坑的护栏。
### 那个预期内的编译警告
全量编译会有 **1 个警告 `AVLN3001`**:设置页只有 DI 构造函数、没有公开无参构造。
本插件走的是编译期 XAML + ClassIsland 的 DI 实例化,**加个无参构造反而会把 DI 弄坏**。
别为了消警告去改它。
## 部署到教室机器
```powershell
# 1) 完全退出 ClassIsland(它会锁住 DLL)
Get-Process ClassIsland.Desktop | Stop-Process -Force
# 2) 覆盖插件 DLL(**不要覆盖 Settings.json**,那里存着服务器地址与设备令牌)
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll `
D:\Classisland\data\Plugins\classhelper.classisland.bridge\ -Force
# 3) 重新启动
Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
```
改配置要在 **ClassIsland 退出时**改:运行中改会被内存里的副本随时回写覆盖。
### 读日志要用 GBK
.NET 的控制台输出是**系统 ANSI(GBK)**编码。用 UTF-8 读会变成乱码,
**中文关键字一条都搜不到**(只能搜到 ASCII 的类名):
```powershell
[System.IO.File]::ReadAllLines($path, [System.Text.Encoding]::GetEncoding(936))
```
日志文件被运行中的进程占用时,先 `Copy-Item` 一份再读。
## 真机验收链路
`pnpm verify:classisland`(需要后端在跑)会真的走一遍:设备令牌 → 上报 → 提醒下发与回执 →
镜像契约 → 幽灵行清理。
> 这个脚本开头会先**抽干本班的历史积压提醒**。因为待提醒接口是「本班全部未确认提醒,
> 按时间升序取前 20 条」,而其他冒烟会真的往演示班发通知 —— 攒够 20 条之后,
> 本脚本刚发的提醒会被挤出窗口,表现为"待提醒里找不到刚发的那条",跟代码对不对毫无关系。
> **别把那个 drain 删掉。**
+59 -140
View File
@@ -1,155 +1,74 @@
---
title: 数据模型
description: 24 张表的职责、跨库通用约定、迁移历史与启动时自动迁移。
description: 现行模型职责、迁移与快照边界。
---
# 数据模型
模型定义只有一份:`packages/server/prisma/schema.prisma`。
模型定义以 `packages/server/prisma/schema.prisma` 为准,迁移以 `prisma/migrations/` 为准。
仅维护一份 schema,SQLite/MySQL 切换改 provider;不使用 Prisma enum 和 `@db.*`。
## 跨库通用约定
## 模型职责
- 默认是 **SQLite**(Prisma 7 的 libSQL driver adapter,数据文件 `packages/server/prisma/dev.db`);
- 字段**只用** `String / Int / Float / Boolean / DateTime`,**不用 Prisma enum、不用 `@db.*` 原生类型**;
角色与优先级存字符串,在 API 层用 zod 校验 —— 这样 SQLite 与 MySQL 行为完全一致;
- 因此切库只需改 `provider` 与连接串(`pnpm db:switch:mysql` 就地改写它,没有第二份 schema);
- 主键统一用 cuid 字符串,便于多端离线生成与合并。
| 模型 | 职责 |
| ---------------------- | -------------------------------------------------- |
| `User` | 教师与管理员账号;学生不在此表,班级凭据在 Class |
| `Student` | 学生名单、学号、在读/转出/毕业状态,无密码和角色 |
| `StudentClassTransfer` | 调班与转出历史,保留班级/姓名快照 |
| `ArchivedYear` | 毕业届别与统计快照 |
| `TermWeek` | 逐周日期区间,classId 空串为全校默认 |
| `Class` | 班级、班级码/密码、班主任、周数与成绩查询开关 |
| `Course` | 班级+科目+教师任课关系,含简称和室外标记 |
| `Schedule` | 旧课表迁移审计来源,现行接口不再以它为业务真值 |
| `Homework` | 作业与本地 assignDate;dueAt 为遗留列 |
| `HomeworkStatus` | 按 studentId 记录作业完成状态 |
| `Notification` | 通知/叫人记录、优先级与本地 assignDate |
| `NotificationRead` | 按 studentId 记录已读 |
| `TimeLayout` | 共享或班级自有时间表;旧 items JSON 保留迁移审计 |
| `Grade` | studentId、课程、考试、分数和等级 |
| `PermissionGroup` | 默认/自定义/岗位组及 scope/scopeValue |
| `PermissionGroupItem` | 组内权限键 |
| `UserPermissionGroup` | 账号与组的多对多分配 |
| `AppSetting` | 周次、升级、AI 和系统设置 |
| `ClientDevice` | deviceKey、心跳、更新目标与状态 |
| `ClientCommand` | 白名单远控、有效期与执行回执 |
| `ClientRelease` | 服务端缓存安装包、SHA256 与大小 |
| `AiAudit` | 助手解析审计,不保存 API Key |
| `Exam` | 考试名称与是否计入总分 |
| `TimeLayoutItem` | 有序时间点及类型,被课程显式引用 |
| `ClassPlanGroup` | 班级课表群组、全局/默认标记 |
| `ClassPlan` | 日课表、时间表、轮换、启用与临时层规则 |
| `ClassPlanEntry` | 日课表与时间点、课程的外键关联、空课/停用/换课标记 |
| `ScheduledClassPlan` | 按本地日期预定日课表 |
| `PromotionRun` | 年度升级幂等与审计 |
| `Courseware` | 文件元数据、上传教师、存储路径与 SHA256 |
| `CoursewareVisibility` | 课件可见班级 |
| `CoursewarePush` | 按班级的推送任务 |
| `CoursewareReceipt` | 按设备的课件接收回执 |
## 两条核心不变量
学生和账号分离:Student 没有密码/角色;教师/管理员存 User,CLASS_DEVICE 只是 Class 登录形成的会话。
任课关系只在 Course;班主任关系在 Class.teacherId,可以与任课关系指向同一账号。
课表真值是 ClassPlan/ClassPlanEntry/TimeLayoutItem,旧 Schedule 与 TimeLayout.items 不再参与现行业务写入。
周次口径见[课表](../app/schedules.md),成绩限制见[成绩](../app/grades.md)。
这两条是 2026-10-06 那次重构立下的,后面所有表都围着它们转:
## 迁移与启动
**① 学生不是账号。** `Student` 是独立的一张表,只有学号 / 姓名 / 班级 / 性别 / 家长手机号 /
状态,**没有 `passwordHash`、没有 `role`、没有登录字段** —— 「学生不能登录」由表结构保证,
不再靠"登录前先判 role === 'STUDENT' 并 403"这类代码分支维持。
`User` 因此收缩为**纯账号表**(管理员 / 教师 / ClassHelper 班级端)。
不要删除或重写已应用的 SQLite 迁移目录。源码开发使用 `pnpm --filter @classhelper/server db:deploy`。
安装形态由 `lib/db-bootstrap.ts` 执行启动迁移,账本 `_ch_migrations` 记录已执行目录。
升级迁移包含学生表拆分、联动退役、关系化课表、权限范围、年度升级和课件表;具体顺序读取 migrations。
历史学生迁移中的 STUDENT 只用于搬运旧数据,不是现行账号角色。
MySQL 建模走受支持的换库流程,不能直接执行 SQLite SQL,详见[MySQL](../management/mysql.md)。
**② 任课关系的唯一来源是 `Course`。** `Course(classId, name, teacherId)` 就是
「班级 + 科目 + 教师」这条关系本身,作业与成绩都挂在它上面;班主任另由 `Class.teacherId` 表示
(每班 1 人)。历史上那张**没有科目**的 `ClassTeacher` 协作表已删除 —— 两套关系并存必然漂移。
同一位教师可以在同一个班同时是班主任和某科科任老师:两处指向同一个 `User`,不重复建号,
权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。
## 快照与文件
## 24 张表
`lib/snapshot.ts` 的 `SNAPSHOT_TABLES` 是备份/导入/跨库迁移表清单,按依赖顺序导出,恢复阶段临时关闭外键检查并分批写入。
模型新增或删除必须同步此清单;不要在文档复制固定表数。
JSON 快照包含课件元数据、可见范围、推送与回执,**不含文件实体**;完整备份需带上课件目录。
User.careMode 是预留列;当前 Web 偏好在 localStorage,客户端偏好在本机 config.json,不宣称已有跨设备同步。
| 表 | 职责 | 值得注意的字段 |
| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `User` | **纯账号表**:管理员 / 教师 / ClassHelper 班级端 | `username`(教师即**工号**,班级端即班级码)、`role`、`phone`、`careMode`(关怀模式偏好,目前只由本机界面读写);**学生不在这里** |
| `Student` | 学生名单记录(**没有账号**) | `studentNo`(学号,唯一,唯一标识与查询键)、`classId`、`status`、`archivedYearId` |
| `StudentClassTransfer` | 调班 / 转出历史 | 班级 id 与班级名**都存快照且不建外键**(班级删了历史仍要能读)、`mode`(single|batch|transfer-out) |
| `ArchivedYear` | 毕业归档的届别档案 | `enrollmentYear`(届别,唯一)、`graduationYear`、归档时的统计快照 |
| `Class` | 班级 | `name`(**「2026级1班」**,由 `enrollmentYear` + `classIndex` 生成)、`code` + `passwordHash`(班级端账号)、`termWeeks`、`termStartDate`、`notificationChannel`、`studentGradeQueryEnabled`、`archivedYearId` |
| `TermWeek` | 学期逐周日期区间 | `classId`(**空串 = 全校默认**)、`weekNumber`、`startDate` / `endDate` |
| `Course` | 课程 = **「班级 + 科目 + 教师」任课关系** | `teacherId` 就是该班该科的任课老师;`@@unique([classId, name])` |
| `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual|classisland) |
| `Homework` | 作业 | `assignDate`(所属日期)、`attachmentUrl`;`dueAt` 是已下线的遗留列 |
| `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, studentId])` |
| `Notification` | 通知 | `priority`、`assignDate`(所属本地日期,禁止 UTC 切片);`@@index([classId, assignDate])` 供按天归档 |
| `NotificationRead` | 已读记录 | `@@unique([notificationId, studentId])` |
| `TimeLayout` | 节次时间配置(ClassIsland 同款 JSON 导入得到) | `items` 是 JSON 字符串:`[{ index, name, startTime, endTime, type, skipped }]` |
| `Grade` | 成绩 | `studentId`、`score` / `totalScore`、`levelType`(percent|letter|custom)与 `level` |
| `IntegrationDevice` | ClassHelper 班级端 / ClassIsland 联动设备 | `tokenHash`(只存 sha256)、`lastHeartbeatAt`(在线判定)、`lastSeenAt`(最后一次带状态的上报)、三个方向开关 |
| `ClassIslandPush` | 下发给 ClassIsland 的提醒 | `kind`(notification|call)、`ackedAt`、`expiresAt` |
| `PermissionGroup` | 权限组(角色之上的**默认模板**) | `name`(唯一)、`builtin`(内置组不可删);生效集合见 [角色与权限](permissions.md) |
| `PermissionGroupItem` | 权限组里的一个权限键(一行一个键) | `@@unique([groupId, key])`;键取值必须是 shared 的 `PERMISSION_KEYS` 之一 |
| `UserPermissionGroup` | 账号 ←→ 权限组 的分配关系(可属多个组) | `@@unique([userId, groupId])`;**没有任何行 = 回落角色默认模板** |
| `AppSetting` | 全局键值设置(大模型配置 / 开发者模式) | `key` 即主键、`value` 存字符串(复杂结构存 JSON);密钥类值做 AES-256-GCM 加密后存这里(**只进不出**) |
| `ClientDevice` | ClassHelper 客户端设备(教室 / 学生机) | `deviceKey`(唯一,客户端生成)、`classId`、`clientVersion`、`updateTargetVersion`、`lastHeartbeatAt`(**在线判定看它**) |
| `ClientCommand` | 下发给客户端的远控指令 | `kind`(shutdown|restart|logoff|lock)、`expiresAt`(10 分钟)、`ackedAt` / `result`、`cancelledAt` |
| `ClientRelease` | 服务端缓存的客户端安装包 | `version`(唯一,白名单校验防路径穿越)、`fileName`、`sha256`、`size`;文件在 `data/client-releases/` |
| `AiAudit` | 大模型指令审计(意图与结果,不记密钥) | `userId`、`prompt`、`intent`、`status`(pending|confirmed|rejected|failed)、`rawReply` |
## 修改模型时
### 几个容易忽略的设计
**`ClassIslandPush` 与 `IntegrationDevice`** —— 提醒是**落库**的,不是"发出去就算"。
设备离线时通知不丢:插件重连后拉取未确认的那条立即弹出,弹完回执(`ackedAt`)。
`expiresAt` 防止学生几天后突然弹出旧提醒。
**在线判定看 `lastHeartbeatAt`,不看 `lastSeenAt`** —— 同一台设备的上报其实有两种:
心跳(每 60 秒一次,只表明"我还在线")与带状态的上报(课表 / 上课状态)。
合成一个字段后无法区分"在线但一直没新状态"和"整个失联",所以拆开:
`lastHeartbeatAt` 每次上报都刷(60 秒窗口判在线),`lastSeenAt` 只在带运行状态时刷。
**课表同步方向:服务端 → 教室是主方向。**
`IntegrationDevice.mirrorScheduleToClassIsland` 默认 **true**(服务端自动下发),
`syncScheduleToServer` 默认 **false**(教室不再自动回传)。要取教室的课表,走
`POST /integrations/devices/:id/request-schedule` 这个**人工动作**(前端点击前会弹警告)——
自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
**`Schedule.source`** —— 决定插件上报时能清理哪些行:只清 `classisland` 来源且本次未再上报的行。
**`source` 只在 create 时确定,update 一律保持原值**(否则手排的课会被"收养"后又被清掉)。
**`Homework.dueAt`** —— 截止时间功能已下线,UI 与接口都不再读写它。
保留列是为了**不对已安装的库做破坏性迁移**;新库里它恒为 `null`。
**`Notification.assignDate`** —— 与 `Homework.assignDate` 同口径:**本地日期**(`YYYY-MM-DD`),
创建时用 `dayKeyLocal()` 写入,按天 / 区间归档都读它。**不要用 `createdAt` 切 UTC 当"哪一天"** ——
晚上发的通知会被算到第二天。`@@index([classId, assignDate])` 是归档查询的支撑。
**`User.careMode`** —— 关怀模式的账号级偏好列(放大正文与控件)。**目前服务端没有任何接口读写它**:
Web 端存在 `localStorage`(`classhelper.admin.careMode` + `careScale`),客户端存在本机 `config.json`。
列先建好是为了将来跨设备同步偏好时不必再迁移;别把它当成"已有服务端开关"。
**`TermWeek.classId` 用空串而不是 null 表示"全校默认"** —— SQLite 与 MySQL 的唯一约束都把
NULL 视作互不相同,`@@unique([classId, weekNumber])` 用 null 就形同虚设。
## 迁移历史(17 个)
```
init → add_time_layout → add_class_account → add_schedule_week_parity →
add_class_term_weeks → add_classisland_integration → add_class_notification_channel →
add_homework_assign_date → add_push_kind → add_schedule_source →
add_student_table → add_student_grade_query → add_grade_level →
add_classhelper_heartbeat → add_archives → add_term_weeks →
add_permission_groups_and_clients
```
`add_student_table` 是一次真正的表重构(不是加列):新建 `Student` / `StudentClassTransfer`、
把 `User` 里 `role='STUDENT'` 的行**按 id 原样搬过去**、把 `Grade` / `HomeworkStatus` /
`NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。
因为学生行的 id 保持不变,三张个人数据表的 `userId` 可以直接当 `studentId` 用,历史一条不丢。
`add_permission_groups_and_clients` 是纯加表 / 加列:新建 `PermissionGroup` /
`PermissionGroupItem` / `UserPermissionGroup`(权限组)、`AppSetting`(全局键值设置)、
`ClientDevice` / `ClientCommand`(客户端设备与远控)、`ClientRelease`(安装包缓存)、
`AiAudit`(大模型审计),并给 `Notification` 加 `assignDate` + `[classId, assignDate]` 索引、
给 `User` 加 `careMode`。没有数据搬运,旧库直接补跑即可。
### 启动时自动迁移
`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名:
| 情况 | 行为 |
| -------- | --------------------------------------------------- |
| 全新安装 | 依次执行全部迁移并创建初始管理员 |
| 覆盖安装 | 只补跑账本里没有的迁移(已存在的表 / 索引自动跳过) |
| 已是最新 | **不做任何写操作** |
日志会写明「全新安装 / 升级安装」、应用了几个迁移、跳过了几个已存在对象。
用户因此**不需要手动执行 `prisma migrate deploy`**。
## 备份格式就是这份模型的拓扑序导出
「数据库管理」里的备份 / 导出导入 / 跨库迁移共用一种 JSON 快照:**23 张表按拓扑序导出**
(见 `lib/snapshot.ts` 的 `SNAPSHOT_TABLES`,现在共 23 项,顺序为
`User` → `PermissionGroup` → `PermissionGroupItem` → `UserPermissionGroup` → `AppSetting` →
`ArchivedYear` → `Class` → `Student` → … → `AiAudit`),恢复时**临时关闭外键检查** ——
因为 `Class.teacherId → User` 与 `Student.classId → Class` 是一条链、
`Class/Student.archivedYearId → ArchivedYear` 又是一条,任何排序都会被其中一侧卡死。
见[数据库管理](../management/database.md)。
## 开发时的模型改动
1. 改 `schema.prisma`;
2. 在 `prisma/migrations/` 下加一个迁移目录(本机用
`pnpm --filter @classhelper/server db:deploy` 应用,**别用 `pnpm db:migrate`** ——
它在 Windows 上不可靠)。要改已有表结构而不只是加列时,可以先用
`pnpm exec prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script`
生成骨架,再手工补数据搬运语句;
3. 改完核对 **`SNAPSHOT_TABLES`**(新增 / 删除表要同步)与 `deleteTeacher` 的护栏清单
(它必须覆盖 `Class.teacherId` / `Course.teacherId` / `Homework.createdBy` /
`Notification.createdBy` 四项;`ClassIslandPush.createdBy` 是裸列、无外键、不级联,不用加);
再回到 `packages/server/scripts/apply-column-migrations.cjs` 补上可 `ALTER TABLE` 的简单列。
模型总数与迁移个数会随版本增长,以 `schema.prisma` 与 `prisma/migrations/` 的实际内容为准;
本文里的数字标注的是**当前代码库**的状态。
1. 同步 schema、迁移、shared 契约、服务层、相关前端与快照清单。
2. 核对外键级联与删除护栏,特别是教师的班级、课程、作业、通知和课件。
3. 在临时库验证新建和旧库升级;不对真实业务库运行 seed/reset。
4. 重新生成 Prisma Client,并按修改范围执行[验收](testing.md)。
+40
View File
@@ -0,0 +1,40 @@
---
title: 当前实现约定
description: 课表、周次、学年、岛、设备与课件的现行实现入口。
---
# 当前实现约定
本页维护当前工作区的实现契约,不汇总多批构建的验收数字。验证命令与实际范围见[验收与测试](testing.md)。
## 关系化课表
`ClassPlan/ClassPlanEntry/TimeLayoutItem` 是业务真值。时间点外键独立于排序,`Schedule` 与旧 JSON 列仅保留迁移审计来源。
兼容 `/schedules` 写入新模型,日课表、周视图和上课状态共用 `lib/class-plan.ts`。
解析顺序是预定、当日临时课表、同日临时层、有效临时群/选定或默认群的规则匹配、全局群、无群默认。
轮换使用 weekday/weekCountDiv/weekCountDivTotal 与周次范围,支持 1–9 周;没有新增 Profile 实体。
完整档案导入直接保留轮换、空课、停用、时间类型、群组与预定,不经过单双周中间格式。
旧数据同班同日同周同时间冲突仅最小 id 进入新表,旧行保留;缺失的历史群组、临时层与时间点名称无法恢复。
## 权限、成绩与年度升级
默认权限组、岗位范围、科目写权限分别由 permissions/permission-scope/access 维护,范围并集不授予任课权。
全校周数上限在 `term.maxWeeks`,默认 30、允许 1–60;开学日和逐周区间支持全校与班级设置。
个人成绩查询关闭后只返回服务端集体聚合,个人明细、原始全班成绩与排名拒绝读取;样本不足或稀疏分段抑制。
年度升级由 `term.promotionAt` 和 `PromotionRun.year` 管理,先存快照;运行期到点执行,启动漏跑需管理员预览补跑。
毕业班归档,其余升一级,新班不复制任课关系。转出学生由 `lib/students.ts` 统一过滤。
## 岛与本机配置
方案存 `config.json.islandLayout`,默认 fixed。当前 custom 即常驻,归一化将兼容字段 residentEnabled 与 custom 对齐。
`LayoutIslandApp.vue` 选择经典 `IslandApp.vue` 或 `CustomIslandRuntime.vue`,只有一个原生岛窗口。
自定义常态行与无消息展开行独立编辑,通知列表复用 `ClassicNotifications.vue`;编辑页是主窗口设置子路由。
自定义模式通过铃铛提示新消息,点击查看;经典模式普通通知上课暂存、普通叫人可点开胶囊、紧急消息自动展开。
课程提醒配置独立保存,课件传输期间由 `CoursewareTransfer.vue` 显示进度。
## 设备维护与文件
设备心跳和命令只走 `/api/clients/*`。切服务端或登录状态变化要重置代理计时,并隔离迟到响应;deviceKey 不变。
更新从服务端缓存下载,客户端验 SHA256,按下发策略安装;远控白名单动作执行后回执,测试只用假执行器。
课件上限 512 MB,服务端流式落盘后创建元数据和推送,客户端逐设备验摘要并回执,不自动运行文件。
JSON 快照含课件表,完整恢复还必须带文件实体目录。细节见[课件](../app/courseware.md)。
+12 -16
View File
@@ -7,13 +7,12 @@ description: ClassHelper的架构、接口、数据模型、权限、动效层
ClassHelper是一个 **pnpm monorepo**,一份代码产出四个交付物:
| 交付物 | 位置 | 形态 |
| -------------------- | ----------------------------- | --------------------------------------------------------------- |
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,接口前缀 `/api` |
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师 / 管理员用,PWA 可安装) |
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(ClassHelper 班级端 / 教室机器,含灵动岛浮窗) |
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(**独立于 pnpm workspace**,不参与 `pnpm install`) |
| 交付物 | 位置 | 形态 |
| ---------- | ------------------------- | --------------------------------------------------------------- |
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,接口前缀 `/api` |
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师 / 管理员用,PWA 可安装) |
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(ClassHelper 班级端 / 教室机器,含灵动岛浮窗) |
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
核心链路:
@@ -26,7 +25,6 @@ ClassHelper是一个 **pnpm monorepo**,一份代码产出四个交付物:
▼
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI(含灵动岛)
ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
▲ │
└──── 老师下发的提醒 ◄──────┘
```
@@ -36,20 +34,18 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
| 页面 | 内容 |
| -------------------------------------------- | ------------------------------------------------ |
| [架构总览](architecture.md) | 目录结构、技术栈、模块化设计、几条贯穿全局的约定 |
| [REST API](api.md) | 全部接口一览(方法 / 路径 / 权限) |
| [REST API](api.md) | 接口分组、常用路径与权限边界 |
| [实时事件(Socket.IO)](realtime.md) | 事件清单、房间规则、新增事件要改哪三处 |
| [数据模型](data-model.md) | 24 张表、迁移历史、跨库约定 |
| [数据模型](data-model.md) | 模型职责、迁移与快照边界 |
| [角色与权限](permissions.md) | 权限矩阵与实现位置 |
| [动效层与主题](motion.md) | beUI 令牌、CSS `linear()` 弹簧、深色主题 |
| [ClassIsland 插件](classisland-plugin.md) | 插件契约、镜像为什么要带 `startTime`、部署与调试 |
| [灵动岛设计令牌](winisland-design-tokens.md) | 岛逐项的几何 / 配色 / 排版 token |
| [验收与测试](testing.md) | 五套验收脚本怎么跑、哪些用例是脆弱的 |
| [验收与测试](testing.md) | 隔离验收、后台冒烟与显式视觉模式 |
| [版本迭代与发布](release.md) | 改版本号、出安装包、发 Release 的完整流程 |
## 仓库里另外两份文档
- **[完整参考](../reference.md)** —— 产品说明书 + 验收对照表(原 README 全文),
功能行为的**权威来源**;仓库首页那份 README 是精简后的门面页,细节都在这里。
- **[功能与源码索引](../reference.md)** —— 各帮助章节与源码入口的统一索引;功能行为在对应章节维护,按源码核验。
- **[`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md)** ——
写给 AI 编码代理的工程约定与本机踩坑记录,**改代码前必读**。
里面每一条硬约定都对应一次真实的翻车,比代码注释更值得先看一遍。
工程约定与开发环境注意事项,**改代码前必读**。里面每一条硬约定都对应一处容易踩空的地方,
规则与实际实现冲突时应查源码并同步修正。
+5 -7
View File
@@ -69,15 +69,13 @@ DOM 注入与 Vue 组合式函数在两端各自的 `composables/motion.ts` 里
| 页面内容 | `.page > *` 按块错峰浮现(30ms 一档,封顶 180ms) |
| 统计数字 | 0 → 实际值滚动(`useCountUp`) |
| 主题切换(两端) | View Transition API:从点击位置扩散的圆整页换肤 |
| 灵动岛 | **刻意只有单调缓动,不用弹簧** |
| 灵动岛 | 独立 SpringValue 帧积分与过冲钳制,见设计令牌 |
### 灵动岛为什么不用弹簧
### 灵动岛使用独立弹簧
岛的形变由**主进程**缓动窗口尺寸,而冒烟断言「展开过程不震动」「收回过程不抖动」
是**逐帧采样岛体几何**并要求单调的 —— 任何带过冲的弹簧都会直接判红。
所以岛用 `--ch-ease-out` 这类单调曲线,壳的物理仍归它自己的 `src/island/spring.ts`(WinIsland 移植)。
**不要拿 beUI 的 `SPRING_*` 去换**。
经典与自定义岛的形变使用 `src/island/spring.ts` 的 SpringValue,渲染层驱动岛体几何,主进程管理原生窗口与命中。
它与 shared/motion.ts 的 CSS 弹簧是不同实现,不能互相替换参数。默认阻尼、换向与过冲钳制见[设计令牌](winisland-design-tokens.md)。
岛动画由应用开关及可见性控制,主窗口 prefers-reduced-motion 规则不直接替代岛自身设置。
另外岛的 `:hover` 规则必须关在 `@media (hover: hover) and (pointer: fine)` 里 ——
教室触摸屏的"幽灵 hover"会让点过「知道了」的按钮永远高亮。
+50 -139
View File
@@ -1,158 +1,69 @@
---
title: 角色与权限
description: 权限矩阵、科目维度的收口规则,以及"前端隐藏 ≠ 权限"这条纪律。
description: 功能权限、岗位范围与班级科目边界。
---
# 角色与权限
## 矩阵在哪
授权分为功能权限、数据范围和业务关系三层,任何一层拒绝都不能操作。
shared 的 `permissions.ts` 定义键、标签、默认模板与班级关系规则;服务端 `lib/permissions.ts`、
`lib/permission-scope.ts`、`lib/access.ts` 分别计算生效键、查询范围和具体业务断言。
**唯一一份**在 `packages/shared/src/permissions.ts`:
## 身份与权限组
- 后端 `lib/access.ts` 复用它做断言;
- 两端前端用它隐藏入口;
- 验收脚本按它逐条断言。
账号角色为 ADMIN、TEACHER;班级登录形成 CLASS_DEVICE 会话。学生是 Student 名单,不是账号。
HEAD/SUBJECT 是班级关系,级长/部长是岗位组,均不新增 User.role。
所以不存在"前端一套后端一套"。改权限就改这一个文件(改完记得 `pnpm build:shared`)。
| 情况 | 有效功能权限 |
| ------------ | ----------------------------------------------------- |
| 已分配权限组 | 所有分组键的并集,可以比角色默认更少 |
| 没有分组 | 数据库对应角色的可编辑默认组 |
| ADMIN | 同上,始终额外保留 permission:manage 与 system:manage |
| CLASS_DEVICE | 空集;通过明确允许班级会话的接口与业务断言操作 |
## 角色判定
每请求读取生效权限,编辑后无需等待令牌过期。内置组可改不可删,默认组不是写死的全权限快捷方式。
路由使用 requirePermission(AND)、requireAnyPermission(OR),员工与班级端共享读取入口按 requireStaffPermission 等规则检查。
具体 API 的额外角色限制以模块路由和服务断言为准,不能把“默认只分配给管理员”写成所有接口硬编码 ADMIN。
角色只有三种,**没有 `STUDENT`** —— 学生不是账号(见[数据模型](data-model.md))。
班级内的角色由「账号角色 + 与该班级的关系」共同决定:
## 数据范围与科目
| 角色 | 判定方式 | 可见范围 |
| --------------------------- | ------------------------------------------ | ------------------------------------ |
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
| 科任老师 `SUBJECT` | 该班存在 `Course.teacherId === 当前用户` | 被任课的班级 |
| `CLASS_DEVICE` 班级端 | 班级码 + 班级密码登录(`classSession`) | 本班(代全班读写个人数据) |
| `NONE` | 与这个班没有任何关系 | 什么都看不到 |
| 岗位/身份 | 范围 |
| ----------- | --------------------------------------------- |
| 管理员 | 全部班级;仍须功能键 |
| 班主任/教师 | 自己担任班主任或任课的班级,按分组 scope 收敛 |
| 级长 | 配置年级 |
| 部长 | 配置的一组年级,scopeValue 用逗号分隔 |
| 班级端 | 登录绑定班级 |
判定函数是 `resolveClassRole()`。「科任」的判定源是 **`Course`**(班级 + 科目 + 教师),
不是那张已经没有的 `ClassTeacher` 协作表。
空年级/部配置不放行。设备写操作只合并含对应功能键的分组范围,不能借只读组扩权。
科目编辑再由 `assertCanManageSubjectContent` 检查:管理员可管理,教师仅自己任课科目,无科目内容归班主任。
班主任不任教某科就不能改该科的作业或成绩;班级端只允许作业写入,不能写成绩。
## 权限矩阵
| 业务 | 主要功能键与额外边界 |
| ---------------- | ---------------------------------------------------------------------------- |
| 名单读取/管理 | roster:read / roster:manage;管理仍须管理员或本班班主任断言 |
| 教师列表/维护 | teacher:read / teacher:manage;导入名单另检查导入权限和角色 |
| 日课表写入 | schedule:write + 管理员/本班班主任 |
| 全校时间表写入 | timetable:manage;默认给 ADMIN |
| 作业/成绩 | homework:write / grade:write 或 grade:import,再按班级科目收口 |
| 通知/叫人 | notification:publish / call:create,再检查班级范围 |
| 课件 | courseware:read / courseware:write,上传和推送自己的文件还受本人授课班级约束 |
| 客户端维护/远控 | client:read、client:manage、remote:control,写操作按对应键范围 |
| 版本与安装包写入 | client:update;旧 client:update-push 不再作为写入口要求 |
| 数据库/系统/权限 | database:manage / system:manage / permission:manage |
| 操作 | 管理员 | 班主任 | 科任老师 | 班级端 |
| -------------------------------------------------------- | :----: | :----------: | :------: | :----: |
| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | ❌ |
| 分配班主任与各科任课老师 | ✅ | ❌ | ❌ | ❌ |
| 学生名单管理(增删 / 导入 / 调班 / 转出) | ✅ | ✅(仅本班) | ❌ | ❌ |
| 教师录入(新建 / 导入名单 / 修改密码) | ✅ | ❌ | ❌ | ❌ |
| 毕业归档 / 学年升级 / 学期周次 | ✅ | ❌ | ❌ | ❌ |
| 数据库管理(备份 / 导入导出 / 一键切换) | ✅ | ❌ | ❌ | ❌ |
| 课表管理(增删改 / 时间配置导入) | ✅ | ✅(仅本班) | ❌ | ❌ |
| 布置作业(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ✅ |
| 叫人 / 发布通知 | ✅ | ✅ | ✅ | ✅ |
| 成绩录入 / 修改 / 导入(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ❌ |
| 查看本班成绩汇总 | ✅ | ✅ | ✅ | ✅ |
| 按学号查询学生成绩明细 | ✅ | ✅ | ✅ | 需班级开关 |
| 查看 ClassHelper 班级端在线状态 | ✅ | ✅ | ✅ | ❌ |
| 班级密码 / 班级码维护(`PATCH /classes/:id/class-account`) | ✅ | ❌ | ❌ | ❌ |
完整键目录读取 `PERMISSION_KEYS`,不在文档维护固定键数。
## 科目维度的收口(本次重构的核心规则)
## 前端门控与成绩隐私
作业与成绩不是"凡是能进这个班就能改",而是**再按科目收一道**:
Web 菜单属性来自路由,`meta.roles` 粗筛、`meta.permission` 细筛;先 ensurePermissions 再按生效集合判断。
服务端仍检查功能、范围与业务关系,直接构造请求不能绕过。
岗位范围可以扩大读范围,不能自动扩大科目写权限,也不能替代课件的本人任课关系。
班级成绩查询开关仅员工按业务权限修改;关闭后班级端仍看集体聚合,个人明细、原始全班成绩和排名返回拒绝。
稀疏分段与补集抑制,服务端不向客户端下发个人原始成绩再聚合。
| 情形 | 谁可以写 |
| --------------------------------------- | ------------------------------------------ |
| 管理员 | 全部 |
| 挂了科目(`courseId` 有值) | **该科的任课老师**(`Course.teacherId`) |
| 没挂科目(`courseId` 为空) | 本班**班主任** |
| ClassHelper 班级端 | 只有**作业**可以(老师在教室随手录一条);**成绩不给** |
## 设备维护
于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
实现在 `lib/access.ts` 的 **`assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice })`**,
作业 / 成绩 / 叫人三处写入统一走它。
## 权限键与权限组(2026-10 起:权限组驱动,角色只是默认模板)
从权限组重构开始,**功能级准入不再看角色,而看权限键**:键目录同样是
`packages/shared/src/permissions.ts` 的 `PERMISSION_KEYS`(39 个,形如 `domain:action`,
中文名在 `PERMISSION_LABELS`、分组在 `PERMISSION_CATEGORIES`)。路由上挂的是
```ts
requirePermission('notification:publish') // 需要全部列出的键
requireAnyPermission('grade:write', 'grade:import') // 有任意一个即可
```
**生效规则(四句话)**:
| 账号 | 有效权限 |
| --------------------------------------- | -------------------------------------------------------------- |
| `ADMIN` | 全集;其中 `permission:manage` / `system:manage` **不可被剥夺** |
| `CLASS_DEVICE`(班级端) | 空集,**不受任何分组影响**(它的能力来自班级维度断言) |
| 被分配了权限分组 | 分组键的**并集**(可以比角色默认更少 —— 这就是"能收权") |
| 没有被分配任何分组 | 回落到角色默认模板 `defaultPermissionsForRole(role)` |
默认模板逐条镜像重构前的 `requireRole`,所以"不动分组"= 行为与以前完全一致。
实现见 `packages/server/src/lib/permissions.ts` 的 `loadEffectivePermissions()`:它**每个请求实时查库**
(不缓存),因此管理员改完分组**立刻生效**,不需要等令牌过期 —— 与下面「鉴权中间件会回查数据库」是同一个理由。
**管理接口**(`packages/server/src/modules/permissions/`):
| 方法与路径 | 权限要求 | 说明 |
| --------------------------------- | -------------------- | -------------------------------------------------------- |
| `GET /api/permissions/me` | 登录即可 | 当前账号的有效权限与来源(前端据此显示/隐藏入口) |
| `GET /api/permissions/catalog` | `permission:manage` | 全部权限键(含中文名、分类、默认角色、不可移除标记) |
| `GET/POST /api/permissions/groups`、`PATCH/DELETE /groups/:id` | `permission:manage` | 分组 CRUD;内置分组(`builtin=true`)可改不可删 |
| `GET /api/permissions/users`、`PUT /api/permissions/users/:id/groups` | `permission:manage` | 成员列表与「整组替换」式分配;拒绝给 ADMIN / CLASS_DEVICE 分配 |
**一条不能松的边界**:权限组只管**功能/路由级**准入;**班级维度的科目规则不可被权限组覆盖**。
换句话说,给某人授了 `homework:write` 只代表"他能进布置作业这条路由",
**具体到哪个班、哪个科目**仍然由 `assertCanManageSubjectContent` 判(下一节)。
否则「班主任能布置别人科目的作业」这个老 bug 会从权限组这条新路重新长出来。
## 实现要点
**路由级只做权限过滤**:`requirePermission('…')`(需要列出的键全有)或
`requireAnyPermission('…')`(任意一个即可)。改造前这里是 `requireRole('ADMIN','TEACHER')`,
按键目录里的 `PERMISSION_DEFAULT_ROLES` 逐条映射过来,因此**默认行为没有变化**。
403 的文案会直接点名缺哪个键:「当前账号缺少「X」权限,请联系管理员在「权限组」里分配」。
**班级维度的判断在服务层**:`src/lib/access.ts` 提供
```
assertCanManageClasses / assertCanAssignTeachers / assertCanManageRoster
assertCanManageSchedule / assertCanPublishNotification
assertCanManageGrades / assertCanManageSubjectContent
```
**列表数据用 `resolveClassScope()` 收敛**(`classId in [...]`),单条接口直接断言;
越权一律 403。
**鉴权中间件会回查数据库**(`middleware/auth.ts`):JWT 校验之后重新读一次账号,
所以改了角色 / 班级关系会**立即生效**,不用等令牌过期。班级端会话则回查 `Class` 表。
**班级端的成绩查询开关**:`Class.studentGradeQueryEnabled`(默认开启)。
关闭后班级端调 `GET /grades/student/:studentNo` 一律 403,教师端不受影响 ——
这就是需求「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」。
## 前端隐藏 ≠ 权限
前端的隐藏只是"别让人看见入口":
- 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 毕业归档 / 学期周次 / 数据库管理 / 权限组 / 客户端设备」;
- 课表页的增删改按钮只对「管理员或本班班主任」显示;
- 成绩页的录入 / 删除按钮对教师显示(科任也能用,**具体到某一条由后端按科目判定**)。
**门控怎么写的**:路由 `meta.roles` 做粗筛(角色),`meta.permission` 做细筛(权限键)——
`packages/web-admin/src/router/index.ts` 的 `beforeEach` 会 `await auth.ensurePermissions()`
再问 `auth.can(key)`(管理员短路为 true),不通过就 **fail-closed** 回仪表盘;
侧栏菜单项同理带 `permission` 字段(`AdminLayout.vue` 的 `visibleMenuItems`)。
`auth.ensurePermissions()` 读的就是 `GET /api/permissions/me`。
但这些页面**直接输网址也进不去**(路由 `meta.roles` 会把非管理员挡回仪表盘),
而且**所有写入请求服务端都会再断言一次**。绕过菜单、绕过前端,一样 403。
> 新增视图时别忘了两处:路由的 `meta.roles` + 服务层对应 service 里的 `assertCanXxx`。
> 只做其中一个都等于没做。
## 学生没有账号的两个反向约束
1. **学生不能登录,而且根本不存在可以登录的学生账号**。`User` 表里没有学生行,
拿学号当账号登录只会得到 401("工号或密码错误"),连"这个学号存在吗"都探测不出来。
2. **学生没有任何密码接口**。`POST /students/:id/reset-password` 已删除(调用返回 404),
新建学生也不设密码 —— `Student` 表里压根没有那一列。
`/api/clients/heartbeat` 和命令回执供已登录设备使用,不依赖班级端的空权限集合;服务端校验 deviceKey 与班级归属。
远控从管理端确认下发,客户端执行后回传真实结果。更新文件由服务端缓存,教室机验 SHA256,
按立即、放学后、空闲策略安装;关闭自动更新不会强制安装。详见[更新与升级](../management/upgrade.md)。
+35 -67
View File
@@ -1,81 +1,49 @@
---
title: 实时事件(Socket.IO)
description: 事件清单、房间规则,以及新增一个实时事件要同时改哪三处。
description: 实际事件与房间、断线恢复和客户端提醒分工。
---
# 实时事件(Socket.IO)
## 连接
连接使用 `io(url, { auth: { token } })`,服务端在握手校验 JWT。
事件与房间定义在 shared 的 `constants.ts`,载荷类型在 shared 的 `types.ts`,以这些契约和发送点为准。
```ts
io(url, { auth: { token } }); // 握手阶段用 JWT 鉴权
```
## 房间与定向推送
客户端断线由 Socket.IO 自动重连。教室机器断线期间的变更,重连后靠各页重新拉取补齐
(客户端在检测到服务器恢复时会触发 `onServerRecovered`);**灵动岛的上课时段暂存通知**由服务端保留、
下课后补发,不依赖重连。
| 房间 | 作用 |
| --------------- | -------------------------------------------------- |
| class:{classId} | 某班级的业务广播 |
| session:{id} | 会话定向名;员工 id 为账号 id,班级端 id 为班级 id |
| role:{role} | 角色房间名,定义在共享常量 |
| teacher:{id} | 教师房间名,定义在共享常量 |
| students | 班级端连接的广播房间,代表全班,不是个人学生账号 |
| teachers | 教师和管理员连接的广播房间 |
## 事件清单
`realtime/socket.ts` 当前显式 join 可访问的 class 房间与 students/teachers 广播房间;
同时在 connected 载荷列出 session/role 名。不能把共享常量或该列表当成所有房间均已成功 join 的证明,实际订阅以 join 调用为准。
业务经 `realtime/bus.ts` 的 emitToClass/emitToSession/emitToRooms 推送。
叫人记录属于班级通知,并发送班级广播与班级会话定向事件;学生本身没有连接和个人房间。
事件名定义在 `@classhelper/shared` 的 `SOCKET_EVENTS` —— **不要手写字符串**。
## 主要事件
| 事件 | 方向 | 载荷 |
| ------------------------------------- | --------------- | ------------------------------------------------ |
| `connected` | 服务端 → 客户端 | `{ userId, role, rooms }` |
| `notification:new` | 服务端 → 客户端 | `NotificationDto` |
| `homework:new` | 服务端 → 客户端 | `HomeworkDto` |
| `homework:updated` | 服务端 → 客户端 | `HomeworkDto & { deleted?: boolean }` |
| `homework:status` | 服务端 → 客户端 | `HomeworkStatusDto & { classId }` |
| `grade:updated` | 服务端 → 客户端 | `GradeDto` |
| `schedule:updated` | 服务端 → 客户端 | `{ classId, action, schedule? }` |
| `class:updated` | 服务端 → 客户端 | `{ classId, action }` |
| `call:new` | 服务端 → 客户端 | 叫人消息(定向到 `user:{studentId}` / 班级设备) |
| `classisland:state` | 服务端 → 客户端 | `ClassIslandStateEvent`(教室现在上什么课) |
| `classisland:notification` | 服务端 → 客户端 | `ClassIslandNotificationEvent`(提醒已下发) |
| `class:join` / `class:leave` / `ping` | 客户端 → 服务端 | 手动订阅班级(服务端二次校验权限) |
| 事件 | 内容 |
| ------------------------------------------------- | ---------------------------------------------------------- |
| connected | `{ sessionId, role, rooms }` |
| notification:new / notification:deleted | 通知新增/撤回;客户端同步列表、缓存和岛队列 |
| homework:new / homework:updated / homework:status | 作业新增、修改/删除与完成状态 |
| grade:updated | 成绩变化通知;载荷按范围与隐私规则发送,客户端按需重新读取 |
| schedule:updated / class:updated | 课表/班级变化,重新加载对应数据 |
| courseware:changed | 课件列表/可见范围/推送变化,触发客户端检查 |
| client:release-progress | 服务端缓存客户端安装包的进度 |
| call:new | 叫人内容,与 notification:new 按同 id 合并去重 |
| class:join / class:leave / ping | 客户端订阅/离开班级、探活;join 重新校验范围 |
## 房间规则
事件没有持久离线消息队列保证。重连后由页面和恢复钩子重新拉取业务数据。
普通通知上课暂存是客户端岛控制器的本机行为,服务端仍即时落库广播;
断线通知补弹由客户端未读读取逻辑处理,课件未收任务由服务端保存、客户端心跳补领。
经典/自定义模式的展示差异见[通知与提醒](../app/notifications.md)。
房间名定义在 `SOCKET_ROOMS`:
## 修改事件
| 房间 | 谁在里面 |
| --------------------- | --------------------------------- |
| `class:{classId}` | 这个班的所有在线端 |
| `user:{userId}` | 某个用户自己的连接 |
| `teacher:{teacherId}` | 某个教师 |
| `role:{role}` | 某一角色(`ADMIN` / `TEACHER` …) |
| `students` | 全部 ClassHelper 班级端连接 |
| `teachers` | 全部教师端连接 |
服务端通过 `realtime/bus.ts` 的 `emitToClass` / `emitToUser` 发事件 ——
**业务模块不直接依赖 Socket.IO 实例**,只依赖事件总线。
## 新增一个实时事件要改三处
```
① 服务端 shared/src/constants.ts 的 SOCKET_EVENTS 加常量
→ business service 调 bus.emitToXxx(...)
→ realtime/socket.ts 房间映射(如果需要新房间)
② Web 端 web-admin/src/stores/realtime.ts 订阅
③ 客户端 desktop-client/src/renderer/stores/realtime.ts 订阅
→ 灵动岛也依赖它(renderer/island/bridge.ts)
```
**只改服务端不会报错**,只是前端静静地不更新 —— 所以这三处必须一起改。
事件名一律取自共享常量,不要手写字符串(拼错一个字就是一个静默失效)。
## 事件到达之后发生什么
以通知为例:
```
服务端写库 → bus.emitToClass(classId, notification:new)
├─► Web 端 stores/realtime.ts → 通知列表刷新
└─► 客户端 stores/realtime.ts → 更新内存状态 + 写回 IndexedDB
└─► island/bridge.ts → pushNotificationToIsland()
└─► 主进程 IslandController 决定:隐藏 / 胶囊 / 详情
└─► 灵动岛渲染进程 IslandApp.vue
```
上课时段的取舍(隐藏还是立即展开)由主进程的 `IslandController` 按当前上课状态决定,
见[通知与提醒](../app/notifications.md)与[灵动岛](../app/island.md)。
同步修改共享事件名与载荷、服务端发送点、Web `stores/realtime.ts`、客户端 `stores/realtime.ts` 及相关缓存/岛逻辑。
不要手写事件名;只改服务端会导致前端静默不刷新。涉及成绩时另检查隐私载荷和班级范围。
+57 -197
View File
@@ -1,221 +1,81 @@
---
title: 版本迭代与发布
description: 改版本号、跑验收、打安装包、发 GitHub Release、发布文档站——一次完整发布的命令清单。
description: 版本单一来源、本地与发布文件名、验证和发布同步。
---
# 版本迭代与发布
## 一条总原则
日常文档/代码维护不自动改版本。版本唯一修改入口是 scripts/version.mjs;先执行 pnpm version:check。
正式新发布执行 pnpm version:bump patch|minor|major,指定固定版本的本地重构建保持版本不变。
版本落点由脚本清单决定,不另维护“文件数/落点数”。
> **版本号按「发布」迭代,不按「提交」迭代。**
日常的 `feat` / `fix` 提交**不动版本号**。只有当你要产出一批安装包、在 GitHub 上开一个 Release 时,
才走一次完整的版本迭代。理由很实际:安装包、`.cipx`、Docker 镜像 tag、文档站的版本徽标、
更新检查接口全都按版本号对齐;每次提交都动它,等于每天都在发版,用户那边的「检查更新」就废了。
| 算一次发布 | 不算 |
| --------------------------------------- | ---------------------------- |
| 攒够一批功能 / 修好一个用户能感知的 bug | 改注释、改文档、重构、加测试 |
| 要给别人一份新安装包 | 只在自己机器上跑 |
| 插件单独发补丁(只动插件第 4 段版本号) | — |
## 版本号写在哪(14 处 / 12 个文件)
同一个版本号分散在 14 个落点上,**手工改必然漏一处**,而漏掉的那处不会报错:
| 文件 | 内容 |
| ---------------------------------------------------------------- | ---------------------------------- |
| `package.json` | 单一来源(服务端运行时读它) |
| `packages/{shared,server,web-admin,desktop-client}/package.json` | 各包的版本 |
| `deploy/package.runtime.json` | 安装包 / 容器里的运行时依赖清单 |
| `deploy/docker-compose.yml`、`docker-compose.sqlite.yml` | 镜像 tag 的默认值 |
| `packages/classisland-plugin/manifest.yml` | 插件清单(四段:`x.y.z.0`) |
| `packages/classisland-plugin/src/Services/BridgeService.cs` | 插件上报的 `PluginVersion` 常量 |
| `website/index.html` × 3 | 官网顶栏徽标 / 首屏芯片 / 页脚 |
| `website/download.html`(`<meta name="ch-version">`) | 下载页读不到版本清单时的兜底版本号 |
最后两行值得单独说:官网是**纯静态站、没有构建步骤**,没有哪段代码会去读 `package.json`,
所以那几处只能靠脚本一起改 —— 漏了的话首页会一直挂着旧版本号(而那是别人看到的第一眼),
下载页则会给离线访客一串指向**不存在版本**的直链。
所以**改版本号的唯一入口是脚本**:
```bash
pnpm version:check # 逐处列出并比对,不一致 exit 1
pnpm version:bump patch # 1.1.2 → 1.1.3
pnpm version:bump minor # 1.1.2 → 1.2.0
pnpm version:bump major # 1.1.2 → 2.0.0
pnpm version:check # 再确认一次
```
脚本按表逐个改写,**任何一处没匹配到或匹配到多处就直接失败退出**,不做"尽力而为"的部分改写。
> 插件版本是 **四段** `x.y.z.0`:前三段跟产品版本走,第 4 段留给"只改插件、不动产品版本"的补丁发布。
> `--bump` / `--set` 会把第 4 段归零;要单独发插件补丁就手工改那两处(清单 + `BridgeService.cs`)。
## 一次完整的发布
下面这一串命令就是全部流程,逐条照抄即可。
### 1. 改版本号
## 构建与验证
```bash
pnpm version:check
pnpm version:bump patch
```
### 2. 本地体检
```bash
pnpm lint
pnpm typecheck
pnpm check:icons
pnpm docs:check # 文档站构建(警告即失败)
```
### 3. 跑验收
```bash
pnpm dev:server # 另开一个终端跑着
pnpm docs:check
pnpm verify:e2e
pnpm verify:classisland
pnpm verify:classisland-plugin
pnpm build:desktop && pnpm verify:desktop
pnpm build && pnpm verify:web
pnpm verify:v14
pnpm verify:courseware
pnpm build:desktop
pnpm verify:desktop
pnpm --filter @classhelper/web-admin build
pnpm verify:web:isolated
pnpm dist:all
pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版本>-x64-portable.exe" --offline
```
哪一项红了先看[验收与测试](testing.md)的「失败时先别改代码」——
里面列了几个已知脆弱的用例,它们红不代表代码坏了。
以上按发布改动范围执行;显式 --visual 才是桌面视觉验收。测试数量和结果必须来自本次输出。
完整边界见[验收](testing.md)。同号本地重构建先保留可恢复副本,最新包核验完成后按用户要求清理旧归档。
2026-10-18 工作区清理后,1.4.0 仅保留当前 client/server 目录和当前更新日志/校验清单。
### 4. 打安装包
## 产物与文件名
| 本地构建产物 | GitHub Release 上传名 |
| --------------------------------------------------------------------------- | ------------------------------------------ |
| releases/client/<版本>/安装包/ClassHelper-<版本>-x64-setup.exe | ClassHelper-<版本>-x64-client-setup.exe |
| releases/client/<版本>/安装包/ClassHelper-<版本>-x64-portable.exe | ClassHelper-<版本>-x64-client-portable.exe |
| releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe | ClassHelper-<版本>-x64-server-setup.exe |
| releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz | 同名 |
Linux 还需同名 .tar.gz.sha256。正式 Linux 包在 Linux runner 运行 pnpm dist:server:linux;
Windows 上的交叉构建仅作验证,可能缺目标平台的 Prisma engine。
构建中间物可再生成,不将 win-unpacked、server staging 或解包副本当交付物保留。
## 更新日志与校验
releases/ReleaseNotes-<版本>.md 维护本批包的功能、升级须知、实际验证范围与文件清单。
它描述该构建批次,不能把之后的源码功能写成旧包已经包含。
SHA256SUMS-<版本>.txt 用上传名记录各文件哈希;本地查验必须先按上表映射,不能因为本地文件没带 client/server 后缀就判断丢包。
可在 PowerShell 对 client/server 安装包运行 Get-FileHash -Algorithm SHA256,将三项实际结果按上传名写入清单。
不要仅对客户端目录做重定向而漏掉服务端;Linux 行待 CI 完成后同步。
## 发布同步
完成验证和本地更新日志后,检查 diff,按用户授权提交和推送 master;不要把其他未完成改动一并提交。
将三个 Windows 包复制到独立的 .cache/release-staging/<版本>/,使用上传名,避免通配符混入别的版本。
先准备当前 Windows SHA256 清单并随 Release 上传。创建 Release 时显式 --target master 或明确提交,说明用 --notes-file。
ClassIsland 插件已退役,不再上传插件资产。
```bash
pnpm dist:all # = dist:server + dist:win + dist:classisland-plugin
# <版本> 和提交均替换为本次实际值
gh release create v<版本> --target master --title "ClassHelper v<版本>" --notes-file releases/ReleaseNotes-<版本>.md
gh release upload v<版本> <三个已按上传名暂存的EXE> releases/SHA256SUMS-<版本>.txt
```
产物落在 `releases/<组件>/<版本>/`:`安装包/`(交付物)、`构建中间/`(可重新生成)。
**没有免安装目录** —— 组装/解包出来的那一份是中间物,打包脚本用完就删(见仓库根 `AGENTS.md` §4
「交付产物归集」)。
推送 v* tag 触发 release-linux-server.yml,按 tag 检出,在 Ubuntu 构建并上传 Linux 包、独立 .sha256,合并跨组件清单。
等待 Actions 成功,再下载 Linux 文件和最终 SHA256SUMS 同步回本地;检查清单的 Windows 行仍与本次文件一致。
如需重跑,可手动运行该 workflow,指定准确 tag 和 attach=true。master 修复不会自动改变旧 tag 指向。
打完之后**对打包副本再验一次**(开发产物通过 ≠ 用户机器上的副本通过):
官网和文档站由 pages.yml 拼装同一个站点:官网根、文档 /docs/。文档版本徽标读根 package.json,
官网和下载页兜底版本由 version 脚本维护。Linux workflow 与 Pages 是独立流水线,分别确认结果。
官网行为变化同步 README 与相应帮助章节,功能索引放[源码索引](../reference.md)。
```bash
# 默认指向装好的客户端;推荐直接拿刚打出来的便携版单文件当被测副本
pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版本>-x64-portable.exe"
```
## 已发布版本与回滚
> Linux 包**只能在 Linux 上构建**(依赖是平台相关的),所以不在这条命令里 ——
> 它由 GitHub Actions 出,见第 7 步。
### 5. 生成跨组件哈希清单
`releases/SHA256SUMS-<版本>.txt` 是**人读的**(表头 + 体积 + 说明),由发布流程维护,
本地只写 Windows 与插件那几行:
```bash
cd releases/client/<版本>/安装包 && sha256sum *.exe > ../../../SHA256SUMS-<版本>.txt # 按实际文件名整理
```
(Linux 那一行由 CI 在第 7 步里**合并**进去,不要手工写。)
### 6. 提交并推上去
```bash
git add -A
git commit -m "chore(release): 1.1.3"
git push origin master
```
提交信息用 Conventional Commits + 模块 scope,描述写中文,可多 scope,例如
`fix(island): 胶囊"点不动"的根因——窗口必须始终可激活`。
### 7. 开 Release
上传前先把产物改成 ASCII 名 —— 构建产物是中文名,Release 上的名字是给别人看、要贴进脚本的:
```bash
V=1.1.3
mkdir -p .cache/release-staging
cp "releases/client/$V/安装包/"*-x64-setup.exe ".cache/release-staging/ClassHelper-$V-x64-client-setup.exe"
cp "releases/client/$V/安装包/"*-x64-portable.exe ".cache/release-staging/ClassHelper-$V-x64-client-portable.exe"
cp "releases/server/$V/安装包/"*-x64-setup.exe ".cache/release-staging/ClassHelper-$V-x64-server-setup.exe"
cp "releases/classisland-plugin/$V/安装包/ClassHelper.ClassIslandPlugin.cipx" .cache/release-staging/
gh release create v$V --title "v$V" --generate-notes
gh release upload v$V .cache/release-staging/* "releases/SHA256SUMS-$V.txt" --clobber
```
| 构建产物 | Release 上的名字 |
| --------------------------- | -------------------------------------------- |
| `<客户端>-x64-setup.exe` | `ClassHelper-<版本>-x64-client-setup.exe` |
| `<客户端>-x64-portable.exe` | `ClassHelper-<版本>-x64-client-portable.exe` |
| `<服务端>-x64-setup.exe` | `ClassHelper-<版本>-x64-server-setup.exe` |
这三个名字是**外部契约**:README 的「开始使用」表、官网下载页读不到版本清单时拼的兜底直链,
都按它们写;改名要三处一起改。
`gh release create` 会**创建 tag 并推送**,这一步会自动触发
[`release-linux-server.yml`](https://github.com/LoveChengke/classhelper/blob/master/.github/workflows/release-linux-server.yml)
构建 Linux 包(ubuntu runner)。它跑完之后会:
1. 把 `classhelper-server-linux-x64-<版本>.tar.gz` 与 `.sha256` 挂到 Release 上;
2. **把 Linux 那一行合并进** `SHA256SUMS-<版本>.txt`(保留 Windows / 插件那几行)。
> **顺序很重要**:Linux workflow 是在最后一步才去合并 SHA256SUMS 的,
> 所以第 5 步那份清单必须**先上传**。万一手速反了(workflow 先跑完、你后上传),
> 重新跑一次 `gh workflow run release-linux-server.yml -f tag=v1.1.3 -f attach=true` 就能把那一行补回来。
想手动触发(比如只需要重出 Linux 包):
```bash
gh workflow run release-linux-server.yml -f tag=v1.1.3 -f attach=true
```
### 8. 官网与文档站
**不用做任何事。** 推送 master 时只要 `website/**`、`docs/**`、`scripts/build-pages.mjs` 或
`package.json` 变过,
[`pages.yml`](https://github.com/LoveChengke/classhelper/blob/master/.github/workflows/pages.yml)
就会把两者构建成**同一个** GitHub Pages 站点发出去:
```
https://lovechengke.github.io/ClassHelper/ ← 官网
https://lovechengke.github.io/ClassHelper/docs/ ← 文档站
```
(一个仓库在 Pages 上只有一个站点,所以是拼在一起发的 —— 官网在根、文档站在 `/docs/` 下。)
文档站顶栏的版本徽标取自根 `package.json`,所以第 1 步改完就跟着变了;
官网首屏那排芯片、顶栏徽标与页脚里的版本号,以及下载页那个兜底版本号,
也都是**由 `pnpm version:bump` 一起改的**(它们是那 14 个落点里的四个)。
> 官网的「立即下载」会跳到 `/download.html`,那一页的版本清单是**当场**从 GitHub Releases 读的
> (读不到才退回上面那个兜底版本号),所以发完 Release 不用重新部署那一页 —— 但**兜底值要跟着发版一起改**,
> 不然离线访客点到的就是旧版本的直链。
### 9. 检查
- [ ] Release 页面上四个组件都在,`SHA256SUMS-<版本>.txt` 里 Windows / 插件 / Linux 三种行齐全;
- [ ] 文档站能打开,顶栏版本号是新版;
- [ ] 装一台机器试一遍(或至少跑 `pnpm verify:packaged`)。
## 回滚
发布出去之后发现严重问题:
| 情形 | 做法 |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| 只是文档 / Release 说明写错 | 直接改 Release 说明或重发文档站(`gh workflow run docs.yml`) |
| 二进制有问题 | 删掉 Release 与 tag(`gh release delete v1.1.3 --cleanup-tag`),修完重新发;**不要**拿同一个版本号发第二次内容不同的包 |
| 用户已经装了 | 发一个**新版本号**(`pnpm version:bump patch`)修复,不要原地改 |
> 已经发出去的版本号**不要复用**:客户端把它记进「忽略此版本」,服务端与 Web 端各有 30 分钟缓存,
> 同号换内容会让"检查更新"永久失灵。
## 相关
- 文档站本身怎么写的 → [架构总览的目录结构](architecture.md)
- 验收脚本与脆弱用例 → [验收与测试](testing.md)
- 各形态升级的**用户侧**步骤 → [更新与升级](../management/upgrade.md)
已公开且用户安装的版本建议发补丁版本修复,避免同号不同内容使更新缓存/忽略版本与摘要失配。
用户明确要求固定版本重构建时,按要求保持版本,并清楚区分本地包和远端资产;不自动改远端 tag/Release。
仅说明或文档错误直接修改说明/文档;线上故障按对应部署形态从完整备份恢复。
+50 -103
View File
@@ -1,122 +1,69 @@
---
title: 验收与测试
description: 五套验收脚本怎么跑、覆盖什么、哪些用例是脆弱的,以及怎么判断失败是不是真回归。
description: 隔离 API、隐藏 UI、后台客户端与显式视觉验收。
---
# 验收与测试
这个仓库的验收方式是**跑真东西**:真的起后端、真的开 Electron 窗口、真的点击菜单、
真的发一条通知。不是单元测试,是端到端回归。
按改动范围选择验证;数量和结果读取本次输出,不沿用历史报告数字。
默认客户端冒烟为后台快速模式;动画、透明度、桌面截图必须显式视觉模式。
## 五套脚本
## 命令与前置条件
| 命令 | 覆盖 | 需要什么 |
| -------------------------------- | -------------------------------------------------------------------------------- | --------------------------- |
| `pnpm verify:e2e` | 后端 + 实时推送 + RBAC 越权 + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查 | **后端已启动** |
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动) | 后端已启动 + Web 产物已构建 |
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) | 已构建的客户端产物 |
| `pnpm verify:packaged` | 对**打包后 / 已安装**的客户端 EXE 复跑同一套冒烟(`--exe` 指定路径) | 打包产物 |
| `pnpm verify:classisland` | 设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理 | 后端已启动 |
| `pnpm verify:classisland-plugin` | 插件静态契约(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由) | 不需要 .NET,也不需要后端 |
| 命令 | 覆盖/前置条件 |
| ------------------------------------------------ | ----------------------------------------------------------------- |
| pnpm lint / pnpm typecheck / pnpm check:icons | 静态检查;typecheck 会先构建 shared |
| pnpm docs:check | docfx 严格构建,需 .NET SDK 与 docfx |
| pnpm verify:e2e | 自动临时 SQLite、种子和带前缀账号,启动随机端口服务;无需真实服务 |
| pnpm verify:web:isolated | 同上隔离服务,隐藏 Electron UI 回归;先构建 Web 产物 |
| pnpm verify:web | 连接已准备测试服务,需 Web 产物和测试管理员;不应指向业务服务 |
| pnpm verify:v14 | 临时库验证关系课表、周次、范围、隐私与升级等 |
| pnpm verify:courseware | 临时库和文件目录的课件专项,不向真实班级推送 |
| pnpm verify:client-agent | 假系统适配器验证命令、回执和更新策略 |
| pnpm verify:client-migrations | 临时旧库验证迁移、联动退役与默认组持久化 |
| pnpm verify:review-client | 无窗口逻辑回归 |
| pnpm verify:review-api / pnpm verify:clients-api | 需明确准备的隔离测试服务,使用 VERIFY_BASE_URL 等脚本参数 |
| pnpm verify:desktop | 先 pnpm build:desktop;隐藏快速检查,独立 userData |
| pnpm verify:desktop:online | 显式联网,需要测试管理员并创建临时教师/班级 |
| pnpm verify:desktop:visual | 显式使用桌面,完整岛几何/动画/透明度/截图检查 |
| pnpm verify:packaged --exe <客户端EXE> | 默认快速离线,两次启动、packaged=true;--visual 才使用桌面 |
条目数由脚本自行统计并打印(`=== 结果:N/N 项通过 ===`),**文档里不写死容易过期的总数** ——
每次跑完看脚本输出。
联网 packaged 使用 --online;调用方须预先准备隔离服务、临时教师和班级并显式注入凭据,该启动器不会自动创建它们。
开发版 verify:desktop:online 则通过 provisionSmokeClass 创建和清理临时身份。--offline 保持离线。
不得回退到真实班级、重置真实密码或向真实班级发测试通知;远控与安装器使用假执行器。
桌面进程超时和失败日志先定位,不能因为一个历史脆弱用例名称就断言当前失败必然正常。
另外三套是"门禁"而不是验收:`pnpm typecheck`、`pnpm lint`、`pnpm check:icons`。
### 一次完整的交付前检查
## 常用检查顺序
```bash
pnpm lint # ESLint,0 error 是底线
pnpm typecheck # 全仓库类型检查(含 vue-tsc)
pnpm dev:server # 另开一个终端
pnpm lint
pnpm typecheck
pnpm check:icons
pnpm docs:check
pnpm verify:e2e
pnpm verify:classisland
pnpm verify:classisland-plugin
pnpm build:desktop && pnpm verify:desktop
pnpm build && pnpm verify:web
pnpm verify:v14
pnpm verify:courseware
pnpm build:desktop
pnpm verify:desktop
pnpm --filter @classhelper/web-admin build
pnpm verify:web:isolated
```
改动涉及打包/交付形态时,再加 `pnpm dist:all` 与
`pnpm verify:packaged --exe "<客户端路径>"`(**开发产物通过不等于用户机器上的副本通过**)。
仅文档修改只需格式、链接与 docs:check;不因此重打包或占用桌面。
改打包/运行时裁剪还要构建实际副本并跑 packaged;Linux 交付修改需 Linux 真机/CI 验证和 bash 语法检查。
## 失败时先别改代码
## 故障定位
下面这些用例**不是回归**,遇到时先看条件。完整清单在 `AGENTS.md` 的「已知脆弱用例」表。
- 临时服务失败查 .cache/v14-verification 下本次日志;不要改业务 .env 或密码迁就测试。
- 显式联网管理员变量:桌面 ELECTRON_SMOKE_ADMIN/ELECTRON_SMOKE_ADMIN_PASSWORD,Web UI_SMOKE_ADMIN/UI_SMOKE_ADMIN_PASS。
- 视觉用例需要真实桌面与稳定帧,先区分状态未稳定、窗口遮挡和实际回归,再针对原因重跑。
- Element Plus dialog 关闭后 DOM 仍保留,应检查 overlay 的可见状态。
- 本地 HTTP 探测绕过代理;Prisma 生成物和 shared/dist 缺失时先补前置构建。
| 用例 | 触发条件 |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `verify:desktop`「真实通知链路」「灵动岛标为已读」等 3 项 | 要求**当前不在上课时段**。种子课表恰有课正在进行时,客户端会**正确地**把普通通知暂存到下课 —— 这三项就会红(日志里会写 `客户端判定上课中=true`) |
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把周一第 1 节限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`.env` 的 `TERM_START_DATE` 对齐即可全绿 |
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里就没有可自动建课的「统一科目」分组 |
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤 |
| `verify:desktop`「作业看板全屏自适应」 | 演示数据某天有 6 条长作业、而冒烟窗口只有约 543px 高时,缩放被下限 0.45 钳住 → 必然溢出 |
| 岛交互 / 岛动画 / 岛截图留档 | **AI 会话里会间歇性失败**:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** |
## 截图
## 大面积报「登录失败 / 401」时
先核对本机 `teacher1` 的密码 —— 本机实测可能被改成了 `123456`,不是种子默认的 `teacher123`
(原因是「修改密码」弹窗留空会落到默认初始密码)。**处理方式是用环境变量传实际凭据,不要去改数据。**
两个演示账号的口令**不一定一起漂**,所以不能简单地全局替换。
| 脚本 | 覆盖凭据的方式 |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `verify:desktop` | `set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop` |
| `verify:packaged` | 同上(`ELECTRON_SMOKE_*`) |
| `verify:web` | `set "UI_SMOKE_USER=teacher1" && set "UI_SMOKE_PASS=123456" && pnpm verify:web`(另有 `UI_SMOKE_ADMIN*`) |
| `verify:e2e` | **不支持环境变量**,需用临时副本替换(且副本必须放在 `packages/server/scripts/` 里,否则 `import 'socket.io-client'` 会失败) |
## 冒烟会真的动你的屏幕
`verify:web` / `verify:desktop` / `verify:packaged` 会**真的弹窗口**、真的发通知、真的移动鼠标:
- 跑的时候别动键鼠;
- 验证实例的灵动岛是**洋红主题色**(`#e91e8c`),一眼可以与你自己客户端那个区分开。
两条反馈里的"屏幕上出现了第二个灵动岛""点它反应不对"都源于这个叠影;
- 冒烟会往演示班发几条「张老师 · _自检_」通知,用完即删;中途被 kill 会留下残条。
## 截图留档
截图分两套,各管一件事:
| 目录 | 谁在用 | 谁产出 |
| ------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------- |
| `docs/images/` | **文档站、README、官网用的是同一份图**,页面上展示的都是它 | `capture-shots.mjs`(Web / 手机)+ 客户端冒烟(客户端 / 灵动岛) |
| `docs/screenshots/` | 回归留档,页面不展示;`verify:*` 的像素级断言比对的就是它们 | 各冒烟脚本自动写入(island / client / web-mobile / classisland 四组) |
**展示图重新采集**(保证文档站与 README、官网用的是同一份图):
```bash
pnpm dev:server # 后端同时托管 Web 管理端
CAPTURE_OUT=docs/images,website/assets/shots node website/tools/capture-shots.mjs # → web-*.jpg / mobile-*.jpg
# 客户端与灵动岛走的是冒烟脚本。它除了正片,还会顺手落一份「岛外留白对照图」
# (*-window.png / *-backdrop.png),所以先落到临时目录,再挑要用的拷进去。
ISLAND_SHOTS_DIR="$PWD/.cache/shots" ELECTRON_CLIENT_SHOTS_DIR="$PWD/.cache/shots" \
ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \
ELECTRON_SMOKE_USER=teacher1 ELECTRON_SMOKE_PASSWORD=123456 \
node packages/desktop-client/scripts/smoke.mjs
cp .cache/shots/{island-*,client-*}.png docs/images/
rm -f docs/images/*-window.png docs/images/*-backdrop.png
cp docs/images/{island-*,client-*}.png website/assets/shots/ # 官网那份保持同步
```
**回归留档重新采集**(同一套冒烟,把输出指到留档目录):
```bash
pnpm dev:server
ISLAND_SHOTS_DIR="$PWD/.cache/island-shots" \
ELECTRON_CLIENT_SHOTS_DIR="$PWD/.cache/client-shots" \
ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \
ELECTRON_SMOKE_USER=teacher1 ELECTRON_SMOKE_PASSWORD=123456 \
node packages/desktop-client/scripts/smoke.mjs
```
> 展示图都是**逻辑像素 1×**(灵动岛的 268×44、424×230 就是这个数,不再乘 2),
> 页面按原始尺寸显示即可 —— 换图时别顺手放大,否则整页的尺寸对不上。
> 采集脚本会把窗口真的弹在屏幕上。客户端留档会**临时停掉 CSS 动画**再截图 ——
> 窗口被遮挡时 Chromium 冻结动画,而页面入场动画的起始帧是 `opacity: 0`,
> 不停掉的话截出来是**整片空白**(DOM 里文本齐全,只有图是空的)。
docs/images 为 README/文档展示图;docs/screenshots 为回归留档;website/assets/shots 为官网素材。
只把显式视觉验收采集、人工确认过的正片用作展示,不把窗口对照图或旧失败截图直接发布。
Web 采集入口 `website/tools/capture-shots.mjs`,先准备隔离测试服务和数据。
岛/客户端采集使用 smoke.mjs --visual,并按需 --online;ISLAND_SHOTS_DIR 与 ELECTRON_CLIENT_SHOTS_DIR 指向临时目录。
日常 quick 模式不会生成完整视觉截图,不能把 quick 通过表述为动画/透明度已验证。
+3 -2
View File
@@ -12,11 +12,12 @@
href: permissions.md
- name: 动效层与主题
href: motion.md
- name: ClassIsland 插件
href: classisland-plugin.md
- name: 灵动岛设计令牌
href: winisland-design-tokens.md
- name: 验收与测试
href: testing.md
- name: 版本迭代与发布
href: release.md
- name: 当前实现约定
href: implementation.md
+44 -578
View File
@@ -1,593 +1,59 @@
# WinIsland 视觉设计系统(Design Token 规格)
来源:`D:\classhelper\.cache\winisland-ref\WinIsland-master`(只读 checkout)。
所有数值均取自代码常量,标注了「常量名 = 值 @ 文件」。未在代码中找到的项明确写「未找到」。
关键前提:**岛内所有几何尺寸都乘以 `compact_scale`(默认 1.0)或 `expanded_scale`(默认 1.0)**,
下文数值默认 scale = 1.0。文字颜色在渲染入口被硬编码为 `Color::WHITE`(`src/core/render.rs`
`draw_expanded_layer` / `draw_compact_layer` 传 `text_color: Color::WHITE`, `text_color_sec: Color::WHITE`),
所有"次级/次要"文字靠 **alpha** 或 **灰度通道值** 表达,而不是换颜色。
---
title: 灵动岛设计令牌
description: 本地经典/自定义岛的几何、外观与动画入口。
---
## 1. 几何(几何 token)
# 灵动岛设计令牌
### 1.1 两个状态的尺寸
经典岛以 WinIsland 的连续圆角和形变为设计参考,自定义组件行以 ClassIsland 为参考。
当前数值与行为以本仓库 shared 常量和渲染实现为准;本页不把上游音乐/歌词/媒体功能列为 ClassHelper 功能。
| 项 | compact / mini | expanded | 来源 |
| -------- | ------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------- |
| 宽 | `base_width = 120.0` | `expanded_width = 360.0` | `src/core/config.rs` `AppConfig::default()` |
| 高 | `base_height = 27.0` | `expanded_height = 200.0` | 同上 |
| 圆角 r | `h / 2`(=13.5)→ 胶囊/体育场形 | `min(48*scale, w/2, h/2)` = **48.0** | `src/window/app/frame.rs` `default_target_r`;`src/utils/shape.rs` `expanded_island_radius()` |
| 全局缩放 | `compact_scale = 1.0` | `expanded_scale = 1.0` | `src/core/config.rs` |
## 经典几何与外观
圆角函数原文(`src/utils/shape.rs`):
| 来源 | 内容 |
| ---------------------------------------------------- | ----------------------------------------------------- |
| shared/src/constants.ts 的 DEFAULT_ISLAND_APPEARANCE | 默认宽 268、高 44、圆角 20、字号 13、边距 8,顶部居中 |
| ISLAND_APPEARANCE_RANGES | 滑块与主进程归一化共用的范围 |
| ISLAND_SIZE_DELTA | 展开、紧急与叫人相对尺寸 |
| ISLAND_SHADOW_PAD | 窗口边界的透明留白 |
| ISLAND_LIST_METRICS / islandListLayout | 主进程和渲染层共用列表度量,控制行数与工作区高度 |
| desktop-client/src/island/squircle.ts | 超椭圆路径 |
| desktop-client/src/island/classic-island.css | 经典底色、文字、图形与状态样式 |
```rust
pub(crate) fn expanded_island_radius(w: f32, h: f32, scale: f32) -> f32 {
(48.0 * scale).min(w / 2.0).min(h / 2.0).max(0.0)
}
```
classic 的 black/glass/tinted 是本地 CSS 外观;glass 使用半透明色,不采样桌面模糊。
不要对透明岛调用 setBackgroundMaterial:系统材质作用于整窗矩形,可能破坏逐像素透明。
投影常驻,只随形态改参数,避免切换 filter 创建合成表面的闪帧。
默认停靠中线不受左右边距影响,只有左/右停靠使用水平边距。
compact 圆角:`IslandSprings::new` 里 `r = (base_height * compact_scale) / 2.0`(`src/window/app.rs`);
展开时 `default_target_r = compact_content_h / 2.0`(`src/window/app/frame.rs` `update_spring_targets`)。
## 自定义常驻行
### 1.2 compact 宽度的动态规则(不是固定 120)
CLASSISLAND_ISLAND_METRICS 位于 shared/src/island-layout.ts:
`src/window/app/layout.rs` `compute_lyric_target_width()`:
| 参数 | 本地值 |
| ----------------------- | -------------------- |
| 单行基准高度 / 水平留白 | 40 / 12 px |
| 次要/正文/强调/大字号 | 14 / 16 / 18 / 20 px |
| 默认缩放 / 范围 | 1 / 0.75–2.5 |
| 默认圆角 / 最大 | 8 / 20 px |
| 默认不透明度 / 行距 | 0.5 / 5 px |
| 鼠标避让淡化比例 | 0.05 |
- 无媒体 / 不在播放态:宽 = `base_width` = **120**
- 有媒体但无歌词:宽 = `base_width + 35.0` = **155**
- 有歌词(不滚动):`w = 60.0 + 文本宽`,再 `clamp(base_width+35, MAX_LYRIC_WIDTH)`
- `min_w = base_width + 35.0`、`MAX_LYRIC_WIDTH = 700.0`(`src/core/config.rs`)
- 有歌词(滚动模式,`lyrics_scroll = true`):`natural_w = 60.0 + text_w`,超过 `lyrics_scroll_max_width`(默认
`300.0`,`default_lyrics_scroll_max_width`)则固定为该上限;内部可用文本宽 = `(fixed_w - 59.0) * compact_scale`
- 二级歌词存在时:高度 = `max(base_height*scale, mini_lyric_pair_height)`,
其中 `lyric_pair_height = 主字号*(1 + 0.85 + 0.18) + 8*scale`
(`SECONDARY_LYRIC_SCALE = 0.85`、`LYRIC_PAIR_GAP_SCALE = 0.18` @ `src/core/render/mini.rs`)
→ 默认字号 12 时 = 12×2.03 + 8 = **32.36 px 高**,r ≈ 16.18
方案有常态 rows 和无通知展开 expandedRows,运行时由 CustomIslandRuntime.vue 测量自然内容,
再按屏幕工作区与方案缩放适配。字体与外观覆盖按组件契约生效,不能拿经典固定宽高解释自定义内容尺寸。
### 1.3 挂件(compact widget)导致的最小宽度
## 动画与命中
`src/ui/widget/compact/mod.rs`:
经典与自定义岛的形变都使用 src/island/spring.ts 的 SpringValue;默认 stiffness=0.10、damping=0.68,
换向保留部分动量,按剩余距离限速并钳制过冲。不同用途可能覆盖 snapThreshold 等参数,以调用点为准。
这是独立的帧积分,不等同于主窗口 shared/motion.ts 的 CSS 弹簧。
应用内动画开关、原生窗口可见性与文档可见性控制运行时动画和静止落定。
- `CONTENT_EDGE_INSET = 9.0`、`CONTENT_GAP = 7.0`
- `widget_width()`:`Time = 48.0`、`ResourceUsage = 132.0`
- 三组对齐宽度:`aligned_layout_width = 9*2 + left + center + right + 7*(占用组数-1)`
- 单侧扩展宽:`extension_width = CONTENT_EDGE_INSET + strip_width + CONTENT_GAP`
(例:左 Time → 9+48+7 = **64**;ResourceUsage → 9+132+7 = **148**)
- 最终 compact 宽 = `max(布局宽, base_width)`
鼠标模式按光标命中控制穿透;触摸模式处理贴合、输入区域与失焦宽限,自定义常驻模式还维护独立交互入口和窗口形状。
几何改变时同步实际窗口、命中矩形和测量,不在动画中反推自然尺寸。
视觉验收必须显式 --visual;本页数值不代表本次已完成像素验收。
### 1.4 compact overlay(通知 / 音量)会替换 base 尺寸
## 修改入口
| overlay | 宽 | 高 | 圆角 | 来源 |
| ------- | ------------------------------------------ | ----------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------- |
| 通知 | `max(base_width, 330.0) * scale` = **330** | `max(base_height, 82.0) * scale` = **82** | `h/2` = **41** | `src/ui/compact/notification.rs` `target_size()`;r 取自 `src/window/app/frame.rs`(`size.height/2.0`) |
| 音量 | `(base_width + 72.0) * scale` = **192** | `(base_height + 10.0) * scale` = **37** | `h/2` = **18.5** | `src/ui/compact/volume.rs` `target_size()` |
### 1.5 隐藏态尺寸
- `hidden_width` 默认 `5.0`(`default_hidden_width` @ `src/core/config.rs`),
- `MIN_HIDDEN_WIDTH = 0.0`、`MAX_HIDDEN_WIDTH = 400.0`
- 隐藏时高度收缩到 `hidden_width * compact_scale`,并保留 `hidden_reveal_h = 1.0`(1px 可点击边);
位移距离 `hide_distance = concealed_height + TOP_OFFSET`(`src/window/app/layout.rs`)
### 1.6 锚点(两种状态之间什么不动)
定位全部来自 `src/window/app/layout.rs` `compute_island_layout()` / `compute_window_position()`:
- **顶部居中停靠(默认)**:`island_y = TOP_OFFSET = 10.0`(**顶边固定**),
`offset_x = (os_w - w) / 2.0`(**水平中心固定**)
- **底部停靠**:`island_y = os_h - TOP_OFFSET - h`(**底边固定**,向上生长)
- **左/右停靠**:`offset_x = TOP_OFFSET` 或 `os_w - TOP_OFFSET - w`(对应侧边固定)
- `TOP_OFFSET: i32 = 10`(`src/core/config.rs`)= 岛顶边距屏幕顶 10 px
- 状态切换由三个弹簧同时插值:`w/h/r` 参数为 `stiffness = 0.10, damping = 0.68`(`src/window/app/frame.rs`),
页面横移 `view` 用 `0.12 / 0.68`;隐藏用 `(0.12, 0.70)` 隐藏 / `(0.08, 0.78)` 显示
- **compact 内容不跟随变大的岛做垂直居中重排**:mini 内容画在 `stable_island_y`(= 顶边)加上 `base_h` 带内居中
(`src/core/render/mini.rs`:封面 `y = stable_offset_y + (base_h - size)/2`,歌词 `center_y = stable_offset_y + base_h/2`)。
效果=岛向下长大,胶囊内容始终贴在顶部那条带里。
- 交叉淡出:`expanded_alpha = expansion_progress² * (1-hide_progress)`,
`mini_alpha = clamp(1 - expansion_progress*1.5, 0, 1) * (1-hide_progress)`
(`MINI_FADE_RATE = 1.5` @ `src/core/render.rs`)→ 即进度 0~0.667 之间完成交叉淡出
### 1.7 有没有"挖孔/刘海"造型?
**没有。** 代码里没有 `SetWindowRgn` / `CreateRoundRectRgn` / notch / cutout 相关调用(已全仓 grep 确认),
也没有"顶部贴边 + 上方两角变直角"的特例分支。造型是:
- 一个**悬浮胶囊**(compact,r = h/2),**距屏幕顶 10 px**;
- 展开后是**四角一致的连续超椭圆**(squircle),r 最大 48。
圆角实现不是普通圆弧,而是 Flutter `RoundedSuperellipse` 的移植(`src/utils/shape.rs`,
注释标明 "Rounded-superellipse geometry adapted from Flutter under the BSD-3-Clause license"):
- `superellipse_parameters()`:11 项查表 `PARAMETERS`(如 `(2.0, 1.13276676) … (6.43023796, 2.98020421)`),
ratio > 5 时线性外推 `exponent = 6.43023796 + 1.559599389*(ratio-5.0)`、`factor = 2.98020421 + 0.522807185*(ratio-5.0)`
- `Octant::new()`:13 项 `BEZIER_FACTORS`(`(0.7078, 8.3194) … (0.9705, 1.2880)`),用 conic + cubic 拼四分之一角
- 路径缓存 `PATH_CACHE_CAPACITY = 64`
- **Web 复刻提示**:CSS `border-radius` 是正圆角,观感会偏"软";要还原请用 SVG path 或
`corner-shape: squircle`(若可用)/ `clip-path` 近似。**未找到**等价的简单圆角公式——真实几何就是上面那张表。
- **本仓库的落地实现**(`packages/desktop-client/src/island/squircle.ts`):不查表,用单参数超椭圆近似
`point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`,`p = 2/n`、`n = 4.2`,
四角分别以 `TR(w-r,r) → BR(w-r,h-r) → BL(r,h-r) → TL(r,r)`、起始角 `-π/2, 0, π/2, π` 各扫 `π/2`,
每角 14 段。**四个角必须各用自己的圆心 + 角度参数**:早期版本四个角共用同一个"偏移向量",
左上/右下被切成缺一块(用户反馈的"左上角和右下角怎么缺一块 / 圆角边缘割裂"),
现已由 `verify:desktop` 的「连续圆角四角一致」断言(四角沿对角线边界步进两两一致 + 路径绕行 \|winding\|≈2π)长期守住。
### 1.8 窗口留白 / 裁切
- `PADDING: f32 = 80.0`(`src/core/config.rs`);窗口尺寸 = `(岛宽 + 80, 岛高 + 80)`
(`src/window/app/layout.rs` `required_window_size()`)→ **岛四周各 40 px 透明留白**,供阴影/动画使用
- 绘制时 `canvas.clip_path(island_path, ClipOp::Intersect)`,所有内容裁在岛形内;
1px 描边在 `canvas.restore()` **之后**绘制(`src/core/render.rs` `draw_island()`)
- 挂件网格额外内缩,避免内容进圆角(`src/ui/widget/expanded/mod.rs` `widget_grid_layout`):
```rust
corner_inset = radius * (1.0 - FRAC_1_SQRT_2); // 48 * 0.29289 = 14.06
inset = (corner_inset + 8.0*scale).max(24.0*scale); // = 24.0(scale=1)
gap = 7.0 * scale;
```
### 1.9 expanded 内容网格
- `WIDGET_GRID_COLS = 6`、`WIDGET_GRID_ROWS = 3`、`WIDGET_GRID_SLOTS = 18`(`src/core/config.rs`)
- 槽位宽 = `(inner_w - 7*5) / 6`,高 = `(inner_h - 7*2) / 3`;
用 360×200、inset 24 计算:inner = 312×152 → **slot ≈ 46.17 × 46.0**(`src/ui/widget/expanded/mod.rs`)
- 组件占格 `WidgetKind::span()`:`Clock = (2,1)`、`ResourceUsage = (2,1)`、`Calendar = (2,2)`、`Settings = (1,1)`
(`src/core/config.rs`)
---
## 2. 配色(颜色 token)
### 2.1 岛背景(`src/core/render/background.rs` `draw_background()`)
| 样式 | 颜色值 | 说明 |
| --------------------------- | -------------------------------------------------------------- | -------------------- |
| `default`(`SOLID_STYLE`) | `Color::BLACK` = **#000000**,完全不透明 | `bg_color` |
| `glass` + host backdrop | `Color::from_argb(150, 10, 10, 14)` = **rgba(10,10,14,0.588)** | `draw_host_glass()` |
| `glass` 无 backdrop(回退) | `Color::from_argb(205, 32, 32, 36)` = **rgba(32,32,36,0.804)** | `fallback_color` |
| `dynamic` 底色 | `Color::from_rgb(32, 32, 36)` = **#202024**,不透明 | `draw_effect_base()` |
| `dynamic` 叠加层 | `Color::from_argb(120, 20, 20, 24)` = **rgba(20,20,24,0.471)** | 盖在旋转模糊封面之上 |
| `dynamic` 无封面回退 | 同 glass 分支(150/10,10,14 或 205/32,32,36) | |
`dynamic` 封面动效参数(同一函数):
- 集显:`rotate_speed = 0.015 rad/s, drift_speed_x = 0.075, drift_speed_y = 0.06, drift_amp_x = 10.0, drift_amp_y = 7.5`
- 独显:`rotate_speed = 0.03, drift_speed_x = 0.15, drift_speed_y = 0.12, drift_amp_x = 20.0, drift_amp_y = 15.0`
- 旋转矩形边长 `side_len = diagonal * 1.3`
**亚克力/backdrop 真实实现**(`src/window/d3d.rs` + `src/utils/win32.rs`):
Windows 11 `DWMWA_USE_HOSTBACKDROPBRUSH`,失败时退回 `SetWindowCompositionAttribute`
(`AccentPolicy { state: 5, flags: 0, gradient_color: 0 }`,state 5 = host backdrop);
再用 `Compositor::CreateHostBackdropBrush()` + `RoundedRectangleGeometry` 裁圆角
(`HOST_BACKDROP_INSET = 1.0`,几何 = `w-2, h-2, radius-1`)。
**未找到**任何模糊半径数值——模糊由系统 HostBackdropBrush 决定。
### 2.2 描边(`src/core/render.rs` `draw_island_border()`)
```rust
BORDER_WIDTH = 1.0; BORDER_INSET = BORDER_WIDTH / 2.0; // 0.5
SOLID_STYLE = "default"; SOLID_BORDER_ALPHA = 30; EFFECT_BORDER_ALPHA = 40;
```
- 颜色 = **纯白**,alpha:
- `default` 样式:`rgba(255,255,255, 30/255=0.118)`,**并再乘 `(1 - expansion_progress)`**(展开后描边消失)
- `glass` / `dynamic`:`rgba(255,255,255, 40/255=0.157)`,不随展开衰减
- 描边矩形 = `(x+0.5, y+0.5, w-1, h-1)`,圆角 `r-0.5`
### 2.3 阴影(`src/core/render.rs` `draw_expanded_shadow()`,仅展开时)
- 颜色 `Color::from_argb((28.0 * opacity) as u8, 0,0,0)` = **rgba(0,0,0,0.11)**
- `opacity = expansion_progress² * (1 - hide_progress)`,`opacity <= 0.01` 直接跳过
- 偏移 `offset_y = 2.0 * scale`;模糊 `sigma = min(3.0*scale, margin/3.0)`(margin = 到画布边界的余量)
- 用 `ClipOp::Difference` 只画在岛形之外
- **未找到内阴影**(没有任何 inner shadow 实现)
### 2.4 分隔线
| 位置 | 颜色 | 尺寸 | 来源 |
| -------------------- | -------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------- |
| compact 挂件之间 | `rgba(255,255,255, 0.14*alpha)` | 宽 `max(scale,1)`,从 `top+25%h` 到 `bottom-25%h` | `src/ui/widget/compact/mod.rs` `draw_separator()` |
| compact 资源占用内部 | `rgba(255,255,255, 0.10*alpha)` | 宽 `max(0.75*scale, 0.75)`,半高 `DIVIDER_HALF_HEIGHT = 6.0*scale` | `src/ui/widget/compact/resource_usage.rs` |
| expanded 资源环分隔 | `rgba(白, 0.09*alpha)` | 宽 `scale.max(0.75)`,从 `y+0.2h` 到 `y+0.8h` | `src/ui/widget/expanded/resource_usage.rs` |
| 设置窗口分隔线 | `rgba(255,255,255,26)` 暗 / `rgba(20,28,40,18)` 亮 | — | `src/utils/color.rs` |
### 2.5 文字颜色与不透明度分层
主文字一律 **#FFFFFF**;次级用 alpha:
| 元素 | 有效颜色 | 来源 |
| ------------------ | ------------------------------------------------------------ | ---------------------------------------------------- |
| 通知 app 名 | `rgba(255,255,255, 0.65*alpha)` | `src/ui/compact/notification.rs` |
| 通知标题 | `rgba(255,255,255, alpha)` | 同上 |
| 通知详情 | `rgba(255,255,255, 0.72*alpha)` | 同上 |
| 音量标签 | `rgba(255,255,255, 0.9*alpha)` | `src/ui/compact/volume.rs` |
| expanded 歌手 | `rgba(白, alpha*0.6)` | `src/ui/expanded/music_view.rs` `draw_track_text()` |
| 迷你歌词主行 | 白 alpha | `src/core/render/mini.rs` |
| 迷你歌词次行 | `rgb(176,176,176)`(`SECONDARY_LYRIC_CHANNEL = 176`) | 同上 |
| 逐字歌词"未唱"部分 | `rgb(190,190,190)`(`PENDING_LYRIC_CHANNEL = 190`) | 同上 |
| 进度条时间 | 白,`PROGRESS_TIME_IDLE_ALPHA = 0.5` → hover 1.0(线性插值) | `src/ui/expanded/music_view.rs` |
| 挂件时钟 | 白 alpha(bold) | `src/ui/widget/expanded/time.rs` |
| 挂件日历 月 | `0.78*alpha` | `src/ui/widget/expanded/calendar.rs` |
| 挂件日历 日 | `1.0*alpha` | 同上 |
| 挂件日历 星期 | `0.62*alpha` | 同上 |
| 挂件资源 数值 | `1.0*alpha`(bold) | `src/ui/widget/expanded/resource_usage.rs` |
| 挂件资源 标签 | `0.58*alpha` | 同上 |
| 封面占位块 | `rgba(白, 0.15*alpha)` | `src/ui/expanded/music_view.rs` `draw_placeholder()` |
### 2.6 强调色 / 状态色
岛内(`src/ui/widget/resource_usage.rs`):
```rust
CPU_COLOR = rgb(50, 190, 246) // #32BEF6
RAM_COLOR = rgb(175, 82, 222) // #AF52DE
WARNING_COLOR = rgb(255, 159, 10) // #FF9F0A
CRITICAL_COLOR = rgb(255, 69, 58) // #FF453A
```
阈值曲线 `usage_color(base, usage)`:
- `usage <= 0.75` → base
- `0.75 < usage <= 0.9` → base ↔ WARNING 线性混合,`t = (usage-0.75)/0.15`
- `0.9 < usage` → WARNING ↔ CRITICAL 线性混合,`t = (usage-0.9)/0.1`
采样间隔 `SAMPLE_INTERVAL = 1s`;无数值时显示 `—`。
进度条/音量条(`src/ui/expanded/music_view.rs`、`src/ui/compact/volume.rs`):
```rust
PROGRESS_TRACK_ALPHA = 0.15; // 轨道 rgba(白, 0.15*alpha)
PROGRESS_TIME_IDLE_ALPHA = 0.5; // 时间文字静止透明度
// 音量轨道 rgba(255,255,255, 0.28*alpha),填充纯白 alpha
```
音量的"填充"就是**白色**(不是强调色),图标仍是白色。
可视化条颜色**来自专辑封面**(`src/ui/expanded/music_view/palette.rs`):
- 把封面画到 8×8 的 GPU surface(`PALETTE_SAMPLE_SIZE = 8`)读回求平均 RGB
- `primary = brighten(avg, 1.3)`、`secondary = brighten(avg, 1.5)`
- `brighten` 用 BT.601 亮度 `0.299R + 0.587G + 0.114B`;若 < 80 则整体加 `80 - brightness`(抬到最低亮度 80)
- 调色板 = `[primary, secondary, primary]`,缓存上限 50 条
- 无封面默认值(`default_media_palette`):`rgb(180,180,180)` + `rgb(100,100,100)`
- 采样完全失败的兜底:`rgb(200,200,200)`
### 2.7 不同状态是否不同配色?
- **同一套**:底 + 白字 + alpha 分层;通知 / 音量 / 音乐 / 挂件没有各自的独立配色,
视觉区分靠 **尺寸(330×82 / 192×37 / 120×27)** 与 **内容排版**,而不是换色。
- 差异只在两处:**可视化条用封面取色**(2.6),**资源占用用 CPU/RAM/警告/危险四色**(2.6)。
- 岛背景三样式(default/glass/dynamic)才是真正的配色分支(2.1)。
### 2.8 设置窗口色板(另一套,`src/utils/color.rs`,供参考)
暗色主题 `dark_settings_theme()`:
```
win_bg #1C1C1E sidebar_bg #242426 group_bg #2C2C2E card_highlight #48484A
accent #0A84FF text_pri #F5F5F7 text_sec #AEAEB2 disabled #636366
danger #E6372D toggle_on #30D158 toggle_off #636366
sidebar_hover rgba(255,255,255,20) separator rgba(255,255,255,26)
popup_bg #323234 popup_border rgba(255,255,255,40) popup_shadow rgba(0,0,0,60)
popup_separator rgba(255,255,255,30) control_bg #3A3A3C control_hover #48484A
control_disabled #303032 control_border rgba(255,255,255,36)
group_border rgba(255,255,255,24) shadow rgba(0,0,0,45) scrollbar rgba(255,255,255,60)
```
亮色主题 `light_settings_theme()`:
```
win_bg #F4F5F7 sidebar_bg #FAFAFC group_bg #FFFFFF card_highlight #EFF2F7
text_pri #1F2228 text_sec #5B626E disabled #9297A1 accent #0067C0
danger #C42B1C toggle_on #0078D4 toggle_off #B0B5BE
selection_bg #E0EEFA selection_text #005599
sidebar_hover rgba(20,32,48,14) separator rgba(24,32,44,18)
popup_bg #FFFFFF popup_border rgba(20,28,40,24) popup_shadow rgba(20,28,40,20)
control_bg #F7F8FA control_hover #EBEFF4 control_disabled #F1F2F5
control_border rgba(20,28,40,24) group_border rgba(20,28,40,14)
shadow rgba(20,28,40,20) scrollbar rgba(52,58,68,52)
```
设置窗口另有"岛预览"配色(与真实渲染略有差异,`src/utils/settings_ui/renderer/widget_preview.rs`):
glass 预览 `rgba(24,24,28,220)`;dynamic 预览为线性渐变 **`rgb(18,12,36)` → `rgb(8,24,48)`**(左上→右下);
default 预览 `rgb(10,10,10)`;预览描边 alpha `52`(glass) / `38`(其他);预览阴影 `rgba(0,0,0,72)`,偏移 +4。
→ 若前端需要一张**静态渐变**替代 `dynamic`,这两个色值就是现成可抄的。
---
## 3. 排版(字体 token)
### 3.1 字体族(`src/utils/font.rs`)
1. 用户自定义字体:`config.custom_font_path` → `FontMgr::new_from_data()`(优先于全部)
2. ASCII 文本:`"Microsoft YaHei"` → `"Segoe UI"` → 系统默认(`legacy_make_typeface`)
3. 非 ASCII:逐字符匹配 `match_family_style_character("", style, &["zh-CN","ja-JP","en-US"], ch)`,
再依次尝试 `"Segoe UI Emoji"` → `"Microsoft YaHei"` → `"Segoe UI Symbol"` → `"Segoe UI"`
4. 合成粗体:`needs_synthetic_bold()` = 请求 `weight >= 600` 且字体本身 `weight < 600` → `font.set_embolden(true)`
5. 亚像素渲染始终开启 `font.set_subpixel(true)`
字重只有两档:`FontStyle::bold()`(700)与 `FontStyle::normal()`(400);
滚动文本判定粗体用 `style.weight() >= 700`(`src/utils/scroll.rs`)。
**字距 / 行高**:代码里**未找到** letter-spacing 或 line-height 参数;
行距全部通过显式 y 偏移实现(见下表)。文本绘制走 Skia `draw_str`,
大字文本(标题/歌手)`render_as_paths = true`(`src/utils/scroll.rs` 调用点)。
### 3.2 字号表(默认 `config.font_size = 0.0` 时用内置值;>0 时用户值参与计算)
| 用途 | 字号 | 字重 | 说明 / 来源 |
| -------------------- | -------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------- |
| mini 歌词主行 | `font_size*0.8*scale` 或 **12.0\*scale** | normal | `src/core/render/mini.rs` `lyric_font_size()` |
| mini 歌词次行 | 主字号 × `SECONDARY_LYRIC_SCALE = 0.85` | normal | 同上 |
| mini 歌词对高度 | `主*(1+0.85+0.18) + 8*scale` | — | `lyric_pair_height()` |
| mini 插件文字(主) | `font_size*0.7*scale` 或 **11.0\*scale** | **bold** | `PLUGIN_FONT_SCALE = 0.7`、`DEFAULT_PLUGIN_FONT_SIZE = 11.0` |
| mini 插件文字(次) | 主 × `0.8` | normal | `PLUGIN_SECONDARY_FONT_SCALE = 0.8`,行距 `font_size*1.3`(`PLUGIN_SECONDARY_LINE_SPACING = 1.3`) |
| expanded 曲名 | `font_size*scale` 或 **15.0\*scale** | **bold** | `src/ui/expanded/music_view.rs` |
| expanded 歌手 | 同上 **15.0\*scale** | normal | 基线 = 曲名基线 + `22.0*scale` |
| 进度时间 | `font_size*0.67*scale` 或 **10.0\*scale** | normal | `PROGRESS_TIME_FONT_SCALE = 0.67`、`DEFAULT_PROGRESS_TIME_FONT_SIZE = 10.0` |
| 通知 app 名 | **11.0\*scale** | normal | `src/ui/compact/notification.rs` |
| 通知标题 | **13.0\*scale** | **bold** | 同上 |
| 通知详情 | **11.0\*scale** | normal | 行间距 `DETAIL_LINE_GAP = 21.0*scale` |
| 音量标签 | **12.0\*scale** | normal | `src/ui/compact/volume.rs` |
| compact 时钟挂件 | `clamp(h*0.43, 10, 12)` | **bold** | `src/ui/widget/compact/time.rs` |
| compact 资源—标签 | `METRIC_LABEL_SIZE = 7.0*scale` | **bold** | `src/ui/widget/compact/resource_usage.rs` |
| compact 资源—数值 | `METRIC_VALUE_SIZE = 10.0*scale` | **bold** | 同上 |
| expanded 时钟挂件 | `min(h*0.60, w*0.31)`,最小 `13.0*scale` | **bold** | `src/ui/widget/expanded/time.rs` |
| expanded 日历—月 | `clamp(h*0.14, 9, 15)*scale` | **bold** | `src/ui/widget/expanded/calendar.rs` |
| expanded 日历—日 | `min(h*0.53, w*0.70)`,最小 `26.0*scale` | **bold** | 同上 |
| expanded 日历—星期 | `clamp(h*0.12, 8, 12)*scale` | normal | 同上 |
| expanded 资源—圆环值 | `clamp(d*0.27, 7, 10.5*scale)`,d = 圆环直径 | **bold** | `src/ui/widget/expanded/resource_usage.rs` |
| expanded 资源—标签 | `clamp(h*0.12, 6, 8*scale)` | **bold** | 同上 |
| 设置窗口—行标签/值 | **13.0** | normal | `src/utils/settings_ui/renderer/items.rs` |
| 设置窗口—页签标签 | **11.5** | normal(选中为 bold) | `src/window/settings/renderer.rs` |
| 设置窗口—弹窗项 | **12.0** | normal | 同上 |
| 设置窗口—Markdown | `BODY_SIZE = 12.0`,`LINE_HEIGHT = 18.0` | — | `src/window/settings/pages/plugins/markdown.rs` |
字体基线规则(值得照抄):
- 居中对齐用 **字形包围盒** 居中:`baseline_y = bounds.top + (bounds.h - glyph.h)/2 - glyph.top`
(`src/ui/widget/expanded/mod.rs` `draw_widget_text_centered()`)
- 时间文字基线 = 进度条中心 + `0.35*字号`(`PROGRESS_TIME_BASELINE_SCALE = 0.35`)
- 迷你歌词单行时 `primary_y = center_y + size/3`;双行时按堆叠高度对齐(见 5.1)
### 3.3 是否随窗口尺寸 / 用户设置缩放
- 随 `compact_scale` / `expanded_scale` 缩放(乘在所有尺寸上)
- 字号受 `config.font_size`(默认 0 = 用内置值)影响:只有 mini 歌词、mini 插件、expanded 曲名/歌手、进度时间用它
- 挂件字号是**几何驱动**的(按自身矩形宽高 clamp),与 `font_size` 无关
- 设置窗口有自己的 `settings_theme`(`system` / 暗 / 亮)
---
## 4. 层级与效果
### 4.1 圆角
见 1.7。总结:连续超椭圆(squircle),compact 满圆角(h/2),expanded 最大 48。
挂件卡片 `widget_corner_radius = min(12.0*scale, min(w,h)/2)`(`src/ui/widget/expanded/mod.rs`)。
迷你封面 `MINI_COVER_RADIUS = 5.0`,expanded 封面 `16.0*scale`,通知图标 `11.0*scale`。
### 4.2 阴影 / 发光
- **外阴影**:仅 expanded,`rgba(0,0,0,0.11)`,y+2,blur σ = `min(3*scale, margin/3)`(见 2.3)
- **内阴影**:**未找到**
- **发光**:只出现在资源占用圆环——`glow` 圆半径 `diameter*0.57`,颜色 `accent`,alpha `0.07*alpha`
(`src/ui/widget/expanded/resource_usage.rs`)
- 设置窗口:窗口阴影 `rgba(0,0,0,90)`、弹出菜单阴影 `theme.popup_shadow` + 偏移 +2(`src/window/settings/renderer.rs`);
拖拽幽灵阴影 `rgba(0,0,0,90)`,偏移 +4
### 4.3 模糊半径(全部数值)
| 场景 | 参数 | 来源 |
| ----------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------- |
| 运动模糊(整体) | 横向 = ` | w速度 | *0.3 + | view速度 | *w*0.4`,纵向 = ` | h速度 | *0.3`;上限集显 `(4.0, 3.5)`、独显 `(12.0, 10.0)`;仅在 σ>0.1 时 `saveLayer` | `src/utils/blur.rs` `calculate_blur_sigmas()`、`src/core/render.rs` `MIN_BLUR_SIGMA = 0.1` |
| 封面翻转 | `σ = (1- | cos | )^0.6 * 8.0 * scale`,x 方向再 ×0.3 | `src/ui/expanded/music_view.rs` |
| 暂停按钮速度模糊 | ` | 弹簧速度 | * 16.0 * scale`,上限 `2.5*scale` | `PAUSE_CONTROL_BLUR_SCALE = 16.0`、`PAUSE_CONTROL_MAX_BLUR = 2.5` |
| 快进/快退按钮 | `(1 - t/0.3).max(0) * 6.0 * scale`,x×0.3 | 同文件 `draw_skip_button()` |
| 歌词切换(Blur 模式) | `σ = 6.0*scale`,x 压缩 `LYRIC_TRANSITION_BLUR_X_SCALE = 0.25`,位移 `LYRIC_TRANSITION_OFFSET = 10.0*scale` | `src/core/render/mini.rs` |
| dynamic 背景封面 | 先降采样到 **64×64**,再 `image_filters::blur((8.0, 8.0), TileMode::Clamp)` | `src/utils/backdrop.rs` |
| host backdrop(亚克力) | **未找到数值**(由系统 HostBackdropBrush 决定,仅 `HOST_BACKDROP_INSET = 1.0`) | `src/window/d3d.rs` |
### 4.4 不透明度分层与可见性阈值
```
MIN_VISIBLE_OPACITY = 0.01 // 低于此不绘制
MIN_BLUR_SIGMA = 0.1
MINI_FADE_RATE = 1.5 // mini 淡出速率
COLLAPSED_VISUALIZER_HEIGHT_SCALE = 0.45 // 收起时可视化高度 = 45%
```
- `expanded_alpha = expansion_progress² * (1 - hide_progress)`(平方 → 展开"迟入场")
- `mini_alpha = clamp(1 - expansion_progress*1.5, 0, 1) * (1 - hide_progress)`
- 可视化高度比例 `visualizer_height_scale = 0.45 + 0.55 * expansion_progress`
- 歌词额外淡出 `LYRIC_EXPANSION_FADE_RATE = 2.5`(`src/core/render/mini.rs`)
- 展开页横向滑动:`page_shift = view_offset * current_w`;
音乐页 `translate(-page_shift)`,挂件页 `translate(current_w - page_shift)`
(`src/core/render/expanded.rs`);`view` 弹簧 `0.12/0.68`
### 4.5 卡片四周留白 / 裁切
见 1.8:窗口 = 岛 + 80(每侧 40 透明);内容 `clip_path(Intersect)`;
描边在裁切外画;挂件网格 inset 24。
---
## 5. 布局骨架
### 5.1 compact(mini)120×27
`src/core/render/mini.rs`:
```
MIN_MINI_CONTENT_WIDTH = 45.0 // 可用宽小于此不画内容
MINI_COVER_SIZE = 18.0 // 封面尺寸
MINI_COVER_LEFT_INSET = 10.0 // 封面左边距
MINI_COVER_RADIUS = 5.0 // 封面圆角
MINI_VISUALIZER_RIGHT_INSET = 17.0
MINI_VISUALIZER_WIDTH_SCALE = 0.55
MINI_VISUALIZER_SMOOTHING = (0.6, 0.08) // (上升, 下降)
LYRIC_LEFT_INSET = 30.0
LYRIC_RIGHT_INSET = 29.0
```
- **左**:18×18 封面,x = 10,y 垂直居中(`(base_h - 18)/2 = 4.5`),圆角 5,中心裁剪
- **中**:歌词,可用区 x ∈ [30, w-29],垂直居中;主行居中(滚动模式改为左对齐并从 x=30 起滚动),
次行始终水平居中;主行单行时 `y = center_y + size/3`,双行时
`stack_h = size + size*0.85 + size*0.18`,主行 `y = center_y - stack_h/2 + size*0.8`,
次行 `y = center_y + stack_h/2 - secondary*0.2`
- **右**:可视化条,中心 x = `w - 17`,中心 y = `base_h/2`
- 无媒体时改由 compact 挂件条接管:Left / Center / Right 三组;
有 mini 内容时只画 Left 与(Center+Right)两组并贴两端,并在内容两侧画竖分隔线
(`src/ui/widget/compact/mod.rs` `draw()`)
- 挂件宽:时间 **48**、资源 **132**;边缘 inset **9**;组内间距 **7**
### 5.2 expanded 音乐页 360×200
`src/ui/expanded/music_view.rs` 常量全表:
```
CONTENT_PADDING = 24.0 // 四周内边距
COVER_SIZE = 64.0 // 封面 64×64,圆角 16.0*scale,位置 (24,24)
TRACK_TEXT_GAP = 18.0 // 封面右到文字左
TRACK_TEXT_RIGHT_INSET = 70.0 // 文字右侧预留(给可视化条)
TRACK_TITLE_BASELINE_OFFSET= 26.0 // 曲名基线 y = 24 + 26 = 50
PROGRESS_TOP_GAP = 24.0 // 进度条中心 y = 24 + 64 + 24 = 112
PROGRESS_TIME_WIDTH = 28.0 // 两端时间文字预留宽
PROGRESS_TIME_GAP = 4.0
PROGRESS_BAR_HEIGHT = 6.5 // 常态条高
PROGRESS_BAR_HOVER_GROWTH = 3.5 // hover 增至 10.0
PLAYBACK_CONTROLS_TOP_GAP = 42.0 // 控制行中心 y = 112 + bar_h/2 + 42 ≈ 157.25
SKIP_BUTTON_GAP = 75.0 // 上一首/下一首中心距播放键 75
PAGE_ARROW_RIGHT_INSET = 7.5 // 右侧翻页条 x = w - 7.5,y = h/2
PAGE_ARROW_FADE_RATE = 5.0
COLLAPSED_VISUALIZER_INSET = 17.0 // 视觉条右侧 inset 随展开 17 → 37
EXPANDED_VISUALIZER_INSET = 37.0
VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4
```
- **左**:64 封面;**中**:曲名(15 bold)/歌手(15, α0.6) 两行,x = 24+64+18 = **106**;**右**:可视化条
- 进度条:左右各内缩 24,两端 28 宽时间 + 4 间距;条圆角 = bar_h/2;
填充宽度 `max(bar_total_w*progress, bar_h)`;填充只圆左端(RRect 左上/左下半径 = bar_h/2,右侧 0)
- 控制行中心 x = 180;播放键在中心、上一首 105、下一首 255
- 点击热区(`src/ui/expanded/music_view/controls.rs`):播放 **40×40**,上/下一首 **36×36**,
进度条热区高 **16.0\*scale**,封面整块可双击(激活媒体 App)
- 动画参数:`PROGRESS_SMOOTHING = 0.15`、`PROGRESS_HOVER_SMOOTHING = 0.18`、
`PROGRESS_HOVER_SNAP_THRESHOLD = 0.005`、`PROGRESS_START_THRESHOLD = 0.02`、`PROGRESS_JUMP_THRESHOLD = 0.3`、
`PLAY_STATE_RESPONSE = 0.18`、`SKIP_ANIMATION_DURATION_SECS = 0.5`、封面翻转 0.6s、
`PAUSE_CONTROL_STIFFNESS = 0.18`、`PAUSE_CONTROL_DAMPING = 0.64`、`PAUSE_CONTROL_MIN_SCALE = 0.8`、
`PAUSE_CONTROL_MAX_SCALE = 1.03`、`PAUSE_CONTROL_PRESS_VELOCITY = -0.18`
- 播放/暂停过渡:`cover_scale = 0.85 + 0.15*pause_t`、`cover_brightness = 0.75 + 0.25*pause_t`
(即暂停时封面缩小到 85% 并压暗到 75%),图标用 `smoothstep` 交叉淡出
### 5.3 expanded 挂件页(同 360×200)
- 6×3 网格,inset 24,gap 7(见 1.9)
- 卡片:背景 `rgba(28,28,30, 0.05*alpha)`,描边 `1px rgba(255,255,255, 0.16*alpha)`,圆角 `min(12*scale, min(w,h)/2)`
- 左侧翻页条 x = **7.5**,y = `h/2`(3×14 圆角矩形,α0.4);右侧同(x = w-7.5)
- 内置挂件各视图排布:
- **时钟**:整卡居中,字号 `min(h*0.6, w*0.31)`(≥13),bold,`HH:MM` 格式
- **日历**:月 `y+0.08h..0.26h`(bold,α0.78)/日 `y+0.27h..0.75h`(bold,字号 `min(0.53h,0.70w)`,≥26)/
星期 `y+0.78h..0.92h`(normal,α0.62)
- **资源占用(2×1)**:中间竖分隔(α0.09,`y+0.2h..0.8h`);两个圆环各占一半:
环心 `(center_x, top+0.43h)`,直径 `min(0.58h, 0.62w)`(≥20),描边 `min(3*scale, d*0.12)` 圆头,
底环 α0.13,进度弧从 **-90°** 起扫 `usage*360°`(α0.92),值文字 bold 居中,标签在 `top+0.76h`(α0.58)
- **设置(1×1)**:整卡居中齿轮图标,`icon_scale = min(w,h)*0.38/24`,α0.72
### 5.4 compact 各业务视图差异
| 视图 | 尺寸 | 排布 |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| 时间挂件 | 48×27 | 居中 `HH:MM`,字号 `clamp(0.43h, 10..12)` bold,基线 `center_y + size*0.36` |
| 资源挂件 | 132×27 | 两个 metric 各 `(w-8)/2` 宽;标签 7px bold 在左(accent α0.78),数值 10px bold 在右(白); |
| 文字基线 `center_y + scale`;下方 2px 进度轨(y = 基线+5),轨 α0.13,填充 accent α0.9;中间分隔 α0.1,半高 6 |
| 通知 | 330×82 | 图标 42×42 圆角 11 @ x+18 垂直居中;文本左 = 有图标 `x+72` / 无图标 `x+20`;右内缩 18; |
| app 名基线 `top+22`(11px α0.65);标题 13px bold,基线 `top+34`(无 app 名)/ `top+43`(有 app 名)/ 垂直居中 `top+(h+13)/2`(两者皆无);详情 11px α0.72 在标题下 21px |
| 音量 | 192×37 | 喇叭图标 20px,中心 `x = left+21`;标签 12px @ `left+37`(α0.9);轨道从 `label_x + label_w + 26` 到 `right-14`,高 4 圆角 2,轨 α0.28,填充白 α1.0 |
### 5.5 动画时长(通知 / 音量)
- 通知:`DISPLAY_DURATION = 5s`,`ENTER_DURATION = 220ms`(`ease_out_cubic = 1-(1-t)³`),
`FADE_DURATION = 280ms`;入场位移 `(1-enter)*7.0*scale`(从上滑下 7px)
- 音量:`DISPLAY_DURATION = 1600ms`,`FADE_DURATION = 240ms`,
`VOLUME_CHANGE_THRESHOLD = 0.002`(音量变化阈值,低于此不显示)
- 歌词切换进度用 `smoothstep`:`p*p*(3-2p)`(`src/core/render/mini.rs`)
- 长文本跑马灯(`src/utils/scroll.rs`):先停 **2.0s**,再以 **35.0\*scale px/s** 滚动,
循环间隔 `full_w + 50*scale`,裁剪区 `(x, y-size*1.2, max_w, size*1.5)`
---
## 6. 图标
**体系**:全部是 Skia 内的 **SVG path 数据 + 填充(`PaintStyle::Fill`)**,没有线性描边图标体系,
也没有 icon font。颜色一律单色:由 `text_color`(= 白)+ alpha 决定;只有资源环用 accent 色。
**未找到**线宽(stroke-width)概念——唯一的描边图标是设置窗口的翻页箭头(`1.8px`, round cap/join)。
| 图标 | 路径坐标系 | 缩放 | 实际视觉尺寸 | 来源 |
| ---------------- | ------------------------ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 播放 ▶ | 640 单位(128..544) | `0.083 * scale` | ≈ 34.5 × 37 px | `src/icons/controls.rs` `draw_play_button` |
| 暂停 ⏸ | 640 单位 | `0.083 * scale` | ≈ 34.5 × 37 px | `draw_pause_button` |
| 快退/快进三角 | 640 单位(中心 336,320) | `path_scale * global_scale`,静止 `0.050`(左)与 `0.055`(右);动画中 `0.050~0.055` | 约 21 × 22 px | `draw_control_triangle`,`src/ui/expanded/music_view.rs` `draw_skip_button`(中心 ±10.92*scale) |
| 音乐(封面占位) | 1024 单位 | `scale*1.8`,内部 `0.024` | 随卡片 | `src/icons/music.rs` |
| 设置齿轮 | 1024 单位 | `min(w,h)*0.38/24`,内部 `0.024` | 卡片 38% 短边 | `src/icons/settings.rs` |
| 音量(4 档) | 25.75 单位 | `size / 25.75`,偏移 `(-11.089286, -7.883929)` | 传 20.0*scale | `src/icons/volume.rs`;档位 `level<=0→静音, <=1/3→低, <=2/3→中, else→高` |
| 翻页指示条 | 不是 path | — | `3.0*scale × 14.0*scale` 圆角矩形,圆角 `2.0*scale`,α×0.4 | `src/icons/arrows.rs` |
其他图标使用点:
- 通知 App 图标:`AppDisplayInfo::GetLogo(Size{64,64})`,按 alpha>8 计算可见包围盒,等比缩放塞进 42×42,圆角 11 裁切
- 封面:64×64(expanded)与 18×18(mini),中心裁剪,`FilterMode::Linear + MipmapMode::Linear`
- 迷你封面圆角 5、expanded 封面圆角 16(连续超椭圆路径,不是普通 `clip_rrect`)
---
## 7. 明确「未找到」的项
1. **刘海/挖孔造型**:不存在(无 `SetWindowRgn`、无顶部直角特例)
2. **内阴影 / inset shadow**:代码中无任何实现
3. **字距(letter-spacing)与行高倍数**:无;行距全靠显式 y 偏移
4. **亚克力的模糊半径/透明度数值**:由 Windows `HostBackdropBrush` 系统决定,代码只给 `HOST_BACKDROP_INSET = 1.0`
→ Electron 侧**没有**等价物:`setBackgroundMaterial('acrylic')` 是整窗系统材质(跟随应用主题、覆盖窗口矩形),
不能像 `DWMWA_USE_HOSTBACKDROPBRUSH` 那样只画在圆角形状里;本项目因此改用无 backdrop 的降级色(见 §8)
5. **强调色用于岛内 UI**:岛内几乎没有强调色(只有资源占用的 CPU/RAM/警告/危险色);
统一强调色 `#0A84FF` 仅存在于**设置窗口**(`COLOR_ACCENT`)
6. **岛自身的亮/暗主题**:岛屿永远是深底白字;明暗主题只在设置窗口
7. **固定渐变色背景**:真实 `dynamic` 背景是封面图,没有固定渐变;
仅设置页预览用 `rgb(18,12,36) → rgb(8,24,48)`(可当静态替代)
8. **CSS 等价的超椭圆圆角公式**:真实实现是 Flutter 超椭圆查表(`src/utils/shape.rs`),没有简单的 border-radius 参数
---
## 8. 给 Web/Electron 复刻的映射速查
- 岛容器:定位在屏幕顶上 `top: 10px`、水平居中,尺寸与圆角用 spring 插值
(`cubic-bezier` 或 JS 弹簧,参考 `stiffness 0.10 / damping 0.68`);`overflow: hidden` 做裁切
- compact:`120×27`(有歌词时宽度跟着文本走,上限 700;只有媒体无歌词时 155),`border-radius: 13.5px`(胶囊)
- expanded:`360×200`,`border-radius: 48px`(想要 iOS 味请用 SVG 超椭圆路径)
- 背景:`#000` 不透明;"glass" **不要**照搬 WinIsland 的 host backdrop —— Electron 的
`setBackgroundMaterial()` 是"整窗系统材质",覆盖范围是窗口矩形而不是卡片形状,且明暗跟随应用主题,
在浅色主题下会给卡片垫出一圈浅色面板(详见 `packages/desktop-client/src/main/island.ts`);
我们改用 WinIsland 自己的**无 backdrop 降级色** `rgba(32,32,36,.804)` 画在卡片上;
"dynamic" 用封面模糊层 + `rgba(20,20,24,.471)` 叠加
- 描边:`1px solid rgba(255,255,255,.118)`(纯色样式,展开时淡出)/ `.157`(glass、dynamic)
- 阴影:仅展开 `0 2px σ rgba(0,0,0,.11)`(σ ≈ 3px)
- 文字:`-apple-system / "Microsoft YaHei" / "Segoe UI"`,白 + alpha 分层(.65 / .72 / .9 / .6 / .5 / .58 / .62)
- 图标:内联 SVG,`fill: currentColor`,播放/暂停按 640 坐标系、尺寸 ≈ 34.5×37
---
## 附:ClassHelper实际采纳了哪些(本次重写对照表)
| WinIsland 做法 | 出处 | 我们的落地 | 验证 |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| **窗口一次建大包围盒,形变发生在窗口内** | `window/app/layout.rs` `required_window_size()` + `PADDING=80` | 窗口固定为「最大形态 + `ISLAND_SHADOW_PAD(10)`」,开合期间**一帧都不 setBounds** | 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种) |
| 尺寸/圆角由弹簧驱动 | `utils/physics.rs` + `app.rs` `IslandSprings`(w/h/r = `0.10/0.68`) | `src/island/spring.ts` 同参数同更新式(dt 以帧计、`damping^dt`、换向保留 35% 动量 + 20% 距离上限),并加**零过冲钳制** | 岛中心/边缘波动 0.00px、无过冲 |
| 连续圆角(超椭圆) | `utils/shape.rs` `continuous_rounded_rect_path` / `expanded_island_radius = min(48*scale, w/2, h/2)` | `src/island/squircle.ts` 超椭圆角路径;展开半径 `min(48×圆角/20, w/2, h/2)` | 形状 bbox 与卡片框一致(±2px) |
| 内容交叉淡入 `expanded=progress²`、`mini=1-1.5×progress` | `core/render.rs`(`MINI_FADE_RATE=1.5`) | 两层常驻、透明度按同一公式 | 截图与 DOM 断言 |
| 描边 1px 白 alpha 30(纯色)/ 40(glass) | `core/render.rs` `BORDER_*_ALPHA` | `--wn-border` 取同值(`.118` / `.157`) | 计算样式断言 |
| 展开投影 `rgba(0,0,0,.11)`、y+2、σ=3 | `draw_expanded_shadow` | `drop-shadow(0 2px 3px rgba(0,0,0,.11))` | 截图 |
| 纯黑底 / 亚克力(HostBackdropBrush) | `background.rs` + `win32.rs` `DWMWA_USE_HOSTBACKDROPBRUSH` | 纯黑底 / **glass 用 CSS 半透明深色卡**(WinIsland 无 backdrop 降级色 `rgba(32,32,36,.804)`);**不调用** `setBackgroundMaterial()` | 计算样式 + 「卡片外圈透出桌面」整屏截图像素断言 |
| `set_cursor_hittest` 命中控制 | `frame.rs`(`set_cursor_hittest`) | 默认 `setIgnoreMouseEvents(true,{forward:true})`,渲染层命中测试后切换 | 岛内 true / 岛外 false |
| 空闲细缝 `hidden_width = 5` | `core/config.rs` | 6px 宽竖条(窗口固定,不再受 Windows 最小窗口高度限制) | 空闲 `6x22` → 来消息 `268x44` |
| 6 个停靠位 `DockPosition` | `core/config.rs` | 顶/底 × 左/中/右 6 锚点,岛(而非窗口)精确落在锚点 | 6 锚点坐标断言 |
| `font_size` = 基础字号 × 各文本系数 | `core/render/mini.rs`、`ui/expanded/music_view.rs` | 基础字号 11–20px × 系数表(标题 1.08/正文 0.95/次要 0.78/徽标与按钮 0.76/胶囊 0.92、0.74) | 13→19px 全部文本倍率 1.462 |
| 未采纳:音乐/歌词/小组件/音量浮窗 | `ui/expanded/**`、`ui/widget/**` | 业务不同(我们做通知/作业/叫人/紧急) | — |
外观范围改 shared,经典样式改 classic-island.css,自定义样式改 custom-runtime.css 和 custom-island.css,
原生窗口与命中改 main/island.ts。组件结构/设置改 shared/island-layout.ts 并同步编辑器和运行时。
展示行为见[灵动岛](../app/island.md),测试见[验收与测试](testing.md)。
+9 -27
View File
@@ -7,24 +7,6 @@ description: 装不上、连不上、改了不生效——最常被问到的那
## 安装与启动
### 客户端装不上 / 便捷版双击没反应 / 应用一闪就没了
**现象**:安装程序报「拒绝访问」,便捷版双击毫无反应,装上了也启动即退且不写日志
(退出码 `0x80000003`)。
**原因**:交付物被打了 **Low 完整性标签**。带 Low 标签的 exe 会被 Windows 降级运行:
写不进 `%LOCALAPPDATA%` / `%TEMP%`;即使装上,Chromium 的子进程(完整性更低)也读不到这些文件而秒崩。
**处置**:从 [Releases](https://github.com/LoveChengke/classhelper/releases) 下载的官方包不受影响
(打包脚本收尾会自动修正标签)。自己构建时如果碰到,用:
```cmd
icacls <产物目录> /setintegritylevel Medium /T /C
```
判据是 `icacls <exe> | findstr Mandatory` 里出现 `Low`。
另外**别把安装测试装进仓库目录里** —— 在那里又会继承 Low。
### 双击客户端"点了没反应"
**关窗只是隐藏到托盘,进程还在。** 再双击 EXE 会被单实例锁秒退(看起来像没反应),
@@ -46,7 +28,7 @@ icacls <产物目录> /setintegritylevel Medium /T /C
1. 服务器地址要填**局域网 IP**(`http://192.168.1.10:4000`),不要写 `localhost`(那是客户端自己);
2. 服务器防火墙放行 **4000**;
3. 服务器上如果开了系统代理,内网地址可能被代理接管 —— 浏览器与客户端**不会**自动绕过
(只有 ClassIsland 插件对本地 / 内网做了强制直连);
请在系统代理中为内网服务端设置绕过规则;
4. 在服务器本机 `curl.exe --noproxy '*' http://127.0.0.1:4000/healthz` 是不是 200?
### 页面能打开,但接口全 401
@@ -66,8 +48,8 @@ icacls <产物目录> /setintegritylevel Medium /T /C
### 上课时段发的通知学生看不到
**这是设计行为。** 上课时段普通通知只进队列、不打断课堂,**下课自动补弹**。
紧急通知与叫人则立刻弹。规则见[通知与提醒](app/notifications.md)。
**经典模式的设计行为如此。** 上课时段普通通知暂存,下课补弹;自定义常驻模式先提示铃铛。
经典模式紧急消息立即展开,普通叫人保留可点击胶囊;自定义常驻模式先提示铃铛,主动查看。规则见[通知与提醒](app/notifications.md)。
### 老师说发了通知,教室机器上什么都没弹
@@ -75,13 +57,13 @@ icacls <产物目录> /setintegritylevel Medium /T /C
1. 客户端顶栏的实时通道是不是"已连接"?
2. 现在是上课时段吗?(同上,是设计行为)
3. 教室客户端的「设置 → 提醒」是不是设成了"只在 ClassIsland 上弹"?那样客户端只进通知中心。
3. 确认客户端登录的是这条通知对应的班级。
4. 反向代理放行 `/socket.io/` 的 WebSocket 升级了吗?
### 屏幕上出现了第二个灵动岛,点它反应不对
那是**自动化验证实例**的岛 —— `pnpm verify:desktop` / `verify:packaged` 会真的在屏幕上放一个,
而且它归脚本控制,会自己展开 / 收起 / 消失。验证实例的岛是**洋红主题色**,一眼可辨。
先确认是否有人显式运行了 `verify:desktop:visual` 或 `verify:packaged --visual`;视觉实例由脚本控制,会自行开合。
默认 `verify:desktop` / `verify:packaged` 是后台快速检查,不创建可见岛或托盘。
### 灵动岛上的文字往右跳一下再缩回
@@ -105,7 +87,7 @@ icacls <产物目录> /setintegritylevel Medium /T /C
### 学生登录不了
**学生没有个人账号。** ClassHelper 班级端统一用**班级码 + 班级密码**登录(见[装 ClassHelper 班级端](get-started/client.md))。
用学生用户名去登录一定会失败,这是设计。
用学生学号去登录会失败,这是设计。
### 客户端里找不到"修改密码"
@@ -148,8 +130,8 @@ Linux 上跑 `sudo classhelper doctor` 有专门一条断言盯着这个软链
## 还没解决?
- 开发环境的坑(命令、代理与 TLS、完整性标签、ClassIsland 路径与日志编码……)
都记在仓库的 [`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 里;
- **开发与构建环境**的问题(命令、代理、TLS 与日志编码等)
记在仓库的 [`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 里;
- 功能行为的权威说明在[完整参考](reference.md);
- 都不是的话,去 [Issues](https://github.com/LoveChengke/classhelper/issues) 提一个,
附上版本号(Web 端顶栏 / 客户端「设置 → 关于」里都有)和日志。
-120
View File
@@ -1,120 +0,0 @@
---
title: 接入 ClassIsland
description: 把教室里的 ClassIsland 接上 ClassHelper:下课表上报、大屏提醒下发、课表镜像三个方向。
---
# 接入 ClassIsland
课堂里真正"知道现在第几节、在上什么课"的是 [ClassIsland](https://classisland.tech)。
它跟 ClassHelper管的事情不重叠,接起来之后两边各拿各的好处:
| 方向 | 做什么 | 老师在哪看 |
| ------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------- |
| ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止),写进本班课表 | Web 端「ClassIsland 联动」的实时状态卡片 |
| ClassHelper → ClassIsland | 老师发一条**提醒** → 落库 → 教室插件取回 → ClassIsland 上**全屏弹出**(可语音朗读) | Web 端「ClassIsland 联动 → 下发提醒」 |
| (可选)班级 → ClassIsland 镜像 | 把小助手这边排好的课表**镜像**回 ClassIsland,新建一份档案,不动老师原有的课表 | 同一页的「开镜像」开关 |
> 没接 ClassIsland 也能用 ClassHelper,只是教室里少一个大屏播报的出口。**这一步是可选的。**
## 前置:ClassIsland 装好了
本插件按 ClassIsland 插件 API 2.0 编写,ClassIsland 2.0.x 与 2.1.0.x 都能加载。
先确认教室机器上 ClassIsland 本身能正常显示课表。
## 第一步:在 Web 端签发设备令牌
**ClassIsland 联动**页(管理员 / 本班班主任)→「接入新设备」→ 选班级 → 生成令牌。
令牌形如 `chci_xxxxxxxx…`,**明文只显示这一次**:服务端只存它的 sha256 与一个前缀提示
(`chci_80fa1c…`),用来在设备列表里人眼区分。丢了就重置一个。
## 第二步:在教室机器上装插件并填令牌
### 装插件
从 [Releases](https://github.com/LoveChengke/classhelper/releases) 取
`ClassHelper.ClassIslandPlugin.cipx`:
- **推荐**:在 ClassIsland 里直接安装这个 `.cipx`(插件页有「从文件安装」)。
- 或者手工解压:把包里的 `ClassHelper.ClassIslandPlugin/` 目录整个丢进
`<ClassIsland 数据目录>\Plugins\`,然后重启 ClassIsland。
> **ClassIsland 运行时会锁住 DLL**。要覆盖升级插件,必须先完全退出 ClassIsland 再覆盖,
> 否则写入会被拒。插件配置在 `Config\Plugins\<插件 id>\Settings.json`,
> 里面存着服务器地址与设备令牌 —— **升级时别覆盖这个文件**。
> 另外改配置要在 ClassIsland 退出时改,运行中改会被内存里的副本回写覆盖。
### 填令牌
ClassIsland 的**设置 → ClassHelper 联动**里填:
| 设置项 | 说明 |
| ---------------------------- | --------------------------------------------------------------- |
| 后端地址 | `http://<服务器IP>:4000` |
| 设备令牌 | 上一步生成的 `chci_…` |
| 上报课表到小助手 | 打开后才会把 ClassIsland 的课表同步过来 |
| 接收小助手提醒 | 关掉就收不到老师发的提醒 |
| 提醒轮询间隔 | 默认 10 秒(可调到 5 秒)。上报本身就是心跳,轮询只是顺带取提醒 |
| 把班级课表镜像回 ClassIsland | 见下面「镜像」一节 |
填完点**「立即上报」**。
## 第三步:验证
回到 Web 端的「ClassIsland 联动」页,应该出现设备卡片和「在线」标记,实时状态卡上会显示这台机器
现在上什么课。到这一步链路就通了。
如果一直离线:先看插件的日志,再看服务端能不能从教室机器访问到(**别让系统代理拦住内网地址** ——
插件对本地 / 内网做了强制直连,但填错地址一样连不上)。
## 提醒弹在哪个端:由教室客户端自己选
教室机器的**客户端**「设置 → 提醒」里选,选择会同步到班级记录:
| 选择 | ClassHelper 客户端 | ClassIsland |
| ----------------------------------------- | ------------------------------ | ----------- |
| `ClassHelper 与 ClassIsland 都弹`(默认) | 弹窗 + 灵动岛 | 全屏提醒 |
| `只在 ClassHelper 客户端弹` | 弹窗 + 灵动岛 | 不推送 |
| `只在 ClassIsland 上弹` | 只进通知中心(不弹窗、不上岛) | 全屏提醒 |
规则只有一条,**任何入口都遵守**:通知发布页、叫人、联动页的「下发提醒」三个入口共用同一份推送实现,
不存在"某个页面发的通知不联动"。
## 两个方向各需要哪些开关
| 要同步的内容 | 需要打开 |
| -------------------------------- | ------------------------------------------------------------------------------- |
| 服务端 → ClassIsland(镜像课表) | ① 插件设置「把班级课表镜像回 ClassIsland」+ ② Web 端该设备的「镜像课表」 |
| ClassIsland → 服务端(上报课表) | 插件设置「上报课表到 ClassHelper」 |
| 提醒下发 | ① 教室客户端的「通知显示位置」不是"只在 ClassHelper" + ② 插件「接收小助手提醒」 |
## 上课时段:两边同一套规则
老师在**上课时段**发通知时,教室里的两个端遵守同一条规则,否则会出现"学生在客户端看不到、
却在 ClassIsland 上被打断"的怪事:
| 提醒类型 | 上课时段 | 下课 / 放学 |
| ------------------------------------------------------ | ---------------------------- | ----------- |
| 通知类(`kind=notification`:通知发布页 / 联动页下发) | **暂存不弹**(两边都先收着) | 自动补弹 |
| 主动通知:**紧急提醒**、**叫人**(`kind=call`) | **立刻弹** | 立刻弹 |
ClassIsland 侧的暂存由插件实现:它订阅课程服务,下课后把暂存的提醒补弹出来;未弹出前不回执,
所以服务端会一直保留着待提醒,教室机器断网也不丢。
## 上报课表是"双向对齐"的
插件每次全量上报时,服务端只清理**「来源是 ClassIsland 且这次没再上报」**的课表行。于是:
- 老师在 ClassIsland 里删掉 / 挪动一节 → 小助手这边跟着同步;
- 老师在小助手这边手排的课 → 一律不删。
## 镜像:把小助手的课表写回 ClassIsland
开启后,插件会新建一份名为 `ClassHelper-<班级名>` 的档案课表,**不动老师原有的课表**。
镜像时会连节次时间一起带过去,并且**每两节之间补一段课间、最后一节之后不补**(放学)。
这一步不做的话,ClassIsland 的课表会整体错位 —— 表现为"看着有内容、每节课都对不上"。
细节与实现见[ClassIsland 插件](../dev/classisland-plugin.md)。
下一步:[发布第一条内容](first-content.md)。
+12 -15
View File
@@ -12,10 +12,10 @@ description: 在教室机器上安装 ClassHelper桌面客户端,用班级码
从 [Releases](https://github.com/LoveChengke/classhelper/releases) 取:
| 产物 | 用途 |
| ------------------------------------ | ------------------------------------------ |
| `ClassHelper-<版本>-x64-setup.exe` | 安装包(约 98 MB),装到本机并建快捷方式 |
| `ClassHelper-<版本>-x64-portable.exe` | 单文件便携版,双击即用,适合不改动机房机器 |
| 产物 | 用途 |
| --------------------------------------------------------------------- | ------------------------------------------ |
| `ClassHelper-<版本>-x64-client-setup.exe`(本地构建名不带 client) | 安装包(约 98 MB),装到本机并建快捷方式 |
| `ClassHelper-<版本>-x64-client-portable.exe`(本地构建名不带 client) | 单文件便携版,双击即用,适合不改动机房机器 |
要自己打:
@@ -23,9 +23,7 @@ description: 在教室机器上安装 ClassHelper桌面客户端,用班级码
pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的解包目录)
```
> **装不上 / 双击没反应 / 一闪就没**(报 `0x80000003`)通常不是产物坏了,而是交付物被打了
> Low 完整性标签。打包脚本收尾已自动修正;从 Release 下载的包不受影响。
> 另外:关窗是隐藏到托盘,进程还在,再双击会因单实例锁秒退 —— 先从托盘退出。
> **双击没反应?** 关窗只是隐藏到托盘,进程还在,再双击会因单实例锁秒退 —— 先从托盘右键退出。
## 2. 登录
@@ -52,15 +50,16 @@ pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的
## 4. 认一认主界面
左侧是四个一级项(**课表 / 作业 / 通知 / 成绩**)加上一个可展开的**设置**分组,
左侧是业务项(**课表 / 作业 / 通知 / 课件 / 成绩**)加上一个可展开的**设置**分组,
顶部汉堡按钮把整栏收成 64px 的图标导轨(折叠状态会记住)。「通知」项上有未读红点。
| 页面 | 一句话 |
| ---- | -------------------------------------------------------------- |
| 课表 | 周视图 + 「今天」时间轴(大时钟 / 正在上什么 / 下一节) |
| 作业 | 看板模式(按科目卡片、可全屏)/ 列表模式;教室机器还能录入作业 |
| 课件 | 查看本班可见文件、下载和打开本地文件夹 |
| 通知 | 未读红点、优先级标签、点开自动标为已读 |
| 成绩 | 班级账号看到的是全班总览(表格 + 柱状图 + 等级分布) |
| 成绩 | 个人查询开启时看本班成绩,关闭后只看服务端集体分析 |
| 设置 | 通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,六个子页 |
细节见[应用帮助](../app/index.md)。
@@ -73,17 +72,15 @@ pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的
2. **调灵动岛** —— 设置 → 灵动岛:高度、宽度、字号、主题色、停靠位置、视觉风格。
讲台机器上通常把字号调大一点、位置放到顶部居中。
触摸屏(希沃白板等)走的是另一套命中逻辑,客户端会自动识别,不用手动开。
3. **选提醒弹在哪端** —— 设置 → 提醒:`ClassHelper 与 ClassIsland 都弹` / 只在客户端 / 只在 ClassIsland。
这个选择会同步到班级记录,服务端据此决定要不要推 ClassIsland。
3. **查看提醒规则** —— 设置 → 提醒维护本机课程等提醒设置;经典通知模式上课暂存普通消息,
自定义常驻模式提示铃铛并主动查看。规则保存在本机,服务端仍即时广播。
## 6. 确认实时链路是通的
在 Web 管理端给这个班发一条普通通知,教室机器上应该几秒内出现灵动岛胶囊。如果没有:
在隔离测试服务中给临时验证班级发一条普通通知,经典岛课间应出现胶囊,自定义常驻岛应出现铃铛数量提示。如果没有:
- 客户端顶栏的实时通道状态是不是"已连接"?
- 现在是上课时段吗?上课时段的普通通知**只进队列、下课才弹**,这是设计行为,不是故障。
- 现在是上课时段吗?经典模式普通通知上课暂存;自定义常驻模式看铃铛并主动展开。
- 服务器与教室机器之间能互相 ping 通吗?防火墙放行 4000 了吗?
排查清单见[常见问题](../faq.md)。
下一步(可选):[接入 ClassIsland](classisland.md)。
+22 -64
View File
@@ -1,78 +1,36 @@
---
title: 发布第一条内容
description: 发通知、布置作业、叫人、录成绩——把 ClassHelper真正用起来的第一轮操作。
description: 通知、作业、叫人、成绩与课件的首次操作。
---
# 发布第一条内容
前面几步做完,系统就完整了。这一页走一遍四件最常用的事,每件都点出**它会不会弹、弹在哪个端**。
先完成班级、学生与任课关系配置。页面是否可见及能否发布,由实际功能权限、班级范围和科目关系共同决定。
## 发一条通知
## 通知与叫人
**通知发布** → 选班级 → 填标题和内容 → 选优先级 → 发布。
在“通知发布”选班、填写标题/内容、选择优先级后发布;叫人使用该页叫人入口,选择学生和事由。
通知有低/普通/重要/紧急四档,叫人分普通 HIGH 与紧急 URGENT。
经典岛普通通知上课暂存,普通叫人保留可点击胶囊,紧急消息立即展开;自定义常驻岛先提示铃铛,主动查看。
上课发布紧急通知需服务端二次确认,Web 确认框倒计时结束后才能再次提交。
完整行为见[通知](../app/notifications.md)和[叫人](../app/calls.md)。
| 优先级 | ClassHelper 班级端表现 |
| ------ | -------------------------------------------------------------------------- |
| 普通 | 课间弹胶囊,点击展开;**上课时段只进队列**,下课自动补弹 |
| 重要 | 同上,卡片上带优先级标签 |
| 紧急 | **无论是否上课都立即展开**(卡片内部红色呼吸光晕 + 紧急角标),45 秒后收起 |
## 作业
**上课时段发紧急通知会被拦一次。** 服务端返回 `409 URGENT_DURING_CLASS`,前端弹一个全屏二次确认,
上面写着"现在为上课时间段……"、正在上的那节课,还有待发布的标题;确认按钮带 **3 秒倒计时**,
倒计时结束前点不动。确认之后才真的发出去。
在“作业发布”选班、科目、所属日期,填写标题与要求;附件为受校验的 http(s) 或站内链接。
作业按所属日期展示,可补录历史日期;科目写权限以本人任课关系为准。
教室客户端可录作业和维护未交名单;本机录入的内容不会再提醒自己。
可选全屏看板与字号/日期等偏好,详见[作业](../app/homeworks.md)。
这个拦截是**服务端做的**,绕过界面直接调接口一样会被拦 —— 它是产品行为,不是前端提醒。
## 成绩
## 布置一份作业
在“成绩录入”选择班级、考试,单条/批量录入或导入表格。
具备功能权限后,仍只能写自己任教科目;表格导入还要求管理员/本班班主任,按行返回错误和警告,文件上限 8 MB。
班级端个人查询开关开启时可看本班明细与排名,关闭后只看服务端集体分析。
见[成绩](../app/grades.md)和[导入](../app/imports.md)。
**作业发布** → 选班级 / 科目 → 填标题与要求 → 选**所属日期** → 发布。
## 课件
> 作业按**所属日期**归类,不是按录入时间。晚上 8 点留的作业算今天。
> 老师可以给今天、明天甚至上周补录一份作业,客户端按日期查得到。
附件支持站内相对路径或 `http(s)://` 链接(其他 scheme 服务端会拒,避免被当成脚本执行)。
ClassHelper 班级端那边默认是**看板模式**:按科目分卡片、每条作业完整显示要求、整个看板缩放到刚好铺满屏幕;
老师也可以在教室机器上直接**录入作业**、勾选**未交名单**(教室机器用的是班级账号)。
## 叫一位同学
**通知发布 → 叫人**(或管理员在**学生管理 → 叫人**)→ 选学生 → 点一个快捷短语或自己写一句 → 发送。
| 级别 | ClassHelper 班级端 |
| -------- | ------------------------------------------------------------------ |
| 普通叫人 | 课间显示「叫人」胶囊,点击展开;**上课时段只进队列**,下课自动弹出 |
| 紧急叫人 | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 |
文案是「请 XXX 同学找 XXX 老师」,卡片上是「收到」按钮(复用通知的已读机制)。
> 学生管理页是管理员专属,但班主任与科任老师同样需要叫人,所以给老师留的入口在「通知发布 → 叫人」。
## 录一次成绩
**成绩录入** → 选班级与考试名称 → 单条录入 / 批量录入 / 导入表格。
- **科目**下拉同样是全校统一目录,选一个该班还没有的科目会自动建课。
- 导入表格支持 `.xlsx / .xls / .csv / .tsv`,列名按同义词自动映射(「用户名→学号」「得分→分数」),
也可以手动改。**文件上限 8MB**,超了会返回明确的 413 而不是一句英文的内部错误。
- 导入结果会给逐行的错误与警告,`errors[].row` 是 **Excel 视角的行号**(含表头),改完可以再导。
ClassHelper 班级端看到的是**全班总览**:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新。
## 在教室大屏上播一条提醒(需要 ClassIsland)
**ClassIsland 联动 → 下发提醒** → 选班级、填标题内容、设置显示时长与语音朗读 → 下发。
它会**落库**再推给教室。教室机器断网也不丢:插件重连后会拉取还没确认的那条,
立刻弹出来,弹完回执,不会重复弹。
如果教室客户端把「通知显示位置」设成了"只在 ClassHelper 客户端显示",这一步会回一句
「该班教室已设置为只在 ClassHelper 客户端显示」—— 那是设计行为,不是报错。
---
到这里你已经把整套系统跑通了。接下来:
- 每一项功能的完整行为 → [应用帮助](../app/index.md)
- 服务器怎么维护 → [管理运维](../management/index.md)
- 遇到问题 → [常见问题](../faq.md)
在“教学事务 → 课件”上传文件,选择本人授课班级并设置“仅保存”或“保存后推送”。
文件先保存到服务端,班级端按可见范围下载;推送离线补领、逐设备回执,客户端不自动运行文件。
单文件上限 512 MB,详见[课件上传与传输](../app/courseware.md)。
+9 -11
View File
@@ -1,18 +1,17 @@
---
title: 快速上手
description: 从零把 ClassHelper跑起来:装服务端、认管理端、建班级排课、装 ClassHelper 班级端、接 ClassIsland、发第一条内容。
description: 从零把 ClassHelper跑起来:装服务端、认管理端、建班级排课、装 ClassHelper 班级端、发第一条内容。
---
# 快速上手
ClassHelper不是一个单机软件,它由四部分组成,**必须先有服务端,其他三端才有内容可看**:
ClassHelper不是一个单机软件,它由三部分组成,**必须先有服务端,管理端与客户端才有内容可看**:
| 组成部分 | 装在哪 | 谁用 | 必须先装? |
| ------------------------ | --------------------------------------- | -------------------------- | ---------------- |
| **服务端** | 一台能一直开着的机器(服务器/办公电脑) | —(它同时托管 Web 管理端) | ✅ 是 |
| **Web 管理端** | 不用装 —— 浏览器打开服务端地址即可 | 老师、班主任、管理员 | 跟着服务端一起有 |
| **桌面客户端** | 教室的电脑 / 教室大屏 | 学生看课表作业、老师录作业 | 服务端就绪之后 |
| **ClassIsland 联动插件** | 教室那台装了 ClassIsland 的机器 | 教室大屏播报 | 可选 |
| 组成部分 | 装在哪 | 谁用 | 必须先装? |
| -------------- | --------------------------------------- | -------------------------- | ---------------- |
| **服务端** | 一台能一直开着的机器(服务器/办公电脑) | —(它同时托管 Web 管理端) | ✅ 是 |
| **Web 管理端** | 不用装 —— 浏览器打开服务端地址即可 | 老师、班主任、管理员 | 跟着服务端一起有 |
| **桌面客户端** | 教室的电脑 / 教室大屏 | 学生看课表作业、老师录作业 | 服务端就绪之后 |
按顺序走下面这几页就行,全程大约半小时(不含下载时间):
@@ -21,8 +20,7 @@ ClassHelper不是一个单机软件,它由四部分组成,**必须先有服
3. [认识 Web 管理端](web-admin.md) —— 登录进去,认一认界面
4. [建班级、排课表](setup-class.md) —— 这是后面所有功能的前提
5. [装 ClassHelper 班级端](client.md) —— 教室机器用班级码登录
6. [接入 ClassIsland](classisland.md) —— 让教室大屏也会弹提醒(可选)
7. [发布第一条内容](first-content.md) —— 通知 / 作业 / 叫人 / 成绩
6. [发布第一条内容](first-content.md) —— 通知 / 作业 / 叫人 / 成绩
## 只想先看看它长什么样?
@@ -30,7 +28,7 @@ ClassHelper不是一个单机软件,它由四部分组成,**必须先有服
- **看界面**:本页文档里各处嵌的都是实机截图,比如[灵动岛](../app/island.md)、[课表](../app/schedules.md)。
- **本地跑一套演示数据**:装好 Node 20.19+ 与 pnpm 之后,
`pnpm install && pnpm build:shared && pnpm db:seed && pnpm dev` 会在本机拉起一套带演示班级的后端与
`pnpm install && pnpm build:shared && pnpm db:seed && pnpm dev` 会在本地拉起一套带演示班级的后端与
Web 管理端(详见[安装服务端](server.md#从源码跑开发模式))。
## 装完之后呢
+13 -14
View File
@@ -7,17 +7,17 @@ description: ClassHelper 服务端、Web 管理端、桌面客户端的运行环
## 服务端
服务端的安装包**自带 Node 运行时**,目标机器不需要另外装 Node.js,也不需要数据库软件
(默认用内嵌的 SQLite)。
Windows 服务端安装包自带 Node;Linux 包不内置 Node,一键安装器检测或安装兼容运行时。
默认使用内嵌 SQLite,无需另装数据库服务;MySQL 形态需可用数据库。
| 项目 | 要求 |
| -------- | --------------------------------------------------------------------------- |
| 操作系统 | Windows 10 / 11 / Server 2016+(x64);Linux(glibc 2.28+,见下);Docker |
| 内存 | 512 MB 起步,2 GB 以上更稳(Node + SQLite 常驻约 120–200 MB) |
| 磁盘 | 程序约 340 MB(免安装目录)/ 安装后约 400 MB;数据按班级量增长,通常几十 MB |
| CPU | 任意 x64;不需要独显 |
| 项目 | 要求 |
| -------- | -------------------------------------------------------------------------- |
| 操作系统 | Windows 10 / 11 / Server 2016+(x64);Linux(glibc 2.28+,见下);Docker |
| 内存 | 512 MB 起步,2 GB 以上更稳(Node + SQLite 常驻约 120–200 MB) |
| 磁盘 | 预留程序、数据库、备份和课件文件空间;课件最多 512 MB/文件,按实际使用增长 |
| CPU | 任意 x64;不需要独显 |
**Linux 的 glibc 要求**:内置的 Node 官方 linux-x64 构建要求 glibc ≥ 2.28。
**Linux 的运行时要求**:安装器下载的官方 Node linux-x64 运行时通常要求 glibc ≥ 2.28。
CentOS 7 是 2.17,一键安装器会检测并自动换成 `-glibc-217` 变体(尽力而为,未做长期验证)。
判据:
@@ -25,8 +25,8 @@ CentOS 7 是 2.17,一键安装器会检测并自动换成 `-glibc-217` 变体
ldd --version | head -1
```
**外网**:服务端只在「检查更新」时访问 GitHub,且失败是设计内的结果(返回"查不到",不报错)。
内网机器可以完全不连通外网。
**外网**:更新检查/下载安装包、假日建议和配置的在线 AI 服务等功能可能访问外网。
基础教学业务可在内网运行;使用离线包、手工周次和 mock/关闭在线 AI,相关联网失败按接口返回原因处理。
## Web 管理端
@@ -64,9 +64,8 @@ ldd --version | head -1
| 教室机器 / 老师电脑也要用 | 客户端与管理端填服务器的**局域网 IP**(如 `http://192.168.1.10:4000`),防火墙放行 4000 |
| 公网访问 | 前面加 Nginx / Caddy 反代并启用 HTTPS,见[生产部署指南](../management/production.md) |
> **别用代理**。本机自测时用 `curl.exe --noproxy '*'`,PowerShell 用 `-NoProxy`;
> 教室机器上如果开了系统代理,填服务端地址后可能拿到代理返回的 404。插件里对本地 / 内网地址做了强制直连,
> 但浏览器与客户端本身不会绕过。
> **别用代理**。自测时用 `curl.exe --noproxy '*'`,PowerShell 用 `-NoProxy`;
> 教室机器上如果开了系统代理,填服务端地址后可能拿到代理返回的 404。请检查系统代理绕过列表是否包含服务端的局域网地址。
## 卸载 / 重置
+23 -57
View File
@@ -1,71 +1,37 @@
---
title: 建班级、排课表
description: 建第一个班级、配班级账号、加学生与老师、排课表——后面所有功能的前提。
description: 班级、名单、任课关系与日课表的首次配置。
---
# 建班级、排课表
后面所有功能(发通知、布置作业、录成绩、接 ClassIsland)都挂在**班级**上,所以先把班级建出来。
## 创建班级
## 1. 建班级(仅管理员)
管理员在“班级管理”填写入学年份、班号、年级等资料;班级名称按年份和班号生成,如“2026级1班”。
设置班主任、学期周数及班级凭据;班主任可暂空,但发布依赖教师归属的内容前需先完成配置。
班级周数默认 20,受全校可配置上限限制,全校默认上限 30、范围 1–60。
保存班级码与密码,教室客户端用它登录;已读和作业状态操作代表全班。
Web 管理端 → **班级管理** → 「新建班级」:
## 录入学生与教师
| 字段 | 说明 |
| -------- | ----------------------------------------------------------------------- |
| 班级名称 | 例如「高二(3)班」 |
| 年级 | 用于分组与统计 |
| 班主任 | 从教师账号里选,默认是自己;之后随时可在班级详情顶部「更换班主任」改 |
| 学期周数 | 默认 20,**上限 30**。课表的周次选择与周次范围都以它为准 |
| 班级码 | 保存时自动生成(字母数字 4–16 位,存大写),可以改成好记的,例如 `G203` |
| 班级密码 | 新建班级时的初始密码就是它 —— 学生在教室机器上要用这个登录 |
学生是名单,填写学号和姓名,不创建账号、密码或自动登录名。
学号 1–32 位字母/数字/._-,不能复用;支持管理员表格导入,班主任有名单管理权限时可维护本班。
教师使用工号账号,管理员在教师管理录入或导入。
在班级详情按科目配置任课老师:任课关系只存 Course,决定该科作业/成绩写权限。
班主任不任教某科就不能改该科内容,详见[班级与人员](../app/classes.md)。
> **班级码 + 班级密码 = ClassHelper 班级端的登录凭据。** ClassHelper 班级端没有个人账号,一台教室机器用班级账号登录后,
> 那台机器上的「已读 / 完成」是**代全班**操作的。
>
> 学期周数只有管理员能改:它决定"现在是第几教学周",乱改会让课表的周次过滤和上课时段判定错位。
## 配置作息与日课表
## 2. 加学生(仅管理员)
先在“时间表与课表编辑”维护时间表的上课/课间等时间点,再创建班级日课表并为时间点选择课程。
按星期、周次范围和 1–9 周轮换设置启用规则,也可复制、临时换课或预定某天。
时间表写入需 timetable:manage;日课表写入需 schedule:write 并通过本班班主任/管理员检查。
可以从 ClassIsland 完整 JSON 离线导入作息、日课表、群组与预定,见[导入](../app/imports.md)。
**学生管理** → 「新建」或「导入名单」:
## 校对周次与提醒
- **单个新建**:填姓名即可,登录名会自动生成。
- **导入名单**:下载模板(列是 `学号 / 姓名 / 班级 / 性别 / 家长手机号`,学生**不是账号**,没有密码列),填好上传。
用户名要匹配 `^[A-Za-z0-9_.-]{3,32}$`;重复用户名在 `append` 模式下跳过、`upsert` 模式下更新姓名。
用户名已经属于某个教师 / 管理员账号时,这一行会进错误列表,不会覆盖别人的账号。
管理员在“学期周次”设置开学日、全校上限和逐周日期,班级可以有独立区间。
在周视图和客户端今天时间轴核对当前课程、下一节、上下课时刻。
经典岛普通通知上课暂存、普通叫人保留可点胶囊、紧急消息立即展开;自定义常驻岛先提示铃铛,主动点击查看。
验证应使用独立测试班级,详见[验收](../dev/testing.md),不要给业务班级发送测试通知。
学生的用途是:**成绩、未交名单、叫人、已读记录都按名单记录**。所以名单不全,这几项就不准。
## 3. 加科任老师
教师的**录入**只有管理员能做(**教师管理**页,支持单个录入与导入名单)。班级层面的**分配**在班级详情里:
在「班级详情 → 任课老师」里给某位老师配一门课,他就能看到这个班,并**只能改自己任教科目**的作业与成绩。
角色与可见范围见[班级、学生与教师](../app/classes.md)与[角色与权限](../dev/permissions.md)。
## 4. 排课表
**课表管理** → 选中班级 → 「新增课表」:
- **科目**下拉直接列出全校统一的 18 科目录(语文 / 数学 / 英语 / 政治 / 历史 / 物理 / 化学 / 地理 /
生物 / 信息技术 / 体育 / 美术 / 音乐 / 班会 / 早读 / 晚读 / 通用技术 / 听力)。
选中一个该班还没有的科目时,前端会**自动为该班建同名课程**,不用先去把科目的建一遍。
- 填星期、开始 / 结束时间、周次范围(第几周到第几周)、单双周(每周 / 单周 / 双周)。
- 一节课**必须同时有开始时间和结束时间** —— 「上课时段」的判定就是拿这两个时间比对的。
排完在**课表管理**里能看到周视图(7 列),客户端那边则多一个「今天」时间轴视图。
### 从 ClassIsland 导入更省事
如果教室里已经在用 ClassIsland,直接导入它的档案 JSON 就行:课表、节次时间、单双周都会一起进来,
不用排两遍。见[表格与课表导入](../app/imports.md)。
## 5. 确认「上课时段」判定是对的
ClassHelper有一整套「上课时段策略」:普通通知在上课时段只进队列、下课自动补弹,紧急通知与叫人则立刻弹。
这套判定完全基于课表,所以**课表排错,提醒就会在该弹的时候不弹**。
排完课之后建议立刻验一下:在**通知发布**页选这个班,随便发一条普通通知,
看客户端是不是在课间弹出来、上课时是不是安静地收着。规则详见[通知与提醒](../app/notifications.md)。
下一步:[装 ClassHelper 班级端](client.md)。
下一步:[装班级端](client.md)。
-2
View File
@@ -10,7 +10,5 @@
href: setup-class.md
- name: 装 ClassHelper 班级端
href: client.md
- name: 接入 ClassIsland
href: classisland.md
- name: 发布第一条内容
href: first-content.md
+20 -44
View File
@@ -1,58 +1,34 @@
---
title: 认识 Web 管理端
description: 登录 ClassHelper Web 管理端,认一认 12 个页面、新手引导与手机端布局。
description: 登录、菜单分组、权限与移动布局。
---
# 认识 Web 管理端
Web 管理端不用装:服务端起来之后,浏览器打开 `http://<服务器地址>:4000/` 就是它。
服务端启动后,用浏览器打开 http://<服务器地址>:4000/,教师或管理员以工号和密码登录。
学生无个人账号,教室客户端使用[班级登录](client.md)。新安装按部署配置创建初始管理员,首次使用后修改初始密码。
## 登录
## 菜单与权限
用**教师 / 管理员账号**登录(用户名 + 密码)。学生没有个人账号,学生那一端走
[班级码 + 班级密码](client.md),见[班级、学生与教师](../app/classes.md)。
侧栏按以下分组组织,具体页面由路由权限筛选,并支持浅色/深色和关怀模式。
- 新装的服务端只有安装时创建的那一个管理员账号。**第一次登录后请立刻在顶栏头像菜单 → 修改密码里改掉它。**
- 登录页**不含任何演示内容**(没有"点击填充示例账号"之类的按钮),也不会提示种子口令。
- 连不上服务器时页面会直接失败,不会静默重试 —— 先确认服务端在跑、端口对、没走代理。
| 分组 | 主要页面 |
| ---------- | -------------------------------------------------- |
| 工作台 | 仪表盘、AI 助手 |
| 教学事务 | 课表、时间表与日课表编辑、作业、通知、课件、成绩 |
| 班级与人员 | 班级、学生、教师、只读学生名单、学期周次、毕业归档 |
| 设备运维 | 客户端在线状态、命令、更新与安装策略 |
| 系统管理 | 权限组、系统设置、大模型设置、数据库管理 |
## 界面
班级/学生/教师管理、学期周次、归档和数据库页面还有管理员角色门槛;
教师只读名单、教学页与设备页按权限键和数据范围开放。
每次操作服务端继续校验,不是显示菜单就能改所有班和科目。详见[角色与权限](../dev/permissions.md)。
顶栏提供本人密码、使用引导、检查更新与退出等入口。
布局沿用 1Panel 那一套:左边固定深色侧边栏,右边浅灰底 + 白色圆角卡片。
## 引导与手机
| 页面 | 干什么 | 谁能进 |
| ---------------- | ---------------------------------------------- | ---------------------- |
| 仪表盘 | 全校 / 本班的汇总数字与最近动态 | 所有登录用户 |
| 课表管理 | 排课、导入 ClassIsland 课表、节次时间 | 管理员、本班班主任 |
| 作业发布 | 发作业、按天查看、看未交名单 | 教师、管理员、班级账号 |
| 通知发布 | 发通知、发叫人 | 教师、管理员 |
| 成绩录入 | 单条 / 批量录成绩、导入表格、看统计 | 管理员、本班班主任 |
| ClassIsland 联动 | 接入教室设备、下发提醒、看实时状态、开镜像 | 管理员、教师 |
| 班级管理 | 建班级、换班主任、改班级账号 | **仅管理员** |
| 学生管理 | 学生名单增删、导入 | **仅管理员** |
| 教师管理 | 教师账号增删改、导入名单、重置密码 | **仅管理员** |
| 数据库管理 | 备份 / 恢复、导出导入、一键切换 SQLite ⇄ MySQL | **仅管理员** |
| 检查更新 | 顶栏头像菜单里,查 GitHub 上有没有新版本 | 所有登录用户 |
> **前端隐藏 ≠ 权限**。上面这张表只是"入口藏不藏",每一条写入请求服务端都会再判一次
> (见[角色与权限](../dev/permissions.md))。直接输网址绕过菜单,一样会被挡回仪表盘。
## 第一次登录的引导
首次登录进入主布局后会自动弹一次聚焦式引导,依次指出:侧边菜单 → 实时通道状态 → 账号菜单 → 完成。
走完或跳过(右上角 × 或 Esc)都记作"已看过",之后不再自动弹。
想重看:**右上角头像菜单 →「使用引导」**。
## 手机 / 平板
| 屏幕宽度 | 布局 |
| --------- | ------------------------------------------------------ |
| > 992px | 常驻 210px 深色侧边栏 + 顶栏显示连接状态与实时事件计数 |
| 769–992px | 保留侧边栏,内容区内边距压缩 |
| ≤ 768px | 侧边栏收进抽屉,顶栏汉堡按钮唤出;表格在卡片内横向滚动 |
手机上(与电脑同一局域网)用 `http://<主机IP>:4000` 打开,可以「添加到主屏幕」当 App 用(PWA)。
做成 HTTPS 之后 PWA 才能完全离线化,见[生产部署指南](../management/production.md)。
首次登录展示使用引导,完成/跳过后记住;可从顶栏“使用引导”重看。
小屏侧栏收进抽屉,表格在卡片内横向滚动;支持安装 PWA,需要 HTTPS 或 localhost 安全上下文。
同局域网手机填写服务器局域网地址,不用 localhost;公网反代见[生产部署](../management/production.md)。
下一步:[建班级、排课表](setup-class.md)。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

+8 -10
View File
@@ -5,22 +5,21 @@ description: ClassHelper(Class Helper)的官方文档:安装部署、功
# ClassHelper文档
ClassHelper(Class Helper)是给中小学班级用的一套信息管理系统。**一份代码,四个交付物**:
装在服务器上的后端、给老师用的 Web 管理端、装在教室机器的桌面客户端(ClassHelper 班级端 / 教室大屏),
以及装在 [ClassIsland](https://classisland.tech) 上的联动插件。
ClassHelper(Class Helper)是给中小学班级用的一套信息管理系统。**一份代码,三个交付物**:
装在服务器上的后端、给老师用的 Web 管理端、装在教室机器的桌面客户端(ClassHelper 班级端 / 教室大屏)。
<p class="ch-tagline">
老师发一条通知,教室那台机器上的灵动岛和 ClassIsland 会同时弹出来;学生打开客户端就能看到当天的课表和作业。
老师发一条通知,教室那台机器上的客户端和灵动岛会实时显示;学生打开客户端就能看到当天的课表和作业。
</p>
<div class="ch-cards">
<a class="ch-card" href="get-started/index.md">
<span class="ch-card-title">第一次装</span>
<span class="ch-card-desc">从零把服务端、管理端、ClassHelper 班级端和 ClassIsland 插件跑起来,大约半小时。</span>
<span class="ch-card-desc">从零把服务端、管理端、ClassHelper 班级端跑起来,大约半小时。</span>
</a>
<a class="ch-card" href="app/index.md">
<span class="ch-card-title">已经在用了</span>
<span class="ch-card-desc">课表、作业、通知、叫人、成绩、灵动岛,每一项具体怎么用。</span>
<span class="ch-card-desc">课表、作业、通知、叫人、课件、成绩、灵动岛的使用说明。</span>
</a>
<a class="ch-card" href="management/index.md">
<span class="ch-card-title">负责运维</span>
@@ -54,11 +53,10 @@ ClassHelper(Class Helper)是给中小学班级用的一套信息管理系统
## 这套系统的三件事
**一、老师发,教室弹。** Web 端发布的内容通过 Socket.IO 按班级房间广播,客户端与灵动岛实时更新;
落到 ClassIsland 上的那部分走设备令牌的独立通道,走 HTTP 上报,不依赖教室机器的网络环境有多好。
**一、老师发,教室弹。** Web 端发布的内容通过 Socket.IO 按班级房间广播,客户端与灵动岛实时更新。
**二、上课时段不打扰。** 「上课时段」由课表实时判定,普通通知只进队列、下课自动补弹,
紧急通知和叫人则立刻弹出来 —— 服务端强制拦截,绕开界面直接调接口一样会被拦。
**二、课堂提醒有明确规则。** 经典岛普通通知暂存到下课,普通叫人保留可点胶囊,紧急消息立即展开;
自定义常驻岛先显示铃铛,由用户主动查看。课堂发布紧急通知还需服务端确认。
**三、离线可用。** 客户端先写 IndexedDB,断网时回退缓存并在顶部提示;
恢复连接后自动重新拉取,教室网络抖一下不会让大屏变白。
+6 -6
View File
@@ -5,14 +5,14 @@ description: 备份与恢复、导出导入、定时备份,以及 Web 管理
# 数据库管理
Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可见**,后端同样 `requireRole('ADMIN')`),
Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可见**,接口要求 `database:manage`),
把过去要手工做的数据库运维都收进了界面。
## 它能做什么
| 能力 | 说明 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、23 张表的行数、连接串(MySQL 密码脱敏) |
| 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、快照清单中各表的行数、连接串(MySQL 密码脱敏) |
| 连接测试 | 对任意目标库试连(切换前的第一步) |
| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → 生成客户端 / 建表 → 子进程迁移全部数据 → 改写 `.env`」,异步任务带步骤进度,**失败自动回滚** |
| 备份 / 恢复 | gzip JSON 快照存在 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 |
@@ -23,12 +23,12 @@ Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可
**备份、导入导出、跨库迁移共用同一种「JSON 快照」**:
- 23 张表按**拓扑序**导出;
- 恢复时临时**关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User`
是**互相引用**的,任何排序都会被其中一侧卡死;
- 按 `lib/snapshot.ts` 的 `SNAPSHOT_TABLES` 依赖顺序导出;
- 恢复时临时**关闭外键检查** —— 用于处理跨表外键关联,表清单与恢复顺序由 `lib/snapshot.ts` 统一维护;
- 分批 `createMany` 写入。
因此**任意一份备份都能恢复回任意一种受支持的数据库**(SQLite 备份可以恢复进 MySQL,反之亦然)。
相容版本的快照可以在受支持的 SQLite/MySQL 之间恢复;格式与缺失表由快照校验器检查。
课件元数据包含在快照中,文件实体不包含,完整备份需另带 `data/courseware` 或 COURSEWARE_DIR 指定目录。
MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-mariadb`,不需要额外安装。
+70 -70
View File
@@ -23,8 +23,8 @@ curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/Love
```
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`,
> 不是 `$1` —— 脚本收不到它(实测踩到:本意是体检,结果走进了交互式安装)。
> 先落盘再执行没这个坑,也不会因为下载中断而执行到半个脚本。
> 不是 `$1` —— 脚本收不到它(想体检却会走进交互式安装)。
> 先落盘再执行没这个问题,也不会因为下载中断而执行到半个脚本。
以下是"已有安装包 / 内网离线"的用法:
@@ -37,11 +37,11 @@ sudo classhelper status
## 1. 三种形态怎么选
| 形态 | 适合 | 命令 | 说明 |
| --- | --- | --- | --- |
| **直接安装(native)** | 有一台 Linux 服务器/Mini 主机,单机跑 | `install.sh` 选 1 | systemd 管理,自带 Node 运行时,SQLite 或 MySQL |
| **直接安装 + MySQL** | 多终端共享数据、已有 MySQL | `install.sh` 选 2 | 连接串写在配置里;库要先建好 |
| **Docker** | 机器上已有 Docker、想与其他服务隔离 | `install.sh` 选 3 | 用安装包里随带的 Dockerfile 构建镜像 + compose 起容器 |
| 形态 | 适合 | 命令 | 说明 |
| ---------------------- | ------------------------------------- | ----------------- | ----------------------------------------------------- |
| **直接安装(native)** | 有一台 Linux 服务器/Mini 主机,单机跑 | `install.sh` 选 1 | systemd 管理,自带 Node 运行时,SQLite 或 MySQL |
| **直接安装 + MySQL** | 多终端共享数据、已有 MySQL | `install.sh` 选 2 | 连接串写在配置里;库要先建好 |
| **Docker** | 机器上已有 Docker、想与其他服务隔离 | `install.sh` 选 3 | 用安装包里随带的 Dockerfile 构建镜像 + compose 起容器 |
> 只想先看看这台机器能不能装:`bash install.sh --check`(**不需要 root**,不改动任何东西)。
> Windows 机器用服务端安装包(`pnpm dist:server` 的产物),不需要本文。
@@ -60,12 +60,12 @@ classhelper-server-linux-x64-<版本>.tar.gz.sha256 ← 旁边那份校验值
它发布在 [GitHub Releases](https://github.com/LoveChengke/ClassHelper/releases) 上。
**为什么 Linux 包要单独出一个**:`@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` 这些依赖是平台相关的,
Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不起来,因此 Linux 包必须在 Linux 上构建。
Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不起来,因此正式 Linux 包在 Linux 上构建,交叉构建限制见下文。
仓库里的 [`.github/workflows/release-linux-server.yml`](https://github.com/LoveChengke/classhelper/blob/master/.github/workflows/release-linux-server.yml)
就是干这个的(拿 ubuntu runner 跑 `pnpm dist:server:linux`,再把产物挂到对应 Release 上):
1. 本地照惯例发 Windows 包的 Release(tag 形如 `v1.1.2`);
2. GitHub → Actions → 「发布 Linux 服务端安装包」→ Run workflow → 填 tag → 运行;
2. 推送 v* tag 会自动触发;也可在 Actions 的「发布 Linux 服务端安装包」中手动指定 tag 运行;
3. 跑完 Release 里会多出 Linux 包与更新后的 `SHA256SUMS-<版本>.txt`。
内网离线分发也可以:在能上网的机器上 `pnpm dist:server:linux`,把 tar.gz 拷进去,
@@ -75,13 +75,13 @@ Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不
`pnpm dist:server:linux` 在非 Linux 上也**能跑**:脚本会用 npm 的 `--os=linux --cpu=x64`
按目标平台解析可选依赖,所以 `@libsql/linux-x64-gnu` 这些"按平台分发"的包会被正确装进来。
适合本地快速打一个包去验证安装与运维流程。已核对过的结论:
适合本地快速打一个包去验证安装与运维流程。逐项核对过的结论:
| 项 | 结论 |
| --- | --- |
| SQLite 驱动 | ✅ 包内是 `@libsql/linux-x64-gnu` + `linux-x64-musl`,**没有任何 win32 残留** |
| 服务端本体 | ✅ Node + libsql + express + socket.io 都是纯 JS 或已就位的 Linux 原生包 |
| 运维命令 | ✅ `status` / `doctor` / `password` / `backup` / `upgrade` / `rollback` 都不依赖平台差异 |
| 项 | 结论 |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SQLite 驱动 | ✅ 包内是 `@libsql/linux-x64-gnu` + `linux-x64-musl`,**没有任何 win32 残留** |
| 服务端本体 | ✅ Node + libsql + express + socket.io 都是纯 JS 或已就位的 Linux 原生包 |
| 运维命令 | ✅ `status` / `doctor` / `password` / `backup` / `upgrade` / `rollback` 都不依赖平台差异 |
| Web 端「一键切换数据库」 | ⚠ **可能失败**:这条链路要调 prisma CLI 的 schema engine,CLI 在运行期按平台扫 `@prisma/engines/schema-engine-debian-openssl-3.0.x` 这类路径,而交叉包里只有宿主平台的 `schema-engine-windows.exe` |
所以交叉包**能装、能跑、能验收**,只是那一个 Web 端功能存疑 —— 它会在包内写入 `.cross-built` 标记,
@@ -126,18 +126,18 @@ sudo bash install.sh --yes --database mysql --mysql-host 10.0.0.5 --mysql-db cla
完整参数见 `bash install.sh --help`。要点:
| 参数 | 作用 |
| --- | --- |
| `--package <url|file://路径>` | 安装包地址(默认从 GitHub Release 取最新版) |
| `--sha256 <哈希>` | 强制校验安装包哈希 |
| `--admin-password-stdin` | 从 stdin 读一行作为初始管理员密码 |
| `--node-source bundled|system|tarball` | 内置 Node / 系统 Node / 指定压缩包 |
| `--node-mirror <url>` | Node 下载镜像(默认 `https://npmmirror.com/mirrors/node`) |
| `--node-tarball <url|路径>` | 内网离线时直接用本地 Node 包 |
| `--with-nginx` / `--open-firewall` | 顺带生成反代配置 / 放行端口 |
| `--yes` | 全默认、不再交互 |
| `--check` | 只体检(无需 root) |
| `--force` | 覆盖已有配置;在无 systemd 的机器上只铺文件不装服务 |
| 参数 | 作用 |
| ---------------------------------------- | ---------------------------------------------------------- |
| `--package <url|file://路径>` | 安装包地址(默认从 GitHub Release 取最新版) |
| `--sha256 <哈希>` | 强制校验安装包哈希 |
| `--admin-password-stdin` | 从 stdin 读一行作为初始管理员密码 |
| `--node-source bundled|system|tarball` | 内置 Node / 系统 Node / 指定压缩包 |
| `--node-mirror <url>` | Node 下载镜像(默认 `https://npmmirror.com/mirrors/node`) |
| `--node-tarball <url|路径>` | 内网离线时直接用本地 Node 包 |
| `--with-nginx` / `--open-firewall` | 顺带生成反代配置 / 放行端口 |
| `--yes` | 全默认、不再交互 |
| `--check` | 只体检(无需 root) |
| `--force` | 覆盖已有配置;在无 systemd 的机器上只铺文件不装服务 |
**幂等性**:重复执行就是覆盖安装。已有的 `config.env`(含 `JWT_SECRET`)与 `data/` 一律保留,
用户不会被踢下线;端口与数据库形态也沿用旧配置,不会被改回默认值。
@@ -169,13 +169,13 @@ classhelper backup # 容器内做一致性快照再取出来
Docker 形态与直接安装的差异:
| | 直接安装 | Docker |
| --- | --- | --- |
| 数据位置 | `<安装目录>/data` | Docker 卷 `classhelper-data` |
| 配置 | `/etc/classhelper/config.env`(systemd 注入) | 同一份文件(compose `env_file`) |
| 升级 / 回滚 | 替换程序文件后重启服务 | 同一套流程:替换构建上下文 → 重建镜像(`classhelper-server:local`)→ `compose up -d --force-recreate` |
| 恢复数据 | `classhelper backup restore <目录>` | 需手工 `docker cp` 回容器(命令里有提示) |
| `backup schedule` | 支持(systemd timer) | 用宿主机 cron 调 `classhelper backup --quiet` |
| | 直接安装 | Docker |
| ----------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| 数据位置 | `<安装目录>/data` | Docker 卷 `classhelper-data` |
| 配置 | `/etc/classhelper/config.env`(systemd 注入) | 同一份文件(compose `env_file`) |
| 升级 / 回滚 | 替换程序文件后重启服务 | 同一套流程:替换构建上下文 → 重建镜像(`classhelper-server:local`)→ `compose up -d --force-recreate` |
| 恢复数据 | `classhelper backup restore <目录>` | 需手工 `docker cp` 回容器(命令里有提示) |
| `backup schedule` | 支持(systemd timer) | 用宿主机 cron 调 `classhelper backup --quiet` |
> Docker 形态的镜像用**稳定标签** `classhelper-server:local`(另打一个 `classhelper-server:<版本>` 便于追溯)。
> 版本号写在 compose 里的话,升级替换完程序文件后那个标签就过期了,`compose up` 会一直沿用旧镜像 ——
@@ -224,19 +224,19 @@ Web 端「数据库管理 → 一键切换数据库」在运行中改写的是 *
配置就是一份 `KEY=VALUE` 的 env 文件,systemd / dotenv / `source` 三种读法都兼容
(含空白或 `#` 的值请加双引号)。最常见的几项:
| KEY | 默认 | 说明 |
| --- | --- | --- |
| `PORT` / `HOST` | `4000` / `0.0.0.0` | 监听端口与地址 |
| `DATABASE_PROVIDER` | `sqlite` | `sqlite` 或 `mysql` |
| `DATABASE_URL` | `file:../data/classhelper.db` | SQLite 相对 `<安装目录>/server` 解析;MySQL 是连接串 |
| `JWT_SECRET` | 随机 | 登录令牌签名密钥。**换掉=所有用户重新登录**(`classhelper key rotate`) |
| `JWT_EXPIRES_IN` | `7d` | 令牌有效期 |
| `CORS_ORIGIN` | `*` | 走域名/反代时建议收敛成 `https://你的域名` |
| `TRUST_PROXY` | `1` | 前面有 Nginx 时保持 1,限流才按真实 IP 统计 |
| `AUTO_MIGRATE` | `true` | 空库首启动自动建表 + 建管理员 |
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | **只在空库首次初始化时生效**,之后改密码用 `classhelper password` |
| `DEFAULT_CLASS_PASSWORD` / `DEFAULT_TEACHER_PASSWORD` | `123456` | 新建班级 / 重置教师时的初始密码 |
| `TERM_START_DATE` | 空 | 第 1 教学周的周一(`YYYY-MM-DD`),影响"当前周次" |
| KEY | 默认 | 说明 |
| ----------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------ |
| `PORT` / `HOST` | `4000` / `0.0.0.0` | 监听端口与地址 |
| `DATABASE_PROVIDER` | `sqlite` | `sqlite` 或 `mysql` |
| `DATABASE_URL` | `file:../data/classhelper.db` | SQLite 相对 `<安装目录>/server` 解析;MySQL 是连接串 |
| `JWT_SECRET` | 随机 | 登录令牌签名密钥。**换掉=所有用户重新登录**(`classhelper key rotate`) |
| `JWT_EXPIRES_IN` | `7d` | 令牌有效期 |
| `CORS_ORIGIN` | `*` | 走域名/反代时建议收敛成 `https://你的域名` |
| `TRUST_PROXY` | `1` | 前面有 Nginx 时保持 1,限流才按真实 IP 统计 |
| `AUTO_MIGRATE` | `true` | 空库首启动自动建表 + 建管理员 |
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | **只在空库首次初始化时生效**,之后改密码用 `classhelper password` |
| `DEFAULT_CLASS_PASSWORD` / `DEFAULT_TEACHER_PASSWORD` | `123456` | 新建班级 / 重置教师时的初始密码 |
| `TERM_START_DATE` | 空 | 第 1 教学周的周一(`YYYY-MM-DD`),影响"当前周次" |
改配置的三种方式(都会先自动备份、改完问你是否重启):
@@ -422,18 +422,18 @@ sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
## 8. 故障排查
| 现象 | 先查什么 |
| --- | --- |
| 服务起不来 | `classhelper doctor` → `classhelper logs -n 100`。常见:端口被占用、MySQL 连接串错、`data/` 权限 |
| 装完打不开页面 | `classhelper status` 看探针与监听地址;云服务器还要在**安全组**放行端口 |
| 页面能打开但是**空白** | 1.1.0 的服务端在 HTTP 直连访问时会下发 CSP `upgrade-insecure-requests`,浏览器把所有子资源改写成 https 加载而服务器没有 TLS,于是整页空白(HTML/接口都正常)。已修复的版本按请求协议条件下发;若暂不能升级,唯一正解是上 HTTPS 反代 |
| 改了配置没生效 | 是否重启过(`classhelper config set` 会问);`EnvironmentFile` 的值优先于文件里的同项 |
| Web 端切库后还是连旧库 | `classhelper doctor` 看 `.env` 软链是否被破坏(`ln -sfn /etc/classhelper/config.env <安装目录>/.env`) |
| 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB |
| `classhelper` 命令找不到 | `ls -l /usr/local/bin/classhelper`;被删了就重装或 `ln -sfn <安装目录>/bin/classhelper /usr/local/bin/classhelper` |
| 忘记管理员密码 | `sudo classhelper password admin`(直连数据库,不需要登录) |
| ClassHelper 班级端连不上 | 客户端「服务器地址」填 `http://IP:端口` 或域名;确认没有多余路径与斜杠 |
| CentOS 7 装完立刻退出 | glibc < 2.28,见下面的"已知限制" |
| 现象 | 先查什么 |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 服务起不来 | `classhelper doctor` → `classhelper logs -n 100`。常见:端口被占用、MySQL 连接串错、`data/` 权限 |
| 装完打不开页面 | `classhelper status` 看探针与监听地址;云服务器还要在**安全组**放行端口 |
| 页面能打开但是**空白** | 1.1.0 的服务端在 HTTP 直连访问时会下发 CSP `upgrade-insecure-requests`,浏览器把所有子资源改写成 https 加载而服务器没有 TLS,于是整页空白(HTML/接口都正常)。已修复的版本按请求协议条件下发;若暂不能升级,唯一正解是上 HTTPS 反代 |
| 改了配置没生效 | 是否重启过(`classhelper config set` 会问);`EnvironmentFile` 的值优先于文件里的同项 |
| Web 端切库后还是连旧库 | `classhelper doctor` 看 `.env` 软链是否被破坏(`ln -sfn /etc/classhelper/config.env <安装目录>/.env`) |
| 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB |
| `classhelper` 命令找不到 | `ls -l /usr/local/bin/classhelper`;被删了就重装或 `ln -sfn <安装目录>/bin/classhelper /usr/local/bin/classhelper` |
| 忘记管理员密码 | `sudo classhelper password admin`(直连数据库,不需要登录) |
| ClassHelper 班级端连不上 | 客户端「服务器地址」填 `http://IP:端口` 或域名;确认没有多余路径与斜杠 |
| CentOS 7 装完立刻退出 | glibc < 2.28,见下面的"已知限制" |
---
@@ -502,16 +502,16 @@ sudo bash /tmp/verify-linux.sh --admin-password-stdin <<< 'Verify-Pass-2026!'
覆盖项:
| 分组 | 检查内容 |
| --- | --- |
| 安装结果 | 程序文件、`VERSION`、`.installed.json`、`config.env` 600、配置目录 700、`.env` 软链、内置 Node、`/usr/local/bin/classhelper`、安装/审计日志、profile.d 生效 |
| 服务 | systemd 单元、active、enabled、`/healthz` 200、`status --json` 合法且 healthy |
| 体检 | `classhelper doctor` 无待处理项、`help` / `completion` 可用 |
| API | 用初始密码登录拿 token、带 token 读 `/api/classes` 与 `/api/auth/me` |
| 改密 | 改密成功 → 新密码可登录 → **旧密码被拒** → 原哈希已备份且 600 → 审计有记录且**不含密码明文** → 弱密码被拒 → stdin 结束不卡死 → `restore-hash` 把原密码回滚回来 |
| 备份 | 备份目录/文件/权限、SQLite 快照是合法库文件、`backup list` |
| 配置 | `config get/show`、敏感值打码、读写往返 |
| key rotate | 密钥确实变了且 ≥32 位、服务仍可用、**旧 token 变 401**、重新登录成功 |
| 分组 | 检查内容 |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 安装结果 | 程序文件、`VERSION`、`.installed.json`、`config.env` 600、配置目录 700、`.env` 软链、内置 Node、`/usr/local/bin/classhelper`、安装/审计日志、profile.d 生效 |
| 服务 | systemd 单元、active、enabled、`/healthz` 200、`status --json` 合法且 healthy |
| 体检 | `classhelper doctor` 无待处理项、`help` / `completion` 可用 |
| API | 用初始密码登录拿 token、带 token 读 `/api/classes` 与 `/api/auth/me` |
| 改密 | 改密成功 → 新密码可登录 → **旧密码被拒** → 原哈希已备份且 600 → 审计有记录且**不含密码明文** → 弱密码被拒 → stdin 结束不卡死 → `restore-hash` 把原密码回滚回来 |
| 备份 | 备份目录/文件/权限、SQLite 快照是合法库文件、`backup list` |
| 配置 | `config get/show`、敏感值打码、读写往返 |
| key rotate | 密钥确实变了且 ≥32 位、服务仍可用、**旧 token 变 401**、重新登录成功 |
追加升级/回滚链路(会真的替换一次程序文件):
@@ -527,7 +527,7 @@ sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x6
```bash
# ① 浏览器打开 http://<IP>:4000/ ,用 admin 登录,改一次密码再登录
# ② ClassHelper 班级端客户端填 http://<IP>:4000 用班级码 G101/123456 登录,收到一条老师发的通知
# ② 在隔离测试服务创建临时教师和班级,用其班级凭据登录客户端,验证提醒后清理临时数据
# ③ 重跑安装器(幂等性):sudo bash /tmp/install.sh --yes --package <同一个包>
# 期望:配置与数据保留、JWT_SECRET 不变(已登录用户不被踢下线)
# ④ 卸载:sudo classhelper uninstall --yes
+31 -118
View File
@@ -1,134 +1,47 @@
---
title: MySQL 切换指南
description: 通过快照和受支持的换库流程切换 SQLite/MySQL,保留迁移历史。
---
# MySQL 切换指南
本项目默认使用 **SQLite(libSQL 内嵌模式)** 跑本地开发,数据模型与访问层已按
MySQL 兼容方式设计,可平滑切换到云端 MySQL / MariaDB 统一存储。
默认数据库为 SQLite,服务端也支持 MySQL/MariaDB。唯一 schema 为 packages/server/prisma/schema.prisma;
Prisma 通过 libSQL 或 MariaDB driver adapter 访问,两种适配器已随运行时依赖内置。
角色、优先级等以 String 加 API 校验存储,不用 Prisma enum 或 @db.*。
## 为什么默认是 SQLite
## 已有数据的切换
- 本地零依赖即可跑通全部功能(无需安装数据库服务)
- `schema.prisma` 刻意只使用跨库通用类型
(`String` / `Int` / `Float` / `Boolean` / `DateTime`,无 `enum`、无 `@db.*` 原生类型)
管理员在“数据库管理 → 一键切换数据库”填写**空目标库**连接串,并完成界面确认。
还须 database:manage;页面和具体接口各自检查权限。
## 设计上的兼容约定
1. 备份并导出当前数据库为 JSON 快照。
2. 测试目标连接,确认目标为空。
3. 改写 schema provider,子进程生成对应 Prisma Client。
4. 打包形态编译生成的 TypeScript 客户端。
5. 子进程基于当前 schema 建表,并用 apply-snapshot 写入快照。
6. 成功后写入 .env 的 DATABASE_PROVIDER/DATABASE_URL;重启服务端连接新库。
| 约定 | 说明 |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| 不使用 Prisma `enum` | 角色(`ADMIN/TEACHER/STUDENT`)与优先级(`LOW/NORMAL/HIGH/URGENT`)以 `String` 存储,由 zod 在 API 层校验,两种数据库行为完全一致 |
| 主键为 `String @default(cuid())` | 不依赖自增,便于多端离线生成与合并 |
| 时间统一 `DateTime` | 由应用层与驱动适配器负责时区,不使用数据库原生时间函数 |
| 唯一约束仅使用 `@@unique` / `@unique` | SQLite 与 MySQL 语义一致 |
| 级联删除使用 Prisma 的 `onDelete` | 由 Prisma 生成对应外键动作 |
失败时回滚 schema,环境配置仅在成功后写入。快照覆盖的表以 lib/snapshot.ts 为准。
目标已有表时拒绝;回到 SQLite 也应指定新空文件,不能删除正在使用的数据库。
旧源库请保留到验证完成。课件实体不在 JSON 内,换主机时需另复制文件目录,见[课件](../app/courseware.md)。
## 切换到 MySQL
## 源码开发与配置
### 方式一:Web 管理端「数据库管理 → 一键切换」(推荐,v1.0.0 新增)
管理员在 Web 管理端打开 **数据库管理** 页(`/database`,仅 ADMIN 可见),在「一键切换数据库」卡片里:
1. 选目标类型(SQLite ⇄ MySQL)并填**空库**连接串(有表的库会被拒绝,防误覆盖);
2. 输入「确认迁移」→ 点「开始迁移」。
界面会自动依次执行(带步骤进度条):
1. **备份当前数据库**(gzip JSON 快照存 `<数据目录>/backups/`,失败可回滚);
2. 导出当前数据为快照;
3. 测试目标库连接;
4. 检查目标库为空;
5. 改写 `schema.prisma` 的 provider;
6. 子进程 `prisma generate` 生成新方言的 Prisma 客户端;
7. (打包形态)把生成的 `.ts` 客户端编译进 `dist/generated`;
8. 子进程 `prisma db push` 在目标库建表;
9. 子进程 `dist/tools/apply-snapshot.js` 把快照写入目标库;
10. 全部成功后改写 `.env` 的 `DATABASE_PROVIDER` / `DATABASE_URL`。
**完成后必须重启服务端**(安装版运行 `restart.cmd`,开发模式重跑 `pnpm dev:server`)才会连接新库。
中途任一步失败会**回滚 `schema.prisma`**,`.env` 不被改写 —— 现有数据库与配置保持可用。
> 同一页还提供:连接状态检测(版本 / 体积 / 各表行数)、连接测试、手动与**定时备份**、
> 备份恢复、快照导出导入、SQLite 数据库文件下载。备份与导入导出共用同一种 JSON 快照格式,
> 因此**任意备份都能恢复回任意一种受支持的数据库**。
>
> 只支持 SQLite 与 MySQL 两种主库:Redis 等键值库不是 Prisma 支持的主数据库(无法建表/迁移),
> 接口层直接拒绝。
### 方式二:命令行手工切换
#### 1. 修改 provider
```bash
# 自动把 prisma/schema.prisma 的 provider 改成 mysql
node scripts/use-database.mjs mysql
```
#### 2. MySQL 驱动适配器(自 v1.0.0 起已随包内置)
Prisma 7 要求所有数据库都通过 driver adapter 接入,MySQL 对应 `@prisma/adapter-mariadb`
(该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。**它已在 `packages/server/package.json` 的
依赖里**(数据库管理模块的连接测试/迁移需要),因此无需再手动安装:
```bash
# 仅当从旧版本升级、依赖里还没有时才需要
pnpm --filter @classhelper/server add @prisma/adapter-mariadb
```
`packages/server/src/lib/db.ts` 内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器,
服务端代码无需改动。
#### 3. 配置环境变量
编辑 `packages/server/.env`:
scripts/use-database.mjs 只切换 schema provider,不迁移数据、不改 .env、不替代完整换库流程。
如果只准备一个全新的开发环境,可用 pnpm db:switch:mysql,再配置独立空库并生成客户端;
建模和导入仍按受支持流程完成。配置示例:
```dotenv
DATABASE_PROVIDER=mysql
DATABASE_URL="mysql://classhelper:your-password@127.0.0.1:3306/classhelper"
```
> 云端部署时把 `127.0.0.1` 换成云数据库内网/公网地址;
> 生产环境请同时修改 `JWT_SECRET` 与 `CORS_ORIGIN`。
SQLite 迁移目录属于仓库升级历史,不要为了切库删除它,也不要对已有数据运行 db:seed/db:reset。
SQLite SQL 不直接应用到 MySQL;换库流程基于当前 schema 建表,保留原迁移用于 SQLite 升级。
Prisma Client 与实际 provider 必须一致;切换后未重启时旧进程仍连接旧库。
#### 4. 重建迁移并生成客户端
## 部署核验
SQLite 与 MySQL 的 DDL 方言不同,**必须重新生成迁移文件**:
```bash
# 删除 SQLite 迁移历史(迁移文件是方言相关的)
rm -rf packages/server/prisma/migrations
# 重新生成并应用(需要目标库可连接,且账号有建表权限)
pnpm db:migrate
# 初始化演示数据
pnpm db:seed
```
也可以先只生成 SQL 供 DBA 审核:
```bash
pnpm --filter @classhelper/server exec prisma migrate dev --create-only --name init_mysql
```
> 只搬数据、不要演示数据时,用界面的「下载快照 / 导入快照」更省事:
> 旧库导出 JSON → 切库建表 → 新库导入,一份快照也可用 CLI 迁移:
> `node server/dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json`
#### 5. 回到 SQLite
```bash
node scripts/use-database.mjs sqlite
# 同步 .env:DATABASE_PROVIDER=sqlite、DATABASE_URL="file:./prisma/dev.db"
rm -rf packages/server/prisma/migrations # 若之前已切换成 MySQL 迁移
pnpm db:migrate
```
> 用界面「一键切换」回 SQLite 时,目标库同样必须是空文件:删除旧 `.db` 或指定一个新路径。
## 生产部署注意事项
1. **连接池**:Prisma 7 的 driver adapter 使用底层驱动(`mariadb`)的连接池设置,
与 Prisma 6 默认值不同(例如默认无连接超时)。建议通过适配器参数显式设置连接上限与时区。
2. **时区**:`Grade.publishedAt`、`Homework.dueAt` 等字段建议在数据库会话中统一为 UTC,
三端展示时再按本地时区格式化(前端使用 `@classhelper/shared` 的 `formatDate`)。
3. **迁移部署**:CI/CD 中使用 `prisma migrate deploy`(对应 `pnpm db:deploy`)而不是 `migrate dev`。
4. **备份**:切换云数据库后,`packages/server/prisma/dev.db` 不再被使用,可从 `.gitignore` 之外移除。
界面「定时备份」把快照写进 `<数据目录>/backups/`(MySQL 模式下即安装根 `data/backups/`),
**服务端进程不运行时不会补跑**;关键环境建议同时用数据库自带的备份策略。
5. **安装包体积**:切换链路需要随包内置 `prisma` CLI 与 `typescript` 编译器(Prisma 7 生成器产出
`.ts`,切换后需在目标机编译进 `dist/generated`),服务端安装包因此约 34MB → 71MB。
确认新库连接、表行数、班级登录、课表和成绩读取;保留切换前快照。
生产凭据放 .env 或部署密钥中,不提交 Git。完整备份和各形态命令见[数据库管理](database.md)。
Linux 正式包应在 Linux 构建,否则 Prisma engine 可能是宿主平台版本,见[Linux 部署](linux-deploy.md)。
+45 -43
View File
@@ -2,12 +2,12 @@
本文档覆盖三种上线形态,按适用场景选择:
| 形态 | 适用场景 | 产物 | 本机验证情况 |
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- |
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 |
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | 在仓库里构建后直接跑(见第四节) | ✅ 已实测(163 项端到端) |
| 形态 | 适用场景 | 产物 | 验证状态 |
| --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已验证(静默安装 / 启停 / 卸载 / UI) |
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,尚未实机验证 |
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 实机验收清单见 [linux-deploy.md](linux-deploy.md) §10 |
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | 在仓库里构建后直接跑(见第四节) | ✅ 已验证(端到端验收通过) |
---
@@ -59,7 +59,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
├── server\dist\ 后端程序(入口 index.js)
├── server\prisma\ 迁移 SQL(首启动自动执行)
├── web\ Web 管理端(由后端托管)
├── data\ classhelper.db + server.pid(备份只需复制此目录)
├── data\ 数据库、课件与其他持久化文件(完整备份另含 .env)
├── logs\server.log UTF-8 日志(含请求耗时)
├── .env 全部配置
├── start.cmd / stop.cmd / restart.cmd / status.cmd
@@ -101,8 +101,8 @@ uninstall-service.cmd :: 删除计划任务
## 二、Docker + MySQL(形态 B,云部署推荐)
> 本机没有 Docker,因此这套文件未在本机实测;结构经过与已验证的产物对照(依赖清单、
> 目录布局、迁移方式与服务端完全一致)。
> 这套文件与已验证的产物结构一致(依赖清单、目录布局、迁移方式与服务端完全相同),
> 但**尚未在真实 Docker 环境跑过**。第一次上线请先在测试机走一遍。
```bash
cp deploy/.env.example deploy/.env
@@ -206,19 +206,19 @@ printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \
日常运维全部走 `classhelper`(`classhelper help` 看全部):
| 事项 | 命令 |
| ------ | --------------------------------------------------------------------------------------- |
| 状态 | `classhelper status [--json]` |
| 体检 | `classhelper doctor`(权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份) |
| 改密码 | `classhelper password [用户名]`(交互输入、不回显、不进 shell history) |
| 备份 | `classhelper backup`;每日自动:`classhelper backup schedule daily` |
| 事项 | 命令 |
| ------ | ---------------------------------------------------------------------------------------- |
| 状态 | `classhelper status [--json]` |
| 体检 | `classhelper doctor`(权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份) |
| 改密码 | `classhelper password [用户名]`(交互输入、不回显、不进 shell history) |
| 备份 | `classhelper backup`;每日自动:`classhelper backup schedule daily` |
| 升级 | `classhelper upgrade [--check]`(下载 + 校验 sha256 + 备份 + 替换 + 探针,失败自动回滚) |
| 回滚 | `classhelper rollback [--list]` |
| 日志 | `classhelper logs -f` |
| 改配置 | `classhelper config set KEY VALUE` / `config show` / `port 8080` |
| 换密钥 | `classhelper key rotate`(所有用户需重新登录) |
| 卸载 | `classhelper uninstall`(默认保留配置、数据与备份) |
| 服务 | `classhelper start|stop|restart|reload` |
| 回滚 | `classhelper rollback [--list]` |
| 日志 | `classhelper logs -f` |
| 改配置 | `classhelper config set KEY VALUE` / `config show` / `port 8080` |
| 换密钥 | `classhelper key rotate`(所有用户需重新登录) |
| 卸载 | `classhelper uninstall`(默认保留配置、数据与备份) |
| 服务 | `classhelper start|stop|restart|reload` |
### 3.3 手动部署(不想用安装器 / 没有 systemd)
@@ -273,7 +273,6 @@ sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x6
升级与回滚整条链路,末尾打印 `=== 结果:N/N 项通过 ===`。逐条清单见
[linux-deploy.md](linux-deploy.md) 第 10 节。
---
## 四、手动部署(形态 D)
@@ -308,26 +307,27 @@ pnpm --filter @classhelper/server start # 或 node packages/server/dist/ind
## 五、环境变量清单
| 变量 | 默认 | 说明 |
| --------------------------------------------------- | ---------------------- | --------------------------------------------------------- |
| `NODE_ENV` | `development` | `production` 会启用生产校验、HSTS、更严的限流 |
| `HOST` / `PORT` | `0.0.0.0` / `4000` | 监听地址与端口 |
| `DATABASE_PROVIDER` | `sqlite` | `sqlite` 或 `mysql` |
| `DATABASE_URL` | `file:./prisma/dev.db` | SQLite 相对路径以 `server` 根为基准;MySQL 用连接串 |
| `AUTO_MIGRATE` | `false` | SQLite 空库时自动执行随包迁移并创建管理员 |
| `JWT_SECRET` | — | **必填**,≥32 位;生产环境仍为示例值会拒绝启动 |
| `JWT_EXPIRES_IN` | `7d` | 令牌有效期 |
| `CORS_ORIGIN` | `*` | 允许来源,多个用逗号分隔;生产建议写具体域名 |
| `TRUST_PROXY` | `false` | 反向代理层数(Nginx 填 `1`),影响限流与日志中的真实 IP |
| `RATE_LIMIT_ENABLED` | `true` | 通用限流(生产 600 次/5 分钟)+ 登录限流(20 次/10 分钟) |
| `STARTUP_DB_CHECK` | `true` | 启动校验数据库;生产不可用时直接退出 |
| `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 |
| `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) |
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
| `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(ClassHelper 班级端登录)的默认密码 |
| `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) |
| `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 |
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | 空库首启动创建的管理员 |
| 变量 | 默认 | 说明 |
| --------------------------------------------------- | ---------------------- | ----------------------------------------------------------- |
| `NODE_ENV` | `development` | `production` 会启用生产校验、HSTS、更严的限流 |
| `HOST` / `PORT` | `0.0.0.0` / `4000` | 监听地址与端口 |
| `DATABASE_PROVIDER` | `sqlite` | `sqlite` 或 `mysql` |
| `DATABASE_URL` | `file:./prisma/dev.db` | SQLite 相对路径以 `server` 根为基准;MySQL 用连接串 |
| `AUTO_MIGRATE` | `false` | SQLite 启动时补跑随包迁移,空库另创建管理员 |
| `JWT_SECRET` | — | **必填**,至少 16 位;生产需 ≥32 位且拒绝示例密钥 |
| `JWT_EXPIRES_IN` | `7d` | 令牌有效期 |
| `CORS_ORIGIN` | `*` | 允许来源,多个用逗号分隔;生产建议写具体域名 |
| `TRUST_PROXY` | `false` | 反向代理层数(Nginx 填 `1`),影响限流与日志中的真实 IP |
| `RATE_LIMIT_ENABLED` | `true` | 通用限流(生产 600 次/5 分钟)+ 登录限流(20 次/10 分钟) |
| `STARTUP_DB_CHECK` | `true` | 启动校验数据库;生产不可用时直接退出 |
| `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 |
| `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) |
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
| `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(ClassHelper 班级端登录)的默认密码 |
| `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) |
| `COURSEWARE_DIR` | 默认 data/courseware | 课件文件实体目录;需纳入完整备份 |
| `TERM_START_DATE` | — | 开学日环境兜底;界面全校/班级设置与逐周区间优先,见课表帮助 |
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | 空库首启动创建的管理员 |
---
@@ -354,6 +354,8 @@ pnpm --filter @classhelper/server start # 或 node packages/server/dist/ind
| SQLite | 停止服务后复制 `data/classhelper.db`(Linux:`/opt/classhelper/data`) | 替换该文件后启动 |
| MySQL | `mysqldump -u classhelper -p classhelper > backup.sql` | `mysql ... < backup.sql` |
课件文件实体不在 JSON/SQL 数据快照里,完整备份还需 `data/courseware` 或 `COURSEWARE_DIR` 指定目录。
建议:每日自动备份 + 保留 7 份;变更 `JWT_SECRET` 会导致所有用户重新登录,请勿在无必要时更换。
### 6.4 安全清单(上线前逐项确认)
@@ -392,7 +394,7 @@ pnpm --filter @classhelper/server start # 或 node packages/server/dist/ind
## 八、性能与容量参考
- 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),实测 62 项接口与实时推送全部达标。
- 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),接口与实时推送均已通过端到端验收。
- 多校区 / 高并发建议切 MySQL,并横向扩容服务端(Socket.IO 需要配置 Redis adapter 才能多实例广播,
当前为单实例广播;如需多实例请参考 `packages/server/src/realtime/socket.ts` 引入 `@socket.io/redis-adapter`)。
- 静态资源已设置 immutable 长缓存 + gzip;Web 管理端为 SPA,首屏包体约 900 KB(gzip 约 290 KB),
+6 -7
View File
@@ -113,16 +113,11 @@ docker compose up -d
> 如果你是在**自己维护的机器**上只替换了 `releases/` 里的产物、而没有同步到安装目录,
> 用户打开的还是旧的 `resources/app.asar` —— 表现为"改了但界面不生效"。
> 覆盖升级时用 `robocopy ... /E` 推一遍,再对**那个副本**跑一次打包版冒烟确认。
### ClassIsland 插件
**必须先完全退出 ClassIsland**(它会锁住 DLL),再覆盖插件,然后重启。
插件配置 `Config\Plugins\<插件 id>\Settings.json` 里存着服务器地址与设备令牌,**别覆盖它**。
> 停止客户端后使用新安装程序覆盖安装,再对**实际安装副本**跑一次打包版冒烟确认。
## 升级前的准备清单
- [ ] 备份数据(`classhelper backup` / 复制 `data` 目录 / `mysqldump`);
- [ ] 备份数据库、配置与课件实体(完整复制 `data`/`COURSEWARE_DIR`,数据库用对应快照或 dump);
- [ ] 记下当前版本号,必要时能回滚到它;
- [ ] 挑一个没课的时间 —— 升级期间实时推送会中断;
- [ ] 升级后确认 `/readyz` 是 200,并且客户端顶栏的实时通道显示"已连接"。
@@ -130,3 +125,7 @@ docker compose up -d
## 给维护者
打版本、出安装包、发 Release 的完整流程见[版本迭代与发布](../dev/release.md)。
### 客户端远控与更新修复
先升级服务端,再推送修复后的客户端安装包。服务端兼容旧客户端读取顶层心跳字段,旧设备也可领取更新;第一次升级前仍遵循旧客户端的空闲安装条件。新客户端支持立即、放学后和空闲安装三种策略。放学后取指定的最早时间与当天最后一节课结束时间中的较晚者;设备离线时在再次上线后处理。
+33 -1720
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -26,7 +26,7 @@ export default tseslint.config(
'**/dist-electron/**',
'**/release/**',
'**/release-server/**',
// 交付产物归集目录(pnpm dist:* 的输出,见 AGENTS.md §4):里面是构建后的压缩 JS,不该进 lint
// 交付产物归集目录(pnpm dist:* 的输出,见 AGENTS.md 的现行工程约定):里面是构建后的压缩 JS,不该进 lint
'**/releases/**',
'**/build/**',
'**/coverage/**',
+13 -7
View File
@@ -1,6 +1,6 @@
{
"name": "class-helper",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0",
@@ -15,7 +15,6 @@
"dev:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client dev",
"build": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && pnpm --filter @classhelper/desktop-client build",
"build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
"build:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs",
"check:icons": "node scripts/check-icons.mjs",
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
"lint": "eslint .",
@@ -29,12 +28,18 @@
"db:studio": "pnpm --filter @classhelper/server db:studio",
"db:switch:mysql": "node scripts/use-database.mjs mysql",
"db:switch:sqlite": "node scripts/use-database.mjs sqlite",
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e",
"verify:e2e": "node scripts/verify-e2e-isolated.mjs",
"verify:courseware": "node scripts/verify-courseware.mjs",
"verify:v14": "node scripts/verify-v14.mjs",
"verify:client-agent": "node scripts/verify-client-agent.mjs",
"verify:review-client": "node scripts/verify-review-client.mjs",
"verify:review-api": "node scripts/verify-review-api.mjs",
"verify:clients-api": "node scripts/verify-clients-api.mjs",
"verify:client-migrations": "node scripts/verify-client-migrations.mjs",
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
"verify:web": "node scripts/ui-smoke/run.mjs",
"verify:web:isolated": "node scripts/verify-e2e-isolated.mjs --web",
"verify:packaged": "node scripts/verify-packaged.mjs",
"verify:classisland": "pnpm --filter @classhelper/server verify:classisland",
"verify:classisland-plugin": "node packages/classisland-plugin/scripts/verify.mjs",
"icons": "node scripts/generate-icons.mjs",
"version:check": "node scripts/version.mjs",
"version:bump": "node scripts/version.mjs --bump",
@@ -46,8 +51,9 @@
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs",
"dist:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux",
"dist:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs --cipx",
"dist:all": "pnpm dist:server && pnpm dist:win && pnpm dist:classisland-plugin"
"dist:all": "pnpm dist:server && pnpm dist:win",
"verify:desktop:visual": "node packages/desktop-client/scripts/smoke.mjs --visual",
"verify:desktop:online": "node packages/desktop-client/scripts/smoke.mjs --online"
},
"devDependencies": {
"concurrently": "^10.0.5",
-4
View File
@@ -1,4 +0,0 @@
# .NET 构建产物(与仓库根的 .gitignore 分开:根 .gitignore 是 GBK 编码,apply_patch 读不了)
bin/
obj/
cipx/
@@ -1,45 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!--
ClassIsland 2.0 / 2.1.0.x 面向 net8.0;2.1.1 起改为 net10.0。
本插件按 net8.0 交付,因此 SDK 版本固定到最后一个 net8.0 的版本(2.1.0.1),
这样 ClassIsland 2.0.x 与 2.1.0.x 都能加载(apiVersion 见 manifest.yml)。
-->
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12</LangVersion>
<RootNamespace>ClassHelper.ClassIslandPlugin</RootNamespace>
<AssemblyName>ClassHelper.ClassIslandPlugin</AssemblyName>
<ClassIslandPluginSdkVersion>2.1.0.1</ClassIslandPluginSdkVersion>
<!-- 插件由 ClassIsland 加载,不需要生成自带运行时(ExcludeAssets 见下) -->
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<!-- 设置页是 .axaml:需要让 Avalonia 的默认项把 **/*.axaml 收成 AvaloniaXaml 并编译 -->
<EnableDefaultItems>true</EnableDefaultItems>
<!-- 绑定走运行时解析:插件与主程序各自编译,编译期绑定会因看不到对方的类型而失败 -->
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<!-- 运行时与原生资源由 ClassIsland 本体提供,插件只编译期依赖 -->
<PackageReference Include="ClassIsland.PluginSdk" Version="$(ClassIslandPluginSdkVersion)">
<ExcludeAssets>runtime;native</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Update="manifest.yml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="README.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="icon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,10 +0,0 @@
{
"profiles": {
"ClassIsland 插件": {
"commandName": "Executable",
"executablePath": "$(ClassIsland_DebugBinaryFile)",
"commandLineArgs": "-epp $(TargetDir)",
"workingDirectory": "$(ClassIsland_DebugBinaryDirectory)"
}
}
}
-163
View File
@@ -1,163 +0,0 @@
# ClassHelper 联动插件
把 [ClassIsland](https://classisland.tech) 与 [ClassHelper](https://github.com/LoveChengke/ClassHelper) 连起来:
让 ClassHelper**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
## 它能做什么
| 方向 | 说明 |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| ClassIsland → ClassHelper | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
| ClassHelper → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
| ClassHelper → ClassIsland(可选) | 把 ClassHelper上排好的课表**镜像**回 ClassIsland(新建 `ClassHelper-<班级名>` 档案课表,不改动老师原有的课表) |
## 安装
1. 把插件放进 ClassIsland 的插件目录,二选一:
- 用打包好的 `ClassHelper.ClassIslandPlugin.cipx`:ClassIsland →「设置 → 插件 → 从文件安装」;
- 或直接把 `ClassHelper.ClassIslandPlugin/` 目录(含 `manifest.yml` 与 dll)丢进插件目录
(Windows 默认 `%APPDATA%\ClassIsland\Plugins`,便携版在安装目录下的 `Plugins`)。
2. 重启 ClassIsland,在「设置 → 插件」里确认「ClassHelper 联动」已启用。
3. 打开「设置 → ClassHelper 联动」,填入:
- **服务器地址**:ClassHelper后端地址,例如 `http://127.0.0.1:4000`;
- **设备令牌**:在 ClassHelper Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
4. 点「立即上报」验证连接状态;状态卡片显示「已连接」即接入成功。
## 配置项(全部在「设置 → ClassHelper 联动」里)
| 配置项 | 说明 |
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
| 服务器地址 | ClassHelper后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
| 设备令牌 | 由 Web 端「ClassIsland 联动」页生成;服务端只存 sha256,明文只在页面上出现一次 |
| 上报课表到 ClassHelper | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
| 把班级课表镜像回 ClassIsland | 反方向同步,新建带前缀的档案课表;关闭后仍会照常上报「现在上什么课」 |
| 自动上报 | 启动 / 课程事件(上课、下课、放学、状态变化)/ 定时各上报一次;关闭后只能用「立即上报」手动触发 |
| 上报间隔(秒) | 定时上报间隔,最小 15 秒,教室机器建议 60 秒 |
| 接收 ClassHelper提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
| 语音朗读 | 是否允许本机朗读(老师下发时也勾了「语音朗读」才会念,两个条件都满足才出声) |
| 默认显示时长(秒) | 老师未指定时长时用这个值;单条提醒可以在 Web 端单独指定更长的时长 |
| 提醒轮询间隔(秒) | **老师按下发送 → 教室弹出之间的最大延迟**(插件不持有长连接,是轮询取回的),默认 10 秒、最小 5 秒 |
| 显示调试信息 | 展开设备标识、与服务端的时钟差、配置目录等,排查「为什么同步不上」时打开 |
设置保存在 `<插件配置目录>/Settings.json`(随 ClassIsland 档案备份走,升级插件不丢)。
改动即时生效 —— 改地址 / 令牌 / 间隔会立刻重建上报定时器,不需要重启 ClassIsland。
> **上课时段策略(与 ClassHelper 客户端同一套规则)**:
> 上课时段里,通知类提醒会**先暂存、下课后自动补弹**;**紧急提醒**与**叫人**属于「主动通知」,立刻弹。
> 暂存期间不回执,服务端会保留这条待提醒。
> **课间**:镜像回 ClassIsland 的课表会自动补上课间 —— **每两节之间**一段
> (上一节下课 → 下一节上课),**最后一节之后不补**(放学)。课间由插件按课目时间算出来,
> 不依赖 ClassHelper那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
> **两个方向各自需要的开关**:
>
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
> - ClassIsland → 服务端(上报课表):插件「上报课表到 ClassHelper」打开;
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到 ClassHelper」开关已关闭)」),不是上报失败;
> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。
> **提醒到底会不会弹,取决于两个开关:**
>
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
> 服务端根本不会推过来(此时本插件收不到东西,属正常);
> 2. 本插件的「接收 ClassHelper提醒」关掉时,即使推过来也不弹。
>
> 两者都开着,提醒才会在 ClassIsland 上出现。
## 工作原理(为什么这么写)
- **不用跨进程 IPC,插件内直接读课程服务**:ClassIsland 的 IPC 暴露的是缓存数据,不足以还原课表;
插件直接读 `ILessonsService`(当前科目 / 状态 / 时间点)与 `IProfileService`(档案里的课表、时间表、科目),
拿到的才是「教室此刻真实的状态」。
- **「上报即拉取」,不引入 Socket 依赖**:每次上报的返回值里会顺带带一条「尚未确认的提醒」,
插件收到就交给提醒提供方显示;应用启动时额外拉一次 `pending`,
补上「教室机器关机期间老师发的通知」。上报间隔本身就是心跳,插件因此少一个长连接故障面。
- **提醒必须由「提醒提供方」发出**:只有通过 `NotificationProviderBase` 发出的提醒,
才会走 ClassIsland 主界面的全屏特效 / 语音 / 音效那一整套流程,因此插件注册了
「ClassHelper提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
- **回执时机 = 真的播完**:回执挂在提醒的 `Completed` / `Canceled` 上,
中途退出 ClassIsland 不会回执,下次上报会重发 —— 既不静默丢提醒,也不会重复弹。
- **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里
**第 i 个 `TimeType == 0`(上课)的时间点**一一对应,
因此镜像接口为每条课目带上 `startTime` / `endTime`,插件据此重建时间表
(课间点从服务端的时间表里按起始时刻对齐)。少一个时间点,整份课表就会错位。
- **单双周口径与三端一致**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN`
与 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` 互逆;
3 周及以上的轮换本系统无法表达,会降级为「每周」并在日志里提示。
- **非破坏性**:镜像只创建 / 更新名字以 `ClassHelper-` 开头的课表与时间表;
老师手动建的课表不删不改名,同一星期已有启用课表时会在日志里给出重叠提示。
## 上机联调记录(ClassIsland 2.1.0.1 实测)
下面几条都是把插件装进真实 ClassIsland 跑出来的问题与结论,改动理由就写在这里,避免以后被“顺手优化”回去:
1. **提醒内容必须在 UI 线程构造并显示。** 上报回调在线程池上,而提醒模板里的图标是 Avalonia 对象
(`LucideIconSource → IconSource → AvaloniaObject` 的构造函数会 `Dispatcher.VerifyAccess()`),
在线程池上直接构造会抛 `Call from invalid thread`,表现就是“ClassHelper 客户端收到了、ClassIsland 毫无反应”。
现在统一用 `Dispatcher.UIThread.Post` 切回 UI 线程。
2. **同一条提醒在播放期间要按 id 去重。** 服务端在收到回执前会一直把提醒算作“待弹出”,
而轮询间隔(默认 10 秒)可能小于提醒时长(最长 120 秒)—— 不去重就会一遍遍重复弹。
只有播完(`Completed`/`Canceled`)才把 id 从“正在播放”里移除并回执。
3. **不要自己拼图标串。** 双图标遮罩模板的默认图标由模板自己创建(`new LucideIconSource("\ue0ff")` 这种**字形字符**),
自己传 `"fluent(...)"` 之类既可能格式不对、也可能字形不存在。紧急提醒改用 ClassIsland 自带的**滚动文本**正文来表达。
4. **`Subject.Fallback` 是占位符(名字就是 `???`)。** 没有课表时 ClassIsland 会把它挂到
`CurrentSubject`/`NextClassSubject` 上,直接上报会让 Web 端显示“正在上 ???”,因此上报前归一化成“没有科目”。
实测截图(真机弹出提醒的遮罩):[普通提醒](../../docs/screenshots/classisland/02-normal-mask.png)、
[紧急提醒](../../docs/screenshots/classisland/01-urgent-mask.png)。
## 构建与打包
```bash
pnpm build:classisland-plugin # Release 构建(产物:packages/classisland-plugin/bin/Release)
pnpm dist:classisland-plugin # 追加打包 .cipx 并归集到 releases/classisland-plugin/
pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性 / 注册完整性 / DTO ↔ 服务端契约)
```
构建脚本会把 NuGet 缓存与临时目录指到仓库内的 `.cache/`(避免写满 C 盘),
并在打包后自检「清单 / 程序集名 / 版本号」三者一致。
## 开发提示
- 需要 **.NET 8 SDK**;SDK 包固定 `ClassIsland.PluginSdk 2.1.0.1`(2.1.1 起改为 net10.0,
本插件按 net8.0 交付,因此 2.0.x / 2.1.0.x 都能加载,见 `manifest.yml` 的 `apiVersion`)。
- 设置页是 Avalonia 的 `.axaml`(继承 `SettingsPageBase`);绑定走运行时解析
(`AvaloniaUseCompiledBindingsByDefault=false`),因为插件与主程序各自编译,编译期绑定看不到对方类型。
- **开发机没有安装 ClassIsland 本体**,所以插件只能做到编译 + 打包 + 静态校验;
运行期行为需要在装了 ClassIsland 的机器上按上面的「安装」步骤实测。
## 常见问题
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是 ClassHelper的标准格式)**
这台机器上的**系统代理拦了内网请求**。Windows 的代理设置里 `127.0.0.1` 并不在默认绕过列表里,
于是浏览器/插件对「本地服务」的请求也被送到代理,拿到的是**代理自己的 404 错误页**。
插件已对本地/内网地址(localhost、回环、10/172.16-31/192.168、链路本地、无点主机名)**强制直连、绕开系统代理**;
如果仍然报 404,请依次检查:
1. 服务器地址是否写成了别的端口 / 别的机器(提示里会带上实际请求的完整 URL,照着核对最快);
2. 该地址在浏览器里打开 `<服务器地址>/api/health` 是否返回 `{"success":true,...}`;
3. 反向代理(Nginx 等)是否把 `/api/integrations` 也转发到了后端。
**上报失败,提示「设备令牌无效」**
在 Web 端重置该设备的令牌,然后把新令牌填回插件设置(旧令牌会立即失效)。
**发布提醒后 ClassIsland 没反应,或者要等一会儿才弹**
插件不持有长连接,提醒是**轮询取回**的:默认最多 10 秒(设置页「提醒轮询间隔」可调到 5 秒)。超过这个时间还没弹,按下面逐条排查。
**ClassIsland 上完全不弹提醒**
1. 确认插件设置里打开了「接收提醒」;
2. 确认 Web 端「ClassIsland 联动」页面上该设备是「已接入」状态(停用后服务端会直接 403);
3. 提醒会在 24 小时后过期,避免教室机器一开机弹出一堆旧通知。
**课表上报了但 ClassHelper上没变化**
确认插件设置里打开了「上报课表到服务端」,且 Web 端该设备的「回传课表」开关也是允许状态。
**镜像开关打开了,但 ClassIsland 的课表没变**
镜像需要 Web 端该设备开启「镜像课表」且班级里已有课表;插件只在**上报时**顺路拉取镜像,
所以打开开关后点一下「立即上报」或等一个上报间隔即可。镜像不会覆盖老师原有的课表。
## 许可
与 ClassHelper仓库一致。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

-19
View File
@@ -1,19 +0,0 @@
# ClassHelper ClassIsland 联动插件 —— 清单文件
#
# 字段说明见 https://docs.classisland.tech/dev/plugins/create-project/
# apiVersion 指向 ClassIsland 2.0.0.0:本插件只用到 2.0 就有的插件 / 提醒 / 课程服务 API。
id: classhelper.classisland.bridge
name: ClassHelper 联动
description: |-
把 ClassIsland 的课表与上课状态同步到 ClassHelper,并在 ClassIsland 上显示老师下发的提醒。
功能:
1. 上报当前课表与节次时间(ClassHelper Web 端与桌面客户端据此显示课程);
2. 上报"正在上哪节课 / 下一节是什么 / 还有多久下课"的实时状态;
3. 接收老师在 ClassHelper上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper
version: 1.3.0.0
apiVersion: 2.0.0.0
author: ClassHelper
@@ -1,168 +0,0 @@
/**
* ClassIsland 联动插件构建脚本:`pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin`
*
* 用法:
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
* node scripts/build.mjs --cipx # 追加打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
*
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
* 1. `dotnet` 默认把 NuGet 缓存与临时文件写在 C 盘(`%USERPROFILE%\.nuget`、`%TEMP%`),
* 本机 C 盘紧张时会直接构建失败。这里统一改到仓库内 `.cache/`(已 gitignore)。
* 2. 插件包(.cipx)的最后一步要调 PowerShell 生成哈希清单,SDK 默认用 `pwsh`;
* 没有 pwsh 的机器会失败,因此这里探测一次并显式传给 MSBuild。
* 3. 构建完顺手校验"清单 / 程序集 / 版本号"三者一致,避免打出装不上的包。
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(pluginDir, '..', '..');
const cacheDir = path.join(repoRoot, '.cache');
const releasesRoot = path.join(repoRoot, 'releases', 'classisland-plugin');
const argv = process.argv.slice(2);
const withCipx = argv.includes('--cipx');
/** `--` 之后的参数原样透传给 dotnet build;其余以 - 开头的参数同样透传 */
const passthrough = argv.filter((item) => item !== '--cipx' && item !== '--');
const log = (message) => console.log(`[classisland-plugin] ${message}`);
/* ------------------------------------------------------------ 1. 环境准备 */
const env = { ...process.env };
for (const sub of ['nuget', 'tmp', 'dotnet-home']) {
fs.mkdirSync(path.join(cacheDir, sub), { recursive: true });
}
const setDefault = (key, value) => {
if (!env[key]) env[key] = value;
};
setDefault('NUGET_PACKAGES', path.join(cacheDir, 'nuget'));
setDefault('TEMP', path.join(cacheDir, 'tmp'));
setDefault('TMP', env.TEMP);
setDefault('DOTNET_CLI_HOME', path.join(cacheDir, 'dotnet-home'));
env.DOTNET_NOLOGO = '1';
env.DOTNET_CLI_TELEMETRY_OPTOUT = '1';
/** 可执行文件探测:先看显式配置,再问 PATH,最后试常见安装路径 */
function resolveExecutable(envKey, names, fallbacks) {
const candidates = [];
if (env[envKey]) candidates.push(env[envKey]);
const finder = process.platform === 'win32' ? 'where' : 'which';
for (const name of names) {
const found = spawnSync(finder, [name], { encoding: 'utf8', env });
if (found.status === 0) {
for (const line of String(found.stdout ?? '').split(/\r?\n/)) candidates.push(line.trim());
}
}
candidates.push(...fallbacks);
for (const candidate of candidates) {
if (!candidate) continue;
try {
if (fs.statSync(candidate).isFile()) return candidate;
} catch {
/* 继续试下一个 */
}
}
return null;
}
const dotnet = resolveExecutable(
'DOTNET_EXE',
['dotnet'],
process.platform === 'win32'
? [path.join(env.ProgramFiles ?? 'C:\\Program Files', 'dotnet', 'dotnet.exe')]
: ['/usr/local/bin/dotnet', '/usr/bin/dotnet'],
);
if (!dotnet) {
throw new Error('未找到 dotnet SDK(需要 .NET 8 SDK)。安装后用 DOTNET_EXE 指定完整路径。');
}
const powerShell = resolveExecutable(
'POWERSHELL_EXE',
['pwsh', 'powershell'],
process.platform === 'win32'
? [path.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')]
: [],
);
/* ------------------------------------------------------------ 2. 构建 */
const buildArgs = ['build', '-c', 'Release'];
if (withCipx) {
buildArgs.push('-p:CreateCipx=true');
// SDK 的 cipx 目标默认调 pwsh;这里按探测结果显式指定,避免"没有 pwsh 就打包失败"
if (powerShell) buildArgs.push(`-p:PowershellBinaryName=${powerShell}`);
}
buildArgs.push(...passthrough);
log(`dotnet build(缓存目录 ${path.relative(repoRoot, cacheDir)})`);
const build = spawnSync(dotnet, buildArgs, { cwd: pluginDir, stdio: 'inherit', env });
if (build.error) throw new Error(`无法启动 dotnet:${build.error.message}`);
if (build.status !== 0) throw new Error(`dotnet build 失败(退出码 ${build.status})`);
/* ------------------------------------------------------------ 3. 产物自检 */
const outputDir = path.join(pluginDir, 'bin', 'Release');
const manifestPath = path.join(outputDir, 'manifest.yml');
const assemblyPath = path.join(outputDir, 'ClassHelper.ClassIslandPlugin.dll');
for (const required of [manifestPath, assemblyPath, path.join(outputDir, 'icon.png')]) {
if (!fs.existsSync(required)) {
throw new Error(`构建产物缺少 ${path.relative(pluginDir, required)},ClassIsland 会加载失败`);
}
}
const manifest = fs.readFileSync(manifestPath, 'utf8');
// prettier 会把 YAML 字符串统一成单引号,因此两种引号都要能剥掉
const entrance = /^entranceAssembly:\s*(.+?)\s*$/m.exec(manifest)?.[1]?.replace(/^['"]|['"]$/g, '');
if (entrance !== path.basename(assemblyPath)) {
throw new Error(
`manifest.yml 的 entranceAssembly(${entrance})与程序集(${path.basename(assemblyPath)})不一致`,
);
}
const manifestVersion = /^version:\s*([\d.]+)/m.exec(manifest)?.[1];
const bridgeSource = fs.readFileSync(path.join(pluginDir, 'src', 'Services', 'BridgeService.cs'), 'utf8');
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
if (!manifestVersion || manifestVersion !== codeVersion) {
throw new Error(
`版本号不一致:manifest.yml=${manifestVersion ?? '缺失'},BridgeService.cs=${codeVersion ?? '缺失'}`,
);
}
log(`产物自检通过(版本 ${manifestVersion},入口程序集 ${entrance})`);
/* ------------------------------------------------------------ 4. 打包归集 */
if (!withCipx) {
log('完成。加 --cipx 可同时打包成 ClassIsland 插件包(.cipx)');
process.exit(0);
}
const cipxDir = path.join(pluginDir, 'cipx');
const cipxFile = path.join(cipxDir, 'ClassHelper.ClassIslandPlugin.cipx');
if (!fs.existsSync(cipxFile)) {
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
}
// 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。
// 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把
// 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。
// 产物只有 `安装包/*.cipx` + `checksums.md`:**.cipx 本身就是个 zip**,解压即用的那份
// 「免安装目录」与它内容完全一样,纯属重复留档,已不再产出。
const pluginVersion = manifestVersion.split('.').slice(0, 3).join('.');
const versionDir = path.join(releasesRoot, pluginVersion);
const installerDir = path.join(versionDir, '安装包');
fs.rmSync(installerDir, { recursive: true, force: true });
fs.rmSync(path.join(versionDir, '免安装'), { recursive: true, force: true });
fs.mkdirSync(installerDir, { recursive: true });
for (const file of fs.readdirSync(cipxDir)) {
// .cipx 是插件包本体,校验清单跟着版本目录走
const to = file.toLowerCase().endsWith('.cipx') ? installerDir : versionDir;
fs.copyFileSync(path.join(cipxDir, file), path.join(to, file));
}
log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`);
for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`);
@@ -1,390 +0,0 @@
/**
* ClassIsland 联动插件静态校验:`pnpm verify:classisland-plugin`
*
* 为什么是"静态"校验:插件要跑起来必须有 ClassIsland 本体,而本机(以及 CI)都没有,
* 所以这里校验"装不上 / 对不上"的那几类问题 —— 它们不需要运行就能发现:
*
* 1. 清单三件套一致性:manifest.yml ↔ csproj ↔ 代码里的版本号;
* 2. 注册完整性:插件入口、提醒提供方、设置页是否都挂上了(挂漏了功能会"静默消失");
* 3. **接口契约**:C# DTO 的 JSON 字段名 ↔ 服务端路由 / 请求字段,
* 这类错误(比如 dayOfWeek 写成 dayofweek)在运行时只会表现为"上报成功但课表是空的",
* 最难排查,所以专门盯住。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(pluginDir, '..', '..');
const results = [];
const check = (name, ok, detail = '') => results.push({ name, ok: Boolean(ok), detail });
const read = (relative) => fs.readFileSync(path.join(pluginDir, relative), 'utf8');
const readRepo = (relative) => fs.readFileSync(path.join(repoRoot, relative), 'utf8');
const manifest = read('manifest.yml');
const csproj = read('ClassHelper.ClassIslandPlugin.csproj');
const pluginSource = read('src/Plugin.cs');
const providerSource = read('src/Services/ClassHelperNotificationProvider.cs');
const bridgeSource = read('src/Services/BridgeService.cs');
const clientSource = read('src/Interop/ClassHelperClient.cs');
const settingsPageSource = read('src/Views/BridgeSettingsPage.axaml.cs');
const settingsPageXaml = read('src/Views/BridgeSettingsPage.axaml');
const mapperSource = read('src/Services/ScheduleMapper.cs');
const settingsSource = read('src/Models/PluginSettings.cs');
const serverModule = readRepo('packages/server/src/modules/integrations/integrations.module.ts');
const serverSchemas = readRepo('packages/server/src/modules/integrations/integrations.schemas.ts');
const serverService = readRepo('packages/server/src/modules/integrations/integrations.service.ts');
const sharedTypes = readRepo('packages/shared/src/types.ts');
const sharedConstants = readRepo('packages/shared/src/constants.ts');
/* ------------------------------------------------------------ 1. 清单一致性 */
/** 取 YAML 里某个顶层字段的值(prettier 会把字符串统一成单引号,两种引号都要能剥掉) */
function field(name) {
const raw = new RegExp('^' + name + ':\\s*(.+?)\\s*$', 'm').exec(manifest)?.[1];
return raw?.replace(/^['"]|['"]$/g, '');
}
check('manifest.yml 有插件 id', Boolean(field('id')));
check('manifest.yml 声明了入口程序集', Boolean(field('entranceAssembly')));
check('manifest.yml 声明了 apiVersion', /\d+\.\d+\.\d+\.\d+/.test(field('apiVersion') ?? ''));
const assemblyName = /<AssemblyName>([^<]+)<\/AssemblyName>/.exec(csproj)?.[1];
check(
'entranceAssembly 与 csproj 的 AssemblyName 一致',
field('entranceAssembly') === assemblyName + '.dll',
'entranceAssembly=' + field('entranceAssembly') + ' AssemblyName=' + assemblyName,
);
const manifestVersion = field('version');
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
check(
'插件版本号在清单与代码里一致',
Boolean(manifestVersion) && manifestVersion === codeVersion,
'manifest=' + manifestVersion + ' code=' + codeVersion,
);
check(
'图标与自述文件按清单约定命名(icon.png / README.md)',
fs.existsSync(path.join(pluginDir, 'icon.png')) && fs.existsSync(path.join(pluginDir, 'README.md')),
);
/* ------------------------------------------------------------ 2. 注册完整性 */
check(
'插件入口带 [PluginEntrance] 且继承 PluginBase',
/\[PluginEntrance\]/.test(pluginSource) && /:\s*PluginBase/.test(pluginSource),
);
check(
'初始化时注册了提醒提供方',
/AddNotificationProvider<\s*ClassHelperNotificationProvider\s*>/.test(pluginSource),
);
check('初始化时注册了设置页', /AddSettingsPage<\s*BridgeSettingsPage\s*>/.test(pluginSource));
check('初始化时把联动服务注册为单例', /AddSingleton<\s*BridgeService\s*>/.test(pluginSource));
check('应用启动后才接课程服务(不能更早)', /AppStarted/.test(pluginSource));
check(
'设置以 JSON 持久化到插件配置目录',
/PluginConfigFolder/.test(pluginSource) && /Settings\.json/.test(pluginSource),
);
const providerGuid = /ProviderGuidValue = "([0-9A-Fa-f-]{36})"/.exec(providerSource)?.[1];
check('提醒提供方声明了合法 GUID', Boolean(providerGuid));
check(
'提醒提供方属性引用同一个 GUID 常量',
/NotificationProviderInfo\(\s*ProviderGuidValue/.test(providerSource),
);
check(
'提醒提供方会在播完后回执给服务端',
/Completed/.test(providerSource) && /AckAsync/.test(providerSource),
);
check(
'设置页带 [SettingsPageInfo] 且声明了页面 id',
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"ClassHelper 联动"/.test(settingsPageSource),
);
check(
'设置页 XAML 的 x:Class 与代码后置类一致',
settingsPageXaml.includes('x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"') &&
/namespace ClassHelper\.ClassIslandPlugin\.Views;/.test(settingsPageSource),
);
check('设置页 XAML 继承 ClassIsland 的设置页基类', /SettingsPageBase/.test(settingsPageXaml));
/* ------------------------------------------------------------ 3. 接口契约 */
// 3.1 路由:插件调用的路径必须都在服务端注册过
const clientPaths = [...clientSource.matchAll(/"(\/classisland\/[a-z-]+)"/g)].map((item) => item[1]);
const registeredPaths = new Set(
[...serverModule.matchAll(/router\.(?:get|post|patch|delete)\(\s*'(\/classisland\/[a-z-]+)'/g)].map(
(item) => item[1],
),
);
for (const route of [
'/classisland/report',
'/classisland/pending',
'/classisland/ack',
'/classisland/class-plan',
]) {
check('服务端注册了 ' + route, registeredPaths.has(route));
}
check(
'插件调用的路由都存在于服务端',
clientPaths.length > 0 && clientPaths.every((item) => registeredPaths.has(item)),
'插件调用=[' + clientPaths.join(',') + ']',
);
check('插件把接口前缀拼成 /api/integrations', clientSource.includes('/api/integrations'));
check('插件用设备令牌头鉴权', clientSource.includes('X-ClassIsland-Token'));
// 3.2 上报请求字段:服务端 zod schema 里出现的字段名,插件必须真的会发
const reportBlock = /export const classIslandReportSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
const requestFields = [
'pluginVersion',
'classIslandVersion',
'state',
'schedule',
'timeLayout',
'inClass',
'subject',
'nextSubject',
'timeState',
'periodStart',
'periodEnd',
'classPlanLoaded',
'clientTime',
'mode',
'entries',
'items',
];
const missingRequestFields = requestFields.filter(
(name) => reportBlock.includes(name) && !new RegExp('\\b' + name + '\\b').test(clientSource),
);
check(
'上报请求字段都出现在插件 DTO 里',
missingRequestFields.length === 0,
missingRequestFields.length > 0 ? '缺少=' + missingRequestFields.join(',') : '',
);
// 3.3 课表条目字段:这些是"上报成功但课表为空"的典型来源
const entrySchema = /export const reportScheduleEntrySchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
const entryFields = [
'dayOfWeek',
'startTime',
'endTime',
'subject',
'teacherName',
'weekParity',
'weekCountDiv',
'weekCountDivTotal',
'planName',
];
const csharpJsonNames = new Set(
[...clientSource.matchAll(/\[JsonPropertyName\("([^"]+)"\)\]/g)].map((item) => item[1]),
);
const missingEntryFields = entryFields.filter(
(name) => entrySchema.includes(name) && !csharpJsonNames.has(name),
);
check(
'课表条目字段服务端 zod 与插件 DTO 对齐',
missingEntryFields.length === 0,
missingEntryFields.length > 0 ? '插件缺少=' + missingEntryFields.join(',') : '',
);
// 3.4 插件 DTO 里的 JSON 字段名必须能在共享契约里找到(防错拼)
const knownLocalOnly = new Set(['data', 'success', 'message', 'code']);
const unknownJsonNames = [...csharpJsonNames].filter((name) => {
if (knownLocalOnly.has(name)) return false;
return !new RegExp('\\b' + name + '\\b').test(sharedTypes);
});
check(
'插件 DTO 的 JSON 字段名都能在 @classhelper/shared 里找到',
unknownJsonNames.length === 0,
unknownJsonNames.length > 0 ? '疑似拼写错误=' + unknownJsonNames.join(',') : '',
);
// 3.5 课表镜像:服务端给了 Classes[i] 对齐所需的时间,插件必须读
for (const name of ['startTime', 'endTime', 'timeLayouts', 'profileName', 'weekDay', 'weekCountDivTotal']) {
check('镜像契约字段 ' + name + ' 在插件侧存在', csharpJsonNames.has(name));
}
// 3.6 提醒下发字段(Web 端 → 服务端 → 插件)
const notifySchema = /export const sendNotificationSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
for (const name of [
'classId',
'title',
'content',
'durationSeconds',
'speech',
'speechContent',
'saveToNotifications',
'priority',
]) {
check('下发提醒字段 ' + name + ' 在服务端 schema 里', notifySchema.includes(name));
}
check(
'提醒时长有上界(避免老师发出永不消失的提醒)',
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(serverSchemas) &&
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(sharedConstants),
);
// 3.7 实时事件:Web 端订阅的事件名必须在共享契约里声明过
check(
'共享契约声明了 classisland 实时事件',
sharedTypes.includes("'classisland:state'") && sharedTypes.includes("'classisland:notification'"),
);
check(
'服务端广播事件名取自共享常量(不是手写字符串)',
/SOCKET_EVENTS\.classislandState/.test(serverService),
);
// 3.8 网络层:教室机器(以及开发机)常配了系统代理,而 Windows 的代理设置默认连 127.0.0.1
// 也会走代理,表现为"填了令牌立刻 HTTP 404"(其实是代理自己的错误页,跟本服务无关)。
// 插件必须对本地/内网地址绕开系统代理,否则内网部署必然失败。
check(
'HttpClient 显式配置代理(不裸用默认行为)',
/SocketsHttpHandler\s*\{[^}]*Proxy\s*=/.test(clientSource),
);
check(
'本地/内网地址绕开系统代理',
/class LocalAwareProxy/.test(clientSource) && /IsBypassed\(Uri host\)/.test(clientSource),
);
check(
'代理判定覆盖回环与内网网段',
/IsLoopback/.test(clientSource) &&
/10\.0\.0\.0\/8/.test(clientSource) &&
/192\.168\.0\.0\/16/.test(clientSource),
);
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
// 3.10 课表同步方向(2026-10-06 反转):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
// 「把教室课表传上来」改成 Web 端的一次性人工请求。这几条盯着反转后的契约,别删。
check(
'默认「上报课表到 ClassHelper」关闭、镜像到 ClassIsland 开启(新方向)',
/_uploadSchedule\s*=\s*false/.test(settingsSource) && /_mirrorSchedule\s*=\s*true/.test(settingsSource),
);
check(
'响应 DTO 里有 scheduleRequested(服务端请求立刻上报一次课表)',
/scheduleRequested/.test(clientSource),
);
check(
'收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)',
/ScheduleRequested\s*==\s*true/.test(bridgeSource) &&
/forceSchedule:\s*true/.test(bridgeSource),
);
check(
'服务端请求课表不依赖「自动回传」开关(forceSchedule 或上开关二者取一)',
/forceSchedule\s*\|\|\s*_settings\.UploadSchedule/.test(bridgeSource),
);
check(
'补发课表发生在镜像之前(顺序反了会把刚拿到的课表盖掉)',
bridgeSource.indexOf('ScheduleRequested == true') < bridgeSource.indexOf('MirrorScheduleAsync()'),
);
check(
'课表拉取 DTO 带 weekRanges(管理员逐周配的学期周次区间)',
/weekRanges/.test(clientSource) && /TermWeekRangeDto/.test(clientSource),
);
// 3.9 提醒显示链路:下面几条全是"在真机上跑出来的",回归时别删
check(
'提醒在 UI 线程构造并显示(否则 Avalonia 直接抛 Call from invalid thread)',
providerSource.includes('Dispatcher.UIThread.Post'),
);
check(
'同一条提醒在播放期间按 id 去重(否则轮询会让它反复弹)',
providerSource.includes('_playing') && providerSource.includes('HashSet<string>'),
);
check(
'不再自造图标串(用模板默认图标,紧急用滚动文本表达)',
// 不再给 CreateTwoIconsMask 传 leftIcon(自造图标串会踩格式/字形坑)
!providerSource.includes('leftIcon') && providerSource.includes('CreateRollingTextContent'),
);
check(
'ClassIsland 的占位科目(Subject.Fallback)折算成"没有科目"',
mapperSource.includes('Subject.Fallback'),
);
check(
'上课时段暂存非主动通知(与客户端灵动岛同一套规则)',
providerSource.includes('_deferred') &&
providerSource.includes('IsImmediate') &&
providerSource.includes('FlushDeferred') &&
providerSource.includes('OnBreakingTime'),
);
check(
'主动通知(紧急 / 叫人)上课时段也立即弹',
providerSource.includes("\"call\"") && providerSource.includes('notification.Urgent'),
);
check(
'「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")',
bridgeSource.includes('上报课表到 ClassHelper') && bridgeSource.includes('开关已关闭'),
);
check(
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
settingsSource.includes('NotificationPollSeconds') &&
bridgeSource.includes('_notifyTimer') &&
bridgeSource.includes('NotificationPollSeconds'),
);
/* ------------------------------------------------------------ 8. 回归护栏:本次修复过的坑 */
check(
'课表配对用原始 TimeType==0 列表,**不用** ValidTimeLayoutItems(后者会剔除被停用的节次,导致整份课表错位)',
// 只查代码形态(`.Count`),因为源码注释里特意点名了这个属性来说明"为什么不能用它"
mapperSource.includes('classPoints[index]') && !mapperSource.includes('.ValidTimeLayoutItems.Count'),
);
check(
'上报字段按服务端 zod 上限截断(一个超长名字会让整批上报 400,连状态与心跳一起丢掉)',
mapperSource.includes('Truncate(') &&
mapperSource.includes('SubjectMaxLength') &&
mapperSource.includes('PlanNameMaxLength'),
);
check(
'课表镜像在 UI 线程改写档案(线程池上改 Observable 集合会与落盘并发,可能写坏档案)',
bridgeSource.includes('Dispatcher.UIThread.InvokeAsync') && bridgeSource.includes('SaveProfile()'),
);
check(
'镜像内容未变化时跳过(否则每次上报都重建课表并全量落盘)',
bridgeSource.includes('_lastMirrorSignature') && bridgeSource.includes('MirrorSignature('),
);
check(
'上报异常有 catch(fire-and-forget 的异常会被静默吞掉)',
bridgeSource.includes('catch (Exception exception)') && bridgeSource.includes('上报异常'),
);
check(
'镜像前过滤时间非法的条目(避免档案里出现 00:00 的课次,且必须与 Classes 同步过滤)',
read('src/Services/ClassPlanWriter.cs').includes('IsValidTime'),
);
check(
'上报体带上 deviceKey,且服务端 schema 也声明了该字段(回填机器码的契约两端一致)',
clientSource.includes('"deviceKey"') && serverSchemas.includes('deviceKey:'),
);
check(
'2xx 但不是本服务响应体时给出原因(而不是"成功但无数据 + 空消息"),并限制响应体大小',
clientSource.includes('响应不是 ClassHelper的标准格式') &&
clientSource.includes('MaxResponseContentBufferSize'),
);
check(
'提醒提供方惰性获取课程服务并重试(拿不到服务时"上课暂存"会永久失效且静默)',
providerSource.includes('EnsureLessons') && providerSource.includes('_lessonsHooked'),
);
check(
'设置落盘先写临时文件再原子替换(逐字符写盘 + 截断式写入会留下坏 JSON)',
pluginSource.includes('File.Move(temp, path, overwrite: true)'),
);
check(
'服务器地址只接受 http/https(裸 Uri.TryCreate 会放过 file:// / ftp://)',
settingsSource.includes('TryGetServerUri') && settingsSource.includes('UriSchemeHttp'),
);
/* ------------------------------------------------------------ 结果 */
const failed = results.filter((item) => !item.ok);
for (const item of results) {
console.log((item.ok ? '√ ' : '× ') + item.name + (item.detail ? ' (' + item.detail + ')' : ''));
}
console.log('');
console.log(
'ClassIsland 联动插件静态校验:' + (results.length - failed.length) + '/' + results.length + ' 项通过',
);
if (failed.length > 0) {
console.log('未通过:\n - ' + failed.map((item) => item.name).join('\n - '));
process.exit(1);
}
@@ -1,476 +0,0 @@
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using ClassHelper.ClassIslandPlugin.Models;
namespace ClassHelper.ClassIslandPlugin.Interop;
/* ---------------------------------------------------------------- 服务端契约(与 packages/shared 保持一致) */
/// <summary>插件上报的课表条目。</summary>
public class ScheduleEntryDto
{
[JsonPropertyName("dayOfWeek")] public int DayOfWeek { get; set; }
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
[JsonPropertyName("subject")] public string Subject { get; set; } = "";
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
/// <summary>ALL / ODD / EVEN;留空则由服务端按 weekCountDiv 推导。</summary>
[JsonPropertyName("weekParity")] public string? WeekParity { get; set; }
[JsonPropertyName("weekCountDiv")] public int WeekCountDiv { get; set; }
[JsonPropertyName("weekCountDivTotal")] public int WeekCountDivTotal { get; set; }
[JsonPropertyName("planName")] public string? PlanName { get; set; }
}
/// <summary>插件上报的节次时间。</summary>
public class TimeLayoutItemDto
{
[JsonPropertyName("index")] public int Index { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
/// <summary>class / break / divider / action</summary>
[JsonPropertyName("type")] public string Type { get; set; } = "class";
[JsonPropertyName("skipped")] public bool Skipped { get; set; }
}
/// <summary>本机运行状态快照。</summary>
public class StateDto
{
[JsonPropertyName("inClass")] public bool InClass { get; set; }
[JsonPropertyName("subject")] public string? Subject { get; set; }
[JsonPropertyName("nextSubject")] public string? NextSubject { get; set; }
[JsonPropertyName("timeState")] public string? TimeState { get; set; }
[JsonPropertyName("periodStart")] public string? PeriodStart { get; set; }
[JsonPropertyName("periodEnd")] public string? PeriodEnd { get; set; }
[JsonPropertyName("week")] public int? Week { get; set; }
[JsonPropertyName("classPlanLoaded")] public bool ClassPlanLoaded { get; set; }
[JsonPropertyName("clientTime")] public string ClientTime { get; set; } = "";
}
public class SchedulePayloadDto
{
[JsonPropertyName("mode")] public string Mode { get; set; } = "merge";
[JsonPropertyName("entries")] public List<ScheduleEntryDto> Entries { get; set; } = new();
}
public class TimeLayoutPayloadDto
{
[JsonPropertyName("name")] public string Name { get; set; } = "ClassIsland 时间表";
[JsonPropertyName("mode")] public string Mode { get; set; } = "replace";
[JsonPropertyName("items")] public List<TimeLayoutItemDto> Items { get; set; } = new();
}
public class ReportRequestDto
{
[JsonPropertyName("pluginVersion")] public string PluginVersion { get; set; } = "";
[JsonPropertyName("classIslandVersion")] public string ClassIslandVersion { get; set; } = "";
/// <summary>本机机器码:设备创建时服务端还不知道它,靠这里回填(见服务端 syncDeviceKey)。</summary>
[JsonPropertyName("deviceKey")] public string DeviceKey { get; set; } = "";
[JsonPropertyName("state")] public StateDto? State { get; set; }
[JsonPropertyName("schedule")] public SchedulePayloadDto? Schedule { get; set; }
[JsonPropertyName("timeLayout")] public TimeLayoutPayloadDto? TimeLayout { get; set; }
}
public class ReportSettingsDto
{
[JsonPropertyName("mirrorScheduleToClassIsland")] public bool MirrorScheduleToClassIsland { get; set; }
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
/// <summary>
/// 服务端请求**立刻上报一次课表**(老师在 Web 端点了「从教室机器获取课表」)。
///
/// 它与本机的「上报课表到 ClassHelper」开关无关:那个开关管的是"要不要**自动**回传",
/// 现在是默认关闭的;而这是一次显式的人工请求,看到就要照做。
/// </summary>
[JsonPropertyName("scheduleRequested")] public bool ScheduleRequested { get; set; }
}
public class ReportResultDto
{
[JsonPropertyName("scheduleApplied")] public bool ScheduleApplied { get; set; }
[JsonPropertyName("scheduleCreated")] public int ScheduleCreated { get; set; }
[JsonPropertyName("scheduleUpdated")] public int ScheduleUpdated { get; set; }
[JsonPropertyName("courses")] public List<string> Courses { get; set; } = new();
[JsonPropertyName("timeLayoutApplied")] public bool TimeLayoutApplied { get; set; }
[JsonPropertyName("week")] public int Week { get; set; }
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
[JsonPropertyName("settings")] public ReportSettingsDto? Settings { get; set; }
[JsonPropertyName("pendingNotification")] public PushNotificationDto? PendingNotification { get; set; }
}
/// <summary>服务端下发的提醒。</summary>
public class PushNotificationDto
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("title")] public string Title { get; set; } = "";
[JsonPropertyName("content")] public string Content { get; set; } = "";
[JsonPropertyName("durationSeconds")] public int DurationSeconds { get; set; } = 8;
[JsonPropertyName("speechContent")] public string? SpeechContent { get; set; }
[JsonPropertyName("createdAt")] public string CreatedAt { get; set; } = "";
[JsonPropertyName("urgent")] public bool Urgent { get; set; }
/// <summary>提醒类型:notification(默认)/ call(叫人,算"主动通知",上课时段也立刻弹)</summary>
[JsonPropertyName("kind")] public string? Kind { get; set; }
[JsonPropertyName("classId")] public string ClassId { get; set; } = "";
[JsonPropertyName("className")] public string? ClassName { get; set; }
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
}
public class PendingResultDto
{
[JsonPropertyName("notifications")] public List<PushNotificationDto> Notifications { get; set; } = new();
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
[JsonPropertyName("week")] public int Week { get; set; }
}
/* ---------------------------------------------------------------- 课表镜像(服务端 → ClassIsland) */
public class ClassPlanEntryMirrorDto
{
[JsonPropertyName("weekDay")] public int WeekDay { get; set; }
[JsonPropertyName("weekCountDiv")] public int WeekCountDiv { get; set; }
[JsonPropertyName("weekCountDivTotal")] public int WeekCountDivTotal { get; set; }
[JsonPropertyName("subject")] public string Subject { get; set; } = "";
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
/// <summary>起止时间(HH:mm)。Classes[i] 必须与第 i 个上课时间点对齐,靠它重建时间表。</summary>
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
[JsonPropertyName("timeLayoutId")] public string TimeLayoutId { get; set; } = "";
}
public class TimeLayoutMirrorDto
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("layouts")] public List<TimeLayoutItemMirrorDto> Layouts { get; set; } = new();
}
public class TimeLayoutItemMirrorDto
{
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
[JsonPropertyName("timeType")] public int TimeType { get; set; }
}
public class ClassPlanMirrorDto
{
[JsonPropertyName("entries")] public List<ClassPlanEntryMirrorDto> Entries { get; set; } = new();
[JsonPropertyName("timeLayouts")] public List<TimeLayoutMirrorDto> TimeLayouts { get; set; } = new();
[JsonPropertyName("profileName")] public string ProfileName { get; set; } = "";
[JsonPropertyName("termStartDate")] public string TermStartDate { get; set; } = "";
}
/// <summary>服务端逐周配的学期周次区间(管理员在「学期周次」里维护)。</summary>
public class TermWeekRangeDto
{
[JsonPropertyName("weekNumber")] public int WeekNumber { get; set; }
/// <summary>YYYY-MM-DD</summary>
[JsonPropertyName("startDate")] public string StartDate { get; set; } = "";
/// <summary>YYYY-MM-DD</summary>
[JsonPropertyName("endDate")] public string EndDate { get; set; } = "";
}
public class PullResultDto
{
[JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; }
[JsonPropertyName("week")] public int Week { get; set; }
/// <summary>
/// 学期逐周区间;空 = 服务端只按学期开始日期线性推算。
///
/// ClassIsland 自身只认"教学周序号 + 单双周",没有"按日期区间定义某一周"的模型
/// (本机装的 2.1.0.1 没有该字段),所以这份区间在插件侧只用于:
/// 1) 校正"现在是第几周"(调休会让线性推算整体错位);
/// 2) 在设置页里显示教室这台机器当前处在哪一段日期区间,方便老师核对。
/// 课表本身的周次口径仍由 <see cref="ClassPlanMirrorDto.TermStartDate"/> 与单双周表达。
/// </summary>
[JsonPropertyName("weekRanges")] public List<TermWeekRangeDto> WeekRanges { get; set; } = new();
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
}
/* ---------------------------------------------------------------- 统一响应体 */
internal class ApiEnvelope<T>
{
[JsonPropertyName("success")] public bool Success { get; set; }
[JsonPropertyName("data")] public T? Data { get; set; }
[JsonPropertyName("message")] public string Message { get; set; } = "";
[JsonPropertyName("code")] public string? Code { get; set; }
}
/// <summary>调用 ClassHelper后端的结果(不抛异常,便于设置页展示原因)。</summary>
public class ApiCallResult
{
public bool Ok { get; init; }
public string Message { get; init; } = "";
public bool NetworkError { get; init; }
public static ApiCallResult Success(string message) => new() { Ok = true, Message = message };
public static ApiCallResult Failure(string message) => new() { Ok = false, Message = message };
public static ApiCallResult Offline(string message) => new() { Ok = false, Message = message, NetworkError = true };
}
/// <summary>
/// ClassHelper后端客户端。
///
/// 只用 <see cref="HttpClient"/> + System.Text.Json:不引入额外依赖包,
/// 避免插件加载时因为"插件程序集隔离"缺少依赖而无法启动
/// (见 https://docs.classisland.tech/dev/plugins/basics.html 的程序集隔离说明)。
/// </summary>
public sealed class ClassHelperClient : IDisposable
{
/// <summary>
/// 单次请求超时。教室机器可能是内网慢链路,但也不该卡住上报线程太久。
/// </summary>
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(12);
/// <summary>
/// 响应体上限(4MB)。默认是 2GB —— 服务器地址填错(指到一个会吐大页面的地址)时,
/// 一次请求就能在 12 秒内把内存吃光。
/// </summary>
private const long MaxResponseBytes = 4 * 1024 * 1024;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>
/// 与 ClassHelper通信的 HttpClient。
///
/// **必须显式处理代理**:教室机器(以及开发机)常常配了系统代理,
/// 而 Windows 的代理设置默认连 `127.0.0.1` 也会走代理 —— 于是"填写令牌后立刻 HTTP 404",
/// 那个 404 其实是**代理自己的错误页**,跟本服务毫无关系。
/// 因此这里用 <see cref="LocalAwareProxy"/>:本地/内网地址直连,公网地址照旧走系统代理。
/// </summary>
private readonly HttpClient _http = new(new SocketsHttpHandler { Proxy = new LocalAwareProxy() })
{
Timeout = Timeout,
MaxResponseContentBufferSize = MaxResponseBytes,
};
public string LastError { get; private set; } = "";
public DateTimeOffset? LastSuccessAt { get; private set; }
/// <summary>
/// 包装系统代理:**本地/内网地址标记为"绕过"**,其余交给系统代理决定。
///
/// 判定为本地/内网的情形:localhost、回环地址、IPv4/IPv6 私有段与链路本地段,
/// 以及不含点的主机名(`server` / `classhelper` 这类校内机器名,DNS 也可能解析不出来)。
/// 判定为公网时保持原行为,避免"云服务器 + 公司代理"这种场景被我们改坏。
/// </summary>
private sealed class LocalAwareProxy : IWebProxy
{
private readonly IWebProxy _inner = HttpClient.DefaultProxy;
// 只读透传即可:HttpClient.DefaultProxy 是全局单例,改它的凭据会波及宿主进程里的其它请求
public ICredentials? Credentials { get; set; }
public Uri? GetProxy(Uri destination) => _inner.GetProxy(destination);
public bool IsBypassed(Uri host) => IsLocalHost(host) || _inner.IsBypassed(host);
/// <summary>该地址是否属于"不该走代理"的本地/内网范围。</summary>
internal static bool IsLocalHost(Uri uri)
{
var host = uri.Host;
if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) return true;
// 不含点的主机名(校内机器名)走直连:解析成内网 IP 的可能性远大于公网
if (!host.Contains('.')) return true;
if (!IPAddress.TryParse(host, out var address)) return false;
if (IPAddress.IsLoopback(address)) return true;
if (address.AddressFamily == AddressFamily.InterNetwork)
{
var bytes = address.GetAddressBytes();
return bytes[0] == 10 // 10.0.0.0/8
|| (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) // 172.16.0.0/12
|| (bytes[0] == 192 && bytes[1] == 168) // 192.168.0.0/16
|| (bytes[0] == 169 && bytes[1] == 254); // 169.254.0.0/16(链路本地)
}
// IPv6:fc00::/7(唯一本地地址)与 fe80::/10(链路本地)
var first = address.GetAddressBytes()[0];
return (first & 0xFE) == 0xFC || (first & 0xC0) == 0x80;
}
}
/// <summary>拼接 API 地址,容忍用户填了带/不带末尾斜杠、甚至带 /api 后缀的地址。</summary>
private static string BuildUrl(string serverUrl, string path)
{
var baseUrl = (serverUrl ?? "").Trim().TrimEnd('/');
if (baseUrl.EndsWith("/api", StringComparison.OrdinalIgnoreCase))
{
baseUrl = baseUrl[..^4];
}
return $"{baseUrl}/api/integrations{path}";
}
private HttpRequestMessage BuildRequest(HttpMethod method, string serverUrl, string path, string token)
{
var request = new HttpRequestMessage(method, BuildUrl(serverUrl, path));
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.TryAddWithoutValidation("X-ClassIsland-Token", token);
}
return request;
}
private async Task<ApiEnvelope<T>?> SendAsync<T>(
HttpMethod method,
string serverUrl,
string path,
string token,
object? body,
CancellationToken cancellationToken)
{
try
{
using var request = BuildRequest(method, serverUrl, path, token);
if (body is not null)
{
var json = JsonSerializer.Serialize(body, JsonOptions);
request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
}
using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
ApiEnvelope<T>? envelope = null;
try
{
envelope = JsonSerializer.Deserialize<ApiEnvelope<T>>(text, JsonOptions);
}
catch (JsonException)
{
// 非 JSON 响应(例如被网关拦截)——交给下面的兜底分支
}
if (!response.IsSuccessStatusCode)
{
var message = envelope?.Message;
if (string.IsNullOrWhiteSpace(message))
{
// 响应不是本服务的统一响应体:最常见的原因就是被系统代理 / 网关拦下来
// (典型表现:HTTP 404 且 body 是一张 HTML 错误页)。把 URL 一起报出来,
// 老师截图给我们就能一眼定位。
message = envelope is null
? $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确、" +
"或该系统代理是否拦截了内网请求"
: $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})";
}
LastError = message!;
return envelope ?? new ApiEnvelope<T> { Success = false, Message = message! };
}
if (envelope is null)
{
// 2xx 但响应不是本服务的统一响应体(网关/反代的 HTML 错误页、地址指到了别的服务等)。
// 必须留下原因:否则调用方拿到的是"成功但无数据 + 空错误消息",
// 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。
LastError =
$"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
return new ApiEnvelope<T> { Success = false, Message = LastError };
}
LastError = "";
LastSuccessAt = DateTimeOffset.Now;
return envelope;
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
LastError = "请求超时(请检查服务器地址与网络)";
return null;
}
catch (HttpRequestException exception)
{
LastError = $"无法连接 ClassHelper服务:{exception.Message}";
return null;
}
catch (Exception exception)
{
LastError = exception.Message;
return null;
}
}
/// <summary>上报状态 +(可选)课表。</summary>
public async Task<(ApiCallResult Result, ReportResultDto? Data)> ReportAsync(
PluginSettings settings,
ReportRequestDto payload,
CancellationToken cancellationToken)
{
var envelope = await SendAsync<ReportResultDto>(
HttpMethod.Post, settings.ServerUrl, "/classisland/report", settings.DeviceToken, payload, cancellationToken);
if (envelope is null)
{
return (ApiCallResult.Offline(LastError), null);
}
if (!envelope.Success || envelope.Data is null)
{
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError), null);
}
return (ApiCallResult.Success(envelope.Message), envelope.Data);
}
/// <summary>拉取尚未确认的下发提醒(插件启动/重连时补齐)。</summary>
public async Task<(ApiCallResult Result, List<PushNotificationDto> Items)> PendingAsync(
PluginSettings settings,
CancellationToken cancellationToken)
{
var envelope = await SendAsync<PendingResultDto>(
HttpMethod.Get, settings.ServerUrl, "/classisland/pending", settings.DeviceToken, null, cancellationToken);
if (envelope is null) return (ApiCallResult.Offline(LastError), new List<PushNotificationDto>());
if (!envelope.Success || envelope.Data is null)
{
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError),
new List<PushNotificationDto>());
}
return (ApiCallResult.Success(envelope.Message), envelope.Data.Notifications);
}
/// <summary>确认提醒已弹出(之后服务端不再补发)。</summary>
public async Task<ApiCallResult> AckAsync(PluginSettings settings, string id, CancellationToken cancellationToken)
{
var envelope = await SendAsync<JsonElement>(
HttpMethod.Post, settings.ServerUrl, "/classisland/ack", settings.DeviceToken, new { id }, cancellationToken);
if (envelope is null) return ApiCallResult.Offline(LastError);
return envelope.Success
? ApiCallResult.Success("已确认")
: ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError);
}
/// <summary>拉取班级课表(用于镜像回 ClassIsland)。未开启镜像时服务端返回 data=null。</summary>
public async Task<(ApiCallResult Result, ClassPlanMirrorDto? Plan)> PullClassPlanAsync(
PluginSettings settings,
CancellationToken cancellationToken)
{
var envelope = await SendAsync<PullResultDto>(
HttpMethod.Get, settings.ServerUrl, "/classisland/class-plan", settings.DeviceToken, null, cancellationToken);
if (envelope is null) return (ApiCallResult.Offline(LastError), null);
if (!envelope.Success)
{
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError), null);
}
if (envelope.Data?.ClassPlan is null)
{
return (ApiCallResult.Failure(envelope.Message.Length > 0
? envelope.Message
: "该设备未开启「把班级课表镜像到 ClassIsland」"), null);
}
return (ApiCallResult.Success(envelope.Message), envelope.Data.ClassPlan);
}
public void Dispose() => _http.Dispose();
}
@@ -1,186 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClassHelper.ClassIslandPlugin.Models;
/// <summary>
/// 插件设置。
///
/// 存储由 ClassIsland 负责:插件入口把它保存在 <c>PluginConfigFolder/Settings.json</c>
/// (**不要**放在插件安装目录,那里不会随档案备份、升级时还会被清掉)。
/// 因此这里必须继承 <see cref="ObservableObject"/> 并让每个属性都走 <c>SetProperty</c>:
/// ClassIsland 通过监听 <c>PropertyChanged</c> 来即时保存。
/// </summary>
public class PluginSettings : ObservableObject
{
private string _serverUrl = "http://127.0.0.1:4000";
private string _deviceToken = "";
private string _deviceKey = "";
private bool _autoReport = true;
private int _reportIntervalSeconds = 60;
// 同步方向(2026-10-06 起):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
// 「把教室课表传上去」改为 Web 端的一次性人工动作(点之前会弹警告)。
// 自动回传会悄悄盖掉老师刚手排的课,所以默认关闭。
private bool _uploadSchedule = false;
private bool _mirrorSchedule = true;
private bool _receiveNotifications = true;
private int _notificationDurationSeconds = 8;
private int _notificationPollSeconds = 10;
private bool _speechEnabled = false;
private bool _showDebugInfo = false;
/// <summary>
/// ClassHelper后端地址,例如 <c>http://127.0.0.1:4000</c>。
/// </summary>
public string ServerUrl
{
get => _serverUrl;
set => SetProperty(ref _serverUrl, value);
}
/// <summary>
/// 设备令牌(形如 <c>chci_...</c>):在 ClassHelper Web 端「ClassIsland 联动」页面生成。
/// </summary>
public string DeviceToken
{
get => _deviceToken;
// 在 setter 里就去掉首尾空白:粘贴令牌时带前导空格非常常见,
// 而 Normalize() 只在启动加载时跑一次 —— 运行期粘进来的脏值会让 IsConfigured
// 一直为 false,用户看着明明填好了却查不出原因。
set => SetProperty(ref _deviceToken, (value ?? "").Trim());
}
/// <summary>
/// 本机标识(首次生成后固定):服务端据此把同一台机器识别为同一设备,重装插件不会产生重复设备。
/// </summary>
public string DeviceKey
{
get => _deviceKey;
set => SetProperty(ref _deviceKey, value);
}
/// <summary>
/// 是否自动上报:启动后、课表变化时、以及每隔 <see cref="ReportIntervalSeconds"/> 秒各上报一次。
/// </summary>
public bool AutoReport
{
get => _autoReport;
set => SetProperty(ref _autoReport, value);
}
/// <summary>
/// 定时上报间隔(秒),最小 15 秒。
/// </summary>
public int ReportIntervalSeconds
{
get => _reportIntervalSeconds;
set => SetProperty(ref _reportIntervalSeconds, Math.Clamp(value, 15, 3600));
}
/// <summary>
/// 是否把 ClassIsland 的课表**自动**同步到 ClassHelper。
///
/// **默认关闭**:课表以 ClassHelper为准,教室这边改完课表不会自己传上去
/// (否则老师刚在 ClassHelper上排好的课会被教室的旧课表悄悄覆盖)。
/// 要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,
/// 与本开关无关(见 <c>ReportSettingsDto.ScheduleRequested</c>)。
/// 关闭后仍会上报"当前上什么课"的状态。
/// </summary>
public bool UploadSchedule
{
get => _uploadSchedule;
set => SetProperty(ref _uploadSchedule, value);
}
/// <summary>
/// 是否把 ClassHelper上排好的课表镜像回 ClassIsland
/// (新建一份名为「ClassHelper-&lt;班级名&gt;」的档案课表,不覆盖老师原有的课表)。
///
/// **默认开启**:这是现在课表同步的主方向。
/// </summary>
public bool MirrorSchedule
{
get => _mirrorSchedule;
set => SetProperty(ref _mirrorSchedule, value);
}
/// <summary>
/// 是否接收老师在 ClassHelper上发布的提醒,并在 ClassIsland 上弹出。
/// </summary>
public bool ReceiveNotifications
{
get => _receiveNotifications;
set => SetProperty(ref _receiveNotifications, value);
}
/// <summary>
/// 提醒轮询间隔(秒),最小 5 秒。
///
/// 插件与后端之间没有长连接(刻意不引入 Socket 依赖),提醒是"轮询取回"的,
/// 因此这个值就是**老师按下发送到教室弹出之间的最大延迟**。
/// 单独一个轻量轮询(只查待提醒,不上传课表)比把上报间隔改小划算得多。
/// </summary>
public int NotificationPollSeconds
{
get => _notificationPollSeconds;
set => SetProperty(ref _notificationPollSeconds, Math.Clamp(value, 5, 600));
}
/// <summary>
/// 默认提醒显示时长(秒);老师在下发时可以单独指定更长的时长。
/// </summary>
public int NotificationDurationSeconds
{
get => _notificationDurationSeconds;
set => SetProperty(ref _notificationDurationSeconds, Math.Clamp(value, 1, 120));
}
/// <summary>
/// 提醒弹出时是否语音朗读(沿用 ClassIsland 的语音设置与语音提供方)。
/// </summary>
public bool SpeechEnabled
{
get => _speechEnabled;
set => SetProperty(ref _speechEnabled, value);
}
/// <summary>
/// 是否在设置页显示调试信息(最后一次上报结果、课表条目数等),排查问题时打开。
/// </summary>
public bool ShowDebugInfo
{
get => _showDebugInfo;
set => SetProperty(ref _showDebugInfo, value);
}
/// <summary>
/// 兼容旧配置:反序列化后把 DeviceKey 补齐(首次运行时由服务生成)。
/// </summary>
public void Normalize()
{
ServerUrl = (ServerUrl ?? "").Trim().TrimEnd('/');
DeviceToken = (DeviceToken ?? "").Trim();
DeviceKey = (DeviceKey ?? "").Trim();
ReportIntervalSeconds = Math.Clamp(ReportIntervalSeconds, 15, 3600);
NotificationDurationSeconds = Math.Clamp(NotificationDurationSeconds, 1, 120);
NotificationPollSeconds = Math.Clamp(NotificationPollSeconds, 5, 600);
}
/// <summary>是否已经配置好(地址 + 令牌都填了)。</summary>
public bool IsConfigured =>
TryGetServerUri(out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal);
/// <summary>
/// 解析服务器地址,**只接受 http/https 绝对地址**。
///
/// 不用裸的 `Uri.TryCreate(..., UriKind.Absolute)`:它会接受 `file://` / `ftp://`,
/// 那种地址会在发请求时抛一句难以理解的 NotSupportedException,用户根本猜不到是地址填错了。
/// </summary>
public bool TryGetServerUri(out Uri uri)
{
uri = null!;
if (!Uri.TryCreate((ServerUrl ?? "").Trim(), UriKind.Absolute, out var parsed)) return false;
if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) return false;
uri = parsed;
return true;
}
}
-171
View File
@@ -1,171 +0,0 @@
using System.Text.Json;
using ClassHelper.ClassIslandPlugin.Models;
using ClassHelper.ClassIslandPlugin.Services;
using ClassHelper.ClassIslandPlugin.Views;
using ClassIsland.Core;
using ClassIsland.Core.Abstractions;
using ClassIsland.Core.Abstractions.Services;
using ClassIsland.Core.Attributes;
using ClassIsland.Core.Extensions.Registry;
using ClassIsland.Shared;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ClassHelper.ClassIslandPlugin;
/// <summary>
/// 插件入口。
///
/// 职责只有三件:读配置 → 注册扩展(提醒提供方 / 设置页 / 联动服务)→ 在应用启动后接上课程服务。
/// 真正的联动逻辑都在 <see cref="BridgeService"/> 里,入口保持"看一眼就知道装了哪些东西"。
/// </summary>
[PluginEntrance]
public class Plugin : PluginBase
{
/// <summary>插件设置文件名。存在 <see cref="PluginBase.PluginConfigFolder"/> 下:随档案备份走,升级插件不丢。</summary>
private const string SettingsFileName = "Settings.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>插件设置。注册成单例后,设置页与 <see cref="BridgeService"/> 拿到的是同一个对象。</summary>
public PluginSettings Settings { get; private set; } = new();
/// <summary>联动只接一次(AppStarted 与"运行中启用"两条路都会走到这里)</summary>
private bool _bridgeStarted;
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
Settings = LoadSettings();
// 设置页与联动服务共用一个实例:设置页改了值,服务立刻能看到(不需要重启 ClassIsland)
services.AddSingleton(Settings);
services.AddSingleton<BridgeService>();
services.AddSettingsPage<BridgeSettingsPage>();
services.AddNotificationProvider<ClassHelperNotificationProvider>();
// 初始化阶段课程服务还没准备好(它在主机启动流程里才被创建),
// 因此这里先挂一个"应用已启动"的回调,等到那时再取 ILessonsService 并开始上报。
var application = AppBase.Current;
if (application is null)
{
Logger?.LogError("ClassHelper 联动:拿不到 ClassIsland 应用实例,联动未启动");
return;
}
application.AppStarted += (_, _) => StartBridge();
// 插件是"运行中才被启用"时,AppStarted 早就过去了 —— 主窗口已经存在就直接接一次
if (application.MainWindow is not null) StartBridge();
}
private void StartBridge()
{
if (_bridgeStarted) return;
try
{
var lessons = IAppHost.TryGetService<ILessonsService>();
if (lessons is null)
{
Logger?.LogError("ClassHelper 联动:拿不到课程服务,联动已停用");
return;
}
var bridge = IAppHost.TryGetService<BridgeService>();
if (bridge is null)
{
Logger?.LogError("ClassHelper 联动:拿不到联动服务,联动已停用");
return;
}
_bridgeStarted = true;
bridge.Attach(lessons);
bridge.RestartTimer();
// 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知):
// 不这么做的话,最长要等一个上报间隔才会弹出来
_ = bridge.PullPendingAsync();
Logger?.LogInformation("ClassHelper 联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
}
catch (Exception exception)
{
// 联动失败绝不能把 ClassIsland 本体拖下水
Logger?.LogError(exception, "ClassHelper 联动:初始化联动服务失败");
}
}
private ILogger<Plugin>? Logger => IAppHost.TryGetService<ILogger<Plugin>>();
/// <summary>
/// 读取设置。首次运行时 <c>DeviceKey</c> 为空,这里补一个随机值并立即落盘 ——
/// 服务端用它把同一台机器识别为同一设备,重装插件不会堆出重复设备。
/// </summary>
private PluginSettings LoadSettings()
{
var path = Path.Combine(PluginConfigFolder, SettingsFileName);
var settings = new PluginSettings();
try
{
if (File.Exists(path))
{
var loaded = JsonSerializer.Deserialize<PluginSettings>(File.ReadAllText(path), JsonOptions);
if (loaded is not null) settings = loaded;
}
}
catch (Exception exception)
{
// 配置损坏不该拦住插件加载:用默认值继续,原文件保留供排查
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
"ClassHelper 联动:读取设置失败,已回退到默认设置({Path})", path);
}
var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey);
settings.Normalize();
if (needGenerateKey)
{
// 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车。
// 注意长度:原先写的是 `[..Math.Min(24, machine.Length + 33)]`,而 machine.Length + 33
// 恒大于 24,所以实际**永远只取前 24 个字符** —— 机器名稍长就把随机段整个切掉,
// "同名机器不撞车"的保障就没了。这里改成"机器名按需截短 + 保留 8 位随机段"。
var machine = Environment.MachineName.ToLowerInvariant();
var suffix = Guid.NewGuid().ToString("N")[..8];
var maxMachineLength = Math.Max(4, 24 - suffix.Length - 1);
if (machine.Length > maxMachineLength) machine = machine[..maxMachineLength];
settings.DeviceKey = $"{machine}-{suffix}";
}
Settings = settings;
// 落盘:一是固定首次生成的 DeviceKey,二是把缺字段的旧配置补齐成完整文件
SaveSettings(settings, path);
// 设置变化时即时保存
settings.PropertyChanged += (_, _) => SaveSettings(settings, path);
return settings;
}
private static void SaveSettings(PluginSettings settings, string path)
{
try
{
var folder = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
// 先写临时文件再原子替换。
//
// 设置页的地址/令牌输入框是 TwoWay 绑定(逐字符提交),直接 `File.WriteAllText`
// 会就地截断原文件;一旦此刻进程被杀或断电,留下的是半截 JSON,而 LoadSettings 的
// catch 会**静默回退到默认设置** —— 用户看到的是"令牌和地址莫名其妙没了",
// 而且原文件已被覆盖,无从恢复。
var temp = path + ".tmp";
File.WriteAllText(temp, JsonSerializer.Serialize(settings, JsonOptions));
File.Move(temp, path, overwrite: true);
}
catch (Exception exception)
{
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
"ClassHelper 联动:保存设置失败({Path})", path);
}
}
}
@@ -1,640 +0,0 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text;
using Avalonia.Threading;
using ClassHelper.ClassIslandPlugin.Interop;
using ClassHelper.ClassIslandPlugin.Models;
using ClassIsland.Core;
using ClassIsland.Core.Abstractions.Services;
using ClassIsland.Core.Models.Notification;
using ClassIsland.Shared;
using Microsoft.Extensions.Logging;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 联动主循环。三件事:
/// 1. <b>上报</b>:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒,
/// 把"当前上什么课"与(可选的)全量课表推给 ClassHelper;
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
/// 3. <b>镜像</b>:开启镜像开关时,把 ClassHelper上排好的课表写回 ClassIsland 档案。
///
/// 为什么用"上报即拉取"而不是长连接:
/// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题),
/// 而教室机器的上报间隔本身就是心跳 —— 顺路把待提醒带回来,依赖更少、故障面更小。
///
/// 线程约定:HttpClient 回调在线程池上,因此所有跨线程可见的状态都用 <c>lock</c> 或
/// 赋值引用(<c>string</c> / <c>int</c>)保护,属性变化通过 <see cref="PropertyChanged"/> 通知设置页。
/// </summary>
public sealed class BridgeService : INotifyPropertyChanged, IDisposable
{
private readonly PluginSettings _settings;
private readonly ILogger<BridgeService> _logger;
private readonly ClassHelperClient _client = new();
private readonly object _gate = new();
private ILessonsService? _lessons;
private Timer? _timer;
private Timer? _notifyTimer;
private bool _disposed;
private bool _reporting;
private bool _pulling;
private bool _eventsHooked;
/// <summary>
/// 上次成功镜像的课表内容指纹。内容没变就完全跳过镜像:既省掉每分钟一次的全量重建 + 落盘,
/// 也把"跨线程改档案"的窗口从"每分钟一次"降回"只在课表真的变化时"。
/// </summary>
private volatile string? _lastMirrorSignature;
public BridgeService(PluginSettings settings, ILogger<BridgeService> logger)
{
_settings = settings;
_logger = logger;
}
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>上报结果变化时触发(设置页据此刷新)。</summary>
public event EventHandler? StateChanged;
/// <summary>需要把提醒丢给提醒提供方去显示。</summary>
public event EventHandler<PushNotificationDto>? NotificationReceived;
/* ---------------------------------------------------------------- 供设置页绑定的状态 */
private string _lastReportMessage = "尚未上报";
/// <summary>最近一次上报的结果描述。</summary>
public string LastReportMessage
{
get => _lastReportMessage;
private set => SetField(ref _lastReportMessage, value);
}
private bool _lastReportOk;
public bool LastReportOk
{
get => _lastReportOk;
private set => SetField(ref _lastReportOk, value);
}
private DateTimeOffset? _lastReportAt;
public DateTimeOffset? LastReportAt
{
get => _lastReportAt;
private set
{
if (SetField(ref _lastReportAt, value)) Raise(nameof(LastReportAtText));
}
}
/// <summary>供界面直接显示的"最后上报时间"。</summary>
public string LastReportAtText => LastReportAt is null
? "从未"
: LastReportAt.Value.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
private int _lastScheduleEntryCount;
/// <summary>最近一次上报里的课表条目数。</summary>
public int LastScheduleEntryCount
{
get => _lastScheduleEntryCount;
private set => SetField(ref _lastScheduleEntryCount, value);
}
private int _lastTimeLayoutItemCount;
/// <summary>最近一次上报里的节次数量。</summary>
public int LastTimeLayoutItemCount
{
get => _lastTimeLayoutItemCount;
private set => SetField(ref _lastTimeLayoutItemCount, value);
}
private int? _lastServerWeek;
/// <summary>服务端返回的教学周。</summary>
public int? LastServerWeek
{
get => _lastServerWeek;
private set
{
if (SetField(ref _lastServerWeek, value)) Raise(nameof(ClientTimeOffsetText));
}
}
private int _notificationCount;
/// <summary>已弹出并回执的提醒数量。</summary>
public int NotificationCount
{
get => _notificationCount;
private set => SetField(ref _notificationCount, value);
}
private bool _busy;
/// <summary>是否正在上报("立即上报"按钮据此禁用)。</summary>
public bool Busy
{
get => _busy;
private set => SetField(ref _busy, value);
}
private TimeSpan? _serverTimeOffset;
/// <summary>服务端与本机的时钟差(诊断用:差得多说明教室机器时间不对)。</summary>
public string ClientTimeOffsetText => _serverTimeOffset is null
? "—"
: $"{_serverTimeOffset.Value.TotalSeconds:+0;-0;0} 秒";
/// <summary>当前配置是否完整(设置页据此提示"还没填令牌")。</summary>
public bool IsConfigured => _settings.IsConfigured;
/// <summary>是否已接入课程服务(拿到 ILessonsService 才算真正跑起来)。</summary>
public bool IsAttached
{
get { lock (_gate) return _lessons is not null; }
}
/* ---------------------------------------------------------------- 生命周期 */
/// <summary>接入课程服务并订阅课程事件(由插件入口在 AppStarted 时调用)。</summary>
public void Attach(ILessonsService lessons)
{
lock (_gate)
{
_lessons = lessons;
}
if (!_eventsHooked)
{
// 上课 / 下课 / 放学 / 状态变化时立刻上报一次,不等到下一个定时点。
// 这些事件在 ClassIsland 的公开约定里是在**主线程**触发的,可以直接订阅。
lessons.OnClass += (_, _) => RequestReport("上课");
lessons.OnBreakingTime += (_, _) => RequestReport("下课");
lessons.OnAfterSchool += (_, _) => RequestReport("放学");
lessons.CurrentTimeStateChanged += (_, _) => RequestReport("状态变化");
_eventsHooked = true;
}
Raise(nameof(IsAttached));
_logger.LogInformation("ClassHelper 联动:已订阅课程事件");
RequestReport("应用启动");
}
/// <summary>按设置重建定时器(设置页改间隔后调用)。</summary>
public void RestartTimer()
{
lock (_gate)
{
_timer?.Dispose();
_timer = null;
_notifyTimer?.Dispose();
_notifyTimer = null;
if (!_settings.IsConfigured) return;
if (_settings.AutoReport)
{
var interval = TimeSpan.FromSeconds(Math.Clamp(_settings.ReportIntervalSeconds, 15, 3600));
_timer = new Timer(_ => RequestReport("定时"), null, interval, interval);
}
// 提醒单独用一个轻量轮询(只查待提醒,不上传课表):
// 老师按下发送到教室弹出之间的延迟就是这个间隔,因此默认 10 秒,不跟随上报间隔。
if (_settings.ReceiveNotifications)
{
var poll = TimeSpan.FromSeconds(Math.Clamp(_settings.NotificationPollSeconds, 5, 600));
_notifyTimer = new Timer(_ => _ = PullPendingAsync(), null, poll, poll);
}
}
}
/// <summary>发起一次上报(事件驱动:已有上报在跑时直接丢弃本次,避免事件风暴打出并发请求)。</summary>
public void RequestReport(string reason)
{
_ = ReportAsync(reason);
}
/// <summary>
/// 执行一次上报并返回是否真的发出去了。
/// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。
/// </summary>
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true, bool forceSchedule = false)
{
if (_disposed) return false;
ILessonsService? lessons;
lock (_gate) lessons = _lessons;
if (lessons is null) return false;
if (!_settings.IsConfigured)
{
SetState(false, "尚未配置服务器地址或设备令牌", 0, 0, null);
return false;
}
lock (_gate)
{
if (_reporting) return false;
_reporting = true;
}
Busy = true;
try
{
var profile = ResolveProfile();
var request = new ReportRequestDto
{
PluginVersion = PluginVersion,
ClassIslandVersion = SafeAppVersion(),
// 机器码:设备创建时服务端只能用 pending-… 占位,靠这里回填,Web 端设备列表才能与教室机器对上
DeviceKey = _settings.DeviceKey,
State = BuildState(lessons),
};
var entryCount = 0;
var layoutCount = 0;
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
// forceSchedule:服务端显式点了「从教室机器获取课表」——这是一次人工请求,
// 不受本机「自动上报课表」开关影响(见下方 ReportSettingsDto.ScheduleRequested)
var uploadSchedule = forceSchedule || _settings.UploadSchedule;
if (includeSchedule && !uploadSchedule)
{
_logger.LogInformation(
"ClassHelper 联动:本机设置里「上报课表到 ClassHelper」是关闭的,本次只上报状态;" +
"需要把教室课表同步到 ClassHelper请打开该开关,或在 Web 端点「从教室机器获取课表」");
}
if (includeSchedule && uploadSchedule && profile is not null)
{
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
foreach (var warning in warnings) _logger.LogWarning("ClassHelper 联动:{Warning}", warning);
entryCount = entries.Count;
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
// 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**:
// 日志里只出现"仅状态"时,用户会以为上报失败,实际是 ClassIsland 里还没有课表。
if (entries.Count == 0)
{
request.Schedule = null;
_logger.LogWarning(
"ClassHelper 联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由 ClassHelper下发一份");
}
var layout = ResolvePrimaryTimeLayout(profile);
if (layout is not null)
{
var items = ScheduleMapper.MapTimeLayout(layout);
layoutCount = items.Count;
request.TimeLayout = new TimeLayoutPayloadDto
{
Name = ScheduleMapper.Truncate(
string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name,
ScheduleMapper.PlanNameMaxLength)!,
Mode = "replace",
Items = items,
};
}
}
var (result, data) = await _client.ReportAsync(_settings, request, CancellationToken.None)
.ConfigureAwait(false);
if (!result.Ok || data is null)
{
_logger.LogWarning("ClassHelper 联动上报失败({Reason}):{Message}", reason, result.Message);
SetState(false, result.Message, entryCount, layoutCount, null);
return false;
}
LastServerWeek = data.Week;
UpdateClockOffset(data.ServerTime);
if (data.ScheduleApplied)
{
_logger.LogInformation(
"ClassHelper 联动:课表已同步(新增 {Created} / 更新 {Updated})",
data.ScheduleCreated, data.ScheduleUpdated);
}
var detail = entryCount > 0
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
: uploadSchedule
? "仅状态(ClassIsland 里还没有启用的课表)"
: "仅状态(本机「上报课表到 ClassHelper」开关已关闭)";
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
entryCount, layoutCount, data.Week);
// 顺路把"还没弹出过的"提醒带回来
var pending = data.PendingNotification;
if (pending is not null && _settings.ReceiveNotifications)
{
NotificationReceived?.Invoke(this, pending);
}
// 服务端请求"立刻上报一次课表"(老师在 Web 端点了「从教室机器获取课表」)。
// 放在镜像之前:先把教室的现状推上去,再做服务端→教室的镜像,顺序反了会把刚拿到的课表盖掉。
if (data.Settings?.ScheduleRequested == true && !uploadSchedule)
{
_logger.LogInformation("ClassHelper 联动:服务端请求立即上报课表,正在补发一次…");
_ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true);
}
if (_settings.MirrorSchedule)
{
await MirrorScheduleAsync().ConfigureAwait(false);
}
return true;
}
catch (Exception exception)
{
// ReportAsync 是通过 `_ = ReportAsync(reason)` 这种 fire-and-forget 方式发起的
// (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的
// "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有,
// 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。
_logger.LogError(exception, "ClassHelper 联动:上报异常({Reason})", reason);
SetState(false, $"上报异常:{exception.Message}", 0, 0, null);
return false;
}
finally
{
Busy = false;
lock (_gate) _reporting = false;
}
}
/// <summary>
/// 拉取本班尚未弹出过的提醒并逐条交给提醒提供方(用于"应用刚起来还没到第一个上报点"的场景)。
/// </summary>
public async Task PullPendingAsync()
{
if (_disposed) return;
if (!_settings.IsConfigured || !_settings.ReceiveNotifications) return;
// 轮询与"上报顺路拉取"可能同时到达:同一时刻只放一个请求过去
lock (_gate)
{
if (_pulling) return;
_pulling = true;
}
try
{
var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false);
if (!result.Ok)
{
_logger.LogInformation("ClassHelper 联动:拉取待提醒失败({Message})", result.Message);
return;
}
foreach (var item in items)
{
_logger.LogInformation("ClassHelper 联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
NotificationReceived?.Invoke(this, item);
}
}
finally
{
lock (_gate) _pulling = false;
}
}
/// <summary>把服务端的一条提醒标记为已弹出。</summary>
public async Task AckAsync(string id)
{
var result = await _client.AckAsync(_settings, id, CancellationToken.None).ConfigureAwait(false);
if (result.Ok)
{
NotificationCount += 1;
_logger.LogInformation("ClassHelper 联动:已确认提醒 {Id}", id);
}
else
{
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
_logger.LogWarning("ClassHelper 联动:确认提醒失败 {Message}", result.Message);
}
}
/// <summary>把 ClassHelper上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
public async Task MirrorScheduleAsync()
{
var profile = ResolveProfile();
if (profile is null) return;
var (result, plan) = await _client.PullClassPlanAsync(_settings, CancellationToken.None).ConfigureAwait(false);
if (!result.Ok || plan is null)
{
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开
_logger.LogInformation(
"ClassHelper 联动:跳过课表镜像({Message})。" +
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
result.Message);
return;
}
// 内容没变就直接返回:上报是"启动 + 每个课程事件 + 每 N 秒"触发的,原先每次都要
// 重建全部课表对象并整体落盘 —— 无谓的网络/磁盘 I/O,还会把档案页刷得不停重绘。
var signature = MirrorSignature(plan);
if (signature == _lastMirrorSignature)
{
_logger.LogDebug("ClassHelper 联动:课表内容与上次一致,跳过镜像");
return;
}
try
{
// **必须在 UI 线程上改写档案**:ClassIsland 的档案是 ObservableDictionary /
// ObservableCollection,落盘是"属性变化即整体序列化 + FileStream 就地截断"。
// 而本方法是从线程池线程继续执行的(ReportAsync 里 ConfigureAwait(false) 之后,
// 定时器回调本就在线程池上),在线程池上改集合会与 UI 线程的保存/绑定并发:
// 轻则 InvalidOperationException(Collection was modified),重则两次写入互相截断,
// **把整个档案写成半截 JSON**(老师的课表/科目全丢,只能靠 .bak 兜底)。
var outcome = await Dispatcher.UIThread.InvokeAsync(() =>
{
var applied = ClassPlanWriter.Apply(profile, plan);
if (!applied.Applied)
{
return applied;
}
// 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘;
// 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。
IAppHost.TryGetService<IProfileService>()?.SaveProfile();
return applied;
});
if (!outcome.Applied)
{
_logger.LogInformation("ClassHelper 联动:课表镜像未生效({Reason})",
outcome.Warnings.FirstOrDefault() ?? "服务端课表为空");
return;
}
_lastMirrorSignature = signature;
_logger.LogInformation("ClassHelper 联动:已把班级课表写入 ClassIsland({Plans})",
string.Join("、", outcome.PlanNames.Select(name => $"「{name}」")));
foreach (var warning in outcome.Warnings)
{
_logger.LogWarning("ClassHelper 联动镜像提示:{Warning}", warning);
}
}
catch (Exception exception)
{
_logger.LogError(exception, "ClassHelper 联动:课表镜像失败");
}
}
/// <summary>镜像载荷的内容指纹(只用于"内容没变就跳过",不参与任何业务判断)</summary>
private static string MirrorSignature(ClassPlanMirrorDto plan)
{
var builder = new StringBuilder();
foreach (var entry in plan.Entries)
{
builder.Append(entry.WeekDay).Append('|').Append(entry.WeekCountDiv).Append('|')
.Append(entry.WeekCountDivTotal).Append('|').Append(entry.Subject).Append('|')
.Append(entry.StartTime).Append('|').Append(entry.EndTime).Append(';');
}
foreach (var layout in plan.TimeLayouts)
{
builder.Append('#').Append(layout.Id).Append(':');
foreach (var item in layout.Layouts)
{
builder.Append(item.StartTime).Append('-').Append(item.EndTime).Append('@')
.Append(item.TimeType).Append(',');
}
}
return builder.ToString();
}
/* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "1.3.0.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion()
{
try
{
return AppBase.AppVersion ?? "";
}
catch
{
return "";
}
}
/// <summary>
/// 取得当前状态快照。
/// <c>CurrentTimeLayoutItem</c> 在"没有课表"时是 <see cref="ClassIsland.Shared.Models.Profile.TimeLayoutItem.Empty"/>,
/// 语义上等价于"没有节次",因此要转成 null 而不是 00:00。
/// </summary>
private StateDto BuildState(ILessonsService lessons)
{
try
{
return ScheduleMapper.BuildState(lessons, LastServerWeek);
}
catch (Exception exception)
{
// 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报
_logger.LogWarning(exception, "ClassHelper 联动:读取课程状态失败,本次只上报心跳");
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
}
}
/// <summary>
/// 选一份"最有代表性"的时间表上报给 ClassHelper:**上课点最多的那一份**。
///
/// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表,
/// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去,
/// 服务端那边的节次时间表就退化了。取上课点最多的那份,得到的是完整作息。
/// 并列时优先"每周"型(单双周的两份内容一致,每周型更通用)。
/// </summary>
private static ClassIsland.Shared.Models.Profile.TimeLayout? ResolvePrimaryTimeLayout(
ClassIsland.Shared.Models.Profile.Profile profile)
{
if (profile.TimeLayouts.Count == 0) return null;
var best = profile.TimeLayouts
.Select(pair => (pair.Value, ClassPoints: pair.Value.Layouts.Count(item => item.TimeType == 0)))
.Where(item => item.ClassPoints > 0)
.OrderByDescending(item => item.ClassPoints)
.ToList();
if (best.Count > 0) return best[0].Value;
// 兜底:档案里被激活的时间表,最后才是任意一个
return profile.TimeLayouts.Values.FirstOrDefault(item => item.IsActivated)
?? profile.TimeLayouts.Values.First();
}
private static ClassIsland.Shared.Models.Profile.ClassPlan? CurrentClassPlan(
ClassIsland.Shared.Models.Profile.Profile profile)
{
// 用课程服务拿"今天该上哪张课表"最准
try
{
var lessons = IAppHost.TryGetService<ILessonsService>();
return lessons?.CurrentClassPlan;
}
catch
{
return null;
}
}
private ClassIsland.Shared.Models.Profile.Profile? ResolveProfile()
{
try
{
// 档案由 ProfileService 持有;这里用 TryGetService 避免服务未注册时抛异常
return IAppHost.TryGetService<IProfileService>()?.Profile;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "ClassHelper 联动:读取档案失败");
return null;
}
}
/// <summary>用服务端时间校准本机时钟差(只影响设置页展示,不改系统时间)。</summary>
private void UpdateClockOffset(string serverTime)
{
if (!DateTimeOffset.TryParse(serverTime, out var parsed)) return;
_serverTimeOffset = parsed - DateTimeOffset.Now;
Raise(nameof(ClientTimeOffsetText));
}
private void SetState(bool ok, string message, int entryCount, int layoutCount, int? week)
{
LastReportOk = ok;
LastReportMessage = message;
LastReportAt = DateTimeOffset.Now;
LastScheduleEntryCount = entryCount;
LastTimeLayoutItemCount = layoutCount;
if (week.HasValue) LastServerWeek = week;
StateChanged?.Invoke(this, EventArgs.Empty);
}
private void Raise(string propertyName) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
Raise(propertyName!);
return true;
}
public void Dispose()
{
_disposed = true;
lock (_gate)
{
_timer?.Dispose();
_timer = null;
_notifyTimer?.Dispose();
_notifyTimer = null;
}
_client.Dispose();
}
}
@@ -1,260 +0,0 @@
using Avalonia.Threading;
using ClassHelper.ClassIslandPlugin.Interop;
using ClassHelper.ClassIslandPlugin.Models;
using ClassIsland.Core.Abstractions.Services;
using ClassIsland.Shared;
using ClassIsland.Shared.Enums;
using ClassIsland.Core.Abstractions.Services.NotificationProviders;
using ClassIsland.Core.Attributes;
using ClassIsland.Core.Models.Notification;
using Microsoft.Extensions.Logging;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 提醒提供方:把老师在 ClassHelper上发布的提醒显示到 ClassIsland 上。
///
/// 与 <see cref="BridgeService"/> 的分工:
/// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack);
/// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的
/// 全屏特效 / 语音 / 音效那一整套流程。
///
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「ClassHelper 联动」设置页里,
/// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。
/// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。
/// </summary>
// 第 3 个参数是 Fluent 图标字体里的字形字符(ClassIsland 内置提醒提供方用的也是字形字符,
// 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。
[NotificationProviderInfo(
ProviderGuidValue,
"ClassHelper提醒",
"\uE8BD",
"显示老师从 ClassHelper下发的提醒。")]
public sealed class ClassHelperNotificationProvider : NotificationProviderBase
{
/// <summary>
/// 提醒提供方 GUID(全局唯一,别改:改了等于换了一个提供方,用户原有的提醒设置会失联)。
/// 名字带 Value 后缀是为了不和基类的 <see cref="NotificationProviderBase.ProviderGuid"/> 属性撞名。
/// </summary>
public const string ProviderGuidValue = "7C4B1F6E-2A85-4A1F-9E3C-5B7D0C1A9E42";
private readonly BridgeService _bridge;
private readonly PluginSettings _settings;
private readonly ILogger<ClassHelperNotificationProvider> _logger;
/// <summary>
/// 正在播放(尚未回执)的提醒 id。
///
/// 服务端在提醒被回执前会**一直**把它算作"待弹出",而插件是按固定间隔轮询取回的,
/// 因此时长超过轮询间隔的提醒会被反复取到 —— 没有这层去重就会一遍遍重复弹。
/// 只有播完(或用户关掉)才从集合里移除,那一刻才回执。
/// </summary>
private readonly HashSet<string> _playing = new();
/// <summary>
/// 上课时段被暂存的提醒(与客户端灵动岛同一套规则)。
///
/// 为什么要有它:老师在**上课时段**发通知,教室里正上着课,直接全屏弹会打断课堂。
/// 客户端的灵动岛就是"上课先收着、下课再弹",ClassIsland 这侧必须一致,
/// 否则学生在客户端看不到、却在 ClassIsland 上被打断。
///
/// 例外("主动通知"):紧急提醒与**叫人**,老师正在等学生,必须立刻弹。
/// </summary>
private readonly List<PushNotificationDto> _deferred = new();
private ILessonsService? _lessons;
private bool _lessonsHooked;
public ClassHelperNotificationProvider(
BridgeService bridge,
PluginSettings settings,
ILogger<ClassHelperNotificationProvider> logger)
{
_bridge = bridge;
_settings = settings;
_logger = logger;
_bridge.NotificationReceived += OnNotificationReceived;
// 尽力而为地先订阅一次;拿不到也没关系,后面每次需要判定时都会重试
// (本类的构造函数可能在课程服务建好之前就被 DI 调用)
EnsureLessons();
}
/// <summary>
/// 惰性获取课程服务并**只订阅一次**课程事件(下课后补弹上课时段暂存的提醒)。
///
/// 为什么不能只在构造函数里订阅:提供方是随主机启动被构造的,而课程服务在启动流程里的
/// 创建时机并不固定。一旦构造函数里拿到 null,原先的实现既不重试、也不打日志,
/// 于是 `IsInClass()` 恒为 false —— "上课时段先暂存、下课再补弹"这条规则**永久失效**,
/// 老师在课上发的通知会直接全屏打断课堂,而且完全静默、无从排查。
/// </summary>
private void EnsureLessons()
{
if (_lessonsHooked) return;
try
{
_lessons = IAppHost.TryGetService<ILessonsService>();
if (_lessons is null) return;
_lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessonsHooked = true;
_logger.LogInformation("ClassHelper 联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
}
catch (Exception exception)
{
_logger.LogWarning(exception, "ClassHelper 联动:订阅课程事件失败,将在下次提醒时重试");
}
}
/// <summary>当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢)</summary>
private bool IsInClass()
{
EnsureLessons();
try
{
return _lessons?.CurrentState == TimeState.OnClass;
}
catch
{
return false;
}
}
/// <summary>
/// 这条提醒是否必须立刻弹("主动通知"):
/// 紧急提醒,或叫人(老师正在等学生)。
/// </summary>
private static bool IsImmediate(PushNotificationDto notification) =>
notification.Urgent || string.Equals(notification.Kind, "call", StringComparison.OrdinalIgnoreCase);
/// <summary>下课后补弹暂存的提醒(按进入顺序)</summary>
private void FlushDeferred()
{
if (IsInClass()) return; // 还在上课(例如只是状态在 OnClass 内部变化)
if (_deferred.Count == 0) return;
var pending = _deferred.ToList();
_deferred.Clear();
_logger.LogInformation("ClassHelper 联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
foreach (var item in pending) ShowOnUiThread(item);
}
private void OnNotificationReceived(object? sender, PushNotificationDto notification)
{
if (!_settings.ReceiveNotifications) return;
if (string.IsNullOrWhiteSpace(notification.Id)) return;
// 同一条提醒在播放期间会被后续轮询反复取回,这里按 id 去重(详见 _playing 的说明)
lock (_playing)
{
if (!_playing.Add(notification.Id))
{
_logger.LogDebug("ClassHelper 联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
return;
}
}
// 必须切回 UI 线程:提醒内容里的图标、以及提醒主机本身都是 Avalonia 的 UI 对象,
// 而本方法是从上报回调(线程池)里被调用的 —— 在线程池上直接显示会静默失败/抛异常。
Dispatcher.UIThread.Post(() => ShowOnUiThread(notification));
}
private void ShowOnUiThread(PushNotificationDto notification)
{
try
{
// 上课时段:非"主动通知"先收着(与客户端灵动岛同一套规则),下课后补弹
if (IsInClass() && !IsImmediate(notification))
{
_deferred.Add(notification);
_logger.LogInformation("ClassHelper 联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
return;
}
var request = BuildRequest(notification);
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
AckWhenFinished(request, notification);
ShowNotification(request);
_logger.LogInformation("ClassHelper 联动:已弹出提醒「{Title}」({Seconds} 秒)",
notification.Title, notification.DurationSeconds);
}
catch (Exception exception)
{
// 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。
// 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。
lock (_playing) _playing.Remove(notification.Id);
_logger.LogError(exception, "ClassHelper 联动:显示提醒「{Title}」失败", notification.Title);
}
}
/// <summary>
/// 把服务端下发的提醒转成 ClassIsland 的提醒请求。
///
/// 时长口径:老师指定的时长给**正文**(正文才是持续显示的那一段),遮罩只停留几秒 ——
/// 遮罩占满整个时长会把正文压没,不如"遮罩几秒 + 正文 N 秒"清楚。
/// </summary>
private NotificationRequest BuildRequest(PushNotificationDto notification)
{
var seconds = Math.Clamp(
notification.DurationSeconds > 0 ? notification.DurationSeconds : _settings.NotificationDurationSeconds,
1,
120);
var duration = TimeSpan.FromSeconds(seconds);
var maskDuration = TimeSpan.FromSeconds(Math.Min(notification.Urgent ? 5 : 3, seconds));
var title = string.IsNullOrWhiteSpace(notification.Title) ? "ClassHelper" : notification.Title;
var content = string.IsNullOrWhiteSpace(notification.Content) ? title : notification.Content;
// 语音:老师勾了朗读 + 本机允许朗读,两者都满足才念(本机开关是给学生机的总闸)
var speech = _settings.SpeechEnabled ? notification.SpeechContent : null;
var speechEnabled = !string.IsNullOrWhiteSpace(speech);
// 遮罩用双图标模板的**默认图标**:这两个图标由模板自己创建(LucideIconSource),
// 自己塞图标串很容易踩到"图标表达式格式不对/字形不存在",没必要冒这个险。
var mask = NotificationContent.CreateTwoIconsMask(title, factory: content_ =>
{
content_.Duration = maskDuration;
content_.SpeechContent = speech ?? string.Empty;
// 没勾朗读时明确关掉,否则 ClassIsland 会按默认设置把标题也念出来
content_.IsSpeechEnabled = speechEnabled;
});
// 正文:普通提醒用静态文本;紧急提醒改用**滚动文本**(ClassIsland 自带的抢注意力模板),
// 这样"紧急"在视觉上真的有区别,而不是只靠一个可能不存在的图标。
NotificationContent overlay = notification.Urgent
? NotificationContent.CreateRollingTextContent(content, factory: content_ =>
{
content_.Duration = duration;
content_.SpeechContent = speech ?? string.Empty;
content_.IsSpeechEnabled = speechEnabled;
})
: NotificationContent.CreateSimpleTextContent(content, factory: content_ =>
{
content_.Duration = duration;
content_.SpeechContent = speech ?? string.Empty;
content_.IsSpeechEnabled = speechEnabled;
});
return new NotificationRequest { MaskContent = mask, OverlayContent = overlay };
}
/// <summary>提醒播完 / 被关掉后回执给 ClassHelper,服务端不再补发。</summary>
private void AckWhenFinished(NotificationRequest request, PushNotificationDto notification)
{
void Handler(object? sender, EventArgs args)
{
request.Completed -= Handler;
request.Canceled -= Handler;
// 播完再放行:即使回执失败(例如断网)也移除,让后续轮询能重新投递一次,
// 避免"回执丢了 + 不再重试"把提醒彻底吞掉
lock (_playing) _playing.Remove(notification.Id);
_ = _bridge.AckAsync(notification.Id);
}
request.Completed += Handler;
request.Canceled += Handler;
}
}
@@ -1,282 +0,0 @@
using System.Collections.ObjectModel;
using ClassHelper.ClassIslandPlugin.Interop;
using ClassIsland.Shared.Models.Profile;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 把 ClassHelper拉回来的课表写进 ClassIsland 档案。
///
/// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/>
/// (即一个星期几 + 一个单双周相位),而 ClassHelper的课表同时覆盖 7 天与单双周。
/// 因此这里把服务端的条目**按(星期几 + 单双周)分组**,每一组生成一份独立的档案课表。
///
/// **时间点的构造方式**:ClassIsland 的 <c>ClassPlan.Classes[i]</c> 必须与
/// <c>TimeLayout.Layouts</c> 里第 i 个 <c>TimeType == 0</c>(上课)的点一一对应。
/// 与其去"匹配服务端时间表里的时间点"(一旦对不齐整份课表就错位),
/// 这里**按分组内的条目顺序直接重建时间点**:每条课目生成一个上课点,
/// 并在其结束后接上服务端时间表里同一起始时刻的课间点。这样索引关系天然成立。
///
/// **非破坏性**:
/// - 只创建/更新名字带 <see cref="PlanNamePrefix"/> 前缀的课表与时间表;
/// - 老师手动建的课表一律不删除、不改名(同名星期的冲突会在日志里警告);
/// - 重复镜像时原地更新,不会越积越多。
/// </summary>
public static class ClassPlanWriter
{
/// <summary>识别前缀:只有名字带此前缀的课表才由本插件创建与维护。</summary>
public const string PlanNamePrefix = "ClassHelper-";
/// <summary>时间表名字后缀。</summary>
public const string TimeLayoutNameSuffix = "(ClassHelper)";
/// <summary>一次镜像的结果(写入的课表名与冲突提示)。</summary>
public sealed class ApplyResult
{
public List<string> PlanNames { get; } = new();
public List<string> Warnings { get; } = new();
public bool Applied { get; set; }
}
/// <summary>
/// 应用服务端下发的课表。
/// </summary>
public static ApplyResult Apply(Profile profile, ClassPlanMirrorDto source)
{
var result = new ApplyResult();
if (source.Entries.Count == 0 || source.TimeLayouts.Count == 0)
{
result.Warnings.Add("服务端返回的课表为空,未改动 ClassIsland 档案");
return result;
}
var layout = source.TimeLayouts[0];
// 1) 补齐科目(同名的复用,避免每次镜像堆出新的 Subject)
EnsureSubjects(profile, source);
// 2) 按(星期几 + 单双周)分组,每组一份课表
var groups = source.Entries
.GroupBy(entry => new
{
entry.WeekDay,
entry.WeekCountDiv,
entry.WeekCountDivTotal,
})
.Select(group => new
{
group.Key.WeekDay,
group.Key.WeekCountDiv,
group.Key.WeekCountDivTotal,
// 时间点顺序 = 课表顺序:`Classes[i]` 靠这个顺序与上课时间点对齐,
// 「HH:mm」是定长字符串,字典序就是时间序
Entries = group.OrderBy(entry => entry.StartTime, StringComparer.Ordinal).ToList(),
})
.OrderBy(group => group.WeekDay)
.ToList();
foreach (var group in groups)
{
var planName = BuildPlanName(source.ProfileName, group.WeekDay, group.WeekCountDiv,
group.WeekCountDivTotal, groups.Count);
// 时间非法的条目必须**在这里**就整批丢掉:`Classes` 与时间表里的上课点必须等长且同序,
// 只在一处跳过会让整份课表错位。原先 ParseTime 把无法解析的值悄悄变成 00:00,
// 于是档案里凭空出现 00:00-00:00 的课次(时间轴显示与当前节次判断都会乱),还没有任何日志。
var usable = group.Entries
.Where(entry => IsValidTime(entry.StartTime) && IsValidTime(entry.EndTime))
.ToList();
if (usable.Count < group.Entries.Count)
{
result.Warnings.Add(
$"「{planName}」有 {group.Entries.Count - usable.Count} 节的时间无法解析(需要 HH:mm),已跳过");
}
if (usable.Count == 0) continue;
var planId = FindPlanId(profile, planName);
var (timeLayoutId, timeLayout) = BuildTimeLayout(profile, planName, usable);
var plan = profile.ClassPlans.TryGetValue(planId, out var existing)
? existing
: new ClassPlan();
plan.Name = planName;
plan.TimeLayoutId = timeLayoutId;
plan.TimeRule = new TimeRule
{
WeekDay = group.WeekDay,
WeekCountDiv = group.WeekCountDiv,
WeekCountDivTotal = group.WeekCountDivTotal,
};
// Classes[i] ↔ 第 i 个上课点:按分组内条目的时间顺序直接构造,索引天然一致
// ClassIsland 的 `ClassInfo.CurrentTimeLayoutItem` = CurrentTimeLayout 里第 Index 个上课点,
// 因此这里必须显式把 Index 与 CurrentTimeLayout 一起写上 —— 只在构造时依赖
// `RefreshClassesList()` 是不够的:更新已有课表时 `TimeLayouts` 引用没变,
// 档案不会重新触发刷新,课次就会停留在旧时间点上。
var classes = new ObservableCollection<ClassInfo>();
var index = 0;
foreach (var entry in usable)
{
var subjectId = profile.Subjects
.FirstOrDefault(pair => pair.Value.Name == entry.Subject).Key;
classes.Add(new ClassInfo
{
Index = index++,
CurrentTimeLayout = timeLayout,
SubjectId = subjectId,
IsEnabled = true,
});
}
plan.Classes = classes;
plan.IsEnabled = true;
profile.ClassPlans[planId] = plan;
result.PlanNames.Add(planName);
// 冲突提示:同一星期几 + 同一单双周相位下老师还开着别的课表
var conflicts = profile.ClassPlans
.Where(pair => pair.Key != planId
&& pair.Value.IsEnabled
&& !pair.Value.Name.StartsWith(PlanNamePrefix, StringComparison.Ordinal)
&& pair.Value.TimeRule.WeekDay == group.WeekDay
&& pair.Value.TimeRule.WeekCountDivTotal == group.WeekCountDivTotal
&& pair.Value.TimeRule.WeekCountDiv == group.WeekCountDiv)
.Select(pair => pair.Value.Name)
.ToList();
if (conflicts.Count > 0)
{
result.Warnings.Add(
$"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," +
"两者重叠时以 ClassHelper同步的课表为准");
}
}
// 3) 停用上一次镜像留下来的、本次不再需要的课表(只动自己创建的)
var currentNames = new HashSet<string>(result.PlanNames);
foreach (var pair in profile.ClassPlans)
{
if (!pair.Value.Name.StartsWith(PlanNamePrefix, StringComparison.Ordinal)) continue;
if (currentNames.Contains(pair.Value.Name)) continue;
pair.Value.IsEnabled = false;
}
result.Applied = true;
return result;
}
private static void EnsureSubjects(Profile profile, ClassPlanMirrorDto source)
{
foreach (var name in source.Entries.Select(entry => entry.Subject).Distinct())
{
if (string.IsNullOrWhiteSpace(name)) continue;
if (profile.Subjects.Any(pair => pair.Value.Name == name)) continue;
profile.Subjects[Guid.NewGuid()] = new Subject { Name = name };
}
}
private static Guid FindPlanId(Profile profile, string planName)
{
var existing = profile.ClassPlans.FirstOrDefault(pair => pair.Value.Name == planName);
return existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
}
/// <summary>
/// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。
///
/// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来,
/// 不依赖服务端有没有给课间点 —— 这样无论 ClassHelper那边是否维护节次时间表,
/// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。
/// </summary>
private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
Profile profile,
string planName,
IReadOnlyList<ClassPlanEntryMirrorDto> entries)
{
var layoutName = planName + TimeLayoutNameSuffix;
var existing = profile.TimeLayouts.FirstOrDefault(pair => pair.Value.Name == layoutName);
var id = existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
var layout = new TimeLayout { Name = layoutName, IsActivated = true };
for (var index = 0; index < entries.Count; index++)
{
var entry = entries[index];
layout.Layouts.Add(new TimeLayoutItem
{
TimeType = 0, // 上课
StartTime = ParseTime(entry.StartTime),
EndTime = ParseTime(entry.EndTime),
});
// 课间:下一节开始晚于本节结束才有空隙;最后一节之后(放学)不插
if (index + 1 >= entries.Count) continue;
var next = entries[index + 1];
var breakStart = ParseTime(entry.EndTime);
var breakEnd = ParseTime(next.StartTime);
if (breakEnd <= breakStart) continue;
layout.Layouts.Add(new TimeLayoutItem
{
TimeType = 1, // 课间
StartTime = breakStart,
EndTime = breakEnd,
});
}
profile.TimeLayouts[id] = layout;
return (id, layout);
}
/// <summary>
/// 课表名:单组时用「ClassHelper-&lt;班级名&gt;」;多组时补上星期与单双周以免互相覆盖。
/// </summary>
private static string BuildPlanName(
string profileName,
int weekDay,
int weekCountDiv,
int weekCountDivTotal,
int groupCount)
{
var baseName = string.IsNullOrWhiteSpace(profileName) ? "ClassHelper课表" : profileName.Trim();
if (!baseName.StartsWith(PlanNamePrefix, StringComparison.Ordinal))
{
baseName = PlanNamePrefix + baseName;
}
if (groupCount <= 1) return baseName;
var weekday = weekDay switch
{
0 => "周日",
1 => "周一",
2 => "周二",
3 => "周三",
4 => "周四",
5 => "周五",
6 => "周六",
_ => $"星期{weekDay}",
};
var parity = weekCountDivTotal <= 1
? "每周"
: $"第{weekCountDiv}/{weekCountDivTotal}周";
return $"{baseName} · {weekday} · {parity}";
}
/// <summary>"HH:mm" / "HH:mm:ss" → TimeSpan(解析失败回退到 0 点)。</summary>
private static TimeSpan ParseTime(string value)
{
return TimeSpan.TryParse(value, out var parsed) ? parsed : TimeSpan.Zero;
}
/// <summary>
/// 时间字符串是否可用(HH:mm / HH:mm:ss)。
///
/// 服务端输出侧没有校验 `Schedule.startTime`(老数据/异常数据可能是空串),
/// 而 <see cref="ParseTime"/> 会把非法值变成 0 点 —— 因此调用方必须先用本方法过滤,
/// 并把 Classes 与时间表**同时**按过滤后的列表构造,保证索引仍然一致。
/// </summary>
private static bool IsValidTime(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
return TimeSpan.TryParse(value, out var parsed)
&& parsed >= TimeSpan.Zero
&& parsed < TimeSpan.FromDays(1);
}
}
@@ -1,272 +0,0 @@
using System.Collections.ObjectModel;
using ClassHelper.ClassIslandPlugin.Interop;
using ClassIsland.Core.Abstractions.Services;
using ClassIsland.Shared;
using ClassIsland.Shared.Enums;
using ClassIsland.Shared.Models.Profile;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 课表 / 状态 → 上报载荷 的换算层。
///
/// 这里集中全部"ClassIsland 口径 ↔ ClassHelper口径"的转换,保证只有一处需要跟着
/// ClassIsland 的模型变化而修改:
/// - 星期:ClassIsland <c>WeekDay</c> 是 0=周日、1=周一…6=周六;本系统是 1=周一…7=周日;
/// - 单双周:ClassIsland <c>WeekCountDiv</c>/<c>WeekCountDivTotal</c>;本系统 ALL/ODD/EVEN;
/// - 节次:ClassIsland <c>TimeLayoutItem.TimeType</c> 0=上课 1=课间 2=分割线 3=行动。
/// </summary>
public static class ScheduleMapper
{
/// <summary>
/// ClassIsland 的 WeekDay(0=周日…6=周六)→ 本系统 dayOfWeek(1=周一…7=周日)。
/// </summary>
public static int ToServerDayOfWeek(int weekDay)
{
var normalized = ((weekDay % 7) + 7) % 7;
return normalized == 0 ? 7 : normalized;
}
/// <summary>
/// ClassIsland 的 WeekCountDiv/Total → 本系统单双周。
/// 与 <c>packages/shared/src/utils.ts</c> 的 <c>weekParityFromDiv</c> 完全一致:
/// 3 周及以上的循环本系统无法表达,降级为「每周」。
/// </summary>
public static string ToWeekParity(int weekCountDiv, int weekCountDivTotal)
{
if (weekCountDiv <= 0 || weekCountDivTotal <= 1) return "ALL";
var position = ((weekCountDiv - 1) % weekCountDivTotal) + 1;
if (weekCountDivTotal == 2) return position == 1 ? "ODD" : "EVEN";
return "ALL";
}
/// <summary>ClassIsland TimeType → 本系统节次类型。</summary>
public static string ToTimeLayoutType(int timeType) => timeType switch
{
1 => "break",
2 => "divider",
3 => "action",
_ => "class",
};
/// <summary>TimeSpan → "HH:mm"(跨天的时间点做钳制,避免出现 24:xx)。</summary>
public static string ToHhMm(TimeSpan time)
{
var totalMinutes = (int)Math.Round(time.TotalMinutes);
totalMinutes = Math.Clamp(totalMinutes, 0, 24 * 60 - 1);
return $"{totalMinutes / 60:D2}:{totalMinutes % 60:D2}";
}
/// <summary>
/// 把档案里的一张课表(ClassPlan)展开成"每个上课时间点一条"的课表条目。
///
/// 与 ClassIsland 的语义严格对齐:<c>ClassPlan.Classes[i]</c> 对应
/// <c>TimeLayout.Layouts</c> 里**第 i 个 TimeType=0(上课)的点**,课间 / 分割线 / 行动不占位。
///
/// **注意不要用 `plan.ValidTimeLayoutItems`**:它的口径与 `Classes` 不同 ——
/// `GetValidTimeLayoutItems()`(ClassIsland ClassPlan.cs)会正反两遍扫描,
/// 把 `ClassInfo.IsEnabled == false`(老师在课表编辑里"停用"这一节)的课**连同其后的课间**
/// 一起从结果里剔除;而 `RefreshClassesList()` 只按 `TimeType == 0` 的**总数**对齐
/// `Classes` 的长度,`ClassInfo.CurrentTimeLayoutItem` 也是按原始列表的第 `Index` 个取的。
/// 于是只要停用过任意一节,用 ValidTimeLayoutItems 配对就会让索引整体前移:
/// 第 i 节配上第 i+1 节的时间点、最后一节丢失,且 `Math.Min` 会把缺口静默吃掉 ——
/// 表现就是"课表看着有内容、每节课都对不上",极难排查。
/// </summary>
public static List<ScheduleEntryDto> MapClassPlan(ClassPlan plan, Profile profile, out string? warning)
{
warning = null;
var entries = new List<ScheduleEntryDto>();
var timeLayout = ResolveTimeLayout(plan, profile);
if (timeLayout is null)
{
warning = $"课表「{plan.Name}」找不到对应的时间表,已跳过";
return entries;
}
// 只保留上课类型的时间点,顺序必须与 Classes 的索引一致(见上面注释)
var classPoints = timeLayout.Layouts.Where(item => item.TimeType == 0).ToList();
var weekDay = ToServerDayOfWeek(plan.TimeRule.WeekDay);
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
if (plan.TimeRule.WeekCountDivTotal > 2)
{
warning = "存在 3 周以上的轮换课表,ClassHelper只支持单双周,已按「每周」处理";
}
var classes = plan.Classes;
var count = Math.Min(classes.Count, classPoints.Count);
for (var index = 0; index < count; index++)
{
var info = classes[index];
// 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取
var point = classPoints[index];
if (info is null) continue;
// 老师停用的一节:本系统没有"停用"概念,不上报(否则 ClassHelper 班级端会看到一节实际上不上的课)
if (!info.IsEnabled) continue;
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
var subject = ResolveSubjectName(info.SubjectId, profile);
if (string.IsNullOrWhiteSpace(subject)) continue;
entries.Add(new ScheduleEntryDto
{
DayOfWeek = weekDay,
StartTime = ToHhMm(point.StartTime),
EndTime = ToHhMm(point.EndTime),
// 服务端对这几个字段有 zod 长度上限,且是**整批**校验:一个超长名字会让整次上报
// 400,连"现在上什么课"的状态与心跳都不更新,Web 端把教室显示成离线。
Subject = Truncate(subject, SubjectMaxLength)!,
TeacherName = Truncate(ResolveSubjectTeacher(info.SubjectId, profile), NameMaxLength),
WeekParity = parity,
WeekCountDiv = plan.TimeRule.WeekCountDiv,
WeekCountDivTotal = plan.TimeRule.WeekCountDivTotal,
PlanName = Truncate(plan.Name, PlanNameMaxLength),
});
}
return entries;
}
/// <summary>服务端 zod 对各类名称的长度上限(超出会让整批上报 400,所以要截断) */
public const int SubjectMaxLength = 40;
public const int NameMaxLength = 40;
public const int PlanNameMaxLength = 60;
/// <summary>按服务端上限截断(null / 空串原样返回)</summary>
public static string? Truncate(string? value, int max)
{
if (string.IsNullOrEmpty(value)) return value;
var trimmed = value.Trim();
return trimmed.Length <= max ? trimmed : trimmed[..max];
}
/// <summary>把时间表(TimeLayout)转成本系统的节次时间配置。</summary>
public static List<TimeLayoutItemDto> MapTimeLayout(TimeLayout layout)
{
var items = new List<TimeLayoutItemDto>();
// ClassIsland 的上课时间点没有独立名称,这里按"它之前有几个上课点"编出"第 N 节"
var classPointIndex = 0;
for (var index = 0; index < layout.Layouts.Count; index++)
{
var point = layout.Layouts[index];
items.Add(new TimeLayoutItemDto
{
Index = index + 1,
// 节次名同样是服务端有上限的字段(max 40),超长会让整批上报失败
Name = Truncate(BuildTimePointName(point, ref classPointIndex), NameMaxLength)!,
StartTime = ToHhMm(point.StartTime),
EndTime = ToHhMm(point.EndTime),
Type = ToTimeLayoutType(point.TimeType),
Skipped = false,
});
}
return items;
}
/// <summary>
/// 节次名称:ClassIsland 只有课间有名字(BreakName),上课时间点没有独立名称,
/// 因此按类型生成"第 N 节 / 课间 / 分割线",保证本系统的时间轴可读。
/// </summary>
private static string BuildTimePointName(TimeLayoutItem point, ref int classPointIndex)
{
switch (point.TimeType)
{
case 1:
return string.IsNullOrWhiteSpace(point.BreakName) ? "课间" : point.BreakName!;
case 2:
return "分割线";
case 3:
return "行动";
default:
classPointIndex++;
return $"第 {classPointIndex} 节";
}
}
/// <summary>取出课表使用的时间表:优先按 TimeLayoutId,其次用档案里第一个。</summary>
public static TimeLayout? ResolveTimeLayout(ClassPlan plan, Profile profile)
{
if (profile.TimeLayouts.Count == 0) return null;
if (plan.TimeLayoutId != Guid.Empty && profile.TimeLayouts.TryGetValue(plan.TimeLayoutId, out var byId))
{
return byId;
}
return plan.TimeLayout ?? profile.TimeLayouts.Values.FirstOrDefault();
}
/// <summary>科目名(找不到时返回空字符串,由调用方决定是否跳过)。</summary>
public static string? ResolveSubjectName(Guid subjectId, Profile profile)
{
if (subjectId == Guid.Empty) return null;
return profile.Subjects.TryGetValue(subjectId, out var subject) ? subject.Name : null;
}
/// <summary>科目任课老师(ClassIsland 在 Subject 上记录 TeacherName)。</summary>
public static string? ResolveSubjectTeacher(Guid subjectId, Profile profile)
{
if (subjectId == Guid.Empty) return null;
return profile.Subjects.TryGetValue(subjectId, out var subject) ? subject.TeacherName : null;
}
/// <summary>
/// 收集档案里今天(按当前周次与星期)应该上的课表,
/// 用于"只上报今天"这类轻量场景;当前实现为上报全部课表(服务端按周次并存)。
/// </summary>
public static List<ScheduleEntryDto> MapAllClassPlans(Profile profile, out List<string> warnings)
{
warnings = new List<string>();
var all = new List<ScheduleEntryDto>();
foreach (var plan in profile.ClassPlans.Values)
{
if (!plan.IsEnabled) continue;
if (plan.IsOverlay) continue; // 临时层课表不下发,避免把临时调整固化到班级课表
var entries = MapClassPlan(plan, profile, out var warning);
if (!string.IsNullOrWhiteSpace(warning) && !warnings.Contains(warning!))
{
warnings.Add(warning!);
}
all.AddRange(entries);
}
return all;
}
/// <summary>
/// 科目名归一化。
///
/// 没有课表 / 没有当前科目时,ClassIsland 会把 <see cref="Subject.Fallback"/> 挂到
/// `CurrentSubject` / `NextClassSubject` 上(见 LessonsService:
/// `CurrentSubject = currentSubject ?? Subject.Fallback`),而它的名字就是占位符本身。
/// 直接上报会让 Web 端显示成"正在上 ???",因此这里统一折算成"没有科目"。
/// </summary>
private static string? NormalizeSubjectName(Subject? subject)
{
if (subject is null) return null;
if (ReferenceEquals(subject, Subject.Fallback)) return null;
var name = subject.Name?.Trim();
if (string.IsNullOrEmpty(name)) return null;
// 兜底:万一拿到的是另一个同名占位符实例(不是同一个引用)
if (string.Equals(name, Subject.Fallback.Name, StringComparison.Ordinal)) return null;
return name;
}
/// <summary>当前状态快照:服务端据此在 Web 端展示"现在上什么课"。</summary>
public static StateDto BuildState(ILessonsService lessons, int? currentWeek)
{
var current = lessons.CurrentTimeLayoutItem;
var state = lessons.CurrentState;
return new StateDto
{
InClass = state == TimeState.OnClass,
Subject = NormalizeSubjectName(lessons.CurrentSubject),
NextSubject = NormalizeSubjectName(lessons.NextClassSubject),
TimeState = state.ToString(),
PeriodStart = current is null || current == TimeLayoutItem.Empty ? null : ToHhMm(current.StartTime),
PeriodEnd = current is null || current == TimeLayoutItem.Empty ? null : ToHhMm(current.EndTime),
Week = currentWeek,
ClassPlanLoaded = lessons.IsClassPlanLoaded,
ClientTime = DateTimeOffset.Now.ToString("o"),
};
}
}
@@ -1,171 +0,0 @@
<controls:SettingsPageBase x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:ClassHelper.ClassIslandPlugin.Views"
xmlns:controls="clr-namespace:ClassIsland.Core.Abstractions.Controls;assembly=ClassIsland.Core"
xmlns:ci="clr-namespace:ClassIsland.Core.Controls;assembly=ClassIsland.Core"
xmlns:fa="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800"
d:DataContext="{d:DesignInstance local:BridgeSettingsPage}"
Loaded="OnLoaded"
Unloaded="OnUnloaded">
<ScrollViewer>
<StackPanel Classes="settings-container animated-intro" Spacing="0">
<!-- 连接 -->
<TextBlock Text="连接 ClassHelper"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="填入 ClassHelper后端的地址与设备令牌。设备令牌在 ClassHelper Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
<TextBox Watermark="http://127.0.0.1:4000"
Text="{Binding Settings.ServerUrl, Mode=TwoWay}" />
<TextBox Margin="0 8 0 0"
Watermark="chci_..."
PasswordChar="●"
Text="{Binding Settings.DeviceToken, Mode=TwoWay}" />
<!-- 令牌是设备级长期凭证(只有管理员在 Web 端重置才会失效),而 ClassIsland 的插件配置
是明文 JSON 且会随档案备份/迁移一起被带走,所以这里明确提示用户别外传。 -->
<TextBlock Margin="0 6 0 0" TextWrapping="Wrap" Opacity="0.7"
Text="令牌是这台教室机器的长期凭证(仅管理员在 Web 端重置才会失效),会明文保存在本机配置文件里并随 ClassIsland 档案备份一起复制,请不要外传或提交到版本库。" />
<StackPanel Orientation="Horizontal" Margin="0 12 0 0" Spacing="8">
<Button Content="立即上报" Command="{Binding ReportCommand}" IsEnabled="{Binding !Bridge.Busy}" />
<Button Content="检查连接" Command="{Binding TestCommand}" IsEnabled="{Binding !Bridge.Busy}" />
<Button Content="拉取待提醒" Command="{Binding PullCommand}" IsEnabled="{Binding !Bridge.Busy}" />
<Button Content="重建档案课表" Command="{Binding MirrorCommand}"
IsEnabled="{Binding Settings.MirrorSchedule}" />
</StackPanel>
<!-- 状态 -->
<Border Margin="0 16 0 0" Padding="12" CornerRadius="4"
Background="{DynamicResource SystemFillColorNeutralBackgroundBrush}">
<StackPanel Spacing="4">
<TextBlock Text="{Binding StatusText}" TextWrapping="Wrap" FontWeight="SemiBold" />
<TextBlock Text="{Binding Bridge.LastReportMessage}" TextWrapping="Wrap" />
<TextBlock Text="{Binding DetailText}" TextWrapping="Wrap" Opacity="0.8" />
</StackPanel>
</Border>
<!-- 同步 -->
<TextBlock Margin="0 24 0 0" Text="同步内容"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="课表以「合并」方式写入 ClassHelper:同一星期、同一时间的课目会被更新,不会产生重复行。" />
<fa:SettingsExpander Header="自动上报课表到 ClassHelper"
Description="把 ClassIsland 里启用的课表**自动**同步给 ClassHelper。默认关闭:课表以 ClassHelper 为准,自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,与本开关无关。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8A5;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.UploadSchedule, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<fa:SettingsExpander Header="把班级课表镜像回 ClassIsland"
Description="反方向同步:把 ClassHelper上排好的课表写回 ClassIsland,会新建一份以「ClassHelper-」开头的档案课表,不会修改老师原有的课表。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8F1;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.MirrorSchedule, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<fa:SettingsExpander Header="自动上报"
Description="应用启动、课表变化以及每隔一段时间各上报一次;关闭后只能用上面的「立即上报」手动触发。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE895;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.AutoReport, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="上报间隔(秒)" />
<TextBlock Text="最小 15 秒;教室机器建议 60 秒。" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" Width="140"
Minimum="15" Maximum="3600" Increment="15"
FormatString="0"
IsEnabled="{Binding Settings.AutoReport}"
Value="{Binding Settings.ReportIntervalSeconds, Mode=TwoWay}" />
</Grid>
<!-- 提醒 -->
<TextBlock Margin="0 24 0 0" Text="提醒"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="老师在 ClassHelper上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
<fa:SettingsExpander Header="接收 ClassHelper提醒"
Description="关闭后本机会忽略老师在 ClassHelper上发布的提醒(提醒仍会留在班级通知中心)。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8BD;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.ReceiveNotifications, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<fa:SettingsExpander Header="语音朗读"
Description="提醒弹出时按 ClassIsland 的语音设置为全机朗读提醒内容。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8D6;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.SpeechEnabled, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="默认显示时长(秒)" />
<TextBlock Text="老师在下发单条提醒时可以另外指定更长的时长。" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" Width="140"
Minimum="1" Maximum="120" Increment="1"
FormatString="0"
Value="{Binding Settings.NotificationDurationSeconds, Mode=TwoWay}" />
</Grid>
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="提醒轮询间隔(秒)" />
<TextBlock Text="老师按下发送到教室弹出之间的最大延迟(插件不持有长连接,是轮询取回提醒)。默认 10 秒。"
Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" Width="140"
Minimum="5" Maximum="600" Increment="5"
FormatString="0"
IsEnabled="{Binding Settings.ReceiveNotifications}"
Value="{Binding Settings.NotificationPollSeconds, Mode=TwoWay}" />
</Grid>
<!-- 调试 -->
<TextBlock Margin="0 24 0 0" Text="调试"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<fa:SettingsExpander Header="显示调试信息"
Description="显示设备标识、时钟差等细节,排查「为什么同步不上」时打开。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE7EF;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.ShowDebugInfo, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<StackPanel Margin="0 8 0 0" Spacing="2" IsVisible="{Binding Settings.ShowDebugInfo}">
<TextBlock Text="{Binding DebugText}" TextWrapping="Wrap" FontFamily="Consolas,monospace" />
</StackPanel>
<TextBlock Margin="0 24 0 0" Opacity="0.6" TextWrapping="Wrap"
Text="提示:ClassHelper Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
</StackPanel>
</ScrollViewer>
</controls:SettingsPageBase>
@@ -1,198 +0,0 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Avalonia.Threading;
using ClassHelper.ClassIslandPlugin.Models;
using ClassHelper.ClassIslandPlugin.Services;
using ClassIsland.Core.Abstractions.Controls;
using ClassIsland.Core.Attributes;
using ClassIsland.Core.Enums.SettingsWindow;
using CommunityToolkit.Mvvm.Input;
namespace ClassHelper.ClassIslandPlugin.Views;
/// <summary>
/// 「ClassHelper 联动」设置页。
///
/// 页面上的绑定源就是本页自己(<c>DataContext = this</c>):<see cref="Settings"/> 暴露全部配置项,
/// <see cref="Bridge"/> 暴露运行状态。这样 XAML 里能直接写 <c>{Binding Settings.Xxx}</c>,
/// 不需要再套一层 ViewModel,改配置项时也少一处"两边同步"的机会。
/// </summary>
// 图标用 Fluent 字体的码位(与 ClassIsland 内置设置页写法一致):未选中/选中同一枚插头图标
[SettingsPageInfo(
"classhelper.classisland.bridge.settings",
"ClassHelper 联动",
"\uE71B",
"\uE71B",
SettingsPageCategory.External)]
public partial class BridgeSettingsPage : SettingsPageBase, INotifyPropertyChanged
{
public BridgeSettingsPage(PluginSettings settings, BridgeService bridge)
{
Settings = settings;
Bridge = bridge;
ReportCommand = new AsyncRelayCommand(ReportAsync);
TestCommand = new AsyncRelayCommand(TestAsync);
PullCommand = new AsyncRelayCommand(PullAsync);
MirrorCommand = new AsyncRelayCommand(MirrorAsync);
InitializeComponent();
DataContext = this;
Raise(nameof(StatusText));
}
public PluginSettings Settings { get; }
public BridgeService Bridge { get; }
public IAsyncRelayCommand ReportCommand { get; }
public IAsyncRelayCommand TestCommand { get; }
public IAsyncRelayCommand PullCommand { get; }
public IAsyncRelayCommand MirrorCommand { get; }
private PropertyChangedEventHandler? _propertyChanged;
/// <summary>
/// 显式实现 <see cref="INotifyPropertyChanged"/>:<c>AvaloniaObject</c> 自带一个同名但类型不同的事件,
/// 直接声明会遮蔽基类成员。显式实现既不影响 Avalonia 的绑定查找(它按接口取),也不会有歧义。
/// </summary>
event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged
{
add => _propertyChanged += value;
remove => _propertyChanged -= value;
}
/* ---------------------------------------------------------------- 绑定用的展示文本 */
/// <summary>一句话结论:还没配 / 已配置 / 已连接 / 连接异常。</summary>
public string StatusText
{
get
{
if (!Settings.IsConfigured) return "尚未配置:请填写服务器地址与设备令牌";
if (!Bridge.IsAttached) return "已填写配置,等待 ClassIsland 课程服务就绪";
return Bridge.LastReportOk ? "已连接" : "连接异常";
}
}
public string DetailText
{
get
{
var week = Bridge.LastServerWeek?.ToString() ?? "—";
return $"最后上报:{Bridge.LastReportAtText} 教学周:{week} " +
$"课表 {Bridge.LastScheduleEntryCount} 节 / 节次 {Bridge.LastTimeLayoutItemCount} 条 " +
$"已弹出提醒:{Bridge.NotificationCount} 条";
}
}
/// <summary>调试信息(设置页里打开"显示调试信息"才可见)。</summary>
public string DebugText
{
get
{
var lines = new List<string>
{
$"服务器地址:{(string.IsNullOrWhiteSpace(Settings.ServerUrl) ? "(未填)" : Settings.ServerUrl)}",
$"设备令牌:{(string.IsNullOrWhiteSpace(Settings.DeviceToken) ? "(未填)" : Mask(Settings.DeviceToken))}",
$"设备标识:{(string.IsNullOrWhiteSpace(Settings.DeviceKey) ? "(未生成)" : Settings.DeviceKey)}",
$"课程服务:{(Bridge.IsAttached ? "已接入" : "未接入")}",
$"与服务端时钟差:{Bridge.ClientTimeOffsetText}",
$"ClassIsland 版本:{SafeVersion()}",
$"配置目录:{PluginConfigFolder}",
};
return string.Join(Environment.NewLine, lines);
}
}
/// <summary>设置页所在的插件配置目录(从插件入口拿;拿不到时留空)。</summary>
private string PluginConfigFolder =>
ClassIsland.Shared.IAppHost.TryGetService<ClassIsland.Core.Abstractions.PluginBase>()?.PluginConfigFolder ?? "—";
private static string SafeVersion()
{
try { return ClassIsland.Core.AppBase.AppVersion ?? "—"; }
catch { return "—"; }
}
/// <summary>令牌只露头尾,避免设置页截图时把可用凭证泄露出去。</summary>
private static string Mask(string token) =>
token.Length <= 14 ? token : $"{token[..12]}…{token[^4..]}";
/* ---------------------------------------------------------------- 交互 */
private async Task ReportAsync()
{
await Bridge.ReportAsync("手动").ConfigureAwait(true);
}
/// <summary>不带课表上报一次:用来快速验证"地址 + 令牌"是否可用,不会因为课表为空而出错。</summary>
private async Task TestAsync()
{
await Bridge.ReportAsync("连接检查", includeSchedule: false).ConfigureAwait(true);
}
private async Task PullAsync()
{
await Bridge.PullPendingAsync().ConfigureAwait(true);
}
private async Task MirrorAsync()
{
await Bridge.MirrorScheduleAsync().ConfigureAwait(true);
}
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs args)
{
// 影响上报链路的改动需要立刻重建定时器;纯界面项(如"显示调试信息")不用管
if (args.PropertyName is nameof(PluginSettings.ServerUrl)
or nameof(PluginSettings.DeviceToken)
or nameof(PluginSettings.AutoReport)
or nameof(PluginSettings.ReportIntervalSeconds)
or nameof(PluginSettings.ReceiveNotifications)
or nameof(PluginSettings.NotificationPollSeconds))
{
Bridge.RestartTimer();
}
Raise(nameof(StatusText));
Raise(nameof(DebugText));
}
private void OnBridgeChanged(object? sender, PropertyChangedEventArgs args)
{
// Bridge 的属性变化可能来自线程池(HttpClient 回调),绑定必须在 UI 线程上更新
if (Dispatcher.UIThread.CheckAccess())
{
RaiseStateProperties();
}
else
{
Dispatcher.UIThread.Post(RaiseStateProperties);
}
}
private void RaiseStateProperties()
{
Raise(nameof(StatusText));
Raise(nameof(DetailText));
Raise(nameof(DebugText));
}
private void OnLoaded(object sender, Avalonia.Interactivity.RoutedEventArgs args)
{
// 改完地址/令牌/间隔要重建定时器,否则"改了设置但上报节奏还是旧的"
Settings.PropertyChanged += OnSettingsChanged;
// 运行状态(上一次上报结果 / 待弹出提醒数)变了要刷新状态卡片
Bridge.PropertyChanged += OnBridgeChanged;
}
private void OnUnloaded(object sender, Avalonia.Interactivity.RoutedEventArgs args)
{
Settings.PropertyChanged -= OnSettingsChanged;
Bridge.PropertyChanged -= OnBridgeChanged;
}
private void Raise([CallerMemberName] string? propertyName = null) =>
_propertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/desktop-client",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
+1 -1
View File
@@ -136,7 +136,7 @@ function normalizeIntegrityLabel(dir) {
run([dir, '/setintegritylevel', 'Medium', '/T', '/C']);
console.log('[dist] 已把交付物完整性标签修正为 Medium:', dir);
} catch (error) {
console.warn('[dist] 完整性标签修正失败(交付物可能装不上,见 AGENTS.md §7 第 7 条):', error.message);
console.warn('[dist] 完整性标签修正失败(交付物可能装不上,见 AGENTS.md 的现行工程约定):', error.message);
}
// 目录自身恢复为"继承父目录的 Low",保证下次构建时带 Low 标签的 7za 仍能写进这个目录。
// 宿主目录树本来就没有 Low 标签体系时,这一步会被系统拒绝(保持继承即可),属正常,不提示。
+45 -121
View File
@@ -9,10 +9,17 @@ import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { provisionSmokeClass } from '../../../scripts/lib/smoke-class.mjs';
import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(root, '..', '..');
const startedAt = Date.now();
const mode = process.argv.includes('--visual') ? 'visual' : (process.env.ELECTRON_SMOKE_MODE ?? 'quick');
if (!['quick', 'visual'].includes(mode)) throw new Error('冒烟模式只能是 quick 或 visual');
const resultFile =
process.env.ELECTRON_SMOKE_RESULT ?? path.join(repoRoot, '.cache', 'desktop-smoke-result.json');
console.log(`[smoke] 模式:${mode === 'visual' ? '视觉验收(会使用桌面)' : '后台快速检查(不占用前台)'}`);
const electronPath = resolveElectronExecutable(path.join(root, 'node_modules', 'electron'));
const required = [path.join(root, 'dist/main/index.js'), path.join(root, 'dist/renderer/index.html')];
const missing = required.filter((file) => !fs.existsSync(file));
@@ -37,124 +44,13 @@ try {
}
console.log(`[smoke] 独立配置目录:${profileDir}`);
// 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试
let online = process.env.ELECTRON_SMOKE_ONLINE;
if (online === undefined) {
online = '0';
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2500);
const response = await fetch('http://127.0.0.1:4000/api/health', { signal: controller.signal });
clearTimeout(timer);
online = response.ok ? '1' : '0';
} catch {
online = '0';
}
console.log(
online === '1'
? '[smoke] 检测到后端在线,将执行联网集成与侧边栏点击测试'
: '[smoke] 后端未在线,仅执行本地检查(离线缓存/界面渲染);如需联网测试请先启动 pnpm dev:server',
);
}
/**
* 准备一个已知可用的班级账号(ClassHelper 班级端已改为班级码 + 班级密码登录):
* 用管理员账号登录 → 取第一个班级 → 先试"已知密码"(冒烟密码 / 种子默认 123456),
* 命中就直接用、**不改动任何数据**;都不行才把该班重置成冒烟专用密码,
* 并在脚本收尾时恢复为种子默认密码(避免把演示实例的 123456 悄悄改掉)。
*/
async function provisionClassAccount() {
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
const username = process.env.ELECTRON_SMOKE_ADMIN ?? 'admin';
const adminPassword = process.env.ELECTRON_SMOKE_ADMIN_PASSWORD ?? 'admin123';
const password = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? 'smoke123456';
const CANDIDATES = [...new Set([password, process.env.ELECTRON_SMOKE_SEED_PASSWORD ?? '123456'])];
const tryLogin = async (code, candidate) => {
const response = await fetch(`${base}/auth/class-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, password: candidate }),
});
return response.ok;
};
try {
const loginResponse = await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password: adminPassword }),
});
const login = await loginResponse.json();
const token = login?.data?.token;
if (!token) return null;
const authHeaders = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const classesResponse = await fetch(`${base}/classes`, { headers: authHeaders });
const classes = (await classesResponse.json())?.data ?? [];
const target = classes[0];
if (!target) return null;
// 冒烟**永不修改班级码**(班级码是给学生用的,不应被测试工具改写):
// 只有"现有班级码 + 已知密码全部登录失败"时才重置密码,班级码原样保留。
const code = target.code;
if (!code) return null;
for (const candidate of CANDIDATES) {
if (await tryLogin(code, candidate)) return { code, password: candidate, className: target.name };
}
const patchResponse = await fetch(`${base}/classes/${target.id}/class-account`, {
method: 'PATCH',
headers: authHeaders,
body: JSON.stringify({ code, password }),
});
if (!patchResponse.ok) return null;
const patched = (await patchResponse.json())?.data;
// 记下"需要恢复",脚本收尾时改回种子默认密码(已知密码都不通时无法得知原密码)
patchedAccount = { id: target.id, code: patched?.code ?? code, base, authHeaders };
console.log(
`[smoke] 已知密码均不可用,已把班级 ${target.name} 的密码临时重置为 ${password}` +
`(结束时恢复为 ${CANDIDATES[1]})`,
);
return { code: patched?.code ?? code, password, className: target.name };
} catch {
return null;
}
}
/** 冒烟临时重置过的班级账号(结束时恢复) */
let patchedAccount = null;
/** 恢复被冒烟临时改过的班级密码(失败只告警,不影响冒烟结论) */
async function restoreClassAccount() {
if (!patchedAccount) return;
try {
const response = await fetch(`${patchedAccount.base}/classes/${patchedAccount.id}/class-account`, {
method: 'PATCH',
headers: patchedAccount.authHeaders,
body: JSON.stringify({
code: patchedAccount.code,
password: process.env.ELECTRON_SMOKE_SEED_PASSWORD ?? '123456',
}),
});
console.log(
response.ok
? '[smoke] 已恢复班级密码为种子默认 123456(冒烟不留副作用)'
: `[smoke] [WARN] 班级密码恢复失败:status=${response.status},请到后台重新设置`,
);
} catch (error) {
console.log(`[smoke] [WARN] 班级密码恢复失败:${error?.message ?? error}`);
}
}
// 联网验收必须显式指定,避免自动碰到用户正在使用的实例。
const online = process.argv.includes('--online') ? '1' : (process.env.ELECTRON_SMOKE_ONLINE ?? '0');
let classCredentials = null;
if (online === '1') {
classCredentials = await provisionClassAccount();
console.log(
classCredentials
? `[smoke] 班级账号就绪:${classCredentials.code}(${classCredentials.className})`
: '[smoke] 未能准备班级账号,联网集成将使用种子默认凭据',
);
classCredentials = await provisionSmokeClass();
if (!classCredentials) throw new Error('无法建立独立验证班级,请检查测试服务端与管理员凭据');
console.log('[smoke] 已建立独立验证班级:' + classCredentials.code);
}
/** 启动一次冒烟(返回退出码) */
@@ -163,26 +59,49 @@ function launchSmoke(extraEnv) {
const child = spawn(electronPath, ['.'], {
cwd: root,
stdio: 'inherit',
windowsHide: true,
env: resolveElectronEnv({
ELECTRON_SMOKE_TEST: '1',
ELECTRON_SMOKE_MODE: mode,
ELECTRON_SMOKE_RESULT: resultFile,
ELECTRON_SMOKE_ONLINE: online,
ELECTRON_SMOKE_PROFILE: profileDir,
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
// 教师凭据只从这里传入:主进程的冒烟代码从环境变量读,
// 因此安装包(app.asar)里不会留下教师口令字面量(本脚本不进安装包)
ELECTRON_SMOKE_USER: process.env.ELECTRON_SMOKE_USER ?? 'teacher1',
ELECTRON_SMOKE_PASSWORD: process.env.ELECTRON_SMOKE_PASSWORD ?? 'teacher123',
ELECTRON_SMOKE_USER:
classCredentials?.teacherUsername ?? process.env.ELECTRON_SMOKE_USER ?? 'teacher1',
ELECTRON_SMOKE_PASSWORD:
classCredentials?.teacherPassword ?? process.env.ELECTRON_SMOKE_PASSWORD ?? 'teacher123',
ELECTRON_SMOKE_CLASS_ID: classCredentials?.classId ?? '',
...extraEnv,
}),
});
child.on('exit', (code) => resolve(code ?? 1));
const deadline = setTimeout(
() => {
console.error('[smoke] 超时,结束验证进程');
child.kill();
resolve(124);
},
// 快速套件已包含本地 HTTP、校验下载和布局编辑;保留冷启动余量,单项断言不放宽。
mode === 'visual' ? 360_000 : 90_000,
);
child.once('exit', (code) => {
clearTimeout(deadline);
resolve(code ?? 1);
});
child.once('error', (error) => {
clearTimeout(deadline);
console.error(error);
resolve(1);
});
});
}
const code = await launchSmoke();
console.log(`\n[smoke] Electron 退出,code=${code}`);
await restoreClassAccount();
await classCredentials?.cleanup();
if (code !== 0) process.exit(code);
/**
@@ -208,7 +127,11 @@ try {
}
console.log('\n[smoke] 二次启动校验(单实例锁 / 句柄是否已释放)...');
const relaunchCode = await launchSmoke({ ELECTRON_SMOKE_ONLINE: '0', ELECTRON_SMOKE_RESULT: relaunchResult });
const relaunchCode = await launchSmoke({
ELECTRON_SMOKE_ONLINE: '0',
ELECTRON_SMOKE_MODE: 'quick',
ELECTRON_SMOKE_RESULT: relaunchResult,
});
let relaunchPassed = 0;
let relaunchTotal = 0;
@@ -232,4 +155,5 @@ try {
} catch {
// 二次启动可能又建了目录,删除失败不影响结论(首次删除已证明锁已释放)
}
console.log('[smoke] 总耗时:' + ((Date.now() - startedAt) / 1000).toFixed(1) + ' 秒');
process.exit(resourcesOk ? 0 : 1);
@@ -0,0 +1,234 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
PRIORITY_RANK,
PRIORITY_LABELS,
formatDate,
islandListLayout,
ISLAND_SIZE_DELTA,
type IslandState,
type IslandAppearance,
type IslandNotification,
type IslandNotificationKind,
} from '@classhelper/shared';
const props = defineProps<{ state: IslandState; appearance: IslandAppearance; maxHeight: number }>();
const state = computed(() => props.state);
const appearance = computed(() => props.appearance);
const notification = computed(() => state.value.active);
const isUrgent = computed(() => notification.value?.priority === 'URGENT');
const isCall = computed(() => notification.value?.kind === 'call');
const isHomework = computed(() => notification.value?.kind === 'homework');
const pendingNotifications = computed(() =>
[
...new Map(
[state.value.active, ...state.value.queued]
.filter((n): n is IslandNotification => !!n)
.map((n) => [n.id, n]),
).values(),
].sort(
(a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority] || b.createdAt.localeCompare(a.createdAt),
),
);
const pendingCount = computed(() => pendingNotifications.value.length);
const queueCount = computed(() => state.value.queued.length);
const listLayout = computed(() =>
pendingCount.value < 2
? null
: islandListLayout({
count: pendingCount.value,
fontSize: appearance.value.fontSize,
maxHeight: props.maxHeight,
expanded: state.value.listExpanded,
}),
);
const isListMode = computed(() => !!listLayout.value);
const listItems = computed(() => pendingNotifications.value.slice(0, listLayout.value?.rows ?? 0));
const hiddenCount = computed(() => pendingCount.value - listItems.value.length);
const listHint = computed(() => listLayout.value?.hint);
const size = computed(() => {
const extra = isCall.value
? ISLAND_SIZE_DELTA.call
: isUrgent.value
? ISLAND_SIZE_DELTA.urgent
: ISLAND_SIZE_DELTA.expanded;
return {
width: appearance.value.width + (isListMode.value ? ISLAND_SIZE_DELTA.expanded.width : extra.width),
height: listLayout.value?.height ?? appearance.value.height + extra.height,
};
});
const TYPE_ORDER: IslandNotificationKind[] = ['call', 'homework', 'notification'];
const TYPE_LABELS = { call: '叫人', homework: '作业', notification: '通知' };
const pendingTypes = computed(() =>
TYPE_ORDER.filter((k) => pendingNotifications.value.some((n) => (n.kind ?? 'notification') === k)).map(
(k) => TYPE_LABELS[k],
),
);
function rowKind(n: IslandNotification) {
return n.kind ?? 'notification';
}
function rowBadgeText(n: IslandNotification) {
return n.kind === 'call' ? '叫人' : n.kind === 'homework' ? '新作业' : PRIORITY_LABELS[n.priority];
}
function rowBadgeClass(n: IslandNotification) {
return n.kind === 'call'
? 'badge-call'
: n.kind === 'homework'
? 'badge-homework'
: `badge-${n.priority.toLowerCase()}`;
}
function rowSubText(n: IslandNotification) {
return `${n.teacherName ? n.teacherName + ' · ' : ''}${formatDate(n.createdAt, true)}`;
}
const badgeText = computed(() => (notification.value ? rowBadgeText(notification.value) : '通知'));
const badgeClass = computed(() => (notification.value ? rowBadgeClass(notification.value) : 'badge-normal'));
const subtitle = computed(() => (notification.value ? rowSubText(notification.value) : ''));
const callHint = computed(() => notification.value?.subtitle ?? '');
const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass);
const canMarkRead = computed(() => pendingNotifications.value.some((n) => n.kind !== 'homework'));
function collapse() {
window.island?.sendAction('collapse');
}
function openApp() {
window.island?.sendAction('open-app');
}
function expandList() {
window.island?.sendAction('expand-list');
}
function dismiss() {
window.island?.sendAction(isListMode.value ? 'dismiss-all' : 'dismiss');
}
function markRead() {
window.island?.sendAction(isListMode.value ? 'mark-all-read' : 'mark-read', notification.value?.id);
}
</script>
<template>
<span
class="expanded-layer classic-notifications"
:class="{ 'list-mode': isListMode }"
:style="{ width: `${size.width}px`, height: `${size.height}px` }"
>
<header class="head">
<span
class="head-icon"
:class="{ urgent: isUrgent, call: isCall, homework: isHomework, list: isListMode }"
>
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
/>
</svg>
<svg v-else-if="isCall" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
/>
</svg>
<svg v-else viewBox="0 0 24 24" 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="isListMode ? 'badge-normal' : badgeClass">
{{ isListMode ? `共 ${pendingCount} 条` : badgeText }}
</span>
<template v-if="isListMode">
<span v-if="pendingTypes.length > 1" class="head-sub">{{ pendingTypes.join(' / ') }}</span>
<span v-else class="head-sub">按重要程度排列</span>
</template>
<template v-else>
<span v-if="isCall" class="live-dot call">老师正在等你</span>
<span v-else-if="isUrgent" class="live-dot">需立即查看</span>
<span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span>
<span class="head-sub">{{ subtitle }}</span>
</template>
</div>
<button type="button" class="icon-btn" title="收起" @click.stop="collapse">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
d="m6 15 6-6 6 6"
/>
</svg>
</button>
</header>
<!-- 多条消息:竖向排列,按重要程度排序,默认只显示前三条 -->
<template v-if="isListMode">
<h3 class="title">{{ pendingCount }} 条待处理通知</h3>
<div class="body list-body">
<button
v-for="item in listItems"
:key="item.id"
type="button"
class="list-row"
:title="`打开应用查看:${item.title}`"
@click.stop="openApp"
>
<span class="row-icon" :class="rowKind(item)">
<svg v-if="rowKind(item) === 'homework'" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
/>
</svg>
<svg v-else-if="rowKind(item) === 'call'" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
/>
</svg>
<svg v-else viewBox="0 0 24 24" 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="row-text">
<span class="row-title">{{ item.title }}</span>
<span class="row-sub">{{ rowSubText(item) }}</span>
</span>
<span class="row-badge" :class="rowBadgeClass(item)">{{ rowBadgeText(item) }}</span>
</button>
<!-- 还有没显示出来的:能展开就"展开更多",屏幕到任务栏了就只能去应用里看 -->
<div v-if="listHint" class="list-hint">
<button v-if="listHint === 'more'" type="button" class="more-btn" @click.stop="expandList">
展开更多(还有 {{ hiddenCount }} 条)
</button>
<span v-else class="more-app">更多请前往应用内操作</span>
</div>
</div>
</template>
<template v-else>
<h3 class="title">{{ notification?.title ?? '' }}</h3>
<div class="body">
<p class="content-text">{{ notification?.content ?? '' }}</p>
<p v-if="callHint" class="call-hint">{{ callHint }}</p>
<p v-else-if="notification?.subtitle" class="call-hint">{{ notification?.subtitle }}</p>
</div>
</template>
<!-- 一排按钮(多条消息时也是这一排):展开态的操作都收在这里 -->
<footer class="foot">
<span v-if="isListMode" class="more">共 {{ pendingCount }} 条待处理</span>
<span v-else-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
<span v-else class="more muted">来自 ClassHelper</span>
<span class="actions">
<button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button>
<button v-if="canMarkRead" type="button" class="ghost-btn" @click.stop="markRead">标为已读</button>
<button type="button" class="solid-btn" @click.stop="dismiss">
{{ isCall && !isListMode ? '收到' : '知道了' }}
</button>
</span>
</footer>
</span>
</template>
<style src="./classic-island.css"></style>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { IslandComponentKind } from '@classhelper/shared';
defineProps<{ kind: IslandComponentKind }>();
const paths: Record<IslandComponentKind, string> = {
clock: 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM12 7v5l3 2',
date: 'M6 5h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2ZM8 3v4m8-4v4M4 10h16M8 14h2m4 0h2m-8 3h2',
schedule: 'M9 5h11M9 12h11M9 19h11M4 5h1m-1 7h1m-1 7h1',
countdown: 'M6 3h12M6 21h12M8 3v4l4 5-4 5v4m8-18v4l-4 5 4 5v4',
text: 'M5 6V4h14v2M12 4v16m-4 0h8',
separator: 'M12 4v16M5 8v8m14-8v8',
notification: 'M9 19a3 3 0 0 0 6 0M6 10a6 6 0 0 1 12 0v4l2 3H4l2-3v-4Z',
homework: 'M9 4H6a2 2 0 0 0-2 2v14h16V6a2 2 0 0 0-2-2h-3M9 3h6v4H9V3Zm-1 9h8m-8 4h5',
call: 'M4 10h4l6-5v14l-6-5H4v-4Zm13-2a6 6 0 0 1 0 8m3-11a10 10 0 0 1 0 14',
group: 'M3 5h18v14H3V5Zm4 4h4v6H7V9Zm8 0h2v6h-2V9Z',
stack: 'm12 3 9 5-9 5-9-5 9-5ZM3 12l9 5 9-5M3 16l9 5 9-5',
slide: 'M6 5h12v14H6V5ZM2 8v8m20-8v8m-12 7h4',
rolling: 'M3 8h17m-4-4 4 4-4 4M21 16H4m4-4-4 4 4 4',
};
</script>
<template>
<svg
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path :d="paths[kind]" />
</svg>
</template>
@@ -0,0 +1,363 @@
<script setup lang="ts">
import { computed, type CSSProperties } from 'vue';
import {
ISLAND_COMPONENT_LABELS,
resolveClassStatus,
type IslandComponentNode,
type IslandState,
type ScheduleDto,
} from '@classhelper/shared';
const props = defineProps<{
node: IslandComponentNode;
state: IslandState;
now: number;
schedules: ScheduleDto[];
editing: boolean;
selectedId: string;
alertNodeId: string;
parentHasWidth?: boolean;
}>();
const emit = defineEmits<{ select: [id: string]; drag: [event: PointerEvent, id: string] }>();
function select(event: MouseEvent) {
if (!props.editing) return;
event.stopPropagation();
emit('select', props.node.id);
}
const container = computed(() => ['group', 'stack', 'slide', 'rolling'].includes(props.node.kind));
const today = computed(() =>
props.schedules
.filter((s) => s.dayOfWeek === (new Date(props.now).getDay() || 7))
.sort((a, b) => a.startTime.localeCompare(b.startTime)),
);
const status = computed(() => resolveClassStatus(today.value, new Date(props.now)));
const current = computed(() => today.value.find((s) => s.id === status.value.current?.scheduleId));
const next = computed(() => today.value.find((s) => s.id === status.value.next?.scheduleId));
function timeAt(time: string) {
const d = new Date(props.now);
const [h, m] = time.split(':').map(Number);
d.setHours(h!, m!, 0, 0);
return d.getTime();
}
const elapsed = computed(() =>
current.value
? Math.max(
0,
Math.min(
1,
(props.now - timeAt(current.value.startTime)) /
Math.max(1, timeAt(current.value.endTime) - timeAt(current.value.startTime)),
),
)
: 0,
);
const remaining = computed(() =>
current.value ? Math.max(0, Math.ceil((timeAt(current.value.endTime) - props.now) / 1000)) : 0,
);
const hidden = computed(
() =>
!props.editing &&
((['notification', 'homework', 'call'].includes(props.node.kind) &&
(!props.state.active || props.state.mode === 'hidden' || props.node.id !== props.alertNodeId)) ||
props.node.advanced.hideRules.some(
(r) =>
(r === 'in-class' && props.state.inClass) ||
(r === 'no-message' && !props.state.active) ||
(r === 'no-class' && !current.value),
)),
);
const visibleChildren = computed(() =>
props.node.kind === 'slide'
? props.node.children.filter(
(_, i) =>
i ===
Math.floor(props.now / (props.node.settings.interval * 1000)) %
Math.max(1, props.node.children.length),
)
: props.node.children,
);
const content = computed(() => {
const n = props.node,
d = new Date(props.now);
if (n.kind === 'date')
return d.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' });
if (n.kind === 'clock')
return d.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
...(n.settings.seconds ? { second: '2-digit' } : {}),
hour12: n.settings.hour12,
});
if (n.kind === 'text')
return n.settings.template
? n.settings.text
.replaceAll('{date}', d.toLocaleDateString('zh-CN'))
.replaceAll('{time}', d.toLocaleTimeString('zh-CN'))
.replaceAll('{course}', current.value?.course?.name || '暂无课程')
: n.settings.text;
if (n.kind === 'countdown') {
const ending = n.settings.target === 'end' || (n.settings.target === 'auto' && !!current.value);
const time =
n.settings.target === 'school'
? today.value.at(-1)?.endTime
: ending
? current.value?.endTime
: next.value?.startTime;
if (!time) return '今日课程已结束';
const [h, m] = time.split(':').map(Number);
const end = new Date(d);
end.setHours(h!, m!, 0, 0);
const left = Math.max(0, Math.ceil((end.getTime() - props.now) / 1000));
return `${n.settings.target === 'school' ? '距放学' : ending ? '距下课' : '距上课'} ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
}
return '';
});
const lessons = computed(() =>
props.node.settings.display === 'today'
? today.value.slice(0, props.node.settings.count)
: [props.node.settings.display === 'next' ? next.value : current.value || next.value].filter(
(s): s is ScheduleDto => Boolean(s),
),
);
const style = computed<CSSProperties>(() => {
const n = props.node,
a = n.appearance,
override = a.resourceOverridingEnabled;
const color =
a.background +
Math.round((a.customBackgroundOpacityEnabled ? a.backgroundOpacity : 1) * 255)
.toString(16)
.padStart(2, '0');
return {
opacity: a.opacity,
width: n.advanced.width ? `${n.advanced.width}px` : undefined,
maxWidth: n.advanced.maxWidth ? `${n.advanced.maxWidth}px` : undefined,
alignSelf: !props.parentHasWidth
? undefined
: ({ stretch: 'stretch', start: 'flex-start', center: 'center', end: 'flex-end' } as const)[
n.advanced.align
],
textAlign: n.advanced.align === 'start' ? 'start' : n.advanced.align === 'end' ? 'end' : 'center',
color: override && a.customForegroundEnabled ? a.foreground : undefined,
background: override && a.customBackgroundEnabled ? color : undefined,
borderRadius: override && a.customCornerRadiusEnabled ? `${a.cornerRadius}px` : undefined,
fontSize: override ? `${a.bodyFontSize}px` : undefined,
'--secondary': override ? `${a.secondaryFontSize}px` : undefined,
'--emphasized': override ? `${a.emphasizedFontSize}px` : undefined,
'--large': override ? `${a.largeFontSize}px` : undefined,
'--rolling-duration': `${Math.max(1, 200 / n.settings.speed)}s`,
flexDirection: container.value ? (n.settings.direction === 'vertical' ? 'column' : 'row') : undefined,
gap: container.value ? `${n.settings.gap}px` : undefined,
display: container.value ? 'flex' : undefined,
};
});
</script>
<template>
<div
v-if="!hidden"
class="component-node"
:class="[{ selected: editing && selectedId === node.id, container, 'node-editing': editing }, node.kind]"
:style="style"
:data-node="node.id"
@click="select"
>
<button
v-if="editing"
class="node-handle"
:data-kind="node.kind"
aria-label="拖动组件"
@pointerdown.stop="emit('drag', $event, node.id)"
>
⠿ {{ ISLAND_COMPONENT_LABELS[node.kind] }}
</button>
<template v-if="container">
<ComponentNode
v-for="child in visibleChildren"
:key="child.id"
:node="child"
:state="state"
:now="now"
:schedules="schedules"
:editing="editing"
:selected-id="selectedId"
:alert-node-id="alertNodeId"
:parent-has-width="Boolean(node.advanced.width || node.advanced.maxWidth)"
@select="emit('select', $event)"
@drag="(e, id) => emit('drag', e, id)"
>
<template #alerts><slot name="alerts" /></template>
</ComponentNode>
</template>
<template v-else-if="node.kind === 'separator'"><span aria-hidden="true">│</span></template>
<template v-else-if="node.kind === 'schedule'">
<div v-for="s in lessons" :key="s.id" class="lesson-line">
<strong>{{ s.course?.name }}</strong>
<small v-if="node.settings.showPeriod" class="lesson-time">{{ s.startTime }}–{{ s.endTime }}</small>
<small v-if="node.settings.showProgress && current?.id === s.id" class="lesson-remaining">
距下课 {{ Math.floor(remaining / 60) }}:{{ String(remaining % 60).padStart(2, '0') }}
</small>
</div>
<span v-if="!lessons.length">暂无课程</span>
<div
v-if="current && node.settings.showProgress && node.settings.display !== 'next'"
class="lesson-progress"
role="progressbar"
:aria-valuenow="Math.round(elapsed * 100)"
:aria-valuemin="0"
:aria-valuemax="100"
:aria-label="`${current.course?.name}课程进度,距下课${remaining}秒`"
>
<i :style="{ transform: `scaleX(${elapsed})` }" />
</div>
</template>
<template v-else-if="['notification', 'homework', 'call'].includes(node.kind)">
<slot v-if="node.id === alertNodeId" name="alerts" />
<span v-else-if="editing">{{ ISLAND_COMPONENT_LABELS[node.kind] }}内容组件</span>
</template>
<span v-else :class="{ emphasized: node.kind === 'clock' || node.kind === 'countdown' }">
{{ content }}
</span>
</div>
</template>
<style scoped>
.component-node {
min-width: 0;
overflow: hidden;
padding: 6px;
flex-shrink: 0;
position: relative;
}
.component-node:not(.node-editing) {
line-height: 1.4;
}
.clock {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.date,
.countdown {
white-space: nowrap;
}
.schedule > .lesson-line {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
}
.lesson-line strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lesson-time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.lesson-progress {
position: absolute;
inset: auto 4px 0;
height: 2px;
background: #ffffff24;
overflow: hidden;
}
.lesson-progress i {
display: block;
height: 100%;
background: var(--island-accent, #60a5fa);
transform-origin: left;
transition: transform 1s linear;
}
.schedule > .lesson-line + .lesson-line {
margin-top: 8px;
}
.lesson-remaining {
font-size: var(--secondary, 14px);
font-variant-numeric: tabular-nums;
opacity: 0.8;
white-space: nowrap;
}
.separator > span {
display: block;
width: 1px;
height: 24px;
overflow: hidden;
background: currentColor;
font-size: 0;
opacity: 0.25;
}
.container {
align-items: center;
}
.stack {
flex-wrap: wrap;
}
.rolling {
overflow: hidden;
}
.rolling > :deep(.component-node) {
animation: rolling var(--rolling-duration) linear infinite;
}
.selected {
outline: 2px solid #85aaff;
border-radius: 8px;
background: #759cff14;
}
.node-handle {
touch-action: none;
cursor: grab;
display: block;
font:
10px 'Microsoft YaHei',
sans-serif;
padding: 3px 6px;
border: 0;
border-radius: 4px;
min-height: 24px;
margin-bottom: 4px;
color: #ffffff80;
background: #ffffff0d;
}
.node-handle:hover {
background: #ffffff20;
color: #fff;
}
.notification,
.homework,
.call {
padding: 0;
}
.notification.node-editing,
.homework.node-editing,
.call.node-editing {
padding: 6px;
flex-basis: auto;
}
.node-editing {
cursor: pointer;
}
.node-editing:hover {
box-shadow: inset 0 0 0 1px #ffffff28;
border-radius: 8px;
}
small {
font-size: var(--secondary, 12px);
opacity: 0.7;
}
strong,
.emphasized {
font-size: var(--emphasized, 20px);
}
.large {
font-size: var(--large, 28px);
}
@keyframes rolling {
from {
transform: translateX(30%);
}
to {
transform: translateX(-80%);
}
}
</style>
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import {
COURSEWARE_CATEGORIES,
COURSEWARE_ISLAND_SIZE,
ISLAND_SHADOW_PAD,
type CoursewareTransfer,
type IslandAppearance,
} from '@classhelper/shared';
const props = defineProps<{ transfer: CoursewareTransfer; appearance: IslandAppearance }>();
const card = ref<HTMLElement>();
const style = computed(() => {
const position = props.appearance.position;
return {
width: `${COURSEWARE_ISLAND_SIZE.width}px`,
height: `${COURSEWARE_ISLAND_SIZE.height}px`,
[position.startsWith('bottom') ? 'bottom' : 'top']: `${ISLAND_SHADOW_PAD}px`,
...(position.endsWith('left')
? { left: `${ISLAND_SHADOW_PAD}px` }
: position.endsWith('right')
? { right: `${ISLAND_SHADOW_PAD}px` }
: { left: '50%', transform: 'translateX(-50%)' }),
'--transfer-accent': props.appearance.accent,
};
});
const status = computed(
() =>
({
downloading: '正在接收课件',
verifying: '正在校验文件',
completed: '课件接收完成',
failed: '课件接收失败',
})[props.transfer.stage],
);
function report() {
const rect = card.value?.getBoundingClientRect();
if (rect) window.island?.setHitRect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
}
watch(
() => [props.appearance.position, props.transfer],
() => void nextTick(report),
);
onMounted(() => {
report();
window.addEventListener('resize', report);
});
onUnmounted(() => window.removeEventListener('resize', report));
</script>
<template>
<section ref="card" class="courseware-transfer" :style="style" aria-label="课件传输状态">
<header>
<span>{{ status }}</span>
<strong>{{ transfer.stage === 'failed' ? '失败' : `${transfer.progress}%` }}</strong>
</header>
<div class="filename" :title="transfer.name">{{ transfer.name }}</div>
<div class="metadata">
{{ COURSEWARE_CATEGORIES[transfer.category] }} · {{ transfer.teacherName || '教师' }}
<span>
{{ (transfer.receivedBytes / 1048576).toFixed(1) }} /
{{ (transfer.totalBytes / 1048576).toFixed(1) }} MB
</span>
</div>
<progress :value="transfer.progress" max="100" :aria-label="`${transfer.name} 传输进度`" />
<p v-if="transfer.error">{{ transfer.error }}</p>
</section>
</template>
<style scoped>
.courseware-transfer {
position: fixed;
box-sizing: border-box;
padding: 17px 24px;
border-radius: 24px;
color: white;
background: rgba(20, 20, 24, 0.97);
box-shadow: 0 2px 8px #0004;
font:
13px/1.4 'Segoe UI',
'Microsoft YaHei',
sans-serif;
overflow: hidden;
pointer-events: none;
}
header,
.metadata {
display: flex;
justify-content: space-between;
gap: 10px;
}
header {
color: #ffffffb8;
font-size: 12px;
}
header strong {
color: var(--transfer-accent);
}
.filename {
font-weight: 600;
font-size: 16px;
margin: 5px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.metadata {
color: #ffffffb8;
font-size: 12px;
}
progress {
display: block;
width: 100%;
height: 5px;
margin-top: 11px;
border: 0;
border-radius: 8px;
overflow: hidden;
accent-color: var(--transfer-accent);
}
progress::-webkit-progress-bar {
background: #ffffff30;
}
progress::-webkit-progress-value {
background: var(--transfer-accent);
}
p {
margin: 3px 0;
font-size: 11px;
color: #ffb4ab;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,504 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type CSSProperties } from 'vue';
import {
ISLAND_SHADOW_PAD,
PRIORITY_RANK,
type IslandAppearance,
type IslandLayoutSettings,
type IslandState,
type ScheduleDto,
type IslandLessonReminder,
} from '@classhelper/shared';
import ComponentNode from './ComponentNode.vue';
import ComponentIcon from './ComponentIcon.vue';
import ClassicNotifications from './ClassicNotifications.vue';
import { squirclePath } from './squircle.js';
import { SpringValue } from './spring.js';
import LessonReminderContent from './LessonReminderContent.vue';
const props = defineProps<{
settings: IslandLayoutSettings;
state: IslandState;
schedules: ScheduleDto[];
appearance: IslandAppearance;
nativeVisible: boolean;
}>();
const scheme = computed(() => props.settings.schemes.find((s) => s.id === props.settings.activeSchemeId)!);
const expanded = computed(() => props.state.mode === 'expanded');
const lesson = computed(() => props.state.lessonReminder ?? null);
const lastLesson = ref<IslandLessonReminder | null>(null);
watch(
lesson,
(value) => {
if (value) lastLesson.value = value;
},
{ immediate: true },
);
const messages = computed(() => {
const unique = new Map(
[props.state.active, ...props.state.queued].filter((n) => n !== null).map((n) => [n.id, n]),
);
return [...unique.values()].sort(
(a, b) =>
PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority] ||
Date.parse(b.createdAt) - Date.parse(a.createdAt),
);
});
const shell = ref<HTMLElement>();
const compact = ref<HTMLElement>();
const details = ref<HTMLElement>();
const lessonSource = ref<HTMLElement>();
const now = ref(Date.now());
const visible = ref(!document.hidden);
const motion = computed(() => props.appearance.animations && visible.value && props.nativeVisible);
const available = ref({ width: 940, height: 620 });
const natural = ref({
compact: { width: 40, height: 40 },
details: { width: 280, height: 80 },
lesson: { width: 480, height: 40 },
});
const anchor = computed(() =>
props.appearance.position.endsWith('left')
? 'left'
: props.appearance.position.endsWith('right')
? 'right'
: 'center',
);
function fitted(size: { width: number; height: number }) {
// 以未变形内容测量;字体、配置缩放、行数和屏幕变化都参与适配,不从动画中的壳反推内容。
const scale = Math.min(
scheme.value.scale,
available.value.width / size.width,
available.value.height / size.height,
);
return { scale, width: Math.ceil(size.width * scale), height: Math.ceil(size.height * scale) };
}
const compactFit = computed(() => fitted(natural.value.compact));
const detailsFit = computed(() => fitted(natural.value.details));
const lessonFit = computed(() => fitted(natural.value.lesson));
const fit = computed(() =>
lesson.value ? lessonFit.value : expanded.value ? detailsFit.value : compactFit.value,
);
const morph = ref({ width: 40, height: 40, radius: 20 });
const springs = {
width: new SpringValue(40),
height: new SpringValue(40),
radius: new SpringValue(20),
content: new SpringValue(0, { snapThreshold: 0.001 }),
lesson: new SpringValue(0, { snapThreshold: 0.001 }),
};
// 与经典岛一致:常态内容先退净,展开内容按进度平方进入;反向收起沿同一条曲线返回。
// 独立进度也保证两页尺寸相同时仍播放过渡,不能靠高度差推导。
const progress = ref(0);
const lessonProgress = ref(0);
const compactAlpha = computed(() => Math.max(0, 1 - progress.value * 1.5) * (1 - lessonProgress.value));
const detailsAlpha = computed(() => progress.value * progress.value * (1 - lessonProgress.value));
const path = computed(() => squirclePath(morph.value.width, morph.value.height, morph.value.radius));
const style = computed<CSSProperties>(() => ({
width: `${morph.value.width}px`,
height: `${morph.value.height}px`,
'--card-path': `path("${path.value}")`,
'--island-font': `${props.appearance.fontSize}px`,
'--island-accent': props.appearance.accent,
'--state-color':
props.state.active?.priority === 'URGENT'
? '#ff453a'
: props.state.active?.kind === 'call'
? '#ff9f0a'
: props.appearance.accent,
'--resident-background': 'var(--wn-bg)',
'--resident-radius': `${morph.value.radius}px`,
'--resident-scale': fit.value.scale,
'--custom-row-gap': `${scheme.value.spacing}px`,
'--resident-row-width': `${Math.max(60, available.value.width / scheme.value.scale - 58)}px`,
'--secondary': `${(props.appearance.fontSize / 13) * 14}px`,
'--emphasized': `${(props.appearance.fontSize / 13) * 18}px`,
'--large': `${(props.appearance.fontSize / 13) * 20}px`,
'--ci-body-font': `${(props.appearance.fontSize / 13) * 16}px`,
'--resident-fade-duration': motion.value ? `${200 / props.appearance.speed}ms` : '0ms',
}));
let morphFrame = 0;
let morphFallback: ReturnType<typeof setTimeout> | undefined;
function animate() {
const size = fit.value;
const radius = Math.min(
lesson.value ? 8 : expanded.value ? props.appearance.radius : scheme.value.radius,
size.width / 2,
size.height / 2,
);
const target = { width: size.width, height: size.height, radius };
const snap = () => {
cancelAnimationFrame(morphFrame);
morphFrame = 0;
clearTimeout(morphFallback);
for (const key of ['width', 'height', 'radius'] as const) springs[key].snap(target[key]);
springs.content.snap(expanded.value ? 1 : 0);
springs.lesson.snap(lesson.value ? 1 : 0);
progress.value = springs.content.value;
lessonProgress.value = springs.lesson.value;
morph.value = target;
void nextTick(reportHit);
};
// 应用内动画开关与经典岛保持一致;系统减少动态效果不能再次覆盖用户已开启的动画。
if (!motion.value) {
snap();
return;
}
for (const key of ['width', 'height', 'radius'] as const) springs[key].setTarget(target[key]);
springs.content.setTarget(expanded.value ? 1 : 0);
springs.lesson.setTarget(lesson.value ? 1 : 0);
clearTimeout(morphFallback);
morphFallback = setTimeout(snap, 1200 / props.appearance.speed);
if (morphFrame) return;
let last = performance.now();
const frame = (time: number) => {
const dt = (time - last) * props.appearance.speed;
last = time;
// 三个维度每帧都推进;不能用短路 || 导致后面的维度等宽度结束才开始。
const moving = Object.values(springs)
.map((s) => s.step(dt))
.some(Boolean);
morph.value = { width: springs.width.value, height: springs.height.value, radius: springs.radius.value };
progress.value = springs.content.value;
lessonProgress.value = springs.lesson.value;
if (moving) morphFrame = requestAnimationFrame(frame);
else {
morphFrame = 0;
clearTimeout(morphFallback);
void nextTick(reportHit);
}
};
morphFrame = requestAnimationFrame(frame);
}
function action(value: 'expand' | 'collapse' | 'expand-list' | 'dismiss-all' | 'mark-all-read' | 'open-app') {
window.island?.sendAction(value);
}
function open(event: Event) {
if (!expanded.value && !(event.target as HTMLElement).closest('button')) action('expand');
}
function key(event: KeyboardEvent) {
if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) {
event.preventDefault();
action(expanded.value ? 'collapse' : 'expand');
}
if (event.key === 'Escape') action('collapse');
}
function pointer(event: PointerEvent) {
if (event.pointerType !== 'mouse') window.island?.noteResidentTouch();
}
let observer: ResizeObserver | undefined;
let clock: ReturnType<typeof setInterval>;
let hitFrame = 0;
let hitUntil = 0;
let hitTimer: ReturnType<typeof setTimeout> | undefined;
let lastHit = '';
function reportHit() {
const r = shell.value?.getBoundingClientRect();
if (!r) return;
const payload = { x: r.x, y: r.y, width: r.width, height: r.height };
const key = Object.values(payload).map(Math.round).join(',');
if (key !== lastHit) {
lastHit = key;
window.island?.setHitRect(payload);
}
const bell = !expanded.value ? shell.value?.querySelector('.resident-bell')?.getBoundingClientRect() : null;
window.island?.reportResidentControl(
bell ? { x: bell.x, y: bell.y, width: bell.width, height: bell.height } : null,
);
}
function trackHit() {
// 几何动画只在合成/渲染层播放;主进程只接收限流命中矩形,绝不据此改窗口大小。
if (!motion.value) {
reportHit();
return;
}
hitUntil = performance.now() + 600 / props.appearance.speed;
clearTimeout(hitTimer);
hitTimer = setTimeout(reportHit, 650 / props.appearance.speed);
if (hitFrame) return;
let last = 0;
const frame = (time: number) => {
if (time - last >= 60) {
reportHit();
last = time;
}
if (time < hitUntil) hitFrame = requestAnimationFrame(frame);
else {
hitFrame = 0;
reportHit();
}
};
hitFrame = requestAnimationFrame(frame);
}
let measuring = false;
function measure() {
if (measuring) return;
measuring = true;
void nextTick(() => {
measuring = false;
if (!compact.value || !details.value) return;
const read = (element: HTMLElement) => ({
width: Math.max(40, element.offsetWidth),
height: Math.max(40, element.offsetHeight),
});
natural.value = {
compact: read(compact.value),
details: read(details.value),
lesson: lessonSource.value ? read(lessonSource.value) : natural.value.lesson,
};
void nextTick(() => {
window.island?.reportSize({ width: fit.value.width, height: fit.value.height });
trackHit();
});
});
}
function resizeCanvas(width = innerWidth, height = innerHeight) {
available.value = {
width: Math.max(40, Math.min(width, innerWidth) - 2 * ISLAND_SHADOW_PAD - 4),
height: Math.max(40, Math.min(height, innerHeight) - 2 * ISLAND_SHADOW_PAD - 4),
};
measure();
}
const resize = () => resizeCanvas();
let stopViewport: (() => void) | undefined;
function visibility() {
visible.value = !document.hidden;
resize();
void nextTick(reportHit);
}
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
watch([expanded, scheme, lesson, () => props.state.listExpanded, messages], measure, {
deep: true,
flush: 'post',
});
watch(
[fit, motion, expanded, lesson, () => props.appearance.radius, () => props.appearance.speed],
() => {
animate();
trackHit();
},
{ flush: 'post' },
);
watch(
() => props.appearance.position,
() => void nextTick(trackHit),
);
onMounted(() => {
resize();
stopViewport = window.island?.onViewport((v) => resizeCanvas(v.width, v.height));
observer = new ResizeObserver((entries) => {
if (entries.some((entry) => entry.target === document.documentElement)) resize();
else measure();
});
observer.observe(document.documentElement);
if (compact.value) observer.observe(compact.value);
if (details.value) observer.observe(details.value);
if (lessonSource.value) observer.observe(lessonSource.value);
clock = setInterval(() => (now.value = Date.now()), 1000);
window.addEventListener('resize', resize);
document.addEventListener('visibilitychange', visibility);
void document.fonts.ready.then(measure);
});
onUnmounted(() => {
stopViewport?.();
observer?.disconnect();
clearInterval(clock);
clearTimeout(hitTimer);
cancelAnimationFrame(hitFrame);
cancelAnimationFrame(morphFrame);
clearTimeout(morphFallback);
window.removeEventListener('resize', resize);
document.removeEventListener('visibilitychange', visibility);
window.island?.reportResidentControl(null);
});
</script>
<template>
<div
class="resident-window"
:data-anchor="anchor"
:data-bottom="appearance.position.startsWith('bottom')"
@pointerdown.capture="pointer"
>
<section
ref="shell"
class="custom-card resident-card island-card"
:data-style="appearance.style"
:class="{
'resident-open': expanded,
expanded,
pill: !expanded,
urgent: state.active?.priority === 'URGENT',
call: state.active?.kind === 'call',
'resident-separated': scheme.separated && !expanded && !lesson,
'resident-lesson': !!lesson,
'message-surface': expanded && messages.length > 0 && !lesson,
}"
:style="style"
:role="expanded ? 'region' : 'button'"
tabindex="0"
:aria-label="
expanded
? '自定义灵动岛内容'
: messages.length
? `查看 ${messages.length} 条通知`
: '展开自定义灵动岛'
"
:aria-expanded="expanded"
@click="open"
@keydown="key"
@transitionend.self="reportHit"
>
<svg
class="shape"
:width="morph.width"
:height="morph.height"
:viewBox="`0 0 ${morph.width} ${morph.height}`"
preserveAspectRatio="none"
aria-hidden="true"
>
<path :d="path" />
</svg>
<span class="tint" aria-hidden="true" />
<div
class="resident-page resident-compact-page"
:style="{
'--resident-scale': compactFit.scale,
opacity: compactAlpha,
visibility: compactAlpha > 0 ? 'visible' : 'hidden',
}"
:class="{ 'page-active': !expanded }"
:inert="expanded"
:aria-hidden="expanded"
>
<div ref="compact" class="resident-source resident-compact">
<div class="layout-rows" :style="{ gap: `${scheme.spacing}px` }">
<section
v-for="row in scheme.rows"
:key="row.id"
class="layout-row"
:data-row="row.id"
:style="{ '--row-gap': `${row.gap}px` }"
>
<div class="row-components">
<ComponentNode
v-for="node in row.nodes"
:key="node.id"
:node="node"
:state="state"
:now="now"
:schedules="schedules"
:editing="false"
selected-id=""
alert-node-id=""
/>
</div>
</section>
</div>
<button
class="resident-bell"
:class="{ 'bell-empty': !messages.length }"
:aria-label="messages.length ? `查看 ${messages.length} 条通知` : '展开自定义灵动岛'"
@click.stop="action('expand')"
>
<ComponentIcon v-if="messages.length" kind="notification" />
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
aria-hidden="true"
>
<path d="m8 10 4 4 4-4" />
</svg>
<span v-if="messages.length" :key="messages.length">
{{ messages.length > 99 ? '99+' : messages.length }}
</span>
</button>
</div>
</div>
<div
class="resident-page resident-detail-page"
:style="{
'--resident-scale': detailsFit.scale,
opacity: detailsAlpha,
visibility: detailsAlpha > 0 ? 'visible' : 'hidden',
}"
:class="{ 'page-active': expanded }"
:inert="!expanded"
:aria-hidden="!expanded"
>
<div
ref="details"
class="resident-source resident-details"
:class="{ 'with-messages': messages.length }"
>
<ClassicNotifications
v-if="messages.length"
id="custom-alert-host"
:state="state"
:appearance="appearance"
:max-height="available.height / scheme.scale"
/>
<header v-else class="resident-panel-heading">
<span>自定义灵动岛</span>
<button class="icon-btn" aria-label="收起自定义灵动岛" @click.stop="action('collapse')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="m8 14 4-4 4 4" />
</svg>
</button>
</header>
<div
v-if="!messages.length"
class="layout-rows resident-expanded-rows"
:style="{ gap: `${scheme.spacing}px` }"
>
<section
v-for="row in scheme.expandedRows"
:key="row.id"
class="layout-row"
:data-row="row.id"
:style="{ '--row-gap': `${row.gap}px` }"
>
<div class="row-components">
<ComponentNode
v-for="node in row.nodes"
:key="node.id"
:node="node"
:state="state"
:now="now"
:schedules="schedules"
:editing="false"
selected-id=""
alert-node-id=""
/>
</div>
</section>
</div>
</div>
</div>
<div
class="resident-page resident-lesson-page"
:style="{
'--resident-scale': lessonFit.scale,
opacity: lessonProgress,
visibility: lessonProgress > 0 ? 'visible' : 'hidden',
}"
:aria-hidden="!lesson"
:inert="!lesson"
>
<div ref="lessonSource" class="resident-source">
<LessonReminderContent
v-if="lastLesson"
:reminder="lastLesson"
:now="now"
:appearance="appearance"
:motion="motion"
/>
</div>
</div>
</section>
</div>
</template>
<style src="./custom-runtime.css"></style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
import {
defaultIslandLayouts,
DEFAULT_ISLAND_APPEARANCE,
type IslandAppearance,
type IslandState,
type ScheduleDto,
} from '@classhelper/shared';
import FixedIsland from './IslandApp.vue';
import CoursewareTransfer from './CoursewareTransfer.vue';
import './custom-island.css';
import CustomIslandRuntime from './CustomIslandRuntime.vue';
const settings = ref(defaultIslandLayouts()),
ready = ref(false);
const schedules = ref<ScheduleDto[]>([]);
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const nativeVisible = ref(false);
const resident = () => settings.value.layout === 'custom';
const state = ref<IslandState>({
mode: 'hidden',
active: null,
queued: [],
inClass: false,
currentPeriodEnd: null,
reason: null,
listExpanded: false,
maxCardHeight: 1024,
updatedAt: 0,
});
let heartbeat: ReturnType<typeof setInterval>;
onUnmounted(() => clearInterval(heartbeat));
onMounted(async () => {
heartbeat = setInterval(() => window.island?.alive(), 5000);
const bridge = window.island;
if (bridge) {
bridge.onLayout((value) => (settings.value = value));
bridge.onSchedules((value) => (schedules.value = value));
bridge.onState((value) => (state.value = value));
bridge.onAppearance((value) => (appearance.value = value));
bridge.onVisibility((value) => (nativeVisible.value = value));
nativeVisible.value = await bridge.getVisible();
appearance.value = await bridge.getAppearance();
settings.value = await bridge.getLayout();
schedules.value = await bridge.getSchedules();
state.value = await bridge.getState();
}
await nextTick();
ready.value = true;
});
</script>
<template>
<CustomIslandRuntime
v-if="resident() && !state.coursewareTransfer"
:settings="settings"
:state="state"
:schedules="schedules"
:appearance="appearance"
:native-visible="nativeVisible"
/>
<FixedIsland v-if="ready" v-show="!resident() && !state.coursewareTransfer" :suspended="resident() || !!state.coursewareTransfer" />
<CoursewareTransfer v-if="state.coursewareTransfer" :transfer="state.coursewareTransfer" :appearance="appearance" />
</template>
@@ -0,0 +1,299 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import type { IslandAppearance, IslandLessonReminder } from '@classhelper/shared';
const props = defineProps<{
reminder: IslandLessonReminder;
now: number;
appearance: IslandAppearance;
motion: boolean;
}>();
const root = ref<HTMLElement>();
const width = ref(480);
const mask = computed(() => props.now < props.reminder.maskUntil);
const title = computed(
() =>
({
prepare: '即将上课',
'on-class': '上课',
break: '课间休息',
ending: '即将下课',
'in-class': '正在上课',
})[props.reminder.kind],
);
const leftSeconds = computed(() =>
Math.max(
0,
Math.ceil(
((props.reminder.kind === 'ending' || props.reminder.kind === 'in-class'
? props.reminder.lessonEndsAt
: props.reminder.lessonStartsAt)! -
props.now) /
1000,
),
),
);
const progress = computed(() =>
Math.max(
0,
Math.min(
1,
props.reminder.kind === 'in-class' || props.reminder.kind === 'ending'
? (props.now - props.reminder.lessonStartsAt) /
Math.max(
1,
(props.reminder.lessonEndsAt ?? props.reminder.endsAt) - props.reminder.lessonStartsAt,
)
: (props.reminder.endsAt - props.now) / Math.max(1, props.reminder.endsAt - props.reminder.startedAt),
),
),
);
const course = computed(() =>
props.reminder.kind === 'break' ? props.reminder.nextCourseName : props.reminder.courseName,
);
const time = computed(() =>
props.reminder.kind === 'break'
? `${props.reminder.nextStartTime || ''}–${props.reminder.nextEndTime || ''}`
: `${props.reminder.startTime}–${props.reminder.endTime}`,
);
const regions = ref([0, 0, 0, 0, 0]);
let frame = 0;
// ClassIsland SlantedMaskControl:5 个斜切区域、30°偏移、两侧向中央打开,中央向两侧关闭。
const polygons = computed(() => {
const offset = Math.tan(Math.PI / 6) * 40;
const total = width.value + offset;
const edges = [0, 0.1, 0.3, 0.7, 0.9, 1];
return regions.value.map((p, i) => {
const center = (total * (edges[i]! + edges[i + 1]!)) / 2;
const half = ((total * (edges[i + 1]! - edges[i]!) + 0.5) * p) / 2;
return `${center - half},0 ${center + half},0 ${center + half - offset},40 ${center - half - offset},40`;
});
});
function animateMask() {
cancelAnimationFrame(frame);
const target = mask.value ? 1 : 0;
if (!props.motion) {
regions.value = [target, target, target, target, target];
return;
}
const from = [...regions.value];
const started = performance.now();
const delays = mask.value ? [0, 90, 120, 90, 0] : [120, 90, 0, 90, 120];
const update = (at: number) => {
const elapsed = (at - started) * props.appearance.speed;
regions.value = from.map((value, i) => {
const p = Math.max(0, Math.min(1, (elapsed - delays[i]!) / 360));
const eased = target ? 1 - (1 - p) ** 4 : p * p;
return value + (target - value) * eased;
});
if (elapsed < 480) frame = requestAnimationFrame(update);
};
frame = requestAnimationFrame(update);
}
watch(
[mask, () => props.reminder.id, () => props.motion, () => props.appearance.speed],
() => {
if (mask.value) regions.value = [0, 0, 0, 0, 0];
animateMask();
},
{ flush: 'post' },
);
let observer: ResizeObserver | undefined;
onMounted(() => {
observer = new ResizeObserver(() => {
if (root.value) width.value = root.value.offsetWidth;
});
if (root.value) observer.observe(root.value);
void nextTick(animateMask);
});
onUnmounted(() => {
observer?.disconnect();
cancelAnimationFrame(frame);
});
</script>
<template>
<div
ref="root"
class="ci-lesson"
:class="{ 'ci-mask-active': mask }"
:data-kind="reminder.kind"
:style="{
'--ci-speed': appearance.speed,
'--ci-duration': motion ? `${200 / appearance.speed}ms` : '0ms',
'--ci-title-duration': motion ? `${250 / appearance.speed}ms` : '0ms',
'--ci-scale-duration': motion ? `${750 / appearance.speed}ms` : '0ms',
'--ci-delay': motion ? `${260 / appearance.speed}ms` : '0ms',
}"
role="status"
aria-live="polite"
>
<div class="ci-lesson-info">
<span
v-if="reminder.kind === 'prepare' || reminder.kind === 'ending' || reminder.kind === 'in-class'"
class="ci-countdown"
>
{{ reminder.kind === 'prepare' ? '距上课还剩' : '距下课还剩' }}
<strong>{{ leftSeconds }}</strong>
秒
</span>
<span v-else class="ci-countdown">课间休息</span>
<span class="ci-lesson-course">
<span>
{{
reminder.kind === 'ending' || reminder.kind === 'on-class' || reminder.kind === 'in-class'
? '本节课:'
: '下节课是:'
}}
</span>
<strong>{{ course }}</strong>
<small>{{ time }}</small>
</span>
</div>
<div
class="ci-lesson-progress"
role="progressbar"
:aria-valuenow="Math.round(progress * 100)"
aria-valuemin="0"
aria-valuemax="100"
aria-label="课程与提醒进度"
:style="{ transform: `scaleX(${progress})` }"
/>
<svg class="ci-mask" :viewBox="`0 0 ${width} 40`" preserveAspectRatio="none" aria-hidden="true">
<polygon v-for="(points, i) in polygons" :key="i" :points="points" />
</svg>
<div class="ci-mask-title" :class="{ 'ci-title-in': mask }">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M6 9a6 6 0 0 1 12 0v4l2 3H4l2-3V9Zm4 10a2 2 0 0 0 4 0" />
</svg>
<strong>{{ title }}</strong>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
</div>
</div>
</template>
<style scoped>
.ci-lesson {
position: relative;
width: max-content;
min-width: 300px;
max-width: 800px;
height: 40px;
overflow: hidden;
color: #fff;
font-size: 16px;
line-height: 1;
border-radius: 8px;
background: #20202080;
}
.ci-lesson-info {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 0 28px;
white-space: nowrap;
transition: opacity var(--ci-duration) ease-in;
}
.ci-mask-active .ci-lesson-info {
opacity: 0;
}
.ci-countdown {
flex-shrink: 0;
}
.ci-countdown strong {
font-variant-numeric: tabular-nums;
}
.ci-lesson-course {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.ci-lesson-course strong {
font-size: 20px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.ci-lesson-course small {
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.ci-mask {
position: absolute;
inset: 0;
width: 100%;
height: 40px;
fill: var(--island-accent, #60a5fa);
pointer-events: none;
}
.ci-mask-title {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
opacity: 0;
font-size: 18px;
transform: scale(1.1);
transition:
opacity var(--ci-duration) linear,
transform var(--ci-duration) cubic-bezier(0.5, 0, 0.75, 0);
pointer-events: none;
}
.ci-mask-title svg {
width: 20px;
height: 20px;
}
.ci-title-in {
animation:
ci-title-enter var(--ci-title-duration) var(--ci-delay) cubic-bezier(0.25, 1, 0.5, 1) both,
ci-title-scale var(--ci-scale-duration) var(--ci-delay) cubic-bezier(0.25, 1, 0.5, 1) both;
}
.ci-mask-title:not(.ci-title-in) {
animation: ci-title-leave var(--ci-duration) linear both;
}
@keyframes ci-title-enter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes ci-title-scale {
from {
transform: scale(1.1);
}
to {
transform: scale(1);
}
}
@keyframes ci-title-leave {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(1.1);
}
}
.ci-lesson-progress {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--island-accent, #60a5fa);
transform-origin: left;
transition: transform 50ms linear;
}
</style>
@@ -0,0 +1,757 @@
/*
* 排版系数(必须与 @classhelper/shared 的 ISLAND_TYPE_SCALE 保持一致):
* title 1.08 / body 0.95 / meta 0.78 / badge 0.76 / pillTitle 0.92 / pillSub 0.74
*
* 关键:所有文本尺寸都由 --island-font 通过 calc() 推导,所以「字号」设置一改,
* 岛内文字立即整体等比缩放(WinIsland 的 font_size 模型),不存在写死的字号。
*/
html,
body,
#app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: transparent;
overflow: hidden;
-webkit-user-select: none;
user-select: none;
}
body {
font-family:
-apple-system, 'Segoe UI Variable Display', 'Segoe UI', 'Microsoft YaHei', 'PingFang SC',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
.island-root {
width: 100%;
height: 100%;
position: relative;
background: transparent;
}
/* ---------------------------------------------------------------- 卡片与形状 */
.island-root.embedded .island-card {
left: 0 !important;
top: 0 !important;
right: auto !important;
bottom: auto !important;
transform: none !important;
}
.island-card {
position: absolute;
display: block;
padding: 0;
margin: 0;
border: none;
background: none;
color: rgba(255, 255, 255, 0.92);
font-size: var(--island-font, 13px);
text-align: left;
cursor: pointer;
/* 配色 token(默认:WinIsland 纯黑风格) */
--wn-bg: #000000;
--wn-border: rgba(255, 255, 255, 0.118);
--wn-text-1: rgba(255, 255, 255, 0.92);
--wn-text-2: rgba(255, 255, 255, 0.72);
--wn-text-3: rgba(255, 255, 255, 0.58);
--wn-surface: rgba(255, 255, 255, 0.1);
--wn-surface-hover: rgba(255, 255, 255, 0.18);
--wn-divider: rgba(255, 255, 255, 0.09);
}
.island-card[data-style='glass'] {
/* WinIsland「glass 无 host backdrop」降级色:比纯黑透一点,但保证任何桌面下都不是白底 */
--wn-bg: rgba(32, 32, 36, 0.804);
--wn-border: rgba(255, 255, 255, 0.157);
--wn-surface: rgba(255, 255, 255, 0.14);
--wn-surface-hover: rgba(255, 255, 255, 0.22);
}
.island-card[data-style='tinted'] {
--wn-bg: #0b0b0f;
--wn-border: rgba(255, 255, 255, 0.14);
}
/* 阅读老师消息时使用实色底,玻璃风格也不透出桌面。 */
.island-card.message-surface {
--wn-bg: #202020;
}
.island-card.hidden-placeholder {
visibility: hidden;
}
.island-card.ci-lesson-card .shape path {
fill: transparent;
stroke: none;
}
.island-card .ci-fixed-lesson {
position: absolute;
inset: 0;
width: 100%;
min-width: 0;
}
/* 连续圆角形状:填充 + 1px 描边(描边画在裁切之外,与 WinIsland 一致) */
.island-card .shape {
position: absolute;
left: 0;
top: 0;
display: block;
}
.island-card .shape path {
fill: var(--wn-bg);
stroke: var(--wn-border);
stroke-width: 1;
/*
投影**始终声明**,形态切换只改参数 —— 不要写成"只在 .expanded 上挂 filter"。
给元素加 / 去 CSS filter 会让 Chromium 新建或销毁它的渲染表面(effect node),
而首帧的合成可能发生在新表面栅格化完成之前,那一帧会被当作空内容画出去;
卡片的底色恰恰就画在这个被过滤的元素上(fill 在本元素),于是**开/合的那一瞬间
底座会闪掉一帧、只剩未过滤的文字层**(用户反馈的"开合时一瞬间的闪动")。
胶囊态用 0 0 0:硬轮廓投影正好压在路径底下,肉眼等同无投影,与 WinIsland
"只有展开态有投影"的观感一致。
*/
filter: drop-shadow(0 var(--card-shadow-y, 0px) var(--card-shadow-blur, 0px) rgba(0, 0, 0, 0.11));
}
/* 内容层裁切到同一形状内;内部两层(胶囊层 / 展开层)交叉淡入 */
.island-card .content {
position: absolute;
inset: 0;
display: block;
clip-path: var(--card-path);
overflow: hidden;
}
.island-card .layer {
position: absolute;
inset: 0;
display: flex;
pointer-events: none;
}
/* 只有"当前形态"的那一层响应鼠标,避免透明层的按钮被误点 */
.island-card.pill .pill-layer,
.island-card.expanded .expanded-layer {
pointer-events: auto;
}
/* 展开态投影(WinIsland: rgba(0,0,0,.11)、y+2、σ=3 → CSS 0 2px 3px)。
只改参数、不动 filter 声明本身,理由见上面的 .shape path。 */
.island-card.expanded .shape path {
--card-shadow-y: 2px;
--card-shadow-blur: 3px;
}
/* 状态色:只做"内层微染 + 描边",不外溢(保持桌面干净,也不会有光晕硬边) */
.island-card.urgent .shape path,
.island-card.call .shape path {
stroke: color-mix(in srgb, var(--state-color) 62%, rgba(255, 255, 255, 0.2));
}
.island-card .tint {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
}
.island-card.urgent .tint {
opacity: 1;
background: radial-gradient(120% 90% at 50% 0%, rgba(255, 69, 58, 0.22), transparent 68%);
}
.island-card.call .tint {
opacity: 1;
background: radial-gradient(120% 90% at 50% 0%, rgba(255, 159, 10, 0.2), transparent 68%);
}
.island-card[data-style='tinted'] .tint {
opacity: 1;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--island-accent) 30%, transparent),
transparent 62%
);
}
/* ---------------------------------------------------------------- 胶囊(compact) */
.pill-layer {
flex-direction: row;
align-items: center;
/* 水平对齐交给 .pill-inner(它固定为胶囊几何,且按停靠方式贴边/居中) */
justify-content: flex-start;
}
/**
* 胶囊内容盒:**固定为胶囊自己的宽高与内边距**,不随卡片形变重排。
*
* 为什么必须固定:卡片在开合时 268 ⇄ 424 变宽变窄,而中心停靠下卡片的左右边都会动。
* 内容若跟着卡片宽度走,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回"
* (用户反馈的"新消息那行字往右跳一下再缩回"),底部也会被收缩的形状裁掉一块。
* 固定成胶囊几何后:文字在整段动画里位置与省略号都不变,只是随卡片一起淡出/淡入。
*
* 贴哪条边与卡片停靠方式一致(左停靠贴左、右停靠贴右、居中则居中对齐)——
* 这样卡片变宽时内容也不会横移,静止态外观与之前完全一致。
*/
.pill-inner {
display: inline-flex;
flex-direction: row;
align-items: center;
flex: 0 0 auto;
gap: calc(var(--island-font, 13px) * 0.62);
width: var(--island-w, 268px);
height: 100%;
padding: 0 calc(var(--island-font, 13px) * 0.78);
}
.island-card[data-anchor='center'] .pill-layer {
justify-content: center;
}
.island-card[data-anchor='right'] .pill-layer {
justify-content: flex-end;
}
/**
* 收回动画(`reason === 'closing'`):胶囊收回过程中**不显示任何胶囊内容**。
*
* 这时状态已经在同一帧清空(没有可展示的消息),若照常渲染胶囊层,卡片刚收回就会闪出
* 一句"新消息 · 点击查看" —— 语义正好相反。这里保留 DOM(宽度固定为 `--island-w`,
* 内容盒不会重排,符合"开合时胶囊内容不许重排"的约定)只把内容隐藏,
* 卡片形状照样沿既有弹簧收回,随后由主进程淡出窗口。
*/
.island-card.closing .pill-inner {
opacity: 0;
}
.pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: calc(var(--island-font, 13px) * 1.24);
height: calc(var(--island-font, 13px) * 1.24);
border-radius: 50%;
background: var(--wn-surface);
color: var(--wn-text-1);
}
.pill-icon svg {
width: calc(var(--island-font, 13px) * 0.86);
height: calc(var(--island-font, 13px) * 0.86);
}
.pill-icon.call {
color: #ff9f0a;
}
.pill-icon.urgent {
color: #ff453a;
}
.pill-icon.homework {
color: var(--island-accent);
}
.pill-text {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
flex: 1 1 auto;
gap: 1px;
}
.pill-title {
font-size: calc(var(--island-font, 13px) * 0.92);
font-weight: 600;
line-height: 1.15;
color: var(--wn-text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pill-sub {
font-size: calc(var(--island-font, 13px) * 0.74);
line-height: 1.15;
color: var(--wn-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 多条消息时把类型做成胶囊小标签(叫人/作业/通知),收起状态也能一眼看出内容构成 */
.pill-types {
display: inline-flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.2);
flex: 0 0 auto;
padding-right: calc(var(--island-font, 13px) * 0.2);
}
.pill-type {
font-size: calc(var(--island-font, 13px) * 0.6);
line-height: 1.4;
padding: 0 calc(var(--island-font, 13px) * 0.36);
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: var(--wn-text-2);
white-space: nowrap;
}
.pill-chevron {
display: inline-flex;
align-items: center;
color: rgba(255, 255, 255, 0.34);
flex: 0 0 auto;
}
.pill-chevron svg {
width: calc(var(--island-font, 13px) * 0.86);
height: calc(var(--island-font, 13px) * 0.86);
}
/* ---------------------------------------------------------------- 展开卡(expanded) */
.expanded-layer {
/* 关键:展开层必须纵向排列。此前把 flex-direction 从基类挪到分层时漏了这一条,
导致展开卡内容横排(标题/元信息/按钮散落,实机可见) */
flex-direction: column;
padding: calc(var(--island-font, 13px) * 1.4) calc(var(--island-font, 13px) * 1.6);
gap: calc(var(--island-font, 13px) * 0.7);
justify-content: center;
}
/*
* 多条通知的列表形态。
*
* 这里的每个尺寸都必须与 @classhelper/shared 的 `ISLAND_LIST_METRICS` 一一对应:
* 主进程按那份度量算出**窗口包围盒**(列表比普通展开卡高,窗口不够高会把底部按钮裁掉),
* 渲染进程按同一份度量排布。改这里就必须改那里,否则会出现"最后一行被切掉"。
* 选择器一律写成 `.expanded-layer.list-mode ...`(比基类的单类选择器更具体),
* 免得被后面定义的 `.title` / `.body` 覆盖掉尺寸——那样高度就对不上算出来的值了。
*/
.expanded-layer.list-mode {
/* 高度已经算准,改 flex-start:居中排版一旦有像素级误差会把上下两端同时切掉 */
justify-content: flex-start;
}
.expanded-layer.list-mode .head {
height: calc(var(--island-font, 13px) * 1.7);
}
.expanded-layer.list-mode .title {
flex: 0 0 auto;
display: block;
height: calc(var(--island-font, 13px) * 1.4);
line-height: 1.4;
-webkit-line-clamp: unset;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.expanded-layer.list-mode .body {
flex: 1 1 auto;
min-height: 0;
gap: calc(var(--island-font, 13px) * 0.4);
justify-content: flex-start;
overflow: hidden;
}
.head-icon.list {
background: color-mix(in srgb, var(--island-accent) 20%, transparent);
color: var(--island-accent);
}
.list-row {
display: flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.55);
flex: 0 0 auto;
width: 100%;
height: calc(var(--island-font, 13px) * 2.7);
padding: 0 calc(var(--island-font, 13px) * 0.6);
border: none;
border-radius: calc(var(--island-font, 13px) * 0.5);
background: rgba(255, 255, 255, 0.045);
color: var(--wn-text-1);
text-align: left;
cursor: pointer;
transition: background var(--ch-dur-fast) var(--ch-ease-out);
}
.row-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: calc(var(--island-font, 13px) * 1.3);
height: calc(var(--island-font, 13px) * 1.3);
border-radius: calc(var(--island-font, 13px) * 0.36);
background: var(--wn-surface);
color: var(--wn-text-2);
}
.row-icon svg {
width: calc(var(--island-font, 13px) * 0.86);
height: calc(var(--island-font, 13px) * 0.86);
}
.row-icon.call {
color: #ff9f0a;
}
.row-icon.homework {
color: var(--island-accent);
}
.row-text {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
min-width: 0;
flex: 1 1 auto;
}
.row-title {
font-size: calc(var(--island-font, 13px) * 0.9);
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-sub {
font-size: calc(var(--island-font, 13px) * 0.68);
line-height: 1.1;
color: var(--wn-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-badge {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
height: calc(var(--island-font, 13px) * 1.25);
padding: 0 calc(var(--island-font, 13px) * 0.5);
border-radius: 999px;
font-size: calc(var(--island-font, 13px) * 0.68);
font-weight: 600;
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.78);
}
/* 列表底部的提示行:能展开时是按钮,屏幕到任务栏了就是一句"去应用里看" */
.list-hint {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
height: calc(var(--island-font, 13px) * 2.1);
}
.more-btn {
border: none;
background: var(--wn-surface);
color: var(--wn-text-1);
height: 100%;
width: 100%;
border-radius: calc(var(--island-font, 13px) * 0.5);
font-size: calc(var(--island-font, 13px) * 0.76);
font-weight: 600;
cursor: pointer;
transition: background var(--ch-dur-fast) var(--ch-ease-out);
}
.more-app {
font-size: calc(var(--island-font, 13px) * 0.76);
color: var(--wn-text-3);
white-space: nowrap;
}
.head {
display: flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.7);
flex: 0 0 auto;
}
.head-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(var(--island-font, 13px) * 1.62);
height: calc(var(--island-font, 13px) * 1.62);
border-radius: calc(var(--island-font, 13px) * 0.5);
background: var(--wn-surface);
color: var(--wn-text-1);
flex: 0 0 auto;
}
.head-icon svg {
width: calc(var(--island-font, 13px) * 1.05);
height: calc(var(--island-font, 13px) * 1.05);
}
.head-icon.urgent {
background: rgba(255, 69, 58, 0.2);
color: #ff453a;
}
.head-icon.call {
background: rgba(255, 159, 10, 0.2);
color: #ff9f0a;
}
.head-icon.homework {
background: color-mix(in srgb, var(--island-accent) 22%, transparent);
color: var(--island-accent);
}
.head-meta {
display: flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.55);
min-width: 0;
flex: 1 1 auto;
}
.badge {
display: inline-flex;
align-items: center;
height: calc(var(--island-font, 13px) * 1.5);
padding: 0 calc(var(--island-font, 13px) * 0.62);
border-radius: 999px;
font-size: calc(var(--island-font, 13px) * 0.76);
font-weight: 600;
letter-spacing: 0.02em;
background: color-mix(in srgb, var(--island-accent) 26%, transparent);
color: color-mix(in srgb, var(--island-accent) 70%, #ffffff);
flex: 0 0 auto;
}
.badge-urgent {
background: rgba(255, 69, 58, 0.24);
color: #ff6961;
}
.badge-high {
background: rgba(255, 159, 10, 0.22);
color: #ffb340;
}
.badge-low,
.badge-normal {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.78);
}
.badge-call {
background: rgba(255, 159, 10, 0.24);
color: #ffb340;
}
.badge-homework {
background: color-mix(in srgb, var(--island-accent) 26%, transparent);
color: color-mix(in srgb, var(--island-accent) 72%, #ffffff);
}
.live-dot {
display: inline-flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.32);
font-size: calc(var(--island-font, 13px) * 0.76);
color: #ff6961;
white-space: nowrap;
}
.live-dot.call {
color: #ffb340;
}
.live-dot.muted {
color: var(--wn-text-3);
}
.head-sub {
font-size: calc(var(--island-font, 13px) * 0.78);
color: var(--wn-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(var(--island-font, 13px) * 1.7);
height: calc(var(--island-font, 13px) * 1.7);
border: none;
border-radius: 50%;
background: var(--wn-surface);
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
flex: 0 0 auto;
transition:
background var(--ch-dur-fast) var(--ch-ease-out),
color var(--ch-dur-fast) var(--ch-ease-out);
}
.icon-btn svg {
width: calc(var(--island-font, 13px) * 0.8);
height: calc(var(--island-font, 13px) * 0.8);
}
.title {
margin: 0;
font-size: calc(var(--island-font, 13px) * 1.08);
font-weight: 700;
line-height: 1.3;
color: #fff;
flex: 0 0 auto;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.body {
flex: 0 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: calc(var(--island-font, 13px) * 0.35);
overflow: hidden;
}
.content-text {
margin: 0;
font-size: calc(var(--island-font, 13px) * 0.95);
line-height: 1.42;
color: var(--wn-text-2);
word-break: break-word;
}
.call-hint {
margin: 0;
font-size: calc(var(--island-font, 13px) * 0.85);
line-height: 1.4;
color: color-mix(in srgb, var(--state-color) 66%, #ffffff);
word-break: break-word;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(var(--island-font, 13px) * 0.7);
flex: 0 0 auto;
margin-top: calc(var(--island-font, 13px) * 0.5);
padding-top: calc(var(--island-font, 13px) * 0.7);
border-top: 1px solid var(--wn-divider);
}
.more {
font-size: calc(var(--island-font, 13px) * 0.78);
color: var(--wn-text-2);
white-space: nowrap;
}
.more.muted {
color: var(--wn-text-3);
}
.actions {
display: flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.5);
flex: 0 0 auto;
}
.ghost-btn,
.solid-btn {
height: calc(var(--island-font, 13px) * 2.05);
padding: 0 calc(var(--island-font, 13px) * 0.9);
border: none;
border-radius: 999px;
font-size: calc(var(--island-font, 13px) * 0.76);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition:
background var(--ch-dur-fast) var(--ch-ease-out),
transform var(--ch-dur-instant) var(--ch-ease-out);
}
.ghost-btn {
background: var(--wn-surface);
color: var(--wn-text-1);
}
.solid-btn {
background: #ffffff;
color: #0b0b0f;
}
.island-card.call .solid-btn {
background: #ff9f0a;
color: #1a1205;
}
.island-card.urgent .solid-btn {
background: #ff453a;
color: #ffffff;
}
.ghost-btn:active,
.solid-btn:active {
transform: scale(0.97);
}
/* --------------------------------------------------------------- 悬停微交互
*
* **必须关在 `@media (hover: hover) and (pointer: fine)` 里**:教室机器上的希沃触摸屏
* 点一下会产生"幽灵 hover"并**一直粘着**,直到点别处才消失 —— 不关的话,点过「知道了」
* 的按钮会永远保持高亮(同 AGENTS.md 的现行工程约定那类问题)。
* 这是 beUI `useHoverCapable` 的 CSS 版:纯样式效果用媒体查询就够,不必挂 composable。
*/
@media (hover: hover) and (pointer: fine) {
.list-row:hover {
background: var(--wn-surface);
}
.more-btn:hover,
.ghost-btn:hover {
background: var(--wn-surface-hover);
}
.icon-btn:hover {
background: var(--wn-surface-hover);
color: #ffffff;
}
}
/* 同一个通知组件用于经典岛绝对层与自定义岛自然测量层。 */
.classic-notifications {
display: flex;
flex-direction: column;
flex-shrink: 0;
}
@@ -0,0 +1,205 @@
.custom-window {
padding: 10px;
width: 100vw;
height: 100vh;
}
.custom-card {
box-sizing: border-box;
padding: 12px;
overflow: hidden;
background: var(--custom-color);
border-radius: var(--custom-radius);
box-shadow:
inset 0 0 0 1px #ffffff1e,
0 2px 3px #0000001c;
}
.custom-inner {
width: max-content;
display: flex;
flex-direction: column;
gap: var(--custom-row-gap);
}
.custom-card .layout-rows {
display: flex;
flex-direction: column;
gap: var(--custom-row-gap);
min-width: 0;
}
.custom-card .custom-inner.resident-compact {
flex-direction: row;
align-items: center;
gap: 6px;
}
.custom-card.resident-size {
padding: 0 var(--resident-padding, 12px);
}
.custom-card.resident-size .row-content,
.custom-card.resident-size .row-components {
height: var(--resident-height, 40px);
min-height: var(--resident-height, 40px);
}
.custom-card.resident-size .row-components {
gap: 0;
}
.custom-card.resident-size .component-node:not(.node-editing) {
height: var(--resident-height, 40px);
max-height: var(--resident-height, 40px);
padding: 0 calc(var(--row-gap, 12px) / 2);
display: flex;
align-items: center;
justify-content: center;
}
.custom-card.resident-size .component-node.schedule {
flex-direction: column;
}
.custom-card.resident-size .schedule small,
.custom-card.resident-size .schedule strong {
line-height: 1.05;
}
.custom-card.resident-size .separator > span {
height: 20px;
}
.custom-card[role='button'] {
cursor: pointer;
}
.custom-card[role='button']:focus-visible {
outline: 2px solid #8dafff;
outline-offset: -3px;
}
.custom-card .resident-bell {
position: relative;
display: grid;
place-items: center;
width: 28px;
height: 28px;
flex: 0 0 28px;
margin: 0;
padding: 4px;
border: 0;
border-radius: 6px;
background: transparent;
color: #ffda8b;
cursor: pointer;
}
.custom-card .resident-bell:hover {
background: #ffffff1a;
}
.custom-card .resident-bell.bell-empty {
color: #ffffff60;
}
.custom-card .resident-bell svg {
width: 18px;
height: 18px;
}
.custom-card .resident-bell span {
position: absolute;
top: -3px;
right: -3px;
display: grid;
place-items: center;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 7px;
background: #f45b58;
color: white;
font:
600 9px/1 'Segoe UI',
sans-serif;
box-shadow: 0 0 0 2px var(--custom-color);
}
.custom-card .resident-panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 4px 6px 12px;
border-bottom: 1px solid #ffffff1a;
margin-bottom: 4px;
color: #ffffff94;
font-size: 12px;
}
.custom-card .resident-panel-heading button {
min-height: 30px;
padding: 4px 10px;
border: 0;
border-radius: 8px;
background: #ffffff0d;
color: inherit;
font: inherit;
cursor: pointer;
}
.layout-editor .surface-tabs {
pointer-events: auto;
display: flex;
gap: 6px;
}
.layout-editor .surface-tabs button {
font-size: 12px;
}
.layout-editor .surface-tabs button.active {
color: var(--editor-accent);
background: #edf2ff;
border-color: #b6c8f0;
}
.custom-card .layout-row {
display: flex;
flex-direction: column;
min-width: 0;
}
.custom-card .row-content {
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--row-gap);
min-height: 24px;
}
.custom-card .row-components {
display: flex;
align-items: center;
gap: var(--row-gap);
}
.custom-card .row-components:empty {
display: none;
}
.custom-card #custom-alert-host {
width: max-content;
max-width: 100%;
}
.custom-card.has-alert {
box-shadow:
inset 0 0 0 1px #ffffff2b,
0 2px 3px #0000001c;
}
.custom-card.separated {
background: transparent;
box-shadow: none;
padding: 0;
}
.custom-card.separated .layout-row {
background: var(--custom-color);
border-radius: var(--custom-radius);
padding: 12px;
box-shadow: inset 0 0 0 1px #ffffff1e;
}
/* 自定义模式只保留一个岛壳。原提醒渲染器继续负责内容、动画和按钮。 */
.custom-card .island-root.embedded .shape,
.custom-card .island-root.embedded .tint {
display: none;
}
.custom-card .island-root.embedded .island-card {
color: inherit;
}
.custom-card .island-root.embedded .content {
clip-path: none;
}
.custom-card .island-root.embedded .expanded-layer:not(.list-mode) {
padding: 16px 10px 10px;
justify-content: flex-start;
}
.custom-card .island-root.embedded {
border-top: 1px solid #ffffff1a;
}
.custom-window .preview-stage {
width: max-content;
}
@@ -0,0 +1,255 @@
/* 自定义岛:共用经典岛形状/动效与通知排版,组件行采用居中自然尺寸并自适应。 */
.resident-window {
position: fixed;
inset: 10px;
overflow: visible;
pointer-events: none;
}
.resident-window .resident-card {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
padding: 0;
min-width: 0;
min-height: 0;
max-width: none;
max-height: none;
overflow: hidden;
clip-path: var(--card-path);
isolation: isolate;
pointer-events: auto;
background: none;
border-radius: var(--resident-radius);
font-size: var(--island-font);
line-height: 1.4;
box-shadow: none;
}
.resident-window[data-anchor='left'] .resident-card {
left: 0;
transform: none;
}
.resident-window[data-anchor='right'] .resident-card {
left: auto;
right: 0;
transform: none;
}
.resident-window[data-bottom='true'] .resident-card {
top: auto;
bottom: 0;
}
.resident-card .resident-page {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: max-content;
pointer-events: none;
}
.resident-card .resident-page.page-active {
pointer-events: auto;
}
.resident-card .resident-source {
width: max-content;
transform: scale(var(--resident-scale));
transform-origin: center top;
}
.resident-window[data-anchor='left'] .resident-page {
left: 0;
transform: none;
}
.resident-window[data-anchor='left'] .resident-source {
transform-origin: left top;
}
.resident-window[data-anchor='right'] .resident-page {
left: auto;
right: 0;
transform: none;
}
.resident-window[data-anchor='right'] .resident-source {
transform-origin: right top;
}
.resident-card .resident-compact {
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
min-height: max(40px, calc(var(--island-font) * 2.5));
font-size: var(--ci-body-font, 16px);
}
.resident-window .resident-card.resident-separated {
background: transparent;
box-shadow: none;
}
.resident-separated .resident-compact {
padding: 0;
}
.resident-separated .resident-compact .layout-rows {
align-items: center;
}
.resident-separated .resident-compact .layout-row {
padding: 0 12px;
background: var(--resident-background);
border-radius: var(--resident-radius);
}
.resident-separated .resident-compact .resident-bell {
background: var(--resident-background);
}
.resident-card .layout-rows {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
}
.resident-card .row-components {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
min-height: max(40px, calc(var(--island-font) * 2.5));
}
.resident-card .resident-compact .component-node:not(.container) {
min-height: max(40px, calc(var(--island-font) * 2.5));
display: flex;
align-items: center;
padding: 0 calc(var(--row-gap, 12px) / 2);
}
.resident-card .resident-compact .component-node.container {
padding: 0;
}
.resident-card .resident-compact .component-node.text {
max-width: min(320px, max(80px, calc(var(--resident-row-width) - 112px)));
}
.resident-card .resident-compact .text > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resident-card .resident-compact .schedule {
flex-direction: row;
gap: 12px;
}
.resident-card .resident-compact .schedule > div + div {
margin-top: 0;
}
.resident-card .separator > span {
height: 20px;
}
.resident-card .resident-bell {
align-self: center;
color: inherit;
}
.resident-card .resident-bell.bell-empty {
color: #ffffff80;
}
.resident-card .resident-bell span {
animation: resident-indicator-in var(--resident-fade-duration) cubic-bezier(0.25, 1, 0.5, 1);
}
.resident-card .resident-details {
min-width: 160px;
padding: 0 12px 12px;
text-align: center;
}
.resident-card .resident-panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
height: 40px;
padding: 0 6px;
margin: 0;
border: 0;
color: #ffffff94;
font-size: 12px;
}
.resident-card .resident-panel-heading .icon-btn {
min-height: 28px;
width: 28px;
height: 28px;
padding: 4px;
}
.resident-card .icon-btn svg {
width: 18px;
height: 18px;
display: block;
}
.resident-card .resident-expanded-rows {
font-size: var(--ci-body-font, 16px);
border-top: 1px solid #ffffff1a;
padding-top: 8px;
}
.resident-card .resident-expanded-rows .row-components {
gap: var(--row-gap, 12px);
}
.resident-card #custom-alert-host {
width: 100%;
max-width: 100%;
}
.resident-card button:focus-visible {
outline: 2px solid #adc4ff;
outline-offset: -2px;
}
@media (hover: hover) and (pointer: fine) {
.resident-card button:hover {
filter: brightness(1.18);
}
}
@keyframes resident-indicator-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.resident-card .resident-details.with-messages {
padding: 0;
min-width: 0;
}
.resident-card .classic-notifications {
position: relative;
text-align: center;
}
.resident-card .classic-notifications .head-meta {
justify-content: center;
}
.resident-card .classic-notifications .foot {
justify-content: center;
flex-wrap: wrap;
}
.resident-card .classic-notifications .row-text {
align-items: center;
}
.resident-card .classic-notifications .row-title,
.resident-card .classic-notifications .row-sub {
max-width: 100%;
}
.resident-card .resident-source .component-node {
text-align: center;
justify-content: center;
}
.resident-card .resident-compact .schedule > div {
align-items: center;
}
.resident-card.resident-separated > .shape,
.resident-card.resident-separated > .tint {
visibility: hidden;
}
/* ClassIsland Fluent MainWindowLine:半透明底、1px 边框,强调遮罩只在提醒时播放。 */
.resident-window .resident-card {
--wn-bg: #20202080;
--wn-border: #ffffff26;
}
.resident-window .resident-card > .tint {
display: none;
}
.resident-card.resident-lesson .shape path {
fill: transparent;
stroke: none;
}
.resident-window .resident-card.message-surface {
--wn-bg: #202020;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { createApp } from 'vue';
import IslandApp from './IslandApp.vue';
import IslandApp from './LayoutIslandApp.vue';
import { applyMotionTokens } from '../renderer/composables/motion.js';
/**
+137 -21
View File
@@ -1,10 +1,16 @@
import type { ReleaseProgressDto as ContractReleaseProgressDto } from '@classhelper/shared';
import crypto from 'node:crypto';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { BrowserWindow, app } from 'electron';
import { CLIENT_UPDATE_IDLE_MS, type ClientUpdateInfoDto } from '@classhelper/shared';
import {
isNewerVersion,
CLIENT_UPDATE_IDLE_MS,
type ClientHeartbeatResponse,
type ClientUpdateInfoDto,
} from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { island } from './island.js';
import { logger } from './logger.js';
@@ -13,16 +19,58 @@ import { logger } from './logger.js';
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
*
* 设计要点(都是"教室机器"这个使用场景逼出来的):
* 1. **下载随时进行、安装只在完全空闲时**:教室机的带宽与注意力都有限,上课期间重启应用是灾难,
* 所以安装的三个必要条件缺一不可 —— 不在上课、主窗口已隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)、
* 本进程还没装过;
* 1. **下载随时进行,按下发策略安装**:立即安装;放学后到指定时刻且不在上课时安装;
* 空闲安装要求不在上课且主窗口隐藏满五分钟;
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
* 3. **安装脚本脱离本进程**:`start /wait` 等安装器跑完后重新拉起应用,
* 3. **安装脚本脱离本进程**:PowerShell 等旧进程退出、安装器完成后重新拉起应用,
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`,
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
*/
let policy: Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> | null =
null;
function policyPath(): string {
return path.join(pendingUpdateDir(), 'policy.json');
}
function loadPolicy(): typeof policy {
if (policy) return policy;
try {
policy = JSON.parse(fs.readFileSync(policyPath(), 'utf8'));
} catch {
/* 尚无下发策略 */
}
return policy;
}
/** 更新包只来自专用目录;清理与执行均不得接受目录外的配置路径。 */
function isManagedInstaller(file: string): boolean {
return path.dirname(path.resolve(file)) === path.resolve(pendingUpdateDir()) && /\.exe$/i.test(file);
}
function removeManagedInstaller(file: string): void {
if (!file || !isManagedInstaller(file)) return;
try {
fs.rmSync(file, { force: true });
} catch (error) {
logger.warn(`清理旧更新包失败:${String(error)}`);
}
}
/** 每次成功心跳先同步策略;取消或更换目标后旧包不得自行安装。 */
export function syncUpdatePolicy(next: ClientHeartbeatResponse): void {
policy = {
updateTargetVersion: next.updateTargetVersion,
updateMode: next.updateMode ?? 'idle',
updateNotBefore: next.updateNotBefore ?? null,
};
fs.mkdirSync(pendingUpdateDir(), { recursive: true });
fs.writeFileSync(policyPath(), JSON.stringify(policy), 'utf8');
if (getConfig().pendingUpdateVersion !== (next.updateTargetVersion ?? '')) {
removeManagedInstaller(getConfig().pendingUpdateFile);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
}
}
/** 单个安装包的下载超时:教室机网络慢,给足 10 分钟 */
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
@@ -38,6 +86,29 @@ export function pendingUpdateDir(): string {
let mainWindowHiddenAt: number | null = null;
/** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */
let installing = false;
let updateProgress: ContractReleaseProgressDto = {
version: '',
stage: 'idle',
downloadedBytes: 0,
totalBytes: 0,
percent: null,
error: '',
};
export function getUpdateProgress() {
return { ...updateProgress };
}
let progressPublishedAt = 0;
function publishProgress(patch: Partial<typeof updateProgress>) {
const previousStage = updateProgress.stage;
updateProgress = { ...updateProgress, ...patch };
updateProgress.percent = updateProgress.totalBytes
? Math.min(100, Math.round((updateProgress.downloadedBytes / updateProgress.totalBytes) * 100))
: null;
if (previousStage === updateProgress.stage && Date.now() - progressPublishedAt < 250) return;
progressPublishedAt = Date.now();
for (const win of BrowserWindow.getAllWindows())
if (!win.isDestroyed()) win.webContents.send('client:update-progress', getUpdateProgress());
}
/**
* 退出本进程的回调(由 index.ts 注册成既有的 `quitApp()`)。
*
@@ -77,7 +148,7 @@ export function watchMainWindow(win: BrowserWindow): void {
mainWindowHiddenAt = null;
if (hookedWindow === win) hookedWindow = null;
});
if (win.isVisible()) mainWindowHiddenAt = null;
mainWindowHiddenAt = win.isVisible() ? null : Date.now();
}
/** 主窗口是否已经隐藏满 `CLIENT_UPDATE_IDLE_MS` */
@@ -140,6 +211,7 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
}
downloading.add(version);
publishProgress({ version, stage: 'downloading', downloadedBytes: 0, totalBytes: info.size, error: '' });
const startedAt = Date.now();
try {
fs.mkdirSync(dir, { recursive: true });
@@ -164,20 +236,24 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
try {
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
await handle.write(chunk);
publishProgress({ downloadedBytes: updateProgress.downloadedBytes + chunk.byteLength });
}
} finally {
await handle.close();
}
publishProgress({ stage: 'verifying' });
const digest = await sha256File(temp);
const expected = info.sha256.trim().toLowerCase();
if (!expected) {
// 服务端没给哈希就没法校验:宁可不装,也不留一个来源不明的安装包在磁盘上
await fsp.rm(temp, { force: true });
publishProgress({ stage: 'failed', error: '服务端未提供 sha256,拒绝更新' });
logger.error(`更新 ${version} 的安装包没有提供 sha256,已删除(不做信任放行)`);
return;
}
if (digest !== expected) {
publishProgress({ stage: 'failed', error: '安装包 sha256 校验失败' });
await fsp.rm(temp, { force: true });
logger.error(
`更新 ${version} 的安装包校验失败(sha256 不符),已删除:期望 ${expected},实际 ${digest}`,
@@ -187,15 +263,21 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
await fsp.rm(target, { force: true });
await fsp.rename(temp, target);
publishProgress({ stage: 'waiting' });
if (loadPolicy()?.updateTargetVersion !== version) {
removeManagedInstaller(target);
return;
}
saveConfig({ pendingUpdateVersion: version, pendingUpdateFile: target });
logger.info(
`更新 ${version} 已下载并校验通过(${formatSize(fs.statSync(target).size)},耗时 ${Math.round(
(Date.now() - startedAt) / 1000,
)} 秒),将在空闲时自动安装`,
)} 秒),将按服务端指定的时机安装`,
);
await maybeInstallPendingUpdate();
} catch (error) {
await fsp.rm(temp, { force: true }).catch(() => undefined);
publishProgress({ stage: 'failed', error: String(error) });
logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
downloading.delete(version);
@@ -230,32 +312,52 @@ function formatSize(bytes: number): string {
/**
* 如果条件全部满足就装更新。
*
* 条件(缺一不可):非冒烟模式 / 配置里有待安装版本 / 文件还在 / 不在上课 /
* 主窗口已隐藏满 5 分钟 / 本进程没装过。**每次心跳后与主窗口 hide 后都会调用**,
* 所以"老师下课关掉窗口 → 5 分钟后自动装上"就是靠这里触发的。
* 非冒烟模式、有有效目标与安装包且自动更新开启;其余条件按安装策略判断。
* 每次心跳后与主窗口隐藏后都会调用。
*/
export async function maybeInstallPendingUpdate(): Promise<void> {
const courseware = island.getCoursewareTransfer();
if (courseware && ['downloading', 'verifying'].includes(courseware.stage)) return;
if (installing) return;
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
const config = getConfig();
const version = config.pendingUpdateVersion?.trim() ?? '';
const installer = config.pendingUpdateFile?.trim() ?? '';
if (!version || !installer) return;
if (!version || !installer || config.autoUpdate === false || !config.token) return;
if (!isNewerVersion(app.getVersion(), version)) {
removeManagedInstaller(installer);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return;
}
const active = loadPolicy();
if (!active || active.updateTargetVersion !== version) return;
if (active.updateMode === 'after-school') {
const at = Date.parse(active.updateNotBefore ?? '');
if (!Number.isFinite(at) || Date.now() < at) return;
}
if (!fs.existsSync(installer)) {
logger.warn(`待安装的更新 ${version} 已不存在(${installer}),清除待安装记录`);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return;
}
if (island.getState().inClass) {
if (
!isManagedInstaller(installer) ||
path.dirname(fs.realpathSync(installer)) !== fs.realpathSync(pendingUpdateDir())
) {
logger.error('拒绝执行更新目录之外的安装文件');
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return;
}
if (active.updateMode !== 'immediate' && island.getState().inClass) {
logger.info(`更新 ${version} 已就绪,但正在上课,暂不安装`);
return;
}
if (mainWindowVisible()) {
if (active.updateMode === 'idle' && mainWindowVisible()) {
logger.debug(`更新 ${version} 已就绪,但主窗口可见(人正在用),暂不安装`);
return;
}
if (!mainWindowIdleLongEnough()) {
if (active.updateMode === 'idle' && !mainWindowIdleLongEnough()) {
const waited = mainWindowHiddenAt === null ? 0 : Math.round((Date.now() - mainWindowHiddenAt) / 1000);
logger.debug(
`更新 ${version} 已就绪,但主窗口隐藏仅 ${waited} 秒` +
@@ -273,17 +375,31 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
}
installing = true;
publishProgress({ stage: 'installing' });
logger.info(`开始静默安装更新 ${version}:${installer}`);
try {
const exe = app.getPath('exe');
const command = `start "" /wait "${installer}" /S && start "" "${exe}"`;
const child = spawn('cmd.exe', ['/c', command], {
detached: true,
stdio: 'ignore',
windowsHide: true,
const quote = (value: string): string => "'" + value.replaceAll("'", "''") + "'";
const script =
`$ErrorActionPreference = 'Stop'; Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` +
`$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -Wait -PassThru; ` +
`if ($installer.ExitCode -ne 0 -and (Test-Path -LiteralPath ${quote(exe)})) { Start-Process -FilePath ${quote(exe)} }`;
const encoded = Buffer.from(script, 'utf16le').toString('base64');
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', encoded],
{
detached: true,
stdio: 'ignore',
windowsHide: true,
},
);
await new Promise<void>((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
});
child.unref();
// 安装脚本会等安装器结束后重新拉起应用;本进程随即退出(复用既有退出路径,保证配置已落盘)
// 安装器从实际安装目录启动新版;免安装版的旧 exe 可能随退出消失,只在安装失败时尝试恢复它。
if (quitHandler) {
quitHandler();
} else {
@@ -293,7 +409,7 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
} catch (error) {
installing = false;
logger.error(
`启动更新安装失败(将在下次空闲时重试):${error instanceof Error ? error.message : String(error)}`,
`启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`,
);
}
}
+121 -70
View File
@@ -1,17 +1,22 @@
import { pollCourseware } from './courseware.js';
import { spawn } from 'node:child_process';
import os from 'node:os';
import { BrowserWindow, app, dialog } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import { BrowserWindow, app } from 'electron';
import {
CLIENT_COMMAND_LABELS,
CLIENT_HEARTBEAT_INTERVAL_MS,
isNewerVersion,
type ClientCommandDto,
type ApiResponse,
type ClientCommandKind,
type ClientHeartbeatRequest,
type ClientHeartbeatResponse,
type ClientUpdateInfoDto,
} from '@classhelper/shared';
import { maybeInstallPendingUpdate, considerUpdate } from './auto-update.js';
import { maybeInstallPendingUpdate, considerUpdate, syncUpdatePolicy } from './auto-update.js';
import { connectionEvents, type ConnectionEvent } from './config-events.js';
import { getConfig } from './config.js';
import { island } from './island.js';
import { logger } from './logger.js';
@@ -21,7 +26,7 @@ import { logger } from './logger.js';
*
* **为什么是"客户端定时拉"而不是服务端推**:教室机的连接经常断(代理、休眠、重启),
* 而「这台机器是什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上 —— 所以统一用
* 「服务端记状态 + 客户端定时上报」的范式(与 ClassIsland 插件的上报同一套路),不引入新的长连接握手。
* 「服务端记状态 + 客户端定时上报」的范式,不引入新的长连接握手。
* 每次上报的响应里同时带回「服务端指定的目标版本 + 待执行命令」。
*
* 三条边界:
@@ -42,14 +47,6 @@ const UNAUTHORIZED_BACKOFF_MS = 5 * 60_000;
/** 单次上报的超时:教室机网络慢,但也不能挂着不放 */
const HEARTBEAT_TIMEOUT_MS = 10_000;
/**
* 远控命令的「本机二次确认」等待时长:60 秒内没人点确认就按取消处理。
*
* 为什么必须有这个超时:管理员在办公室下发关机,而教室里可能已经没人了 ——
* 一个永远挂着等确认的对话框会让这条命令一直"待执行",既没执行也看不出失败。
*/
const COMMAND_CONFIRM_TIMEOUT_MS = 60_000;
/**
* 「在线」判定窗口:与 shared 的 `CLIENT_ONLINE_WINDOW_MS`(90 秒)同口径 ——
* 连续丢两次心跳(30 秒 × 3)才算离线。这里不复用那个常量是因为它属于**服务端**的判定口径,
@@ -111,7 +108,7 @@ function isAgentSuppressed(): boolean {
* 上报里的"这台机器现在在干什么"。
*
* 取值就三种(服务端设备列表直接展示这段文字):
* - `上课中` —— 灵动岛按渲染进程上报的课表状态判定正在上课(此时绝不安装更新);
* - `上课中` —— 灵动岛按渲染进程上报的课表状态判定正在上课(延后策略此时不安装更新);
* - `使用中` —— 主窗口可见,人正在用;
* - `待机` —— 窗口隐藏(最小化到托盘),机器空闲。
*/
@@ -168,14 +165,17 @@ async function beat(): Promise<void> {
return;
}
const epoch = connectionEpoch;
state.beating = true;
let backoff = false;
try {
const body: ClientHeartbeatRequest = {
agentProtocol: 2,
deviceKey: config.deviceKey,
name: os.hostname(),
clientVersion: app.getVersion(),
platform: `${process.platform}-${process.arch}`,
autoUpdate: config.autoUpdate !== false,
pendingVersion: pendingVersionOrNull(),
stateNote: currentStateNote(),
};
@@ -189,6 +189,7 @@ async function beat(): Promise<void> {
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS),
});
if (epoch !== connectionEpoch) return;
if (response.status === 401 || response.status === 403) {
// 令牌失效:退避到 5 分钟一次,直到用户重新登录
backoff = true;
@@ -202,7 +203,11 @@ async function beat(): Promise<void> {
return;
}
const payload = (await response.json()) as ClientHeartbeatResponse;
const envelope = (await response.json()) as ApiResponse<ClientHeartbeatResponse>;
if (!envelope.success || !envelope.data?.deviceId) throw new Error('心跳响应格式不正确');
if (epoch !== connectionEpoch) return;
const payload = envelope.data;
void pollCourseware();
state.lastHeartbeatAt = new Date().toISOString();
state.lastError = '';
state.updateTargetVersion = payload.updateTargetVersion?.trim() ?? '';
@@ -211,17 +216,24 @@ async function beat(): Promise<void> {
`${state.updateTargetVersion ? `,服务端要求升到 ${state.updateTargetVersion}` : ''})`,
);
void handleCommand(payload.command);
await handleUpdate(payload.update, payload.updateTargetVersion);
void handleCommand(payload.command).catch((error) => logger.error(`远控处理失败:${String(error)}`));
// 下载不能阻塞心跳,否则慢速下载期间远控和在线状态都会失效。
syncUpdatePolicy(payload);
void handleUpdate(payload.update, payload.updateTargetVersion).catch((error) =>
logger.error(`更新处理失败:${String(error)}`),
);
} catch (error) {
// 网络错误 / 超时 / JSON 解析失败:按原节奏重试
if (epoch !== connectionEpoch) return;
state.lastError = error instanceof Error ? error.message : String(error);
logger.warn(`客户端上报失败(将于 ${HEARTBEAT_INTERVAL_TEXT} 后重试):${state.lastError}`);
} finally {
state.beating = false;
// 每次心跳(无论成败)都顺手看一下有没有下载好的更新该装了 —— 上课/窗口可见时会自动跳过
// 每次心跳后检查已下载的更新是否满足安装策略。
void maybeInstallPendingUpdate();
scheduleNext(backoff ? UNAUTHORIZED_BACKOFF_MS : CLIENT_HEARTBEAT_INTERVAL_MS);
scheduleNext(
epoch !== connectionEpoch ? 0 : backoff ? UNAUTHORIZED_BACKOFF_MS : CLIENT_HEARTBEAT_INTERVAL_MS,
);
}
}
@@ -230,20 +242,47 @@ const HEARTBEAT_INTERVAL_TEXT = `${Math.round(CLIENT_HEARTBEAT_INTERVAL_MS / 100
/**
* 服务端下发的远控命令(关机 / 重启 / 注销 / 锁屏)。
*
* 两道护栏缺一不可(用户要求「必须二次确认」):
* 1. **本机二次确认**:弹一个默认选中「取消」的对话框,60 秒不确认按取消处理 ——
* 服务端那边虽然也确认过,但真正会被关掉的机器上必须有人当场知道;
* 2. **同一条命令只弹一次**:它随每次心跳重复下发,用内存集合去重,避免 30 秒弹一个框。
*
* 执行后**必须回执**(`POST /api/clients/commands/:id/ack`,body 带 deviceKey):
* 否则服务端那条命令会一直挂在"待执行"里,Web 端看不到结果。回执失败时把结果暂存,
* 下一次心跳只重试回执、不重复弹窗。
* 按产品要求收到即执行,保留十分钟有效期、持久去重与设备归属校验。
* 执行结果必须回执;网络失败只重试回执,不重复执行。
* 冒烟在执行器处强制返回假结果,永不调用系统命令。
*/
const inFlightCommands = new Set<string>();
const unackedResults = new Map<string, { ok: boolean; result: string }>();
type CommandOutcome = { ok: boolean; result: string };
const commandResults = new Map<string, CommandOutcome>();
let resultsLoaded = false;
function commandResultsPath(): string {
return path.join(app.getPath('userData'), 'command-results.json');
}
function readCommandResults(): void {
if (resultsLoaded) return;
try {
const entries: unknown = JSON.parse(fs.readFileSync(commandResultsPath(), 'utf8'));
if (Array.isArray(entries))
for (const entry of entries.slice(-200)) {
if (
Array.isArray(entry) &&
typeof entry[0] === 'string' &&
typeof entry[1]?.ok === 'boolean' &&
typeof entry[1]?.result === 'string'
)
commandResults.set(entry[0], entry[1]);
}
} catch {
/* 首次运行尚无记录 */
}
resultsLoaded = true;
}
function rememberCommand(commandId: string, outcome: CommandOutcome): void {
readCommandResults();
commandResults.set(commandId, outcome);
while (commandResults.size > 200) commandResults.delete(commandResults.keys().next().value!);
fs.mkdirSync(app.getPath('userData'), { recursive: true });
fs.writeFileSync(commandResultsPath(), JSON.stringify([...commandResults]), 'utf8');
}
async function handleCommand(command: ClientCommandDto | null): Promise<void> {
if (!command) return;
const commandConfig = getConfig();
const label = CLIENT_COMMAND_LABELS[command.kind] ?? command.kind;
const expired =
@@ -255,78 +294,80 @@ async function handleCommand(command: ClientCommandDto | null): Promise<void> {
return;
}
// 正在执行时不能用预存的中间结果抢先回执。
if (inFlightCommands.has(command.id)) return;
// 处理过但回执没送达:只重试回执
const pending = unackedResults.get(command.id);
readCommandResults();
const pending = commandResults.get(command.id);
if (pending) {
await ackCommand(command.id, pending.ok, pending.result);
await ackCommand(command.id, pending.ok, pending.result, commandConfig);
return;
}
// 正在等本机确认:不要再弹第二个框
if (inFlightCommands.has(command.id)) return;
inFlightCommands.add(command.id);
try {
const confirmed = await confirmCommand(label, command.payload);
if (!confirmed) {
const result = '本机用户取消(或 60 秒未确认)';
logger.warn(`远控命令「${label}」未执行:${result}`);
await ackCommand(command.id, false, result);
// 注销可能立刻结束本进程,先落盘,重启后不能再次执行同一条指令。
rememberCommand(command.id, {
ok: false,
result: '已提交系统执行,最终结果未回传,请核对设备状态',
});
if (getConfig().serverUrl !== commandConfig.serverUrl || getConfig().token !== commandConfig.token)
return;
}
const outcome = await executeCommand(command.kind);
logger.warn(`远控命令「${label}」执行${outcome.ok ? '成功' : '失败'}:${outcome.result}`);
await ackCommand(command.id, outcome.ok, outcome.result);
await ackCommand(command.id, outcome.ok, outcome.result, commandConfig);
} finally {
inFlightCommands.delete(command.id);
}
}
/** 本机二次确认对话框:默认「取消」,60 秒未操作按取消处理 */
async function confirmCommand(label: string, note: string): Promise<boolean> {
const answered = dialog
.showMessageBox({
type: 'warning',
title: '来自管理员的远程指令',
message: `管理员要求本机「${label}」`,
detail: `${note ? `备注:${note}\n` : ''}确认后本机将立即${label};60 秒内未确认视为取消。`,
buttons: ['取消', '确认执行'],
defaultId: 0,
cancelId: 0,
noLink: true,
})
.then((result) => result.response === 1)
.catch(() => false);
return Promise.race([
answered,
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), COMMAND_CONFIRM_TIMEOUT_MS)),
]);
}
/** 执行系统命令:只支持 Windows(本项目客户端只发 Windows 版) */
async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; result: string }> {
if (process.env.ELECTRON_SMOKE_TEST === '1')
return { ok: true, result: '冒烟假执行器:已接收,未调用系统命令' };
if (!['shutdown', 'restart', 'logoff', 'lock'].includes(kind))
return { ok: false, result: '未知的远控动作' };
if (process.platform !== 'win32') return { ok: false, result: '仅支持 Windows' };
const commands: Record<ClientCommandKind, { file: string; args: string[] }> = {
shutdown: { file: 'shutdown.exe', args: ['/s', '/t', '0'] },
restart: { file: 'shutdown.exe', args: ['/r', '/t', '0'] },
shutdown: { file: 'shutdown.exe', args: ['/s', '/t', '5'] },
restart: { file: 'shutdown.exe', args: ['/r', '/t', '5'] },
logoff: { file: 'shutdown.exe', args: ['/l'] },
lock: { file: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] },
};
const plan = commands[kind];
try {
// detached + unref:不能等它退出(关机/注销会把本进程一起带走)
spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
return { ok: true, result: '已下达系统命令' };
// 关机/重启预留 5 秒用于回执;检查系统命令进程的真实退出码。
const child = spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true });
/**
* **必须监听 `error` 事件**:`spawn` 找不到可执行文件(或没权限)时不是抛异常,而是在
* next tick 上发一个 `error` 事件 —— 只包 `try/catch` 的话,这种情况会一路走到
* `return { ok: true }`,回执写着"已下达系统命令",Web 端看起来一切正常,机器却纹丝不动。
* 同时检查非零退出码,系统拒绝指令时不能回报成功。
*/
return await new Promise((resolve) => {
child.once('error', (error) => resolve({ ok: false, result: error.message }));
child.once('exit', (code) =>
resolve({
ok: code === 0,
result: code === 0 ? '系统已接受指令' : `系统命令执行失败(退出码 ${code ?? '未知'})`,
}),
);
});
} catch (error) {
return { ok: false, result: error instanceof Error ? error.message : String(error) };
}
}
/** 回执:服务端用 body 里的 deviceKey 校验"这条命令是发给这台机器的" */
async function ackCommand(commandId: string, ok: boolean, result: string): Promise<void> {
const config = getConfig();
async function ackCommand(
commandId: string,
ok: boolean,
result: string,
config: ReturnType<typeof getConfig>,
): Promise<void> {
rememberCommand(commandId, { ok, result });
// 回执始终发给接收命令时的服务器,切换实例不能把旧命令带到新实例。
if (!config.token) return;
try {
const response = await fetch(`${config.serverUrl}/api/clients/commands/${commandId}/ack`, {
@@ -337,16 +378,13 @@ async function ackCommand(commandId: string, ok: boolean, result: string): Promi
});
if (!response.ok) {
logger.warn(`远控命令回执失败(HTTP ${response.status}),将在下次心跳重试`);
unackedResults.set(commandId, { ok, result });
return;
}
unackedResults.delete(commandId);
logger.info(`远控命令回执已送达(${ok ? '成功' : '失败'}):${result}`);
} catch (error) {
logger.warn(
`远控命令回执失败(${error instanceof Error ? error.message : String(error)}),将在下次心跳重试`,
);
unackedResults.set(commandId, { ok, result });
}
}
@@ -363,6 +401,19 @@ async function handleUpdate(info: ClientUpdateInfoDto | null, targetVersion: str
await considerUpdate(info, target);
}
let connectionEpoch = 0;
/** 显式身份事件清除旧服务端退避;旧请求返回也不得覆盖新服务器状态。 */
export function resetClientAgent(reason: ConnectionEvent): void {
connectionEpoch++;
clearTimer();
state.lastHeartbeatAt = null;
state.lastError = '';
state.updateTargetVersion = '';
logger.info(`客户端连接状态已重置:${reason}`);
if (state.running && !state.beating) scheduleNext(0);
}
connectionEvents.on('changed', resetClientAgent);
/** 启动心跳(重复调用无副作用) */
export function startClientAgent(): void {
if (state.running) return;
@@ -0,0 +1,6 @@
import { EventEmitter } from 'node:events';
export type ConnectionEvent = 'server-changed' | 'token-changed' | 'login' | 'logout';
export const connectionEvents = new EventEmitter();
export function connectionChanged(event: ConnectionEvent) {
connectionEvents.emit('changed', event);
}
+19 -21
View File
@@ -1,16 +1,16 @@
import type { IslandLayoutSettings as ContractIslandLayoutSettings } from '@classhelper/shared';
import { connectionChanged } from './config-events.js';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { app, safeStorage } from 'electron';
import {
CLASSISLAND_NOTIFICATION_CHANNELS,
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
DEFAULT_ISLAND_APPEARANCE,
normalizeIslandLayouts,
DEFAULT_SERVER_URL,
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH,
type ClassIslandNotificationChannel,
type IslandAppearance,
} from '@classhelper/shared';
import { logger } from './logger.js';
@@ -24,6 +24,7 @@ const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = {
};
interface PersistedConfig {
islandLayout?: ContractIslandLayoutSettings;
serverUrl: string;
username: string;
/** 加密后的 token(base64)或明文(当系统不支持加密时) */
@@ -33,8 +34,6 @@ interface PersistedConfig {
island?: Partial<IslandAppearance>;
/** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */
homeworkBoard?: Partial<HomeworkBoardSettings>;
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */
notificationChannel?: ClassIslandNotificationChannel;
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
homeworkPhrases?: string[];
/** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */
@@ -68,7 +67,6 @@ const DEFAULT_CONFIG: PersistedConfig = {
tokenEncrypted: false,
island: { ...DEFAULT_ISLAND_APPEARANCE },
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
onboardingDone: false,
theme: 'light',
@@ -107,15 +105,6 @@ function normalizeHomeworkPhrases(input?: string[]): string[] {
return result;
}
/** 通知显示位置的白名单校验:非法值一律回落到默认值,避免坏配置把提醒"静默吞掉" */
function normalizeNotificationChannel(
input?: ClassIslandNotificationChannel,
): ClassIslandNotificationChannel {
return CLASSISLAND_NOTIFICATION_CHANNELS.includes(input as ClassIslandNotificationChannel)
? (input as ClassIslandNotificationChannel)
: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL;
}
/** 作业页偏好的合法化(模式白名单、字号夹紧) */
function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): HomeworkBoardSettings {
const mode = input?.mode === 'list' ? 'list' : 'board';
@@ -209,6 +198,7 @@ function readPersistedFromDisk(): PersistedConfig {
const content = fs.readFileSync(configFilePath(), 'utf8');
const parsed = JSON.parse(content) as Partial<PersistedConfig>;
return {
islandLayout: normalizeIslandLayouts(parsed.islandLayout),
serverUrl:
typeof parsed.serverUrl === 'string' && parsed.serverUrl
? (normalizeServerUrl(parsed.serverUrl) ?? DEFAULT_CONFIG.serverUrl)
@@ -218,7 +208,6 @@ function readPersistedFromDisk(): PersistedConfig {
tokenEncrypted: parsed.tokenEncrypted === true,
island: normalizeIslandAppearance(parsed.island),
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
onboardingDone: parsed.onboardingDone === true,
theme: normalizeTheme(parsed.theme),
@@ -376,12 +365,12 @@ function normalizeServerUrl(input: string): string | null {
function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
const persisted = state.config;
return {
islandLayout: normalizeIslandLayouts(persisted.islandLayout),
serverUrl: persisted.serverUrl,
username: persisted.username,
token: state.token,
island: normalizeIslandAppearance(persisted.island),
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
onboardingDone: persisted.onboardingDone === true,
theme: normalizeTheme(persisted.theme),
@@ -442,6 +431,9 @@ export function getConfig(): DesktopStoredConfig {
export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredConfig {
const state = loadCache();
const persisted = state.config;
if (patch.islandLayout !== undefined) persisted.islandLayout = normalizeIslandLayouts(patch.islandLayout);
const oldServer = persisted.serverUrl;
const oldToken = state.token;
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) {
const normalized = normalizeServerUrl(patch.serverUrl);
@@ -463,9 +455,6 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (patch.homeworkPhrases !== undefined) {
persisted.homeworkPhrases = normalizeHomeworkPhrases(patch.homeworkPhrases);
}
if (patch.notificationChannel !== undefined) {
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
}
// 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态,
// 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。
if (patch.onboardingDone === true) {
@@ -493,7 +482,7 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (patch.careMode !== undefined) {
persisted.careMode = patch.careMode === true;
}
// 待安装的更新只由主进程的 auto-update 模块写入;渲染进程传进来也照收(退出登录/重置走 clearConfig)
// 仅供主进程更新模块写入;IPC 层会剔除这两个字段。
if (typeof patch.pendingUpdateVersion === 'string') {
persisted.pendingUpdateVersion = patch.pendingUpdateVersion;
}
@@ -505,6 +494,8 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (typeof patch.deviceKey === 'string' && patch.deviceKey && patch.deviceKey !== persisted.deviceKey) {
logger.warn('忽略对设备标识(deviceKey)的改写:它是这台机器在服务端的身份,不允许变更');
}
if (persisted.serverUrl !== oldServer && (!patch.token || patch.token === oldToken))
patch = { ...patch, token: null };
if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token;
@@ -514,6 +505,11 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
}
scheduleWrite(persisted);
if (persisted.serverUrl !== oldServer) connectionChanged('server-changed');
if (state.token !== oldToken) {
connectionChanged('token-changed');
connectionChanged(state.token ? 'login' : 'logout');
}
return toDesktopConfig(state);
}
@@ -528,6 +524,7 @@ export function clearConfig(): DesktopStoredConfig {
const current = cache?.config ?? DEFAULT_CONFIG;
const kept: PersistedConfig = {
...DEFAULT_CONFIG,
islandLayout: normalizeIslandLayouts(current.islandLayout),
deviceKey: current.deviceKey ?? '',
autoLaunch: current.autoLaunch === true,
autoUpdate: current.autoUpdate !== false,
@@ -539,6 +536,7 @@ export function clearConfig(): DesktopStoredConfig {
ensureDeviceKey(kept);
const state: ConfigCache = { config: kept, token: null };
cache = state;
connectionChanged('logout');
scheduleWrite(state.config, true);
return toDesktopConfig(state);
}
@@ -0,0 +1,240 @@
import type { ReadableStream } from 'node:stream/web';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { app, BrowserWindow, shell } from 'electron';
import {
COURSEWARE_MAX_BYTES,
COURSEWARE_TRANSFER_TIMEOUT_MS,
type ApiResponse,
type CoursewareDto,
type CoursewarePushDto,
type CoursewareTransfer,
} from '@classhelper/shared';
import { getConfig } from './config.js';
import { connectionEvents } from './config-events.js';
import { island } from './island.js';
import { logger } from './logger.js';
let transfer: CoursewareTransfer | null = null,
epoch = 0;
let abort: AbortController | null = null,
polling = false;
let queue: Promise<unknown> = Promise.resolve();
const jobs = new Map<string, Promise<void>>();
let clearTimer: ReturnType<typeof setTimeout> | undefined;
export function getCoursewareTransfer() {
return transfer;
}
function publish(value: CoursewareTransfer | null) {
transfer = value;
island.setCoursewareTransfer(value);
for (const win of BrowserWindow.getAllWindows())
if (!win.isDestroyed()) win.webContents.send('courseware:progress', value);
}
connectionEvents.on('changed', () => {
epoch++;
abort?.abort();
clearTimeout(clearTimer);
publish(null);
});
function checkId(id: string) {
if (typeof id !== 'string' || !/^[a-z0-9-]{1,100}$/i.test(id)) throw new Error('课件编号无效');
}
function scopeDirectory() {
const config = getConfig();
const scope = createHash('sha256')
.update(`${config.serverUrl}\n${config.username}`)
.digest('hex')
.slice(0, 24);
return path.join(app.getPath('userData'), 'courseware', scope);
}
async function json<T>(route: string, signal: AbortSignal, body?: object): Promise<T> {
const config = getConfig();
if (!config.token) throw new Error('请先登录班级账号');
const response = await fetch(`${config.serverUrl}/api/courseware${route}`, {
method: body ? 'POST' : 'GET',
headers: {
authorization: `Bearer ${config.token}`,
...(body ? { 'content-type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal,
redirect: 'error',
});
const value = (await response.json()) as ApiResponse<T>;
if (!response.ok || !value.success) throw new Error(value.message || `请求失败(${response.status})`);
return value.data;
}
async function digest(file: string) {
const hash = createHash('sha256');
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
return hash.digest('hex');
}
export function downloadCourseware(id: string): Promise<void> {
checkId(id);
const startedEpoch = epoch,
key = `${epoch}:${id}`;
const existing = jobs.get(key);
if (existing) return existing;
const job = queue
.catch(() => undefined)
.then(async () => {
if (startedEpoch !== epoch) throw new Error('登录会话已切换');
clearTimeout(clearTimer);
abort = new AbortController();
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(COURSEWARE_TRANSFER_TIMEOUT_MS)]);
const config = getConfig();
let partial = '';
try {
const item = await json<CoursewareDto>(`/${encodeURIComponent(id)}`, signal);
if (startedEpoch !== epoch) throw new Error('登录会话已切换');
if (
!Number.isInteger(item.size) ||
item.size < 1 ||
item.size > COURSEWARE_MAX_BYTES ||
!/^[a-f0-9]{64}$/.test(item.sha256)
)
throw new Error('课件文件信息无效');
const safeName =
`_${item.name.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').replace(/[. ]+$/, '')}`.slice(0, 182);
const dir = path.join(scopeDirectory(), id),
file = path.join(dir, safeName);
partial = `${file}.part`;
const base: CoursewareTransfer = {
id,
name: item.name,
category: item.category,
teacherName: item.teacher.name,
receivedBytes: 0,
totalBytes: item.size,
progress: 0,
stage: 'downloading',
};
publish(base);
await fsp.mkdir(dir, { recursive: true });
let valid = false;
try {
valid = (await fsp.stat(file)).size === item.size && (await digest(file)) === item.sha256;
} catch {
/* 尚未下载 */
}
if (!valid) {
const response = await fetch(
`${config.serverUrl}/api/courseware/${encodeURIComponent(id)}/download`,
{ headers: { authorization: `Bearer ${config.token}` }, signal, redirect: 'error' },
);
if (!response.ok || !response.body) throw new Error(`课件下载失败(HTTP ${response.status})`);
const hash = createHash('sha256');
let received = 0,
last = 0;
await pipeline(
Readable.fromWeb(response.body as ReadableStream),
new Transform({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.length;
if (received > item.size) return callback(new Error('课件大小不匹配'));
hash.update(chunk);
if (Date.now() - last > 120) {
last = Date.now();
if (startedEpoch === epoch)
publish({
...base,
receivedBytes: received,
progress: Math.min(99, Math.floor((received / item.size) * 100)),
});
}
callback(null, chunk);
},
}),
fs.createWriteStream(partial, { flags: 'w' }),
{ signal },
);
if (startedEpoch !== epoch) throw new Error('登录会话已切换');
publish({ ...base, receivedBytes: received, progress: 99, stage: 'verifying' });
if (received !== item.size || hash.digest('hex') !== item.sha256)
throw new Error('课件校验失败,请重新下载');
// 下载完成后再次检查可见权限,撤回的文件不得落成本地副本。
await json<CoursewareDto>(`/${encodeURIComponent(id)}`, signal);
if (startedEpoch !== epoch || signal.aborted) throw new Error('登录会话已切换或下载已取消');
await fsp.rm(file, { force: true });
await fsp.rename(partial, file);
}
if (startedEpoch !== epoch) throw new Error('登录会话已切换');
publish({ ...base, receivedBytes: item.size, progress: 100, stage: 'completed' });
} catch (error) {
if (partial) await fsp.rm(partial, { force: true }).catch(() => undefined);
if (startedEpoch === epoch)
publish({
...(transfer?.id === id
? transfer
: {
id,
name: '课件',
category: 'other',
teacherName: '',
receivedBytes: 0,
totalBytes: 0,
progress: 0,
}),
stage: 'failed',
error: error instanceof Error ? error.message : String(error),
});
throw error;
} finally {
abort = null;
if (startedEpoch === epoch)
clearTimer = setTimeout(
() => {
if (transfer?.id === id) publish(null);
},
transfer?.stage === 'failed' ? 12000 : 5000,
);
}
})
.finally(() => jobs.delete(key));
jobs.set(key, job);
queue = job;
// IPC 调用者或轮询负责呈现错误;队列自身始终有拒绝处理器。
void job.catch(() => undefined);
return job;
}
export async function pollCourseware() {
if (polling || !getConfig().token || process.env.ELECTRON_SMOKE_TEST === '1') return;
const startedEpoch = epoch,
config = getConfig();
polling = true;
try {
const tasks = await json<CoursewarePushDto[]>(
`/transfers?deviceKey=${encodeURIComponent(config.deviceKey)}`,
AbortSignal.timeout(10000),
);
for (const task of tasks) {
if (startedEpoch !== epoch) break;
try {
await downloadCourseware(task.courseware.id);
if (startedEpoch !== epoch) break;
await json(`/transfers/${encodeURIComponent(task.id)}/ack`, AbortSignal.timeout(10000), {
deviceKey: config.deviceKey,
});
} catch (error) {
logger.warn(`课件接收失败,下次心跳重试:${String(error)}`);
}
}
} catch (error) {
logger.debug(`读取课件传输任务失败:${String(error)}`);
} finally {
polling = false;
}
}
export async function revealCourseware(id: string) {
checkId(id);
const dir = path.join(scopeDirectory(), id);
const names = await fsp.readdir(dir).catch(() => []);
const file = names.find((name) => !name.endsWith('.part'));
if (!file) throw new Error('请先完成课件下载');
shell.showItemInFolder(path.join(dir, file));
}
@@ -10,8 +10,7 @@ import { logger } from './logger.js';
*
* 1. `render-process-gone` —— 渲染进程没了(崩溃 / 被杀),**静默重建窗口**;
* 2. `unresponsive` —— 界面卡住超过 10 秒还没缓过来,同样重建;期间恢复(`responsive`)就取消;
* 3. `uncaughtException` / `unhandledRejection` —— 主进程的未捕获错误,
* 记完整堆栈,并且**只有在本机确认空闲时**才重启自己。
* 3. `uncaughtException` 只在本机空闲时重启;普通 `unhandledRejection` 仅记录堆栈。
*
* 两条限流(照抄灵动岛 supervisor 的口径,避免"崩溃 → 重建 → 立刻又崩"死循环刷日志):
* **10 分钟内最多 3 次**,超了只记日志、停下来等人工处理。
@@ -27,7 +26,7 @@ const UNRESPONSIVE_GRACE_MS = 10_000;
export interface CrashGuardOptions {
/** 销毁并重建主窗口(index.ts 里复用原有的"建窗 + 载入界面"逻辑) */
recoverMainWindow: () => void;
recoverMainWindow: () => void | Promise<void>;
/** 本机是否空闲(不在上课且主窗口不可见)—— 决定主进程崩溃后能不能自动重启 */
isIdle: () => boolean;
/** 退出应用(复用既有的退出路径,保证配置落盘) */
@@ -56,7 +55,7 @@ export function installCrashGuard(guard: CrashGuardOptions): void {
logger.error(
`主窗口渲染进程异常退出:reason=${details.reason} exitCode=${details.exitCode}(准备重建窗口)`,
);
recoverWindow(`渲染进程退出(${details.reason})`);
void recoverWindow(`渲染进程退出(${details.reason})`);
});
app.on('child-process-gone', (_event, details) => {
@@ -72,7 +71,6 @@ export function installCrashGuard(guard: CrashGuardOptions): void {
process.on('unhandledRejection', (reason) => {
logger.error('主进程未处理的 Promise 拒绝:', reason);
restartIfIdle('unhandledRejection');
});
}
@@ -107,18 +105,17 @@ function armUnresponsiveTimer(): void {
unresponsiveTimer = setTimeout(() => {
unresponsiveTimer = null;
if (!mainWindow || mainWindow.isDestroyed()) return;
recoverWindow('界面无响应超过 10 秒');
void recoverWindow('界面无响应超过 10 秒');
}, UNRESPONSIVE_GRACE_MS);
}
function isMainWindowContents(id: number): boolean {
// 主窗口引用还没建立(启动早期)时按"就是它"处理,避免漏掉重建
if (!mainWindow || mainWindow.isDestroyed()) return true;
if (!mainWindow || mainWindow.isDestroyed()) return false;
return mainWindow.webContents.id === id;
}
/** 静默重建主窗口(限流) */
function recoverWindow(reason: string): void {
async function recoverWindow(reason: string): Promise<void> {
const recover = options?.recoverMainWindow;
if (recovering) return;
if (!recover) {
@@ -145,7 +142,7 @@ function recoverWindow(reason: string): void {
win.hide();
}
logger.warn(`开始重建主窗口(${reason},本窗口期第 ${windowRecoverAt.length} 次)`);
recover();
await recover();
} catch (error) {
logger.error(`重建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
+101 -85
View File
@@ -18,6 +18,7 @@ const currentDir = __dirname;
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
const isQuickSmoke = isSmokeTest && process.env.ELECTRON_SMOKE_MODE !== 'visual';
/**
* 这次启动是否需要"直接进托盘、不弹窗"——开机自启专用(见 `applyAutoLaunch()`)。
@@ -73,6 +74,7 @@ function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
title: 'ClassHelper',
// 冒烟验证时不弹窗,避免打扰使用者;开机自启(--hidden)时直接进托盘
show: !isSmokeTest && options.hidden !== true,
focusable: !isQuickSmoke,
autoHideMenuBar: true,
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
webPreferences: {
@@ -81,6 +83,8 @@ function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
nodeIntegration: false,
sandbox: true,
webSecurity: true,
backgroundThrottling: false,
offscreen: isQuickSmoke,
},
});
@@ -99,6 +103,12 @@ function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
win.on('closed', () => {
if (mainWindow === win) mainWindow = null;
});
win.on('close', (event) => {
if (isQuitting) return;
event.preventDefault();
win.hide();
logger.info('主窗口已隐藏到系统托盘(后台继续运行)');
});
// 崩溃自愈与「空闲安装更新」都要盯着主窗口:窗口重建后换的是新实例,因此每次建窗都重挂一次
watchCrashGuardWindow(win);
@@ -117,7 +127,7 @@ function ensureMainWindow(options: { hidden?: boolean } = {}): BrowserWindow | n
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
try {
mainWindow = createWindow(options);
void loadRenderer(mainWindow);
void loadRenderer(mainWindow).catch((error) => logger.error('重建主窗口加载失败', error));
} catch (error) {
logger.error(`创建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
mainWindow = null;
@@ -172,95 +182,102 @@ if (!gotLock) {
showMainWindow();
});
app.whenReady().then(async () => {
// 日志级别跟开发者模式走:debug 只在开启时才输出(设置页「开发者模式」)
logger.setVerbose(getConfig().developerMode === true);
registerIpcHandlers();
registerIslandIpc();
app
.whenReady()
.then(async () => {
// 日志级别跟开发者模式走:debug 只在开启时才输出(设置页「开发者模式」)
logger.setVerbose(getConfig().developerMode === true);
registerIpcHandlers();
registerIslandIpc();
// 开机自启:登录项要与配置保持一致(用户在设置页改过之后由 IPC 立即同步)
applyAutoLaunch();
// 开机自启:登录项要与配置保持一致(用户在设置页改过之后由 IPC 立即同步)
applyAutoLaunch();
mainWindow = createWindow({ hidden: startHidden });
await loadRenderer(mainWindow);
mainWindow = createWindow({ hidden: startHidden });
// 崩溃自愈:主窗口渲染进程崩溃 / 界面无响应时静默重建;主进程崩溃且本机空闲时自动重启
installCrashGuard({
recoverMainWindow: () => {
const alive = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
if (alive) {
alive.hide();
alive.destroy();
}
mainWindow = ensureMainWindow();
},
isIdle: () =>
island.getState().inClass === false &&
mainWindow?.isVisible() !== true &&
!BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible()),
quitApp: () => quitApp(),
});
// 空闲安装更新:先把手动退出的回调交给 auto-update(它不能再 import 本文件,否则循环导入)
setQuitHandler(() => quitApp());
// 灵动岛:独立的置顶透明小窗口(桌面中上方)
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();
// 崩溃自愈:主窗口渲染进程崩溃 / 界面无响应时静默重建;主进程崩溃且本机空闲时自动重启
installCrashGuard({
recoverMainWindow: async () => {
const alive = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
if (alive) {
alive.hide();
alive.destroy();
}
mainWindow = createWindow({ hidden: true });
await loadRenderer(mainWindow);
},
isIdle: () =>
island.getState().inClass === false &&
mainWindow?.isVisible() !== true &&
!BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible()),
quitApp: () => quitApp(),
});
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
island.setMarkReadHandler((id) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-read', id);
});
// 多条通知时的"标为已读"是**整批**操作:一次把列表里的通知都标上
island.setMarkAllReadHandler((ids) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-all-read', ids);
});
} catch (error) {
logger.error('灵动岛初始化失败(不影响主功能)', error);
}
await loadRenderer(mainWindow).catch((error) => logger.error('主窗口启动加载失败', error));
// 空闲安装更新:先把手动退出的回调交给 auto-update(它不能再 import 本文件,否则循环导入)
setQuitHandler(() => quitApp());
// 关闭主窗口 → 隐藏到系统托盘(需求 5),只有托盘"退出"才真正结束进程
// 注意:必须在冒烟验证之前挂载,否则 close() 会真的销毁窗口
mainWindow?.on('close', (event) => {
if (isQuitting) return;
event.preventDefault();
mainWindow?.hide();
logger.info('主窗口已隐藏到系统托盘(后台继续运行,左键托盘图标可恢复)');
});
try {
createTray({
onShow: () => showMainWindow(),
onHide: () => mainWindow?.hide(),
onQuit: () => quitApp(),
});
} catch (error) {
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
}
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
scheduleStartupUpdateCheck(() => mainWindow);
// 服务端统一下发:定时上报本机状态并领取「待执行命令 / 统一更新」(冒烟默认不上报)
startClientAgent();
if (isSmokeTest) {
// 灵动岛:独立的置顶透明小窗口(桌面中上方)
try {
await runSmokeTest(mainWindow);
if (!isQuickSmoke)
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();
});
island.setEditLayoutHandler(() => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
mainWindow.webContents.send('island:open-editor');
});
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
island.setMarkReadHandler((id) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-read', id);
});
// 多条通知时的"标为已读"是**整批**操作:一次把列表里的通知都标上
island.setMarkAllReadHandler((ids) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-all-read', ids);
});
} catch (error) {
console.error('[SMOKE] 冒烟验证异常', error);
app.exit(1);
logger.error('灵动岛初始化失败(不影响主功能)', error);
}
}
});
try {
if (!isQuickSmoke)
createTray({
onShow: () => showMainWindow(),
onHide: () => mainWindow?.hide(),
onQuit: () => quitApp(),
});
} catch (error) {
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
}
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
scheduleStartupUpdateCheck(() => mainWindow);
// 服务端统一下发:定时上报本机状态并领取「待执行命令 / 统一更新」(冒烟默认不上报)
startClientAgent();
if (isSmokeTest) {
try {
await runSmokeTest(mainWindow);
} catch (error) {
console.error('[SMOKE] 冒烟验证异常', error);
app.exit(1);
}
}
})
.catch((error) => {
logger.error('应用初始化失败', error);
if (isSmokeTest) app.exit(1);
});
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动 / 灵动岛"打开应用") */
function showMainWindow(): void {
@@ -326,10 +343,9 @@ if (!gotLock) {
logger.info(`应用退出清理:${summary}`);
});
app.on('activate', async () => {
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
const win = ensureMainWindow();
if (win) await loadRenderer(win);
ensureMainWindow();
}
});
}
+16 -2
View File
@@ -1,3 +1,6 @@
import { downloadCourseware, getCoursewareTransfer, pollCourseware, revealCourseware } from './courseware.js';
import { island } from './island.js';
import { getUpdateProgress } from './auto-update.js';
import { app, BrowserWindow, ipcMain, shell } from 'electron';
import type { UpdateInfo } from '@classhelper/shared';
import { IPC_CHANNELS } from './channels.js';
@@ -12,12 +15,23 @@ import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
export { IPC_CHANNELS };
export function registerIpcHandlers(): void {
ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id));
ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id));
ipcMain.handle('courseware:sync', () => { void pollCourseware(); });
ipcMain.handle('courseware:get-progress', () => getCoursewareTransfer());
ipcMain.handle('client:get-update-progress', () => getUpdateProgress());
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
ipcMain.handle(
IPC_CHANNELS.saveConfig,
(_event, patch: Partial<DesktopStoredConfig>): DesktopStoredConfig => {
return saveConfig(patch ?? {});
// 更新安装记录只允许主进程内部写入,渲染进程不得借配置通道指定可执行文件。
const allowed = { ...patch };
delete allowed.pendingUpdateFile;
delete allowed.pendingUpdateVersion;
const saved = saveConfig(allowed);
if (allowed.islandLayout) island.syncCustomLayout();
return saved;
},
);
@@ -127,7 +141,7 @@ export function registerIpcHandlers(): void {
return true;
});
/** 服务端联动状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md §5 第 9 条) */
/** 服务端连接状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md 的现行工程约定) */
ipcMain.handle(IPC_CHANNELS.clientStatus, () => getClientAgentStatus());
/** 立即上报一次(设置页「立即上报」按钮) */
+518 -34
View File
@@ -1,8 +1,13 @@
import type { ScheduleDto as ContractScheduleDto } from '@classhelper/shared';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, ipcMain, screen } from 'electron';
import {
COURSEWARE_ISLAND_SIZE,
type CoursewareTransfer,
DEFAULT_ISLAND_APPEARANCE,
CLASSISLAND_ISLAND_METRICS,
normalizeIslandLayouts,
ISLAND_POSITIONS,
ISLAND_SHADOW_PAD,
ISLAND_SIZE_DELTA,
@@ -15,9 +20,11 @@ import {
type IslandMode,
type IslandNotification,
type IslandState,
type IslandLessonReminder,
} from '@classhelper/shared';
import { logger } from './logger.js';
import { getConfig } from './config.js';
import { getConfig, saveConfig } from './config.js';
import { LessonReminderTracker } from './lesson-reminder.js';
/** 生产环境的灵动岛页面地址(导航白名单的精确比对用) */
const prodIslandUrl = pathToFileURL(path.join(__dirname, '../renderer/island.html')).href;
@@ -130,6 +137,7 @@ export interface IslandPushContext {
}
export interface IslandClassStatePayload {
schedules?: ContractScheduleDto[];
inClass: boolean;
currentPeriodEnd?: string | null;
week?: number;
@@ -154,6 +162,43 @@ interface IslandActionPayload {
}
class IslandController {
private lessonTracker = new LessonReminderTracker();
private lessonCue: IslandLessonReminder | null = null;
private lessonTimer: ReturnType<typeof setInterval> | null = null;
private lessonTestTime: number | null = null;
private get lessonShowing(): boolean {
return Boolean(
this.lessonCue &&
!this.editingLayout &&
!this.previewId &&
!(
this.state.active &&
(this.explicitShowId === this.state.active.id ||
(this.state.active.priority === 'URGENT' && this.state.mode !== 'hidden'))
),
);
}
/** 冒烟只在隔离配置中推进时钟,不修改操作系统时间。 */
setLessonReminderTestTime(at: number | null): void {
if (process.env.ELECTRON_SMOKE_TEST !== '1') return;
this.lessonTestTime = at;
this.refreshLessonReminder();
}
private refreshLessonReminder(): void {
let next = this.lessonTracker.update(
this.schedules,
new Date(this.lessonTestTime ?? Date.now()),
getConfig().islandLayout.lessonReminders,
);
// 自定义布局由课程组件持续显示进度,时间点提醒才临时接管岛体。
if (this.customLayout && next?.kind === 'in-class') next = null;
if (JSON.stringify(next) === JSON.stringify(this.lessonCue)) return;
this.lessonCue = next;
// 触摸窗口先放得下新横条,再下发内容;混合宽高变化也只能增大画布。
if (this.lessonShowing) this.syncTouchLayout();
this.emit();
this.syncWindow();
}
private win: BrowserWindow | null = null;
private rendererUrl: string | null = null;
private ready = false;
@@ -172,6 +217,15 @@ class IslandController {
private boundsFrame: number | null = null;
/** 外观设置(设置页可调,主进程持久化) */
private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE };
/** 阅读消息时同时关闭原生窗口透明度,防止实色 CSS 仍被窗口整体淡化。 */
private get displayOpacity(): number {
const readingMessage =
this.state.active &&
this.state.mode !== 'hidden' &&
(!this.customLayout || this.state.mode === 'expanded') &&
!this.lessonShowing;
return readingMessage ? 1 : this.appearance.opacity;
}
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
/** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */
private blurCollapseEnabled = true;
@@ -189,6 +243,148 @@ class IslandController {
* 只有投影所需的 10px 留白,不再吞掉桌面点击。
*/
private touchMode = false;
private editingLayout = false;
private settlingLayout = false;
private layoutSettleTimer: ReturnType<typeof setTimeout> | null = null;
private customSize = { width: 424, height: 80 };
private customDesiredSize = { width: 424, height: 80 };
private customTimer: ReturnType<typeof setTimeout> | null = null;
private get customLayout(): boolean {
return getConfig().islandLayout.layout === 'custom';
}
private get residentEnabled(): boolean {
return this.customLayout;
}
private residentWasEnabled = false;
private residentFaded = false;
private residentTouchUntil = 0;
private residentMotionUntil = 0;
private residentControl: { x: number; y: number; width: number; height: number } | null = null;
private residentShapeTimer: ReturnType<typeof setTimeout> | null = null;
private residentShaped = false;
/** 触摸屏的透明画布不应吞掉桌面:动画时放开绘制区,停稳后只保留岛体及投影区域。 */
private syncResidentShape(transition = false): void {
if (!this.win || this.win.isDestroyed() || process.platform !== 'win32') return;
if (this.residentShapeTimer) clearTimeout(this.residentShapeTimer);
this.residentShapeTimer = null;
if (!this.residentEnabled || !this.touchMode || this.editingLayout) {
if (this.residentShaped) this.win.setShape([]);
this.residentShaped = false;
return;
}
this.residentShaped = true;
const apply = () => {
this.residentShapeTimer = null;
const r = this.hitRect;
if (
!r ||
!this.win ||
this.win.isDestroyed() ||
!this.residentEnabled ||
!this.touchMode ||
this.editingLayout
)
return;
this.win.setShape([
{
x: Math.max(0, Math.floor(r.x - 8)),
y: Math.max(0, Math.floor(r.y - 8)),
width: Math.ceil(r.width + 16),
height: Math.ceil(r.height + 16),
},
]);
};
if (transition) {
this.win.setShape([]);
this.residentShapeTimer = setTimeout(
apply,
this.appearance.animations ? 750 / this.appearance.speed : 50,
);
} else apply();
}
getEditingLayout(): boolean {
return this.editingLayout;
}
syncCustomLayout(): void {
this.refreshLessonReminder();
if (this.residentEnabled !== this.residentWasEnabled) {
this.residentFaded = false;
this.residentWasEnabled = this.residentEnabled;
this.clearTimers();
this.state = {
...this.state,
mode: this.state.active ? 'pill' : 'hidden',
listExpanded: false,
updatedAt: Date.now(),
};
if (this.residentEnabled) this.settleCustomLayout();
this.emit();
}
this.win?.webContents.send('island:layout', getConfig().islandLayout);
this.syncResidentShape(true);
this.syncWindow();
}
/** 仅用于首次切换布局、启动与退出编辑;日常常驻岛开合保持窗口可见。 */
private settleCustomLayout(): void {
if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer);
this.settlingLayout = true;
this.win?.hide();
this.layoutSettleTimer = setTimeout(() => {
this.layoutSettleTimer = null;
this.settlingLayout = false;
this.syncWindow();
}, 320);
}
private editLayoutHandler: (() => void) | null = null;
setEditLayoutHandler(handler: () => void): void {
this.editLayoutHandler = handler;
}
editLayout(enabled = true): void {
if (enabled) this.editLayoutHandler?.();
}
reportCustomSize(value: { width: number; height: number }): void {
if (
!this.customLayout ||
!Number.isFinite(value?.width) ||
!Number.isFinite(value?.height) ||
value.width <= 0 ||
value.height <= 0
)
return;
this.customDesiredSize = { ...value };
if (this.editingLayout) return;
if (this.residentEnabled) {
if (this.customTimer) clearTimeout(this.customTimer);
this.customTimer = null;
// 常驻岛只报告目标几何;窗口画布固定,禁止逐帧 setBounds 和两端尺寸防抖互相追赶。
if (
this.customSize.width !== Math.ceil(value.width) ||
this.customSize.height !== Math.ceil(value.height)
)
this.residentMotionUntil =
Date.now() + (this.appearance.animations ? 600 / this.appearance.speed : 0);
this.customSize = { width: Math.ceil(value.width), height: Math.ceil(value.height) };
this.syncResidentShape(true);
return;
}
if (this.customTimer) clearTimeout(this.customTimer);
this.customTimer = setTimeout(() => {
this.customTimer = null;
if (!this.win || !this.customLayout || this.editingLayout || this.residentEnabled) return;
const area = screen.getDisplayNearestPoint(this.win.getBounds()).workArea;
const current = this.win.getBounds();
const width = Math.max(40, Math.min(Math.ceil(value.width), area.width - 2 * ISLAND_SHADOW_PAD));
const height = Math.max(24, Math.min(Math.ceil(value.height), area.height - 2 * ISLAND_SHADOW_PAD));
// 可见/编辑期间不主动缩窗;只有隐藏后收缩。渲染器按获准尺寸铺底,窗口=卡片+2×留白。
this.customSize = {
width: this.win.isVisible() ? Math.max(width, current.width - 2 * ISLAND_SHADOW_PAD) : width,
height: this.win.isVisible() ? Math.max(height, current.height - 2 * ISLAND_SHADOW_PAD) : height,
};
this.applyWindowLayout();
this.win.webContents.send('island:viewport', this.customSize);
this.setHitRect({ x: ISLAND_SHADOW_PAD, y: ISLAND_SHADOW_PAD, ...this.customSize });
}, 120);
}
/** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */
private hitRect: { x: number; y: number; width: number; height: number } | null = null;
/** 光标命中兜底轮询定时器(见 syncHitFromCursor 注释) */
@@ -316,6 +512,12 @@ class IslandController {
// 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击),
// 只有指针进入"岛"的可见区域时,渲染进程才通过 island:set-interactive 打开命中。
win.setIgnoreMouseEvents(true, { forward: true });
const notifyVisibility = (visible: boolean) => {
if (this.win === win && this.ready && !this.recovering && !win.webContents.isDestroyed())
win.webContents.send('island:visibility', visible);
};
win.on('show', () => notifyVisibility(true));
win.on('hide', () => notifyVisibility(false));
// 光标命中兜底轮询:即使 forward 的 mousemove 丢失,也能按光标位置自行校正命中
this.startHitPoll();
win.on('closed', () => {
@@ -333,22 +535,27 @@ class IslandController {
* 用户看到的就是「点了没反应 / 点开又立刻缩回去」。
* 因此这里再用**指针位置**兜一层:指针还在岛上就绝不收起(详见 isCursorOnIsland)。
*/
win.on('resize', () => {
if (this.customLayout && !this.editingLayout && this.ready && !win.isDestroyed()) {
const { width, height } = win.getContentBounds();
win.webContents.send('island:viewport', { width, height });
}
});
win.on('blur', () => {
if (this.editingLayout) return;
// 冒烟里可以临时关掉"失焦收起",避免测试过程被无关的焦点变化打断(默认开启)
if (!this.blurCollapseEnabled) return;
if (this.state.mode !== 'expanded' || this.state.inClass) return;
if (this.state.mode !== 'expanded') return;
// 设置页预览:示例岛要继续留在屏幕上(用户在调滑块时主窗口一直是焦点)
if (this.previewId && this.state.active?.id === this.previewId) return;
const sinceActivated = Date.now() - this.state.updatedAt;
if (this.isCursorOnIsland()) {
logger.info(
`灵动岛:忽略失焦(指针仍在岛上,判定为展开时的激活抖动)距上次状态=${sinceActivated}ms`,
);
logger.info(`灵动岛:忽略失焦(指针仍在岛上,判定为展开时的激活抖动)距上次状态=${sinceActivated}ms`);
return;
}
// 触摸屏上没有"指针"可依据(isCursorOnIsland 恒为 false),只能用更宽的宽限期覆盖
// 上面实测到的 70~520ms 抖动区间,否则手指点开后约 0.5s 会莫名缩回胶囊。
const grace = this.touchMode ? BLUR_GRACE_TOUCH_MS : BLUR_GRACE_MS;
const grace = this.touchMode || this.editingLayout ? BLUR_GRACE_TOUCH_MS : BLUR_GRACE_MS;
if (sinceActivated < grace) {
logger.info(`灵动岛:忽略激活后 ${sinceActivated}ms 内的失焦(宽限期内不收起)`);
return;
@@ -396,13 +603,19 @@ class IslandController {
await win.loadFile(path.join(__dirname, '../renderer/island.html'));
}
this.ready = true;
if (!this.lessonTimer) this.lessonTimer = setInterval(() => this.refreshLessonReminder(), 1000);
if (this.pendingState) {
this.emit(this.pendingState);
this.pendingState = null;
}
// 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值
this.emitAppearance();
this.win?.webContents.send('island:schedules', this.schedules);
this.emit();
if (this.residentEnabled) {
this.residentWasEnabled = true;
this.settleCustomLayout();
}
logger.info('灵动岛已就绪(置顶透明窗口,顶部居中)');
}
@@ -410,8 +623,35 @@ class IslandController {
return this.win;
}
private schedules: ContractScheduleDto[] = [];
getSchedules(): ContractScheduleDto[] {
return [...this.schedules];
}
getState(): IslandState {
return { ...this.state, queued: [...this.state.queued] };
return {
...this.state,
coursewareTransfer: this.coursewareShowing ? this.state.coursewareTransfer : null,
lessonReminder: this.lessonShowing ? this.lessonCue : null,
queued: [...this.state.queued],
};
}
setCoursewareTransfer(value: CoursewareTransfer | null): void {
this.setState({ coursewareTransfer: value });
}
getCoursewareTransfer(): CoursewareTransfer | null {
return this.state.coursewareTransfer ?? null;
}
private get coursewareShowing(): boolean {
return (
!!this.state.coursewareTransfer &&
!(
this.state.mode !== 'hidden' &&
this.state.active &&
(this.state.active.priority === 'URGENT' || this.state.active.kind === 'call')
)
);
}
isReady(): boolean {
@@ -442,7 +682,7 @@ class IslandController {
/**
* 同一条消息**可能经由两条广播链路到达**:服务端 `createCall` 既发 `notification:new`
* (班级房间)又发 `call:new`(学生/班级 user 房间),两条 DTO 的 id 相同、
* (班级房间)又发 `call:new`(班级会话 session 房间),两条 DTO 的 id 相同、
* 只有"是否叫人"的语义不同。后到的那条绝不能把已有的"叫人"降级成普通通知 ——
* 否则胶囊显示成"新消息"、上课时段还会被当成普通通知隐藏(用户实测截图:
* 叫人胶囊写着"新消息 / 系统管理员 · 点击查看")。
@@ -607,6 +847,12 @@ class IslandController {
/** 上课状态变化:进入上课隐藏;下课则自动弹出队列 */
setClassState(payload: IslandClassStatePayload): void {
if (Array.isArray(payload.schedules)) {
this.schedules = payload.schedules.slice(0, 100);
this.refreshLessonReminder();
if (this.ready && this.win && !this.win.isDestroyed())
this.win.webContents.send('island:schedules', this.schedules);
}
const wasInClass = this.state.inClass;
this.state.inClass = payload.inClass;
this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null;
@@ -661,8 +907,17 @@ class IslandController {
);
switch (payload.action) {
case 'expand': {
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
if (this.residentEnabled) {
this.clearTimers();
const pending = this.pendingNotifications();
this.state.active = pending[0] ?? null;
this.state.queued = pending.slice(1);
this.explicitShowId = this.state.active?.id ?? null;
this.setState({ mode: 'expanded', listExpanded: false });
if (this.state.active) this.scheduleCollapse(TIMEOUTS.expanded);
break;
}
// 经典模式课堂普通通知保持隐藏;普通叫人可主动点开,紧急消息也须支持收起后再次查看。
const showing = this.state.active;
// 岛当前可见(胶囊态)时,用户是"对着屏幕上这条岛"点的:
// 只要队列里有更**新**的消息(例如紧急通知之后又来了作业),就应当打开新那条,
@@ -734,6 +989,11 @@ class IslandController {
break;
}
case 'collapse':
if (this.residentEnabled) {
this.clearTimers();
this.setState({ mode: this.pendingNotifications().length ? 'pill' : 'hidden', reason: null });
break;
}
// 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊)
// 上课时段:普通通知彻底隐藏;**叫人 / 紧急消息**回缩为胶囊(保持可见,允许再次展开)
if (this.state.inClass && !this.isOpenable(this.state.active)) {
@@ -794,6 +1054,10 @@ class IslandController {
this.clearTimers();
this.explicitShowId = null;
if (!this.win || this.win.isDestroyed()) return;
if (this.residentEnabled) {
this.setState({ mode: 'hidden', reason: null });
return;
}
// 开启"空闲细缝"时不能走 fadeOut + skipWindow:那样会完全隐藏,
// 必须让 syncWindow() 把窗口收成一条细缝(上课时段仍由 syncWindow 兜底为立即隐藏)。
if (this.appearance.idleSliver) {
@@ -810,7 +1074,7 @@ class IslandController {
* 起因:用户反馈"点「知道了」「已读」时没有收回动画"(其他场景都有)。
* 状态**在同一帧就已经落地**(调用方 `dismissActive()` / `clearPendingForClose()` 已清空
* active/queued),这里只是把形态切回胶囊 —— **不引入任何跨进程握手,也没有收回快照**
* (见 AGENTS.md §5 第 34 条:早先自研的 `closing` / `closingSnapshot` 已整体删除,
* (见 AGENTS.md 的现行工程约定:早先自研的 `closing` / `closingSnapshot` 已整体删除,
* 它在触摸屏上会露旧帧;这里复用的是已被 `collapse` 用例验收过的那条路径)。
*/
private beginClose(): void {
@@ -824,14 +1088,17 @@ class IslandController {
* 而窗口此刻还是展开/列表态的大包围盒 —— 若这里同步缩窗,卡片会在动画的 360ms 里
* 被窗口裁掉(冒烟用例「整批关闭过程中卡片不被窗口裁切」实测最大越界 554.9px,
* 用户看到的是一瞬间的硬裁边)。窗口要变小一律等**隐藏之后**由 `syncWindow()` 做,
* 这与触摸模式那条"窗口可见期间只增不减"(AGENTS.md §5 第 30 条)是同一条纪律。
* 这与触摸模式那条"窗口可见期间只增不减"(AGENTS.md 的现行工程约定)是同一条纪律。
*/
this.setState({ mode: 'pill', reason: 'closing' }, { skipWindow: true });
const speed = clamp(this.appearance.speed, 0.5, 2, 1);
this.closeTimer = setTimeout(() => {
this.closeTimer = null;
this.finishClose();
}, Math.round(CLOSE_ANIMATION_MS / speed));
this.closeTimer = setTimeout(
() => {
this.closeTimer = null;
this.finishClose();
},
Math.round(CLOSE_ANIMATION_MS / speed),
);
}
/**
@@ -844,6 +1111,26 @@ class IslandController {
if (this.state.active || this.state.queued.length > 0) return;
this.fadeOut();
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
if (this.customLayout)
setTimeout(() => {
// 提醒沿原路径收回后,隐藏窗口再缩回组件栏;时钟等普通组件继续显示。
if (
!this.win ||
this.win.isDestroyed() ||
!this.customLayout ||
this.editingLayout ||
this.state.mode !== 'hidden'
)
return;
this.win.hide();
const area = screen.getDisplayNearestPoint(this.win.getBounds()).workArea;
this.customSize = {
width: Math.max(40, Math.min(this.customDesiredSize.width, area.width - 2 * ISLAND_SHADOW_PAD)),
height: Math.max(24, Math.min(this.customDesiredSize.height, area.height - 2 * ISLAND_SHADOW_PAD)),
};
this.win.webContents.send('island:viewport', this.customSize);
this.syncWindow();
}, 200);
}
/**
@@ -864,18 +1151,38 @@ class IslandController {
if (!this.win || this.win.isDestroyed()) return;
// 焦点/可激活性统一由 applyFocusable 维护(窗口必须始终可激活,否则丢 mousedown)
if (this.win.isFocused()) this.win.blur();
if (this.customLayout) {
this.syncWindow();
return;
}
if (this.win.isVisible()) this.win.hide();
this.win.setOpacity(1);
}
destroy(): void {
if (this.lessonTimer) clearInterval(this.lessonTimer);
this.lessonTimer = null;
this.lessonCue = null;
this.lessonTracker = new LessonReminderTracker();
if (this.residentShapeTimer) clearTimeout(this.residentShapeTimer);
this.residentShapeTimer = null;
this.clearTimers();
if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer);
this.layoutSettleTimer = null;
this.settlingLayout = false;
this.stopHitPoll();
this.cancelBoundsAnimation();
if (this.win && !this.win.isDestroyed()) this.win.destroy();
this.win = null;
}
/** 教师撤回通知后,同时移除正在展示和等待下课的副本。 */
removeNotification(id: string): void {
this.state.queued = this.state.queued.filter((item) => item.id !== id);
if (this.state.active?.id === id) this.dismissActive();
else this.setState({});
}
/* ------------------------------------------------------------ 内部实现 */
private dismissActive(): void {
@@ -908,7 +1215,6 @@ class IslandController {
this.hide();
}
private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void {
this.clearTimers();
// 正在展示的通知从队列中移出,queue 只保留"尚未看过"的
@@ -1010,11 +1316,7 @@ class IslandController {
? 'right'
: 'center';
const hValue =
hMode === 'left'
? area.x + marginX
: hMode === 'right'
? area.x + area.width - marginX
: center;
hMode === 'left' ? area.x + marginX : hMode === 'right' ? area.x + area.width - marginX : center;
return { hMode, hValue, vMode, vValue };
}
@@ -1048,7 +1350,14 @@ class IslandController {
/** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */
private setState(patch: Partial<IslandState>, options: { skipWindow?: boolean } = {}): void {
if (
this.residentEnabled &&
patch.mode &&
(patch.mode === 'expanded') !== (this.state.mode === 'expanded')
)
this.residentMotionUntil = Date.now() + (this.appearance.animations ? 600 / this.appearance.speed : 0);
this.state = { ...this.state, ...patch, updatedAt: Date.now() };
if (this.residentEnabled) this.syncResidentShape(true);
// 离开展开态就把"展开更多"复位:下次再点开时默认回到"只显示前三条"
if (this.state.mode !== 'expanded') this.state.listExpanded = false;
// 列表形态比普通展开卡高:窗口要**先**长高,卡片才开始形变,否则底部会被窗口裁掉
@@ -1078,6 +1387,48 @@ class IslandController {
*/
private syncWindow(): void {
if (!this.win || this.win.isDestroyed()) return;
if (this.coursewareShowing && !this.editingLayout) {
this.cancelFade();
if (!this.win.isVisible() || this.customLayout) this.applyWindowLayout();
this.syncTouchLayout();
this.win.setOpacity(this.appearance.opacity);
if (!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual'))
this.win.showInactive();
this.setInteractive(false, '课件传输不遮挡桌面操作');
return;
}
if (this.editingLayout) {
this.setInteractive(true, '布局编辑');
return;
}
if (this.customLayout) {
if (this.settlingLayout) return;
this.cancelFade();
this.applyWindowLayout();
if (this.residentEnabled) this.applyFocusable();
this.updateResidentPointer();
this.win.setOpacity(
this.displayOpacity * (this.residentFaded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1),
);
if (
!this.win.isVisible() &&
!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual')
)
this.win.showInactive();
if (this.residentEnabled) this.syncHitFromCursor();
else this.setInteractive(true, '自定义组件栏');
return;
}
if (this.lessonShowing) {
this.cancelFade();
if (!this.win.isVisible()) this.applyWindowLayout();
this.win.setOpacity(this.displayOpacity);
if (!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual'))
this.win.showInactive();
this.syncHitFromCursor();
return;
}
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知 / 叫人"允许出现在屏幕上)
// 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算
@@ -1105,7 +1456,7 @@ class IslandController {
this.setInteractive(false, '隐藏态');
if (this.appearance.idleSliver) {
this.cancelFade();
this.win.setOpacity(this.appearance.opacity);
this.win.setOpacity(this.displayOpacity);
if (!this.win.isVisible()) this.win.showInactive();
// 细缝也要"可见即可点":按真实光标位置决定是否接收鼠标
this.syncHitFromCursor();
@@ -1118,8 +1469,8 @@ class IslandController {
// 岛应该可见:**必须**先取消可能还在跑的淡出 —— 否则那次淡出会继续把透明度降到 0
// 并 hide() 掉窗口,造成"状态是胶囊/展开、窗口却被隐藏"的错位(岛看着在、点不动或直接消失)。
this.cancelFade();
if (this.win.getOpacity() !== this.appearance.opacity) {
this.win.setOpacity(this.appearance.opacity);
if (this.win.getOpacity() !== this.displayOpacity) {
this.win.setOpacity(this.displayOpacity);
}
if (!this.win.isVisible()) {
// 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示
@@ -1138,7 +1489,27 @@ class IslandController {
* - 触摸模式:贴合当前形态(岛体 + 2×阴影留白),否则大包围盒的透明区会吞掉桌面的触摸。
*/
private windowBox(): { width: number; height: number } {
if (this.residentEnabled && this.win) {
const area = this.targetDisplay().workArea;
// 与 ClassIsland 一样,让背景/内容在固定画布内过渡。预留一帧绘制边界,避免过冲裁切。
return {
width: Math.min(960, area.width),
height: Math.min(640, Math.max(60, area.height - this.appearance.marginY)),
};
}
if (this.customLayout)
return {
width: this.customSize.width + 2 * ISLAND_SHADOW_PAD,
height: this.customSize.height + 2 * ISLAND_SHADOW_PAD,
};
const size = this.formSize();
if (this.touchMode && this.lessonShowing) {
const current = this.win?.isVisible() ? this.win.getBounds() : null;
return {
width: Math.round(Math.max(size.width + 2 * ISLAND_SHADOW_PAD, current?.width ?? 0)),
height: Math.round(Math.max(size.height + 2 * ISLAND_SHADOW_PAD, current?.height ?? 0)),
};
}
if (!this.touchMode) {
return {
width: Math.round(Math.max(BBOX_SIZE.width, size.width + ISLAND_SHADOW_PAD * 2)),
@@ -1197,6 +1568,7 @@ class IslandController {
private applyWindowLayout(): void {
if (!this.win || this.win.isDestroyed()) return;
const next = this.windowBounds();
if (this.editingLayout) return;
const current = this.win.getBounds();
if (
current.x === next.x &&
@@ -1233,6 +1605,15 @@ class IslandController {
}
const y = anchor.vMode === 'bottom' ? anchor.vValue + pad - height : anchor.vValue - pad;
if (this.customLayout) {
const area = screen.getDisplayNearestPoint({ x, y }).workArea;
return {
x: Math.max(area.x, Math.min(x, area.x + area.width - width)),
y: Math.max(area.y, Math.min(y, area.y + area.height - height)),
width: Math.min(width, area.width),
height: Math.min(height, area.height),
};
}
return { x, y, width, height };
}
@@ -1251,6 +1632,7 @@ class IslandController {
*/
private applyFocusable(): void {
if (!this.win || this.win.isDestroyed()) return;
if (this.editingLayout) return;
if (!this.win.isFocusable()) this.win.setFocusable(true);
if (this.state.mode === 'expanded') {
this.win.focus();
@@ -1261,7 +1643,7 @@ class IslandController {
private fadeIn(): void {
if (!this.win || this.win.isDestroyed()) return;
const target = this.appearance.opacity;
const target = this.displayOpacity;
if (!this.appearance.animations) {
this.fadeToken += 1;
this.win.setOpacity(target);
@@ -1354,7 +1736,7 @@ class IslandController {
this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
if (this.win.isVisible()) {
// 显示中:立即应用透明度(隐藏时由 fadeIn 负责)
this.win.setOpacity(this.appearance.opacity);
this.win.setOpacity(this.displayOpacity);
}
}
this.emitAppearance();
@@ -1387,7 +1769,12 @@ class IslandController {
// 但**注入光标**(冒烟用 setHitTestCursor 模拟指针在岛上/岛外)照常生效,
// 否则「光标轮询校正命中」「岛外穿透」这类断言全会被自己的开关弄红。
const passthrough = this.testInputPassthrough && this.cursorOverride === null;
const next = passthrough ? false : this.touchMode ? true : interactive;
const next =
passthrough || this.residentFaded
? false
: this.touchMode || this.editingLayout || (this.customLayout && !this.residentEnabled)
? true
: interactive;
if (this.interactive === next) return;
this.interactive = next;
logger.info(
@@ -1417,6 +1804,47 @@ class IslandController {
// 渲染进程每 ≥50ms 就会上报一次真实几何,正好拿来当这个判据。
this.syncTouchLayout();
}
setResidentControl(rect: { x: number; y: number; width: number; height: number } | null): void {
this.residentControl =
rect &&
[rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) &&
rect.width > 0 &&
rect.height > 0
? { ...rect }
: null;
}
noteResidentTouch(): void {
this.residentTouchUntil = Date.now() + 1000;
this.updateResidentPointer();
this.setInteractive(true, '常驻岛触摸操作');
}
private updateResidentPointer(cursor?: { x: number; y: number } | null): void {
if (!this.win || this.win.isDestroyed()) return;
const settings = getConfig().islandLayout;
const point = this.cursorLocal(cursor);
const control = this.residentControl;
const overControl = Boolean(
point &&
control &&
point.x >= control.x &&
point.x <= control.x + control.width &&
point.y >= control.y &&
point.y <= control.y + control.height,
);
const faded =
this.residentEnabled &&
settings.pointerFadeEnabled &&
!this.editingLayout &&
!this.lessonShowing &&
Date.now() >= this.residentTouchUntil &&
Date.now() >= this.residentMotionUntil &&
this.state.mode !== 'expanded' &&
this.isInsideHitRect(point) !== settings.pointerFadeReversed &&
!overControl;
if (faded === this.residentFaded) return;
this.residentFaded = faded;
this.win.setOpacity(this.displayOpacity * (faded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1));
}
/** 当前岛体矩形(供冒烟验证) */
getHitRect(): { x: number; y: number; width: number; height: number } | null {
@@ -1433,6 +1861,7 @@ class IslandController {
setTouchMode(enabled: boolean): void {
if (this.touchMode === enabled) return;
this.touchMode = enabled;
this.syncResidentShape();
logger.info(
`灵动岛:触摸模式=${enabled}(窗口${enabled ? '贴合岛体并始终接收输入' : '回到固定包围盒穿透'})`,
);
@@ -1494,6 +1923,9 @@ class IslandController {
/** 当前形态下**岛本身**的尺寸(不是窗口尺寸;响应进程的 `.island-card` 宽高) */
private formSize(): { width: number; height: number } {
if (this.coursewareShowing) return COURSEWARE_ISLAND_SIZE;
if (this.customLayout) return this.customSize;
if (this.lessonShowing) return { width: SIZES.expanded.width, height: 40 };
// 多条通知的列表形态:高度由统一布局算法算出(渲染进程算的是同一个数)
const layout = this.listLayout();
if (layout) return { width: SIZES.expanded.width, height: layout.height };
@@ -1562,13 +1994,22 @@ class IslandController {
/** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */
private syncHitFromCursor(cursor?: { x: number; y: number } | null): void {
if (this.coursewareShowing) {
this.setInteractive(false, '课件传输');
return;
}
this.updateResidentPointer(cursor);
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) {
this.setInteractive(false, '窗口不可见');
return;
}
if (this.lessonShowing) {
this.setInteractive(false, '课表提醒不遮挡桌面操作');
return;
}
// 完全隐藏(含淡出过程中):一律不再接收鼠标,避免窗口在消失的几百毫秒里吞掉桌面点击。
// 空闲细缝态(idleSliver)是"可见即可点",不走这条。
if (this.state.mode === 'hidden' && !this.appearance.idleSliver) {
if (this.state.mode === 'hidden' && !this.appearance.idleSliver && !this.customLayout) {
this.setInteractive(false, '隐藏态');
return;
}
@@ -1834,7 +2275,6 @@ const PREVIEW_DURATION_MS = 30_000;
*/
const MIN_CARD_HEIGHT = 120;
/**
* 渲染进程心跳(渲染进程每 5s 上报一次,间隔写死在 `src/island/IslandApp.vue`):
* 超过这个时间没上报就认为它已经卡死/不响应。
@@ -1866,6 +2306,35 @@ export const island = new IslandController();
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
export function registerIslandIpc(): void {
ipcMain.handle('island:get-schedules', (event) =>
event.sender === island.getWindow()?.webContents ? island.getSchedules() : [],
);
ipcMain.handle('island:get-layout', () => getConfig().islandLayout);
ipcMain.handle('island:save-layout', (_event, value: unknown) => {
const result = saveConfig({ islandLayout: normalizeIslandLayouts(value) }).islandLayout;
island.syncCustomLayout();
return result;
});
ipcMain.handle('island:edit-layout', () => island.editLayout());
ipcMain.handle('island:get-editing', () => island.getEditingLayout());
ipcMain.handle(
'island:get-visible',
(event) => event.sender === island.getWindow()?.webContents && (island.getWindow()?.isVisible() ?? false),
);
ipcMain.on('island:resident-control', (event, value) => {
if (event.sender === island.getWindow()?.webContents) island.setResidentControl(value);
});
ipcMain.on('island:resident-touch', (event) => {
if (event.sender === island.getWindow()?.webContents) island.noteResidentTouch();
});
ipcMain.handle('island:set-editing', (_event, value: unknown) => island.editLayout(value === true));
ipcMain.on('island:content-size', (event, value) => {
if (event.sender === island.getWindow()?.webContents) island.reportCustomSize(value);
});
ipcMain.on('island:remove-notification', (_event, id: string) => {
if (typeof id === 'string') island.removeNotification(id);
});
ipcMain.on(
'island:push',
(_event, payload: { notification: IslandNotification; context?: IslandPushContext }) => {
@@ -1884,13 +2353,28 @@ export function registerIslandIpc(): void {
island.noteRendererAlive();
});
ipcMain.handle('island:get-state', () => island.getState());
let appearancePatch: Partial<IslandAppearance> | null = null;
let appearanceTimer: ReturnType<typeof setTimeout> | null = null;
const flushAppearance = (): void => {
if (appearanceTimer) clearTimeout(appearanceTimer);
appearanceTimer = null;
if (appearancePatch) island.setAppearance(appearancePatch);
appearancePatch = null;
};
ipcMain.handle('island:get-state', () => {
flushAppearance();
return island.getState();
});
ipcMain.on('island:set-appearance', (_event, patch: Partial<IslandAppearance>) => {
if (patch) island.setAppearance(patch);
if (!patch) return;
appearancePatch = { ...appearancePatch, ...patch };
if (!appearanceTimer) appearanceTimer = setTimeout(flushAppearance, 16);
});
ipcMain.handle('island:get-appearance', () => island.getAppearance());
ipcMain.handle('island:get-appearance', () => {
flushAppearance();
return island.getAppearance();
});
// 渲染进程命中测试结果 → **只用于立刻打开命中**(比 60ms 轮询更快)。
// 关闭命中一律由主进程按真实光标决定:两边都下发 true/false 会互相覆盖,
@@ -0,0 +1,107 @@
import {
dayKeyLocal,
resolveClassStatus,
DEFAULT_LESSON_REMINDERS,
type LessonReminderSettings,
type IslandLessonReminder,
type ScheduleDto,
} from '@classhelper/shared';
/**
* 来源:ClassIsland/Services/NotificationProviders/ClassNotificationProvider.cs。
* 室内课提前 60 秒,准备遮罩 3 秒,上课 5 秒,下课遮罩 2 秒后预告下节课 10 秒。
* 只消费服务端已经解析的日课表投影;不在这里重写 ClassPlan 启用或轮换规则。
*/
export class LessonReminderTracker {
private current: IslandLessonReminder | null = null;
update(
schedules: ScheduleDto[],
now: Date,
settings: LessonReminderSettings = DEFAULT_LESSON_REMINDERS,
): IslandLessonReminder | null {
const at = now.getTime();
const keyOf = (s: ScheduleDto, kind: IslandLessonReminder['kind']) =>
`${dayKeyLocal(now)}:${s.id}:${s.startTime}:${s.endTime}:${kind}`;
const status = resolveClassStatus(schedules, now);
const timeAt = (time: string) => {
const d = new Date(now);
const [h, m] = time.split(':').map(Number);
d.setHours(h!, m!, 0, 0);
return d.getTime();
};
const today = schedules.filter((s) => s.dayOfWeek === (now.getDay() || 7));
const current = today.find((s) => s.id === status.current?.scheduleId);
const next = today.find((s) => s.id === status.next?.scheduleId);
const ended = today
.filter((s) => at >= timeAt(s.endTime) && at - timeAt(s.endTime) < 12_000)
.sort((a, b) => b.endTime.localeCompare(a.endTime))[0];
let kind: IslandLessonReminder['kind'] | null = null;
let lesson: ScheduleDto | undefined;
if (current && at - timeAt(current.startTime) < 5_000 && settings.onClassEnabled) {
kind = 'on-class';
lesson = current;
} else if (
settings.breakEnabled &&
!current &&
ended &&
at - timeAt(ended.endTime) < (next ? 12_000 : 5_000) &&
(at - timeAt(ended.endTime) < 5_000 || this.current?.id === keyOf(ended, 'break'))
) {
kind = 'break';
lesson = ended;
} else if (
current &&
settings.endingEnabled &&
timeAt(current.endTime) - at <= settings.endingSeconds * 1000
) {
kind = 'ending';
lesson = current;
} else if (
!current &&
next &&
settings.prepareEnabled &&
timeAt(next.startTime) - at <= settings.prepareSeconds * 1000
) {
kind = 'prepare';
lesson = next;
} else if (current && settings.classProgressEnabled) {
kind = 'in-class';
lesson = current;
}
if (!lesson || !kind) return (this.current = null);
const id = keyOf(lesson, kind);
const entered = this.current?.id === id && at >= this.current.startedAt ? this.current.startedAt : at;
const start = timeAt(lesson.startTime);
const end = timeAt(lesson.endTime);
return (this.current = {
id,
kind,
courseName: lesson.course?.name || '课程',
startTime: lesson.startTime,
endTime: lesson.endTime,
startedAt: entered,
maskUntil:
kind === 'ending' || kind === 'in-class'
? entered
: kind === 'prepare'
? Math.min(start, entered + 3000)
: kind === 'break' && next
? entered + 2000
: entered + 5000,
endsAt:
kind === 'ending' || kind === 'in-class'
? end
: kind === 'prepare'
? start
: kind === 'on-class'
? start + 5000
: end + (next ? 12_000 : 5000),
lessonStartsAt: start,
lessonEndsAt: end,
nextCourseName: kind === 'break' ? next?.course?.name : undefined,
nextStartTime: kind === 'break' ? next?.startTime : undefined,
nextEndTime: kind === 'break' ? next?.endTime : undefined,
});
}
}
@@ -0,0 +1,168 @@
import { createHash } from 'node:crypto';
import { considerUpdate, getUpdateProgress, syncUpdatePolicy } from './auto-update.js';
import { createServer } from 'node:http';
import { app } from 'electron';
import { getConfig, saveConfig } from './config.js';
import { beatNow, getClientAgentStatus, stopClientAgent, startClientAgent } from './client-agent.js';
export async function verifyAgentSmoke(
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
) {
const original = getConfig(),
previous = process.env.ELECTRON_SMOKE_CLIENT_AGENT;
let firstCalls = 0,
secondCalls = 0,
acked = false,
invalidToken = false;
const one = createServer((req, res) => {
firstCalls++;
res.writeHead(401, { 'content-type': 'application/json' });
res.end('{}');
});
const two = createServer((req, res) => {
if (req.headers.authorization !== 'Bearer v14-local-fake-token') invalidToken = true;
if (req.url === '/fake-installer') {
res.setHeader('content-length', String(Buffer.byteLength('仅测试,不是可执行文件')));
res.end('仅测试,不是可执行文件');
return;
}
res.setHeader('content-type', 'application/json');
if (req.url?.includes('/ack')) {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const value = JSON.parse(body);
acked =
value.deviceKey === original.deviceKey &&
value.ok === true &&
value.result.includes('冒烟假执行器');
res.end(JSON.stringify({ success: true, data: {} }));
});
return;
}
secondCalls++;
res.end(
JSON.stringify({
success: true,
data: {
deviceId: 'local-smoke-device',
serverTime: new Date().toISOString(),
updateTargetVersion: null,
updateMode: 'idle',
update: null,
command: acked
? null
: {
id: `local-smoke-${app.getVersion()}-${Date.now()}`,
deviceId: 'local-smoke-device',
kind: 'lock',
payload: '仅假执行',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60000).toISOString(),
ackedAt: null,
result: '',
},
},
}),
);
});
await Promise.all([
new Promise<void>((r) => one.listen(0, '127.0.0.1', r)),
new Promise<void>((r) => two.listen(0, '127.0.0.1', r)),
]);
const url = (server: typeof one) => `http://127.0.0.1:${(server.address() as { port: number }).port}`;
try {
process.env.ELECTRON_SMOKE_CLIENT_AGENT = '1';
stopClientAgent();
saveConfig({ serverUrl: url(one), token: 'v14-old-fake-token' });
startClientAgent();
await beatNow();
await check(
'401 后明确进入退避状态',
() => firstCalls > 0 && getClientAgentStatus().lastError.includes('401'),
);
const begin = Date.now();
saveConfig({ serverUrl: url(two) });
await check(
'换服务端清除旧令牌与退避,设备身份保持不变',
() =>
getConfig().token === null &&
getConfig().deviceKey === original.deviceKey &&
!getClientAgentStatus().lastError,
);
saveConfig({ token: 'v14-local-fake-token' });
await beatNow();
await check('新服务端登录后30秒内自动心跳', async () => {
const deadline = Date.now() + 3000;
while (secondCalls === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 30));
return secondCalls > 0 && Date.now() - begin < 30000 && getClientAgentStatus().online && !invalidToken;
});
await check('收到远控立即假执行并以deviceKey回执(未调用系统命令)', async () => {
const deadline = Date.now() + 3000;
while (!acked && Date.now() < deadline) await new Promise((r) => setTimeout(r, 30));
return acked;
});
await check('安装包哈希失败会显示失败并拒绝待安装记录', async () => {
saveConfig({ autoUpdate: true });
const info = {
version: '99.0.0',
fileName: 'smoke-update.exe',
size: Buffer.byteLength('仅测试,不是可执行文件'),
sha256: 'bad',
notes: '仅本机假数据',
url: '/fake-installer',
};
syncUpdatePolicy({
deviceId: 'local-smoke-device',
serverTime: new Date().toISOString(),
command: null,
update: null,
updateTargetVersion: info.version,
updateMode: 'idle',
updateNotBefore: null,
});
await considerUpdate(info, info.version);
return (
getUpdateProgress().stage === 'failed' &&
getUpdateProgress().error.includes('sha256') &&
getConfig().pendingUpdateVersion !== info.version
);
});
await check('下载字节与校验完成显示等待安装(冒烟禁止运行安装器)', async () => {
const info = {
version: '99.0.1',
fileName: 'smoke-update.exe',
size: Buffer.byteLength('仅测试,不是可执行文件'),
sha256: createHash('sha256').update('仅测试,不是可执行文件').digest('hex'),
notes: '仅本机假数据',
url: '/fake-installer',
};
syncUpdatePolicy({
deviceId: 'local-smoke-device',
serverTime: new Date().toISOString(),
command: null,
update: null,
updateTargetVersion: info.version,
updateMode: 'idle',
updateNotBefore: null,
});
await considerUpdate(info, info.version);
const progress = getUpdateProgress();
return (
progress.stage === 'waiting' &&
progress.percent === 100 &&
progress.downloadedBytes === info.size &&
getConfig().pendingUpdateVersion === info.version
);
});
} finally {
stopClientAgent();
saveConfig(original);
if (previous === undefined) delete process.env.ELECTRON_SMOKE_CLIENT_AGENT;
else process.env.ELECTRON_SMOKE_CLIENT_AGENT = previous;
startClientAgent();
await Promise.all([
new Promise<void>((r) => one.close(() => r())),
new Promise<void>((r) => two.close(() => r())),
]);
}
}
@@ -0,0 +1,195 @@
/** 本地 HTTP 替身验证真实下载管线;只使用冒烟专属 userData,不连接真实服务端。 */
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { app } from 'electron';
import type { CoursewareDto } from '@classhelper/shared';
import { defaultIslandLayouts } from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { downloadCourseware, getCoursewareTransfer } from './courseware.js';
import { island } from './island.js';
export async function verifyCoursewareSmoke(
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
) {
if (process.env.ELECTRON_SMOKE_TEST !== '1' || !app.getPath('userData').includes('smoke'))
throw new Error('课件自检只允许使用冒烟临时配置');
const original = getConfig(),
bytes = Buffer.alloc(1024 * 1024, 83);
let corrupt = false,
downloads = 0,
slow = false;
const item: CoursewareDto = {
id: 'courseware-smoke',
name: '课件接收验证.pptx',
size: bytes.length,
category: 'presentation',
extension: 'pptx',
archiveDate: '2026-10-18',
sha256: createHash('sha256').update(bytes).digest('hex'),
createdAt: new Date().toISOString(),
teacher: { id: 'temporary-teacher', name: '临时课件教师' },
classes: [],
};
const server = http.createServer((req, res) => {
if (req.url?.endsWith('/download')) {
downloads++;
res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': bytes.length });
let offset = 0;
const timer = setInterval(
() => {
const chunk = bytes.subarray(offset, offset + 32768);
res.write(corrupt ? Buffer.alloc(chunk.length) : chunk);
offset += chunk.length;
if (offset >= bytes.length) {
clearInterval(timer);
res.end();
}
},
slow ? 100 : 12,
);
res.on('close', () => clearInterval(timer));
return;
}
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ success: true, data: item, message: 'ok' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as AddressInfo).port;
const wait = async (predicate: () => boolean | Promise<boolean>) => {
const end = Date.now() + 10000;
while (Date.now() < end) {
if (await predicate()) return true;
await new Promise((resolve) => setTimeout(resolve, 40));
}
return false;
};
async function localFiles(dir = path.join(app.getPath('userData'), 'courseware')): Promise<string[]> {
return (
await Promise.all(
(await fs.readdir(dir, { withFileTypes: true }).catch(() => [])).map((entry) =>
entry.isDirectory() ? localFiles(path.join(dir, entry.name)) : [path.join(dir, entry.name)],
),
)
).flat();
}
try {
saveConfig({
serverUrl: `http://127.0.0.1:${port}`,
username: 'courseware-smoke',
token: 'local-courseware-smoke',
islandLayout: defaultIslandLayouts(),
});
island.syncCustomLayout();
const job = downloadCourseware(item.id);
await check('课件后台下载去重且持续报告真实字节进度', async () => {
const same = job === downloadCourseware(item.id);
const progressed = await wait(() => {
const value = getCoursewareTransfer();
return !!value && value.progress > 0 && value.progress < 100;
});
return same && progressed && getCoursewareTransfer()?.teacherName === item.teacher.name;
});
await job;
await check('课件校验完成后保存正式文件并显示完成', async () => {
const files = await localFiles();
return (
getCoursewareTransfer()?.stage === 'completed' &&
getCoursewareTransfer()?.progress === 100 &&
files.length === 1 &&
(await fs.readFile(files[0]!)).equals(bytes)
);
});
await check('已完整接收的相同课件不重复传输', async () => {
await downloadCourseware(item.id);
return downloads === 1;
});
for (const layout of ['fixed', 'custom'] as const) {
await check(
`${layout === 'fixed' ? '固定岛' : '自定义常驻岛'}显示课件名称、类型、教师和进度`,
async () => {
saveConfig({ islandLayout: { ...defaultIslandLayouts(), layout } });
island.syncCustomLayout();
island.setCoursewareTransfer({
id: item.id,
name: item.name,
category: item.category,
teacherName: item.teacher.name,
receivedBytes: 512000,
totalBytes: item.size,
progress: 48,
stage: 'downloading',
});
return wait(
async () =>
!!(await island
.getWindow()!
.webContents.executeJavaScript(
`(() => {const card=document.querySelector('.courseware-transfer');return card?.textContent.includes('课件接收验证.pptx')&&card.textContent.includes('临时课件教师')&&card.textContent.includes('演示文稿')&&card.querySelector('progress')?.value===48;})()`,
)),
);
},
);
}
await check('紧急通知优先展示,关闭后恢复课件进度', async () => {
island.pushNotification({
id: 'courseware-urgent',
title: '紧急通知优先',
content: '课件接收继续进行',
priority: 'URGENT',
createdAt: new Date().toISOString(),
});
const urgent =
island.getState().mode === 'expanded' &&
!island.getState().coursewareTransfer &&
island.getCoursewareTransfer()?.progress === 48;
island.removeNotification('courseware-urgent');
return urgent && (await wait(() => island.getState().coursewareTransfer?.progress === 48));
});
island.setCoursewareTransfer(null);
item.id = 'corrupt-courseware';
corrupt = true;
await check('摘要不匹配时拒绝保存且清理临时文件', async () => {
let rejected = false;
try {
await downloadCourseware(item.id);
} catch {
rejected = true;
}
return (
rejected &&
getCoursewareTransfer()?.stage === 'failed' &&
!(await localFiles()).some((file) => file.includes(item.id))
);
});
corrupt = false;
slow = true;
item.id = 'cancel-courseware';
const cancelled = downloadCourseware(item.id).then(
() => false,
() => true,
);
await wait(() => getCoursewareTransfer()?.id === item.id && (getCoursewareTransfer()?.progress ?? 0) > 0);
saveConfig({ token: null });
await check(
'切换登录会话会中止接收并清理进度与临时文件',
async () =>
(await cancelled) &&
getCoursewareTransfer() === null &&
!(await localFiles()).some((file) => file.endsWith('.part')),
);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
saveConfig({
serverUrl: original.serverUrl,
username: original.username,
token: original.token,
islandLayout: original.islandLayout,
});
island.setCoursewareTransfer(null);
island.syncCustomLayout();
island.getWindow()?.hide();
}
}
@@ -0,0 +1,208 @@
import fs from 'node:fs';
import { BrowserWindow } from 'electron';
import path from 'node:path';
import { island } from './island.js';
import { getConfig, saveConfig } from './config.js';
import { defaultIslandLayouts, ISLAND_SHADOW_PAD } from '@classhelper/shared';
import { verifyResidentSmoke } from './smoke-resident.js';
export async function verifyLayoutSmoke(
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
) {
const original = getConfig().islandLayout;
await island.init(null);
island.setTestInputPassthrough(true);
const win = island.getWindow()!;
const editor = BrowserWindow.getAllWindows().find(
(w) => w !== win && !w.isDestroyed() && /\/index\.html(?:#|$)/.test(w.webContents.getURL()),
)!;
let editingBounds = win.getBounds();
const pause = () => new Promise<void>((r) => setTimeout(r, 400));
try {
await pause();
await check('默认 fixed 布局且尺寸上报不参与', async () => {
saveConfig({ islandLayout: defaultIslandLayouts() });
const before = win.getBounds();
island.reportCustomSize({ width: 900, height: 900 });
await pause();
const after = win.getBounds();
return (
before.width === after.width &&
before.height === after.height &&
getConfig().islandLayout.layout === 'fixed'
);
});
await check('自定义编辑模式可进入并添加组件行', async () => {
saveConfig({ islandLayout: { ...defaultIslandLayouts(), layout: 'custom' } });
island.syncCustomLayout();
await pause();
editingBounds = win.getBounds();
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(true)');
await pause();
return (
(await editor.webContents.executeJavaScript(
'location.hash.includes("/settings/island/editor") && Boolean(document.querySelector(".aside"))',
)) &&
editor.webContents.executeJavaScript(
`(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='新增行');b?.click();return Boolean(document.querySelector('.island-editor-page .el-card')) && !document.querySelector('.layout-editor, iframe'); })()`,
)
);
});
await check('触摸拖动可跨行并持久化', async () => {
await pause();
const dragGeometry = await editor.webContents.executeJavaScript(`(() => {
const handle=document.querySelector('.node-handle[data-kind=clock]');
const row=document.querySelectorAll('[data-row]')[1]; if(!handle||!row)throw Error('缺少拖动目标');
document.querySelector('.preview-canvas')?.scrollIntoView({block:'center'});
const r=row.getBoundingClientRect(); const x=r.left+Math.min(20,r.width/2),y=r.top+r.height/2;
handle.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:19,pointerType:'touch',clientX:10,clientY:10}));
handle.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:19,pointerType:'touch',clientX:x,clientY:y}));
handle.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:19,pointerType:'touch',clientX:x,clientY:y}));
return {row:r.toJSON(), point:{x,y}, hit:document.elementFromPoint(x,y)?.outerHTML.slice(0,600), canvas:document.querySelector('.preview-canvas')?.getBoundingClientRect().toJSON()};
})()`);
await pause();
const moved =
getConfig().islandLayout.schemes[0]?.rows[1]?.nodes.some((n) => n.kind === 'clock') === true;
if (!moved) throw new Error(JSON.stringify(dragGeometry));
return true;
});
await check('组件可以包裹进容器且设置独立保存', async () => {
await editor.webContents.executeJavaScript(
`document.querySelector('[data-action=wrap] .el-select__wrapper')?.click()`,
);
await pause();
await editor.webContents.executeJavaScript(
`(() => { const option=[...document.querySelectorAll('.el-select-dropdown__item')].find(e=>e.textContent.trim()==='分组'); if(!option)throw Error('容器菜单未出现');option.click(); })()`,
);
await pause();
return (
getConfig().islandLayout.schemes[0]?.rows.some((r) =>
r.nodes.some((n) => n.kind === 'group' && n.children.some((c) => c.kind === 'clock')),
) === true
);
});
await check('组件设置按内容外观高级切换,整体外观单独显示', async () => {
const initial = await editor.webContents.executeJavaScript(`(() => {
const panel=[...document.querySelectorAll('.component-settings .el-tab-pane')].find(e=>getComputedStyle(e).display!=='none');
return panel?.textContent.includes('排列方向') && !panel.textContent.includes('背景颜色');
})()`);
await editor.webContents.executeJavaScript(
`document.querySelectorAll('.component-settings .el-tabs__item')[1].click()`,
);
await pause();
const appearance = await editor.webContents.executeJavaScript(`(() => {
const panel=[...document.querySelectorAll('.component-settings .el-tab-pane')].find(e=>getComputedStyle(e).display!=='none');
return panel?.textContent.includes('正文字号') && !panel.textContent.includes('排列方向');
})()`);
await editor.webContents.executeJavaScript(
`document.querySelectorAll('.component-settings .el-tabs__item')[2].click()`,
);
await pause();
const advanced = await editor.webContents.executeJavaScript(`(() => {
const panel=[...document.querySelectorAll('.component-settings .el-tab-pane')].find(e=>getComputedStyle(e).display!=='none');
return panel?.textContent.includes('隐藏条件') && !panel.textContent.includes('背景颜色');
})()`);
await editor.webContents.executeJavaScript(
`document.querySelectorAll('.component-settings .el-tabs__item')[0].click()`,
);
await pause();
return initial && appearance && advanced;
});
await check('主窗口编辑器的预览完整适配画布且不遮挡缩放控件', async () => {
return editor.webContents.executeJavaScript(`(() => {
const canvas=document.querySelector('.preview-canvas')?.getBoundingClientRect();
const footer=document.querySelector('.preview-toolbar')?.getBoundingClientRect();
return Boolean(canvas&&footer&&canvas.bottom<=footer.top+1&&document.documentElement.scrollWidth<=innerWidth);
})()`);
});
await check('原生编辑页使用客户端主题并兼容深色', async () => {
return editor.webContents.executeJavaScript(`(() => {
const root=document.documentElement, wasDark=root.classList.contains('dark');
root.classList.add('dark');
const card=document.querySelector('.scheme-card'), style=getComputedStyle(card);
const ok=style.getPropertyValue('--ch-text').trim() && !document.querySelector('.layout-editor, iframe') && document.querySelectorAll('.island-editor-page .el-card').length>=4;
root.classList.toggle('dark',wasDark);return Boolean(ok);
})()`);
});
if (process.env.ISLAND_SHOTS_DIR) {
await editor.webContents.executeJavaScript(`document.querySelector('.main').scrollTop=0`);
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
fs.writeFileSync(
path.join(process.env.ISLAND_SHOTS_DIR, 'custom-editor.png'),
(await editor.webContents.capturePage()).toPNG(),
);
}
await check('退出编辑后配置可从 getConfig 读回', async () => {
await editor.webContents.executeJavaScript(
`[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='应用并返回')?.click()`,
);
await pause();
return (
!island.getEditingLayout() &&
getConfig().islandLayout.schemes[0]!.rows.length === 2 &&
!editor.webContents.getURL().includes('/editor') &&
win.getBounds().width === editingBounds.width
);
});
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(false)');
await check('自定义岛统一使用固定画布且常态组件居中适配', async () => {
await pause();
return win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.resident-card'), source=card?.querySelector('.resident-compact');
if(!card||!source)return false;
const r=card.getBoundingClientRect(),s=source.getBoundingClientRect();
return r.width<=innerWidth-${2 * ISLAND_SHADOW_PAD}&&r.height<=innerHeight-${2 * ISLAND_SHADOW_PAD}&&Math.abs(r.left+r.width/2-s.left-s.width/2)<=2;
})()`);
});
await check('自定义岛紧急通知立即弹出并复用经典通知内容', async () => {
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
island.pushNotification({
id: 'custom-smoke-alert',
title: '组件化提醒验收',
content: '通知、作业与叫人共用经典卡片',
priority: 'URGENT',
createdAt: new Date().toISOString(),
teacherName: '临时教师',
courseName: null,
});
await pause();
await pause();
const result = await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.resident-card'), content=card?.querySelector('.classic-notifications'),r=card?.getBoundingClientRect(),c=content?.getBoundingClientRect();
const valid=Boolean(content&&content.textContent.includes('组件化提醒验收')&&card.querySelectorAll('.shape').length===1&&c.left>=r.left-1&&c.right<=r.right+1&&c.bottom<=r.bottom+1);
return {valid,card:r?.toJSON(),content:c?.toJSON(),shape:card?.querySelectorAll('.shape').length};
})()`);
if (!result.valid) throw Error(JSON.stringify(result));
return island.getState().mode === 'expanded';
});
if (process.env.ISLAND_SHOTS_DIR)
fs.writeFileSync(
path.join(process.env.ISLAND_SHOTS_DIR, 'custom-alert.png'),
(await win.webContents.capturePage()).toPNG(),
);
await win.webContents.executeJavaScript(
`document.querySelector('#custom-alert-host .solid-btn')?.click()`,
);
await pause();
await pause();
await pause();
await check('提醒收回后缩回组件栏,保留时钟与可见状态', async () => {
const content = await win.webContents.executeJavaScript(
`Boolean(document.querySelector('.custom-card .clock'))`,
);
const visible = process.env.ELECTRON_SMOKE_MODE === 'visual' ? win.isVisible() : !win.isVisible();
return content && visible && island.getState().mode === 'hidden';
});
await check('没有提醒时消息组件不占位', async () => {
return win.webContents.executeJavaScript(
`document.querySelectorAll('.custom-card .component-node.notification, .custom-card .component-node.homework, .custom-card .component-node.call').length === 0`,
);
});
await verifyResidentSmoke(check);
} finally {
if (editor && !editor.isDestroyed())
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(false)');
saveConfig({ islandLayout: original });
island.syncCustomLayout();
win.hide();
}
}
@@ -0,0 +1,113 @@
import { verifyCoursewareSmoke } from './smoke-courseware.js';
import { verifyLayoutSmoke } from './smoke-layout.js';
import { verifyAgentSmoke } from './smoke-agent.js';
import fs from 'node:fs';
import path from 'node:path';
import { app, BrowserWindow } from 'electron';
import { getConfig } from './config.js';
/** 后台冒烟只检启动、桥接、缓存与窗口生命周期;屏幕像素和动画由显式视觉验收覆盖。 */
export async function runQuickSmoke(win: BrowserWindow): Promise<void> {
const startedAt = Date.now();
const results: Array<{ name: string; ok: boolean; detail: string; durationMs: number }> = [];
async function check(name: string, action: () => Promise<boolean | string> | boolean): Promise<void> {
const start = Date.now();
try {
const result = await action();
results.push({
name,
ok: result !== false,
detail: typeof result === 'string' ? result : '',
durationMs: Date.now() - start,
});
} catch (error) {
results.push({ name, ok: false, detail: String(error), durationMs: Date.now() - start });
}
const last = results.at(-1)!;
console.log(`[${last.ok ? 'PASS' : 'FAIL'}] ${name}(${last.durationMs}ms)${last.detail}`);
}
await check('后台启动并挂载界面', async () => {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
if (
await win.webContents.executeJavaScript(
`Boolean(window.__classhelperSmoke__ && document.querySelector('input, .el-menu-item'))`,
)
)
return true;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return false;
});
await check('所有验证窗口保持隐藏且不获取焦点', () =>
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
);
await check('预加载桥接白名单', async () =>
win.webContents.executeJavaScript(`Boolean(window.desktop?.getConfig && !window.desktop.ipcRenderer)`),
);
await check('渲染进程不能改写安装记录或设备身份', async () => {
const before = getConfig();
const after = await win.webContents.executeJavaScript(
`window.desktop.saveConfig({ pendingUpdateFile: 'C:/unexpected.exe', pendingUpdateVersion: '999.0.0', deviceKey: 'unexpected' })`,
);
return (
after.pendingUpdateFile === before.pendingUpdateFile &&
after.pendingUpdateVersion === before.pendingUpdateVersion &&
after.deviceKey === before.deviceKey
);
});
for (const [name, method] of [
['IndexedDB 缓存读写', 'cacheSelfTest'],
['课表变更和重连立即刷新,慢学期接口与迟到响应不阻塞', 'islandScheduleSyncSelfTest'],
['断网缓存回退', 'offlineScenario'],
['服务端与班级缓存隔离', 'cacheIsolationSelfTest'],
]) {
await check(name!, async () => {
const result = await win.webContents.executeJavaScript(`window.__classhelperSmoke__.${method}()`);
if (!result.ok) throw new Error(result.detail);
return result.detail;
});
}
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
await check('后台登录与数据、实时通道集成', async () => {
const credentials = {
code: process.env.ELECTRON_SMOKE_CLASS_CODE ?? '',
password: process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '',
serverUrl: (process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api').replace(/\/api\/?$/, ''),
};
const result = await win.webContents.executeJavaScript(
`window.__classhelperSmoke__.onlineScenario(${JSON.stringify(credentials)})`,
);
if (!result.ok) throw new Error(result.detail);
await win.webContents.executeJavaScript('window.__classhelperSmoke__.sessionCleanup()');
return result.detail;
});
}
await verifyAgentSmoke(check);
await verifyLayoutSmoke(check);
await verifyCoursewareSmoke(check);
await check('检查结束时没有可见或聚焦的验证窗口', () =>
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
);
await check('关闭窗口仍隐藏而不销毁', () => {
win.close();
return !win.isDestroyed() && !win.isVisible() && app.isReady();
});
const failed = results.filter((result) => !result.ok);
const result = {
passed: results.length - failed.length,
total: results.length,
ok: !failed.length,
mode: 'quick',
durationMs: Date.now() - startedAt,
results,
appVersion: app.getVersion(),
packaged: app.isPackaged,
};
if (process.env.ELECTRON_SMOKE_RESULT) {
fs.mkdirSync(path.dirname(process.env.ELECTRON_SMOKE_RESULT), { recursive: true });
fs.writeFileSync(process.env.ELECTRON_SMOKE_RESULT, JSON.stringify(result, null, 2));
}
console.log(`[SMOKE] 后台快速检查 ${result.passed}/${result.total} 项通过,${result.durationMs}ms`);
app.exit(failed.length ? 1 : 0);
}
Loaded 100 of 300 files, more files were not shown because too many files have changed in this diff. Show more