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

+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "@classhelper/shared",
"version": "1.1.2",
"version": "1.2.0",
"private": true,
"description": "班级小助手 - 三端共享的类型、常量与工具函数",
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
+140 -10
View File
@@ -1,4 +1,4 @@
import type { NotificationPriority, UserRole } from './types.js';
import type { GradeLevelType, NotificationPriority, StudentGender, StudentStatus, UserRole } from './types.js';
/** API 前缀 */
export const API_PREFIX = '/api';
@@ -23,7 +23,11 @@ export const API_PATHS = {
teachers: '/teachers',
/** 导入:模板下载 / 表格导入 / ClassIsland 课表时间配置 */
imports: '/imports',
/** ClassIsland 联动:设备接入、状态上报、通知下发 */
/** 毕业归档:届别档案、归档详情、学年升级(仅管理员) */
archives: '/archives',
/** 学期周次:逐周日期区间、按开学日期自动生成、联网拉取调休建议(仅管理员) */
term: '/term',
/** ClassIsland 联动:设备接入、状态上报、通知下发 / ClassHelper 班级端在线状态 */
integrations: '/integrations',
/** 数据库管理(仅管理员):状态/备份/导入导出/一键切换 */
database: '/database',
@@ -32,15 +36,140 @@ export const API_PATHS = {
dashboard: '/dashboard/summary',
} as const;
/** 用户角色 */
export const USER_ROLES: readonly UserRole[] = ['ADMIN', 'TEACHER', 'STUDENT'];
/**
* 账号角色。
*
* **没有 STUDENT** —— 学生不是账号,只是班级名单记录(`Student` 表),没有密码、没有登录入口。
* `CLASS_DEVICE` = 教室里绑定到某个班的 ClassHelper 班级端。
*/
export const USER_ROLES: readonly UserRole[] = ['ADMIN', 'TEACHER', 'CLASS_DEVICE'];
export const ROLE_LABELS: Record<UserRole, string> = {
ADMIN: '系统管理员',
TEACHER: '教师',
STUDENT: '学生',
CLASS_DEVICE: 'ClassHelper 班级端',
};
/** 教师与管理员(staff)角色 */
export const STAFF_ROLES: readonly UserRole[] = ['ADMIN', 'TEACHER'];
/* ------------------------------------------------------------------ 班级教师结构 */
/**
* 班级内的教师角色:
* - `HEAD` 班主任:每个班 1 人(`Class.teacherId`)
* - `SUBJECT` 科任老师:按科目配置(`Course.teacherId`,即"班级 + 科目 + 教师")
*
* 同一教师可以在同一个班同时是班主任和某科科任老师 —— 系统里只保留**一个**教师账号,
* 两处都指向它,权限在 `resolveClassRole()` 里合并,不重复建号、不产生冲突。
*/
export const CLASS_TEACHER_ROLES = ['HEAD', 'SUBJECT'] as const;
export type ClassTeacherRole = (typeof CLASS_TEACHER_ROLES)[number];
export const CLASS_TEACHER_ROLE_LABELS: Record<ClassTeacherRole, string> = {
HEAD: '班主任',
SUBJECT: '科任老师',
};
/* ------------------------------------------------------------------ 学生名单 */
export const STUDENT_STATUSES: readonly StudentStatus[] = [
'active',
'graduated',
'transferred',
'inactive',
];
export const STUDENT_STATUS_LABELS: Record<StudentStatus, string> = {
active: '在读',
graduated: '已毕业',
transferred: '已转出',
inactive: '停用',
};
/** Element Plus tag 类型(学生状态徽标配色,三端统一) */
export const STUDENT_STATUS_TAG_TYPES: Record<StudentStatus, 'success' | 'info' | 'warning' | 'danger'> = {
active: 'success',
graduated: 'info',
transferred: 'warning',
inactive: 'danger',
};
export const STUDENT_GENDERS: readonly StudentGender[] = ['', 'MALE', 'FEMALE'];
export const STUDENT_GENDER_LABELS: Record<StudentGender, string> = {
'': '未填',
MALE: '男',
FEMALE: '女',
};
/* ------------------------------------------------------------------ 班级称呼与届别 */
/** 默认学制(年):入学年份 + 它 = 毕业年份。高中与初中都是 3 年 */
export const DEFAULT_SCHOOLING_YEARS = 3;
/**
* 班级称呼:`XXXX级X班`(入学年份 + 班号)。
*
* 用入学年份而不是「高一(1)班」这类写法,是因为后者每年都要改一遍,
* 而前者一路跟着这届学生到毕业 —— 升级只改 `Class.grade`,称呼不变。
*/
export function formatClassName(enrollmentYear: number, classIndex: number): string {
return `${enrollmentYear}级${classIndex}班`;
}
/** 届别名:`2026 级` */
export function formatYearLabel(enrollmentYear: number): string {
return `${enrollmentYear} 级`;
}
/** 毕业年份(入学年份 + 学制) */
export function graduationYearOf(enrollmentYear: number, schoolingYears = DEFAULT_SCHOOLING_YEARS): number {
return enrollmentYear + schoolingYears;
}
/**
* 按当前时间推断「本学年的起始年份」:学年在 9 月切换,
* 9 月及以后算今年起始,之前算去年起始(8 月及以前仍属上一学年)。
*/
export function currentAcademicYearStart(now: Date = new Date()): number {
return now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1;
}
/* ------------------------------------------------------------------ 成绩等级 */
/** 等级口径可选值:百分制 / 等级制 A-D / 自定义等级 */
export const GRADE_LEVEL_TYPES: readonly GradeLevelType[] = ['percent', 'letter', 'custom'];
export const GRADE_LEVEL_TYPE_LABELS: Record<GradeLevelType, string> = {
percent: '百分制',
letter: '等级制(A/B/C/D)',
custom: '自定义等级',
};
export const GRADE_LEVEL_TYPE_HINTS: Record<GradeLevelType, string> = {
percent: '按分数录入,等级由得分率自动换算(教师仍可逐条手动改)',
letter: '直接给 A / B / C / D,分数可留空',
custom: '自己定义等级文本(如 优 / 良 / 合格 / 待提高)',
};
/** 等级制的默认档位(仅 UI 快捷选项,自定义等级不受限制) */
export const DEFAULT_LETTER_LEVELS: readonly string[] = ['A', 'B', 'C', 'D'];
/* ------------------------------------------------------------------ ClassHelper 班级端 */
/**
* ClassHelper 班级端在线判定窗口(毫秒)。
*
* 插件默认 60 秒打一次心跳,因此「最近 60 秒内有心跳 = 在线」是最贴合的口径;
* 判定与展示(教师网页的「ClassHelper 在线状态」)共用这一个常量,避免两处各写一个数。
*/
export const CLASS_HELPER_ONLINE_WINDOW_MS = 60_000;
/** 教师网页「ClassHelper 在线状态」的自动刷新间隔(秒) */
export const CLASS_HELPER_STATUS_REFRESH_SECONDS = 30;
/** 通知优先级 */
export const NOTIFICATION_PRIORITIES: readonly NotificationPriority[] = ['LOW', 'NORMAL', 'HIGH', 'URGENT'];
@@ -443,7 +572,7 @@ export const CLASSISLAND_NOTIFICATION_DEFAULT_DURATION = 8;
export const CLASSISLAND_NOTIFICATION_MAX_DURATION = 120;
/**
* "叫人"快捷短语(Web 管理端一键选择,学生端灵动岛同步展示)。
* "叫人"快捷短语(Web 管理端一键选择,ClassHelper 班级端灵动岛同步展示)。
* 自定义消息会替换/补全这些短语;`message` 为空时用短语本身。
*/
export const CALL_QUICK_PHRASES: readonly string[] = [
@@ -466,7 +595,11 @@ export function buildCallTitle(studentName: string, teacherName: string): string
/** Socket.IO 房间名生成规则:与后端保持一致,客户端订阅时复用 */
export const SOCKET_ROOMS = {
class: (classId: string): string => `class:${classId}`,
user: (userId: string): string => `user:${userId}`,
/**
* 会话自身的房间:老师/管理员是账号 id,ClassHelper 班级端是班级 id。
* 「叫人」等定向消息发给它。注意**没有"学生房间"** —— 学生不是账号,没有客户端会订阅。
*/
session: (sessionId: string): string => `session:${sessionId}`,
teacher: (teacherId: string): string => `teacher:${teacherId}`,
role: (role: UserRole): string => `role:${role}`,
students: 'students',
@@ -496,9 +629,6 @@ export const DEFAULT_SERVER_PORT = 4000;
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_PAGE_SIZE = 200;
/** 教师与管理员角色集合,便于 RBAC 判断 */
export const STAFF_ROLES: readonly UserRole[] = ['ADMIN', 'TEACHER'];
/* ------------------------------------------------------------------ 更新检查 */
/**
+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 },
];
+507 -42
View File
@@ -4,15 +4,43 @@
* 后端返回值与前端调用方都必须依赖它,避免契约漂移。
*/
/** 用户角色:管理员 / 教师 / 学生 */
export type UserRole = 'ADMIN' | 'TEACHER' | 'STUDENT';
/**
* 账号角色:管理员 / 教师 / ClassHelper 班级端。
*
* **没有 STUDENT**:学生不是账号,只是 `Student` 表里的班级名单记录,
* 既没有密码也没有登录入口(2026-10-06 起由表结构保证,不再靠代码分支拦)。
* `CLASS_DEVICE` 是教室里那台绑定到某个班的 ClassHelper 班级端(班级码 + 班级密码登录),
* 它代全班读写个人数据。
*/
export type UserRole = 'ADMIN' | 'TEACHER' | 'CLASS_DEVICE';
/** 通知优先级 */
export type NotificationPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
/** 成绩等级 */
/** 百分制默认等级换算结果的取值域(自定义等级不受它限制) */
export type GradeLevel = 'A' | 'B' | 'C' | 'D' | 'E';
/**
* 成绩的等级口径:
* - `percent` 百分制:等级由得分率自动换算(教师仍可手改单条)
* - `letter` 等级制:A / B / C / D
* - `custom` 自定义等级:教师填任意文本(优 / 良 / 合格 / 待提高…)
*/
export type GradeLevelType = 'percent' | 'letter' | 'custom';
/**
* 学生状态。
*
* - `active` 在读
* - `graduated` 已毕业(随届别归档)
* - `transferred` 已转出(学籍离开本校,班级归属保留作历史)
* - `inactive` 停用(休学等,记录保留)
*/
export type StudentStatus = 'active' | 'graduated' | 'transferred' | 'inactive';
/** 学生性别:'' = 未填 */
export type StudentGender = '' | 'MALE' | 'FEMALE';
/** 统一响应体(后端所有接口均返回该结构) */
export interface ApiResponse<T = unknown> {
success: boolean;
@@ -43,22 +71,24 @@ export interface PaginatedResult<T> {
pageSize: number;
}
/* ------------------------------------------------------------------ 用户 / 认证 */
/* ------------------------------------------------------------------ 账号(管理员 / 教师) */
/**
* 账号 DTO:管理员与教师。
*
* `username` 对教师而言就是**工号**(界面上一律显示「工号」),也是登录名。
* 学生不在这个类型里 —— 他们只是班级名单记录,见 `StudentDto`。
*/
export interface UserDto {
id: string;
username: string;
name: string;
role: UserRole;
classId: string | null;
/** 手机号(可空) */
phone: string;
createdAt: string;
}
export interface StudentDto extends UserDto {
className?: string | null;
grade?: string | null;
}
export interface LoginRequest {
username: string;
password: string;
@@ -66,14 +96,30 @@ export interface LoginRequest {
/**
* 登录会话主体。
* - 普通账号(教师/管理员):就是用户本身;学生个人账号已清理,学生只有名单记录;
* - 班级账号(班级设备):`classSession = true`,此时 `id` 与 `classId` 都是班级 id,
* `name` 是班级名,个人数据由服务端按"全班"范围读写。
* - 教师 / 管理员:就是账号本身(`classSession` 为空);
* - ClassHelper 班级端:`classSession = true`,此时 `id` 与 `classId` 都是班级 id,
* `name` 是班级名,`username` 是班级码,个人数据由服务端按「全班」范围读写。
*/
export interface SessionUser extends StudentDto {
export interface SessionUser {
id: string;
/** 教师/管理员的工号,或班级端的班级码 */
username: string;
name: string;
role: UserRole;
classId: string | null;
createdAt: string;
phone?: string;
className?: string | null;
grade?: string | null;
/** ClassHelper 班级端(班级会话) */
classSession?: boolean;
/** 班级账号的班级码(仅班级会话返回) */
/** 班级端的班级码(仅班级会话返回) */
classCode?: string;
/**
* 班级端按学号查询成绩明细的开关(仅班级会话返回)。
* 客户端据此决定要不要显示"按学号查成绩"入口。
*/
studentGradeQueryEnabled?: boolean;
}
export interface LoginResponse {
@@ -81,18 +127,265 @@ export interface LoginResponse {
user: SessionUser;
}
/** 班级账号登录(学生端):班级码 + 班级密码 */
/** 班级端登录(ClassHelper 班级端):班级码 + 班级密码 */
export interface ClassLoginRequest {
code: string;
password: string;
}
/* ------------------------------------------------------------------ 学生(班级名单记录,没有账号) */
/**
* 学生 DTO。
*
* 学生**不是账号**:没有密码、不能登录、没有个人入口。
* `studentNo`(学号)是学生在系统里的唯一标识,也是查询键(成绩、作业提交名单、导入导出都用它)。
*/
export interface StudentDto {
id: string;
/** 学号:学生唯一标识(全校唯一) */
studentNo: string;
name: string;
/** 当前班级;null = 未分班 */
classId: string | null;
className?: string | null;
grade?: string | null;
gender: StudentGender;
/** 家长手机号(可空) */
guardianPhone: string;
status: StudentStatus;
/** 毕业归档所属届别;null = 未归档 */
archivedYearId?: string | null;
archivedAt?: string | null;
/** 转出时间(status='transferred' 时写入) */
transferredAt?: string | null;
transferNote?: string;
createdAt: string;
}
/** 学生简要信息(成绩、提交名单等引用处使用) */
export interface StudentBrief {
id: string;
studentNo: string;
name: string;
}
export interface CreateStudentRequest {
studentNo: string;
name: string;
classId?: string | null;
gender?: StudentGender;
guardianPhone?: string;
status?: StudentStatus;
}
export interface UpdateStudentRequest {
studentNo?: string;
name?: string;
classId?: string | null;
gender?: StudentGender;
guardianPhone?: string;
status?: StudentStatus;
}
/** 学生是否属于某个班的判定范围(列表查询用) */
export interface StudentQueryParams {
classId?: string;
keyword?: string;
}
/* ------------------------------------------------------------------ 调班 */
/** 调班 / 转出的方式 */
export type StudentTransferMode = 'single' | 'batch' | 'transfer-out';
/** 一次调班 / 转出的历史记录(原班级 / 新班级 / 操作人 / 时间) */
export interface StudentTransferDto {
id: string;
studentId: string;
/** 学号快照:学生被删除后历史仍可读 */
studentNo: string;
studentName: string;
fromClassId: string | null;
fromClassName: string;
toClassId: string | null;
toClassName: string;
operatorId: string | null;
operatorName: string;
/** single = 单个调班 / batch = 批量调班 / transfer-out = 转出(离开本校) */
mode: StudentTransferMode;
note: string;
createdAt: string;
}
/**
* 调班请求:单个与批量共用。
* 传一个 id 就是单个调班;`toClassId = null` 表示移出班级(回到「未分班」)。
* **学号不变**,只改学生当前的班级归属;历史作业与成绩保留原归属。
*/
export interface TransferStudentsRequest {
studentIds: string[];
toClassId: string | null;
note?: string;
}
/**
* 学生转出(学籍离开本校,不是调班):班级归属保留作历史,状态置为 `transferred`。
* 单个与批量共用 —— 传一个 id 就是单个。
*/
export interface TransferOutStudentsRequest {
studentIds: string[];
/** 转出去向 / 原因备注 */
note?: string;
}
/* ------------------------------------------------------------------ 毕业归档 */
/**
* 届别档案:一次「毕业归档」的留痕。
*
* 归档是**打标记 + 只读**,不删任何数据:毕业班级的作业与通知原样留在库里,
* 通过归档详情只读查看。**未毕业而升级的班级不归档**(升级只改年级,数据沿用)。
*/
export interface ArchivedYearDto {
id: string;
/** 届别 = 入学年份(2026 表示 2026 级) */
enrollmentYear: number;
/** 毕业年份 */
graduationYear: number;
/** 届别名,如「2026 级」 */
name: string;
note: string;
operatorId: string | null;
operatorName: string;
/** 归档时的统计快照 */
classCount: number;
studentCount: number;
transferredCount: number;
homeworkCount: number;
notificationCount: number;
archivedAt: string;
}
/** 归档里的一个班级 */
export interface ArchivedClassDto {
id: string;
name: string;
grade: string;
enrollmentYear: number | null;
classIndex: number | null;
headTeacherName: string | null;
studentCount: number;
homeworkCount: number;
notificationCount: number;
archivedAt: string | null;
}
export interface ArchivedYearDetailDto extends ArchivedYearDto {
classes: ArchivedClassDto[];
/** 本届毕业生 */
graduates: StudentDto[];
/** 本届在读期间转出的学生 */
transferred: StudentDto[];
}
/** 归档班级的作业与通知(只读) */
export interface ArchivedClassContentDto {
classId: string;
className: string;
homeworks: HomeworkDto[];
notifications: NotificationDto[];
}
export interface CreateArchiveRequest {
/** 要归档的届别(入学年份) */
enrollmentYear: number;
/** 毕业年份;留空 = 入学年份 + 默认学制(3 年) */
graduationYear?: number;
note?: string;
}
/** 学年升级:只改年级,不归档、数据沿用 */
export interface PromoteClassesRequest {
classIds: string[];
/** 升级后的年级,如「高二」 */
grade: string;
}
/* ------------------------------------------------------------------ 学期周次 */
/**
* 一个教学周的日期区间。
*
* 学期周次不能只靠"开学日期 + 每周七天"线性推算:法定节假日调休、周末补课、错峰开学
* 都会让某一周变长变短。管理员可以逐周设置区间(也可以先按开学日期一键生成再微调)。
*/
export interface TermWeekDto {
weekNumber: number;
/** YYYY-MM-DD(本地日期) */
startDate: string;
/** YYYY-MM-DD */
endDate: string;
note: string;
}
export interface TermWeeksDto {
/**
* 这套区间属于谁:空串 = **全校默认**;非空 = 某个班的覆盖。
* 班级没有自己的配置时回落到全校默认。
*/
classId: string;
/** 学期开始日期(第 1 教学周的周一,YYYY-MM-DD) */
termStartDate: string;
/** 当前教学周(按区间判定;不在任何区间内时按学期开始日期线性兜底) */
currentWeek: number;
/** 本学期的教学周数上限 */
maxWeek: number;
/** 是否已逐周配置(false = 完全按学期开始日期线性推算) */
configured: boolean;
weeks: TermWeekDto[];
}
export interface UpdateTermWeeksRequest {
/** 不传 = 改全校默认 */
classId?: string | null;
/** 学期开始日期(第 1 教学周的周一);不传则保持原值 */
termStartDate?: string;
/** 逐周区间(整表覆盖) */
weeks: Array<{ weekNumber: number; startDate: string; endDate: string; note?: string }>;
}
export interface AutoTermWeeksRequest {
classId?: string | null;
termStartDate?: string;
/** 生成多少周;不传用班级的教学周数(Class.termWeeks) */
maxWeek?: number;
}
/** 联网获取的法定节假日建议(失败时 ok=false,属于正常结果) */
export interface HolidaySuggestionDto {
ok: boolean;
/** 失败原因(给人看的一句话);ok=true 时为 null */
error: string | null;
/** 数据来源年份 */
year: number;
/** 建议的「不上课」日期区间(调休放假),管理员可据此调整周次 */
holidays: Array<{ name: string; startDate: string; endDate: string }>;
checkedAt: string;
}
/* ------------------------------------------------------------------ 班级 */
export interface ClassDto {
id: string;
/** 班级称呼,如「2026级1班」(由 enrollmentYear + classIndex 生成) */
name: string;
/** 当前年级(高一 / 高二 / 高三),学年升级时更新 */
grade: string;
/** 入学年份(届别):2026 → 称呼「2026级…」 */
enrollmentYear: number | null;
/** 班号(同一届内的序号) */
classIndex: number | null;
teacherId: string;
createdAt: string;
teacher?: ClassTeacherBrief | null;
@@ -100,33 +393,79 @@ export interface ClassDto {
courseCount?: number;
homeworkCount?: number;
notificationCount?: number;
/** 班级码(学生端班级账号登录用);仅对有管理权限的角色返回 */
/** 班级码(ClassHelper 班级端班级账号登录用);仅对有管理权限的角色返回 */
code?: string;
/** 是否已设置班级密码(哈希永不外泄) */
hasPassword?: boolean;
/** 本学期教学周数(班主任可调,默认 20):课表周次选择与默认 weekEnd 都用它 */
termWeeks: number;
/** 本班学期开始日期(YYYY-MM-DD);null = 用全局配置 */
termStartDate?: string | null;
/** 是否已有启用中的 ClassIsland 联动设备(班级列表/课表页显示「已接入」徽标) */
classIslandConnected?: boolean;
/** 通知显示位置(由教室的班级客户端设置,见 CLASSISLAND_NOTIFICATION_CHANNELS) */
/** 通知显示位置(由 ClassHelper 班级端设置,见 CLASSISLAND_NOTIFICATION_CHANNELS) */
notificationChannel?: ClassIslandNotificationChannel;
/**
* 是否允许 ClassHelper 班级端按学号查询本班学生的成绩明细(默认开启)。
* 关闭后班级端查成绩一律 403,教师端不受影响。
*/
studentGradeQueryEnabled: boolean;
/** 毕业归档所属届别;null = 在读(常规班级列表只显示未归档的) */
archivedYearId?: string | null;
archivedAt?: string | null;
}
/** 账号简要信息(工号 + 姓名):用于"班主任""发布人"等展示位 */
export interface ClassTeacherBrief {
id: string;
name: string;
/** 教师的工号(管理员为登录名) */
username: string;
}
/**
* 某班某科的任课老师 —— 需求里的「班级 + 科目 + 教师」任课关系。
* 落在 `Course` 上(它本来就是 `classId + name + teacherId`)。
*/
export interface SubjectTeacherDto {
courseId: string;
/** 科目名 */
subjectName: string;
teacherId: string;
teacherName: string;
}
/** 管理员设置某班某科的任课老师 */
export interface AssignSubjectTeachersRequest {
assignments: Array<{
/** 已有课程 id;留空时按 subjectName 匹配或自动建课 */
courseId?: string | null;
subjectName: string;
/** 留空表示解除该科的任课老师(保留课程本身) */
teacherId?: string | null;
}>;
}
export interface ClassDetailDto extends ClassDto {
students: StudentDto[];
courses: CourseDto[];
teachers: ClassTeacherBrief[];
/** 班主任(与 `teacher` 同一人,单独给一个语义明确的字段) */
headTeacher: ClassTeacherBrief | null;
/** 各科任课老师 */
subjectTeachers: SubjectTeacherDto[];
}
export interface CreateClassRequest {
name: string;
/**
* 班级称呼。**可以留空** —— 填了 `enrollmentYear` 与 `classIndex` 时由服务端生成「2026级1班」,
* 这时传进来的 name 会被忽略(避免出现"名字是高一(1)班、届别是 2026 级"这种自相矛盾的数据)。
*/
name?: string;
grade: string;
/** 入学年份(届别),如 2026 */
enrollmentYear: number;
/** 班号(同一届内的序号),如 1 */
classIndex: number;
/** 可选:自定义班级码,留空自动生成 */
code?: string;
/** 本学期教学周数(1~30,仅管理员可调);不传由服务端用默认值 20 */
@@ -136,12 +475,23 @@ export interface CreateClassRequest {
}
export interface UpdateClassRequest {
name?: string;
/** 本班学期开始日期(YYYY-MM-DD,第 1 教学周的周一);留空串表示回落到全局配置 */
termStartDate?: string | null;
/** 入学年份(有名无实的旧数据需要补填;填了会重算 name) */
enrollmentYear?: number;
/** 班号(同上) */
classIndex?: number;
/** 当前年级(高一 / 高二 / 高三;学年升级时改它,**不归档**) */
grade?: string;
/** 本学期教学周数(1~30,仅管理员可调) */
termWeeks?: number;
}
/** 班级端成绩查询开关 */
export interface UpdateStudentGradeQueryRequest {
enabled: boolean;
}
/** 管理员设置/重置班级账号(班级码 + 班级密码) */
export interface UpdateClassAccountRequest {
/** 新的班级码(4~16 位字母数字,留空表示不改) */
@@ -292,7 +642,7 @@ export interface HomeworkDto {
assignDate: string;
course?: CourseBrief | null;
creator?: ClassTeacherBrief | null;
/** 当前登录学生的完成状态(学生端接口返回) */
/** 当前登录学生的完成状态(ClassHelper 班级端接口返回) */
completed?: boolean;
homeworkStatus?: HomeworkStatusDto | null;
/** 教师视角:已提交人数 */
@@ -315,7 +665,8 @@ export interface HomeworkDaysDto {
export interface HomeworkStatusDto {
id: string;
homeworkId: string;
userId: string;
/** 学生 id(学生是名单记录,没有账号) */
studentId: string;
completed: boolean;
updatedAt: string;
}
@@ -336,15 +687,16 @@ export interface UpdateHomeworkStatusRequest {
/** 作业提交名单里的一位学生 */
export interface HomeworkSubmissionDto {
userId: string;
studentId: string;
/** 学号(学生唯一标识与查询键) */
studentNo: string;
name: string;
username: string;
completed: boolean;
}
/**
* 作业提交名单("未交名单"功能的载体):
* 教师端 / 班级设备用它勾选谁没交作业,其余学生一律视为已交。
* 教师端 / ClassHelper 班级端用它勾选谁没交作业,其余学生一律视为已交。
*/
export interface HomeworkSubmissionsDto {
homeworkId: string;
@@ -360,13 +712,13 @@ export interface HomeworkSubmissionsDto {
export interface UpdateHomeworkSubmissionsRequest {
/** 未交作业的学生 id(其余学生一律标记为已交) */
notSubmittedUserIds: string[];
notSubmittedStudentIds: string[];
}
export interface HomeworkQueryParams {
classId?: string;
courseId?: string;
/** 学生端:只看未完成 */
/** ClassHelper 班级端:只看未完成 */
pendingOnly?: boolean;
keyword?: string;
}
@@ -403,14 +755,14 @@ export interface CreateNotificationRequest {
/**
* 叫人请求:老师在 Web 管理端选中学生 + 快捷短语/自定义消息,
* 学生端灵动岛会立即弹出"请 XXX 同学找 XXX 老师"。
* ClassHelper 班级端灵动岛会立即弹出"请 XXX 同学找 XXX 老师"。
*/
export interface CreateCallRequest {
classId: string;
studentId: string;
/**
* 是否"紧急叫人":
* - true:落库为 URGENT,学生端灵动岛无视上课时段立即展开(与紧急通知同等待遇);
* - true:落库为 URGENT,ClassHelper 班级端灵动岛无视上课时段立即展开(与紧急通知同等待遇);
* - 省略/false:普通叫人(默认),按普通通知处理 —— 上课时段只进队列、不打断课堂,下课后弹出。
*/
urgent?: boolean;
@@ -433,23 +785,38 @@ export interface GradeDto {
id: string;
classId: string;
courseId: string | null;
userId: string;
/** 学生 id(学生是名单记录,没有账号) */
studentId: string;
examName: string;
score: number;
totalScore: number;
/**
* 等级口径:percent 百分制 / letter 等级制 A-D / custom 自定义等级。
* 决定 `level` 是怎么来的,也决定界面上给不给"改等级"的入口。
*/
levelType: GradeLevelType;
/**
* 等级文本。`percent` 下由服务端按得分率生成初值(教师仍可手改并覆盖),
* `letter`/`custom` 下由教师直接指定。
*/
level: string;
publishedAt: string;
course?: CourseBrief | null;
student?: ClassTeacherBrief | null;
student?: StudentBrief | null;
className?: string | null;
}
export interface CreateGradeRequest {
classId: string;
courseId?: string | null;
userId: string;
studentId: string;
examName: string;
score: number;
totalScore?: number;
/** 等级口径,默认 percent */
levelType?: GradeLevelType;
/** 手填等级;percent 且留空时由服务端按得分率换算 */
level?: string;
publishedAt?: string | null;
}
@@ -459,16 +826,35 @@ export interface BulkCreateGradeRequest {
courseId?: string | null;
examName: string;
totalScore?: number;
levelType?: GradeLevelType;
publishedAt?: string | null;
items: Array<{ userId: string; score: number }>;
items: Array<{ studentId: string; score: number; level?: string }>;
}
/** 成绩统计(教师端图表 / 学生端汇总) */
/** 单个 / 批量修改等级 */
export interface UpdateGradeLevelsRequest {
/** 逐条指定等级(单个就是长度为 1 的数组) */
items: Array<{ id: string; level: string }>;
}
/** 按学号查询的学生成绩明细(ClassHelper 班级端 / 教师端共用) */
export interface StudentGradeDetailDto {
student: StudentBrief;
classId: string | null;
className: string | null;
/** 该生的全部成绩(按考试时间倒序) */
items: GradeDto[];
/** 得分率均值(0-100,一位小数) */
averagePercent: number;
}
/** 成绩统计(教师端图表 / 班级端汇总) */
export interface GradeStats {
total: number;
averagePercent: number;
byCourse: Array<{ courseId: string | null; courseName: string; count: number; averagePercent: number }>;
distribution: Array<{ level: GradeLevel; count: number }>;
/** 等级分布:等级文本 -> 人数(自定义等级也能统计) */
distribution: Array<{ level: string; count: number }>;
}
/* ------------------------------------------------------------------ 上课时段(灵动岛 / 紧急通知确认共用) */
@@ -647,14 +1033,24 @@ export interface ServerToClientEvents {
'classisland:state': (payload: ClassIslandStateEvent) => void;
/** ClassIsland 联动:老师发的提醒已下发给该班设备(Web 端据此提示「已送达」) */
'classisland:notification': (payload: ClassIslandNotificationEvent) => void;
connected: (payload: { userId: string; role: UserRole; rooms: string[] }) => void;
/** 会话主体 id:教师/管理员是自己的账号 id,ClassHelper 班级端是班级 id */
connected: (payload: { sessionId: string; role: UserRole; rooms: string[] }) => void;
}
/* ------------------------------------------------------------------ 导入(模板 / 表格 / 课表时间配置) */
/**
* 表格导入的类型:
* - `grades` 成绩单(按学号匹配本班学生)
* - `students` 学生名单(学号必填唯一;只建名单,不建账号)
* - `teachers` 教师账号名单(工号 + 姓名)
* - `classTeachers` 班级任课老师(班级 + 科目 + 工号 + 角色)
*/
export type TableImportKind = 'grades' | 'students' | 'teachers' | 'classTeachers';
/** 表格导入预览:列名、前若干行、建议字段映射与校验问题 */
export interface TableImportPreview {
kind: 'grades' | 'students';
kind: TableImportKind;
columns: string[];
rows: string[][];
totalRows: number;
@@ -672,7 +1068,7 @@ export interface TableImportRowError {
/** 导入结果统计 */
export interface TableImportResult {
kind: 'grades' | 'students';
kind: TableImportKind;
total: number;
inserted: number;
updated: number;
@@ -731,7 +1127,7 @@ export type ClassIslandNotificationChannel = 'both' | 'client' | 'classisland';
/**
* 联动方式:
* - `plugin`:安装「班级小助手联动插件」,由插件把 ClassIsland 的当前状态与课表推给本服务;
* - `plugin`:安装「ClassHelper 联动插件」,由插件把 ClassIsland 的当前状态与课表推给本服务;
* - `import`:只把 ClassIsland 导出的档案 JSON 导入本服务(一次性,无实时联动)。
*/
export type IntegrationMode = 'plugin' | 'import';
@@ -752,12 +1148,20 @@ export interface IntegrationDeviceDto {
mode: IntegrationMode;
/** 是否接入:接入后插件才能上报状态/课表并接收通知 */
enabled: boolean;
/** 是否允许插件把 ClassIsland 的课表回传到本服务(覆盖/合并本班课表) */
/**
* 是否允许插件把 ClassIsland 的课表**自动**回传到本服务(覆盖/合并本班课表)。
* **默认关闭**:课表以服务端为准,自动回传会在老师手排课之后被教室的旧课表覆盖;
* 要取教室的课表请用「从教室机器获取课表」这个手动动作。
*/
syncScheduleToServer: boolean;
/** 是否允许把班级课表镜像回 ClassIsland(由插件执行) */
mirrorScheduleToClassIsland: boolean;
/** 最后一次成功上报的时间 */
/** 最后一次"带运行状态"的上报时间(心跳之外还有实际内容的那次) */
lastSeenAt: string | null;
/** 最后一次心跳时间(每次上报都刷新);在线判定看它 */
lastHeartbeatAt: string | null;
/** 是否在线:最后一次心跳在 CLASS_HELPER_ONLINE_WINDOW_MS 之内 */
online: boolean;
/** 最后一次上报里 ClassIsland 是否已加载课表 */
classPlanLoaded: boolean;
/** 最后一次上报里的当前科目 */
@@ -769,6 +1173,8 @@ export interface IntegrationDeviceDto {
currentPeriodEnd: string | null;
/** 最后一次上报里的下一节课科目 */
nextSubject: string | null;
/** 是否有待办的「立即上报一次课表」请求(Web 端点过之后置位,插件上报后清除) */
requestScheduleReport: boolean;
/** 令牌前缀提示(chci_xxxxxxxx…):设备列表里用于区分多台设备各自用的令牌 */
tokenHint?: string;
/** 创建/重置接口一次性下发的纯文本设备令牌(其他接口永不返回) */
@@ -799,6 +1205,53 @@ export interface UpdateIntegrationDeviceRequest {
mode?: IntegrationMode;
}
/* ------------------ ClassHelper 班级端在线状态(教师网页查看) ------------------ */
/** 一台 ClassHelper 班级端设备的在线状态 */
export interface ClassHelperDeviceStatusDto {
deviceId: string;
/** 设备显示名(默认取机器名) */
deviceName: string;
/** 设备/机器码 */
deviceKey: string;
/** 是否接入(停用的设备一律按离线展示) */
enabled: boolean;
/** 在线 = 最近一次心跳在 CLASS_HELPER_ONLINE_WINDOW_MS 之内 */
online: boolean;
/** 最后一次带运行状态的上报时间 */
lastSeenAt: string | null;
/** 最后一次心跳时间 */
lastHeartbeatAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
}
/**
* 某个班的 ClassHelper 班级端在线状态。
*
* 范围:管理员看全部班级;普通教师看自己担任班主任的班级 ∪ 任课(有 Course)的班级。
*/
export interface ClassHelperStatusDto {
classId: string;
className: string;
grade: string;
/** 该班绑定的全部设备(可能多台:讲台机 + 备用机) */
devices: ClassHelperDeviceStatusDto[];
/** 任一设备在线即视为该班在线 */
online: boolean;
/** 是否允许班级端按学号查询本班学生成绩明细 */
studentGradeQueryEnabled: boolean;
}
/** GET /api/integrations/classisland/status 的返回 */
export interface ClassHelperStatusListDto {
/** 后端判定所用的服务器时间(前端据此算"距今多久",避免依赖本机时钟) */
serverTime: string;
/** 在线判定窗口(毫秒) */
onlineWindowMs: number;
classes: ClassHelperStatusDto[];
}
/** 通知下发:把一条通知推给某班级的 ClassIsland 设备(在 ClassIsland 上全屏提醒) */
export interface SendClassIslandNotificationRequest {
classId: string;
@@ -907,6 +1360,11 @@ export interface ClassIslandReportResult {
settings: {
mirrorScheduleToClassIsland: boolean;
enabled: boolean;
/**
* 服务端请求插件**立刻上报一次课表**(Web 端点了「从教室机器获取课表」)。
* 独立于 `syncScheduleToServer`:那个开关管的是"要不要自动回传"。
*/
scheduleRequested?: boolean;
};
/** 尚未在 ClassIsland 上确认过的一条提醒(插件可立即弹出) */
pendingNotification?: ClassIslandPushNotification | null;
@@ -980,6 +1438,13 @@ export interface ClassIslandClassPlanPull {
termStartDate: string;
};
week: number;
/**
* 学期逐周区间(管理员在「学期周次」里配的)。
* 空数组 = 完全按 `termStartDate` 线性推算。
* ClassIsland 自身只认"教学周序号 + 单双周",因此它拿这个主要是为了
* 校正"现在是第几周"、以及在设置页里展示教室所在的实际日期区间。
*/
weekRanges?: Array<{ weekNumber: number; startDate: string; endDate: string }>;
serverTime: string;
}
+108 -1
View File
@@ -173,6 +173,30 @@ export function durationMinutes(startTime: string, endTime: string): number {
return (eh as number) * 60 + (em as number) - ((sh as number) * 60 + (sm as number));
}
/* ------------------------------------------------------------------ 学生 / 学号 */
/**
* 学号归一化:去掉**全部空白**(首尾与内部),字母统一转大写。
*
* 学号是学生的唯一标识与查询键,因此这些手输差异必须收敛到同一个键上,
* 否则同一个学生会在名单/成绩里变成两条:
* 「2026 001」与「2026001」 —— 多余空格;
* 「a2026001」与「A2026001」 —— 大小写。
* 统一转大写(而不是只去空格)是关键:`Student.studentNo` 上的唯一约束因此
* **天然就是大小写无关的**,不需要在写入前多查一次库,也不会出现
* "库里存着两条只差大小写的学号"这种脏数据。数字学号不受影响。
*
* 允许的字符集与后端 zod 校验一致:1~32 位数字 / 字母 / `.` `_` `-`。
*/
export function normalizeStudentNo(value: string): string {
return value.replace(/\s+/g, '').toUpperCase();
}
/** 学号格式校验(与后端 `studentNoSchema` 同一口径) */
export function isValidStudentNo(value: string): boolean {
return /^[A-Za-z0-9._-]{1,32}$/.test(normalizeStudentNo(value));
}
/* ------------------------------------------------------------------ 成绩 */
/** 得分率(0-100),总分非法时返回 0 */
@@ -181,7 +205,7 @@ export function gradePercent(score: number, totalScore: number): number {
return round((score / totalScore) * 100, 1);
}
/** 等级换算,学生端图表与标签复用 */
/** 等级换算,ClassHelper 班级端图表与标签复用 */
export function gradeLevel(percent: number): GradeLevel {
if (percent >= 90) return 'A';
if (percent >= 80) return 'B';
@@ -291,6 +315,89 @@ export function resolveCurrentWeek(
return clamp(week, 1, maxWeek);
}
/* ------------------------------------------------------------------ 学期周次区间 */
/**
* 按**逐周区间**判定当前是第几教学周。
*
* 为什么需要它:`resolveCurrentWeek` 假设"每周都是七天、中间没有断档",
* 而法定节假日调休、周末补课、错峰开学都会让某一周变长变短 —— 线性推算出来的周次
* 会与教室里实际情况整体错位(课表、作业、成绩都按周次组织,错一周就全错)。
*
* @returns 命中的周次;当前日期不在任何已配置的区间内时返回 `null`(交给调用方线性兜底)
*/
export function resolveWeekFromRanges(
ranges: readonly { weekNumber: number; startDate: string; endDate: string }[],
now: Date = new Date(),
): number | null {
const today = dayKeyLocal(now);
if (!today) return null;
for (const range of ranges) {
// 日期串是 YYYY-MM-DD,字典序即时间序,不需要解析成 Date
if (today >= range.startDate && today <= range.endDate) return range.weekNumber;
}
return null;
}
/**
* 按"学期开始日期 + 每周七天"生成逐周区间(管理员的起点,生成后可以逐周微调)。
*
* @param termStartDate 第 1 教学周的**周一**(YYYY-MM-DD)
*/
export function buildAutoTermWeeks(
termStartDate: string,
maxWeek: number = MAX_TERM_WEEK,
): Array<{ weekNumber: number; startDate: string; endDate: string; note: string }> {
if (!termStartDate) return [];
const start = toDate(termStartDate);
if (Number.isNaN(start.getTime())) return [];
const weekday = start.getDay() === 0 ? 7 : start.getDay();
const monday = new Date(start.getTime() - (weekday - 1) * MS_PER_DAY);
monday.setHours(0, 0, 0, 0);
const total = Math.max(1, Math.min(maxWeek, MAX_TERM_WEEK));
const weeks: Array<{ weekNumber: number; startDate: string; endDate: string; note: string }> = [];
for (let index = 0; index < total; index += 1) {
const from = new Date(monday.getTime() + index * 7 * MS_PER_DAY);
const to = new Date(from.getTime() + 6 * MS_PER_DAY);
weeks.push({
weekNumber: index + 1,
startDate: dayKeyLocal(from),
endDate: dayKeyLocal(to),
note: '',
});
}
return weeks;
}
/**
* 校验一组周次区间:周次为正整数、起止顺序正确、**区间之间不重叠**。
* 返回人类可读的错误列表(空数组 = 通过)。
*/
export function validateTermWeeks(
weeks: readonly { weekNumber: number; startDate: string; endDate: string }[],
): string[] {
const errors: string[] = [];
const ordered = [...weeks].sort((a, b) => a.startDate.localeCompare(b.startDate));
for (const week of ordered) {
if (!isDayKey(week.startDate) || !isDayKey(week.endDate)) {
errors.push(`第 ${week.weekNumber} 周:日期格式应为 YYYY-MM-DD`);
continue;
}
if (week.startDate > week.endDate) {
errors.push(`第 ${week.weekNumber} 周:开始日期晚于结束日期`);
}
}
for (let index = 1; index < ordered.length; index += 1) {
const prev = ordered[index - 1]!;
const current = ordered[index]!;
if (current.startDate <= prev.endDate) {
errors.push(`第 ${prev.weekNumber} 周与第 ${current.weekNumber} 周的日期区间重叠`);
}
}
return errors;
}
/** 把课表条目转换成时段信息 */
export function toClassPeriod(schedule: ScheduleDto): ClassPeriod {
return {