优化排课

This commit is contained in:
2026-07-24 19:35:49 +08:00 Unverified
parent 6538b6080a
commit e6d8eb0662
17 changed files with 4073 additions and 6 deletions
+1 -1
View File
@@ -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");
"""
];
}
@@ -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");
}
}
}
@@ -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)));
}
}
+2
View File
@@ -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']
+7
View File
@@ -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: '教学任务' }),
],
+8
View File
@@ -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',
+12
View File
@@ -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);
+234
View File
@@ -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>
+167 -4
View File
@@ -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>
<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>