This commit is contained in:
2026-07-27 12:20:29 +08:00 Unverified
parent 727a429abf
commit 33ae991e86
16 changed files with 7117 additions and 8 deletions
+815
View File
@@ -0,0 +1,815 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Delete, MagicStick, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
interface PlanningTerm {
semester: number
label: string
isBeyondStandard: boolean
courseIds: string[]
}
const loading = ref(false)
const simulationLoading = ref(false)
const initialized = ref(false)
const payload = ref<any>(null)
const simulation = ref<any>(null)
const terms = ref<PlanningTerm[]>([])
const keyword = ref('')
const moduleFilter = ref('')
let simulationTimer: ReturnType<typeof setTimeout> | undefined
const courses = computed<any[]>(() => payload.value?.courses ?? [])
const baseline = computed(() => payload.value?.baseline ?? {})
const projected = computed(() => simulation.value?.projected ?? {
earnedCredits: baseline.value.earnedCredits ?? 0,
planCompletedCredits: baseline.value.planCompletedCredits ?? 0,
creditGap: baseline.value.creditGap ?? 0,
completionRate: baseline.value.completionRate ?? 0,
requirementCount: baseline.value.requirementCount ?? 0,
passedRequirementCount: baseline.value.passedRequirementCount ?? 0,
missingRequirements: baseline.value.missingRequirements ?? [],
graduationConclusion: 'Ineligible',
estimatedGraduationTerm: '等待模拟',
isBeyondStandard: false,
})
const modules = computed(() =>
[...new Map(courses.value.map((course) =>
[course.moduleCode, { code: course.moduleCode, name: course.moduleName }],
)).values()],
)
const assigned = computed(() => {
const result = new Map<string, number>()
for (const term of terms.value) {
for (const courseId of term.courseIds) result.set(courseId, term.semester)
}
return result
})
const remainingCourses = computed(() => {
const normalized = keyword.value.trim().toLowerCase()
return courses.value.filter((course) => {
if (['Completed', 'InProgress', 'Retaking'].includes(course.status)) return false
if (moduleFilter.value && course.moduleCode !== moduleFilter.value) return false
return !normalized ||
`${course.courseCode} ${course.courseName} ${course.moduleName}`
.toLowerCase()
.includes(normalized)
})
})
const plannedCourseCount = computed(() =>
terms.value.reduce((count, term) => count + term.courseIds.length, 0),
)
const plannedCredits = computed(() =>
courses.value
.filter((course) => assigned.value.has(course.courseId))
.reduce((sum, course) => sum + Number(course.credits), 0),
)
const conflictCourseIds = computed(() =>
new Set<string>((simulation.value?.conflicts ?? []).map((item: any) => item.courseId)),
)
const termSummaryMap = computed(() =>
new Map<number, any>((simulation.value?.termSummaries ?? [])
.map((item: any) => [item.semester, item])),
)
function courseById(courseId: string) {
return courses.value.find((course) => course.courseId === courseId)
}
function typeLabel(type: string) {
return type === 'Required' ? '指定必修' : '组内选修'
}
function setCourseSemester(courseId: string, semester?: number) {
for (const term of terms.value) {
term.courseIds = term.courseIds.filter((id) => id !== courseId)
}
if (semester) {
const target = terms.value.find((term) => term.semester === semester)
if (target) target.courseIds.push(courseId)
}
}
function removeCourse(courseId: string) {
setCourseSemester(courseId)
}
function applySuggestion() {
const target = terms.value.find(
(term) => term.semester === payload.value?.plan?.nextSemester,
)
if (!target) return
const ids = (payload.value?.nextSemesterSuggestion ?? [])
.map((course: any) => course.courseId)
.filter((courseId: string) => !assigned.value.has(courseId))
target.courseIds.push(...ids)
ElMessage.success(`已把 ${ids.length} 门建议课程安排到下一学期`)
}
function clearPlan() {
for (const term of terms.value) term.courseIds = []
}
async function simulate() {
simulationLoading.value = true
try {
simulation.value = (await http.post('/student/academic-planning/simulate', {
terms: terms.value.map((term) => ({
semester: term.semester,
courseIds: term.courseIds,
})),
})).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
simulationLoading.value = false
}
}
function scheduleSimulation() {
if (!initialized.value) return
if (simulationTimer) clearTimeout(simulationTimer)
simulationTimer = setTimeout(simulate, 180)
}
async function load() {
loading.value = true
initialized.value = false
try {
payload.value = (await http.get('/student/academic-planning')).data
terms.value = payload.value.terms.map((term: any) => ({
...term,
courseIds: [],
}))
initialized.value = true
await simulate()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
watch(terms, scheduleSimulation, { deep: true })
onMounted(load)
onBeforeUnmount(() => {
if (simulationTimer) clearTimeout(simulationTimer)
})
</script>
<template>
<div class="page-stack academic-planning-page" v-loading="loading">
<section class="page-intro">
<div>
<span class="section-kicker">DEGREE FLIGHT PLAN</span>
<h2>学业规划与毕业模拟</h2>
<p>把未来课程排进学期航线实时检查培养方案完成度先修顺序与毕业时间</p>
</div>
<div class="page-actions">
<el-button :icon="Refresh" @click="load">重置数据</el-button>
<el-button
type="primary"
:icon="MagicStick"
:disabled="!payload?.nextSemesterSuggestion?.length"
@click="applySuggestion"
>
采用下学期建议
</el-button>
</div>
</section>
<template v-if="payload">
<section class="planning-cockpit">
<div class="student-call-sign">
<span>{{ payload.student.enrollmentYear }} · {{ payload.student.collegeName }}</span>
<h3>{{ payload.student.name }}的毕业航线</h3>
<p>{{ payload.student.studentNumber }} · {{ payload.student.majorName }} · {{ payload.plan.name }} {{ payload.plan.version }}</p>
</div>
<div class="completion-dial" aria-label="模拟培养方案完成度">
<span>方案完成度</span>
<b>{{ projected.completionRate }}<small>%</small></b>
<i><em :style="{ width: `${projected.completionRate}%` }" /></i>
</div>
<div class="arrival-board">
<span>预计毕业</span>
<b>{{ projected.estimatedGraduationTerm }}</b>
<small :class="{ delayed: projected.isBeyondStandard }">
{{ projected.isBeyondStandard ? '超过标准学制' : '标准学制内' }}
</small>
</div>
</section>
<section class="planning-metrics" aria-label="毕业模拟摘要">
<div>
<span>已获 / 预计学分</span>
<b>{{ baseline.earnedCredits }} <small></small> {{ projected.earnedCredits }}</b>
</div>
<div :class="{ alert: projected.creditGap > 0 }">
<span>毕业学分缺口</span>
<b>{{ projected.creditGap }}<small> 学分</small></b>
</div>
<div>
<span>培养要求</span>
<b>{{ projected.passedRequirementCount }}<small> / {{ projected.requirementCount }} </small></b>
</div>
<div>
<span>本次模拟</span>
<b>{{ plannedCourseCount }}<small> · {{ plannedCredits }} 学分</small></b>
</div>
</section>
<section class="next-term-brief">
<header>
<span>NEXT TERM ADVICE</span>
<h3>下一学期建议</h3>
<p>{{ payload.terms[0]?.label }}</p>
</header>
<div v-if="payload.nextSemesterSuggestion.length" class="suggestion-list">
<article
v-for="course in payload.nextSemesterSuggestion"
:key="course.courseId"
>
<div>
<span>{{ course.courseCode }} · {{ typeLabel(course.type) }}</span>
<b>{{ course.courseName }}</b>
</div>
<small>{{ course.credits }} 学分</small>
<p>{{ course.reason }}</p>
</article>
</div>
<p v-else class="no-suggestion">
结合当前在读课程先修条件和培养方案建议学期下一学期暂无需要额外安排的课程仍可在下方课程清单模拟其他路径
</p>
</section>
<section
v-if="payload.latestGraduationAudit"
class="official-audit-strip"
>
<div>
<span>最近正式毕业审核</span>
<b>{{ payload.latestGraduationAudit.batchName }}</b>
<small>{{ payload.latestGraduationAudit.publishedAt?.slice(0, 10) }}</small>
</div>
<p>
正式结果{{ payload.latestGraduationAudit.conclusion === 'Eligible' ? '符合毕业条件' : '暂不符合毕业条件' }}
· 已认定 {{ payload.latestGraduationAudit.earnedCredits }} 学分
</p>
<em>模拟不会更改正式审核结果</em>
</section>
<section
v-if="simulation?.conflicts?.length || simulation?.warnings?.length"
class="planning-alerts"
>
<article v-for="item in simulation.conflicts" :key="`${item.type}-${item.courseId}-${item.prerequisiteCourseId}`">
<span>先修冲突</span>
<p>{{ item.message }}</p>
</article>
<article v-for="(item, index) in simulation.warnings" :key="`${item.type}-${index}`" class="warning">
<span>安排提示</span>
<p>{{ item.message }}</p>
</article>
</section>
<section class="planner-workspace">
<aside class="course-pool">
<header>
<div>
<span>COURSE MANIFEST</span>
<h3>待规划课程</h3>
</div>
<b>{{ remainingCourses.length }}</b>
</header>
<div class="pool-filters">
<el-input
v-model="keyword"
:prefix-icon="Search"
clearable
placeholder="搜索课程"
/>
<el-select v-model="moduleFilter" clearable placeholder="全部培养模块">
<el-option
v-for="module in modules"
:key="module.code"
:label="module.name"
:value="module.code"
/>
</el-select>
</div>
<div class="pool-list">
<article
v-for="course in remainingCourses"
:key="course.courseId"
:class="{
assigned: assigned.has(course.courseId),
conflicted: conflictCourseIds.has(course.courseId),
}"
>
<div class="course-manifest-line">
<span>{{ course.courseCode }} · {{ course.moduleName }}</span>
<i>{{ course.credits }} 学分</i>
</div>
<h4>{{ course.courseName }}</h4>
<div class="course-flags">
<span>{{ typeLabel(course.type) }}</span>
<span>建议第 {{ course.recommendedSemester }} 学期</span>
<span v-if="course.status === 'Failed'" class="failed">有未通过记录</span>
</div>
<p v-if="course.prerequisites.length">
先修
<template v-for="(item, index) in course.prerequisites" :key="item.courseId">
<b :class="{ ready: item.isCompleted || item.isInProgress }">{{ item.courseName }}</b>{{ Number(index) < course.prerequisites.length - 1 ? '、' : '' }}
</template>
</p>
<el-select
:model-value="assigned.get(course.courseId)"
clearable
placeholder="安排到学期"
@change="setCourseSemester(course.courseId, $event)"
>
<el-option
v-for="term in terms"
:key="term.semester"
:label="`第 ${term.semester} 学期 · ${term.label}`"
:value="term.semester"
/>
</el-select>
</article>
<el-empty v-if="!remainingCourses.length" description="没有符合条件的待规划课程" />
</div>
</aside>
<main class="semester-runway" v-loading="simulationLoading">
<header>
<div>
<span>SEMESTER RUNWAY</span>
<h3>未来学期航线</h3>
<p>课程必须在其先修课程之后单学期超过 30 学分会标记负荷风险</p>
</div>
<el-button text :icon="Delete" @click="clearPlan">清空安排</el-button>
</header>
<div class="runway-line">
<article
v-for="term in terms"
:key="term.semester"
class="semester-stop"
:class="{ beyond: term.isBeyondStandard }"
>
<div class="semester-marker">
<i>{{ term.semester }}</i>
<span>{{ term.isBeyondStandard ? '延长学期' : `${term.semester} 学期` }}</span>
</div>
<div class="semester-sheet">
<header>
<div>
<span>{{ term.label }}</span>
<h4>{{ term.semester === payload.plan.nextSemester ? '下一学期' : `未来第 ${term.semester - payload.plan.currentSemester} 学期` }}</h4>
</div>
<b>
{{ termSummaryMap.get(term.semester)?.credits ?? 0 }}
<small>学分</small>
</b>
</header>
<div v-if="term.courseIds.length" class="scheduled-courses">
<article
v-for="courseId in term.courseIds"
:key="courseId"
:class="{ conflicted: conflictCourseIds.has(courseId) }"
>
<div>
<span>{{ courseById(courseId)?.courseCode }} · {{ typeLabel(courseById(courseId)?.type) }}</span>
<b>{{ courseById(courseId)?.courseName }}</b>
</div>
<small>{{ courseById(courseId)?.credits }} 学分</small>
<el-button
text
type="danger"
aria-label="移除课程"
@click="removeCourse(courseId)"
>×</el-button>
</article>
</div>
<p v-else class="empty-semester">尚未安排课程可从左侧课程清单选择学期</p>
</div>
</article>
<div class="graduation-gate" :class="{ ready: projected.graduationConclusion === 'Eligible' }">
<span>GRADUATION GATE</span>
<b>{{ projected.graduationConclusion === 'Eligible' ? '模拟达到毕业条件' : '仍有培养要求未完成' }}</b>
<p>{{ projected.estimatedGraduationTerm }}</p>
</div>
</div>
</main>
</section>
<section class="planning-closeout">
<div>
<span>剩余培养要求</span>
<h3>{{ projected.missingRequirements?.length ? `还需完成 ${projected.missingRequirements.length}` : '培养要求已覆盖' }}</h3>
</div>
<div class="missing-requirements">
<span v-for="item in projected.missingRequirements" :key="item">{{ item }}</span>
<em v-if="!projected.missingRequirements?.length">本次模拟已覆盖全部指定课程和课程组要求</em>
</div>
<ul>
<li v-for="item in payload.assumptions" :key="item">{{ item }}</li>
</ul>
</section>
</template>
</div>
</template>
<style scoped>
.planning-cockpit {
min-height: 154px;
display: grid;
grid-template-columns: minmax(300px, 1.2fr) minmax(230px, .7fr) minmax(280px, .9fr);
color: white;
border: 1px solid #15275b;
background:
linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px),
linear-gradient(rgba(255,255,255,.04) 1px, transparent 1px),
linear-gradient(112deg, #142557, #253e78 63%, #0c6f70);
background-size: 26px 26px, 26px 26px, auto;
}
.student-call-sign,
.completion-dial,
.arrival-board { padding: 26px 28px; }
.student-call-sign > span,
.completion-dial > span,
.arrival-board > span,
.course-pool header span,
.semester-runway > header span {
color: #69d8c7;
font: 700 9px/1.2 Consolas, monospace;
letter-spacing: .12em;
}
.student-call-sign h3 {
margin: 10px 0 7px;
font-family: "STZhongsong", "Songti SC", serif;
font-size: 25px;
letter-spacing: .04em;
}
.student-call-sign p { margin: 0; color: #c8d1e6; font-size: 10px; }
.completion-dial,
.arrival-board {
border-left: 1px solid rgba(255,255,255,.16);
}
.completion-dial b {
display: block;
margin: 12px 0 11px;
font: 700 34px/1 Consolas, monospace;
}
.completion-dial b small { font-size: 13px; }
.completion-dial i {
display: block;
height: 6px;
background: rgba(255,255,255,.17);
}
.completion-dial em {
display: block;
height: 100%;
background: #5fe0c8;
transition: width .2s ease;
}
.arrival-board b {
display: block;
margin: 14px 0 10px;
font-family: "STZhongsong", "Songti SC", serif;
font-size: 16px;
line-height: 1.45;
}
.arrival-board small {
padding-left: 8px;
color: #76e1cf;
border-left: 3px solid #5fe0c8;
font-size: 10px;
}
.arrival-board small.delayed { color: #ffd48a; border-color: #e8a840; }
.planning-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
border: 1px solid var(--line);
background: white;
}
.planning-metrics > div {
min-height: 80px;
padding: 15px 19px;
display: grid;
align-content: center;
border-right: 1px solid var(--line);
box-shadow: inset 0 3px #23827a;
}
.planning-metrics > div:last-child { border-right: 0; }
.planning-metrics > div.alert { box-shadow: inset 0 3px #bd5a4f; }
.planning-metrics span { color: var(--muted); font-size: 9px; }
.planning-metrics b {
margin-top: 8px;
color: var(--ink);
font: 700 21px/1 Consolas, monospace;
}
.planning-metrics small { color: var(--muted); font-size: 9px; }
.next-term-brief {
min-height: 74px;
padding: 13px 16px;
display: grid;
grid-template-columns: 180px 1fr;
gap: 18px;
align-items: center;
border: 1px solid #cfd8e5;
background: #f7f9fc;
}
.next-term-brief > header span {
color: var(--teal);
font: 700 9px/1.2 Consolas, monospace;
letter-spacing: .1em;
}
.next-term-brief > header h3 { margin: 5px 0 3px; font-size: 14px; }
.next-term-brief > header p,
.no-suggestion { margin: 0; color: var(--muted); font-size: 9px; line-height: 1.6; }
.suggestion-list { display: flex; flex-wrap: wrap; gap: 7px; }
.suggestion-list article {
min-width: 220px;
padding: 9px 11px;
display: grid;
grid-template-columns: 1fr auto;
gap: 4px 12px;
border-left: 3px solid #168377;
background: white;
}
.suggestion-list span { color: var(--teal); font-size: 8px; }
.suggestion-list b { display: block; margin-top: 3px; font-size: 10px; }
.suggestion-list small { color: var(--indigo); font: 700 10px/1.2 Consolas, monospace; }
.suggestion-list p {
grid-column: 1 / -1;
margin: 0;
color: var(--muted);
font-size: 8px;
}
.official-audit-strip {
min-height: 58px;
padding: 10px 16px;
display: grid;
grid-template-columns: minmax(220px, .8fr) 1fr auto;
gap: 22px;
align-items: center;
border: 1px solid #cdd6e5;
background: #f5f8fc;
}
.official-audit-strip div { display: grid; gap: 3px; }
.official-audit-strip span { color: var(--teal); font-size: 9px; font-weight: 700; }
.official-audit-strip b { font-size: 11px; }
.official-audit-strip small,
.official-audit-strip p { margin: 0; color: var(--muted); font-size: 9px; }
.official-audit-strip em {
padding: 5px 8px;
color: #52617a;
border: 1px solid #cdd5e1;
font-size: 9px;
font-style: normal;
}
.planning-alerts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.planning-alerts article {
min-height: 55px;
padding: 10px 13px;
display: grid;
grid-template-columns: 74px 1fr;
gap: 10px;
align-items: center;
border: 1px solid #e2b9b5;
background: #fff7f6;
}
.planning-alerts article.warning { border-color: #e4cca0; background: #fffaf0; }
.planning-alerts span { color: #a4423b; font-size: 9px; font-weight: 700; }
.planning-alerts .warning span { color: #9b6417; }
.planning-alerts p { margin: 0; color: #5d4b4b; font-size: 10px; line-height: 1.5; }
.planner-workspace {
display: grid;
grid-template-columns: minmax(310px, 360px) minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.course-pool,
.semester-runway {
border: 1px solid var(--line);
background: white;
}
.course-pool {
position: sticky;
top: 14px;
}
.course-pool > header,
.semester-runway > header {
min-height: 72px;
padding: 15px 17px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--line);
background: #f7f9fc;
}
.course-pool h3,
.semester-runway h3 { margin: 5px 0 0; font-size: 15px; }
.course-pool > header b {
color: var(--indigo);
font: 700 25px/1 Consolas, monospace;
}
.pool-filters {
padding: 11px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
border-bottom: 1px solid var(--line);
}
.pool-list {
max-height: 680px;
overflow: auto;
}
.pool-list > article {
padding: 14px;
border-bottom: 1px solid #e8ebf0;
box-shadow: inset 3px 0 #8d97aa;
transition: background .15s ease, box-shadow .15s ease;
}
.pool-list > article.assigned { background: #f1f8f7; box-shadow: inset 3px 0 #0d8175; }
.pool-list > article.conflicted { background: #fff6f5; box-shadow: inset 3px 0 #b64b43; }
.course-manifest-line {
display: flex;
justify-content: space-between;
gap: 8px;
}
.course-manifest-line span { color: var(--teal); font: 700 9px/1.2 Consolas, monospace; }
.course-manifest-line i { color: var(--muted); font-size: 9px; font-style: normal; }
.pool-list h4 { margin: 7px 0; font-size: 13px; }
.course-flags { display: flex; flex-wrap: wrap; gap: 5px; }
.course-flags span {
padding: 3px 5px;
color: #58657a;
background: #edf0f4;
font-size: 8px;
}
.course-flags span.failed { color: #a43f3b; background: #faeae8; }
.pool-list article > p { margin: 8px 0; color: var(--muted); font-size: 9px; line-height: 1.5; }
.pool-list article > p b { color: #a4423b; font-weight: 600; }
.pool-list article > p b.ready { color: #0b796f; }
.pool-list .el-select { width: 100%; margin-top: 9px; }
.semester-runway > header p { margin: 5px 0 0; color: var(--muted); font-size: 9px; }
.runway-line {
position: relative;
padding: 18px 18px 22px 82px;
}
.runway-line::before {
position: absolute;
top: 31px;
bottom: 64px;
left: 43px;
width: 2px;
content: "";
background: linear-gradient(#233b77, #0d8275 76%, #d0d6df);
}
.semester-stop {
position: relative;
min-height: 116px;
margin-bottom: 13px;
}
.semester-marker {
position: absolute;
top: 15px;
left: -67px;
width: 52px;
display: grid;
justify-items: center;
gap: 5px;
}
.semester-marker i {
width: 31px;
height: 31px;
display: grid;
place-items: center;
color: white;
border: 4px solid white;
outline: 1px solid #263c78;
background: #263c78;
font: 700 11px/1 Consolas, monospace;
font-style: normal;
}
.semester-marker span { color: var(--muted); font-size: 8px; text-align: center; }
.semester-stop.beyond .semester-marker i { outline-color: #a7752a; background: #a7752a; }
.semester-sheet { border: 1px solid #dce1e9; }
.semester-sheet > header {
min-height: 61px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
background: #f8f9fb;
border-bottom: 1px solid #e1e5eb;
}
.semester-sheet header span { color: var(--teal); font-size: 9px; }
.semester-sheet header h4 { margin: 4px 0 0; font-size: 13px; }
.semester-sheet header > b { color: var(--indigo); font: 700 18px/1 Consolas, monospace; }
.semester-sheet header > b small { color: var(--muted); font-size: 8px; }
.scheduled-courses {
padding: 9px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.scheduled-courses article {
min-height: 58px;
padding: 9px 8px 9px 11px;
display: grid;
grid-template-columns: 1fr auto 25px;
gap: 8px;
align-items: center;
border-left: 3px solid #0c8275;
background: #f1f8f7;
}
.scheduled-courses article.conflicted { border-color: #b64b43; background: #fff1ef; }
.scheduled-courses span { color: var(--muted); font-size: 8px; }
.scheduled-courses b { display: block; margin-top: 4px; font-size: 10px; }
.scheduled-courses small { color: var(--muted); font-size: 8px; }
.empty-semester { margin: 0; padding: 18px 14px; color: #9199a8; font-size: 9px; }
.graduation-gate {
min-height: 76px;
padding: 14px 18px;
display: grid;
grid-template-columns: 145px 1fr auto;
gap: 16px;
align-items: center;
color: #5d6676;
border: 1px dashed #aeb6c3;
background: #f7f8fa;
}
.graduation-gate.ready {
color: #075f59;
border-color: #2b9187;
background: #edf8f6;
}
.graduation-gate span { font: 700 9px/1.2 Consolas, monospace; letter-spacing: .1em; }
.graduation-gate b { font-size: 13px; }
.graduation-gate p { margin: 0; font-size: 9px; }
.planning-closeout {
padding: 19px;
display: grid;
grid-template-columns: 190px 1fr minmax(260px, .7fr);
gap: 20px;
align-items: start;
border: 1px solid var(--line);
background: white;
}
.planning-closeout > div:first-child span { color: var(--teal); font-size: 9px; font-weight: 700; }
.planning-closeout h3 { margin: 7px 0 0; font-size: 15px; }
.missing-requirements { display: flex; flex-wrap: wrap; gap: 6px; }
.missing-requirements span {
padding: 5px 7px;
color: #705829;
border: 1px solid #ead6aa;
background: #fff9ed;
font-size: 9px;
}
.missing-requirements em { color: #0d766d; font-size: 10px; font-style: normal; }
.planning-closeout ul { margin: 0; padding-left: 16px; color: var(--muted); font-size: 9px; line-height: 1.7; }
@media (max-width: 1050px) {
.planning-cockpit { grid-template-columns: 1.2fr .8fr; }
.arrival-board { grid-column: 1 / -1; border-top: 1px solid rgba(255,255,255,.16); border-left: 0; }
.planner-workspace { grid-template-columns: 300px minmax(0, 1fr); }
.scheduled-courses { grid-template-columns: 1fr; }
.planning-closeout { grid-template-columns: 170px 1fr; }
.planning-closeout ul { grid-column: 1 / -1; }
}
@media (max-width: 760px) {
.planning-cockpit { grid-template-columns: 1fr; }
.completion-dial,
.arrival-board { border-top: 1px solid rgba(255,255,255,.16); border-left: 0; }
.planning-metrics { grid-template-columns: 1fr 1fr; }
.planning-metrics > div:nth-child(2) { border-right: 0; }
.planning-metrics > div:nth-child(-n + 2) { border-bottom: 1px solid var(--line); }
.official-audit-strip { grid-template-columns: 1fr; gap: 7px; }
.official-audit-strip em { width: max-content; }
.planning-alerts { grid-template-columns: 1fr; }
.next-term-brief { grid-template-columns: 1fr; gap: 8px; }
.planner-workspace { grid-template-columns: 1fr; }
.course-pool { position: static; }
.pool-list { max-height: 460px; }
.runway-line { padding-left: 64px; }
.runway-line::before { left: 31px; }
.semester-marker { left: -55px; }
.graduation-gate { grid-template-columns: 1fr; gap: 6px; }
.planning-closeout { grid-template-columns: 1fr; }
.planning-closeout ul { grid-column: auto; }
}
@media (prefers-reduced-motion: reduce) {
.completion-dial em,
.pool-list > article { transition: none; }
}
</style>
+36 -1
View File
@@ -11,6 +11,7 @@ const rows = ref<any[]>([])
const total = ref(0)
const colleges = ref<any[]>([])
const categories = ref<any[]>([])
const courseOptions = ref<any[]>([])
const dialogVisible = ref(false)
const editingId = ref('')
const importing = ref(false)
@@ -61,6 +62,9 @@ const maintenanceScope = computed(() =>
? `${formColleges.value[0]?.name ?? '所属学院'} · 专业课与实践课`
: '全校课程 · 全性质',
)
const prerequisiteOptions = computed(() =>
courseOptions.value.filter((item) => item.id !== editingId.value),
)
function canManageRow(row: any) {
return row.canManage === true
@@ -81,6 +85,7 @@ function resetForm(row?: any) {
nature: isCollegeAdmin.value ? 'MajorRequired' : 'GeneralRequired',
assessmentMethod: 'Examination',
description: '',
prerequisiteCourseIds: [],
isEnabled: true,
sortOrder: 0,
}, row ?? {})
@@ -168,6 +173,7 @@ async function handleImport(event: Event) {
)
query.page = 1
await load()
courseOptions.value = (await http.get('/courses/options')).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
@@ -203,6 +209,7 @@ async function save() {
ElMessage.success(editingId.value ? '课程已更新' : '课程已加入课程库')
dialogVisible.value = false
await load()
courseOptions.value = (await http.get('/courses/options')).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
@@ -224,12 +231,14 @@ async function remove(row: any) {
}
onMounted(async () => {
const [collegeRes, categoryRes] = await Promise.all([
const [collegeRes, categoryRes, courseRes] = await Promise.all([
http.get('/base-data/colleges'),
http.get('/base-data/course-categories'),
http.get('/courses/options'),
])
colleges.value = collegeRes.data
categories.value = categoryRes.data
courseOptions.value = courseRes.data
await load()
})
</script>
@@ -331,6 +340,14 @@ onMounted(async () => {
<el-table-column label="考核" width="80">
<template #default="{ row }">{{ assessmentLabels[row.assessmentMethod] }}</template>
</el-table-column>
<el-table-column label="先修课程" min-width="180">
<template #default="{ row }">
<span v-if="row.prerequisites?.length" class="prerequisite-list">
{{ row.prerequisites.map((item: any) => item.code).join('、') }}
</span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="状态" width="85">
<template #default="{ row }">
<span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span>
@@ -407,6 +424,24 @@ onMounted(async () => {
<el-form-item label="讲授学时"><el-input-number v-model="form.lectureHours" :min="0" /></el-form-item>
<el-form-item label="实践学时"><el-input-number v-model="form.practiceHours" :min="0" /></el-form-item>
</div>
<el-form-item label="先修课程">
<el-select
v-model="form.prerequisiteCourseIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="选择修读本课程前必须通过的课程"
>
<el-option
v-for="item in prerequisiteOptions"
:key="item.id"
:label="`${item.code} · ${item.name}`"
:value="item.id"
/>
</el-select>
<span class="field-hint">学生规划时会检查这些课程是否已经通过或安排在更早的模拟学期</span>
</el-form-item>
<el-form-item label="课程简介"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item>
<div class="form-grid compact">
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
+4 -1
View File
@@ -88,7 +88,10 @@ onMounted(load)
<h2>我的培养方案</h2>
<p>按培养方案核对已完成在读重修与尚未通过的课程</p>
</div>
<el-button :icon="Refresh" @click="load">刷新进度</el-button>
<div class="page-actions">
<el-button @click="$router.push('/academic-planning')">学业规划与毕业模拟</el-button>
<el-button :icon="Refresh" @click="load">刷新进度</el-button>
</div>
</section>
<section v-if="student" class="student-plan-head">