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

+236 -29
View File
@@ -1,5 +1,7 @@
import { dayKeyLocal } from '@classhelper/shared';
import type {
ArchivedClassDto,
ArchivedYearDto,
ClassDto,
ClassIslandNotificationChannel,
ClassTeacherBrief,
@@ -11,7 +13,10 @@ import type {
NotificationDto,
NotificationPriority,
ScheduleDto,
SessionUser,
StudentBrief,
StudentDto,
StudentTransferDto,
UserDto,
UserRole,
} from '@classhelper/shared';
@@ -28,13 +33,13 @@ const toIso = (value: Date): string => value.toISOString();
const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);
/**
* "个人记录"归属范围解析:
* - 传入 userIds(班级账号 = 全班学生)时优先使用;
* - 否则退化为单个 userId(普通学生账号 = 自己)。
* "个人记录"归属范围解析(记录键是**学生 id**):
* - 传入 studentIds(ClassHelper 班级端 = 全班学生)时优先使用;
* - 否则退化为单个 studentId。
*/
function resolvePool(options: { userId?: string | null; userIds?: string[] }): string[] {
if (options.userIds && options.userIds.length > 0) return options.userIds;
return options.userId ? [options.userId] : [];
function resolvePool(options: { studentId?: string | null; studentIds?: string[] }): string[] {
if (options.studentIds && options.studentIds.length > 0) return options.studentIds;
return options.studentId ? [options.studentId] : [];
}
export interface UserLike {
@@ -42,7 +47,8 @@ export interface UserLike {
username: string;
name: string;
role: string;
classId: string | null;
/** 手机号;老库/老对象可能没有这个字段 */
phone?: string | null;
createdAt: Date;
}
@@ -52,7 +58,32 @@ export interface TeacherBriefLike {
username: string;
}
export interface StudentLike extends UserLike {
/** 学生简要信息(id + 学号 + 姓名) */
export interface StudentBriefLike {
id: string;
studentNo: string;
name: string;
}
/**
* 学生记录(`Student` 表)。
*
* 学生不是账号 —— 这里没有 `role`、没有 `passwordHash`,
* 唯一的标识是 `studentNo`(学号)。
*/
export interface StudentLike {
id: string;
studentNo: string;
name: string;
classId: string | null;
gender: string;
guardianPhone: string;
status: string;
archivedYearId?: string | null;
archivedAt?: Date | null;
transferredAt?: Date | null;
transferNote?: string;
createdAt: Date;
class?: { name: string; grade: string } | null;
}
@@ -67,19 +98,78 @@ export function toUserDto(user: UserLike): UserDto {
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: user.classId,
phone: user.phone ?? '',
createdAt: toIso(user.createdAt),
};
}
export function toStudentDto(user: StudentLike): StudentDto {
/** 账号的会话主体(登录返回 / GET /auth/me) */
export function toUserSessionUser(user: UserLike): SessionUser {
return {
...toUserDto(user),
className: user.class?.name ?? null,
grade: user.class?.grade ?? null,
id: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
phone: user.phone ?? '',
classId: null,
createdAt: toIso(user.createdAt),
};
}
/** ClassHelper 班级端登录时用到的班级字段 */
export interface ClassDeviceLike {
id: string;
code: string;
name: string;
grade: string;
studentGradeQueryEnabled: boolean;
createdAt: Date;
}
/**
* ClassHelper 班级端的会话主体:`id` 与 `classId` 都是**班级 id**,
* 角色是 `CLASS_DEVICE`(学生不是账号,因此这里不会出现学生身份)。
*/
export function toClassDeviceUser(record: ClassDeviceLike): SessionUser {
return {
id: record.id,
username: record.code,
name: record.name,
role: 'CLASS_DEVICE',
classId: record.id,
className: record.name,
grade: record.grade,
classSession: true,
classCode: record.code,
studentGradeQueryEnabled: record.studentGradeQueryEnabled,
createdAt: toIso(record.createdAt),
};
}
export function toStudentDto(student: StudentLike): StudentDto {
return {
id: student.id,
studentNo: student.studentNo,
name: student.name,
classId: student.classId,
className: student.class?.name ?? null,
grade: student.class?.grade ?? null,
gender: (student.gender ?? '') as StudentDto['gender'],
guardianPhone: student.guardianPhone ?? '',
status: (student.status ?? 'active') as StudentDto['status'],
archivedYearId: student.archivedYearId ?? null,
archivedAt: toIsoOrNull(student.archivedAt),
transferredAt: toIsoOrNull(student.transferredAt),
transferNote: student.transferNote ?? '',
createdAt: toIso(student.createdAt),
};
}
/** 学生简要信息(成绩、提交名单等引用位) */
export function toStudentBrief(student: StudentBriefLike): StudentBrief {
return { id: student.id, studentNo: student.studentNo, name: student.name };
}
export function toTeacherBrief(user: TeacherBriefLike): ClassTeacherBrief {
return { id: user.id, name: user.name, username: user.username };
}
@@ -94,11 +184,20 @@ export interface ClassLike {
id: string;
name: string;
grade: string;
/** 入学年份(届别);老数据可能为空 */
enrollmentYear?: number | null;
/** 班号;老数据可能为空 */
classIndex?: number | null;
teacherId: string;
/** 本学期教学周数(班主任可调,默认 20) */
termWeeks?: number;
/** 通知显示位置:both / client / classisland(由教室的班级客户端设置) */
/** 通知显示位置:both / client / classisland(由 ClassHelper 班级端设置) */
notificationChannel?: string | null;
/** 班级端按学号查成绩的开关 */
studentGradeQueryEnabled?: boolean | null;
/** 毕业归档所属届别;null = 在读 */
archivedYearId?: string | null;
archivedAt?: Date | null;
createdAt: Date;
teacher?: TeacherBriefLike | null;
_count?: {
@@ -120,9 +219,15 @@ export function toClassDto(item: ClassLike): ClassDto {
id: item.id,
name: item.name,
grade: item.grade,
enrollmentYear: item.enrollmentYear ?? null,
classIndex: item.classIndex ?? null,
teacherId: item.teacherId,
termWeeks: item.termWeeks ?? 20,
notificationChannel: toNotificationChannel(item.notificationChannel),
// 默认开启:老库补列时的默认值也是 true,两边保持一致
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
archivedYearId: item.archivedYearId ?? null,
archivedAt: toIsoOrNull(item.archivedAt),
createdAt: toIso(item.createdAt),
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
studentCount: item._count?.students ?? undefined,
@@ -193,7 +298,7 @@ export function toScheduleDto(item: ScheduleLike): ScheduleDto {
export interface HomeworkStatusLike {
id: string;
homeworkId: string;
userId: string;
studentId: string;
completed: boolean;
updatedAt: Date;
}
@@ -218,24 +323,24 @@ export function toHomeworkStatusDto(item: HomeworkStatusLike): HomeworkStatusDto
return {
id: item.id,
homeworkId: item.homeworkId,
userId: item.userId,
studentId: item.studentId,
completed: item.completed,
updatedAt: toIso(item.updatedAt),
};
}
/**
* @param options.userId 指定当前用户,用于填充 completed / homeworkStatus
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找完成状态
* @param options.studentId 指定单个学生,用于填充 completed / homeworkStatus
* @param options.studentIds ClassHelper 班级端场景:以"全班学生"为范围找完成状态
* @param options.withStatus 是否统计完成人数(教师视角)
*/
export function toHomeworkDto(
item: HomeworkLike,
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
options: { studentId?: string | null; studentIds?: string[]; withStatus?: boolean } = {},
): HomeworkDto {
const statuses = item.statuses ?? [];
const pool = resolvePool(options);
const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.userId)) : undefined;
const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.studentId)) : undefined;
return {
id: item.id,
@@ -261,7 +366,7 @@ export function toHomeworkDto(
export interface NotificationReadLike {
id: string;
notificationId: string;
userId: string;
studentId: string;
readAt: Date;
}
@@ -278,17 +383,17 @@ export interface NotificationLike {
}
/**
* @param options.userId 指定当前用户(普通学生账号)
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找已读状态
* @param options.studentId 指定单个学生
* @param options.studentIds ClassHelper 班级端场景:以"全班学生"为范围找已读状态
* @param options.withStatus 是否统计已读人数(教师视角)
*/
export function toNotificationDto(
item: NotificationLike,
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
options: { studentId?: string | null; studentIds?: string[]; withStatus?: boolean } = {},
): NotificationDto {
const reads = item.reads ?? [];
const pool = resolvePool(options);
const own = pool.length > 0 ? reads.find((read) => pool.includes(read.userId)) : undefined;
const own = pool.length > 0 ? reads.find((read) => pool.includes(read.studentId)) : undefined;
return {
id: item.id,
@@ -311,13 +416,17 @@ export interface GradeLike {
id: string;
classId: string;
courseId: string | null;
userId: string;
studentId: string;
examName: string;
score: number;
totalScore: number;
/** 等级口径:percent / letter / custom(老库可能为空,按 percent 兜底) */
levelType?: string | null;
/** 等级文本(老库可能为空) */
level?: string | null;
publishedAt: Date;
course?: CourseBriefLike | null;
student?: TeacherBriefLike | null;
student?: StudentBriefLike | null;
class?: { name: string } | null;
}
@@ -326,13 +435,111 @@ export function toGradeDto(item: GradeLike): GradeDto {
id: item.id,
classId: item.classId,
courseId: item.courseId,
userId: item.userId,
studentId: item.studentId,
examName: item.examName,
score: item.score,
totalScore: item.totalScore,
levelType: (item.levelType ?? 'percent') as GradeDto['levelType'],
level: item.level ?? '',
publishedAt: toIso(item.publishedAt),
course: item.course ? toCourseBrief(item.course) : null,
student: item.student ? toTeacherBrief(item.student) : null,
student: item.student ? toStudentBrief(item.student) : null,
className: item.class?.name ?? null,
};
}
/* ------------------------------------------------------------------ 调班 / 归档 */
export interface StudentTransferLike {
id: string;
studentId: string;
studentNo: string;
studentName: string;
fromClassId: string | null;
fromClassName: string;
toClassId: string | null;
toClassName: string;
operatorId: string | null;
operatorName: string;
mode: string;
note?: string | null;
createdAt: Date;
}
export function toStudentTransferDto(item: StudentTransferLike): StudentTransferDto {
return {
id: item.id,
studentId: item.studentId,
studentNo: item.studentNo,
studentName: item.studentName,
fromClassId: item.fromClassId,
fromClassName: item.fromClassName,
toClassId: item.toClassId,
toClassName: item.toClassName,
operatorId: item.operatorId,
operatorName: item.operatorName,
mode: item.mode as StudentTransferDto['mode'],
note: item.note ?? '',
createdAt: toIso(item.createdAt),
};
}
export interface ArchivedYearLike {
id: string;
enrollmentYear: number;
graduationYear: number;
name: string;
note: string;
operatorId: string | null;
operatorName: string;
classCount: number;
studentCount: number;
transferredCount: number;
homeworkCount: number;
notificationCount: number;
archivedAt: Date;
}
export function toArchivedYearDto(item: ArchivedYearLike): ArchivedYearDto {
return {
id: item.id,
enrollmentYear: item.enrollmentYear,
graduationYear: item.graduationYear,
name: item.name,
note: item.note,
operatorId: item.operatorId,
operatorName: item.operatorName,
classCount: item.classCount,
studentCount: item.studentCount,
transferredCount: item.transferredCount,
homeworkCount: item.homeworkCount,
notificationCount: item.notificationCount,
archivedAt: toIso(item.archivedAt),
};
}
export interface ArchivedClassLike {
id: string;
name: string;
grade: string;
enrollmentYear: number | null;
classIndex: number | null;
archivedAt: Date | null;
teacher?: { name: string } | null;
_count?: { students?: number; homeworks?: number; notifications?: number };
}
export function toArchivedClassDto(item: ArchivedClassLike): ArchivedClassDto {
return {
id: item.id,
name: item.name,
grade: item.grade,
enrollmentYear: item.enrollmentYear,
classIndex: item.classIndex,
headTeacherName: item.teacher?.name ?? null,
studentCount: item._count?.students ?? 0,
homeworkCount: item._count?.homeworks ?? 0,
notificationCount: item._count?.notifications ?? 0,
archivedAt: toIsoOrNull(item.archivedAt),
};
}