feat(core-edu): admin/dashboard/leave-requests 模块 + gRPC + 状态机测试 + nextstep 文档
This commit is contained in:
8
services/core-edu/src/dashboard/dashboard.module.ts
Normal file
8
services/core-edu/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { DashboardService } from "./dashboard.service.js";
|
||||
|
||||
@Module({
|
||||
providers: [DashboardService],
|
||||
exports: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
245
services/core-edu/src/dashboard/dashboard.service.ts
Normal file
245
services/core-edu/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user