import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Card, Button, Select, Modal, Form, Input, DatePicker, TimePicker, Popconfirm, Space, Spin, Empty, Tag, Tooltip, Segmented, Badge, Row, Col, Statistic, Alert, Switch, } from 'antd'; import { CalendarOutlined, LeftOutlined, RightOutlined, DeleteOutlined, CloudSyncOutlined, PlusOutlined, EditOutlined, } from '@ant-design/icons'; import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { usePermission } from '../../hooks/usePermission'; import { message } from '../../ui/app-message'; import { buildSchedulePayload, scheduleToFormValues, type ScheduleFormValues, } from './schedule-form'; import { filterSchedulesForClass, isMaskedSchedule } from './schedule-visibility'; import { classifySyncResult } from './sync-result'; // ---- Types ---- interface ClassScheduleItem { id: number | null; classId: number | null; classroomId: number; weekDay: number; startTime: string; endTime: string; startDate: string; endDate: string; subject: string; teacherId: number | null; scheduleType: string; status: string; notes: string | null; createdAt: string; updatedAt: string; canViewDetails?: boolean; } interface ClassroomItem { id: number; name: string; building: string; floor: number; roomType: string; } interface ClassItem { id: number; name: string; code: string; } interface ClassTeacherOption { id: number; userId: number; username?: string; name?: string; roleType: string; subject?: string | null; } /** 排班同步返回结果 */ 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 WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7]; // ---- Component ---- const SchedulesPage: React.FC = () => { const { hasPermission } = usePermission(); // View mode and navigation const [viewMode, setViewMode] = useState<'week' | 'month'>('week'); const [viewDate, setViewDate] = useState(() => dayjs().weekday(1).startOf('day')); // Modal date selection (month view) const [selectedDate, setSelectedDate] = useState(null); // Data const [classrooms, setClassrooms] = useState([]); const [classes, setClasses] = useState([]); const [classTeachers, setClassTeachers] = useState([]); const [matrix, setMatrix] = useState>>({}); const [loading, setLoading] = useState(false); // Filters const [filterClassroomIds, setFilterClassroomIds] = useState([]); const [filterClassId, setFilterClassId] = useState(undefined); // Modal 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 [syncStatus, setSyncStatus] = useState<{ activeSchedules: number; mappedClasses: number; totalClasses: number; } | null>(null); const [syncResult, setSyncResult] = useState<{ scheduleCount: number; shiftCount: number; groupCount: number; syncedItems: number; skippedNoMapping: number; failedBatchCount: number; failedItems: number; errors: string[]; groups: Array<{ className: string; groupId: number; itemCount: number }>; } | null>(null); const [syncDateFrom, setSyncDateFrom] = useState(dayjs); const [syncDays, setSyncDays] = useState(30); const [attendanceMachineOnly, setAttendanceMachineOnly] = useState(false); /** 打开同步弹窗时先查询就绪状态 */ const openSyncModal = useCallback(async () => { setSyncModalOpen(true); setSyncResult(null); try { const res = await api.get<{ success: boolean; data: { activeSchedules: number; mappedClasses: number; totalClasses: number }; }>('/sync/schedule/status'); setSyncStatus(res.data); } catch { setSyncStatus(null); } }, []); /** 执行排班同步 */ const handleSyncSchedule = useCallback(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); } else { message.success(classification.message); } } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '同步失败'); } finally { setSyncing(false); } }, [syncDateFrom, syncDays, attendanceMachineOnly]); const [form] = Form.useForm(); // Derived week/month info 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]); // ---- Data fetching ---- const fetchData = useCallback(async () => { setLoading(true); try { 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>>, ]); setClassrooms(lookups.classrooms); setClasses(lookups.classes); // Convert string keys to numbers const typedMatrix: Record> = {}; for (const [cId, dayMap] of Object.entries(schedulesRes)) { const classroomId = Number(cId); typedMatrix[classroomId] = {}; for (const [wd, schedules] of Object.entries(dayMap)) { typedMatrix[classroomId][Number(wd)] = schedules; } } setMatrix(typedMatrix); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '加载排课数据失败'); } finally { setLoading(false); } }, [startDateStr, endDateStr, filterClassroomIds]); useEffect(() => { fetchData(); }, [fetchData]); // ---- Filtered classrooms ---- const filteredClassrooms = useMemo(() => { if (filterClassroomIds.length === 0) return classrooms; const idSet = new Set(filterClassroomIds); return classrooms.filter((c) => idSet.has(c.id)); }, [classrooms, filterClassroomIds]); // Apply class filter to the matrix 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]); // ---- Cell click handlers ---- 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 }); 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 = useCallback(async (classId: number) => { try { const teachers = await api.get( `/class-schedules/classes/${classId}/teachers`, ); setClassTeachers(teachers); return teachers; } catch { setClassTeachers([]); return []; } }, []); const applyClassTeacherDefaults = useCallback( 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); } }, [form, loadClassTeachers], ); // ---- Create / edit schedule ---- const handleSubmit = async () => { if (modalMode === 'create' && !selectedCell) return; if (modalMode === 'edit' && !editingSchedule) return; try { const values = (await form.validateFields()) as ScheduleFormValues; setSubmitting(true); const payload = buildSchedulePayload(values); if (modalMode === 'edit' && editingSchedule) { await api.put(`/class-schedules/${editingSchedule.id}`, payload); message.success('排课更新成功,请重新同步到钉钉排班'); } else { await api.post('/class-schedules', payload); message.success('排课创建成功'); } setModalOpen(false); setEditingSchedule(null); fetchData(); } catch (e: unknown) { const err = e as { message?: string; status?: number }; message.error(err?.message || (modalMode === 'edit' ? '更新排课失败' : '创建排课失败')); } finally { setSubmitting(false); } }; const openEditSchedule = (schedule: ClassScheduleItem) => { if (isMaskedSchedule(schedule) || schedule.id === null || schedule.classId === null) return; if (schedule.scheduleType === 'RENTAL') { message.warning('租赁排课请在租赁订单中修改'); return; } const editableSchedule = { ...schedule, id: schedule.id, classId: schedule.classId }; setEditingSchedule(schedule); setModalMode('edit'); form.setFieldsValue(scheduleToFormValues(editableSchedule)); void loadClassTeachers(editableSchedule.classId); }; // ---- Delete schedule ---- const handleDelete = async (id: number | null) => { if (id === null) return; try { await api.delete(`/class-schedules/${id}`); message.success('排课已删除'); // Refresh the displayed schedules const remaining = selectedSchedules.filter((s) => s.id !== id); setSelectedSchedules(remaining); if (remaining.length === 0) { setModalOpen(false); } fetchData(); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '删除失败'); } }; // ---- Classroom select options ---- 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], ); // ---- Render ---- const selectedClassroom = selectedCell ? classrooms.find((c) => c.id === selectedCell.classroomId) : undefined; return (
{/* Header */}

