feat(courseware,docs): 完善课件传输与岛编辑并统一全仓文档

- 增加课件上传、班级可见范围、设备补收与传输进度
- 整合自定义灵动岛编辑器和相关客户端交互
- 同步三端契约、数据库迁移、部署配置与隔离回归
- 按源码统一文档与官网说明,移除过时任务书和审查记录
- 保持产品版本为 1.4.0
This commit is contained in:
LoveChengke committed 2026-10-18 11:53:37 +08:00
1 parent 536379036a
commit c7f215a09c
127 files changed
+4223 -8371

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/
+123 -1711
View File
File diff suppressed because it is too large. Load diff
+11 -11
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,13 +71,13 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
### 灵动岛(桌面通知浮窗)
- [x] 通知、新作业、叫人都会上岛:先是胶囊,点开是详情卡
- [x] 通知、新作业、叫人都会上岛:经典模式使用胶囊与详情卡,自定义模式使用常驻内容与铃铛
- [x] 多条消息展开为竖排列表,按重要程度排序,放不下时提示去应用内看
- [x] **默认提醒模式上课时段不打扰**:普通通知暂存到下课再弹;紧急通知与叫人立刻弹
- [x] **经典模式上课时段不打扰**:普通通知暂存,普通叫人可点开胶囊,紧急消息立即展开;自定义模式点击铃铛查看
- [x] 尺寸、圆角、字号、主题色、停靠位置(顶部 / 底部 × 左 / 中 / 右)都可以调
- [x] 纯黑 / 毛玻璃 / 主题色渐变三种底
- [x] 触摸屏(希沃白板这类一体机)也能点开
- [x] 可选常驻岛:自定义收起与无通知展开内容,收到消息显示铃铛,点击查看通知
- [x] 自定义常驻岛:分别编辑常态与无通知展开内容,收到消息显示铃铛,点击查看通知
### 部署与运维
@@ -212,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) —— 架构总览
## 致谢
@@ -229,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: 叫人
-210
View File
@@ -1,210 +0,0 @@
# 2026-10-17 实施与验收记录
## 阶段
1. 数据与共享契约:关系化时间点、日课表、群组、预定层;权限范围与内置组;周数设置。
2. 服务端:唯一课表解析器与兼容门面、事务编辑;转出过滤、聚合成绩、自动升级、下载状态。
3. Web:时间表/日课表编辑、只读名单、日期归档、设置与进度。
4. 客户端:心跳重置、立即远控(冒烟假执行)、更新进度、本机组件方案与编辑器。
5. 验证与文档:临时数据库验证、静态检查、后台冒烟;明确记录未通过与未执行项目。
## 架构选择
- 不引入 Profile:共享时间表与班级日课表替代单机档案。
- 旧 Schedule 保留迁移来源,通过兼容门面投影新模型;迁移保留起止周、单双周与地点,无法从旧数据恢复原来的群组、临时层或时间点名称。
- 范围保存在 PermissionGroup,多个分组取范围并集;部用逗号分隔的年级字符串,空范围不放行。
- 周次上限取 AppSetting,默认 30、允许 1–60;离线取默认。
- 岛方案保存在本机 config.json;天气无离线数据源,本轮不做。
- 自动升级以年份作事务幂等键,升级前存全库快照;新学年保留周数上限、开学日重置为当年 9 月 1 日,清除旧逐周区间。新班不复制任课关系。
## 实测记录
- `pnpm lint`:0 error、0 warning;全仓类型检查、完整构建、图标门禁与客户端产物凭据检查通过。
- `pnpm verify:e2e`:243/243。根入口改为全新临时 SQLite 库与带前缀账号,禁止外网;不会连到本机真实服务。
- `pnpm verify:v14`:35/35。覆盖四周轮换、四种时间点、外键排序与下一节时间、跨班课程、换课不改原表、预定优先级、集体成绩与实时隐私、转出学生的名单/成绩/叫人/未交入口、四岗位越权、41 周生成、兼容 CRUD、上游导入幂等、自启动假适配器。年度升级另在专用临时库并发运行两次,确认同年幂等、快照存在、其余班只升一级、新班无班主任与任课关系。
- `pnpm verify:web`:43/43。包含新增日课表整表保存、时间表编辑入口、自启动不可用原因、升级设置与转出分区;全程隐藏、无焦点。
- 桌面快速检查 24/24;视觉检查 99/99,并通过退出后的第二次快速启动 24/24。均使用独立 userData,远控与安装器不会执行系统操作。
- 文档站严格构建通过,0 warning、0 error。
- 原库迁移前备份:`.cache/dev-before-v14-20261017092353.db`。先在副本验证,再应用本机迁移。用户 3、班级 5、学生 18、课程 53、旧课表 89、成绩 98、作业 16、通知 23 的行数全部保留;89 条旧课表均有关系化映射,生成 450 张按周日课表,外键检查 0 异常。历史 JSON 时间表的 3 个时间点也全部补齐。
- 视觉截图:`.cache/v14-island-shots/`(固定模式各形态、窗口全图、`custom-editor.png`、`custom-alert.png`);Web 手机截图:`.cache/v14-web-shots/`。它们是本次验收留档,不作为官网素材。
- 未打包、未改版本、未改 releases;未真实修改注册表/systemd,未在业务库或演示库执行年度升级,未真实执行关机/重启/注销/锁屏。自启动安装形态使用可注入的系统适配器检查开关与重建控制器后回读。
## 两处 bug 的定位
### 换服务端后迟迟不上报
旧 `client-agent.ts` 在收到 401/403 后把唯一计时器排到五分钟后,配置保存/登录成功没有事件重排,旧 token 又可能继续保留。用两个本机假服务端复现:第一个明确返回 401,切换第二个后必须清空旧 token,登录新 token 后自动恢复心跳。修复为四个显式配置事件、状态清空与 epoch 隔离;迟到响应不能覆盖新实例状态。设备 key 不重建,命令回执固定发回接收命令时的地址。
### 自启动入口与状态不一致
旧 Web 没有调用现有自启动 API 的可达设置页。Windows 服务端还从 `dirname(process.execPath)` 找 `start.cmd`,安装包里的 Node 在 runtime 子目录,而安装器把脚本放在安装根,导致错误地识别为开发环境。旧状态只检查 Run 项存在,没有核对值。现在从服务端目录定位安装根,并校验安装器的 InstallDir,比较完整 Run 值;写入后重新读取并校验。开发/免安装/容器环境明确返回不可用原因。Web 实测开发环境按钮禁用且原因可见,安装形态开关与回读由假系统适配器验证。
## 迁移与兼容的取舍
- 采用新模型投影的兼容门面,不双写业务表。旧 Schedule 与 TimeLayout.items 只作迁移审计来源,后续编辑不会再读取它们复活被删除的课程。种子初始化另有一次物化过程。
- 旧行按实际教学周展开,因此不同周次范围可以共存;同班同日、同周、完全相同起止时间的旧冲突记录只取最小 id。旧数据没有的群组/临时层/时间点名称无法恢复;冲突原行保留可追溯。
- 老时间配置导入同样写关系化时间点,并要求 timetable:manage;同起点但不同类型的课间和课程保留独立外键。整套 ClassIsland 课表导入仍可由本班班主任使用,只建立班级自有作息,不把 TeacherName 当成任课授权。
- 上游导入直接保留 1–9 周三元组、四种时间点、空课、停用、换课标记、群组、预定和临时选择,不经单双周中间格式。稳定导入 id 使 merge 重复导入不复制整套课表。
- 全校周数默认 30、上限 60,由 shared 取值函数统一归一化;旧 MAX_TERM_WEEK 仅保留为弃用的外部兼容导出,内部不再使用。
- 不引入 Profile、天气、.NET 或插件依赖;部使用 PermissionGroup.scopeValue 的逗号分隔年级,空范围不放行。范围并集不会替代 Course.teacherId 的科目写权限。
## 对两项架构判断的答复
本机方案存 config.json 的决定予以保留:这是设备个体偏好,不增加服务端配置或同步通道。通知/作业/叫人使用同一个 IslandApp.vue 实例及原状态机,custom 模式仅通过 Teleport 把它放到组件槽;不是固定层盖在自定义层上,也没有第二个岛窗口。主要行禁用提醒时回落到允许提醒的第一行;全部禁用时不展示提醒槽。多种内容仍服从原队列/列表规则,不会在多个位置重复投递。
## 自定义灵动岛界面重写
按后续反馈,将组件栏明确收进同一个岛体:普通组件和提醒共用外层背景、圆角与阴影,嵌入提醒只保留原渲染器的内容、动画与操作,去掉原卡片的第二层背景形状。每行的普通组件与提醒上下排布,岛宽取两者中较宽的一项,避免横向累加造成留白。没有消息的通知、作业、叫人组件不占位。固定模式继续使用原有完整卡片。
编辑界面重写为左侧组件库、中央岛内预览、右侧当前对象设置。组件库按日常信息、消息提醒、组合容器分组,采用统一 SVG 线性图标;组件设置分为内容、外观、高级三个标签,独立外观关闭时禁用覆盖项。行名称、间距、主要行和通知开关放在行设置中,整体配色、缩放和多行选项放在整体外观中;方案复制与导入导出收在顶部菜单。
主设置页以两个选项选择经典或自定义灵动岛,自定义模式直接进入「编辑岛内组件」,隐藏不适用的固定宽高等选项。退出全屏编辑时,先在隐藏期间等待常态组件重新测量,避免保留行工具栏的空白高度;自然宽度测量也不再被上一轮获准窗口宽度撑住。分离多行时按实际外层留白计算尺寸。
新增回归检查设置标签隔离、共用岛体且无第二层形状、无消息不占位,以及退出编辑后的自然内容尺寸;关闭提醒通过原渲染器的「知道了」按钮验证。最终截图留档在 `.cache/island-rewrite-final-shots/`。
本轮视觉回归通过 102/102,随后补充的岛宽修正与展开可见性检查通过后台 28/28,并通过二次启动。后台最终几何检查先等待原渲染器的形变兜底和尺寸防抖完成,避免在隐藏窗口的中间动画帧判断最终尺寸。客户端构建、类型检查、lint、改动文件格式检查及文档严格构建均通过。
## 可选常驻岛
本机 `islandLayout.residentEnabled` 默认关闭,仅在 custom 模式生效。开启后,现有方案 rows 作为收起内容,新增 expandedRows 保存没有通知时点击展开的内容;两套内容在同一编辑器中切换编辑,随方案复制、导入导出和本机配置一起保存。旧配置自动补齐展开布局,不改变原来的常驻选择。
按本次产品要求,常驻模式中普通、紧急和叫人消息都先提示右侧铃铛及去重后的数量,点击常驻内容、铃铛或按 Enter/空格才展开通知列表;上课时允许主动查看。没有消息时展开自定义内容,收起、处理完成及通知撤回后保留常驻岛。经典模式与关闭常驻的自定义模式继续使用原提醒规则。
铃铛固定占位,避免收消息时内容横移。普通组件默认继承行的垂直居中对齐;原先无条件 align-self:stretch 会使不同字号文字从顶部排版,现只在父容器有宽度且明确配置时应用覆盖对齐。时钟采用等宽数字,分隔符保持固定线高,展开页标题与收起按钮在同一基准线上。设置页重新获得焦点时读回本机配置,避免编辑器内改常驻选项后主设置页显示旧值。
新增隔离检查覆盖旧配置兼容、空闲常驻、铃铛与文本对齐、无消息展开/收起、普通与紧急消息点击展示、同 id 去重、课堂中查看、键盘展开、处理完成后的窗口几何、空闲展开时新消息提示、两套内容独立编辑和关闭常驻恢复经典模式。截图留档于 `.cache/resident-island-shots/`。
### 再次核对 ClassIsland 尺寸与避让设置
2026-10-17 重新从 GitHub master 读取 [经典主题](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/XamlThemes/ClassicTheme/Styles.axaml)、[Fluent 主题](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/XamlThemes/FluentTheme/Styles.axaml)、[默认设置](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Models/Settings.cs)、[窗口设置页](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Views/SettingPages/WindowSettingsPage.axaml)、[外观设置页](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Views/SettingPages/AppearanceSettingsPage.axaml)及 [行样式](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Controls/MainWindowLine.axaml)。主题行高为 40、内容左右留白 12;四档字体为 14/16/18/20,时钟用强调字号 18;默认圆角 8、背景不透明度 0.5、缩放 1、行距 5。共享 CLASSISLAND_ISLAND_METRICS 作为唯一尺寸与默认值表,常驻行移除上下 12px 留白。缩放、圆角与背景透明度范围也与上游一致。
原生窗口复用现有光标轮询,在常驻背景移入时降低到 5% 不透明度并开启点击穿透,移出恢复;支持反转及关闭。保留独立的右侧交互入口:无消息时是小展开箭头,有消息时显示铃铛与数量。它不触发避让,避免和此前点击查看通知的要求冲突;编辑、展开和触笔/触摸后 1 秒内暂停避让。这是为本产品保留交互入口的明确适配,上游原始主界面默认整体穿透。未引入 RawInput/.NET 依赖,轮询方式对应上游默认 UseRawInput=false。窗口隐藏或淡化不清空消息队列。
补充后台检查实测常驻行 40px、原生 setOpacity 的 5% 比例、点击穿透状态、移出恢复、反转/关闭淡化、右侧入口与触摸宽限期。源码缓存和截图分别留在 `.cache/classisland-current/`、`.cache/resident-island-ci-shots/`。
最终构建视觉验收 118/118,随后后台重启检查 43/43、单实例锁和配置句柄释放通过;全仓类型、lint、构建与产物凭据门禁、格式及文档严格检查通过。最终截图为 `.cache/resident-island-ci-final-shots/`,验收日志为 `.cache/resident-island-ci-visual-final.log`。本次不修改业务数据库、不打包或推送发布。
## 常驻岛界面与动画重写
按用户反馈重新读取上游 `ClassicTheme/Styles.axaml`、`Controls/MainWindowLine.cs` 与 `Controls/MainWindowLine.axaml`。
上游背景宽度过渡为 550ms、`cubic-bezier(0,1,.5,1.1)`,内容使用 200ms 的 SineEaseIn 淡入;行高 40、左右留白 12。
本产品需要可交互的通知列表和独立空闲展开页,沿用这些度量、背景曲线与内容淡入,展开页高度也使用同一背景曲线。
旧问题由三个因素叠加:内嵌提醒仍播放 WinIsland 胶囊弹簧,外层 ResizeObserver 把动画中间帧当成自然尺寸,渲染端与主进程各延迟 120ms 调整窗口;外壳 `overflow:auto` 在两端不同步时出现滚动条。
新 `ResidentIsland.vue` 独立渲染常驻内容、空闲页和通知列表,保留既有的消息去重、排序、读回执及按钮动作。
经典渲染器保留挂载和心跳,但在常驻模式停止弹簧循环、兜底定时器和命中上报,不再内嵌到常驻岛。
常驻窗口使用受工作区限制的固定画布,开合不调用逐帧 setBounds,也不再隐藏 320ms 后重新显示。
两页内容按各自未变形的 offsetWidth/offsetHeight 测量,保持自己的排版和缩放;只让一个岛壳过渡尺寸。
内容自然尺寸、配置缩放、字体加载和窗口 resize 决定目标尺寸。长文字先限制宽度并省略,时间、分隔线和文字成组对齐;多行布局整体适配可用宽高。
消息正文限制可见行数,按高度限制列表数量,剩余内容由应用内入口承接;底部动作不被正文挤出,运行界面没有滚动容器。
原生窗口的 show/hide 通过专用白名单桥同步到渲染器;不可见时直接应用目标几何,避免后台 CSS 动画冻结在中间帧。
命中矩形仅在动画期间每 60ms 限流上报,结束时补报;静止时没有动画采样循环。
开合期间暂停指针淡化,避免鼠标留在旧展开区域时因过期命中矩形误触发 5% 透明度,造成收起闪烁。
Windows 触摸模式使用 Electron setShape:动画开始放开绘制区域,完成后只保留岛体及 8px 投影留白的原生区域,透明画布不再长期吞掉周围桌面输入。进入编辑、关闭常驻或关闭触摸模式时清除区域。
新增回归覆盖开合窗口矩形恒定、动画帧间隔、所有运行节点无滚动容器、360×260 窄画布与 2.5 倍缩放、底部右侧停靠、长正文及多通知底部动作、触摸输入区域收敛。测试保持隔离配置与本地通知,不连接真实班级。
本轮视觉验收 123/123,通过连续快速开合与停靠命中检查;1.8 秒取得 109 个动画采样,帧间隔 P95 为 16.8ms,原生窗口矩形只有 1 个。
最终补充收起期间错误淡化的回归后,后台检查 49/49;桌面类型检查、全仓 lint、构建及产物凭据门禁、格式和文档严格构建通过。
视觉记录与截图为 `.cache/resident-rewrite-final-visual.json`、`.cache/resident-rewrite-final-shots/`,最终构建后台记录为 `.cache/resident-rewrite-delivery-quick.json`。
## 十项自检
1. 第三周周三第二节:ClassPlan 的规则与 ClassPlanEntry 的 timeLayoutItemId → `resolveActiveClassPlan` → `loadTermContext/resolveCurrentWeek` → `projectPlan` → `/schedules/active-week` → `refreshIslandSchedule` 的整周 IndexedDB 缓存 → `recomputeClassState` 发送当天课程 → `ComponentNode.vue` 课程表组件。
2. 编辑时间点起止时,所有引用该 TimeLayoutItem 外键的课随之改变;只排序改变 position,不改变外键。旧单节时间编辑会先克隆班级日作息,避免误改共享时间表。
3. 拖到第二行后,保存到 `<userData>/config.json` 的 `islandLayout.schemes[].rows[].nodes`;仍由 config.ts 的内存缓存、120ms 合并写盘和退出 flush 管理,重启经 getConfig/白名单桥读回。
4. 新提醒优先进入 main 且 allowNotifications 的行,否则第一条允许提醒的行;在该行对应内容组件的槽展示,没有对应组件时使用该行默认提醒槽。
5. fixed 不挂载 CustomIsland,主进程 reportCustomSize 也先检查 customLayout;原固定窗口、形变路径和默认外观值保留。
6. 关闭个人查询后 overview 直接返回聚合 DTO:没有学号、姓名、个人 id、排名或极值;个人明细/排名接口被服务端拒绝。实时成绩广播同样只发 classId 与 collective 刷新标记,避免经 Socket.IO 绕过 HTTP 闸门。少于五人隐藏均值,均值取整,小于三条的分段及可反推补集的比率不披露;前端也清空已有个人数据。
7. 8 月 3 日启动时识别最近应执行学年缺记录,写警告并在学期周次页显示预览/补跑;不会静默升级业务库。运行中的服务跨过计划时间才自动执行。年份唯一键与事务保证重入只提交一次,执行前先完成快照。
8. 一处 activeStudentWhere/isActiveStudent 规则,应用于名单/班级详情/成绩/作业状态/叫人/通知已读/导入匹配/班级会话与 AI 叫人选择。历史归档/转出专页显式读取历史状态。漏一个入口会造成历史身份重新被选中或历史个人成绩外露,因此回归有服务端负断言。
9. 级长的班级列表与接口断言先经 access.ts → permission-scope.ts 取 GRADE/DIVISION 范围,越界接口返回 403;前端菜单和班级筛选只用于展示。科目内容仍由任课关系独立裁决。
10. 地址变化 → server-changed 清 token/失败/退避与待排计时器 → token-changed/login 立即排心跳 → 新服务器鉴权成功后按稳定 deviceKey 登记 → lastHeartbeatAt 写入并进入在线列表。epoch 丢弃旧请求的迟到结果。
## 验收中修复的边界
- 兼容投影保留单双周元数据;清理测试导入时删除整张测试日课表,空课表不会被隐式回退。
- Windows libsql 同库连接探测复用当前活跃连接;此前完整回归在跨适配器探测处出现 0xC0000005,修复后完整离线回归通过。临时库目录额外带时间戳,避免进程号复用撞到此前的验收库。
- 新旧课表界面与岛桥接共用的缓存仍保存整周数据,岛组件只接收当天投影,避免当天响应覆盖整周离线缓存。
- 最终复跑曾遇到本机冷启动超时:桌面第一次界面无响应,原样重跑在已通过的检查之后被旧 45 秒总限时中止;临时后端也超过了原 10 秒就绪等待。外层预算分别调整为 90 秒与 60 秒,心跳 30 秒等单项业务断言不变。
调整后最终复跑通过后端 243/243 与桌面 24/24(含二次启动),桌面整套实际耗时 20.6 秒。
## 常驻岛并入自定义灵动岛(最新行为)
后续产品要求统一两种自定义显示方式,本节取代上文关于可选常驻、铃铛优先与独立通知卡的历史行为。
`layout` 仍为 fixed/custom;custom 直接显示常态组件,`residentEnabled` 只保留为旧配置兼容字段,归一化为 custom 时开启。
设置页不再提供独立的常驻开关。编辑器保留常态 rows 与无通知展开 expandedRows,两套内容随本机方案保存。
自定义运行时统一为 `CustomIslandRuntime.vue`,不再将旧卡片嵌入组件行。
经典岛与自定义岛共同使用 `ClassicNotifications.vue` 和 `classic-island.css`,通知类型、排序、徽标、正文、按钮与已读动作只有一份实现。
纯黑、玻璃、主题色、透明度、字号、圆角、动画开关与速度均来自同一份 island 外观设置;方案保留组件缩放、行距和布局。
自定义组件行、消息正文与通知操作区居中对齐。无通知展开时显示 expandedRows,有通知时显示共用经典通知内容。
自定义模式不再提前截获所有消息并只亮铃铛:与经典岛走相同的 pushNotification/setClassState 状态机。
紧急通知和紧急叫人立即展开,课中普通通知排队、下课后展开;主动点击铃铛仍可查看待处理消息。
收起和处理完通知后恢复自定义常态内容。铃铛固定占位,收到消息不横移。空白桌面输入穿透与触摸区域裁剪继续保留。
外壳使用经典岛同源的 SpringValue 与 squirclePath;宽高和圆角每帧同时推进,保留零过冲钳制、动画速度与后台吸附兜底。
原生画布固定,隐藏渲染器不运行旧形变;统一心跳不随布局切换重复注册。
内容过渡与外壳采用同参数弹簧同步推进:常态透明度为 max(0, 1-1.5p),展开透明度为 p²,收起时反向播放。
不再使用独立的 200ms 内容淡入,避免内容已经切完、外壳还在展开的割裂;等尺寸页面也有完整过渡。
动画开关和速度同时作用于外壳、内容与铃铛提示,快速反向时保留当前进度;后台窗口仍直接落位。
视觉验收逐帧断言展开与收起都有多帧尺寸变化和中间透明度,可用 ISLAND_MOTION_CAPTURE=1 留档真实合成帧。
本机 Electron 实测 prefers-reduced-motion=true;合并版自定义岛额外检查此系统偏好,造成应用内开着动画仍直接吸附。
现统一以应用内动画开关为准,与经典岛一致;不再由第二套系统判据覆盖已开启的动画。
课表更新链路做了以下修正:
- 课表变更事件立即取 active-week,学期接口在旁路校正,避免慢学期请求串行挡住新课表。
- 网络结果先同步到岛,再异步完成缓存写入;请求序号在请求开始前分配,迟到响应和已切换账号的结果不会覆盖当前内容。
- 实时连接建立及重连时立即刷新;周期补偿从五分钟缩至三十秒,课程边界每秒复算。跨午夜即使上课状态未改变,也同步新一天的课程。
- 主进程保留最新日课表快照;窗口启动、重建时补发,渲染器通过受限 getSchedules IPC 读回,登出时清空。
- 自适应同时接收原生窗口尺寸通知,防止隐藏窗口的浏览器 resize 延迟而沿用旧宽度;订阅在组件退出时释放。
验证使用隔离配置、本地消息和替换的本地 API,覆盖慢学期接口、变更事件、重连刷新、迟到请求隔离、课表 IPC 即时更新及窗口重新加载恢复。
桌面视觉回归覆盖经典岛原有状态,以及自定义岛单通知、多通知、常态、空闲展开、窄屏、长文本、触摸和固定原生窗口动画。
## ClassIsland 课前与上下课提醒(后续最新要求)
现场重新读取了上游 master 的以下实现:
- [ClassNotificationProvider](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Services/NotificationProviders/ClassNotificationProvider.cs):准备上课、上课、下课的独立提醒链。
- [ClassNotificationSettings](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Models/NotificationProviderSettings/ClassNotificationSettings.cs):室内课程默认提前 60 秒。
- [ClassNotificationProviderControl](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Controls/NotificationProviders/ClassNotificationProviderControl.axaml):倒计时、下节科目及右侧起止时间。
- [FluentTheme](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/XamlThemes/FluentTheme/Styles.axaml) 与 [SlantedMaskControl](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Controls/SlantedMaskControl.cs):40px 横条、五段斜切遮罩、360ms 区域动画与 120ms 错峰、标题延迟 260ms 淡入和缩放。
自定义岛的常态外观按上述 ClassIsland 横条恢复半透明深色底、1px 描边、8px 默认圆角和 14/16/18/20 四档字体,内容居中。
通知继续共用经典操作组件,使用同一半透明岛壳。课程组件按「课程名 起始时间–结束时间」横向排列,时间位于名称右侧,开关文案改为「显示课程时间」。
LessonReminderTracker 只读取服务端已解析的当天 ScheduleDto 投影,不参与 ClassPlan 在线启用与轮换选择。
课前 60 秒开始:3 秒强调遮罩后显示实时秒数和课程时间,持续到上课;上课遮罩 5 秒后退出。
下课先提示 2 秒,再预告下节课程 10 秒;当天最后一课下课只提示 5 秒。不补弹已经过去的上下课事件。
刷新同一课表不会重置遮罩,临时换课立即更新名称,移除课程或退出账号会取消提醒。
提醒是独立本机状态,不写入教师通知、未读计数或通知队列;用户主动打开的通知详情和紧急提示优先展示,自动补弹的普通通知不再挡住上下课提醒。
经典与自定义两种布局使用同一个 LessonReminderContent;原生窗口仍为固定画布,自定义岛用自然尺寸缩放适配。
本次只移植课表可见提醒和动画,不自动启用语音与音效。
## 课程进度、即将下课与主窗口编辑(本批补齐)
- 对照上游 `LessonControlExpanded.axaml` 的 `LeftSeconds` 和 `ProgressPercent`,课程组件增加持续的本节进度条与距下课时间,课间自动显示下一课;起止时间保持在课程名右侧,按秒更新。
- 倒计时组件支持自动上下课、本节下课、下一节课、放学四种目标,按当天课表排序,排除其它日期。已有配置缺少新字段时自动补齐。
- 默认最后 60 秒显示即将下课倒数;经典岛在上课遮罩后持续显示本节课程、剩余时间与进度,自定义岛平时由组件显示进度,时间点提醒接管同一个岛体。
- 设置→提醒提供课前、上课、即将下课、下课独立开关,以及经典岛课中进度开关;课前提前 5–1800 秒,下课倒数提前 5–600 秒,保存后即刻生效。
- 自定义编辑器移到主窗口 `/settings/island/editor`;设置和兼容 IPC 入口都导航到主窗口,浮岛不再被扩大成全屏编辑窗口。主窗口保留侧栏,画布按内容与可用空间自动缩放,支持触摸跨行拖动、容器和独立常态/展开内容。
- 编辑使用独立预览,不上报桌面岛尺寸;配置保存按顺序执行。关闭编辑返回灵动岛设置。
## 管理后台界面与二级菜单
按用户确认,本批“重写后端”指 Web 管理后台界面与导航。参考 art-design-pro 的 sidebar-menu、breadcrumb、header-bar,重写工作区框架,服务端接口、鉴权和既有业务页面保留。
- 工作台、教学事务、班级与人员、设备运维、系统管理五个一级分组;原有全部业务入口位于二级菜单。
- 菜单直接读取路由的标题、图标、角色与生效权限;空分组隐藏,搜索结果和页签也按相同权限过滤,深链继续经过路由守卫。
- 增加面包屑、最多 20 个可关闭页签、关闭其它页签、Ctrl+K 功能搜索;页签保留该页面的完整查询参数。
- 统一浅/深主题下的侧栏、顶栏、卡片、表格和页面留白;仪表盘增加真实账号欢迎区与按权限显示的快捷入口。手机端使用同一棵二级菜单的抽屉。
- 回归使用独立临时 SQLite、临时教师和班级;不读取或改写真实班级业务数据。
### 老师消息阅读背景
通知、作业、叫人内容展示时,两种布局统一使用 `#202020` 不透明背景,原生窗口透明度临时为 1,玻璃风格和用户设置的透明度不会降低消息可读性。常驻组件、空闲展开和课程提醒仍按原有透明度显示,收起消息后恢复本机透明度偏好。
+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 -191
View File
@@ -1,210 +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`(形如 `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 导入。
服务端兼容旧客户端的心跳字段读取,旧设备可领取修复版;第一次升级仍遵循旧版空闲安装条件,新策略需修复版客户端。
## 日课表与岗位范围(2026-10-17)
| 能力 | 新权限键 | 默认角色 |
| -------------------------- | ------------------ | -------- |
| 全校时间表与时间点管理 | `timetable:manage` | ADMIN |
| 安装包、目标版本与安装策略 | `client:update` | ADMIN |
`client:update-push` 只保留旧配置兼容标识;下发与安装包写入口统一检查 `client:update`。
新增权限同步出现在 shared 的键目录、标签、类别与默认角色表;升级迁移为管理员默认组补齐新键。
| 内置教师岗位组 | 默认功能 | 数据范围 |
| -------------- | ---------------------------------------- | -------------------------------- |
| 班主任 | 教学内容 + 查看/管理设备,不含版本下发 | 自己担任班主任的班级 |
| 教师 | 作业、成绩、通知、叫人、课表与名单读取 | 班主任与任教班级 |
| 级长 | 设备、远控、名单/班级/课表/成绩/看板读取 | 管理员配置的年级 |
| 部长 | 同级长 | 管理员配置的一组年级(逗号分隔) |
四组 `builtin=true`、`defaultRole=null`;账号仍是 TEACHER。实际范围来自分组并集,
空年级/部范围不授权。通用查询通过 `teacherScopeWhere/resolveClassScope`,
设备写入还只合并含对应功能键的分组范围。管理员的 `permission:manage/system:manage` 不可剥夺。
范围从不赋予科目编辑权,仍须 `assertCanManageSubjectContent` 检查 Course 任课关系。
教师名单页复用学生管理视图与导出,路由 `roster:read`,隐藏编辑/调班/导入操作;服务端过滤范围和转出学生。
成绩关闭个人查询时只返回服务端聚合;班级端无个人明细、排名或极值,稀疏样本抑制,教师/管理员不受该个人查询开关影响。
旧时间表导入接口的写入/删除也要求 `timetable:manage`,避免绕过统一时间表权限;离线整套课表导入仍可由本班班主任使用,创建班级自有时间点。
`/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 测试库,没有执行真实关机或安装;未运行会占用桌面的完整视觉验收。
+51 -113
View File
@@ -1,131 +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 verify:e2e # 自建全新隔离库与临时账号,不依赖真实服务
pnpm verify:v14 # 新课表/权限/年度升级专项隔离验收
pnpm build:desktop && pnpm verify:desktop
pnpm build && pnpm verify:web
pnpm lint
pnpm typecheck
pnpm check:icons
pnpm docs:check
pnpm verify:e2e
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 里文本齐全,只有图是空的)。
## V1.4 专项验收
`verify:e2e` 由根启动器创建独立数据库、带前缀的临时账号与服务;服务禁止外网,请勿改回真实班级。`verify:v14` 在专用临时库验证新模型与自动升级幂等。详细结果、迁移保留情况和十项自检见 [实施记录](20261017-implementation.md)。
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 通过表述为动画/透明度已验证。
+2 -4
View File
@@ -16,10 +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: 2026-10-17 实施与验收
href: 20261017-implementation.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 -1612
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/**',
+3 -1
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",
@@ -29,6 +29,7 @@
"db:switch:mysql": "node scripts/use-database.mjs mysql",
"db:switch:sqlite": "node scripts/use-database.mjs sqlite",
"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",
@@ -37,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 标签体系时,这一步会被系统拒绝(保持继承即可),属正常,不提示。
@@ -1,260 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
ISLAND_COMPONENT_LABELS,
type IslandComponentKind,
type IslandComponentNode,
} from '@classhelper/shared';
const props = defineProps<{ node: IslandComponentNode }>();
const node = computed(() => props.node);
const emit = defineEmits<{
change: [];
remove: [];
duplicate: [];
wrap: [kind: IslandComponentKind];
}>();
const tab = ref('content');
watch(
() => props.node.id,
() => (tab.value = 'content'),
);
const fonts = {
secondaryFontSize: '辅助文字',
bodyFontSize: '正文',
emphasizedFontSize: '强调文字',
largeFontSize: '大号文字',
} as const;
</script>
<template>
<div class="inspector-heading">
<span class="eyebrow">组件设置</span>
<h2>{{ ISLAND_COMPONENT_LABELS[node.kind] }}</h2>
<p>只影响当前选中的组件</p>
</div>
<nav class="panel-tabs" aria-label="组件设置分类">
<button
v-for="item in [
['content', '内容'],
['appearance', '外观'],
['advanced', '高级'],
]"
:key="item[0]"
:class="{ active: tab === item[0] }"
@click="tab = item[0]!"
>
{{ item[1] }}
</button>
</nav>
<div class="inspector-body" @change="emit('change')">
<template v-if="tab === 'content'">
<section class="setting-section">
<h3>显示内容</h3>
<template v-if="node.kind === 'clock'">
<label class="toggle-field">
<span>12 小时制</span>
<input type="checkbox" v-model="node.settings.hour12" />
</label>
<label class="toggle-field">
<span>显示秒</span>
<input type="checkbox" v-model="node.settings.seconds" />
</label>
</template>
<label v-else-if="node.kind === 'countdown'" class="field">
倒计时目标
<select v-model="node.settings.target">
<option value="auto">自动:课中距下课,课间距上课</option>
<option value="next">下一节课</option>
<option value="end">本节下课</option>
<option value="school">放学</option>
</select>
</label>
<template v-else-if="node.kind === 'text'">
<label class="field">
文字内容
<textarea rows="4" v-model="node.settings.text" />
</label>
<label class="toggle-field">
<span>使用模板</span>
<input type="checkbox" v-model="node.settings.template" />
</label>
<p class="field-hint">可用变量:{date} 日期、{time} 时间、{course} 当前课程。</p>
</template>
<template v-else-if="node.kind === 'schedule'">
<label class="field">
显示方式
<select v-model="node.settings.display">
<option value="current">当前课程</option>
<option value="next">下一节课</option>
<option value="today">今日课表</option>
</select>
</label>
<label v-if="node.settings.display === 'today'" class="field inline-field">
显示节数
<input type="number" min="1" max="30" v-model.number="node.settings.count" />
</label>
<label class="toggle-field">
<span>显示课程时间</span>
<input type="checkbox" v-model="node.settings.showPeriod" />
</label>
<label class="toggle-field">
<span>显示上课进度条</span>
<input type="checkbox" v-model="node.settings.showProgress" />
</label>
</template>
<template v-else-if="['group', 'stack', 'slide', 'rolling'].includes(node.kind)">
<label class="field">
排列方向
<select v-model="node.settings.direction">
<option value="horizontal">横向</option>
<option value="vertical">纵向</option>
</select>
</label>
<label class="field inline-field">
组件间距
<input type="number" min="0" max="80" v-model.number="node.settings.gap" />
</label>
<label v-if="node.kind === 'slide'" class="field inline-field">
切换间隔(秒)
<input type="number" min="1" max="300" v-model.number="node.settings.interval" />
</label>
<label v-if="node.kind === 'rolling'" class="field inline-field">
滚动速度
<input type="number" min="1" max="200" v-model.number="node.settings.speed" />
</label>
<p class="field-hint">将组件拖到此容器内,即可一起排列。</p>
</template>
<p v-else class="field-hint">
{{
['notification', 'homework', 'call'].includes(node.kind)
? '收到消息后在岛内显示,沿用原有的展开、已读和关闭操作。没有消息时不占空间。'
: '此组件无需额外内容设置,可在「外观」中调整显示。'
}}
</p>
</section>
<section class="setting-section">
<h3>组件操作</h3>
<button class="wide-button" @click="emit('duplicate')">创建副本</button>
<label class="field">
包裹到新容器
<select
data-action="wrap"
:value="''"
@change="emit('wrap', ($event.target as HTMLSelectElement).value as IslandComponentKind)"
>
<option value="" disabled>选择容器类型</option>
<option v-for="kind in ['slide', 'rolling', 'group', 'stack'] as const" :key="kind" :value="kind">
{{ ISLAND_COMPONENT_LABELS[kind] }}
</option>
</select>
</label>
<button class="wide-button danger-button" @click="emit('remove')">删除组件</button>
</section>
</template>
<template v-else-if="tab === 'appearance'">
<section class="setting-section">
<label class="toggle-field">
<span>独立外观</span>
<input type="checkbox" v-model="node.appearance.resourceOverridingEnabled" />
</label>
<p class="field-hint">关闭时使用灵动岛的整体外观。</p>
</section>
<fieldset :disabled="!node.appearance.resourceOverridingEnabled">
<section class="setting-section">
<h3>字体大小</h3>
<label v-for="(label, key) in fonts" :key="key" class="field inline-field">
{{ label }}
<input type="number" min="8" max="96" v-model.number="node.appearance[key]" />
</label>
</section>
<section class="setting-section">
<h3>颜色与形状</h3>
<label class="toggle-field">
<span>自定义文字颜色</span>
<input type="checkbox" v-model="node.appearance.customForegroundEnabled" />
</label>
<label v-if="node.appearance.customForegroundEnabled" class="field inline-field">
文字颜色
<input type="color" v-model="node.appearance.foreground" />
</label>
<label class="toggle-field">
<span>自定义背景颜色</span>
<input type="checkbox" v-model="node.appearance.customBackgroundEnabled" />
</label>
<label v-if="node.appearance.customBackgroundEnabled" class="field inline-field">
背景颜色
<input type="color" v-model="node.appearance.background" />
</label>
<label class="toggle-field">
<span>自定义背景透明度</span>
<input type="checkbox" v-model="node.appearance.customBackgroundOpacityEnabled" />
</label>
<label v-if="node.appearance.customBackgroundOpacityEnabled" class="field inline-field">
背景透明度
<input
type="number"
min="0"
max="1"
step="0.05"
v-model.number="node.appearance.backgroundOpacity"
/>
</label>
<label class="toggle-field">
<span>自定义圆角</span>
<input type="checkbox" v-model="node.appearance.customCornerRadiusEnabled" />
</label>
<label v-if="node.appearance.customCornerRadiusEnabled" class="field inline-field">
圆角
<input type="number" min="0" max="100" v-model.number="node.appearance.cornerRadius" />
</label>
</section>
</fieldset>
<section class="setting-section">
<label class="field inline-field">
组件透明度
<input type="number" min="0" max="1" step="0.05" v-model.number="node.appearance.opacity" />
</label>
</section>
</template>
<template v-else>
<section class="setting-section">
<h3>尺寸与对齐</h3>
<label class="field">
对齐方式
<select v-model="node.advanced.align">
<option value="stretch">拉伸</option>
<option value="start">起始</option>
<option value="center">居中</option>
<option value="end">末端</option>
</select>
</label>
<p class="field-hint">父容器设有宽度时生效。</p>
<label class="field inline-field">
固定宽度
<input type="number" min="0" max="2000" v-model.number="node.advanced.width" />
</label>
<label class="field inline-field">
最大宽度
<input type="number" min="0" max="2000" v-model.number="node.advanced.maxWidth" />
</label>
<p class="field-hint">单位为像素,0 表示自适应;超过最大宽度的内容从右侧截断。</p>
</section>
<section class="setting-section">
<h3>隐藏条件</h3>
<p class="field-hint">任一条件满足时隐藏,且不占用空间。</p>
<label
v-for="(label, rule) in {
'in-class': '上课时',
'no-message': '没有消息时',
'no-class': '没有当前课程时',
}"
:key="rule"
class="toggle-field"
>
<span>{{ label }}</span>
<input type="checkbox" v-model="node.advanced.hideRules" :value="rule" />
</label>
</section>
</template>
</div>
</template>
@@ -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>
@@ -1,815 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type CSSProperties } from 'vue';
import {
ISLAND_COMPONENT_LABELS,
CLASSISLAND_ISLAND_METRICS as CI,
ISLAND_SHADOW_PAD,
islandNode,
normalizeIslandLayouts,
type IslandLayoutSettings,
type IslandAppearance,
type IslandComponentKind,
type IslandComponentNode,
type IslandState,
type ScheduleDto,
} from '@classhelper/shared';
import ComponentNode from './ComponentNode.vue';
import ComponentInspector from './ComponentInspector.vue';
import ComponentIcon from './ComponentIcon.vue';
const props = defineProps<{
settings: IslandLayoutSettings;
state: IslandState;
editing: boolean;
appearance: IslandAppearance;
schedules: ScheduleDto[];
embedded?: boolean;
}>();
const emit = defineEmits<{
save: [settings: IslandLayoutSettings];
done: [];
host: [element: HTMLElement | null];
}>();
function hostRef(element: unknown) {
emit('host', element instanceof HTMLElement ? element : null);
}
const draft = ref(normalizeIslandLayouts(props.settings));
watch(
() => props.settings,
(s) => {
draft.value = normalizeIslandLayouts(s);
},
{ deep: true },
);
const scheme = computed(() => draft.value.schemes.find((s) => s.id === draft.value.activeSchemeId)!);
const editingSurface = ref<'compact' | 'expanded'>('compact');
const pendingCount = computed(
() => new Set([props.state.active?.id, ...props.state.queued.map((item) => item.id)].filter(Boolean)).size,
);
const residentCompact = computed(
() => draft.value.residentEnabled && !props.editing && props.state.mode !== 'expanded',
);
const idleExpanded = computed(
() =>
draft.value.residentEnabled &&
!props.editing &&
props.state.mode === 'expanded' &&
pendingCount.value === 0,
);
const rows = computed(() =>
(props.editing ? editingSurface.value === 'expanded' : idleExpanded.value)
? scheme.value.expandedRows
: scheme.value.rows,
);
function setSurface(surface: 'compact' | 'expanded') {
editingSurface.value = surface;
selectedId.value = '';
selectedRowId.value = '';
panel.value = 'appearance';
}
function openResident(event: Event) {
if (!residentCompact.value || (event.target as HTMLElement).closest('button, a, input, select, textarea'))
return;
window.island?.sendAction('expand');
}
function residentKey(event: KeyboardEvent) {
if (event.target !== event.currentTarget) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openResident(event);
}
}
function expandResident() {
window.island?.sendAction('expand');
}
function collapseResident() {
window.island?.sendAction('collapse');
}
function onPointerDown(event: PointerEvent) {
if (event.pointerType === 'touch' || event.pointerType === 'pen') window.island?.noteResidentTouch();
}
const selectedId = ref(''),
selectedKind = ref<IslandComponentKind>('clock');
const panel = ref<'component' | 'row' | 'appearance'>('appearance');
const selectedRowId = ref('');
const activeRow = computed(() => rows.value.find((r) => r.id === selectedRowId.value) || rows.value[0]!);
const componentGroups = [
{ name: '日常信息', kinds: ['clock', 'date', 'schedule', 'countdown', 'text', 'separator'] },
{ name: '消息提醒', kinds: ['notification', 'homework', 'call'] },
{ name: '组合容器', kinds: ['group', 'stack', 'slide', 'rolling'] },
] as const;
const savedAt = ref('');
function selectNode(nodeId: string) {
selectedId.value = nodeId;
panel.value = 'component';
}
function selectRow(rowId: string) {
selectedRowId.value = rowId;
selectedId.value = '';
panel.value = 'row';
}
function switchScheme() {
selectedId.value = '';
selectedRowId.value = '';
panel.value = 'appearance';
save();
}
const now = ref(Date.now()),
canvasScale = ref(1),
pan = ref({ x: 0, y: 0 });
const canvas = ref<HTMLElement>();
const previewStage = ref<HTMLElement>();
const autoScale = ref(1);
const previewScale = computed(() => canvasScale.value * autoScale.value);
const shell = ref<HTMLElement>(),
inner = ref<HTMLElement>();
const viewport = ref({ width: 0, height: 0 });
function allNodes(nodes: IslandComponentNode[]): IslandComponentNode[] {
return nodes.flatMap((n) => [n, ...allNodes(n.children)]);
}
const selected = computed(() =>
rows.value.flatMap((r) => allNodes(r.nodes)).find((n) => n.id === selectedId.value),
);
const alertRow = computed(
() =>
rows.value.find((r) => r.main && r.allowNotifications) || rows.value.find((r) => r.allowNotifications),
);
const alertNodeId = computed(() => {
if (residentCompact.value || idleExpanded.value) return '';
const kind = props.state.active?.kind ?? 'notification';
return (
(alertRow.value &&
allNodes(alertRow.value.nodes).find((n) => n.kind === kind || n.kind === 'notification')?.id) ||
''
);
});
const hasMessage = computed(
() =>
props.state.mode !== 'hidden' &&
Boolean(props.state.active) &&
(!draft.value.residentEnabled || props.state.mode === 'expanded') &&
!props.editing,
);
const shellStyle = computed<CSSProperties>(() => ({
width: viewport.value.width ? `${viewport.value.width}px` : 'max-content',
minHeight: !props.editing && viewport.value.height ? `${viewport.value.height}px` : undefined,
maxWidth: `calc(100vw - ${2 * ISLAND_SHADOW_PAD}px)`,
maxHeight: `calc(100vh - ${2 * ISLAND_SHADOW_PAD}px)`,
color: '#ffffffeb',
fontSize: `${(props.appearance.fontSize / 13) * CI.bodyFontSize}px`,
'--custom-color': '#20202080',
'--custom-radius': `${scheme.value.radius}px`,
'--custom-row-gap': `${scheme.value.spacing}px`,
...(draft.value.residentEnabled
? {
'--secondary': `${CI.secondaryFontSize}px`,
'--emphasized': `${CI.emphasizedFontSize}px`,
'--large': `${CI.largeFontSize}px`,
'--resident-height': `${CI.rowHeight}px`,
'--resident-padding': `${CI.horizontalPadding * scheme.value.scale}px`,
}
: {}),
gap: `${scheme.value.spacing}px`,
}));
function id() {
return `node-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function save() {
const value = normalizeIslandLayouts(JSON.parse(JSON.stringify(draft.value)));
draft.value = value;
emit('save', value);
savedAt.value = '已自动保存';
}
function addRow(after: number) {
rows.value.splice(after + 1, 0, {
id: id(),
name: '新主界面行',
main: false,
allowNotifications: true,
gap: 8,
nodes: [],
});
selectRow(rows.value[after + 1]!.id);
save();
}
function add(kind = selectedKind.value, row = activeRow.value) {
if (!row) return;
const n = islandNode(kind, id());
row.nodes.push(n);
selectNode(n.id);
save();
}
function findList(nodeId: string, nodes?: IslandComponentNode[]): IslandComponentNode[] | null {
for (const list of nodes ? [nodes] : rows.value.map((r) => r.nodes)) {
if (list.some((n) => n.id === nodeId)) return list;
for (const n of list) {
const found = findList(nodeId, n.children);
if (found) return found;
}
}
return null;
}
function remove() {
const list = findList(selectedId.value);
if (list)
list.splice(
list.findIndex((n) => n.id === selectedId.value),
1,
);
selectedId.value = '';
panel.value = 'row';
save();
}
function duplicate() {
const n = selected.value,
list = findList(selectedId.value);
if (!n || !list) return;
const copy = JSON.parse(JSON.stringify(n)) as IslandComponentNode;
for (const child of allNodes([copy])) child.id = id();
list.splice(list.indexOf(n) + 1, 0, copy);
selectNode(copy.id);
save();
}
function wrap(kind: IslandComponentKind) {
if (!['slide', 'rolling', 'group', 'stack'].includes(kind)) return;
const n = selected.value,
list = findList(selectedId.value);
if (!n || !list) return;
const container = islandNode(kind, id());
container.children = [n];
list.splice(list.indexOf(n), 1, container);
selectNode(container.id);
save();
}
function newScheme() {
const name = `新方案 ${draft.value.schemes.length + 1}`;
const copy = JSON.parse(JSON.stringify(scheme.value));
copy.id = id();
copy.name = name;
for (const r of [...copy.rows, ...copy.expandedRows]) {
r.id = id();
for (const n of allNodes(r.nodes)) n.id = id();
}
draft.value.schemes.push(copy);
draft.value.activeSchemeId = copy.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}.json`;
a.click();
URL.revokeObjectURL(url);
}
const error = ref('');
async function importScheme(event: Event) {
try {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file || file.size > 1_000_000) throw new Error('请选择小于 1MB 的 JSON');
const parsed = JSON.parse(await file.text());
if (parsed.format !== 'classhelper-island-layout') throw new Error('方案格式不正确');
const value = normalizeIslandLayouts({
layout: 'custom',
activeSchemeId: parsed.scheme.id,
schemes: [parsed.scheme],
});
const s = value.schemes[0]!;
s.id = id();
draft.value.schemes.push(s);
draft.value.activeSchemeId = s.id;
switchScheme();
error.value = '';
} catch (e) {
error.value = String(e);
}
}
let drag: {
type: 'node' | 'library' | 'row';
id: string;
kind?: IslandComponentKind;
rowId?: string;
targetId?: string;
} | null = null;
function startDrag(
event: PointerEvent,
type: 'node' | 'library' | 'row',
nodeId: string,
kind?: IslandComponentKind,
) {
event.preventDefault();
event.stopPropagation();
try {
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
} catch {
/* 合成事件无活动指针;真实触摸使用捕获 */
}
drag = { type, id: nodeId, kind };
}
function move(event: PointerEvent) {
if (!drag) return;
const element = document.elementFromPoint(event.clientX, event.clientY);
drag.rowId = element?.closest<HTMLElement>('[data-row]')?.dataset.row;
drag.targetId = element?.closest<HTMLElement>('[data-node]')?.dataset.node;
}
function drop() {
if (!drag) return;
const moving = drag;
drag = null;
const row = rows.value.find((r) => r.id === moving.rowId);
if (!row) return;
if (moving.type === 'library') {
add(moving.kind, row);
return;
}
if (moving.type === 'row') {
const from = rows.value.findIndex((r) => r.id === moving.id),
to = rows.value.indexOf(row);
if (from >= 0) rows.value.splice(to, 0, rows.value.splice(from, 1)[0]!);
save();
return;
}
const list = findList(moving.id),
node = list?.find((n) => n.id === moving.id);
if (!list || !node || allNodes([node]).some((n) => n.id === moving.targetId)) return;
const target = allNodes(row.nodes).find((n) => n.id === moving.targetId);
list.splice(list.indexOf(node), 1);
if (target && ['group', 'stack', 'slide', 'rolling'].includes(target.kind)) target.children.push(node);
else if (target) {
const targetList = findList(target.id)!;
targetList.splice(targetList.indexOf(target), 0, node);
} else row.nodes.push(node);
selectNode(node.id);
save();
}
let panning: { x: number; y: number; originX: number; originY: number } | null = null;
function panStart(e: PointerEvent) {
if (e.target !== e.currentTarget) return;
panning = { x: e.clientX, y: e.clientY, originX: pan.value.x, originY: pan.value.y };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function panMove(e: PointerEvent) {
if (panning)
pan.value = { x: panning.originX + e.clientX - panning.x, y: panning.originY + e.clientY - panning.y };
move(e);
}
let stopViewport: (() => void) | undefined;
let observer: ResizeObserver | undefined,
measureTimer: ReturnType<typeof setTimeout> | undefined,
clock: ReturnType<typeof setInterval>;
function measure() {
clearTimeout(measureTimer);
measureTimer = setTimeout(() => {
if (props.embedded && shell.value && canvas.value) {
const padding = getComputedStyle(canvas.value);
const availableWidth =
canvas.value.clientWidth - parseFloat(padding.paddingLeft) - parseFloat(padding.paddingRight);
const availableHeight =
canvas.value.clientHeight - parseFloat(padding.paddingTop) - parseFloat(padding.paddingBottom);
autoScale.value = Math.min(
1,
Math.max(80, availableWidth) /
Math.max(1, previewStage.value?.offsetWidth ?? shell.value.offsetWidth),
Math.max(100, availableHeight) /
Math.max(1, previewStage.value?.offsetHeight ?? shell.value.offsetHeight),
);
return;
}
if (inner.value) {
// 内部组件的自然尺寸决定整个岛体;提醒不再拥有另一层岛壳。
const rect = inner.value.getBoundingClientRect(),
padding = shell.value ? getComputedStyle(shell.value) : null,
scale = props.editing ? canvasScale.value : 1;
window.island?.reportSize({
width: Math.ceil(
rect.width / scale +
parseFloat(padding?.paddingLeft || '0') +
parseFloat(padding?.paddingRight || '0'),
),
height: Math.ceil(
rect.height / scale +
parseFloat(padding?.paddingTop || '0') +
parseFloat(padding?.paddingBottom || '0'),
),
});
const control = shell.value?.querySelector('.resident-bell')?.getBoundingClientRect();
window.island?.reportResidentControl(
control ? { x: control.x, y: control.y, width: control.width, height: control.height } : null,
);
}
}, 120);
}
watch([() => props.editing, scheme, () => props.state], () => void nextTick(measure), { deep: true });
onMounted(() => {
clock = setInterval(() => (now.value = Date.now()), 1000);
observer = new ResizeObserver(measure);
if (inner.value) observer.observe(inner.value);
if (canvas.value) observer.observe(canvas.value);
if (previewStage.value) observer.observe(previewStage.value);
stopViewport = window.island?.onViewport((v) => (viewport.value = v));
measure();
});
onUnmounted(() => {
clearInterval(clock);
clearTimeout(measureTimer);
stopViewport?.();
observer?.disconnect();
});
</script>
<template>
<div
:class="editing ? 'layout-editor' : 'custom-window'"
:data-embedded="embedded || undefined"
@pointerdown.capture="onPointerDown"
@pointermove="panMove"
@pointerup="
drop();
panning = null;
"
@pointercancel="
drag = null;
panning = null;
"
>
<header v-if="editing" class="editor-header">
<div class="editor-title">
<span class="editor-mark" aria-hidden="true">◒</span>
<div>
<h1>自定义灵动岛</h1>
<p>把需要的信息,放进岛里</p>
</div>
</div>
<div class="scheme-picker">
<span>当前方案</span>
<select aria-label="当前方案" v-model="draft.activeSchemeId" @change="switchScheme">
<option v-for="s in draft.schemes" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
<details class="scheme-menu">
<summary aria-label="方案操作">•••</summary>
<div class="scheme-menu-body">
<button @click="newScheme">复制为新方案</button>
<button @click="exportScheme">导出方案</button>
<label class="import-button">
导入方案
<input type="file" accept="application/json" @change="importScheme" />
</label>
</div>
</details>
</div>
<span class="save-state" role="status">{{ savedAt || '更改会自动保存' }}</span>
<button
class="primary-button done-button"
@click="
save();
emit('done');
"
>
完成
</button>
</header>
<aside v-if="editing" class="library">
<div class="panel-heading">
<h2>添加组件</h2>
<p>拖入岛内,或选中后添加</p>
</div>
<section v-for="group in componentGroups" :key="group.name" class="library-group">
<h3>{{ group.name }}</h3>
<div class="component-grid">
<button
v-for="kind in group.kinds"
:key="kind"
class="library-item"
:class="{ active: selectedKind === kind }"
:aria-pressed="selectedKind === kind"
@pointerdown="startDrag($event, 'library', '', kind)"
@click="selectedKind = kind"
@dblclick="add(kind)"
>
<span class="component-symbol"><ComponentIcon :kind="kind" /></span>
<span>{{ ISLAND_COMPONENT_LABELS[kind] }}</span>
</button>
</div>
</section>
<div class="library-add">
<label class="field">
添加到
<select :value="activeRow.id" @change="selectedRowId = ($event.target as HTMLSelectElement).value">
<option v-for="row in rows" :key="row.id" :value="row.id">{{ row.name }}</option>
</select>
</label>
<button class="primary-button wide-button" @click="add()">
添加{{ ISLAND_COMPONENT_LABELS[selectedKind] }}
</button>
</div>
</aside>
<main ref="canvas" :class="{ canvas: editing }" @pointerdown="panStart">
<div v-if="editing" class="canvas-toolbar">
<nav class="surface-tabs" aria-label="编辑自定义灵动岛内容">
<button :class="{ active: editingSurface === 'compact' }" @click="setSurface('compact')">
常态内容
</button>
<button :class="{ active: editingSurface === 'expanded' }" @click="setSurface('expanded')">
无通知展开时
</button>
</nav>
<div>
<button
:class="{ active: panel === 'appearance' }"
@click="
panel = 'appearance';
selectedId = '';
"
>
整体外观
</button>
<button
@click="
pan = { x: 0, y: 0 };
canvasScale = 1;
"
>
重置视图
</button>
</div>
</div>
<div
ref="previewStage"
class="preview-stage"
:style="editing ? { transform: `translate(${pan.x}px,${pan.y}px) scale(${previewScale})` } : {}"
>
<div
ref="shell"
class="custom-card"
:class="{ separated: scheme.separated, 'has-alert': hasMessage, 'resident-size': residentCompact }"
:role="residentCompact ? 'button' : undefined"
:tabindex="residentCompact ? 0 : undefined"
:aria-label="
residentCompact ? (pendingCount ? `查看 ${pendingCount} 条通知` : '展开常驻岛') : undefined
"
:aria-expanded="draft.residentEnabled && !editing ? state.mode === 'expanded' : undefined"
@click="openResident"
@keydown="residentKey"
:data-priority="hasMessage ? state.active?.priority : undefined"
:style="[shellStyle, editing ? { width: 'max-content', maxWidth: 'none', maxHeight: 'none' } : {}]"
>
<div
ref="inner"
class="custom-inner"
:class="{ 'resident-compact': residentCompact }"
:style="{ zoom: scheme.scale }"
>
<header v-if="idleExpanded" class="resident-panel-heading">
<span>常驻岛</span>
<button aria-label="收起常驻岛" @click.stop="collapseResident">收起 ↑</button>
</header>
<div class="layout-rows">
<section
v-for="row in rows"
:key="row.id"
:data-row="row.id"
class="layout-row"
:class="{ 'row-selected': editing && selectedRowId === row.id, 'row-editing': editing }"
:style="{ '--row-gap': `${row.gap}px` }"
>
<div v-if="editing" class="row-tools">
<button
class="row-drag"
aria-label="拖动行"
@pointerdown="startDrag($event, 'row', row.id)"
>
⠿
</button>
<button class="row-name" @click="selectRow(row.id)">
{{ row.name }}
<span v-if="row.main">主要行</span>
</button>
<button class="row-settings" :aria-label="`${row.name}设置`" @click="selectRow(row.id)">
行设置
</button>
</div>
<div class="row-content" @click.self="editing && selectRow(row.id)">
<div class="row-components">
<ComponentNode
v-for="n in row.nodes.filter(
(n) => !['notification', 'homework', 'call'].includes(n.kind),
)"
:key="n.id"
:node="n"
:state="state"
:now="now"
:schedules="schedules"
:editing="editing"
:selected-id="selectedId"
:alert-node-id="alertNodeId"
@select="selectNode"
@drag="(e, id) => startDrag(e, 'node', id)"
>
<template #alerts>
<div id="custom-alert-host" :ref="hostRef" v-show="hasMessage" />
</template>
</ComponentNode>
</div>
<ComponentNode
v-for="n in row.nodes.filter((n) =>
['notification', 'homework', 'call'].includes(n.kind),
)"
:key="n.id"
:node="n"
:state="state"
:now="now"
:schedules="schedules"
:editing="editing"
:selected-id="selectedId"
:alert-node-id="alertNodeId"
@select="selectNode"
@drag="(e, id) => startDrag(e, 'node', id)"
>
<template #alerts>
<div id="custom-alert-host" :ref="hostRef" v-show="hasMessage" />
</template>
</ComponentNode>
<div
v-if="row.id === alertRow?.id && !alertNodeId"
id="custom-alert-host"
:ref="hostRef"
v-show="hasMessage"
/>
<span v-if="editing && !row.nodes.length" class="empty-row">拖入组件,或从左侧添加</span>
</div>
</section>
</div>
<button
v-if="residentCompact"
class="resident-bell"
:class="{ 'bell-empty': !pendingCount }"
:aria-label="pendingCount ? `查看 ${pendingCount} 条通知` : '展开常驻岛'"
@click.stop="expandResident"
>
<ComponentIcon v-if="pendingCount" kind="notification" />
<svg
v-else
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
stroke-width="1.6"
aria-hidden="true"
>
<path d="m8 10 4 4 4-4" />
</svg>
<span v-if="pendingCount">{{ pendingCount > 99 ? '99+' : pendingCount }}</span>
</button>
</div>
</div>
<button
v-if="editing"
class="add-row-button"
@click="addRow(rows.findIndex((r) => r.id === activeRow.id))"
>
+ 在下方新建主界面行
</button>
</div>
<div v-if="editing" class="canvas-footer">
<span>点选组件以设置 · 拖动句柄以排序 · 空白处可平移</span>
<label>
缩放
<input
aria-label="画布缩放"
type="range"
v-model.number="canvasScale"
min="0.3"
max="2"
step="0.1"
/>
<output>{{ Math.round(canvasScale * 100) }}%</output>
</label>
</div>
</main>
<div v-if="!alertRow" id="custom-alert-host" :ref="hostRef" style="display: none" />
<aside v-if="editing" class="settings">
<ComponentInspector
v-if="panel === 'component' && selected"
:node="selected"
@change="save"
@remove="remove"
@duplicate="duplicate"
@wrap="wrap"
/>
<template v-else-if="panel === 'row'">
<div class="inspector-heading">
<span class="eyebrow">布局设置</span>
<h2>组件行</h2>
<p>管理这一行的排列与提醒</p>
</div>
<div class="inspector-body" @change="save">
<section class="setting-section">
<h3>基本设置</h3>
<label class="field">
行名称
<input v-model="activeRow.name" />
</label>
<label class="field inline-field">
组件间距
<input type="number" min="0" max="80" v-model.number="activeRow.gap" />
</label>
</section>
<section class="setting-section">
<h3>消息提醒</h3>
<label class="toggle-field">
<span>允许显示提醒</span>
<input type="checkbox" v-model="activeRow.allowNotifications" />
</label>
<button
class="wide-button"
:disabled="activeRow.main"
@click="
rows.forEach((r) => (r.main = r.id === activeRow.id));
save();
"
>
{{ activeRow.main ? '已设为主要行' : '设为主要行' }}
</button>
<p class="field-hint">提醒优先显示在主要行内;若该行禁用提醒,则显示在下一条允许提醒的行内。</p>
</section>
<section class="setting-section">
<button class="wide-button" @click="addRow(rows.indexOf(activeRow))">在此行下方新增行</button>
<button
class="wide-button danger-button"
:disabled="rows.length < 2"
@click="
rows.splice(rows.indexOf(activeRow), 1);
selectedRowId = '';
save();
"
>
删除这一行
</button>
</section>
</div>
</template>
<template v-else>
<div class="inspector-heading">
<span class="eyebrow">方案设置</span>
<h2>整体外观</h2>
<p>
{{
draft.residentEnabled
? editingSurface === 'compact'
? '正在编辑收起时的常驻内容'
: '正在编辑没有通知时的展开内容'
: '所有组件共用一个灵动岛'
}}
</p>
</div>
<div class="inspector-body" @change="save">
<section class="setting-section">
<p class="field-hint">
通知、外观与弹出动画沿用经典灵动岛。在应用的灵动岛设置中调整颜色、字号、圆角和动画速度。
</p>
</section>
<section class="setting-section">
<h3>当前方案</h3>
<label class="field">
方案名称
<input v-model="scheme.name" />
</label>
<label class="field inline-field">
界面缩放
<input
type="number"
v-model.number="scheme.scale"
:min="draft.residentEnabled ? CI.scaleMin : 0.5"
:max="draft.residentEnabled ? CI.scaleMax : 3"
step="0.05"
/>
</label>
</section>
<section class="setting-section">
<h3>多行布局</h3>
<label class="field inline-field">
行间距
<input
type="number"
v-model.number="scheme.spacing"
min="0"
:max="draft.residentEnabled ? CI.rowSpacingMax : 100"
step="5"
/>
</label>
<label class="toggle-field">
<span>各行分离显示</span>
<input type="checkbox" v-model="scheme.separated" />
</label>
<p class="field-hint">默认将所有行放在同一个岛体内;开启后各行拥有独立背景。</p>
</section>
</div>
</template>
<p v-if="error" class="editor-error" role="alert">{{ error }}</p>
</aside>
</div>
</template>
<style src="./custom-island.css"></style>
@@ -8,6 +8,7 @@ import {
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()),
@@ -50,12 +51,13 @@ onMounted(async () => {
</script>
<template>
<CustomIslandRuntime
v-if="resident()"
v-if="resident() && !state.coursewareTransfer"
:settings="settings"
:state="state"
:schedules="schedules"
:appearance="appearance"
:native-visible="nativeVisible"
/>
<FixedIsland v-if="ready" v-show="!resident()" :suspended="resident()" />
<FixedIsland v-if="ready" v-show="!resident() && !state.coursewareTransfer" :suspended="resident() || !!state.coursewareTransfer" />
<CoursewareTransfer v-if="state.coursewareTransfer" :transfer="state.coursewareTransfer" :appearance="appearance" />
</template>
@@ -730,7 +730,7 @@ body {
*
* **必须关在 `@media (hover: hover) and (pointer: fine)` 里**:教室机器上的希沃触摸屏
* 点一下会产生"幽灵 hover"并**一直粘着**,直到点别处才消失 —— 不关的话,点过「知道了」
* 的按钮会永远保持高亮(同 AGENTS.md §5 第 30 条那类问题)。
* 的按钮会永远保持高亮(同 AGENTS.md 的现行工程约定那类问题)。
* 这是 beUI `useHoverCapable` 的 CSS 版:纯样式效果用媒体查询就够,不必挂 composable。
*/
@media (hover: hover) and (pointer: fine) {
@@ -203,535 +203,3 @@
.custom-window .preview-stage {
width: max-content;
}
.layout-editor {
--editor-bg: #f6f7f9;
--editor-border: #e6e9ef;
--editor-text: #202735;
--editor-muted: #7b8494;
--editor-accent: #3c69de;
position: fixed;
inset: 10px;
display: grid;
grid-template-columns: 232px minmax(0, 1fr) 300px;
grid-template-rows: 76px minmax(0, 1fr);
color: var(--editor-text);
background: #fff;
border: 1px solid #dce1e9;
border-radius: 18px;
overflow: hidden;
font:
13px 'Segoe UI Variable Display',
'Microsoft YaHei',
sans-serif;
box-shadow: 0 12px 40px #15203c22;
}
.layout-editor h1,
.layout-editor h2,
.layout-editor h3,
.layout-editor p {
margin: 0;
}
.layout-editor button,
.layout-editor select,
.layout-editor input,
.layout-editor textarea {
font: inherit;
}
.layout-editor button,
.layout-editor select,
.layout-editor input:not([type='checkbox']):not([type='range']),
.layout-editor textarea {
min-height: 36px;
border: 1px solid var(--editor-border);
border-radius: 8px;
padding: 7px 10px;
color: var(--editor-text);
background: #fff;
max-width: 100%;
}
.layout-editor button {
cursor: pointer;
touch-action: none;
transition: background var(--ch-dur-fast) var(--ch-ease-out);
}
.layout-editor button:hover {
background: #f0f3f9;
}
.layout-editor button:disabled {
cursor: default;
opacity: 0.45;
}
.layout-editor button:focus-visible,
.layout-editor input:focus-visible,
.layout-editor select:focus-visible,
.layout-editor textarea:focus-visible,
.layout-editor summary:focus-visible {
outline: 2px solid var(--editor-accent);
outline-offset: 2px;
}
.layout-editor input[type='checkbox'],
.layout-editor input[type='range'] {
accent-color: var(--editor-accent);
}
.layout-editor input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
flex-shrink: 0;
}
.layout-editor input[type='color'] {
width: 48px;
padding: 4px !important;
cursor: pointer;
}
.layout-editor .editor-header {
grid-column: 1/-1;
display: flex;
align-items: center;
gap: 24px;
padding: 0 24px;
border-bottom: 1px solid var(--editor-border);
z-index: 3;
}
.layout-editor .editor-title {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.layout-editor .editor-mark {
font-size: 30px;
color: #222b3b;
}
.layout-editor h1 {
font-size: 17px;
font-weight: 650;
letter-spacing: 0.5px;
}
.layout-editor .editor-title p {
font-size: 11px;
color: var(--editor-muted);
margin-top: 3px;
}
.layout-editor .scheme-picker {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.layout-editor .scheme-picker > span {
color: var(--editor-muted);
font-size: 12px;
}
.layout-editor .scheme-picker select {
width: 170px;
background: var(--editor-bg);
}
.layout-editor .scheme-menu {
position: relative;
}
.layout-editor .scheme-menu summary {
list-style: none;
cursor: pointer;
padding: 10px;
border-radius: 8px;
}
.layout-editor .scheme-menu-body {
position: absolute;
right: 0;
top: 40px;
width: 170px;
padding: 6px;
background: white;
border: 1px solid var(--editor-border);
border-radius: 12px;
box-shadow: 0 8px 28px #1c2d4c1a;
}
.layout-editor .scheme-menu-body button,
.layout-editor .import-button {
width: 100%;
display: block;
border: 0;
text-align: left;
padding: 10px;
cursor: pointer;
border-radius: 6px;
}
.layout-editor .import-button:hover {
background: var(--editor-bg);
}
.layout-editor .import-button input {
display: none;
}
.layout-editor .save-state {
color: var(--editor-muted);
font-size: 11px;
}
.layout-editor .primary-button {
color: #fff;
background: var(--editor-accent);
border-color: var(--editor-accent);
}
.layout-editor .primary-button:hover {
background: #305ac8;
}
.layout-editor .done-button {
min-width: 80px;
}
.layout-editor .library,
.layout-editor .settings {
overflow: auto;
background: #fff;
}
.layout-editor .library {
border-right: 1px solid var(--editor-border);
padding: 22px 16px 16px;
}
.layout-editor .panel-heading {
padding: 0 4px 20px;
}
.layout-editor h2 {
font-size: 17px;
font-weight: 650;
}
.layout-editor .panel-heading p,
.layout-editor .inspector-heading p {
font-size: 12px;
color: var(--editor-muted);
margin-top: 7px;
line-height: 1.6;
}
.layout-editor .library-group {
margin-bottom: 22px;
}
.layout-editor .library-group h3 {
color: var(--editor-muted);
font-size: 11px;
font-weight: 500;
margin: 0 4px 10px;
}
.layout-editor .component-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.layout-editor .library-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 4px;
font-size: 12px;
border-color: #eff1f5;
}
.layout-editor .component-symbol {
font-size: 22px;
height: 24px;
display: flex;
align-items: center;
color: #65728a;
}
.layout-editor .library-item.active {
background: #f0f4ff;
border-color: #97b2f4;
color: var(--editor-accent);
}
.layout-editor .library-item.active .component-symbol {
color: inherit;
}
.layout-editor .library-add {
position: sticky;
bottom: -16px;
padding: 16px 0;
background: white;
border-top: 1px solid var(--editor-border);
}
.layout-editor .canvas {
position: relative;
overflow: hidden;
touch-action: none;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 160px 36px 100px;
background:
radial-gradient(#d9dfe9 0.8px, transparent 0.8px) 0 0 / 20px 20px,
#f2f4f8;
}
.layout-editor .canvas-toolbar {
position: absolute;
top: 22px;
left: 24px;
right: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.layout-editor .canvas-toolbar > span {
color: var(--editor-muted);
font-size: 12px;
}
.layout-editor .canvas-toolbar > div {
display: flex;
gap: 6px;
pointer-events: auto;
}
.layout-editor .canvas-toolbar button {
font-size: 12px;
box-shadow: 0 2px 4px #18305a05;
}
.layout-editor .canvas-toolbar button.active {
color: var(--editor-accent);
border-color: #b6c8f0;
}
.layout-editor .preview-stage {
transform-origin: center top;
flex-shrink: 0;
max-width: none;
}
.layout-editor .custom-card {
min-width: 280px;
overflow: visible;
}
.layout-editor .row-editing {
position: relative;
padding: 34px 4px 8px;
border: 1px dashed #ffffff36;
border-radius: 12px;
}
.layout-editor .row-editing.row-selected {
border-color: #8baeff;
}
.layout-editor .row-tools {
position: absolute;
top: 0;
left: 2px;
right: 2px;
display: flex;
align-items: center;
gap: 3px;
height: 28px;
}
.layout-editor .row-tools button {
border: 0;
min-height: 24px;
padding: 3px 6px;
background: transparent;
color: #ffffffab;
font-size: 11px;
}
.layout-editor .row-tools button:hover {
background: #ffffff12;
color: white;
}
.layout-editor .row-name {
display: flex;
align-items: center;
gap: 6px;
}
.layout-editor .row-name span {
font-size: 9px;
padding: 1px 5px;
color: #a9c3ff;
border-radius: 4px;
background: #83a8ff22;
}
.layout-editor .row-tools .row-settings {
margin-left: auto;
}
.layout-editor .row-content {
min-width: 260px;
min-height: 52px;
}
.layout-editor .empty-row {
color: #ffffff60;
padding: 10px 18px;
font-size: 12px;
}
.layout-editor .add-row-button {
display: block;
width: 100%;
margin-top: 16px;
color: #7d899d;
background: #ffffff70;
border: 1px dashed #c7d0e0;
font-size: 12px;
}
.layout-editor .canvas-footer {
position: absolute;
bottom: 18px;
left: 24px;
right: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--editor-muted);
font-size: 11px;
pointer-events: none;
}
.layout-editor .canvas-footer label {
display: flex;
align-items: center;
gap: 8px;
pointer-events: auto;
}
.layout-editor .canvas-footer input {
width: 90px;
}
.layout-editor .canvas-footer output {
width: 34px;
}
.layout-editor .settings {
border-left: 1px solid var(--editor-border);
}
.layout-editor .inspector-heading {
padding: 24px 24px 20px;
}
.layout-editor .eyebrow {
display: block;
color: var(--editor-muted);
font-size: 10px;
margin-bottom: 8px;
}
.layout-editor .panel-tabs {
display: flex;
gap: 6px;
padding: 0 24px 16px;
border-bottom: 1px solid var(--editor-border);
}
.layout-editor .panel-tabs button {
flex: 1;
border: 0;
min-height: 32px;
font-size: 12px;
background: var(--editor-bg);
color: #788295;
}
.layout-editor .panel-tabs button.active {
color: var(--editor-accent);
background: #edf2ff;
}
.layout-editor .inspector-body {
padding: 0 24px 24px;
}
.layout-editor .setting-section {
padding: 20px 0;
border-bottom: 1px solid var(--editor-border);
}
.layout-editor .setting-section:last-child {
border-bottom: 0;
}
.layout-editor .setting-section h3 {
font-size: 12px;
font-weight: 650;
margin-bottom: 16px;
}
.layout-editor .field {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
margin-bottom: 16px;
}
.layout-editor .field:last-child {
margin-bottom: 0;
}
.layout-editor .field > input:not([type='color']),
.layout-editor .field > textarea,
.layout-editor .field > select {
width: 100%;
}
.layout-editor .inline-field {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.layout-editor .inline-field input[type='number'] {
width: 80px;
}
.layout-editor .toggle-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 38px;
font-size: 12px;
}
.layout-editor .field-hint {
font-size: 11px;
color: var(--editor-muted);
line-height: 1.8;
margin: 8px 0 12px;
}
.layout-editor .range-field {
display: flex;
align-items: center;
gap: 12px;
}
.layout-editor .range-field input {
flex: 1;
min-width: 0;
}
.layout-editor .range-field output {
width: 36px;
color: var(--editor-muted);
}
.layout-editor .wide-button {
width: 100%;
margin-bottom: 12px;
}
.layout-editor .wide-button:last-child {
margin-bottom: 0;
}
.layout-editor .danger-button {
color: #cc4a52;
background: #fff;
}
.layout-editor .danger-button:hover {
background: #fff3f4;
}
.layout-editor fieldset {
padding: 0;
margin: 0;
border: 0;
}
.layout-editor fieldset:disabled {
opacity: 0.45;
}
.layout-editor .editor-error {
margin: 12px 24px;
padding: 10px;
background: #fff1f1;
color: #bc3f4b;
border-radius: 8px;
line-height: 1.7;
}
@media (max-width: 1100px) {
.layout-editor {
grid-template-columns: 188px minmax(0, 1fr) 260px;
}
.layout-editor .editor-header {
gap: 12px;
padding: 0 18px;
}
.layout-editor .scheme-picker > span,
.layout-editor .save-state,
.layout-editor .canvas-footer > span {
display: none;
}
.layout-editor .inspector-heading,
.layout-editor .inspector-body {
padding-left: 18px;
padding-right: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.layout-editor button {
transition: none;
}
}
@@ -316,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;
@@ -1,3 +1,4 @@
import { pollCourseware } from './courseware.js';
import { spawn } from 'node:child_process';
import os from 'node:os';
import fs from 'node:fs';
@@ -206,6 +207,7 @@ async function beat(): Promise<void> {
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() ?? '';
@@ -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));
}
+6 -1
View File
@@ -1,3 +1,4 @@
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';
@@ -14,6 +15,10 @@ 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());
@@ -136,7 +141,7 @@ export function registerIpcHandlers(): void {
return true;
});
/** 服务端联动状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md §5 第 9 条) */
/** 服务端连接状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md 的现行工程约定) */
ipcMain.handle(IPC_CHANNELS.clientStatus, () => getClientAgentStatus());
/** 立即上报一次(设置页「立即上报」按钮) */
+39 -5
View File
@@ -3,6 +3,8 @@ 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,
@@ -629,11 +631,29 @@ class IslandController {
getState(): IslandState {
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 {
return this.ready && Boolean(this.win) && !this.win?.isDestroyed();
}
@@ -662,7 +682,7 @@ class IslandController {
/**
* 同一条消息**可能经由两条广播链路到达**:服务端 `createCall` 既发 `notification:new`
* (班级房间)又发 `call:new`(学生/班级 user 房间),两条 DTO 的 id 相同、
* (班级房间)又发 `call:new`(班级会话 session 房间),两条 DTO 的 id 相同、
* 只有"是否叫人"的语义不同。后到的那条绝不能把已有的"叫人"降级成普通通知 ——
* 否则胶囊显示成"新消息"、上课时段还会被当成普通通知隐藏(用户实测截图:
* 叫人胶囊写着"新消息 / 系统管理员 · 点击查看")。
@@ -897,8 +917,7 @@ class IslandController {
if (this.state.active) this.scheduleCollapse(TIMEOUTS.expanded);
break;
}
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
// 经典模式课堂普通通知保持隐藏;普通叫人可主动点开,紧急消息也须支持收起后再次查看。
const showing = this.state.active;
// 岛当前可见(胶囊态)时,用户是"对着屏幕上这条岛"点的:
// 只要队列里有更**新**的消息(例如紧急通知之后又来了作业),就应当打开新那条,
@@ -1055,7 +1074,7 @@ class IslandController {
* 起因:用户反馈"点「知道了」「已读」时没有收回动画"(其他场景都有)。
* 状态**在同一帧就已经落地**(调用方 `dismissActive()` / `clearPendingForClose()` 已清空
* active/queued),这里只是把形态切回胶囊 —— **不引入任何跨进程握手,也没有收回快照**
* (见 AGENTS.md §5 第 34 条:早先自研的 `closing` / `closingSnapshot` 已整体删除,
* (见 AGENTS.md 的现行工程约定:早先自研的 `closing` / `closingSnapshot` 已整体删除,
* 它在触摸屏上会露旧帧;这里复用的是已被 `collapse` 用例验收过的那条路径)。
*/
private beginClose(): void {
@@ -1069,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);
@@ -1368,6 +1387,16 @@ 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;
@@ -1894,6 +1923,7 @@ 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 };
// 多条通知的列表形态:高度由统一布局算法算出(渲染进程算的是同一个数)
@@ -1964,6 +1994,10 @@ 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, '窗口不可见');
@@ -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();
}
}
@@ -43,7 +43,7 @@ export async function verifyLayoutSmoke(
'location.hash.includes("/settings/island/editor") && Boolean(document.querySelector(".aside"))',
)) &&
editor.webContents.executeJavaScript(
`(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.includes('在下方新建'));b?.click();return Boolean(document.querySelector('.layout-editor')); })()`,
`(() => { 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'); })()`,
)
);
});
@@ -52,11 +52,12 @@ export async function verifyLayoutSmoke(
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('.canvas')?.getBoundingClientRect().toJSON(), stage:document.querySelector('.preview-stage')?.getBoundingClientRect().toJSON()};
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 =
@@ -66,7 +67,11 @@ export async function verifyLayoutSmoke(
});
await check('组件可以包裹进容器且设置独立保存', async () => {
await editor.webContents.executeJavaScript(
`(() => { const select=document.querySelector('[data-action=wrap]');if(!select)throw Error('未选中组件');select.value='group';select.dispatchEvent(new Event('change',{bubbles:true})); })()`,
`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 (
@@ -77,40 +82,49 @@ export async function verifyLayoutSmoke(
});
await check('组件设置按内容外观高级切换,整体外观单独显示', async () => {
const initial = await editor.webContents.executeJavaScript(`(() => {
const panel=document.querySelector('.settings');
return panel?.textContent.includes('显示内容') && !panel.textContent.includes('字体大小') && !panel.textContent.includes('背景颜色');
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('.panel-tabs button')[1].click()`,
`document.querySelectorAll('.component-settings .el-tabs__item')[1].click()`,
);
await pause();
const appearance = await editor.webContents.executeJavaScript(`(() => {
const panel=document.querySelector('.settings');
return panel?.textContent.includes('字体大小') && !panel.textContent.includes('组件操作');
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('.panel-tabs button')[2].click()`,
`document.querySelectorAll('.component-settings .el-tabs__item')[2].click()`,
);
await pause();
const advanced = await editor.webContents.executeJavaScript(`(() => {
const panel=document.querySelector('.settings');
return panel?.textContent.includes('隐藏条件') && !panel.textContent.includes('字体大小');
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('.panel-tabs button')[0].click()`,
`document.querySelectorAll('.component-settings .el-tabs__item')[0].click()`,
);
await pause();
return initial && appearance && advanced;
});
await check('主窗口编辑器的预览完整适配画布且不遮挡缩放控件', async () => {
return editor.webContents.executeJavaScript(`(() => {
const stage=document.querySelector('.preview-stage')?.getBoundingClientRect();
const canvas=document.querySelector('.canvas')?.getBoundingClientRect();
const footer=document.querySelector('.canvas-footer')?.getBoundingClientRect();
return Boolean(stage&&canvas&&footer&&stage.left>=canvas.left&&stage.right<=canvas.right&&stage.bottom<=footer.top-4);
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'),
@@ -118,7 +132,9 @@ export async function verifyLayoutSmoke(
);
}
await check('退出编辑后配置可从 getConfig 读回', async () => {
await editor.webContents.executeJavaScript('document.querySelector(".done-button")?.click()');
await editor.webContents.executeJavaScript(
`[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='应用并返回')?.click()`,
);
await pause();
return (
!island.getEditingLayout() &&
@@ -1,3 +1,4 @@
import { verifyCoursewareSmoke } from './smoke-courseware.js';
import { verifyLayoutSmoke } from './smoke-layout.js';
import { verifyAgentSmoke } from './smoke-agent.js';
import fs from 'node:fs';
@@ -84,6 +85,7 @@ export async function runQuickSmoke(win: BrowserWindow): Promise<void> {
}
await verifyAgentSmoke(check);
await verifyLayoutSmoke(check);
await verifyCoursewareSmoke(check);
await check('检查结束时没有可见或聚焦的验证窗口', () =>
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
);
+10 -1
View File
@@ -1,4 +1,4 @@
import type { ReleaseProgressDto as ContractReleaseProgressDto } from '@classhelper/shared';
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';
@@ -20,6 +20,15 @@ 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();
+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;
@@ -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>
@@ -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,
@@ -40,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',
@@ -48,6 +48,10 @@ 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);
});
@@ -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>
@@ -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,19 +1,48 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
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 {
defaultIslandLayouts,
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 CustomIsland from '../../../island/CustomIsland.vue';
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());
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const schedules = ref(getIslandSchedules());
const state = ref<IslandState>({
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: [],
@@ -23,105 +52,706 @@ const state = ref<IslandState>({
listExpanded: false,
maxCardHeight: 1024,
updatedAt: 0,
});
const ready = ref(false);
const error = ref('');
let refresh: ReturnType<typeof setInterval>;
onMounted(async () => {
};
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 = { ...config.islandLayout, layout: 'custom', residentEnabled: true };
settings.value = normalizeIslandLayouts(config.islandLayout);
appearance.value = config.island;
await window.desktop?.saveConfig({ islandLayout: JSON.parse(JSON.stringify(settings.value)) });
}
ready.value = true;
refresh = setInterval(() => {
error.value = '';
} catch (e) {
error.value = String(e);
}
}
onMounted(async () => {
await load();
clock = setInterval(() => {
now.value = Date.now();
schedules.value = getIslandSchedules();
}, 1000);
});
onUnmounted(() => clearInterval(refresh));
// 保存按顺序发送;快速拖动不会让较早的响应覆盖较新的草稿。
let saving = Promise.resolve();
function save(value: typeof settings.value) {
settings.value = value;
const snapshot = JSON.parse(JSON.stringify(value));
saving = saving
.then(async () => {
await window.desktop?.saveConfig({ islandLayout: snapshot });
error.value = '';
})
.catch(() => {
error.value = '保存失败,请重试';
});
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[];
}
async function done() {
await saving;
if (!error.value) await router.push('/settings/island');
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="island-editor-page" aria-label="自定义灵动岛编辑器">
<p v-if="error" role="alert">{{ error }}</p>
<CustomIsland
v-if="ready"
:settings="settings"
:appearance="appearance"
:schedules="schedules"
:state="state"
editing
embedded
@save="save"
@done="done"
<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>
<style scoped>
.island-editor-page {
height: 100%;
min-height: 430px;
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);
}
.island-editor-page .layout-editor {
position: relative;
inset: auto;
flex: 1;
min-height: 430px;
width: 100%;
border-radius: 0;
.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;
grid-template-columns: 160px minmax(200px, 1fr) 248px;
grid-template-rows: 64px minmax(0, 1fr);
}
.island-editor-page .layout-editor .canvas {
padding: 110px 16px 80px;
.separated .preview-row {
padding: 12px;
background: #202024;
}
.island-editor-page .layout-editor .library,
.island-editor-page .layout-editor .settings {
min-height: 0;
padding: 14px 12px;
.empty-preview {
color: #ffffffb8;
padding: 8px 14px;
cursor: pointer;
font-size: 13px;
}
.island-editor-page .layout-editor .editor-header {
padding: 10px 14px;
gap: 12px;
.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);
}
.island-editor-page .layout-editor .editor-title p,
.island-editor-page .layout-editor .save-state {
display: none;
.preview-toolbar .el-slider {
flex: 1;
max-width: 160px;
}
.island-editor-page .layout-editor .scheme-picker {
margin-left: auto;
.card-heading > div {
display: flex;
gap: 6px;
}
@media (max-width: 1100px) {
.island-editor-page .layout-editor {
grid-template-columns: 130px minmax(140px, 1fr) 220px;
.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;
}
.island-editor-page .layout-editor .component-grid {
.scheme-toolbar .el-form-item {
flex-basis: 100%;
}
}
@media (max-width: 700px) {
.editor-columns {
grid-template-columns: 1fr;
}
.island-editor-page .layout-editor .scheme-picker > span {
display: none;
.heading-actions {
width: 100%;
}
.component-options {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
+7
View File
@@ -117,6 +117,13 @@ export interface IslandClassStatePayload {
}
export interface DesktopBridge {
downloadCourseware(id: string): Promise<void>;
revealCourseware(id: string): Promise<void>;
syncCourseware(): Promise<void>;
getCoursewareTransfer(): Promise<import('@classhelper/shared').CoursewareTransfer | null>;
onCoursewareTransfer(
handler: (value: import('@classhelper/shared').CoursewareTransfer | null) => void,
): () => void;
islandEditLayout(): Promise<void>;
onIslandEditorOpen(handler: () => void): () => void;
getUpdateProgress(): Promise<ContractReleaseProgressDto>;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
"version": "1.3.3",
"version": "1.4.0",
"private": true,
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
@@ -0,0 +1,35 @@
CREATE TABLE "Courseware" (
"id" TEXT NOT NULL PRIMARY KEY, "ownerId" TEXT NOT NULL, "name" TEXT NOT NULL,
"size" INTEGER NOT NULL, "category" TEXT NOT NULL, "extension" TEXT NOT NULL,
"archiveDate" TEXT NOT NULL, "storagePath" TEXT NOT NULL, "sha256" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Courseware_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "Courseware_storagePath_key" ON "Courseware"("storagePath");
CREATE INDEX "Courseware_ownerId_createdAt_idx" ON "Courseware"("ownerId", "createdAt");
CREATE TABLE "CoursewareVisibility" (
"coursewareId" TEXT NOT NULL, "classId" TEXT NOT NULL, PRIMARY KEY ("coursewareId", "classId"),
FOREIGN KEY ("coursewareId") REFERENCES "Courseware"("id") ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "CoursewareVisibility_classId_idx" ON "CoursewareVisibility"("classId");
CREATE TABLE "CoursewarePush" (
"id" TEXT NOT NULL PRIMARY KEY, "coursewareId" TEXT NOT NULL, "classId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("coursewareId") REFERENCES "Courseware"("id") ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "CoursewarePush_classId_createdAt_idx" ON "CoursewarePush"("classId", "createdAt");
CREATE TABLE "CoursewareReceipt" (
"pushId" TEXT NOT NULL, "deviceId" TEXT NOT NULL, "completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("pushId", "deviceId"),
FOREIGN KEY ("pushId") REFERENCES "CoursewarePush"("id") ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY ("deviceId") REFERENCES "ClientDevice"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- 仅为旧默认组和内置教学岗位补充新能力;自定义组仍需管理员按需授权。
INSERT INTO "PermissionGroupItem" ("id", "groupId", "key")
SELECT 'cw-read-' || "id", "id", 'courseware:read' FROM "PermissionGroup"
WHERE "defaultRole" IN ('ADMIN', 'TEACHER') OR "id" IN ('builtin-head', 'builtin-teacher');
INSERT INTO "PermissionGroupItem" ("id", "groupId", "key")
SELECT 'cw-write-' || "id", "id", 'courseware:write' FROM "PermissionGroup"
WHERE "defaultRole" IN ('ADMIN', 'TEACHER') OR "id" IN ('builtin-head', 'builtin-teacher');
+51
View File
@@ -34,6 +34,7 @@ datasource db {
/// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
model User {
coursewares Courseware[]
id String @id @default(cuid())
/// 登录名:教师即**工号**,班级端即班级码
username String @unique
@@ -201,6 +202,8 @@ model TermWeek {
/// `grade`(高一/高二/高三)表示**当前年级**,学年升级时批量改它即可 ——
/// 升级只动这一个字段,班级记录、学生名单、作业与成绩全部沿用,**不归档**。
model Class {
coursewares CoursewareVisibility[]
coursewarePushes CoursewarePush[]
id String @id @default(cuid())
/// 班级称呼:由 enrollmentYear + classIndex 生成,如「2026级1班」
name String
@@ -501,6 +504,7 @@ model AppSetting {
/// 服务端由此知道**每台机器在哪个班、版本多少、是否在线**,并把更新与远控
/// 指令按「查库 + 心跳拉取」的范式下发,不新增长连接握手。
model ClientDevice {
coursewareReceipts CoursewareReceipt[]
id String @id @default(cuid())
/// 该设备当前登录的班级(未登录时为空)
classId String?
@@ -717,3 +721,50 @@ model PromotionRun {
createdAt DateTime @default(now())
finishedAt DateTime?
}
/// 课件只在完整接收并计算摘要后入库;存储路径不对客户端暴露。
model Courseware {
id String @id @default(cuid())
ownerId String
name String
size Int
category String
extension String
archiveDate String
storagePath String @unique
sha256 String
createdAt DateTime @default(now())
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
visibility CoursewareVisibility[]
pushes CoursewarePush[]
@@index([ownerId, createdAt])
}
model CoursewareVisibility {
coursewareId String
classId String
courseware Courseware @relation(fields: [coursewareId], references: [id], onDelete: Cascade)
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
@@id([coursewareId, classId])
@@index([classId])
}
/// 下发持久化,班级离线时不丢失;每台设备完成下载后单独回执。
model CoursewarePush {
id String @id @default(cuid())
coursewareId String
classId String
createdAt DateTime @default(now())
courseware Courseware @relation(fields: [coursewareId], references: [id], onDelete: Cascade)
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
receipts CoursewareReceipt[]
@@index([classId, createdAt])
}
model CoursewareReceipt {
pushId String
deviceId String
completedAt DateTime @default(now())
push CoursewarePush @relation(fields: [pushId], references: [id], onDelete: Cascade)
device ClientDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
@@id([pushId, deviceId])
}
+1
View File
@@ -78,6 +78,7 @@ async function resetDatabase(): Promise<void> {
await prisma.clientCommand.deleteMany();
await prisma.clientDevice.deleteMany();
await prisma.clientRelease.deleteMany();
await prisma.courseware.deleteMany();
await prisma.aiAudit.deleteMany();
await prisma.userPermissionGroup.deleteMany();
await prisma.permissionGroupItem.deleteMany();
@@ -0,0 +1,330 @@
import type { AddressInfo } from 'node:net';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import http from 'node:http';
import { createHash } from 'node:crypto';
import { once } from 'node:events';
import { createApp } from '../src/app.js';
import { prisma } from '../src/lib/db.js';
import { signToken } from '../src/lib/jwt.js';
import { exportSnapshot, assertValidSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
import { ensureDefaultGroups } from '../src/lib/permissions.js';
import { registerEmitter } from '../src/realtime/bus.js';
import {
COURSEWARE_MAX_BYTES,
SOCKET_EVENTS,
coursewareCategory,
dayKeyLocal,
type CoursewareDto,
} from '@classhelper/shared';
if (
!process.env.DATABASE_URL?.includes('v14-verification') ||
!process.env.COURSEWARE_DIR?.includes('v14-verification')
)
throw new Error('课件验收只能使用临时库和文件目录');
let count = 0;
function check(name: string, value: unknown) {
assert.ok(value, name);
console.log(`通过 ${++count}:${name}`);
}
const emitted: { rooms: string[]; event: string; payload: unknown }[] = [];
registerEmitter((rooms, event, payload) => emitted.push({ rooms, event, payload }));
const staff = await Promise.all(
['课件教师甲', '课件教师乙', '仅班主任', '临时管理员'].map((name, i) =>
prisma.user.create({
data: {
name,
username: `cw-${Date.now()}-${i}`,
passwordHash: '仅签发临时令牌',
role: i === 3 ? 'ADMIN' : 'TEACHER',
},
}),
),
);
const tokens = staff.map((user) =>
signToken({
sub: user.id,
username: user.username,
name: user.name,
role: user.role as 'ADMIN' | 'TEACHER',
classId: null,
}),
);
const classes = await Promise.all(
[0, 1, 2].map((i) =>
prisma.class.create({
data: {
name: `课件验证班${i + 1}`,
grade: '验证',
code: `CW${Date.now()}${i}`,
teacherId: staff[2]!.id,
},
}),
),
);
await prisma.course.createMany({
data: classes.map((c, i) => ({ name: '语文', classId: c.id, teacherId: staff[i === 2 ? 1 : 0]!.id })),
});
const classTokens = classes.map((c) =>
signToken({
sub: c.id,
classId: c.id,
username: c.code,
name: c.name,
role: 'CLASS_DEVICE',
classSession: true,
}),
);
await ensureDefaultGroups();
let server = http.createServer(createApp());
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = () => `http://127.0.0.1:${(server.address() as AddressInfo).port}/api`;
async function request(route: string, token = tokens[0], body?: unknown, method = body ? 'POST' : 'GET') {
const res = await fetch(`${base()}${route}`, {
method,
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
return { status: res.status, ...(await res.json()) };
}
const bytes = Buffer.from('ClassHelper 课件流式上传验收\n'.repeat(3000));
async function upload(
options: Partial<{ name: string; size: number; classIds: string[]; mode: string }> = {},
token = tokens[0],
data = bytes,
) {
const meta = {
name: '第1课 演示.pptx',
size: data.length,
classIds: [classes[0]!.id],
mode: 'cloud',
...options,
};
const res = await fetch(
`${base()}/courseware/upload?metadata=${encodeURIComponent(JSON.stringify(meta))}`,
{
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' },
body: data,
signal: AbortSignal.timeout(10000),
},
);
return { status: res.status, ...(await res.json()) };
}
const list = async (token: string) => (await request('/courseware', token)).data as CoursewareDto[];
const heartbeat = async (i: number, key: string) =>
request('/clients/heartbeat', classTokens[i], {
deviceKey: key,
clientVersion: '1.4.0',
platform: 'verification',
});
const pending = async (i: number, key: string) =>
request(`/courseware/transfers?deviceKey=${key}`, classTokens[i]);
async function files(dir = process.env.COURSEWARE_DIR!): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
return (
await Promise.all(
entries.map((entry) =>
entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)],
),
)
).flat();
}
try {
check('未登录不能列出课件', (await request('/courseware', '')).status === 401);
const ownClasses = (await request('/courseware/classes')).data;
check(
'只能选择自己的授课班级',
ownClasses.length === 2 && !ownClasses.some((c: { id: string }) => c.id === classes[2]!.id),
);
check(
'仅班主任而无任课关系不能选择班级',
(await request('/courseware/classes', tokens[2])).data.length === 0,
);
check('管理员上传同样只能选择本人任课班级', (await upload({}, tokens[3])).status === 403);
check('班级端不能上传', (await upload({}, classTokens[0])).status === 403);
check('拒绝非任课班级上传', (await upload({ classIds: [classes[2]!.id] })).status === 403);
check('拒绝目录穿越文件名', (await upload({ name: '../secret.pptx' })).status === 422);
check('拒绝空文件', (await upload({ size: 0 }, tokens[0], Buffer.alloc(0))).status === 422);
check('拒绝超过 512 MB 的课件', (await upload({ size: COURSEWARE_MAX_BYTES + 1 })).status === 422);
check('拒绝错误文件长度', (await upload({ size: 1 })).status === 400);
check('推送不能不选班级', (await upload({ mode: 'push', classIds: [] })).status === 422);
const created = await upload();
const item = created.data as CoursewareDto;
check(
'完整上传后创建课件元数据',
created.status === 201 && item.size === bytes.length && item.name === '第1课 演示.pptx',
);
check(
'摘要、日期与类型正确',
item.sha256 === createHash('sha256').update(bytes).digest('hex') &&
item.archiveDate === dayKeyLocal(new Date()) &&
item.category === 'presentation',
);
check('API 不暴露服务端路径', !('storagePath' in item));
const stored = await prisma.courseware.findUniqueOrThrow({ where: { id: item.id } });
check(
'文件按日期和类型保存',
stored.storagePath.startsWith(path.join(item.archiveDate, 'presentation')) &&
(await fs.readFile(path.join(process.env.COURSEWARE_DIR!, stored.storagePath))).equals(bytes),
);
check(
'班级仅看到自己可见的课件',
(await list(classTokens[0]!)).length === 1 && (await list(classTokens[1]!)).length === 0,
);
check('其他教师不能访问本人文件', (await request(`/courseware/${item.id}`, tokens[1])).status === 403);
check(
'其他班级不能绕过列表下载',
(await request(`/courseware/${item.id}/download`, classTokens[1])).status === 403,
);
const downloaded = await fetch(`${base()}/courseware/${item.id}/download`, {
headers: { authorization: `Bearer ${classTokens[0]}` },
});
check(
'中文文件按附件下载且内容一致',
downloaded.headers.get('content-disposition')?.includes('attachment') &&
Buffer.from(await downloaded.arrayBuffer()).equals(bytes),
);
check('下载响应禁止共享缓存', downloaded.headers.get('cache-control')?.includes('no-store'));
await heartbeat(0, 'courseware-device-1');
await heartbeat(0, 'courseware-device-2');
await heartbeat(1, 'courseware-device-3');
check('仅存云端不会自动发送任务', (await pending(0, 'courseware-device-1')).data.length === 0);
check('未登记设备不能拉取传输', (await pending(0, 'unknown-device')).status === 403);
check('不能使用其他班级设备标识', (await pending(1, 'courseware-device-1')).status === 403);
const privateItem = (await upload({ classIds: [], name: '私有讲义.pdf' })).data as CoursewareDto;
check('仅自己可见文件不向班级披露', !(await list(classTokens[0]!)).some((c) => c.id === privateItem.id));
check(
'无法推送到课件可见范围以外',
(await request(`/courseware/${item.id}/push`, tokens[0], { classIds: [classes[1]!.id] })).status === 400,
);
check(
'他人不能改可见范围',
(await request(`/courseware/${item.id}/visibility`, tokens[1], { classIds: [] }, 'PATCH')).status === 403,
);
const pushed = (
await upload({ mode: 'push', name: '课堂练习.docx', classIds: classes.slice(0, 2).map((c) => c.id) })
).data as CoursewareDto;
check(
'推送模式先存云端实体',
!!(await prisma.courseware.findUnique({ where: { id: pushed.id } })) &&
(await list(tokens[0]!)).some((c) => c.id === pushed.id),
);
check(
'班级端不泄露其他可见班级',
(await list(classTokens[0]!)).every((c) => c.classes.every((v) => v.id === classes[0]!.id)),
);
const task = (await pending(0, 'courseware-device-1')).data[0];
check('推送任务可由离线班级下次上线拉取', task.courseware.id === pushed.id);
check(
'实时提示只发送到所选班级房间',
emitted
.filter((e) => e.event === SOCKET_EVENTS.coursewareChanged)
.every((e) =>
e.rooms.every((room) => [classes[0]!.id, classes[1]!.id].some((id) => room === `class:${id}`)),
),
);
await new Promise<void>((resolve) => server.close(() => resolve()));
server = http.createServer(createApp());
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
check('重启服务端后仍保留未完成传输', (await pending(0, 'courseware-device-1')).data[0].id === task.id);
const ackRoute = `/courseware/transfers/${task.id}/ack`;
check(
'其他班级不能回执任务',
(await request(ackRoute, classTokens[1], { deviceKey: 'courseware-device-3' })).status === 404,
);
check(
'接收完成后可回执',
(await request(ackRoute, classTokens[0], { deviceKey: 'courseware-device-1' })).status === 200,
);
check(
'重复回执幂等',
(await request(ackRoute, classTokens[0], { deviceKey: 'courseware-device-1' })).status === 200,
);
check(
'回执仅清除本设备任务',
(await pending(0, 'courseware-device-1')).data.length === 0 &&
(await pending(0, 'courseware-device-2')).data.length === 1,
);
await request(`/courseware/${pushed.id}/visibility`, tokens[0], { classIds: [classes[0]!.id] }, 'PATCH');
check(
'撤回班级可见范围同时取消其待传任务',
(await pending(1, 'courseware-device-3')).data.length === 0 &&
!(await list(classTokens[1]!)).some((c) => c.id === pushed.id),
);
check(
'撤回后不能通过直链下载',
(await request(`/courseware/${pushed.id}/download`, classTokens[1])).status === 403,
);
await request(`/courseware/${pushed.id}/push`, tokens[0], { classIds: [classes[0]!.id] });
check('已归档课件可再次推送', (await pending(0, 'courseware-device-1')).data.length === 1);
const defaultGroup = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
await prisma.permissionGroupItem.deleteMany({
where: { groupId: defaultGroup.id, key: 'courseware:write' },
});
check('收回写权限立即禁止上传', (await upload()).status === 403);
await prisma.permissionGroupItem.create({ data: { groupId: defaultGroup.id, key: 'courseware:write' } });
const snapshot = await exportSnapshot(prisma, 'sqlite');
check(
'备份包含课件、可见范围、推送和回执',
snapshot.data.Courseware.length === 3 &&
snapshot.data.CoursewareReceipt.length === 1 &&
snapshot.data.CoursewarePush.length > 0 &&
snapshot.data.CoursewareVisibility.length > 0,
);
const oldSnapshot = structuredClone(snapshot);
for (const key of ['Courseware', 'CoursewarePush', 'CoursewareReceipt', 'CoursewareVisibility'])
delete (oldSnapshot.data as Record<string, unknown>)[key];
assertValidSnapshot(oldSnapshot);
check('允许读取升级前的旧版快照', true);
await request(`/courseware/${pushed.id}`, tokens[0], undefined, 'DELETE');
check(
'删除课件级联清理推送任务',
(await pending(0, 'courseware-device-1')).data.length === 0 &&
(await pending(0, 'courseware-device-2')).data.length === 0,
);
check('删除实体后无残余文件', (await files()).length === 2);
const before = (await files()).length;
await new Promise<void>((resolve) => {
const metadata = { name: '中断上传.zip', size: 100000, classIds: [], mode: 'cloud' };
const req = http.request(
`${base()}/courseware/upload?metadata=${encodeURIComponent(JSON.stringify(metadata))}`,
{
method: 'POST',
headers: {
authorization: `Bearer ${tokens[0]}`,
'content-type': 'application/octet-stream',
'content-length': '100000',
},
},
);
req.on('error', () => resolve());
req.write(Buffer.alloc(1000));
setTimeout(() => req.destroy(), 250);
});
await new Promise((resolve) => setTimeout(resolve, 250));
check(
'上传中断自动清理临时文件',
(await files()).length === before && !(await files()).some((file) => file.endsWith('.part')),
);
check(
'文件分类覆盖演示、文档、媒体与压缩包',
coursewareCategory('A.PPTX') === 'presentation' &&
coursewareCategory('a.pdf') === 'document' &&
coursewareCategory('v.mp4') === 'video' &&
coursewareCategory('a.7z') === 'archive',
);
await restoreSnapshot(prisma, 'sqlite', snapshot);
check(
'恢复数据库后保留课件共享和回执数据',
(await prisma.courseware.count()) === 3 && (await prisma.coursewareReceipt.count()) === 1,
);
console.log(`=== 课件隔离验收:${count}/${count} 项通过 ===`);
} finally {
server.closeAllConnections();
server.close();
await once(server, 'close').catch(() => undefined);
await prisma.$disconnect();
}
+4
View File
@@ -3222,6 +3222,10 @@ async function main() {
'UserPermissionGroup',
'AppSetting',
'ClientDevice',
'Courseware',
'CoursewareVisibility',
'CoursewarePush',
'CoursewareReceipt',
'ClientCommand',
'ClientRelease',
'AiAudit',
+3
View File
@@ -1,3 +1,4 @@
import { COURSEWARE_TRANSFER_TIMEOUT_MS } from '@classhelper/shared';
import fs from 'node:fs';
import path from 'node:path';
import { createServer } from 'node:http';
@@ -61,6 +62,8 @@ await refreshDeveloperMode();
const app = createApp();
const httpServer = createServer(app);
// 大课件允许较慢的内网上传;请求头仍沿用 Node 的独立超时限制。
httpServer.requestTimeout = COURSEWARE_TRANSFER_TIMEOUT_MS;
const io = initRealtime(httpServer);
/** 写入 PID 文件(安装包的 start.cmd / stop.cmd 依赖它精确控制进程) */
+7 -1
View File
@@ -59,6 +59,10 @@ export const SNAPSHOT_TABLES = [
'NotificationRead',
'Grade',
'ClientDevice',
'Courseware',
'CoursewareVisibility',
'CoursewarePush',
'CoursewareReceipt',
'ClientCommand',
'ClientRelease',
'AiAudit',
@@ -132,6 +136,8 @@ export function assertValidSnapshot(value: unknown): asserts value is DatabaseSn
}
for (const table of SNAPSHOT_TABLES) {
const rows = (snapshot.data as Record<string, unknown>)[table];
// 旧版本快照没有课件表;允许恢复,文件实体仍需单独备份。
if (rows === undefined && table.startsWith('Courseware')) continue;
if (!Array.isArray(rows)) {
throw new Error(`快照缺少表 ${table} 的数据`);
}
@@ -174,7 +180,7 @@ export async function restoreSnapshot(
}
}
for (const table of SNAPSHOT_TABLES) {
const rows = snapshot.data[table].map((row) => {
const rows = (snapshot.data[table] ?? []).map((row) => {
if (table !== 'Class' || typeof row !== 'object' || row === null) return row;
const copy = { ...(row as Record<string, unknown>) };
delete copy.notificationChannel;
+1 -1
View File
@@ -22,7 +22,7 @@ router.use(authenticate());
*
* 权限分成两把:`ai:manage`(设置,默认只有管理员)与 `ai:use`(用一句话办事,默认教师也有)。
* **`/interpret` 只解析、绝不落库**:真正发布由前端确认后调既有端点,权限与上课时段保护
* 都在那条路径上(AGENTS.md §5 第 5/14 条)。
* 都在那条路径上(AGENTS.md 的现行工程约定)。
*/
router.get('/settings', requirePermission('ai:manage'), async (_req, res) => {
sendOk(res, await aiService.getAiSettings(), '获取大模型设置成功');
+1 -1
View File
@@ -24,7 +24,7 @@ import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
* 设计边界(照 approved 计划):
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
* 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md §5 第 5/14 条);
* 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md 的现行工程约定);
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
@@ -30,7 +30,7 @@ router.post(
*
* 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里,
* 通过 `GET /:id/classes/:classId/content` 只读查看。
* 「未毕业而升级的班级不归档」—— 升级走 `POST /api/classes/promote`,只改年级。
* 年度升级走 `POST /api/term/promotion/run`,毕业班归档,其余升一级;本模块读取归档结果。
*/
/** GET /api/archives - 届别列表 */
@@ -27,7 +27,7 @@ import type {
/**
* ClassHelper 桌面客户端设备的心跳 / 远控命令 / 更新下发。
*
* 范式(AGENTS.md §5 第 67 条):**DB 记状态 + 客户端定时拉取** ——
* 范式(AGENTS.md 的现行工程约定):**DB 记状态 + 客户端定时拉取** ——
* 因此本文件里没有任何"推送"动作:所有下发都只是改 `ClientDevice` / `ClientCommand` 的字段,
* 由客户端下一次心跳(30 秒内)带走。
*/
@@ -0,0 +1,109 @@
import { Router } from 'express';
import { sendOk, sendCreated } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireRole,
requirePermission,
requireStaffPermission,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import { coursewareDeviceQuery, coursewareScopeSchema, coursewareUploadQuery } from './courseware.schemas.js';
import type { CoursewareUploadRequest } from '@classhelper/shared';
import * as service from './courseware.service.js';
const router = Router();
router.use(authenticate());
const staff = [requireRole('ADMIN', 'TEACHER'), requirePermission('courseware:write')];
router.get('/', requireStaffPermission('courseware:read'), async (req, res) =>
sendOk(res, await service.listCourseware(getAuthUser(req))),
);
router.get('/classes', ...staff, async (req, res) =>
sendOk(res, await service.teachingClasses(getAuthUser(req))),
);
router.get(
'/transfers',
requireRole('CLASS_DEVICE'),
validate({ query: coursewareDeviceQuery }),
async (req, res) =>
sendOk(
res,
await service.pendingTransfers(getAuthUser(req), validatedQuery<{ deviceKey: string }>(req).deviceKey),
),
);
router.post(
'/transfers/:id/ack',
requireRole('CLASS_DEVICE'),
validate({ params: idParamSchema, body: coursewareDeviceQuery }),
async (req, res) => {
await service.acknowledgeTransfer(
getAuthUser(req),
validatedParams<{ id: string }>(req).id,
validatedBody<{ deviceKey: string }>(req).deviceKey,
);
sendOk(res, null);
},
);
router.post('/upload', ...staff, validate({ query: coursewareUploadQuery }), async (req, res) =>
sendCreated(
res,
await service.uploadCourseware(
getAuthUser(req),
validatedQuery<{ metadata: CoursewareUploadRequest }>(req).metadata,
req,
),
),
);
router.get(
'/:id/download',
requireStaffPermission('courseware:read'),
validate({ params: idParamSchema }),
async (req, res, next) => {
const row = await service.readable(getAuthUser(req), validatedParams<{ id: string }>(req).id);
res.setHeader('Cache-Control', 'private, no-store');
res.setHeader('Content-Type', 'application/octet-stream');
res.download(row.storagePath, row.name, { root: service.coursewareRoot, dotfiles: 'allow' }, (error) => {
if (error && !res.headersSent) next(error);
});
},
);
router.patch(
'/:id/visibility',
...staff,
validate({ params: idParamSchema, body: coursewareScopeSchema }),
async (req, res) =>
sendOk(
res,
await service.setVisibility(
getAuthUser(req),
validatedParams<{ id: string }>(req).id,
validatedBody<{ classIds: string[] }>(req).classIds,
),
),
);
router.post(
'/:id/push',
...staff,
validate({ params: idParamSchema, body: coursewareScopeSchema }),
async (req, res) => {
await service.pushCourseware(
getAuthUser(req),
validatedParams<{ id: string }>(req).id,
validatedBody<{ classIds: string[] }>(req).classIds,
);
sendOk(res, null);
},
);
router.delete('/:id', ...staff, validate({ params: idParamSchema }), async (req, res) => {
await service.deleteCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id);
sendOk(res, null);
});
router.get(
'/:id',
requireStaffPermission('courseware:read'),
validate({ params: idParamSchema }),
async (req, res) =>
sendOk(res, await service.getCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id)),
);
export const coursewareModule = defineModule({ name: 'courseware', basePath: '/courseware', router });
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { COURSEWARE_MAX_BYTES } from '@classhelper/shared';
export const coursewareScopeSchema = z.object({ classIds: z.array(z.string().min(1).max(100)).max(100) });
export const coursewareUploadSchema = coursewareScopeSchema
.extend({
name: z
.string()
.trim()
.min(1)
.max(180)
.refine(
(name) => !/[<>:"/\\|?*\u0000-\u001f]/.test(name) && !name.endsWith('.') && !name.endsWith(' '),
'文件名含无效字符',
),
size: z.number().int().min(1).max(COURSEWARE_MAX_BYTES),
mode: z.enum(['cloud', 'push']),
})
.refine((input) => input.mode !== 'push' || input.classIds.length > 0, '推送时请选择授课班级');
export const coursewareUploadQuery = z.object({
metadata: z
.string()
.transform((value, ctx): unknown => {
try {
return JSON.parse(value);
} catch {
ctx.addIssue({ code: 'custom', message: '上传信息格式错误' });
return z.NEVER;
}
})
.pipe(coursewareUploadSchema),
});
export const coursewareDeviceQuery = z.object({ deviceKey: z.string().min(1).max(100) });
@@ -0,0 +1,228 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { randomUUID, createHash } from 'node:crypto';
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { Request } from 'express';
import {
COURSEWARE_MAX_BYTES,
coursewareCategory,
dayKeyLocal,
SOCKET_EVENTS,
type CoursewareDto,
type CoursewareUploadRequest,
} from '@classhelper/shared';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import { assertCanManageSubjectContent, requireClassDeviceClassId } from '../../lib/access.js';
import type { TokenPayload } from '../../lib/jwt.js';
import type { Prisma } from '../../generated/prisma/client.js';
import { emitToClass } from '../../realtime/bus.js';
export const coursewareRoot = path.resolve(
process.env.COURSEWARE_DIR || path.join(env.serverRoot, '..', 'data', 'courseware'),
);
const include = {
owner: { select: { id: true, name: true } },
visibility: { include: { class: { select: { id: true, name: true } } } },
} as const;
type RecordWithRelations = Prisma.CoursewareGetPayload<{ include: typeof include }>;
function dto(row: RecordWithRelations): CoursewareDto {
return {
id: row.id,
name: row.name,
size: row.size,
category: row.category as CoursewareDto['category'],
extension: row.extension,
archiveDate: row.archiveDate,
sha256: row.sha256,
createdAt: row.createdAt.toISOString(),
teacher: row.owner,
classes: row.visibility.map((item) => item.class),
};
}
export async function teachingClasses(user: TokenPayload) {
return prisma.class.findMany({
where: { archivedYearId: null, courses: { some: { teacherId: user.sub } } },
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
}
async function validateClasses(user: TokenPayload, ids: string[]) {
const classIds = [...new Set(ids)];
const allowed = new Set((await teachingClasses(user)).map((item) => item.id));
if (classIds.some((id) => !allowed.has(id))) throw ApiError.forbidden('只能选择自己任教的在读班级');
for (const classId of classIds) {
const course = await prisma.course.findFirst({
where: { classId, teacherId: user.sub },
select: { id: true },
});
await assertCanManageSubjectContent(user, classId, course?.id, '发布课件');
}
return classIds;
}
export async function readable(user: TokenPayload, id: string) {
const row = await prisma.courseware.findUnique({ where: { id }, include });
if (!row) throw ApiError.notFound('课件不存在');
if (user.role === 'CLASS_DEVICE') {
if (!row.visibility.some((item) => item.classId === requireClassDeviceClassId(user)))
throw ApiError.forbidden('课件不在本班可见范围内');
} else if (row.ownerId !== user.sub && user.role !== 'ADMIN')
throw ApiError.forbidden('只能访问自己的课件');
return row;
}
async function owned(user: TokenPayload, id: string) {
const row = await readable(user, id);
if (row.ownerId !== user.sub) throw ApiError.forbidden('只能修改自己的课件');
return row;
}
export async function getCourseware(user: TokenPayload, id: string) {
const item = dto(await readable(user, id));
if (user.role === 'CLASS_DEVICE')
item.classes = item.classes.filter((c) => c.id === requireClassDeviceClassId(user));
return item;
}
export async function listCourseware(user: TokenPayload) {
const classId = user.role === 'CLASS_DEVICE' ? requireClassDeviceClassId(user) : null;
const rows = await prisma.courseware.findMany({
where: classId
? { visibility: { some: { classId } } }
: user.role === 'ADMIN'
? {}
: { ownerId: user.sub },
include,
orderBy: { createdAt: 'desc' },
});
// 班级端只暴露本班的可见关系。
return rows.map((row) => {
const item = dto(row);
if (classId) item.classes = item.classes.filter((c) => c.id === classId);
return item;
});
}
export async function uploadCourseware(user: TokenPayload, input: CoursewareUploadRequest, req: Request) {
const classIds = await validateClasses(user, input.classIds);
if (!req.is('application/octet-stream')) throw ApiError.badRequest('请使用二进制文件上传');
if (req.headers['content-length'] && Number(req.headers['content-length']) !== input.size)
throw ApiError.badRequest('文件长度与上传信息不符');
const category = coursewareCategory(input.name),
archiveDate = dayKeyLocal(new Date());
const extension = path.extname(input.name).slice(1).toLowerCase();
const relative = path.join(archiveDate, category, randomUUID());
const file = path.join(coursewareRoot, relative),
partial = `${file}.part`;
await fsp.mkdir(path.dirname(file), { recursive: true });
let bytes = 0;
const hash = createHash('sha256');
try {
await pipeline(
req,
new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytes += chunk.length;
if (bytes > COURSEWARE_MAX_BYTES || bytes > input.size)
return callback(ApiError.badRequest('课件超过上传大小限制'));
hash.update(chunk);
callback(null, chunk);
},
}),
fs.createWriteStream(partial, { flags: 'wx' }),
);
if (bytes !== input.size) throw ApiError.badRequest('文件上传不完整,请重试');
await fsp.rename(partial, file);
// 上传耗时较长,完成后重新验证任课关系。
await validateClasses(user, classIds);
const row = await prisma.courseware.create({
data: {
ownerId: user.sub,
name: input.name,
size: bytes,
category,
extension,
archiveDate,
storagePath: relative,
sha256: hash.digest('hex'),
visibility: { create: classIds.map((classId) => ({ classId })) },
pushes: input.mode === 'push' ? { create: classIds.map((classId) => ({ classId })) } : undefined,
},
include,
});
for (const classId of classIds) emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id: row.id });
return dto(row);
} catch (error) {
await Promise.all([fsp.rm(partial, { force: true }), fsp.rm(file, { force: true })]);
throw error;
}
}
export async function setVisibility(user: TokenPayload, id: string, ids: string[]) {
await owned(user, id);
const classIds = await validateClasses(user, ids);
const old = await prisma.coursewareVisibility.findMany({ where: { coursewareId: id } });
const row = await prisma.$transaction(async (tx) => {
await tx.coursewarePush.deleteMany({ where: { coursewareId: id, classId: { notIn: classIds } } });
await tx.coursewareVisibility.deleteMany({ where: { coursewareId: id } });
return tx.courseware.update({
where: { id },
data: { visibility: { create: classIds.map((classId) => ({ classId })) } },
include,
});
});
for (const classId of new Set([...classIds, ...old.map((v) => v.classId)]))
emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id });
return dto(row);
}
export async function pushCourseware(user: TokenPayload, id: string, ids: string[]) {
const row = await owned(user, id);
const classIds = await validateClasses(user, ids);
if (!classIds.length || classIds.some((classId) => !row.visibility.some((v) => v.classId === classId)))
throw ApiError.badRequest('推送班级必须在课件可见范围内');
await prisma.$transaction(
classIds.map((classId) => prisma.coursewarePush.create({ data: { coursewareId: id, classId } })),
);
for (const classId of classIds) emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id });
}
export async function deleteCourseware(user: TokenPayload, id: string) {
const row = await owned(user, id);
await prisma.courseware.delete({ where: { id } });
await fsp.rm(path.join(coursewareRoot, row.storagePath), { force: true });
for (const v of row.visibility) emitToClass(v.classId, SOCKET_EVENTS.coursewareChanged, { id });
}
export async function deviceFor(user: TokenPayload, deviceKey: string) {
const classId = requireClassDeviceClassId(user);
const device = await prisma.clientDevice.findUnique({ where: { deviceKey } });
if (!device || device.classId !== classId) throw ApiError.forbidden('设备未登记或不属于当前班级');
return { ...device, classId };
}
export async function pendingTransfers(user: TokenPayload, deviceKey: string) {
const device = await deviceFor(user, deviceKey);
const rows = await prisma.coursewarePush.findMany({
where: {
classId: device.classId,
receipts: { none: { deviceId: device.id } },
courseware: { visibility: { some: { classId: device.classId } } },
},
include: { courseware: { include } },
orderBy: { createdAt: 'asc' },
take: 50,
});
return rows.map((row) => ({
id: row.id,
courseware: {
...dto(row.courseware),
classes: dto(row.courseware).classes.filter((c) => c.id === device.classId),
},
}));
}
export async function acknowledgeTransfer(user: TokenPayload, pushId: string, deviceKey: string) {
const device = await deviceFor(user, deviceKey);
const push = await prisma.coursewarePush.findFirst({ where: { id: pushId, classId: device.classId } });
if (!push) throw ApiError.notFound('传输任务不存在');
await readable(user, push.coursewareId);
await prisma.coursewareReceipt.upsert({
where: { pushId_deviceId: { pushId, deviceId: device.id } },
create: { pushId, deviceId: device.id },
update: {},
});
}
@@ -77,7 +77,7 @@ router.post(
* POST /api/homeworks/bulk - 一次给多个班级布置同一份作业(逐班结果)
*
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md §5 第 20 条)。
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md 的现行工程约定)。
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 ——
* 批量不是绕过科目门的后门(§5 第 5 条)。
*/
@@ -149,7 +149,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
* - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了);
* - **没选科目** → 本班班主任或管理员;
* - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求,
* 见 AGENTS.md §5 第 20 条),班级端录入的作业归属该班班主任。
* 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。
*/
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', {
+2
View File
@@ -1,3 +1,4 @@
import { coursewareModule } from './courseware/courseware.module.js';
import {
classPlansModule,
timeLayoutsModule,
@@ -33,6 +34,7 @@ import type { ApiModule } from './module.types.js';
* 临时下线:把 enabled 置为 false。
*/
export const apiModules: ApiModule[] = [
coursewareModule,
authModule,
aiModule,
classesModule,
@@ -18,11 +18,7 @@ import * as teacherService from './teachers.service.js';
const router = Router();
router.use(authenticate());
/**
* 教师账号管理:**全部接口仅管理员**。
* ClassHelper 班级端主体是班级,但教师账号决定谁是哪班班主任、谁能录课表/成绩,属于系统级配置,
* 因此录入/修改/重置密码/删除都只开放给 ADMIN(路由层 + 前端菜单双重收敛)。
*/
/** 教师列表使用 teacher:read/teacher:manage,写入统一要求 teacher:manage;额外业务约束见 service。 */
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
router.get(
@@ -35,7 +31,7 @@ router.get(
},
);
/** POST /api/teachers - 录入教师账号(仅管理员) */
/** POST /api/teachers - 录入教师账号(需 teacher:manage) */
router.post(
'/',
requirePermission('teacher:manage'),
@@ -104,11 +104,12 @@ export async function resetTeacherPassword(
export async function deleteTeacher(userId: string): Promise<void> {
const teacher = await findTeacherOrThrow(userId);
const [ownedClasses, courses, homeworks, notifications] = await Promise.all([
const [ownedClasses, courses, homeworks, notifications, coursewares] = await Promise.all([
prisma.class.count({ where: { teacherId: teacher.id } }),
prisma.course.count({ where: { teacherId: teacher.id } }),
prisma.homework.count({ where: { createdBy: teacher.id } }),
prisma.notification.count({ where: { createdBy: teacher.id } }),
prisma.courseware.count({ where: { ownerId: teacher.id } }),
]);
if (ownedClasses > 0) {
throw ApiError.conflict(
@@ -127,5 +128,7 @@ export async function deleteTeacher(userId: string): Promise<void> {
);
}
if (coursewares > 0) throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`);
await prisma.user.delete({ where: { id: teacher.id } });
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/shared",
"version": "1.3.3",
"version": "1.4.0",
"private": true,
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
"type": "module",
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more