feat(web): migrate teaching task administration
This commit is contained in:
1 parent
a0f1d65a59
commit
e11772f3aa
6 files changed
+263
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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: '修读课程管理' },
|
||||
|
||||
@@ -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
Reference in new issue
Block a user