课程库:保留并验证现有 Excel 导入,支持模板下载、按编码新增/更新、整批校验及事务回滚。

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