From a63c9438152c7b407e4bf20e6fc56615b65f77ba Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Jul 2026 11:03:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B2=E5=AE=8C=E6=88=90=E6=95=99=E5=AD=A6?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=BD=92=E6=A1=A3=E7=BA=A0=E9=94=99=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=9A=20=E5=B7=B2=E5=BD=92=E6=A1=A3=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=96=B0=E5=A2=9E=E2=80=9C=E6=92=A4=E9=94=80=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=E2=80=9D=E5=92=8C=E2=80=9C=E5=88=A0=E9=99=A4=E2=80=9D?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=8D=95=E6=9D=A1=E5=8F=8A=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=93=8D=E4=BD=9C=E3=80=82=20=E6=92=A4=E9=94=80?= =?UTF-8?q?=E5=90=8E=E6=81=A2=E5=A4=8D=E4=B8=BA=E2=80=9C=E5=B7=B2=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E2=80=9D=E7=8A=B6=E6=80=81=E3=80=82=20=E5=B7=B2?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E4=BB=BB=E5=8A=A1=E4=BB=8D=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=88=A0=E9=99=A4=EF=BC=9B=E8=A2=AB=E6=8E=92?= =?UTF-8?q?=E8=AF=BE=E3=80=81=E9=80=89=E8=AF=BE=E3=80=81=E6=88=90=E7=BB=A9?= =?UTF-8?q?=E6=88=96=E8=80=83=E8=AF=95=E5=BC=95=E7=94=A8=E7=9A=84=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=E4=BB=BB=E5=8A=A1=E4=B9=9F=E4=BC=9A=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=B9=B6=E6=98=8E=E7=A1=AE=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E3=80=82=20=E7=8A=B6=E6=80=81=E4=B8=8E=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=96=87=E6=A1=88=E7=BB=9F=E4=B8=80=E4=B8=BA=E2=80=9C=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/TeachingTasksController.cs | 45 ++++- .../TeachingTasksControllerTests.cs | 166 ++++++++++++++++++ web/src/views/TeachingTasksView.vue | 92 ++++++++-- 3 files changed, 279 insertions(+), 24 deletions(-) create mode 100644 tests/Jiaowu.Api.Tests/TeachingTasksControllerTests.cs diff --git a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs index 07f869e..5f465ad 100644 --- a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs +++ b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs @@ -228,10 +228,14 @@ public sealed class TeachingTasksController( .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (task is null) return NotFound(); if (!CanManage(task.Course!)) return Forbid(); - if (task.Status != TeachingTaskStatus.Draft) - return ConflictProblem("仅草稿教学任务可以删除。"); + if (task.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed)) + return ConflictProblem("仅草稿或已归档的教学任务可以删除。"); db.TeachingTasks.Remove(task); - return await SaveAsync(id, false, cancellationToken); + return await SaveAsync( + id, + false, + cancellationToken, + "教学任务已被排课、选课、成绩或考试等业务引用,不能删除;如需继续使用,可撤销归档。"); } [HttpPost("{id:guid}/publish")] @@ -263,11 +267,25 @@ public sealed class TeachingTasksController( if (task is null) return NotFound(); if (!CanManage(task.Course!)) return Forbid(); if (task.Status != TeachingTaskStatus.Published) - return ConflictProblem("只有已发布的教学任务可以结课。"); + return ConflictProblem("只有已发布的教学任务可以归档。"); task.Status = TeachingTaskStatus.Closed; return await SaveAsync(id, false, cancellationToken); } + [HttpPost("{id:guid}/unarchive")] + public async Task Unarchive(Guid id, CancellationToken cancellationToken) + { + var task = await ScopedTasks() + .Include(x => x.Course) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (task is null) return NotFound(); + if (!CanManage(task.Course!)) return Forbid(); + if (task.Status != TeachingTaskStatus.Closed) + return ConflictProblem("只有已归档的教学任务可以撤销归档。"); + task.Status = TeachingTaskStatus.Published; + return await SaveAsync(id, false, cancellationToken); + } + [HttpPost("batch")] public async Task Batch( TeachingTaskBatchRequest request, @@ -305,9 +323,10 @@ public sealed class TeachingTasksController( break; } case TeachingTaskBatchOperation.Delete: - if (tasks.Any(x => x.Status != TeachingTaskStatus.Draft)) + if (tasks.Any(x => + x.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed))) return ConflictProblem( - $"教学任务“{tasks.First(x => x.Status != TeachingTaskStatus.Draft).Name}”不是草稿,整批未删除。"); + $"教学任务“{tasks.First(x => x.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed)).Name}”不是草稿或已归档状态,整批未删除。"); db.TeachingTasks.RemoveRange(tasks); break; case TeachingTaskBatchOperation.Archive: @@ -316,6 +335,12 @@ public sealed class TeachingTasksController( $"教学任务“{tasks.First(x => x.Status != TeachingTaskStatus.Published).Name}”不是已发布状态,整批未归档。"); foreach (var task in tasks) task.Status = TeachingTaskStatus.Closed; break; + case TeachingTaskBatchOperation.Unarchive: + if (tasks.Any(x => x.Status != TeachingTaskStatus.Closed)) + return ConflictProblem( + $"教学任务“{tasks.First(x => x.Status != TeachingTaskStatus.Closed).Name}”不是已归档状态,整批未撤销归档。"); + foreach (var task in tasks) task.Status = TeachingTaskStatus.Published; + break; default: return ValidationProblem("不支持的批量操作。"); } @@ -664,7 +689,8 @@ public sealed class TeachingTasksController( private async Task SaveAsync( Guid id, bool created, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string conflictMessage = "教学任务编号重复,或关联数据已失效。") { try { @@ -673,7 +699,7 @@ public sealed class TeachingTasksController( } catch (DbUpdateException) { - return ConflictProblem("教学任务编号重复,或关联数据已失效。"); + return ConflictProblem(conflictMessage); } } @@ -739,5 +765,6 @@ public enum TeachingTaskBatchOperation { Publish = 1, Delete = 2, - Archive = 3 + Archive = 3, + Unarchive = 4 } diff --git a/tests/Jiaowu.Api.Tests/TeachingTasksControllerTests.cs b/tests/Jiaowu.Api.Tests/TeachingTasksControllerTests.cs new file mode 100644 index 0000000..465ac48 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/TeachingTasksControllerTests.cs @@ -0,0 +1,166 @@ +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class TeachingTasksControllerTests +{ + [Fact] + public async Task Delete_AllowsArchivedTask_ButRejectsPublishedTask() + { + await using var database = await TestDatabase.CreateAsync(); + var archived = await database.AddTaskAsync("ARCHIVED", TeachingTaskStatus.Closed); + var published = await database.AddTaskAsync("PUBLISHED", TeachingTaskStatus.Published); + + var deleteArchived = await database.Controller.Delete( + archived.Id, + CancellationToken.None); + var deletePublished = await database.Controller.Delete( + published.Id, + CancellationToken.None); + + Assert.IsType(deleteArchived); + Assert.IsType(deletePublished); + Assert.False(await database.Db.TeachingTasks.AnyAsync(x => x.Id == archived.Id)); + Assert.True(await database.Db.TeachingTasks.AnyAsync(x => x.Id == published.Id)); + } + + [Fact] + public async Task Unarchive_RestoresArchivedTaskToPublished() + { + await using var database = await TestDatabase.CreateAsync(); + var task = await database.AddTaskAsync("RESTORE", TeachingTaskStatus.Closed); + + var result = await database.Controller.Unarchive(task.Id, CancellationToken.None); + + Assert.IsType(result); + await database.Db.Entry(task).ReloadAsync(); + Assert.Equal(TeachingTaskStatus.Published, task.Status); + } + + [Fact] + public async Task Batch_UnarchivesOnlyArchivedTasks() + { + await using var database = await TestDatabase.CreateAsync(); + var first = await database.AddTaskAsync("FIRST", TeachingTaskStatus.Closed); + var second = await database.AddTaskAsync("SECOND", TeachingTaskStatus.Closed); + + var result = await database.Controller.Batch( + new TeachingTaskBatchRequest( + TeachingTaskBatchOperation.Unarchive, + [first.Id, second.Id]), + CancellationToken.None); + + Assert.IsType(result); + Assert.All( + await database.Db.TeachingTasks + .Where(x => x.Id == first.Id || x.Id == second.Id) + .ToListAsync(), + task => Assert.Equal(TeachingTaskStatus.Published, task.Status)); + } + + private sealed class TestDatabase : IAsyncDisposable + { + private readonly SqliteConnection connection; + private readonly AcademicTerm term; + private readonly Course course; + + private TestDatabase( + SqliteConnection connection, + AppDbContext db, + AcademicTerm term, + Course course) + { + this.connection = connection; + Db = db; + this.term = term; + this.course = course; + Controller = new TeachingTasksController( + db, + new TestDataScope()); + } + + public AppDbContext Db { get; } + public TeachingTasksController Controller { get; } + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var college = new College { Code = "TEST", Name = "测试学院" }; + var term = new AcademicTerm + { + Code = "2026-1", + Name = "2026—2027 学年第一学期", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 15) + }; + var course = new Course + { + Code = "TEST101", + Name = "测试课程", + CollegeId = college.Id, + Nature = CourseNature.MajorRequired, + TotalHours = 32, + LectureHours = 32 + }; + db.AddRange(college, term, course); + await db.SaveChangesAsync(); + return new TestDatabase(connection, db, term, course); + } + + public async Task AddTaskAsync( + string suffix, + TeachingTaskStatus status) + { + var task = new TeachingTask + { + TaskNumber = $"TASK-{suffix}", + Name = $"教学任务 {suffix}", + AcademicTermId = term.Id, + CourseId = course.Id, + Capacity = 40, + StartWeek = 1, + EndWeek = 16, + WeeklyHours = 2, + Status = status, + PublishedAt = status == TeachingTaskStatus.Draft + ? null + : DateTime.UtcNow + }; + Db.TeachingTasks.Add(task); + await Db.SaveChangesAsync(); + return task; + } + + public async ValueTask DisposeAsync() + { + await Db.DisposeAsync(); + await connection.DisposeAsync(); + } + } + + private sealed class TestDataScope : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + Guid.NewGuid(), + "测试管理员", + null, + DataScope.All, + new HashSet([SystemRoles.SuperAdmin])); + } +} diff --git a/web/src/views/TeachingTasksView.vue b/web/src/views/TeachingTasksView.vue index f933891..db26dc7 100644 --- a/web/src/views/TeachingTasksView.vue +++ b/web/src/views/TeachingTasksView.vue @@ -56,7 +56,7 @@ const manualEligibleTeachers = ref([]) const statusLabels: Record = { Draft: '草稿', Published: '已发布', - Closed: '已结课', + Closed: '已归档', } const courseNatureLabels: Record = { GeneralRequired: '公共必修', @@ -166,6 +166,9 @@ const selectedDraftRows = computed(() => const selectedPublishedRows = computed(() => selectedRows.value.filter((item) => item.status === 'Published'), ) +const selectedClosedRows = computed(() => + selectedRows.value.filter((item) => item.status === 'Closed'), +) const responsibilityText = computed(() => { if (isSuperAdmin.value) return '超级管理员可处理全部教学任务,并保留跨层级纠错权限。' if (isAcademicAdmin.value && isCollegeAdmin.value) { @@ -242,7 +245,7 @@ function clearFilteredClasses(target: Record, filtered: any[]) { } function rowSelectable(row: any) { - return canManageRow(row) && ['Draft', 'Published'].includes(row.status) + return canManageRow(row) && ['Draft', 'Published', 'Closed'].includes(row.status) } function onTaskSelectionChanged(selection: any[]) { @@ -368,11 +371,21 @@ async function save() { } async function remove(row: any) { + const isArchived = row.status === 'Closed' try { - await ElMessageBox.confirm(`确定删除教学任务“${row.name}”吗?`, '删除草稿', { - type: 'warning', - }) + await ElMessageBox.confirm( + isArchived + ? `将永久删除已归档教学任务“${row.name}”。如任务已被排课、选课、成绩或考试引用,系统会拒绝删除。确定继续吗?` + : `确定删除教学任务草稿“${row.name}”吗?`, + isArchived ? '删除已归档任务' : '删除草稿', + { + type: 'error', + confirmButtonText: '确认删除', + cancelButtonText: '取消', + }, + ) await http.delete(`/teaching-tasks/${row.id}`) + ElMessage.success(isArchived ? '已归档教学任务已删除' : '教学任务草稿已删除') await load() } catch (error: any) { if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) @@ -396,12 +409,32 @@ async function publish(row: any) { async function closeTask(row: any) { try { - await ElMessageBox.confirm('结课后将停止后续选课和排课调整,确定继续吗?', '教学任务结课', { + await ElMessageBox.confirm('归档后将停止后续选课和排课调整,确定继续吗?', '归档教学任务', { type: 'warning', - confirmButtonText: '确认结课', + confirmButtonText: '确认归档', cancelButtonText: '取消', }) await http.post(`/teaching-tasks/${row.id}/close`) + ElMessage.success('教学任务已归档') + await load() + } catch (error: any) { + if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) + } +} + +async function unarchive(row: any) { + try { + await ElMessageBox.confirm( + '撤销归档后,教学任务将恢复为已发布状态,并重新进入相关业务流程。确定继续吗?', + '撤销教学任务归档', + { + type: 'warning', + confirmButtonText: '撤销归档', + cancelButtonText: '取消', + }, + ) + await http.post(`/teaching-tasks/${row.id}/unarchive`) + ElMessage.success('教学任务已撤销归档') await load() } catch (error: any) { if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) @@ -409,10 +442,12 @@ async function closeTask(row: any) { } async function batchAction( - operation: 'Publish' | 'Delete' | 'Archive', + operation: 'Publish' | 'Delete' | 'Archive' | 'Unarchive', targets: any[], ) { if (!targets.length) return + const deletingArchived = operation === 'Delete' && + targets.every((item) => item.status === 'Closed') const descriptions = { Publish: { title: '批量发布教学任务', @@ -422,10 +457,12 @@ async function batchAction( type: 'warning', }, Delete: { - title: '批量删除教学任务', - message: `将永久删除选中的 ${targets.length} 个草稿,此操作不可撤销。确定继续吗?`, + title: deletingArchived ? '批量删除已归档任务' : '批量删除教学任务草稿', + message: deletingArchived + ? `将永久删除选中的 ${targets.length} 个已归档任务。已被后续业务引用的任务会拒绝删除,此操作不可撤销。确定继续吗?` + : `将永久删除选中的 ${targets.length} 个草稿,此操作不可撤销。确定继续吗?`, confirmButtonText: '批量删除', - success: '教学任务草稿已批量删除', + success: deletingArchived ? '已归档教学任务已批量删除' : '教学任务草稿已批量删除', type: 'error', }, Archive: { @@ -435,6 +472,13 @@ async function batchAction( success: '教学任务已批量归档', type: 'warning', }, + Unarchive: { + title: '批量撤销教学任务归档', + message: `将选中的 ${targets.length} 个已归档任务恢复为已发布状态,并重新进入相关业务流程。确定继续吗?`, + confirmButtonText: '批量撤销归档', + success: '教学任务已批量撤销归档', + type: 'warning', + }, } as const const description = descriptions[operation] try { @@ -572,7 +616,7 @@ onMounted(async () => {
已选择 {{ selectedRows.length }} 个教学任务 - 可操作:{{ selectedDraftRows.length }} 个草稿,{{ selectedPublishedRows.length }} 个已发布 + 可操作:{{ selectedDraftRows.length }} 个草稿,{{ selectedPublishedRows.length }} 个已发布,{{ selectedClosedRows.length }} 个已归档
{ > 归档已发布({{ selectedPublishedRows.length }}) + + 撤销归档({{ selectedClosedRows.length }}) + + + 删除已归档({{ selectedClosedRows.length }}) +
@@ -625,14 +685,16 @@ onMounted(async () => { - +