From dcdbb86e2f3d82ddee89bbf8ac85e9f58b81b132 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 2 Oct 2026 08:24:47 +0800 Subject: [PATCH] feat(web): expand React student services Migrate curriculum progress, academic analytics, classroom availability, calendar, student status changes, official documents, graduation audits, tuition, other exams, makeup schedules, and course evaluations. Connect role-aware navigation and preserve existing API workflows. --- web-react/MIGRATION_MATRIX.md | 11 ++++ web-react/README.md | 3 + web-react/src/app/App.tsx | 22 ++++++++ web-react/src/layout/AppShell.tsx | 22 ++++++++ web-react/src/pages/AcademicCalendarPage.tsx | 4 ++ web-react/src/pages/AcademicOverviewPage.tsx | 32 +++++++++++ web-react/src/pages/EvaluationsPage.tsx | 41 ++++++++++++++ web-react/src/pages/FreeClassroomsPage.tsx | 44 +++++++++++++++ web-react/src/pages/GraduationAuditPage.tsx | 3 + web-react/src/pages/MakeupExamsPage.tsx | 38 +++++++++++++ web-react/src/pages/OfficialDocumentsPage.tsx | 8 +++ web-react/src/pages/OtherExamsPage.tsx | 55 +++++++++++++++++++ web-react/src/pages/StudentCurriculumPage.tsx | 49 +++++++++++++++++ .../src/pages/StudentStatusChangesPage.tsx | 30 ++++++++++ web-react/src/pages/TuitionPage.tsx | 3 + web-react/src/pages/student-exams.css | 3 + web-react/src/pages/student-planning.css | 31 +++++++++++ web-react/src/pages/student-records.css | 3 + 18 files changed, 402 insertions(+) create mode 100644 web-react/src/pages/AcademicCalendarPage.tsx create mode 100644 web-react/src/pages/AcademicOverviewPage.tsx create mode 100644 web-react/src/pages/EvaluationsPage.tsx create mode 100644 web-react/src/pages/FreeClassroomsPage.tsx create mode 100644 web-react/src/pages/GraduationAuditPage.tsx create mode 100644 web-react/src/pages/MakeupExamsPage.tsx create mode 100644 web-react/src/pages/OfficialDocumentsPage.tsx create mode 100644 web-react/src/pages/OtherExamsPage.tsx create mode 100644 web-react/src/pages/StudentCurriculumPage.tsx create mode 100644 web-react/src/pages/StudentStatusChangesPage.tsx create mode 100644 web-react/src/pages/TuitionPage.tsx create mode 100644 web-react/src/pages/student-exams.css create mode 100644 web-react/src/pages/student-planning.css create mode 100644 web-react/src/pages/student-records.css diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index f0fd843..2e54349 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -16,5 +16,16 @@ | `/my-profile` | 我的个人档案 | Student | 学籍只读信息、身份与联系资料、紧急联系人、特殊支持需求 | verification | | `/my-attendance` | 我的考勤 | Student | 定位/二维码签到、课程归档、出勤汇总、异常记录申诉 | verification | | `/course-selections` | 学生选课 | Student | 批次窗口、学分预算、教学班余量、选课/退选、候补顺位 | verification | +| `/my-curriculum` | 我的培养方案 | Student | 发布方案匹配、课程模块、修读状态、完成学分与失败记录 | verification | +| `/academic-overview` | 学业成绩总览 | Student | 全学期指标、班级/专业/学院排名、学期趋势 | verification | +| `/free-classrooms` | 空闲教室 | Student | 正式课表、预约占用、周次/节次/校区/容量筛选 | verification | +| `/academic-calendar` | 学术校历 | Student | 当前学期角色日程、月视图、节假日和考试提醒 | verification | +| `/student-status-changes` | 学籍异动 | Student | 休学/复学/退学申请、三级进度、提交前撤回 | verification | +| `/official-documents` | 电子凭证 | Student | 自助签发、短时去重、状态追踪、PDF 下载 | verification | +| `/graduation-audits` | 毕业审核 | Student | 发布结论、学分/必修完成度、缺失课程与复核说明 | verification | +| `/tuition` | 我的学费 | Student | 四类费用明细、已缴/待缴、校园余额分项支付 | verification | +| `/other-exams` | 其他考试 | Student | 最佳成绩、历次考试、通过/等级/分数制结果 | verification | +| `/makeup-exams` | 补考安排 | Student | 已发布场次、补考原因、考场、座位和成绩 | verification | +| `/evaluations` | 教学评价 | Student | 开放任务、动态评分维度、一次性提交 | verification | 后续从 `web/src/router/index.ts` 逐条补全。只有关键合同和各状态验收完整后,状态才能更新为 `complete`。 diff --git a/web-react/README.md b/web-react/README.md index 1e7da7a..8f41605 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -34,6 +34,9 @@ npm.cmd run build - 迁移 `/exams` 的学生考试与教师监考安排; - 迁移学生 `/grades` 成绩单、绩点排名,以及 `/warnings` 预警确认和帮扶进度。 - 迁移学生 `/my-profile` 个人档案、`/my-attendance` 定位/二维码签到与申诉、`/course-selections` 选课/退选和候补流程。 +- 迁移学生 `/my-curriculum` 培养方案进度、`/academic-overview` 学业排名趋势与 `/free-classrooms` 空闲教室查询。 +- 批量迁移学生校历、学籍异动、电子凭证、毕业审核和学费分项缴纳流程。 +- 迁移学生其他考试成绩、补考安排和教学评价提交闭环。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index 35ce50a..2f8b89a 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -17,6 +17,17 @@ 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 StudentCurriculumPage = lazy(() => import('../pages/StudentCurriculumPage')) +const AcademicOverviewPage = lazy(() => import('../pages/AcademicOverviewPage')) +const FreeClassroomsPage = lazy(() => import('../pages/FreeClassroomsPage')) +const StudentStatusChangesPage = lazy(() => import('../pages/StudentStatusChangesPage')) +const OfficialDocumentsPage = lazy(() => import('../pages/OfficialDocumentsPage')) +const GraduationAuditPage = lazy(() => import('../pages/GraduationAuditPage')) +const TuitionPage = lazy(() => import('../pages/TuitionPage')) +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 SsoCallbackPage = lazy(() => import('../pages/SsoCallbackPage')) const SsoBindPage = lazy(() => import('../pages/SsoBindPage')) const AppShell = lazy(() => import('../layout/AppShell')) @@ -68,6 +79,17 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 34fc236..bc584af 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -14,6 +14,17 @@ import { AuditOutlined, CheckSquareOutlined, FormOutlined, + BookOutlined, + LineChartOutlined, + EnvironmentOutlined, + SwapOutlined, + FileProtectOutlined, + SafetyOutlined, + WalletOutlined, + ScheduleOutlined, + TrophyOutlined, + SolutionOutlined, + CommentOutlined, } from '@ant-design/icons' import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd' import { useQuery } from '@tanstack/react-query' @@ -58,6 +69,17 @@ export default function AppShell() { { key: '/grades', icon: , label: '我的成绩' }, { key: '/my-attendance', icon: , label: '我的考勤' }, { key: '/course-selections', icon: , label: '学生选课' }, + { key: '/my-curriculum', icon: , label: '培养方案' }, + { key: '/academic-overview', icon: , label: '学业总览' }, + { key: '/free-classrooms', icon: , label: '空闲教室' }, + { key: '/academic-calendar', icon: , label: '学术校历' }, + { key: '/student-status-changes', icon: , label: '学籍异动' }, + { key: '/official-documents', icon: , label: '电子凭证' }, + { key: '/graduation-audits', icon: , label: '毕业审核' }, + { key: '/tuition', icon: , label: '我的学费' }, + { key: '/other-exams', icon: , label: '其他考试' }, + { key: '/makeup-exams', icon: , label: '补考安排' }, + { key: '/evaluations', icon: , label: '教学评价' }, { key: '/warnings', icon: , label: '学业预警' }, ] : []), ...(user?.roles.includes('SuperAdmin') diff --git a/web-react/src/pages/AcademicCalendarPage.tsx b/web-react/src/pages/AcademicCalendarPage.tsx new file mode 100644 index 0000000..71b3530 --- /dev/null +++ b/web-react/src/pages/AcademicCalendarPage.tsx @@ -0,0 +1,4 @@ +import{useMemo,useState}from'react';import{LeftOutlined,RightOutlined}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'./student-records.css' +interface Event{id:string;title:string;kind:string;startsOn:string;endsOn?:string;reminderDays:number;description?:string;isOffDay?:boolean}const kindLabels:Record={CourseSelection:'选课',Grade:'成绩',Evaluation:'评教',Exam:'考试',GraduationAudit:'毕业审核',PublicHoliday:'节假日',Other:'其他'};const dateKey=(d:Date)=>`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` +interface Exam{id:string;examDate:string;courseName:string;startsAt:string;endsAt:string;buildingName?:string;classroomName?:string} +export default function AcademicCalendarPage(){const[cursor,setCursor]=useState(()=>new Date(new Date().getFullYear(),new Date().getMonth(),1));const q=useQuery({queryKey:['academic-calendar'],queryFn:async()=>{const[calendar,exams]=await Promise.all([http.get('/academic-calendar'),http.get('/exams/my-schedule')]);return[...calendar.data,...exams.data.map(exam=>({id:`exam-${exam.id}`,title:`考试:${exam.courseName}`,kind:'Exam',startsOn:exam.examDate,reminderDays:7,description:`${exam.startsAt.slice(11,16)}—${exam.endsAt.slice(11,16)} · ${[exam.buildingName,exam.classroomName].filter(Boolean).join(' ')}`}))]as Event[]}});const days=useMemo(()=>{const first=new Date(cursor.getFullYear(),cursor.getMonth(),1),start=new Date(first);start.setDate(first.getDate()-((first.getDay()+6)%7));return Array.from({length:42},(_,i)=>{const d=new Date(start);d.setDate(start.getDate()+i);const key=dateKey(d);return{d,key,current:d.getMonth()===cursor.getMonth(),events:(q.data??[]).filter(e=>e.startsOn<=key&&(e.endsOn??e.startsOn)>=key)}})},[cursor,q.data]);if(q.isLoading)return
;if(q.isError)return
;return
}/>
{['一','二','三','四','五','六','日'].map(x=>周{x})}
{days.map(day=>
{day.events.slice(0,3).map(e=>
{kindLabels[e.kind]??e.kind}{e.title}
)}{day.events.length>3&&另有 {day.events.length-3} 项}
)}
本月日程{q.data?.filter(e=>{const s=`${cursor.getFullYear()}-${String(cursor.getMonth()+1).padStart(2,'0')}`;return e.startsOn.startsWith(s)||(e.endsOn??'').startsWith(s)}).length??0} 项
{q.data?.filter(e=>{const month=`${cursor.getFullYear()}-${String(cursor.getMonth()+1).padStart(2,'0')}`;return e.startsOn.startsWith(month)||(e.endsOn??'').startsWith(month)}).map(e=>
{kindLabels[e.kind]??e.kind}{e.title}

{e.description||'暂无补充说明'}

)}{!q.data?.length&&}
} diff --git a/web-react/src/pages/AcademicOverviewPage.tsx b/web-react/src/pages/AcademicOverviewPage.tsx new file mode 100644 index 0000000..864ce83 --- /dev/null +++ b/web-react/src/pages/AcademicOverviewPage.tsx @@ -0,0 +1,32 @@ +import { ReloadOutlined } from '@ant-design/icons' +import { Alert, Button, Empty, Skeleton } from 'antd' +import { useQuery } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './student-planning.css' + +interface Rank { rank?: number; peerCount: number } +interface Metric { value?: number; classRank: Rank; majorRank: Rank; collegeRank: Rank } +interface Trend { academicTermId: string; termName: string; courseCount: number; gpa?: number; weightedAverageScore?: number; averageScore?: number } +interface Overview { isRefreshing: boolean; courseCount: number; calculatedAt?: string; termTrend: Trend[]; metrics: { gpa: Metric; weightedAverageScore: Metric; averageScore: Metric } } +const metricRows = (overview: Overview) => [{ label: 'GPA', hint: '绩点按课程学分加权', metric: overview.metrics.gpa }, { label: '加权平均成绩', hint: '课程总评按课程学分加权', metric: overview.metrics.weightedAverageScore }, { label: '平均成绩', hint: '已发布课程总评的算术平均', metric: overview.metrics.averageScore }] +const rankText = (rank: Rank) => !rank.peerCount ? '—' : rank.rank == null ? `暂未形成(${rank.peerCount} 人)` : `${rank.rank} / ${rank.peerCount}` + +function TrendChart({ data }: { data: Trend[] }) { + const width = Math.max(680, data.length * 120), height = 260, pad = 42 + const points = (key: 'gpa' | 'weightedAverageScore' | 'averageScore', max: number) => data.map((item, index) => { const value = item[key]; return value == null ? null : `${pad + index * (width - pad * 2) / Math.max(1, data.length - 1)},${height - pad - value / max * (height - pad * 2)}` }).filter(Boolean).join(' ') + return
{[0, 1, 2, 3, 4].map((tick) => )}{data.map((item, index) => {item.termName})}
+} + +export default function AcademicOverviewPage() { + const query = useQuery({ queryKey: ['student-academic-overview'], queryFn: async () => (await http.get('/grades/student/academic-overview')).data }) + if (query.isLoading) return
+ if (query.isError || !query.data) return
query.refetch()}>重新加载} />
+ const overview = query.data + return
+ } onClick={() => query.refetch()}>刷新} /> + +
ALL PUBLISHED COURSES

