同学期、同课程、同教学班的实验项目合并展示,组内连续排列。
管理列表改为数据库筛选、按教学任务服务端分页(10–100 条/页)。 增加开课学院、课程名称/编码、教学班、任课教师、教学任务号筛选。 增加当前页草稿项目的批量选中、批量发布、批量删除;后端会再次校验权限、状态和发布条件。 确认框改为固定居中白色底板与阴影,不再出现只有文字按钮、没有底色的情况。
This commit is contained in:
@@ -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,12 +460,14 @@ 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();
|
||||
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
|
||||
{
|
||||
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Where(x => x.Code == code)
|
||||
@@ -399,6 +477,55 @@ public sealed class ExperimentsController(
|
||||
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,9 +534,33 @@ public sealed class ExperimentsController(
|
||||
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
|
||||
if (problem is not null) return ValidationProblem(problem);
|
||||
|
||||
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)
|
||||
{
|
||||
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,
|
||||
@@ -419,6 +570,7 @@ public sealed class ExperimentsController(
|
||||
EndDate = request.EndDate
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
db.ExperimentProjects.AddRange(projects);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -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);
|
||||
|
||||
var userIds = await TeachingTaskRosterQuery
|
||||
.ForTask(db, project.TeachingTaskId)
|
||||
.Where(x => x.UserId.HasValue)
|
||||
.Select(x => x.UserId!.Value)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
if (userIds.Count > 0)
|
||||
{
|
||||
var mode = project.ArrangementMode ==
|
||||
ExperimentArrangementMode.Centralized
|
||||
? "集中安排"
|
||||
: "自行预约";
|
||||
await NotificationService.SendToUserIdsAsync(
|
||||
db,
|
||||
userIds,
|
||||
"实验项目已发布",
|
||||
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
|
||||
"/experiments",
|
||||
cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
await NotifyProjectPublishedAsync(project, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[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 (projects.Count != ids.Count) return NotFound();
|
||||
|
||||
foreach (var project in projects)
|
||||
{
|
||||
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,
|
||||
|
||||
@@ -9,6 +9,8 @@ public sealed class ExperimentProject : EntityBase
|
||||
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
|
||||
public Guid? ScheduleEntryId { get; set; }
|
||||
public ScheduleEntry? ScheduleEntry { get; set; }
|
||||
// 集中安排按课表的具体周次拆分为实验项目;自行安排为空。
|
||||
public int? ScheduleWeek { get; set; }
|
||||
public required string Code { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public ExperimentArrangementMode ArrangementMode { get; set; }
|
||||
|
||||
@@ -678,7 +678,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
entity.Property(x => x.Description).HasMaxLength(1000);
|
||||
entity.Property(x => x.Requirements).HasMaxLength(1000);
|
||||
entity.HasIndex(x => new { x.TeachingTaskId, x.Code }).IsUnique();
|
||||
// 集中安排会为同一教学任务的每一条实验课表记录生成项目;
|
||||
// 自行安排仍由控制器保持“教学任务 + 编码”唯一。
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.TeachingTaskId,
|
||||
x.Code,
|
||||
x.ScheduleEntryId,
|
||||
x.ScheduleWeek
|
||||
})
|
||||
.IsUnique();
|
||||
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
|
||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||
.HasForeignKey(x => x.TeachingTaskId)
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// <auto-generated />
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260809112000_AllowAllScheduledExperimentLessons")]
|
||||
partial class AllowAllScheduledExperimentLessons
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||
|
||||
public partial class AllowAllScheduledExperimentLessons : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// MySQL 可能将旧唯一索引用于外键支撑。先提供同列的普通索引,
|
||||
// 再替换业务唯一索引,避免线上迁移因外键依赖而中断。
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentProjects_TeachingTaskId_Code_FkSupport",
|
||||
table: "ExperimentProjects",
|
||||
columns: new[] { "TeachingTaskId", "Code" });
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ExperimentProjects_TeachingTaskId_Code",
|
||||
table: "ExperimentProjects");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId",
|
||||
table: "ExperimentProjects",
|
||||
columns: new[] { "TeachingTaskId", "Code", "ScheduleEntryId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId",
|
||||
table: "ExperimentProjects");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentProjects_TeachingTaskId_Code",
|
||||
table: "ExperimentProjects",
|
||||
columns: new[] { "TeachingTaskId", "Code" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ExperimentProjects_TeachingTaskId_Code_FkSupport",
|
||||
table: "ExperimentProjects");
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// <auto-generated />
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260809114000_SplitCentralizedExperimentProjectsByWeek")]
|
||||
partial class SplitCentralizedExperimentProjectsByWeek
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||
|
||||
public partial class SplitCentralizedExperimentProjectsByWeek : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ExecuteWhenMissing(
|
||||
migrationBuilder,
|
||||
"COLUMNS",
|
||||
"COLUMN_NAME = 'ScheduleWeek'",
|
||||
"ALTER TABLE `ExperimentProjects` ADD COLUMN `ScheduleWeek` int NULL");
|
||||
|
||||
ExecuteWhenPresent(
|
||||
migrationBuilder,
|
||||
"STATISTICS",
|
||||
"INDEX_NAME = 'IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId'",
|
||||
"DROP INDEX `IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId` ON `ExperimentProjects`");
|
||||
|
||||
ExecuteWhenMissing(
|
||||
migrationBuilder,
|
||||
"STATISTICS",
|
||||
"INDEX_NAME = 'IX_ExpProj_Task_Code_Entry_Week'",
|
||||
"CREATE UNIQUE INDEX `IX_ExpProj_Task_Code_Entry_Week` ON `ExperimentProjects` (`TeachingTaskId`, `Code`, `ScheduleEntryId`, `ScheduleWeek`)");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ExpProj_Task_Code_Entry_Week",
|
||||
table: "ExperimentProjects");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId",
|
||||
table: "ExperimentProjects",
|
||||
columns: new[] { "TeachingTaskId", "Code", "ScheduleEntryId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ScheduleWeek",
|
||||
table: "ExperimentProjects");
|
||||
}
|
||||
|
||||
private static void ExecuteWhenMissing(
|
||||
MigrationBuilder migrationBuilder,
|
||||
string informationSchemaTable,
|
||||
string condition,
|
||||
string command)
|
||||
{
|
||||
ExecuteConditionally(migrationBuilder, informationSchemaTable, condition, command, "= 0");
|
||||
}
|
||||
|
||||
private static void ExecuteWhenPresent(
|
||||
MigrationBuilder migrationBuilder,
|
||||
string informationSchemaTable,
|
||||
string condition,
|
||||
string command)
|
||||
{
|
||||
ExecuteConditionally(migrationBuilder, informationSchemaTable, condition, command, "> 0");
|
||||
}
|
||||
|
||||
private static void ExecuteConditionally(
|
||||
MigrationBuilder migrationBuilder,
|
||||
string informationSchemaTable,
|
||||
string condition,
|
||||
string command,
|
||||
string comparison)
|
||||
{
|
||||
migrationBuilder.Sql($"SET @jiaowu_exists = (SELECT COUNT(*) FROM `information_schema`.`{informationSchemaTable}` WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'ExperimentProjects' AND {condition})");
|
||||
migrationBuilder.Sql($"SET @jiaowu_sql = IF(@jiaowu_exists {comparison}, '{command}', 'SELECT 1')");
|
||||
migrationBuilder.Sql("PREPARE jiaowu_migration_statement FROM @jiaowu_sql");
|
||||
migrationBuilder.Sql("EXECUTE jiaowu_migration_statement");
|
||||
migrationBuilder.Sql("DEALLOCATE PREPARE jiaowu_migration_statement");
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -2432,6 +2432,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<Guid?>("ScheduleEntryId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int?>("ScheduleWeek")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
@@ -2448,7 +2451,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.HasIndex("ScheduleEntryId");
|
||||
|
||||
b.HasIndex("TeachingTaskId", "Code")
|
||||
b.HasIndex("TeachingTaskId", "Code", "ScheduleEntryId", "ScheduleWeek")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status", "StartDate", "EndDate");
|
||||
|
||||
@@ -37,6 +37,53 @@ public sealed class ExperimentsControllerTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralizedBatchProjects_CreatesOneProjectForEveryScheduledExperimentLesson()
|
||||
{
|
||||
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||
fixture.Db.ScheduleEntries.Add(new ScheduleEntry
|
||||
{
|
||||
SchedulePlanId = fixture.ScheduleEntry.SchedulePlanId,
|
||||
TeachingTaskId = fixture.Task.Id,
|
||||
Kind = ScheduleEntryKind.Experiment,
|
||||
ClassroomId = fixture.SecondClassroom.Id,
|
||||
DayOfWeek = 3,
|
||||
StartPeriod = 5,
|
||||
PeriodCount = 2,
|
||||
StartWeek = 1,
|
||||
EndWeek = 8,
|
||||
WeekPattern = WeekPattern.All
|
||||
});
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var result = await fixture.Controller(fixture.ManagerScope).CreateProjects(
|
||||
new ExperimentProjectBatchRequest(
|
||||
[fixture.Task.Id],
|
||||
"LAB-ALL",
|
||||
"全部课表实验",
|
||||
ExperimentArrangementMode.Centralized,
|
||||
null,
|
||||
null,
|
||||
fixture.Term.StartDate,
|
||||
fixture.Term.StartDate.AddDays(14)),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<CreatedResult>(result);
|
||||
var projects = await fixture.Db.ExperimentProjects
|
||||
.Where(x => x.Code == "LAB-ALL")
|
||||
.OrderBy(x => x.ScheduleEntryId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(16, projects.Count);
|
||||
Assert.All(projects, project =>
|
||||
{
|
||||
Assert.Equal(fixture.Task.Id, project.TeachingTaskId);
|
||||
Assert.Equal(ExperimentArrangementMode.Centralized, project.ArrangementMode);
|
||||
Assert.NotNull(project.ScheduleEntryId);
|
||||
});
|
||||
Assert.Equal(2, projects.Select(x => x.ScheduleEntryId).Distinct().Count());
|
||||
Assert.Equal(8, projects.Select(x => x.ScheduleWeek).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
|
||||
{
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<JiaowuBackendVersion>2.3.2-beta.4</JiaowuBackendVersion>
|
||||
<JiaowuFrontendVersion>2.3.2-beta.4</JiaowuFrontendVersion>
|
||||
<JiaowuSwaggerVersion>2.3.2-beta.4</JiaowuSwaggerVersion>
|
||||
<JiaowuBackendVersion>2.3.2-beta.5</JiaowuBackendVersion>
|
||||
<JiaowuFrontendVersion>2.3.2-beta.5</JiaowuFrontendVersion>
|
||||
<JiaowuSwaggerVersion>2.3.2-beta.5</JiaowuSwaggerVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -343,6 +343,29 @@ button { cursor: pointer; }
|
||||
.form-grid.compact { align-items: center; }
|
||||
.form-grid.three { grid-template-columns: repeat(3, 1fr); }
|
||||
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
/* MessageBox 的定位与底色不能依赖其伪元素:部分浏览器在遮罩层中会将其压到左上角。 */
|
||||
.el-overlay.is-message-box {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.el-overlay.is-message-box .el-overlay-message-box {
|
||||
display: block !important;
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
min-height: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.el-overlay.is-message-box .el-overlay-message-box::after { display: none !important; }
|
||||
.el-overlay.is-message-box .el-message-box {
|
||||
width: 100%;
|
||||
margin: 0 !important;
|
||||
color: var(--ink);
|
||||
background: #fff !important;
|
||||
box-shadow: 0 18px 48px rgba(18, 37, 63, .28);
|
||||
}
|
||||
.password-reset-form { margin-top: 18px; }
|
||||
|
||||
.registry-switch { display: grid; grid-template-columns: 1fr 1fr 180px; min-height: 112px; border: 1px solid var(--line); background: white; }
|
||||
|
||||
@@ -16,14 +16,24 @@ const loading = ref(false)
|
||||
const terms = ref<any[]>([])
|
||||
const termId = ref('')
|
||||
const projects = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const selectedProjectIds = ref<string[]>([])
|
||||
const options = reactive({
|
||||
tasks: [] as any[],
|
||||
colleges: [] as any[],
|
||||
scheduleEntries: [] as any[],
|
||||
classrooms: [] as any[],
|
||||
periods: [] as any[],
|
||||
})
|
||||
const modeFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
const offeringCollegeId = ref('')
|
||||
const courseKeyword = ref('')
|
||||
const classKeyword = ref('')
|
||||
const teacherKeyword = ref('')
|
||||
const taskKeyword = ref('')
|
||||
|
||||
const projectDialog = ref(false)
|
||||
const editingProjectId = ref('')
|
||||
@@ -68,6 +78,51 @@ const batchProjectOptions = computed(() =>
|
||||
projects.value.filter((project) =>
|
||||
project.status !== 'Closed' && project.arrangementMode === 'SelfScheduled'),
|
||||
)
|
||||
const taskGroups = computed(() => {
|
||||
const groups = new Map<string, any>()
|
||||
options.tasks.forEach((task) => {
|
||||
const key = `${task.academicTermId}-${task.courseId}`
|
||||
const group = groups.get(key) ?? {
|
||||
key,
|
||||
termName: task.termName,
|
||||
courseCode: task.courseCode,
|
||||
courseName: task.courseName,
|
||||
tasks: [],
|
||||
}
|
||||
group.tasks.push(task)
|
||||
groups.set(key, group)
|
||||
})
|
||||
return [...groups.values()]
|
||||
})
|
||||
const projectGroups = computed(() => {
|
||||
const groups = new Map<string, any>()
|
||||
projects.value.forEach((project) => {
|
||||
const classKey = [...(project.classNames ?? [])].sort().join('|')
|
||||
const key = `${project.academicTermId}-${project.courseCode}-${classKey}`
|
||||
const group = groups.get(key) ?? {
|
||||
key,
|
||||
termName: project.termName,
|
||||
courseCode: project.courseCode,
|
||||
courseName: project.courseName,
|
||||
classNames: project.classNames,
|
||||
teacherNames: project.teacherNames,
|
||||
taskNumbers: [],
|
||||
projects: [],
|
||||
}
|
||||
if (!group.taskNumbers.includes(project.taskNumber)) group.taskNumbers.push(project.taskNumber)
|
||||
group.projects.push(project)
|
||||
groups.set(key, group)
|
||||
})
|
||||
return [...groups.values()]
|
||||
})
|
||||
const draftSelectedProjectIds = computed(() =>
|
||||
selectedProjectIds.value.filter((id) =>
|
||||
projects.value.some((project) => project.id === id && project.status === 'Draft')),
|
||||
)
|
||||
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))
|
||||
})
|
||||
const activePeriods = computed(() =>
|
||||
options.periods.filter((period) =>
|
||||
!selectedProject.value
|
||||
@@ -119,6 +174,11 @@ function onScheduleEntryChange() {
|
||||
onTaskChange()
|
||||
}
|
||||
|
||||
function formatScheduleEntry(entry: any) {
|
||||
const classes = entry.classNames?.join('、') || '选课学生'
|
||||
return `${entry.courseCode} · ${entry.courseName} · ${entry.taskNumber} · ${classes} · 星期 ${entry.dayOfWeek} 第 ${entry.startPeriod}–${entry.startPeriod + entry.periodCount - 1} 节 · ${entry.campusName} ${entry.buildingName} ${entry.classroomName}`
|
||||
}
|
||||
|
||||
function openCreateProject() {
|
||||
resetProjectForm()
|
||||
projectDialog.value = true
|
||||
@@ -141,10 +201,10 @@ function openEditProject(project: any) {
|
||||
|
||||
async function saveProject() {
|
||||
if (!projectForm.teachingTaskIds.length ||
|
||||
(projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
|
||||
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
|
||||
!projectForm.code.trim()
|
||||
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
|
||||
ElMessage.warning(projectForm.arrangementMode === 'Centralized'
|
||||
ElMessage.warning(projectForm.arrangementMode === 'Centralized' && editingProjectId.value
|
||||
? '请选择课表实验课,并填写项目编码、名称和开放日期'
|
||||
: '请填写教学任务、项目编码、名称和开放日期')
|
||||
return
|
||||
@@ -164,16 +224,15 @@ async function saveProject() {
|
||||
if (editingProjectId.value) {
|
||||
await http.put(`/experiments/${editingProjectId.value}`, payload)
|
||||
ElMessage.success('实验项目已更新')
|
||||
} else if (projectForm.arrangementMode === 'Centralized') {
|
||||
await http.post('/experiments', payload)
|
||||
ElMessage.success('实验项目已绑定课表实验课')
|
||||
} else {
|
||||
await http.post('/experiments/batch', {
|
||||
...payload,
|
||||
teachingTaskId: undefined,
|
||||
teachingTaskIds: projectForm.teachingTaskIds,
|
||||
})
|
||||
ElMessage.success(`已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
|
||||
ElMessage.success(projectForm.arrangementMode === 'Centralized'
|
||||
? `已按 ${projectForm.teachingTaskIds.length} 个教学班的全部实验课生成项目`
|
||||
: `已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
|
||||
}
|
||||
projectDialog.value = false
|
||||
await load()
|
||||
@@ -336,6 +395,57 @@ async function deleteProject(project: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function togglePageDraftSelection(value: unknown) {
|
||||
const ids = projects.value.filter((project) => project.status === 'Draft').map((project) => project.id)
|
||||
selectedProjectIds.value = Boolean(value)
|
||||
? [...new Set([...selectedProjectIds.value, ...ids])]
|
||||
: selectedProjectIds.value.filter((id) => !ids.includes(id))
|
||||
}
|
||||
|
||||
function toggleProjectSelection(projectId: string, value: unknown) {
|
||||
selectedProjectIds.value = Boolean(value)
|
||||
? [...new Set([...selectedProjectIds.value, projectId])]
|
||||
: selectedProjectIds.value.filter((id) => id !== projectId)
|
||||
}
|
||||
|
||||
async function publishSelectedProjects() {
|
||||
const ids = draftSelectedProjectIds.value
|
||||
if (!ids.length) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将发布选中的 ${ids.length} 个草稿实验项目,并通知对应学生。`,
|
||||
'批量发布实验项目',
|
||||
{ confirmButtonText: '确认发布', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
await http.post('/experiments/batch/publish', { projectIds: ids })
|
||||
ElMessage.success(`已发布 ${ids.length} 个实验项目`)
|
||||
selectedProjectIds.value = []
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelectedProjects() {
|
||||
const ids = draftSelectedProjectIds.value
|
||||
if (!ids.length) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将删除选中的 ${ids.length} 个草稿实验项目及其未发布场次。`,
|
||||
'批量删除实验项目',
|
||||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'error' },
|
||||
)
|
||||
await http.delete('/experiments/batch', { data: { projectIds: ids } })
|
||||
ElMessage.success(`已删除 ${ids.length} 个实验项目`)
|
||||
selectedProjectIds.value = []
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSession(project: any, session: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
@@ -424,14 +534,20 @@ async function loadOptions() {
|
||||
if (isStudent.value) return
|
||||
try {
|
||||
const { data } = await http.get('/experiments/options', {
|
||||
params: { academicTermId: termId.value || undefined },
|
||||
params: {
|
||||
academicTermId: termId.value || undefined,
|
||||
offeringCollegeId: offeringCollegeId.value || undefined,
|
||||
courseKeyword: courseKeyword.value.trim() || undefined,
|
||||
},
|
||||
})
|
||||
options.tasks = data.tasks
|
||||
options.colleges = data.colleges
|
||||
options.scheduleEntries = data.scheduleEntries
|
||||
options.classrooms = data.classrooms
|
||||
options.periods = data.periods
|
||||
} catch (error) {
|
||||
options.tasks = []
|
||||
options.colleges = []
|
||||
options.classrooms = []
|
||||
options.periods = []
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
@@ -445,17 +561,28 @@ async function load() {
|
||||
projects.value = (await http.get('/experiments/student', {
|
||||
params: { academicTermId: termId.value || undefined },
|
||||
})).data
|
||||
total.value = projects.value.length
|
||||
} else {
|
||||
projects.value = (await http.get('/experiments/management', {
|
||||
const { data } = await http.get('/experiments/management', {
|
||||
params: {
|
||||
academicTermId: termId.value || undefined,
|
||||
arrangementMode: modeFilter.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
offeringCollegeId: offeringCollegeId.value || undefined,
|
||||
courseKeyword: courseKeyword.value.trim() || undefined,
|
||||
classKeyword: classKeyword.value.trim() || undefined,
|
||||
teacherKeyword: teacherKeyword.value.trim() || undefined,
|
||||
taskKeyword: taskKeyword.value.trim() || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
},
|
||||
})).data
|
||||
})
|
||||
projects.value = data.items
|
||||
total.value = data.total
|
||||
}
|
||||
} catch (error) {
|
||||
projects.value = []
|
||||
total.value = 0
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -463,10 +590,30 @@ async function load() {
|
||||
}
|
||||
|
||||
async function changeTerm() {
|
||||
page.value = 1
|
||||
selectedProjectIds.value = []
|
||||
await loadOptions()
|
||||
await load()
|
||||
}
|
||||
|
||||
async function searchTaskOptions() {
|
||||
page.value = 1
|
||||
selectedProjectIds.value = []
|
||||
await loadOptions()
|
||||
await load()
|
||||
}
|
||||
|
||||
async function changePage(nextPage: number) {
|
||||
page.value = nextPage
|
||||
selectedProjectIds.value = []
|
||||
await load()
|
||||
}
|
||||
|
||||
async function changePageSize(size: number) {
|
||||
pageSize.value = size
|
||||
await changePage(1)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
terms.value = (await http.get('/base-data/terms')).data
|
||||
@@ -524,6 +671,13 @@ onMounted(async () => {
|
||||
/>
|
||||
</el-select>
|
||||
<template v-if="!isStudent">
|
||||
<el-select v-model="offeringCollegeId" clearable filterable placeholder="开课学院" @change="searchTaskOptions">
|
||||
<el-option v-for="college in options.colleges" :key="college.collegeId" :label="college.collegeName" :value="college.collegeId" />
|
||||
</el-select>
|
||||
<el-input v-model="courseKeyword" clearable placeholder="课程名称或编码" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
|
||||
<el-input v-model="classKeyword" clearable placeholder="教学班" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
|
||||
<el-input v-model="teacherKeyword" clearable placeholder="任课教师" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
|
||||
<el-input v-model="taskKeyword" clearable placeholder="教学任务号" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
|
||||
<el-select v-model="modeFilter" clearable placeholder="全部安排方式" @change="load">
|
||||
<el-option label="集中安排" value="Centralized" />
|
||||
<el-option label="自行安排" value="SelfScheduled" />
|
||||
@@ -534,12 +688,33 @@ onMounted(async () => {
|
||||
<el-option label="已关闭" value="Closed" />
|
||||
</el-select>
|
||||
</template>
|
||||
<span class="result-note">共 {{ projects.length }} 个实验项目</span>
|
||||
<span class="result-note">共 {{ total }} 个教学班课程</span>
|
||||
</section>
|
||||
|
||||
<section v-if="!isStudent" class="experiment-batch-bar">
|
||||
<el-checkbox :model-value="allPageDraftSelected" :indeterminate="!!draftSelectedProjectIds.length && !allPageDraftSelected" @change="togglePageDraftSelection">
|
||||
选中当前页全部草稿
|
||||
</el-checkbox>
|
||||
<span>已选 {{ draftSelectedProjectIds.length }} 个草稿项目</span>
|
||||
<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>
|
||||
|
||||
<section v-loading="loading" class="project-list">
|
||||
<section v-for="group in projectGroups" :key="group.key" class="project-group">
|
||||
<header class="project-group-head">
|
||||
<div>
|
||||
<span>{{ group.termName }}</span>
|
||||
<h3>{{ group.courseCode }} · {{ group.courseName }}</h3>
|
||||
</div>
|
||||
<div class="project-group-task">
|
||||
<b>{{ group.taskNumbers.join('、') }}</b>
|
||||
<span>{{ group.classNames?.join('、') || '选课学生' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="project-group-projects">
|
||||
<article
|
||||
v-for="project in projects"
|
||||
v-for="project in group.projects"
|
||||
:key="project.id"
|
||||
class="project-card"
|
||||
:class="[
|
||||
@@ -551,9 +726,15 @@ onMounted(async () => {
|
||||
<div class="project-identity">
|
||||
<span class="project-code">{{ project.courseCode }} · {{ project.code }}</span>
|
||||
<h3>{{ project.name }}</h3>
|
||||
<p>{{ project.courseName }} · {{ project.taskNumber }}</p>
|
||||
<p>{{ project.arrangementMode === 'Centralized' ? '统一到场实验' : '开放预约实验' }}</p>
|
||||
</div>
|
||||
<div class="project-tags">
|
||||
<el-checkbox
|
||||
v-if="!isStudent && project.status === 'Draft'"
|
||||
:model-value="selectedProjectIds.includes(project.id)"
|
||||
@change="toggleProjectSelection(project.id, $event)"
|
||||
aria-label="选择实验项目"
|
||||
/>
|
||||
<el-tag
|
||||
:type="project.arrangementMode === 'Centralized' ? 'primary' : 'success'"
|
||||
effect="plain"
|
||||
@@ -600,8 +781,8 @@ onMounted(async () => {
|
||||
<article class="session-ticket">
|
||||
<div class="ticket-date"><strong>课表</strong><span>固定</span></div>
|
||||
<div class="ticket-body">
|
||||
<b>星期 {{ project.scheduleEntry.dayOfWeek }} · 第 {{ project.scheduleEntry.startPeriod }}–{{ project.scheduleEntry.startPeriod + project.scheduleEntry.periodCount - 1 }} 节</b>
|
||||
<span>第 {{ project.scheduleEntry.startWeek }}–{{ project.scheduleEntry.endWeek }} 周 · {{ project.scheduleEntry.campusName }} · {{ project.scheduleEntry.buildingName }} {{ project.scheduleEntry.classroomName }}</span>
|
||||
<b>第 {{ project.scheduleEntry.projectWeek ?? `${project.scheduleEntry.startWeek}–${project.scheduleEntry.endWeek}` }} 周 · 星期 {{ project.scheduleEntry.dayOfWeek }} · 第 {{ project.scheduleEntry.startPeriod }}–{{ project.scheduleEntry.startPeriod + project.scheduleEntry.periodCount - 1 }} 节</b>
|
||||
<span>{{ project.scheduleEntry.campusName }} · {{ project.scheduleEntry.buildingName }} {{ project.scheduleEntry.classroomName }}</span>
|
||||
</div>
|
||||
<div class="ticket-action"><el-tag type="primary" size="small" effect="plain">课表已安排</el-tag></div>
|
||||
</article>
|
||||
@@ -736,6 +917,8 @@ onMounted(async () => {
|
||||
</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-empty
|
||||
v-if="!loading && !projects.length"
|
||||
@@ -747,6 +930,19 @@ onMounted(async () => {
|
||||
</el-empty>
|
||||
</section>
|
||||
|
||||
<el-pagination
|
||||
v-if="!isStudent && total > pageSize"
|
||||
class="experiment-pagination"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="changePage"
|
||||
@size-change="changePageSize"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
v-model="projectDialog"
|
||||
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
||||
@@ -756,26 +952,24 @@ onMounted(async () => {
|
||||
<el-form label-position="top" class="experiment-form">
|
||||
<div class="form-section">
|
||||
<header><span>PROJECT</span><b>规定实验项目</b></header>
|
||||
<el-form-item v-if="projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
|
||||
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
|
||||
<el-select v-model="projectForm.scheduleEntryId" filterable :disabled="!!editingProjectId" placeholder="选择已安排实验室的实验课" @change="onScheduleEntryChange">
|
||||
<el-option
|
||||
v-for="entry in options.scheduleEntries"
|
||||
:key="entry.id"
|
||||
:label="`${entry.courseCode} · ${entry.courseName} · ${entry.taskNumber} · 星期 ${entry.dayOfWeek} 第 ${entry.startPeriod}–${entry.startPeriod + entry.periodCount - 1} 节 · ${entry.campusName} ${entry.buildingName} ${entry.classroomName}`"
|
||||
:label="formatScheduleEntry(entry)"
|
||||
:value="entry.id"
|
||||
/>
|
||||
</el-select>
|
||||
<small class="form-help">集中实验复用课表的时间和实验室,不会再生成独立实验场次。</small>
|
||||
<small class="form-help">显示当前管理范围内全部已发布、已安排实验室的实验课;集中实验复用其时间和实验室,不会再生成独立实验场次。</small>
|
||||
</el-form-item>
|
||||
<el-form-item v-else :label="editingProjectId ? '所属教学任务' : '适用教学任务(可多选)'" required>
|
||||
<el-form-item v-else :label="editingProjectId ? '所属教学任务' : '适用教学班(可多选)'" required>
|
||||
<el-select
|
||||
v-if="editingProjectId"
|
||||
v-model="projectForm.teachingTaskIds"
|
||||
filterable
|
||||
:multiple="!editingProjectId"
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:disabled="!!editingProjectId"
|
||||
placeholder="选择同一学期、同一课程的已发布教学任务"
|
||||
disabled
|
||||
placeholder="所属教学任务"
|
||||
@change="onTaskChange"
|
||||
>
|
||||
<el-option
|
||||
@@ -783,13 +977,45 @@ onMounted(async () => {
|
||||
:key="task.id"
|
||||
:label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`"
|
||||
:value="task.id"
|
||||
:disabled="!!selectedTask
|
||||
&& (task.academicTermId !== selectedTask.academicTermId
|
||||
|| task.courseId !== selectedTask.courseId)"
|
||||
/>
|
||||
</el-select>
|
||||
<el-checkbox-group
|
||||
v-else
|
||||
v-model="projectForm.teachingTaskIds"
|
||||
class="grouped-task-picker"
|
||||
@change="onTaskChange"
|
||||
>
|
||||
<div class="task-option-filters">
|
||||
<el-select v-model="offeringCollegeId" clearable filterable placeholder="开课学院" @change="searchTaskOptions">
|
||||
<el-option v-for="college in options.colleges" :key="college.collegeId" :label="college.collegeName" :value="college.collegeId" />
|
||||
</el-select>
|
||||
<el-input v-model="courseKeyword" clearable placeholder="课程名称或代码" @keyup.enter="searchTaskOptions" />
|
||||
<el-button @click="searchTaskOptions">查询</el-button>
|
||||
</div>
|
||||
<section v-for="group in taskGroups" :key="group.key" class="task-picker-course">
|
||||
<header>
|
||||
<span>{{ group.termName }}</span>
|
||||
<b>{{ group.courseCode }} · {{ group.courseName }}</b>
|
||||
</header>
|
||||
<div>
|
||||
<el-checkbox
|
||||
v-for="task in group.tasks"
|
||||
:key="task.id"
|
||||
:value="task.id"
|
||||
:disabled="projectForm.arrangementMode === 'SelfScheduled' && !!selectedTask
|
||||
&& (task.academicTermId !== selectedTask.academicTermId
|
||||
|| task.courseId !== selectedTask.courseId)"
|
||||
>
|
||||
<b>{{ task.taskNumber }}</b>
|
||||
<span>{{ task.classNames.join('、') || task.name }}</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</section>
|
||||
<el-empty v-if="!taskGroups.length" :image-size="46" description="没有匹配的已发布教学班,请调整筛选条件" />
|
||||
</el-checkbox-group>
|
||||
<small v-if="!editingProjectId" class="form-help">
|
||||
已选 {{ projectForm.teachingTaskIds.length }} 个;实验编码、名称、内容和开放日期将一次应用到这些教学任务。
|
||||
<template v-if="projectForm.arrangementMode === 'Centralized'">已选 {{ projectForm.teachingTaskIds.length }} 个教学班;系统会为每个教学班的全部已发布实验课生成项目。</template>
|
||||
<template v-else>已选 {{ projectForm.teachingTaskIds.length }} 个教学班;只能勾选同一学期、同一课程,实验编码、名称、内容和开放日期将一次应用到这些教学任务。</template>
|
||||
</small>
|
||||
</el-form-item>
|
||||
<div class="form-grid two">
|
||||
@@ -1066,14 +1292,27 @@ onMounted(async () => {
|
||||
.rail-switch i { width: 30px; height: 7px; border: 2px solid #7794a5; border-radius: 999px; background: #fff; }
|
||||
.experiment-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 16px; }
|
||||
.experiment-toolbar .el-select { width: 210px; }
|
||||
.experiment-toolbar .el-input { width: 172px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.project-group-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 17px; border-left: 5px solid var(--lab-blue); background: #edf4f6; }
|
||||
.project-group-head > div:first-child { display: grid; gap: 3px; }
|
||||
.project-group-head h3 { margin: 0; color: var(--lab-ink); font-size: 15px; }
|
||||
.project-group-head > div:first-child span { color: #68808d; font: 700 10px/1 Consolas, monospace; letter-spacing: .06em; }
|
||||
.project-group-task { display: grid; justify-items: end; gap: 3px; color: #5c7481; font-size: 11px; text-align: right; }
|
||||
.project-group-task b { color: #365364; font-size: 12px; }
|
||||
.project-group-projects { display: grid; gap: 0; padding: 0; }
|
||||
.project-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid #dce5ea;
|
||||
border-top: 4px solid var(--lab-blue);
|
||||
border: 0;
|
||||
border-top: 1px solid #dce5ea;
|
||||
border-left: 4px solid var(--lab-blue);
|
||||
background: #fff;
|
||||
box-shadow: 0 7px 22px rgb(35 67 85 / 5%);
|
||||
box-shadow: none;
|
||||
}
|
||||
.project-card.is-flexible { border-top-color: var(--lab-teal); }
|
||||
.project-card.is-closed { opacity: .82; }
|
||||
@@ -1140,6 +1379,16 @@ onMounted(async () => {
|
||||
.student-booking-summary span { display: grid; gap: 2px; color: #365f5b; font-size: 12px; }
|
||||
.student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.experiment-form { display: grid; gap: 13px; }
|
||||
.grouped-task-picker { display: grid; gap: 10px; max-height: 310px; overflow: auto; padding: 10px; border: 1px solid #d8e3e8; background: #fff; }
|
||||
.task-option-filters { display: grid; grid-template-columns: minmax(150px, 1fr) minmax(180px, 1.2fr) auto; gap: 8px; }
|
||||
.task-picker-course { overflow: hidden; border: 1px solid #e0e8eb; }
|
||||
.task-picker-course header { display: flex; align-items: baseline; gap: 8px; padding: 8px 10px; background: #f3f7f8; }
|
||||
.task-picker-course header span { color: #738994; font: 700 10px/1 Consolas, monospace; }
|
||||
.task-picker-course header b { color: #314f60; font-size: 12px; }
|
||||
.task-picker-course > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 2px 12px; padding: 8px 10px; }
|
||||
.task-picker-course :deep(.el-checkbox) { height: auto; margin-right: 0; white-space: normal; }
|
||||
.task-picker-course :deep(.el-checkbox__label) { display: inline-flex; gap: 6px; min-width: 0; color: #536b78; font-size: 12px; }
|
||||
.task-picker-course :deep(.el-checkbox__label b) { flex: 0 0 auto; color: #284757; }
|
||||
.form-help { display: block; margin-top: 7px; color: var(--muted); line-height: 1.5; }
|
||||
.form-section { padding: 14px 15px 1px; border: 1px solid var(--line); background: #fbfcfd; }
|
||||
.form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; }
|
||||
|
||||
Reference in New Issue
Block a user