This commit is contained in:
2026-07-27 12:20:29 +08:00 Unverified
parent 727a429abf
commit 33ae991e86
16 changed files with 7117 additions and 8 deletions
@@ -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");
"""
];
}
@@ -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");
}
}
}
@@ -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));
}
}
+4
View File
@@ -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 ? '毕业资格' : '毕业审核' },
+6
View File
@@ -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',
+815
View File
@@ -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>
+36 -1
View File
@@ -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>
+3
View File
@@ -88,7 +88,10 @@ onMounted(load)
<h2>我的培养方案</h2>
<p>按培养方案核对已完成在读重修与尚未通过的课程</p>
</div>
<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">