diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 2e54349..a5d9e0c 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -27,5 +27,9 @@ | `/other-exams` | 其他考试 | Student | 最佳成绩、历次考试、通过/等级/分数制结果 | verification | | `/makeup-exams` | 补考安排 | Student | 已发布场次、补考原因、考场、座位和成绩 | verification | | `/evaluations` | 教学评价 | Student | 开放任务、动态评分维度、一次性提交 | verification | +| `/academic-planning` | 学业规划与毕业模拟 | Student | 未来学期排课、下学期建议、先修冲突、负荷和毕业时间模拟 | verification | +| `/physical-fitness` | 我的体质健康 | Student | 已发布学年、单项成绩、标准分、附加分和综合等级 | verification | +| `/degree-awards` | 我的学位授予 | Student | 正式发布结论、绩点门槛、规则或人工复核说明 | verification | +| `/graduation-clearance` | 我的毕业离校 | Student | 跨部门事项、必办标记、办结/豁免状态和整体进度 | verification | 后续从 `web/src/router/index.ts` 逐条补全。只有关键合同和各状态验收完整后,状态才能更新为 `complete`。 diff --git a/web-react/README.md b/web-react/README.md index 8f41605..c693b8c 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -37,6 +37,7 @@ npm.cmd run build - 迁移学生 `/my-curriculum` 培养方案进度、`/academic-overview` 学业排名趋势与 `/free-classrooms` 空闲教室查询。 - 批量迁移学生校历、学籍异动、电子凭证、毕业审核和学费分项缴纳流程。 - 迁移学生其他考试成绩、补考安排和教学评价提交闭环。 +- 迁移学生学业规划与毕业模拟、体质健康、学位授予和毕业离校进度。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index 2f8b89a..92e3c25 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index bc584af..6143cec 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -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: , label: '其他考试' }, { key: '/makeup-exams', icon: , label: '补考安排' }, { key: '/evaluations', icon: , label: '教学评价' }, + { key: '/academic-planning', icon: , label: '学业规划' }, + { key: '/physical-fitness', icon: , label: '体质健康' }, + { key: '/degree-awards', icon: , label: '学位授予' }, + { key: '/graduation-clearance', icon: , label: '毕业离校' }, { key: '/warnings', icon: , label: '学业预警' }, ] : []), ...(user?.roles.includes('SuperAdmin') diff --git a/web-react/src/pages/AcademicPlanningPage.tsx b/web-react/src/pages/AcademicPlanningPage.tsx new file mode 100644 index 0000000..f977df1 --- /dev/null +++ b/web-react/src/pages/AcademicPlanningPage.tsx @@ -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('/student/academic-planning')).data }) + const [assignments, setAssignments] = useState>({}) + const simulation = useMutation({ mutationFn: async (value: Record) => (await http.post('/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
+ if (query.isError || !query.data) return
+ return
+ } /> +
{query.data.student.enrollmentYear} 级 · {query.data.student.collegeName}

{query.data.student.name}的毕业航线

{query.data.student.studentNumber} · {query.data.student.majorName} · {query.data.plan.name} {query.data.plan.version}

预计毕业{projected?.estimatedGraduationTerm ?? '等待规划'}
+
预计获得学分{projected?.earnedCredits ?? 0} / {query.data.plan.totalCredits}
0 ? 'warning' : ''}>毕业学分缺口{projected?.creditGap ?? 0}
培养要求{projected?.passedRequirementCount ?? 0} / {projected?.requirementCount ?? 0}
已规划课程{Object.keys(assignments).length}
+ {(simulation.data?.conflicts.length || simulation.data?.warnings.length) ? item.message).join(';')} /> : null} +
{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
SEMESTER {term.semester}

{term.label}

{summary?.credits ?? 0} 学分
{courses.map((course) =>
{course.courseCode}{course.courseName}{course.credits} 学分 · {course.moduleName}
)}{!courses.length && 把下方课程安排到本学期}
})}
+
COURSE POOL

待规划课程

调整学期后会自动重新模拟
{available.map((course) =>
{course.courseCode} · {course.moduleName}

{course.courseName}

{course.credits} 学分 · 建议第 {course.recommendedSemester} 学期{course.prerequisites.length ? ` · 先修 ${course.prerequisites.map((item) => item.courseName).join('、')}` : ''}

{course.isSuggested && 下学期建议}