Files
gongxue-base/apps/admin/src/pages/Schedules/index.tsx
wangziqi a32c0a0731 fix(admin): 前端功能与安全修复(角色勾选/月视图新建/附件预览/考勤 late 等)
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- Roles 多组勾选、Schedules 月视图新建、useDirtyGuard 稳定引用
- JinshujuMatchModal success:false、Attendance store 订阅、EditableCell Enter、DynamicChart 空数据、渲染期 ref
- AttachmentsTab 附件预览不再 window.open(防存储型 XSS)+ 请求乱序防护
- 考勤 late 计入出勤率与主状态

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:54 +08:00

608 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 () => {
// 周视图走 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,
}),
);
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;