feat(web): migrate schedule administration

This commit is contained in:
biss authored and biss committed 2026-10-03 21:38:14 +08:00
1 parent e11772f3aa
commit dea8f70b48
6 files changed
+135

No files matched your search

+2
View File
@@ -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() {
<Route path="/teachers" element={<PersonnelRegistryPage />} />
<Route path="/students" element={<PersonnelRegistryPage />} />
<Route path="/teaching-tasks" element={<TeachingTasksPage />} />
<Route path="/schedules" element={<SchedulesPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
+1
View File
@@ -113,6 +113,7 @@ export default function AppShell() {
] : []),
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? [
{ key: '/teaching-tasks', icon: <ScheduleOutlined />, label: '教学任务' },
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ? [{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理' }] : []),
{ key: '/curriculum', icon: <ReadOutlined />, label: '培养方案管理' },
{ key: '/course-equivalency-rules', icon: <SwapOutlined />, label: '等价课程规则' },
{ key: '/course-enrollments', icon: <BookOutlined />, label: '修读课程管理' },
+129
View File
@@ -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<string, string> = { Draft: '草稿', Published: '已发布', Archived: '已归档' }
const patternLabels: Record<string, string> = { 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<string>()
const [selectedId, setSelectedId] = useState<string>()
const [keyword, setKeyword] = useState('')
const [planEditor, setPlanEditor] = useState<PlanDetail | null | undefined>()
const [cloneOpen, setCloneOpen] = useState(false)
const [entryEditor, setEntryEditor] = useState<Entry | null | undefined>()
const [settingsOpen, setSettingsOpen] = useState(false)
const [settingsTab, setSettingsTab] = useState('time')
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([])
const [constraintEditor, setConstraintEditor] = useState<Constraint>()
const [constraintKeyword, setConstraintKeyword] = useState('')
const [constraintCollege, setConstraintCollege] = useState<string>()
const [selectedConstraintIds, setSelectedConstraintIds] = useState<string[]>([])
const [batchOpen, setBatchOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [planForm] = Form.useForm<PlanForm>(); const [cloneForm] = Form.useForm<CloneForm>(); const [entryForm] = Form.useForm<EntryForm>(); const [constraintForm] = Form.useForm(); const [batchForm] = Form.useForm(); const [optimizationForm] = Form.useForm<Optimization>()
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<Term[]>('/base-data/terms'), http.get<Classroom[]>('/base-data/classrooms'), http.get<Campus[]>('/base-data/campuses'), http.get<Building[]>('/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<PlanSummary[]>('/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<PlanDetail>(`/schedules/plans/${selectedId}`)).data })
const slotsQuery = useQuery({ queryKey: ['schedule-time-slots', termId], enabled: !!termId, queryFn: async () => (await http.get<TimeSlot[]>('/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<Constraint[]>('/schedules/constraints', { params: { academicTermId: termId } })).data })
const optimization = useQuery({ queryKey: ['schedule-optimization', termId], enabled: !!termId, queryFn: async () => (await http.get<Optimization>('/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<Job | null>(`/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<Job | null>(`/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 ? <div><p>尚有 {data.unscheduledTasks} 个教学班未安排:</p><ol>{data.messages.map((item) => <li key={item}>{item}</li>)}</ol></div> : '当前草稿已覆盖全部需要排课的教学班。' }) } 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<Optimization>(`/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<string, unknown>) { 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<string, unknown>) { 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<Constraint> = [{ title: '教学任务', render: (_, row) => <div className="constraint-task"><strong>{row.name} · {row.courseName}</strong><span>{row.taskNumber} · {row.collegeName} · {row.teacherNames.join('、') || '未分配教师'}</span></div> }, { title: '授课方式', width: 110, render: (_, row) => <Tag color={row.schedulingMode === 'Flexible' ? 'green' : 'blue'}>{row.schedulingMode === 'Flexible' ? '非排时课程' : '正常排课'}</Tag> }, { title: '场地', width: 110, render: (_, row) => row.requiresClassroom ? '占用教室' : '不占教室' }, { title: '约束', width: 110, render: (_, row) => row.hasCustomConstraint ? <Tag color="gold">已自定义</Tag> : <Tag>默认约束</Tag> }, { title: '操作', width: 90, render: (_, row) => <Button size="small" onClick={() => openConstraint(row)}>设置</Button> }]
const constraintSelection: TableRowSelection<Constraint> = { 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 <div className={`schedule-job is-${job.status.toLowerCase()}`}><strong>{text}</strong><Progress percent={percent} status={job.status === 'Failed' ? 'exception' : job.status === 'Succeeded' ? 'success' : 'active'} />{!!job.messages?.length && <Alert type="warning" showIcon message={`${job.messages.length} 个任务仍需人工处理`} description={<ol>{job.messages.map((item) => <li key={item}>{item}</li>)}</ol>} />}</div> }
if (refs.isLoading) return <main className="page-container"><Skeleton active paragraph={{ rows: 10 }} /></main>
if (refs.isError) return <main className="page-container"><Alert type="error" showIcon message="排课基础数据无法加载" description={apiErrorMessage(refs.error)} /></main>
return <main className="page-container schedules-page">
<PageHeader kicker="TIMETABLE BOARD" title="排课与课表" description="用作息时间和课程约束驱动教师、班级、场地自动分配,生成后仍可逐项微调。" actions={<div className="schedule-header-actions"><Button icon={<SettingOutlined />} onClick={() => setSettingsOpen(true)}>排课规则与作息</Button><Button type="primary" icon={<PlusOutlined />} onClick={() => openPlan()}>新建排课版本</Button></div>} />
<section className="schedule-toolbar"><Select value={termId} options={(refs.data?.terms ?? []).map((item) => ({ value: item.id, label: termLabel(item) }))} onChange={(value) => { setTermId(value); setSelectedId(undefined) }} /><div className="schedule-versions">{plans.data?.map((plan) => <button key={plan.id} className={selectedId === plan.id ? 'active' : ''} onClick={() => setSelectedId(plan.id)}><strong>{plan.version}</strong><span>{statusLabels[plan.status]}</span><small>{plan.entryCount} 条安排</small></button>)}{!plans.isLoading && !plans.data?.length && <span>该学期还没有排课版本</span>}</div></section>
{selected ? <section className="schedule-sheet"><header><div><span>{selected.termName} · {selected.version}</span><h2>{selected.name}</h2><p>共 {selected.entries.length} 条安排 · {statusLabels[selected.status]}</p></div><div className="schedule-actions">{isDraft && <Button disabled={jobRunning} onClick={() => openPlan(selected)}>编辑版本</Button>}<Button icon={<CopyOutlined />} disabled={jobRunning} onClick={openClone}>{selected.status === 'Published' ? '发布后修改' : '复制调整'}</Button>{isDraft && <><Button disabled={jobRunning} onClick={() => void preflight()}>排课前检查</Button><Button icon={<ThunderboltOutlined />} disabled={jobRunning} onClick={() => startJob('auto-schedule')}>自动排课</Button><Button type="primary" icon={<PlusOutlined />} disabled={jobRunning} onClick={() => openEntry()}>添加排课</Button><Button type="primary" disabled={jobRunning} onClick={() => startJob('publish')}>发布课表</Button><Button danger disabled={jobRunning} onClick={deletePlan}>删除草稿</Button></>}</div></header>{jobPanel(autoJob.data, 'auto')}{jobPanel(publishJob.data, 'publish')}<div className="schedule-search"><Input allowClear value={keyword} placeholder="筛选课程、教师、行政班或教室" onChange={(event) => setKeyword(event.target.value)} /><Button icon={<ReloadOutlined />} onClick={() => setKeyword('')}>清除筛选</Button><span>单双周和周次范围显示在课程卡片内</span></div><div className="timetable-scroll"><div className="timetable-grid"><div className="timetable-corner">节次</div>{weekdays.map((day) => <div className="timetable-day" key={day}>{day}</div>)}{periods.map((period) => <div className="timetable-row" key={period}><div className="timetable-period"><strong>{period}</strong><span>{timeLabel(period)}</span></div>{weekdays.map((_, index) => { const entries = visibleEntries.filter((entry) => entry.dayOfWeek === index + 1 && entry.startPeriod === period); return <div className={`timetable-cell ${isDraft ? 'editable' : ''}`} key={`${index}-${period}`} onDoubleClick={() => isDraft && openEntry(undefined, index + 1, period)}>{entries.map((entry) => <article className={entry.kind === 'Experiment' ? 'experiment' : ''} key={entry.id} onClick={() => isDraft && openEntry(entry)}><span>{entry.kind === 'Experiment' ? '实验课' : '理论课'} · {entry.courseCode} · {patternLabels[entry.weekPattern]}</span><strong>{entry.courseName}</strong><small>{entry.teacherNames.join('、')} · {entry.classroomName || '不占教室'}</small><i>{entry.startWeek}—{entry.endWeek} 周 / 连上 {entry.periodCount} 节</i>{isDraft && <button onClick={(event) => { event.stopPropagation(); removeEntry(entry) }}>×</button>}</article>)}{isDraft && !entries.length && <em>双击添加</em>}</div> })}</div>)}</div></div></section> : <section className="schedule-empty"><Empty description="请选择或新建一个排课版本" /></section>}
<Modal open={planEditor !== undefined} title={planEditor?.id ? '编辑排课版本' : '新建排课版本'} okText="保存" confirmLoading={saving} onCancel={() => setPlanEditor(undefined)} onOk={() => planForm.submit()}><Form form={planForm} layout="vertical" onFinish={(values) => void savePlan(values)}><Form.Item name="academicTermId" label="学期" rules={[{ required: true }]}><Select options={(refs.data?.terms ?? []).map((item) => ({ value: item.id, label: termLabel(item) }))} /></Form.Item><div className="schedule-form-grid"><Form.Item name="name" label="版本名称" rules={[{ required: true, whitespace: true }]}><Input placeholder="如:第一轮正式课表" /></Form.Item><Form.Item name="version" label="版本号" rules={[{ required: true, whitespace: true }]}><Input placeholder="如 V1" /></Form.Item></div><Form.Item name="notes" label="备注"><Input.TextArea rows={2} maxLength={500} /></Form.Item></Form></Modal>
<Modal open={cloneOpen} title={selected?.status === 'Published' ? '发布后修改课表' : '复制排课版本'} okText={selected?.status === 'Published' ? '建立修订草稿' : '复制'} confirmLoading={saving} onCancel={() => setCloneOpen(false)} onOk={() => cloneForm.submit()}>{selected?.status === 'Published' && <Alert type="info" showIcon message="系统会建立可编辑的修订草稿;原课表在修订版重新发布前继续生效。" />}<Form form={cloneForm} layout="vertical" onFinish={(values) => void clonePlan(values)}><Form.Item name="name" label="新版本名称" rules={[{ required: true }]}><Input /></Form.Item><Form.Item name="version" label="新版本号" rules={[{ required: true }]}><Input /></Form.Item></Form></Modal>
<Modal open={entryEditor !== undefined} title={entryEditor?.id ? '调整排课' : '添加排课'} width={700} okText="检查冲突并保存" confirmLoading={saving} onCancel={() => setEntryEditor(undefined)} onOk={() => entryForm.submit()}><Form form={entryForm} layout="vertical" onFinish={(values) => void saveEntry(values)}><Form.Item name="teachingTaskId" label="教学任务" rules={[{ required: true }]}><Select showSearch optionFilterProp="label" options={taskConstraints.map((item) => ({ value: item.id, label: `${item.taskNumber} · ${item.name} · ${item.courseName}` }))} onChange={(value) => { const task = taskConstraints.find((item) => item.id === value); if (task) entryForm.setFieldsValue({ startWeek: task.startWeek, endWeek: task.endWeek, classroomId: undefined }) }} /></Form.Item><Form.Item name="kind" label="课次类型"><Radio.Group optionType="button" options={[{ value: 'Lecture', label: '理论课' }, { value: 'Experiment', label: '实验课', disabled: !selectedTask?.coursePracticeHours }]} onChange={() => entryForm.setFieldValue('classroomId', undefined)} /></Form.Item>{selectedTask && <Alert className="entry-alert" type="info" showIcon message={`课程共 ${selectedTask.courseTotalHours} 学时,其中实践 ${selectedTask.coursePracticeHours} 学时;可排第 ${selectedTask.startWeek}—${selectedTask.endWeek} 周。`} />}{!(selectedTask?.requiresClassroom === false && entryKind !== 'Experiment') && <Form.Item name="classroomId" label={entryKind === 'Experiment' ? '教学场地' : '教室'} rules={[{ required: true }]}><Select showSearch optionFilterProp="label" options={availableEntryRooms.map((item) => ({ value: item.id, label: `${item.campusName} / ${item.buildingName} / ${item.name}(${item.capacity} 人)` }))} /></Form.Item>}<div className="schedule-form-grid three"><Form.Item name="dayOfWeek" label="星期"><Select options={weekdays.map((label, index) => ({ value: index + 1, label }))} /></Form.Item><Form.Item name="startPeriod" label="开始节次"><Select options={periods.map((value) => ({ value, label: `第 ${value} 节 · ${timeLabel(value)}` }))} /></Form.Item><Form.Item name="periodCount" label="连续节数"><InputNumber min={1} max={6} /></Form.Item></div><div className="schedule-form-grid three"><Form.Item name="startWeek" label="开始周"><InputNumber min={1} max={30} /></Form.Item><Form.Item name="endWeek" label="结束周"><InputNumber min={1} max={30} /></Form.Item><Form.Item name="weekPattern" label="单双周"><Select options={Object.entries(patternLabels).map(([value, label]) => ({ value, label }))} /></Form.Item></div><Form.Item name="notes" label="备注"><Input.TextArea rows={2} /></Form.Item></Form></Modal>
<Drawer open={settingsOpen} title="排课规则与作息" width={820} onClose={() => setSettingsOpen(false)}><Tabs activeKey={settingsTab} onChange={setSettingsTab} items={[{ key: 'time', label: '上课时间表', children: <><div className="settings-lead"><div><strong>上课时间表</strong><span>自动排课只使用启用节次。</span></div><div>{!timeSlots.length && <Button onClick={initializeSlots}>载入常用作息</Button>}<Button onClick={() => setTimeSlots((items) => [...items, { periodNumber: (items.at(-1)?.periodNumber ?? 0) + 1, name: `第 ${(items.at(-1)?.periodNumber ?? 0) + 1} 节`, startsAt: '08:00', endsAt: '08:45', isEnabled: true }])}>增加节次</Button><Button type="primary" loading={saving} onClick={() => void saveSlots()}>保存时间表</Button></div></div><Table<TimeSlot> rowKey={(row) => String(row.id ?? row.periodNumber)} pagination={false} dataSource={timeSlots} columns={[{ title: '节次', width: 95, render: (_, row, index) => <InputNumber min={1} max={30} value={row.periodNumber} onChange={(value) => setTimeSlots((items) => items.map((item, i) => i === index ? { ...item, periodNumber: value ?? 1 } : item))} /> }, { title: '名称', render: (_, row, index) => <Input value={row.name} onChange={(event) => setTimeSlots((items) => items.map((item, i) => i === index ? { ...item, name: event.target.value } : item))} /> }, { title: '上课', width: 115, render: (_, row, index) => <Input type="time" value={row.startsAt} onChange={(event) => setTimeSlots((items) => items.map((item, i) => i === index ? { ...item, startsAt: event.target.value } : item))} /> }, { title: '下课', width: 115, render: (_, row, index) => <Input type="time" value={row.endsAt} onChange={(event) => setTimeSlots((items) => items.map((item, i) => i === index ? { ...item, endsAt: event.target.value } : item))} /> }, { title: '启用', width: 75, render: (_, row, index) => <Switch checked={row.isEnabled} onChange={(checked) => setTimeSlots((items) => items.map((item, i) => i === index ? { ...item, isEnabled: checked } : item))} /> }, { title: '', width: 65, render: (_, __, index) => <Button danger type="link" onClick={() => setTimeSlots((items) => items.filter((_, i) => i !== index))}>移除</Button> }]} /></> }, { key: 'optimization', label: '自动排课优化', children: <><Alert type="info" showIcon message="设置仅影响之后提交的自动排课任务,不会改动已生成或已发布课表。" /><Form className="optimization-form" form={optimizationForm} layout="vertical" onFinish={(values) => void saveOptimization(values)}><div className="schedule-form-grid">{[{ 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) => <Form.Item key={item.key} name={item.key} label={item.label}><InputNumber min={item.min} max={item.max} /></Form.Item>)}</div><Button type="primary" htmlType="submit" loading={saving}>保存优化策略</Button></Form></> }, { key: 'constraints', label: '课程排课约束', children: <><div className="settings-lead"><div><strong>课程排课约束</strong><span>非排时课程不占用正常时间和场地。</span></div><Button type="primary" disabled={!selectedConstraintIds.length} onClick={() => { batchForm.setFieldsValue({ updateSchedulingMode: false, schedulingMode: 'Standard', updateRequiresClassroom: false, requiresClassroom: true, updateDays: false, allowedDayOfWeeks: [1, 2, 3, 4, 5], updatePeriodRange: false, updateClassroomScope: false, allowedClassroomIds: [], updateExperimentClassroomScope: false, allowedExperimentVenueNatures: [], allowedExperimentClassroomIds: [] }); setBatchOpen(true) }}>批量修改已选({selectedConstraintIds.length})</Button></div><div className="constraint-filters"><Input allowClear value={constraintKeyword} placeholder="任务、课程或教师" onChange={(event) => setConstraintKeyword(event.target.value)} /><Select allowClear value={constraintCollege} placeholder="全部开课单位" options={colleges.map((item) => ({ value: item.id, label: item.name }))} onChange={setConstraintCollege} /></div><Table<Constraint> rowKey="id" size="small" rowSelection={conLine truncated
<Modal open={!!constraintEditor} title="设置课程排课约束" width={760} okText="保存约束" confirmLoading={saving} onCancel={() => setConstraintEditor(undefined)} onOk={() => constraintForm.submit()}><Form form={constraintForm} layout="vertical" onFinish={(values) => void saveConstraint(values)}><Alert type="info" showIcon message={constraintEditor ? `${constraintEditor.taskNumber} · ${constraintEditor.name} · ${constraintEditor.courseName}` : ''} /><div className="schedule-form-grid"><Form.Item name="schedulingMode" label="授课方式"><Radio.Group optionType="button" options={[{ value: 'Standard', label: '正常排课' }, { value: 'Flexible', label: '非排时课程' }]} /></Form.Item><Form.Item name="requiresClassroom" label="是否占用教室" valuePropName="checked"><Switch checkedChildren="需要教室" unCheckedChildren="不占教室" /></Form.Item></div><Form.Item name="allowedDayOfWeeks" label="允许上课日"><Checkbox.Group options={weekdays.map((label, index) => ({ value: index + 1, label }))} /></Form.Item><div className="schedule-form-grid"><Form.Item name="earliestPeriod" label="最早节次"><InputNumber min={1} max={30} /></Form.Item><Form.Item name="latestPeriod" label="最晚节次"><InputNumber min={1} max={30} /></Form.Item></div><div className="schedule-form-grid"><Form.Item name="requiredCampusId" label="限定校区"><Select allowClear options={(refs.data?.campuses ?? []).map((item) => ({ value: item.id, label: item.name }))} onChange={() => constraintForm.setFieldsValue({ requiredBuildingId: undefined, allowedClassroomIds: [] })} /></Form.Item><Form.Item name="requiredBuildingId" label="限定教学楼"><Select allowClear options={filteredBuildings(constraintCampusId).map((item) => ({ value: item.id, label: item.name }))} onChange={() => constraintForm.setFieldValue('allowedClassroomIds', [])} /></Form.Item></div><Form.Item name="allowedClassroomIds" label="指定普通课教室"><Select mode="multiple" showSearch optionFilterProp="label" options={filteredRooms(constraintCampusId, constraintBuildingId).map((item) => ({ value: item.id, label: `${item.buildingName} / ${item.name}(${item.capacity} 人)` }))} /></Form.Item><Form.Item name="preferredDayOfWeeks" label="偏好上课日"><Checkbox.Group options={weekdays.map((label, index) => ({ value: index + 1, label }))} /></Form.Item><div className="schedule-form-grid"><Form.Item name="preferredEarliestPeriod" label="偏好最早节次"><InputNumber min={1} max={30} /></Form.Item><Form.Item name="preferredLatestPeriod" label="偏好最晚节次"><InputNumber min={1} max={30} /></Form.Item></div>{!!constraintEditor?.coursePracticeHours && <><Form.Item name="allowedExperimentVenueNatures" label="实验场地性质"><Checkbox.Group options={venueNatures} /></Form.Item><div className="schedule-form-grid"><Form.Item name="experimentRequiredCampusId" label="实验校区"><Select allowClear options={(refs.data?.campuses ?? []).map((item) => ({ value: item.id, label: item.name }))} onChange={() => constraintForm.setFieldsValue({ experimentRequiredBuildingId: undefined, allowedExperimentClassroomIds: [] })} /></Form.Item><Form.Item name="experimentRequiredBuildingId" label="实验教学楼"><Select allowClear options={filteredBuildings(experimentCampusId).map((item) => ({ value: item.id, label: item.name }))} onChange={() => constraintForm.setFieldValue('allowedExperimentClassroomIds', [])} /></Form.Item></div><Form.Item name="allowedExperimentClassroomIds" label="指定实验场地"><Select mode="multiple" showSearch optionFilterProp="label" options={filteredRooms(experimentCampusId, experimentBuildingId).map((item) => ({ value: item.id, label: `${item.buildingName} / ${item.name}` }))} /></Form.Item></>}</Form></Modal>
<Modal open={batchOpen} title="批量修改排课约束" width={720} okText="修改已选任务" confirmLoading={saving} onCancel={() => setBatchOpen(false)} onOk={() => batchForm.submit()}><Alert type="warning" showIcon message={`将修改选中的 ${selectedConstraintIds.length} 个教学任务;只会提交已勾选的设置。`} /><Form form={batchForm} layout="vertical" onFinish={(values) => void saveBatch(values)}><Form.Item name="updateSchedulingMode" valuePropName="checked"><Checkbox>修改授课方式</Checkbox></Form.Item><Form.Item name="schedulingMode"><Radio.Group optionType="button" options={[{ value: 'Standard', label: '正常排课' }, { value: 'Flexible', label: '非排时课程' }]} /></Form.Item><Form.Item name="updateRequiresClassroom" valuePropName="checked"><Checkbox>修改场地要求</Checkbox></Form.Item><Form.Item name="requiresClassroom" valuePropName="checked"><Switch checkedChildren="需要教室" unCheckedChildren="不占教室" /></Form.Item><Form.Item name="updateDays" valuePropName="checked"><Checkbox>修改允许上课日</Checkbox></Form.Item><Form.Item name="allowedDayOfWeeks"><Checkbox.Group options={weekdays.map((label, index) => ({ value: index + 1, label }))} /></Form.Item><Form.Item name="updatePeriodRange" valuePropName="checked"><Checkbox>修改允许节次范围</Checkbox></Form.Item><div className="schedule-form-grid"><Form.Item name="earliestPeriod" label="最早节次"><InputNumber min={1} max={30} /></Form.Item><Form.Item name="latestPeriod" label="最晚节次"><InputNumber min={1} max={30} /></Form.Item></div><Form.Item name="updateClassroomScope" valuePropName="checked"><Checkbox>修改普通课教室范围</Checkbox></Form.Item><div className="schedule-form-grid"><Form.Item name="requiredCampusId" label="限定校区"><Select allowClear options={(refs.data?.campuses ?? []).map((item) => ({ value: item.id, label: item.name }))} onChange={() => batchForm.setFieldsValue({ requiredBuildingId: undefined, allowedClassroomIds: [] })} /></Form.Item><Form.Item name="requiredBuildingId" label="限定教学楼"><Select allowClear options={filteredBuildings(batchCampusId).map((item) => ({ value: item.id, label: item.name }))} onChange={() => batchForm.setFieldValue('allowedClassroomIds', [])} /></Form.Item></div><Form.Item name="allowedClassroomIds" label="指定普通课教室"><Select mode="multiple" showSearch optionFilterProp="label" options={filteredRooms(batchCampusId, batchBuildingId).map((item) => ({ value: item.id, label: `${item.buildingName} / ${item.name}` }))} /></Form.Item><Form.Item name="updateExperimentClassroomScope" valuePropName="checked"><Checkbox>修改实验场地范围</Checkbox></Form.Item><Form.Item name="allowedExperimentVenueNatures" label="实验场地性质"><Checkbox.Group options={venueNatures} /></Form.Item><div className="schedule-form-grid"><Form.Item name="experimentRequiredCampusId" label="实验校区"><Select allowClear options={(refs.data?.campuses ?? []).map((item) => ({ value: item.id, label: item.name }))} onChange={() => batchForm.setFieldsValue({ experimentRequiredBuildingId: undefined, allowedExperimentClassroomIds: [] })} /></Form.Item><Form.Item name="experimentRequiredBuildingId" label="实验教学楼"><Select allowClear options={filteredBuildings(batchExperimentCampusId).map((item) => ({ value: item.id, label: item.name }))} onChange={() => batchForm.setFieldValue('allowedExperimentClassroomIds', [])} /></Form.Item></div><Form.Item name="allowedExperimentClassroomIds" label="指定实验场地"><Select mode="multiple" showSearch optionFilterProp="label" options={filteredRooms(batchExperimentCampusId, batchExperimentBuildingId).map((item) => ({ value: item.id, label: `${item.buildingName} / ${item.name}` }))} /></Form.Item></Form></Modal>
</main>
}
Loaded 3 of 6 files, more files were not shown because too many files have changed in this diff. Show more