diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index e28b2af..5716953 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -49,6 +49,7 @@ | `/curriculum` | 培养方案管理 | SuperAdmin、AcademicAdmin、CollegeAdmin | 版本、模块、课程、课程组、发布替换与已发布受控编辑 | verification | | `/teachers`、`/students` | 教师/学生档案 | 教务、学院、辅导员;管理员可写 | 服务端分页、数据范围、完整档案、Excel、账号激活与归档审计 | verification | | `/teaching-tasks` | 教学任务 | SuperAdmin、AcademicAdmin、CollegeAdmin | 游标分页、职责范围、草稿编制、授课资格、状态流转、批量操作与公共课合班生成 | verification | +| `/schedules` | 排课管理 | SuperAdmin、AcademicAdmin | 版本草稿、手工排课、冲突校验、后台自动排课、异步发布、作息、优化策略与课程约束 | 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 46bcfd7..25a67cc 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -48,6 +48,7 @@ npm.cmd run build - 迁移培养方案管理,覆盖版本复制、模块与课程编制、公共课程组、发布替换和已发布方案受控调整。 - 迁移教师与学生档案名册,覆盖分页筛选、完整个人资料、Excel、教师账号激活和人员归档审计。 - 迁移教学任务管理,覆盖权限职责范围、草稿编制、授课资格、发布/归档流转、批量操作与公共课合班生成。 +- 迁移排课管理,覆盖版本修订、手工排课、后台自动排课和发布、作息、优化参数及课程约束。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index a6525c0..19c6a4b 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -47,6 +47,7 @@ const CourseCataloguePage = lazy(() => import('../pages/CourseCataloguePage')) const CurriculumManagementPage = lazy(() => import('../pages/CurriculumManagementPage')) const PersonnelRegistryPage = lazy(() => import('../pages/PersonnelRegistryPage')) const TeachingTasksPage = lazy(() => import('../pages/TeachingTasksPage')) +const SchedulesPage = lazy(() => import('../pages/SchedulesPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -140,6 +141,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 7d2430b..484b4bc 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -113,6 +113,7 @@ export default function AppShell() { ] : []), ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? [ { key: '/teaching-tasks', icon: , label: '教学任务' }, + ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ? [{ key: '/schedules', icon: , label: '排课管理' }] : []), { key: '/curriculum', icon: , label: '培养方案管理' }, { key: '/course-equivalency-rules', icon: , label: '等价课程规则' }, { key: '/course-enrollments', icon: , label: '修读课程管理' }, diff --git a/web-react/src/pages/SchedulesPage.tsx b/web-react/src/pages/SchedulesPage.tsx new file mode 100644 index 0000000..cad5243 --- /dev/null +++ b/web-react/src/pages/SchedulesPage.tsx @@ -0,0 +1,129 @@ +import { useEffect, useMemo, useState } from 'react' +import { CopyOutlined, PlusOutlined, ReloadOutlined, SettingOutlined, ThunderboltOutlined } from '@ant-design/icons' +import { Alert, App, Button, Checkbox, Drawer, Empty, Form, Input, InputNumber, Modal, Progress, Radio, Select, Skeleton, Switch, Table, Tabs, 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 PageHeader from '../components/PageHeader' +import './schedules.css' + +interface Term { id: string; name: string; isCurrent?: boolean; isArchived?: boolean; isEnabled?: boolean } +interface Campus { id: string; name: string; isEnabled?: boolean } +interface Building { id: string; name: string; campusId: string; campusName?: string; isEnabled?: boolean } +interface Classroom { id: string; name: string; buildingId: string; buildingName: string; campusId: string; campusName: string; roomType?: string; capacity: number; teachingVenueNature?: string | number; isEnabled?: boolean } +interface PlanSummary { id: string; name: string; version: string; academicTermId: string; termName: string; status: 'Draft' | 'Published' | 'Archived'; publishedAt?: string; entryCount: number; updatedAt: string } +interface Entry { id: string; teachingTaskId: string; kind: 'Lecture' | 'Experiment'; taskNumber: string; taskName: string; courseCode: string; courseName: string; teacherNames: string[]; classNames: string[]; classroomId?: string; classroomName?: string; buildingName?: string; campusName?: string; dayOfWeek: number; startPeriod: number; periodCount: number; startWeek: number; endWeek: number; weekPattern: 'All' | 'Odd' | 'Even'; notes?: string } +interface PlanDetail extends PlanSummary { notes?: string; entries: Entry[] } +interface TimeSlot { id?: string; periodNumber: number; name: string; startsAt: string; endsAt: string; isEnabled: boolean } +interface Constraint { id: string; taskNumber: string; name: string; courseCode: string; courseName: string; collegeId: string; collegeName: string; teacherNames: string[]; capacity: number; startWeek: number; endWeek: number; weeklyHours: number; courseTotalHours: number; coursePracticeHours: number; schedulingMode: 'Standard' | 'Flexible'; hasCustomConstraint: boolean; requiresClassroom: boolean; requiredCampusId?: string; requiredBuildingId?: string; experimentRequiredCampusId?: string; experimentRequiredBuildingId?: string; allowedDayOfWeeks: number[]; earliestPeriod?: number; latestPeriod?: number; preferredDayOfWeeks: number[]; preferredEarliestPeriod?: number; preferredLatestPeriod?: number; allowedClassroomIds: string[]; allowedExperimentClassroomIds: string[]; allowedExperimentVenueNatures: number } +interface Optimization { teacherSameDayWeight: number; classSameDayWeight: number; classGapWeight: number; consecutiveClassWeight: number; earlyPeriodPenalty: number; latePeriodPenalty: number; roomCapacityWasteWeight: number; coursePreferenceWeight: number; geneticPopulationSize: number; geneticGenerationCount: number; geneticMutationRatePercent: number } +interface Job { id: string; schedulePlanId: string; status: 'Queued' | 'Running' | 'Succeeded' | 'Failed'; totalTasks?: number; processedTasks?: number; createdEntries?: number; currentGeneration?: number; totalGenerations?: number; messages?: string[]; totalSteps?: number; completedSteps?: number; currentStep?: string; errorMessage?: string } +interface PlanForm { academicTermId: string; name: string; version: string; notes?: string } +interface CloneForm { name: string; version: string } +interface EntryForm { teachingTaskId: string; kind: 'Lecture' | 'Experiment'; classroomId?: string; dayOfWeek: number; startPeriod: number; periodCount: number; startWeek: number; endWeek: number; weekPattern: string; notes?: string } + +const weekdays = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日'] +const statusLabels: Record = { Draft: '草稿', Published: '已发布', Archived: '已归档' } +const patternLabels: Record = { All: '每周', Odd: '单周', Even: '双周' } +const venueNatures = [{ value: 1, label: '普通教室' }, { value: 2, label: '实验室' }, { value: 4, label: '实训室' }, { value: 8, label: '计算机机房' }, { value: 16, label: '语音室' }, { value: 32, label: '体育场地' }, { value: 64, label: '艺术场地' }] +const defaultOptimization: Optimization = { teacherSameDayWeight: 120, classSameDayWeight: 100, classGapWeight: 240, consecutiveClassWeight: 60, earlyPeriodPenalty: 30, latePeriodPenalty: 30, roomCapacityWasteWeight: 1, coursePreferenceWeight: 180, geneticPopulationSize: 32, geneticGenerationCount: 80, geneticMutationRatePercent: 12 } +const defaultTermId = (terms: Term[]) => terms.find((item) => item.isCurrent)?.id ?? terms.find((item) => !item.isArchived)?.id ?? terms[0]?.id +const termLabel = (term: Term) => `${term.name}${term.isCurrent ? ' · 当前' : term.isArchived ? ' · 已归档' : ' · 历史'}` + +export default function SchedulesPage() { + const { message, modal } = App.useApp() + const client = useQueryClient() + const [termId, setTermId] = useState() + const [selectedId, setSelectedId] = useState() + const [keyword, setKeyword] = useState('') + const [planEditor, setPlanEditor] = useState() + const [cloneOpen, setCloneOpen] = useState(false) + const [entryEditor, setEntryEditor] = useState() + const [settingsOpen, setSettingsOpen] = useState(false) + const [settingsTab, setSettingsTab] = useState('time') + const [timeSlots, setTimeSlots] = useState([]) + const [constraintEditor, setConstraintEditor] = useState() + const [constraintKeyword, setConstraintKeyword] = useState('') + const [constraintCollege, setConstraintCollege] = useState() + const [selectedConstraintIds, setSelectedConstraintIds] = useState([]) + const [batchOpen, setBatchOpen] = useState(false) + const [saving, setSaving] = useState(false) + const [planForm] = Form.useForm(); const [cloneForm] = Form.useForm(); const [entryForm] = Form.useForm(); const [constraintForm] = Form.useForm(); const [batchForm] = Form.useForm(); const [optimizationForm] = Form.useForm() + const entryTaskId = Form.useWatch('teachingTaskId', entryForm) + const entryKind = Form.useWatch('kind', entryForm) + const constraintCampusId = Form.useWatch('requiredCampusId', constraintForm) + const constraintBuildingId = Form.useWatch('requiredBuildingId', constraintForm) + const experimentCampusId = Form.useWatch('experimentRequiredCampusId', constraintForm) + const experimentBuildingId = Form.useWatch('experimentRequiredBuildingId', constraintForm) + const batchCampusId = Form.useWatch('requiredCampusId', batchForm) + const batchBuildingId = Form.useWatch('requiredBuildingId', batchForm) + const batchExperimentCampusId = Form.useWatch('experimentRequiredCampusId', batchForm) + const batchExperimentBuildingId = Form.useWatch('experimentRequiredBuildingId', batchForm) + + const refs = useQuery({ queryKey: ['schedule-references'], queryFn: async () => { const [terms, classrooms, campuses, buildings] = await Promise.all([http.get('/base-data/terms'), http.get('/base-data/classrooms'), http.get('/base-data/campuses'), http.get('/base-data/buildings')]); return { terms: terms.data, classrooms: classrooms.data.filter((item) => item.isEnabled !== false), campuses: campuses.data.filter((item) => item.isEnabled !== false), buildings: buildings.data.filter((item) => item.isEnabled !== false) } } }) + useEffect(() => { if (!termId && refs.data?.terms.length) setTermId(defaultTermId(refs.data.terms)) }, [refs.data?.terms, termId]) + const plans = useQuery({ queryKey: ['schedule-plans', termId], enabled: !!termId, queryFn: async () => (await http.get('/schedules/plans', { params: { academicTermId: termId } })).data }) + useEffect(() => { if (!selectedId || !plans.data?.some((item) => item.id === selectedId)) setSelectedId(plans.data?.[0]?.id) }, [plans.data, selectedId]) + const detail = useQuery({ queryKey: ['schedule-plan', selectedId], enabled: !!selectedId, queryFn: async () => (await http.get(`/schedules/plans/${selectedId}`)).data }) + const slotsQuery = useQuery({ queryKey: ['schedule-time-slots', termId], enabled: !!termId, queryFn: async () => (await http.get('/schedules/time-slots', { params: { academicTermId: termId } })).data }) + useEffect(() => { if (slotsQuery.data) setTimeSlots(slotsQuery.data.map((item) => ({ ...item }))) }, [slotsQuery.data]) + const constraints = useQuery({ queryKey: ['schedule-constraints', termId], enabled: !!termId, queryFn: async () => (await http.get('/schedules/constraints', { params: { academicTermId: termId } })).data }) + const optimization = useQuery({ queryKey: ['schedule-optimization', termId], enabled: !!termId, queryFn: async () => (await http.get('/schedules/optimization-settings', { params: { academicTermId: termId } })).data }) + useEffect(() => { optimizationForm.setFieldsValue(optimization.data ?? defaultOptimization) }, [optimization.data, optimizationForm]) + const autoJob = useQuery({ queryKey: ['schedule-auto-job', selectedId], enabled: !!selectedId && detail.data?.status === 'Draft', queryFn: async () => (await http.get(`/schedules/plans/${selectedId}/auto-schedule-job`)).data, refetchInterval: (query) => ['Queued', 'Running'].includes(query.state.data?.status ?? '') ? 1000 : false }) + const publishJob = useQuery({ queryKey: ['schedule-publish-job', selectedId], enabled: !!selectedId, queryFn: async () => (await http.get(`/schedules/plans/${selectedId}/publish-job`)).data, refetchInterval: (query) => ['Queued', 'Running'].includes(query.state.data?.status ?? '') ? 1000 : false }) + useEffect(() => { if (autoJob.data?.status === 'Succeeded' || publishJob.data?.status === 'Succeeded') { void client.invalidateQueries({ queryKey: ['schedule-plan', selectedId] }); void client.invalidateQueries({ queryKey: ['schedule-plans'] }) } }, [autoJob.data?.status, publishJob.data?.status, client, selectedId]) + + const selected = detail.data + const isDraft = selected?.status === 'Draft' + const jobRunning = ['Queued', 'Running'].includes(autoJob.data?.status ?? '') || ['Queued', 'Running'].includes(publishJob.data?.status ?? '') + const periods = useMemo(() => { const configured = timeSlots.filter((item) => item.isEnabled).map((item) => item.periodNumber); return configured.length ? configured : Array.from({ length: 12 }, (_, index) => index + 1) }, [timeSlots]) + const taskConstraints = (constraints.data ?? []).filter((item) => item.schedulingMode === 'Standard') + const selectedTask = (constraints.data ?? []).find((item) => item.id === entryTaskId) + const visibleEntries = (selected?.entries ?? []).filter((entry) => !keyword.trim() || [entry.taskNumber, entry.taskName, entry.courseCode, entry.courseName, entry.classroomName, ...entry.teacherNames, ...entry.classNames].some((value) => String(value ?? '').toLowerCase().includes(keyword.trim().toLowerCase()))) + const filteredConstraints = (constraints.data ?? []).filter((item) => (!constraintKeyword.trim() || [item.taskNumber, item.name, item.courseCode, item.courseName, ...item.teacherNames].some((value) => String(value).toLowerCase().includes(constraintKeyword.trim().toLowerCase()))) && (!constraintCollege || item.collegeId === constraintCollege)) + const colleges = [...new Map((constraints.data ?? []).map((item) => [item.collegeId, item.collegeName])).entries()].map(([id, name]) => ({ id, name })) + const filteredBuildings = (campusId?: string) => (refs.data?.buildings ?? []).filter((item) => !campusId || item.campusId === campusId) + const filteredRooms = (campusId?: string, buildingId?: string) => (refs.data?.classrooms ?? []).filter((item) => (!campusId || item.campusId === campusId) && (!buildingId || item.buildingId === buildingId)) + const availableEntryRooms = (refs.data?.classrooms ?? []).filter((room) => entryKind === 'Experiment' ? (!selectedTask?.experimentRequiredCampusId || room.campusId === selectedTask.experimentRequiredCampusId) && (!selectedTask?.experimentRequiredBuildingId || room.buildingId === selectedTask.experimentRequiredBuildingId) && (!selectedTask?.allowedExperimentClassroomIds.length || selectedTask.allowedExperimentClassroomIds.includes(room.id)) : (!selectedTask?.requiredCampusId || room.campusId === selectedTask.requiredCampusId) && (!selectedTask?.requiredBuildingId || room.buildingId === selectedTask.requiredBuildingId) && (!selectedTask?.allowedClassroomIds.length || selectedTask.allowedClassroomIds.includes(room.id))) + const timeLabel = (period: number) => { const slot = timeSlots.find((item) => item.periodNumber === period); return slot ? `${slot.startsAt}—${slot.endsAt}` : `第 ${period} 节` } + + function refreshPlan() { return Promise.all([client.invalidateQueries({ queryKey: ['schedule-plans'] }), client.invalidateQueries({ queryKey: ['schedule-plan', selectedId] })]) } + function openPlan(plan?: PlanDetail) { planForm.setFieldsValue({ academicTermId: plan?.academicTermId ?? termId!, name: plan?.name ?? '', version: plan?.version ?? `V${(plans.data?.length ?? 0) + 1}`, notes: plan?.notes ?? '' }); setPlanEditor(plan ?? null) } + async function savePlan(values: PlanForm) { setSaving(true); try { if (planEditor?.id) await http.put(`/schedules/plans/${planEditor.id}`, values); else await http.post('/schedules/plans', values); message.success('排课版本已保存'); setPlanEditor(undefined); await client.invalidateQueries({ queryKey: ['schedule-plans'] }) } catch (error) { message.error(apiErrorMessage(error)) } finally { setSaving(false) } } + function openClone() { if (!selected) return; cloneForm.setFieldsValue({ name: `${selected.name}(修订版)`, version: `V${(plans.data?.length ?? 0) + 1}` }); setCloneOpen(true) } + async function clonePlan(values: CloneForm) { if (!selected) return; setSaving(true); try { const response = await http.post<{ id: string }>(`/schedules/plans/${selected.id}/clone`, values); message.success(selected.status === 'Published' ? '已建立修订草稿,原课表继续生效' : '已复制为可调整草稿'); setCloneOpen(false); await client.invalidateQueries({ queryKey: ['schedule-plans'] }); setSelectedId(response.data.id) } catch (error) { message.error(apiErrorMessage(error)) } finally { setSaving(false) } } + function openEntry(entry?: Entry, day = 1, period = 1) { entryForm.setFieldsValue({ teachingTaskId: entry?.teachingTaskId, kind: entry?.kind ?? 'Lecture', classroomId: entry?.classroomId, dayOfWeek: entry?.dayOfWeek ?? day, startPeriod: entry?.startPeriod ?? period, periodCount: entry?.periodCount ?? 2, startWeek: entry?.startWeek ?? 1, endWeek: entry?.endWeek ?? 16, weekPattern: entry?.weekPattern ?? 'All', notes: entry?.notes ?? '' }); setEntryEditor(entry ?? null) } + async function saveEntry(values: EntryForm) { if (!selected) return; const task = (constraints.data ?? []).find((item) => item.id === values.teachingTaskId); if (values.startWeek > values.endWeek) return message.warning('开始周不能晚于结束周。'); if (task && (values.startWeek < task.startWeek || values.endWeek > task.endWeek)) return message.warning(`排课周次必须位于第 ${task.startWeek}—${task.endWeek} 周内。`); if (task?.requiresClassroom !== false || values.kind === 'Experiment') { if (!values.classroomId) return message.warning('请按课程约束选择教学场地。') } else values.classroomId = undefined; setSaving(true); try { const base = `/schedules/plans/${selected.id}/entries`; if (entryEditor?.id) await http.put(`${base}/${entryEditor.id}`, values); else await http.post(base, values); message.success(entryEditor?.id ? '排课已调整' : '排课已添加'); setEntryEditor(undefined); await refreshPlan() } catch (error) { message.error(apiErrorMessage(error)) } finally { setSaving(false) } } + function removeEntry(entry: Entry) { if (!selected) return; modal.confirm({ title: `移除“${entry.taskName}”的这条安排?`, okText: '移除', okButtonProps: { danger: true }, onOk: async () => { try { await http.delete(`/schedules/plans/${selected.id}/entries/${entry.id}`); await refreshPlan() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) } + function deletePlan() { if (!selected) return; modal.confirm({ title: '删除该排课草稿及全部排课条目?', okText: '删除草稿', okButtonProps: { danger: true }, onOk: async () => { try { await http.delete(`/schedules/plans/${selected.id}`); setSelectedId(undefined); await client.invalidateQueries({ queryKey: ['schedule-plans'] }) } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) } + async function preflight() { if (!selected) return; try { const { data } = await http.get<{ totalTasks: number; scheduledTasks: number; unscheduledTasks: number; messages: string[] }>(`/schedules/plans/${selected.id}/preflight`); modal.info({ title: `排课前检查 · 已覆盖 ${data.scheduledTasks}/${data.totalTasks}`, content: data.unscheduledTasks ?

尚有 {data.unscheduledTasks} 个教学班未安排:

    {data.messages.map((item) =>
  1. {item}
  2. )}
: '当前草稿已覆盖全部需要排课的教学班。' }) } catch (error) { message.error(apiErrorMessage(error)) } } + function startJob(kind: 'auto-schedule' | 'publish') { if (!selected) return; modal.confirm({ title: kind === 'auto-schedule' ? '开始自动排课?' : '检查并发布课表?', content: kind === 'auto-schedule' ? '系统会保留手工安排并补齐理论课和实验课,生成后仍可逐项调整。' : '系统会再次检查教师、行政班和教室冲突;发布成功后本版本锁定并替换旧课表。', okText: kind === 'auto-schedule' ? '生成排课' : '检查并发布', onOk: async () => { try { await http.post(`/schedules/plans/${selected.id}/${kind}`); message.success('后台任务已提交,离开页面不会中断'); await client.invalidateQueries({ queryKey: [kind === 'auto-schedule' ? 'schedule-auto-job' : 'schedule-publish-job', selected.id] }) } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) } + function initializeSlots() { const values = [['08:00', '08:45'], ['08:55', '09:40'], ['10:00', '10:45'], ['10:55', '11:40'], ['14:00', '14:45'], ['14:55', '15:40'], ['16:00', '16:45'], ['16:55', '17:40'], ['19:00', '19:45'], ['19:55', '20:40']]; setTimeSlots(values.map(([startsAt, endsAt], index) => ({ periodNumber: index + 1, name: `第 ${index + 1} 节`, startsAt, endsAt, isEnabled: true }))) } + async function saveSlots() { if (!termId) return; setSaving(true); try { await http.put(`/schedules/time-slots/${termId}`, timeSlots); message.success('上课时间表已保存'); await client.invalidateQueries({ queryKey: ['schedule-time-slots', termId] }) } catch (error) { message.error(apiErrorMessage(error)) } finally { setSaving(false) } } + async function saveOptimization(values: Optimization) { if (!termId) return; setSaving(true); try { const response = await http.put(`/schedules/optimization-settings/${termId}`, values); optimizationForm.setFieldsValue(response.data); message.success('自动排课优化策略已保存') } catch (error) { message.error(apiErrorMessage(error)) } finally { setSaving(false) } } + function openConstraint(item: Constraint) { constraintForm.setFieldsValue({ ...item, allowedExperimentVenueNatures: venueNatures.filter((nature) => (item.allowedExperimentVenueNatures & nature.value) !== 0).map((nature) => nature.value), allowedDayOfWeeks: item.allowedDayOfWeeks.length ? item.allowedDayOfWeeks : [1, 2, 3, 4, 5] }); setConstraintEditor(item) } + async function saveConstraint(values: Record) { if (!constraintEditor) return; const payload = { ...values, allowedClassroomIds: values.allowedClassroomIds ?? [], allowedExperimentClassroomIds: values.allowedExperimentClassroomIds ?? [], preferredDayOfWeeks: values.preferredDayOfWeeks ?? [], allowedExperimentVenueNatures: ((values.allowedExperimentVenueNatures as number[] | undefined) ?? []).reduce((sum, value) => sum | value, 0) }; setSaving(true); try { await http.put(`/schedules/constraints/${constraintEditor.id}`, payload); message.success('排课约束已保存'); setConstraintEditor(undefined); await client.invalidateQueries({ queryKey: ['schedule-constraints', termId] }) } catch (error) { message.error(apiErrorMessage(error)) } finally { setSaving(false) } } + async function saveBatch(values: Record) { if (!termId || !selectedConstraintIds.length) return; if (!values.updateSchedulingMode && !values.updateRequiresClassroom && !values.updateDays && !values.updatePeriodRange && !values.updateClassroomScope && !values.updateExperimentClassroomScope) return message.warning('请至少勾选一项需要批量修改的设置。'); const updateClassroomScope = !!values.updateClassroomScope; const updateExperimentClassroomScope = !!values.updateExperimentClassroomScope; const payload = { academicTermId: termId, teachingTaskIds: selectedConstraintIds, schedulingMode: values.updateSchedulingMode ? values.schedulingMode : null, requiresClassroom: values.updateRequiresClassroom ? values.requiresClassroom : null, allowedDayOfWeeks: values.updateDays ? values.allowedDayOfWeeks : null, updateClassroomScope, requiredCampusId: updateClassroomScope ? values.requiredCampusId ?? null : null, requiredBuildingId: updateClassroomScope ? values.requiredBuildingId ?? null : null, allowedClassroomIds: updateClassroomScope ? values.allowedClassroomIds ?? [] : null, updatePeriodRange: !!values.updatePeriodRange, earliestPeriod: values.updatePeriodRange ? values.earliestPeriod ?? null : null, latestPeriod: values.updatePeriodRange ? values.latestPeriod ?? null : null, updateExperimentClassroomScope, allowedExperimentVenueNatures: updateExperimentClassroomScope ? ((values.allowedExperimentVenueNatures as number[] | undefined) ?? []).reduce((sum, value) => sum | value, 0) : null, allowedExperimentClassroomIds: updateExperimentClassroomScope ? values.allowedExperimentClassroomIds ?? [] : null, experimentRequiredCampusId: updateExperimentClassroomScope ? values.experimentRequiredCampusId ?? null : null, experimentRequiredBuildingId: updateExperimentClassroomScope ? values.experimentRequiredBuildingId ?? null : null }; setSaving(true); try { const response = await http.put<{ affectedCount: number }>('/schedules/constraints/batch', payload); message.success(`已批量更新 ${response.data.affectedCount} 个教学任务`); setBatchOpen(false); setSelectedConstraintIds([]); await client.invalidateQueries({ queryKey: ['schedule-constraints', termId] }) } catch (error) { message.error(apiErrorMessage(error)) } finally { setSaving(false) } } + + const constraintColumns: ColumnsType = [{ title: '教学任务', render: (_, row) =>
{row.name} · {row.courseName}{row.taskNumber} · {row.collegeName} · {row.teacherNames.join('、') || '未分配教师'}
}, { title: '授课方式', width: 110, render: (_, row) => {row.schedulingMode === 'Flexible' ? '非排时课程' : '正常排课'} }, { title: '场地', width: 110, render: (_, row) => row.requiresClassroom ? '占用教室' : '不占教室' }, { title: '约束', width: 110, render: (_, row) => row.hasCustomConstraint ? 已自定义 : 默认约束 }, { title: '操作', width: 90, render: (_, row) => }] + const constraintSelection: TableRowSelection = { selectedRowKeys: selectedConstraintIds, onChange: (keys) => setSelectedConstraintIds(keys.map(String)) } + const jobPanel = (job: Job | null | undefined, kind: 'auto' | 'publish') => { if (!job) return null; const total = kind === 'auto' ? job.totalTasks ?? 0 : job.totalSteps ?? 0; const done = kind === 'auto' ? job.processedTasks ?? 0 : job.completedSteps ?? 0; const percent = total ? Math.min(100, Math.round(done / total * 100)) : 0; const text = job.status === 'Queued' ? '任务已进入队列' : job.status === 'Running' ? (kind === 'auto' ? job.totalGenerations ? `遗传算法正在搜索第 ${job.currentGeneration}/${job.totalGenerations} 代` : `正在处理 ${done}/${total} 个教学班` : job.currentStep || '正在检查并发布课表') : job.status === 'Succeeded' ? (kind === 'auto' ? `自动排课完成,共生成 ${job.createdEntries ?? 0} 条安排` : '检查通过,课表发布成功') : job.errorMessage || '后台任务失败'; return
{text}{!!job.messages?.length && {job.messages.map((item) =>
  • {item}
  • )}} />}
    } + + if (refs.isLoading) return
    + if (refs.isError) return
    + return
    + } /> +
    setKeyword(event.target.value)} />单双周和周次范围显示在课程卡片内
    节次
    {weekdays.map((day) =>
    {day}
    )}{periods.map((period) =>
    {period}{timeLabel(period)}
    {weekdays.map((_, index) => { const entries = visibleEntries.filter((entry) => entry.dayOfWeek === index + 1 && entry.startPeriod === period); return
    isDraft && openEntry(undefined, index + 1, period)}>{entries.map((entry) =>
    isDraft && openEntry(entry)}>{entry.kind === 'Experiment' ? '实验课' : '理论课'} · {entry.courseCode} · {patternLabels[entry.weekPattern]}{entry.courseName}{entry.teacherNames.join('、')} · {entry.classroomName || '不占教室'}{entry.startWeek}—{entry.endWeek} 周 / 连上 {entry.periodCount} 节{isDraft && }
    )}{isDraft && !entries.length && 双击添加}
    })}
    )}
    :
    } + + setPlanEditor(undefined)} onOk={() => planForm.submit()}>
    void savePlan(values)}>
    + setCloneOpen(false)} onOk={() => cloneForm.submit()}>{selected?.status === 'Published' && }
    void clonePlan(values)}>
    + setEntryEditor(undefined)} onOk={() => entryForm.submit()}>
    void saveEntry(values)}> ({ value: item.id, label: `${item.campusName} / ${item.buildingName} / ${item.name}(${item.capacity} 人)` }))} />}
    ({ value, label: `第 ${value} 节 · ${timeLabel(value)}` }))} />
    setTimeSlots((items) => items.map((item, i) => i === index ? { ...item, name: event.target.value } : item))} /> }, { title: '上课', width: 115, render: (_, row, index) => setTimeSlots((items) => items.map((item, i) => i === index ? { ...item, startsAt: event.target.value } : item))} /> }, { title: '下课', width: 115, render: (_, row, index) => setTimeSlots((items) => items.map((item, i) => i === index ? { ...item, endsAt: event.target.value } : item))} /> }, { title: '启用', width: 75, render: (_, row, index) => setTimeSlots((items) => items.map((item, i) => i === index ? { ...item, isEnabled: checked } : item))} /> }, { title: '', width: 65, render: (_, __, index) => }]} /> }, { key: 'optimization', label: '自动排课优化', children: <> void saveOptimization(values)}>
    {[{ key: 'teacherSameDayWeight', label: '教师同日课次惩罚', min: 0, max: 10000 }, { key: 'classSameDayWeight', label: '行政班同日课次惩罚', min: 0, max: 10000 }, { key: 'classGapWeight', label: '行政班空档惩罚', min: 0, max: 10000 }, { key: 'consecutiveClassWeight', label: '行政班连堂惩罚', min: 0, max: 10000 }, { key: 'earlyPeriodPenalty', label: '首节课惩罚', min: 0, max: 10000 }, { key: 'latePeriodPenalty', label: '末节课惩罚', min: 0, max: 10000 }, { key: 'roomCapacityWasteWeight', label: '教室容量冗余权重', min: 0, max: 10000 }, { key: 'coursePreferenceWeight', label: '课程偏好权重', min: 0, max: 10000 }, { key: 'geneticPopulationSize', label: '遗传算法种群规模', min: 12, max: 64 }, { key: 'geneticGenerationCount', label: '遗传算法迭代代数', min: 12, max: 160 }, { key: 'geneticMutationRatePercent', label: '遗传算法变异率(%)', min: 1, max: 50 }].map((item) => )}
    }, { key: 'constraints', label: '课程排课约束', children: <>
    课程排课约束非排时课程不占用正常时间和场地。
    setConstraintKeyword(event.target.value)} /> ({ value: item.id, label: item.name }))} onChange={() => constraintForm.setFieldsValue({ requiredBuildingId: undefined, allowedClassroomIds: [] })} /> ({ value: item.id, label: `${item.buildingName} / ${item.name}(${item.capacity} 人)` }))} /> ({ value: index + 1, label }))} />
    {!!constraintEditor?.coursePracticeHours && <>
    ({ value: item.id, label: item.name }))} onChange={() => constraintForm.setFieldValue('allowedExperimentClassroomIds', [])} />
    ({ value: item.id, label: item.name }))} onChange={() => batchForm.setFieldsValue({ requiredBuildingId: undefined, allowedClassroomIds: [] })} /> ({ value: item.id, label: `${item.buildingName} / ${item.name}` }))} />修改实验场地范围
    ({ value: item.id, label: item.name }))} onChange={() => batchForm.setFieldValue('allowedExperimentClassroomIds', [])} />