From e28123f1c7e58e742188139d6afc0b458b4bd179 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 2 Oct 2026 13:45:58 +0800 Subject: [PATCH] feat(web): migrate teaching and enrollment administration Add React workflows for teaching eligibility, course equivalency rules, enrollment management, and campus wallet administration. Preserve role-aware navigation, college scope, audit trails, and existing server authorization. --- web-react/MIGRATION_MATRIX.md | 4 ++ web-react/README.md | 1 + web-react/src/app/App.tsx | 8 ++++ web-react/src/layout/AppShell.tsx | 8 ++++ .../src/pages/CampusWalletManagementPage.tsx | 32 +++++++++++++ .../pages/CourseEnrollmentManagementPage.tsx | 37 +++++++++++++++ .../src/pages/CourseEquivalencyRulesPage.tsx | 22 +++++++++ .../src/pages/TeachingPreferencesPage.tsx | 45 +++++++++++++++++++ web-react/src/pages/admin-ledgers.css | 1 + 9 files changed, 158 insertions(+) create mode 100644 web-react/src/pages/CampusWalletManagementPage.tsx create mode 100644 web-react/src/pages/CourseEnrollmentManagementPage.tsx create mode 100644 web-react/src/pages/CourseEquivalencyRulesPage.tsx create mode 100644 web-react/src/pages/TeachingPreferencesPage.tsx create mode 100644 web-react/src/pages/admin-ledgers.css diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 9260289..c764e96 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -37,6 +37,10 @@ | `/approvals` | 我的审批申请 | Student | 免修、缓考、课程替代申请与审核结果 | verification | | `/teacher-roster` | 选课名单 | Teacher | 本人授课教学班、学生联系与特殊支持信息、Excel 导出 | verification | | `/teacher-attendance` | 教学点名 | Teacher、教务管理、Counselor | 教学班筛选、人工/扫码/定位签到、Excel、提交、风险信号与课程统计 | verification | +| `/teaching-preferences` | 授课科目申报/资格审核 | Teacher、教务管理 | 教师申报与撤回、学院审核、直接分配授课资格 | verification | +| `/course-equivalency-rules` | 等价课程规则 | 教务管理 | 规则建立、适用年级、启停与安全删除 | verification | +| `/course-enrollments` | 修读课程管理 | 教务管理 | 课程/学生双视图、多维筛选、退选递补与全量导出 | verification | +| `/campus-wallets` | 学生余额管理 | 教务管理 | 学生筛选、单笔调整、批量导入、模板与余额导出 | verification | | `/activate` | 学生账号激活 | 公开 | 学籍选项、身份匹配、强密码和一次性激活 | verification | | `/verify/:verificationCode?` | 电子凭证验真 | 公开 | 唯一编号、实时状态、签发信息和 PDF 哈希 | verification | | `/classroom-repairs/report/:classroomId` | 教室扫码报修 | 公开 | 教室设备、故障类别、联系方式和工单提交 | verification | diff --git a/web-react/README.md b/web-react/README.md index 1b786fd..dd17b2b 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -42,6 +42,7 @@ npm.cmd run build - 迁移公开账号激活、电子凭证验真、教室扫码报修及场地信息屏。 - 迁移教师选课名单工作台,支持按学期查看本人教学班、学生支持信息和 Excel 导出。 - 迁移教师教学点名工作台,覆盖人工/扫码/定位签到、Excel 导入导出、提交及课程出勤统计。 +- 批量迁移教师授课意愿、等价课程规则、修读课程台账和学生余额管理四个教务工作流。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index 1f6c687..d8721fc 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -38,6 +38,10 @@ const CampusRegistrationsPage = lazy(() => import('../pages/CampusRegistrationsP const StudentApprovalsPage = lazy(() => import('../pages/StudentApprovalsPage')) const TeacherRosterPage = lazy(() => import('../pages/TeacherRosterPage')) const TeacherAttendancePage = lazy(() => import('../pages/TeacherAttendancePage')) +const TeachingPreferencesPage = lazy(() => import('../pages/TeachingPreferencesPage')) +const CourseEquivalencyRulesPage = lazy(() => import('../pages/CourseEquivalencyRulesPage')) +const CampusWalletManagementPage = lazy(() => import('../pages/CampusWalletManagementPage')) +const CourseEnrollmentManagementPage = lazy(() => import('../pages/CourseEnrollmentManagementPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -118,6 +122,10 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index a6d69b7..2b3fc36 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -106,6 +106,14 @@ export default function AppShell() { ...(roles.some((role) => ['Teacher', 'SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role)) ? [ { key: '/teacher-attendance', icon: , label: '教学点名' }, ] : []), + ...(roles.some((role) => ['Teacher', 'SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? [ + { key: '/teaching-preferences', icon: , label: roles.includes('Teacher') && !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? '授课科目申报' : '授课资格审核' }, + ] : []), + ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? [ + { key: '/course-equivalency-rules', icon: , label: '等价课程规则' }, + { key: '/course-enrollments', icon: , label: '修读课程管理' }, + { key: '/campus-wallets', icon: , label: '学生余额管理' }, + ] : []), ...(user?.roles.includes('SuperAdmin') ? [{ key: '/users', icon: , label: '用户与权限' }] : []), diff --git a/web-react/src/pages/CampusWalletManagementPage.tsx b/web-react/src/pages/CampusWalletManagementPage.tsx new file mode 100644 index 0000000..3349868 --- /dev/null +++ b/web-react/src/pages/CampusWalletManagementPage.tsx @@ -0,0 +1,32 @@ +import { BankOutlined, DownloadOutlined, UploadOutlined } from '@ant-design/icons' +import { Alert, App, Button, Empty, Form, Input, InputNumber, Modal, Pagination, Select, Skeleton, Table } from 'antd' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useMemo, useRef, useState } from 'react' +import { downloadApiFile, importExcel } from '../api/files' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './admin-ledgers.css' + +interface WalletStudent { id: string; studentNumber: string; name: string; collegeName: string; majorName: string; className: string; balance: number; lastChangedAt?: string } +interface Option { id: string; name: string } +interface Major extends Option { collegeId: string } +interface ClassOption extends Option { grade: number; majorId: string } +interface Options { colleges: Option[]; majors: Major[]; classes: ClassOption[] } +interface Paged { items: T[]; total: number } +interface Filters { grade?: number; collegeId?: string; majorId?: string; administrativeClassId?: string } +export default function CampusWalletManagementPage() { + const { message } = App.useApp(); const client = useQueryClient(); const fileRef = useRef(null); const [page, setPage] = useState(1); const [keyword, setKeyword] = useState(''); const [search, setSearch] = useState(''); const [filters, setFilters] = useState({}); const [adjusting, setAdjusting] = useState(); const [form] = Form.useForm<{ amount: number; reason: string }>() + const options = useQuery({ queryKey: ['wallet-filter-options'], queryFn: async () => (await http.get('/campus-registrations/wallets/filter-options')).data }) + const wallets = useQuery({ queryKey: ['student-wallets', page, search, filters], enabled: options.isSuccess, queryFn: async () => (await http.get>('/campus-registrations/wallets/students', { params: { keyword: search || undefined, ...filters, page, pageSize: 20 } })).data }) + const majors = useMemo(() => options.data?.majors.filter((item) => !filters.collegeId || item.collegeId === filters.collegeId) ?? [], [filters.collegeId, options.data]) + const classes = useMemo(() => options.data?.classes.filter((item) => (!filters.majorId || item.majorId === filters.majorId) && (!filters.grade || item.grade === filters.grade)) ?? [], [filters.grade, filters.majorId, options.data]) + const grades = useMemo(() => [...new Set((options.data?.classes ?? []).map((item) => item.grade))].sort((a, b) => b - a), [options.data]) + const refresh = () => client.invalidateQueries({ queryKey: ['student-wallets'] }) + async function adjust(values: { amount: number; reason: string }) { if (!adjusting) return; try { await http.post(`/campus-registrations/wallets/students/${adjusting.id}/adjust`, values); message.success('余额已调整'); setAdjusting(undefined); form.resetFields(); await refresh() } catch (error) { message.error(apiErrorMessage(error)) } } + async function importFile(file?: File) { if (!file) return; try { const { data } = await importExcel('/campus-registrations/wallets/import-adjustments', file); message.success(`已调整 ${data.adjusted} 名学生`); await refresh() } catch (error) { message.error(apiErrorMessage(error)) } finally { if (fileRef.current) fileRef.current.value = '' } } + async function download(path: string, name: string) { try { await downloadApiFile(path, name) } catch (error) { message.error(apiErrorMessage(error)) } } + if (options.isLoading) return
+ if (options.isError) return
+ const optionData = options.data! + return
{wallets.data?.total ?? 0}名范围内学生} />
setKeyword(event.target.value)} onPressEnter={() => { setSearch(keyword.trim()); setPage(1) }} /> ({ value: item.id, label: item.name }))} onChange={(value) => { setFilters((item) => ({ ...item, collegeId: value, majorId: undefined, administrativeClassId: undefined })); setPage(1) }} /> ({ value: item.id, label: `${item.grade}级 ${item.name}` }))} onChange={(value) => { setFilters((item) => ({ ...item, administrativeClassId: value })); setPage(1) }} /> void importFile(event.target.files?.[0])} />
{wallets.isError ? : rowKey="id" loading={wallets.isLoading} pagination={false} dataSource={wallets.data?.items} locale={{ emptyText: }} columns={[{ title: '学生', render: (_, item) =>
{item.studentNumber} · {item.name}{item.collegeName} · {item.majorName} · {item.className}
}, { title: '当前余额', dataIndex: 'balance', width: 150, render: (value: number) => ¥{value.toFixed(2)} }, { title: '最后变动', dataIndex: 'lastChangedAt', width: 190, render: (value?: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '从未调整' }, { title: '操作', width: 110, fixed: 'right', render: (_, item) => }]} />}{(wallets.data?.total ?? 0) > 20 && }
setAdjusting(undefined)} onOk={() => form.submit()}>
void adjust(values)}>
+} diff --git a/web-react/src/pages/CourseEnrollmentManagementPage.tsx b/web-react/src/pages/CourseEnrollmentManagementPage.tsx new file mode 100644 index 0000000..2544977 --- /dev/null +++ b/web-react/src/pages/CourseEnrollmentManagementPage.tsx @@ -0,0 +1,37 @@ +import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons' +import { Alert, App, Button, Empty, Input, Pagination, Radio, Select, Skeleton, Table, Tag } from 'antd' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' +import { downloadApiFile } from '../api/files' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import { useAuthStore } from '../stores/auth' +import './admin-ledgers.css' + +type Mode = 'course' | 'student' +interface Option { id: string; name: string; code?: string; collegeId?: string; majorId?: string; grade?: number; enrollmentYear?: number } +interface Paged { items: T[]; total: number } +interface Enrollment { id: string; courseSelectionOfferingId: string; termName: string; courseCode: string; courseName: string; taskNumber: string; credits: number; studentNumber: string; studentName: string; className: string; majorName: string; status: string; exemptionStatus?: string } +interface StudentSummary { studentId: string; studentNumber: string; studentName: string; majorName: string; className: string; grade: number; courseCount: number; activeCredits: number; courses: Enrollment[] } +interface Filters { academicTermId?: string; courseId?: string; collegeId?: string; majorId?: string; administrativeClassId?: string; grade?: number; enrollmentYear?: number; status?: string; enrollmentType?: string; exemptionStatus?: string; keyword?: string } +const statusLabels: Record = { Enrolled: '已修读', Waitlisted: '候补中', Withdrawn: '已退选', Cancelled: '已取消', Expired: '已失效' } +const exemptionLabels: Record = { Submitted: '审核中', Approved: '已批准', Rejected: '已驳回' } +export default function CourseEnrollmentManagementPage() { + const { message, modal } = App.useApp(); const client = useQueryClient(); const user = useAuthStore((state) => state.user); const canAdjust = user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false; const scopedCollegeId = user?.roles.includes('CollegeAdmin') ? user.collegeId : undefined + const [mode, setMode] = useState('course'); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [draft, setDraft] = useState({ collegeId: scopedCollegeId }); const [filters, setFilters] = useState({ collegeId: scopedCollegeId }) + useEffect(() => { if (scopedCollegeId) { setDraft((item) => ({ ...item, collegeId: scopedCollegeId })); setFilters((item) => ({ ...item, collegeId: scopedCollegeId })) } }, [scopedCollegeId]) + const options = useQuery({ queryKey: ['enrollment-management-options'], queryFn: async () => { const [terms, courses, colleges, majors, classes] = await Promise.all([http.get('/base-data/terms'), http.get('/courses/options'), http.get('/base-data/colleges'), http.get('/base-data/majors'), http.get('/base-data/classes')]); return { terms: terms.data, courses: courses.data, colleges: colleges.data, majors: majors.data, classes: classes.data } } }) + const rows = useQuery({ queryKey: ['course-enrollment-management', mode, filters, page, pageSize], enabled: options.isSuccess, queryFn: async () => (await http.get>(mode === 'student' ? '/course-selections/enrollments/students' : '/course-selections/enrollments', { params: { ...filters, keyword: filters.keyword?.trim() || undefined, page, pageSize } })).data }) + const majors = useMemo(() => options.data?.majors.filter((item) => (!draft.collegeId || item.collegeId === draft.collegeId) && (!scopedCollegeId || item.collegeId === scopedCollegeId)) ?? [], [draft.collegeId, options.data, scopedCollegeId]) + const classes = useMemo(() => options.data?.classes.filter((item) => (!draft.majorId || item.majorId === draft.majorId) && (!draft.collegeId || majors.some((major) => major.id === item.majorId))) ?? [], [draft.collegeId, draft.majorId, majors, options.data]) + const grades = useMemo(() => [...new Set((options.data?.classes ?? []).map((item) => item.grade).filter((item): item is number => !!item))].sort((a, b) => b - a), [options.data]); const years = useMemo(() => [...new Set((options.data?.classes ?? []).map((item) => item.enrollmentYear).filter((item): item is number => !!item))].sort((a, b) => b - a), [options.data]) + const refresh = () => client.invalidateQueries({ queryKey: ['course-enrollment-management'] }) + async function exportRows() { try { await downloadApiFile(mode === 'student' ? '/course-selections/enrollments/students/export.xlsx' : '/course-selections/enrollments/export.xlsx', mode === 'student' ? '学生修读汇总.xlsx' : '修读课程名单.xlsx', { params: filters }); message.success(`已导出符合条件的全部 ${rows.data?.total ?? 0} 条结果`) } catch (error) { message.error(apiErrorMessage(error)) } } + function withdraw(item: Enrollment) { modal.confirm({ title: `将 ${item.studentNumber} ${item.studentName} 从《${item.courseName}》移出?`, content: '系统会按原有规则处理候补递补。', okText: '确认退选', okButtonProps: { danger: true }, onOk: async () => { try { await http.delete(`/course-selections/offerings/${item.courseSelectionOfferingId}/admin-enrollments/${item.id}`); message.success('已完成退选处理'); await refresh() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) } + if (options.isLoading) return
+ if (options.isError) return
+ const optionData = options.data! + const filterFields: Array<{ key: keyof Filters; placeholder: string; options: Array<{ value: string | number; label: string }>; disabled?: boolean }> = [{ key: 'academicTermId', placeholder: '全部学期', options: optionData.terms.map((item) => ({ value: item.id, label: item.name })) }, { key: 'collegeId', placeholder: '全部开课院系', disabled: !!scopedCollegeId, options: optionData.colleges.filter((item) => !scopedCollegeId || item.id === scopedCollegeId).map((item) => ({ value: item.id, label: item.name })) }, { key: 'majorId', placeholder: '全部专业', options: majors.map((item) => ({ value: item.id, label: item.name })) }, { key: 'administrativeClassId', placeholder: '全部行政班', options: classes.map((item) => ({ value: item.id, label: item.name })) }, { key: 'grade', placeholder: '全部年级', options: grades.map((value) => ({ value, label: `${value} 级` })) }, { key: 'enrollmentYear', placeholder: '全部入学年份', options: years.map((value) => ({ value, label: `${value} 年入学` })) }, { key: 'courseId', placeholder: '全部课程', options: optionData.courses.map((item) => ({ value: item.id, label: `${item.code} · ${item.name}` })) }, { key: 'status', placeholder: '全部修读状态', options: Object.entries(statusLabels).map(([value, label]) => ({ value, label })) }, { key: 'enrollmentType', placeholder: '全部修读类型', options: [{ value: 'Normal', label: '正常修读' }, { value: 'Retake', label: '重修' }] }, { key: 'exemptionStatus', placeholder: '全部免修状态', options: Object.entries(exemptionLabels).map(([value, label]) => ({ value, label })) }] + const enrollmentColumns = [{ title: '学期', dataIndex: 'termName', width: 130 }, { title: '课程 / 教学班', width: 235, render: (_: unknown, item: Enrollment) =>
{item.courseName}{item.courseCode} · {item.taskNumber} · {item.credits} 学分
}, { title: '学生', width: 175, render: (_: unknown, item: Enrollment) =>
{item.studentNumber}{item.studentName} · {item.className}
}, { title: '专业', dataIndex: 'majorName' }, { title: '修读状态', width: 105, render: (_: unknown, item: Enrollment) => {statusLabels[item.status] ?? item.status} }, { title: '免修', width: 95, render: (_: unknown, item: Enrollment) => item.exemptionStatus ? {exemptionLabels[item.exemptionStatus]} : '—' }, ...(canAdjust ? [{ title: '操作', width: 80, fixed: 'right' as const, render: (_: unknown, item: Enrollment) => item.status === 'Enrolled' ? : null }] : [])] + return
} />
{ setMode(event.target.value); setPage(1) }} options={[{ value: 'course', label: '按课程 / 教学班' }, { value: 'student', label: '按学生汇总' }]} />
{filterFields.map((field) => } value={draft.keyword} placeholder="课程、教学班、学号或姓名" onChange={(event) => setDraft((item) => ({ ...item, keyword: event.target.value }))} onPressEnter={() => { setFilters(draft); setPage(1) }} />

导出不受当前页限制,将按全部筛选条件导出 {rows.data?.total ?? 0} 条{mode === 'student' ? '学生' : '修读记录'}。

{rows.isError ? : mode === 'course' ? rowKey="id" loading={rows.isLoading} pagination={false} scroll={{ x: 1050 }} dataSource={rows.data?.items as Enrollment[]} columns={enrollmentColumns} locale={{ emptyText: }} /> : rowKey="studentId" loading={rows.isLoading} pagination={false} dataSource={rows.data?.items as StudentSummary[]} expandable={{ expandedRowRender: (item) => rowKey="id" size="small" pagination={false} dataSource={item.courses} columns={enrollmentColumns} /> }} columns={[{ title: '学号', dataIndex: 'studentNumber', width: 140 }, { title: '姓名', dataIndex: 'studentName', width: 110 }, { title: '专业', dataIndex: 'majorName' }, { title: '行政班', dataIndex: 'className' }, { title: '年级', dataIndex: 'grade', width: 85, render: (value: number) => `${value} 级` }, { title: '课程数', dataIndex: 'courseCount', width: 85 }, { title: '在修学分', dataIndex: 'activeCredits', width: 100 }]} />}{(rows.data?.total ?? 0) > 0 && { setPage(value); setPageSize(size) }} />}
+} diff --git a/web-react/src/pages/CourseEquivalencyRulesPage.tsx b/web-react/src/pages/CourseEquivalencyRulesPage.tsx new file mode 100644 index 0000000..c30b2ae --- /dev/null +++ b/web-react/src/pages/CourseEquivalencyRulesPage.tsx @@ -0,0 +1,22 @@ +import { DeleteOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons' +import { Alert, App, Button, Empty, Form, Input, InputNumber, Select, Skeleton, Switch, Table, Tag } from 'antd' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './admin-ledgers.css' + +interface Course { id: string; code: string; name: string } +interface Rule { id: string; originalCourseId: string; originalCourseCode: string; originalCourseName: string; substituteCourseId: string; substituteCourseCode: string; substituteCourseName: string; majorId?: string; majorName?: string; effectiveGrade?: number; isEnabled: boolean; notes?: string } +interface FormValues { originalCourseId: string; substituteCourseId: string; effectiveGrade?: number; notes?: string } +export default function CourseEquivalencyRulesPage() { + const { message, modal } = App.useApp(); const client = useQueryClient(); const [form] = Form.useForm() + const query = useQuery({ queryKey: ['course-equivalency-rules'], queryFn: async () => { const [rules, courses] = await Promise.all([http.get('/course-equivalency-rules'), http.get('/curriculum-plans/course-options')]); return { rules: rules.data, courses: courses.data } } }) + const refresh = () => client.invalidateQueries({ queryKey: ['course-equivalency-rules'] }) + const create = useMutation({ mutationFn: (values: FormValues) => http.post('/course-equivalency-rules', { ...values, majorId: null, isEnabled: true }), onSuccess: () => { message.success('等价课程规则已建立'); form.resetFields(); void refresh() }, onError: (error) => message.error(apiErrorMessage(error)) }) + const toggle = useMutation({ mutationFn: (rule: Rule) => http.put(`/course-equivalency-rules/${rule.id}`, { originalCourseId: rule.originalCourseId, substituteCourseId: rule.substituteCourseId, majorId: rule.majorId, effectiveGrade: rule.effectiveGrade, notes: rule.notes, isEnabled: !rule.isEnabled }), onSuccess: () => void refresh(), onError: (error) => message.error(apiErrorMessage(error)) }) + function remove(rule: Rule) { modal.confirm({ title: `删除“${rule.substituteCourseName} → ${rule.originalCourseName}”规则?`, content: '已有课程替代申请引用时不能删除,应停用以保留审批与毕业审核留痕。', okText: '删除', okButtonProps: { danger: true }, onOk: async () => { try { await http.delete(`/course-equivalency-rules/${rule.id}`); message.success('规则已删除'); await refresh() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) } + if (query.isLoading) return
+ if (query.isError) return
+ const data = query.data! + return
{data.rules.filter((item) => item.isEnabled).length}条启用规则} />
create.mutate(values)}> ({ value: item.id, label: `${item.code} · ${item.name}` }))} />
rowKey="id" pagination={false} dataSource={data.rules} locale={{ emptyText: }} columns={[{ title: '等价关系', render: (_, item) =>
{item.substituteCourseCode} {item.substituteCourseName}替代{item.originalCourseCode} {item.originalCourseName}
}, { title: '适用范围', width: 180, render: (_, item) => `${item.majorName || '全部专业'} · ${item.effectiveGrade ? `${item.effectiveGrade} 级` : '全部年级'}` }, { title: '说明', dataIndex: 'notes', render: (value?: string) => value || '—' }, { title: '状态', width: 130, render: (_, item) => toggle.mutate(item)} /> }, { title: '操作', width: 80, fixed: 'right', render: (_, item) => }]} />
+} diff --git a/web-react/src/pages/TeachingPreferencesPage.tsx b/web-react/src/pages/TeachingPreferencesPage.tsx new file mode 100644 index 0000000..a5eb6b2 --- /dev/null +++ b/web-react/src/pages/TeachingPreferencesPage.tsx @@ -0,0 +1,45 @@ +import { PlusOutlined, ReloadOutlined } from '@ant-design/icons' +import { Alert, App, Button, Descriptions, Empty, Form, Input, Modal, Pagination, Radio, Select, Skeleton, Table, Tag } from 'antd' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import { useAuthStore } from '../stores/auth' +import './admin-ledgers.css' + +interface Term { id: string; name: string; isCurrent: boolean; isArchived: boolean; isEnabled: boolean } +interface Course { id: string; code: string; name: string; collegeName: string; credits: number } +interface Teacher { id: string; teacherNumber: string; name: string; collegeName: string; title?: string } +interface Application { id: string; teacherName?: string; teacherNumber?: string; teacherCollegeName?: string; courseCode: string; courseName: string; courseCollegeName?: string; collegeName?: string; termName: string; statement?: string; status: string; reviewComment?: string } +interface Paged { items: T[]; total: number } +interface SubmitForm { academicTermId: string; courseId: string; statement?: string } +interface AssignForm { academicTermId: string; teacherId: string; courseId: string; comment?: string } + +const statusLabels: Record = { Pending: '待审核', Approved: '已通过', Rejected: '已驳回', Withdrawn: '已撤回' } +const statusColors: Record = { Pending: 'gold', Approved: 'green', Rejected: 'red', Withdrawn: 'default' } +const termLabel = (term: Term) => `${term.name}${term.isCurrent ? ' · 当前' : term.isArchived ? ' · 已归档' : !term.isEnabled ? ' · 已停用' : ''}` + +export default function TeachingPreferencesPage() { + const { message, modal } = App.useApp(); const client = useQueryClient(); const user = useAuthStore((state) => state.user) + const reviewer = user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ?? false + const teacher = (user?.roles.includes('Teacher') ?? false) && !reviewer + const [page, setPage] = useState(1); const [termId, setTermId] = useState(); const [status, setStatus] = useState(teacher ? '' : 'Pending'); const [submitOpen, setSubmitOpen] = useState(false); const [assignOpen, setAssignOpen] = useState(false); const [reviewing, setReviewing] = useState(); const [submitForm] = Form.useForm(); const [assignForm] = Form.useForm(); const [reviewForm] = Form.useForm<{ status: string; reviewComment?: string }>() + const terms = useQuery({ queryKey: ['teaching-preference-terms'], queryFn: async () => (await http.get('/base-data/terms')).data }) + const options = useQuery({ queryKey: ['teaching-preference-options', teacher], queryFn: async () => teacher ? { courses: (await http.get('/teacher-course-applications/course-options')).data, teachers: [] as Teacher[] } : (await http.get<{ courses: Course[]; teachers: Teacher[] }>('/teacher-course-applications/assignment-options')).data }) + useEffect(() => { if (terms.data && termId === undefined) setTermId(terms.data.find((item) => item.isCurrent)?.id ?? '') }, [termId, terms.data]) + const applications = useQuery({ queryKey: ['teaching-preferences', teacher, termId ?? 'all', status, page], enabled: terms.isSuccess && options.isSuccess, queryFn: async () => (await http.get>(teacher ? '/teacher-course-applications/mine' : '/teacher-course-applications/reviews', { params: { academicTermId: termId || undefined, status: reviewer ? status || undefined : undefined, page, pageSize: 20 } })).data }) + const refresh = () => client.invalidateQueries({ queryKey: ['teaching-preferences'] }) + const submit = useMutation({ mutationFn: (values: SubmitForm) => http.post('/teacher-course-applications/mine', values), onSuccess: () => { message.success('课程申报已提交,等待学院审核'); setSubmitOpen(false); void refresh() }, onError: (error) => message.error(apiErrorMessage(error)) }) + const assign = useMutation({ mutationFn: (values: AssignForm) => http.post('/teacher-course-applications/assign', values), onSuccess: () => { message.success('授课资格已直接分配'); setAssignOpen(false); void refresh() }, onError: (error) => message.error(apiErrorMessage(error)) }) + const review = useMutation({ mutationFn: (values: { status: string; reviewComment?: string }) => http.post(`/teacher-course-applications/${reviewing?.id}/review`, values), onSuccess: (_, values) => { message.success(values.status === 'Approved' ? '已审核通过' : '已驳回申报'); setReviewing(undefined); void refresh() }, onError: (error) => message.error(apiErrorMessage(error)) }) + function withdraw(item: Application) { modal.confirm({ title: `撤回“${item.courseName}”的授课申报?`, okText: '撤回申报', okButtonProps: { danger: true }, onOk: async () => { try { await http.delete(`/teacher-course-applications/mine/${item.id}`); message.success('申报已撤回'); await refresh() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) } + if (terms.isLoading || options.isLoading) return
+ if (terms.isError || options.isError) return
+ return
} onClick={() => { const current = termId || terms.data?.find((item) => item.isCurrent)?.id; if (teacher) { submitForm.setFieldsValue({ academicTermId: current }); setSubmitOpen(true) } else { assignForm.setFieldsValue({ academicTermId: current }); setAssignOpen(true) } }}>{teacher ? '申报课程' : '直接分配教师'}} /> +
{[['教师', '选择学期与科目'], ['学院', '审核或直接分配'], ['教务', '合班并分配教师'], ['排课', '生成课表']].map(([role, action], index) =>
{role}{action}{index < 3 && →}
)}
+
{ setStatus(value ?? ''); setPage(1) }} options={Object.entries(statusLabels).map(([value, label]) => ({ value, label }))} />}共 {applications.data?.total ?? 0} 条
{applications.isError ? : rowKey="id" loading={applications.isLoading} pagination={false} dataSource={applications.data?.items} locale={{ emptyText: }} columns={[...(teacher ? [] : [{ title: '申报教师', width: 190, render: (_: unknown, item: Application) =>
{item.teacherName}{item.teacherNumber} · {item.teacherCollegeName}
}]), { title: '申报课程', width: 240, render: (_, item) =>
{item.courseName}{item.courseCode} · {item.courseCollegeName || item.collegeName}
}, { title: '学期', dataIndex: 'termName', width: 170 }, { title: '申报说明', dataIndex: 'statement', render: (value?: string) => value || '—' }, { title: '状态', width: 100, render: (_, item) => {statusLabels[item.status]} }, { title: '审核意见', dataIndex: 'reviewComment', render: (value?: string) => value || '—' }, { title: '操作', width: 90, fixed: 'right', render: (_, item) => item.status === 'Pending' ? teacher ? : : 已处理 }]} />}{(applications.data?.total ?? 0) > 20 && }
+ setSubmitOpen(false)} onOk={() => submitForm.submit()}>
submit.mutate(values)}> ({ value: item.id, label: `${item.code} · ${item.name}` }))} />
+ setReviewing(undefined)} onOk={() => reviewForm.submit()}>
review.mutate(values)}>
+ setAssignOpen(false)} onOk={() => assignForm.submit()}>
assign.mutate(values)}> ({ value: item.id, label: `${item.teacherNumber} · ${item.name} · ${item.collegeName}` }))} />