feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)
This commit is contained in:
commit
84ab49de10
102 files changed
+14751
No files matched your search
@@ -0,0 +1,105 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
bulkCreateGradeSchema,
|
||||
createGradeSchema,
|
||||
listGradesQuerySchema,
|
||||
statsQuerySchema,
|
||||
updateGradeSchema,
|
||||
type BulkCreateGradeInput,
|
||||
type CreateGradeInput,
|
||||
type UpdateGradeInput,
|
||||
} from './grades.schemas.js';
|
||||
import * as gradeService from './grades.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/** GET /api/grades/my - 学生查看个人成绩(任意角色都可查自己的) */
|
||||
router.get('/my', async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(res, await gradeService.listMyGrades(user), '获取个人成绩成功');
|
||||
});
|
||||
|
||||
/** GET /api/grades/stats?classId=&courseId=&examName= - 成绩统计(图表) */
|
||||
router.get(
|
||||
'/stats',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ query: statsQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<{ classId?: string; courseId?: string; examName?: string }>(req);
|
||||
sendOk(res, await gradeService.getGradeStats(user, query), '获取成绩统计成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/grades?classId=&courseId=&userId=&examName= - 成绩列表(教师) */
|
||||
router.get(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ query: listGradesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<{ classId?: string; courseId?: string; userId?: string; examName?: string }>(
|
||||
req,
|
||||
);
|
||||
sendOk(res, await gradeService.listGrades(user, query), '获取成绩列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/grades - 录入单条成绩(广播 grade:updated) */
|
||||
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(
|
||||
res,
|
||||
await gradeService.createGrade(user, req.body as CreateGradeInput),
|
||||
'成绩录入成功,已实时推送',
|
||||
);
|
||||
});
|
||||
|
||||
/** POST /api/grades/bulk - 批量录入 / 导入成绩 */
|
||||
router.post(
|
||||
'/bulk',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ body: bulkCreateGradeSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const grades = await gradeService.bulkCreateGrades(user, req.body as BulkCreateGradeInput);
|
||||
sendCreated(res, { count: grades.length, items: grades }, `已录入 ${grades.length} 条成绩`);
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/grades/:id - 修改成绩 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: idParamSchema, body: updateGradeSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await gradeService.updateGrade(user, id, req.body as UpdateGradeInput), '成绩更新成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/grades/:id - 删除成绩 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await gradeService.deleteGrade(user, id);
|
||||
sendOk(res, { id }, '成绩已删除');
|
||||
},
|
||||
);
|
||||
|
||||
export const gradesModule = defineModule({
|
||||
name: 'grades',
|
||||
basePath: '/grades',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
import { optionalDateTimeSchema } from '../../lib/schemas.js';
|
||||
|
||||
export const listGradesQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
courseId: z.string().min(1).optional(),
|
||||
userId: z.string().min(1).optional(),
|
||||
examName: z.string().trim().max(64).optional(),
|
||||
});
|
||||
|
||||
export const statsQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
courseId: z.string().min(1).optional(),
|
||||
examName: z.string().trim().max(64).optional(),
|
||||
});
|
||||
|
||||
export const createGradeSchema = z.object({
|
||||
classId: z.string().min(1, '请选择班级'),
|
||||
courseId: z.string().min(1).nullish(),
|
||||
userId: z.string().min(1, '请选择学生'),
|
||||
examName: z.string().trim().min(1, '请输入考试名称').max(64),
|
||||
score: z.coerce.number().min(0, '分数不能为负数'),
|
||||
totalScore: z.coerce.number().positive('总分必须大于 0').optional(),
|
||||
publishedAt: optionalDateTimeSchema,
|
||||
});
|
||||
|
||||
export const updateGradeSchema = z.object({
|
||||
examName: z.string().trim().min(1).max(64).optional(),
|
||||
score: z.coerce.number().min(0).optional(),
|
||||
totalScore: z.coerce.number().positive().optional(),
|
||||
courseId: z.string().min(1).nullish(),
|
||||
publishedAt: optionalDateTimeSchema,
|
||||
});
|
||||
|
||||
/** 批量录入 / 导入成绩 */
|
||||
export const bulkCreateGradeSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
courseId: z.string().min(1).nullish(),
|
||||
examName: z.string().trim().min(1).max(64),
|
||||
totalScore: z.coerce.number().positive().optional(),
|
||||
publishedAt: optionalDateTimeSchema,
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
userId: z.string().min(1),
|
||||
score: z.coerce.number().min(0),
|
||||
}),
|
||||
)
|
||||
.min(1, '至少需要一条成绩记录'),
|
||||
});
|
||||
|
||||
export type CreateGradeInput = z.infer<typeof createGradeSchema>;
|
||||
export type UpdateGradeInput = z.infer<typeof updateGradeSchema>;
|
||||
export type BulkCreateGradeInput = z.infer<typeof bulkCreateGradeSchema>;
|
||||
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
gradeLevel,
|
||||
gradePercent,
|
||||
type GradeDto,
|
||||
type GradeLevel,
|
||||
type GradeStats,
|
||||
} 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 { toGradeDto } from '../../lib/mappers.js';
|
||||
import { parseOptionalDate } from '../../lib/schemas.js';
|
||||
import { emitToClass, emitToUser } from '../../realtime/bus.js';
|
||||
import type { BulkCreateGradeInput, CreateGradeInput, UpdateGradeInput } from './grades.schemas.js';
|
||||
|
||||
const courseSelect = { select: { id: true, name: true } } as const;
|
||||
const studentSelect = { select: { id: true, name: true, username: true } } as const;
|
||||
const classSelect = { select: { name: true } } as const;
|
||||
const include = { course: courseSelect, student: studentSelect, class: classSelect } as const;
|
||||
|
||||
export interface ListGradeOptions {
|
||||
classId?: string;
|
||||
courseId?: string;
|
||||
userId?: string;
|
||||
examName?: string;
|
||||
}
|
||||
|
||||
/** 学生查看自己的成绩 */
|
||||
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
|
||||
const grades = await prisma.grade.findMany({
|
||||
where: { userId: user.sub },
|
||||
include,
|
||||
orderBy: [{ publishedAt: 'desc' }],
|
||||
take: 300,
|
||||
});
|
||||
return grades.map(toGradeDto);
|
||||
}
|
||||
|
||||
/** 教师查看班级成绩 */
|
||||
export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise<GradeDto[]> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const grades = await prisma.grade.findMany({
|
||||
where: {
|
||||
...classScopeWhere(scope),
|
||||
...(options.courseId ? { courseId: options.courseId } : {}),
|
||||
...(options.userId ? { userId: options.userId } : {}),
|
||||
...(options.examName ? { examName: options.examName } : {}),
|
||||
},
|
||||
include,
|
||||
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
|
||||
take: 500,
|
||||
});
|
||||
return grades.map(toGradeDto);
|
||||
}
|
||||
|
||||
/** 成绩统计(等级分布 + 各课程平均得分率),供 Web 端图表使用 */
|
||||
export async function getGradeStats(user: TokenPayload, options: ListGradeOptions): Promise<GradeStats> {
|
||||
const grades = await listGrades(user, options);
|
||||
const levels: GradeLevel[] = ['A', 'B', 'C', 'D', 'E'];
|
||||
const distribution = levels.map((level) => ({ level, count: 0 }));
|
||||
|
||||
const byCourseMap = new Map<string, { courseId: string | null; courseName: string; percents: number[] }>();
|
||||
|
||||
for (const grade of grades) {
|
||||
const percent = gradePercent(grade.score, grade.totalScore);
|
||||
const bucket = distribution.find((item) => item.level === gradeLevel(percent));
|
||||
if (bucket) bucket.count += 1;
|
||||
|
||||
const key = grade.courseId ?? '__none__';
|
||||
const entry = byCourseMap.get(key) ?? {
|
||||
courseId: grade.courseId,
|
||||
courseName: grade.course?.name ?? '未关联课程',
|
||||
percents: [],
|
||||
};
|
||||
entry.percents.push(percent);
|
||||
byCourseMap.set(key, entry);
|
||||
}
|
||||
|
||||
const allPercents = grades.map((grade) => gradePercent(grade.score, grade.totalScore));
|
||||
|
||||
return {
|
||||
total: grades.length,
|
||||
averagePercent:
|
||||
allPercents.length === 0
|
||||
? 0
|
||||
: Math.round((allPercents.reduce((sum, value) => sum + value, 0) / allPercents.length) * 10) / 10,
|
||||
byCourse: [...byCourseMap.values()].map((entry) => ({
|
||||
courseId: entry.courseId,
|
||||
courseName: entry.courseName,
|
||||
count: entry.percents.length,
|
||||
averagePercent:
|
||||
Math.round((entry.percents.reduce((sum, value) => sum + value, 0) / entry.percents.length) * 10) / 10,
|
||||
})),
|
||||
distribution,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
|
||||
await assertClassWritable(user, input.classId);
|
||||
await assertStudentInClass(input.userId, input.classId);
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
|
||||
|
||||
const dto = await upsertGrade({
|
||||
classId: input.classId,
|
||||
courseId: input.courseId ?? null,
|
||||
userId: input.userId,
|
||||
examName: input.examName,
|
||||
score: input.score,
|
||||
totalScore: input.totalScore ?? 100,
|
||||
publishedAt: parseOptionalDate(input.publishedAt) ?? new Date(),
|
||||
});
|
||||
|
||||
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
|
||||
emitToUser(dto.userId, SOCKET_EVENTS.gradeUpdated, dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/** 批量录入:同一个考试一次提交整班成绩 */
|
||||
export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> {
|
||||
await assertClassWritable(user, input.classId);
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
|
||||
|
||||
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
|
||||
const results: GradeDto[] = [];
|
||||
|
||||
for (const item of input.items) {
|
||||
await assertStudentInClass(item.userId, input.classId);
|
||||
const dto = await upsertGrade({
|
||||
classId: input.classId,
|
||||
courseId: input.courseId ?? null,
|
||||
userId: item.userId,
|
||||
examName: input.examName,
|
||||
score: item.score,
|
||||
totalScore: input.totalScore ?? 100,
|
||||
publishedAt,
|
||||
});
|
||||
results.push(dto);
|
||||
}
|
||||
|
||||
// 一次广播覆盖整班(房间内包含全部学生)
|
||||
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, {
|
||||
...results[0],
|
||||
bulk: true,
|
||||
count: results.length,
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function updateGrade(
|
||||
user: TokenPayload,
|
||||
gradeId: string,
|
||||
input: UpdateGradeInput,
|
||||
): Promise<GradeDto> {
|
||||
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
|
||||
if (!current) throw ApiError.notFound('成绩记录不存在');
|
||||
await assertClassWritable(user, current.classId);
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
|
||||
|
||||
const updated = await prisma.grade.update({
|
||||
where: { id: gradeId },
|
||||
data: {
|
||||
...(input.examName ? { examName: input.examName } : {}),
|
||||
...(input.score !== undefined ? { score: input.score } : {}),
|
||||
...(input.totalScore !== undefined ? { totalScore: input.totalScore } : {}),
|
||||
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
|
||||
...(input.publishedAt !== undefined
|
||||
? { publishedAt: parseOptionalDate(input.publishedAt) ?? new Date() }
|
||||
: {}),
|
||||
},
|
||||
include,
|
||||
});
|
||||
|
||||
const dto = toGradeDto(updated);
|
||||
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
|
||||
emitToUser(dto.userId, SOCKET_EVENTS.gradeUpdated, dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<void> {
|
||||
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
|
||||
if (!current) throw ApiError.notFound('成绩记录不存在');
|
||||
await assertClassWritable(user, current.classId);
|
||||
await prisma.grade.delete({ where: { id: gradeId } });
|
||||
emitToClass(current.classId, SOCKET_EVENTS.gradeUpdated, {
|
||||
...toGradeDto({ ...current, course: null, student: null, class: null }),
|
||||
deleted: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一学生 + 同一课程 + 同一考试名称视为同一条记录,
|
||||
* 重复录入时覆盖旧分数,避免教师"改分"产生重复行。
|
||||
*/
|
||||
async function upsertGrade(data: {
|
||||
classId: string;
|
||||
courseId: string | null;
|
||||
userId: string;
|
||||
examName: string;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
publishedAt: Date;
|
||||
}): Promise<GradeDto> {
|
||||
const existing = await prisma.grade.findFirst({
|
||||
where: {
|
||||
classId: data.classId,
|
||||
courseId: data.courseId,
|
||||
userId: data.userId,
|
||||
examName: data.examName,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const updated = await prisma.grade.update({
|
||||
where: { id: existing.id },
|
||||
data: { score: data.score, totalScore: data.totalScore, publishedAt: data.publishedAt },
|
||||
include,
|
||||
});
|
||||
return toGradeDto(updated);
|
||||
}
|
||||
|
||||
const created = await prisma.grade.create({ data, include });
|
||||
return toGradeDto(created);
|
||||
}
|
||||
|
||||
async function assertStudentInClass(userId: string, classId: string): Promise<void> {
|
||||
const student = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true, classId: true },
|
||||
});
|
||||
if (!student || student.role !== 'STUDENT') throw ApiError.badRequest('学生不存在');
|
||||
if (student.classId !== classId) throw ApiError.badRequest('该学生不属于所选班级');
|
||||
}
|
||||
|
||||
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('该课程不属于所选班级');
|
||||
}
|
||||
Reference in new issue
Block a user