课程库:保留并验证现有 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
+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>
+132 -8
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>
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openSubmit">
申报课程
</el-button>
</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>