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:
1 parent
17f55864bf
commit
9d5ac7adf9
172 files changed
+8761
-1896
No files matched your search
@@ -1,64 +1,81 @@
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
canQueryStudentGrades,
|
||||
gradeLevel,
|
||||
gradePercent,
|
||||
normalizeStudentNo,
|
||||
type GradeDto,
|
||||
type GradeLevel,
|
||||
type GradeLevelType,
|
||||
type GradeStats,
|
||||
type StudentGradeDetailDto,
|
||||
} from '@classhelper/shared';
|
||||
import { assertCanManageGrades, classScopeWhere, resolveClassScope } from '../../lib/access.js';
|
||||
import {
|
||||
assertCanManageSubjectContent,
|
||||
assertClassAccess,
|
||||
classScopeWhere,
|
||||
isClassDevice,
|
||||
resolveClassScope,
|
||||
resolveUserClassRole,
|
||||
} 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 { toGradeDto, toStudentBrief } from '../../lib/mappers.js';
|
||||
import { parseOptionalDate } from '../../lib/schemas.js';
|
||||
import { resolvePersonalIds } from '../../lib/session.js';
|
||||
import { emitToClass, emitToUser } from '../../realtime/bus.js';
|
||||
import type { BulkCreateGradeInput, CreateGradeInput, UpdateGradeInput } from './grades.schemas.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import type {
|
||||
BulkCreateGradeInput,
|
||||
CreateGradeInput,
|
||||
UpdateGradeInput,
|
||||
UpdateGradeLevelsInput,
|
||||
} 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 studentSelect = { select: { id: true, studentNo: true, name: 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;
|
||||
studentId?: string;
|
||||
examName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生查看成绩。
|
||||
* - 普通学生账号:只看自己的成绩;
|
||||
* - 班级账号(班级设备):返回**本班全部学生的成绩总览**(个人学生不再是登录主体,
|
||||
* 班级设备上一屏展示全班成绩,便于张贴/核对)。
|
||||
* ClassHelper 班级端查看本班成绩。
|
||||
*
|
||||
* 班级端是个人数据的唯一读写主体(学生不是账号),因此这里返回**本班全部学生的成绩**,
|
||||
* 便于在教室大屏上一屏展示全班成绩供核对。
|
||||
* 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。
|
||||
*/
|
||||
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
const studentIds = await resolvePersonalIds(user);
|
||||
if (studentIds.length === 0) return [];
|
||||
|
||||
const grades = await prisma.grade.findMany({
|
||||
where: { userId: { in: personalIds } },
|
||||
where: { studentId: { in: studentIds } },
|
||||
include,
|
||||
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
|
||||
take: 500,
|
||||
take: 1000,
|
||||
});
|
||||
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.studentId ? { studentId: options.studentId } : {}),
|
||||
...(options.examName ? { examName: options.examName } : {}),
|
||||
},
|
||||
include,
|
||||
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
|
||||
take: 500,
|
||||
take: 1000,
|
||||
});
|
||||
return grades.map(toGradeDto);
|
||||
}
|
||||
@@ -66,15 +83,15 @@ export async function listGrades(user: TokenPayload, options: ListGradeOptions):
|
||||
/** 成绩统计(等级分布 + 各课程平均得分率),供 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 distributionMap = new Map<string, number>();
|
||||
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;
|
||||
// 自定义等级也要能统计,所以按 level 文本分桶(不再是固定的 A~E)
|
||||
const level = grade.level || gradeLevel(percent);
|
||||
distributionMap.set(level, (distributionMap.get(level) ?? 0) + 1);
|
||||
|
||||
const key = grade.courseId ?? '__none__';
|
||||
const entry = byCourseMap.get(key) ?? {
|
||||
@@ -101,58 +118,77 @@ export async function getGradeStats(user: TokenPayload, options: ListGradeOption
|
||||
averagePercent:
|
||||
Math.round((entry.percents.reduce((sum, value) => sum + value, 0) / entry.percents.length) * 10) / 10,
|
||||
})),
|
||||
distribution,
|
||||
distribution: [...distributionMap.entries()]
|
||||
.map(([level, count]) => ({ level, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 录入 / 修改 / 删除 */
|
||||
|
||||
/**
|
||||
* 录入一条成绩。
|
||||
*
|
||||
* 权限走 `assertCanManageSubjectContent`:**科任老师只能录自己任教科目**的成绩,
|
||||
* 班主任若不教这一科同样被拒;不指定科目(courseId 为空)时只有班主任/管理员可录。
|
||||
*/
|
||||
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
|
||||
await assertCanManageGrades(user, input.classId);
|
||||
await assertStudentInClass(input.userId, input.classId);
|
||||
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
|
||||
await assertStudentInClass(input.studentId, input.classId);
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
|
||||
|
||||
const levelType: GradeLevelType = input.levelType ?? 'percent';
|
||||
const totalScore = input.totalScore ?? 100;
|
||||
|
||||
const dto = await upsertGrade({
|
||||
classId: input.classId,
|
||||
courseId: input.courseId ?? null,
|
||||
userId: input.userId,
|
||||
studentId: input.studentId,
|
||||
examName: input.examName,
|
||||
score: input.score,
|
||||
totalScore: input.totalScore ?? 100,
|
||||
totalScore,
|
||||
levelType,
|
||||
level: resolveLevel(levelType, input.level, input.score, totalScore),
|
||||
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 assertCanManageGrades(user, input.classId);
|
||||
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
|
||||
|
||||
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
|
||||
const levelType: GradeLevelType = input.levelType ?? 'percent';
|
||||
const totalScore = input.totalScore ?? 100;
|
||||
const results: GradeDto[] = [];
|
||||
|
||||
for (const item of input.items) {
|
||||
await assertStudentInClass(item.userId, input.classId);
|
||||
await assertStudentInClass(item.studentId, input.classId);
|
||||
const dto = await upsertGrade({
|
||||
classId: input.classId,
|
||||
courseId: input.courseId ?? null,
|
||||
userId: item.userId,
|
||||
studentId: item.studentId,
|
||||
examName: input.examName,
|
||||
score: item.score,
|
||||
totalScore: input.totalScore ?? 100,
|
||||
totalScore,
|
||||
levelType,
|
||||
level: resolveLevel(levelType, item.level, item.score, totalScore),
|
||||
publishedAt,
|
||||
});
|
||||
results.push(dto);
|
||||
}
|
||||
|
||||
// 一次广播覆盖整班(房间内包含全部学生)
|
||||
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, {
|
||||
...results[0],
|
||||
bulk: true,
|
||||
count: results.length,
|
||||
});
|
||||
if (results[0]) {
|
||||
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, {
|
||||
...results[0],
|
||||
bulk: true,
|
||||
count: results.length,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -163,8 +199,22 @@ export async function updateGrade(
|
||||
): Promise<GradeDto> {
|
||||
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
|
||||
if (!current) throw ApiError.notFound('成绩记录不存在');
|
||||
await assertCanManageGrades(user, current.classId);
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
|
||||
|
||||
// 改的是哪条成绩,就按那条成绩所属的班级 + 科目判权限
|
||||
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;
|
||||
|
||||
const updated = await prisma.grade.update({
|
||||
where: { id: gradeId },
|
||||
@@ -172,7 +222,9 @@ export async function updateGrade(
|
||||
...(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.courseId !== undefined ? { courseId: nextCourseId } : {}),
|
||||
levelType,
|
||||
level,
|
||||
...(input.publishedAt !== undefined
|
||||
? { publishedAt: parseOptionalDate(input.publishedAt) ?? new Date() }
|
||||
: {}),
|
||||
@@ -182,14 +234,14 @@ export async function updateGrade(
|
||||
|
||||
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 assertCanManageGrades(user, current.classId);
|
||||
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩');
|
||||
|
||||
await prisma.grade.delete({ where: { id: gradeId } });
|
||||
emitToClass(current.classId, SOCKET_EVENTS.gradeUpdated, {
|
||||
...toGradeDto({ ...current, course: null, student: null, class: null }),
|
||||
@@ -197,6 +249,172 @@ export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 等级:单个 / 批量 */
|
||||
|
||||
/**
|
||||
* 批量修改等级。
|
||||
*
|
||||
* 两种用法:
|
||||
* 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)) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一学生 + 同一课程 + 同一考试名称视为同一条记录,
|
||||
* 重复录入时覆盖旧分数,避免教师"改分"产生重复行。
|
||||
@@ -204,17 +422,19 @@ export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<
|
||||
async function upsertGrade(data: {
|
||||
classId: string;
|
||||
courseId: string | null;
|
||||
userId: string;
|
||||
studentId: string;
|
||||
examName: string;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
levelType: GradeLevelType;
|
||||
level: string;
|
||||
publishedAt: Date;
|
||||
}): Promise<GradeDto> {
|
||||
const existing = await prisma.grade.findFirst({
|
||||
where: {
|
||||
classId: data.classId,
|
||||
courseId: data.courseId,
|
||||
userId: data.userId,
|
||||
studentId: data.studentId,
|
||||
examName: data.examName,
|
||||
},
|
||||
select: { id: true },
|
||||
@@ -223,7 +443,13 @@ async function upsertGrade(data: {
|
||||
if (existing) {
|
||||
const updated = await prisma.grade.update({
|
||||
where: { id: existing.id },
|
||||
data: { score: data.score, totalScore: data.totalScore, publishedAt: data.publishedAt },
|
||||
data: {
|
||||
score: data.score,
|
||||
totalScore: data.totalScore,
|
||||
levelType: data.levelType,
|
||||
level: data.level,
|
||||
publishedAt: data.publishedAt,
|
||||
},
|
||||
include,
|
||||
});
|
||||
return toGradeDto(updated);
|
||||
@@ -233,12 +459,12 @@ async function upsertGrade(data: {
|
||||
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 },
|
||||
async function assertStudentInClass(studentId: string, classId: string): Promise<void> {
|
||||
const student = await prisma.student.findUnique({
|
||||
where: { id: studentId },
|
||||
select: { classId: true },
|
||||
});
|
||||
if (!student || student.role !== 'STUDENT') throw ApiError.badRequest('学生不存在');
|
||||
if (!student) throw ApiError.badRequest('学生不存在');
|
||||
if (student.classId !== classId) throw ApiError.badRequest('该学生不属于所选班级');
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user