feat(core-edu): admin/dashboard/leave-requests 模块 + gRPC + 状态机测试 + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 15:56:10 +08:00
parent d260df864c
commit 7dd5c44406
23 changed files with 3344 additions and 2 deletions

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { DashboardService } from "./dashboard.service.js";
@Module({
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule {}

View 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;
}
}