已纳入统计 {overview.courseCount} 门课程

{overview.calculatedAt ? `统计时间 ${new Date(overview.calculatedAt).toLocaleString('zh-CN')}` : '暂无统计时间'}
统计指标我的成绩班级排名专业排名学院排名
{metricRows(overview).map((row) =>
{row.label}{row.hint}
{row.metric.value == null ? '—' : Number(row.metric.value).toFixed(2)}{rankText(row.metric.classRank)}{rankText(row.metric.majorRank)}{rankText(row.metric.collegeRank)}
)}
+
TERM-BY-TERM TREND

各学期学业成绩变化

GPA加权平均平均成绩
{overview.termTrend.length ? : }
+
+} diff --git a/web-react/src/pages/EvaluationsPage.tsx b/web-react/src/pages/EvaluationsPage.tsx new file mode 100644 index 0000000..3677c84 --- /dev/null +++ b/web-react/src/pages/EvaluationsPage.tsx @@ -0,0 +1,41 @@ +import { CheckCircleOutlined, CommentOutlined } from '@ant-design/icons' +import { Alert, App, Button, Empty, Modal, Skeleton, Slider, Tag } from 'antd' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './student-exams.css' + +interface EvaluationTask { teachingTaskId: string; taskNumber: string; taskName: string; courseCode: string; courseName: string; teacherNames: string[]; setupId: string; isEvaluated: boolean } +interface Dimension { id: string; name: string; maxScore: number; sortOrder: number } +interface EvaluationForm { setupId: string; name: string; dimensions: Dimension[]; task: { id: string; taskNumber: string; name: string; courseCode: string; courseName: string; teacherNames: string[] } } + +export default function EvaluationsPage() { + const { message } = App.useApp() + const client = useQueryClient() + const [selected, setSelected] = useState(null) + const [scores, setScores] = useState>({}) + const tasks = useQuery({ queryKey: ['evaluation-my-tasks'], queryFn: async () => (await http.get<{ tasks: EvaluationTask[] }>('/evaluations/my-tasks')).data.tasks }) + const form = useQuery({ queryKey: ['evaluation-form', selected?.teachingTaskId], enabled: !!selected, queryFn: async () => (await http.get(`/evaluations/tasks/${selected!.teachingTaskId}/form`)).data }) + const dimensions = useMemo(() => [...(form.data?.dimensions ?? [])].sort((a, b) => a.sortOrder - b.sortOrder), [form.data]) + const total = dimensions.reduce((sum, item) => sum + (scores[item.id] ?? item.maxScore), 0) + const maximum = dimensions.reduce((sum, item) => sum + item.maxScore, 0) + const close = () => { setSelected(null); setScores({}) } + const submit = useMutation({ + mutationFn: () => http.post(`/evaluations/tasks/${selected!.teachingTaskId}/submit`, { setupId: form.data!.setupId, scores: dimensions.map((item) => ({ dimensionId: item.id, score: scores[item.id] ?? item.maxScore })) }), + onSuccess: () => { message.success('教学评价已提交'); close(); void client.invalidateQueries({ queryKey: ['evaluation-my-tasks'] }) }, + onError: (error) => message.error(apiErrorMessage(error)), + }) + if (tasks.isLoading) return
+ if (tasks.isError) return
+ const pending = tasks.data?.filter((item) => !item.isEvaluated).length ?? 0 + return
+
{pending}项待评价
} /> +
{tasks.data?.map((item) =>
{item.courseCode} · {item.taskNumber}{item.isEvaluated ? '已评价' : '待评价'}

{item.courseName}

{item.taskName}

{item.teacherNames.join('、') || '任课教师'}
)}{!tasks.data?.length && }
+ submit.mutate()} onCancel={close}> + {form.isLoading && } + {form.isError && } + {form.data &&
当前总分{total} / {maximum}
{dimensions.map((item) => )}
} +
+
+} diff --git a/web-react/src/pages/FreeClassroomsPage.tsx b/web-react/src/pages/FreeClassroomsPage.tsx new file mode 100644 index 0000000..75400d2 --- /dev/null +++ b/web-react/src/pages/FreeClassroomsPage.tsx @@ -0,0 +1,44 @@ +import { useEffect, useMemo, useState } from 'react' +import { EnvironmentOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons' +import { Alert, App, Button, Empty, Form, InputNumber, Pagination, Select, Skeleton, Tag } from 'antd' +import { useQuery } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './student-planning.css' + +interface Term { id: string; name: string; startDate: string; endDate: string; isCurrent: boolean; isArchived: boolean; hasPublishedTimetable: boolean } +interface Place { id: string; code: string; name: string; campusId?: string } +interface Slot { periodNumber: number; name: string; startTime: string; endTime: string } +interface Room { id: string; code: string; name: string; capacity: number; buildingId: string; buildingName: string; campusId: string; campusName: string } +interface Options { terms: Term[]; selectedTermId?: string; campuses: Place[]; buildings: Place[]; timeSlots: Slot[] } +interface SearchForm { academicTermId: string; week: number; dayOfWeek: number; startPeriod: number; periodCount: number; campusId?: string; buildingId?: string; minimumCapacity?: number } +const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] +const currentWeek = (term?: Term) => term ? Math.min(30, Math.max(1, Math.floor((Date.now() - new Date(`${term.startDate}T00:00:00`).getTime()) / 604800000) + 1)) : 1 + +export default function FreeClassroomsPage() { + const { message } = App.useApp() + const [form] = Form.useForm() + const [termId, setTermId] = useState() + const [submitted, setSubmitted] = useState() + const [page, setPage] = useState(1) + const options = useQuery({ queryKey: ['free-classroom-options', termId], queryFn: async () => (await http.get('/timetables/free-classrooms/options', { params: termId ? { academicTermId: termId } : undefined })).data }) + useEffect(() => { if (!options.data) return; const selected = termId ?? options.data.selectedTermId; const term = options.data.terms.find((item) => item.id === selected); setTermId(selected); form.setFieldsValue({ academicTermId: selected, week: currentWeek(term), dayOfWeek: new Date().getDay() || 7, startPeriod: options.data.timeSlots[0]?.periodNumber ?? 1, periodCount: 1 }) }, [form, options.data, termId]) + const rooms = useQuery({ queryKey: ['free-classrooms', submitted], enabled: !!submitted, retry: false, queryFn: async () => (await http.get<{ items: Room[] }>('/timetables/free-classrooms', { params: submitted })).data.items }) + const campusId = Form.useWatch('campusId', form) + const buildings = (options.data?.buildings ?? []).filter((item) => !campusId || item.campusId === campusId) + const paged = (rooms.data ?? []).slice((page - 1) * 48, page * 48) + const groups = useMemo(() => Object.values(paged.reduce>((all, room) => { const key = `${room.campusId}:${room.buildingId}`; (all[key] ??= { campusName: room.campusName, buildingName: room.buildingName, rooms: [] }).rooms.push(room); return all }, {})), [paged]) + const summary = submitted ? `第 ${submitted.week} 周 · ${weekdays[submitted.dayOfWeek - 1]} · 第 ${submitted.startPeriod}${submitted.periodCount > 1 ? `—${submitted.startPeriod + submitted.periodCount - 1}` : ''} 节` : '设置条件后查询' + if (options.isLoading) return
+ if (options.isError || !options.data) return
+ return
+ 仅使用正式课表} /> +
{ if (!values.academicTermId) return message.warning('请先选择学期'); setPage(1); setSubmitted(values) }}> + ({ value: index + 1, label }))} /> + form.setFieldValue('buildingId', undefined)} options={options.data.campuses.map((item) => ({ value: item.id, label: item.name }))} />{setFilter(value);setPage(1)}} options={Object.entries(types).map(([value,label])=>({value,label}))}/>
{query.data?.items.map(doc=>
{doc.documentNumber}

{types[doc.type]}

{doc.purpose||'未填写用途'} · {new Date(doc.issuedAt).toLocaleString('zh-CN')} · {doc.issuedByName}

{doc.invalidationReason&&失效原因:{doc.invalidationReason}}
{statuses[doc.status]}已下载 {doc.downloadCount} 次
)}{!query.data?.items.length&&}
{(query.data?.total??0)>20&&}issue.mutate()} onCancel={()=>setOpen(false)}>
} diff --git a/web-react/src/pages/OtherExamsPage.tsx b/web-react/src/pages/OtherExamsPage.tsx new file mode 100644 index 0000000..cbf8e63 --- /dev/null +++ b/web-react/src/pages/OtherExamsPage.tsx @@ -0,0 +1,55 @@ +import { TrophyOutlined } from '@ant-design/icons' +import { Alert, Empty, Skeleton, Tag } from 'antd' +import { useQuery } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './student-exams.css' + +interface ExamResult { + id: string + examCode: string + batchId: string + examName: string + examDate: string + metricKind: string | number + maxScore?: number + levelOptions?: string + attemptNumber: number + score?: number + level?: string + isPassed?: boolean + publishedAt: string +} + +interface MineResponse { best: ExamResult[]; history: ExamResult[] } + +function metricKind(value: string | number) { + if (value === 1 || value === 'PassFail') return 'pass' + if (value === 2 || value === 'Level') return 'level' + return 'score' +} + +function resultText(item: ExamResult) { + const kind = metricKind(item.metricKind) + if (kind === 'pass') return item.isPassed ? '通过' : '未通过' + if (kind === 'level') return item.level || '待公布' + return item.score == null ? '待公布' : `${item.score}${item.maxScore ? ` / ${item.maxScore}` : ''}` +} + +function resultColor(item: ExamResult) { + if (metricKind(item.metricKind) === 'pass') return item.isPassed ? 'green' : 'red' + return item.score == null && !item.level ? 'default' : 'blue' +} + +export default function OtherExamsPage() { + const query = useQuery({ queryKey: ['other-exams-mine'], queryFn: async () => (await http.get('/other-exams/mine')).data }) + if (query.isLoading) return
+ if (query.isError) return
+ const best = query.data?.best ?? [] + const history = query.data?.history ?? [] + return
+
{best.length}已登记考试
} /> +
{best.map((item) =>
{item.examCode}{resultText(item)}

{item.examName}

{item.examDate} · 第 {item.attemptNumber} 次

最佳记录
)}{!best.length && }
+ {!!history.length &&
ATTEMPT HISTORY

历次考试记录

{history.map((item) =>
{item.examName}{item.examCode} · 第 {item.attemptNumber} 次
{resultText(item)}
)}
} +
+} diff --git a/web-react/src/pages/StudentCurriculumPage.tsx b/web-react/src/pages/StudentCurriculumPage.tsx new file mode 100644 index 0000000..84b2919 --- /dev/null +++ b/web-react/src/pages/StudentCurriculumPage.tsx @@ -0,0 +1,49 @@ +import { useMemo, useState } from 'react' +import { ReloadOutlined, SearchOutlined } from '@ant-design/icons' +import { Alert, Button, Empty, Input, Progress, Select, Skeleton, Tag } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './student-planning.css' + +interface LatestResult { totalScore?: number; gradePoint?: number; examStatus: string; termName: string } +interface PlanCourse { id: string; courseCode: string; courseName: string; credits: number; totalHours: number; nature: string; categoryName?: string; recommendedSemester: number; type: string; notes?: string; status: string; hasFailedAttempt: boolean; hasPassedAttempt: boolean; latestResult?: LatestResult } +interface PlanModule { id: string; code: string; name: string; requiredCredits: number; courses: PlanCourse[] } +interface Payload { message?: string; student: { studentNumber: string; name: string; enrollmentYear: number; className: string; majorName: string; collegeName: string }; plan?: { name: string; version: string; totalCredits: number; description?: string; majorName: string; schoolingYears: number; summary: { courseCount: number; completedCourseCount: number; inProgressCourseCount: number; failedCourseCount: number; completedCredits: number; completionRate: number }; modules: PlanModule[] } } +const statusLabels: Record = { Completed: '已完成', InProgress: '在读', Retaking: '重修中', Failed: '未通过', Pending: '待完成', NotStarted: '未修读' } +const statusColors: Record = { Completed: 'green', InProgress: 'blue', Retaking: 'orange', Failed: 'red', Pending: 'purple' } + +function resultText(course: PlanCourse) { + const result = course.latestResult + if (!result) return '暂无成绩' + if (result.examStatus === 'Exempt') return `${result.termName} · 免修通过` + if (result.totalScore != null) return `${result.termName} · ${result.totalScore} 分 · 绩点 ${result.gradePoint ?? '—'}` + return `${result.termName} · ${result.examStatus === 'Deferred' ? '缓考' : result.examStatus === 'Absent' ? '缺考' : '待完成'}` +} + +export default function StudentCurriculumPage() { + const navigate = useNavigate() + const [keyword, setKeyword] = useState('') + const [status, setStatus] = useState('All') + const query = useQuery({ queryKey: ['student-curriculum'], queryFn: async () => (await http.get('/student/curriculum-plan')).data }) + const modules = useMemo(() => (query.data?.plan?.modules ?? []).map((module) => ({ ...module, courses: module.courses.filter((course) => { + const text = `${course.courseCode} ${course.courseName} ${course.categoryName ?? ''}`.toLowerCase() + const statusMatch = status === 'All' || status === 'InProgress' && ['InProgress', 'Retaking'].includes(course.status) || status === 'Failed' && course.hasFailedAttempt && !course.hasPassedAttempt || course.status === status + return text.includes(keyword.trim().toLowerCase()) && statusMatch + }) })).filter((module) => module.courses.length), [keyword, query.data?.plan?.modules, status]) + const count = modules.reduce((sum, module) => sum + module.courses.length, 0) + if (query.isLoading) return
+ if (query.isError || !query.data) return
query.refetch()}>重新加载} />
+ const { student, plan } = query.data + return
+ } /> +
{student.enrollmentYear} 级 · {student.collegeName}

{student.name}

{student.studentNumber} · {student.majorName} · {student.className}

{plan &&
适用方案{plan.version}{plan.schoolingYears} 年制
}
+ {!plan ?
: <> +
{plan.majorName}

{plan.name}

{plan.description || '本页仅展示已正式发布的培养方案及本人实时修读情况。'}

已完成学分{plan.summary.completedCredits}/ {plan.totalCredits}

完成度 {plan.summary.completionRate}%

+
{[['方案课程', plan.summary.courseCount, '门'], ['已完成', plan.summary.completedCourseCount, '门'], ['当前在读', plan.summary.inProgressCourseCount, '门'], ['未通过记录', plan.summary.failedCourseCount, '门']].map(([label, value, unit], index) =>
{label}{value}{unit}
)}
+
} allowClear value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索课程编码、名称或分类" />