feat(schedules): implement frontend schedule week view page

- Create SchedulesPage with week selector, classroom/class filters
- Matrix: rows=classrooms, cols=Mon-Sun, cells show subject/teacher/time
- Click empty cell -> create modal with schedule form
- Click occupied cell -> detail modal with delete button
- Conflict: backend 409/400 -> message.error with conflict info
- Route /schedules with PermissionRoute (schedule:view)
- Menu item '排课管理' with CalendarOutlined icon
This commit is contained in:
2026-07-05 19:28:18 +08:00
parent 037c0e07e0
commit 92e6ba0e34
3 changed files with 633 additions and 0 deletions

View File

@@ -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 = () => {
</PermissionRoute>
}
/>
<Route
path="schedules"
element={
<PermissionRoute permission="schedule:view">
<SchedulesPage />
</PermissionRoute>
}
/>
</Route>
</Routes>
</BrowserRouter>

View File

@@ -77,6 +77,7 @@ const allMenuItems: MenuItemType[] = [
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
],
},
{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理', permission: 'schedule:view' },
{ key: '/operation-logs', icon: <AuditOutlined />, label: '操作日志', permission: 'log:view' },
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },
{ key: '/permissions', icon: <KeyOutlined />, label: '权限一览', permission: 'role:view' },

View File

