// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 import React, { useMemo, useRef, useState } from 'react'; import { Button, Card, Form, Segmented, Select, Space } from 'antd'; import { CalendarOutlined, CloudSyncOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { QueryErrorState } from '../../components/QueryState'; import { NextStepHint } from '../../components/NextStepHint'; import { usePermission } from '../../hooks/usePermission'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { message } from '../../ui/app-message'; import { useQuery } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { scheduleLookupsSchema, weeklyScheduleSchema } from '../../api/schemas'; import { buildSchedulePayload, scheduleToFormValues, type ScheduleFormValues, } from './schedule-form'; import { filterSchedulesForClass, isMaskedSchedule } from './schedule-visibility'; import { classifySyncResult } from './sync-result'; import { getErrorMessage } from '../../utils/error'; import { ScheduleGrid } from './ScheduleGrids'; import type { ClassItem, ClassScheduleItem, ClassTeacherOption, ClassroomItem, } from './ScheduleGrids'; import { ScheduleModal, SyncModal } from './ScheduleModals'; interface ScheduleSyncResult { scheduleCount: number; shiftCount: number; groupCount: number; syncedItems: number; skippedNoMapping: number; failedBatchCount: number; failedItems: number; errors: string[]; groups: Array<{ className: string; groupId: number; itemCount: number }>; } const SchedulesPage: React.FC = () => { const { hasPermission } = usePermission(); const [viewMode, setViewMode] = useState<'week' | 'month'>('week'); const [viewDate, setViewDate] = useState(() => dayjs().weekday(1).startOf('day')); const [selectedDate, setSelectedDate] = useState(null); const [classTeachers, setClassTeachers] = useState([]); const [classTeachersLoading, setClassTeachersLoading] = useState(false); const [filterClassroomIds, setFilterClassroomIds] = useState([]); const [filterClassId, setFilterClassId] = useState(undefined); const [modalOpen, setModalOpen] = useState(false); const [modalMode, setModalMode] = useState<'create' | 'edit' | 'detail'>('create'); const [editingSchedule, setEditingSchedule] = useState(null); const [selectedCell, setSelectedCell] = useState<{ classroomId: number; weekDay: number; } | null>(null); const [selectedSchedules, setSelectedSchedules] = useState([]); const [submitting, setSubmitting] = useState(false); const [syncModalOpen, setSyncModalOpen] = useState(false); const [syncing, setSyncing] = useState(false); // 排课创建成功后的「下一步:同步钉钉」引导 const [syncHint, setSyncHint] = useState(false); const [syncStatus, setSyncStatus] = useState<{ activeSchedules: number; mappedClasses: number; totalClasses: number; } | null>(null); const [syncStatusError, setSyncStatusError] = useState(false); const [syncResult, setSyncResult] = useState(null); const [syncDateFrom, setSyncDateFrom] = useState(dayjs); const [syncDays, setSyncDays] = useState(30); const [attendanceMachineOnly, setAttendanceMachineOnly] = useState(false); const [form] = Form.useForm(); const openSyncModal = async () => { setSyncModalOpen(true); setSyncResult(null); setSyncStatusError(false); try { const res = await api.get<{ success: boolean; data: { activeSchedules: number; mappedClasses: number; totalClasses: number }; }>('/sync/schedule/status'); setSyncStatus(res.data); } catch { setSyncStatusError(true); setSyncStatus(null); } }; const handleSyncSchedule = async () => { setSyncing(true); try { const res = await api.post<{ success: boolean; data: ScheduleSyncResult; }>('/sync/schedule/sync', null, { params: { dateFrom: syncDateFrom.format('YYYY-MM-DD'), days: syncDays, attendanceMachineOnly, }, }); setSyncResult(res.data); const classification = classifySyncResult(res.data); if (classification.level === 'error') { message.error(classification.message); } else if (classification.level === 'warning') { message.warning(classification.message); setSyncHint(false); } else { message.success(classification.message); setSyncHint(false); } } catch (e: unknown) { message.error(getErrorMessage(e, '同步失败')); } finally { setSyncing(false); } }; const weekStart = useMemo(() => viewDate.weekday(1).startOf('day'), [viewDate]); const monthStart = useMemo(() => viewDate.startOf('month'), [viewDate]); const weekEnd = useMemo(() => weekStart.add(6, 'day'), [weekStart]); const weekNum = useMemo(() => weekStart.week(), [weekStart]); const weekYear = useMemo(() => weekStart.year(), [weekStart]); const calendarDays = useMemo(() => { const monthEnd = monthStart.endOf('month'); const startOffset = (monthStart.day() + 6) % 7; const endOffset = (7 - monthEnd.day()) % 7; const start = monthStart.subtract(startOffset, 'day'); const end = monthEnd.add(endOffset, 'day'); const totalDays = end.diff(start, 'day') + 1; const days: Dayjs[] = []; for (let i = 0; i < totalDays; i++) { days.push(start.add(i, 'day')); } return days; }, [monthStart]); const weeks = useMemo(() => { const result: Dayjs[][] = []; for (let i = 0; i < calendarDays.length; i += 7) { result.push(calendarDays.slice(i, i + 7)); } return result; }, [calendarDays]); const startDateStr = useMemo(() => { if (viewMode === 'month') return calendarDays[0].format('YYYY-MM-DD'); return weekStart.format('YYYY-MM-DD'); }, [viewMode, weekStart, calendarDays]); const endDateStr = useMemo(() => { if (viewMode === 'month') return calendarDays[calendarDays.length - 1].format('YYYY-MM-DD'); return weekEnd.format('YYYY-MM-DD'); }, [viewMode, weekEnd, calendarDays]); const { data: fetchResult = { classrooms: [], classes: [], matrix: {} }, isLoading, isFetching, isError, refetch, } = useQuery<{ classrooms: ClassroomItem[]; classes: ClassItem[]; matrix: Record>; }>({ queryKey: ['class-schedules', startDateStr, endDateStr, filterClassroomIds], queryFn: async () => { const [lookups, schedulesRes] = await Promise.all([ api.get('/class-schedules/lookups') as Promise<{ classrooms: ClassroomItem[]; classes: ClassItem[]; }>, api.get('/class-schedules/weekly', { params: { startDate: startDateStr, endDate: endDateStr, ...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}), }, }) as Promise>>, ]); const validatedLookups = validateResponse<{ classrooms: ClassroomItem[]; classes: ClassItem[]; }>(scheduleLookupsSchema, lookups); const validatedWeekly = validateResponse< Record> >(weeklyScheduleSchema, schedulesRes); const typedMatrix: Record> = {}; for (const [cId, dayMap] of Object.entries(validatedWeekly)) { const classroomId = Number(cId); typedMatrix[classroomId] = {}; for (const [wd, schedules] of Object.entries(dayMap)) { typedMatrix[classroomId][Number(wd)] = schedules; } } return { classrooms: validatedLookups.classrooms, classes: validatedLookups.classes, matrix: typedMatrix, }; }, }); const classrooms = fetchResult.classrooms; const classes = fetchResult.classes; const matrix = fetchResult.matrix; const loading = isLoading || isFetching; // RouteKeeper 保活页面切回时刷新排课数据 useVisibleRefetch(['class-schedules']); const saveMutation = useApiMutation( async (payload: Record) => modalMode === 'edit' && editingSchedule ? api.put(`/class-schedules/${editingSchedule.id}`, payload) : api.post('/class-schedules', payload), { invalidate: [['class-schedules']] }, ); const disableMutation = useApiMutation( async (id: number) => api.put(`/class-schedules/${id}`, { status: 'inactive' }), { invalidate: [['class-schedules']] }, ); const filteredClassrooms = useMemo(() => { if (filterClassroomIds.length === 0) return classrooms; const idSet = new Set(filterClassroomIds); return classrooms.filter((c) => idSet.has(c.id)); }, [classrooms, filterClassroomIds]); const displayMatrix = useMemo(() => { if (filterClassId == null) return matrix; const filtered: Record> = {}; for (const [cId, dayMap] of Object.entries(matrix)) { const classroomId = Number(cId); filtered[classroomId] = {}; for (const [wd, schedules] of Object.entries(dayMap)) { const matched = filterSchedulesForClass(schedules, filterClassId); if (matched.length > 0) { filtered[classroomId][Number(wd)] = matched; } } } return filtered; }, [matrix, filterClassId]); const monthScheduleMap = useMemo(() => { const map: Record = {}; for (const day of calendarDays) { const wd = day.day() === 0 ? 7 : day.day(); const dateStr = day.format('YYYY-MM-DD'); const result: ClassScheduleItem[] = []; for (const classroom of filteredClassrooms) { const daySchedules = displayMatrix[classroom.id]?.[wd] || []; for (const s of daySchedules) { if (dateStr >= s.startDate && dateStr <= s.endDate) { result.push(s); } } } map[dateStr] = result; } return map; }, [calendarDays, displayMatrix, filteredClassrooms]); const handleCellClick = (classroomId: number, weekDay: number) => { const schedules = displayMatrix[classroomId]?.[weekDay] || []; setSelectedCell({ classroomId, weekDay }); setSelectedDate(null); if (schedules.length > 0) { setSelectedSchedules(schedules); setModalMode('detail'); setModalOpen(true); } else if (hasPermission('schedule:create')) { setSelectedSchedules([]); setEditingSchedule(null); setModalMode('create'); // 作废编辑/默认值流程中在途的教师请求,避免过期响应回填刚重置的表单 teacherReqSeq.current += 1; form.resetFields(); form.setFieldsValue({ classroomId, weekDay, attendanceAdvanceMinutes: 30 }); setModalOpen(true); } }; const getSchedulesForDate = (date: Dayjs): ClassScheduleItem[] => { const wd = date.day() === 0 ? 7 : date.day(); const dateStr = date.format('YYYY-MM-DD'); const result: ClassScheduleItem[] = []; for (const classroom of filteredClassrooms) { const daySchedules = displayMatrix[classroom.id]?.[wd] || []; for (const s of daySchedules) { if (dateStr >= s.startDate && dateStr <= s.endDate) { result.push(s); } } } return result; }; const handleDateClick = (date: Dayjs) => { const dateKey = date.format('YYYY-MM-DD'); const schedules = monthScheduleMap[dateKey] || getSchedulesForDate(date); setSelectedDate(date); setSelectedCell(null); setSelectedSchedules(schedules); setModalMode('detail'); setModalOpen(true); }; // 教师列表/默认值请求序号:applyClassTeacherDefaults 与 openEditSchedule 可能并发, // 过期响应不得覆盖共享 classTeachers 状态或表单默认值 const teacherReqSeq = useRef(0); const loadClassTeachers = async (classId: number): Promise => { const seq = teacherReqSeq.current; setClassTeachersLoading(true); try { const teachers = await api.get( `/class-schedules/classes/${classId}/teachers`, ); if (seq === teacherReqSeq.current) { setClassTeachers(teachers); setClassTeachersLoading(false); } return teachers; } catch { // 失败:清空选项,避免展示上一个班级的教师列表并误导选择; // 返回 null 区分「加载失败」与「真无教师」,由调用方提示且不触碰表单 if (seq === teacherReqSeq.current) { setClassTeachers([]); setClassTeachersLoading(false); } return null; } }; // 每行一科目 -> 按老师合并(一个老师一行,subjects 为该班全部科目) const buildTeacherGroups = (teachers: ClassTeacherOption[]) => { const groups = new Map< number, { userId: number; name?: string; username?: string; roleType: string; subjects: string[] } >(); for (const teacher of teachers) { const group = groups.get(teacher.userId) ?? { userId: teacher.userId, name: teacher.name, username: teacher.username, roleType: teacher.roleType, subjects: [], }; // 同一用户可同时持多个角色行(唯一键含 role_type),优先归类为任课老师, // 避免班主任行排序在前时整个分组被误排除出 subjectTeachers if (teacher.roleType === 'subject_teacher') group.roleType = 'subject_teacher'; if (teacher.subject) group.subjects.push(teacher.subject); groups.set(teacher.userId, group); } return [...groups.values()]; }; const applyClassTeacherDefaults = async (classId: number, subject?: string) => { teacherReqSeq.current += 1; const seq = teacherReqSeq.current; const teachers = await loadClassTeachers(classId); if (seq !== teacherReqSeq.current) return; // 期间发起了更新的请求,丢弃过期结果 if (teachers === null) { message.error('教师列表加载失败,请重试'); return; } const groups = buildTeacherGroups(teachers); const subjectTeachers = groups.filter((teacher) => teacher.roleType === 'subject_teacher'); const matchedBySubject = subject ? subjectTeachers.filter((teacher) => teacher.subjects.includes(subject)) : []; if (matchedBySubject.length === 1) { form.setFieldValue('teacherId', matchedBySubject[0].userId); return; } if (matchedBySubject.length > 1) { // 多人都教该科目:无法自动判定,清空老师让用户选择;科目本身有效,保留 form.setFieldValue('teacherId', undefined); return; } // 无科目(新增/清空科目):仅当存在唯一任课老师时自动带出老师与首个科目 if (!subject && subjectTeachers.length === 1) { form.setFieldValue('teacherId', subjectTeachers[0].userId); if (subjectTeachers[0].subjects.length > 0) { form.setFieldValue('subject', subjectTeachers[0].subjects[0]); } return; } // 显式科目无人教授(含切班残留的旧科目):清空老师与科目,避免错配提交 form.setFieldValue('teacherId', undefined); if (subject) form.setFieldValue('subject', undefined); }; // 选择任课老师行(key = userId__科目)后带出对应科目;科目为空(非任课老师)只记录老师 const handleTeacherChange = (key?: string) => { if (!key) { form.setFieldValue('teacherId', undefined); form.setFieldValue('subject', undefined); return; } const [userIdRaw, ...subjectParts] = key.split('__'); const userId = Number(userIdRaw); if (!Number.isInteger(userId)) return; form.setFieldValue('teacherId', userId); const subject = subjectParts.join('__'); // 非任课老师行(key 无科目部分)必须清空科目,避免残留旧科目与错误老师配对提交 form.setFieldValue('subject', subject || undefined); }; const handleSubmit = async () => { // 周视图走 selectedCell;月视图走 selectedDate(此时 selectedCell 为 null) if (modalMode === 'create' && !selectedCell && !selectedDate) return; if (modalMode === 'edit' && !editingSchedule) return; try { const values = (await form.validateFields()) as ScheduleFormValues; setSubmitting(true); const payload = buildSchedulePayload(values); await saveMutation.mutateAsync(payload); message.success( modalMode === 'edit' ? '排课更新成功,请重新同步到钉钉排班' : '排课创建成功', ); if (modalMode === 'create') setSyncHint(true); setModalOpen(false); setEditingSchedule(null); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setSubmitting(false); } }; const openEditSchedule = (schedule: ClassScheduleItem) => { if (isMaskedSchedule(schedule) || schedule.id === null || schedule.classId === null) return; if (schedule.scheduleType === 'RENTAL') { message.warning('租赁排课请在租赁订单中修改'); return; } setEditingSchedule(schedule); setModalMode('edit'); form.setFieldsValue( scheduleToFormValues({ ...schedule, id: schedule.id ?? undefined, classId: schedule.classId ?? undefined, }), ); // 排课科目可能不在该老师当前配置的科目中(科目已调整)或老师为非任课老师行: // 等老师选项加载完后再校验/同步表单值,保证下拉与表单一致。 // 闭包内 TS 无法保留属性收窄,先捕获收窄后的值 const classId = schedule.classId; const teacherId = schedule.teacherId; const resolveTeacherKey = async () => { teacherReqSeq.current += 1; const seq = teacherReqSeq.current; const teachers = await loadClassTeachers(classId); if (seq !== teacherReqSeq.current) return; // 期间打开了其他排课/发起了新请求,丢弃 if (teachers === null) { // 加载失败:下拉选项已清空(loadClassTeachers 失败时置空),表单保留排课原值,用户可重试 message.error('教师列表加载失败,请重试'); return; } if (teacherId === null || teacherId === undefined) { form.setFieldValue('teacherId', undefined); return; } const exact = `${teacherId}__${schedule.subject ?? ''}`; if (teachers.some((t) => `${t.userId}__${t.subject ?? ''}` === exact)) { return; // 表单值本就与选项一致,无需改动 } // 无精确匹配:优先取该老师的任课老师行(getClassTeachers 按 roleType 排序, // 非科目行在前,直接 find 首个会匹配到 subject 为空的班主任/生活老师行并误清科目), // 科目同步为该老师当前科目,避免下拉与表单不一致;无任何行时清空老师与科目 const fallback = teachers.find((t) => t.userId === teacherId && t.roleType === 'subject_teacher') ?? teachers.find((t) => t.userId === teacherId); if (fallback) { form.setFieldValue('subject', fallback.subject ?? undefined); } else { form.setFieldValue('teacherId', undefined); form.setFieldValue('subject', undefined); } }; void resolveTeacherKey(); }; const removeScheduleFromSelection = (id: number) => { const remaining = selectedSchedules.filter((s) => s.id !== id); setSelectedSchedules(remaining); if (remaining.length === 0) { setModalOpen(false); } }; const handleDisable = async (id: number | null) => { if (id === null) return; try { await disableMutation.mutateAsync(id); message.success('排课已停用,历史考勤记录已保留,教室占用已释放'); removeScheduleFromSelection(id); } catch { // 错误提示由 useApiMutation 统一处理 } }; const classroomOptions = useMemo( () => classrooms.map((c) => ({ value: c.id, label: `${c.building ? c.building + ' · ' : ''}${c.name}`, })), [classrooms], ); const classOptions = useMemo( () => classes.map((c) => ({ value: c.id, label: `${c.name}${c.code ? ` (${c.code})` : ''}`, })), [classes], ); const selectedClassroom = selectedCell ? classrooms.find((c) => c.id === selectedCell.classroomId) : undefined; return (
{syncHint && ( void handleSyncSchedule() }} onClose={() => setSyncHint(false)} /> )}

