From 20ca20508032644d09dc9b18306c8ffcb54884d9 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 24 Jul 2026 20:37:42 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=89=E8=AF=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 + .../Controllers/CourseSelectionsController.cs | 278 +++++++- .../CourseSelection/CourseSelectionRules.cs | 3 + .../Persistence/DatabaseInitializer.cs | 2 + .../Persistence/DevelopmentDemoDataSeeder.cs | 620 ++++++++++++++++++ src/Jiaowu.Api/Program.cs | 6 + .../CourseSelectionRulesTests.cs | 13 + web/src/style.css | 15 + web/src/views/CourseSelectionView.vue | 177 ++++- 9 files changed, 1115 insertions(+), 7 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentDemoDataSeeder.cs diff --git a/README.md b/README.md index a5fd121..1ff016b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,14 @@ 本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。 +Development 环境还会按业务编码幂等补齐一套大规模测试数据:16 个常见学院或教学单位、52 个常见本科专业、每个专业 2 个 2026 级行政班、每班 35 名学生、每学院 8 名教师,以及覆盖公共必修、公共选修、专业必修、专业选修和实践教学的 150 余门课程。测试教师同时生成当前学期已审核通过的授课资格;重复启动不会重复累加数据,也不会覆盖已有记录。 + +只执行数据库迁移和测试数据补齐、不启动 Web 服务时,可以运行: + +```powershell +dotnet run --project src/Jiaowu.Api -- --seed-only +``` + ```powershell dotnet run --project src/Jiaowu.Api ``` diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index 6f9d337..a2ce8a7 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -179,6 +179,7 @@ public sealed class CourseSelectionsController( TaskName = x.TeachingTask.Name, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, + CourseNature = x.TeachingTask.Course.Nature, CollegeName = x.TeachingTask.Course.College!.Name, x.TeachingTask.Course.Credits, TeacherNames = x.TeachingTask.Teachers @@ -279,10 +280,14 @@ public sealed class CourseSelectionsController( x.TeachingTaskId, x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, + CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course!.Name, + CourseNature = x.TeachingTask.Course.Nature, CollegeId = x.TeachingTask.Course.CollegeId, TeacherUserIds = x.TeachingTask.Teachers .Select(item => item.Teacher!.UserId), + x.CourseSelectionRoundId, + RoundStatus = x.CourseSelectionRound!.Status, x.Capacity }) .FirstOrDefaultAsync(cancellationToken); @@ -316,13 +321,281 @@ public sealed class CourseSelectionsController( offering.TeachingTaskId, offering.TaskNumber, offering.TaskName, + offering.CourseCode, offering.CourseName, + offering.CourseNature, offering.Capacity, EnrolledCount = students.Count, - Students = students + Students = students, + CanProxyEnroll = + CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature) && + offering.RoundStatus != CourseSelectionRoundStatus.Draft && + (currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || + currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin)) }); } + [HttpGet("offerings/{id:guid}/eligible-students")] + [Authorize(Roles = RoundManagers)] + public async Task GetEligibleStudents( + Guid id, + string? keyword = null, + int page = 1, + int pageSize = 20, + CancellationToken cancellationToken = default) + { + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 10, 100); + var offering = await db.CourseSelectionOfferings.AsNoTracking() + .Where(x => x.Id == id) + .Select(x => new + { + x.Id, + x.IsOpenToAll, + CourseNature = x.TeachingTask!.Course!.Nature, + RoundStatus = x.CourseSelectionRound!.Status, + ClassIds = x.TeachingTask.Classes + .Select(item => item.AdministrativeClassId) + }) + .FirstOrDefaultAsync(cancellationToken); + if (offering is null) return NotFound(); + if (!CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature)) + return ConflictProblem("管理员代选仅适用于公共必修课。"); + if (offering.RoundStatus == CourseSelectionRoundStatus.Draft) + return ConflictProblem("选课批次开放后才能办理管理员代选。"); + + var source = db.Students.AsNoTracking() + .Where(x => + x.Status == StudentStatus.Active && + (offering.IsOpenToAll || + offering.ClassIds.Contains(x.AdministrativeClassId)) && + !db.CourseEnrollments.Any(enrollment => + enrollment.CourseSelectionOfferingId == id && + enrollment.StudentId == x.Id && + enrollment.Status == CourseEnrollmentStatus.Enrolled)); + if (!string.IsNullOrWhiteSpace(keyword)) + { + keyword = keyword.Trim(); + source = source.Where(x => + x.StudentNumber.Contains(keyword) || + x.Name.Contains(keyword) || + x.AdministrativeClass!.Name.Contains(keyword)); + } + + var total = await source.CountAsync(cancellationToken); + var items = await source + .OrderBy(x => x.StudentNumber) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => new + { + x.Id, + x.StudentNumber, + x.Name, + ClassName = x.AdministrativeClass!.Name, + MajorName = x.AdministrativeClass.Major!.Name, + CollegeName = x.AdministrativeClass.Major.College!.Name + }) + .ToListAsync(cancellationToken); + return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize }); + } + + [HttpPost("offerings/{id:guid}/admin-enrollments")] + [Authorize(Roles = RoundManagers)] + public async Task AdminEnroll( + Guid id, + AdminEnrollmentRequest request, + CancellationToken cancellationToken) + { + var studentIds = request.StudentIds.Distinct().ToArray(); + if (studentIds.Length == 0) + return ValidationProblem("请至少选择一名学生。"); + if (studentIds.Length > 100) + return ValidationProblem("单次最多可为 100 名学生代选。"); + + await using var transaction = await db.Database.BeginTransactionAsync( + IsolationLevel.Serializable, + cancellationToken); + var offering = await db.CourseSelectionOfferings + .Include(x => x.CourseSelectionRound) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Course) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Classes) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (offering is null) return NotFound(); + var round = offering.CourseSelectionRound!; + var task = offering.TeachingTask!; + if (!CourseSelectionRules.SupportsProxyEnrollment(task.Course!.Nature)) + return ConflictProblem("管理员代选仅适用于公共必修课。"); + if (round.Status == CourseSelectionRoundStatus.Draft) + return ConflictProblem("选课批次开放后才能办理管理员代选。"); + if (task.Status != TeachingTaskStatus.Published) + return ConflictProblem("该教学班当前不可办理代选。"); + + var students = await db.Students + .Include(x => x.AdministrativeClass) + .Where(x => studentIds.Contains(x.Id)) + .OrderBy(x => x.StudentNumber) + .ToListAsync(cancellationToken); + if (students.Count != studentIds.Length) + return ValidationProblem("存在无效的学生档案。"); + var inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active); + if (inactive is not null) + return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。"); + var outOfScope = students.FirstOrDefault(student => + !offering.IsOpenToAll && + !task.Classes.Any(item => + item.AdministrativeClassId == student.AdministrativeClassId)); + if (outOfScope is not null) + { + return ConflictProblem( + $"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。"); + } + + var existingEnrollments = await db.CourseEnrollments + .Where(x => + x.CourseSelectionOfferingId == id && + studentIds.Contains(x.StudentId)) + .ToListAsync(cancellationToken); + var alreadyEnrolled = existingEnrollments.FirstOrDefault(x => + x.Status == CourseEnrollmentStatus.Enrolled); + if (alreadyEnrolled is not null) + { + var student = students.First(x => x.Id == alreadyEnrolled.StudentId); + return ConflictProblem( + $"学生 {student.StudentNumber} {student.Name} 已在该教学班名单中。"); + } + + var enrolledCount = await db.CourseEnrollments.CountAsync( + x => + x.CourseSelectionOfferingId == id && + x.Status == CourseEnrollmentStatus.Enrolled, + cancellationToken); + if (enrolledCount + students.Count > offering.Capacity) + { + return ConflictProblem( + $"教学班仅剩 {Math.Max(0, offering.Capacity - enrolledCount)} 个名额,无法完成本次代选。"); + } + + var duplicateStudentIds = await db.CourseEnrollments.AsNoTracking() + .Where(x => + studentIds.Contains(x.StudentId) && + x.Status == CourseEnrollmentStatus.Enrolled && + x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && + x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId == + round.AcademicTermId) + .Select(x => x.StudentId) + .Distinct() + .ToListAsync(cancellationToken); + if (duplicateStudentIds.Count > 0) + { + var student = students.First(x => duplicateStudentIds.Contains(x.Id)); + return ConflictProblem( + $"学生 {student.StudentNumber} {student.Name} 本学期已选择相同课程。"); + } + + var candidateEntries = await PublishedScheduleEntries( + round.AcademicTermId, + [task.Id], + cancellationToken); + if (candidateEntries.Count == 0) + return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。"); + + foreach (var student in students) + { + var selectedCredits = await db.CourseEnrollments + .Where(x => + x.StudentId == student.Id && + x.Status == CourseEnrollmentStatus.Enrolled && + x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id) + .SumAsync( + x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits, + cancellationToken) ?? 0; + if (selectedCredits + task.Course.Credits > round.MaxCredits) + { + return ConflictProblem( + $"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。"); + } + + var selectedTaskIds = await db.CourseEnrollments.AsNoTracking() + .Where(x => + x.StudentId == student.Id && + x.Status == CourseEnrollmentStatus.Enrolled && + x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == + round.AcademicTermId) + .Select(x => x.CourseSelectionOffering!.TeachingTaskId) + .Distinct() + .ToArrayAsync(cancellationToken); + var selectedEntries = await PublishedScheduleEntries( + round.AcademicTermId, + selectedTaskIds, + cancellationToken); + if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries)) + { + return ConflictProblem( + $"学生 {student.StudentNumber} {student.Name} 的已选课程与该教学班时间冲突。"); + } + } + + var now = DateTime.UtcNow; + foreach (var student in students) + { + var enrollment = existingEnrollments.FirstOrDefault(x => + x.StudentId == student.Id); + if (enrollment is null) + { + db.CourseEnrollments.Add(new CourseEnrollment + { + CourseSelectionOfferingId = id, + StudentId = student.Id, + EnrolledAt = now + }); + } + else + { + enrollment.Status = CourseEnrollmentStatus.Enrolled; + enrollment.EnrolledAt = now; + enrollment.WithdrawnAt = null; + } + } + + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(new { EnrolledCount = students.Count }); + } + + [HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")] + [Authorize(Roles = RoundManagers)] + public async Task AdminWithdraw( + Guid offeringId, + Guid enrollmentId, + CancellationToken cancellationToken) + { + var enrollment = await db.CourseEnrollments + .Include(x => x.CourseSelectionOffering) + .ThenInclude(x => x!.TeachingTask) + .ThenInclude(x => x!.Course) + .FirstOrDefaultAsync( + x => + x.Id == enrollmentId && + x.CourseSelectionOfferingId == offeringId, + cancellationToken); + if (enrollment is null) return NotFound(); + var offering = enrollment.CourseSelectionOffering!; + if (!CourseSelectionRules.SupportsProxyEnrollment( + offering.TeachingTask!.Course!.Nature)) + return ConflictProblem("管理员名单调整仅适用于公共必修课。"); + if (offering.TeachingTask.Status != TeachingTaskStatus.Published) + return ConflictProblem("该教学班当前不可调整名单。"); + if (enrollment.Status != CourseEnrollmentStatus.Enrolled) + return ConflictProblem("该学生已不在教学班名单中。"); + + enrollment.Status = CourseEnrollmentStatus.Withdrawn; + enrollment.WithdrawnAt = DateTime.UtcNow; + return await SaveAsync(enrollmentId, false, cancellationToken); + } + [HttpGet("student/options")] [Authorize(Roles = SystemRoles.Student)] public async Task GetStudentOptions( @@ -722,6 +995,9 @@ public sealed record CourseSelectionOfferingRequest( public sealed record StudentEnrollmentRequest(Guid OfferingId); +public sealed record AdminEnrollmentRequest( + [MinLength(1)] IReadOnlyCollection StudentIds); + public sealed record StudentOfferingDto( Guid Id, Guid TeachingTaskId, diff --git a/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs b/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs index ba05deb..10064eb 100644 --- a/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs +++ b/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs @@ -14,6 +14,9 @@ public static class CourseSelectionRules round.Status == CourseSelectionRoundStatus.Open && nowUtc <= round.WithdrawalEndsAt; + public static bool SupportsProxyEnrollment(CourseNature nature) => + nature == CourseNature.GeneralRequired; + public static bool HasScheduleConflict( IEnumerable candidateEntries, IEnumerable selectedEntries) => diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs index b55cda5..47d64e2 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs @@ -10,6 +10,7 @@ public sealed class DatabaseInitializer( RoleManager roleManager, UserManager userManager, DevelopmentSqliteMigrator sqliteMigrator, + DevelopmentDemoDataSeeder developmentDemoDataSeeder, IConfiguration configuration, IHostEnvironment environment, ILogger logger) @@ -479,6 +480,7 @@ public sealed class DatabaseInitializer( await SeedDevelopmentCourseSelectionAsync(); await SeedDevelopmentGradesAsync(); await SeedDevelopmentExamsAsync(); + await developmentDemoDataSeeder.SeedAsync(); } private async Task SeedDevelopmentCourseSelectionAsync() diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentDemoDataSeeder.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentDemoDataSeeder.cs new file mode 100644 index 0000000..27bb948 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentDemoDataSeeder.cs @@ -0,0 +1,620 @@ +using Jiaowu.Api.Domain.Academic; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.Persistence; + +public sealed class DevelopmentDemoDataSeeder( + AppDbContext db, + ILogger logger) +{ + private const int Grade = 2026; + private const int ClassesPerMajor = 2; + private const int StudentsPerClass = 35; + private const int TeachersPerCollege = 8; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + var campus = await db.Campuses + .OrderBy(x => x.Code == "MAIN" ? 0 : 1) + .ThenBy(x => x.Code) + .FirstOrDefaultAsync(cancellationToken); + if (campus is null) + { + campus = new Campus + { + Code = "MAIN", + Name = "主校区", + Address = "大学路 1 号" + }; + db.Campuses.Add(campus); + await db.SaveChangesAsync(cancellationToken); + } + + await SeedCollegesAsync(campus.Id, cancellationToken); + await SeedMajorsAsync(cancellationToken); + await SeedClassesAsync(cancellationToken); + await SeedTeachersAsync(cancellationToken); + await SeedStudentsAsync(cancellationToken); + await SeedCoursesAsync(cancellationToken); + await SeedTeacherCourseApplicationsAsync(cancellationToken); + await LogSummaryAsync(cancellationToken); + } + + private async Task SeedCollegesAsync(Guid campusId, CancellationToken cancellationToken) + { + var existingCodes = (await db.Colleges + .Select(x => x.Code) + .ToListAsync(cancellationToken)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var additions = CollegeDefinitions + .Where(x => !existingCodes.Contains(x.Code)) + .Select((x, index) => new College + { + Code = x.Code, + Name = x.Name, + ShortName = x.ShortName, + CampusId = campusId, + SortOrder = (index + 1) * 10 + }) + .ToList(); + if (additions.Count == 0) return; + db.Colleges.AddRange(additions); + await db.SaveChangesAsync(cancellationToken); + } + + private async Task SeedMajorsAsync(CancellationToken cancellationToken) + { + var colleges = await db.Colleges + .ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken); + var existingCodes = (await db.Majors + .Select(x => x.Code) + .ToListAsync(cancellationToken)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var additions = new List(); + foreach (var collegeDefinition in CollegeDefinitions) + { + if (!colleges.TryGetValue(collegeDefinition.Code, out var college)) continue; + for (var index = 0; index < collegeDefinition.Majors.Count; index++) + { + var definition = collegeDefinition.Majors[index]; + if (existingCodes.Contains(definition.Code)) continue; + additions.Add(new Major + { + Code = definition.Code, + Name = definition.Name, + CollegeId = college.Id, + DegreeType = definition.DegreeType, + SchoolingYears = definition.SchoolingYears, + SortOrder = (index + 1) * 10 + }); + } + } + if (additions.Count == 0) return; + db.Majors.AddRange(additions); + await db.SaveChangesAsync(cancellationToken); + } + + private async Task SeedClassesAsync(CancellationToken cancellationToken) + { + var targetMajorCodes = CollegeDefinitions + .SelectMany(x => x.Majors) + .Select(x => x.Code) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var majors = await db.Majors + .Where(x => targetMajorCodes.Contains(x.Code)) + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken); + var existingClasses = await db.AdministrativeClasses + .Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code)) + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken); + var existingCodes = (await db.AdministrativeClasses + .Select(x => x.Code) + .ToListAsync(cancellationToken)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var additions = new List(); + foreach (var major in majors) + { + var currentCount = existingClasses.Count(x => x.MajorId == major.Id); + for (var section = currentCount + 1; section <= ClassesPerMajor; section++) + { + var code = CreateAvailableCode( + $"{major.Code}-{Grade}-{section:D2}", + existingCodes); + additions.Add(new AdministrativeClass + { + Code = code, + Name = $"{major.Name}{Grade}级{section}班", + MajorId = major.Id, + Grade = Grade, + CounselorName = $"{CounselorSurnames[(section + major.Code.Length) % CounselorSurnames.Length]}老师", + SortOrder = section * 10 + }); + } + } + if (additions.Count == 0) return; + db.AdministrativeClasses.AddRange(additions); + await db.SaveChangesAsync(cancellationToken); + } + + private async Task SeedTeachersAsync(CancellationToken cancellationToken) + { + var collegeCodes = CollegeDefinitions + .Select(x => x.Code) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var colleges = await db.Colleges + .Where(x => collegeCodes.Contains(x.Code)) + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken); + var existingTeachers = await db.Teachers + .Where(x => colleges.Select(c => c.Id).Contains(x.CollegeId)) + .ToListAsync(cancellationToken); + var existingNumbers = (await db.Teachers + .Select(x => x.TeacherNumber) + .ToListAsync(cancellationToken)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var additions = new List(); + for (var collegeIndex = 0; collegeIndex < colleges.Count; collegeIndex++) + { + var college = colleges[collegeIndex]; + var currentCount = existingTeachers.Count(x => x.CollegeId == college.Id); + for (var position = currentCount + 1; position <= TeachersPerCollege; position++) + { + var teacherNumber = CreateAvailableCode( + $"T26{collegeIndex + 1:D2}{position:D3}", + existingNumbers); + additions.Add(new Teacher + { + TeacherNumber = teacherNumber, + Name = BuildPersonName(collegeIndex, position), + Gender = position % 2 == 0 ? Gender.Female : Gender.Male, + CollegeId = college.Id, + Title = TeacherTitles[(position - 1) % TeacherTitles.Length], + Status = TeacherStatus.Active, + HireDate = new DateOnly(2012 + position, 7, 1), + Email = $"{teacherNumber.ToLowerInvariant()}@example.edu.cn", + Notes = "Development 环境批量测试教师。" + }); + } + } + if (additions.Count == 0) return; + db.Teachers.AddRange(additions); + await db.SaveChangesAsync(cancellationToken); + } + + private async Task SeedStudentsAsync(CancellationToken cancellationToken) + { + var targetMajorCodes = CollegeDefinitions + .SelectMany(x => x.Majors) + .Select(x => x.Code) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var classes = await db.AdministrativeClasses + .Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code)) + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken); + var classIds = classes.Select(x => x.Id).ToHashSet(); + var existingStudents = await db.Students + .Where(x => classIds.Contains(x.AdministrativeClassId)) + .ToListAsync(cancellationToken); + var existingNumbers = (await db.Students + .Select(x => x.StudentNumber) + .ToListAsync(cancellationToken)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var additions = new List(); + for (var classIndex = 0; classIndex < classes.Count; classIndex++) + { + var administrativeClass = classes[classIndex]; + var existingCount = existingStudents.Count(x => + x.AdministrativeClassId == administrativeClass.Id); + var candidate = 1; + while (existingCount + additions.Count(x => + x.AdministrativeClassId == administrativeClass.Id) < StudentsPerClass) + { + var studentNumber = $"{Grade}{classIndex + 1:D3}{candidate:D3}"; + candidate++; + if (!existingNumbers.Add(studentNumber)) continue; + additions.Add(new Student + { + StudentNumber = studentNumber, + Name = BuildPersonName(classIndex, candidate + 5), + Gender = candidate % 2 == 0 ? Gender.Male : Gender.Female, + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = Grade, + EnrollmentDate = new DateOnly(Grade, 9, 7), + DateOfBirth = new DateOnly(2007 + candidate % 2, candidate % 12 + 1, + candidate % 27 + 1), + Status = StudentStatus.Active, + Email = $"{studentNumber}@student.example.edu.cn", + Notes = "Development 环境批量测试学生。" + }); + } + } + if (additions.Count == 0) return; + db.Students.AddRange(additions); + await db.SaveChangesAsync(cancellationToken); + } + + private async Task SeedCoursesAsync(CancellationToken cancellationToken) + { + var colleges = await db.Colleges + .ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken); + var categories = await db.CourseCategories + .ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken); + var existingCodes = (await db.Courses + .Select(x => x.Code) + .ToListAsync(cancellationToken)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var additions = new List(); + + foreach (var definition in PublicCourseDefinitions) + { + if (existingCodes.Contains(definition.Code) || + !colleges.TryGetValue(definition.CollegeCode, out var college) || + !categories.TryGetValue(definition.CategoryCode, out var category)) + continue; + additions.Add(new Course + { + Code = definition.Code, + Name = definition.Name, + CollegeId = college.Id, + CourseCategoryId = category.Id, + Credits = definition.Credits, + TotalHours = definition.TotalHours, + LectureHours = definition.LectureHours, + PracticeHours = definition.TotalHours - definition.LectureHours, + Nature = definition.Nature, + AssessmentMethod = definition.AssessmentMethod, + Description = "Development 环境公共课程测试数据。" + }); + } + + foreach (var collegeDefinition in CollegeDefinitions) + { + if (!colleges.TryGetValue(collegeDefinition.Code, out var college)) continue; + for (var index = 0; index < collegeDefinition.ProfessionalCourses.Count; index++) + { + var code = $"{collegeDefinition.Code}D{index + 1:D2}"; + if (existingCodes.Contains(code)) continue; + var nature = index switch + { + < 4 => CourseNature.MajorRequired, + < 6 => CourseNature.MajorElective, + _ => CourseNature.Practice + }; + var categoryCode = nature == CourseNature.Practice ? "PRACTICE" : "MAJOR"; + additions.Add(new Course + { + Code = code, + Name = collegeDefinition.ProfessionalCourses[index], + CollegeId = college.Id, + CourseCategoryId = categories[categoryCode].Id, + Credits = nature switch + { + CourseNature.MajorRequired => index % 2 == 0 ? 3m : 3.5m, + _ => 2m + }, + TotalHours = nature switch + { + CourseNature.MajorRequired => index % 2 == 0 ? 48 : 56, + CourseNature.MajorElective => 32, + _ => 48 + }, + LectureHours = nature == CourseNature.Practice ? 8 : + nature == CourseNature.MajorElective ? 24 : + index % 2 == 0 ? 40 : 48, + PracticeHours = nature == CourseNature.Practice ? 40 : + nature == CourseNature.MajorElective ? 8 : 8, + Nature = nature, + AssessmentMethod = nature == CourseNature.Practice + ? AssessmentMethod.Assessment + : AssessmentMethod.Examination, + Description = $"由{collegeDefinition.Name}开设的专业课程测试数据。" + }); + } + } + if (additions.Count == 0) return; + db.Courses.AddRange(additions); + await db.SaveChangesAsync(cancellationToken); + } + + private async Task SeedTeacherCourseApplicationsAsync(CancellationToken cancellationToken) + { + var term = await db.AcademicTerms + .OrderByDescending(x => x.IsCurrent) + .ThenByDescending(x => x.StartDate) + .FirstOrDefaultAsync(cancellationToken); + if (term is null) return; + + var collegeCodes = CollegeDefinitions + .Select(x => x.Code) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var colleges = await db.Colleges + .Where(x => collegeCodes.Contains(x.Code)) + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken); + var collegeIds = colleges.Select(x => x.Id).ToHashSet(); + var teachers = await db.Teachers + .Where(x => collegeIds.Contains(x.CollegeId) && x.Status == TeacherStatus.Active) + .OrderBy(x => x.TeacherNumber) + .ToListAsync(cancellationToken); + var courses = await db.Courses + .Where(x => collegeIds.Contains(x.CollegeId)) + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken); + var publicCourses = courses + .Where(x => x.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective) + .ToList(); + if (publicCourses.Count == 0) return; + + var existing = (await db.TeacherCourseApplications + .Where(x => x.AcademicTermId == term.Id) + .Select(x => new { x.TeacherId, x.CourseId }) + .ToListAsync(cancellationToken)) + .Select(x => (x.TeacherId, x.CourseId)) + .ToHashSet(); + var additions = new List(); + for (var teacherIndex = 0; teacherIndex < teachers.Count; teacherIndex++) + { + var teacher = teachers[teacherIndex]; + var professionalCourses = courses + .Where(x => x.CollegeId == teacher.CollegeId && + x.Nature is CourseNature.MajorRequired or + CourseNature.MajorElective or CourseNature.Practice) + .Take(4); + var selectedPublicCourses = Enumerable.Range(0, 2) + .Select(offset => publicCourses[(teacherIndex + offset) % publicCourses.Count]); + foreach (var course in professionalCourses + .Concat(selectedPublicCourses) + .DistinctBy(x => x.Id)) + { + if (!existing.Add((teacher.Id, course.Id))) continue; + additions.Add(new TeacherCourseApplication + { + AcademicTermId = term.Id, + TeacherId = teacher.Id, + CourseId = course.Id, + Status = TeacherCourseApplicationStatus.Approved, + Statement = "Development 环境批量生成的授课意向。", + ReviewComment = "测试数据自动审核通过。", + SubmittedAt = DateTime.UtcNow.AddDays(-7), + ReviewedAt = DateTime.UtcNow.AddDays(-6) + }); + } + } + if (additions.Count == 0) return; + db.TeacherCourseApplications.AddRange(additions); + await db.SaveChangesAsync(cancellationToken); + } + + private async Task LogSummaryAsync(CancellationToken cancellationToken) + { + var courseCounts = await db.Courses + .GroupBy(x => x.Nature) + .Select(x => new { Nature = x.Key, Count = x.Count() }) + .ToDictionaryAsync(x => x.Nature, x => x.Count, cancellationToken); + logger.LogInformation( + "Development 测试数据已就绪:学院/教学单位 {CollegeCount},专业 {MajorCount}," + + "行政班 {ClassCount},教师 {TeacherCount},学生 {StudentCount},课程 {CourseCount};" + + "公共必修 {GeneralRequiredCount},公共选修 {GeneralElectiveCount}," + + "专业必修 {MajorRequiredCount},专业选修 {MajorElectiveCount},实践课程 {PracticeCount};" + + "已审核授课资格 {ApplicationCount}。", + await db.Colleges.CountAsync(cancellationToken), + await db.Majors.CountAsync(cancellationToken), + await db.AdministrativeClasses.CountAsync(cancellationToken), + await db.Teachers.CountAsync(cancellationToken), + await db.Students.CountAsync(cancellationToken), + await db.Courses.CountAsync(cancellationToken), + courseCounts.GetValueOrDefault(CourseNature.GeneralRequired), + courseCounts.GetValueOrDefault(CourseNature.GeneralElective), + courseCounts.GetValueOrDefault(CourseNature.MajorRequired), + courseCounts.GetValueOrDefault(CourseNature.MajorElective), + courseCounts.GetValueOrDefault(CourseNature.Practice), + await db.TeacherCourseApplications + .CountAsync(x => x.Status == TeacherCourseApplicationStatus.Approved, + cancellationToken)); + } + + private static string CreateAvailableCode(string preferredCode, ISet existingCodes) + { + var candidate = preferredCode; + var suffix = 1; + while (!existingCodes.Add(candidate)) + { + candidate = $"{preferredCode}-{suffix++}"; + } + return candidate; + } + + private static string BuildPersonName(int groupIndex, int position) + { + var surname = Surnames[(groupIndex + position) % Surnames.Length]; + var givenName = GivenNames[ + (groupIndex * 7 + position * 3) % GivenNames.Length]; + return surname + givenName; + } + + private static readonly string[] Surnames = + ["王", "李", "张", "刘", "陈", "杨", "黄", "赵", "吴", "周", "徐", "孙", "马", "朱", "胡", "郭", "何", "高", "林", "罗"]; + + private static readonly string[] GivenNames = + ["明远", "知夏", "嘉树", "雨桐", "思源", "若溪", "景行", "书雅", "子涵", "浩然", "清越", "语晨", "承宇", "欣怡", "博文", "婉宁", "俊逸", "安然", "泽楷", "可心"]; + + private static readonly string[] CounselorSurnames = + ["陈", "林", "周", "王", "李", "张", "刘", "赵"]; + + private static readonly string[] TeacherTitles = + ["教授", "副教授", "讲师", "讲师", "副教授", "实验师", "讲师", "教授"]; + + private static readonly CollegeSeed[] CollegeDefinitions = + [ + new("CS", "计算机学院", "计算机学院", + [ + new("080901", "计算机科学与技术", "工学学士"), + new("080902", "软件工程", "工学学士"), + new("080903", "网络工程", "工学学士"), + new("080910T", "数据科学与大数据技术", "工学学士"), + new("080717T", "人工智能", "工学学士") + ], + ["程序设计基础", "离散数学", "数据结构", "计算机组成原理", "操作系统", "数据库系统原理", "软件工程课程设计", "人工智能项目实践"]), + new("EIA", "电子信息与自动化学院", "电子信息学院", + [ + new("080701", "电子信息工程", "工学学士"), + new("080703", "通信工程", "工学学士"), + new("080801", "自动化", "工学学士"), + new("080803T", "机器人工程", "工学学士") + ], + ["电路分析", "模拟电子技术", "数字电子技术", "信号与系统", "通信原理", "嵌入式系统", "电子系统设计", "综合电子实训"]), + new("ME", "机械与车辆工程学院", "机械学院", + [ + new("080202", "机械设计制造及其自动化", "工学学士"), + new("080207", "车辆工程", "工学学士"), + new("080205", "工业设计", "工学学士"), + new("080213T", "智能制造工程", "工学学士") + ], + ["工程制图", "理论力学", "材料力学", "机械原理", "机械设计", "智能制造技术", "机械创新设计", "工程训练"]), + new("EE", "电气工程学院", "电气学院", + [ + new("080601", "电气工程及其自动化", "工学学士"), + new("080604T", "电气工程与智能控制", "工学学士"), + new("080605T", "电机电器智能化", "工学学士") + ], + ["电路原理", "电机学", "电力电子技术", "自动控制原理", "电力系统分析", "继电保护", "电气控制实训", "电力系统综合设计"]), + new("CIVIL", "土木建筑工程学院", "土建学院", + [ + new("081001", "土木工程", "工学学士"), + new("082801", "建筑学", "建筑学学士", 5), + new("120103", "工程管理", "管理学学士"), + new("081006T", "道路桥梁与渡河工程", "工学学士") + ], + ["工程制图与识图", "工程力学", "结构力学", "混凝土结构", "土力学与地基基础", "工程项目管理", "建筑设计基础", "工程测量实习"]), + new("ECON", "经济与管理学院", "经管学院", + [ + new("120201K", "工商管理", "管理学学士"), + new("120203K", "会计学", "管理学学士"), + new("020301K", "金融学", "经济学学士"), + new("020401", "国际经济与贸易", "经济学学士"), + new("120202", "市场营销", "管理学学士") + ], + ["微观经济学", "宏观经济学", "管理学原理", "会计学原理", "统计学", "财务管理", "企业经营沙盘", "商务数据分析实践"]), + new("FOREIGN", "外国语学院", "外国语学院", + [ + new("050201", "英语", "文学学士"), + new("050207", "日语", "文学学士"), + new("050262", "商务英语", "文学学士") + ], + ["综合英语", "英语听力", "英语口语", "英语写作", "翻译理论与实践", "跨文化交际", "商务英语实训", "口译实践"]), + new("MATH", "数学与统计学院", "数统学院", + [ + new("070101", "数学与应用数学", "理学学士"), + new("071201", "统计学", "理学学士"), + new("020102", "经济统计学", "经济学学士") + ], + ["数学分析", "高等代数", "解析几何", "常微分方程", "实变函数", "数值分析", "数学建模", "统计软件实践"]), + new("PHYSICS", "物理与光电工程学院", "物电学院", + [ + new("070201", "物理学", "理学学士"), + new("070202", "应用物理学", "理学学士"), + new("080705", "光电信息科学与工程", "工学学士") + ], + ["力学", "热学", "电磁学", "光学", "量子力学", "固体物理", "近代物理实验", "光电技术综合实验"]), + new("CHEM", "化学与环境工程学院", "化环学院", + [ + new("070301", "化学", "理学学士"), + new("070302", "应用化学", "理学学士"), + new("081301", "化学工程与工艺", "工学学士"), + new("082502", "环境工程", "工学学士") + ], + ["无机化学", "有机化学", "分析化学", "物理化学", "化工原理", "仪器分析", "基础化学实验", "化工设计实践"]), + new("HUMANITIES", "人文与法学院", "人文法学院", + [ + new("050101", "汉语言文学", "文学学士"), + new("030101K", "法学", "法学学士"), + new("120402", "行政管理", "管理学学士") + ], + ["中国古代文学", "中国现当代文学", "现代汉语", "古代汉语", "文学概论", "行政管理学", "新闻写作实训", "社会调查实践"]), + new("EDU", "教育科学学院", "教育学院", + [ + new("040101", "教育学", "教育学学士"), + new("040107", "小学教育", "教育学学士"), + new("040106", "学前教育", "教育学学士") + ], + ["教育学原理", "普通心理学", "教育心理学", "课程与教学论", "教育研究方法", "班级管理", "微格教学", "教育见习"]), + new("ART", "艺术设计学院", "艺术学院", + [ + new("130502", "视觉传达设计", "艺术学学士"), + new("130503", "环境设计", "艺术学学士"), + new("130202", "音乐学", "艺术学学士") + ], + ["设计素描", "色彩基础", "构成基础", "艺术概论", "数字媒体设计", "品牌视觉设计", "专业采风", "毕业创作实践"]), + new("PE", "体育学院", "体育学院", + [ + new("040201", "体育教育", "教育学学士"), + new("040203", "社会体育指导与管理", "教育学学士") + ], + ["运动解剖学", "运动生理学", "学校体育学", "体育心理学", "运动训练学", "体育社会学", "田径专项训练", "球类专项训练"]), + new("LIFE", "生命科学与食品工程学院", "生食学院", + [ + new("071001", "生物科学", "理学学士"), + new("071002", "生物技术", "理学学士"), + new("082701", "食品科学与工程", "工学学士") + ], + ["普通生物学", "生物化学", "细胞生物学", "遗传学", "微生物学", "食品化学", "分子生物学实验", "生物工程综合实践"]), + new("MARXISM", "马克思主义学院", "马克思主义学院", [], []) + ]; + + private static readonly PublicCourseSeed[] PublicCourseDefinitions = + [ + new("PUB001", "思想道德与法治", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired), + new("PUB002", "中国近现代史纲要", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired), + new("PUB003", "马克思主义基本原理", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired), + new("PUB004", "毛泽东思想和中国特色社会主义理论体系概论", "MARXISM", "MORAL", 5, 80, 64, CourseNature.GeneralRequired), + new("PUB005", "习近平新时代中国特色社会主义思想概论", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired), + new("PUB006", "形势与政策", "MARXISM", "MORAL", 2, 32, 32, CourseNature.GeneralRequired, AssessmentMethod.Assessment), + new("PUB007", "大学英语 I", "FOREIGN", "ENGLISH", 4, 64, 48, CourseNature.GeneralRequired), + new("PUB008", "大学英语 II", "FOREIGN", "ENGLISH", 4, 64, 48, CourseNature.GeneralRequired), + new("PUB009", "高等数学 A(上)", "MATH", "BASIC", 5, 80, 80, CourseNature.GeneralRequired), + new("PUB010", "高等数学 A(下)", "MATH", "BASIC", 5, 80, 80, CourseNature.GeneralRequired), + new("PUB011", "线性代数", "MATH", "BASIC", 3, 48, 48, CourseNature.GeneralRequired), + new("PUB012", "概率论与数理统计", "MATH", "BASIC", 3, 48, 48, CourseNature.GeneralRequired), + new("PUB013", "大学计算机基础", "CS", "BASIC", 2, 32, 16, CourseNature.GeneralRequired), + new("PUB014", "Python 程序设计", "CS", "BASIC", 3, 48, 24, CourseNature.GeneralRequired), + new("PUB015", "大学体育 I", "PE", "SPORTS", 1, 32, 4, CourseNature.GeneralRequired, AssessmentMethod.Assessment), + new("PUB016", "大学体育 II", "PE", "SPORTS", 1, 32, 4, CourseNature.GeneralRequired, AssessmentMethod.Assessment), + new("PUB017", "军事理论", "HUMANITIES", "MILITARY", 2, 36, 32, CourseNature.GeneralRequired, AssessmentMethod.Assessment), + new("PUB018", "大学生心理健康教育", "EDU", "BASIC", 2, 32, 24, CourseNature.GeneralRequired, AssessmentMethod.Assessment), + new("PUB019", "大学生职业发展与就业指导", "EDU", "BASIC", 2, 32, 24, CourseNature.GeneralRequired, AssessmentMethod.Assessment), + new("PUB020", "创新创业基础", "ECON", "INNOVATION", 2, 32, 20, CourseNature.GeneralRequired, AssessmentMethod.Assessment), + new("PUB021", "劳动教育", "EDU", "LABOR", 1, 32, 8, CourseNature.GeneralRequired, AssessmentMethod.Assessment), + new("PUB022", "国家安全教育", "HUMANITIES", "MORAL", 1, 16, 16, CourseNature.GeneralRequired, AssessmentMethod.Assessment), + new("PUB023", "文献检索与学术规范", "HUMANITIES", "BASIC", 1, 16, 12, CourseNature.GeneralRequired, AssessmentMethod.Assessment), + new("PUB024", "艺术鉴赏", "ART", "AESTHETIC", 2, 32, 24, CourseNature.GeneralElective, AssessmentMethod.Assessment), + new("PUB025", "中国传统文化", "HUMANITIES", "AESTHETIC", 2, 32, 32, CourseNature.GeneralElective, AssessmentMethod.Assessment), + new("PUB026", "生态文明导论", "LIFE", "BASIC", 2, 32, 24, CourseNature.GeneralElective, AssessmentMethod.Assessment), + new("PUB027", "人工智能导论", "CS", "INNOVATION", 2, 32, 20, CourseNature.GeneralElective, AssessmentMethod.Assessment), + new("PUB028", "经济学通识", "ECON", "BASIC", 2, 32, 32, CourseNature.GeneralElective, AssessmentMethod.Assessment) + ]; + + private sealed record CollegeSeed( + string Code, + string Name, + string ShortName, + IReadOnlyList Majors, + IReadOnlyList ProfessionalCourses); + + private sealed record MajorSeed( + string Code, + string Name, + string DegreeType, + int SchoolingYears = 4); + + private sealed record PublicCourseSeed( + string Code, + string Name, + string CollegeCode, + string CategoryCode, + decimal Credits, + int TotalHours, + int LectureHours, + CourseNature Nature, + AssessmentMethod AssessmentMethod = AssessmentMethod.Examination); +} diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 40d624f..7529ad3 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -87,6 +87,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services @@ -248,6 +249,11 @@ using (var scope = app.Services.CreateScope()) .InitializeAsync(); } +if (args.Contains("--seed-only", StringComparer.OrdinalIgnoreCase)) +{ + return; +} + app.Run(); public partial class Program; diff --git a/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs b/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs index 08ef34a..9c004da 100644 --- a/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs +++ b/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs @@ -53,6 +53,19 @@ public sealed class CourseSelectionRulesTests [selected])); } + [Theory] + [InlineData(CourseNature.GeneralRequired, true)] + [InlineData(CourseNature.GeneralElective, false)] + [InlineData(CourseNature.MajorRequired, false)] + [InlineData(CourseNature.MajorElective, false)] + [InlineData(CourseNature.Practice, false)] + public void Proxy_enrollment_is_limited_to_general_required_courses( + CourseNature nature, + bool expected) + { + Assert.Equal(expected, CourseSelectionRules.SupportsProxyEnrollment(nature)); + } + private static CourseSelectionRound CreateRound(DateTime startsAt, DateTime endsAt) => new() { diff --git a/web/src/style.css b/web/src/style.css index ce19ffd..2e33429 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -486,11 +486,26 @@ button { cursor: pointer; } margin: 0 0 18px; padding: 16px; display: flex; align-items: center; gap: 14px; color: white; background: linear-gradient(115deg, #1b3067, #294584); } +.roster-summary > div { min-width: 0; flex: 1; } +.roster-summary > .el-button { flex: 0 0 auto; } .roster-summary > .el-icon { font-size: 28px; color: #5bd4c3; } .roster-summary span, .roster-summary b, .roster-summary small { display: block; } .roster-summary span { color: #59d2c1; font: 700 9px Consolas, monospace; } .roster-summary b { margin-top: 5px; font-size: 14px; } .roster-summary small { margin-top: 4px; color: #bec8e2; font-size: 10px; } +.roster-notice { margin-bottom: 14px; } +.proxy-course-note { + margin-bottom: 14px; padding: 13px 15px; display: flex; align-items: center; + justify-content: space-between; gap: 16px; border-left: 3px solid var(--teal); + background: #f5f8fa; +} +.proxy-course-note b { font-size: 13px; } +.proxy-course-note span { color: var(--muted); font-size: 10px; } +.proxy-search { margin-bottom: 12px; display: flex; gap: 9px; } +.proxy-search .el-input { flex: 1; } +.proxy-pagination { margin-top: 14px; justify-content: flex-end; } +.proxy-dialog-footer { display: flex; align-items: center; } +.proxy-selected-count { margin-right: auto; color: var(--muted); font-size: 11px; } .grade-page { min-width: 0; } .grade-toolbar { diff --git a/web/src/views/CourseSelectionView.vue b/web/src/views/CourseSelectionView.vue index 045a4b1..d89388b 100644 --- a/web/src/views/CourseSelectionView.vue +++ b/web/src/views/CourseSelectionView.vue @@ -31,9 +31,18 @@ const detailLoading = ref(false) const roundDialog = ref(false) const offeringDialog = ref(false) const rosterDrawer = ref(false) +const proxyDialog = ref(false) const editingRoundId = ref('') const editingOfferingId = ref('') const roster = ref(null) +const rosterLoading = ref(false) +const eligibleStudents = ref([]) +const eligibleTotal = ref(0) +const eligiblePage = ref(1) +const eligibleLoading = ref(false) +const proxySubmitting = ref(false) +const studentKeyword = ref('') +const selectedStudentIds = ref([]) const roundForm = reactive>({}) const offeringForm = reactive>({}) @@ -286,13 +295,97 @@ async function deleteOffering(offering: any) { } async function showRoster(offering: any) { + rosterDrawer.value = true + await loadRoster(offering.id) +} + +async function loadRoster(offeringId: string) { + rosterLoading.value = true try { roster.value = ( - await http.get(`/course-selections/offerings/${offering.id}/roster`) + await http.get(`/course-selections/offerings/${offeringId}/roster`) ).data - rosterDrawer.value = true } catch (error) { ElMessage.error(apiErrorMessage(error)) + } finally { + rosterLoading.value = false + } +} + +async function openProxyEnrollment() { + studentKeyword.value = '' + selectedStudentIds.value = [] + eligiblePage.value = 1 + proxyDialog.value = true + await loadEligibleStudents() +} + +async function loadEligibleStudents(page = eligiblePage.value) { + if (!roster.value) return + eligibleLoading.value = true + eligiblePage.value = page + try { + const { data } = await http.get( + `/course-selections/offerings/${roster.value.id}/eligible-students`, + { + params: { + keyword: studentKeyword.value.trim() || undefined, + page, + pageSize: 20, + }, + }, + ) + eligibleStudents.value = data.items + eligibleTotal.value = data.total + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } finally { + eligibleLoading.value = false + } +} + +function onEligibleSelectionChanged(rows: any[]) { + selectedStudentIds.value = rows.map((item) => item.id) +} + +async function proxyEnroll() { + if (!roster.value || selectedStudentIds.value.length === 0) { + ElMessage.warning('请至少选择一名学生。') + return + } + proxySubmitting.value = true + try { + const { data } = await http.post( + `/course-selections/offerings/${roster.value.id}/admin-enrollments`, + { studentIds: selectedStudentIds.value }, + ) + ElMessage.success(`已为 ${data.enrolledCount} 名学生完成代选`) + proxyDialog.value = false + await loadRoster(roster.value.id) + if (selectedRound.value) await selectRound(selectedRound.value) + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } finally { + proxySubmitting.value = false + } +} + +async function removeFromRoster(student: any) { + if (!roster.value) return + try { + await ElMessageBox.confirm( + `确认将 ${student.studentNumber} ${student.name} 移出“${roster.value.courseName}”教学班名单?`, + '调整教学班名单', + { type: 'warning', confirmButtonText: '确认移出', cancelButtonText: '取消' }, + ) + await http.delete( + `/course-selections/offerings/${roster.value.id}/admin-enrollments/${student.id}`, + ) + ElMessage.success('已移出教学班名单') + await loadRoster(roster.value.id) + if (selectedRound.value) await selectRound(selectedRound.value) + } catch (error: any) { + if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) } } @@ -455,7 +548,7 @@ onMounted(async () => { - + + + + + +