diff --git a/src/Jiaowu.Api/Controllers/ExperimentsController.cs b/src/Jiaowu.Api/Controllers/ExperimentsController.cs index cabed29..8fc4525 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentsController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentsController.cs @@ -503,12 +503,32 @@ public sealed class ExperimentsController( x.CourseId != first.CourseId)) return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。"); + if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && + request.ProjectCount is < 1 or > 100) + return ValidationProblem("自行安排一次可生成 1 至 100 个实验项目。"); + var code = request.Code.Trim(); + var names = NormalizeBatchProjectNames(request.Names); + var definitionCount = request.ArrangementMode == ExperimentArrangementMode.SelfScheduled + ? request.ProjectCount + : 1; + if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && + names is not null && names.Count != definitionCount) + return ValidationProblem($"已填写 {names.Count} 个项目名称,请将生成项目数设为相同数量。"); + if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && + taskIds.Count * definitionCount > 1000) + return ValidationProblem("本次最多生成 1000 个自行安排实验项目,请缩小教学任务或项目数量。"); + + var projectCodes = Enumerable.Range(1, definitionCount) + .Select(index => ProjectCode(code, index, definitionCount)) + .ToList(); + if (projectCodes.Any(item => item.Length > 40)) + return ValidationProblem("项目编码前缀过长,无法追加顺序号。"); if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled) { var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking() .WhereIn(taskIds, x => x.TeachingTaskId) - .Where(x => x.Code == code) + .Where(x => projectCodes.Contains(x.Code)) .Select(x => x.TeachingTask!.TaskNumber) .OrderBy(x => x) .ToListAsync(cancellationToken); @@ -565,7 +585,7 @@ public sealed class ExperimentsController( return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。"); } - var projects = new List(tasks.Count); + var projects = new List(tasks.Count * definitionCount); foreach (var task in tasks) { var item = request.ForTeachingTask(task.Id); @@ -594,43 +614,57 @@ public sealed class ExperimentsController( continue; } } - projects.Add(new ExperimentProject + for (var definitionIndex = 1; definitionIndex <= definitionCount; definitionIndex++) { - TeachingTaskId = task.Id, - ScheduleEntryId = occurrence.Entry?.Id, - ScheduleWeek = occurrence.Week, - Code = code, - Name = request.Name.Trim(), - ArrangementMode = request.ArrangementMode, - Description = Normalize(request.Description), - Requirements = Normalize(request.Requirements), - StartDate = request.StartDate, - EndDate = request.EndDate, - SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt), - SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt) - }); + projects.Add(new ExperimentProject + { + TeachingTaskId = task.Id, + ScheduleEntryId = occurrence.Entry?.Id, + ScheduleWeek = occurrence.Week, + Code = ProjectCode(code, definitionIndex, definitionCount), + Name = request.Name.Trim(), + ArrangementMode = request.ArrangementMode, + Description = Normalize(request.Description), + Requirements = Normalize(request.Requirements), + StartDate = request.StartDate, + EndDate = request.EndDate, + SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt), + SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt) + }); + } } } - var names = NormalizeBatchProjectNames(request.Names); if (request.GenerateSequentialNames) { if (names is not null) return ValidationProblem("自动生成顺序号时无需填写逐行项目名称。"); - var width = Math.Max(2, projects.Count.ToString().Length); + var width = Math.Max(2, (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled + ? definitionCount + : 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}")}"; + { + var number = request.ArrangementMode == ExperimentArrangementMode.SelfScheduled + ? index % definitionCount + 1 + : index + 1; + projects[index].Name = $"{request.Name.Trim()} {number.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) + var expectedNameCount = request.ArrangementMode == ExperimentArrangementMode.SelfScheduled + ? definitionCount + : projects.Count; + if (names.Count != expectedNameCount) return ValidationProblem($"已填写 {names.Count} 个项目名称,但本次将生成 {projects.Count} 个实验项目;请确保每行对应一个项目。"); for (var index = 0; index < projects.Count; index++) - projects[index].Name = names[index]; + projects[index].Name = names[request.ArrangementMode == ExperimentArrangementMode.SelfScheduled + ? index % definitionCount + : index]; } db.ExperimentProjects.AddRange(projects); @@ -1370,9 +1404,7 @@ public sealed class ExperimentsController( .FirstOrDefaultAsync(x => x.Id == request.ClassroomId && x.IsEnabled, cancellationToken); - if (classroom is null) return "实验教室不存在或已停用。"; - if (!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature)) - return "所选场地未标注实验教学性质。"; + if (classroom is null) return "实验场地不存在或已停用。"; if (request.Capacity > classroom.Capacity) return $"场次容量不能超过教室容量 {classroom.Capacity} 人。"; if (project.ArrangementMode == @@ -1737,6 +1769,11 @@ public sealed class ExperimentsController( return names.Select(name => name.Trim()).ToList(); } + private static string ProjectCode(string prefix, int index, int count) => + count == 1 + ? prefix + : $"{prefix}-{index.ToString($"D{Math.Max(2, count.ToString().Length)}")}"; + private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { @@ -1776,7 +1813,8 @@ public sealed record ExperimentProjectBatchRequest( IReadOnlyList? Names = null, bool GenerateSequentialNames = false, DateTime? SelectionStartsAt = null, - DateTime? SelectionEndsAt = null) + DateTime? SelectionEndsAt = null, + [Range(1, 100)] int ProjectCount = 1) { public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) => new( diff --git a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs index 5c7172a..96b60b9 100644 --- a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs @@ -45,24 +45,28 @@ public sealed class ExperimentsControllerTests var pasted = fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled) with { - Names = ["实验一", "实验二"] + Names = ["实验一", "实验二"], + ProjectCount = 2 }; Assert.IsType(await controller.CreateProjects(pasted, CancellationToken.None)); - Assert.Equal(["实验一", "实验二"], await fixture.Db.ExperimentProjects - .OrderBy(x => x.TeachingTask!.TaskNumber) + Assert.Equal(["实验一", "实验二", "实验一", "实验二"], await fixture.Db.ExperimentProjects + .OrderBy(x => x.TeachingTask!.TaskNumber).ThenBy(x => x.Code) .Select(x => x.Name) .ToListAsync()); + Assert.Equal(["LAB-BATCH-01", "LAB-BATCH-02"], await fixture.Db.ExperimentProjects + .Select(x => x.Code).Distinct().OrderBy(x => x).ToListAsync()); fixture.Db.ExperimentProjects.RemoveRange(fixture.Db.ExperimentProjects); await fixture.Db.SaveChangesAsync(); var sequential = fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled) with { Name = "数据库实验", - GenerateSequentialNames = true + GenerateSequentialNames = true, + ProjectCount = 3 }; Assert.IsType(await controller.CreateProjects(sequential, CancellationToken.None)); - Assert.Equal(["数据库实验 01", "数据库实验 02"], await fixture.Db.ExperimentProjects - .OrderBy(x => x.TeachingTask!.TaskNumber) + Assert.Equal(["数据库实验 01", "数据库实验 02", "数据库实验 03", "数据库实验 01", "数据库实验 02", "数据库实验 03"], await fixture.Db.ExperimentProjects + .OrderBy(x => x.TeachingTask!.TaskNumber).ThenBy(x => x.Code) .Select(x => x.Name) .ToListAsync()); } @@ -254,6 +258,23 @@ public sealed class ExperimentsControllerTests await fixture.Controller(fixture.StudentScope).Book(session.Id, CancellationToken.None)); } + [Fact] + public async Task SelfScheduledSession_AllowsAnyEnabledTeachingVenue() + { + await using var fixture = await ExperimentFixture.CreateAsync(); + fixture.Classroom.TeachingVenueNature = TeachingVenueNature.GeneralClassroom; + await fixture.Db.SaveChangesAsync(); + var manager = fixture.Controller(fixture.ManagerScope); + + await manager.CreateProject( + fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled), + CancellationToken.None); + var project = await fixture.Db.ExperimentProjects.SingleAsync(); + + Assert.IsType(await manager.CreateSession( + project.Id, fixture.SessionRequest(1, 2), CancellationToken.None)); + } + [Fact] public async Task BoundInstructor_CanCancelOwnSession() { diff --git a/web/src/views/ExperimentsView.vue b/web/src/views/ExperimentsView.vue index 3b5e93f..f17830e 100644 --- a/web/src/views/ExperimentsView.vue +++ b/web/src/views/ExperimentsView.vue @@ -46,6 +46,7 @@ const projectForm = reactive({ name: '', projectNames: '', nameMode: 'Sequence', + projectCount: 1, arrangementMode: 'Centralized', description: '', requirements: '', @@ -142,6 +143,7 @@ const activePeriods = computed(() => || period.academicTermId === selectedProject.value.academicTermId, ), ) +const experimentClassrooms = computed(() => options.classrooms) const modeMeta: Record = { Centralized: { @@ -170,6 +172,7 @@ function resetProjectForm() { name: '', projectNames: '', nameMode: 'Sequence', + projectCount: 1, arrangementMode: 'Centralized', description: '', requirements: '', @@ -252,6 +255,9 @@ async function saveProject() { scheduleEntryId: projectForm.scheduleEntryId || null, code: projectForm.code, name: projectForm.nameMode === 'InputNames' && !editingProjectId.value ? names[0] : projectForm.name, + projectCount: projectForm.arrangementMode === 'SelfScheduled' + ? (projectForm.nameMode === 'InputNames' ? names.length : projectForm.projectCount) + : 1, arrangementMode: projectForm.arrangementMode, description: projectForm.description || null, requirements: projectForm.requirements || null, @@ -283,7 +289,7 @@ async function saveProject() { }) ElMessage.success(projectForm.arrangementMode === 'Centralized' ? `已按 ${projectForm.teachingTaskIds.length} 个教学班的全部实验课生成项目` - : `已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`) + : `已为 ${projectForm.teachingTaskIds.length} 个教学任务创建 ${projectForm.teachingTaskIds.length * payload.projectCount} 个自选实验项目`) } projectDialog.value = false await load() @@ -1117,9 +1123,8 @@ onMounted(async () => { :key="task.id" :value="task.id" :disabled="projectForm.arrangementMode === 'SelfScheduled' - && (task.schedulingMode !== 'Flexible' - || (!!selectedTask && (task.academicTermId !== selectedTask.academicTermId - || task.courseId !== selectedTask.courseId)))" + && !!selectedTask && (task.academicTermId !== selectedTask.academicTermId + || task.courseId !== selectedTask.courseId)" > {{ task.taskNumber }} {{ task.classNames.join('、') || task.name }} @@ -1130,7 +1135,7 @@ onMounted(async () => { - +
@@ -1151,6 +1156,10 @@ onMounted(async () => { + + + 同一门课程会生成 {{ projectForm.projectCount }} 个自选项目;编码和名称均自动追加顺序号。 + { maxlength="120000" placeholder="每行一个名称,可直接从 Excel 粘贴;系统按实际生成顺序逐行对应" /> - 已输入 {{ projectNameLines(projectForm.projectNames).length }} 行有效名称。 + 已输入 {{ projectNameLines(projectForm.projectNames).length }} 行有效名称;同一门课程将按这些名称生成多个自选项目。
@@ -1295,10 +1304,10 @@ onMounted(async () => { - - + + { /> - +