已完成“专业课教学任务下放学院管理”。

专业必修、专业选修、实践课程:由课程所属学院创建、修改、删除、发布和关闭。
公共必修、公共选修:仍由校级教务管理。
校级教务可以查看全校专业课任务,但不能代替学院维护。
超级管理员保留跨学院纠错权限。
教师分配统一要求:教师已申报该课程,并通过学院审核。
兼岗账号同时拥有校级公共课和本学院专业课职责。
This commit is contained in:
biss committed 2026-07-24 19:43:24 +08:00
1 parent e6d8eb0662
commit 5da1a8a403
5 files changed
+212 -20

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。 当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。 权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
@@ -23,6 +23,9 @@ public sealed class TeachingTasksController(
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," + SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin; SystemRoles.CollegeAdmin;
private const string SchoolManagementRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<object>>> Get( public async Task<ActionResult<PagedResult<object>>> Get(
@@ -68,6 +71,7 @@ public sealed class TeachingTasksController(
x.CourseId, x.CourseId,
CourseCode = x.Course!.Code, CourseCode = x.Course!.Code,
CourseName = x.Course.Name, CourseName = x.Course.Name,
CourseNature = x.Course.Nature,
CollegeId = x.Course.CollegeId, CollegeId = x.Course.CollegeId,
CollegeName = x.Course.College!.Name, CollegeName = x.Course.College!.Name,
x.Capacity, x.Capacity,
@@ -105,6 +109,7 @@ public sealed class TeachingTasksController(
x.CourseId, x.CourseId,
CourseCode = x.Course!.Code, CourseCode = x.Course!.Code,
CourseName = x.Course.Name, CourseName = x.Course.Name,
CourseNature = x.Course.Nature,
CollegeId = x.Course.CollegeId, CollegeId = x.Course.CollegeId,
CollegeName = x.Course.College!.Name, CollegeName = x.Course.College!.Name,
x.Capacity, x.Capacity,
@@ -181,10 +186,12 @@ public sealed class TeachingTasksController(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var task = await ScopedTasks() var task = await ScopedTasks()
.Include(x => x.Course)
.Include(x => x.Teachers) .Include(x => x.Teachers)
.Include(x => x.Classes) .Include(x => x.Classes)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (task is null) return NotFound(); if (task is null) return NotFound();
if (!CanManage(task.Course!)) return Forbid();
if (task.Status != TeachingTaskStatus.Draft) if (task.Status != TeachingTaskStatus.Draft)
return ConflictProblem("已发布或已结课的教学任务不可直接修改。"); return ConflictProblem("已发布或已结课的教学任务不可直接修改。");
var validation = await ValidateRequestAsync(request, cancellationToken); var validation = await ValidateRequestAsync(request, cancellationToken);
@@ -210,8 +217,11 @@ public sealed class TeachingTasksController(
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{ {
var task = await ScopedTasks().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); var task = await ScopedTasks()
.Include(x => x.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (task is null) return NotFound(); if (task is null) return NotFound();
if (!CanManage(task.Course!)) return Forbid();
if (task.Status != TeachingTaskStatus.Draft) if (task.Status != TeachingTaskStatus.Draft)
return ConflictProblem("仅草稿教学任务可以删除。"); return ConflictProblem("仅草稿教学任务可以删除。");
db.TeachingTasks.Remove(task); db.TeachingTasks.Remove(task);
@@ -222,18 +232,29 @@ public sealed class TeachingTasksController(
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{ {
var task = await ScopedTasks() var task = await ScopedTasks()
.Include(x => x.Course)
.Include(x => x.Teachers) .Include(x => x.Teachers)
.Include(x => x.Classes) .Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students) .ThenInclude(x => x!.Students)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (task is null) return NotFound(); if (task is null) return NotFound();
if (!CanManage(task.Course!)) return Forbid();
if (task.Status != TeachingTaskStatus.Draft) if (task.Status != TeachingTaskStatus.Draft)
return ConflictProblem("只有草稿教学任务可以发布。"); return ConflictProblem("只有草稿教学任务可以发布。");
if (task.Teachers.Count == 0 || task.Teachers.Count(x => x.IsPrimary) != 1) if (task.Teachers.Count == 0 || task.Teachers.Count(x => x.IsPrimary) != 1)
return ConflictProblem("发布前必须指定且只能指定一名主讲教师。"); return ConflictProblem("发布前必须指定且只能指定一名主讲教师。");
if (task.Classes.Count == 0) if (task.Classes.Count == 0)
return ConflictProblem("发布前至少需要关联一个行政班。"); return ConflictProblem("发布前至少需要关联一个行政班。");
var teacherIds = task.Teachers.Select(x => x.TeacherId).ToArray();
if (await db.TeacherCourseApplications.CountAsync(
x =>
x.AcademicTermId == task.AcademicTermId &&
x.CourseId == task.CourseId &&
teacherIds.Contains(x.TeacherId) &&
x.Status == TeacherCourseApplicationStatus.Approved,
cancellationToken) != teacherIds.Length)
return ConflictProblem("授课教师尚未完成该课程申报,或学院审核尚未通过。");
var studentCount = task.Classes.Sum(x => var studentCount = task.Classes.Sum(x =>
x.AdministrativeClass!.Students.Count(student => x.AdministrativeClass!.Students.Count(student =>
student.Status == StudentStatus.Active)); student.Status == StudentStatus.Active));
@@ -248,8 +269,11 @@ public sealed class TeachingTasksController(
[HttpPost("{id:guid}/close")] [HttpPost("{id:guid}/close")]
public async Task<ActionResult> Close(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Close(Guid id, CancellationToken cancellationToken)
{ {
var task = await ScopedTasks().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); var task = await ScopedTasks()
.Include(x => x.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (task is null) return NotFound(); if (task is null) return NotFound();
if (!CanManage(task.Course!)) return Forbid();
if (task.Status != TeachingTaskStatus.Published) if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("只有已发布的教学任务可以结课。"); return ConflictProblem("只有已发布的教学任务可以结课。");
task.Status = TeachingTaskStatus.Closed; task.Status = TeachingTaskStatus.Closed;
@@ -257,6 +281,7 @@ public sealed class TeachingTasksController(
} }
[HttpPost("generate-public-course")] [HttpPost("generate-public-course")]
[Authorize(Roles = SchoolManagementRoles)]
public async Task<ActionResult> GeneratePublicCourseTasks( public async Task<ActionResult> GeneratePublicCourseTasks(
PublicCourseTaskGenerationRequest request, PublicCourseTaskGenerationRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -273,6 +298,7 @@ public sealed class TeachingTasksController(
x => x.Id == request.CourseId && x.IsEnabled, x => x.Id == request.CourseId && x.IsEnabled,
cancellationToken); cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。"); if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid();
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective)) if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。"); return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
var scopedCollegeId = ScopedCollegeId(); var scopedCollegeId = ScopedCollegeId();
@@ -431,8 +457,8 @@ public sealed class TeachingTasksController(
var course = await db.Courses.AsNoTracking() var course = await db.Courses.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken); .FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。"); if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid();
var collegeId = ScopedCollegeId(); var collegeId = ScopedCollegeId();
if (collegeId.HasValue && course.CollegeId != collegeId.Value) return Forbid();
if (!await db.AcademicTerms.AnyAsync( if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled, x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken)) cancellationToken))
@@ -447,7 +473,6 @@ public sealed class TeachingTasksController(
cancellationToken) != teacherIds.Length) cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效或非在职授课教师。"); return ValidationProblem("存在无效或非在职授课教师。");
if (teacherIds.Length > 0 && if (teacherIds.Length > 0 &&
course.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective &&
await db.TeacherCourseApplications.CountAsync( await db.TeacherCourseApplications.CountAsync(
x => x =>
x.AcademicTermId == request.AcademicTermId && x.AcademicTermId == request.AcademicTermId &&
@@ -455,7 +480,7 @@ public sealed class TeachingTasksController(
teacherIds.Contains(x.TeacherId) && teacherIds.Contains(x.TeacherId) &&
x.Status == TeacherCourseApplicationStatus.Approved, x.Status == TeacherCourseApplicationStatus.Approved,
cancellationToken) != teacherIds.Length) cancellationToken) != teacherIds.Length)
return ValidationProblem("公共课授课教师必须已完成课程申报并经学院审核通过。"); return ValidationProblem("授课教师必须已完成该课程申报并经学院审核通过。");
var classIds = request.ClassIds.Distinct().ToArray(); var classIds = request.ClassIds.Distinct().ToArray();
var classes = db.AdministrativeClasses.AsNoTracking() var classes = db.AdministrativeClasses.AsNoTracking()
@@ -489,6 +514,12 @@ public sealed class TeachingTasksController(
private Guid? ScopedCollegeId() private Guid? ScopedCollegeId()
=> currentUserDataScope.Current.RestrictedCollegeId; => currentUserDataScope.Current.RestrictedCollegeId;
private bool CanManage(Course course) =>
TeachingTaskMaintenancePolicy.CanManage(
currentUserDataScope.Current,
course.CollegeId,
course.Nature);
private async Task<ActionResult> SaveAsync( private async Task<ActionResult> SaveAsync(
Guid id, Guid id,
bool created, bool created,
@@ -0,0 +1,30 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
namespace Jiaowu.Api.Infrastructure.Auth;
public static class TeachingTaskMaintenancePolicy
{
public static bool CanManage(
CurrentUserScope scope,
Guid courseCollegeId,
CourseNature nature)
{
if (scope.IsInRole(SystemRoles.SuperAdmin)) return true;
if (scope.IsInRole(SystemRoles.AcademicAdmin) &&
IsSchoolManagedNature(nature))
return true;
return scope.IsInRole(SystemRoles.CollegeAdmin) &&
scope.CollegeId == courseCollegeId &&
IsCollegeManagedNature(nature);
}
public static bool IsCollegeManagedNature(CourseNature nature) =>
nature is CourseNature.MajorRequired or
CourseNature.MajorElective or
CourseNature.Practice;
public static bool IsSchoolManagedNature(CourseNature nature) =>
nature is CourseNature.GeneralRequired or
CourseNature.GeneralElective;
}
Loaded 3 of 5 files, more files were not shown because too many files have changed in this diff. Show more