已完成“成绩管理与学业档案”模块:

教师批量录入平时、期中、期末成绩。
自动计算总评、绩点和及格状态。
支持缺考、缓考、免修。
教师提交 → 学院审核/退回 → 校级发布。
学生只能查看正式发布成绩。
自动统计平均分、完成度、及格率和加权 GPA。
SQLite 本地增量升级和 MySQL 正式迁移均已完成。
分级权限在前后端同时校验。
This commit is contained in:
2026-07-24 15:33:47 +08:00 Unverified
parent 151a22554f
commit af52e27a3a
18 changed files with 3686 additions and 22 deletions
@@ -33,6 +33,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
Set<CourseSelectionOffering>();
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
public DbSet<GradeSheet> GradeSheets => Set<GradeSheet>();
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -317,6 +319,40 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<GradeSheet>(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<GradeRecord>(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<AuditLog>(entity =>
{
entity.Property(x => x.Method).HasMaxLength(10);
@@ -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
}
]
});
@@ -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");
"""
];
}
@@ -0,0 +1,111 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class GradeManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GradeSheets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
RegularWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
MidtermWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
FinalWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "char(36)", nullable: false),
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
RegularScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
MidtermScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
FinalScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
TotalScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
GradePoint = table.Column<decimal>(type: "decimal(3,1)", precision: 3, scale: 1, nullable: true),
ExamStatus = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GradeRecords");
migrationBuilder.DropTable(
name: "GradeSheets");
}
}
}
@@ -634,6 +634,114 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("CurriculumPlans");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("ExamStatus")
.HasColumnType("int");
b.Property<decimal?>("FinalScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal?>("GradePoint")
.HasPrecision(3, 1)
.HasColumnType("decimal(3,1)");
b.Property<Guid>("GradeSheetId")
.HasColumnType("char(36)");
b.Property<decimal?>("MidtermScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<string>("Notes")
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<decimal?>("RegularScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<decimal?>("TotalScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("FinalWeight")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal>("MidtermWeight")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("RegularWeight")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<string>("ReviewComment")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime?>("SubmittedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("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<Guid>("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");