学年学期页面新增“设为当前”“归档”“撤销归档”,当前学期不能归档、删除或直接取消;已归档学期需先撤销才能重新设为当前。[BaseDataController.cs (line 286)](/E:/jiaowu/src/Jiaowu.Api/Controllers/BaseDataController.cs:286)
切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[academicTerms.ts (line 1)](/E:/jiaowu/web/src/utils/academicTerms.ts:1) 桌面使用分层表格,390px 手机端改为独立学期卡片,操作不会再挤压表格。[BaseDataView.vue (line 211)](/E:/jiaowu/web/src/views/BaseDataView.vue:211) 归档定义为“历史显示状态”,不是成绩冻结:补考成绩仍可录入并回写,已发布成绩仍可走成绩更正审批。 已加入 SQLite 开发迁移、MySQL 正式迁移和切换/归档/撤销测试。[20260726143000_AcademicTermArchiving.cs (line 1)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs:1)
This commit is contained in:
@@ -287,7 +287,9 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.AcademicTerms.AsNoTracking()
|
||||
.OrderByDescending(x => x.StartDate)
|
||||
.OrderByDescending(x => x.IsCurrent)
|
||||
.ThenBy(x => x.IsArchived)
|
||||
.ThenByDescending(x => x.StartDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
[HttpPost("terms")]
|
||||
@@ -298,11 +300,20 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
if (request.EndDate <= request.StartDate)
|
||||
return ValidationProblem("学期结束日期必须晚于开始日期。");
|
||||
if (request.IsCurrent && !request.IsEnabled)
|
||||
return ValidationProblem("当前学期必须保持启用。");
|
||||
|
||||
if (request.IsCurrent)
|
||||
await db.AcademicTerms.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(x => x.IsCurrent, false),
|
||||
cancellationToken);
|
||||
var hasCurrentTerm = await db.AcademicTerms
|
||||
.AnyAsync(x => x.IsCurrent, cancellationToken);
|
||||
var shouldBeCurrent = request.IsCurrent || (!hasCurrentTerm && request.IsEnabled);
|
||||
if (shouldBeCurrent)
|
||||
{
|
||||
var currentTerms = await db.AcademicTerms
|
||||
.Where(x => x.IsCurrent)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var currentTerm in currentTerms)
|
||||
currentTerm.IsCurrent = false;
|
||||
}
|
||||
|
||||
var entity = new AcademicTerm
|
||||
{
|
||||
@@ -312,7 +323,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
Season = request.Season,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
IsCurrent = request.IsCurrent,
|
||||
IsCurrent = shouldBeCurrent,
|
||||
IsEnabled = request.IsEnabled
|
||||
};
|
||||
return await CreateAsync(entity, "GetTerms", cancellationToken);
|
||||
@@ -327,10 +338,22 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
if (request.EndDate <= request.StartDate)
|
||||
return ValidationProblem("学期结束日期必须晚于开始日期。");
|
||||
if (entity.IsCurrent && !request.IsCurrent)
|
||||
return ConflictProblem("不能直接取消当前学期,请将另一个学期设为当前。");
|
||||
if (request.IsCurrent && entity.IsArchived)
|
||||
return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。");
|
||||
if (request.IsCurrent && !request.IsEnabled)
|
||||
return ValidationProblem("当前学期必须保持启用。");
|
||||
if (request.IsCurrent)
|
||||
await db.AcademicTerms.Where(x => x.Id != id).ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(x => x.IsCurrent, false),
|
||||
cancellationToken);
|
||||
{
|
||||
var currentTerms = await db.AcademicTerms
|
||||
.Where(x => x.Id != id && x.IsCurrent)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var currentTerm in currentTerms)
|
||||
currentTerm.IsCurrent = false;
|
||||
}
|
||||
ApplyCatalog(entity, request);
|
||||
entity.AcademicYear = request.AcademicYear.Trim();
|
||||
entity.Season = request.Season;
|
||||
@@ -341,6 +364,64 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpPost("terms/{id:guid}/set-current")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
public async Task<ActionResult<AcademicTerm>> SetCurrentTerm(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
if (entity.IsArchived)
|
||||
return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。");
|
||||
if (!entity.IsEnabled)
|
||||
return ConflictProblem("停用学期不能设为当前,请先启用。");
|
||||
if (entity.IsCurrent) return entity;
|
||||
|
||||
var currentTerms = await db.AcademicTerms
|
||||
.Where(x => x.Id != id && x.IsCurrent)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var currentTerm in currentTerms)
|
||||
currentTerm.IsCurrent = false;
|
||||
entity.IsCurrent = true;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpPost("terms/{id:guid}/archive")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
public async Task<ActionResult<AcademicTerm>> ArchiveTerm(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
if (entity.IsCurrent)
|
||||
return ConflictProblem("当前学期不能归档,请先切换到新的当前学期。");
|
||||
if (entity.IsArchived) return entity;
|
||||
|
||||
entity.IsArchived = true;
|
||||
entity.ArchivedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpPost("terms/{id:guid}/unarchive")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
public async Task<ActionResult<AcademicTerm>> UnarchiveTerm(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
if (!entity.IsArchived) return entity;
|
||||
|
||||
entity.IsArchived = false;
|
||||
entity.ArchivedAt = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("classrooms")]
|
||||
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Classrooms.AsNoTracking()
|
||||
@@ -447,6 +528,8 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
};
|
||||
|
||||
if (entity is null) return NotFound();
|
||||
if (entity is AcademicTerm { IsCurrent: true })
|
||||
return ConflictProblem("当前学期不能删除,请先切换到其他学期。");
|
||||
db.Remove(entity);
|
||||
try
|
||||
{
|
||||
@@ -508,6 +591,14 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.SortOrder = request.SortOrder;
|
||||
entity.IsEnabled = request.IsEnabled;
|
||||
}
|
||||
|
||||
private ObjectResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "当前操作无法完成",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
|
||||
public record CatalogRequest(
|
||||
|
||||
@@ -58,6 +58,8 @@ public sealed class CourseSelectionsController(
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
TermIsCurrent = x.AcademicTerm.IsCurrent,
|
||||
TermIsArchived = x.AcademicTerm.IsArchived,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.WithdrawalEndsAt,
|
||||
|
||||
@@ -44,6 +44,8 @@ public sealed class EvaluationsController(
|
||||
x.Status,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
TermIsCurrent = x.AcademicTerm.IsCurrent,
|
||||
TermIsArchived = x.AcademicTerm.IsArchived,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
Dimensions = x.Dimensions.Select(d => new
|
||||
|
||||
@@ -43,6 +43,8 @@ public sealed class ExamsController(
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
TermIsCurrent = x.AcademicTerm.IsCurrent,
|
||||
TermIsArchived = x.AcademicTerm.IsArchived,
|
||||
x.Status,
|
||||
SessionCount = x.Sessions.Count,
|
||||
x.Notes,
|
||||
|
||||
@@ -48,6 +48,8 @@ public sealed class MakeupExamsController(
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
TermIsCurrent = x.AcademicTerm.IsCurrent,
|
||||
TermIsArchived = x.AcademicTerm.IsArchived,
|
||||
x.Status,
|
||||
SessionCount = x.Sessions.Count,
|
||||
x.Notes,
|
||||
|
||||
@@ -220,6 +220,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
var terms = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderByDescending(x => x.IsCurrent)
|
||||
.ThenBy(x => x.IsArchived)
|
||||
.ThenByDescending(x => x.StartDate)
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -228,6 +229,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.IsCurrent,
|
||||
x.IsArchived,
|
||||
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
|
||||
plan.AcademicTermId == x.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published)
|
||||
@@ -235,6 +237,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
.ToListAsync(cancellationToken);
|
||||
var selectedTermId = academicTermId
|
||||
?? terms.FirstOrDefault(x => x.IsCurrent && x.HasPublishedTimetable)?.Id
|
||||
?? terms.FirstOrDefault(x => !x.IsArchived && x.HasPublishedTimetable)?.Id
|
||||
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
||||
var campuses = await db.Campuses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed class TimetablesController(
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.IsCurrent,
|
||||
x.IsArchived,
|
||||
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
|
||||
plan.AcademicTermId == x.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published) ||
|
||||
|
||||
@@ -56,6 +56,8 @@ public sealed class AcademicTerm : CatalogEntity
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
public bool IsCurrent { get; set; }
|
||||
public bool IsArchived { get; set; }
|
||||
public DateTime? ArchivedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum TermSeason
|
||||
|
||||
@@ -167,8 +167,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.HasForeignKey(x => x.BuildingId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<AcademicTerm>()
|
||||
.HasIndex(x => x.IsCurrent);
|
||||
builder.Entity<AcademicTerm>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => x.IsCurrent);
|
||||
entity.HasIndex(x => x.IsArchived);
|
||||
});
|
||||
|
||||
builder.Entity<Teacher>(entity =>
|
||||
{
|
||||
|
||||
@@ -46,6 +46,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260725_24_approval_tables";
|
||||
private const string AcademicWarningsMigration =
|
||||
"20260725_25_academic_warnings";
|
||||
private const string AcademicTermArchivingMigration =
|
||||
"20260726_27_academic_term_archiving";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -329,6 +331,19 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
MakeupExamAutoJobsMigration,
|
||||
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
|
||||
cancellationToken);
|
||||
|
||||
var academicTermArchivingExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('AcademicTerms')
|
||||
WHERE name = 'IsArchived'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
AcademicTermArchivingMigration,
|
||||
academicTermArchivingExists ? [] : AcademicTermArchivingStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -1688,6 +1703,13 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""ALTER TABLE "WarningRules" ADD COLUMN "LastCheckAt" TEXT NULL;"""
|
||||
];
|
||||
|
||||
private static readonly string[] AcademicTermArchivingStatements =
|
||||
[
|
||||
"""ALTER TABLE "AcademicTerms" ADD COLUMN "IsArchived" INTEGER NOT NULL DEFAULT 0;""",
|
||||
"""ALTER TABLE "AcademicTerms" ADD COLUMN "ArchivedAt" TEXT NULL;""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_AcademicTerms_IsArchived" ON "AcademicTerms" ("IsArchived");"""
|
||||
];
|
||||
|
||||
private const string TeachingEvaluationMigration = "TeachingEvaluation";
|
||||
|
||||
private static readonly string[] TeachingEvaluationStatements =
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260726143000_AcademicTermArchiving")]
|
||||
public partial class AcademicTermArchiving : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ArchivedAt",
|
||||
table: "AcademicTerms",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsArchived",
|
||||
table: "AcademicTerms",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AcademicTerms_IsArchived",
|
||||
table: "AcademicTerms",
|
||||
column: "IsArchived");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AcademicTerms_IsArchived",
|
||||
table: "AcademicTerms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ArchivedAt",
|
||||
table: "AcademicTerms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsArchived",
|
||||
table: "AcademicTerms");
|
||||
}
|
||||
}
|
||||
+8
@@ -29,6 +29,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime?>("ArchivedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
@@ -43,6 +46,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<bool>("IsCurrent")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsArchived")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
@@ -70,6 +76,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.HasIndex("IsCurrent");
|
||||
|
||||
b.HasIndex("IsArchived");
|
||||
|
||||
b.HasIndex("IsEnabled", "SortOrder");
|
||||
|
||||
b.ToTable("AcademicTerms");
|
||||
|
||||
@@ -194,6 +194,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
query = query.Where(x => x.Id == academicTermId);
|
||||
else
|
||||
query = query.OrderByDescending(x => x.IsCurrent)
|
||||
.ThenBy(x => x.IsArchived)
|
||||
.ThenByDescending(x => x.StartDate);
|
||||
return await query.Select(x => new TimetableTermDto(
|
||||
x.Id,
|
||||
|
||||
Reference in New Issue
Block a user