diff --git a/src/Jiaowu.Api/Controllers/BaseDataController.cs b/src/Jiaowu.Api/Controllers/BaseDataController.cs index 874a5db..bbc98ec 100644 --- a/src/Jiaowu.Api/Controllers/BaseDataController.cs +++ b/src/Jiaowu.Api/Controllers/BaseDataController.cs @@ -287,7 +287,9 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase public async Task>> 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> 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> 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> 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> 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( diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index 29cf83e..07c9366 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -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, diff --git a/src/Jiaowu.Api/Controllers/EvaluationsController.cs b/src/Jiaowu.Api/Controllers/EvaluationsController.cs index e149d77..3c65c52 100644 --- a/src/Jiaowu.Api/Controllers/EvaluationsController.cs +++ b/src/Jiaowu.Api/Controllers/EvaluationsController.cs @@ -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 diff --git a/src/Jiaowu.Api/Controllers/ExamsController.cs b/src/Jiaowu.Api/Controllers/ExamsController.cs index 104edd3..da20067 100644 --- a/src/Jiaowu.Api/Controllers/ExamsController.cs +++ b/src/Jiaowu.Api/Controllers/ExamsController.cs @@ -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, diff --git a/src/Jiaowu.Api/Controllers/MakeupExamsController.cs b/src/Jiaowu.Api/Controllers/MakeupExamsController.cs index 94bc1d3..25e3f30 100644 --- a/src/Jiaowu.Api/Controllers/MakeupExamsController.cs +++ b/src/Jiaowu.Api/Controllers/MakeupExamsController.cs @@ -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, diff --git a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs index 9f0690e..3c8632a 100644 --- a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs +++ b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs @@ -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) diff --git a/src/Jiaowu.Api/Controllers/TimetablesController.cs b/src/Jiaowu.Api/Controllers/TimetablesController.cs index 5127fda..2a5029e 100644 --- a/src/Jiaowu.Api/Controllers/TimetablesController.cs +++ b/src/Jiaowu.Api/Controllers/TimetablesController.cs @@ -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) || diff --git a/src/Jiaowu.Api/Domain/Academic/OrganizationEntities.cs b/src/Jiaowu.Api/Domain/Academic/OrganizationEntities.cs index 4db1687..a013d0a 100644 --- a/src/Jiaowu.Api/Domain/Academic/OrganizationEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/OrganizationEntities.cs @@ -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 diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 2fdc56b..45cd414 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -167,8 +167,11 @@ public sealed class AppDbContext(DbContextOptions options) .HasForeignKey(x => x.BuildingId) .OnDelete(DeleteBehavior.Restrict); - builder.Entity() - .HasIndex(x => x.IsCurrent); + builder.Entity(entity => + { + entity.HasIndex(x => x.IsCurrent); + entity.HasIndex(x => x.IsArchived); + }); builder.Entity(entity => { diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 9501a52..87d2e3a 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -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( + """ + 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 = diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs new file mode 100644 index 0000000..9d4e9e2 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs @@ -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( + name: "ArchivedAt", + table: "AcademicTerms", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + 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"); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index 9a30431..1492e59 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -29,6 +29,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql .IsRequired() .HasColumnType("longtext"); + b.Property("ArchivedAt") + .HasColumnType("datetime(6)"); + b.Property("Code") .IsRequired() .HasMaxLength(40) @@ -43,6 +46,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Property("IsCurrent") .HasColumnType("tinyint(1)"); + b.Property("IsArchived") + .HasColumnType("tinyint(1)"); + b.Property("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"); diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs index e82b376..1dcda52 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs @@ -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, diff --git a/tests/Jiaowu.Api.Tests/BaseDataControllerTests.cs b/tests/Jiaowu.Api.Tests/BaseDataControllerTests.cs new file mode 100644 index 0000000..74c20a4 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/BaseDataControllerTests.cs @@ -0,0 +1,129 @@ +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class BaseDataControllerTests : IAsyncDisposable +{ + private readonly SqliteConnection connection = new("Data Source=:memory:"); + private readonly AppDbContext db; + private readonly BaseDataController controller; + + public BaseDataControllerTests() + { + connection.Open(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + db = new AppDbContext(options); + db.Database.EnsureCreated(); + controller = new BaseDataController(db); + } + + [Fact] + public async Task SetCurrentTerm_clears_previous_current_in_one_save() + { + var previous = CreateTerm("2025-2026-2", true); + var next = CreateTerm("2026-2027-1", false); + db.AcademicTerms.AddRange(previous, next); + await db.SaveChangesAsync(); + + var result = await controller.SetCurrentTerm(next.Id, CancellationToken.None); + + Assert.Null(result.Result); + db.ChangeTracker.Clear(); + Assert.False((await db.AcademicTerms.FindAsync(previous.Id))!.IsCurrent); + Assert.True((await db.AcademicTerms.FindAsync(next.Id))!.IsCurrent); + Assert.Equal(1, await db.AcademicTerms.CountAsync(x => x.IsCurrent)); + } + + [Fact] + public async Task Archive_requires_switching_current_and_is_reversible() + { + var current = CreateTerm("2026-2027-1", true); + var history = CreateTerm("2025-2026-2", false); + db.AcademicTerms.AddRange(current, history); + await db.SaveChangesAsync(); + + var currentResult = await controller.ArchiveTerm( + current.Id, CancellationToken.None); + Assert.IsType(currentResult.Result); + + var archiveResult = await controller.ArchiveTerm( + history.Id, CancellationToken.None); + Assert.Null(archiveResult.Result); + Assert.True(history.IsArchived); + Assert.NotNull(history.ArchivedAt); + Assert.True(history.IsEnabled); + + var unarchiveResult = await controller.UnarchiveTerm( + history.Id, CancellationToken.None); + Assert.Null(unarchiveResult.Result); + Assert.False(history.IsArchived); + Assert.Null(history.ArchivedAt); + } + + [Fact] + public async Task Archived_term_must_be_unarchived_before_becoming_current() + { + var current = CreateTerm("2026-2027-1", true); + var archived = CreateTerm("2025-2026-2", false); + archived.IsArchived = true; + archived.ArchivedAt = DateTime.UtcNow; + db.AcademicTerms.AddRange(current, archived); + await db.SaveChangesAsync(); + + var result = await controller.SetCurrentTerm( + archived.Id, CancellationToken.None); + + Assert.IsType(result.Result); + Assert.True(current.IsCurrent); + Assert.False(archived.IsCurrent); + } + + [Fact] + public async Task Current_term_cannot_be_cleared_without_a_replacement() + { + var current = CreateTerm("2026-2027-1", true); + db.AcademicTerms.Add(current); + await db.SaveChangesAsync(); + + var request = new TermRequest( + current.Code, + current.Name, + true, + current.AcademicYear, + current.Season, + current.StartDate, + current.EndDate, + false); + var result = await controller.UpdateTerm( + current.Id, request, CancellationToken.None); + + Assert.IsType(result.Result); + Assert.True(current.IsCurrent); + } + + private static AcademicTerm CreateTerm(string code, bool isCurrent) => + new() + { + Code = code, + Name = code, + AcademicYear = code[..9], + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 20), + IsCurrent = isCurrent, + IsEnabled = true + }; + + public async ValueTask DisposeAsync() + { + await db.DisposeAsync(); + await connection.DisposeAsync(); + } +} diff --git a/web/src/style.css b/web/src/style.css index a54cdce..e3ffbf6 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -182,6 +182,160 @@ button { cursor: pointer; } .table-toolbar .el-input { width: min(340px, 60vw); } .table-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; } .data-table { min-height: 360px; } + +.el-select-dropdown__item.academic-term-option { + transition: color 160ms ease, opacity 160ms ease, background-color 160ms ease; +} + +.el-select-dropdown__item.academic-term-option--current { + color: #17694b; + font-weight: 700; +} + +.el-select-dropdown__item.academic-term-option--historical { + color: #6f7d8c; + opacity: .76; +} + +.el-select-dropdown__item.academic-term-option--archived { + color: #98a2ad; + opacity: .62; +} + +.el-select-dropdown__item.academic-term-option--historical.is-hovering, +.el-select-dropdown__item.academic-term-option--archived.is-hovering { + opacity: 1; +} + +.academic-term-row--historical > .el-table__cell { + color: #73808e; + opacity: .78; +} + +.academic-term-row--archived > .el-table__cell { + color: #8e98a3; + background: #f8fafc; + opacity: .66; +} + +.academic-term-row--historical:hover > .el-table__cell, +.academic-term-row--archived:hover > .el-table__cell { + opacity: .94; +} + +.historical-record { + opacity: .68; + filter: saturate(.72); +} + +.historical-record:hover, +.historical-record.active { + opacity: 1; + filter: none; +} + +.archived-record { + opacity: .54; + filter: grayscale(.18) saturate(.58); +} + +.archived-record:hover, +.archived-record.active { + opacity: .92; +} + +.term-policy-note { + margin: 0 0 14px; +} + +.field-help { + margin-left: 10px; + color: #8994a1; + font-size: 12px; +} + +.term-mobile-list { + display: none; +} + +@media (max-width: 720px) { + .term-desktop-table { + display: none; + } + + .term-mobile-list { + display: grid; + gap: 10px; + } + + .term-mobile-list article { + border: 1px solid #e5e9ef; + border-radius: 12px; + background: #fff; + padding: 14px; + } + + .term-mobile-list article.academic-term-row--historical { + color: #73808e; + opacity: .78; + } + + .term-mobile-list article.academic-term-row--archived { + color: #8e98a3; + background: #f8fafc; + opacity: .66; + } + + .term-mobile-list article:focus-within, + .term-mobile-list article:hover { + opacity: 1; + } + + .term-mobile-list header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + } + + .term-mobile-list header span:first-child { + color: #258779; + font-size: 12px; + font-weight: 700; + letter-spacing: .06em; + } + + .term-mobile-list h3 { + margin: 5px 0 3px; + color: inherit; + font-size: 15px; + line-height: 1.45; + } + + .term-mobile-list p { + margin: 0; + font-size: 12px; + } + + .term-history-label { + white-space: nowrap; + color: #7c8895; + font-size: 12px; + } + + .term-mobile-list footer { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #edf0f4; + } + + .term-mobile-list footer .el-button { + margin-left: 0; + } +} .el-table th.el-table__cell { color: #596274; background: #fafbfc; font-size: 12px; font-weight: 650; } .el-table .cell { font-size: 12px; } .table-status { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--teal); } diff --git a/web/src/utils/academicTerms.ts b/web/src/utils/academicTerms.ts new file mode 100644 index 0000000..1df0b7c --- /dev/null +++ b/web/src/utils/academicTerms.ts @@ -0,0 +1,41 @@ +export interface AcademicTermLike { + id: string + name: string + isCurrent?: boolean + isArchived?: boolean + isEnabled?: boolean + hasPublishedTimetable?: boolean +} + +export function academicTermLabel(term: AcademicTermLike): string { + if (term.isCurrent) return `${term.name} · 当前` + if (term.isArchived) return `${term.name} · 已归档` + return `${term.name} · 历史` +} + +export function academicTermOptionClass(term: AcademicTermLike): string { + if (term.isCurrent) return 'academic-term-option academic-term-option--current' + if (term.isArchived) return 'academic-term-option academic-term-option--archived' + return 'academic-term-option academic-term-option--historical' +} + +export function defaultAcademicTermId( + terms: AcademicTermLike[], + requirePublishedTimetable = false, +): string | undefined { + const usable = requirePublishedTimetable + ? terms.filter((term) => term.hasPublishedTimetable) + : terms + return usable.find((term) => term.isCurrent)?.id + ?? usable.find((term) => !term.isArchived)?.id + ?? usable[0]?.id +} + +export function academicTermRowClass(term: { + isCurrent?: boolean + isArchived?: boolean +}): string { + if (term.isCurrent) return 'academic-term-row academic-term-row--current' + if (term.isArchived) return 'academic-term-row academic-term-row--archived' + return 'academic-term-row academic-term-row--historical' +} diff --git a/web/src/views/BaseDataView.vue b/web/src/views/BaseDataView.vue index 7dddfa4..5fcfd27 100644 --- a/web/src/views/BaseDataView.vue +++ b/web/src/views/BaseDataView.vue @@ -5,9 +5,19 @@ import { useRoute } from 'vue-router' import http, { apiErrorMessage } from '../api/http' import { downloadApiFile, importExcel } from '../api/excel' import { useAuthStore } from '../stores/auth' +import { academicTermRowClass } from '../utils/academicTerms' type Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms' | 'course-categories' -interface Row { id: string; code: string; name: string; isEnabled: boolean; [key: string]: unknown } +interface Row { + id: string + code: string + name: string + isEnabled: boolean + academicYear?: string + isCurrent?: boolean + isArchived?: boolean + [key: string]: unknown +} const allTabs: { key: Kind; label: string; hint: string }[] = [ { key: 'campuses', label: '校区', hint: '学校的物理校区' }, @@ -210,6 +220,47 @@ async function remove(row: any) { } } +function termTableRowClass({ row }: { row: any }) { + return active.value === 'terms' ? academicTermRowClass(row) : '' +} + +async function setCurrentTerm(row: any) { + try { + await ElMessageBox.confirm( + `将“${row.name}”切换为当前学期?其他学期会自动转为历史显示,但不会自动归档,也不会冻结成绩和补考。`, + '切换当前学期', + { type: 'warning', confirmButtonText: '切换为当前', cancelButtonText: '取消' }, + ) + await http.post(`/base-data/terms/${row.id}/set-current`) + ElMessage.success(`当前学期已切换为“${row.name}”`) + await load() + } catch (error: any) { + if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) + } +} + +async function toggleTermArchive(row: any) { + const restoring = row.isArchived + try { + await ElMessageBox.confirm( + restoring + ? `撤销“${row.name}”的归档状态?撤销后仍作为历史学期显示,可再次设为当前。` + : `归档“${row.name}”?归档只降低历史数据的显示优先级,不会冻结成绩更正或补考成绩录入。`, + restoring ? '撤销学期归档' : '归档学期', + { + type: restoring ? 'info' : 'warning', + confirmButtonText: restoring ? '撤销归档' : '确认归档', + cancelButtonText: '取消', + }, + ) + await http.post(`/base-data/terms/${row.id}/${restoring ? 'unarchive' : 'archive'}`) + ElMessage.success(restoring ? '已撤销归档' : '学期已归档') + await load() + } catch (error: any) { + if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) + } +} + onMounted(async () => { await Promise.all([load(), loadReferences()]) }) @@ -276,7 +327,65 @@ watch( 共 {{ filteredRows.length }} 条 - + + +
+
+
+
+ {{ row.code }} +

{{ row.name }}

+

{{ row.academicYear }}

+
+ 当前 + 已归档 + 历史学期 +
+
+ 设为当前 + {{ row.isArchived ? '撤销归档' : '归档' }} + 编辑 + 删除 +
+
+
+ + @@ -288,8 +397,12 @@ watch( - - + + @@ -299,10 +412,34 @@ watch( {{ row.isEnabled ? '启用' : '停用' }} - + @@ -366,7 +503,12 @@ watch( - 设为当前学期 + + + 设为当前学期 + + 已归档学期需先撤销归档。 + diff --git a/web/src/views/CourseAdjustmentsView.vue b/web/src/views/CourseAdjustmentsView.vue index 1e7438d..29d9ada 100644 --- a/web/src/views/CourseAdjustmentsView.vue +++ b/web/src/views/CourseAdjustmentsView.vue @@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { Bell, Check, Plus, Refresh } from '@element-plus/icons-vue' import http, { apiErrorMessage } from '../api/http' import { useAuthStore } from '../stores/auth' +import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms' const auth = useAuthStore() const isManager = computed(() => @@ -172,7 +173,7 @@ onMounted(async () => { ]) terms.value = termRes.data tasks.value = taskRes.data.items - termId.value = terms.value.find(t => t.isCurrent)?.id + termId.value = defaultAcademicTermId(terms.value) } catch (_) {} await load() }) @@ -204,7 +205,7 @@ function showCancel(type: string) { return type === 'Cancel' }
- + {{ round.termName }} @@ -1056,7 +1062,7 @@ onMounted(async () => {
- + diff --git a/web/src/views/EvaluationView.vue b/web/src/views/EvaluationView.vue index 06d1429..3177d98 100644 --- a/web/src/views/EvaluationView.vue +++ b/web/src/views/EvaluationView.vue @@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { Delete, Edit, Lock, Plus, Unlock } from '@element-plus/icons-vue' import http, { apiErrorMessage } from '../api/http' import { useAuthStore } from '../stores/auth' +import { academicTermLabel, academicTermOptionClass, academicTermRowClass, defaultAcademicTermId } from '../utils/academicTerms' const auth = useAuthStore() const roles = computed(() => auth.user?.roles ?? []) @@ -31,7 +32,7 @@ function addDim() { setupForm.dimensions.push({ name: '', maxScore: 10 }) } function removeDim(i: number) { setupForm.dimensions.splice(i, 1) } function resetSetupForm() { - setupForm.academicTermId = undefined + setupForm.academicTermId = defaultAcademicTermId(terms.value) setupForm.name = '' setupForm.startsAt = '' setupForm.endsAt = '' @@ -43,6 +44,13 @@ function resetSetupForm() { ] } +function setupRowClass({ row }: { row: any }) { + return academicTermRowClass({ + isCurrent: row.termIsCurrent, + isArchived: row.termIsArchived, + }) +} + async function loadSetups() { setupLoading.value = true try { setups.value = (await http.get('/evaluations/setups')).data } @@ -207,7 +215,7 @@ onMounted(async () => {
新建方案
- + @@ -273,7 +281,7 @@ onMounted(async () => {
- + diff --git a/web/src/views/ExamsView.vue b/web/src/views/ExamsView.vue index 8c705ef..7e1266e 100644 --- a/web/src/views/ExamsView.vue +++ b/web/src/views/ExamsView.vue @@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue' import http, { apiErrorMessage } from '../api/http' import { useAuthStore } from '../stores/auth' +import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms' const auth = useAuthStore() const isManager = computed(() => @@ -53,7 +54,9 @@ async function load() { return } plans.value = (await http.get('/exams/plans')).data - const plan = plans.value.find((x) => x.id === selected.value?.id) ?? plans.value[0] + const plan = plans.value.find((x) => x.id === selected.value?.id) + ?? plans.value.find((x) => x.termIsCurrent) + ?? plans.value[0] if (plan) await selectPlan(plan.id) } catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false } @@ -63,7 +66,7 @@ async function selectPlan(id: string) { } function openPlan() { Object.assign(planForm, { - academicTermId: terms.value.find((x) => x.isCurrent)?.id, + academicTermId: defaultAcademicTermId(terms.value), name: '', notes: '', }) planDialog.value = true @@ -196,7 +199,7 @@ onMounted(async () => {