新建时可选“自动生成顺序号”:例如 数据库实验 01、02…

或选“逐行粘贴名称”:每行一个名称,按实际生成顺序对应项目。
草稿项目支持勾选后“批量改名”,可直接粘贴多行名称。
服务端会校验名称数量、长度和草稿状态,避免错位或修改已发布项目。
This commit is contained in:
2026-08-10 09:45:02 +08:00 Unverified
parent 4be61ab921
commit 3bcb976aa4
3 changed files with 207 additions and 4 deletions
@@ -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<ActionResult> 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<ActionResult> DeleteProjects(
@@ -1494,6 +1543,12 @@ public sealed class ExperimentsController(
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static List<string>? NormalizeBatchProjectNames(IReadOnlyList<string>? 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<string>? 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<Guid> ProjectIds);
public sealed record ExperimentProjectBatchNamesRequest(
[Required] IReadOnlyList<Guid> ProjectIds,
[Required] IReadOnlyList<string> Names);
public sealed record ExperimentSessionRequest(
Guid ClassroomId,
DateOnly SessionDate,
@@ -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<CreatedResult>(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<CreatedResult>(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<NoContentResult>(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()
{
+92 -3
View File
@@ -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<string[]>([])
const batchRenameNames = ref('')
const sessionDialog = ref(false)
const selectedProject = ref<any>(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 () => {
选中当前页全部草稿
</el-checkbox>
<span>已选 {{ draftSelectedProjectIds.length }} 个草稿项目</span>
<el-button size="small" :disabled="!draftSelectedProjectIds.length" @click="openBatchRename">批量改名</el-button>
<el-button size="small" type="primary" :disabled="!draftSelectedProjectIds.length" @click="publishSelectedProjects">批量发布</el-button>
<el-button size="small" type="danger" plain :disabled="!draftSelectedProjectIds.length" @click="deleteSelectedProjects">批量删除</el-button>
</section>
@@ -979,10 +1029,31 @@ onMounted(async () => {
<el-form-item label="项目编码" required>
<el-input v-model="projectForm.code" maxlength="40" placeholder="如 LAB-01" />
</el-form-item>
<el-form-item label="项目名称" required>
<el-form-item v-if="editingProjectId" label="项目名称" required>
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
</el-form-item>
</div>
<template v-if="!editingProjectId">
<el-form-item label="项目命名方式" required>
<el-radio-group v-model="projectForm.nameMode">
<el-radio-button value="Sequence">自动生成顺序号</el-radio-button>
<el-radio-button value="InputNames">逐行粘贴名称</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="projectForm.nameMode === 'Sequence'" label="项目名称前缀" required>
<el-input v-model="projectForm.name" maxlength="116" placeholder="如 数据库实验;将生成“数据库实验 01、数据库实验 02”" />
</el-form-item>
<el-form-item v-else label="实验项目名称" required>
<el-input
v-model="projectForm.projectNames"
type="textarea"
:rows="6"
maxlength="120000"
placeholder="每行一个名称,可直接从 Excel 粘贴;系统按实际生成顺序逐行对应"
/>
<small class="form-help">已输入 {{ projectNameLines(projectForm.projectNames).length }} 行有效名称</small>
</el-form-item>
</template>
</div>
<div class="form-section">
@@ -1043,6 +1114,23 @@ onMounted(async () => {
</template>
</el-dialog>
<el-dialog v-model="batchRenameDialog" title="批量修改实验项目名称" width="680px">
<el-alert
:title="`已选择 ${batchRenameProjects.length} 个草稿项目。请按下方显示顺序,每行填写一个名称。`"
type="info"
:closable="false"
show-icon
/>
<ol class="batch-rename-preview">
<li v-for="project in batchRenameProjects" :key="project.id">{{ project.code }} · {{ project.name }}</li>
</ol>
<el-input v-model="batchRenameNames" type="textarea" :rows="Math.min(12, Math.max(5, batchRenameProjects.length))" maxlength="12000" placeholder="每行一个实验项目名称" />
<template #footer>
<el-button @click="batchRenameDialog = false">取消</el-button>
<el-button type="primary" @click="saveBatchRename">保存名称</el-button>
</template>
</el-dialog>
<el-dialog
v-model="sessionDialog"
:title="`安排场次 · ${selectedProject?.name ?? ''}`"
@@ -1253,6 +1341,7 @@ onMounted(async () => {
.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; }