优化排课
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
||||
|
||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||
|
||||
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/teacher-course-applications")]
|
||||
public sealed class TeacherCourseApplicationsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
private const string ReviewRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin;
|
||||
|
||||
[HttpGet("mine")]
|
||||
[Authorize(Roles = SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> GetMine(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var teacher = await CurrentTeacherAsync(cancellationToken);
|
||||
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
|
||||
var source = db.TeacherCourseApplications.AsNoTracking()
|
||||
.Where(x => x.TeacherId == teacher.Id);
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
return Ok(await source
|
||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.Course!.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
x.CourseId,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course.Name,
|
||||
CollegeName = x.Course.College!.Name,
|
||||
x.Status,
|
||||
x.Statement,
|
||||
x.ReviewComment,
|
||||
x.SubmittedAt,
|
||||
x.ReviewedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("mine")]
|
||||
[Authorize(Roles = SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> Submit(
|
||||
TeacherCourseApplicationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var teacher = await CurrentTeacherAsync(cancellationToken);
|
||||
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
|
||||
if (!await db.AcademicTerms.AnyAsync(
|
||||
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
||||
cancellationToken))
|
||||
return ValidationProblem("所选学期不存在或已停用。");
|
||||
if (!await db.Courses.AnyAsync(
|
||||
x => x.Id == request.CourseId && x.IsEnabled,
|
||||
cancellationToken))
|
||||
return ValidationProblem("所选课程不存在或已停用。");
|
||||
|
||||
var application = await db.TeacherCourseApplications
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeacherId == teacher.Id &&
|
||||
x.CourseId == request.CourseId,
|
||||
cancellationToken);
|
||||
if (application is not null &&
|
||||
application.Status is TeacherCourseApplicationStatus.Pending or
|
||||
TeacherCourseApplicationStatus.Approved)
|
||||
return ConflictProblem("该学期的课程申报已提交或已审核通过。");
|
||||
if (application is null)
|
||||
{
|
||||
application = new TeacherCourseApplication
|
||||
{
|
||||
AcademicTermId = request.AcademicTermId,
|
||||
TeacherId = teacher.Id,
|
||||
CourseId = request.CourseId
|
||||
};
|
||||
db.TeacherCourseApplications.Add(application);
|
||||
}
|
||||
application.Status = TeacherCourseApplicationStatus.Pending;
|
||||
application.Statement = Normalize(request.Statement);
|
||||
application.ReviewComment = null;
|
||||
application.SubmittedAt = DateTime.UtcNow;
|
||||
application.ReviewedAt = null;
|
||||
application.ReviewedByUserId = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Created(string.Empty, new { application.Id });
|
||||
}
|
||||
|
||||
[HttpDelete("mine/{id:guid}")]
|
||||
[Authorize(Roles = SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> Withdraw(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var teacher = await CurrentTeacherAsync(cancellationToken);
|
||||
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
|
||||
var application = await db.TeacherCourseApplications.FirstOrDefaultAsync(
|
||||
x => x.Id == id && x.TeacherId == teacher.Id,
|
||||
cancellationToken);
|
||||
if (application is null) return NotFound();
|
||||
if (application.Status != TeacherCourseApplicationStatus.Pending)
|
||||
return ConflictProblem("只有待审核申报可以撤回。");
|
||||
application.Status = TeacherCourseApplicationStatus.Withdrawn;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("reviews")]
|
||||
[Authorize(Roles = ReviewRoles)]
|
||||
public async Task<ActionResult> GetReviews(
|
||||
Guid? academicTermId,
|
||||
TeacherCourseApplicationStatus? status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = ScopedApplications().AsNoTracking();
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
if (status.HasValue) source = source.Where(x => x.Status == status);
|
||||
return Ok(await source
|
||||
.OrderBy(x => x.Status)
|
||||
.ThenByDescending(x => x.SubmittedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
x.TeacherId,
|
||||
x.Teacher!.TeacherNumber,
|
||||
TeacherName = x.Teacher.Name,
|
||||
TeacherCollegeName = x.Teacher.College!.Name,
|
||||
x.CourseId,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course.Name,
|
||||
CourseCollegeName = x.Course.College!.Name,
|
||||
x.Status,
|
||||
x.Statement,
|
||||
x.ReviewComment,
|
||||
x.SubmittedAt,
|
||||
x.ReviewedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/review")]
|
||||
[Authorize(Roles = ReviewRoles)]
|
||||
public async Task<ActionResult> Review(
|
||||
Guid id,
|
||||
TeacherCourseReviewRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Status is not (
|
||||
TeacherCourseApplicationStatus.Approved or
|
||||
TeacherCourseApplicationStatus.Rejected))
|
||||
return ValidationProblem("审核结论只能为通过或驳回。");
|
||||
var application = await ScopedApplications()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (application is null) return NotFound();
|
||||
if (application.Status != TeacherCourseApplicationStatus.Pending)
|
||||
return ConflictProblem("只有待审核申报可以审核。");
|
||||
application.Status = request.Status;
|
||||
application.ReviewComment = Normalize(request.ReviewComment);
|
||||
application.ReviewedAt = DateTime.UtcNow;
|
||||
application.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("eligible")]
|
||||
[Authorize(Roles = ReviewRoles)]
|
||||
public async Task<ActionResult> GetEligible(
|
||||
Guid academicTermId,
|
||||
Guid courseId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = ScopedApplications().AsNoTracking()
|
||||
.Where(x =>
|
||||
x.AcademicTermId == academicTermId &&
|
||||
x.CourseId == courseId &&
|
||||
x.Status == TeacherCourseApplicationStatus.Approved &&
|
||||
x.Teacher!.Status == TeacherStatus.Active);
|
||||
return Ok(await source
|
||||
.OrderBy(x => x.Teacher!.TeacherNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.TeacherId,
|
||||
x.Teacher!.TeacherNumber,
|
||||
x.Teacher.Name,
|
||||
x.Teacher.Title,
|
||||
CollegeName = x.Teacher.College!.Name
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
private IQueryable<TeacherCourseApplication> ScopedApplications()
|
||||
{
|
||||
var source = db.TeacherCourseApplications.AsQueryable();
|
||||
var collegeId = currentUserDataScope.Current.RestrictedCollegeId;
|
||||
return collegeId.HasValue
|
||||
? source.Where(x => x.Teacher!.CollegeId == collegeId.Value)
|
||||
: source;
|
||||
}
|
||||
|
||||
private Task<Teacher?> CurrentTeacherAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
return db.Teachers.FirstOrDefaultAsync(
|
||||
x => x.UserId == userId && x.Status == TeacherStatus.Active,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
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 TeacherCourseApplicationRequest(
|
||||
Guid AcademicTermId,
|
||||
Guid CourseId,
|
||||
[MaxLength(500)] string? Statement);
|
||||
|
||||
public sealed record TeacherCourseReviewRequest(
|
||||
TeacherCourseApplicationStatus Status,
|
||||
[MaxLength(500)] string? ReviewComment);
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -72,6 +74,7 @@ public sealed class TeachingTasksController(
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
x.GenerationBatchCode,
|
||||
x.Status,
|
||||
TeacherNames = x.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
@@ -108,6 +111,7 @@ public sealed class TeachingTasksController(
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
x.GenerationBatchCode,
|
||||
x.Status,
|
||||
x.Notes,
|
||||
x.PublishedAt,
|
||||
@@ -252,6 +256,163 @@ public sealed class TeachingTasksController(
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("generate-public-course")]
|
||||
public async Task<ActionResult> GeneratePublicCourseTasks(
|
||||
PublicCourseTaskGenerationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.StartWeek > request.EndWeek)
|
||||
return ValidationProblem("开始周不能晚于结束周。");
|
||||
var term = await db.AcademicTerms.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (term is null) return ValidationProblem("所选学期不存在或已停用。");
|
||||
var course = await db.Courses.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == request.CourseId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
|
||||
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
|
||||
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
|
||||
var scopedCollegeId = ScopedCollegeId();
|
||||
if (scopedCollegeId.HasValue && course.CollegeId != scopedCollegeId.Value)
|
||||
return Forbid();
|
||||
|
||||
var classIds = request.ClassIds.Distinct().ToArray();
|
||||
if (classIds.Length == 0) return ValidationProblem("请至少选择一个行政班。");
|
||||
var classesQuery = db.AdministrativeClasses
|
||||
.Where(x => classIds.Contains(x.Id) && x.IsEnabled)
|
||||
.Include(x => x.Major)
|
||||
.Include(x => x.Students)
|
||||
.AsQueryable();
|
||||
if (scopedCollegeId.HasValue)
|
||||
classesQuery = classesQuery.Where(x => x.Major!.CollegeId == scopedCollegeId.Value);
|
||||
var classes = await classesQuery
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (classes.Count != classIds.Length)
|
||||
return ValidationProblem("存在无效或不在当前数据范围内的行政班。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable,
|
||||
cancellationToken);
|
||||
var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
|
||||
.Where(x =>
|
||||
classIds.Contains(x.AdministrativeClassId) &&
|
||||
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeachingTask.CourseId == request.CourseId)
|
||||
.Select(x => x.AdministrativeClassId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
if (assignedClassIds.Count > 0)
|
||||
{
|
||||
var names = classes
|
||||
.Where(x => assignedClassIds.Contains(x.Id))
|
||||
.Select(x => x.Name);
|
||||
return ConflictProblem(
|
||||
$"以下行政班已生成该课程教学任务:{string.Join('、', names)}。");
|
||||
}
|
||||
|
||||
var eligibleTeachers = await db.TeacherCourseApplications.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.CourseId == request.CourseId &&
|
||||
x.Status == TeacherCourseApplicationStatus.Approved &&
|
||||
x.Teacher!.Status == TeacherStatus.Active)
|
||||
.Where(x => !scopedCollegeId.HasValue ||
|
||||
x.Teacher!.CollegeId == scopedCollegeId.Value)
|
||||
.Select(x => x.Teacher!)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (eligibleTeachers.Count == 0)
|
||||
return ConflictProblem("该课程没有学院审核通过的可授课教师,无法生成教学任务。");
|
||||
|
||||
var eligibleTeacherIds = eligibleTeachers.Select(x => x.Id).ToArray();
|
||||
var existingLoads = await db.TeachingTaskTeachers.AsNoTracking()
|
||||
.Where(x =>
|
||||
eligibleTeacherIds.Contains(x.TeacherId) &&
|
||||
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeachingTask.Status != TeachingTaskStatus.Closed)
|
||||
.GroupBy(x => x.TeacherId)
|
||||
.Select(group => new { TeacherId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(x => x.TeacherId, x => x.Count, cancellationToken);
|
||||
var existingNumbers = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == request.AcademicTermId)
|
||||
.Select(x => x.TaskNumber)
|
||||
.ToHashSetAsync(cancellationToken);
|
||||
var batchCode =
|
||||
$"AUTO-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..31];
|
||||
var groups = classes.Chunk(request.ClassesPerTask).ToList();
|
||||
var teacherAssignments = PublicCourseTaskAssignmentPlanner.AssignTeachers(
|
||||
eligibleTeacherIds,
|
||||
existingLoads,
|
||||
groups.Count);
|
||||
var created = new List<TeachingTask>();
|
||||
for (var index = 0; index < groups.Count; index++)
|
||||
{
|
||||
var teacher = eligibleTeachers.First(x => x.Id == teacherAssignments[index]);
|
||||
var group = groups[index];
|
||||
var taskNumber = NextTaskNumber(
|
||||
term.Code,
|
||||
course.Code,
|
||||
index + 1,
|
||||
existingNumbers);
|
||||
existingNumbers.Add(taskNumber);
|
||||
var studentCount = group.Sum(administrativeClass =>
|
||||
administrativeClass.Students.Count(student =>
|
||||
student.Status == StudentStatus.Active));
|
||||
var task = new TeachingTask
|
||||
{
|
||||
TaskNumber = taskNumber,
|
||||
Name = $"{course.Name}教学班 {index + 1:D2}",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = Math.Max(1, studentCount),
|
||||
StartWeek = request.StartWeek,
|
||||
EndWeek = request.EndWeek,
|
||||
WeeklyHours = request.WeeklyHours,
|
||||
GenerationBatchCode = batchCode,
|
||||
Notes = "公共课合班自动生成,发布前可继续调整。",
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher
|
||||
{
|
||||
TeacherId = teacher.Id,
|
||||
IsPrimary = true
|
||||
}
|
||||
],
|
||||
Classes = group.Select(administrativeClass =>
|
||||
new TeachingTaskClass
|
||||
{
|
||||
AdministrativeClassId = administrativeClass.Id
|
||||
}).ToList()
|
||||
};
|
||||
created.Add(task);
|
||||
}
|
||||
|
||||
db.TeachingTasks.AddRange(created);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
BatchCode = batchCode,
|
||||
CreatedCount = created.Count,
|
||||
Tasks = created.Select(task => new
|
||||
{
|
||||
task.Id,
|
||||
task.TaskNumber,
|
||||
task.Name,
|
||||
TeacherName = eligibleTeachers
|
||||
.First(x => x.Id == task.Teachers.Single().TeacherId).Name,
|
||||
ClassNames = classes
|
||||
.Where(x => task.Classes.Any(item =>
|
||||
item.AdministrativeClassId == x.Id))
|
||||
.Select(x => x.Name),
|
||||
task.Capacity
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> ScopedTasks()
|
||||
{
|
||||
var source = db.TeachingTasks.AsQueryable();
|
||||
@@ -285,6 +446,16 @@ public sealed class TeachingTasksController(
|
||||
x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active,
|
||||
cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("存在无效或非在职授课教师。");
|
||||
if (teacherIds.Length > 0 &&
|
||||
course.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective &&
|
||||
await db.TeacherCourseApplications.CountAsync(
|
||||
x =>
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.CourseId == request.CourseId &&
|
||||
teacherIds.Contains(x.TeacherId) &&
|
||||
x.Status == TeacherCourseApplicationStatus.Approved,
|
||||
cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("公共课授课教师必须已完成课程申报并经学院审核通过。");
|
||||
|
||||
var classIds = request.ClassIds.Distinct().ToArray();
|
||||
var classes = db.AdministrativeClasses.AsNoTracking()
|
||||
@@ -344,6 +515,24 @@ public sealed class TeachingTasksController(
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string NextTaskNumber(
|
||||
string termCode,
|
||||
string courseCode,
|
||||
int sequence,
|
||||
IReadOnlySet<string> existing)
|
||||
{
|
||||
var prefix = $"{termCode}-{courseCode}"
|
||||
.Replace(" ", string.Empty, StringComparison.Ordinal);
|
||||
if (prefix.Length > 31) prefix = prefix[..31];
|
||||
var candidateSequence = sequence;
|
||||
while (true)
|
||||
{
|
||||
var candidate = $"{prefix}-A{candidateSequence:D2}";
|
||||
if (!existing.Contains(candidate)) return candidate;
|
||||
candidateSequence++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TeachingTaskRequest(
|
||||
@@ -359,3 +548,12 @@ public sealed record TeachingTaskRequest(
|
||||
Guid? PrimaryTeacherId,
|
||||
IReadOnlyCollection<Guid> ClassIds,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record PublicCourseTaskGenerationRequest(
|
||||
Guid AcademicTermId,
|
||||
Guid CourseId,
|
||||
[Range(1, 10)] int ClassesPerTask,
|
||||
[Range(1, 30)] int StartWeek,
|
||||
[Range(1, 30)] int EndWeek,
|
||||
[Range(1, 40)] int WeeklyHours,
|
||||
IReadOnlyCollection<Guid> ClassIds);
|
||||
|
||||
@@ -15,12 +15,30 @@ public sealed class TeachingTask : EntityBase
|
||||
public int EndWeek { get; set; } = 16;
|
||||
public int WeeklyHours { get; set; } = 2;
|
||||
public TeachingTaskStatus Status { get; set; } = TeachingTaskStatus.Draft;
|
||||
public string? GenerationBatchCode { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public ICollection<TeachingTaskTeacher> Teachers { get; set; } = [];
|
||||
public ICollection<TeachingTaskClass> Classes { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class TeacherCourseApplication : EntityBase
|
||||
{
|
||||
public Guid AcademicTermId { get; set; }
|
||||
public AcademicTerm? AcademicTerm { get; set; }
|
||||
public Guid TeacherId { get; set; }
|
||||
public Teacher? Teacher { get; set; }
|
||||
public Guid CourseId { get; set; }
|
||||
public Course? Course { get; set; }
|
||||
public TeacherCourseApplicationStatus Status { get; set; } =
|
||||
TeacherCourseApplicationStatus.Pending;
|
||||
public string? Statement { get; set; }
|
||||
public string? ReviewComment { get; set; }
|
||||
public DateTime SubmittedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
public Guid? ReviewedByUserId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TeachingTaskTeacher
|
||||
{
|
||||
public Guid TeachingTaskId { get; set; }
|
||||
@@ -44,3 +62,11 @@ public enum TeachingTaskStatus
|
||||
Published = 2,
|
||||
Closed = 3
|
||||
}
|
||||
|
||||
public enum TeacherCourseApplicationStatus
|
||||
{
|
||||
Pending = 1,
|
||||
Approved = 2,
|
||||
Rejected = 3,
|
||||
Withdrawn = 4
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>();
|
||||
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
|
||||
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
|
||||
public DbSet<TeacherCourseApplication> TeacherCourseApplications =>
|
||||
Set<TeacherCourseApplication>();
|
||||
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
|
||||
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
|
||||
public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>();
|
||||
@@ -229,6 +231,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
{
|
||||
entity.Property(x => x.TaskNumber).HasMaxLength(40);
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
entity.Property(x => x.GenerationBatchCode).HasMaxLength(40);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => x.TaskNumber).IsUnique();
|
||||
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
|
||||
@@ -268,6 +271,35 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<TeacherCourseApplication>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Statement).HasMaxLength(500);
|
||||
entity.Property(x => x.ReviewComment).HasMaxLength(500);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.AcademicTermId,
|
||||
x.TeacherId,
|
||||
x.CourseId
|
||||
}).IsUnique();
|
||||
entity.HasIndex(x => new { x.Status, x.AcademicTermId });
|
||||
entity.HasOne(x => x.AcademicTerm)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.AcademicTermId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Teacher)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.TeacherId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Course)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CourseId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne<ApplicationUser>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ReviewedByUserId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<SchedulePlan>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
|
||||
@@ -21,6 +21,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
private const string CourseCategoriesMigration = "20260724_13_course_categories";
|
||||
private const string SchedulingOptimizationMigration =
|
||||
"20260724_14_scheduling_optimization";
|
||||
private const string TeacherCourseApplicationsMigration =
|
||||
"20260724_15_teacher_course_applications";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -121,6 +123,18 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
SchedulingOptimizationMigration,
|
||||
schedulingOptimizationExists ? [] : SchedulingOptimizationStatements,
|
||||
cancellationToken);
|
||||
var teacherCourseApplicationsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'TeacherCourseApplications'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
TeacherCourseApplicationsMigration,
|
||||
teacherCourseApplicationsExist ? [] : TeacherCourseApplicationStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -953,4 +967,59 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ON "TeachingTaskAllowedClassrooms" ("ClassroomId");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] TeacherCourseApplicationStatements =
|
||||
[
|
||||
"""
|
||||
ALTER TABLE "TeachingTasks" ADD COLUMN "GenerationBatchCode" TEXT NULL;
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE "TeacherCourseApplications" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_TeacherCourseApplications" PRIMARY KEY,
|
||||
"AcademicTermId" TEXT NOT NULL,
|
||||
"TeacherId" TEXT NOT NULL,
|
||||
"CourseId" TEXT NOT NULL,
|
||||
"Status" INTEGER NOT NULL,
|
||||
"Statement" TEXT NULL,
|
||||
"ReviewComment" TEXT NULL,
|
||||
"SubmittedAt" TEXT NOT NULL,
|
||||
"ReviewedAt" TEXT NULL,
|
||||
"ReviewedByUserId" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_TeacherCourseApplications_AcademicTerms"
|
||||
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id")
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_TeacherCourseApplications_Teachers"
|
||||
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id")
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_TeacherCourseApplications_Courses"
|
||||
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id")
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_TeacherCourseApplications_ReviewedBy"
|
||||
FOREIGN KEY ("ReviewedByUserId") REFERENCES "AspNetUsers" ("Id")
|
||||
ON DELETE SET NULL
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_TeacherCourseApplications_Term_Teacher_Course"
|
||||
ON "TeacherCourseApplications" ("AcademicTermId", "TeacherId", "CourseId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_TeacherCourseApplications_Status_AcademicTermId"
|
||||
ON "TeacherCourseApplications" ("Status", "AcademicTermId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_TeacherCourseApplications_TeacherId"
|
||||
ON "TeacherCourseApplications" ("TeacherId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_TeacherCourseApplications_CourseId"
|
||||
ON "TeacherCourseApplications" ("CourseId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_TeacherCourseApplications_ReviewedByUserId"
|
||||
ON "TeacherCourseApplications" ("ReviewedByUserId");
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
+2794
File diff suppressed because it is too large
Load Diff
+106
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TeacherCourseApplications : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GenerationBatchCode",
|
||||
table: "TeachingTasks",
|
||||
type: "varchar(40)",
|
||||
maxLength: 40,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TeacherCourseApplications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
TeacherId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
Statement = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
ReviewedByUserId = table.Column<Guid>(type: "char(36)", 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_TeacherCourseApplications", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeacherCourseApplications_AcademicTerms_AcademicTermId",
|
||||
column: x => x.AcademicTermId,
|
||||
principalTable: "AcademicTerms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeacherCourseApplications_AspNetUsers_ReviewedByUserId",
|
||||
column: x => x.ReviewedByUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeacherCourseApplications_Courses_CourseId",
|
||||
column: x => x.CourseId,
|
||||
principalTable: "Courses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeacherCourseApplications_Teachers_TeacherId",
|
||||
column: x => x.TeacherId,
|
||||
principalTable: "Teachers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeacherCourseApplications_AcademicTermId_TeacherId_CourseId",
|
||||
table: "TeacherCourseApplications",
|
||||
columns: new[] { "AcademicTermId", "TeacherId", "CourseId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeacherCourseApplications_CourseId",
|
||||
table: "TeacherCourseApplications",
|
||||
column: "CourseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeacherCourseApplications_ReviewedByUserId",
|
||||
table: "TeacherCourseApplications",
|
||||
column: "ReviewedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeacherCourseApplications_Status_AcademicTermId",
|
||||
table: "TeacherCourseApplications",
|
||||
columns: new[] { "Status", "AcademicTermId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeacherCourseApplications_TeacherId",
|
||||
table: "TeacherCourseApplications",
|
||||
column: "TeacherId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TeacherCourseApplications");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GenerationBatchCode",
|
||||
table: "TeachingTasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -1614,6 +1614,63 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("Teachers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("AcademicTermId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("CourseId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("ReviewComment")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.Property<DateTime?>("ReviewedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid?>("ReviewedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("Statement")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("SubmittedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("TeacherId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CourseId");
|
||||
|
||||
b.HasIndex("ReviewedByUserId");
|
||||
|
||||
b.HasIndex("TeacherId");
|
||||
|
||||
b.HasIndex("Status", "AcademicTermId");
|
||||
|
||||
b.HasIndex("AcademicTermId", "TeacherId", "CourseId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TeacherCourseApplications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1635,6 +1692,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<int>("EndWeek")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("GenerationBatchCode")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("varchar(40)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
@@ -2464,6 +2525,38 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("College");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
|
||||
.WithMany()
|
||||
.HasForeignKey("AcademicTermId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
|
||||
.WithMany()
|
||||
.HasForeignKey("CourseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ReviewedByUserId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher")
|
||||
.WithMany()
|
||||
.HasForeignKey("TeacherId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AcademicTerm");
|
||||
|
||||
b.Navigation("Course");
|
||||
|
||||
b.Navigation("Teacher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Jiaowu.Api.Infrastructure.Teaching;
|
||||
|
||||
public static class PublicCourseTaskAssignmentPlanner
|
||||
{
|
||||
public static IReadOnlyList<Guid> AssignTeachers(
|
||||
IReadOnlyCollection<Guid> eligibleTeacherIds,
|
||||
IReadOnlyDictionary<Guid, int> existingLoads,
|
||||
int groupCount,
|
||||
Random? random = null)
|
||||
{
|
||||
if (eligibleTeacherIds.Count == 0)
|
||||
throw new ArgumentException("至少需要一名可分配教师。", nameof(eligibleTeacherIds));
|
||||
if (groupCount < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(groupCount));
|
||||
random ??= Random.Shared;
|
||||
var teachers = eligibleTeacherIds.Distinct().ToArray();
|
||||
var loads = teachers.ToDictionary(
|
||||
teacherId => teacherId,
|
||||
teacherId => existingLoads.GetValueOrDefault(teacherId));
|
||||
var assignments = new List<Guid>(groupCount);
|
||||
for (var index = 0; index < groupCount; index++)
|
||||
{
|
||||
var minimumLoad = loads.Values.Min();
|
||||
var candidates = teachers
|
||||
.Where(teacherId => loads[teacherId] == minimumLoad)
|
||||
.ToArray();
|
||||
var selected = candidates[random.Next(candidates.Length)];
|
||||
assignments.Add(selected);
|
||||
loads[selected]++;
|
||||
}
|
||||
return assignments;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class PublicCourseTaskAssignmentPlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Assignments_are_randomized_among_least_loaded_eligible_teachers()
|
||||
{
|
||||
var first = Guid.NewGuid();
|
||||
var second = Guid.NewGuid();
|
||||
var third = Guid.NewGuid();
|
||||
var assignments = PublicCourseTaskAssignmentPlanner.AssignTeachers(
|
||||
[first, second, third],
|
||||
new Dictionary<Guid, int>
|
||||
{
|
||||
[first] = 2,
|
||||
[second] = 0,
|
||||
[third] = 0
|
||||
},
|
||||
6,
|
||||
new Random(42));
|
||||
|
||||
Assert.Equal(6, assignments.Count);
|
||||
Assert.All(assignments, teacherId =>
|
||||
Assert.Contains(teacherId, new[] { first, second, third }));
|
||||
var finalLoads = new Dictionary<Guid, int>
|
||||
{
|
||||
[first] = 2,
|
||||
[second] = 0,
|
||||
[third] = 0
|
||||
};
|
||||
foreach (var teacherId in assignments) finalLoads[teacherId]++;
|
||||
Assert.True(finalLoads.Values.Max() - finalLoads.Values.Min() <= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assignment_requires_at_least_one_eligible_teacher()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
PublicCourseTaskAssignmentPlanner.AssignTeachers(
|
||||
[],
|
||||
new Dictionary<Guid, int>(),
|
||||
1,
|
||||
new Random(1)));
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -16,6 +16,8 @@ declare module 'vue' {
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
|
||||
@@ -79,6 +79,13 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
{ path: '/base-data/course-categories', label: '课程分类' },
|
||||
),
|
||||
{ path: '/courses', label: '课程库' },
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']),
|
||||
{
|
||||
path: '/teaching-preferences',
|
||||
label: isTeacher.value ? '授课科目申报' : '授课资格审核',
|
||||
},
|
||||
),
|
||||
...whenVisible(isTeachingAdmin.value, { path: '/curriculum', label: '培养方案' }),
|
||||
...whenVisible(isTeachingAdmin.value, { path: '/teaching-tasks', label: '教学任务' }),
|
||||
],
|
||||
|
||||
@@ -100,6 +100,14 @@ const router = createRouter({
|
||||
component: () => import('../views/TeachingTasksView.vue'),
|
||||
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
|
||||
},
|
||||
{
|
||||
path: 'teaching-preferences',
|
||||
name: 'teaching-preferences',
|
||||
component: () => import('../views/TeachingPreferencesView.vue'),
|
||||
meta: {
|
||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'schedules',
|
||||
name: 'schedules',
|
||||
|
||||
@@ -352,6 +352,16 @@ button { cursor: pointer; }
|
||||
.constraint-list article small { color: var(--muted); font-size: 9px; }
|
||||
.constraint-badges { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 5px; }
|
||||
.constraint-title { margin-bottom: 16px; padding: 12px 14px; color: #eef4ff; background: linear-gradient(120deg, #243d7a, #182b5d); font-size: 13px; font-weight: 650; }
|
||||
.eligibility-flow { padding: 14px 18px; display: grid; grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr; align-items: center; gap: 12px; border: 1px solid var(--line); background: #f8fafc; }
|
||||
.eligibility-flow > div { min-width: 0; display: grid; gap: 4px; }
|
||||
.eligibility-flow span { color: var(--teal); font: 700 9px/1 Consolas, monospace; }
|
||||
.eligibility-flow b { color: #2b3854; font-size: 11px; }
|
||||
.eligibility-flow i { color: #aeb7c6; font-style: normal; }
|
||||
.generation-notice { margin-bottom: 16px; padding: 12px 14px; color: #526078; border-left: 3px solid var(--teal); background: #f3f9f8; font-size: 10px; line-height: 1.7; }
|
||||
.generation-preview { padding: 14px 18px; display: flex; align-items: baseline; gap: 8px; color: #718096; background: #f6f8fb; border: 1px solid var(--line); }
|
||||
.generation-preview b { color: var(--indigo); font: 700 25px/1 Consolas, monospace; }
|
||||
.generation-preview span, .generation-preview small { font-size: 10px; }
|
||||
.field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 9px; }
|
||||
|
||||
.selection-page { min-width: 0; }
|
||||
.selection-round-strip {
|
||||
@@ -969,6 +979,8 @@ button { cursor: pointer; }
|
||||
.settings-lead > div:last-child { flex-wrap: wrap; }
|
||||
.constraint-list article { grid-template-columns: 1fr; }
|
||||
.constraint-badges { justify-content: flex-start; }
|
||||
.eligibility-flow { grid-template-columns: 1fr; }
|
||||
.eligibility-flow i { display: none; }
|
||||
.selection-window { grid-template-columns: 82px 1fr; }
|
||||
.credit-meter, .round-actions {
|
||||
grid-column: 1 / -1; width: auto; border-top: 1px solid rgba(255,255,255,.13);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') ?? false)
|
||||
const loading = ref(false)
|
||||
const rows = ref<any[]>([])
|
||||
const terms = ref<any[]>([])
|
||||
const courses = ref<any[]>([])
|
||||
const submitDialog = ref(false)
|
||||
const reviewDialog = ref(false)
|
||||
const form = reactive<Record<string, any>>({})
|
||||
const reviewForm = reactive<Record<string, any>>({})
|
||||
const filters = reactive({
|
||||
academicTermId: undefined as string | undefined,
|
||||
status: isTeacher.value ? undefined : 'Pending' as string | undefined,
|
||||
})
|
||||
const statusLabels: Record<string, string> = {
|
||||
Pending: '待审核',
|
||||
Approved: '已通过',
|
||||
Rejected: '已驳回',
|
||||
Withdrawn: '已撤回',
|
||||
}
|
||||
const statusTypes: Record<string, 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
Pending: 'warning',
|
||||
Approved: 'success',
|
||||
Rejected: 'danger',
|
||||
Withdrawn: 'info',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const endpoint = isTeacher.value
|
||||
? '/teacher-course-applications/mine'
|
||||
: '/teacher-course-applications/reviews'
|
||||
const { data } = await http.get(endpoint, { params: filters })
|
||||
rows.value = data
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openSubmit() {
|
||||
Object.assign(form, {
|
||||
academicTermId: filters.academicTermId ??
|
||||
terms.value.find((item) => item.isCurrent)?.id,
|
||||
courseId: undefined,
|
||||
statement: '',
|
||||
})
|
||||
submitDialog.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.academicTermId || !form.courseId) {
|
||||
ElMessage.warning('请选择学期和课程。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await http.post('/teacher-course-applications/mine', form)
|
||||
submitDialog.value = false
|
||||
ElMessage.success('课程申报已提交,等待学院审核')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function withdraw(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`撤回“${row.courseName}”的授课申报?`, '撤回申报', {
|
||||
type: 'warning',
|
||||
})
|
||||
await http.delete(`/teacher-course-applications/mine/${row.id}`)
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
function openReview(row: any) {
|
||||
Object.assign(reviewForm, {
|
||||
id: row.id,
|
||||
title: `${row.teacherName} · ${row.courseCode} ${row.courseName}`,
|
||||
statement: row.statement,
|
||||
status: 'Approved',
|
||||
reviewComment: '',
|
||||
})
|
||||
reviewDialog.value = true
|
||||
}
|
||||
|
||||
async function review() {
|
||||
try {
|
||||
await http.post(`/teacher-course-applications/${reviewForm.id}/review`, {
|
||||
status: reviewForm.status,
|
||||
reviewComment: reviewForm.reviewComment,
|
||||
})
|
||||
reviewDialog.value = false
|
||||
ElMessage.success(reviewForm.status === 'Approved' ? '已审核通过' : '已驳回申报')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const requests: Promise<any>[] = [http.get('/base-data/terms')]
|
||||
if (isTeacher.value) {
|
||||
requests.push(http.get('/courses', {
|
||||
params: { page: 1, pageSize: 100, isEnabled: true },
|
||||
}))
|
||||
}
|
||||
const [termRes, courseRes] = await Promise.all(requests)
|
||||
terms.value = termRes.data
|
||||
courses.value = courseRes?.data.items ?? []
|
||||
filters.academicTermId = terms.value.find((item) => item.isCurrent)?.id
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="section-kicker">TEACHING ELIGIBILITY</span>
|
||||
<h2>{{ isTeacher ? '授课科目申报' : '授课资格审核' }}</h2>
|
||||
<p v-if="isTeacher">在排课前申报本学期愿意承担的课程,学院审核通过后才能分配公共课教学任务。</p>
|
||||
<p v-else>审核教师的授课科目申报;通过后,该教师进入相应学期和课程的可分配教师池。</p>
|
||||
</div>
|
||||
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openSubmit">
|
||||
申报课程
|
||||
</el-button>
|
||||
</section>
|
||||
|
||||
<section class="eligibility-flow">
|
||||
<div class="active"><span>教师</span><b>选择学期与科目</b></div>
|
||||
<i>→</i>
|
||||
<div><span>学院</span><b>审核授课资格</b></div>
|
||||
<i>→</i>
|
||||
<div><span>教务</span><b>合班并分配教师</b></div>
|
||||
<i>→</i>
|
||||
<div><span>排课</span><b>生成课表</b></div>
|
||||
</section>
|
||||
|
||||
<section class="data-card">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filters.academicTermId" clearable placeholder="全部学期" @change="load">
|
||||
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load">
|
||||
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
||||
</el-select>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
<span>共 {{ rows.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" class="data-table">
|
||||
<el-table-column v-if="!isTeacher" label="申报教师" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="course-name"><b>{{ row.teacherName }}</b><span>{{ row.teacherNumber }} · {{ row.teacherCollegeName }}</span></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申报课程" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="course-name"><b>{{ row.courseName }}</b><span>{{ row.courseCode }} · {{ row.courseCollegeName || row.collegeName }}</span></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="termName" label="学期" min-width="180" />
|
||||
<el-table-column label="申报说明" min-width="180">
|
||||
<template #default="{ row }">{{ row.statement || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="statusTypes[row.status]">{{ statusLabels[row.status] }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核意见" min-width="160">
|
||||
<template #default="{ row }">{{ row.reviewComment || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="isTeacher && row.status === 'Pending'" link type="danger" @click="withdraw(row)">撤回</el-button>
|
||||
<el-button v-if="!isTeacher && row.status === 'Pending'" link type="primary" @click="openReview(row)">审核</el-button>
|
||||
<span v-if="row.status !== 'Pending'" class="muted-action">已处理</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :description="isTeacher ? '尚未申报授课科目' : '没有待处理的授课申报'" /></template>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="submitDialog" title="申报授课科目" width="600px">
|
||||
<el-form label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="学期" required>
|
||||
<el-select v-model="form.academicTermId">
|
||||
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="拟授课程" required>
|
||||
<el-select v-model="form.courseId" filterable>
|
||||
<el-option v-for="item in courses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="申报说明">
|
||||
<el-input v-model="form.statement" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="可填写相关教学经历、研究方向或其他说明" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="submitDialog = false">取消</el-button><el-button type="primary" @click="submit">提交学院审核</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="reviewDialog" title="审核授课资格" width="580px">
|
||||
<div class="constraint-title">{{ reviewForm.title }}</div>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="教师说明">{{ reviewForm.statement || '未填写' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-form label-position="top" style="margin-top: 16px">
|
||||
<el-form-item label="审核结论">
|
||||
<el-radio-group v-model="reviewForm.status">
|
||||
<el-radio-button value="Approved">通过</el-radio-button>
|
||||
<el-radio-button value="Rejected">驳回</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核意见">
|
||||
<el-input v-model="reviewForm.reviewComment" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="reviewDialog = false">取消</el-button><el-button type="primary" @click="review">确认审核</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,8 @@ const loading = ref(false)
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const generationDialog = ref(false)
|
||||
const generating = ref(false)
|
||||
const editingId = ref('')
|
||||
const terms = ref<any[]>([])
|
||||
const courses = ref<any[]>([])
|
||||
@@ -23,6 +25,9 @@ const query = reactive({
|
||||
status: undefined as string | undefined,
|
||||
})
|
||||
const form = reactive<Record<string, any>>({})
|
||||
const generationForm = reactive<Record<string, any>>({})
|
||||
const eligibleTeachers = ref<any[]>([])
|
||||
const manualEligibleTeachers = ref<any[]>([])
|
||||
const statusLabels: Record<string, string> = {
|
||||
Draft: '草稿',
|
||||
Published: '已发布',
|
||||
@@ -35,8 +40,29 @@ const availableClasses = computed(() => {
|
||||
)
|
||||
return classes.value.filter((item) => ids.has(item.majorId))
|
||||
})
|
||||
const selectedCourse = computed(() =>
|
||||
courses.value.find((item) => item.id === form.courseId),
|
||||
)
|
||||
const manualCourseIsPublic = computed(() =>
|
||||
['GeneralRequired', 'GeneralElective'].includes(selectedCourse.value?.nature),
|
||||
)
|
||||
const assignableTeachers = computed(() => {
|
||||
if (!manualCourseIsPublic.value) return teachers.value
|
||||
const approvedIds = new Set(manualEligibleTeachers.value.map((item) => item.teacherId))
|
||||
return teachers.value.filter((item) =>
|
||||
approvedIds.has(item.id) || form.teacherIds?.includes(item.id),
|
||||
)
|
||||
})
|
||||
const selectedTeachers = computed(() =>
|
||||
teachers.value.filter((item) => form.teacherIds?.includes(item.id)),
|
||||
assignableTeachers.value.filter((item) => form.teacherIds?.includes(item.id)),
|
||||
)
|
||||
const publicCourses = computed(() =>
|
||||
courses.value.filter((item) =>
|
||||
['GeneralRequired', 'GeneralElective'].includes(item.nature),
|
||||
),
|
||||
)
|
||||
const generationGroupCount = computed(() =>
|
||||
Math.ceil((generationForm.classIds?.length ?? 0) / (generationForm.classesPerTask || 1)),
|
||||
)
|
||||
|
||||
async function load() {
|
||||
@@ -97,12 +123,28 @@ async function openEdit(row: any) {
|
||||
const detail = (await http.get(`/teaching-tasks/${row.id}`)).data
|
||||
editingId.value = row.id
|
||||
resetForm(detail)
|
||||
await loadManualEligibleTeachers()
|
||||
dialogVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManualEligibleTeachers() {
|
||||
manualEligibleTeachers.value = []
|
||||
if (!form.academicTermId || !form.courseId || !manualCourseIsPublic.value) return
|
||||
try {
|
||||
manualEligibleTeachers.value = (await http.get('/teacher-course-applications/eligible', {
|
||||
params: {
|
||||
academicTermId: form.academicTermId,
|
||||
courseId: form.courseId,
|
||||
},
|
||||
})).data
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
function onTeachersChanged(values: string[]) {
|
||||
if (form.primaryTeacherId && !values.includes(form.primaryTeacherId)) {
|
||||
form.primaryTeacherId = undefined
|
||||
@@ -170,6 +212,65 @@ async function closeTask(row: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function openGeneration() {
|
||||
const currentTerm = terms.value.find((item) => item.isCurrent)
|
||||
Object.assign(generationForm, {
|
||||
academicTermId: query.academicTermId ?? currentTerm?.id,
|
||||
courseId: undefined,
|
||||
classesPerTask: 3,
|
||||
startWeek: 1,
|
||||
endWeek: 16,
|
||||
weeklyHours: 2,
|
||||
classIds: [],
|
||||
})
|
||||
eligibleTeachers.value = []
|
||||
generationDialog.value = true
|
||||
}
|
||||
|
||||
async function loadEligibleTeachers() {
|
||||
eligibleTeachers.value = []
|
||||
if (!generationForm.academicTermId || !generationForm.courseId) return
|
||||
try {
|
||||
eligibleTeachers.value = (await http.get('/teacher-course-applications/eligible', {
|
||||
params: {
|
||||
academicTermId: generationForm.academicTermId,
|
||||
courseId: generationForm.courseId,
|
||||
},
|
||||
})).data
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePublicTasks() {
|
||||
if (!generationForm.academicTermId ||
|
||||
!generationForm.courseId ||
|
||||
!generationForm.classIds?.length) {
|
||||
ElMessage.warning('请选择学期、公共课和需要合班的行政班。')
|
||||
return
|
||||
}
|
||||
if (!eligibleTeachers.value.length) {
|
||||
ElMessage.warning('该课程还没有审核通过的可授课教师。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将 ${generationForm.classIds.length} 个行政班分成 ${generationGroupCount.value} 个教学班,并在 ${eligibleTeachers.value.length} 名合格教师中随机均衡分配。生成结果为草稿,确定继续吗?`,
|
||||
'批量生成公共课教学任务',
|
||||
{ type: 'warning', confirmButtonText: '生成草稿', cancelButtonText: '取消' },
|
||||
)
|
||||
generating.value = true
|
||||
const { data } = await http.post('/teaching-tasks/generate-public-course', generationForm)
|
||||
generationDialog.value = false
|
||||
ElMessage.success(`已生成 ${data.createdCount} 个教学任务草稿`)
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [termRes, courseRes, teacherRes, classRes, majorRes] = await Promise.all([
|
||||
http.get('/base-data/terms'),
|
||||
@@ -196,7 +297,10 @@ onMounted(async () => {
|
||||
<h2>教学任务</h2>
|
||||
<p>将学期、课程、授课教师和行政班组合为可排课、可选课的教学班。</p>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新建教学任务</el-button>
|
||||
<div class="page-actions">
|
||||
<el-button @click="openGeneration">公共课合班生成</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新建教学任务</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="task-summary">
|
||||
@@ -252,8 +356,8 @@ onMounted(async () => {
|
||||
<el-form-item label="教学班名称" required><el-input v-model="form.name" /></el-form-item>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item label="课程" required><el-select v-model="form.courseId" filterable><el-option v-for="item in courses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item label="课程" required><el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()"><el-option v-for="item in courses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" /></el-select></el-form-item>
|
||||
</div>
|
||||
<div class="form-grid three">
|
||||
<el-form-item label="容量"><el-input-number v-model="form.capacity" :min="1" /></el-form-item>
|
||||
@@ -263,8 +367,9 @@ onMounted(async () => {
|
||||
<el-form-item label="周学时"><el-input-number v-model="form.weeklyHours" :min="1" :max="40" /></el-form-item>
|
||||
<el-form-item label="授课教师">
|
||||
<el-select v-model="form.teacherIds" multiple filterable @change="onTeachersChanged">
|
||||
<el-option v-for="item in teachers" :key="item.id" :label="`${item.teacherNumber} · ${item.name}`" :value="item.id" />
|
||||
<el-option v-for="item in assignableTeachers" :key="item.id" :label="`${item.teacherNumber} · ${item.name}`" :value="item.id" />
|
||||
</el-select>
|
||||
<small v-if="manualCourseIsPublic" class="field-hint">公共课仅显示本学期已申报且学院审核通过的教师。</small>
|
||||
</el-form-item>
|
||||
<el-form-item label="主讲教师">
|
||||
<el-select v-model="form.primaryTeacherId" clearable :disabled="!form.teacherIds?.length">
|
||||
@@ -280,5 +385,63 @@ onMounted(async () => {
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="save">保存草稿</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="generationDialog" title="公共课合班生成教学任务" width="760px">
|
||||
<div class="generation-notice">
|
||||
行政班按编码顺序分组;教师只从学院审核通过的名单中选择,并优先分配给本学期教学任务较少的教师。
|
||||
</div>
|
||||
<el-form label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="开课学期" required>
|
||||
<el-select v-model="generationForm.academicTermId" @change="loadEligibleTeachers">
|
||||
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="公共课程" required>
|
||||
<el-select v-model="generationForm.courseId" filterable @change="loadEligibleTeachers">
|
||||
<el-option v-for="item in publicCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-alert
|
||||
:title="eligibleTeachers.length ? `已有 ${eligibleTeachers.length} 名教师审核通过` : '请选择课程查看可分配教师'"
|
||||
:type="eligibleTeachers.length ? 'success' : 'warning'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #default>
|
||||
<span v-if="eligibleTeachers.length">{{ eligibleTeachers.map((item) => item.name).join('、') }}</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-form-item label="参与合班的行政班" required style="margin-top: 16px">
|
||||
<el-select v-model="generationForm.classIds" multiple filterable collapse-tags>
|
||||
<el-option v-for="item in availableClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="form-grid three">
|
||||
<el-form-item label="每个教学班合并行政班数">
|
||||
<el-input-number v-model="generationForm.classesPerTask" :min="1" :max="10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开始周">
|
||||
<el-input-number v-model="generationForm.startWeek" :min="1" :max="30" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束周">
|
||||
<el-input-number v-model="generationForm.endWeek" :min="1" :max="30" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="周学时">
|
||||
<el-input-number v-model="generationForm.weeklyHours" :min="1" :max="40" />
|
||||
</el-form-item>
|
||||
<div class="generation-preview">
|
||||
<span>预计生成</span>
|
||||
<b>{{ generationGroupCount }}</b>
|
||||
<small>个教学任务草稿</small>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="generationDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="generating" @click="generatePublicTasks">生成教学任务草稿</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user