课程库:保留并验证现有 Excel 导入,支持模板下载、按编码新增/更新、整批校验及事务回滚。
授课资格:学院可直接选择本院教师、学期和课程并分配资格,服务端强制学院数据范围。[后端接口 (line 178)](/E:/jiaowu/src/Jiaowu.Api/Controllers/TeacherCourseApplicationsController.cs:178) · [页面 (line 151)](/E:/jiaowu/web/src/views/TeachingPreferencesView.vue:151) 学生培养方案:新增“我的培养方案”,展示学分进度及已完成、在读、重修中、未通过、待完成、未修读课程,并支持搜索筛选。[学生接口 (line 15)](/E:/jiaowu/src/Jiaowu.Api/Controllers/StudentCurriculumController.cs:15) · [学生页面 (line 88)](/E:/jiaowu/web/src/views/StudentCurriculumView.vue:88)
This commit is contained in:
1 parent
0d34936d38
commit
9a3afa3df5
9 files changed
+1116
-5
No files matched your search
@@ -4,6 +4,8 @@
|
||||
|
||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||
|
||||
课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。
|
||||
|
||||
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
||||
|
||||
人员档案与登录账号分开维护。新增或 Excel 导入学生、教师档案时不会自动创建账号,也不会在修改档案时同步账号。学生首次使用时可以在登录页进入“自助激活”,填写姓名、学号、学院、专业、年级和行政班;全部匹配在籍档案后自行设置密码,系统才创建 Identity 登录账号并关联学生角色。`AspNetUsers` 作为 ASP.NET Core Identity 的内部安全存储,负责密码哈希、登录锁定、角色和令牌。
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Graduation;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
[Route("api/student/curriculum-plan")]
|
||||
public sealed class StudentCurriculumController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> GetMine(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var student = await db.Students.AsNoTracking()
|
||||
.Include(x => x.AdministrativeClass)
|
||||
.ThenInclude(x => x!.Major)
|
||||
.ThenInclude(x => x!.College)
|
||||
.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
|
||||
if (student is null)
|
||||
return ConflictProblem("当前账号尚未关联学生档案。");
|
||||
|
||||
var studentProfile = new
|
||||
{
|
||||
student.Id,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
student.EnrollmentYear,
|
||||
student.Status,
|
||||
ClassName = student.AdministrativeClass!.Name,
|
||||
MajorName = student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = student.AdministrativeClass.Major.College!.Name
|
||||
};
|
||||
|
||||
var plan = await db.CurriculumPlans.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(x => x.Major)
|
||||
.ThenInclude(x => x!.College)
|
||||
.Include(x => x.Modules)
|
||||
.ThenInclude(x => x.Courses)
|
||||
.ThenInclude(x => x.Course)
|
||||
.ThenInclude(x => x!.CourseCategory)
|
||||
.Where(x =>
|
||||
x.MajorId == student.AdministrativeClass.MajorId &&
|
||||
x.EffectiveGrade == student.EnrollmentYear &&
|
||||
x.Status == CurriculumPlanStatus.Published)
|
||||
.OrderByDescending(x => x.PublishedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (plan is null)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
Student = studentProfile,
|
||||
Plan = (object?)null,
|
||||
Message =
|
||||
$"{student.EnrollmentYear} 级{student.AdministrativeClass.Major.Name}尚未发布培养方案。"
|
||||
});
|
||||
}
|
||||
|
||||
var planCourseIds = plan.Modules
|
||||
.SelectMany(x => x.Courses)
|
||||
.Select(x => x.CourseId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var gradeAttempts = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
planCourseIds.Contains(x.GradeSheet!.TeachingTask!.CourseId) &&
|
||||
x.GradeSheet.Status == GradeSheetStatus.Published)
|
||||
.Select(x => new StudentGradeAttempt(
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
x.TotalScore,
|
||||
x.GradePoint,
|
||||
x.ExamStatus,
|
||||
x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||
x.GradeSheet.TeachingTask.TaskNumber,
|
||||
x.GradeSheet.PublishedAt ?? x.GradeSheet.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var currentCourseIds = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(task =>
|
||||
planCourseIds.Contains(task.CourseId) &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.AcademicTerm!.IsCurrent &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClassId == student.AdministrativeClassId) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)))
|
||||
.Select(x => x.CourseId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
var currentCourseIdSet = currentCourseIds.ToHashSet();
|
||||
var attemptsByCourse = gradeAttempts
|
||||
.GroupBy(x => x.CourseId)
|
||||
.ToDictionary(x => x.Key, x => x.OrderByDescending(
|
||||
attempt => attempt.PublishedAt).ToList());
|
||||
|
||||
var progressByCourse = new Dictionary<Guid, CourseProgress>();
|
||||
foreach (var courseId in planCourseIds)
|
||||
{
|
||||
var attempts = attemptsByCourse.GetValueOrDefault(courseId) ?? [];
|
||||
var evaluation = StudentCourseProgressRules.Evaluate(
|
||||
attempts.Select(x => new StudentCourseAttemptSnapshot(
|
||||
x.TotalScore,
|
||||
x.ExamStatus)),
|
||||
currentCourseIdSet.Contains(courseId));
|
||||
var displayedAttempt = evaluation.HasPassedAttempt
|
||||
? attempts.FirstOrDefault(x => StudentCourseProgressRules.IsPassed(
|
||||
new StudentCourseAttemptSnapshot(x.TotalScore, x.ExamStatus)))
|
||||
: attempts.FirstOrDefault();
|
||||
progressByCourse[courseId] = new CourseProgress(
|
||||
evaluation,
|
||||
displayedAttempt);
|
||||
}
|
||||
|
||||
var distinctCourses = plan.Modules
|
||||
.SelectMany(x => x.Courses)
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Select(x => x.First())
|
||||
.ToList();
|
||||
var completedCredits = distinctCourses
|
||||
.Where(x =>
|
||||
progressByCourse[x.CourseId].Evaluation.Status ==
|
||||
StudentCourseProgressStatus.Completed)
|
||||
.Sum(x => x.Course?.Credits ?? 0);
|
||||
var completedCourseCount = distinctCourses.Count(x =>
|
||||
progressByCourse[x.CourseId].Evaluation.Status ==
|
||||
StudentCourseProgressStatus.Completed);
|
||||
var inProgressCourseCount = distinctCourses.Count(x =>
|
||||
progressByCourse[x.CourseId].Evaluation.Status is
|
||||
StudentCourseProgressStatus.InProgress or
|
||||
StudentCourseProgressStatus.Retaking);
|
||||
var failedCourseCount = distinctCourses.Count(x =>
|
||||
progressByCourse[x.CourseId].Evaluation.HasFailedAttempt &&
|
||||
!progressByCourse[x.CourseId].Evaluation.HasPassedAttempt);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Student = studentProfile,
|
||||
Plan = new
|
||||
{
|
||||
plan.Id,
|
||||
plan.Name,
|
||||
plan.Version,
|
||||
plan.EffectiveGrade,
|
||||
plan.TotalCredits,
|
||||
plan.Description,
|
||||
plan.PublishedAt,
|
||||
MajorName = plan.Major!.Name,
|
||||
CollegeName = plan.Major.College!.Name,
|
||||
SchoolingYears = plan.Major.SchoolingYears,
|
||||
Summary = new
|
||||
{
|
||||
CourseCount = distinctCourses.Count,
|
||||
CompletedCourseCount = completedCourseCount,
|
||||
InProgressCourseCount = inProgressCourseCount,
|
||||
FailedCourseCount = failedCourseCount,
|
||||
CompletedCredits = completedCredits,
|
||||
CompletionRate = plan.TotalCredits <= 0
|
||||
? 0
|
||||
: Math.Min(100, Math.Round(
|
||||
completedCredits / plan.TotalCredits * 100,
|
||||
1))
|
||||
},
|
||||
Modules = plan.Modules
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(module => new
|
||||
{
|
||||
module.Id,
|
||||
module.Code,
|
||||
module.Name,
|
||||
module.RequiredCredits,
|
||||
module.SortOrder,
|
||||
Courses = module.Courses
|
||||
.OrderBy(x => x.RecommendedSemester)
|
||||
.ThenBy(x => x.Course!.Code)
|
||||
.Select(item =>
|
||||
{
|
||||
var progress = progressByCourse[item.CourseId];
|
||||
return new
|
||||
{
|
||||
item.Id,
|
||||
item.CourseId,
|
||||
CourseCode = item.Course!.Code,
|
||||
CourseName = item.Course.Name,
|
||||
item.Course.Credits,
|
||||
item.Course.TotalHours,
|
||||
item.Course.Nature,
|
||||
CategoryName = item.Course.CourseCategory?.Name,
|
||||
item.RecommendedSemester,
|
||||
item.Type,
|
||||
item.Notes,
|
||||
progress.Evaluation.Status,
|
||||
progress.Evaluation.HasFailedAttempt,
|
||||
progress.Evaluation.HasPassedAttempt,
|
||||
LatestResult = progress.DisplayedAttempt is null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
progress.DisplayedAttempt.TotalScore,
|
||||
progress.DisplayedAttempt.GradePoint,
|
||||
progress.DisplayedAttempt.ExamStatus,
|
||||
progress.DisplayedAttempt.TermName,
|
||||
progress.DisplayedAttempt.TaskNumber,
|
||||
progress.DisplayedAttempt.PublishedAt
|
||||
}
|
||||
};
|
||||
})
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "无法查看培养方案",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private sealed record StudentGradeAttempt(
|
||||
Guid CourseId,
|
||||
decimal? TotalScore,
|
||||
decimal? GradePoint,
|
||||
GradeExamStatus ExamStatus,
|
||||
string TermName,
|
||||
string TaskNumber,
|
||||
DateTime PublishedAt);
|
||||
|
||||
private sealed record CourseProgress(
|
||||
StudentCourseProgressEvaluation Evaluation,
|
||||
StudentGradeAttempt? DisplayedAttempt);
|
||||
}
|
||||
@@ -175,6 +175,102 @@ public sealed class TeacherCourseApplicationsController(
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("assignment-options")]
|
||||
[Authorize(Roles = ReviewRoles)]
|
||||
public async Task<ActionResult> GetAssignmentOptions(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var teachers = db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active);
|
||||
var collegeId = currentUserDataScope.Current.RestrictedCollegeId;
|
||||
if (collegeId.HasValue)
|
||||
teachers = teachers.Where(x => x.CollegeId == collegeId.Value);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Teachers = await teachers
|
||||
.OrderBy(x => x.TeacherNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TeacherNumber,
|
||||
x.Name,
|
||||
x.Title,
|
||||
x.CollegeId,
|
||||
CollegeName = x.College!.Name
|
||||
})
|
||||
.ToListAsync(cancellationToken),
|
||||
Courses = await db.Courses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.CollegeId,
|
||||
CollegeName = x.College!.Name,
|
||||
x.Credits,
|
||||
x.Nature
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("assign")]
|
||||
[Authorize(Roles = ReviewRoles)]
|
||||
public async Task<ActionResult> Assign(
|
||||
TeacherCourseAssignmentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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 teachers = db.Teachers.Where(x =>
|
||||
x.Id == request.TeacherId &&
|
||||
x.Status == TeacherStatus.Active);
|
||||
var collegeId = currentUserDataScope.Current.RestrictedCollegeId;
|
||||
if (collegeId.HasValue)
|
||||
teachers = teachers.Where(x => x.CollegeId == collegeId.Value);
|
||||
if (!await teachers.AnyAsync(cancellationToken))
|
||||
return ValidationProblem("所选教师不存在、不在职或不属于当前学院。");
|
||||
|
||||
var application = await db.TeacherCourseApplications
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeacherId == request.TeacherId &&
|
||||
x.CourseId == request.CourseId,
|
||||
cancellationToken);
|
||||
if (application?.Status == TeacherCourseApplicationStatus.Approved)
|
||||
return ConflictProblem("该教师在所选学期已具备此课程的授课资格。");
|
||||
|
||||
if (application is null)
|
||||
{
|
||||
application = new TeacherCourseApplication
|
||||
{
|
||||
AcademicTermId = request.AcademicTermId,
|
||||
TeacherId = request.TeacherId,
|
||||
CourseId = request.CourseId,
|
||||
SubmittedAt = DateTime.UtcNow
|
||||
};
|
||||
db.TeacherCourseApplications.Add(application);
|
||||
}
|
||||
|
||||
application.Status = TeacherCourseApplicationStatus.Approved;
|
||||
application.ReviewComment = Normalize(request.Comment) ?? "学院直接分配";
|
||||
application.ReviewedAt = DateTime.UtcNow;
|
||||
application.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new { application.Id });
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/review")]
|
||||
[Authorize(Roles = ReviewRoles)]
|
||||
public async Task<ActionResult> Review(
|
||||
@@ -262,3 +358,9 @@ public sealed record TeacherCourseApplicationRequest(
|
||||
public sealed record TeacherCourseReviewRequest(
|
||||
TeacherCourseApplicationStatus Status,
|
||||
[MaxLength(500)] string? ReviewComment);
|
||||
|
||||
public sealed record TeacherCourseAssignmentRequest(
|
||||
Guid AcademicTermId,
|
||||
Guid TeacherId,
|
||||
Guid CourseId,
|
||||
[MaxLength(500)] string? Comment);
|
||||
Loaded 3 of 9 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user