Compare commits
16
Commits
@@ -18,6 +18,7 @@ BackgroundJobs__AutomaticScheduleConcurrency=1
|
|||||||
BackgroundJobs__SchedulePublishConcurrency=1
|
BackgroundJobs__SchedulePublishConcurrency=1
|
||||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||||
BackgroundJobs__ExamArrangementConcurrency=1
|
BackgroundJobs__ExamArrangementConcurrency=1
|
||||||
|
BackgroundJobs__CourseGradeStatisticsRefreshConcurrency=1
|
||||||
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||||
# RabbitMq__Port=5671
|
# RabbitMq__Port=5671
|
||||||
# RabbitMq__UserName=jiaowu
|
# RabbitMq__UserName=jiaowu
|
||||||
|
|||||||
@@ -251,13 +251,6 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
// Auto-apply: update grade record
|
// Auto-apply: update grade record
|
||||||
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
||||||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||||||
var statisticsJob = new CourseGradeStatisticsRefreshJob
|
|
||||||
{
|
|
||||||
GradeSheetId = gm.GradeRecord.GradeSheetId
|
|
||||||
};
|
|
||||||
db.CourseGradeStatisticsRefreshJobs.Add(statisticsJob);
|
|
||||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
|
||||||
BackgroundJobKind.CourseGradeStatisticsRefresh, statisticsJob.Id));
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
await NotificationService.SendAsync(
|
await NotificationService.SendAsync(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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;
|
||||||
@@ -286,6 +287,8 @@ 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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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,7 +1011,11 @@ 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,6 +432,48 @@ 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,
|
||||||
@@ -586,3 +628,8 @@ 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);
|
||||||
|
|||||||
@@ -38,8 +38,15 @@ public sealed class ExperimentGradesController(
|
|||||||
public async Task<ActionResult> GetManagement(
|
public async Task<ActionResult> GetManagement(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
ExperimentGradeSheetStatus? status,
|
ExperimentGradeSheetStatus? status,
|
||||||
CancellationToken cancellationToken)
|
Guid? collegeId = null,
|
||||||
|
string? keyword = null,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 50);
|
||||||
|
keyword = Normalize(keyword);
|
||||||
var source = ScopedProjects().AsNoTracking()
|
var source = ScopedProjects().AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.Status == ExperimentProjectStatus.Published ||
|
x.Status == ExperimentProjectStatus.Published ||
|
||||||
@@ -51,11 +58,28 @@ public sealed class ExperimentGradesController(
|
|||||||
source = source.Where(x =>
|
source = source.Where(x =>
|
||||||
x.GradeSheet != null &&
|
x.GradeSheet != null &&
|
||||||
x.GradeSheet.Status == status.Value);
|
x.GradeSheet.Status == status.Value);
|
||||||
|
if (collegeId.HasValue)
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.TeachingTask!.Course!.CollegeId == collegeId.Value);
|
||||||
|
if (keyword is not null)
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.Code.Contains(keyword) ||
|
||||||
|
x.Name.Contains(keyword) ||
|
||||||
|
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
||||||
|
x.TeachingTask.Name.Contains(keyword) ||
|
||||||
|
x.TeachingTask.Course!.Code.Contains(keyword) ||
|
||||||
|
x.TeachingTask.Course.Name.Contains(keyword) ||
|
||||||
|
x.TeachingTask.Teachers.Any(item =>
|
||||||
|
item.Teacher!.TeacherNumber.Contains(keyword) ||
|
||||||
|
item.Teacher.Name.Contains(keyword)));
|
||||||
|
|
||||||
return Ok(await source
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
||||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||||
.ThenBy(x => x.Code)
|
.ThenBy(x => x.Code)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -96,7 +120,14 @@ public sealed class ExperimentGradesController(
|
|||||||
x.GradeSheet.PublishedAt
|
x.GradeSheet.PublishedAt
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
Total = total,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("mine")]
|
[HttpGet("mine")]
|
||||||
@@ -747,6 +778,10 @@ public sealed class ExperimentGradesController(
|
|||||||
.Include(x => x.ExperimentProject)
|
.Include(x => x.ExperimentProject)
|
||||||
.ThenInclude(x => x!.TeachingTask)
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
|
.Include(x => x.ExperimentProject)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
|
using System.Text.Json;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Experiments;
|
using Jiaowu.Api.Infrastructure.Experiments;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -31,12 +33,21 @@ public sealed class ExperimentsController(
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> GetOptions(
|
public async Task<ActionResult> GetOptions(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
|
Guid? offeringCollegeId,
|
||||||
|
string? courseKeyword,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var tasks = AccessibleTeachingTasks().AsNoTracking()
|
var tasks = AccessibleTeachingTasks().AsNoTracking()
|
||||||
.Where(x => x.Status == TeachingTaskStatus.Published);
|
.Where(x => x.Status == TeachingTaskStatus.Published);
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
|
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
if (offeringCollegeId.HasValue)
|
||||||
|
tasks = tasks.Where(x => x.Course!.CollegeId == offeringCollegeId);
|
||||||
|
var normalizedCourseKeyword = Normalize(courseKeyword);
|
||||||
|
if (normalizedCourseKeyword is not null)
|
||||||
|
tasks = tasks.Where(x =>
|
||||||
|
x.Course!.Code.Contains(normalizedCourseKeyword) ||
|
||||||
|
x.Course.Name.Contains(normalizedCourseKeyword));
|
||||||
|
|
||||||
var periods = db.ScheduleTimeSlots.AsNoTracking()
|
var periods = db.ScheduleTimeSlots.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled);
|
.Where(x => x.IsEnabled);
|
||||||
@@ -90,6 +101,13 @@ public sealed class ExperimentsController(
|
|||||||
.OrderBy(item => item.AdministrativeClass!.Code)
|
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||||
.Select(item => item.AdministrativeClass!.Name)
|
.Select(item => item.AdministrativeClass!.Name)
|
||||||
})
|
})
|
||||||
|
.Take(200)
|
||||||
|
.ToListAsync(cancellationToken),
|
||||||
|
Colleges = await AccessibleTeachingTasks().AsNoTracking()
|
||||||
|
.Where(x => !academicTermId.HasValue || x.AcademicTermId == academicTermId.Value)
|
||||||
|
.Select(x => new { x.Course!.CollegeId, CollegeName = x.Course.College!.Name })
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(x => x.CollegeName)
|
||||||
.ToListAsync(cancellationToken),
|
.ToListAsync(cancellationToken),
|
||||||
ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
||||||
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||||
@@ -99,6 +117,11 @@ public sealed class ExperimentsController(
|
|||||||
.Contains(x.TeachingTaskId))
|
.Contains(x.TeachingTaskId))
|
||||||
.Where(x => !academicTermId.HasValue ||
|
.Where(x => !academicTermId.HasValue ||
|
||||||
x.SchedulePlan!.AcademicTermId == academicTermId.Value)
|
x.SchedulePlan!.AcademicTermId == academicTermId.Value)
|
||||||
|
.Where(x => !offeringCollegeId.HasValue ||
|
||||||
|
x.TeachingTask!.Course!.CollegeId == offeringCollegeId.Value)
|
||||||
|
.Where(x => normalizedCourseKeyword == null ||
|
||||||
|
x.TeachingTask!.Course!.Code.Contains(normalizedCourseKeyword) ||
|
||||||
|
x.TeachingTask.Course.Name.Contains(normalizedCourseKeyword))
|
||||||
.OrderBy(x => x.TeachingTask!.Course!.Code)
|
.OrderBy(x => x.TeachingTask!.Course!.Code)
|
||||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||||
.ThenBy(x => x.DayOfWeek)
|
.ThenBy(x => x.DayOfWeek)
|
||||||
@@ -116,10 +139,14 @@ public sealed class ExperimentsController(
|
|||||||
x.StartWeek,
|
x.StartWeek,
|
||||||
x.EndWeek,
|
x.EndWeek,
|
||||||
x.WeekPattern,
|
x.WeekPattern,
|
||||||
|
ClassNames = x.TeachingTask!.Classes
|
||||||
|
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||||
|
.Select(item => item.AdministrativeClass!.Name),
|
||||||
ClassroomName = x.Classroom!.Name,
|
ClassroomName = x.Classroom!.Name,
|
||||||
BuildingName = x.Classroom.Building!.Name,
|
BuildingName = x.Classroom.Building!.Name,
|
||||||
CampusName = x.Classroom.Building.Campus!.Name
|
CampusName = x.Classroom.Building.Campus!.Name
|
||||||
})
|
})
|
||||||
|
.Take(500)
|
||||||
.ToListAsync(cancellationToken),
|
.ToListAsync(cancellationToken),
|
||||||
Classrooms = await db.Classrooms.AsNoTracking()
|
Classrooms = await db.Classrooms.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled)
|
.Where(x => x.IsEnabled)
|
||||||
@@ -146,7 +173,14 @@ public sealed class ExperimentsController(
|
|||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
ExperimentArrangementMode? arrangementMode,
|
ExperimentArrangementMode? arrangementMode,
|
||||||
ExperimentProjectStatus? status,
|
ExperimentProjectStatus? status,
|
||||||
CancellationToken cancellationToken)
|
Guid? offeringCollegeId,
|
||||||
|
string? courseKeyword,
|
||||||
|
string? classKeyword,
|
||||||
|
string? teacherKeyword,
|
||||||
|
string? taskKeyword,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20)
|
||||||
{
|
{
|
||||||
var source = ScopedProjects().AsNoTracking();
|
var source = ScopedProjects().AsNoTracking();
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
@@ -158,7 +192,47 @@ public sealed class ExperimentsController(
|
|||||||
if (status.HasValue)
|
if (status.HasValue)
|
||||||
source = source.Where(x => x.Status == status);
|
source = source.Where(x => x.Status == status);
|
||||||
|
|
||||||
return Ok(await source
|
var normalizedCourseKeyword = Normalize(courseKeyword);
|
||||||
|
var normalizedClassKeyword = Normalize(classKeyword);
|
||||||
|
var normalizedTeacherKeyword = Normalize(teacherKeyword);
|
||||||
|
var normalizedTaskKeyword = Normalize(taskKeyword);
|
||||||
|
var taskSource = AccessibleTeachingTasks().AsNoTracking()
|
||||||
|
.Where(task => source.Select(project => project.TeachingTaskId).Contains(task.Id));
|
||||||
|
if (academicTermId.HasValue)
|
||||||
|
taskSource = taskSource.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
if (offeringCollegeId.HasValue)
|
||||||
|
taskSource = taskSource.Where(x => x.Course!.CollegeId == offeringCollegeId);
|
||||||
|
if (normalizedCourseKeyword is not null)
|
||||||
|
taskSource = taskSource.Where(x =>
|
||||||
|
x.Course!.Code.Contains(normalizedCourseKeyword) ||
|
||||||
|
x.Course.Name.Contains(normalizedCourseKeyword));
|
||||||
|
if (normalizedClassKeyword is not null)
|
||||||
|
taskSource = taskSource.Where(x => x.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.Code.Contains(normalizedClassKeyword) ||
|
||||||
|
item.AdministrativeClass.Name.Contains(normalizedClassKeyword)));
|
||||||
|
if (normalizedTeacherKeyword is not null)
|
||||||
|
taskSource = taskSource.Where(x => x.Teachers.Any(item =>
|
||||||
|
item.Teacher!.TeacherNumber.Contains(normalizedTeacherKeyword) ||
|
||||||
|
item.Teacher.Name.Contains(normalizedTeacherKeyword)));
|
||||||
|
if (normalizedTaskKeyword is not null)
|
||||||
|
taskSource = taskSource.Where(x =>
|
||||||
|
x.TaskNumber.Contains(normalizedTaskKeyword) ||
|
||||||
|
x.Name.Contains(normalizedTaskKeyword));
|
||||||
|
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||||
|
var total = await taskSource.CountAsync(cancellationToken);
|
||||||
|
var taskIds = await taskSource
|
||||||
|
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||||
|
.ThenBy(x => x.Course!.Code)
|
||||||
|
.ThenBy(x => x.TaskNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var items = await source
|
||||||
|
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||||
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
|
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
|
||||||
.ThenBy(x => x.StartDate)
|
.ThenBy(x => x.StartDate)
|
||||||
.ThenBy(x => x.Code)
|
.ThenBy(x => x.Code)
|
||||||
@@ -167,6 +241,7 @@ public sealed class ExperimentsController(
|
|||||||
x.Id,
|
x.Id,
|
||||||
x.TeachingTaskId,
|
x.TeachingTaskId,
|
||||||
x.ScheduleEntryId,
|
x.ScheduleEntryId,
|
||||||
|
x.ScheduleWeek,
|
||||||
x.Code,
|
x.Code,
|
||||||
x.Name,
|
x.Name,
|
||||||
x.ArrangementMode,
|
x.ArrangementMode,
|
||||||
@@ -197,6 +272,7 @@ public sealed class ExperimentsController(
|
|||||||
x.ScheduleEntry.StartWeek,
|
x.ScheduleEntry.StartWeek,
|
||||||
x.ScheduleEntry.EndWeek,
|
x.ScheduleEntry.EndWeek,
|
||||||
x.ScheduleEntry.WeekPattern,
|
x.ScheduleEntry.WeekPattern,
|
||||||
|
ProjectWeek = x.ScheduleWeek,
|
||||||
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
||||||
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
||||||
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
||||||
@@ -220,7 +296,9 @@ public sealed class ExperimentsController(
|
|||||||
CampusName = item.Classroom.Building.Campus!.Name
|
CampusName = item.Classroom.Building.Campus!.Name
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("student")]
|
[HttpGet("student")]
|
||||||
@@ -254,6 +332,7 @@ public sealed class ExperimentsController(
|
|||||||
x.Name,
|
x.Name,
|
||||||
x.ArrangementMode,
|
x.ArrangementMode,
|
||||||
x.ScheduleEntryId,
|
x.ScheduleEntryId,
|
||||||
|
x.ScheduleWeek,
|
||||||
x.Description,
|
x.Description,
|
||||||
x.Requirements,
|
x.Requirements,
|
||||||
x.StartDate,
|
x.StartDate,
|
||||||
@@ -275,6 +354,7 @@ public sealed class ExperimentsController(
|
|||||||
x.ScheduleEntry.StartWeek,
|
x.ScheduleEntry.StartWeek,
|
||||||
x.ScheduleEntry.EndWeek,
|
x.ScheduleEntry.EndWeek,
|
||||||
x.ScheduleEntry.WeekPattern,
|
x.ScheduleEntry.WeekPattern,
|
||||||
|
ProjectWeek = x.ScheduleWeek,
|
||||||
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
||||||
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
||||||
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
||||||
@@ -363,8 +443,6 @@ public sealed class ExperimentsController(
|
|||||||
ExperimentProjectBatchRequest request,
|
ExperimentProjectBatchRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (request.ArrangementMode == ExperimentArrangementMode.Centralized)
|
|
||||||
return ValidationProblem("集中安排的实验项目请逐项绑定已发布课表中的实验课。");
|
|
||||||
var taskIds = request.TeachingTaskIds
|
var taskIds = request.TeachingTaskIds
|
||||||
.Where(x => x != Guid.Empty)
|
.Where(x => x != Guid.Empty)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
@@ -384,21 +462,72 @@ public sealed class ExperimentsController(
|
|||||||
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
|
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
|
||||||
|
|
||||||
var first = tasks[0];
|
var first = tasks[0];
|
||||||
if (tasks.Any(x =>
|
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && tasks.Any(x =>
|
||||||
x.AcademicTermId != first.AcademicTermId ||
|
x.AcademicTermId != first.AcademicTermId ||
|
||||||
x.CourseId != first.CourseId))
|
x.CourseId != first.CourseId))
|
||||||
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
|
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
|
||||||
|
|
||||||
var code = request.Code.Trim();
|
var code = request.Code.Trim();
|
||||||
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
|
||||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
{
|
||||||
.Where(x => x.Code == code)
|
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
||||||
.Select(x => x.TeachingTask!.TaskNumber)
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
.OrderBy(x => x)
|
.Where(x => x.Code == code)
|
||||||
.ToListAsync(cancellationToken);
|
.Select(x => x.TeachingTask!.TaskNumber)
|
||||||
if (conflictingTaskNumbers.Count > 0)
|
.OrderBy(x => x)
|
||||||
return ConflictProblem(
|
.ToListAsync(cancellationToken);
|
||||||
$"以下教学任务已存在实验项目编码 {code}:{string.Join("、", conflictingTaskNumbers)}。");
|
if (conflictingTaskNumbers.Count > 0)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"以下教学任务已存在实验项目编码 {code}:{string.Join("、", conflictingTaskNumbers)}。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheduledEntries = request.ArrangementMode == ExperimentArrangementMode.Centralized
|
||||||
|
? await db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||||
|
x.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
x.ClassroomId.HasValue &&
|
||||||
|
taskIds.Contains(x.TeachingTaskId))
|
||||||
|
.OrderBy(x => x.TeachingTaskId)
|
||||||
|
.ThenBy(x => x.DayOfWeek)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
: [];
|
||||||
|
if (request.ArrangementMode == ExperimentArrangementMode.Centralized &&
|
||||||
|
tasks.Any(task => scheduledEntries.All(entry => entry.TeachingTaskId != task.Id)))
|
||||||
|
return ValidationProblem("所选教学班中包含未排入实验室的已发布实验课,请先完成课表安排。");
|
||||||
|
|
||||||
|
var scheduledOccurrences = scheduledEntries
|
||||||
|
.SelectMany(entry => Enumerable.Range(
|
||||||
|
entry.StartWeek,
|
||||||
|
entry.EndWeek - entry.StartWeek + 1)
|
||||||
|
.Where(week => FreeClassroomRules.MatchesWeek(entry.WeekPattern, week))
|
||||||
|
.Select(week => (Entry: entry, Week: week)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var legacyProjects = scheduledEntries.Count > 0
|
||||||
|
? await db.ExperimentProjects
|
||||||
|
.Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
|
||||||
|
x.ScheduleWeek == null &&
|
||||||
|
taskIds.Contains(x.TeachingTaskId))
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
: [];
|
||||||
|
if (legacyProjects.Any(x => x.Status != ExperimentProjectStatus.Draft))
|
||||||
|
return ConflictProblem("存在旧版已发布实验项目,不能自动拆分为每周项目;请先关闭后重新设置。");
|
||||||
|
|
||||||
|
if (scheduledOccurrences.Count > 0)
|
||||||
|
{
|
||||||
|
var entryIds = scheduledOccurrences.Select(x => x.Entry.Id).Distinct().ToList();
|
||||||
|
var existingOccurrences = await db.ExperimentProjects.AsNoTracking()
|
||||||
|
.Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
|
||||||
|
x.ScheduleWeek.HasValue && entryIds.Contains(x.ScheduleEntryId.Value))
|
||||||
|
.Select(x => new { ScheduleEntryId = x.ScheduleEntryId!.Value, ScheduleWeek = x.ScheduleWeek!.Value })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var existingKeys = existingOccurrences
|
||||||
|
.Select(x => (x.ScheduleEntryId, x.ScheduleWeek))
|
||||||
|
.ToHashSet();
|
||||||
|
if (scheduledOccurrences.Any(x => existingKeys.Contains((x.Entry.Id, x.Week))))
|
||||||
|
return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。");
|
||||||
|
}
|
||||||
|
|
||||||
var projects = new List<ExperimentProject>(tasks.Count);
|
var projects = new List<ExperimentProject>(tasks.Count);
|
||||||
foreach (var task in tasks)
|
foreach (var task in tasks)
|
||||||
@@ -407,17 +536,42 @@ public sealed class ExperimentsController(
|
|||||||
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
|
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
|
||||||
if (problem is not null) return ValidationProblem(problem);
|
if (problem is not null) return ValidationProblem(problem);
|
||||||
|
|
||||||
projects.Add(new ExperimentProject
|
IEnumerable<(ScheduleEntry? Entry, int? Week)> taskOccurrences =
|
||||||
|
request.ArrangementMode == ExperimentArrangementMode.Centralized
|
||||||
|
? scheduledOccurrences
|
||||||
|
.Where(item => item.Entry.TeachingTaskId == task.Id)
|
||||||
|
.Select(item => ((ScheduleEntry?)item.Entry, (int?)item.Week))
|
||||||
|
: [(null, null)];
|
||||||
|
foreach (var occurrence in taskOccurrences)
|
||||||
{
|
{
|
||||||
TeachingTaskId = task.Id,
|
var scheduleEntry = occurrence.Entry;
|
||||||
Code = code,
|
var legacy = scheduleEntry is null ? null : legacyProjects
|
||||||
Name = request.Name.Trim(),
|
.SingleOrDefault(x => x.ScheduleEntryId == scheduleEntry.Id);
|
||||||
ArrangementMode = request.ArrangementMode,
|
if (legacy is not null)
|
||||||
Description = Normalize(request.Description),
|
{
|
||||||
Requirements = Normalize(request.Requirements),
|
var firstWeek = scheduledOccurrences
|
||||||
StartDate = request.StartDate,
|
.Where(item => item.Entry.Id == scheduleEntry!.Id)
|
||||||
EndDate = request.EndDate
|
.Min(item => item.Week);
|
||||||
});
|
if (occurrence.Week == firstWeek)
|
||||||
|
{
|
||||||
|
legacy.ScheduleWeek = occurrence.Week;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projects.Add(new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
ScheduleEntryId = occurrence.Entry?.Id,
|
||||||
|
ScheduleWeek = occurrence.Week,
|
||||||
|
Code = code,
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
ArrangementMode = request.ArrangementMode,
|
||||||
|
Description = Normalize(request.Description),
|
||||||
|
Requirements = Normalize(request.Requirements),
|
||||||
|
StartDate = request.StartDate,
|
||||||
|
EndDate = request.EndDate
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db.ExperimentProjects.AddRange(projects);
|
db.ExperimentProjects.AddRange(projects);
|
||||||
@@ -491,6 +645,26 @@ public sealed class ExperimentsController(
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("batch")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> DeleteProjects(
|
||||||
|
ExperimentProjectBulkRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ids = ValidateBulkProjectIds(request.ProjectIds);
|
||||||
|
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
|
||||||
|
var projects = await ScopedProjects()
|
||||||
|
.Where(x => ids.Contains(x.Id))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (projects.Count != ids.Count) return NotFound();
|
||||||
|
if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft))
|
||||||
|
return ConflictProblem("批量删除只能包含草稿实验项目。");
|
||||||
|
|
||||||
|
db.ExperimentProjects.RemoveRange(projects);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/publish")]
|
[HttpPost("{id:guid}/publish")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> PublishProject(
|
public async Task<ActionResult> PublishProject(
|
||||||
@@ -522,31 +696,47 @@ public sealed class ExperimentsController(
|
|||||||
project.Status = ExperimentProjectStatus.Published;
|
project.Status = ExperimentProjectStatus.Published;
|
||||||
project.PublishedAt = DateTime.UtcNow;
|
project.PublishedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await NotifyProjectPublishedAsync(project, cancellationToken);
|
||||||
var userIds = await TeachingTaskRosterQuery
|
|
||||||
.ForTask(db, project.TeachingTaskId)
|
|
||||||
.Where(x => x.UserId.HasValue)
|
|
||||||
.Select(x => x.UserId!.Value)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
if (userIds.Count > 0)
|
|
||||||
{
|
|
||||||
var mode = project.ArrangementMode ==
|
|
||||||
ExperimentArrangementMode.Centralized
|
|
||||||
? "集中安排"
|
|
||||||
: "自行预约";
|
|
||||||
await NotificationService.SendToUserIdsAsync(
|
|
||||||
db,
|
|
||||||
userIds,
|
|
||||||
"实验项目已发布",
|
|
||||||
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
|
|
||||||
"/experiments",
|
|
||||||
cancellationToken,
|
|
||||||
NotificationCategory.Schedule);
|
|
||||||
}
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("batch/publish")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> PublishProjects(
|
||||||
|
ExperimentProjectBulkRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ids = ValidateBulkProjectIds(request.ProjectIds);
|
||||||
|
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
|
||||||
|
if (await ScopedProjects().CountAsync(x => ids.Contains(x.Id), cancellationToken) != ids.Count)
|
||||||
|
return NotFound();
|
||||||
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
|
var job = new ExamPublishJob
|
||||||
|
{
|
||||||
|
Kind = ExamPublishJobKind.ExperimentProjects,
|
||||||
|
PlanId = ids[0],
|
||||||
|
RequestedByUserId = userId == Guid.Empty ? null : userId,
|
||||||
|
ProjectIdsJson = JsonSerializer.Serialize(ids),
|
||||||
|
CurrentStep = "等待后台校验"
|
||||||
|
};
|
||||||
|
db.ExamPublishJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.ExamPublish, job.Id));
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Accepted(new { JobId = job.Id, Status = job.Status, Message = "实验项目发布任务已提交。" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("batch/publish-jobs/{jobId:guid}")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetPublishJob(Guid jobId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.ExamPublishJobs.AsNoTracking().FirstOrDefaultAsync(x =>
|
||||||
|
x.Id == jobId && x.Kind == ExamPublishJobKind.ExperimentProjects,
|
||||||
|
cancellationToken);
|
||||||
|
if (job is null) return NotFound();
|
||||||
|
return Ok(new { job.Id, job.Status, job.CurrentStep, job.ErrorMessage, job.StartedAt, job.CompletedAt });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/close")]
|
[HttpPost("{id:guid}/close")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> CloseProject(
|
public async Task<ActionResult> CloseProject(
|
||||||
@@ -1234,6 +1424,31 @@ public sealed class ExperimentsController(
|
|||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
private async Task NotifyProjectPublishedAsync(
|
||||||
|
ExperimentProject project,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
|
||||||
|
if (userIds.Count == 0) return;
|
||||||
|
var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized
|
||||||
|
? "集中安排"
|
||||||
|
: "自行预约";
|
||||||
|
await NotificationService.SendToUserIdsAsync(
|
||||||
|
db,
|
||||||
|
userIds,
|
||||||
|
"实验项目已发布",
|
||||||
|
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
|
||||||
|
"/experiments",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
|
||||||
|
{
|
||||||
|
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
|
||||||
|
return ids is { Count: > 0 and <= 100 } ? ids : null;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? ValidateProjectRequest(
|
private static string? ValidateProjectRequest(
|
||||||
ExperimentProjectRequest request,
|
ExperimentProjectRequest request,
|
||||||
AcademicTerm term)
|
AcademicTerm term)
|
||||||
@@ -1321,6 +1536,9 @@ public sealed record ExperimentProjectBatchRequest(
|
|||||||
EndDate);
|
EndDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record ExperimentProjectBulkRequest(
|
||||||
|
[Required] IReadOnlyList<Guid> ProjectIds);
|
||||||
|
|
||||||
public sealed record ExperimentSessionRequest(
|
public sealed record ExperimentSessionRequest(
|
||||||
Guid ClassroomId,
|
Guid ClassroomId,
|
||||||
DateOnly SessionDate,
|
DateOnly SessionDate,
|
||||||
|
|||||||
@@ -25,6 +25,69 @@ public sealed class GradeAnalyticsController(
|
|||||||
SystemRoles.CollegeAdmin + "," +
|
SystemRoles.CollegeAdmin + "," +
|
||||||
SystemRoles.Leader + "," +
|
SystemRoles.Leader + "," +
|
||||||
SystemRoles.Teacher;
|
SystemRoles.Teacher;
|
||||||
|
private const string ScheduleManagers =
|
||||||
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
[HttpGet("refresh-schedule")]
|
||||||
|
[Authorize(Roles = ScheduleManagers)]
|
||||||
|
public async Task<ActionResult> GetRefreshSchedule(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var setting = await db.CourseGradeStatisticsRefreshSettings.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
||||||
|
cancellationToken);
|
||||||
|
var defaults = new CourseGradeStatisticsRefreshSetting();
|
||||||
|
var enabled = setting?.IsEnabled ?? defaults.IsEnabled;
|
||||||
|
var intervalSeconds = setting?.IntervalSeconds ?? defaults.IntervalSeconds;
|
||||||
|
var batchSize = setting?.BatchSize ?? defaults.BatchSize;
|
||||||
|
var lastRunAt = setting?.LastRunAt;
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
IsEnabled = enabled,
|
||||||
|
IntervalSeconds = intervalSeconds,
|
||||||
|
BatchSize = batchSize,
|
||||||
|
LastRunAt = lastRunAt,
|
||||||
|
NextRunAt = enabled && lastRunAt.HasValue
|
||||||
|
? lastRunAt.Value.AddSeconds(intervalSeconds)
|
||||||
|
: null as DateTime?
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("refresh-schedule")]
|
||||||
|
[Authorize(Roles = ScheduleManagers)]
|
||||||
|
public async Task<ActionResult> SaveRefreshSchedule(
|
||||||
|
SaveGradeStatisticsRefreshScheduleRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.IntervalSeconds is < 10 or > 86400)
|
||||||
|
return BadRequest(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "刷新间隔应在 10 秒到 24 小时之间。",
|
||||||
|
Status = StatusCodes.Status400BadRequest
|
||||||
|
});
|
||||||
|
if (request.BatchSize is < 1 or > 5000)
|
||||||
|
return BadRequest(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "单次刷新批量应在 1 到 5000 之间。",
|
||||||
|
Status = StatusCodes.Status400BadRequest
|
||||||
|
});
|
||||||
|
|
||||||
|
var setting = await db.CourseGradeStatisticsRefreshSettings
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
||||||
|
cancellationToken);
|
||||||
|
if (setting is null)
|
||||||
|
{
|
||||||
|
setting = new CourseGradeStatisticsRefreshSetting();
|
||||||
|
db.CourseGradeStatisticsRefreshSettings.Add(setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
setting.IsEnabled = request.IsEnabled;
|
||||||
|
setting.IntervalSeconds = request.IntervalSeconds;
|
||||||
|
setting.BatchSize = request.BatchSize;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("teaching-classes")]
|
[HttpGet("teaching-classes")]
|
||||||
public async Task<ActionResult> GetTeachingClasses(
|
public async Task<ActionResult> GetTeachingClasses(
|
||||||
@@ -526,3 +589,8 @@ public sealed class GradeAnalyticsController(
|
|||||||
IReadOnlyList<HistoricalComparison> History,
|
IReadOnlyList<HistoricalComparison> History,
|
||||||
ComparisonDelta? UniversityDelta);
|
ComparisonDelta? UniversityDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record SaveGradeStatisticsRefreshScheduleRequest(
|
||||||
|
bool IsEnabled,
|
||||||
|
int IntervalSeconds,
|
||||||
|
int BatchSize);
|
||||||
|
|||||||
@@ -508,7 +508,6 @@ public sealed class GradesController(
|
|||||||
}
|
}
|
||||||
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||||
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||||
QueueCourseStatisticsRefresh(sheet.Id);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -668,7 +667,6 @@ public sealed class GradesController(
|
|||||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||||
sheet.Status = GradeSheetStatus.Published;
|
sheet.Status = GradeSheetStatus.Published;
|
||||||
sheet.PublishedAt = DateTime.UtcNow;
|
sheet.PublishedAt = DateTime.UtcNow;
|
||||||
QueueCourseStatisticsRefresh(sheet.Id);
|
|
||||||
|
|
||||||
// The grade sheet roster is authoritative at publication time. This also
|
// The grade sheet roster is authoritative at publication time. This also
|
||||||
// covers students added through approved roster corrections.
|
// covers students added through approved roster corrections.
|
||||||
@@ -918,7 +916,6 @@ public sealed class GradesController(
|
|||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
return ImportValidationProblem(errors);
|
return ImportValidationProblem(errors);
|
||||||
|
|
||||||
QueueCourseStatisticsRefresh(sheet.Id);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new { updated, total = rows.Count });
|
return Ok(new { updated, total = rows.Count });
|
||||||
}
|
}
|
||||||
@@ -1161,7 +1158,6 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
QueueCourseStatisticsRefresh(id);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||||
}
|
}
|
||||||
@@ -1171,14 +1167,6 @@ public sealed class GradesController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void QueueCourseStatisticsRefresh(Guid gradeSheetId)
|
|
||||||
{
|
|
||||||
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
|
|
||||||
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
|
||||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
|
||||||
BackgroundJobKind.CourseGradeStatisticsRefresh, job.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
private ActionResult ConflictProblem(string detail) =>
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
Conflict(new ProblemDetails
|
Conflict(new ProblemDetails
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ 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 =>
|
||||||
{
|
{
|
||||||
@@ -132,11 +133,15 @@ 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
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
@@ -168,6 +173,7 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -195,6 +201,22 @@ 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)
|
||||||
@@ -208,8 +230,23 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -223,6 +260,12 @@ 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());
|
||||||
@@ -230,10 +273,15 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -256,21 +304,26 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
!Enum.IsDefined(request.SchedulingMode.Value))
|
!Enum.IsDefined(request.SchedulingMode.Value))
|
||||||
return ValidationProblem("授课方式无效。");
|
return ValidationProblem("授课方式无效。");
|
||||||
if (!request.SchedulingMode.HasValue &&
|
if (!request.SchedulingMode.HasValue &&
|
||||||
!request.RequiresClassroom.HasValue &&
|
!request.RequiresClassroom.HasValue &&
|
||||||
request.AllowedDayOfWeeks is null &&
|
request.AllowedDayOfWeeks is null &&
|
||||||
!request.UpdateClassroomScope &&
|
!request.UpdateClassroomScope &&
|
||||||
!request.UpdatePeriodRange &&
|
!request.UpdateExperimentClassroomScope &&
|
||||||
|
!request.AllowedExperimentVenueNatures.HasValue &&
|
||||||
|
!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("部分教学任务不存在、未发布或不属于当前学期。");
|
||||||
@@ -281,9 +334,16 @@ 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)
|
||||||
@@ -320,10 +380,42 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -342,6 +434,8 @@ 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 };
|
||||||
@@ -357,7 +451,10 @@ 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)
|
||||||
@@ -379,6 +476,17 @@ 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;
|
||||||
@@ -402,11 +510,15 @@ 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) =>
|
||||||
@@ -441,7 +553,10 @@ 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,
|
||||||
@@ -455,4 +570,9 @@ 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,6 +552,7 @@ 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);
|
||||||
@@ -580,20 +581,26 @@ 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 &&
|
||||||
!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
|
constraint?.RequiredCampusId is Guid campusId &&
|
||||||
return ValidationProblem(
|
|
||||||
$"实验课必须安排在具有实验教学性质的场地;“{classroom.Name}”未标注实验室、实训室、机房或语音室性质。 ");
|
|
||||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
|
||||||
classroom.Building!.CampusId != campusId)
|
classroom.Building!.CampusId != campusId)
|
||||||
return ValidationProblem("所选教室不在该课程指定的校区。");
|
return ValidationProblem("所选教室不在该课程指定的校区。");
|
||||||
if (constraint?.RequiredBuildingId is Guid buildingId &&
|
if (request.Kind != ScheduleEntryKind.Experiment &&
|
||||||
|
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 (allowedClassroomIds.Count > 0 &&
|
if (request.Kind != ScheduleEntryKind.Experiment && 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 &&
|
||||||
@@ -601,6 +608,13 @@ 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 =>
|
||||||
|
|||||||
@@ -21,32 +21,45 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
|
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
|
||||||
Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId)
|
Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId)
|
||||||
.OrderBy(x => x.Type).Select(x => new { x.Id, x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
|
.OrderBy(x => x.Type).Select(x => new { x.Id, Type = (int)x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
|
||||||
[HttpPut("rules")]
|
[HttpPut("rules")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
|
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (rules.GroupBy(x => x.Type).Any(group => group.Count() > 1))
|
||||||
|
return BadRequest(new ProblemDetails { Title = "预警类型不能重复。", Status = StatusCodes.Status400BadRequest });
|
||||||
|
if (rules.Any(x => x.CheckDayOfWeek is < 0 or > 7 || x.CheckHour is < 0 or > 23 || x.CheckMinute is < 0 or > 59))
|
||||||
|
return BadRequest(new ProblemDetails { Title = "自动检测时间无效。", Status = StatusCodes.Status400BadRequest });
|
||||||
|
|
||||||
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
|
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
|
||||||
db.WarningRules.RemoveRange(existing);
|
var incomingTypes = rules.Select(x => x.Type).ToHashSet();
|
||||||
|
db.WarningRules.RemoveRange(existing.Where(x => !incomingTypes.Contains(x.Type)));
|
||||||
foreach (var r in rules)
|
foreach (var r in rules)
|
||||||
{
|
{
|
||||||
db.WarningRules.Add(new WarningRule
|
var entity = existing.FirstOrDefault(x => x.Type == r.Type);
|
||||||
|
if (entity is null)
|
||||||
{
|
{
|
||||||
AcademicTermId = academicTermId,
|
entity = new WarningRule
|
||||||
Type = r.Type,
|
{
|
||||||
Name = r.Name.Trim(),
|
AcademicTermId = academicTermId,
|
||||||
Threshold = r.Threshold,
|
Type = r.Type,
|
||||||
IsEnabled = r.IsEnabled,
|
Name = r.Name.Trim()
|
||||||
NotifyStudent = r.NotifyStudent,
|
};
|
||||||
NotifyCounselor = r.NotifyCounselor,
|
db.WarningRules.Add(entity);
|
||||||
Description = r.Description?.Trim(),
|
}
|
||||||
AutoCheckEnabled = r.AutoCheckEnabled,
|
|
||||||
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
|
entity.Name = r.Name.Trim();
|
||||||
CheckHour = r.CheckHour,
|
entity.Threshold = r.Threshold;
|
||||||
CheckMinute = r.CheckMinute
|
entity.IsEnabled = r.IsEnabled;
|
||||||
});
|
entity.NotifyStudent = r.NotifyStudent;
|
||||||
|
entity.NotifyCounselor = r.NotifyCounselor;
|
||||||
|
entity.Description = r.Description?.Trim();
|
||||||
|
entity.AutoCheckEnabled = r.AutoCheckEnabled;
|
||||||
|
entity.CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek;
|
||||||
|
entity.CheckHour = r.CheckHour;
|
||||||
|
entity.CheckMinute = r.CheckMinute;
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
@@ -120,7 +133,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
}
|
}
|
||||||
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
||||||
if (type.HasValue) q = q.Where(x => x.Type == type);
|
if (type.HasValue) q = q.Where(x => x.Type == type);
|
||||||
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
|
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════ Student ═══════════
|
// ═══════════ Student ═══════════
|
||||||
@@ -131,7 +144,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
var sid = await GetStudentIdAsync(ct);
|
var sid = await GetStudentIdAsync(ct);
|
||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
|
return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
|
||||||
.Select(x => new { x.Id, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
|
.Select(x => new { x.Id, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class CourseGradeStatisticsRefreshSetting : EntityBase
|
||||||
|
{
|
||||||
|
public const string DefaultKey = "default";
|
||||||
|
|
||||||
|
public string Key { get; set; } = DefaultKey;
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public int IntervalSeconds { get; set; } = 300;
|
||||||
|
public int BatchSize { get; set; } = 100;
|
||||||
|
public DateTime? LastRunAt { get; set; }
|
||||||
|
}
|
||||||
@@ -38,6 +38,22 @@ 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,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class ExamArrangementJob : EntityBase
|
|||||||
public Guid PlanId { get; set; }
|
public Guid PlanId { get; set; }
|
||||||
public Guid? ActivePlanId { get; set; }
|
public Guid? ActivePlanId { get; set; }
|
||||||
public Guid? RequestedByUserId { get; set; }
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public string? ProjectIdsJson { get; set; }
|
||||||
public string? SessionIdsJson { get; set; }
|
public string? SessionIdsJson { get; set; }
|
||||||
public bool AssignClassrooms { get; set; }
|
public bool AssignClassrooms { get; set; }
|
||||||
public bool AssignInvigilators { get; set; }
|
public bool AssignInvigilators { get; set; }
|
||||||
@@ -138,6 +139,7 @@ public sealed class ExamPublishJob : EntityBase
|
|||||||
public Guid PlanId { get; set; }
|
public Guid PlanId { get; set; }
|
||||||
public Guid? ActivePlanId { get; set; }
|
public Guid? ActivePlanId { get; set; }
|
||||||
public Guid? RequestedByUserId { get; set; }
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public string? ProjectIdsJson { get; set; }
|
||||||
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
|
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
|
||||||
public string? CurrentStep { get; set; }
|
public string? CurrentStep { get; set; }
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
@@ -148,7 +150,8 @@ public sealed class ExamPublishJob : EntityBase
|
|||||||
public enum ExamPublishJobKind
|
public enum ExamPublishJobKind
|
||||||
{
|
{
|
||||||
FormalExam = 1,
|
FormalExam = 1,
|
||||||
MakeupExam = 2
|
MakeupExam = 2,
|
||||||
|
ExperimentProjects = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ExamPublishJobStatus
|
public enum ExamPublishJobStatus
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public sealed class ExperimentProject : EntityBase
|
|||||||
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
|
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
|
||||||
public Guid? ScheduleEntryId { get; set; }
|
public Guid? ScheduleEntryId { get; set; }
|
||||||
public ScheduleEntry? ScheduleEntry { get; set; }
|
public ScheduleEntry? ScheduleEntry { get; set; }
|
||||||
|
// 集中安排按课表的具体周次拆分为实验项目;自行安排为空。
|
||||||
|
public int? ScheduleWeek { get; set; }
|
||||||
public required string Code { get; set; }
|
public required string Code { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public ExperimentArrangementMode ArrangementMode { get; set; }
|
public ExperimentArrangementMode ArrangementMode { get; set; }
|
||||||
|
|||||||
@@ -52,11 +52,16 @@ 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
|
||||||
@@ -67,6 +72,31 @@ 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; }
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Controllers;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Teaching;
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
@@ -39,6 +42,9 @@ public sealed class ExamPublishJobProcessor(
|
|||||||
case ExamPublishJobKind.MakeupExam:
|
case ExamPublishJobKind.MakeupExam:
|
||||||
await PublishMakeupExamAsync(job, stoppingToken);
|
await PublishMakeupExamAsync(job, stoppingToken);
|
||||||
break;
|
break;
|
||||||
|
case ExamPublishJobKind.ExperimentProjects:
|
||||||
|
await PublishExperimentProjectsAsync(job, stoppingToken);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"不支持的考试发布类型:{job.Kind}。");
|
$"不支持的考试发布类型:{job.Kind}。");
|
||||||
@@ -248,6 +254,58 @@ public sealed class ExamPublishJobProcessor(
|
|||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PublishExperimentProjectsAsync(ExamPublishJob job, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ids = JsonSerializer.Deserialize<List<Guid>>(job.ProjectIdsJson ?? "[]")?
|
||||||
|
.Where(x => x != Guid.Empty).Distinct().ToList() ?? [];
|
||||||
|
if (ids.Count is 0 or > 100)
|
||||||
|
throw new ExamPublishValidationException("实验发布任务的数据无效。请重新提交。");
|
||||||
|
|
||||||
|
var projects = await db.ExperimentProjects
|
||||||
|
.Include(x => x.Sessions)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.Where(x => ids.Contains(x.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
if (projects.Count != ids.Count)
|
||||||
|
throw new ExamPublishValidationException("部分实验项目不存在,请刷新后重新提交。");
|
||||||
|
|
||||||
|
foreach (var project in projects)
|
||||||
|
{
|
||||||
|
if (project.Status != ExperimentProjectStatus.Draft)
|
||||||
|
throw new ExamPublishValidationException("批量发布只能包含草稿实验项目。");
|
||||||
|
var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized
|
||||||
|
? project.ScheduleEntryId.HasValue || project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled)
|
||||||
|
: project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled);
|
||||||
|
if (!hasSchedule)
|
||||||
|
throw new ExamPublishValidationException($"“{project.Name}”尚未具备发布条件。");
|
||||||
|
if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
|
||||||
|
(x.SessionDate < project.StartDate || x.SessionDate > project.EndDate)))
|
||||||
|
throw new ExamPublishValidationException($"“{project.Name}”存在不在开放日期范围内的实验场次。");
|
||||||
|
}
|
||||||
|
|
||||||
|
job.CurrentStep = "正在发布实验项目";
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
var publishedAt = DateTime.UtcNow;
|
||||||
|
foreach (var project in projects)
|
||||||
|
{
|
||||||
|
project.Status = ExperimentProjectStatus.Published;
|
||||||
|
project.PublishedAt = publishedAt;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
foreach (var project in projects)
|
||||||
|
{
|
||||||
|
var userIds = await TeachingTaskRosterQuery.ForTask(db, project.TeachingTaskId)
|
||||||
|
.Where(x => x.UserId.HasValue).Select(x => x.UserId!.Value).Distinct().ToListAsync(ct);
|
||||||
|
if (userIds.Count == 0) continue;
|
||||||
|
var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized ? "集中安排" : "自行预约";
|
||||||
|
await NotificationService.SendToUserIdsAsync(db, userIds, "实验项目已发布",
|
||||||
|
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
|
||||||
|
"/experiments", ct, NotificationCategory.Schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task MarkFailedAsync(Guid jobId, string message)
|
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||||
{
|
{
|
||||||
db.ChangeTracker.Clear();
|
db.ChangeTracker.Clear();
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repairs missing or stale materialized grade statistics on a database-
|
||||||
|
/// configured fixed interval. Grade writes do not enqueue refresh jobs; this
|
||||||
|
/// worker batches changes made during bulk imports.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CourseGradeStatisticsRefreshWorker(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
ILogger<CourseGradeStatisticsRefreshWorker> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Database-configured course grade statistics scheduler started.");
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), timeProvider);
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var scheduler = scope.ServiceProvider
|
||||||
|
.GetRequiredService<CourseGradeStatisticsRefreshScheduler>();
|
||||||
|
var queued = await scheduler.EnqueueDueAsync(
|
||||||
|
timeProvider.GetUtcNow().UtcDateTime,
|
||||||
|
stoppingToken);
|
||||||
|
if (queued > 0)
|
||||||
|
logger.LogInformation(
|
||||||
|
"Scheduled course grade statistics scan queued {Count} refresh jobs.",
|
||||||
|
queued);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Scheduled course grade statistics scan failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await timer.WaitForNextTickAsync(stoppingToken)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CourseGradeStatisticsRefreshScheduler(
|
||||||
|
AppDbContext db,
|
||||||
|
ILogger<CourseGradeStatisticsRefreshScheduler> logger)
|
||||||
|
{
|
||||||
|
public async Task<int> EnqueueDueAsync(
|
||||||
|
DateTime utcNow,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await ExecuteWithLeaseAsync(async () =>
|
||||||
|
{
|
||||||
|
var setting = await db.CourseGradeStatisticsRefreshSettings
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
||||||
|
cancellationToken);
|
||||||
|
if (setting is null)
|
||||||
|
{
|
||||||
|
setting = new CourseGradeStatisticsRefreshSetting();
|
||||||
|
db.CourseGradeStatisticsRefreshSettings.Add(setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
var interval = TimeSpan.FromSeconds(
|
||||||
|
Math.Clamp(setting.IntervalSeconds, 10, 86400));
|
||||||
|
if (!setting.IsEnabled ||
|
||||||
|
setting.LastRunAt.HasValue && utcNow < setting.LastRunAt.Value + interval)
|
||||||
|
{
|
||||||
|
if (db.Entry(setting).State == EntityState.Added)
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setting.LastRunAt = utcNow;
|
||||||
|
var queued = await EnqueueStaleCoreAsync(setting.BatchSize, cancellationToken);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return queued;
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<int> EnqueueStaleAsync(
|
||||||
|
int batchSize,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await ExecuteWithLeaseAsync(async () =>
|
||||||
|
{
|
||||||
|
var queued = await EnqueueStaleCoreAsync(batchSize, cancellationToken);
|
||||||
|
if (queued > 0) await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return queued;
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<int> ExecuteWithLeaseAsync(
|
||||||
|
Func<Task<int>> action,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var usesMySqlLease = db.Database.ProviderName?.Contains(
|
||||||
|
"MySql",
|
||||||
|
StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
if (usesMySqlLease && !await TryAcquireMySqlLeaseAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
await db.Database.CloseConnectionAsync();
|
||||||
|
logger.LogDebug("Another instance owns the grade statistics refresh lease.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await action();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (usesMySqlLease)
|
||||||
|
await ReleaseMySqlLeaseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> EnqueueStaleCoreAsync(
|
||||||
|
int batchSize,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
batchSize = Math.Clamp(batchSize, 1, 5000);
|
||||||
|
var activeTargets = await (
|
||||||
|
from job in db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
|
||||||
|
join sheet in db.GradeSheets.AsNoTracking()
|
||||||
|
on job.GradeSheetId equals sheet.Id
|
||||||
|
where job.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
|
||||||
|
job.Status == CourseGradeStatisticsRefreshJobStatus.Running
|
||||||
|
select new CourseTermTarget(
|
||||||
|
sheet.TeachingTask!.CourseId,
|
||||||
|
sheet.TeachingTask.AcademicTermId))
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var active = activeTargets.ToHashSet();
|
||||||
|
|
||||||
|
var rows = await db.GradeSheets.AsNoTracking()
|
||||||
|
.Where(sheet =>
|
||||||
|
sheet.Status == GradeSheetStatus.Published &&
|
||||||
|
sheet.Records.Any(record => record.TotalScore != null))
|
||||||
|
.Select(sheet => new RefreshCandidate(
|
||||||
|
sheet.Id,
|
||||||
|
sheet.TeachingTask!.CourseId,
|
||||||
|
sheet.TeachingTask.AcademicTermId,
|
||||||
|
sheet.UpdatedAt,
|
||||||
|
sheet.Records
|
||||||
|
.Where(record => record.TotalScore != null)
|
||||||
|
.Max(record => record.UpdatedAt),
|
||||||
|
db.TeachingTaskGradeStatistics
|
||||||
|
.Where(statistic => statistic.GradeSheetId == sheet.Id)
|
||||||
|
.Select(statistic => (DateTime?)statistic.CalculatedAt)
|
||||||
|
.FirstOrDefault()))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var stale = rows
|
||||||
|
.Where(row =>
|
||||||
|
row.CalculatedAt is null ||
|
||||||
|
row.SheetUpdatedAt > row.CalculatedAt ||
|
||||||
|
row.RecordsUpdatedAt > row.CalculatedAt)
|
||||||
|
.GroupBy(row => new CourseTermTarget(row.CourseId, row.AcademicTermId))
|
||||||
|
.Where(group => !active.Contains(group.Key))
|
||||||
|
.Select(group => group
|
||||||
|
.OrderByDescending(row => row.RecordsUpdatedAt)
|
||||||
|
.ThenByDescending(row => row.SheetUpdatedAt)
|
||||||
|
.First())
|
||||||
|
.Take(batchSize)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var candidate in stale)
|
||||||
|
{
|
||||||
|
var job = new CourseGradeStatisticsRefreshJob
|
||||||
|
{
|
||||||
|
GradeSheetId = candidate.GradeSheetId
|
||||||
|
};
|
||||||
|
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh,
|
||||||
|
job.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stale.Length == 0) return 0;
|
||||||
|
logger.LogDebug(
|
||||||
|
"Queued {Count} stale course grade statistics targets.",
|
||||||
|
stale.Length);
|
||||||
|
return stale.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryAcquireMySqlLeaseAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await db.Database.OpenConnectionAsync(cancellationToken);
|
||||||
|
await using var command = db.Database.GetDbConnection().CreateCommand();
|
||||||
|
command.CommandText = "SELECT GET_LOCK('jiaowu:grade-statistics-refresh', 0);";
|
||||||
|
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||||
|
return Convert.ToInt32(result) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReleaseMySqlLeaseAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var command = db.Database.GetDbConnection().CreateCommand();
|
||||||
|
command.CommandText = "SELECT RELEASE_LOCK('jiaowu:grade-statistics-refresh');";
|
||||||
|
await command.ExecuteScalarAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Failed to release grade statistics refresh lease.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await db.Database.CloseConnectionAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RefreshCandidate(
|
||||||
|
Guid GradeSheetId,
|
||||||
|
Guid CourseId,
|
||||||
|
Guid AcademicTermId,
|
||||||
|
DateTime SheetUpdatedAt,
|
||||||
|
DateTime RecordsUpdatedAt,
|
||||||
|
DateTime? CalculatedAt);
|
||||||
|
|
||||||
|
private sealed record CourseTermTarget(Guid CourseId, Guid AcademicTermId);
|
||||||
|
}
|
||||||
@@ -51,7 +51,8 @@ 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));
|
||||||
@@ -467,9 +468,10 @@ 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 path = new SKPath();
|
using var builder = new SKPathBuilder();
|
||||||
path.MoveTo(points[0]);
|
builder.MoveTo(points[0]);
|
||||||
foreach (var point in points.Skip(1)) path.LineTo(point);
|
foreach (var point in points.Skip(1)) builder.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,6 +26,8 @@ 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>();
|
||||||
@@ -33,11 +35,14 @@ 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 =>
|
||||||
@@ -139,6 +144,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<AppUpdateRelease>();
|
Set<AppUpdateRelease>();
|
||||||
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
|
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
|
||||||
Set<SystemFeatureSetting>();
|
Set<SystemFeatureSetting>();
|
||||||
|
public DbSet<CourseGradeStatisticsRefreshSetting> CourseGradeStatisticsRefreshSettings =>
|
||||||
|
Set<CourseGradeStatisticsRefreshSetting>();
|
||||||
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
||||||
|
|
||||||
protected override void ConfigureConventions(
|
protected override void ConfigureConventions(
|
||||||
@@ -511,6 +518,14 @@ 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 =>
|
||||||
@@ -530,6 +545,57 @@ 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);
|
||||||
@@ -614,7 +680,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
entity.Property(x => x.Description).HasMaxLength(1000);
|
entity.Property(x => x.Description).HasMaxLength(1000);
|
||||||
entity.Property(x => x.Requirements).HasMaxLength(1000);
|
entity.Property(x => x.Requirements).HasMaxLength(1000);
|
||||||
entity.HasIndex(x => new { x.TeachingTaskId, x.Code }).IsUnique();
|
// 集中安排会为同一教学任务的每一条实验课表记录生成项目;
|
||||||
|
// 自行安排仍由控制器保持“教学任务 + 编码”唯一。
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.Code,
|
||||||
|
x.ScheduleEntryId,
|
||||||
|
x.ScheduleWeek
|
||||||
|
})
|
||||||
|
.IsUnique();
|
||||||
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
|
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
|
||||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
.HasForeignKey(x => x.TeachingTaskId)
|
.HasForeignKey(x => x.TeachingTaskId)
|
||||||
@@ -1463,6 +1538,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasIndex(x => x.Key).IsUnique();
|
entity.HasIndex(x => x.Key).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<CourseGradeStatisticsRefreshSetting>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Key).HasMaxLength(50);
|
||||||
|
entity.HasIndex(x => x.Key).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<OfficialDocument>(entity =>
|
builder.Entity<OfficialDocument>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ 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";
|
||||||
|
private const string CourseGradeStatisticsRefreshSettingsMigration =
|
||||||
|
"20260809_53_course_grade_statistics_refresh_settings";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -671,6 +679,14 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
TeachingTaskGradeAnalyticsMigration,
|
TeachingTaskGradeAnalyticsMigration,
|
||||||
TeachingTaskGradeAnalyticsStatements,
|
TeachingTaskGradeAnalyticsStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var gradeRefreshSettingsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseGradeStatisticsRefreshSettings'")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
CourseGradeStatisticsRefreshSettingsMigration,
|
||||||
|
gradeRefreshSettingsExist ? [] : CourseGradeStatisticsRefreshSettingsStatements,
|
||||||
|
cancellationToken);
|
||||||
var swaggerSettingsExist = await db.Database
|
var swaggerSettingsExist = await db.Database
|
||||||
.SqlQueryRaw<int>(
|
.SqlQueryRaw<int>(
|
||||||
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'SystemFeatureSettings'")
|
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'SystemFeatureSettings'")
|
||||||
@@ -679,6 +695,32 @@ 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(
|
||||||
@@ -2940,4 +2982,88 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ON "SystemFeatureSettings" ("Key");
|
ON "SystemFeatureSettings" ("Key");
|
||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] CourseGradeStatisticsRefreshSettingsStatements =
|
||||||
|
[
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatisticsRefreshSettings" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatisticsRefreshSettings" PRIMARY KEY, "Key" TEXT NOT NULL, "IsEnabled" INTEGER NOT NULL, "IntervalSeconds" INTEGER NOT NULL, "BatchSize" INTEGER NOT NULL, "LastRunAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL);""",
|
||||||
|
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("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");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+6631
File diff suppressed because it is too large
Load Diff
+52
@@ -0,0 +1,52 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6655
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6739
File diff suppressed because it is too large
Load Diff
+90
@@ -0,0 +1,90 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6741
File diff suppressed because it is too large
Load Diff
+27
@@ -0,0 +1,27 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6827
File diff suppressed because it is too large
Load Diff
+87
@@ -0,0 +1,87 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||||
|
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260809112000_AllowAllScheduledExperimentLessons")]
|
||||||
|
partial class AllowAllScheduledExperimentLessons
|
||||||
|
{
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||||
|
|
||||||
|
public partial class AllowAllScheduledExperimentLessons : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// MySQL 可能将旧唯一索引用于外键支撑。先提供同列的普通索引,
|
||||||
|
// 再替换业务唯一索引,避免线上迁移因外键依赖而中断。
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code_FkSupport",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
columns: new[] { "TeachingTaskId", "Code" });
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code",
|
||||||
|
table: "ExperimentProjects");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
columns: new[] { "TeachingTaskId", "Code", "ScheduleEntryId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId",
|
||||||
|
table: "ExperimentProjects");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
columns: new[] { "TeachingTaskId", "Code" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code_FkSupport",
|
||||||
|
table: "ExperimentProjects");
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||||
|
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260809114000_SplitCentralizedExperimentProjectsByWeek")]
|
||||||
|
partial class SplitCentralizedExperimentProjectsByWeek
|
||||||
|
{
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||||
|
|
||||||
|
public partial class SplitCentralizedExperimentProjectsByWeek : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
ExecuteWhenMissing(
|
||||||
|
migrationBuilder,
|
||||||
|
"COLUMNS",
|
||||||
|
"COLUMN_NAME = 'ScheduleWeek'",
|
||||||
|
"ALTER TABLE `ExperimentProjects` ADD COLUMN `ScheduleWeek` int NULL");
|
||||||
|
|
||||||
|
ExecuteWhenPresent(
|
||||||
|
migrationBuilder,
|
||||||
|
"STATISTICS",
|
||||||
|
"INDEX_NAME = 'IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId'",
|
||||||
|
"DROP INDEX `IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId` ON `ExperimentProjects`");
|
||||||
|
|
||||||
|
ExecuteWhenMissing(
|
||||||
|
migrationBuilder,
|
||||||
|
"STATISTICS",
|
||||||
|
"INDEX_NAME = 'IX_ExpProj_Task_Code_Entry_Week'",
|
||||||
|
"CREATE UNIQUE INDEX `IX_ExpProj_Task_Code_Entry_Week` ON `ExperimentProjects` (`TeachingTaskId`, `Code`, `ScheduleEntryId`, `ScheduleWeek`)");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ExpProj_Task_Code_Entry_Week",
|
||||||
|
table: "ExperimentProjects");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
columns: new[] { "TeachingTaskId", "Code", "ScheduleEntryId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ScheduleWeek",
|
||||||
|
table: "ExperimentProjects");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ExecuteWhenMissing(
|
||||||
|
MigrationBuilder migrationBuilder,
|
||||||
|
string informationSchemaTable,
|
||||||
|
string condition,
|
||||||
|
string command)
|
||||||
|
{
|
||||||
|
ExecuteConditionally(migrationBuilder, informationSchemaTable, condition, command, "= 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ExecuteWhenPresent(
|
||||||
|
MigrationBuilder migrationBuilder,
|
||||||
|
string informationSchemaTable,
|
||||||
|
string condition,
|
||||||
|
string command)
|
||||||
|
{
|
||||||
|
ExecuteConditionally(migrationBuilder, informationSchemaTable, condition, command, "> 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ExecuteConditionally(
|
||||||
|
MigrationBuilder migrationBuilder,
|
||||||
|
string informationSchemaTable,
|
||||||
|
string condition,
|
||||||
|
string command,
|
||||||
|
string comparison)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql($"SET @jiaowu_exists = (SELECT COUNT(*) FROM `information_schema`.`{informationSchemaTable}` WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'ExperimentProjects' AND {condition})");
|
||||||
|
migrationBuilder.Sql($"SET @jiaowu_sql = IF(@jiaowu_exists {comparison}, '{command}', 'SELECT 1')");
|
||||||
|
migrationBuilder.Sql("PREPARE jiaowu_migration_statement FROM @jiaowu_sql");
|
||||||
|
migrationBuilder.Sql("EXECUTE jiaowu_migration_statement");
|
||||||
|
migrationBuilder.Sql("DEALLOCATE PREPARE jiaowu_migration_statement");
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||||
|
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260809121000_ExperimentPublishJobPayload")]
|
||||||
|
public partial class ExperimentPublishJobPayload : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ProjectIdsJson",
|
||||||
|
table: "ExamPublishJobs",
|
||||||
|
type: "longtext",
|
||||||
|
maxLength: 5000,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProjectIdsJson",
|
||||||
|
table: "ExamPublishJobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
+6870
File diff suppressed because it is too large
Load Diff
+48
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class CourseGradeStatisticsRefreshSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CourseGradeStatisticsRefreshSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Key = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
|
||||||
|
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
IntervalSeconds = table.Column<int>(type: "int", nullable: false),
|
||||||
|
BatchSize = table.Column<int>(type: "int", nullable: false),
|
||||||
|
LastRunAt = table.Column<DateTime>(type: "datetime(6)", 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_CourseGradeStatisticsRefreshSettings", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseGradeStatisticsRefreshSettings_Key",
|
||||||
|
table: "CourseGradeStatisticsRefreshSettings",
|
||||||
|
column: "Key",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CourseGradeStatisticsRefreshSettings");
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6873
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExamArrangementJobPayload : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ProjectIdsJson",
|
||||||
|
table: "ExamArrangementJobs",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProjectIdsJson",
|
||||||
|
table: "ExamArrangementJobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+279
-1
@@ -1098,6 +1098,105 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("CourseGradeStatisticsRefreshJobs");
|
b.ToTable("CourseGradeStatisticsRefreshJobs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("BatchSize")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("IntervalSeconds")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastRunAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CourseGradeStatisticsRefreshSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -1749,6 +1848,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<int>("ProcessedSessions")
|
b.Property<int>("ProcessedSessions")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectIdsJson")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
b.Property<Guid?>("RequestedByUserId")
|
b.Property<Guid?>("RequestedByUserId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
@@ -1851,6 +1953,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<Guid>("PlanId")
|
b.Property<Guid>("PlanId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectIdsJson")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
b.Property<Guid?>("RequestedByUserId")
|
b.Property<Guid?>("RequestedByUserId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
@@ -2370,6 +2475,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<Guid?>("ScheduleEntryId")
|
b.Property<Guid?>("ScheduleEntryId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int?>("ScheduleWeek")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime>("StartDate")
|
b.Property<DateTime>("StartDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
@@ -2386,7 +2494,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
b.HasIndex("ScheduleEntryId");
|
b.HasIndex("ScheduleEntryId");
|
||||||
|
|
||||||
b.HasIndex("TeachingTaskId", "Code")
|
b.HasIndex("TeachingTaskId", "Code", "ScheduleEntryId", "ScheduleWeek")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("Status", "StartDate", "EndDate");
|
b.HasIndex("Status", "StartDate", "EndDate");
|
||||||
@@ -3499,6 +3607,66 @@ 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")
|
||||||
@@ -4102,6 +4270,21 @@ 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")
|
||||||
@@ -4257,6 +4440,12 @@ 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");
|
||||||
|
|
||||||
@@ -4277,6 +4466,10 @@ 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");
|
||||||
@@ -5167,6 +5360,25 @@ 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")
|
||||||
@@ -6021,6 +6233,32 @@ 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")
|
||||||
@@ -6198,6 +6436,25 @@ 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")
|
||||||
@@ -6261,6 +6518,16 @@ 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")
|
||||||
@@ -6277,6 +6544,10 @@ 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");
|
||||||
@@ -6406,6 +6677,11 @@ 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");
|
||||||
@@ -6585,6 +6861,8 @@ 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,6 +44,7 @@ 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)
|
||||||
@@ -253,8 +254,15 @@ 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,6 +287,9 @@ 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 =>
|
||||||
@@ -286,16 +297,25 @@ 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 &&
|
||||||
(constraint?.RequiredCampusId is not Guid requiredCampusId ||
|
(kind == ScheduleEntryKind.Experiment ||
|
||||||
|
constraint?.RequiredCampusId is not Guid requiredCampusId ||
|
||||||
room.Building!.CampusId == requiredCampusId) &&
|
room.Building!.CampusId == requiredCampusId) &&
|
||||||
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
|
(kind == ScheduleEntryKind.Experiment ||
|
||||||
|
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 ||
|
||||||
TeachingVenueNatureRules.SupportsExperiment(room.TeachingVenueNature)) &&
|
constraint?.ExperimentRequiredCampusId is not Guid experimentCampusId ||
|
||||||
|
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,6 +2,7 @@ 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;
|
||||||
|
|
||||||
@@ -73,6 +74,8 @@ 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;
|
||||||
@@ -167,6 +170,7 @@ 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)
|
||||||
@@ -270,21 +274,40 @@ 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 &&
|
||||||
!IsExperimentRoom(classroom.RoomType))
|
constraint?.RequiredCampusId is Guid campusId &&
|
||||||
Fail(entry, $"实验课不能安排在“{classroom.RoomType}”类型的场地");
|
|
||||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
|
||||||
classroom.Building!.CampusId != campusId)
|
classroom.Building!.CampusId != campusId)
|
||||||
Fail(entry, "所选教室不在指定校区");
|
Fail(entry, "所选教室不在指定校区");
|
||||||
if (constraint?.RequiredBuildingId is Guid buildingId &&
|
if (entry.Kind != ScheduleEntryKind.Experiment &&
|
||||||
|
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 (allowedClassroomIds.Count > 0 &&
|
if (entry.Kind != ScheduleEntryKind.Experiment && 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 =>
|
||||||
@@ -305,12 +328,6 @@ 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)
|
||||||
{
|
{
|
||||||
|
|||||||
+27
-26
@@ -17,33 +17,34 @@ 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 scheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
var hasProjection = await db.PublishedScheduleOccurrences.AsNoTracking()
|
||||||
.Where(entry =>
|
.AnyAsync(entry => entry.AcademicTermId == term.Id, cancellationToken);
|
||||||
entry.ClassroomId.HasValue &&
|
if (hasProjection)
|
||||||
entry.SchedulePlan!.AcademicTermId == term.Id &&
|
|
||||||
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
|
||||||
entry.DayOfWeek == dayOfWeek &&
|
|
||||||
entry.StartWeek <= week &&
|
|
||||||
entry.EndWeek >= week &&
|
|
||||||
entry.StartPeriod < startPeriod + periodCount &&
|
|
||||||
startPeriod < entry.StartPeriod + entry.PeriodCount)
|
|
||||||
.Select(entry => new
|
|
||||||
{
|
|
||||||
entry.ClassroomId,
|
|
||||||
entry.WeekPattern,
|
|
||||||
entry.StartPeriod,
|
|
||||||
entry.PeriodCount
|
|
||||||
})
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
foreach (var entry in scheduleEntries.Where(entry =>
|
|
||||||
FreeClassroomRules.MatchesWeek(entry.WeekPattern, week) &&
|
|
||||||
FreeClassroomRules.PeriodsOverlap(
|
|
||||||
startPeriod,
|
|
||||||
periodCount,
|
|
||||||
entry.StartPeriod,
|
|
||||||
entry.PeriodCount)))
|
|
||||||
{
|
{
|
||||||
occupiedIds.Add(entry.ClassroomId!.Value);
|
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()
|
||||||
|
.Where(entry => entry.ClassroomId.HasValue &&
|
||||||
|
entry.SchedulePlan!.AcademicTermId == term.Id &&
|
||||||
|
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||||
|
entry.DayOfWeek == dayOfWeek && entry.StartWeek <= week &&
|
||||||
|
entry.EndWeek >= week && entry.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < entry.StartPeriod + entry.PeriodCount)
|
||||||
|
.Select(entry => new { entry.ClassroomId, entry.WeekPattern, entry.StartPeriod, entry.PeriodCount })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var entry in scheduleEntries.Where(entry =>
|
||||||
|
FreeClassroomRules.MatchesWeek(entry.WeekPattern, week) &&
|
||||||
|
FreeClassroomRules.PeriodsOverlap(startPeriod, periodCount, entry.StartPeriod, entry.PeriodCount)))
|
||||||
|
occupiedIds.Add(entry.ClassroomId!.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
var examRoomIds = await db.ExamSessions.AsNoTracking()
|
var examRoomIds = await db.ExamSessions.AsNoTracking()
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -422,6 +422,7 @@ 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>();
|
||||||
@@ -437,6 +438,9 @@ builder.Services.AddScoped<ExamArrangementJobProcessor>();
|
|||||||
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
|
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
|
||||||
builder.Services.AddScoped<ExamPublishJobProcessor>();
|
builder.Services.AddScoped<ExamPublishJobProcessor>();
|
||||||
builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
|
builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
|
||||||
|
builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>();
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>();
|
||||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||||
builder.Services.AddScoped<OperationalHealthService>();
|
builder.Services.AddScoped<OperationalHealthService>();
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class CourseGradeStatisticsRefreshSchedulerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task EnqueueDueAsync_UsesPersistentScheduleAndHonorsInterval()
|
||||||
|
{
|
||||||
|
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 scheduler = new CourseGradeStatisticsRefreshScheduler(
|
||||||
|
db,
|
||||||
|
NullLogger<CourseGradeStatisticsRefreshScheduler>.Instance);
|
||||||
|
var now = new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
await scheduler.EnqueueDueAsync(now, CancellationToken.None);
|
||||||
|
var setting = Assert.Single(await db.CourseGradeStatisticsRefreshSettings.ToListAsync());
|
||||||
|
Assert.True(setting.IsEnabled);
|
||||||
|
Assert.Equal(now, setting.LastRunAt);
|
||||||
|
|
||||||
|
await scheduler.EnqueueDueAsync(now.AddMinutes(1), CancellationToken.None);
|
||||||
|
Assert.Equal(now, setting.LastRunAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnqueueStaleAsync_GroupsByCourseTerm_AndSkipsActiveTarget()
|
||||||
|
{
|
||||||
|
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 college = new College { Code = "SCHEDULE", Name = "定时任务学院" };
|
||||||
|
var major = new Major
|
||||||
|
{
|
||||||
|
Code = "SCHEDULE-M",
|
||||||
|
Name = "定时任务专业",
|
||||||
|
College = college,
|
||||||
|
DegreeType = "本科"
|
||||||
|
};
|
||||||
|
var administrativeClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = "SCHEDULE-C",
|
||||||
|
Name = "定时任务一班",
|
||||||
|
Major = major,
|
||||||
|
Grade = 2026
|
||||||
|
};
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
StudentNumber = "SCHEDULE-001",
|
||||||
|
Name = "定时任务学生",
|
||||||
|
AdministrativeClass = administrativeClass,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
};
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "SCHEDULE-COURSE",
|
||||||
|
Name = "定时任务课程",
|
||||||
|
College = college,
|
||||||
|
Credits = 2,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 32
|
||||||
|
};
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-A",
|
||||||
|
Name = "2026-2027 学年第一学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
|
};
|
||||||
|
var firstTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "SCHEDULE-T1",
|
||||||
|
Name = "定时任务教学班一",
|
||||||
|
Course = course,
|
||||||
|
AcademicTerm = term,
|
||||||
|
Capacity = 30
|
||||||
|
};
|
||||||
|
var secondTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "SCHEDULE-T2",
|
||||||
|
Name = "定时任务教学班二",
|
||||||
|
Course = course,
|
||||||
|
AcademicTerm = term,
|
||||||
|
Capacity = 30
|
||||||
|
};
|
||||||
|
var firstSheet = PublishedSheet(firstTask, student, 80m);
|
||||||
|
var secondSheet = PublishedSheet(secondTask, student, 90m);
|
||||||
|
db.GradeSheets.AddRange(firstSheet, secondSheet);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var scheduler = new CourseGradeStatisticsRefreshScheduler(
|
||||||
|
db,
|
||||||
|
NullLogger<CourseGradeStatisticsRefreshScheduler>.Instance);
|
||||||
|
|
||||||
|
var queued = await scheduler.EnqueueStaleAsync(100, CancellationToken.None);
|
||||||
|
var queuedAgain = await scheduler.EnqueueStaleAsync(100, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, queued);
|
||||||
|
Assert.Equal(0, queuedAgain);
|
||||||
|
var job = Assert.Single(await db.CourseGradeStatisticsRefreshJobs.ToListAsync());
|
||||||
|
var outbox = Assert.Single(await db.BackgroundJobOutboxMessages.ToListAsync());
|
||||||
|
Assert.Equal(BackgroundJobKind.CourseGradeStatisticsRefresh, outbox.JobKind);
|
||||||
|
|
||||||
|
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
|
||||||
|
db.TeachingTaskGradeStatistics.AddRange(
|
||||||
|
FreshStatistic(firstSheet, course, term),
|
||||||
|
FreshStatistic(secondSheet, course, term));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var queuedAfterFreshStatistics = await scheduler.EnqueueStaleAsync(
|
||||||
|
100,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, queuedAfterFreshStatistics);
|
||||||
|
Assert.Single(await db.CourseGradeStatisticsRefreshJobs.ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GradeSheet PublishedSheet(
|
||||||
|
TeachingTask task,
|
||||||
|
Student student,
|
||||||
|
decimal score)
|
||||||
|
{
|
||||||
|
var sheet = new GradeSheet
|
||||||
|
{
|
||||||
|
TeachingTask = task,
|
||||||
|
Status = GradeSheetStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
sheet.Records.Add(new GradeRecord
|
||||||
|
{
|
||||||
|
GradeSheet = sheet,
|
||||||
|
Student = student,
|
||||||
|
TotalScore = score,
|
||||||
|
GradePoint = 3m
|
||||||
|
});
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeachingTaskGradeStatistic FreshStatistic(
|
||||||
|
GradeSheet sheet,
|
||||||
|
Course course,
|
||||||
|
AcademicTerm term) => new()
|
||||||
|
{
|
||||||
|
GradeSheetId = sheet.Id,
|
||||||
|
TeachingTaskId = sheet.TeachingTaskId,
|
||||||
|
CourseId = course.Id,
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
StudentCount = 1,
|
||||||
|
PassedCount = 1,
|
||||||
|
HighestScore = 80m,
|
||||||
|
AverageScore = 80m,
|
||||||
|
MedianScore = 80m,
|
||||||
|
LowestScore = 80m,
|
||||||
|
PassRate = 100m,
|
||||||
|
CalculatedAt = DateTime.UtcNow.AddMinutes(1)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -205,6 +205,79 @@ 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 = "计算机学院" };
|
||||||
@@ -358,6 +431,14 @@ 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,
|
||||||
|
|||||||
@@ -11,6 +11,160 @@ namespace Jiaowu.Api.Tests;
|
|||||||
|
|
||||||
public sealed class ExperimentGradesControllerTests
|
public sealed class ExperimentGradesControllerTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignedTeacher_CanSubmitExperimentGradeSheet()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentGradeFixture.CreateAsync();
|
||||||
|
var project = new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
Code = "LAB-TEACHER",
|
||||||
|
Name = "教师提交实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
fixture.Db.ExperimentProjects.Add(project);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var admin = fixture.ExperimentGrades(fixture.AdminScope);
|
||||||
|
await admin.CreateSheet(
|
||||||
|
new ExperimentGradeSheetRequest(
|
||||||
|
project.Id,
|
||||||
|
1,
|
||||||
|
60,
|
||||||
|
[new ExperimentGradeItemRequest(
|
||||||
|
"操作",
|
||||||
|
ExperimentGradeItemKind.Operation,
|
||||||
|
100)]),
|
||||||
|
CancellationToken.None);
|
||||||
|
var sheet = await fixture.Db.ExperimentGradeSheets
|
||||||
|
.Include(x => x.Items)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.SingleAsync();
|
||||||
|
var record = Assert.Single(sheet.Records);
|
||||||
|
var item = Assert.Single(sheet.Items);
|
||||||
|
|
||||||
|
var teacher = fixture.ExperimentGrades(fixture.TeacherScope);
|
||||||
|
Assert.IsType<NoContentResult>(await teacher.UpdateRecords(
|
||||||
|
sheet.Id,
|
||||||
|
new ExperimentGradeRecordsRequest(
|
||||||
|
[
|
||||||
|
new ExperimentGradeRecordRequest(
|
||||||
|
record.Id,
|
||||||
|
ExperimentParticipationStatus.Completed,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
[new ExperimentGradeItemScoreRequest(item.Id, 85, null)])
|
||||||
|
]),
|
||||||
|
CancellationToken.None));
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
Assert.IsType<NoContentResult>(await teacher.Submit(
|
||||||
|
sheet.Id,
|
||||||
|
CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ManagementList_IsPagedFilteredAndCollegeScoped()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentGradeFixture.CreateAsync();
|
||||||
|
var collegeId = await fixture.Db.Courses
|
||||||
|
.Where(x => x.Id == fixture.Task.CourseId)
|
||||||
|
.Select(x => x.CollegeId)
|
||||||
|
.SingleAsync();
|
||||||
|
for (var index = 1; index <= 12; index++)
|
||||||
|
{
|
||||||
|
fixture.Db.ExperimentProjects.Add(new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
Code = $"LAB-{index:00}",
|
||||||
|
Name = $"分页实验 {index:00}",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var otherCollege = new College { Code = "OTHER", Name = "其他学院" };
|
||||||
|
var otherCourse = new Course
|
||||||
|
{
|
||||||
|
CollegeId = otherCollege.Id,
|
||||||
|
Code = "OTHER-LAB",
|
||||||
|
Name = "其他学院实验",
|
||||||
|
Credits = 1,
|
||||||
|
TotalHours = 16,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.Practice,
|
||||||
|
AssessmentMethod = AssessmentMethod.Assessment
|
||||||
|
};
|
||||||
|
var otherTask = new TeachingTask
|
||||||
|
{
|
||||||
|
AcademicTermId = fixture.Term.Id,
|
||||||
|
CourseId = otherCourse.Id,
|
||||||
|
TaskNumber = "OTHER-LAB-01",
|
||||||
|
Name = "其他学院实验班",
|
||||||
|
Capacity = 20,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
var otherProject = new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = otherTask.Id,
|
||||||
|
Code = "FOREIGN-LAB",
|
||||||
|
Name = "不可见实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
fixture.Db.AddRange(otherCollege, otherCourse, otherTask, otherProject);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var collegeScope = new FixedScope(new CurrentUserScope(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"学院管理员",
|
||||||
|
collegeId,
|
||||||
|
DataScope.College,
|
||||||
|
new HashSet<string>([SystemRoles.CollegeAdmin])));
|
||||||
|
var controller = fixture.ExperimentGrades(collegeScope);
|
||||||
|
var page = Assert.IsType<OkObjectResult>(await controller.GetManagement(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
CancellationToken.None));
|
||||||
|
Assert.Equal(12, Property<int>(page.Value, "Total"));
|
||||||
|
Assert.Equal(10, Property<System.Collections.IEnumerable>(
|
||||||
|
page.Value,
|
||||||
|
"Items").Cast<object>().Count());
|
||||||
|
|
||||||
|
var filtered = Assert.IsType<OkObjectResult>(await controller.GetManagement(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"LAB-12",
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
CancellationToken.None));
|
||||||
|
Assert.Equal(1, Property<int>(filtered.Value, "Total"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T Property<T>(object? value, string name) =>
|
||||||
|
Assert.IsAssignableFrom<T>(
|
||||||
|
value!.GetType().GetProperty(name)!.GetValue(value));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IndependentExperimentGrade_CanPublishAndImportAsCourseSnapshot()
|
public async Task IndependentExperimentGrade_CanPublishAndImportAsCourseSnapshot()
|
||||||
{
|
{
|
||||||
@@ -260,6 +414,7 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
Student student,
|
Student student,
|
||||||
Classroom classroom,
|
Classroom classroom,
|
||||||
ICurrentUserDataScope adminScope,
|
ICurrentUserDataScope adminScope,
|
||||||
|
ICurrentUserDataScope teacherScope,
|
||||||
ICurrentUserDataScope studentScope)
|
ICurrentUserDataScope studentScope)
|
||||||
{
|
{
|
||||||
Connection = connection;
|
Connection = connection;
|
||||||
@@ -269,6 +424,7 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
Student = student;
|
Student = student;
|
||||||
Classroom = classroom;
|
Classroom = classroom;
|
||||||
AdminScope = adminScope;
|
AdminScope = adminScope;
|
||||||
|
TeacherScope = teacherScope;
|
||||||
StudentScope = studentScope;
|
StudentScope = studentScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +435,7 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
public Student Student { get; }
|
public Student Student { get; }
|
||||||
public Classroom Classroom { get; }
|
public Classroom Classroom { get; }
|
||||||
public ICurrentUserDataScope AdminScope { get; }
|
public ICurrentUserDataScope AdminScope { get; }
|
||||||
|
public ICurrentUserDataScope TeacherScope { get; }
|
||||||
public ICurrentUserDataScope StudentScope { get; }
|
public ICurrentUserDataScope StudentScope { get; }
|
||||||
|
|
||||||
public static async Task<ExperimentGradeFixture> CreateAsync()
|
public static async Task<ExperimentGradeFixture> CreateAsync()
|
||||||
@@ -410,6 +567,7 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
student,
|
student,
|
||||||
classroom,
|
classroom,
|
||||||
Scope(admin, SystemRoles.SuperAdmin, DataScope.All),
|
Scope(admin, SystemRoles.SuperAdmin, DataScope.All),
|
||||||
|
Scope(teacherUser, SystemRoles.Teacher, DataScope.Self),
|
||||||
Scope(studentUser, SystemRoles.Student, DataScope.Self));
|
Scope(studentUser, SystemRoles.Student, DataScope.Self));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,53 @@ public sealed class ExperimentsControllerTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CentralizedBatchProjects_CreatesOneProjectForEveryScheduledExperimentLesson()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
fixture.Db.ScheduleEntries.Add(new ScheduleEntry
|
||||||
|
{
|
||||||
|
SchedulePlanId = fixture.ScheduleEntry.SchedulePlanId,
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
Kind = ScheduleEntryKind.Experiment,
|
||||||
|
ClassroomId = fixture.SecondClassroom.Id,
|
||||||
|
DayOfWeek = 3,
|
||||||
|
StartPeriod = 5,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 8,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await fixture.Controller(fixture.ManagerScope).CreateProjects(
|
||||||
|
new ExperimentProjectBatchRequest(
|
||||||
|
[fixture.Task.Id],
|
||||||
|
"LAB-ALL",
|
||||||
|
"全部课表实验",
|
||||||
|
ExperimentArrangementMode.Centralized,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
fixture.Term.StartDate,
|
||||||
|
fixture.Term.StartDate.AddDays(14)),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<CreatedResult>(result);
|
||||||
|
var projects = await fixture.Db.ExperimentProjects
|
||||||
|
.Where(x => x.Code == "LAB-ALL")
|
||||||
|
.OrderBy(x => x.ScheduleEntryId)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.Equal(16, projects.Count);
|
||||||
|
Assert.All(projects, project =>
|
||||||
|
{
|
||||||
|
Assert.Equal(fixture.Task.Id, project.TeachingTaskId);
|
||||||
|
Assert.Equal(ExperimentArrangementMode.Centralized, project.ArrangementMode);
|
||||||
|
Assert.NotNull(project.ScheduleEntryId);
|
||||||
|
});
|
||||||
|
Assert.Equal(2, projects.Select(x => x.ScheduleEntryId).Distinct().Count());
|
||||||
|
Assert.Equal(8, projects.Select(x => x.ScheduleWeek).Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
|
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -85,13 +85,19 @@ 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);
|
||||||
@@ -103,6 +109,11 @@ 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class WarningRulePersistenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveRules_PersistsAutoCheck_AndReturnsNumericType()
|
||||||
|
{
|
||||||
|
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 term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "WARN-TERM",
|
||||||
|
Name = "预警测试学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
|
};
|
||||||
|
db.AcademicTerms.Add(term);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var controller = new WarningsController(db, new AllDataScope());
|
||||||
|
|
||||||
|
var saveResult = await controller.SaveRules(term.Id,
|
||||||
|
[
|
||||||
|
new WarningRuleDto(
|
||||||
|
WarningType.FailedCredits,
|
||||||
|
"不及格学分",
|
||||||
|
2m,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
1,
|
||||||
|
9,
|
||||||
|
30)
|
||||||
|
], CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(saveResult);
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var persisted = Assert.Single(await db.WarningRules.AsNoTracking().ToListAsync());
|
||||||
|
Assert.True(persisted.AutoCheckEnabled);
|
||||||
|
Assert.Equal(1, persisted.CheckDayOfWeek);
|
||||||
|
Assert.Equal(9, persisted.CheckHour);
|
||||||
|
Assert.Equal(30, persisted.CheckMinute);
|
||||||
|
|
||||||
|
var getResult = await controller.GetRules(term.Id, CancellationToken.None);
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(getResult);
|
||||||
|
var row = Assert.Single(Assert.IsAssignableFrom<IEnumerable>(ok.Value).Cast<object>());
|
||||||
|
var type = row.GetType().GetProperty("Type")?.GetValue(row);
|
||||||
|
Assert.Equal(1, Assert.IsType<int>(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AllDataScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"测试管理员",
|
||||||
|
null,
|
||||||
|
DataScope.All,
|
||||||
|
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<JiaowuBackendVersion>2.3.2-beta.2</JiaowuBackendVersion>
|
<JiaowuBackendVersion>2.3.2-beta.5</JiaowuBackendVersion>
|
||||||
<JiaowuFrontendVersion>2.3.2-beta.2</JiaowuFrontendVersion>
|
<JiaowuFrontendVersion>2.3.2-beta.5</JiaowuFrontendVersion>
|
||||||
<JiaowuSwaggerVersion>2.3.2-beta.2</JiaowuSwaggerVersion>
|
<JiaowuSwaggerVersion>2.3.2-beta.5</JiaowuSwaggerVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -343,6 +343,30 @@ button { cursor: pointer; }
|
|||||||
.form-grid.compact { align-items: center; }
|
.form-grid.compact { align-items: center; }
|
||||||
.form-grid.three { grid-template-columns: repeat(3, 1fr); }
|
.form-grid.three { grid-template-columns: repeat(3, 1fr); }
|
||||||
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||||
|
|
||||||
|
/* 由遮罩层 flex 居中;MessageBox 默认的全屏 fixed 包装层会脱离该布局。 */
|
||||||
|
.el-overlay.is-message-box {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.el-overlay.is-message-box .el-overlay-message-box {
|
||||||
|
position: static !important;
|
||||||
|
display: block !important;
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
min-height: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
.el-overlay.is-message-box .el-overlay-message-box::after { display: none !important; }
|
||||||
|
.el-overlay.is-message-box .el-message-box {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 !important;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #fff !important;
|
||||||
|
box-shadow: 0 18px 48px rgba(18, 37, 63, .28);
|
||||||
|
}
|
||||||
.password-reset-form { margin-top: 18px; }
|
.password-reset-form { margin-top: 18px; }
|
||||||
|
|
||||||
.registry-switch { display: grid; grid-template-columns: 1fr 1fr 180px; min-height: 112px; border: 1px solid var(--line); background: white; }
|
.registry-switch { display: grid; grid-template-columns: 1fr 1fr 180px; min-height: 112px; border: 1px solid var(--line); background: white; }
|
||||||
@@ -431,6 +455,10 @@ 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; }
|
||||||
@@ -439,6 +467,34 @@ 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; }
|
||||||
@@ -512,6 +568,8 @@ 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; }
|
||||||
@@ -1278,6 +1336,12 @@ 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; }
|
||||||
|
|||||||
@@ -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 { CopyDocument, Plus, Promotion, Refresh, Search } from '@element-plus/icons-vue'
|
import { Collection, 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,15 +14,20 @@ 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,
|
||||||
@@ -36,6 +41,9 @@ 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: '草稿',
|
||||||
@@ -47,6 +55,9 @@ 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
|
||||||
@@ -68,12 +79,46 @@ 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
|
||||||
@@ -112,6 +157,97 @@ 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,
|
||||||
@@ -327,6 +463,7 @@ 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
|
||||||
@@ -347,7 +484,10 @@ onMounted(async () => {
|
|||||||
<h2>培养方案</h2>
|
<h2>培养方案</h2>
|
||||||
<p>由学院按专业和入学年级维护课程结构;已发布方案可受控调整,修改结果即时生效。</p>
|
<p>由学院按专业和入学年级维护课程结构;已发布方案可受控调整,修改结果即时生效。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" :icon="Plus" @click="openPlan()">新建方案</el-button>
|
<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>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="maintenance-scope" aria-label="培养方案维护范围">
|
<section class="maintenance-scope" aria-label="培养方案维护范围">
|
||||||
@@ -426,7 +566,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>
|
||||||
@@ -450,12 +590,21 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div class="module-toolbar">
|
<div class="module-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<b>课程结构</b>
|
<b>{{ detailView === 'structure' ? '课程结构' : '学期视图' }}</b>
|
||||||
<span>指定必修须逐门通过;英语、体育等多选课程用“组内选修”,修满模块最低学分即可</span>
|
<span>{{ detailView === 'structure'
|
||||||
|
? '指定必修须逐门通过;英语、体育等多选课程用“组内选修”,修满模块最低学分即可'
|
||||||
|
: '按建议修读学期展示课程安排,包含每学期的课程数量、学分和学时。' }}</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>
|
||||||
@@ -471,6 +620,7 @@ 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>
|
||||||
@@ -500,6 +650,34 @@ onMounted(async () => {
|
|||||||
</el-table>
|
</el-table>
|
||||||
</section>
|
</section>
|
||||||
<el-empty v-if="selected.modules.length === 0" description="先建立课程模块,再添加课程" />
|
<el-empty v-if="selected.modules.length === 0" description="先建立课程模块,再添加课程" />
|
||||||
|
</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>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -527,7 +705,8 @@ 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">
|
||||||
@@ -535,7 +714,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">
|
||||||
@@ -572,5 +751,55 @@ 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>
|
||||||
|
|||||||
@@ -19,12 +19,21 @@ import {
|
|||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
|
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
|
||||||
|
const canViewAllColleges = computed(() =>
|
||||||
|
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false,
|
||||||
|
)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const terms = ref<any[]>([])
|
const terms = ref<any[]>([])
|
||||||
|
const colleges = ref<any[]>([])
|
||||||
const termId = ref('')
|
const termId = ref('')
|
||||||
const statusFilter = ref('')
|
const statusFilter = ref('')
|
||||||
|
const collegeId = ref('')
|
||||||
|
const projectKeyword = ref('')
|
||||||
|
const projectPage = ref(1)
|
||||||
|
const projectPageSize = ref(20)
|
||||||
|
const projectTotal = ref(0)
|
||||||
const projects = ref<any[]>([])
|
const projects = ref<any[]>([])
|
||||||
const studentResults = ref<any[]>([])
|
const studentResults = ref<any[]>([])
|
||||||
const detailDrawer = ref(false)
|
const detailDrawer = ref(false)
|
||||||
@@ -177,12 +186,20 @@ async function load() {
|
|||||||
if (isStudent.value) {
|
if (isStudent.value) {
|
||||||
studentResults.value = (await http.get('/experiment-grades/mine')).data
|
studentResults.value = (await http.get('/experiment-grades/mine')).data
|
||||||
} else {
|
} else {
|
||||||
projects.value = (await http.get('/experiment-grades/management', {
|
const data = (await http.get('/experiment-grades/management', {
|
||||||
params: {
|
params: {
|
||||||
academicTermId: termId.value || undefined,
|
academicTermId: termId.value || undefined,
|
||||||
status: statusFilter.value || undefined,
|
status: statusFilter.value || undefined,
|
||||||
|
collegeId: collegeId.value || undefined,
|
||||||
|
keyword: projectKeyword.value.trim() || undefined,
|
||||||
|
page: projectPage.value,
|
||||||
|
pageSize: projectPageSize.value,
|
||||||
},
|
},
|
||||||
})).data
|
})).data
|
||||||
|
projects.value = data.items
|
||||||
|
projectTotal.value = data.total
|
||||||
|
projectPage.value = data.page
|
||||||
|
projectPageSize.value = data.pageSize
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -367,7 +384,11 @@ function scoreTone(result: any) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!isStudent.value) {
|
if (!isStudent.value) {
|
||||||
try {
|
try {
|
||||||
terms.value = (await http.get('/base-data/terms')).data
|
const requests: Promise<any>[] = [http.get('/base-data/terms')]
|
||||||
|
if (canViewAllColleges.value) requests.push(http.get('/base-data/colleges'))
|
||||||
|
const [termResponse, collegeResponse] = await Promise.all(requests)
|
||||||
|
terms.value = termResponse.data
|
||||||
|
colleges.value = collegeResponse?.data ?? []
|
||||||
termId.value = defaultAcademicTermId(terms.value) ?? ''
|
termId.value = defaultAcademicTermId(terms.value) ?? ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -438,7 +459,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section class="grade-toolbar">
|
<section class="grade-toolbar">
|
||||||
<el-select v-model="termId" clearable placeholder="全部学期" @change="load">
|
<el-select v-model="termId" clearable placeholder="全部学期" @change="projectPage = 1; load()">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="term in terms"
|
v-for="term in terms"
|
||||||
:key="term.id"
|
:key="term.id"
|
||||||
@@ -447,10 +468,27 @@ onMounted(async () => {
|
|||||||
:class="academicTermOptionClass(term)"
|
:class="academicTermOptionClass(term)"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select v-model="statusFilter" clearable placeholder="全部成绩状态" @change="load">
|
<el-select v-model="statusFilter" clearable placeholder="全部成绩状态" @change="projectPage = 1; load()">
|
||||||
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<span>每个实验项目独立建单;课程只读取已发布的汇总快照。</span>
|
<el-select
|
||||||
|
v-if="canViewAllColleges"
|
||||||
|
v-model="collegeId"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="全部开课学院"
|
||||||
|
@change="projectPage = 1; load()"
|
||||||
|
>
|
||||||
|
<el-option v-for="college in colleges" :key="college.id" :label="college.name" :value="college.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-model="projectKeyword"
|
||||||
|
clearable
|
||||||
|
placeholder="项目、课程、教学班或教师"
|
||||||
|
@keyup.enter="projectPage = 1; load()"
|
||||||
|
@clear="projectPage = 1; load()"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" @click="projectPage = 1; load()">查询</el-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-loading="loading" class="grade-project-list">
|
<section v-loading="loading" class="grade-project-list">
|
||||||
@@ -488,6 +526,15 @@ onMounted(async () => {
|
|||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!loading && !projects.length" description="当前筛选条件下没有可评分实验项目" />
|
<el-empty v-if="!loading && !projects.length" description="当前筛选条件下没有可评分实验项目" />
|
||||||
</section>
|
</section>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="projectPage"
|
||||||
|
v-model:page-size="projectPageSize"
|
||||||
|
:total="projectTotal"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="load"
|
||||||
|
@size-change="projectPage = 1; load()"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -724,6 +771,7 @@ onMounted(async () => {
|
|||||||
.assessment-flow > i { display: grid; place-items: center; color: #8aa0aa; font-style: normal; background: #f4f7f8; }
|
.assessment-flow > i { display: grid; place-items: center; color: #8aa0aa; font-style: normal; background: #f4f7f8; }
|
||||||
.grade-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
.grade-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||||
.grade-toolbar .el-select { width: 220px; }
|
.grade-toolbar .el-select { width: 220px; }
|
||||||
|
.grade-toolbar .el-input { width: min(320px, 100%); }
|
||||||
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; }
|
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; }
|
||||||
.grade-project-list { display: grid; gap: 12px; min-height: 180px; }
|
.grade-project-list { display: grid; gap: 12px; min-height: 180px; }
|
||||||
.grade-project-card { min-width: 0; padding: 16px 18px; display: grid; grid-template-columns: 130px minmax(220px, 1.4fr) minmax(220px, 1fr) auto; align-items: center; gap: 18px; border: 1px solid #d9e4e8; border-left: 5px solid var(--grade-blue); background: #fff; box-shadow: 0 6px 18px rgb(30 68 86 / 5%); }
|
.grade-project-card { min-width: 0; padding: 16px 18px; display: grid; grid-template-columns: 130px minmax(220px, 1.4fr) minmax(220px, 1fr) auto; align-items: center; gap: 18px; border: 1px solid #d9e4e8; border-left: 5px solid var(--grade-blue); background: #fff; box-shadow: 0 6px 18px rgb(30 68 86 / 5%); }
|
||||||
@@ -812,7 +860,7 @@ onMounted(async () => {
|
|||||||
.assessment-flow { grid-template-columns: 1fr; }
|
.assessment-flow { grid-template-columns: 1fr; }
|
||||||
.assessment-flow > i { display: none; }
|
.assessment-flow > i { display: none; }
|
||||||
.grade-toolbar { align-items: stretch; flex-direction: column; }
|
.grade-toolbar { align-items: stretch; flex-direction: column; }
|
||||||
.grade-toolbar .el-select { width: 100%; }
|
.grade-toolbar .el-select, .grade-toolbar .el-input { width: 100%; }
|
||||||
.grade-toolbar > span { margin-left: 0; }
|
.grade-toolbar > span { margin-left: 0; }
|
||||||
.grade-project-card { grid-template-columns: 1fr; }
|
.grade-project-card { grid-template-columns: 1fr; }
|
||||||
.sheet-progress, .sheet-empty, .grade-project-card > .el-button { grid-column: 1; grid-row: auto; width: 100%; }
|
.sheet-progress, .sheet-empty, .grade-project-card > .el-button { grid-column: 1; grid-row: auto; width: 100%; }
|
||||||
|
|||||||
@@ -16,14 +16,24 @@ const loading = ref(false)
|
|||||||
const terms = ref<any[]>([])
|
const terms = ref<any[]>([])
|
||||||
const termId = ref('')
|
const termId = ref('')
|
||||||
const projects = ref<any[]>([])
|
const projects = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const selectedProjectIds = ref<string[]>([])
|
||||||
const options = reactive({
|
const options = reactive({
|
||||||
tasks: [] as any[],
|
tasks: [] as any[],
|
||||||
|
colleges: [] as any[],
|
||||||
scheduleEntries: [] as any[],
|
scheduleEntries: [] as any[],
|
||||||
classrooms: [] as any[],
|
classrooms: [] as any[],
|
||||||
periods: [] as any[],
|
periods: [] as any[],
|
||||||
})
|
})
|
||||||
const modeFilter = ref('')
|
const modeFilter = ref('')
|
||||||
const statusFilter = ref('')
|
const statusFilter = ref('')
|
||||||
|
const offeringCollegeId = ref('')
|
||||||
|
const courseKeyword = ref('')
|
||||||
|
const classKeyword = ref('')
|
||||||
|
const teacherKeyword = ref('')
|
||||||
|
const taskKeyword = ref('')
|
||||||
|
|
||||||
const projectDialog = ref(false)
|
const projectDialog = ref(false)
|
||||||
const editingProjectId = ref('')
|
const editingProjectId = ref('')
|
||||||
@@ -68,6 +78,51 @@ const batchProjectOptions = computed(() =>
|
|||||||
projects.value.filter((project) =>
|
projects.value.filter((project) =>
|
||||||
project.status !== 'Closed' && project.arrangementMode === 'SelfScheduled'),
|
project.status !== 'Closed' && project.arrangementMode === 'SelfScheduled'),
|
||||||
)
|
)
|
||||||
|
const taskGroups = computed(() => {
|
||||||
|
const groups = new Map<string, any>()
|
||||||
|
options.tasks.forEach((task) => {
|
||||||
|
const key = `${task.academicTermId}-${task.courseId}`
|
||||||
|
const group = groups.get(key) ?? {
|
||||||
|
key,
|
||||||
|
termName: task.termName,
|
||||||
|
courseCode: task.courseCode,
|
||||||
|
courseName: task.courseName,
|
||||||
|
tasks: [],
|
||||||
|
}
|
||||||
|
group.tasks.push(task)
|
||||||
|
groups.set(key, group)
|
||||||
|
})
|
||||||
|
return [...groups.values()]
|
||||||
|
})
|
||||||
|
const projectGroups = computed(() => {
|
||||||
|
const groups = new Map<string, any>()
|
||||||
|
projects.value.forEach((project) => {
|
||||||
|
const classKey = [...(project.classNames ?? [])].sort().join('|')
|
||||||
|
const key = `${project.academicTermId}-${project.courseCode}-${classKey}`
|
||||||
|
const group = groups.get(key) ?? {
|
||||||
|
key,
|
||||||
|
termName: project.termName,
|
||||||
|
courseCode: project.courseCode,
|
||||||
|
courseName: project.courseName,
|
||||||
|
classNames: project.classNames,
|
||||||
|
teacherNames: project.teacherNames,
|
||||||
|
taskNumbers: [],
|
||||||
|
projects: [],
|
||||||
|
}
|
||||||
|
if (!group.taskNumbers.includes(project.taskNumber)) group.taskNumbers.push(project.taskNumber)
|
||||||
|
group.projects.push(project)
|
||||||
|
groups.set(key, group)
|
||||||
|
})
|
||||||
|
return [...groups.values()]
|
||||||
|
})
|
||||||
|
const draftSelectedProjectIds = computed(() =>
|
||||||
|
selectedProjectIds.value.filter((id) =>
|
||||||
|
projects.value.some((project) => project.id === id && project.status === 'Draft')),
|
||||||
|
)
|
||||||
|
const allPageDraftSelected = computed(() => {
|
||||||
|
const ids = projects.value.filter((project) => project.status === 'Draft').map((project) => project.id)
|
||||||
|
return ids.length > 0 && ids.every((id) => selectedProjectIds.value.includes(id))
|
||||||
|
})
|
||||||
const activePeriods = computed(() =>
|
const activePeriods = computed(() =>
|
||||||
options.periods.filter((period) =>
|
options.periods.filter((period) =>
|
||||||
!selectedProject.value
|
!selectedProject.value
|
||||||
@@ -119,6 +174,11 @@ function onScheduleEntryChange() {
|
|||||||
onTaskChange()
|
onTaskChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatScheduleEntry(entry: any) {
|
||||||
|
const classes = entry.classNames?.join('、') || '选课学生'
|
||||||
|
return `${entry.courseCode} · ${entry.courseName} · ${entry.taskNumber} · ${classes} · 星期 ${entry.dayOfWeek} 第 ${entry.startPeriod}–${entry.startPeriod + entry.periodCount - 1} 节 · ${entry.campusName} ${entry.buildingName} ${entry.classroomName}`
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateProject() {
|
function openCreateProject() {
|
||||||
resetProjectForm()
|
resetProjectForm()
|
||||||
projectDialog.value = true
|
projectDialog.value = true
|
||||||
@@ -141,10 +201,10 @@ function openEditProject(project: any) {
|
|||||||
|
|
||||||
async function saveProject() {
|
async function saveProject() {
|
||||||
if (!projectForm.teachingTaskIds.length ||
|
if (!projectForm.teachingTaskIds.length ||
|
||||||
(projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
|
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
|
||||||
!projectForm.code.trim()
|
!projectForm.code.trim()
|
||||||
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
|
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
|
||||||
ElMessage.warning(projectForm.arrangementMode === 'Centralized'
|
ElMessage.warning(projectForm.arrangementMode === 'Centralized' && editingProjectId.value
|
||||||
? '请选择课表实验课,并填写项目编码、名称和开放日期'
|
? '请选择课表实验课,并填写项目编码、名称和开放日期'
|
||||||
: '请填写教学任务、项目编码、名称和开放日期')
|
: '请填写教学任务、项目编码、名称和开放日期')
|
||||||
return
|
return
|
||||||
@@ -164,16 +224,15 @@ async function saveProject() {
|
|||||||
if (editingProjectId.value) {
|
if (editingProjectId.value) {
|
||||||
await http.put(`/experiments/${editingProjectId.value}`, payload)
|
await http.put(`/experiments/${editingProjectId.value}`, payload)
|
||||||
ElMessage.success('实验项目已更新')
|
ElMessage.success('实验项目已更新')
|
||||||
} else if (projectForm.arrangementMode === 'Centralized') {
|
|
||||||
await http.post('/experiments', payload)
|
|
||||||
ElMessage.success('实验项目已绑定课表实验课')
|
|
||||||
} else {
|
} else {
|
||||||
await http.post('/experiments/batch', {
|
await http.post('/experiments/batch', {
|
||||||
...payload,
|
...payload,
|
||||||
teachingTaskId: undefined,
|
teachingTaskId: undefined,
|
||||||
teachingTaskIds: projectForm.teachingTaskIds,
|
teachingTaskIds: projectForm.teachingTaskIds,
|
||||||
})
|
})
|
||||||
ElMessage.success(`已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
|
ElMessage.success(projectForm.arrangementMode === 'Centralized'
|
||||||
|
? `已按 ${projectForm.teachingTaskIds.length} 个教学班的全部实验课生成项目`
|
||||||
|
: `已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
|
||||||
}
|
}
|
||||||
projectDialog.value = false
|
projectDialog.value = false
|
||||||
await load()
|
await load()
|
||||||
@@ -336,6 +395,76 @@ async function deleteProject(project: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePageDraftSelection(value: unknown) {
|
||||||
|
const ids = projects.value.filter((project) => project.status === 'Draft').map((project) => project.id)
|
||||||
|
selectedProjectIds.value = Boolean(value)
|
||||||
|
? [...new Set([...selectedProjectIds.value, ...ids])]
|
||||||
|
: selectedProjectIds.value.filter((id) => !ids.includes(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProjectSelection(projectId: string, value: unknown) {
|
||||||
|
selectedProjectIds.value = Boolean(value)
|
||||||
|
? [...new Set([...selectedProjectIds.value, projectId])]
|
||||||
|
: selectedProjectIds.value.filter((id) => id !== projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishSelectedProjects() {
|
||||||
|
const ids = draftSelectedProjectIds.value
|
||||||
|
if (!ids.length) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`将发布选中的 ${ids.length} 个草稿实验项目,并通知对应学生。`,
|
||||||
|
'批量发布实验项目',
|
||||||
|
{ confirmButtonText: '确认发布', cancelButtonText: '取消', type: 'warning' },
|
||||||
|
)
|
||||||
|
const { data } = await http.post('/experiments/batch/publish', { projectIds: ids })
|
||||||
|
ElMessage.success(`已提交 ${ids.length} 个实验项目的后台发布任务`)
|
||||||
|
selectedProjectIds.value = []
|
||||||
|
void pollPublishJob(data.jobId)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollPublishJob(jobId: string) {
|
||||||
|
try {
|
||||||
|
const { data } = await http.get(`/experiments/batch/publish-jobs/${jobId}`)
|
||||||
|
if (data.status === 'Succeeded') {
|
||||||
|
ElMessage.success('实验项目已完成发布')
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.status === 'Failed') {
|
||||||
|
ElMessage.error(data.errorMessage || '实验项目发布失败')
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.setTimeout(() => { void pollPublishJob(jobId) }, 1200)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedProjects() {
|
||||||
|
const ids = draftSelectedProjectIds.value
|
||||||
|
if (!ids.length) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`将删除选中的 ${ids.length} 个草稿实验项目及其未发布场次。`,
|
||||||
|
'批量删除实验项目',
|
||||||
|
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'error' },
|
||||||
|
)
|
||||||
|
await http.delete('/experiments/batch', { data: { projectIds: ids } })
|
||||||
|
ElMessage.success(`已删除 ${ids.length} 个实验项目`)
|
||||||
|
selectedProjectIds.value = []
|
||||||
|
await load()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function cancelSession(project: any, session: any) {
|
async function cancelSession(project: any, session: any) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
@@ -424,14 +553,20 @@ async function loadOptions() {
|
|||||||
if (isStudent.value) return
|
if (isStudent.value) return
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get('/experiments/options', {
|
const { data } = await http.get('/experiments/options', {
|
||||||
params: { academicTermId: termId.value || undefined },
|
params: {
|
||||||
|
academicTermId: termId.value || undefined,
|
||||||
|
offeringCollegeId: offeringCollegeId.value || undefined,
|
||||||
|
courseKeyword: courseKeyword.value.trim() || undefined,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
options.tasks = data.tasks
|
options.tasks = data.tasks
|
||||||
|
options.colleges = data.colleges
|
||||||
options.scheduleEntries = data.scheduleEntries
|
options.scheduleEntries = data.scheduleEntries
|
||||||
options.classrooms = data.classrooms
|
options.classrooms = data.classrooms
|
||||||
options.periods = data.periods
|
options.periods = data.periods
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.tasks = []
|
options.tasks = []
|
||||||
|
options.colleges = []
|
||||||
options.classrooms = []
|
options.classrooms = []
|
||||||
options.periods = []
|
options.periods = []
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -445,17 +580,28 @@ async function load() {
|
|||||||
projects.value = (await http.get('/experiments/student', {
|
projects.value = (await http.get('/experiments/student', {
|
||||||
params: { academicTermId: termId.value || undefined },
|
params: { academicTermId: termId.value || undefined },
|
||||||
})).data
|
})).data
|
||||||
|
total.value = projects.value.length
|
||||||
} else {
|
} else {
|
||||||
projects.value = (await http.get('/experiments/management', {
|
const { data } = await http.get('/experiments/management', {
|
||||||
params: {
|
params: {
|
||||||
academicTermId: termId.value || undefined,
|
academicTermId: termId.value || undefined,
|
||||||
arrangementMode: modeFilter.value || undefined,
|
arrangementMode: modeFilter.value || undefined,
|
||||||
status: statusFilter.value || undefined,
|
status: statusFilter.value || undefined,
|
||||||
|
offeringCollegeId: offeringCollegeId.value || undefined,
|
||||||
|
courseKeyword: courseKeyword.value.trim() || undefined,
|
||||||
|
classKeyword: classKeyword.value.trim() || undefined,
|
||||||
|
teacherKeyword: teacherKeyword.value.trim() || undefined,
|
||||||
|
taskKeyword: taskKeyword.value.trim() || undefined,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
},
|
},
|
||||||
})).data
|
})
|
||||||
|
projects.value = data.items
|
||||||
|
total.value = data.total
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
projects.value = []
|
projects.value = []
|
||||||
|
total.value = 0
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -463,10 +609,30 @@ async function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function changeTerm() {
|
async function changeTerm() {
|
||||||
|
page.value = 1
|
||||||
|
selectedProjectIds.value = []
|
||||||
await loadOptions()
|
await loadOptions()
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchTaskOptions() {
|
||||||
|
page.value = 1
|
||||||
|
selectedProjectIds.value = []
|
||||||
|
await loadOptions()
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePage(nextPage: number) {
|
||||||
|
page.value = nextPage
|
||||||
|
selectedProjectIds.value = []
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePageSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
await changePage(1)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
terms.value = (await http.get('/base-data/terms')).data
|
terms.value = (await http.get('/base-data/terms')).data
|
||||||
@@ -524,6 +690,13 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<template v-if="!isStudent">
|
<template v-if="!isStudent">
|
||||||
|
<el-select v-model="offeringCollegeId" clearable filterable placeholder="开课学院" @change="searchTaskOptions">
|
||||||
|
<el-option v-for="college in options.colleges" :key="college.collegeId" :label="college.collegeName" :value="college.collegeId" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="courseKeyword" clearable placeholder="课程名称或编码" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
|
||||||
|
<el-input v-model="classKeyword" clearable placeholder="教学班" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
|
||||||
|
<el-input v-model="teacherKeyword" clearable placeholder="任课教师" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
|
||||||
|
<el-input v-model="taskKeyword" clearable placeholder="教学任务号" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
|
||||||
<el-select v-model="modeFilter" clearable placeholder="全部安排方式" @change="load">
|
<el-select v-model="modeFilter" clearable placeholder="全部安排方式" @change="load">
|
||||||
<el-option label="集中安排" value="Centralized" />
|
<el-option label="集中安排" value="Centralized" />
|
||||||
<el-option label="自行安排" value="SelfScheduled" />
|
<el-option label="自行安排" value="SelfScheduled" />
|
||||||
@@ -534,12 +707,95 @@ onMounted(async () => {
|
|||||||
<el-option label="已关闭" value="Closed" />
|
<el-option label="已关闭" value="Closed" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</template>
|
</template>
|
||||||
<span class="result-note">共 {{ projects.length }} 个实验项目</span>
|
<span class="result-note">共 {{ total }} 个教学班课程</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="!isStudent" class="experiment-batch-bar">
|
||||||
|
<el-checkbox :model-value="allPageDraftSelected" :indeterminate="!!draftSelectedProjectIds.length && !allPageDraftSelected" @change="togglePageDraftSelection">
|
||||||
|
选中当前页全部草稿
|
||||||
|
</el-checkbox>
|
||||||
|
<span>已选 {{ draftSelectedProjectIds.length }} 个草稿项目</span>
|
||||||
|
<el-button size="small" type="primary" :disabled="!draftSelectedProjectIds.length" @click="publishSelectedProjects">批量发布</el-button>
|
||||||
|
<el-button size="small" type="danger" plain :disabled="!draftSelectedProjectIds.length" @click="deleteSelectedProjects">批量删除</el-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-loading="loading" class="project-list">
|
<section v-loading="loading" class="project-list">
|
||||||
|
<section v-for="group in projectGroups" :key="group.key" class="project-group">
|
||||||
|
<header class="project-group-head">
|
||||||
|
<div>
|
||||||
|
<span>{{ group.termName }}</span>
|
||||||
|
<h3>{{ group.courseCode }} · {{ group.courseName }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="project-group-task">
|
||||||
|
<b>{{ group.taskNumbers.join('、') }}</b>
|
||||||
|
<span>{{ group.classNames?.join('、') || '选课学生' }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="project-group-projects">
|
||||||
|
<el-table v-if="!isStudent" :data="group.projects" class="project-table" size="small">
|
||||||
|
<el-table-column width="46" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-checkbox
|
||||||
|
v-if="row.status === 'Draft'"
|
||||||
|
:model-value="selectedProjectIds.includes(row.id)"
|
||||||
|
@change="toggleProjectSelection(row.id, $event)"
|
||||||
|
aria-label="选择实验项目"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="实验项目" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="table-project-name">
|
||||||
|
<b>{{ row.code }} · {{ row.name }}</b>
|
||||||
|
<small v-if="row.description">{{ row.description }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="实验课次 / 场次" min-width="280">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.arrangementMode === 'Centralized' && row.scheduleEntry" class="table-schedule">
|
||||||
|
<b>第 {{ row.scheduleEntry.projectWeek ?? `${row.scheduleEntry.startWeek}–${row.scheduleEntry.endWeek}` }} 周 · 星期 {{ row.scheduleEntry.dayOfWeek }} · 第 {{ row.scheduleEntry.startPeriod }}–{{ row.scheduleEntry.startPeriod + row.scheduleEntry.periodCount - 1 }} 节</b>
|
||||||
|
<small>{{ row.scheduleEntry.campusName }} · {{ row.scheduleEntry.buildingName }} {{ row.scheduleEntry.classroomName }}</small>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="row.sessions.length" class="table-schedule">
|
||||||
|
<b>{{ row.sessions.length }} 个开放场次</b>
|
||||||
|
<small>{{ row.sessions.map((session: any) => formatSessionTime(session)).join(';') }}</small>
|
||||||
|
</div>
|
||||||
|
<span v-else class="table-muted">尚未安排</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="方式" width="100" align="center">
|
||||||
|
<template #default="{ row }"><el-tag :type="row.arrangementMode === 'Centralized' ? 'primary' : 'success'" effect="plain">{{ modeMeta[row.arrangementMode].label }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="88" align="center">
|
||||||
|
<template #default="{ row }"><el-tag :type="row.status === 'Published' ? 'success' : row.status === 'Closed' ? 'info' : 'warning'">{{ statusLabels[row.status] }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="244" fixed="right" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.status === 'Draft'">
|
||||||
|
<el-button size="small" text @click="openEditProject(row)">编辑</el-button>
|
||||||
|
<el-button size="small" text type="danger" @click="deleteProject(row)">删除</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:disabled="row.arrangementMode === 'Centralized' ? !row.scheduleEntry : !row.sessions.some((item: any) => item.status !== 'Cancelled')"
|
||||||
|
@click="publishProject(row)"
|
||||||
|
>发布</el-button>
|
||||||
|
</template>
|
||||||
|
<el-button v-else-if="row.status === 'Published'" size="small" text @click="closeProject(row)">关闭项目</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
@click="openSession(row)"
|
||||||
|
>安排场次</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
<article
|
<article
|
||||||
v-for="project in projects"
|
v-else
|
||||||
|
v-for="project in group.projects"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
class="project-card"
|
class="project-card"
|
||||||
:class="[
|
:class="[
|
||||||
@@ -551,9 +807,15 @@ onMounted(async () => {
|
|||||||
<div class="project-identity">
|
<div class="project-identity">
|
||||||
<span class="project-code">{{ project.courseCode }} · {{ project.code }}</span>
|
<span class="project-code">{{ project.courseCode }} · {{ project.code }}</span>
|
||||||
<h3>{{ project.name }}</h3>
|
<h3>{{ project.name }}</h3>
|
||||||
<p>{{ project.courseName }} · {{ project.taskNumber }}</p>
|
<p>{{ project.arrangementMode === 'Centralized' ? '统一到场实验' : '开放预约实验' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-tags">
|
<div class="project-tags">
|
||||||
|
<el-checkbox
|
||||||
|
v-if="!isStudent && project.status === 'Draft'"
|
||||||
|
:model-value="selectedProjectIds.includes(project.id)"
|
||||||
|
@change="toggleProjectSelection(project.id, $event)"
|
||||||
|
aria-label="选择实验项目"
|
||||||
|
/>
|
||||||
<el-tag
|
<el-tag
|
||||||
:type="project.arrangementMode === 'Centralized' ? 'primary' : 'success'"
|
:type="project.arrangementMode === 'Centralized' ? 'primary' : 'success'"
|
||||||
effect="plain"
|
effect="plain"
|
||||||
@@ -600,8 +862,8 @@ onMounted(async () => {
|
|||||||
<article class="session-ticket">
|
<article class="session-ticket">
|
||||||
<div class="ticket-date"><strong>课表</strong><span>固定</span></div>
|
<div class="ticket-date"><strong>课表</strong><span>固定</span></div>
|
||||||
<div class="ticket-body">
|
<div class="ticket-body">
|
||||||
<b>星期 {{ project.scheduleEntry.dayOfWeek }} · 第 {{ project.scheduleEntry.startPeriod }}–{{ project.scheduleEntry.startPeriod + project.scheduleEntry.periodCount - 1 }} 节</b>
|
<b>第 {{ project.scheduleEntry.projectWeek ?? `${project.scheduleEntry.startWeek}–${project.scheduleEntry.endWeek}` }} 周 · 星期 {{ project.scheduleEntry.dayOfWeek }} · 第 {{ project.scheduleEntry.startPeriod }}–{{ project.scheduleEntry.startPeriod + project.scheduleEntry.periodCount - 1 }} 节</b>
|
||||||
<span>第 {{ project.scheduleEntry.startWeek }}–{{ project.scheduleEntry.endWeek }} 周 · {{ project.scheduleEntry.campusName }} · {{ project.scheduleEntry.buildingName }} {{ project.scheduleEntry.classroomName }}</span>
|
<span>{{ project.scheduleEntry.campusName }} · {{ project.scheduleEntry.buildingName }} {{ project.scheduleEntry.classroomName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ticket-action"><el-tag type="primary" size="small" effect="plain">课表已安排</el-tag></div>
|
<div class="ticket-action"><el-tag type="primary" size="small" effect="plain">课表已安排</el-tag></div>
|
||||||
</article>
|
</article>
|
||||||
@@ -736,6 +998,8 @@ onMounted(async () => {
|
|||||||
</el-button>
|
</el-button>
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<el-empty
|
<el-empty
|
||||||
v-if="!loading && !projects.length"
|
v-if="!loading && !projects.length"
|
||||||
@@ -747,6 +1011,19 @@ onMounted(async () => {
|
|||||||
</el-empty>
|
</el-empty>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-if="!isStudent && total > pageSize"
|
||||||
|
class="experiment-pagination"
|
||||||
|
background
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
@current-change="changePage"
|
||||||
|
@size-change="changePageSize"
|
||||||
|
/>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="projectDialog"
|
v-model="projectDialog"
|
||||||
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
||||||
@@ -756,26 +1033,24 @@ onMounted(async () => {
|
|||||||
<el-form label-position="top" class="experiment-form">
|
<el-form label-position="top" class="experiment-form">
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<header><span>PROJECT</span><b>规定实验项目</b></header>
|
<header><span>PROJECT</span><b>规定实验项目</b></header>
|
||||||
<el-form-item v-if="projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
|
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
|
||||||
<el-select v-model="projectForm.scheduleEntryId" filterable :disabled="!!editingProjectId" placeholder="选择已安排实验室的实验课" @change="onScheduleEntryChange">
|
<el-select v-model="projectForm.scheduleEntryId" filterable :disabled="!!editingProjectId" placeholder="选择已安排实验室的实验课" @change="onScheduleEntryChange">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="entry in options.scheduleEntries"
|
v-for="entry in options.scheduleEntries"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
:label="`${entry.courseCode} · ${entry.courseName} · ${entry.taskNumber} · 星期 ${entry.dayOfWeek} 第 ${entry.startPeriod}–${entry.startPeriod + entry.periodCount - 1} 节 · ${entry.campusName} ${entry.buildingName} ${entry.classroomName}`"
|
:label="formatScheduleEntry(entry)"
|
||||||
:value="entry.id"
|
:value="entry.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<small class="form-help">集中实验复用课表的时间和实验室,不会再生成独立实验场次。</small>
|
<small class="form-help">显示当前管理范围内全部已发布、已安排实验室的实验课;集中实验复用其时间和实验室,不会再生成独立实验场次。</small>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-else :label="editingProjectId ? '所属教学任务' : '适用教学任务(可多选)'" required>
|
<el-form-item v-else :label="editingProjectId ? '所属教学任务' : '适用教学班(可多选)'" required>
|
||||||
<el-select
|
<el-select
|
||||||
|
v-if="editingProjectId"
|
||||||
v-model="projectForm.teachingTaskIds"
|
v-model="projectForm.teachingTaskIds"
|
||||||
filterable
|
filterable
|
||||||
:multiple="!editingProjectId"
|
disabled
|
||||||
collapse-tags
|
placeholder="所属教学任务"
|
||||||
collapse-tags-tooltip
|
|
||||||
:disabled="!!editingProjectId"
|
|
||||||
placeholder="选择同一学期、同一课程的已发布教学任务"
|
|
||||||
@change="onTaskChange"
|
@change="onTaskChange"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
@@ -783,13 +1058,45 @@ onMounted(async () => {
|
|||||||
:key="task.id"
|
:key="task.id"
|
||||||
:label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`"
|
:label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`"
|
||||||
:value="task.id"
|
:value="task.id"
|
||||||
:disabled="!!selectedTask
|
|
||||||
&& (task.academicTermId !== selectedTask.academicTermId
|
|
||||||
|| task.courseId !== selectedTask.courseId)"
|
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-checkbox-group
|
||||||
|
v-else
|
||||||
|
v-model="projectForm.teachingTaskIds"
|
||||||
|
class="grouped-task-picker"
|
||||||
|
@change="onTaskChange"
|
||||||
|
>
|
||||||
|
<div class="task-option-filters">
|
||||||
|
<el-select v-model="offeringCollegeId" clearable filterable placeholder="开课学院" @change="searchTaskOptions">
|
||||||
|
<el-option v-for="college in options.colleges" :key="college.collegeId" :label="college.collegeName" :value="college.collegeId" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="courseKeyword" clearable placeholder="课程名称或代码" @keyup.enter="searchTaskOptions" />
|
||||||
|
<el-button @click="searchTaskOptions">查询</el-button>
|
||||||
|
</div>
|
||||||
|
<section v-for="group in taskGroups" :key="group.key" class="task-picker-course">
|
||||||
|
<header>
|
||||||
|
<span>{{ group.termName }}</span>
|
||||||
|
<b>{{ group.courseCode }} · {{ group.courseName }}</b>
|
||||||
|
</header>
|
||||||
|
<div>
|
||||||
|
<el-checkbox
|
||||||
|
v-for="task in group.tasks"
|
||||||
|
:key="task.id"
|
||||||
|
:value="task.id"
|
||||||
|
:disabled="projectForm.arrangementMode === 'SelfScheduled' && !!selectedTask
|
||||||
|
&& (task.academicTermId !== selectedTask.academicTermId
|
||||||
|
|| task.courseId !== selectedTask.courseId)"
|
||||||
|
>
|
||||||
|
<b>{{ task.taskNumber }}</b>
|
||||||
|
<span>{{ task.classNames.join('、') || task.name }}</span>
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<el-empty v-if="!taskGroups.length" :image-size="46" description="没有匹配的已发布教学班,请调整筛选条件" />
|
||||||
|
</el-checkbox-group>
|
||||||
<small v-if="!editingProjectId" class="form-help">
|
<small v-if="!editingProjectId" class="form-help">
|
||||||
已选 {{ projectForm.teachingTaskIds.length }} 个;实验编码、名称、内容和开放日期将一次应用到这些教学任务。
|
<template v-if="projectForm.arrangementMode === 'Centralized'">已选 {{ projectForm.teachingTaskIds.length }} 个教学班;系统会为每个教学班的全部已发布实验课生成项目。</template>
|
||||||
|
<template v-else>已选 {{ projectForm.teachingTaskIds.length }} 个教学班;只能勾选同一学期、同一课程,实验编码、名称、内容和开放日期将一次应用到这些教学任务。</template>
|
||||||
</small>
|
</small>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="form-grid two">
|
<div class="form-grid two">
|
||||||
@@ -1066,14 +1373,35 @@ onMounted(async () => {
|
|||||||
.rail-switch i { width: 30px; height: 7px; border: 2px solid #7794a5; border-radius: 999px; background: #fff; }
|
.rail-switch i { width: 30px; height: 7px; border: 2px solid #7794a5; border-radius: 999px; background: #fff; }
|
||||||
.experiment-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 16px; }
|
.experiment-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 16px; }
|
||||||
.experiment-toolbar .el-select { width: 210px; }
|
.experiment-toolbar .el-select { width: 210px; }
|
||||||
|
.experiment-toolbar .el-input { width: 172px; }
|
||||||
.result-note { margin-left: auto; color: var(--muted); font-size: 12px; }
|
.result-note { margin-left: auto; color: var(--muted); font-size: 12px; }
|
||||||
|
.experiment-batch-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-height: 46px; margin: -6px 0 16px; padding: 8px 12px; border: 1px solid #cddce3; background: #f7fafb; }
|
||||||
|
.experiment-batch-bar > span { margin-right: auto; color: #5c7481; font-size: 12px; }
|
||||||
|
.experiment-pagination { justify-content: flex-end; margin-top: 4px; }
|
||||||
.project-list { display: grid; gap: 14px; min-height: 160px; }
|
.project-list { display: grid; gap: 14px; min-height: 160px; }
|
||||||
|
.project-group { overflow: hidden; border: 1px solid #cddce3; background: #f6fafb; }
|
||||||
|
.project-group-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 17px; border-left: 5px solid var(--lab-blue); background: #edf4f6; }
|
||||||
|
.project-group-head > div:first-child { display: grid; gap: 3px; }
|
||||||
|
.project-group-head h3 { margin: 0; color: var(--lab-ink); font-size: 15px; }
|
||||||
|
.project-group-head > div:first-child span { color: #68808d; font: 700 10px/1 Consolas, monospace; letter-spacing: .06em; }
|
||||||
|
.project-group-task { display: grid; justify-items: end; gap: 3px; color: #5c7481; font-size: 11px; text-align: right; }
|
||||||
|
.project-group-task b { color: #365364; font-size: 12px; }
|
||||||
|
.project-group-projects { display: grid; gap: 0; padding: 0; }
|
||||||
|
.project-table { --el-table-border-color: #dce5ea; --el-table-header-bg-color: #f7fafb; --el-table-row-hover-bg-color: #f6fafb; width: 100%; }
|
||||||
|
.project-table :deep(th.el-table__cell) { padding: 8px 0; color: #617986; font-size: 11px; font-weight: 650; }
|
||||||
|
.project-table :deep(td.el-table__cell) { padding: 9px 0; vertical-align: top; }
|
||||||
|
.table-project-name, .table-schedule { display: grid; gap: 4px; min-width: 0; }
|
||||||
|
.table-project-name b { color: var(--lab-ink); font-size: 13px; }
|
||||||
|
.table-project-name small, .table-schedule small { overflow: hidden; color: #667f8d; font-size: 11px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.table-schedule b { color: #365364; font-size: 12px; font-weight: 600; }
|
||||||
|
.table-muted { color: #8b9aa4; font-size: 12px; }
|
||||||
.project-card {
|
.project-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #dce5ea;
|
border: 0;
|
||||||
border-top: 4px solid var(--lab-blue);
|
border-top: 1px solid #dce5ea;
|
||||||
|
border-left: 4px solid var(--lab-blue);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 7px 22px rgb(35 67 85 / 5%);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.project-card.is-flexible { border-top-color: var(--lab-teal); }
|
.project-card.is-flexible { border-top-color: var(--lab-teal); }
|
||||||
.project-card.is-closed { opacity: .82; }
|
.project-card.is-closed { opacity: .82; }
|
||||||
@@ -1140,6 +1468,16 @@ onMounted(async () => {
|
|||||||
.student-booking-summary span { display: grid; gap: 2px; color: #365f5b; font-size: 12px; }
|
.student-booking-summary span { display: grid; gap: 2px; color: #365f5b; font-size: 12px; }
|
||||||
.student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
|
.student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
.experiment-form { display: grid; gap: 13px; }
|
.experiment-form { display: grid; gap: 13px; }
|
||||||
|
.grouped-task-picker { display: grid; gap: 10px; max-height: 310px; overflow: auto; padding: 10px; border: 1px solid #d8e3e8; background: #fff; }
|
||||||
|
.task-option-filters { display: grid; grid-template-columns: minmax(150px, 1fr) minmax(180px, 1.2fr) auto; gap: 8px; }
|
||||||
|
.task-picker-course { overflow: hidden; border: 1px solid #e0e8eb; }
|
||||||
|
.task-picker-course header { display: flex; align-items: baseline; gap: 8px; padding: 8px 10px; background: #f3f7f8; }
|
||||||
|
.task-picker-course header span { color: #738994; font: 700 10px/1 Consolas, monospace; }
|
||||||
|
.task-picker-course header b { color: #314f60; font-size: 12px; }
|
||||||
|
.task-picker-course > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 2px 12px; padding: 8px 10px; }
|
||||||
|
.task-picker-course :deep(.el-checkbox) { height: auto; margin-right: 0; white-space: normal; }
|
||||||
|
.task-picker-course :deep(.el-checkbox__label) { display: inline-flex; gap: 6px; min-width: 0; color: #536b78; font-size: 12px; }
|
||||||
|
.task-picker-course :deep(.el-checkbox__label b) { flex: 0 0 auto; color: #284757; }
|
||||||
.form-help { display: block; margin-top: 7px; color: var(--muted); line-height: 1.5; }
|
.form-help { display: block; margin-top: 7px; color: var(--muted); line-height: 1.5; }
|
||||||
.form-section { padding: 14px 15px 1px; border: 1px solid var(--line); background: #fbfcfd; }
|
.form-section { padding: 14px 15px 1px; border: 1px solid var(--line); background: #fbfcfd; }
|
||||||
.form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; }
|
.form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; }
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { Download, Refresh, Search } from '@element-plus/icons-vue'
|
import { Download, Refresh, Search, Setting } from '@element-plus/icons-vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import { downloadApiFile } from '../api/excel'
|
import { downloadApiFile } from '../api/excel'
|
||||||
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
|
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
interface TeachingClassItem {
|
interface TeachingClassItem {
|
||||||
gradeSheetId: string
|
gradeSheetId: string
|
||||||
@@ -25,6 +26,8 @@ interface TeachingClassItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const terms = ref<any[]>([])
|
const terms = ref<any[]>([])
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const canManageSchedule = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
|
||||||
const classes = ref<TeachingClassItem[]>([])
|
const classes = ref<TeachingClassItem[]>([])
|
||||||
const selected = ref<TeachingClassItem>()
|
const selected = ref<TeachingClassItem>()
|
||||||
const report = ref<any>()
|
const report = ref<any>()
|
||||||
@@ -37,6 +40,16 @@ const loading = ref(false)
|
|||||||
const reportLoading = ref(false)
|
const reportLoading = ref(false)
|
||||||
const exporting = ref(false)
|
const exporting = ref(false)
|
||||||
const historyMetric = ref<'average' | 'passRate' | 'excellentRate'>('average')
|
const historyMetric = ref<'average' | 'passRate' | 'excellentRate'>('average')
|
||||||
|
const scheduleDialogVisible = ref(false)
|
||||||
|
const scheduleLoading = ref(false)
|
||||||
|
const scheduleSaving = ref(false)
|
||||||
|
const schedule = reactive({
|
||||||
|
isEnabled: true,
|
||||||
|
intervalMinutes: 5,
|
||||||
|
batchSize: 100,
|
||||||
|
lastRunAt: null as string | null,
|
||||||
|
nextRunAt: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
const distributionElement = ref<HTMLElement>()
|
const distributionElement = ref<HTMLElement>()
|
||||||
const peerAverageElement = ref<HTMLElement>()
|
const peerAverageElement = ref<HTMLElement>()
|
||||||
@@ -134,6 +147,40 @@ async function refreshStatistics() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openScheduleSettings() {
|
||||||
|
scheduleDialogVisible.value = true
|
||||||
|
scheduleLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = (await http.get('/grade-analytics/refresh-schedule')).data
|
||||||
|
schedule.isEnabled = data.isEnabled
|
||||||
|
schedule.intervalMinutes = Math.max(1, Math.round(data.intervalSeconds / 60))
|
||||||
|
schedule.batchSize = data.batchSize
|
||||||
|
schedule.lastRunAt = data.lastRunAt
|
||||||
|
schedule.nextRunAt = data.nextRunAt
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
scheduleLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveScheduleSettings() {
|
||||||
|
scheduleSaving.value = true
|
||||||
|
try {
|
||||||
|
await http.put('/grade-analytics/refresh-schedule', {
|
||||||
|
isEnabled: schedule.isEnabled,
|
||||||
|
intervalSeconds: schedule.intervalMinutes * 60,
|
||||||
|
batchSize: schedule.batchSize,
|
||||||
|
})
|
||||||
|
ElMessage.success('成绩统计定时刷新设置已保存')
|
||||||
|
scheduleDialogVisible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
scheduleSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function exportWordReport() {
|
async function exportWordReport() {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
exporting.value = true
|
exporting.value = true
|
||||||
@@ -307,6 +354,7 @@ onBeforeUnmount(() => {
|
|||||||
<p>对比当前教学班、同课程教学班、学生来源范围与历年成绩;统计仅使用已正式发布成绩。</p>
|
<p>对比当前教学班、同课程教学班、学生来源范围与历年成绩;统计仅使用已正式发布成绩。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="intro-actions">
|
<div class="intro-actions">
|
||||||
|
<el-button v-if="canManageSchedule" :icon="Setting" @click="openScheduleSettings">定时刷新设置</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
:icon="Download"
|
:icon="Download"
|
||||||
@@ -318,6 +366,32 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="scheduleDialogVisible" title="成绩统计定时刷新" width="520px">
|
||||||
|
<el-form v-loading="scheduleLoading" label-width="130px">
|
||||||
|
<el-form-item label="启用定时刷新">
|
||||||
|
<el-switch v-model="schedule.isEnabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="刷新间隔">
|
||||||
|
<el-input-number v-model="schedule.intervalMinutes" :min="1" :max="1440" :disabled="!schedule.isEnabled" />
|
||||||
|
<span class="schedule-unit">分钟</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="单次处理上限">
|
||||||
|
<el-input-number v-model="schedule.batchSize" :min="1" :max="5000" :step="50" :disabled="!schedule.isEnabled" />
|
||||||
|
<span class="schedule-unit">个课程学期</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="运行状态">
|
||||||
|
<div class="schedule-status">
|
||||||
|
<span>上次扫描:{{ schedule.lastRunAt ? new Date(schedule.lastRunAt).toLocaleString('zh-CN') : '尚未运行' }}</span>
|
||||||
|
<span v-if="schedule.isEnabled">下次扫描:{{ schedule.nextRunAt ? new Date(schedule.nextRunAt).toLocaleString('zh-CN') : '启用后将尽快运行' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="scheduleDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="scheduleSaving" @click="saveScheduleSettings">保存设置</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<section class="filter-bar">
|
<section class="filter-bar">
|
||||||
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)">
|
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)">
|
||||||
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" />
|
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" />
|
||||||
@@ -461,6 +535,8 @@ onBeforeUnmount(() => {
|
|||||||
.chart-card h4 { margin: 5px 0 0; color: var(--ink); font-size: 16px; }
|
.chart-card h4 { margin: 5px 0 0; color: var(--ink); font-size: 16px; }
|
||||||
.chart-card header p, .chart-caption { min-width: 0; margin: 0; color: #667085; font-size: 11px; text-align: right; }
|
.chart-card header p, .chart-caption { min-width: 0; margin: 0; color: #667085; font-size: 11px; text-align: right; }
|
||||||
.chart-caption { margin-top: 9px; text-align: left; }
|
.chart-caption { margin-top: 9px; text-align: left; }
|
||||||
|
.schedule-unit { margin-left: 10px; color: #667085; font-size: 12px; }
|
||||||
|
.schedule-status { display: grid; gap: 3px; color: #667085; font-size: 12px; }
|
||||||
.chart { height: 310px; margin-top: 10px; }
|
.chart { height: 310px; margin-top: 10px; }
|
||||||
.chart-large { height: 360px; }
|
.chart-large { height: 360px; }
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
|||||||
+176
-21
@@ -55,9 +55,20 @@ const weekdays = [
|
|||||||
{ value: 7, label: '星期日' },
|
{ value: 7, label: '星期日' },
|
||||||
]
|
]
|
||||||
const experimentVenueNatures = [
|
const experimentVenueNatures = [
|
||||||
{ value: 2, label: '实验室' }, { value: 4, label: '实训室' },
|
{ value: 1, label: '普通教室' }, { value: 2, label: '实验室' },
|
||||||
{ value: 8, label: '计算机机房' }, { value: 16, label: '语音室' },
|
{ value: 4, label: '实训室' }, { value: 8, 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)
|
||||||
@@ -129,17 +140,21 @@ 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) =>
|
||||||
(!selectedTaskConstraint.value?.requiredCampusId
|
(entryForm.kind === 'Experiment' || !selectedTaskConstraint.value?.requiredCampusId
|
||||||
|| room.campusId === selectedTaskConstraint.value.requiredCampusId) &&
|
|| room.campusId === selectedTaskConstraint.value.requiredCampusId) &&
|
||||||
(!selectedTaskConstraint.value?.requiredBuildingId
|
(entryForm.kind === 'Experiment' || !selectedTaskConstraint.value?.requiredBuildingId
|
||||||
|| room.buildingId === selectedTaskConstraint.value.requiredBuildingId) &&
|
|| room.buildingId === selectedTaskConstraint.value.requiredBuildingId) &&
|
||||||
(!selectedTaskConstraint.value?.allowedClassroomIds?.length
|
(entryForm.kind === 'Experiment' || !selectedTaskConstraint.value?.allowedClassroomIds?.length
|
||||||
|| selectedTaskConstraint.value.allowedClassroomIds.includes(room.id)) &&
|
|| selectedTaskConstraint.value.allowedClassroomIds.includes(room.id)) &&
|
||||||
(entryForm.kind !== 'Experiment' || isExperimentRoom(room)),
|
(entryForm.kind !== 'Experiment' || !selectedTaskConstraint.value?.experimentRequiredCampusId
|
||||||
|
|| 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(() => {
|
||||||
@@ -192,6 +207,22 @@ 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)
|
||||||
@@ -205,6 +236,23 @@ 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 ?? []
|
||||||
@@ -293,11 +341,15 @@ 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),
|
||||||
@@ -317,7 +369,10 @@ 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 ?? [],
|
||||||
@@ -358,6 +413,11 @@ 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,
|
||||||
@@ -369,8 +429,9 @@ function openConstraintBatch() {
|
|||||||
|
|
||||||
async function saveConstraintBatch() {
|
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('请至少勾选一项需要批量修改的设置。')
|
||||||
@@ -390,6 +451,9 @@ 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),
|
||||||
@@ -409,6 +473,20 @@ 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,
|
||||||
@@ -708,10 +786,13 @@ 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 && (
|
||||||
(task.requiredCampusId && room.campusId !== task.requiredCampusId) ||
|
(entryForm.kind !== 'Experiment' && task.requiredCampusId && room.campusId !== task.requiredCampusId) ||
|
||||||
(task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) ||
|
(entryForm.kind !== 'Experiment' && task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) ||
|
||||||
(task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id)) ||
|
(entryForm.kind !== 'Experiment' && task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id)) ||
|
||||||
(entryForm.kind === 'Experiment' && !isExperimentRoom(room))
|
(entryForm.kind === 'Experiment' && task.experimentRequiredCampusId && room.campusId !== task.experimentRequiredCampusId) ||
|
||||||
|
(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
|
||||||
}
|
}
|
||||||
@@ -719,7 +800,9 @@ 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)
|
||||||
if (entryForm.kind === 'Experiment' && room && !isExperimentRoom(room)) {
|
const task = selectedTaskConstraint.value
|
||||||
|
if (entryForm.kind === 'Experiment' && room && task?.allowedExperimentClassroomIds?.length &&
|
||||||
|
!task.allowedExperimentClassroomIds.includes(room.id)) {
|
||||||
entryForm.classroomId = null
|
entryForm.classroomId = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1047,7 +1130,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 ? '仅显示约束范围内教室' : ''"
|
||||||
>
|
>
|
||||||
@@ -1227,12 +1310,38 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="实验课允许的场地性质">
|
<el-form-item label="实验课允许的场地性质">
|
||||||
<el-checkbox-group v-model="constraintForm.allowedExperimentVenueNatures">
|
<el-checkbox-group v-model="constraintForm.allowedExperimentVenueNatures">
|
||||||
<el-checkbox v-for="nature in experimentVenueNatures" :key="nature.value" :value="nature.value">{{ nature.label }}</el-checkbox>
|
<el-checkbox v-for="nature in experimentVenueNatures" :key="nature.value" :value="nature.value">{{ nature.label }}</el-checkbox>
|
||||||
</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">
|
||||||
@@ -1291,7 +1400,7 @@ onBeforeUnmount(() => {
|
|||||||
<el-checkbox v-model="constraintBatchForm.updateClassroomScope">
|
<el-checkbox v-model="constraintBatchForm.updateClassroomScope">
|
||||||
批量指定教室范围
|
批量指定教室范围
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
<div v-if="constraintBatchForm.updateClassroomScope" class="batch-classroom-scope">
|
<div v-if="constraintBatchForm.updateClassroomScope" class="batch-classroom-scope">
|
||||||
<el-alert
|
<el-alert
|
||||||
title="将统一设置为需要教室;校区、教学楼和教室均不选择时,表示清除原有限定并允许系统自动分配。"
|
title="将统一设置为需要教室;校区、教学楼和教室均不选择时,表示清除原有限定并允许系统自动分配。"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -1317,7 +1426,7 @@ onBeforeUnmount(() => {
|
|||||||
<el-option v-for="item in batchFilteredBuildings" :key="item.id" :label="item.name" :value="item.id" />
|
<el-option v-for="item in batchFilteredBuildings" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="统一指定可用教室">
|
<el-form-item label="统一指定可用教室">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="constraintBatchForm.allowedClassroomIds"
|
v-model="constraintBatchForm.allowedClassroomIds"
|
||||||
@@ -1337,6 +1446,52 @@ 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">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useAuthStore } from '../stores/auth'
|
|||||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin'))
|
const isWarningManager = computed(() => auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin'].includes(r)) ?? false)
|
||||||
const isCounselor = computed(() => auth.user?.roles.includes('Counselor') && !auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)))
|
const isCounselor = computed(() => auth.user?.roles.includes('Counselor') && !auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)))
|
||||||
const isStudent = computed(() => auth.user?.roles.includes('Student') && !auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(r)))
|
const isStudent = computed(() => auth.user?.roles.includes('Student') && !auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(r)))
|
||||||
|
|
||||||
@@ -43,11 +43,11 @@ const ruleRows = reactive([
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
if (isSuperAdmin.value && termId.value) {
|
if (isWarningManager.value && termId.value) {
|
||||||
const serverRules = (await http.get('/warnings/rules', { params: { academicTermId: termId.value } })).data
|
const serverRules = (await http.get('/warnings/rules', { params: { academicTermId: termId.value } })).data
|
||||||
// Merge server values into reactive rows
|
// Merge server values into reactive rows
|
||||||
for (const row of ruleRows) {
|
for (const row of ruleRows) {
|
||||||
const sr = serverRules.find((r: any) => r.type === row.type)
|
const sr = serverRules.find((r: any) => Number(r.type) === row.type)
|
||||||
if (sr) {
|
if (sr) {
|
||||||
row.threshold = sr.threshold
|
row.threshold = sr.threshold
|
||||||
row.isEnabled = sr.isEnabled
|
row.isEnabled = sr.isEnabled
|
||||||
@@ -62,7 +62,7 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isSuperAdmin.value || isCounselor.value)
|
if (isWarningManager.value || isCounselor.value)
|
||||||
records.value = (await http.get('/warnings/records', { params: { academicTermId: termId.value || undefined, type: filterType.value || undefined } })).data
|
records.value = (await http.get('/warnings/records', { params: { academicTermId: termId.value || undefined, type: filterType.value || undefined } })).data
|
||||||
if (isStudent.value) myWarnings.value = (await http.get('/warnings/my-warnings')).data
|
if (isStudent.value) myWarnings.value = (await http.get('/warnings/my-warnings')).data
|
||||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false }
|
} catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false }
|
||||||
@@ -72,6 +72,7 @@ async function saveRules() {
|
|||||||
try {
|
try {
|
||||||
const payload = ruleRows.map(r => ({ type: r.type, name: r.name, threshold: r.threshold, isEnabled: r.isEnabled, notifyStudent: r.notifyStudent, notifyCounselor: r.notifyCounselor, description: r.description, autoCheckEnabled: r.autoCheckEnabled, checkDayOfWeek: r.checkDayOfWeek, checkHour: r.checkHour, checkMinute: r.checkMinute }))
|
const payload = ruleRows.map(r => ({ type: r.type, name: r.name, threshold: r.threshold, isEnabled: r.isEnabled, notifyStudent: r.notifyStudent, notifyCounselor: r.notifyCounselor, description: r.description, autoCheckEnabled: r.autoCheckEnabled, checkDayOfWeek: r.checkDayOfWeek, checkHour: r.checkHour, checkMinute: r.checkMinute }))
|
||||||
await http.put('/warnings/rules', payload, { params: { academicTermId: termId.value } })
|
await http.put('/warnings/rules', payload, { params: { academicTermId: termId.value } })
|
||||||
|
await load()
|
||||||
ElMessage.success('预警规则已保存')
|
ElMessage.success('预警规则已保存')
|
||||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||||
}
|
}
|
||||||
@@ -102,7 +103,7 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-stack warn-page">
|
<div class="page-stack warn-page">
|
||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isSuperAdmin ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
|
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isWarningManager ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
<el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /></el-select>
|
<el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /></el-select>
|
||||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||||
@@ -110,7 +111,7 @@ onMounted(async () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Admin: Rule config -->
|
<!-- Admin: Rule config -->
|
||||||
<section v-if="isSuperAdmin && termId" class="warn-rules">
|
<section v-if="isWarningManager && termId" class="warn-rules">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||||
<h3 style="margin:0">预警规则配置</h3>
|
<h3 style="margin:0">预警规则配置</h3>
|
||||||
<div>
|
<div>
|
||||||
@@ -160,7 +161,7 @@ onMounted(async () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Records -->
|
<!-- Records -->
|
||||||
<section v-if="isSuperAdmin || isCounselor" v-loading="loading" class="warn-records">
|
<section v-if="isWarningManager || isCounselor" v-loading="loading" class="warn-records">
|
||||||
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px">
|
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px">
|
||||||
<h3 style="margin:0">预警记录</h3>
|
<h3 style="margin:0">预警记录</h3>
|
||||||
<el-select v-model="filterType" clearable placeholder="全部类型" @change="load" style="width:140px"><el-option v-for="(v,k) in typeLabels" :key="k" :label="v" :value="Number(k)" /></el-select>
|
<el-select v-model="filterType" clearable placeholder="全部类型" @change="load" style="width:140px"><el-option v-for="(v,k) in typeLabels" :key="k" :label="v" :value="Number(k)" /></el-select>
|
||||||
|
|||||||
Reference in New Issue
Block a user