统计报表
This commit is contained in:
@@ -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<ActionResult<object>> 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<IActionResult> 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<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||
{ "各专业人数", ((IEnumerable<dynamic>)data.byMajor).Select(x => new object?[] { x.majorName, x.collegeName, x.count }) },
|
||||
{ "各班级人数", ((IEnumerable<dynamic>)data.byClass).Select(x => new object?[] { x.className, x.grade, x.majorName, x.count }) },
|
||||
{ "历年招生", ((IEnumerable<dynamic>)data.enrollmentTrend).Select(x => new object?[] { x.year, x.count }) },
|
||||
{ "性别分布", ((IEnumerable<dynamic>)data.byGender).Select(x => new object?[] { x.gender, x.count }) },
|
||||
{ "学籍状态", ((IEnumerable<dynamic>)data.byStatus).Select(x => new object?[] { x.status, x.count }) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── 2. Course Statistics ─────────────────────────────────────────
|
||||
|
||||
[HttpGet("courses/summary")]
|
||||
public async Task<ActionResult<object>> 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<IActionResult> 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<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||
{ "各分类课程数", ((IEnumerable<dynamic>)data.byCategory).Select(x => new object?[] { x.categoryName, x.count }) },
|
||||
{ "课程性质", ((IEnumerable<dynamic>)data.byNature).Select(x => new object?[] { x.natureLabel, x.count }) },
|
||||
{ "学分分布", ((IEnumerable<dynamic>)data.creditDistribution).Select(x => new object?[] { x.range, x.count }) },
|
||||
{ "考核方式", ((IEnumerable<dynamic>)data.byAssessment).Select(x => new object?[] { x.label, x.count }) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Grade Statistics ──────────────────────────────────────────
|
||||
|
||||
[HttpGet("grades/summary")]
|
||||
public async Task<ActionResult<object>> 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<IActionResult> 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<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
|
||||
{ "GPA分布", ((IEnumerable<dynamic>)data.gpaDistribution).Select(x => new object?[] { x.range, x.count }) },
|
||||
{ "各学院通过率", ((IEnumerable<dynamic>)data.passRateByCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.averageScore, x.totalRecords }) },
|
||||
{ "课程均分Top20", ((IEnumerable<dynamic>)data.averageByCourse).Select(x => new object?[] { x.courseName, x.averageScore, x.recordCount }) },
|
||||
{ "总体", new List<object?[]> { new object?[] { data.overall.averageScore, data.overall.passRate, data.overall.totalRecords } } },
|
||||
});
|
||||
}
|
||||
|
||||
// ── 4. Pass Rate Statistics ──────────────────────────────────────
|
||||
|
||||
[HttpGet("pass-rates/summary")]
|
||||
public async Task<ActionResult<object>> 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<IActionResult> 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<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) },
|
||||
{ "各课程通过率", ((IEnumerable<dynamic>)data.byCourse).Select(x => new object?[] { x.courseCode, x.courseName, x.passRate, x.total }) },
|
||||
{ "学期趋势", ((IEnumerable<dynamic>)data.trendByTerm).Select(x => new object?[] { x.termName, x.passRate }) },
|
||||
{ "课程性质", ((IEnumerable<dynamic>)data.passRateByNature).Select(x => new object?[] { x.natureLabel, x.passRate, x.total }) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── 5. Teacher Workload Statistics ───────────────────────────────
|
||||
|
||||
[HttpGet("teacher-workload/summary")]
|
||||
public async Task<ActionResult<object>> 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<IActionResult> ExportTeacherWorkload(
|
||||
Guid? academicTermId, Guid? collegeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetTeacherWorkloadSummary(academicTermId, collegeId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
return ExportSummary("教师工作量统计", new()
|
||||
{
|
||||
{ "教师明细", ((IEnumerable<dynamic>)data.byTeacher).Select(x => new object?[] { x.teacherName, x.teacherNumber, x.collegeName, x.title, x.totalHours, x.courseCount, x.taskCount }) },
|
||||
{ "各学院汇总", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.totalHours, x.teacherCount, x.avgHoursPerTeacher }) },
|
||||
{ "各职称", ((IEnumerable<dynamic>)data.byTitle).Select(x => new object?[] { x.title, x.avgHours, x.teacherCount }) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── 6. Classroom Utilization Statistics ──────────────────────────
|
||||
|
||||
[HttpGet("classroom-utilization/summary")]
|
||||
public async Task<ActionResult<object>> 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<object>(),
|
||||
byDayOfWeek = Array.Empty<object>(),
|
||||
byTimeSlot = Array.Empty<object>(),
|
||||
byClassroomType = Array.Empty<object>(),
|
||||
byCampus = Array.Empty<object>(),
|
||||
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<IActionResult> 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<dynamic>)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) },
|
||||
{ "各工作日", ((IEnumerable<dynamic>)data.byDayOfWeek).Select(x => new object?[] { x.dayLabel, x.utilizationRate }) },
|
||||
{ "各节次", ((IEnumerable<dynamic>)data.byTimeSlot).Select(x => new object?[] { x.period, x.utilizationRate }) },
|
||||
{ "教室类型", ((IEnumerable<dynamic>)data.byClassroomType).Select(x => new object?[] { x.roomType, x.count, x.utilizationRate }) },
|
||||
{ "各校区", ((IEnumerable<dynamic>)data.byCampus).Select(x => new object?[] { x.campusName, x.utilizationRate }) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private FileContentResult ExportSummary(
|
||||
string title,
|
||||
Dictionary<string, IEnumerable<object?[]>> sheets)
|
||||
{
|
||||
var bytes = ExcelWorkbookHelper.Create(title,
|
||||
new[] { "数据" },
|
||||
new List<object?[]> { 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
|
||||
};
|
||||
}
|
||||
Generated
+32
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+2
@@ -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']
|
||||
|
||||
@@ -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<NavigationGroup[]>(() => [
|
||||
direct: true,
|
||||
items: [{ path: '/dashboard', label: '教务总览' }],
|
||||
},
|
||||
...(isStatisticsViewer.value
|
||||
? [{
|
||||
key: 'statistics',
|
||||
label: '统计报表',
|
||||
direct: true,
|
||||
items: [{ path: '/statistics', label: '统计报表' }],
|
||||
} as NavigationGroup]
|
||||
: []),
|
||||
{
|
||||
key: 'organization',
|
||||
label: '组织与权限',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { Download, Search } from '@element-plus/icons-vue'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { BarChart, LineChart, PieChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { downloadApiFile } from '../api/excel'
|
||||
import http from '../api/http'
|
||||
|
||||
echarts.use([BarChart, LineChart, PieChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent, CanvasRenderer])
|
||||
|
||||
// ── Reference data ──
|
||||
const terms = ref<any[]>([])
|
||||
const colleges = ref<any[]>([])
|
||||
const courseCategories = ref<any[]>([])
|
||||
|
||||
// ── Active tab ──
|
||||
const activeTab = ref('students')
|
||||
|
||||
// ── Chart instance management ──
|
||||
const chartMap = new Map<string, echarts.ECharts>()
|
||||
function setChart(key: string, inst: echarts.ECharts) {
|
||||
chartMap.get(key)?.dispose()
|
||||
chartMap.set(key, inst)
|
||||
}
|
||||
function disposeCharts(prefix: string) {
|
||||
for (const [k, v] of chartMap) { if (k.startsWith(prefix)) { v.dispose(); chartMap.delete(k) } }
|
||||
}
|
||||
|
||||
onUnmounted(() => { for (const v of chartMap.values()) v.dispose(); chartMap.clear() })
|
||||
|
||||
window.addEventListener('resize', () => { for (const v of chartMap.values()) v.resize() })
|
||||
|
||||
// ── Shared filter state ──
|
||||
const globalTermId = ref<string>()
|
||||
|
||||
// ── 1. Students ──
|
||||
const studentFilter = reactive({ collegeId: undefined as string | undefined, majorId: undefined as string | undefined, classId: undefined as string | undefined, grade: undefined as number | undefined })
|
||||
const studentMajors = ref<any[]>([]); const studentClasses = ref<any[]>([])
|
||||
const studentStats = ref<any>(null); const studentLoading = ref(false)
|
||||
|
||||
async function loadStudentStats() {
|
||||
studentLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (globalTermId.value) params.academicTermId = globalTermId.value
|
||||
if (studentFilter.collegeId) params.collegeId = studentFilter.collegeId
|
||||
if (studentFilter.majorId) params.majorId = studentFilter.majorId
|
||||
if (studentFilter.classId) params.classId = studentFilter.classId
|
||||
if (studentFilter.grade) params.grade = studentFilter.grade
|
||||
const { data } = await http.get('/statistics/students/summary', { params })
|
||||
studentStats.value = data
|
||||
await nextTick()
|
||||
renderStudentCharts(data)
|
||||
} finally { studentLoading.value = false }
|
||||
}
|
||||
|
||||
function renderStudentCharts(d: any) {
|
||||
const key = 'student-'
|
||||
disposeCharts(key)
|
||||
renderPie(key + 'college', 'student-college-chart', d.byCollege?.map((x: any) => ({ name: x.collegeName, value: x.count })) ?? [], '各学院学生分布')
|
||||
renderPie(key + 'gender', 'student-gender-chart', d.byGender?.map((x: any) => ({ name: x.gender === 'Male' ? '男' : x.gender === 'Female' ? '女' : x.gender, value: x.count })) ?? [], '性别分布')
|
||||
renderBar(key + 'status', 'student-status-chart', d.byStatus?.map((x: any) => x.status) ?? [], d.byStatus?.map((x: any) => x.count) ?? [], '学籍状态')
|
||||
renderLine(key + 'trend', 'student-trend-chart', d.enrollmentTrend?.map((x: any) => `${x.year}年`) ?? [], d.enrollmentTrend?.map((x: any) => x.count) ?? [], '历年招生趋势')
|
||||
}
|
||||
|
||||
async function loadStudentMajors() {
|
||||
if (studentFilter.collegeId) {
|
||||
const { data } = await http.get('/base-data/majors')
|
||||
studentMajors.value = (data as any[]).filter((m: any) => m.collegeId === studentFilter.collegeId)
|
||||
} else { studentMajors.value = [] }
|
||||
}
|
||||
async function onStudentCollegeChange() {
|
||||
studentFilter.majorId = undefined; studentFilter.classId = undefined; studentClasses.value = []
|
||||
await loadStudentMajors()
|
||||
}
|
||||
async function onStudentMajorChange() {
|
||||
studentFilter.classId = undefined; studentClasses.value = []
|
||||
if (studentFilter.majorId) {
|
||||
const { data } = await http.get('/base-data/classes')
|
||||
studentClasses.value = (data as any[]).filter((c: any) => c.majorId === studentFilter.majorId)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Courses ──
|
||||
const courseFilter = reactive({ collegeId: undefined as string | undefined, categoryId: undefined as string | undefined, nature: undefined as string | undefined })
|
||||
const courseStats = ref<any>(null); const courseLoading = ref(false)
|
||||
|
||||
async function loadCourseStats() {
|
||||
courseLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (courseFilter.collegeId) params.collegeId = courseFilter.collegeId
|
||||
if (courseFilter.categoryId) params.categoryId = courseFilter.categoryId
|
||||
if (courseFilter.nature) params.nature = courseFilter.nature
|
||||
const { data } = await http.get('/statistics/courses/summary', { params })
|
||||
courseStats.value = data
|
||||
await nextTick()
|
||||
renderCourseCharts(data)
|
||||
} finally { courseLoading.value = false }
|
||||
}
|
||||
|
||||
function renderCourseCharts(d: any) {
|
||||
const key = 'course-'
|
||||
disposeCharts(key)
|
||||
renderBar(key + 'college', 'course-college-chart', d.byCollege?.map((x: any) => x.collegeName) ?? [], d.byCollege?.map((x: any) => x.count) ?? [], '各学院课程数')
|
||||
renderPie(key + 'nature', 'course-nature-chart', d.byNature?.map((x: any) => ({ name: x.natureLabel, value: x.count })) ?? [], '课程性质')
|
||||
renderBar(key + 'credit', 'course-credit-chart', d.creditDistribution?.map((x: any) => x.range) ?? [], d.creditDistribution?.map((x: any) => x.count) ?? [], '学分分布')
|
||||
renderPie(key + 'assessment', 'course-assessment-chart', d.byAssessment?.map((x: any) => ({ name: x.label, value: x.count })) ?? [], '考核方式')
|
||||
}
|
||||
|
||||
// ── 3. Grades ──
|
||||
const gradeFilter = reactive({ collegeId: undefined as string | undefined, majorId: undefined as string | undefined, classId: undefined as string | undefined, courseId: undefined as string | undefined })
|
||||
const gradeMajors = ref<any[]>([]); const gradeClasses = ref<any[]>([])
|
||||
const gradeStats = ref<any>(null); const gradeLoading = ref(false)
|
||||
|
||||
async function loadGradeStats() {
|
||||
gradeLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (globalTermId.value) params.academicTermId = globalTermId.value
|
||||
if (gradeFilter.collegeId) params.collegeId = gradeFilter.collegeId
|
||||
if (gradeFilter.majorId) params.majorId = gradeFilter.majorId
|
||||
if (gradeFilter.classId) params.classId = gradeFilter.classId
|
||||
if (gradeFilter.courseId) params.courseId = gradeFilter.courseId
|
||||
const { data } = await http.get('/statistics/grades/summary', { params })
|
||||
gradeStats.value = data
|
||||
await nextTick()
|
||||
renderGradeCharts(data)
|
||||
} finally { gradeLoading.value = false }
|
||||
}
|
||||
|
||||
function renderGradeCharts(d: any) {
|
||||
const key = 'grade-'
|
||||
disposeCharts(key)
|
||||
renderBar(key + 'score', 'grade-score-chart', d.scoreDistribution?.map((x: any) => x.label) ?? [], d.scoreDistribution?.map((x: any) => x.count) ?? [], '分数段分布')
|
||||
renderBar(key + 'gpa', 'grade-gpa-chart', d.gpaDistribution?.map((x: any) => x.range) ?? [], d.gpaDistribution?.map((x: any) => x.count) ?? [], 'GPA分布')
|
||||
const prbc = d.passRateByCollege ?? []
|
||||
renderBarHorizontal(key + 'pr-college', 'grade-pr-college-chart', prbc.map((x: any) => x.collegeName), prbc.map((x: any) => +(x.passRate * 100).toFixed(1)), '各学院通过率(%)')
|
||||
const abc = (d.averageByCourse ?? []).slice(0, 15)
|
||||
renderBarHorizontal(key + 'avg-course', 'grade-avg-course-chart', abc.map((x: any) => x.courseName), abc.map((x: any) => x.averageScore), '课程均分Top15')
|
||||
}
|
||||
|
||||
async function onGradeCollegeChange() {
|
||||
gradeFilter.majorId = undefined; gradeFilter.classId = undefined; gradeMajors.value = []; gradeClasses.value = []
|
||||
if (gradeFilter.collegeId) {
|
||||
const { data } = await http.get('/base-data/majors')
|
||||
gradeMajors.value = (data as any[]).filter((m: any) => m.collegeId === gradeFilter.collegeId)
|
||||
}
|
||||
}
|
||||
async function onGradeMajorChange() {
|
||||
gradeFilter.classId = undefined; gradeClasses.value = []
|
||||
if (gradeFilter.majorId) {
|
||||
const { data } = await http.get('/base-data/classes')
|
||||
gradeClasses.value = (data as any[]).filter((c: any) => c.majorId === gradeFilter.majorId)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Pass Rates ──
|
||||
const passRateFilter = reactive({ collegeId: undefined as string | undefined, courseId: undefined as string | undefined })
|
||||
const passRateStats = ref<any>(null); const passRateLoading = ref(false)
|
||||
|
||||
async function loadPassRateStats() {
|
||||
passRateLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (globalTermId.value) params.academicTermId = globalTermId.value
|
||||
if (passRateFilter.collegeId) params.collegeId = passRateFilter.collegeId
|
||||
if (passRateFilter.courseId) params.courseId = passRateFilter.courseId
|
||||
const { data } = await http.get('/statistics/pass-rates/summary', { params })
|
||||
passRateStats.value = data
|
||||
await nextTick()
|
||||
renderPassRateCharts(data)
|
||||
} finally { passRateLoading.value = false }
|
||||
}
|
||||
|
||||
function renderPassRateCharts(d: any) {
|
||||
const key = 'pr-'
|
||||
disposeCharts(key)
|
||||
const bc = d.byCollege ?? []
|
||||
renderBar(key + 'college', 'pr-college-chart', bc.map((x: any) => x.collegeName), bc.map((x: any) => +(x.passRate * 100).toFixed(1)), '各学院通过率(%)')
|
||||
const bcn = (d.byCourse ?? []).slice(0, 15)
|
||||
renderBar(key + 'course', 'pr-course-chart', bcn.map((x: any) => x.courseName), bcn.map((x: any) => +(x.passRate * 100).toFixed(1)), '课程通过率Top15(%)')
|
||||
renderLine(key + 'trend', 'pr-trend-chart', d.trendByTerm?.map((x: any) => x.termName) ?? [], d.trendByTerm?.map((x: any) => +(x.passRate * 100).toFixed(1)) ?? [], '学期通过率趋势(%)')
|
||||
renderPie(key + 'nature', 'pr-nature-chart', d.passRateByNature?.map((x: any) => ({ name: x.natureLabel, value: x.total })) ?? [], '各性质课程记录数')
|
||||
}
|
||||
|
||||
// ── 5. Teacher Workload ──
|
||||
const workloadFilter = reactive({ collegeId: undefined as string | undefined })
|
||||
const workloadStats = ref<any>(null); const workloadLoading = ref(false)
|
||||
|
||||
async function loadWorkloadStats() {
|
||||
workloadLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (globalTermId.value) params.academicTermId = globalTermId.value
|
||||
if (workloadFilter.collegeId) params.collegeId = workloadFilter.collegeId
|
||||
const { data } = await http.get('/statistics/teacher-workload/summary', { params })
|
||||
workloadStats.value = data
|
||||
await nextTick()
|
||||
renderWorkloadCharts(data)
|
||||
} finally { workloadLoading.value = false }
|
||||
}
|
||||
|
||||
function renderWorkloadCharts(d: any) {
|
||||
const key = 'wl-'
|
||||
disposeCharts(key)
|
||||
const bt = (d.byTeacher ?? []).slice(0, 20)
|
||||
renderBarHorizontal(key + 'teacher', 'wl-teacher-chart', bt.map((x: any) => x.teacherName), bt.map((x: any) => x.totalHours), '教师工作量Top20(学时)')
|
||||
const bcol = d.byCollege ?? []
|
||||
renderBar(key + 'college', 'wl-college-chart', bcol.map((x: any) => x.collegeName), bcol.map((x: any) => x.avgHoursPerTeacher), '各学院人均学时')
|
||||
const btit = d.byTitle ?? []
|
||||
renderBar(key + 'title', 'wl-title-chart', btit.map((x: any) => x.title), btit.map((x: any) => x.avgHours), '各职称人均学时')
|
||||
// distribute teachers by workload range
|
||||
const ranges = [
|
||||
{ range: '0-50', min: 0, max: 50 },
|
||||
{ range: '51-100', min: 51, max: 100 },
|
||||
{ range: '101-150', min: 101, max: 150 },
|
||||
{ range: '151-200', min: 151, max: 200 },
|
||||
{ range: '200+', min: 201, max: 9999 },
|
||||
]
|
||||
const dist = ranges.map(r => ({
|
||||
name: r.range + '学时',
|
||||
value: (d.byTeacher ?? []).filter((x: any) => x.totalHours >= r.min && x.totalHours <= r.max).length
|
||||
}))
|
||||
renderPie(key + 'dist', 'wl-dist-chart', dist, '工作量分布')
|
||||
}
|
||||
|
||||
// ── 6. Classroom Utilization ──
|
||||
const classroomFilter = reactive({ buildingId: undefined as string | undefined, campusId: undefined as string | undefined })
|
||||
const classroomBuildings = ref<any[]>([]); const classroomCampuses = ref<any[]>([])
|
||||
const classroomStats = ref<any>(null); const classroomLoading = ref(false)
|
||||
|
||||
async function loadClassroomStats() {
|
||||
classroomLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (globalTermId.value) params.academicTermId = globalTermId.value
|
||||
if (classroomFilter.buildingId) params.buildingId = classroomFilter.buildingId
|
||||
if (classroomFilter.campusId) params.campusId = classroomFilter.campusId
|
||||
const { data } = await http.get('/statistics/classroom-utilization/summary', { params })
|
||||
classroomStats.value = data
|
||||
await nextTick()
|
||||
renderClassroomCharts(data)
|
||||
} finally { classroomLoading.value = false }
|
||||
}
|
||||
|
||||
function renderClassroomCharts(d: any) {
|
||||
const key = 'cr-'
|
||||
disposeCharts(key)
|
||||
const bb = d.byBuilding ?? []
|
||||
renderBar(key + 'building', 'cr-building-chart', bb.map((x: any) => x.buildingName), bb.map((x: any) => +(x.utilizationRate * 100).toFixed(1)), '各教学楼利用率(%)')
|
||||
const bdow = d.byDayOfWeek ?? []
|
||||
renderBar(key + 'dow', 'cr-dow-chart', bdow.map((x: any) => x.dayLabel), bdow.map((x: any) => +(x.utilizationRate * 100).toFixed(1)), '各工作日利用率(%)')
|
||||
const bts = d.byTimeSlot ?? []
|
||||
renderBar(key + 'slot', 'cr-slot-chart', bts.map((x: any) => `第${x.period}节`), bts.map((x: any) => +(x.utilizationRate * 100).toFixed(1)), '各节次利用率(%)')
|
||||
renderBar(key + 'type', 'cr-type-chart', d.byClassroomType?.map((x: any) => x.roomType) ?? [], d.byClassroomType?.map((x: any) => +(x.utilizationRate * 100).toFixed(1)) ?? [], '各教室类型利用率(%)')
|
||||
}
|
||||
|
||||
// ── Reusable chart renderers ──
|
||||
function ensureDom(id: string): HTMLElement | null {
|
||||
return document.getElementById(id)
|
||||
}
|
||||
|
||||
function renderPie(chartKey: string, domId: string, data: { name: string; value: number }[], title: string) {
|
||||
const dom = ensureDom(domId)
|
||||
if (!dom) return
|
||||
const inst = echarts.init(dom)
|
||||
inst.setOption({
|
||||
title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
series: [{ type: 'pie', radius: ['35%', '65%'], center: ['50%', '55%'], data, label: { formatter: '{b}\n{d}%' } }]
|
||||
})
|
||||
setChart(chartKey, inst)
|
||||
}
|
||||
|
||||
function renderBar(chartKey: string, domId: string, categories: string[], values: number[], title: string) {
|
||||
const dom = ensureDom(domId)
|
||||
if (!dom) return
|
||||
const inst = echarts.init(dom)
|
||||
inst.setOption({
|
||||
title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '12%', containLabel: true },
|
||||
xAxis: { type: 'category', data: categories, axisLabel: { rotate: 30, fontSize: 11 } },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ type: 'bar', data: values, itemStyle: { color: '#409EFF' } }]
|
||||
})
|
||||
setChart(chartKey, inst)
|
||||
}
|
||||
|
||||
function renderBarHorizontal(chartKey: string, domId: string, categories: string[], values: number[], title: string) {
|
||||
const dom = ensureDom(domId)
|
||||
if (!dom) return
|
||||
const inst = echarts.init(dom)
|
||||
inst.setOption({
|
||||
title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: categories.reverse(), axisLabel: { fontSize: 11 } },
|
||||
series: [{ type: 'bar', data: values.reverse(), itemStyle: { color: '#67C23A' } }]
|
||||
})
|
||||
setChart(chartKey, inst)
|
||||
}
|
||||
|
||||
function renderLine(chartKey: string, domId: string, categories: string[], values: number[], title: string) {
|
||||
const dom = ensureDom(domId)
|
||||
if (!dom) return
|
||||
const inst = echarts.init(dom)
|
||||
inst.setOption({
|
||||
title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '12%', containLabel: true },
|
||||
xAxis: { type: 'category', data: categories, axisLabel: { rotate: 30, fontSize: 11 } },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ type: 'line', data: values, smooth: true, itemStyle: { color: '#E6A23C' }, areaStyle: { color: 'rgba(230,162,60,0.15)' } }]
|
||||
})
|
||||
setChart(chartKey, inst)
|
||||
}
|
||||
|
||||
// ── Excel export ──
|
||||
function buildFilterParams(type: string): any {
|
||||
const p: any = {}
|
||||
if (globalTermId.value) p.academicTermId = globalTermId.value
|
||||
if (type === 'students') { if (studentFilter.collegeId) p.collegeId = studentFilter.collegeId; if (studentFilter.majorId) p.majorId = studentFilter.majorId; if (studentFilter.classId) p.classId = studentFilter.classId; if (studentFilter.grade) p.grade = studentFilter.grade }
|
||||
if (type === 'courses') { if (courseFilter.collegeId) p.collegeId = courseFilter.collegeId; if (courseFilter.categoryId) p.categoryId = courseFilter.categoryId; if (courseFilter.nature) p.nature = courseFilter.nature }
|
||||
if (type === 'grades') { if (gradeFilter.collegeId) p.collegeId = gradeFilter.collegeId; if (gradeFilter.majorId) p.majorId = gradeFilter.majorId; if (gradeFilter.classId) p.classId = gradeFilter.classId; if (gradeFilter.courseId) p.courseId = gradeFilter.courseId }
|
||||
if (type === 'pass-rates') { if (passRateFilter.collegeId) p.collegeId = passRateFilter.collegeId; if (passRateFilter.courseId) p.courseId = passRateFilter.courseId }
|
||||
if (type === 'teacher-workload') { if (workloadFilter.collegeId) p.collegeId = workloadFilter.collegeId }
|
||||
if (type === 'classroom-utilization') { if (classroomFilter.buildingId) p.buildingId = classroomFilter.buildingId; if (classroomFilter.campusId) p.campusId = classroomFilter.campusId }
|
||||
return p
|
||||
}
|
||||
async function exportStats(type: string) {
|
||||
const labelMap: Record<string, string> = { students: '学生统计', courses: '课程统计', grades: '成绩统计', 'pass-rates': '通过率统计', 'teacher-workload': '教师工作量统计', 'classroom-utilization': '教室利用率统计' }
|
||||
await downloadApiFile(`/statistics/${type}/export`, `${labelMap[type] ?? type}.xlsx`, { params: buildFilterParams(type) })
|
||||
}
|
||||
|
||||
// ── Tab switching ──
|
||||
watch(activeTab, async (tab) => {
|
||||
const loadMap: Record<string, { load: () => Promise<void>; stats: any }> = {
|
||||
'students': { load: loadStudentStats, stats: studentStats },
|
||||
'courses': { load: loadCourseStats, stats: courseStats },
|
||||
'grades': { load: loadGradeStats, stats: gradeStats },
|
||||
'pass-rates': { load: loadPassRateStats, stats: passRateStats },
|
||||
'teacher-workload': { load: loadWorkloadStats, stats: workloadStats },
|
||||
'classroom-utilization': { load: loadClassroomStats, stats: classroomStats },
|
||||
}
|
||||
const entry = loadMap[tab]
|
||||
if (!entry) return
|
||||
if (!entry.stats.value) { await entry.load() } else { await nextTick(); for (const v of chartMap.values()) v.resize() }
|
||||
})
|
||||
|
||||
// ── Init ──
|
||||
onMounted(async () => {
|
||||
const [tRes, cRes, catRes, bRes, campRes] = await Promise.all([
|
||||
http.get('/base-data/terms'),
|
||||
http.get('/base-data/colleges'),
|
||||
http.get('/base-data/course-categories'),
|
||||
http.get('/base-data/buildings').catch(() => ({ data: [] })),
|
||||
http.get('/base-data/campuses').catch(() => ({ data: [] })),
|
||||
])
|
||||
terms.value = tRes.data
|
||||
colleges.value = cRes.data
|
||||
courseCategories.value = catRes.data
|
||||
classroomBuildings.value = bRes.data
|
||||
classroomCampuses.value = campRes.data
|
||||
if (terms.value.length > 0) {
|
||||
const cur = terms.value.find((t: any) => t.isCurrent) ?? terms.value[terms.value.length - 1]
|
||||
globalTermId.value = cur.id
|
||||
}
|
||||
loadStudentStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="section-kicker">REPORTS</span>
|
||||
<h2>统计报表</h2>
|
||||
<p>学生、课程、成绩、教师工作量与教室利用率的数据汇总与可视化分析。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-select v-model="globalTermId" placeholder="选择学期" style="width:220px" clearable @change="() => { loadStudentStats(); loadGradeStats(); loadPassRateStats(); loadWorkloadStats(); loadClassroomStats() }">
|
||||
<el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-tabs v-model="activeTab" class="stat-tabs">
|
||||
<!-- ═══ 学生统计 ═══ -->
|
||||
<el-tab-pane label="学生统计" name="students">
|
||||
<section class="data-card" v-loading="studentLoading">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="studentFilter.collegeId" clearable placeholder="学院" @change="onStudentCollegeChange">
|
||||
<el-option v-for="c in colleges" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
<el-select v-model="studentFilter.majorId" clearable placeholder="专业" @change="onStudentMajorChange">
|
||||
<el-option v-for="m in studentMajors" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
<el-select v-model="studentFilter.classId" clearable placeholder="班级">
|
||||
<el-option v-for="cl in studentClasses" :key="cl.id" :label="cl.name" :value="cl.id" />
|
||||
</el-select>
|
||||
<el-input-number v-model="studentFilter.grade" :min="2010" :max="2030" placeholder="年级" controls-position="right" style="width:140px" />
|
||||
<el-button type="primary" :icon="Search" @click="loadStudentStats">查询</el-button>
|
||||
<el-button :icon="Download" @click="exportStats('students')">导出Excel</el-button>
|
||||
</div>
|
||||
<div class="chart-grid">
|
||||
<div id="student-college-chart" class="chart-box" />
|
||||
<div id="student-gender-chart" class="chart-box" />
|
||||
<div id="student-status-chart" class="chart-box" />
|
||||
<div id="student-trend-chart" class="chart-box" />
|
||||
</div>
|
||||
<el-collapse v-if="studentStats">
|
||||
<el-collapse-item title="明细数据">
|
||||
<el-table :data="studentStats.byCollege ?? []" size="small" max-height="300">
|
||||
<el-table-column prop="collegeName" label="学院" />
|
||||
<el-table-column prop="count" label="学生数" sortable />
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ═══ 课程统计 ═══ -->
|
||||
<el-tab-pane label="课程统计" name="courses">
|
||||
<section class="data-card" v-loading="courseLoading">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="courseFilter.collegeId" clearable placeholder="学院">
|
||||
<el-option v-for="c in colleges" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
<el-select v-model="courseFilter.categoryId" clearable placeholder="课程类别">
|
||||
<el-option v-for="cat in courseCategories" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||
</el-select>
|
||||
<el-select v-model="courseFilter.nature" clearable placeholder="课程性质">
|
||||
<el-option label="通识必修" value="GeneralRequired" />
|
||||
<el-option label="专业必修" value="MajorRequired" />
|
||||
<el-option label="专业选修" value="MajorElective" />
|
||||
<el-option label="通识选修" value="GeneralElective" />
|
||||
<el-option label="实践环节" value="Practice" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="loadCourseStats">查询</el-button>
|
||||
<el-button :icon="Download" @click="exportStats('courses')">导出Excel</el-button>
|
||||
</div>
|
||||
<div class="chart-grid">
|
||||
<div id="course-college-chart" class="chart-box" />
|
||||
<div id="course-nature-chart" class="chart-box" />
|
||||
<div id="course-credit-chart" class="chart-box" />
|
||||
<div id="course-assessment-chart" class="chart-box" />
|
||||
</div>
|
||||
<el-collapse v-if="courseStats">
|
||||
<el-collapse-item title="明细数据">
|
||||
<el-table :data="courseStats.byCollege ?? []" size="small" max-height="300">
|
||||
<el-table-column prop="collegeName" label="学院" />
|
||||
<el-table-column prop="count" label="课程数" sortable />
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ═══ 成绩统计 ═══ -->
|
||||
<el-tab-pane label="成绩统计" name="grades">
|
||||
<section class="data-card" v-loading="gradeLoading">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="gradeFilter.collegeId" clearable placeholder="学院" @change="onGradeCollegeChange">
|
||||
<el-option v-for="c in colleges" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
<el-select v-model="gradeFilter.majorId" clearable placeholder="专业" @change="onGradeMajorChange">
|
||||
<el-option v-for="m in gradeMajors" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
<el-select v-model="gradeFilter.classId" clearable placeholder="班级">
|
||||
<el-option v-for="cl in gradeClasses" :key="cl.id" :label="cl.name" :value="cl.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="loadGradeStats">查询</el-button>
|
||||
<el-button :icon="Download" @click="exportStats('grades')">导出Excel</el-button>
|
||||
</div>
|
||||
<div v-if="gradeStats?.overall" class="metrics-strip">
|
||||
<span class="metric-chip"><b>{{ gradeStats.overall.totalRecords }}</b> 条成绩</span>
|
||||
<span class="metric-chip"><b>{{ gradeStats.overall.averageScore }}</b> 平均分</span>
|
||||
<span class="metric-chip"><b>{{ (gradeStats.overall.passRate * 100).toFixed(1) }}%</b> 通过率</span>
|
||||
</div>
|
||||
<div class="chart-grid">
|
||||
<div id="grade-score-chart" class="chart-box" />
|
||||
<div id="grade-gpa-chart" class="chart-box" />
|
||||
<div id="grade-pr-college-chart" class="chart-box" />
|
||||
<div id="grade-avg-course-chart" class="chart-box" />
|
||||
</div>
|
||||
<el-collapse v-if="gradeStats">
|
||||
<el-collapse-item title="各学院通过率明细">
|
||||
<el-table :data="gradeStats.passRateByCollege ?? []" size="small" max-height="300">
|
||||
<el-table-column prop="collegeName" label="学院" />
|
||||
<el-table-column label="通过率" sortable="custom">
|
||||
<template #default="{ row }">{{ (row.passRate * 100).toFixed(1) }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="averageScore" label="平均分" sortable />
|
||||
<el-table-column prop="totalRecords" label="成绩条数" sortable />
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ═══ 通过率统计 ═══ -->
|
||||
<el-tab-pane label="通过率统计" name="pass-rates">
|
||||
<section class="data-card" v-loading="passRateLoading">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="passRateFilter.collegeId" clearable placeholder="学院">
|
||||
<el-option v-for="c in colleges" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="loadPassRateStats">查询</el-button>
|
||||
<el-button :icon="Download" @click="exportStats('pass-rates')">导出Excel</el-button>
|
||||
</div>
|
||||
<div v-if="passRateStats" class="metrics-strip">
|
||||
<span class="metric-chip"><b>{{ (passRateStats.overallPassRate * 100).toFixed(1) }}%</b> 总通过率</span>
|
||||
<span class="metric-chip"><b>{{ passRateStats.totalRecords }}</b> 条成绩</span>
|
||||
</div>
|
||||
<div class="chart-grid">
|
||||
<div id="pr-college-chart" class="chart-box" />
|
||||
<div id="pr-course-chart" class="chart-box" />
|
||||
<div id="pr-trend-chart" class="chart-box" />
|
||||
<div id="pr-nature-chart" class="chart-box" />
|
||||
</div>
|
||||
<el-collapse v-if="passRateStats">
|
||||
<el-collapse-item title="低通过率课程 (底10)">
|
||||
<el-table :data="passRateStats.byCourseTopFail ?? []" size="small" max-height="300">
|
||||
<el-table-column prop="courseCode" label="课程代码" />
|
||||
<el-table-column prop="courseName" label="课程名称" />
|
||||
<el-table-column label="通过率" sortable="custom">
|
||||
<template #default="{ row }">{{ (row.passRate * 100).toFixed(1) }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total" label="学生数" sortable />
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ═══ 教师工作量 ═══ -->
|
||||
<el-tab-pane label="教师工作量" name="teacher-workload">
|
||||
<section class="data-card" v-loading="workloadLoading">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="workloadFilter.collegeId" clearable placeholder="学院">
|
||||
<el-option v-for="c in colleges" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="loadWorkloadStats">查询</el-button>
|
||||
<el-button :icon="Download" @click="exportStats('teacher-workload')">导出Excel</el-button>
|
||||
</div>
|
||||
<div v-if="workloadStats?.totals" class="metrics-strip">
|
||||
<span class="metric-chip"><b>{{ workloadStats.totals.totalTeachers }}</b> 位教师</span>
|
||||
<span class="metric-chip"><b>{{ workloadStats.totals.totalHours }}</b> 总学时</span>
|
||||
<span class="metric-chip"><b>{{ workloadStats.totals.avgHoursPerTeacher }}</b> 人均学时</span>
|
||||
</div>
|
||||
<div class="chart-grid">
|
||||
<div id="wl-teacher-chart" class="chart-box" />
|
||||
<div id="wl-college-chart" class="chart-box" />
|
||||
<div id="wl-title-chart" class="chart-box" />
|
||||
<div id="wl-dist-chart" class="chart-box" />
|
||||
</div>
|
||||
<el-collapse v-if="workloadStats">
|
||||
<el-collapse-item title="教师工作量明细 (Top50)">
|
||||
<el-table :data="(workloadStats.byTeacher ?? []).slice(0, 50)" size="small" max-height="400">
|
||||
<el-table-column prop="teacherName" label="姓名" />
|
||||
<el-table-column prop="teacherNumber" label="工号" />
|
||||
<el-table-column prop="collegeName" label="学院" />
|
||||
<el-table-column prop="title" label="职称" />
|
||||
<el-table-column prop="totalHours" label="总学时" sortable />
|
||||
<el-table-column prop="courseCount" label="课程数" sortable />
|
||||
<el-table-column prop="taskCount" label="教学班数" sortable />
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ═══ 教室利用率 ═══ -->
|
||||
<el-tab-pane label="教室利用率" name="classroom-utilization">
|
||||
<section class="data-card" v-loading="classroomLoading">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="classroomFilter.campusId" clearable placeholder="校区">
|
||||
<el-option v-for="c in classroomCampuses" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
<el-select v-model="classroomFilter.buildingId" clearable placeholder="教学楼">
|
||||
<el-option v-for="b in classroomBuildings" :key="b.id" :label="b.name" :value="b.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="loadClassroomStats">查询</el-button>
|
||||
<el-button :icon="Download" @click="exportStats('classroom-utilization')">导出Excel</el-button>
|
||||
</div>
|
||||
<div v-if="classroomStats?.totals" class="metrics-strip">
|
||||
<span class="metric-chip"><b>{{ classroomStats.totals.totalClassrooms }}</b> 间教室</span>
|
||||
<span class="metric-chip"><b>{{ (classroomStats.totals.overallUtilizationRate * 100).toFixed(1) }}%</b> 总利用率</span>
|
||||
</div>
|
||||
<div class="chart-grid">
|
||||
<div id="cr-building-chart" class="chart-box" />
|
||||
<div id="cr-dow-chart" class="chart-box" />
|
||||
<div id="cr-slot-chart" class="chart-box" />
|
||||
<div id="cr-type-chart" class="chart-box" />
|
||||
</div>
|
||||
<el-collapse v-if="classroomStats">
|
||||
<el-collapse-item title="各教学楼利用率明细">
|
||||
<el-table :data="classroomStats.byBuilding ?? []" size="small" max-height="300">
|
||||
<el-table-column prop="buildingName" label="教学楼" />
|
||||
<el-table-column prop="totalClassrooms" label="教室数" sortable />
|
||||
<el-table-column label="利用率" sortable="custom">
|
||||
<template #default="{ row }">{{ (row.utilizationRate * 100).toFixed(1) }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalUsedPeriods" label="已用节次" sortable />
|
||||
<el-table-column prop="totalAvailablePeriods" label="可用节次" sortable />
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-stack { max-width: 1400px; margin: 0 auto }
|
||||
.stat-tabs { margin-top: 16px }
|
||||
.stat-tabs :deep(.el-tabs__header) { margin-bottom: 12px }
|
||||
|
||||
.filter-bar {
|
||||
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.chart-box { width: 100%; height: 340px; background: var(--el-fill-color-lighter, #fafafa); border-radius: 8px }
|
||||
|
||||
.metrics-strip {
|
||||
display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.metric-chip {
|
||||
background: var(--el-color-primary-light-9, #ecf5ff); color: var(--el-color-primary, #409EFF);
|
||||
padding: 6px 16px; border-radius: 20px; font-size: 14px;
|
||||
}
|
||||
.metric-chip b { margin-right: 4px }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.chart-grid { grid-template-columns: 1fr }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user