// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 import React, { useMemo, 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 [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'); 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); }; const loadClassTeachers = async (classId: number) => { try { const teachers = await api.get( `/class-schedules/classes/${classId}/teachers`, ); setClassTeachers(teachers); return teachers; } catch { setClassTeachers([]); return []; } }; const applyClassTeacherDefaults = async (classId: number, subject?: string) => { const teachers = await loadClassTeachers(classId); const subjectTeachers = teachers.filter((teacher) => teacher.roleType === 'subject_teacher'); const matchedBySubject = subject ? subjectTeachers.filter((teacher) => teacher.subject && teacher.subject === subject) : []; const matched = matchedBySubject.length > 0 ? matchedBySubject : subjectTeachers; if (matched.length === 1) { form.setFieldValue('teacherId', matched[0].userId); if (!subject && matched[0].subject) form.setFieldValue('subject', matched[0].subject); } else { form.setFieldValue('teacherId', 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, }), ); void loadClassTeachers(schedule.classId); }; 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'); 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); }} /> { setSyncModalOpen(false); setSyncResult(null); }} onRetryStatus={() => void openSyncModal()} onSync={handleSyncSchedule} onDateChange={setSyncDateFrom} onDaysChange={setSyncDays} onMachineOnlyChange={setAttendanceMachineOnly} />
); }; export default SchedulesPage;