From 35b4ad6a303020ad0b0ebe59e5e230e538eb9939 Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 1 Oct 2026 14:11:30 +0800 Subject: [PATCH] eat(web): migrate student function --- web-react/MIGRATION_MATRIX.md | 3 + web-react/README.md | 3 +- web-react/src/app/App.tsx | 6 ++ web-react/src/layout/AppShell.tsx | 6 ++ web-react/src/pages/CourseSelectionPage.tsx | 72 ++++++++++++++ web-react/src/pages/StudentAttendancePage.tsx | 60 ++++++++++++ web-react/src/pages/StudentProfilePage.tsx | 82 ++++++++++++++++ web-react/src/pages/student-services.css | 98 +++++++++++++++++++ 8 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 web-react/src/pages/CourseSelectionPage.tsx create mode 100644 web-react/src/pages/StudentAttendancePage.tsx create mode 100644 web-react/src/pages/StudentProfilePage.tsx create mode 100644 web-react/src/pages/student-services.css diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 4b9f2a5..f0fd843 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -13,5 +13,8 @@ | `/exams` | 考试/监考安排 | Student、Teacher | 已发布个人安排、考场、座位、监考教师、历史区分 | verification | | `/grades` | 我的成绩 | Student | 学期筛选、成绩构成、学分绩点、学业指标和排名 | verification | | `/warnings` | 我的学业预警 | Student | 预警确认、确认备注、帮扶与复核进度 | verification | +| `/my-profile` | 我的个人档案 | Student | 学籍只读信息、身份与联系资料、紧急联系人、特殊支持需求 | verification | +| `/my-attendance` | 我的考勤 | Student | 定位/二维码签到、课程归档、出勤汇总、异常记录申诉 | verification | +| `/course-selections` | 学生选课 | Student | 批次窗口、学分预算、教学班余量、选课/退选、候补顺位 | verification | 后续从 `web/src/router/index.ts` 逐条补全。只有关键合同和各状态验收完整后,状态才能更新为 `complete`。 diff --git a/web-react/README.md b/web-react/README.md index 65c15a9..1e7da7a 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -33,6 +33,7 @@ npm.cmd run build - 迁移 `/my-timetable` 的周视图、考试与实验融合、Excel/PDF/ICS 导出和日历订阅管理。 - 迁移 `/exams` 的学生考试与教师监考安排; - 迁移学生 `/grades` 成绩单、绩点排名,以及 `/warnings` 预警确认和帮扶进度。 +- 迁移学生 `/my-profile` 个人档案、`/my-attendance` 定位/二维码签到与申诉、`/course-selections` 选课/退选和候补流程。 ## 迁移纪律 @@ -47,7 +48,7 @@ npm.cmd run build | 批次 | 内容 | 状态 | | --- | --- | --- | | 0 | 工程、主题、会话、登录、应用外壳 | 进行中 | -| 1 | Dashboard、Users、Account、Notifications、个人课表、考试、学生成绩与预警、移动端代表页 | 已实现,进入联调验收 | +| 1 | Dashboard、Users、Account、Notifications、个人课表、考试、学生档案、成绩、考勤、预警、选课与移动端代表页 | 已实现,进入联调验收 | | 2 | 标准 CRUD 与基础数据模块 | 未开始 | | 3 | 学生、教师与审批工作台 | 未开始 | | 4 | 选课、排课、成绩、实验等复杂模块 | 未开始 | diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index 8e80d62..35ce50a 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index fe71a99..34fc236 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -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: , label: roles.includes('Teacher') ? '监考安排' : '考试安排' }] : []), ...(isStudentHome ? [ + { key: '/my-profile', icon: , label: '个人档案' }, { key: '/grades', icon: , label: '我的成绩' }, + { key: '/my-attendance', icon: , label: '我的考勤' }, + { key: '/course-selections', icon: , label: '学生选课' }, { key: '/warnings', icon: , label: '学业预警' }, ] : []), ...(user?.roles.includes('SuperAdmin') diff --git a/web-react/src/pages/CourseSelectionPage.tsx b/web-react/src/pages/CourseSelectionPage.tsx new file mode 100644 index 0000000..7a07b44 --- /dev/null +++ b/web-react/src/pages/CourseSelectionPage.tsx @@ -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() + const [keyword, setKeyword] = useState('') + const [filter, setFilter] = useState('全部') + const rounds = useQuery({ queryKey: ['student-selection-rounds'], queryFn: async () => (await http.get('/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('/course-selections/student/options', { params: { roundId: selectedId } })).data }) + const enrollments = useQuery({ queryKey: ['student-selection-enrollments', selectedRound?.academicTermId], enabled: !!selectedRound, queryFn: async () => (await http.get('/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
+ if (rounds.isError) return
rounds.refetch()}>重新加载} />
+ return
+ } onClick={reload}>刷新名额} /> + {!rounds.data?.length ?
: <> +
} allowClear value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索课程代码、名称、教学班或教师" /> setFilter(String(value))} options={['全部', '已选', '候补', '重修', '有余量']} />
+ {options.isLoading || enrollments.isLoading ? :
{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
{offering.courseCode}{offering.credits}学分
{offering.isRetake ? '重修' : offering.isOpenToAll ? '开放教学班' : '本班可选'}{offering.isFlexible && 灵活安排}{possibleConflict && !offering.enrollmentStatus && 可能时间冲突,以提交校验为准}

{offering.courseName}

{offering.taskNumber} · {offering.teacherNames.join('、') || '教师待定'}

{offering.schedules.length ? offering.schedules.map((schedule, index) => {dayLabels[schedule.dayOfWeek]} 第 {schedule.startPeriod}-{schedule.startPeriod + schedule.periodCount - 1} 节 · {schedule.startWeek}-{schedule.endWeek} 周 · {schedule.classroomName}) : 暂无固定上课时间}
名额{offering.enrolledCount} / {offering.capacity}{offering.waitlistedCount ? `${offering.waitlistedCount} 人候补` : `${Math.max(0, offering.capacity - offering.enrolledCount)} 个余位`}
{offering.enrollmentStatus === 'Enrolled' ? <>已选 : offering.enrollmentStatus === 'Waitlisted' ? <>候补第 {offering.waitlistPosition ?? '-'} 位 : full ? : }
})}{!offerings.length && }
} + } + } +
+} diff --git a/web-react/src/pages/StudentAttendancePage.tsx b/web-react/src/pages/StudentAttendancePage.tsx new file mode 100644 index 0000000..9f73d71 --- /dev/null +++ b/web-react/src/pages/StudentAttendancePage.tsx @@ -0,0 +1,60 @@ +import { useEffect, useMemo, useState } from 'react' +import { AimOutlined, CheckCircleOutlined, EnvironmentOutlined, ReloadOutlined } from '@ant-design/icons' +import { Alert, App, Button, Empty, Input, Modal, Progress, Skeleton, Tag } from 'antd' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useSearchParams } from 'react-router-dom' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './student-services.css' + +interface Activity { sheetId: string; sheetName: string; attendanceDate: string; checkInStartsAt: string; checkInEndsAt: string; locationRadiusMeters?: number; courseCode: string; courseName: string; taskNumber: string; checkInAt?: string; isOpen?: boolean } +interface RecordItem { attendanceSheetId: string; teachingTaskId: string; sheetName: string; attendanceDate: string; taskNumber: string; courseCode: string; courseName: string; teacherNames: string[]; status: string; notes?: string; appealStatus: string; appealReason?: string; appealReviewComment?: string } +const statusLabels: Record = { Present: '出勤', Absent: '缺勤', Late: '迟到', Leave: '请假', Excused: '免考勤' } +const appealLabels: Record = { None: '未申诉', Pending: '处理中', Approved: '已通过', Rejected: '未通过' } + +function deviceId() { + const key = 'jiaowu_attendance_device_id' + let value = localStorage.getItem(key) + if (!value) { value = crypto.randomUUID(); localStorage.setItem(key, value) } + return value +} + +export default function StudentAttendancePage() { + const { message } = App.useApp() + const client = useQueryClient() + const [params, setParams] = useSearchParams() + const token = params.get('token') ?? '' + const [appeal, setAppeal] = useState(null) + const [reason, setReason] = useState('') + const records = useQuery({ queryKey: ['student-attendance-records'], queryFn: async () => (await http.get('/attendance/my-records')).data }) + const activities = useQuery({ queryKey: ['student-open-check-ins'], queryFn: async () => (await http.get('/attendance/open-check-ins')).data, refetchInterval: 30_000 }) + const tokenActivity = useQuery({ queryKey: ['student-check-in-token', token], enabled: !!token, retry: false, queryFn: async () => (await http.get('/attendance/check-in-info', { params: { token } })).data }) + const refresh = () => { void activities.refetch(); void records.refetch(); if (token) void tokenActivity.refetch() } + const checkIn = useMutation({ + mutationFn: async (activity: Activity | null) => { + if (!activity && token) return http.post('/attendance/check-in', { token, deviceId: deviceId(), devicePlatform: 'web' }) + if (!activity) throw new Error('missing activity') + const position = await new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true, timeout: 12_000 })) + return http.post('/attendance/check-in', { attendanceSheetId: activity.sheetId, latitude: position.coords.latitude, longitude: position.coords.longitude, accuracyMeters: position.coords.accuracy, deviceId: deviceId(), devicePlatform: 'web' }) + }, + onSuccess: (response) => { message.success(response.data.alreadyCheckedIn ? '你已完成过签到' : '签到成功'); refresh() }, + onError: (error) => message.error(error instanceof Error && error.name === 'GeolocationPositionError' ? '无法获取当前位置,请允许定位后重试。' : apiErrorMessage(error)), + }) + const submitAppeal = useMutation({ mutationFn: () => http.post('/attendance/records/appeal', { attendanceSheetId: appeal!.attendanceSheetId, reason: reason.trim() }), onSuccess: () => { message.success('申诉已提交'); setAppeal(null); setReason(''); void client.invalidateQueries({ queryKey: ['student-attendance-records'] }) }, onError: (error) => message.error(apiErrorMessage(error)) }) + useEffect(() => { if (tokenActivity.data?.checkInAt) message.info('此活动已完成签到') }, [message, tokenActivity.data?.checkInAt]) + const summary = useMemo(() => { + const effective = (records.data ?? []).filter((item) => item.status !== 'Excused') + const attended = effective.filter((item) => item.status === 'Present' || item.status === 'Late').length + return { total: effective.length, attended, rate: effective.length ? Math.round(attended / effective.length * 100) : 100 } + }, [records.data]) + const grouped = useMemo(() => Object.entries((records.data ?? []).reduce>((groups, item) => { (groups[item.teachingTaskId] ??= []).push(item); return groups }, {})), [records.data]) + if (records.isLoading || activities.isLoading) return
+ if (records.isError || activities.isError) return
} onClick={refresh}>重新加载} />
+ return
+
有效出勤{summary.attended} / {summary.total}
} /> + {token &&
QR CHECK-IN

{tokenActivity.data?.courseName ?? '正在验证签到二维码'}

{tokenActivity.data ? `${tokenActivity.data.courseCode} · ${tokenActivity.data.taskNumber} · ${tokenActivity.data.sheetName}` : '请稍候,系统正在读取当前签到活动。'}

{tokenActivity.isError ? : }
} +
LIVE CHECK-IN