@@ -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>(() => dayjs().weekday(1).startOf('day'));
// Data
const [classrooms, setClassrooms] = useState<ClassroomItem[]>([]);
const [classes, setClasses] = useState<ClassItem[]>([]);
const [matrix, setMatrix] = useState<Record<number, Record<number, ClassScheduleItem[]>>>({});
const [loading, setLoading] = useState(false);
// Filters
const [filterClassroomIds, setFilterClassroomIds] = useState<number[]>([]);
const [filterClassId, setFilterClassId] = useState<number | undefined>(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<ClassScheduleItem[]>([]);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm<ScheduleFormValues>();
// 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<ClassroomItem[]>,
api.get('/classes') as Promise<ClassItem[]>,
api.get('/class-schedules/weekly', {
params: {
startDate: startDateStr,
endDate: endDateStr,
...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}),
},
}) as Promise<Record<string, Record<string, ClassScheduleItem[]>>>,
]);
setClassrooms(classroomsRes);
setClasses(classesRes);
// Convert string keys to numbers
const typedMatrix: Record<number, Record<number, ClassScheduleItem[]>> = {};
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<number, Record<number, ClassScheduleItem[]>> = {};
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 (
<div>
{/* Header */}
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space>
<CalendarOutlined style={{ fontSize: 20 }} />
<h3 style={{ margin: 0 }}></h3>
</Space>
<Space>
<Button
icon={<LeftOutlined />}
onClick={() => setWeekStart(weekStart.subtract(7, 'day'))}
>
</Button>
<span style={{ fontWeight: 500, fontSize: 15 }}>
{weekYear} W{weekNum}
<span style={{ color: '#8c8c8c', fontWeight: 400, fontSize: 13, marginLeft: 6 }}>
({startDateStr} ~ {endDateStr})
</span>
</span>
<Button
icon={<RightOutlined />}
onClick={() => setWeekStart(weekStart.add(7, 'day'))}
>
</Button>
</Space>
</div>
{/* Filters */}
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<Select
mode="multiple"
placeholder="筛选教室"
allowClear
style={{ minWidth: 200 }}
value={filterClassroomIds}
onChange={(v) => setFilterClassroomIds(v)}
options={classroomOptions}
maxTagCount={3}
/>
<Select
placeholder="筛选班级"
allowClear
style={{ minWidth: 160 }}
value={filterClassId}
onChange={(v) => setFilterClassId(v)}
options={classOptions}
/>
</Space>
</Card>
{/* Matrix */}
<Spin spinning={loading}>
{classrooms.length === 0 ? (
<Empty description="暂无教室数据" />
) : (
<div style={{ overflowX: 'auto' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 13,
tableLayout: 'fixed',
}}
>
<thead>
<tr style={{ background: '#fafafa' }}>
<th
style={{
position: 'sticky',
left: 0,
background: '#fafafa',
zIndex: 2,
padding: '10px 12px',
border: '1px solid #f0f0f0',
width: 150,
textAlign: 'left',
}}
>
</th>
{WEEKDAYS.map((day, idx) => (
<th
key={day}
style={{
padding: '10px 8px',
border: '1px solid #f0f0f0',
textAlign: 'center',
background: '#fafafa',
fontWeight: 500,
}}
>
{day}
</th>
))}
</tr>
</thead>
<tbody>
{filteredClassrooms.map((classroom) => (
<tr key={classroom.id}>
<td
style={{
position: 'sticky',
left: 0,
background: '#fff',
zIndex: 1,
padding: '8px 12px',
border: '1px solid #f0f0f0',
fontWeight: 500,
}}
>
<div>{classroom.name}</div>
{classroom.building && (
<div style={{ fontSize: 11, color: '#8c8c8c', marginTop: 2 }}>
{classroom.building}
{classroom.floor ? ` ${classroom.floor}F` : ''}
</div>
)}
</td>
{WEEKDAY_NUMBERS.map((wd) => {
const schedules = displayMatrix[classroom.id]?.[wd] || [];
const hasContent = schedules.length > 0;
return (
<td
key={wd}
onClick={() => 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 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{schedules.map((s) => (
<Tooltip
key={s.id}
title={`${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`}
>
<div
style={{
background: '#e6f4ff',
border: '1px solid #91caff',
borderRadius: 4,
padding: '2px 6px',
fontSize: 12,
lineHeight: '18px',
}}
>
<div style={{ fontWeight: 600, color: '#1677ff' }}>
{s.subject}
</div>
<div style={{ color: '#595959' }}>
{s.startTime}-{s.endTime}
</div>
</div>
</Tooltip>
))}
</div>
) : (
<div
style={{
color: '#d9d9d9',
fontSize: 20,
textAlign: 'center',
lineHeight: '44px',
}}
>
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</Spin>
{/* Modal */}
<Modal
title={
modalMode === 'create'
? `新增排课 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
: `排课详情 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
}
open={modalOpen}
onCancel={() => 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' ? (
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="classId"
label="班级"
rules={[{ required: true, message: '请选择班级' }]}
>
<Select
placeholder="选择班级"
showSearch
optionFilterProp="label"
options={classOptions}
/>
</Form.Item>
<Form.Item
name="subject"
label="科目"
rules={[{ required: true, message: '请输入科目' }]}
>
<Input placeholder="如:数学、语文" />
</Form.Item>
<Form.Item
name="teacherId"
label="教师ID可选"
normalize={(v) => (v ? Number(v) : undefined)}
>
<Input type="number" placeholder="输入教师用户ID" />
</Form.Item>
<Form.Item label="教室">
<Input value={selectedClassroom?.name || ''} disabled />
</Form.Item>
<Form.Item label="星期">
<Input
value={selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}
disabled
/>
</Form.Item>
<Form.Item
name="timeRange"
label="上课时段"
rules={[{ required: true, message: '请选择时段' }]}
>
<TimePicker.RangePicker
format="HH:mm"
minuteStep={10}
style={{ width: '100%' }}
placeholder={['开始时间', '结束时间']}
/>
</Form.Item>
<Form.Item
name="dateRange"
label="日期范围"
rules={[{ required: true, message: '请选择日期范围' }]}
>
<DatePicker.RangePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
/>
</Form.Item>
</Form>
) : (
<div style={{ lineHeight: 2 }}>
{selectedSchedules.length === 0 ? (
<Empty description="该时段暂无排课" />
) : (
selectedSchedules.map((s) => (
<Card
key={s.id}
size="small"
style={{ marginBottom: 8 }}
styles={{ body: { padding: 12 } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div>
<strong></strong>
<Tag color="blue">{s.subject}</Tag>
</div>
<div>
<strong></strong>
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
</div>
{s.teacherId != null && (
<div>
<strong>ID</strong>
{s.teacherId}
</div>
)}
<div>
<strong></strong>
{s.startTime} ~ {s.endTime}
</div>
<div>
<strong></strong>
{s.startDate} ~ {s.endDate}
</div>
{s.notes && (
<div>
<strong></strong>
{s.notes}
</div>
)}
<div>
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
</Tag>
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
</div>
</div>
<Popconfirm
title="确认删除该排课?"
onConfirm={() => handleDelete(s.id)}
okText="删除"
cancelText="取消"
>
<PermissionButton
permission="schedule:delete"
size="small"
danger
icon={<DeleteOutlined />}
>
</PermissionButton>
</Popconfirm>
</div>
</Card>
))
)}
</div>
)}
</Modal>
</div>
);
};
export default SchedulesPage;