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

@@ -1,8 +1,10 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import * as gradeService from '../grades/grades.service.js';
import { studentGradeQuerySchema } from '../grades/grades.schemas.js';
import { authenticateDevice, assertDeviceEnabled, getDevice } from './device-auth.js';
import {
ackNotificationSchema,
@@ -70,6 +72,38 @@ router.get('/classisland/class-plan', authenticateDevice(), async (req, res) =>
sendOk(res, await integrationService.pullClassPlanForDevice(device), '获取课表成功');
});
/**
* GET /api/integrations/classisland/student-grades?studentNo= - 班级端按学号查成绩明细
*
* 给"插件形态的 ClassHelper 班级端"留的通道:走设备令牌而不是 JWT。
* 受**同一个班级开关**约束(`Class.studentGradeQueryEnabled`),与桌面客户端走的是同一条 service。
*/
router.get(
'/classisland/student-grades',
authenticateDevice(),
validate({ query: studentGradeQuerySchema }),
async (req, res) => {
const device = getDevice(req);
assertDeviceEnabled(device);
const { studentNo } = validatedQuery<{ studentNo?: string }>(req);
if (!studentNo) throw ApiError.badRequest('请提供学号(studentNo)');
// 设备令牌没有"账号",构造一个等价于 ClassHelper 班级端的会话载荷
const session = {
sub: device.classId,
username: '',
name: '',
role: 'CLASS_DEVICE' as const,
classId: device.classId,
classSession: true,
};
sendOk(
res,
await gradeService.getStudentGradeDetail(session, studentNo, device.classId),
'获取学生成绩明细成功',
);
},
);
/* ================================================================== Web 管理端
* 用 JWT 鉴权;权限与"课表管理 / 发通知"保持一致,由 service 内二次校验班级维度角色。
*/
@@ -91,6 +125,16 @@ router.get(
},
);
/**
* GET /api/integrations/classisland/status - **ClassHelper 班级端在线状态**(教师网页)
*
* 管理员看全部班级;普通教师看自己担任班主任的班级 ∪ 任课的班级。
* 每条含:班级、设备名/设备号、在线/离线、最后在线时间、最后心跳时间。
*/
router.get('/classisland/status', requireRole('ADMIN', 'TEACHER'), async (req, res) => {
sendOk(res, await integrationService.listClassHelperStatus(getAuthUser(req)), '获取在线状态成功');
});
/** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */
router.post(
'/devices',
@@ -120,6 +164,25 @@ router.patch(
},
);
/**
* POST /api/integrations/devices/:id/request-schedule - 「从教室机器获取一次课表」
*
* **这是现在取教室课表的唯一入口**(原来的自动回传已默认关闭,见
* `IntegrationDevice.syncScheduleToServer`)。Web 端点击前会弹警告对话框,
* 说明它会用教室机器的课表覆盖本班现有课表。
* 置一个待办标记,插件在下一次心跳看到就立刻把课表推上来。
*/
router.post(
'/devices/:id/request-schedule',
requireRole('ADMIN', 'TEACHER'),
validate({ params: deviceIdParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const result = await integrationService.requestScheduleReport(getAuthUser(req), id);
sendOk(res, result, result.nextHeartbeatHint);
},
);
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
router.post(
'/devices/:id/token',
@@ -22,9 +22,12 @@ export const createDeviceSchema = z.object({
classId: z.string().min(1, '请选择班级'),
name: z.string().trim().max(40).optional(),
mode: integrationModeSchema.optional(),
/** 是否允许插件把 ClassIsland 的课表回传到本服务 */
/**
* 是否允许插件把 ClassIsland 的课表**自动**回传到本服务。
* 默认 **false**(课表以服务端为准);要取教室的课表用「从教室机器获取课表」这个手动动作。
*/
syncScheduleToServer: z.boolean().optional(),
/** 是否允许把本班课表镜像回 ClassIsland */
/** 是否允许把本班课表镜像回 ClassIsland。默认 **true** —— 这是现在课表同步的主方向。 */
mirrorScheduleToClassIsland: z.boolean().optional(),
});
@@ -1,8 +1,10 @@
import {
CLASS_HELPER_ONLINE_WINDOW_MS,
SOCKET_EVENTS,
resolveCurrentWeek,
weekDivFromParity,
type ClassDto,
type ClassHelperDeviceStatusDto,
type ClassHelperStatusListDto,
type ClassIslandPendingResult,
type ClassIslandPushNotification,
type ClassIslandReportResult,
@@ -13,8 +15,8 @@ import {
type TimeLayoutEntry,
type NotificationDto,
} from '@classhelper/shared';
import { env } from '../../config/env.js';
import { assertCanManageSchedule, assertCanPublishContent, resolveClassScope } from '../../lib/access.js';
import { assertCanManageSchedule, assertCanPublishNotification, resolveClassScope } from '../../lib/access.js';
import { loadTermContext, resolveCurrentWeek } from '../../lib/term.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
@@ -54,7 +56,13 @@ import {
/* ------------------------------------------------------------------ 设备管理(Web 端,JWT 鉴权) */
/** 设备记录 -> DTO(tokenHash 永不出库) */
/**
* 设备记录 -> DTO(tokenHash 永不出库)。
*
* `online` 由**最后一次心跳**(`lastHeartbeatAt`)与共享的
* `CLASS_HELPER_ONLINE_WINDOW_MS`(60 秒)算出,而不是最后一次带状态的上报 ——
* 插件每 60 秒打一次心跳,因此"最近一分钟内有心跳"才是最贴合的口径。
*/
function toDeviceDto(record: {
id: string;
classId: string;
@@ -64,9 +72,11 @@ function toDeviceDto(record: {
enabled: boolean;
syncScheduleToServer: boolean;
mirrorScheduleToClassIsland: boolean;
requestScheduleReport: boolean;
classIslandVersion: string | null;
pluginVersion: string | null;
lastSeenAt: Date | null;
lastHeartbeatAt: Date | null;
classPlanLoaded: boolean;
currentSubject: string | null;
currentTimeState: string | null;
@@ -78,6 +88,7 @@ function toDeviceDto(record: {
updatedAt: Date;
class?: { name: string } | null;
}): IntegrationDeviceDto {
const heartbeat = record.lastHeartbeatAt ?? record.lastSeenAt;
return {
id: record.id,
classId: record.classId,
@@ -88,9 +99,12 @@ function toDeviceDto(record: {
enabled: record.enabled,
syncScheduleToServer: record.syncScheduleToServer,
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
requestScheduleReport: record.requestScheduleReport,
classIslandVersion: record.classIslandVersion,
pluginVersion: record.pluginVersion,
lastSeenAt: record.lastSeenAt ? record.lastSeenAt.toISOString() : null,
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
online: Boolean(record.enabled && heartbeat && Date.now() - heartbeat.getTime() < CLASS_HELPER_ONLINE_WINDOW_MS),
classPlanLoaded: record.classPlanLoaded,
currentSubject: record.currentSubject,
currentTimeState: record.currentTimeState,
@@ -103,6 +117,74 @@ function toDeviceDto(record: {
};
}
/**
* GET /api/integrations/classisland/status —— **ClassHelper 班级端在线状态**(教师网页)。
*
* 范围:管理员看全部班级,普通教师看自己担任班主任的班级 ∪ 任课(有 Course)的班级。
* 这是「教师网页可查看班级 ClassHelper 在线状态」这条需求的落点。
*/
export async function listClassHelperStatus(user: TokenPayload): Promise<ClassHelperStatusListDto> {
const scope = await resolveClassScope(user);
const classes = await prisma.class.findMany({
where: {
...(scope.mode === 'all' ? {} : { id: { in: scope.classIds } }),
// 归档班级的班级端已经停用,不列出来
archivedYearId: null,
},
orderBy: [{ enrollmentYear: 'desc' }, { classIndex: 'asc' }],
select: {
id: true,
name: true,
grade: true,
studentGradeQueryEnabled: true,
integrationDevices: {
where: { mode: 'plugin' },
orderBy: [{ lastHeartbeatAt: 'desc' }],
select: {
id: true,
name: true,
deviceKey: true,
enabled: true,
lastSeenAt: true,
lastHeartbeatAt: true,
pluginVersion: true,
classIslandVersion: true,
},
},
},
});
const now = Date.now();
return {
serverTime: new Date().toISOString(),
onlineWindowMs: CLASS_HELPER_ONLINE_WINDOW_MS,
classes: classes.map((item) => {
const devices: ClassHelperDeviceStatusDto[] = item.integrationDevices.map((device) => {
const heartbeat = device.lastHeartbeatAt ?? device.lastSeenAt;
return {
deviceId: device.id,
deviceName: device.name,
deviceKey: device.deviceKey,
enabled: device.enabled,
online: Boolean(device.enabled && heartbeat && now - heartbeat.getTime() < CLASS_HELPER_ONLINE_WINDOW_MS),
lastSeenAt: device.lastSeenAt ? device.lastSeenAt.toISOString() : null,
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
pluginVersion: device.pluginVersion,
classIslandVersion: device.classIslandVersion,
};
});
return {
classId: item.id,
className: item.name,
grade: item.grade,
devices,
online: devices.some((device) => device.online),
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
};
}),
};
}
export async function listDevices(user: TokenPayload, classId?: string): Promise<IntegrationDeviceDto[]> {
// 管理员不传 classId 时看全部;教师只看自己可访问的班级
const scope = await resolveClassScope(user, classId);
@@ -134,13 +216,15 @@ export async function createDevice(
const created = await prisma.integrationDevice.create({
data: {
classId: input.classId,
name: input.name?.trim() || `${classRecord.name} ClassIsland 设备`,
name: input.name?.trim() || `${classRecord.name} ClassHelper 班级端`,
deviceKey: `pending-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
mode: input.mode ?? 'plugin',
tokenHash: hashDeviceToken(token),
tokenHint: tokenHintOf(token),
syncScheduleToServer: input.syncScheduleToServer !== false,
mirrorScheduleToClassIsland: input.mirrorScheduleToClassIsland === true,
// 同步方向(2026-10-06 起):课表以服务端为准,**服务端 → 教室自动下发**;
// 「把教室课表传上来」改成 Web 端的一次性人工动作,所以默认关掉自动回传。
syncScheduleToServer: input.syncScheduleToServer === true,
mirrorScheduleToClassIsland: input.mirrorScheduleToClassIsland !== false,
},
include: { class: { select: { name: true } } },
});
@@ -153,10 +237,11 @@ async function findDeviceOrFail(id: string): Promise<{
id: string;
classId: string;
name: string;
enabled: boolean;
}> {
const record = await prisma.integrationDevice.findUnique({
where: { id },
select: { id: true, classId: true, name: true },
select: { id: true, classId: true, name: true, enabled: true },
});
if (!record) throw ApiError.notFound('设备不存在或已被删除');
return record;
@@ -189,6 +274,37 @@ export async function updateDevice(
return toDeviceDto(updated);
}
/**
* 「**从教室机器获取一次课表**」的待办(Web 端手动动作)。
*
* 为什么是"待办"而不是直接拉:课表由**插件主动上报**,服务端没有反向通道去催它。
* 所以这里只置一个标记,插件在下一次上报(默认 60 秒一次的心跳)看到
* `settings.scheduleRequested = true` 就立刻把当前课表推上来并完成待办。
*
* 它独立于 `syncScheduleToServer`:后者是"允许自动回传",现在默认**关闭**
* (自动回传会在老师手排课之后被教室的旧课表悄悄覆盖);
* 管理员显式点"获取课表"时仍然应当能取到一次。
*/
export async function requestScheduleReport(
user: TokenPayload,
deviceId: string,
): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> {
const current = await findDeviceOrFail(deviceId);
await assertCanManageSchedule(user, current.classId);
if (!current.enabled) throw ApiError.badRequest('该设备已停用,请先启用后再获取课表');
await prisma.integrationDevice.update({
where: { id: deviceId },
data: { requestScheduleReport: true },
});
logger.info(`已请求设备上报课表:${current.name}(classId=${current.classId})由 ${user.name} 发起`);
return {
requested: true,
deviceName: current.name,
nextHeartbeatHint: '教室机器会在下一次心跳(约 1 分钟内)把当前课表上报上来',
};
}
/** 重置令牌:旧令牌立即失效(插件侧需要重新填写) */
export async function resetDeviceToken(
user: TokenPayload,
@@ -251,14 +367,14 @@ function toPushDto(
*
* - 落库(ClassIslandPush)→ 在线设备通过 Socket.IO 立即收到;离线设备重连后走
* `GET /api/integrations/classisland/pending` 补齐,保证"老师按了发送就一定送到";
* - `saveToNotifications` 默认 true:同时写一条班级通知,学生端通知中心/灵动岛也能看到,
* - `saveToNotifications` 默认 true:同时写一条班级通知,ClassHelper 班级端通知中心/灵动岛也能看到,
* 避免"只在 ClassIsland 上弹了一下,事后查无此事"。
*/
export async function sendNotification(
user: TokenPayload,
input: SendNotificationInput,
): Promise<SendClassIslandNotificationResult> {
await assertCanPublishContent(user, input.classId);
await assertCanPublishNotification(user, input.classId);
const teacher = await prisma.user.findUnique({
where: { id: user.sub },
@@ -286,8 +402,8 @@ export async function sendNotification(
include: { creator: { select: { id: true, name: true, username: true } }, reads: true },
});
notificationId = created.id;
const dto: NotificationDto = toNotificationDto(created, { userId: user.sub, withStatus: true });
// 学生端(桌面客户端 / 班级设备)照旧收到通知中心的实时推送
const dto: NotificationDto = toNotificationDto(created, { withStatus: true });
// ClassHelper 班级端照旧收到通知中心的实时推送
emitToClass(input.classId, SOCKET_EVENTS.notificationNew, dto);
}
@@ -420,17 +536,30 @@ export async function applyReport(
input: ClassIslandReportInput,
): Promise<ClassIslandReportResult> {
const now = new Date();
const week = resolveCurrentWeek(env.termStartDate, now);
// 周次口径按**本班**算(管理员可以给每个班单独配开学日期与逐周区间)
const week = resolveCurrentWeek(now, await loadTermContext(device.classId));
// 0) 机器码回填(设备创建时只能用 pending-… 占位,首次上报才知道它到底是哪台机器)
if (input.deviceKey) await syncDeviceKey(device, input.deviceKey);
// 1) 状态快照(无论课表是否上报都更新,Web 端据此显示"现在上什么课")
//
// 心跳与"带状态的上报"分开记:`lastHeartbeatAt` 每次上报都刷(在线判定看它),
// `lastSeenAt` 只在确实带了运行状态时刷 —— 这样"连得上但一直没上报状态"的设备
// 不会被误判成一切正常。
const state = input.state;
const currentDevice = await prisma.integrationDevice.findUnique({
where: { id: device.deviceId },
select: { requestScheduleReport: true },
});
const scheduleRequested = currentDevice?.requestScheduleReport === true;
await prisma.integrationDevice.update({
where: { id: device.deviceId },
data: {
lastSeenAt: now,
lastHeartbeatAt: now,
// 这次真的带了课表上来 → 待办完成
...(input.schedule ? { requestScheduleReport: false } : {}),
...(state ? { lastSeenAt: now } : {}),
...(input.pluginVersion ? { pluginVersion: input.pluginVersion } : {}),
...(input.classIslandVersion ? { classIslandVersion: input.classIslandVersion } : {}),
...(state
@@ -516,6 +645,10 @@ export async function applyReport(
settings: {
mirrorScheduleToClassIsland: device.mirrorScheduleToClassIsland,
enabled: device.enabled,
// 「从教室机器取一次课表」的待办:Web 端点过之后置位,插件看到就立刻推一次课表上来。
// 注意它**独立于** syncScheduleToServer —— 后者是"允许自动回传",现在默认关闭,
// 但管理员显式点"获取课表"时仍然应当能取到。
scheduleRequested: scheduleRequested,
},
pendingNotification: pending,
};
@@ -588,7 +721,7 @@ export async function listPendingNotifications(device: DeviceContext): Promise<C
return {
notifications: records.map((item) => toPushDto(item)),
serverTime: now.toISOString(),
week: resolveCurrentWeek(env.termStartDate, now),
week: resolveCurrentWeek(now, await loadTermContext(device.classId)),
};
}
@@ -613,14 +746,18 @@ export async function ackNotification(device: DeviceContext, id: string): Promis
export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
classPlan: MirrorClassPlan;
week: number;
/** 学期周次区间(管理员逐周配置的);空数组 = 按学期开始日期线性推算 */
weekRanges: Array<{ weekNumber: number; startDate: string; endDate: string }>;
serverTime: string;
}> {
const now = new Date();
const week = resolveCurrentWeek(env.termStartDate, now);
// 周次口径按**本班**算:管理员可以给每个班单独配开学日期与逐周区间(调休、错峰开学)
const termContext = await loadTermContext(device.classId);
const week = resolveCurrentWeek(now, termContext);
const classRecord = await prisma.class.findUnique({
where: { id: device.classId },
select: { id: true, name: true, termWeeks: true },
select: { id: true, name: true, termWeeks: true, termStartDate: true },
});
if (!classRecord) throw ApiError.notFound('班级不存在');
@@ -638,7 +775,7 @@ export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
// 节次时间表:优先用班级里已导入的 ClassIsland 时间表,其次按课表的开始时间合成
const layoutId = `ch-${device.classId}`;
const layoutName = timeLayouts[0]?.name ?? '班级小助手时间表';
const layoutName = timeLayouts[0]?.name ?? 'ClassHelper时间表';
let layouts: { startTime: string; endTime: string; timeType: number }[] = [];
if (timeLayouts.length > 0 && timeLayouts[0]) {
layouts = parseStoredTimeLayout(timeLayouts[0].items).map((item) => ({
@@ -669,14 +806,31 @@ export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
classPlan: {
entries,
timeLayouts: [{ id: layoutId, name: layoutName, layouts }],
profileName: `班级小助手-${classRecord.name}`,
termStartDate: env.termStartDate,
profileName: `ClassHelper-${classRecord.name}`,
// 下发本班实际生效的开学日期(管理员可以为某个班单独配,优先级高于全局 .env)
termStartDate: termContext.termStartDate,
},
week,
weekRanges: termContext.weeks,
serverTime: now.toISOString(),
};
}
/**
* 「课表/周次变了,请重新镜像」的轻量通知。
*
* 插件是**轮询**镜像的(不订阅 Socket.IO),所以这里只做两件事:
* 广播给 Web 端让页面刷新,以及把该班的设备标记为"有新版本"。
* 真正的下发发生在插件下一次拉取课表时 —— 它会把新的开学日期与周次区间一并带走。
*/
export async function notifyScheduleChanged(classId: string): Promise<void> {
emitToRooms([SOCKET_ROOMS.class(classId), SOCKET_ROOMS.teachers], SOCKET_EVENTS.scheduleUpdated, {
classId,
action: 'imported',
});
logger.info(`课表/周次已变更,等待教室机器下次镜像时下发:classId=${classId}`);
}
/** 解析库里存的节次 JSON(容错:坏数据不抛异常,退化为空) */
function parseStoredTimeLayout(raw: string): { startTime: string; endTime: string; type: string }[] {
try {
@@ -702,7 +856,7 @@ function parseStoredTimeLayout(raw: string): { startTime: string; endTime: strin
* 没有导入节次时间表时,用课表里出现过的起止时间合成一份(去重、按时间排序)。
*
* **每节课之间补一个课间**(timeType=1:上一节下课 → 下一节上课),最后一节之后不补(放学)。
* 班级小助手这边的节次时间表与 ClassIsland 档案保持同一口径,镜像过去才不会缺课间。
* ClassHelper这边的节次时间表与 ClassIsland 档案保持同一口径,镜像过去才不会缺课间。
*/
function synthesizeTimeLayout(
schedules: { startTime: string; endTime: string }[],