feat(web): migrate student progress workflows

Add React experiences for academic planning simulations, published physical fitness results, degree award decisions, and graduation clearance tracking. Wire student navigation and document the migrated API contracts.
This commit is contained in:
biss committed 2026-10-02 08:33:52 +08:00
1 parent dcdbb86e2f
commit 0d9dfaf8ff
9 files changed
+117

No files matched your search

+8
View File
@@ -28,6 +28,10 @@ const AcademicCalendarPage = lazy(() => import('../pages/AcademicCalendarPage'))
const OtherExamsPage = lazy(() => import('../pages/OtherExamsPage'))
const MakeupExamsPage = lazy(() => import('../pages/MakeupExamsPage'))
const EvaluationsPage = lazy(() => import('../pages/EvaluationsPage'))
const AcademicPlanningPage = lazy(() => import('../pages/AcademicPlanningPage'))
const PhysicalFitnessPage = lazy(() => import('../pages/PhysicalFitnessPage'))
const DegreeAwardsPage = lazy(() => import('../pages/DegreeAwardsPage'))
const GraduationClearancePage = lazy(() => import('../pages/GraduationClearancePage'))
const SsoCallbackPage = lazy(() => import('../pages/SsoCallbackPage'))
const SsoBindPage = lazy(() => import('../pages/SsoBindPage'))
const AppShell = lazy(() => import('../layout/AppShell'))
@@ -90,6 +94,10 @@ function AppRoutes() {
<Route path="/other-exams" element={<OtherExamsPage />} />
<Route path="/makeup-exams" element={<MakeupExamsPage />} />
<Route path="/evaluations" element={<EvaluationsPage />} />
<Route path="/academic-planning" element={<AcademicPlanningPage />} />
<Route path="/physical-fitness" element={<PhysicalFitnessPage />} />
<Route path="/degree-awards" element={<DegreeAwardsPage />} />
<Route path="/graduation-clearance" element={<GraduationClearancePage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
+8
View File
@@ -25,6 +25,10 @@ import {
TrophyOutlined,
SolutionOutlined,
CommentOutlined,
RocketOutlined,
HeartOutlined,
SafetyCertificateOutlined as DegreeOutlined,
CheckCircleOutlined,
} from '@ant-design/icons'
import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd'
import { useQuery } from '@tanstack/react-query'
@@ -80,6 +84,10 @@ export default function AppShell() {
{ key: '/other-exams', icon: <TrophyOutlined />, label: '其他考试' },
{ key: '/makeup-exams', icon: <SolutionOutlined />, label: '补考安排' },
{ key: '/evaluations', icon: <CommentOutlined />, label: '教学评价' },
{ key: '/academic-planning', icon: <RocketOutlined />, label: '学业规划' },
{ key: '/physical-fitness', icon: <HeartOutlined />, label: '体质健康' },
{ key: '/degree-awards', icon: <DegreeOutlined />, label: '学位授予' },
{ key: '/graduation-clearance', icon: <CheckCircleOutlined />, label: '毕业离校' },
{ key: '/warnings', icon: <WarningOutlined />, label: '学业预警' },
] : []),
...(user?.roles.includes('SuperAdmin')
@@ -0,0 +1,37 @@
import { DeleteOutlined, ExperimentOutlined, ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { Alert, App, Button, Empty, Progress, Select, Skeleton, Tag } from 'antd'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import http, { apiErrorMessage } from '../api/http'
import PageHeader from '../components/PageHeader'
import './student-progress.css'
interface PlanningCourse { courseId: string; courseCode: string; courseName: string; credits: number; recommendedSemester: number; type: string; moduleCode: string; moduleName: string; status: string; isSuggested: boolean; prerequisites: { courseName: string; isCompleted: boolean; isInProgress: boolean }[] }
interface PlanningTerm { semester: number; label: string; isBeyondStandard: boolean }
interface PlanningPayload { student: { studentNumber: string; name: string; enrollmentYear: number; majorName: string; collegeName: string }; plan: { name: string; version: string; totalCredits: number; nextSemester: number }; baseline: Projection; terms: PlanningTerm[]; courses: PlanningCourse[]; nextSemesterSuggestion: PlanningCourse[]; assumptions: string[] }
interface Projection { earnedCredits: number; planCompletedCredits: number; creditGap: number; completionRate: number; requirementCount: number; passedRequirementCount: number; missingRequirements: string[]; graduationConclusion?: string; estimatedGraduationTerm?: string; isBeyondStandard?: boolean }
interface Simulation { projected: Projection; conflicts: { courseId: string; message: string }[]; warnings: { message: string }[]; termSummaries: { semester: number; courseCount: number; credits: number }[] }
export default function AcademicPlanningPage() {
const { message } = App.useApp()
const query = useQuery({ queryKey: ['academic-planning'], queryFn: async () => (await http.get<PlanningPayload>('/student/academic-planning')).data })
const [assignments, setAssignments] = useState<Record<string, number>>({})
const simulation = useMutation({ mutationFn: async (value: Record<string, number>) => (await http.post<Simulation>('/student/academic-planning/simulate', { terms: (query.data?.terms ?? []).map((term) => ({ semester: term.semester, courseIds: Object.entries(value).filter(([, semester]) => semester === term.semester).map(([courseId]) => courseId) })) })).data, onError: (error) => message.error(apiErrorMessage(error)) })
useEffect(() => { if (!query.data) return; const timer = window.setTimeout(() => simulation.mutate(assignments), 180); return () => window.clearTimeout(timer) }, [assignments, query.data])
const available = useMemo(() => (query.data?.courses ?? []).filter((course) => !['Completed', 'InProgress', 'Retaking'].includes(course.status)), [query.data])
const projected = simulation.data?.projected ?? query.data?.baseline
const conflictIds = new Set(simulation.data?.conflicts.map((item) => item.courseId) ?? [])
const assign = (courseId: string, semester?: number) => setAssignments((current) => { const next = { ...current }; if (semester) next[courseId] = semester; else delete next[courseId]; return next })
const applySuggestion = () => { if (!query.data) return; setAssignments((current) => ({ ...current, ...Object.fromEntries(query.data.nextSemesterSuggestion.map((course) => [course.courseId, query.data!.plan.nextSemester])) })); message.success('已采用下学期建议') }
if (query.isLoading) return <main className="page-container"><Skeleton active paragraph={{ rows: 12 }} /></main>
if (query.isError || !query.data) return <main className="page-container"><Alert type="error" showIcon message="学业规划无法加载" description={apiErrorMessage(query.error)} /></main>
return <main className="page-container progress-service-page">
<PageHeader kicker="DEGREE FLIGHT PLAN" title="学业规划与毕业模拟" description="把未完成课程排入未来学期,检查先修顺序、学期负荷和预计毕业时间。" aside={<div className="planning-actions"><Button icon={<ReloadOutlined />} onClick={() => { setAssignments({}); void query.refetch() }}>重置</Button><Button type="primary" icon={<ThunderboltOutlined />} disabled={!query.data.nextSemesterSuggestion.length} onClick={applySuggestion}>采用建议</Button></div>} />
<section className="planning-cockpit"><div><span>{query.data.student.enrollmentYear} 级 · {query.data.student.collegeName}</span><h2>{query.data.student.name}的毕业航线</h2><p>{query.data.student.studentNumber} · {query.data.student.majorName} · {query.data.plan.name} {query.data.plan.version}</p></div><div><Progress type="circle" percent={Math.round(projected?.completionRate ?? 0)} size={72} strokeColor="#54cfbd" /><span>预计毕业</span><strong>{projected?.estimatedGraduationTerm ?? '等待规划'}</strong></div></section>
<section className="planning-metrics"><article><span>预计获得学分</span><strong>{projected?.earnedCredits ?? 0} / {query.data.plan.totalCredits}</strong></article><article className={(projected?.creditGap ?? 0) > 0 ? 'warning' : ''}><span>毕业学分缺口</span><strong>{projected?.creditGap ?? 0}</strong></article><article><span>培养要求</span><strong>{projected?.passedRequirementCount ?? 0} / {projected?.requirementCount ?? 0}</strong></article><article><span>已规划课程</span><strong>{Object.keys(assignments).length}</strong></article></section>
{(simulation.data?.conflicts.length || simulation.data?.warnings.length) ? <Alert type="warning" showIcon message="规划需要调整" description={[...(simulation.data?.conflicts ?? []), ...(simulation.data?.warnings ?? [])].map((item) => item.message).join(';')} /> : null}
<section className="planning-board">{query.data.terms.map((term) => { const courses = available.filter((course) => assignments[course.courseId] === term.semester); const summary = simulation.data?.termSummaries.find((item) => item.semester === term.semester); return <article key={term.semester} className={term.isBeyondStandard ? 'beyond' : ''}><header><div><span>SEMESTER {term.semester}</span><h3>{term.label}</h3></div><Tag color={term.isBeyondStandard ? 'orange' : 'blue'}>{summary?.credits ?? 0} 学分</Tag></header><div>{courses.map((course) => <div key={course.courseId} className={conflictIds.has(course.courseId) ? 'conflict' : ''}><span>{course.courseCode}</span><strong>{course.courseName}</strong><small>{course.credits} 学分 · {course.moduleName}</small><Button type="text" danger icon={<DeleteOutlined />} aria-label={`移除${course.courseName}`} onClick={() => assign(course.courseId)} /></div>)}{!courses.length && <small className="term-empty">把下方课程安排到本学期</small>}</div></article>})}</section>
<section className="course-pool"><header><div><span>COURSE POOL</span><h2>待规划课程</h2></div><small>调整学期后会自动重新模拟</small></header><div>{available.map((course) => <article key={course.courseId}><div><span>{course.courseCode} · {course.moduleName}</span><h3>{course.courseName}</h3><p>{course.credits} 学分 · 建议第 {course.recommendedSemester} 学期{course.prerequisites.length ? ` · 先修 ${course.prerequisites.map((item) => item.courseName).join('、')}` : ''}</p></div>{course.isSuggested && <Tag color="cyan">下学期建议</Tag>}<Select allowClear placeholder="选择学期" value={assignments[course.courseId]} options={query.data.terms.map((term) => ({ value: term.semester, label: term.label }))} onChange={(value) => assign(course.courseId, value)} /></article>)}</div>{!available.length && <Empty description="培养方案课程均已完成或在读" />}</section>
<section className="assumption-strip"><ExperimentOutlined />{query.data.assumptions.map((item) => <span key={item}>{item}</span>)}</section>
</main>
}
Loaded 3 of 9 files, more files were not shown because too many files have changed in this diff. Show more