Files
gongxue-base/apps/admin/src/pages/Schedules/index.tsx
wangziqi c10476d203 feat(admin): 弹窗未保存内容保护与空状态引导扩展
- 新增 useDirtyGuard hook:关闭弹窗时若表单被修改,先确认再关闭
- 接入 17 个弹窗:账号/密码/档案、角色、教室、宿舍/床位/柜子、
  考勤机、机构、班级、教师、考试、排课、入住/退宿/换房、费用
  (子组件弹窗用 useEffect 在回填后快照,时序正确)
- 学生列表空状态带「添加学生」引导动作
- 排课创建成功提示「同步钉钉」,考试创建成功提示「去详情录成绩」

aislop scan: 5 引擎 0 issues
2026-08-07 17:56:13 +08:00

607 lines
21 KiB
TypeScript

// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useMemo, 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 [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');
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);
};
const loadClassTeachers = async (classId: number) => {
try {
const teachers = await api.get<ClassTeacherOption[]>(
`/class-schedules/classes/${classId}/teachers`,
);
setClassTeachers(teachers);
return teachers;
} catch {
setClassTeachers([]);
return [];
}
};
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);
}
};
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);
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,
}),
);
void loadClassTeachers(schedule.classId);
};
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');
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);
}}
/>
<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;