diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 4e3afb1..482e173 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -45,6 +45,7 @@ | `/base-data/terms` | 学年学期 | SuperAdmin、AcademicAdmin | 当前学期切换、手动归档/恢复、预检查、原因和操作历史 | verification | | `/base-data/facilities` | 教学场所 | SuperAdmin、AcademicAdmin | 教学楼、教室、容量/场地性质筛选、Excel | verification | | `/base-data/course-categories` | 课程分类 | SuperAdmin、AcademicAdmin | 分类增删改查、启停、排序和 Excel | verification | +| `/courses` | 课程库 | 已登录用户;教务与学院管理员可维护 | 游标分页、数据范围、先修关系、分类/性质、Excel、归档审计 | 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 772c0f3..b72ef8a 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -44,6 +44,7 @@ npm.cmd run build - 迁移教师教学点名工作台,覆盖人工/扫码/定位签到、Excel 导入导出、提交及课程出勤统计。 - 批量迁移教师授课意愿、等价课程规则、修读课程台账和学生余额管理四个教务工作流。 - 迁移组织机构、学年学期、教学场所与课程分类基础数据,保留 Excel、服务端分页和学期归档审计闭环。 +- 迁移课程库游标台账,保留学院维护范围、先修关系、筛选导出、批量导入与归档审计。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index cedbad0..fcd40b5 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -43,6 +43,7 @@ const CourseEquivalencyRulesPage = lazy(() => import('../pages/CourseEquivalency const CampusWalletManagementPage = lazy(() => import('../pages/CampusWalletManagementPage')) const CourseEnrollmentManagementPage = lazy(() => import('../pages/CourseEnrollmentManagementPage')) const BaseDataPage = lazy(() => import('../pages/BaseDataPage')) +const CourseCataloguePage = lazy(() => import('../pages/CourseCataloguePage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -131,6 +132,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index ecb28fb..c17ec95 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -69,6 +69,7 @@ export default function AppShell() { { key: '/dashboard', icon: , label: isStudentHome ? '学习首页' : isTeacherHome ? '教学工作台' : '教务总览' }, { key: '/account', icon: , label: '个人账户' }, { key: '/notifications', icon: , label: '通知中心' }, + { key: '/courses', icon: , label: '课程库' }, ...((roles.includes('Student') || roles.includes('Teacher')) ? [{ key: '/my-timetable', icon: , label: '我的课表' }] : []), diff --git a/web-react/src/pages/CourseCataloguePage.tsx b/web-react/src/pages/CourseCataloguePage.tsx new file mode 100644 index 0000000..58539b7 --- /dev/null +++ b/web-react/src/pages/CourseCataloguePage.tsx @@ -0,0 +1,174 @@ +import { useMemo, useRef, useState } from 'react' +import { + BookOutlined, + DeleteOutlined, + DownloadOutlined, + HistoryOutlined, + PlusOutlined, + ReloadOutlined, + UploadOutlined, +} from '@ant-design/icons' +import { + Alert, + App, + Button, + Empty, + Form, + Input, + InputNumber, + Modal, + Select, + Skeleton, + Switch, + Table, + Tag, +} from 'antd' +import type { ColumnsType, TableRowSelection } from 'antd/es/table/interface' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import { downloadApiFile, importExcel } from '../api/files' +import PageHeader from '../components/PageHeader' +import { useAuthStore } from '../stores/auth' +import './course-catalogue.css' + +interface Option { id: string; code?: string; name: string; isEnabled?: boolean; [key: string]: unknown } +interface Prerequisite { prerequisiteCourseId: string; code: string; name: string } +interface Course { + id: string; code: string; name: string; englishName?: string; collegeId: string; collegeName: string; + courseCategoryId: string; categoryName?: string; credits: number; totalHours: number; lectureHours: number; + practiceHours: number; nature: string; assessmentMethod: string; description?: string; + prerequisiteCourseIds: string[]; prerequisites: Prerequisite[]; isEnabled: boolean; sortOrder: number; + isArchived: boolean; canManage: boolean +} +interface CursorPage { items: Course[]; nextCursor?: string; hasMore: boolean; pageSize: number; total: number } +interface Filters { keyword: string; collegeId?: string; categoryId?: string; nature?: string; isEnabled?: boolean; isArchived?: boolean } +interface ArchivePreview { id: string; name?: string; isArchived?: boolean; archiveReason?: string; error?: string; blockers?: { message: string }[]; notices?: { message: string }[] } +interface ArchiveResult { id: string; success: boolean; message: string } +interface ArchiveHistory { id: string; isArchived: boolean; actorName?: string; actorUserId?: string; createdAt: string; reason: string } + +const natureLabels: Record = { + GeneralRequired: '通识必修', MajorRequired: '专业必修', MajorElective: '专业选修', + GeneralElective: '通识选修', Practice: '实践课程', +} +const assessmentLabels: Record = { Examination: '考试', Assessment: '考查' } + +export default function CourseCataloguePage() { + const { message, modal } = App.useApp() + const client = useQueryClient() + const user = useAuthStore((state) => state.user) + const fileRef = useRef(null) + const roles = user?.roles ?? [] + const canManage = roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) + const isCollegeAdmin = roles.includes('CollegeAdmin') + const scopedCollegeId = isCollegeAdmin ? user?.collegeId : undefined + const initialFilters: Filters = { keyword: '', collegeId: scopedCollegeId, isArchived: false } + const [draft, setDraft] = useState(initialFilters) + const [filters, setFilters] = useState(initialFilters) + const [pageSize, setPageSize] = useState(20) + const [cursors, setCursors] = useState([]) + const [selectedIds, setSelectedIds] = useState([]) + const [editing, setEditing] = useState() + const [importing, setImporting] = useState(false) + const [form] = Form.useForm() + const [archive, setArchive] = useState<{ open: boolean; target: boolean; previews: ArchivePreview[]; results: ArchiveResult[]; reason: string; busy: boolean }>({ open: false, target: true, previews: [], results: [], reason: '', busy: false }) + const [history, setHistory] = useState<{ open: boolean; page: number; busy: boolean; entries: ArchiveHistory[]; id?: string }>({ open: false, page: 1, busy: false, entries: [] }) + const cursor = cursors.at(-1) + + const options = useQuery({ + queryKey: ['course-catalogue-options'], + queryFn: async () => { + const [colleges, categories, courses] = await Promise.all([ + http.get('/base-data/colleges'), http.get('/base-data/course-categories'), http.get('/courses/options'), + ]) + return { colleges: colleges.data, categories: categories.data, courses: courses.data } + }, + }) + const courses = useQuery({ + queryKey: ['course-catalogue', cursor, pageSize, filters], + queryFn: async () => (await http.get('/courses', { params: { cursor, pageSize, ...filters, keyword: filters.keyword || undefined } })).data, + }) + const availableColleges = useMemo(() => scopedCollegeId ? (options.data?.colleges ?? []).filter((item) => item.id === scopedCollegeId) : options.data?.colleges ?? [], [options.data?.colleges, scopedCollegeId]) + const editableNatures = isCollegeAdmin ? Object.entries(natureLabels).filter(([value]) => ['MajorRequired', 'MajorElective', 'Practice'].includes(value)) : Object.entries(natureLabels) + const maintenanceScope = isCollegeAdmin ? `${availableColleges[0]?.name ?? '所属学院'} · 专业课与实践课` : '全校课程 · 全性质' + const eligible = archive.previews.filter((item) => !item.error && !item.blockers?.length && item.isArchived !== archive.target) + + function applyFilters() { setCursors([]); setSelectedIds([]); setFilters({ ...draft }) } + function resetFilters() { const reset = { ...initialFilters }; setDraft(reset); setFilters(reset); setCursors([]); setSelectedIds([]) } + function openEditor(row?: Course) { + const firstCategory = options.data?.categories.find((item) => item.isEnabled)?.id + form.setFieldsValue({ + code: '', name: '', englishName: '', collegeId: scopedCollegeId, courseCategoryId: firstCategory, + credits: 2, totalHours: 32, lectureHours: 24, practiceHours: 8, + nature: isCollegeAdmin ? 'MajorRequired' : 'GeneralRequired', assessmentMethod: 'Examination', + description: '', prerequisiteCourseIds: [], isEnabled: true, sortOrder: 0, ...row, + }) + setEditing(row ?? null) + } + async function save(values: Record) { + const lecture = Number(values.lectureHours ?? 0); const practice = Number(values.practiceHours ?? 0); const total = Number(values.totalHours ?? 0) + if (lecture + practice > total) { message.warning('讲授学时与实践学时之和不能超过总学时。'); return } + try { + if (editing?.id) await http.put(`/courses/${editing.id}`, values); else await http.post('/courses', values) + message.success(editing?.id ? '课程已更新' : '课程已加入课程库'); setEditing(undefined) + await Promise.all([client.invalidateQueries({ queryKey: ['course-catalogue'] }), client.invalidateQueries({ queryKey: ['course-catalogue-options'] })]) + } catch (error) { message.error(apiErrorMessage(error)) } + } + function remove(row: Course) { + modal.confirm({ title: `删除课程“${row.name}”?`, content: '已进入培养方案的课程将无法删除。', okText: '删除', okButtonProps: { danger: true }, onOk: async () => { try { await http.delete(`/courses/${row.id}`); message.success('课程已删除'); await client.invalidateQueries({ queryKey: ['course-catalogue'] }) } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) + } + async function handleImport(file?: File) { + if (!file) return + setImporting(true) + try { + const response = await importExcel('/courses/import', file) + message.success(`导入完成:新增 ${response.data.created} 门,更新 ${response.data.updated} 门,共处理 ${response.data.total} 门。`) + setCursors([]); await Promise.all([client.invalidateQueries({ queryKey: ['course-catalogue'] }), client.invalidateQueries({ queryKey: ['course-catalogue-options'] })]) + } catch (error) { message.error(apiErrorMessage(error)) } finally { setImporting(false); if (fileRef.current) fileRef.current.value = '' } + } + async function openArchive(ids: string[], target: boolean) { + if (!ids.length) return message.info('请先选择课程') + setArchive({ open: true, target, previews: [], results: [], reason: '', busy: true }) + try { const response = await http.post('/archives/courses/preview', { ids, archived: target }); setArchive((state) => ({ ...state, previews: response.data, busy: false })) } catch (error) { message.error(apiErrorMessage(error)); setArchive((state) => ({ ...state, busy: false })) } + } + async function applyArchive() { + if (!archive.reason.trim() || !eligible.length) return + setArchive((state) => ({ ...state, busy: true })) + try { const response = await http.post('/archives/courses/apply', { ids: eligible.map((item) => item.id), archived: archive.target, reason: archive.reason.trim() }); setArchive((state) => ({ ...state, results: response.data, busy: false })); setSelectedIds([]); await client.invalidateQueries({ queryKey: ['course-catalogue'] }) } catch (error) { message.error(apiErrorMessage(error)); setArchive((state) => ({ ...state, busy: false })) } + } + async function loadHistory(id: string, historyPage = 1) { + setHistory({ open: true, id, page: historyPage, busy: true, entries: [] }) + try { const response = await http.get(`/archives/courses/${id}/history`, { params: { page: historyPage } }); setHistory({ open: true, id, page: historyPage, busy: false, entries: response.data }) } catch (error) { message.error(apiErrorMessage(error)); setHistory((state) => ({ ...state, busy: false })) } + } + const exportParams = { ...filters, keyword: filters.keyword || undefined } + const rowSelection: TableRowSelection | undefined = canManage ? { selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(String)), getCheckboxProps: (row) => ({ disabled: !row.canManage }) } : undefined + const columns: ColumnsType = [ + { title: '归档状态', width: 105, render: (_, row) => {row.isArchived ? '已归档' : '未归档'} }, + { title: '课程编码', dataIndex: 'code', width: 125, render: (value: string) => {value} }, + { title: '课程名称', width: 210, render: (_, row) =>
{row.name}{row.englishName || '—'}
}, + { title: '开课学院', dataIndex: 'collegeName', width: 145 }, + { title: '课程分类', dataIndex: 'categoryName', width: 110, render: (value?: string) => value || '未分类' }, + { title: '性质', dataIndex: 'nature', width: 105, render: (value: string) => natureLabels[value] ?? value }, + { title: '学分/学时', width: 120, render: (_, row) => <>{row.credits} 学分 · {row.totalHours} 学时 }, + { title: '学时构成', width: 135, render: (_, row) => `讲授 ${row.lectureHours} / 实践 ${row.practiceHours}` }, + { title: '考核', dataIndex: 'assessmentMethod', width: 80, render: (value: string) => assessmentLabels[value] ?? value }, + { title: '先修课程', width: 180, render: (_, row) => row.prerequisites?.length ? row.prerequisites.map((item) => item.code).join('、') : '—' }, + { title: '状态', dataIndex: 'isEnabled', width: 80, render: (value: boolean) => {value ? '启用' : '停用'} }, + ...(canManage ? [{ title: '操作', width: 265, fixed: 'right' as const, render: (_: unknown, row: Course) => row.canManage ?
: 校级维护 }] : []), + ] + + if (options.isLoading && !options.data) return
+ if (options.isError) return
+ return
+ {canManage && }} /> +
当前可见{courses.data?.total ?? 0}门课程
维护范围{maintenanceScope}{isCollegeAdmin ? '通识课程可查看、可引用' : '负责跨学院标准统筹'}
+
+
setDraft((state) => ({ ...state, keyword: event.target.value }))} onPressEnter={applyFilters} /> ({ value: item.id, label: item.name }))} onChange={(value) => setDraft((state) => ({ ...state, categoryId: value }))} /> setDraft((state) => ({ ...state, isArchived: value }))} /> void handleImport(event.target.files?.[0])} />}当前筛选条件会原样用于 Excel 导出 · 共 {courses.data?.total ?? 0} 门{selectedIds.length ? ` · 已选 ${selectedIds.length} 门` : ''}
+ {courses.isError ? : rowKey="id" loading={courses.isLoading} rowSelection={rowSelection} dataSource={courses.data?.items} columns={columns} pagination={false} scroll={{ x: 1450 }} locale={{ emptyText: }} />} +