开放中的定位签到

{activities.data?.map((item) =>
{item.courseCode} · {item.taskNumber}

{item.courseName}

{item.sheetName} · {item.locationRadiusMeters} 米范围 · 截止 {new Date(item.checkInEndsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}

)}{!activities.data?.length && }
+
COURSE ARCHIVE

课程考勤记录

{grouped.map(([taskId, items]) => { const first = items![0]; return
{first.courseCode} · {first.taskNumber}

{first.courseName}

{first.teacherNames.join('、') || '任课教师'}

{items!.map((item) =>
{item.sheetName}{statusLabels[item.status] ?? item.status}{item.notes && {item.notes}}{item.appealStatus !== 'None' && 申诉:{appealLabels[item.appealStatus]}{item.appealReviewComment ? ` · ${item.appealReviewComment}` : ''}}{['Absent', 'Late'].includes(item.status) && ['None', 'Rejected'].includes(item.appealStatus) && }
)}
})}{!grouped.length && }
+ submitAppeal.mutate()} onCancel={() => setAppeal(null)}> +
+} diff --git a/web-react/src/pages/StudentProfilePage.tsx b/web-react/src/pages/StudentProfilePage.tsx new file mode 100644 index 0000000..8d88505 --- /dev/null +++ b/web-react/src/pages/StudentProfilePage.tsx @@ -0,0 +1,82 @@ +import { useEffect } from 'react' +import { SaveOutlined, UserOutlined } from '@ant-design/icons' +import { Alert, App, Button, DatePicker, Form, Input, Select, Skeleton, Tag } from 'antd' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import dayjs, { type Dayjs } from 'dayjs' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './student-services.css' + +interface Profile { + studentNumber: string; name: string; className: string; majorName: string; collegeName: string + enrollmentYear: number; enrollmentDate: string; status: string; gender: string; dateOfBirth?: string + englishName?: string; idCardNumber?: string; nationality?: string; ethnicity?: string; politicalStatus?: string + nativePlace?: string; householdAddress?: string; currentAddress?: string; postalCode?: string; phone?: string + email?: string; qq?: string; weChat?: string; emergencyContactName?: string + emergencyContactRelationship?: string; emergencyContactPhone?: string; specialTags?: string + specialNeeds?: string; biography?: string +} +type ProfileForm = Omit & { dateOfBirth?: Dayjs; specialTags?: string[] } +const statusLabels: Record = { Active: '在籍', Suspended: '休学', Graduated: '毕业', Withdrawn: '退学' } + +export default function StudentProfilePage() { + const { message } = App.useApp() + const client = useQueryClient() + const [form] = Form.useForm() + const nationality = Form.useWatch('nationality', form) + const isChinese = nationality === '中国' + const profile = useQuery({ queryKey: ['student-profile'], queryFn: async () => (await http.get('/student/profile')).data }) + useEffect(() => { + if (!profile.data) return + form.setFieldsValue({ ...profile.data, dateOfBirth: profile.data.dateOfBirth ? dayjs(profile.data.dateOfBirth) : undefined, specialTags: profile.data.specialTags?.split(/[,,]/).map((item) => item.trim()).filter(Boolean) ?? [] }) + }, [form, profile.data]) + useEffect(() => { + if (nationality && !isChinese) form.setFieldsValue({ ethnicity: undefined, politicalStatus: undefined, nativePlace: undefined }) + }, [form, isChinese, nationality]) + const save = useMutation({ + mutationFn: async (values: ProfileForm) => http.put('/student/profile', { ...values, dateOfBirth: values.dateOfBirth?.format('YYYY-MM-DD') ?? null, specialTags: values.specialTags?.join(',') || null }), + onSuccess: () => { message.success('个人档案已保存'); void client.invalidateQueries({ queryKey: ['student-profile'] }) }, + onError: (error) => message.error(apiErrorMessage(error)), + }) + if (profile.isLoading) return
+ if (profile.isError || !profile.data) return
profile.refetch()}>重新加载} />
+ return
+ {profile.data.studentNumber}{statusLabels[profile.data.status] ?? profile.data.status}} /> +
姓名{profile.data.name}
学院 / 专业{profile.data.collegeName} · {profile.data.majorName}
行政班{profile.data.className}
入学信息{profile.data.enrollmentYear} 级 · {profile.data.enrollmentDate}
+ +
save.mutate(values)} className="record-form"> + + + + + + + + + + + + + + + + + + + + + +