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

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