Files
gongxue-base/apps/admin/src/pages/Schedules/index.tsx
wangziqi 375c7ec60b feat: refine admin forms, attendance and finance workflows
Squash merge PR #23.

Included changes:
- complete occupancy check-in required fields/default payload
- improve responsive admin management pages
- fix attendance edge cases and attendance period config
- refine wallet/finance-related workflow handling

Checks:
- npm run typecheck -w apps/admin
- npm run typecheck -w apps/server
2026-07-18 12:54:10 +00:00

1350 lines
48 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.

import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Card,
Button,
Select,
Modal,
Form,
Input,
InputNumber,
DatePicker,
TimePicker,
Popconfirm,
Space,
Spin,
Empty,
Tag,
Tooltip,
Segmented,
Badge,
Row,
Col,
Statistic,
Alert,
Switch,
} from 'antd';
import {
CalendarOutlined,
LeftOutlined,
RightOutlined,
CloudSyncOutlined,
PlusOutlined,
EditOutlined,
StopOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { usePermission } from '../../hooks/usePermission';
import { message } from '../../ui/app-message';
import {
buildSchedulePayload,
scheduleToFormValues,
type ScheduleFormValues,
} from './schedule-form';
import { filterSchedulesForClass, isMaskedSchedule } from './schedule-visibility';
import { classifySyncResult } from './sync-result';
// ---- Types ----
interface ClassScheduleItem {
id: number | null;
classId: number | null;
classroomId: number;
weekDay: number;
startTime: string;
endTime: string;
attendanceAdvanceMinutes: number;
startDate: string;
endDate: string;
subject: string;
teacherId: number | null;
scheduleType: string;
status: string;
notes: string | null;
createdAt: string;
updatedAt: string;
canViewDetails?: boolean;
}
interface ClassroomItem {
id: number;
name: string;
building: string;
floor: number;
roomType: string;
}
interface ClassItem {
id: number;
name: string;
code: string;
}
interface ClassTeacherOption {
id: number;
userId: number;
username?: string;
name?: string;
roleType: string;
subject?: string | null;
}
/** 排班同步返回结果 */
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 WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7];
// ---- Component ----
const SchedulesPage: React.FC = () => {
const { hasPermission } = usePermission();
// View mode and navigation
const [viewMode, setViewMode] = useState<'week' | 'month'>('week');
const [viewDate, setViewDate] = useState<Dayjs>(() => dayjs().weekday(1).startOf('day'));
// Modal date selection (month view)
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
// Data
const [classrooms, setClassrooms] = useState<ClassroomItem[]>([]);
const [classes, setClasses] = useState<ClassItem[]>([]);
const [classTeachers, setClassTeachers] = useState<ClassTeacherOption[]>([]);
const [matrix, setMatrix] = useState<Record<number, Record<number, ClassScheduleItem[]>>>({});
const [loading, setLoading] = useState(false);
// Filters
const [filterClassroomIds, setFilterClassroomIds] = useState<number[]>([]);
const [filterClassId, setFilterClassId] = useState<number | undefined>(undefined);
// Modal
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 [syncStatus, setSyncStatus] = useState<{
activeSchedules: number;
mappedClasses: number;
totalClasses: number;
} | null>(null);
const [syncResult, setSyncResult] = useState<{
scheduleCount: number;
shiftCount: number;
groupCount: number;
syncedItems: number;
skippedNoMapping: number;
failedBatchCount: number;
failedItems: number;
errors: string[];
groups: Array<{ className: string; groupId: number; itemCount: number }>;
} | null>(null);
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
const [syncDays, setSyncDays] = useState(30);
const [attendanceMachineOnly, setAttendanceMachineOnly] = useState(false);
/** 打开同步弹窗时先查询就绪状态 */
const openSyncModal = useCallback(async () => {
setSyncModalOpen(true);
setSyncResult(null);
try {
const res = await api.get<{
success: boolean;
data: { activeSchedules: number; mappedClasses: number; totalClasses: number };
}>('/sync/schedule/status');
setSyncStatus(res.data);
} catch {
setSyncStatus(null);
}
}, []);
/** 执行排班同步 */
const handleSyncSchedule = useCallback(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);
} else {
message.success(classification.message);
}
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '同步失败');
} finally {
setSyncing(false);
}
}, [syncDateFrom, syncDays, attendanceMachineOnly]);
const [form] = Form.useForm<ScheduleFormValues>();
// Derived week/month info
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]);
// ---- Data fetching ----
const fetchData = useCallback(async () => {
setLoading(true);
try {
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[]>>>,
]);
setClassrooms(lookups.classrooms);
setClasses(lookups.classes);
// Convert string keys to numbers
const typedMatrix: Record<number, Record<number, ClassScheduleItem[]>> = {};
for (const [cId, dayMap] of Object.entries(schedulesRes)) {
const classroomId = Number(cId);
typedMatrix[classroomId] = {};
for (const [wd, schedules] of Object.entries(dayMap)) {
typedMatrix[classroomId][Number(wd)] = schedules;
}
}
setMatrix(typedMatrix);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载排课数据失败');
} finally {
setLoading(false);
}
}, [startDateStr, endDateStr, filterClassroomIds]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ---- Filtered classrooms ----
const filteredClassrooms = useMemo(() => {
if (filterClassroomIds.length === 0) return classrooms;
const idSet = new Set(filterClassroomIds);
return classrooms.filter((c) => idSet.has(c.id));
}, [classrooms, filterClassroomIds]);
// Apply class filter to the matrix
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]);
// ---- Cell click handlers ----
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 = useCallback(async (classId: number) => {
try {
const teachers = await api.get<ClassTeacherOption[]>(
`/class-schedules/classes/${classId}/teachers`,
);
setClassTeachers(teachers);
return teachers;
} catch {
setClassTeachers([]);
return [];
}
}, []);
const applyClassTeacherDefaults = useCallback(
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);
}
},
[form, loadClassTeachers],
);
// ---- Create / edit schedule ----
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);
if (modalMode === 'edit' && editingSchedule) {
await api.put(`/class-schedules/${editingSchedule.id}`, payload);
message.success('排课更新成功,请重新同步到钉钉排班');
} else {
await api.post('/class-schedules', payload);
message.success('排课创建成功');
}
setModalOpen(false);
setEditingSchedule(null);
fetchData();
} catch (e: unknown) {
const err = e as { message?: string; status?: number };
message.error(err?.message || (modalMode === 'edit' ? '更新排课失败' : '创建排课失败'));
} finally {
setSubmitting(false);
}
};
const openEditSchedule = (schedule: ClassScheduleItem) => {
if (isMaskedSchedule(schedule) || schedule.id === null || schedule.classId === null) return;
if (schedule.scheduleType === 'RENTAL') {
message.warning('租赁排课请在租赁订单中修改');
return;
}
const editableSchedule = { ...schedule, id: schedule.id, classId: schedule.classId };
setEditingSchedule(schedule);
setModalMode('edit');
form.setFieldsValue(scheduleToFormValues(editableSchedule));
void loadClassTeachers(editableSchedule.classId);
};
const removeScheduleFromSelection = (id: number) => {
const remaining = selectedSchedules.filter((s) => s.id !== id);
setSelectedSchedules(remaining);
if (remaining.length === 0) {
setModalOpen(false);
}
};
// ---- Disable / delete schedule ----
const handleDisable = async (id: number | null) => {
if (id === null) return;
try {
await api.put(`/class-schedules/${id}`, { status: 'inactive' });
message.success('排课已停用,历史考勤记录已保留,教室占用已释放');
removeScheduleFromSelection(id);
fetchData();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '停用失败');
}
};
// ---- Classroom select options ----
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],
);
// ---- Render ----
const selectedClassroom = selectedCell
? classrooms.find((c) => c.id === selectedCell.classroomId)
: undefined;
return (
<div>
{/* Header */}
<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={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>
{/* Filters */}
<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>
{/* Matrix */}
<Spin spinning={loading}>
{classrooms.length === 0 ? (
<Empty description="暂无教室数据" />
) : viewMode === 'week' ? (
<div style={{ overflowX: 'auto' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 13,
tableLayout: 'fixed',
}}
>
<thead>
<tr style={{ background: '#fafafa' }}>
<th
style={{
position: 'sticky',
left: 0,
background: '#fafafa',
zIndex: 2,
padding: '10px 12px',
border: '1px solid #f0f0f0',
width: 150,
textAlign: 'left',
}}
>
</th>
{WEEKDAYS.map((day) => (
<th
key={day}
style={{
padding: '10px 8px',
border: '1px solid #f0f0f0',
textAlign: 'center',
background: '#fafafa',
fontWeight: 500,
}}
>
{day}
</th>
))}
</tr>
</thead>
<tbody>
{filteredClassrooms.map((classroom) => (
<tr key={classroom.id}>
<td
style={{
position: 'sticky',
left: 0,
background: '#fff',
zIndex: 1,
padding: '8px 12px',
border: '1px solid #f0f0f0',
fontWeight: 500,
}}
>
<div>{classroom.name}</div>
{classroom.building && (
<div style={{ fontSize: 11, color: '#8c8c8c', marginTop: 2 }}>
{classroom.building}
{classroom.floor ? ` ${classroom.floor}F` : ''}
</div>
)}
</td>
{WEEKDAY_NUMBERS.map((wd) => {
const schedules = displayMatrix[classroom.id]?.[wd] || [];
const hasContent = schedules.length > 0;
return (
<td
key={wd}
tabIndex={0}
role="button"
aria-label={`选择教室 ${classroom.name} ${WEEKDAYS[wd - 1]} 排课`}
onClick={() => handleCellClick(classroom.id, wd)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCellClick(classroom.id, wd);
}
}}
style={{
padding: 4,
border: '1px solid #f0f0f0',
verticalAlign: 'top',
cursor: 'pointer',
minHeight: 56,
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = '#f6f8fa';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = '';
}}
>
{hasContent ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{schedules.map((s) => (
<Tooltip
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
title={
isMaskedSchedule(s)
? `已占用 · ${s.startTime}-${s.endTime}`
: `${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`
}
>
<div
style={{
background: isMaskedSchedule(s) ? '#f5f5f5' : '#e6f4ff',
border: isMaskedSchedule(s)
? '1px solid #d9d9d9'
: '1px solid #91caff',
borderRadius: 4,
padding: '2px 6px',
fontSize: 12,
lineHeight: '18px',
}}
>
<div
style={{
fontWeight: 600,
color: isMaskedSchedule(s) ? '#595959' : '#1677ff',
}}
>
{s.subject}
</div>
<div style={{ color: '#595959' }}>
{s.startTime}-{s.endTime}
</div>
</div>
</Tooltip>
))}
</div>
) : (
<div
style={{
color: '#d9d9d9',
fontSize: 20,
textAlign: 'center',
lineHeight: '44px',
}}
>
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 13,
tableLayout: 'fixed',
}}
>
<thead>
<tr style={{ background: '#fafafa' }}>
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map((d) => (
<th
key={d}
style={{
padding: '10px 8px',
border: '1px solid #f0f0f0',
textAlign: 'center',
fontWeight: 500,
}}
>
{d}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week, wi) => (
<tr key={wi}>
{week.map((day, di) => {
const isCurrentMonth = day.month() === monthStart.month();
const dateKey = day.format('YYYY-MM-DD');
const daySchedules = monthScheduleMap[dateKey] || [];
const count = daySchedules.length;
return (
<td
key={di}
tabIndex={0}
role="button"
aria-label={`${day.format('YYYY-MM-DD')} 排课详情`}
onClick={() => handleDateClick(day)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDateClick(day);
}
}}
style={{
padding: '6px 8px',
border: '1px solid #f0f0f0',
verticalAlign: 'top',
cursor: 'pointer',
height: 90,
background: isCurrentMonth ? '#fff' : '#fafafa',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = isCurrentMonth
? '#f0f5ff'
: '#f0f0f0';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = isCurrentMonth
? ''
: '#fafafa';
}}
>
<div
style={{
fontWeight: isCurrentMonth ? 600 : 400,
color: isCurrentMonth ? '#262626' : '#bfbfbf',
fontSize: 14,
marginBottom: 4,
}}
>
{day.date()}
</div>
{count > 0 && (
<Badge
count={count}
size="small"
overflowCount={99}
style={{ backgroundColor: '#1677ff' }}
/>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</Spin>
{/* Modal */}
<Modal
title={
modalMode === 'create'
? `新增排课 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
: modalMode === 'edit'
? `编辑排课 — ${editingSchedule?.subject || ''}`
: selectedDate
? `排课详情 — ${selectedDate.format('YYYY-MM-DD')} ${WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1]}`
: `排课详情 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
}
open={modalOpen}
onCancel={() => {
setModalOpen(false);
setEditingSchedule(null);
}}
onOk={modalMode !== 'detail' ? handleSubmit : undefined}
confirmLoading={submitting}
okText={modalMode === 'edit' ? '保存' : modalMode === 'create' ? '创建' : undefined}
footer={modalMode === 'detail' ? null : undefined}
width={600}
destroyOnHidden
>
{modalMode !== 'detail' ? (
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="classId"
label="班级"
rules={[{ required: true, message: '请选择班级' }]}
>
<Select
placeholder="选择班级"
showSearch
optionFilterProp="label"
options={classOptions}
onChange={(classId: number) => {
form.setFieldValue('teacherId', undefined);
void applyClassTeacherDefaults(classId, form.getFieldValue('subject'));
}}
/>
</Form.Item>
<Form.Item
name="classroomId"
label="教室"
rules={[{ required: true, message: '请选择教室' }]}
>
<Select
placeholder="选择教室"
showSearch
optionFilterProp="label"
options={classroomOptions}
/>
</Form.Item>
<Form.Item
name="weekDay"
label="星期"
rules={[{ required: true, message: '请选择星期' }]}
>
<Select
options={WEEKDAY_NUMBERS.map((value) => ({ value, label: WEEKDAYS[value - 1] }))}
/>
</Form.Item>
<Form.Item
name="subject"
label="科目"
rules={[{ required: true, message: '请输入科目' }]}
>
<Input
placeholder="如:数学、语文"
onBlur={(event) => {
const classId = form.getFieldValue('classId');
if (classId) void applyClassTeacherDefaults(classId, event.target.value);
}}
/>
</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="该班级暂无可选教师,请先在班级详情配置教师"
/>
</Form.Item>
<Form.Item
name="notes"
label="备注"
rules={[{ max: 500, message: '备注不能超过500字' }]}
>
<Input.TextArea
rows={3}
maxLength={500}
showCount
placeholder="可填写排课说明、设备需求或临时调整原因"
/>
</Form.Item>
<Form.Item
name="attendanceAdvanceMinutes"
label="课前签到时间"
tooltip="从上课前指定分钟开始,到下课时间结束;期间任意上班或下班打卡都计为出勤"
initialValue={30}
rules={[{ required: true, message: '请设置课前签到时间' }]}
>
<InputNumber
min={0}
max={1440}
step={5}
addonAfter="分钟"
style={{ width: '100%' }}
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>
</Form>
) : (
<div style={{ lineHeight: 2 }}>
<div
style={{
marginBottom: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontWeight: 500 }}></span>
<PermissionButton
permission="schedule:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
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,
});
}}
>
</PermissionButton>
</div>
{selectedSchedules.length === 0 ? (
<Empty description="该时段暂无排课" />
) : (
selectedSchedules.map((s) => (
<Card
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
size="small"
style={{ marginBottom: 8 }}
styles={{ body: { padding: 12 } }}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<div>
<div>
<strong>{isMaskedSchedule(s) ? '状态:' : '科目:'}</strong>
<Tag color={isMaskedSchedule(s) ? 'default' : 'blue'}>{s.subject}</Tag>
</div>
{!isMaskedSchedule(s) && (
<div>
<strong></strong>
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
</div>
)}
{!isMaskedSchedule(s) && s.teacherId != null && (
<div>
<strong></strong>
{classTeachers.find((u) => u.userId === s.teacherId)?.name ||
classTeachers.find((u) => u.userId === s.teacherId)?.username ||
`#${s.teacherId}`}
</div>
)}
<div>
<strong></strong>
{s.startTime} ~ {s.endTime}
</div>
{!isMaskedSchedule(s) && (
<div>
<strong></strong>
{s.attendanceAdvanceMinutes ?? 30}
</div>
)}
<div>
<strong></strong>
{s.startDate} ~ {s.endDate}
</div>
{!isMaskedSchedule(s) && s.notes && (
<div>
<strong></strong>
{s.notes}
</div>
)}
{!isMaskedSchedule(s) && (
<div>
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
</Tag>
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
</div>
)}
</div>
{!isMaskedSchedule(s) && (
<Space>
{s.scheduleType !== 'RENTAL' && (
<PermissionButton
permission="schedule:edit"
size="small"
icon={<EditOutlined />}
onClick={() => openEditSchedule(s)}
>
</PermissionButton>
)}
{s.scheduleType !== 'RENTAL' && s.status === 'active' && (
<Popconfirm
title="确认停用该排课?"
description="停用后历史考勤记录会保留,但该排课不会再显示或占用教室。"
onConfirm={() => handleDisable(s.id)}
okText="停用"
cancelText="取消"
>
<PermissionButton
permission="schedule:edit"
size="small"
icon={<StopOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
</Space>
)}
</div>
</Card>
))
)}
</div>
)}
</Modal>
{/* ── 钉钉排班同步 Modal ── */}
<Modal
title="同步排课到钉钉考勤排班"
open={syncModalOpen}
onCancel={() => {
setSyncModalOpen(false);
setSyncResult(null);
}}
footer={
syncResult
? [
<Button
key="close"
onClick={() => {
setSyncModalOpen(false);
setSyncResult(null);
}}
>
</Button>,
]
: [
<Button
key="cancel"
onClick={() => {
setSyncModalOpen(false);
setSyncResult(null);
}}
>
</Button>,
<Button
key="sync"
type="primary"
icon={<CloudSyncOutlined />}
loading={syncing}
onClick={handleSyncSchedule}
disabled={!syncStatus || syncStatus.activeSchedules === 0}
>
</Button>,
]
}
width={560}
>
{syncResult ? (
/* ── 同步结果 ── */
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Statistic title="排课" value={syncResult.scheduleCount} suffix="条" />
</Col>
<Col xs={12} sm={6}>
<Statistic title="班次" value={syncResult.shiftCount} suffix="个" />
</Col>
<Col xs={12} sm={6}>
<Statistic title="考勤组" value={syncResult.groupCount} suffix="个" />
</Col>
<Col xs={12} sm={6}>
<Statistic
title="排班数"
value={syncResult.syncedItems}
suffix="条"
valueStyle={{ color: '#3f8600' }}
/>
</Col>
</Row>
{syncResult.skippedNoMapping > 0 && (
<Alert
type="warning"
message={`${syncResult.skippedNoMapping} 条排课因班级无钉钉绑定学生而跳过`}
style={{ marginBottom: 16 }}
showIcon
/>
)}
{syncResult.failedBatchCount > 0 && (
<>
<Alert
type="error"
message={`${syncResult.failedBatchCount} 批写入失败,共 ${syncResult.failedItems}`}
description={
syncResult.errors.length > 0
? syncResult.errors.slice(0, 5).map((err, i) => (
<div key={i} style={{ wordBreak: 'break-all' }}>
{err}
</div>
))
: undefined
}
style={{ marginBottom: 16 }}
showIcon
/>
{syncResult.errors.length > 5 && (
<div style={{ fontSize: 12, color: '#999', marginBottom: 16, marginTop: -12 }}>
... {syncResult.errors.length - 5}
</div>
)}
</>
)}
{syncResult.groups.length > 0 && (
<div>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>
{syncResult.groups.map((g) => (
<Tag key={g.groupId} color="blue" style={{ marginBottom: 4 }}>
{g.className}{g.itemCount}
</Tag>
))}
</div>
)}
</div>
) : syncStatus ? (
/* ── 同步确认信息 ── */
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Statistic title="活跃排课" value={syncStatus.activeSchedules} suffix="条" />
</Col>
<Col xs={24} sm={8}>
<Statistic
title="已就绪班级"
value={syncStatus.mappedClasses}
suffix={`/ ${syncStatus.totalClasses}`}
valueStyle={{
color:
syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600',
}}
/>
</Col>
<Col xs={24} sm={8}>
<Statistic
title="无绑定学生班级"
value={syncStatus.totalClasses - syncStatus.mappedClasses}
suffix="个"
/>
</Col>
</Row>
{syncStatus.mappedClasses < syncStatus.totalClasses && (
<Alert
type="warning"
message={`${syncStatus.totalClasses - syncStatus.mappedClasses} 个班级没有已绑定钉钉的学生,其排课将被跳过。请先在钉钉集成页导入并绑定学生。`}
style={{ marginBottom: 16 }}
showIcon
/>
)}
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Space wrap>
<span></span>
<DatePicker
value={syncDateFrom}
onChange={(d) => d && setSyncDateFrom(d)}
allowClear={false}
/>
<span></span>
<Select
value={syncDays}
onChange={setSyncDays}
style={{ width: 100 }}
options={[
{ value: 7, label: '7 天' },
{ value: 14, label: '14 天' },
{ value: 30, label: '30 天' },
{ value: 60, label: '60 天' },
{ value: 90, label: '90 天' },
]}
/>
</Space>
<div style={{ marginTop: 16 }}>
<Space align="start">
<Switch checked={attendanceMachineOnly} onChange={setAttendanceMachineOnly} />
<div>
<div style={{ fontWeight: 500 }}></div>
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 2 }}>
Wi-Fi
</div>
</div>
</Space>
</div>
{attendanceMachineOnly && (
<Alert
type="info"
showIcon
message="已存在的同名考勤组也会在本次同步中更新为仅考勤机打卡。"
style={{ marginTop: 12 }}
/>
)}
</div>
{syncStatus.activeSchedules === 0 && (
<Alert
type="info"
message="当前没有活跃排课。请先在排课页面创建排课记录。"
showIcon
/>
)}
</div>
) : (
<Spin tip="查询同步状态..." />
)}
</Modal>
</div>
);
};
export default SchedulesPage;