2026-09-12 15:51:08 +08:00
|
|
|
|
import {
|
2026-09-11 21:57:38 +08:00
|
|
|
|
SOCKET_EVENTS,
|
2026-10-06 19:41:18 +08:00
|
|
|
|
canQueryStudentGrades,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
gradeLevel,
|
|
|
|
|
|
gradePercent,
|
2026-10-06 19:41:18 +08:00
|
|
|
|
normalizeStudentNo,
|
2026-10-11 16:21:38 +08:00
|
|
|
|
type ExamDto,
|
|
|
|
|
|
type ExamRankingDto,
|
|
|
|
|
|
type ExamSubjectRanking,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
type GradeDto,
|
2026-10-06 19:41:18 +08:00
|
|
|
|
type GradeLevelType,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
type GradeStats,
|
2026-10-06 19:41:18 +08:00
|
|
|
|
type StudentGradeDetailDto,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
} from '@classhelper/shared';
|
2026-10-06 19:41:18 +08:00
|
|
|
|
import {
|
|
|
|
|
|
assertCanManageSubjectContent,
|
|
|
|
|
|
assertClassAccess,
|
|
|
|
|
|
classScopeWhere,
|
|
|
|
|
|
isClassDevice,
|
2026-10-11 16:21:38 +08:00
|
|
|
|
isStaff,
|
2026-10-06 19:41:18 +08:00
|
|
|
|
resolveClassScope,
|
|
|
|
|
|
resolveUserClassRole,
|
|
|
|
|
|
} from '../../lib/access.js';
|
2026-09-11 21:57:38 +08:00
|
|
|
|
import { prisma } from '../../lib/db.js';
|
|
|
|
|
|
import { ApiError } from '../../lib/http.js';
|
|
|
|
|
|
import type { TokenPayload } from '../../lib/jwt.js';
|
2026-10-11 16:21:38 +08:00
|
|
|
|
import { logger } from '../../lib/logger.js';
|
2026-10-06 19:41:18 +08:00
|
|
|
|
import { toGradeDto, toStudentBrief } from '../../lib/mappers.js';
|
2026-09-11 21:57:38 +08:00
|
|
|
|
import { parseOptionalDate } from '../../lib/schemas.js';
|
2026-09-12 15:51:08 +08:00
|
|
|
|
import { resolvePersonalIds } from '../../lib/session.js';
|
2026-10-06 19:41:18 +08:00
|
|
|
|
import { emitToClass } from '../../realtime/bus.js';
|
|
|
|
|
|
import type {
|
|
|
|
|
|
BulkCreateGradeInput,
|
|
|
|
|
|
CreateGradeInput,
|
|
|
|
|
|
UpdateGradeInput,
|
|
|
|
|
|
UpdateGradeLevelsInput,
|
|
|
|
|
|
} from './grades.schemas.js';
|
2026-09-11 21:57:38 +08:00
|
|
|
|
|
|
|
|
|
|
const courseSelect = { select: { id: true, name: true } } as const;
|
2026-10-06 19:41:18 +08:00
|
|
|
|
const studentSelect = { select: { id: true, studentNo: true, name: true } } as const;
|
2026-09-11 21:57:38 +08:00
|
|
|
|
const classSelect = { select: { name: true } } as const;
|
|
|
|
|
|
const include = { course: courseSelect, student: studentSelect, class: classSelect } as const;
|
|
|
|
|
|
|
|
|
|
|
|
export interface ListGradeOptions {
|
|
|
|
|
|
classId?: string;
|
|
|
|
|
|
courseId?: string;
|
2026-10-06 19:41:18 +08:00
|
|
|
|
studentId?: string;
|
2026-09-11 21:57:38 +08:00
|
|
|
|
examName?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 15:51:08 +08:00
|
|
|
|
/**
|
2026-10-06 19:41:18 +08:00
|
|
|
|
* ClassHelper 班级端查看本班成绩。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 班级端是个人数据的唯一读写主体(学生不是账号),因此这里返回**本班全部学生的成绩**,
|
|
|
|
|
|
* 便于在教室大屏上一屏展示全班成绩供核对。
|
|
|
|
|
|
* 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。
|
2026-09-12 15:51:08 +08:00
|
|
|
|
*/
|
2026-09-11 21:57:38 +08:00
|
|
|
|
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
|
2026-10-06 19:41:18 +08:00
|
|
|
|
const studentIds = await resolvePersonalIds(user);
|
|
|
|
|
|
if (studentIds.length === 0) return [];
|
2026-09-12 15:51:08 +08:00
|
|
|
|
|
2026-09-11 21:57:38 +08:00
|
|
|
|
const grades = await prisma.grade.findMany({
|
2026-10-06 19:41:18 +08:00
|
|
|
|
where: { studentId: { in: studentIds } },
|
2026-09-11 21:57:38 +08:00
|
|
|
|
include,
|
2026-09-12 15:51:08 +08:00
|
|
|
|
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
|
2026-10-06 19:41:18 +08:00
|
|
|
|
take: 1000,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
});
|
|
|
|
|
|
return grades.map(toGradeDto);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 19:41:18 +08:00
|
|
|
|
/** 教师 / 管理员查看成绩(按可访问班级过滤) */
|
2026-09-11 21:57:38 +08:00
|
|
|
|
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 } : {}),
|
2026-10-06 19:41:18 +08:00
|
|
|
|
...(options.studentId ? { studentId: options.studentId } : {}),
|
2026-09-11 21:57:38 +08:00
|
|
|
|
...(options.examName ? { examName: options.examName } : {}),
|
|
|
|
|
|
},
|
|
|
|
|
|
include,
|
|
|
|
|
|
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
|
2026-10-06 19:41:18 +08:00
|
|
|
|
take: 1000,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
});
|
|
|
|
|
|
return grades.map(toGradeDto);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 成绩统计(等级分布 + 各课程平均得分率),供 Web 端图表使用 */
|
|
|
|
|
|
export async function getGradeStats(user: TokenPayload, options: ListGradeOptions): Promise<GradeStats> {
|
|
|
|
|
|
const grades = await listGrades(user, options);
|
|
|
|
|
|
|
2026-10-06 19:41:18 +08:00
|
|
|
|
const distributionMap = new Map<string, number>();
|
2026-09-11 21:57:38 +08:00
|
|
|
|
const byCourseMap = new Map<string, { courseId: string | null; courseName: string; percents: number[] }>();
|
|
|
|
|
|
|
|
|
|
|
|
for (const grade of grades) {
|
|
|
|
|
|
const percent = gradePercent(grade.score, grade.totalScore);
|
2026-10-06 19:41:18 +08:00
|
|
|
|
// 自定义等级也要能统计,所以按 level 文本分桶(不再是固定的 A~E)
|
|
|
|
|
|
const level = grade.level || gradeLevel(percent);
|
|
|
|
|
|
distributionMap.set(level, (distributionMap.get(level) ?? 0) + 1);
|
2026-09-11 21:57:38 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
})),
|
2026-10-06 19:41:18 +08:00
|
|
|
|
distribution: [...distributionMap.entries()]
|
|
|
|
|
|
.map(([level, count]) => ({ level, count }))
|
|
|
|
|
|
.sort((a, b) => b.count - a.count),
|
2026-09-11 21:57:38 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 19:41:18 +08:00
|
|
|
|
/* ---------------------------------------------------------------- 录入 / 修改 / 删除 */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 录入一条成绩。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 权限走 `assertCanManageSubjectContent`:**科任老师只能录自己任教科目**的成绩,
|
|
|
|
|
|
* 班主任若不教这一科同样被拒;不指定科目(courseId 为空)时只有班主任/管理员可录。
|
|
|
|
|
|
*/
|
2026-09-11 21:57:38 +08:00
|
|
|
|
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
|
2026-10-06 19:41:18 +08:00
|
|
|
|
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
|
|
|
|
|
|
await assertStudentInClass(input.studentId, input.classId);
|
2026-09-11 21:57:38 +08:00
|
|
|
|
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
|
|
|
|
|
|
|
2026-10-06 19:41:18 +08:00
|
|
|
|
const levelType: GradeLevelType = input.levelType ?? 'percent';
|
|
|
|
|
|
const totalScore = input.totalScore ?? 100;
|
|
|
|
|
|
|
2026-09-11 21:57:38 +08:00
|
|
|
|
const dto = await upsertGrade({
|
|
|
|
|
|
classId: input.classId,
|
|
|
|
|
|
courseId: input.courseId ?? null,
|
2026-10-06 19:41:18 +08:00
|
|
|
|
studentId: input.studentId,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
examName: input.examName,
|
|
|
|
|
|
score: input.score,
|
2026-10-06 19:41:18 +08:00
|
|
|
|
totalScore,
|
|
|
|
|
|
levelType,
|
|
|
|
|
|
level: resolveLevel(levelType, input.level, input.score, totalScore),
|
2026-09-11 21:57:38 +08:00
|
|
|
|
publishedAt: parseOptionalDate(input.publishedAt) ?? new Date(),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
|
|
|
|
|
|
return dto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 批量录入:同一个考试一次提交整班成绩 */
|
|
|
|
|
|
export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> {
|
2026-10-06 19:41:18 +08:00
|
|
|
|
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
|
2026-09-11 21:57:38 +08:00
|
|
|
|
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
|
|
|
|
|
|
|
|
|
|
|
|
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
|
2026-10-06 19:41:18 +08:00
|
|
|
|
const levelType: GradeLevelType = input.levelType ?? 'percent';
|
|
|
|
|
|
const totalScore = input.totalScore ?? 100;
|
2026-09-11 21:57:38 +08:00
|
|
|
|
const results: GradeDto[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const item of input.items) {
|
2026-10-06 19:41:18 +08:00
|
|
|
|
await assertStudentInClass(item.studentId, input.classId);
|
2026-09-11 21:57:38 +08:00
|
|
|
|
const dto = await upsertGrade({
|
|
|
|
|
|
classId: input.classId,
|
|
|
|
|
|
courseId: input.courseId ?? null,
|
2026-10-06 19:41:18 +08:00
|
|
|
|
studentId: item.studentId,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
examName: input.examName,
|
|
|
|
|
|
score: item.score,
|
2026-10-06 19:41:18 +08:00
|
|
|
|
totalScore,
|
|
|
|
|
|
levelType,
|
|
|
|
|
|
level: resolveLevel(levelType, item.level, item.score, totalScore),
|
2026-09-11 21:57:38 +08:00
|
|
|
|
publishedAt,
|
|
|
|
|
|
});
|
|
|
|
|
|
results.push(dto);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 19:41:18 +08:00
|
|
|
|
if (results[0]) {
|
|
|
|
|
|
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, {
|
|
|
|
|
|
...results[0],
|
|
|
|
|
|
bulk: true,
|
|
|
|
|
|
count: results.length,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-11 21:57:38 +08:00
|
|
|
|
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('成绩记录不存在');
|
2026-10-06 19:41:18 +08:00
|
|
|
|
|
|
|
|
|
|
// 改的是哪条成绩,就按那条成绩所属的班级 + 科目判权限
|
|
|
|
|
|
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
|
|
|
|
|
|
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改成绩');
|
|
|
|
|
|
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
|
|
|
|
|
|
|
|
|
|
|
|
const totalScore = input.totalScore ?? current.totalScore;
|
|
|
|
|
|
const score = input.score ?? current.score;
|
|
|
|
|
|
const levelType = (input.levelType ?? current.levelType) as GradeLevelType;
|
|
|
|
|
|
// 改了分数就重算 percent 口径下的等级;显式传了 level 则以它为准
|
|
|
|
|
|
const level =
|
|
|
|
|
|
input.level !== undefined
|
|
|
|
|
|
? input.level
|
|
|
|
|
|
: levelType === 'percent'
|
|
|
|
|
|
? resolveLevel(levelType, '', score, totalScore)
|
|
|
|
|
|
: current.level;
|
2026-09-11 21:57:38 +08:00
|
|
|
|
|
|
|
|
|
|
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 } : {}),
|
2026-10-06 19:41:18 +08:00
|
|
|
|
...(input.courseId !== undefined ? { courseId: nextCourseId } : {}),
|
|
|
|
|
|
levelType,
|
|
|
|
|
|
level,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
...(input.publishedAt !== undefined
|
|
|
|
|
|
? { publishedAt: parseOptionalDate(input.publishedAt) ?? new Date() }
|
|
|
|
|
|
: {}),
|
|
|
|
|
|
},
|
|
|
|
|
|
include,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const dto = toGradeDto(updated);
|
|
|
|
|
|
emitToClass(dto.classId, 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('成绩记录不存在');
|
2026-10-06 19:41:18 +08:00
|
|
|
|
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩');
|
|
|
|
|
|
|
2026-09-11 21:57:38 +08:00
|
|
|
|
await prisma.grade.delete({ where: { id: gradeId } });
|
|
|
|
|
|
emitToClass(current.classId, SOCKET_EVENTS.gradeUpdated, {
|
|
|
|
|
|
...toGradeDto({ ...current, course: null, student: null, class: null }),
|
|
|
|
|
|
deleted: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 19:41:18 +08:00
|
|
|
|
/* ---------------------------------------------------------------- 等级:单个 / 批量 */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 批量修改等级。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 两种用法:
|
|
|
|
|
|
* 1. `items`:逐条指定等级(前端表格里改完一次提交;单个编辑就是长度 1 的数组);
|
|
|
|
|
|
* 2. `classId + examName + levelType`:把某个考试的某门课整批**重算**等级
|
|
|
|
|
|
* (percent 按得分率换算,其余口径沿用原等级或留空)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 逐条改时按**每条成绩自己的班级 + 科目**判权限,科任老师改不了别人的科目。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function updateGradeLevels(
|
|
|
|
|
|
user: TokenPayload,
|
|
|
|
|
|
input: UpdateGradeLevelsInput,
|
|
|
|
|
|
): Promise<{ updated: number }> {
|
|
|
|
|
|
if (input.items && input.items.length > 0) {
|
|
|
|
|
|
const records = await prisma.grade.findMany({
|
|
|
|
|
|
where: { id: { in: input.items.map((item) => item.id) } },
|
|
|
|
|
|
select: { id: true, classId: true, courseId: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (records.length !== input.items.length) {
|
|
|
|
|
|
throw ApiError.badRequest('部分成绩记录不存在,请刷新后重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
const byId = new Map(records.map((item) => [item.id, item]));
|
|
|
|
|
|
|
|
|
|
|
|
// 逐条判权限:可能跨多个班级/科目(管理员在成绩页多选修改时会这样)
|
|
|
|
|
|
for (const item of input.items) {
|
|
|
|
|
|
const record = byId.get(item.id)!;
|
|
|
|
|
|
await assertCanManageSubjectContent(user, record.classId, record.courseId, '修改成绩等级');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.$transaction(
|
|
|
|
|
|
input.items.map((item) =>
|
|
|
|
|
|
prisma.grade.update({ where: { id: item.id }, data: { level: item.level } }),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const sample = byId.get(input.items[0]!.id);
|
|
|
|
|
|
if (sample) emitToClass(sample.classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count: input.items.length });
|
|
|
|
|
|
return { updated: input.items.length };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 整批重算
|
|
|
|
|
|
const classId = input.classId!;
|
|
|
|
|
|
await assertCanManageSubjectContent(user, classId, input.courseId ?? null, '调整成绩等级');
|
|
|
|
|
|
const levelType = input.levelType!;
|
|
|
|
|
|
const records = await prisma.grade.findMany({
|
|
|
|
|
|
where: {
|
|
|
|
|
|
classId,
|
|
|
|
|
|
examName: input.examName!,
|
|
|
|
|
|
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
|
|
|
|
|
|
},
|
|
|
|
|
|
select: { id: true, score: true, totalScore: true, level: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.$transaction(
|
|
|
|
|
|
records.map((item) =>
|
|
|
|
|
|
prisma.grade.update({
|
|
|
|
|
|
where: { id: item.id },
|
|
|
|
|
|
data: {
|
|
|
|
|
|
levelType,
|
|
|
|
|
|
level:
|
|
|
|
|
|
levelType === 'percent'
|
|
|
|
|
|
? gradeLevel(gradePercent(item.score, item.totalScore))
|
|
|
|
|
|
: item.level,
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
emitToClass(classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count: records.length });
|
|
|
|
|
|
return { updated: records.length };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------- 按学号查明细 */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 按**学号**查询某个学生的成绩明细(ClassHelper 班级端 / 教师端共用)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 权限:
|
|
|
|
|
|
* - 管理员:全库任意学号;
|
|
|
|
|
|
* - 班主任 / 科任老师:自己可访问的班级;
|
|
|
|
|
|
* - ClassHelper 班级端:仅本班,**且**该班打开了 `studentGradeQueryEnabled`
|
|
|
|
|
|
* —— 这就是「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function getStudentGradeDetail(
|
|
|
|
|
|
user: TokenPayload,
|
|
|
|
|
|
studentNo: string,
|
|
|
|
|
|
requestedClassId?: string,
|
|
|
|
|
|
): Promise<StudentGradeDetailDto> {
|
|
|
|
|
|
const scope = await resolveClassScope(user, requestedClassId);
|
|
|
|
|
|
|
|
|
|
|
|
const student = await prisma.student.findFirst({
|
|
|
|
|
|
where: { studentNo: normalizeStudentNo(studentNo), ...classScopeWhere(scope) },
|
|
|
|
|
|
include: { class: { select: { id: true, name: true, studentGradeQueryEnabled: true } } },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!student) throw ApiError.notFound('没找到这个学号的学生,请核对学号是否输入正确');
|
|
|
|
|
|
|
|
|
|
|
|
if (student.class) {
|
|
|
|
|
|
const classRole = await resolveUserClassRole(user, student.class.id);
|
|
|
|
|
|
const allowed = canQueryStudentGrades({
|
|
|
|
|
|
classRole,
|
|
|
|
|
|
// 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理
|
|
|
|
|
|
gradeQueryEnabled: student.class.studentGradeQueryEnabled ?? true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!allowed) {
|
|
|
|
|
|
throw ApiError.forbidden('本班未开放学生自助查询成绩,请联系老师开启');
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (isClassDevice(user)) {
|
|
|
|
|
|
throw ApiError.forbidden('该学生尚未分班,无法查询');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const grades = await prisma.grade.findMany({
|
|
|
|
|
|
where: { studentId: student.id },
|
|
|
|
|
|
include,
|
|
|
|
|
|
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
|
|
|
|
|
|
take: 500,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const percents = grades.map((item) => gradePercent(item.score, item.totalScore));
|
|
|
|
|
|
return {
|
|
|
|
|
|
student: toStudentBrief(student),
|
|
|
|
|
|
classId: student.classId,
|
|
|
|
|
|
className: student.class?.name ?? null,
|
|
|
|
|
|
items: grades.map(toGradeDto),
|
|
|
|
|
|
averagePercent:
|
|
|
|
|
|
percents.length === 0
|
|
|
|
|
|
? 0
|
|
|
|
|
|
: Math.round((percents.reduce((sum, value) => sum + value, 0) / percents.length) * 10) / 10,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 教师端:查看某个学生的成绩明细(按学生 id,内部复用按学号的实现) */
|
|
|
|
|
|
export async function getStudentGradeDetailById(
|
|
|
|
|
|
user: TokenPayload,
|
|
|
|
|
|
studentId: string,
|
|
|
|
|
|
): Promise<StudentGradeDetailDto> {
|
|
|
|
|
|
const student = await prisma.student.findUnique({
|
|
|
|
|
|
where: { id: studentId },
|
|
|
|
|
|
select: { studentNo: true, classId: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!student) throw ApiError.notFound('学生不存在');
|
|
|
|
|
|
await assertClassAccess(user, student.classId ?? '');
|
|
|
|
|
|
return getStudentGradeDetail(user, student.studentNo);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------- 内部工具 */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 等级文本:
|
|
|
|
|
|
* - 显式给了就用它(教师手改的等级优先);
|
|
|
|
|
|
* - 否则 percent 口径按得分率换算;
|
|
|
|
|
|
* - letter / custom 留空时给空串,由前端提示"请选择等级"。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function resolveLevel(
|
|
|
|
|
|
levelType: GradeLevelType,
|
|
|
|
|
|
level: string | undefined,
|
|
|
|
|
|
score: number,
|
|
|
|
|
|
totalScore: number,
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const provided = (level ?? '').trim();
|
|
|
|
|
|
if (provided) return provided;
|
|
|
|
|
|
return levelType === 'percent' ? gradeLevel(gradePercent(score, totalScore)) : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 21:57:38 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 同一学生 + 同一课程 + 同一考试名称视为同一条记录,
|
|
|
|
|
|
* 重复录入时覆盖旧分数,避免教师"改分"产生重复行。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function upsertGrade(data: {
|
|
|
|
|
|
classId: string;
|
|
|
|
|
|
courseId: string | null;
|
2026-10-06 19:41:18 +08:00
|
|
|
|
studentId: string;
|
2026-09-11 21:57:38 +08:00
|
|
|
|
examName: string;
|
|
|
|
|
|
score: number;
|
|
|
|
|
|
totalScore: number;
|
2026-10-06 19:41:18 +08:00
|
|
|
|
levelType: GradeLevelType;
|
|
|
|
|
|
level: string;
|
2026-09-11 21:57:38 +08:00
|
|
|
|
publishedAt: Date;
|
|
|
|
|
|
}): Promise<GradeDto> {
|
|
|
|
|
|
const existing = await prisma.grade.findFirst({
|
|
|
|
|
|
where: {
|
|
|
|
|
|
classId: data.classId,
|
|
|
|
|
|
courseId: data.courseId,
|
2026-10-06 19:41:18 +08:00
|
|
|
|
studentId: data.studentId,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
examName: data.examName,
|
|
|
|
|
|
},
|
|
|
|
|
|
select: { id: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
const updated = await prisma.grade.update({
|
|
|
|
|
|
where: { id: existing.id },
|
2026-10-06 19:41:18 +08:00
|
|
|
|
data: {
|
|
|
|
|
|
score: data.score,
|
|
|
|
|
|
totalScore: data.totalScore,
|
|
|
|
|
|
levelType: data.levelType,
|
|
|
|
|
|
level: data.level,
|
|
|
|
|
|
publishedAt: data.publishedAt,
|
|
|
|
|
|
},
|
2026-09-11 21:57:38 +08:00
|
|
|
|
include,
|
|
|
|
|
|
});
|
|
|
|
|
|
return toGradeDto(updated);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const created = await prisma.grade.create({ data, include });
|
|
|
|
|
|
return toGradeDto(created);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 19:41:18 +08:00
|
|
|
|
async function assertStudentInClass(studentId: string, classId: string): Promise<void> {
|
|
|
|
|
|
const student = await prisma.student.findUnique({
|
|
|
|
|
|
where: { id: studentId },
|
|
|
|
|
|
select: { classId: true },
|
2026-09-11 21:57:38 +08:00
|
|
|
|
});
|
2026-10-06 19:41:18 +08:00
|
|
|
|
if (!student) throw ApiError.badRequest('学生不存在');
|
2026-09-11 21:57:38 +08:00
|
|
|
|
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('该课程不属于所选班级');
|
|
|
|
|
|
}
|
2026-10-11 16:21:38 +08:00
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------- 考试与排名 */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 列出某班出现过的考试名(`Grade.examName` 的去重),并带上"是否计入总分"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 以**成绩行**为准而不是以 `Exam` 表为准:老师可能先导了成绩才开始管口径,
|
|
|
|
|
|
* 只列 `Exam` 会让刚导进去的那次考试在界面上根本找不到。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function listExams(user: TokenPayload, classId: string): Promise<ExamDto[]> {
|
|
|
|
|
|
await assertClassAccess(user, classId);
|
|
|
|
|
|
const [groups, exams] = await Promise.all([
|
|
|
|
|
|
prisma.grade.groupBy({ by: ['examName'], where: { classId }, _count: { _all: true } }),
|
|
|
|
|
|
prisma.exam.findMany({
|
|
|
|
|
|
where: { classId },
|
|
|
|
|
|
select: { id: true, name: true, countInTotal: true },
|
|
|
|
|
|
}),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const byName = new Map(exams.map((item) => [item.name, item]));
|
|
|
|
|
|
return groups
|
|
|
|
|
|
.map((group) => {
|
|
|
|
|
|
const row = byName.get(group.examName);
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: row?.id ?? '',
|
|
|
|
|
|
classId,
|
|
|
|
|
|
name: group.examName,
|
|
|
|
|
|
countInTotal: row?.countInTotal ?? false,
|
|
|
|
|
|
gradeCount: group._count._all,
|
|
|
|
|
|
};
|
|
|
|
|
|
})
|
|
|
|
|
|
.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置「这次考试要不要把所有科目相加做总分排名」。仅教师 / 管理员。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么要单独一个开关:语数外的 150 分和音体美的 100 分不是一个量纲,
|
|
|
|
|
|
* "这次考试要不要合总分"是学校的口径,程序不能替它默认决定 ——
|
|
|
|
|
|
* 而客户端要显示总分榜就必须知道,所以按「班级 + 考试名」存一条(`Exam` 表)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function setExamCountInTotal(
|
|
|
|
|
|
user: TokenPayload,
|
|
|
|
|
|
classId: string,
|
|
|
|
|
|
name: string,
|
|
|
|
|
|
countInTotal: boolean,
|
|
|
|
|
|
): Promise<ExamDto> {
|
|
|
|
|
|
await assertClassAccess(user, classId);
|
|
|
|
|
|
if (!isStaff(user)) throw ApiError.forbidden('只有教师/管理员可以设置考试口径');
|
|
|
|
|
|
|
|
|
|
|
|
const row = await prisma.exam.upsert({
|
|
|
|
|
|
where: { classId_name: { classId, name } },
|
|
|
|
|
|
create: { classId, name, countInTotal },
|
|
|
|
|
|
update: { countInTotal },
|
|
|
|
|
|
});
|
|
|
|
|
|
const gradeCount = await prisma.grade.count({ where: { classId, examName: name } });
|
|
|
|
|
|
logger.info(`考试口径:班级=${classId} 考试=${name} 计入总分=${countInTotal} 由 ${user.name} 设置`);
|
|
|
|
|
|
return { id: row.id, classId, name, countInTotal, gradeCount };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 并列名次:分数相同的取同一名次,下一个名次跳过(1、2、2、4) */
|
|
|
|
|
|
function assignRanks<T extends { score: number }>(
|
|
|
|
|
|
rows: T[],
|
|
|
|
|
|
rankOf: (row: T) => number,
|
|
|
|
|
|
): Array<T & { rank: number }> {
|
|
|
|
|
|
const sorted = [...rows].sort((a, b) => rankOf(b) - rankOf(a));
|
|
|
|
|
|
let lastScore: number | null = null;
|
|
|
|
|
|
let lastRank = 0;
|
|
|
|
|
|
return sorted.map((row, index) => {
|
|
|
|
|
|
const score = rankOf(row);
|
|
|
|
|
|
const rank = lastScore !== null && score === lastScore ? lastRank : index + 1;
|
|
|
|
|
|
lastScore = score;
|
|
|
|
|
|
lastRank = rank;
|
|
|
|
|
|
return { ...row, rank };
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 一次考试的排名(单科 + 总分)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 单科榜按 `courseId` 分组;成绩行里 `courseId` 可能为 null(没挂科目的成绩),
|
|
|
|
|
|
* 那种归到「未关联课程」一栏,不并进任何科目。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 总分榜只在 `Exam.countInTotal` 为 true 时才算 —— 见 `setExamCountInTotal` 的说明。
|
|
|
|
|
|
* 合计时**按科目去重**:同一个学生同一个科目在一次考试里有多行(老师补录过)时只取最高分,
|
|
|
|
|
|
* 否则"补录一条"会变成"总分多一科",排名直接失真。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 班级端受 `Class.studentGradeQueryEnabled` 控制(与按学号查明细同一条边界)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function getExamRanking(
|
|
|
|
|
|
user: TokenPayload,
|
|
|
|
|
|
classId: string,
|
|
|
|
|
|
examName: string,
|
|
|
|
|
|
): Promise<ExamRankingDto> {
|
|
|
|
|
|
await assertClassAccess(user, classId);
|
|
|
|
|
|
const classRecord = await prisma.class.findUnique({
|
|
|
|
|
|
where: { id: classId },
|
|
|
|
|
|
select: { name: true, studentGradeQueryEnabled: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!classRecord) throw ApiError.notFound('班级不存在');
|
|
|
|
|
|
|
|
|
|
|
|
const classRole = await resolveUserClassRole(user, classId);
|
|
|
|
|
|
const allowed = canQueryStudentGrades({
|
|
|
|
|
|
classRole,
|
|
|
|
|
|
// 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理
|
|
|
|
|
|
gradeQueryEnabled: classRecord.studentGradeQueryEnabled ?? true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!allowed) throw ApiError.forbidden('本班未开放成绩查询,请联系老师开启');
|
|
|
|
|
|
|
|
|
|
|
|
const [grades, exam] = await Promise.all([
|
|
|
|
|
|
prisma.grade.findMany({
|
|
|
|
|
|
where: { classId, examName },
|
|
|
|
|
|
include,
|
|
|
|
|
|
orderBy: [{ publishedAt: 'desc' }],
|
|
|
|
|
|
}),
|
|
|
|
|
|
prisma.exam.findUnique({
|
|
|
|
|
|
where: { classId_name: { classId, name: examName } },
|
|
|
|
|
|
select: { countInTotal: true },
|
|
|
|
|
|
}),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// 同一个学生同一科目只留最高分(补录 / 重考的场景)
|
|
|
|
|
|
const best = new Map<string, GradeDto>();
|
|
|
|
|
|
for (const grade of grades.map(toGradeDto)) {
|
|
|
|
|
|
const key = `${grade.studentId}|${grade.courseId ?? ''}`;
|
|
|
|
|
|
const current = best.get(key);
|
|
|
|
|
|
if (!current || grade.score > current.score) best.set(key, grade);
|
|
|
|
|
|
}
|
|
|
|
|
|
const unique = [...best.values()];
|
|
|
|
|
|
|
|
|
|
|
|
const byCourse = new Map<string, { courseId: string | null; courseName: string; rows: GradeDto[] }>();
|
|
|
|
|
|
for (const grade of unique) {
|
|
|
|
|
|
const key = grade.courseId ?? '__none__';
|
|
|
|
|
|
const entry = byCourse.get(key) ?? {
|
|
|
|
|
|
courseId: grade.courseId,
|
|
|
|
|
|
courseName: grade.course?.name ?? '未关联课程',
|
|
|
|
|
|
rows: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
entry.rows.push(grade);
|
|
|
|
|
|
byCourse.set(key, entry);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const subjects: ExamSubjectRanking[] = [...byCourse.values()]
|
|
|
|
|
|
.map((entry) => ({
|
|
|
|
|
|
courseId: entry.courseId,
|
|
|
|
|
|
courseName: entry.courseName,
|
|
|
|
|
|
rows: assignRanks(entry.rows, (row) => gradePercent(row.score, row.totalScore)).map((row) => ({
|
|
|
|
|
|
studentId: row.studentId,
|
|
|
|
|
|
studentNo: row.student?.studentNo ?? '',
|
|
|
|
|
|
studentName: row.student?.name ?? '',
|
|
|
|
|
|
score: row.score,
|
|
|
|
|
|
totalScore: row.totalScore,
|
|
|
|
|
|
rank: row.rank,
|
|
|
|
|
|
})),
|
|
|
|
|
|
}))
|
|
|
|
|
|
.sort((a, b) => a.courseName.localeCompare(b.courseName, 'zh-Hans-CN'));
|
|
|
|
|
|
|
|
|
|
|
|
const countInTotal = exam?.countInTotal === true;
|
|
|
|
|
|
let total: ExamRankingDto['total'] = [];
|
|
|
|
|
|
if (countInTotal) {
|
|
|
|
|
|
const sum = new Map<
|
|
|
|
|
|
string,
|
|
|
|
|
|
{ studentId: string; studentNo: string; studentName: string; score: number; subjectCount: number }
|
|
|
|
|
|
>();
|
|
|
|
|
|
for (const grade of unique) {
|
|
|
|
|
|
const entry = sum.get(grade.studentId) ?? {
|
|
|
|
|
|
studentId: grade.studentId,
|
|
|
|
|
|
studentNo: grade.student?.studentNo ?? '',
|
|
|
|
|
|
studentName: grade.student?.name ?? '',
|
|
|
|
|
|
score: 0,
|
|
|
|
|
|
subjectCount: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
entry.score += grade.score;
|
|
|
|
|
|
entry.subjectCount += 1;
|
|
|
|
|
|
sum.set(grade.studentId, entry);
|
|
|
|
|
|
}
|
|
|
|
|
|
total = assignRanks([...sum.values()], (row) => row.score);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
classId,
|
|
|
|
|
|
className: classRecord.name,
|
|
|
|
|
|
examName,
|
|
|
|
|
|
countInTotal,
|
|
|
|
|
|
subjects,
|
|
|
|
|
|
total,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|