开放实验项目发布后修正功能
This commit is contained in:
@@ -666,6 +666,39 @@ public sealed class ExperimentsController(
|
|||||||
return NoContent();
|
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")]
|
[HttpPut("batch/names")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> UpdateProjectNames(
|
public async Task<ActionResult> UpdateProjectNames(
|
||||||
@@ -1442,6 +1475,16 @@ public sealed class ExperimentsController(
|
|||||||
taskIds.Contains(x.TeachingTaskId));
|
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(
|
private Task<Student?> CurrentStudentAsync(
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
db.Students.FirstOrDefaultAsync(x =>
|
db.Students.FirstOrDefaultAsync(x =>
|
||||||
@@ -1492,6 +1535,22 @@ public sealed class ExperimentsController(
|
|||||||
NotificationCategory.Schedule);
|
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)
|
private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
|
||||||
{
|
{
|
||||||
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
|
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
|
||||||
@@ -1569,6 +1628,11 @@ public sealed record ExperimentProjectRequest(
|
|||||||
DateOnly EndDate,
|
DateOnly EndDate,
|
||||||
Guid? ScheduleEntryId = null);
|
Guid? ScheduleEntryId = null);
|
||||||
|
|
||||||
|
public sealed record PublishedExperimentProjectCorrectionRequest(
|
||||||
|
[Required, MaxLength(120)] string Name,
|
||||||
|
[MaxLength(1000)] string? Description,
|
||||||
|
[MaxLength(1000)] string? Requirements);
|
||||||
|
|
||||||
public sealed record ExperimentProjectBatchRequest(
|
public sealed record ExperimentProjectBatchRequest(
|
||||||
[Required] IReadOnlyList<Guid> TeachingTaskIds,
|
[Required] IReadOnlyList<Guid> TeachingTaskIds,
|
||||||
[Required, MaxLength(40)] string Code,
|
[Required, MaxLength(40)] string Code,
|
||||||
|
|||||||
@@ -246,6 +246,35 @@ public sealed class ExperimentsControllerTests
|
|||||||
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
|
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]
|
[Fact]
|
||||||
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
|
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const taskKeyword = ref('')
|
|||||||
|
|
||||||
const projectDialog = ref(false)
|
const projectDialog = ref(false)
|
||||||
const editingProjectId = ref('')
|
const editingProjectId = ref('')
|
||||||
|
const correctingPublishedProject = ref(false)
|
||||||
const projectForm = reactive({
|
const projectForm = reactive({
|
||||||
teachingTaskIds: [] as string[],
|
teachingTaskIds: [] as string[],
|
||||||
scheduleEntryId: '',
|
scheduleEntryId: '',
|
||||||
@@ -158,6 +159,7 @@ const statusLabels: Record<string, string> = {
|
|||||||
|
|
||||||
function resetProjectForm() {
|
function resetProjectForm() {
|
||||||
editingProjectId.value = ''
|
editingProjectId.value = ''
|
||||||
|
correctingPublishedProject.value = false
|
||||||
Object.assign(projectForm, {
|
Object.assign(projectForm, {
|
||||||
teachingTaskIds: [],
|
teachingTaskIds: [],
|
||||||
scheduleEntryId: '',
|
scheduleEntryId: '',
|
||||||
@@ -197,6 +199,7 @@ function openCreateProject() {
|
|||||||
|
|
||||||
function openEditProject(project: any) {
|
function openEditProject(project: any) {
|
||||||
editingProjectId.value = project.id
|
editingProjectId.value = project.id
|
||||||
|
correctingPublishedProject.value = false
|
||||||
Object.assign(projectForm, {
|
Object.assign(projectForm, {
|
||||||
teachingTaskIds: [project.teachingTaskId],
|
teachingTaskIds: [project.teachingTaskId],
|
||||||
scheduleEntryId: project.scheduleEntryId ?? '',
|
scheduleEntryId: project.scheduleEntryId ?? '',
|
||||||
@@ -212,18 +215,23 @@ function openEditProject(project: any) {
|
|||||||
projectDialog.value = true
|
projectDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCorrectPublishedProject(project: any) {
|
||||||
|
openEditProject(project)
|
||||||
|
correctingPublishedProject.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function projectNameLines(value: string) {
|
function projectNameLines(value: string) {
|
||||||
return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean)
|
return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveProject() {
|
async function saveProject() {
|
||||||
const names = projectNameLines(projectForm.projectNames)
|
const names = projectNameLines(projectForm.projectNames)
|
||||||
const hasValidName = editingProjectId.value
|
const hasValidName = correctingPublishedProject.value || editingProjectId.value
|
||||||
? !!projectForm.name.trim()
|
? !!projectForm.name.trim()
|
||||||
: projectForm.nameMode === 'InputNames'
|
: projectForm.nameMode === 'InputNames'
|
||||||
? names.length > 0
|
? names.length > 0
|
||||||
: !!projectForm.name.trim()
|
: !!projectForm.name.trim()
|
||||||
if (!projectForm.teachingTaskIds.length ||
|
if ((!correctingPublishedProject.value && !projectForm.teachingTaskIds.length) ||
|
||||||
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
|
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
|
||||||
!projectForm.code.trim()
|
!projectForm.code.trim()
|
||||||
|| !hasValidName || projectForm.dates.length !== 2) {
|
|| !hasValidName || projectForm.dates.length !== 2) {
|
||||||
@@ -244,7 +252,14 @@ async function saveProject() {
|
|||||||
endDate: projectForm.dates[1],
|
endDate: projectForm.dates[1],
|
||||||
}
|
}
|
||||||
try {
|
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)
|
await http.put(`/experiments/${editingProjectId.value}`, payload)
|
||||||
ElMessage.success('实验项目已更新')
|
ElMessage.success('实验项目已更新')
|
||||||
} else {
|
} else {
|
||||||
@@ -842,7 +857,10 @@ onMounted(async () => {
|
|||||||
@click="publishProject(row)"
|
@click="publishProject(row)"
|
||||||
>发布</el-button>
|
>发布</el-button>
|
||||||
</template>
|
</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
|
<el-button
|
||||||
v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'"
|
v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -952,11 +970,17 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="projectDialog"
|
v-model="projectDialog"
|
||||||
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
:title="correctingPublishedProject ? '修正已发布实验项目内容' : editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
||||||
width="720px"
|
width="720px"
|
||||||
top="5vh"
|
top="5vh"
|
||||||
>
|
>
|
||||||
<el-form label-position="top" class="experiment-form">
|
<el-form label-position="top" class="experiment-form">
|
||||||
|
<el-alert
|
||||||
|
v-if="correctingPublishedProject"
|
||||||
|
title="已发布项目仅可修正名称、实验内容和到场要求;编码、安排方式、课表绑定及开放日期保持不变。保存后将通知相关学生。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<header><span>PROJECT</span><b>规定实验项目</b></header>
|
<header><span>PROJECT</span><b>规定实验项目</b></header>
|
||||||
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
|
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
|
||||||
@@ -1027,7 +1051,7 @@ onMounted(async () => {
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="form-grid two">
|
<div class="form-grid two">
|
||||||
<el-form-item label="项目编码" required>
|
<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>
|
||||||
<el-form-item v-if="editingProjectId" label="项目名称" required>
|
<el-form-item v-if="editingProjectId" label="项目名称" required>
|
||||||
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
|
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
|
||||||
@@ -1058,7 +1082,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<header><span>ROUTE</span><b>选择运行轨道</b></header>
|
<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">
|
<el-radio-button value="Centralized">
|
||||||
<b>集中安排</b><small>复用已发布课表的实验课</small>
|
<b>集中安排</b><small>复用已发布课表的实验课</small>
|
||||||
</el-radio-button>
|
</el-radio-button>
|
||||||
@@ -1069,6 +1093,7 @@ onMounted(async () => {
|
|||||||
<el-form-item label="项目开放日期" required>
|
<el-form-item label="项目开放日期" required>
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="projectForm.dates"
|
v-model="projectForm.dates"
|
||||||
|
:disabled="correctingPublishedProject"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
range-separator="至"
|
range-separator="至"
|
||||||
@@ -1105,7 +1130,9 @@ onMounted(async () => {
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="projectDialog = false">取消</el-button>
|
<el-button @click="projectDialog = false">取消</el-button>
|
||||||
<el-button type="primary" @click="saveProject">
|
<el-button type="primary" @click="saveProject">
|
||||||
{{ editingProjectId
|
{{ correctingPublishedProject
|
||||||
|
? '保存修正'
|
||||||
|
: editingProjectId
|
||||||
? '保存项目'
|
? '保存项目'
|
||||||
: projectForm.teachingTaskIds.length
|
: projectForm.teachingTaskIds.length
|
||||||
? `创建 ${projectForm.teachingTaskIds.length} 个项目`
|
? `创建 ${projectForm.teachingTaskIds.length} 个项目`
|
||||||
|
|||||||
Reference in New Issue
Block a user