7 Commits
Author SHA1 Message Date
LoveChengke 71cc8eb605 fix(update): 修复云端安装交接并完善发布验收
修复隐藏缓存目录下载和响应收尾,使用原生助手校验安装包并确认交接。
补齐真实静默升级、便携版及中文路径验收,并按现行设备和科目授权契约更新回归。
服务端安装时生成独立密钥,覆盖升级保留既有配置;补齐安装包副本验证说明。
2026-10-31 17:07:17 +08:00
LoveChengke 66be3a63b1 feat(authz): 重写权限模板分配与班级授权边界
拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。
补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。
重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。
增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。

验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
2026-10-31 09:40:23 +08:00
LoveChengke 682818dd4a feat: 重做灵动岛编辑器与课表档案时间表编辑
灵动岛
- 重构自定义方案编辑器:组件库常驻、拖拽添加与排序、方案切换/新建/复制/删除、编辑模式预览与缩放,容器在编辑时展开全部子组件。
- 新增天气简报组件:主进程固定请求 Open-Meteo,只接受数值经纬度,失败退避并保留本次运行缓存;冒烟模式不访问外网。
- 倒计时支持目标名称与日期;课程表组件新增「今日时间点」,按时间点类型折叠/展开,课中不再常驻倒计时。
- preload 暴露 getIslandWeather 与时间点订阅,契约同步 desktop.d.ts。

课表档案
- 时间点默认课程:布局返回 defaultCourse,日课表没有条目时回退;新增 apply-default 覆盖所有引用课表;全校时间表不携带默认课程。
- 新增 ClassIsland 式时间表编辑器(列表/时间轴视图、拖动把柄、按类型添加、默认时长与缩放)与「课表信息」表单。
- 课表页默认进入单表编辑,底部保留列表/周/月视图;时间表支持新路由删除。
- 「课间」统一改称「课间休息」。

验证
- pnpm typecheck、pnpm lint 通过。
- verify:e2e 隔离验收 248/248 项通过(含默认课程回退、覆盖写入与时间表删除)。
- ui-smoke 覆盖单表编辑、课表信息与时间轴视图,并更新回归截图。
2026-10-25 16:41:29 +08:00
Love_Chengke 938285bb9c fix: 修复课表回归截图文件 2026-10-25 08:59:59 +08:00
Love_Chengke d63100e0e1 fix: 修正 UI 回归脚本文件编码 2026-10-25 08:57:59 +08:00
Love_Chengke e43c3a93d8 feat(web-admin): 整合课表档案页面 2026-10-24 15:43:32 +08:00
LoveChengke 24e8dd902b feat: 完善学生生命周期管理与权限范围并发布 1.4.2 2026-10-23 22:55:21 +08:00
202 changed files with 14917 additions and 4358 deletions

No files matched your search

+7 -7
View File
@@ -28,9 +28,9 @@ pnpm monorepo,根 `package.json` 是版本单一来源。文档、注释、界
- 模块由 `packages/server/src/modules/registry.ts` 注册;新增或下线功能改注册表,不另改 `app.ts` 挂载。 - 模块由 `packages/server/src/modules/registry.ts` 注册;新增或下线功能改注册表,不另改 `app.ts` 挂载。
- 路由用 `middleware/validate.ts` 与 zod;响应复用 `lib/http.ts`,错误交给统一中间件。 - 路由用 `middleware/validate.ts` 与 zod;响应复用 `lib/http.ts`,错误交给统一中间件。
- 学生是 `Student` 名单记录,没有账号、角色或密码。教师/管理员存 `User`;班级登录读取 `Class.code/passwordHash`,会话角色 `CLASS_DEVICE`,主体 id 为班级 id。 - 学生是 `Student` 名单记录,没有账号、角色或密码。教师/管理员存 `User`;班级登录读取 `Class.code/passwordHash`,会话角色 `CLASS_DEVICE`,主体 id 为班级 id。
- 任课关系只在 `Course(classId, name, teacherId)`。班主任不自动获得别的科目的作业、成绩写权限,统一走 `assertCanManageSubjectContent`。班级端可录作业,不可写成绩;员工还须对应功能权限。 - 任课关系只在 `Course(classId, name, teacherId)`。班主任不自动获得别的科目的作业、成绩写权限,统一走 `lib/authz/assert.ts` 的 `assertContentWrite`。班级端可录作业,不可写成绩;员工还须对应功能权限。
- 功能权限取 `lib/permissions.ts` 的 `loadEffectivePermissions()`:已分组取并集;未分组使用数据库对应角色的可编辑默认组;管理员永远保留 `permission:manage/system:manage`;班级端为空集。 - 功能、范围、关系统一由 `lib/authz/` 判定,所有服务显式传权限键。ADMIN 固定全部能力;TEACHER 永久忽略管理员专用键;CLASS_DEVICE 为空集,接口明确允许并校验本班。权限管理仅 ADMIN。
- 岗位组不新增 `User.role`。班主任/教师/级长/部长仍为 TEACHER;范围在 `PermissionGroup.scope/scopeValue`,由 `lib/permission-scope.ts` 收口,空年级/部范围拒绝。设备写权限只合并含对应键的分组范围,范围不替代科目授权。 - 岗位模板只管能力与建议范围,生效范围存 `UserPermissionGroup.scope/scopeValue`,使用 TEACHING/HEAD/GRADE/DIVISION/ALL。无显式分配的教师使用默认模板与固定 TEACHING;空或未知年级拒绝。按键独立合并范围,无键查询为空集;班级账号/学期设置永远要求本班班主任,科目写检查原/目标科目。具体断言和迁移见 `docs/dev/permissions.md`。
- 只有一份 `prisma/schema.prisma`,切库改 provider;保持 SQLite/MySQL 通用,不加 Prisma enum 或 `@db.*`。模型变动同步迁移、`lib/snapshot.ts` 的 `SNAPSHOT_TABLES` 与旧库兼容逻辑。 - 只有一份 `prisma/schema.prisma`,切库改 provider;保持 SQLite/MySQL 通用,不加 Prisma enum 或 `@db.*`。模型变动同步迁移、`lib/snapshot.ts` 的 `SNAPSHOT_TABLES` 与旧库兼容逻辑。
- `deleteTeacher` 必须保护班主任、课程任课、发布的作业/通知和所属课件,避免级联删除业务内容。 - `deleteTeacher` 必须保护班主任、课程任课、发布的作业/通知和所属课件,避免级联删除业务内容。
- 作业和通知按本地 `assignDate` 归类,使用 `dayKeyLocal/isDayKey/shiftDayKey`,不用 UTC 日期切片。 - 作业和通知按本地 `assignDate` 归类,使用 `dayKeyLocal/isDayKey/shiftDayKey`,不用 UTC 日期切片。
@@ -44,7 +44,7 @@ pnpm monorepo,根 `package.json` 是版本单一来源。文档、注释、界
- `/api/schedules/*` 是新模型兼容门面。启用解析统一走 `lib/class-plan.ts`,在线客户端不得另写启用优先级。 - `/api/schedules/*` 是新模型兼容门面。启用解析统一走 `lib/class-plan.ts`,在线客户端不得另写启用优先级。
- 时间点有上课、课间、分割线、行动四种;课程显式引用时间点外键。排序不能靠数组下标代替关联。 - 时间点有上课、课间、分割线、行动四种;课程显式引用时间点外键。排序不能靠数组下标代替关联。
- 支持 1–9 周轮换、群组、预定及临时层;完整档案导入走 `profile-import.service.ts`,不经过单双周有损格式。 - 支持 1–9 周轮换、群组、预定及临时层;完整档案导入走 `profile-import.service.ts`,不经过单双周有损格式。
- 全校时间表写入用 `timetable:manage`;班级日课表写入还须班主任/管理员断言。离线整套课表导入可建立班级自有作息,不把 JSON 的 TeacherName 当授权。 - 全校时间表写入仅管理员;班级时间表按存储 classId 校验 `schedule:write` 与班主任关系,引用班级也校验。日课表/名单在 TEACHING/HEAD 范围要求班主任,显式年级/部/全校分配可在范围内维护。离线整套课表导入可建立班级自有作息,不把 JSON 的 TeacherName 当授权。
- 周次统一 `loadTermContext/resolveCurrentWeek`:班级逐周区间优先于全校区间,缺省再线性推算。开学日会参考班级设置、第 1 周区间、全校设置与环境兜底,详见 `lib/term.ts`。 - 周次统一 `loadTermContext/resolveCurrentWeek`:班级逐周区间优先于全校区间,缺省再线性推算。开学日会参考班级设置、第 1 周区间、全校设置与环境兜底,详见 `lib/term.ts`。
- 全校周数上限存 `term.maxWeeks`,默认 30、范围 1–60;服务端 `getMaxTermWeeks()`,shared 做归一化与离线兜底。班级周数不超全校上限。 - 全校周数上限存 `term.maxWeeks`,默认 30、范围 1–60;服务端 `getMaxTermWeeks()`,shared 做归一化与离线兜底。班级周数不超全校上限。
- 关闭个人成绩查询后,班级端仍可看服务端集体聚合;个人明细、原始全班成绩和排名拒绝读取。稀疏样本及补集抑制,不下发原始成绩后在客户端聚合。 - 关闭个人成绩查询后,班级端仍可看服务端集体聚合;个人明细、原始全班成绩和排名拒绝读取。稀疏样本及补集抑制,不下发原始成绩后在客户端聚合。
@@ -57,9 +57,9 @@ pnpm monorepo,根 `package.json` 是版本单一来源。文档、注释、界
- preload 仅暴露 contextBridge 白名单,不暴露 ipcRenderer;跨桥接传纯数据,不传 Vue Proxy。契约写在 `src/types/desktop.d.ts`。 - preload 仅暴露 contextBridge 白名单,不暴露 ipcRenderer;跨桥接传纯数据,不传 Vue Proxy。契约写在 `src/types/desktop.d.ts`。
- 保留 CSP、导航同源校验与弹窗限制。安装包不能含凭据,保留 `check-bundle-secrets` 构建门禁。 - 保留 CSP、导航同源校验与弹窗限制。安装包不能含凭据,保留 `check-bundle-secrets` 构建门禁。
- 离线缓存按服务端与班级隔离,缓存故障不应阻断在线读取。退出、切服务器时清理会话状态,设备 `deviceKey` 保持不变,渲染层不能改写它。 - 离线缓存按服务端与班级隔离,缓存故障不应阻断在线读取。退出、切服务器时清理会话状态,设备 `deviceKey` 保持不变,渲染层不能改写它。
- 设备维护只走 `/api/clients/*`,管理页 `ClientsView.vue`。心跳约 30 秒,90 秒窗口判在线;未登录不报,401/403 退避,切配置重置计时并隔离迟到响应。 - 设备维护只走 `/api/clients/*`,管理页 `ClientsView.vue`;心跳、进度、回执仅接受本班 CLASS_DEVICE,deviceKey 不可跨班迁移。心跳约 30 秒,90 秒窗口判在线;未登录不报,401/403 退避,切配置重置计时并隔离迟到响应。
- 远控须 `remote:control`,只允许 shutdown/restart/logoff/lock,客户端立即执行、持久去重并回执。冒烟始终假执行,不得真关机/重启/注销/锁屏。 - 远控须 `remote:control`,只允许 shutdown/restart/logoff/lock,客户端立即执行、持久去重并回执。冒烟始终假执行,不得真关机/重启/注销/锁屏。
- 更新写入口用 `client:update`;服务端缓存包并登记 SHA256,教室机从本服务下载;无哈希拒收,按立即/放学后/空闲策略执行,关闭自动更新不得强装。冒烟不运行安装器。 - 安装包登记/删除为管理员 `client:update`;班级推送用 `client:update-push`;服务端缓存包并登记 SHA256,教室机从本服务下载;无哈希拒收,按立即/放学后/空闲策略执行,关闭自动更新不得强装。冒烟不运行安装器。
- 灵动岛方案保存在本机 `config.json.islandLayout`,默认 `layout=fixed`;`custom` 当前即常驻模式,旧 `residentEnabled` 是兼容字段。新尺寸上报仅在 custom 模式执行。 - 灵动岛方案保存在本机 `config.json.islandLayout`,默认 `layout=fixed`;`custom` 当前即常驻模式,旧 `residentEnabled` 是兼容字段。新尺寸上报仅在 custom 模式执行。
- `LayoutIslandApp.vue` 选择经典 `IslandApp.vue` 或 `CustomIslandRuntime.vue`,共用一个原生岛窗口;课件传输状态优先显示 `CoursewareTransfer.vue`。 - `LayoutIslandApp.vue` 选择经典 `IslandApp.vue` 或 `CustomIslandRuntime.vue`,共用一个原生岛窗口;课件传输状态优先显示 `CoursewareTransfer.vue`。
- 经典模式:普通通知/作业上课暂存;普通叫人保留可点击胶囊;紧急消息立即展开。自定义常驻模式:消息先提示铃铛数量,用户主动展开;上课也可主动查看。不要把两个模式规则混写。 - 经典模式:普通通知/作业上课暂存;普通叫人保留可点击胶囊;紧急消息立即展开。自定义常驻模式:消息先提示铃铛数量,用户主动展开;上课也可主动查看。不要把两个模式规则混写。
@@ -71,7 +71,7 @@ pnpm monorepo,根 `package.json` 是版本单一来源。文档、注释、界
## 5. 实时、界面与资源 ## 5. 实时、界面与资源
- 事件名与房间名取 shared 常量。业务用 `emitToClass/emitToSession`,不直接依赖 Socket.IO 实例。 - 事件名与房间名取 shared 常量。班级房间为 `class:{id}:{channel}`,教师按每域读键及范围订阅,班级端仅本班全部通道;握手回查数据库身份。业务用 `emitToClass/emitToSession`,由总线选择事件域。
- 会话房间为 `session:{id}`;教师/管理员 id 为账号 id,班级端为班级 id。`students` 是班级端广播房间,不是个人学生账号房间。 - 会话房间为 `session:{id}`;教师/管理员 id 为账号 id,班级端为班级 id。`students` 是班级端广播房间,不是个人学生账号房间。
- 新实时事件同步服务端、Web 订阅、客户端订阅。成绩实时载荷不可泄漏个人成绩。 - 新实时事件同步服务端、Web 订阅、客户端订阅。成绩实时载荷不可泄漏个人成绩。
- Web 菜单分组在 `config/navigation.ts`,标题/图标/权限取路由;守卫与服务端都检查权限,前端隐藏不能代替鉴权。 - Web 菜单分组在 `config/navigation.ts`,标题/图标/权限取路由;守卫与服务端都检查权限,前端隐藏不能代替鉴权。
+32
View File
@@ -0,0 +1,32 @@
/** 首次安装生成独立密钥;覆盖升级不改变已有配置。 */
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const MARKER = 'CLASSHELPER_GENERATE_SECRET_ON_INSTALL';
function initialiseEnvironment(file = path.join(__dirname, '.env')) {
const content = fs.readFileSync(file, 'utf8');
const line = /^JWT_SECRET=CLASSHELPER_GENERATE_SECRET_ON_INSTALL\r?$/m;
if (!line.test(content)) return false;
const value = crypto.randomBytes(48).toString('hex');
const temporary = file + '.initialising-' + crypto.randomUUID();
try {
fs.writeFileSync(temporary, content.replace(line, 'JWT_SECRET=' + value), {
encoding: 'utf8',
mode: 0o600,
flag: 'wx',
});
fs.renameSync(temporary, file);
} finally {
fs.rmSync(temporary, { force: true });
}
return true;
}
module.exports = { initialiseEnvironment, MARKER };
if (require.main === module) {
try {
initialiseEnvironment();
} catch (error) {
console.error('服务端配置初始化失败:' + error.message);
process.exitCode = 1;
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-1.4.1} image: classhelper-server:${APP_VERSION:-1.4.2}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+1 -1
View File
@@ -60,7 +60,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-1.4.1} image: classhelper-server:${APP_VERSION:-1.4.2}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "classhelper-server-runtime", "name": "classhelper-server-runtime",
"version": "1.4.1", "version": "1.4.2",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)", "description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
+29 -5
View File
@@ -17,10 +17,12 @@ description: 名单、班级会话、任课关系与年度升级。
## 学生名单 ## 学生名单
Web 统一从“学生管理”查看和维护名单;旧 `/roster` 地址转到同一页面,不再提供重复菜单。班级详情中的名单与课程按当前班级的生效范围展示维护入口,任课关系本身不授予跨班名单、课程或班级设置管理权。
Student 存学号、姓名、班级、性别、家长电话和状态,没有密码或角色字段。 Student 存学号、姓名、班级、性别、家长电话和状态,没有密码或角色字段。
学号为必填唯一标识,格式为 1–32 位字母、数字或 . _ -;不会自动生成登录名。 学号为必填唯一标识,格式为 1–32 位字母、数字或 . _ -;不会自动生成登录名。
管理员或本班班主任在具备名单管理权限后管理学生,教师也可按授权范围只读名单。 具有名单管理键的管理员、本班班主任或全校名单范围的授权员工管理学生;只读账号按名单读取键和范围查看历史。
名单表格导入仍要求管理员及相应导入权限;学号查重在 Student 表完成,不当成 User 用户名。 名单表格导入要求 import:students、roster:manage 和对应班级范围;学号查重在 Student 表完成,不当成 User 用户名。
调班保留历史内容原归属;转出独立记账并从现行业务名单过滤,学号不可复用。 调班保留历史内容原归属;转出独立记账并从现行业务名单过滤,学号不可复用。
## 教师与科目 ## 教师与科目
@@ -35,9 +37,31 @@ Student 存学号、姓名、班级、性别、家长电话和状态,没有密
## 权限、学年与归档 ## 权限、学年与归档
角色给默认权限模板,实际入口由可编辑权限组决定;岗位组还能配置年级或部范围。 管理员固定拥有全部能力;教师按岗位模板及每项分配的范围获得能力,未显式分配时使用教师默认模板与固定任教范围。
范围不代替科目写权限,详见[角色与权限](../dev/permissions.md)。 范围不代替科目写权限,详见[角色与权限](../dev/permissions.md)。
“学期周次”只维护教学周与日期区间;年度轨道、执行计划与补跑统一在管理员专用的“学年升级”页维护。
学期周数受全校可配置上限约束,默认上限 30、允许 1–60,见[课表](schedules.md)。 学期周数受全校可配置上限约束,默认上限 30、允许 1–60,见[课表](schedules.md)。
学年升级按计划时间运行,执行前保存快照,以年份幂等;启动发现漏跑需管理员预览补跑。 学年升级按计划时间运行,执行前保存快照,以年份幂等;启动发现漏跑需管理员预览补跑。
高三归档、高一/高二升一级,新高一不复制任课关系。归档页查看届别历史与转出记录, 默认高中轨道按高一、高二、高三排列,毕业学段归档,其余升一级;也可配置其他轨道。新班不复制任课关系。归档页查看届别历史与转出记录,
不再通过手动创建归档来代替年度升级。撤回转出等操作仍受归档与管理员业务检查约束。 不再通过手动创建归档来代替年度升级。撤回转出由 roster:manage 控制,原班归档后可指定其他在读班。
## 学生生命周期
资料编辑仅修改学号、姓名、性别与家长电话,不接受状态或班级字段;旧客户端发送这两项会收到 400 和专用接口提示。
默认名单只显示未归档的 active;状态筛选可查看 inactive、transferred、graduated,“包含归档”另行控制归档标记。
调班、转出、转入、复学、撤回与移出班级均在一个事务内修改学生并写历史,包含批次、学年、原新班级与年级、来源去向学校、操作人和备注。
调班需同时覆盖源班与目标班;跨年级须管理员勾选允许跨年级并填写备注。目标班必须在读。
转出保留原班及业务历史,只接受在读或停用学生。转入支持新建学号或选择转出/停用学生,毕业或归档学生不能通过名单入口复活。
撤回转出可选择另一个在读班,原历史保留。默认删除动作改为停用;硬删除另须管理员确认且无成绩、作业、已读或历史。
## 升级预览与执行
“学年升级”页面独立要求 promotion:manage,全校范围。先保存轨道、有序年级、起始班数、执行时间、开学日和周次重置策略,再预览逐班影响。
预览包含在读/停用/转出人数、新班码、周次删除行数、阻断和警告、计划哈希。未知年级、缺失届别、重复班号、已有届别档案或新班码冲突会阻断执行。
执行需确认学年和预览哈希;业务或配置变化后必须重新预览。未来学年禁止执行,过去学年仅 ADMIN 明确补跑。
毕业班所有学生挂届别:在读转毕业,转出和停用保留状态;非毕业班只升到下一学段并清本班开学日覆盖。
新班只有骨架,开学日取计划值(未配置时为执行学年 9 月 1 日)。保留周次时,旧逐周区间会继续覆盖新学年,页面会警告。
同年执行幂等,执行记录显示操作人、快照路径、SHA256、明细与错误。快照失败不升级,失败事务回滚后可修正重试。
撤销归档只清标记并将毕业生恢复在读,转出/停用不变;不回滚年级、不删除新班、不恢复已清空周次,整学年恢复需管理员手工使用快照。
+15 -2
View File
@@ -36,8 +36,13 @@ description: 经典提醒、自定义常驻方案、课程与课件传输。
## 编辑自定义方案 ## 编辑自定义方案
“设置 → 灵动岛 → 自定义 → 编辑岛内组件”进入主窗口设置页。 “设置 → 灵动岛 → 自定义 → 编辑岛内组件”进入主窗口设置页。
方案支持复制、删除、JSON 导入导出;组件支持日期、时钟、倒计时、课程、文本、分隔符、通知/作业/叫人及组合容器。 页面上方排列当前组件,右上角选择配置方案;刷新图标重置当前方案,加号新建方案,文件夹菜单提供方案设置、复制、删除及 JSON 导入导出。
分别编辑常态 rows 与无消息展开 expandedRows,拖动调整结构,设置内容、外观、尺寸与隐藏条件。 下方组件库常驻显示文本、分割线、课程表、日期、时钟、天气简报、倒计时和组合容器,并保留班级通知、作业和叫人组件。
从组件库拖入上方的行或容器以添加,拖动已有组件调整顺序或跨行移动;也可点击组件库添加到选中的容器或当前行。
分别编辑常态 rows 与无消息展开 expandedRows,点选组件打开内容、外观、尺寸与隐藏条件设置;行可以新增、排序、设为主要行或删除。
“进入编辑模式”切换为实际内容预览,支持预览缩放;退出后回到组件标签排列。轮播和滚动容器在编辑时展示全部子组件,便于选择和排列。
倒计时可指定目标名称与日期,也可按课表倒数至上课、下课或放学。
天气简报需填写地点名称及经纬度,使用 [Open-Meteo](https://open-meteo.com/en/docs) 的天气数据,每 15 分钟更新;断网时保留本次客户端运行中的缓存并标明“缓存”。天气提示根据天气概况生成,不提供官方气象预警。
方案保存到本机 config.json.islandLayout,不增加服务端同步。 方案保存到本机 config.json.islandLayout,不增加服务端同步。
编辑预览不创建额外桌面窗口;打开编辑页不自动切布局,应用后启用自定义方案。 编辑预览不创建额外桌面窗口;打开编辑页不自动切布局,应用后启用自定义方案。
保存按序进行,失败可重试,主题与关怀模式沿用客户端设置。 保存按序进行,失败可重试,主题与关怀模式沿用客户端设置。
@@ -56,6 +61,14 @@ description: 经典提醒、自定义常驻方案、课程与课件传输。
## 课程与课件 ## 课程与课件
准备、上课、下课、下课前倒数和课中进度等提醒在本机配置独立开关,课程依据服务端解析后的课表。 准备、上课、下课、下课前倒数和课中进度等提醒在本机配置独立开关,课程依据服务端解析后的课表。
自定义常驻岛的「课程表」组件可选「当前时间点 / 下一节课 / 今日时间点」:
「今日时间点」显示当天全部时间点,只有当前项展开显示起止时间,其余折叠只显示课程名;
课间休息与「默认隐藏」的上课时间点折叠时完全隐藏,轮到它们时才展开;分割线显示一条竖线,行动不显示。
课中课程表不再常驻显示倒计时,只在最后一分钟由既有的「下课提醒」页显示「距下课还剩」;
倒计时阈值沿用本机提醒设置(默认 60 秒)。没有时间点数据时退回按课表条目显示课程。
经典 fixed 岛的课中提醒条保持原有倒计时行为。
课件接收时显示文件名、类型、教师、字节和进度,覆盖当前固定/自定义内容;完成后校验 SHA256 并回执。 课件接收时显示文件名、类型、教师、字节和进度,覆盖当前固定/自定义内容;完成后校验 SHA256 并回执。
客户端不会自动执行收到的文件,见[课件](courseware.md)。 客户端不会自动执行收到的文件,见[课件](courseware.md)。
+8 -2
View File
@@ -8,9 +8,15 @@ description: 日课表、共享作息、轮换、临时换课和教学周。
Web 管理端统一从「教学事务 → 课表档案」查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。 Web 管理端统一从「教学事务 → 课表档案」查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。
上课状态、灵动岛课程提醒和紧急通知确认共用服务端的课表判定。 上课状态、灵动岛课程提醒和紧急通知确认共用服务端的课表判定。
编辑工作区参照 [CsesWebEditor](https://github.com/SmartTeachCN/CsesWebEditor) 的资源列表、设置卡片、课时列表与快速选科布局实现。左侧集中切换课程表、时间表、科目、群组与预定、整周预览和文件预览。旧管理页地址 `/schedules` 自动进入同一工作区。 编辑工作区参照 [ClassIsland 档案编辑器](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Views/ProfileSettingsWindow.axaml) 的课表交互实现,作为管理端内的普通页面展示,沿用全站的导航、间距、控件及浅色/深色主题。顶部切换课表、时间表、科目和调课;课表页默认进入单表编辑:左侧按课表群分组的课表列表,中间逐节选择科目,右侧切换「科目 / 课表信息」。底部仍可切换列表、周和月视图。旧管理页地址 `/schedules` 自动进入同一页面,侧栏和顶部导航始终可用。
点击课时后可用右侧科目按钮快速填充;拖动课时交换课程,拖动时间点调整时间表顺序,完成后保存。时间表支持任意分钟输入;设置的课程/课间分钟数会用于新建时间点的默认起止时间。完整档案可在页头导入或导出 JSON,保留轮换、时间点类型、空课、停用、群组和预定。 点击每天列头的「新课表」选择时间表并创建日课表。周视图点击网格中的课时后可用右侧科目按钮快速填充,支持选择是否自动移动到下一节;方向键可移动焦点,Delete 可清空选中课程。课表名称、时间表、启用星期、轮换规则、生效周次与群组在右侧「课表信息」中维护。列表视图提供批量排课、复制粘贴、拖动交换课程等操作。
时间表页采用 ClassIsland 式编辑器:左侧时间表列表(新建、复制、搜索),顶部按类型添加上课、课间休息、分割线、行动,中间可在列表视图与时间轴视图之间切换,右侧编辑选中时间点,左下设置默认上课/课间时长,右下缩放。时间轴视图可按分钟拖动时间点的开始/结束把柄,并按 1 分钟取整;列表视图可拖动排序。保存仍走页面顶部「保存」或 Ctrl+S;切周与实时刷新保留草稿,切换班级和离开页面时提醒未保存的修改。
上课时间点可以设置一个默认课程。日课表里**没有单独条目**的课时会回退到该默认课程;已有条目(包括显式空课)不会被覆盖。「覆盖现有课程」会把这门课写入所有引用该时间表的日课表。默认隐藏的上课时间点只在轮到时才在主界面显示;课间休息类型代表下课时间,折叠时完全隐藏,轮到它时才展开。
完整档案可在页头「导入/导出」中导入或导出 JSON,保留轮换、时间点类型、空课、停用、群组和预定。月视图提供日期导航,点击日期后由服务端解析教学周并进入周视图。
## 日课表与时间表 ## 日课表与时间表
+55 -13
View File
@@ -16,8 +16,8 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
| ----------------------------------------------------------------------- | -------------------------------------------------------------- | | ----------------------------------------------------------------------- | -------------------------------------------------------------- |
| POST /auth/login、/auth/class-login | 员工/班级登录,共享登录限流;学生无账号 | | POST /auth/login、/auth/class-login | 员工/班级登录,共享登录限流;学生无账号 |
| GET /auth/me;POST /auth/logout;PATCH /auth/password | 当前身份、退出、员工本人改密码 | | GET /auth/me;POST /auth/logout;PATCH /auth/password | 当前身份、退出、员工本人改密码 |
| GET/POST /classes;GET/PATCH/DELETE /classes/:id | 班级查询/管理,写入需 class:manage 与管理员业务断言 | | GET/POST /classes;GET/PATCH/DELETE /classes/:id | 班级查询/管理,读取 class:read;写入 class:manage 且仅管理员 |
| PATCH /classes/:id/class-account、/classes/:id/head-teacher | 班级凭据/班主任维护 | | PATCH /classes/:id/class-account、/classes/:id/head-teacher | 班级凭据 class:account + 班主任;班主任分配仅管理员 |
| PUT /classes/:id/subject-teachers | 任课关系设置,Course 为唯一来源 | | PUT /classes/:id/subject-teachers | 任课关系设置,Course 为唯一来源 |
| GET/PATCH /classes/:id/student-grade-query | 查看/修改班级个人成绩查询开关,班级端不能修改 | | GET/PATCH /classes/:id/student-grade-query | 查看/修改班级个人成绩查询开关,班级端不能修改 |
| GET/POST /classes/:id/students;DELETE /classes/:id/students/:studentId | 本班名单及维护,受名单功能权限和班级关系约束 | | GET/POST /classes/:id/students;DELETE /classes/:id/students/:studentId | 本班名单及维护,受名单功能权限和班级关系约束 |
@@ -28,22 +28,22 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
## 教学与文件 ## 教学与文件
| 路径/动作 | 行为及边界 | | 路径/动作 | 行为及边界 |
| ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| /courses | 班级科目与任课关系 | | /courses | 班级科目与任课关系 |
| GET /schedules、/grid、/current、/active-week | 新关系模型的兼容投影,按班级范围读取 | | GET /schedules、/grid、/current、/active-week | 新关系模型的兼容投影,按班级范围读取 |
| POST/PATCH/DELETE /schedules[/:id] | 兼容 CRUD,写新模型,不双写旧 Schedule | | POST/PATCH/DELETE /schedules[/:id] | 兼容 CRUD,写新模型,不双写旧 Schedule |
| POST /schedules/swap、/overlay、/scheduled;GET /schedules/scheduled;DELETE /schedules/scheduled/:id | 换课、临时层、预定 | | POST /schedules/swap、/overlay、/scheduled;GET /schedules/scheduled;DELETE /schedules/scheduled/:id | 换课、临时层、预定 |
| /time-layouts;POST /time-layouts/:id/clone、/:id/import | 时间表 CRUD/克隆/导入,写入 timetable:manage | | /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 + 班主任/管理员 | | /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 | 复制、导入导出及完整档案导入 | | POST /class-plans/:id/clone、/class-plans/import、/class-plans/import/profile;GET /class-plans/:id/export | 复制、导入导出及完整档案导入;导入另需 import:schedule |
| /class-plan-groups;GET /class-plan-groups/selection;POST /class-plan-groups/select | 群组及当前/临时选择 | | /class-plan-groups;GET /class-plan-groups/selection;POST /class-plan-groups/select | 群组及当前/临时选择 |
| /homeworks、/homeworks/bulk、/:id/status、/:id/submissions | 作业、批量、完成与未交;员工功能键+科目检查,班级端可录作业 | | /homeworks、/homeworks/bulk、/:id/status、/:id/submissions | 作业、批量、完成与未交;员工功能键+科目检查,班级端可录作业 |
| /notifications、/notifications/bulk、/days、/creators、/unread-count、/:id/read、/read-all | 通知、归档和已读;发布需 notification:publish,课堂紧急须确认 | | /notifications、/notifications/bulk、/days、/creators、/unread-count、/:id/read、/read-all | 通知、归档和已读;发布需 notification:publish,课堂紧急须确认 |
| POST /calls | 叫人,call:create + 范围检查 | | POST /calls | 叫人,call:create + 范围检查 |
| /grades、/grades/bulk、/stats、/levels、/my、/overview、/collective、/student/:studentNo、/exams、/ranking | [成绩与隐私](../app/grades.md),查询列表参数用 studentId,写入仍按科目 | | /grades、/grades/bulk、/stats、/levels、/my、/overview、/collective、/student/:studentNo、/exams、/ranking | [成绩与隐私](../app/grades.md),查询列表参数用 studentId,写入仍按科目 |
| /imports/template、/table/preview、/table/commit | 表格导入,按 kind 再检查角色、导入权限、班级和科目 | | /imports/template、/table/preview、/table/commit | 表格导入,按 kind 再检查对应导入键、管理键、班级和科目 |
| /imports/class-plan/preview、/imports/class-plan | 旧预览与完整档案提交;提交走关系化导入 | | /imports/class-plan/preview、/imports/class-plan | 预览与提交均需 import:schedule + schedule:write;提交走关系化导入 |
| /imports/time-layout[/:id]、/time-layout/preview | 时间配置兼容入口,写入/删除需 timetable:manage | | /imports/time-layout[/:id]、/time-layout/preview | 时间配置兼容入口,导入需 import:schedule + timetable:manage,删除需 timetable:manage |
| GET /class-plans/profile?classId= | 完整档案导出,按班级范围读取,保留时间点与课表关联 | | GET /class-plans/profile?classId= | 完整档案导出,按班级范围读取,保留时间点与课表关联 |
| GET /courseware、/courseware/:id、/:id/download | 课件目录/详情/下载;员工需 courseware:read,班级端按可见范围 | | GET /courseware、/courseware/:id、/:id/download | 课件目录/详情/下载;员工需 courseware:read,班级端按可见范围 |
| POST /courseware/upload、/:id/push;PATCH /courseware/:id/visibility;DELETE /courseware/:id | 写入 courseware:write + 员工身份;本人文件、任课班级与可见范围限制 | | POST /courseware/upload、/:id/push;PATCH /courseware/:id/visibility;DELETE /courseware/:id | 写入 courseware:write + 员工身份;本人文件、任课班级与可见范围限制 |
@@ -53,19 +53,19 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
## 设备、系统与运维 ## 设备、系统与运维
| 路径/动作 | 行为及边界 | | 路径/动作 | 行为及边界 |
| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| POST /clients/heartbeat、/clients/commands/:id/ack | 登录设备心跳/回执,校验 deviceKey 和归属 | | POST /clients/heartbeat、/clients/commands/:id/ack | 登录设备心跳/回执,校验 deviceKey 和归属 |
| POST /clients/update-progress | 设备上传下载、校验、等待、安装和失败状态,校验设备班级归属 | | POST /clients/update-progress | 设备上传下载、校验、等待、安装和失败状态,校验设备班级归属 |
| GET /clients/devices;DELETE /clients/devices/:id | 设备读取/维护,分别检查功能键和范围 | | GET /clients/devices;DELETE /clients/devices/:id | 设备读取/维护,分别检查功能键和范围 |
| POST /clients/commands | remote:control + 对应范围,白名单动作 | | POST /clients/commands | remote:control + 对应范围,白名单动作 |
| POST /clients/updates | client:update,安装策略 immediate/after-school/idle | | POST /clients/updates | client:update-push,安装策略 immediate/after-school/idle |
| GET /update/check | 员工需 update:check;强制刷新另检查 update:force | | GET /update/check | 员工需 update:check;强制刷新另检查 update:force |
| GET /update/client/releases | 缓存版本读取,按路由允许的读取权限 | | GET /update/client/releases | 缓存版本读取,按路由允许的读取权限 |
| POST /update/client/fetch、/update/client/releases;DELETE /update/client/releases/:version | 安装包写入口统一 client:update | | POST /update/client/fetch、/update/client/releases;DELETE /update/client/releases/:version | 安装包写入口仅管理员 client:update |
| GET /update/client/download/:version | 已登录下载,不挂员工权限键,供班级端更新 | | GET /update/client/download/:version | 本班 CLASS_DEVICE 或 client:read + 有效班级范围 |
| /term;POST /term/auto;GET /term/holidays | 学期读取/编辑/假日建议,term:read/term:write | | /term;POST /term/auto;GET /term/holidays | 学期读取/编辑/假日建议,term:read/term:write |
| GET/PUT /term/promotion;POST /term/promotion/run | 升级计划、预览和执行,system:manage,执行还做业务校验 | | GET/PUT /term/promotion;POST /term/promotion/run | 升级计划与执行,promotion:manage + 全校范围,执行要求确认与计划哈希 |
| /archives、/archives/transferred、/archives/transferred/:id/restore | 历史归档与撤回转出,archive:manage;不提供创建归档代替年度升级的入口 | | /archives、/archives/transferred、/archives/transferred/:id/restore | 历史归档需 archive:manage 全校范围;撤回转出需 roster:manage;不提供手动创建归档 |
| /database/status、/test-connection、/backups、/export、/import、/sqlite-file、/backup-schedule、/switch | database:manage;目标空库、快照及换库子进程保护 | | /database/status、/test-connection、/backups、/export、/import、/sqlite-file、/backup-schedule、/switch | database:manage;目标空库、快照及换库子进程保护 |
| /system/settings、/autostart、/diagnostics | system:manage,非安装形态自启动返回不可用原因 | | /system/settings、/autostart、/diagnostics | system:manage,非安装形态自启动返回不可用原因 |
| /ai/settings、/interpret、/audit | ai:manage/ai:use;只解析待确认动作,密钥只进不出 | | /ai/settings、/interpret、/audit | ai:manage/ai:use;只解析待确认动作,密钥只进不出 |
@@ -75,3 +75,45 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
课件上传是原始文件流,512 MB 限制独立于表格 JSON 的 12 MB 限制;metadata 走上传查询 schema。 课件上传是原始文件流,512 MB 限制独立于表格 JSON 的 12 MB 限制;metadata 走上传查询 schema。
心跳 agentProtocol:2 使用统一响应 data,旧协议兼容字段只用于升级过渡。 心跳 agentProtocol:2 使用统一响应 data,旧协议兼容字段只用于升级过渡。
更多说明见[权限](permissions.md)、[实时事件](realtime.md)、[数据模型](data-model.md)。 更多说明见[权限](permissions.md)、[实时事件](realtime.md)、[数据模型](data-model.md)。
## 权限治理与学生年度流程
| 方法与路径(省略 /api) | 参数与权限边界 |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| GET /permissions/scopes | permission:manage;返回可选年级、部建议和计划年级 |
| GET /permissions/groups/:id/members | permission:manage;包含显式分组及角色默认回落的生效成员 |
| GET /permissions/audit?page=&pageSize= | permission:manage;分页操作人、目标、前后 JSON,pageSize 最大 100 |
| POST/PATCH/DELETE /permissions/groups[/:id] | 仅 ADMIN;仅可分配键,范围校验,教师默认模板固定 TEACHING,内置组不可删 |
| PUT /permissions/users/:id/groups | assignments 整体替换,每项 groupId/scope/scopeValue;兼容 groupIds 按建议范围分配;空数组恢复教师默认模板;ADMIN 不可分配 |
| GET /students?status=&includeArchived= | roster:read;默认 active,归档单独筛选;历史状态不会再被在读条件覆盖 |
| POST /students | roster:manage;仅 active/inactive,学号重复 409,班级不得归档 |
| PATCH /students/:id | roster:manage;仅资料字段;classId/status 明确返回 400 |
| POST /students/transfer | roster:manage;studentIds、toClassId(null 表示移出)、note;跨年级需 ADMIN、allowGradeChange=true 与备注 |
| POST /students/transfer-out | roster:manage;studentIds、note、toSchool;仅 active/inactive,毕业/归档/重复转出 409 |
| POST /students/transfer-in | roster:manage;指定 toClassId,新建用 studentNo/name,重入用 studentId;可传 fromSchool/note |
| POST /students/:id/restore | roster:manage;可传 toClassId/note;转出或停用恢复,原班归档时应另选在读班 |
| POST /archives/transferred/:id/restore | 兼容同上,权限改为 roster:manage |
| DELETE /students/:id | roster:manage;默认停用;hard=true&confirm=true 仅 ADMIN,存在业务或历史则 409 |
| GET /students/transfers;GET /students/:id/transfers | roster:read;不排除转出状态;支持 studentId/classId/batchId/academicYear 与分页 |
| GET/PUT /term/promotion | promotion:manage 全校范围;读取计划、执行时间、应执行学年与记录;保存兼容旧 month/day/hour/classCount,也接受 plan |
| POST /term/promotion/preview | promotion:manage 全校范围;year;只读返回逐轨道逐班统计、阻断项、警告、新班码、周次影响、planHash |
| POST /term/promotion/run | promotion:manage 全校范围;year、planHash、confirm:true;过去学年还须 ADMIN + catchUp:true;未来年份 400;阻断或哈希变化 409 带 details |
| POST /classes/promote | promotion:manage 与各目标范围;仅 classIds,不收自由 grade;从计划推导下一学段,毕业学段 409 |
| PATCH /classes/:id | 不再接受 grade,旧请求返回 400;改年级须走升级入口 |
| DELETE /archives/:id | archive:manage 全校范围;仅撤销归档标记,保留转出与停用状态,不回滚年级、新班及周次 |
生命周期历史以 kind 为事实源,mode 为派生兼容字段;新响应另含 batchId、academicYear、fromGrade/toGrade、fromSchool/toSchool。
归档详情含 inactive 列表、inactiveCount 和按学生去重的 transferInCount;升级记录含 operatorId/operatorName、detailsJson、snapshotSha256、error。
所有学生与历史写入在同一事务,提交后再广播旧班与新班。班级详情只有持 roster:read 且在对应范围内的员工才能获得学生明细。
## 分配级范围与班级设置
`GET /permissions/catalog` 增加 meta(kind/risk/relation/allowedScopes)。模板 DTO 的 defaultScope/defaultScopeValue/defaultScopeLabel 及兼容 scope 字段都是建议范围;kind、inactiveKeys、warnings 展示模板类别和停用键。成员 DTO 的 assignments 是实际分配,groupIds 为派生值;ADMIN fixed=true。
`GET /permissions/users/:id/effective` 仅 ADMIN,返回 permissions、entries(每键 scopes/classes/classCount)、conflicts、headOnlyKeys。可选 query assignments 为 JSON 数组,预览尚未保存的整体分配;预览与实际授权共用求值器。
`PATCH /classes/:id/settings` 使用 class:settings + 本班班主任(ADMIN 例外)保存 termWeeks/termStartDate。管理员 `PATCH /classes/:id` 只接收 enrollmentYear/classIndex。`PATCH /classes/:id/class-account` 使用 class:account + 本班班主任。全局周次仅管理员,带 classId 的周次写走 class:settings + 班主任。时间表写入按存储 classId 分流,班级时间表需要 schedule:write + 班主任,全校时间表仅管理员。
成绩 overview/collective/exams/ranking 显式检查 grade:read,班级端由服务断言限本班并执行查询开关。设备 heartbeat/update-progress/commands/:id/ack 仅接受绑定本班的 CLASS_DEVICE,不接受教师个人设备。
`GET /permissions/me` 同时返回 classScopes、manageScopes、allScopeKeys,供 Web 按钮与班级选择器显示实际能力范围;manageScopes 对名单、日课表、导入和考试配置叠加班主任或显式组织范围规则。界面范围提示不替代服务端断言。
+13
View File
@@ -37,6 +37,19 @@ ClassHelper 是 pnpm workspace:Express/Prisma/Socket.IO 服务端、Vue Web
客户端先维护 IndexedDB 缓存,断网读取缓存;主窗口通过 preload 与主进程通信,主进程管理唯一原生岛窗口。 客户端先维护 IndexedDB 缓存,断网读取缓存;主窗口通过 preload 与主进程通信,主进程管理唯一原生岛窗口。
课件先流式上传到服务端文件目录,再由客户端下载、验摘要并独立回执。 课件先流式上传到服务端文件目录,再由客户端下载、验摘要并独立回执。
客户端统一更新由服务端缓存安装包、校验设备权限并通过心跳下发。客户端下载并校验 SHA256 后,
把安装交给独立的 Windows 原生助手;助手从本机 `updates` 目录运行,再次验包并确认就绪,
主进程复核更新策略后才退出。助手等待旧进程结束、执行安装并检查安装版的新版本及启动状态,
失败写入 `install-result.json` 并尝试恢复客户端;成功仍以新版心跳为最终回执。
排障日志为同目录下的 `install-helper.log`。Windows 构建使用系统 .NET Framework 编译器生成助手,
教室机运行时不需要安装开发工具;助手与安装包均随客户端构建,不能只替换主进程 JS。
更新专项验证使用 `pnpm verify:update-download`、Windows 下的 `pnpm verify:update-installer`。
显式执行 `pnpm verify:client-update:real` 会创建临时数据库、班级和独立应用标识,真实安装并升级
测试副本,再卸载测试安装,报告保存在 `.cache/client-update-real/`;它不属于普通冒烟,
不会使用真实班级或桌面自动化,也不修改根版本号。
传入 `--portable --unicode-paths --prefix-version` 可覆盖便携运行转安装版、中文空格路径和带 `v` 的目标版本号。
## 工程边界 ## 工程边界
- 三端契约只放 shared,改后先 `pnpm build:shared`。 - 三端契约只放 shared,改后先 `pnpm build:shared`。
+24 -4
View File
@@ -11,10 +11,10 @@ description: 现行模型职责、迁移与快照边界。
## 模型职责 ## 模型职责
| 模型 | 职责 | | 模型 | 职责 |
| ---------------------- | -------------------------------------------------- | | ---------------------- | --------------------------------------------------- |
| `User` | 教师与管理员账号;学生不在此表,班级凭据在 Class | | `User` | 教师与管理员账号;学生不在此表,班级凭据在 Class |
| `Student` | 学生名单、学号、在读/转出/毕业状态,无密码和角色 | | `Student` | 学生名单、学号、在读/转出/毕业状态,无密码和角色 |
| `StudentClassTransfer` | 调班与转出历史,保留班级/姓名快照 | | `StudentClassTransfer` | 生命周期历史,kind/batchId/学年/班级年级及学校快照 |
| `ArchivedYear` | 毕业届别与统计快照 | | `ArchivedYear` | 毕业届别与统计快照 |
| `TermWeek` | 逐周日期区间,classId 空串为全校默认 | | `TermWeek` | 逐周日期区间,classId 空串为全校默认 |
| `Class` | 班级、班级码/密码、班主任、周数与成绩查询开关 | | `Class` | 班级、班级码/密码、班主任、周数与成绩查询开关 |
@@ -26,9 +26,10 @@ description: 现行模型职责、迁移与快照边界。
| `NotificationRead` | 按 studentId 记录已读 | | `NotificationRead` | 按 studentId 记录已读 |
| `TimeLayout` | 共享或班级自有时间表;旧 items JSON 保留迁移审计 | | `TimeLayout` | 共享或班级自有时间表;旧 items JSON 保留迁移审计 |
| `Grade` | studentId、课程、考试、分数和等级 | | `Grade` | studentId、课程、考试、分数和等级 |
| `PermissionGroup` | 默认/自定义/岗位组及 scope/scopeValue | | `PermissionAudit` | 权限与人工升级审计,操作人和前后 JSON,不建账号外键 |
| `PermissionGroup` | 岗位模板与建议 scope/scopeValue |
| `PermissionGroupItem` | 组内权限键 | | `PermissionGroupItem` | 组内权限键 |
| `UserPermissionGroup` | 账号与组的多对多分配 | | `UserPermissionGroup` | 账号与模板的分配及实际 scope/scopeValue |
| `AppSetting` | 周次、升级、AI 和系统设置 | | `AppSetting` | 周次、升级、AI 和系统设置 |
| `ClientDevice` | deviceKey、心跳、更新目标与状态 | | `ClientDevice` | deviceKey、心跳、更新目标与状态 |
| `ClientCommand` | 白名单远控、有效期与执行回执 | | `ClientCommand` | 白名单远控、有效期与执行回执 |
@@ -73,3 +74,22 @@ User.careMode 是预留列;当前 Web 偏好在 localStorage,客户端偏好
2. 核对外键级联与删除护栏,特别是教师的班级、课程、作业、通知和课件。 2. 核对外键级联与删除护栏,特别是教师的班级、课程、作业、通知和课件。
3. 在临时库验证新建和旧库升级;不对真实业务库运行 seed/reset。 3. 在临时库验证新建和旧库升级;不对真实业务库运行 seed/reset。
4. 重新生成 Prisma Client,并按修改范围执行[验收](testing.md)。 4. 重新生成 Prisma Client,并按修改范围执行[验收](testing.md)。
## 权限与生命周期迁移
`20261023000000_promotion_permission` 为已初始化的 ADMIN 默认组补 promotion:manage;默认组尚未创建时允许不插入,由运行时模板创建。
`20261023010000_student_lifecycle_audit` 增加 PermissionAudit、StudentClassTransfer 的批次/学年/班级年级/学校快照、ArchivedYear.inactiveCount、PromotionRun 的操作人/明细/摘要/错误。
旧 mode 按 single/batch → class-change、transfer-out → transfer-out、restore-transfer → restore 迁移后删除该列,避免双事实源。
旧行的新字符串为空,学年为空,统计默认零;不臆造无法恢复的历史年级或批次。
SNAPSHOT_TABLES 已加入 PermissionAudit,新增字段随模型导出。旧快照没有审计表可恢复为空,旧历史 mode 在恢复时转为 kind;
没有新增列的旧行使用 schema 默认值。执行记录与升级快照不自动清理。
升级计划存 AppSetting.term.promotionPlan,时间存 term.promotionAt,无需新增学校或学生账号模型。
## 授权分配迁移
`20261031000000_authz_assignments` 为 UserPermissionGroup 增加 scope(默认 TEACHING)与 scopeValue。先按旧模板范围回填分配,再将模板 SELF_CLASS/空值和 SELF_CLASS/HEAD 规范为 TEACHING/HEAD。教师默认模板建议范围固定 TEACHING;旧 ADMIN 默认模板与旧管理员专用键保留在库中,由运行时忽略。
同一成员可将同一模板分配到多个范围,唯一约束为 userId/groupId/scope/scopeValue。PermissionGroup 的范围列仅作建议,不参与已有分配求值。
快照表集合和版本 1 保持兼容。恢复旧模板时映射 SELF_CLASS;旧 UserPermissionGroup 没有范围列时按快照内对应模板推导,缺组回落 TEACHING。新快照按实际分配列恢复。真实库升级前先备份快照,隔离回归不替代真实库升级演练。
+16 -2
View File
@@ -18,10 +18,10 @@ description: 课表、周次、学年、岛、设备与课件的现行实现入
## 权限、成绩与年度升级 ## 权限、成绩与年度升级
默认权限组、岗位范围、科目写权限分别由 permissions/permission-scope/access 维护,范围并集不授予任课权。 能力、分配范围、业务关系统一由 lib/authz 的 principal/scope/relations/assert 维护;access 只提供带显式键的窄封装。ADMIN 固定全集,TEACHER 过滤管理员专用键。
全校周数上限在 `term.maxWeeks`,默认 30、允许 1–60;开学日和逐周区间支持全校与班级设置。 全校周数上限在 `term.maxWeeks`,默认 30、允许 1–60;开学日和逐周区间支持全校与班级设置。
个人成绩查询关闭后只返回服务端集体聚合,个人明细、原始全班成绩与排名拒绝读取;样本不足或稀疏分段抑制。 个人成绩查询关闭后只返回服务端集体聚合,个人明细、原始全班成绩与排名拒绝读取;样本不足或稀疏分段抑制。
年度升级由 `term.promotionAt` 和 `PromotionRun.year` 管理,先存快照;运行期到点执行,启动漏跑需管理员预览补跑。 年度升级由 `term.promotionPlan`、`term.promotionAt` 和 `PromotionRun.year` 管理,先存快照;运行期到点执行,启动漏跑需管理员预览补跑。
毕业班归档,其余升一级,新班不复制任课关系。转出学生由 `lib/students.ts` 统一过滤。 毕业班归档,其余升一级,新班不复制任课关系。转出学生由 `lib/students.ts` 统一过滤。
## 岛与本机配置 ## 岛与本机配置
@@ -38,3 +38,17 @@ description: 课表、周次、学年、岛、设备与课件的现行实现入
更新从服务端缓存下载,客户端验 SHA256,按下发策略安装;远控白名单动作执行后回执,测试只用假执行器。 更新从服务端缓存下载,客户端验 SHA256,按下发策略安装;远控白名单动作执行后回执,测试只用假执行器。
课件上限 512 MB,服务端流式落盘后创建元数据和推送,客户端逐设备验摘要并回执,不自动运行文件。 课件上限 512 MB,服务端流式落盘后创建元数据和推送,客户端逐设备验摘要并回执,不自动运行文件。
JSON 快照含课件表,完整恢复还必须带文件实体目录。细节见[课件](../app/courseware.md)。 JSON 快照含课件表,完整恢复还必须带文件实体目录。细节见[课件](../app/courseware.md)。
## 学生与权限写入契约
学生状态/班级写入口集中在 `lib/student-lifecycle.ts`,班级名单添加/移出、名单导入、转出和转入均复用它。
资料 PATCH 不承担状态或班级迁移。学生与历史在事务内提交,UUID 标识批次,事务后广播两端班级,历史读取不附带在读过滤。
权限、功能对应范围、业务关系依次断言;权限只在当前请求对象上复用,下一请求重新计算。
范围解析在 shared,实际范围存分配记录,模板范围仅作新分配建议。各键独立合并,无键为空集;默认教师范围固定 TEACHING。
权限组或成员分配与 PermissionAudit 同事务,成员权限一次预取计算。
年度升级服务由计划推导所有轨道的下一学段。预览哈希覆盖配置、班级、学生和周次,执行事务内重新计算;
先保存全库快照并计算 SHA256,再以年份唯一键提交升级。毕业班所有状态挂届别,非毕业班仅升级及清班级开学日覆盖。
周次是否清空由计划决定,预览公开影响。服务保留 runPromotion(year, dryRun) 内部兼容签名;HTTP 执行必须提供哈希、确认并检查年份。
实时班级事件按 core/homework/notification/grade/courseware 分房间,事件总线按 shared 映射选择通道,教师仅订阅对应读键范围。HTTP 与 Socket 握手共用数据库身份加载;敏感权限与账号变更使旧订阅失效。事务路径传递同一数据库连接给授权断言,保留原/目标资源的校验。
+57 -50
View File
@@ -1,69 +1,76 @@
--- ---
title: 角色与权限 title: 角色与权限
description: 功能权限、岗位范围与班级科目边界。 description: 能力目录、岗位模板、分配范围与业务关系的授权契约。
--- ---
# 角色与权限 # 角色与权限
授权分为功能权限、数据范围和业务关系三层,任何一层拒绝都不能操作。 授权依次检查能力、该能力的数据范围、数据行上的业务关系。服务端统一入口为 `lib/authz/`,路由预检不能替代服务断言。共享目录 `PERMISSION_META` 提供分类、风险、关系提示、合法范围、标签和默认角色;完整键目录读取源码,不在文档维护固定数量。
shared 的 `permissions.ts` 定义键、标签、默认模板与班级关系规则;服务端 `lib/permissions.ts`、
`lib/permission-scope.ts`、`lib/access.ts` 分别计算生效键、查询范围和具体业务断言。
## 身份与权限组 ## 身份与模板
账号角色为 ADMIN、TEACHER;班级登录形成 CLASS_DEVICE 会话。学生是 Student 名单,不是账号。 | 主体 | 生效能力 |
HEAD/SUBJECT 是班级关系,级长/部长是岗位组,均不新增 User.role。 | -------------------- | -------------------------------------------------- |
| ADMIN | 固定拥有全部目录键及全校范围,忽略权限组 |
| TEACHER | 显式分配模板的可分配能力并集;管理员专用键永久无效 |
| 未显式分配的 TEACHER | 数据库教师默认模板与固定 TEACHING 范围 |
| CLASS_DEVICE | 空集;接口明确允许班级端并校验本班关系 |
| 情况 | 有效功能权限 | 学生是 Student 名单记录,没有账号。HEAD/SUBJECT 是班主任和科任关系,级长/部长是岗位模板,不增加 User.role。权限管理只允许 ADMIN;旧 ADMIN 默认模板保留存储但隐藏、忽略。
| ------------ | ----------------------------------------------------- |
| 已分配权限组 | 所有分组键的并集,可以比角色默认更少 |
| 没有分组 | 数据库对应角色的可编辑默认组 |
| ADMIN | 同上,始终额外保留 permission:manage 与 system:manage |
| CLASS_DEVICE | 空集;通过明确允许班级会话的接口与业务断言操作 |
每请求读取生效权限,编辑后无需等待令牌过期。内置组可改不可删,默认组不是写死的全权限快捷方式。 `PermissionGroup` 保存能力和新分配的建议范围,`UserPermissionGroup` 保存实际 `scope/scopeValue`。模板改建议范围不会改变已有分配;模板改能力会影响其成员。同一模板可分配不同范围,多条分配按键取并集。空分配恢复默认模板,界面不会将未分组成员自动转换成显式默认组。
路由使用 requirePermission(AND)、requireAnyPermission(OR),员工与班级端共享读取入口按 requireStaffPermission 等规则检查。
具体 API 的额外角色限制以模块路由和服务断言为准,不能把“默认只分配给管理员”写成所有接口硬编码 ADMIN。
## 数据范围与科目 ## 能力目录与范围
| 岗位/身份 | 范围 | 能力分为管理员专用、班级级、组织只读。管理员专用能力包括组织与账号维护、权限/系统/数据库、全局周次、全校时间表、升级归档、安装包管理等;具体清单以 `PERMISSION_META.kind` 为准。`teacher:read` 和 `update:check` 为组织只读,忽略班级范围。其余班级级能力按分配范围求值。
| ----------- | --------------------------------------------- |
| 管理员 | 全部班级;仍须功能键 |
| 班主任/教师 | 自己担任班主任或任课的班级,按分组 scope 收敛 |
| 级长 | 配置年级 |
| 部长 | 配置的一组年级,scopeValue 用逗号分隔 |
| 班级端 | 登录绑定班级 |
空年级/部配置不放行。设备写操作只合并含对应功能键的分组范围,不能借只读组扩权。 | 范围 | 语义 |
科目编辑再由 `assertCanManageSubjectContent` 检查:管理员可管理,教师仅自己任课科目,无科目内容归班主任。 | -------- | -------------------------- |
班主任不任教某科就不能改该科的作业或成绩;班级端只允许作业写入,不能写成绩。 | TEACHING | 本人担任班主任或任课的班级 |
| HEAD | 仅本人担任班主任的班级 |
| GRADE | 指定一个年级 |
| DIVISION | 指定多个年级 |
| ALL | 全校 |
| 业务 | 主要功能键与额外边界 | `parsePermissionScope` 统一校验范围。GRADE/DIVISION 必须选择已有年级或升级计划中的年级,范围值按逗号分隔、去重排序;其余范围值必须为空串。旧 SELF_CLASS 仅在迁移和旧快照恢复中映射为 TEACHING/HEAD,不是运行时枚举。
| ---------------- | ---------------------------------------------------------------------------- |
| 名单读取/管理 | 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 |
完整键目录读取 `PERMISSION_KEYS`,不在文档维护固定键数。 `loadAuthPrincipal` 在请求主体上缓存只读能力和分配快照,没有可变的“当前权限键”。`classIdsFor(user, key)` 和 `classWhere(user, key)` 强制传键,无键或无授权返回空集合;不会合并其他能力的范围。`classWhere` 过滤 Class 表主键,下游子表使用明确的 classId 条件。
## 前端门控与成绩隐私 ## 关系与写入
Web 菜单属性来自路由,`meta.roles` 粗筛、`meta.permission` 细筛;先 ensurePermissions 再按生效集合判断。 | 操作 | 功能与关系 |
服务端仍检查功能、范围与业务关系,直接构造请求不能绕过。 | --------------------------------------------- | -------------------------------------------------------------------------------- |
岗位范围可以扩大读范围,不能自动扩大科目写权限,也不能替代课件的本人任课关系。 | 建班/删班/届别班号、班主任/任课分配、教师账号 | 管理员专用,记录审计;最后一个管理员不可降级或删除 |
班级成绩查询开关仅员工按业务权限修改;关闭后班级端仍看集体聚合,个人明细、原始全班成绩和排名返回拒绝。 | 班级账号 | class:account + 本班班主任,管理员例外;班级码只给班主任或管理员 |
稀疏分段与补集抑制,服务端不向客户端下发个人原始成绩再聚合。 | 班级学期设置/班级周次 | class:settings + 本班班主任,管理员例外 |
| 名单、日课表与课程写入 | 对应写键;TEACHING/HEAD 要求班主任,显式年级/部/全校范围允许范围内维护 |
| 学生/课表导入 | 导入键与相应写键同时满足;先校验全部班级再写,学生及任课导入使用事务 |
| 班级时间表写入 | schedule:write + 本班班主任;按存储 classId 校验,所有引用班级也要校验 |
| 全校时间表、全局周次 | 管理员;班级设置使用独立接口 |
| 作业/成绩写入 | assertContentWrite;有科目要求该科任教,无科目要求班主任;原科目与目标科目都检查 |
| 作业提交/未交名单 | homework:write + 同样的科目关系;班级端明确允许本班作业 |
| 通知/叫人 | 发布能力和范围,再检查班主任/任课关系;班级端限本班 |
| 通知删除 | notification:delete + 发布者/班主任/管理员 |
| 课件 | 本人文件、对应功能范围与本人授课班级共同约束,不赋予班主任同事文件访问权 |
## 设备维护 ALL 也不能替代班主任、任课、发布者关系,只有 ADMIN 无条件绕过关系。班级端可代录作业,不能写成绩。跨班调班检查源班与目标班。事务中的断言使用同一事务连接,不能因事务路径省略授权。
`/api/clients/heartbeat` 和命令回执供已登录设备使用,不依赖班级端的空权限集合;服务端校验 deviceKey 与班级归属。 成绩列表、考试、排名和集体分析都要求 grade:read 与对应班级范围。学生明细另要求 student:grade-query。关闭班级自助查询后,本班设备仍可获得集体聚合,个人明细、原始成绩、考试列表和排名拒绝。实时载荷同样受隐私开关保护,服务端执行稀疏样本和补集抑制。
远控从管理端确认下发,客户端执行后回传真实结果。更新文件由服务端缓存,教室机验 SHA256,
按立即、放学后、空闲策略安装;关闭自动更新不会强制安装。详见[更新与升级](../management/upgrade.md)。 ## 实时与设备
班级房间为 `class:{id}:{channel}`,事件映射见 `SOCKET_EVENT_CHANNELS`。班级端加入本班全部通道;教师按各域读键及范围加入,core 由 class:read/schedule:read 决定。显式订阅再次校验通道,握手与 HTTP 共用数据库身份加载;权限/账号变更断开旧订阅。
心跳、进度和命令回执只接受 CLASS_DEVICE,deviceKey 必须属于会话班级。远控只排队允许的系统指令,自动化始终假执行。安装包登记/删除使用管理员 client:update;班级推送使用 client:update-push。下载要求本班班级端或有范围的 client:read。版本与文件名在请求校验和路径构造两处拒绝不安全路径段。
## 权限页与审计
权限页默认展示成员与范围,每条分配独立选择范围,保存前预览新增/移除能力、每键合并范围、班级列表、混合范围提示、班主任专属提示和风险标记。模板页仅提供可分配能力,旧管理员键显示为停用。组织只读能力标注“不限班级”。路由用 `meta.permission` 检查读取能力;维护页面额外用 `meta.menuActions` 声明可执行动作,动作须在至少一个可读班级生效才显示侧栏、搜索及页签,有部分功能可操作就保留入口。`permissions/me.actionScopes` 结合范围和班主任、任课、发布者关系提供动作班级;班级管理动作另用 `manageScopes`。纯查看页面按读取能力展示,服务端始终做最终判定。
学期周次维护入口要求全校周次写能力或至少一个可维护的本班设置范围。非全校维护者进入后默认选择可编辑班级,仍可切换查看授权范围内的其他班级。
目录、模板、成员、分配预览和审计的公开契约见[API](api.md)。模板/分配和账号敏感操作写入 PermissionAudit;审计不保存密码明文或哈希。迁移与快照兼容见[数据模型](data-model.md)。
## 验证
`pnpm check:authz` 扫描旧授权符号和缺少显式键的查询,并接入 lint。`pnpm verify:authz` 使用临时 SQLite 验证接管班级、管理员提权、跨键借范围、科目改挂、跨班时间表/周次/导入、成绩隐私、通道订阅、设备冒用和旧快照等攻击场景。其他专项回归保留生命周期、课表、课件、设备与升级覆盖。验证不触及真实班级、不访问外网、不执行系统指令。
+8 -5
View File
@@ -5,22 +5,21 @@ description: 实际事件与房间、断线恢复和客户端提醒分工。
# 实时事件(Socket.IO) # 实时事件(Socket.IO)
连接使用 `io(url, { auth: { token } })`,服务端在握手校验 JWT。 连接使用 `io(url, { auth: { token } })`,服务端握手校验 JWT 并与 HTTP 共用数据库身份加载。
事件与房间定义在 shared 的 `constants.ts`,载荷类型在 shared 的 `types.ts`,以这些契约和发送点为准。 事件与房间定义在 shared 的 `constants.ts`,载荷类型在 shared 的 `types.ts`,以这些契约和发送点为准。
## 房间与定向推送 ## 房间与定向推送
| 房间 | 作用 | | 房间 | 作用 |
| --------------- | -------------------------------------------------- | | ------------------------- | -------------------------------------------------- |
| class:{classId} | 某班级的业务广播 | | class:{classId}:{channel} | 某班级对应读域的业务广播 |
| session:{id} | 会话定向名;员工 id 为账号 id,班级端 id 为班级 id | | session:{id} | 会话定向名;员工 id 为账号 id,班级端 id 为班级 id |
| role:{role} | 角色房间名,定义在共享常量 | | role:{role} | 角色房间名,定义在共享常量 |
| teacher:{id} | 教师房间名,定义在共享常量 | | teacher:{id} | 教师房间名,定义在共享常量 |
| students | 班级端连接的广播房间,代表全班,不是个人学生账号 | | students | 班级端连接的广播房间,代表全班,不是个人学生账号 |
| teachers | 教师和管理员连接的广播房间 | | teachers | 教师和管理员连接的广播房间 |
`realtime/socket.ts` 当前显式 join 可访问的 class 房间与 students/teachers 广播房间; `realtime/socket.ts` 按域加入授权班级房间,并加入 session/role 与 students/teachers 房间;connected 载荷列出实际已加入房间。
同时在 connected 载荷列出 session/role 名。不能把共享常量或该列表当成所有房间均已成功 join 的证明,实际订阅以 join 调用为准。
业务经 `realtime/bus.ts` 的 emitToClass/emitToSession/emitToRooms 推送。 业务经 `realtime/bus.ts` 的 emitToClass/emitToSession/emitToRooms 推送。
叫人记录属于班级通知,并发送班级广播与班级会话定向事件;学生本身没有连接和个人房间。 叫人记录属于班级通知,并发送班级广播与班级会话定向事件;学生本身没有连接和个人房间。
@@ -47,3 +46,7 @@ description: 实际事件与房间、断线恢复和客户端提醒分工。
同步修改共享事件名与载荷、服务端发送点、Web `stores/realtime.ts`、客户端 `stores/realtime.ts` 及相关缓存/岛逻辑。 同步修改共享事件名与载荷、服务端发送点、Web `stores/realtime.ts`、客户端 `stores/realtime.ts` 及相关缓存/岛逻辑。
不要手写事件名;只改服务端会导致前端静默不刷新。涉及成绩时另检查隐私载荷和班级范围。 不要手写事件名;只改服务端会导致前端静默不刷新。涉及成绩时另检查隐私载荷和班级范围。
## 读域订阅
现行班级房间为 `class:{id}:{channel}`。channel 为 core/homework/notification/grade/courseware,事件映射来自 shared 的 SOCKET_EVENT_CHANNELS。旧 `class:{id}` 仅保留兼容名称,生产不订阅。班级端加入本班全部域;教师按对应读键和分配范围加入。`class:join` 接受旧 classId 字符串或 `{classId, channels}`,后者逐通道校验,有任一无权则拒绝整次请求。握手回查账号与角色,权限变更断开旧订阅。
+2
View File
@@ -41,6 +41,8 @@ pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版
| releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe | ClassHelper-<版本>-x64-server-setup.exe | | releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe | ClassHelper-<版本>-x64-server-setup.exe |
| releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz | 同名 | | releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz | 同名 |
Windows 服务端安装包只携带配置占位符,首次安装时生成独立的 JWT 密钥;覆盖升级保留原有 `.env`。`pnpm verify:server-bootstrap` 验证密钥独立性和配置保留,打包后还需在解包副本中复验。
Linux 还需同名 .tar.gz.sha256。正式 Linux 包在 Linux runner 运行 pnpm dist:server:linux; Linux 还需同名 .tar.gz.sha256。正式 Linux 包在 Linux runner 运行 pnpm dist:server:linux;
Windows 上的交叉构建仅作验证,可能缺目标平台的 Prisma engine。 Windows 上的交叉构建仅作验证,可能缺目标平台的 Prisma engine。
构建中间物可再生成,不将 win-unpacked、server staging 或解包副本当交付物保留。 构建中间物可再生成,不将 win-unpacked、server staging 或解包副本当交付物保留。
+8 -1
View File
@@ -11,12 +11,13 @@ description: 隔离 API、隐藏 UI、后台客户端与显式视觉验收。
## 命令与前置条件 ## 命令与前置条件
| 命令 | 覆盖/前置条件 | | 命令 | 覆盖/前置条件 |
| ------------------------------------------------ | ----------------------------------------------------------------- | | ------------------------------------------------ | ---------------------------------------------------------------------------- |
| pnpm lint / pnpm typecheck / pnpm check:icons | 静态检查;typecheck 会先构建 shared | | pnpm lint / pnpm typecheck / pnpm check:icons | 静态检查;typecheck 会先构建 shared |
| pnpm docs:check | docfx 严格构建,需 .NET SDK 与 docfx | | pnpm docs:check | docfx 严格构建,需 .NET SDK 与 docfx |
| pnpm verify:e2e | 自动临时 SQLite、种子和带前缀账号,启动随机端口服务;无需真实服务 | | pnpm verify:e2e | 自动临时 SQLite、种子和带前缀账号,启动随机端口服务;无需真实服务 |
| pnpm verify:web:isolated | 同上隔离服务,隐藏 Electron UI 回归;先构建 Web 产物 | | pnpm verify:web:isolated | 同上隔离服务,隐藏 Electron UI 回归;先构建 Web 产物 |
| pnpm verify:web | 连接已准备测试服务,需 Web 产物和测试管理员;不应指向业务服务 | | pnpm verify:web | 连接已准备测试服务,需 Web 产物和测试管理员;不应指向业务服务 |
| pnpm verify:lifecycle | 临时 SQLite 验证权限治理、事务故障回滚、状态机、历史、多轨道、计划哈希和快照 |
| pnpm verify:v14 | 临时库验证关系课表、周次、范围、隐私与升级等 | | pnpm verify:v14 | 临时库验证关系课表、周次、范围、隐私与升级等 |
| pnpm verify:courseware | 临时库和文件目录的课件专项,不向真实班级推送 | | pnpm verify:courseware | 临时库和文件目录的课件专项,不向真实班级推送 |
| pnpm verify:client-agent | 假系统适配器验证命令、回执和更新策略 | | pnpm verify:client-agent | 假系统适配器验证命令、回执和更新策略 |
@@ -42,6 +43,8 @@ pnpm check:icons
pnpm docs:check pnpm docs:check
pnpm verify:e2e pnpm verify:e2e
pnpm verify:v14 pnpm verify:v14
pnpm verify:lifecycle
pnpm verify:client-migrations
pnpm verify:courseware pnpm verify:courseware
pnpm build:desktop pnpm build:desktop
pnpm verify:desktop pnpm verify:desktop
@@ -67,3 +70,7 @@ docs/images 为 README/文档展示图;docs/screenshots 为回归留档;webs
Web 采集入口 `website/tools/capture-shots.mjs`,先准备隔离测试服务和数据。 Web 采集入口 `website/tools/capture-shots.mjs`,先准备隔离测试服务和数据。
岛/客户端采集使用 smoke.mjs --visual,并按需 --online;ISLAND_SHOTS_DIR 与 ELECTRON_CLIENT_SHOTS_DIR 指向临时目录。 岛/客户端采集使用 smoke.mjs --visual,并按需 --online;ISLAND_SHOTS_DIR 与 ELECTRON_CLIENT_SHOTS_DIR 指向临时目录。
日常 quick 模式不会生成完整视觉截图,不能把 quick 通过表述为动画/透明度已验证。 日常 quick 模式不会生成完整视觉截图,不能把 quick 通过表述为动画/透明度已验证。
权限与年度流程回归入口为 `scripts/verify-lifecycle.mjs`,为子进程指定临时库与测试密钥,不使用业务 .env 的登录凭据。
旧库升级脚本动态读取最新迁移,另建重构前夹具校验 ADMIN 默认组补键、历史 kind 映射、空组保留及重复启动。
事务回滚用 SQLite 触发器模拟历史写入失败,不能通过删除断言或放宽状态规则通过测试。
+10
View File
@@ -84,3 +84,13 @@ pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的
- 服务器与教室机器之间能互相 ping 通吗?防火墙放行 4000 了吗? - 服务器与教室机器之间能互相 ping 通吗?防火墙放行 4000 了吗?
排查清单见[常见问题](../faq.md)。 排查清单见[常见问题](../faq.md)。
## 7. 接收统一更新
管理员在设备管理中缓存并推送客户端安装包,客户端会后台下载、校验文件,再按立即、放学后或空闲策略安装。
关闭本机自动更新后,不会执行服务端下发的安装。安装助手未能就绪时,旧客户端会继续运行并显示失败原因。
服务端下发的是安装版:便携运行的客户端接收更新后转为安装版,原便携文件不会原地替换,后续请使用安装版入口。
新版重新连接服务端后,设备管理中的更新状态才会变为完成。
已发布旧版若在拉起安装器时失败,需要先手动安装 1.4.2 以获得新的安装助手;服务端推送安装包不能替换旧客户端中已经损坏的交接逻辑。
+8 -4
View File
@@ -13,15 +13,15 @@ description: 登录、菜单分组、权限与移动布局。
侧栏按以下分组组织,具体页面由路由权限筛选,并支持浅色/深色和关怀模式。 侧栏按以下分组组织,具体页面由路由权限筛选,并支持浅色/深色和关怀模式。
| 分组 | 主要页面 | | 分组 | 主要页面 |
| ---------- | -------------------------------------------------- | | ---------- | ------------------------------------------------------------ |
| 工作台 | 仪表盘、AI 助手 | | 工作台 | 仪表盘、AI 助手 |
| 教学事务 | 课表、时间表与日课表编辑、作业、通知、课件、成绩 | | 教学事务 | 课表、时间表与日课表编辑、作业、通知、课件、成绩 |
| 班级与人员 | 班级、学生、教师、只读学生名单、学期周次、毕业归档 | | 班级与人员 | 班级、学生、教师、只读学生名单、学期周次、学年升级、毕业归档 |
| 设备运维 | 客户端在线状态、命令、更新与安装策略 | | 设备运维 | 客户端在线状态、命令、更新与安装策略 |
| 系统管理 | 权限组、系统设置、大模型设置、数据库管理 | | 系统管理 | 权限组、系统设置、大模型设置、数据库管理 |
班级/学生/教师管理、学期周次、归档和数据库页面还有管理员角色门槛; 班级、学生、学期周次、学年升级和归档页面按各自生效权限开放;教师管理和数据库页面仍保留角色门槛。
教师只读名单、教学页与设备页按权限键和数据范围开放。 名单、教学页与设备页还按对应数据范围开放,写按钮需额外管理键。
每次操作服务端继续校验,不是显示菜单就能改所有班和科目。详见[角色与权限](../dev/permissions.md)。 每次操作服务端继续校验,不是显示菜单就能改所有班和科目。详见[角色与权限](../dev/permissions.md)。
顶栏提供本人密码、使用引导、检查更新与退出等入口。 顶栏提供本人密码、使用引导、检查更新与退出等入口。
@@ -32,3 +32,7 @@ description: 登录、菜单分组、权限与移动布局。
同局域网手机填写服务器局域网地址,不用 localhost;公网反代见[生产部署](../management/production.md)。 同局域网手机填写服务器局域网地址,不用 localhost;公网反代见[生产部署](../management/production.md)。
下一步:[建班级、排课表](setup-class.md)。 下一步:[建班级、排课表](setup-class.md)。
权限页选组会替代角色默认模板,保存前可查看新增/移除权限与影响成员;保留默认能力时同时选择同角色默认组。
名单页默认在读,历史状态与包含归档分开筛选;资料编辑、调班、转出、转入/复学各有专用入口。
学年升级先保存计划、预览阻断项和周次影响,再输入学年确认执行;执行记录保留快照与 SHA256。
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 67 KiB

+12 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "class-helper", "name": "class-helper",
"version": "1.4.1", "version": "1.4.2",
"private": true, "private": true,
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@11.8.0",
@@ -17,7 +17,7 @@
"build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build", "build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
"check:icons": "node scripts/check-icons.mjs", "check:icons": "node scripts/check-icons.mjs",
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck", "typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
"lint": "eslint .", "lint": "node scripts/check-authz.mjs && eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write \"**/*.{ts,js,mjs,cjs,vue,json,md,yaml,yml,css,scss,html}\"", "format": "prettier --write \"**/*.{ts,js,mjs,cjs,vue,json,md,yaml,yml,css,scss,html}\"",
"format:check": "prettier --check \"**/*.{ts,js,mjs,cjs,vue,json,md,yaml,yml,css,scss,html}\"", "format:check": "prettier --check \"**/*.{ts,js,mjs,cjs,vue,json,md,yaml,yml,css,scss,html}\"",
@@ -31,10 +31,14 @@
"verify:e2e": "node scripts/verify-e2e-isolated.mjs", "verify:e2e": "node scripts/verify-e2e-isolated.mjs",
"verify:courseware": "node scripts/verify-courseware.mjs", "verify:courseware": "node scripts/verify-courseware.mjs",
"verify:v14": "node scripts/verify-v14.mjs", "verify:v14": "node scripts/verify-v14.mjs",
"verify:lifecycle": "node scripts/verify-lifecycle.mjs",
"verify:client-agent": "node scripts/verify-client-agent.mjs", "verify:client-agent": "node scripts/verify-client-agent.mjs",
"verify:update-download": "node scripts/verify-update-download.mjs",
"verify:update-installer": "node scripts/verify-update-installer.mjs",
"verify:client-update:real": "node scripts/verify-client-update-real.mjs --real-install",
"verify:review-client": "node scripts/verify-review-client.mjs", "verify:review-client": "node scripts/verify-review-client.mjs",
"verify:review-api": "node scripts/verify-review-api.mjs", "verify:review-api": "node scripts/verify-e2e-isolated.mjs --review-api",
"verify:clients-api": "node scripts/verify-clients-api.mjs", "verify:clients-api": "node scripts/verify-e2e-isolated.mjs --clients-api",
"verify:client-migrations": "node scripts/verify-client-migrations.mjs", "verify:client-migrations": "node scripts/verify-client-migrations.mjs",
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke", "verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
"verify:web": "node scripts/ui-smoke/run.mjs", "verify:web": "node scripts/ui-smoke/run.mjs",
@@ -53,7 +57,10 @@
"dist:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux", "dist:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux",
"dist:all": "pnpm dist:server && pnpm dist:win", "dist:all": "pnpm dist:server && pnpm dist:win",
"verify:desktop:visual": "node packages/desktop-client/scripts/smoke.mjs --visual", "verify:desktop:visual": "node packages/desktop-client/scripts/smoke.mjs --visual",
"verify:desktop:online": "node packages/desktop-client/scripts/smoke.mjs --online" "verify:desktop:online": "node packages/desktop-client/scripts/smoke.mjs --online",
"verify:authz": "node scripts/verify-authz.mjs",
"check:authz": "node scripts/check-authz.mjs",
"verify:server-bootstrap": "node scripts/verify-server-bootstrap.mjs"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.5", "concurrently": "^10.0.5",
@@ -0,0 +1,172 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Web.Script.Serialization;
// 独立 WinExe:从 updates 目录运行,安装器覆盖应用时不会锁住安装目录。
// 只接收主进程生成的清单;不接受任意命令或安装参数。
internal static class UpdateHelper
{
public sealed class Manifest
{
public string Attempt;
public int ParentId;
public string ParentExe;
public string Installer;
public string RestartExe;
public string Version;
public string ServerUrl;
public string Username;
public string Sha256;
public long Size;
public bool Portable;
}
private static readonly JavaScriptSerializer Json = new JavaScriptSerializer();
private static readonly Encoding Utf8 = new UTF8Encoding(false);
private static string directory;
private static string prefix;
private static Manifest manifest;
private static Process parent;
private static bool installerRunning;
[STAThread]
private static int Main(string[] args)
{
try
{
if (args.Length != 1) return 2;
var file = Path.GetFullPath(args[0]);
directory = Path.GetDirectoryName(file);
if (!String.Equals(Path.GetFileName(directory), "updates", StringComparison.OrdinalIgnoreCase)) return 2;
manifest = Json.Deserialize<Manifest>(File.ReadAllText(file, Utf8));
if (manifest == null || !Regex.IsMatch(manifest.Attempt ?? "", "^[0-9a-f-]{36}$")) return 2;
prefix = Path.Combine(directory, "install-" + manifest.Attempt);
if (!SamePath(file, prefix + ".json")) return 2;
Log("助手启动,目标版本 " + manifest.Version);
parent = Process.GetProcessById(manifest.ParentId);
if (!SamePath(parent.MainModule.FileName, manifest.ParentExe)) throw new Exception("旧进程身份不匹配");
var installer = Path.GetFullPath(manifest.Installer);
if (!SamePath(Path.GetDirectoryName(installer), directory) ||
!String.Equals(Path.GetExtension(installer), ".exe", StringComparison.OrdinalIgnoreCase) ||
(File.GetAttributes(installer) & FileAttributes.ReparsePoint) != 0)
throw new Exception("安装包不在受管理目录内");
if (!Regex.IsMatch(manifest.Sha256 ?? "", "^[0-9a-fA-F]{64}$")) throw new Exception("缺少有效安装包摘要");
if (manifest.Size <= 0 || new FileInfo(installer).Length != manifest.Size) throw new Exception("安装包长度不匹配");
using (var stream = File.OpenRead(installer))
using (var hash = SHA256.Create())
{
var actual = BitConverter.ToString(hash.ComputeHash(stream)).Replace("-", "");
if (!String.Equals(actual, manifest.Sha256, StringComparison.OrdinalIgnoreCase)) throw new Exception("安装包 SHA256 校验失败");
}
File.WriteAllText(prefix + ".ready", manifest.Attempt, Utf8);
Log("校验通过,等待主进程确认交接");
var deadline = DateTime.UtcNow.AddSeconds(30);
while (!File.Exists(prefix + ".start"))
{
if (File.Exists(prefix + ".cancel")) { Log("主进程取消交接"); return 0; }
if (parent.HasExited) throw new Exception("主进程在确认交接前退出");
if (DateTime.UtcNow >= deadline) throw new Exception("等待主进程确认超时");
Thread.Sleep(50);
}
if (File.ReadAllText(prefix + ".start", Utf8) != manifest.Attempt) throw new Exception("交接标识不匹配");
if (!parent.WaitForExit(60000)) throw new Exception("等待旧客户端退出超时");
Log("旧进程已退出,开始安装");
int exitCode;
using (var installerProcess = Process.Start(new ProcessStartInfo(installer, "/S --updated --force-run")
{
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = directory
}))
{
if (installerProcess == null) throw new Exception("安装器未能启动");
installerRunning = true;
if (!installerProcess.WaitForExit(10 * 60 * 1000)) throw new Exception("安装器执行超时,请查看安装日志");
installerRunning = false;
exitCode = installerProcess.ExitCode;
}
Log("安装器退出码:" + exitCode);
if (exitCode != 0) throw new Exception("安装器退出码:" + exitCode);
if (!manifest.Portable)
{
var expected = ParseVersion(manifest.Version);
var actual = ParseVersion(FileVersionInfo.GetVersionInfo(manifest.RestartExe).ProductVersion);
if (actual.CompareTo(expected) < 0) throw new Exception("安装器结束后客户端版本仍未更新");
// 安装器通常会启动新版;未启动时补启一次,避免留在“安装中”。
var startedAt = DateTime.UtcNow;
while (!IsClientRunning(manifest.RestartExe) && DateTime.UtcNow - startedAt < TimeSpan.FromSeconds(3)) Thread.Sleep(100);
if (!IsClientRunning(manifest.RestartExe)) RestartClient();
}
Result("installed", "", exitCode);
Log("安装交接完成,等待新版客户端心跳确认");
return 0;
}
catch (Exception error)
{
try { Log("更新失败:" + error.Message); Result("failed", error.Message, null); } catch { }
// 超时的安装器可能仍在替换文件,不可在它退出之前重新打开客户端。
try { if (!installerRunning && parent != null && parent.HasExited && manifest != null && File.Exists(manifest.RestartExe) && !IsClientRunning(manifest.RestartExe)) RestartClient(); }
catch (Exception restoreError) { try { Log("恢复客户端失败:" + restoreError.Message); } catch { } }
return 1;
}
finally
{
if (prefix != null) foreach (var suffix in new[] { ".ready", ".start", ".cancel", ".json" })
try { File.Delete(prefix + suffix); } catch { }
if (parent != null) parent.Dispose();
}
}
private static bool SamePath(string a, string b)
{
return String.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
}
private static Version ParseVersion(string value)
{
Version version;
if (!Version.TryParse((value ?? "").Trim().TrimStart('v', 'V').Split('-', '+')[0], out version)) throw new Exception("无法识别客户端版本:" + value);
return version;
}
private static bool IsClientRunning(string exe)
{
foreach (var process in Process.GetProcessesByName(Path.GetFileNameWithoutExtension(exe)))
{
using (process) try { if (!process.HasExited && SamePath(process.MainModule.FileName, exe)) return true; } catch { }
}
return false;
}
private static void RestartClient()
{
Process.Start(new ProcessStartInfo(manifest.RestartExe, "--hidden")
{
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(manifest.RestartExe)
});
Log("已启动客户端");
}
private static void Log(string message)
{
if (directory != null) File.AppendAllText(Path.Combine(directory, "install-helper.log"), DateTime.UtcNow.ToString("o") + " " + message + Environment.NewLine, Utf8);
}
private static void Result(string stage, string error, int? exitCode)
{
if (manifest == null || prefix == null) return;
var value = new { version = manifest.Version, serverUrl = manifest.ServerUrl, username = manifest.Username, attempt = manifest.Attempt, stage = stage, error = error, installerExitCode = exitCode, at = DateTime.UtcNow.ToString("o") };
var target = Path.Combine(directory, "install-result.json");
var temp = prefix + ".result";
File.WriteAllText(temp, Json.Serialize(value), Utf8);
if (File.Exists(target)) File.Delete(target);
File.Move(temp, target);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/desktop-client", "name": "@classhelper/desktop-client",
"version": "1.4.1", "version": "1.4.2",
"private": true, "private": true,
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)", "description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper", "author": "Class Helper",
@@ -6,8 +6,10 @@
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { build } from 'esbuild'; import { build } from 'esbuild';
import { buildUpdateHelper } from './build-update-helper.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
buildUpdateHelper(path.join(root, 'dist/main/update-helper.exe'));
const shared = { const shared = {
bundle: true, bundle: true,
@@ -0,0 +1,31 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
/** Windows 自带 .NET Framework 编译器;构建产物随客户端分发,不在教室机上编译。 */
export function buildUpdateHelper(output) {
if (process.platform !== 'win32') return;
const framework = path.join(
process.env.SystemRoot ?? 'C:\\Windows',
'Microsoft.NET',
'Framework64',
'v4.0.30319',
);
const compiler = path.join(framework, 'csc.exe');
if (!fs.existsSync(compiler)) throw new Error('缺少 Windows .NET Framework 编译器,无法构建更新助手');
fs.mkdirSync(path.dirname(output), { recursive: true });
execFileSync(
compiler,
[
'/nologo',
'/target:winexe',
'/optimize+',
'/platform:anycpu',
`/reference:${path.join(framework, 'System.Web.Extensions.dll')}`,
`/out:${output}`,
fileURLToPath(new URL('../native/update-helper.cs', import.meta.url)),
],
{ stdio: 'pipe', windowsHide: true },
);
}
@@ -3,10 +3,12 @@ import type { IslandComponentKind } from '@classhelper/shared';
defineProps<{ kind: IslandComponentKind }>(); defineProps<{ kind: IslandComponentKind }>();
const paths: Record<IslandComponentKind, string> = { const paths: Record<IslandComponentKind, string> = {
clock: 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM12 7v5l3 2', clock: 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM12 7v5l3 2',
weather:
'M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5',
date: 'M6 5h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2ZM8 3v4m8-4v4M4 10h16M8 14h2m4 0h2m-8 3h2', date: 'M6 5h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2ZM8 3v4m8-4v4M4 10h16M8 14h2m4 0h2m-8 3h2',
schedule: 'M9 5h11M9 12h11M9 19h11M4 5h1m-1 7h1m-1 7h1', schedule: 'M9 5h11M9 12h11M9 19h11M4 5h1m-1 7h1m-1 7h1',
countdown: 'M6 3h12M6 21h12M8 3v4l4 5-4 5v4m8-18v4l-4 5 4 5v4', countdown: 'M6 3h12M6 21h12M8 3v4l4 5-4 5v4m8-18v4l-4 5 4 5v4',
text: 'M5 6V4h14v2M12 4v16m-4 0h8', text: 'M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm1 5h10M7 12h7m-7 4h10',
separator: 'M12 4v16M5 8v8m14-8v8', separator: 'M12 4v16M5 8v8m14-8v8',
notification: 'M9 19a3 3 0 0 0 6 0M6 10a6 6 0 0 1 12 0v4l2 3H4l2-3v-4Z', notification: 'M9 19a3 3 0 0 0 6 0M6 10a6 6 0 0 1 12 0v4l2 3H4l2-3v-4Z',
homework: 'M9 4H6a2 2 0 0 0-2 2v14h16V6a2 2 0 0 0-2-2h-3M9 3h6v4H9V3Zm-1 9h8m-8 4h5', homework: 'M9 4H6a2 2 0 0 0-2 2v14h16V6a2 2 0 0 0-2-2h-3M9 3h6v4H9V3Zm-1 9h8m-8 4h5',
@@ -5,13 +5,16 @@ import {
resolveClassStatus, resolveClassStatus,
type IslandComponentNode, type IslandComponentNode,
type IslandState, type IslandState,
type IslandTimePointDto,
type ScheduleDto, type ScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import WeatherBrief from './WeatherBrief.vue';
const props = defineProps<{ const props = defineProps<{
node: IslandComponentNode; node: IslandComponentNode;
state: IslandState; state: IslandState;
now: number; now: number;
schedules: ScheduleDto[]; schedules: ScheduleDto[];
timePoints: IslandTimePointDto[];
editing: boolean; editing: boolean;
selectedId: string; selectedId: string;
alertNodeId: string; alertNodeId: string;
@@ -38,20 +41,69 @@ function timeAt(time: string) {
d.setHours(h!, m!, 0, 0); d.setHours(h!, m!, 0, 0);
return d.getTime(); return d.getTime();
} }
const elapsed = computed(() => /**
current.value * 今天的时间点:优先用服务端下发的完整时间表(含课间/分割线/默认隐藏),
? Math.max( * 没有时回退到课表条目,保证旧服务端与离线缓存仍能显示课程。
0, */
Math.min( const dayPoints = computed<IslandTimePointDto[]>(() => {
1, const source = props.timePoints.length
(props.now - timeAt(current.value.startTime)) / ? props.timePoints
Math.max(1, timeAt(current.value.endTime) - timeAt(current.value.startTime)), : today.value.map<IslandTimePointDto>((s) => ({
), id: s.id,
) timeType: 'LESSON',
: 0, name: '',
courseName: s.course?.name ?? null,
startTime: s.startTime,
endTime: s.endTime,
isHideDefault: false,
}));
return [...source].sort((a, b) => a.startTime.localeCompare(b.startTime));
});
const currentPoint = computed(
() =>
dayPoints.value.find(
(point) =>
point.timeType !== 'DIVIDER' &&
point.timeType !== 'ACTION' &&
props.now >= timeAt(point.startTime) &&
props.now < timeAt(point.endTime),
) ?? null,
); );
const currentLesson = computed(() => (currentPoint.value?.timeType === 'LESSON' ? currentPoint.value : null));
/** 课程表组件按 ClassIsland 主界面的折叠/展开规则挑选时间点。 */
const schedulePoints = computed<IslandTimePointDto[]>(() => {
const display = props.node.settings.display;
if (display === 'next')
return dayPoints.value
.filter((point) => point.timeType === 'LESSON' && timeAt(point.startTime) > props.now)
.slice(0, 1);
if (display === 'current') {
if (currentPoint.value) return [currentPoint.value];
return dayPoints.value
.filter((point) => point.timeType === 'LESSON' && timeAt(point.startTime) > props.now)
.slice(0, 1);
}
const visible = dayPoints.value.filter(
(point) =>
point.timeType === 'DIVIDER' ||
point.id === currentPoint.value?.id ||
(point.timeType === 'LESSON' && !point.isHideDefault),
);
const count = Math.max(1, props.node.settings.count);
if (visible.length <= count) return visible;
const index = visible.findIndex((point) => point.id === currentPoint.value?.id);
const start = Math.max(0, Math.min(index - Math.floor(count / 2), visible.length - count));
return visible.slice(start, start + count);
});
const elapsed = computed(() => {
const point = currentLesson.value;
if (!point) return 0;
const start = timeAt(point.startTime);
const end = timeAt(point.endTime);
return Math.max(0, Math.min(1, (props.now - start) / Math.max(1, end - start)));
});
const remaining = computed(() => const remaining = computed(() =>
current.value ? Math.max(0, Math.ceil((timeAt(current.value.endTime) - props.now) / 1000)) : 0, currentLesson.value ? Math.max(0, Math.ceil((timeAt(currentLesson.value.endTime) - props.now) / 1000)) : 0,
); );
const hidden = computed( const hidden = computed(
() => () =>
@@ -62,11 +114,11 @@ const hidden = computed(
(r) => (r) =>
(r === 'in-class' && props.state.inClass) || (r === 'in-class' && props.state.inClass) ||
(r === 'no-message' && !props.state.active) || (r === 'no-message' && !props.state.active) ||
(r === 'no-class' && !current.value), (r === 'no-class' && !currentLesson.value),
)), )),
); );
const visibleChildren = computed(() => const visibleChildren = computed(() =>
props.node.kind === 'slide' props.node.kind === 'slide' && !props.editing
? props.node.children.filter( ? props.node.children.filter(
(_, i) => (_, i) =>
i === i ===
@@ -95,6 +147,18 @@ const content = computed(() => {
.replaceAll('{course}', current.value?.course?.name || '暂无课程') .replaceAll('{course}', current.value?.course?.name || '暂无课程')
: n.settings.text; : n.settings.text;
if (n.kind === 'countdown') { if (n.kind === 'countdown') {
if (n.settings.target === 'date') {
if (!n.settings.targetDate) return '请设置目标日期';
const [year, month, day] = n.settings.targetDate.split('-').map(Number);
const days = Math.round(
(Date.UTC(year!, month! - 1, day!) - Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())) / 86400000,
);
return days > 0
? `距${n.settings.countdownTitle} ${days} 天`
: days === 0
? `${n.settings.countdownTitle}就在今天`
: `${n.settings.countdownTitle}已过 ${-days} 天`;
}
const ending = n.settings.target === 'end' || (n.settings.target === 'auto' && !!current.value); const ending = n.settings.target === 'end' || (n.settings.target === 'auto' && !!current.value);
const time = const time =
n.settings.target === 'school' n.settings.target === 'school'
@@ -111,13 +175,6 @@ const content = computed(() => {
} }
return ''; return '';
}); });
const lessons = computed(() =>
props.node.settings.display === 'today'
? today.value.slice(0, props.node.settings.count)
: [props.node.settings.display === 'next' ? next.value : current.value || next.value].filter(
(s): s is ScheduleDto => Boolean(s),
),
);
const style = computed<CSSProperties>(() => { const style = computed<CSSProperties>(() => {
const n = props.node, const n = props.node,
a = n.appearance, a = n.appearance,
@@ -177,6 +234,7 @@ const style = computed<CSSProperties>(() => {
:state="state" :state="state"
:now="now" :now="now"
:schedules="schedules" :schedules="schedules"
:time-points="timePoints"
:editing="editing" :editing="editing"
:selected-id="selectedId" :selected-id="selectedId"
:alert-node-id="alertNodeId" :alert-node-id="alertNodeId"
@@ -188,27 +246,34 @@ const style = computed<CSSProperties>(() => {
</ComponentNode> </ComponentNode>
</template> </template>
<template v-else-if="node.kind === 'separator'"><span aria-hidden="true">│</span></template> <template v-else-if="node.kind === 'separator'"><span aria-hidden="true">│</span></template>
<WeatherBrief v-else-if="node.kind === 'weather'" :settings="node.settings" />
<template v-else-if="node.kind === 'schedule'"> <template v-else-if="node.kind === 'schedule'">
<div v-for="s in lessons" :key="s.id" class="lesson-line"> <template v-for="p in schedulePoints" :key="p.id">
<strong>{{ s.course?.name }}</strong> <span v-if="p.timeType === 'DIVIDER'" class="time-divider" aria-hidden="true">│</span>
<small v-if="node.settings.showPeriod" class="lesson-time">{{ s.startTime }}–{{ s.endTime }}</small>
<small v-if="node.settings.showProgress && current?.id === s.id" class="lesson-remaining">
距下课 {{ Math.floor(remaining / 60) }}:{{ String(remaining % 60).padStart(2, '0') }}
</small>
</div>
<span v-if="!lessons.length">暂无课程</span>
<div <div
v-if="current && node.settings.showProgress && node.settings.display !== 'next'" v-else
class="lesson-line"
:class="{ current: p.id === currentPoint?.id, break: p.timeType === 'BREAK' }"
>
<strong>{{ p.courseName || p.name || '—' }}</strong>
<small v-if="p.id === currentPoint?.id && node.settings.showPeriod" class="lesson-time">
{{ p.startTime }}–{{ p.endTime }}
</small>
<span
v-if="p.id === currentPoint?.id && p.timeType === 'LESSON' && node.settings.showProgress"
class="lesson-progress" class="lesson-progress"
role="progressbar" role="progressbar"
:aria-valuenow="Math.round(elapsed * 100)" :aria-valuenow="Math.round(elapsed * 100)"
:aria-valuemin="0" aria-valuemin="0"
:aria-valuemax="100" aria-valuemax="100"
:aria-label="`${current.course?.name}课程进度,距下课${remaining}秒`" :aria-label="`${p.courseName || '课程'}课程进度,距下课${remaining}秒`"
> >
<i :style="{ transform: `scaleX(${elapsed})` }" /> <i :style="{ transform: `scaleX(${elapsed})` }" />
</span>
</div> </div>
</template> </template>
<span v-if="!schedulePoints.length">暂无课程</span>
</template>
<template v-else-if="['notification', 'homework', 'call'].includes(node.kind)"> <template v-else-if="['notification', 'homework', 'call'].includes(node.kind)">
<slot v-if="node.id === alertNodeId" name="alerts" /> <slot v-if="node.id === alertNodeId" name="alerts" />
<span v-else-if="editing">{{ ISLAND_COMPONENT_LABELS[node.kind] }}内容组件</span> <span v-else-if="editing">{{ ISLAND_COMPONENT_LABELS[node.kind] }}内容组件</span>
@@ -238,11 +303,13 @@ const style = computed<CSSProperties>(() => {
white-space: nowrap; white-space: nowrap;
} }
.schedule > .lesson-line { .schedule > .lesson-line {
position: relative;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
padding-bottom: 3px;
} }
.lesson-line strong { .lesson-line strong {
min-width: 0; min-width: 0;
@@ -250,6 +317,21 @@ const style = computed<CSSProperties>(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.lesson-line.current strong {
color: #fff;
}
.lesson-line.break {
opacity: 0.86;
}
.time-divider {
display: block;
width: 1px;
height: 18px;
overflow: hidden;
background: currentColor;
font-size: 0;
opacity: 0.25;
}
.lesson-time { .lesson-time {
flex-shrink: 0; flex-shrink: 0;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -257,10 +339,11 @@ const style = computed<CSSProperties>(() => {
} }
.lesson-progress { .lesson-progress {
position: absolute; position: absolute;
inset: auto 4px 0; inset: auto 0 0;
height: 2px; height: 2px;
background: #ffffff24; background: #ffffff24;
overflow: hidden; overflow: hidden;
border-radius: 1px;
} }
.lesson-progress i { .lesson-progress i {
display: block; display: block;
@@ -272,12 +355,6 @@ const style = computed<CSSProperties>(() => {
.schedule > .lesson-line + .lesson-line { .schedule > .lesson-line + .lesson-line {
margin-top: 8px; margin-top: 8px;
} }
.lesson-remaining {
font-size: var(--secondary, 14px);
font-variant-numeric: tabular-nums;
opacity: 0.8;
white-space: nowrap;
}
.separator > span { .separator > span {
display: block; display: block;
width: 1px; width: 1px;
@@ -299,6 +376,9 @@ const style = computed<CSSProperties>(() => {
.rolling > :deep(.component-node) { .rolling > :deep(.component-node) {
animation: rolling var(--rolling-duration) linear infinite; animation: rolling var(--rolling-duration) linear infinite;
} }
.rolling.node-editing > :deep(.component-node) {
animation: none;
}
.selected { .selected {
outline: 2px solid #85aaff; outline: 2px solid #85aaff;
border-radius: 8px; border-radius: 8px;
@@ -6,6 +6,7 @@ import {
type IslandAppearance, type IslandAppearance,
type IslandLayoutSettings, type IslandLayoutSettings,
type IslandState, type IslandState,
type IslandTimePointDto,
type ScheduleDto, type ScheduleDto,
type IslandLessonReminder, type IslandLessonReminder,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -20,6 +21,7 @@ const props = defineProps<{
settings: IslandLayoutSettings; settings: IslandLayoutSettings;
state: IslandState; state: IslandState;
schedules: ScheduleDto[]; schedules: ScheduleDto[];
timePoints: IslandTimePointDto[];
appearance: IslandAppearance; appearance: IslandAppearance;
nativeVisible: boolean; nativeVisible: boolean;
}>(); }>();
@@ -267,10 +269,19 @@ function visibility() {
void nextTick(reportHit); void nextTick(reportHit);
} }
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。 // 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
watch([expanded, scheme, lesson, () => props.state.listExpanded, messages], measure, { watch(
deep: true, [
flush: 'post', expanded,
}); scheme,
lesson,
() => props.state.listExpanded,
messages,
() => props.timePoints,
() => Math.floor(now.value / 60_000),
],
measure,
{ deep: true, flush: 'post' },
);
watch( watch(
[fit, motion, expanded, lesson, () => props.appearance.radius, () => props.appearance.speed], [fit, motion, expanded, lesson, () => props.appearance.radius, () => props.appearance.speed],
() => { () => {
@@ -388,6 +399,7 @@ onUnmounted(() => {
:state="state" :state="state"
:now="now" :now="now"
:schedules="schedules" :schedules="schedules"
:time-points="timePoints"
:editing="false" :editing="false"
selected-id="" selected-id=""
alert-node-id="" alert-node-id=""
@@ -469,6 +481,7 @@ onUnmounted(() => {
:state="state" :state="state"
:now="now" :now="now"
:schedules="schedules" :schedules="schedules"
:time-points="timePoints"
:editing="false" :editing="false"
selected-id="" selected-id=""
alert-node-id="" alert-node-id=""
@@ -5,6 +5,7 @@ import {
DEFAULT_ISLAND_APPEARANCE, DEFAULT_ISLAND_APPEARANCE,
type IslandAppearance, type IslandAppearance,
type IslandState, type IslandState,
type IslandTimePointDto,
type ScheduleDto, type ScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import FixedIsland from './IslandApp.vue'; import FixedIsland from './IslandApp.vue';
@@ -14,6 +15,7 @@ import CustomIslandRuntime from './CustomIslandRuntime.vue';
const settings = ref(defaultIslandLayouts()), const settings = ref(defaultIslandLayouts()),
ready = ref(false); ready = ref(false);
const schedules = ref<ScheduleDto[]>([]); const schedules = ref<ScheduleDto[]>([]);
const timePoints = ref<IslandTimePointDto[]>([]);
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE }); const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const nativeVisible = ref(false); const nativeVisible = ref(false);
const resident = () => settings.value.layout === 'custom'; const resident = () => settings.value.layout === 'custom';
@@ -36,6 +38,7 @@ onMounted(async () => {
if (bridge) { if (bridge) {
bridge.onLayout((value) => (settings.value = value)); bridge.onLayout((value) => (settings.value = value));
bridge.onSchedules((value) => (schedules.value = value)); bridge.onSchedules((value) => (schedules.value = value));
bridge.onTimePoints((value) => (timePoints.value = value));
bridge.onState((value) => (state.value = value)); bridge.onState((value) => (state.value = value));
bridge.onAppearance((value) => (appearance.value = value)); bridge.onAppearance((value) => (appearance.value = value));
bridge.onVisibility((value) => (nativeVisible.value = value)); bridge.onVisibility((value) => (nativeVisible.value = value));
@@ -43,6 +46,7 @@ onMounted(async () => {
appearance.value = await bridge.getAppearance(); appearance.value = await bridge.getAppearance();
settings.value = await bridge.getLayout(); settings.value = await bridge.getLayout();
schedules.value = await bridge.getSchedules(); schedules.value = await bridge.getSchedules();
timePoints.value = await bridge.getTimePoints();
state.value = await bridge.getState(); state.value = await bridge.getState();
} }
await nextTick(); await nextTick();
@@ -55,9 +59,18 @@ onMounted(async () => {
:settings="settings" :settings="settings"
:state="state" :state="state"
:schedules="schedules" :schedules="schedules"
:time-points="timePoints"
:appearance="appearance" :appearance="appearance"
:native-visible="nativeVisible" :native-visible="nativeVisible"
/> />
<FixedIsland v-if="ready" v-show="!resident() && !state.coursewareTransfer" :suspended="resident() || !!state.coursewareTransfer" /> <FixedIsland
<CoursewareTransfer v-if="state.coursewareTransfer" :transfer="state.coursewareTransfer" :appearance="appearance" /> v-if="ready"
v-show="!resident() && !state.coursewareTransfer"
:suspended="resident() || !!state.coursewareTransfer"
/>
<CoursewareTransfer
v-if="state.coursewareTransfer"
:transfer="state.coursewareTransfer"
:appearance="appearance"
/>
</template> </template>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue';
import type { IslandComponentNode, IslandWeather } from '@classhelper/shared';
const props = defineProps<{ settings: IslandComponentNode['settings'] }>();
const weather = ref<IslandWeather | null>(null),
loading = ref(false),
unavailable = ref(false);
const configured = computed(
() => typeof props.settings.latitude === 'number' && typeof props.settings.longitude === 'number',
);
let revision = 0;
async function refresh() {
const current = ++revision;
if (!configured.value) {
loading.value = false;
return;
}
loading.value = true;
try {
const bridge = window.island ?? window.desktop;
if (!bridge) throw new Error('仅桌面客户端可读取天气');
const result = await bridge.getIslandWeather({
latitude: props.settings.latitude!,
longitude: props.settings.longitude!,
});
if (current === revision) {
weather.value = result;
unavailable.value = false;
}
} catch {
if (current === revision) unavailable.value = true;
} finally {
if (current === revision) loading.value = false;
}
}
watch(
() => [props.settings.latitude, props.settings.longitude],
() => {
weather.value = null;
unavailable.value = false;
void refresh();
},
{ immediate: true },
);
const timer = setInterval(() => void refresh(), 60_000);
onUnmounted(() => {
clearInterval(timer);
revision++;
});
const description = computed(() => {
const code = weather.value?.code ?? -1;
if (code === 0) return '晴';
if (code <= 3 && code > 0) return ['少云', '多云', '阴'][code - 1];
if ([45, 48].includes(code)) return '雾';
if ([51, 53, 55, 56, 57].includes(code)) return '毛毛雨';
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code)) return '雨';
if ([71, 73, 75, 77, 85, 86].includes(code)) return '雪';
if ([95, 96, 99].includes(code)) return '雷雨';
return '天气';
});
const advice = computed(() => {
if (!weather.value) return '';
if (weather.value.code >= 95) return '雷雨天气,注意避雷';
if (weather.value.windSpeed >= 40) return '风力较大,注意出行';
return '';
});
</script>
<template>
<span
class="weather-brief"
:title="weather ? `Open-Meteo · ${new Date(weather.fetchedAt).toLocaleString('zh-CN')}\n${advice}` : ''"
>
<template v-if="weather">
{{ settings.locationName }} {{ description }} {{ Math.round(weather.temperature) }}°C
<span v-if="weather.stale || unavailable">· 缓存</span>
<small v-if="advice">{{ advice }}</small>
</template>
<template v-else>
{{ !configured ? '请设置天气地点' : loading ? '天气加载中…' : '天气暂不可用' }}
</template>
</span>
</template>
<style scoped>
.weather-brief {
white-space: nowrap;
}
small {
display: block;
font-size: var(--secondary, 12px);
}
</style>
+40 -29
View File
@@ -3,7 +3,6 @@ import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import fsp from 'node:fs/promises'; import fsp from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { spawn } from 'node:child_process';
import { BrowserWindow, app } from 'electron'; import { BrowserWindow, app } from 'electron';
import { import {
isNewerVersion, isNewerVersion,
@@ -15,6 +14,7 @@ import { getConfig, saveConfig } from './config.js';
import { island } from './island.js'; import { island } from './island.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { connectionEvents } from './config-events.js'; import { connectionEvents } from './config-events.js';
import { prepareUpdateInstaller } from './update-installer.js';
let updateEpoch = 0; let updateEpoch = 0;
let downloadAbort = new AbortController(); let downloadAbort = new AbortController();
@@ -43,15 +43,17 @@ connectionEvents.on('changed', () => {
* 空闲安装要求不在上课且主窗口隐藏满五分钟; * 空闲安装要求不在上课且主窗口隐藏满五分钟;
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行** * 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里); * (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
* 3. **安装脚本脱离本进程**:PowerShell 等旧进程退出、安装器完成后重新拉起应用, * 3. **独立安装助手**:助手就绪后才退出旧进程,由助手执行安装并确认新版启动;
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`, * 助手从更新目录运行,不锁住正在被安装器替换的应用文件。
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
*/ */
let policy: let policy:
| (Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> & { | (Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> & {
serverUrl: string; serverUrl: string;
username: string; username: string;
updateSha256: string;
updateSize: number;
updateFileName: string;
}) })
| null = null; | null = null;
function policyPath(): string { function policyPath(): string {
@@ -107,6 +109,9 @@ export function syncUpdatePolicy(next: ClientHeartbeatResponse): void {
updateTargetVersion: next.updateTargetVersion, updateTargetVersion: next.updateTargetVersion,
updateMode: next.updateMode ?? 'idle', updateMode: next.updateMode ?? 'idle',
updateNotBefore: next.updateNotBefore ?? null, updateNotBefore: next.updateNotBefore ?? null,
updateSha256: next.update?.version === next.updateTargetVersion ? next.update.sha256 : '',
updateSize: next.update?.version === next.updateTargetVersion ? next.update.size : 0,
updateFileName: next.update?.version === next.updateTargetVersion ? next.update.fileName : '',
}; };
fs.mkdirSync(pendingUpdateDir(), { recursive: true }); fs.mkdirSync(pendingUpdateDir(), { recursive: true });
fs.writeFileSync(policyPath(), JSON.stringify(policy), 'utf8'); fs.writeFileSync(policyPath(), JSON.stringify(policy), 'utf8');
@@ -458,6 +463,14 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
} }
const active = loadPolicy(); const active = loadPolicy();
if (!active || active.updateTargetVersion !== version) return; if (!active || active.updateTargetVersion !== version) return;
if (
!/^[0-9a-f]{64}$/i.test(active.updateSha256 ?? '') ||
active.updateSize <= 0 ||
active.updateFileName !== path.basename(installer)
) {
logger.warn(`更新 ${version} 尚无有效的服务端摘要,等待心跳重新确认安装包`);
return;
}
if (active.updateMode === 'after-school') { if (active.updateMode === 'after-school') {
const at = Date.parse(active.updateNotBefore ?? ''); const at = Date.parse(active.updateNotBefore ?? '');
if (!Number.isFinite(at) || Date.now() < at) return; if (!Number.isFinite(at) || Date.now() < at) return;
@@ -520,33 +533,29 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
logger.info(`开始静默安装更新 ${version}:${installer}`); logger.info(`开始静默安装更新 ${version}:${installer}`);
try { try {
const exe = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe'); const exe = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe');
const quote = (value: string): string => "'" + value.replaceAll("'", "''") + "'";
const config = getConfig(); const config = getConfig();
const resultFile = path.join(pendingUpdateDir(), 'install-result.json'); const helper = await prepareUpdateInstaller({
// 上一次失败的结果不能冒充这一次:安装开始时先清掉,只有本轮失败才重新写入。 directory: pendingUpdateDir(),
fs.rmSync(resultFile, { force: true }); installer,
const script = version,
`$ErrorActionPreference = 'Stop'; try { Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` + sha256: active.updateSha256,
`$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -WindowStyle Hidden -Wait -PassThru; ` + size: active.updateSize,
`if ($installer.ExitCode -ne 0) { throw ('安装器退出码:' + $installer.ExitCode) } ` + serverUrl: config.serverUrl,
`} catch { @{ version=${quote(version)}; serverUrl=${quote(config.serverUrl)}; username=${quote(config.username)}; error=$_.Exception.Message } | ConvertTo-Json -Compress | Set-Content -LiteralPath ${quote(resultFile)} -Encoding UTF8; ` + username: config.username,
`if (Test-Path -LiteralPath ${quote(exe)}) { Start-Process -FilePath ${quote(exe)} -WindowStyle Hidden } }`; restartExe: exe,
const encoded = Buffer.from(script, 'utf16le').toString('base64'); portable: Boolean(process.env.PORTABLE_EXECUTABLE_FILE),
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', encoded],
{
detached: true,
stdio: 'ignore',
windowsHide: true,
},
);
await new Promise<void>((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
}); });
child.unref(); if (
// 安装器从实际安装目录启动新版;免安装版的旧 exe 可能随退出消失,只在安装失败时尝试恢复它。 installEpoch !== updateEpoch ||
loadPolicy()?.updateTargetVersion !== version ||
getConfig().autoUpdate === false
) {
helper.cancel();
installing = false;
return;
}
helper.commit();
logger.info(`更新 ${version} 的独立安装助手已就绪,确认退出旧客户端`);
if (quitHandler) { if (quitHandler) {
quitHandler(); quitHandler();
} else { } else {
@@ -555,6 +564,8 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
} }
} catch (error) { } catch (error) {
installing = false; installing = false;
if (installEpoch !== updateEpoch) return;
installationRetryAt = Date.now() + 5 * 60_000;
publishProgress({ stage: 'failed', error: String(error).slice(0, 1000) }); publishProgress({ stage: 'failed', error: String(error).slice(0, 1000) });
logger.error( logger.error(
`启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`, `启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`,
+2
View File
@@ -6,6 +6,7 @@ import {
listLocalCourseware, listLocalCourseware,
} from './courseware.js'; } from './courseware.js';
import { island } from './island.js'; import { island } from './island.js';
import { getIslandWeather } from './island-weather.js';
import { getUpdateProgress } from './auto-update.js'; import { getUpdateProgress } from './auto-update.js';
import { app, BrowserWindow, ipcMain, shell } from 'electron'; import { app, BrowserWindow, ipcMain, shell } from 'electron';
import type { UpdateInfo } from '@classhelper/shared'; import type { UpdateInfo } from '@classhelper/shared';
@@ -21,6 +22,7 @@ import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
export { IPC_CHANNELS }; export { IPC_CHANNELS };
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
ipcMain.handle('island:weather', (_event, coordinates: unknown) => getIslandWeather(coordinates));
ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id)); ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id));
ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id)); ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id));
ipcMain.handle('courseware:local-list', () => listLocalCourseware()); ipcMain.handle('courseware:local-list', () => listLocalCourseware());
@@ -0,0 +1,73 @@
import type { IslandWeather } from '@classhelper/shared';
const cache = new Map<string, { data: IslandWeather | null; retryAt: number }>();
const pending = new Map<string, Promise<IslandWeather>>();
/** 固定天气服务与数值坐标,不接受渲染层传入 URL。失败退避并保留本次运行的缓存。 */
export async function getIslandWeather(
input: unknown,
request: typeof fetch = fetch,
): Promise<IslandWeather> {
const coordinates = input as { latitude?: unknown; longitude?: unknown } | null;
const latitude = coordinates?.latitude,
longitude = coordinates?.longitude;
if (
typeof latitude !== 'number' ||
!Number.isFinite(latitude) ||
Math.abs(latitude) > 90 ||
typeof longitude !== 'number' ||
!Number.isFinite(longitude) ||
Math.abs(longitude) > 180
)
throw new Error('请设置有效的经纬度');
const key = `${latitude.toFixed(4)},${longitude.toFixed(4)}`;
const stored = cache.get(key);
if (stored && stored.retryAt > Date.now()) {
if (stored.data) return { ...stored.data, stale: Date.now() - stored.data.fetchedAt >= 15 * 60_000 };
throw new Error('天气暂不可用,稍后自动重试');
}
const running = pending.get(key);
if (running) return running;
const job = Promise.resolve().then(async () => {
try {
if (process.env.ELECTRON_SMOKE_TEST === '1' && request === fetch)
throw new Error('自动验收不访问外部天气服务');
const url = new URL('https://api.open-meteo.com/v1/forecast');
url.search = new URLSearchParams({
latitude: String(latitude),
longitude: String(longitude),
current: 'temperature_2m,weather_code,wind_speed_10m',
forecast_days: '1',
}).toString();
const response = await request(url, { signal: AbortSignal.timeout(8000), redirect: 'error' });
if (!response.ok) throw new Error('天气服务暂不可用');
const body = await response.text();
if (body.length > 64_000) throw new Error('天气响应过大');
const current = JSON.parse(body).current;
if (
![current?.temperature_2m, current?.weather_code, current?.wind_speed_10m].every(
(value) => typeof value === 'number' && Number.isFinite(value),
)
)
throw new Error('天气数据格式不正确');
const data: IslandWeather = {
temperature: current.temperature_2m,
code: current.weather_code,
windSpeed: current.wind_speed_10m,
fetchedAt: Date.now(),
stale: false,
};
cache.set(key, { data, retryAt: Date.now() + 15 * 60_000 });
return data;
} catch {
cache.set(key, { data: stored?.data ?? null, retryAt: Date.now() + 60_000 });
if (stored?.data) return { ...stored.data, stale: true };
throw new Error('天气暂不可用,请检查网络或坐标');
} finally {
pending.delete(key);
while (cache.size > 32) cache.delete(cache.keys().next().value!);
}
});
pending.set(key, job);
return job;
}
+25 -7
View File
@@ -1,4 +1,7 @@
import type { ScheduleDto as ContractScheduleDto } from '@classhelper/shared'; import type {
IslandTimePointDto as ContractIslandTimePointDto,
ScheduleDto as ContractScheduleDto,
} from '@classhelper/shared';
import path from 'node:path'; import path from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { BrowserWindow, ipcMain, screen } from 'electron'; import { BrowserWindow, ipcMain, screen } from 'electron';
@@ -138,6 +141,7 @@ export interface IslandPushContext {
export interface IslandClassStatePayload { export interface IslandClassStatePayload {
schedules?: ContractScheduleDto[]; schedules?: ContractScheduleDto[];
timePoints?: ContractIslandTimePointDto[];
inClass: boolean; inClass: boolean;
currentPeriodEnd?: string | null; currentPeriodEnd?: string | null;
week?: number; week?: number;
@@ -364,14 +368,15 @@ class IslandController {
if (this.customTimer) clearTimeout(this.customTimer); if (this.customTimer) clearTimeout(this.customTimer);
this.customTimer = null; this.customTimer = null;
// 常驻岛只报告目标几何;窗口画布固定,禁止逐帧 setBounds 和两端尺寸防抖互相追赶。 // 常驻岛只报告目标几何;窗口画布固定,禁止逐帧 setBounds 和两端尺寸防抖互相追赶。
if ( const width = Math.ceil(value.width);
this.customSize.width !== Math.ceil(value.width) || const height = Math.ceil(value.height);
this.customSize.height !== Math.ceil(value.height) const changed = this.customSize.width !== width || this.customSize.height !== height;
) if (changed)
this.residentMotionUntil = this.residentMotionUntil =
Date.now() + (this.appearance.animations ? 600 / this.appearance.speed : 0); Date.now() + (this.appearance.animations ? 600 / this.appearance.speed : 0);
this.customSize = { width: Math.ceil(value.width), height: Math.ceil(value.height) }; this.customSize = { width, height };
this.syncResidentShape(true); // 时钟每分钟触发的重复测量尺寸没变,不要清空触摸形状(那 750ms 会漏掉点击)。
if (changed || !this.residentShaped) this.syncResidentShape(true);
return; return;
} }
if (this.customTimer) clearTimeout(this.customTimer); if (this.customTimer) clearTimeout(this.customTimer);
@@ -618,6 +623,7 @@ class IslandController {
// 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值 // 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值
this.emitAppearance(); this.emitAppearance();
this.win?.webContents.send('island:schedules', this.schedules); this.win?.webContents.send('island:schedules', this.schedules);
this.win?.webContents.send('island:time-points', this.timePoints);
this.emit(); this.emit();
if (this.residentEnabled) { if (this.residentEnabled) {
this.residentWasEnabled = true; this.residentWasEnabled = true;
@@ -634,6 +640,10 @@ class IslandController {
getSchedules(): ContractScheduleDto[] { getSchedules(): ContractScheduleDto[] {
return [...this.schedules]; return [...this.schedules];
} }
private timePoints: ContractIslandTimePointDto[] = [];
getTimePoints(): ContractIslandTimePointDto[] {
return [...this.timePoints];
}
getState(): IslandState { getState(): IslandState {
return { return {
@@ -860,6 +870,11 @@ class IslandController {
if (this.ready && this.win && !this.win.isDestroyed()) if (this.ready && this.win && !this.win.isDestroyed())
this.win.webContents.send('island:schedules', this.schedules); this.win.webContents.send('island:schedules', this.schedules);
} }
if (Array.isArray(payload.timePoints)) {
this.timePoints = payload.timePoints.slice(0, 100);
if (this.ready && this.win && !this.win.isDestroyed())
this.win.webContents.send('island:time-points', this.timePoints);
}
const wasInClass = this.state.inClass; const wasInClass = this.state.inClass;
this.state.inClass = payload.inClass; this.state.inClass = payload.inClass;
this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null; this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null;
@@ -2320,6 +2335,9 @@ export function registerIslandIpc(): void {
ipcMain.handle('island:get-schedules', (event) => ipcMain.handle('island:get-schedules', (event) =>
event.sender === island.getWindow()?.webContents ? island.getSchedules() : [], event.sender === island.getWindow()?.webContents ? island.getSchedules() : [],
); );
ipcMain.handle('island:get-time-points', (event) =>
event.sender === island.getWindow()?.webContents ? island.getTimePoints() : [],
);
ipcMain.handle('island:get-layout', () => getConfig().islandLayout); ipcMain.handle('island:get-layout', () => getConfig().islandLayout);
ipcMain.handle('island:save-layout', (_event, value: unknown) => { ipcMain.handle('island:save-layout', (_event, value: unknown) => {
const result = saveConfig({ islandLayout: normalizeIslandLayouts(value) }).islandLayout; const result = saveConfig({ islandLayout: normalizeIslandLayouts(value) }).islandLayout;
@@ -3,7 +3,13 @@ import { BrowserWindow } from 'electron';
import path from 'node:path'; import path from 'node:path';
import { island } from './island.js'; import { island } from './island.js';
import { getConfig, saveConfig } from './config.js'; import { getConfig, saveConfig } from './config.js';
import { defaultIslandLayouts, ISLAND_SHADOW_PAD } from '@classhelper/shared'; import {
defaultIslandLayouts,
islandNode,
normalizeIslandLayouts,
ISLAND_SHADOW_PAD,
} from '@classhelper/shared';
import { getIslandWeather } from './island-weather.js';
import { verifyResidentSmoke } from './smoke-resident.js'; import { verifyResidentSmoke } from './smoke-resident.js';
export async function verifyLayoutSmoke( export async function verifyLayoutSmoke(
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>, check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
@@ -38,12 +44,30 @@ export async function verifyLayoutSmoke(
editingBounds = win.getBounds(); editingBounds = win.getBounds();
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(true)'); await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(true)');
await pause(); await pause();
if (process.env.ISLAND_SHOTS_DIR) {
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
const bounds = editor.getContentBounds();
editor.setContentSize(1600, 1000);
for (const theme of ['light', 'dark']) {
await editor.webContents.executeJavaScript(
`document.documentElement.classList.toggle('dark', ${theme === 'dark'})`,
);
await pause();
fs.writeFileSync(
path.join(process.env.ISLAND_SHOTS_DIR, `component-editor-${theme}.png`),
(await editor.webContents.capturePage()).toPNG(),
);
}
await editor.webContents.executeJavaScript(`document.documentElement.classList.remove('dark')`);
editor.setContentSize(bounds.width, bounds.height);
await pause();
}
return ( return (
(await editor.webContents.executeJavaScript( (await editor.webContents.executeJavaScript(
'location.hash.includes("/settings/island/editor") && Boolean(document.querySelector(".aside"))', 'location.hash.includes("/settings/island/editor") && Boolean(document.querySelector(".aside"))',
)) && )) &&
editor.webContents.executeJavaScript( editor.webContents.executeJavaScript(
`(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='新增行');b?.click();return Boolean(document.querySelector('.island-editor-page .el-card')) && !document.querySelector('.layout-editor, iframe'); })()`, `(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='新增行');b?.click();return document.querySelectorAll('.component-library > .component-options:first-of-type .library-item').length===11 && !document.querySelector('.layout-editor, iframe'); })()`,
) )
); );
}); });
@@ -66,12 +90,16 @@ export async function verifyLayoutSmoke(
return true; return true;
}); });
await check('组件可以包裹进容器且设置独立保存', async () => { await check('组件可以包裹进容器且设置独立保存', async () => {
await editor.webContents.executeJavaScript(
`document.querySelector('.node-handle[data-kind=clock]')?.click()`,
);
await pause();
await editor.webContents.executeJavaScript( await editor.webContents.executeJavaScript(
`document.querySelector('[data-action=wrap] .el-select__wrapper')?.click()`, `document.querySelector('[data-action=wrap] .el-select__wrapper')?.click()`,
); );
await pause(); await pause();
await editor.webContents.executeJavaScript( await editor.webContents.executeJavaScript(
`(() => { const option=[...document.querySelectorAll('.el-select-dropdown__item')].find(e=>e.textContent.trim()==='分组'); if(!option)throw Error('容器菜单未出现');option.click(); })()`, `(() => { const option=[...document.querySelectorAll('.el-select-dropdown__item')].find(e=>e.textContent.trim()==='分组容器'); if(!option)throw Error('容器菜单未出现');option.click(); })()`,
); );
await pause(); await pause();
return ( return (
@@ -108,6 +136,10 @@ export async function verifyLayoutSmoke(
return initial && appearance && advanced; return initial && appearance && advanced;
}); });
await check('主窗口编辑器的预览完整适配画布且不遮挡缩放控件', async () => { await check('主窗口编辑器的预览完整适配画布且不遮挡缩放控件', async () => {
await editor.webContents.executeJavaScript(
`document.querySelector('.el-drawer__close-btn')?.click();document.querySelector('.surface-actions button:last-child')?.click()`,
);
await pause();
return editor.webContents.executeJavaScript(`(() => { return editor.webContents.executeJavaScript(`(() => {
const canvas=document.querySelector('.preview-canvas')?.getBoundingClientRect(); const canvas=document.querySelector('.preview-canvas')?.getBoundingClientRect();
const footer=document.querySelector('.preview-toolbar')?.getBoundingClientRect(); const footer=document.querySelector('.preview-toolbar')?.getBoundingClientRect();
@@ -118,11 +150,190 @@ export async function verifyLayoutSmoke(
return editor.webContents.executeJavaScript(`(() => { return editor.webContents.executeJavaScript(`(() => {
const root=document.documentElement, wasDark=root.classList.contains('dark'); const root=document.documentElement, wasDark=root.classList.contains('dark');
root.classList.add('dark'); root.classList.add('dark');
const card=document.querySelector('.scheme-card'), style=getComputedStyle(card); const page=document.querySelector('.island-editor-page'), style=getComputedStyle(page);
const ok=style.getPropertyValue('--ch-text').trim() && !document.querySelector('.layout-editor, iframe') && document.querySelectorAll('.island-editor-page .el-card').length>=4; const ok=style.getPropertyValue('--ch-text').trim() && !document.querySelector('.layout-editor, iframe') && document.querySelector('.component-library .library-title') && document.querySelector('.scheme-controls');
root.classList.toggle('dark',wasDark);return Boolean(ok); root.classList.toggle('dark',wasDark);return Boolean(ok);
})()`); })()`);
}); });
await check('从常驻组件库拖入容器,阻止将父组件放进自己的后代', async () => {
await editor.webContents.executeJavaScript(
`document.querySelector('.surface-actions button:last-child')?.click()`,
);
await pause();
await editor.webContents.executeJavaScript(`(() => {
const source=document.querySelector('[data-library-kind=text]'), target=document.querySelector('.editor-component.container');
if(!source||!target)throw Error('缺少组件库或容器');
const r=target.getBoundingClientRect(), x=r.left+r.width/2, y=r.top+r.height/2;
source.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:21,clientX:10,clientY:10}));
source.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:21,clientX:x,clientY:y}));
source.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:21,clientX:x,clientY:y}));
})()`);
await pause();
const group = getConfig()
.islandLayout.schemes[0]!.rows.flatMap((r) => r.nodes)
.find((n) => n.kind === 'group');
if (!group?.children.some((n) => n.kind === 'text')) throw Error('组件库内容没有进入容器');
const before = JSON.stringify(getConfig().islandLayout);
await editor.webContents.executeJavaScript(`(() => {
const source=document.querySelector('.editor-component.container > .node-handle'), target=document.querySelector('.editor-component.container .container-children .node-handle');
const r=target.getBoundingClientRect(), x=r.left+r.width/2, y=r.top+r.height/2;
source.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:22,clientX:10,clientY:10}));
source.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:22,clientX:x,clientY:y}));
})()`);
await pause();
return before === JSON.stringify(getConfig().islandLayout);
});
await check('常态与无通知展开内容分别保存,日期倒计时可配置并预览', async () => {
const before = JSON.stringify(getConfig().islandLayout.schemes[0]!.rows);
await editor.webContents.executeJavaScript(
`document.querySelectorAll('.surface-tabs button')[1].click();document.querySelector('[data-library-kind=countdown]').click()`,
);
await pause();
await editor.webContents.executeJavaScript(`(() => {
const set=(input,value)=>{input.value=value;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));};
const field=[...document.querySelectorAll('.component-settings .el-form-item')].find(e=>e.textContent.includes('目标名称'));
set(field.querySelector('input'),'运动会');
document.querySelector('.component-settings .el-date-editor input').focus();
})()`);
await pause();
await editor.webContents.executeJavaScript(`(() => {
const picker=document.querySelector('.component-settings .el-date-editor input');
picker.value='2099-10-25';picker.dispatchEvent(new Event('input',{bubbles:true}));
})()`);
await pause();
await editor.webContents.executeJavaScript(`(() => {
const picker=document.querySelector('.component-settings .el-date-editor input');
picker.dispatchEvent(new Event('change',{bubbles:true}));picker.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true}));picker.blur();
})()`);
await pause();
const added = getConfig()
.islandLayout.schemes[0]!.expandedRows.flatMap((r) => r.nodes)
.find((n) => n.kind === 'countdown');
if (added?.settings.targetDate !== '2099-10-25' || added.settings.countdownTitle !== '运动会')
throw Error(JSON.stringify(added?.settings));
await editor.webContents.executeJavaScript(
`document.querySelector('.el-drawer__close-btn')?.click();document.querySelector('.surface-actions button:last-child')?.click()`,
);
await pause();
const preview = await editor.webContents.executeJavaScript(
`document.querySelector('.island-preview .countdown')?.textContent.includes('距运动会')`,
);
await editor.webContents.executeJavaScript(
`document.querySelectorAll('.surface-tabs button')[0].click()`,
);
await pause();
return preview && JSON.stringify(getConfig().islandLayout.schemes[0]!.rows) === before;
});
await check('天气坐标校验、并发合并与缓存使用 mock,不访问外网', async () => {
let calls = 0;
const request: typeof fetch = async (input) => {
calls++;
const url = new URL(String(input));
if (url.origin !== 'https://api.open-meteo.com' || url.searchParams.get('latitude') !== '12.34')
throw Error('请求边界错误');
return new Response(
JSON.stringify({ current: { temperature_2m: 23.4, weather_code: 2, wind_speed_10m: 8 } }),
);
};
const results = await Promise.all([
getIslandWeather({ latitude: 12.34, longitude: 56.78 }, request),
getIslandWeather({ latitude: 12.34, longitude: 56.78 }, request),
]);
await getIslandWeather({ latitude: 12.34, longitude: 56.78 }, request);
let invalid = false;
try {
await getIslandWeather({ latitude: 100, longitude: 0 }, request);
} catch {
invalid = true;
}
const weather = islandNode('weather', 'weather-test'),
countdown = islandNode('countdown', 'countdown-test');
weather.settings.latitude = NaN;
countdown.settings.targetDate = '2026-02-31';
const config = defaultIslandLayouts();
config.schemes[0]!.rows[0]!.nodes = [weather, countdown];
const normalized = normalizeIslandLayouts(config).schemes[0]!.rows[0]!.nodes;
return (
calls === 1 &&
results.every((r) => r.temperature === 23.4 && !r.stale) &&
invalid &&
normalized[0]!.settings.latitude === null &&
normalized[1]!.settings.targetDate === ''
);
});
await check('右上角新建、重置和删除方案均可保存并恢复原方案', async () => {
await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="新建方案"]').click()`);
await pause();
await editor.webContents.executeJavaScript(
`(() => { const input=document.querySelector('.el-message-box__input input'); input.value='组件方案验收';input.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('.el-message-box__btns .el-button--primary').click(); })()`,
);
await pause();
if (getConfig().islandLayout.schemes.length !== 2) throw Error('新建方案没有保存');
await editor.webContents.executeJavaScript(
`document.querySelector('[aria-label="重置当前方案"]').click()`,
);
await pause();
await editor.webContents.executeJavaScript(
`document.querySelector('.el-message-box__btns .el-button--primary').click()`,
);
await pause();
await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="管理方案"]').click()`);
await pause();
await editor.webContents.executeJavaScript(
`[...document.querySelectorAll('.el-dropdown-menu__item')].find(e=>e.textContent.trim()==='删除方案').click()`,
);
await pause();
await editor.webContents.executeJavaScript(
`document.querySelector('.el-message-box__btns .el-button--primary').click()`,
);
await pause();
return (
getConfig().islandLayout.schemes.length === 1 &&
getConfig().islandLayout.activeSchemeId === 'default' &&
getConfig().islandLayout.schemes[0]!.rows.length === 2
);
});
await check('方案 JSON 导入导出保留容器结构并重新生成标识', async () => {
await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="管理方案"]').click()`);
await pause();
const exported = await editor.webContents.executeJavaScript(`(async () => {
let blob; const originalURL=URL.createObjectURL, originalClick=HTMLAnchorElement.prototype.click;
try {
URL.createObjectURL=value=>{blob=value;return 'blob:editor-smoke';};HTMLAnchorElement.prototype.click=()=>{};
[...document.querySelectorAll('.el-dropdown-menu__item')].find(e=>e.textContent.trim()==='导出方案').click();
} finally { URL.createObjectURL=originalURL;HTMLAnchorElement.prototype.click=originalClick; }
const text=await blob.text(), parsed=JSON.parse(text);
const input=document.querySelector('.island-editor-page input[type=file]'), transfer=new DataTransfer();
transfer.items.add(new File([text],'组件方案.json',{type:'application/json'}));input.files=transfer.files;
input.dispatchEvent(new Event('change',{bubbles:true}));return parsed;
})()`);
await pause();
const layouts = getConfig().islandLayout;
const imported = layouts.schemes.find((s) => s.id === layouts.activeSchemeId)!;
const shape = (nodes: ReturnType<typeof islandNode>[]): string =>
JSON.stringify(
nodes.map((n) => ({ kind: n.kind, children: shape(n.children), settings: n.settings })),
);
const preserved =
exported.format === 'classhelper-island-layout' &&
imported.id !== exported.scheme.id &&
imported.rows.every(
(row, index) =>
row.id !== exported.scheme.rows[index].id &&
shape(row.nodes) === shape(exported.scheme.rows[index].nodes),
);
await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="管理方案"]').click()`);
await pause();
await editor.webContents.executeJavaScript(
`[...document.querySelectorAll('.el-dropdown-menu__item')].find(e=>e.textContent.trim()==='删除方案').click()`,
);
await pause();
await editor.webContents.executeJavaScript(
`document.querySelector('.el-message-box__btns .el-button--primary').click()`,
);
await pause();
return preserved && getConfig().islandLayout.schemes.length === 1;
});
if (process.env.ISLAND_SHOTS_DIR) { if (process.env.ISLAND_SHOTS_DIR) {
await editor.webContents.executeJavaScript(`document.querySelector('.main').scrollTop=0`); await editor.webContents.executeJavaScript(`document.querySelector('.main').scrollTop=0`);
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true }); fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
@@ -5,6 +5,7 @@ import {
islandNode, islandNode,
normalizeIslandLayouts, normalizeIslandLayouts,
ISLAND_SHADOW_PAD, ISLAND_SHADOW_PAD,
type IslandTimePointDto,
type ScheduleDto, type ScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js'; import { getConfig, saveConfig } from './config.js';
@@ -607,7 +608,9 @@ export async function verifyResidentSmoke(
await pause(1100); await pause(1100);
const half = await win.webContents.executeJavaScript(`(() => { const half = await win.webContents.executeJavaScript(`(() => {
const root=document.querySelector('${layout === 'custom' ? '.resident-compact' : '.ci-fixed-lesson'}'); const root=document.querySelector('${layout === 'custom' ? '.resident-compact' : '.ci-fixed-lesson'}');
return Boolean(root&&root.textContent.includes('数学')&&root.textContent.includes('距下课')&&Number(root.querySelector('[role=progressbar]')?.getAttribute('aria-valuenow'))===50); const text=root?.textContent??'';
const progress=Number(root?.querySelector('[role=progressbar]')?.getAttribute('aria-valuenow'));
return Boolean(root&&text.includes('数学')&&progress===50&&(${layout === 'custom' ? "text.includes('09:00–09:45')&&!text.includes('距下课')" : "text.includes('距下课')"}));
})()`); })()`);
if (!half) if (!half)
throw new Error( throw new Error(
@@ -679,6 +682,87 @@ export async function verifyResidentSmoke(
await pause(800); await pause(800);
} }
}); });
await check('自定义岛课程表按时间点折叠:课间折叠隐藏、当前项展开显示时间段', async () => {
const scheduleLayout = JSON.parse(JSON.stringify(fixture));
const node = islandNode('schedule', 'time-point-schedule');
node.settings.display = 'today';
node.settings.count = 10;
scheduleLayout.schemes[0].rows[0].nodes = [node];
saveConfig({ islandLayout: { ...scheduleLayout, layout: 'custom' } });
island.syncCustomLayout();
const timePoints: IslandTimePointDto[] = [
{
id: 'p-math',
timeType: 'LESSON',
name: '第 1 节',
courseName: '数学',
startTime: '09:00',
endTime: '09:45',
isHideDefault: false,
},
{
id: 'p-break',
timeType: 'BREAK',
name: '课间休息',
courseName: null,
startTime: '09:45',
endTime: '09:55',
isHideDefault: false,
},
{
id: 'p-chinese',
timeType: 'LESSON',
name: '第 2 节',
courseName: '语文',
startTime: '09:55',
endTime: '10:40',
isHideDefault: false,
},
];
const setTime = async (hour: number, minute: number) => {
const at = dateAt(hour, minute).getTime();
await win.webContents.executeJavaScript(`window.__lessonSmokeTime=${at}`);
island.setLessonReminderTestTime(at);
};
try {
await win.webContents.executeJavaScript(
`window.__lessonRealNow=Date.now;Date.now=()=>window.__lessonSmokeTime??window.__lessonRealNow();true`,
);
island.setClassState({ inClass: false, schedules: lessonFixtures, timePoints });
await setTime(9, 20);
await pause(900);
const duringClass = await win.webContents.executeJavaScript(`(() => {
const text=document.querySelector('.resident-compact')?.textContent??'';
return { math: text.includes('数学'), chinese: text.includes('语文'), rest: text.includes('课间休息'), period: text.includes('09:00–09:45'), otherPeriod: text.includes('09:55–10:40') };
})()`);
await setTime(9, 50);
await pause(900);
const duringBreak = await win.webContents.executeJavaScript(`(() => {
const text=document.querySelector('.resident-compact')?.textContent??'';
return { math: text.includes('数学'), chinese: text.includes('语文'), rest: text.includes('课间休息'), period: text.includes('09:45–09:55') };
})()`);
if (!(
duringClass.math &&
duringClass.chinese &&
!duringClass.rest &&
duringClass.period &&
!duringClass.otherPeriod
))
throw new Error('课中时间点折叠错误:' + JSON.stringify(duringClass));
if (!(duringBreak.math && duringBreak.chinese && duringBreak.rest && duringBreak.period))
throw new Error('课间展开错误:' + JSON.stringify(duringBreak));
return true;
} finally {
await win.webContents.executeJavaScript(
`Date.now=window.__lessonRealNow;delete window.__lessonRealNow;delete window.__lessonSmokeTime`,
);
island.setLessonReminderTestTime(null);
island.setClassState({ inClass: false, schedules: [], timePoints: [] });
saveConfig({ islandLayout: fixture });
island.syncCustomLayout();
await pause(800);
}
});
await check('长文本多行与2.5倍字号自动适配,变窄窗口和底部右侧停靠均不溢出', async () => { await check('长文本多行与2.5倍字号自动适配,变窄窗口和底部右侧停靠均不溢出', async () => {
const appearance = island.getAppearance(); const appearance = island.getAppearance();
const dense = JSON.parse(JSON.stringify(fixture)); const dense = JSON.parse(JSON.stringify(fixture));
@@ -743,15 +827,26 @@ export async function verifyResidentSmoke(
island.handleAction({ action: 'expand' }); island.handleAction({ action: 'expand' });
await pause(1000); await pause(1000);
island.handleAction({ action: 'collapse' }); island.handleAction({ action: 'collapse' });
await pause(1000); // 收起后渲染进程要重新量宽并走 750ms 的触摸形状延时;尺寸稳定前不要断言。
const hit = island.getHitRect(); let settled = false;
return Boolean( const deadline = Date.now() + 3500;
hit && while (Date.now() < deadline) {
await pause(100);
const current = island.getHitRect();
if (
current &&
shape.length === 1 && shape.length === 1 &&
shape[0]!.width <= hit.width + 18 && shape[0]!.width <= current.width + 18 &&
shape[0]!.height <= hit.height + 18 && shape[0]!.height <= current.height + 18
JSON.stringify(box) === JSON.stringify(win.getBounds()), ) {
); settled = true;
break;
}
}
const hit = island.getHitRect();
const ok = Boolean(settled && hit && JSON.stringify(box) === JSON.stringify(win.getBounds()));
if (!ok) throw new Error(JSON.stringify({ hit, shape, bounds: win.getBounds(), box, settled }));
return true;
} finally { } finally {
win.setShape = original; win.setShape = original;
island.setTouchMode(false); island.setTouchMode(false);
@@ -0,0 +1,105 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
interface InstallerOptions {
directory: string;
installer: string;
version: string;
sha256: string;
size: number;
serverUrl: string;
username: string;
restartExe: string;
portable: boolean;
}
/** 先确认独立助手就绪;调用方复核策略后 commit,再退出应用。 */
export async function prepareUpdateInstaller(options: InstallerOptions) {
const attempt = crypto.randomUUID();
const prefix = path.join(options.directory, `install-${attempt}`);
const helper = path.join(options.directory, `updater-${attempt}.exe`);
const resultFile = path.join(options.directory, 'install-result.json');
const logFile = path.join(options.directory, 'install-helper.log');
fs.mkdirSync(options.directory, { recursive: true });
for (const name of fs.readdirSync(options.directory)) {
if (/^updater-[0-9a-f-]{36}\.exe$/i.test(name)) {
try {
fs.rmSync(path.join(options.directory, name));
} catch {
/* 仍在运行的助手留待下次清理 */
}
}
}
fs.copyFileSync(path.join(__dirname, 'update-helper.exe'), helper);
fs.writeFileSync(
`${prefix}.json`,
JSON.stringify({
Attempt: attempt,
ParentId: process.pid,
ParentExe: process.execPath,
Installer: options.installer,
RestartExe: options.restartExe,
Version: options.version,
ServerUrl: options.serverUrl,
Username: options.username,
Sha256: options.sha256,
Size: options.size,
Portable: options.portable,
}),
'utf8',
);
fs.rmSync(resultFile, { force: true });
fs.writeFileSync(logFile, `${new Date().toISOString()} 准备安装 ${options.version}\n`, 'utf8');
let child: ReturnType<typeof spawn>;
try {
child = spawn(helper, [`${prefix}.json`], {
detached: true,
stdio: 'ignore',
windowsHide: true,
cwd: options.directory,
});
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => finish(new Error('更新助手就绪超时,请查看 install-helper.log')),
15_000,
);
const poll = setInterval(() => {
try {
if (fs.readFileSync(`${prefix}.ready`, 'utf8').trim() === attempt) finish();
} catch {
/* 尚未就绪 */
}
}, 50);
const onError = (error: Error) => finish(error);
const onExit = (code: number | null) => {
let detail = '';
try {
detail = JSON.parse(fs.readFileSync(resultFile, 'utf8')).error ?? '';
} catch {
/* 原生进程未能启动 */
}
finish(new Error(detail || `更新助手提前退出(${code ?? '未知退出码'}),请查看 install-helper.log`));
};
function finish(error?: Error) {
clearTimeout(timeout);
clearInterval(poll);
child.removeListener('error', onError);
child.removeListener('exit', onExit);
if (error) reject(error);
else resolve();
}
child.once('error', onError);
child.once('exit', onExit);
});
child.unref();
} catch (error) {
fs.writeFileSync(`${prefix}.cancel`, attempt, 'utf8');
throw error;
}
return {
commit: () => fs.writeFileSync(`${prefix}.start`, attempt, 'utf8'),
cancel: () => fs.writeFileSync(`${prefix}.cancel`, attempt, 'utf8'),
};
}
@@ -23,6 +23,7 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。 * 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
*/ */
const bridge: DesktopBridge = { const bridge: DesktopBridge = {
getIslandWeather: (coordinates) => ipcRenderer.invoke('island:weather', coordinates),
downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id), downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id),
revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id), revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id),
listLocalCourseware: () => ipcRenderer.invoke('courseware:local-list'), listLocalCourseware: () => ipcRenderer.invoke('courseware:local-list'),
@@ -1,5 +1,7 @@
import type { import type {
IslandLayoutSettings as ContractIslandLayoutSettings, IslandLayoutSettings as ContractIslandLayoutSettings,
IslandTimePointDto as ContractIslandTimePointDto,
IslandWeather,
ScheduleDto as ContractScheduleDto, ScheduleDto as ContractScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
@@ -10,6 +12,7 @@ import type { IslandAppearance, IslandState } from '@classhelper/shared';
* 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。 * 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
*/ */
export interface IslandBridge { export interface IslandBridge {
getIslandWeather(coordinates: { latitude: number; longitude: number }): Promise<IslandWeather>;
getLayout(): Promise<ContractIslandLayoutSettings>; getLayout(): Promise<ContractIslandLayoutSettings>;
saveLayout(value: ContractIslandLayoutSettings): Promise<ContractIslandLayoutSettings>; saveLayout(value: ContractIslandLayoutSettings): Promise<ContractIslandLayoutSettings>;
onLayout(handler: (value: ContractIslandLayoutSettings) => void): void; onLayout(handler: (value: ContractIslandLayoutSettings) => void): void;
@@ -24,6 +27,8 @@ export interface IslandBridge {
onViewport(handler: (value: { width: number; height: number }) => void): () => void; onViewport(handler: (value: { width: number; height: number }) => void): () => void;
getSchedules(): Promise<ContractScheduleDto[]>; getSchedules(): Promise<ContractScheduleDto[]>;
onSchedules(handler: (value: ContractScheduleDto[]) => void): void; onSchedules(handler: (value: ContractScheduleDto[]) => void): void;
getTimePoints(): Promise<ContractIslandTimePointDto[]>;
onTimePoints(handler: (value: ContractIslandTimePointDto[]) => void): void;
onState(handler: (state: IslandState) => void): void; onState(handler: (state: IslandState) => void): void;
sendAction( sendAction(
@@ -57,6 +62,7 @@ export interface IslandBridge {
} }
const bridge: IslandBridge = { const bridge: IslandBridge = {
getIslandWeather: (coordinates) => ipcRenderer.invoke('island:weather', coordinates),
getLayout: () => ipcRenderer.invoke('island:get-layout'), getLayout: () => ipcRenderer.invoke('island:get-layout'),
saveLayout: (value) => ipcRenderer.invoke('island:save-layout', value), saveLayout: (value) => ipcRenderer.invoke('island:save-layout', value),
onLayout: (handler) => { onLayout: (handler) => {
@@ -84,6 +90,10 @@ const bridge: IslandBridge = {
onSchedules: (handler) => { onSchedules: (handler) => {
ipcRenderer.on('island:schedules', (_event, value) => handler(value)); ipcRenderer.on('island:schedules', (_event, value) => handler(value));
}, },
getTimePoints: () => ipcRenderer.invoke('island:get-time-points'),
onTimePoints: (handler) => {
ipcRenderer.on('island:time-points', (_event, value) => handler(value));
},
onState: (handler) => { onState: (handler) => {
ipcRenderer.on('island:state', (_event, state: IslandState) => handler(state)); ipcRenderer.on('island:state', (_event, state: IslandState) => handler(state));
@@ -37,14 +37,63 @@ const containers = ['group', 'stack', 'slide', 'rolling'] as const;
<el-switch v-model="node.settings.seconds" @change="emit('change')" /> <el-switch v-model="node.settings.seconds" @change="emit('change')" />
</el-form-item> </el-form-item>
</template> </template>
<el-form-item v-else-if="node.kind === 'countdown'" label="倒计时目标"> <template v-else-if="node.kind === 'countdown'">
<el-form-item label="倒计时目标">
<el-select v-model="node.settings.target" @change="emit('change')"> <el-select v-model="node.settings.target" @change="emit('change')">
<el-option label="自动:距上课 / 下课" value="auto" /> <el-option label="自动:距上课 / 下课" value="auto" />
<el-option label="下一节课" value="next" /> <el-option label="下一节课" value="next" />
<el-option label="本节下课" value="end" /> <el-option label="本节下课" value="end" />
<el-option label="放学" value="school" /> <el-option label="放学" value="school" />
<el-option label="指定日期" value="date" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<template v-if="node.settings.target === 'date'">
<el-form-item label="目标名称">
<el-input v-model="node.settings.countdownTitle" :maxlength="64" @change="emit('change')" />
</el-form-item>
<el-form-item label="目标日期">
<el-date-picker
v-model="node.settings.targetDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
@change="emit('change')"
/>
</el-form-item>
</template>
</template>
<template v-else-if="node.kind === 'weather'">
<el-form-item label="地点名称">
<el-input
v-model="node.settings.locationName"
placeholder="例如:学校"
:maxlength="64"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="纬度">
<el-input-number
v-model="node.settings.latitude"
:min="-90"
:max="90"
:precision="4"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="经度">
<el-input-number
v-model="node.settings.longitude"
:min="-180"
:max="180"
:precision="4"
@change="emit('change')"
/>
</el-form-item>
<p class="hint">
填写地点坐标后每 15 分钟更新天气。数据来自
Open-Meteo,断网时显示本次运行的缓存;天气提示不作为官方预警。
</p>
</template>
<template v-else-if="node.kind === 'text'"> <template v-else-if="node.kind === 'text'">
<el-form-item label="文字内容"> <el-form-item label="文字内容">
<el-input v-model="node.settings.text" type="textarea" :rows="3" @change="emit('change')" /> <el-input v-model="node.settings.text" type="textarea" :rows="3" @change="emit('change')" />
@@ -57,20 +106,24 @@ const containers = ['group', 'stack', 'slide', 'rolling'] as const;
<template v-else-if="node.kind === 'schedule'"> <template v-else-if="node.kind === 'schedule'">
<el-form-item label="显示课程"> <el-form-item label="显示课程">
<el-select v-model="node.settings.display" @change="emit('change')"> <el-select v-model="node.settings.display" @change="emit('change')">
<el-option label="当前课程" value="current" /> <el-option label="当前时间点" value="current" />
<el-option label="下一节课" value="next" /> <el-option label="下一节课" value="next" />
<el-option label="今日课表" value="today" /> <el-option label="今日时间点" value="today" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="node.settings.display === 'today'" label="显示节数"> <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-input-number v-model="node.settings.count" :min="1" :max="30" @change="emit('change')" />
</el-form-item> </el-form-item>
<el-form-item label="显示课程时间"> <el-form-item label="当前时间点显示起止时间">
<el-switch v-model="node.settings.showPeriod" @change="emit('change')" /> <el-switch v-model="node.settings.showPeriod" @change="emit('change')" />
</el-form-item> </el-form-item>
<el-form-item label="显示上课进度"> <el-form-item label="当前课程显示进度">
<el-switch v-model="node.settings.showProgress" @change="emit('change')" /> <el-switch v-model="node.settings.showProgress" @change="emit('change')" />
</el-form-item> </el-form-item>
<p class="hint">
今日时间点只展开当前项并显示时间段;课间休息与默认隐藏的时间点折叠时完全隐藏,轮到时才展开。
下课前一分钟的倒计时由「提醒」设置里的下课提醒负责。
</p>
</template> </template>
<template v-else-if="containers.includes(node.kind as (typeof containers)[number])"> <template v-else-if="containers.includes(node.kind as (typeof containers)[number])">
<el-form-item label="排列方向"> <el-form-item label="排列方向">
@@ -80,7 +133,7 @@ const containers = ['group', 'stack', 'slide', 'rolling'] as const;
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="组件间距"> <el-form-item label="组件间距">
<el-input-number v-model="node.settings.gap" :min="0" :max="80" @change="emit('change')" /> <el-input-number v-model="node.settings.gap" :min="0" :max="64" @change="emit('change')" />
</el-form-item> </el-form-item>
<el-form-item v-if="node.kind === 'slide'" label="切换间隔(秒)"> <el-form-item v-if="node.kind === 'slide'" label="切换间隔(秒)">
<el-input-number <el-input-number
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ISLAND_COMPONENT_LABELS, type IslandComponentNode } from '@classhelper/shared';
import ComponentIcon from '../../island/ComponentIcon.vue';
const props = defineProps<{
node: IslandComponentNode;
selectedId: string;
dropId?: string;
dropPosition?: string;
}>();
const emit = defineEmits<{ select: [id: string]; drag: [event: PointerEvent, id: string] }>();
const container = computed(() => ['group', 'stack', 'slide', 'rolling'].includes(props.node.kind));
</script>
<template>
<div
class="editor-component"
:class="{
container,
selected: selectedId === node.id,
'drop-before': dropId === node.id && dropPosition === 'before',
'drop-after': dropId === node.id && dropPosition === 'after',
'drop-inside': dropId === node.id && dropPosition === 'inside',
}"
:data-node="node.id"
>
<button
class="component-chip node-handle"
:data-kind="node.kind"
:aria-label="`编辑${ISLAND_COMPONENT_LABELS[node.kind]}`"
@pointerdown="emit('drag', $event, node.id)"
@click.stop="emit('select', node.id)"
>
<ComponentIcon :kind="node.kind" />
<span>{{ ISLAND_COMPONENT_LABELS[node.kind] }}</span>
</button>
<div v-if="container" class="container-children">
<IslandEditorNode
v-for="child in node.children"
:key="child.id"
:node="child"
:selected-id="selectedId"
:drop-id="dropId"
:drop-position="dropPosition"
@select="emit('select', $event)"
@drag="(event, id) => emit('drag', event, id)"
/>
<span v-if="!node.children.length" class="container-empty">拖入组件</span>
</div>
</div>
</template>
<style scoped>
.editor-component {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
border-radius: 5px;
}
.component-chip {
display: flex;
align-items: center;
gap: 5px;
min-height: 36px;
padding: 5px 9px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: inherit;
font: inherit;
white-space: nowrap;
cursor: grab;
touch-action: none;
}
.component-chip svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.component-chip:hover {
background: var(--ch-hover-soft);
}
.component-chip:focus-visible {
outline: 2px solid var(--ch-accent);
outline-offset: 2px;
}
.selected > .component-chip {
background: var(--ch-accent-soft);
border-color: var(--ch-accent);
}
.container {
border: 1px dashed var(--ch-border-strong);
padding: 3px;
gap: 3px;
}
.container-children {
display: flex;
align-items: center;
gap: 3px;
min-width: 60px;
min-height: 34px;
}
.container-empty {
color: var(--ch-text-tertiary);
font-size: 12px;
padding: 0 10px;
}
.drop-inside {
background: var(--ch-accent-soft);
outline: 2px solid var(--ch-accent);
}
.drop-before::before,
.drop-after::after {
content: '';
position: absolute;
inset: 3px auto 3px -4px;
width: 3px;
border-radius: 3px;
background: var(--ch-accent);
}
.drop-after::after {
left: auto;
right: -4px;
}
</style>
@@ -5,6 +5,7 @@ import {
resolveClassStatus, resolveClassStatus,
type ClassPeriod, type ClassPeriod,
type HomeworkDto, type HomeworkDto,
type IslandTimePointDto,
type NotificationDto, type NotificationDto,
type ScheduleDto, type ScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -30,6 +31,8 @@ const TICK_INTERVAL_MS = 1_000;
const REFRESH_INTERVAL_MS = 30_000; const REFRESH_INTERVAL_MS = 30_000;
let schedules: ScheduleDto[] = []; let schedules: ScheduleDto[] = [];
/** 今天的完整时间点(含课间/分割线/默认隐藏),自定义岛的课程表组件使用。 */
let dayTimePoints: IslandTimePointDto[] = [];
let currentPeriod: ClassPeriod | null = null; let currentPeriod: ClassPeriod | null = null;
let inClass = false; let inClass = false;
let tickTimer: ReturnType<typeof setInterval> | null = null; let tickTimer: ReturnType<typeof setInterval> | null = null;
@@ -47,6 +50,51 @@ export function getIslandSchedules(): ScheduleDto[] {
return schedules; return schedules;
} }
export function getIslandTimePoints(): IslandTimePointDto[] {
return dayTimePoints;
}
/** 从 active-week 的今天取完整时间点;课程名优先条目、没有条目才回退默认课程。 */
function buildDayTimePoints(days: ContractActiveClassPlanWeekDto['days']): IslandTimePointDto[] {
const weekday = new Date().getDay() || 7;
const day = days.find((item) => item.weekday === weekday);
const points = day?.timeLayout?.points ?? [];
if (!points.length) return [];
const entries = day?.classPlan?.entries ?? [];
return points.map((point) => {
const entry = entries.find((item) => item.timeLayoutItemId === point.id);
return {
id: point.id,
timeType: point.timeType,
name: point.breakName || point.name || '',
courseName: entry
? entry.isEnabled
? (entry.course?.name ?? null)
: null
: (point.defaultCourse?.name ?? null),
startTime: point.startTime,
endTime: point.endTime,
isHideDefault: point.isHideDefault,
};
});
}
/** 离线/旧服务端只有课表条目时退化成只有上课时间点。 */
function timePointsFromSchedules(list: ScheduleDto[]): IslandTimePointDto[] {
const weekday = new Date().getDay() || 7;
return list
.filter((item) => item.dayOfWeek === weekday)
.map((item) => ({
id: item.id,
timeType: 'LESSON' as const,
name: '',
courseName: item.course?.name ?? null,
startTime: item.startTime,
endTime: item.endTime,
isHideDefault: false,
}));
}
/** 主动刷新课表(课表变更事件触发) */ /** 主动刷新课表(课表变更事件触发) */
export async function refreshIslandSchedules(): Promise<void> { export async function refreshIslandSchedules(): Promise<void> {
const appStore = useAppStore(); const appStore = useAppStore();
@@ -61,13 +109,16 @@ export async function refreshIslandSchedules(): Promise<void> {
'schedules', 'schedules',
`week:${week}`, `week:${week}`,
async () => { async () => {
const data = ( const payload = await api.get<ContractActiveClassPlanWeekDto>('/schedules/active-week', {
await api.get<ContractActiveClassPlanWeekDto>('/schedules/active-week', { classId, week }) classId,
).days.flatMap((day) => day.schedules); week,
});
const data = payload.days.flatMap((day) => day.schedules);
// 网络结果先上岛,IndexedDB 写入不再挡住可见内容;旧请求与切换账号的结果不得回写。 // 网络结果先上岛,IndexedDB 写入不再挡住可见内容;旧请求与切换账号的结果不得回写。
if (scope !== getCacheScope() || request !== scheduleRequest || !auth.token) if (scope !== getCacheScope() || request !== scheduleRequest || !auth.token)
throw new Error('课表刷新已被新请求替代'); throw new Error('课表刷新已被新请求替代');
schedules = data; schedules = data;
dayTimePoints = buildDayTimePoints(payload.days);
scheduleWeek = week; scheduleWeek = week;
recomputeClassState(true); recomputeClassState(true);
return data; return data;
@@ -78,12 +129,22 @@ export async function refreshIslandSchedules(): Promise<void> {
if (result.error && !result.updatedAt) return; if (result.error && !result.updatedAt) return;
if (result.fromCache) { if (result.fromCache) {
schedules = result.data; schedules = result.data;
dayTimePoints = timePointsFromSchedules(result.data);
scheduleWeek = week; scheduleWeek = week;
recomputeClassState(true); recomputeClassState(true);
} }
} }
let sentDay = ''; let sentDay = '';
let sentTimePoints = '';
function timePointsKey(): string {
return dayTimePoints
.map(
(point) =>
`${point.id}|${point.timeType}|${point.startTime}|${point.endTime}|${point.name}|${point.courseName ?? ''}|${point.isHideDefault ? 1 : 0}`,
)
.join(',');
}
function recomputeClassState(force = false): void { function recomputeClassState(force = false): void {
const day = new Date().toDateString(); const day = new Date().toDateString();
const appStore = useAppStore(); const appStore = useAppStore();
@@ -94,15 +155,19 @@ function recomputeClassState(force = false): void {
currentPeriod?.endTime === status.current?.endTime; currentPeriod?.endTime === status.current?.endTime;
currentPeriod = status.current; currentPeriod = status.current;
if (!force && day === sentDay && nextInClass === inClass && samePeriod) return; const pointsKey = timePointsKey();
if (!force && day === sentDay && nextInClass === inClass && samePeriod && pointsKey === sentTimePoints)
return;
sentDay = day; sentDay = day;
inClass = nextInClass; inClass = nextInClass;
sentTimePoints = pointsKey;
window.desktop?.islandSetClassState({ window.desktop?.islandSetClassState({
inClass, inClass,
currentPeriodEnd: currentPeriod?.endTime ?? null, currentPeriodEnd: currentPeriod?.endTime ?? null,
week: appStore.currentWeek, week: appStore.currentWeek,
schedules: schedules.filter((s) => s.dayOfWeek === (new Date().getDay() || 7)), schedules: schedules.filter((s) => s.dayOfWeek === (new Date().getDay() || 7)),
timePoints: dayTimePoints,
}); });
} }
@@ -389,7 +454,9 @@ export function stopIslandBridge(): void {
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated); useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
useRealtimeStore().off('__connected__', onScheduleUpdated); useRealtimeStore().off('__connected__', onScheduleUpdated);
sentDay = ''; sentDay = '';
sentTimePoints = '';
schedules = []; schedules = [];
dayTimePoints = [];
currentPeriod = null; currentPeriod = null;
inClass = false; inClass = false;
@@ -401,5 +468,6 @@ export function stopIslandBridge(): void {
currentPeriodEnd: null, currentPeriodEnd: null,
week: useAppStore().currentWeek, week: useAppStore().currentWeek,
schedules: [], schedules: [],
timePoints: [],
}); });
} }
@@ -60,9 +60,10 @@ export const useRealtimeStore = defineStore('realtime', () => {
connecting.value = false; connecting.value = false;
dispatch('__connected__', null); dispatch('__connected__', null);
}); });
instance.on('disconnect', () => { instance.on('disconnect', (reason: string) => {
connected.value = false; connected.value = false;
dispatch('__disconnected__', null); dispatch('__disconnected__', null);
if (reason === 'io server disconnect' && socket === instance) instance.connect();
}); });
instance.on('connect_error', (error: Error) => { instance.on('connect_error', (error: Error) => {
connected.value = false; connected.value = false;
@@ -0,0 +1,453 @@
.island-editor-page {
display: flex;
flex-direction: column;
flex: 1 0 auto;
min-height: 0;
min-width: 0;
color: var(--ch-text);
padding: 12px 24px 16px;
background: var(--editor-background, var(--ch-bg));
}
:global(.main:has(> .island-editor-page)) {
display: flex;
flex-direction: column;
}
:global(.main:has(> .island-editor-page) > .offline-banner),
.island-editor-page > * {
flex-shrink: 0;
}
:global(html.dark) .island-editor-page {
--editor-background: #262626;
--editor-divider: #3a3a3a;
--editor-card: #2c2c2c;
}
.editor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-height: 30px;
margin-bottom: 4px;
}
.save-status {
color: var(--ch-text-tertiary);
font-size: 12px;
margin-right: 8px;
}
.save-status.failed {
color: var(--el-color-danger);
}
.component-heading {
align-items: flex-end;
gap: 24px;
margin-bottom: 20px;
}
.heading-copy {
flex: 1 1 380px;
}
.component-heading .page-title {
font-size: calc(34px * var(--ch-font-scale));
font-weight: 600;
line-height: 1.4;
margin-bottom: 20px;
}
.heading-copy p {
font-size: calc(16px * var(--ch-font-scale));
margin: 0;
line-height: 1.6;
}
.scheme-tools {
flex: 0 1 348px;
min-width: 300px;
}
.scheme-tools label {
display: block;
margin-bottom: 6px;
font-size: 14px;
font-weight: 600;
}
.scheme-controls {
display: flex;
gap: 6px;
align-items: center;
}
.scheme-controls .el-select {
min-width: 140px;
flex: 1;
}
.scheme-controls :deep(.el-select__wrapper) {
min-height: 38px;
background: var(--ch-card-subtle);
}
.icon-button {
width: 34px;
height: 36px;
padding: 8px;
margin: 0;
flex-shrink: 0;
font-size: 19px;
}
.surface-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.surface-tabs,
.surface-actions {
display: flex;
gap: 12px;
align-items: center;
}
.surface-actions {
gap: 0;
}
.surface-tabs button {
position: relative;
border: 0;
background: transparent;
color: var(--ch-text-secondary);
font: inherit;
font-size: 12px;
padding: 6px 0 9px;
cursor: pointer;
}
.surface-tabs button.active {
color: var(--ch-text);
}
.surface-tabs button.active::after {
content: '';
position: absolute;
bottom: 0;
left: 10%;
right: 10%;
height: 2px;
background: var(--ch-accent);
border-radius: 2px;
}
.preview-canvas {
min-height: 80px;
overflow: auto;
padding: 10px 0 20px;
}
.component-lines {
min-width: 100%;
width: max-content;
}
.component-line {
display: flex;
align-items: center;
min-height: 54px;
border-radius: 6px;
gap: 12px;
padding: 4px;
border: 1px solid transparent;
}
.component-line + .component-line {
margin-top: 6px;
}
.component-line.active-row {
border-color: var(--ch-border-strong);
}
.row-handle {
align-self: stretch;
width: 24px;
flex-shrink: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--ch-text-secondary);
font: inherit;
font-size: 20px;
cursor: grab;
touch-action: none;
}
.row-handle:hover {
color: var(--ch-accent);
background: var(--ch-hover-soft);
}
.line-components {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 0 28px 0 0;
min-height: 38px;
}
.empty-line {
border: 1px dashed var(--ch-border-strong);
background: transparent;
color: var(--ch-text-tertiary);
padding: 10px 24px;
border-radius: 5px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.preview-canvas.live-preview {
display: grid;
place-items: center;
min-height: 160px;
padding: 24px;
background: var(--ch-layer-alt);
border-radius: 6px;
margin-top: 8px;
}
.island-preview {
display: flex;
flex-direction: column;
gap: var(--preview-gap);
width: max-content;
color: #ffffffeb;
padding: 12px;
border-radius: var(--preview-radius);
background: #202024;
}
.preview-row {
display: flex;
align-items: center;
min-width: 120px;
min-height: 44px;
border-radius: var(--preview-radius);
}
.island-preview.separated {
background: transparent;
padding: 0;
}
.separated .preview-row {
padding: 12px;
background: #202024;
}
.empty-preview {
color: #ffffffb8;
background: transparent;
border: 1px dashed #ffffff60;
border-radius: 5px;
padding: 10px 20px;
cursor: pointer;
font: inherit;
font-size: 13px;
}
.drop-row {
outline: 2px dashed var(--ch-accent);
outline-offset: -2px;
background: var(--ch-accent-soft);
}
.preview-toolbar {
display: flex;
align-items: center;
gap: 14px;
padding: 6px 0 12px;
color: var(--ch-text-secondary);
font-size: 12px;
}
.preview-toolbar .el-slider {
width: 130px;
}
.hint {
color: var(--ch-text-secondary);
line-height: 1.6;
font-size: 12px;
}
.component-library {
border-top: 6px solid var(--editor-divider, var(--ch-divider));
margin: 0 -24px;
padding: 14px 24px 24px;
flex: 1 0 auto;
}
.library-title {
position: relative;
width: max-content;
font-size: 17px;
font-weight: 600;
margin: 0 0 12px;
padding: 0 0 7px;
}
.library-title::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 3px;
background: var(--ch-accent);
}
.component-options {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 20px;
}
.library-item {
min-width: 0;
width: 100%;
display: flex;
gap: 12px;
align-items: center;
text-align: left;
min-height: 76px;
padding: 10px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
cursor: grab;
touch-action: none;
transition:
background var(--ch-dur-fast) var(--ch-ease-out),
border-color var(--ch-dur-fast) var(--ch-ease-out);
}
.library-item:hover {
background: var(--ch-hover-soft);
border-color: var(--ch-border);
}
.library-item:active {
background: var(--ch-accent-soft);
}
.library-item:focus-visible,
.row-handle:focus-visible,
.surface-tabs button:focus-visible {
outline: 2px solid var(--ch-accent);
outline-offset: 2px;
}
.library-item > svg {
width: 36px;
height: 36px;
flex: 0 0 36px;
}
.library-item > span {
min-width: 0;
}
.library-item strong {
display: block;
font-size: calc(18px * var(--ch-font-scale));
font-weight: 500;
line-height: 1.5;
}
.component-description {
display: block;
color: var(--ch-text-secondary);
font-size: calc(14px * var(--ch-font-scale));
line-height: 1.5;
}
.message-library-title {
color: var(--ch-text-tertiary);
font-size: 12px;
font-weight: 400;
margin: 20px 8px 4px;
}
.message-options .library-item {
min-height: 64px;
}
.message-options .library-item strong {
font-size: 15px;
}
.message-options .library-item > svg {
width: 26px;
height: 26px;
flex-basis: 26px;
}
.editor-tip {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
min-height: 60px;
border: 1px solid var(--ch-border-strong);
border-radius: 5px;
background: var(--editor-card, var(--ch-card-subtle));
font-size: 14px;
}
.editor-tip > span {
flex: 1;
line-height: 1.6;
}
.tip-icon {
color: var(--ch-accent);
font-size: 22px;
}
.dismiss-tip {
border: 0;
color: var(--ch-text);
background: transparent;
font-size: 26px;
cursor: pointer;
margin-left: auto;
padding: 0 8px;
}
.drag-ghost {
position: fixed;
z-index: 10000;
pointer-events: none;
padding: 10px 16px;
background: var(--ch-layer-solid);
border: 1px solid var(--ch-accent);
border-radius: 6px;
box-shadow: var(--ch-shadow-flyout);
color: var(--ch-text);
font-size: 14px;
}
.inspector-tabs {
margin-bottom: 20px;
}
.row-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 20px;
}
.row-actions .el-button {
margin: 0;
}
@media (max-width: 1150px) {
.component-options {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.component-heading {
gap: 16px;
}
.heading-copy {
flex-basis: 300px;
}
.scheme-tools {
flex-basis: 300px;
}
}
@media (max-width: 800px) {
.component-options {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.component-heading .page-title {
margin-bottom: 10px;
}
.scheme-tools {
flex: 1 1 100%;
}
.editor-tip {
flex-wrap: wrap;
}
.preview-toolbar .hint {
display: none;
}
}
@media (max-width: 520px) {
.component-options {
grid-template-columns: 1fr;
}
.island-editor-page {
padding-right: 16px;
padding-left: 16px;
}
.component-library {
margin-right: -16px;
margin-left: -16px;
padding-right: 16px;
padding-left: 16px;
}
.save-status {
display: none;
}
}
File diff suppressed because it is too large. Load diff
+5
View File
@@ -1,5 +1,7 @@
import type { import type {
IslandLayoutSettings as ContractIslandLayoutSettings, IslandLayoutSettings as ContractIslandLayoutSettings,
IslandTimePointDto as ContractIslandTimePointDto,
IslandWeather,
ReleaseProgressDto as ContractReleaseProgressDto, ReleaseProgressDto as ContractReleaseProgressDto,
ScheduleDto as ContractScheduleDto, ScheduleDto as ContractScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -111,6 +113,8 @@ export interface IslandPushContext {
export interface IslandClassStatePayload { export interface IslandClassStatePayload {
schedules?: ContractScheduleDto[]; schedules?: ContractScheduleDto[];
/** 今天的完整时间点(含课间/分割线/默认隐藏标记),自定义岛课程表组件使用。 */
timePoints?: ContractIslandTimePointDto[];
inClass: boolean; inClass: boolean;
currentPeriodEnd?: string | null; currentPeriodEnd?: string | null;
week?: number; week?: number;
@@ -175,6 +179,7 @@ export interface DesktopBridge {
onUpdateAvailable(handler: (info: UpdateInfo) => void): void; onUpdateAvailable(handler: (info: UpdateInfo) => void): void;
/* 灵动岛 */ /* 灵动岛 */
getIslandWeather(coordinates: { latitude: number; longitude: number }): Promise<IslandWeather>;
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */ /** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void; islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
islandRemoveNotification(id: string): void; islandRemoveNotification(id: string): void;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/server", "name": "@classhelper/server",
"version": "1.4.1", "version": "1.4.2",
"private": true, "private": true,
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问", "description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module", "type": "module",
@@ -0,0 +1,6 @@
-- 已存在的管理员默认组补升级键;默认组尚未初始化时由运行时模板创建。
INSERT INTO "PermissionGroupItem" ("id", "groupId", "key")
SELECT 'promotion-key-' || "id", "id", 'promotion:manage'
FROM "PermissionGroup"
WHERE "defaultRole" = 'ADMIN'
AND NOT EXISTS (SELECT 1 FROM "PermissionGroupItem" i WHERE i."groupId" = "PermissionGroup"."id" AND i."key" = 'promotion:manage');
@@ -0,0 +1,23 @@
ALTER TABLE "StudentClassTransfer" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'class-change';
ALTER TABLE "StudentClassTransfer" ADD COLUMN "batchId" TEXT NOT NULL DEFAULT '';
ALTER TABLE "StudentClassTransfer" ADD COLUMN "academicYear" INTEGER;
ALTER TABLE "StudentClassTransfer" ADD COLUMN "fromGrade" TEXT NOT NULL DEFAULT '';
ALTER TABLE "StudentClassTransfer" ADD COLUMN "toGrade" TEXT NOT NULL DEFAULT '';
ALTER TABLE "StudentClassTransfer" ADD COLUMN "fromSchool" TEXT NOT NULL DEFAULT '';
ALTER TABLE "StudentClassTransfer" ADD COLUMN "toSchool" TEXT NOT NULL DEFAULT '';
UPDATE "StudentClassTransfer" SET "kind" = CASE "mode" WHEN 'transfer-out' THEN 'transfer-out' WHEN 'restore-transfer' THEN 'restore' ELSE 'class-change' END;
ALTER TABLE "StudentClassTransfer" DROP COLUMN "mode";
CREATE INDEX "StudentClassTransfer_batchId_idx" ON "StudentClassTransfer"("batchId");
ALTER TABLE "ArchivedYear" ADD COLUMN "inactiveCount" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "PromotionRun" ADD COLUMN "operatorId" TEXT;
ALTER TABLE "PromotionRun" ADD COLUMN "operatorName" TEXT NOT NULL DEFAULT '';
ALTER TABLE "PromotionRun" ADD COLUMN "detailsJson" TEXT NOT NULL DEFAULT '{}';
ALTER TABLE "PromotionRun" ADD COLUMN "snapshotSha256" TEXT NOT NULL DEFAULT '';
ALTER TABLE "PromotionRun" ADD COLUMN "error" TEXT NOT NULL DEFAULT '';
CREATE TABLE "PermissionAudit" (
"id" TEXT NOT NULL PRIMARY KEY, "actorId" TEXT NOT NULL, "actorName" TEXT NOT NULL,
"action" TEXT NOT NULL, "targetType" TEXT NOT NULL, "targetId" TEXT NOT NULL,
"beforeJson" TEXT NOT NULL DEFAULT '{}', "afterJson" TEXT NOT NULL DEFAULT '{}',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "PermissionAudit_createdAt_idx" ON "PermissionAudit"("createdAt");
@@ -0,0 +1,7 @@
ALTER TABLE "UserPermissionGroup" ADD COLUMN "scope" TEXT NOT NULL DEFAULT 'TEACHING';
ALTER TABLE "UserPermissionGroup" ADD COLUMN "scopeValue" TEXT NOT NULL DEFAULT '';
UPDATE "UserPermissionGroup" SET "scope" = COALESCE((SELECT CASE WHEN g."scope" = 'SELF_CLASS' THEN CASE WHEN g."scopeValue" = 'HEAD' THEN 'HEAD' ELSE 'TEACHING' END ELSE g."scope" END FROM "PermissionGroup" g WHERE g."id" = "groupId"), 'TEACHING'), "scopeValue" = COALESCE((SELECT CASE WHEN g."scope" IN ('GRADE', 'DIVISION') THEN g."scopeValue" ELSE '' END FROM "PermissionGroup" g WHERE g."id" = "groupId"), '');
UPDATE "PermissionGroup" SET "scope" = CASE WHEN "scopeValue" = 'HEAD' THEN 'HEAD' ELSE 'TEACHING' END, "scopeValue" = '' WHERE "scope" = 'SELF_CLASS';
UPDATE "PermissionGroup" SET "scope" = 'TEACHING', "scopeValue" = '' WHERE "defaultRole" = 'TEACHER';
DROP INDEX "UserPermissionGroup_userId_groupId_key";
CREATE UNIQUE INDEX "UserPermissionGroup_userId_groupId_scope_scopeValue_key" ON "UserPermissionGroup"("userId", "groupId", "scope", "scopeValue");
+36 -4
View File
@@ -121,14 +121,21 @@ model StudentClassTransfer {
/// 操作人(User.id);账号被删后留空,姓名快照保留 /// 操作人(User.id);账号被删后留空,姓名快照保留
operatorId String? operatorId String?
operatorName String @default("") operatorName String @default("")
/// single = 单个调班 / batch = 批量调班 / transfer-out = 转出(离开本校) /// 生命周期类型;旧 mode 仅在 DTO 中派生兼容
mode String @default("single") kind String @default("class-change")
batchId String @default("")
academicYear Int?
fromGrade String @default("")
toGrade String @default("")
fromSchool String @default("")
toSchool String @default("")
note String @default("") note String @default("")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@index([studentId, createdAt]) @@index([studentId, createdAt])
@@index([batchId])
@@index([createdAt]) @@index([createdAt])
} }
@@ -157,6 +164,7 @@ model ArchivedYear {
classCount Int @default(0) classCount Int @default(0)
studentCount Int @default(0) studentCount Int @default(0)
transferredCount Int @default(0) transferredCount Int @default(0)
inactiveCount Int @default(0)
homeworkCount Int @default(0) homeworkCount Int @default(0)
notificationCount Int @default(0) notificationCount Int @default(0)
archivedAt DateTime @default(now()) archivedAt DateTime @default(now())
@@ -443,7 +451,8 @@ model Grade {
} }
model PermissionGroup { model PermissionGroup {
scope String @default("SELF_CLASS") /// 新分配的建议范围,不参与已有分配求值。
scope String @default("TEACHING")
scopeValue String @default("") scopeValue String @default("")
/// 角色默认组;自定义组为空,改名不影响对应角色。 /// 角色默认组;自定义组为空,改名不影响对应角色。
defaultRole String? @unique defaultRole String? @unique
@@ -475,6 +484,9 @@ model PermissionGroupItem {
/// 账号 ←→ 权限组 的分配关系(一个账号可属多个组) /// 账号 ←→ 权限组 的分配关系(一个账号可属多个组)
model UserPermissionGroup { model UserPermissionGroup {
/// 此条分配的生效范围。
scope String @default("TEACHING")
scopeValue String @default("")
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
groupId String groupId String
@@ -483,7 +495,7 @@ model UserPermissionGroup {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([userId, groupId]) @@unique([userId, groupId, scope, scopeValue])
@@index([userId]) @@index([userId])
@@index([groupId]) @@index([groupId])
} }
@@ -717,12 +729,32 @@ model PromotionRun {
year Int @id year Int @id
status String status String
snapshotPath String @default("") snapshotPath String @default("")
operatorId String?
operatorName String @default("")
detailsJson String @default("{}")
snapshotSha256 String @default("")
error String @default("")
message String @default("") message String @default("")
classCount Int @default(0) classCount Int @default(0)
studentCount Int @default(0) studentCount Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
finishedAt DateTime? finishedAt DateTime?
} }
/// 权限变更与人工升级审计;不建账号外键,删除账号后仍保留操作人快照。
model PermissionAudit {
id String @id @default(cuid())
actorId String
actorName String
action String
targetType String
targetId String
beforeJson String @default("{}")
afterJson String @default("{}")
createdAt DateTime @default(now())
@@index([createdAt])
}
/// 课件只在完整接收并计算摘要后入库;存储路径不对客户端暴露。 /// 课件只在完整接收并计算摘要后入库;存储路径不对客户端暴露。
model Courseware { model Courseware {
id String @id @default(cuid()) id String @id @default(cuid())
+2 -2
View File
@@ -465,7 +465,7 @@ async function main(): Promise<void> {
toClassName: classes[1]!.name, toClassName: classes[1]!.name,
operatorId: admin.id, operatorId: admin.id,
operatorName: admin.name, operatorName: admin.name,
mode: 'batch', kind: 'class-change',
note: '军训后按分班考试成绩重新均衡', note: '军训后按分班考试成绩重新均衡',
createdAt: new Date(now - 30 * day), createdAt: new Date(now - 30 * day),
}, },
@@ -492,7 +492,7 @@ async function main(): Promise<void> {
toClassName: '转出本校', toClassName: '转出本校',
operatorId: admin.id, operatorId: admin.id,
operatorName: admin.name, operatorName: admin.name,
mode: 'transfer-out', kind: 'transfer-out',
note: '随家长工作调动转学至外地', note: '随家长工作调动转学至外地',
createdAt: new Date(now - 12 * day), createdAt: new Date(now - 12 * day),
}, },
+516
View File
@@ -0,0 +1,516 @@
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { io as connect, type Socket } from 'socket.io-client';
import {
PERMISSION_KEYS,
PERMISSION_META,
SOCKET_CHANNELS,
SOCKET_ROOMS,
SOCKET_EVENTS,
parsePermissionScope,
} from '@classhelper/shared';
import { prisma } from '../src/lib/db.js';
import { createApp } from '../src/app.js';
import { signToken, type TokenPayload } from '../src/lib/jwt.js';
import { ensureDefaultGroups } from '../src/lib/permissions.js';
import { classIdsFor } from '../src/lib/authz/index.js';
import { initRealtime } from '../src/realtime/socket.js';
import { emitToClass } from '../src/realtime/bus.js';
import { exportSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
if (!process.env.DATABASE_URL?.includes('authz-verification'))
throw new Error('只能在授权验收临时数据库运行');
let count = 0;
const check = (name: string) => console.log(`通过 ${++count}:${name}`);
async function account(name: string, role: TokenPayload['role'] = 'TEACHER'): Promise<TokenPayload> {
const row = await prisma.user.create({
data: { username: name, name, role, passwordHash: '隔离夹具,不使用真实账号' },
});
return { sub: row.id, username: name, name, role, classId: null };
}
const admin = await account('authz-admin', 'ADMIN'),
aTeacher = await account('authz-A'),
bTeacher = await account('authz-B'),
unrelated = await account('authz-C'),
lead = await account('authz-grade'),
division = await account('authz-division');
await ensureDefaultGroups();
const cls = async (name: string, grade: string, teacherId: string, index: number) =>
prisma.class.create({
data: {
name,
grade,
teacherId,
classIndex: index,
enrollmentYear: 2026,
code: `AUTHZ${index}`,
passwordHash: '隔离',
},
});
const A = await cls('授权A', '高一', aTeacher.sub, 1),
B = await cls('授权B', '高一', bTeacher.sub, 2),
_C = await cls('授权C', '高二', bTeacher.sub, 3);
const course = async (classId: string, name: string, teacherId: string) =>
prisma.course.create({ data: { classId, name, teacherId } });
const mathB = await course(B.id, '数学', aTeacher.sub),
chineseB = await course(B.id, '语文', bTeacher.sub),
mathA = await course(A.id, '数学', aTeacher.sub);
const studentA = await prisma.student.create({ data: { studentNo: 'AUTHZA', name: '临时A', classId: A.id } }),
studentB = await prisma.student.create({ data: { studentNo: 'AUTHZB', name: '临时B', classId: B.id } });
const device = (c: typeof A): TokenPayload => ({
sub: c.id,
username: c.code,
name: c.name,
role: 'CLASS_DEVICE',
classId: c.id,
classSession: true,
classCode: c.code,
});
const aDevice = device(A),
bDevice = device(B);
const server = createServer(createApp()),
realtime = initRealtime(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`,
base = origin + '/api';
async function request(url: string, actor: TokenPayload, method = 'GET', body?: unknown, expected = 200) {
const res = await fetch(base + url, {
method,
headers: { authorization: `Bearer ${signToken(actor)}`, 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const json = await res.json();
assert.equal(res.status, expected, `${method} ${url} ${JSON.stringify(json)}`);
return json.data;
}
async function group(name: string, keys: string[], scope = 'TEACHING', scopeValue = '') {
return prisma.permissionGroup.create({
data: { name, scope, scopeValue, items: { create: keys.map((key) => ({ key })) } },
});
}
async function assign(actor: TokenPayload, rows: { groupId: string; scope: string; scopeValue?: string }[]) {
await prisma.userPermissionGroup.deleteMany({ where: { userId: actor.sub } });
if (rows.length)
await prisma.userPermissionGroup.createMany({
data: rows.map((r) => ({ userId: actor.sub, ...r, scopeValue: r.scopeValue ?? '' })),
});
}
const sockets: Socket[] = [];
async function socket(actor: TokenPayload) {
const s = connect(origin, {
auth: { token: signToken(actor) },
transports: ['websocket'],
reconnection: false,
});
sockets.push(s);
const info = await new Promise<{ rooms: string[] }>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('实时连接超时')), 5000);
s.once('connected', (data) => {
clearTimeout(timer);
resolve(data);
});
s.once('connect_error', reject);
});
return { s, rooms: info.rooms };
}
try {
// 旧残留管理员键全部装备给科任,管理员能力仍永久无效。
const old = await group('旧残留全能力', [...PERMISSION_KEYS], 'ALL');
await assign(aTeacher, [{ groupId: old.id, scope: 'ALL' }]);
const mine = await request('/permissions/me', aTeacher);
assert(
!mine.permissions.some((key: keyof typeof PERMISSION_META) => PERMISSION_META[key].kind === 'admin'),
);
assert.equal((await request('/permissions/me', admin)).permissions.length, PERMISSION_KEYS.length);
check('管理员全集;旧组残留管理员键对教师无效');
for (const [url, method, body] of [
[`/classes/${B.id}/class-account`, 'PATCH', { password: 'authz-test' }],
[`/classes/${B.id}/settings`, 'PATCH', { termWeeks: 12 }],
[`/classes/${B.id}/head-teacher`, 'PATCH', { teacherId: aTeacher.sub }],
[
`/classes/${B.id}/subject-teachers`,
'PUT',
{ assignments: [{ subjectName: '语文', teacherId: aTeacher.sub }] },
],
['/classes', 'POST', { grade: '高一', enrollmentYear: 2029, classIndex: 99 }],
['/teachers', 'POST', { username: 'intruder', name: '攻击', role: 'ADMIN' }],
[`/teachers/${admin.sub}`, 'PATCH', { role: 'TEACHER' }],
[`/teachers/${admin.sub}/reset-password`, 'POST', {}],
[`/teachers/${admin.sub}`, 'DELETE', undefined],
['/permissions/groups', 'POST', { name: '越权', keys: [] }],
['/system/settings', 'PUT', {}],
['/database/status', 'GET', undefined],
['/term', 'PUT', { weeks: [] }],
['/term/promotion', 'GET', undefined],
[
'/imports/table/commit',
'POST',
{ kind: 'classTeachers', fileName: 'authz.csv', contentBase64: '', mapping: {}, mode: 'append' },
],
] as const)
await request(url, aTeacher, method, body, 403);
assert.equal((await request(`/classes/${B.id}`, aTeacher)).code, undefined);
check('科任不能接管班级账号、学期设置、人员关系或组织写入口,班级码不泄漏');
await request(`/classes/${A.id}/class-account`, aTeacher, 'PATCH', { code: 'AUTHZA1' });
await request(`/classes/${A.id}/settings`, aTeacher, 'PATCH', { termWeeks: 12 });
await request(`/classes/${B.id}/class-account`, admin, 'PATCH', { code: 'AUTHZB1' });
check('本班班主任可改账号与学期设置;管理员可改其他班');
await request(`/teachers/${admin.sub}`, admin, 'PATCH', { role: 'TEACHER' }, 409);
await request(`/teachers/${admin.sub}`, admin, 'DELETE', undefined, 409);
check('最后一个管理员不可降级或删除');
const helper = await request(
'/teachers',
admin,
'POST',
{ username: 'authz-new', name: '新增教师', password: 'authz-pass' },
201,
);
await request(`/teachers/${helper.id}/reset-password`, admin, 'POST', { newPassword: 'authz-pass-2' });
await request(`/teachers/${helper.id}`, admin, 'DELETE');
check('管理员账号写入口正常且产生审计');
const readA = await group(
'A的关联读取',
[
'class:read',
'homework:read',
'notification:read',
'grade:read',
'dashboard:read',
'schedule:read',
'roster:read',
],
'HEAD',
);
const otherAll = await group('无关全校键', ['client:read'], 'ALL');
await assign(aTeacher, [
{ groupId: readA.id, scope: 'HEAD' },
{ groupId: otherAll.id, scope: 'ALL' },
]);
for (const url of [
`/grades/ranking?classId=${B.id}&examName=测试`,
`/grades/exams?classId=${B.id}`,
`/grades/collective?classId=${B.id}`,
`/homeworks?classId=${B.id}`,
`/notifications?classId=${B.id}`,
])
await request(url, aTeacher, 'GET', undefined, 403);
assert.deepEqual(
(await request('/classes', aTeacher)).map((c: { id: string }) => c.id),
[A.id],
);
const dashboard = await request('/dashboard/summary', aTeacher);
assert.equal(dashboard.classCount, 1);
assert.equal((await classIdsFor({ ...aTeacher }, undefined as never)).length, 0);
check('按键独立计算,设备全校读不能扩大内容读取;无键范围为空');
await assign(unrelated, [{ groupId: otherAll.id, scope: 'ALL' }]);
for (const url of ['/homeworks', '/notifications', '/dashboard/summary', `/grades/exams?classId=${B.id}`])
await request(url, unrelated, 'GET', undefined, 403);
const onlyDashboard = await group('仅看板', ['dashboard:read'], 'ALL');
await assign(unrelated, [{ groupId: onlyDashboard.id, scope: 'ALL' }]);
const hidden = await request('/dashboard/summary', unrelated);
assert.equal(hidden.gradeCount, 0);
assert.equal(hidden.studentCount, 0);
assert.equal(hidden.sections.grades, false);
check('宽范围无关能力不授予读权限,看板各段独立受控');
await assign(aTeacher, [{ groupId: old.id, scope: 'ALL' }]);
const hw = await prisma.homework.create({
data: {
classId: B.id,
courseId: chineseB.id,
title: '同事语文',
content: '隔离测试',
createdBy: bTeacher.sub,
},
});
const grade = await prisma.grade.create({
data: { classId: B.id, courseId: chineseB.id, studentId: studentB.id, examName: '授权考试', score: 80 },
});
await request(`/homeworks/${hw.id}`, aTeacher, 'PATCH', { courseId: mathB.id }, 403);
await request(`/grades/${grade.id}`, aTeacher, 'PATCH', { courseId: mathB.id }, 403);
await request(
`/homeworks/${hw.id}/status`,
aTeacher,
'PATCH',
{ completed: true, studentId: studentB.id },
403,
);
await request(`/homeworks/${hw.id}/submissions`, aTeacher, 'GET', undefined, 403);
await request(`/homeworks/${hw.id}/submissions`, aTeacher, 'PATCH', { notSubmittedStudentIds: [] }, 403);
await request(`/grades/exams`, aTeacher, 'PUT', { classId: B.id, name: '授权考试', countInTotal: true });
// 年级/全校授权可配置考试口径;仅任教范围的科任不能。
await assign(aTeacher, [{ groupId: old.id, scope: 'TEACHING' }]);
await request(
`/grades/exams`,
aTeacher,
'PUT',
{ classId: B.id, name: '授权考试', countInTotal: false },
403,
);
await request(`/classes/${B.id}/student-grade-query`, aTeacher, 'PATCH', { enabled: false }, 403);
check('原/目标科目双校验;未交名单与完成状态需要科目关系;科任不能改考试配置');
const ownHw = await request(
'/homeworks',
aTeacher,
'POST',
{ classId: B.id, courseId: mathB.id, title: '本人数学', content: '隔离测试' },
201,
);
await request(`/homeworks/${ownHw.id}/status`, bDevice, 'PATCH', { completed: true });
check('科任可发布本科作业,班级端可维护本班作业');
const layoutBody = {
classId: A.id,
name: '本班时间表',
points: [{ name: '第一节', startTime: '08:00', endTime: '08:40', timeType: 'LESSON' }],
};
const layout = await request('/time-layouts', aTeacher, 'POST', layoutBody, 201);
const foreign = await request(
'/time-layouts',
admin,
'POST',
{ ...layoutBody, classId: B.id, name: '别班时间表' },
201,
);
for (const method of ['PUT', 'DELETE'])
await request(
`/time-layouts/${foreign.id}`,
aTeacher,
method,
method === 'PUT' ? { ...layoutBody, classId: A.id } : undefined,
403,
);
await request(`/time-layouts/${foreign.id}/clone`, aTeacher, 'POST', { name: '偷复制' }, 403);
await request('/time-layouts', aTeacher, 'POST', { ...layoutBody, classId: null }, 403);
await request('/term', aTeacher, 'PUT', { classId: B.id, weeks: [] }, 403);
await request('/term', aTeacher, 'PUT', { classId: A.id, weeks: [] });
await request(`/time-layouts/${layout.id}`, aTeacher, 'DELETE');
check('时间表按存储班级校验;本班可写、跨班与全局拒绝;班级周次要求班主任');
const csv = `学号,姓名,班级\nAUTHZSAFE,首行,${A.name}\nAUTHZBAD,末行,${B.name}`;
await request(
'/imports/table/commit',
aTeacher,
'POST',
{
kind: 'students',
fileName: 'authz.csv',
contentBase64: Buffer.from('\ufeff' + csv).toString('base64'),
mapping: { studentNo: '学号', name: '姓名', className: '班级' },
mode: 'append',
},
403,
);
assert.equal(await prisma.student.count({ where: { studentNo: 'AUTHZSAFE' } }), 0);
check('跨班学生导入预检全部范围,403 不留下首行数据');
const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
await request(`/permissions/groups/${defaults.id}`, admin, 'PATCH', { scope: 'ALL' }, 400);
await request(`/permissions/groups/${defaults.id}`, admin, 'PATCH', { keys: ['teacher:manage'] }, 400);
await request(
'/permissions/groups',
admin,
'POST',
{ name: '非法年级', scope: 'GRADE', scopeValue: '不存在', keys: ['class:read'] },
400,
);
await request('/permissions/groups', admin, 'POST', { name: '非法能力', keys: ['permission:manage'] }, 400);
const legacyTemplate = await group('保留旧停用键', ['class:read', 'class:manage']);
const updatedLegacyTemplate = await request(`/permissions/groups/${legacyTemplate.id}`, admin, 'PATCH', {
keys: ['class:read', 'grade:read'],
});
assert(updatedLegacyTemplate.inactiveKeys.includes('class:manage'));
assert.equal(
await prisma.permissionGroupItem.count({ where: { groupId: legacyTemplate.id, key: 'class:manage' } }),
1,
);
await assign(unrelated, [{ groupId: legacyTemplate.id, scope: 'ALL' }]);
assert(!(await request('/permissions/me', unrelated)).permissions.includes('class:manage'));
check('默认模板固定 TEACHING;不可保存管理员能力或未知年级');
const scoped = await request(
'/permissions/groups',
admin,
'POST',
{ name: '分配级范围', scope: 'HEAD', keys: ['class:read', 'grade:read', 'class:account'] },
201,
);
await request(`/permissions/users/${lead.sub}/groups`, admin, 'PUT', {
assignments: [{ groupId: scoped.id, scope: 'GRADE', scopeValue: '高一' }],
});
await request(`/permissions/users/${division.sub}/groups`, admin, 'PUT', {
assignments: [{ groupId: scoped.id, scope: 'DIVISION', scopeValue: '高二,高一' }],
});
assert.equal((await request('/classes', lead)).length, 2);
assert.equal((await request('/classes', division)).length, 3);
const effective = await request(`/permissions/users/${lead.sub}/effective`, admin);
assert.equal(effective.entries.find((e: { key: string }) => e.key === 'class:account').classCount, 0);
const leadPermissions = await request('/permissions/me', lead);
assert.deepEqual(leadPermissions.classScopes['grade:read'].sort(), [A.id, B.id].sort());
await request(`/grades/ranking?classId=${B.id}&examName=授权考试`, lead);
await request(`/permissions/groups/${scoped.id}`, admin, 'PATCH', { scope: 'ALL' });
assert.equal((await request('/classes', lead)).length, 2);
check('模板建议范围变更不影响已有分配;级长/部长范围与班主任关系独立');
const query = encodeURIComponent(
JSON.stringify([
{ groupId: scoped.id, scope: 'HEAD', scopeValue: '' },
{ groupId: scoped.id, scope: 'GRADE', scopeValue: '高二' },
]),
);
const merged = await request(`/permissions/users/${aTeacher.sub}/effective?assignments=${query}`, admin);
assert.equal(merged.entries.find((e: { key: string }) => e.key === 'class:read').classCount, 2);
assert(merged.conflicts.length > 0);
await request(`/permissions/users/${admin.sub}/groups`, admin, 'PUT', { assignments: [] }, 400);
check('同模板多范围正确合并并预览冲突,管理员不可分配');
const beforeDefault = await request('/permissions/me', bTeacher);
await request(`/permissions/users/${bTeacher.sub}/groups`, admin, 'PUT', { assignments: [] });
assert.equal((await request('/permissions/me', bTeacher)).fromGroups, false);
assert(beforeDefault.permissions.length > 0);
check('空分配恢复默认,无隐式自动预选转换');
await request(`/classes/${B.id}/student-grade-query`, admin, 'PATCH', { enabled: false });
for (const url of [
`/grades/ranking?classId=${B.id}&examName=授权考试`,
`/grades/exams?classId=${B.id}`,
`/grades/student/${studentB.studentNo}`,
'/grades/my',
])
await request(url, bDevice, 'GET', undefined, 403);
assert.equal((await request(`/grades/collective?classId=${B.id}`, bDevice)).mode, 'collective');
await request(`/grades/collective?classId=${B.id}`, aDevice, 'GET', undefined, 403);
check('班级端关闭查询后拒绝个人/排名/原始列表,保留本班集体聚合');
const hb = {
deviceKey: 'authz-device-b',
name: '临时设备',
clientVersion: '1.0.0',
platform: 'win32',
agentProtocol: 2,
};
const d = await request('/clients/heartbeat', bDevice, 'POST', hb);
await request('/clients/heartbeat', aTeacher, 'POST', hb, 403);
await request('/clients/heartbeat', aDevice, 'POST', hb, 403);
const command = await prisma.clientCommand.create({
data: { deviceId: d.deviceId, kind: 'lock', createdBy: admin.sub, payload: '隔离假执行' },
});
for (const actor of [aTeacher, aDevice])
await request(
`/clients/commands/${command.id}/ack`,
actor,
'POST',
{ deviceKey: hb.deviceKey, ok: true },
403,
);
await request(`/clients/commands/${command.id}/ack`, bDevice, 'POST', {
deviceKey: hb.deviceKey,
ok: true,
result: '未执行任何系统操作',
});
await request(
'/clients/update-progress',
aTeacher,
'POST',
{
deviceKey: hb.deviceKey,
progress: {
version: '1.1.0',
stage: 'downloading',
downloadedBytes: 0,
totalBytes: 1,
percent: 0,
error: '',
},
},
403,
);
check('心跳/进度/回执只接受本班班级端,教师知道 deviceKey 仍不能冒用');
await request('/update/client/releases', admin, 'POST', { version: '..' }, 400);
await request('/update/client/download/1.0.0', unrelated, 'GET', undefined, 403);
check('更新路径拒绝 ..,下载要求班级端或设备读能力及范围');
const readOnly = await group('只读作业实时', ['homework:read'], 'HEAD');
await assign(aTeacher, [{ groupId: readOnly.id, scope: 'TEACHING' }]);
const previousSubjectSocket = await socket(aTeacher);
assert(previousSubjectSocket.rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
const disconnected = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('任课导入后旧订阅未断开')), 5000);
previousSubjectSocket.s.once('disconnect', () => {
clearTimeout(timer);
resolve();
});
});
await request('/imports/table/commit', admin, 'POST', {
kind: 'classTeachers',
fileName: 'relations.csv',
mapping: { className: '班级', subjectName: '科目', teacherNo: '工号', teacherName: '姓名', role: '角色' },
contentBase64: Buffer.from(
`\ufeff班级,科目,工号,姓名,角色\n${B.name},数学,${bTeacher.username},临时教师,科任`,
).toString('base64'),
mode: 'upsert',
});
await disconnected;
const updatedSubjectSocket = await socket(aTeacher);
assert(!updatedSubjectSocket.rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
check('任课导入变更后旧房间立即失效,重连按现行关系订阅');
await assign(aTeacher, [
{ groupId: readOnly.id, scope: 'HEAD' },
{ groupId: otherAll.id, scope: 'ALL' },
]);
const { s, rooms } = await socket(aTeacher),
deviceSocket = await socket(aDevice);
assert(rooms.includes(SOCKET_ROOMS.classChannel(A.id, 'homework')));
assert(!rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
assert(!rooms.includes(SOCKET_ROOMS.classChannel(A.id, 'grade')));
assert(SOCKET_CHANNELS.every((ch) => deviceSocket.rooms.includes(SOCKET_ROOMS.classChannel(A.id, ch))));
const join = await new Promise<{ ok: boolean }>((resolve) =>
s.emit('class:join', { classId: B.id, channels: ['homework'] }, resolve),
);
assert.equal(join.ok, false);
const seen: string[] = [];
s.on(SOCKET_EVENTS.gradeUpdated, () => seen.push('grade'));
s.on(SOCKET_EVENTS.homeworkNew, () => seen.push('homework'));
emitToClass(A.id, SOCKET_EVENTS.gradeUpdated, { classId: A.id });
emitToClass(B.id, SOCKET_EVENTS.homeworkNew, { id: 'foreign' });
emitToClass(A.id, SOCKET_EVENTS.homeworkNew, { id: 'own' });
await new Promise((resolve) => setTimeout(resolve, 100));
assert.deepEqual(seen, ['homework']);
check('实时按读域与范围订阅,跨班 join 和跨域事件均拒绝');
const stale = signToken(admin);
await prisma.user.update({ where: { id: admin.sub }, data: { role: 'TEACHER' } });
const staleSocket = connect(origin, {
auth: { token: stale },
transports: ['websocket'],
reconnection: false,
});
sockets.push(staleSocket);
const staleInfo = await new Promise<{ role: string }>((resolve) => staleSocket.once('connected', resolve));
assert.equal(staleInfo.role, 'TEACHER');
await prisma.user.update({ where: { id: admin.sub }, data: { role: 'ADMIN' } });
check('Socket 与 HTTP 共用数据库身份加载,旧管理员 JWT 不保留角色');
const snapshot = await exportSnapshot(prisma, 'sqlite');
const legacy = structuredClone(snapshot);
for (const g of legacy.data.PermissionGroup)
if (g.id === readOnly.id) {
g.scope = 'SELF_CLASS';
g.scopeValue = 'HEAD';
}
for (const a of legacy.data.UserPermissionGroup) {
delete a.scope;
delete a.scopeValue;
}
await restoreSnapshot(prisma, 'sqlite', legacy);
assert.equal(
(
await prisma.userPermissionGroup.findFirstOrThrow({
where: { userId: aTeacher.sub, groupId: readOnly.id },
})
).scope,
'HEAD',
);
assert.equal(
(await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } })).scope,
'TEACHING',
);
assert.throws(() => parsePermissionScope('SELF_CLASS' as never, ''));
check('旧快照按模板回填分配、默认模板归一化、运行时拒绝旧范围枚举');
check(`授权攻击矩阵完成,夹具 ${studentA.id} / ${mathA.id} 均位于临时库`);
} finally {
for (const s of sockets) s.disconnect();
await new Promise<void>((resolve) => realtime.close(() => resolve()));
server.closeAllConnections();
if (server.listening) await new Promise<void>((resolve) => server.close(() => resolve()));
await prisma.$disconnect();
}
+16 -11
View File
@@ -255,7 +255,9 @@ try {
emitted emitted
.filter((e) => e.event === SOCKET_EVENTS.coursewareChanged) .filter((e) => e.event === SOCKET_EVENTS.coursewareChanged)
.every((e) => .every((e) =>
e.rooms.every((room) => [classes[0]!.id, classes[1]!.id].some((id) => room === `class:${id}`)), e.rooms.every((room) =>
[classes[0]!.id, classes[1]!.id].some((id) => room === `class:${id}:courseware`),
),
), ),
); );
await new Promise<void>((resolve) => server.close(() => resolve())); await new Promise<void>((resolve) => server.close(() => resolve()));
@@ -357,24 +359,27 @@ try {
state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } }); state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } });
check('取消目标后忽略旧任务进度', state.updateProgress === ''); check('取消目标后忽略旧任务进度', state.updateProgress === '');
const teacherDeviceKey = 'courseware-teacher-device'; const teacherDeviceKey = 'courseware-teacher-device';
await request('/clients/heartbeat', tokens[0], { deviceKey: teacherDeviceKey, clientVersion: '1.0.0' }); check(
let teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } }); '教师会话不能注册设备心跳',
check('教师电脑设备不绑定班级', teacherDevice.classId === null); (await request('/clients/heartbeat', tokens[0], { deviceKey: teacherDeviceKey, clientVersion: '1.0.0' }))
await prisma.clientDevice.update({ .status === 403,
where: { id: teacherDevice.id }, );
data: { updateTargetVersion: '2.0.0' }, const teacherDevice = await prisma.clientDevice.create({
data: { deviceKey: teacherDeviceKey, updateTargetVersion: '2.0.0' },
}); });
check( check(
'教师电脑可上报自己的更新进度', '旧未绑定设备也不能由教师冒报进度',
( (
await request('/clients/update-progress', tokens[0], { await request('/clients/update-progress', tokens[0], {
deviceKey: teacherDeviceKey, deviceKey: teacherDeviceKey,
progress: { ...updateProgress, version: '2.0.0' }, progress: { ...updateProgress, version: '2.0.0' },
}) })
).status === 200, ).status === 403,
);
check(
'拒绝请求不改变旧设备进度',
(await prisma.clientDevice.findUniqueOrThrow({ where: { id: teacherDevice.id } })).updateProgress === '',
); );
teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } });
check('教师电脑更新进度已记录', JSON.parse(teacherDevice.updateProgress).stage === 'downloading');
const snapshot = await exportSnapshot(prisma, 'sqlite'); const snapshot = await exportSnapshot(prisma, 'sqlite');
check( check(
'备份包含课件、可见范围、推送和回执', '备份包含课件、可见范围、推送和回执',
+251 -98
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { readFileSync } from 'node:fs';
/** /**
* 端到端联调验证(对应验收标准): * 端到端联调验证(对应验收标准):
* 1. 教师发布通知 -> ClassHelper 班级端 Socket.IO 5 秒内收到 * 1. 教师发布通知 -> ClassHelper 班级端 Socket.IO 5 秒内收到
@@ -1056,9 +1057,13 @@ async function main() {
const studentCalls = await api('/calls', { const studentCalls = await api('/calls', {
method: 'POST', method: 'POST',
token: studentToken, token: studentToken,
body: { classId, studentId: targetStudent?.id, quickPhrase: '学生不能叫人' }, body: { classId, studentId: targetStudent?.id, quickPhrase: '班级端代叫人' },
}); });
record('学生调用叫人接口被拒绝(403)', studentCalls.status === 403, `status=${studentCalls.status}`); record(
'班级端可为本班叫人(201)',
studentCalls.status === 201 && Boolean(studentCalls.payload?.data?.id),
`status=${studentCalls.status}`,
);
const studentListAfterCall = await api('/notifications', { token: studentToken }); const studentListAfterCall = await api('/notifications', { token: studentToken });
const receivedCall = (studentListAfterCall.payload?.data ?? []).find( const receivedCall = (studentListAfterCall.payload?.data ?? []).find(
@@ -1558,24 +1563,18 @@ async function main() {
`含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`, `含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`,
); );
// 管理员可以分配权限组,但始终保留两个管理入口
const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, { const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, {
method: 'PUT', method: 'PUT',
token: adminToken, token: adminToken,
body: { groupIds: permissionGroupId ? [permissionGroupId] : [] }, body: { assignments: [] },
}); });
const fixedAdmin = await api('/permissions/me', { token: adminToken });
record( record(
'管理员可以分配权限组(PUT /users/:id/groups → 200)', '管理员固定全集并拒绝分配模板',
Boolean(adminMember) && assignAdmin.status === 200 && assignAdmin.payload?.data?.fixed === false, assignAdmin.status === 400 && fixedAdmin.payload?.data?.permissions.includes('teacher:manage'),
`status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`, `status=${assignAdmin.status}`,
); );
await api(`/permissions/users/${adminMember?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [] },
});
// 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败) // 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败)
const groupsList = await api('/permissions/groups', { token: adminToken }); const groupsList = await api('/permissions/groups', { token: adminToken });
const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null; const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null;
@@ -1625,11 +1624,23 @@ async function main() {
stateNote: '待机', stateNote: '待机',
}; };
// 心跳不强求权限键(教室端会话权限集为空),但**必须登录**:没有 JWT 一律 401 const deviceLogin = await api('/auth/class-login', {
method: 'POST',
body: { code: classes.find((item) => item.id === classId)?.code, password: '123456' },
});
const deviceToken = deviceLogin.payload?.data?.token;
if (!deviceToken) throw new Error('隔离班级设备登录失败,不能用员工身份替代');
const staffHeartbeat = await api('/clients/heartbeat', {
method: 'POST',
token: adminToken,
body: heartbeatBase,
});
record('员工会话不能冒充班级设备上报', staffHeartbeat.status === 403, 'status=' + staffHeartbeat.status);
// 只有本班 CLASS_DEVICE 可以上报和回执;管理端仍用管理员读取及下发。
const heartbeatNoAuth = await api('/clients/heartbeat', { method: 'POST', body: heartbeatBase }); const heartbeatNoAuth = await api('/clients/heartbeat', { method: 'POST', body: heartbeatBase });
const heartbeat = await api('/clients/heartbeat', { const heartbeat = await api('/clients/heartbeat', {
method: 'POST', method: 'POST',
token: adminToken, token: deviceToken,
body: heartbeatBase, body: heartbeatBase,
}); });
const heartbeatData = heartbeat.payload?.data; const heartbeatData = heartbeat.payload?.data;
@@ -1676,7 +1687,7 @@ async function main() {
}); });
const heartbeatWithCommand = await api('/clients/heartbeat', { const heartbeatWithCommand = await api('/clients/heartbeat', {
method: 'POST', method: 'POST',
token: adminToken, token: deviceToken,
body: { ...heartbeatBase, stateNote: '待机' }, body: { ...heartbeatBase, stateNote: '待机' },
}); });
const delivered = heartbeatWithCommand.payload?.data?.command; const delivered = heartbeatWithCommand.payload?.data?.command;
@@ -1689,13 +1700,13 @@ async function main() {
const ackBody = { deviceKey, ok: true, result: 'e2e' }; const ackBody = { deviceKey, ok: true, result: 'e2e' };
const ackFirst = await api(`/clients/commands/${delivered?.id}/ack`, { const ackFirst = await api(`/clients/commands/${delivered?.id}/ack`, {
method: 'POST', method: 'POST',
token: adminToken, token: deviceToken,
body: ackBody, body: ackBody,
}); });
// 幂等:客户端重试回执不报错、也不覆盖第一次结果 // 幂等:客户端重试回执不报错、也不覆盖第一次结果
const ackSecond = await api(`/clients/commands/${delivered?.id}/ack`, { const ackSecond = await api(`/clients/commands/${delivered?.id}/ack`, {
method: 'POST', method: 'POST',
token: adminToken, token: deviceToken,
body: { deviceKey, ok: false, result: '重试不应覆盖结果' }, body: { deviceKey, ok: false, result: '重试不应覆盖结果' },
}); });
record( record(
@@ -1717,13 +1728,13 @@ async function main() {
const otherDeviceKey = `${deviceKey}-other`; const otherDeviceKey = `${deviceKey}-other`;
const otherHeartbeat = await api('/clients/heartbeat', { const otherHeartbeat = await api('/clients/heartbeat', {
method: 'POST', method: 'POST',
token: adminToken, token: deviceToken,
body: { ...heartbeatBase, deviceKey: otherDeviceKey, name: 'E2E 另一台测试机' }, body: { ...heartbeatBase, deviceKey: otherDeviceKey, name: 'E2E 另一台测试机' },
}); });
const otherDeviceId = otherHeartbeat.payload?.data?.deviceId ?? null; const otherDeviceId = otherHeartbeat.payload?.data?.deviceId ?? null;
const ackWrongDevice = await api(`/clients/commands/${delivered?.id}/ack`, { const ackWrongDevice = await api(`/clients/commands/${delivered?.id}/ack`, {
method: 'POST', method: 'POST',
token: adminToken, token: deviceToken,
body: { deviceKey: otherDeviceKey, ok: true, result: 'e2e-other' }, body: { deviceKey: otherDeviceKey, ok: true, result: 'e2e-other' },
}); });
record( record(
@@ -2035,9 +2046,13 @@ async function main() {
const studentPublish = await api('/notifications', { const studentPublish = await api('/notifications', {
method: 'POST', method: 'POST',
token: studentToken, token: studentToken,
body: { classId, title: '学生越权发布', content: '不应成功' }, body: { classId, title: '班级端本班通知', content: '允许本班发布' },
}); });
record('学生调用教师接口被拒绝(403)', studentPublish.status === 403, `status=${studentPublish.status}`); record(
'班级端可发布本班通知(201)',
studentPublish.status === 201 && Boolean(studentPublish.payload?.data?.id),
`status=${studentPublish.status}`,
);
const anonymous = await api('/classes'); const anonymous = await api('/classes');
record('未登录访问被拒绝(401)', anonymous.status === 401, `status=${anonymous.status}`); record('未登录访问被拒绝(401)', anonymous.status === 401, `status=${anonymous.status}`);
@@ -2098,8 +2113,8 @@ async function main() {
token: teacherToken, token: teacherToken,
}); });
record( record(
'班主任下载名单模板被拒绝(403,名单仅管理员)', '有导入键的班主任可下载名单模板(200)',
teacherRosterTemplate.status === 403, teacherRosterTemplate.status === 200 && Boolean(teacherRosterTemplate.payload?.data?.content),
`status=${teacherRosterTemplate.status}`, `status=${teacherRosterTemplate.status}`,
); );
@@ -2211,6 +2226,114 @@ async function main() {
`status=${studentLayoutList.status}`, `status=${studentLayoutList.status}`,
); );
// 8.2.1 时间点默认课程:随布局返回、无条目时回退、覆盖现有课程写入条目
if (layoutId && courseId) {
const defaultCourseName = courses.payload?.data?.[0]?.name ?? '';
const savedLayout = await api(`/time-layouts/${layoutId}`, {
method: 'PUT',
token: adminToken,
body: {
name: '默认时间表',
classId,
points: [
{
name: '第 1 节',
startTime: '08:00',
endTime: '08:40',
timeType: 'LESSON',
defaultClassId: courseId,
isHideDefault: false,
breakName: '',
actionSet: '{}',
},
{
name: '课间',
startTime: '08:40',
endTime: '08:50',
timeType: 'BREAK',
breakName: '课间休息',
actionSet: '{}',
},
],
},
});
const savedPoints = savedLayout.payload?.data?.points ?? [];
const lessonPoint = savedPoints.find((point) => point.timeType === 'LESSON');
record(
'时间表保存默认课程并随布局返回',
savedLayout.status === 200 &&
lessonPoint?.defaultClassId === courseId &&
lessonPoint?.defaultCourse?.name === defaultCourseName,
`status=${savedLayout.status} default=${lessonPoint?.defaultCourse?.name ?? '-'}`,
);
const defaultPlan = await api('/class-plans', {
method: 'POST',
token: teacherToken,
body: {
classId,
name: '默认课程验证课表',
timeLayoutId: layoutId,
weekday: 0,
weekCountDiv: 0,
weekCountDivTotal: 2,
weekStart: 1,
weekEnd: 30,
isEnabled: true,
},
});
const defaultPlanId = defaultPlan.payload?.data?.id ?? null;
const activeDefault = await api(`/schedules/active-week?classId=${classId}&week=1`, {
token: teacherToken,
});
const fallback = (activeDefault.payload?.data?.days ?? [])
.flatMap((day) => day.schedules ?? [])
.find((item) => item.course?.name === defaultCourseName && String(item.id).startsWith('default:'));
record(
'日课表没有单独条目时回退时间点默认课程',
activeDefault.status === 200 && Boolean(fallback),
`status=${activeDefault.status} fallback=${fallback ? fallback.course?.name : '-'}`,
);
if (lessonPoint?.id) {
const applied = await api(`/time-layouts/${layoutId}/points/${lessonPoint.id}/apply-default`, {
method: 'POST',
token: adminToken,
});
const plansAfterApply = await api(`/class-plans?classId=${classId}`, { token: teacherToken });
const appliedPlan = (plansAfterApply.payload?.data ?? []).find((item) => item.id === defaultPlanId);
const appliedEntry = appliedPlan?.entries?.find((entry) => entry.timeLayoutItemId === lessonPoint.id);
record(
'覆盖现有课程写入所有引用课表',
applied.status === 200 &&
(applied.payload?.data?.plans ?? 0) >= 1 &&
appliedEntry?.courseId === courseId,
`status=${applied.status} plans=${applied.payload?.data?.plans ?? 0}`,
);
}
if (defaultPlanId) {
await api(`/class-plans/${defaultPlanId}`, { method: 'DELETE', token: teacherToken });
}
}
// 8.2.2 时间表可通过新路由删除(Web 时间表编辑器使用的接口)
const removableLayout = await api('/time-layouts', {
method: 'POST',
token: adminToken,
body: { name: '临时删除验证', classId, points: [] },
});
const removableId = removableLayout.payload?.data?.id ?? null;
const removedLayout = removableId
? await api(`/time-layouts/${removableId}`, { method: 'DELETE', token: adminToken })
: { status: 0 };
record(
'时间表可通过新路由删除',
removableLayout.status === 201 && removedLayout.status === 200,
`创建=${removableLayout.status} 删除=${removedLayout.status}`,
);
// 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示) // 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示)
const gradesCsv = [ const gradesCsv = [
'学号,姓名,考试名称,分数,总分,科目', '学号,姓名,考试名称,分数,总分,科目',
@@ -2385,13 +2508,14 @@ async function main() {
// 8.3.1 科目归属越权回归(修复"非该科科任老师可以布置/导入其他科内容"): // 8.3.1 科目归属越权回归(修复"非该科科任老师可以布置/导入其他科内容"):
// ① 新建成同名课程必须被拒绝 —— 否则谁都能借"新建科目"把自己写成该科任课老师, // ① 新建成同名课程必须被拒绝 —— 否则谁都能借"新建科目"把自己写成该科任课老师,
// 后面那道 assertCanManageSubjectContent 的科目门随即失效; // 后面那道 assertCanManageSubjectContent 的科目门随即失效;
// ② 班主任导入**别人任教科目**的成绩必须逐行拒绝(原先只判到班级这一层)。 // ② 班主任导入**别人任教科目**的成绩必须拒绝(原先只判到班级这一层)。
{ {
const guardedCourseName = `越权校验科目${Date.now().toString(36).slice(-5).toUpperCase()}`; const guardedCourseName = `越权校验科目${Date.now().toString(36).slice(-5).toUpperCase()}`;
const otherTeacher = await api('/auth/me', { token: teacher2Token });
const firstCourse = await api('/courses', { const firstCourse = await api('/courses', {
method: 'POST', method: 'POST',
token: adminToken, token: adminToken,
body: { classId, name: guardedCourseName }, body: { classId, name: guardedCourseName, teacherId: otherTeacher.payload?.data?.id },
}); });
const firstCourseId = firstCourse.payload?.data?.id; const firstCourseId = firstCourse.payload?.data?.id;
record( record(
@@ -2413,8 +2537,7 @@ async function main() {
`status=${duplicateCourse.status} code=${duplicateCourse.payload?.code} details=${JSON.stringify(duplicateCourse.payload?.details ?? {})}`, `status=${duplicateCourse.status} code=${duplicateCourse.payload?.code} details=${JSON.stringify(duplicateCourse.payload?.details ?? {})}`,
); );
// 这门课的任课老师是**管理员**(course.create 会把创建者写成任课老师): // 显式配置另一位教师任课;班主任身份不能代替科目写权限。
// 班主任导入它的成绩必须被拒,且不能落库。
const guardedCsv = [ const guardedCsv = [
'学号,考试名称,分数,总分,科目', '学号,考试名称,分数,总分,科目',
`${targetStudent?.studentNo ?? '202601'},越权校验考试,88,100,${guardedCourseName}`, `${targetStudent?.studentNo ?? '202601'},越权校验考试,88,100,${guardedCourseName}`,
@@ -2437,14 +2560,12 @@ async function main() {
mode: 'append', mode: 'append',
}, },
}); });
const guardedErrors = guardedImport.payload?.data?.errors ?? []; const guardedRows = await api('/grades?classId=' + classId, { token: adminToken });
record( record(
'班主任导入他人任教科目成绩被逐行拒绝(不落库)', '班主任不能导入他人任教科目成绩,拒绝后不落库',
guardedImport.status === 200 && guardedImport.status === 403 &&
guardedImport.payload?.data?.inserted === 0 && !(guardedRows.payload?.data ?? []).some((item) => item.examName === '越权校验考试'),
guardedImport.payload?.data?.failed === 1 && 'status=' + guardedImport.status + ' message=' + guardedImport.payload?.message,
String(guardedErrors[0]?.message ?? '').includes('不是你任教的科目'),
`新增=${guardedImport.payload?.data?.inserted} 失败=${guardedImport.payload?.data?.failed} 提示=${guardedErrors[0]?.message ?? '-'}`,
); );
if (firstCourseId) { if (firstCourseId) {
@@ -2477,7 +2598,7 @@ async function main() {
); );
// 8.5 需求 6:表格导入在"老师端"可用且不越权 // 8.5 需求 6:表格导入在"老师端"可用且不越权
// 班主任 → 本班成绩可预览/导入;科任 → 403;班主任 → 名单导入仍 403(人员管理仅管理员) // 授课教师可导入本人科目;他人科目与班级范围之外仍拒绝,名单导入另走人员管理权限。
const teacherGradesPreview = await api('/imports/table/preview', { const teacherGradesPreview = await api('/imports/table/preview', {
method: 'POST', method: 'POST',
token: teacherToken, token: teacherToken,
@@ -2517,32 +2638,66 @@ async function main() {
`status=${teacherGradesCommit.status} 新增=${teacherGradesCommit.payload?.data?.inserted} 更新=${teacherGradesCommit.payload?.data?.updated} 失败=${teacherGradesCommit.payload?.data?.failed}`, `status=${teacherGradesCommit.status} 新增=${teacherGradesCommit.payload?.data?.inserted} 更新=${teacherGradesCommit.payload?.data?.updated} 失败=${teacherGradesCommit.payload?.data?.failed}`,
); );
// teacher2 是2025级3班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景 // 科任可以导入本人任教科目,不因没有班主任身份被拒。
const subjectOnlyClass = classes.find((item) => item.name?.includes('2025级3')) ?? null; const subjectOnlyClass = classes.find((item) => item.name?.includes('2025级3'));
const subjectGradesImport = await api('/imports/table/commit', { if (!subjectOnlyClass) throw new Error('隔离科任班级缺失');
method: 'POST', const subjectIdentity = await api('/auth/me', { token: teacher2Token });
token: teacher2Token, const subjectCourses = await api('/courses?classId=' + subjectOnlyClass.id, { token: adminToken });
body: { const subjectCourse = (subjectCourses.payload?.data ?? []).find(
(item) => item.teacherId === subjectIdentity.payload?.data?.id,
);
const subjectStudents = await api('/students?classId=' + subjectOnlyClass.id, { token: adminToken });
const subjectStudent = subjectStudents.payload?.data?.[0];
if (!subjectCourse || !subjectStudent) throw new Error('隔离科任导入数据缺失');
const subjectExam = '科任导入验证' + Date.now();
const subjectCsv = [
'学号,考试名称,分数,总分,科目',
[subjectStudent.studentNo, subjectExam, 88, 100, subjectCourse.name].join(','),
].join(NL);
const subjectBody = {
kind: 'grades', kind: 'grades',
classId: subjectOnlyClass?.id ?? classId, classId: subjectOnlyClass.id,
fileName: 'grades.csv', fileName: 'subject.csv',
contentBase64: gradesCsvBase64, contentBase64: Buffer.from('\ufeff' + subjectCsv + NL, 'utf8').toString('base64'),
mapping: { mapping: {
studentNo: '学号', studentNo: '学号',
name: '姓名',
examName: '考试名称', examName: '考试名称',
score: '分数', score: '分数',
totalScore: '总分', totalScore: '总分',
courseName: '科目', courseName: '科目',
}, },
mode: 'upsert', mode: 'append',
}, };
const subjectGradesImport = await api('/imports/table/commit', {
method: 'POST',
token: teacher2Token,
body: subjectBody,
}); });
record( record(
'科任老师导入本班成绩被拒绝(403,需求 7)', '科任老师可导入本人授课科目成绩',
Boolean(subjectOnlyClass) && subjectGradesImport.status === 403, subjectGradesImport.status === 200 && subjectGradesImport.payload?.data?.inserted === 1,
`班级=${subjectOnlyClass?.name ?? '未找到2025级3班'} status=${subjectGradesImport.status}`, 'status=' + subjectGradesImport.status,
); );
const otherSubject = (subjectCourses.payload?.data ?? []).find(
(item) => item.teacherId !== subjectIdentity.payload?.data?.id,
);
if (!otherSubject) throw new Error('隔离越权科目缺失');
const deniedCsv = [
'学号,考试名称,分数,总分,科目',
[subjectStudent.studentNo, subjectExam + '越权', 88, 100, otherSubject.name].join(','),
].join(NL);
const deniedImport = await api('/imports/table/commit', {
method: 'POST',
token: teacher2Token,
body: {
...subjectBody,
contentBase64: Buffer.from('\ufeff' + deniedCsv + NL, 'utf8').toString('base64'),
},
});
record('科任老师仍不能导入他人任教科目成绩', deniedImport.status === 403, 'status=' + deniedImport.status);
const subjectRows = await api('/grades?classId=' + subjectOnlyClass.id, { token: adminToken });
for (const row of subjectRows.payload?.data ?? [])
if (row.examName === subjectExam) await api('/grades/' + row.id, { method: 'DELETE', token: adminToken });
if (foreignClass) { if (foreignClass) {
const crossGradesImport = await api('/imports/table/commit', { const crossGradesImport = await api('/imports/table/commit', {
@@ -2581,11 +2736,12 @@ async function main() {
}, },
}); });
record( record(
'班主任预览学生名单模板被拒绝(403,名单仅管理员)', '有导入键的班主任可预览名单模板(200)',
teacherRosterPreview.status === 403, teacherRosterPreview.status === 200 && Boolean(teacherRosterPreview.payload?.data),
`status=${teacherRosterPreview.status}`, `status=${teacherRosterPreview.status}`,
); );
const teacherImportedNo = `T${Date.now()}`;
const studentsByTeacher = await api('/imports/table/commit', { const studentsByTeacher = await api('/imports/table/commit', {
method: 'POST', method: 'POST',
token: teacherToken, token: teacherToken,
@@ -2593,17 +2749,43 @@ async function main() {
kind: 'students', kind: 'students',
classId, classId,
fileName: 'students.csv', fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'), contentBase64: Buffer.from(
`\ufeff学号,姓名${NL}${teacherImportedNo},班主任导入学生${NL}`,
'utf8',
).toString('base64'),
mapping: { studentNo: '学号', name: '姓名' }, mapping: { studentNo: '学号', name: '姓名' },
mode: 'append', mode: 'append',
}, },
}); });
record( record(
'教师导入学生名单被拒绝(403)', '有导入键的班主任按范围导入本班名单(200)',
studentsByTeacher.status === 403, studentsByTeacher.status === 200 &&
studentsByTeacher.payload?.data?.failed === 0 &&
studentsByTeacher.payload?.data?.inserted === 1,
`status=${studentsByTeacher.status}`, `status=${studentsByTeacher.status}`,
); );
const teacherImportedStudents = await api(`/students?classId=${classId}&keyword=${teacherImportedNo}`, {
token: adminToken,
});
const teacherImportedStudent = teacherImportedStudents.payload?.data?.find(
(item) => item.studentNo === teacherImportedNo,
);
if (teacherImportedStudent)
await api(`/students/${teacherImportedStudent.id}`, { method: 'DELETE', token: adminToken });
const teacherImportAfter = await api(
`/students?classId=${classId}&status=inactive&keyword=${teacherImportedNo}`,
{ token: adminToken },
);
record(
'班主任导入夹具收尾:停用并保留历史,恢复原在读名单',
Boolean(
teacherImportAfter.payload?.data?.some(
(item) => item.studentNo === teacherImportedNo && item.status === 'inactive',
),
),
);
// 清理:删除导入产生的成绩、时间配置与学生,保持环境可重复验证 // 清理:删除导入产生的成绩、时间配置与学生,保持环境可重复验证
const importedGrades = await api( const importedGrades = await api(
`/grades?classId=${classId}&examName=${encodeURIComponent('期中导入考试')}`, `/grades?classId=${classId}&examName=${encodeURIComponent('期中导入考试')}`,
@@ -2725,9 +2907,13 @@ async function main() {
const classPublish = await api('/notifications', { const classPublish = await api('/notifications', {
method: 'POST', method: 'POST',
token: classToken, token: classToken,
body: { classId, title: '班级账号越权发布', content: '不应成功' }, body: { classId, title: '班级账号本班发布', content: '允许本班发布' },
}); });
record('班级账号发布通知被拒绝(403)', classPublish.status === 403, `status=${classPublish.status}`); record(
'班级账号发布本班通知成功(201)',
classPublish.status === 201 && Boolean(classPublish.payload?.data?.id),
`status=${classPublish.status}`,
);
const classGradesWrite = await api('/grades', { const classGradesWrite = await api('/grades', {
method: 'POST', method: 'POST',
@@ -3201,44 +3387,11 @@ async function main() {
// ---------------------------------------------------------------- 数据库管理(仅管理员) // ---------------------------------------------------------------- 数据库管理(仅管理员)
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。 // 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。 // 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
// 数据表清单随模型演进增长(2026-10-10 加了权限组 / 客户端管理 8 张表后共 23 张)。 // 直接读取快照注册表,避免新增模型后测试清单遗漏。
// **不写死总数**:断言"服务端快照枚举 == 这份清单",加模型时只改这一处。 const snapshotSource = readFileSync(new URL('../src/lib/snapshot.ts', import.meta.url), 'utf8');
const REQUIRED_DB_TABLES = [ const registry = snapshotSource.match(/export const SNAPSHOT_TABLES = \[([\s\S]*?)\] as const/);
'User', if (!registry) throw new Error('无法解析快照表注册表');
'ArchivedYear', const REQUIRED_DB_TABLES = [...registry[1].matchAll(/'([^']+)'/g)].map((match) => match[1]);
'Class',
'Student',
'Course',
'StudentClassTransfer',
'Schedule',
'TimeLayout',
'Homework',
'HomeworkStatus',
'Notification',
'NotificationRead',
'Grade',
'PermissionGroup',
'PermissionGroupItem',
'UserPermissionGroup',
'AppSetting',
'ClientDevice',
'Courseware',
'CoursewareFolder',
'CoursewareVisibility',
'CoursewarePush',
'CoursewareReceipt',
'ClientCommand',
'ClientRelease',
'AiAudit',
'TimeLayoutItem',
'ClassPlanGroup',
'ClassPlan',
'ClassPlanEntry',
'ScheduledClassPlan',
'PromotionRun',
'TermWeek',
'Exam',
];
const dbStatus = await api('/database/status', { token: adminToken }); const dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {}; const dbStatusData = dbStatus.payload?.data ?? {};
const dbStatusTableNames = Array.isArray(dbStatusData.tables) const dbStatusTableNames = Array.isArray(dbStatusData.tables)
@@ -3278,14 +3431,14 @@ async function main() {
`status=${dbStatusTeacher.status} 备份=${dbBackupTeacher.status} 定时=${dbScheduleTeacher.status} 切换=${dbSwitchTeacher.status}`, `status=${dbStatusTeacher.status} 备份=${dbBackupTeacher.status} 定时=${dbScheduleTeacher.status} 切换=${dbSwitchTeacher.status}`,
); );
// 连接测试:当前库必须可达(SQLite 用相对路径,MySQL 用一个不可达地址测失败分支) // 连接测试:当前库必须可达(SQLite 取服务端当前临时库地址,MySQL 用一个不可达地址测失败分支)
let testOkResult; let testOkResult;
let testOkName; let testOkName;
if (dbStatusData.provider === 'sqlite') { if (dbStatusData.provider === 'sqlite') {
testOkResult = await api('/database/test-connection', { testOkResult = await api('/database/test-connection', {
method: 'POST', method: 'POST',
token: adminToken, token: adminToken,
body: { provider: 'sqlite', url: 'file:./prisma/dev.db' }, body: { provider: 'sqlite', url: dbStatusData.databaseUrlMasked },
}); });
testOkName = '连接测试(当前 SQLite 库可达)'; testOkName = '连接测试(当前 SQLite 库可达)';
} else { } else {
+405
View File
@@ -0,0 +1,405 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { PERMISSION_KEYS, defaultPromotionPlan, parsePermissionScope } from '@classhelper/shared';
import { prisma } from '../src/lib/db.js';
import { createApp } from '../src/app.js';
import { signToken, type TokenPayload } from '../src/lib/jwt.js';
import { ensureDefaultGroups, loadAuthPrincipal } from '../src/lib/permissions.js';
import { registerEmitter } from '../src/realtime/bus.js';
import * as students from '../src/modules/students/students.service.js';
import * as classes from '../src/modules/classes/classes.service.js';
import * as permissions from '../src/modules/permissions/permissions.service.js';
import {
configurePromotion,
previewPromotion,
runPromotion,
promotionStatus,
} from '../src/modules/term/promotion.service.js';
import { deleteArchive, getArchivedYearDetail } from '../src/modules/archives/archives.service.js';
import { updateStudentSchema } from '../src/modules/students/students.schemas.js';
import { exportSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
if (!process.env.DATABASE_URL?.includes('lifecycle-verification'))
throw new Error('只能对生命周期专用临时数据库运行');
let count = 0;
function check(name: string, fn: () => unknown) {
fn();
console.log(`通过 ${++count}:${name}`);
}
async function denied(action: Promise<unknown>, status: number) {
await assert.rejects(action, (error: unknown) => (error as { status?: number }).status === status);
}
const account = async (name: string, role: string) => {
const row = await prisma.user.create({
data: { username: name, name, role, passwordHash: '隔离测试,不登录' },
});
return { sub: row.id, username: row.username, name, role, classId: null } as TokenPayload;
};
const admin = await account('临时管理员', 'ADMIN'),
teacher = await account('临时班主任', 'TEACHER'),
manager = await account('临时权限协助', 'TEACHER');
await ensureDefaultGroups();
const cls = async (
name: string,
grade: string,
enrollmentYear: number,
classIndex: number,
teacherId = teacher.sub,
) => prisma.class.create({ data: { name, grade, enrollmentYear, classIndex, code: name, teacherId } });
const a = await cls('TESTA', '高一', 2025, 1),
b = await cls('TESTB', '高一', 2025, 2),
c = await cls('TESTC', '高二', 2024, 1, admin.sub);
const events: { classId: string; event: string }[] = [];
registerEmitter((rooms, event) => {
for (const room of rooms) events.push({ classId: room.split(':')[1], event });
});
const server = 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 { port: number }).port}/api`;
async function request(url: string, actor: TokenPayload, method = 'GET', body?: unknown, expected = 200) {
const response = await fetch(base + url, {
method,
headers: { authorization: `Bearer ${signToken(actor)}`, 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const data = await response.json();
assert.equal(response.status, expected, `${method} ${url}: ${JSON.stringify(data)}`);
return data.data;
}
try {
check('资料 schema 拒绝班级和状态字段', () => {
assert.equal(updateStudentSchema.safeParse({ status: 'graduated' }).success, false);
assert.equal(updateStudentSchema.safeParse({ classId: b.id }).success, false);
});
const empty = await permissions.createGroup(admin, { name: '空组', keys: [] });
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [empty.id] });
for (const url of [
'/classes',
`/classes/${a.id}`,
`/classes/${a.id}/students`,
`/schedules?classId=${a.id}`,
`/homeworks?classId=${a.id}`,
`/notifications?classId=${a.id}`,
'/students',
'/term',
])
await request(url, teacher, 'GET', undefined, 403);
await request('/homeworks', teacher, 'POST', {}, 403);
check('取消读写键立即拒绝所有对应入口', () =>
assert.equal((PERMISSION_KEYS as readonly string[]).includes('promotion:manage'), true),
);
const delegate = await account('临时教务授权', 'TEACHER');
await denied(
permissions.createGroup(admin, {
name: '不可下放组织能力',
keys: ['class:manage', 'archive:manage', 'promotion:manage'],
}),
400,
);
for (const url of ['/classes', '/archives', '/term/promotion'])
await request(
url,
delegate,
url === '/classes' ? 'POST' : 'GET',
url === '/classes' ? {} : undefined,
403,
);
check('组织管理能力不能下放给教师,路由永久拒绝', () => {});
const allRoster = await permissions.createGroup(admin, {
name: '全校名单',
scope: 'ALL',
keys: ['roster:read', 'roster:manage'],
});
await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [allRoster.id] });
const unassigned = await students.createStudent(delegate, {
studentNo: 'UNASSIGNED',
name: '未分班测试学生',
});
assert(
(await students.listStudents(delegate, { includeArchived: false })).some(
(student) => student.id === unassigned.id,
),
);
assert((await students.listTransfers(delegate, { studentId: unassigned.id })).items.length > 0);
check('全校名单授权包含未分班学生及其历史', () => {});
const readAll = await permissions.createGroup(admin, {
name: '全校班级只读',
scope: 'ALL',
keys: ['class:read'],
});
const ownWrite = await permissions.createGroup(admin, { name: '关联班级维护', keys: ['class:settings'] });
await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [readAll.id, ownWrite.id] });
const foreignDetail = await request(`/classes/${a.id}`, delegate);
assert.equal(foreignDetail.students.length, 0, '班级读取键不能代替名单读取键');
await request(`/classes/${a.id}/settings`, delegate, 'PATCH', { termWeeks: 11 }, 403);
check('全校读取组不能借给写操作范围,班级详情不泄漏未授权名单', () => {});
const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [] });
assert((await loadAuthPrincipal(teacher)).permissions.has('roster:manage'));
const additional = await permissions.createGroup(admin, {
name: '追加设备读权限',
scope: 'ALL',
keys: ['client:read'],
});
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [defaults.id, additional.id] });
assert((await loadAuthPrincipal(teacher)).permissions.has('client:read'));
assert(!(await loadAuthPrincipal(teacher)).permissions.has('promotion:manage'));
await denied(
permissions.assignUserGroups(admin, teacher.sub, { groupIds: ['default-permission-admin'] }),
400,
);
await denied(permissions.createGroup(manager, { name: '越权', keys: ['system:manage'] }), 403);
await denied(permissions.updateGroup(manager, defaults.id, { keys: [] }), 403);
await denied(permissions.assignUserGroups(manager, manager.sub, { groupIds: [additional.id] }), 403);
check('分配并集、管理员默认组忽略、非管理员权限管理均拒绝', () => {});
await denied(permissions.createGroup(admin, { name: '空范围', scope: 'GRADE' }), 400);
await denied(permissions.createGroup(admin, { name: '未知范围', scope: 'GRADE', scopeValue: '未知' }), 400);
check('范围统一归一化并拒绝空值和未知年级', () =>
assert.equal(parsePermissionScope('DIVISION', '高二,高一、 高一').scopeValue, '高一,高二'),
);
const memberList = await permissions.listMembers(admin);
check('成员含真实权限与范围,组成员包含默认回落账号', () =>
assert(memberList.find((m) => m.id === teacher.sub)?.permissions.includes('client:read')),
);
const audits = await permissions.listAudits(admin);
check('审计记录操作人与变更前后快照', () =>
assert(audits.items.every((audit) => audit.actorId && audit.actorName && audit.afterJson)),
);
let queryCount = 0;
const restoreMethods: (() => void)[] = [];
for (const model of [prisma.permissionGroup, prisma.userPermissionGroup, prisma.user]) {
for (const method of ['findMany', 'findUnique', 'upsert']) {
const original = Reflect.get(model, method);
Object.defineProperty(model, method, {
configurable: true,
value: (...args: unknown[]) => {
queryCount++;
return Reflect.apply(original, model, args);
},
});
restoreMethods.push(() =>
Object.defineProperty(model, method, { configurable: true, value: original }),
);
}
}
try {
await permissions.listMembers(admin);
const small = queryCount;
queryCount = 0;
await prisma.user.createMany({
data: Array.from({ length: 30 }, (_, i) => ({
username: `批量账号${i}`,
name: `成员${i}`,
role: 'TEACHER',
passwordHash: '临时',
})),
});
await permissions.listMembers(admin);
check('成员增加后权限查询次数保持不变', () => assert.equal(queryCount, small));
} finally {
restoreMethods.forEach((restore) => restore());
}
const scheduleOnly = await permissions.createGroup(admin, { name: '仅课表编辑', keys: ['schedule:write'] });
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [scheduleOnly.id] });
const layoutPreview = {
classId: a.id,
payload: { TimeLayouts: [{ Name: '第一节', StartTime: '08:00', EndTime: '08:45', TimeType: 0 }] },
};
await request('/imports/time-layout/preview', teacher, 'POST', layoutPreview, 403);
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [] });
const parsedLayout = await request('/imports/time-layout/preview', teacher, 'POST', layoutPreview);
check('课表导入键取消后真实预览入口 403,有键可解析', () => assert.equal(parsedLayout.items.length, 1));
const student = await students.createStudent(admin, { studentNo: 'S1', name: '学生一', classId: a.id });
await request(`/students/${student.id}`, admin, 'PATCH', { status: 'graduated' }, 400);
await request(`/students/${student.id}`, admin, 'PATCH', { classId: b.id }, 400);
check('HTTP 旧编辑请求明确返回 400', () => {});
await prisma.$executeRawUnsafe(
`CREATE TRIGGER lifecycle_fail BEFORE INSERT ON StudentClassTransfer BEGIN SELECT RAISE(ABORT, '历史写入故障'); END`,
);
await assert.rejects(students.transferStudents(admin, { studentIds: [student.id], toClassId: b.id }));
assert.equal((await prisma.student.findUniqueOrThrow({ where: { id: student.id } })).classId, a.id);
await prisma.$executeRawUnsafe('DROP TRIGGER lifecycle_fail');
check('注入历史写入故障后学生班级事务回滚', () => {});
const moved = await students.transferStudents(teacher, {
studentIds: [student.id],
toClassId: b.id,
note: '同年级调班',
});
check('班主任调班按名单键授权、批次精确返回、两班广播', () => {
assert.equal(moved.transfers[0].fromGrade, '高一');
assert(moved.batchId);
assert(events.some((e) => e.classId === a.id) && events.some((e) => e.classId === b.id));
});
await denied(students.transferStudents(teacher, { studentIds: [student.id], toClassId: c.id }), 403);
await denied(students.transferStudents(admin, { studentIds: [student.id], toClassId: c.id }), 409);
check('源目标范围与跨年级调班限制生效', () => {});
await students.transferOutStudents(admin, { studentIds: [student.id], note: '迁居', toSchool: '另一学校' });
const history = await students.listTransfers(admin, { studentId: student.id });
check('转出状态筛选与转出历史均可见', () => {
assert(history.items.some((h) => h.kind === 'transfer-out' && h.toSchool === '另一学校'));
});
assert.equal(
(await students.listStudents(admin, { status: 'transferred', includeArchived: false })).length,
1,
);
await request(`/students/${student.id}/transfers`, teacher);
await denied(students.transferOutStudents(admin, { studentIds: [student.id] }), 409);
await students.transferInStudent(admin, { studentId: student.id, toClassId: a.id, fromSchool: '另一学校' });
await students.transferInStudent(admin, {
studentNo: 'S2',
name: '学生二',
toClassId: a.id,
fromSchool: '其他学校',
});
check('重复转出拒绝,新建转入与历史学生重入有完整记录', () => {});
await students.deleteStudent(admin, student.id);
assert.equal(
(await students.listStudents(admin, { status: 'inactive', includeArchived: false })).length,
1,
);
await students.restoreStudent(admin, student.id, { toClassId: a.id });
await denied(students.deleteStudent(admin, student.id, { hard: true, confirm: true }), 409);
check('停用、复学和有历史学生的硬删除保护', () => {});
const device: TokenPayload = {
sub: a.id,
role: 'CLASS_DEVICE',
classId: a.id,
name: a.name,
username: a.code,
classSession: true,
};
for (const url of [
`/classes/${a.id}/students`,
`/schedules?classId=${a.id}`,
`/notifications?classId=${a.id}`,
])
await request(url, device);
await request(
'/homeworks',
device,
'POST',
{ classId: a.id, title: '临时作业', content: '完成练习', assignDate: '2026-10-23' },
201,
);
await request(
'/notifications',
device,
'POST',
{ classId: a.id, title: '临时通知', content: '隔离验收' },
201,
);
await request('/grades', device, 'POST', {}, 403);
await request(
'/calls',
device,
'POST',
{ classId: a.id, studentId: student.id, quickPhrase: '请到办公室' },
201,
);
check('班级端空权限保留名单课表通知和代录作业,禁止写成绩', () => {});
const g = await cls('TESTG', '高三', 2023, 1);
for (const [i, status] of ['active', 'transferred', 'inactive'].entries())
await prisma.student.create({
data: { studentNo: `G${i}`, name: `毕业班学生${i}`, classId: g.id, status },
});
await prisma.termWeek.create({
data: { classId: a.id, weekNumber: 1, startDate: '2026-09-01', endDate: '2026-09-07' },
});
const plan = defaultPromotionPlan(1);
plan.resetTermWeeks = false;
plan.termStartDate = '2026-09-08';
await configurePromotion({ month: 8, day: 1, hour: 0, plan }, admin);
const before = await prisma.promotionRun.count();
const preview = await previewPromotion(2026);
check('预览逐班逐状态、不写库、保留周次有警告', () => {
assert.equal(preview.blockers.length, 0);
assert.equal(preview.termWeekDeleteCount, 0);
assert(preview.tracks[0].classes.find((c) => c.id === g.id)?.transferred === 1);
});
assert.equal(await prisma.promotionRun.count(), before);
await denied(runPromotion(2026, { actor: admin, planHash: '0'.repeat(64), confirm: true }), 409);
const due = (await promotionStatus()).year;
await denied(runPromotion(due + 1, { actor: admin, planHash: preview.planHash, confirm: true }), 400);
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [defaults.id, additional.id] });
await denied(runPromotion(due - 1, { actor: teacher, planHash: preview.planHash, confirm: true }), 403);
check('过期哈希、未来年份与教师补跑过去学年均拒绝', () => {});
const result = await runPromotion(2026, { actor: admin, planHash: preview.planHash, confirm: true });
assert.equal(result.status, 'done');
if (!('snapshotPath' in result)) throw new Error('执行未返回快照');
const awaitedContent = await fs.readFile(result.snapshotPath);
check('快照 SHA256 与磁盘内容一致、执行记录有操作人明细', () =>
assert.equal(result.snapshotSha256, createHash('sha256').update(awaitedContent).digest('hex')),
);
const archived = await prisma.archivedYear.findUniqueOrThrow({ where: { enrollmentYear: 2023 } });
const detail = await getArchivedYearDetail(admin, archived.id);
check('毕业转出停用全部挂届别、状态保持且统计完整', () => {
assert.equal(detail.graduates.length, 1);
assert.equal(detail.transferred.length, 1);
assert.equal(detail.inactive.length, 1);
assert.equal(detail.inactiveCount, 1);
});
assert.equal(await prisma.termWeek.count(), 1);
const skeleton = await prisma.class.findFirstOrThrow({ where: { enrollmentYear: 2026, grade: '高一' } });
assert.equal(skeleton.teacherId, null);
assert.equal(await prisma.course.count({ where: { classId: skeleton.id } }), 0);
assert.equal(
(await prisma.appSetting.findUniqueOrThrow({ where: { key: 'term.startDate' } })).value,
'2026-09-08',
);
await runPromotion(2026, { actor: admin, planHash: preview.planHash, confirm: true });
assert.equal(await prisma.archivedYear.count(), 1);
check('升级日期按计划、周次保留、新班无任课、重复运行幂等', () => {});
await denied(classes.addStudent(admin, a.id, { studentNo: 'G0', name: '不能复活' }), 409);
await denied(students.transferOutStudents(admin, { studentIds: [detail.graduates[0].id] }), 409);
await students.restoreStudent(admin, detail.transferred[0].id, { toClassId: a.id });
check('归档学生不可复活或转出,归档转出学生可指定在读班撤回', () => {});
await deleteArchive(admin, archived.id);
assert.equal((await prisma.student.findUniqueOrThrow({ where: { studentNo: 'G2' } })).status, 'inactive');
check('撤销归档保留停用状态,不回滚升级周次', () => assert.equal(detail.inactive[0].status, 'inactive'));
const unknown = await cls('TESTUNKNOWN', '未知学段', 2022, 10);
assert((await previewPromotion(2027)).blockers.some((b) => b.code === 'UNKNOWN_GRADE'));
await prisma.class.delete({ where: { id: unknown.id } });
await prisma.class.create({
data: { name: 'CODECONFLICT', code: 'N202701', grade: '高一', enrollmentYear: 2027, classIndex: 2 },
});
check('未知年级与新班码冲突在预览阶段阻断', () => {});
assert((await previewPromotion(2027)).blockers.some((b) => b.code === 'CODE_CONFLICT'));
await prisma.class.deleteMany({ where: { name: 'CODECONFLICT' } });
const duplicate = await cls('DUPLICATE', '高二', 2025, 1);
assert((await previewPromotion(2027)).blockers.some((b) => b.code === 'DUPLICATE_INDEX'));
await prisma.class.delete({ where: { id: duplicate.id } });
check('同轨道同届重复班号被阻断', () => {});
await request(`/classes/${a.id}`, admin, 'PATCH', { grade: '任意年级' }, 400);
await denied(classes.promoteClasses(admin, { classIds: [c.id] }), 409);
await classes.promoteClasses(admin, { classIds: [a.id] });
assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: a.id } })).grade, '高三');
check('手动升级只到下一学段,毕业班与直接修改年级被拒绝', () => {});
const junior = await cls('JUNIOR', '初一', 2025, 1);
const multi = defaultPromotionPlan(0);
multi.tracks.push({
id: 'junior',
name: '初中',
entryClassCount: 0,
stages: [{ grade: '初一' }, { grade: '初二' }, { grade: '初三', terminal: true }],
});
await configurePromotion({ month: 8, day: 1, hour: 0, plan: multi }, admin);
const multiPreview = await previewPromotion(2099);
assert.equal(multiPreview.blockers.length, 0);
assert.equal(multiPreview.termWeekDeleteCount, 1);
await runPromotion(2099);
check('多轨道分别升级,确认重置后仅清除预览中的周次', () => {});
assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: junior.id } })).grade, '初二');
assert.equal(await prisma.termWeek.count(), 0);
const snapshot = await exportSnapshot(prisma, 'sqlite');
const auditCount = snapshot.counts.PermissionAudit;
await restoreSnapshot(prisma, 'sqlite', snapshot);
check('全库快照恢复保留审计、生命周期和执行明细', () => assert(auditCount > 0));
assert.equal(await prisma.permissionAudit.count(), auditCount);
console.log(`=== 权限与生命周期:${count}/${count} 项通过 ===`);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await prisma.$disconnect();
}
+13 -2
View File
@@ -114,8 +114,17 @@ for (const [userId, id, scopeValue] of [
[head.id, 'builtin-head', 'HEAD'], [head.id, 'builtin-head', 'HEAD'],
[teacher.id, 'builtin-teacher', ''], [teacher.id, 'builtin-teacher', ''],
] as const) { ] as const) {
await prisma.permissionGroup.update({ where: { id }, data: { scopeValue } }); const scope =
await prisma.userPermissionGroup.create({ data: { userId, groupId: id } }); id === 'builtin-grade'
? 'GRADE'
: id === 'builtin-division'
? 'DIVISION'
: id === 'builtin-head'
? 'HEAD'
: 'TEACHING';
await prisma.userPermissionGroup.create({
data: { userId, groupId: id, scope, scopeValue: ['GRADE', 'DIVISION'].includes(scope) ? scopeValue : '' },
});
} }
const server = createServer(createApp()); const server = createServer(createApp());
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
@@ -476,6 +485,8 @@ try {
assert.equal(value.supported, false); assert.equal(value.supported, false);
assert(value.detail.length > 10); assert(value.detail.length > 10);
}); });
// 范围测试的初中班不属于默认高中计划,结束该测试夹具后再升级高中;未知年级阻断另由 lifecycle 验收。
await prisma.class.delete({ where: { id: third.id } });
// 此文件开头已强制临时库,实际升级不会触及演示或业务数据。 // 此文件开头已强制临时库,实际升级不会触及演示或业务数据。
await prisma.class.create({ await prisma.class.create({
data: { data: {
+99 -295
View File
@@ -1,325 +1,129 @@
import type { PermissionKey as ContractPermissionKey } from '@classhelper/shared'; import type { Prisma } from '../generated/prisma/client.js';
import { import type { PermissionKey, ClassRole } from '@classhelper/shared';
canAssignTeachers, import { resolveClassRole } from '@classhelper/shared';
canManageClasses,
canManageGrades,
canManageRoster,
canManageSchedule,
canManageSubjectContent,
canPublishNotification,
resolveClassRole,
type ClassRole,
} from '@classhelper/shared';
import { teacherScopeWhere } from './permission-scope.js';
import { prisma } from './db.js'; import { prisma } from './db.js';
import { ApiError } from './http.js'; import { ApiError } from './http.js';
import type { TokenPayload } from './jwt.js'; import type { TokenPayload } from './jwt.js';
import {
/** assertAdmin,
* 权限(RBAC)规则。 assertPermission,
* assertClassRead,
* | 角色 | 范围 | 能做什么 | assertManage,
* | ---- | ---- | -------- | classIdsFor,
* | ADMIN | 全局 | 班级/教师/学生名单/调班/归档/全部科目的作业与成绩 | classWhere,
* | HEAD 班主任 | 本班 | 本班学生名单、课表、本班汇总;**作业与成绩仍按科目收口** | classDeviceClassId,
* | SUBJECT 科任 | 班级 + 科目 | 只能发布/修改/删除**自己任教科目**的作业与成绩 | isHead,
* | CLASS_DEVICE | 绑定班级 | ClassHelper 班级端:代本班读写个人数据、按开关查成绩明细 | hasOrganizationalGrant,
* | 学生 | 无账号 | 仅作为 `Student` 名单记录,不登录、无权限 | } from './authz/index.js';
* export { classDeviceClassId } from './authz/relations.js';
* 三条铁律(都是这次重构的落点): export const isAdmin = (user: TokenPayload) => user.role === 'ADMIN';
* 1. **任课关系的唯一来源是 `Course`**(班级 + 科目 + 教师),没有第二张关系表; export const isTeacher = (user: TokenPayload) => user.role === 'TEACHER';
* 2. **班主任不自动拥有本班所有科目的作业成绩编辑权** —— 只有同时是该科科任老师时才有; export const isStaff = (user: TokenPayload) => user.role === 'ADMIN' || user.role === 'TEACHER';
* 3. **所有权限都在后端判**,前端只做展示控制。 export const isClassDevice = (user: TokenPayload) => user.role === 'CLASS_DEVICE';
* export async function resolveContentCreator(user: TokenPayload, classId: string) {
* 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。 if (!isClassDevice(user)) return user.sub;
*/ const record = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } });
if (!record?.teacherId) throw ApiError.conflict('请先为班级配置班主任');
export function isAdmin(user: TokenPayload): boolean { return record.teacherId;
return user.role === 'ADMIN';
} }
export function requireClassDeviceClassId(user: TokenPayload): string {
export function isTeacher(user: TokenPayload): boolean { const id = classDeviceClassId(user);
return user.role === 'TEACHER'; if (!id) throw ApiError.forbidden('当前会话未绑定班级');
return id;
} }
export const getAccessibleClassIds = classIdsFor;
/** 教师与管理员(staff):能以自己的身份操作班级内容 */ export async function canAccessClass(
export function isStaff(user: TokenPayload): boolean {
return user.role === 'ADMIN' || user.role === 'TEACHER';
}
/**
* 是否是 ClassHelper 班级端(教室机器上那台绑定到某个班的客户端)。
* 它代全班读写个人数据(作业完成、通知已读),也能按开关查本班学生成绩明细。
*/
export function isClassDevice(user: TokenPayload): boolean {
return user.role === 'CLASS_DEVICE';
}
/** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */
export function classDeviceClassId(user: TokenPayload): string | null {
return isClassDevice(user) ? (user.classId ?? user.sub) : null;
}
/**
* 班级访问的判定条件:班主任,**或在该班有任课关系**(`Course.teacherId`)。
*
* 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表,
* 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。
*/
/** 当前用户可访问的全部班级 id */
export async function getAccessibleClassIds(
user: TokenPayload,
permission?: ContractPermissionKey,
): Promise<string[]> {
if (isAdmin(user)) {
const classes = await prisma.class.findMany({ select: { id: true } });
return classes.map((item) => item.id);
}
if (isTeacher(user)) {
const classes = await prisma.class.findMany({
where: await teacherScopeWhere(user, permission),
select: { id: true },
});
return classes.map((item) => item.id);
}
const classId = classDeviceClassId(user);
return classId ? [classId] : [];
}
export async function canAccessClass(user: TokenPayload, classId: string): Promise<boolean> {
if (isAdmin(user)) return true;
if (isTeacher(user)) {
const count = await prisma.class.count({
where: { AND: [{ id: classId }, await teacherScopeWhere(user)] },
});
return count > 0;
}
return classDeviceClassId(user) === classId;
}
/** 读权限断言 */
export async function assertClassAccess(user: TokenPayload, classId: string): Promise<void> {
const exists = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!exists) throw ApiError.notFound('班级不存在');
if (!(await canAccessClass(user, classId))) throw ApiError.forbidden('无权访问该班级的数据');
}
/** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */
export async function assertClassWritable(user: TokenPayload, classId: string): Promise<void> {
if (!isStaff(user) && !isClassDevice(user)) throw ApiError.forbidden('当前身份没有管理权限');
await assertClassAccess(user, classId);
}
/* ---------------------------------------------------------------- 班级内角色 */
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / CLASS_DEVICE / NONE) */
export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> {
if (user.role === 'ADMIN') return 'ADMIN';
if (user.role === 'CLASS_DEVICE') return (await canAccessClass(user, classId)) ? 'CLASS_DEVICE' : 'NONE';
const record = await prisma.class.findUnique({
where: { id: classId },
select: {
teacherId: true,
courses: { where: { teacherId: user.sub }, select: { id: true } },
},
});
if (!record) return 'NONE';
return resolveClassRole({
role: user.role,
isHeadTeacher: record.teacherId === user.sub,
teachesSubjects: record.courses.length > 0,
});
}
/** 班级本身(增删改)与人员分配:仅管理员 */
export function assertCanManageClasses(user: TokenPayload): void {
if (!canManageClasses(user.role)) {
throw ApiError.forbidden('只有管理员可以创建、修改或删除班级');
}
}
/** 班主任 / 各科科任老师的分配:仅管理员 */
export function assertCanAssignTeachers(user: TokenPayload): void {
if (!canAssignTeachers(user.role)) {
throw ApiError.forbidden('只有管理员可以分配班主任与各科科任老师');
}
}
/**
* 学生名单(增删学生 / 导入名单 / 调班):管理员,或**本班班主任**。
*
* @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员
*/
export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise<void> {
if (!classId) {
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
return;
}
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (!canManageRoster(classRole)) {
throw ApiError.forbidden(
classRole === 'SUBJECT'
? '科任老师不能管理学生名单,请联系班主任或管理员'
: '只能管理自己担任班主任的班级的学生名单',
);
}
}
/** 课表管理(含时间配置导入):管理员或本班班主任 */
export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (!canManageSchedule(classRole)) {
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与班级端不能管理课表,请联系班主任或管理员'
: '无权管理该班级的课表',
);
}
}
/** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */
export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (!canPublishNotification(classRole)) {
throw ApiError.forbidden('当前账号没有发布通知或叫人的权限');
}
}
/**
* 成绩录入 / 修改 / 导入的**班级维度**准入:管理员,或本班班主任。
*
* @param classId 目标成绩所属班级;不传时按"全局"判定(仅管理员可用,例如空白模板下载)
*
* 这只是第一道门:具体某一条成绩能不能动,还要过 `assertCanManageSubjectContent` 的**科目**那道门。
*/
export async function assertCanManageGrades(user: TokenPayload, classId?: string): Promise<void> {
if (!classId) {
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
return;
}
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (canManageGrades(classRole)) return;
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与班级端不能代录全班成绩,请在自己的科目里录入'
: '只能录入自己担任班主任的班级的成绩',
);
}
/**
* **科目维度的写权限**(作业与成绩共用的那道细门)—— 本次重构的核心规则。
*
* | 情形 | 谁可以写 |
* | ---- | -------- |
* | 管理员 | 全部 |
* | ClassHelper 班级端 | 仅当 `allowClassDevice`(作业允许、成绩不允许) |
* | 挂了科目(courseId 有值) | **该科的任课老师**(`Course.teacherId`) |
* | 没挂科目(courseId 为空) | 本班**班主任** |
*
* 于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
* 「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
*
* @param courseId 目标内容挂在哪门课(科目)上;null/undefined 表示"不指定科目"
* @param action 给用户看的动作名,例如「布置作业」「录入成绩」
* @param options.allowClassDevice 是否允许教室机器代写(作业为 true,成绩为 false)
*/
export async function assertCanManageSubjectContent(
user: TokenPayload, user: TokenPayload,
classId: string, classId: string,
courseId: string | null | undefined, key: PermissionKey,
action: string, ): Promise<boolean> {
options: { allowClassDevice?: boolean } = {}, if (isClassDevice(user)) return classDeviceClassId(user) === classId;
return !!(await prisma.class.count({ where: { AND: [{ id: classId }, await classWhere(user, key)] } }));
}
/** 兼容窄封装,键必须由调用点显式提供。 */
export async function assertClassAccess(
user: TokenPayload,
classId: string,
key: PermissionKey,
): Promise<void> { ): Promise<void> {
await assertClassAccess(user, classId); await assertClassRead(user, key, classId, { allowClassDevice: true });
const classRole = await resolveUserClassRole(user, classId);
const allowClassDevice = options.allowClassDevice === true;
// 管理员不受科目限制,提前返回省掉一次课程查询
if (classRole === 'ADMIN') return;
if (classRole === 'CLASS_DEVICE') {
if (allowClassDevice) return;
throw ApiError.forbidden(`ClassHelper 班级端不能${action},请由任课老师在 Web 端操作`);
} }
export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> {
let isSubjectTeacherOfCourse = false; if (isAdmin(user)) return 'ADMIN';
let courseName = ''; if (isClassDevice(user)) return classDeviceClassId(user) === classId ? 'CLASS_DEVICE' : 'NONE';
if (courseId) { return resolveClassRole({
const course = await prisma.course.findUnique({ role: user.role,
where: { id: courseId }, isHeadTeacher: await isHead(user, classId),
select: { classId: true, teacherId: true, name: true }, teachesSubjects: !!(await prisma.course.count({ where: { classId, teacherId: user.sub } })),
}); });
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
courseName = course.name;
isSubjectTeacherOfCourse = course.teacherId === user.sub;
} }
export async function assertCanManageClasses(user: TokenPayload, classId?: string) {
const allowed = canManageSubjectContent({ assertAdmin(user);
classRole, if (classId && (await prisma.class.findUnique({ where: { id: classId } }))?.archivedYearId)
hasSubject: Boolean(courseId), throw ApiError.conflict('该班级已归档');
isSubjectTeacherOfCourse,
allowClassDevice,
});
if (allowed) return;
throw ApiError.forbidden(
courseId
? `「${courseName}」不是你任教的科目,不能${action}该科目的内容`
: `未指定科目的内容只有班主任或管理员可以${action}`,
);
} }
export const assertCanAssignTeachers = assertCanManageClasses;
/** 学生的主班级(ClassHelper 班级端会话);未绑定班级时抛 403 */ export async function assertGlobalPermission(user: TokenPayload, key: PermissionKey) {
export function requireClassDeviceClassId(user: TokenPayload): string { assertAdmin(user);
const classId = classDeviceClassId(user); await assertPermission(user, key);
if (!classId) throw ApiError.forbidden('当前会话未绑定班级,请联系管理员'); }
return classId; export async function assertClassInScope(user: TokenPayload, classId: string, key: PermissionKey) {
await assertClassRead(user, key, classId);
}
export async function assertClassesInScope(user: TokenPayload, ids: string[], key: PermissionKey) {
for (const id of new Set(ids)) await assertClassInScope(user, id, key);
}
export async function assertCanManageRoster(
user: TokenPayload,
classId?: string,
db: Prisma.TransactionClient = prisma,
) {
if (!classId) {
await assertPermission(user, 'roster:manage');
if (!(await hasOrganizationalGrant(user, 'roster:manage')))
throw ApiError.forbidden('未分班学生需要全校名单范围');
return;
}
await assertManage(user, 'roster:manage', classId, db);
}
export async function assertCanManageSchedule(
user: TokenPayload,
classId: string,
db: Prisma.TransactionClient = prisma,
) {
await assertManage(user, 'schedule:write', classId, db);
}
export async function assertCanImportSchedule(user: TokenPayload, classId: string) {
await assertManage(user, 'import:schedule', classId);
await assertCanManageSchedule(user, classId);
} }
/**
* 查询范围解析:
* - 传入 classId:校验后只查该班级
* - 未传:教师只能查自己可访问的班级;管理员不加限制
*/
export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] }; export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] };
export async function resolveClassScope( export async function resolveClassScope(
user: TokenPayload, user: TokenPayload,
requestedClassId?: string, requestedClassId: string | undefined,
permission?: ContractPermissionKey, key: PermissionKey,
): Promise<ClassScope> { ): Promise<ClassScope> {
if (requestedClassId) { if (requestedClassId) {
await assertClassAccess(user, requestedClassId); await assertClassAccess(user, requestedClassId, key);
return { mode: 'in', classIds: [requestedClassId] }; return { mode: 'in', classIds: [requestedClassId] };
} }
if (isAdmin(user)) return { mode: 'all' }; if (isClassDevice(user)) return { mode: 'in', classIds: [requireClassDeviceClassId(user)] };
await assertPermission(user, key);
const classIds = await getAccessibleClassIds(user, permission); if (Object.keys(await classWhere(user, key)).length === 0) return { mode: 'all' };
// 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错 // 其余范围显式枚举班级,避免空集合被下游当成全量。
return { mode: 'in', classIds }; return { mode: 'in', classIds: await classIdsFor(user, key) };
} }
/** 把 ClassScope 转换成 Prisma 的 where 片段(子表:classId 字段) */
export function classScopeWhere(scope: ClassScope): { classId?: { in: string[] } } { export function classScopeWhere(scope: ClassScope): { classId?: { in: string[] } } {
return scope.mode === 'all' ? {} : { classId: { in: scope.classIds } }; return scope.mode === 'all' ? {} : { classId: { in: scope.classIds } };
} }
/** Class 表自身的范围过滤:注意这里过滤的是主键 id,而不是子表的 classId */
export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } } { export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } } {
return scope.mode === 'all' ? {} : { id: { in: scope.classIds } }; return scope.mode === 'all' ? {} : { id: { in: scope.classIds } };
} }
/** 单班级场景:班级端强制取自己绑定的班级,其他角色沿用请求参数 */
export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined { export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined {
if (isClassDevice(user)) return requireClassDeviceClassId(user); if (isClassDevice(user)) {
const id = requireClassDeviceClassId(user);
if (requestedClassId && requestedClassId !== id) throw ApiError.forbidden('不能访问其他班级');
return id;
}
return requestedClassId; return requestedClassId;
} }
+104
View File
@@ -0,0 +1,104 @@
import type { Prisma } from '../../generated/prisma/client.js';
import { PERMISSION_LABELS, type PermissionKey } from '@classhelper/shared';
import { prisma } from '../db.js';
import { ApiError } from '../http.js';
import type { TokenPayload } from '../jwt.js';
import { loadAuthPrincipal } from './principal.js';
import { assertClassInScope, hasOrganizationalGrant } from './scope.js';
import { classDeviceClassId, isHead, isTeaching } from './relations.js';
export function assertAdmin(user: TokenPayload): void {
if (user.role !== 'ADMIN' || user.classSession) throw ApiError.forbidden('此操作仅限管理员');
}
export async function assertPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
if (!(await loadAuthPrincipal(user)).permissions.has(key))
throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`);
}
export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise<void> {
const auth = await loadAuthPrincipal(user);
if (!keys.some((key) => auth.permissions.has(key))) throw ApiError.forbidden('缺少此操作所需权限');
}
export function assertClassDevice(user: TokenPayload, classId: string): void {
if (classDeviceClassId(user) !== classId) throw ApiError.forbidden('仅限绑定本班的班级端会话');
}
export async function assertPermissionOrClassDevice(user: TokenPayload, key: PermissionKey): Promise<void> {
if (user.role !== 'CLASS_DEVICE') await assertPermission(user, key);
}
export async function assertClassRead(
user: TokenPayload,
key: PermissionKey,
classId: string,
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
): Promise<void> {
if (user.role === 'CLASS_DEVICE' && options.allowClassDevice) {
assertClassDevice(user, classId);
return;
}
await assertPermission(user, key);
await assertClassInScope(user, key, classId, options.db);
}
export async function assertHeadOnly(
user: TokenPayload,
key: PermissionKey,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
await assertClassRead(user, key, classId, { db });
if (user.role !== 'ADMIN' && !(await isHead(user, classId, db)))
throw ApiError.forbidden('此操作仅限本班班主任或管理员');
}
export async function assertManage(
user: TokenPayload,
key: PermissionKey,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
await assertClassRead(user, key, classId, { db });
if (
user.role !== 'ADMIN' &&
!(await isHead(user, classId, db)) &&
!(await hasOrganizationalGrant(user, key, classId, db))
)
throw ApiError.forbidden('此操作需要班主任关系或该功能的年级、部、全校范围');
}
export async function assertContentWrite(
user: TokenPayload,
key: PermissionKey,
classId: string,
courseId: string | null | undefined,
action: string,
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
): Promise<void> {
await assertClassRead(user, key, classId, options);
if (courseId) {
const course = await (options.db ?? prisma).course.findUnique({ where: { id: courseId } });
if (!course || course.classId !== classId) throw ApiError.badRequest('课程不属于目标班级');
if (
user.role === 'ADMIN' ||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
course.teacherId === user.sub
)
return;
} else if (
user.role === 'ADMIN' ||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
(await isHead(user, classId, options.db))
)
return;
throw ApiError.forbidden(`只能${action}本人任教科目或班主任班未指定科目的内容`);
}
export async function assertPublish(
user: TokenPayload,
key: PermissionKey,
classId: string,
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
): Promise<void> {
await assertClassRead(user, key, classId, options);
if (
user.role === 'ADMIN' ||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
(await isTeaching(user, classId, options.db))
)
return;
throw ApiError.forbidden('只有班主任或本班任课老师可以发布');
}
+4
View File
@@ -0,0 +1,4 @@
export * from './principal.js';
export * from './scope.js';
export * from './relations.js';
export * from './assert.js';
@@ -0,0 +1,99 @@
import {
PERMISSION_KEYS,
PERMISSION_META,
parsePermissionScope,
type PermissionKey,
type PermissionScope,
} from '@classhelper/shared';
import { prisma } from '../db.js';
import type { TokenPayload } from '../jwt.js';
import { ensureDefaultGroups } from '../permissions.js';
export interface Grant {
scope: PermissionScope;
scopeValue: string;
groupId: string;
}
export interface AuthPrincipal {
permissions: ReadonlySet<PermissionKey>;
grants: ReadonlyMap<PermissionKey, readonly Grant[]>;
fromGroups: boolean;
}
export interface AssignmentRow {
groupId: string;
scope: string;
scopeValue: string;
group: { defaultRole: string | null; items: { key: string }[] };
}
let requests = new WeakMap<TokenPayload, Promise<AuthPrincipal>>();
export function invalidateAuthPrincipals() {
requests = new WeakMap();
}
/** 缓存仅附着于一个请求的主体;没有可变的“当前权限键”。 */
export function loadAuthPrincipal(user: TokenPayload): Promise<AuthPrincipal> {
let principal = requests.get(user);
if (!principal) {
principal = load(user);
requests.set(user, principal);
}
return principal;
}
async function load(user: TokenPayload): Promise<AuthPrincipal> {
if (user.role !== 'TEACHER') return principalForAssignments(user, []);
await ensureDefaultGroups();
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
include: { group: { include: { items: true } } },
});
return principalForAssignments(user, assignments);
}
/** 分配预览与运行时共用求值,不写数据库。 */
export async function principalForAssignments(
user: TokenPayload,
assignments: AssignmentRow[],
defaultGroup?: AssignmentRow['group'] & { id: string },
): Promise<AuthPrincipal> {
const permissions = new Set<PermissionKey>();
const grants = new Map<PermissionKey, Grant[]>();
if (user.role === 'CLASS_DEVICE' || user.classSession) return { permissions, grants, fromGroups: false };
if (user.role === 'ADMIN') {
for (const key of PERMISSION_KEYS) {
permissions.add(key);
grants.set(key, [{ scope: 'ALL', scopeValue: '', groupId: '' }]);
}
return { permissions, grants, fromGroups: false };
}
const fromGroups = assignments.length > 0;
if (!fromGroups) {
const group =
defaultGroup ??
(await prisma.permissionGroup.findUnique({
where: { defaultRole: 'TEACHER' },
include: { items: true },
}));
assignments = group ? [{ groupId: group.id, scope: 'TEACHING', scopeValue: '', group }] : [];
}
for (const assignment of assignments) {
if (assignment.group.defaultRole === 'ADMIN') continue;
for (const { key: raw } of assignment.group.items) {
if (!(PERMISSION_KEYS as readonly string[]).includes(raw)) continue;
const key = raw as PermissionKey;
if (PERMISSION_META[key].kind === 'admin') continue;
permissions.add(key);
try {
const parsed = parsePermissionScope(assignment.scope as PermissionScope, assignment.scopeValue);
grants.set(key, [
...(grants.get(key) ?? []),
{ scope: parsed.scope, scopeValue: parsed.scopeValue, groupId: assignment.groupId },
]);
} catch {
/* 非法旧分配不授予班级。 */
}
}
}
return { permissions, grants, fromGroups };
}
@@ -0,0 +1,24 @@
import type { Prisma } from '../../generated/prisma/client.js';
import { prisma } from '../db.js';
import type { TokenPayload } from '../jwt.js';
export function classDeviceClassId(user: TokenPayload): string | null {
return user.role === 'CLASS_DEVICE' && user.classSession ? (user.classId ?? user.sub) : null;
}
export async function isHead(
user: TokenPayload,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<boolean> {
return !!(await db.class.count({ where: { id: classId, teacherId: user.sub } }));
}
export async function isTeaching(
user: TokenPayload,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<boolean> {
return (
(await isHead(user, classId, db)) ||
!!(await db.course.count({ where: { classId, teacherId: user.sub } }))
);
}
+124
View File
@@ -0,0 +1,124 @@
import { PERMISSION_META, parsePermissionScope, type PermissionKey } from '@classhelper/shared';
import type { Prisma } from '../../generated/prisma/client.js';
import { prisma } from '../db.js';
import { ApiError } from '../http.js';
import type { TokenPayload } from '../jwt.js';
import { loadAuthPrincipal, type AuthPrincipal } from './principal.js';
/** 返回 Class 表过滤条件;无键、无授权、组织只读键均不产生班级集合。 */
export async function classWhere(
user: TokenPayload,
key: PermissionKey,
principal?: AuthPrincipal,
): Promise<Prisma.ClassWhereInput> {
if (!key) return { id: { in: [] } };
const auth = principal ?? (await loadAuthPrincipal(user));
if (!auth.permissions.has(key)) return { id: { in: [] } };
if (user.role === 'ADMIN') return {};
if (PERMISSION_META[key].kind !== 'class') return { id: { in: [] } };
const alternatives: Prisma.ClassWhereInput[] = [];
for (const grant of auth.grants.get(key) ?? []) {
if (grant.scope === 'ALL') return PERMISSION_META[key].relation === 'head' ? { teacherId: user.sub } : {};
if (grant.scope === 'HEAD') alternatives.push({ teacherId: user.sub });
else if (grant.scope === 'TEACHING')
alternatives.push({ OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] });
else alternatives.push({ grade: { in: parsePermissionScope(grant.scope, grant.scopeValue).grades } });
}
// 班主任专属键仍须满足分配范围与关系的交集。
if (PERMISSION_META[key].relation === 'head') {
return { AND: [{ OR: alternatives }, { teacherId: user.sub }] };
}
return { OR: alternatives };
}
export async function classIdsFor(
user: TokenPayload,
key: PermissionKey,
principal?: AuthPrincipal,
db: Prisma.TransactionClient = prisma,
): Promise<string[]> {
const rows = await db.class.findMany({
where: await classWhere(user, key, principal),
select: { id: true },
});
return rows.map((row) => row.id);
}
/** 名单、日课表、导入及考试配置的班级管理范围,供界面准确显示操作入口。 */
export async function manageClassIdsFor(
user: TokenPayload,
key: PermissionKey,
principal: AuthPrincipal,
): Promise<string[]> {
if (user.role === 'ADMIN') return classIdsFor(user, key, principal);
const grants = (principal.grants.get(key) ?? []).filter((g) =>
['GRADE', 'DIVISION', 'ALL'].includes(g.scope),
);
const broad = await classWhere(user, key, { ...principal, grants: new Map([[key, grants]]) });
const rows = await prisma.class.findMany({
where: { AND: [await classWhere(user, key, principal), { OR: [{ teacherId: user.sub }, broad] }] },
select: { id: true },
});
return rows.map((row) => row.id);
}
/** 每项输入都携带权限键;关系一次读取,输出仍逐键与各自授权范围求交。 */
export async function actionScopesFor(
user: TokenPayload,
scopes: ReadonlyArray<readonly [PermissionKey, readonly string[]]>,
management: ReadonlyArray<readonly [PermissionKey, readonly string[]]>,
): Promise<Partial<Record<PermissionKey, readonly string[]>>> {
const result = { ...Object.fromEntries(scopes), ...Object.fromEntries(management) };
const related = scopes.filter(([key]) =>
['subject', 'publish', 'creator'].includes(PERMISSION_META[key].relation),
);
if (user.role === 'ADMIN' || !related.length) return result;
const rows = await prisma.class.findMany({
where: { id: { in: [...new Set(related.flatMap(([, ids]) => [...ids]))] } },
select: {
id: true,
teacherId: true,
courses: { where: { teacherId: user.sub }, select: { id: true }, take: 1 },
notifications: { where: { createdBy: user.sub }, select: { id: true }, take: 1 },
},
});
const relations = new Map(rows.map((row) => [row.id, row]));
for (const [key, ids] of related) {
const allowed = ids.filter((id) => {
const row = relations.get(id);
return (
row &&
(row.teacherId === user.sub ||
(PERMISSION_META[key].relation === 'creator'
? row.notifications.length > 0
: row.courses.length > 0))
);
});
// 成绩页还包含考试口径/查询开关,显式组织范围可维护这些配置。
result[key] =
key === 'grade:write'
? [...new Set([...allowed, ...(management.find(([k]) => k === key)?.[1] ?? [])])]
: allowed;
}
return result;
}
export async function assertClassInScope(
user: TokenPayload,
key: PermissionKey,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
if (!(await db.class.count({ where: { AND: [{ id: classId }, await classWhere(user, key)] } })))
throw ApiError.forbidden('目标班级不在此功能的授权范围内');
}
/** 管理动作只有显式年级/部/全校分配可越过班主任关系。 */
export async function hasOrganizationalGrant(
user: TokenPayload,
key: PermissionKey,
classId?: string,
db: Prisma.TransactionClient = prisma,
): Promise<boolean> {
if (user.role === 'ADMIN') return true;
const auth = await loadAuthPrincipal(user);
const grants = (auth.grants.get(key) ?? []).filter((g) => ['GRADE', 'DIVISION', 'ALL'].includes(g.scope));
if (!classId) return grants.some((g) => g.scope === 'ALL');
return (await classIdsFor(user, key, { ...auth, grants: new Map([[key, grants]]) }, db)).includes(classId);
}
+23
View File
@@ -0,0 +1,23 @@
import type { UserRole } from '@classhelper/shared';
import { prisma } from '../db.js';
import { ApiError } from '../http.js';
import type { TokenPayload } from '../jwt.js';
export async function loadSessionUser(payload: TokenPayload): Promise<TokenPayload> {
if (payload.classSession && payload.role === 'CLASS_DEVICE') {
const cls = await prisma.class.findUnique({ where: { id: payload.classId ?? payload.sub } });
if (!cls || cls.archivedYearId) throw ApiError.unauthorized('班级不存在或已归档');
return {
sub: cls.id,
username: cls.code,
name: cls.name,
role: 'CLASS_DEVICE',
classId: cls.id,
classSession: true,
classCode: cls.code,
};
}
if (payload.role === 'CLASS_DEVICE') throw ApiError.unauthorized('无效班级会话');
const row = await prisma.user.findUnique({ where: { id: payload.sub } });
if (!row || !['ADMIN', 'TEACHER'].includes(row.role)) throw ApiError.unauthorized('账号不存在');
return { sub: row.id, username: row.username, name: row.name, role: row.role as UserRole, classId: null };
}
+82 -12
View File
@@ -1,6 +1,7 @@
import { import {
dayKeyLocal, dayKeyLocal,
resolveClassStatus, resolveClassStatus,
type CourseBrief,
type ScheduleDto, type ScheduleDto,
type ActiveClassPlanDto, type ActiveClassPlanDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -20,18 +21,82 @@ export async function loadPlans(classId: string) {
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
}); });
} }
export function projectPlan(plan: PlanRow): ScheduleDto[] {
return plan.entries /** 时间点的默认课程映射(defaultClassId → 课程摘要)。 */
.filter((e) => e.isEnabled && e.course && e.timePoint.timeType === 'LESSON') export type DefaultCourseMap = Map<string, CourseBrief>;
.map<ScheduleDto>((e) => ({
id: e.legacyScheduleId || e.id, /**
* 一次性取出时间表里所有默认课程。
*
* 刻意不在 Prisma 里给 `TimeLayoutItem.defaultClassId` 加关系:该字段早已存在,
* 加外键要重建表;这里按 id 查一次课程即可,SQLite/MySQL 都通用。
*/
export async function loadDefaultCourseMap(
points: ReadonlyArray<{ defaultClassId: string | null }>,
): Promise<DefaultCourseMap> {
const ids = [...new Set(points.flatMap((p) => (p.defaultClassId ? [p.defaultClassId] : [])))];
if (!ids.length) return new Map();
const courses = await prisma.course.findMany({
where: { id: { in: ids } },
select: { id: true, name: true },
});
return new Map(courses.map((course) => [course.id, course]));
}
/** 给时间表时间点附加默认课程,供 Web 编辑器与客户端读取。 */
export async function decorateLayout<
T extends { classId: string | null; points: Array<{ defaultClassId: string | null }> },
>(
layout: T,
): Promise<
Omit<T, 'points'> & { points: Array<T['points'][number] & { defaultCourse: CourseBrief | null }> }
> {
const defaults = await loadDefaultCourseMap(layout.points);
return {
...layout,
points: layout.points.map((point) => ({
...point,
// 全校时间表不能携带默认课程。
defaultCourse:
layout.classId && point.defaultClassId ? (defaults.get(point.defaultClassId) ?? null) : null,
})),
};
}
/**
* 日课表投影。
*
* 按**时间表的 LESSON 时间点**遍历,而不是只遍历已保存的条目:
* - 有条目:以条目为准(停用则整节不出现,课程为空则是显式空课,不回退);
* - 没有条目:回退时间点的默认课程。
* 默认课程的回退项没有 `ClassPlanEntry`,用 `default:<planId>:<pointId>` 作为稳定 id,
* 仅供读取展示;旧 `/schedules` 写接口不会命中它。
*/
export function projectPlan(plan: PlanRow, defaults: DefaultCourseMap = new Map()): ScheduleDto[] {
const entryOf = new Map(plan.entries.map((entry) => [entry.timeLayoutItemId, entry]));
return plan.layout.points
.filter((point) => point.timeType === 'LESSON')
.flatMap<ScheduleDto>((point) => {
const entry = entryOf.get(point.id);
if (entry && !entry.isEnabled) return [];
// 全校时间表不能携带默认课程:旧数据即使残留 DefaultClassId 也不参与回退,
// 否则会把这门课泄漏到别的班级。
const fallback =
!entry && point.defaultClassId && plan.layout.classId
? (defaults.get(point.defaultClassId) ?? null)
: null;
const course = entry ? (entry.course ?? null) : fallback;
if (!course) return [];
return [
{
id: entry?.legacyScheduleId || entry?.id || `default:${plan.id}:${point.id}`,
classId: plan.classId, classId: plan.classId,
courseId: e.courseId!, courseId: course.id,
course: e.course!, course,
dayOfWeek: plan.weekday || 1, dayOfWeek: plan.weekday || 1,
startTime: e.timePoint.startTime, startTime: point.startTime,
endTime: e.timePoint.endTime, endTime: point.endTime,
location: e.location, location: entry?.location ?? null,
weekStart: plan.weekStart, weekStart: plan.weekStart,
weekEnd: plan.weekEnd, weekEnd: plan.weekEnd,
weekParity: weekParity:
@@ -43,7 +108,9 @@ export function projectPlan(plan: PlanRow): ScheduleDto[] {
source: 'manual', source: 'manual',
createdAt: plan.createdAt.toISOString(), createdAt: plan.createdAt.toISOString(),
updatedAt: plan.updatedAt.toISOString(), updatedAt: plan.updatedAt.toISOString(),
})) },
];
})
.sort((a, b) => a.startTime.localeCompare(b.startTime)); .sort((a, b) => a.startTime.localeCompare(b.startTime));
} }
@@ -103,8 +170,9 @@ export async function resolveActiveClassPlan(
candidates.find((p) => p.group?.isGlobal) ?? candidates.find((p) => p.group?.isGlobal) ??
candidates.find((p) => !p.associatedGroup) ?? candidates.find((p) => !p.associatedGroup) ??
null; null;
const defaults = plan ? await loadDefaultCourseMap(plan.layout.points) : new Map<string, CourseBrief>();
const schedules = plan const schedules = plan
? projectPlan(plan).map((s) => ({ ? projectPlan(plan, defaults).map((s) => ({
...s, ...s,
dayOfWeek: weekday, dayOfWeek: weekday,
weekStart: week, weekStart: week,
@@ -120,6 +188,8 @@ export async function resolveActiveClassPlan(
points: plan.layout.points.map((p) => ({ points: plan.layout.points.map((p) => ({
...p, ...p,
timeType: p.timeType as 'LESSON' | 'BREAK' | 'DIVIDER' | 'ACTION', timeType: p.timeType as 'LESSON' | 'BREAK' | 'DIVIDER' | 'ACTION',
defaultCourse:
plan.layout.classId && p.defaultClassId ? (defaults.get(p.defaultClassId) ?? null) : null,
})), })),
} }
: null, : null,
+18 -2
View File
@@ -458,7 +458,13 @@ export interface StudentTransferLike {
toClassName: string; toClassName: string;
operatorId: string | null; operatorId: string | null;
operatorName: string; operatorName: string;
mode: string; kind: string;
batchId: string;
academicYear: number | null;
fromGrade: string;
toGrade: string;
fromSchool: string;
toSchool: string;
note?: string | null; note?: string | null;
createdAt: Date; createdAt: Date;
} }
@@ -475,7 +481,15 @@ export function toStudentTransferDto(item: StudentTransferLike): StudentTransfer
toClassName: item.toClassName, toClassName: item.toClassName,
operatorId: item.operatorId, operatorId: item.operatorId,
operatorName: item.operatorName, operatorName: item.operatorName,
mode: item.mode as StudentTransferDto['mode'], kind: item.kind as StudentTransferDto['kind'],
batchId: item.batchId,
academicYear: item.academicYear,
fromGrade: item.fromGrade,
toGrade: item.toGrade,
fromSchool: item.fromSchool,
toSchool: item.toSchool,
mode:
item.kind === 'transfer-out' ? 'transfer-out' : item.kind === 'restore' ? 'restore-transfer' : 'single',
note: item.note ?? '', note: item.note ?? '',
createdAt: toIso(item.createdAt), createdAt: toIso(item.createdAt),
}; };
@@ -492,6 +506,7 @@ export interface ArchivedYearLike {
classCount: number; classCount: number;
studentCount: number; studentCount: number;
transferredCount: number; transferredCount: number;
inactiveCount: number;
homeworkCount: number; homeworkCount: number;
notificationCount: number; notificationCount: number;
archivedAt: Date; archivedAt: Date;
@@ -509,6 +524,7 @@ export function toArchivedYearDto(item: ArchivedYearLike): ArchivedYearDto {
classCount: item.classCount, classCount: item.classCount,
studentCount: item.studentCount, studentCount: item.studentCount,
transferredCount: item.transferredCount, transferredCount: item.transferredCount,
inactiveCount: item.inactiveCount,
homeworkCount: item.homeworkCount, homeworkCount: item.homeworkCount,
notificationCount: item.notificationCount, notificationCount: item.notificationCount,
archivedAt: toIso(item.archivedAt), archivedAt: toIso(item.archivedAt),
@@ -1,32 +0,0 @@
import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js';
import type { Prisma } from '../generated/prisma/client.js';
import type { PermissionKey } from '@classhelper/shared';
/** 范围与功能键正交;显式分组覆盖角色默认,不在范围内一律不放行。 */
export async function teacherScopeWhere(
user: TokenPayload,
permission?: PermissionKey,
): Promise<Prisma.ClassWhereInput> {
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
include: { group: { include: { items: true } } },
});
const own = { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] };
if (!assignments.length) return own;
const alternatives: Prisma.ClassWhereInput[] = [];
for (const { group } of assignments) {
if (permission && !group.items.some((item) => item.key === permission)) continue;
if (group.scope === 'ALL') return {};
if (group.scope === 'SELF_CLASS')
alternatives.push(group.scopeValue === 'HEAD' ? { teacherId: user.sub } : own);
if (group.scope === 'GRADE' || group.scope === 'DIVISION') {
const grades = group.scopeValue
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (grades.length) alternatives.push({ grade: { in: grades } });
}
}
return { OR: alternatives };
}
+11 -59
View File
@@ -1,64 +1,12 @@
import { import {
BUILTIN_TEACHER_GROUPS, BUILTIN_TEACHER_GROUPS,
PERMISSION_KEYS,
PERMISSION_LABELS, PERMISSION_LABELS,
ROLE_DEFAULT_PERMISSIONS, ROLE_DEFAULT_PERMISSIONS,
defaultPermissionsForRole,
type PermissionKey, type PermissionKey,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { prisma } from './db.js'; import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js'; import type { TokenPayload } from './jwt.js';
/**
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
*
* 语义(与 shared 的 `PERMISSION_DEFAULT_ROLES` 一一对应):
* - `ADMIN`:按权限组计算,另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
* 把自己(或最后一个管理员)锁在权限页之外;
* - `CLASS_DEVICE`(ClassHelper 班级端):**空集,且不受权限组影响** —— 班级端的会话主体是
* 班级而非账号,它的能力由班级维度断言(`canManageSubjectContent(..., { allowClassDevice })`
* 等)决定,给某个班级端"分配权限组"没有意义;
* - 其他账号:**有分组**时取这些组的键并集(因此可以比默认更少 ⇒ 也能用来收回权限);
* **没有任何分组**时使用数据库中可编辑的角色默认组(逐条镜像权限组上线前各路由的 `requireRole`)。
*
* 每次请求实时查询(不缓存):权限改动**立即生效**,代价是每请求多一条 `findMany`
* (账号规模很小,这个代价换"改了立刻生效"是值的)。
*/
export async function loadEffectivePermissions(user: TokenPayload): Promise<Set<PermissionKey>> {
const permissions = new Set<PermissionKey>();
if (user.classSession || user.role === 'CLASS_DEVICE') return permissions;
await ensureDefaultGroups();
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
select: { group: { select: { items: { select: { key: true } } } } },
});
if (assignments.length === 0) {
const group = await prisma.permissionGroup.findUnique({
where: { defaultRole: user.role },
include: { items: true },
});
for (const key of group ? group.items.map((item) => item.key) : defaultPermissionsForRole(user.role)) {
if ((PERMISSION_KEYS as readonly string[]).includes(key)) permissions.add(key as PermissionKey);
}
if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
return permissions;
}
const known = new Set<string>(PERMISSION_KEYS);
for (const assignment of assignments) {
for (const item of assignment.group.items) {
// 库里可能留着已下线的键(旧版本写入的),这里静默忽略,避免整账号权限异常
if (known.has(item.key)) permissions.add(item.key as PermissionKey);
}
}
if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
return permissions;
}
/** 首次升级创建默认组;后续启动保留管理员的编辑结果(包括空权限组)。 */ /** 首次升级创建默认组;后续启动保留管理员的编辑结果(包括空权限组)。 */
let defaultGroupsReady: Promise<void> | null = null; let defaultGroupsReady: Promise<void> | null = null;
export function ensureDefaultGroups(): Promise<void> { export function ensureDefaultGroups(): Promise<void> {
@@ -83,10 +31,10 @@ async function createDefaultGroups(): Promise<void> {
}, },
}); });
} }
for (const role of ['ADMIN', 'TEACHER'] as const) { for (const role of ['TEACHER'] as const) {
if (await prisma.permissionGroup.findUnique({ where: { defaultRole: role }, select: { id: true } })) if (await prisma.permissionGroup.findUnique({ where: { defaultRole: role }, select: { id: true } }))
continue; continue;
const baseName = `${role === 'ADMIN' ? '管理员' : '教师'}默认权限`; const baseName = '教师默认权限';
let name = baseName; let name = baseName;
let suffix = 1; let suffix = 1;
// 旧库可能已有同名自定义组,保留它并为角色默认组选一个不冲突的名称。 // 旧库可能已有同名自定义组,保留它并为角色默认组选一个不冲突的名称。
@@ -101,18 +49,22 @@ async function createDefaultGroups(): Promise<void> {
defaultRole: role, defaultRole: role,
name, name,
builtin: true, builtin: true,
scope: role === 'ADMIN' ? 'ALL' : 'SELF_CLASS', scope: 'TEACHING',
description: '未分配其他权限组时自动生效;可调整权限。管理员始终保留权限与系统管理入口。', description: '未显式分配时使用此模板与固定任教班级范围。',
items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) }, items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) },
}, },
}); });
} }
} }
/** 管理员不可被剥夺的权限键(防锁死) */
export const ADMIN_UNREMOVABLE_PERMISSIONS: readonly PermissionKey[] = ['permission:manage', 'system:manage'];
/** 把权限键翻译成中文提示(403 文案与权限页共用) */ /** 把权限键翻译成中文提示(403 文案与权限页共用) */
export function permissionLabel(key: PermissionKey): string { export function permissionLabel(key: PermissionKey): string {
return PERMISSION_LABELS[key]; return PERMISSION_LABELS[key];
} }
export { loadAuthPrincipal } from './authz/principal.js';
export { assertPermission, assertAnyPermission, assertPermissionOrClassDevice } from './authz/assert.js';
import { loadAuthPrincipal } from './authz/principal.js';
export async function hasEffectivePermission(user: TokenPayload, key: PermissionKey) {
return (await loadAuthPrincipal(user)).permissions.has(key);
}
+1
View File
@@ -30,6 +30,7 @@ export const SETTING_KEYS = {
termStartDate: 'term.startDate', termStartDate: 'term.startDate',
termMaxWeeks: 'term.maxWeeks', termMaxWeeks: 'term.maxWeeks',
promotionAt: 'term.promotionAt', promotionAt: 'term.promotionAt',
promotionPlan: 'term.promotionPlan',
} as const; } as const;
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS]; export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
+38 -1
View File
@@ -1,3 +1,6 @@
import { refreshClassSubscriptions } from '../realtime/bus.js';
import { invalidateAuthPrincipals } from './authz/principal.js';
import { migratePermissionScope } from '@classhelper/shared';
import type { PrismaClient } from '../generated/prisma/client.js'; import type { PrismaClient } from '../generated/prisma/client.js';
import type { DatabaseProvider } from './db.js'; import type { DatabaseProvider } from './db.js';
@@ -34,6 +37,7 @@ export const SNAPSHOT_VERSION = 1;
*/ */
export const SNAPSHOT_TABLES = [ export const SNAPSHOT_TABLES = [
'User', 'User',
'PermissionAudit',
'PermissionGroup', 'PermissionGroup',
'PermissionGroupItem', 'PermissionGroupItem',
'UserPermissionGroup', 'UserPermissionGroup',
@@ -138,7 +142,7 @@ export function assertValidSnapshot(value: unknown): asserts value is DatabaseSn
for (const table of SNAPSHOT_TABLES) { for (const table of SNAPSHOT_TABLES) {
const rows = (snapshot.data as Record<string, unknown>)[table]; const rows = (snapshot.data as Record<string, unknown>)[table];
// 旧版本快照没有课件表;允许恢复,文件实体仍需单独备份。 // 旧版本快照没有课件表;允许恢复,文件实体仍需单独备份。
if (rows === undefined && table.startsWith('Courseware')) continue; if (rows === undefined && (table.startsWith('Courseware') || table === 'PermissionAudit')) continue;
if (!Array.isArray(rows)) { if (!Array.isArray(rows)) {
throw new Error(`快照缺少表 ${table} 的数据`); throw new Error(`快照缺少表 ${table} 的数据`);
} }
@@ -182,6 +186,37 @@ export async function restoreSnapshot(
} }
for (const table of SNAPSHOT_TABLES) { for (const table of SNAPSHOT_TABLES) {
const rows = (snapshot.data[table] ?? []).map((row) => { const rows = (snapshot.data[table] ?? []).map((row) => {
if (table === 'PermissionGroup') {
const mapped = migratePermissionScope(
String(row.scope ?? 'TEACHING'),
String(row.scopeValue ?? ''),
);
return {
...row,
...mapped,
...(row.defaultRole === 'TEACHER' ? { scope: 'TEACHING', scopeValue: '' } : {}),
};
}
if (table === 'UserPermissionGroup' && row.scope === undefined) {
const group = snapshot.data.PermissionGroup.find((g) => g.id === row.groupId);
return {
...row,
...migratePermissionScope(String(group?.scope ?? 'TEACHING'), String(group?.scopeValue ?? '')),
};
}
if (table === 'StudentClassTransfer') {
const { mode, ...rest } = row;
return {
...rest,
kind:
rest.kind ??
(mode === 'transfer-out'
? 'transfer-out'
: mode === 'restore-transfer'
? 'restore'
: 'class-change'),
};
}
if (table !== 'Class' || typeof row !== 'object' || row === null) return row; if (table !== 'Class' || typeof row !== 'object' || row === null) return row;
const copy = { ...(row as Record<string, unknown>) }; const copy = { ...(row as Record<string, unknown>) };
delete copy.notificationChannel; delete copy.notificationChannel;
@@ -195,6 +230,8 @@ export async function restoreSnapshot(
} finally { } finally {
await setForeignKeys(prisma, provider, true).catch(() => undefined); await setForeignKeys(prisma, provider, true).catch(() => undefined);
} }
invalidateAuthPrincipals();
refreshClassSubscriptions();
return { counts }; return { counts };
} }
@@ -0,0 +1,171 @@
import type { Prisma } from '../generated/prisma/client.js';
import { randomUUID } from 'node:crypto';
import { currentAcademicYearStart, SOCKET_EVENTS, type StudentLifecycleKind } from '@classhelper/shared';
import { prisma } from './db.js';
import { ApiError } from './http.js';
import { assertCanManageRoster } from './access.js';
import type { TokenPayload } from './jwt.js';
import { toStudentTransferDto } from './mappers.js';
import { emitToClass } from '../realtime/bus.js';
export function notifyStudentClasses(ids: (string | null)[]) {
for (const classId of new Set(ids.filter((id): id is string => Boolean(id))))
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
}
/** 生命周期写入只有此入口;数据、班级快照与历史在同一事务提交。 */
export async function changeStudentLifecycle(
user: TokenPayload,
input: {
studentIds: string[];
kind: StudentLifecycleKind;
toClassId?: string | null;
note?: string;
fromSchool?: string;
toSchool?: string;
allowGradeChange?: boolean;
newStudent?: {
studentNo: string;
name: string;
gender?: string;
guardianPhone?: string;
status?: 'active' | 'inactive';
};
},
transaction?: Prisma.TransactionClient,
) {
const batchId = randomUUID();
const ids = [...new Set(input.studentIds)];
const existing = ids.length
? await (transaction ?? prisma).student.findMany({ where: { id: { in: ids } } })
: [];
if (existing.length !== ids.length) throw ApiError.notFound('部分学生不存在,请刷新后重试');
for (const cls of new Set(existing.map((student) => student.classId)))
await assertCanManageRoster(user, cls ?? undefined, transaction);
if (input.toClassId) await assertCanManageRoster(user, input.toClassId, transaction);
else if (!existing.length) await assertCanManageRoster(user, undefined, transaction);
const changedClasses: (string | null)[] = [];
const work = async (tx: Prisma.TransactionClient) => {
const target = input.toClassId ? await tx.class.findUnique({ where: { id: input.toClassId } }) : null;
if (input.toClassId && !target) throw ApiError.badRequest('目标班级不存在');
if (target?.archivedYearId) throw ApiError.conflict('目标班级已归档,不能调入');
if (['transfer-in', 'restore'].includes(input.kind) && !target)
throw ApiError.badRequest('请指定在读班级');
const students = ids.length
? await tx.student.findMany({ where: { id: { in: ids } }, include: { class: true } })
: [];
if (input.newStudent) {
if (await tx.student.findUnique({ where: { studentNo: input.newStudent.studentNo } }))
throw ApiError.conflict('学号已被占用,请使用转入或复学入口');
const created = await tx.student.create({
data: { ...input.newStudent, classId: target?.id ?? null },
include: { class: true },
});
students.push(created);
}
if (!students.length) throw ApiError.badRequest('请至少选择一名学生');
const records = [];
for (const student of students) {
const isNew = Boolean(input.newStudent);
const prior = existing.find((item) => item.id === student.id);
if (
prior &&
(prior.classId !== student.classId ||
prior.status !== student.status ||
prior.archivedYearId !== student.archivedYearId)
)
throw ApiError.conflict('学生状态已变化,请刷新后重试');
// 撤回转出是唯一允许将归档转出记录迁入其他在读班的路径。
if (
student.status === 'graduated' ||
(!isNew &&
(student.archivedYearId || student.class?.archivedYearId) &&
!(student.status === 'transferred' && input.kind === 'restore'))
)
throw ApiError.conflict('该生已毕业或已归档,请先在归档页撤销归档');
if (!isNew) {
const allowed: Record<StudentLifecycleKind, string[]> = {
'class-change': ['active'],
unassign: ['active'],
'transfer-out': ['active', 'inactive'],
'transfer-in': ['transferred', 'inactive'],
restore: ['transferred', 'inactive'],
deactivate: ['active'],
};
if (!allowed[input.kind].includes(student.status))
throw ApiError.conflict('当前学生状态不允许此操作,请刷新名单并使用对应生命周期入口');
}
const from = isNew ? null : student.class;
if (!isNew && input.kind === 'class-change' && student.classId === target?.id) continue;
if (
input.kind === 'class-change' &&
from &&
target &&
from.grade !== target.grade &&
!(user.role === 'ADMIN' && input.allowGradeChange && input.note?.trim())
)
throw ApiError.conflict('跨年级调班需要管理员勾选允许跨年级并填写备注');
const keepClass = ['transfer-out', 'deactivate'].includes(input.kind);
const nextClassId = keepClass ? student.classId : (target?.id ?? null);
const nextStatus =
input.kind === 'transfer-out'
? 'transferred'
: input.kind === 'deactivate'
? 'inactive'
: isNew
? student.status
: 'active';
await tx.student.update({
where: { id: student.id },
data: {
classId: nextClassId,
status: nextStatus,
...(input.kind === 'transfer-out'
? { transferredAt: new Date(), transferNote: input.note ?? '' }
: {}),
...(['transfer-in', 'restore'].includes(input.kind)
? { transferredAt: null, archivedYearId: null, archivedAt: null }
: {}),
},
});
records.push(
await tx.studentClassTransfer.create({
data: {
studentId: student.id,
studentNo: student.studentNo,
studentName: student.name,
kind: input.kind,
batchId,
academicYear: currentAcademicYearStart(),
fromClassId: from?.id ?? null,
fromClassName: from?.name ?? '',
fromGrade: from?.grade ?? '',
toClassId: keepClass ? null : (target?.id ?? null),
toClassName:
input.kind === 'transfer-out'
? '转出本校'
: input.kind === 'deactivate'
? '停用'
: (target?.name ?? '未分班'),
toGrade: keepClass ? '' : (target?.grade ?? ''),
fromSchool: input.fromSchool ?? '',
toSchool: input.toSchool ?? '',
operatorId: user.sub,
operatorName: user.name,
note: input.note ?? '',
},
}),
);
changedClasses.push(from?.id ?? null, nextClassId);
}
return {
batchId,
count: records.length,
transfers: records.map(toStudentTransferDto),
studentId: students[0].id,
};
};
const result = transaction ? await work(transaction) : await prisma.$transaction(work);
if (!transaction) notifyStudentClasses(changedClasses);
return result;
}
+3 -3
View File
@@ -1,7 +1,7 @@
/** 所有在读入口共用;归档/转出专页显式读取历史状态。 */ /** 所有在读入口共用;归档/转出专页显式读取历史状态。 */
export function activeStudentWhere() { export function activeStudentWhere() {
return { status: { notIn: ['transferred', 'inactive'] } }; return { status: 'active', archivedYearId: null };
} }
export function isActiveStudent(student: { status: string }) { export function isActiveStudent(student: { status: string; archivedYearId?: string | null }) {
return !activeStudentWhere().status.notIn.includes(student.status); return student.status === 'active' && !student.archivedYearId;
} }
+8 -41
View File
@@ -1,9 +1,9 @@
import type { Request, RequestHandler } from 'express'; import type { Request, RequestHandler } from 'express';
import type { PermissionKey, UserRole } from '@classhelper/shared'; import type { PermissionKey, UserRole } from '@classhelper/shared';
import { PERMISSION_LABELS } from '@classhelper/shared'; import { PERMISSION_LABELS } from '@classhelper/shared';
import { prisma } from '../lib/db.js';
import { ApiError } from '../lib/http.js'; import { ApiError } from '../lib/http.js';
import { loadEffectivePermissions } from '../lib/permissions.js'; import { loadAuthPrincipal } from '../lib/authz/index.js';
import { loadSessionUser } from '../lib/authz/session.js';
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js'; import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
/** /**
@@ -21,43 +21,8 @@ export function authenticate(): RequestHandler {
const payload = verifyToken(token); const payload = verifyToken(token);
// ClassHelper 班级端:主体是**班级**而不是某个账号,回查 Class 表 const user = await loadSessionUser(payload);
if (payload.classSession) { req.auth = { token, user, permissions: (await loadAuthPrincipal(user)).permissions };
const record = await prisma.class.findUnique({
where: { id: payload.classId ?? payload.sub },
select: { id: true, name: true, code: true },
});
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
const user: TokenPayload = {
sub: record.id,
username: record.code,
name: record.name,
role: 'CLASS_DEVICE',
classId: record.id,
classSession: true,
classCode: record.code,
};
req.auth = { token, user, permissions: await loadEffectivePermissions(user) };
next();
return;
}
// 账号(教师 / 管理员):`User` 是纯账号表,学生不在里面
const user = await prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, username: true, name: true, role: true },
});
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
const authUser: TokenPayload = {
sub: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: null,
};
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
next(); next();
} catch (error) { } catch (error) {
next(error); next(error);
@@ -84,7 +49,7 @@ export function requireRole(...roles: UserRole[]): RequestHandler {
/** /**
* 权限守卫:要求当前登录主体拥有**全部**列出的权限键。 * 权限守卫:要求当前登录主体拥有**全部**列出的权限键。
* *
* 权限集合来自 {@link loadEffectivePermissions}(权限组并集,或角色默认模板)。 * 权限集合来自 {@link loadAuthPrincipal}(管理员全集,或教师模板与分配)。
* 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。 * 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。
*/ */
export function requirePermission(...keys: PermissionKey[]): RequestHandler { export function requirePermission(...keys: PermissionKey[]): RequestHandler {
@@ -105,7 +70,7 @@ export function requirePermission(...keys: PermissionKey[]): RequestHandler {
} }
/** 班级端按班级范围开放读取;教师/管理员仍按可编辑的功能权限校验。 */ /** 班级端按班级范围开放读取;教师/管理员仍按可编辑的功能权限校验。 */
export function requireStaffPermission(...keys: PermissionKey[]): RequestHandler { export function requirePermissionOrClassDevice(...keys: PermissionKey[]): RequestHandler {
const guard = requirePermission(...keys); const guard = requirePermission(...keys);
return (req, res, next) => { return (req, res, next) => {
if (req.auth?.user.role === 'CLASS_DEVICE') return next(); if (req.auth?.user.role === 'CLASS_DEVICE') return next();
@@ -153,3 +118,5 @@ export function getAuthPermissions(req: Request): ReadonlySet<PermissionKey> {
export function hasAuthPermission(req: Request, key: PermissionKey): boolean { export function hasAuthPermission(req: Request, key: PermissionKey): boolean {
return (req.auth?.permissions ?? new Set<PermissionKey>()).has(key); return (req.auth?.permissions ?? new Set<PermissionKey>()).has(key);
} }
export const requireAdmin = (): RequestHandler => requireRole('ADMIN');
+14 -2
View File
@@ -1,5 +1,6 @@
import type { Request, RequestHandler } from 'express'; import type { Request, RequestHandler } from 'express';
import type { ZodType } from 'zod'; import { ZodError, type ZodType } from 'zod';
import { ApiError } from '../lib/http.js';
interface ValidationTargets<B, Q, P> { interface ValidationTargets<B, Q, P> {
body?: ZodType<B>; body?: ZodType<B>;
@@ -14,6 +15,7 @@ interface ValidationTargets<B, Q, P> {
*/ */
export function validate<B = unknown, Q = unknown, P = unknown>( export function validate<B = unknown, Q = unknown, P = unknown>(
targets: ValidationTargets<B, Q, P>, targets: ValidationTargets<B, Q, P>,
errorStatus: 400 | 422 = 422,
): RequestHandler { ): RequestHandler {
return (req, _res, next) => { return (req, _res, next) => {
try { try {
@@ -22,11 +24,21 @@ export function validate<B = unknown, Q = unknown, P = unknown>(
if (targets.params) req.validatedParams = targets.params.parse(req.params) as unknown; if (targets.params) req.validatedParams = targets.params.parse(req.params) as unknown;
next(); next();
} catch (error) { } catch (error) {
next(error); next(
errorStatus === 400 && error instanceof ZodError
? ApiError.badRequest('请求参数不合法', error.issues)
: error,
);
} }
}; };
} }
export function validateBadRequest<B = unknown, Q = unknown, P = unknown>(
targets: ValidationTargets<B, Q, P>,
): RequestHandler {
return validate(targets, 400);
}
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */ /** 读取已校验的 query(类型由调用方用 zod schema 保证) */
/** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */ /** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */
export function validatedBody<T>(req: Request): T { export function validatedBody<T>(req: Request): T {
+24 -5
View File
@@ -1,3 +1,5 @@
import type { PermissionKey } from '@classhelper/shared';
import { assertAdmin, assertPermission, classIdsFor } from '../../lib/authz/index.js';
import { activeStudentWhere } from '../../lib/students.js'; import { activeStudentWhere } from '../../lib/students.js';
import { import {
dayKeyLocal, dayKeyLocal,
@@ -84,6 +86,7 @@ export async function updateAiSettings(
user: TokenPayload, user: TokenPayload,
input: UpdateAiSettingsInput, input: UpdateAiSettingsInput,
): Promise<AiSettingsDto> { ): Promise<AiSettingsDto> {
assertAdmin(user);
if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false'); if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false');
if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider); if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider);
if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim()); if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim());
@@ -105,15 +108,31 @@ interface AssistantContext {
} }
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> { async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
if (classId) await assertClassAccess(user, classId); if (classId) await assertClassAccess(user, classId, 'ai:use');
const classIds = await getAccessibleClassIds(user); await assertPermission(user, 'ai:use');
const candidates = new Set(
(
await Promise.all(
['notification:publish', 'homework:write', 'call:create'].map((key) =>
classIdsFor(user, key as PermissionKey),
),
)
).flat(),
);
const classIds = (await getAccessibleClassIds(user, 'ai:use')).filter((id) => candidates.has(id));
const classes = await prisma.class.findMany({ const classes = await prisma.class.findMany({
where: { id: { in: classIds } }, where: {
id: { in: classIds },
...(user.role !== 'ADMIN'
? { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] }
: {}),
},
select: { id: true, name: true }, select: { id: true, name: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
take: 80, take: 80,
}); });
const courses = classId const courses =
classId && classIds.includes(classId)
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } }) ? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
: []; : [];
return { classes, courses }; return { classes, courses };
@@ -345,7 +364,7 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
} }
if (typeof params.classId === 'string') { if (typeof params.classId === 'string') {
await assertClassAccess(user, params.classId); await assertClassAccess(user, params.classId, 'ai:use');
if (typeof params.courseId === 'string') { if (typeof params.courseId === 'string') {
const course = await prisma.course.findFirst({ const course = await prisma.course.findFirst({
where: { id: params.courseId, classId: params.classId }, where: { id: params.courseId, classId: params.classId },
@@ -1,3 +1,5 @@
import { requireAdmin } from '../../middleware/auth.js';
import { restoreStudentSchema } from '../students/students.schemas.js';
import { listTransferred, restoreTransferred } from './transferred.service.js'; import { listTransferred, restoreTransferred } from './transferred.service.js';
import { Router } from 'express'; import { Router } from 'express';
import { sendOk } from '../../lib/http.js'; import { sendOk } from '../../lib/http.js';
@@ -14,19 +16,20 @@ import {
import * as archiveService from './archives.service.js'; import * as archiveService from './archives.service.js';
const router = Router(); const router = Router();
router.use(authenticate()); router.use(authenticate(), requireAdmin());
router.get('/transferred', requirePermission('archive:manage'), async (req, res) => router.get('/transferred', requirePermission('archive:manage'), async (req, res) =>
sendOk(res, await listTransferred(getAuthUser(req))), sendOk(res, await listTransferred(getAuthUser(req))),
); );
router.post( router.post(
'/transferred/:id/restore', '/transferred/:id/restore',
requirePermission('archive:manage'), requirePermission('roster:manage'),
validate({ params: idParamSchema }), validate({ params: idParamSchema, body: restoreStudentSchema }),
async (req, res) => sendOk(res, await restoreTransferred(getAuthUser(req), String(req.params.id))), async (req, res) =>
sendOk(res, await restoreTransferred(getAuthUser(req), String(req.params.id), req.body)),
); );
/** /**
* 毕业归档(**全部接口仅管理员**)。 * 毕业归档(**归档操作按 archive:manage 与全校范围校验**)。
* *
* 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里, * 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里,
* 通过 `GET /:id/classes/:classId/content` 只读查看。 * 通过 `GET /:id/classes/:classId/content` 只读查看。
@@ -49,7 +52,7 @@ router.get('/summary', requirePermission('archive:manage'), async (req, res) =>
sendOk( sendOk(
res, res,
{ {
...(await archiveService.getArchiveSummary()), ...(await archiveService.getArchiveSummary(getAuthUser(req))),
suggestedEnrollmentYears: suggestedEnrollmentYears(), suggestedEnrollmentYears: suggestedEnrollmentYears(),
}, },
'获取归档汇总成功', '获取归档汇总成功',
@@ -2,14 +2,6 @@ import { z } from 'zod';
import { currentAcademicYearStart, graduationYearOf } from '@classhelper/shared'; import { currentAcademicYearStart, graduationYearOf } from '@classhelper/shared';
import { enrollmentYearSchema } from '../../lib/schemas.js'; import { enrollmentYearSchema } from '../../lib/schemas.js';
/** 执行毕业归档:按**届别**(入学年份)把整届班级与学生归档 */
export const createArchiveSchema = z.object({
enrollmentYear: enrollmentYearSchema,
/** 毕业年份;留空 = 入学年份 + 默认学制(3 年) */
graduationYear: z.coerce.number().int().min(2000).max(2200).optional(),
note: z.string().trim().max(200).optional(),
});
/** 届别列表的查询参数 */ /** 届别列表的查询参数 */
export const listArchivesQuerySchema = z.object({ export const listArchivesQuerySchema = z.object({
/** 只看某一届 */ /** 只看某一届 */
@@ -1,12 +1,10 @@
import { import {
SOCKET_EVENTS, SOCKET_EVENTS,
formatYearLabel,
graduationYearOf,
type ArchivedClassContentDto, type ArchivedClassContentDto,
type ArchivedYearDetailDto, type ArchivedYearDetailDto,
type ArchivedYearDto, type ArchivedYearDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { assertCanManageClasses } from '../../lib/access.js'; import { assertGlobalPermission } from '../../lib/access.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
@@ -19,10 +17,6 @@ import {
} from '../../lib/mappers.js'; } from '../../lib/mappers.js';
import { emitToClass } from '../../realtime/bus.js'; import { emitToClass } from '../../realtime/bus.js';
import { logger } from '../../lib/logger.js'; import { logger } from '../../lib/logger.js';
import type { createArchiveSchema } from './archives.schemas.js';
import type { z } from 'zod';
type CreateArchiveInput = z.infer<typeof createArchiveSchema>;
/** /**
* 毕业归档。 * 毕业归档。
@@ -36,12 +30,12 @@ type CreateArchiveInput = z.infer<typeof createArchiveSchema>;
* **未毕业而升级的班级不归档**:升级只改 `Class.grade`(高一 → 高二), * **未毕业而升级的班级不归档**:升级只改 `Class.grade`(高一 → 高二),
* 班级记录、学生名单、学号、作业成绩全部沿用(见 `classes.service.promoteClasses`)。 * 班级记录、学生名单、学号、作业成绩全部沿用(见 `classes.service.promoteClasses`)。
* *
* 全部接口**仅管理员**。 * 全部接口受 archive:manage 与全校范围保护。
*/ */
/** 届别列表(按入学年份倒序) */ /** 届别列表(按入学年份倒序) */
export async function listArchivedYears(user: TokenPayload): Promise<ArchivedYearDto[]> { export async function listArchivedYears(user: TokenPayload): Promise<ArchivedYearDto[]> {
assertCanManageClasses(user); await assertGlobalPermission(user, 'archive:manage');
const records = await prisma.archivedYear.findMany({ orderBy: { enrollmentYear: 'desc' } }); const records = await prisma.archivedYear.findMany({ orderBy: { enrollmentYear: 'desc' } });
return records.map(toArchivedYearDto); return records.map(toArchivedYearDto);
} }
@@ -51,7 +45,7 @@ export async function getArchivedYearDetail(
user: TokenPayload, user: TokenPayload,
archiveId: string, archiveId: string,
): Promise<ArchivedYearDetailDto> { ): Promise<ArchivedYearDetailDto> {
assertCanManageClasses(user); await assertGlobalPermission(user, 'archive:manage');
const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } }); const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } });
if (!record) throw ApiError.notFound('该届别档案不存在'); if (!record) throw ApiError.notFound('该届别档案不存在');
@@ -72,11 +66,18 @@ export async function getArchivedYearDetail(
]); ]);
const dtos = students.map(toStudentDto); const dtos = students.map(toStudentDto);
const transferredIn = await prisma.studentClassTransfer.findMany({
where: { kind: 'transfer-in', student: { archivedYearId: archiveId } },
distinct: ['studentId'],
select: { studentId: true },
});
return { return {
...toArchivedYearDto(record), ...toArchivedYearDto(record),
transferInCount: transferredIn.length,
classes: classes.map(toArchivedClassDto), classes: classes.map(toArchivedClassDto),
graduates: dtos.filter((item) => item.status === 'graduated'), graduates: dtos.filter((item) => item.status === 'graduated'),
transferred: dtos.filter((item) => item.status === 'transferred'), transferred: dtos.filter((item) => item.status === 'transferred'),
inactive: dtos.filter((item) => item.status === 'inactive'),
}; };
} }
@@ -92,7 +93,7 @@ export async function getArchivedClassContent(
classId: string, classId: string,
limit = 200, limit = 200,
): Promise<ArchivedClassContentDto> { ): Promise<ArchivedClassContentDto> {
assertCanManageClasses(user); await assertGlobalPermission(user, 'archive:manage');
const record = await prisma.class.findUnique({ const record = await prisma.class.findUnique({
where: { id: classId }, where: { id: classId },
select: { id: true, name: true, archivedYearId: true }, select: { id: true, name: true, archivedYearId: true },
@@ -132,99 +133,6 @@ export async function getArchivedClassContent(
}; };
} }
/**
* 执行归档:把某一届的全部班级与学生标记为已归档。
*
* 步骤:
* 1. 找到该届**尚未归档**的班级(`enrollmentYear` 匹配且 `archivedYearId` 为空);
* 2. 把这些班的学生标记为 `graduated`;把其中已经 `transferred` 的保持原状态;
* 3. 统计班级数 / 学生数 / 转出数 / 作业数 / 通知数写进档案快照;
* 4. 全部在**一个事务**里完成,中途失败不会留下"班归档了、学生没归档"的半截状态。
*/
export async function createArchive(
user: TokenPayload,
input: CreateArchiveInput,
): Promise<ArchivedYearDetailDto> {
assertCanManageClasses(user);
const existing = await prisma.archivedYear.findUnique({
where: { enrollmentYear: input.enrollmentYear },
select: { id: true },
});
if (existing) throw ApiError.conflict(`${formatYearLabel(input.enrollmentYear)} 已经归档过了`);
const classes = await prisma.class.findMany({
where: { enrollmentYear: input.enrollmentYear, archivedYearId: null },
select: { id: true },
});
if (classes.length === 0) {
throw ApiError.badRequest(
`没有找到 ${formatYearLabel(input.enrollmentYear)} 的在读班级(可能已归档,或入学年份没填对)`,
);
}
const classIds = classes.map((item) => item.id);
const [students, homeworkCount, notificationCount] = await Promise.all([
prisma.student.findMany({
where: { classId: { in: classIds }, archivedYearId: null },
select: { id: true, status: true },
}),
prisma.homework.count({ where: { classId: { in: classIds } } }),
prisma.notification.count({ where: { classId: { in: classIds } } }),
]);
const transferredCount = students.filter((item) => item.status === 'transferred').length;
const graduateCount = students.length - transferredCount;
const created = await prisma.$transaction(async (tx) => {
const archive = await tx.archivedYear.create({
data: {
enrollmentYear: input.enrollmentYear,
graduationYear: input.graduationYear ?? graduationYearOf(input.enrollmentYear),
name: formatYearLabel(input.enrollmentYear),
note: input.note ?? '',
operatorId: user.sub,
operatorName: user.name,
classCount: classes.length,
studentCount: graduateCount,
transferredCount,
homeworkCount,
notificationCount,
},
select: { id: true },
});
const now = new Date();
await tx.class.updateMany({
where: { id: { in: classIds } },
data: { archivedYearId: archive.id, archivedAt: now },
});
// 在读的 → 已毕业;已经转出的保持 transferred(他们的归档记录也一并挂到这一届)
if (students.length > 0) {
await tx.student.updateMany({
where: { id: { in: students.filter((s) => s.status !== 'transferred').map((s) => s.id) } },
data: { status: 'graduated', archivedYearId: archive.id, archivedAt: now },
});
await tx.student.updateMany({
where: { id: { in: students.filter((s) => s.status === 'transferred').map((s) => s.id) } },
data: { archivedYearId: archive.id, archivedAt: now },
});
}
return archive;
});
for (const classId of classIds) {
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
}
logger.info(
`毕业归档:${formatYearLabel(input.enrollmentYear)} 班级=${classes.length} 毕业生=${graduateCount} 转出=${transferredCount} 由 ${user.name} 执行`,
);
return getArchivedYearDetail(user, created.id);
}
/** /**
* 撤销归档(管理员误操作的退路)。 * 撤销归档(管理员误操作的退路)。
* *
@@ -232,7 +140,7 @@ export async function createArchive(
* **不会删除任何作业或通知** —— 它们本来就没被删过。 * **不会删除任何作业或通知** —— 它们本来就没被删过。
*/ */
export async function deleteArchive(user: TokenPayload, archiveId: string): Promise<void> { export async function deleteArchive(user: TokenPayload, archiveId: string): Promise<void> {
assertCanManageClasses(user); await assertGlobalPermission(user, 'archive:manage');
const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } }); const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } });
if (!record) throw ApiError.notFound('该届别档案不存在'); if (!record) throw ApiError.notFound('该届别档案不存在');
@@ -265,12 +173,13 @@ export async function deleteArchive(user: TokenPayload, archiveId: string): Prom
} }
/** 归档页顶部的汇总(一届一行之外的全局数字) */ /** 归档页顶部的汇总(一届一行之外的全局数字) */
export async function getArchiveSummary(): Promise<{ export async function getArchiveSummary(user: TokenPayload): Promise<{
archiveCount: number; archiveCount: number;
classCount: number; classCount: number;
graduateCount: number; graduateCount: number;
transferredCount: number; transferredCount: number;
}> { }> {
await assertGlobalPermission(user, 'archive:manage');
const [archiveCount, classCount, graduateCount, transferredCount] = await Promise.all([ const [archiveCount, classCount, graduateCount, transferredCount] = await Promise.all([
prisma.archivedYear.count(), prisma.archivedYear.count(),
prisma.class.count({ where: { NOT: { archivedYearId: null } } }), prisma.class.count({ where: { NOT: { archivedYearId: null } } }),
@@ -1,10 +1,9 @@
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { assertCanManageClasses } from '../../lib/access.js'; import { assertGlobalPermission } from '../../lib/access.js';
import { restoreStudent } from '../students/students.service.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { ApiError } from '../../lib/http.js';
import { notifyScheduleChanged } from '../class-plans/class-plans.service.js';
export async function listTransferred(user: TokenPayload) { export async function listTransferred(user: TokenPayload) {
assertCanManageClasses(user); await assertGlobalPermission(user, 'archive:manage');
return prisma.student.findMany({ return prisma.student.findMany({
where: { status: 'transferred' }, where: { status: 'transferred' },
select: { select: {
@@ -19,31 +18,11 @@ export async function listTransferred(user: TokenPayload) {
orderBy: { transferredAt: 'desc' }, orderBy: { transferredAt: 'desc' },
}); });
} }
export async function restoreTransferred(user: TokenPayload, id: string) { export async function restoreTransferred(
assertCanManageClasses(user); user: TokenPayload,
const student = await prisma.student.findUnique({ where: { id }, include: { class: true } }); id: string,
if (student?.status !== 'transferred' || !student.classId || student.class?.archivedYearId) input: { toClassId?: string; note?: string } = {},
throw ApiError.badRequest('只能撤回原班仍在读的转出记录'); ) {
await prisma.$transaction([ await restoreStudent(user, id, input);
prisma.student.update({
where: { id },
data: { status: 'active', transferredAt: null, archivedAt: null, archivedYearId: null },
}),
prisma.studentClassTransfer.create({
data: {
studentId: id,
studentNo: student.studentNo,
studentName: student.name,
fromClassId: null,
toClassId: student.classId,
toClassName: student.class?.name ?? '',
operatorId: user.sub,
operatorName: user.name,
mode: 'restore-transfer',
note: '撤回转出,保留原转出记录',
},
}),
]);
await notifyScheduleChanged(student.classId);
return { id, restored: true }; return { id, restored: true };
} }
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated } from '../../lib/http.js'; import { sendCreated } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { authenticate, getAuthUser, requirePermissionOrClassDevice } from '../../middleware/auth.js';
import { validate } from '../../middleware/validate.js'; import { validate } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { createCallSchema, type CreateCallInput } from './calls.schemas.js'; import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
@@ -13,10 +13,15 @@ router.use(authenticate());
* POST /api/calls - 叫人(老师点名让学生过来) * POST /api/calls - 叫人(老师点名让学生过来)
* ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。 * ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
*/ */
router.post('/', requirePermission('call:create'), validate({ body: createCallSchema }), async (req, res) => { router.post(
'/',
requirePermissionOrClassDevice('call:create'),
validate({ body: createCallSchema }),
async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生'); sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生');
}); },
);
export const callsModule = defineModule({ export const callsModule = defineModule({
name: 'calls', name: 'calls',
@@ -1,5 +1,7 @@
import { assertPublish } from '../../lib/authz/index.js';
import { assertPermissionOrClassDevice } from '../../lib/permissions.js';
import { resolveContentCreator } from '../../lib/access.js';
import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared'; import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared';
import { assertCanPublishNotification } from '../../lib/access.js';
import { isActiveStudent } from '../../lib/students.js'; import { isActiveStudent } from '../../lib/students.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
@@ -27,7 +29,8 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
* 全班都看得到在叫谁,这与需求「教室机器代全班」一致。 * 全班都看得到在叫谁,这与需求「教室机器代全班」一致。
*/ */
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> { export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
await assertCanPublishNotification(user, input.classId); await assertPermissionOrClassDevice(user, 'call:create');
await assertPublish(user, 'call:create', input.classId, { allowClassDevice: true });
const student = await prisma.student.findUnique({ const student = await prisma.student.findUnique({
where: { id: input.studentId }, where: { id: input.studentId },
@@ -54,7 +57,7 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
// 紧急叫人 → URGENT(客户端无视上课时段立即展开);普通叫人 → HIGH // 紧急叫人 → URGENT(客户端无视上课时段立即展开);普通叫人 → HIGH
// (不会触发上课时段 409 拦截,但客户端按普通通知排队处理) // (不会触发上课时段 409 拦截,但客户端按普通通知排队处理)
priority: input.urgent ? 'URGENT' : 'HIGH', priority: input.urgent ? 'URGENT' : 'HIGH',
createdBy: user.sub, createdBy: await resolveContentCreator(user, input.classId),
assignDate: dayKeyLocal(new Date()), assignDate: dayKeyLocal(new Date()),
}, },
include: { creator: creatorSelect, reads: true }, include: { creator: creatorSelect, reads: true },
@@ -6,7 +6,13 @@ import { prisma } from '../../lib/db.js';
import { assertCanManageSchedule, assertClassAccess } from '../../lib/access.js'; import { assertCanManageSchedule, assertClassAccess } from '../../lib/access.js';
import { sendOk, sendCreated, ApiError } from '../../lib/http.js'; import { sendOk, sendCreated, ApiError } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js'; import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import {
authenticate,
getAuthUser,
requirePermissionOrClassDevice,
requirePermission,
requireAnyPermission,
} from '../../middleware/auth.js';
import { validate, validatedQuery } from '../../middleware/validate.js'; import { validate, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import * as schemas from './class-plans.schemas.js'; import * as schemas from './class-plans.schemas.js';
@@ -14,31 +20,39 @@ import * as service from './class-plans.service.js';
const layouts = Router(); const layouts = Router();
layouts.use(authenticate()); layouts.use(authenticate());
layouts.get('/', validate({ query: schemas.scopeQuery }), async (req, res) => layouts.get(
sendOk(res, await service.listLayouts(getAuthUser(req), validatedQuery<{ classId?: string }>(req).classId)), '/',
requirePermissionOrClassDevice('schedule:read'),
validate({ query: schemas.scopeQuery }),
async (req, res) =>
sendOk(
res,
await service.listLayouts(getAuthUser(req), validatedQuery<{ classId?: string }>(req).classId),
),
); );
layouts.post( layouts.post(
'/', '/',
requirePermission('timetable:manage'), requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ body: schemas.layoutSchema }), validate({ body: schemas.layoutSchema }),
async (req, res) => sendCreated(res, await service.saveLayout(req.body)), async (req, res) => sendCreated(res, await service.saveLayout(getAuthUser(req), req.body)),
); );
layouts.put( layouts.put(
'/:id', '/:id',
requirePermission('timetable:manage'), requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema, body: schemas.layoutSchema }), validate({ params: idParamSchema, body: schemas.layoutSchema }),
async (req, res) => sendOk(res, await service.saveLayout(req.body, String(req.params.id))), async (req, res) =>
sendOk(res, await service.saveLayout(getAuthUser(req), req.body, String(req.params.id))),
); );
layouts.post( layouts.post(
'/:id/clone', '/:id/clone',
requirePermission('timetable:manage'), requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema, body: schemas.cloneSchema }), validate({ params: idParamSchema, body: schemas.cloneSchema }),
async (req, res) => { async (req, res) => {
const current = (await service.listLayouts(getAuthUser(req))).find((p) => p.id === req.params.id); const current = (await service.listLayouts(getAuthUser(req))).find((p) => p.id === req.params.id);
if (!current) throw ApiError.notFound('时间表不存在'); if (!current) throw ApiError.notFound('时间表不存在');
sendCreated( sendCreated(
res, res,
await service.saveLayout({ await service.saveLayout(getAuthUser(req), {
...current, ...current,
name: req.body.name || `${current.name} 副本`, name: req.body.name || `${current.name} 副本`,
points: current.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })), points: current.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })),
@@ -48,7 +62,7 @@ layouts.post(
); );
layouts.post( layouts.post(
'/:id/import', '/:id/import',
requirePermission('timetable:manage'), requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema, body: schemas.importLayoutSchema }), validate({ params: idParamSchema, body: schemas.importLayoutSchema }),
async (req, res) => { async (req, res) => {
const all = await service.listLayouts(getAuthUser(req)); const all = await service.listLayouts(getAuthUser(req));
@@ -58,6 +72,7 @@ layouts.post(
sendOk( sendOk(
res, res,
await service.saveLayout( await service.saveLayout(
getAuthUser(req),
{ {
...target, ...target,
points: source.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })), points: source.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })),
@@ -67,28 +82,39 @@ layouts.post(
); );
}, },
); );
layouts.post(
'/:id/points/:pointId/apply-default',
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: z.object({ id: z.string().min(1), pointId: z.string().min(1) }) }),
async (req, res) =>
sendOk(
res,
await service.applyDefaultCourse(getAuthUser(req), String(req.params.id), String(req.params.pointId)),
),
);
layouts.delete( layouts.delete(
'/:id', '/:id',
requirePermission('timetable:manage'), requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const id = String(req.params.id); const id = String(req.params.id);
if (await prisma.classPlan.count({ where: { timeLayoutId: id } })) await service.deleteLayout(getAuthUser(req), id);
throw ApiError.conflict('仍有课表使用该时间表');
await prisma.timeLayout.delete({ where: { id } });
await service.notifyScheduleChanged();
sendOk(res, { id }); sendOk(res, { id });
}, },
); );
const plans = Router(); const plans = Router();
plans.use(authenticate()); plans.use(authenticate());
plans.get('/profile', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => plans.get(
'/profile',
requirePermissionOrClassDevice('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) =>
sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)), sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)),
); );
plans.post( plans.post(
'/import/profile', '/import/profile',
requirePermission('schedule:write'), requirePermission('schedule:write', 'import:schedule'),
validate({ validate({
body: z.object({ body: z.object({
classId: z.string().min(1), classId: z.string().min(1),
@@ -102,10 +128,15 @@ plans.post(
await importProfile(getAuthUser(req), req.body.classId, req.body.payload, req.body.mode), await importProfile(getAuthUser(req), req.body.classId, req.body.payload, req.body.mode),
), ),
); );
plans.get('/', validate({ query: schemas.scopeQuery }), async (req, res) => { plans.get(
'/',
requirePermissionOrClassDevice('schedule:read'),
validate({ query: schemas.scopeQuery }),
async (req, res) => {
const q = validatedQuery<{ classId?: string; week?: number }>(req); const q = validatedQuery<{ classId?: string; week?: number }>(req);
sendOk(res, await service.listPlans(getAuthUser(req), q.classId, q.week)); sendOk(res, await service.listPlans(getAuthUser(req), q.classId, q.week));
}); },
);
plans.post( plans.post(
'/', '/',
requirePermission('schedule:write'), requirePermission('schedule:write'),
@@ -145,13 +176,18 @@ plans.post(
async (req, res) => async (req, res) =>
sendCreated(res, await service.clonePlan(getAuthUser(req), String(req.params.id), req.body.name)), sendCreated(res, await service.clonePlan(getAuthUser(req), String(req.params.id), req.body.name)),
); );
plans.get('/:id/export', validate({ params: idParamSchema }), async (req, res) => { plans.get(
'/:id/export',
requirePermissionOrClassDevice('schedule:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false); const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false);
sendOk(res, { format: 'classhelper-class-plan', plan: p, timeLayout: p.layout }); sendOk(res, { format: 'classhelper-class-plan', plan: p, timeLayout: p.layout });
}); },
);
plans.post( plans.post(
'/import', '/import',
requirePermission('schedule:write'), requirePermission('schedule:write', 'import:schedule'),
validate({ body: z.object({ plan: schemas.planSchema, entries: schemas.entriesSchema.shape.entries }) }), validate({ body: z.object({ plan: schemas.planSchema, entries: schemas.entriesSchema.shape.entries }) }),
async (req, res) => { async (req, res) => {
const p = await service.savePlan(getAuthUser(req), req.body.plan); const p = await service.savePlan(getAuthUser(req), req.body.plan);
@@ -172,17 +208,27 @@ plans.delete(
const groups = Router(); const groups = Router();
groups.use(authenticate()); groups.use(authenticate());
groups.get('/selection', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => { groups.get(
'/selection',
requirePermissionOrClassDevice('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req); const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId); await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } }); const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } });
sendOk(res, value ? JSON.parse(value.value) : {}); sendOk(res, value ? JSON.parse(value.value) : {});
}); },
groups.get('/', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => { );
groups.get(
'/',
requirePermissionOrClassDevice('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req); const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId); await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } })); sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } }));
}); },
);
groups.post( groups.post(
'/', '/',
requirePermission('schedule:write'), requirePermission('schedule:write'),
@@ -1,3 +1,4 @@
import { assertAdmin, assertHeadOnly, assertPermissionOrClassDevice } from '../../lib/authz/index.js';
import { SOCKET_EVENTS } from '@classhelper/shared'; import { SOCKET_EVENTS } from '@classhelper/shared';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { assertCanManageSchedule, assertClassAccess, getAccessibleClassIds } from '../../lib/access.js'; import { assertCanManageSchedule, assertClassAccess, getAccessibleClassIds } from '../../lib/access.js';
@@ -5,7 +6,7 @@ import { getMaxTermWeeks } from '../../lib/term.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { emitToClass } from '../../realtime/bus.js'; import { emitToClass } from '../../realtime/bus.js';
import { planInclude } from '../../lib/class-plan.js'; import { decorateLayout, planInclude } from '../../lib/class-plan.js';
import type { z } from 'zod'; import type { z } from 'zod';
import type { import type {
planSchema, planSchema,
@@ -24,16 +25,37 @@ export async function notifyScheduleChanged(classId?: string | null) {
for (const id of ids) emitToClass(id, SOCKET_EVENTS.scheduleUpdated, { classId: id, action: 'updated' }); for (const id of ids) emitToClass(id, SOCKET_EVENTS.scheduleUpdated, { classId: id, action: 'updated' });
} }
export async function listLayouts(user: TokenPayload, classId?: string) { export async function listLayouts(user: TokenPayload, classId?: string) {
if (classId) await assertClassAccess(user, classId); await assertPermissionOrClassDevice(user, 'schedule:read');
const ids = classId ? [classId] : await getAccessibleClassIds(user); if (classId) await assertClassAccess(user, classId, 'schedule:read');
return prisma.timeLayout.findMany({ const ids = classId ? [classId] : await getAccessibleClassIds(user, 'schedule:read');
const rows = await prisma.timeLayout.findMany({
where: { OR: [{ classId: null }, { classId: { in: ids } }] }, where: { OR: [{ classId: null }, { classId: { in: ids } }] },
include: { points: { orderBy: { position: 'asc' } } }, include: { points: { orderBy: { position: 'asc' } } },
}); });
return Promise.all(rows.map((row) => decorateLayout(row)));
} }
export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: string) { export async function saveLayout(user: TokenPayload, input: z.infer<typeof layoutSchema>, id?: string) {
if (id && !(await prisma.timeLayout.findUnique({ where: { id } }))) throw ApiError.notFound('时间表不存在'); const existing = id
const pointIds = input.points.flatMap((p) => (p.id ? [p.id] : [])); ? await prisma.timeLayout.findUnique({ where: { id }, select: { classId: true } })
: null;
if (id && !existing) throw ApiError.notFound('时间表不存在');
const layoutClassId = existing ? existing.classId : (input.classId ?? null);
await assertLayoutWrite(user, layoutClassId, id);
if (existing && input.classId !== undefined && input.classId !== existing.classId)
throw ApiError.forbidden('不能移动时间表到其他班级');
// 默认课程属于具体班级:全校时间表不保留默认课程,非上课时间点也不保留。
const points = input.points.map((point) => ({
...point,
defaultClassId: point.timeType === 'LESSON' && layoutClassId ? (point.defaultClassId ?? null) : null,
}));
const defaultIds = [...new Set(points.flatMap((p) => (p.defaultClassId ? [p.defaultClassId] : [])))];
if (defaultIds.length && layoutClassId) {
const count = await prisma.course.count({
where: { id: { in: defaultIds }, classId: layoutClassId },
});
if (count !== defaultIds.length) throw ApiError.badRequest('默认课程不属于该班级');
}
const pointIds = points.flatMap((p) => (p.id ? [p.id] : []));
if (new Set(pointIds).size !== pointIds.length) throw ApiError.badRequest('时间点不能重复'); if (new Set(pointIds).size !== pointIds.length) throw ApiError.badRequest('时间点不能重复');
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
const layout = id const layout = id
@@ -48,7 +70,7 @@ export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: strin
if (pointIds.some((p) => !owned.some((o) => o.id === p))) if (pointIds.some((p) => !owned.some((o) => o.id === p)))
throw ApiError.badRequest('时间点不属于这份时间表'); throw ApiError.badRequest('时间点不属于这份时间表');
await tx.timeLayoutItem.deleteMany({ where: { timeLayoutId: layout.id, id: { notIn: pointIds } } }); await tx.timeLayoutItem.deleteMany({ where: { timeLayoutId: layout.id, id: { notIn: pointIds } } });
for (const [position, point] of input.points.entries()) { for (const [position, point] of points.entries()) {
const { id: pointId, ...data } = point; const { id: pointId, ...data } = point;
if (pointId) await tx.timeLayoutItem.update({ where: { id: pointId }, data: { ...data, position } }); if (pointId) await tx.timeLayoutItem.update({ where: { id: pointId }, data: { ...data, position } });
else await tx.timeLayoutItem.create({ data: { ...data, position, timeLayoutId: layout.id } }); else await tx.timeLayoutItem.create({ data: { ...data, position, timeLayoutId: layout.id } });
@@ -59,13 +81,57 @@ export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: strin
}); });
}); });
await notifyScheduleChanged(); await notifyScheduleChanged();
return result; return decorateLayout(result);
}
/**
* 把时间点的默认课程写入所有引用该时间表的日课表(ClassIsland 的「覆盖现有课程」)。
* 只处理班级时间表:全校时间表的默认课程没有归属班级。
*/
export async function applyDefaultCourse(user: TokenPayload, layoutId: string, pointId: string) {
const layout = await prisma.timeLayout.findUnique({
where: { id: layoutId },
include: { points: true },
});
if (!layout) throw ApiError.notFound('时间表不存在');
await assertLayoutWrite(user, layout.classId, layout.id);
if (!layout.classId) throw ApiError.badRequest('全校时间表不能覆盖默认课程');
const point = layout.points.find((p) => p.id === pointId);
if (!point || point.timeType !== 'LESSON') throw ApiError.badRequest('时间点不是上课时间点');
if (!point.defaultClassId) throw ApiError.badRequest('请先设置默认课程');
const course = await prisma.course.findFirst({
where: { id: point.defaultClassId, classId: layout.classId },
select: { id: true },
});
if (!course) throw ApiError.badRequest('默认课程不属于该班级');
const plans = await prisma.classPlan.findMany({
where: { timeLayoutId: layout.id, classId: layout.classId },
select: { id: true },
});
const courseId = point.defaultClassId;
await prisma.$transaction(
plans.map((plan) =>
prisma.classPlanEntry.upsert({
where: { classPlanId_timeLayoutItemId: { classPlanId: plan.id, timeLayoutItemId: point.id } },
create: {
classPlanId: plan.id,
timeLayoutItemId: point.id,
courseId,
isEnabled: true,
},
update: { courseId, isEnabled: true },
}),
),
);
await notifyScheduleChanged(layout.classId);
return { plans: plans.length };
} }
export async function listPlans(user: TokenPayload, classId?: string, week?: number) { export async function listPlans(user: TokenPayload, classId?: string, week?: number) {
if (classId) await assertClassAccess(user, classId); await assertPermissionOrClassDevice(user, 'schedule:read');
if (classId) await assertClassAccess(user, classId, 'schedule:read');
return prisma.classPlan.findMany({ return prisma.classPlan.findMany({
where: { where: {
classId: { in: classId ? [classId] : await getAccessibleClassIds(user) }, classId: { in: classId ? [classId] : await getAccessibleClassIds(user, 'schedule:read') },
...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}), ...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}),
}, },
include: planInclude, include: planInclude,
@@ -75,7 +141,8 @@ export async function listPlans(user: TokenPayload, classId?: string, week?: num
export async function requirePlan(user: TokenPayload, id: string, write = true) { export async function requirePlan(user: TokenPayload, id: string, write = true) {
const p = await prisma.classPlan.findUnique({ where: { id }, include: planInclude }); const p = await prisma.classPlan.findUnique({ where: { id }, include: planInclude });
if (!p) throw ApiError.notFound('课表不存在'); if (!p) throw ApiError.notFound('课表不存在');
await (write ? assertCanManageSchedule : assertClassAccess)(user, p.classId); if (write) await assertCanManageSchedule(user, p.classId);
else await assertClassAccess(user, p.classId, 'schedule:read');
return p; return p;
} }
export async function savePlan(user: TokenPayload, input: z.infer<typeof planSchema>, id?: string) { export async function savePlan(user: TokenPayload, input: z.infer<typeof planSchema>, id?: string) {
@@ -226,3 +293,24 @@ export async function createOverlay(
await notifyScheduleChanged(source.classId); await notifyScheduleChanged(source.classId);
return result; return result;
} }
export async function assertLayoutWrite(user: TokenPayload, classId: string | null, layoutId?: string) {
if (classId) await assertHeadOnly(user, 'schedule:write', classId);
else assertAdmin(user);
if (layoutId) {
const refs = await prisma.classPlan.findMany({
where: { timeLayoutId: layoutId },
select: { classId: true },
});
for (const id of new Set(refs.map((p) => p.classId))) await assertHeadOnly(user, 'schedule:write', id);
}
}
export async function deleteLayout(user: TokenPayload, id: string) {
const layout = await prisma.timeLayout.findUnique({ where: { id } });
if (!layout) throw ApiError.notFound('时间表不存在');
await assertLayoutWrite(user, layout.classId, id);
if (await prisma.classPlan.count({ where: { timeLayoutId: id } }))
throw ApiError.conflict('仍有课表使用该时间表');
await prisma.timeLayout.delete({ where: { id } });
await notifyScheduleChanged(layout.classId);
}
@@ -4,7 +4,7 @@ import type { TokenPayload } from '../../lib/jwt.js';
/** 按时间点外键生成完整档案,空课、停用课程与非上课时间点均保留。 */ /** 按时间点外键生成完整档案,空课、停用课程与非上课时间点均保留。 */
export async function exportProfile(user: TokenPayload, classId: string) { export async function exportProfile(user: TokenPayload, classId: string) {
await assertClassAccess(user, classId); await assertClassAccess(user, classId, 'schedule:read');
const [cls, layouts, plans, subjects, groups, scheduled, setting] = await Promise.all([ const [cls, layouts, plans, subjects, groups, scheduled, setting] = await Promise.all([
prisma.class.findUniqueOrThrow({ where: { id: classId } }), prisma.class.findUniqueOrThrow({ where: { id: classId } }),
prisma.timeLayout.findMany({ prisma.timeLayout.findMany({
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import { z } from 'zod'; import { z } from 'zod';
import { isDayKey, dayKeyLocal } from '@classhelper/shared'; import { isDayKey, dayKeyLocal } from '@classhelper/shared';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { assertCanManageSchedule } from '../../lib/access.js'; import { assertCanImportSchedule } from '../../lib/access.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { notifyScheduleChanged } from './class-plans.service.js'; import { notifyScheduleChanged } from './class-plans.service.js';
@@ -21,7 +21,7 @@ function time(value: unknown) {
} }
/** 直接保留上游三元组/四种时间点/显式课程位置,绝不经过单双周的有损中间格式。 */ /** 直接保留上游三元组/四种时间点/显式课程位置,绝不经过单双周的有损中间格式。 */
export async function importProfile(user: TokenPayload, classId: string, payload: unknown, mode = 'merge') { export async function importProfile(user: TokenPayload, classId: string, payload: unknown, mode = 'merge') {
await assertCanManageSchedule(user, classId); await assertCanImportSchedule(user, classId);
let parsed = payload; let parsed = payload;
if (typeof payload === 'string') { if (typeof payload === 'string') {
try { try {
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff. Show more