diff --git a/src/Jiaowu.Api/Controllers/ExperimentsController.cs b/src/Jiaowu.Api/Controllers/ExperimentsController.cs index 00f7515..639299f 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentsController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentsController.cs @@ -574,6 +574,27 @@ public sealed class ExperimentsController( } } + var names = NormalizeBatchProjectNames(request.Names); + if (request.GenerateSequentialNames) + { + if (names is not null) + return ValidationProblem("自动生成顺序号时无需填写逐行项目名称。"); + var width = Math.Max(2, projects.Count.ToString().Length); + if (request.Name.Trim().Length + width + 1 > 120) + return ValidationProblem("项目名称前缀过长,无法追加顺序号。"); + for (var index = 0; index < projects.Count; index++) + projects[index].Name = $"{request.Name.Trim()} {(index + 1).ToString($"D{width}")}"; + } + else if (names is not null) + { + if (names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120)) + return ValidationProblem("每个实验项目名称均不能为空,且不能超过 120 个字符。"); + if (names.Count != projects.Count) + return ValidationProblem($"已填写 {names.Count} 个项目名称,但本次将生成 {projects.Count} 个实验项目;请确保每行对应一个项目。"); + for (var index = 0; index < projects.Count; index++) + projects[index].Name = names[index]; + } + db.ExperimentProjects.AddRange(projects); await db.SaveChangesAsync(cancellationToken); return Created(string.Empty, new @@ -645,6 +666,34 @@ public sealed class ExperimentsController( return NoContent(); } + [HttpPut("batch/names")] + [Authorize(Roles = Managers)] + public async Task UpdateProjectNames( + ExperimentProjectBatchNamesRequest request, + CancellationToken cancellationToken) + { + var ids = ValidateBulkProjectIds(request.ProjectIds); + if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。"); + + var names = NormalizeBatchProjectNames(request.Names); + if (names is null || names.Count != ids.Count || names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120)) + return ValidationProblem("请为每个已选实验项目填写一个名称,每行一个且数量必须一致。"); + + var projects = await ScopedProjects() + .Where(x => ids.Contains(x.Id)) + .ToListAsync(cancellationToken); + if (projects.Count != ids.Count) return NotFound(); + if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft)) + return ConflictProblem("批量修改名称只能包含草稿实验项目。"); + + var projectsById = projects.ToDictionary(x => x.Id); + for (var index = 0; index < ids.Count; index++) + projectsById[ids[index]].Name = names[index]; + + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + [HttpDelete("batch")] [Authorize(Roles = Managers)] public async Task DeleteProjects( @@ -1494,6 +1543,12 @@ public sealed class ExperimentsController( private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static List? NormalizeBatchProjectNames(IReadOnlyList? names) + { + if (names is null || names.Count == 0) return null; + return names.Select(name => name.Trim()).ToList(); + } + private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { @@ -1522,7 +1577,9 @@ public sealed record ExperimentProjectBatchRequest( [MaxLength(1000)] string? Description, [MaxLength(1000)] string? Requirements, DateOnly StartDate, - DateOnly EndDate) + DateOnly EndDate, + IReadOnlyList? Names = null, + bool GenerateSequentialNames = false) { public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) => new( @@ -1539,6 +1596,10 @@ public sealed record ExperimentProjectBatchRequest( public sealed record ExperimentProjectBulkRequest( [Required] IReadOnlyList ProjectIds); +public sealed record ExperimentProjectBatchNamesRequest( + [Required] IReadOnlyList ProjectIds, + [Required] IReadOnlyList Names); + public sealed record ExperimentSessionRequest( Guid ClassroomId, DateOnly SessionDate, diff --git a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs index bdc9d59..51a15a7 100644 --- a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs @@ -37,6 +37,59 @@ public sealed class ExperimentsControllerTests }); } + [Fact] + public async Task BatchProjects_AssignsPastedNamesAndSequentialNamesInGenerationOrder() + { + await using var fixture = await ExperimentFixture.CreateAsync(); + var controller = fixture.Controller(fixture.ManagerScope); + + var pasted = fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled) with + { + Names = ["实验一", "实验二"] + }; + Assert.IsType(await controller.CreateProjects(pasted, CancellationToken.None)); + Assert.Equal(["实验一", "实验二"], await fixture.Db.ExperimentProjects + .OrderBy(x => x.TeachingTask!.TaskNumber) + .Select(x => x.Name) + .ToListAsync()); + + fixture.Db.ExperimentProjects.RemoveRange(fixture.Db.ExperimentProjects); + await fixture.Db.SaveChangesAsync(); + var sequential = fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled) with + { + Name = "数据库实验", + GenerateSequentialNames = true + }; + Assert.IsType(await controller.CreateProjects(sequential, CancellationToken.None)); + Assert.Equal(["数据库实验 01", "数据库实验 02"], await fixture.Db.ExperimentProjects + .OrderBy(x => x.TeachingTask!.TaskNumber) + .Select(x => x.Name) + .ToListAsync()); + } + + [Fact] + public async Task BatchProjectNames_UpdatesOnlySelectedDraftProjectsInRequestOrder() + { + await using var fixture = await ExperimentFixture.CreateAsync(); + var controller = fixture.Controller(fixture.ManagerScope); + await controller.CreateProjects( + fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled), + CancellationToken.None); + var ids = await fixture.Db.ExperimentProjects + .OrderByDescending(x => x.TeachingTask!.TaskNumber) + .Select(x => x.Id) + .ToListAsync(); + + var result = await controller.UpdateProjectNames( + new ExperimentProjectBatchNamesRequest(ids, ["第二个", "第一个"]), + CancellationToken.None); + + Assert.IsType(result); + var projectsById = await fixture.Db.ExperimentProjects.ToDictionaryAsync(x => x.Id, x => x.Name); + Assert.Equal("第二个", projectsById[ids[0]]); + Assert.Equal("第一个", projectsById[ids[1]]); + } + [Fact] public async Task CentralizedBatchProjects_CreatesOneProjectForEveryScheduledExperimentLesson() { diff --git a/web/src/views/ExperimentsView.vue b/web/src/views/ExperimentsView.vue index d9eacad..c8bb08d 100644 --- a/web/src/views/ExperimentsView.vue +++ b/web/src/views/ExperimentsView.vue @@ -42,12 +42,18 @@ const projectForm = reactive({ scheduleEntryId: '', code: '', name: '', + projectNames: '', + nameMode: 'Sequence', arrangementMode: 'Centralized', description: '', requirements: '', dates: [] as string[], }) +const batchRenameDialog = ref(false) +const batchRenameProjectIds = ref([]) +const batchRenameNames = ref('') + const sessionDialog = ref(false) const selectedProject = ref(null) const sessionForm = reactive({ @@ -119,6 +125,9 @@ const draftSelectedProjectIds = computed(() => selectedProjectIds.value.filter((id) => projects.value.some((project) => project.id === id && project.status === 'Draft')), ) +const batchRenameProjects = computed(() => + projects.value.filter((project) => batchRenameProjectIds.value.includes(project.id)), +) const allPageDraftSelected = computed(() => { const ids = projects.value.filter((project) => project.status === 'Draft').map((project) => project.id) return ids.length > 0 && ids.every((id) => selectedProjectIds.value.includes(id)) @@ -154,6 +163,8 @@ function resetProjectForm() { scheduleEntryId: '', code: '', name: '', + projectNames: '', + nameMode: 'Sequence', arrangementMode: 'Centralized', description: '', requirements: '', @@ -191,6 +202,8 @@ function openEditProject(project: any) { scheduleEntryId: project.scheduleEntryId ?? '', code: project.code, name: project.name, + projectNames: '', + nameMode: 'Sequence', arrangementMode: project.arrangementMode, description: project.description ?? '', requirements: project.requirements ?? '', @@ -199,11 +212,21 @@ function openEditProject(project: any) { projectDialog.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 + ? !!projectForm.name.trim() + : projectForm.nameMode === 'InputNames' + ? names.length > 0 + : !!projectForm.name.trim() if (!projectForm.teachingTaskIds.length || (editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) || !projectForm.code.trim() - || !projectForm.name.trim() || projectForm.dates.length !== 2) { + || !hasValidName || projectForm.dates.length !== 2) { ElMessage.warning(projectForm.arrangementMode === 'Centralized' && editingProjectId.value ? '请选择课表实验课,并填写项目编码、名称和开放日期' : '请填写教学任务、项目编码、名称和开放日期') @@ -213,7 +236,7 @@ async function saveProject() { teachingTaskId: projectForm.teachingTaskIds[0], scheduleEntryId: projectForm.scheduleEntryId || null, code: projectForm.code, - name: projectForm.name, + name: projectForm.nameMode === 'InputNames' && !editingProjectId.value ? names[0] : projectForm.name, arrangementMode: projectForm.arrangementMode, description: projectForm.description || null, requirements: projectForm.requirements || null, @@ -229,6 +252,8 @@ async function saveProject() { ...payload, teachingTaskId: undefined, teachingTaskIds: projectForm.teachingTaskIds, + names: projectForm.nameMode === 'InputNames' ? names : undefined, + generateSequentialNames: projectForm.nameMode === 'Sequence', }) ElMessage.success(projectForm.arrangementMode === 'Centralized' ? `已按 ${projectForm.teachingTaskIds.length} 个教学班的全部实验课生成项目` @@ -395,6 +420,30 @@ async function deleteProject(project: any) { } } +function openBatchRename() { + batchRenameProjectIds.value = projects.value + .filter((project) => draftSelectedProjectIds.value.includes(project.id)) + .map((project) => project.id) + batchRenameNames.value = batchRenameProjects.value.map((project) => project.name).join('\n') + batchRenameDialog.value = true +} + +async function saveBatchRename() { + const names = projectNameLines(batchRenameNames.value) + if (names.length !== batchRenameProjectIds.value.length) { + ElMessage.warning(`已选 ${batchRenameProjectIds.value.length} 个项目,请粘贴 ${batchRenameProjectIds.value.length} 行项目名称`) + return + } + try { + await http.put('/experiments/batch/names', { projectIds: batchRenameProjectIds.value, names }) + ElMessage.success('实验项目名称已批量更新') + batchRenameDialog.value = false + await load() + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } +} + function togglePageDraftSelection(value: unknown) { const ids = projects.value.filter((project) => project.status === 'Draft').map((project) => project.id) selectedProjectIds.value = Boolean(value) @@ -715,6 +764,7 @@ onMounted(async () => { 选中当前页全部草稿 已选 {{ draftSelectedProjectIds.length }} 个草稿项目 + 批量改名 批量发布 批量删除 @@ -979,10 +1029,31 @@ onMounted(async () => { - + +
@@ -1043,6 +1114,23 @@ onMounted(async () => { + + +
    +
  1. {{ project.code }} · {{ project.name }}
  2. +
+ + +
+ { .result-note { margin-left: auto; color: var(--muted); font-size: 12px; } .experiment-batch-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-height: 46px; margin: -6px 0 16px; padding: 8px 12px; border: 1px solid #cddce3; background: #f7fafb; } .experiment-batch-bar > span { margin-right: auto; color: #5c7481; font-size: 12px; } +.batch-rename-preview { max-height: 180px; margin: 14px 0; overflow: auto; padding: 10px 12px 10px 32px; border: 1px solid #dce5e9; background: #f8fbfc; color: #526b78; font-size: 12px; line-height: 1.8; } .experiment-pagination { justify-content: flex-end; margin-top: 4px; } .project-list { display: grid; gap: 14px; min-height: 160px; } .project-group { overflow: hidden; border: 1px solid #cddce3; background: #f6fafb; }