学年学期页面新增“设为当前”“归档”“撤销归档”,当前学期不能归档、删除或直接取消;已归档学期需先撤销才能重新设为当前。[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:
2026-07-26 11:14:05 +08:00 Unverified
parent 3b73292d93
commit c91d826169
32 changed files with 752 additions and 73 deletions
@@ -287,7 +287,9 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms( public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
await db.AcademicTerms.AsNoTracking() await db.AcademicTerms.AsNoTracking()
.OrderByDescending(x => x.StartDate) .OrderByDescending(x => x.IsCurrent)
.ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
[HttpPost("terms")] [HttpPost("terms")]
@@ -298,11 +300,20 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
{ {
if (request.EndDate <= request.StartDate) if (request.EndDate <= request.StartDate)
return ValidationProblem("学期结束日期必须晚于开始日期。"); return ValidationProblem("学期结束日期必须晚于开始日期。");
if (request.IsCurrent && !request.IsEnabled)
return ValidationProblem("当前学期必须保持启用。");
if (request.IsCurrent) var hasCurrentTerm = await db.AcademicTerms
await db.AcademicTerms.ExecuteUpdateAsync( .AnyAsync(x => x.IsCurrent, cancellationToken);
setters => setters.SetProperty(x => x.IsCurrent, false), var shouldBeCurrent = request.IsCurrent || (!hasCurrentTerm && request.IsEnabled);
cancellationToken); 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 var entity = new AcademicTerm
{ {
@@ -312,7 +323,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
Season = request.Season, Season = request.Season,
StartDate = request.StartDate, StartDate = request.StartDate,
EndDate = request.EndDate, EndDate = request.EndDate,
IsCurrent = request.IsCurrent, IsCurrent = shouldBeCurrent,
IsEnabled = request.IsEnabled IsEnabled = request.IsEnabled
}; };
return await CreateAsync(entity, "GetTerms", cancellationToken); 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); var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound(); 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) if (request.IsCurrent)
await db.AcademicTerms.Where(x => x.Id != id).ExecuteUpdateAsync( {
setters => setters.SetProperty(x => x.IsCurrent, false), var currentTerms = await db.AcademicTerms
cancellationToken); .Where(x => x.Id != id && x.IsCurrent)
.ToListAsync(cancellationToken);
foreach (var currentTerm in currentTerms)
currentTerm.IsCurrent = false;
}
ApplyCatalog(entity, request); ApplyCatalog(entity, request);
entity.AcademicYear = request.AcademicYear.Trim(); entity.AcademicYear = request.AcademicYear.Trim();
entity.Season = request.Season; entity.Season = request.Season;
@@ -341,6 +364,64 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
return entity; 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")] [HttpGet("classrooms")]
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) => public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) =>
Ok(await db.Classrooms.AsNoTracking() 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 null) return NotFound();
if (entity is AcademicTerm { IsCurrent: true })
return ConflictProblem("当前学期不能删除,请先切换到其他学期。");
db.Remove(entity); db.Remove(entity);
try try
{ {
@@ -508,6 +591,14 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.SortOrder = request.SortOrder; entity.SortOrder = request.SortOrder;
entity.IsEnabled = request.IsEnabled; entity.IsEnabled = request.IsEnabled;
} }
private ObjectResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "当前操作无法完成",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
} }
public record CatalogRequest( public record CatalogRequest(
@@ -58,6 +58,8 @@ public sealed class CourseSelectionsController(
x.Name, x.Name,
x.AcademicTermId, x.AcademicTermId,
TermName = x.AcademicTerm!.Name, TermName = x.AcademicTerm!.Name,
TermIsCurrent = x.AcademicTerm.IsCurrent,
TermIsArchived = x.AcademicTerm.IsArchived,
x.StartsAt, x.StartsAt,
x.EndsAt, x.EndsAt,
x.WithdrawalEndsAt, x.WithdrawalEndsAt,
@@ -44,6 +44,8 @@ public sealed class EvaluationsController(
x.Status, x.Status,
x.AcademicTermId, x.AcademicTermId,
TermName = x.AcademicTerm!.Name, TermName = x.AcademicTerm!.Name,
TermIsCurrent = x.AcademicTerm.IsCurrent,
TermIsArchived = x.AcademicTerm.IsArchived,
x.StartsAt, x.StartsAt,
x.EndsAt, x.EndsAt,
Dimensions = x.Dimensions.Select(d => new Dimensions = x.Dimensions.Select(d => new
@@ -43,6 +43,8 @@ public sealed class ExamsController(
x.Name, x.Name,
x.AcademicTermId, x.AcademicTermId,
TermName = x.AcademicTerm!.Name, TermName = x.AcademicTerm!.Name,
TermIsCurrent = x.AcademicTerm.IsCurrent,
TermIsArchived = x.AcademicTerm.IsArchived,
x.Status, x.Status,
SessionCount = x.Sessions.Count, SessionCount = x.Sessions.Count,
x.Notes, x.Notes,
@@ -48,6 +48,8 @@ public sealed class MakeupExamsController(
x.Name, x.Name,
x.AcademicTermId, x.AcademicTermId,
TermName = x.AcademicTerm!.Name, TermName = x.AcademicTerm!.Name,
TermIsCurrent = x.AcademicTerm.IsCurrent,
TermIsArchived = x.AcademicTerm.IsArchived,
x.Status, x.Status,
SessionCount = x.Sessions.Count, SessionCount = x.Sessions.Count,
x.Notes, x.Notes,
@@ -220,6 +220,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
var terms = await db.AcademicTerms.AsNoTracking() var terms = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsEnabled) .Where(x => x.IsEnabled)
.OrderByDescending(x => x.IsCurrent) .OrderByDescending(x => x.IsCurrent)
.ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate) .ThenByDescending(x => x.StartDate)
.Select(x => new .Select(x => new
{ {
@@ -228,6 +229,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
x.StartDate, x.StartDate,
x.EndDate, x.EndDate,
x.IsCurrent, x.IsCurrent,
x.IsArchived,
HasPublishedTimetable = db.SchedulePlans.Any(plan => HasPublishedTimetable = db.SchedulePlans.Any(plan =>
plan.AcademicTermId == x.Id && plan.AcademicTermId == x.Id &&
plan.Status == SchedulePlanStatus.Published) plan.Status == SchedulePlanStatus.Published)
@@ -235,6 +237,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var selectedTermId = academicTermId var selectedTermId = academicTermId
?? terms.FirstOrDefault(x => x.IsCurrent && x.HasPublishedTimetable)?.Id ?? terms.FirstOrDefault(x => x.IsCurrent && x.HasPublishedTimetable)?.Id
?? terms.FirstOrDefault(x => !x.IsArchived && x.HasPublishedTimetable)?.Id
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id; ?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
var campuses = await db.Campuses.AsNoTracking() var campuses = await db.Campuses.AsNoTracking()
.Where(x => x.IsEnabled) .Where(x => x.IsEnabled)
@@ -32,6 +32,7 @@ public sealed class TimetablesController(
x.StartDate, x.StartDate,
x.EndDate, x.EndDate,
x.IsCurrent, x.IsCurrent,
x.IsArchived,
HasPublishedTimetable = db.SchedulePlans.Any(plan => HasPublishedTimetable = db.SchedulePlans.Any(plan =>
plan.AcademicTermId == x.Id && plan.AcademicTermId == x.Id &&
plan.Status == SchedulePlanStatus.Published) || plan.Status == SchedulePlanStatus.Published) ||
@@ -56,6 +56,8 @@ public sealed class AcademicTerm : CatalogEntity
public DateOnly StartDate { get; set; } public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; } public DateOnly EndDate { get; set; }
public bool IsCurrent { get; set; } public bool IsCurrent { get; set; }
public bool IsArchived { get; set; }
public DateTime? ArchivedAt { get; set; }
} }
public enum TermSeason public enum TermSeason
@@ -167,8 +167,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.HasForeignKey(x => x.BuildingId) .HasForeignKey(x => x.BuildingId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
builder.Entity<AcademicTerm>() builder.Entity<AcademicTerm>(entity =>
.HasIndex(x => x.IsCurrent); {
entity.HasIndex(x => x.IsCurrent);
entity.HasIndex(x => x.IsArchived);
});
builder.Entity<Teacher>(entity => builder.Entity<Teacher>(entity =>
{ {
@@ -46,6 +46,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260725_24_approval_tables"; "20260725_24_approval_tables";
private const string AcademicWarningsMigration = private const string AcademicWarningsMigration =
"20260725_25_academic_warnings"; "20260725_25_academic_warnings";
private const string AcademicTermArchivingMigration =
"20260726_27_academic_term_archiving";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -329,6 +331,19 @@ public sealed class DevelopmentSqliteMigrator(
MakeupExamAutoJobsMigration, MakeupExamAutoJobsMigration,
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements, makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
cancellationToken); 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( private async Task ApplyMigrationAsync(
@@ -1688,6 +1703,13 @@ public sealed class DevelopmentSqliteMigrator(
"""ALTER TABLE "WarningRules" ADD COLUMN "LastCheckAt" TEXT NULL;""" """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 const string TeachingEvaluationMigration = "TeachingEvaluation";
private static readonly string[] TeachingEvaluationStatements = private static readonly string[] TeachingEvaluationStatements =
@@ -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");
}
}
@@ -29,6 +29,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.IsRequired() .IsRequired()
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<DateTime?>("ArchivedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Code") b.Property<string>("Code")
.IsRequired() .IsRequired()
.HasMaxLength(40) .HasMaxLength(40)
@@ -43,6 +46,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<bool>("IsCurrent") b.Property<bool>("IsCurrent")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");
b.Property<bool>("IsArchived")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsEnabled") b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");
@@ -70,6 +76,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsCurrent"); b.HasIndex("IsCurrent");
b.HasIndex("IsArchived");
b.HasIndex("IsEnabled", "SortOrder"); b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("AcademicTerms"); b.ToTable("AcademicTerms");
@@ -194,6 +194,7 @@ public sealed class TimetableDataService(AppDbContext db)
query = query.Where(x => x.Id == academicTermId); query = query.Where(x => x.Id == academicTermId);
else else
query = query.OrderByDescending(x => x.IsCurrent) query = query.OrderByDescending(x => x.IsCurrent)
.ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate); .ThenByDescending(x => x.StartDate);
return await query.Select(x => new TimetableTermDto( return await query.Select(x => new TimetableTermDto(
x.Id, x.Id,
@@ -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<AppDbContext>()
.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<ConflictObjectResult>(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<ConflictObjectResult>(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<ConflictObjectResult>(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();
}
}
+154
View File
@@ -182,6 +182,160 @@ button { cursor: pointer; }
.table-toolbar .el-input { width: min(340px, 60vw); } .table-toolbar .el-input { width: min(340px, 60vw); }
.table-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; } .table-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; }
.data-table { min-height: 360px; } .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 th.el-table__cell { color: #596274; background: #fafbfc; font-size: 12px; font-weight: 650; }
.el-table .cell { font-size: 12px; } .el-table .cell { font-size: 12px; }
.table-status { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--teal); } .table-status { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--teal); }
+41
View File
@@ -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'
}
+149 -7
View File
@@ -5,9 +5,19 @@ import { useRoute } from 'vue-router'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel' import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermRowClass } from '../utils/academicTerms'
type Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms' | 'course-categories' 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 }[] = [ const allTabs: { key: Kind; label: string; hint: string }[] = [
{ key: 'campuses', label: '校区', hint: '学校的物理校区' }, { 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 () => { onMounted(async () => {
await Promise.all([load(), loadReferences()]) await Promise.all([load(), loadReferences()])
}) })
@@ -276,7 +327,65 @@ watch(
<span> {{ filteredRows.length }} </span> <span> {{ filteredRows.length }} </span>
</div> </div>
<el-table v-loading="loading" :data="filteredRows" class="data-table"> <el-alert
v-if="active === 'terms'"
class="term-policy-note"
type="info"
:closable="false"
show-icon
title="切换当前学期只改变默认工作学期;归档需手动执行,归档后仍可处理补考与成绩更正。"
/>
<div v-if="active === 'terms'" class="term-mobile-list">
<article
v-for="row in filteredRows"
:key="row.id"
:class="academicTermRowClass(row)"
>
<header>
<div>
<span>{{ row.code }}</span>
<h3>{{ row.name }}</h3>
<p>{{ row.academicYear }}</p>
</div>
<el-tag v-if="row.isCurrent" type="success">当前</el-tag>
<el-tag v-else-if="row.isArchived" type="info">已归档</el-tag>
<span v-else class="term-history-label">历史学期</span>
</header>
<footer v-if="canManage">
<el-button
v-if="!row.isCurrent && !row.isArchived"
size="small"
type="success"
plain
@click="setCurrentTerm(row)"
>设为当前</el-button>
<el-button
v-if="!row.isCurrent"
size="small"
:type="row.isArchived ? 'primary' : 'warning'"
plain
@click="toggleTermArchive(row)"
>{{ row.isArchived ? '撤销归档' : '归档' }}</el-button>
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button
v-if="!row.isCurrent"
size="small"
type="danger"
plain
@click="remove(row)"
>删除</el-button>
</footer>
</article>
</div>
<el-table
v-loading="loading"
:data="filteredRows"
:row-class-name="termTableRowClass"
class="data-table"
:class="{ 'term-desktop-table': active === 'terms' }"
>
<el-table-column prop="code" label="编码" min-width="130" /> <el-table-column prop="code" label="编码" min-width="130" />
<el-table-column prop="name" label="名称" min-width="180" /> <el-table-column prop="name" label="名称" min-width="180" />
<el-table-column v-if="active === 'colleges'" prop="campusName" label="所属校区" min-width="140" /> <el-table-column v-if="active === 'colleges'" prop="campusName" label="所属校区" min-width="140" />
@@ -288,8 +397,12 @@ watch(
<template #default="{ row }">{{ row.counselorName || '未分配' }}</template> <template #default="{ row }">{{ row.counselorName || '未分配' }}</template>
</el-table-column> </el-table-column>
<el-table-column v-if="active === 'terms'" prop="academicYear" label="学年" width="120" /> <el-table-column v-if="active === 'terms'" prop="academicYear" label="学年" width="120" />
<el-table-column v-if="active === 'terms'" label="当前学期" width="100"> <el-table-column v-if="active === 'terms'" label="运行状态" width="120">
<template #default="{ row }"><el-tag v-if="row.isCurrent" type="success">当前</el-tag><span v-else></span></template> <template #default="{ row }">
<el-tag v-if="row.isCurrent" type="success">当前</el-tag>
<el-tag v-else-if="row.isArchived" type="info">已归档</el-tag>
<span v-else>历史学期</span>
</template>
</el-table-column> </el-table-column>
<el-table-column v-if="active === 'buildings'" prop="campusName" label="所属校区" min-width="140" /> <el-table-column v-if="active === 'buildings'" prop="campusName" label="所属校区" min-width="140" />
<el-table-column v-if="active === 'classrooms'" prop="buildingName" label="教学楼" min-width="130" /> <el-table-column v-if="active === 'classrooms'" prop="buildingName" label="教学楼" min-width="130" />
@@ -299,10 +412,34 @@ watch(
<span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span> <span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="canManage" label="操作" width="150" fixed="right"> <el-table-column
v-if="canManage"
label="操作"
:width="active === 'terms' ? 280 : 150"
fixed="right"
>
<template #default="{ row }"> <template #default="{ row }">
<template v-if="active === 'terms'">
<el-button
v-if="!row.isCurrent && !row.isArchived"
link
type="success"
@click="setCurrentTerm(row)"
>设为当前</el-button>
<el-button
v-if="!row.isCurrent"
link
:type="row.isArchived ? 'primary' : 'warning'"
@click="toggleTermArchive(row)"
>{{ row.isArchived ? '撤销归档' : '归档' }}</el-button>
</template>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button> <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button> <el-button
v-if="active !== 'terms' || !row.isCurrent"
link
type="danger"
@click="remove(row)"
>删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
<template #empty><el-empty description="暂无数据,点击右上角开始新增" /></template> <template #empty><el-empty description="暂无数据,点击右上角开始新增" /></template>
@@ -366,7 +503,12 @@ watch(
<el-form-item label="开始日期"><el-date-picker v-model="form.startDate" value-format="YYYY-MM-DD" /></el-form-item> <el-form-item label="开始日期"><el-date-picker v-model="form.startDate" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="结束日期"><el-date-picker v-model="form.endDate" value-format="YYYY-MM-DD" /></el-form-item> <el-form-item label="结束日期"><el-date-picker v-model="form.endDate" value-format="YYYY-MM-DD" /></el-form-item>
</div> </div>
<el-form-item><el-checkbox v-model="form.isCurrent">设为当前学期</el-checkbox></el-form-item> <el-form-item>
<el-checkbox v-model="form.isCurrent" :disabled="form.isArchived">
设为当前学期
</el-checkbox>
<span v-if="form.isArchived" class="field-help">已归档学期需先撤销归档</span>
</el-form-item>
</template> </template>
<el-form-item v-if="active === 'classrooms'" label="所属教学楼" required> <el-form-item v-if="active === 'classrooms'" label="所属教学楼" required>
<el-select v-model="form.buildingId"> <el-select v-model="form.buildingId">
+3 -2
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Bell, Check, Plus, Refresh } from '@element-plus/icons-vue' import { Bell, Check, Plus, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isManager = computed(() => const isManager = computed(() =>
@@ -172,7 +173,7 @@ onMounted(async () => {
]) ])
terms.value = termRes.data terms.value = termRes.data
tasks.value = taskRes.data.items tasks.value = taskRes.data.items
termId.value = terms.value.find(t => t.isCurrent)?.id termId.value = defaultAcademicTermId(terms.value)
} catch (_) {} } catch (_) {}
await load() await load()
}) })
@@ -204,7 +205,7 @@ function showCancel(type: string) { return type === 'Cancel' }
<section class="adj-toolbar"> <section class="adj-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="load"> <el-select v-model="termId" clearable placeholder="全部学期" @change="load">
<el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" /> <el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
</el-select> </el-select>
<el-segmented v-model="tab" :options="[ <el-segmented v-model="tab" :options="[
...(isTeacher ? [{ label: '我的申请', value: 'mine' }] : []), ...(isTeacher ? [{ label: '我的申请', value: 'mine' }] : []),
+10 -4
View File
@@ -12,6 +12,7 @@ import {
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const managerRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] const managerRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin']
@@ -309,6 +310,8 @@ async function loadRounds(keepSelection = true) {
rounds.value = (await http.get('/course-selections/rounds')).data rounds.value = (await http.get('/course-selections/rounds')).data
const previousId = keepSelection ? selectedRound.value?.id : undefined const previousId = keepSelection ? selectedRound.value?.id : undefined
const preferred = rounds.value.find((item) => item.id === previousId) const preferred = rounds.value.find((item) => item.id === previousId)
?? rounds.value.find((item) => item.isAvailableNow && item.termIsCurrent)
?? rounds.value.find((item) => item.termIsCurrent)
?? rounds.value.find((item) => item.isAvailableNow) ?? rounds.value.find((item) => item.isAvailableNow)
?? rounds.value[0] ?? rounds.value[0]
if (preferred) await selectRound(preferred) if (preferred) await selectRound(preferred)
@@ -362,12 +365,11 @@ async function loadTasks(academicTermId: string) {
function openRound(round?: any) { function openRound(round?: any) {
editingRoundId.value = round?.id ?? '' editingRoundId.value = round?.id ?? ''
const currentTerm = terms.value.find((item) => item.isCurrent)
const now = new Date() const now = new Date()
const ends = new Date(now.getTime() + 7 * 86400000) const ends = new Date(now.getTime() + 7 * 86400000)
const withdrawal = new Date(now.getTime() + 14 * 86400000) const withdrawal = new Date(now.getTime() + 14 * 86400000)
Object.assign(roundForm, { Object.assign(roundForm, {
academicTermId: round?.academicTermId ?? currentTerm?.id, academicTermId: round?.academicTermId ?? defaultAcademicTermId(terms.value),
name: round?.name ?? '', name: round?.name ?? '',
startsAt: round ? toPickerValue(round.startsAt) : toPickerValue(now.toISOString()), startsAt: round ? toPickerValue(round.startsAt) : toPickerValue(now.toISOString()),
endsAt: round ? toPickerValue(round.endsAt) : toPickerValue(ends.toISOString()), endsAt: round ? toPickerValue(round.endsAt) : toPickerValue(ends.toISOString()),
@@ -719,7 +721,11 @@ onMounted(async () => {
v-for="round in rounds" v-for="round in rounds"
:key="round.id" :key="round.id"
type="button" type="button"
:class="{ active: selectedRound?.id === round.id }" :class="{
active: selectedRound?.id === round.id,
'historical-record': !round.termIsCurrent && !round.termIsArchived,
'archived-record': round.termIsArchived,
}"
@click="selectRound(round)" @click="selectRound(round)"
> >
<span>{{ round.termName }}</span> <span>{{ round.termName }}</span>
@@ -1056,7 +1062,7 @@ onMounted(async () => {
<div class="form-grid"> <div class="form-grid">
<el-form-item label="开课学期" required> <el-form-item label="开课学期" required>
<el-select v-model="roundForm.academicTermId"> <el-select v-model="roundForm.academicTermId">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="批次名称" required> <el-form-item label="批次名称" required>
+11 -3
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Delete, Edit, Lock, Plus, Unlock } from '@element-plus/icons-vue' import { Delete, Edit, Lock, Plus, Unlock } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, academicTermRowClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const roles = computed(() => auth.user?.roles ?? []) 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 removeDim(i: number) { setupForm.dimensions.splice(i, 1) }
function resetSetupForm() { function resetSetupForm() {
setupForm.academicTermId = undefined setupForm.academicTermId = defaultAcademicTermId(terms.value)
setupForm.name = '' setupForm.name = ''
setupForm.startsAt = '' setupForm.startsAt = ''
setupForm.endsAt = '' setupForm.endsAt = ''
@@ -43,6 +44,13 @@ function resetSetupForm() {
] ]
} }
function setupRowClass({ row }: { row: any }) {
return academicTermRowClass({
isCurrent: row.termIsCurrent,
isArchived: row.termIsArchived,
})
}
async function loadSetups() { async function loadSetups() {
setupLoading.value = true setupLoading.value = true
try { setups.value = (await http.get('/evaluations/setups')).data } try { setups.value = (await http.get('/evaluations/setups')).data }
@@ -207,7 +215,7 @@ onMounted(async () => {
<div style="display:flex;justify-content:flex-end;margin-bottom:12px"> <div style="display:flex;justify-content:flex-end;margin-bottom:12px">
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openCreate">新建方案</el-button> <el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openCreate">新建方案</el-button>
</div> </div>
<el-table :data="setups" size="small" empty-text="暂无评教方案"> <el-table :data="setups" :row-class-name="setupRowClass" size="small" empty-text="暂无评教方案">
<el-table-column prop="name" label="方案名称" min-width="200" /> <el-table-column prop="name" label="方案名称" min-width="200" />
<el-table-column prop="termName" label="学期" width="140" /> <el-table-column prop="termName" label="学期" width="140" />
<el-table-column label="状态" width="90"> <el-table-column label="状态" width="90">
@@ -273,7 +281,7 @@ onMounted(async () => {
<div class="form-grid"> <div class="form-grid">
<el-form-item label="学期"> <el-form-item label="学期">
<el-select v-model="setupForm.academicTermId" placeholder="选择学期" :disabled="!!editingSetup"> <el-select v-model="setupForm.academicTermId" placeholder="选择学期" :disabled="!!editingSetup">
<el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" /> <el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="方案名称"> <el-form-item label="方案名称">
+7 -4
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue' import { Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isManager = computed(() => const isManager = computed(() =>
@@ -53,7 +54,9 @@ async function load() {
return return
} }
plans.value = (await http.get('/exams/plans')).data 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) if (plan) await selectPlan(plan.id)
} catch (error) { ElMessage.error(apiErrorMessage(error)) } } catch (error) { ElMessage.error(apiErrorMessage(error)) }
finally { loading.value = false } finally { loading.value = false }
@@ -63,7 +66,7 @@ async function selectPlan(id: string) {
} }
function openPlan() { function openPlan() {
Object.assign(planForm, { Object.assign(planForm, {
academicTermId: terms.value.find((x) => x.isCurrent)?.id, academicTermId: defaultAcademicTermId(terms.value),
name: '', notes: '', name: '', notes: '',
}) })
planDialog.value = true planDialog.value = true
@@ -196,7 +199,7 @@ onMounted(async () => {
<template v-if="isManager"> <template v-if="isManager">
<section class="exam-plan-strip"> <section class="exam-plan-strip">
<button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id }" @click="selectPlan(plan.id)"> <button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id, 'historical-record': !plan.termIsCurrent && !plan.termIsArchived, 'archived-record': plan.termIsArchived }" @click="selectPlan(plan.id)">
<span>{{ plan.termName }}</span><b>{{ plan.name }}</b> <span>{{ plan.termName }}</span><b>{{ plan.name }}</b>
<small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i> <small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i>
</button> </button>
@@ -271,7 +274,7 @@ onMounted(async () => {
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="学期"> <el-form-item label="学期">
<el-select v-model="planForm.academicTermId"> <el-select v-model="planForm.academicTermId">
<el-option v-for="x in terms" :key="x.id" :label="x.name" :value="x.id" /> <el-option v-for="x in terms" :key="x.id" :label="academicTermLabel(x)" :value="x.id" :class="academicTermOptionClass(x)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item> <el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item>
+4 -1
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Location, Refresh, Search } from '@element-plus/icons-vue' import { Location, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { academicTermLabel, academicTermOptionClass } from '../utils/academicTerms'
interface TermOption { interface TermOption {
id: string id: string
@@ -9,6 +10,7 @@ interface TermOption {
startDate: string startDate: string
endDate: string endDate: string
isCurrent: boolean isCurrent: boolean
isArchived: boolean
hasPublishedTimetable: boolean hasPublishedTimetable: boolean
} }
@@ -205,8 +207,9 @@ onMounted(async () => {
<el-option <el-option
v-for="term in terms" v-for="term in terms"
:key="term.id" :key="term.id"
:label="term.name" :label="academicTermLabel(term)"
:value="term.id" :value="term.id"
:class="academicTermOptionClass(term)"
:disabled="!term.hasPublishedTimetable" :disabled="!term.hasPublishedTimetable"
> >
<span>{{ term.name }}</span> <span>{{ term.name }}</span>
+4 -3
View File
@@ -14,6 +14,7 @@ import {
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel' import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') && const isStudent = computed(() => auth.user?.roles.includes('Student') &&
@@ -351,7 +352,7 @@ function calcPreviewTotal(record: any): number | null {
onMounted(async () => { onMounted(async () => {
try { try {
terms.value = (await http.get('/base-data/terms')).data terms.value = (await http.get('/base-data/terms')).data
termId.value = terms.value.find((item) => item.isCurrent)?.id termId.value = defaultAcademicTermId(terms.value)
await load() await load()
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
@@ -381,7 +382,7 @@ onMounted(async () => {
<label> <label>
<span>查看学期</span> <span>查看学期</span>
<el-select v-model="termId" clearable placeholder="全部学期" @change="load"> <el-select v-model="termId" clearable placeholder="全部学期" @change="load">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
</label> </label>
</section> </section>
@@ -421,7 +422,7 @@ onMounted(async () => {
<template v-else> <template v-else>
<section class="grade-toolbar"> <section class="grade-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="load"> <el-select v-model="termId" clearable placeholder="全部学期" @change="load">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
<el-select v-model="status" clearable placeholder="全部状态" @change="load"> <el-select v-model="status" clearable placeholder="全部状态" @change="load">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" /> <el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
+7 -4
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Promotion, Refresh, UserFilled, Setting, Search, MagicStick } from '@element-plus/icons-vue' import { Plus, Promotion, Refresh, UserFilled, Setting, Search, MagicStick } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isManager = computed(() => const isManager = computed(() =>
@@ -66,7 +67,9 @@ async function load() {
return return
} }
plans.value = (await http.get('/makeup-exams/plans')).data plans.value = (await http.get('/makeup-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) if (plan) await selectPlan(plan.id)
} catch (error) { ElMessage.error(apiErrorMessage(error)) } } catch (error) { ElMessage.error(apiErrorMessage(error)) }
finally { loading.value = false } finally { loading.value = false }
@@ -76,7 +79,7 @@ async function selectPlan(id: string) {
} }
function openPlan() { function openPlan() {
Object.assign(planForm, { Object.assign(planForm, {
academicTermId: terms.value.find((x) => x.isCurrent)?.id, academicTermId: defaultAcademicTermId(terms.value),
name: '', notes: '', name: '', notes: '',
}) })
planDialog.value = true planDialog.value = true
@@ -339,7 +342,7 @@ onMounted(async () => {
<template v-if="isManager"> <template v-if="isManager">
<section class="exam-plan-strip"> <section class="exam-plan-strip">
<button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id }" @click="selectPlan(plan.id)"> <button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id, 'historical-record': !plan.termIsCurrent && !plan.termIsArchived, 'archived-record': plan.termIsArchived }" @click="selectPlan(plan.id)">
<span>{{ plan.termName }}</span><b>{{ plan.name }}</b> <span>{{ plan.termName }}</span><b>{{ plan.name }}</b>
<small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i> <small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i>
</button> </button>
@@ -478,7 +481,7 @@ onMounted(async () => {
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="学期"> <el-form-item label="学期">
<el-select v-model="planForm.academicTermId"> <el-select v-model="planForm.academicTermId">
<el-option v-for="x in terms" :key="x.id" :label="x.name" :value="x.id" /> <el-option v-for="x in terms" :key="x.id" :label="academicTermLabel(x)" :value="x.id" :class="academicTermOptionClass(x)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item> <el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item>
+4 -3
View File
@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { CopyDocument, EditPen, Plus, Promotion, Refresh, Search, Setting } from '@element-plus/icons-vue' import { CopyDocument, EditPen, Plus, Promotion, Refresh, Search, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const plans = ref<any[]>([]) const plans = ref<any[]>([])
const selected = ref<any | null>(null) const selected = ref<any | null>(null)
@@ -749,7 +750,7 @@ onMounted(async () => {
classrooms.value = classroomRes.data.filter((item: any) => item.isEnabled) classrooms.value = classroomRes.data.filter((item: any) => item.isEnabled)
campuses.value = campusRes.data.filter((item: any) => item.isEnabled) campuses.value = campusRes.data.filter((item: any) => item.isEnabled)
buildings.value = buildingRes.data.filter((item: any) => item.isEnabled) buildings.value = buildingRes.data.filter((item: any) => item.isEnabled)
termId.value = terms.value.find((item) => item.isCurrent)?.id termId.value = defaultAcademicTermId(terms.value)
await Promise.all([loadPlans(false), loadSchedulingSettings()]) await Promise.all([loadPlans(false), loadSchedulingSettings()])
}) })
@@ -775,7 +776,7 @@ onBeforeUnmount(() => {
<section class="schedule-toolbar"> <section class="schedule-toolbar">
<el-select v-model="termId" placeholder="选择学期" @change="changeTerm"> <el-select v-model="termId" placeholder="选择学期" @change="changeTerm">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
<div class="schedule-version-strip"> <div class="schedule-version-strip">
<button <button
@@ -946,7 +947,7 @@ onBeforeUnmount(() => {
<el-dialog v-model="planDialog" :title="editingPlanId ? '编辑排课版本' : '新建排课版本'" width="560px"> <el-dialog v-model="planDialog" :title="editingPlanId ? '编辑排课版本' : '新建排课版本'" width="560px">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="学期" required><el-select v-model="planForm.academicTermId"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item> <el-form-item label="学期" required><el-select v-model="planForm.academicTermId"><el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" /></el-select></el-form-item>
<el-form-item label="版本名称" required><el-input v-model="planForm.name" placeholder="如:第一轮正式课表" /></el-form-item> <el-form-item label="版本名称" required><el-input v-model="planForm.name" placeholder="如:第一轮正式课表" /></el-form-item>
<el-form-item label="版本号" required><el-input v-model="planForm.version" placeholder="如 V1" /></el-form-item> <el-form-item label="版本号" required><el-input v-model="planForm.version" placeholder="如 V1" /></el-form-item>
<el-form-item label="备注"><el-input v-model="planForm.notes" type="textarea" :rows="2" /></el-form-item> <el-form-item label="备注"><el-input v-model="planForm.notes" type="textarea" :rows="2" /></el-form-item>
+3 -5
View File
@@ -7,6 +7,7 @@ import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { downloadApiFile } from '../api/excel' import { downloadApiFile } from '../api/excel'
import http from '../api/http' import http from '../api/http'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
echarts.use([BarChart, LineChart, PieChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent, CanvasRenderer]) echarts.use([BarChart, LineChart, PieChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent, CanvasRenderer])
@@ -367,10 +368,7 @@ onMounted(async () => {
courseCategories.value = catRes.data courseCategories.value = catRes.data
classroomBuildings.value = bRes.data classroomBuildings.value = bRes.data
classroomCampuses.value = campRes.data classroomCampuses.value = campRes.data
if (terms.value.length > 0) { globalTermId.value = defaultAcademicTermId(terms.value) ?? ''
const cur = terms.value.find((t: any) => t.isCurrent) ?? terms.value[terms.value.length - 1]
globalTermId.value = cur.id
}
loadStudentStats() loadStudentStats()
}) })
</script> </script>
@@ -385,7 +383,7 @@ onMounted(async () => {
</div> </div>
<div class="page-actions"> <div class="page-actions">
<el-select v-model="globalTermId" placeholder="选择学期" style="width:220px" clearable @change="() => { loadStudentStats(); loadGradeStats(); loadPassRateStats(); loadWorkloadStats(); loadClassroomStats() }"> <el-select v-model="globalTermId" placeholder="选择学期" style="width:220px" clearable @change="() => { loadStudentStats(); loadGradeStats(); loadPassRateStats(); loadWorkloadStats(); loadClassroomStats() }">
<el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" /> <el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
</el-select> </el-select>
</div> </div>
</section> </section>
+3 -2
View File
@@ -19,6 +19,7 @@ import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/compon
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel' import { downloadApiFile, importExcel } from '../api/excel'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
echarts.use([ echarts.use([
LineChart, LineChart,
@@ -592,7 +593,7 @@ onMounted(async () => {
window.addEventListener('resize', resizeCharts) window.addEventListener('resize', resizeCharts)
clockTimer = window.setInterval(() => { now.value = Date.now() }, 1000) clockTimer = window.setInterval(() => { now.value = Date.now() }, 1000)
terms.value = (await http.get('/base-data/terms')).data terms.value = (await http.get('/base-data/terms')).data
termId.value = terms.value.find((item: any) => item.isCurrent)?.id termId.value = defaultAcademicTermId(terms.value)
await loadTasks() await loadTasks()
}) })
@@ -616,7 +617,7 @@ onUnmounted(() => {
<section class="attendance-toolbar"> <section class="attendance-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks"> <el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
<span> {{ tasks.length }} 个教学班</span> <span> {{ tasks.length }} 个教学班</span>
<el-radio-group v-model="activeMode" class="mode-switch" size="small"> <el-radio-group v-model="activeMode" class="mode-switch" size="small">
+3 -2
View File
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
import { Download, Refresh } from '@element-plus/icons-vue' import { Download, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel' import { downloadApiFile } from '../api/excel'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const loading = ref(false) const loading = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
@@ -59,7 +60,7 @@ async function exportRoster() {
onMounted(async () => { onMounted(async () => {
terms.value = (await http.get('/base-data/terms')).data terms.value = (await http.get('/base-data/terms')).data
termId.value = terms.value.find((item: any) => item.isCurrent)?.id termId.value = defaultAcademicTermId(terms.value)
await loadOfferings() await loadOfferings()
}) })
</script> </script>
@@ -77,7 +78,7 @@ onMounted(async () => {
<section class="roster-toolbar"> <section class="roster-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadOfferings"> <el-select v-model="termId" clearable placeholder="全部学期" @change="loadOfferings">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
<span> {{ offerings.length }} 个教学班</span> <span> {{ offerings.length }} 个教学班</span>
</section> </section>
+7 -6
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Refresh } from '@element-plus/icons-vue' import { Plus, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isReviewer = computed(() => const isReviewer = computed(() =>
@@ -56,7 +57,7 @@ async function load() {
function openSubmit() { function openSubmit() {
Object.assign(form, { Object.assign(form, {
academicTermId: filters.academicTermId ?? academicTermId: filters.academicTermId ??
terms.value.find((item) => item.isCurrent)?.id, defaultAcademicTermId(terms.value),
courseId: undefined, courseId: undefined,
statement: '', statement: '',
}) })
@@ -118,7 +119,7 @@ async function review() {
function openAssignment() { function openAssignment() {
Object.assign(assignmentForm, { Object.assign(assignmentForm, {
academicTermId: filters.academicTermId ?? academicTermId: filters.academicTermId ??
terms.value.find((item) => item.isCurrent)?.id, defaultAcademicTermId(terms.value),
teacherId: undefined, teacherId: undefined,
courseId: undefined, courseId: undefined,
comment: '', comment: '',
@@ -158,7 +159,7 @@ onMounted(async () => {
courses.value = optionRes?.data?.courses ?? [] courses.value = optionRes?.data?.courses ?? []
teachers.value = optionRes?.data?.teachers ?? [] teachers.value = optionRes?.data?.teachers ?? []
} }
filters.academicTermId = terms.value.find((item) => item.isCurrent)?.id filters.academicTermId = defaultAcademicTermId(terms.value)
await load() await load()
}) })
</script> </script>
@@ -195,7 +196,7 @@ onMounted(async () => {
<section class="data-card"> <section class="data-card">
<div class="filter-bar"> <div class="filter-bar">
<el-select v-model="filters.academicTermId" clearable placeholder="全部学期" @change="load"> <el-select v-model="filters.academicTermId" clearable placeholder="全部学期" @change="load">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load"> <el-select v-model="filters.status" clearable placeholder="全部状态" @change="load">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" /> <el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
@@ -241,7 +242,7 @@ onMounted(async () => {
<div class="form-grid"> <div class="form-grid">
<el-form-item label="学期" required> <el-form-item label="学期" required>
<el-select v-model="form.academicTermId"> <el-select v-model="form.academicTermId">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="拟授课程" required> <el-form-item label="拟授课程" required>
@@ -286,7 +287,7 @@ onMounted(async () => {
<el-form label-position="top" style="margin-top: 18px"> <el-form label-position="top" style="margin-top: 18px">
<el-form-item label="学期" required> <el-form-item label="学期" required>
<el-select v-model="assignmentForm.academicTermId" style="width: 100%"> <el-select v-model="assignmentForm.academicTermId" style="width: 100%">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<div class="form-grid"> <div class="form-grid">
+7 -8
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Refresh, Search } from '@element-plus/icons-vue' import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin') ?? false) const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin') ?? false)
@@ -283,11 +284,10 @@ function resetFilters() {
function resetForm(detail?: any) { function resetForm(detail?: any) {
Object.keys(form).forEach((key) => delete form[key]) Object.keys(form).forEach((key) => delete form[key])
const currentTerm = terms.value.find((item) => item.isCurrent)
Object.assign(form, { Object.assign(form, {
taskNumber: '', taskNumber: '',
name: '', name: '',
academicTermId: currentTerm?.id, academicTermId: defaultAcademicTermId(terms.value),
courseId: undefined, courseId: undefined,
capacity: 60, capacity: 60,
startWeek: 1, startWeek: 1,
@@ -499,9 +499,8 @@ async function batchAction(
} }
function openGeneration() { function openGeneration() {
const currentTerm = terms.value.find((item) => item.isCurrent)
Object.assign(generationForm, { Object.assign(generationForm, {
academicTermId: query.academicTermId ?? currentTerm?.id, academicTermId: query.academicTermId ?? defaultAcademicTermId(terms.value),
courseId: undefined, courseId: undefined,
classesPerTask: 3, classesPerTask: 3,
startWeek: 1, startWeek: 1,
@@ -576,7 +575,7 @@ onMounted(async () => {
courses.value = courseRes.data courses.value = courseRes.data
classes.value = classRes.data classes.value = classRes.data
majors.value = majorRes.data majors.value = majorRes.data
query.academicTermId = terms.value.find((item) => item.isCurrent)?.id query.academicTermId = defaultAcademicTermId(terms.value)
await load() await load()
}) })
</script> </script>
@@ -604,7 +603,7 @@ onMounted(async () => {
<div class="filter-bar"> <div class="filter-bar">
<el-input v-model="query.keyword" :prefix-icon="Search" clearable placeholder="搜索任务编号、教学班或课程" @keyup.enter="query.page = 1; load()" /> <el-input v-model="query.keyword" :prefix-icon="Search" clearable placeholder="搜索任务编号、教学班或课程" @keyup.enter="query.page = 1; load()" />
<el-select v-model="query.academicTermId" clearable placeholder="全部学期"> <el-select v-model="query.academicTermId" clearable placeholder="全部学期">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
<el-select v-model="query.status" clearable placeholder="全部状态"> <el-select v-model="query.status" clearable placeholder="全部状态">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" /> <el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
@@ -712,7 +711,7 @@ onMounted(async () => {
<el-form-item label="教学班名称" required><el-input v-model="form.name" /></el-form-item> <el-form-item label="教学班名称" required><el-input v-model="form.name" /></el-form-item>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item> <el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" /></el-select></el-form-item>
<el-form-item label="课程" required> <el-form-item label="课程" required>
<el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()"> <el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()">
<el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}${item.totalHours} 学时)`" :value="item.id" /> <el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}${item.totalHours} 学时)`" :value="item.id" />
@@ -807,7 +806,7 @@ onMounted(async () => {
<div class="form-grid"> <div class="form-grid">
<el-form-item label="开课学期" required> <el-form-item label="开课学期" required>
<el-select v-model="generationForm.academicTermId" @change="loadEligibleTeachers"> <el-select v-model="generationForm.academicTermId" @change="loadEligibleTeachers">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="公共课程" required> <el-form-item label="公共课程" required>
+5 -6
View File
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel' import { downloadApiFile } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const route = useRoute() const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
@@ -297,9 +298,8 @@ async function loadPublicOptions() {
colleges.value = data.colleges colleges.value = data.colleges
majors.value = data.majors majors.value = data.majors
classes.value = data.classes classes.value = data.classes
termId.value = data.terms.find((item: any) => item.isCurrent)?.id termId.value = defaultAcademicTermId(data.terms)
?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id ?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id
?? data.terms[0]?.id
?? '' ?? ''
const initialClass = data.classes.find((item: any) => item.hasPublishedTimetable) const initialClass = data.classes.find((item: any) => item.hasPublishedTimetable)
?? data.classes[0] ?? data.classes[0]
@@ -502,9 +502,7 @@ onMounted(async () => {
if (isTeacherView.value) { if (isTeacherView.value) {
const { data } = await http.get('/timetables/options') const { data } = await http.get('/timetables/options')
terms.value = data.terms terms.value = data.terms
termId.value = data.terms.find((item: any) => item.isCurrent)?.id termId.value = defaultAcademicTermId(data.terms) ?? ''
?? data.terms[0]?.id
?? ''
await loadTimetable() await loadTimetable()
return return
} }
@@ -539,8 +537,9 @@ onMounted(async () => {
<el-option <el-option
v-for="term in terms" v-for="term in terms"
:key="term.id" :key="term.id"
:label="`${term.name}${!isManager && !term.hasPublishedTimetable ? '(未发布)' : ''}`" :label="`${academicTermLabel(term)}${!isManager && !term.hasPublishedTimetable ? '(未发布)' : ''}`"
:value="term.id" :value="term.id"
:class="academicTermOptionClass(term)"
/> />
</el-select> </el-select>
<el-select v-if="isManager" v-model="planId" placeholder="选择课表版本"> <el-select v-if="isManager" v-model="planId" placeholder="选择课表版本">
+3 -2
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Check, Refresh, Search } from '@element-plus/icons-vue' import { Check, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin')) const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin'))
@@ -93,7 +94,7 @@ async function acknowledge(w: any) {
} }
onMounted(async () => { onMounted(async () => {
try { terms.value = (await http.get('/base-data/terms')).data; termId.value = terms.value.find(t => t.isCurrent)?.id; await load() } try { terms.value = (await http.get('/base-data/terms')).data; termId.value = defaultAcademicTermId(terms.value) ?? ''; await load() }
catch (e) { ElMessage.error(apiErrorMessage(e)) } catch (e) { ElMessage.error(apiErrorMessage(e)) }
}) })
</script> </script>
@@ -103,7 +104,7 @@ onMounted(async () => {
<section class="page-intro"> <section class="page-intro">
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isSuperAdmin ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div> <div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isSuperAdmin ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
<div style="display:flex;gap:8px;align-items:center"> <div style="display:flex;gap:8px;align-items:center">
<el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" /></el-select> <el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /></el-select>
<el-button :icon="Refresh" @click="load">刷新</el-button> <el-button :icon="Refresh" @click="load">刷新</el-button>
</div> </div>
</section> </section>