diff --git a/README.md b/README.md index 1055f94..a5fd121 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 -当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。 +当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。 权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。 diff --git a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs index 7283f91..3d05431 100644 --- a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs +++ b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs @@ -23,6 +23,9 @@ public sealed class TeachingTasksController( SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; + private const string SchoolManagementRoles = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin; [HttpGet] public async Task>> Get( @@ -68,6 +71,7 @@ public sealed class TeachingTasksController( x.CourseId, CourseCode = x.Course!.Code, CourseName = x.Course.Name, + CourseNature = x.Course.Nature, CollegeId = x.Course.CollegeId, CollegeName = x.Course.College!.Name, x.Capacity, @@ -105,6 +109,7 @@ public sealed class TeachingTasksController( x.CourseId, CourseCode = x.Course!.Code, CourseName = x.Course.Name, + CourseNature = x.Course.Nature, CollegeId = x.Course.CollegeId, CollegeName = x.Course.College!.Name, x.Capacity, @@ -181,10 +186,12 @@ public sealed class TeachingTasksController( CancellationToken cancellationToken) { var task = await ScopedTasks() + .Include(x => x.Course) .Include(x => x.Teachers) .Include(x => x.Classes) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (task is null) return NotFound(); + if (!CanManage(task.Course!)) return Forbid(); if (task.Status != TeachingTaskStatus.Draft) return ConflictProblem("已发布或已结课的教学任务不可直接修改。"); var validation = await ValidateRequestAsync(request, cancellationToken); @@ -210,8 +217,11 @@ public sealed class TeachingTasksController( [HttpDelete("{id:guid}")] public async Task 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 (!CanManage(task.Course!)) return Forbid(); if (task.Status != TeachingTaskStatus.Draft) return ConflictProblem("仅草稿教学任务可以删除。"); db.TeachingTasks.Remove(task); @@ -222,18 +232,29 @@ public sealed class TeachingTasksController( public async Task Publish(Guid id, CancellationToken cancellationToken) { var task = await ScopedTasks() + .Include(x => x.Course) .Include(x => x.Teachers) .Include(x => x.Classes) .ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x!.Students) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (task is null) return NotFound(); + if (!CanManage(task.Course!)) return Forbid(); if (task.Status != TeachingTaskStatus.Draft) return ConflictProblem("只有草稿教学任务可以发布。"); if (task.Teachers.Count == 0 || task.Teachers.Count(x => x.IsPrimary) != 1) return ConflictProblem("发布前必须指定且只能指定一名主讲教师。"); if (task.Classes.Count == 0) 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 => x.AdministrativeClass!.Students.Count(student => student.Status == StudentStatus.Active)); @@ -248,8 +269,11 @@ public sealed class TeachingTasksController( [HttpPost("{id:guid}/close")] public async Task 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 (!CanManage(task.Course!)) return Forbid(); if (task.Status != TeachingTaskStatus.Published) return ConflictProblem("只有已发布的教学任务可以结课。"); task.Status = TeachingTaskStatus.Closed; @@ -257,6 +281,7 @@ public sealed class TeachingTasksController( } [HttpPost("generate-public-course")] + [Authorize(Roles = SchoolManagementRoles)] public async Task GeneratePublicCourseTasks( PublicCourseTaskGenerationRequest request, CancellationToken cancellationToken) @@ -273,6 +298,7 @@ public sealed class TeachingTasksController( x => x.Id == request.CourseId && x.IsEnabled, cancellationToken); if (course is null) return ValidationProblem("所选课程不存在或已停用。"); + if (!CanManage(course)) return Forbid(); if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective)) return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。"); var scopedCollegeId = ScopedCollegeId(); @@ -431,8 +457,8 @@ public sealed class TeachingTasksController( var course = await db.Courses.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken); if (course is null) return ValidationProblem("所选课程不存在或已停用。"); + if (!CanManage(course)) return Forbid(); var collegeId = ScopedCollegeId(); - if (collegeId.HasValue && course.CollegeId != collegeId.Value) return Forbid(); if (!await db.AcademicTerms.AnyAsync( x => x.Id == request.AcademicTermId && x.IsEnabled, cancellationToken)) @@ -447,7 +473,6 @@ public sealed class TeachingTasksController( 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 && @@ -455,7 +480,7 @@ public sealed class TeachingTasksController( teacherIds.Contains(x.TeacherId) && x.Status == TeacherCourseApplicationStatus.Approved, cancellationToken) != teacherIds.Length) - return ValidationProblem("公共课授课教师必须已完成课程申报并经学院审核通过。"); + return ValidationProblem("授课教师必须已完成该课程申报并经学院审核通过。"); var classIds = request.ClassIds.Distinct().ToArray(); var classes = db.AdministrativeClasses.AsNoTracking() @@ -489,6 +514,12 @@ public sealed class TeachingTasksController( private Guid? ScopedCollegeId() => currentUserDataScope.Current.RestrictedCollegeId; + private bool CanManage(Course course) => + TeachingTaskMaintenancePolicy.CanManage( + currentUserDataScope.Current, + course.CollegeId, + course.Nature); + private async Task SaveAsync( Guid id, bool created, diff --git a/src/Jiaowu.Api/Infrastructure/Auth/TeachingTaskMaintenancePolicy.cs b/src/Jiaowu.Api/Infrastructure/Auth/TeachingTaskMaintenancePolicy.cs new file mode 100644 index 0000000..29a96f3 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Auth/TeachingTaskMaintenancePolicy.cs @@ -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; +} diff --git a/tests/Jiaowu.Api.Tests/DataScopeTests.cs b/tests/Jiaowu.Api.Tests/DataScopeTests.cs index e661760..f716253 100644 --- a/tests/Jiaowu.Api.Tests/DataScopeTests.cs +++ b/tests/Jiaowu.Api.Tests/DataScopeTests.cs @@ -99,4 +99,102 @@ public sealed class DataScopeTests Guid.NewGuid(), CourseNature.GeneralRequired)); } + + [Theory] + [InlineData(CourseNature.MajorRequired, true)] + [InlineData(CourseNature.MajorElective, true)] + [InlineData(CourseNature.Practice, true)] + [InlineData(CourseNature.GeneralRequired, false)] + [InlineData(CourseNature.GeneralElective, false)] + public void CollegeAdmin_Manages_Own_Professional_TeachingTasks( + CourseNature nature, + bool expected) + { + var collegeId = Guid.NewGuid(); + var scope = new CurrentUserScope( + Guid.NewGuid(), + "学院教务", + collegeId, + DataScope.College, + new HashSet([SystemRoles.CollegeAdmin])); + + Assert.Equal( + expected, + TeachingTaskMaintenancePolicy.CanManage(scope, collegeId, nature)); + Assert.False(TeachingTaskMaintenancePolicy.CanManage( + scope, + Guid.NewGuid(), + nature)); + } + + [Theory] + [InlineData(CourseNature.GeneralRequired, true)] + [InlineData(CourseNature.GeneralElective, true)] + [InlineData(CourseNature.MajorRequired, false)] + [InlineData(CourseNature.MajorElective, false)] + [InlineData(CourseNature.Practice, false)] + public void AcademicAdmin_Manages_Public_Not_Professional_TeachingTasks( + CourseNature nature, + bool expected) + { + var scope = new CurrentUserScope( + Guid.NewGuid(), + "校级教务", + null, + DataScope.All, + new HashSet([SystemRoles.AcademicAdmin])); + + Assert.Equal( + expected, + TeachingTaskMaintenancePolicy.CanManage( + scope, + Guid.NewGuid(), + nature)); + } + + [Theory] + [InlineData(CourseNature.GeneralRequired)] + [InlineData(CourseNature.MajorRequired)] + [InlineData(CourseNature.Practice)] + public void SuperAdmin_Can_Override_TeachingTask_Responsibility( + CourseNature nature) + { + var scope = new CurrentUserScope( + Guid.NewGuid(), + "超级管理员", + null, + DataScope.All, + new HashSet([SystemRoles.SuperAdmin])); + + Assert.True(TeachingTaskMaintenancePolicy.CanManage( + scope, + Guid.NewGuid(), + nature)); + } + + [Fact] + public void Combined_School_And_College_Roles_Keep_Both_Responsibilities() + { + var collegeId = Guid.NewGuid(); + var scope = new CurrentUserScope( + Guid.NewGuid(), + "校院兼岗教务", + collegeId, + DataScope.All, + new HashSet( + [SystemRoles.AcademicAdmin, SystemRoles.CollegeAdmin])); + + Assert.True(TeachingTaskMaintenancePolicy.CanManage( + scope, + Guid.NewGuid(), + CourseNature.GeneralRequired)); + Assert.True(TeachingTaskMaintenancePolicy.CanManage( + scope, + collegeId, + CourseNature.MajorRequired)); + Assert.False(TeachingTaskMaintenancePolicy.CanManage( + scope, + Guid.NewGuid(), + CourseNature.MajorRequired)); + } } diff --git a/web/src/views/TeachingTasksView.vue b/web/src/views/TeachingTasksView.vue index dbb6e2f..73b447e 100644 --- a/web/src/views/TeachingTasksView.vue +++ b/web/src/views/TeachingTasksView.vue @@ -5,6 +5,10 @@ import http, { apiErrorMessage } from '../api/http' import { useAuthStore } from '../stores/auth' const auth = useAuthStore() +const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin') ?? false) +const isAcademicAdmin = computed(() => auth.user?.roles.includes('AcademicAdmin') ?? false) +const isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false) +const canGeneratePublic = computed(() => isSuperAdmin.value || isAcademicAdmin.value) const loading = ref(false) const rows = ref([]) const total = ref(0) @@ -43,11 +47,8 @@ const availableClasses = computed(() => { 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 + if (!selectedCourse.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), @@ -61,10 +62,35 @@ const publicCourses = computed(() => ['GeneralRequired', 'GeneralElective'].includes(item.nature), ), ) +const manageableCourses = computed(() => { + if (isSuperAdmin.value) return courses.value + return courses.value.filter((item) => { + const isPublic = ['GeneralRequired', 'GeneralElective'].includes(item.nature) + return isAcademicAdmin.value && isPublic || + isCollegeAdmin.value && + item.collegeId === auth.user?.collegeId && + ['MajorRequired', 'MajorElective', 'Practice'].includes(item.nature) + }) +}) +const responsibilityText = computed(() => { + if (isSuperAdmin.value) return '超级管理员可处理全部教学任务,并保留跨层级纠错权限。' + if (isAcademicAdmin.value && isCollegeAdmin.value) { + return '兼岗账号负责校级公共课,以及本学院开设的专业必修、专业选修和实践课程。' + } + if (isCollegeAdmin.value) return '学院负责本院专业必修、专业选修和实践课程的教学任务。' + return '校级教务负责公共必修和公共选修课程;专业课教学任务由课程所属学院维护。' +}) const generationGroupCount = computed(() => Math.ceil((generationForm.classIds?.length ?? 0) / (generationForm.classesPerTask || 1)), ) +function canManageRow(row: any) { + if (isSuperAdmin.value) return true + const isPublic = ['GeneralRequired', 'GeneralElective'].includes(row.courseNature) + return isAcademicAdmin.value && isPublic || + isCollegeAdmin.value && !isPublic +} + async function load() { loading.value = true try { @@ -132,7 +158,7 @@ async function openEdit(row: any) { async function loadManualEligibleTeachers() { manualEligibleTeachers.value = [] - if (!form.academicTermId || !form.courseId || !manualCourseIsPublic.value) return + if (!form.academicTermId || !form.courseId) return try { manualEligibleTeachers.value = (await http.get('/teacher-course-applications/eligible', { params: { @@ -298,14 +324,14 @@ onMounted(async () => {

将学期、课程、授课教师和行政班组合为可排课、可选课的教学班。

- 公共课合班生成 + 公共课合班生成 新建教学任务
当前结果{{ total }}个教学班
-

发布校验:主讲教师唯一 · 行政班已关联 · 容量覆盖在籍学生 · 周次有效

+

{{ responsibilityText }} 发布时还会校验教师授课资格、行政班、容量和周次。

@@ -324,7 +350,13 @@ onMounted(async () => { - + @@ -334,10 +366,11 @@ onMounted(async () => { @@ -357,7 +390,7 @@ onMounted(async () => {
- +
@@ -369,7 +402,7 @@ onMounted(async () => { - 公共课仅显示本学期已申报且学院审核通过的教师。 + 仅显示本学期申报该课程且学院审核通过的教师。