预演
This commit is contained in:
@@ -0,0 +1,621 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
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/academic-planning")]
|
||||
public sealed class AcademicPlanningController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
var loaded = await LoadAsync(cancellationToken);
|
||||
if (loaded.Error is not null) return loaded.Error;
|
||||
var context = loaded.Context!;
|
||||
|
||||
var completion = CurriculumCompletionRules.Evaluate(
|
||||
context.Plan.Modules,
|
||||
context.PassedCourseIds);
|
||||
var planCompletedCredits = context.Courses
|
||||
.Where(x => context.PassedCourseIds.Contains(x.CourseId))
|
||||
.Sum(x => x.Credits);
|
||||
var suggestionIds = AcademicPlanningRules.SuggestNextSemester(
|
||||
context.CourseSnapshots,
|
||||
context.PassedCourseIds,
|
||||
context.InProgressCourseIds,
|
||||
context.NextSemester);
|
||||
var suggestionIdSet = suggestionIds.ToHashSet();
|
||||
var latestAudit = await db.GraduationAuditResults.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == context.Student.Id &&
|
||||
x.GraduationAuditBatch!.Status ==
|
||||
GraduationAuditBatchStatus.Published)
|
||||
.OrderByDescending(x => x.GraduationAuditBatch!.GraduationYear)
|
||||
.ThenByDescending(x => x.GraduationAuditBatch!.PublishedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
BatchName = x.GraduationAuditBatch!.Name,
|
||||
x.GraduationAuditBatch.GraduationYear,
|
||||
x.RequiredCredits,
|
||||
x.EarnedCredits,
|
||||
x.RequiredCourseCount,
|
||||
x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount,
|
||||
x.MissingCourseNames,
|
||||
x.Conclusion,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.GraduationAuditBatch.PublishedAt
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
var endSemester = Math.Max(
|
||||
context.Plan.Major!.SchoolingYears * 2,
|
||||
context.NextSemester + 3);
|
||||
return Ok(new
|
||||
{
|
||||
Student = new
|
||||
{
|
||||
context.Student.Id,
|
||||
context.Student.StudentNumber,
|
||||
context.Student.Name,
|
||||
context.Student.EnrollmentYear,
|
||||
context.Student.Status,
|
||||
ClassName = context.Student.AdministrativeClass!.Name,
|
||||
MajorName = context.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName =
|
||||
context.Student.AdministrativeClass.Major.College!.Name
|
||||
},
|
||||
Plan = new
|
||||
{
|
||||
context.Plan.Id,
|
||||
context.Plan.Name,
|
||||
context.Plan.Version,
|
||||
context.Plan.TotalCredits,
|
||||
SchoolingYears = context.Plan.Major.SchoolingYears,
|
||||
CurrentSemester = context.CurrentSemester,
|
||||
NextSemester = context.NextSemester,
|
||||
StandardGraduationSemester =
|
||||
context.Plan.Major.SchoolingYears * 2
|
||||
},
|
||||
Baseline = new
|
||||
{
|
||||
EarnedCredits = context.EarnedCredits,
|
||||
PlanCompletedCredits = planCompletedCredits,
|
||||
CreditGap = Math.Max(
|
||||
context.Plan.TotalCredits - context.EarnedCredits,
|
||||
0),
|
||||
CompletionRate = context.Plan.TotalCredits <= 0
|
||||
? 0
|
||||
: Math.Min(100, Math.Round(
|
||||
planCompletedCredits / context.Plan.TotalCredits * 100,
|
||||
1)),
|
||||
completion.RequirementCount,
|
||||
completion.PassedRequirementCount,
|
||||
completion.MissingRequirements,
|
||||
InProgressCredits = context.Courses
|
||||
.Where(x => context.InProgressCourseIds.Contains(x.CourseId))
|
||||
.Sum(x => x.Credits)
|
||||
},
|
||||
Terms = Enumerable.Range(
|
||||
context.NextSemester,
|
||||
endSemester - context.NextSemester + 1)
|
||||
.Select(semester => new
|
||||
{
|
||||
Semester = semester,
|
||||
Label = FormatSemester(
|
||||
context.Student.EnrollmentYear,
|
||||
semester),
|
||||
IsBeyondStandard =
|
||||
semester > context.Plan.Major.SchoolingYears * 2
|
||||
}),
|
||||
Courses = context.Courses
|
||||
.OrderBy(x => x.RecommendedSemester)
|
||||
.ThenBy(x => x.CourseCode)
|
||||
.Select(course => new
|
||||
{
|
||||
course.CourseId,
|
||||
course.CourseCode,
|
||||
course.CourseName,
|
||||
course.Credits,
|
||||
course.RecommendedSemester,
|
||||
course.Type,
|
||||
course.ModuleCode,
|
||||
course.ModuleName,
|
||||
Status = context.StatusByCourse[course.CourseId],
|
||||
IsSuggested = suggestionIdSet.Contains(course.CourseId),
|
||||
Prerequisites = course.Prerequisites.Select(item => new
|
||||
{
|
||||
item.CourseId,
|
||||
item.CourseCode,
|
||||
item.CourseName,
|
||||
IsCompleted =
|
||||
context.PassedCourseIds.Contains(item.CourseId),
|
||||
IsInProgress =
|
||||
context.InProgressCourseIds.Contains(item.CourseId)
|
||||
})
|
||||
}),
|
||||
NextSemesterSuggestion = suggestionIds.Select(courseId =>
|
||||
{
|
||||
var course = context.CourseById[courseId];
|
||||
return new
|
||||
{
|
||||
course.CourseId,
|
||||
course.CourseCode,
|
||||
course.CourseName,
|
||||
course.Credits,
|
||||
course.Type,
|
||||
Reason = course.Type == CurriculumCourseType.Required &&
|
||||
course.RecommendedSemester <= context.NextSemester
|
||||
? "计划学期已到,且先修条件已满足"
|
||||
: course.Type == CurriculumCourseType.Required
|
||||
? "必修课程,按培养方案顺序推进"
|
||||
: "用于补足培养模块与总学分"
|
||||
};
|
||||
}),
|
||||
LatestGraduationAudit = latestAudit,
|
||||
Assumptions = new[]
|
||||
{
|
||||
"模拟课程按顺利通过计算,不会写入成绩或正式选课。",
|
||||
$"预计毕业学期按每学期最多 {AcademicPlanningRules.RecommendedSemesterCreditLimit:0} 学分估算。",
|
||||
"当前在读课程按本学期顺利完成计入预测。"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("simulate")]
|
||||
public async Task<ActionResult> Simulate(
|
||||
AcademicPlanningRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var loaded = await LoadAsync(cancellationToken);
|
||||
if (loaded.Error is not null) return loaded.Error;
|
||||
var context = loaded.Context!;
|
||||
|
||||
var selections = request.Terms
|
||||
.SelectMany(term => term.CourseIds.Select(courseId => new
|
||||
{
|
||||
term.Semester,
|
||||
CourseId = courseId
|
||||
}))
|
||||
.ToList();
|
||||
var duplicate = selections
|
||||
.GroupBy(x => x.CourseId)
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
if (duplicate is not null)
|
||||
return ValidationProblem("同一门课程不能安排在多个学期。");
|
||||
if (request.Terms.Any(x =>
|
||||
x.Semester < context.NextSemester ||
|
||||
x.Semester > context.NextSemester + 12))
|
||||
return ValidationProblem("模拟学期超出了可规划范围。");
|
||||
|
||||
var invalidCourse = selections.FirstOrDefault(x =>
|
||||
!context.CourseById.ContainsKey(x.CourseId));
|
||||
if (invalidCourse is not null)
|
||||
return ValidationProblem("模拟计划包含不属于当前培养方案的课程。");
|
||||
var alreadyHandled = selections.FirstOrDefault(x =>
|
||||
context.PassedCourseIds.Contains(x.CourseId) ||
|
||||
context.InProgressCourseIds.Contains(x.CourseId));
|
||||
if (alreadyHandled is not null)
|
||||
return ValidationProblem("已完成或当前在读课程无需重复安排。");
|
||||
|
||||
var plannedSemesters = selections.ToDictionary(
|
||||
x => x.CourseId,
|
||||
x => x.Semester);
|
||||
var projectedCourseIds = context.PassedCourseIds
|
||||
.Concat(context.InProgressCourseIds)
|
||||
.Concat(plannedSemesters.Keys)
|
||||
.ToHashSet();
|
||||
var completion = CurriculumCompletionRules.Evaluate(
|
||||
context.Plan.Modules,
|
||||
projectedCourseIds);
|
||||
var addedCredits = context.Courses
|
||||
.Where(x =>
|
||||
!context.PassedCourseIds.Contains(x.CourseId) &&
|
||||
(context.InProgressCourseIds.Contains(x.CourseId) ||
|
||||
plannedSemesters.ContainsKey(x.CourseId)))
|
||||
.Sum(x => x.Credits);
|
||||
var projectedEarnedCredits = context.EarnedCredits + addedCredits;
|
||||
var creditGap = Math.Max(
|
||||
context.Plan.TotalCredits - projectedEarnedCredits,
|
||||
0);
|
||||
var prerequisiteIssues = AcademicPlanningRules.FindPrerequisiteIssues(
|
||||
context.CourseSnapshots,
|
||||
context.PassedCourseIds,
|
||||
context.InProgressCourseIds,
|
||||
plannedSemesters);
|
||||
var conflicts = prerequisiteIssues.Select(issue =>
|
||||
{
|
||||
var course = context.CourseById[issue.CourseId];
|
||||
var prerequisite = context.AllCourseLabels.GetValueOrDefault(
|
||||
issue.PrerequisiteCourseId,
|
||||
new CourseLabel(
|
||||
issue.PrerequisiteCourseId,
|
||||
"未知课程",
|
||||
"未找到的先修课程"));
|
||||
return new
|
||||
{
|
||||
Type = "Prerequisite",
|
||||
issue.CourseId,
|
||||
course.CourseName,
|
||||
PrerequisiteCourseId = prerequisite.CourseId,
|
||||
PrerequisiteCourseName = prerequisite.CourseName,
|
||||
issue.PlannedSemester,
|
||||
issue.PrerequisitePlannedSemester,
|
||||
Message = issue.PrerequisitePlannedSemester.HasValue
|
||||
? $"《{prerequisite.CourseName}》必须安排在《{course.CourseName}》之前。"
|
||||
: $"《{course.CourseName}》的先修课程《{prerequisite.CourseName}》尚未完成或安排。"
|
||||
};
|
||||
}).ToList();
|
||||
var workloadWarnings = request.Terms
|
||||
.Select(term => new
|
||||
{
|
||||
term.Semester,
|
||||
Credits = term.CourseIds.Sum(courseId =>
|
||||
context.CourseById.GetValueOrDefault(courseId)?.Credits ?? 0)
|
||||
})
|
||||
.Where(x =>
|
||||
x.Credits > AcademicPlanningRules.HeavySemesterCreditLimit)
|
||||
.Select(x => new
|
||||
{
|
||||
Type = "Workload",
|
||||
x.Semester,
|
||||
x.Credits,
|
||||
Message =
|
||||
$"第 {x.Semester} 学期安排了 {x.Credits:0.#} 学分,超过建议上限 {AcademicPlanningRules.HeavySemesterCreditLimit:0.#} 学分。"
|
||||
})
|
||||
.ToList();
|
||||
var timingWarnings = selections
|
||||
.Select(item => new
|
||||
{
|
||||
item.Semester,
|
||||
Course = context.CourseById[item.CourseId]
|
||||
})
|
||||
.Where(x => x.Semester < x.Course.RecommendedSemester)
|
||||
.Select(x => new
|
||||
{
|
||||
Type = "EarlyCourse",
|
||||
x.Course.CourseId,
|
||||
x.Course.CourseName,
|
||||
x.Semester,
|
||||
x.Course.RecommendedSemester,
|
||||
Message =
|
||||
$"《{x.Course.CourseName}》早于培养方案建议学期修读,请确认课程开设条件。"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var unresolvedFailedCourseCount = context.FailedCourseIds.Count(
|
||||
courseId => !projectedCourseIds.Contains(courseId));
|
||||
var conclusion = GraduationAuditRules.Evaluate(
|
||||
true,
|
||||
context.Student.Status,
|
||||
context.Plan.TotalCredits,
|
||||
projectedEarnedCredits,
|
||||
completion.RequirementCount,
|
||||
completion.PassedRequirementCount,
|
||||
unresolvedFailedCourseCount);
|
||||
var latestPlannedSemester = plannedSemesters.Count == 0
|
||||
? context.NextSemester - 1
|
||||
: plannedSemesters.Values.Max();
|
||||
var estimatedSemester =
|
||||
AcademicPlanningRules.EstimateCompletionSemester(
|
||||
context.NextSemester,
|
||||
latestPlannedSemester,
|
||||
creditGap,
|
||||
completion.RequirementCount -
|
||||
completion.PassedRequirementCount);
|
||||
var remainingRequiredSemesterFloor = context.Courses
|
||||
.Where(course =>
|
||||
course.Type == CurriculumCourseType.Required &&
|
||||
!projectedCourseIds.Contains(course.CourseId))
|
||||
.Select(course => course.RecommendedSemester)
|
||||
.DefaultIfEmpty(estimatedSemester)
|
||||
.Max();
|
||||
estimatedSemester = Math.Max(
|
||||
estimatedSemester,
|
||||
remainingRequiredSemesterFloor);
|
||||
var planCompletedCredits = context.Courses
|
||||
.Where(x => projectedCourseIds.Contains(x.CourseId))
|
||||
.Sum(x => x.Credits);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Projected = new
|
||||
{
|
||||
EarnedCredits = projectedEarnedCredits,
|
||||
PlanCompletedCredits = planCompletedCredits,
|
||||
CreditGap = creditGap,
|
||||
CompletionRate = context.Plan.TotalCredits <= 0
|
||||
? 0
|
||||
: Math.Min(100, Math.Round(
|
||||
planCompletedCredits / context.Plan.TotalCredits * 100,
|
||||
1)),
|
||||
completion.RequirementCount,
|
||||
completion.PassedRequirementCount,
|
||||
completion.MissingRequirements,
|
||||
UnresolvedFailedCourseCount = unresolvedFailedCourseCount,
|
||||
GraduationConclusion = conclusion,
|
||||
EstimatedGraduationSemester = estimatedSemester,
|
||||
EstimatedGraduationTerm = FormatSemester(
|
||||
context.Student.EnrollmentYear,
|
||||
estimatedSemester),
|
||||
IsBeyondStandard =
|
||||
estimatedSemester >
|
||||
context.Plan.Major!.SchoolingYears * 2
|
||||
},
|
||||
Conflicts = conflicts,
|
||||
Warnings = workloadWarnings.Cast<object>()
|
||||
.Concat(timingWarnings)
|
||||
.ToList(),
|
||||
TermSummaries = request.Terms
|
||||
.OrderBy(x => x.Semester)
|
||||
.Select(term => new
|
||||
{
|
||||
term.Semester,
|
||||
Label = FormatSemester(
|
||||
context.Student.EnrollmentYear,
|
||||
term.Semester),
|
||||
CourseCount = term.CourseIds.Count,
|
||||
Credits = term.CourseIds.Sum(courseId =>
|
||||
context.CourseById.GetValueOrDefault(courseId)?.Credits ??
|
||||
0)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<LoadResult> LoadAsync(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 new LoadResult(
|
||||
null,
|
||||
ConflictProblem("当前账号尚未关联学生档案。"));
|
||||
|
||||
var plan = await db.CurriculumPlans.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(x => x.Major)
|
||||
.Include(x => x.Modules)
|
||||
.ThenInclude(x => x.Courses)
|
||||
.ThenInclude(x => x.Course)
|
||||
.ThenInclude(x => x!.Prerequisites)
|
||||
.ThenInclude(x => x.PrerequisiteCourse)
|
||||
.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 new LoadResult(
|
||||
null,
|
||||
ConflictProblem(
|
||||
$"{student.EnrollmentYear} 级{student.AdministrativeClass!.Major!.Name}尚未发布培养方案。"));
|
||||
|
||||
var courses = plan.Modules
|
||||
.SelectMany(module => module.Courses.Select(item =>
|
||||
new PlanningCourse(
|
||||
item.CourseId,
|
||||
item.Course!.Code,
|
||||
item.Course.Name,
|
||||
item.Course.Credits,
|
||||
item.RecommendedSemester,
|
||||
item.Type,
|
||||
module.Code,
|
||||
module.Name,
|
||||
item.Course.Prerequisites
|
||||
.Select(prerequisite => new CourseLabel(
|
||||
prerequisite.PrerequisiteCourseId,
|
||||
prerequisite.PrerequisiteCourse!.Code,
|
||||
prerequisite.PrerequisiteCourse.Name))
|
||||
.OrderBy(x => x.CourseCode)
|
||||
.ToList())))
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
var planCourseIds = courses.Select(x => x.CourseId).ToArray();
|
||||
|
||||
var gradeAttempts = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Select(x => new GradeAttempt(
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
x.GradeSheet.TeachingTask.Course!.Credits,
|
||||
x.TotalScore,
|
||||
x.ExamStatus))
|
||||
.ToListAsync(cancellationToken);
|
||||
var passedCourseIds = gradeAttempts
|
||||
.Where(x => StudentCourseProgressRules.IsPassed(
|
||||
new StudentCourseAttemptSnapshot(x.TotalScore, x.ExamStatus)))
|
||||
.Select(x => x.CourseId)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
var failedCourseIds = gradeAttempts
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Where(group => !group.Any(x =>
|
||||
StudentCourseProgressRules.IsPassed(
|
||||
new StudentCourseAttemptSnapshot(
|
||||
x.TotalScore,
|
||||
x.ExamStatus))))
|
||||
.Select(group => group.Key)
|
||||
.ToHashSet();
|
||||
var earnedCredits = gradeAttempts
|
||||
.Where(x => passedCourseIds.Contains(x.CourseId))
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Sum(group => group.Max(x => x.Credits));
|
||||
|
||||
var inProgressCourseIds = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(task =>
|
||||
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 inProgressSet = inProgressCourseIds
|
||||
.Where(planCourseIds.Contains)
|
||||
.Where(courseId => !passedCourseIds.Contains(courseId))
|
||||
.ToHashSet();
|
||||
|
||||
var attemptsByCourse = gradeAttempts
|
||||
.Where(x => planCourseIds.Contains(x.CourseId))
|
||||
.GroupBy(x => x.CourseId)
|
||||
.ToDictionary(x => x.Key, x => x.ToList());
|
||||
var statuses = courses.ToDictionary(
|
||||
x => x.CourseId,
|
||||
x => StudentCourseProgressRules.Evaluate(
|
||||
attemptsByCourse.GetValueOrDefault(x.CourseId, [])
|
||||
.Select(attempt => new StudentCourseAttemptSnapshot(
|
||||
attempt.TotalScore,
|
||||
attempt.ExamStatus)),
|
||||
inProgressSet.Contains(x.CourseId)).Status);
|
||||
var currentTerm = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.IsCurrent)
|
||||
.OrderByDescending(x => x.StartDate)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var currentSemester = CalculateCurrentSemester(
|
||||
student.EnrollmentYear,
|
||||
currentTerm);
|
||||
var snapshots = courses.Select(x =>
|
||||
new AcademicPlanningCourseSnapshot(
|
||||
x.CourseId,
|
||||
x.CourseName,
|
||||
x.Credits,
|
||||
x.RecommendedSemester,
|
||||
x.Type,
|
||||
x.Prerequisites.Select(p => p.CourseId).ToArray()))
|
||||
.ToList();
|
||||
var allLabels = courses
|
||||
.Select(x => new CourseLabel(
|
||||
x.CourseId,
|
||||
x.CourseCode,
|
||||
x.CourseName))
|
||||
.Concat(courses.SelectMany(x => x.Prerequisites))
|
||||
.GroupBy(x => x.CourseId)
|
||||
.ToDictionary(x => x.Key, x => x.First());
|
||||
|
||||
return new LoadResult(
|
||||
new PlanningContext(
|
||||
student,
|
||||
plan,
|
||||
courses,
|
||||
courses.ToDictionary(x => x.CourseId),
|
||||
snapshots,
|
||||
allLabels,
|
||||
passedCourseIds,
|
||||
inProgressSet,
|
||||
failedCourseIds,
|
||||
statuses,
|
||||
earnedCredits,
|
||||
currentSemester,
|
||||
currentSemester + 1),
|
||||
null);
|
||||
}
|
||||
|
||||
private static int CalculateCurrentSemester(
|
||||
int enrollmentYear,
|
||||
AcademicTerm? currentTerm)
|
||||
{
|
||||
if (currentTerm is not null)
|
||||
{
|
||||
return Math.Max(
|
||||
1,
|
||||
currentTerm.Season == TermSeason.Autumn
|
||||
? (currentTerm.StartDate.Year - enrollmentYear) * 2 + 1
|
||||
: (currentTerm.StartDate.Year - enrollmentYear - 1) * 2 + 2);
|
||||
}
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
return Math.Max(
|
||||
1,
|
||||
today.Month >= 8
|
||||
? (today.Year - enrollmentYear) * 2 + 1
|
||||
: (today.Year - enrollmentYear - 1) * 2 + 2);
|
||||
}
|
||||
|
||||
private static string FormatSemester(int enrollmentYear, int semester)
|
||||
{
|
||||
var startYear = enrollmentYear + (semester - 1) / 2;
|
||||
var season = semester % 2 == 1 ? "秋季学期" : "春季学期";
|
||||
return $"{startYear}—{startYear + 1} 学年{season}";
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "无法进行学业规划",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private sealed record GradeAttempt(
|
||||
Guid CourseId,
|
||||
decimal Credits,
|
||||
decimal? TotalScore,
|
||||
GradeExamStatus ExamStatus);
|
||||
|
||||
private sealed record CourseLabel(
|
||||
Guid CourseId,
|
||||
string CourseCode,
|
||||
string CourseName);
|
||||
|
||||
private sealed record PlanningCourse(
|
||||
Guid CourseId,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
int RecommendedSemester,
|
||||
CurriculumCourseType Type,
|
||||
string ModuleCode,
|
||||
string ModuleName,
|
||||
IReadOnlyList<CourseLabel> Prerequisites);
|
||||
|
||||
private sealed record PlanningContext(
|
||||
Student Student,
|
||||
CurriculumPlan Plan,
|
||||
IReadOnlyList<PlanningCourse> Courses,
|
||||
IReadOnlyDictionary<Guid, PlanningCourse> CourseById,
|
||||
IReadOnlyList<AcademicPlanningCourseSnapshot> CourseSnapshots,
|
||||
IReadOnlyDictionary<Guid, CourseLabel> AllCourseLabels,
|
||||
IReadOnlySet<Guid> PassedCourseIds,
|
||||
IReadOnlySet<Guid> InProgressCourseIds,
|
||||
IReadOnlySet<Guid> FailedCourseIds,
|
||||
IReadOnlyDictionary<Guid, StudentCourseProgressStatus> StatusByCourse,
|
||||
decimal EarnedCredits,
|
||||
int CurrentSemester,
|
||||
int NextSemester);
|
||||
|
||||
private sealed record LoadResult(
|
||||
PlanningContext? Context,
|
||||
ActionResult? Error);
|
||||
}
|
||||
|
||||
public sealed record AcademicPlanningRequest(
|
||||
IReadOnlyCollection<AcademicPlanningTermRequest> Terms);
|
||||
|
||||
public sealed record AcademicPlanningTermRequest(
|
||||
[Range(1, 30)] int Semester,
|
||||
IReadOnlyCollection<Guid> CourseIds);
|
||||
@@ -84,6 +84,17 @@ public sealed class CoursesController(
|
||||
x.Nature,
|
||||
x.AssessmentMethod,
|
||||
x.Description,
|
||||
PrerequisiteCourseIds = x.Prerequisites
|
||||
.OrderBy(item => item.PrerequisiteCourse!.Code)
|
||||
.Select(item => item.PrerequisiteCourseId),
|
||||
Prerequisites = x.Prerequisites
|
||||
.OrderBy(item => item.PrerequisiteCourse!.Code)
|
||||
.Select(item => new
|
||||
{
|
||||
item.PrerequisiteCourseId,
|
||||
item.PrerequisiteCourse!.Code,
|
||||
item.PrerequisiteCourse.Name
|
||||
}),
|
||||
x.IsEnabled,
|
||||
x.SortOrder,
|
||||
x.CreatedAt,
|
||||
@@ -146,9 +157,6 @@ public sealed class CoursesController(
|
||||
CourseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await ValidateAsync(request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
var entity = new Course
|
||||
{
|
||||
Code = request.Code.Trim(),
|
||||
@@ -166,6 +174,16 @@ public sealed class CoursesController(
|
||||
IsEnabled = request.IsEnabled,
|
||||
SortOrder = request.SortOrder
|
||||
};
|
||||
var validation = await ValidateAsync(entity.Id, request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
entity.Prerequisites = NormalizePrerequisiteIds(request)
|
||||
.Select(prerequisiteId => new CoursePrerequisite
|
||||
{
|
||||
CourseId = entity.Id,
|
||||
PrerequisiteCourseId = prerequisiteId
|
||||
})
|
||||
.ToList();
|
||||
db.Courses.Add(entity);
|
||||
return await SaveAsync(entity.Id, true, cancellationToken);
|
||||
}
|
||||
@@ -177,10 +195,12 @@ public sealed class CoursesController(
|
||||
CourseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.Courses.FindAsync([id], cancellationToken);
|
||||
var entity = await db.Courses
|
||||
.Include(x => x.Prerequisites)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid();
|
||||
var validation = await ValidateAsync(request, cancellationToken);
|
||||
var validation = await ValidateAsync(id, request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
entity.Code = request.Code.Trim();
|
||||
@@ -197,6 +217,19 @@ public sealed class CoursesController(
|
||||
entity.Description = Normalize(request.Description);
|
||||
entity.IsEnabled = request.IsEnabled;
|
||||
entity.SortOrder = request.SortOrder;
|
||||
var prerequisiteIds = NormalizePrerequisiteIds(request).ToHashSet();
|
||||
db.CoursePrerequisites.RemoveRange(
|
||||
entity.Prerequisites.Where(x =>
|
||||
!prerequisiteIds.Contains(x.PrerequisiteCourseId)));
|
||||
foreach (var prerequisiteId in prerequisiteIds.Except(
|
||||
entity.Prerequisites.Select(x => x.PrerequisiteCourseId)))
|
||||
{
|
||||
entity.Prerequisites.Add(new CoursePrerequisite
|
||||
{
|
||||
CourseId = entity.Id,
|
||||
PrerequisiteCourseId = prerequisiteId
|
||||
});
|
||||
}
|
||||
return await SaveAsync(entity.Id, false, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -212,6 +245,7 @@ public sealed class CoursesController(
|
||||
}
|
||||
|
||||
private async Task<ActionResult?> ValidateAsync(
|
||||
Guid courseId,
|
||||
CourseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -224,9 +258,62 @@ public sealed class CoursesController(
|
||||
return ValidationProblem("所选课程分类不存在或已停用。");
|
||||
if (request.LectureHours + request.PracticeHours > request.TotalHours)
|
||||
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。");
|
||||
|
||||
var prerequisiteIds = NormalizePrerequisiteIds(request).ToArray();
|
||||
if (prerequisiteIds.Contains(courseId))
|
||||
return ValidationProblem("课程不能把自身设置为先修课程。");
|
||||
var accessiblePrerequisiteCount = await ScopedCourses()
|
||||
.CountAsync(x =>
|
||||
prerequisiteIds.Contains(x.Id) &&
|
||||
x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (accessiblePrerequisiteCount != prerequisiteIds.Length)
|
||||
return ValidationProblem("包含不存在、已停用或不在当前数据范围内的先修课程。");
|
||||
if (await CreatesPrerequisiteCycleAsync(
|
||||
courseId,
|
||||
prerequisiteIds,
|
||||
cancellationToken))
|
||||
return ValidationProblem("先修关系不能形成循环依赖。");
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> CreatesPrerequisiteCycleAsync(
|
||||
Guid courseId,
|
||||
IReadOnlyCollection<Guid> prerequisiteIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (prerequisiteIds.Count == 0) return false;
|
||||
|
||||
var edges = await db.CoursePrerequisites.AsNoTracking()
|
||||
.Where(x => x.CourseId != courseId)
|
||||
.Select(x => new { x.CourseId, x.PrerequisiteCourseId })
|
||||
.ToListAsync(cancellationToken);
|
||||
var prerequisitesByCourse = edges
|
||||
.GroupBy(x => x.CourseId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(x => x.PrerequisiteCourseId).ToArray());
|
||||
|
||||
foreach (var prerequisiteId in prerequisiteIds)
|
||||
{
|
||||
var pending = new Stack<Guid>();
|
||||
var visited = new HashSet<Guid>();
|
||||
pending.Push(prerequisiteId);
|
||||
while (pending.TryPop(out var candidate))
|
||||
{
|
||||
if (candidate == courseId) return true;
|
||||
if (!visited.Add(candidate) ||
|
||||
!prerequisitesByCourse.TryGetValue(candidate, out var next))
|
||||
continue;
|
||||
foreach (var item in next) pending.Push(item);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<Guid> NormalizePrerequisiteIds(CourseRequest request) =>
|
||||
(request.PrerequisiteCourseIds ?? []).Distinct();
|
||||
|
||||
private IQueryable<Course> ScopedCourses()
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
@@ -307,4 +394,5 @@ public sealed record CourseRequest(
|
||||
AssessmentMethod AssessmentMethod,
|
||||
[MaxLength(1000)] string? Description,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
int SortOrder,
|
||||
IReadOnlyCollection<Guid>? PrerequisiteCourseIds = null);
|
||||
|
||||
@@ -50,6 +50,16 @@ public sealed class Course : CatalogEntity
|
||||
public CourseNature Nature { get; set; }
|
||||
public AssessmentMethod AssessmentMethod { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public ICollection<CoursePrerequisite> Prerequisites { get; set; } = [];
|
||||
public ICollection<CoursePrerequisite> RequiredByCourses { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class CoursePrerequisite : EntityBase
|
||||
{
|
||||
public Guid CourseId { get; set; }
|
||||
public Course? Course { get; set; }
|
||||
public Guid PrerequisiteCourseId { get; set; }
|
||||
public Course? PrerequisiteCourse { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CourseCategory : CatalogEntity
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Graduation;
|
||||
|
||||
public sealed record AcademicPlanningCourseSnapshot(
|
||||
Guid CourseId,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
int RecommendedSemester,
|
||||
CurriculumCourseType Type,
|
||||
IReadOnlyCollection<Guid> PrerequisiteCourseIds);
|
||||
|
||||
public sealed record AcademicPlanningPrerequisiteIssue(
|
||||
Guid CourseId,
|
||||
Guid PrerequisiteCourseId,
|
||||
int PlannedSemester,
|
||||
int? PrerequisitePlannedSemester);
|
||||
|
||||
public static class AcademicPlanningRules
|
||||
{
|
||||
public const decimal RecommendedSemesterCreditLimit = 24;
|
||||
public const decimal HeavySemesterCreditLimit = 30;
|
||||
|
||||
public static IReadOnlyList<AcademicPlanningPrerequisiteIssue>
|
||||
FindPrerequisiteIssues(
|
||||
IEnumerable<AcademicPlanningCourseSnapshot> courses,
|
||||
IReadOnlySet<Guid> completedCourseIds,
|
||||
IReadOnlySet<Guid> inProgressCourseIds,
|
||||
IReadOnlyDictionary<Guid, int> plannedSemesters)
|
||||
{
|
||||
var issues = new List<AcademicPlanningPrerequisiteIssue>();
|
||||
foreach (var course in courses.Where(x =>
|
||||
plannedSemesters.ContainsKey(x.CourseId)))
|
||||
{
|
||||
var plannedSemester = plannedSemesters[course.CourseId];
|
||||
foreach (var prerequisiteId in course.PrerequisiteCourseIds)
|
||||
{
|
||||
if (completedCourseIds.Contains(prerequisiteId) ||
|
||||
inProgressCourseIds.Contains(prerequisiteId))
|
||||
continue;
|
||||
|
||||
if (!plannedSemesters.TryGetValue(
|
||||
prerequisiteId,
|
||||
out var prerequisiteSemester) ||
|
||||
prerequisiteSemester >= plannedSemester)
|
||||
{
|
||||
issues.Add(new AcademicPlanningPrerequisiteIssue(
|
||||
course.CourseId,
|
||||
prerequisiteId,
|
||||
plannedSemester,
|
||||
plannedSemesters.GetValueOrDefault(prerequisiteId)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<Guid> SuggestNextSemester(
|
||||
IEnumerable<AcademicPlanningCourseSnapshot> courses,
|
||||
IReadOnlySet<Guid> completedCourseIds,
|
||||
IReadOnlySet<Guid> inProgressCourseIds,
|
||||
int nextSemester,
|
||||
decimal targetCredits = RecommendedSemesterCreditLimit)
|
||||
{
|
||||
var available = courses
|
||||
.Where(x =>
|
||||
!completedCourseIds.Contains(x.CourseId) &&
|
||||
!inProgressCourseIds.Contains(x.CourseId) &&
|
||||
x.RecommendedSemester <= nextSemester &&
|
||||
x.PrerequisiteCourseIds.All(prerequisiteId =>
|
||||
completedCourseIds.Contains(prerequisiteId) ||
|
||||
inProgressCourseIds.Contains(prerequisiteId)))
|
||||
.OrderBy(x => x.Type == CurriculumCourseType.Required ? 0 : 1)
|
||||
.ThenBy(x => x.RecommendedSemester > nextSemester ? 1 : 0)
|
||||
.ThenBy(x => x.RecommendedSemester)
|
||||
.ThenBy(x => x.CourseName)
|
||||
.ToList();
|
||||
|
||||
var selected = new List<Guid>();
|
||||
decimal credits = 0;
|
||||
foreach (var course in available)
|
||||
{
|
||||
var isOverdueRequired =
|
||||
course.Type == CurriculumCourseType.Required &&
|
||||
course.RecommendedSemester <= nextSemester;
|
||||
if (!isOverdueRequired &&
|
||||
selected.Count > 0 &&
|
||||
credits + course.Credits > targetCredits)
|
||||
continue;
|
||||
|
||||
selected.Add(course.CourseId);
|
||||
credits += course.Credits;
|
||||
if (credits >= targetCredits) break;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
public static int EstimateCompletionSemester(
|
||||
int nextSemester,
|
||||
int latestPlannedSemester,
|
||||
decimal remainingCredits,
|
||||
int remainingRequirementCount)
|
||||
{
|
||||
if (remainingCredits <= 0 && remainingRequirementCount <= 0)
|
||||
return Math.Max(nextSemester - 1, latestPlannedSemester);
|
||||
|
||||
var byCredits = (int)Math.Ceiling(
|
||||
Math.Max(remainingCredits, 0) / RecommendedSemesterCreditLimit);
|
||||
var byRequirements = (int)Math.Ceiling(
|
||||
Math.Max(remainingRequirementCount, 0) / 6m);
|
||||
var additionalSemesters = Math.Max(1, Math.Max(byCredits, byRequirements));
|
||||
return Math.Max(nextSemester - 1, latestPlannedSemester) +
|
||||
additionalSemesters;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<Student> Students => Set<Student>();
|
||||
public DbSet<CourseCategory> CourseCategories => Set<CourseCategory>();
|
||||
public DbSet<Course> Courses => Set<Course>();
|
||||
public DbSet<CoursePrerequisite> CoursePrerequisites => Set<CoursePrerequisite>();
|
||||
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
|
||||
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
|
||||
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
|
||||
@@ -247,6 +248,20 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<CoursePrerequisite>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.CourseId, x.PrerequisiteCourseId })
|
||||
.IsUnique();
|
||||
entity.HasOne(x => x.Course)
|
||||
.WithMany(x => x.Prerequisites)
|
||||
.HasForeignKey(x => x.CourseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.PrerequisiteCourse)
|
||||
.WithMany(x => x.RequiredByCourses)
|
||||
.HasForeignKey(x => x.PrerequisiteCourseId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<CurriculumPlan>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
|
||||
@@ -62,6 +62,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260726_33_background_job_outbox";
|
||||
private const string CourseAdjustmentOccurrencesMigration =
|
||||
"20260727_34_course_adjustment_occurrences";
|
||||
private const string AcademicPlanningPrerequisitesMigration =
|
||||
"20260727_35_academic_planning_prerequisites";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -453,6 +455,19 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
BackgroundJobOutboxMigration,
|
||||
backgroundJobOutboxExists ? [] : BackgroundJobOutboxStatements,
|
||||
cancellationToken);
|
||||
|
||||
var coursePrerequisitesExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'CoursePrerequisites'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
AcademicPlanningPrerequisitesMigration,
|
||||
coursePrerequisitesExist ? [] : AcademicPlanningPrerequisiteStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -2090,4 +2105,31 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ON "BackgroundJobOutboxMessages" ("State", "CompletedAt");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] AcademicPlanningPrerequisiteStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "CoursePrerequisites" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_CoursePrerequisites" PRIMARY KEY,
|
||||
"CourseId" TEXT NOT NULL,
|
||||
"PrerequisiteCourseId" TEXT NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_CoursePrerequisites_Courses_CourseId"
|
||||
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id")
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_CoursePrerequisites_Courses_PrerequisiteCourseId"
|
||||
FOREIGN KEY ("PrerequisiteCourseId") REFERENCES "Courses" ("Id")
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_CoursePrerequisites_CourseId_PrerequisiteCourseId"
|
||||
ON "CoursePrerequisites" ("CourseId", "PrerequisiteCourseId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_CoursePrerequisites_PrerequisiteCourseId"
|
||||
ON "CoursePrerequisites" ("PrerequisiteCourseId");
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
+4921
File diff suppressed because it is too large
Load Diff
+61
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AcademicPlanningPrerequisites : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CoursePrerequisites",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
PrerequisiteCourseId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CoursePrerequisites", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CoursePrerequisites_Courses_CourseId",
|
||||
column: x => x.CourseId,
|
||||
principalTable: "Courses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_CoursePrerequisites_Courses_PrerequisiteCourseId",
|
||||
column: x => x.PrerequisiteCourseId,
|
||||
principalTable: "Courses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CoursePrerequisites_CourseId_PrerequisiteCourseId",
|
||||
table: "CoursePrerequisites",
|
||||
columns: new[] { "CourseId", "PrerequisiteCourseId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CoursePrerequisites_PrerequisiteCourseId",
|
||||
table: "CoursePrerequisites",
|
||||
column: "PrerequisiteCourseId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CoursePrerequisites");
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -908,6 +908,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("CourseExemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("CourseId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("PrerequisiteCourseId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PrerequisiteCourseId");
|
||||
|
||||
b.HasIndex("CourseId", "PrerequisiteCourseId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CoursePrerequisites");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -3818,6 +3846,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("TeachingTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
|
||||
.WithMany("Prerequisites")
|
||||
.HasForeignKey("CourseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "PrerequisiteCourse")
|
||||
.WithMany("RequiredByCourses")
|
||||
.HasForeignKey("PrerequisiteCourseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Course");
|
||||
|
||||
b.Navigation("PrerequisiteCourse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound")
|
||||
@@ -4734,6 +4781,13 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Records");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b =>
|
||||
{
|
||||
b.Navigation("Prerequisites");
|
||||
|
||||
b.Navigation("RequiredByCourses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
|
||||
{
|
||||
b.Navigation("Enrollments");
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class AcademicPlanningControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Simulation_reuses_published_grade_and_checks_prerequisites()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = "student",
|
||||
NormalizedUserName = "STUDENT",
|
||||
DisplayName = "规划学生"
|
||||
};
|
||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||
var major = new Major
|
||||
{
|
||||
Code = "SE",
|
||||
Name = "软件工程",
|
||||
CollegeId = college.Id,
|
||||
DegreeType = "工学",
|
||||
SchoolingYears = 4
|
||||
};
|
||||
var administrativeClass = new AdministrativeClass
|
||||
{
|
||||
Code = "SE2501",
|
||||
Name = "软件工程2501班",
|
||||
MajorId = major.Id,
|
||||
Grade = 2025
|
||||
};
|
||||
var student = new Student
|
||||
{
|
||||
StudentNumber = "20250001",
|
||||
Name = "规划学生",
|
||||
AdministrativeClassId = administrativeClass.Id,
|
||||
EnrollmentYear = 2025,
|
||||
EnrollmentDate = new DateOnly(2025, 9, 1),
|
||||
UserId = user.Id
|
||||
};
|
||||
var pastTerm = new AcademicTerm
|
||||
{
|
||||
Code = "2025-A",
|
||||
Name = "2025—2026 学年秋季学期",
|
||||
AcademicYear = "2025-2026",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2025, 9, 1),
|
||||
EndDate = new DateOnly(2026, 1, 15)
|
||||
};
|
||||
var currentTerm = new AcademicTerm
|
||||
{
|
||||
Code = "2025-S",
|
||||
Name = "2025—2026 学年春季学期",
|
||||
AcademicYear = "2025-2026",
|
||||
Season = TermSeason.Spring,
|
||||
StartDate = new DateOnly(2026, 2, 20),
|
||||
EndDate = new DateOnly(2026, 7, 10),
|
||||
IsCurrent = true
|
||||
};
|
||||
var introduction = new Course
|
||||
{
|
||||
Code = "CS101",
|
||||
Name = "程序设计基础",
|
||||
CollegeId = college.Id,
|
||||
Credits = 4,
|
||||
TotalHours = 64,
|
||||
LectureHours = 48,
|
||||
PracticeHours = 16,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
AssessmentMethod = AssessmentMethod.Examination
|
||||
};
|
||||
var dataStructures = new Course
|
||||
{
|
||||
Code = "CS201",
|
||||
Name = "数据结构",
|
||||
CollegeId = college.Id,
|
||||
Credits = 4,
|
||||
TotalHours = 64,
|
||||
LectureHours = 48,
|
||||
PracticeHours = 16,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
AssessmentMethod = AssessmentMethod.Examination,
|
||||
Prerequisites =
|
||||
[
|
||||
new CoursePrerequisite
|
||||
{
|
||||
PrerequisiteCourseId = introduction.Id
|
||||
}
|
||||
]
|
||||
};
|
||||
var plan = new CurriculumPlan
|
||||
{
|
||||
MajorId = major.Id,
|
||||
Name = "软件工程本科培养方案",
|
||||
Version = "2025",
|
||||
EffectiveGrade = 2025,
|
||||
TotalCredits = 8,
|
||||
Status = CurriculumPlanStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow,
|
||||
Modules =
|
||||
[
|
||||
new CurriculumModule
|
||||
{
|
||||
Code = "M01",
|
||||
Name = "专业基础",
|
||||
RequiredCredits = 0,
|
||||
Courses =
|
||||
[
|
||||
new CurriculumCourse
|
||||
{
|
||||
CourseId = introduction.Id,
|
||||
RecommendedSemester = 1,
|
||||
Type = CurriculumCourseType.Required
|
||||
},
|
||||
new CurriculumCourse
|
||||
{
|
||||
CourseId = dataStructures.Id,
|
||||
RecommendedSemester = 3,
|
||||
Type = CurriculumCourseType.Required
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
var teachingTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "2025-CS101-01",
|
||||
Name = "程序设计基础",
|
||||
AcademicTermId = pastTerm.Id,
|
||||
CourseId = introduction.Id,
|
||||
Capacity = 50,
|
||||
Status = TeachingTaskStatus.Closed
|
||||
};
|
||||
var gradeSheet = new GradeSheet
|
||||
{
|
||||
TeachingTaskId = teachingTask.Id,
|
||||
Status = GradeSheetStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow,
|
||||
Records =
|
||||
[
|
||||
new GradeRecord
|
||||
{
|
||||
StudentId = student.Id,
|
||||
TotalScore = 82,
|
||||
GradePoint = 3.2m
|
||||
}
|
||||
]
|
||||
};
|
||||
db.AddRange(
|
||||
user,
|
||||
college,
|
||||
major,
|
||||
administrativeClass,
|
||||
student,
|
||||
pastTerm,
|
||||
currentTerm,
|
||||
introduction,
|
||||
dataStructures,
|
||||
plan,
|
||||
teachingTask,
|
||||
gradeSheet);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = new AcademicPlanningController(
|
||||
db,
|
||||
new StudentDataScope(user.Id));
|
||||
var overview = Assert.IsType<OkObjectResult>(
|
||||
await controller.Get(default));
|
||||
using var overviewJson = ToJson(overview.Value);
|
||||
Assert.Equal(
|
||||
4,
|
||||
overviewJson.RootElement
|
||||
.GetProperty("baseline")
|
||||
.GetProperty("earnedCredits")
|
||||
.GetDecimal());
|
||||
Assert.Equal(
|
||||
dataStructures.Id,
|
||||
overviewJson.RootElement
|
||||
.GetProperty("nextSemesterSuggestion")[0]
|
||||
.GetProperty("courseId")
|
||||
.GetGuid());
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(
|
||||
await controller.Simulate(
|
||||
new AcademicPlanningRequest(
|
||||
[
|
||||
new AcademicPlanningTermRequest(
|
||||
3,
|
||||
[dataStructures.Id])
|
||||
]),
|
||||
default));
|
||||
using var resultJson = ToJson(result.Value);
|
||||
Assert.Empty(resultJson.RootElement.GetProperty("conflicts").EnumerateArray());
|
||||
var projected = resultJson.RootElement.GetProperty("projected");
|
||||
Assert.Equal(8, projected.GetProperty("earnedCredits").GetDecimal());
|
||||
Assert.Equal(
|
||||
(int)GraduationAuditConclusion.Eligible,
|
||||
projected.GetProperty("graduationConclusion").GetInt32());
|
||||
}
|
||||
|
||||
private static JsonDocument ToJson(object? value) => JsonDocument.Parse(
|
||||
JsonSerializer.Serialize(
|
||||
value,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web)));
|
||||
|
||||
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
userId,
|
||||
"规划学生",
|
||||
null,
|
||||
DataScope.Self,
|
||||
new HashSet<string>([SystemRoles.Student]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Graduation;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class AcademicPlanningRulesTests
|
||||
{
|
||||
private static readonly Guid AdvancedCourseId = Guid.NewGuid();
|
||||
private static readonly Guid PrerequisiteCourseId = Guid.NewGuid();
|
||||
|
||||
private static readonly AcademicPlanningCourseSnapshot[] Courses =
|
||||
[
|
||||
new(
|
||||
PrerequisiteCourseId,
|
||||
"程序设计基础",
|
||||
4,
|
||||
1,
|
||||
CurriculumCourseType.Required,
|
||||
[]),
|
||||
new(
|
||||
AdvancedCourseId,
|
||||
"数据结构",
|
||||
4,
|
||||
2,
|
||||
CurriculumCourseType.Required,
|
||||
[PrerequisiteCourseId])
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void Prerequisite_must_be_completed_or_planned_in_an_earlier_term()
|
||||
{
|
||||
var sameTerm = AcademicPlanningRules.FindPrerequisiteIssues(
|
||||
Courses,
|
||||
new HashSet<Guid>(),
|
||||
new HashSet<Guid>(),
|
||||
new Dictionary<Guid, int>
|
||||
{
|
||||
[PrerequisiteCourseId] = 3,
|
||||
[AdvancedCourseId] = 3
|
||||
});
|
||||
|
||||
var correctOrder = AcademicPlanningRules.FindPrerequisiteIssues(
|
||||
Courses,
|
||||
new HashSet<Guid>(),
|
||||
new HashSet<Guid>(),
|
||||
new Dictionary<Guid, int>
|
||||
{
|
||||
[PrerequisiteCourseId] = 3,
|
||||
[AdvancedCourseId] = 4
|
||||
});
|
||||
|
||||
Assert.Single(sameTerm);
|
||||
Assert.Empty(correctOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void In_progress_prerequisite_unlocks_next_term_suggestion()
|
||||
{
|
||||
var suggestion = AcademicPlanningRules.SuggestNextSemester(
|
||||
Courses,
|
||||
new HashSet<Guid>(),
|
||||
new HashSet<Guid>([PrerequisiteCourseId]),
|
||||
2);
|
||||
|
||||
Assert.Equal([AdvancedCourseId], suggestion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0, 5, 4, 4)]
|
||||
[InlineData(25, 0, 5, 4, 6)]
|
||||
[InlineData(0, 7, 5, 4, 6)]
|
||||
public void Completion_estimate_uses_credit_and_requirement_capacity(
|
||||
decimal remainingCredits,
|
||||
int remainingRequirements,
|
||||
int nextSemester,
|
||||
int latestPlannedSemester,
|
||||
int expectedSemester)
|
||||
{
|
||||
Assert.Equal(
|
||||
expectedSemester,
|
||||
AcademicPlanningRules.EstimateCompletionSemester(
|
||||
nextSemester,
|
||||
latestPlannedSemester,
|
||||
remainingCredits,
|
||||
remainingRequirements));
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
key: 'graduation',
|
||||
label: '毕业管理',
|
||||
items: [
|
||||
...whenVisible(
|
||||
isStudent.value,
|
||||
{ path: '/academic-planning', label: '学业规划与毕业模拟' },
|
||||
),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
|
||||
{ path: '/graduation-audits', label: isStudent.value ? '毕业资格' : '毕业审核' },
|
||||
|
||||
@@ -118,6 +118,12 @@ const router = createRouter({
|
||||
component: () => import('../views/StudentCurriculumView.vue'),
|
||||
meta: { roles: ['Student'] },
|
||||
},
|
||||
{
|
||||
path: 'academic-planning',
|
||||
name: 'academic-planning',
|
||||
component: () => import('../views/AcademicPlanningView.vue'),
|
||||
meta: { roles: ['Student'] },
|
||||
},
|
||||
{
|
||||
path: 'teaching-tasks',
|
||||
name: 'teaching-tasks',
|
||||
|
||||
@@ -0,0 +1,815 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Delete, MagicStick, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
|
||||
interface PlanningTerm {
|
||||
semester: number
|
||||
label: string
|
||||
isBeyondStandard: boolean
|
||||
courseIds: string[]
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const simulationLoading = ref(false)
|
||||
const initialized = ref(false)
|
||||
const payload = ref<any>(null)
|
||||
const simulation = ref<any>(null)
|
||||
const terms = ref<PlanningTerm[]>([])
|
||||
const keyword = ref('')
|
||||
const moduleFilter = ref('')
|
||||
let simulationTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const courses = computed<any[]>(() => payload.value?.courses ?? [])
|
||||
const baseline = computed(() => payload.value?.baseline ?? {})
|
||||
const projected = computed(() => simulation.value?.projected ?? {
|
||||
earnedCredits: baseline.value.earnedCredits ?? 0,
|
||||
planCompletedCredits: baseline.value.planCompletedCredits ?? 0,
|
||||
creditGap: baseline.value.creditGap ?? 0,
|
||||
completionRate: baseline.value.completionRate ?? 0,
|
||||
requirementCount: baseline.value.requirementCount ?? 0,
|
||||
passedRequirementCount: baseline.value.passedRequirementCount ?? 0,
|
||||
missingRequirements: baseline.value.missingRequirements ?? [],
|
||||
graduationConclusion: 'Ineligible',
|
||||
estimatedGraduationTerm: '等待模拟',
|
||||
isBeyondStandard: false,
|
||||
})
|
||||
const modules = computed(() =>
|
||||
[...new Map(courses.value.map((course) =>
|
||||
[course.moduleCode, { code: course.moduleCode, name: course.moduleName }],
|
||||
)).values()],
|
||||
)
|
||||
const assigned = computed(() => {
|
||||
const result = new Map<string, number>()
|
||||
for (const term of terms.value) {
|
||||
for (const courseId of term.courseIds) result.set(courseId, term.semester)
|
||||
}
|
||||
return result
|
||||
})
|
||||
const remainingCourses = computed(() => {
|
||||
const normalized = keyword.value.trim().toLowerCase()
|
||||
return courses.value.filter((course) => {
|
||||
if (['Completed', 'InProgress', 'Retaking'].includes(course.status)) return false
|
||||
if (moduleFilter.value && course.moduleCode !== moduleFilter.value) return false
|
||||
return !normalized ||
|
||||
`${course.courseCode} ${course.courseName} ${course.moduleName}`
|
||||
.toLowerCase()
|
||||
.includes(normalized)
|
||||
})
|
||||
})
|
||||
const plannedCourseCount = computed(() =>
|
||||
terms.value.reduce((count, term) => count + term.courseIds.length, 0),
|
||||
)
|
||||
const plannedCredits = computed(() =>
|
||||
courses.value
|
||||
.filter((course) => assigned.value.has(course.courseId))
|
||||
.reduce((sum, course) => sum + Number(course.credits), 0),
|
||||
)
|
||||
const conflictCourseIds = computed(() =>
|
||||
new Set<string>((simulation.value?.conflicts ?? []).map((item: any) => item.courseId)),
|
||||
)
|
||||
const termSummaryMap = computed(() =>
|
||||
new Map<number, any>((simulation.value?.termSummaries ?? [])
|
||||
.map((item: any) => [item.semester, item])),
|
||||
)
|
||||
|
||||
function courseById(courseId: string) {
|
||||
return courses.value.find((course) => course.courseId === courseId)
|
||||
}
|
||||
|
||||
function typeLabel(type: string) {
|
||||
return type === 'Required' ? '指定必修' : '组内选修'
|
||||
}
|
||||
|
||||
function setCourseSemester(courseId: string, semester?: number) {
|
||||
for (const term of terms.value) {
|
||||
term.courseIds = term.courseIds.filter((id) => id !== courseId)
|
||||
}
|
||||
if (semester) {
|
||||
const target = terms.value.find((term) => term.semester === semester)
|
||||
if (target) target.courseIds.push(courseId)
|
||||
}
|
||||
}
|
||||
|
||||
function removeCourse(courseId: string) {
|
||||
setCourseSemester(courseId)
|
||||
}
|
||||
|
||||
function applySuggestion() {
|
||||
const target = terms.value.find(
|
||||
(term) => term.semester === payload.value?.plan?.nextSemester,
|
||||
)
|
||||
if (!target) return
|
||||
const ids = (payload.value?.nextSemesterSuggestion ?? [])
|
||||
.map((course: any) => course.courseId)
|
||||
.filter((courseId: string) => !assigned.value.has(courseId))
|
||||
target.courseIds.push(...ids)
|
||||
ElMessage.success(`已把 ${ids.length} 门建议课程安排到下一学期`)
|
||||
}
|
||||
|
||||
function clearPlan() {
|
||||
for (const term of terms.value) term.courseIds = []
|
||||
}
|
||||
|
||||
async function simulate() {
|
||||
simulationLoading.value = true
|
||||
try {
|
||||
simulation.value = (await http.post('/student/academic-planning/simulate', {
|
||||
terms: terms.value.map((term) => ({
|
||||
semester: term.semester,
|
||||
courseIds: term.courseIds,
|
||||
})),
|
||||
})).data
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
simulationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSimulation() {
|
||||
if (!initialized.value) return
|
||||
if (simulationTimer) clearTimeout(simulationTimer)
|
||||
simulationTimer = setTimeout(simulate, 180)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
initialized.value = false
|
||||
try {
|
||||
payload.value = (await http.get('/student/academic-planning')).data
|
||||
terms.value = payload.value.terms.map((term: any) => ({
|
||||
...term,
|
||||
courseIds: [],
|
||||
}))
|
||||
initialized.value = true
|
||||
await simulate()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(terms, scheduleSimulation, { deep: true })
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => {
|
||||
if (simulationTimer) clearTimeout(simulationTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack academic-planning-page" v-loading="loading">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="section-kicker">DEGREE FLIGHT PLAN</span>
|
||||
<h2>学业规划与毕业模拟</h2>
|
||||
<p>把未来课程排进学期航线,实时检查培养方案完成度、先修顺序与毕业时间。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-button :icon="Refresh" @click="load">重置数据</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="MagicStick"
|
||||
:disabled="!payload?.nextSemesterSuggestion?.length"
|
||||
@click="applySuggestion"
|
||||
>
|
||||
采用下学期建议
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-if="payload">
|
||||
<section class="planning-cockpit">
|
||||
<div class="student-call-sign">
|
||||
<span>{{ payload.student.enrollmentYear }} 级 · {{ payload.student.collegeName }}</span>
|
||||
<h3>{{ payload.student.name }}的毕业航线</h3>
|
||||
<p>{{ payload.student.studentNumber }} · {{ payload.student.majorName }} · {{ payload.plan.name }} {{ payload.plan.version }}</p>
|
||||
</div>
|
||||
<div class="completion-dial" aria-label="模拟培养方案完成度">
|
||||
<span>方案完成度</span>
|
||||
<b>{{ projected.completionRate }}<small>%</small></b>
|
||||
<i><em :style="{ width: `${projected.completionRate}%` }" /></i>
|
||||
</div>
|
||||
<div class="arrival-board">
|
||||
<span>预计毕业</span>
|
||||
<b>{{ projected.estimatedGraduationTerm }}</b>
|
||||
<small :class="{ delayed: projected.isBeyondStandard }">
|
||||
{{ projected.isBeyondStandard ? '超过标准学制' : '标准学制内' }}
|
||||
</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="planning-metrics" aria-label="毕业模拟摘要">
|
||||
<div>
|
||||
<span>已获 / 预计学分</span>
|
||||
<b>{{ baseline.earnedCredits }} <small>→</small> {{ projected.earnedCredits }}</b>
|
||||
</div>
|
||||
<div :class="{ alert: projected.creditGap > 0 }">
|
||||
<span>毕业学分缺口</span>
|
||||
<b>{{ projected.creditGap }}<small> 学分</small></b>
|
||||
</div>
|
||||
<div>
|
||||
<span>培养要求</span>
|
||||
<b>{{ projected.passedRequirementCount }}<small> / {{ projected.requirementCount }} 项</small></b>
|
||||
</div>
|
||||
<div>
|
||||
<span>本次模拟</span>
|
||||
<b>{{ plannedCourseCount }}<small> 门 · {{ plannedCredits }} 学分</small></b>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="next-term-brief">
|
||||
<header>
|
||||
<span>NEXT TERM ADVICE</span>
|
||||
<h3>下一学期建议</h3>
|
||||
<p>{{ payload.terms[0]?.label }}</p>
|
||||
</header>
|
||||
<div v-if="payload.nextSemesterSuggestion.length" class="suggestion-list">
|
||||
<article
|
||||
v-for="course in payload.nextSemesterSuggestion"
|
||||
:key="course.courseId"
|
||||
>
|
||||
<div>
|
||||
<span>{{ course.courseCode }} · {{ typeLabel(course.type) }}</span>
|
||||
<b>{{ course.courseName }}</b>
|
||||
</div>
|
||||
<small>{{ course.credits }} 学分</small>
|
||||
<p>{{ course.reason }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="no-suggestion">
|
||||
结合当前在读课程、先修条件和培养方案建议学期,下一学期暂无需要额外安排的课程;仍可在下方课程清单模拟其他路径。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="payload.latestGraduationAudit"
|
||||
class="official-audit-strip"
|
||||
>
|
||||
<div>
|
||||
<span>最近正式毕业审核</span>
|
||||
<b>{{ payload.latestGraduationAudit.batchName }}</b>
|
||||
<small>{{ payload.latestGraduationAudit.publishedAt?.slice(0, 10) }}</small>
|
||||
</div>
|
||||
<p>
|
||||
正式结果:{{ payload.latestGraduationAudit.conclusion === 'Eligible' ? '符合毕业条件' : '暂不符合毕业条件' }}
|
||||
· 已认定 {{ payload.latestGraduationAudit.earnedCredits }} 学分
|
||||
</p>
|
||||
<em>模拟不会更改正式审核结果</em>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="simulation?.conflicts?.length || simulation?.warnings?.length"
|
||||
class="planning-alerts"
|
||||
>
|
||||
<article v-for="item in simulation.conflicts" :key="`${item.type}-${item.courseId}-${item.prerequisiteCourseId}`">
|
||||
<span>先修冲突</span>
|
||||
<p>{{ item.message }}</p>
|
||||
</article>
|
||||
<article v-for="(item, index) in simulation.warnings" :key="`${item.type}-${index}`" class="warning">
|
||||
<span>安排提示</span>
|
||||
<p>{{ item.message }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="planner-workspace">
|
||||
<aside class="course-pool">
|
||||
<header>
|
||||
<div>
|
||||
<span>COURSE MANIFEST</span>
|
||||
<h3>待规划课程</h3>
|
||||
</div>
|
||||
<b>{{ remainingCourses.length }}</b>
|
||||
</header>
|
||||
<div class="pool-filters">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
placeholder="搜索课程"
|
||||
/>
|
||||
<el-select v-model="moduleFilter" clearable placeholder="全部培养模块">
|
||||
<el-option
|
||||
v-for="module in modules"
|
||||
:key="module.code"
|
||||
:label="module.name"
|
||||
:value="module.code"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="pool-list">
|
||||
<article
|
||||
v-for="course in remainingCourses"
|
||||
:key="course.courseId"
|
||||
:class="{
|
||||
assigned: assigned.has(course.courseId),
|
||||
conflicted: conflictCourseIds.has(course.courseId),
|
||||
}"
|
||||
>
|
||||
<div class="course-manifest-line">
|
||||
<span>{{ course.courseCode }} · {{ course.moduleName }}</span>
|
||||
<i>{{ course.credits }} 学分</i>
|
||||
</div>
|
||||
<h4>{{ course.courseName }}</h4>
|
||||
<div class="course-flags">
|
||||
<span>{{ typeLabel(course.type) }}</span>
|
||||
<span>建议第 {{ course.recommendedSemester }} 学期</span>
|
||||
<span v-if="course.status === 'Failed'" class="failed">有未通过记录</span>
|
||||
</div>
|
||||
<p v-if="course.prerequisites.length">
|
||||
先修:
|
||||
<template v-for="(item, index) in course.prerequisites" :key="item.courseId">
|
||||
<b :class="{ ready: item.isCompleted || item.isInProgress }">{{ item.courseName }}</b>{{ Number(index) < course.prerequisites.length - 1 ? '、' : '' }}
|
||||
</template>
|
||||
</p>
|
||||
<el-select
|
||||
:model-value="assigned.get(course.courseId)"
|
||||
clearable
|
||||
placeholder="安排到学期"
|
||||
@change="setCourseSemester(course.courseId, $event)"
|
||||
>
|
||||
<el-option
|
||||
v-for="term in terms"
|
||||
:key="term.semester"
|
||||
:label="`第 ${term.semester} 学期 · ${term.label}`"
|
||||
:value="term.semester"
|
||||
/>
|
||||
</el-select>
|
||||
</article>
|
||||
<el-empty v-if="!remainingCourses.length" description="没有符合条件的待规划课程" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="semester-runway" v-loading="simulationLoading">
|
||||
<header>
|
||||
<div>
|
||||
<span>SEMESTER RUNWAY</span>
|
||||
<h3>未来学期航线</h3>
|
||||
<p>课程必须在其先修课程之后;单学期超过 30 学分会标记负荷风险。</p>
|
||||
</div>
|
||||
<el-button text :icon="Delete" @click="clearPlan">清空安排</el-button>
|
||||
</header>
|
||||
|
||||
<div class="runway-line">
|
||||
<article
|
||||
v-for="term in terms"
|
||||
:key="term.semester"
|
||||
class="semester-stop"
|
||||
:class="{ beyond: term.isBeyondStandard }"
|
||||
>
|
||||
<div class="semester-marker">
|
||||
<i>{{ term.semester }}</i>
|
||||
<span>{{ term.isBeyondStandard ? '延长学期' : `第 ${term.semester} 学期` }}</span>
|
||||
</div>
|
||||
<div class="semester-sheet">
|
||||
<header>
|
||||
<div>
|
||||
<span>{{ term.label }}</span>
|
||||
<h4>{{ term.semester === payload.plan.nextSemester ? '下一学期' : `未来第 ${term.semester - payload.plan.currentSemester} 学期` }}</h4>
|
||||
</div>
|
||||
<b>
|
||||
{{ termSummaryMap.get(term.semester)?.credits ?? 0 }}
|
||||
<small>学分</small>
|
||||
</b>
|
||||
</header>
|
||||
<div v-if="term.courseIds.length" class="scheduled-courses">
|
||||
<article
|
||||
v-for="courseId in term.courseIds"
|
||||
:key="courseId"
|
||||
:class="{ conflicted: conflictCourseIds.has(courseId) }"
|
||||
>
|
||||
<div>
|
||||
<span>{{ courseById(courseId)?.courseCode }} · {{ typeLabel(courseById(courseId)?.type) }}</span>
|
||||
<b>{{ courseById(courseId)?.courseName }}</b>
|
||||
</div>
|
||||
<small>{{ courseById(courseId)?.credits }} 学分</small>
|
||||
<el-button
|
||||
text
|
||||
type="danger"
|
||||
aria-label="移除课程"
|
||||
@click="removeCourse(courseId)"
|
||||
>×</el-button>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="empty-semester">尚未安排课程,可从左侧课程清单选择学期。</p>
|
||||
</div>
|
||||
</article>
|
||||
<div class="graduation-gate" :class="{ ready: projected.graduationConclusion === 'Eligible' }">
|
||||
<span>GRADUATION GATE</span>
|
||||
<b>{{ projected.graduationConclusion === 'Eligible' ? '模拟达到毕业条件' : '仍有培养要求未完成' }}</b>
|
||||
<p>{{ projected.estimatedGraduationTerm }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<section class="planning-closeout">
|
||||
<div>
|
||||
<span>剩余培养要求</span>
|
||||
<h3>{{ projected.missingRequirements?.length ? `还需完成 ${projected.missingRequirements.length} 项` : '培养要求已覆盖' }}</h3>
|
||||
</div>
|
||||
<div class="missing-requirements">
|
||||
<span v-for="item in projected.missingRequirements" :key="item">{{ item }}</span>
|
||||
<em v-if="!projected.missingRequirements?.length">本次模拟已覆盖全部指定课程和课程组要求</em>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="item in payload.assumptions" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.planning-cockpit {
|
||||
min-height: 154px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 1.2fr) minmax(230px, .7fr) minmax(280px, .9fr);
|
||||
color: white;
|
||||
border: 1px solid #15275b;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px),
|
||||
linear-gradient(rgba(255,255,255,.04) 1px, transparent 1px),
|
||||
linear-gradient(112deg, #142557, #253e78 63%, #0c6f70);
|
||||
background-size: 26px 26px, 26px 26px, auto;
|
||||
}
|
||||
.student-call-sign,
|
||||
.completion-dial,
|
||||
.arrival-board { padding: 26px 28px; }
|
||||
.student-call-sign > span,
|
||||
.completion-dial > span,
|
||||
.arrival-board > span,
|
||||
.course-pool header span,
|
||||
.semester-runway > header span {
|
||||
color: #69d8c7;
|
||||
font: 700 9px/1.2 Consolas, monospace;
|
||||
letter-spacing: .12em;
|
||||
}
|
||||
.student-call-sign h3 {
|
||||
margin: 10px 0 7px;
|
||||
font-family: "STZhongsong", "Songti SC", serif;
|
||||
font-size: 25px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.student-call-sign p { margin: 0; color: #c8d1e6; font-size: 10px; }
|
||||
.completion-dial,
|
||||
.arrival-board {
|
||||
border-left: 1px solid rgba(255,255,255,.16);
|
||||
}
|
||||
.completion-dial b {
|
||||
display: block;
|
||||
margin: 12px 0 11px;
|
||||
font: 700 34px/1 Consolas, monospace;
|
||||
}
|
||||
.completion-dial b small { font-size: 13px; }
|
||||
.completion-dial i {
|
||||
display: block;
|
||||
height: 6px;
|
||||
background: rgba(255,255,255,.17);
|
||||
}
|
||||
.completion-dial em {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #5fe0c8;
|
||||
transition: width .2s ease;
|
||||
}
|
||||
.arrival-board b {
|
||||
display: block;
|
||||
margin: 14px 0 10px;
|
||||
font-family: "STZhongsong", "Songti SC", serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.arrival-board small {
|
||||
padding-left: 8px;
|
||||
color: #76e1cf;
|
||||
border-left: 3px solid #5fe0c8;
|
||||
font-size: 10px;
|
||||
}
|
||||
.arrival-board small.delayed { color: #ffd48a; border-color: #e8a840; }
|
||||
.planning-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
}
|
||||
.planning-metrics > div {
|
||||
min-height: 80px;
|
||||
padding: 15px 19px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
border-right: 1px solid var(--line);
|
||||
box-shadow: inset 0 3px #23827a;
|
||||
}
|
||||
.planning-metrics > div:last-child { border-right: 0; }
|
||||
.planning-metrics > div.alert { box-shadow: inset 0 3px #bd5a4f; }
|
||||
.planning-metrics span { color: var(--muted); font-size: 9px; }
|
||||
.planning-metrics b {
|
||||
margin-top: 8px;
|
||||
color: var(--ink);
|
||||
font: 700 21px/1 Consolas, monospace;
|
||||
}
|
||||
.planning-metrics small { color: var(--muted); font-size: 9px; }
|
||||
.next-term-brief {
|
||||
min-height: 74px;
|
||||
padding: 13px 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
border: 1px solid #cfd8e5;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
.next-term-brief > header span {
|
||||
color: var(--teal);
|
||||
font: 700 9px/1.2 Consolas, monospace;
|
||||
letter-spacing: .1em;
|
||||
}
|
||||
.next-term-brief > header h3 { margin: 5px 0 3px; font-size: 14px; }
|
||||
.next-term-brief > header p,
|
||||
.no-suggestion { margin: 0; color: var(--muted); font-size: 9px; line-height: 1.6; }
|
||||
.suggestion-list { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.suggestion-list article {
|
||||
min-width: 220px;
|
||||
padding: 9px 11px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 4px 12px;
|
||||
border-left: 3px solid #168377;
|
||||
background: white;
|
||||
}
|
||||
.suggestion-list span { color: var(--teal); font-size: 8px; }
|
||||
.suggestion-list b { display: block; margin-top: 3px; font-size: 10px; }
|
||||
.suggestion-list small { color: var(--indigo); font: 700 10px/1.2 Consolas, monospace; }
|
||||
.suggestion-list p {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 8px;
|
||||
}
|
||||
.official-audit-strip {
|
||||
min-height: 58px;
|
||||
padding: 10px 16px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, .8fr) 1fr auto;
|
||||
gap: 22px;
|
||||
align-items: center;
|
||||
border: 1px solid #cdd6e5;
|
||||
background: #f5f8fc;
|
||||
}
|
||||
.official-audit-strip div { display: grid; gap: 3px; }
|
||||
.official-audit-strip span { color: var(--teal); font-size: 9px; font-weight: 700; }
|
||||
.official-audit-strip b { font-size: 11px; }
|
||||
.official-audit-strip small,
|
||||
.official-audit-strip p { margin: 0; color: var(--muted); font-size: 9px; }
|
||||
.official-audit-strip em {
|
||||
padding: 5px 8px;
|
||||
color: #52617a;
|
||||
border: 1px solid #cdd5e1;
|
||||
font-size: 9px;
|
||||
font-style: normal;
|
||||
}
|
||||
.planning-alerts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.planning-alerts article {
|
||||
min-height: 55px;
|
||||
padding: 10px 13px;
|
||||
display: grid;
|
||||
grid-template-columns: 74px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border: 1px solid #e2b9b5;
|
||||
background: #fff7f6;
|
||||
}
|
||||
.planning-alerts article.warning { border-color: #e4cca0; background: #fffaf0; }
|
||||
.planning-alerts span { color: #a4423b; font-size: 9px; font-weight: 700; }
|
||||
.planning-alerts .warning span { color: #9b6417; }
|
||||
.planning-alerts p { margin: 0; color: #5d4b4b; font-size: 10px; line-height: 1.5; }
|
||||
.planner-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(310px, 360px) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
.course-pool,
|
||||
.semester-runway {
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
}
|
||||
.course-pool {
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
}
|
||||
.course-pool > header,
|
||||
.semester-runway > header {
|
||||
min-height: 72px;
|
||||
padding: 15px 17px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #f7f9fc;
|
||||
}
|
||||
.course-pool h3,
|
||||
.semester-runway h3 { margin: 5px 0 0; font-size: 15px; }
|
||||
.course-pool > header b {
|
||||
color: var(--indigo);
|
||||
font: 700 25px/1 Consolas, monospace;
|
||||
}
|
||||
.pool-filters {
|
||||
padding: 11px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.pool-list {
|
||||
max-height: 680px;
|
||||
overflow: auto;
|
||||
}
|
||||
.pool-list > article {
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid #e8ebf0;
|
||||
box-shadow: inset 3px 0 #8d97aa;
|
||||
transition: background .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.pool-list > article.assigned { background: #f1f8f7; box-shadow: inset 3px 0 #0d8175; }
|
||||
.pool-list > article.conflicted { background: #fff6f5; box-shadow: inset 3px 0 #b64b43; }
|
||||
.course-manifest-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.course-manifest-line span { color: var(--teal); font: 700 9px/1.2 Consolas, monospace; }
|
||||
.course-manifest-line i { color: var(--muted); font-size: 9px; font-style: normal; }
|
||||
.pool-list h4 { margin: 7px 0; font-size: 13px; }
|
||||
.course-flags { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.course-flags span {
|
||||
padding: 3px 5px;
|
||||
color: #58657a;
|
||||
background: #edf0f4;
|
||||
font-size: 8px;
|
||||
}
|
||||
.course-flags span.failed { color: #a43f3b; background: #faeae8; }
|
||||
.pool-list article > p { margin: 8px 0; color: var(--muted); font-size: 9px; line-height: 1.5; }
|
||||
.pool-list article > p b { color: #a4423b; font-weight: 600; }
|
||||
.pool-list article > p b.ready { color: #0b796f; }
|
||||
.pool-list .el-select { width: 100%; margin-top: 9px; }
|
||||
.semester-runway > header p { margin: 5px 0 0; color: var(--muted); font-size: 9px; }
|
||||
.runway-line {
|
||||
position: relative;
|
||||
padding: 18px 18px 22px 82px;
|
||||
}
|
||||
.runway-line::before {
|
||||
position: absolute;
|
||||
top: 31px;
|
||||
bottom: 64px;
|
||||
left: 43px;
|
||||
width: 2px;
|
||||
content: "";
|
||||
background: linear-gradient(#233b77, #0d8275 76%, #d0d6df);
|
||||
}
|
||||
.semester-stop {
|
||||
position: relative;
|
||||
min-height: 116px;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
.semester-marker {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: -67px;
|
||||
width: 52px;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.semester-marker i {
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
border: 4px solid white;
|
||||
outline: 1px solid #263c78;
|
||||
background: #263c78;
|
||||
font: 700 11px/1 Consolas, monospace;
|
||||
font-style: normal;
|
||||
}
|
||||
.semester-marker span { color: var(--muted); font-size: 8px; text-align: center; }
|
||||
.semester-stop.beyond .semester-marker i { outline-color: #a7752a; background: #a7752a; }
|
||||
.semester-sheet { border: 1px solid #dce1e9; }
|
||||
.semester-sheet > header {
|
||||
min-height: 61px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f8f9fb;
|
||||
border-bottom: 1px solid #e1e5eb;
|
||||
}
|
||||
.semester-sheet header span { color: var(--teal); font-size: 9px; }
|
||||
.semester-sheet header h4 { margin: 4px 0 0; font-size: 13px; }
|
||||
.semester-sheet header > b { color: var(--indigo); font: 700 18px/1 Consolas, monospace; }
|
||||
.semester-sheet header > b small { color: var(--muted); font-size: 8px; }
|
||||
.scheduled-courses {
|
||||
padding: 9px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
.scheduled-courses article {
|
||||
min-height: 58px;
|
||||
padding: 9px 8px 9px 11px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 25px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-left: 3px solid #0c8275;
|
||||
background: #f1f8f7;
|
||||
}
|
||||
.scheduled-courses article.conflicted { border-color: #b64b43; background: #fff1ef; }
|
||||
.scheduled-courses span { color: var(--muted); font-size: 8px; }
|
||||
.scheduled-courses b { display: block; margin-top: 4px; font-size: 10px; }
|
||||
.scheduled-courses small { color: var(--muted); font-size: 8px; }
|
||||
.empty-semester { margin: 0; padding: 18px 14px; color: #9199a8; font-size: 9px; }
|
||||
.graduation-gate {
|
||||
min-height: 76px;
|
||||
padding: 14px 18px;
|
||||
display: grid;
|
||||
grid-template-columns: 145px 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
color: #5d6676;
|
||||
border: 1px dashed #aeb6c3;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.graduation-gate.ready {
|
||||
color: #075f59;
|
||||
border-color: #2b9187;
|
||||
background: #edf8f6;
|
||||
}
|
||||
.graduation-gate span { font: 700 9px/1.2 Consolas, monospace; letter-spacing: .1em; }
|
||||
.graduation-gate b { font-size: 13px; }
|
||||
.graduation-gate p { margin: 0; font-size: 9px; }
|
||||
.planning-closeout {
|
||||
padding: 19px;
|
||||
display: grid;
|
||||
grid-template-columns: 190px 1fr minmax(260px, .7fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
}
|
||||
.planning-closeout > div:first-child span { color: var(--teal); font-size: 9px; font-weight: 700; }
|
||||
.planning-closeout h3 { margin: 7px 0 0; font-size: 15px; }
|
||||
.missing-requirements { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.missing-requirements span {
|
||||
padding: 5px 7px;
|
||||
color: #705829;
|
||||
border: 1px solid #ead6aa;
|
||||
background: #fff9ed;
|
||||
font-size: 9px;
|
||||
}
|
||||
.missing-requirements em { color: #0d766d; font-size: 10px; font-style: normal; }
|
||||
.planning-closeout ul { margin: 0; padding-left: 16px; color: var(--muted); font-size: 9px; line-height: 1.7; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.planning-cockpit { grid-template-columns: 1.2fr .8fr; }
|
||||
.arrival-board { grid-column: 1 / -1; border-top: 1px solid rgba(255,255,255,.16); border-left: 0; }
|
||||
.planner-workspace { grid-template-columns: 300px minmax(0, 1fr); }
|
||||
.scheduled-courses { grid-template-columns: 1fr; }
|
||||
.planning-closeout { grid-template-columns: 170px 1fr; }
|
||||
.planning-closeout ul { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.planning-cockpit { grid-template-columns: 1fr; }
|
||||
.completion-dial,
|
||||
.arrival-board { border-top: 1px solid rgba(255,255,255,.16); border-left: 0; }
|
||||
.planning-metrics { grid-template-columns: 1fr 1fr; }
|
||||
.planning-metrics > div:nth-child(2) { border-right: 0; }
|
||||
.planning-metrics > div:nth-child(-n + 2) { border-bottom: 1px solid var(--line); }
|
||||
.official-audit-strip { grid-template-columns: 1fr; gap: 7px; }
|
||||
.official-audit-strip em { width: max-content; }
|
||||
.planning-alerts { grid-template-columns: 1fr; }
|
||||
.next-term-brief { grid-template-columns: 1fr; gap: 8px; }
|
||||
.planner-workspace { grid-template-columns: 1fr; }
|
||||
.course-pool { position: static; }
|
||||
.pool-list { max-height: 460px; }
|
||||
.runway-line { padding-left: 64px; }
|
||||
.runway-line::before { left: 31px; }
|
||||
.semester-marker { left: -55px; }
|
||||
.graduation-gate { grid-template-columns: 1fr; gap: 6px; }
|
||||
.planning-closeout { grid-template-columns: 1fr; }
|
||||
.planning-closeout ul { grid-column: auto; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.completion-dial em,
|
||||
.pool-list > article { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@ const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const colleges = ref<any[]>([])
|
||||
const categories = ref<any[]>([])
|
||||
const courseOptions = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref('')
|
||||
const importing = ref(false)
|
||||
@@ -61,6 +62,9 @@ const maintenanceScope = computed(() =>
|
||||
? `${formColleges.value[0]?.name ?? '所属学院'} · 专业课与实践课`
|
||||
: '全校课程 · 全性质',
|
||||
)
|
||||
const prerequisiteOptions = computed(() =>
|
||||
courseOptions.value.filter((item) => item.id !== editingId.value),
|
||||
)
|
||||
|
||||
function canManageRow(row: any) {
|
||||
return row.canManage === true
|
||||
@@ -81,6 +85,7 @@ function resetForm(row?: any) {
|
||||
nature: isCollegeAdmin.value ? 'MajorRequired' : 'GeneralRequired',
|
||||
assessmentMethod: 'Examination',
|
||||
description: '',
|
||||
prerequisiteCourseIds: [],
|
||||
isEnabled: true,
|
||||
sortOrder: 0,
|
||||
}, row ?? {})
|
||||
@@ -168,6 +173,7 @@ async function handleImport(event: Event) {
|
||||
)
|
||||
query.page = 1
|
||||
await load()
|
||||
courseOptions.value = (await http.get('/courses/options')).data
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
@@ -203,6 +209,7 @@ async function save() {
|
||||
ElMessage.success(editingId.value ? '课程已更新' : '课程已加入课程库')
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
courseOptions.value = (await http.get('/courses/options')).data
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
@@ -224,12 +231,14 @@ async function remove(row: any) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [collegeRes, categoryRes] = await Promise.all([
|
||||
const [collegeRes, categoryRes, courseRes] = await Promise.all([
|
||||
http.get('/base-data/colleges'),
|
||||
http.get('/base-data/course-categories'),
|
||||
http.get('/courses/options'),
|
||||
])
|
||||
colleges.value = collegeRes.data
|
||||
categories.value = categoryRes.data
|
||||
courseOptions.value = courseRes.data
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
@@ -331,6 +340,14 @@ onMounted(async () => {
|
||||
<el-table-column label="考核" width="80">
|
||||
<template #default="{ row }">{{ assessmentLabels[row.assessmentMethod] }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="先修课程" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.prerequisites?.length" class="prerequisite-list">
|
||||
{{ row.prerequisites.map((item: any) => item.code).join('、') }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="85">
|
||||
<template #default="{ row }">
|
||||
<span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span>
|
||||
@@ -407,6 +424,24 @@ onMounted(async () => {
|
||||
<el-form-item label="讲授学时"><el-input-number v-model="form.lectureHours" :min="0" /></el-form-item>
|
||||
<el-form-item label="实践学时"><el-input-number v-model="form.practiceHours" :min="0" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="先修课程">
|
||||
<el-select
|
||||
v-model="form.prerequisiteCourseIds"
|
||||
multiple
|
||||
filterable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="选择修读本课程前必须通过的课程"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in prerequisiteOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.code} · ${item.name}`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="field-hint">学生规划时会检查这些课程是否已经通过,或安排在更早的模拟学期。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="课程简介"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item>
|
||||
<div class="form-grid compact">
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
|
||||
|
||||
@@ -88,7 +88,10 @@ onMounted(load)
|
||||
<h2>我的培养方案</h2>
|
||||
<p>按培养方案核对已完成、在读、重修与尚未通过的课程。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="load">刷新进度</el-button>
|
||||
<div class="page-actions">
|
||||
<el-button @click="$router.push('/academic-planning')">学业规划与毕业模拟</el-button>
|
||||
<el-button :icon="Refresh" @click="load">刷新进度</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="student" class="student-plan-head">
|
||||
|
||||
Reference in New Issue
Block a user