feat(web): migrate student academic insights

Add React exam and invigilation schedules, student transcript metrics and score breakdowns, and academic warning acknowledgement with support follow-up timelines. Connect role-aware navigation and dashboard links.
This commit is contained in:
biss authored and biss committed 2026-10-03 21:38:14 +08:00
1 parent e926dd00f2
commit f657290e71
9 files changed
+217 -5

No files matched your search

+6
View File
@@ -11,6 +11,9 @@ const AccountPage = lazy(() => import('../pages/AccountPage'))
const UsersPage = lazy(() => import('../pages/UsersPage'))
const NotificationsPage = lazy(() => import('../pages/NotificationsPage'))
const MyTimetablePage = lazy(() => import('../pages/MyTimetablePage'))
const ExamsPage = lazy(() => import('../pages/ExamsPage'))
const StudentGradesPage = lazy(() => import('../pages/StudentGradesPage'))
const StudentWarningsPage = lazy(() => import('../pages/StudentWarningsPage'))
const SsoCallbackPage = lazy(() => import('../pages/SsoCallbackPage'))
const SsoBindPage = lazy(() => import('../pages/SsoBindPage'))
const AppShell = lazy(() => import('../layout/AppShell'))
@@ -56,6 +59,9 @@ function AppRoutes() {
<Route path="/users" element={<UsersPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/my-timetable" element={<MyTimetablePage />} />
<Route path="/exams" element={<ExamsPage />} />
<Route path="/grades" element={<StudentGradesPage />} />
<Route path="/warnings" element={<StudentWarningsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
+10
View File
@@ -8,6 +8,9 @@ import {
MenuUnfoldOutlined,
SafetyCertificateOutlined,
CalendarOutlined,
FileDoneOutlined,
ReadOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd'
import { useQuery } from '@tanstack/react-query'
@@ -44,6 +47,13 @@ export default function AppShell() {
...((roles.includes('Student') || roles.includes('Teacher'))
? [{ key: '/my-timetable', icon: <CalendarOutlined />, label: '我的课表' }]
: []),
...((roles.includes('Student') || roles.includes('Teacher'))
? [{ key: '/exams', icon: <FileDoneOutlined />, label: roles.includes('Teacher') ? '监考安排' : '考试安排' }]
: []),
...(isStudentHome ? [
{ key: '/grades', icon: <ReadOutlined />, label: '我的成绩' },
{ key: '/warnings', icon: <WarningOutlined />, label: '学业预警' },
] : []),
...(user?.roles.includes('SuperAdmin')
? [{ key: '/users', icon: <SafetyCertificateOutlined />, label: '用户与权限' }]
: []),
+30
View File
@@ -0,0 +1,30 @@
import { ClockCircleOutlined, EnvironmentOutlined, ReloadOutlined, TeamOutlined } from '@ant-design/icons'
import { Alert, Button, Empty, Skeleton, Tag } from 'antd'
import { useQuery } from '@tanstack/react-query'
import http, { apiErrorMessage } from '../api/http'
import PageHeader from '../components/PageHeader'
import { useAuthStore } from '../stores/auth'
import './student-learning.css'
interface Exam { id: string; planName: string; examDate: string; startsAt: string; endsAt: string; taskNumber: string; courseCode: string; courseName: string; classroomName?: string; buildingName?: string; invigilatorNames: string[]; seatNumber?: string; studentCount?: number; isExam: boolean }
function date(value: string) { return new Date(`${value.slice(0, 10)}T00:00:00`) }
function formatDate(value: string) { return new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }).format(date(value)) }
function formatTime(value: string) { return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value)) }
export default function ExamsPage() {
const roles = useAuthStore((state) => state.user?.roles ?? [])
const teacher = roles.includes('Teacher') && !roles.includes('Student')
const query = useQuery({ queryKey: ['my-exams'], queryFn: async () => (await http.get<Exam[]>('/exams/my-schedule')).data })
if (query.isLoading) return <main className="page-container"><Skeleton active paragraph={{ rows: 12 }} /></main>
if (query.isError) return <main className="page-container"><Alert type="error" showIcon message="考试安排暂时无法加载" description={apiErrorMessage(query.error)} action={<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>重新加载</Button>} /></main>
const now = Date.now()
const items = query.data ?? []
const upcoming = items.filter((item) => new Date(item.endsAt).getTime() >= now)
const history = items.filter((item) => new Date(item.endsAt).getTime() < now).reverse()
return <main className="page-container learning-page">
<PageHeader kicker={teacher ? 'INVIGILATION' : 'EXAM SCHEDULE'} title={teacher ? '我的监考安排' : '我的考试安排'} description={teacher ? '查看已发布考试中的监考时间、考场和考生规模。' : '查看已发布考试的时间、考场、座位和监考信息。'} aside={<div className="learning-ledger"><span>UPCOMING</span><strong>{upcoming.length}</strong><small>场待进行</small></div>} />
<section className="exam-timeline">{upcoming.map((item, index) => <article key={item.id}><div className="exam-date-block"><span>{formatDate(item.examDate)}</span><strong>{Math.max(0, Math.ceil((date(item.examDate).getTime() - new Date().setHours(0,0,0,0)) / 86400000))}</strong><small>{index === 0 ? '距离最近安排 / 天' : '天后'}</small></div><div className="exam-card"><header><div><Tag color={item.isExam ? 'blue' : 'default'}>{item.planName}</Tag><span>{item.taskNumber}</span></div><time><ClockCircleOutlined /> {formatTime(item.startsAt)}—{formatTime(item.endsAt)}</time></header><h2>{item.courseName}</h2><p>{item.courseCode}</p><footer><span><EnvironmentOutlined /> {[item.buildingName, item.classroomName].filter(Boolean).join(' · ') || '考场待定'}</span>{teacher ? <span><TeamOutlined /> {item.studentCount ?? 0} 名考生</span> : <span>座位号 <b>{item.seatNumber ?? '待分配'}</b></span>}<span>监考:{item.invigilatorNames.join('、') || '待安排'}</span></footer></div></article>)}</section>
{!upcoming.length && <section className="learning-empty"><Empty description="当前没有待进行的考试安排" /></section>}
{!!history.length && <section className="history-panel"><header><span>HISTORY</span><h2>已结束安排</h2></header><div>{history.map((item) => <article key={item.id}><time>{formatDate(item.examDate)} · {formatTime(item.startsAt)}</time><strong>{item.courseName}</strong><span>{[item.buildingName, item.classroomName].filter(Boolean).join(' · ') || '未记录考场'}</span></article>)}</div></section>}
</main>
}
Loaded 3 of 9 files, more files were not shown because too many files have changed in this diff. Show more