From 7f3e37de2038088923ef5af4bdda67f77a46bf2a Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 24 Jul 2026 15:43:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B2=E5=AE=8C=E6=88=90=E2=80=9C=E8=80=83?= =?UTF-8?q?=E8=AF=95=E5=AE=89=E6=8E=92=E4=B8=8E=E8=80=83=E5=9C=BA=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E2=80=9D=E6=A8=A1=E5=9D=97=EF=BC=9A=20=E8=80=83?= =?UTF-8?q?=E8=AF=95=E8=AE=A1=E5=88=92=E8=8D=89=E7=A8=BF=E3=80=81=E5=9C=BA?= =?UTF-8?q?=E6=AC=A1=E9=85=8D=E7=BD=AE=E4=B8=8E=E6=AD=A3=E5=BC=8F=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E3=80=82=20=E6=95=99=E5=AD=A6=E7=8F=AD=E3=80=81?= =?UTF-8?q?=E8=80=83=E8=AF=95=E6=97=B6=E9=97=B4=E3=80=81=E8=80=83=E5=9C=BA?= =?UTF-8?q?=E3=80=81=E7=9B=91=E8=80=83=E6=95=99=E5=B8=88=E5=85=B3=E8=81=94?= =?UTF-8?q?=E3=80=82=20=E8=87=AA=E5=8A=A8=E6=A0=A1=E9=AA=8C=E8=80=83?= =?UTF-8?q?=E5=9C=BA=E5=AE=B9=E9=87=8F=E3=80=82=20=E9=98=BB=E6=AD=A2?= =?UTF-8?q?=E8=80=83=E5=9C=BA=E3=80=81=E7=9B=91=E8=80=83=E6=95=99=E5=B8=88?= =?UTF-8?q?=E5=92=8C=E5=AD=A6=E7=94=9F=E8=80=83=E8=AF=95=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E3=80=82=20=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E8=80=83=E7=94=9F=E5=90=8D=E5=8D=95=E3=80=82?= =?UTF-8?q?=20=E6=95=99=E5=B8=88=E6=9F=A5=E7=9C=8B=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E7=9B=91=E8=80=83=E5=AE=89=E6=8E=92=E3=80=82=20=E5=AD=A6?= =?UTF-8?q?=E7=94=9F=E6=9F=A5=E7=9C=8B=E5=B7=B2=E5=8F=91=E5=B8=83=E8=80=83?= =?UTF-8?q?=E8=AF=95=E6=97=A5=E7=A8=8B=E3=80=82=20SQLite=20=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=E5=8D=87=E7=BA=A7=E5=92=8C=20MySQL=20=E6=AD=A3?= =?UTF-8?q?=E5=BC=8F=E8=BF=81=E7=A7=BB=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- scripts/smoke-test.ps1 | 20 + .../Controllers/DashboardController.cs | 2 + src/Jiaowu.Api/Controllers/ExamsController.cs | 307 +++ .../Domain/Academic/ExamEntities.cs | 43 + .../Infrastructure/Exams/ExamConflictRules.cs | 11 + .../Persistence/AppDbContext.cs | 34 + .../Persistence/DatabaseInitializer.cs | 33 + .../Persistence/DevelopmentSqliteMigrator.cs | 45 + .../20260724073707_ExamManagement.Designer.cs | 1968 +++++++++++++++++ .../MySql/20260724073707_ExamManagement.cs | 141 ++ .../MySql/AppDbContextModelSnapshot.cs | 161 ++ .../ExamConflictRulesTests.cs | 22 + web/src/layouts/AdminLayout.vue | 9 + web/src/router/index.ts | 6 + web/src/style.css | 44 + web/src/views/DashboardView.vue | 8 +- web/src/views/ExamsView.vue | 203 ++ 18 files changed, 3057 insertions(+), 2 deletions(-) create mode 100644 src/Jiaowu.Api/Controllers/ExamsController.cs create mode 100644 src/Jiaowu.Api/Domain/Academic/ExamEntities.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Exams/ExamConflictRules.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724073707_ExamManagement.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724073707_ExamManagement.cs create mode 100644 tests/Jiaowu.Api.Tests/ExamConflictRulesTests.cs create mode 100644 web/src/views/ExamsView.vue diff --git a/README.md b/README.md index 7f4a3b1..5941a07 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 365f41a..accd849 100644 --- a/scripts/smoke-test.ps1 +++ b/scripts/smoke-test.ps1 @@ -309,6 +309,24 @@ try { if (@($studentTranscript.records).Count -lt 1) { throw 'Published grade is not visible in the student transcript.' } + $examPlans = Invoke-RestMethod -Uri 'http://localhost:5255/api/exams/plans' -Headers $headers + if (@($examPlans).Count -lt 1) { throw 'Development exam plan was not seeded.' } + $examDetail = Invoke-RestMethod ` + -Uri "http://localhost:5255/api/exams/plans/$($examPlans[0].id)" ` + -Headers $headers + if (@($examDetail.sessions).Count -lt 1) { throw 'Exam plan has no sessions.' } + $examRoster = Invoke-RestMethod ` + -Uri "http://localhost:5255/api/exams/sessions/$($examDetail.sessions[0].id)/roster" ` + -Headers $headers + $studentExams = Invoke-RestMethod ` + -Uri 'http://localhost:5255/api/exams/my-schedule' ` + -Headers $studentHeaders + $teacherExams = Invoke-RestMethod ` + -Uri 'http://localhost:5255/api/exams/my-schedule' ` + -Headers $teacherHeaders + if (@($studentExams).Count -lt 1 -or @($teacherExams).Count -lt 1) { + throw 'Personal exam schedule is not visible to student or invigilator.' + } $frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5 $spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5 @@ -341,6 +359,8 @@ try { GradeSheets = @($gradeTasks).Count GradeRecords = @($gradeDetail.sheet.records).Count TranscriptRecords = @($studentTranscript.records).Count + ExamSessions = @($examDetail.sessions).Count + ExamRoster = @($examRoster.students).Count AccessUpdate = $true ScopeChecks = $scopeChecks -join ', ' StaticIndex = $frontend.Content.Contains('明序教务管理系统') diff --git a/src/Jiaowu.Api/Controllers/DashboardController.cs b/src/Jiaowu.Api/Controllers/DashboardController.cs index 19d0176..27b44c0 100644 --- a/src/Jiaowu.Api/Controllers/DashboardController.cs +++ b/src/Jiaowu.Api/Controllers/DashboardController.cs @@ -49,6 +49,8 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase x => x.Status == GradeSheetStatus.Published, cancellationToken), GradeRecords = await db.GradeRecords.CountAsync(cancellationToken), + ExamPlans = await db.ExamPlans.CountAsync(cancellationToken), + ExamSessions = await db.ExamSessions.CountAsync(cancellationToken), Users = await db.Users.CountAsync(cancellationToken) } }; diff --git a/src/Jiaowu.Api/Controllers/ExamsController.cs b/src/Jiaowu.Api/Controllers/ExamsController.cs new file mode 100644 index 0000000..4ff3eff --- /dev/null +++ b/src/Jiaowu.Api/Controllers/ExamsController.cs @@ -0,0 +1,307 @@ +using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/exams")] +public sealed class ExamsController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase +{ + private const string Managers = + SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; + + [HttpGet("plans")] + public async Task GetPlans( + Guid? academicTermId, + CancellationToken cancellationToken) + { + var source = db.ExamPlans.AsNoTracking().AsQueryable(); + if (academicTermId.HasValue) + source = source.Where(x => x.AcademicTermId == academicTermId); + if (!IsManager()) + source = source.Where(x => x.Status == ExamPlanStatus.Published); + return Ok(await source.OrderByDescending(x => x.AcademicTerm!.StartDate) + .ThenByDescending(x => x.CreatedAt) + .Select(x => new + { + x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, + x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt + }).ToListAsync(cancellationToken)); + } + + [HttpPost("plans")] + [Authorize(Roles = Managers)] + public async Task CreatePlan( + ExamPlanRequest request, + CancellationToken cancellationToken) + { + if (!await db.AcademicTerms.AnyAsync( + x => x.Id == request.AcademicTermId && x.IsEnabled, + cancellationToken)) + return ValidationProblem("所选学期不存在或已停用。"); + var plan = new ExamPlan + { + AcademicTermId = request.AcademicTermId, + Name = request.Name.Trim(), + Notes = Normalize(request.Notes) + }; + db.ExamPlans.Add(plan); + return await SaveAsync(plan.Id, true, cancellationToken); + } + + [HttpGet("plans/{id:guid}")] + public async Task GetPlan(Guid id, CancellationToken cancellationToken) + { + var manager = IsManager(); + var plan = await db.ExamPlans.AsNoTracking() + .Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published)) + .Select(x => new + { + x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, + x.Status, x.Notes, x.PublishedAt, + Sessions = x.Sessions.OrderBy(item => item.StartsAt).Select(item => new + { + item.Id, item.TeachingTaskId, item.TeachingTask!.TaskNumber, + TaskName = item.TeachingTask.Name, + CourseCode = item.TeachingTask.Course!.Code, + CourseName = item.TeachingTask.Course.Name, + item.ClassroomId, ClassroomName = item.Classroom!.Name, + BuildingName = item.Classroom.Building!.Name, + ClassroomCapacity = item.Classroom.Capacity, + item.StartsAt, item.EndsAt, item.Notes, + InvigilatorIds = item.Invigilators.Select(i => i.TeacherId), + InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name), + StudentCount = db.CourseEnrollments.Count(e => + e.Status == CourseEnrollmentStatus.Enrolled && + e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId) + }) + }).FirstOrDefaultAsync(cancellationToken); + return plan is null ? NotFound() : Ok(plan); + } + + [HttpPost("plans/{planId:guid}/sessions")] + [Authorize(Roles = Managers)] + public async Task CreateSession( + Guid planId, + ExamSessionRequest request, + CancellationToken cancellationToken) + { + var plan = await db.ExamPlans.FindAsync([planId], cancellationToken); + if (plan is null) return NotFound(); + if (plan.Status != ExamPlanStatus.Draft) + return ConflictProblem("已发布的考试计划不能调整场次。"); + var validation = await ValidateSessionAsync(plan, null, request, cancellationToken); + if (validation is not null) return validation; + var session = new ExamSession + { + ExamPlanId = planId, + TeachingTaskId = request.TeachingTaskId, + ClassroomId = request.ClassroomId, + StartsAt = request.StartsAt.ToUniversalTime(), + EndsAt = request.EndsAt.ToUniversalTime(), + Notes = Normalize(request.Notes), + Invigilators = request.InvigilatorIds.Distinct().Select(id => + new ExamSessionInvigilator { TeacherId = id }).ToList() + }; + db.ExamSessions.Add(session); + return await SaveAsync(session.Id, true, cancellationToken); + } + + [HttpDelete("plans/{planId:guid}/sessions/{id:guid}")] + [Authorize(Roles = Managers)] + public async Task DeleteSession( + Guid planId, + Guid id, + CancellationToken cancellationToken) + { + var session = await db.ExamSessions.Include(x => x.ExamPlan) + .FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken); + if (session is null) return NotFound(); + if (session.ExamPlan!.Status != ExamPlanStatus.Draft) + return ConflictProblem("已发布的考试计划不能调整场次。"); + db.ExamSessions.Remove(session); + return await SaveAsync(id, false, cancellationToken); + } + + [HttpPost("plans/{id:guid}/publish")] + [Authorize(Roles = Managers)] + public async Task Publish(Guid id, CancellationToken cancellationToken) + { + var plan = await db.ExamPlans.Include(x => x.Sessions) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (plan is null) return NotFound(); + if (plan.Status != ExamPlanStatus.Draft) + return ConflictProblem("只有草稿考试计划可以发布。"); + if (plan.Sessions.Count == 0) + return ConflictProblem("至少安排一个考试场次后才能发布。"); + plan.Status = ExamPlanStatus.Published; + plan.PublishedAt = DateTime.UtcNow; + return await SaveAsync(id, false, cancellationToken); + } + + [HttpGet("sessions/{id:guid}/roster")] + [Authorize(Roles = Managers)] + public async Task GetRoster(Guid id, CancellationToken cancellationToken) + { + var session = await db.ExamSessions.AsNoTracking() + .Where(x => x.Id == id) + .Select(x => new + { + x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, + CourseName = x.TeachingTask.Course!.Name, + ClassroomName = x.Classroom!.Name, x.StartsAt, + Students = db.CourseEnrollments + .Where(e => e.Status == CourseEnrollmentStatus.Enrolled && + e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId) + .OrderBy(e => e.Student!.StudentNumber) + .Select(e => new + { + e.StudentId, e.Student!.StudentNumber, e.Student.Name, + ClassName = e.Student.AdministrativeClass!.Name + }).ToList() + }).FirstOrDefaultAsync(cancellationToken); + return session is null ? NotFound() : Ok(session); + } + + [HttpGet("my-schedule")] + public async Task GetMySchedule(CancellationToken cancellationToken) + { + var scope = currentUserDataScope.Current; + if (scope.IsInRole(SystemRoles.Student)) + { + var studentId = await db.Students.Where(x => x.UserId == scope.UserId) + .Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken); + if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); + return Ok(await db.ExamSessions.AsNoTracking() + .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && + db.CourseEnrollments.Any(e => e.StudentId == studentId && + e.Status == CourseEnrollmentStatus.Enrolled && + e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)) + .OrderBy(x => x.StartsAt).Select(x => new + { + x.Id, PlanName = x.ExamPlan!.Name, x.StartsAt, x.EndsAt, + x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code, + CourseName = x.TeachingTask.Course.Name, + ClassroomName = x.Classroom!.Name, + BuildingName = x.Classroom.Building!.Name, + InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name) + }) + .ToListAsync(cancellationToken)); + } + if (scope.IsInRole(SystemRoles.Teacher)) + { + return Ok(await db.ExamSessions.AsNoTracking() + .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && + x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId)) + .OrderBy(x => x.StartsAt).Select(x => new + { + x.Id, PlanName = x.ExamPlan!.Name, x.StartsAt, x.EndsAt, + x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code, + CourseName = x.TeachingTask.Course.Name, + ClassroomName = x.Classroom!.Name, + BuildingName = x.Classroom.Building!.Name, + InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name), + StudentCount = db.CourseEnrollments.Count(e => + e.Status == CourseEnrollmentStatus.Enrolled && + e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId) + }) + .ToListAsync(cancellationToken)); + } + return Ok(Array.Empty()); + } + + private async Task ValidateSessionAsync( + ExamPlan plan, + Guid? currentId, + ExamSessionRequest request, + CancellationToken cancellationToken) + { + var starts = request.StartsAt.ToUniversalTime(); + var ends = request.EndsAt.ToUniversalTime(); + if (starts >= ends) return ValidationProblem("考试开始时间必须早于结束时间。"); + var task = await db.TeachingTasks.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); + if (task is null || task.AcademicTermId != plan.AcademicTermId) + return ValidationProblem("教学班与考试计划必须属于同一学期。"); + var room = await db.Classrooms.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.ClassroomId && x.IsEnabled, cancellationToken); + if (room is null) return ValidationProblem("所选考场不存在或已停用。"); + var studentCount = await db.CourseEnrollments.CountAsync(x => + x.Status == CourseEnrollmentStatus.Enrolled && + x.CourseSelectionOffering!.TeachingTaskId == task.Id, cancellationToken); + if (studentCount > room.Capacity) + return ConflictProblem($"考场容量不足:需容纳 {studentCount} 人,教室容量为 {room.Capacity}。"); + var teacherIds = request.InvigilatorIds.Distinct().ToArray(); + if (teacherIds.Length == 0) return ValidationProblem("至少安排一名监考教师。"); + if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) && + x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) + return ValidationProblem("存在无效监考教师。"); + + var overlaps = db.ExamSessions.Where(x => x.ExamPlanId == plan.Id && + x.Id != currentId && x.StartsAt < ends && starts < x.EndsAt); + if (await overlaps.AnyAsync(x => x.ClassroomId == room.Id, cancellationToken)) + return ConflictProblem("该时段考场已被占用。"); + if (await overlaps.AnyAsync(x => + x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), cancellationToken)) + return ConflictProblem("监考教师在该时段已有考试任务。"); + var studentIds = db.CourseEnrollments.Where(x => + x.Status == CourseEnrollmentStatus.Enrolled && + x.CourseSelectionOffering!.TeachingTaskId == task.Id) + .Select(x => x.StudentId); + if (await overlaps.AnyAsync(other => + db.CourseEnrollments.Any(e => + studentIds.Contains(e.StudentId) && + e.Status == CourseEnrollmentStatus.Enrolled && + e.CourseSelectionOffering!.TeachingTaskId == other.TeachingTaskId), + cancellationToken)) + return ConflictProblem("存在学生考试时间冲突。"); + return null; + } + + private bool IsManager() => + currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || + currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); + + private async Task SaveAsync(Guid id, bool created, CancellationToken token) + { + try + { + await db.SaveChangesAsync(token); + return created ? Created(string.Empty, new { id }) : NoContent(); + } + catch (DbUpdateException) + { + return 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 ExamPlanRequest( + Guid AcademicTermId, + [Required, MaxLength(120)] string Name, + [MaxLength(500)] string? Notes); + +public sealed record ExamSessionRequest( + Guid TeachingTaskId, + Guid ClassroomId, + DateTime StartsAt, + DateTime EndsAt, + IReadOnlyCollection InvigilatorIds, + [MaxLength(500)] string? Notes); diff --git a/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs b/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs new file mode 100644 index 0000000..4df4e72 --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs @@ -0,0 +1,43 @@ +using Jiaowu.Api.Domain.Common; + +namespace Jiaowu.Api.Domain.Academic; + +public sealed class ExamPlan : EntityBase +{ + public Guid AcademicTermId { get; set; } + public AcademicTerm? AcademicTerm { get; set; } + public required string Name { get; set; } + public ExamPlanStatus Status { get; set; } = ExamPlanStatus.Draft; + public string? Notes { get; set; } + public DateTime? PublishedAt { get; set; } + public ICollection Sessions { get; set; } = []; +} + +public sealed class ExamSession : EntityBase +{ + public Guid ExamPlanId { get; set; } + public ExamPlan? ExamPlan { get; set; } + public Guid TeachingTaskId { get; set; } + public TeachingTask? TeachingTask { get; set; } + public Guid ClassroomId { get; set; } + public Classroom? Classroom { get; set; } + public DateTime StartsAt { get; set; } + public DateTime EndsAt { get; set; } + public string? Notes { get; set; } + public ICollection Invigilators { get; set; } = []; +} + +public sealed class ExamSessionInvigilator +{ + public Guid ExamSessionId { get; set; } + public ExamSession? ExamSession { get; set; } + public Guid TeacherId { get; set; } + public Teacher? Teacher { get; set; } +} + +public enum ExamPlanStatus +{ + Draft = 1, + Published = 2, + Archived = 3 +} diff --git a/src/Jiaowu.Api/Infrastructure/Exams/ExamConflictRules.cs b/src/Jiaowu.Api/Infrastructure/Exams/ExamConflictRules.cs new file mode 100644 index 0000000..c399008 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Exams/ExamConflictRules.cs @@ -0,0 +1,11 @@ +namespace Jiaowu.Api.Infrastructure.Exams; + +public static class ExamConflictRules +{ + public static bool TimeOverlaps( + DateTime firstStart, + DateTime firstEnd, + DateTime secondStart, + DateTime secondEnd) => + firstStart < secondEnd && secondStart < firstEnd; +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 3c89de8..420e087 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -35,6 +35,10 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet CourseEnrollments => Set(); public DbSet GradeSheets => Set(); public DbSet GradeRecords => Set(); + public DbSet ExamPlans => Set(); + public DbSet ExamSessions => Set(); + public DbSet ExamSessionInvigilators => + Set(); public DbSet AuditLogs => Set(); protected override void OnModelCreating(ModelBuilder builder) @@ -353,6 +357,36 @@ public sealed class AppDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.Property(x => x.Name).HasMaxLength(120); + 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.ExamPlanId, x.StartsAt }); + entity.HasIndex(x => x.TeachingTaskId); + entity.HasIndex(x => x.ClassroomId); + entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions) + .HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.TeachingTask).WithMany() + .HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.Classroom).WithMany() + .HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict); + }); + builder.Entity(entity => + { + entity.HasKey(x => new { x.ExamSessionId, x.TeacherId }); + entity.HasOne(x => x.ExamSession).WithMany(x => x.Invigilators) + .HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Teacher).WithMany() + .HasForeignKey(x => x.TeacherId).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 1dad956..c581e42 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs @@ -423,6 +423,7 @@ public sealed class DatabaseInitializer( await SeedDevelopmentUsersAsync(computerCollege.Id); await SeedDevelopmentCourseSelectionAsync(); await SeedDevelopmentGradesAsync(); + await SeedDevelopmentExamsAsync(); } private async Task SeedDevelopmentCourseSelectionAsync() @@ -506,6 +507,38 @@ public sealed class DatabaseInitializer( await db.SaveChangesAsync(); } + private async Task SeedDevelopmentExamsAsync() + { + if (await db.ExamPlans.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 room = await db.Classrooms.SingleAsync(x => x.Code == "J1-201"); + var teacher = await db.Teachers.SingleAsync(x => x.TeacherNumber == "T2026001"); + db.ExamPlans.Add(new ExamPlan + { + AcademicTermId = term.Id, + Name = "2026—2027 学年第一学期期末考试", + Status = ExamPlanStatus.Published, + PublishedAt = DateTime.UtcNow, + Notes = "本地开发演示考试计划。", + Sessions = + [ + new ExamSession + { + TeachingTaskId = task.Id, + ClassroomId = room.Id, + StartsAt = new DateTime(2027, 1, 8, 9, 0, 0, DateTimeKind.Utc), + EndsAt = new DateTime(2027, 1, 8, 11, 0, 0, DateTimeKind.Utc), + Invigilators = + [ + new ExamSessionInvigilator { TeacherId = teacher.Id } + ] + } + ] + }); + await db.SaveChangesAsync(); + } + private async Task SeedDevelopmentUsersAsync(Guid collegeId) { var definitions = new[] diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index d77fbf1..90dd503 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -13,6 +13,7 @@ public sealed class DevelopmentSqliteMigrator( private const string ClassCounselorMigration = "20260724_05_class_counselor"; private const string CourseSelectionMigration = "20260724_06_course_selection"; private const string GradesMigration = "20260724_07_grades"; + private const string ExamsMigration = "20260724_08_exams"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -68,6 +69,7 @@ public sealed class DevelopmentSqliteMigrator( GradesMigration, GradesStatements, cancellationToken); + await ApplyMigrationAsync(ExamsMigration, ExamsStatements, cancellationToken); } private async Task ApplyMigrationAsync( @@ -569,4 +571,47 @@ public sealed class DevelopmentSqliteMigrator( ON "GradeRecords" ("StudentId", "TotalScore"); """ ]; + + private static readonly string[] ExamsStatements = + [ + """ + CREATE TABLE IF NOT EXISTS "ExamPlans" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ExamPlans" PRIMARY KEY, + "AcademicTermId" TEXT NOT NULL, "Name" TEXT NOT NULL, + "Status" INTEGER NOT NULL, "Notes" TEXT NULL, "PublishedAt" TEXT NULL, + "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_ExamPlans_AcademicTerms_AcademicTermId" + FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT + ); + """, + """CREATE INDEX IF NOT EXISTS "IX_ExamPlans_AcademicTermId_Status" ON "ExamPlans" ("AcademicTermId", "Status");""", + """ + CREATE TABLE IF NOT EXISTS "ExamSessions" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ExamSessions" PRIMARY KEY, + "ExamPlanId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, + "ClassroomId" TEXT NOT NULL, "StartsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL, + "Notes" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_ExamSessions_ExamPlans_ExamPlanId" FOREIGN KEY ("ExamPlanId") + REFERENCES "ExamPlans" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId") + REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_ExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId") + REFERENCES "Classrooms" ("Id") ON DELETE RESTRICT + ); + """, + """CREATE INDEX IF NOT EXISTS "IX_ExamSessions_ExamPlanId_StartsAt" ON "ExamSessions" ("ExamPlanId", "StartsAt");""", + """CREATE INDEX IF NOT EXISTS "IX_ExamSessions_TeachingTaskId" ON "ExamSessions" ("TeachingTaskId");""", + """CREATE INDEX IF NOT EXISTS "IX_ExamSessions_ClassroomId" ON "ExamSessions" ("ClassroomId");""", + """ + CREATE TABLE IF NOT EXISTS "ExamSessionInvigilators" ( + "ExamSessionId" TEXT NOT NULL, "TeacherId" TEXT NOT NULL, + CONSTRAINT "PK_ExamSessionInvigilators" PRIMARY KEY ("ExamSessionId", "TeacherId"), + CONSTRAINT "FK_ExamSessionInvigilators_ExamSessions_ExamSessionId" + FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ExamSessionInvigilators_Teachers_TeacherId" + FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id") ON DELETE RESTRICT + ); + """, + """CREATE INDEX IF NOT EXISTS "IX_ExamSessionInvigilators_TeacherId" ON "ExamSessionInvigilators" ("TeacherId");""" + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724073707_ExamManagement.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724073707_ExamManagement.Designer.cs new file mode 100644 index 0000000..8b5fdd0 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724073707_ExamManagement.Designer.cs @@ -0,0 +1,1968 @@ +// +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("20260724073707_ExamManagement")] + partial class ExamManagement + { + /// + 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.ExamPlan", 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.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("ExamPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamPlanId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("ExamPlanId", "StartsAt"); + + b.ToTable("ExamSessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.Property("ExamSessionId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("ExamSessionId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("ExamSessionInvigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamStatus") + .HasColumnType("int"); + + b.Property("FinalScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("GradePoint") + .HasPrecision(3, 1) + .HasColumnType("decimal(3,1)"); + + b.Property("GradeSheetId") + .HasColumnType("char(36)"); + + b.Property("MidtermScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("Notes") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RegularScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("TotalScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GradeSheetId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "TotalScore"); + + b.ToTable("GradeRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FinalWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("MidtermWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("RegularWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TeachingTaskId") + .IsUnique(); + + b.ToTable("GradeSheets"); + }); + + 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.ExamPlan", 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.ExamSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan") + .WithMany("Sessions") + .HasForeignKey("ExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("ExamPlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession") + .WithMany("Invigilators") + .HasForeignKey("ExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamSession"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet") + .WithMany("Records") + .HasForeignKey("GradeSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GradeSheet"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("TeachingTask"); + }); + + 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.ExamPlan", b => + { + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Navigation("Invigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Navigation("Records"); + }); + + 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/20260724073707_ExamManagement.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724073707_ExamManagement.cs new file mode 100644 index 0000000..ffd49a2 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724073707_ExamManagement.cs @@ -0,0 +1,141 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class ExamManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ExamPlans", + 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), + Status = table.Column(type: "int", nullable: false), + Notes = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + PublishedAt = 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_ExamPlans", x => x.Id); + table.ForeignKey( + name: "FK_ExamPlans_AcademicTerms_AcademicTermId", + column: x => x.AcademicTermId, + principalTable: "AcademicTerms", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ExamSessions", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + ExamPlanId = table.Column(type: "char(36)", nullable: false), + TeachingTaskId = table.Column(type: "char(36)", nullable: false), + ClassroomId = table.Column(type: "char(36)", nullable: false), + StartsAt = table.Column(type: "datetime(6)", nullable: false), + EndsAt = table.Column(type: "datetime(6)", 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_ExamSessions", x => x.Id); + table.ForeignKey( + name: "FK_ExamSessions_Classrooms_ClassroomId", + column: x => x.ClassroomId, + principalTable: "Classrooms", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ExamSessions_ExamPlans_ExamPlanId", + column: x => x.ExamPlanId, + principalTable: "ExamPlans", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ExamSessions_TeachingTasks_TeachingTaskId", + column: x => x.TeachingTaskId, + principalTable: "TeachingTasks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ExamSessionInvigilators", + columns: table => new + { + ExamSessionId = table.Column(type: "char(36)", nullable: false), + TeacherId = table.Column(type: "char(36)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExamSessionInvigilators", x => new { x.ExamSessionId, x.TeacherId }); + table.ForeignKey( + name: "FK_ExamSessionInvigilators_ExamSessions_ExamSessionId", + column: x => x.ExamSessionId, + principalTable: "ExamSessions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ExamSessionInvigilators_Teachers_TeacherId", + column: x => x.TeacherId, + principalTable: "Teachers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_ExamPlans_AcademicTermId_Status", + table: "ExamPlans", + columns: new[] { "AcademicTermId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_ExamSessionInvigilators_TeacherId", + table: "ExamSessionInvigilators", + column: "TeacherId"); + + migrationBuilder.CreateIndex( + name: "IX_ExamSessions_ClassroomId", + table: "ExamSessions", + column: "ClassroomId"); + + migrationBuilder.CreateIndex( + name: "IX_ExamSessions_ExamPlanId_StartsAt", + table: "ExamSessions", + columns: new[] { "ExamPlanId", "StartsAt" }); + + migrationBuilder.CreateIndex( + name: "IX_ExamSessions_TeachingTaskId", + table: "ExamSessions", + column: "TeachingTaskId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ExamSessionInvigilators"); + + migrationBuilder.DropTable( + name: "ExamSessions"); + + migrationBuilder.DropTable( + name: "ExamPlans"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index 53120c4..62e33d0 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -634,6 +634,100 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.ToTable("CurriculumPlans"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", 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.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("ExamPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamPlanId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("ExamPlanId", "StartsAt"); + + b.ToTable("ExamSessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.Property("ExamSessionId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("ExamSessionId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("ExamSessionInvigilators"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => { b.Property("Id") @@ -1538,6 +1632,63 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("Major"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", 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.ExamSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan") + .WithMany("Sessions") + .HasForeignKey("ExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("ExamPlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession") + .WithMany("Invigilators") + .HasForeignKey("ExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamSession"); + + b.Navigation("Teacher"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => { b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet") @@ -1782,6 +1933,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("Modules"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Navigation("Invigilators"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => { b.Navigation("Records"); diff --git a/tests/Jiaowu.Api.Tests/ExamConflictRulesTests.cs b/tests/Jiaowu.Api.Tests/ExamConflictRulesTests.cs new file mode 100644 index 0000000..fd75b91 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/ExamConflictRulesTests.cs @@ -0,0 +1,22 @@ +using Jiaowu.Api.Infrastructure.Exams; + +namespace Jiaowu.Api.Tests; + +public sealed class ExamConflictRulesTests +{ + [Fact] + public void Overlapping_exam_times_conflict() => + Assert.True(ExamConflictRules.TimeOverlaps( + new DateTime(2027, 1, 8, 9, 0, 0), + new DateTime(2027, 1, 8, 11, 0, 0), + new DateTime(2027, 1, 8, 10, 30, 0), + new DateTime(2027, 1, 8, 12, 0, 0))); + + [Fact] + public void Adjacent_exam_times_do_not_conflict() => + Assert.False(ExamConflictRules.TimeOverlaps( + new DateTime(2027, 1, 8, 9, 0, 0), + new DateTime(2027, 1, 8, 11, 0, 0), + new DateTime(2027, 1, 8, 11, 0, 0), + new DateTime(2027, 1, 8, 13, 0, 0))); +} diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 1777978..2d8f641 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -11,6 +11,7 @@ import { Calendar, CircleCheck, DocumentChecked, + AlarmClock, User, UserFilled, } from '@element-plus/icons-vue' @@ -43,6 +44,7 @@ const pageTitle = computed(() => { schedules: '排课与课表', 'course-selections': '选课与教学班', grades: '成绩与学业档案', + exams: '考试与考场', users: '用户与权限', } return titles[String(route.name)] ?? '教务管理' @@ -138,6 +140,13 @@ onMounted(() => auth.refresh().catch(() => undefined)) + + + + diff --git a/web/src/router/index.ts b/web/src/router/index.ts index fed0f2e..36e385f 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -74,6 +74,12 @@ const router = createRouter({ roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'], }, }, + { + path: 'exams', + name: 'exams', + component: () => import('../views/ExamsView.vue'), + meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] }, + }, { path: 'users', name: 'users', diff --git a/web/src/style.css b/web/src/style.css index cd0336a..40bca59 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -510,6 +510,42 @@ button { cursor: pointer; } .transcript-result b { margin: 5px 0 3px; color: var(--indigo); font: 700 25px/1 Consolas, monospace; } .transcript-result small { color: var(--muted); font-size: 9px; } +.exam-plan-strip { padding: 10px; display: flex; gap: 9px; overflow-x: auto; border: 1px solid var(--line); background: white; } +.exam-plan-strip button { flex: 0 0 275px; padding: 14px; display: grid; grid-template-columns: 1fr auto; gap: 6px; text-align: left; border: 1px solid var(--line); background: #fafbfc; } +.exam-plan-strip button.active { color: white; border-color: var(--indigo); background: var(--indigo); } +.exam-plan-strip b { grid-column: 1/-1; font-family: "STZhongsong","Songti SC",serif; } +.exam-plan-strip span, .exam-plan-strip small, .exam-plan-strip i { color: #8c94a3; font-size: 9px; font-style: normal; } +.exam-plan-strip button.active span, .exam-plan-strip button.active small { color: #c5cee5; } +.exam-board { border: 1px solid var(--line); background: white; } +.exam-board > header { padding: 22px 24px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line); } +.exam-board header span { color: var(--teal); font: 700 9px Consolas,monospace; letter-spacing: .13em; } +.exam-board header h3 { margin: 7px 0 5px; font: 600 21px "STZhongsong","Songti SC",serif; } +.exam-board header p { margin: 0; color: var(--muted); font-size: 10px; } +.exam-timeline { padding: 10px 22px 24px 42px; } +.exam-timeline article { min-height: 108px; display: grid; grid-template-columns: 120px minmax(220px,1fr) 210px 120px; align-items: center; gap: 20px; position: relative; border-bottom: 1px solid var(--line); } +.exam-timeline article::before { content:""; position:absolute; left:-24px; width:9px; height:9px; border:3px solid white; border-radius:50%; background:var(--teal); box-shadow:0 0 0 1px var(--teal); } +.exam-timeline article::after { content:""; position:absolute; left:-20px; top:0; bottom:0; width:1px; background:#cdd4df; z-index:-1; } +.exam-timeline time b, .exam-timeline time span { display:block; } +.exam-timeline time b { color:var(--indigo); font:700 15px Consolas,monospace; } +.exam-timeline time span { margin-top:5px; color:var(--muted); font-size:9px; } +.exam-timeline article > div > span { color:var(--teal); font-size:9px; } +.exam-timeline h4 { margin:6px 0 4px; font-size:14px; } +.exam-timeline p { margin:0; color:var(--muted); font-size:9px; } +.exam-staff { padding-left:18px; border-left:1px solid var(--line); } +.exam-staff b,.exam-staff small { display:block; margin-top:5px; font-size:10px; } +.exam-staff small { color:var(--muted); } +.exam-row-actions { text-align:right; } +.exam-ticket-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; } +.exam-ticket-grid article { min-height:180px; display:grid; grid-template-columns:105px 1fr; border:1px solid var(--line); background:white; overflow:hidden; } +.exam-ticket-date { padding:20px 14px; display:grid; place-content:center; text-align:center; color:white; background:linear-gradient(145deg,#182d64,#21706f); } +.exam-ticket-date b { font:700 17px Consolas,monospace; } +.exam-ticket-date span { margin-top:8px; color:#bfe0da; font-size:9px; } +.exam-ticket-grid article > div:nth-child(2) { padding:24px 20px; } +.exam-ticket-grid article > div:nth-child(2) span { color:var(--teal); font-size:9px; } +.exam-ticket-grid h3 { margin:9px 0 7px; font:600 19px "STZhongsong","Songti SC",serif; } +.exam-ticket-grid p { margin:0; color:var(--muted); font-size:10px; } +.exam-ticket-grid footer { grid-column:1/-1; padding:12px 17px; display:flex; align-items:center; gap:7px; border-top:1px dashed var(--line); color:#586275; 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; } @@ -615,6 +651,8 @@ button { cursor: pointer; } .transcript-list > article { grid-template-columns: 1fr 120px; } .grade-scale { grid-column: 1 / -1; grid-row: 2; } .transcript-result { grid-column: 2; grid-row: 1; } + .exam-timeline article { grid-template-columns:100px 1fr 180px; } + .exam-row-actions { grid-column:2/-1; padding-bottom:10px; text-align:left; } .role-editor-summary { align-items: flex-start; flex-direction: column; gap: 4px; } .form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; } .el-dialog { width: calc(100vw - 24px) !important; } @@ -654,6 +692,12 @@ button { cursor: pointer; } .transcript-list > article { grid-template-columns: 1fr; gap: 17px; } .transcript-result { grid-column: auto; grid-row: auto; padding: 12px 0 0; border-left: none; border-top: 1px solid var(--line); text-align: left; } .grade-scale { grid-column: auto; grid-row: auto; } + .exam-board > header { align-items:flex-start; flex-direction:column; } + .exam-timeline { padding-left:28px; } + .exam-timeline article { padding:16px 0; grid-template-columns:1fr; gap:9px; } + .exam-staff { padding:10px 0 0; border-left:none; border-top:1px solid var(--line); } + .exam-row-actions { grid-column:auto; padding:0; } + .exam-ticket-grid { grid-template-columns:1fr; } .el-drawer { width: 100% !important; } } diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index e774644..975e6e3 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -117,13 +117,18 @@ onMounted(async () => { {{ data.counts.gradeSheets ?? 0 }} 张登记册 · {{ data.counts.publishedGradeSheets ?? 0 }} 张已发布 已建立 +
+ 考试考场 + {{ data.counts.examPlans ?? 0 }} 个计划 · {{ data.counts.examSessions ?? 0 }} 个场次 + 已建立 +
NEXT MILESTONE

下一段业务链

-

成绩录入、审核、发布与学生成绩单已就绪,下一步进入考试安排与考场管理。

+

考试计划、考场、监考与个人日程已就绪,下一步进入学籍异动与毕业审核。

基础底座 人员档案 @@ -131,6 +136,7 @@ onMounted(async () => { 排课课表 学生选课 成绩管理 + 考试考场
diff --git a/web/src/views/ExamsView.vue b/web/src/views/ExamsView.vue new file mode 100644 index 0000000..f91801d --- /dev/null +++ b/web/src/views/ExamsView.vue @@ -0,0 +1,203 @@ + + +