feat(web): migrate teaching task administration

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

No files matched your search

+2
View File
@@ -46,6 +46,7 @@ const BaseDataPage = lazy(() => import('../pages/BaseDataPage'))
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 AccountActivationPage = lazy(() => import('../pages/AccountActivationPage'))
const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage'))
const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage'))
@@ -138,6 +139,7 @@ function AppRoutes() {
<Route path="/curriculum" element={<CurriculumManagementPage />} />
<Route path="/teachers" element={<PersonnelRegistryPage />} />
<Route path="/students" element={<PersonnelRegistryPage />} />
<Route path="/teaching-tasks" element={<TeachingTasksPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
+1
View File
@@ -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: '/teaching-tasks', icon: <ScheduleOutlined />, label: '教学任务' },
{ key: '/curriculum', icon: <ReadOutlined />, label: '培养方案管理' },
{ key: '/course-equivalency-rules', icon: <SwapOutlined />, label: '等价课程规则' },
{ key: '/course-enrollments', icon: <BookOutlined />, label: '修读课程管理' },
+257
View File
@@ -0,0 +1,257 @@
import { useEffect, useMemo, useState } from 'react'
import { ApartmentOutlined, PlusOutlined, ReloadOutlined, TeamOutlined } from '@ant-design/icons'
import { Alert, App, Button, Empty, Form, Input, InputNumber, Modal, Radio, Select, Skeleton, Table, Tag } from 'antd'
import type { ColumnsType, TableRowSelection } from 'antd/es/table/interface'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import http, { apiErrorMessage } from '../api/http'
import PageHeader from '../components/PageHeader'
import { useAuthStore } from '../stores/auth'
import './teaching-tasks.css'
interface Term { id: string; name: string; isCurrent?: boolean; isArchived?: boolean }
interface Course { id: string; code: string; name: string; nature: string; collegeId: string; collegeName: string; totalHours: number; practiceHours: number; isEnabled?: boolean }
interface Major { id: string; name: string; collegeId: string; collegeName: string; isEnabled?: boolean }
interface AdministrativeClass { id: string; code: string; name: string; majorId: string; majorName: string; grade: number; isEnabled?: boolean }
interface EligibleTeacher { teacherId: string; teacherNumber: string; name: string; title?: string; collegeName?: string }
interface TeachingTask {
id: string; taskNumber: string; name: string; academicTermId: string; termName: string; courseId: string;
courseCode: string; courseName: string; courseNature: string; collegeId: string; collegeName: string;
capacity: number; startWeek: number; endWeek: number; weeklyHours: number; schedulingMode: string;
courseTotalHours: number; coursePracticeHours: number; courseRegularScheduleHours: number; generationBatchCode?: string;
status: 'Draft' | 'Published' | 'Closed'; teacherNames: string[]; classNames: string[]; studentCount: number
}
interface TaskDetail extends TeachingTask { teacherIds: string[]; primaryTeacherId?: string; classIds: string[]; notes?: string }
interface CursorPage { items: TeachingTask[]; nextCursor?: string; hasMore: boolean; pageSize: number; total: number }
interface Filters { keyword: string; academicTermId?: string; collegeId?: string; status?: string; teacherKeyword: string; classKeyword: string }
interface TaskForm {
taskNumber: string; name: string; academicTermId: string; courseId: string; capacity: number; startWeek: number;
endWeek: number; weeklyHours: number; schedulingMode: string; teacherIds: string[]; primaryTeacherId?: string;
classIds: string[]; notes?: string
}
interface GenerationForm { academicTermId: string; courseId: string; classesPerTask: number; startWeek: number; endWeek: number; weeklyHours: number; classIds: string[] }
const statusLabels: Record<string, string> = { Draft: '草稿', Published: '已发布', Closed: '已归档' }
const natureLabels: Record<string, string> = { GeneralRequired: '公共必修', GeneralElective: '公共选修', MajorRequired: '专业必修', MajorElective: '专业选修', Practice: '实践教学' }
const publicNatures = new Set(['GeneralRequired', 'GeneralElective'])
const majorNatures = new Set(['MajorRequired', 'MajorElective', 'Practice'])
const termLabel = (term: Term) => `${term.name}${term.isCurrent ? ' · 当前' : term.isArchived ? ' · 已归档' : ' · 历史'}`
const defaultTermId = (terms: Term[]) => terms.find((item) => item.isCurrent)?.id ?? terms.find((item) => !item.isArchived)?.id ?? terms[0]?.id
export default function TeachingTasksPage() {
const { message, modal } = App.useApp()
const client = useQueryClient()
const user = useAuthStore((state) => state.user)
const roles = user?.roles ?? []
const isSuperAdmin = roles.includes('SuperAdmin')
const isAcademicAdmin = roles.includes('AcademicAdmin')
const isCollegeAdmin = roles.includes('CollegeAdmin')
const canGeneratePublic = isSuperAdmin || isAcademicAdmin
const [draft, setDraft] = useState<Filters>({ keyword: '', teacherKeyword: '', classKeyword: '' })
const [filters, setFilters] = useState<Filters>({ keyword: '', teacherKeyword: '', classKeyword: '' })
const [pageSize, setPageSize] = useState(20)
const [cursors, setCursors] = useState<string[]>([])
const [selectedRows, setSelectedRows] = useState<TeachingTask[]>([])
const [editing, setEditing] = useState<{ id?: string }>()
const [generating, setGenerating] = useState(false)
const [generationOpen, setGenerationOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [eligibleTeachers, setEligibleTeachers] = useState<EligibleTeacher[]>([])
const [generationTeachers, setGenerationTeachers] = useState<EligibleTeacher[]>([])
const [form] = Form.useForm<TaskForm>()
const [generationForm] = Form.useForm<GenerationForm>()
const cursor = cursors.at(-1)
const watchedCourseId = Form.useWatch('courseId', form)
const watchedTeacherIds = Form.useWatch('teacherIds', form) ?? []
const watchedStartWeek = Form.useWatch('startWeek', form) ?? 0
const watchedEndWeek = Form.useWatch('endWeek', form) ?? 0
const watchedWeeklyHours = Form.useWatch('weeklyHours', form) ?? 0
const generationCourseId = Form.useWatch('courseId', generationForm)
const generationClassIds = Form.useWatch('classIds', generationForm) ?? []
const generationClassesPerTask = Form.useWatch('classesPerTask', generationForm) ?? 1
const generationStartWeek = Form.useWatch('startWeek', generationForm) ?? 0
const generationEndWeek = Form.useWatch('endWeek', generationForm) ?? 0
const generationWeeklyHours = Form.useWatch('weeklyHours', generationForm) ?? 0
const references = useQuery({
queryKey: ['teaching-task-references'],
queryFn: async () => {
const [terms, courses, classes, majors] = await Promise.all([
http.get<Term[]>('/base-data/terms'), http.get<Course[]>('/courses/options'),
http.get<AdministrativeClass[]>('/base-data/classes'), http.get<Major[]>('/base-data/majors'),
])
return { terms: terms.data, courses: courses.data, classes: classes.data, majors: majors.data }
},
})
useEffect(() => {
const termId = defaultTermId(references.data?.terms ?? [])
if (termId && !filters.academicTermId) {
const next = { ...filters, academicTermId: termId }
setDraft(next); setFilters(next)
}
}, [references.data?.terms]) // eslint-disable-line react-hooks/exhaustive-deps
const tasks = useQuery({
queryKey: ['teaching-tasks', cursor, pageSize, filters],
enabled: !!references.data,
queryFn: async () => (await http.get<CursorPage>('/teaching-tasks', { params: {
cursor, pageSize, keyword: filters.keyword || undefined, academicTermId: filters.academicTermId,
collegeId: filters.collegeId, status: filters.status, teacherKeyword: filters.teacherKeyword || undefined,
classKeyword: filters.classKeyword || undefined,
} })).data,
})
const refs = references.data ?? { terms: [], courses: [], classes: [], majors: [] }
const manageableCourses = useMemo(() => refs.courses.filter((course) => {
if (isSuperAdmin) return true
if (isAcademicAdmin && publicNatures.has(course.nature)) return true
return isCollegeAdmin && course.collegeId === user?.collegeId && majorNatures.has(course.nature)
}), [refs.courses, isSuperAdmin, isAcademicAdmin, isCollegeAdmin, user?.collegeId])
const publicCourses = refs.courses.filter((course) => publicNatures.has(course.nature))
const availableClasses = useMemo(() => {
const enabled = refs.classes.filter((item) => item.isEnabled !== false)
if (!isCollegeAdmin || !user?.collegeId) return enabled
const majorIds = new Set(refs.majors.filter((major) => major.collegeId === user.collegeId).map((major) => major.id))
return enabled.filter((item) => majorIds.has(item.majorId))
}, [refs.classes, refs.majors, isCollegeAdmin, user?.collegeId])
const courseColleges = useMemo(() => [...new Map(refs.courses.map((item) => [item.collegeId, item.collegeName])).entries()].map(([id, name]) => ({ id, name })), [refs.courses])
const selectedCourse = manageableCourses.find((course) => course.id === watchedCourseId)
const selectedGenerationCourse = publicCourses.find((course) => course.id === generationCourseId)
const plannedHours = watchedEndWeek >= watchedStartWeek ? (watchedEndWeek - watchedStartWeek + 1) * watchedWeeklyHours : 0
const generationHours = generationEndWeek >= generationStartWeek ? (generationEndWeek - generationStartWeek + 1) * generationWeeklyHours : 0
const groups = Math.ceil(generationClassIds.length / Math.max(1, generationClassesPerTask))
const responsibility = isSuperAdmin ? '可处理全部教学任务,并保留跨层级纠错权限。' : isAcademicAdmin && isCollegeAdmin ? '负责校级公共课,以及本学院专业课与实践课程。' : isCollegeAdmin ? '负责本学院专业课与实践课程。' : '负责公共必修与公共选修课程。'
function canManage(row: TeachingTask) {
if (isSuperAdmin) return true
if (isAcademicAdmin && publicNatures.has(row.courseNature)) return true
return isCollegeAdmin && !publicNatures.has(row.courseNature)
}
function applyFilters() { setCursors([]); setSelectedRows([]); setFilters({ ...draft }) }
function resetFilters() {
const reset = { keyword: '', academicTermId: defaultTermId(refs.terms), teacherKeyword: '', classKeyword: '' }
setDraft(reset); setFilters(reset); setCursors([]); setSelectedRows([])
}
async function loadEligible(target: 'manual' | 'generation', academicTermId?: string, courseId?: string) {
const setter = target === 'manual' ? setEligibleTeachers : setGenerationTeachers
setter([])
if (!academicTermId || !courseId) return
try { setter((await http.get<EligibleTeacher[]>('/teacher-course-applications/eligible', { params: { academicTermId, courseId } })).data) }
catch (error) { message.error(apiErrorMessage(error)) }
}
function openCreate() {
const academicTermId = filters.academicTermId ?? defaultTermId(refs.terms)
form.setFieldsValue({ taskNumber: '', name: '', academicTermId: academicTermId!, courseId: undefined!, capacity: 60, startWeek: 1, endWeek: 16, weeklyHours: 2, schedulingMode: 'Standard', teacherIds: [], primaryTeacherId: undefined, classIds: [], notes: '' })
setEligibleTeachers([]); setEditing({})
}
async function openEdit(row: TeachingTask) {
try {
const detail = (await http.get<TaskDetail>(`/teaching-tasks/${row.id}`)).data
form.setFieldsValue(detail); setEditing({ id: row.id })
await loadEligible('manual', detail.academicTermId, detail.courseId)
} catch (error) { message.error(apiErrorMessage(error)) }
}
async function save(values: TaskForm) {
if (values.startWeek > values.endWeek) return message.warning('开始周不能晚于结束周。')
const course = manageableCourses.find((item) => item.id === values.courseId)
const hours = (values.endWeek - values.startWeek + 1) * values.weeklyHours
if (course && hours !== course.totalHours) return message.warning(`该课程应安排 ${course.totalHours} 学时,当前安排 ${hours} 学时。`)
if (values.primaryTeacherId && !values.teacherIds.includes(values.primaryTeacherId)) return message.warning('主讲教师必须包含在授课教师中。')
setSaving(true)
try {
if (editing?.id) await http.put(`/teaching-tasks/${editing.id}`, values); else await http.post('/teaching-tasks', values)
message.success(editing?.id ? '教学任务已更新' : '教学任务草稿已创建'); setEditing(undefined)
await client.invalidateQueries({ queryKey: ['teaching-tasks'] })
} catch (error) { message.error(apiErrorMessage(error)) } finally { setSaving(false) }
}
function confirmAction(row: TeachingTask, action: 'publish' | 'close' | 'unarchive' | 'delete') {
const copy = {
publish: ['发布教学任务', '发布后任务将锁定,并可进入排课与选课流程。', '发布'],
close: ['归档教学任务', '归档后将停止后续选课和排课调整。', '确认归档'],
unarchive: ['撤销教学任务归档', '撤销后将恢复为已发布状态,并重新进入相关业务流程。', '撤销归档'],
delete: [row.status === 'Closed' ? '删除已归档任务' : '删除草稿', row.status === 'Closed' ? '将永久删除该任务;如已被后续业务引用,系统会拒绝。' : '将永久删除该教学任务草稿。', '确认删除'],
}[action]
modal.confirm({ title: `${copy[0]}“${row.name}”?`, content: copy[1], okText: copy[2], okButtonProps: { danger: action === 'delete' }, onOk: async () => {
try { if (action === 'delete') await http.delete(`/teaching-tasks/${row.id}`); else await http.post(`/teaching-tasks/${row.id}/${action}`); message.success(`${copy[0]}成功`); await client.invalidateQueries({ queryKey: ['teaching-tasks'] }) }
catch (error) { message.error(apiErrorMessage(error)); throw error }
} })
}
function batchAction(operation: 'Publish' | 'Delete' | 'Archive' | 'Unarchive', targets: TeachingTask[]) {
const labels = { Publish: '发布草稿', Delete: '永久删除', Archive: '归档已发布任务', Unarchive: '撤销归档' }
modal.confirm({ title: `批量${labels[operation]}`, content: `将对选中的 ${targets.length} 个教学任务执行“${labels[operation]}”。服务端会逐项校验状态、权限和引用关系。`, okText: `确认${labels[operation]}`, okButtonProps: { danger: operation === 'Delete' }, onOk: async () => {
try { const response = await http.post<{ affectedCount: number }>('/teaching-tasks/batch', { operation, ids: targets.map((item) => item.id) }); message.success(`操作完成,共处理 ${response.data.affectedCount} 个教学任务`); setSelectedRows([]); await client.invalidateQueries({ queryKey: ['teaching-tasks'] }) }
catch (error) { message.error(apiErrorMessage(error)); throw error }
} })
}
function openGeneration() {
generationForm.setFieldsValue({ academicTermId: filters.academicTermId ?? defaultTermId(refs.terms)!, courseId: undefined!, classesPerTask: 3, startWeek: 1, endWeek: 16, weeklyHours: 2, classIds: [] })
setGenerationTeachers([]); setGenerationOpen(true)
}
async function generate(values: GenerationForm) {
const course = publicCourses.find((item) => item.id === values.courseId)
const hours = (values.endWeek - values.startWeek + 1) * values.weeklyHours
if (!generationTeachers.length) return message.warning('该课程还没有审核通过的可授课教师。')
if (course && hours !== course.totalHours) return message.warning(`该课程应安排 ${course.totalHours} 学时,当前安排 ${hours} 学时。`)
modal.confirm({ title: '批量生成公共课教学任务', content: `将 ${values.classIds.length} 个行政班分成 ${Math.ceil(values.classIds.length / values.classesPerTask)} 个教学班,并在 ${generationTeachers.length} 名合格教师中均衡分配。生成结果为草稿。`, okText: '生成草稿', onOk: async () => {
setGenerating(true)
try { const response = await http.post<{ createdCount: number }>('/teaching-tasks/generate-public-course', values); message.success(`已生成 ${response.data.createdCount} 个教学任务草稿`); setGenerationOpen(false); await client.invalidateQueries({ queryKey: ['teaching-tasks'] }) }
catch (error) { message.error(apiErrorMessage(error)); throw error } finally { setGenerating(false) }
} })
}
const selectedDrafts = selectedRows.filter((item) => item.status === 'Draft')
const selectedPublished = selectedRows.filter((item) => item.status === 'Published')
const selectedClosed = selectedRows.filter((item) => item.status === 'Closed')
const rowSelection: TableRowSelection<TeachingTask> = { selectedRowKeys: selectedRows.map((item) => item.id), onChange: (_, rows) => setSelectedRows(rows), getCheckboxProps: (row) => ({ disabled: !canManage(row) }) }
const columns: ColumnsType<TeachingTask> = [
{ title: '任务编号', dataIndex: 'taskNumber', width: 150, render: (value: string) => <span className="task-number">{value}</span> },
{ title: '教学班 / 课程', width: 225, render: (_, row) => <div className="task-course"><strong>{row.name}</strong><span>{row.courseCode} · {row.courseName}</span><small>{natureLabels[row.courseNature] ?? row.courseNature} · {row.collegeName}</small></div> },
{ title: '开课学期', dataIndex: 'termName', width: 175 },
{ title: '授课教师', width: 130, render: (_, row) => row.teacherNames.join('、') || <span className="muted">未安排</span> },
{ title: '行政班', width: 150, render: (_, row) => row.classNames.join('、') || <span className="muted">开放选课</span> },
{ title: '人数 / 容量', width: 105, render: (_, row) => `${row.studentCount} / ${row.capacity}` },
{ title: '周次', width: 100, render: (_, row) => `${row.startWeek}—${row.endWeek} 周` },
{ title: '常规 / 实践', width: 115, render: (_, row) => `${row.courseRegularScheduleHours} / ${row.coursePracticeHours}` },
{ title: '状态', dataIndex: 'status', width: 90, render: (value: string) => <Tag color={value === 'Draft' ? 'gold' : value === 'Published' ? 'green' : 'default'}>{statusLabels[value] ?? value}</Tag> },
{ title: '操作', width: 220, fixed: 'right', render: (_, row) => canManage(row) ? <div className="task-actions">{row.status === 'Draft' && <><Button size="small" onClick={() => void openEdit(row)}>编辑</Button><Button size="small" type="primary" ghost onClick={() => confirmAction(row, 'publish')}>发布</Button><Button size="small" danger onClick={() => confirmAction(row, 'delete')}>删除</Button></>}{row.status === 'Published' && <Button size="small" onClick={() => confirmAction(row, 'close')}>归档</Button>}{row.status === 'Closed' && <><Button size="small" onClick={() => confirmAction(row, 'unarchive')}>撤销归档</Button><Button size="small" danger onClick={() => confirmAction(row, 'delete')}>删除</Button></>}</div> : <span className="muted">只读</span> },
]
if (references.isLoading) return <main className="page-container"><Skeleton active paragraph={{ rows: 9 }} /></main>
if (references.isError) return <main className="page-container"><Alert type="error" showIcon message="教学任务基础数据无法加载" description={apiErrorMessage(references.error)} /></main>
return <main className="page-container teaching-tasks-page">
<PageHeader kicker="TEACHING COMMISSION" title="教学任务" description="将学期、课程、合格授课教师和行政班组合为可排课、可选课的教学班。" actions={<div className="task-header-actions">{canGeneratePublic && <Button icon={<ApartmentOutlined />} onClick={openGeneration}>公共课合班生成</Button>}<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建教学任务</Button></div>} />
<section className="task-summary"><div><span>当前结果</span><strong>{tasks.data?.total ?? 0}</strong><small>个教学班</small></div><p><TeamOutlined /> {responsibility} 发布时还会校验教师授课资格、容量和周次;选修课可不关联行政班。</p></section>
<section className="task-card">
<div className="task-filters"><Input allowClear value={draft.keyword} placeholder="任务编号、教学班或课程" onChange={(event) => setDraft((state) => ({ ...state, keyword: event.target.value }))} onPressEnter={applyFilters} /><Select allowClear value={draft.academicTermId} placeholder="全部学期" options={refs.terms.map((item) => ({ value: item.id, label: termLabel(item) }))} onChange={(value) => setDraft((state) => ({ ...state, academicTermId: value }))} /><Select allowClear value={draft.collegeId} placeholder="全部开课学院" options={courseColleges.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => setDraft((state) => ({ ...state, collegeId: value }))} /><Select allowClear value={draft.status} placeholder="全部状态" options={Object.entries(statusLabels).map(([value, label]) => ({ value, label }))} onChange={(value) => setDraft((state) => ({ ...state, status: value }))} /><Input allowClear value={draft.teacherKeyword} placeholder="教师姓名或工号" onChange={(event) => setDraft((state) => ({ ...state, teacherKeyword: event.target.value }))} onPressEnter={applyFilters} /><Input allowClear value={draft.classKeyword} placeholder="行政班、专业或代码" onChange={(event) => setDraft((state) => ({ ...state, classKeyword: event.target.value }))} onPressEnter={applyFilters} /><Button type="primary" onClick={applyFilters}>查询</Button><Button icon={<ReloadOutlined />} onClick={resetFilters}>重置</Button></div>
{!!selectedRows.length && <div className="task-batch"><div><strong>已选择 {selectedRows.length} 个教学任务</strong><span>草稿 {selectedDrafts.length} · 已发布 {selectedPublished.length} · 已归档 {selectedClosed.length}</span></div><div><Button disabled={!selectedDrafts.length} onClick={() => batchAction('Publish', selectedDrafts)}>发布草稿</Button><Button danger disabled={!selectedDrafts.length} onClick={() => batchAction('Delete', selectedDrafts)}>删除草稿</Button><Button disabled={!selectedPublished.length} onClick={() => batchAction('Archive', selectedPublished)}>归档已发布</Button><Button disabled={!selectedClosed.length} onClick={() => batchAction('Unarchive', selectedClosed)}>撤销归档</Button><Button danger disabled={!selectedClosed.length} onClick={() => batchAction('Delete', selectedClosed)}>删除已归档</Button></div></div>}
{tasks.isError ? <Alert type="error" showIcon message="教学任务无法加载" description={apiErrorMessage(tasks.error)} /> : <Table<TeachingTask> rowKey="id" loading={tasks.isLoading} rowSelection={rowSelection} columns={columns} dataSource={tasks.data?.items} pagination={false} scroll={{ x: 1480 }} locale={{ emptyText: <Empty description="没有符合条件的教学任务" /> }} />}
<footer className="task-pager"><span>共 {tasks.data?.total ?? 0} 个 · 每批 <Select value={pageSize} options={[20, 50, 100].map((value) => ({ value, label: value }))} onChange={(value) => { setPageSize(value); setCursors([]); setSelectedRows([]) }} /> 个</span><div><Button disabled={!cursors.length || tasks.isLoading} onClick={() => { setCursors((state) => state.slice(0, -1)); setSelectedRows([]) }}>上一批</Button><span>第 {cursors.length + 1} 批</span><Button type="primary" disabled={!tasks.data?.hasMore || tasks.isLoading} onClick={() => { if (tasks.data?.nextCursor) setCursors((state) => [...state, tasks.data!.nextCursor!]); setSelectedRows([]) }}>下一批</Button></div></footer>
</section>
<Modal open={editing !== undefined} title={editing?.id ? '编辑教学任务' : '新建教学任务'} width={760} okText="保存草稿" confirmLoading={saving} onCancel={() => setEditing(undefined)} onOk={() => form.submit()} destroyOnHidden>
<Form form={form} layout="vertical" onFinish={(values) => void save(values)}>
<div className="task-form-grid"><Form.Item name="taskNumber" label="任务编号" rules={[{ required: true, whitespace: true }]}><Input placeholder="如 2026-1-CS101-01" /></Form.Item><Form.Item name="name" label="教学班名称" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item></div>
<div className="task-form-grid"><Form.Item name="academicTermId" label="开课学期" rules={[{ required: true }]}><Select options={refs.terms.map((item) => ({ value: item.id, label: termLabel(item) }))} onChange={(value) => { form.setFieldsValue({ teacherIds: [], primaryTeacherId: undefined }); void loadEligible('manual', value, form.getFieldValue('courseId')) }} /></Form.Item><Form.Item name="courseId" label="课程" rules={[{ required: true }]}><Select showSearch optionFilterProp="label" options={manageableCourses.map((item) => ({ value: item.id, label: `${item.code} · ${item.name}(${natureLabels[item.nature]},${item.totalHours} 学时)` }))} onChange={(value) => { form.setFieldsValue({ teacherIds: [], primaryTeacherId: undefined }); void loadEligible('manual', form.getFieldValue('academicTermId'), value) }} /></Form.Item></div>
<div className="task-form-grid three"><Form.Item name="capacity" label="容量" rules={[{ required: true }]}><InputNumber min={1} max={10000} /></Form.Item><Form.Item name="startWeek" label="开始周" rules={[{ required: true }]}><InputNumber min={1} max={30} /></Form.Item><Form.Item name="endWeek" label="结束周" rules={[{ required: true }]}><InputNumber min={1} max={30} /></Form.Item></div>
<Form.Item name="weeklyHours" label="周学时" rules={[{ required: true }]}><InputNumber min={1} max={40} /></Form.Item>
{selectedCourse && <Alert className="hours-check" type={plannedHours === selectedCourse.totalHours ? 'success' : 'warning'} showIcon message={plannedHours === selectedCourse.totalHours ? '学时匹配' : '学时不匹配'} description={`第 ${watchedStartWeek}—${watchedEndWeek} 周 × 每周 ${watchedWeeklyHours} 学时 = ${plannedHours} 学时;课程要求 ${selectedCourse.totalHours} 学时,其中实践 ${selectedCourse.practiceHours} 学时。`} />}
<Form.Item name="schedulingMode" label="授课方式"><Radio.Group optionType="button" options={[{ value: 'Standard', label: '正常排课' }, { value: 'Flexible', label: '非排时课程' }]} /></Form.Item>
<Form.Item name="teacherIds" label="授课教师"><Select mode="multiple" showSearch optionFilterProp="label" placeholder={watchedCourseId ? '仅显示已审核通过或直接分配的教师' : '请先选择课程'} options={eligibleTeachers.map((item) => ({ value: item.teacherId, label: `${item.teacherNumber} · ${item.name}${item.title ? ` · ${item.title}` : ''}` }))} onChange={(values) => { const primary = form.getFieldValue('primaryTeacherId'); if (primary && !values.includes(primary)) form.setFieldValue('primaryTeacherId', undefined) }} /></Form.Item>
<Form.Item name="primaryTeacherId" label="主讲教师"><Select allowClear disabled={!watchedTeacherIds.length} options={eligibleTeachers.filter((item) => watchedTeacherIds.includes(item.teacherId)).map((item) => ({ value: item.teacherId, label: item.name }))} /></Form.Item>
<Form.Item name="classIds" label="关联行政班(选修课可留空)"><Select mode="multiple" showSearch optionFilterProp="label" maxTagCount="responsive" options={availableClasses.map((item) => ({ value: item.id, label: `${item.code} · ${item.name} · ${item.majorName}` }))} /></Form.Item>
<Form.Item name="notes" label="备注"><Input.TextArea rows={2} maxLength={500} showCount /></Form.Item>
</Form>
</Modal>
<Modal open={generationOpen} title="公共课合班生成教学任务" width={760} okText="生成教学任务草稿" confirmLoading={generating} onCancel={() => setGenerationOpen(false)} onOk={() => generationForm.submit()} destroyOnHidden>
<Alert type="info" showIcon message="行政班按编码顺序分组;教师从已审核通过的名单中选择,并优先分配给本学期教学任务较少的教师。" />
<Form form={generationForm} layout="vertical" onFinish={(values) => void generate(values)}>
<div className="task-form-grid"><Form.Item name="academicTermId" label="开课学期" rules={[{ required: true }]}><Select options={refs.terms.map((item) => ({ value: item.id, label: termLabel(item) }))} onChange={(value) => void loadEligible('generation', value, generationForm.getFieldValue('courseId'))} /></Form.Item><Form.Item name="courseId" label="公共课程" rules={[{ required: true }]}><Select showSearch optionFilterProp="label" options={publicCourses.map((item) => ({ value: item.id, label: `${item.code} · ${item.name} · ${item.totalHours} 学时` }))} onChange={(value) => void loadEligible('generation', generationForm.getFieldValue('academicTermId'), value)} /></Form.Item></div>
<Alert className="generation-teachers" type={generationTeachers.length ? 'success' : 'warning'} showIcon message={generationTeachers.length ? `已有 ${generationTeachers.length} 名教师审核通过` : '请选择课程查看可分配教师'} description={generationTeachers.map((item) => item.name).join('、') || undefined} />
<Form.Item name="classIds" label="参与合班的行政班" rules={[{ required: true, message: '请选择至少一个行政班' }]}><Select mode="multiple" showSearch optionFilterProp="label" maxTagCount="responsive" options={availableClasses.map((item) => ({ value: item.id, label: `${item.code} · ${item.name} · ${item.majorName}` }))} /></Form.Item>
<div className="task-form-grid three"><Form.Item name="classesPerTask" label="每班合并行政班数" rules={[{ required: true }]}><InputNumber min={1} max={10} /></Form.Item><Form.Item name="startWeek" label="开始周" rules={[{ required: true }]}><InputNumber min={1} max={30} /></Form.Item><Form.Item name="endWeek" label="结束周" rules={[{ required: true }]}><InputNumber min={1} max={30} /></Form.Item></div>
<Form.Item name="weeklyHours" label="周学时" rules={[{ required: true }]}><InputNumber min={1} max={40} /></Form.Item>
{selectedGenerationCourse && <Alert className="hours-check" type={generationHours === selectedGenerationCourse.totalHours ? 'success' : 'warning'} showIcon message={generationHours === selectedGenerationCourse.totalHours ? '学时匹配' : '学时不匹配'} description={`当前合计 ${generationHours} 学时,课程要求 ${selectedGenerationCourse.totalHours} 学时。`} />}
<div className="generation-preview"><span>预计生成</span><strong>{groups}</strong><small>个教学任务草稿</small></div>
</Form>
</Modal>
</main>
}
Loaded 3 of 6 files, more files were not shown because too many files have changed in this diff. Show more