选课批次:时间窗口、退课截止、学分上限、开放/关闭。

教学班投放:容量、班级范围、全校开放。
学生选退课:容量、重复课程、学分上限、课表冲突校验。
教学班实时名单。
校级、学院级、学生角色分级操作。
SQLite 本地增量升级及演示数据。
MySQL 正式 EF Core 迁移。
Vue 已编译进 wwwroot,单服务运行无需 npm run dev。
学生端和管理端均完成桌面、手机响应式检查。
This commit is contained in:
2026-07-24 15:17:26 +08:00 Unverified
parent bcc4d33bd5
commit 151a22554f
19 changed files with 3972 additions and 6 deletions
@@ -28,6 +28,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>();
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
Set<CourseSelectionOffering>();
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -267,6 +272,51 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseSelectionRound>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.MaxCredits).HasPrecision(6, 1);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
entity.HasOne(x => x.AcademicTerm)
.WithMany()
.HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseSelectionOffering>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
x.CourseSelectionRoundId,
x.TeachingTaskId
}).IsUnique();
entity.HasOne(x => x.CourseSelectionRound)
.WithMany(x => x.Offerings)
.HasForeignKey(x => x.CourseSelectionRoundId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask)
.WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseEnrollment>(entity =>
{
entity.HasIndex(x => new { x.CourseSelectionOfferingId, x.StudentId })
.IsUnique();
entity.HasIndex(x => new { x.StudentId, x.Status });
entity.HasOne(x => x.CourseSelectionOffering)
.WithMany(x => x.Enrollments)
.HasForeignKey(x => x.CourseSelectionOfferingId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student)
.WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AuditLog>(entity =>
{
entity.Property(x => x.Method).HasMaxLength(10);
@@ -421,6 +421,42 @@ public sealed class DatabaseInitializer(
}
await SeedDevelopmentUsersAsync(computerCollege.Id);
await SeedDevelopmentCourseSelectionAsync();
}
private async Task SeedDevelopmentCourseSelectionAsync()
{
if (await db.CourseSelectionRounds.AnyAsync())
{
return;
}
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var task = await db.TeachingTasks.SingleAsync(
x => x.TaskNumber == "2026-1-CS101-01");
var now = DateTime.UtcNow;
db.CourseSelectionRounds.Add(new CourseSelectionRound
{
AcademicTermId = term.Id,
Name = "2026—2027 学年第一学期第一轮选课",
StartsAt = now.AddDays(-2),
EndsAt = now.AddDays(14),
WithdrawalEndsAt = now.AddDays(21),
MaxCredits = 30,
Status = CourseSelectionRoundStatus.Open,
Notes = "本地开发演示轮次,可用于验证选课、退课和教学班名单。",
Offerings =
[
new CourseSelectionOffering
{
TeachingTaskId = task.Id,
Capacity = 60,
IsOpenToAll = false,
Notes = "面向计科 2026-1 班开放。"
}
]
});
await db.SaveChangesAsync();
}
private async Task SeedDevelopmentUsersAsync(Guid collegeId)
@@ -11,6 +11,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string TeachingTasksMigration = "20260724_03_teaching_tasks";
private const string SchedulesMigration = "20260724_04_schedules";
private const string ClassCounselorMigration = "20260724_05_class_counselor";
private const string CourseSelectionMigration = "20260724_06_course_selection";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -58,6 +59,10 @@ public sealed class DevelopmentSqliteMigrator(
? ClassCounselorStatements.Skip(1)
: ClassCounselorStatements,
cancellationToken);
await ApplyMigrationAsync(
CourseSelectionMigration,
CourseSelectionStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -426,4 +431,79 @@ public sealed class DevelopmentSqliteMigrator(
ON "AdministrativeClasses" ("CounselorUserId");
"""
];
private static readonly string[] CourseSelectionStatements =
[
"""
CREATE TABLE IF NOT EXISTS "CourseSelectionRounds" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseSelectionRounds" PRIMARY KEY,
"AcademicTermId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"StartsAt" TEXT NOT NULL,
"EndsAt" TEXT NOT NULL,
"WithdrawalEndsAt" TEXT NOT NULL,
"MaxCredits" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CourseSelectionRounds_AcademicTerms_AcademicTermId"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CourseSelectionRounds_AcademicTermId_Status"
ON "CourseSelectionRounds" ("AcademicTermId", "Status");
""",
"""
CREATE TABLE IF NOT EXISTS "CourseSelectionOfferings" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseSelectionOfferings" PRIMARY KEY,
"CourseSelectionRoundId" TEXT NOT NULL,
"TeachingTaskId" TEXT NOT NULL,
"Capacity" INTEGER NOT NULL,
"IsOpenToAll" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CourseSelectionOfferings_CourseSelectionRounds_CourseSelectionRoundId"
FOREIGN KEY ("CourseSelectionRoundId") REFERENCES "CourseSelectionRounds" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_CourseSelectionOfferings_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseSelectionOfferings_CourseSelectionRoundId_TeachingTaskId"
ON "CourseSelectionOfferings" ("CourseSelectionRoundId", "TeachingTaskId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CourseSelectionOfferings_TeachingTaskId"
ON "CourseSelectionOfferings" ("TeachingTaskId");
""",
"""
CREATE TABLE IF NOT EXISTS "CourseEnrollments" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseEnrollments" PRIMARY KEY,
"CourseSelectionOfferingId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"EnrolledAt" TEXT NOT NULL,
"WithdrawnAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CourseEnrollments_CourseSelectionOfferings_CourseSelectionOfferingId"
FOREIGN KEY ("CourseSelectionOfferingId") REFERENCES "CourseSelectionOfferings" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_CourseEnrollments_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseEnrollments_CourseSelectionOfferingId_StudentId"
ON "CourseEnrollments" ("CourseSelectionOfferingId", "StudentId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CourseEnrollments_StudentId_Status"
ON "CourseEnrollments" ("StudentId", "Status");
"""
];
}
@@ -0,0 +1,145 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class CourseSelection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CourseSelectionRounds",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
StartsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
EndsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
WithdrawalEndsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
MaxCredits = table.Column<decimal>(type: "decimal(6,1)", precision: 6, scale: 1, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, 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_CourseSelectionRounds", x => x.Id);
table.ForeignKey(
name: "FK_CourseSelectionRounds_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "CourseSelectionOfferings",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CourseSelectionRoundId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Capacity = table.Column<int>(type: "int", nullable: false),
IsOpenToAll = table.Column<bool>(type: "tinyint(1)", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, 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_CourseSelectionOfferings", x => x.Id);
table.ForeignKey(
name: "FK_CourseSelectionOfferings_CourseSelectionRounds_CourseSelecti~",
column: x => x.CourseSelectionRoundId,
principalTable: "CourseSelectionRounds",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CourseSelectionOfferings_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "CourseEnrollments",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CourseSelectionOfferingId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
EnrolledAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
WithdrawnAt = 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_CourseEnrollments", x => x.Id);
table.ForeignKey(
name: "FK_CourseEnrollments_CourseSelectionOfferings_CourseSelectionOf~",
column: x => x.CourseSelectionOfferingId,
principalTable: "CourseSelectionOfferings",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CourseEnrollments_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CourseEnrollments_CourseSelectionOfferingId_StudentId",
table: "CourseEnrollments",
columns: new[] { "CourseSelectionOfferingId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CourseEnrollments_StudentId_Status",
table: "CourseEnrollments",
columns: new[] { "StudentId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_CourseSelectionOfferings_CourseSelectionRoundId_TeachingTask~",
table: "CourseSelectionOfferings",
columns: new[] { "CourseSelectionRoundId", "TeachingTaskId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CourseSelectionOfferings_TeachingTaskId",
table: "CourseSelectionOfferings",
column: "TeachingTaskId");
migrationBuilder.CreateIndex(
name: "IX_CourseSelectionRounds_AcademicTermId_Status",
table: "CourseSelectionRounds",
columns: new[] { "AcademicTermId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CourseEnrollments");
migrationBuilder.DropTable(
name: "CourseSelectionOfferings");
migrationBuilder.DropTable(
name: "CourseSelectionRounds");
}
}
}
@@ -382,6 +382,128 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Courses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("CourseSelectionOfferingId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("EnrolledAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("Capacity")
.HasColumnType("int");
b.Property<Guid>("CourseSelectionRoundId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsOpenToAll")
.HasColumnType("tinyint(1)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("EndsAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("MaxCredits")
.HasPrecision(6, 1)
.HasColumnType("decimal(6,1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime>("StartsAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("WithdrawalEndsAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId", "Status");
b.ToTable("CourseSelectionRounds");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b =>
{
b.Property<Guid>("Id")
@@ -1218,6 +1340,55 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("College");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", "CourseSelectionOffering")
.WithMany("Enrollments")
.HasForeignKey("CourseSelectionOfferingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CourseSelectionOffering");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound")
.WithMany("Offerings")
.HasForeignKey("CourseSelectionRoundId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CourseSelectionRound");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("AcademicTerm");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
@@ -1453,6 +1624,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Students");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{
b.Navigation("Enrollments");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
{
b.Navigation("Offerings");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b =>
{
b.Navigation("Courses");