已完成“专业课教学任务下放学院管理”。
专业必修、专业选修、实践课程:由课程所属学院创建、修改、删除、发布和关闭。 公共必修、公共选修:仍由校级教务管理。 校级教务可以查看全校专业课任务,但不能代替学院维护。 超级管理员保留跨学院纠错权限。 教师分配统一要求:教师已申报该课程,并通过学院审核。 兼岗账号同时拥有校级公共课和本学院专业课职责。
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -99,4 +99,102 @@ public sealed class DataScopeTests
|
|||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
CourseNature.GeneralRequired));
|
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<string>([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<string>([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<string>([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<string>(
|
||||||
|
[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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import http, { apiErrorMessage } from '../api/http'
|
|||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
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 loading = ref(false)
|
||||||
const rows = ref<any[]>([])
|
const rows = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -43,11 +47,8 @@ const availableClasses = computed(() => {
|
|||||||
const selectedCourse = computed(() =>
|
const selectedCourse = computed(() =>
|
||||||
courses.value.find((item) => item.id === form.courseId),
|
courses.value.find((item) => item.id === form.courseId),
|
||||||
)
|
)
|
||||||
const manualCourseIsPublic = computed(() =>
|
|
||||||
['GeneralRequired', 'GeneralElective'].includes(selectedCourse.value?.nature),
|
|
||||||
)
|
|
||||||
const assignableTeachers = computed(() => {
|
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))
|
const approvedIds = new Set(manualEligibleTeachers.value.map((item) => item.teacherId))
|
||||||
return teachers.value.filter((item) =>
|
return teachers.value.filter((item) =>
|
||||||
approvedIds.has(item.id) || form.teacherIds?.includes(item.id),
|
approvedIds.has(item.id) || form.teacherIds?.includes(item.id),
|
||||||
@@ -61,10 +62,35 @@ const publicCourses = computed(() =>
|
|||||||
['GeneralRequired', 'GeneralElective'].includes(item.nature),
|
['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(() =>
|
const generationGroupCount = computed(() =>
|
||||||
Math.ceil((generationForm.classIds?.length ?? 0) / (generationForm.classesPerTask || 1)),
|
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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -132,7 +158,7 @@ async function openEdit(row: any) {
|
|||||||
|
|
||||||
async function loadManualEligibleTeachers() {
|
async function loadManualEligibleTeachers() {
|
||||||
manualEligibleTeachers.value = []
|
manualEligibleTeachers.value = []
|
||||||
if (!form.academicTermId || !form.courseId || !manualCourseIsPublic.value) return
|
if (!form.academicTermId || !form.courseId) return
|
||||||
try {
|
try {
|
||||||
manualEligibleTeachers.value = (await http.get('/teacher-course-applications/eligible', {
|
manualEligibleTeachers.value = (await http.get('/teacher-course-applications/eligible', {
|
||||||
params: {
|
params: {
|
||||||
@@ -298,14 +324,14 @@ onMounted(async () => {
|
|||||||
<p>将学期、课程、授课教师和行政班组合为可排课、可选课的教学班。</p>
|
<p>将学期、课程、授课教师和行政班组合为可排课、可选课的教学班。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-actions">
|
<div class="page-actions">
|
||||||
<el-button @click="openGeneration">公共课合班生成</el-button>
|
<el-button v-if="canGeneratePublic" @click="openGeneration">公共课合班生成</el-button>
|
||||||
<el-button type="primary" :icon="Plus" @click="openCreate">新建教学任务</el-button>
|
<el-button type="primary" :icon="Plus" @click="openCreate">新建教学任务</el-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="task-summary">
|
<section class="task-summary">
|
||||||
<div><span>当前结果</span><b>{{ total }}</b><small>个教学班</small></div>
|
<div><span>当前结果</span><b>{{ total }}</b><small>个教学班</small></div>
|
||||||
<p>发布校验:主讲教师唯一 · 行政班已关联 · 容量覆盖在籍学生 · 周次有效</p>
|
<p>{{ responsibilityText }} 发布时还会校验教师授课资格、行政班、容量和周次。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="data-card">
|
<section class="data-card">
|
||||||
@@ -324,7 +350,13 @@ onMounted(async () => {
|
|||||||
<el-table v-loading="loading" :data="rows" class="data-table task-table">
|
<el-table v-loading="loading" :data="rows" class="data-table task-table">
|
||||||
<el-table-column label="任务编号" width="155"><template #default="{ row }"><span class="registry-number">{{ row.taskNumber }}</span></template></el-table-column>
|
<el-table-column label="任务编号" width="155"><template #default="{ row }"><span class="registry-number">{{ row.taskNumber }}</span></template></el-table-column>
|
||||||
<el-table-column label="教学班 / 课程" min-width="230">
|
<el-table-column label="教学班 / 课程" min-width="230">
|
||||||
<template #default="{ row }"><div class="course-name"><b>{{ row.name }}</b><span>{{ row.courseCode }} · {{ row.courseName }}</span></div></template>
|
<template #default="{ row }">
|
||||||
|
<div class="course-name">
|
||||||
|
<b>{{ row.name }}</b>
|
||||||
|
<span>{{ row.courseCode }} · {{ row.courseName }}</span>
|
||||||
|
<small>{{ ['GeneralRequired', 'GeneralElective'].includes(row.courseNature) ? '校级教务负责' : '开课学院负责' }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="termName" label="开课学期" min-width="180" />
|
<el-table-column prop="termName" label="开课学期" min-width="180" />
|
||||||
<el-table-column label="授课教师" min-width="130"><template #default="{ row }">{{ row.teacherNames.join('、') || '未安排' }}</template></el-table-column>
|
<el-table-column label="授课教师" min-width="130"><template #default="{ row }">{{ row.teacherNames.join('、') || '未安排' }}</template></el-table-column>
|
||||||
@@ -334,10 +366,11 @@ onMounted(async () => {
|
|||||||
<el-table-column label="状态" width="90"><template #default="{ row }"><span class="table-status" :class="{ off: row.status === 'Closed' }">{{ statusLabels[row.status] }}</span></template></el-table-column>
|
<el-table-column label="状态" width="90"><template #default="{ row }"><span class="table-status" :class="{ off: row.status === 'Closed' }">{{ statusLabels[row.status] }}</span></template></el-table-column>
|
||||||
<el-table-column label="操作" width="185" fixed="right">
|
<el-table-column label="操作" width="185" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button v-if="row.status === 'Draft'" link type="primary" @click="openEdit(row)">编辑</el-button>
|
<el-button v-if="canManageRow(row) && row.status === 'Draft'" link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||||
<el-button v-if="row.status === 'Draft'" link type="success" @click="publish(row)">发布</el-button>
|
<el-button v-if="canManageRow(row) && row.status === 'Draft'" link type="success" @click="publish(row)">发布</el-button>
|
||||||
<el-button v-if="row.status === 'Draft'" link type="danger" @click="remove(row)">删除</el-button>
|
<el-button v-if="canManageRow(row) && row.status === 'Draft'" link type="danger" @click="remove(row)">删除</el-button>
|
||||||
<el-button v-if="row.status === 'Published'" link type="warning" @click="closeTask(row)">结课</el-button>
|
<el-button v-if="canManageRow(row) && row.status === 'Published'" link type="warning" @click="closeTask(row)">结课</el-button>
|
||||||
|
<span v-if="!canManageRow(row) && row.status !== 'Closed'" class="muted-action">只读</span>
|
||||||
<span v-if="row.status === 'Closed'" class="muted-action">已归档</span>
|
<span v-if="row.status === 'Closed'" class="muted-action">已归档</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -357,7 +390,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<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.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>
|
<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 manageableCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" /></el-select></el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid three">
|
<div class="form-grid three">
|
||||||
<el-form-item label="容量"><el-input-number v-model="form.capacity" :min="1" /></el-form-item>
|
<el-form-item label="容量"><el-input-number v-model="form.capacity" :min="1" /></el-form-item>
|
||||||
@@ -369,7 +402,7 @@ onMounted(async () => {
|
|||||||
<el-select v-model="form.teacherIds" multiple filterable @change="onTeachersChanged">
|
<el-select v-model="form.teacherIds" multiple filterable @change="onTeachersChanged">
|
||||||
<el-option v-for="item in assignableTeachers" :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>
|
</el-select>
|
||||||
<small v-if="manualCourseIsPublic" class="field-hint">公共课仅显示本学期已申报且学院审核通过的教师。</small>
|
<small v-if="selectedCourse" class="field-hint">仅显示本学期申报该课程且学院审核通过的教师。</small>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="主讲教师">
|
<el-form-item label="主讲教师">
|
||||||
<el-select v-model="form.primaryTeacherId" clearable :disabled="!form.teacherIds?.length">
|
<el-select v-model="form.primaryTeacherId" clearable :disabled="!form.teacherIds?.length">
|
||||||
|
|||||||
Reference in New Issue
Block a user