课程库 Excel 导入导出已完成:

支持下载标准导入模板。
导出严格沿用当前关键词、学院、分类、课程性质和状态筛选。
以课程编码为唯一键:存在则更新,不存在则新增。
校验学院编码、课程分类、课程性质、学分学时及考核方式。
整批原子导入,任一行错误则全部不写入,并提示具体行号。
学院管理员仍只能维护本学院的专业课和实践课,无法借 Excel 越权。
界面入口沿用现有课程库工具栏样式。
This commit is contained in:
2026-07-24 18:56:25 +08:00 Unverified
parent 5a7a8d530b
commit d7cf3f9e76
24 changed files with 3710 additions and 32 deletions
@@ -19,6 +19,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<AcademicTerm> AcademicTerms => Set<AcademicTerm>();
public DbSet<Teacher> Teachers => Set<Teacher>();
public DbSet<Student> Students => Set<Student>();
public DbSet<CourseCategory> CourseCategories => Set<CourseCategory>();
public DbSet<Course> Courses => Set<Course>();
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
@@ -78,6 +79,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
ConfigureCatalog<Building>(builder);
ConfigureCatalog<Classroom>(builder);
ConfigureCatalog<AcademicTerm>(builder);
ConfigureCatalog<CourseCategory>(builder);
ConfigureCatalog<Course>(builder);
builder.Entity<College>()
@@ -167,10 +169,15 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Credits).HasPrecision(5, 2);
entity.Property(x => x.Description).HasMaxLength(1000);
entity.HasIndex(x => new { x.CollegeId, x.Nature });
entity.HasIndex(x => x.CourseCategoryId);
entity.HasOne(x => x.College)
.WithMany()
.HasForeignKey(x => x.CollegeId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.CourseCategory)
.WithMany()
.HasForeignKey(x => x.CourseCategoryId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CurriculumPlan>(entity =>
@@ -28,6 +28,7 @@ public sealed class DatabaseInitializer(
await SeedRolesAsync();
await SeedAdministratorAsync();
await SeedCourseCategoriesAsync();
if (environment.IsDevelopment())
{
@@ -108,6 +109,55 @@ public sealed class DatabaseInitializer(
}
}
private async Task SeedCourseCategoriesAsync()
{
var defaults = new[]
{
("BASIC", "基础课程", 10),
("MORAL", "德育课程", 20),
("AESTHETIC", "美育课程", 30),
("LABOR", "劳动教育", 40),
("INNOVATION", "创新创业", 50),
("ENGLISH", "大学英语", 60),
("SPORTS", "大学体育", 70),
("MILITARY", "国防教育", 80),
("MAJOR", "专业教育", 90),
("PRACTICE", "实践教学", 100)
};
var existingCodes = (await db.CourseCategories
.Select(x => x.Code)
.ToListAsync())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var (code, name, sortOrder) in defaults)
{
if (existingCodes.Contains(code)) continue;
db.CourseCategories.Add(new CourseCategory
{
Code = code,
Name = name,
SortOrder = sortOrder
});
}
await db.SaveChangesAsync();
var categories = await db.CourseCategories
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase);
var uncategorizedCourses = await db.Courses
.Where(x => x.CourseCategoryId == null)
.ToListAsync();
foreach (var course in uncategorizedCourses)
{
var categoryCode = course.Nature switch
{
CourseNature.Practice => "PRACTICE",
CourseNature.MajorRequired or CourseNature.MajorElective => "MAJOR",
_ => "BASIC"
};
course.CourseCategoryId = categories[categoryCode].Id;
}
await db.SaveChangesAsync();
}
private async Task SeedDevelopmentDataAsync()
{
if (!await db.Campuses.AnyAsync())
@@ -242,6 +292,9 @@ public sealed class DatabaseInitializer(
});
}
var courseCategories = await db.CourseCategories
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase);
if (!await db.Courses.AnyAsync())
{
db.Courses.AddRange(
@@ -251,6 +304,7 @@ public sealed class DatabaseInitializer(
Name = "程序设计基础",
EnglishName = "Fundamentals of Programming",
CollegeId = computerCollege.Id,
CourseCategoryId = courseCategories["BASIC"].Id,
Credits = 4,
TotalHours = 64,
LectureHours = 40,
@@ -265,6 +319,7 @@ public sealed class DatabaseInitializer(
Name = "数据结构",
EnglishName = "Data Structures",
CollegeId = computerCollege.Id,
CourseCategoryId = courseCategories["BASIC"].Id,
Credits = 3.5m,
TotalHours = 56,
LectureHours = 40,
@@ -278,6 +333,7 @@ public sealed class DatabaseInitializer(
Name = "软件工程实践",
EnglishName = "Software Engineering Practice",
CollegeId = computerCollege.Id,
CourseCategoryId = courseCategories["PRACTICE"].Id,
Credits = 2,
TotalHours = 48,
LectureHours = 8,
@@ -286,7 +342,6 @@ public sealed class DatabaseInitializer(
AssessmentMethod = AssessmentMethod.Assessment
});
}
await db.SaveChangesAsync();
if (!await db.CurriculumPlans.AnyAsync())
@@ -18,6 +18,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string GraduationAuditsMigration = "20260724_10_graduation_audits";
private const string DegreeAwardsMigration = "20260724_11_degree_awards";
private const string GraduationClearanceMigration = "20260724_12_graduation_clearance";
private const string CourseCategoriesMigration = "20260724_13_course_categories";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -90,6 +91,22 @@ public sealed class DevelopmentSqliteMigrator(
GraduationClearanceMigration,
GraduationClearanceStatements,
cancellationToken);
var courseCategoryColumnExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('Courses')
WHERE name = 'CourseCategoryId'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
CourseCategoriesMigration,
courseCategoryColumnExists
? CourseCategoriesStatements.Where(
statement => !statement.StartsWith(
"ALTER TABLE", StringComparison.OrdinalIgnoreCase))
: CourseCategoriesStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -767,4 +784,34 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_GraduationClearanceRecords_GraduationClearanceItemId_StudentId" ON "GraduationClearanceRecords" ("GraduationClearanceItemId", "StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationClearanceRecords_StudentId_Status" ON "GraduationClearanceRecords" ("StudentId", "Status");"""
];
private static readonly string[] CourseCategoriesStatements =
[
"""
CREATE TABLE IF NOT EXISTS "CourseCategories" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseCategories" PRIMARY KEY,
"Code" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
"IsEnabled" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseCategories_Code"
ON "CourseCategories" ("Code");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CourseCategories_IsEnabled_SortOrder"
ON "CourseCategories" ("IsEnabled", "SortOrder");
""",
"""
ALTER TABLE "Courses" ADD COLUMN "CourseCategoryId" TEXT NULL;
""",
"""
CREATE INDEX IF NOT EXISTS "IX_Courses_CourseCategoryId"
ON "Courses" ("CourseCategoryId");
"""
];
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class CourseCategories : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CourseCategoryId",
table: "Courses",
type: "char(36)",
nullable: true);
migrationBuilder.CreateTable(
name: "CourseCategories",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Code = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseCategories", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Courses_CourseCategoryId",
table: "Courses",
column: "CourseCategoryId");
migrationBuilder.CreateIndex(
name: "IX_CourseCategories_Code",
table: "CourseCategories",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CourseCategories_IsEnabled_SortOrder",
table: "CourseCategories",
columns: new[] { "IsEnabled", "SortOrder" });
migrationBuilder.AddForeignKey(
name: "FK_Courses_CourseCategories_CourseCategoryId",
table: "Courses",
column: "CourseCategoryId",
principalTable: "CourseCategories",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Courses_CourseCategories_CourseCategoryId",
table: "Courses");
migrationBuilder.DropTable(
name: "CourseCategories");
migrationBuilder.DropIndex(
name: "IX_Courses_CourseCategoryId",
table: "Courses");
migrationBuilder.DropColumn(
name: "CourseCategoryId",
table: "Courses");
}
}
}
@@ -329,6 +329,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<Guid>("CollegeId")
.HasColumnType("char(36)");
b.Property<Guid?>("CourseCategoryId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
@@ -375,6 +378,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("Code")
.IsUnique();
b.HasIndex("CourseCategoryId");
b.HasIndex("CollegeId", "Nature");
b.HasIndex("IsEnabled", "SortOrder");
@@ -382,6 +387,44 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Courses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("CourseCategories");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
{
b.Property<Guid>("Id")
@@ -1948,7 +1991,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.CourseCategory", "CourseCategory")
.WithMany()
.HasForeignKey("CourseCategoryId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("College");
b.Navigation("CourseCategory");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>