- findTimeConflicts:半选值/无效日期防护、日期未选不误报、坏时间按必定冲突、10 分钟缓冲命名常量 - 教师下拉完全由表单驱动,移除冗余 teacherKey 状态(消除整类双源 desync 问题) - 教师加载加请求序号防过期覆盖 + loading 禁用下拉 + 失败提示不清空表单 - 编辑模式 teacherKey 按实际选项推导,fallback 优先任课老师行并同步科目 - 切班/切老师自动带出:仅真匹配时自动选中,显式科目无人教授时清空防错配 - 选项按复合 key 去重,冲突提示标注基于当前视图
725 lines
27 KiB
TypeScript
725 lines
27 KiB
TypeScript
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
||
import React, { useMemo, useRef, 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>(() => dayjs().weekday(1).startOf('day'));
|
||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
||
const [classTeachers, setClassTeachers] = useState<ClassTeacherOption[]>([]);
|
||
const [classTeachersLoading, setClassTeachersLoading] = useState(false);
|
||
const [filterClassroomIds, setFilterClassroomIds] = useState<number[]>([]);
|
||
const [filterClassId, setFilterClassId] = useState<number | undefined>(undefined);
|
||
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 [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<ScheduleSyncResult | null>(null);
|
||
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
|
||
const [syncDays, setSyncDays] = useState(30);
|
||
const [attendanceMachineOnly, setAttendanceMachineOnly] = useState(false);
|
||
const [form] = Form.useForm<ScheduleFormValues>();
|
||
|
||
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<number, Record<number, ClassScheduleItem[]>>;
|
||
}>({
|
||
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<Record<string, Record<string, ClassScheduleItem[]>>>,
|
||
]);
|
||
const validatedLookups = validateResponse<{
|
||
classrooms: ClassroomItem[];
|
||
classes: ClassItem[];
|
||
}>(scheduleLookupsSchema, lookups);
|
||
const validatedWeekly = validateResponse<
|
||
Record<string, Record<string, ClassScheduleItem[]>>
|
||
>(weeklyScheduleSchema, schedulesRes);
|
||
|
||
const typedMatrix: Record<number, Record<number, ClassScheduleItem[]>> = {};
|
||
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<string, unknown>) =>
|
||
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<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]);
|
||
|
||
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');
|
||
// 作废编辑/默认值流程中在途的教师请求,避免过期响应回填刚重置的表单
|
||
teacherReqSeq.current += 1;
|
||
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);
|
||
};
|
||
|
||
// 教师列表/默认值请求序号:applyClassTeacherDefaults 与 openEditSchedule 可能并发,
|
||
// 过期响应不得覆盖共享 classTeachers 状态或表单默认值
|
||
const teacherReqSeq = useRef(0);
|
||
|
||
const loadClassTeachers = async (classId: number): Promise<ClassTeacherOption[] | null> => {
|
||
const seq = teacherReqSeq.current;
|
||
setClassTeachersLoading(true);
|
||
try {
|
||
const teachers = await api.get<ClassTeacherOption[]>(
|
||
`/class-schedules/classes/${classId}/teachers`,
|
||
);
|
||
if (seq === teacherReqSeq.current) {
|
||
setClassTeachers(teachers);
|
||
setClassTeachersLoading(false);
|
||
}
|
||
return teachers;
|
||
} catch {
|
||
// 失败:清空选项,避免展示上一个班级的教师列表并误导选择;
|
||
// 返回 null 区分「加载失败」与「真无教师」,由调用方提示且不触碰表单
|
||
if (seq === teacherReqSeq.current) {
|
||
setClassTeachers([]);
|
||
setClassTeachersLoading(false);
|
||
}
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// 每行一科目 -> 按老师合并(一个老师一行,subjects 为该班全部科目)
|
||
const buildTeacherGroups = (teachers: ClassTeacherOption[]) => {
|
||
const groups = new Map<
|
||
number,
|
||
{ userId: number; name?: string; username?: string; roleType: string; subjects: string[] }
|
||
>();
|
||
for (const teacher of teachers) {
|
||
const group = groups.get(teacher.userId) ?? {
|
||
userId: teacher.userId,
|
||
name: teacher.name,
|
||
username: teacher.username,
|
||
roleType: teacher.roleType,
|
||
subjects: [],
|
||
};
|
||
// 同一用户可同时持多个角色行(唯一键含 role_type),优先归类为任课老师,
|
||
// 避免班主任行排序在前时整个分组被误排除出 subjectTeachers
|
||
if (teacher.roleType === 'subject_teacher') group.roleType = 'subject_teacher';
|
||
if (teacher.subject) group.subjects.push(teacher.subject);
|
||
groups.set(teacher.userId, group);
|
||
}
|
||
return [...groups.values()];
|
||
};
|
||
|
||
const applyClassTeacherDefaults = async (classId: number, subject?: string) => {
|
||
teacherReqSeq.current += 1;
|
||
const seq = teacherReqSeq.current;
|
||
const teachers = await loadClassTeachers(classId);
|
||
if (seq !== teacherReqSeq.current) return; // 期间发起了更新的请求,丢弃过期结果
|
||
if (teachers === null) {
|
||
message.error('教师列表加载失败,请重试');
|
||
return;
|
||
}
|
||
const groups = buildTeacherGroups(teachers);
|
||
const subjectTeachers = groups.filter((teacher) => teacher.roleType === 'subject_teacher');
|
||
const matchedBySubject = subject
|
||
? subjectTeachers.filter((teacher) => teacher.subjects.includes(subject))
|
||
: [];
|
||
if (matchedBySubject.length === 1) {
|
||
form.setFieldValue('teacherId', matchedBySubject[0].userId);
|
||
return;
|
||
}
|
||
if (matchedBySubject.length > 1) {
|
||
// 多人都教该科目:无法自动判定,清空老师让用户选择;科目本身有效,保留
|
||
form.setFieldValue('teacherId', undefined);
|
||
return;
|
||
}
|
||
// 无科目(新增/清空科目):仅当存在唯一任课老师时自动带出老师与首个科目
|
||
if (!subject && subjectTeachers.length === 1) {
|
||
form.setFieldValue('teacherId', subjectTeachers[0].userId);
|
||
if (subjectTeachers[0].subjects.length > 0) {
|
||
form.setFieldValue('subject', subjectTeachers[0].subjects[0]);
|
||
}
|
||
return;
|
||
}
|
||
// 显式科目无人教授(含切班残留的旧科目):清空老师与科目,避免错配提交
|
||
form.setFieldValue('teacherId', undefined);
|
||
if (subject) form.setFieldValue('subject', undefined);
|
||
};
|
||
|
||
// 选择任课老师行(key = userId__科目)后带出对应科目;科目为空(非任课老师)只记录老师
|
||
const handleTeacherChange = (key?: string) => {
|
||
if (!key) {
|
||
form.setFieldValue('teacherId', undefined);
|
||
form.setFieldValue('subject', undefined);
|
||
return;
|
||
}
|
||
const [userIdRaw, ...subjectParts] = key.split('__');
|
||
const userId = Number(userIdRaw);
|
||
if (!Number.isInteger(userId)) return;
|
||
form.setFieldValue('teacherId', userId);
|
||
const subject = subjectParts.join('__');
|
||
// 非任课老师行(key 无科目部分)必须清空科目,避免残留旧科目与错误老师配对提交
|
||
form.setFieldValue('subject', subject || 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,
|
||
}),
|
||
);
|
||
// 排课科目可能不在该老师当前配置的科目中(科目已调整)或老师为非任课老师行:
|
||
// 等老师选项加载完后再校验/同步表单值,保证下拉与表单一致。
|
||
// 闭包内 TS 无法保留属性收窄,先捕获收窄后的值
|
||
const classId = schedule.classId;
|
||
const teacherId = schedule.teacherId;
|
||
const resolveTeacherKey = async () => {
|
||
teacherReqSeq.current += 1;
|
||
const seq = teacherReqSeq.current;
|
||
const teachers = await loadClassTeachers(classId);
|
||
if (seq !== teacherReqSeq.current) return; // 期间打开了其他排课/发起了新请求,丢弃
|
||
if (teachers === null) {
|
||
// 加载失败:下拉选项已清空(loadClassTeachers 失败时置空),表单保留排课原值,用户可重试
|
||
message.error('教师列表加载失败,请重试');
|
||
return;
|
||
}
|
||
if (teacherId === null || teacherId === undefined) {
|
||
form.setFieldValue('teacherId', undefined);
|
||
return;
|
||
}
|
||
const exact = `${teacherId}__${schedule.subject ?? ''}`;
|
||
if (teachers.some((t) => `${t.userId}__${t.subject ?? ''}` === exact)) {
|
||
return; // 表单值本就与选项一致,无需改动
|
||
}
|
||
// 无精确匹配:优先取该老师的任课老师行(getClassTeachers 按 roleType 排序,
|
||
// 非科目行在前,直接 find 首个会匹配到 subject 为空的班主任/生活老师行并误清科目),
|
||
// 科目同步为该老师当前科目,避免下拉与表单不一致;无任何行时清空老师与科目
|
||
const fallback =
|
||
teachers.find((t) => t.userId === teacherId && t.roleType === 'subject_teacher') ??
|
||
teachers.find((t) => t.userId === teacherId);
|
||
if (fallback) {
|
||
form.setFieldValue('subject', fallback.subject ?? undefined);
|
||
} else {
|
||
form.setFieldValue('teacherId', undefined);
|
||
form.setFieldValue('subject', undefined);
|
||
}
|
||
};
|
||
void resolveTeacherKey();
|
||
};
|
||
|
||
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 (
|
||
<div>
|
||
{syncHint && (
|
||
<NextStepHint
|
||
title="排课已创建"
|
||
description="同步到钉钉后,教师端与考勤设备才能看到新排课。"
|
||
action={{ label: '同步钉钉', onClick: () => void handleSyncSchedule() }}
|
||
onClose={() => setSyncHint(false)}
|
||
/>
|
||
)}
|
||
<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={() => void 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>
|
||
|
||
<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>
|
||
|
||
{isError ? (
|
||
<QueryErrorState
|
||
title="排课数据加载失败"
|
||
description="请检查网络后重试。"
|
||
onRetry={() => void refetch()}
|
||
/>
|
||
) : (
|
||
<ScheduleGrid
|
||
loading={loading}
|
||
viewMode={viewMode}
|
||
classrooms={classrooms}
|
||
filteredClassrooms={filteredClassrooms}
|
||
displayMatrix={displayMatrix}
|
||
weeks={weeks}
|
||
monthStart={monthStart}
|
||
monthScheduleMap={monthScheduleMap}
|
||
onCellClick={handleCellClick}
|
||
onDateClick={handleDateClick}
|
||
/>
|
||
)}
|
||
|
||
<ScheduleModal
|
||
open={modalOpen}
|
||
mode={modalMode}
|
||
submitting={submitting}
|
||
form={form}
|
||
selectedCell={selectedCell}
|
||
selectedDate={selectedDate}
|
||
selectedSchedules={selectedSchedules}
|
||
editingSchedule={editingSchedule}
|
||
selectedClassroom={selectedClassroom}
|
||
classOptions={classOptions}
|
||
classroomOptions={classroomOptions}
|
||
classTeachers={classTeachers}
|
||
classes={classes}
|
||
onCancel={() => {
|
||
setModalOpen(false);
|
||
setEditingSchedule(null);
|
||
}}
|
||
onSubmit={handleSubmit}
|
||
onStartCreate={() => {
|
||
setEditingSchedule(null);
|
||
setModalMode('create');
|
||
teacherReqSeq.current += 1; // 作废在途教师请求,防止过期响应污染新建表单
|
||
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);
|
||
}}
|
||
onTeacherChange={handleTeacherChange}
|
||
classTeachersLoading={classTeachersLoading}
|
||
weekMatrix={matrix}
|
||
/>
|
||
|
||
<SyncModal
|
||
open={syncModalOpen}
|
||
syncing={syncing}
|
||
syncStatus={syncStatus}
|
||
syncStatusError={syncStatusError}
|
||
syncResult={syncResult}
|
||
syncDateFrom={syncDateFrom}
|
||
syncDays={syncDays}
|
||
attendanceMachineOnly={attendanceMachineOnly}
|
||
onClose={() => {
|
||
setSyncModalOpen(false);
|
||
setSyncResult(null);
|
||
}}
|
||
onRetryStatus={() => void openSyncModal()}
|
||
onSync={handleSyncSchedule}
|
||
onDateChange={setSyncDateFrom}
|
||
onDaysChange={setSyncDays}
|
||
onMachineOnlyChange={setAttendanceMachineOnly}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SchedulesPage;
|