新建时可选“自动生成顺序号”:例如 数据库实验 01、02…
或选“逐行粘贴名称”:每行一个名称,按实际生成顺序对应项目。 草稿项目支持勾选后“批量改名”,可直接粘贴多行名称。 服务端会校验名称数量、长度和草稿状态,避免错位或修改已发布项目。
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user