246 lines
6.9 KiB
TypeScript
246 lines
6.9 KiB
TypeScript
import { eq, inArray, gte, and } from "drizzle-orm";
|
||
import { Injectable } from "@nestjs/common";
|
||
import { db } from "../config/database.js";
|
||
import { classes } from "../classes/classes.schema.js";
|
||
import { exams, examSubmissions } from "../exams/exams.schema.js";
|
||
import { homework, homeworkSubmissions } from "../homework/homework.schema.js";
|
||
import { grades } from "../grades/grades.schema.js";
|
||
import {
|
||
NotFoundError,
|
||
ValidationError,
|
||
} from "../shared/errors/application-error.js";
|
||
|
||
export interface DashboardData {
|
||
teacherId: string;
|
||
totalClasses: number;
|
||
totalStudents: number;
|
||
pendingHomework: number;
|
||
upcomingExams: number;
|
||
ungradedSubmissions: number;
|
||
classes: Array<{
|
||
classId: string;
|
||
className: string;
|
||
studentCount: number;
|
||
}>;
|
||
upcomingExamList: Array<{
|
||
examId: string;
|
||
title: string;
|
||
examDate: string;
|
||
classId: string;
|
||
className: string;
|
||
}>;
|
||
generatedAt: string;
|
||
}
|
||
|
||
export interface ClassPerformance {
|
||
classId: string;
|
||
className: string;
|
||
studentCount: number;
|
||
averageScore: string;
|
||
highestScore: string;
|
||
lowestScore: string;
|
||
medianScore: string;
|
||
subjects: Array<{
|
||
subjectId: string;
|
||
subjectName: string;
|
||
averageScore: string;
|
||
studentCount: number;
|
||
}>;
|
||
generatedAt: string;
|
||
}
|
||
|
||
@Injectable()
|
||
export class DashboardService {
|
||
async getDashboard(teacherId: string): Promise<DashboardData> {
|
||
if (!teacherId) {
|
||
throw new ValidationError("teacherId is required");
|
||
}
|
||
|
||
// 1. 查询该教师负责的班级
|
||
const teacherClasses = await db
|
||
.select()
|
||
.from(classes)
|
||
.where(eq(classes.headTeacherId, teacherId));
|
||
|
||
const classIds = teacherClasses.map((c) => c.id);
|
||
const classMap = new Map(teacherClasses.map((c) => [c.id, c.name]));
|
||
|
||
if (classIds.length === 0) {
|
||
return {
|
||
teacherId,
|
||
totalClasses: 0,
|
||
totalStudents: 0,
|
||
pendingHomework: 0,
|
||
upcomingExams: 0,
|
||
ungradedSubmissions: 0,
|
||
classes: [],
|
||
upcomingExamList: [],
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
// 2. 构造班级卡片(学生数需 IAM 集成,P3.13 stub 用 0 占位)
|
||
const classCards = teacherClasses.map((c) => ({
|
||
classId: c.id,
|
||
className: c.name,
|
||
studentCount: 0,
|
||
}));
|
||
|
||
// 3. 统计待处理作业(status='assigned')
|
||
const pendingHomeworkRows = await db
|
||
.select({ id: homework.id })
|
||
.from(homework)
|
||
.where(
|
||
and(
|
||
inArray(homework.classId, classIds),
|
||
eq(homework.status, "assigned"),
|
||
),
|
||
);
|
||
|
||
// 4. 统计即将到来的考试(status='published', exam_date >= now)
|
||
const now = new Date();
|
||
const upcomingExamRows = await db
|
||
.select()
|
||
.from(exams)
|
||
.where(
|
||
and(
|
||
inArray(exams.classId, classIds),
|
||
eq(exams.status, "published"),
|
||
gte(exams.examDate, now),
|
||
),
|
||
);
|
||
|
||
const upcomingExamList = upcomingExamRows.map((e) => ({
|
||
examId: e.id,
|
||
title: e.title,
|
||
examDate:
|
||
e.examDate instanceof Date ? e.examDate.toISOString() : e.examDate,
|
||
classId: e.classId,
|
||
className: classMap.get(e.classId) ?? "",
|
||
}));
|
||
|
||
// 5. 统计未批阅提交(exam_submissions + homework_submissions status='submitted')
|
||
const examIds = upcomingExamRows.map((e) => e.id);
|
||
let ungradedExamCount = 0;
|
||
if (examIds.length > 0) {
|
||
const ungradedExamRows = await db
|
||
.select({ id: examSubmissions.id })
|
||
.from(examSubmissions)
|
||
.where(
|
||
and(
|
||
inArray(examSubmissions.examId, examIds),
|
||
eq(examSubmissions.status, "submitted"),
|
||
),
|
||
);
|
||
ungradedExamCount = ungradedExamRows.length;
|
||
}
|
||
|
||
const homeworkIds = pendingHomeworkRows.map((h) => h.id);
|
||
let ungradedHwCount = 0;
|
||
if (homeworkIds.length > 0) {
|
||
const ungradedHwRows = await db
|
||
.select({ id: homeworkSubmissions.id })
|
||
.from(homeworkSubmissions)
|
||
.where(
|
||
and(
|
||
inArray(homeworkSubmissions.homeworkId, homeworkIds),
|
||
eq(homeworkSubmissions.status, "submitted"),
|
||
),
|
||
);
|
||
ungradedHwCount = ungradedHwRows.length;
|
||
}
|
||
|
||
return {
|
||
teacherId,
|
||
totalClasses: teacherClasses.length,
|
||
totalStudents: 0, // P3.13 stub: 需 IAM 集成
|
||
pendingHomework: pendingHomeworkRows.length,
|
||
upcomingExams: upcomingExamRows.length,
|
||
ungradedSubmissions: ungradedExamCount + ungradedHwCount,
|
||
classes: classCards,
|
||
upcomingExamList,
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
async getClassPerformance(
|
||
classId: string,
|
||
_subjectId?: string,
|
||
): Promise<ClassPerformance> {
|
||
if (!classId) {
|
||
throw new ValidationError("classId is required");
|
||
}
|
||
|
||
// 1. 查询班级信息
|
||
const classRows = await db
|
||
.select()
|
||
.from(classes)
|
||
.where(eq(classes.id, classId))
|
||
.limit(1);
|
||
|
||
const cls = classRows[0];
|
||
if (!cls) {
|
||
throw new NotFoundError(`Class ${classId} not found`);
|
||
}
|
||
|
||
// 2. 查询该班学生的成绩(通过 exam_id 关联 exam.class_id)
|
||
const classExamIds = await db
|
||
.select({ id: exams.id })
|
||
.from(exams)
|
||
.where(eq(exams.classId, classId));
|
||
|
||
const examIdList = classExamIds.map((e) => e.id);
|
||
|
||
let allGrades: Array<{ score: string; totalScore: string }> = [];
|
||
if (examIdList.length > 0) {
|
||
const gradeRows = await db
|
||
.select({
|
||
score: grades.score,
|
||
totalScore: grades.totalScore,
|
||
})
|
||
.from(grades)
|
||
.where(inArray(grades.examId, examIdList));
|
||
allGrades = gradeRows;
|
||
}
|
||
|
||
// 3. 计算统计值
|
||
const percentages = allGrades.map((g) => {
|
||
const total = Number(g.totalScore);
|
||
return total > 0 ? (Number(g.score) / total) * 100 : 0;
|
||
});
|
||
|
||
// 学生数需 IAM 集成,P3.13 stub 用成绩记录去重学生数估算
|
||
const average =
|
||
percentages.length > 0
|
||
? percentages.reduce((a, b) => a + b, 0) / percentages.length
|
||
: 0;
|
||
const highest = percentages.length > 0 ? Math.max(...percentages) : 0;
|
||
const lowest = percentages.length > 0 ? Math.min(...percentages) : 0;
|
||
const median = this.calcMedian(percentages);
|
||
|
||
return {
|
||
classId,
|
||
className: cls.name,
|
||
studentCount: 0, // P3.13 stub: 需 IAM 集成
|
||
averageScore: average.toFixed(2),
|
||
highestScore: highest.toFixed(2),
|
||
lowestScore: lowest.toFixed(2),
|
||
medianScore: median.toFixed(2),
|
||
subjects: [], // P3.13 stub: 需 content 服务集成获取学科名
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
private calcMedian(values: number[]): number {
|
||
if (values.length === 0) return 0;
|
||
const sorted = [...values].sort((a, b) => a - b);
|
||
const mid = Math.floor(sorted.length / 2);
|
||
if (sorted.length % 2 === 0) {
|
||
const a = sorted[mid - 1] ?? 0;
|
||
const b = sorted[mid] ?? 0;
|
||
return (a + b) / 2;
|
||
}
|
||
return sorted[mid] ?? 0;
|
||
}
|
||
}
|