feat(admin): 排课冲突检测与教师选择状态收敛

- findTimeConflicts:半选值/无效日期防护、日期未选不误报、坏时间按必定冲突、10 分钟缓冲命名常量
- 教师下拉完全由表单驱动,移除冗余 teacherKey 状态(消除整类双源 desync 问题)
- 教师加载加请求序号防过期覆盖 + loading 禁用下拉 + 失败提示不清空表单
- 编辑模式 teacherKey 按实际选项推导,fallback 优先任课老师行并同步科目
- 切班/切老师自动带出:仅真匹配时自动选中,显式科目无人教授时清空防错配
- 选项按复合 key 去重,冲突提示标注基于当前视图
This commit is contained in:
2026-08-11 11:53:58 +08:00
parent 5f03f7a164
commit f19e72ca40
4 changed files with 449 additions and 67 deletions

View File

@@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import React, { useEffect, useMemo } from 'react';
import {
Alert,
AutoComplete,
Button,
Card,
Col,
@@ -25,6 +26,7 @@ import type { Dayjs } from 'dayjs';
import PermissionButton from '../../components/PermissionButton';
import { QueryErrorState } from '../../components/QueryState';
import { isMaskedSchedule } from './schedule-visibility';
import { findTimeConflicts } from './schedule-conflicts';
import type { ScheduleFormValues } from './schedule-form';
import type { ClassItem, ClassScheduleItem, ClassTeacherOption, ClassroomItem } from './ScheduleGrids';
import { WEEKDAYS } from './ScheduleGrids';
@@ -43,6 +45,7 @@ export interface ScheduleModalProps {
classOptions: Array<{ value: number; label: string }>;
classroomOptions: Array<{ value: number; label: string }>;
classTeachers: ClassTeacherOption[];
classTeachersLoading?: boolean;
classes: ClassItem[];
onCancel: () => void;
onSubmit: () => void;
@@ -51,6 +54,8 @@ export interface ScheduleModalProps {
onDisable: (id: number | null) => void;
onClassChange: (classId: number) => void;
onSubjectBlur: (value: string) => void;
onTeacherChange: (key?: string) => void;
weekMatrix: Record<number, Record<number, ClassScheduleItem[]>>;
}
export const ScheduleModal: React.FC<ScheduleModalProps> = ({
@@ -66,6 +71,7 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
classOptions,
classroomOptions,
classTeachers,
classTeachersLoading,
classes,
onCancel,
onSubmit,
@@ -74,8 +80,77 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
onDisable,
onClassChange,
onSubjectBlur,
onTeacherChange,
weekMatrix,
}) => {
const scheduleGuard = useDirtyGuard(form);
// 该班任课老师的全部科目(去重),用于科目输入联想
const subjectOptions = useMemo(
() =>
Array.from(
new Set(
classTeachers
.filter((teacher) => teacher.roleType === 'subject_teacher')
.flatMap((teacher) => (teacher.subject ? [teacher.subject] : [])),
),
).map((value) => ({ value, label: value })),
[classTeachers],
);
// 老师下拉一行一科目:同一老师教多科时按科目拆成多行(如「陈奕静 · 数学」「陈奕静 · 英语」)
// value 用唯一 keyuserId__科目。同一用户可同时持多个角色行班主任+生活老师等,
// subject 均为空),会产生重复的 `userId__` value——按复合 key 去重,避免 antd Select 选中异常
const teacherOptions = useMemo(
() =>
Array.from(
new Map(
classTeachers.map((teacher) => {
const value = `${teacher.userId}__${teacher.subject ?? ''}`;
return [
value,
{
value,
label: `${teacher.name || teacher.username || `#${teacher.userId}`}${
teacher.subject ? ` · ${teacher.subject}` : ''
}`,
},
];
}),
).values(),
),
[classTeachers],
);
// 老师下拉完全由表单状态驱动teacherId+subject 是唯一事实来源):
// 手动改科目时下拉立即同步;切班/加载失败时表单 teacherId 已清空,下拉自然回落空,
// 不会展示可能属于上一个班级的过期老师
const watchedTeacherId = Form.useWatch('teacherId', form);
const watchedSubject = Form.useWatch('subject', form);
const resolvedTeacherKey = useMemo(() => {
if (watchedTeacherId === undefined || watchedTeacherId === null) return undefined;
const fromForm = `${watchedTeacherId}__${watchedSubject ?? ''}`;
// 表单科目不在该老师配置中(手动输入未配置科目):回落 undefined 而不是显示旧标签
return teacherOptions.some((option) => option.value === fromForm) ? fromForm : undefined;
}, [teacherOptions, watchedTeacherId, watchedSubject]);
// 时段冲突即时校验:同一教室同一天,与已有排课(含 10 分钟缓冲)重叠即提示
const watchedClassroomId = Form.useWatch('classroomId', form);
const watchedWeekDay = Form.useWatch('weekDay', form);
const watchedTimeRange = Form.useWatch('timeRange', form);
const watchedDateRange = Form.useWatch('dateRange', form);
const timeConflicts = useMemo(
() =>
findTimeConflicts(
watchedTimeRange,
weekMatrix,
watchedClassroomId,
watchedWeekDay,
editingSchedule?.id,
watchedDateRange,
),
[watchedClassroomId, watchedWeekDay, watchedTimeRange, watchedDateRange, weekMatrix, editingSchedule?.id],
);
// 弹窗打开或切换为创建/编辑模式时,父组件已完成表单回填,这里记录「未修改」基准
useEffect(() => {
if (open && mode !== 'detail') scheduleGuard.snapshot();
@@ -119,6 +194,32 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
onChange={onClassChange}
/>
</Form.Item>
<Form.Item label="任课老师">
<Select
showSearch
allowClear
placeholder="先选班级;选择任课老师后自动带出科目"
optionFilterProp="label"
value={resolvedTeacherKey}
options={teacherOptions}
loading={classTeachersLoading}
onChange={onTeacherChange}
notFoundContent="该班级暂无可选教师,请先在班级详情配置教师"
/>
</Form.Item>
<Form.Item
name="subject"
label="科目"
rules={[{ required: true, message: '请输入科目' }]}
>
<AutoComplete
placeholder="选择任课老师后自动带出,也可手动输入"
options={subjectOptions}
onBlur={(event) =>
onSubjectBlur((event.target as HTMLInputElement).value)
}
/>
</Form.Item>
<Form.Item
name="classroomId"
label="教室"
@@ -141,36 +242,39 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
/>
</Form.Item>
<Form.Item
name="subject"
label="科目"
rules={[{ required: true, message: '请输入科目' }]}
name="timeRange"
label="上课时段"
tooltip="同一教室的前后两节排课必须至少间隔10分钟"
extra="系统按10分钟选择时间并为相邻排课强制预留至少10分钟。"
rules={[{ required: true, message: '请选择时段' }]}
>
<Input
placeholder="如:数学、语文"
onBlur={(event) => onSubjectBlur(event.target.value)}
<TimePicker.RangePicker
format="HH:mm"
minuteStep={10}
style={{ width: '100%' }}
placeholder={['开始时间', '结束时间']}
/>
</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="该班级暂无可选教师,请先在班级详情配置教师"
{timeConflicts.length > 0 && (
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
message={`${timeConflicts.length} 条已有排课时间冲突`}
description={`${timeConflicts
.slice(0, 3)
.map((schedule) => `${schedule.subject || '未知科目'} ${schedule.startTime}-${schedule.endTime}`)
.join('')}(基于当前视图内排课,保存时服务端仍会校验)`}
/>
</Form.Item>
<Form.Item name="notes" label="备注" rules={[{ max: 500, message: '备注不能超过500字' }]}>
<Input.TextArea
rows={3}
maxLength={500}
showCount
placeholder="可填写排课说明、设备需求或临时调整原因"
)}
<Form.Item
name="dateRange"
label="日期范围"
rules={[{ required: true, message: '请选择日期范围' }]}
>
<DatePicker.RangePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
/>
</Form.Item>
<Form.Item
@@ -189,28 +293,12 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
placeholder="例如 30"
/>
</Form.Item>
<Form.Item
name="timeRange"
label="上课时段"
tooltip="同一教室的前后两节排课必须至少间隔10分钟"
extra="系统按10分钟选择时间并为相邻排课强制预留至少10分钟。"
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 name="notes" label="备注" rules={[{ max: 500, message: '备注不能超过500字' }]}>
<Input.TextArea
rows={3}
maxLength={500}
showCount
placeholder="可填写排课说明、设备需求或临时调整原因"
/>
</Form.Item>
</Form>

View File

@@ -1,5 +1,5 @@
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useMemo, useState } from 'react';
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';
@@ -49,6 +49,7 @@ const SchedulesPage: React.FC = () => {
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);
@@ -281,6 +282,8 @@ const SchedulesPage: React.FC = () => {
setSelectedSchedules([]);
setEditingSchedule(null);
setModalMode('create');
// 作废编辑/默认值流程中在途的教师请求,避免过期响应回填刚重置的表单
teacherReqSeq.current += 1;
form.resetFields();
form.setFieldsValue({ classroomId, weekDay, attendanceAdvanceMinutes: 30 });
setModalOpen(true);
@@ -312,32 +315,106 @@ const SchedulesPage: React.FC = () => {
setModalOpen(true);
};
const loadClassTeachers = async (classId: number) => {
// 教师列表/默认值请求序号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`,
);
setClassTeachers(teachers);
if (seq === teacherReqSeq.current) {
setClassTeachers(teachers);
setClassTeachersLoading(false);
}
return teachers;
} catch {
setClassTeachers([]);
return [];
// 失败:清空选项,避免展示上一个班级的教师列表并误导选择;
// 返回 null 区分「加载失败」与「真无教师」,由调用方提示且不触碰表单
if (seq === teacherReqSeq.current) {
setClassTeachers([]);
setClassTeachersLoading(false);
}
return null;
}
};
const applyClassTeacherDefaults = 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);
// 每行一科目 -> 按老师合并一个老师一行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 () => {
@@ -377,7 +454,43 @@ const SchedulesPage: React.FC = () => {
classId: schedule.classId ?? undefined,
}),
);
void loadClassTeachers(schedule.classId);
// 排课科目可能不在该老师当前配置的科目中(科目已调整)或老师为非任课老师行:
// 等老师选项加载完后再校验/同步表单值,保证下拉与表单一致。
// 闭包内 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) => {
@@ -560,6 +673,7 @@ const SchedulesPage: React.FC = () => {
onStartCreate={() => {
setEditingSchedule(null);
setModalMode('create');
teacherReqSeq.current += 1; // 作废在途教师请求,防止过期响应污染新建表单
form.resetFields();
form.setFieldsValue({
classroomId: selectedCell?.classroomId,
@@ -579,6 +693,9 @@ const SchedulesPage: React.FC = () => {
const classId = form.getFieldValue('classId');
if (classId) void applyClassTeacherDefaults(classId, value);
}}
onTeacherChange={handleTeacherChange}
classTeachersLoading={classTeachersLoading}
weekMatrix={matrix}
/>
<SyncModal

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import dayjs from 'dayjs';
import { findTimeConflicts } from './schedule-conflicts';
import type { ClassScheduleItem } from './ScheduleGrids';
const schedule = (
overrides: Partial<ClassScheduleItem> = {},
): ClassScheduleItem => ({
id: 1,
classId: 1,
classroomId: 6,
weekDay: 1,
subject: '英语',
startTime: '09:00',
endTime: '12:00',
attendanceAdvanceMinutes: 30,
startDate: '2026-01-01',
endDate: '2026-12-31',
teacherId: null,
scheduleType: 'CLASS',
status: 'active',
notes: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
});
const matrix = (list: ClassScheduleItem[]) => ({ 6: { 1: list } });
const range = (start: string, end: string): [dayjs.Dayjs, dayjs.Dayjs] => [
dayjs(`2000-01-01 ${start}`),
dayjs(`2000-01-01 ${end}`),
];
// 与 schedule() 的生效日期2026 全年)重叠的新排课日期范围:
// 新排课日期未选时 findTimeConflicts 直接返回空(见实现),时间冲突用例需显式传入
const fullRange: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs('2026-06-01'), dayjs('2026-06-30')];
describe('findTimeConflicts', () => {
it('returns empty when no time range is selected', () => {
expect(findTimeConflicts(undefined, matrix([schedule()]), 6, 1)).toEqual([]);
});
it('flags a fully overlapping time range', () => {
const result = findTimeConflicts(range('09:00', '12:00'), matrix([schedule()]), 6, 1, null, fullRange);
expect(result).toHaveLength(1);
});
it('flags a partially overlapping time range', () => {
const result = findTimeConflicts(range('11:00', '13:00'), matrix([schedule()]), 6, 1, null, fullRange);
expect(result).toHaveLength(1);
});
it('allows a slot with exactly 10 minutes of buffer', () => {
const result = findTimeConflicts(range('12:10', '13:00'), matrix([schedule()]), 6, 1, null, fullRange);
expect(result).toEqual([]);
});
it('flags a slot within the 10-minute buffer', () => {
const result = findTimeConflicts(range('11:55', '12:10'), matrix([schedule()]), 6, 1, null, fullRange);
expect(result).toHaveLength(1);
});
it('ignores the schedule being edited', () => {
const result = findTimeConflicts(
range('09:00', '12:00'),
matrix([schedule({ id: 42 })]),
6,
1,
42,
);
expect(result).toEqual([]);
});
it('ignores inactive schedules', () => {
const result = findTimeConflicts(
range('09:00', '12:00'),
matrix([schedule({ status: 'inactive' })]),
6,
1,
);
expect(result).toEqual([]);
});
it('does not check other classrooms or weekdays', () => {
const list = [schedule()];
expect(findTimeConflicts(range('09:00', '12:00'), matrix(list), 7, 1, null, fullRange)).toEqual([]);
expect(findTimeConflicts(range('09:00', '12:00'), matrix(list), 6, 2, null, fullRange)).toEqual([]);
});
it('ignores schedules whose effective date range does not overlap the new schedule', () => {
const existing = schedule({ startDate: '2026-01-01', endDate: '2026-01-31' });
const newRange: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs('2026-03-01'), dayjs('2026-03-31')];
expect(findTimeConflicts(range('09:00', '12:00'), matrix([existing]), 6, 1, null, newRange)).toEqual([]);
});
it('flags schedules whose effective date range overlaps the new schedule', () => {
const existing = schedule({ startDate: '2026-03-15', endDate: '2026-04-15' });
const newRange: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs('2026-03-01'), dayjs('2026-03-31')];
expect(findTimeConflicts(range('09:00', '12:00'), matrix([existing]), 6, 1, null, newRange)).toHaveLength(1);
});
it('treats schedules without date range as always overlapping', () => {
const existing = schedule({ startDate: '', endDate: '' });
const newRange: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs('2026-03-01'), dayjs('2026-03-31')];
expect(findTimeConflicts(range('09:00', '12:00'), matrix([existing]), 6, 1, null, newRange)).toHaveLength(1);
});
});

View File

@@ -0,0 +1,69 @@
import type { Dayjs } from 'dayjs';
import type { ClassScheduleItem } from './ScheduleGrids';
const toMinutes = (time: string): number => {
const [h, m] = time.split(':').map(Number);
if (Number.isNaN(h) || Number.isNaN(m)) return NaN;
return h * 60 + m;
};
/** 前后排课最小间隔(分钟),与后端 schedules.service.ts 的 SCHEDULE_GAP_MINUTES 保持一致。 */
const SCHEDULE_GAP_MINUTES = 10;
/** 已有排课的生效日期范围是否与新排课重叠ISO 日期字符串可直接比较)。 */
function dateRangesOverlap(
schedule: ClassScheduleItem,
newStartDate: string,
newEndDate: string,
): boolean {
if (!schedule.startDate || !schedule.endDate) return true;
return schedule.startDate <= newEndDate && schedule.endDate >= newStartDate;
}
/**
* 找出与所选时段冲突的已有排课(同一教室同一天,含前后 10 分钟缓冲)。
* 编辑模式传 editingId 排除自身;已停用排课不参与。
* dateRange 传入新排课的起止日期后,还会按生效日期范围重叠过滤,与后端 checkConflict 语义一致,
* 避免月/周视图内日期不重叠的排课被误报为冲突。
*/
export function findTimeConflicts(
timeRange: [Dayjs, Dayjs] | undefined,
weekMatrix: Record<number, Record<number, ClassScheduleItem[]>>,
classroomId: number | undefined,
weekDay: number | undefined,
editingId?: number | null,
dateRange?: [Dayjs, Dayjs] | null,
): ClassScheduleItem[] {
// RangePicker 选择中途会提交 [Dayjs, null] 半选值,直接 .format 会抛错;
// 无效 Dayjs 也会让时间比较恒为 false 静默漏报。任一元素缺失/无效即视为「未选完」
if (
!timeRange ||
!timeRange[0]?.isValid() ||
!timeRange[1]?.isValid() ||
(dateRange && (!dateRange[0]?.isValid() || !dateRange[1]?.isValid())) ||
classroomId === undefined ||
classroomId === null ||
weekDay === undefined ||
weekDay === null
) {
return [];
}
const newStart = toMinutes(timeRange[0].format('HH:mm')) - SCHEDULE_GAP_MINUTES;
const newEnd = toMinutes(timeRange[1].format('HH:mm')) + SCHEDULE_GAP_MINUTES;
const newStartDate = dateRange?.[0].format('YYYY-MM-DD');
const newEndDate = dateRange?.[1].format('YYYY-MM-DD');
// 新排课日期范围未选(创建弹窗打开初期)时无法判断与已有排课的生效日期是否重叠,
// 直接返回空避免把日期不重叠的排课误报为冲突;用户选完日期后校验自然生效
if (!newStartDate || !newEndDate) return [];
return (weekMatrix[classroomId]?.[weekDay] || []).filter((schedule) => {
if (schedule.id !== undefined && schedule.id !== null && schedule.id === editingId) return false;
if (schedule.status === 'inactive') return false;
// 前面已保证 newStartDate/newEndDate 均存在(缺失直接返回空)
if (!dateRangesOverlap(schedule, newStartDate, newEndDate)) return false;
const existingStart = toMinutes(schedule.startTime);
const existingEnd = toMinutes(schedule.endTime);
// 时间不可解析(空/非 HH:mm时按「必定冲突」处理避免坏数据被静默跳过漏报
if (Number.isNaN(existingStart) || Number.isNaN(existingEnd)) return true;
return newStart < existingEnd && newEnd > existingStart;
});
}