forked from wangziqi/gongxue-base
- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
1304 lines
46 KiB
TypeScript
1304 lines
46 KiB
TypeScript
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>(() => dayjs().weekday(1).startOf('day'));
|
||
|
||
// Modal date selection (month view)
|
||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
||
|
||
// Data
|
||
const [classrooms, setClassrooms] = useState<ClassroomItem[]>([]);
|
||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||
const [classTeachers, setClassTeachers] = useState<ClassTeacherOption[]>([]);
|
||
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' | 'edit' | 'detail'>('create');
|
||
const [editingSchedule, setEditingSchedule] = useState<ClassScheduleItem | null>(null);
|
||
const [selectedCell, setSelectedCell] = useState<{
|
||
classroomId: number;
|
||
weekDay: number;
|
||
} | null>(null);
|
||
const [selectedSchedules, setSelectedSchedules] = useState<ClassScheduleItem[]>([]);
|
||
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>(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<ScheduleFormValues>();
|
||
|
||
// 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<Record<string, Record<string, ClassScheduleItem[]>>>,
|
||
]);
|
||
|
||
setClassrooms(lookups.classrooms);
|
||
setClasses(lookups.classes);
|
||
|
||
// 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 = filterSchedulesForClass(schedules, filterClassId);
|
||
if (matched.length > 0) {
|
||
filtered[classroomId][Number(wd)] = matched;
|
||
}
|
||
}
|
||
}
|
||
return filtered;
|
||
}, [matrix, filterClassId]);
|
||
|
||
const monthScheduleMap = useMemo(() => {
|
||
const map: Record<string, ClassScheduleItem[]> = {};
|
||
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<ClassTeacherOption[]>(
|
||
`/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 (
|
||
<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>
|
||
<Segmented
|
||
value={viewMode}
|
||
onChange={(v) => {
|
||
setViewMode(v as 'week' | 'month');
|
||
setSelectedDate(null);
|
||
}}
|
||
options={[
|
||
{ label: '周视图', value: 'week' },
|
||
{ label: '月视图', value: 'month' },
|
||
]}
|
||
/>
|
||
<PermissionButton
|
||
permission="sync:trigger"
|
||
type="primary"
|
||
icon={<CloudSyncOutlined />}
|
||
onClick={openSyncModal}
|
||
>
|
||
同步到钉钉排班
|
||
</PermissionButton>
|
||
{viewMode === 'week' ? (
|
||
<>
|
||
<Button
|
||
icon={<LeftOutlined />}
|
||
onClick={() => setViewDate(viewDate.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={() => setViewDate(viewDate.add(7, 'day'))}>
|
||
下一周
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Button
|
||
icon={<LeftOutlined />}
|
||
onClick={() => setViewDate(viewDate.subtract(1, 'month'))}
|
||
>
|
||
上一月
|
||
</Button>
|
||
<span style={{ fontWeight: 500, fontSize: 15 }}>
|
||
{monthStart.format('YYYY年 M月')}
|
||
</span>
|
||
<Button
|
||
icon={<RightOutlined />}
|
||
onClick={() => setViewDate(viewDate.add(1, 'month'))}
|
||
>
|
||
下一月
|
||
</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="暂无教室数据" />
|
||
) : viewMode === 'week' ? (
|
||
<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) => (
|
||
<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}
|
||
tabIndex={0}
|
||
role="button"
|
||
aria-label={`选择教室 ${classroom.name} ${WEEKDAYS[wd - 1]} 排课`}
|
||
onClick={() => 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 ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
{schedules.map((s) => (
|
||
<Tooltip
|
||
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
|
||
title={
|
||
isMaskedSchedule(s)
|
||
? `已占用 · ${s.startTime}-${s.endTime}`
|
||
: `${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`
|
||
}
|
||
>
|
||
<div
|
||
style={{
|
||
background: isMaskedSchedule(s) ? '#f5f5f5' : '#e6f4ff',
|
||
border: isMaskedSchedule(s)
|
||
? '1px solid #d9d9d9'
|
||
: '1px solid #91caff',
|
||
borderRadius: 4,
|
||
padding: '2px 6px',
|
||
fontSize: 12,
|
||
lineHeight: '18px',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontWeight: 600,
|
||
color: isMaskedSchedule(s) ? '#595959' : '#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>
|
||
) : (
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table
|
||
style={{
|
||
width: '100%',
|
||
borderCollapse: 'collapse',
|
||
fontSize: 13,
|
||
tableLayout: 'fixed',
|
||
}}
|
||
>
|
||
<thead>
|
||
<tr style={{ background: '#fafafa' }}>
|
||
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map((d) => (
|
||
<th
|
||
key={d}
|
||
style={{
|
||
padding: '10px 8px',
|
||
border: '1px solid #f0f0f0',
|
||
textAlign: 'center',
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{d}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{weeks.map((week, wi) => (
|
||
<tr key={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 (
|
||
<td
|
||
key={di}
|
||
tabIndex={0}
|
||
role="button"
|
||
aria-label={`${day.format('YYYY-MM-DD')} 排课详情`}
|
||
onClick={() => 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';
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontWeight: isCurrentMonth ? 600 : 400,
|
||
color: isCurrentMonth ? '#262626' : '#bfbfbf',
|
||
fontSize: 14,
|
||
marginBottom: 4,
|
||
}}
|
||
>
|
||
{day.date()}
|
||
</div>
|
||
{count > 0 && (
|
||
<Badge
|
||
count={count}
|
||
size="small"
|
||
overflowCount={99}
|
||
style={{ backgroundColor: '#1677ff' }}
|
||
/>
|
||
)}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</Spin>
|
||
|
||
{/* Modal */}
|
||
<Modal
|
||
title={
|
||
modalMode === 'create'
|
||
? `新增排课 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
|
||
: modalMode === 'edit'
|
||
? `编辑排课 — ${editingSchedule?.subject || ''}`
|
||
: selectedDate
|
||
? `排课详情 — ${selectedDate.format('YYYY-MM-DD')} ${WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1]}`
|
||
: `排课详情 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
|
||
}
|
||
open={modalOpen}
|
||
onCancel={() => {
|
||
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' ? (
|
||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||
<Form.Item
|
||
name="classId"
|
||
label="班级"
|
||
rules={[{ required: true, message: '请选择班级' }]}
|
||
>
|
||
<Select
|
||
placeholder="选择班级"
|
||
showSearch
|
||
optionFilterProp="label"
|
||
options={classOptions}
|
||
onChange={(classId: number) => {
|
||
form.setFieldValue('teacherId', undefined);
|
||
void applyClassTeacherDefaults(classId, form.getFieldValue('subject'));
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="classroomId"
|
||
label="教室"
|
||
rules={[{ required: true, message: '请选择教室' }]}
|
||
>
|
||
<Select
|
||
placeholder="选择教室"
|
||
showSearch
|
||
optionFilterProp="label"
|
||
options={classroomOptions}
|
||
/>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="weekDay"
|
||
label="星期"
|
||
rules={[{ required: true, message: '请选择星期' }]}
|
||
>
|
||
<Select
|
||
options={WEEKDAY_NUMBERS.map((value) => ({ value, label: WEEKDAYS[value - 1] }))}
|
||
/>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="subject"
|
||
label="科目"
|
||
rules={[{ required: true, message: '请输入科目' }]}
|
||
>
|
||
<Input
|
||
placeholder="如:数学、语文"
|
||
onBlur={(event) => {
|
||
const classId = form.getFieldValue('classId');
|
||
if (classId) void applyClassTeacherDefaults(classId, event.target.value);
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
|
||
<Form.Item name="teacherId" label="任课老师">
|
||
<Select
|
||
showSearch
|
||
allowClear
|
||
placeholder="先选班级;系统会按科目自动带出任课老师"
|
||
optionFilterProp="label"
|
||
options={classTeachers.map((teacher) => ({
|
||
value: teacher.userId,
|
||
label: `${teacher.name || teacher.username || `#${teacher.userId}`}${teacher.subject ? ` · ${teacher.subject}` : ''}`,
|
||
}))}
|
||
notFoundContent="该班级暂无可选教师,请先在班级详情配置教师"
|
||
/>
|
||
</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 }}>
|
||
<div
|
||
style={{
|
||
marginBottom: 12,
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
<span style={{ fontWeight: 500 }}>已有排课</span>
|
||
<PermissionButton
|
||
permission="schedule:create"
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => {
|
||
setEditingSchedule(null);
|
||
setModalMode('create');
|
||
form.resetFields();
|
||
form.setFieldsValue({
|
||
classroomId: selectedCell?.classroomId,
|
||
weekDay:
|
||
selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
|
||
dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
|
||
});
|
||
}}
|
||
>
|
||
新增排课
|
||
</PermissionButton>
|
||
</div>
|
||
{selectedSchedules.length === 0 ? (
|
||
<Empty description="该时段暂无排课" />
|
||
) : (
|
||
selectedSchedules.map((s) => (
|
||
<Card
|
||
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
|
||
size="small"
|
||
style={{ marginBottom: 8 }}
|
||
styles={{ body: { padding: 12 } }}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'flex-start',
|
||
}}
|
||
>
|
||
<div>
|
||
<div>
|
||
<strong>{isMaskedSchedule(s) ? '状态:' : '科目:'}</strong>
|
||
<Tag color={isMaskedSchedule(s) ? 'default' : 'blue'}>{s.subject}</Tag>
|
||
</div>
|
||
{!isMaskedSchedule(s) && (
|
||
<div>
|
||
<strong>班级:</strong>
|
||
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
|
||
</div>
|
||
)}
|
||
{!isMaskedSchedule(s) && s.teacherId != null && (
|
||
<div>
|
||
<strong>教师:</strong>
|
||
{classTeachers.find((u) => u.userId === s.teacherId)?.name ||
|
||
classTeachers.find((u) => u.userId === s.teacherId)?.username ||
|
||
`#${s.teacherId}`}
|
||
</div>
|
||
)}
|
||
<div>
|
||
<strong>时段:</strong>
|
||
{s.startTime} ~ {s.endTime}
|
||
</div>
|
||
<div>
|
||
<strong>日期:</strong>
|
||
{s.startDate} ~ {s.endDate}
|
||
</div>
|
||
{!isMaskedSchedule(s) && s.notes && (
|
||
<div>
|
||
<strong>备注:</strong>
|
||
{s.notes}
|
||
</div>
|
||
)}
|
||
{!isMaskedSchedule(s) && (
|
||
<div>
|
||
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
|
||
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
|
||
</Tag>
|
||
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!isMaskedSchedule(s) && (
|
||
<Space>
|
||
{s.scheduleType !== 'RENTAL' && (
|
||
<PermissionButton
|
||
permission="schedule:edit"
|
||
size="small"
|
||
icon={<EditOutlined />}
|
||
onClick={() => openEditSchedule(s)}
|
||
>
|
||
编辑
|
||
</PermissionButton>
|
||
)}
|
||
<Popconfirm
|
||
title="确认删除该排课?"
|
||
onConfirm={() => handleDelete(s.id)}
|
||
okText="删除"
|
||
cancelText="取消"
|
||
>
|
||
<PermissionButton
|
||
permission="schedule:delete"
|
||
size="small"
|
||
danger
|
||
icon={<DeleteOutlined />}
|
||
>
|
||
删除
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
</Space>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* ── 钉钉排班同步 Modal ── */}
|
||
<Modal
|
||
title="同步排课到钉钉考勤排班"
|
||
open={syncModalOpen}
|
||
onCancel={() => {
|
||
setSyncModalOpen(false);
|
||
setSyncResult(null);
|
||
}}
|
||
footer={
|
||
syncResult
|
||
? [
|
||
<Button
|
||
key="close"
|
||
onClick={() => {
|
||
setSyncModalOpen(false);
|
||
setSyncResult(null);
|
||
}}
|
||
>
|
||
关闭
|
||
</Button>,
|
||
]
|
||
: [
|
||
<Button
|
||
key="cancel"
|
||
onClick={() => {
|
||
setSyncModalOpen(false);
|
||
setSyncResult(null);
|
||
}}
|
||
>
|
||
取消
|
||
</Button>,
|
||
<Button
|
||
key="sync"
|
||
type="primary"
|
||
icon={<CloudSyncOutlined />}
|
||
loading={syncing}
|
||
onClick={handleSyncSchedule}
|
||
disabled={!syncStatus || syncStatus.activeSchedules === 0}
|
||
>
|
||
开始同步
|
||
</Button>,
|
||
]
|
||
}
|
||
width={560}
|
||
>
|
||
{syncResult ? (
|
||
/* ── 同步结果 ── */
|
||
<div>
|
||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||
<Col span={6}>
|
||
<Statistic title="排课" value={syncResult.scheduleCount} suffix="条" />
|
||
</Col>
|
||
<Col span={6}>
|
||
<Statistic title="班次" value={syncResult.shiftCount} suffix="个" />
|
||
</Col>
|
||
<Col span={6}>
|
||
<Statistic title="考勤组" value={syncResult.groupCount} suffix="个" />
|
||
</Col>
|
||
<Col span={6}>
|
||
<Statistic
|
||
title="排班数"
|
||
value={syncResult.syncedItems}
|
||
suffix="条"
|
||
valueStyle={{ color: '#3f8600' }}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
{syncResult.skippedNoMapping > 0 && (
|
||
<Alert
|
||
type="warning"
|
||
message={`${syncResult.skippedNoMapping} 条排课因班级无钉钉绑定学生而跳过`}
|
||
style={{ marginBottom: 16 }}
|
||
showIcon
|
||
/>
|
||
)}
|
||
{syncResult.failedBatchCount > 0 && (
|
||
<>
|
||
<Alert
|
||
type="error"
|
||
message={`${syncResult.failedBatchCount} 批写入失败,共 ${syncResult.failedItems} 条`}
|
||
description={
|
||
syncResult.errors.length > 0
|
||
? syncResult.errors.slice(0, 5).map((err, i) => (
|
||
<div key={i} style={{ wordBreak: 'break-all' }}>
|
||
{err}
|
||
</div>
|
||
))
|
||
: undefined
|
||
}
|
||
style={{ marginBottom: 16 }}
|
||
showIcon
|
||
/>
|
||
{syncResult.errors.length > 5 && (
|
||
<div style={{ fontSize: 12, color: '#999', marginBottom: 16, marginTop: -12 }}>
|
||
...以及其他 {syncResult.errors.length - 5} 条错误
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
{syncResult.groups.length > 0 && (
|
||
<div>
|
||
<div style={{ fontWeight: 500, marginBottom: 8 }}>按班级分组:</div>
|
||
{syncResult.groups.map((g) => (
|
||
<Tag key={g.groupId} color="blue" style={{ marginBottom: 4 }}>
|
||
{g.className}:{g.itemCount} 条排班
|
||
</Tag>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : syncStatus ? (
|
||
/* ── 同步确认信息 ── */
|
||
<div>
|
||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||
<Col span={8}>
|
||
<Statistic title="活跃排课" value={syncStatus.activeSchedules} suffix="条" />
|
||
</Col>
|
||
<Col span={8}>
|
||
<Statistic
|
||
title="已就绪班级"
|
||
value={syncStatus.mappedClasses}
|
||
suffix={`/ ${syncStatus.totalClasses}`}
|
||
valueStyle={{
|
||
color:
|
||
syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600',
|
||
}}
|
||
/>
|
||
</Col>
|
||
<Col span={8}>
|
||
<Statistic
|
||
title="无绑定学生班级"
|
||
value={syncStatus.totalClasses - syncStatus.mappedClasses}
|
||
suffix="个"
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
{syncStatus.mappedClasses < syncStatus.totalClasses && (
|
||
<Alert
|
||
type="warning"
|
||
message={`${syncStatus.totalClasses - syncStatus.mappedClasses} 个班级没有已绑定钉钉的学生,其排课将被跳过。请先在钉钉集成页导入并绑定学生。`}
|
||
style={{ marginBottom: 16 }}
|
||
showIcon
|
||
/>
|
||
)}
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div style={{ marginBottom: 8, fontWeight: 500 }}>同步参数</div>
|
||
<Space wrap>
|
||
<span>起始日期:</span>
|
||
<DatePicker
|
||
value={syncDateFrom}
|
||
onChange={(d) => d && setSyncDateFrom(d)}
|
||
allowClear={false}
|
||
/>
|
||
<span>天数:</span>
|
||
<Select
|
||
value={syncDays}
|
||
onChange={setSyncDays}
|
||
style={{ width: 100 }}
|
||
options={[
|
||
{ value: 7, label: '7 天' },
|
||
{ value: 14, label: '14 天' },
|
||
{ value: 30, label: '30 天' },
|
||
{ value: 60, label: '60 天' },
|
||
{ value: 90, label: '90 天' },
|
||
]}
|
||
/>
|
||
</Space>
|
||
<div style={{ marginTop: 16 }}>
|
||
<Space align="start">
|
||
<Switch checked={attendanceMachineOnly} onChange={setAttendanceMachineOnly} />
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>仅允许考勤机打卡</div>
|
||
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 2 }}>
|
||
开启后将关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,并禁止无排班打卡。
|
||
</div>
|
||
</div>
|
||
</Space>
|
||
</div>
|
||
{attendanceMachineOnly && (
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message="已存在的同名考勤组也会在本次同步中更新为仅考勤机打卡。"
|
||
style={{ marginTop: 12 }}
|
||
/>
|
||
)}
|
||
</div>
|
||
{syncStatus.activeSchedules === 0 && (
|
||
<Alert
|
||
type="info"
|
||
message="当前没有活跃排课。请先在排课页面创建排课记录。"
|
||
showIcon
|
||
/>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<Spin tip="查询同步状态..." />
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SchedulesPage;
|