feat(admin): 排课冲突检测与教师选择状态收敛
- findTimeConflicts:半选值/无效日期防护、日期未选不误报、坏时间按必定冲突、10 分钟缓冲命名常量 - 教师下拉完全由表单驱动,移除冗余 teacherKey 状态(消除整类双源 desync 问题) - 教师加载加请求序号防过期覆盖 + loading 禁用下拉 + 失败提示不清空表单 - 编辑模式 teacherKey 按实际选项推导,fallback 优先任课老师行并同步科目 - 切班/切老师自动带出:仅真匹配时自动选中,显式科目无人教授时清空防错配 - 选项按复合 key 去重,冲突提示标注基于当前视图
This commit is contained in:
@@ -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 用唯一 key(userId__科目)。同一用户可同时持多个角色行(班主任+生活老师等,
|
||||
// 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
69
apps/admin/src/pages/Schedules/schedule-conflicts.ts
Normal file
69
apps/admin/src/pages/Schedules/schedule-conflicts.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user