排课管理

{ setViewMode(v as 'week' | 'month'); setSelectedDate(null); }} options={[ { label: '周视图', value: 'week' }, { label: '月视图', value: 'month' }, ]} /> } onClick={() => void openSyncModal()} > 同步到钉钉排班 {viewMode === 'week' ? ( <> {weekYear} W{weekNum} ({startDateStr} ~ {endDateStr}) ) : ( <> {monthStart.format('YYYY年 M月')} )}
setFilterClassId(v)} options={classOptions} /> {isError ? ( void refetch()} /> ) : ( )} { setModalOpen(false); setEditingSchedule(null); }} onSubmit={handleSubmit} onStartCreate={() => { setEditingSchedule(null); setModalMode('create'); teacherReqSeq.current += 1; // 作废在途教师请求,防止过期响应污染新建表单 form.resetFields(); form.setFieldsValue({ classroomId: selectedCell?.classroomId, weekDay: selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined), dateRange: selectedDate ? [selectedDate, selectedDate] : undefined, attendanceAdvanceMinutes: 30, }); }} onEdit={openEditSchedule} onDisable={handleDisable} onClassChange={(classId) => { form.setFieldValue('teacherId', undefined); void applyClassTeacherDefaults(classId, form.getFieldValue('subject')); }} onSubjectBlur={(value) => { const classId = form.getFieldValue('classId'); if (classId) void applyClassTeacherDefaults(classId, value); }} onTeacherChange={handleTeacherChange} classTeachersLoading={classTeachersLoading} weekMatrix={matrix} /> { setSyncModalOpen(false); setSyncResult(null); }} onRetryStatus={() => void openSyncModal()} onSync={handleSyncSchedule} onDateChange={setSyncDateFrom} onDaysChange={setSyncDays} onMachineOnlyChange={setAttendanceMachineOnly} />
); }; export default SchedulesPage;