From 97691cb203a17d270c41353fddf0c76b9b12190c Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Jul 2026 17:48:53 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=8A=A5=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/StatisticsController.cs | 787 ++++++++++++++++++ web/package-lock.json | 32 + web/package.json | 1 + web/src/components.d.ts | 2 + web/src/layouts/AdminLayout.vue | 14 + web/src/router/index.ts | 8 + web/src/views/StatisticsView.vue | 649 +++++++++++++++ 7 files changed, 1493 insertions(+) create mode 100644 src/Jiaowu.Api/Controllers/StatisticsController.cs create mode 100644 web/src/views/StatisticsView.vue diff --git a/src/Jiaowu.Api/Controllers/StatisticsController.cs b/src/Jiaowu.Api/Controllers/StatisticsController.cs new file mode 100644 index 0000000..a9cd79e --- /dev/null +++ b/src/Jiaowu.Api/Controllers/StatisticsController.cs @@ -0,0 +1,787 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize(Roles = ViewerRoles)] +[Route("api/statistics")] +public sealed class StatisticsController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase +{ + private const string ViewerRoles = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin + "," + + SystemRoles.Leader; + + private Guid? RestrictedCollegeId => currentUserDataScope.Current.RestrictedCollegeId; + + private Guid? ResolveCollegeId(Guid? requestedCollegeId) + { + if (RestrictedCollegeId.HasValue) + return RestrictedCollegeId; + return requestedCollegeId; + } + + // ── 1. Student Statistics ──────────────────────────────────────── + + [HttpGet("students/summary")] + public async Task> GetStudentSummary( + Guid? collegeId, Guid? majorId, Guid? classId, + int? grade, int? enrollmentYear, + CancellationToken cancellationToken) + { + var effectiveCollegeId = ResolveCollegeId(collegeId); + if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid(); + + var baseQuery = db.Students.AsNoTracking() + .Where(s => effectiveCollegeId == null || + s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId) + .Where(s => majorId == null || s.AdministrativeClass!.MajorId == majorId) + .Where(s => classId == null || s.AdministrativeClassId == classId) + .Where(s => grade == null || s.AdministrativeClass!.Grade == grade) + .Where(s => enrollmentYear == null || s.EnrollmentYear == enrollmentYear); + + var byCollege = await baseQuery + .GroupBy(s => new { s.AdministrativeClass!.Major!.CollegeId, CollegeName = s.AdministrativeClass.Major.College!.Name }) + .Select(g => new { collegeId = g.Key.CollegeId, collegeName = g.Key.CollegeName, count = g.Count() }) + .OrderByDescending(x => x.count) + .ToListAsync(cancellationToken); + + var byMajor = await baseQuery + .GroupBy(s => new { s.AdministrativeClass!.MajorId, MajorName = s.AdministrativeClass.Major!.Name, CollegeName = s.AdministrativeClass.Major.College!.Name }) + .Select(g => new { majorId = g.Key.MajorId, majorName = g.Key.MajorName, collegeName = g.Key.CollegeName, count = g.Count() }) + .OrderByDescending(x => x.count) + .ToListAsync(cancellationToken); + + var byClass = await baseQuery + .GroupBy(s => new { s.AdministrativeClassId, ClassName = s.AdministrativeClass!.Name, s.AdministrativeClass.Grade, MajorName = s.AdministrativeClass.Major!.Name }) + .Select(g => new { classId = g.Key.AdministrativeClassId, className = g.Key.ClassName, grade = g.Key.Grade, majorName = g.Key.MajorName, count = g.Count() }) + .OrderByDescending(x => x.count) + .ToListAsync(cancellationToken); + + var byGrade = await baseQuery + .GroupBy(s => s.AdministrativeClass!.Grade) + .Select(g => new { grade = g.Key, count = g.Count() }) + .OrderBy(x => x.grade) + .ToListAsync(cancellationToken); + + var byStatus = await baseQuery + .GroupBy(s => s.Status) + .Select(g => new { status = g.Key.ToString(), count = g.Count() }) + .ToListAsync(cancellationToken); + + var byGender = await baseQuery + .GroupBy(s => s.Gender) + .Select(g => new { gender = g.Key.ToString(), count = g.Count() }) + .ToListAsync(cancellationToken); + + var enrollmentTrend = await db.Students.AsNoTracking() + .Where(s => effectiveCollegeId == null || s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId) + .GroupBy(s => s.EnrollmentYear) + .Select(g => new { year = g.Key, count = g.Count() }) + .OrderBy(x => x.year) + .ToListAsync(cancellationToken); + + var totalStudents = await baseQuery.CountAsync(cancellationToken); + + return new + { + byCollege, byMajor, byClass, byGrade, byStatus, byGender, + enrollmentTrend, + totals = new { totalStudents } + }; + } + + [HttpGet("students/export")] + public async Task ExportStudents( + Guid? collegeId, Guid? majorId, Guid? classId, + int? grade, int? enrollmentYear, + CancellationToken cancellationToken) + { + var data = (dynamic)(await GetStudentSummary(collegeId, majorId, classId, grade, enrollmentYear, cancellationToken) + .ConfigureAwait(false)).Value!; + return ExportSummary("学生统计", new() + { + { "各学院人数", ((IEnumerable)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) }, + { "各专业人数", ((IEnumerable)data.byMajor).Select(x => new object?[] { x.majorName, x.collegeName, x.count }) }, + { "各班级人数", ((IEnumerable)data.byClass).Select(x => new object?[] { x.className, x.grade, x.majorName, x.count }) }, + { "历年招生", ((IEnumerable)data.enrollmentTrend).Select(x => new object?[] { x.year, x.count }) }, + { "性别分布", ((IEnumerable)data.byGender).Select(x => new object?[] { x.gender, x.count }) }, + { "学籍状态", ((IEnumerable)data.byStatus).Select(x => new object?[] { x.status, x.count }) }, + }); + } + + // ── 2. Course Statistics ───────────────────────────────────────── + + [HttpGet("courses/summary")] + public async Task> GetCourseSummary( + Guid? collegeId, Guid? categoryId, CourseNature? nature, + CancellationToken cancellationToken) + { + var effectiveCollegeId = ResolveCollegeId(collegeId); + if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid(); + + var baseQuery = db.Courses.AsNoTracking() + .Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId) + .Where(c => categoryId == null || c.CourseCategoryId == categoryId) + .Where(c => nature == null || c.Nature == nature); + + var byCollege = await baseQuery + .GroupBy(c => new { c.CollegeId, CollegeName = c.College!.Name }) + .Select(g => new { collegeId = g.Key.CollegeId, collegeName = g.Key.CollegeName, count = g.Count() }) + .OrderByDescending(x => x.count) + .ToListAsync(cancellationToken); + + var byCategory = await baseQuery + .GroupBy(c => new { c.CourseCategoryId, CategoryName = c.CourseCategory!.Name }) + .Select(g => new { categoryId = g.Key.CourseCategoryId, categoryName = g.Key.CategoryName, count = g.Count() }) + .OrderByDescending(x => x.count) + .ToListAsync(cancellationToken); + + var byNature = await baseQuery + .GroupBy(c => c.Nature) + .Select(g => new { nature = g.Key.ToString(), natureLabel = MapCourseNature(g.Key), count = g.Count() }) + .ToListAsync(cancellationToken); + + var byAssessment = await baseQuery + .GroupBy(c => c.AssessmentMethod) + .Select(g => new { method = g.Key.ToString(), label = g.Key == AssessmentMethod.Examination ? "考试" : "考查", count = g.Count() }) + .ToListAsync(cancellationToken); + + // credit distribution in-memory + var credits = await baseQuery.Select(c => c.Credits).ToListAsync(cancellationToken); + var creditDistribution = new[] + { + new { range = "0-1", min = 0m, max = 1m, count = credits.Count(c => c > 0 && c <= 1) }, + new { range = "1-2", min = 1m, max = 2m, count = credits.Count(c => c > 1 && c <= 2) }, + new { range = "2-3", min = 2m, max = 3m, count = credits.Count(c => c > 2 && c <= 3) }, + new { range = "3-4", min = 3m, max = 4m, count = credits.Count(c => c > 3 && c <= 4) }, + new { range = "4-5", min = 4m, max = 5m, count = credits.Count(c => c > 4 && c <= 5) }, + new { range = "5+", min = 5m, max = 99m, count = credits.Count(c => c > 5) }, + }; + + var totalCourses = await baseQuery.CountAsync(cancellationToken); + + return new { byCollege, byCategory, byNature, creditDistribution, byAssessment, totals = new { totalCourses } }; + } + + [HttpGet("courses/export")] + public async Task ExportCourses( + Guid? collegeId, Guid? categoryId, CourseNature? nature, + CancellationToken cancellationToken) + { + var data = (dynamic)(await GetCourseSummary(collegeId, categoryId, nature, cancellationToken) + .ConfigureAwait(false)).Value!; + return ExportSummary("课程统计", new() + { + { "各学院课程数", ((IEnumerable)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) }, + { "各分类课程数", ((IEnumerable)data.byCategory).Select(x => new object?[] { x.categoryName, x.count }) }, + { "课程性质", ((IEnumerable)data.byNature).Select(x => new object?[] { x.natureLabel, x.count }) }, + { "学分分布", ((IEnumerable)data.creditDistribution).Select(x => new object?[] { x.range, x.count }) }, + { "考核方式", ((IEnumerable)data.byAssessment).Select(x => new object?[] { x.label, x.count }) }, + }); + } + + // ── 3. Grade Statistics ────────────────────────────────────────── + + [HttpGet("grades/summary")] + public async Task> GetGradeSummary( + Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId, + Guid? courseId, CancellationToken cancellationToken) + { + var effectiveCollegeId = ResolveCollegeId(collegeId); + if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid(); + + var recordsQuery = db.GradeRecords.AsNoTracking() + .Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published) + .Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId) + .Where(r => effectiveCollegeId == null || r.Student!.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId) + .Where(r => majorId == null || r.Student!.AdministrativeClass!.MajorId == majorId) + .Where(r => classId == null || r.Student!.AdministrativeClassId == classId) + .Where(r => courseId == null || r.GradeSheet!.TeachingTask!.CourseId == courseId); + + // fetch scores in-memory for distribution bucketing + var scores = await recordsQuery + .Where(r => r.TotalScore != null) + .Select(r => r.TotalScore!.Value) + .ToListAsync(cancellationToken); + + var scoreDistribution = new[] + { + new { range = "0-59", label = "不及格", count = scores.Count(s => s < 60) }, + new { range = "60-69", label = "及格", count = scores.Count(s => s >= 60 && s < 70) }, + new { range = "70-79", label = "中等", count = scores.Count(s => s >= 70 && s < 80) }, + new { range = "80-89", label = "良好", count = scores.Count(s => s >= 80 && s < 90) }, + new { range = "90-100", label = "优秀", count = scores.Count(s => s >= 90) }, + }; + + var gpas = await recordsQuery + .Where(r => r.GradePoint != null) + .Select(r => r.GradePoint!.Value) + .ToListAsync(cancellationToken); + + var gpaDistribution = new[] + { + new { range = "0-0.9", count = gpas.Count(g => g < 1.0m) }, + new { range = "1.0-1.9", count = gpas.Count(g => g >= 1.0m && g < 2.0m) }, + new { range = "2.0-2.9", count = gpas.Count(g => g >= 2.0m && g < 3.0m) }, + new { range = "3.0-3.5", count = gpas.Count(g => g >= 3.0m && g < 3.6m) }, + new { range = "3.6-4.0", count = gpas.Count(g => g >= 3.6m) }, + }; + + var passRateByCollege = await db.GradeRecords.AsNoTracking() + .Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published) + .Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId) + .Where(r => effectiveCollegeId == null || r.Student!.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId) + .GroupBy(r => new { r.Student!.AdministrativeClass!.Major!.CollegeId, CollegeName = r.Student.AdministrativeClass.Major.College!.Name }) + .Select(g => new + { + collegeName = g.Key.CollegeName, + totalRecords = g.Count(), + passedRecords = g.Count(r => r.TotalScore != null && r.TotalScore >= 60), + averageScore = g.Where(r => r.TotalScore != null).Average(r => (double?)r.TotalScore) ?? 0, + }) + .OrderByDescending(x => x.totalRecords) + .ToListAsync(cancellationToken); + + var passRateByCollegeResult = passRateByCollege.Select(x => new + { + x.collegeName, x.totalRecords, + passRate = x.totalRecords > 0 ? Math.Round((double)x.passedRecords / x.totalRecords, 4) : 0, + averageScore = Math.Round(x.averageScore, 1) + }).ToList(); + + var averageByCourse = await db.GradeRecords.AsNoTracking() + .Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published) + .Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId) + .Where(r => effectiveCollegeId == null || r.GradeSheet!.TeachingTask!.Course!.CollegeId == effectiveCollegeId) + .GroupBy(r => new { r.GradeSheet!.TeachingTask!.CourseId, CourseName = r.GradeSheet.TeachingTask.Course!.Name }) + .Select(g => new + { + courseName = g.Key.CourseName, + recordCount = g.Count(), + averageScore = g.Where(r => r.TotalScore != null).Average(r => (double?)r.TotalScore) ?? 0, + }) + .OrderByDescending(x => x.recordCount) + .Take(20) + .ToListAsync(cancellationToken); + + var hasScores = scores.Count > 0; + return new + { + scoreDistribution, + gpaDistribution, + passRateByCollege = passRateByCollegeResult, + averageByCourse = averageByCourse.Select(x => new { x.courseName, x.recordCount, averageScore = Math.Round(x.averageScore, 1) }), + overall = new + { + averageScore = hasScores ? Math.Round(scores.Average(), 1) : 0, + passRate = hasScores ? Math.Round((double)scores.Count(s => s >= 60) / scores.Count, 4) : 0, + totalRecords = scores.Count + } + }; + } + + [HttpGet("grades/export")] + public async Task ExportGrades( + Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId, + Guid? courseId, CancellationToken cancellationToken) + { + var data = (dynamic)(await GetGradeSummary(academicTermId, collegeId, majorId, classId, courseId, cancellationToken) + .ConfigureAwait(false)).Value!; + return ExportSummary("成绩统计", new() + { + { "分数段分布", ((IEnumerable)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) }, + { "GPA分布", ((IEnumerable)data.gpaDistribution).Select(x => new object?[] { x.range, x.count }) }, + { "各学院通过率", ((IEnumerable)data.passRateByCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.averageScore, x.totalRecords }) }, + { "课程均分Top20", ((IEnumerable)data.averageByCourse).Select(x => new object?[] { x.courseName, x.averageScore, x.recordCount }) }, + { "总体", new List { new object?[] { data.overall.averageScore, data.overall.passRate, data.overall.totalRecords } } }, + }); + } + + // ── 4. Pass Rate Statistics ────────────────────────────────────── + + [HttpGet("pass-rates/summary")] + public async Task> GetPassRateSummary( + Guid? academicTermId, Guid? collegeId, Guid? courseId, + CancellationToken cancellationToken) + { + var effectiveCollegeId = ResolveCollegeId(collegeId); + if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid(); + + var recordsQuery = db.GradeRecords.AsNoTracking() + .Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published) + .Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId) + .Where(r => effectiveCollegeId == null || r.Student!.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId) + .Where(r => courseId == null || r.GradeSheet!.TeachingTask!.CourseId == courseId); + + var byCollege = await recordsQuery + .GroupBy(r => new { r.Student!.AdministrativeClass!.Major!.CollegeId, CollegeName = r.Student.AdministrativeClass.Major.College!.Name }) + .Select(g => new + { + collegeName = g.Key.CollegeName, + total = g.Count(), + passed = g.Count(r => r.TotalScore != null && r.TotalScore >= 60), + }) + .ToListAsync(cancellationToken); + + var byCollegeResult = byCollege.Select(x => new + { + x.collegeName, x.total, + passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0 + }).OrderByDescending(x => x.passRate).ToList(); + + var byCourse = await recordsQuery + .GroupBy(r => new { r.GradeSheet!.TeachingTask!.CourseId, CourseCode = r.GradeSheet.TeachingTask.Course!.Code, CourseName = r.GradeSheet.TeachingTask.Course!.Name }) + .Select(g => new + { + courseCode = g.Key.CourseCode, + courseName = g.Key.CourseName, + total = g.Count(), + passed = g.Count(r => r.TotalScore != null && r.TotalScore >= 60), + }) + .ToListAsync(cancellationToken); + + var byCourseResult = byCourse.Select(x => new + { + x.courseCode, x.courseName, x.total, + passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0 + }).OrderByDescending(x => x.passRate).ToList(); + + var topFail = byCourseResult.Where(x => x.total >= 10).OrderBy(x => x.passRate).Take(10).ToList(); + + // trend by term + var termRecords = await db.GradeRecords.AsNoTracking() + .Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published) + .Where(r => effectiveCollegeId == null || r.Student!.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId) + .Where(r => courseId == null || r.GradeSheet!.TeachingTask!.CourseId == courseId) + .Select(r => new { r.TotalScore, TermName = r.GradeSheet!.TeachingTask!.AcademicTerm!.Name }) + .ToListAsync(cancellationToken); + + var trendByTerm = termRecords + .GroupBy(x => x.TermName) + .Select(g => new + { + termName = g.Key, + passRate = Math.Round((double)g.Count(x => x.TotalScore != null && x.TotalScore >= 60) / + Math.Max(1, g.Count(x => x.TotalScore != null)), 4) + }) + .OrderBy(x => x.termName) + .ToList(); + + // by course nature + var byNature = await recordsQuery + .GroupBy(r => r.GradeSheet!.TeachingTask!.Course!.Nature) + .Select(g => new + { + nature = g.Key.ToString(), + total = g.Count(), + passed = g.Count(r => r.TotalScore != null && r.TotalScore >= 60), + }) + .ToListAsync(cancellationToken); + + var byNatureResult = byNature.Select(x => new + { + natureLabel = MapCourseNature(x.nature), + x.total, + passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0 + }).ToList(); + + var totalRecords = await recordsQuery.CountAsync(cancellationToken); + var totalPassed = await recordsQuery.CountAsync(r => r.TotalScore != null && r.TotalScore >= 60, cancellationToken); + + return new + { + overallPassRate = totalRecords > 0 ? Math.Round((double)totalPassed / totalRecords, 4) : 0, + totalRecords, + byCollege = byCollegeResult, + byCourse = byCourseResult, + byCourseTopFail = topFail, + trendByTerm, + passRateByNature = byNatureResult, + }; + } + + [HttpGet("pass-rates/export")] + public async Task ExportPassRates( + Guid? academicTermId, Guid? collegeId, Guid? courseId, + CancellationToken cancellationToken) + { + var data = (dynamic)(await GetPassRateSummary(academicTermId, collegeId, courseId, cancellationToken) + .ConfigureAwait(false)).Value!; + return ExportSummary("通过率统计", new() + { + { "各学院通过率", ((IEnumerable)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) }, + { "各课程通过率", ((IEnumerable)data.byCourse).Select(x => new object?[] { x.courseCode, x.courseName, x.passRate, x.total }) }, + { "学期趋势", ((IEnumerable)data.trendByTerm).Select(x => new object?[] { x.termName, x.passRate }) }, + { "课程性质", ((IEnumerable)data.passRateByNature).Select(x => new object?[] { x.natureLabel, x.passRate, x.total }) }, + }); + } + + // ── 5. Teacher Workload Statistics ─────────────────────────────── + + [HttpGet("teacher-workload/summary")] + public async Task> GetTeacherWorkloadSummary( + Guid? academicTermId, Guid? collegeId, + CancellationToken cancellationToken) + { + var effectiveCollegeId = ResolveCollegeId(collegeId); + if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid(); + + var tasks = await db.TeachingTaskTeachers.AsNoTracking() + .Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId) + .Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId) + .Select(tt => new + { + tt.TeacherId, + TeacherName = tt.Teacher!.Name, + tt.Teacher.TeacherNumber, + tt.IsPrimary, + CollegeName = tt.Teacher.College!.Name, + tt.Teacher.Title, + CourseId = tt.TeachingTask!.CourseId, + tt.TeachingTask.WeeklyHours, + tt.TeachingTask.StartWeek, + tt.TeachingTask.EndWeek, + }) + .ToListAsync(cancellationToken); + + // compute workload per teacher + var byTeacher = tasks + .GroupBy(x => new { x.TeacherId, x.TeacherName, x.TeacherNumber, x.CollegeName, x.Title }) + .Select(g => new + { + teacherId = g.Key.TeacherId, + teacherName = g.Key.TeacherName, + teacherNumber = g.Key.TeacherNumber, + collegeName = g.Key.CollegeName, + title = g.Key.Title ?? "-", + totalHours = g.Sum(x => x.WeeklyHours * (x.EndWeek - x.StartWeek + 1)), + courseCount = g.Select(x => x.CourseId).Distinct().Count(), + taskCount = g.Count(), + weeklyHours = (int)Math.Round(g.Average(x => x.WeeklyHours * (g.Count(y => y.CourseId == x.CourseId) > 0 ? 1 : 1))), + }) + .OrderByDescending(x => x.totalHours) + .ToList(); + + var byCollege = byTeacher + .GroupBy(x => x.collegeName) + .Select(g => new + { + collegeName = g.Key, + totalHours = g.Sum(x => x.totalHours), + teacherCount = g.Count(), + avgHoursPerTeacher = (int)Math.Round(g.Average(x => (double)x.totalHours)), + }) + .OrderByDescending(x => x.totalHours) + .ToList(); + + var byTitle = byTeacher + .GroupBy(x => x.title) + .Select(g => new + { + title = g.Key, + teacherCount = g.Count(), + avgHours = (int)Math.Round(g.Average(x => (double)x.totalHours)), + }) + .OrderByDescending(x => x.teacherCount) + .ToList(); + + return new + { + byTeacher, + byCollege, + byTitle, + totals = new + { + totalTeachers = byTeacher.Count, + totalHours = byTeacher.Sum(x => x.totalHours), + avgHoursPerTeacher = byTeacher.Count > 0 ? (int)Math.Round(byTeacher.Average(x => (double)x.totalHours)) : 0, + } + }; + } + + [HttpGet("teacher-workload/export")] + public async Task ExportTeacherWorkload( + Guid? academicTermId, Guid? collegeId, + CancellationToken cancellationToken) + { + var data = (dynamic)(await GetTeacherWorkloadSummary(academicTermId, collegeId, cancellationToken) + .ConfigureAwait(false)).Value!; + return ExportSummary("教师工作量统计", new() + { + { "教师明细", ((IEnumerable)data.byTeacher).Select(x => new object?[] { x.teacherName, x.teacherNumber, x.collegeName, x.title, x.totalHours, x.courseCount, x.taskCount }) }, + { "各学院汇总", ((IEnumerable)data.byCollege).Select(x => new object?[] { x.collegeName, x.totalHours, x.teacherCount, x.avgHoursPerTeacher }) }, + { "各职称", ((IEnumerable)data.byTitle).Select(x => new object?[] { x.title, x.avgHours, x.teacherCount }) }, + }); + } + + // ── 6. Classroom Utilization Statistics ────────────────────────── + + [HttpGet("classroom-utilization/summary")] + public async Task> GetClassroomUtilizationSummary( + Guid? academicTermId, Guid? buildingId, Guid? campusId, + CancellationToken cancellationToken) + { + // Find published schedule plan + var planQuery = db.SchedulePlans.AsNoTracking() + .Where(p => p.Status == SchedulePlanStatus.Published); + if (academicTermId.HasValue) + planQuery = planQuery.Where(p => p.AcademicTermId == academicTermId); + + var plan = await planQuery + .OrderByDescending(p => p.PublishedAt) + .Select(p => new { p.Id, p.AcademicTermId }) + .FirstOrDefaultAsync(cancellationToken); + + // classroom filter + var classroomsQuery = db.Classrooms.AsNoTracking() + .Where(c => buildingId == null || c.BuildingId == buildingId) + .Where(c => campusId == null || c.Building!.CampusId == campusId); + + var classroomIds = await classroomsQuery.Select(c => c.Id).ToListAsync(cancellationToken); + var totalClassrooms = classroomIds.Count; + + if (plan == null || totalClassrooms == 0) + { + return new + { + byBuilding = Array.Empty(), + byDayOfWeek = Array.Empty(), + byTimeSlot = Array.Empty(), + byClassroomType = Array.Empty(), + byCampus = Array.Empty(), + totals = new { totalClassrooms, overallUtilizationRate = 0.0 } + }; + } + + // get entries + var entries = await db.ScheduleEntries.AsNoTracking() + .Where(e => e.SchedulePlanId == plan.Id) + .Where(e => e.ClassroomId != null && classroomIds.Contains(e.ClassroomId.Value)) + .Select(e => new + { + e.ClassroomId, + e.DayOfWeek, + e.StartPeriod, + e.PeriodCount, + BuildingName = e.Classroom!.Building!.Name, + ClassroomType = e.Classroom.RoomType, + CampusName = e.Classroom.Building.Campus!.Name, + e.Classroom.BuildingId, + CampusId = e.Classroom.Building.CampusId, + }) + .ToListAsync(cancellationToken); + + // max periods per day from time slots + var maxPeriods = await db.ScheduleTimeSlots.AsNoTracking() + .Where(s => s.AcademicTermId == plan.AcademicTermId && s.IsEnabled) + .Select(s => s.PeriodNumber) + .Distinct() + .CountAsync(cancellationToken); + if (maxPeriods == 0) maxPeriods = 10; + + int totalAvailableSlots = totalClassrooms * 5 * maxPeriods; + int totalUsedSlots = entries.Sum(e => e.PeriodCount); + double overallRate = totalAvailableSlots > 0 ? Math.Round((double)totalUsedSlots / totalAvailableSlots, 4) : 0; + + // by building + var byBuilding = entries + .GroupBy(e => new { e.BuildingId, e.BuildingName }) + .Select(g => new + { + buildingName = g.Key.BuildingName, + usedSlots = g.Sum(e => e.PeriodCount), + // count distinct classrooms in this building used or available + }) + .ToList(); + + // better: compute per building + var classroomsByBuilding = await classroomsQuery + .GroupBy(c => new { c.BuildingId, BuildingName = c.Building!.Name }) + .Select(g => new { g.Key.BuildingId, g.Key.BuildingName, count = g.Count() }) + .ToListAsync(cancellationToken); + + var byBuildingResult = classroomsByBuilding.Select(b => + { + var used = entries.Where(e => e.BuildingId == b.BuildingId).Sum(e => e.PeriodCount); + int available = b.count * 5 * maxPeriods; + return new + { + buildingName = b.BuildingName, + totalClassrooms = b.count, + utilizationRate = available > 0 ? Math.Round((double)used / available, 4) : 0, + totalUsedPeriods = used, + totalAvailablePeriods = available + }; + }).OrderByDescending(x => x.utilizationRate).ToList(); + + // by day of week + var dayLabels = new[] { "", "周一", "周二", "周三", "周四", "周五", "周六", "周日" }; + int dailyAvailable = totalClassrooms * maxPeriods; + var byDayOfWeek = Enumerable.Range(1, 7).Select(day => + { + var used = entries.Where(e => e.DayOfWeek == day).Sum(e => e.PeriodCount); + return new + { + day, dayLabel = day < dayLabels.Length ? dayLabels[day] : $"周{day}", + utilizationRate = dailyAvailable > 0 ? Math.Round((double)used / dailyAvailable, 4) : 0 + }; + }).ToList(); + + // by time slot + var byTimeSlot = Enumerable.Range(1, maxPeriods).Select(period => + { + int periodAvailable = totalClassrooms * 5; // 5 days + var used = entries.Where(e => e.StartPeriod <= period && period < e.StartPeriod + e.PeriodCount).Sum(_ => 1); + return new + { + period, + utilizationRate = periodAvailable > 0 ? Math.Round((double)used / periodAvailable, 4) : 0 + }; + }).ToList(); + + // by classroom type + var byClassroomTypeResult = await classroomsQuery + .GroupBy(c => c.RoomType) + .Select(g => new { roomType = g.Key, count = g.Count() }) + .ToListAsync(cancellationToken); + + var byClassroomType = byClassroomTypeResult.Select(ct => + { + var typeClassroomIds = entries.Where(e => e.ClassroomType == ct.roomType).Select(e => e.ClassroomId).Distinct().Count(); + var used = entries.Where(e => e.ClassroomType == ct.roomType).Sum(e => e.PeriodCount); + int available = ct.count * 5 * maxPeriods; + return new + { + roomType = ct.roomType, + count = ct.count, + utilizationRate = available > 0 ? Math.Round((double)used / available, 4) : 0 + }; + }).ToList(); + + // by campus + var byCampus = await classroomsQuery + .GroupBy(c => c.Building!.Campus!.Name) + .Select(g => new { campusName = g.Key, count = g.Count() }) + .ToListAsync(cancellationToken); + + var byCampusResult = byCampus.Select(c => + { + var used = entries.Where(e => e.CampusName == c.campusName).Sum(e => e.PeriodCount); + int available = c.count * 5 * maxPeriods; + return new + { + c.campusName, + utilizationRate = available > 0 ? Math.Round((double)used / available, 4) : 0 + }; + }).OrderByDescending(x => x.utilizationRate).ToList(); + + return new + { + byBuilding = byBuildingResult, + byDayOfWeek, + byTimeSlot, + byClassroomType, + byCampus = byCampusResult, + totals = new { totalClassrooms, overallUtilizationRate = overallRate } + }; + } + + [HttpGet("classroom-utilization/export")] + public async Task ExportClassroomUtilization( + Guid? academicTermId, Guid? buildingId, Guid? campusId, + CancellationToken cancellationToken) + { + var data = (dynamic)(await GetClassroomUtilizationSummary(academicTermId, buildingId, campusId, cancellationToken) + .ConfigureAwait(false)).Value!; + return ExportSummary("教室利用率统计", new() + { + { "各教学楼", ((IEnumerable)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) }, + { "各工作日", ((IEnumerable)data.byDayOfWeek).Select(x => new object?[] { x.dayLabel, x.utilizationRate }) }, + { "各节次", ((IEnumerable)data.byTimeSlot).Select(x => new object?[] { x.period, x.utilizationRate }) }, + { "教室类型", ((IEnumerable)data.byClassroomType).Select(x => new object?[] { x.roomType, x.count, x.utilizationRate }) }, + { "各校区", ((IEnumerable)data.byCampus).Select(x => new object?[] { x.campusName, x.utilizationRate }) }, + }); + } + + // ── Helpers ────────────────────────────────────────────────────── + + private FileContentResult ExportSummary( + string title, + Dictionary> sheets) + { + var bytes = ExcelWorkbookHelper.Create(title, + new[] { "数据" }, + new List { new object?[] { title } }); + + // MultiSheet export: use ClosedXML directly + using var workbook = new ClosedXML.Excel.XLWorkbook(); + foreach (var (sheetName, rows) in sheets) + { + var sheet = workbook.Worksheets.Add(sheetName); + var rowList = rows.ToList(); + if (rowList.Count > 0 && rowList[0].Length > 0) + { + // Write rows + for (int r = 0; r < rowList.Count; r++) + { + for (int c = 0; c < rowList[r].Length; c++) + { + var cell = sheet.Cell(r + 1, c + 1); + var val = rowList[r][c]; + if (val is double d) + cell.Value = d; + else if (val is int i) + cell.Value = i; + else if (val is decimal m) + cell.Value = (double)m; + else + cell.SetValue(val?.ToString() ?? ""); + } + } + // bold header row + sheet.Row(1).Style.Font.Bold = true; + sheet.Row(1).Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#1F3A6D"); + sheet.Row(1).Style.Font.FontColor = ClosedXML.Excel.XLColor.White; + sheet.SheetView.FreezeRows(1); + sheet.Columns().AdjustToContents(10, 38); + sheet.RangeUsed()?.SetAutoFilter(); + } + } + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return File(stream.ToArray(), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + $"{title}.xlsx"); + } + + private static string MapCourseNature(CourseNature nature) => nature switch + { + CourseNature.GeneralRequired => "通识必修", + CourseNature.MajorRequired => "专业必修", + CourseNature.MajorElective => "专业选修", + CourseNature.GeneralElective => "通识选修", + CourseNature.Practice => "实践环节", + _ => nature.ToString() + }; + + private static string MapCourseNature(string nature) => nature switch + { + "GeneralRequired" => "通识必修", + "MajorRequired" => "专业必修", + "MajorElective" => "专业选修", + "GeneralElective" => "通识选修", + "Practice" => "实践环节", + _ => nature + }; +} diff --git a/web/package-lock.json b/web/package-lock.json index 871c649..d860fdb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "axios": "^1.18.1", + "echarts": "^6.1.0", "element-plus": "^2.14.3", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", @@ -1083,6 +1084,22 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/element-plus": { "version": "2.14.3", "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz", @@ -2581,6 +2598,21 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "dev": true, "license": "MIT" + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/web/package.json b/web/package.json index 42e08e6..7d6d2bf 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,7 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "axios": "^1.18.1", + "echarts": "^6.1.0", "element-plus": "^2.14.3", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", diff --git a/web/src/components.d.ts b/web/src/components.d.ts index c608d88..71827e5 100644 --- a/web/src/components.d.ts +++ b/web/src/components.d.ts @@ -17,6 +17,8 @@ declare module 'vue' { ElCard: typeof import('element-plus/es')['ElCard'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] + ElCollapse: typeof import('element-plus/es')['ElCollapse'] + ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 36559cf..3c65f8c 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -43,6 +43,12 @@ const isTimetableManager = computed(() => ), ) +const isStatisticsViewer = computed(() => + roles.value.some((role) => + ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role), + ), +) + function hasAnyRole(allowedRoles: string[]) { return roles.value.some((role) => allowedRoles.includes(role)) } @@ -58,6 +64,14 @@ const navigationGroups = computed(() => [ direct: true, items: [{ path: '/dashboard', label: '教务总览' }], }, + ...(isStatisticsViewer.value + ? [{ + key: 'statistics', + label: '统计报表', + direct: true, + items: [{ path: '/statistics', label: '统计报表' }], + } as NavigationGroup] + : []), { key: 'organization', label: '组织与权限', diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 62771b4..28d2617 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -260,6 +260,14 @@ const router = createRouter({ component: () => import('../views/UsersView.vue'), meta: { roles: ['SuperAdmin'] }, }, + { + path: 'statistics', + name: 'statistics', + component: () => import('../views/StatisticsView.vue'), + meta: { + roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'], + }, + }, ], }, { path: '/:pathMatch(.*)*', redirect: '/dashboard' }, diff --git a/web/src/views/StatisticsView.vue b/web/src/views/StatisticsView.vue new file mode 100644 index 0000000..24ba79e --- /dev/null +++ b/web/src/views/StatisticsView.vue @@ -0,0 +1,649 @@ + + + + +