diff --git a/src/Jiaowu.Api/Controllers/ExperimentsController.cs b/src/Jiaowu.Api/Controllers/ExperimentsController.cs index 639299f..c4ed31a 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentsController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentsController.cs @@ -666,6 +666,39 @@ public sealed class ExperimentsController( return NoContent(); } + [HttpPut("{id:guid}/published-details")] + [Authorize(Roles = Managers)] + public async Task CorrectPublishedProjectDetails( + Guid id, + PublishedExperimentProjectCorrectionRequest request, + CancellationToken cancellationToken) + { + var project = await ScopedProjects() + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .ThenInclude(x => x.Teacher) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Course) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (project is null) return NotFound(); + if (project.Status != ExperimentProjectStatus.Published) + return ConflictProblem("只有已发布实验项目可以修正教学内容。"); + if (!CanCorrectPublishedProject(project.TeachingTask!)) return Forbid(); + if (string.IsNullOrWhiteSpace(request.Name)) + return ValidationProblem("请填写实验项目名称。"); + + var changed = project.Name != request.Name.Trim() || + project.Description != Normalize(request.Description) || + project.Requirements != Normalize(request.Requirements); + project.Name = request.Name.Trim(); + project.Description = Normalize(request.Description); + project.Requirements = Normalize(request.Requirements); + await db.SaveChangesAsync(cancellationToken); + if (changed) + await NotifyProjectCorrectionAsync(project, cancellationToken); + return NoContent(); + } + [HttpPut("batch/names")] [Authorize(Roles = Managers)] public async Task UpdateProjectNames( @@ -1442,6 +1475,16 @@ public sealed class ExperimentsController( taskIds.Contains(x.TeachingTaskId)); } + private bool CanCorrectPublishedProject(TeachingTask task) + { + var scope = currentUserDataScope.Current; + return scope.IsInRole(SystemRoles.SuperAdmin) || + scope.IsInRole(SystemRoles.AcademicAdmin) || + scope.IsInRole(SystemRoles.CollegeAdmin) || + task.Teachers.Any(item => + item.Teacher?.UserId == scope.UserId); + } + private Task CurrentStudentAsync( CancellationToken cancellationToken) => db.Students.FirstOrDefaultAsync(x => @@ -1492,6 +1535,22 @@ public sealed class ExperimentsController( NotificationCategory.Schedule); } + private async Task NotifyProjectCorrectionAsync( + ExperimentProject project, + CancellationToken cancellationToken) + { + var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken); + if (userIds.Count == 0) return; + await NotificationService.SendToUserIdsAsync( + db, + userIds, + "实验项目内容已更新", + $"《{project.TeachingTask!.Course!.Name}》的“{project.Name}”教学内容或要求已修正,请重新查看。", + "/experiments", + cancellationToken, + NotificationCategory.Schedule); + } + private static List? ValidateBulkProjectIds(IReadOnlyList? projectIds) { var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList(); @@ -1569,6 +1628,11 @@ public sealed record ExperimentProjectRequest( DateOnly EndDate, Guid? ScheduleEntryId = null); +public sealed record PublishedExperimentProjectCorrectionRequest( + [Required, MaxLength(120)] string Name, + [MaxLength(1000)] string? Description, + [MaxLength(1000)] string? Requirements); + public sealed record ExperimentProjectBatchRequest( [Required] IReadOnlyList TeachingTaskIds, [Required, MaxLength(40)] string Code, diff --git a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs index 51a15a7..d2c7c15 100644 --- a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs @@ -246,6 +246,35 @@ public sealed class ExperimentsControllerTests Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync()); } + [Fact] + public async Task PublishedProject_AllowsTeachingDetailCorrectionWithoutChangingSchedule() + { + await using var fixture = await ExperimentFixture.CreateAsync(); + var controller = fixture.Controller(fixture.ManagerScope); + await controller.CreateProject( + fixture.ProjectRequest(ExperimentArrangementMode.Centralized), + CancellationToken.None); + var project = await fixture.Db.ExperimentProjects.SingleAsync(); + await controller.PublishProject(project.Id, CancellationToken.None); + fixture.Db.ChangeTracker.Clear(); + + Assert.IsType(await controller.CorrectPublishedProjectDetails( + project.Id, + new PublishedExperimentProjectCorrectionRequest( + "修正后的实验名称", + "按本班教学计划调整实验步骤。", + "请提前完成环境检查。"), + CancellationToken.None)); + + fixture.Db.ChangeTracker.Clear(); + var corrected = await fixture.Db.ExperimentProjects.SingleAsync(); + Assert.Equal("修正后的实验名称", corrected.Name); + Assert.Equal("按本班教学计划调整实验步骤。", corrected.Description); + Assert.Equal("请提前完成环境检查。", corrected.Requirements); + Assert.Equal("LAB-C", corrected.Code); + Assert.Equal(fixture.ScheduleEntry.Id, corrected.ScheduleEntryId); + } + [Fact] public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged() { diff --git a/web/src/views/ExperimentsView.vue b/web/src/views/ExperimentsView.vue index c8bb08d..ec3fa34 100644 --- a/web/src/views/ExperimentsView.vue +++ b/web/src/views/ExperimentsView.vue @@ -37,6 +37,7 @@ const taskKeyword = ref('') const projectDialog = ref(false) const editingProjectId = ref('') +const correctingPublishedProject = ref(false) const projectForm = reactive({ teachingTaskIds: [] as string[], scheduleEntryId: '', @@ -158,6 +159,7 @@ const statusLabels: Record = { function resetProjectForm() { editingProjectId.value = '' + correctingPublishedProject.value = false Object.assign(projectForm, { teachingTaskIds: [], scheduleEntryId: '', @@ -197,6 +199,7 @@ function openCreateProject() { function openEditProject(project: any) { editingProjectId.value = project.id + correctingPublishedProject.value = false Object.assign(projectForm, { teachingTaskIds: [project.teachingTaskId], scheduleEntryId: project.scheduleEntryId ?? '', @@ -212,18 +215,23 @@ function openEditProject(project: any) { projectDialog.value = true } +function openCorrectPublishedProject(project: any) { + openEditProject(project) + correctingPublishedProject.value = true +} + function projectNameLines(value: string) { return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean) } async function saveProject() { const names = projectNameLines(projectForm.projectNames) - const hasValidName = editingProjectId.value + const hasValidName = correctingPublishedProject.value || editingProjectId.value ? !!projectForm.name.trim() : projectForm.nameMode === 'InputNames' ? names.length > 0 : !!projectForm.name.trim() - if (!projectForm.teachingTaskIds.length || + if ((!correctingPublishedProject.value && !projectForm.teachingTaskIds.length) || (editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) || !projectForm.code.trim() || !hasValidName || projectForm.dates.length !== 2) { @@ -244,7 +252,14 @@ async function saveProject() { endDate: projectForm.dates[1], } try { - if (editingProjectId.value) { + if (correctingPublishedProject.value) { + await http.put(`/experiments/${editingProjectId.value}/published-details`, { + name: projectForm.name, + description: projectForm.description || null, + requirements: projectForm.requirements || null, + }) + ElMessage.success('已修正已发布项目的教学内容,并通知学生查看') + } else if (editingProjectId.value) { await http.put(`/experiments/${editingProjectId.value}`, payload) ElMessage.success('实验项目已更新') } else { @@ -842,7 +857,10 @@ onMounted(async () => { @click="publishProject(row)" >发布 - 关闭项目 + { +
PROJECT规定实验项目
@@ -1027,7 +1051,7 @@ onMounted(async () => {
- + @@ -1058,7 +1082,7 @@ onMounted(async () => {
ROUTE选择运行轨道
- + 集中安排复用已发布课表的实验课 @@ -1069,6 +1093,7 @@ onMounted(async () => { {