eat(web): migrate student function

This commit is contained in:
biss committed 2026-10-01 14:11:30 +08:00
1 parent 2c14001677
commit 2a06e37267
8 files changed
+329 -1

No files matched your search

+6
View File
@@ -14,6 +14,9 @@ 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 StudentProfilePage = lazy(() => import('../pages/StudentProfilePage'))
const StudentAttendancePage = lazy(() => import('../pages/StudentAttendancePage'))
const CourseSelectionPage = lazy(() => import('../pages/CourseSelectionPage'))
const SsoCallbackPage = lazy(() => import('../pages/SsoCallbackPage'))
const SsoBindPage = lazy(() => import('../pages/SsoBindPage'))
const AppShell = lazy(() => import('../layout/AppShell'))
@@ -62,6 +65,9 @@ function AppRoutes() {
<Route path="/exams" element={<ExamsPage />} />
<Route path="/grades" element={<StudentGradesPage />} />
<Route path="/warnings" element={<StudentWarningsPage />} />
<Route path="/my-profile" element={<StudentProfilePage />} />
<Route path="/my-attendance" element={<StudentAttendancePage />} />
<Route path="/course-selections" element={<CourseSelectionPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
+6
View File
@@ -11,6 +11,9 @@ import {
FileDoneOutlined,
ReadOutlined,
WarningOutlined,
AuditOutlined,
CheckSquareOutlined,
FormOutlined,
} from '@ant-design/icons'
import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd'
import { useQuery } from '@tanstack/react-query'
@@ -51,7 +54,10 @@ export default function AppShell() {
? [{ key: '/exams', icon: <FileDoneOutlined />, label: roles.includes('Teacher') ? '监考安排' : '考试安排' }]
: []),
...(isStudentHome ? [
{ key: '/my-profile', icon: <AuditOutlined />, label: '个人档案' },
{ key: '/grades', icon: <ReadOutlined />, label: '我的成绩' },
{ key: '/my-attendance', icon: <CheckSquareOutlined />, label: '我的考勤' },
{ key: '/course-selections', icon: <FormOutlined />, label: '学生选课' },
{ key: '/warnings', icon: <WarningOutlined />, label: '学业预警' },
] : []),
...(user?.roles.includes('SuperAdmin')
@@ -0,0 +1,72 @@
import { useMemo, useState } from 'react'
import { ClockCircleOutlined, ReloadOutlined, SearchOutlined, SwapOutlined } from '@ant-design/icons'
import { Alert, App, Button, Empty, Input, Modal, Progress, Segmented, Select, Skeleton, Tag } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import http, { apiErrorMessage } from '../api/http'
import PageHeader from '../components/PageHeader'
import './student-services.css'
interface Round { id: string; name: string; academicTermId: string; termName: string; termIsCurrent: boolean; startsAt: string; endsAt: string; withdrawalEndsAt: string; maxCredits: number; maxCourseCount?: number; scope: string; status: string; isAvailableNow: boolean; notes?: string }
interface Schedule { dayOfWeek: number; startPeriod: number; periodCount: number; startWeek: number; endWeek: number; weekPattern: string; classroomName: string }
interface Offering { id: string; taskNumber: string; courseCode: string; courseName: string; credits: number; teacherNames: string[]; capacity: number; enrolledCount: number; waitlistedCount: number; isOpenToAll: boolean; enrollmentStatus?: string; isFlexible: boolean; isRetake: boolean; schedules: Schedule[]; waitlistPosition?: number }
interface Enrollment { id: string; courseSelectionOfferingId: string; status: string; credits: number; canWithdraw: boolean }
interface OptionsResponse { round: Round; student: { studentNumber: string; name: string; className: string; grade: number }; offerings: Offering[] }
const dayLabels = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
function scheduleOverlaps(left: Schedule, right: Schedule) {
if (left.dayOfWeek !== right.dayOfWeek || left.endWeek < right.startWeek || right.endWeek < left.startWeek) return false
const leftEnd = left.startPeriod + left.periodCount - 1
const rightEnd = right.startPeriod + right.periodCount - 1
if (leftEnd < right.startPeriod || rightEnd < left.startPeriod) return false
return left.weekPattern === 'All' || right.weekPattern === 'All' || left.weekPattern === right.weekPattern
}
export default function CourseSelectionPage() {
const { message } = App.useApp()
const client = useQueryClient()
const [roundId, setRoundId] = useState<string>()
const [keyword, setKeyword] = useState('')
const [filter, setFilter] = useState('全部')
const rounds = useQuery({ queryKey: ['student-selection-rounds'], queryFn: async () => (await http.get<Round[]>('/course-selections/rounds')).data })
const selectedId = roundId ?? rounds.data?.find((item) => item.termIsCurrent && item.isAvailableNow)?.id ?? rounds.data?.find((item) => item.termIsCurrent)?.id ?? rounds.data?.[0]?.id
const selectedRound = rounds.data?.find((item) => item.id === selectedId)
const options = useQuery({ queryKey: ['student-selection-options', selectedId], enabled: !!selectedId, queryFn: async () => (await http.get<OptionsResponse>('/course-selections/student/options', { params: { roundId: selectedId } })).data })
const enrollments = useQuery({ queryKey: ['student-selection-enrollments', selectedRound?.academicTermId], enabled: !!selectedRound, queryFn: async () => (await http.get<Enrollment[]>('/course-selections/student/enrollments', { params: { academicTermId: selectedRound!.academicTermId } })).data })
const activeEnrollments = (enrollments.data ?? []).filter((item) => item.status === 'Enrolled')
const credits = activeEnrollments.reduce((sum, item) => sum + Number(item.credits), 0)
const courseCount = activeEnrollments.length
const reload = () => { void options.refetch(); void enrollments.refetch(); void rounds.refetch() }
const action = useMutation({
mutationFn: async ({ type, offering }: { type: 'enroll' | 'waitlist' | 'remove'; offering: Offering }) => {
if (type === 'enroll') return http.post('/course-selections/student/enrollments', { offeringId: offering.id })
if (type === 'waitlist') return http.post('/course-selections/student/waitlist', { offeringId: offering.id })
const enrollment = enrollments.data?.find((item) => item.courseSelectionOfferingId === offering.id && ['Enrolled', 'Waitlisted'].includes(item.status))
if (!enrollment) throw new Error('未找到有效选课记录')
return http.delete(`/course-selections/student/enrollments/${enrollment.id}`)
},
onSuccess: (response, variables) => { const position = response.data?.position; message.success(variables.type === 'enroll' ? `已选“${variables.offering.courseName}”` : variables.type === 'waitlist' ? `已加入候补${position ? `,当前第 ${position} 位` : ''}` : variables.offering.enrollmentStatus === 'Waitlisted' ? '已取消候补' : '已退选'); void client.invalidateQueries({ queryKey: ['student-selection-options', selectedId] }); void client.invalidateQueries({ queryKey: ['student-selection-enrollments', selectedRound?.academicTermId] }) },
onError: (error) => message.error(error instanceof Error && error.message === '未找到有效选课记录' ? error.message : apiErrorMessage(error)),
})
const offerings = useMemo(() => (options.data?.offerings ?? []).filter((item) => {
const matches = `${item.courseCode} ${item.courseName} ${item.taskNumber} ${item.teacherNames.join(' ')}`.toLowerCase().includes(keyword.trim().toLowerCase())
if (!matches) return false
if (filter === '已选') return item.enrollmentStatus === 'Enrolled'
if (filter === '候补') return item.enrollmentStatus === 'Waitlisted'
if (filter === '重修') return item.isRetake
if (filter === '有余量') return item.enrolledCount < item.capacity && !item.enrollmentStatus
return true
}), [filter, keyword, options.data?.offerings])
if (rounds.isLoading) return <main className="page-container"><Skeleton active paragraph={{ rows: 12 }} /></main>
if (rounds.isError) return <main className="page-container"><Alert type="error" showIcon message="选课批次暂时无法加载" description={apiErrorMessage(rounds.error)} action={<Button onClick={() => rounds.refetch()}>重新加载</Button>} /></main>
return <main className="page-container student-service-page">
<PageHeader kicker="COURSE REGISTRATION" title="学生选课" description="在开放批次中安排课程,实时查看学分预算、教学班余量、候补顺位与上课时间。" aside={<Button icon={<ReloadOutlined />} onClick={reload}>刷新名额</Button>} />
{!rounds.data?.length ? <section className="learning-empty"><Empty description="当前没有可查看的选课批次" /></section> : <>
<section className="round-selector"><Select value={selectedId} onChange={setRoundId} options={rounds.data.map((item) => ({ value: item.id, label: `${item.termName} · ${item.name}` }))} /><div><Tag color={selectedRound?.isAvailableNow ? 'green' : 'default'}>{selectedRound?.isAvailableNow ? '正在开放' : selectedRound?.status === 'Open' ? '不在开放时间' : '已结束'}</Tag><span>{selectedRound && `${new Date(selectedRound.startsAt).toLocaleString('zh-CN')} — ${new Date(selectedRound.endsAt).toLocaleString('zh-CN')}`}</span></div></section>
<section className="credit-budget"><div><span>CREDIT BUDGET</span><h2>{selectedRound?.name}</h2><p>退课截止 {selectedRound && new Date(selectedRound.withdrawalEndsAt).toLocaleString('zh-CN')} · {selectedRound?.scope === 'RetakeOnly' ? '仅限重修' : '普通选课'}</p></div><div className="budget-number"><strong>{credits}</strong><span>/ {selectedRound?.maxCredits ?? 0} 学分</span><Progress percent={selectedRound?.maxCredits ? Math.min(100, Math.round(credits / selectedRound.maxCredits * 100)) : 0} showInfo={false} strokeColor="#54cfbd" /><small>{courseCount}{selectedRound?.maxCourseCount ? ` / ${selectedRound.maxCourseCount}` : ''} 门课程</small></div></section>
{options.isError || enrollments.isError ? <Alert type="error" showIcon message="本批次课程暂时无法加载" description={apiErrorMessage(options.error ?? enrollments.error)} /> : <>
<section className="catalog-tools"><Input prefix={<SearchOutlined />} allowClear value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索课程代码、名称、教学班或教师" /><Segmented value={filter} onChange={(value) => setFilter(String(value))} options={['全部', '已选', '候补', '重修', '有余量']} /></section>
{options.isLoading || enrollments.isLoading ? <Skeleton active paragraph={{ rows: 8 }} /> : <section className="course-catalog">{offerings.map((offering) => { const full = offering.enrolledCount >= offering.capacity; const existing = enrollments.data?.find((item) => item.courseSelectionOfferingId === offering.id && ['Enrolled', 'Waitlisted'].includes(item.status)); const enrolledOfferings = options.data?.offerings.filter((item) => item.enrollmentStatus === 'Enrolled' && item.id !== offering.id) ?? []; const possibleConflict = offering.schedules.some((candidate) => enrolledOfferings.some((selected) => selected.schedules.some((current) => scheduleOverlaps(candidate, current)))); return <article key={offering.id}><div className="course-code"><span>{offering.courseCode}</span><strong>{offering.credits}</strong><small>学分</small></div><div className="course-copy"><div><Tag color={offering.isRetake ? 'purple' : offering.isOpenToAll ? 'blue' : 'default'}>{offering.isRetake ? '重修' : offering.isOpenToAll ? '开放教学班' : '本班可选'}</Tag>{offering.isFlexible && <Tag>灵活安排</Tag>}{possibleConflict && !offering.enrollmentStatus && <Tag color="orange">可能时间冲突,以提交校验为准</Tag>}</div><h2>{offering.courseName}</h2><p>{offering.taskNumber} · {offering.teacherNames.join('、') || '教师待定'}</p><div className="schedule-list">{offering.schedules.length ? offering.schedules.map((schedule, index) => <span key={`${offering.id}-${index}`}><ClockCircleOutlined /> {dayLabels[schedule.dayOfWeek]} 第 {schedule.startPeriod}-{schedule.startPeriod + schedule.periodCount - 1} 节 · {schedule.startWeek}-{schedule.endWeek} 周 · {schedule.classroomName}</span>) : <span>暂无固定上课时间</span>}</div></div><div className="seat-meter"><span>名额</span><strong className={full ? 'is-full' : ''}>{offering.enrolledCount}<small> / {offering.capacity}</small></strong><Progress percent={Math.min(100, Math.round(offering.enrolledCount / Math.max(1, offering.capacity) * 100))} showInfo={false} strokeColor={full ? '#b8781d' : '#087f73'} /><small>{offering.waitlistedCount ? `${offering.waitlistedCount} 人候补` : `${Math.max(0, offering.capacity - offering.enrolledCount)} 个余位`}</small></div><div className="course-action">{offering.enrollmentStatus === 'Enrolled' ? <><Tag color="green">已选</Tag><Button danger disabled={!existing?.canWithdraw} onClick={() => Modal.confirm({ title: `退选“${offering.courseName}”?`, content: '名额释放后可能由候补同学递补。', okText: '确认退选', cancelText: '暂不退选', onOk: () => action.mutateAsync({ type: 'remove', offering }) })}>退选</Button></> : offering.enrollmentStatus === 'Waitlisted' ? <><Tag color="orange">候补第 {offering.waitlistPosition ?? '-'} 位</Tag><Button disabled={!existing?.canWithdraw} onClick={() => Modal.confirm({ title: `取消“${offering.courseName}”候补?`, content: '取消后将失去当前顺位。', okText: '确认取消', cancelText: '继续候补', onOk: () => action.mutateAsync({ type: 'remove', offering }) })}>取消候补</Button></> : full ? <Button icon={<SwapOutlined />} disabled={!selectedRound?.isAvailableNow} loading={action.isPending} onClick={() => action.mutate({ type: 'waitlist', offering })}>加入候补</Button> : <Button type="primary" disabled={!selectedRound?.isAvailableNow} loading={action.isPending} onClick={() => action.mutate({ type: 'enroll', offering })}>选择课程</Button>}</div></article>})}{!offerings.length && <Empty description="没有符合当前筛选的课程" />}</section>}
</>}
</>}
</main>
}
Loaded 3 of 8 files, more files were not shown because too many files have changed in this diff. Show more