feat(web): migrate student, public, and teacher workflows

Add React workflows for experiments, campus registration, student approvals, account activation, document verification, classroom repair, venue displays, and teacher rosters. Wire role-aware navigation while preserving existing API authorization boundaries.
This commit is contained in:
biss authored and biss committed 2026-10-03 21:38:14 +08:00
1 parent adeffce03b
commit b19c40f09a
17 files changed
+342

No files matched your search

+18
View File
@@ -32,6 +32,15 @@ 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 StudentExperimentsPage = lazy(() => import('../pages/StudentExperimentsPage'))
const StudentExperimentGradesPage = lazy(() => import('../pages/StudentExperimentGradesPage'))
const CampusRegistrationsPage = lazy(() => import('../pages/CampusRegistrationsPage'))
const StudentApprovalsPage = lazy(() => import('../pages/StudentApprovalsPage'))
const TeacherRosterPage = lazy(() => import('../pages/TeacherRosterPage'))
const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage'))
const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage'))
const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage'))
const VenueDisplayPage = lazy(() => import('../pages/VenueDisplayPage'))
const SsoCallbackPage = lazy(() => import('../pages/SsoCallbackPage'))
const SsoBindPage = lazy(() => import('../pages/SsoBindPage'))
const AppShell = lazy(() => import('../layout/AppShell'))
@@ -71,6 +80,10 @@ function AppRoutes() {
<Route path="/login" element={<LoginPage />} />
<Route path="/sso/callback" element={<SsoCallbackPage />} />
<Route path="/sso/bind" element={<SsoBindPage />} />
<Route path="/activate" element={<AccountActivationPage />} />
<Route path="/verify/:verificationCode?" element={<OfficialDocumentVerifyPage />} />
<Route path="/classroom-repairs/report/:classroomId" element={<ClassroomRepairReportPage />} />
<Route path="/venue-display/:type/:id" element={<VenueDisplayPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/account" element={<AccountPage />} />
@@ -98,6 +111,11 @@ function AppRoutes() {
<Route path="/physical-fitness" element={<PhysicalFitnessPage />} />
<Route path="/degree-awards" element={<DegreeAwardsPage />} />
<Route path="/graduation-clearance" element={<GraduationClearancePage />} />
<Route path="/experiments" element={<StudentExperimentsPage />} />
<Route path="/experiment-grades" element={<StudentExperimentGradesPage />} />
<Route path="/campus-registrations" element={<CampusRegistrationsPage />} />
<Route path="/approvals" element={<StudentApprovalsPage />} />
<Route path="/teacher-roster" element={<TeacherRosterPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
+12
View File
@@ -29,6 +29,11 @@ import {
HeartOutlined,
SafetyCertificateOutlined as DegreeOutlined,
CheckCircleOutlined,
ExperimentOutlined,
FundProjectionScreenOutlined,
BankOutlined,
ApartmentOutlined,
TeamOutlined,
} from '@ant-design/icons'
import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd'
import { useQuery } from '@tanstack/react-query'
@@ -88,8 +93,15 @@ export default function AppShell() {
{ key: '/physical-fitness', icon: <HeartOutlined />, label: '体质健康' },
{ key: '/degree-awards', icon: <DegreeOutlined />, label: '学位授予' },
{ key: '/graduation-clearance', icon: <CheckCircleOutlined />, label: '毕业离校' },
{ key: '/experiments', icon: <ExperimentOutlined />, label: '我的实验' },
{ key: '/experiment-grades', icon: <FundProjectionScreenOutlined />, label: '实验成绩' },
{ key: '/campus-registrations', icon: <BankOutlined />, label: '校园报名' },
{ key: '/approvals', icon: <ApartmentOutlined />, label: '审批申请' },
{ key: '/warnings', icon: <WarningOutlined />, label: '学业预警' },
] : []),
...(roles.includes('Teacher') ? [
{ key: '/teacher-roster', icon: <TeamOutlined />, label: '选课名单' },
] : []),
...(user?.roles.includes('SuperAdmin')
? [{ key: '/users', icon: <SafetyCertificateOutlined />, label: '用户与权限' }]
: []),
@@ -0,0 +1,25 @@
import { SafetyCertificateOutlined } from '@ant-design/icons'
import { Alert, App, Button, Form, Input, Select, Skeleton } from 'antd'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import http, { apiErrorMessage } from '../api/http'
import { branding } from '../config/branding'
import './public-services.css'
interface College { id: string; name: string }
interface Major { id: string; name: string; collegeId: string }
interface ClassOption { id: string; name: string; majorId: string; grade: number }
interface ActivationOptions { colleges: College[]; majors: Major[]; classes: ClassOption[] }
interface ActivationForm { name: string; studentNumber: string; collegeId: string; majorId: string; grade: number; administrativeClassId: string; password: string; confirmPassword: string }
export default function AccountActivationPage() {
const { message } = App.useApp(); const navigate = useNavigate(); const [form] = Form.useForm<ActivationForm>()
const options = useQuery({ queryKey: ['activation-options'], queryFn: async () => (await http.get<ActivationOptions>('/auth/activation-options', { skipAuthSession: true })).data })
const collegeId = Form.useWatch('collegeId', form); const majorId = Form.useWatch('majorId', form); const grade = Form.useWatch('grade', form)
const majors = useMemo(() => options.data?.majors.filter((item) => item.collegeId === collegeId) ?? [], [options.data, collegeId])
const grades = useMemo(() => [...new Set((options.data?.classes ?? []).filter((item) => item.majorId === majorId).map((item) => Number(item.grade)))].sort((a, b) => b - a), [options.data, majorId])
const classes = useMemo(() => options.data?.classes.filter((item) => item.majorId === majorId && Number(item.grade) === grade) ?? [], [options.data, majorId, grade])
const activate = useMutation({ mutationFn: async (value: ActivationForm) => (await http.post<{ userName: string }>('/auth/activate-student', { ...value, name: value.name.trim(), studentNumber: value.studentNumber.trim(), confirmPassword: undefined }, { skipAuthSession: true })).data, onSuccess: (data) => { message.success('账号激活成功,请使用学号和新密码登录'); navigate(`/login?userName=${encodeURIComponent(data.userName)}&activated=1`, { replace: true }) }, onError: (error) => message.error(apiErrorMessage(error)) })
return <main className="public-page activation-public"><header className="public-topbar"><Link to="/login">{branding.shortName}</Link><span>{branding.institutionName} · 学生服务</span></header><section className="public-card activation-card"><div className="public-intro"><SafetyCertificateOutlined /><div><span>STUDENT ACCOUNT ACTIVATION</span><h1>学生账号自助激活</h1><p>按学籍档案填写身份信息,核验通过后学号将成为登录账号。</p></div></div>{options.isLoading ? <Skeleton active paragraph={{ rows: 9 }} /> : options.isError ? <Alert type="error" showIcon message="激活选项无法加载" description={apiErrorMessage(options.error)} /> : <><Alert type="info" showIcon message="每个学号只能激活一次;已激活或忘记密码请联系教务管理员。" /><Form form={form} layout="vertical" className="activation-form" onFinish={(value) => activate.mutate(value)}><div className="public-form-grid"><Form.Item name="name" label="姓名" rules={[{ required: true, whitespace: true, message: '请输入姓名' }]}><Input autoComplete="name" placeholder="与学籍档案一致" /></Form.Item><Form.Item name="studentNumber" label="学号" rules={[{ required: true, whitespace: true, message: '请输入学号' }]}><Input autoComplete="username" placeholder="完整学号" /></Form.Item><Form.Item name="collegeId" label="学院" rules={[{ required: true, message: '请选择学院' }]}><Select showSearch optionFilterProp="label" options={options.data?.colleges.map((item) => ({ value: item.id, label: item.name }))} onChange={() => form.setFieldsValue({ majorId: undefined, grade: undefined, administrativeClassId: undefined })} /></Form.Item><Form.Item name="majorId" label="专业" rules={[{ required: true, message: '请选择专业' }]}><Select disabled={!collegeId} showSearch optionFilterProp="label" options={majors.map((item) => ({ value: item.id, label: item.name }))} onChange={() => form.setFieldsValue({ grade: undefined, administrativeClassId: undefined })} /></Form.Item><Form.Item name="grade" label="年级" rules={[{ required: true, message: '请选择年级' }]}><Select disabled={!majorId} options={grades.map((item) => ({ value: item, label: `${item} 级` }))} onChange={() => form.setFieldValue('administrativeClassId', undefined)} /></Form.Item><Form.Item name="administrativeClassId" label="行政班" rules={[{ required: true, message: '请选择行政班' }]}><Select disabled={!grade} showSearch optionFilterProp="label" options={classes.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item><Form.Item name="password" label="设置密码" rules={[{ required: true }, { pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/, message: '至少 8 位并包含大小写字母、数字和特殊字符' }]}><Input.Password autoComplete="new-password" /></Form.Item><Form.Item name="confirmPassword" label="确认密码" dependencies={['password']} rules={[{ required: true }, ({ getFieldValue }) => ({ validator: (_, value) => !value || getFieldValue('password') === value ? Promise.resolve() : Promise.reject(new Error('两次输入的密码不一致')) })]}><Input.Password autoComplete="new-password" /></Form.Item></div><Button block size="large" type="primary" htmlType="submit" loading={activate.isPending}>核验信息并激活</Button></Form></>}</section></main>
}
Loaded 3 of 17 files, more files were not shown because too many files have changed in this diff. Show more