课程库:保留并验证现有 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:
2026-07-25 09:23:10 +08:00 Unverified
parent 0d34936d38
commit 9a3afa3df5
9 changed files with 1119 additions and 8 deletions
@@ -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);
@@ -0,0 +1,62 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Graduation;
public enum StudentCourseProgressStatus
{
NotStarted = 1,
InProgress = 2,
Retaking = 3,
Completed = 4,
Failed = 5,
Pending = 6
}
public sealed record StudentCourseAttemptSnapshot(
decimal? TotalScore,
GradeExamStatus ExamStatus);
public sealed record StudentCourseProgressEvaluation(
StudentCourseProgressStatus Status,
bool HasPassedAttempt,
bool HasFailedAttempt);
public static class StudentCourseProgressRules
{
public static StudentCourseProgressEvaluation Evaluate(
IEnumerable<StudentCourseAttemptSnapshot> attempts,
bool isInProgress)
{
var attemptList = attempts.ToList();
var hasPassedAttempt = attemptList.Any(IsPassed);
var hasFailedAttempt = attemptList.Any(IsFailed);
var hasPendingAttempt = attemptList.Any(x =>
!IsPassed(x) &&
!IsFailed(x));
var status = hasPassedAttempt
? StudentCourseProgressStatus.Completed
: isInProgress && hasFailedAttempt
? StudentCourseProgressStatus.Retaking
: isInProgress
? StudentCourseProgressStatus.InProgress
: hasFailedAttempt
? StudentCourseProgressStatus.Failed
: hasPendingAttempt
? StudentCourseProgressStatus.Pending
: StudentCourseProgressStatus.NotStarted;
return new StudentCourseProgressEvaluation(
status,
hasPassedAttempt,
hasFailedAttempt);
}
public static bool IsPassed(StudentCourseAttemptSnapshot attempt) =>
attempt.ExamStatus == GradeExamStatus.Exempt ||
attempt.TotalScore is decimal score && score >= 60;
public static bool IsFailed(StudentCourseAttemptSnapshot attempt) =>
attempt.ExamStatus == GradeExamStatus.Absent ||
attempt.TotalScore is decimal score && score < 60;
}