1 Commits
33 changed files with 97 additions and 35448 deletions
@@ -3,7 +3,6 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Teaching; using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -287,8 +286,6 @@ public sealed class CourseAdjustmentsController(
db.CourseAdjustments.Add(adj); db.CourseAdjustments.Add(adj);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await new PublishedTimetableProjectionService(db)
.RebuildPublishedPlansForTaskAsync(adj.TeachingTaskId, cancellationToken);
if (request.Submit) if (request.Submit)
{ {
@@ -1,161 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = ReadRoles)]
[Route("api/course-groups")]
public sealed class CourseGroupsController(AppDbContext db) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string ManageRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
[HttpGet]
public async Task<ActionResult> GetAll(CancellationToken cancellationToken)
{
var groups = await db.CourseGroups.AsNoTracking()
.OrderBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.Description,
CourseCount = x.Courses.Count,
Courses = x.Courses.OrderBy(item => item.Course!.Code).Select(item => new
{
item.Id,
item.CourseId,
CourseCode = item.Course!.Code,
CourseName = item.Course.Name,
item.Course.Credits,
item.Course.TotalHours,
item.Course.Nature
})
})
.ToListAsync(cancellationToken);
return Ok(groups);
}
[HttpPost]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> Create(
CourseGroupRequest request,
CancellationToken cancellationToken)
{
var group = new CourseGroup
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
Description = Normalize(request.Description)
};
db.CourseGroups.Add(group);
return await SaveCreatedAsync(group.Id, cancellationToken);
}
[HttpPut("{id:guid}")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> Update(
Guid id,
CourseGroupRequest request,
CancellationToken cancellationToken)
{
var group = await db.CourseGroups.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (group is null) return NotFound();
group.Code = request.Code.Trim();
group.Name = request.Name.Trim();
group.Description = Normalize(request.Description);
return await SaveNoContentAsync(cancellationToken);
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var group = await db.CourseGroups.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (group is null) return NotFound();
db.CourseGroups.Remove(group);
return await SaveNoContentAsync(cancellationToken);
}
[HttpPost("{id:guid}/courses")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> AddCourse(
Guid id,
CourseGroupCourseRequest request,
CancellationToken cancellationToken)
{
if (!await db.CourseGroups.AnyAsync(x => x.Id == id, cancellationToken)) return NotFound();
if (!await db.Courses.AnyAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken))
return ValidationProblem("所选课程不存在或已停用。");
db.CourseGroupCourses.Add(new CourseGroupCourse { CourseGroupId = id, CourseId = request.CourseId });
return await SaveCreatedAsync(id, cancellationToken);
}
[HttpDelete("{id:guid}/courses/{courseId:guid}")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> RemoveCourse(
Guid id,
Guid courseId,
CancellationToken cancellationToken)
{
var item = await db.CourseGroupCourses.FirstOrDefaultAsync(
x => x.CourseGroupId == id && x.CourseId == courseId,
cancellationToken);
if (item is null) return NotFound();
db.CourseGroupCourses.Remove(item);
return await SaveNoContentAsync(cancellationToken);
}
private async Task<ActionResult> SaveCreatedAsync(Guid id, CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { id });
}
catch (DbUpdateException)
{
return ConflictProblem("课程组编码或组内课程重复。");
}
}
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("课程组编码或组内课程重复。");
}
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成操作", Detail = detail, Status = StatusCodes.Status409Conflict
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record CourseGroupRequest(
[Required, MaxLength(30)] string Code,
[Required, MaxLength(100)] string Name,
[MaxLength(500)] string? Description);
public sealed record CourseGroupCourseRequest(Guid CourseId);
@@ -1011,11 +1011,7 @@ public sealed class CourseSelectionsController(
x.TeachingTask!.Status == TeachingTaskStatus.Published && x.TeachingTask!.Status == TeachingTaskStatus.Published &&
(x.IsOpenToAll || (x.IsOpenToAll ||
x.TeachingTask.Classes.Any(item => x.TeachingTask.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId) || item.AdministrativeClassId == student.AdministrativeClassId)))
x.Enrollments.Any(item =>
item.StudentId == student.Id &&
(item.Status == CourseEnrollmentStatus.Enrolled ||
item.Status == CourseEnrollmentStatus.Waitlisted))))
.OrderBy(x => x.TeachingTask!.Course!.Code) .OrderBy(x => x.TeachingTask!.Course!.Code)
.Select(x => new StudentOfferingDto( .Select(x => new StudentOfferingDto(
x.Id, x.Id,
@@ -432,48 +432,6 @@ public sealed class CurriculumPlansController(
return await SaveCreatedAsync(item.Id, cancellationToken); return await SaveCreatedAsync(item.Id, cancellationToken);
} }
[HttpPost("{planId:guid}/modules/{moduleId:guid}/course-groups/{groupId:guid}")]
public async Task<ActionResult> AddCourseGroup(
Guid planId,
Guid moduleId,
Guid groupId,
CurriculumCourseGroupImportRequest request,
CancellationToken cancellationToken)
{
var plan = await ModifiablePlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2)
return ValidationProblem("建议学期超出了该专业学制。");
if (!await db.CurriculumModules.AnyAsync(
x => x.Id == moduleId && x.CurriculumPlanId == planId,
cancellationToken))
return NotFound();
var courseIds = await db.CourseGroupCourses.AsNoTracking()
.Where(x => x.CourseGroupId == groupId && x.Course!.IsEnabled)
.Select(x => x.CourseId)
.ToListAsync(cancellationToken);
if (courseIds.Count == 0)
return ValidationProblem("课程组不存在,或其中没有可用课程。");
var existingCourseIds = await db.CurriculumCourses.AsNoTracking()
.Where(x => x.CurriculumModule!.CurriculumPlanId == planId &&
courseIds.Contains(x.CourseId))
.Select(x => x.CourseId)
.ToListAsync(cancellationToken);
if (existingCourseIds.Count > 0)
return ConflictProblem("课程组中有课程已存在于该培养方案,请先移除重复课程后再导入。");
db.CurriculumCourses.AddRange(courseIds.Select(courseId => new CurriculumCourse
{
CurriculumModuleId = moduleId,
CourseId = courseId,
RecommendedSemester = request.RecommendedSemester,
Type = request.Type,
Notes = Normalize(request.Notes)
}));
return await SaveNoContentAsync(cancellationToken);
}
[HttpPut("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")] [HttpPut("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")]
public async Task<ActionResult> UpdateCourse( public async Task<ActionResult> UpdateCourse(
Guid planId, Guid planId,
@@ -628,8 +586,3 @@ public sealed record CurriculumCourseRequest(
[Range(1, 20)] int RecommendedSemester, [Range(1, 20)] int RecommendedSemester,
CurriculumCourseType Type, CurriculumCourseType Type,
[MaxLength(500)] string? Notes); [MaxLength(500)] string? Notes);
public sealed record CurriculumCourseGroupImportRequest(
[Range(1, 20)] int RecommendedSemester,
CurriculumCourseType Type,
[MaxLength(500)] string? Notes);
@@ -105,7 +105,6 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
return Ok(tasks.Select(task => return Ok(tasks.Select(task =>
{ {
@@ -133,15 +132,11 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
: constraint?.RequiresClassroom ?? true, : constraint?.RequiresClassroom ?? true,
constraint?.RequiredCampusId, constraint?.RequiredCampusId,
constraint?.RequiredBuildingId, constraint?.RequiredBuildingId,
constraint?.ExperimentRequiredCampusId,
constraint?.ExperimentRequiredBuildingId,
AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks), AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks),
constraint?.EarliestPeriod, constraint?.EarliestPeriod,
constraint?.LatestPeriod, constraint?.LatestPeriod,
AllowedClassroomIds = constraint?.AllowedClassrooms AllowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId) ?? [], .Select(x => x.ClassroomId) ?? [],
AllowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
.Select(x => x.ClassroomId) ?? [],
AllowedExperimentVenueNatures = constraint?.AllowedExperimentVenueNatures ?? 0 AllowedExperimentVenueNatures = constraint?.AllowedExperimentVenueNatures ?? 0
}; };
})); }));
@@ -173,7 +168,6 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
{ {
var flexibleConstraint = await db.TeachingTaskScheduleConstraints var flexibleConstraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (flexibleConstraint is not null) if (flexibleConstraint is not null)
{ {
@@ -201,22 +195,6 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
cancellationToken)) cancellationToken))
return ValidationProblem("指定校区不存在或已停用。"); return ValidationProblem("指定校区不存在或已停用。");
Building? experimentBuilding = null;
if (request.ExperimentRequiredBuildingId.HasValue)
{
experimentBuilding = await db.Buildings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.ExperimentRequiredBuildingId && x.IsEnabled,
cancellationToken);
if (experimentBuilding is null) return ValidationProblem("指定实验教学楼不存在或已停用。");
if (request.ExperimentRequiredCampusId.HasValue &&
experimentBuilding.CampusId != request.ExperimentRequiredCampusId)
return ValidationProblem("指定实验教学楼不属于所选校区。");
}
if (request.ExperimentRequiredCampusId.HasValue &&
!await db.Campuses.AnyAsync(x => x.Id == request.ExperimentRequiredCampusId && x.IsEnabled,
cancellationToken))
return ValidationProblem("指定实验校区不存在或已停用。");
var allowedRooms = await db.Classrooms.AsNoTracking() var allowedRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled) .Where(x => x.IsEnabled)
.WhereIn(request.AllowedClassroomIds, x => x.Id) .WhereIn(request.AllowedClassroomIds, x => x.Id)
@@ -230,23 +208,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId)) allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId))
return ValidationProblem("指定教室必须位于所选校区。"); return ValidationProblem("指定教室必须位于所选校区。");
var allowedExperimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? [];
var allowedExperimentRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.WhereIn(allowedExperimentRoomIds, x => x.Id)
.Include(x => x.Building)
.ToListAsync(cancellationToken);
if (allowedExperimentRooms.Count != allowedExperimentRoomIds.Length)
return ValidationProblem("部分指定实验场地不存在或已停用。");
if (experimentBuilding is not null && allowedExperimentRooms.Any(x => x.BuildingId != experimentBuilding.Id))
return ValidationProblem("指定实验场地必须位于所选实验教学楼。");
if (request.ExperimentRequiredCampusId.HasValue &&
allowedExperimentRooms.Any(x => x.Building!.CampusId != request.ExperimentRequiredCampusId))
return ValidationProblem("指定实验场地必须位于所选实验校区。");
var constraint = await db.TeachingTaskScheduleConstraints var constraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (constraint is null) if (constraint is null)
{ {
@@ -260,12 +223,6 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.RequiredBuildingId = request.RequiresClassroom constraint.RequiredBuildingId = request.RequiresClassroom
? request.RequiredBuildingId ? request.RequiredBuildingId
: null; : null;
constraint.ExperimentRequiredCampusId = request.RequiresClassroom
? request.ExperimentRequiredCampusId
: null;
constraint.ExperimentRequiredBuildingId = request.RequiresClassroom
? request.ExperimentRequiredBuildingId
: null;
constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0 constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0
? null ? null
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order()); : string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
@@ -273,15 +230,10 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.LatestPeriod = request.LatestPeriod; constraint.LatestPeriod = request.LatestPeriod;
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures; constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms); db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms);
constraint.AllowedClassrooms = request.RequiresClassroom constraint.AllowedClassrooms = request.RequiresClassroom
? request.AllowedClassroomIds.Distinct().Select(classroomId => ? request.AllowedClassroomIds.Distinct().Select(classroomId =>
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList() new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
: []; : [];
constraint.AllowedExperimentClassrooms = request.RequiresClassroom
? allowedExperimentRoomIds.Select(classroomId =>
new TeachingTaskAllowedExperimentClassroom { ClassroomId = classroomId }).ToList()
: [];
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return NoContent(); return NoContent();
} }
@@ -307,23 +259,18 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
!request.RequiresClassroom.HasValue && !request.RequiresClassroom.HasValue &&
request.AllowedDayOfWeeks is null && request.AllowedDayOfWeeks is null &&
!request.UpdateClassroomScope && !request.UpdateClassroomScope &&
!request.UpdateExperimentClassroomScope &&
!request.AllowedExperimentVenueNatures.HasValue &&
!request.UpdatePeriodRange && !request.UpdatePeriodRange &&
!request.EarliestPeriod.HasValue && !request.EarliestPeriod.HasValue &&
!request.LatestPeriod.HasValue) !request.LatestPeriod.HasValue)
return ValidationProblem("请至少选择一项需要批量修改的设置。"); return ValidationProblem("请至少选择一项需要批量修改的设置。");
if (request.UpdateClassroomScope && request.RequiresClassroom == false) if (request.UpdateClassroomScope && request.RequiresClassroom == false)
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。"); return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
if (request.UpdateExperimentClassroomScope && request.RequiresClassroom == false)
return ValidationProblem("批量指定实验场地时,场地要求不能设置为不占用教室。");
var tasks = await db.TeachingTasks var tasks = await db.TeachingTasks
.Where(x => .Where(x =>
x.AcademicTermId == request.AcademicTermId && x.AcademicTermId == request.AcademicTermId &&
x.Status == TeachingTaskStatus.Published) x.Status == TeachingTaskStatus.Published)
.WhereIn(taskIds, x => x.Id) .WhereIn(taskIds, x => x.Id)
.Include(x => x.Course)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (tasks.Count != taskIds.Length) if (tasks.Count != taskIds.Length)
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。"); return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
@@ -334,16 +281,9 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
(request.SchedulingMode ?? task.SchedulingMode) == (request.SchedulingMode ?? task.SchedulingMode) ==
TeachingTaskSchedulingMode.Flexible)) TeachingTaskSchedulingMode.Flexible))
return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。"); return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。");
if (request.UpdateExperimentClassroomScope && tasks.Any(task =>
(request.SchedulingMode ?? task.SchedulingMode) ==
TeachingTaskSchedulingMode.Flexible))
return ConflictProblem("非排时课程不能指定实验场地,请先将当前筛选结果限定为正常排课课程。");
Building? building = null; Building? building = null;
List<Classroom> allowedRooms = []; List<Classroom> allowedRooms = [];
var experimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? [];
Building? experimentBuilding = null;
List<Classroom> allowedExperimentRooms = [];
if (request.UpdateClassroomScope) if (request.UpdateClassroomScope)
{ {
if (request.RequiredBuildingId.HasValue) if (request.RequiredBuildingId.HasValue)
@@ -380,42 +320,10 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
x.Building!.CampusId != request.RequiredCampusId)) x.Building!.CampusId != request.RequiredCampusId))
return ValidationProblem("指定教室必须位于所选校区。"); return ValidationProblem("指定教室必须位于所选校区。");
} }
if (request.UpdateExperimentClassroomScope)
{
if (request.ExperimentRequiredBuildingId.HasValue)
{
experimentBuilding = await db.Buildings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.ExperimentRequiredBuildingId && x.IsEnabled,
cancellationToken);
if (experimentBuilding is null)
return ValidationProblem("指定实验教学楼不存在或已停用。");
if (request.ExperimentRequiredCampusId.HasValue &&
experimentBuilding.CampusId != request.ExperimentRequiredCampusId)
return ValidationProblem("指定实验教学楼不属于所选实验校区。");
}
if (request.ExperimentRequiredCampusId.HasValue &&
!await db.Campuses.AnyAsync(x => x.Id == request.ExperimentRequiredCampusId && x.IsEnabled,
cancellationToken))
return ValidationProblem("指定实验校区不存在或已停用。");
allowedExperimentRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.WhereIn(experimentRoomIds, x => x.Id)
.Include(x => x.Building)
.ToListAsync(cancellationToken);
if (allowedExperimentRooms.Count != experimentRoomIds.Length)
return ValidationProblem("部分指定实验场地不存在或已停用。");
if (experimentBuilding is not null &&
allowedExperimentRooms.Any(x => x.BuildingId != experimentBuilding.Id))
return ValidationProblem("指定实验场地必须位于所选实验教学楼。");
if (request.ExperimentRequiredCampusId.HasValue &&
allowedExperimentRooms.Any(x => x.Building!.CampusId != request.ExperimentRequiredCampusId))
return ValidationProblem("指定实验场地必须位于所选实验校区。");
}
var constraints = await db.TeachingTaskScheduleConstraints var constraints = await db.TeachingTaskScheduleConstraints
.WhereIn(taskIds, x => x.TeachingTaskId) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
foreach (var task in tasks) foreach (var task in tasks)
{ {
@@ -434,8 +342,6 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
request.RequiresClassroom.HasValue || request.RequiresClassroom.HasValue ||
request.AllowedDayOfWeeks is not null || request.AllowedDayOfWeeks is not null ||
request.UpdateClassroomScope || request.UpdateClassroomScope ||
request.UpdateExperimentClassroomScope ||
request.AllowedExperimentVenueNatures.HasValue ||
request.UpdatePeriodRange; request.UpdatePeriodRange;
if (!changesConstraint) continue; if (!changesConstraint) continue;
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id }; constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id };
@@ -451,10 +357,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.RequiredCampusId = null; constraint.RequiredCampusId = null;
constraint.RequiredBuildingId = null; constraint.RequiredBuildingId = null;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms); db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(
constraint.AllowedExperimentClassrooms);
constraint.AllowedClassrooms = []; constraint.AllowedClassrooms = [];
constraint.AllowedExperimentClassrooms = [];
} }
} }
if (request.AllowedDayOfWeeks is not null) if (request.AllowedDayOfWeeks is not null)
@@ -476,17 +379,6 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
ClassroomId = room.Id ClassroomId = room.Id
}).ToList(); }).ToList();
} }
if (task.Course?.PracticeHours > 0 && request.AllowedExperimentVenueNatures.HasValue)
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures.Value;
if (task.Course?.PracticeHours > 0 && request.UpdateExperimentClassroomScope)
{
constraint.ExperimentRequiredCampusId = request.ExperimentRequiredCampusId;
constraint.ExperimentRequiredBuildingId = request.ExperimentRequiredBuildingId;
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(
constraint.AllowedExperimentClassrooms);
constraint.AllowedExperimentClassrooms = allowedExperimentRooms.Select(room =>
new TeachingTaskAllowedExperimentClassroom { ClassroomId = room.Id }).ToList();
}
if (request.UpdatePeriodRange) if (request.UpdatePeriodRange)
{ {
constraint.EarliestPeriod = request.EarliestPeriod; constraint.EarliestPeriod = request.EarliestPeriod;
@@ -510,15 +402,11 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.RequiresClassroom = false; constraint.RequiresClassroom = false;
constraint.RequiredCampusId = null; constraint.RequiredCampusId = null;
constraint.RequiredBuildingId = null; constraint.RequiredBuildingId = null;
constraint.ExperimentRequiredCampusId = null;
constraint.ExperimentRequiredBuildingId = null;
constraint.AllowedDayOfWeeks = null; constraint.AllowedDayOfWeeks = null;
constraint.EarliestPeriod = null; constraint.EarliestPeriod = null;
constraint.LatestPeriod = null; constraint.LatestPeriod = null;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms); db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms);
constraint.AllowedClassrooms = []; constraint.AllowedClassrooms = [];
constraint.AllowedExperimentClassrooms = [];
} }
private ActionResult ConflictProblem(string detail) => private ActionResult ConflictProblem(string detail) =>
@@ -553,10 +441,7 @@ public sealed record TeachingTaskScheduleConstraintRequest(
IReadOnlyList<int> AllowedDayOfWeeks, IReadOnlyList<int> AllowedDayOfWeeks,
[Range(1, 30)] int? EarliestPeriod, [Range(1, 30)] int? EarliestPeriod,
[Range(1, 30)] int? LatestPeriod, [Range(1, 30)] int? LatestPeriod,
TeachingVenueNature AllowedExperimentVenueNatures = 0, TeachingVenueNature AllowedExperimentVenueNatures = 0);
IReadOnlyList<Guid>? AllowedExperimentClassroomIds = null,
Guid? ExperimentRequiredCampusId = null,
Guid? ExperimentRequiredBuildingId = null);
public sealed record TeachingTaskScheduleConstraintBatchRequest( public sealed record TeachingTaskScheduleConstraintBatchRequest(
Guid AcademicTermId, Guid AcademicTermId,
@@ -570,9 +455,4 @@ public sealed record TeachingTaskScheduleConstraintBatchRequest(
IReadOnlyList<Guid>? AllowedClassroomIds, IReadOnlyList<Guid>? AllowedClassroomIds,
bool UpdatePeriodRange, bool UpdatePeriodRange,
[Range(1, 30)] int? EarliestPeriod, [Range(1, 30)] int? EarliestPeriod,
[Range(1, 30)] int? LatestPeriod, [Range(1, 30)] int? LatestPeriod);
bool UpdateExperimentClassroomScope = false,
TeachingVenueNature? AllowedExperimentVenueNatures = null,
IReadOnlyList<Guid>? AllowedExperimentClassroomIds = null,
Guid? ExperimentRequiredCampusId = null,
Guid? ExperimentRequiredBuildingId = null);
@@ -552,7 +552,6 @@ public sealed class SchedulesController(
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking() var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.FirstOrDefaultAsync( .FirstOrDefaultAsync(
x => x.TeachingTaskId == request.TeachingTaskId, x => x.TeachingTaskId == request.TeachingTaskId,
cancellationToken); cancellationToken);
@@ -581,26 +580,20 @@ public sealed class SchedulesController(
x => x.Id == request.ClassroomId && x.IsEnabled, x => x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken); cancellationToken);
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。"); if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
if (request.Kind != ScheduleEntryKind.Experiment && if (request.Kind == ScheduleEntryKind.Experiment &&
constraint?.RequiredCampusId is Guid campusId && !TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
return ValidationProblem(
$"实验课必须安排在具有实验教学性质的场地;“{classroom.Name}”未标注实验室、实训室、机房或语音室性质。 ");
if (constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId) classroom.Building!.CampusId != campusId)
return ValidationProblem("所选教室不在该课程指定的校区。"); return ValidationProblem("所选教室不在该课程指定的校区。");
if (request.Kind != ScheduleEntryKind.Experiment && if (constraint?.RequiredBuildingId is Guid buildingId &&
constraint?.RequiredBuildingId is Guid buildingId &&
classroom.BuildingId != buildingId) classroom.BuildingId != buildingId)
return ValidationProblem("所选教室不在该课程指定的教学楼。"); return ValidationProblem("所选教室不在该课程指定的教学楼。");
if (request.Kind == ScheduleEntryKind.Experiment &&
constraint?.ExperimentRequiredCampusId is Guid experimentCampusId &&
classroom.Building!.CampusId != experimentCampusId)
return ValidationProblem("所选场地不在该实验课指定的校区。");
if (request.Kind == ScheduleEntryKind.Experiment &&
constraint?.ExperimentRequiredBuildingId is Guid experimentBuildingId &&
classroom.BuildingId != experimentBuildingId)
return ValidationProblem("所选场地不在该实验课指定的教学楼。");
var allowedClassroomIds = constraint?.AllowedClassrooms var allowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId) .Select(x => x.ClassroomId)
.ToHashSet() ?? []; .ToHashSet() ?? [];
if (request.Kind != ScheduleEntryKind.Experiment && allowedClassroomIds.Count > 0 && if (allowedClassroomIds.Count > 0 &&
!allowedClassroomIds.Contains(classroom.Id)) !allowedClassroomIds.Contains(classroom.Id))
return ValidationProblem("所选教室不在该课程指定的教室范围内。"); return ValidationProblem("所选教室不在该课程指定的教室范围内。");
if (request.Kind == ScheduleEntryKind.Experiment && if (request.Kind == ScheduleEntryKind.Experiment &&
@@ -608,13 +601,6 @@ public sealed class SchedulesController(
allowedNatures != 0 && allowedNatures != 0 &&
(classroom.TeachingVenueNature & allowedNatures) == 0) (classroom.TeachingVenueNature & allowedNatures) == 0)
return ValidationProblem("所选场地不在该实验课允许的教学场地性质范围内。"); return ValidationProblem("所选场地不在该实验课允许的教学场地性质范围内。");
var allowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
if (request.Kind == ScheduleEntryKind.Experiment &&
allowedExperimentClassroomIds.Count > 0 &&
!allowedExperimentClassroomIds.Contains(classroom.Id))
return ValidationProblem("所选场地不在该实验课指定的场地范围内。");
} }
var studentCount = task.Classes.Sum(x => var studentCount = task.Classes.Sum(x =>
x.AdministrativeClass!.Students.Count(student => x.AdministrativeClass!.Students.Count(student =>
@@ -38,22 +38,6 @@ public sealed class CurriculumCourse : EntityBase
public string? Notes { get; set; } public string? Notes { get; set; }
} }
public sealed class CourseGroup : EntityBase
{
public required string Code { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public ICollection<CourseGroupCourse> Courses { get; set; } = [];
}
public sealed class CourseGroupCourse : EntityBase
{
public Guid CourseGroupId { get; set; }
public CourseGroup? CourseGroup { get; set; }
public Guid CourseId { get; set; }
public Course? Course { get; set; }
}
public enum CurriculumPlanStatus public enum CurriculumPlanStatus
{ {
Draft = 1, Draft = 1,
@@ -52,16 +52,11 @@ public sealed class TeachingTaskScheduleConstraint : EntityBase
public Campus? RequiredCampus { get; set; } public Campus? RequiredCampus { get; set; }
public Guid? RequiredBuildingId { get; set; } public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; } public Building? RequiredBuilding { get; set; }
public Guid? ExperimentRequiredCampusId { get; set; }
public Campus? ExperimentRequiredCampus { get; set; }
public Guid? ExperimentRequiredBuildingId { get; set; }
public Building? ExperimentRequiredBuilding { get; set; }
public string? AllowedDayOfWeeks { get; set; } public string? AllowedDayOfWeeks { get; set; }
public int? EarliestPeriod { get; set; } public int? EarliestPeriod { get; set; }
public int? LatestPeriod { get; set; } public int? LatestPeriod { get; set; }
public TeachingVenueNature AllowedExperimentVenueNatures { get; set; } public TeachingVenueNature AllowedExperimentVenueNatures { get; set; }
public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = []; public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = [];
public ICollection<TeachingTaskAllowedExperimentClassroom> AllowedExperimentClassrooms { get; set; } = [];
} }
public sealed class TeachingTaskAllowedClassroom public sealed class TeachingTaskAllowedClassroom
@@ -72,31 +67,6 @@ public sealed class TeachingTaskAllowedClassroom
public Classroom? Classroom { get; set; } public Classroom? Classroom { get; set; }
} }
public sealed class PublishedScheduleOccurrence : EntityBase
{
public Guid SchedulePlanId { get; set; }
public Guid AcademicTermId { get; set; }
public Guid ScheduleEntryId { get; set; }
public Guid TeachingTaskId { get; set; }
public Guid? ClassroomId { get; set; }
public int Week { get; set; }
public int DayOfWeek { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public ScheduleEntryKind Kind { get; set; }
public ScheduleEntry? ScheduleEntry { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Classroom? Classroom { get; set; }
}
public sealed class TeachingTaskAllowedExperimentClassroom
{
public Guid TeachingTaskScheduleConstraintId { get; set; }
public TeachingTaskScheduleConstraint? TeachingTaskScheduleConstraint { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
}
public sealed class AutomaticScheduleJob : EntityBase public sealed class AutomaticScheduleJob : EntityBase
{ {
public Guid SchedulePlanId { get; set; } public Guid SchedulePlanId { get; set; }
@@ -51,8 +51,7 @@ public static class GradeAnalysisWordReportGenerator
DateTime generatedAt, DateTime generatedAt,
HeaderFooterIds headerFooterIds) HeaderFooterIds headerFooterIds)
{ {
var body = mainPart.Document?.Body var body = mainPart.Document.Body!;
?? throw new InvalidOperationException("The report document body has not been initialized.");
var summary = report.Summary!; var summary = report.Summary!;
body.Append(Paragraph("成绩分析报告", 46, true, "000000", 0, 80)); body.Append(Paragraph("成绩分析报告", 46, true, "000000", 0, 80));
@@ -468,10 +467,9 @@ public static class GradeAnalysisWordReportGenerator
{ {
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke }; using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
using var fill = new SKPaint { Color = color, IsAntialias = true }; using var fill = new SKPaint { Color = color, IsAntialias = true };
using var builder = new SKPathBuilder(); using var path = new SKPath();
builder.MoveTo(points[0]); path.MoveTo(points[0]);
foreach (var point in points.Skip(1)) builder.LineTo(point); foreach (var point in points.Skip(1)) path.LineTo(point);
using var path = builder.Detach();
canvas.DrawPath(path, paint); canvas.DrawPath(path, paint);
for (var i = 0; i < points.Length; i++) for (var i = 0; i < points.Length; i++)
{ {
@@ -26,8 +26,6 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>(); public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>(); public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>(); public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
public DbSet<CourseGroup> CourseGroups => Set<CourseGroup>();
public DbSet<CourseGroupCourse> CourseGroupCourses => Set<CourseGroupCourse>();
public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>(); public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>();
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>(); public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>(); public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
@@ -35,14 +33,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<TeacherCourseApplication>(); Set<TeacherCourseApplication>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>(); public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>(); public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<PublishedScheduleOccurrence> PublishedScheduleOccurrences => Set<PublishedScheduleOccurrence>();
public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>(); public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>();
public DbSet<TeachingTaskScheduleConstraint> TeachingTaskScheduleConstraints => public DbSet<TeachingTaskScheduleConstraint> TeachingTaskScheduleConstraints =>
Set<TeachingTaskScheduleConstraint>(); Set<TeachingTaskScheduleConstraint>();
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms => public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
Set<TeachingTaskAllowedClassroom>(); Set<TeachingTaskAllowedClassroom>();
public DbSet<TeachingTaskAllowedExperimentClassroom> TeachingTaskAllowedExperimentClassrooms =>
Set<TeachingTaskAllowedExperimentClassroom>();
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs => public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
Set<AutomaticScheduleJob>(); Set<AutomaticScheduleJob>();
public DbSet<SchedulePublishJob> SchedulePublishJobs => public DbSet<SchedulePublishJob> SchedulePublishJobs =>
@@ -516,14 +511,6 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.WithMany() .WithMany()
.HasForeignKey(x => x.RequiredBuildingId) .HasForeignKey(x => x.RequiredBuildingId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ExperimentRequiredCampus)
.WithMany()
.HasForeignKey(x => x.ExperimentRequiredCampusId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ExperimentRequiredBuilding)
.WithMany()
.HasForeignKey(x => x.ExperimentRequiredBuildingId)
.OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<TeachingTaskAllowedClassroom>(entity => builder.Entity<TeachingTaskAllowedClassroom>(entity =>
@@ -543,57 +530,6 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<CourseGroup>(entity =>
{
entity.Property(x => x.Code).HasMaxLength(30);
entity.Property(x => x.Name).HasMaxLength(100);
entity.Property(x => x.Description).HasMaxLength(500);
entity.HasIndex(x => x.Code).IsUnique();
});
builder.Entity<CourseGroupCourse>(entity =>
{
entity.HasIndex(x => new { x.CourseGroupId, x.CourseId }).IsUnique();
entity.HasOne(x => x.CourseGroup)
.WithMany(x => x.Courses)
.HasForeignKey(x => x.CourseGroupId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Course)
.WithMany()
.HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<PublishedScheduleOccurrence>(entity =>
{
entity.HasIndex(x => new { x.AcademicTermId, x.TeachingTaskId, x.Week });
entity.HasIndex(x => new
{
x.AcademicTermId,
x.Week,
x.DayOfWeek,
x.StartPeriod,
x.ClassroomId
});
entity.HasIndex(x => new { x.SchedulePlanId, x.ClassroomId, x.Week, x.DayOfWeek, x.StartPeriod });
entity.HasIndex(x => new { x.ScheduleEntryId, x.Week }).IsUnique();
entity.HasOne(x => x.ScheduleEntry).WithMany().HasForeignKey(x => x.ScheduleEntryId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany().HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany().HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<TeachingTaskAllowedExperimentClassroom>(entity =>
{
entity.HasKey(x => new { x.TeachingTaskScheduleConstraintId, x.ClassroomId });
entity.HasOne(x => x.TeachingTaskScheduleConstraint)
.WithMany(x => x.AllowedExperimentClassrooms)
.HasForeignKey(x => x.TeachingTaskScheduleConstraintId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Classroom)
.WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AutomaticScheduleJob>(entity => builder.Entity<AutomaticScheduleJob>(entity =>
{ {
entity.Property(x => x.ErrorMessage).HasMaxLength(2000); entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
@@ -92,12 +92,6 @@ public sealed class DevelopmentSqliteMigrator(
"20260809_48_teaching_task_grade_analytics"; "20260809_48_teaching_task_grade_analytics";
private const string SwaggerDocumentationSettingMigration = private const string SwaggerDocumentationSettingMigration =
"20260809_49_swagger_documentation_setting"; "20260809_49_swagger_documentation_setting";
private const string ExperimentClassroomConstraintsMigration =
"20260809_50_experiment_classroom_constraints";
private const string SeparateExperimentClassroomScopeMigration =
"20260809_51_separate_experiment_classroom_scope";
private const string ReusableCourseGroupsMigration =
"20260809_52_reusable_course_groups";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -685,32 +679,6 @@ public sealed class DevelopmentSqliteMigrator(
SwaggerDocumentationSettingMigration, SwaggerDocumentationSettingMigration,
swaggerSettingsExist ? [] : SwaggerDocumentationSettingStatements, swaggerSettingsExist ? [] : SwaggerDocumentationSettingStatements,
cancellationToken); cancellationToken);
var experimentClassroomConstraintsExist = await db.Database
.SqlQueryRaw<int>(
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'TeachingTaskAllowedExperimentClassrooms'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExperimentClassroomConstraintsMigration,
experimentClassroomConstraintsExist ? [] : ExperimentClassroomConstraintStatements,
cancellationToken);
var experimentScopeColumns = (await db.Database.SqlQueryRaw<string>(
"SELECT name AS \"Value\" FROM pragma_table_info('TeachingTaskScheduleConstraints')")
.ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
await ApplyMigrationAsync(
SeparateExperimentClassroomScopeMigration,
experimentScopeColumns.Contains("ExperimentRequiredCampusId")
? []
: SeparateExperimentClassroomScopeStatements,
cancellationToken);
var courseGroupsExist = await db.Database
.SqlQueryRaw<int>(
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseGroups'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ReusableCourseGroupsMigration,
courseGroupsExist ? [] : ReusableCourseGroupsStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -2972,82 +2940,4 @@ public sealed class DevelopmentSqliteMigrator(
ON "SystemFeatureSettings" ("Key"); ON "SystemFeatureSettings" ("Key");
""" """
]; ];
private static readonly string[] ExperimentClassroomConstraintStatements =
[
"""
CREATE TABLE "TeachingTaskAllowedExperimentClassrooms" (
"TeachingTaskScheduleConstraintId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL,
CONSTRAINT "PK_TeachingTaskAllowedExperimentClassrooms"
PRIMARY KEY ("TeachingTaskScheduleConstraintId", "ClassroomId"),
CONSTRAINT "FK_TeachingTaskAllowedExperimentClassrooms_Constraints"
FOREIGN KEY ("TeachingTaskScheduleConstraintId")
REFERENCES "TeachingTaskScheduleConstraints" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_TeachingTaskAllowedExperimentClassrooms_Classrooms"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX "IX_TeachingTaskAllowedExperimentClassrooms_ClassroomId"
ON "TeachingTaskAllowedExperimentClassrooms" ("ClassroomId");
"""
];
private static readonly string[] SeparateExperimentClassroomScopeStatements =
[
"""
ALTER TABLE "TeachingTaskScheduleConstraints"
ADD COLUMN "ExperimentRequiredCampusId" TEXT NULL;
""",
"""
ALTER TABLE "TeachingTaskScheduleConstraints"
ADD COLUMN "ExperimentRequiredBuildingId" TEXT NULL;
""",
"""
CREATE INDEX "IX_TeachingTaskScheduleConstraints_ExperimentRequiredCampusId"
ON "TeachingTaskScheduleConstraints" ("ExperimentRequiredCampusId");
""",
"""
CREATE INDEX "IX_TeachingTaskScheduleConstraints_ExperimentRequiredBuildingId"
ON "TeachingTaskScheduleConstraints" ("ExperimentRequiredBuildingId");
"""
];
private static readonly string[] ReusableCourseGroupsStatements =
[
"""
CREATE TABLE "CourseGroups" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseGroups" PRIMARY KEY,
"Code" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Description" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""",
"""
CREATE UNIQUE INDEX "IX_CourseGroups_Code" ON "CourseGroups" ("Code");
""",
"""
CREATE TABLE "CourseGroupCourses" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseGroupCourses" PRIMARY KEY,
"CourseGroupId" TEXT NOT NULL,
"CourseId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CourseGroupCourses_CourseGroups_CourseGroupId"
FOREIGN KEY ("CourseGroupId") REFERENCES "CourseGroups" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_CourseGroupCourses_Courses_CourseId"
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX "IX_CourseGroupCourses_CourseGroupId_CourseId"
ON "CourseGroupCourses" ("CourseGroupId", "CourseId");
""",
"""
CREATE INDEX "IX_CourseGroupCourses_CourseId" ON "CourseGroupCourses" ("CourseId");
"""
];
} }
@@ -1,52 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AddExperimentClassroomConstraints : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TeachingTaskAllowedExperimentClassrooms",
columns: table => new
{
TeachingTaskScheduleConstraintId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeachingTaskAllowedExperimentClassrooms", x => new { x.TeachingTaskScheduleConstraintId, x.ClassroomId });
table.ForeignKey(
name: "FK_TeachingTaskAllowedExperimentClassrooms_Classrooms_Classroom~",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskAllowedExperimentClassrooms_TeachingTaskSchedule~",
column: x => x.TeachingTaskScheduleConstraintId,
principalTable: "TeachingTaskScheduleConstraints",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskAllowedExperimentClassrooms_ClassroomId",
table: "TeachingTaskAllowedExperimentClassrooms",
column: "ClassroomId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeachingTaskAllowedExperimentClassrooms");
}
}
}
@@ -1,81 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class SeparateExperimentClassroomScope : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ExperimentRequiredBuildingId",
table: "TeachingTaskScheduleConstraints",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "ExperimentRequiredCampusId",
table: "TeachingTaskScheduleConstraints",
type: "char(36)",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskScheduleConstraints_ExperimentRequiredBuildingId",
table: "TeachingTaskScheduleConstraints",
column: "ExperimentRequiredBuildingId");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskScheduleConstraints_ExperimentRequiredCampusId",
table: "TeachingTaskScheduleConstraints",
column: "ExperimentRequiredCampusId");
migrationBuilder.AddForeignKey(
name: "FK_TeachingTaskScheduleConstraints_Buildings_ExperimentRequired~",
table: "TeachingTaskScheduleConstraints",
column: "ExperimentRequiredBuildingId",
principalTable: "Buildings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_TeachingTaskScheduleConstraints_Campuses_ExperimentRequiredC~",
table: "TeachingTaskScheduleConstraints",
column: "ExperimentRequiredCampusId",
principalTable: "Campuses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_TeachingTaskScheduleConstraints_Buildings_ExperimentRequired~",
table: "TeachingTaskScheduleConstraints");
migrationBuilder.DropForeignKey(
name: "FK_TeachingTaskScheduleConstraints_Campuses_ExperimentRequiredC~",
table: "TeachingTaskScheduleConstraints");
migrationBuilder.DropIndex(
name: "IX_TeachingTaskScheduleConstraints_ExperimentRequiredBuildingId",
table: "TeachingTaskScheduleConstraints");
migrationBuilder.DropIndex(
name: "IX_TeachingTaskScheduleConstraints_ExperimentRequiredCampusId",
table: "TeachingTaskScheduleConstraints");
migrationBuilder.DropColumn(
name: "ExperimentRequiredBuildingId",
table: "TeachingTaskScheduleConstraints");
migrationBuilder.DropColumn(
name: "ExperimentRequiredCampusId",
table: "TeachingTaskScheduleConstraints");
}
}
}
@@ -1,90 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class PublishedTimetableOccurrences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PublishedScheduleOccurrences",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
SchedulePlanId = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
ScheduleEntryId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: true),
Week = table.Column<int>(type: "int", nullable: false),
DayOfWeek = table.Column<int>(type: "int", nullable: false),
StartPeriod = table.Column<int>(type: "int", nullable: false),
PeriodCount = table.Column<int>(type: "int", nullable: false),
Kind = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PublishedScheduleOccurrences", x => x.Id);
table.ForeignKey(
name: "FK_PublishedScheduleOccurrences_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_PublishedScheduleOccurrences_ScheduleEntries_ScheduleEntryId",
column: x => x.ScheduleEntryId,
principalTable: "ScheduleEntries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PublishedScheduleOccurrences_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_PublishedScheduleOccurrences_AcademicTermId_TeachingTaskId_W~",
table: "PublishedScheduleOccurrences",
columns: new[] { "AcademicTermId", "TeachingTaskId", "Week" });
migrationBuilder.CreateIndex(
name: "IX_PublishedScheduleOccurrences_ClassroomId",
table: "PublishedScheduleOccurrences",
column: "ClassroomId");
migrationBuilder.CreateIndex(
name: "IX_PublishedScheduleOccurrences_ScheduleEntryId_Week",
table: "PublishedScheduleOccurrences",
columns: new[] { "ScheduleEntryId", "Week" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PublishedScheduleOccurrences_SchedulePlanId_ClassroomId_Week~",
table: "PublishedScheduleOccurrences",
columns: new[] { "SchedulePlanId", "ClassroomId", "Week", "DayOfWeek", "StartPeriod" });
migrationBuilder.CreateIndex(
name: "IX_PublishedScheduleOccurrences_TeachingTaskId",
table: "PublishedScheduleOccurrences",
column: "TeachingTaskId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PublishedScheduleOccurrences");
}
}
}
@@ -1,27 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class OptimizePublishedTimetableOccurrenceLookup : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_PublishedScheduleOccurrences_AcademicTermId_Week_DayOfWeek_S~",
table: "PublishedScheduleOccurrences",
columns: new[] { "AcademicTermId", "Week", "DayOfWeek", "StartPeriod", "ClassroomId" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_PublishedScheduleOccurrences_AcademicTermId_Week_DayOfWeek_S~",
table: "PublishedScheduleOccurrences");
}
}
}
@@ -1,87 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AddReusableCourseGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CourseGroups",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Code = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
Description = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseGroups", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "CourseGroupCourses",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CourseGroupId = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseGroupCourses", x => x.Id);
table.ForeignKey(
name: "FK_CourseGroupCourses_CourseGroups_CourseGroupId",
column: x => x.CourseGroupId,
principalTable: "CourseGroups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CourseGroupCourses_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CourseGroupCourses_CourseGroupId_CourseId",
table: "CourseGroupCourses",
columns: new[] { "CourseGroupId", "CourseId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CourseGroupCourses_CourseId",
table: "CourseGroupCourses",
column: "CourseId");
migrationBuilder.CreateIndex(
name: "IX_CourseGroups_Code",
table: "CourseGroups",
column: "Code",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CourseGroupCourses");
migrationBuilder.DropTable(
name: "CourseGroups");
}
}
}
@@ -1098,68 +1098,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("CourseGradeStatisticsRefreshJobs"); b.ToTable("CourseGradeStatisticsRefreshJobs");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("CourseGroups");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroupCourse", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("CourseGroupId")
.HasColumnType("char(36)");
b.Property<Guid>("CourseId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("CourseId");
b.HasIndex("CourseGroupId", "CourseId")
.IsUnique();
b.ToTable("CourseGroupCourses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -3561,66 +3499,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("OtherExamResults"); b.ToTable("OtherExamResults");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.PublishedScheduleOccurrence", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<Guid?>("ClassroomId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("DayOfWeek")
.HasColumnType("int");
b.Property<int>("Kind")
.HasColumnType("int");
b.Property<int>("PeriodCount")
.HasColumnType("int");
b.Property<Guid>("ScheduleEntryId")
.HasColumnType("char(36)");
b.Property<Guid>("SchedulePlanId")
.HasColumnType("char(36)");
b.Property<int>("StartPeriod")
.HasColumnType("int");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Week")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ClassroomId");
b.HasIndex("TeachingTaskId");
b.HasIndex("ScheduleEntryId", "Week")
.IsUnique();
b.HasIndex("AcademicTermId", "TeachingTaskId", "Week");
b.HasIndex("AcademicTermId", "Week", "DayOfWeek", "StartPeriod", "ClassroomId");
b.HasIndex("SchedulePlanId", "ClassroomId", "Week", "DayOfWeek", "StartPeriod");
b.ToTable("PublishedScheduleOccurrences");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -4224,21 +4102,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("TeachingTaskAllowedClassrooms"); b.ToTable("TeachingTaskAllowedClassrooms");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedExperimentClassroom", b =>
{
b.Property<Guid>("TeachingTaskScheduleConstraintId")
.HasColumnType("char(36)");
b.Property<Guid>("ClassroomId")
.HasColumnType("char(36)");
b.HasKey("TeachingTaskScheduleConstraintId", "ClassroomId");
b.HasIndex("ClassroomId");
b.ToTable("TeachingTaskAllowedExperimentClassrooms");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
{ {
b.Property<Guid>("TeachingTaskId") b.Property<Guid>("TeachingTaskId")
@@ -4394,12 +4257,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<int?>("EarliestPeriod") b.Property<int?>("EarliestPeriod")
.HasColumnType("int"); .HasColumnType("int");
b.Property<Guid?>("ExperimentRequiredBuildingId")
.HasColumnType("char(36)");
b.Property<Guid?>("ExperimentRequiredCampusId")
.HasColumnType("char(36)");
b.Property<int?>("LatestPeriod") b.Property<int?>("LatestPeriod")
.HasColumnType("int"); .HasColumnType("int");
@@ -4420,10 +4277,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ExperimentRequiredBuildingId");
b.HasIndex("ExperimentRequiredCampusId");
b.HasIndex("RequiredBuildingId"); b.HasIndex("RequiredBuildingId");
b.HasIndex("RequiredCampusId"); b.HasIndex("RequiredCampusId");
@@ -5314,25 +5167,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroupCourse", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.CourseGroup", "CourseGroup")
.WithMany("Courses")
.HasForeignKey("CourseGroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
.WithMany()
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Course");
b.Navigation("CourseGroup");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
@@ -6187,32 +6021,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Student"); b.Navigation("Student");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.PublishedScheduleOccurrence", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany()
.HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Jiaowu.Api.Domain.Academic.ScheduleEntry", "ScheduleEntry")
.WithMany()
.HasForeignKey("ScheduleEntryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Classroom");
b.Navigation("ScheduleEntry");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
@@ -6390,25 +6198,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("TeachingTaskScheduleConstraint"); b.Navigation("TeachingTaskScheduleConstraint");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedExperimentClassroom", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany()
.HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", "TeachingTaskScheduleConstraint")
.WithMany("AllowedExperimentClassrooms")
.HasForeignKey("TeachingTaskScheduleConstraintId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Classroom");
b.Navigation("TeachingTaskScheduleConstraint");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
@@ -6472,16 +6261,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "ExperimentRequiredBuilding")
.WithMany()
.HasForeignKey("ExperimentRequiredBuildingId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "ExperimentRequiredCampus")
.WithMany()
.HasForeignKey("ExperimentRequiredCampusId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding")
.WithMany() .WithMany()
.HasForeignKey("RequiredBuildingId") .HasForeignKey("RequiredBuildingId")
@@ -6498,10 +6277,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("ExperimentRequiredBuilding");
b.Navigation("ExperimentRequiredCampus");
b.Navigation("RequiredBuilding"); b.Navigation("RequiredBuilding");
b.Navigation("RequiredCampus"); b.Navigation("RequiredCampus");
@@ -6631,11 +6406,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("RequiredByCourses"); b.Navigation("RequiredByCourses");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroup", b =>
{
b.Navigation("Courses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{ {
b.Navigation("Enrollments"); b.Navigation("Enrollments");
@@ -6815,8 +6585,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{ {
b.Navigation("AllowedClassrooms"); b.Navigation("AllowedClassrooms");
b.Navigation("AllowedExperimentClassrooms");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
@@ -44,7 +44,6 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
var classrooms = await db.Classrooms.AsNoTracking() var classrooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled) .Where(x => x.IsEnabled)
@@ -254,15 +253,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
x.TeachingTaskId == task.Id && x.DayOfWeek == day); x.TeachingTaskId == task.Id && x.DayOfWeek == day);
var dayLoad = entries.Count(x => x.DayOfWeek == day); var dayLoad = entries.Count(x => x.DayOfWeek == day);
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity); var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
var experimentGeneralClassroomPenalty =
kind == ScheduleEntryKind.Experiment &&
constraint?.AllowedExperimentVenueNatures == 0 &&
room?.TeachingVenueNature == TeachingVenueNature.GeneralClassroom
? 100_000
: 0;
var score = sameTaskDay * 1000 + dayLoad * 10 + start + var score = sameTaskDay * 1000 + dayLoad * 10 + start +
roomWaste / 10 + startWeek + roomWaste / 10 + startWeek;
experimentGeneralClassroomPenalty;
candidates.Add((proposed, score)); candidates.Add((proposed, score));
} }
} }
@@ -287,9 +279,6 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
var allowedRoomIds = constraint?.AllowedClassrooms var allowedRoomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId) .Select(x => x.ClassroomId)
.ToHashSet() ?? []; .ToHashSet() ?? [];
var allowedExperimentRoomIds = constraint?.AllowedExperimentClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
var minimumCapacity = Math.Max( var minimumCapacity = Math.Max(
task.Capacity, task.Capacity,
task.Classes.Sum(x => task.Classes.Sum(x =>
@@ -297,25 +286,16 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
student.Status == StudentStatus.Active) ?? 0)); student.Status == StudentStatus.Active) ?? 0));
return classrooms.Where(room => return classrooms.Where(room =>
room.Capacity >= minimumCapacity && room.Capacity >= minimumCapacity &&
(kind == ScheduleEntryKind.Experiment || (constraint?.RequiredCampusId is not Guid requiredCampusId ||
constraint?.RequiredCampusId is not Guid requiredCampusId ||
room.Building!.CampusId == requiredCampusId) && room.Building!.CampusId == requiredCampusId) &&
(kind == ScheduleEntryKind.Experiment || (constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
room.BuildingId == requiredBuildingId) && room.BuildingId == requiredBuildingId) &&
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)) &&
(kind != ScheduleEntryKind.Experiment || (kind != ScheduleEntryKind.Experiment ||
constraint?.ExperimentRequiredCampusId is not Guid experimentCampusId || TeachingVenueNatureRules.SupportsExperiment(room.TeachingVenueNature)) &&
room.Building!.CampusId == experimentCampusId) &&
(kind != ScheduleEntryKind.Experiment ||
constraint?.ExperimentRequiredBuildingId is not Guid experimentBuildingId ||
room.BuildingId == experimentBuildingId) &&
(kind == ScheduleEntryKind.Experiment ||
allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)) &&
(kind != ScheduleEntryKind.Experiment || constraint is null || (kind != ScheduleEntryKind.Experiment || constraint is null ||
constraint.AllowedExperimentVenueNatures == 0 || constraint.AllowedExperimentVenueNatures == 0 ||
(room.TeachingVenueNature & constraint.AllowedExperimentVenueNatures) != 0) && (room.TeachingVenueNature & constraint.AllowedExperimentVenueNatures) != 0))
(kind != ScheduleEntryKind.Experiment || allowedExperimentRoomIds.Count == 0 ||
allowedExperimentRoomIds.Contains(room.Id)))
.ToList(); .ToList();
} }
@@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Jiaowu.Api.Infrastructure.Teaching; using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -74,8 +73,6 @@ public sealed class SchedulePublishJobProcessor(
foreach (var oldPlan in previous) foreach (var oldPlan in previous)
oldPlan.Status = SchedulePlanStatus.Archived; oldPlan.Status = SchedulePlanStatus.Archived;
await new PublishedTimetableProjectionService(db)
.RebuildAsync(publishPlan, stoppingToken);
publishPlan.Status = SchedulePlanStatus.Published; publishPlan.Status = SchedulePlanStatus.Published;
publishPlan.PublishedAt = DateTime.UtcNow; publishPlan.PublishedAt = DateTime.UtcNow;
publishJob.Status = SchedulePublishJobStatus.Succeeded; publishJob.Status = SchedulePublishJobStatus.Succeeded;
@@ -170,7 +167,6 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
foreach (var entry in plan.Entries) foreach (var entry in plan.Entries)
@@ -274,40 +270,21 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
{ {
if (classroom is null || !classroom.IsEnabled) if (classroom is null || !classroom.IsEnabled)
Fail(entry, "所选教室不存在或已停用"); Fail(entry, "所选教室不存在或已停用");
if (entry.Kind != ScheduleEntryKind.Experiment && if (entry.Kind == ScheduleEntryKind.Experiment &&
constraint?.RequiredCampusId is Guid campusId && !IsExperimentRoom(classroom.RoomType))
Fail(entry, $"实验课不能安排在“{classroom.RoomType}”类型的场地");
if (constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId) classroom.Building!.CampusId != campusId)
Fail(entry, "所选教室不在指定校区"); Fail(entry, "所选教室不在指定校区");
if (entry.Kind != ScheduleEntryKind.Experiment && if (constraint?.RequiredBuildingId is Guid buildingId &&
constraint?.RequiredBuildingId is Guid buildingId &&
classroom.BuildingId != buildingId) classroom.BuildingId != buildingId)
Fail(entry, "所选教室不在指定教学楼"); Fail(entry, "所选教室不在指定教学楼");
if (entry.Kind == ScheduleEntryKind.Experiment &&
constraint?.ExperimentRequiredCampusId is Guid experimentCampusId &&
classroom.Building!.CampusId != experimentCampusId)
Fail(entry, "所选场地不在实验课指定校区");
if (entry.Kind == ScheduleEntryKind.Experiment &&
constraint?.ExperimentRequiredBuildingId is Guid experimentBuildingId &&
classroom.BuildingId != experimentBuildingId)
Fail(entry, "所选场地不在实验课指定教学楼");
var allowedClassroomIds = constraint?.AllowedClassrooms var allowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId) .Select(x => x.ClassroomId)
.ToHashSet() ?? []; .ToHashSet() ?? [];
if (entry.Kind != ScheduleEntryKind.Experiment && allowedClassroomIds.Count > 0 && if (allowedClassroomIds.Count > 0 &&
!allowedClassroomIds.Contains(classroom.Id)) !allowedClassroomIds.Contains(classroom.Id))
Fail(entry, "所选教室不在指定教室范围内"); Fail(entry, "所选教室不在指定教室范围内");
var allowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
if (entry.Kind == ScheduleEntryKind.Experiment &&
allowedExperimentClassroomIds.Count > 0 &&
!allowedExperimentClassroomIds.Contains(classroom.Id))
Fail(entry, "所选场地不在实验课指定场地范围内");
if (entry.Kind == ScheduleEntryKind.Experiment &&
constraint?.AllowedExperimentVenueNatures is { } allowedNatures &&
allowedNatures != 0 &&
(classroom.TeachingVenueNature & allowedNatures) == 0)
Fail(entry, "所选场地不在实验课允许的场地性质范围内");
} }
var studentCount = task.Classes.Sum(x => var studentCount = task.Classes.Sum(x =>
@@ -328,6 +305,12 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
.Select(int.Parse) .Select(int.Parse)
.ToHashSet(); .ToHashSet();
private static bool IsExperimentRoom(string roomType) =>
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
[DoesNotReturn] [DoesNotReturn]
private static void Fail(ScheduleEntry entry, string message) private static void Fail(ScheduleEntry entry, string message)
{ {
@@ -17,33 +17,32 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
var occupiedIds = new HashSet<Guid>(); var occupiedIds = new HashSet<Guid>();
var (week, dayOfWeek) = ResolveTeachingWeek(term, reservationDate); var (week, dayOfWeek) = ResolveTeachingWeek(term, reservationDate);
var hasProjection = await db.PublishedScheduleOccurrences.AsNoTracking()
.AnyAsync(entry => entry.AcademicTermId == term.Id, cancellationToken);
if (hasProjection)
{
var projectedRoomIds = await db.PublishedScheduleOccurrences.AsNoTracking()
.Where(entry => entry.AcademicTermId == term.Id && entry.Week == week &&
entry.DayOfWeek == dayOfWeek && entry.ClassroomId.HasValue &&
entry.StartPeriod < startPeriod + periodCount &&
startPeriod < entry.StartPeriod + entry.PeriodCount)
.Select(entry => entry.ClassroomId!.Value)
.ToListAsync(cancellationToken);
occupiedIds.UnionWith(projectedRoomIds);
}
else
{
var scheduleEntries = await db.ScheduleEntries.AsNoTracking() var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
.Where(entry => entry.ClassroomId.HasValue && .Where(entry =>
entry.ClassroomId.HasValue &&
entry.SchedulePlan!.AcademicTermId == term.Id && entry.SchedulePlan!.AcademicTermId == term.Id &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published && entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
entry.DayOfWeek == dayOfWeek && entry.StartWeek <= week && entry.DayOfWeek == dayOfWeek &&
entry.EndWeek >= week && entry.StartPeriod < startPeriod + periodCount && entry.StartWeek <= week &&
entry.EndWeek >= week &&
entry.StartPeriod < startPeriod + periodCount &&
startPeriod < entry.StartPeriod + entry.PeriodCount) startPeriod < entry.StartPeriod + entry.PeriodCount)
.Select(entry => new { entry.ClassroomId, entry.WeekPattern, entry.StartPeriod, entry.PeriodCount }) .Select(entry => new
{
entry.ClassroomId,
entry.WeekPattern,
entry.StartPeriod,
entry.PeriodCount
})
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
foreach (var entry in scheduleEntries.Where(entry => foreach (var entry in scheduleEntries.Where(entry =>
FreeClassroomRules.MatchesWeek(entry.WeekPattern, week) && FreeClassroomRules.MatchesWeek(entry.WeekPattern, week) &&
FreeClassroomRules.PeriodsOverlap(startPeriod, periodCount, entry.StartPeriod, entry.PeriodCount))) FreeClassroomRules.PeriodsOverlap(
startPeriod,
periodCount,
entry.StartPeriod,
entry.PeriodCount)))
{
occupiedIds.Add(entry.ClassroomId!.Value); occupiedIds.Add(entry.ClassroomId!.Value);
} }
@@ -1,63 +0,0 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Timetables;
public sealed class PublishedTimetableProjectionService(AppDbContext db)
{
private const int WriteBatchSize = 2_000;
public async Task RebuildPublishedPlansForTaskAsync(Guid teachingTaskId, CancellationToken cancellationToken)
{
var plans = await db.SchedulePlans
.Where(plan => plan.Status == SchedulePlanStatus.Published &&
plan.Entries.Any(entry => entry.TeachingTaskId == teachingTaskId))
.Include(plan => plan.Entries)
.ToListAsync(cancellationToken);
foreach (var plan in plans)
await RebuildAsync(plan, cancellationToken);
}
public async Task RebuildAsync(SchedulePlan plan, CancellationToken cancellationToken)
{
await db.PublishedScheduleOccurrences
.Where(x => x.SchedulePlanId == plan.Id)
.ExecuteDeleteAsync(cancellationToken);
var rows = new List<PublishedScheduleOccurrence>(WriteBatchSize);
foreach (var entry in plan.Entries)
for (var week = entry.StartWeek; week <= entry.EndWeek; week++)
{
if (entry.WeekPattern == WeekPattern.Odd && week % 2 == 0 ||
entry.WeekPattern == WeekPattern.Even && week % 2 != 0) continue;
rows.Add(new PublishedScheduleOccurrence
{
SchedulePlanId = plan.Id,
AcademicTermId = plan.AcademicTermId,
ScheduleEntryId = entry.Id,
TeachingTaskId = entry.TeachingTaskId,
ClassroomId = entry.ClassroomId,
Week = week,
DayOfWeek = entry.DayOfWeek,
StartPeriod = entry.StartPeriod,
PeriodCount = entry.PeriodCount,
Kind = entry.Kind
});
if (rows.Count == WriteBatchSize)
await WriteBatchAsync(rows, cancellationToken);
}
if (rows.Count > 0)
await WriteBatchAsync(rows, cancellationToken);
}
private async Task WriteBatchAsync(
List<PublishedScheduleOccurrence> rows,
CancellationToken cancellationToken)
{
db.PublishedScheduleOccurrences.AddRange(rows);
await db.SaveChangesAsync(cancellationToken);
foreach (var row in rows)
db.Entry(row).State = EntityState.Detached;
rows.Clear();
}
}
-1
View File
@@ -422,7 +422,6 @@ builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DemoDataSeeder>(); builder.Services.AddScoped<DemoDataSeeder>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>(); builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<TimetableDataService>(); builder.Services.AddScoped<TimetableDataService>();
builder.Services.AddScoped<PublishedTimetableProjectionService>();
builder.Services.AddScoped<AutomaticScheduleGenerator>(); builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services.AddScoped<PersonalCalendarService>(); builder.Services.AddScoped<PersonalCalendarService>();
builder.Services.AddScoped<ClassroomReservationAvailabilityService>(); builder.Services.AddScoped<ClassroomReservationAvailabilityService>();
@@ -205,79 +205,6 @@ public sealed class CourseSelectionsControllerTests
x.Grade == 2026)); x.Grade == 2026));
} }
[Fact]
public async Task Student_options_include_an_administrator_assigned_offering_outside_the_students_class_scope()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var data = await SeedFullOfferingAsync(db);
var originalClass = await db.AdministrativeClasses.SingleAsync();
var otherClass = new AdministrativeClass
{
Code = "CS2026-02",
Name = "计科 2026-2 班",
MajorId = originalClass.MajorId,
Grade = 2026
};
var taskId = await db.CourseSelectionOfferings
.Where(x => x.Id == data.OfferingId)
.Select(x => x.TeachingTaskId)
.SingleAsync();
var task = await db.TeachingTasks
.Include(x => x.Classes)
.SingleAsync(x => x.Id == taskId);
task.Classes.Clear();
task.Classes.Add(new TeachingTaskClass { AdministrativeClassId = otherClass.Id });
task.SchedulingMode = TeachingTaskSchedulingMode.Standard;
var termId = await db.CourseSelectionRounds
.Where(x => x.Id == data.RoundId)
.Select(x => x.AcademicTermId)
.SingleAsync();
var plan = new SchedulePlan
{
AcademicTermId = termId,
Name = "正式课表",
Version = "V1",
Status = SchedulePlanStatus.Published,
PublishedAt = DateTime.UtcNow
};
db.AddRange(otherClass, plan);
await db.SaveChangesAsync();
db.ScheduleEntries.Add(new ScheduleEntry
{
SchedulePlanId = plan.Id,
TeachingTaskId = task.Id,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 16,
WeekPattern = WeekPattern.All
});
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var controller = new CourseSelectionsController(
db,
new StudentDataScope(data.EnrolledUserId));
var result = Assert.IsType<OkObjectResult>(await controller.GetStudentOptions(
data.RoundId,
CancellationToken.None));
var offerings = ReadProperty<IEnumerable<StudentOfferingDto>>(
result.Value, "Offerings");
var assignedOffering = Assert.Single(offerings);
Assert.Equal(data.OfferingId, assignedOffering.Id);
Assert.Equal(CourseEnrollmentStatus.Enrolled, assignedOffering.EnrollmentStatus);
Assert.Single(assignedOffering.Schedules);
}
private static async Task<SeededSelection> SeedFullOfferingAsync(AppDbContext db) private static async Task<SeededSelection> SeedFullOfferingAsync(AppDbContext db)
{ {
var college = new College { Code = "CS", Name = "计算机学院" }; var college = new College { Code = "CS", Name = "计算机学院" };
@@ -431,14 +358,6 @@ public sealed class CourseSelectionsControllerTests
return Assert.IsType<int>(property.GetValue(value)); return Assert.IsType<int>(property.GetValue(value));
} }
private static T ReadProperty<T>(object? value, string propertyName)
{
Assert.NotNull(value);
var property = value.GetType().GetProperty(propertyName);
Assert.NotNull(property);
return Assert.IsAssignableFrom<T>(property.GetValue(value));
}
private static Student CreateStudent( private static Student CreateStudent(
string number, string number,
string name, string name,
@@ -85,19 +85,13 @@ public sealed class ScheduleSettingsControllerTests
[classroom.Id], [classroom.Id],
false, false,
null, null,
null, null),
UpdateExperimentClassroomScope: true,
AllowedExperimentVenueNatures: TeachingVenueNature.Laboratory,
AllowedExperimentClassroomIds: [classroom.Id],
ExperimentRequiredCampusId: campus.Id,
ExperimentRequiredBuildingId: building.Id),
CancellationToken.None); CancellationToken.None);
Assert.IsType<OkObjectResult>(result); Assert.IsType<OkObjectResult>(result);
db.ChangeTracker.Clear(); db.ChangeTracker.Clear();
var constraints = await db.TeachingTaskScheduleConstraints var constraints = await db.TeachingTaskScheduleConstraints
.Include(item => item.AllowedClassrooms) .Include(item => item.AllowedClassrooms)
.Include(item => item.AllowedExperimentClassrooms)
.OrderBy(item => item.TeachingTaskId) .OrderBy(item => item.TeachingTaskId)
.ToListAsync(); .ToListAsync();
Assert.Equal(2, constraints.Count); Assert.Equal(2, constraints.Count);
@@ -109,11 +103,6 @@ public sealed class ScheduleSettingsControllerTests
Assert.Equal( Assert.Equal(
classroom.Id, classroom.Id,
Assert.Single(constraint.AllowedClassrooms).ClassroomId); Assert.Single(constraint.AllowedClassrooms).ClassroomId);
Assert.Equal(campus.Id, constraint.ExperimentRequiredCampusId);
Assert.Equal(building.Id, constraint.ExperimentRequiredBuildingId);
Assert.Equal(
classroom.Id,
Assert.Single(constraint.AllowedExperimentClassrooms).ClassroomId);
}); });
} }
+3 -3
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<JiaowuBackendVersion>2.3.2-beta.4</JiaowuBackendVersion> <JiaowuBackendVersion>2.3.2-beta.2</JiaowuBackendVersion>
<JiaowuFrontendVersion>2.3.2-beta.4</JiaowuFrontendVersion> <JiaowuFrontendVersion>2.3.2-beta.2</JiaowuFrontendVersion>
<JiaowuSwaggerVersion>2.3.2-beta.4</JiaowuSwaggerVersion> <JiaowuSwaggerVersion>2.3.2-beta.2</JiaowuSwaggerVersion>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
-40
View File
@@ -431,10 +431,6 @@ button { cursor: pointer; }
.module-toolbar b, .module-toolbar span { display: block; } .module-toolbar b, .module-toolbar span { display: block; }
.module-toolbar b { font-size: 15px; } .module-toolbar b { font-size: 15px; }
.module-toolbar span { margin-top: 4px; color: var(--muted); font-size: 10px; } .module-toolbar span { margin-top: 4px; color: var(--muted); font-size: 10px; }
.curriculum-view-actions { display: flex; align-items: center; gap: 10px; }
.curriculum-view-actions .el-button + .el-button { margin-left: 0; }
.page-intro-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.page-intro-actions .el-button + .el-button { margin-left: 0; }
.curriculum-module { margin-top: 12px; border: 1px solid var(--line); } .curriculum-module { margin-top: 12px; border: 1px solid var(--line); }
.curriculum-module > header { min-height: 72px; padding: 13px 16px; display: flex; align-items: center; gap: 18px; background: #f8fafb; border-bottom: 1px solid var(--line); } .curriculum-module > header { min-height: 72px; padding: 13px 16px; display: flex; align-items: center; gap: 18px; background: #f8fafb; border-bottom: 1px solid var(--line); }
.curriculum-module > header > div:first-child { min-width: 160px; } .curriculum-module > header > div:first-child { min-width: 160px; }
@@ -443,34 +439,6 @@ button { cursor: pointer; }
.curriculum-module > header p { margin: 0 auto 0 0; color: var(--muted); font-size: 11px; } .curriculum-module > header p { margin: 0 auto 0 0; color: var(--muted); font-size: 11px; }
.curriculum-module > header > div:last-child { display: flex; align-items: center; white-space: nowrap; } .curriculum-module > header > div:last-child { display: flex; align-items: center; white-space: nowrap; }
.curriculum-course-table { min-height: 80px; } .curriculum-course-table { min-height: 80px; }
.semester-view { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.semester-card { min-width: 0; border: 1px solid var(--line); background: #fff; }
.semester-card > header { min-height: 70px; padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; background: linear-gradient(135deg, #f4faf9, #f8fafb); border-bottom: 1px solid var(--line); }
.semester-card > header span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .12em; }
.semester-card h4 { margin: 7px 0 0; font-size: 15px; }
.semester-card > header > b { color: #1d6b68; font: 700 18px/1 Consolas, monospace; }
.semester-summary { display: flex; gap: 14px; padding: 9px 16px; color: var(--muted); font-size: 11px; border-bottom: 1px solid var(--line); }
.semester-summary span + span { padding-left: 14px; border-left: 1px solid var(--line); }
.semester-courses { margin: 0; padding: 0; list-style: none; }
.semester-courses li { min-height: 68px; padding: 11px 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid #edf0f2; }
.semester-courses li:last-child { border-bottom: none; }
.semester-courses li > div { min-width: 0; display: grid; gap: 3px; }
.semester-courses li span { color: var(--teal); font-size: 10px; }
.semester-courses li b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.semester-courses li small { color: var(--muted); font-size: 10px; }
.semester-courses li > strong { flex: none; font: 700 15px/1 Consolas, monospace; }
.semester-courses li > strong small { margin-left: 3px; font: 400 9px/1 inherit; }
.semester-empty { margin: 0; padding: 25px 16px; color: var(--muted); font-size: 11px; text-align: center; }
.course-group-manager { min-height: 420px; display: grid; grid-template-columns: 230px minmax(0, 1fr); border: 1px solid var(--line); }
.course-group-manager > aside { padding: 12px; display: grid; align-content: start; gap: 6px; border-right: 1px solid var(--line); background: #fafbfc; }
.course-group-manager > aside > button:not(.el-button) { padding: 11px; display: grid; gap: 4px; text-align: left; border: 1px solid transparent; background: transparent; }
.course-group-manager > aside > button.active { border-color: #b8d9d5; background: #edf8f6; }
.course-group-manager > aside span { color: var(--teal); font: 700 9px/1 Consolas, monospace; }
.course-group-manager > aside b { font-size: 13px; }
.course-group-manager > aside small { color: var(--muted); font-size: 10px; }
.course-group-manager > main { min-width: 0; padding: 16px; }
.course-group-actions { margin: 0 0 18px; }
.course-group-add { margin: 18px 0 10px; display: grid; grid-template-columns: auto minmax(200px, 1fr) auto; align-items: center; gap: 10px; }
.task-summary { min-height: 82px; padding: 15px 22px; display: flex; align-items: center; gap: 28px; color: white; background: linear-gradient(108deg, #17295a, #263f80); } .task-summary { min-height: 82px; padding: 15px 22px; display: flex; align-items: center; gap: 28px; color: white; background: linear-gradient(108deg, #17295a, #263f80); }
.task-summary > div { min-width: 170px; display: flex; align-items: baseline; gap: 8px; } .task-summary > div { min-width: 170px; display: flex; align-items: baseline; gap: 8px; }
.task-summary span, .task-summary small { color: #b8c1de; font-size: 10px; } .task-summary span, .task-summary small { color: #b8c1de; font-size: 10px; }
@@ -544,8 +512,6 @@ button { cursor: pointer; }
.constraint-batch-form { margin-top: 16px; display: grid; gap: 10px; } .constraint-batch-form { margin-top: 16px; display: grid; gap: 10px; }
.constraint-batch-form > .el-checkbox { padding: 8px 10px; background: #f7f9fb; border-left: 3px solid #ccd8e1; } .constraint-batch-form > .el-checkbox { padding: 8px 10px; background: #f7f9fb; border-left: 3px solid #ccd8e1; }
.batch-classroom-scope { padding: 12px 14px 2px; display: grid; gap: 12px; border: 1px solid #d8e2e8; background: #fbfcfd; } .batch-classroom-scope { padding: 12px 14px 2px; display: grid; gap: 12px; border: 1px solid #d8e2e8; background: #fbfcfd; }
.experiment-classroom-scope { margin-bottom: 18px; padding: 12px 14px 2px; border: 1px solid #d8e2e8; background: #fbfcfd; }
.experiment-classroom-scope__title { margin-bottom: 12px; color: #34435e; font-size: 13px; font-weight: 650; }
.constraint-list article { min-width: 0; padding: 13px 15px; display: grid; grid-template-columns: minmax(230px, 1fr) minmax(170px, auto) auto; align-items: center; gap: 14px; border: 1px solid var(--line); background: #fff; } .constraint-list article { min-width: 0; padding: 13px 15px; display: grid; grid-template-columns: minmax(230px, 1fr) minmax(170px, auto) auto; align-items: center; gap: 14px; border: 1px solid var(--line); background: #fff; }
.constraint-list article > div:first-child { min-width: 0; display: grid; gap: 4px; } .constraint-list article > div:first-child { min-width: 0; display: grid; gap: 4px; }
.constraint-list article span { color: var(--teal); font: 700 9px/1.2 Consolas, monospace; } .constraint-list article span { color: var(--teal); font: 700 9px/1.2 Consolas, monospace; }
@@ -1312,12 +1278,6 @@ button { cursor: pointer; }
.plan-metrics > div:nth-child(-n+2) { border-bottom: 1px solid var(--line); } .plan-metrics > div:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
.curriculum-module > header { align-items: flex-start; flex-wrap: wrap; } .curriculum-module > header { align-items: flex-start; flex-wrap: wrap; }
.curriculum-module > header p { order: 3; width: 100%; } .curriculum-module > header p { order: 3; width: 100%; }
.module-toolbar { align-items: flex-start; flex-direction: column; gap: 12px; }
.curriculum-view-actions { width: 100%; justify-content: space-between; }
.semester-view { grid-template-columns: 1fr; }
.course-group-manager { grid-template-columns: 1fr; }
.course-group-manager > aside { max-height: 220px; overflow-y: auto; border-right: none; border-bottom: 1px solid var(--line); }
.course-group-add { grid-template-columns: 1fr; align-items: stretch; }
.task-summary { align-items: flex-start; flex-direction: column; gap: 12px; } .task-summary { align-items: flex-start; flex-direction: column; gap: 12px; }
.task-summary > div { width: 100%; } .task-summary > div { width: 100%; }
.task-summary p { padding: 12px 0 0; border-left: none; border-top: 1px solid rgba(255,255,255,.16); line-height: 1.6; } .task-summary p { padding: 12px 0 0; border-left: none; border-top: 1px solid rgba(255,255,255,.16); line-height: 1.6; }
+7 -236
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Collection, CopyDocument, Plus, Promotion, Refresh, Search } from '@element-plus/icons-vue' import { CopyDocument, Plus, Promotion, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -14,20 +14,15 @@ const colleges = ref<any[]>([])
const majors = ref<any[]>([]) const majors = ref<any[]>([])
const grades = ref<number[]>([]) const grades = ref<number[]>([])
const courses = ref<any[]>([]) const courses = ref<any[]>([])
const courseGroups = ref<any[]>([])
const planDialog = ref(false) const planDialog = ref(false)
const moduleDialog = ref(false) const moduleDialog = ref(false)
const courseDialog = ref(false) const courseDialog = ref(false)
const cloneDialog = ref(false) const cloneDialog = ref(false)
const courseGroupDialog = ref(false)
const courseGroupImportDialog = ref(false)
const detailView = ref<'structure' | 'semester'>('structure')
const editingPlanId = ref('') const editingPlanId = ref('')
const editingPlanStatus = ref('') const editingPlanStatus = ref('')
const editingModuleId = ref('') const editingModuleId = ref('')
const editingCourseId = ref('') const editingCourseId = ref('')
const activeModuleId = ref('') const activeModuleId = ref('')
const activeCourseGroupId = ref('')
const query = reactive({ const query = reactive({
page: 1, page: 1,
pageSize: 20, pageSize: 20,
@@ -41,9 +36,6 @@ const planForm = reactive<Record<string, any>>({})
const moduleForm = reactive<Record<string, any>>({}) const moduleForm = reactive<Record<string, any>>({})
const courseForm = reactive<Record<string, any>>({}) const courseForm = reactive<Record<string, any>>({})
const cloneForm = reactive<Record<string, any>>({}) const cloneForm = reactive<Record<string, any>>({})
const courseGroupForm = reactive<Record<string, any>>({})
const courseGroupCourseForm = reactive<Record<string, any>>({})
const courseGroupImportForm = reactive<Record<string, any>>({})
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
Draft: '草稿', Draft: '草稿',
@@ -55,9 +47,6 @@ const courseTypeLabels: Record<string, string> = {
Elective: '组内选修', Elective: '组内选修',
} }
const isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false) const isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false)
const canManageCourseGroups = computed(() =>
auth.user?.roles.some((role) => role === 'SuperAdmin' || role === 'AcademicAdmin') ?? false,
)
const availableMajors = computed(() => majors.value) const availableMajors = computed(() => majors.value)
const filteredMajors = computed(() => const filteredMajors = computed(() =>
query.collegeId query.collegeId
@@ -79,46 +68,12 @@ const isDraft = computed(() => selected.value?.status === 'Draft')
const isPublished = computed(() => selected.value?.status === 'Published') const isPublished = computed(() => selected.value?.status === 'Published')
const canEdit = computed(() => isDraft.value || isPublished.value) const canEdit = computed(() => isDraft.value || isPublished.value)
const isEditingPublished = computed(() => editingPlanStatus.value === 'Published') const isEditingPublished = computed(() => editingPlanStatus.value === 'Published')
const activeCourseGroup = computed(() =>
courseGroups.value.find((group) => group.id === activeCourseGroupId.value) ?? null,
)
const importingCourseGroup = computed(() =>
courseGroups.value.find((group) => group.id === courseGroupImportForm.courseGroupId) ?? null,
)
const configuredCredits = computed(() => const configuredCredits = computed(() =>
selected.value?.modules.reduce( selected.value?.modules.reduce(
(sum: number, module: any) => sum + Number(module.requiredCredits), (sum: number, module: any) => sum + Number(module.requiredCredits),
0, 0,
) ?? 0, ) ?? 0,
) )
const semesterGroups = computed(() => {
if (!selected.value) return []
const courses = selected.value.modules.flatMap((module: any) =>
module.courses.map((course: any) => ({
...course,
moduleCode: module.code,
moduleName: module.name,
})),
)
const semesterCount = Number(selected.value.schoolingYears) * 2
return Array.from({ length: semesterCount }, (_, index) => {
const semester = index + 1
const items = courses
.filter((course: any) => Number(course.recommendedSemester) === semester)
.sort((first: any, second: any) =>
first.moduleCode.localeCompare(second.moduleCode) ||
first.courseCode.localeCompare(second.courseCode),
)
return {
semester,
courses: items,
credits: items.reduce((sum: number, course: any) => sum + Number(course.credits), 0),
totalHours: items.reduce((sum: number, course: any) => sum + Number(course.totalHours), 0),
}
})
})
async function loadPlans(keepSelection = true) { async function loadPlans(keepSelection = true) {
loading.value = true loading.value = true
@@ -157,97 +112,6 @@ async function loadDetail(id: string) {
} }
} }
async function loadCourseGroups() {
try {
courseGroups.value = (await http.get('/course-groups')).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openCourseGroups() {
courseGroupDialog.value = true
openCourseGroup(activeCourseGroupId.value || undefined)
}
function openCourseGroup(id?: string) {
activeCourseGroupId.value = id ?? ''
const group = courseGroups.value.find((item) => item.id === id)
Object.assign(courseGroupForm, {
code: group?.code ?? '', name: group?.name ?? '', description: group?.description ?? '',
})
courseGroupCourseForm.courseId = undefined
}
async function saveCourseGroup() {
if (!courseGroupForm.code?.trim() || !courseGroupForm.name?.trim()) {
ElMessage.warning('请填写课程组编码和名称。')
return
}
try {
if (activeCourseGroupId.value) {
await http.put(`/course-groups/${activeCourseGroupId.value}`, courseGroupForm)
} else {
const { data } = await http.post('/course-groups', courseGroupForm)
activeCourseGroupId.value = data.id
}
await loadCourseGroups()
openCourseGroup(activeCourseGroupId.value)
ElMessage.success('课程组已保存')
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function deleteCourseGroup() {
if (!activeCourseGroup.value) return
try {
await ElMessageBox.confirm(`确定删除课程组“${activeCourseGroup.value.name}”吗?`, '删除课程组', { type: 'warning' })
await http.delete(`/course-groups/${activeCourseGroupId.value}`)
await loadCourseGroups()
openCourseGroup()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function addCourseToGroup() {
if (!activeCourseGroupId.value || !courseGroupCourseForm.courseId) return
try {
await http.post(`/course-groups/${activeCourseGroupId.value}/courses`, courseGroupCourseForm)
await loadCourseGroups()
openCourseGroup(activeCourseGroupId.value)
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function removeCourseFromGroup(courseId: string) {
try {
await http.delete(`/course-groups/${activeCourseGroupId.value}/courses/${courseId}`)
await loadCourseGroups()
openCourseGroup(activeCourseGroupId.value)
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
function openCourseGroupImport(moduleId: string) {
activeModuleId.value = moduleId
Object.assign(courseGroupImportForm, {
courseGroupId: courseGroups.value[0]?.id, recommendedSemester: 1, type: 'Elective', notes: '',
})
courseGroupImportDialog.value = true
}
async function importCourseGroup() {
if (!courseGroupImportForm.courseGroupId) {
ElMessage.warning('请选择课程组。')
return
}
if (!await confirmPublishedChange('课程组导入')) return
try {
await http.post(`/curriculum-plans/${selected.value.id}/modules/${activeModuleId.value}/course-groups/${courseGroupImportForm.courseGroupId}`, courseGroupImportForm)
courseGroupImportDialog.value = false
await loadDetail(selected.value.id)
ElMessage.success('课程组已导入,可按本方案需要继续逐门调整。')
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
function resetFilters() { function resetFilters() {
Object.assign(query, { Object.assign(query, {
page: 1, page: 1,
@@ -463,7 +327,6 @@ onMounted(async () => {
const [optionRes, courseRes] = await Promise.all([ const [optionRes, courseRes] = await Promise.all([
http.get('/curriculum-plans/filter-options'), http.get('/curriculum-plans/filter-options'),
http.get('/courses/options'), http.get('/courses/options'),
loadCourseGroups(),
]) ])
colleges.value = optionRes.data.colleges colleges.value = optionRes.data.colleges
majors.value = optionRes.data.majors majors.value = optionRes.data.majors
@@ -484,10 +347,7 @@ onMounted(async () => {
<h2>培养方案</h2> <h2>培养方案</h2>
<p>由学院按专业和入学年级维护课程结构已发布方案可受控调整修改结果即时生效</p> <p>由学院按专业和入学年级维护课程结构已发布方案可受控调整修改结果即时生效</p>
</div> </div>
<div class="page-intro-actions">
<el-button v-if="canManageCourseGroups" :icon="Collection" @click="openCourseGroups">课程组</el-button>
<el-button type="primary" :icon="Plus" @click="openPlan()">新建方案</el-button> <el-button type="primary" :icon="Plus" @click="openPlan()">新建方案</el-button>
</div>
</section> </section>
<section class="maintenance-scope" aria-label="培养方案维护范围"> <section class="maintenance-scope" aria-label="培养方案维护范围">
@@ -566,7 +426,7 @@ onMounted(async () => {
</div> </div>
<div class="plan-actions"> <div class="plan-actions">
<el-button v-if="canEdit" @click="openPlan(selected)">编辑</el-button> <el-button v-if="canEdit" @click="openPlan(selected)">编辑</el-button>
<el-button :icon="CopyDocument" @click="openClone">修订并创建草稿</el-button> <el-button :icon="CopyDocument" @click="openClone">复制版本</el-button>
<el-button v-if="isDraft" type="success" :icon="Promotion" @click="publishPlan">发布</el-button> <el-button v-if="isDraft" type="success" :icon="Promotion" @click="publishPlan">发布</el-button>
<el-button v-if="isDraft" type="danger" plain @click="deletePlan">删除</el-button> <el-button v-if="isDraft" type="danger" plain @click="deletePlan">删除</el-button>
</div> </div>
@@ -590,21 +450,12 @@ onMounted(async () => {
<div class="module-toolbar"> <div class="module-toolbar">
<div> <div>
<b>{{ detailView === 'structure' ? '课程结构' : '学期视图' }}</b> <b>课程结构</b>
<span>{{ detailView === 'structure' <span>指定必修须逐门通过英语体育等多选课程用组内选修修满模块最低学分即可</span>
? '指定必修须逐门通过;英语、体育等多选课程用“组内选修”,修满模块最低学分即可'
: '按建议修读学期展示课程安排,包含每学期的课程数量、学分和学时。' }}</span>
</div>
<div class="curriculum-view-actions">
<el-radio-group v-model="detailView" size="small" aria-label="培养方案展示方式">
<el-radio-button value="structure">课程结构</el-radio-button>
<el-radio-button value="semester">学期视图</el-radio-button>
</el-radio-group>
<el-button v-if="canEdit && detailView === 'structure'" :icon="Plus" @click="openModule()">新增模块</el-button>
</div> </div>
<el-button v-if="canEdit" :icon="Plus" @click="openModule()">新增模块</el-button>
</div> </div>
<template v-if="detailView === 'structure'">
<section v-for="module in selected.modules" :key="module.id" class="curriculum-module"> <section v-for="module in selected.modules" :key="module.id" class="curriculum-module">
<header> <header>
<div> <div>
@@ -620,7 +471,6 @@ onMounted(async () => {
<div v-if="canEdit"> <div v-if="canEdit">
<el-button link type="primary" @click="openModule(module)">编辑</el-button> <el-button link type="primary" @click="openModule(module)">编辑</el-button>
<el-button link type="danger" @click="deleteModule(module)">删除</el-button> <el-button link type="danger" @click="deleteModule(module)">删除</el-button>
<el-button size="small" @click="openCourseGroupImport(module.id)">从课程组添加</el-button>
<el-button size="small" :icon="Plus" @click="openCourse(module.id)">添加课程</el-button> <el-button size="small" :icon="Plus" @click="openCourse(module.id)">添加课程</el-button>
</div> </div>
</header> </header>
@@ -651,34 +501,6 @@ onMounted(async () => {
</section> </section>
<el-empty v-if="selected.modules.length === 0" description="先建立课程模块,再添加课程" /> <el-empty v-if="selected.modules.length === 0" description="先建立课程模块,再添加课程" />
</template> </template>
<section v-else class="semester-view" aria-label="课程学期安排">
<article v-for="group in semesterGroups" :key="group.semester" class="semester-card">
<header>
<div>
<span>SEMESTER {{ String(group.semester).padStart(2, '0') }}</span>
<h4> {{ group.semester }} 学期</h4>
</div>
<b>{{ group.courses.length }} </b>
</header>
<div class="semester-summary">
<span>{{ group.credits }} 学分</span>
<span>{{ group.totalHours }} 学时</span>
</div>
<ul v-if="group.courses.length" class="semester-courses">
<li v-for="course in group.courses" :key="course.id">
<div>
<span>{{ course.moduleName }}</span>
<b>{{ course.courseName }}</b>
<small>{{ course.courseCode }} · {{ courseTypeLabels[course.type] }}</small>
</div>
<strong>{{ course.credits }}<small>学分</small></strong>
</li>
</ul>
<p v-else class="semester-empty">本学期暂未安排课程</p>
</article>
</section>
</template>
</main> </main>
</div> </div>
@@ -705,8 +527,7 @@ onMounted(async () => {
<template #footer><el-button @click="planDialog = false">取消</el-button><el-button type="primary" @click="savePlan">保存</el-button></template> <template #footer><el-button @click="planDialog = false">取消</el-button><el-button type="primary" @click="savePlan">保存</el-button></template>
</el-dialog> </el-dialog>
<el-dialog v-model="cloneDialog" title="修订培养方案并创建草稿" width="520px"> <el-dialog v-model="cloneDialog" title="复制为新版本" width="520px">
<p class="form-help">将完整复制当前方案为独立草稿修订后的课程模块和课程组导入内容均可单独调整不影响原版本</p>
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="方案名称" required><el-input v-model="cloneForm.name" /></el-form-item> <el-form-item label="方案名称" required><el-input v-model="cloneForm.name" /></el-form-item>
<div class="form-grid"> <div class="form-grid">
@@ -714,7 +535,7 @@ onMounted(async () => {
<el-form-item label="适用入学年级" required><el-input-number v-model="cloneForm.effectiveGrade" :min="2000" :max="2200" /></el-form-item> <el-form-item label="适用入学年级" required><el-input-number v-model="cloneForm.effectiveGrade" :min="2000" :max="2200" /></el-form-item>
</div> </div>
</el-form> </el-form>
<template #footer><el-button @click="cloneDialog = false">取消</el-button><el-button type="primary" @click="clonePlan">创建修订草稿</el-button></template> <template #footer><el-button @click="cloneDialog = false">取消</el-button><el-button type="primary" @click="clonePlan">复制</el-button></template>
</el-dialog> </el-dialog>
<el-dialog v-model="moduleDialog" :title="editingModuleId ? '编辑课程模块' : '新增课程模块'" width="520px"> <el-dialog v-model="moduleDialog" :title="editingModuleId ? '编辑课程模块' : '新增课程模块'" width="520px">
@@ -751,55 +572,5 @@ onMounted(async () => {
</el-form> </el-form>
<template #footer><el-button @click="courseDialog = false">取消</el-button><el-button type="primary" @click="saveCourse">保存</el-button></template> <template #footer><el-button @click="courseDialog = false">取消</el-button><el-button type="primary" @click="saveCourse">保存</el-button></template>
</el-dialog> </el-dialog>
<el-dialog v-model="courseGroupImportDialog" title="从课程组添加课程" width="560px">
<el-alert type="info" :closable="false" show-icon title="导入后课程会复制到当前方案,可逐门修改建议学期、修读规则和备注,不影响公共课程组。" />
<el-form label-position="top">
<el-form-item label="课程组" required>
<el-select v-model="courseGroupImportForm.courseGroupId" filterable>
<el-option v-for="group in courseGroups" :key="group.id" :label="`${group.code} · ${group.name}${group.courseCount} 门)`" :value="group.id" />
</el-select>
</el-form-item>
<p v-if="importingCourseGroup" class="form-help">将导入{{ importingCourseGroup.courses.map((course: any) => course.courseName).join('、') }}</p>
<div class="form-grid">
<el-form-item label="修读规则"><el-select v-model="courseGroupImportForm.type"><el-option v-for="(label, value) in courseTypeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="建议学期"><el-input-number v-model="courseGroupImportForm.recommendedSemester" :min="1" :max="selected?.schoolingYears * 2" /></el-form-item>
</div>
<el-form-item label="统一备注"><el-input v-model="courseGroupImportForm.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer><el-button @click="courseGroupImportDialog = false">取消</el-button><el-button type="primary" @click="importCourseGroup">导入课程组</el-button></template>
</el-dialog>
<el-dialog v-model="courseGroupDialog" title="公共课程组" width="920px">
<div class="course-group-manager">
<aside>
<el-button type="primary" :icon="Plus" @click="openCourseGroup()">新建课程组</el-button>
<button v-for="group in courseGroups" :key="group.id" type="button" :class="{ active: activeCourseGroupId === group.id }" @click="openCourseGroup(group.id)">
<span>{{ group.code }}</span><b>{{ group.name }}</b><small>{{ group.courseCount }} 门课程</small>
</button>
</aside>
<main>
<el-form label-position="top">
<div class="form-grid">
<el-form-item label="课程组编码" required><el-input v-model="courseGroupForm.code" /></el-form-item>
<el-form-item label="课程组名称" required><el-input v-model="courseGroupForm.name" /></el-form-item>
</div>
<el-form-item label="说明"><el-input v-model="courseGroupForm.description" type="textarea" :rows="2" /></el-form-item>
</el-form>
<div class="course-group-actions"><el-button type="primary" @click="saveCourseGroup">保存课程组</el-button><el-button v-if="activeCourseGroup" type="danger" plain @click="deleteCourseGroup">删除</el-button></div>
<div v-if="activeCourseGroup" class="course-group-add">
<b>组内课程</b>
<el-select v-model="courseGroupCourseForm.courseId" filterable placeholder="选择课程加入课程组"><el-option v-for="course in courses" :key="course.id" :label="`${course.code} · ${course.name}`" :value="course.id" /></el-select>
<el-button type="primary" @click="addCourseToGroup">加入</el-button>
</div>
<el-table v-if="activeCourseGroup" :data="activeCourseGroup.courses">
<el-table-column prop="courseCode" label="课程编码" width="120" />
<el-table-column prop="courseName" label="课程名称" />
<el-table-column prop="credits" label="学分" width="80" />
<el-table-column label="操作" width="80"><template #default="{ row }"><el-button link type="danger" @click="removeCourseFromGroup(row.courseId)">移除</el-button></template></el-table-column>
</el-table>
</main>
</div>
</el-dialog>
</div> </div>
</template> </template>
+14 -169
View File
@@ -55,20 +55,9 @@ const weekdays = [
{ value: 7, label: '星期日' }, { value: 7, label: '星期日' },
] ]
const experimentVenueNatures = [ const experimentVenueNatures = [
{ value: 1, label: '普通教室' }, { value: 2, label: '实室' }, { value: 2, label: '实验室' }, { value: 4, label: '实室' },
{ value: 4, label: '实训室' }, { value: 8, label: '计算机机房' }, { value: 8, label: '计算机机房' }, { value: 16, label: '语音室' },
{ value: 16, label: '语音室' }, { value: 32, label: '体育场地' },
{ value: 64, label: '艺术场地' },
] ]
const venueNatureValue = (value: unknown) => {
if (typeof value === 'number') return value
if (typeof value !== 'string') return 0
const names: Record<string, number> = {
GeneralClassroom: 1, Laboratory: 2, TrainingRoom: 4, ComputerLab: 8,
LanguageLab: 16, SportsVenue: 32, ArtsVenue: 64,
}
return value.split(',').reduce((sum, name) => sum | (names[name.trim()] ?? 0), 0)
}
const periods = computed(() => { const periods = computed(() => {
const configured = timeSlots.value const configured = timeSlots.value
.filter((item) => item.isEnabled) .filter((item) => item.isEnabled)
@@ -140,21 +129,17 @@ const publishStatusText = computed(() => {
const selectedTaskConstraint = computed(() => const selectedTaskConstraint = computed(() =>
constraints.value.find((item) => item.id === entryForm.teachingTaskId), constraints.value.find((item) => item.id === entryForm.teachingTaskId),
) )
const isExperimentRoom = (room: any) =>
(Number(room.teachingVenueNature) & (2 | 4 | 8 | 16)) !== 0
const entryClassrooms = computed(() => const entryClassrooms = computed(() =>
classrooms.value.filter((room) => classrooms.value.filter((room) =>
(entryForm.kind === 'Experiment' || !selectedTaskConstraint.value?.requiredCampusId (!selectedTaskConstraint.value?.requiredCampusId
|| room.campusId === selectedTaskConstraint.value.requiredCampusId) && || room.campusId === selectedTaskConstraint.value.requiredCampusId) &&
(entryForm.kind === 'Experiment' || !selectedTaskConstraint.value?.requiredBuildingId (!selectedTaskConstraint.value?.requiredBuildingId
|| room.buildingId === selectedTaskConstraint.value.requiredBuildingId) && || room.buildingId === selectedTaskConstraint.value.requiredBuildingId) &&
(entryForm.kind === 'Experiment' || !selectedTaskConstraint.value?.allowedClassroomIds?.length (!selectedTaskConstraint.value?.allowedClassroomIds?.length
|| selectedTaskConstraint.value.allowedClassroomIds.includes(room.id)) && || selectedTaskConstraint.value.allowedClassroomIds.includes(room.id)) &&
(entryForm.kind !== 'Experiment' || !selectedTaskConstraint.value?.experimentRequiredCampusId (entryForm.kind !== 'Experiment' || isExperimentRoom(room)),
|| room.campusId === selectedTaskConstraint.value.experimentRequiredCampusId) &&
(entryForm.kind !== 'Experiment' || !selectedTaskConstraint.value?.experimentRequiredBuildingId
|| room.buildingId === selectedTaskConstraint.value.experimentRequiredBuildingId) &&
(entryForm.kind !== 'Experiment' ||
!selectedTaskConstraint.value?.allowedExperimentClassroomIds?.length ||
selectedTaskConstraint.value.allowedExperimentClassroomIds.includes(room.id)),
), ),
) )
const entryWeekdays = computed(() => { const entryWeekdays = computed(() => {
@@ -207,22 +192,6 @@ const filteredClassrooms = computed(() =>
(!constraintForm.requiredBuildingId || item.buildingId === constraintForm.requiredBuildingId), (!constraintForm.requiredBuildingId || item.buildingId === constraintForm.requiredBuildingId),
), ),
) )
const filteredExperimentBuildings = computed(() =>
constraintForm.experimentRequiredCampusId
? buildings.value.filter((item) => item.campusId === constraintForm.experimentRequiredCampusId)
: buildings.value,
)
const filteredExperimentClassrooms = computed(() =>
classrooms.value.filter((item) =>
(!constraintForm.experimentRequiredCampusId ||
item.campusId === constraintForm.experimentRequiredCampusId) &&
(!constraintForm.experimentRequiredBuildingId ||
item.buildingId === constraintForm.experimentRequiredBuildingId) &&
(!(constraintForm.allowedExperimentVenueNatures ?? []).length ||
(venueNatureValue(item.teachingVenueNature) & (constraintForm.allowedExperimentVenueNatures ?? [])
.reduce((value: number, nature: number) => value | nature, 0)) !== 0),
),
)
const batchFilteredBuildings = computed(() => const batchFilteredBuildings = computed(() =>
constraintBatchForm.requiredCampusId constraintBatchForm.requiredCampusId
? buildings.value.filter((item) => item.campusId === constraintBatchForm.requiredCampusId) ? buildings.value.filter((item) => item.campusId === constraintBatchForm.requiredCampusId)
@@ -236,23 +205,6 @@ const batchFilteredClassrooms = computed(() =>
item.buildingId === constraintBatchForm.requiredBuildingId), item.buildingId === constraintBatchForm.requiredBuildingId),
), ),
) )
const batchFilteredExperimentBuildings = computed(() =>
constraintBatchForm.experimentRequiredCampusId
? buildings.value.filter((item) => item.campusId === constraintBatchForm.experimentRequiredCampusId)
: buildings.value,
)
const batchFilteredExperimentClassrooms = computed(() =>
classrooms.value.filter((item) =>
(!constraintBatchForm.experimentRequiredCampusId ||
item.campusId === constraintBatchForm.experimentRequiredCampusId) &&
(!constraintBatchForm.experimentRequiredBuildingId ||
item.buildingId === constraintBatchForm.experimentRequiredBuildingId) &&
(!(constraintBatchForm.allowedExperimentVenueNatures ?? []).length ||
(venueNatureValue(item.teachingVenueNature) &
(constraintBatchForm.allowedExperimentVenueNatures ?? [])
.reduce((value: number, nature: number) => value | nature, 0)) !== 0),
),
)
const filteredEntries = computed(() => { const filteredEntries = computed(() => {
const text = keyword.value.trim().toLowerCase() const text = keyword.value.trim().toLowerCase()
if (!text) return selected.value?.entries ?? [] if (!text) return selected.value?.entries ?? []
@@ -341,15 +293,11 @@ function openConstraint(item: any) {
Object.assign(constraintForm, { Object.assign(constraintForm, {
teachingTaskId: item.id, teachingTaskId: item.id,
title: `${item.taskNumber} · ${item.name}`, title: `${item.taskNumber} · ${item.name}`,
coursePracticeHours: item.coursePracticeHours,
schedulingMode: item.schedulingMode, schedulingMode: item.schedulingMode,
requiresClassroom: item.requiresClassroom, requiresClassroom: item.requiresClassroom,
requiredCampusId: item.requiredCampusId, requiredCampusId: item.requiredCampusId,
requiredBuildingId: item.requiredBuildingId, requiredBuildingId: item.requiredBuildingId,
experimentRequiredCampusId: item.experimentRequiredCampusId,
experimentRequiredBuildingId: item.experimentRequiredBuildingId,
allowedClassroomIds: [...item.allowedClassroomIds], allowedClassroomIds: [...item.allowedClassroomIds],
allowedExperimentClassroomIds: [...item.allowedExperimentClassroomIds],
allowedExperimentVenueNatures: experimentVenueNatures allowedExperimentVenueNatures: experimentVenueNatures
.filter((nature) => (Number(item.allowedExperimentVenueNatures) & nature.value) !== 0) .filter((nature) => (Number(item.allowedExperimentVenueNatures) & nature.value) !== 0)
.map((nature) => nature.value), .map((nature) => nature.value),
@@ -369,10 +317,7 @@ async function saveConstraint() {
requiresClassroom: constraintForm.requiresClassroom, requiresClassroom: constraintForm.requiresClassroom,
requiredCampusId: constraintForm.requiredCampusId || null, requiredCampusId: constraintForm.requiredCampusId || null,
requiredBuildingId: constraintForm.requiredBuildingId || null, requiredBuildingId: constraintForm.requiredBuildingId || null,
experimentRequiredCampusId: constraintForm.experimentRequiredCampusId || null,
experimentRequiredBuildingId: constraintForm.experimentRequiredBuildingId || null,
allowedClassroomIds: constraintForm.allowedClassroomIds ?? [], allowedClassroomIds: constraintForm.allowedClassroomIds ?? [],
allowedExperimentClassroomIds: constraintForm.allowedExperimentClassroomIds ?? [],
allowedExperimentVenueNatures: (constraintForm.allowedExperimentVenueNatures ?? []) allowedExperimentVenueNatures: (constraintForm.allowedExperimentVenueNatures ?? [])
.reduce((value: number, nature: number) => value | nature, 0), .reduce((value: number, nature: number) => value | nature, 0),
allowedDayOfWeeks: constraintForm.allowedDayOfWeeks ?? [], allowedDayOfWeeks: constraintForm.allowedDayOfWeeks ?? [],
@@ -413,11 +358,6 @@ function openConstraintBatch() {
requiredCampusId: undefined, requiredCampusId: undefined,
requiredBuildingId: undefined, requiredBuildingId: undefined,
allowedClassroomIds: [], allowedClassroomIds: [],
updateExperimentClassroomScope: false,
experimentRequiredCampusId: undefined,
experimentRequiredBuildingId: undefined,
allowedExperimentVenueNatures: [],
allowedExperimentClassroomIds: [],
updateDays: false, updateDays: false,
allowedDayOfWeeks: [1, 2, 3, 4, 5], allowedDayOfWeeks: [1, 2, 3, 4, 5],
updatePeriodRange: false, updatePeriodRange: false,
@@ -431,7 +371,6 @@ async function saveConstraintBatch() {
if (!constraintBatchForm.updateSchedulingMode && if (!constraintBatchForm.updateSchedulingMode &&
!constraintBatchForm.updateRequiresClassroom && !constraintBatchForm.updateRequiresClassroom &&
!constraintBatchForm.updateClassroomScope && !constraintBatchForm.updateClassroomScope &&
!constraintBatchForm.updateExperimentClassroomScope &&
!constraintBatchForm.updateDays && !constraintBatchForm.updateDays &&
!constraintBatchForm.updatePeriodRange) { !constraintBatchForm.updatePeriodRange) {
ElMessage.warning('请至少勾选一项需要批量修改的设置。') ElMessage.warning('请至少勾选一项需要批量修改的设置。')
@@ -451,9 +390,6 @@ async function saveConstraintBatch() {
!(constraintBatchForm.updateRequiresClassroom && !(constraintBatchForm.updateRequiresClassroom &&
!constraintBatchForm.requiresClassroom) && !constraintBatchForm.requiresClassroom) &&
constraintBatchForm.updateClassroomScope constraintBatchForm.updateClassroomScope
const updateExperimentClassroomScope = !flexible &&
!(constraintBatchForm.updateRequiresClassroom && !constraintBatchForm.requiresClassroom) &&
constraintBatchForm.updateExperimentClassroomScope
const { data } = await http.put('/schedules/constraints/batch', { const { data } = await http.put('/schedules/constraints/batch', {
academicTermId: termId.value, academicTermId: termId.value,
teachingTaskIds: targets.map((item) => item.id), teachingTaskIds: targets.map((item) => item.id),
@@ -473,20 +409,6 @@ async function saveConstraintBatch() {
allowedClassroomIds: updateClassroomScope allowedClassroomIds: updateClassroomScope
? constraintBatchForm.allowedClassroomIds ? constraintBatchForm.allowedClassroomIds
: null, : null,
updateExperimentClassroomScope,
experimentRequiredCampusId: updateExperimentClassroomScope
? constraintBatchForm.experimentRequiredCampusId || null
: null,
experimentRequiredBuildingId: updateExperimentClassroomScope
? constraintBatchForm.experimentRequiredBuildingId || null
: null,
allowedExperimentVenueNatures: updateExperimentClassroomScope
? (constraintBatchForm.allowedExperimentVenueNatures ?? [])
.reduce((value: number, nature: number) => value | nature, 0)
: null,
allowedExperimentClassroomIds: updateExperimentClassroomScope
? constraintBatchForm.allowedExperimentClassroomIds
: null,
allowedDayOfWeeks: !flexible && constraintBatchForm.updateDays allowedDayOfWeeks: !flexible && constraintBatchForm.updateDays
? constraintBatchForm.allowedDayOfWeeks ? constraintBatchForm.allowedDayOfWeeks
: null, : null,
@@ -786,13 +708,10 @@ function changeEntryTask() {
} }
const room = classrooms.value.find((item) => item.id === entryForm.classroomId) const room = classrooms.value.find((item) => item.id === entryForm.classroomId)
if (room && ( if (room && (
(entryForm.kind !== 'Experiment' && task.requiredCampusId && room.campusId !== task.requiredCampusId) || (task.requiredCampusId && room.campusId !== task.requiredCampusId) ||
(entryForm.kind !== 'Experiment' && task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) || (task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) ||
(entryForm.kind !== 'Experiment' && task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id)) || (task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id)) ||
(entryForm.kind === 'Experiment' && task.experimentRequiredCampusId && room.campusId !== task.experimentRequiredCampusId) || (entryForm.kind === 'Experiment' && !isExperimentRoom(room))
(entryForm.kind === 'Experiment' && task.experimentRequiredBuildingId && room.buildingId !== task.experimentRequiredBuildingId) ||
(entryForm.kind === 'Experiment' && task.allowedExperimentClassroomIds?.length &&
!task.allowedExperimentClassroomIds.includes(room.id))
)) { )) {
entryForm.classroomId = null entryForm.classroomId = null
} }
@@ -800,9 +719,7 @@ function changeEntryTask() {
function changeEntryKind() { function changeEntryKind() {
const room = classrooms.value.find((item) => item.id === entryForm.classroomId) const room = classrooms.value.find((item) => item.id === entryForm.classroomId)
const task = selectedTaskConstraint.value if (entryForm.kind === 'Experiment' && room && !isExperimentRoom(room)) {
if (entryForm.kind === 'Experiment' && room && task?.allowedExperimentClassroomIds?.length &&
!task.allowedExperimentClassroomIds.includes(room.id)) {
entryForm.classroomId = null entryForm.classroomId = null
} }
} }
@@ -1130,7 +1047,7 @@ onBeforeUnmount(() => {
/> />
<el-form-item <el-form-item
v-else v-else
:label="entryForm.kind === 'Experiment' ? '教学场地' : '教室'" :label="entryForm.kind === 'Experiment' ? '实验室 / 实训室 / 机房' : '教室'"
required required
:hint="selectedTaskConstraint?.requiredBuildingId ? '仅显示约束范围内教室' : ''" :hint="selectedTaskConstraint?.requiredBuildingId ? '仅显示约束范围内教室' : ''"
> >
@@ -1316,32 +1233,6 @@ onBeforeUnmount(() => {
</el-checkbox-group> </el-checkbox-group>
<small class="field-hint">仅约束实验课不勾选时可使用全部实验教学场地</small> <small class="field-hint">仅约束实验课不勾选时可使用全部实验教学场地</small>
</el-form-item> </el-form-item>
<section v-if="constraintForm.coursePracticeHours" class="experiment-classroom-scope">
<div class="experiment-classroom-scope__title">实验课指定可用场地</div>
<div class="form-grid">
<el-form-item label="实验课限定校区">
<el-select v-model="constraintForm.experimentRequiredCampusId" clearable @change="constraintForm.experimentRequiredBuildingId = undefined; constraintForm.allowedExperimentClassroomIds = []">
<el-option v-for="item in campuses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="实验课限定教学楼">
<el-select v-model="constraintForm.experimentRequiredBuildingId" clearable @change="constraintForm.allowedExperimentClassroomIds = []">
<el-option v-for="item in filteredExperimentBuildings" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</div>
<el-form-item label="实验课指定可用场地">
<el-select v-model="constraintForm.allowedExperimentClassroomIds" multiple filterable collapse-tags>
<el-option
v-for="item in filteredExperimentClassrooms"
:key="item.id"
:label="`${item.buildingName} / ${item.name}${item.roomType}${item.capacity}人)`"
:value="item.id"
/>
</el-select>
<small class="field-hint">可在上方场地性质范围内指定实验室不选择时按场地性质自动筛选</small>
</el-form-item>
</section>
</template> </template>
<el-form-item label="允许上课日"> <el-form-item label="允许上课日">
<el-checkbox-group v-model="constraintForm.allowedDayOfWeeks"> <el-checkbox-group v-model="constraintForm.allowedDayOfWeeks">
@@ -1446,52 +1337,6 @@ onBeforeUnmount(() => {
</el-form-item> </el-form-item>
</div> </div>
</template> </template>
<el-checkbox v-model="constraintBatchForm.updateExperimentClassroomScope">
批量指定实验课场地
</el-checkbox>
<div v-if="constraintBatchForm.updateExperimentClassroomScope" class="batch-classroom-scope">
<el-alert
title="仅作用于含实验学时的教学任务;不选择具体场地时,按场地性质自动分配。"
type="warning"
:closable="false"
show-icon
/>
<el-form-item label="统一实验课允许的场地性质">
<el-checkbox-group v-model="constraintBatchForm.allowedExperimentVenueNatures">
<el-checkbox v-for="nature in experimentVenueNatures" :key="nature.value" :value="nature.value">{{ nature.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<div class="form-grid">
<el-form-item label="统一实验课限定校区">
<el-select
v-model="constraintBatchForm.experimentRequiredCampusId"
clearable
@change="constraintBatchForm.experimentRequiredBuildingId = undefined; constraintBatchForm.allowedExperimentClassroomIds = []"
>
<el-option v-for="item in campuses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="统一实验课限定教学楼">
<el-select
v-model="constraintBatchForm.experimentRequiredBuildingId"
clearable
@change="constraintBatchForm.allowedExperimentClassroomIds = []"
>
<el-option v-for="item in batchFilteredExperimentBuildings" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</div>
<el-form-item label="统一实验课指定可用场地">
<el-select v-model="constraintBatchForm.allowedExperimentClassroomIds" multiple filterable collapse-tags placeholder="不选择则允许符合性质的任意实验场地">
<el-option
v-for="item in batchFilteredExperimentClassrooms"
:key="item.id"
:label="`${item.buildingName} / ${item.name}${item.roomType}${item.capacity}人)`"
:value="item.id"
/>
</el-select>
</el-form-item>
</div>
<el-checkbox v-model="constraintBatchForm.updateDays">修改允许上课日</el-checkbox> <el-checkbox v-model="constraintBatchForm.updateDays">修改允许上课日</el-checkbox>
<el-form-item v-if="constraintBatchForm.updateDays" label="统一允许上课日"> <el-form-item v-if="constraintBatchForm.updateDays" label="统一允许上课日">
<el-checkbox-group v-model="constraintBatchForm.allowedDayOfWeeks"> <el-checkbox-group v-model="constraintBatchForm.allowedDayOfWeeks">