排课管理

{ setViewMode(v as 'week' | 'month'); setSelectedDate(null); }} options={[ { label: '周视图', value: 'week' }, { label: '月视图', value: 'month' }, ]} /> } onClick={openSyncModal} > 同步到钉钉排班 {viewMode === 'week' ? ( <> {weekYear} W{weekNum} ({startDateStr} ~ {endDateStr}) ) : ( <> {monthStart.format('YYYY年 M月')} )}
{/* Filters */} setFilterClassId(v)} options={classOptions} /> {/* Matrix */} {classrooms.length === 0 ? ( ) : viewMode === 'week' ? (
{WEEKDAYS.map((day) => ( ))} {filteredClassrooms.map((classroom) => ( {WEEKDAY_NUMBERS.map((wd) => { const schedules = displayMatrix[classroom.id]?.[wd] || []; const hasContent = schedules.length > 0; return ( ); })} ))}
教室 {day}
{classroom.name}
{classroom.building && (
{classroom.building} {classroom.floor ? ` ${classroom.floor}F` : ''}
)}
handleCellClick(classroom.id, wd)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCellClick(classroom.id, wd); } }} style={{ padding: 4, border: '1px solid #f0f0f0', verticalAlign: 'top', cursor: 'pointer', minHeight: 56, transition: 'background 0.15s', }} onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = '#f6f8fa'; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = ''; }} > {hasContent ? (
{schedules.map((s) => (
{s.subject}
{s.startTime}-{s.endTime}
))}
) : (
)}
) : (
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map((d) => ( ))} {weeks.map((week, wi) => ( {week.map((day, di) => { const isCurrentMonth = day.month() === monthStart.month(); const dateKey = day.format('YYYY-MM-DD'); const daySchedules = monthScheduleMap[dateKey] || []; const count = daySchedules.length; return ( ); })} ))}
{d}
handleDateClick(day)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleDateClick(day); } }} style={{ padding: '6px 8px', border: '1px solid #f0f0f0', verticalAlign: 'top', cursor: 'pointer', height: 90, background: isCurrentMonth ? '#fff' : '#fafafa', transition: 'background 0.15s', }} onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = isCurrentMonth ? '#f0f5ff' : '#f0f0f0'; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = isCurrentMonth ? '' : '#fafafa'; }} >
{day.date()}
{count > 0 && ( )}
)}
{/* Modal */} { setModalOpen(false); setEditingSchedule(null); }} onOk={modalMode !== 'detail' ? handleSubmit : undefined} confirmLoading={submitting} okText={modalMode === 'edit' ? '保存' : modalMode === 'create' ? '创建' : undefined} footer={modalMode === 'detail' ? null : undefined} width={600} destroyOnHidden > {modalMode !== 'detail' ? (
{ const classId = form.getFieldValue('classId'); if (classId) void applyClassTeacherDefaults(classId, event.target.value); }} />
仅允许考勤机打卡
开启后将关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,并禁止无排班打卡。
{attendanceMachineOnly && ( )}
{syncStatus.activeSchedules === 0 && ( )} ) : ( )} ); }; export default SchedulesPage;