From 7994ed1ed3316bf54fcca82e991382202b2d85db Mon Sep 17 00:00:00 2001 From: biss Date: Wed, 12 Aug 2026 11:39:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/PersonnelController.cs | 25 ++++ .../Controllers/PersonnelExcelController.cs | 60 ++++++++++ .../Academic/StudentCurrentCourseQuery.cs | 99 ++++++++++++++++ .../StudentCurrentCourseQueryTests.cs | 110 ++++++++++++++++++ .../java/edu/mingxu/jiaowu/MainActivity.java | 23 ++++ web/src/layouts/AdminLayout.vue | 14 ++- web/src/style.css | 32 +++-- web/src/views/PersonnelView.vue | 58 ++++++++- 8 files changed, 408 insertions(+), 13 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Academic/StudentCurrentCourseQuery.cs create mode 100644 tests/Jiaowu.Api.Tests/StudentCurrentCourseQueryTests.cs diff --git a/src/Jiaowu.Api/Controllers/PersonnelController.cs b/src/Jiaowu.Api/Controllers/PersonnelController.cs index e37144b..7b3f153 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelController.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; +using Jiaowu.Api.Infrastructure.Academic; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; @@ -302,6 +303,30 @@ public sealed class PersonnelController( return Ok(new PagedResult(items, total, page, pageSize)); } + [HttpGet("students/current-courses")] + public async Task GetStudentCurrentCourses( + [FromQuery] Guid[] studentIds, + CancellationToken cancellationToken) + { + var requestedIds = studentIds.Distinct().Take(100).ToArray(); + if (requestedIds.Length == 0) return Ok(Array.Empty()); + + var students = await ApplyStudentScope(db.Students.AsNoTracking()) + .WhereIn(requestedIds, x => x.Id) + .Select(x => new CurrentCourseStudent(x.Id, x.AdministrativeClassId)) + .ToListAsync(cancellationToken); + var summaries = await StudentCurrentCourseQuery.GetAsync( + db, + students, + cancellationToken); + return Ok(students.Select(student => new + { + student.Id, + CurrentCourses = summaries[student.Id].Courses, + summaries[student.Id].TotalCredits + })); + } + [HttpPost("students")] [Authorize(Roles = WriteRoles)] public async Task CreateStudent( diff --git a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs index b962b43..026522b 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs @@ -2,6 +2,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; +using Jiaowu.Api.Infrastructure.Academic; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; @@ -49,6 +50,11 @@ public sealed class PersonnelExcelController( "紧急联系电话", "特殊标记", "特殊情况说明", "个人简介", "备注" ]; + private static readonly string[] StudentCurrentCourseHeaders = + [ + "学号", "姓名", "学院", "专业", "行政班", "正在修读课程", "学分", "总学分" + ]; + [HttpGet("{kind}/template")] [Authorize(Roles = WriteRoles)] public IActionResult DownloadTemplate(string kind) @@ -117,6 +123,60 @@ public sealed class PersonnelExcelController( $"{RegistryName(kind)}-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); } + [HttpGet("students/current-courses/export")] + [Authorize(Roles = ExportRoles)] + public async Task ExportStudentCurrentCourses( + [FromQuery] PersonnelQuery query, + [FromQuery] Guid[]? studentIds, + CancellationToken cancellationToken) + { + var source = ApplyStudentFilters( + ApplyStudentScope(db.Students.AsNoTracking()), + query); + var selectedIds = studentIds?.Distinct().ToArray() ?? []; + if (selectedIds.Length > 0) + source = source.WhereIn(selectedIds, x => x.Id); + + var students = await source.OrderBy(x => x.StudentNumber) + .Select(x => new + { + x.Id, + x.StudentNumber, + x.Name, + x.AdministrativeClassId, + ClassName = x.AdministrativeClass!.Name, + MajorName = x.AdministrativeClass.Major!.Name, + CollegeName = x.AdministrativeClass.Major.College!.Name + }) + .ToListAsync(cancellationToken); + var summaries = await StudentCurrentCourseQuery.GetAsync( + db, + students.Select(x => new CurrentCourseStudent(x.Id, x.AdministrativeClassId)) + .ToList(), + cancellationToken); + var rows = students.Select(student => + { + var summary = summaries[student.Id]; + return Row( + student.StudentNumber, + student.Name, + student.CollegeName, + student.MajorName, + student.ClassName, + string.Join(";", summary.Courses.Select(course => + $"{course.CourseCode} {course.CourseName}")), + string.Join(";", summary.Courses.Select(course => + $"{course.Credits:0.##}")), + summary.TotalCredits); + }).ToList(); + var bytes = ExcelWorkbookHelper.Create( + "学生正在修读课程", + StudentCurrentCourseHeaders, + rows); + return File(bytes, ExcelWorkbookHelper.ContentType, + $"学生正在修读课程-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); + } + [HttpPost("{kind}/import")] [Authorize(Roles = WriteRoles)] [RequestSizeLimit(10 * 1024 * 1024)] diff --git a/src/Jiaowu.Api/Infrastructure/Academic/StudentCurrentCourseQuery.cs b/src/Jiaowu.Api/Infrastructure/Academic/StudentCurrentCourseQuery.cs new file mode 100644 index 0000000..f37085c --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Academic/StudentCurrentCourseQuery.cs @@ -0,0 +1,99 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.Academic; + +public sealed record CurrentCourseStudent(Guid Id, Guid AdministrativeClassId); + +public sealed record CurrentCourseItem( + Guid CourseId, + string CourseCode, + string CourseName, + decimal Credits); + +public sealed record StudentCurrentCourseSummary( + IReadOnlyList Courses, + decimal TotalCredits); + +public static class StudentCurrentCourseQuery +{ + public static async Task> + GetAsync( + AppDbContext db, + IReadOnlyList students, + CancellationToken cancellationToken) + { + if (students.Count == 0) + return new Dictionary(); + + var studentIds = students.Select(x => x.Id).Distinct().ToArray(); + var classIds = students.Select(x => x.AdministrativeClassId).Distinct().ToArray(); + var currentTasks = db.TeachingTasks.AsNoTracking() + .Where(x => x.Status == TeachingTaskStatus.Published && x.AcademicTerm!.IsCurrent); + var taskClasses = db.TeachingTaskClasses.AsNoTracking() + .WhereIn(classIds, x => x.AdministrativeClassId); + var enrollments = db.CourseEnrollments.AsNoTracking() + .Where(x => x.Status == CourseEnrollmentStatus.Enrolled) + .WhereIn(studentIds, x => x.StudentId); + + var assigned = await ( + from link in taskClasses + join task in currentTasks on link.TeachingTaskId equals task.Id + join course in db.Courses.AsNoTracking() on task.CourseId equals course.Id + select new CurrentCourseAssignment( + null, + link.AdministrativeClassId, + course.Id, + course.Code, + course.Name, + course.Credits)) + .ToListAsync(cancellationToken); + + var enrolled = await ( + from enrollment in enrollments + join offering in db.CourseSelectionOfferings.AsNoTracking() + on enrollment.CourseSelectionOfferingId equals offering.Id + join task in currentTasks on offering.TeachingTaskId equals task.Id + join course in db.Courses.AsNoTracking() on task.CourseId equals course.Id + select new CurrentCourseAssignment( + enrollment.StudentId, + null, + course.Id, + course.Code, + course.Name, + course.Credits)) + .ToListAsync(cancellationToken); + + var assignedByClass = assigned.GroupBy(x => x.AdministrativeClassId!.Value) + .ToDictionary(x => x.Key, x => x.ToList()); + var enrolledByStudent = enrolled.GroupBy(x => x.StudentId!.Value) + .ToDictionary(x => x.Key, x => x.ToList()); + + return students.ToDictionary( + student => student.Id, + student => + { + var courses = (assignedByClass.GetValueOrDefault(student.AdministrativeClassId) ?? []) + .Concat(enrolledByStudent.GetValueOrDefault(student.Id) ?? []) + .GroupBy(x => x.CourseId) + .Select(x => x.First()) + .OrderBy(x => x.CourseCode) + .Select(x => new CurrentCourseItem( + x.CourseId, + x.CourseCode, + x.CourseName, + x.Credits)) + .ToList(); + return new StudentCurrentCourseSummary(courses, courses.Sum(x => x.Credits)); + }); + } + + private sealed record CurrentCourseAssignment( + Guid? StudentId, + Guid? AdministrativeClassId, + Guid CourseId, + string CourseCode, + string CourseName, + decimal Credits); +} diff --git a/tests/Jiaowu.Api.Tests/StudentCurrentCourseQueryTests.cs b/tests/Jiaowu.Api.Tests/StudentCurrentCourseQueryTests.cs new file mode 100644 index 0000000..05134d1 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/StudentCurrentCourseQueryTests.cs @@ -0,0 +1,110 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class StudentCurrentCourseQueryTests +{ + [Fact] + public async Task GetAsync_CombinesAssignedAndEnrolledCourses_AndDeduplicatesByCourse() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var college = new College { Code = "CS", Name = "计算机学院" }; + var major = new Major + { + Code = "CS01", Name = "软件工程", CollegeId = college.Id, DegreeType = "本科" + }; + var administrativeClass = new AdministrativeClass + { + Code = "SE2026", Name = "软件 2601", MajorId = major.Id, Grade = 2026 + }; + var student = new Student + { + StudentNumber = "20260001", Name = "测试学生", + AdministrativeClassId = administrativeClass.Id, EnrollmentDate = new DateOnly(2026, 9, 1) + }; + var term = new AcademicTerm + { + Code = "2026-1", Name = "2026-2027-1", AcademicYear = "2026-2027", + Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 31), IsCurrent = true + }; + var assignedCourse = new Course + { + Code = "CS101", Name = "程序设计", CollegeId = college.Id, Credits = 3 + }; + var selectedCourse = new Course + { + Code = "CS102", Name = "数据结构", CollegeId = college.Id, Credits = 4 + }; + var assignedTask = new TeachingTask + { + TaskNumber = "T-CS101", Name = "程序设计 2601", AcademicTermId = term.Id, + CourseId = assignedCourse.Id, Capacity = 50, Status = TeachingTaskStatus.Published + }; + var duplicateSelectedTask = new TeachingTask + { + TaskNumber = "T-CS101-E", Name = "程序设计选课", AcademicTermId = term.Id, + CourseId = assignedCourse.Id, Capacity = 50, Status = TeachingTaskStatus.Published + }; + var selectedTask = new TeachingTask + { + TaskNumber = "T-CS102", Name = "数据结构选课", AcademicTermId = term.Id, + CourseId = selectedCourse.Id, Capacity = 50, Status = TeachingTaskStatus.Published + }; + var round = new CourseSelectionRound + { + AcademicTermId = term.Id, Name = "第一轮", StartsAt = DateTime.UtcNow, + EndsAt = DateTime.UtcNow.AddDays(1), WithdrawalEndsAt = DateTime.UtcNow.AddDays(2) + }; + var duplicateOffering = new CourseSelectionOffering + { + CourseSelectionRoundId = round.Id, TeachingTaskId = duplicateSelectedTask.Id, Capacity = 50 + }; + var selectedOffering = new CourseSelectionOffering + { + CourseSelectionRoundId = round.Id, TeachingTaskId = selectedTask.Id, Capacity = 50 + }; + db.AddRange( + college, major, administrativeClass, student, term, assignedCourse, selectedCourse, + assignedTask, duplicateSelectedTask, selectedTask, round, duplicateOffering, selectedOffering, + new TeachingTaskClass + { + TeachingTaskId = assignedTask.Id, + AdministrativeClassId = administrativeClass.Id + }, + new CourseEnrollment + { + CourseSelectionOfferingId = duplicateOffering.Id, + StudentId = student.Id, + Status = CourseEnrollmentStatus.Enrolled + }, + new CourseEnrollment + { + CourseSelectionOfferingId = selectedOffering.Id, + StudentId = student.Id, + Status = CourseEnrollmentStatus.Enrolled + }); + await db.SaveChangesAsync(); + + var summaries = await StudentCurrentCourseQuery.GetAsync( + db, + [new CurrentCourseStudent(student.Id, administrativeClass.Id)], + CancellationToken.None); + + var summary = summaries[student.Id]; + Assert.Equal(2, summary.Courses.Count); + Assert.Equal(7, summary.TotalCredits); + Assert.Equal(["CS101", "CS102"], summary.Courses.Select(x => x.CourseCode)); + } +} diff --git a/web/native/android/app/src/main/java/edu/mingxu/jiaowu/MainActivity.java b/web/native/android/app/src/main/java/edu/mingxu/jiaowu/MainActivity.java index 170a721..512b9b3 100644 --- a/web/native/android/app/src/main/java/edu/mingxu/jiaowu/MainActivity.java +++ b/web/native/android/app/src/main/java/edu/mingxu/jiaowu/MainActivity.java @@ -1,9 +1,16 @@ package edu.mingxu.jiaowu; import android.content.Intent; +import android.graphics.Color; import android.net.Uri; import android.os.Bundle; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; + import com.getcapacitor.BridgeActivity; import com.getcapacitor.JSObject; @@ -23,6 +30,22 @@ public class MainActivity extends BridgeActivity { protected void onCreate(Bundle savedInstanceState) { registerPlugin(NativeHomePlugin.class); super.onCreate(savedInstanceState); + // Keep the WebView below Android system bars. The web shell also honours + // iOS safe-area insets, so content stays clear of every supported notch. + WindowCompat.setDecorFitsSystemWindows(getWindow(), true); + getWindow().setStatusBarColor(Color.rgb(21, 37, 80)); + getWindow().setNavigationBarColor(Color.rgb(21, 37, 80)); + WindowInsetsControllerCompat controller = + new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView()); + controller.setAppearanceLightStatusBars(false); + controller.setAppearanceLightNavigationBars(false); + ViewCompat.setOnApplyWindowInsetsListener(bridge.getWebView(), (view, insets) -> { + Insets systemBars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); + view.setPadding(0, systemBars.top, 0, systemBars.bottom); + return insets; + }); + ViewCompat.requestApplyInsets(bridge.getWebView()); } @Override diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 5707895..0699910 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -348,8 +348,8 @@ onMounted(() => { {{ auth.user?.displayName ?? '系统管理员' }} {{ auth.user?.roles?.[0] ?? '教务人员' }} - 个人账户 - 退出 + + @@ -431,6 +431,16 @@ onMounted(() => { + +
diff --git a/web/src/style.css b/web/src/style.css index 8d90185..9e65cd0 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -16,21 +16,24 @@ --el-color-primary: #233876; --el-color-success: #087f73; --el-border-radius-base: 7px; + --safe-area-top: env(safe-area-inset-top, 0px); + --safe-area-bottom: env(safe-area-inset-bottom, 0px); } * { box-sizing: border-box; } -body { margin: 0; min-width: 320px; min-height: 100vh; } +html { min-height: 100%; background: #f3f5f8; } +body { margin: 0; min-width: 320px; min-height: 100vh; min-height: 100dvh; } button, input, textarea, select { font: inherit; } button { cursor: pointer; } -#app { min-height: 100vh; } +#app { min-height: 100vh; min-height: 100dvh; } -.shell { min-height: 100vh; } +.shell { min-height: 100vh; min-height: 100dvh; } .app-header { position: sticky; top: 0; z-index: 20; box-shadow: 0 5px 18px rgba(19,34,73,.08); } .topbar { - height: 70px; padding: 0 32px; display: flex; align-items: center; gap: 24px; + min-height: calc(70px + var(--safe-area-top)); padding: var(--safe-area-top) 32px 0; display: flex; align-items: center; gap: 24px; color: white; background: linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(112deg, #152550 0%, #1d326c 72%, #176a70 130%); @@ -83,7 +86,7 @@ button { cursor: pointer; } .horizontal-menu .el-sub-menu__icon-arrow { display: none; } .mobile-navigation { position: fixed; inset: 0 auto 0 0; z-index: 30; width: 286px; - display: flex; flex-direction: column; overflow-y: auto; + display: flex; flex-direction: column; overflow-y: auto; padding-top: var(--safe-area-top); color: #eef2ff; background: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px), @@ -96,6 +99,12 @@ button { cursor: pointer; } min-height: 70px; padding: 0 18px 0 20px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(255,255,255,.1); } +.mobile-account-actions { margin: auto 20px calc(18px + var(--safe-area-bottom)); padding-top: 14px; display: grid; gap: 8px; border-top: 1px solid rgba(255,255,255,.12); } +.mobile-account-actions button { min-height: 46px; padding: 10px 13px; display: flex; align-items: center; justify-content: space-between; border: 1px solid rgba(255,255,255,.13); border-radius: 7px; color: #eef2ff; background: rgba(255,255,255,.06); text-align: left; } +.mobile-account-actions button:hover { background: rgba(255,255,255,.12); } +.mobile-account-actions button span { font-size: 13px; font-weight: 650; } +.mobile-account-actions button small { color: #aeb9d8; font-size: 11px; } +.mobile-account-actions .mobile-sign-out { justify-content: center; color: #cbd3ea; background: transparent; } .mobile-navigation-head button { width: 34px; height: 34px; border: 1px solid rgba(255,255,255,.17); border-radius: 6px; color: #cbd3ea; background: rgba(255,255,255,.05); @@ -119,7 +128,7 @@ button { cursor: pointer; } width: 38px; height: 38px; border: 1px solid rgba(255,255,255,.18); border-radius: 7px; color: white; background: rgba(255,255,255,.07); } -.content { padding: 28px 32px 48px; max-width: 1540px; margin: 0 auto; } +.content { padding: 28px 32px calc(48px + var(--safe-area-bottom)); max-width: 1540px; margin: 0 auto; } .mobile-only { display: none; } .section-kicker { color: var(--teal); font: 700 11px/1.2 "Microsoft YaHei", sans-serif; letter-spacing: .1em; } @@ -1289,7 +1298,7 @@ button { cursor: pointer; } .mobile-mask { position: fixed; inset: 0; z-index: 25; background: rgba(13,24,48,.52); } .desktop-only { display: none; } .mobile-only { display: inline-grid; place-items: center; } - .topbar { height: 66px; padding: 0 16px; gap: 13px; } + .topbar { min-height: calc(66px + var(--safe-area-top)); padding: var(--safe-area-top) 16px 0; gap: 13px; } .top-brand { min-width: 0; padding-right: 0; border-right: none; } .top-brand .brand-mark { width: 27px; height: 27px; } .top-brand strong { font-size: 17px; } @@ -1298,7 +1307,8 @@ button { cursor: pointer; } .user-copy { display: none; } .avatar { width: 32px; height: 32px; font-size: 12px; } .topbar .user-block { gap: 4px; } - .content { padding: 18px 14px 35px; } + .desktop-account-action { display: none; } + .content { padding: 18px 14px calc(35px + var(--safe-area-bottom)); } .term-hero { min-height: 220px; padding: 25px; flex-direction: column; align-items: flex-start; justify-content: flex-end; } .term-hero button { margin: 22px 0 0; width: 100%; } .metric-grid { grid-template-columns: 1fr 1fr; gap: 10px; } @@ -1392,7 +1402,7 @@ button { cursor: pointer; } .exam-row-actions { grid-column:2/-1; padding-bottom:10px; text-align:left; } .role-editor-summary { align-items: flex-start; flex-direction: column; gap: 4px; } .form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; } - .el-dialog { width: calc(100vw - 24px) !important; } + .el-dialog { width: calc(100vw - 24px) !important; max-height: calc(100dvh - var(--safe-area-top) - var(--safe-area-bottom) - 24px); overflow-y: auto; } .login-page { display: block; min-height: 100vh; background: #f4f6f9; } .login-story { min-height: 310px; padding: 30px 25px; } .story-copy { margin: auto 0 0; } @@ -1409,6 +1419,10 @@ button { cursor: pointer; } .option-filter-grid.classes { grid-template-columns: 1fr; } .batch-action-bar > div:last-child { width: 100%; } .batch-action-bar > div:last-child .el-button { flex: 1 1 auto; } + .page-actions, .page-intro-actions { gap: 8px; } + .page-actions .el-button, .page-intro-actions .el-button { min-height: 42px; margin-left: 0; } + .pagination-bar { min-height: 0; padding: 12px 14px; align-items: flex-start; flex-direction: column; gap: 8px; } + .pagination-bar .el-pagination { width: 100%; overflow-x: auto; justify-content: flex-start; } .selection-round-strip > button { flex-basis: 240px; } .selection-window { display: block; } .window-seal { min-height: 54px; padding: 10px; display: flex; border-right: none; border-bottom: 1px solid rgba(255,255,255,.13); } diff --git a/web/src/views/PersonnelView.vue b/web/src/views/PersonnelView.vue index da23bd9..6d4b06a 100644 --- a/web/src/views/PersonnelView.vue +++ b/web/src/views/PersonnelView.vue @@ -25,6 +25,7 @@ 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) @@ -54,6 +55,11 @@ const canManage = computed(() => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role), ) ?? false, ) +const canExport = computed(() => + auth.user?.roles.some((role) => + ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role), + ) ?? false, +) const scopedCollegeId = computed(() => auth.user?.roles.some((role) => ['CollegeAdmin', 'Counselor'].includes(role)) ? auth.user.collegeId @@ -179,6 +185,17 @@ 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 { @@ -252,6 +269,28 @@ 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() } @@ -420,6 +459,7 @@ watch(active, async () => { + @@ -445,10 +485,15 @@ watch(active, async () => { @change="handleImport" /> - 导出结果 + 导出结果 +
- + + @@ -470,6 +515,15 @@ watch(active, async () => { + + + + + +