排课版本新建、编辑、复制、发布、归档和删除。

周课表视图,支持12节课、单双周和起止周。
自动校验教师、行政班、教室、时间段冲突。
校验教室容量、教学任务状态和学期范围。
SQLite 开发环境自动升级,MySQL 提供正式 EF Core 迁移。
Vue 已编译为静态文件并随 ASP.NET Core 发布,无需 npm run dev。
桌面端和390px移动端页面均已验收。
This commit is contained in:
2026-07-24 14:27:01 +08:00 Unverified
parent 628944f64d
commit 0b463fa4f2
19 changed files with 2950 additions and 9 deletions
@@ -26,6 +26,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>();
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -221,6 +223,42 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<SchedulePlan>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Version).HasMaxLength(30);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.AcademicTermId, x.Version }).IsUnique();
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
entity.HasOne(x => x.AcademicTerm)
.WithMany()
.HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ScheduleEntry>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
x.SchedulePlanId,
x.DayOfWeek,
x.StartPeriod
});
entity.HasOne(x => x.SchedulePlan)
.WithMany(x => x.Entries)
.HasForeignKey(x => x.SchedulePlanId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask)
.WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom)
.WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AuditLog>(entity =>
{
entity.Property(x => x.Method).HasMaxLength(10);
@@ -389,6 +389,36 @@ public sealed class DatabaseInitializer(
});
await db.SaveChangesAsync();
}
if (!await db.SchedulePlans.AnyAsync())
{
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var task = await db.TeachingTasks.SingleAsync(x => x.TaskNumber == "2026-1-CS101-01");
var classroom = await db.Classrooms.SingleAsync(x => x.Code == "J1-201");
db.SchedulePlans.Add(new SchedulePlan
{
AcademicTermId = term.Id,
Name = "2026—2027 学年第一学期正式课表",
Version = "V1",
Status = SchedulePlanStatus.Published,
PublishedAt = DateTime.UtcNow,
Entries =
[
new ScheduleEntry
{
TeachingTaskId = task.Id,
ClassroomId = classroom.Id,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 16,
WeekPattern = WeekPattern.All
}
]
});
await db.SaveChangesAsync();
}
}
private static void EnsureSucceeded(IdentityResult result, string action)
@@ -9,6 +9,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string PeopleAndCoursesMigration = "20260724_01_people_courses";
private const string CurriculumPlansMigration = "20260724_02_curriculum_plans";
private const string TeachingTasksMigration = "20260724_03_teaching_tasks";
private const string SchedulesMigration = "20260724_04_schedules";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -38,6 +39,10 @@ public sealed class DevelopmentSqliteMigrator(
TeachingTasksMigration,
TeachingTasksStatements,
cancellationToken);
await ApplyMigrationAsync(
SchedulesMigration,
SchedulesStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -332,4 +337,66 @@ public sealed class DevelopmentSqliteMigrator(
ON "TeachingTaskClasses" ("AdministrativeClassId");
"""
];
private static readonly string[] SchedulesStatements =
[
"""
CREATE TABLE IF NOT EXISTS "SchedulePlans" (
"Id" TEXT NOT NULL CONSTRAINT "PK_SchedulePlans" PRIMARY KEY,
"AcademicTermId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Version" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"Notes" TEXT NULL,
"PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_SchedulePlans_AcademicTerms_AcademicTermId"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_SchedulePlans_AcademicTermId_Version"
ON "SchedulePlans" ("AcademicTermId", "Version");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_SchedulePlans_AcademicTermId_Status"
ON "SchedulePlans" ("AcademicTermId", "Status");
""",
"""
CREATE TABLE IF NOT EXISTS "ScheduleEntries" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ScheduleEntries" PRIMARY KEY,
"SchedulePlanId" TEXT NOT NULL,
"TeachingTaskId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL,
"DayOfWeek" INTEGER NOT NULL,
"StartPeriod" INTEGER NOT NULL,
"PeriodCount" INTEGER NOT NULL,
"StartWeek" INTEGER NOT NULL,
"EndWeek" INTEGER NOT NULL,
"WeekPattern" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ScheduleEntries_SchedulePlans_SchedulePlanId"
FOREIGN KEY ("SchedulePlanId") REFERENCES "SchedulePlans" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_ScheduleEntries_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_ScheduleEntries_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_ScheduleEntries_SchedulePlanId_DayOfWeek_StartPeriod"
ON "ScheduleEntries" ("SchedulePlanId", "DayOfWeek", "StartPeriod");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_ScheduleEntries_TeachingTaskId"
ON "ScheduleEntries" ("TeachingTaskId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_ScheduleEntries_ClassroomId"
ON "ScheduleEntries" ("ClassroomId");
"""
];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class Schedules : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SchedulePlans",
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),
Version = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, 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_SchedulePlans", x => x.Id);
table.ForeignKey(
name: "FK_SchedulePlans_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ScheduleEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
SchedulePlanId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
DayOfWeek = table.Column<int>(type: "int", nullable: false),
StartPeriod = table.Column<int>(type: "int", nullable: false),
PeriodCount = table.Column<int>(type: "int", nullable: false),
StartWeek = table.Column<int>(type: "int", nullable: false),
EndWeek = table.Column<int>(type: "int", nullable: false),
WeekPattern = 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_ScheduleEntries", x => x.Id);
table.ForeignKey(
name: "FK_ScheduleEntries_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ScheduleEntries_SchedulePlans_SchedulePlanId",
column: x => x.SchedulePlanId,
principalTable: "SchedulePlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ScheduleEntries_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ClassroomId",
table: "ScheduleEntries",
column: "ClassroomId");
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_SchedulePlanId_DayOfWeek_StartPeriod",
table: "ScheduleEntries",
columns: new[] { "SchedulePlanId", "DayOfWeek", "StartPeriod" });
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_TeachingTaskId",
table: "ScheduleEntries",
column: "TeachingTaskId");
migrationBuilder.CreateIndex(
name: "IX_SchedulePlans_AcademicTermId_Status",
table: "SchedulePlans",
columns: new[] { "AcademicTermId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_SchedulePlans_AcademicTermId_Version",
table: "SchedulePlans",
columns: new[] { "AcademicTermId", "Version" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ScheduleEntries");
migrationBuilder.DropTable(
name: "SchedulePlans");
}
}
}
@@ -557,6 +557,105 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Majors");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("ClassroomId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("DayOfWeek")
.HasColumnType("int");
b.Property<int>("EndWeek")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("PeriodCount")
.HasColumnType("int");
b.Property<Guid>("SchedulePlanId")
.HasColumnType("char(36)");
b.Property<int>("StartPeriod")
.HasColumnType("int");
b.Property<int>("StartWeek")
.HasColumnType("int");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("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<Guid>("Id")
@@ -1159,6 +1258,44 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
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")
@@ -1314,6 +1451,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Modules");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
{
b.Navigation("Entries");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b =>
{
b.Navigation("Classes");
@@ -0,0 +1,62 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Scheduling;
public static class ScheduleConflictDetector
{
public static string? FindConflict(IReadOnlyList<ScheduleEntry> entries)
{
for (var i = 0; i < entries.Count; i++)
{
for (var j = i + 1; j < entries.Count; j++)
{
var first = entries[i];
var second = entries[j];
if (!TimeOverlaps(first, second)) continue;
var reason = ConflictReason(first, second);
if (reason is not null)
return $"“{first.TeachingTask!.Name}”与“{second.TeachingTask!.Name}”存在{reason}冲突。";
}
}
return null;
}
public static bool TimeOverlaps(ScheduleEntry first, ScheduleEntry second) =>
first.DayOfWeek == second.DayOfWeek &&
first.StartPeriod < second.StartPeriod + second.PeriodCount &&
second.StartPeriod < first.StartPeriod + first.PeriodCount &&
WeeksOverlap(first, second);
public static string? ConflictReason(ScheduleEntry first, ScheduleEntry second)
{
if (first.ClassroomId == second.ClassroomId) return "教室";
var firstTeachers = first.TeachingTask!.Teachers.Select(x => x.TeacherId).ToHashSet();
if (second.TeachingTask!.Teachers.Any(x => firstTeachers.Contains(x.TeacherId)))
return "教师";
var firstClasses = first.TeachingTask.Classes
.Select(x => x.AdministrativeClassId)
.ToHashSet();
return second.TeachingTask.Classes.Any(x =>
firstClasses.Contains(x.AdministrativeClassId))
? "行政班"
: null;
}
private static bool WeeksOverlap(ScheduleEntry first, ScheduleEntry second)
{
var start = Math.Max(first.StartWeek, second.StartWeek);
var end = Math.Min(first.EndWeek, second.EndWeek);
for (var week = start; week <= end; week++)
{
if (IncludesWeek(first.WeekPattern, week) &&
IncludesWeek(second.WeekPattern, week))
return true;
}
return false;
}
private static bool IncludesWeek(WeekPattern pattern, int week) =>
pattern == WeekPattern.All ||
pattern == WeekPattern.Odd && week % 2 == 1 ||
pattern == WeekPattern.Even && week % 2 == 0;
}