修正实验室选择

This commit is contained in:
biss committed 2026-08-15 22:42:20 +08:00
1 parent 57372cf615
commit 90877ae428
3 files changed
+97 -29

No files matched your search

@@ -503,12 +503,32 @@ public sealed class ExperimentsController(
x.CourseId != first.CourseId)) x.CourseId != first.CourseId))
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。"); return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
request.ProjectCount is < 1 or > 100)
return ValidationProblem("自行安排一次可生成 1 至 100 个实验项目。");
var code = request.Code.Trim(); 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) if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
{ {
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking() var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId) .WhereIn(taskIds, x => x.TeachingTaskId)
.Where(x => x.Code == code) .Where(x => projectCodes.Contains(x.Code))
.Select(x => x.TeachingTask!.TaskNumber) .Select(x => x.TeachingTask!.TaskNumber)
.OrderBy(x => x) .OrderBy(x => x)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -565,7 +585,7 @@ public sealed class ExperimentsController(
return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。"); return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。");
} }
var projects = new List<ExperimentProject>(tasks.Count); var projects = new List<ExperimentProject>(tasks.Count * definitionCount);
foreach (var task in tasks) foreach (var task in tasks)
{ {
var item = request.ForTeachingTask(task.Id); var item = request.ForTeachingTask(task.Id);
@@ -594,12 +614,14 @@ public sealed class ExperimentsController(
continue; continue;
} }
} }
for (var definitionIndex = 1; definitionIndex <= definitionCount; definitionIndex++)
{
projects.Add(new ExperimentProject projects.Add(new ExperimentProject
{ {
TeachingTaskId = task.Id, TeachingTaskId = task.Id,
ScheduleEntryId = occurrence.Entry?.Id, ScheduleEntryId = occurrence.Entry?.Id,
ScheduleWeek = occurrence.Week, ScheduleWeek = occurrence.Week,
Code = code, Code = ProjectCode(code, definitionIndex, definitionCount),
Name = request.Name.Trim(), Name = request.Name.Trim(),
ArrangementMode = request.ArrangementMode, ArrangementMode = request.ArrangementMode,
Description = Normalize(request.Description), Description = Normalize(request.Description),
@@ -611,26 +633,38 @@ public sealed class ExperimentsController(
}); });
} }
} }
}
var names = NormalizeBatchProjectNames(request.Names);
if (request.GenerateSequentialNames) if (request.GenerateSequentialNames)
{ {
if (names is not null) if (names is not null)
return ValidationProblem("自动生成顺序号时无需填写逐行项目名称。"); 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) if (request.Name.Trim().Length + width + 1 > 120)
return ValidationProblem("项目名称前缀过长,无法追加顺序号。"); return ValidationProblem("项目名称前缀过长,无法追加顺序号。");
for (var index = 0; index < projects.Count; index++) 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) else if (names is not null)
{ {
if (names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120)) if (names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120))
return ValidationProblem("每个实验项目名称均不能为空,且不能超过 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} 个实验项目;请确保每行对应一个项目。"); return ValidationProblem($"已填写 {names.Count} 个项目名称,但本次将生成 {projects.Count} 个实验项目;请确保每行对应一个项目。");
for (var index = 0; index < projects.Count; index++) 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); db.ExperimentProjects.AddRange(projects);
@@ -1370,9 +1404,7 @@ public sealed class ExperimentsController(
.FirstOrDefaultAsync(x => .FirstOrDefaultAsync(x =>
x.Id == request.ClassroomId && x.IsEnabled, x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken); cancellationToken);
if (classroom is null) return "实验教室不存在或已停用。"; if (classroom is null) return "实验场地不存在或已停用。";
if (!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
return "所选场地未标注实验教学性质。";
if (request.Capacity > classroom.Capacity) if (request.Capacity > classroom.Capacity)
return $"场次容量不能超过教室容量 {classroom.Capacity} 人。"; return $"场次容量不能超过教室容量 {classroom.Capacity} 人。";
if (project.ArrangementMode == if (project.ArrangementMode ==
@@ -1737,6 +1769,11 @@ public sealed class ExperimentsController(
return names.Select(name => name.Trim()).ToList(); 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) => private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails Conflict(new ProblemDetails
{ {
@@ -1776,7 +1813,8 @@ public sealed record ExperimentProjectBatchRequest(
IReadOnlyList<string>? Names = null, IReadOnlyList<string>? Names = null,
bool GenerateSequentialNames = false, bool GenerateSequentialNames = false,
DateTime? SelectionStartsAt = null, DateTime? SelectionStartsAt = null,
DateTime? SelectionEndsAt = null) DateTime? SelectionEndsAt = null,
[Range(1, 100)] int ProjectCount = 1)
{ {
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) => public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
new( new(
@@ -45,24 +45,28 @@ public sealed class ExperimentsControllerTests
var pasted = fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled) with var pasted = fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled) with
{ {
Names = ["实验一", "实验二"] Names = ["实验一", "实验二"],
ProjectCount = 2
}; };
Assert.IsType<CreatedResult>(await controller.CreateProjects(pasted, CancellationToken.None)); Assert.IsType<CreatedResult>(await controller.CreateProjects(pasted, CancellationToken.None));
Assert.Equal(["实验一", "实验二"], await fixture.Db.ExperimentProjects Assert.Equal(["实验一", "实验二", "实验一", "实验二"], await fixture.Db.ExperimentProjects
.OrderBy(x => x.TeachingTask!.TaskNumber) .OrderBy(x => x.TeachingTask!.TaskNumber).ThenBy(x => x.Code)
.Select(x => x.Name) .Select(x => x.Name)
.ToListAsync()); .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); fixture.Db.ExperimentProjects.RemoveRange(fixture.Db.ExperimentProjects);
await fixture.Db.SaveChangesAsync(); await fixture.Db.SaveChangesAsync();
var sequential = fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled) with var sequential = fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled) with
{ {
Name = "数据库实验", Name = "数据库实验",
GenerateSequentialNames = true GenerateSequentialNames = true,
ProjectCount = 3
}; };
Assert.IsType<CreatedResult>(await controller.CreateProjects(sequential, CancellationToken.None)); Assert.IsType<CreatedResult>(await controller.CreateProjects(sequential, CancellationToken.None));
Assert.Equal(["数据库实验 01", "数据库实验 02"], await fixture.Db.ExperimentProjects Assert.Equal(["数据库实验 01", "数据库实验 02", "数据库实验 03", "数据库实验 01", "数据库实验 02", "数据库实验 03"], await fixture.Db.ExperimentProjects
.OrderBy(x => x.TeachingTask!.TaskNumber) .OrderBy(x => x.TeachingTask!.TaskNumber).ThenBy(x => x.Code)
.Select(x => x.Name) .Select(x => x.Name)
.ToListAsync()); .ToListAsync());
} }
@@ -254,6 +258,23 @@ public sealed class ExperimentsControllerTests
await fixture.Controller(fixture.StudentScope).Book(session.Id, CancellationToken.None)); 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<CreatedResult>(await manager.CreateSession(
project.Id, fixture.SessionRequest(1, 2), CancellationToken.None));
}
[Fact] [Fact]
public async Task BoundInstructor_CanCancelOwnSession() public async Task BoundInstructor_CanCancelOwnSession()
{ {
+20 -11
View File
@@ -46,6 +46,7 @@ const projectForm = reactive({
name: '', name: '',
projectNames: '', projectNames: '',
nameMode: 'Sequence', nameMode: 'Sequence',
projectCount: 1,
arrangementMode: 'Centralized', arrangementMode: 'Centralized',
description: '', description: '',
requirements: '', requirements: '',
@@ -142,6 +143,7 @@ const activePeriods = computed(() =>
|| period.academicTermId === selectedProject.value.academicTermId, || period.academicTermId === selectedProject.value.academicTermId,
), ),
) )
const experimentClassrooms = computed(() => options.classrooms)
const modeMeta: Record<string, { label: string; note: string }> = { const modeMeta: Record<string, { label: string; note: string }> = {
Centralized: { Centralized: {
@@ -170,6 +172,7 @@ function resetProjectForm() {
name: '', name: '',
projectNames: '', projectNames: '',
nameMode: 'Sequence', nameMode: 'Sequence',
projectCount: 1,
arrangementMode: 'Centralized', arrangementMode: 'Centralized',
description: '', description: '',
requirements: '', requirements: '',
@@ -252,6 +255,9 @@ async function saveProject() {
scheduleEntryId: projectForm.scheduleEntryId || null, scheduleEntryId: projectForm.scheduleEntryId || null,
code: projectForm.code, code: projectForm.code,
name: projectForm.nameMode === 'InputNames' && !editingProjectId.value ? names[0] : projectForm.name, 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, arrangementMode: projectForm.arrangementMode,
description: projectForm.description || null, description: projectForm.description || null,
requirements: projectForm.requirements || null, requirements: projectForm.requirements || null,
@@ -283,7 +289,7 @@ async function saveProject() {
}) })
ElMessage.success(projectForm.arrangementMode === 'Centralized' ElMessage.success(projectForm.arrangementMode === 'Centralized'
? `已按 ${projectForm.teachingTaskIds.length} 个教学班的全部实验课生成项目` ? `已按 ${projectForm.teachingTaskIds.length} 个教学班的全部实验课生成项目`
: `已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`) : `已为 ${projectForm.teachingTaskIds.length} 个教学任务创建 ${projectForm.teachingTaskIds.length * payload.projectCount} 个自选实验项目`)
} }
projectDialog.value = false projectDialog.value = false
await load() await load()
@@ -1117,9 +1123,8 @@ onMounted(async () => {
:key="task.id" :key="task.id"
:value="task.id" :value="task.id"
:disabled="projectForm.arrangementMode === 'SelfScheduled' :disabled="projectForm.arrangementMode === 'SelfScheduled'
&& (task.schedulingMode !== 'Flexible' && !!selectedTask && (task.academicTermId !== selectedTask.academicTermId
|| (!!selectedTask && (task.academicTermId !== selectedTask.academicTermId || task.courseId !== selectedTask.courseId)"
|| task.courseId !== selectedTask.courseId)))"
> >
<b>{{ task.taskNumber }}</b> <b>{{ task.taskNumber }}</b>
<span>{{ task.classNames.join('、') || task.name }}</span> <span>{{ task.classNames.join('、') || task.name }}</span>
@@ -1130,7 +1135,7 @@ onMounted(async () => {
</el-checkbox-group> </el-checkbox-group>
<small v-if="!editingProjectId" class="form-help"> <small v-if="!editingProjectId" class="form-help">
<template v-if="projectForm.arrangementMode === 'Centralized'">已选 {{ projectForm.teachingTaskIds.length }} 个教学班;系统会为每个教学班的全部已发布实验课生成项目。</template> <template v-if="projectForm.arrangementMode === 'Centralized'">已选 {{ projectForm.teachingTaskIds.length }} 个教学班;系统会为每个教学班的全部已发布实验课生成项目。</template>
<template v-else>自行安排仅可选择“非排时课程”的教学任务;只能勾选同一学期、同一课程,实验编码、名称、内容和开放日期将一次应用到这些教学任务。</template> <template v-else>自行安排不写入课表;只能勾选同一学期、同一课程。每个项目都可另行设置多个可预约场次。</template>
</small> </small>
</el-form-item> </el-form-item>
<div class="form-grid two"> <div class="form-grid two">
@@ -1151,6 +1156,10 @@ onMounted(async () => {
<el-form-item v-if="projectForm.nameMode === 'Sequence'" label="项目名称前缀" required> <el-form-item v-if="projectForm.nameMode === 'Sequence'" label="项目名称前缀" required>
<el-input v-model="projectForm.name" maxlength="116" placeholder="如 数据库实验;将生成“数据库实验 01、数据库实验 02”" /> <el-input v-model="projectForm.name" maxlength="116" placeholder="如 数据库实验;将生成“数据库实验 01、数据库实验 02”" />
</el-form-item> </el-form-item>
<el-form-item v-if="projectForm.arrangementMode === 'SelfScheduled' && projectForm.nameMode === 'Sequence'" label="生成项目数" required>
<el-input-number v-model="projectForm.projectCount" :min="1" :max="100" />
<small class="form-help">同一门课程会生成 {{ projectForm.projectCount }} 个自选项目;编码和名称均自动追加顺序号。</small>
</el-form-item>
<el-form-item v-else label="实验项目名称" required> <el-form-item v-else label="实验项目名称" required>
<el-input <el-input
v-model="projectForm.projectNames" v-model="projectForm.projectNames"
@@ -1159,7 +1168,7 @@ onMounted(async () => {
maxlength="120000" maxlength="120000"
placeholder="每行一个名称,可直接从 Excel 粘贴;系统按实际生成顺序逐行对应" placeholder="每行一个名称,可直接从 Excel 粘贴;系统按实际生成顺序逐行对应"
/> />
<small class="form-help">已输入 {{ projectNameLines(projectForm.projectNames).length }} 行有效名称。</small> <small class="form-help">已输入 {{ projectNameLines(projectForm.projectNames).length }} 行有效名称;同一门课程将按这些名称生成多个自选项目。</small>
</el-form-item> </el-form-item>
</template> </template>
</div> </div>
@@ -1295,10 +1304,10 @@ onMounted(async () => {
<el-input-number v-model="sessionForm.capacity" :min="1" :max="10000" /> <el-input-number v-model="sessionForm.capacity" :min="1" :max="10000" />
</el-form-item> </el-form-item>
</div> </div>
<el-form-item label="实验室" required> <el-form-item label="实验场地" required>
<el-select v-model="sessionForm.classroomId" filterable placeholder="选择实验室或教学场所"> <el-select v-model="sessionForm.classroomId" filterable placeholder="选择教学场地">
<el-option <el-option
v-for="room in options.classrooms.filter((item: any) => (Number(item.teachingVenueNature) & (2 | 4 | 8 | 16)) !== 0)" v-for="room in experimentClassrooms"
:key="room.id" :key="room.id"
:label="`${room.campusName} · ${room.buildingName} ${room.name} · ${room.capacity} 人`" :label="`${room.campusName} · ${room.buildingName} ${room.name} · ${room.capacity} 人`"
:value="room.id" :value="room.id"
@@ -1388,9 +1397,9 @@ onMounted(async () => {
/> />
</el-select> </el-select>
<el-input-number v-model="row.periodCount" :min="1" :max="12" controls-position="right" /> <el-input-number v-model="row.periodCount" :min="1" :max="12" controls-position="right" />
<el-select v-model="row.classroomId" filterable placeholder="实验室"> <el-select v-model="row.classroomId" filterable placeholder="实验场地">
<el-option <el-option
v-for="room in options.classrooms.filter((item: any) => (Number(item.teachingVenueNature) & (2 | 4 | 8 | 16)) !== 0)" v-for="room in experimentClassrooms"
:key="room.id" :key="room.id"
:label="`${room.campusName} · ${room.buildingName} ${room.name} · ${room.capacity} 人`" :label="`${room.campusName} · ${room.buildingName} ${room.name} · ${room.capacity} 人`"
:value="room.id" :value="room.id"