diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index a149529..fc09c89 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -48,10 +48,7 @@ public sealed class CourseSelectionsController( var total = await source.CountAsync(cancellationToken); var page = Math.Max(1, query.Page); var pageSize = Math.Clamp(query.PageSize, 1, 100); - var items = await ProjectEnrollments(source) - .OrderByDescending(x => x.TermStartDate) - .ThenBy(x => x.CourseCode) - .ThenBy(x => x.StudentNumber) + var items = await ProjectEnrollments(OrderEnrollments(source)) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); @@ -64,10 +61,8 @@ public sealed class CourseSelectionsController( [FromQuery] CourseEnrollmentQuery query, CancellationToken cancellationToken) { - var items = await ProjectEnrollments(ApplyEnrollmentFilters(ScopedEnrollments(), query)) - .OrderByDescending(x => x.TermStartDate) - .ThenBy(x => x.CourseCode) - .ThenBy(x => x.StudentNumber) + var items = await ProjectEnrollments(OrderEnrollments( + ApplyEnrollmentFilters(ScopedEnrollments(), query))) .ToListAsync(cancellationToken); var rows = items.Select(x => (IReadOnlyList)[ x.TermName, x.CollegeName, x.CourseCode, x.CourseName, x.TaskNumber, @@ -83,6 +78,66 @@ public sealed class CourseSelectionsController( $"修读课程名单-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); } + [HttpGet("enrollments/students")] + [Authorize(Roles = OfferingManagers)] + public async Task GetStudentEnrollmentSummaries( + [FromQuery] CourseEnrollmentQuery query, + CancellationToken cancellationToken) + { + var source = ApplyEnrollmentFilters(ScopedEnrollments(), query); + var students = source.Select(x => new EnrollmentStudentIdentity( + x.StudentId, x.Student!.StudentNumber, x.Student.Name, + x.Student.AdministrativeClass!.Major!.Name, + x.Student.AdministrativeClass.Name, x.Student.AdministrativeClass.Grade)); + var total = await students.Distinct().CountAsync(cancellationToken); + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + var pageStudents = await students.Distinct() + .OrderBy(x => x.StudentNumber).Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(cancellationToken); + var studentIds = pageStudents.Select(x => x.StudentId).ToArray(); + var enrollments = await ProjectEnrollments(OrderEnrollments(source.Where(x => + studentIds.Contains(x.StudentId)))) + .ToListAsync(cancellationToken); + var summaries = pageStudents.Select(student => + { + var courses = enrollments.Where(x => x.StudentId == student.StudentId).ToList(); + return new StudentEnrollmentSummary( + student.StudentId, student.StudentNumber, student.StudentName, + student.MajorName, student.ClassName, student.Grade, courses, + courses.Count, courses.Where(x => x.Status == CourseEnrollmentStatus.Enrolled) + .Sum(x => x.Credits)); + }).ToList(); + return Ok(new PagedResult(summaries, total, page, pageSize)); + } + + [HttpGet("enrollments/students/export.xlsx")] + [Authorize(Roles = OfferingManagers)] + public async Task ExportStudentEnrollmentSummaries( + [FromQuery] CourseEnrollmentQuery query, + CancellationToken cancellationToken) + { + var items = await ProjectEnrollments(OrderEnrollments( + ApplyEnrollmentFilters(ScopedEnrollments(), query))) + .ToListAsync(cancellationToken); + var rows = items.GroupBy(x => new { x.StudentId, x.StudentNumber, x.StudentName, + x.MajorName, x.ClassName, x.Grade }) + .OrderBy(x => x.Key.StudentNumber) + .Select(group => (IReadOnlyList)[ + group.Key.StudentNumber, group.Key.StudentName, group.Key.MajorName, + group.Key.ClassName, group.Key.Grade, + string.Join(";", group.Select(x => + $"{x.TermName} {x.CourseCode} {x.CourseName}({x.Status})")), + group.Where(x => x.Status == CourseEnrollmentStatus.Enrolled) + .Sum(x => x.Credits) + ]).ToList(); + var bytes = ExcelWorkbookHelper.Create("学生修读汇总", [ + "学号", "姓名", "专业", "行政班", "年级", "修读课程", "在修学分" + ], rows); + return File(bytes, ExcelWorkbookHelper.ContentType, + $"学生修读汇总-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); + } + [HttpGet("rounds")] [Authorize(Roles = SelectionUsers)] public async Task GetRounds( @@ -2282,7 +2337,7 @@ public sealed class CourseSelectionsController( scope.RestrictedCollegeId); } - private static IQueryable ApplyEnrollmentFilters( + private IQueryable ApplyEnrollmentFilters( IQueryable source, CourseEnrollmentQuery query) { @@ -2295,8 +2350,27 @@ public sealed class CourseSelectionsController( if (query.CollegeId.HasValue) source = source.Where(x => x.CourseSelectionOffering!.TeachingTask!.Course!.CollegeId == query.CollegeId.Value); + if (query.MajorId.HasValue) + source = source.Where(x => x.Student!.AdministrativeClass!.MajorId == + query.MajorId.Value); + if (query.AdministrativeClassId.HasValue) + source = source.Where(x => x.Student!.AdministrativeClassId == + query.AdministrativeClassId.Value); + if (query.Grade.HasValue) + source = source.Where(x => x.Student!.AdministrativeClass!.Grade == query.Grade.Value); + if (query.EnrollmentYear.HasValue) + source = source.Where(x => x.Student!.EnrollmentYear == query.EnrollmentYear.Value); if (query.Status.HasValue) source = source.Where(x => x.Status == query.Status.Value); + if (query.EnrollmentType.HasValue) + source = source.Where(x => x.EnrollmentType == query.EnrollmentType.Value); + if (query.ExemptionStatus.HasValue) + source = source.Where(x => db.CourseExemptions + .Where(exemption => exemption.StudentId == x.StudentId && + exemption.TeachingTaskId == x.CourseSelectionOffering!.TeachingTaskId) + .OrderByDescending(exemption => exemption.CreatedAt) + .Select(exemption => (ApprovalStatus?)exemption.Status) + .FirstOrDefault() == query.ExemptionStatus.Value); if (!string.IsNullOrWhiteSpace(query.Keyword)) { var keyword = query.Keyword.Trim(); @@ -2310,9 +2384,17 @@ public sealed class CourseSelectionsController( return source; } + private static IOrderedQueryable OrderEnrollments( + IQueryable source) => source + .OrderByDescending(x => x.CourseSelectionOffering!.CourseSelectionRound! + .AcademicTerm!.StartDate) + .ThenBy(x => x.CourseSelectionOffering!.TeachingTask!.Course!.Code) + .ThenBy(x => x.Student!.StudentNumber); + private IQueryable ProjectEnrollments( IQueryable source) => source.Select(x => new CourseEnrollmentListItem( x.Id, + x.StudentId, x.CourseSelectionOfferingId, x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTerm!.Name, x.CourseSelectionOffering.CourseSelectionRound.AcademicTerm.StartDate, @@ -2320,6 +2402,7 @@ public sealed class CourseSelectionsController( x.CourseSelectionOffering.TeachingTask.Course.Code, x.CourseSelectionOffering.TeachingTask.Course.Name, x.CourseSelectionOffering.TeachingTask.TaskNumber, + x.CourseSelectionOffering.TeachingTask.Course.Credits, x.Student!.StudentNumber, x.Student.Name, x.Student.AdministrativeClass!.Major!.Name, @@ -2409,13 +2492,20 @@ public sealed record CourseEnrollmentQuery( Guid? AcademicTermId, Guid? CourseId, Guid? CollegeId, + Guid? MajorId, + Guid? AdministrativeClassId, + int? Grade, + int? EnrollmentYear, CourseEnrollmentStatus? Status, + EnrollmentType? EnrollmentType, + ApprovalStatus? ExemptionStatus, string? Keyword, int Page = 1, int PageSize = 20); public sealed record CourseEnrollmentListItem( Guid Id, + Guid StudentId, Guid CourseSelectionOfferingId, string TermName, DateOnly TermStartDate, @@ -2423,6 +2513,7 @@ public sealed record CourseEnrollmentListItem( string CourseCode, string CourseName, string TaskNumber, + decimal Credits, string StudentNumber, string StudentName, string MajorName, @@ -2434,6 +2525,25 @@ public sealed record CourseEnrollmentListItem( DateTime? WithdrawnAt, ApprovalStatus? ExemptionStatus); +public sealed record EnrollmentStudentIdentity( + Guid StudentId, + string StudentNumber, + string StudentName, + string MajorName, + string ClassName, + int Grade); + +public sealed record StudentEnrollmentSummary( + Guid StudentId, + string StudentNumber, + string StudentName, + string MajorName, + string ClassName, + int Grade, + IReadOnlyList Courses, + int CourseCount, + decimal ActiveCredits); + public sealed record StudentOfferingDto( Guid Id, Guid TeachingTaskId, diff --git a/versions.props b/versions.props index 8e12cbc..46ac191 100644 --- a/versions.props +++ b/versions.props @@ -1,7 +1,7 @@ - 2.4.1 - 2.4.1 - 2.4.1 + 2.4.2-beta1 + 2.4.2-beta1 + 2.4.2-beta1 diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index ee6e64c..5acda04 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -171,13 +171,6 @@ const navigationGroups = computed(() => [ hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin']), { path: '/course-enrollments', label: '修读课程管理' }, ), - ...whenVisible( - hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']), - { - path: '/course-adjustments', - label: isTeacher.value ? '我的调停课' : '调停课审核', - }, - ), ], }, { @@ -247,7 +240,7 @@ const navigationGroups = computed(() => [ ), ...whenVisible( hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher', 'Student']), - { path: '/approvals', label: '审批中心' }, + { path: '/approvals', label: '审批工作台' }, ), ...whenVisible( hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Counselor', 'Student']), diff --git a/web/src/views/ApprovalCenterView.vue b/web/src/views/ApprovalCenterView.vue index 6a03f7e..450793c 100644 --- a/web/src/views/ApprovalCenterView.vue +++ b/web/src/views/ApprovalCenterView.vue @@ -1,5 +1,6 @@