feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)

This commit is contained in:
LoveChengke committed 2026-09-11 21:57:38 +08:00
commit 84ab49de10
102 files changed
+14751

No files matched your search

@@ -0,0 +1,87 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { resolveCurrentWeek } from '../../lib/term.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
createScheduleSchema,
gridQuerySchema,
listSchedulesQuerySchema,
updateScheduleSchema,
type CreateScheduleInput,
type UpdateScheduleInput,
} from './schedules.schemas.js';
import * as scheduleService from './schedules.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */
router.get('/', validate({ query: listSchedulesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; week?: number; dayOfWeek?: number }>(req);
sendOk(res, await scheduleService.listSchedules(user, query), '获取课表成功');
});
/** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */
router.get('/grid', validate({ query: gridQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId, week } = validatedQuery<{ classId?: string; week?: number }>(req);
sendOk(
res,
await scheduleService.getScheduleGrid(user, { classId, week: week ?? resolveCurrentWeek() }),
'获取周视图成功',
);
});
/** POST /api/schedules - 新增课表 */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createScheduleSchema }),
async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
await scheduleService.createSchedule(user, req.body as CreateScheduleInput),
'课表创建成功',
);
},
);
/** PATCH /api/schedules/:id - 修改课表 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateScheduleSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(
res,
await scheduleService.updateSchedule(user, id, req.body as UpdateScheduleInput),
'课表更新成功',
);
},
);
/** DELETE /api/schedules/:id - 删除课表 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await scheduleService.deleteSchedule(user, id);
sendOk(res, { id }, '课表已删除');
},
);
export const schedulesModule = defineModule({
name: 'schedules',
basePath: '/schedules',
router,
});
@@ -0,0 +1,52 @@
import { z } from 'zod';
import { timeSchema, weekNumberSchema } from '../../lib/schemas.js';
export const listSchedulesQuerySchema = z.object({
classId: z.string().min(1).optional(),
/** 不传则按"当前教学周"过滤 */
week: weekNumberSchema.optional(),
dayOfWeek: z.coerce.number().int().min(1).max(7).optional(),
});
export const gridQuerySchema = z.object({
classId: z.string().min(1).optional(),
week: weekNumberSchema.optional(),
});
export const createScheduleSchema = z
.object({
classId: z.string().min(1, '请选择班级'),
courseId: z.string().min(1, '请选择课程'),
dayOfWeek: z.coerce.number().int().min(1, '星期范围为 1-7').max(7),
startTime: timeSchema,
endTime: timeSchema,
location: z.string().trim().max(64).nullish(),
weekStart: weekNumberSchema.optional(),
weekEnd: weekNumberSchema.optional(),
})
.refine((data) => data.startTime < data.endTime, {
message: '结束时间必须晚于开始时间',
path: ['endTime'],
})
.refine((data) => (data.weekStart ?? 1) <= (data.weekEnd ?? 20), {
message: '起始周次不能大于结束周次',
path: ['weekEnd'],
});
export const updateScheduleSchema = z
.object({
courseId: z.string().min(1).optional(),
dayOfWeek: z.coerce.number().int().min(1).max(7).optional(),
startTime: timeSchema.optional(),
endTime: timeSchema.optional(),
location: z.string().trim().max(64).nullish(),
weekStart: weekNumberSchema.optional(),
weekEnd: weekNumberSchema.optional(),
})
.refine((data) => !data.startTime || !data.endTime || data.startTime < data.endTime, {
message: '结束时间必须晚于开始时间',
path: ['endTime'],
});
export type CreateScheduleInput = z.infer<typeof createScheduleSchema>;
export type UpdateScheduleInput = z.infer<typeof updateScheduleSchema>;
@@ -0,0 +1,141 @@
import {
SOCKET_EVENTS,
buildScheduleWeekView,
type ScheduleDto,
type ScheduleWeekView,
} from '@classhelper/shared';
import { assertClassWritable, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toScheduleDto } from '../../lib/mappers.js';
import { resolveCurrentWeek } from '../../lib/term.js';
import { emitToClass } from '../../realtime/bus.js';
import type { CreateScheduleInput, UpdateScheduleInput } from './schedules.schemas.js';
const courseSelect = { select: { id: true, name: true } } as const;
export interface ListScheduleOptions {
classId?: string;
week?: number;
dayOfWeek?: number;
}
/** 课表列表:按班级权限过滤,week 过滤 weekStart <= week <= weekEnd */
export async function listSchedules(
user: TokenPayload,
options: ListScheduleOptions,
): Promise<ScheduleDto[]> {
const scope = await resolveClassScope(user, options.classId);
const week = options.week;
const schedules = await prisma.schedule.findMany({
where: {
...classScopeWhere(scope),
...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}),
...(options.dayOfWeek ? { dayOfWeek: options.dayOfWeek } : {}),
},
include: { course: courseSelect },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
});
return schedules.map(toScheduleDto);
}
/** 周视图:7 列固定结构,供 Web / EXE 直接渲染 */
export async function getScheduleGrid(
user: TokenPayload,
options: { classId?: string; week?: number },
): Promise<ScheduleWeekView> {
const week = options.week ?? resolveCurrentWeek();
const items = await listSchedules(user, { classId: options.classId, week });
return buildScheduleWeekView(items, week);
}
export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise<ScheduleDto> {
await assertClassWritable(user, input.classId);
await assertCourseInClass(input.courseId, input.classId);
const created = await prisma.schedule.create({
data: {
classId: input.classId,
courseId: input.courseId,
dayOfWeek: input.dayOfWeek,
startTime: input.startTime,
endTime: input.endTime,
location: input.location ?? null,
weekStart: input.weekStart ?? 1,
weekEnd: input.weekEnd ?? 20,
},
include: { course: courseSelect },
});
const dto = toScheduleDto(created);
emitToClass(created.classId, SOCKET_EVENTS.scheduleUpdated, {
classId: created.classId,
action: 'created',
schedule: dto,
});
return dto;
}
export async function updateSchedule(
user: TokenPayload,
scheduleId: string,
input: UpdateScheduleInput,
): Promise<ScheduleDto> {
const current = await prisma.schedule.findUnique({ where: { id: scheduleId } });
if (!current) throw ApiError.notFound('课表记录不存在');
await assertClassWritable(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
const startTime = input.startTime ?? current.startTime;
const endTime = input.endTime ?? current.endTime;
if (startTime >= endTime) throw ApiError.badRequest('结束时间必须晚于开始时间');
const weekStart = input.weekStart ?? current.weekStart;
const weekEnd = input.weekEnd ?? current.weekEnd;
if (weekStart > weekEnd) throw ApiError.badRequest('起始周次不能大于结束周次');
const updated = await prisma.schedule.update({
where: { id: scheduleId },
data: {
...(input.courseId ? { courseId: input.courseId } : {}),
...(input.dayOfWeek ? { dayOfWeek: input.dayOfWeek } : {}),
startTime,
endTime,
...(input.location !== undefined ? { location: input.location ?? null } : {}),
weekStart,
weekEnd,
},
include: { course: courseSelect },
});
const dto = toScheduleDto(updated);
emitToClass(updated.classId, SOCKET_EVENTS.scheduleUpdated, {
classId: updated.classId,
action: 'updated',
schedule: dto,
});
return dto;
}
export async function deleteSchedule(user: TokenPayload, scheduleId: string): Promise<void> {
const current = await prisma.schedule.findUnique({ where: { id: scheduleId } });
if (!current) throw ApiError.notFound('课表记录不存在');
await assertClassWritable(user, current.classId);
await prisma.schedule.delete({ where: { id: scheduleId } });
emitToClass(current.classId, SOCKET_EVENTS.scheduleUpdated, {
classId: current.classId,
action: 'deleted',
});
}
/** 校验课程确实属于该班级,避免把 A 班课程排进 B 班课表 */
async function assertCourseInClass(courseId: string, classId: string): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { classId: true } });
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
}