From af52e27a3a7af290b1c3f2da4c502fdaff64809d Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 24 Jul 2026 15:33:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B2=E5=AE=8C=E6=88=90=E2=80=9C=E6=88=90?= =?UTF-8?q?=E7=BB=A9=E7=AE=A1=E7=90=86=E4=B8=8E=E5=AD=A6=E4=B8=9A=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=E2=80=9D=E6=A8=A1=E5=9D=97=EF=BC=9A=20=E6=95=99?= =?UTF-8?q?=E5=B8=88=E6=89=B9=E9=87=8F=E5=BD=95=E5=85=A5=E5=B9=B3=E6=97=B6?= =?UTF-8?q?=E3=80=81=E6=9C=9F=E4=B8=AD=E3=80=81=E6=9C=9F=E6=9C=AB=E6=88=90?= =?UTF-8?q?=E7=BB=A9=E3=80=82=20=E8=87=AA=E5=8A=A8=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E6=80=BB=E8=AF=84=E3=80=81=E7=BB=A9=E7=82=B9=E5=92=8C=E5=8F=8A?= =?UTF-8?q?=E6=A0=BC=E7=8A=B6=E6=80=81=E3=80=82=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=BC=BA=E8=80=83=E3=80=81=E7=BC=93=E8=80=83=E3=80=81=E5=85=8D?= =?UTF-8?q?=E4=BF=AE=E3=80=82=20=E6=95=99=E5=B8=88=E6=8F=90=E4=BA=A4=20?= =?UTF-8?q?=E2=86=92=20=E5=AD=A6=E9=99=A2=E5=AE=A1=E6=A0=B8/=E9=80=80?= =?UTF-8?q?=E5=9B=9E=20=E2=86=92=20=E6=A0=A1=E7=BA=A7=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E3=80=82=20=E5=AD=A6=E7=94=9F=E5=8F=AA=E8=83=BD=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E6=AD=A3=E5=BC=8F=E5=8F=91=E5=B8=83=E6=88=90=E7=BB=A9?= =?UTF-8?q?=E3=80=82=20=E8=87=AA=E5=8A=A8=E7=BB=9F=E8=AE=A1=E5=B9=B3?= =?UTF-8?q?=E5=9D=87=E5=88=86=E3=80=81=E5=AE=8C=E6=88=90=E5=BA=A6=E3=80=81?= =?UTF-8?q?=E5=8F=8A=E6=A0=BC=E7=8E=87=E5=92=8C=E5=8A=A0=E6=9D=83=20GPA?= =?UTF-8?q?=E3=80=82=20SQLite=20=E6=9C=AC=E5=9C=B0=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=92=8C=20MySQL=20=E6=AD=A3=E5=BC=8F?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=9D=87=E5=B7=B2=E5=AE=8C=E6=88=90=E3=80=82?= =?UTF-8?q?=20=E5=88=86=E7=BA=A7=E6=9D=83=E9=99=90=E5=9C=A8=E5=89=8D?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=90=8C=E6=97=B6=E6=A0=A1=E9=AA=8C=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 | 91 + .../Controllers/DashboardController.cs | 5 + .../Controllers/GradesController.cs | 515 +++++ .../Domain/Academic/GradeEntities.cs | 50 + .../Infrastructure/Grades/GradeCalculator.cs | 57 + .../Persistence/AppDbContext.cs | 36 + .../Persistence/DatabaseInitializer.cs | 85 +- .../Persistence/DevelopmentSqliteMigrator.cs | 63 + ...20260724072230_GradeManagement.Designer.cs | 1807 +++++++++++++++++ .../MySql/20260724072230_GradeManagement.cs | 111 + .../MySql/AppDbContextModelSnapshot.cs | 143 ++ .../Jiaowu.Api.Tests/GradeCalculatorTests.cs | 61 + web/src/layouts/AdminLayout.vue | 11 +- web/src/router/index.ts | 8 + web/src/style.css | 156 ++ web/src/views/DashboardView.vue | 8 +- web/src/views/GradesView.vue | 499 +++++ 18 files changed, 3686 insertions(+), 22 deletions(-) create mode 100644 src/Jiaowu.Api/Controllers/GradesController.cs create mode 100644 src/Jiaowu.Api/Domain/Academic/GradeEntities.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724072230_GradeManagement.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724072230_GradeManagement.cs create mode 100644 tests/Jiaowu.Api.Tests/GradeCalculatorTests.cs create mode 100644 web/src/views/GradesView.vue diff --git a/README.md b/README.md index e304eef..7f4a3b1 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 66b1c24..365f41a 100644 --- a/scripts/smoke-test.ps1 +++ b/scripts/smoke-test.ps1 @@ -222,6 +222,94 @@ try { throw 'Course-selection roster did not include the selected student.' } + $gradeTasks = Invoke-RestMethod ` + -Uri 'http://localhost:5255/api/grades/sheets' ` + -Headers $headers + $gradeTask = @($gradeTasks) | + Where-Object { $null -ne $_.sheet } | + Select-Object -First 1 + if ($null -eq $gradeTask) { + throw 'Development grade sheet was not seeded.' + } + $gradeDetail = Invoke-RestMethod ` + -Uri "http://localhost:5255/api/grades/sheets/$($gradeTask.sheet.id)" ` + -Headers $headers + if (@($gradeDetail.sheet.records).Count -lt 1) { + throw 'Development grade sheet has no student records.' + } + + $teacherLoginBody = @{ + userName = 'teacher' + password = 'Teacher@123456' + } | ConvertTo-Json + $teacherLogin = Invoke-RestMethod ` + -Method Post ` + -Uri 'http://localhost:5255/api/auth/login' ` + -ContentType 'application/json' ` + -Body $teacherLoginBody + $teacherHeaders = @{ Authorization = "Bearer $($teacherLogin.token)" } + $teacherGradeTasks = Invoke-RestMethod ` + -Uri 'http://localhost:5255/api/grades/sheets' ` + -Headers $teacherHeaders + $teacherGradeTask = @($teacherGradeTasks) | + Where-Object { $_.id -eq $gradeTask.id } | + Select-Object -First 1 + if ($null -eq $teacherGradeTask) { + throw 'Assigned teacher cannot access the grade sheet.' + } + if ($teacherGradeTask.sheet.status -eq 'Draft' -or + $teacherGradeTask.sheet.status -eq 'Returned') { + Invoke-RestMethod ` + -Method Post ` + -Uri "http://localhost:5255/api/grades/sheets/$($teacherGradeTask.sheet.id)/submit" ` + -Headers $teacherHeaders | + Out-Null + } + + $collegeLoginBody = @{ + userName = 'college' + password = 'College@123456' + } | ConvertTo-Json + $collegeLogin = Invoke-RestMethod ` + -Method Post ` + -Uri 'http://localhost:5255/api/auth/login' ` + -ContentType 'application/json' ` + -Body $collegeLoginBody + $collegeHeaders = @{ Authorization = "Bearer $($collegeLogin.token)" } + $collegeGradeTasks = Invoke-RestMethod ` + -Uri 'http://localhost:5255/api/grades/sheets' ` + -Headers $collegeHeaders + $collegeGradeTask = @($collegeGradeTasks) | + Where-Object { $_.id -eq $gradeTask.id } | + Select-Object -First 1 + if ($collegeGradeTask.sheet.status -eq 'Submitted') { + Invoke-RestMethod ` + -Method Post ` + -Uri "http://localhost:5255/api/grades/sheets/$($collegeGradeTask.sheet.id)/approve" ` + -Headers $collegeHeaders | + Out-Null + } + + $adminGradeTasks = Invoke-RestMethod ` + -Uri 'http://localhost:5255/api/grades/sheets' ` + -Headers $headers + $adminGradeTask = @($adminGradeTasks) | + Where-Object { $_.id -eq $gradeTask.id } | + Select-Object -First 1 + if ($adminGradeTask.sheet.status -eq 'Approved') { + Invoke-RestMethod ` + -Method Post ` + -Uri "http://localhost:5255/api/grades/sheets/$($adminGradeTask.sheet.id)/publish" ` + -Headers $headers | + Out-Null + } + $studentTranscript = Invoke-RestMethod ` + -Uri 'http://localhost:5255/api/grades/student/transcript' ` + -Headers $studentHeaders + if (@($studentTranscript.records).Count -lt 1) { + throw 'Published grade is not visible in the student transcript.' + } + $frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5 $spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5 $unknownApiParameters = @{ @@ -250,6 +338,9 @@ try { SelectionOfferings = @($selectionOfferings).Count StudentEnrollments = $activeEnrollments.Count RosterStudents = $selectionRoster.enrolledCount + GradeSheets = @($gradeTasks).Count + GradeRecords = @($gradeDetail.sheet.records).Count + TranscriptRecords = @($studentTranscript.records).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 f9f3a45..19d0176 100644 --- a/src/Jiaowu.Api/Controllers/DashboardController.cs +++ b/src/Jiaowu.Api/Controllers/DashboardController.cs @@ -44,6 +44,11 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase .CountAsync( x => x.Status == CourseEnrollmentStatus.Enrolled, cancellationToken), + GradeSheets = await db.GradeSheets.CountAsync(cancellationToken), + PublishedGradeSheets = await db.GradeSheets.CountAsync( + x => x.Status == GradeSheetStatus.Published, + cancellationToken), + GradeRecords = await db.GradeRecords.CountAsync(cancellationToken), Users = await db.Users.CountAsync(cancellationToken) } }; diff --git a/src/Jiaowu.Api/Controllers/GradesController.cs b/src/Jiaowu.Api/Controllers/GradesController.cs new file mode 100644 index 0000000..ac3a923 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/GradesController.cs @@ -0,0 +1,515 @@ +using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Grades; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/grades")] +public sealed class GradesController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase +{ + private const string SheetUsers = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin + "," + + SystemRoles.Teacher; + + private const string Reviewers = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin; + + private const string Publishers = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin; + + [HttpGet("sheets")] + [Authorize(Roles = SheetUsers)] + public async Task GetSheets( + Guid? academicTermId, + GradeSheetStatus? status, + CancellationToken cancellationToken) + { + var source = AccessibleTasks().AsNoTracking() + .Where(x => + x.Status == TeachingTaskStatus.Published || + x.Status == TeachingTaskStatus.Closed); + if (academicTermId.HasValue) + source = source.Where(x => x.AcademicTermId == academicTermId); + + var items = await source + .OrderByDescending(x => x.AcademicTerm!.StartDate) + .ThenBy(x => x.TaskNumber) + .Select(x => new + { + x.Id, + x.TaskNumber, + TaskName = x.Name, + x.AcademicTermId, + TermName = x.AcademicTerm!.Name, + CourseCode = x.Course!.Code, + CourseName = x.Course.Name, + CollegeName = x.Course.College!.Name, + x.Course.Credits, + TeacherNames = x.Teachers + .OrderByDescending(item => item.IsPrimary) + .Select(item => item.Teacher!.Name), + ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name), + Sheet = db.GradeSheets + .Where(sheet => sheet.TeachingTaskId == x.Id) + .Select(sheet => new + { + sheet.Id, + sheet.Status, + sheet.RegularWeight, + sheet.MidtermWeight, + sheet.FinalWeight, + StudentCount = sheet.Records.Count, + CompletedCount = sheet.Records.Count(record => + record.TotalScore != null || + record.ExamStatus != GradeExamStatus.Normal), + PassedCount = sheet.Records.Count(record => + record.TotalScore >= 60), + sheet.SubmittedAt, + sheet.ReviewedAt, + sheet.PublishedAt, + sheet.UpdatedAt + }) + .FirstOrDefault() + }) + .ToListAsync(cancellationToken); + + if (status.HasValue) + items = items.Where(x => x.Sheet?.Status == status.Value).ToList(); + return Ok(items); + } + + [HttpPost("sheets")] + [Authorize(Roles = SheetUsers)] + public async Task CreateSheet( + GradeSheetRequest request, + CancellationToken cancellationToken) + { + if (!GradeCalculator.AreWeightsValid( + request.RegularWeight, + request.MidtermWeight, + request.FinalWeight)) + return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。"); + + var task = await AccessibleTasks() + .Include(x => x.Teachers) + .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); + if (task is null) return NotFound(); + if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed)) + return ConflictProblem("只有已发布或已结课的教学班可以建立成绩单。"); + if (!CanInitialize(task)) return Forbid(); + if (await db.GradeSheets.AnyAsync( + x => x.TeachingTaskId == task.Id, + cancellationToken)) + return ConflictProblem("该教学班已经建立成绩单。"); + + var studentIds = await db.CourseEnrollments.AsNoTracking() + .Where(x => + x.Status == CourseEnrollmentStatus.Enrolled && + x.CourseSelectionOffering!.TeachingTaskId == task.Id) + .Select(x => x.StudentId) + .Distinct() + .ToListAsync(cancellationToken); + if (studentIds.Count == 0) + return ConflictProblem("教学班当前没有有效选课学生,无法建立成绩单。"); + + var sheet = new GradeSheet + { + TeachingTaskId = task.Id, + RegularWeight = request.RegularWeight, + MidtermWeight = request.MidtermWeight, + FinalWeight = request.FinalWeight, + Records = studentIds.Select(studentId => new GradeRecord + { + StudentId = studentId + }).ToList() + }; + db.GradeSheets.Add(sheet); + return await SaveAsync(sheet.Id, true, cancellationToken); + } + + [HttpGet("sheets/{id:guid}")] + [Authorize(Roles = SheetUsers)] + public async Task GetSheet(Guid id, CancellationToken cancellationToken) + { + var sheet = await AccessibleSheets().AsNoTracking() + .Where(x => x.Id == id) + .Select(x => new + { + x.Id, + x.TeachingTaskId, + x.TeachingTask!.TaskNumber, + TaskName = x.TeachingTask.Name, + x.TeachingTask.AcademicTermId, + TermName = x.TeachingTask.AcademicTerm!.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.RegularWeight, + x.MidtermWeight, + x.FinalWeight, + x.Status, + x.ReviewComment, + x.SubmittedAt, + x.ReviewedAt, + x.PublishedAt, + Records = x.Records + .OrderBy(record => record.Student!.StudentNumber) + .Select(record => new + { + record.Id, + record.StudentId, + record.Student!.StudentNumber, + record.Student.Name, + ClassName = record.Student.AdministrativeClass!.Name, + record.RegularScore, + record.MidtermScore, + record.FinalScore, + record.TotalScore, + record.GradePoint, + record.ExamStatus, + record.Notes, + record.UpdatedAt + }), + x.CreatedAt, + x.UpdatedAt + }) + .FirstOrDefaultAsync(cancellationToken); + if (sheet is null) return NotFound(); + + var task = await db.TeachingTasks.AsNoTracking() + .Include(x => x.Teachers) + .ThenInclude(x => x.Teacher) + .SingleAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken); + return Ok(new + { + Sheet = sheet, + CanEdit = CanEditScores(task) && + sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned, + CanReview = IsReviewer() && sheet.Status == GradeSheetStatus.Submitted, + CanPublish = IsPublisher() && sheet.Status == GradeSheetStatus.Approved + }); + } + + [HttpPut("sheets/{id:guid}/weights")] + [Authorize(Roles = SheetUsers)] + public async Task UpdateWeights( + Guid id, + GradeWeightsRequest request, + CancellationToken cancellationToken) + { + if (!GradeCalculator.AreWeightsValid( + request.RegularWeight, + request.MidtermWeight, + request.FinalWeight)) + return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。"); + var sheet = await EditableSheetAsync(id, cancellationToken); + if (sheet is null) return NotFound(); + if (!CanEditScores(sheet.TeachingTask!)) return Forbid(); + if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned)) + return ConflictProblem("当前状态不能修改成绩构成。"); + sheet.RegularWeight = request.RegularWeight; + sheet.MidtermWeight = request.MidtermWeight; + sheet.FinalWeight = request.FinalWeight; + foreach (var record in sheet.Records) Recalculate(sheet, record); + return await SaveAsync(id, false, cancellationToken); + } + + [HttpPut("sheets/{id:guid}/records")] + [Authorize(Roles = SheetUsers)] + public async Task UpdateRecords( + Guid id, + GradeRecordsRequest request, + CancellationToken cancellationToken) + { + var sheet = await EditableSheetAsync(id, cancellationToken); + if (sheet is null) return NotFound(); + if (!CanEditScores(sheet.TeachingTask!)) return Forbid(); + if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned)) + return ConflictProblem("成绩单提交后不能继续修改。"); + var records = sheet.Records.ToDictionary(x => x.Id); + if (request.Records.Select(x => x.Id).Distinct().Count() != request.Records.Count || + request.Records.Any(x => !records.ContainsKey(x.Id))) + return ValidationProblem("包含无效或重复的成绩记录。"); + + foreach (var item in request.Records) + { + if (!ValidScore(item.RegularScore) || + !ValidScore(item.MidtermScore) || + !ValidScore(item.FinalScore)) + return ValidationProblem("成绩必须在 0—100 分之间。"); + var record = records[item.Id]; + record.RegularScore = item.RegularScore; + record.MidtermScore = item.MidtermScore; + record.FinalScore = item.FinalScore; + record.ExamStatus = item.ExamStatus; + record.Notes = Normalize(item.Notes); + Recalculate(sheet, record); + } + return await SaveAsync(id, false, cancellationToken); + } + + [HttpPost("sheets/{id:guid}/submit")] + [Authorize(Roles = SheetUsers)] + public async Task Submit(Guid id, CancellationToken cancellationToken) + { + var sheet = await EditableSheetAsync(id, cancellationToken); + if (sheet is null) return NotFound(); + if (!CanEditScores(sheet.TeachingTask!)) return Forbid(); + if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned)) + return ConflictProblem("只有草稿或已退回成绩单可以提交。"); + if (sheet.Records.Count == 0) + return ConflictProblem("成绩单没有学生记录。"); + if (sheet.Records.Any(x => + x.ExamStatus == GradeExamStatus.Normal && + !x.TotalScore.HasValue)) + return ConflictProblem("仍有学生缺少必填成绩分项,请补充后再提交。"); + sheet.Status = GradeSheetStatus.Submitted; + sheet.SubmittedAt = DateTime.UtcNow; + sheet.ReviewComment = null; + return await SaveAsync(id, false, cancellationToken); + } + + [HttpPost("sheets/{id:guid}/approve")] + [Authorize(Roles = Reviewers)] + public async Task Approve(Guid id, CancellationToken cancellationToken) + { + var sheet = await AccessibleSheets().FirstOrDefaultAsync( + x => x.Id == id, + cancellationToken); + if (sheet is null) return NotFound(); + if (!IsReviewer()) return Forbid(); + if (sheet.Status != GradeSheetStatus.Submitted) + return ConflictProblem("只有待审核成绩单可以通过审核。"); + sheet.Status = GradeSheetStatus.Approved; + sheet.ReviewedAt = DateTime.UtcNow; + sheet.ReviewComment = null; + return await SaveAsync(id, false, cancellationToken); + } + + [HttpPost("sheets/{id:guid}/return")] + [Authorize(Roles = Reviewers)] + public async Task Return( + Guid id, + GradeReviewRequest request, + CancellationToken cancellationToken) + { + var sheet = await AccessibleSheets().FirstOrDefaultAsync( + x => x.Id == id, + cancellationToken); + if (sheet is null) return NotFound(); + if (!IsReviewer()) return Forbid(); + if (sheet.Status != GradeSheetStatus.Submitted) + return ConflictProblem("只有待审核成绩单可以退回。"); + if (string.IsNullOrWhiteSpace(request.Comment)) + return ValidationProblem("退回时必须填写修改意见。"); + sheet.Status = GradeSheetStatus.Returned; + sheet.ReviewedAt = DateTime.UtcNow; + sheet.ReviewComment = request.Comment.Trim(); + return await SaveAsync(id, false, cancellationToken); + } + + [HttpPost("sheets/{id:guid}/publish")] + [Authorize(Roles = Publishers)] + public async Task Publish(Guid id, CancellationToken cancellationToken) + { + var sheet = await AccessibleSheets().FirstOrDefaultAsync( + x => x.Id == id, + cancellationToken); + if (sheet is null) return NotFound(); + if (!IsPublisher()) return Forbid(); + if (sheet.Status != GradeSheetStatus.Approved) + return ConflictProblem("只有审核通过的成绩单可以发布。"); + sheet.Status = GradeSheetStatus.Published; + sheet.PublishedAt = DateTime.UtcNow; + return await SaveAsync(id, false, cancellationToken); + } + + [HttpGet("student/transcript")] + [Authorize(Roles = SystemRoles.Student)] + public async Task GetStudentTranscript( + Guid? academicTermId, + CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var student = await db.Students.AsNoTracking() + .Where(x => x.UserId == userId) + .Select(x => new + { + x.Id, + x.StudentNumber, + x.Name, + ClassName = x.AdministrativeClass!.Name, + MajorName = x.AdministrativeClass.Major!.Name + }) + .FirstOrDefaultAsync(cancellationToken); + if (student is null) + return ConflictProblem("当前账号未关联有效学生档案。"); + + var source = db.GradeRecords.AsNoTracking() + .Where(x => + x.StudentId == student.Id && + x.GradeSheet!.Status == GradeSheetStatus.Published); + if (academicTermId.HasValue) + source = source.Where(x => + x.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId); + var records = await source + .OrderByDescending(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate) + .ThenBy(x => x.GradeSheet!.TeachingTask!.Course!.Code) + .Select(x => new + { + x.Id, + AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId, + TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name, + x.GradeSheet.TeachingTaskId, + x.GradeSheet.TeachingTask.TaskNumber, + CourseCode = x.GradeSheet.TeachingTask.Course!.Code, + CourseName = x.GradeSheet.TeachingTask.Course.Name, + x.GradeSheet.TeachingTask.Course.Credits, + x.TotalScore, + x.GradePoint, + x.ExamStatus, + x.GradeSheet.PublishedAt + }) + .ToListAsync(cancellationToken); + return Ok(new { Student = student, Records = records }); + } + + private IQueryable AccessibleTasks() + { + var source = db.TeachingTasks.AsQueryable(); + var scope = currentUserDataScope.Current; + if (scope.Scope == DataScope.All) return source; + if (scope.Scope == DataScope.College) + return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId); + if (scope.IsInRole(SystemRoles.Teacher)) + return source.Where(x => + x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId)); + return source.Where(_ => false); + } + + private IQueryable AccessibleSheets() => + db.GradeSheets.Where(x => AccessibleTasks().Any(task => + task.Id == x.TeachingTaskId)); + + private async Task EditableSheetAsync( + Guid id, + CancellationToken cancellationToken) => + await AccessibleSheets() + .Include(x => x.Records) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .ThenInclude(x => x.Teacher) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + private bool CanInitialize(TeachingTask task) => + currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || + currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) || + currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) || + IsAssignedTeacher(task); + + private bool CanEditScores(TeachingTask task) => + currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || + IsAssignedTeacher(task); + + private bool IsAssignedTeacher(TeachingTask task) => + task.Teachers.Any(x => + x.Teacher?.UserId == currentUserDataScope.Current.UserId); + + private bool IsReviewer() => + currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || + currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) || + currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin); + + private bool IsPublisher() => + currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || + currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); + + private static void Recalculate(GradeSheet sheet, GradeRecord record) + { + record.TotalScore = GradeCalculator.CalculateTotal( + record.RegularScore, + record.MidtermScore, + record.FinalScore, + sheet.RegularWeight, + sheet.MidtermWeight, + sheet.FinalWeight, + record.ExamStatus); + record.GradePoint = GradeCalculator.CalculateGradePoint(record.TotalScore); + } + + private static bool ValidScore(decimal? score) => + !score.HasValue || score.Value is >= 0 and <= 100; + + 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 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 GradeSheetRequest( + Guid TeachingTaskId, + [Range(typeof(decimal), "0", "100")] decimal RegularWeight, + [Range(typeof(decimal), "0", "100")] decimal MidtermWeight, + [Range(typeof(decimal), "0", "100")] decimal FinalWeight); + +public sealed record GradeWeightsRequest( + [Range(typeof(decimal), "0", "100")] decimal RegularWeight, + [Range(typeof(decimal), "0", "100")] decimal MidtermWeight, + [Range(typeof(decimal), "0", "100")] decimal FinalWeight); + +public sealed record GradeRecordsRequest( + IReadOnlyCollection Records); + +public sealed record GradeRecordRequest( + Guid Id, + decimal? RegularScore, + decimal? MidtermScore, + decimal? FinalScore, + GradeExamStatus ExamStatus, + [MaxLength(300)] string? Notes); + +public sealed record GradeReviewRequest( + [MaxLength(500)] string? Comment); diff --git a/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs b/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs new file mode 100644 index 0000000..ae4fd8c --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs @@ -0,0 +1,50 @@ +using Jiaowu.Api.Domain.Common; + +namespace Jiaowu.Api.Domain.Academic; + +public sealed class GradeSheet : EntityBase +{ + public Guid TeachingTaskId { get; set; } + public TeachingTask? TeachingTask { get; set; } + public decimal RegularWeight { get; set; } = 30; + public decimal MidtermWeight { get; set; } + public decimal FinalWeight { get; set; } = 70; + public GradeSheetStatus Status { get; set; } = GradeSheetStatus.Draft; + public string? ReviewComment { get; set; } + public DateTime? SubmittedAt { get; set; } + public DateTime? ReviewedAt { get; set; } + public DateTime? PublishedAt { get; set; } + public ICollection Records { get; set; } = []; +} + +public sealed class GradeRecord : EntityBase +{ + public Guid GradeSheetId { get; set; } + public GradeSheet? GradeSheet { get; set; } + public Guid StudentId { get; set; } + public Student? Student { get; set; } + public decimal? RegularScore { get; set; } + public decimal? MidtermScore { get; set; } + public decimal? FinalScore { get; set; } + public decimal? TotalScore { get; set; } + public decimal? GradePoint { get; set; } + public GradeExamStatus ExamStatus { get; set; } = GradeExamStatus.Normal; + public string? Notes { get; set; } +} + +public enum GradeSheetStatus +{ + Draft = 1, + Submitted = 2, + Approved = 3, + Published = 4, + Returned = 5 +} + +public enum GradeExamStatus +{ + Normal = 1, + Absent = 2, + Deferred = 3, + Exempt = 4 +} diff --git a/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs b/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs new file mode 100644 index 0000000..b2d37c4 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs @@ -0,0 +1,57 @@ +using Jiaowu.Api.Domain.Academic; + +namespace Jiaowu.Api.Infrastructure.Grades; + +public static class GradeCalculator +{ + public static bool AreWeightsValid( + decimal regularWeight, + decimal midtermWeight, + decimal finalWeight) => + regularWeight is >= 0 and <= 100 && + midtermWeight is >= 0 and <= 100 && + finalWeight is >= 0 and <= 100 && + regularWeight + midtermWeight + finalWeight == 100; + + public static decimal? CalculateTotal( + decimal? regularScore, + decimal? midtermScore, + decimal? finalScore, + decimal regularWeight, + decimal midtermWeight, + decimal finalWeight, + GradeExamStatus examStatus) + { + if (examStatus != GradeExamStatus.Normal || + regularWeight > 0 && !regularScore.HasValue || + midtermWeight > 0 && !midtermScore.HasValue || + finalWeight > 0 && !finalScore.HasValue) + { + return null; + } + + var total = + (regularScore ?? 0) * regularWeight / 100 + + (midtermScore ?? 0) * midtermWeight / 100 + + (finalScore ?? 0) * finalWeight / 100; + return Math.Round(total, 1, MidpointRounding.AwayFromZero); + } + + public static decimal? CalculateGradePoint(decimal? totalScore) + { + if (!totalScore.HasValue) return null; + return totalScore.Value switch + { + >= 90 => 4.0m, + >= 85 => 3.7m, + >= 82 => 3.3m, + >= 78 => 3.0m, + >= 75 => 2.7m, + >= 72 => 2.3m, + >= 68 => 2.0m, + >= 64 => 1.5m, + >= 60 => 1.0m, + _ => 0m + }; + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 99ee8ba..3c89de8 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -33,6 +33,8 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet CourseSelectionOfferings => Set(); public DbSet CourseEnrollments => Set(); + public DbSet GradeSheets => Set(); + public DbSet GradeRecords => Set(); public DbSet AuditLogs => Set(); protected override void OnModelCreating(ModelBuilder builder) @@ -317,6 +319,40 @@ public sealed class AppDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.Property(x => x.RegularWeight).HasPrecision(5, 1); + entity.Property(x => x.MidtermWeight).HasPrecision(5, 1); + entity.Property(x => x.FinalWeight).HasPrecision(5, 1); + entity.Property(x => x.ReviewComment).HasMaxLength(500); + entity.HasIndex(x => x.TeachingTaskId).IsUnique(); + entity.HasIndex(x => x.Status); + entity.HasOne(x => x.TeachingTask) + .WithMany() + .HasForeignKey(x => x.TeachingTaskId) + .OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.Property(x => x.RegularScore).HasPrecision(5, 1); + entity.Property(x => x.MidtermScore).HasPrecision(5, 1); + entity.Property(x => x.FinalScore).HasPrecision(5, 1); + entity.Property(x => x.TotalScore).HasPrecision(5, 1); + entity.Property(x => x.GradePoint).HasPrecision(3, 1); + entity.Property(x => x.Notes).HasMaxLength(300); + entity.HasIndex(x => new { x.GradeSheetId, x.StudentId }).IsUnique(); + entity.HasIndex(x => new { x.StudentId, x.TotalScore }); + entity.HasOne(x => x.GradeSheet) + .WithMany(x => x.Records) + .HasForeignKey(x => x.GradeSheetId) + .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 da39f80..1dad956 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs @@ -422,37 +422,84 @@ public sealed class DatabaseInitializer( await SeedDevelopmentUsersAsync(computerCollege.Id); await SeedDevelopmentCourseSelectionAsync(); + await SeedDevelopmentGradesAsync(); } private async Task SeedDevelopmentCourseSelectionAsync() { - if (await db.CourseSelectionRounds.AnyAsync()) + 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(); } - var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent); + var seededTask = await db.TeachingTasks.SingleAsync( + x => x.TaskNumber == "2026-1-CS101-01"); + var offering = await db.CourseSelectionOfferings.FirstOrDefaultAsync( + x => x.TeachingTaskId == seededTask.Id); + var student = await db.Students.SingleAsync(x => + x.StudentNumber == "202601001"); + if (offering is not null && + !await db.CourseEnrollments.AnyAsync(x => + x.CourseSelectionOfferingId == offering.Id && + x.StudentId == student.Id)) + { + db.CourseEnrollments.Add(new CourseEnrollment + { + CourseSelectionOfferingId = offering.Id, + StudentId = student.Id + }); + await db.SaveChangesAsync(); + } + } + + private async Task SeedDevelopmentGradesAsync() + { + if (await db.GradeSheets.AnyAsync()) return; var task = await db.TeachingTasks.SingleAsync( x => x.TaskNumber == "2026-1-CS101-01"); - var now = DateTime.UtcNow; - db.CourseSelectionRounds.Add(new CourseSelectionRound + var student = await db.Students.SingleAsync( + x => x.StudentNumber == "202601001"); + db.GradeSheets.Add(new GradeSheet { - 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 = + TeachingTaskId = task.Id, + RegularWeight = 30, + MidtermWeight = 0, + FinalWeight = 70, + Status = GradeSheetStatus.Draft, + Records = [ - new CourseSelectionOffering + new GradeRecord { - TeachingTaskId = task.Id, - Capacity = 60, - IsOpenToAll = false, - Notes = "面向计科 2026-1 班开放。" + StudentId = student.Id, + RegularScore = 88, + FinalScore = 92, + TotalScore = 90.8m, + GradePoint = 4.0m } ] }); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 511b2a6..d77fbf1 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -12,6 +12,7 @@ public sealed class DevelopmentSqliteMigrator( private const string SchedulesMigration = "20260724_04_schedules"; private const string ClassCounselorMigration = "20260724_05_class_counselor"; private const string CourseSelectionMigration = "20260724_06_course_selection"; + private const string GradesMigration = "20260724_07_grades"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -63,6 +64,10 @@ public sealed class DevelopmentSqliteMigrator( CourseSelectionMigration, CourseSelectionStatements, cancellationToken); + await ApplyMigrationAsync( + GradesMigration, + GradesStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -506,4 +511,62 @@ public sealed class DevelopmentSqliteMigrator( ON "CourseEnrollments" ("StudentId", "Status"); """ ]; + + private static readonly string[] GradesStatements = + [ + """ + CREATE TABLE IF NOT EXISTS "GradeSheets" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_GradeSheets" PRIMARY KEY, + "TeachingTaskId" TEXT NOT NULL, + "RegularWeight" TEXT NOT NULL, + "MidtermWeight" TEXT NOT NULL, + "FinalWeight" TEXT NOT NULL, + "Status" INTEGER NOT NULL, + "ReviewComment" TEXT NULL, + "SubmittedAt" TEXT NULL, + "ReviewedAt" TEXT NULL, + "PublishedAt" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_GradeSheets_TeachingTasks_TeachingTaskId" + FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT + ); + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_GradeSheets_TeachingTaskId" + ON "GradeSheets" ("TeachingTaskId"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_GradeSheets_Status" + ON "GradeSheets" ("Status"); + """, + """ + CREATE TABLE IF NOT EXISTS "GradeRecords" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_GradeRecords" PRIMARY KEY, + "GradeSheetId" TEXT NOT NULL, + "StudentId" TEXT NOT NULL, + "RegularScore" TEXT NULL, + "MidtermScore" TEXT NULL, + "FinalScore" TEXT NULL, + "TotalScore" TEXT NULL, + "GradePoint" TEXT NULL, + "ExamStatus" INTEGER NOT NULL, + "Notes" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_GradeRecords_GradeSheets_GradeSheetId" + FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_GradeRecords_Students_StudentId" + FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT + ); + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_GradeRecords_GradeSheetId_StudentId" + ON "GradeRecords" ("GradeSheetId", "StudentId"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_GradeRecords_StudentId_TotalScore" + ON "GradeRecords" ("StudentId", "TotalScore"); + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724072230_GradeManagement.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724072230_GradeManagement.Designer.cs new file mode 100644 index 0000000..392aa75 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724072230_GradeManagement.Designer.cs @@ -0,0 +1,1807 @@ +// +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("20260724072230_GradeManagement")] + partial class GradeManagement + { + /// + 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.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.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.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/20260724072230_GradeManagement.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724072230_GradeManagement.cs new file mode 100644 index 0000000..1c408d2 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724072230_GradeManagement.cs @@ -0,0 +1,111 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class GradeManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "GradeSheets", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + TeachingTaskId = table.Column(type: "char(36)", nullable: false), + RegularWeight = table.Column(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false), + MidtermWeight = table.Column(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false), + FinalWeight = table.Column(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false), + Status = table.Column(type: "int", nullable: false), + ReviewComment = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + SubmittedAt = table.Column(type: "datetime(6)", nullable: true), + ReviewedAt = table.Column(type: "datetime(6)", 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_GradeSheets", x => x.Id); + table.ForeignKey( + name: "FK_GradeSheets_TeachingTasks_TeachingTaskId", + column: x => x.TeachingTaskId, + principalTable: "TeachingTasks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "GradeRecords", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + GradeSheetId = table.Column(type: "char(36)", nullable: false), + StudentId = table.Column(type: "char(36)", nullable: false), + RegularScore = table.Column(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true), + MidtermScore = table.Column(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true), + FinalScore = table.Column(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true), + TotalScore = table.Column(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true), + GradePoint = table.Column(type: "decimal(3,1)", precision: 3, scale: 1, nullable: true), + ExamStatus = table.Column(type: "int", nullable: false), + Notes = table.Column(type: "varchar(300)", maxLength: 300, nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GradeRecords", x => x.Id); + table.ForeignKey( + name: "FK_GradeRecords_GradeSheets_GradeSheetId", + column: x => x.GradeSheetId, + principalTable: "GradeSheets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GradeRecords_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_GradeRecords_GradeSheetId_StudentId", + table: "GradeRecords", + columns: new[] { "GradeSheetId", "StudentId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GradeRecords_StudentId_TotalScore", + table: "GradeRecords", + columns: new[] { "StudentId", "TotalScore" }); + + migrationBuilder.CreateIndex( + name: "IX_GradeSheets_Status", + table: "GradeSheets", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_GradeSheets_TeachingTaskId", + table: "GradeSheets", + column: "TeachingTaskId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GradeRecords"); + + migrationBuilder.DropTable( + name: "GradeSheets"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index dbade5b..53120c4 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -634,6 +634,114 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.ToTable("CurriculumPlans"); }); + 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") @@ -1430,6 +1538,36 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("Major"); }); + 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") @@ -1644,6 +1782,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("Modules"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Navigation("Records"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => { b.Navigation("Entries"); diff --git a/tests/Jiaowu.Api.Tests/GradeCalculatorTests.cs b/tests/Jiaowu.Api.Tests/GradeCalculatorTests.cs new file mode 100644 index 0000000..6fb8347 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/GradeCalculatorTests.cs @@ -0,0 +1,61 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Grades; + +namespace Jiaowu.Api.Tests; + +public sealed class GradeCalculatorTests +{ + [Theory] + [InlineData(30, 0, 70, true)] + [InlineData(20, 20, 60, true)] + [InlineData(30, 20, 40, false)] + [InlineData(-10, 40, 70, false)] + public void Weights_must_be_non_negative_and_total_one_hundred( + decimal regular, + decimal midterm, + decimal final, + bool expected) + { + Assert.Equal( + expected, + GradeCalculator.AreWeightsValid(regular, midterm, final)); + } + + [Fact] + public void Total_uses_configured_weights_and_one_decimal_rounding() + { + var total = GradeCalculator.CalculateTotal( + 83, + null, + 91, + 30, + 0, + 70, + GradeExamStatus.Normal); + + Assert.Equal(88.6m, total); + Assert.Equal(3.7m, GradeCalculator.CalculateGradePoint(total)); + } + + [Fact] + public void Missing_required_component_keeps_total_unresolved() + { + Assert.Null(GradeCalculator.CalculateTotal( + 83, + null, + null, + 30, + 0, + 70, + GradeExamStatus.Normal)); + } + + [Theory] + [InlineData(GradeExamStatus.Absent)] + [InlineData(GradeExamStatus.Deferred)] + [InlineData(GradeExamStatus.Exempt)] + public void Exceptional_exam_status_has_no_numeric_total(GradeExamStatus status) + { + Assert.Null(GradeCalculator.CalculateTotal(90, null, 90, 30, 0, 70, status)); + } +} diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index fec8761..1777978 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -10,6 +10,7 @@ import { Tickets, Calendar, CircleCheck, + DocumentChecked, User, UserFilled, } from '@element-plus/icons-vue' @@ -41,6 +42,7 @@ const pageTitle = computed(() => { 'teaching-tasks': '教学任务', schedules: '排课与课表', 'course-selections': '选课与教学班', + grades: '成绩与学业档案', users: '用户与权限', } return titles[String(route.name)] ?? '教务管理' @@ -129,6 +131,13 @@ onMounted(() => auth.refresh().catch(() => undefined)) + + + + @@ -137,7 +146,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
第一阶段 · 核心可用版 -

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

+

教学运行、选课与成绩管理已就绪

diff --git a/web/src/router/index.ts b/web/src/router/index.ts index ee65261..fed0f2e 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -66,6 +66,14 @@ const router = createRouter({ roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'], }, }, + { + path: 'grades', + name: 'grades', + component: () => import('../views/GradesView.vue'), + meta: { + roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'], + }, + }, { path: 'users', name: 'users', diff --git a/web/src/style.css b/web/src/style.css index 4887a7b..cd0336a 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -378,6 +378,138 @@ button { cursor: pointer; } .roster-summary b { margin-top: 5px; font-size: 14px; } .roster-summary small { margin-top: 4px; color: #bec8e2; font-size: 10px; } +.grade-page { min-width: 0; } +.grade-toolbar { + min-height: 68px; padding: 13px 16px; display: flex; align-items: center; gap: 10px; + border: 1px solid var(--line); background: white; +} +.grade-toolbar .el-select { width: 210px; } +.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 10px; } +.grade-workspace { + min-height: 650px; display: grid; grid-template-columns: 285px minmax(0, 1fr); + border: 1px solid var(--line); background: white; +} +.grade-sheet-list { + min-width: 0; border-right: 1px solid var(--line); background: #fafbfc; +} +.grade-sheet-list > button { + width: 100%; min-height: 100px; padding: 16px 17px; display: grid; + grid-template-columns: 1fr auto; gap: 6px 10px; text-align: left; + border: none; border-bottom: 1px solid var(--line); border-left: 3px solid transparent; + color: var(--ink); background: transparent; +} +.grade-sheet-list > button:hover { background: white; } +.grade-sheet-list > button.active { border-left-color: var(--teal); background: white; } +.grade-sheet-list span, .grade-sheet-list small { + overflow: hidden; color: var(--muted); font-size: 9px; white-space: nowrap; text-overflow: ellipsis; +} +.grade-sheet-list b { + grid-column: 1 / -1; overflow: hidden; font-family: "STZhongsong", "Songti SC", serif; + font-size: 14px; white-space: nowrap; text-overflow: ellipsis; +} +.grade-sheet-list i { + padding: 3px 7px; border-radius: 10px; color: #777f8c; + background: #eceff3; font-size: 9px; font-style: normal; +} +.grade-sheet-list i.submitted { color: #a66b16; background: #fbefd9; } +.grade-sheet-list i.approved, .grade-sheet-list i.published { color: var(--teal); background: #e4f4f0; } +.grade-sheet-list i.returned { color: #b05252; background: #f8e7e7; } +.grade-register { min-width: 0; } +.grade-create-empty { + min-height: 620px; display: grid; place-content: center; justify-items: center; + padding: 30px; text-align: center; +} +.grade-create-empty > .el-icon { font-size: 50px; color: #98a5be; } +.grade-create-empty h3 { margin: 18px 0 8px; font-family: "STZhongsong", "Songti SC", serif; font-size: 23px; } +.grade-create-empty p { max-width: 440px; margin: 0 0 20px; color: var(--muted); font-size: 11px; line-height: 1.7; } +.grade-register-head { + min-height: 108px; padding: 21px 24px; display: flex; align-items: center; + justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line); +} +.grade-register-head span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .1em; } +.grade-register-head h3 { margin: 8px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 22px; } +.grade-register-head p { margin: 0; color: var(--muted); font-size: 10px; } +.grade-register-head > i { + padding: 7px 12px; border-radius: 15px; color: #657083; background: #edf0f4; + font-size: 10px; font-style: normal; +} +.grade-register-head > i.submitted { color: #a66b16; background: #fbefd9; } +.grade-register-head > i.approved, .grade-register-head > i.published { color: var(--teal); background: #e4f4f0; } +.grade-register-head > i.returned { color: #ad4e4e; background: #f7e5e5; } +.grade-register-ruler { + min-height: 92px; display: grid; grid-template-columns: repeat(4, 120px) 1fr; + color: white; background: + repeating-linear-gradient(90deg, transparent 0, transparent 23px, rgba(255,255,255,.07) 24px), + linear-gradient(105deg, #17295a, #263f80); +} +.grade-register-ruler > div { padding: 17px 19px; border-right: 1px solid rgba(255,255,255,.14); } +.grade-register-ruler span, .grade-register-ruler b { display: block; } +.grade-register-ruler span { color: #b6c0dc; font-size: 9px; } +.grade-register-ruler b { margin-top: 7px; color: #5bd4c3; font: 700 24px/1 Consolas, monospace; } +.grade-register-ruler p { margin: 0; padding: 20px 22px; align-self: center; color: #cbd3e8; font-size: 10px; text-align: right; } +.grade-register > .el-alert { border-radius: 0; } +.grade-table-scroll { max-width: 100%; overflow-x: auto; } +.grade-entry-table { min-width: 800px; } +.grade-entry-table .el-input-number { width: 92px; } +.grade-entry-table .el-input-number .el-input__inner { text-align: center; } +.total-score { color: var(--indigo); font: 700 15px/1 Consolas, monospace; } +.total-score.excellent { color: var(--teal); } +.total-score.failed { color: #b44c4c; } +.total-score.pending { color: #a0a6b0; } +.grade-actions { + min-height: 78px; padding: 14px 20px; display: flex; align-items: center; + justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line); background: #fafbfc; +} +.grade-actions > div { margin-right: auto; } +.grade-actions span, .grade-actions b { display: block; } +.grade-actions span { color: var(--muted); font-size: 9px; } +.grade-actions b { margin-top: 4px; font-size: 13px; } +.grade-actions .el-button + .el-button { margin-left: 0; } +.grade-weight-form { margin-top: 18px; } +.transcript-identity { + min-height: 135px; padding: 24px 28px; display: flex; align-items: center; + justify-content: space-between; gap: 25px; 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 72%, #176b70); + background-size: 28px 28px, 28px 28px, auto; +} +.transcript-identity span { color: #5bd4c3; font: 700 9px/1 Consolas, monospace; letter-spacing: .15em; } +.transcript-identity h3 { margin: 9px 0 6px; font-family: "STZhongsong", "Songti SC", serif; font-size: 25px; } +.transcript-identity p { margin: 0; color: #c2cce5; font-size: 10px; } +.transcript-identity label { width: 265px; } +.transcript-identity label > span { display: block; margin-bottom: 7px; } +.transcript-identity .el-select { width: 100%; } +.transcript-ribbon { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--line); background: white; } +.transcript-ribbon > div { min-height: 85px; padding: 17px 22px; display: flex; align-items: baseline; gap: 7px; border-right: 1px solid var(--line); } +.transcript-ribbon > div:last-child { border-right: none; } +.transcript-ribbon span { color: var(--muted); font-size: 10px; } +.transcript-ribbon b { margin-left: auto; color: var(--indigo); font: 700 27px/1 Consolas, monospace; } +.transcript-ribbon small { color: var(--muted); font-size: 9px; } +.transcript-list { display: grid; gap: 10px; } +.transcript-list > article { + min-height: 114px; padding: 18px 21px; display: grid; + grid-template-columns: minmax(230px, 1fr) minmax(200px, 1.4fr) 105px; + align-items: center; gap: 28px; border: 1px solid var(--line); background: white; +} +.transcript-course span { color: var(--teal); font-size: 9px; } +.transcript-course h3 { margin: 7px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 18px; } +.transcript-course p { margin: 0; color: var(--muted); font-size: 10px; } +.grade-scale { height: 22px; display: grid; grid-template-columns: repeat(11, 1fr); position: relative; border-bottom: 2px solid #cfd5de; } +.grade-scale > i { border-left: 1px solid #cfd5de; } +.grade-scale > span { + position: absolute; bottom: -12px; transform: translateX(-50%); padding: 4px 6px; + border-radius: 9px; color: white; background: var(--indigo); font: 700 10px Consolas, monospace; +} +.grade-scale > span.excellent { background: var(--teal); } +.grade-scale > span.failed { background: #b44c4c; } +.transcript-result { padding-left: 20px; border-left: 1px solid var(--line); text-align: right; } +.transcript-result span, .transcript-result b, .transcript-result small { display: block; } +.transcript-result span { color: var(--muted); font-size: 9px; } +.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; } + .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; } @@ -475,6 +607,14 @@ button { cursor: pointer; } } .round-actions { justify-content: flex-start; } .offering-grid { grid-template-columns: 1fr; } + .grade-workspace { display: block; } + .grade-sheet-list { max-height: 300px; overflow-y: auto; border-right: none; border-bottom: 1px solid var(--line); } + .grade-register-ruler { grid-template-columns: repeat(4, 1fr); } + .grade-register-ruler > div { padding: 14px; } + .grade-register-ruler p { grid-column: 1 / -1; padding: 10px 14px; text-align: left; border-top: 1px solid rgba(255,255,255,.14); } + .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; } .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; } @@ -498,6 +638,22 @@ button { cursor: pointer; } .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%; } + .grade-toolbar { flex-wrap: wrap; } + .grade-toolbar .el-select { flex: 1 1 140px; width: auto; } + .grade-toolbar > span { width: 100%; margin-left: 0; } + .grade-register-head { align-items: flex-start; } + .grade-register-ruler { grid-template-columns: 1fr 1fr; } + .grade-register-ruler > div:nth-child(2) { border-right: none; } + .grade-actions { align-items: stretch; flex-direction: column; } + .grade-actions > div { margin: 0 0 5px; } + .grade-actions .el-button { width: 100%; } + .transcript-identity { align-items: stretch; flex-direction: column; } + .transcript-identity label { width: 100%; } + .transcript-ribbon { grid-template-columns: 1fr; } + .transcript-ribbon > div { min-height: 65px; border-right: none; border-bottom: 1px solid var(--line); } + .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; } .el-drawer { width: 100% !important; } } diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 4c41cbb..e774644 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -112,19 +112,25 @@ onMounted(async () => { {{ data.counts.courseSelectionRounds ?? 0 }} 个批次 · {{ data.counts.courseEnrollments ?? 0 }} 条有效选课 已建立 +
+ 成绩管理 + {{ data.counts.gradeSheets ?? 0 }} 张登记册 · {{ data.counts.publishedGradeSheets ?? 0 }} 张已发布 + 已建立 +
NEXT MILESTONE

下一段业务链

-

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

+

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

基础底座 人员档案 培养方案 排课课表 学生选课 + 成绩管理
diff --git a/web/src/views/GradesView.vue b/web/src/views/GradesView.vue new file mode 100644 index 0000000..4e2d028 --- /dev/null +++ b/web/src/views/GradesView.vue @@ -0,0 +1,499 @@ + + +