开放实验项目发布后修正功能

This commit is contained in:
2026-08-10 17:02:39 +08:00 Unverified
parent fc326ac16e
commit 834574accb
3 changed files with 128 additions and 8 deletions
@@ -666,6 +666,39 @@ public sealed class ExperimentsController(
return NoContent();
}
[HttpPut("{id:guid}/published-details")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CorrectPublishedProjectDetails(
Guid id,
PublishedExperimentProjectCorrectionRequest request,
CancellationToken cancellationToken)
{
var project = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (project is null) return NotFound();
if (project.Status != ExperimentProjectStatus.Published)
return ConflictProblem("只有已发布实验项目可以修正教学内容。");
if (!CanCorrectPublishedProject(project.TeachingTask!)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name))
return ValidationProblem("请填写实验项目名称。");
var changed = project.Name != request.Name.Trim() ||
project.Description != Normalize(request.Description) ||
project.Requirements != Normalize(request.Requirements);
project.Name = request.Name.Trim();
project.Description = Normalize(request.Description);
project.Requirements = Normalize(request.Requirements);
await db.SaveChangesAsync(cancellationToken);
if (changed)
await NotifyProjectCorrectionAsync(project, cancellationToken);
return NoContent();
}
[HttpPut("batch/names")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateProjectNames(
@@ -1442,6 +1475,16 @@ public sealed class ExperimentsController(
taskIds.Contains(x.TeachingTaskId));
}
private bool CanCorrectPublishedProject(TeachingTask task)
{
var scope = currentUserDataScope.Current;
return scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.CollegeAdmin) ||
task.Teachers.Any(item =>
item.Teacher?.UserId == scope.UserId);
}
private Task<Student?> CurrentStudentAsync(
CancellationToken cancellationToken) =>
db.Students.FirstOrDefaultAsync(x =>
@@ -1492,6 +1535,22 @@ public sealed class ExperimentsController(
NotificationCategory.Schedule);
}
private async Task NotifyProjectCorrectionAsync(
ExperimentProject project,
CancellationToken cancellationToken)
{
var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
if (userIds.Count == 0) return;
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验项目内容已更新",
$"《{project.TeachingTask!.Course!.Name}》的“{project.Name}”教学内容或要求已修正,请重新查看。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
{
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
@@ -1569,6 +1628,11 @@ public sealed record ExperimentProjectRequest(
DateOnly EndDate,
Guid? ScheduleEntryId = null);
public sealed record PublishedExperimentProjectCorrectionRequest(
[Required, MaxLength(120)] string Name,
[MaxLength(1000)] string? Description,
[MaxLength(1000)] string? Requirements);
public sealed record ExperimentProjectBatchRequest(
[Required] IReadOnlyList<Guid> TeachingTaskIds,
[Required, MaxLength(40)] string Code,
@@ -246,6 +246,35 @@ public sealed class ExperimentsControllerTests
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
}
[Fact]
public async Task PublishedProject_AllowsTeachingDetailCorrectionWithoutChangingSchedule()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
await controller.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
CancellationToken.None);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
await controller.PublishProject(project.Id, CancellationToken.None);
fixture.Db.ChangeTracker.Clear();
Assert.IsType<NoContentResult>(await controller.CorrectPublishedProjectDetails(
project.Id,
new PublishedExperimentProjectCorrectionRequest(
"修正后的实验名称",
"按本班教学计划调整实验步骤。",
"请提前完成环境检查。"),
CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
var corrected = await fixture.Db.ExperimentProjects.SingleAsync();
Assert.Equal("修正后的实验名称", corrected.Name);
Assert.Equal("按本班教学计划调整实验步骤。", corrected.Description);
Assert.Equal("请提前完成环境检查。", corrected.Requirements);
Assert.Equal("LAB-C", corrected.Code);
Assert.Equal(fixture.ScheduleEntry.Id, corrected.ScheduleEntryId);
}
[Fact]
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
{
+35 -8
View File
@@ -37,6 +37,7 @@ const taskKeyword = ref('')
const projectDialog = ref(false)
const editingProjectId = ref('')
const correctingPublishedProject = ref(false)
const projectForm = reactive({
teachingTaskIds: [] as string[],
scheduleEntryId: '',
@@ -158,6 +159,7 @@ const statusLabels: Record<string, string> = {
function resetProjectForm() {
editingProjectId.value = ''
correctingPublishedProject.value = false
Object.assign(projectForm, {
teachingTaskIds: [],
scheduleEntryId: '',
@@ -197,6 +199,7 @@ function openCreateProject() {
function openEditProject(project: any) {
editingProjectId.value = project.id
correctingPublishedProject.value = false
Object.assign(projectForm, {
teachingTaskIds: [project.teachingTaskId],
scheduleEntryId: project.scheduleEntryId ?? '',
@@ -212,18 +215,23 @@ function openEditProject(project: any) {
projectDialog.value = true
}
function openCorrectPublishedProject(project: any) {
openEditProject(project)
correctingPublishedProject.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
const hasValidName = correctingPublishedProject.value || editingProjectId.value
? !!projectForm.name.trim()
: projectForm.nameMode === 'InputNames'
? names.length > 0
: !!projectForm.name.trim()
if (!projectForm.teachingTaskIds.length ||
if ((!correctingPublishedProject.value && !projectForm.teachingTaskIds.length) ||
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
!projectForm.code.trim()
|| !hasValidName || projectForm.dates.length !== 2) {
@@ -244,7 +252,14 @@ async function saveProject() {
endDate: projectForm.dates[1],
}
try {
if (editingProjectId.value) {
if (correctingPublishedProject.value) {
await http.put(`/experiments/${editingProjectId.value}/published-details`, {
name: projectForm.name,
description: projectForm.description || null,
requirements: projectForm.requirements || null,
})
ElMessage.success('已修正已发布项目的教学内容,并通知学生查看')
} else if (editingProjectId.value) {
await http.put(`/experiments/${editingProjectId.value}`, payload)
ElMessage.success('实验项目已更新')
} else {
@@ -842,7 +857,10 @@ onMounted(async () => {
@click="publishProject(row)"
>发布</el-button>
</template>
<el-button v-else-if="row.status === 'Published'" size="small" text @click="closeProject(row)">关闭项目</el-button>
<template v-else-if="row.status === 'Published'">
<el-button size="small" text @click="openCorrectPublishedProject(row)">修正内容</el-button>
<el-button size="small" text @click="closeProject(row)">关闭项目</el-button>
</template>
<el-button
v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'"
size="small"
@@ -952,11 +970,17 @@ onMounted(async () => {
<el-dialog
v-model="projectDialog"
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
:title="correctingPublishedProject ? '修正已发布实验项目内容' : editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
width="720px"
top="5vh"
>
<el-form label-position="top" class="experiment-form">
<el-alert
v-if="correctingPublishedProject"
title="已发布项目仅可修正名称、实验内容和到场要求;编码、安排方式、课表绑定及开放日期保持不变。保存后将通知相关学生。"
type="info"
:closable="false"
/>
<div class="form-section">
<header><span>PROJECT</span><b>规定实验项目</b></header>
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
@@ -1027,7 +1051,7 @@ onMounted(async () => {
</el-form-item>
<div class="form-grid two">
<el-form-item label="项目编码" required>
<el-input v-model="projectForm.code" maxlength="40" placeholder="如 LAB-01" />
<el-input v-model="projectForm.code" :disabled="correctingPublishedProject" maxlength="40" placeholder="如 LAB-01" />
</el-form-item>
<el-form-item v-if="editingProjectId" label="项目名称" required>
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
@@ -1058,7 +1082,7 @@ onMounted(async () => {
<div class="form-section">
<header><span>ROUTE</span><b>选择运行轨道</b></header>
<el-radio-group v-model="projectForm.arrangementMode" class="mode-choice" :disabled="!!editingProjectId">
<el-radio-group v-model="projectForm.arrangementMode" class="mode-choice" :disabled="!!editingProjectId || correctingPublishedProject">
<el-radio-button value="Centralized">
<b>集中安排</b><small>复用已发布课表的实验课</small>
</el-radio-button>
@@ -1069,6 +1093,7 @@ onMounted(async () => {
<el-form-item label="项目开放日期" required>
<el-date-picker
v-model="projectForm.dates"
:disabled="correctingPublishedProject"
type="daterange"
value-format="YYYY-MM-DD"
range-separator=""
@@ -1105,7 +1130,9 @@ onMounted(async () => {
<template #footer>
<el-button @click="projectDialog = false">取消</el-button>
<el-button type="primary" @click="saveProject">
{{ editingProjectId
{{ correctingPublishedProject
? '保存修正'
: editingProjectId
? '保存项目'
: projectForm.teachingTaskIds.length
? `创建 ${projectForm.teachingTaskIds.length} 个项目`