diff --git a/README.md b/README.md index d6df247..e304eef 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 -当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布。 +当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单。 权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。 diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 index 78bace0..66b1c24 100644 --- a/scripts/smoke-test.ps1 +++ b/scripts/smoke-test.ps1 @@ -91,6 +91,24 @@ try { -Uri "http://localhost:5255/api/schedules/plans/$($schedulePlans[0].id)" ` -Headers $headers } + $selectionRounds = Invoke-RestMethod ` + -Uri 'http://localhost:5255/api/course-selections/rounds' ` + -Headers $headers + if (@($selectionRounds).Count -lt 1) { + throw 'Development course-selection round was not seeded.' + } + $activeSelectionRound = @($selectionRounds) | + Where-Object { $_.isAvailableNow } | + Select-Object -First 1 + if ($null -eq $activeSelectionRound) { + throw 'No course-selection round is open for the smoke test.' + } + $selectionOfferings = Invoke-RestMethod ` + -Uri "http://localhost:5255/api/course-selections/rounds/$($activeSelectionRound.id)/offerings" ` + -Headers $headers + if (@($selectionOfferings).Count -lt 1) { + throw 'Development course-selection offering was not seeded.' + } $managedUsers = Invoke-RestMethod -Uri 'http://localhost:5255/api/users' -Headers $headers $teacherAccount = @($managedUsers) | Where-Object { $_.userName -eq 'teacher' } | @@ -162,6 +180,48 @@ try { "$($scenario.UserName):$($scenario.Scope)" } + $studentLoginBody = @{ + userName = 'student' + password = 'Student@123456' + } | ConvertTo-Json + $studentLogin = Invoke-RestMethod ` + -Method Post ` + -Uri 'http://localhost:5255/api/auth/login' ` + -ContentType 'application/json' ` + -Body $studentLoginBody + $studentHeaders = @{ Authorization = "Bearer $($studentLogin.token)" } + $studentOptions = Invoke-RestMethod ` + -Uri "http://localhost:5255/api/course-selections/student/options?roundId=$($activeSelectionRound.id)" ` + -Headers $studentHeaders + $studentOffering = @($studentOptions.offerings) | Select-Object -First 1 + if ($null -eq $studentOffering) { + throw 'Student has no eligible course-selection offering.' + } + if ($studentOffering.enrollmentStatus -ne 'Enrolled') { + $enrollmentBody = @{ offeringId = $studentOffering.id } | ConvertTo-Json + Invoke-RestMethod ` + -Method Post ` + -Uri 'http://localhost:5255/api/course-selections/student/enrollments' ` + -Headers $studentHeaders ` + -ContentType 'application/json' ` + -Body $enrollmentBody | + Out-Null + } + $studentEnrollments = Invoke-RestMethod ` + -Uri "http://localhost:5255/api/course-selections/student/enrollments?academicTermId=$($activeSelectionRound.academicTermId)" ` + -Headers $studentHeaders + $activeEnrollments = @($studentEnrollments) | + Where-Object { $_.status -eq 'Enrolled' } + if ($activeEnrollments.Count -lt 1) { + throw 'Student course enrollment was not persisted.' + } + $selectionRoster = Invoke-RestMethod ` + -Uri "http://localhost:5255/api/course-selections/offerings/$($studentOffering.id)/roster" ` + -Headers $headers + if ($selectionRoster.enrolledCount -lt 1) { + throw 'Course-selection roster did not include the selected student.' + } + $frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5 $spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5 $unknownApiParameters = @{ @@ -186,6 +246,10 @@ try { TeachingTasks = $teachingTasks.total Schedules = @($schedulePlans).Count ScheduleEntries = if ($null -ne $scheduleDetail) { @($scheduleDetail.entries).Count } else { 0 } + SelectionRounds = @($selectionRounds).Count + SelectionOfferings = @($selectionOfferings).Count + StudentEnrollments = $activeEnrollments.Count + RosterStudents = $selectionRoster.enrolledCount AccessUpdate = $true ScopeChecks = $scopeChecks -join ', ' StaticIndex = $frontend.Content.Contains('明序教务管理系统') diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs new file mode 100644 index 0000000..6dc9113 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -0,0 +1,746 @@ +using System.ComponentModel.DataAnnotations; +using System.Data; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.CourseSelection; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/course-selections")] +public sealed class CourseSelectionsController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase +{ + private const string RoundManagers = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin; + + private const string OfferingManagers = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin; + + private const string SelectionUsers = + OfferingManagers + "," + + SystemRoles.Student; + + private const string RosterReaders = + OfferingManagers + "," + + SystemRoles.Teacher; + + [HttpGet("rounds")] + [Authorize(Roles = SelectionUsers)] + public async Task GetRounds( + Guid? academicTermId, + CancellationToken cancellationToken) + { + var source = db.CourseSelectionRounds.AsNoTracking().AsQueryable(); + if (academicTermId.HasValue) + source = source.Where(x => x.AcademicTermId == academicTermId); + if (currentUserDataScope.Current.IsInRole(SystemRoles.Student)) + source = source.Where(x => x.Status != CourseSelectionRoundStatus.Draft); + + var now = DateTime.UtcNow; + return Ok(await source + .OrderByDescending(x => x.AcademicTerm!.StartDate) + .ThenByDescending(x => x.StartsAt) + .Select(x => new + { + x.Id, + x.Name, + x.AcademicTermId, + TermName = x.AcademicTerm!.Name, + x.StartsAt, + x.EndsAt, + x.WithdrawalEndsAt, + x.MaxCredits, + x.Status, + IsAvailableNow = + x.Status == CourseSelectionRoundStatus.Open && + now >= x.StartsAt && + now <= x.EndsAt, + OfferingCount = x.Offerings.Count, + x.Notes, + x.UpdatedAt + }) + .ToListAsync(cancellationToken)); + } + + [HttpPost("rounds")] + [Authorize(Roles = RoundManagers)] + public async Task CreateRound( + CourseSelectionRoundRequest request, + CancellationToken cancellationToken) + { + var validation = await ValidateRoundAsync(request, cancellationToken); + if (validation is not null) return validation; + var round = new CourseSelectionRound + { + AcademicTermId = request.AcademicTermId, + Name = request.Name.Trim(), + StartsAt = request.StartsAt.ToUniversalTime(), + EndsAt = request.EndsAt.ToUniversalTime(), + WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(), + MaxCredits = request.MaxCredits, + Notes = Normalize(request.Notes) + }; + db.CourseSelectionRounds.Add(round); + return await SaveAsync(round.Id, true, cancellationToken); + } + + [HttpPut("rounds/{id:guid}")] + [Authorize(Roles = RoundManagers)] + public async Task UpdateRound( + Guid id, + CourseSelectionRoundRequest request, + CancellationToken cancellationToken) + { + var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken); + if (round is null) return NotFound(); + if (round.Status != CourseSelectionRoundStatus.Draft) + return ConflictProblem("只有草稿选课批次可以修改。"); + var validation = await ValidateRoundAsync(request, cancellationToken); + if (validation is not null) return validation; + round.AcademicTermId = request.AcademicTermId; + round.Name = request.Name.Trim(); + round.StartsAt = request.StartsAt.ToUniversalTime(); + round.EndsAt = request.EndsAt.ToUniversalTime(); + round.WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(); + round.MaxCredits = request.MaxCredits; + round.Notes = Normalize(request.Notes); + return await SaveAsync(id, false, cancellationToken); + } + + [HttpDelete("rounds/{id:guid}")] + [Authorize(Roles = RoundManagers)] + public async Task DeleteRound(Guid id, CancellationToken cancellationToken) + { + var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken); + if (round is null) return NotFound(); + if (round.Status != CourseSelectionRoundStatus.Draft) + return ConflictProblem("只有草稿选课批次可以删除。"); + db.CourseSelectionRounds.Remove(round); + return await SaveAsync(id, false, cancellationToken); + } + + [HttpPost("rounds/{id:guid}/open")] + [Authorize(Roles = RoundManagers)] + public async Task OpenRound(Guid id, CancellationToken cancellationToken) + { + var round = await db.CourseSelectionRounds + .Include(x => x.Offerings) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (round is null) return NotFound(); + if (round.Status != CourseSelectionRoundStatus.Draft) + return ConflictProblem("只有草稿选课批次可以开放。"); + if (round.Offerings.Count == 0) + return ConflictProblem("至少配置一个可选教学班后才能开放。"); + round.Status = CourseSelectionRoundStatus.Open; + return await SaveAsync(id, false, cancellationToken); + } + + [HttpPost("rounds/{id:guid}/close")] + [Authorize(Roles = RoundManagers)] + public async Task CloseRound(Guid id, CancellationToken cancellationToken) + { + var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken); + if (round is null) return NotFound(); + if (round.Status != CourseSelectionRoundStatus.Open) + return ConflictProblem("只有开放中的选课批次可以关闭。"); + round.Status = CourseSelectionRoundStatus.Closed; + return await SaveAsync(id, false, cancellationToken); + } + + [HttpGet("rounds/{roundId:guid}/offerings")] + [Authorize(Roles = OfferingManagers)] + public async Task GetOfferings( + Guid roundId, + CancellationToken cancellationToken) + { + var source = ScopedOfferings() + .AsNoTracking() + .Where(x => x.CourseSelectionRoundId == roundId); + return Ok(await source + .OrderBy(x => x.TeachingTask!.Course!.Code) + .ThenBy(x => x.TeachingTask!.TaskNumber) + .Select(x => new + { + x.Id, + x.CourseSelectionRoundId, + x.TeachingTaskId, + x.TeachingTask!.TaskNumber, + TaskName = x.TeachingTask.Name, + CourseCode = x.TeachingTask.Course!.Code, + CourseName = x.TeachingTask.Course.Name, + CollegeName = x.TeachingTask.Course.College!.Name, + x.TeachingTask.Course.Credits, + TeacherNames = x.TeachingTask.Teachers + .OrderByDescending(item => item.IsPrimary) + .Select(item => item.Teacher!.Name), + ClassNames = x.TeachingTask.Classes + .Select(item => item.AdministrativeClass!.Name), + x.Capacity, + EnrolledCount = x.Enrollments.Count(item => + item.Status == CourseEnrollmentStatus.Enrolled), + x.IsOpenToAll, + x.Notes, + x.UpdatedAt + }) + .ToListAsync(cancellationToken)); + } + + [HttpPost("rounds/{roundId:guid}/offerings")] + [Authorize(Roles = OfferingManagers)] + public async Task CreateOffering( + Guid roundId, + CourseSelectionOfferingRequest request, + CancellationToken cancellationToken) + { + var round = await db.CourseSelectionRounds.FindAsync([roundId], cancellationToken); + if (round is null) return NotFound(); + if (round.Status != CourseSelectionRoundStatus.Draft) + return ConflictProblem("选课批次开放后不能调整教学班。"); + var task = await FindAccessibleTaskAsync(request.TeachingTaskId, cancellationToken); + var validation = ValidateOffering(round, task, request); + if (validation is not null) return validation; + var offering = new CourseSelectionOffering + { + CourseSelectionRoundId = roundId, + TeachingTaskId = request.TeachingTaskId, + Capacity = request.Capacity, + IsOpenToAll = request.IsOpenToAll, + Notes = Normalize(request.Notes) + }; + db.CourseSelectionOfferings.Add(offering); + return await SaveAsync(offering.Id, true, cancellationToken); + } + + [HttpPut("rounds/{roundId:guid}/offerings/{id:guid}")] + [Authorize(Roles = OfferingManagers)] + public async Task UpdateOffering( + Guid roundId, + Guid id, + CourseSelectionOfferingRequest request, + CancellationToken cancellationToken) + { + var offering = await ScopedOfferings() + .Include(x => x.CourseSelectionRound) + .FirstOrDefaultAsync( + x => x.Id == id && x.CourseSelectionRoundId == roundId, + cancellationToken); + if (offering is null) return NotFound(); + if (offering.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft) + return ConflictProblem("选课批次开放后不能调整教学班。"); + var task = await FindAccessibleTaskAsync(request.TeachingTaskId, cancellationToken); + var validation = ValidateOffering(offering.CourseSelectionRound, task, request); + if (validation is not null) return validation; + offering.TeachingTaskId = request.TeachingTaskId; + offering.Capacity = request.Capacity; + offering.IsOpenToAll = request.IsOpenToAll; + offering.Notes = Normalize(request.Notes); + return await SaveAsync(id, false, cancellationToken); + } + + [HttpDelete("rounds/{roundId:guid}/offerings/{id:guid}")] + [Authorize(Roles = OfferingManagers)] + public async Task DeleteOffering( + Guid roundId, + Guid id, + CancellationToken cancellationToken) + { + var offering = await ScopedOfferings() + .Include(x => x.CourseSelectionRound) + .FirstOrDefaultAsync( + x => x.Id == id && x.CourseSelectionRoundId == roundId, + cancellationToken); + if (offering is null) return NotFound(); + if (offering.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft) + return ConflictProblem("选课批次开放后不能调整教学班。"); + db.CourseSelectionOfferings.Remove(offering); + return await SaveAsync(id, false, cancellationToken); + } + + [HttpGet("offerings/{id:guid}/roster")] + [Authorize(Roles = RosterReaders)] + public async Task GetRoster(Guid id, CancellationToken cancellationToken) + { + var offering = await db.CourseSelectionOfferings.AsNoTracking() + .Where(x => x.Id == id) + .Select(x => new + { + x.Id, + x.TeachingTaskId, + x.TeachingTask!.TaskNumber, + TaskName = x.TeachingTask.Name, + CourseName = x.TeachingTask.Course!.Name, + CollegeId = x.TeachingTask.Course.CollegeId, + TeacherUserIds = x.TeachingTask.Teachers + .Select(item => item.Teacher!.UserId), + x.Capacity + }) + .FirstOrDefaultAsync(cancellationToken); + if (offering is null) return NotFound(); + var scope = currentUserDataScope.Current; + var isAssignedTeacher = + scope.IsInRole(SystemRoles.Teacher) && + offering.TeacherUserIds.Contains(scope.UserId); + if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId)) + return Forbid(); + + var students = await db.CourseEnrollments.AsNoTracking() + .Where(x => + x.CourseSelectionOfferingId == id && + x.Status == CourseEnrollmentStatus.Enrolled) + .OrderBy(x => x.Student!.StudentNumber) + .Select(x => new + { + x.Id, + x.StudentId, + x.Student!.StudentNumber, + x.Student.Name, + ClassName = x.Student.AdministrativeClass!.Name, + MajorName = x.Student.AdministrativeClass.Major!.Name, + x.EnrolledAt + }) + .ToListAsync(cancellationToken); + return Ok(new + { + offering.Id, + offering.TeachingTaskId, + offering.TaskNumber, + offering.TaskName, + offering.CourseName, + offering.Capacity, + EnrolledCount = students.Count, + Students = students + }); + } + + [HttpGet("student/options")] + [Authorize(Roles = SystemRoles.Student)] + public async Task GetStudentOptions( + Guid roundId, + CancellationToken cancellationToken) + { + var student = await CurrentStudentAsync(cancellationToken); + if (student is null) return ProfileNotFound(); + var round = await db.CourseSelectionRounds.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == roundId, cancellationToken); + if (round is null || round.Status == CourseSelectionRoundStatus.Draft) + return NotFound(); + + var offerings = await db.CourseSelectionOfferings.AsNoTracking() + .Where(x => + x.CourseSelectionRoundId == roundId && + x.TeachingTask!.Status == TeachingTaskStatus.Published && + (x.IsOpenToAll || + x.TeachingTask.Classes.Any(item => + item.AdministrativeClassId == student.AdministrativeClassId))) + .OrderBy(x => x.TeachingTask!.Course!.Code) + .Select(x => new StudentOfferingDto( + x.Id, + x.TeachingTaskId, + x.TeachingTask!.TaskNumber, + x.TeachingTask.Course!.Code, + x.TeachingTask.Course.Name, + x.TeachingTask.Course.Credits, + x.TeachingTask.Teachers + .OrderByDescending(item => item.IsPrimary) + .Select(item => item.Teacher!.Name), + x.Capacity, + x.Enrollments.Count(item => + item.Status == CourseEnrollmentStatus.Enrolled), + x.IsOpenToAll, + x.Enrollments + .Where(item => item.StudentId == student.Id) + .Select(item => (CourseEnrollmentStatus?)item.Status) + .FirstOrDefault(), + db.ScheduleEntries + .Where(entry => + entry.TeachingTaskId == x.TeachingTaskId && + entry.SchedulePlan!.AcademicTermId == round.AcademicTermId && + entry.SchedulePlan.Status == SchedulePlanStatus.Published) + .OrderBy(entry => entry.DayOfWeek) + .ThenBy(entry => entry.StartPeriod) + .Select(entry => new StudentScheduleDto( + entry.DayOfWeek, + entry.StartPeriod, + entry.PeriodCount, + entry.StartWeek, + entry.EndWeek, + entry.WeekPattern, + entry.Classroom!.Name)) + .ToList())) + .ToListAsync(cancellationToken); + + return Ok(new + { + Round = new + { + round.Id, + round.Name, + round.StartsAt, + round.EndsAt, + round.WithdrawalEndsAt, + round.MaxCredits, + round.Status, + IsAvailableNow = CourseSelectionRules.IsSelectionOpen( + round, + DateTime.UtcNow) + }, + Student = new + { + student.Id, + student.StudentNumber, + student.Name, + ClassName = student.AdministrativeClass!.Name + }, + Offerings = offerings + }); + } + + [HttpGet("student/enrollments")] + [Authorize(Roles = SystemRoles.Student)] + public async Task GetStudentEnrollments( + Guid? academicTermId, + CancellationToken cancellationToken) + { + var student = await CurrentStudentAsync(cancellationToken); + if (student is null) return ProfileNotFound(); + var source = db.CourseEnrollments.AsNoTracking() + .Where(x => x.StudentId == student.Id); + if (academicTermId.HasValue) + { + source = source.Where(x => + x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == + academicTermId); + } + return Ok(await source + .OrderByDescending(x => x.EnrolledAt) + .Select(x => new + { + x.Id, + x.CourseSelectionOfferingId, + RoundId = x.CourseSelectionOffering!.CourseSelectionRoundId, + RoundName = x.CourseSelectionOffering.CourseSelectionRound!.Name, + TermName = x.CourseSelectionOffering.CourseSelectionRound.AcademicTerm!.Name, + x.CourseSelectionOffering.TeachingTaskId, + TaskNumber = x.CourseSelectionOffering.TeachingTask!.TaskNumber, + CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, + CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name, + x.CourseSelectionOffering.TeachingTask.Course.Credits, + TeacherNames = x.CourseSelectionOffering.TeachingTask.Teachers + .OrderByDescending(item => item.IsPrimary) + .Select(item => item.Teacher!.Name), + x.Status, + x.EnrolledAt, + x.WithdrawnAt, + CanWithdraw = + x.Status == CourseEnrollmentStatus.Enrolled && + x.CourseSelectionOffering.CourseSelectionRound.Status == + CourseSelectionRoundStatus.Open && + DateTime.UtcNow <= + x.CourseSelectionOffering.CourseSelectionRound.WithdrawalEndsAt + }) + .ToListAsync(cancellationToken)); + } + + [HttpPost("student/enrollments")] + [Authorize(Roles = SystemRoles.Student)] + public async Task Enroll( + StudentEnrollmentRequest request, + CancellationToken cancellationToken) + { + var student = await CurrentStudentAsync(cancellationToken); + if (student is null) return ProfileNotFound(); + if (student.Status != StudentStatus.Active) + return ConflictProblem("只有在籍学生可以选课。"); + + 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 == request.OfferingId, cancellationToken); + if (offering is null) return NotFound(); + var round = offering.CourseSelectionRound!; + var task = offering.TeachingTask!; + var now = DateTime.UtcNow; + if (!CourseSelectionRules.IsSelectionOpen(round, now)) + return ConflictProblem("当前不在该选课批次的开放时间内。"); + if (task.Status != TeachingTaskStatus.Published) + return ConflictProblem("该教学班当前不可选。"); + if (!offering.IsOpenToAll && + !task.Classes.Any(x => + x.AdministrativeClassId == student.AdministrativeClassId)) + return Forbid(); + + var existing = await db.CourseEnrollments.FirstOrDefaultAsync( + x => + x.CourseSelectionOfferingId == offering.Id && + x.StudentId == student.Id, + cancellationToken); + if (existing?.Status == CourseEnrollmentStatus.Enrolled) + return ConflictProblem("你已经选择了该教学班。"); + var enrolledCount = await db.CourseEnrollments.CountAsync( + x => + x.CourseSelectionOfferingId == offering.Id && + x.Status == CourseEnrollmentStatus.Enrolled, + cancellationToken); + if (enrolledCount >= offering.Capacity) + return ConflictProblem("该教学班名额已满。"); + + var duplicateCourse = await db.CourseEnrollments.AnyAsync( + x => + x.StudentId == student.Id && + x.Status == CourseEnrollmentStatus.Enrolled && + x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && + x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId == + round.AcademicTermId, + cancellationToken); + if (duplicateCourse) + return ConflictProblem("同一学期不能重复选择相同课程。"); + + 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( + $"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分,超过本轮 {round.MaxCredits:0.#} 学分上限。"); + } + + var candidateEntries = await PublishedScheduleEntries( + round.AcademicTermId, + [task.Id], + cancellationToken); + if (candidateEntries.Count == 0) + return ConflictProblem("该教学班尚未发布课表,暂时不能选课。"); + var selectedTaskIds = await db.CourseEnrollments + .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("该教学班与已选课程的上课时间冲突。"); + + if (existing is null) + { + existing = new CourseEnrollment + { + CourseSelectionOfferingId = offering.Id, + StudentId = student.Id + }; + db.CourseEnrollments.Add(existing); + } + else + { + existing.Status = CourseEnrollmentStatus.Enrolled; + existing.EnrolledAt = now; + existing.WithdrawnAt = null; + } + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Created(string.Empty, new { existing.Id }); + } + + [HttpDelete("student/enrollments/{id:guid}")] + [Authorize(Roles = SystemRoles.Student)] + public async Task Withdraw(Guid id, CancellationToken cancellationToken) + { + var student = await CurrentStudentAsync(cancellationToken); + if (student is null) return ProfileNotFound(); + var enrollment = await db.CourseEnrollments + .Include(x => x.CourseSelectionOffering) + .ThenInclude(x => x!.CourseSelectionRound) + .FirstOrDefaultAsync( + x => x.Id == id && x.StudentId == student.Id, + cancellationToken); + if (enrollment is null) return NotFound(); + if (enrollment.Status != CourseEnrollmentStatus.Enrolled) + return ConflictProblem("该课程已经退选。"); + if (!CourseSelectionRules.CanWithdraw( + enrollment.CourseSelectionOffering!.CourseSelectionRound!, + DateTime.UtcNow)) + return ConflictProblem("当前批次已停止退课。"); + enrollment.Status = CourseEnrollmentStatus.Withdrawn; + enrollment.WithdrawnAt = DateTime.UtcNow; + return await SaveAsync(id, false, cancellationToken); + } + + private IQueryable ScopedOfferings() + { + var source = db.CourseSelectionOfferings.AsQueryable(); + var scope = currentUserDataScope.Current; + return scope.Scope == DataScope.All + ? source + : source.Where(x => + x.TeachingTask!.Course!.CollegeId == scope.RestrictedCollegeId); + } + + private async Task FindAccessibleTaskAsync( + Guid id, + CancellationToken cancellationToken) + { + var scope = currentUserDataScope.Current; + var source = db.TeachingTasks.AsNoTracking().AsQueryable(); + if (scope.Scope != DataScope.All) + { + source = source.Where(x => + x.Course!.CollegeId == scope.RestrictedCollegeId); + } + return await source + .Include(x => x.Course) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + private ActionResult? ValidateOffering( + CourseSelectionRound round, + TeachingTask? task, + CourseSelectionOfferingRequest request) + { + if (task is null) return ValidationProblem("所选教学班不存在或超出数据范围。"); + if (task.AcademicTermId != round.AcademicTermId) + return ValidationProblem("教学班与选课批次必须属于同一学期。"); + if (task.Status != TeachingTaskStatus.Published) + return ValidationProblem("只有已发布的教学班可以进入选课。"); + if (request.Capacity > task.Capacity) + return ValidationProblem($"选课容量不能超过教学班容量 {task.Capacity}。"); + return null; + } + + private async Task ValidateRoundAsync( + CourseSelectionRoundRequest request, + CancellationToken cancellationToken) + { + var startsAt = request.StartsAt.ToUniversalTime(); + var endsAt = request.EndsAt.ToUniversalTime(); + var withdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(); + if (startsAt >= endsAt) + return ValidationProblem("选课开始时间必须早于结束时间。"); + if (withdrawalEndsAt < endsAt) + return ValidationProblem("退课截止时间不能早于选课结束时间。"); + if (!await db.AcademicTerms.AnyAsync( + x => x.Id == request.AcademicTermId && x.IsEnabled, + cancellationToken)) + return ValidationProblem("所选学期不存在或已停用。"); + return null; + } + + private async Task CurrentStudentAsync( + CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + return await db.Students + .Include(x => x.AdministrativeClass) + .FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken); + } + + private async Task> PublishedScheduleEntries( + Guid academicTermId, + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + if (taskIds.Count == 0) return []; + return await db.ScheduleEntries.AsNoTracking() + .Where(x => + taskIds.Contains(x.TeachingTaskId) && + x.SchedulePlan!.AcademicTermId == academicTermId && + x.SchedulePlan.Status == SchedulePlanStatus.Published) + .ToListAsync(cancellationToken); + } + + private async Task SaveAsync( + Guid id, + bool created, + CancellationToken cancellationToken) + { + try + { + await db.SaveChangesAsync(cancellationToken); + return created ? Created(string.Empty, new { id }) : NoContent(); + } + catch (DbUpdateException) + { + return ConflictProblem("记录重复、容量已变化,或关联数据已失效。"); + } + } + + private ActionResult ProfileNotFound() => + ConflictProblem("当前账号未关联有效学生档案,请联系教务管理员。"); + + private ActionResult ConflictProblem(string detail) => + Conflict(new ProblemDetails + { + Title = "无法完成选课操作", + Detail = detail, + Status = StatusCodes.Status409Conflict + }); + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +public sealed record CourseSelectionRoundRequest( + Guid AcademicTermId, + [Required, MaxLength(120)] string Name, + DateTime StartsAt, + DateTime EndsAt, + DateTime WithdrawalEndsAt, + [Range(typeof(decimal), "0.5", "99")] decimal MaxCredits, + [MaxLength(500)] string? Notes); + +public sealed record CourseSelectionOfferingRequest( + Guid TeachingTaskId, + [Range(1, 10000)] int Capacity, + bool IsOpenToAll, + [MaxLength(500)] string? Notes); + +public sealed record StudentEnrollmentRequest(Guid OfferingId); + +public sealed record StudentOfferingDto( + Guid Id, + Guid TeachingTaskId, + string TaskNumber, + string CourseCode, + string CourseName, + decimal Credits, + IEnumerable TeacherNames, + int Capacity, + int EnrolledCount, + bool IsOpenToAll, + CourseEnrollmentStatus? EnrollmentStatus, + IEnumerable Schedules); + +public sealed record StudentScheduleDto( + int DayOfWeek, + int StartPeriod, + int PeriodCount, + int StartWeek, + int EndWeek, + WeekPattern WeekPattern, + string ClassroomName); diff --git a/src/Jiaowu.Api/Controllers/DashboardController.cs b/src/Jiaowu.Api/Controllers/DashboardController.cs index 2a7616d..f9f3a45 100644 --- a/src/Jiaowu.Api/Controllers/DashboardController.cs +++ b/src/Jiaowu.Api/Controllers/DashboardController.cs @@ -1,3 +1,4 @@ +using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -35,6 +36,14 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken), TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken), SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken), + CourseSelectionRounds = await db.CourseSelectionRounds + .CountAsync(cancellationToken), + CourseSelectionOfferings = await db.CourseSelectionOfferings + .CountAsync(cancellationToken), + CourseEnrollments = await db.CourseEnrollments + .CountAsync( + x => x.Status == CourseEnrollmentStatus.Enrolled, + cancellationToken), Users = await db.Users.CountAsync(cancellationToken) } }; diff --git a/src/Jiaowu.Api/Domain/Academic/CourseSelectionEntities.cs b/src/Jiaowu.Api/Domain/Academic/CourseSelectionEntities.cs new file mode 100644 index 0000000..3c6feb4 --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/CourseSelectionEntities.cs @@ -0,0 +1,54 @@ +using Jiaowu.Api.Domain.Common; + +namespace Jiaowu.Api.Domain.Academic; + +public sealed class CourseSelectionRound : EntityBase +{ + public Guid AcademicTermId { get; set; } + public AcademicTerm? AcademicTerm { get; set; } + public required string Name { get; set; } + public DateTime StartsAt { get; set; } + public DateTime EndsAt { get; set; } + public DateTime WithdrawalEndsAt { get; set; } + public decimal MaxCredits { get; set; } = 30; + public CourseSelectionRoundStatus Status { get; set; } = + CourseSelectionRoundStatus.Draft; + public string? Notes { get; set; } + public ICollection Offerings { get; set; } = []; +} + +public sealed class CourseSelectionOffering : EntityBase +{ + public Guid CourseSelectionRoundId { get; set; } + public CourseSelectionRound? CourseSelectionRound { get; set; } + public Guid TeachingTaskId { get; set; } + public TeachingTask? TeachingTask { get; set; } + public int Capacity { get; set; } + public bool IsOpenToAll { get; set; } + public string? Notes { get; set; } + public ICollection Enrollments { get; set; } = []; +} + +public sealed class CourseEnrollment : EntityBase +{ + public Guid CourseSelectionOfferingId { get; set; } + public CourseSelectionOffering? CourseSelectionOffering { get; set; } + public Guid StudentId { get; set; } + public Student? Student { get; set; } + public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled; + public DateTime EnrolledAt { get; set; } = DateTime.UtcNow; + public DateTime? WithdrawnAt { get; set; } +} + +public enum CourseSelectionRoundStatus +{ + Draft = 1, + Open = 2, + Closed = 3 +} + +public enum CourseEnrollmentStatus +{ + Enrolled = 1, + Withdrawn = 2 +} diff --git a/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs b/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs new file mode 100644 index 0000000..ba05deb --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs @@ -0,0 +1,23 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Scheduling; + +namespace Jiaowu.Api.Infrastructure.CourseSelection; + +public static class CourseSelectionRules +{ + public static bool IsSelectionOpen(CourseSelectionRound round, DateTime nowUtc) => + round.Status == CourseSelectionRoundStatus.Open && + nowUtc >= round.StartsAt && + nowUtc <= round.EndsAt; + + public static bool CanWithdraw(CourseSelectionRound round, DateTime nowUtc) => + round.Status == CourseSelectionRoundStatus.Open && + nowUtc <= round.WithdrawalEndsAt; + + public static bool HasScheduleConflict( + IEnumerable candidateEntries, + IEnumerable selectedEntries) => + candidateEntries.Any(candidate => + selectedEntries.Any(selected => + ScheduleConflictDetector.TimeOverlaps(candidate, selected))); +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 514d05a..99ee8ba 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -28,6 +28,11 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet TeachingTaskClasses => Set(); public DbSet SchedulePlans => Set(); public DbSet ScheduleEntries => Set(); + public DbSet CourseSelectionRounds => + Set(); + public DbSet CourseSelectionOfferings => + Set(); + public DbSet CourseEnrollments => Set(); public DbSet AuditLogs => Set(); protected override void OnModelCreating(ModelBuilder builder) @@ -267,6 +272,51 @@ public sealed class AppDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.Property(x => x.Name).HasMaxLength(120); + entity.Property(x => x.MaxCredits).HasPrecision(6, 1); + entity.Property(x => x.Notes).HasMaxLength(500); + entity.HasIndex(x => new { x.AcademicTermId, x.Status }); + entity.HasOne(x => x.AcademicTerm) + .WithMany() + .HasForeignKey(x => x.AcademicTermId) + .OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.Property(x => x.Notes).HasMaxLength(500); + entity.HasIndex(x => new + { + x.CourseSelectionRoundId, + x.TeachingTaskId + }).IsUnique(); + entity.HasOne(x => x.CourseSelectionRound) + .WithMany(x => x.Offerings) + .HasForeignKey(x => x.CourseSelectionRoundId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.TeachingTask) + .WithMany() + .HasForeignKey(x => x.TeachingTaskId) + .OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.HasIndex(x => new { x.CourseSelectionOfferingId, x.StudentId }) + .IsUnique(); + entity.HasIndex(x => new { x.StudentId, x.Status }); + entity.HasOne(x => x.CourseSelectionOffering) + .WithMany(x => x.Enrollments) + .HasForeignKey(x => x.CourseSelectionOfferingId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Student) + .WithMany() + .HasForeignKey(x => x.StudentId) + .OnDelete(DeleteBehavior.Restrict); + }); + builder.Entity(entity => { entity.Property(x => x.Method).HasMaxLength(10); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs index 9c6d5c0..da39f80 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs @@ -421,6 +421,42 @@ public sealed class DatabaseInitializer( } await SeedDevelopmentUsersAsync(computerCollege.Id); + await SeedDevelopmentCourseSelectionAsync(); + } + + private async Task SeedDevelopmentCourseSelectionAsync() + { + if (await db.CourseSelectionRounds.AnyAsync()) + { + return; + } + + var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent); + var task = await db.TeachingTasks.SingleAsync( + x => x.TaskNumber == "2026-1-CS101-01"); + var now = DateTime.UtcNow; + db.CourseSelectionRounds.Add(new CourseSelectionRound + { + AcademicTermId = term.Id, + Name = "2026—2027 学年第一学期第一轮选课", + StartsAt = now.AddDays(-2), + EndsAt = now.AddDays(14), + WithdrawalEndsAt = now.AddDays(21), + MaxCredits = 30, + Status = CourseSelectionRoundStatus.Open, + Notes = "本地开发演示轮次,可用于验证选课、退课和教学班名单。", + Offerings = + [ + new CourseSelectionOffering + { + TeachingTaskId = task.Id, + Capacity = 60, + IsOpenToAll = false, + Notes = "面向计科 2026-1 班开放。" + } + ] + }); + await db.SaveChangesAsync(); } private async Task SeedDevelopmentUsersAsync(Guid collegeId) diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 7c2e113..511b2a6 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -11,6 +11,7 @@ public sealed class DevelopmentSqliteMigrator( private const string TeachingTasksMigration = "20260724_03_teaching_tasks"; private const string SchedulesMigration = "20260724_04_schedules"; private const string ClassCounselorMigration = "20260724_05_class_counselor"; + private const string CourseSelectionMigration = "20260724_06_course_selection"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -58,6 +59,10 @@ public sealed class DevelopmentSqliteMigrator( ? ClassCounselorStatements.Skip(1) : ClassCounselorStatements, cancellationToken); + await ApplyMigrationAsync( + CourseSelectionMigration, + CourseSelectionStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -426,4 +431,79 @@ public sealed class DevelopmentSqliteMigrator( ON "AdministrativeClasses" ("CounselorUserId"); """ ]; + + private static readonly string[] CourseSelectionStatements = + [ + """ + CREATE TABLE IF NOT EXISTS "CourseSelectionRounds" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_CourseSelectionRounds" PRIMARY KEY, + "AcademicTermId" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "StartsAt" TEXT NOT NULL, + "EndsAt" TEXT NOT NULL, + "WithdrawalEndsAt" TEXT NOT NULL, + "MaxCredits" TEXT NOT NULL, + "Status" INTEGER NOT NULL, + "Notes" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_CourseSelectionRounds_AcademicTerms_AcademicTermId" + FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT + ); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_CourseSelectionRounds_AcademicTermId_Status" + ON "CourseSelectionRounds" ("AcademicTermId", "Status"); + """, + """ + CREATE TABLE IF NOT EXISTS "CourseSelectionOfferings" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_CourseSelectionOfferings" PRIMARY KEY, + "CourseSelectionRoundId" TEXT NOT NULL, + "TeachingTaskId" TEXT NOT NULL, + "Capacity" INTEGER NOT NULL, + "IsOpenToAll" INTEGER NOT NULL, + "Notes" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_CourseSelectionOfferings_CourseSelectionRounds_CourseSelectionRoundId" + FOREIGN KEY ("CourseSelectionRoundId") REFERENCES "CourseSelectionRounds" ("Id") + ON DELETE CASCADE, + CONSTRAINT "FK_CourseSelectionOfferings_TeachingTasks_TeachingTaskId" + FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT + ); + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseSelectionOfferings_CourseSelectionRoundId_TeachingTaskId" + ON "CourseSelectionOfferings" ("CourseSelectionRoundId", "TeachingTaskId"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_CourseSelectionOfferings_TeachingTaskId" + ON "CourseSelectionOfferings" ("TeachingTaskId"); + """, + """ + CREATE TABLE IF NOT EXISTS "CourseEnrollments" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_CourseEnrollments" PRIMARY KEY, + "CourseSelectionOfferingId" TEXT NOT NULL, + "StudentId" TEXT NOT NULL, + "Status" INTEGER NOT NULL, + "EnrolledAt" TEXT NOT NULL, + "WithdrawnAt" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_CourseEnrollments_CourseSelectionOfferings_CourseSelectionOfferingId" + FOREIGN KEY ("CourseSelectionOfferingId") REFERENCES "CourseSelectionOfferings" ("Id") + ON DELETE CASCADE, + CONSTRAINT "FK_CourseEnrollments_Students_StudentId" + FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT + ); + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseEnrollments_CourseSelectionOfferingId_StudentId" + ON "CourseEnrollments" ("CourseSelectionOfferingId", "StudentId"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_CourseEnrollments_StudentId_Status" + ON "CourseEnrollments" ("StudentId", "Status"); + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724070409_CourseSelection.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724070409_CourseSelection.Designer.cs new file mode 100644 index 0000000..cb30b38 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724070409_CourseSelection.Designer.cs @@ -0,0 +1,1664 @@ +// +using System; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260724070409_CourseSelection")] + partial class CourseSelection + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicTerm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicYear") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsCurrent") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Season") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsCurrent"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AcademicTerms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CounselorName") + .HasColumnType("longtext"); + + b.Property("CounselorUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CounselorUserId"); + + b.HasIndex("MajorId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AdministrativeClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Buildings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Campuses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BuildingId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Equipment") + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RoomType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ShortName") + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Colleges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AssessmentMethod") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Credits") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EnglishName") + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LectureHours") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Nature") + .HasColumnType("int"); + + b.Property("PracticeHours") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalHours") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CollegeId", "Nature"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseSelectionOfferingId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrolledAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawnAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseSelectionOfferingId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "Status"); + + b.ToTable("CourseEnrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseSelectionRoundId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsOpenToAll") + .HasColumnType("tinyint(1)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("CourseSelectionRoundId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("CourseSelectionOfferings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("MaxCredits") + .HasPrecision(6, 1) + .HasColumnType("decimal(6,1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawalEndsAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("CourseSelectionRounds"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumModuleId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RecommendedSemester") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("CurriculumModuleId", "CourseId") + .IsUnique(); + + b.ToTable("CurriculumCourses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId", "Code") + .IsUnique(); + + b.ToTable("CurriculumModules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EffectiveGrade") + .HasColumnType("int"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("Status", "EffectiveGrade"); + + b.HasIndex("MajorId", "EffectiveGrade", "Version") + .IsUnique(); + + b.ToTable("CurriculumPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SchoolingYears") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CollegeId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Majors"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeekPattern") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("SchedulePlanId", "DayOfWeek", "StartPeriod"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.HasIndex("AcademicTermId", "Version") + .IsUnique(); + + b.ToTable("SchedulePlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EnrollmentDate") + .HasColumnType("date"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentYear"); + + b.HasIndex("StudentNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("AdministrativeClassId", "Status"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("date"); + + b.Property("IsExternal") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TeacherNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Title") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("TeacherNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("CollegeId", "Status"); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeeklyHours") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("TaskNumber") + .IsUnique(); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("TeachingTasks"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskId", "AdministrativeClassId"); + + b.HasIndex("AdministrativeClassId"); + + b.ToTable("TeachingTaskClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.HasKey("TeachingTaskId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("TeachingTaskTeachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("DataScope") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("StaffNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("StaffNumber"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.System.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "CounselorUser") + .WithMany() + .HasForeignKey("CounselorUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CounselorUser"); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "Building") + .WithMany() + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", "CourseSelectionOffering") + .WithMany("Enrollments") + .HasForeignKey("CourseSelectionOfferingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionOffering"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound") + .WithMany("Offerings") + .HasForeignKey("CourseSelectionRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionRound"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumModule", "CurriculumModule") + .WithMany("Courses") + .HasForeignKey("CurriculumModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("CurriculumModule"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany("Modules") + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany("Entries") + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SchedulePlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany("Students") + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AdministrativeClass"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany() + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Classes") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdministrativeClass"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Teachers") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Teacher"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Navigation("Offerings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Navigation("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Navigation("Classes"); + + b.Navigation("Teachers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724070409_CourseSelection.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724070409_CourseSelection.cs new file mode 100644 index 0000000..e4f0aa7 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724070409_CourseSelection.cs @@ -0,0 +1,145 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class CourseSelection : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CourseSelectionRounds", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + AcademicTermId = table.Column(type: "char(36)", nullable: false), + Name = table.Column(type: "varchar(120)", maxLength: 120, nullable: false), + StartsAt = table.Column(type: "datetime(6)", nullable: false), + EndsAt = table.Column(type: "datetime(6)", nullable: false), + WithdrawalEndsAt = table.Column(type: "datetime(6)", nullable: false), + MaxCredits = table.Column(type: "decimal(6,1)", precision: 6, scale: 1, nullable: false), + Status = table.Column(type: "int", nullable: false), + Notes = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CourseSelectionRounds", x => x.Id); + table.ForeignKey( + name: "FK_CourseSelectionRounds_AcademicTerms_AcademicTermId", + column: x => x.AcademicTermId, + principalTable: "AcademicTerms", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "CourseSelectionOfferings", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + CourseSelectionRoundId = table.Column(type: "char(36)", nullable: false), + TeachingTaskId = table.Column(type: "char(36)", nullable: false), + Capacity = table.Column(type: "int", nullable: false), + IsOpenToAll = table.Column(type: "tinyint(1)", nullable: false), + Notes = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CourseSelectionOfferings", x => x.Id); + table.ForeignKey( + name: "FK_CourseSelectionOfferings_CourseSelectionRounds_CourseSelecti~", + column: x => x.CourseSelectionRoundId, + principalTable: "CourseSelectionRounds", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CourseSelectionOfferings_TeachingTasks_TeachingTaskId", + column: x => x.TeachingTaskId, + principalTable: "TeachingTasks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "CourseEnrollments", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + CourseSelectionOfferingId = table.Column(type: "char(36)", nullable: false), + StudentId = table.Column(type: "char(36)", nullable: false), + Status = table.Column(type: "int", nullable: false), + EnrolledAt = table.Column(type: "datetime(6)", nullable: false), + WithdrawnAt = table.Column(type: "datetime(6)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CourseEnrollments", x => x.Id); + table.ForeignKey( + name: "FK_CourseEnrollments_CourseSelectionOfferings_CourseSelectionOf~", + column: x => x.CourseSelectionOfferingId, + principalTable: "CourseSelectionOfferings", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CourseEnrollments_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_CourseEnrollments_CourseSelectionOfferingId_StudentId", + table: "CourseEnrollments", + columns: new[] { "CourseSelectionOfferingId", "StudentId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CourseEnrollments_StudentId_Status", + table: "CourseEnrollments", + columns: new[] { "StudentId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_CourseSelectionOfferings_CourseSelectionRoundId_TeachingTask~", + table: "CourseSelectionOfferings", + columns: new[] { "CourseSelectionRoundId", "TeachingTaskId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CourseSelectionOfferings_TeachingTaskId", + table: "CourseSelectionOfferings", + column: "TeachingTaskId"); + + migrationBuilder.CreateIndex( + name: "IX_CourseSelectionRounds_AcademicTermId_Status", + table: "CourseSelectionRounds", + columns: new[] { "AcademicTermId", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CourseEnrollments"); + + migrationBuilder.DropTable( + name: "CourseSelectionOfferings"); + + migrationBuilder.DropTable( + name: "CourseSelectionRounds"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index 91a1feb..dbade5b 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -382,6 +382,128 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.ToTable("Courses"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseSelectionOfferingId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrolledAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawnAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseSelectionOfferingId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "Status"); + + b.ToTable("CourseEnrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseSelectionRoundId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsOpenToAll") + .HasColumnType("tinyint(1)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("CourseSelectionRoundId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("CourseSelectionOfferings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("MaxCredits") + .HasPrecision(6, 1) + .HasColumnType("decimal(6,1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawalEndsAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("CourseSelectionRounds"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => { b.Property("Id") @@ -1218,6 +1340,55 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("College"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", "CourseSelectionOffering") + .WithMany("Enrollments") + .HasForeignKey("CourseSelectionOfferingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionOffering"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound") + .WithMany("Offerings") + .HasForeignKey("CourseSelectionRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionRound"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => { b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") @@ -1453,6 +1624,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("Students"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Navigation("Offerings"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => { b.Navigation("Courses"); diff --git a/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs b/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs new file mode 100644 index 0000000..08ef34a --- /dev/null +++ b/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs @@ -0,0 +1,82 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.CourseSelection; + +namespace Jiaowu.Api.Tests; + +public sealed class CourseSelectionRulesTests +{ + [Fact] + public void Selection_requires_open_status_and_active_window() + { + var now = new DateTime(2026, 7, 24, 8, 0, 0, DateTimeKind.Utc); + var round = CreateRound(now.AddHours(-1), now.AddHours(1)); + + Assert.True(CourseSelectionRules.IsSelectionOpen(round, now)); + + round.Status = CourseSelectionRoundStatus.Closed; + Assert.False(CourseSelectionRules.IsSelectionOpen(round, now)); + + round.Status = CourseSelectionRoundStatus.Open; + Assert.False(CourseSelectionRules.IsSelectionOpen(round, now.AddHours(2))); + } + + [Fact] + public void Withdrawal_respects_deadline() + { + var now = new DateTime(2026, 7, 24, 8, 0, 0, DateTimeKind.Utc); + var round = CreateRound(now.AddDays(-1), now.AddDays(1)); + round.WithdrawalEndsAt = now.AddMinutes(30); + + Assert.True(CourseSelectionRules.CanWithdraw(round, now)); + Assert.False(CourseSelectionRules.CanWithdraw(round, now.AddHours(1))); + } + + [Fact] + public void Schedule_conflict_detects_overlapping_weeks_and_periods() + { + var selected = CreateEntry(1, 1, 2, 1, 16, WeekPattern.All); + var overlapping = CreateEntry(1, 2, 2, 1, 16, WeekPattern.All); + + Assert.True(CourseSelectionRules.HasScheduleConflict( + [overlapping], + [selected])); + } + + [Fact] + public void Odd_and_even_week_entries_do_not_conflict() + { + var selected = CreateEntry(3, 5, 2, 1, 16, WeekPattern.Odd); + var candidate = CreateEntry(3, 5, 2, 1, 16, WeekPattern.Even); + + Assert.False(CourseSelectionRules.HasScheduleConflict( + [candidate], + [selected])); + } + + private static CourseSelectionRound CreateRound(DateTime startsAt, DateTime endsAt) => + new() + { + Name = "第一轮选课", + StartsAt = startsAt, + EndsAt = endsAt, + WithdrawalEndsAt = endsAt.AddDays(1), + Status = CourseSelectionRoundStatus.Open + }; + + private static ScheduleEntry CreateEntry( + int dayOfWeek, + int startPeriod, + int periodCount, + int startWeek, + int endWeek, + WeekPattern weekPattern) => + new() + { + DayOfWeek = dayOfWeek, + StartPeriod = startPeriod, + PeriodCount = periodCount, + StartWeek = startWeek, + EndWeek = endWeek, + WeekPattern = weekPattern + }; +} diff --git a/web/src/components.d.ts b/web/src/components.d.ts index 7540c81..bb9fedc 100644 --- a/web/src/components.d.ts +++ b/web/src/components.d.ts @@ -16,6 +16,7 @@ declare module 'vue' { ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index e4fd0ac..fec8761 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -9,6 +9,7 @@ import { Reading, Tickets, Calendar, + CircleCheck, User, UserFilled, } from '@element-plus/icons-vue' @@ -19,6 +20,16 @@ const router = useRouter() const auth = useAuthStore() const collapsed = ref(false) const mobileMenu = ref(false) +const workspaceLabel = computed(() => { + const roles = auth.user?.roles ?? [] + if (roles.includes('SuperAdmin')) return '系统全域管理' + if (roles.includes('AcademicAdmin')) return '校级教务管理' + if (roles.includes('CollegeAdmin')) return '学院教务管理' + if (roles.includes('Counselor')) return '辅导员班级工作' + if (roles.includes('Teacher')) return '教师教学工作' + if (roles.includes('Student')) return '学生学业服务' + return '教务工作台' +}) const pageTitle = computed(() => { const titles: Record = { @@ -29,6 +40,7 @@ const pageTitle = computed(() => { curriculum: '培养方案', 'teaching-tasks': '教学任务', schedules: '排课与课表', + 'course-selections': '选课与教学班', users: '用户与权限', } return titles[String(route.name)] ?? '教务管理' @@ -57,7 +69,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
当前工作区 - 校级教务管理 + {{ workspaceLabel }}
auth.refresh().catch(() => undefined)) - + @@ -107,6 +122,13 @@ onMounted(() => auth.refresh().catch(() => undefined)) + + + + @@ -115,7 +137,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
第一阶段 · 核心可用版 -

主数据、培养方案与教学运行已就绪

+

教学运行、排课与学生选课已就绪

diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 7da9a73..ee65261 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -25,6 +25,7 @@ const router = createRouter({ path: 'base-data', name: 'base-data', component: () => import('../views/BaseDataView.vue'), + meta: { roles: ['SuperAdmin', 'AcademicAdmin'] }, }, { path: 'personnel', @@ -57,6 +58,14 @@ const router = createRouter({ component: () => import('../views/SchedulesView.vue'), meta: { roles: ['SuperAdmin', 'AcademicAdmin'] }, }, + { + path: 'course-selections', + name: 'course-selections', + component: () => import('../views/CourseSelectionView.vue'), + meta: { + roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'], + }, + }, { path: 'users', name: 'users', @@ -75,6 +84,15 @@ router.beforeEach((to) => { return { name: 'login', query: { redirect: to.fullPath } } } if (to.name === 'login' && auth.isLoggedIn) return { name: 'dashboard' } + if ( + to.name === 'dashboard' && + auth.user?.roles.includes('Student') && + !auth.user.roles.some((role) => + ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role), + ) + ) { + return { name: 'course-selections' } + } const roles = to.meta.roles as string[] | undefined if (roles && !roles.some((role) => auth.user?.roles.includes(role))) { return { name: 'dashboard' } diff --git a/web/src/style.css b/web/src/style.css index db9d4f0..4887a7b 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -249,6 +249,135 @@ button { cursor: pointer; } .schedule-card > button { position: absolute; right: 4px; top: 3px; border: none; color: #b9c4df; background: transparent; font-size: 14px; } .schedule-card > button:hover { color: white; } +.selection-page { min-width: 0; } +.selection-round-strip { + min-height: 104px; padding: 10px; display: flex; gap: 9px; overflow-x: auto; + border: 1px solid var(--line); background: white; +} +.selection-round-strip > button { + flex: 0 0 285px; min-height: 82px; padding: 13px 15px; display: grid; + grid-template-columns: 1fr auto; gap: 5px 12px; text-align: left; + border: 1px solid var(--line); border-left: 3px solid #bdc3ce; + color: var(--ink); background: #fafbfc; +} +.selection-round-strip > button:hover { background: white; } +.selection-round-strip > button.active { + border-color: var(--indigo); border-left-color: #48c8b7; + color: white; background: linear-gradient(115deg, #1b3067, #294584); +} +.selection-round-strip span, .selection-round-strip small { + overflow: hidden; color: #868f9e; font-size: 9px; white-space: nowrap; text-overflow: ellipsis; +} +.selection-round-strip b { + grid-column: 1 / -1; overflow: hidden; font-family: "STZhongsong", "Songti SC", serif; + font-size: 14px; white-space: nowrap; text-overflow: ellipsis; +} +.selection-round-strip i { + align-self: start; padding: 3px 7px; border-radius: 10px; + color: #6d7583; background: #edf0f3; font-size: 9px; font-style: normal; +} +.selection-round-strip i.open { color: var(--teal); background: #e5f4f1; } +.selection-round-strip > button.active span, +.selection-round-strip > button.active small { color: #c6d0e8; } +.selection-round-strip > button.active i { color: white; background: rgba(255,255,255,.14); } +.selection-window { + min-height: 142px; display: grid; grid-template-columns: 105px minmax(300px, 1fr) auto; + align-items: stretch; color: white; + background: + linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px), + linear-gradient(112deg, #162858, #243d7d 70%, #176b70); + background-size: 28px 28px, 28px 28px, auto; + overflow: hidden; +} +.window-seal { + padding: 20px 12px; display: grid; place-content: center; justify-items: center; gap: 9px; + border-right: 1px solid rgba(255,255,255,.13); color: #57d4c2; +} +.window-seal .el-icon { font-size: 26px; } +.window-seal span { font-size: 10px; font-weight: 700; letter-spacing: .08em; } +.window-copy { padding: 24px 27px; align-self: center; min-width: 0; } +.window-copy > span { color: #5ed4c4; font: 700 9px/1 Consolas, monospace; letter-spacing: .15em; } +.window-copy h3 { + margin: 9px 0 8px; overflow: hidden; font-family: "STZhongsong", "Songti SC", serif; + font-size: 22px; font-weight: 500; letter-spacing: .03em; white-space: nowrap; text-overflow: ellipsis; +} +.window-copy p { margin: 0; color: #c4cee8; font-size: 10px; } +.window-copy em { + margin-left: 13px; padding-left: 13px; border-left: 1px solid rgba(255,255,255,.2); + color: #edcf9a; font-style: normal; +} +.credit-meter { + width: 310px; padding: 22px 28px; align-self: center; + border-left: 1px solid rgba(255,255,255,.13); +} +.credit-meter > div:first-child { display: flex; align-items: baseline; gap: 7px; } +.credit-meter span, .credit-meter small { color: #c2cce5; font-size: 10px; } +.credit-meter b { margin-left: auto; color: #5bd4c3; font: 700 34px/1 Consolas, monospace; } +.credit-track { height: 5px; margin-top: 12px; overflow: hidden; background: rgba(255,255,255,.15); } +.credit-track i { height: 100%; display: block; background: linear-gradient(90deg, #49c9b7, #e3ad54); } +.credit-meter p { margin: 9px 0 0; color: #aeb9d8; font-size: 9px; } +.round-actions { + width: 285px; padding: 23px; display: flex; flex-wrap: wrap; align-content: center; + justify-content: flex-end; gap: 8px; border-left: 1px solid rgba(255,255,255,.13); +} +.round-actions .el-button + .el-button { margin-left: 0; } +.selection-ledger-head { + min-height: 95px; padding: 18px 21px; display: flex; align-items: center; + justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line); +} +.selection-ledger-head span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .14em; } +.selection-ledger-head h3 { margin: 7px 0 4px; font-family: "STZhongsong", "Songti SC", serif; font-size: 19px; } +.selection-ledger-head p { margin: 0; color: var(--muted); font-size: 10px; } +.capacity-number { color: var(--indigo); font: 700 13px/1 Consolas, monospace; } +.offering-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; } +.offering-ticket { + min-width: 0; min-height: 250px; display: flex; flex-direction: column; + border: 1px solid var(--line); background: white; box-shadow: 0 6px 18px rgba(24,36,68,.045); +} +.offering-ticket > header { + min-height: 99px; padding: 19px 20px; display: flex; justify-content: space-between; + gap: 16px; border-bottom: 1px dashed #d7dce4; position: relative; +} +.offering-ticket > header::before, +.offering-ticket > header::after { + content: ""; position: absolute; bottom: -7px; width: 12px; height: 12px; + border-radius: 50%; background: var(--soft); +} +.offering-ticket > header::before { left: -7px; } +.offering-ticket > header::after { right: -7px; } +.offering-ticket header span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .08em; } +.offering-ticket h3 { + margin: 8px 0 6px; font-family: "STZhongsong", "Songti SC", serif; + font-size: 20px; font-weight: 600; +} +.offering-ticket header p { margin: 0; color: var(--muted); font-size: 11px; } +.selected-mark { + flex: 0 0 auto; height: fit-content; padding: 5px 9px; display: flex; align-items: center; gap: 4px; + border-radius: 13px; color: var(--teal); background: #e8f5f2; font-size: 10px; font-style: normal; +} +.ticket-schedules { min-height: 85px; padding: 14px 20px; display: grid; align-content: center; gap: 8px; } +.ticket-schedules > div { display: flex; align-items: flex-start; gap: 7px; color: #4e596d; font-size: 10px; line-height: 1.5; } +.ticket-schedules .el-icon { flex: 0 0 auto; margin-top: 1px; color: var(--indigo); } +.schedule-missing { color: #a36d22; font-size: 10px; } +.offering-ticket > footer { + margin-top: auto; min-height: 65px; padding: 13px 20px; display: flex; align-items: center; + gap: 18px; background: #fafbfc; border-top: 1px solid #edf0f4; +} +.seat-meter { flex: 1; min-width: 0; } +.seat-meter > span { display: block; margin-bottom: 7px; color: var(--muted); font-size: 9px; } +.seat-meter > div { height: 4px; overflow: hidden; background: #e2e6ec; } +.seat-meter i { height: 100%; display: block; background: var(--teal); } +.roster-summary { + margin: 0 0 18px; padding: 16px; display: flex; align-items: center; gap: 14px; + color: white; background: linear-gradient(115deg, #1b3067, #294584); +} +.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; } + .login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; } .login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; } .login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; } @@ -339,6 +468,13 @@ button { cursor: pointer; } .schedule-search { flex-wrap: wrap; } .schedule-search .el-input { width: 100%; } .schedule-search > span { width: 100%; margin-left: 0; } + .selection-window { grid-template-columns: 82px 1fr; } + .credit-meter, .round-actions { + grid-column: 1 / -1; width: auto; border-top: 1px solid rgba(255,255,255,.13); + border-left: none; + } + .round-actions { justify-content: flex-start; } + .offering-grid { grid-template-columns: 1fr; } .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; } @@ -350,6 +486,21 @@ button { cursor: pointer; } .login-panel { padding: 32px 22px; background: white; } } +@media (max-width: 600px) { + .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); } + .window-seal .el-icon { font-size: 18px; } + .window-copy { padding: 20px 18px; } + .window-copy h3 { white-space: normal; } + .window-copy em { display: block; margin: 7px 0 0; padding: 0; border: none; } + .credit-meter, .round-actions { padding: 18px; } + .selection-ledger-head { align-items: flex-start; flex-direction: column; } + .offering-ticket > footer { align-items: stretch; flex-direction: column; } + .offering-ticket > footer .el-button { width: 100%; } + .el-drawer { width: 100% !important; } +} + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } } diff --git a/web/src/views/CourseSelectionView.vue b/web/src/views/CourseSelectionView.vue new file mode 100644 index 0000000..045a4b1 --- /dev/null +++ b/web/src/views/CourseSelectionView.vue @@ -0,0 +1,628 @@ + + + diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index cf900ad..4c41cbb 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { Collection, OfficeBuilding, School, UserFilled } from '@element-plus/icons-vue' import http from '../api/http' +import { useAuthStore } from '../stores/auth' interface DashboardData { currentTerm?: { name: string; startDate: string; endDate: string } @@ -10,6 +11,7 @@ interface DashboardData { } const router = useRouter() +const auth = useAuthStore() const loading = ref(true) const data = ref({ counts: {} }) @@ -40,7 +42,11 @@ onMounted(async () => {

请先在基础数据中建立学期档案。

- @@ -101,18 +107,24 @@ onMounted(async () => { {{ data.counts.schedulePlans ?? 0 }} 个版本 · 冲突校验与发布 已建立 +
+ 学生选课 + {{ data.counts.courseSelectionRounds ?? 0 }} 个批次 · {{ data.counts.courseEnrollments ?? 0 }} 条有效选课 + 已建立 +
NEXT MILESTONE

下一段业务链

-

排课与课表发布链路已就绪,下一步进入学生选课轮次、容量与候补管理。

+

选课批次、容量校验和教学班名单已就绪,下一步进入成绩录入、审核与学业档案。

基础底座 人员档案 培养方案 排课课表 + 学生选课