14 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
219 changed files with 15430 additions and 8445 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/
+108 -1685
View File
File diff suppressed because it is too large. Load diff
+11 -10
View File
@@ -20,7 +20,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。<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>
@@ -43,15 +43,14 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
- [x] 仪表盘:本班人数、课程、课表、作业、通知、成绩一屏概览
- [x] 班级管理:建班自动生成班级码与班级密码,随时更换班主任、按科目配置各科任课老师
- [x] 学生 / 教师管理:单个录入或导入名单(xlsx / xls / csv / tsv)
- [x] 课表管理:按周排课、单双周,可直接从 ClassIsland 档案导入课表与节次时间
- [x] 课表管理:日课表、共享作息、1–9 周轮换、群组与临时换课;支持完整 ClassIsland 档案离线导入
- [x] 作业发布:按「所属日期」归类,支持附件与未交名单
- [x] 通知发布:三档优先级;在**上课时段**发紧急通知会被拦下来并要求二次确认
- [x] 通知发布:四档优先级;在**上课时段**发紧急通知会被拦下来并要求二次确认
- [x] 叫人:点名某位同学到办公室,分「普通」与「紧急」两级
- [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计;单科排名自动出,可选「各科相加排总分榜」
- [x] 客户端管理:查看设备在线状态与版本,远控关机、重启、注销与锁屏;推送更新可选立即、放学后或空闲时安装
- [x] 数据库管理:连接体检、备份与恢复、SQLite ⇄ MySQL 一键切换
- [x] 权限组:37 个权限键,建组分配账号 —— 默认组可自定义,没分组的按角色默认组生效,分组后只按组的并集算
- [x] 客户端设备:每台教室机器的版本、在线状态与最后心跳;一键推送更新,可下发关机 / 重启 / 注销 / 锁屏
- [x] 权限组:按功能键建组分配账号,支持岗位与年级范围 —— 默认组可自定义,没分组的按角色默认组生效,分组后只按组的并集算
- [x] 大模型助手:一句话解析成待确认的作业 / 通知 / 叫人,点确认才落库
- [x] 通知与作业批量发布:勾多个班一次发完,个别班失败不影响其余班级
- [x] 系统设置:开发者模式、运行诊断、开机自启开关(仅安装形态)
@@ -64,7 +63,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
- [x] 课表:「今天」时间轴(大时钟 + 正在上 / 下一节 + 距下课倒计时)与周视图
- [x] 作业看板:按科目分卡、可全屏铺满、字号可调,也能切回列表
- [x] 通知中心:未读红点、优先级标签、点击自动标为已读
- [x] 成绩总览:全班成绩表格 + 柱状图 + 等级分布;按考试看单科排名,老师开启后还有总分排名
- [x] 成绩总览:开启个人查询时显示本班成绩与排名;关闭后仅展示服务端集体分析,保留隐私限制
- [x] 教室机器可直接录入作业、勾选未交名单
- [x] 离线优先:断网回退 IndexedDB 缓存,恢复连接后自动同步
- [x] 断网期间错过的通知,重新上线时补弹一次;渲染进程崩了会安静地自己重建窗口
@@ -72,12 +71,13 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
### 灵动岛(桌面通知浮窗)
- [x] 通知、新作业、叫人都会上岛:先是胶囊,点开是详情卡
- [x] 通知、新作业、叫人都会上岛:经典模式使用胶囊与详情卡,自定义模式使用常驻内容与铃铛
- [x] 多条消息展开为竖排列表,按重要程度排序,放不下时提示去应用内看
- [x] **上课时段不打扰**:普通通知暂存到下课再弹;紧急通知与叫人立刻弹
- [x] **经典模式上课时段不打扰**:普通通知暂存,普通叫人可点开胶囊,紧急消息立即展开;自定义模式点击铃铛查看
- [x] 尺寸、圆角、字号、主题色、停靠位置(顶部 / 底部 × 左 / 中 / 右)都可以调
- [x] 纯黑 / 毛玻璃 / 主题色渐变三种底
- [x] 触摸屏(希沃白板这类一体机)也能点开
- [x] 自定义常驻岛:分别编辑常态与无通知展开内容,收到消息显示铃铛,点击查看通知
### 部署与运维
@@ -211,7 +211,7 @@ pnpm dev # 后端 4000 + Web 端 5
| ClassHelper 班级端(班级) | 班级码 `G101` | `123456` |
- [AGENTS.md](AGENTS.md) —— 工程约定与开发环境注意事项,**改代码前先读它**
- [docs/reference.md](docs/reference.md) —— 完整参考(功能行为、接口、验收记录)
- [docs/reference.md](docs/reference.md) —— 功能章节与源码入口索引
- [docs/dev/architecture.md](docs/dev/architecture.md) —— 架构总览
## 致谢
@@ -228,6 +228,7 @@ pnpm dev # 后端 4000 + Web 端 5
### 客户端维护
支持远程关机、重启、注销与锁屏(管理端和本机分别确认),可查看最近一次执行结果。
支持远程关机、重启、注销与锁屏(管理端确认下发,客户端执行并回执),可查看最近一次执行结果。
统一更新支持立即安装、放学后安装或空闲时安装,支持取消下发。
管理员与教师的默认权限组均可自定义;管理员保留权限管理和系统管理入口。
课件共享:教师可上传教学文件到服务端,按日期与类型归档,仅向本人授课班级开放或推送;班级客户端支持课件目录、离线补收,以及灵动岛传输进度。使用说明见 [课件上传与传输](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.3}
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.3}
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:
+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.3",
"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 -47
View File
@@ -1,56 +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` 定向到被叫学生 / 班级设备。
叫人通过实时通道送达本班客户端与灵动岛。
`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,事件载荷同样须遵守个人数据保护,客户端按需重新读取。
+2 -2
View File
@@ -84,8 +84,8 @@ Web 端作业详情抽屉里同样可看可改。
发布 / 修改 / 删除作业会分别广播:
| 事件 | 客户端反应 |
| ------------------ | ------------------------------------- |
| `homework:new` | 作业列表刷新 + 灵动岛弹「新作业」胶囊 |
| ------------------ | --------------------------------------------------- |
| `homework:new` | 列表刷新;经典岛显示胶囊/课堂暂存,自定义岛提示铃铛 |
| `homework:updated` | 刷新;带 `deleted: true` 表示删除 |
| `homework:status` | 完成状态变化时刷新统计 |
+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)。
+9 -8
View File
@@ -9,10 +9,11 @@ description: ClassHelper每一项功能怎么用:课表、作业、通知、
很多"看起来像故障"的现象其实是设计行为,下面每一页都会点出来。
| 页面 | 讲什么 |
| --------------------------------- | ------------------------------------------------------ |
| --------------------------------- | ---------------------------------------------------------- |
| [课表](schedules.md) | 周视图与「今天」时间轴、单双周、学期周数、课表来源 |
| [课件](courseware.md) | 上传、班级可见范围、推送与逐设备接收 |
| [作业](homeworks.md) | 按所属日期归类、看板与全屏看板、教室机器录入、未交名单 |
| [通知与提醒](notifications.md) | 三档优先级、上课时段策略、二次确认、已读与红点 |
| [通知与提醒](notifications.md) | 四档优先级与两种岛模式、上课时段策略、二次确认、已读与红点 |
| [叫人](calls.md) | 普通叫人与紧急叫人的差别、快捷短语、谁能叫谁 |
| [成绩](grades.md) | 录入方式、导入、等级分布、学生看到的是什么 |
| [班级、学生与教师](classes.md) | 班级账号、名单、班主任与各科任课老师、谁看得见什么 |
@@ -22,16 +23,16 @@ description: ClassHelper每一项功能怎么用:课表、作业、通知、
## 贯穿所有功能的三条规则
**一、角色决定可见范围。** 同一个页面,管理员、班主任、科任老师看到的数据与能点的按钮都不一样。
**一、权限与范围共同决定可见内容。** 同一个页面,管理员、班主任、科任老师看到的数据与能点的按钮都不一样。
前端会按权限隐藏入口,但**真正拦人的是服务端**——直接调接口一样会被拒。完整矩阵在
[角色与权限](../dev/permissions.md)。
**二、上课时段不打扰课堂。** 「上课时段」由课表实时判定,普通通知与普通叫人只进队列、下课补弹;
紧急通知与紧急叫人才会立刻打断。客户端与灵动岛共用这套规则。
**二、课堂提醒遵循本机布局规则。** 经典模式普通通知暂存到下课,普通叫人保留可点胶囊;
紧急通知与紧急叫人立即展开;自定义常驻模式先显示铃铛,由用户主动查看。
细节见[通知与提醒](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 -58
View File
@@ -1,76 +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)的接口规则。
## 紧急通知的二次确认
## 已读与撤回
上课时段发紧急通知,**服务端会拦一次**(`409 URGENT_DURING_CLASS`):
客户端通知页有未读数,打开详情可标已读;班级会话的已读操作代表全班在读学生。
删除需相应员工权限和范围,实时撤回会清理客户端列表、缓存与岛队列。
同 id 的通知与叫人双播合并,不重复计数。
```
POST /api/notifications { classId, title, content, priority: "URGENT" }
→ 409 URGENT_DURING_CLASS(响应体里带着正在上的那节课)
## 接口入口
POST /api/notifications { …, priority: "URGENT", confirmDuringClass: true }
→ 201 发出去
```
以下省略 /api,均先鉴权;具体功能键及范围见[REST API](../dev/api.md)。
Web 端在提交前会先查一次上课状态,命中就弹一个**全屏二次确认**:全屏遮罩 + 强警示配色,
文案是"现在为上课时间段……" + 正在上的课时段 + 待发布的标题。确认按钮带 **3 秒倒计时**
(圆环进度),倒计时结束前点不动,防误触。
| 方法与路径 | 用途 |
| ----------------------------------------------------- | --------------------------------------------------------------------- |
| 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 | 撤回 |
**这个拦截在服务端,不是前端提醒。** 绕过界面直接调接口一样会被拦 —— 这是产品行为,不是可以关掉的提示。
## 提醒显示
通知通过实时通道发送到 ClassHelper 客户端与灵动岛。设置页显示本机提醒规则,旧的跨应用通知渠道已移除。
## 已读、红点与删除
| 能力 | 说明 |
| -------- | ------------------------------------------------------------------------------ |
| 未读红点 | 客户端侧边栏「通知」项右上角的徽标;数字来自 `GET /notifications/unread-count` |
| 标为已读 | 点开通知自动标为已读;也有「全部已读」 |
| 班级设备 | 教室机器的"已读"是**代全班**操作的(它用的是班级账号) |
| 删除 | 只有教师 / 管理员能删 |
## 发布入口只有一个实现
「通知发布」与叫人复用同一套服务端发布流程和实时广播。
## 接口
| 方法 | 路径 | 说明 |
| ------ | -------------------------------------------------------- | ------------------------------- |
| 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 -55
View File
@@ -1,70 +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. 服务端发布接口:上课时段发紧急通知会被拦(`409 URGENT_DURING_CLASS`),确认后放行;
3. Web 端:提交紧急通知前先查一次状态,命中就弹全屏二次确认。
## 课表来源
课表由管理端维护,支持手工录入与离线 JSON 导入。升级会保留旧联动课表并将其来源归为 `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 -116
View File
@@ -1,129 +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>`;
- 请求体校验走 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 / 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 | `/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` | **管理员** | **一键切换数据库**(异步任务)/ 轮询进度 |
| 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)。
客户端心跳可携带 `agentProtocol: 2`,业务数据读取统一响应的 `data`。未带此标记时额外返回旧版需要的顶层字段;放学后更新在到时前不向旧客户端提供安装包。推送更新支持 `mode: immediate | after-school | idle`,`afterSchoolTime` 为服务端本地时区的 `HH:mm`。
探针 /healthz 和 /readyz 不带 API 前缀;/api/health 提供版本与模块详情。
课件上传是原始文件流,512 MB 限制独立于表格 JSON 的 12 MB 限制;metadata 走上传查询 schema。
心跳 agentProtocol:2 使用统一响应 data,旧协议兼容字段只用于升级过渡。
更多说明见[权限](permissions.md)、[实时事件](realtime.md)、[数据模型](data-model.md)。
+46 -145
View File
@@ -1,165 +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 |
| 打包 | 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),页面不展示
│ ├── 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 / 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(模块注册表)
│ ├── 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(当前 20 个模块)
export const apiModules: ApiModule[] = [
authModule,
aiModule, // 大模型助手(只解析不落库,见 docs/dev/api.md)
classesModule,
coursesModule,
schedulesModule,
homeworksModule,
notificationsModule,
callsModule,
importsModule,
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)。
+59 -118
View File
@@ -1,133 +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`、`studentGradeQueryEnabled`、`archivedYearId` |
| `TermWeek` | 学期逐周日期区间 | `classId`(**空串 = 全校默认**)、`weekNumber`、`startDate` / `endDate` |
| `Course` | 课程 = **「班级 + 科目 + 教师」任课关系** | `teacherId` 就是该班该科的任课老师;`@@unique([classId, name])` |
| `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual) |
| `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` |
| `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`(**客户端在线判定看 `ClientDevice.lastHeartbeatAt`**:每 30 秒心跳,90 秒内有成功上报即在线。 |
| 更新目标、安装策略与最早安装时间持久化在设备记录中,离线设备下次上线继续领取。 |
## 修改模型时
**`Schedule.source`** —— 联动退役后历史同步课表统一归为 `manual`,保留排课数据。
**`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` 四项);
再回到 `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)。
+5 -6
View File
@@ -34,19 +34,18 @@ ClassHelper是一个 **pnpm monorepo**,一份代码产出四个交付物:
| 页面 | 内容 |
| -------------------------------------------- | ------------------------------------------------ |
| [架构总览](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()` 弹簧、深色主题 |
| [灵动岛设计令牌](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)** ——
工程约定与开发环境注意事项,**改代码前必读**。里面每一条硬约定都对应一处容易踩空的地方,
比代码注释更值得先看一遍。
规则与实际实现冲突时应查源码并同步修正。
+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 -164
View File
@@ -1,183 +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 |
下表描述角色与班级关系允许的范围;员工还必须拥有对应功能权限,管理员也不例外。
名单读取使用 `roster:read` / `teacher:read`,管理使用独立的 `roster:manage` / `teacher:manage`。
日志与客户端开发者模式不再单列无效权限键,服务端系统设置由 `system:manage` 控制。
完整键目录读取 `PERMISSION_KEYS`,不在文档维护固定键数。
| 操作 | 管理员 | 班主任 | 科任老师 | 班级端 |
| ----------------------------------------------------------- | :----: | :----------: | :------: | :--------: |
| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | ❌ |
| 分配班主任与各科任课老师 | ✅ | ❌ | ❌ | ❌ |
| 学生名单管理(增删 / 导入 / 调班 / 转出) | ✅ | ✅(仅本班) | ❌ | ❌ |
| 教师录入(新建 / 导入名单 / 修改密码) | ✅ | ❌ | ❌ | ❌ |
| 毕业归档 / 学年升级 / 学期周次 | ✅ | ❌ | ❌ | ❌ |
| 数据库管理(备份 / 导入导出 / 一键切换) | ✅ | ❌ | ❌ | ❌ |
| 课表管理(增删改 / 时间配置导入) | ✅ | ✅(仅本班) | ❌ | ❌ |
| 布置作业(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ✅ |
| 叫人 / 发布通知 | ✅ | ✅ | ✅ | ✅ |
| 成绩录入 / 修改 / 导入(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ❌ |
| 查看本班成绩汇总 | ✅ | ✅ | ✅ | ✅ |
| 按学号查询学生成绩明细 | ✅ | ✅ | ✅ | 需班级开关 |
| 查看 ClassHelper 班级端在线状态 | ✅ | ✅ | ✅ | ❌ |
| 班级密码 / 班级码维护(`PATCH /classes/:id/class-account`) | ✅ | ❌ | ❌ | ❌ |
## 前端门控与成绩隐私
## 科目维度的收口(本次重构的核心规则)
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`(37 个,形如 `domain:action`,
中文名在 `PERMISSION_LABELS`、分组在 `PERMISSION_CATEGORIES`)。路由上挂的是
```ts
requirePermission('notification:publish'); // 需要全部列出的键
requireAnyPermission('grade:write', 'grade:import'); // 有任意一个即可
```
**生效规则(四句话)**:
| 账号 | 有效权限 |
| ------------------------ | ----------------------------------------------------------------------- |
| `ADMIN` | 按权限组计算;其中 `permission:manage` / `system:manage` **不可被剥夺** |
| `CLASS_DEVICE`(班级端) | 空集,**不受任何分组影响**(它的能力来自班级维度断言) |
| 被分配了权限分组 | 分组键的**并集**(可以比角色默认更少 —— 这就是"能收权") |
| 没有被分配任何分组 | 使用可编辑的角色默认权限组 |
默认模板逐条镜像重构前的 `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` | 成员列表与「整组替换」式分配;管理员与教师可分配;班级端不参与 |
**一条不能松的边界**:权限组只管**功能/路由级**准入;**班级维度的科目规则不可被权限组覆盖**。
换句话说,给某人授了 `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)`(管理员同样按生效集合判断,仅保留权限和系统管理入口),不通过就 **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` 表里压根没有那一列。
## 默认权限组与客户端维护(2026-10-16)
管理员和教师各有一个默认权限组;未分配其他组时使用对应默认组,编辑后立即生效。
默认组可以改名、增减权限,但不能删除。分配多个组时取并集。
管理员始终保留 `permission:manage` 和 `system:manage`,防止失去管理入口。
班级端不参与账号权限组,班级与科目范围的检查保持生效。
「客户端」页统一显示设备心跳、版本、是否接受自动更新、最近指令结果和安装时机。
更新先从本服务器下载并校验 SHA256,再按所选时机安装:
- 立即更新:下载完成后安装并重启客户端,会中断当前客户端界面。
- 放学后更新:按服务器当天日期,等待所选最早时间与本班最后一节课结束时间的较晚者;上课中继续等待。
- 空闲时更新:不上课且主窗口隐藏满五分钟后安装。
离线设备上线后补领更新;取消下发后,已下载的安装包不再自动安装。
客户端关闭自动更新时,任何下发策略都不会强制安装。
远控关机、重启、注销和锁屏仍需管理端确认及本机确认,取消或系统执行失败都会回执。
联动插件及相关接口已下线;升级迁移会删除旧联动设备和推送记录,保留业务课表、通知及离线 JSON 导入。
服务端兼容旧客户端的心跳字段读取,旧设备可领取修复版;第一次升级仍遵循旧版空闲安装条件,新策略需修复版客户端。
`/api/clients/heartbeat` 和命令回执供已登录设备使用,不依赖班级端的空权限集合;服务端校验 deviceKey 与班级归属。
远控从管理端确认下发,客户端执行后回传真实结果。更新文件由服务端缓存,教室机验 SHA256,
按立即、放学后、空闲策略安装;关闭自动更新不会强制安装。详见[更新与升级](../management/upgrade.md)。
+35 -65
View File
@@ -1,79 +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}` / 班级设备) |
| `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 -211
View File
@@ -1,235 +1,81 @@
---
title: 版本迭代与发布
description: 改版本号、跑验收、打安装包、发 GitHub Release、发布文档站——一次完整发布的命令清单。
description: 版本单一来源、本地与发布文件名、验证和发布同步。
---
# 版本迭代与发布
## 一条总原则
日常文档/代码维护不自动改版本。版本唯一修改入口是 scripts/version.mjs;先执行 pnpm version:check。
正式新发布执行 pnpm version:bump patch|minor|major,指定固定版本的本地重构建保持版本不变。
版本落点由脚本清单决定,不另维护“文件数/落点数”。
> **版本号按「发布」迭代,不按「提交」迭代。**
日常的 `feat` / `fix` 提交**不动版本号**。只有当你要产出一批安装包、在 GitHub 上开一个 Release 时,
更新检查接口全都按版本号对齐;每次提交都动它,等于每天都在发版,用户那边的「检查更新」就废了。
| 算一次发布 | 不算 |
| --------------------------------------- | ---------------------------- |
| 攒够一批功能 / 修好一个用户能感知的 bug | 改注释、改文档、重构、加测试 |
| 要给别人一份新安装包 | 只在自己机器上跑 |
## 每次发版必须一起交付的四样东西
打安装包只是其中一件。**少一样都算「发到一半」**:
| # | 交付物 | 落在哪 | 怎么做 |
| --- | ------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| ① | **更新日志** | GitHub Release 的正文 | 手写,草稿放 `releases/ReleaseNotes-<版本>.md`,用 `--notes-file` 发布。别只挂自动生成的列表 |
| ② | **Linux 服务端包** | Release 上的 `classhelper-server-linux-x64-<版本>.tar.gz` | tag 推上去由 CI 构建;**推完去 Actions 确认没红**(见第 7 步) |
| ③ | **文档** | `docs/reference.md`(功能说明 + 验收对照表)与相关章节 | 新增功能补对应小节;改交付形态一并改第 2 章 / 第 7 章 |
| ④ | **官网与 README** | `website/index.html`、`website/download.html`、`README.md` | 新功能补进官网功能块与 README 功能清单;版本号由脚本覆盖 |
更新日志的骨架(照 1.2.0 / 1.3.0 那两份写):
**一句话定位 → 分节讲功能 → ⚠️ 升级须知(含会自动应用的迁移)→ 验收表 → 产物表**。
验收表里**只写真跑过的数字**,没跑就直说没跑,不要照抄上一版的。
## 版本号写在哪(12 处 / 12 个文件)
同一个版本号分散在 12 个落点上,**手工改必然漏一处**,而漏掉的那处不会报错:
| 文件 | 内容 |
| ---------------------------------------------------------------- | ---------------------------------- |
| `package.json` | 单一来源(服务端运行时读它) |
| `packages/{shared,server,web-admin,desktop-client}/package.json` | 各包的版本 |
| `deploy/package.runtime.json` | 安装包 / 容器里的运行时依赖清单 |
| `deploy/docker-compose.yml`、`docker-compose.sqlite.yml` | 镜像 tag 的默认值 |
| `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 # 再确认一次
```
脚本按表逐个改写,**任何一处没匹配到或匹配到多处就直接失败退出**,不做"尽力而为"的部分改写。
## 一次完整的发布
下面这一串命令就是全部流程,逐条照抄即可。
### 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 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
# <版本> 和提交均替换为本次实际值
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"
gh release create v$V --title "ClassHelper v$V(<一句话定位>)" --notes-file "releases/ReleaseNotes-$V.md"
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
```
> **CI 是按 tag 的 ref 检出源码的。** 发完版又发现构建脚本(或任何影响出包的代码)要修时,
> **光推 master 不够** —— CI 仍会拿旧 tag 的源码跑,得把 tag 挪到修复后的提交:
> `git tag -f v<版本> <提交> && git push --force origin v<版本>`,tag 一变会自动重跑。
> 别拿「Windows 侧绿不绿」当判据:`pnpm dist:server` 走的是另一条分支,
> Linux 那一步红的时候本地完全看不出来。
>
> 另外,本页第 8 步的「官网与文档站」是**另一条**流水线(master 推送触发)——
> 它绿不代表 Linux 包出来了,推完 tag 要去 `gh run list --limit 5` 看一眼。
### 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` 一起改的**(它们是那 12 个落点里的四个)。
> 官网的「立即下载」会跳到 `/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
View File
@@ -1,50 +0,0 @@
---
title: 2026-10-17 审查修复记录
description: 按审查报告核对缺陷、修复行为与验收方式。
---
# 2026-10-17 审查修复记录
本次按仓库根的代码审查报告逐项核对,保留此前联动退役与更新策略改动。
| 报告条目 | 处理结果 |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0.1 | 环境文档补充 PATH 与 Git Bash 启动方式;变更文档统一格式化。 |
| 1.1 | 恢复学期周次、毕业归档、权限组菜单,开发期检查路由是否遗漏菜单。 |
| 1.2 | 叫人通知写本地所属日期;启动时分批幂等回填旧通知,兼容 SQLite / MySQL。 |
| 1.3、2.14、3.10、3.14、4.5 | 每次建窗挂关闭隐藏处理;捕获加载失败;仅主窗口触发恢复,恢复期间等待异步加载;普通 Promise 拒绝不再重启进程,移除 activate 重复加载。 |
| 1.4、1.5 | 缓存按服务端和班级隔离,个人资料按服务端和登录名隔离;忽略跨会话的迟到响应,打开失败和版本切换后可重试 IndexedDB。旧无命名空间缓存不再直接作为新会话数据读取。 |
| 1.6 | 学期周统一加载后再取灵动岛课表,教学周变化触发重取;失败且无缓存时保留已知课表;主窗口禁用后台节流。 |
| 1.7、3.11 | IPC 拒绝渲染端改安装记录;安装只接受更新专用目录中的文件,拒绝目录外及软链接目标;取消、更换目标和更新完成后清理旧包。 |
| 2.1 | 空角色原本已保留旧值;现在未识别的非空值也保留原角色,只有明确的教师/管理员值才变更。 |
| 2.2、2.9 | 员工的成绩明细、看板、更新检查与强制检查落实权限;班级端仍由本班边界授权。增加学生/教师名单只读权限,旧管理组继承对应读取能力;下线没有独立服务端能力的日志与开发者模式权限键,系统设置仍由系统管理权限控制。 |
| 2.3 | 当前班级范围已由 CLASS_DEVICE 角色正确解析,不存在报告描述的必然拒绝,也不会查询全班学生。补齐 Socket 身份中的 classSession,保持元数据完整。 |
| 2.4、2.5 | 课表导入读取本班学期长度,创建课表按实际兜底周数做区间交叉校验。 |
| 2.6 | 姓名重复时要求正确学号,不能静默绑定到同名学生。 |
| 2.7 | 增加共享删除事件与两端订阅,并移除通知中心及灵动岛中的已删通知。 |
| 2.8 | 批量录入接口本身只接收单个 classId;真正缺口在跨班批量改等级,现已逐班广播。 |
| 2.10 | 调班历史按学生当前班级及显式班级过滤条件校验权限;未分班学生仅管理员可读。 |
| 2.11—2.13 | 引导使用主题文字变量,仅可见时响应 Esc;成绩明细优先展示保存的等级。 |
| 2.15—2.17 | 助手叫人校验非空消息;周次草稿提交前校验日期;通知日期改变后清除不再可选的发布人。发布人选择本身已有 change 处理,未重复添加。 |
| 3.1、3.4、3.9 | 列表丢弃过期响应;考试口径读写固定同一个班级/考试快照并禁用加载中的开关;切班立即清空课程候选。 |
| 3.2 | 提交函数增加互斥与 finally 释放,按钮同步显示 loading,快速双击不会重复发请求。 |
| 3.3 | 考试下拉从独立考试列表生成,不再由筛选后的成绩反推。 |
| 3.5—3.8 | 不回退到已归档届别;使用主题语义色、清理死样式;拒绝协议相对和反斜线附件地址;人数加载失败显示不可用。 |
| 3.12、3.13 | 取消确认框正常返回;列表加载与实时新增、删除、已读合并,防止快照抹掉飞行中的变更。 |
| 3.15 | 核对并修正登录、班级开关、设备权限、菜单数量、Socket 房间注释。登录限流当前已经一次挂载到两个入口并共享计数器,该条报告不成立。 |
| 4.1 | 百分制/字母制批量重算使用分数计算等级;自定义等级没有通用换算规则,禁止整批猜算,保留逐条编辑。 |
| 4.2 | 审计明确记录解析结果 interpreted / unrecognized,不再写成永远等待执行的 pending;它不宣称前端业务发布已成功。 |
| 4.3、4.4 | 助手上下文与预览参数均校验班级、科目范围;缺失通知优先级按普通通知展示。 |
| 4.6 | 外观 IPC 在一帧内合并滑块事件,读取时先刷新最后一帧,避免丢失最终值。 |
| 5 | 清理无调用的名单/课程封装和事件标签等;班级页接通既有任课关系导入。旧时间配置弹窗仍直接调用四个 API,按报告的保留要求保留组件及依赖,不盲目删除。 |
管理员的 Web 权限判断已移除全权限短路,与服务端保持一致;默认组收回的功能入口会隐藏。
日常冒烟采用后台快速模式,视觉验收显式执行;具体命令和验证范围见[验收与测试](testing.md)。
## 本次验证
- 全仓类型检查、ESLint、构建、变更格式检查通过,文档构建无警告。
- 隔离测试库端到端 243/243;审查接口回归 31/31;客户端维护接口 28/28。
- 无窗口逻辑回归 18/18;模拟远控及更新代理 15/15;旧库升级回归通过。
- 后台 Web 回归 37/37,约 38 秒;客户端联网冒烟 10/10,二次启动 9/9,总计约 15 秒。
- 本次使用独立 SQLite 测试库,没有执行真实关机或安装;未运行会占用桌面的完整视觉验收。
+50 -108
View File
@@ -1,127 +1,69 @@
---
title: 验收与测试
description: 验收验收脚本怎么跑、覆盖什么、哪些用例是脆弱的,以及怎么判断失败是不是真回归。
description: 隔离 API、隐藏 UI、后台客户端与显式视觉验收。
---
# 验收与测试
验收分为后台快速冒烟、隔离的接口与逻辑回归、显式视觉验收。日常默认不显示窗口、不占用前台;
涉及动画、桌面像素和灵动岛几何时,再单独运行视觉验收。
按改动范围选择验证;数量和结果读取本次输出,不沿用历史报告数字。
默认客户端冒烟为后台快速模式;动画、透明度、桌面截图必须显式视觉模式。
## 验收脚本
## 命令与前置条件
| 命令 | 覆盖 | 需要什么 |
| ---------------------- | -------------------------------------------------------------------------------- | --------------------------- |
| `pnpm verify:e2e` | 后端 + 实时推送 + RBAC 越权 + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查 | **后端已启动** |
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动) | 后端已启动 + Web 产物已构建 |
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) | 已构建的客户端产物 |
| `pnpm verify:packaged` | 对**打包后 / 已安装**的客户端 EXE 复跑同一套冒烟(`--exe` 指定路径) | 打包产物 |
| 命令 | 覆盖/前置条件 |
| ------------------------------------------------ | ----------------------------------------------------------------- |
| 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 verify:client-agent`:模拟操作系统验证远控、回执重试与安装策略,不执行真实关机或安装器。
- `pnpm verify:client-migrations`:创建临时旧库验证升级、联动移除与默认组编辑持久化。
- `pnpm verify:clients-api`:面向 `http://127.0.0.1:4199` 的演示测试库(可用 `VERIFY_BASE_URL` 指定),验证权限、远控与更新接口。会创建并清理测试记录,请使用隔离测试服务。
- `pnpm verify:review-client`:无窗口验证缓存故障恢复、通知竞态、教学周同步、管理员界面权限与崩溃恢复。
- `pnpm verify:review-api`:针对审查缺陷验证接口权限、导入边界和实时删除等,必须连接隔离测试服务。
另外三套是"门禁"而不是验收:`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 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:visual`「真实通知链路」「灵动岛标为已读」等 3 项 | 要求**当前不在上课时段**。种子课表恰有课正在进行时,客户端会**正确地**把普通通知暂存到下课 —— 这三项就会红(日志里会写 `客户端判定上课中=true`) |
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把周一第 1 节限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`.env` 的 `TERM_START_DATE` 对齐即可全绿 |
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里就没有可自动建课的「统一科目」分组 |
| `verify:desktop:visual`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤 |
| `verify:desktop:visual`「作业看板全屏自适应」 | 演示数据某天有 6 条长作业、而冒烟窗口只有约 543px 高时,缩放被下限 0.45 钳住 → 必然溢出 |
| 岛交互 / 岛动画 / 岛截图留档 | 窗口被遮挡或跑在远程会话里时会间歇性失败:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** |
## 截图
## 大面积报「登录失败 / 401」时
先检查隔离服务是否启动,以及测试管理员的当前口令。不要修改真实班级密码来迁就测试。
联网客户端和 Web 回归会自行创建临时教师与班级;下面的环境变量只配置测试管理员:
| 脚本 | 管理员凭据 |
| ------------------- | -------------------------------------------------------- |
| 客户端联网/打包冒烟 | `ELECTRON_SMOKE_ADMIN` / `ELECTRON_SMOKE_ADMIN_PASSWORD` |
| Web 回归 | `UI_SMOKE_ADMIN` / `UI_SMOKE_ADMIN_PASS` |
| 后端端到端回归 | 使用隔离种子库,口令由种子脚本与验收脚本约定 |
默认离线快速冒烟不需要服务端凭据。
## 默认后台运行,视觉验收单独执行
`pnpm verify:desktop` 默认运行隐藏、不可聚焦的离屏窗口,仅检查启动、桥接白名单、缓存读写、
跨服务端/跨班级缓存隔离和关闭隐藏。二次启动也只跑快速检查,不再重复整套动画。
需要联网时运行 `pnpm verify:desktop:online`,创建临时教师与临时班级,结束后删除。
不再自动探测并复用真实班级,也不会重置真实班级密码。Web 回归同样使用隔离的验证教师与班级。
`pnpm verify:desktop:visual`(或客户端启动器 `--visual`)才运行灵动岛动画、透明度、桌面截图等完整验收,
这类用例确实需要使用桌面。`verify:packaged` 也默认快速模式,显式传 `--visual` 才跑视觉验收。
视觉验收不适合在用户操作屏幕时自动运行。截图采集命令需要传入 `--visual`(或设置 `ELECTRON_SMOKE_MODE=visual`)。
结果记录模式和耗时;启动器设置超时,避免验证进程无限等待。
## 截图留档
截图分两套,各管一件事:
| 目录 | 谁在用 | 谁产出 |
| ------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------- |
| `docs/images/` | **文档站、README、官网用的是同一份图**,页面上展示的都是它 | `capture-shots.mjs`(Web / 手机)+ 客户端冒烟(客户端 / 灵动岛) |
| `docs/screenshots/` | 回归留档,页面不展示;`verify:*` 的像素级断言比对的就是它们 | 各冒烟脚本自动写入(island / client / web-mobile 三组) |
**展示图重新采集**(保证文档站与 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 \
node packages/desktop-client/scripts/smoke.mjs --visual --online
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 \
node packages/desktop-client/scripts/smoke.mjs --visual --online
```
> 展示图都是**逻辑像素 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
@@ -16,7 +16,8 @@
href: winisland-design-tokens.md
- name: 验收与测试
href: testing.md
- name: 2026-10-17 审查修复记录
href: review-2026-10-17.md
- name: 版本迭代与发布
href: release.md
- name: 当前实现约定
href: implementation.md
+44 -578
View File
@@ -1,593 +1,59 @@
# WinIsland 视觉设计系统(Design Token 规格)
来源:[WinIsland](https://github.com/WinIslandProject/WinIsland) 的源码(`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)。
+6 -6
View File
@@ -48,8 +48,8 @@ description: 装不上、连不上、改了不生效——最常被问到的那
### 上课时段发的通知学生看不到
**这是设计行为。** 上课时段普通通知只进队列、不打断课堂,**下课自动补弹**。
紧急通知与叫人则立刻弹。规则见[通知与提醒](app/notifications.md)。
**经典模式的设计行为如此。** 上课时段普通通知暂存,下课补弹;自定义常驻模式先提示铃铛。
经典模式紧急消息立即展开,普通叫人保留可点击胶囊;自定义常驻模式先提示铃铛,主动查看。规则见[通知与提醒](app/notifications.md)。
### 老师说发了通知,教室机器上什么都没弹
@@ -62,8 +62,8 @@ description: 装不上、连不上、改了不生效——最常被问到的那
### 屏幕上出现了第二个灵动岛,点它反应不对
那是**自动化验证实例**的岛 —— `pnpm verify:desktop` / `verify:packaged` 会真的在屏幕上放一个,
而且它归脚本控制,会自己展开 / 收起 / 消失。验证实例的岛是**洋红主题色**,一眼可辨。
先确认是否有人显式运行了 `verify:desktop:visual` 或 `verify:packaged --visual`;视觉实例由脚本控制,会自行开合。
默认 `verify:desktop` / `verify:packaged` 是后台快速检查,不创建可见岛或托盘。
### 灵动岛上的文字往右跳一下再缩回
@@ -87,7 +87,7 @@ description: 装不上、连不上、改了不生效——最常被问到的那
### 学生登录不了
**学生没有个人账号。** ClassHelper 班级端统一用**班级码 + 班级密码**登录(见[装 ClassHelper 班级端](get-started/client.md))。
用学生用户名去登录一定会失败,这是设计。
用学生学号去登录会失败,这是设计。
### 客户端里找不到"修改密码"
@@ -130,7 +130,7 @@ Linux 上跑 `sudo classhelper doctor` 有专门一条断言盯着这个软链
## 还没解决?
- **开发与构建环境**的问题(命令、代理与 TLS、ClassIsland 路径与日志编码……)
- **开发与构建环境**的问题(命令、代理、TLS 与日志编码等)
记在仓库的 [`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 里;
- 功能行为的权威说明在[完整参考](reference.md);
- 都不是的话,去 [Issues](https://github.com/LoveChengke/classhelper/issues) 提一个,
+10 -9
View File
@@ -13,9 +13,9 @@ 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) | 单文件便携版,双击即用,适合不改动机房机器 |
要自己打:
@@ -50,15 +50,16 @@ pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的
## 4. 认一认主界面
左侧是四个一级项(**课表 / 作业 / 通知 / 成绩**)加上一个可展开的**设置**分组,
左侧是业务项(**课表 / 作业 / 通知 / 课件 / 成绩**)加上一个可展开的**设置**分组,
顶部汉堡按钮把整栏收成 64px 的图标导轨(折叠状态会记住)。「通知」项上有未读红点。
| 页面 | 一句话 |
| ---- | -------------------------------------------------------------- |
| 课表 | 周视图 + 「今天」时间轴(大时钟 / 正在上什么 / 下一节) |
| 作业 | 看板模式(按科目卡片、可全屏)/ 列表模式;教室机器还能录入作业 |
| 课件 | 查看本班可见文件、下载和打开本地文件夹 |
| 通知 | 未读红点、优先级标签、点开自动标为已读 |
| 成绩 | 班级账号看到的是全班总览(表格 + 柱状图 + 等级分布) |
| 成绩 | 个人查询开启时看本班成绩,关闭后只看服务端集体分析 |
| 设置 | 通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,六个子页 |
细节见[应用帮助](../app/index.md)。
@@ -71,15 +72,15 @@ pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的
2. **调灵动岛** —— 设置 → 灵动岛:高度、宽度、字号、主题色、停靠位置、视觉风格。
讲台机器上通常把字号调大一点、位置放到顶部居中。
触摸屏(希沃白板等)走的是另一套命中逻辑,客户端会自动识别,不用手动开。
3. **查看提醒规则** —— 设置 → 提醒:普通通知下课显示,紧急提醒立即显示。
这个选择会同步到班级记录,服务端据此决定要不要广播到客户端。
3. **查看提醒规则** —— 设置 → 提醒维护本机课程等提醒设置;经典通知模式上课暂存普通消息,
自定义常驻模式提示铃铛并主动查看。规则保存在本机,服务端仍即时广播。
## 6. 确认实时链路是通的
在 Web 管理端给这个班发一条普通通知,教室机器上应该几秒内出现灵动岛胶囊。如果没有:
在隔离测试服务中给临时验证班级发一条普通通知,经典岛课间应出现胶囊,自定义常驻岛应出现铃铛数量提示。如果没有:
- 客户端顶栏的实时通道状态是不是"已连接"?
- 现在是上课时段吗?上课时段的普通通知**只进队列、下课才弹**,这是设计行为,不是故障。
- 现在是上课时段吗?经典模式普通通知上课暂存;自定义常驻模式看铃铛并主动展开。
- 服务器与教室机器之间能互相 ping 通吗?防火墙放行 4000 了吗?
排查清单见[常见问题](../faq.md)。
+22 -46
View File
@@ -1,60 +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 班级端看到的是**全班总览**:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新。
在“教学事务 → 课件”上传文件,选择本人授课班级并设置“仅保存”或“保存后推送”。
文件先保存到服务端,班级端按可见范围下载;推送离线补领、逐设备回执,客户端不自动运行文件。
单文件上限 512 MB,详见[课件上传与传输](../app/courseware.md)。
+7 -7
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 |
| 磁盘 | 预留程序、数据库、备份和课件文件空间;课件最多 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 管理端
+23 -57
View File
@@ -1,71 +1,37 @@
---
title: 建班级、排课表
description: 建第一个班级、配班级账号、加学生与老师、排课表——后面所有功能的前提。
description: 班级、名单、任课关系与日课表的首次配置。
---
# 建班级、排课表
后面所有功能(发通知、布置作业、录成绩)都挂在**班级**上,所以先把班级建出来。
## 创建班级
## 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)。
+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 课表、节次时间 | 管理员、本班班主任 |
| 作业发布 | 发作业、按天查看、看未交名单 | 教师、管理员、班级账号 |
| 通知发布 | 发通知、发叫人 | 教师、管理员 |
| 成绩录入 | 单条 / 批量录成绩、导入表格、看统计 | 管理员、本班班主任 |
| 客户端管理 | 看设备状态、下发远控与更新 | 管理员、教师 |
| 班级管理 | 建班级、换班主任、改班级账号 | **仅管理员** |
| 学生管理 | 学生名单增删、导入 | **仅管理员** |
| 教师管理 | 教师账号增删改、导入名单、重置密码 | **仅管理员** |
| 数据库管理 | 备份 / 恢复、导出导入、一键切换 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)。
+3 -3
View File
@@ -19,7 +19,7 @@ ClassHelper(Class Helper)是给中小学班级用的一套信息管理系统
</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>
@@ -55,8 +55,8 @@ ClassHelper(Class Helper)是给中小学班级用的一套信息管理系统
**一、老师发,教室弹。** 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`,不需要额外安装。
+10 -10
View File
@@ -38,7 +38,7 @@ 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 起容器 |
@@ -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 拷进去,
@@ -78,7 +78,7 @@ Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不
适合本地快速打一个包去验证安装与运维流程。逐项核对过的结论:
| 项 | 结论 |
| --- | --- |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SQLite 驱动 | ✅ 包内是 `@libsql/linux-x64-gnu` + `linux-x64-musl`,**没有任何 win32 残留** |
| 服务端本体 | ✅ Node + libsql + express + socket.io 都是纯 JS 或已就位的 Linux 原生包 |
| 运维命令 | ✅ `status` / `doctor` / `password` / `backup` / `upgrade` / `rollback` 都不依赖平台差异 |
@@ -127,7 +127,7 @@ 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 读一行作为初始管理员密码 |
@@ -170,7 +170,7 @@ classhelper backup # 容器内做一致性快照再取出来
Docker 形态与直接安装的差异:
| | 直接安装 | Docker |
| --- | --- | --- |
| ----------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| 数据位置 | `<安装目录>/data` | Docker 卷 `classhelper-data` |
| 配置 | `/etc/classhelper/config.env`(systemd 注入) | 同一份文件(compose `env_file`) |
| 升级 / 回滚 | 替换程序文件后重启服务 | 同一套流程:替换构建上下文 → 重建镜像(`classhelper-server:local`)→ `compose up -d --force-recreate` |
@@ -225,7 +225,7 @@ Web 端「数据库管理 → 一键切换数据库」在运行中改写的是 *
(含空白或 `#` 的值请加双引号)。最常见的几项:
| KEY | 默认 | 说明 |
| --- | --- | --- |
| ----------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------ |
| `PORT` / `HOST` | `4000` / `0.0.0.0` | 监听端口与地址 |
| `DATABASE_PROVIDER` | `sqlite` | `sqlite` 或 `mysql` |
| `DATABASE_URL` | `file:../data/classhelper.db` | SQLite 相对 `<安装目录>/server` 解析;MySQL 是连接串 |
@@ -423,7 +423,7 @@ 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 反代 |
@@ -503,7 +503,7 @@ 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` 可用 |
@@ -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)。
+10 -8
View File
@@ -3,7 +3,7 @@
本文档覆盖三种上线形态,按适用场景选择:
| 形态 | 适用场景 | 产物 | 验证状态 |
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
| --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
| **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 |
@@ -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
@@ -207,7 +207,7 @@ printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \
日常运维全部走 `classhelper`(`classhelper help` 看全部):
| 事项 | 命令 |
| ------ | --------------------------------------------------------------------------------------- |
| ------ | ---------------------------------------------------------------------------------------- |
| 状态 | `classhelper status [--json]` |
| 体检 | `classhelper doctor`(权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份) |
| 改密码 | `classhelper password [用户名]`(交互输入、不回显、不进 shell history) |
@@ -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)
@@ -309,13 +308,13 @@ 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 位;生产环境仍为示例值会拒绝启动 |
| `AUTO_MIGRATE` | `false` | SQLite 启动时补跑随包迁移,空库另创建管理员 |
| `JWT_SECRET` | — | **必填**,至少 16 位;生产需 ≥32 位且拒绝示例密钥 |
| `JWT_EXPIRES_IN` | `7d` | 令牌有效期 |
| `CORS_ORIGIN` | `*` | 允许来源,多个用逗号分隔;生产建议写具体域名 |
| `TRUST_PROXY` | `false` | 反向代理层数(Nginx 填 `1`),影响限流与日志中的真实 IP |
@@ -326,7 +325,8 @@ pnpm --filter @classhelper/server start # 或 node packages/server/dist/ind
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
| `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(ClassHelper 班级端登录)的默认密码 |
| `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) |
| `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 |
| `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 安全清单(上线前逐项确认)
+2 -2
View File
@@ -113,11 +113,11 @@ docker compose up -d
> 如果你是在**自己维护的机器**上只替换了 `releases/` 里的产物、而没有同步到安装目录,
> 用户打开的还是旧的 `resources/app.asar` —— 表现为"改了但界面不生效"。
> 覆盖升级时用 `robocopy ... /E` 推一遍,再对**那个副本**跑一次打包版冒烟确认。
> 停止客户端后使用新安装程序覆盖安装,再对**实际安装副本**跑一次打包版冒烟确认。
## 升级前的准备清单
- [ ] 备份数据(`classhelper backup` / 复制 `data` 目录 / `mysqldump`);
- [ ] 备份数据库、配置与课件实体(完整复制 `data`/`COURSEWARE_DIR`,数据库用对应快照或 dump);
- [ ] 记下当前版本号,必要时能回滚到它;
- [ ] 挑一个没课的时间 —— 升级期间实时推送会中断;
- [ ] 升级后确认 `/readyz` 是 200,并且客户端顶栏的实时通道显示"已连接"。
+33 -1561
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/**',
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "class-helper",
"version": "1.3.3",
"version": "1.4.0",
"private": true,
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0",
@@ -28,7 +28,9 @@
"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",
@@ -36,6 +38,7 @@
"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",
"icons": "node scripts/generate-icons.mjs",
"version:check": "node scripts/version.mjs",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/desktop-client",
"version": "1.3.3",
"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 标签体系时,这一步会被系统拒绝(保持继承即可),属正常,不提示。
+2 -1
View File
@@ -84,7 +84,8 @@ function launchSmoke(extraEnv) {
child.kill();
resolve(124);
},
mode === 'visual' ? 360_000 : 45_000,
// 快速套件已包含本地 HTTP、校验下载和布局编辑;保留冷启动余量,单项断言不放宽。
mode === 'visual' ? 360_000 : 90_000,
);
child.once('exit', (code) => {
clearTimeout(deadline);
@@ -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';
/**
@@ -1,3 +1,4 @@
import type { ReleaseProgressDto as ContractReleaseProgressDto } from '@classhelper/shared';
import crypto from 'node:crypto';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
@@ -85,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()`)。
*
@@ -187,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 });
@@ -211,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}`,
@@ -234,6 +263,7 @@ 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;
@@ -247,6 +277,7 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
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);
@@ -285,6 +316,8 @@ function formatSize(bytes: number): string {
* 每次心跳后与主窗口隐藏后都会调用。
*/
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;
@@ -342,6 +375,7 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
}
installing = true;
publishProgress({ stage: 'installing' });
logger.info(`开始静默安装更新 ${version}:${installer}`);
try {
const exe = app.getPath('exe');
@@ -1,8 +1,9 @@
import { pollCourseware } from './courseware.js';
import { spawn } from 'node:child_process';
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import { BrowserWindow, app, dialog } from 'electron';
import { BrowserWindow, app } from 'electron';
import {
CLIENT_COMMAND_LABELS,
CLIENT_HEARTBEAT_INTERVAL_MS,
@@ -15,6 +16,7 @@ import {
type ClientUpdateInfoDto,
} from '@classhelper/shared';
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';
@@ -45,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)才算离线。这里不复用那个常量是因为它属于**服务端**的判定口径,
@@ -171,6 +165,7 @@ async function beat(): Promise<void> {
return;
}
const epoch = connectionEpoch;
state.beating = true;
let backoff = false;
try {
@@ -194,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;
@@ -209,7 +205,9 @@ async function beat(): Promise<void> {
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() ?? '';
@@ -226,13 +224,16 @@ async function beat(): Promise<void> {
);
} 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,
);
}
}
@@ -241,14 +242,9 @@ const HEARTBEAT_INTERVAL_TEXT = `${Math.round(CLIENT_HEARTBEAT_INTERVAL_MS / 100
/**
* 服务端下发的远控命令(关机 / 重启 / 注销 / 锁屏)。
*
* 两道护栏缺一不可(用户要求「必须二次确认」):
* 1. **本机二次确认**:弹一个默认选中「取消」的对话框,60 秒不确认按取消处理 ——
* 服务端那边虽然也确认过,但真正会被关掉的机器上必须有人当场知道;
* 2. **同一条命令只执行一次**:等待确认时内存去重,处理结果落盘,重启后也只重试回执。
*
* 执行后**必须回执**(`POST /api/clients/commands/:id/ack`,body 带 deviceKey):
* 否则服务端那条命令会一直挂在"待执行"里,Web 端看不到结果。回执失败时把结果暂存,
* 下一次心跳只重试回执、不重复弹窗。
* 按产品要求收到即执行,保留十分钟有效期、持久去重与设备归属校验。
* 执行结果必须回执;网络失败只重试回执,不重复执行。
* 冒烟在执行器处强制返回假结果,永不调用系统命令。
*/
const inFlightCommands = new Set<string>();
type CommandOutcome = { ok: boolean; result: string };
@@ -286,6 +282,7 @@ function rememberCommand(commandId: string, outcome: CommandOutcome): void {
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 =
@@ -297,65 +294,38 @@ async function handleCommand(command: ClientCommandDto | null): Promise<void> {
return;
}
// 正在确认或执行时不能用预存的中间结果抢先回执。
// 正在执行时不能用预存的中间结果抢先回执。
if (inFlightCommands.has(command.id)) return;
// 处理过但回执没送达:只重试回执
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;
}
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);
return;
}
// 注销可能立刻结束本进程,先落盘,重启后不能再次执行同一条指令。
rememberCommand(command.id, {
ok: false,
result: '本机已确认并提交系统执行,最终结果未回传,请核对设备状态',
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 controller = new AbortController();
const timer = setTimeout(() => controller.abort(), COMMAND_CONFIRM_TIMEOUT_MS);
const answered = dialog
.showMessageBox({
signal: controller.signal,
type: 'warning',
title: '来自管理员的远程指令',
message: `管理员要求本机「${label}」`,
detail: `${note ? `备注:${note}\n` : ''}确认后本机将立即${label};60 秒内未确认视为取消。`,
buttons: ['取消', '确认执行'],
defaultId: 0,
cancelId: 0,
noLink: true,
})
.then((result) => !controller.signal.aborted && result.response === 1)
.catch(() => false);
try {
return await answered;
} finally {
clearTimeout(timer);
}
}
/** 执行系统命令:只支持 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[] }> = {
@@ -390,9 +360,14 @@ async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; r
}
/** 回执:服务端用 body 里的 deviceKey 校验"这条命令是发给这台机器的" */
async function ackCommand(commandId: string, ok: boolean, result: string): Promise<void> {
async function ackCommand(
commandId: string,
ok: boolean,
result: string,
config: ReturnType<typeof getConfig>,
): Promise<void> {
rememberCommand(commandId, { ok, result });
const config = getConfig();
// 回执始终发给接收命令时的服务器,切换实例不能把旧命令带到新实例。
if (!config.token) return;
try {
const response = await fetch(`${config.serverUrl}/api/clients/commands/${commandId}/ack`, {
@@ -426,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);
}
@@ -1,9 +1,12 @@
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 {
DEFAULT_ISLAND_APPEARANCE,
normalizeIslandLayouts,
DEFAULT_SERVER_URL,
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
@@ -21,6 +24,7 @@ const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = {
};
interface PersistedConfig {
islandLayout?: ContractIslandLayoutSettings;
serverUrl: string;
username: string;
/** 加密后的 token(base64)或明文(当系统不支持加密时) */
@@ -194,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)
@@ -360,6 +365,7 @@ 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,
@@ -425,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);
@@ -485,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;
@@ -494,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);
}
@@ -508,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,
@@ -519,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));
}
@@ -227,6 +227,13 @@ if (!gotLock) {
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;
+12 -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,6 +15,11 @@ 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(
@@ -21,7 +29,9 @@ export function registerIpcHandlers(): void {
const allowed = { ...patch };
delete allowed.pendingUpdateFile;
delete allowed.pendingUpdateVersion;
return saveConfig(allowed);
const saved = saveConfig(allowed);
if (allowed.islandLayout) island.syncCustomLayout();
return saved;
},
);
@@ -131,7 +141,7 @@ export function registerIpcHandlers(): void {
return true;
});
/** 服务端联动状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md §5 第 9 条) */
/** 服务端连接状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md 的现行工程约定) */
ipcMain.handle(IPC_CHANNELS.clientStatus, () => getClientAgentStatus());
/** 立即上报一次(设置页「立即上报」按钮) */
+480 -16
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,10 +535,17 @@ 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;
@@ -346,7 +555,7 @@ class IslandController {
}
// 触摸屏上没有"指针"可依据(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;
@@ -394,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('灵动岛已就绪(置顶透明窗口,顶部居中)');
}
@@ -408,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 {
@@ -440,7 +682,7 @@ class IslandController {
/**
* 同一条消息**可能经由两条广播链路到达**:服务端 `createCall` 既发 `notification:new`
* (班级房间)又发 `call:new`(学生/班级 user 房间),两条 DTO 的 id 相同、
* (班级房间)又发 `call:new`(班级会话 session 房间),两条 DTO 的 id 相同、
* 只有"是否叫人"的语义不同。后到的那条绝不能把已有的"叫人"降级成普通通知 ——
* 否则胶囊显示成"新消息"、上课时段还会被当成普通通知隐藏(用户实测截图:
* 叫人胶囊写着"新消息 / 系统管理员 · 点击查看")。
@@ -605,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;
@@ -659,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;
// 岛当前可见(胶囊态)时,用户是"对着屏幕上这条岛"点的:
// 只要队列里有更**新**的消息(例如紧急通知之后又来了作业),就应当打开新那条,
@@ -732,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)) {
@@ -792,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) {
@@ -808,7 +1074,7 @@ class IslandController {
* 起因:用户反馈"点「知道了」「已读」时没有收回动画"(其他场景都有)。
* 状态**在同一帧就已经落地**(调用方 `dismissActive()` / `clearPendingForClose()` 已清空
* active/queued),这里只是把形态切回胶囊 —— **不引入任何跨进程握手,也没有收回快照**
* (见 AGENTS.md §5 第 34 条:早先自研的 `closing` / `closingSnapshot` 已整体删除,
* (见 AGENTS.md 的现行工程约定:早先自研的 `closing` / `closingSnapshot` 已整体删除,
* 它在触摸屏上会露旧帧;这里复用的是已被 `collapse` 用例验收过的那条路径)。
*/
private beginClose(): void {
@@ -822,7 +1088,7 @@ 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);
@@ -845,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);
}
/**
@@ -865,12 +1151,25 @@ 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();
@@ -1051,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;
// 列表形态比普通展开卡高:窗口要**先**长高,卡片才开始形变,否则底部会被窗口裁掉
@@ -1081,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;
}
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知 / 叫人"允许出现在屏幕上)
// 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算
@@ -1108,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();
@@ -1121,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),这里只负责显示
@@ -1141,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)),
@@ -1200,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 &&
@@ -1236,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 };
}
@@ -1254,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();
@@ -1264,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);
@@ -1357,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();
@@ -1390,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(
@@ -1420,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 {
@@ -1436,6 +1861,7 @@ class IslandController {
setTouchMode(enabled: boolean): void {
if (this.touchMode === enabled) return;
this.touchMode = enabled;
this.syncResidentShape();
logger.info(
`灵动岛:触摸模式=${enabled}(窗口${enabled ? '贴合岛体并始终接收输入' : '回到固定包围盒穿透'})`,
);
@@ -1497,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 };
@@ -1565,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;
}
@@ -1868,6 +2306,32 @@ 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);
});
@@ -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();
}
}
@@ -1,3 +1,6 @@
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';
@@ -55,6 +58,7 @@ export async function runQuickSmoke(win: BrowserWindow): Promise<void> {
});
for (const [name, method] of [
['IndexedDB 缓存读写', 'cacheSelfTest'],
['课表变更和重连立即刷新,慢学期接口与迟到响应不阻塞', 'islandScheduleSyncSelfTest'],
['断网缓存回退', 'offlineScenario'],
['服务端与班级缓存隔离', 'cacheIsolationSelfTest'],
]) {
@@ -79,6 +83,9 @@ export async function runQuickSmoke(win: BrowserWindow): Promise<void> {
return result.detail;
});
}
await verifyAgentSmoke(check);
await verifyLayoutSmoke(check);
await verifyCoursewareSmoke(check);
await check('检查结束时没有可见或聚焦的验证窗口', () =>
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
);
@@ -0,0 +1,820 @@
import fs from 'node:fs';
import path from 'node:path';
import {
defaultIslandLayouts,
islandNode,
normalizeIslandLayouts,
ISLAND_SHADOW_PAD,
type ScheduleDto,
} from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { island } from './island.js';
import { LessonReminderTracker } from './lesson-reminder.js';
/** 常驻岛始终用隔离的冒烟配置和本地消息;不读取或标记真实班级通知。 */
export async function verifyResidentSmoke(
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
) {
const win = island.getWindow()!;
const originalTouchMode = island.getTouchMode();
const originalAppearance = island.getAppearance();
island.setAppearance({ animations: true, speed: 1 });
island.setTouchMode(false);
island.setHitTestCursor({ x: 0, y: 0 });
const pause = (ms = 600) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function shot(name: string) {
if (!process.env.ISLAND_SHOTS_DIR || process.env.ELECTRON_SMOKE_MODE !== 'visual') return;
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
fs.writeFileSync(
path.join(process.env.ISLAND_SHOTS_DIR, `${name}.png`),
(
await win.webContents.capturePage(
island.getEditingLayout()
? undefined
: (() => {
const r = island.getHitRect();
return r
? {
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),
}
: undefined;
})(),
)
).toPNG(),
);
}
const fixture = defaultIslandLayouts();
fixture.layout = 'custom';
fixture.residentEnabled = true;
const compactText = islandNode('text', 'resident-compact-text');
compactText.settings.text = '常驻概览';
const expandedText = islandNode('text', 'resident-expanded-text');
expandedText.settings.text = '今日安排';
const scheme = fixture.schemes[0]!;
const dateAt = (h: number, m: number, s = 0) => {
const date = new Date();
date.setHours(h, m, s, 0);
return date;
};
const lessonFixtures: ScheduleDto[] = [
{
id: 'lesson-math',
classId: 'local',
courseId: 'math',
course: { id: 'math', name: '数学' },
dayOfWeek: new Date().getDay() || 7,
startTime: '09:00',
endTime: '09:45',
location: null,
weekStart: 1,
weekEnd: 40,
weekParity: 'ALL',
createdAt: new Date().toISOString(),
},
{
id: 'lesson-chinese',
classId: 'local',
courseId: 'chinese',
course: { id: 'chinese', name: '语文' },
dayOfWeek: new Date().getDay() || 7,
startTime: '09:55',
endTime: '10:40',
location: null,
weekStart: 1,
weekEnd: 40,
weekParity: 'ALL',
createdAt: new Date().toISOString(),
},
];
await check('课前60秒、上课与下课时间边界正确,刷新和改课不重复重置提醒', () => {
const tracker = new LessonReminderTracker();
if (tracker.update(lessonFixtures, dateAt(8, 58, 59))) return false;
const prepare = tracker.update(lessonFixtures, dateAt(8, 59));
const repeat = tracker.update(lessonFixtures, dateAt(8, 59, 5));
const renamed = lessonFixtures.map((s) => ({ ...s, course: { ...s.course!, name: '临时换课' } }));
const changed = tracker.update(renamed, dateAt(8, 59, 6));
const onClass = tracker.update(lessonFixtures, dateAt(9, 0));
const noLate = tracker.update(lessonFixtures, dateAt(9, 0, 6));
const breaking = tracker.update(lessonFixtures, dateAt(9, 45));
const after = tracker.update(lessonFixtures, dateAt(9, 45, 13));
const lastBreak = tracker.update(lessonFixtures, dateAt(10, 40));
const end = tracker.update(lessonFixtures, dateAt(10, 40, 6));
const removed = tracker.update([], dateAt(8, 59));
return (
prepare?.kind === 'prepare' &&
prepare.maskUntil - prepare.startedAt === 3000 &&
repeat?.startedAt === prepare.startedAt &&
changed?.courseName === '临时换课' &&
changed.maskUntil === prepare.maskUntil &&
onClass?.kind === 'on-class' &&
noLate?.kind === 'in-class' &&
breaking?.kind === 'break' &&
breaking.nextCourseName === '语文' &&
!after &&
lastBreak?.kind === 'break' &&
!end &&
!removed
);
});
await check('即将下课倒数边界与四个提醒通道独立生效,旧配置自动补默认值', () => {
const settings = { ...defaultIslandLayouts().lessonReminders, classProgressEnabled: false };
const tracker = new LessonReminderTracker();
const before = tracker.update(lessonFixtures, dateAt(9, 43, 59), settings);
const ending = tracker.update(lessonFixtures, dateAt(9, 44), settings);
const disabled = tracker.update(lessonFixtures, dateAt(9, 44, 1), { ...settings, endingEnabled: false });
const prepareDisabled = tracker.update(lessonFixtures, dateAt(8, 59), {
...settings,
prepareEnabled: false,
});
const startDisabled = tracker.update(lessonFixtures, dateAt(9, 0), {
...settings,
onClassEnabled: false,
});
const breakDisabled = tracker.update(lessonFixtures, dateAt(9, 45), { ...settings, breakEnabled: false });
return (
!before &&
ending?.kind === 'ending' &&
ending.endsAt === dateAt(9, 45).getTime() &&
!disabled &&
!prepareDisabled &&
!startDisabled &&
!breakDisabled &&
normalizeIslandLayouts({ layout: 'custom' }).lessonReminders.endingEnabled
);
});
scheme.rows[0]!.nodes = [
islandNode('clock', 'resident-clock'),
islandNode('separator', 'resident-separator'),
compactText,
];
scheme.expandedRows[0]!.nodes = [islandNode('date', 'resident-date'), expandedText];
island.handleAction({ action: 'dismiss-all' });
await pause();
await check('旧自定义配置迁移到统一常驻界面,并补齐空闲展开布局', () => {
const legacy = JSON.parse(JSON.stringify(fixture));
delete legacy.residentEnabled;
delete legacy.schemes[0].expandedRows;
const value = normalizeIslandLayouts(legacy);
const oversized = JSON.parse(JSON.stringify(fixture));
Object.assign(oversized.schemes[0], { scale: 99, radius: 99, opacity: -1, spacing: 999 });
const bounds = normalizeIslandLayouts(oversized).schemes[0]!;
return (
value.residentEnabled &&
value.schemes[0]!.expandedRows[0]!.nodes.length > 0 &&
bounds.scale === 2.5 &&
bounds.radius === 20 &&
bounds.opacity === 0 &&
bounds.spacing === 150
);
});
saveConfig({ islandLayout: fixture });
island.syncCustomLayout();
await pause(800);
let compactWidth = 0;
await check('无通知常驻岛显示自定义内容,并为铃铛预留对齐空间', async () => {
const layout = await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.custom-card'), strip=card?.querySelector('.resident-compact'), bell=card?.querySelector('.resident-bell'), clock=card?.querySelector('.clock'), text=card?.querySelector('.text span'), digits=clock?.querySelector('span');
if(!card||!strip||!bell||!clock||!text||!digits)return null;
const b=bell.getBoundingClientRect(), c=clock.getBoundingClientRect(), r=card.getBoundingClientRect();
const t=text.getBoundingClientRect(),d=digits.getBoundingClientRect();
return {width:r.width, height:r.height, content:card.textContent.includes('常驻概览'), hidden:bell.classList.contains('bell-empty')&&!bell.querySelector('span'), aligned:Math.abs(b.top+b.height/2-c.top-c.height/2)<=1 && Math.abs(t.top+t.height/2-d.top-d.height/2)<=4};
})()`);
compactWidth = layout?.width ?? 0;
return Boolean(
layout?.content &&
layout.hidden &&
layout.aligned &&
Math.abs(layout.height - 40) <= 1 &&
island.getState().mode === 'hidden',
);
});
await shot('resident-compact');
await check('鼠标移入常驻背景淡化至5%并穿透,移出恢复', async () => {
const normalOpacity = win.getOpacity();
const rect = await win.webContents.executeJavaScript(
`(() => {const r=document.querySelector('.clock').getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`,
);
const box = win.getBounds();
island.setHitTestCursor({ x: box.x + rect.x, y: box.y + rect.y });
await pause(120);
const faded = Math.abs(win.getOpacity() - normalOpacity * 0.05) < 0.01 && !island.getInteractive();
island.setHitTestCursor({ x: 0, y: 0 });
await pause(120);
return faded && Math.abs(win.getOpacity() - normalOpacity) < 0.01;
});
await check('右侧展开入口不避让,触摸交互暂停指针淡化', async () => {
const rect = await win.webContents.executeJavaScript(
`(() => {const r=document.querySelector('.resident-bell').getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`,
);
const box = win.getBounds(),
normalOpacity = win.getOpacity();
island.setHitTestCursor({ x: box.x + rect.x, y: box.y + rect.y });
await pause(120);
const controlAvailable =
Math.abs(win.getOpacity() - normalOpacity) < 0.01 && (!win.isVisible() || island.getInteractive());
island.setTouchMode(true);
island.noteResidentTouch();
island.setHitTestCursor({ x: box.x + 20, y: box.y + 20 });
await pause(120);
const touchAvailable = Math.abs(win.getOpacity() - normalOpacity) < 0.01;
island.setHitTestCursor({ x: 0, y: 0 });
island.setTouchMode(false);
await pause(1100);
return controlAvailable && touchAvailable;
});
await check('ClassIsland 反转淡化和关闭淡化设置生效', async () => {
const config = getConfig().islandLayout,
normalOpacity = win.getOpacity();
config.pointerFadeReversed = true;
saveConfig({ islandLayout: config });
island.syncCustomLayout();
await pause(120);
const reversed = Math.abs(win.getOpacity() - normalOpacity * 0.05) < 0.01;
config.pointerFadeEnabled = false;
saveConfig({ islandLayout: config });
island.syncCustomLayout();
await pause(120);
const disabled = Math.abs(win.getOpacity() - normalOpacity) < 0.01;
config.pointerFadeReversed = false;
config.pointerFadeEnabled = true;
saveConfig({ islandLayout: config });
island.syncCustomLayout();
return reversed && disabled;
});
await check('点击常驻内容展开无通知布局', async () => {
await win.webContents.executeJavaScript(`document.querySelector('.custom-card .clock')?.click()`);
await pause();
return (
island.getState().mode === 'expanded' &&
!island.getState().active &&
(await win.webContents.executeJavaScript(
`(() => {const page=document.querySelector('.resident-detail-page.page-active');return Boolean(page?.querySelector('.resident-panel-heading')&&page.textContent.includes('今日安排')&&document.querySelector('.resident-compact-page[inert]'));})()`,
))
);
});
await shot('resident-empty-expanded');
await check('无通知展开页可以收回常驻岛且尺寸恢复', async () => {
await win.webContents.executeJavaScript(
`document.querySelector('[aria-label="收起自定义灵动岛"]')?.click()`,
);
await pause(800);
return (
island.getState().mode === 'hidden' &&
(await win.webContents.executeJavaScript(
`Math.abs(document.querySelector('.custom-card').getBoundingClientRect().width-${compactWidth})<=2`,
))
);
});
const message = (id: string, title: string, priority: 'NORMAL' | 'URGENT') => ({
id,
title,
content: '本地隔离消息内容',
priority,
createdAt: new Date().toISOString(),
teacherName: '本地验证',
courseName: null,
});
await check('上课收到普通消息只亮铃铛,常驻内容不横移', async () => {
island.setClassState({ inClass: true, currentPeriodEnd: null, week: 1 });
island.pushNotification(message('resident-normal', '常驻通知一', 'NORMAL'));
await pause();
return (
island.getState().mode === 'hidden' &&
(await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.custom-card'),bell=card?.querySelector('.resident-bell');
return Boolean(bell&&!bell.classList.contains('bell-empty')&&bell.textContent.trim()==='1'&&Math.abs(card.getBoundingClientRect().width-${compactWidth})<=2&&!card.classList.contains('has-alert'));
})()`))
);
});
await shot('resident-bell');
await check('紧急消息沿用经典岛立即展开,重复消息不增加数量', async () => {
island.pushNotification(message('resident-urgent', '常驻通知二', 'URGENT'));
island.pushNotification(message('resident-urgent', '常驻通知二', 'URGENT'));
await pause();
return (
island.getState().mode === 'expanded' &&
(await win.webContents.executeJavaScript(
`document.querySelector('.resident-bell')?.textContent.trim()==='2'`,
))
);
});
await check('点击铃铛展开通知列表,上课时也可主动查看', async () => {
island.handleAction({ action: 'collapse' });
await pause(800);
await win.webContents.executeJavaScript(`document.querySelector('.resident-bell')?.click()`);
await pause(1800);
return (
island.getState().mode === 'expanded' &&
(await win.webContents.executeJavaScript(
`(() => {const host=document.querySelector('#custom-alert-host');return Boolean(host&&host.getBoundingClientRect().height>0&&host.querySelectorAll('.list-row').length===2&&host.textContent.includes('常驻通知一')&&host.textContent.includes('常驻通知二')&&!host.querySelector('.island-root'));})()`,
))
);
});
await shot('resident-notifications');
await check('收起通知保留铃铛,键盘可重新展开', async () => {
await win.webContents.executeJavaScript(
`document.querySelector('.classic-notifications .icon-btn')?.click()`,
);
await pause(800);
const collapsed = island.getState().mode === 'pill';
await win.webContents.executeJavaScript(
`document.querySelector('.custom-card')?.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}))`,
);
await pause();
return collapsed && island.getState().mode === 'expanded';
});
await check('处理完通知清除铃铛,恢复40px常驻内容且命中不占整窗', async () => {
await win.webContents.executeJavaScript(
`document.querySelector('#custom-alert-host .solid-btn')?.click()`,
);
await pause(800);
const box = win.getBounds();
const dimensions = await win.webContents.executeJavaScript(
`(() => {const card=document.querySelector('.custom-card'), r=card.getBoundingClientRect();return {width:r.width,height:r.height,empty:card.querySelector('.resident-bell')?.classList.contains('bell-empty'),content:card.textContent.includes('常驻概览')};})()`,
);
return (
!island.getState().active &&
island.getState().queued.length === 0 &&
dimensions.empty === true &&
dimensions.content &&
dimensions.width + 2 * ISLAND_SHADOW_PAD <= box.width &&
Math.abs(dimensions.height - 40) <= 1 &&
Math.abs((island.getHitRect()?.height ?? 0) - dimensions.height) <= 2
);
});
await check('空闲展开时收到消息返回铃铛提示', async () => {
island.handleAction({ action: 'expand' });
await pause();
island.pushNotification(message('resident-after-open', '展开后新消息', 'NORMAL'));
await pause(800);
const collapsed = island.getState().mode === 'hidden';
island.handleAction({ action: 'dismiss-all' });
await pause();
return collapsed;
});
await check('课表变更立即传到常态与展开组件,重新加载后仍恢复最新快照', async () => {
const updated = [
{
id: 'island-schedule-live',
classId: 'local',
courseId: 'local-course',
dayOfWeek: new Date().getDay() || 7,
startTime: '00:00',
endTime: '23:59',
location: null,
weekStart: 1,
weekEnd: 40,
weekParity: 'ALL' as const,
createdAt: new Date().toISOString(),
course: { id: 'local-course', name: '实时课程' },
},
];
const settings = JSON.parse(JSON.stringify(fixture));
settings.schemes[0].rows[0].nodes = [islandNode('schedule', 'live-schedule')];
saveConfig({ islandLayout: settings });
island.syncCustomLayout();
await pause();
const started = Date.now();
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1, schedules: updated });
const waitForCourse = async () => {
for (let i = 0; i < 30; i++) {
if (
await win.webContents.executeJavaScript(
`document.querySelector('.resident-compact .schedule')?.textContent.includes('实时课程')`,
)
)
return true;
await pause(20);
}
return false;
};
const immediate = (await waitForCourse()) && Date.now() - started < 600;
const aligned = await win.webContents.executeJavaScript(`(() => {
const name=document.querySelector('.resident-compact .schedule strong')?.getBoundingClientRect(), time=document.querySelector('.resident-compact .lesson-time')?.getBoundingClientRect();
return Boolean(name&&time&&time.left>=name.right&&Math.abs(time.top+time.height/2-name.top-name.height/2)<=2);
})()`);
await win.webContents.reload();
await pause(800);
const restored = await waitForCourse();
const snapshot = await win.webContents.executeJavaScript('window.island.getSchedules()');
saveConfig({ islandLayout: fixture });
island.syncCustomLayout();
await pause(800);
island.setClassState({ inClass: true, currentPeriodEnd: null, week: 1, schedules: [] });
return immediate && aligned && restored && snapshot[0]?.course?.name === '实时课程';
});
await check('常驻岛开合复用固定画布,动画中没有滚动容器或旧胶囊形变', async () => {
const bounds = new Set<string>();
const timer = setInterval(() => bounds.add(JSON.stringify(win.getBounds())), 20);
const recorded: Array<{ at: number; image: Electron.NativeImage }> = [];
const capture = process.env.ISLAND_MOTION_CAPTURE === '1' && process.env.ELECTRON_SMOKE_MODE === 'visual';
const started = Date.now();
if (capture)
win.webContents.beginFrameSubscription(true, (image) => {
if (Date.now() - (recorded.at(-1)?.at ?? 0) >= 40) recorded.push({ at: Date.now(), image });
});
let result;
try {
result = await win.webContents.executeJavaScript(`new Promise(resolve => {
const frames=[], gaps=[], motion=[]; let last=performance.now(), count=0;
const sample=()=>{
const card=document.querySelector('.resident-card'), r=card?.getBoundingClientRect();
if(!r){frames.push(false);return;}
const nodes=[card,...card.querySelectorAll('*')];
frames.push(r.left>=0&&r.top>=0&&r.right<=innerWidth+1&&r.bottom<=innerHeight+1&&nodes.every(n=>!['auto','scroll'].includes(getComputedStyle(n).overflowY)&&!['auto','scroll'].includes(getComputedStyle(n).overflowX)));
motion.push({at:performance.now()-start,height:r.height,compact:Number(getComputedStyle(card.querySelector('.resident-compact-page')).opacity),details:Number(getComputedStyle(card.querySelector('.resident-detail-page')).opacity)});
};
const animate=time=>{gaps.push(time-last);last=time;count++;sample();if(time-start<1800)requestAnimationFrame(animate);};
const start=performance.now();requestAnimationFrame(animate);
window.island.sendAction('expand');setTimeout(()=>window.island.sendAction('collapse'),700);
setTimeout(()=>{sample(); const card=document.querySelector('.resident-card');resolve({valid:frames.every(Boolean),count,motion,gaps:gaps.filter(g=>g>0).sort((a,b)=>a-b),height:card.getBoundingClientRect().height,legacy:document.querySelector('.island-root')?.getBoundingClientRect().height===0});},1900);
})`);
} finally {
clearInterval(timer);
if (capture) win.webContents.endFrameSubscription();
}
if (capture && process.env.ISLAND_SHOTS_DIR) {
const dir = path.join(process.env.ISLAND_SHOTS_DIR, 'motion-frames');
fs.mkdirSync(dir, { recursive: true });
recorded.forEach((frame, i) => fs.writeFileSync(path.join(dir, `${i}.png`), frame.image.toPNG()));
fs.writeFileSync(path.join(dir, 'times.json'), JSON.stringify(recorded.map((f) => f.at - started)));
}
if (process.env.ISLAND_SHOTS_DIR)
fs.writeFileSync(
path.join(
process.env.ISLAND_SHOTS_DIR,
`resident-animation-metrics-${process.env.ELECTRON_SMOKE_MODE}.json`,
),
JSON.stringify({ ...result, windowRects: bounds.size }, null, 2),
);
const visual = process.env.ELECTRON_SMOKE_MODE === 'visual';
return Boolean(
result?.valid &&
result.legacy &&
Math.abs(result.height - 40) <= 1 &&
bounds.size === 1 &&
(!visual ||
(result.count >= 20 &&
result.gaps[Math.floor(result.gaps.length * 0.95)] < 100 &&
[
result.motion.filter((f: { at: number }) => f.at < 700),
result.motion.filter((f: { at: number }) => f.at >= 700),
].every(
(phase: Array<{ height: number; compact: number; details: number }>) =>
new Set(phase.map((f) => Math.round(f.height))).size >= 3 &&
phase.filter((f) => f.details > 0.01 && f.details < 0.95 && f.compact < 0.95).length >= 3,
))),
);
});
await check('关闭动画后外壳和内容同时落位,恢复动画无需重启', async () => {
island.setAppearance({ animations: false });
try {
island.handleAction({ action: 'expand' });
await pause(100);
const opened = await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.resident-card');
return card.getBoundingClientRect().height>40&&getComputedStyle(card.querySelector('.resident-detail-page')).opacity==='1'&&getComputedStyle(card.querySelector('.resident-compact-page')).opacity==='0';
})()`);
island.handleAction({ action: 'collapse' });
await pause(100);
return (
opened &&
(await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.resident-card');
return Math.abs(card.getBoundingClientRect().height-40)<=1&&getComputedStyle(card.querySelector('.resident-compact-page')).opacity==='1'&&getComputedStyle(card.querySelector('.resident-detail-page')).opacity==='0';
})()`))
);
} finally {
island.setAppearance({ animations: true });
}
});
await check('常驻岛连续快速开合后恢复40px且没有遗留过渡', async () => {
const box = win.getBounds();
const stable = await win.webContents.executeJavaScript(`new Promise(resolve=>{
for(let i=0;i<8;i++)setTimeout(()=>window.island.sendAction(i%2?'collapse':'expand'),i*70);
setTimeout(()=>{const r=document.querySelector('.resident-card').getBoundingClientRect();resolve({width:r.width,height:r.height,active:document.querySelector('.resident-compact-page').classList.contains('page-active')});},1300);
})`);
return Boolean(
stable.active &&
Math.abs(stable.height - 40) <= 1 &&
Math.abs(stable.width - compactWidth) <= 2 &&
JSON.stringify(box) === JSON.stringify(win.getBounds()),
);
});
await check('收起时鼠标留在旧展开区域不会错误触发5%淡化闪烁', async () => {
island.handleAction({ action: 'expand' });
await pause(800);
const hit = island.getHitRect()!,
box = win.getBounds(),
opacity = win.getOpacity();
island.setHitTestCursor({ x: box.x + hit.x + hit.width / 2, y: box.y + hit.y + hit.height - 12 });
island.handleAction({ action: 'collapse' });
const immediate = win.getOpacity();
await pause(120);
const after = win.getOpacity();
island.setHitTestCursor({ x: 0, y: 0 });
await pause(700);
return Math.abs(immediate - opacity) < 0.01 && Math.abs(after - opacity) < 0.01;
});
await check('两种布局均弹出ClassIsland课前/上下课提醒,倒计时与右侧时间正确且不写入通知队列', async () => {
const capture = process.env.ISLAND_MOTION_CAPTURE === '1' && process.env.ELECTRON_SMOKE_MODE === 'visual';
const recorded: Array<{ at: number; image: Electron.NativeImage }> = [];
const start = Date.now();
let recording = false;
const cueAppearance = island.getAppearance();
const setTime = async (h: number, m: number, s = 0) => {
const at = dateAt(h, m, s).getTime();
await win.webContents.executeJavaScript(`window.__lessonSmokeTime=${at}`);
island.setLessonReminderTestTime(at);
};
try {
await win.webContents.executeJavaScript(
`window.__lessonRealNow=Date.now;Date.now=()=>window.__lessonSmokeTime??window.__lessonRealNow();true`,
);
for (const layout of ['custom', 'fixed'] as const) {
saveConfig({ islandLayout: { ...fixture, layout } });
island.syncCustomLayout();
await setTime(8, 58, 59);
island.setClassState({ inClass: false, schedules: lessonFixtures });
await pause(1100);
if (layout === 'fixed') {
island.setAppearance({ idleSliver: true });
island.setTouchMode(true);
island.pushNotification(message('lesson-touch-canvas', '触摸画布验证', 'NORMAL'));
await pause(600);
island.handleAction({ action: 'dismiss-all' });
await pause(600);
}
if (capture && layout === 'custom') {
recording = true;
win.webContents.beginFrameSubscription(true, (image) => {
if (Date.now() - (recorded.at(-1)?.at ?? 0) >= 40) recorded.push({ at: Date.now(), image });
});
}
await setTime(8, 59);
await pause(800);
if (island.getState().lessonReminder?.kind !== 'prepare') return false;
const mask = await win.webContents.executeJavaScript(
`(() => {const cue=document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'} .ci-mask')||document.querySelector('.ci-fixed-lesson .ci-mask');return Boolean(cue&&cue.querySelectorAll('polygon').length===5);})()`,
);
if (!mask) return false;
await shot('lesson-prepare-mask-' + layout);
await setTime(8, 59, 5);
await pause(1100);
const info = await win.webContents.executeJavaScript(`(() => {
const cue=document.querySelector('${layout === 'custom' ? '.resident-lesson-page .ci-lesson' : '.ci-fixed-lesson'}');
const name=cue?.querySelector('.ci-lesson-course strong')?.getBoundingClientRect(),time=cue?.querySelector('.ci-lesson-course small')?.getBoundingClientRect();
const r=cue?.getBoundingClientRect();
return Boolean(cue&&!cue.classList.contains('ci-mask-active')&&cue.textContent.includes('55')&&cue.textContent.includes('数学')&&cue.textContent.includes('09:00–09:45')&&name&&time&&time.left>=name.right&&r&&Math.abs(r.height-40)<=1&&r.left>=0&&r.right<=innerWidth&&r.bottom<=innerHeight);
})()`);
if (!info || island.getState().queued.length || island.getState().active) return false;
await shot('lesson-prepare-info-' + layout);
await setTime(9, 0);
island.setClassState({ inClass: true });
await pause(700);
if (island.getState().lessonReminder?.kind !== 'on-class') return false;
await shot('lesson-start-' + layout);
if (recording) {
win.webContents.endFrameSubscription();
recording = false;
}
await setTime(9, 0, 6);
await pause(700);
if (
layout === 'custom'
? island.getState().lessonReminder
: island.getState().lessonReminder?.kind !== 'in-class'
)
return false;
await setTime(9, 22, 30);
if (layout === 'custom') {
const progressLayout = JSON.parse(JSON.stringify(fixture));
progressLayout.schemes[0].rows[0].nodes = [islandNode('schedule', 'lesson-progress-course')];
saveConfig({ islandLayout: progressLayout });
island.syncCustomLayout();
}
await pause(1100);
const half = await win.webContents.executeJavaScript(`(() => {
const root=document.querySelector('${layout === 'custom' ? '.resident-compact' : '.ci-fixed-lesson'}');
return Boolean(root&&root.textContent.includes('数学')&&root.textContent.includes('距下课')&&Number(root.querySelector('[role=progressbar]')?.getAttribute('aria-valuenow'))===50);
})()`);
if (!half)
throw new Error(
layout +
'课中进度错误:' +
(await win.webContents.executeJavaScript(
'document.querySelector(".resident-compact, .ci-fixed-lesson")?.textContent',
)),
);
await shot('lesson-progress-' + layout);
if (layout === 'custom') {
const countdownLayout = JSON.parse(JSON.stringify(fixture));
countdownLayout.schemes[0].rows[0].nodes = [islandNode('countdown', 'lesson-progress-countdown')];
saveConfig({ islandLayout: countdownLayout });
island.syncCustomLayout();
await pause(1100);
if (
!(await win.webContents.executeJavaScript(
`document.querySelector('.resident-compact .countdown')?.textContent.includes('距下课 22:30')`,
))
)
throw new Error('自动倒计时未切到本节下课');
}
if (layout === 'custom') {
saveConfig({ islandLayout: { ...fixture, layout } });
island.syncCustomLayout();
}
await setTime(9, 44);
await pause(700);
if (island.getState().lessonReminder?.kind !== 'ending')
throw new Error(layout + '下课倒数状态错误:' + JSON.stringify(island.getState().lessonReminder));
const ending = await win.webContents.executeJavaScript(
`document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'}')?.textContent.includes('距下课还剩')`,
);
if (!ending) throw new Error(layout + '下课倒数未渲染');
await shot('lesson-ending-' + layout);
await setTime(9, 45);
island.setClassState({ inClass: false });
await pause(700);
if (island.getState().lessonReminder?.kind !== 'break') return false;
await setTime(9, 45, 3);
await pause(1100);
const next = await win.webContents.executeJavaScript(
`document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'}')?.textContent.includes('语文')`,
);
if (!next) return false;
await shot('lesson-break-' + layout);
await setTime(9, 45, 13);
if (island.getState().lessonReminder) return false;
}
return true;
} finally {
if (recording) win.webContents.endFrameSubscription();
if (capture && process.env.ISLAND_SHOTS_DIR) {
const dir = path.join(process.env.ISLAND_SHOTS_DIR, 'lesson-motion-frames');
fs.mkdirSync(dir, { recursive: true });
recorded.forEach((f, i) => fs.writeFileSync(path.join(dir, `${i}.png`), f.image.toPNG()));
fs.writeFileSync(path.join(dir, 'times.json'), JSON.stringify(recorded.map((f) => f.at - start)));
}
await win.webContents.executeJavaScript(
`Date.now=window.__lessonRealNow;delete window.__lessonRealNow;delete window.__lessonSmokeTime`,
);
island.setLessonReminderTestTime(null);
island.setClassState({ inClass: false, schedules: [] });
island.setTouchMode(originalTouchMode);
island.setAppearance(cueAppearance);
saveConfig({ islandLayout: fixture });
island.syncCustomLayout();
await pause(800);
}
});
await check('长文本多行与2.5倍字号自动适配,变窄窗口和底部右侧停靠均不溢出', async () => {
const appearance = island.getAppearance();
const dense = JSON.parse(JSON.stringify(fixture));
dense.schemes[0].scale = 2.5;
dense.schemes[0].separated = true;
dense.schemes[0].rows[0].nodes[2].settings.text = '自适应常驻内容'.repeat(18);
dense.schemes[0].rows.push({ ...dense.schemes[0].rows[0], id: 'resident-dense-row' });
saveConfig({ islandLayout: dense });
island.syncCustomLayout();
island.setAppearance({ position: 'bottom-right' });
await pause(800);
// 模拟更小的工作区画布,检查响应的是 resize,而不是上一次固定像素尺寸。
const box = win.getBounds();
win.setBounds({ ...box, width: 360, height: 260 });
await pause(800);
const geometry = await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.resident-card'), page=card.querySelector('.page-active'), source=page.querySelector('.resident-source');
const r=card.getBoundingClientRect(), c=source.getBoundingClientRect();
return {r:r.toJSON(), c:c.toJSON(), width:innerWidth,height:innerHeight, fits:r.width<=340&&r.height<=240&&r.right<=innerWidth&&r.bottom<=innerHeight&&c.left>=r.left-1&&c.right<=r.right+1&&c.bottom<=r.bottom+1&&getComputedStyle(card).overflowY!=='auto'&&card.classList.contains('resident-separated')};
})()`);
const hit = island.getHitRect();
const hitMatches = hit && Math.abs(hit.x - geometry.r.x) <= 2 && Math.abs(hit.y - geometry.r.y) <= 2;
await shot('resident-adaptive-narrow');
saveConfig({ islandLayout: fixture });
island.syncCustomLayout();
island.setAppearance(appearance);
await pause(800);
if (!geometry.fits || !hitMatches) throw new Error(JSON.stringify({ ...geometry, hit }));
return true;
});
await check('常驻消息较多及超长正文不产生滚动条,底部操作始终可见', async () => {
for (let i = 0; i < 8; i++)
island.pushNotification({
...message('resident-overflow-' + i, '较多通知 ' + i, 'NORMAL'),
content: '超长正文'.repeat(200),
});
island.handleAction({ action: 'expand' });
await pause(800);
island.handleAction({ action: 'expand-list' });
await pause(800);
const geometry = await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.resident-card'), footer=card.querySelector('.classic-notifications .foot'), r=card.getBoundingClientRect(), f=footer?.getBoundingClientRect();
return {r:r.toJSON(),f:f?.toJSON(), fits:Boolean(f&&f.bottom<=r.bottom+1&&f.left>=r.left&&f.right<=r.right+1&&(card.querySelector('.list-hint')||card.querySelectorAll('.list-row').length===8)&&[card,...card.querySelectorAll('*')].every(n=>!['auto','scroll'].includes(getComputedStyle(n).overflowY)))};
})()`);
await shot('resident-many-notifications');
island.handleAction({ action: 'dismiss-all' });
await pause(800);
if (!geometry.fits) throw new Error(JSON.stringify(geometry));
return true;
});
await check('触摸常驻岛停稳后只保留岛体输入区域,开合不缩放原生窗口', async () => {
if (process.platform !== 'win32') return true;
const original = win.setShape.bind(win);
let shape: Electron.Rectangle[] = [];
win.setShape = (rects) => {
shape = rects;
original(rects);
};
try {
island.setTouchMode(true);
const box = win.getBounds();
island.handleAction({ action: 'expand' });
await pause(1000);
island.handleAction({ action: 'collapse' });
await pause(1000);
const hit = island.getHitRect();
return Boolean(
hit &&
shape.length === 1 &&
shape[0]!.width <= hit.width + 18 &&
shape[0]!.height <= hit.height + 18 &&
JSON.stringify(box) === JSON.stringify(win.getBounds()),
);
} finally {
win.setShape = original;
island.setTouchMode(false);
}
});
await check('编辑入口交给主窗口,浮岛不会变成额外的全屏编辑窗口', async () => {
const before = win.getBounds();
let opened = false;
island.setEditLayoutHandler(() => {
opened = true;
});
island.editLayout();
await pause(100);
return (
opened &&
!island.getEditingLayout() &&
JSON.stringify(before) === JSON.stringify(win.getBounds()) &&
!(await win.webContents.executeJavaScript('Boolean(document.querySelector(".layout-editor"))'))
);
});
await check('通知、作业、叫人均使用不透明背景和原生窗口,收起后恢复常驻透明度', async () => {
const appearance = island.getAppearance();
try {
island.setAppearance({ style: 'glass', opacity: 0.45, animations: false });
island.setClassState({ inClass: false, schedules: [] });
for (const layout of ['custom', 'fixed'] as const) {
saveConfig({ islandLayout: { ...fixture, layout } });
island.syncCustomLayout();
await pause(400);
for (const kind of ['notification', 'homework', 'call'] as const) {
island.handleAction({ action: 'dismiss-all' });
island.pushNotification({
...message('opaque-' + layout + '-' + kind, '老师发来的消息', 'NORMAL'),
kind,
});
island.handleAction({ action: 'expand' });
await pause(300);
const solid = await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('${layout === 'custom' ? '.resident-card' : '.island-card'}');
if (!card?.classList.contains('message-surface')) return false;
const probe=document.createElement('span');probe.style.background='var(--wn-bg)';card.append(probe);
const color=getComputedStyle(probe).backgroundColor;probe.remove();
return color==='rgb(32, 32, 32)'&&!!card.querySelector('.classic-notifications');
})()`);
if (!solid || Math.abs(win.getOpacity() - 1) > 0.01 || island.getAppearance().opacity !== 0.45)
return false;
if (kind === 'notification') await shot('message-opaque-' + layout);
}
island.handleAction({ action: 'dismiss-all' });
await pause(300);
if (layout === 'custom' && Math.abs(win.getOpacity() - 0.45) > 0.02) return false;
}
return true;
} finally {
island.setAppearance(appearance);
saveConfig({ islandLayout: fixture });
island.syncCustomLayout();
}
});
await check('关闭常驻并选择经典岛后恢复原有隐藏方式', async () => {
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
saveConfig({ islandLayout: defaultIslandLayouts() });
island.syncCustomLayout();
await pause();
return (
!win.isVisible() &&
!getConfig().islandLayout.residentEnabled &&
(await win.webContents.executeJavaScript(`!document.querySelector('.custom-card')`))
);
});
island.setHitTestCursor(null);
island.setTouchMode(originalTouchMode);
island.setAppearance(originalAppearance);
}
+39 -7
View File
@@ -1,3 +1,4 @@
import { verifyLayoutSmoke } from './smoke-layout.js';
import fs from 'node:fs';
import { runQuickSmoke } from './smoke-quick.js';
import path from 'node:path';
@@ -2160,7 +2161,7 @@ async function runIslandChecks(
}
return count;
};
const collapseResidue = { shots: 0, max: 0 };
const collapseResidue = { shots: 0, stableShots: 0, max: 0 };
await islandWindow?.webContents.executeJavaScript(
`(() => {
const button = document.querySelector('.island-card.expanded .icon-btn');
@@ -2169,23 +2170,46 @@ async function runIslandChecks(
})()`,
);
for (let attempt = 0; attempt < 22; attempt += 1) {
const before = await readIslandGeometryFrom(islandWindow!);
const image = await grabScreen();
const geometry = await readIslandGeometryFrom(islandWindow!);
if (image && geometry) {
if (image && geometry && before) {
collapseResidue.shots += 1;
// desktopCapturer 返回采样期间的帧;后读 DOM 已可能收拢,不能把合法的上一动画帧算作残影。
// 动画期间排除前后矩形的并集;仍要求至少四张稳态帧,稳态只排除当前岛体。
const left = Math.min(before.island.x, geometry.island.x);
const top = Math.min(before.island.y, geometry.island.y);
const right = Math.max(
before.island.x + before.island.width,
geometry.island.x + geometry.island.width,
);
const bottom = Math.max(
before.island.y + before.island.height,
geometry.island.y + geometry.island.height,
);
if (
Math.abs(before.island.width - geometry.island.width) < 1 &&
Math.abs(before.island.height - geometry.island.height) < 1
)
collapseResidue.stableShots += 1;
const bitmap = image.toBitmap();
const shotScale = image.getSize().width / Math.max(1, displayForCollapse.size.width);
collapseResidue.max = Math.max(
collapseResidue.max,
ringResidue(bitmap, shotScale, geometry.island, geometry.window),
ringResidue(
bitmap,
shotScale,
{ x: left, y: top, width: right - left, height: bottom - top },
geometry.window,
),
);
}
await sleep(30);
}
record(
'触摸模式收起:窗口带内不出现残影(垫底板逐帧检查,窗口让出的区域不留上一帧)',
collapseResidue.shots >= 4 && collapseResidue.max <= 400,
`${collapseResidue.shots} 帧采样,窗口带内岛像素峰值=${collapseResidue.max}(该带本该全是底板色)`,
collapseResidue.stableShots >= 4 && collapseResidue.max <= 400,
`${collapseResidue.shots} 帧采样(稳态 ${collapseResidue.stableShots} 帧),窗口带内岛像素峰值=${collapseResidue.max}(该带本该全是底板色)`,
);
island.setTouchMode(false);
await sleep(200);
@@ -3268,8 +3292,8 @@ async function runIslandChecks(
await sleep(400);
const opacityActual = islandWindow?.getOpacity() ?? -1;
record(
'个性设置:透明度生效(窗口 setOpacity)',
Math.abs(opacityActual - 0.62) < 0.03,
'消息展示时保持不透明,同时保留用户透明度设置',
Math.abs(opacityActual - 1) < 0.03 && island.getAppearance().opacity === 0.62,
`设置=0.62 实际=${opacityActual.toFixed(2)}`,
);
@@ -4603,6 +4627,14 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
}
};
await verifyLayoutSmoke(async (name, action) => {
try {
const value = await action();
record(name, value !== false, typeof value === 'string' ? value : '');
} catch (error) {
record(name, false, String(error));
}
});
writeResult();
// 托盘与退出清理放在最后:会隐藏主窗口并销毁托盘/灵动岛
+2
View File
@@ -1,3 +1,4 @@
import { island } from './island.js';
import fs from 'node:fs';
import path from 'node:path';
import { Menu, Tray, app, nativeImage } from 'electron';
@@ -38,6 +39,7 @@ export function createTray(handlers: TrayHandlers): Tray | null {
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示主窗口', click: () => handlers.onShow() },
{ label: '编辑灵动岛布局', click: () => island.editLayout() },
{ label: '隐藏到托盘', click: () => handlers.onHide() },
{ type: 'separator' },
{ label: '退出 ClassHelper', click: () => handlers.onQuit() },
@@ -1,3 +1,4 @@
import type { ReleaseProgressDto as ContractReleaseProgressDto, CoursewareTransfer } from '@classhelper/shared';
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
import type { ClientAgentStatus } from '../main/client-agent.js';
@@ -19,6 +20,21 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
*/
const bridge: DesktopBridge = {
downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id),
revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id),
syncCourseware: () => ipcRenderer.invoke('courseware:sync'),
getCoursewareTransfer: () => ipcRenderer.invoke('courseware:get-progress'),
onCoursewareTransfer: (handler) => {
const listener = (_event: unknown, value: CoursewareTransfer | null) => handler(value);
ipcRenderer.on('courseware:progress', listener);
return () => ipcRenderer.removeListener('courseware:progress', listener);
},
islandEditLayout: () => ipcRenderer.invoke('island:edit-layout'),
onIslandEditorOpen: (handler) => {
const listener = () => handler();
ipcRenderer.on('island:open-editor', listener);
return () => ipcRenderer.removeListener('island:open-editor', listener);
},
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
// 冒烟凭据:只在冒烟模式下透传,供渲染进程的冒烟钩子构造测试数据用。
// **不走代码字面量**——本文件与渲染产物都会进 app.asar,写死账号等于随安装包把教师口令发出去。
@@ -31,6 +47,12 @@ const bridge: DesktopBridge = {
classPassword: process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '',
}
: undefined,
getUpdateProgress: () => ipcRenderer.invoke('client:get-update-progress'),
onUpdateProgress: (handler: (progress: ContractReleaseProgressDto) => void) => {
const listener = (_event: unknown, value: ContractReleaseProgressDto) => handler(value);
ipcRenderer.on('client:update-progress', listener);
return () => ipcRenderer.removeListener('client:update-progress', listener);
},
getConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.getConfig),
saveConfig: (patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig> =>
ipcRenderer.invoke(IPC_CHANNELS.saveConfig, patch),
@@ -1,3 +1,7 @@
import type {
IslandLayoutSettings as ContractIslandLayoutSettings,
ScheduleDto as ContractScheduleDto,
} from '@classhelper/shared';
import { contextBridge, ipcRenderer } from 'electron';
import type { IslandAppearance, IslandState } from '@classhelper/shared';
@@ -6,6 +10,21 @@ import type { IslandAppearance, IslandState } from '@classhelper/shared';
* 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
*/
export interface IslandBridge {
getLayout(): Promise<ContractIslandLayoutSettings>;
saveLayout(value: ContractIslandLayoutSettings): Promise<ContractIslandLayoutSettings>;
onLayout(handler: (value: ContractIslandLayoutSettings) => void): void;
getEditing(): Promise<boolean>;
getVisible(): Promise<boolean>;
onVisibility(handler: (visible: boolean) => void): void;
setEditing(value: boolean): Promise<void>;
onEditing(handler: (value: boolean) => void): void;
reportSize(value: { width: number; height: number }): void;
reportResidentControl(value: { x: number; y: number; width: number; height: number } | null): void;
noteResidentTouch(): void;
onViewport(handler: (value: { width: number; height: number }) => void): () => void;
getSchedules(): Promise<ContractScheduleDto[]>;
onSchedules(handler: (value: ContractScheduleDto[]) => void): void;
onState(handler: (state: IslandState) => void): void;
sendAction(
action:
@@ -38,6 +57,34 @@ export interface IslandBridge {
}
const bridge: IslandBridge = {
getLayout: () => ipcRenderer.invoke('island:get-layout'),
saveLayout: (value) => ipcRenderer.invoke('island:save-layout', value),
onLayout: (handler) => {
ipcRenderer.on('island:layout', (_event, value) => handler(value));
},
getEditing: () => ipcRenderer.invoke('island:get-editing'),
getVisible: () => ipcRenderer.invoke('island:get-visible'),
onVisibility: (handler) => {
ipcRenderer.on('island:visibility', (_event, value) => handler(value));
},
setEditing: (value) => ipcRenderer.invoke('island:set-editing', value),
onEditing: (handler) => {
ipcRenderer.on('island:editing', (_event, value) => handler(value));
},
reportSize: (value) => ipcRenderer.send('island:content-size', value),
reportResidentControl: (value) => ipcRenderer.send('island:resident-control', value),
noteResidentTouch: () => ipcRenderer.send('island:resident-touch'),
onViewport: (handler) => {
const listener = (_event: Electron.IpcRendererEvent, value: { width: number; height: number }) =>
handler(value);
ipcRenderer.on('island:viewport', listener);
return () => ipcRenderer.removeListener('island:viewport', listener);
},
getSchedules: () => ipcRenderer.invoke('island:get-schedules'),
onSchedules: (handler) => {
ipcRenderer.on('island:schedules', (_event, value) => handler(value));
},
onState: (handler) => {
ipcRenderer.on('island:state', (_event, state: IslandState) => handler(state));
},
+2 -1
View File
@@ -10,6 +10,7 @@ import { getCacheScope, scopedKey } from './scope.js';
*/
export const CACHE_STORES = [
'courseware',
'profile',
'classes',
'schedules',
@@ -32,7 +33,7 @@ export interface CacheStoreStat {
}
const DB_NAME = 'classhelper-cache';
const DB_VERSION = 1;
const DB_VERSION = 2;
let databasePromise: Promise<IDBDatabase> | null = null;
+151
View File
@@ -1192,9 +1192,160 @@ export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
}
}
/** 本地替换 API,不连接真实服务:覆盖变更推送、重连与迟到响应。 */
export async function islandScheduleSyncSelfTest(): Promise<SmokeCheckResult> {
const [{ api }, { useAuthStore }, { useAppStore }, { useRealtimeStore }, bridge, { SOCKET_EVENTS }] =
await Promise.all([
import('../api/http.js'),
import('../stores/auth.js'),
import('../stores/app.js'),
import('../stores/realtime.js'),
import('../island/bridge.js'),
import('@classhelper/shared'),
]);
const auth = useAuthStore(),
app = useAppStore(),
realtime = useRealtimeStore();
const token = auth.token,
week = app.currentWeek,
originalGet = api.get,
originalOn = realtime.on;
const callbacks = new Map<string, (value: unknown) => void>();
const terms: Array<(value: unknown) => void> = [];
let name = '变更后课程',
calls = 0,
delay = false,
release: (() => void) | undefined;
const snapshot = (courseName: string) => ({
days: [
{
schedules: [
{
id: 'local-live-course',
classId: 'local',
courseId: 'local-course',
dayOfWeek: new Date().getDay() || 7,
startTime: '00:00',
endTime: '23:59',
weekStart: 1,
weekEnd: 40,
weekParity: 'ALL',
course: { id: 'local-course', name: courseName },
},
],
},
],
});
bridge.stopIslandBridge();
const key = `week:${week}`,
cached = await cacheGet('schedules', key);
try {
auth.applyToken('local-smoke-schedule');
api.get = <T>(url: string): Promise<T> => {
if (url === '/schedules/active-week') {
calls++;
const data = snapshot(name);
if (delay) {
delay = false;
return new Promise<T>((resolve) => {
release = () => resolve(data as T);
});
}
return Promise.resolve(data as T);
}
// 模拟很慢的学期接口,不能再挡住课表推送。
return new Promise<T>((resolve) => terms.push((value) => resolve(value as T)));
};
realtime.on = (event, handler) => {
callbacks.set(event, handler);
originalOn(event, handler);
};
bridge.startIslandBridge();
const wait = async (expected: string) => {
const deadline = performance.now() + 700;
while (performance.now() < deadline) {
if (bridge.getIslandSchedules()[0]?.course?.name === expected) return true;
await new Promise((r) => setTimeout(r, 10));
}
return false;
};
const started = performance.now();
callbacks.get(SOCKET_EVENTS.scheduleUpdated)?.({});
const changed = await wait(name),
elapsed = performance.now() - started;
name = '断线重连课程';
callbacks.get('__connected__')?.({});
const reconnected = await wait(name);
name = '迟到的旧课程';
delay = true;
const old = bridge.refreshIslandSchedules();
await new Promise((r) => setTimeout(r, 10));
name = '最新课程';
await bridge.refreshIslandSchedules();
release?.();
await old;
const fresh = bridge.getIslandSchedules()[0]?.course?.name === name;
const saved = await cacheGet<Array<{ course?: { name: string } }>>('schedules', key);
const cacheFresh = saved?.value[0]?.course?.name === name;
return {
ok: changed && reconnected && fresh && cacheFresh && calls === 5,
detail: `本地推送刷新 ${Math.round(elapsed)}ms,重连=${reconnected},迟到响应隔离=${fresh && cacheFresh},课表请求=${calls}`,
};
} finally {
bridge.stopIslandBridge();
api.get = originalGet;
realtime.on = originalOn;
for (const resolve of terms) resolve({ currentWeek: week, maxWeek: 40 });
auth.applyToken(token);
app.currentWeek = week;
if (cached) await cacheSet('schedules', key, cached.value);
else await cacheRemove('schedules', key);
}
}
let restoreIslandEditorSmoke: (() => Promise<void>) | undefined;
/** 主窗口内编辑器验收使用纯本地 API 替身,结束后恢复登录态、路由与方法。 */
async function islandEditorSmoke(open: boolean): Promise<void> {
if (!open) {
await restoreIslandEditorSmoke?.();
restoreIslandEditorSmoke = undefined;
return;
}
const [{ api }, { useAuthStore }, { router }] = await Promise.all([
import('../api/http.js'),
import('../stores/auth.js'),
import('../router/index.js'),
]);
const auth = useAuthStore(),
token = auth.token,
path = router.currentRoute.value.fullPath,
originalGet = api.get;
const onboardingDone = (await window.desktop!.getConfig()).onboardingDone;
await window.desktop!.saveConfig({ onboardingDone: true });
api.get = (async (url: string) => {
if (url.includes('unread-count')) return { count: 0 };
if (url.includes('active-week')) return { days: [] };
if (url.includes('term')) return { currentWeek: 1, maxWeek: 40 };
return [];
}) as typeof api.get;
auth.applyToken('local-editor-smoke');
const { useOnboardingStore } = await import('../stores/onboarding.js');
useOnboardingStore().close();
restoreIslandEditorSmoke = async () => {
await router.push('/login');
auth.applyToken(token);
await router.push(path);
api.get = originalGet;
await window.desktop!.saveConfig({ onboardingDone });
};
await router.push('/settings/island/editor');
}
/** 注册到 window,供主进程冒烟脚本调用 */
export function registerSmokeHooks(): void {
window.__classhelperSmoke__ = {
islandEditorSmoke,
islandScheduleSyncSelfTest,
cacheSelfTest,
cacheIsolationSelfTest,
offlineScenario,
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
ISLAND_COMPONENT_LABELS,
type IslandComponentNode,
type IslandComponentKind,
} from '@classhelper/shared';
const props = defineProps<{ node: IslandComponentNode }>();
const emit = defineEmits<{ change: []; remove: []; duplicate: []; wrap: [kind: IslandComponentKind] }>();
const node = computed(() => props.node),
tab = ref('content');
watch(
() => props.node.id,
() => {
tab.value = 'content';
},
);
const fonts = {
secondaryFontSize: '辅助文字',
bodyFontSize: '正文',
emphasizedFontSize: '强调文字',
largeFontSize: '大号文字',
} as const;
const containers = ['group', 'stack', 'slide', 'rolling'] as const;
</script>
<template>
<div class="component-settings">
<h3>{{ ISLAND_COMPONENT_LABELS[node.kind] }}</h3>
<el-tabs v-model="tab" stretch>
<el-tab-pane label="内容" name="content">
<el-form label-position="top">
<template v-if="node.kind === 'clock'">
<el-form-item label="12 小时制">
<el-switch v-model="node.settings.hour12" @change="emit('change')" />
</el-form-item>
<el-form-item label="显示秒">
<el-switch v-model="node.settings.seconds" @change="emit('change')" />
</el-form-item>
</template>
<el-form-item v-else-if="node.kind === 'countdown'" label="倒计时目标">
<el-select v-model="node.settings.target" @change="emit('change')">
<el-option label="自动:距上课 / 下课" value="auto" />
<el-option label="下一节课" value="next" />
<el-option label="本节下课" value="end" />
<el-option label="放学" value="school" />
</el-select>
</el-form-item>
<template v-else-if="node.kind === 'text'">
<el-form-item label="文字内容">
<el-input v-model="node.settings.text" type="textarea" :rows="3" @change="emit('change')" />
</el-form-item>
<el-form-item label="启用模板变量">
<el-switch v-model="node.settings.template" @change="emit('change')" />
</el-form-item>
<p class="hint">支持 {date} 日期、{time} 时间、{course} 当前课程。</p>
</template>
<template v-else-if="node.kind === 'schedule'">
<el-form-item label="显示课程">
<el-select v-model="node.settings.display" @change="emit('change')">
<el-option label="当前课程" value="current" />
<el-option label="下一节课" value="next" />
<el-option label="今日课表" value="today" />
</el-select>
</el-form-item>
<el-form-item v-if="node.settings.display === 'today'" label="显示节数">
<el-input-number v-model="node.settings.count" :min="1" :max="30" @change="emit('change')" />
</el-form-item>
<el-form-item label="显示课程时间">
<el-switch v-model="node.settings.showPeriod" @change="emit('change')" />
</el-form-item>
<el-form-item label="显示上课进度">
<el-switch v-model="node.settings.showProgress" @change="emit('change')" />
</el-form-item>
</template>
<template v-else-if="containers.includes(node.kind as (typeof containers)[number])">
<el-form-item label="排列方向">
<el-select v-model="node.settings.direction" @change="emit('change')">
<el-option label="横向" value="horizontal" />
<el-option label="纵向" value="vertical" />
</el-select>
</el-form-item>
<el-form-item label="组件间距">
<el-input-number v-model="node.settings.gap" :min="0" :max="80" @change="emit('change')" />
</el-form-item>
<el-form-item v-if="node.kind === 'slide'" label="切换间隔(秒)">
<el-input-number
v-model="node.settings.interval"
:min="1"
:max="300"
@change="emit('change')"
/>
</el-form-item>
<el-form-item v-if="node.kind === 'rolling'" label="滚动速度">
<el-input-number v-model="node.settings.speed" :min="1" :max="200" @change="emit('change')" />
</el-form-item>
</template>
<p v-else class="hint">此组件使用实时数据。可在外观和高级设置中调整显示。</p>
<el-divider />
<el-form-item label="包裹到容器">
<el-select
data-action="wrap"
model-value=""
placeholder="选择容器类型"
@change="(kind: IslandComponentKind) => emit('wrap', kind)"
>
<el-option
v-for="kind in containers"
:key="kind"
:label="ISLAND_COMPONENT_LABELS[kind]"
:value="kind"
/>
</el-select>
</el-form-item>
<div class="actions">
<el-button @click="emit('duplicate')">复制组件</el-button>
<el-button type="danger" plain @click="emit('remove')">删除</el-button>
</div>
</el-form>
</el-tab-pane>
<el-tab-pane label="外观" name="appearance">
<el-form label-position="top">
<el-form-item label="独立外观">
<el-switch v-model="node.appearance.resourceOverridingEnabled" @change="emit('change')" />
</el-form-item>
<p class="hint">关闭时沿用灵动岛的整体外观。</p>
<el-form-item v-for="(label, key) in fonts" :key="key" :label="`${label}字号`">
<el-input-number
v-model="node.appearance[key]"
:disabled="!node.appearance.resourceOverridingEnabled"
:min="8"
:max="96"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="自定义文字颜色">
<el-switch
v-model="node.appearance.customForegroundEnabled"
:disabled="!node.appearance.resourceOverridingEnabled"
@change="emit('change')"
/>
<el-color-picker
v-if="node.appearance.customForegroundEnabled"
v-model="node.appearance.foreground"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="自定义背景颜色">
<el-switch
v-model="node.appearance.customBackgroundEnabled"
:disabled="!node.appearance.resourceOverridingEnabled"
@change="emit('change')"
/>
<el-color-picker
v-if="node.appearance.customBackgroundEnabled"
v-model="node.appearance.background"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="自定义背景透明度">
<el-switch
v-model="node.appearance.customBackgroundOpacityEnabled"
:disabled="!node.appearance.resourceOverridingEnabled"
@change="emit('change')"
/>
<el-input-number
v-if="node.appearance.customBackgroundOpacityEnabled"
v-model="node.appearance.backgroundOpacity"
:min="0"
:max="1"
:step="0.05"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="自定义圆角">
<el-switch
v-model="node.appearance.customCornerRadiusEnabled"
:disabled="!node.appearance.resourceOverridingEnabled"
@change="emit('change')"
/>
<el-input-number
v-if="node.appearance.customCornerRadiusEnabled"
v-model="node.appearance.cornerRadius"
:min="0"
:max="100"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="组件透明度">
<el-slider
v-model="node.appearance.opacity"
:min="0"
:max="1"
:step="0.05"
@change="emit('change')"
/>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="高级" name="advanced">
<el-form label-position="top">
<el-form-item label="对齐方式">
<el-select v-model="node.advanced.align" @change="emit('change')">
<el-option
v-for="(label, key) in { stretch: '拉伸', start: '起始', center: '居中', end: '末端' }"
:key="key"
:label="label"
:value="key"
/>
</el-select>
</el-form-item>
<el-form-item label="固定宽度(px)">
<el-input-number v-model="node.advanced.width" :min="0" :max="2000" @change="emit('change')" />
</el-form-item>
<el-form-item label="最大宽度(px)">
<el-input-number v-model="node.advanced.maxWidth" :min="0" :max="2000" @change="emit('change')" />
</el-form-item>
<p class="hint">宽度为 0 时自适应;对齐在父容器设定宽度时生效。</p>
<el-form-item label="隐藏条件">
<el-checkbox-group v-model="node.advanced.hideRules" @change="emit('change')">
<el-checkbox label="上课时" value="in-class" />
<el-checkbox label="没有消息时" value="no-message" />
<el-checkbox label="没有当前课程时" value="no-class" />
</el-checkbox-group>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
h3 {
margin: 0 0 6px;
}
.hint {
color: var(--ch-text-secondary);
line-height: 1.6;
font-size: 12px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.el-select {
width: 100%;
}
.el-form-item :deep(.el-form-item__content) {
gap: 10px;
}
.el-checkbox-group {
display: flex;
flex-direction: column;
}
</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import type { ReleaseProgressDto } from '@classhelper/shared';
const progress = ref<ReleaseProgressDto>();
let off: (() => void) | undefined;
const labels = {
idle: '尚未下载',
downloading: '下载中',
verifying: '校验中',
ready: '完成',
waiting: '等待安装策略',
installing: '安装中',
failed: '失败',
};
onMounted(async () => {
off = window.desktop?.onUpdateProgress((p) => (progress.value = p));
progress.value = await window.desktop?.getUpdateProgress();
});
onUnmounted(() => off?.());
</script>
<template>
<section v-if="progress && progress.stage !== 'idle'" role="status">
<h3>客户端更新 · {{ labels[progress.stage] }}</h3>
<el-progress :percentage="progress.percent ?? 0" />
<p>{{ progress.version }} {{ progress.error }}</p>
</section>
</template>
@@ -8,7 +8,7 @@
*
* 客户端相较 Web 端多一层现实约束:教室机器多为**希沃触摸屏**,
* 触摸点按会产生粘住的幽灵 hover,因此 `useHoverCapable()` 在客户端是**必需**的,
* 不是锦上添花(见 AGENTS.md §5 第 30 条)。
* 不是锦上添花(见 AGENTS.md 的现行工程约定)。
*/
import { computed, onUnmounted, ref, watch, type CSSProperties, type Ref } from 'vue';
import {
@@ -11,6 +11,7 @@ import {
Connection,
Expand,
Fold,
FolderOpened,
FullScreen,
Guide,
InfoFilled,
@@ -62,6 +63,7 @@ const APP_ICONS = {
Connection,
Expand,
Fold,
FolderOpened,
FullScreen,
Guide,
InfoFilled,
@@ -1,3 +1,4 @@
import type { ActiveClassPlanWeekDto as ContractActiveClassPlanWeekDto } from '@classhelper/shared';
import { watch, type WatchStopHandle } from 'vue';
import {
SOCKET_EVENTS,
@@ -7,7 +8,8 @@ import {
type NotificationDto,
type ScheduleDto,
} from '@classhelper/shared';
import { notificationApi, scheduleApi } from '../api/index.js';
import { api } from '../api/http.js';
import { notificationApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { getCacheScope } from '../cache/scope.js';
import { clearOfflineSince, readOfflineSince, useAppStore } from '../stores/app.js';
@@ -21,11 +23,11 @@ import { useRealtimeStore } from '../stores/realtime.js';
* 职责:把"当前是否在上课时间段"这件事算出来并同步给主进程 ——
* 主进程据此决定通知是"立即弹出"还是"上课期间隐藏、下课后自动弹出"。
*
* 判定完全基于本地缓存的课表(离线也能算),并每 10 秒复算一次,
* 因此下课后最多 10 秒内会自动弹出暂存的通知。
* 判定完全基于本地缓存的课表(离线也能算),并每秒复算一次,
* 因此下课后最多 1 秒内会自动弹出暂存的通知。
*/
const TICK_INTERVAL_MS = 10_000;
const REFRESH_INTERVAL_MS = 5 * 60_000;
const TICK_INTERVAL_MS = 1_000;
const REFRESH_INTERVAL_MS = 30_000;
let schedules: ScheduleDto[] = [];
let currentPeriod: ClassPeriod | null = null;
@@ -52,36 +54,55 @@ export async function refreshIslandSchedules(): Promise<void> {
if (!auth.token) return;
const scope = getCacheScope();
const classId = auth.classId ?? undefined;
await appStore.refreshTerm(classId);
if (scope !== getCacheScope() || !auth.token) return;
const request = ++scheduleRequest;
if (scope !== getCacheScope() || !auth.token || request !== scheduleRequest) return;
const week = appStore.currentWeek;
const result = await fetchWithCache<ScheduleDto[]>(
'schedules',
`week:${week}`,
() => scheduleApi.list({ classId, week }),
async () => {
const data = (
await api.get<ContractActiveClassPlanWeekDto>('/schedules/active-week', { classId, week })
).days.flatMap((day) => day.schedules);
// 网络结果先上岛,IndexedDB 写入不再挡住可见内容;旧请求与切换账号的结果不得回写。
if (scope !== getCacheScope() || request !== scheduleRequest || !auth.token)
throw new Error('课表刷新已被新请求替代');
schedules = data;
scheduleWeek = week;
recomputeClassState(true);
return data;
},
[],
);
if (scope !== getCacheScope() || request !== scheduleRequest) return;
if (result.error && !result.updatedAt) return;
if (result.fromCache) {
schedules = result.data;
scheduleWeek = week;
recomputeClassState(true);
}
}
let sentDay = '';
function recomputeClassState(force = false): void {
const day = new Date().toDateString();
const appStore = useAppStore();
const status = resolveClassStatus(schedules, new Date(), scheduleWeek);
const nextInClass = status.inClass;
const samePeriod =
currentPeriod?.scheduleId === status.current?.scheduleId &&
currentPeriod?.endTime === status.current?.endTime;
currentPeriod = status.current;
if (!force && nextInClass === inClass) return;
if (!force && day === sentDay && nextInClass === inClass && samePeriod) return;
sentDay = day;
inClass = nextInClass;
window.desktop?.islandSetClassState({
inClass,
currentPeriodEnd: currentPeriod?.endTime ?? null,
week: appStore.currentWeek,
schedules: schedules.filter((s) => s.dayOfWeek === (new Date().getDay() || 7)),
});
}
@@ -320,6 +341,10 @@ export function subscribeIslandMarkAllRead(): void {
/** 具名的课表变更处理器:匿名函数在停止时无法 off 掉,会随"登出→登录"越挂越多 */
function onScheduleUpdated(): void {
// 教学周校正并行进行,课表变更不再等待另一个接口;周次改变由 watch 补取。
void useAppStore()
.refreshTerm(useAuthStore().classId ?? undefined)
.catch(() => undefined);
void refreshIslandSchedules().catch(() => undefined);
}
@@ -332,14 +357,15 @@ export function startIslandBridge(): void {
},
);
void refreshIslandSchedules().catch(() => undefined);
onScheduleUpdated();
useRealtimeStore().on('__connected__', onScheduleUpdated);
// 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏"
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS);
refreshTimer = setInterval(() => {
void refreshIslandSchedules().catch(() => undefined);
onScheduleUpdated();
}, REFRESH_INTERVAL_MS);
}
@@ -361,6 +387,8 @@ export function stopIslandBridge(): void {
stopWeekWatch = null;
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
useRealtimeStore().off('__connected__', onScheduleUpdated);
sentDay = '';
schedules = [];
currentPeriod = null;
inClass = false;
@@ -372,5 +400,6 @@ export function stopIslandBridge(): void {
inClass: false,
currentPeriodEnd: null,
week: useAppStore().currentWeek,
schedules: [],
});
}
@@ -20,6 +20,10 @@ const auth = useAuthStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const ui = useUiStore();
const stopEditorNavigation = window.desktop?.onIslandEditorOpen(
() => void router.push('/settings/island/editor'),
);
onUnmounted(() => stopEditorNavigation?.());
/**
* 主侧边栏。
@@ -36,6 +40,7 @@ const primaryItems = [
{ path: '/schedule', title: '课表', icon: 'Calendar' },
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
{ path: '/notifications', title: '通知', icon: 'Bell' },
{ path: '/courseware', title: '课件', icon: 'FolderOpened' },
{ path: '/grades', title: '成绩', icon: 'Trophy' },
];
@@ -18,6 +18,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('../layouts/ClientLayout.vue'),
redirect: '/schedule',
children: [
{ path: 'courseware', name: 'courseware', component: () => import('../views/CoursewareView.vue'), meta: { title: '课件' } },
{
path: 'schedule',
name: 'schedule',
@@ -65,6 +66,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/settings/IslandSettingsView.vue'),
meta: { title: '灵动岛' },
},
{
path: 'settings/island/editor',
name: 'settings-island-editor',
component: () => import('../views/settings/IslandEditorView.vue'),
meta: { title: '自定义灵动岛' },
},
{
path: 'settings/reminder',
name: 'settings-reminder',
@@ -1,3 +1,4 @@
import { getMaxTermWeeks } from '@classhelper/shared';
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { STORAGE_KEYS, formatDate } from '@classhelper/shared';
@@ -65,12 +66,12 @@ export const useAppStore = defineStore('app', () => {
const lastSyncAt = ref<number | null>(null);
const cacheStoreStats = ref<CacheStoreStat[]>([]);
const currentWeek = ref(1);
const maxWeek = ref(20);
const maxWeek = ref(getMaxTermWeeks());
const initialized = ref(false);
let termRequest = 0;
onCacheScopeChanged(() => {
currentWeek.value = 1;
maxWeek.value = 20;
maxWeek.value = getMaxTermWeeks();
termRequest += 1;
clearOfflineSince();
});
@@ -81,7 +82,7 @@ export const useAppStore = defineStore('app', () => {
const scope = getCacheScope();
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(classId), {
currentWeek: 1,
maxWeek: 20,
maxWeek: getMaxTermWeeks(),
});
if (request !== termRequest || scope !== getCacheScope() || (result.error && !result.updatedAt)) return;
currentWeek.value = result.data.currentWeek;
@@ -5,7 +5,7 @@ import { io, type Socket } from 'socket.io-client';
import {
PRIORITY_LABELS,
SOCKET_EVENTS,
type GradeDto,
type GradeUpdateEvent,
type HomeworkDto,
type NotificationDto,
type ServerToClientEvents,
@@ -48,6 +48,13 @@ export const useRealtimeStore = defineStore('realtime', () => {
}
function bind(instance: Socket): void {
instance.on(SOCKET_EVENTS.coursewareChanged, (payload: { id: string }) => {
dispatch(SOCKET_EVENTS.coursewareChanged, payload);
void window.desktop?.syncCourseware();
});
instance.on(SOCKET_EVENTS.clientReleaseProgress, (payload) => {
dispatch(SOCKET_EVENTS.clientReleaseProgress, payload);
});
instance.on('connect', () => {
connected.value = true;
connecting.value = false;
@@ -106,9 +113,13 @@ export const useRealtimeStore = defineStore('realtime', () => {
dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
});
instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeDto) => {
instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeUpdateEvent) => {
mark(SOCKET_EVENTS.gradeUpdated);
ElMessage.success(`成绩更新:${payload.examName} ${payload.score}/${payload.totalScore}`);
ElMessage.success(
'score' in payload
? `成绩更新:${payload.examName} ${payload.score}/${payload.totalScore}`
: '班级成绩分析已更新',
);
dispatch(SOCKET_EVENTS.gradeUpdated, payload);
});
@@ -784,7 +784,7 @@ textarea {
/*
* 卡片抬升。**必须关在 `@media (hover: hover) and (pointer: fine)` 里**:
* 教室机器的希沃触摸屏点一下会产生"幽灵 hover"并一直粘着,
* 不加这层的话卡片被点过之后就永远浮着(见 AGENTS.md §5 第 30 条)。
* 不加这层的话卡片被点过之后就永远浮着(见 AGENTS.md 的现行工程约定)。
* 这也正是 `composables/motion.ts` 里 `useHoverCapable()` 的 CSS 版本 ——
* 纯 CSS 效果用媒体查询就够了,不必为它挂一个 composable。
*/
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
COURSEWARE_CATEGORIES,
SOCKET_EVENTS,
type CoursewareDto,
type CoursewareTransfer,
} from '@classhelper/shared';
import { api } from '../api/http.js';
import { fetchWithCache } from '../cache/index.js';
import { getCacheScope } from '../cache/scope.js';
import { useRealtimeStore } from '../stores/realtime.js';
const realtime = useRealtimeStore();
const items = ref<CoursewareDto[]>([]),
loading = ref(false),
offline = ref(false),
error = ref('');
const query = ref(''),
category = ref(''),
date = ref(''),
busy = ref<string[]>([]),
transfer = ref<CoursewareTransfer | null>(null);
const filtered = computed(() =>
items.value.filter(
(item) =>
(!query.value || `${item.name} ${item.teacher.name}`.includes(query.value)) &&
(!category.value || item.category === category.value) &&
(!date.value || item.archiveDate === date.value),
),
);
let disposed = false;
async function refresh() {
const scope = getCacheScope();
loading.value = true;
try {
const result = await fetchWithCache<CoursewareDto[]>(
'courseware',
'list',
() => api.get('/courseware'),
[],
);
if (disposed || scope !== getCacheScope()) return;
items.value = result.data;
offline.value = result.fromCache;
error.value = result.error ?? '';
} finally {
loading.value = false;
}
}
async function download(item: CoursewareDto) {
if (!window.desktop) return;
busy.value.push(item.id);
try {
await window.desktop.downloadCourseware(item.id);
ElMessage.success('课件已下载,可打开所在文件夹');
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : String(e));
} finally {
busy.value = busy.value.filter((id) => id !== item.id);
}
}
async function reveal(id: string) {
try {
await window.desktop?.revealCourseware(id);
} catch (e) {
ElMessage.warning(e instanceof Error ? e.message : '请先下载课件');
}
}
let offProgress: (() => void) | undefined;
const onRefresh = () => void refresh();
realtime.on(SOCKET_EVENTS.coursewareChanged, onRefresh);
realtime.on('__connected__', onRefresh);
let timer: ReturnType<typeof setInterval>;
onMounted(() => {
void refresh();
timer = setInterval(() => void refresh(), 30000);
offProgress = window.desktop?.onCoursewareTransfer((value) => {
transfer.value = value;
});
void window.desktop?.getCoursewareTransfer().then((value) => {
if (!disposed) transfer.value = value;
});
});
onUnmounted(() => {
disposed = true;
clearInterval(timer);
offProgress?.();
realtime.off(SOCKET_EVENTS.coursewareChanged, onRefresh);
realtime.off('__connected__', onRefresh);
});
</script>
<template>
<div class="page courseware-page">
<header class="page-header">
<div>
<h2 class="page-title">课件</h2>
<p>查看本班可见的教学文件。老师推送的课件会自动接收并保存在本机。</p>
</div>
<el-button :loading="loading" @click="refresh">刷新</el-button>
</header>
<el-alert
v-if="offline || error"
:title="offline ? '当前显示离线缓存;联网后会重新校验可见范围。已下载文件可在本机文件夹查看。' : error"
type="warning"
:closable="false"
/>
<el-card v-if="transfer" shadow="never">
<strong>{{ transfer.name }}</strong>
<p>
{{ COURSEWARE_CATEGORIES[transfer.category] }} · {{ transfer.teacherName }} ·
{{
transfer.stage === 'completed' ? '接收完成' : transfer.stage === 'failed' ? '接收失败' : '正在接收'
}}
</p>
<el-progress
:percentage="transfer.progress"
:status="
transfer.stage === 'failed' ? 'exception' : transfer.stage === 'completed' ? 'success' : undefined
"
/>
<p v-if="transfer.error">{{ transfer.error }}</p>
</el-card>
<div class="filters">
<el-input v-model="query" clearable placeholder="搜索课件或教师" />
<el-select v-model="category" clearable placeholder="全部类型">
<el-option v-for="(label, key) in COURSEWARE_CATEGORIES" :key="key" :label="label" :value="key" />
</el-select>
<el-date-picker v-model="date" value-format="YYYY-MM-DD" placeholder="归档日期" />
</div>
<el-card shadow="never" class="table-card" v-loading="loading">
<el-table :data="filtered" empty-text="老师还没有向本班共享课件">
<el-table-column prop="name" label="课件名称" min-width="200" show-overflow-tooltip />
<el-table-column label="类型" min-width="110">
<template #default="{ row }">
{{ COURSEWARE_CATEGORIES[(row as CoursewareDto).category] }} · {{ row.extension || '无扩展名' }}
</template>
</el-table-column>
<el-table-column prop="teacher.name" label="上传教师" width="110" />
<el-table-column prop="archiveDate" label="归档日期" width="120" />
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ (row.size / 1048576).toFixed(2) }} MB</template>
</el-table-column>
<el-table-column label="操作" min-width="200">
<template #default="{ row }">
<el-button link type="primary" :loading="busy.includes(row.id)" @click="download(row)">
下载
</el-button>
<el-button link @click="reveal(row.id)">打开文件夹</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.courseware-page {
display: grid;
gap: 16px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 {
margin: 0;
}
p {
color: var(--ch-text-secondary);
line-height: 1.6;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.filters > * {
flex: 1;
min-width: 160px;
}
</style>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { CollectiveGradeAnalysisDto as ContractCollectiveGradeAnalysisDto } from '@classhelper/shared';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
SOCKET_EVENTS,
@@ -11,6 +13,7 @@ import {
type GradeDto,
type StudentGradeDetailDto,
} from '@classhelper/shared';
import { api } from '../api/http.js';
import { gradeApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import ScoreBarChart from '../components/ScoreBarChart.vue';
@@ -28,6 +31,7 @@ const realtime = useRealtimeStore();
const loading = ref(false);
const grades = ref<GradeDto[]>([]);
const collective = ref<ContractCollectiveGradeAnalysisDto | null>(null);
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
@@ -106,6 +110,31 @@ const examAverages = computed(() => {
async function loadGrades(): Promise<void> {
loading.value = true;
try {
if (auth.isClassSession) {
const result = await fetchWithCache<
ContractCollectiveGradeAnalysisDto | { mode: 'personal'; items: GradeDto[] }
>('grades', 'overview', () => api.get('/grades/overview'), {
mode: 'collective',
classId: auth.classId || '',
suppressed: true,
averagePercent: null,
passRate: null,
excellentRate: null,
subjects: [],
distribution: [],
trend: [],
});
collective.value = result.data.mode === 'collective' ? result.data : null;
grades.value = result.data.mode === 'personal' ? result.data.items : [];
if (collective.value) {
queried.value = null;
queryNo.value = '';
ranking.value = null;
}
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
return;
}
const result = await fetchWithCache<GradeDto[]>('grades', 'my', () => gradeApi.my(), []);
grades.value = result.data;
fromCache.value = result.fromCache;
@@ -134,6 +163,12 @@ const rankingLoading = ref(false);
const rankingError = ref('');
async function loadExams(): Promise<void> {
if (collective.value) {
examList.value = [];
selectedExam.value = '';
ranking.value = null;
return;
}
const classId = auth.user?.classId;
if (!classId) return;
try {
@@ -180,18 +215,43 @@ onMounted(async () => {
await loadGrades();
await loadExams();
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
realtime.on(SOCKET_EVENTS.classUpdated, onGradeEvent);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
realtime.off(SOCKET_EVENTS.classUpdated, onGradeEvent);
appStore.offServerRecovered(onRecovered);
});
</script>
<template>
<div class="page">
<div class="page-header">
<el-card v-if="collective">
<h2>全班集体成绩分析</h2>
<p>个人成绩查询已关闭;只展示服务端聚合结果。人数不足或稀疏分段不展示。</p>
<p>
班级平均得分率:{{ collective.averagePercent ?? '样本不足' }} · 及格率:{{
collective.passRate ?? '已隐藏'
}}
· 优秀率:{{ collective.excellentRate ?? '已隐藏' }}
</p>
<el-table :data="collective.subjects">
<el-table-column prop="name" label="科目" />
<el-table-column prop="averagePercent" label="平均得分率" />
</el-table>
<el-table :data="collective.distribution">
<el-table-column prop="label" label="分数段" />
<el-table-column prop="count" label="人数(稀疏时隐藏)" />
</el-table>
<el-table :data="collective.trend">
<el-table-column prop="examName" label="考试趋势" />
<el-table-column prop="averagePercent" label="平均得分率" />
</el-table>
</el-card>
<template v-if="!collective">
<div v-if="!collective" class="page-header">
<div>
<h2 class="page-title">{{ auth.isClassSession ? '本班成绩' : '我的成绩' }}</h2>
<p class="page-subtitle">
@@ -407,6 +467,7 @@ onUnmounted(() => {
</el-timeline-item>
</el-timeline>
</el-card>
</template>
</div>
</template>
@@ -30,9 +30,9 @@ const visibleItems = computed(() =>
/* ------------------------------------------------------- 按天归档 / 按发布人 / 按类型 */
/**
* 三个筛选都在**已加载的列表**上做,不再请求服务端:
* 客户端是离线优先的(AGENTS.md §5 第 10 条),断网回退缓存时归档功能必须照常可用。
* 客户端是离线优先的(AGENTS.md 的现行工程约定),断网回退缓存时归档功能必须照常可用。
* 口径与服务端一致:按 `assignDate`(本地日期)分组 —— 用 createdAt 的 UTC 切片会把晚上
* 发出来的通知算到第二天(AGENTS.md §5 第 19 条)。
* 发出来的通知算到第二天(AGENTS.md 的现行工程约定)。
*/
const dayFilter = ref('');
const creatorFilter = ref('');
@@ -1,4 +1,5 @@
<script setup lang="ts">
import UpdateProgress from '../../components/UpdateProgress.vue';
import { onMounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import type { UpdateInfo } from '@classhelper/shared';
@@ -70,6 +71,7 @@ onMounted(async () => {
<template>
<div class="page">
<UpdateProgress />
<div class="page-header">
<div>
<h2 class="page-title">关于</h2>
@@ -0,0 +1,757 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { onBeforeRouteLeave, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { AllowDropType } from 'element-plus/es/components/tree/src/tree.type';
import type TreeNode from 'element-plus/es/components/tree/src/model/node';
import {
CLASSISLAND_ISLAND_METRICS as METRICS,
DEFAULT_ISLAND_APPEARANCE,
ISLAND_COMPONENT_LABELS,
defaultIslandLayouts,
islandNode,
normalizeIslandLayouts,
type IslandComponentKind,
type IslandComponentNode,
type IslandLayoutRow,
type IslandState,
type IslandAppearance,
} from '@classhelper/shared';
import ComponentNode from '../../../island/ComponentNode.vue';
import ComponentIcon from '../../../island/ComponentIcon.vue';
import IslandComponentSettings from '../../components/IslandComponentSettings.vue';
import { getIslandSchedules } from '../../island/bridge.js';
const router = useRouter();
const settings = ref(defaultIslandLayouts()),
appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const schedules = ref(getIslandSchedules()),
now = ref(Date.now()),
ready = ref(false),
error = ref(''),
pending = ref(0),
saved = ref(false);
const surface = ref<'compact' | 'expanded'>('compact'),
panel = ref<'scheme' | 'row' | 'component'>('scheme');
const selectedId = ref(''),
rowId = ref(''),
libraryOpen = ref(false),
zoom = ref(1),
importInput = ref<HTMLInputElement>();
const scheme = computed(() =>
settings.value.schemes.find((item) => item.id === settings.value.activeSchemeId)!,
);
const rows = computed(() => (surface.value === 'expanded' ? scheme.value.expandedRows : scheme.value.rows));
const activeRow = computed(() => rows.value.find((row) => row.id === rowId.value) ?? rows.value[0]!);
const state: IslandState = {
mode: 'hidden',
active: null,
queued: [],
inClass: false,
currentPeriodEnd: null,
reason: null,
listExpanded: false,
maxCardHeight: 1024,
updatedAt: 0,
};
const groups = [
{ label: '日常信息', kinds: ['clock', 'date', 'schedule', 'countdown', 'text', 'separator'] },
{ label: '消息提醒', kinds: ['notification', 'homework', 'call'] },
{ label: '组合容器', kinds: ['group', 'stack', 'slide', 'rolling'] },
] as const;
const containers: readonly string[] = ['group', 'stack', 'slide', 'rolling'];
function id() {
return crypto.randomUUID();
}
function all(nodes: IslandComponentNode[]): IslandComponentNode[] {
return nodes.flatMap((node) => [node, ...all(node.children)]);
}
const selected = computed(() =>
rows.value.flatMap((row) => all(row.nodes)).find((node) => node.id === selectedId.value),
);
let saveQueue = Promise.resolve(),
revision = 0,
clock: ReturnType<typeof setInterval>;
function save() {
const snapshot = normalizeIslandLayouts(JSON.parse(JSON.stringify(settings.value)));
const current = ++revision;
pending.value++;
saved.value = false;
saveQueue = saveQueue.then(async () => {
try {
await window.desktop?.saveConfig({ islandLayout: snapshot });
if (current === revision) {
error.value = '';
saved.value = true;
}
} catch (e) {
error.value = e instanceof Error ? e.message : '保存失败,请重试';
} finally {
pending.value--;
}
});
return saveQueue;
}
function selectNode(nodeId: string) {
selectedId.value = nodeId;
panel.value = 'component';
rowId.value = rows.value.find((row) => all(row.nodes).some((node) => node.id === nodeId))?.id ?? '';
}
function selectRow(value: string) {
rowId.value = value;
selectedId.value = '';
panel.value = 'row';
}
function resetSelection() {
selectedId.value = '';
rowId.value = '';
panel.value = 'scheme';
}
function switchScheme() {
resetSelection();
void save();
}
function add(kind: IslandComponentKind) {
const node = islandNode(kind, id());
activeRow.value.nodes.push(node);
selectNode(node.id);
libraryOpen.value = false;
void save();
}
function addRow() {
const row: IslandLayoutRow = {
id: id(),
name: `组件行 ${rows.value.length + 1}`,
main: false,
allowNotifications: true,
gap: 8,
nodes: [],
};
rows.value.push(row);
selectRow(row.id);
void save();
}
function findList(nodeId: string, nodes?: IslandComponentNode[]): IslandComponentNode[] | undefined {
for (const list of nodes ? [nodes] : rows.value.map((row) => row.nodes)) {
if (list.some((node) => node.id === nodeId)) return list;
for (const node of list) {
const found = findList(nodeId, node.children);
if (found) return found;
}
}
}
function removeNode() {
const list = findList(selectedId.value);
if (list)
list.splice(
list.findIndex((node) => node.id === selectedId.value),
1,
);
selectedId.value = '';
panel.value = 'row';
void save();
}
function copyNode() {
if (!selected.value) return;
const list = findList(selected.value.id)!;
const copy: IslandComponentNode = JSON.parse(JSON.stringify(selected.value));
all([copy]).forEach((node) => (node.id = id()));
list.splice(list.indexOf(selected.value) + 1, 0, copy);
selectNode(copy.id);
void save();
}
function wrap(kind: IslandComponentKind) {
if (!selected.value || !containers.includes(kind)) return;
const list = findList(selected.value.id)!,
container = islandNode(kind, id());
container.children = [selected.value];
list.splice(list.indexOf(selected.value), 1, container);
selectNode(container.id);
void save();
}
function removeRow() {
if (rows.value.length < 2) return;
const index = rows.value.indexOf(activeRow.value);
rows.value.splice(index, 1);
if (!rows.value.some((row) => row.main)) rows.value[0]!.main = true;
rowId.value = '';
void save();
}
function setMain() {
rows.value.forEach((row) => (row.main = row.id === activeRow.value.id));
void save();
}
function cloneScheme() {
const copy = JSON.parse(JSON.stringify(scheme.value)) as typeof scheme.value;
copy.id = id();
copy.name = `${copy.name} 副本`;
[...copy.rows, ...copy.expandedRows].forEach((row) => {
row.id = id();
all(row.nodes).forEach((node) => (node.id = id()));
});
settings.value.schemes.push(copy);
settings.value.activeSchemeId = copy.id;
switchScheme();
}
async function deleteScheme() {
if (settings.value.schemes.length < 2) return;
try {
await ElMessageBox.confirm(`删除方案「${scheme.value.name}」?`, '删除方案', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
});
} catch {
return;
}
settings.value.schemes = settings.value.schemes.filter((item) => item.id !== scheme.value.id);
settings.value.activeSchemeId = settings.value.schemes[0]!.id;
switchScheme();
}
function exportScheme() {
const url = URL.createObjectURL(
new Blob([JSON.stringify({ format: 'classhelper-island-layout', scheme: scheme.value }, null, 2)], {
type: 'application/json',
}),
);
const a = document.createElement('a');
a.href = url;
a.download = `${scheme.value.name.replace(/[<>:"/\\|?*]/g, '_')}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function importScheme(event: Event) {
const input = event.target as HTMLInputElement;
try {
const file = input.files?.[0];
if (!file) return;
if (file.size > 1000000) throw new Error('方案文件不能超过 1 MB');
const parsed = JSON.parse(await file.text());
if (parsed.format !== 'classhelper-island-layout' || !parsed.scheme?.rows?.length)
throw new Error('方案格式不正确');
const imported = normalizeIslandLayouts({ schemes: [parsed.scheme], activeSchemeId: parsed.scheme.id })
.schemes[0]!;
imported.id = id();
[...imported.rows, ...imported.expandedRows].forEach((row) => {
row.id = id();
all(row.nodes).forEach((node) => (node.id = id()));
});
settings.value.schemes.push(imported);
settings.value.activeSchemeId = imported.id;
switchScheme();
ElMessage.success('方案已导入');
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '导入失败');
} finally {
input.value = '';
}
}
async function apply() {
if (!ready.value) return;
settings.value.layout = 'custom';
settings.value.residentEnabled = true;
await save();
if (!error.value) {
ElMessage.success('自定义灵动岛已应用');
await router.push('/settings/island');
}
}
async function load() {
try {
const config = await window.desktop?.getConfig();
if (config) {
settings.value = normalizeIslandLayouts(config.islandLayout);
appearance.value = config.island;
}
ready.value = true;
error.value = '';
} catch (e) {
error.value = String(e);
}
}
onMounted(async () => {
await load();
clock = setInterval(() => {
now.value = Date.now();
schedules.value = getIslandSchedules();
}, 1000);
});
onUnmounted(() => clearInterval(clock));
onBeforeRouteLeave(async () => {
if (!ready.value) return true;
await save();
return !error.value;
});
interface TreeItem {
id: string;
label: string;
type: 'row' | 'node';
source?: IslandComponentNode;
row?: IslandLayoutRow;
children: TreeItem[];
}
function nodeItem(node: IslandComponentNode): TreeItem {
return {
id: node.id,
label: ISLAND_COMPONENT_LABELS[node.kind],
type: 'node',
source: node,
children: node.children.map(nodeItem),
};
}
const tree = computed<TreeItem[]>(() =>
rows.value.map((row) => ({
id: row.id,
label: row.name,
type: 'row',
row,
children: row.nodes.map(nodeItem),
})),
);
function treeClick(item: TreeItem) {
if (item.type === 'row') selectRow(item.id);
else selectNode(item.id);
}
function allowDrop(dragging: TreeNode, dropping: TreeNode, type: AllowDropType) {
const from = dragging.data as TreeItem,
to = dropping.data as TreeItem;
if (from.type === 'row') return to.type === 'row' && type !== 'inner';
if (to.type === 'row') return type === 'inner';
return type !== 'inner' || (!!to.source && containers.includes(to.source.kind));
}
function dropped() {
function rebuild(item: TreeItem): IslandComponentNode {
return { ...item.source!, children: item.children.map(rebuild) };
}
const updated = tree.value.map((item) => ({ ...item.row!, nodes: item.children.map(rebuild) }));
if (surface.value === 'expanded') scheme.value.expandedRows = updated;
else scheme.value.rows = updated;
void save();
}
let dragging: string | null = null;
function startDrag(event: PointerEvent, nodeId: string) {
event.preventDefault();
event.stopPropagation();
dragging = nodeId;
try {
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
} catch {
/* 合成事件没有活动指针 */
}
}
function dropPreview(event: PointerEvent) {
if (!dragging) return;
const moving = dragging;
dragging = null;
const target = document.elementFromPoint(event.clientX, event.clientY),
targetId = target?.closest<HTMLElement>('[data-node]')?.dataset.node;
const row = rows.value.find((row) => row.id === target?.closest<HTMLElement>('[data-row]')?.dataset.row);
const list = findList(moving),
node = list?.find((node) => node.id === moving);
if (!row || !list || !node || all([node]).some((n) => n.id === targetId)) return;
const to = all(row.nodes).find((n) => n.id === targetId);
list.splice(list.indexOf(node), 1);
if (to && containers.includes(to.kind)) to.children.push(node);
else if (to) {
const targetList = findList(to.id)!;
targetList.splice(targetList.indexOf(to), 0, node);
} else row.nodes.push(node);
selectNode(node.id);
void save();
}
</script>
<template>
<section class="page island-editor-page" aria-label="自定义灵动岛设置">
<header class="page-header page-heading">
<div>
<h2 class="page-title">自定义灵动岛</h2>
<p>管理方案、排列内容并调整组件。更改自动保存到本机。</p>
</div>
<div class="heading-actions">
<el-tag :type="error ? 'danger' : pending ? 'warning' : 'info'" effect="plain" role="status">
{{ error ? '保存失败' : pending ? '正在保存…' : saved ? '已保存' : '本机方案' }}
</el-tag>
<el-button @click="router.push('/settings/island')">返回设置</el-button>
<el-button type="primary" :loading="!!pending" :disabled="!ready" @click="apply">
应用并返回
</el-button>
</div>
</header>
<el-alert v-if="error" type="error" :closable="false" :title="error">
<el-button link type="primary" @click="ready ? save() : load()">重试</el-button>
</el-alert>
<template v-if="ready">
<el-card shadow="never" class="scheme-card">
<div class="scheme-toolbar">
<el-form-item label="当前方案">
<el-select v-model="settings.activeSchemeId" aria-label="当前方案" @change="switchScheme">
<el-option
v-for="item in settings.schemes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-button @click="cloneScheme">复制方案</el-button>
<el-button @click="importInput?.click()">导入</el-button>
<el-button @click="exportScheme">导出</el-button>
<el-button type="danger" plain :disabled="settings.schemes.length < 2" @click="deleteScheme">
删除方案
</el-button>
<input
ref="importInput"
type="file"
accept="application/json,.json"
hidden
@change="importScheme"
/>
</div>
</el-card>
<div class="editor-columns">
<div class="layout-column">
<el-card shadow="never" class="preview-card">
<template #header>
<div class="card-heading">
<strong>实时预览</strong>
<el-radio-group v-model="surface" size="small" @change="resetSelection">
<el-radio-button value="compact">常态内容</el-radio-button>
<el-radio-button value="expanded">无通知展开时</el-radio-button>
</el-radio-group>
</div>
</template>
<div class="preview-canvas" @pointerup="dropPreview" @pointercancel="dragging = null">
<div
class="island-preview"
:class="{ separated: scheme.separated }"
:style="{
zoom: scheme.scale * zoom,
'--preview-gap': `${scheme.spacing}px`,
'--preview-radius': `${scheme.radius}px`,
'--secondary': `${(appearance.fontSize / 13) * 14}px`,
'--emphasized': `${(appearance.fontSize / 13) * 18}px`,
'--large': `${(appearance.fontSize / 13) * 20}px`,
fontSize: `${(appearance.fontSize / 13) * 16}px`,
}"
>
<section
v-for="row in rows"
:key="row.id"
class="preview-row"
:class="{ selected: panel === 'row' && row.id === activeRow.id }"
:data-row="row.id"
:style="{ gap: `${row.gap}px` }"
@click.self="selectRow(row.id)"
>
<ComponentNode
v-for="node in row.nodes"
:key="node.id"
:node="node"
:state="state"
:now="now"
:schedules="schedules"
editing
:selected-id="selectedId"
alert-node-id=""
@select="selectNode"
@drag="startDrag"
/>
<span v-if="!row.nodes.length" class="empty-preview" @click="selectRow(row.id)">
此行暂无组件
</span>
</section>
</div>
</div>
<div class="preview-toolbar">
<span>预览缩放</span>
<el-slider
v-model="zoom"
:min="0.3"
:max="1.5"
:step="0.1"
:format-tooltip="(v: number) => `${Math.round(v * 100)}%`"
/>
<el-button size="small" @click="zoom = 1">重置</el-button>
</div>
</el-card>
<el-card shadow="never" class="structure-card">
<template #header>
<div class="card-heading">
<strong>内容结构</strong>
<div>
<el-button size="small" @click="addRow">新增行</el-button>
<el-button size="small" type="primary" @click="libraryOpen = true">添加组件</el-button>
</div>
</div>
</template>
<p class="hint">拖动可调整顺序,或将组件移入分组、轮播等容器。点选后在右侧设置。</p>
<el-tree
:data="tree"
node-key="id"
default-expand-all
highlight-current
:expand-on-click-node="false"
:current-node-key="selectedId || rowId"
draggable
:allow-drop="allowDrop"
@node-click="treeClick"
@node-drop="dropped"
>
<template #default="{ data }">
<span class="tree-label">
<span>{{ data.label }}</span>
<el-tag v-if="data.type === 'row' && data.row.main" size="small" type="info">主要行</el-tag>
</span>
</template>
</el-tree>
</el-card>
</div>
<el-card shadow="never" class="properties-card">
<template #header>
<el-radio-group v-model="panel" size="small">
<el-radio-button value="scheme">方案</el-radio-button>
<el-radio-button value="row">行</el-radio-button>
<el-radio-button value="component" :disabled="!selected">组件</el-radio-button>
</el-radio-group>
</template>
<IslandComponentSettings
v-if="panel === 'component' && selected"
:node="selected"
@change="save"
@remove="removeNode"
@duplicate="copyNode"
@wrap="wrap"
/>
<el-form v-else-if="panel === 'row'" label-position="top">
<h3>组件行</h3>
<el-form-item label="当前行">
<el-select :model-value="activeRow.id" @change="selectRow">
<el-option v-for="row in rows" :key="row.id" :value="row.id" :label="row.name" />
</el-select>
</el-form-item>
<el-form-item label="行名称"><el-input v-model="activeRow.name" @change="save" /></el-form-item>
<el-form-item label="组件间距">
<el-input-number v-model="activeRow.gap" :min="0" :max="80" @change="save" />
</el-form-item>
<el-form-item label="允许显示提醒">
<el-switch v-model="activeRow.allowNotifications" @change="save" />
</el-form-item>
<el-button :disabled="activeRow.main" @click="setMain">
{{ activeRow.main ? '已设为主要行' : '设为主要行' }}
</el-button>
<p class="hint">提醒优先使用主要行;关闭提醒后使用下一条允许提醒的行。</p>
<el-button type="danger" plain :disabled="rows.length < 2" @click="removeRow">删除此行</el-button>
</el-form>
<el-form v-else label-position="top">
<h3>方案设置</h3>
<el-form-item label="方案名称"><el-input v-model="scheme.name" @change="save" /></el-form-item>
<el-form-item label="界面缩放">
<el-input-number
v-model="scheme.scale"
:min="METRICS.scaleMin"
:max="METRICS.scaleMax"
:step="0.05"
@change="save"
/>
</el-form-item>
<el-form-item label="行间距">
<el-slider v-model="scheme.spacing" :min="0" :max="METRICS.rowSpacingMax" @change="save" />
</el-form-item>
<el-form-item label="常态圆角">
<el-slider v-model="scheme.radius" :min="0" :max="METRICS.radiusMax" @change="save" />
</el-form-item>
<el-form-item label="各行分离显示">
<el-switch v-model="scheme.separated" @change="save" />
</el-form-item>
<p class="hint">字体、透明度、位置和动画与灵动岛共用设置。</p>
<el-button link type="primary" @click="router.push('/settings/island')">
前往灵动岛外观设置
</el-button>
</el-form>
</el-card>
</div>
</template>
<el-skeleton v-else :rows="8" animated />
<el-dialog v-model="libraryOpen" title="添加组件" width="min(540px, 94vw)">
<el-form-item label="添加到">
<el-select :model-value="activeRow.id" @change="selectRow">
<el-option v-for="row in rows" :key="row.id" :value="row.id" :label="row.name" />
</el-select>
</el-form-item>
<section v-for="group in groups" :key="group.label" class="component-group">
<h3>{{ group.label }}</h3>
<div class="component-options">
<el-button v-for="kind in group.kinds" :key="kind" class="component-option" @click="add(kind)">
<ComponentIcon :kind="kind" />
<span>{{ ISLAND_COMPONENT_LABELS[kind] }}</span>
</el-button>
</div>
</section>
</el-dialog>
</section>
</template>
<style scoped>
.island-editor-page {
display: grid;
gap: 16px;
min-width: 0;
color: var(--ch-text);
}
.page-heading,
.heading-actions,
.scheme-toolbar,
.card-heading {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.page-heading,
.card-heading {
justify-content: space-between;
}
h2,
h3 {
margin: 0 0 6px;
}
.page-heading p,
.hint {
color: var(--ch-text-secondary);
line-height: 1.6;
margin: 6px 0;
font-size: 12px;
}
.scheme-toolbar .el-form-item {
margin: 0 auto 0 0;
}
.scheme-toolbar .el-select {
width: 200px;
}
.editor-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) 288px;
gap: 16px;
align-items: start;
}
.layout-column {
display: grid;
gap: 16px;
min-width: 0;
}
.preview-card :deep(.el-card__body) {
padding: 0;
}
.preview-canvas {
min-height: 208px;
overflow: auto;
display: grid;
place-items: center;
padding: 30px 18px;
background-color: var(--ch-layer-alt);
background-image: radial-gradient(var(--ch-border-strong) 1px, transparent 1px);
background-size: 16px 16px;
}
.island-preview {
display: flex;
flex-direction: column;
gap: var(--preview-gap);
color: #ffffffeb;
width: max-content;
max-width: none;
padding: 12px;
border-radius: var(--preview-radius);
background: #202024;
box-shadow: var(--ch-shadow-card);
}
.preview-row {
display: flex;
align-items: center;
min-height: 40px;
border-radius: var(--preview-radius);
}
.preview-row.selected {
outline: 2px solid var(--ch-accent);
outline-offset: 4px;
}
.island-preview.separated {
background: transparent;
padding: 0;
box-shadow: none;
}
.separated .preview-row {
padding: 12px;
background: #202024;
}
.empty-preview {
color: #ffffffb8;
padding: 8px 14px;
cursor: pointer;
font-size: 13px;
}
.preview-toolbar {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
color: var(--ch-text-secondary);
font-size: 12px;
border-top: 1px solid var(--ch-border);
}
.preview-toolbar .el-slider {
flex: 1;
max-width: 160px;
}
.card-heading > div {
display: flex;
gap: 6px;
}
.tree-label {
min-width: 0;
display: flex;
gap: 10px;
align-items: center;
font-size: 13px;
}
.properties-card .el-select {
width: 100%;
}
.component-group + .component-group {
margin-top: 20px;
}
.component-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.component-option {
height: 60px;
margin: 0;
}
.component-option :deep(span) {
display: flex;
align-items: center;
gap: 8px;
}
.component-option :deep(svg) {
width: 20px;
height: 20px;
}
@media (max-width: 900px) {
.editor-columns {
grid-template-columns: minmax(0, 1fr) 250px;
}
.scheme-toolbar .el-form-item {
flex-basis: 100%;
}
}
@media (max-width: 700px) {
.editor-columns {
grid-template-columns: 1fr;
}
.heading-actions {
width: 100%;
}
.component-options {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { confirmAction } from '../../composables/confirm.js';
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';
import {
DEFAULT_ISLAND_APPEARANCE,
ISLAND_APPEARANCE_RANGES as RANGES,
@@ -23,6 +24,57 @@ import {
*/
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const savingIsland = ref(false);
const router = useRouter();
function editLayout() {
layoutMode.value = 'custom';
void router.push('/settings/island/editor');
}
const layoutMode = ref<'fixed' | 'custom'>('fixed');
const residentEnabled = ref(false);
const pointerFadeEnabled = ref(true);
const pointerFadeReversed = ref(false);
async function changeLayout() {
const config = await window.desktop?.getConfig();
if (config)
await window.desktop?.saveConfig({
islandLayout: {
...config.islandLayout,
layout: layoutMode.value,
residentEnabled: residentEnabled.value,
},
});
}
async function chooseLayout(mode: 'fixed' | 'custom') {
layoutMode.value = mode;
residentEnabled.value = mode === 'custom';
await changeLayout();
}
async function changePointerFade(
key: 'pointerFadeEnabled' | 'pointerFadeReversed',
value: boolean | string | number,
) {
const config = await window.desktop?.getConfig();
if (!config) return;
const saved = await window.desktop?.saveConfig({
islandLayout: { ...config.islandLayout, [key]: value === true },
});
if (saved) {
pointerFadeEnabled.value = saved.islandLayout.pointerFadeEnabled;
pointerFadeReversed.value = saved.islandLayout.pointerFadeReversed;
}
}
async function loadLayout() {
const settings = (await window.desktop?.getConfig())?.islandLayout;
layoutMode.value = settings?.layout ?? 'fixed';
residentEnabled.value = settings?.residentEnabled ?? false;
pointerFadeEnabled.value = settings?.pointerFadeEnabled ?? true;
pointerFadeReversed.value = settings?.pointerFadeReversed ?? false;
}
onMounted(async () => {
await loadLayout();
window.addEventListener('focus', loadLayout);
});
onUnmounted(() => window.removeEventListener('focus', loadLayout));
/**
* 左右边距是否有效:只在停靠左 / 右时生效。
@@ -97,14 +149,71 @@ onMounted(async () => {
<div class="page-header">
<div>
<h2 class="page-title">灵动岛</h2>
<p class="page-subtitle">屏幕上方的消息浮窗:外观、停靠位置与动画</p>
<p class="page-subtitle">自定义组件与 ClassIsland 提醒,共用停靠位置、动画开关与通知规则</p>
</div>
</div>
<el-card shadow="never" class="layout-mode-card">
<template #header><span>岛内布局</span></template>
<div class="layout-choices">
<button
class="layout-choice"
:class="{ active: layoutMode === 'fixed' }"
:aria-pressed="layoutMode === 'fixed'"
@click="chooseLayout('fixed')"
>
<strong>经典灵动岛</strong>
<span>显示课程进度、上下课提醒与班级消息</span>
</button>
<button
class="layout-choice"
:class="{ active: layoutMode === 'custom' }"
:aria-pressed="layoutMode === 'custom'"
@click="chooseLayout('custom')"
>
<strong>自定义灵动岛</strong>
<span>ClassIsland 风格,常驻显示时钟、课程等组件</span>
</button>
</div>
<div v-if="layoutMode === 'custom'" class="layout-edit-entry">
<div>
<strong>按你的习惯布置岛内内容</strong>
<p>编辑常态内容与无通知展开内容;课前自动提示,通知操作与经典岛共用。</p>
</div>
<el-button type="primary" @click="editLayout">编辑岛内组件</el-button>
</div>
<div v-if="layoutMode === 'custom'" class="resident-window-options">
<div>
<strong>指针避让</strong>
<p>指针移入后淡化并穿透,右侧展开入口保持可用;展开和触摸交互期间暂停避让。</p>
</div>
<label>
<span>指针移入淡化</span>
<el-switch
:model-value="pointerFadeEnabled"
@change="(value: boolean | string | number) => changePointerFade('pointerFadeEnabled', value)"
/>
</label>
<label v-if="pointerFadeEnabled">
<span>反转指针移入淡化</span>
<el-switch
:model-value="pointerFadeReversed"
@change="(value: boolean | string | number) => changePointerFade('pointerFadeReversed', value)"
/>
</label>
</div>
</el-card>
<el-card shadow="never">
<template #header><span>灵动岛 · 个性化</span></template>
<el-form label-width="96px" label-position="left">
<el-form-item :label="`高度 ${island.height}px`">
<template #header>
<span>外观、位置与弹出</span>
</template>
<p class="text-muted">紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。</p>
<p class="text-muted">
课前提示与倒计时、课中进度、即将下课倒数及上下课提醒,可在「提醒」页分别设置。课程时间显示在名称右侧。
</p>
<el-form label-width="110px" label-position="left">
<el-form-item :label="`${layoutMode === 'custom' ? '通知高度' : '高度'} ${island.height}px`">
<el-slider
:model-value="island.height"
:min="RANGES.height.min"
@@ -113,7 +222,7 @@ onMounted(async () => {
@input="(value: number) => applyIslandAppearance({ height: value })"
/>
</el-form-item>
<el-form-item :label="`宽度 ${island.width}px`">
<el-form-item :label="`${layoutMode === 'custom' ? '通知宽度' : '宽度'} ${island.width}px`">
<el-slider
:model-value="island.width"
:min="RANGES.width.min"
@@ -166,7 +275,9 @@ onMounted(async () => {
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
</el-form-item>
<el-form-item label="视觉风格">
<span v-if="layoutMode === 'custom'">ClassIsland · 半透明横条</span>
<el-select
v-else
:model-value="island.style"
style="width: 180px"
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
@@ -178,7 +289,9 @@ onMounted(async () => {
:value="item.value"
/>
</el-select>
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
<span v-if="layoutMode === 'fixed'" class="text-muted ml-8">
纯黑 / 毛玻璃(半透明)/ 主题色渐变
</span>
</el-form-item>
<el-form-item label="显示位置">
<el-select
@@ -245,7 +358,7 @@ onMounted(async () => {
"
/>
</el-form-item>
<el-form-item label="空闲细缝">
<el-form-item v-if="layoutMode === 'fixed'" label="空闲细缝">
<el-switch
:model-value="island.idleSliver"
@change="
@@ -270,6 +383,114 @@ onMounted(async () => {
</div>
</template>
<style scoped>
.layout-mode-card {
margin-bottom: 20px;
}
.resident-setting {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--el-border-color-lighter);
}
.resident-setting strong {
font-size: 14px;
}
.resident-setting p {
max-width: 620px;
margin: 8px 0 0;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.8;
}
.resident-window-options {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--el-border-color-lighter);
}
.resident-window-options strong {
font-size: 14px;
}
.resident-window-options p {
margin: 8px 0 14px;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.8;
}
.resident-window-options label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 40px;
font-size: 13px;
}
.layout-choices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.layout-choice {
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color);
border-radius: 12px;
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
font: inherit;
}
.layout-choice strong {
font-size: 15px;
}
.layout-choice span {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.7;
}
.layout-choice.active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.layout-choice:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 3px;
}
.layout-edit-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--el-border-color-lighter);
}
.layout-edit-entry strong {
font-size: 14px;
}
.layout-edit-entry p {
margin: 8px 0 0;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.8;
}
@media (max-width: 680px) {
.layout-choices {
grid-template-columns: 1fr;
}
.layout-edit-entry {
align-items: flex-start;
flex-direction: column;
}
}
</style>
<style scoped>
.ml-8 {
margin-left: 8px;
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more