同学期、同课程、同教学班的实验项目合并展示,组内连续排列。
管理列表改为数据库筛选、按教学任务服务端分页(10–100 条/页)。 增加开课学院、课程名称/编码、教学班、任课教师、教学任务号筛选。 增加当前页草稿项目的批量选中、批量发布、批量删除;后端会再次校验权限、状态和发布条件。 确认框改为固定居中白色底板与阴影,不再出现只有文字按钮、没有底色的情况。
This commit is contained in:
@@ -31,12 +31,21 @@ public sealed class ExperimentsController(
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> GetOptions(
|
public async Task<ActionResult> GetOptions(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
|
Guid? offeringCollegeId,
|
||||||
|
string? courseKeyword,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var tasks = AccessibleTeachingTasks().AsNoTracking()
|
var tasks = AccessibleTeachingTasks().AsNoTracking()
|
||||||
.Where(x => x.Status == TeachingTaskStatus.Published);
|
.Where(x => x.Status == TeachingTaskStatus.Published);
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
|
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()
|
var periods = db.ScheduleTimeSlots.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled);
|
.Where(x => x.IsEnabled);
|
||||||
@@ -90,6 +99,13 @@ public sealed class ExperimentsController(
|
|||||||
.OrderBy(item => item.AdministrativeClass!.Code)
|
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||||
.Select(item => item.AdministrativeClass!.Name)
|
.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),
|
.ToListAsync(cancellationToken),
|
||||||
ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
||||||
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||||
@@ -99,6 +115,11 @@ public sealed class ExperimentsController(
|
|||||||
.Contains(x.TeachingTaskId))
|
.Contains(x.TeachingTaskId))
|
||||||
.Where(x => !academicTermId.HasValue ||
|
.Where(x => !academicTermId.HasValue ||
|
||||||
x.SchedulePlan!.AcademicTermId == academicTermId.Value)
|
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)
|
.OrderBy(x => x.TeachingTask!.Course!.Code)
|
||||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||||
.ThenBy(x => x.DayOfWeek)
|
.ThenBy(x => x.DayOfWeek)
|
||||||
@@ -116,10 +137,14 @@ public sealed class ExperimentsController(
|
|||||||
x.StartWeek,
|
x.StartWeek,
|
||||||
x.EndWeek,
|
x.EndWeek,
|
||||||
x.WeekPattern,
|
x.WeekPattern,
|
||||||
|
ClassNames = x.TeachingTask!.Classes
|
||||||
|
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||||
|
.Select(item => item.AdministrativeClass!.Name),
|
||||||
ClassroomName = x.Classroom!.Name,
|
ClassroomName = x.Classroom!.Name,
|
||||||
BuildingName = x.Classroom.Building!.Name,
|
BuildingName = x.Classroom.Building!.Name,
|
||||||
CampusName = x.Classroom.Building.Campus!.Name
|
CampusName = x.Classroom.Building.Campus!.Name
|
||||||
})
|
})
|
||||||
|
.Take(500)
|
||||||
.ToListAsync(cancellationToken),
|
.ToListAsync(cancellationToken),
|
||||||
Classrooms = await db.Classrooms.AsNoTracking()
|
Classrooms = await db.Classrooms.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled)
|
.Where(x => x.IsEnabled)
|
||||||
@@ -146,7 +171,14 @@ public sealed class ExperimentsController(
|
|||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
ExperimentArrangementMode? arrangementMode,
|
ExperimentArrangementMode? arrangementMode,
|
||||||
ExperimentProjectStatus? status,
|
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();
|
var source = ScopedProjects().AsNoTracking();
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
@@ -158,7 +190,47 @@ public sealed class ExperimentsController(
|
|||||||
if (status.HasValue)
|
if (status.HasValue)
|
||||||
source = source.Where(x => x.Status == status);
|
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)
|
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
|
||||||
.ThenBy(x => x.StartDate)
|
.ThenBy(x => x.StartDate)
|
||||||
.ThenBy(x => x.Code)
|
.ThenBy(x => x.Code)
|
||||||
@@ -167,6 +239,7 @@ public sealed class ExperimentsController(
|
|||||||
x.Id,
|
x.Id,
|
||||||
x.TeachingTaskId,
|
x.TeachingTaskId,
|
||||||
x.ScheduleEntryId,
|
x.ScheduleEntryId,
|
||||||
|
x.ScheduleWeek,
|
||||||
x.Code,
|
x.Code,
|
||||||
x.Name,
|
x.Name,
|
||||||
x.ArrangementMode,
|
x.ArrangementMode,
|
||||||
@@ -197,6 +270,7 @@ public sealed class ExperimentsController(
|
|||||||
x.ScheduleEntry.StartWeek,
|
x.ScheduleEntry.StartWeek,
|
||||||
x.ScheduleEntry.EndWeek,
|
x.ScheduleEntry.EndWeek,
|
||||||
x.ScheduleEntry.WeekPattern,
|
x.ScheduleEntry.WeekPattern,
|
||||||
|
ProjectWeek = x.ScheduleWeek,
|
||||||
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
||||||
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
||||||
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
||||||
@@ -220,7 +294,9 @@ public sealed class ExperimentsController(
|
|||||||
CampusName = item.Classroom.Building.Campus!.Name
|
CampusName = item.Classroom.Building.Campus!.Name
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("student")]
|
[HttpGet("student")]
|
||||||
@@ -254,6 +330,7 @@ public sealed class ExperimentsController(
|
|||||||
x.Name,
|
x.Name,
|
||||||
x.ArrangementMode,
|
x.ArrangementMode,
|
||||||
x.ScheduleEntryId,
|
x.ScheduleEntryId,
|
||||||
|
x.ScheduleWeek,
|
||||||
x.Description,
|
x.Description,
|
||||||
x.Requirements,
|
x.Requirements,
|
||||||
x.StartDate,
|
x.StartDate,
|
||||||
@@ -275,6 +352,7 @@ public sealed class ExperimentsController(
|
|||||||
x.ScheduleEntry.StartWeek,
|
x.ScheduleEntry.StartWeek,
|
||||||
x.ScheduleEntry.EndWeek,
|
x.ScheduleEntry.EndWeek,
|
||||||
x.ScheduleEntry.WeekPattern,
|
x.ScheduleEntry.WeekPattern,
|
||||||
|
ProjectWeek = x.ScheduleWeek,
|
||||||
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
||||||
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
||||||
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
||||||
@@ -363,8 +441,6 @@ public sealed class ExperimentsController(
|
|||||||
ExperimentProjectBatchRequest request,
|
ExperimentProjectBatchRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (request.ArrangementMode == ExperimentArrangementMode.Centralized)
|
|
||||||
return ValidationProblem("集中安排的实验项目请逐项绑定已发布课表中的实验课。");
|
|
||||||
var taskIds = request.TeachingTaskIds
|
var taskIds = request.TeachingTaskIds
|
||||||
.Where(x => x != Guid.Empty)
|
.Where(x => x != Guid.Empty)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
@@ -384,12 +460,14 @@ public sealed class ExperimentsController(
|
|||||||
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
|
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
|
||||||
|
|
||||||
var first = tasks[0];
|
var first = tasks[0];
|
||||||
if (tasks.Any(x =>
|
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && tasks.Any(x =>
|
||||||
x.AcademicTermId != first.AcademicTermId ||
|
x.AcademicTermId != first.AcademicTermId ||
|
||||||
x.CourseId != first.CourseId))
|
x.CourseId != first.CourseId))
|
||||||
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
|
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
|
||||||
|
|
||||||
var code = request.Code.Trim();
|
var code = request.Code.Trim();
|
||||||
|
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
|
||||||
|
{
|
||||||
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
||||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
.Where(x => x.Code == code)
|
.Where(x => x.Code == code)
|
||||||
@@ -399,6 +477,55 @@ public sealed class ExperimentsController(
|
|||||||
if (conflictingTaskNumbers.Count > 0)
|
if (conflictingTaskNumbers.Count > 0)
|
||||||
return ConflictProblem(
|
return ConflictProblem(
|
||||||
$"以下教学任务已存在实验项目编码 {code}:{string.Join("、", conflictingTaskNumbers)}。");
|
$"以下教学任务已存在实验项目编码 {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);
|
var projects = new List<ExperimentProject>(tasks.Count);
|
||||||
foreach (var task in tasks)
|
foreach (var task in tasks)
|
||||||
@@ -407,9 +534,33 @@ public sealed class ExperimentsController(
|
|||||||
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
|
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
|
||||||
if (problem is not null) return ValidationProblem(problem);
|
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
|
projects.Add(new ExperimentProject
|
||||||
{
|
{
|
||||||
TeachingTaskId = task.Id,
|
TeachingTaskId = task.Id,
|
||||||
|
ScheduleEntryId = occurrence.Entry?.Id,
|
||||||
|
ScheduleWeek = occurrence.Week,
|
||||||
Code = code,
|
Code = code,
|
||||||
Name = request.Name.Trim(),
|
Name = request.Name.Trim(),
|
||||||
ArrangementMode = request.ArrangementMode,
|
ArrangementMode = request.ArrangementMode,
|
||||||
@@ -419,6 +570,7 @@ public sealed class ExperimentsController(
|
|||||||
EndDate = request.EndDate
|
EndDate = request.EndDate
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.ExperimentProjects.AddRange(projects);
|
db.ExperimentProjects.AddRange(projects);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -491,6 +643,26 @@ public sealed class ExperimentsController(
|
|||||||
return NoContent();
|
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")]
|
[HttpPost("{id:guid}/publish")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> PublishProject(
|
public async Task<ActionResult> PublishProject(
|
||||||
@@ -522,28 +694,49 @@ public sealed class ExperimentsController(
|
|||||||
project.Status = ExperimentProjectStatus.Published;
|
project.Status = ExperimentProjectStatus.Published;
|
||||||
project.PublishedAt = DateTime.UtcNow;
|
project.PublishedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await NotifyProjectPublishedAsync(project, cancellationToken);
|
||||||
var userIds = await TeachingTaskRosterQuery
|
return NoContent();
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,6 +1427,31 @@ public sealed class ExperimentsController(
|
|||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync(cancellationToken);
|
.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(
|
private static string? ValidateProjectRequest(
|
||||||
ExperimentProjectRequest request,
|
ExperimentProjectRequest request,
|
||||||
AcademicTerm term)
|
AcademicTerm term)
|
||||||
@@ -1321,6 +1539,9 @@ public sealed record ExperimentProjectBatchRequest(
|
|||||||
EndDate);
|
EndDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record ExperimentProjectBulkRequest(
|
||||||
|
[Required] IReadOnlyList<Guid> ProjectIds);
|
||||||
|
|
||||||
public sealed record ExperimentSessionRequest(
|
public sealed record ExperimentSessionRequest(
|
||||||
Guid ClassroomId,
|
Guid ClassroomId,
|
||||||
DateOnly SessionDate,
|
DateOnly SessionDate,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public sealed class ExperimentProject : EntityBase
|
|||||||
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
|
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
|
||||||
public Guid? ScheduleEntryId { get; set; }
|
public Guid? ScheduleEntryId { get; set; }
|
||||||
public ScheduleEntry? ScheduleEntry { get; set; }
|
public ScheduleEntry? ScheduleEntry { get; set; }
|
||||||
|
// 集中安排按课表的具体周次拆分为实验项目;自行安排为空。
|
||||||
|
public int? ScheduleWeek { get; set; }
|
||||||
public required string Code { get; set; }
|
public required string Code { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public ExperimentArrangementMode ArrangementMode { 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.Name).HasMaxLength(120);
|
||||||
entity.Property(x => x.Description).HasMaxLength(1000);
|
entity.Property(x => x.Description).HasMaxLength(1000);
|
||||||
entity.Property(x => x.Requirements).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.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
|
||||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
.HasForeignKey(x => x.TeachingTaskId)
|
.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")
|
b.Property<Guid?>("ScheduleEntryId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int?>("ScheduleWeek")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime>("StartDate")
|
b.Property<DateTime>("StartDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
@@ -2448,7 +2451,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
b.HasIndex("ScheduleEntryId");
|
b.HasIndex("ScheduleEntryId");
|
||||||
|
|
||||||
b.HasIndex("TeachingTaskId", "Code")
|
b.HasIndex("TeachingTaskId", "Code", "ScheduleEntryId", "ScheduleWeek")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("Status", "StartDate", "EndDate");
|
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]
|
[Fact]
|
||||||
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
|
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<JiaowuBackendVersion>2.3.2-beta.4</JiaowuBackendVersion>
|
<JiaowuBackendVersion>2.3.2-beta.5</JiaowuBackendVersion>
|
||||||
<JiaowuFrontendVersion>2.3.2-beta.4</JiaowuFrontendVersion>
|
<JiaowuFrontendVersion>2.3.2-beta.5</JiaowuFrontendVersion>
|
||||||
<JiaowuSwaggerVersion>2.3.2-beta.4</JiaowuSwaggerVersion>
|
<JiaowuSwaggerVersion>2.3.2-beta.5</JiaowuSwaggerVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -343,6 +343,29 @@ button { cursor: pointer; }
|
|||||||
.form-grid.compact { align-items: center; }
|
.form-grid.compact { align-items: center; }
|
||||||
.form-grid.three { grid-template-columns: repeat(3, 1fr); }
|
.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; }
|
.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; }
|
.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; }
|
.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 terms = ref<any[]>([])
|
||||||
const termId = ref('')
|
const termId = ref('')
|
||||||
const projects = ref<any[]>([])
|
const projects = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const selectedProjectIds = ref<string[]>([])
|
||||||
const options = reactive({
|
const options = reactive({
|
||||||
tasks: [] as any[],
|
tasks: [] as any[],
|
||||||
|
colleges: [] as any[],
|
||||||
scheduleEntries: [] as any[],
|
scheduleEntries: [] as any[],
|
||||||
classrooms: [] as any[],
|
classrooms: [] as any[],
|
||||||
periods: [] as any[],
|
periods: [] as any[],
|
||||||
})
|
})
|
||||||
const modeFilter = ref('')
|
const modeFilter = ref('')
|
||||||
const statusFilter = ref('')
|
const statusFilter = ref('')
|
||||||
|
const offeringCollegeId = ref('')
|
||||||
|
const courseKeyword = ref('')
|
||||||
|
const classKeyword = ref('')
|
||||||
|
const teacherKeyword = ref('')
|
||||||
|
const taskKeyword = ref('')
|
||||||
|
|
||||||
const projectDialog = ref(false)
|
const projectDialog = ref(false)
|
||||||
const editingProjectId = ref('')
|
const editingProjectId = ref('')
|
||||||
@@ -68,6 +78,51 @@ const batchProjectOptions = computed(() =>
|
|||||||
projects.value.filter((project) =>
|
projects.value.filter((project) =>
|
||||||
project.status !== 'Closed' && project.arrangementMode === 'SelfScheduled'),
|
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(() =>
|
const activePeriods = computed(() =>
|
||||||
options.periods.filter((period) =>
|
options.periods.filter((period) =>
|
||||||
!selectedProject.value
|
!selectedProject.value
|
||||||
@@ -119,6 +174,11 @@ function onScheduleEntryChange() {
|
|||||||
onTaskChange()
|
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() {
|
function openCreateProject() {
|
||||||
resetProjectForm()
|
resetProjectForm()
|
||||||
projectDialog.value = true
|
projectDialog.value = true
|
||||||
@@ -141,10 +201,10 @@ function openEditProject(project: any) {
|
|||||||
|
|
||||||
async function saveProject() {
|
async function saveProject() {
|
||||||
if (!projectForm.teachingTaskIds.length ||
|
if (!projectForm.teachingTaskIds.length ||
|
||||||
(projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
|
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
|
||||||
!projectForm.code.trim()
|
!projectForm.code.trim()
|
||||||
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
|
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
|
||||||
ElMessage.warning(projectForm.arrangementMode === 'Centralized'
|
ElMessage.warning(projectForm.arrangementMode === 'Centralized' && editingProjectId.value
|
||||||
? '请选择课表实验课,并填写项目编码、名称和开放日期'
|
? '请选择课表实验课,并填写项目编码、名称和开放日期'
|
||||||
: '请填写教学任务、项目编码、名称和开放日期')
|
: '请填写教学任务、项目编码、名称和开放日期')
|
||||||
return
|
return
|
||||||
@@ -164,16 +224,15 @@ async function saveProject() {
|
|||||||
if (editingProjectId.value) {
|
if (editingProjectId.value) {
|
||||||
await http.put(`/experiments/${editingProjectId.value}`, payload)
|
await http.put(`/experiments/${editingProjectId.value}`, payload)
|
||||||
ElMessage.success('实验项目已更新')
|
ElMessage.success('实验项目已更新')
|
||||||
} else if (projectForm.arrangementMode === 'Centralized') {
|
|
||||||
await http.post('/experiments', payload)
|
|
||||||
ElMessage.success('实验项目已绑定课表实验课')
|
|
||||||
} else {
|
} else {
|
||||||
await http.post('/experiments/batch', {
|
await http.post('/experiments/batch', {
|
||||||
...payload,
|
...payload,
|
||||||
teachingTaskId: undefined,
|
teachingTaskId: undefined,
|
||||||
teachingTaskIds: projectForm.teachingTaskIds,
|
teachingTaskIds: projectForm.teachingTaskIds,
|
||||||
})
|
})
|
||||||
ElMessage.success(`已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
|
ElMessage.success(projectForm.arrangementMode === 'Centralized'
|
||||||
|
? `已按 ${projectForm.teachingTaskIds.length} 个教学班的全部实验课生成项目`
|
||||||
|
: `已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
|
||||||
}
|
}
|
||||||
projectDialog.value = false
|
projectDialog.value = false
|
||||||
await load()
|
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) {
|
async function cancelSession(project: any, session: any) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
@@ -424,14 +534,20 @@ async function loadOptions() {
|
|||||||
if (isStudent.value) return
|
if (isStudent.value) return
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get('/experiments/options', {
|
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.tasks = data.tasks
|
||||||
|
options.colleges = data.colleges
|
||||||
options.scheduleEntries = data.scheduleEntries
|
options.scheduleEntries = data.scheduleEntries
|
||||||
options.classrooms = data.classrooms
|
options.classrooms = data.classrooms
|
||||||
options.periods = data.periods
|
options.periods = data.periods
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.tasks = []
|
options.tasks = []
|
||||||
|
options.colleges = []
|
||||||
options.classrooms = []
|
options.classrooms = []
|
||||||
options.periods = []
|
options.periods = []
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -445,17 +561,28 @@ async function load() {
|
|||||||
projects.value = (await http.get('/experiments/student', {
|
projects.value = (await http.get('/experiments/student', {
|
||||||
params: { academicTermId: termId.value || undefined },
|
params: { academicTermId: termId.value || undefined },
|
||||||
})).data
|
})).data
|
||||||
|
total.value = projects.value.length
|
||||||
} else {
|
} else {
|
||||||
projects.value = (await http.get('/experiments/management', {
|
const { data } = await http.get('/experiments/management', {
|
||||||
params: {
|
params: {
|
||||||
academicTermId: termId.value || undefined,
|
academicTermId: termId.value || undefined,
|
||||||
arrangementMode: modeFilter.value || undefined,
|
arrangementMode: modeFilter.value || undefined,
|
||||||
status: statusFilter.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) {
|
} catch (error) {
|
||||||
projects.value = []
|
projects.value = []
|
||||||
|
total.value = 0
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -463,10 +590,30 @@ async function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function changeTerm() {
|
async function changeTerm() {
|
||||||
|
page.value = 1
|
||||||
|
selectedProjectIds.value = []
|
||||||
await loadOptions()
|
await loadOptions()
|
||||||
await load()
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
terms.value = (await http.get('/base-data/terms')).data
|
terms.value = (await http.get('/base-data/terms')).data
|
||||||
@@ -524,6 +671,13 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<template v-if="!isStudent">
|
<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-select v-model="modeFilter" clearable placeholder="全部安排方式" @change="load">
|
||||||
<el-option label="集中安排" value="Centralized" />
|
<el-option label="集中安排" value="Centralized" />
|
||||||
<el-option label="自行安排" value="SelfScheduled" />
|
<el-option label="自行安排" value="SelfScheduled" />
|
||||||
@@ -534,12 +688,33 @@ onMounted(async () => {
|
|||||||
<el-option label="已关闭" value="Closed" />
|
<el-option label="已关闭" value="Closed" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</template>
|
</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>
|
||||||
|
|
||||||
<section v-loading="loading" class="project-list">
|
<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
|
<article
|
||||||
v-for="project in projects"
|
v-for="project in group.projects"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
class="project-card"
|
class="project-card"
|
||||||
:class="[
|
:class="[
|
||||||
@@ -551,9 +726,15 @@ onMounted(async () => {
|
|||||||
<div class="project-identity">
|
<div class="project-identity">
|
||||||
<span class="project-code">{{ project.courseCode }} · {{ project.code }}</span>
|
<span class="project-code">{{ project.courseCode }} · {{ project.code }}</span>
|
||||||
<h3>{{ project.name }}</h3>
|
<h3>{{ project.name }}</h3>
|
||||||
<p>{{ project.courseName }} · {{ project.taskNumber }}</p>
|
<p>{{ project.arrangementMode === 'Centralized' ? '统一到场实验' : '开放预约实验' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-tags">
|
<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
|
<el-tag
|
||||||
:type="project.arrangementMode === 'Centralized' ? 'primary' : 'success'"
|
:type="project.arrangementMode === 'Centralized' ? 'primary' : 'success'"
|
||||||
effect="plain"
|
effect="plain"
|
||||||
@@ -600,8 +781,8 @@ onMounted(async () => {
|
|||||||
<article class="session-ticket">
|
<article class="session-ticket">
|
||||||
<div class="ticket-date"><strong>课表</strong><span>固定</span></div>
|
<div class="ticket-date"><strong>课表</strong><span>固定</span></div>
|
||||||
<div class="ticket-body">
|
<div class="ticket-body">
|
||||||
<b>星期 {{ project.scheduleEntry.dayOfWeek }} · 第 {{ project.scheduleEntry.startPeriod }}–{{ project.scheduleEntry.startPeriod + project.scheduleEntry.periodCount - 1 }} 节</b>
|
<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.startWeek }}–{{ project.scheduleEntry.endWeek }} 周 · {{ project.scheduleEntry.campusName }} · {{ project.scheduleEntry.buildingName }} {{ project.scheduleEntry.classroomName }}</span>
|
<span>{{ project.scheduleEntry.campusName }} · {{ project.scheduleEntry.buildingName }} {{ project.scheduleEntry.classroomName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ticket-action"><el-tag type="primary" size="small" effect="plain">课表已安排</el-tag></div>
|
<div class="ticket-action"><el-tag type="primary" size="small" effect="plain">课表已安排</el-tag></div>
|
||||||
</article>
|
</article>
|
||||||
@@ -736,6 +917,8 @@ onMounted(async () => {
|
|||||||
</el-button>
|
</el-button>
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<el-empty
|
<el-empty
|
||||||
v-if="!loading && !projects.length"
|
v-if="!loading && !projects.length"
|
||||||
@@ -747,6 +930,19 @@ onMounted(async () => {
|
|||||||
</el-empty>
|
</el-empty>
|
||||||
</section>
|
</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
|
<el-dialog
|
||||||
v-model="projectDialog"
|
v-model="projectDialog"
|
||||||
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
||||||
@@ -756,26 +952,24 @@ onMounted(async () => {
|
|||||||
<el-form label-position="top" class="experiment-form">
|
<el-form label-position="top" class="experiment-form">
|
||||||
<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="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-select v-model="projectForm.scheduleEntryId" filterable :disabled="!!editingProjectId" placeholder="选择已安排实验室的实验课" @change="onScheduleEntryChange">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="entry in options.scheduleEntries"
|
v-for="entry in options.scheduleEntries"
|
||||||
:key="entry.id"
|
: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"
|
:value="entry.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<small class="form-help">集中实验复用课表的时间和实验室,不会再生成独立实验场次。</small>
|
<small class="form-help">显示当前管理范围内全部已发布、已安排实验室的实验课;集中实验复用其时间和实验室,不会再生成独立实验场次。</small>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-else :label="editingProjectId ? '所属教学任务' : '适用教学任务(可多选)'" required>
|
<el-form-item v-else :label="editingProjectId ? '所属教学任务' : '适用教学班(可多选)'" required>
|
||||||
<el-select
|
<el-select
|
||||||
|
v-if="editingProjectId"
|
||||||
v-model="projectForm.teachingTaskIds"
|
v-model="projectForm.teachingTaskIds"
|
||||||
filterable
|
filterable
|
||||||
:multiple="!editingProjectId"
|
disabled
|
||||||
collapse-tags
|
placeholder="所属教学任务"
|
||||||
collapse-tags-tooltip
|
|
||||||
:disabled="!!editingProjectId"
|
|
||||||
placeholder="选择同一学期、同一课程的已发布教学任务"
|
|
||||||
@change="onTaskChange"
|
@change="onTaskChange"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
@@ -783,13 +977,45 @@ onMounted(async () => {
|
|||||||
:key="task.id"
|
:key="task.id"
|
||||||
:label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`"
|
:label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`"
|
||||||
:value="task.id"
|
:value="task.id"
|
||||||
:disabled="!!selectedTask
|
|
||||||
&& (task.academicTermId !== selectedTask.academicTermId
|
|
||||||
|| task.courseId !== selectedTask.courseId)"
|
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</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">
|
<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>
|
</small>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="form-grid two">
|
<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; }
|
.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 { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 16px; }
|
||||||
.experiment-toolbar .el-select { width: 210px; }
|
.experiment-toolbar .el-select { width: 210px; }
|
||||||
|
.experiment-toolbar .el-input { width: 172px; }
|
||||||
.result-note { margin-left: auto; color: var(--muted); font-size: 12px; }
|
.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-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 {
|
.project-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #dce5ea;
|
border: 0;
|
||||||
border-top: 4px solid var(--lab-blue);
|
border-top: 1px solid #dce5ea;
|
||||||
|
border-left: 4px solid var(--lab-blue);
|
||||||
background: #fff;
|
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-flexible { border-top-color: var(--lab-teal); }
|
||||||
.project-card.is-closed { opacity: .82; }
|
.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 span { display: grid; gap: 2px; color: #365f5b; font-size: 12px; }
|
||||||
.student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
|
.student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
.experiment-form { display: grid; gap: 13px; }
|
.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-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 { padding: 14px 15px 1px; border: 1px solid var(--line); background: #fbfcfd; }
|
||||||
.form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; }
|
.form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user