feat(web): migrate course catalogue

This commit is contained in:
biss authored and biss committed 2026-10-03 21:38:14 +08:00
1 parent 8ac883ade1
commit ef6111032c
6 files changed
+180

No files matched your search

+2
View File
@@ -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() {
<Route path="/base-data/terms" element={<BaseDataPage />} />
<Route path="/base-data/facilities" element={<BaseDataPage />} />
<Route path="/base-data/course-categories" element={<BaseDataPage />} />
<Route path="/courses" element={<CourseCataloguePage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
+1
View File
@@ -69,6 +69,7 @@ export default function AppShell() {
{ key: '/dashboard', icon: <AppstoreOutlined />, label: isStudentHome ? '学习首页' : isTeacherHome ? '教学工作台' : '教务总览' },
{ key: '/account', icon: <IdcardOutlined />, label: '个人账户' },
{ key: '/notifications', icon: <BellOutlined />, label: '通知中心' },
{ key: '/courses', icon: <BookOutlined />, label: '课程库' },
...((roles.includes('Student') || roles.includes('Teacher'))
? [{ key: '/my-timetable', icon: <CalendarOutlined />, label: '我的课表' }]
: []),
+174
View File
@@ -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<string, string> = {
GeneralRequired: '通识必修', MajorRequired: '专业必修', MajorElective: '专业选修',
GeneralElective: '通识选修', Practice: '实践课程',
}
const assessmentLabels: Record<string, string> = { Examination: '考试', Assessment: '考查' }
export default function CourseCataloguePage() {
const { message, modal } = App.useApp()
const client = useQueryClient()
const user = useAuthStore((state) => state.user)
const fileRef = useRef<HTMLInputElement>(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<Filters>(initialFilters)
const [filters, setFilters] = useState<Filters>(initialFilters)
const [pageSize, setPageSize] = useState(20)
const [cursors, setCursors] = useState<string[]>([])
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [editing, setEditing] = useState<Course | null | undefined>()
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<Option[]>('/base-data/colleges'), http.get<Option[]>('/base-data/course-categories'), http.get<Option[]>('/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<CursorPage>('/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<string, unknown>) {
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<ArchivePreview[]>('/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<ArchiveResult[]>('/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<ArchiveHistory[]>(`/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<Course> | undefined = canManage ? { selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(String)), getCheckboxProps: (row) => ({ disabled: !row.canManage }) } : undefined
const columns: ColumnsType<Course> = [
{ title: '归档状态', width: 105, render: (_, row) => <Tag color={row.isArchived ? 'default' : 'green'}>{row.isArchived ? '已归档' : '未归档'}</Tag> },
{ title: '课程编码', dataIndex: 'code', width: 125, render: (value: string) => <span className="catalogue-code">{value}</span> },
{ title: '课程名称', width: 210, render: (_, row) => <div className="catalogue-name"><strong>{row.name}</strong><span>{row.englishName || '—'}</span></div> },
{ 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) => <><strong>{row.credits}</strong> 学分 · {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) => <Tag color={value ? 'green' : 'default'}>{value ? '启用' : '停用'}</Tag> },
...(canManage ? [{ title: '操作', width: 265, fixed: 'right' as const, render: (_: unknown, row: Course) => row.canManage ? <div className="catalogue-actions"><Button size="small" onClick={() => void openArchive([row.id], !row.isArchived)}>{row.isArchived ? '恢复' : '归档'}</Button><Button size="small" icon={<HistoryOutlined />} onClick={() => void loadHistory(row.id)}>历史</Button><Button size="small" disabled={row.isArchived} onClick={() => openEditor(row)}>编辑</Button><Button size="small" danger icon={<DeleteOutlined />} disabled={row.isArchived} onClick={() => remove(row)}>删除</Button></div> : <span className="muted">校级维护</span> }] : []),
]
if (options.isLoading && !options.data) return <main className="page-container"><Skeleton active paragraph={{ rows: 9 }} /></main>
if (options.isError) return <main className="page-container"><Alert type="error" showIcon message="课程库选项无法加载" description={apiErrorMessage(options.error)} /></main>
return <main className="page-container course-catalogue-page">
<PageHeader kicker="COURSE CATALOGUE" title="课程库" description="通识课程由校级统筹,专业课与实践课由开课学院维护,统一供培养方案与教学任务引用。" actions={<div className="catalogue-header-actions"><Button icon={<DownloadOutlined />} onClick={() => void downloadApiFile('/courses/export', '课程库.xlsx', { params: exportParams }).catch((error) => message.error(apiErrorMessage(error)))}>导出结果</Button>{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>新增课程</Button>}</div>} />
<section className="catalogue-ledger"><div><span>当前可见</span><strong>{courses.data?.total ?? 0}</strong><small>门课程</small></div><div><span>维护范围</span><strong>{maintenanceScope}</strong><small>{isCollegeAdmin ? '通识课程可查看、可引用' : '负责跨学院标准统筹'}</small></div><BookOutlined /></section>
<section className="catalogue-card">
<div className="catalogue-filters"><Input allowClear value={draft.keyword} placeholder="搜索课程编码、中英文名称" onChange={(event) => setDraft((state) => ({ ...state, keyword: event.target.value }))} onPressEnter={applyFilters} /><Select allowClear={!scopedCollegeId} disabled={!!scopedCollegeId} value={draft.collegeId} placeholder={scopedCollegeId ? '所属开课学院' : '全部开课学院'} options={availableColleges.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => setDraft((state) => ({ ...state, collegeId: value }))} /><Select allowClear value={draft.categoryId} placeholder="全部课程分类" options={(options.data?.categories ?? []).map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => setDraft((state) => ({ ...state, categoryId: value }))} /><Select allowClear value={draft.nature} placeholder="全部课程性质" options={Object.entries(natureLabels).map(([value, label]) => ({ value, label }))} onChange={(value) => setDraft((state) => ({ ...state, nature: value }))} /><Select allowClear value={draft.isArchived} placeholder="全部归档状态" options={[{ value: false, label: '未归档' }, { value: true, label: '已归档' }]} onChange={(value) => setDraft((state) => ({ ...state, isArchived: value }))} /><Select allowClear value={draft.isEnabled} placeholder="全部状态" options={[{ value: true, label: '启用' }, { value: false, label: '停用' }]} onChange={(value) => setDraft((state) => ({ ...state, isEnabled: value }))} /><Button type="primary" onClick={applyFilters}>查询</Button><Button icon={<ReloadOutlined />} onClick={resetFilters}>重置</Button></div>
<div className="catalogue-toolbar">{canManage && <><Button onClick={() => void downloadApiFile('/courses/template', '课程库导入模板.xlsx').catch((error) => message.error(apiErrorMessage(error)))}>下载模板</Button><input ref={fileRef} hidden type="file" accept=".xlsx" onChange={(event) => void handleImport(event.target.files?.[0])} /><Button icon={<UploadOutlined />} loading={importing} onClick={() => fileRef.current?.click()}>Excel 导入</Button><Button disabled={!selectedIds.length} onClick={() => void openArchive(selectedIds, true)}>批量归档</Button><Button disabled={!selectedIds.length} onClick={() => void openArchive(selectedIds, false)}>批量恢复</Button></>}<span>当前筛选条件会原样用于 Excel 导出 · 共 {courses.data?.total ?? 0} 门{selectedIds.length ? ` · 已选 ${selectedIds.length} 门` : ''}</span></div>
{courses.isError ? <Alert type="error" showIcon message="课程库无法加载" description={apiErrorMessage(courses.error)} /> : <Table<Course> rowKey="id" loading={courses.isLoading} rowSelection={rowSelection} dataSource={courses.data?.items} columns={columns} pagination={false} scroll={{ x: 1450 }} locale={{ emptyText: <Empty description="没有符合条件的课程" /> }} />}
<footer className="cursor-pager"><span>共 {courses.data?.total ?? 0} 门 · 每批 <Select value={pageSize} options={[20, 50, 100].map((value) => ({ value, label: value }))} onChange={(value) => { setPageSize(value); setCursors([]) }} /> 门</span><div><Button disabled={!cursors.length || courses.isLoading} onClick={() => { setCursors((state) => state.slice(0, -1)); setSelectedIds([]) }}>上一批</Button><span>第 {cursors.length + 1} 批</span><Button type="primary" disabled={!courses.data?.hasMore || courses.isLoading} onClick={() => { if (courses.data?.nextCursor) setCursors((state) => [...state, courses.data!.nextCursor!]); setSelectedIds([]) }}>下一批</Button></div></footer>
</section>
<Modal open={editing !== undefined} title={`${editing?.id ? '编辑' : '新增'}课程`} width={680} okText="保存课程" onCancel={() => setEditing(undefined)} onOk={() => form.submit()}><Form form={form} layout="vertical" onFinish={(values) => void save(values)}><div className="catalogue-form-grid"><Form.Item name="code" label="课程编码" rules={[{ required: true, whitespace: true }]}><Input placeholder="全校唯一" /></Form.Item><Form.Item name="name" label="课程名称" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item></div><Form.Item name="englishName" label="英文名称"><Input /></Form.Item><div className="catalogue-form-grid"><Form.Item name="collegeId" label="开课学院" rules={[{ required: true }]}><Select options={availableColleges.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item><Form.Item name="courseCategoryId" label="课程分类" rules={[{ required: true }]}><Select showSearch options={(options.data?.categories ?? []).filter((item) => item.isEnabled).map((item) => ({ value: item.id, label: item.name }))} /></Form.Item></div><div className="catalogue-form-grid"><Form.Item name="nature" label="课程性质"><Select options={editableNatures.map(([value, label]) => ({ value, label }))} /></Form.Item><Alert type="info" showIcon message="分类表示教学领域,性质决定课程在培养方案中的管理方式。" /></div><div className="catalogue-form-grid three"><Form.Item name="credits" label="学分"><InputNumber min={0.1} max={99} step={0.5} /></Form.Item><Form.Item name="totalHours" label="总学时"><InputNumber min={1} /></Form.Item><Form.Item name="assessmentMethod" label="考核方式"><Select options={Object.entries(assessmentLabels).map(([value, label]) => ({ value, label }))} /></Form.Item></div><div className="catalogue-form-grid"><Form.Item name="lectureHours" label="讲授学时"><InputNumber min={0} /></Form.Item><Form.Item name="practiceHours" label="实践学时"><InputNumber min={0} /></Form.Item></div><Form.Item name="prerequisiteCourseIds" label="先修课程" extra="学生规划时会检查这些课程是否已经通过,或安排在更早的模拟学期。"><Select mode="multiple" showSearch maxTagCount="responsive" placeholder="选择必须先通过的课程" options={(options.data?.courses ?? []).filter((item) => item.id !== editing?.id).map((item) => ({ value: item.id, label: `${item.code} · ${item.name}` }))} /></Form.Item><Form.Item name="description" label="课程简介"><Input.TextArea rows={3} maxLength={1000} showCount /></Form.Item><div className="catalogue-form-grid"><Form.Item name="sortOrder" label="排序"><InputNumber min={0} /></Form.Item><Form.Item name="isEnabled" label="状态" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="停用" /></Form.Item></div></Form></Modal>
<Modal open={archive.open} title={archive.target ? '归档预检查' : '恢复预检查'} okText={`${archive.target ? '确认归档' : '确认恢复'}(${eligible.length})`} confirmLoading={archive.busy} okButtonProps={{ disabled: !archive.reason.trim() || !eligible.length || !!archive.results.length }} onOk={() => void applyArchive()} onCancel={() => setArchive((state) => ({ ...state, open: false }))}><p>符合条件 {eligible.length} / {archive.previews.length} 门。提交时会再次检查,受阻课程不执行。</p>{archive.busy && !archive.previews.length ? <Skeleton active /> : archive.previews.map((item) => <article className="catalogue-archive" key={item.id}><strong>{item.name || '不可操作课程'}</strong>{item.error && <p className="error">{item.error}</p>}{!item.error && <><p>{item.isArchived ? '已归档' : '未归档'}{item.archiveReason ? ` · 原因:${item.archiveReason}` : ''}</p>{item.isArchived === archive.target && <p>已处于目标状态,无需重复操作。</p>}{item.blockers?.map((blocker, index) => <p className="error" key={index}>{blocker.message}</p>)}{item.notices?.map((notice, index) => <p key={index}>{notice.message}</p>)}</>}</article>)}{!archive.results.length ? <label className="catalogue-reason">{archive.target ? '归档原因(必填)' : '恢复原因(必填)'}<Input.TextArea rows={3} maxLength={500} showCount value={archive.reason} onChange={(event) => setArchive((state) => ({ ...state, reason: event.target.value }))} /></label> : archive.results.map((result) => <Alert key={result.id} type={result.success ? 'success' : 'error'} showIcon message={result.message} />)}</Modal>
<Modal open={history.open} title="归档操作历史" footer={null} onCancel={() => setHistory((state) => ({ ...state, open: false }))}>{history.busy ? <Skeleton active /> : history.entries.length ? history.entries.map((entry) => <article className="catalogue-archive" key={entry.id}><strong>{entry.isArchived ? '归档' : '恢复'} · {entry.actorName || entry.actorUserId}</strong><p>{new Date(entry.createdAt).toLocaleString('zh-CN', { hour12: false })}</p><p>{entry.reason}</p></article>) : <Empty description="暂无操作历史(旧归档记录可能没有操作日志)" />}<div className="history-pagination"><Button disabled={history.busy || history.page === 1} onClick={() => void loadHistory(history.id!, history.page - 1)}>上一页</Button><span>第 {history.page} 页</span><Button disabled={history.busy || history.entries.length < 20} onClick={() => void loadHistory(history.id!, history.page + 1)}>下一页</Button></div></Modal>
</main>
}
Loaded 3 of 6 files, more files were not shown because too many files have changed in this diff. Show more