From 45757588120302ddf99d71ca8e7a734ba72de352 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 14 Aug 2026 19:45:19 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=BE=E7=A8=8B=E4=BF=AE=E8=AF=BB=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CourseSelectionsController.cs | 140 ++++++++++++++++ web/src/layouts/AdminLayout.vue | 4 + web/src/router/index.ts | 6 + .../views/CourseEnrollmentManagementView.vue | 150 ++++++++++++++++++ web/src/views/PersonnelView.vue | 47 +----- 5 files changed, 301 insertions(+), 46 deletions(-) create mode 100644 web/src/views/CourseEnrollmentManagementView.vue diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index 15194e5..a149529 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Data; +using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; @@ -37,6 +38,51 @@ public sealed class CourseSelectionsController( OfferingManagers + "," + SystemRoles.Teacher; + [HttpGet("enrollments")] + [Authorize(Roles = OfferingManagers)] + public async Task GetEnrollments( + [FromQuery] CourseEnrollmentQuery query, + CancellationToken cancellationToken) + { + var source = ApplyEnrollmentFilters(ScopedEnrollments(), query); + 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) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + return Ok(new PagedResult(items.Cast().ToList(), total, page, pageSize)); + } + + [HttpGet("enrollments/export.xlsx")] + [Authorize(Roles = OfferingManagers)] + public async Task ExportEnrollments( + [FromQuery] CourseEnrollmentQuery query, + CancellationToken cancellationToken) + { + var items = await ProjectEnrollments(ApplyEnrollmentFilters(ScopedEnrollments(), query)) + .OrderByDescending(x => x.TermStartDate) + .ThenBy(x => x.CourseCode) + .ThenBy(x => x.StudentNumber) + .ToListAsync(cancellationToken); + var rows = items.Select(x => (IReadOnlyList)[ + x.TermName, x.CollegeName, x.CourseCode, x.CourseName, x.TaskNumber, + x.StudentNumber, x.StudentName, x.MajorName, x.ClassName, x.Grade, + x.Status.ToString(), x.EnrollmentType.ToString(), x.EnrolledAt, + x.WithdrawnAt, x.ExemptionStatus?.ToString() ?? "—" + ]).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( @@ -2225,6 +2271,71 @@ public sealed class CourseSelectionsController( : result; } + private IQueryable ScopedEnrollments() + { + var source = db.CourseEnrollments.AsNoTracking(); + var scope = currentUserDataScope.Current; + return scope.Scope == DataScope.All + ? source + : source.Where(x => + x.CourseSelectionOffering!.TeachingTask!.Course!.CollegeId == + scope.RestrictedCollegeId); + } + + private static IQueryable ApplyEnrollmentFilters( + IQueryable source, + CourseEnrollmentQuery query) + { + if (query.AcademicTermId.HasValue) + source = source.Where(x => x.CourseSelectionOffering!.CourseSelectionRound! + .AcademicTermId == query.AcademicTermId.Value); + if (query.CourseId.HasValue) + source = source.Where(x => x.CourseSelectionOffering!.TeachingTask!.CourseId == + query.CourseId.Value); + if (query.CollegeId.HasValue) + source = source.Where(x => x.CourseSelectionOffering!.TeachingTask!.Course!.CollegeId == + query.CollegeId.Value); + if (query.Status.HasValue) + source = source.Where(x => x.Status == query.Status.Value); + if (!string.IsNullOrWhiteSpace(query.Keyword)) + { + var keyword = query.Keyword.Trim(); + source = source.Where(x => + x.Student!.StudentNumber.Contains(keyword) || + x.Student.Name.Contains(keyword) || + x.CourseSelectionOffering!.TeachingTask!.Course!.Code.Contains(keyword) || + x.CourseSelectionOffering.TeachingTask.Course.Name.Contains(keyword) || + x.CourseSelectionOffering.TeachingTask.TaskNumber.Contains(keyword)); + } + return source; + } + + private IQueryable ProjectEnrollments( + IQueryable source) => source.Select(x => new CourseEnrollmentListItem( + x.Id, + x.CourseSelectionOfferingId, + x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTerm!.Name, + x.CourseSelectionOffering.CourseSelectionRound.AcademicTerm.StartDate, + x.CourseSelectionOffering.TeachingTask!.Course!.College!.Name, + x.CourseSelectionOffering.TeachingTask.Course.Code, + x.CourseSelectionOffering.TeachingTask.Course.Name, + x.CourseSelectionOffering.TeachingTask.TaskNumber, + x.Student!.StudentNumber, + x.Student.Name, + x.Student.AdministrativeClass!.Major!.Name, + x.Student.AdministrativeClass.Name, + x.Student.AdministrativeClass.Grade, + x.Status, + x.EnrollmentType, + x.EnrolledAt, + x.WithdrawnAt, + db.CourseExemptions + .Where(exemption => exemption.StudentId == x.StudentId && + exemption.TeachingTaskId == x.CourseSelectionOffering.TeachingTaskId) + .OrderByDescending(exemption => exemption.CreatedAt) + .Select(exemption => (ApprovalStatus?)exemption.Status) + .FirstOrDefault())); + private ActionResult ProfileNotFound() => ConflictProblem("当前账号未关联有效学生档案,请联系教务管理员。"); @@ -2294,6 +2405,35 @@ public sealed record AdminEnrollmentRequest( public sealed record ForceEnrollmentRequest( [MinLength(1)] IReadOnlyCollection StudentIds); +public sealed record CourseEnrollmentQuery( + Guid? AcademicTermId, + Guid? CourseId, + Guid? CollegeId, + CourseEnrollmentStatus? Status, + string? Keyword, + int Page = 1, + int PageSize = 20); + +public sealed record CourseEnrollmentListItem( + Guid Id, + Guid CourseSelectionOfferingId, + string TermName, + DateOnly TermStartDate, + string CollegeName, + string CourseCode, + string CourseName, + string TaskNumber, + string StudentNumber, + string StudentName, + string MajorName, + string ClassName, + int Grade, + CourseEnrollmentStatus Status, + EnrollmentType EnrollmentType, + DateTime? EnrolledAt, + DateTime? WithdrawnAt, + ApprovalStatus? ExemptionStatus); + public sealed record StudentOfferingDto( Guid Id, Guid TeachingTaskId, diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 0699910..ee6e64c 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -167,6 +167,10 @@ const navigationGroups = computed(() => [ label: isTeachingAdmin.value ? '选课管理' : '学生选课', }, ), + ...whenVisible( + hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin']), + { path: '/course-enrollments', label: '修读课程管理' }, + ), ...whenVisible( hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']), { diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 1df6bd9..57eef46 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -251,6 +251,12 @@ const router = createRouter({ roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'], }, }, + { + path: 'course-enrollments', + name: 'course-enrollments', + component: () => import('../views/CourseEnrollmentManagementView.vue'), + meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] }, + }, { path: 'grades', name: 'grades', diff --git a/web/src/views/CourseEnrollmentManagementView.vue b/web/src/views/CourseEnrollmentManagementView.vue new file mode 100644 index 0000000..7fb2b8b --- /dev/null +++ b/web/src/views/CourseEnrollmentManagementView.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/web/src/views/PersonnelView.vue b/web/src/views/PersonnelView.vue index d1b3991..b65a521 100644 --- a/web/src/views/PersonnelView.vue +++ b/web/src/views/PersonnelView.vue @@ -25,7 +25,6 @@ const importing = ref(false) const fileInput = ref() const rows = ref([]) const total = ref(0) -const selectedRows = ref([]) const dialogVisible = ref(false) const activationDialog = ref(false) const activationLoading = ref(false) @@ -185,17 +184,6 @@ async function load() { const { data } = await http.get(`/personnel/${active.value}`, { params }) rows.value = data.items total.value = data.total - selectedRows.value = [] - if (active.value === 'students' && rows.value.length) { - const ids = rows.value.map((row) => row.id) - const { data: summaries } = await http.get('/personnel/students/current-courses', { - params: { studentIds: ids }, - }) - const byId = new Map(summaries.map((item: any) => [item.id, item])) - rows.value = rows.value.map((row) => ({ ...row, ...(byId.get(row.id) ?? { - currentCourses: [], totalCredits: 0, - }) })) - } } catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { @@ -269,28 +257,6 @@ async function exportRows() { } } -async function exportStudentCurrentCourses(selectedOnly = false) { - if (selectedOnly && selectedRows.value.length === 0) { - ElMessage.warning('请先勾选需要导出的学生。') - return - } - try { - await downloadApiFile( - '/personnel/students/current-courses/export', - '学生正在修读课程.xlsx', - { params: { ...exportParams(), studentIds: selectedOnly - ? selectedRows.value.map((row) => row.id) : undefined } }, - ) - ElMessage.success(selectedOnly ? `已导出所选 ${selectedRows.value.length} 名学生` : '已导出筛选学生的修读课程') - } catch (error) { - ElMessage.error(apiErrorMessage(error)) - } -} - -function onSelectionChange(selection: any[]) { - selectedRows.value = selection -} - function chooseImportFile() { fileInput.value?.click() } @@ -491,14 +457,9 @@ watch(active, async () => { /> 导出结果 - - - + @@ -520,12 +481,6 @@ watch(active, async () => { - - -