课程库:保留并验证现有 Excel 导入,支持模板下载、按编码新增/更新、整批校验及事务回滚。
授课资格:学院可直接选择本院教师、学期和课程并分配资格,服务端强制学院数据范围。[后端接口 (line 178)](/E:/jiaowu/src/Jiaowu.Api/Controllers/TeacherCourseApplicationsController.cs:178) · [页面 (line 151)](/E:/jiaowu/web/src/views/TeachingPreferencesView.vue:151) 学生培养方案:新增“我的培养方案”,展示学分进度及已完成、在读、重修中、未通过、待完成、未修读课程,并支持搜索筛选。[学生接口 (line 15)](/E:/jiaowu/src/Jiaowu.Api/Controllers/StudentCurriculumController.cs:15) · [学生页面 (line 88)](/E:/jiaowu/web/src/views/StudentCurriculumView.vue:88)
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||||
|
|
||||||
|
课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。
|
||||||
|
|
||||||
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
||||||
|
|
||||||
人员档案与登录账号分开维护。新增或 Excel 导入学生、教师档案时不会自动创建账号,也不会在修改档案时同步账号。学生首次使用时可以在登录页进入“自助激活”,填写姓名、学号、学院、专业、年级和行政班;全部匹配在籍档案后自行设置密码,系统才创建 Identity 登录账号并关联学生角色。`AspNetUsers` 作为 ASP.NET Core Identity 的内部安全存储,负责密码哈希、登录锁定、角色和令牌。
|
人员档案与登录账号分开维护。新增或 Excel 导入学生、教师档案时不会自动创建账号,也不会在修改档案时同步账号。学生首次使用时可以在登录页进入“自助激活”,填写姓名、学号、学院、专业、年级和行政班;全部匹配在籍档案后自行设置密码,系统才创建 Identity 登录账号并关联学生角色。`AspNetUsers` 作为 ASP.NET Core Identity 的内部安全存储,负责密码哈希、登录锁定、角色和令牌。
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Graduation;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
[Route("api/student/curriculum-plan")]
|
||||||
|
public sealed class StudentCurriculumController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult> GetMine(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
|
var student = await db.Students.AsNoTracking()
|
||||||
|
.Include(x => x.AdministrativeClass)
|
||||||
|
.ThenInclude(x => x!.Major)
|
||||||
|
.ThenInclude(x => x!.College)
|
||||||
|
.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
|
||||||
|
if (student is null)
|
||||||
|
return ConflictProblem("当前账号尚未关联学生档案。");
|
||||||
|
|
||||||
|
var studentProfile = new
|
||||||
|
{
|
||||||
|
student.Id,
|
||||||
|
student.StudentNumber,
|
||||||
|
student.Name,
|
||||||
|
student.EnrollmentYear,
|
||||||
|
student.Status,
|
||||||
|
ClassName = student.AdministrativeClass!.Name,
|
||||||
|
MajorName = student.AdministrativeClass.Major!.Name,
|
||||||
|
CollegeName = student.AdministrativeClass.Major.College!.Name
|
||||||
|
};
|
||||||
|
|
||||||
|
var plan = await db.CurriculumPlans.AsNoTracking()
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(x => x.Major)
|
||||||
|
.ThenInclude(x => x!.College)
|
||||||
|
.Include(x => x.Modules)
|
||||||
|
.ThenInclude(x => x.Courses)
|
||||||
|
.ThenInclude(x => x.Course)
|
||||||
|
.ThenInclude(x => x!.CourseCategory)
|
||||||
|
.Where(x =>
|
||||||
|
x.MajorId == student.AdministrativeClass.MajorId &&
|
||||||
|
x.EffectiveGrade == student.EnrollmentYear &&
|
||||||
|
x.Status == CurriculumPlanStatus.Published)
|
||||||
|
.OrderByDescending(x => x.PublishedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (plan is null)
|
||||||
|
{
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Student = studentProfile,
|
||||||
|
Plan = (object?)null,
|
||||||
|
Message =
|
||||||
|
$"{student.EnrollmentYear} 级{student.AdministrativeClass.Major.Name}尚未发布培养方案。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var planCourseIds = plan.Modules
|
||||||
|
.SelectMany(x => x.Courses)
|
||||||
|
.Select(x => x.CourseId)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
var gradeAttempts = await db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
planCourseIds.Contains(x.GradeSheet!.TeachingTask!.CourseId) &&
|
||||||
|
x.GradeSheet.Status == GradeSheetStatus.Published)
|
||||||
|
.Select(x => new StudentGradeAttempt(
|
||||||
|
x.GradeSheet!.TeachingTask!.CourseId,
|
||||||
|
x.TotalScore,
|
||||||
|
x.GradePoint,
|
||||||
|
x.ExamStatus,
|
||||||
|
x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||||
|
x.GradeSheet.TeachingTask.TaskNumber,
|
||||||
|
x.GradeSheet.PublishedAt ?? x.GradeSheet.UpdatedAt))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var currentCourseIds = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.Where(task =>
|
||||||
|
planCourseIds.Contains(task.CourseId) &&
|
||||||
|
task.Status == TeachingTaskStatus.Published &&
|
||||||
|
task.AcademicTerm!.IsCurrent &&
|
||||||
|
(task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClassId == student.AdministrativeClassId) ||
|
||||||
|
db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.StudentId == student.Id &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)))
|
||||||
|
.Select(x => x.CourseId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var currentCourseIdSet = currentCourseIds.ToHashSet();
|
||||||
|
var attemptsByCourse = gradeAttempts
|
||||||
|
.GroupBy(x => x.CourseId)
|
||||||
|
.ToDictionary(x => x.Key, x => x.OrderByDescending(
|
||||||
|
attempt => attempt.PublishedAt).ToList());
|
||||||
|
|
||||||
|
var progressByCourse = new Dictionary<Guid, CourseProgress>();
|
||||||
|
foreach (var courseId in planCourseIds)
|
||||||
|
{
|
||||||
|
var attempts = attemptsByCourse.GetValueOrDefault(courseId) ?? [];
|
||||||
|
var evaluation = StudentCourseProgressRules.Evaluate(
|
||||||
|
attempts.Select(x => new StudentCourseAttemptSnapshot(
|
||||||
|
x.TotalScore,
|
||||||
|
x.ExamStatus)),
|
||||||
|
currentCourseIdSet.Contains(courseId));
|
||||||
|
var displayedAttempt = evaluation.HasPassedAttempt
|
||||||
|
? attempts.FirstOrDefault(x => StudentCourseProgressRules.IsPassed(
|
||||||
|
new StudentCourseAttemptSnapshot(x.TotalScore, x.ExamStatus)))
|
||||||
|
: attempts.FirstOrDefault();
|
||||||
|
progressByCourse[courseId] = new CourseProgress(
|
||||||
|
evaluation,
|
||||||
|
displayedAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
var distinctCourses = plan.Modules
|
||||||
|
.SelectMany(x => x.Courses)
|
||||||
|
.GroupBy(x => x.CourseId)
|
||||||
|
.Select(x => x.First())
|
||||||
|
.ToList();
|
||||||
|
var completedCredits = distinctCourses
|
||||||
|
.Where(x =>
|
||||||
|
progressByCourse[x.CourseId].Evaluation.Status ==
|
||||||
|
StudentCourseProgressStatus.Completed)
|
||||||
|
.Sum(x => x.Course?.Credits ?? 0);
|
||||||
|
var completedCourseCount = distinctCourses.Count(x =>
|
||||||
|
progressByCourse[x.CourseId].Evaluation.Status ==
|
||||||
|
StudentCourseProgressStatus.Completed);
|
||||||
|
var inProgressCourseCount = distinctCourses.Count(x =>
|
||||||
|
progressByCourse[x.CourseId].Evaluation.Status is
|
||||||
|
StudentCourseProgressStatus.InProgress or
|
||||||
|
StudentCourseProgressStatus.Retaking);
|
||||||
|
var failedCourseCount = distinctCourses.Count(x =>
|
||||||
|
progressByCourse[x.CourseId].Evaluation.HasFailedAttempt &&
|
||||||
|
!progressByCourse[x.CourseId].Evaluation.HasPassedAttempt);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Student = studentProfile,
|
||||||
|
Plan = new
|
||||||
|
{
|
||||||
|
plan.Id,
|
||||||
|
plan.Name,
|
||||||
|
plan.Version,
|
||||||
|
plan.EffectiveGrade,
|
||||||
|
plan.TotalCredits,
|
||||||
|
plan.Description,
|
||||||
|
plan.PublishedAt,
|
||||||
|
MajorName = plan.Major!.Name,
|
||||||
|
CollegeName = plan.Major.College!.Name,
|
||||||
|
SchoolingYears = plan.Major.SchoolingYears,
|
||||||
|
Summary = new
|
||||||
|
{
|
||||||
|
CourseCount = distinctCourses.Count,
|
||||||
|
CompletedCourseCount = completedCourseCount,
|
||||||
|
InProgressCourseCount = inProgressCourseCount,
|
||||||
|
FailedCourseCount = failedCourseCount,
|
||||||
|
CompletedCredits = completedCredits,
|
||||||
|
CompletionRate = plan.TotalCredits <= 0
|
||||||
|
? 0
|
||||||
|
: Math.Min(100, Math.Round(
|
||||||
|
completedCredits / plan.TotalCredits * 100,
|
||||||
|
1))
|
||||||
|
},
|
||||||
|
Modules = plan.Modules
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.Code)
|
||||||
|
.Select(module => new
|
||||||
|
{
|
||||||
|
module.Id,
|
||||||
|
module.Code,
|
||||||
|
module.Name,
|
||||||
|
module.RequiredCredits,
|
||||||
|
module.SortOrder,
|
||||||
|
Courses = module.Courses
|
||||||
|
.OrderBy(x => x.RecommendedSemester)
|
||||||
|
.ThenBy(x => x.Course!.Code)
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var progress = progressByCourse[item.CourseId];
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
item.Id,
|
||||||
|
item.CourseId,
|
||||||
|
CourseCode = item.Course!.Code,
|
||||||
|
CourseName = item.Course.Name,
|
||||||
|
item.Course.Credits,
|
||||||
|
item.Course.TotalHours,
|
||||||
|
item.Course.Nature,
|
||||||
|
CategoryName = item.Course.CourseCategory?.Name,
|
||||||
|
item.RecommendedSemester,
|
||||||
|
item.Type,
|
||||||
|
item.Notes,
|
||||||
|
progress.Evaluation.Status,
|
||||||
|
progress.Evaluation.HasFailedAttempt,
|
||||||
|
progress.Evaluation.HasPassedAttempt,
|
||||||
|
LatestResult = progress.DisplayedAttempt is null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
progress.DisplayedAttempt.TotalScore,
|
||||||
|
progress.DisplayedAttempt.GradePoint,
|
||||||
|
progress.DisplayedAttempt.ExamStatus,
|
||||||
|
progress.DisplayedAttempt.TermName,
|
||||||
|
progress.DisplayedAttempt.TaskNumber,
|
||||||
|
progress.DisplayedAttempt.PublishedAt
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
|
Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "无法查看培养方案",
|
||||||
|
Detail = detail,
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
private sealed record StudentGradeAttempt(
|
||||||
|
Guid CourseId,
|
||||||
|
decimal? TotalScore,
|
||||||
|
decimal? GradePoint,
|
||||||
|
GradeExamStatus ExamStatus,
|
||||||
|
string TermName,
|
||||||
|
string TaskNumber,
|
||||||
|
DateTime PublishedAt);
|
||||||
|
|
||||||
|
private sealed record CourseProgress(
|
||||||
|
StudentCourseProgressEvaluation Evaluation,
|
||||||
|
StudentGradeAttempt? DisplayedAttempt);
|
||||||
|
}
|
||||||
@@ -175,6 +175,102 @@ public sealed class TeacherCourseApplicationsController(
|
|||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("assignment-options")]
|
||||||
|
[Authorize(Roles = ReviewRoles)]
|
||||||
|
public async Task<ActionResult> GetAssignmentOptions(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var teachers = db.Teachers.AsNoTracking()
|
||||||
|
.Where(x => x.Status == TeacherStatus.Active);
|
||||||
|
var collegeId = currentUserDataScope.Current.RestrictedCollegeId;
|
||||||
|
if (collegeId.HasValue)
|
||||||
|
teachers = teachers.Where(x => x.CollegeId == collegeId.Value);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Teachers = await teachers
|
||||||
|
.OrderBy(x => x.TeacherNumber)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.TeacherNumber,
|
||||||
|
x.Name,
|
||||||
|
x.Title,
|
||||||
|
x.CollegeId,
|
||||||
|
CollegeName = x.College!.Name
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken),
|
||||||
|
Courses = await db.Courses.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled)
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.Code)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.CollegeId,
|
||||||
|
CollegeName = x.College!.Name,
|
||||||
|
x.Credits,
|
||||||
|
x.Nature
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("assign")]
|
||||||
|
[Authorize(Roles = ReviewRoles)]
|
||||||
|
public async Task<ActionResult> Assign(
|
||||||
|
TeacherCourseAssignmentRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await db.AcademicTerms.AnyAsync(
|
||||||
|
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
||||||
|
cancellationToken))
|
||||||
|
return ValidationProblem("所选学期不存在或已停用。");
|
||||||
|
if (!await db.Courses.AnyAsync(
|
||||||
|
x => x.Id == request.CourseId && x.IsEnabled,
|
||||||
|
cancellationToken))
|
||||||
|
return ValidationProblem("所选课程不存在或已停用。");
|
||||||
|
|
||||||
|
var teachers = db.Teachers.Where(x =>
|
||||||
|
x.Id == request.TeacherId &&
|
||||||
|
x.Status == TeacherStatus.Active);
|
||||||
|
var collegeId = currentUserDataScope.Current.RestrictedCollegeId;
|
||||||
|
if (collegeId.HasValue)
|
||||||
|
teachers = teachers.Where(x => x.CollegeId == collegeId.Value);
|
||||||
|
if (!await teachers.AnyAsync(cancellationToken))
|
||||||
|
return ValidationProblem("所选教师不存在、不在职或不属于当前学院。");
|
||||||
|
|
||||||
|
var application = await db.TeacherCourseApplications
|
||||||
|
.FirstOrDefaultAsync(x =>
|
||||||
|
x.AcademicTermId == request.AcademicTermId &&
|
||||||
|
x.TeacherId == request.TeacherId &&
|
||||||
|
x.CourseId == request.CourseId,
|
||||||
|
cancellationToken);
|
||||||
|
if (application?.Status == TeacherCourseApplicationStatus.Approved)
|
||||||
|
return ConflictProblem("该教师在所选学期已具备此课程的授课资格。");
|
||||||
|
|
||||||
|
if (application is null)
|
||||||
|
{
|
||||||
|
application = new TeacherCourseApplication
|
||||||
|
{
|
||||||
|
AcademicTermId = request.AcademicTermId,
|
||||||
|
TeacherId = request.TeacherId,
|
||||||
|
CourseId = request.CourseId,
|
||||||
|
SubmittedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
db.TeacherCourseApplications.Add(application);
|
||||||
|
}
|
||||||
|
|
||||||
|
application.Status = TeacherCourseApplicationStatus.Approved;
|
||||||
|
application.ReviewComment = Normalize(request.Comment) ?? "学院直接分配";
|
||||||
|
application.ReviewedAt = DateTime.UtcNow;
|
||||||
|
application.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new { application.Id });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/review")]
|
[HttpPost("{id:guid}/review")]
|
||||||
[Authorize(Roles = ReviewRoles)]
|
[Authorize(Roles = ReviewRoles)]
|
||||||
public async Task<ActionResult> Review(
|
public async Task<ActionResult> Review(
|
||||||
@@ -262,3 +358,9 @@ public sealed record TeacherCourseApplicationRequest(
|
|||||||
public sealed record TeacherCourseReviewRequest(
|
public sealed record TeacherCourseReviewRequest(
|
||||||
TeacherCourseApplicationStatus Status,
|
TeacherCourseApplicationStatus Status,
|
||||||
[MaxLength(500)] string? ReviewComment);
|
[MaxLength(500)] string? ReviewComment);
|
||||||
|
|
||||||
|
public sealed record TeacherCourseAssignmentRequest(
|
||||||
|
Guid AcademicTermId,
|
||||||
|
Guid TeacherId,
|
||||||
|
Guid CourseId,
|
||||||
|
[MaxLength(500)] string? Comment);
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Graduation;
|
||||||
|
|
||||||
|
public enum StudentCourseProgressStatus
|
||||||
|
{
|
||||||
|
NotStarted = 1,
|
||||||
|
InProgress = 2,
|
||||||
|
Retaking = 3,
|
||||||
|
Completed = 4,
|
||||||
|
Failed = 5,
|
||||||
|
Pending = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StudentCourseAttemptSnapshot(
|
||||||
|
decimal? TotalScore,
|
||||||
|
GradeExamStatus ExamStatus);
|
||||||
|
|
||||||
|
public sealed record StudentCourseProgressEvaluation(
|
||||||
|
StudentCourseProgressStatus Status,
|
||||||
|
bool HasPassedAttempt,
|
||||||
|
bool HasFailedAttempt);
|
||||||
|
|
||||||
|
public static class StudentCourseProgressRules
|
||||||
|
{
|
||||||
|
public static StudentCourseProgressEvaluation Evaluate(
|
||||||
|
IEnumerable<StudentCourseAttemptSnapshot> attempts,
|
||||||
|
bool isInProgress)
|
||||||
|
{
|
||||||
|
var attemptList = attempts.ToList();
|
||||||
|
var hasPassedAttempt = attemptList.Any(IsPassed);
|
||||||
|
var hasFailedAttempt = attemptList.Any(IsFailed);
|
||||||
|
var hasPendingAttempt = attemptList.Any(x =>
|
||||||
|
!IsPassed(x) &&
|
||||||
|
!IsFailed(x));
|
||||||
|
|
||||||
|
var status = hasPassedAttempt
|
||||||
|
? StudentCourseProgressStatus.Completed
|
||||||
|
: isInProgress && hasFailedAttempt
|
||||||
|
? StudentCourseProgressStatus.Retaking
|
||||||
|
: isInProgress
|
||||||
|
? StudentCourseProgressStatus.InProgress
|
||||||
|
: hasFailedAttempt
|
||||||
|
? StudentCourseProgressStatus.Failed
|
||||||
|
: hasPendingAttempt
|
||||||
|
? StudentCourseProgressStatus.Pending
|
||||||
|
: StudentCourseProgressStatus.NotStarted;
|
||||||
|
|
||||||
|
return new StudentCourseProgressEvaluation(
|
||||||
|
status,
|
||||||
|
hasPassedAttempt,
|
||||||
|
hasFailedAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsPassed(StudentCourseAttemptSnapshot attempt) =>
|
||||||
|
attempt.ExamStatus == GradeExamStatus.Exempt ||
|
||||||
|
attempt.TotalScore is decimal score && score >= 60;
|
||||||
|
|
||||||
|
public static bool IsFailed(StudentCourseAttemptSnapshot attempt) =>
|
||||||
|
attempt.ExamStatus == GradeExamStatus.Absent ||
|
||||||
|
attempt.TotalScore is decimal score && score < 60;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Graduation;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class StudentCourseProgressRulesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Passed_attempt_takes_precedence_over_other_states()
|
||||||
|
{
|
||||||
|
var result = StudentCourseProgressRules.Evaluate(
|
||||||
|
[
|
||||||
|
new StudentCourseAttemptSnapshot(52, GradeExamStatus.Normal),
|
||||||
|
new StudentCourseAttemptSnapshot(76, GradeExamStatus.Normal)
|
||||||
|
],
|
||||||
|
true);
|
||||||
|
|
||||||
|
Assert.Equal(StudentCourseProgressStatus.Completed, result.Status);
|
||||||
|
Assert.True(result.HasPassedAttempt);
|
||||||
|
Assert.True(result.HasFailedAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Failed_course_becomes_retaking_when_currently_in_progress()
|
||||||
|
{
|
||||||
|
var result = StudentCourseProgressRules.Evaluate(
|
||||||
|
[new StudentCourseAttemptSnapshot(48, GradeExamStatus.Normal)],
|
||||||
|
true);
|
||||||
|
|
||||||
|
Assert.Equal(StudentCourseProgressStatus.Retaking, result.Status);
|
||||||
|
Assert.False(result.HasPassedAttempt);
|
||||||
|
Assert.True(result.HasFailedAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(GradeExamStatus.Absent, StudentCourseProgressStatus.Failed)]
|
||||||
|
[InlineData(GradeExamStatus.Deferred, StudentCourseProgressStatus.Pending)]
|
||||||
|
public void Special_exam_states_are_presented_accurately(
|
||||||
|
GradeExamStatus examStatus,
|
||||||
|
StudentCourseProgressStatus expected)
|
||||||
|
{
|
||||||
|
var result = StudentCourseProgressRules.Evaluate(
|
||||||
|
[new StudentCourseAttemptSnapshot(null, examStatus)],
|
||||||
|
false);
|
||||||
|
|
||||||
|
Assert.Equal(expected, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Current_course_without_result_is_in_progress()
|
||||||
|
{
|
||||||
|
var result = StudentCourseProgressRules.Evaluate([], true);
|
||||||
|
|
||||||
|
Assert.Equal(StudentCourseProgressStatus.InProgress, result.Status);
|
||||||
|
Assert.False(result.HasPassedAttempt);
|
||||||
|
Assert.False(result.HasFailedAttempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
...whenVisible(isTeachingAdmin.value, { path: '/curriculum', label: '培养方案' }),
|
...whenVisible(isTeachingAdmin.value, { path: '/curriculum', label: '培养方案' }),
|
||||||
|
...whenVisible(isStudent.value, { path: '/my-curriculum', label: '我的培养方案' }),
|
||||||
...whenVisible(isTeachingAdmin.value, { path: '/teaching-tasks', label: '教学任务' }),
|
...whenVisible(isTeachingAdmin.value, { path: '/teaching-tasks', label: '教学任务' }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/CurriculumView.vue'),
|
component: () => import('../views/CurriculumView.vue'),
|
||||||
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
|
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'my-curriculum',
|
||||||
|
name: 'my-curriculum',
|
||||||
|
component: () => import('../views/StudentCurriculumView.vue'),
|
||||||
|
meta: { roles: ['Student'] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'teaching-tasks',
|
path: 'teaching-tasks',
|
||||||
name: 'teaching-tasks',
|
name: 'teaching-tasks',
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const payload = ref<any>({ student: null, plan: null })
|
||||||
|
const keyword = ref('')
|
||||||
|
const statusFilter = ref('All')
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
Completed: '已完成',
|
||||||
|
InProgress: '在读',
|
||||||
|
Retaking: '重修中',
|
||||||
|
Failed: '未通过',
|
||||||
|
Pending: '待完成',
|
||||||
|
NotStarted: '未修读',
|
||||||
|
}
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
Required: '指定必修',
|
||||||
|
Elective: '组内选修',
|
||||||
|
}
|
||||||
|
const examStatusLabels: Record<string, string> = {
|
||||||
|
Normal: '正常',
|
||||||
|
Absent: '缺考',
|
||||||
|
Deferred: '缓考',
|
||||||
|
Exempt: '免修',
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = computed(() => payload.value.plan)
|
||||||
|
const student = computed(() => payload.value.student)
|
||||||
|
const visibleModules = computed(() => {
|
||||||
|
const normalizedKeyword = keyword.value.trim().toLowerCase()
|
||||||
|
return (plan.value?.modules ?? [])
|
||||||
|
.map((module: any) => ({
|
||||||
|
...module,
|
||||||
|
courses: module.courses.filter((course: any) => {
|
||||||
|
const matchesKeyword = !normalizedKeyword ||
|
||||||
|
`${course.courseCode} ${course.courseName} ${course.categoryName ?? ''}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(normalizedKeyword)
|
||||||
|
const matchesStatus = statusFilter.value === 'All' ||
|
||||||
|
statusFilter.value === 'InProgress' &&
|
||||||
|
['InProgress', 'Retaking'].includes(course.status) ||
|
||||||
|
statusFilter.value === 'Failed' &&
|
||||||
|
course.hasFailedAttempt && !course.hasPassedAttempt ||
|
||||||
|
course.status === statusFilter.value
|
||||||
|
return matchesKeyword && matchesStatus
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.filter((module: any) => module.courses.length > 0)
|
||||||
|
})
|
||||||
|
const visibleCourseCount = computed(() =>
|
||||||
|
visibleModules.value.reduce(
|
||||||
|
(sum: number, module: any) => sum + module.courses.length,
|
||||||
|
0,
|
||||||
|
))
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
payload.value = (await http.get('/student/curriculum-plan')).data
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultText(course: any) {
|
||||||
|
const result = course.latestResult
|
||||||
|
if (!result) return '暂无成绩'
|
||||||
|
if (result.examStatus === 'Exempt') return `${result.termName} · 免修通过`
|
||||||
|
if (result.totalScore != null) {
|
||||||
|
return `${result.termName} · ${result.totalScore} 分 · 绩点 ${result.gradePoint ?? '—'}`
|
||||||
|
}
|
||||||
|
return `${result.termName} · ${examStatusLabels[result.examStatus] ?? '待完成'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack curriculum-ledger-page" v-loading="loading">
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">MY CURRICULUM</span>
|
||||||
|
<h2>我的培养方案</h2>
|
||||||
|
<p>按培养方案核对已完成、在读、重修与尚未通过的课程。</p>
|
||||||
|
</div>
|
||||||
|
<el-button :icon="Refresh" @click="load">刷新进度</el-button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="student" class="student-plan-head">
|
||||||
|
<div class="student-plan-identity">
|
||||||
|
<span>{{ student.enrollmentYear }} 级 · {{ student.collegeName }}</span>
|
||||||
|
<h3>{{ student.name }}</h3>
|
||||||
|
<p>{{ student.studentNumber }} · {{ student.majorName }} · {{ student.className }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="plan" class="plan-seal">
|
||||||
|
<span>适用方案</span>
|
||||||
|
<b>{{ plan.version }}</b>
|
||||||
|
<small>{{ plan.schoolingYears }} 年制</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-empty
|
||||||
|
v-if="student && !plan && !loading"
|
||||||
|
:description="payload.message || '尚未匹配已发布的培养方案'"
|
||||||
|
class="plan-empty"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="plan">
|
||||||
|
<section class="plan-brief">
|
||||||
|
<div>
|
||||||
|
<span>{{ plan.majorName }}</span>
|
||||||
|
<h3>{{ plan.name }}</h3>
|
||||||
|
<p>{{ plan.description || '本页仅展示已正式发布的培养方案及本人实时修读情况。' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="credit-progress">
|
||||||
|
<div>
|
||||||
|
<span>已完成学分</span>
|
||||||
|
<b>{{ plan.summary.completedCredits }}</b>
|
||||||
|
<small>/ {{ plan.totalCredits }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="credit-track" aria-label="培养方案学分完成比例">
|
||||||
|
<i :style="{ width: `${plan.summary.completionRate}%` }" />
|
||||||
|
</div>
|
||||||
|
<p>完成度 {{ plan.summary.completionRate }}%</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="progress-index" aria-label="课程进度摘要">
|
||||||
|
<div>
|
||||||
|
<span>方案课程</span>
|
||||||
|
<b>{{ plan.summary.courseCount }}</b>
|
||||||
|
<small>门</small>
|
||||||
|
</div>
|
||||||
|
<div class="completed">
|
||||||
|
<span>已完成</span>
|
||||||
|
<b>{{ plan.summary.completedCourseCount }}</b>
|
||||||
|
<small>门</small>
|
||||||
|
</div>
|
||||||
|
<div class="studying">
|
||||||
|
<span>当前在读</span>
|
||||||
|
<b>{{ plan.summary.inProgressCourseCount }}</b>
|
||||||
|
<small>门</small>
|
||||||
|
</div>
|
||||||
|
<div class="failed">
|
||||||
|
<span>未通过记录</span>
|
||||||
|
<b>{{ plan.summary.failedCourseCount }}</b>
|
||||||
|
<small>门</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ledger-tools">
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
:prefix-icon="Search"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索课程编码、名称或分类"
|
||||||
|
/>
|
||||||
|
<el-select v-model="statusFilter" aria-label="按修读状态筛选">
|
||||||
|
<el-option label="全部修读状态" value="All" />
|
||||||
|
<el-option label="已完成" value="Completed" />
|
||||||
|
<el-option label="在读(含重修)" value="InProgress" />
|
||||||
|
<el-option label="未通过(含重修)" value="Failed" />
|
||||||
|
<el-option label="待完成" value="Pending" />
|
||||||
|
<el-option label="未修读" value="NotStarted" />
|
||||||
|
</el-select>
|
||||||
|
<span>当前显示 {{ visibleCourseCount }} 门</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="curriculum-ledger">
|
||||||
|
<article v-for="module in visibleModules" :key="module.id" class="module-sheet">
|
||||||
|
<header>
|
||||||
|
<div class="module-code">{{ module.code }}</div>
|
||||||
|
<div>
|
||||||
|
<span>培养模块</span>
|
||||||
|
<h3>{{ module.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<p>要求学分 <b>{{ module.requiredCredits }}</b></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="course-ledger-head" aria-hidden="true">
|
||||||
|
<span>建议学期</span>
|
||||||
|
<span>课程</span>
|
||||||
|
<span>课程要求</span>
|
||||||
|
<span>学分 / 学时</span>
|
||||||
|
<span>本人进度</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="course in module.courses"
|
||||||
|
:key="course.id"
|
||||||
|
class="course-ledger-row"
|
||||||
|
>
|
||||||
|
<div class="semester-cell">
|
||||||
|
<span>第</span><b>{{ course.recommendedSemester }}</b><span>学期</span>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-course">
|
||||||
|
<span>{{ course.courseCode }} · {{ course.categoryName || '未分类' }}</span>
|
||||||
|
<h4>{{ course.courseName }}</h4>
|
||||||
|
<p v-if="course.notes">{{ course.notes }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="requirement-cell">
|
||||||
|
<b>{{ typeLabels[course.type] }}</b>
|
||||||
|
<span>{{ course.nature === 'Practice' ? '实践课程' : '计划课程' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="credit-cell">
|
||||||
|
<b>{{ course.credits }}</b><span>学分</span>
|
||||||
|
<small>{{ course.totalHours }} 学时</small>
|
||||||
|
</div>
|
||||||
|
<div class="progress-cell">
|
||||||
|
<i :class="course.status">{{ statusLabels[course.status] }}</i>
|
||||||
|
<span>{{ resultText(course) }}</span>
|
||||||
|
<small
|
||||||
|
v-if="course.hasFailedAttempt && course.status !== 'Failed'"
|
||||||
|
class="failed-history"
|
||||||
|
>有未通过记录</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<el-empty
|
||||||
|
v-if="!visibleModules.length"
|
||||||
|
description="没有符合筛选条件的课程"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.student-plan-head {
|
||||||
|
min-height: 138px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 168px;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #21396f;
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px),
|
||||||
|
linear-gradient(112deg, #17295c, #273f7b 70%, #146f70);
|
||||||
|
background-size: 22px 22px, 22px 22px, auto;
|
||||||
|
}
|
||||||
|
.student-plan-identity {
|
||||||
|
align-self: center;
|
||||||
|
padding: 25px 30px;
|
||||||
|
}
|
||||||
|
.student-plan-identity > span,
|
||||||
|
.plan-seal > span {
|
||||||
|
color: #63d8c6;
|
||||||
|
font: 700 10px/1.2 Consolas, monospace;
|
||||||
|
letter-spacing: .11em;
|
||||||
|
}
|
||||||
|
.student-plan-identity h3 {
|
||||||
|
margin: 9px 0 6px;
|
||||||
|
font-family: "STZhongsong", "Songti SC", serif;
|
||||||
|
font-size: 27px;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
.student-plan-identity p {
|
||||||
|
margin: 0;
|
||||||
|
color: #c6cee5;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.plan-seal {
|
||||||
|
margin: 22px;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid rgba(255,255,255,.3);
|
||||||
|
outline: 1px solid rgba(255,255,255,.12);
|
||||||
|
outline-offset: -6px;
|
||||||
|
background: rgba(9,24,60,.25);
|
||||||
|
}
|
||||||
|
.plan-seal b {
|
||||||
|
margin: 8px 0 5px;
|
||||||
|
font-family: "STZhongsong", "Songti SC", serif;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.plan-seal small { color: #b7c2dd; font-size: 10px; }
|
||||||
|
.plan-empty {
|
||||||
|
min-height: 300px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.plan-brief {
|
||||||
|
min-height: 128px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr minmax(300px, 430px);
|
||||||
|
gap: 36px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.plan-brief > div:first-child > span {
|
||||||
|
color: var(--teal);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.plan-brief h3 {
|
||||||
|
margin: 7px 0 7px;
|
||||||
|
font-family: "STZhongsong", "Songti SC", serif;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.plan-brief p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.credit-progress > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.credit-progress span { color: var(--muted); font-size: 10px; }
|
||||||
|
.credit-progress b {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--indigo);
|
||||||
|
font: 700 29px/1 Consolas, monospace;
|
||||||
|
}
|
||||||
|
.credit-progress small { color: var(--muted); font-size: 11px; }
|
||||||
|
.credit-track {
|
||||||
|
height: 7px;
|
||||||
|
margin: 11px 0 7px;
|
||||||
|
background: #e8ebf1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.credit-track i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #243a78, #078276);
|
||||||
|
}
|
||||||
|
.credit-progress > p { text-align: right; font: 700 10px/1.2 Consolas, monospace; }
|
||||||
|
.progress-index {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.progress-index > div {
|
||||||
|
min-height: 78px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
box-shadow: inset 0 3px #8c96aa;
|
||||||
|
}
|
||||||
|
.progress-index > div:last-child { border-right: none; }
|
||||||
|
.progress-index > .completed { box-shadow: inset 0 3px #087f73; }
|
||||||
|
.progress-index > .studying { box-shadow: inset 0 3px #315f9a; }
|
||||||
|
.progress-index > .failed { box-shadow: inset 0 3px #b34e48; }
|
||||||
|
.progress-index span { color: var(--muted); font-size: 10px; }
|
||||||
|
.progress-index b {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--ink);
|
||||||
|
font: 700 25px/1 Consolas, monospace;
|
||||||
|
}
|
||||||
|
.progress-index small { color: var(--muted); font-size: 9px; }
|
||||||
|
.ledger-tools {
|
||||||
|
min-height: 68px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fbfcfd;
|
||||||
|
}
|
||||||
|
.ledger-tools .el-input { width: min(360px, 42vw); }
|
||||||
|
.ledger-tools .el-select { width: 190px; }
|
||||||
|
.ledger-tools > span {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.curriculum-ledger { display: grid; gap: 14px; }
|
||||||
|
.module-sheet {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: white;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.module-sheet > header {
|
||||||
|
min-height: 74px;
|
||||||
|
padding: 0 20px 0 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 82px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
.module-code {
|
||||||
|
align-self: stretch;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: white;
|
||||||
|
background: #233876;
|
||||||
|
font: 700 12px/1 Consolas, monospace;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
}
|
||||||
|
.module-sheet header > div:nth-child(2) { padding-left: 18px; }
|
||||||
|
.module-sheet header span { color: var(--muted); font-size: 9px; }
|
||||||
|
.module-sheet header h3 { margin: 5px 0 0; font-size: 16px; }
|
||||||
|
.module-sheet header p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||||
|
.module-sheet header p b { color: var(--indigo); font: 700 17px/1 Consolas, monospace; }
|
||||||
|
.course-ledger-head,
|
||||||
|
.course-ledger-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 105px minmax(260px, 1.5fr) minmax(120px, .7fr) 120px minmax(220px, 1fr);
|
||||||
|
}
|
||||||
|
.course-ledger-head {
|
||||||
|
min-height: 38px;
|
||||||
|
align-items: center;
|
||||||
|
color: #727b8d;
|
||||||
|
background: #fcfcfd;
|
||||||
|
border-bottom: 1px solid #e9ecf1;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
.course-ledger-head span,
|
||||||
|
.course-ledger-row > div { padding: 0 16px; border-right: 1px solid #edf0f4; }
|
||||||
|
.course-ledger-head span:last-child,
|
||||||
|
.course-ledger-row > div:last-child { border-right: none; }
|
||||||
|
.course-ledger-row {
|
||||||
|
min-height: 92px;
|
||||||
|
border-bottom: 1px solid #edf0f4;
|
||||||
|
}
|
||||||
|
.course-ledger-row:last-child { border-bottom: none; }
|
||||||
|
.course-ledger-row > div {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
.semester-cell {
|
||||||
|
grid-template-columns: auto auto auto;
|
||||||
|
place-content: center;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 3px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.semester-cell span { font-size: 9px; }
|
||||||
|
.semester-cell b { color: var(--indigo); font: 700 21px/1 Consolas, monospace; }
|
||||||
|
.ledger-course > span { color: var(--teal); font-size: 9px; }
|
||||||
|
.ledger-course h4 { margin: 6px 0 0; font-size: 14px; }
|
||||||
|
.ledger-course p { margin: 5px 0 0; color: var(--muted); font-size: 9px; }
|
||||||
|
.requirement-cell,
|
||||||
|
.credit-cell { gap: 5px; }
|
||||||
|
.requirement-cell b { font-size: 11px; }
|
||||||
|
.requirement-cell span,
|
||||||
|
.credit-cell span,
|
||||||
|
.credit-cell small { color: var(--muted); font-size: 9px; }
|
||||||
|
.credit-cell b { color: var(--ink); font: 700 17px/1 Consolas, monospace; }
|
||||||
|
.progress-cell { gap: 6px; }
|
||||||
|
.progress-cell > i {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 58px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-left: 3px solid #8993a5;
|
||||||
|
color: #5e687a;
|
||||||
|
background: #f1f3f6;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.progress-cell > i.Completed { color: #087f73; border-color: #087f73; background: #eaf6f3; }
|
||||||
|
.progress-cell > i.InProgress { color: #315f9a; border-color: #315f9a; background: #edf3fa; }
|
||||||
|
.progress-cell > i.Retaking { color: #a6671b; border-color: #c78724; background: #fbf4e8; }
|
||||||
|
.progress-cell > i.Failed { color: #a43f3b; border-color: #b34e48; background: #faeeee; }
|
||||||
|
.progress-cell > i.Pending { color: #79579a; border-color: #8062a0; background: #f4eff8; }
|
||||||
|
.progress-cell > span { color: var(--muted); font-size: 9px; }
|
||||||
|
.failed-history { color: #a43f3b; font-size: 9px; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.plan-brief { grid-template-columns: 1fr; gap: 20px; }
|
||||||
|
.course-ledger-head { display: none; }
|
||||||
|
.course-ledger-row {
|
||||||
|
grid-template-columns: 76px 1fr 150px;
|
||||||
|
min-height: 112px;
|
||||||
|
}
|
||||||
|
.requirement-cell,
|
||||||
|
.credit-cell { display: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.student-plan-head { grid-template-columns: 1fr; }
|
||||||
|
.plan-seal { min-height: 86px; margin-top: 0; }
|
||||||
|
.progress-index { grid-template-columns: 1fr 1fr; }
|
||||||
|
.progress-index > div:nth-child(2) { border-right: none; }
|
||||||
|
.progress-index > div:nth-child(-n + 2) { border-bottom: 1px solid var(--line); }
|
||||||
|
.ledger-tools { align-items: stretch; flex-direction: column; }
|
||||||
|
.ledger-tools .el-input,
|
||||||
|
.ledger-tools .el-select { width: 100%; }
|
||||||
|
.ledger-tools > span { margin-left: 0; }
|
||||||
|
.module-sheet > header { grid-template-columns: 62px 1fr; }
|
||||||
|
.module-sheet header > p { display: none; }
|
||||||
|
.course-ledger-row {
|
||||||
|
grid-template-columns: 62px 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.semester-cell { padding: 12px 8px !important; }
|
||||||
|
.ledger-course { padding: 14px !important; }
|
||||||
|
.progress-cell {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 11px 14px !important;
|
||||||
|
border-top: 1px dashed #dfe4eb;
|
||||||
|
border-right: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,15 +5,22 @@ import http, { apiErrorMessage } from '../api/http'
|
|||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') ?? false)
|
const isReviewer = computed(() =>
|
||||||
|
auth.user?.roles.some((role) =>
|
||||||
|
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ?? false)
|
||||||
|
const isTeacher = computed(() =>
|
||||||
|
(auth.user?.roles.includes('Teacher') ?? false) && !isReviewer.value)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<any[]>([])
|
const rows = ref<any[]>([])
|
||||||
const terms = ref<any[]>([])
|
const terms = ref<any[]>([])
|
||||||
const courses = ref<any[]>([])
|
const courses = ref<any[]>([])
|
||||||
|
const teachers = ref<any[]>([])
|
||||||
const submitDialog = ref(false)
|
const submitDialog = ref(false)
|
||||||
const reviewDialog = ref(false)
|
const reviewDialog = ref(false)
|
||||||
|
const assignmentDialog = ref(false)
|
||||||
const form = reactive<Record<string, any>>({})
|
const form = reactive<Record<string, any>>({})
|
||||||
const reviewForm = reactive<Record<string, any>>({})
|
const reviewForm = reactive<Record<string, any>>({})
|
||||||
|
const assignmentForm = reactive<Record<string, any>>({})
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
academicTermId: undefined as string | undefined,
|
academicTermId: undefined as string | undefined,
|
||||||
status: isTeacher.value ? undefined : 'Pending' as string | undefined,
|
status: isTeacher.value ? undefined : 'Pending' as string | undefined,
|
||||||
@@ -108,14 +115,49 @@ async function review() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAssignment() {
|
||||||
|
Object.assign(assignmentForm, {
|
||||||
|
academicTermId: filters.academicTermId ??
|
||||||
|
terms.value.find((item) => item.isCurrent)?.id,
|
||||||
|
teacherId: undefined,
|
||||||
|
courseId: undefined,
|
||||||
|
comment: '',
|
||||||
|
})
|
||||||
|
assignmentDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignTeacher() {
|
||||||
|
if (!assignmentForm.academicTermId ||
|
||||||
|
!assignmentForm.teacherId ||
|
||||||
|
!assignmentForm.courseId) {
|
||||||
|
ElMessage.warning('请选择学期、教师和课程。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await http.post('/teacher-course-applications/assign', assignmentForm)
|
||||||
|
assignmentDialog.value = false
|
||||||
|
ElMessage.success('授课资格已直接分配')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const requests: Promise<any>[] = [http.get('/base-data/terms')]
|
const requests: Promise<any>[] = [http.get('/base-data/terms')]
|
||||||
if (isTeacher.value) {
|
if (isTeacher.value) {
|
||||||
requests.push(http.get('/teacher-course-applications/course-options'))
|
requests.push(http.get('/teacher-course-applications/course-options'))
|
||||||
|
} else {
|
||||||
|
requests.push(http.get('/teacher-course-applications/assignment-options'))
|
||||||
}
|
}
|
||||||
const [termRes, courseRes] = await Promise.all(requests)
|
const [termRes, optionRes] = await Promise.all(requests)
|
||||||
terms.value = termRes.data
|
terms.value = termRes.data
|
||||||
courses.value = courseRes?.data ?? []
|
if (isTeacher.value) {
|
||||||
|
courses.value = optionRes?.data ?? []
|
||||||
|
} else {
|
||||||
|
courses.value = optionRes?.data?.courses ?? []
|
||||||
|
teachers.value = optionRes?.data?.teachers ?? []
|
||||||
|
}
|
||||||
filters.academicTermId = terms.value.find((item) => item.isCurrent)?.id
|
filters.academicTermId = terms.value.find((item) => item.isCurrent)?.id
|
||||||
await load()
|
await load()
|
||||||
})
|
})
|
||||||
@@ -128,17 +170,22 @@ onMounted(async () => {
|
|||||||
<span class="section-kicker">TEACHING ELIGIBILITY</span>
|
<span class="section-kicker">TEACHING ELIGIBILITY</span>
|
||||||
<h2>{{ isTeacher ? '授课科目申报' : '授课资格审核' }}</h2>
|
<h2>{{ isTeacher ? '授课科目申报' : '授课资格审核' }}</h2>
|
||||||
<p v-if="isTeacher">在排课前申报本学期愿意承担的课程,学院审核通过后才能分配公共课教学任务。</p>
|
<p v-if="isTeacher">在排课前申报本学期愿意承担的课程,学院审核通过后才能分配公共课教学任务。</p>
|
||||||
<p v-else>审核教师的授课科目申报;通过后,该教师进入相应学期和课程的可分配教师池。</p>
|
<p v-else>审核教师申报,或由学院直接指定教师;通过后进入相应学期和课程的可分配教师池。</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openSubmit">
|
||||||
|
申报课程
|
||||||
|
</el-button>
|
||||||
|
<el-button v-else type="primary" :icon="Plus" @click="openAssignment">
|
||||||
|
直接分配教师
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openSubmit">
|
|
||||||
申报课程
|
|
||||||
</el-button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="eligibility-flow">
|
<section class="eligibility-flow">
|
||||||
<div class="active"><span>教师</span><b>选择学期与科目</b></div>
|
<div class="active"><span>教师</span><b>选择学期与科目</b></div>
|
||||||
<i>→</i>
|
<i>→</i>
|
||||||
<div><span>学院</span><b>审核授课资格</b></div>
|
<div><span>学院</span><b>审核或直接分配</b></div>
|
||||||
<i>→</i>
|
<i>→</i>
|
||||||
<div><span>教务</span><b>合班并分配教师</b></div>
|
<div><span>教务</span><b>合班并分配教师</b></div>
|
||||||
<i>→</i>
|
<i>→</i>
|
||||||
@@ -228,5 +275,82 @@ onMounted(async () => {
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><el-button @click="reviewDialog = false">取消</el-button><el-button type="primary" @click="review">确认审核</el-button></template>
|
<template #footer><el-button @click="reviewDialog = false">取消</el-button><el-button type="primary" @click="review">确认审核</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="assignmentDialog" title="直接分配授课教师" width="640px">
|
||||||
|
<el-alert
|
||||||
|
title="无需教师先申报,保存后立即取得所选学期与课程的授课资格。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<el-form label-position="top" style="margin-top: 18px">
|
||||||
|
<el-form-item label="学期" required>
|
||||||
|
<el-select v-model="assignmentForm.academicTermId" style="width: 100%">
|
||||||
|
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="教师" required>
|
||||||
|
<el-select
|
||||||
|
v-model="assignmentForm.teacherId"
|
||||||
|
filterable
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="按工号或姓名选择"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in teachers"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.teacherNumber} · ${item.name}`"
|
||||||
|
:value="item.id"
|
||||||
|
>
|
||||||
|
<span>{{ item.teacherNumber }} · {{ item.name }}</span>
|
||||||
|
<small class="option-hint">{{ item.collegeName }}{{ item.title ? ` · ${item.title}` : '' }}</small>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="课程" required>
|
||||||
|
<el-select
|
||||||
|
v-model="assignmentForm.courseId"
|
||||||
|
filterable
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="按编码或名称选择"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in courses"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.code} · ${item.name}`"
|
||||||
|
:value="item.id"
|
||||||
|
>
|
||||||
|
<span>{{ item.code }} · {{ item.name }}</span>
|
||||||
|
<small class="option-hint">{{ item.collegeName }} · {{ item.credits }} 学分</small>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="分配说明">
|
||||||
|
<el-input
|
||||||
|
v-model="assignmentForm.comment"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="可填写教研室安排、专业方向等依据"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="assignmentDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="assignTeacher">确认分配</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.option-hint {
|
||||||
|
float: right;
|
||||||
|
margin-left: 20px;
|
||||||
|
color: #8a93a5;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user