diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index c5e0899..8ffbd2d 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -20,6 +20,7 @@ import ClassDetailPage from './pages/Classes/detail'; import TenantsPage from './pages/Tenants'; import ClassroomRentalsPage from './pages/ClassroomRentals'; import ClassroomSchedulePage from './pages/ClassroomSchedule'; +import SchedulesPage from './pages/Schedules'; import RolesPage from './pages/Roles'; import PermissionsPage from './pages/Permissions'; import PermissionRoute from './components/PermissionRoute'; @@ -200,6 +201,15 @@ const App: React.FC = () => { } /> + + + + + } + /> diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 50bdb5c..a96b04b 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -77,6 +77,7 @@ const allMenuItems: MenuItemType[] = [ { key: '/tenants', icon: , label: '租赁方', permission: 'tenant:view' }, ], }, + { key: '/schedules', icon: , label: '排课管理', permission: 'schedule:view' }, { key: '/operation-logs', icon: , label: '操作日志', permission: 'log:view' }, { key: '/roles', icon: , label: '角色管理', permission: 'role:view' }, { key: '/permissions', icon: , label: '权限一览', permission: 'role:view' }, diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx new file mode 100644 index 0000000..a1c75ef --- /dev/null +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -0,0 +1,622 @@ +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { + Card, Button, Select, Modal, Form, Input, DatePicker, TimePicker, + Popconfirm, message, Space, Spin, Empty, Tag, Tooltip, +} from 'antd'; +import { + CalendarOutlined, + LeftOutlined, + RightOutlined, + DeleteOutlined, +} from '@ant-design/icons'; +import dayjs, { Dayjs } from 'dayjs'; +import api from '../../api'; +import PermissionButton from '../../components/PermissionButton'; + +// ---- Types ---- + +interface ClassScheduleItem { + id: number; + classId: number; + 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; +} + +interface ClassroomItem { + id: number; + name: string; + building: string; + floor: number; + roomType: string; +} + +interface ClassItem { + id: number; + name: string; + code: string; +} + +interface ScheduleFormValues { + classId: number; + subject: string; + teacherId?: number; + timeRange: [Dayjs, Dayjs]; + dateRange: [Dayjs, Dayjs]; +} + +const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; +const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7]; + +// ---- Component ---- + +const SchedulesPage: React.FC = () => { + // Week navigation + const [weekStart, setWeekStart] = useState(() => dayjs().weekday(1).startOf('day')); + + // Data + const [classrooms, setClassrooms] = useState([]); + const [classes, setClasses] = 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' | 'detail'>('create'); + const [selectedCell, setSelectedCell] = useState<{ + classroomId: number; + weekDay: number; + } | null>(null); + const [selectedSchedules, setSelectedSchedules] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [form] = Form.useForm(); + + // Derived week info + const weekEnd = useMemo(() => weekStart.add(6, 'day'), [weekStart]); + const weekNum = useMemo(() => weekStart.week(), [weekStart]); + const weekYear = useMemo(() => weekStart.year(), [weekStart]); + const startDateStr = useMemo(() => weekStart.format('YYYY-MM-DD'), [weekStart]); + const endDateStr = useMemo(() => weekEnd.format('YYYY-MM-DD'), [weekEnd]); + + // ---- Data fetching ---- + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const [classroomsRes, classesRes, schedulesRes] = await Promise.all([ + api.get('/classrooms') as Promise, + api.get('/classes') as Promise, + api.get('/class-schedules/weekly', { + params: { + startDate: startDateStr, + endDate: endDateStr, + ...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}), + }, + }) as Promise>>, + ]); + + setClassrooms(classroomsRes); + setClasses(classesRes); + + // 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 = schedules.filter((s) => s.classId === filterClassId); + if (matched.length > 0) { + filtered[classroomId][Number(wd)] = matched; + } + } + } + return filtered; + }, [matrix, filterClassId]); + + // ---- Cell click handlers ---- + const handleCellClick = (classroomId: number, weekDay: number) => { + const schedules = displayMatrix[classroomId]?.[weekDay] || []; + setSelectedCell({ classroomId, weekDay }); + + if (schedules.length > 0) { + setSelectedSchedules(schedules); + setModalMode('detail'); + setModalOpen(true); + } else { + setSelectedSchedules([]); + setModalMode('create'); + setModalOpen(true); + } + }; + + // ---- Create schedule ---- + + const handleSubmit = async () => { + if (!selectedCell) return; + try { + const values = await form.validateFields(); + setSubmitting(true); + + const payload = { + classId: values.classId, + subject: values.subject, + teacherId: values.teacherId, + classroomId: selectedCell.classroomId, + weekDay: selectedCell.weekDay, + startTime: values.timeRange[0].format('HH:mm'), + endTime: values.timeRange[1].format('HH:mm'), + startDate: values.dateRange[0].format('YYYY-MM-DD'), + endDate: values.dateRange[1].format('YYYY-MM-DD'), + }; + + await api.post('/class-schedules', payload); + message.success('排课创建成功'); + setModalOpen(false); + fetchData(); + } catch (e: unknown) { + const err = e as { message?: string; status?: number }; + message.error(err?.message || '创建排课失败'); + } finally { + setSubmitting(false); + } + }; + + + // ---- Delete schedule ---- + + const handleDelete = async (id: number) => { + try { + await api.delete(`/class-schedules/${id}`); + message.success('排课已删除'); + // Refresh the cell's schedules + if (selectedCell) { + 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 */} +
+ + +

排课管理

+
+ + + + {weekYear} W{weekNum} + + ({startDateStr} ~ {endDateStr}) + + + + +
+ + {/* Filters */} + + + setFilterClassId(v)} + options={classOptions} + /> + + + + {/* Matrix */} + + {classrooms.length === 0 ? ( + + ) : ( +
+ + + + + {WEEKDAYS.map((day, idx) => ( + + ))} + + + + {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)} + 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} +
+
+
+ ))} +
+ ) : ( +
+ — +
+ )} +
+
+ )} +
+ + {/* Modal */} + setModalOpen(false)} + onOk={modalMode === 'create' ? handleSubmit : undefined} + confirmLoading={submitting} + okText={modalMode === 'create' ? '创建' : undefined} + footer={ + modalMode === 'create' + ? undefined // use default ok/cancel + : null // no footer for detail mode + } + width={600} + destroyOnClose + > + {modalMode === 'create' ? ( +
+ + + + + (v ? Number(v) : undefined)} + > + + + + + + + + + + + + + + + + + + +
+ ) : ( +
+ {selectedSchedules.length === 0 ? ( + + ) : ( + selectedSchedules.map((s) => ( + +
+
+
+ 科目: + {s.subject} +
+
+ 班级: + {classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`} +
+ {s.teacherId != null && ( +
+ 教师ID: + {s.teacherId} +
+ )} +
+ 时段: + {s.startTime} ~ {s.endTime} +
+
+ 日期: + {s.startDate} ~ {s.endDate} +
+ {s.notes && ( +
+ 备注: + {s.notes} +
+ )} +
+ + {s.scheduleType === 'RENTAL' ? '租赁' : '内部'} + + {s.status} +
+
+ handleDelete(s.id)} + okText="删除" + cancelText="取消" + > + } + > + 删除 + + +
+
+ )) + )} +
+ )} +
+
+ ); +}; + +export default SchedulesPage;