feat(web): migrate curriculum management
This commit is contained in:
1 parent
5753d72238
commit
c2beed236e
6 files changed
+118
No files matched your search
@@ -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() {
|
||||
<Route path="/base-data/facilities" element={<BaseDataPage />} />
|
||||
<Route path="/base-data/course-categories" element={<BaseDataPage />} />
|
||||
<Route path="/courses" element={<CourseCataloguePage />} />
|
||||
<Route path="/curriculum" element={<CurriculumManagementPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function AppShell() {
|
||||
{ key: '/teaching-preferences', icon: <ReadOutlined />, label: roles.includes('Teacher') && !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? '授课科目申报' : '授课资格审核' },
|
||||
] : []),
|
||||
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? [
|
||||
{ key: '/curriculum', icon: <ReadOutlined />, label: '培养方案管理' },
|
||||
{ key: '/course-equivalency-rules', icon: <SwapOutlined />, label: '等价课程规则' },
|
||||
{ key: '/course-enrollments', icon: <BookOutlined />, label: '修读课程管理' },
|
||||
{ key: '/campus-wallets', icon: <WalletOutlined />, label: '学生余额管理' },
|
||||
|
||||
@@ -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<string, string> = { Draft: '草稿', Published: '已发布', Archived: '已归档' }
|
||||
const typeLabels: Record<string, string> = { 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<Filters>(initialFilters)
|
||||
const [filters, setFilters] = useState<Filters>(initialFilters)
|
||||
const [cursors, setCursors] = useState<string[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string>()
|
||||
const [detailView, setDetailView] = useState<'structure' | 'semester'>('structure')
|
||||
const [planEditor, setPlanEditor] = useState<PlanSummary | null | undefined>()
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [moduleEditor, setModuleEditor] = useState<PlanModule | null | undefined>()
|
||||
const [courseEditor, setCourseEditor] = useState<{ moduleId: string; course?: PlanCourse }>()
|
||||
const [importModuleId, setImportModuleId] = useState<string>()
|
||||
const [groupManagerOpen, setGroupManagerOpen] = useState(false)
|
||||
const [activeGroupId, setActiveGroupId] = useState<string>()
|
||||
const [groupCourseId, setGroupCourseId] = useState<string>()
|
||||
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<Option[]>('/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<CourseGroup[]>('/course-groups')).data })
|
||||
const plans = useQuery({ queryKey: ['curriculum-plans', cursor, filters], queryFn: async () => (await http.get<CursorPage>('/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<PlanDetail>(`/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<boolean>((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<string, unknown>) { 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<string, unknown>) { 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<string, unknown>) { 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<string, unknown>) { 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<string, unknown>) { 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<string, unknown>) { 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<PlanCourse> => [
|
||||
{ title: '课程编码', dataIndex: 'courseCode', width: 115 }, { title: '课程名称', dataIndex: 'courseName', width: 180, render: (value: string) => <strong>{value}</strong> },
|
||||
{ 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) => <><Button type="link" size="small" onClick={() => openCourse(moduleId, item)}>编辑</Button><Button type="link" danger size="small" onClick={() => void deleteCourse(moduleId, item)}>移除</Button></> }] : []),
|
||||
]
|
||||
|
||||
if (options.isLoading) return <main className="page-container"><Skeleton active paragraph={{ rows: 10 }} /></main>
|
||||
if (options.isError) return <main className="page-container"><Alert type="error" showIcon message="培养方案选项无法加载" description={apiErrorMessage(options.error)} /></main>
|
||||
return <main className="page-container curriculum-page">
|
||||
<PageHeader kicker="PROGRAMME OF STUDY" title="培养方案" description="由学院按专业和入学年级维护课程结构;已发布方案可受控调整,修改结果即时生效。" actions={<div className="curriculum-header-actions">{canManageGroups && <Button icon={<ApartmentOutlined />} onClick={() => { setGroupManagerOpen(true); selectGroup(activeGroupId) }}>课程组</Button>}<Button type="primary" icon={<PlusOutlined />} onClick={() => openPlan()}>新建方案</Button></div>} />
|
||||
<section className="curriculum-scope"><div><span>维护主体</span><strong>{isCollegeAdmin ? '学院教务' : '校级统筹'}</strong></div><div><span>当前范围</span><strong>{isCollegeAdmin ? options.data?.colleges[0]?.name ?? '所属学院' : '全校各学院'}</strong></div><p>{isCollegeAdmin ? '可新建、编制、发布并维护本学院各专业培养方案' : '可跨学院查看、发布并统筹维护培养方案版本'}</p></section>
|
||||
<section className="curriculum-filters"><Input allowClear value={draft.keyword} placeholder="搜索方案、版本或专业" onChange={(event) => setDraft((state) => ({ ...state, keyword: event.target.value }))} onPressEnter={applyFilters} /><Select allowClear value={draft.effectiveGrade} placeholder="全部年级" options={availableGrades.map((value) => ({ value, label: `${value} 级` }))} onChange={(value) => setDraft((state) => ({ ...state, effectiveGrade: value }))} /><Select allowClear disabled={isCollegeAdmin} value={draft.collegeId} placeholder="全部学院" options={options.data?.colleges.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => setDraft((state) => ({ ...state, collegeId: value, majorId: undefined }))} /><Select allowClear showSearch value={draft.majorId} placeholder="全部专业" options={filteredMajors.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => setDraft((state) => ({ ...state, majorId: value }))} /><Select allowClear value={draft.status} placeholder="全部状态" options={Object.entries(statusLabels).map(([value, label]) => ({ value, label }))} onChange={(value) => setDraft((state) => ({ ...state, status: value }))} /><Button type="primary" onClick={applyFilters}>查询</Button><Button icon={<ReloadOutlined />} onClick={resetFilters}>重置</Button></section>
|
||||
<section className="curriculum-workspace"><aside className="plan-list">{plans.isLoading ? <Skeleton active /> : plans.isError ? <Alert type="error" showIcon message="方案列表无法加载" description={apiErrorMessage(plans.error)} /> : plans.data?.items.length ? plans.data.items.map((plan) => <button key={plan.id} className={selectedId === plan.id ? 'active' : ''} onClick={() => setSelectedId(plan.id)}><span>{plan.majorName} · {plan.effectiveGrade}级</span><strong>{plan.name}</strong><small>{plan.version} · {plan.courseCount} 门课程</small><Tag color={plan.status === 'Published' ? 'green' : plan.status === 'Draft' ? 'blue' : 'default'}>{statusLabels[plan.status]}</Tag></button>) : <Empty description="暂无培养方案" />}<footer><span>共 {plans.data?.total ?? 0} 版</span><div><Button size="small" disabled={!cursors.length || plans.isLoading} onClick={() => { setCursors((state) => state.slice(0, -1)); setSelectedId(undefined) }}>上一批</Button><span>第 {cursors.length + 1} 批</span><Button size="small" type="primary" disabled={!plans.data?.hasMore || plans.isLoading} onClick={() => { if (plans.data?.nextCursor) setCursors((state) => [...state, plans.data!.nextCursor!]); setSelectedId(undefined) }}>下一批</Button></div></footer></aside>
|
||||
<section className="plan-detail">{detail.isLoading ? <Skeleton active paragraph={{ rows: 14 }} /> : detail.isError ? <Alert type="error" showIcon message="方案详情无法加载" description={apiErrorMessage(detail.error)} /> : !selected ? <Empty description="请选择或新建一份培养方案" /> : <><header className="plan-detail-head"><div><span>{selected.collegeName} / {selected.majorName}</span><h2>{selected.name}</h2><p>{selected.version} · 适用 {selected.effectiveGrade} 级 · {selected.schoolingYears} 年制</p></div><div>{canEdit && <Button onClick={() => openPlan(selected)}>编辑</Button>}<Button icon={<CopyOutlined />} onClick={openClone}>修订并创建草稿</Button>{isDraft && <Button type="primary" icon={<SendOutlined />} onClick={() => void publishPlan()}>发布</Button>}{isDraft && <Button danger icon={<DeleteOutlined />} onClick={() => void deletePlan()}>删除</Button>}</div></header>{isPublished && <Alert type="warning" showIcon message="当前方案已发布,修改保存后会立即影响学生端展示及毕业审核依据;适用专业、入学年级和版本号不可更改。" />}<div className="plan-metrics"><div><span>方案状态</span><strong>{statusLabels[selected.status]}</strong></div><div><span>总学分要求</span><strong>{selected.totalCredits}</strong></div><div><span>模块学分合计</span><strong className={configuredCredits !== Number(selected.totalCredits) ? 'warning' : ''}>{configuredCredits}</strong></div><div><span>课程数量</span><strong>{selected.modules.reduce((sum, item) => sum + item.courses.length, 0)}</strong></div></div><div className="module-toolbar"><div><strong>{detailView === 'structure' ? '课程结构' : '学期视图'}</strong><span>{detailView === 'structure' ? '指定必修须逐门通过;组内选修按模块最低学分验收。' : '按建议修读学期展示课程、学分和学时。'}</span></div><div><Radio.Group size="small" value={detailView} onChange={(event) => setDetailView(event.target.value)} options={[{ value: 'structure', label: '课程结构' }, { value: 'semester', label: '学期视图' }]} optionType="button" />{canEdit && detailView === 'structure' && <Button icon={<PlusOutlined />} onClick={() => openModule()}>新增模块</Button>}</div></div>{detailView === 'structure' ? <div className="module-list">{selected.modules.map((module) => <article className="curriculum-module" key={module.id}><header><div><span>{module.code}</span><h3>{module.name}</h3></div><p>最低 {module.requiredCredits} 学分 · 已配置 {module.assignedCredits} 学分{module.courses.some((course) => course.type === 'Elective') ? ' · 课程组选项' : ''}</p>{canEdit && <div><Button size="small" onClick={() => openModule(module)}>编辑</Button><Button size="small" danger onClick={() => void deleteModule(module)}>删除</Button><Button size="small" onClick={() => openGroupImport(module.id)}>从课程组添加</Button><Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => openCourse(module.id)}>添加课程</Button></div>}</header><Table<PlanCourse> rowKey="id" size="small" dataSource={module.courses} columns={courseColumns(module.id)} pagination={false} scroll={{ x: 850 }} locale={{ emptyText: <Empty description="该模块尚未配置课程" /> }} /></article>)}{!selected.modules.length && <Empty description="先建立课程模块,再添加课程" />}</div> : <div className="semester-grid">{semesterGroups.map((group) => <article key={group.semester}><header><div><span>SEMESTER {String(group.semester).padStart(2, '0')}</span><h3>第 {group.semester} 学期</h3></div><strong>{group.courses.length} 门</strong></header><p>{group.credits} 学分 · {group.hours} 学时</p>{group.courses.length ? <ul>{group.courses.map((course) => <li key={course.id}><div><span>{course.moduleName}</span><strong>{course.courseName}</strong><small>{course.courseCode} · {typeLabels[course.type]}</small></div><b>{course.credits}<small>学分</small></b></li>)}</ul> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本学期暂未安排课程" />}</article>)}</div>}</>}</section></section>
|
||||
<Modal open={planEditor !== undefined} title={planEditor?.id ? '编辑培养方案' : '新建培养方案'} okText="保存" onCancel={() => setPlanEditor(undefined)} onOk={() => planForm.submit()}>{planEditor?.status === 'Published' && <Alert type="warning" showIcon message="已发布方案的修改会立即生效;适用专业、年级和版本号已锁定。" />}<Form form={planForm} layout="vertical" onFinish={(values) => void savePlan(values)}><Form.Item name="majorId" label="适用专业" rules={[{ required: true }]}><Select showSearch disabled={planEditor?.status === 'Published'} options={options.data?.majors.map((item) => ({ value: item.id, label: `${item.collegeName} · ${item.name}` }))} /></Form.Item><Form.Item name="name" label="方案名称" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item><div className="curriculum-form-grid"><Form.Item name="version" label="版本号" rules={[{ required: true, whitespace: true }]}><Input disabled={planEditor?.status === 'Published'} /></Form.Item><Form.Item name="effectiveGrade" label="适用入学年级" rules={[{ required: true }]}><InputNumber min={2000} max={2200} disabled={planEditor?.status === 'Published'} /></Form.Item></div><Form.Item name="totalCredits" label="毕业总学分要求" rules={[{ required: true }]}><InputNumber min={0.1} precision={1} step={0.5} /></Form.Item><Form.Item name="description" label="方案说明"><Input.TextArea rows={3} /></Form.Item></Form></Modal>
|
||||
<Modal open={cloneOpen} title="修订培养方案并创建草稿" okText="创建修订草稿" onCancel={() => setCloneOpen(false)} onOk={() => cloneForm.submit()}><Alert type="info" showIcon message="将完整复制当前方案为独立草稿,不影响原版本。" /><Form form={cloneForm} layout="vertical" onFinish={(values) => void clonePlan(values)}><Form.Item name="name" label="方案名称" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item><div className="curriculum-form-grid"><Form.Item name="version" label="新版本号" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item><Form.Item name="effectiveGrade" label="适用入学年级" rules={[{ required: true }]}><InputNumber min={2000} max={2200} /></Form.Item></div></Form></Modal>
|
||||
<Modal open={moduleEditor !== undefined} title={moduleEditor?.id ? '编辑课程模块' : '新增课程模块'} okText="保存" onCancel={() => setModuleEditor(undefined)} onOk={() => moduleForm.submit()}><Form form={moduleForm} layout="vertical" onFinish={(values) => void saveModule(values)}><div className="curriculum-form-grid"><Form.Item name="code" label="模块编码" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item><Form.Item name="sortOrder" label="排序"><InputNumber min={0} /></Form.Item></div><Form.Item name="name" label="模块名称" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item><Form.Item name="requiredCredits" label="最低学分要求" extra="体育、英语等课程组填写学生必须从组内修满的学分。"><InputNumber min={0} precision={1} step={0.5} /></Form.Item></Form></Modal>
|
||||
<Modal open={!!courseEditor} title={courseEditor?.course ? '编辑方案课程' : '添加方案课程'} okText="保存" onCancel={() => setCourseEditor(undefined)} onOk={() => courseForm.submit()}><Form form={courseForm} layout="vertical" onFinish={(values) => void saveCourse(values)}><Form.Item name="courseId" label="课程" rules={[{ required: true }]}><Select showSearch options={options.data?.courses.map((item) => ({ value: item.id, label: `${item.code} · ${item.name}${item.categoryName ? ` · ${item.categoryName}` : ''}` }))} /></Form.Item><div className="curriculum-form-grid"><Form.Item name="type" label="修读规则"><Select options={Object.entries(typeLabels).map(([value, label]) => ({ value, label }))} /></Form.Item><Form.Item name="recommendedSemester" label="建议学期"><InputNumber min={1} max={(selected?.schoolingYears ?? 4) * 2} /></Form.Item></div><Form.Item name="notes" label="备注"><Input.TextArea rows={2} /></Form.Item><Alert type="info" showIcon message="指定必修必须通过;组内选修按模块最低学分验收。" /></Form></Modal>
|
||||
<Modal open={!!importModuleId} title="从课程组添加课程" okText="导入课程组" onCancel={() => setImportModuleId(undefined)} onOk={() => importForm.submit()}><Alert type="info" showIcon message="导入后课程会复制到当前方案,可逐门调整,不影响公共课程组。" /><Form form={importForm} layout="vertical" onFinish={(values) => void importGroup(values)}><Form.Item name="courseGroupId" label="课程组" rules={[{ required: true }]}><Select showSearch options={groups.data?.map((group) => ({ value: group.id, label: `${group.code} · ${group.name}(${group.courseCount} 门)` }))} /></Form.Item><div className="curriculum-form-grid"><Form.Item name="type" label="修读规则"><Select options={Object.entries(typeLabels).map(([value, label]) => ({ value, label }))} /></Form.Item><Form.Item name="recommendedSemester" label="建议学期"><InputNumber min={1} max={(selected?.schoolingYears ?? 4) * 2} /></Form.Item></div><Form.Item name="notes" label="统一备注"><Input.TextArea rows={2} /></Form.Item></Form></Modal>
|
||||
<Modal open={groupManagerOpen} title="公共课程组" width={940} footer={null} onCancel={() => setGroupManagerOpen(false)}><div className="group-manager"><aside><Button type="primary" icon={<PlusOutlined />} onClick={() => selectGroup()}>新建课程组</Button>{groups.data?.map((group) => <button key={group.id} className={activeGroupId === group.id ? 'active' : ''} onClick={() => selectGroup(group.id)}><span>{group.code}</span><strong>{group.name}</strong><small>{group.courseCount} 门课程</small></button>)}</aside><section><Form form={groupForm} layout="vertical" onFinish={(values) => void saveGroup(values)}><div className="curriculum-form-grid"><Form.Item name="code" label="课程组编码" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item><Form.Item name="name" label="课程组名称" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item></div><Form.Item name="description" label="说明"><Input.TextArea rows={2} /></Form.Item><div className="group-actions"><Button type="primary" htmlType="submit">保存课程组</Button>{activeGroup && <Button danger onClick={() => void deleteGroup()}>删除</Button>}</div></Form>{activeGroup && <><div className="group-add"><strong>组内课程</strong><Select showSearch value={groupCourseId} placeholder="选择课程加入课程组" options={options.data?.courses.map((item) => ({ value: item.id, label: `${item.code} · ${item.name}` }))} onChange={setGroupCourseId} /><Button type="primary" disabled={!groupCourseId} onClick={() => void addGroupCourse()}>加入</Button></div><Table<CourseGroupCourse> rowKey="courseId" size="small" pagination={false} dataSource={activeGroup.courses} columns={[{ title: '课程编码', dataIndex: 'courseCode', width: 120 }, { title: '课程名称', dataIndex: 'courseName' }, { title: '学分', dataIndex: 'credits', width: 80 }, { title: '操作', width: 80, render: (_, row) => <Button type="link" danger onClick={() => void removeGroupCourse(row.courseId)}>移除</Button> }]} /></>}</section></div></Modal>
|
||||
</main>
|
||||
}
|
||||
Loaded 3 of 6 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user