From dc949bb6ea66a2df0c98e6be5fb8d5318e0d6353 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 2 Oct 2026 14:17:54 +0800 Subject: [PATCH] feat(web): migrate curriculum management --- web-react/MIGRATION_MATRIX.md | 1 + web-react/README.md | 1 + web-react/src/app/App.tsx | 2 + web-react/src/layout/AppShell.tsx | 1 + .../src/pages/CurriculumManagementPage.tsx | 112 ++++++++++++++++++ web-react/src/pages/curriculum-management.css | 1 + 6 files changed, 118 insertions(+) create mode 100644 web-react/src/pages/CurriculumManagementPage.tsx create mode 100644 web-react/src/pages/curriculum-management.css diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 482e173..cffa415 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -46,6 +46,7 @@ | `/base-data/facilities` | 教学场所 | SuperAdmin、AcademicAdmin | 教学楼、教室、容量/场地性质筛选、Excel | verification | | `/base-data/course-categories` | 课程分类 | SuperAdmin、AcademicAdmin | 分类增删改查、启停、排序和 Excel | verification | | `/courses` | 课程库 | 已登录用户;教务与学院管理员可维护 | 游标分页、数据范围、先修关系、分类/性质、Excel、归档审计 | verification | +| `/curriculum` | 培养方案管理 | SuperAdmin、AcademicAdmin、CollegeAdmin | 版本、模块、课程、课程组、发布替换与已发布受控编辑 | 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 b72ef8a..af357b9 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -45,6 +45,7 @@ npm.cmd run build - 批量迁移教师授课意愿、等价课程规则、修读课程台账和学生余额管理四个教务工作流。 - 迁移组织机构、学年学期、教学场所与课程分类基础数据,保留 Excel、服务端分页和学期归档审计闭环。 - 迁移课程库游标台账,保留学院维护范围、先修关系、筛选导出、批量导入与归档审计。 +- 迁移培养方案管理,覆盖版本复制、模块与课程编制、公共课程组、发布替换和已发布方案受控调整。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index fcd40b5..a65f588 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -44,6 +44,7 @@ const CampusWalletManagementPage = lazy(() => import('../pages/CampusWalletManag const CourseEnrollmentManagementPage = lazy(() => import('../pages/CourseEnrollmentManagementPage')) const BaseDataPage = lazy(() => import('../pages/BaseDataPage')) const CourseCataloguePage = lazy(() => import('../pages/CourseCataloguePage')) +const CurriculumManagementPage = lazy(() => import('../pages/CurriculumManagementPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -133,6 +134,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index c17ec95..6ee350c 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -112,6 +112,7 @@ export default function AppShell() { { 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: '/curriculum', icon: , label: '培养方案管理' }, { key: '/course-equivalency-rules', icon: , label: '等价课程规则' }, { key: '/course-enrollments', icon: , label: '修读课程管理' }, { key: '/campus-wallets', icon: , label: '学生余额管理' }, diff --git a/web-react/src/pages/CurriculumManagementPage.tsx b/web-react/src/pages/CurriculumManagementPage.tsx new file mode 100644 index 0000000..f38af8f --- /dev/null +++ b/web-react/src/pages/CurriculumManagementPage.tsx @@ -0,0 +1,112 @@ +import { useEffect, useMemo, useState } from 'react' +import { ApartmentOutlined, CopyOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SendOutlined } from '@ant-design/icons' +import { Alert, App, Button, Empty, Form, Input, InputNumber, Modal, Radio, Select, Skeleton, Table, Tag } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import { useAuthStore } from '../stores/auth' +import './curriculum-management.css' + +interface Option { id: string; code: string; name: string; collegeId?: string; collegeName?: string; schoolingYears?: number; categoryName?: string; credits?: number; totalHours?: number } +interface PlanSummary { id: string; name: string; version: string; majorId: string; majorName: string; collegeId: string; collegeName: string; effectiveGrade: number; totalCredits: number; status: string; moduleCount: number; courseCount: number } +interface PlanCourse { id: string; courseId: string; courseCode: string; courseName: string; categoryName?: string; credits: number; totalHours: number; recommendedSemester: number; type: string; notes?: string } +interface PlanModule { id: string; code: string; name: string; requiredCredits: number; assignedCredits: number; sortOrder: number; courses: PlanCourse[] } +interface PlanDetail extends PlanSummary { schoolingYears: number; description?: string; modules: PlanModule[] } +interface CursorPage { items: PlanSummary[]; nextCursor?: string; hasMore: boolean; total: number } +interface CourseGroupCourse { courseId: string; courseCode: string; courseName: string; credits: number; totalHours: number } +interface CourseGroup { id: string; code: string; name: string; description?: string; courseCount: number; courses: CourseGroupCourse[] } +interface Filters { keyword: string; collegeId?: string; majorId?: string; effectiveGrade?: number; status?: string } + +const statusLabels: Record = { Draft: '草稿', Published: '已发布', Archived: '已归档' } +const typeLabels: Record = { Required: '指定必修', Elective: '组内选修' } + +export default function CurriculumManagementPage() { + const { message, modal } = App.useApp() + const client = useQueryClient() + const user = useAuthStore((state) => state.user) + const roles = user?.roles ?? [] + const isCollegeAdmin = roles.includes('CollegeAdmin') + const canManageGroups = roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) + const initialFilters: Filters = { keyword: '', collegeId: isCollegeAdmin ? user?.collegeId : undefined } + const [draft, setDraft] = useState(initialFilters) + const [filters, setFilters] = useState(initialFilters) + const [cursors, setCursors] = useState([]) + const [selectedId, setSelectedId] = useState() + const [detailView, setDetailView] = useState<'structure' | 'semester'>('structure') + const [planEditor, setPlanEditor] = useState() + const [cloneOpen, setCloneOpen] = useState(false) + const [moduleEditor, setModuleEditor] = useState() + const [courseEditor, setCourseEditor] = useState<{ moduleId: string; course?: PlanCourse }>() + const [importModuleId, setImportModuleId] = useState() + const [groupManagerOpen, setGroupManagerOpen] = useState(false) + const [activeGroupId, setActiveGroupId] = useState() + const [groupCourseId, setGroupCourseId] = useState() + const [planForm] = Form.useForm(); const [cloneForm] = Form.useForm(); const [moduleForm] = Form.useForm(); const [courseForm] = Form.useForm(); const [importForm] = Form.useForm(); const [groupForm] = Form.useForm() + const cursor = cursors.at(-1) + + const options = useQuery({ queryKey: ['curriculum-options'], queryFn: async () => { const [filtersResponse, coursesResponse] = await Promise.all([http.get('/curriculum-plans/filter-options'), http.get('/curriculum-plans/course-options')]); return { ...filtersResponse.data, courses: coursesResponse.data } as { colleges: Option[]; majors: Option[]; grades: number[]; courses: Option[] } } }) + const groups = useQuery({ queryKey: ['course-groups'], queryFn: async () => (await http.get('/course-groups')).data }) + const plans = useQuery({ queryKey: ['curriculum-plans', cursor, filters], queryFn: async () => (await http.get('/curriculum-plans', { params: { cursor, pageSize: 20, ...filters, keyword: filters.keyword || undefined } })).data }) + useEffect(() => { if (!plans.data?.items.length) { setSelectedId(undefined); return }; if (!selectedId || !plans.data.items.some((item) => item.id === selectedId)) setSelectedId(plans.data.items[0].id) }, [plans.data, selectedId]) + const detail = useQuery({ queryKey: ['curriculum-plan', selectedId], enabled: !!selectedId, queryFn: async () => (await http.get(`/curriculum-plans/${selectedId}`)).data }) + const selected = detail.data + const activeGroup = groups.data?.find((group) => group.id === activeGroupId) + const filteredMajors = (options.data?.majors ?? []).filter((item) => !draft.collegeId || item.collegeId === draft.collegeId) + const availableGrades = [...new Set([...(options.data?.grades ?? []), ...(plans.data?.items ?? []).map((item) => item.effectiveGrade)])].sort((a, b) => b - a) + const configuredCredits = selected?.modules.reduce((sum, item) => sum + Number(item.requiredCredits), 0) ?? 0 + const isDraft = selected?.status === 'Draft'; const isPublished = selected?.status === 'Published'; const canEdit = isDraft || isPublished + const semesterGroups = useMemo(() => { + if (!selected) return [] + const all = selected.modules.flatMap((module) => module.courses.map((course) => ({ ...course, moduleName: module.name, moduleCode: module.code }))) + return Array.from({ length: selected.schoolingYears * 2 }, (_, index) => { const semester = index + 1; const courses = all.filter((course) => course.recommendedSemester === semester).sort((a, b) => a.moduleCode.localeCompare(b.moduleCode) || a.courseCode.localeCompare(b.courseCode)); return { semester, courses, credits: courses.reduce((sum, course) => sum + Number(course.credits), 0), hours: courses.reduce((sum, course) => sum + course.totalHours, 0) } }) + }, [selected]) + + const refreshPlans = async () => { await client.invalidateQueries({ queryKey: ['curriculum-plans'] }); if (selectedId) await client.invalidateQueries({ queryKey: ['curriculum-plan', selectedId] }) } + const confirm = (title: string, content: string, okText = '确认') => new Promise((resolve) => modal.confirm({ title, content, okText, onOk: () => resolve(true), onCancel: () => resolve(false), afterClose: () => resolve(false) })) + async function confirmPublishedChange(action: string) { return !isPublished || confirm('修改已发布方案', `${action}保存后会立即影响学生端培养方案展示及毕业审核依据。确定继续吗?`, '确认修改') } + function applyFilters() { setCursors([]); setSelectedId(undefined); setFilters({ ...draft }) } + function resetFilters() { const reset = { ...initialFilters }; setDraft(reset); setFilters(reset); setCursors([]); setSelectedId(undefined) } + function openPlan(row?: PlanSummary) { planForm.setFieldsValue({ majorId: row?.majorId, name: row?.name ?? '', version: row?.version ?? `${new Date().getFullYear()}版`, effectiveGrade: row?.effectiveGrade ?? new Date().getFullYear(), totalCredits: row?.totalCredits ?? 160, description: selected?.id === row?.id ? selected?.description ?? '' : '' }); setPlanEditor(row ?? null) } + async function savePlan(values: Record) { if (planEditor?.id && !await confirmPublishedChange('方案信息')) return; try { if (planEditor?.id) await http.put(`/curriculum-plans/${planEditor.id}`, values); else await http.post('/curriculum-plans', values); message.success(planEditor?.id ? '方案信息已更新' : '培养方案草稿已创建'); setPlanEditor(undefined); await refreshPlans() } catch (error) { message.error(apiErrorMessage(error)) } } + function openClone() { if (!selected) return; cloneForm.setFieldsValue({ name: selected.name, version: `${new Date().getFullYear()}版`, effectiveGrade: Math.max(new Date().getFullYear(), selected.effectiveGrade + 1) }); setCloneOpen(true) } + async function clonePlan(values: Record) { try { await http.post(`/curriculum-plans/${selected!.id}/clone`, values); message.success('已复制为新的草稿版本'); setCloneOpen(false); await refreshPlans() } catch (error) { message.error(apiErrorMessage(error)) } } + async function publishPlan() { if (!selected || !await confirm('发布培养方案', '发布后该版本将成为学生端展示和毕业审核依据,并自动归档同专业、同年级的旧发布版本。确定发布吗?', '确认发布')) return; try { await http.post(`/curriculum-plans/${selected.id}/publish`); message.success('培养方案已发布'); await refreshPlans() } catch (error) { message.error(apiErrorMessage(error)) } } + async function deletePlan() { if (!selected || !await confirm('删除草稿', '确定删除这份草稿及其全部课程配置吗?', '删除')) return; try { await http.delete(`/curriculum-plans/${selected.id}`); message.success('草稿已删除'); setSelectedId(undefined); await refreshPlans() } catch (error) { message.error(apiErrorMessage(error)) } } + function openModule(item?: PlanModule) { moduleForm.setFieldsValue({ code: item?.code ?? '', name: item?.name ?? '', requiredCredits: item?.requiredCredits ?? 0, sortOrder: item?.sortOrder ?? ((selected?.modules.length ?? 0) + 1) * 10 }); setModuleEditor(item ?? null) } + async function saveModule(values: Record) { if (!selected || !await confirmPublishedChange('课程模块')) return; try { const base = `/curriculum-plans/${selected.id}/modules`; if (moduleEditor?.id) await http.put(`${base}/${moduleEditor.id}`, values); else await http.post(base, values); setModuleEditor(undefined); await client.invalidateQueries({ queryKey: ['curriculum-plan', selected.id] }) } catch (error) { message.error(apiErrorMessage(error)) } } + async function deleteModule(item: PlanModule) { if (!selected || !await confirm('删除课程模块', `删除“${item.name}”及其中课程?${isPublished ? '删除后会立即影响学生端展示及毕业审核。' : ''}`, '删除')) return; try { await http.delete(`/curriculum-plans/${selected.id}/modules/${item.id}`); await client.invalidateQueries({ queryKey: ['curriculum-plan', selected.id] }) } catch (error) { message.error(apiErrorMessage(error)) } } + function openCourse(moduleId: string, item?: PlanCourse) { courseForm.setFieldsValue({ courseId: item?.courseId, recommendedSemester: item?.recommendedSemester ?? 1, type: item?.type ?? 'Required', notes: item?.notes ?? '' }); setCourseEditor({ moduleId, course: item }) } + async function saveCourse(values: Record) { if (!selected || !courseEditor || !await confirmPublishedChange('方案课程')) return; try { const base = `/curriculum-plans/${selected.id}/modules/${courseEditor.moduleId}/courses`; if (courseEditor.course?.id) await http.put(`${base}/${courseEditor.course.id}`, values); else await http.post(base, values); setCourseEditor(undefined); await client.invalidateQueries({ queryKey: ['curriculum-plan', selected.id] }) } catch (error) { message.error(apiErrorMessage(error)) } } + async function deleteCourse(moduleId: string, item: PlanCourse) { if (!selected || !await confirm('移除课程', `从方案中移除“${item.courseName}”?${isPublished ? '移除后会立即影响学生端展示及毕业审核。' : ''}`, '移除')) return; try { await http.delete(`/curriculum-plans/${selected.id}/modules/${moduleId}/courses/${item.id}`); await client.invalidateQueries({ queryKey: ['curriculum-plan', selected.id] }) } catch (error) { message.error(apiErrorMessage(error)) } } + function openGroupImport(moduleId: string) { importForm.setFieldsValue({ courseGroupId: groups.data?.[0]?.id, recommendedSemester: 1, type: 'Elective', notes: '' }); setImportModuleId(moduleId) } + async function importGroup(values: Record) { if (!selected || !importModuleId || !await confirmPublishedChange('课程组导入')) return; try { await http.post(`/curriculum-plans/${selected.id}/modules/${importModuleId}/course-groups/${values.courseGroupId}`, values); message.success('课程组已导入,可继续逐门调整'); setImportModuleId(undefined); await client.invalidateQueries({ queryKey: ['curriculum-plan', selected.id] }) } catch (error) { message.error(apiErrorMessage(error)) } } + function selectGroup(id?: string) { setActiveGroupId(id); setGroupCourseId(undefined); const group = groups.data?.find((item) => item.id === id); groupForm.setFieldsValue({ code: group?.code ?? '', name: group?.name ?? '', description: group?.description ?? '' }) } + async function saveGroup(values: Record) { try { if (activeGroupId) await http.put(`/course-groups/${activeGroupId}`, values); else { const response = await http.post('/course-groups', values); setActiveGroupId(response.data.id) }; message.success('课程组已保存'); await client.invalidateQueries({ queryKey: ['course-groups'] }) } catch (error) { message.error(apiErrorMessage(error)) } } + async function deleteGroup() { if (!activeGroup || !await confirm('删除课程组', `确定删除课程组“${activeGroup.name}”吗?`, '删除')) return; try { await http.delete(`/course-groups/${activeGroup.id}`); setActiveGroupId(undefined); groupForm.resetFields(); await client.invalidateQueries({ queryKey: ['course-groups'] }) } catch (error) { message.error(apiErrorMessage(error)) } } + async function addGroupCourse() { if (!activeGroupId || !groupCourseId) return; try { await http.post(`/course-groups/${activeGroupId}/courses`, { courseId: groupCourseId }); setGroupCourseId(undefined); await client.invalidateQueries({ queryKey: ['course-groups'] }) } catch (error) { message.error(apiErrorMessage(error)) } } + async function removeGroupCourse(courseId: string) { try { await http.delete(`/course-groups/${activeGroupId}/courses/${courseId}`); await client.invalidateQueries({ queryKey: ['course-groups'] }) } catch (error) { message.error(apiErrorMessage(error)) } } + + const courseColumns = (moduleId: string): ColumnsType => [ + { title: '课程编码', dataIndex: 'courseCode', width: 115 }, { title: '课程名称', dataIndex: 'courseName', width: 180, render: (value: string) => {value} }, + { title: '类别', dataIndex: 'type', width: 90, render: (value: string) => typeLabels[value] ?? value }, { title: '课程分类', dataIndex: 'categoryName', width: 105, render: (value?: string) => value || '未分类' }, + { title: '学分', dataIndex: 'credits', width: 70 }, { title: '学时', dataIndex: 'totalHours', width: 70 }, { title: '建议学期', dataIndex: 'recommendedSemester', width: 100, render: (value: number) => `第 ${value} 学期` }, + ...(canEdit ? [{ title: '操作', width: 130, render: (_: unknown, item: PlanCourse) => <> }] : []), + ] + + if (options.isLoading) return
+ if (options.isError) return
+ return
+ {canManageGroups && }} /> +
维护主体{isCollegeAdmin ? '学院教务' : '校级统筹'}
当前范围{isCollegeAdmin ? options.data?.colleges[0]?.name ?? '所属学院' : '全校各学院'}

{isCollegeAdmin ? '可新建、编制、发布并维护本学院各专业培养方案' : '可跨学院查看、发布并统筹维护培养方案版本'}

+
setDraft((state) => ({ ...state, keyword: event.target.value }))} onPressEnter={applyFilters} /> ({ value: item.id, label: item.name }))} onChange={(value) => setDraft((state) => ({ ...state, collegeId: value, majorId: undefined }))} /> ({ value, label }))} onChange={(value) => setDraft((state) => ({ ...state, status: value }))} />
+
+
{detail.isLoading ? : detail.isError ? : !selected ? : <>
{selected.collegeName} / {selected.majorName}

{selected.name}

{selected.version} · 适用 {selected.effectiveGrade} 级 · {selected.schoolingYears} 年制

{canEdit && }{isDraft && }{isDraft && }
{isPublished && }
方案状态{statusLabels[selected.status]}
总学分要求{selected.totalCredits}
模块学分合计{configuredCredits}
课程数量{selected.modules.reduce((sum, item) => sum + item.courses.length, 0)}
{detailView === 'structure' ? '课程结构' : '学期视图'}{detailView === 'structure' ? '指定必修须逐门通过;组内选修按模块最低学分验收。' : '按建议修读学期展示课程、学分和学时。'}
setDetailView(event.target.value)} options={[{ value: 'structure', label: '课程结构' }, { value: 'semester', label: '学期视图' }]} optionType="button" />{canEdit && detailView === 'structure' && }
{detailView === 'structure' ?
{selected.modules.map((module) =>
{module.code}

{module.name}

最低 {module.requiredCredits} 学分 · 已配置 {module.assignedCredits} 学分{module.courses.some((course) => course.type === 'Elective') ? ' · 课程组选项' : ''}

{canEdit &&
}
rowKey="id" size="small" dataSource={module.courses} columns={courseColumns(module.id)} pagination={false} scroll={{ x: 850 }} locale={{ emptyText: }} />
)}{!selected.modules.length && }
:
{semesterGroups.map((group) =>
SEMESTER {String(group.semester).padStart(2, '0')}

第 {group.semester} 学期

{group.courses.length} 门

{group.credits} 学分 · {group.hours} 学时

{group.courses.length ?
    {group.courses.map((course) =>
  • {course.moduleName}{course.courseName}{course.courseCode} · {typeLabels[course.type]}
    {course.credits}学分
  • )}
: }
)}
}}
+ setPlanEditor(undefined)} onOk={() => planForm.submit()}>{planEditor?.status === 'Published' && }
void savePlan(values)}>
+ setCloneOpen(false)} onOk={() => cloneForm.submit()}>
void clonePlan(values)}>
+ setModuleEditor(undefined)} onOk={() => moduleForm.submit()}>
void saveModule(values)}>
+ setCourseEditor(undefined)} onOk={() => courseForm.submit()}>
void saveCourse(values)}> ({ value, label }))} />
+ setImportModuleId(undefined)} onOk={() => importForm.submit()}>
void importGroup(values)}> ({ value, label }))} />
+ setGroupManagerOpen(false)}>
void saveGroup(values)}>
{activeGroup && }
{activeGroup && <>
组内课程