同学期、同课程、同教学班的实验项目合并展示,组内连续排列。

管理列表改为数据库筛选、按教学任务服务端分页(10–100 条/页)。
增加开课学院、课程名称/编码、教学班、任课教师、教学任务号筛选。
增加当前页草稿项目的批量选中、批量发布、批量删除;后端会再次校验权限、状态和发布条件。
确认框改为固定居中白色底板与阴影,不再出现只有文字按钮、没有底色的情况。
This commit is contained in:
2026-08-09 19:44:45 +08:00 Unverified
parent 25a1480739
commit 97aa23bc64
12 changed files with 793 additions and 78 deletions
@@ -31,12 +31,21 @@ public sealed class ExperimentsController(
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetOptions(
Guid? academicTermId,
Guid? offeringCollegeId,
string? courseKeyword,
CancellationToken cancellationToken)
{
var tasks = AccessibleTeachingTasks().AsNoTracking()
.Where(x => x.Status == TeachingTaskStatus.Published);
if (academicTermId.HasValue)
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
if (offeringCollegeId.HasValue)
tasks = tasks.Where(x => x.Course!.CollegeId == offeringCollegeId);
var normalizedCourseKeyword = Normalize(courseKeyword);
if (normalizedCourseKeyword is not null)
tasks = tasks.Where(x =>
x.Course!.Code.Contains(normalizedCourseKeyword) ||
x.Course.Name.Contains(normalizedCourseKeyword));
var periods = db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.IsEnabled);
@@ -90,6 +99,13 @@ public sealed class ExperimentsController(
.OrderBy(item => item.AdministrativeClass!.Code)
.Select(item => item.AdministrativeClass!.Name)
})
.Take(200)
.ToListAsync(cancellationToken),
Colleges = await AccessibleTeachingTasks().AsNoTracking()
.Where(x => !academicTermId.HasValue || x.AcademicTermId == academicTermId.Value)
.Select(x => new { x.Course!.CollegeId, CollegeName = x.Course.College!.Name })
.Distinct()
.OrderBy(x => x.CollegeName)
.ToListAsync(cancellationToken),
ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
@@ -99,6 +115,11 @@ public sealed class ExperimentsController(
.Contains(x.TeachingTaskId))
.Where(x => !academicTermId.HasValue ||
x.SchedulePlan!.AcademicTermId == academicTermId.Value)
.Where(x => !offeringCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId == offeringCollegeId.Value)
.Where(x => normalizedCourseKeyword == null ||
x.TeachingTask!.Course!.Code.Contains(normalizedCourseKeyword) ||
x.TeachingTask.Course.Name.Contains(normalizedCourseKeyword))
.OrderBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.ThenBy(x => x.DayOfWeek)
@@ -116,10 +137,14 @@ public sealed class ExperimentsController(
x.StartWeek,
x.EndWeek,
x.WeekPattern,
ClassNames = x.TeachingTask!.Classes
.OrderBy(item => item.AdministrativeClass!.Code)
.Select(item => item.AdministrativeClass!.Name),
ClassroomName = x.Classroom!.Name,
BuildingName = x.Classroom.Building!.Name,
CampusName = x.Classroom.Building.Campus!.Name
})
.Take(500)
.ToListAsync(cancellationToken),
Classrooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
@@ -146,7 +171,14 @@ public sealed class ExperimentsController(
Guid? academicTermId,
ExperimentArrangementMode? arrangementMode,
ExperimentProjectStatus? status,
CancellationToken cancellationToken)
Guid? offeringCollegeId,
string? courseKeyword,
string? classKeyword,
string? teacherKeyword,
string? taskKeyword,
CancellationToken cancellationToken,
int page = 1,
int pageSize = 20)
{
var source = ScopedProjects().AsNoTracking();
if (academicTermId.HasValue)
@@ -158,7 +190,47 @@ public sealed class ExperimentsController(
if (status.HasValue)
source = source.Where(x => x.Status == status);
return Ok(await source
var normalizedCourseKeyword = Normalize(courseKeyword);
var normalizedClassKeyword = Normalize(classKeyword);
var normalizedTeacherKeyword = Normalize(teacherKeyword);
var normalizedTaskKeyword = Normalize(taskKeyword);
var taskSource = AccessibleTeachingTasks().AsNoTracking()
.Where(task => source.Select(project => project.TeachingTaskId).Contains(task.Id));
if (academicTermId.HasValue)
taskSource = taskSource.Where(x => x.AcademicTermId == academicTermId);
if (offeringCollegeId.HasValue)
taskSource = taskSource.Where(x => x.Course!.CollegeId == offeringCollegeId);
if (normalizedCourseKeyword is not null)
taskSource = taskSource.Where(x =>
x.Course!.Code.Contains(normalizedCourseKeyword) ||
x.Course.Name.Contains(normalizedCourseKeyword));
if (normalizedClassKeyword is not null)
taskSource = taskSource.Where(x => x.Classes.Any(item =>
item.AdministrativeClass!.Code.Contains(normalizedClassKeyword) ||
item.AdministrativeClass.Name.Contains(normalizedClassKeyword)));
if (normalizedTeacherKeyword is not null)
taskSource = taskSource.Where(x => x.Teachers.Any(item =>
item.Teacher!.TeacherNumber.Contains(normalizedTeacherKeyword) ||
item.Teacher.Name.Contains(normalizedTeacherKeyword)));
if (normalizedTaskKeyword is not null)
taskSource = taskSource.Where(x =>
x.TaskNumber.Contains(normalizedTaskKeyword) ||
x.Name.Contains(normalizedTaskKeyword));
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var total = await taskSource.CountAsync(cancellationToken);
var taskIds = await taskSource
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.Course!.Code)
.ThenBy(x => x.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var items = await source
.Where(x => taskIds.Contains(x.TeachingTaskId))
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
.ThenBy(x => x.StartDate)
.ThenBy(x => x.Code)
@@ -167,6 +239,7 @@ public sealed class ExperimentsController(
x.Id,
x.TeachingTaskId,
x.ScheduleEntryId,
x.ScheduleWeek,
x.Code,
x.Name,
x.ArrangementMode,
@@ -197,6 +270,7 @@ public sealed class ExperimentsController(
x.ScheduleEntry.StartWeek,
x.ScheduleEntry.EndWeek,
x.ScheduleEntry.WeekPattern,
ProjectWeek = x.ScheduleWeek,
ClassroomName = x.ScheduleEntry.Classroom!.Name,
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
@@ -220,7 +294,9 @@ public sealed class ExperimentsController(
CampusName = item.Classroom.Building.Campus!.Name
})
})
.ToListAsync(cancellationToken));
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
}
[HttpGet("student")]
@@ -254,6 +330,7 @@ public sealed class ExperimentsController(
x.Name,
x.ArrangementMode,
x.ScheduleEntryId,
x.ScheduleWeek,
x.Description,
x.Requirements,
x.StartDate,
@@ -275,6 +352,7 @@ public sealed class ExperimentsController(
x.ScheduleEntry.StartWeek,
x.ScheduleEntry.EndWeek,
x.ScheduleEntry.WeekPattern,
ProjectWeek = x.ScheduleWeek,
ClassroomName = x.ScheduleEntry.Classroom!.Name,
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
@@ -363,8 +441,6 @@ public sealed class ExperimentsController(
ExperimentProjectBatchRequest request,
CancellationToken cancellationToken)
{
if (request.ArrangementMode == ExperimentArrangementMode.Centralized)
return ValidationProblem("集中安排的实验项目请逐项绑定已发布课表中的实验课。");
var taskIds = request.TeachingTaskIds
.Where(x => x != Guid.Empty)
.Distinct()
@@ -384,21 +460,72 @@ public sealed class ExperimentsController(
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
var first = tasks[0];
if (tasks.Any(x =>
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && tasks.Any(x =>
x.AcademicTermId != first.AcademicTermId ||
x.CourseId != first.CourseId))
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
var code = request.Code.Trim();
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId)
.Where(x => x.Code == code)
.Select(x => x.TeachingTask!.TaskNumber)
.OrderBy(x => x)
.ToListAsync(cancellationToken);
if (conflictingTaskNumbers.Count > 0)
return ConflictProblem(
$"以下教学任务已存在实验项目编码 {code}{string.Join("", conflictingTaskNumbers)}。");
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
{
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId)
.Where(x => x.Code == code)
.Select(x => x.TeachingTask!.TaskNumber)
.OrderBy(x => x)
.ToListAsync(cancellationToken);
if (conflictingTaskNumbers.Count > 0)
return ConflictProblem(
$"以下教学任务已存在实验项目编码 {code}{string.Join("", conflictingTaskNumbers)}。");
}
var scheduledEntries = request.ArrangementMode == ExperimentArrangementMode.Centralized
? await db.ScheduleEntries.AsNoTracking()
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
x.Kind == ScheduleEntryKind.Experiment &&
x.ClassroomId.HasValue &&
taskIds.Contains(x.TeachingTaskId))
.OrderBy(x => x.TeachingTaskId)
.ThenBy(x => x.DayOfWeek)
.ThenBy(x => x.StartPeriod)
.ToListAsync(cancellationToken)
: [];
if (request.ArrangementMode == ExperimentArrangementMode.Centralized &&
tasks.Any(task => scheduledEntries.All(entry => entry.TeachingTaskId != task.Id)))
return ValidationProblem("所选教学班中包含未排入实验室的已发布实验课,请先完成课表安排。");
var scheduledOccurrences = scheduledEntries
.SelectMany(entry => Enumerable.Range(
entry.StartWeek,
entry.EndWeek - entry.StartWeek + 1)
.Where(week => FreeClassroomRules.MatchesWeek(entry.WeekPattern, week))
.Select(week => (Entry: entry, Week: week)))
.ToList();
var legacyProjects = scheduledEntries.Count > 0
? await db.ExperimentProjects
.Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
x.ScheduleWeek == null &&
taskIds.Contains(x.TeachingTaskId))
.ToListAsync(cancellationToken)
: [];
if (legacyProjects.Any(x => x.Status != ExperimentProjectStatus.Draft))
return ConflictProblem("存在旧版已发布实验项目,不能自动拆分为每周项目;请先关闭后重新设置。");
if (scheduledOccurrences.Count > 0)
{
var entryIds = scheduledOccurrences.Select(x => x.Entry.Id).Distinct().ToList();
var existingOccurrences = await db.ExperimentProjects.AsNoTracking()
.Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
x.ScheduleWeek.HasValue && entryIds.Contains(x.ScheduleEntryId.Value))
.Select(x => new { ScheduleEntryId = x.ScheduleEntryId!.Value, ScheduleWeek = x.ScheduleWeek!.Value })
.ToListAsync(cancellationToken);
var existingKeys = existingOccurrences
.Select(x => (x.ScheduleEntryId, x.ScheduleWeek))
.ToHashSet();
if (scheduledOccurrences.Any(x => existingKeys.Contains((x.Entry.Id, x.Week))))
return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。");
}
var projects = new List<ExperimentProject>(tasks.Count);
foreach (var task in tasks)
@@ -407,17 +534,42 @@ public sealed class ExperimentsController(
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
if (problem is not null) return ValidationProblem(problem);
projects.Add(new ExperimentProject
IEnumerable<(ScheduleEntry? Entry, int? Week)> taskOccurrences =
request.ArrangementMode == ExperimentArrangementMode.Centralized
? scheduledOccurrences
.Where(item => item.Entry.TeachingTaskId == task.Id)
.Select(item => ((ScheduleEntry?)item.Entry, (int?)item.Week))
: [(null, null)];
foreach (var occurrence in taskOccurrences)
{
TeachingTaskId = task.Id,
Code = code,
Name = request.Name.Trim(),
ArrangementMode = request.ArrangementMode,
Description = Normalize(request.Description),
Requirements = Normalize(request.Requirements),
StartDate = request.StartDate,
EndDate = request.EndDate
});
var scheduleEntry = occurrence.Entry;
var legacy = scheduleEntry is null ? null : legacyProjects
.SingleOrDefault(x => x.ScheduleEntryId == scheduleEntry.Id);
if (legacy is not null)
{
var firstWeek = scheduledOccurrences
.Where(item => item.Entry.Id == scheduleEntry!.Id)
.Min(item => item.Week);
if (occurrence.Week == firstWeek)
{
legacy.ScheduleWeek = occurrence.Week;
continue;
}
}
projects.Add(new ExperimentProject
{
TeachingTaskId = task.Id,
ScheduleEntryId = occurrence.Entry?.Id,
ScheduleWeek = occurrence.Week,
Code = code,
Name = request.Name.Trim(),
ArrangementMode = request.ArrangementMode,
Description = Normalize(request.Description),
Requirements = Normalize(request.Requirements),
StartDate = request.StartDate,
EndDate = request.EndDate
});
}
}
db.ExperimentProjects.AddRange(projects);
@@ -491,6 +643,26 @@ public sealed class ExperimentsController(
return NoContent();
}
[HttpDelete("batch")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> DeleteProjects(
ExperimentProjectBulkRequest request,
CancellationToken cancellationToken)
{
var ids = ValidateBulkProjectIds(request.ProjectIds);
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
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("批量删除只能包含草稿实验项目。");
db.ExperimentProjects.RemoveRange(projects);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> PublishProject(
@@ -522,28 +694,49 @@ public sealed class ExperimentsController(
project.Status = ExperimentProjectStatus.Published;
project.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await NotifyProjectPublishedAsync(project, cancellationToken);
return NoContent();
}
var userIds = await TeachingTaskRosterQuery
.ForTask(db, project.TeachingTaskId)
.Where(x => x.UserId.HasValue)
.Select(x => x.UserId!.Value)
.Distinct()
[HttpPost("batch/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> PublishProjects(
ExperimentProjectBulkRequest request,
CancellationToken cancellationToken)
{
var ids = ValidateBulkProjectIds(request.ProjectIds);
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
var projects = await ScopedProjects()
.Include(x => x.Sessions)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.Where(x => ids.Contains(x.Id))
.ToListAsync(cancellationToken);
if (userIds.Count > 0)
if (projects.Count != ids.Count) return NotFound();
foreach (var project in projects)
{
var mode = project.ArrangementMode ==
ExperimentArrangementMode.Centralized
? "集中安排"
: "自行预约";
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验项目已发布",
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
if (project.Status != ExperimentProjectStatus.Draft)
return ConflictProblem("批量发布只能包含草稿实验项目。");
var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized
? project.ScheduleEntryId.HasValue || project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled)
: project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled);
if (!hasSchedule)
return ConflictProblem($"“{project.Name}”尚未具备发布条件。");
if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
(x.SessionDate < project.StartDate || x.SessionDate > project.EndDate)))
return ConflictProblem($"“{project.Name}”存在不在开放日期范围内的实验场次。");
}
var publishedAt = DateTime.UtcNow;
foreach (var project in projects)
{
project.Status = ExperimentProjectStatus.Published;
project.PublishedAt = publishedAt;
}
await db.SaveChangesAsync(cancellationToken);
foreach (var project in projects)
await NotifyProjectPublishedAsync(project, cancellationToken);
return NoContent();
}
@@ -1234,6 +1427,31 @@ public sealed class ExperimentsController(
.Distinct()
.ToListAsync(cancellationToken);
private async Task NotifyProjectPublishedAsync(
ExperimentProject project,
CancellationToken cancellationToken)
{
var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
if (userIds.Count == 0) return;
var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized
? "集中安排"
: "自行预约";
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验项目已发布",
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
{
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
return ids is { Count: > 0 and <= 100 } ? ids : null;
}
private static string? ValidateProjectRequest(
ExperimentProjectRequest request,
AcademicTerm term)
@@ -1321,6 +1539,9 @@ public sealed record ExperimentProjectBatchRequest(
EndDate);
}
public sealed record ExperimentProjectBulkRequest(
[Required] IReadOnlyList<Guid> ProjectIds);
public sealed record ExperimentSessionRequest(
Guid ClassroomId,
DateOnly SessionDate,