816 lines
27 KiB
Vue
816 lines
27 KiB
Vue
<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>
|