feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)

学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。

学生不是账号
- 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段
- 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支
- 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher
- 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失

任课关系唯一来源 = Course(班级 + 科目 + 教师)
- 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号
- 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员
- 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关)

成绩与名单
- 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级
- 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制
- 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属
- 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业)
- 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」

ClassHelper 班级端在线状态
- Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳
- lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开

学期周次
- 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置
- 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常)
- 改周次后随课表下发给教室的 ClassHelper 班级端

课表方向反转
- 主方向改为「服务端 → 教室」自动下发;教室不再自动回传
- 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报

改名
- 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、
  安装目录、systemd 描述、插件清单与设置页、官网、文档站、README

BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe),
安装目录随之改变,不支持覆盖安装,需先卸载旧版。
服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢),
升级前请先备份。
This commit is contained in:
LoveChengke committed 2026-10-06 19:41:18 +08:00
1 parent 17f55864bf
commit 9d5ac7adf9
172 files changed
+8761 -1896

No files matched your search

+99 -33
View File
@@ -3,31 +3,39 @@ import type { UserRole } from './types.js';
/**
* 班级内的角色(相对"某一个班级"而言)。
*
* - `ADMIN` 系统管理员:班级增删改、人员分配、全部数据
* - `HEAD` 班主任(`Class.teacherId`):本班课表 + 本班成绩(含导入)+ 作业/通知/叫人
* - `SUBJECT` 科任老师(`ClassTeacher`):仅作业/通知/叫人
* - `STUDENT` 学生(班级账号;个人学生账号已清理):只读
* - `NONE` 与该班级无关
* - `ADMIN` 系统管理员:全部班级、全部科目
* - `HEAD` 班主任(`Class.teacherId`):本班学生名单、本班课表、本班汇总。
* **不自动拥有本班所有科目的作业/成绩编辑权** —— 只有当他同时是
* 该科科任老师(`Course.teacherId === 他`)时才有那一科的权限。
* - `SUBJECT` 科任老师(本班存在 `Course.teacherId === 他`):只能动自己任教科目的作业与成绩
* - `CLASS_DEVICE` ClassHelper 班级端(班级码 + 班级密码登录):代本班读写个人数据
* - `NONE` 与该班级无关
*/
export type ClassRole = 'ADMIN' | 'HEAD' | 'SUBJECT' | 'STUDENT' | 'NONE';
export type ClassRole = 'ADMIN' | 'HEAD' | 'SUBJECT' | 'CLASS_DEVICE' | 'NONE';
export interface ClassRoleContext {
role: UserRole;
/** 是否是该班班主任(Class.teacherId === 当前用户) */
/** 是否是该班班主任(`Class.teacherId === 当前用户`) */
isHeadTeacher?: boolean;
/** 是否被分配为该班科任/协作老师(ClassTeacher 中存在当前用户) */
isSubjectTeacher?: boolean;
/**
* 是否在该班任教(该班存在 `Course.teacherId === 当前用户`)。
*
* 判定源是 **`Course`**(班级 + 科目 + 教师)这一条唯一的任课关系,
* 不再是过去那张没有科目的 `ClassTeacher` 协作表。
*/
teachesSubjects?: boolean;
}
/** 依据账号角色与该班归属关系,解析出班级内角色 */
export function resolveClassRole(context: ClassRoleContext): ClassRole {
if (context.role === 'ADMIN') return 'ADMIN';
if (context.role === 'CLASS_DEVICE') return 'CLASS_DEVICE';
if (context.role === 'TEACHER') {
// 班主任同时是科任老师时取更高的那一个;权限在下面各 canXxx 里合并,不会丢科任权限
if (context.isHeadTeacher) return 'HEAD';
if (context.isSubjectTeacher) return 'SUBJECT';
if (context.teachesSubjects) return 'SUBJECT';
return 'NONE';
}
if (context.role === 'STUDENT') return 'STUDENT';
return 'NONE';
}
@@ -36,14 +44,14 @@ export function canManageClasses(role: UserRole): boolean {
return role === 'ADMIN';
}
/** 班主任与科任老师名单的编辑:仅管理员 */
/** 班主任与科任老师的分配:仅管理员 */
export function canAssignTeachers(role: UserRole): boolean {
return role === 'ADMIN';
}
/** 学生名单(增删学生、导入名单):仅管理员(学生没有账号,因此没有"重置密码"这一步) */
export function canManageRoster(role: UserRole): boolean {
return role === 'ADMIN';
/** 学生名单(增删学生、调班、导入名单):管理员,或**本班班主任** */
export function canManageRoster(classRole: ClassRole): boolean {
return classRole === 'ADMIN' || classRole === 'HEAD';
}
/** 课表管理:管理员或本班班主任(科任老师不可以) */
@@ -56,29 +64,83 @@ export function canManageCourses(classRole: ClassRole): boolean {
return canManageSchedule(classRole);
}
/** 布置作业 / 叫人 / 发通知:管理员、班主任、科任老师皆可(学生不可) */
export function canPublishContent(classRole: ClassRole): boolean {
return classRole === 'ADMIN' || classRole === 'HEAD' || classRole === 'SUBJECT';
/**
* 发布通知 / 叫人:管理员、班主任、科任老师皆可。
*
* 通知与"叫人"是**面向全班**的信息,不像作业/成绩那样挂在某一科目上,
* 因此凡是与本班有关联的教师(班主任或任课老师)都能发;ClassHelper 班级端也可以。
*/
export function canPublishNotification(classRole: ClassRole): boolean {
return classRole === 'ADMIN' || classRole === 'HEAD' || classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE';
}
/**
* 成绩录入 / 修改 / 导入:管理员,或**本班班主任**。
* 成绩录入 / 修改 / 导入的**班级维度**准入:管理员,或**本班班主任**。
*
* 需求 6 要求"成绩与表格导入……老师端与学生端班级账号均可用且不越权",
* 而需求 7 的权限清单(班级增删改、人员分配、科任权限、班主任课表)并未把成绩收归管理员专属,
* 因此按"老师端可用 + 不越权"落地:班主任只能动**自己班上**的成绩,
* 科任老师依旧只有作业 / 叫人 / 通知权限(与需求 7 完全一致)。
* 注意这只是第一道门:真正能不能动某一条成绩,还要看**科目**是否由他任教
* (见服务端的 `assertCanManageCourseContent`)—— 班主任不教这一科就改不了这一科。
*/
export function canManageGrades(classRole: ClassRole): boolean {
return classRole === 'ADMIN' || classRole === 'HEAD';
}
/** 可导入的数据类型 */
export type ImportKind = 'grades' | 'students' | 'teachers' | 'scheduleTimes';
/**
* 科目维度的写权限(作业与成绩共用的那道细门):
*
* | 情形 | 谁可以写 |
* | ---- | ---- |
* | 管理员 | 全部 |
* | ClassHelper 班级端 | 仅当传入 `allowClassDevice`(**作业**如此;成绩不给,与既有行为一致) |
* | 作业/成绩**选了科目** | 该科的任课老师(`Course.teacherId`) |
* | 作业/成绩**没选科目** | 本班班主任 |
*
* 这是需求「班主任不自动拥有所有科目作业成绩编辑权」的落点。
*/
export function canManageSubjectContent(input: {
classRole: ClassRole;
/** 目标是否挂在某个科目(Course)上 */
hasSubject: boolean;
/** 当前用户是否是该科目的任课老师 */
isSubjectTeacherOfCourse: boolean;
/**
* 是否允许 ClassHelper 班级端代写。
* 教室机器可以录**作业**(老师在教室随手录一条很常见,见 AGENTS.md §5 第 20 条),
* 但**成绩**不给 —— 成绩是面向家长/学生的评价数据,仍由教师在 Web 端录入。
*/
allowClassDevice?: boolean;
}): boolean {
if (input.classRole === 'ADMIN') return true;
if (input.classRole === 'CLASS_DEVICE') return input.allowClassDevice === true;
if (input.hasSubject) return input.isSubjectTeacherOfCourse;
return input.classRole === 'HEAD';
}
/** 导入权限:成绩=管理员或本班班主任;学生/教师名单=仅管理员;课表时间=管理员或本班班主任 */
/**
* 按学号查询学生成绩明细的准入。
*
* - 教师/管理员:班主任看本班、科任看本班、管理员看全部(都是"有权限就能看明细");
* - ClassHelper 班级端:需要该班打开 `studentGradeQueryEnabled` —— 这就是
* 「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」。
*/
export function canQueryStudentGrades(input: { classRole: ClassRole; gradeQueryEnabled: boolean }): boolean {
if (input.classRole === 'ADMIN' || input.classRole === 'HEAD' || input.classRole === 'SUBJECT') return true;
if (input.classRole === 'CLASS_DEVICE') return input.gradeQueryEnabled;
return false;
}
/** 可导入的数据类型 */
export type ImportKind = 'grades' | 'students' | 'teachers' | 'classTeachers' | 'scheduleTimes';
/**
* 导入权限:
* - 学生名单 / 教师名单:`students` 允许管理员或本班班主任;`teachers` 仍仅管理员
* - 成绩:管理员或本班班主任(再按科目收口)
* - 班级任课老师:仅管理员(它直接改写班主任与任课关系)
* - 课表时间:管理员或本班班主任
*/
export function canImport(role: UserRole, classRole: ClassRole, kind: ImportKind): boolean {
if (kind === 'students' || kind === 'teachers') return canManageRoster(role);
if (kind === 'teachers' || kind === 'classTeachers') return canManageClasses(role);
if (kind === 'students') return canManageRoster(classRole);
if (kind === 'scheduleTimes') return canManageSchedule(classRole);
return canManageGrades(classRole);
}
@@ -89,14 +151,18 @@ export const PERMISSION_MATRIX: readonly {
admin: boolean;
head: boolean;
subject: boolean;
classDevice?: boolean;
}[] = [
{ action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false },
{ action: '分配班主任与科任老师', admin: true, head: false, subject: false },
{ action: '学生名单管理(增删 / 导入)', admin: true, head: false, subject: false },
{ action: '分配班主任与各科科任老师', admin: true, head: false, subject: false },
{ action: '学生名单管理(增删 / 导入 / 调班)', admin: true, head: true, subject: false },
{ action: '教师录入(新建 / 导入名单 / 重置密码)', admin: true, head: false, subject: false },
{ action: '课表管理(增删改 / 时间配置导入)', admin: true, head: true, subject: false },
{ action: '布置作业', admin: true, head: true, subject: true },
{ action: '叫人', admin: true, head: true, subject: true },
{ action: '发布通知', admin: true, head: true, subject: true },
{ action: '成绩录入 / 修改 / 导入(仅本班)', admin: true, head: true, subject: false },
{ action: '布置作业(自己任教的科目)', admin: true, head: true, subject: true },
{ action: '叫人', admin: true, head: true, subject: true, classDevice: true },
{ action: '发布通知', admin: true, head: true, subject: true, classDevice: true },
{ action: '成绩录入 / 修改 / 导入(自己任教的科目)', admin: true, head: true, subject: true },
{ action: '查看本班成绩汇总', admin: true, head: true, subject: true },
{ action: '按学号查询学生成绩明细', admin: true, head: true, subject: true, classDevice: true },
{ action: '查看 ClassHelper 班级端在线状态', admin: true, head: true, subject: true },
];