fix permissions and teacher attendance workflows
This commit is contained in:
@@ -1,8 +1,26 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Card, Button, Select, Modal, Form, Input, DatePicker, TimePicker,
|
||||
Popconfirm, message, Space, Spin, Empty, Tag, Tooltip, Segmented,
|
||||
Badge, Row, Col, Statistic, Alert,
|
||||
Card,
|
||||
Button,
|
||||
Select,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Spin,
|
||||
Empty,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Segmented,
|
||||
Badge,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Alert,
|
||||
Switch,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
@@ -11,10 +29,17 @@ import {
|
||||
DeleteOutlined,
|
||||
CloudSyncOutlined,
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
import {
|
||||
buildSchedulePayload,
|
||||
scheduleToFormValues,
|
||||
type ScheduleFormValues,
|
||||
} from './schedule-form';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@@ -50,20 +75,14 @@ interface ClassItem {
|
||||
code: string;
|
||||
}
|
||||
|
||||
|
||||
interface UserItem {
|
||||
interface ClassTeacherOption {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
userId: number;
|
||||
username?: string;
|
||||
name?: string;
|
||||
roleType: string;
|
||||
subject?: string | null;
|
||||
}
|
||||
interface ScheduleFormValues {
|
||||
classId: number;
|
||||
subject: string;
|
||||
teacherId?: number;
|
||||
timeRange: [Dayjs, Dayjs];
|
||||
dateRange: [Dayjs, Dayjs];
|
||||
}
|
||||
|
||||
/** 排班同步返回结果 */
|
||||
interface ScheduleSyncResult {
|
||||
scheduleCount: number;
|
||||
@@ -90,7 +109,7 @@ const SchedulesPage: React.FC = () => {
|
||||
// Data
|
||||
const [classrooms, setClassrooms] = useState<ClassroomItem[]>([]);
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [classTeachers, setClassTeachers] = useState<ClassTeacherOption[]>([]);
|
||||
const [matrix, setMatrix] = useState<Record<number, Record<number, ClassScheduleItem[]>>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -100,7 +119,8 @@ const SchedulesPage: React.FC = () => {
|
||||
|
||||
// Modal
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'detail'>('create');
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit' | 'detail'>('create');
|
||||
const [editingSchedule, setEditingSchedule] = useState<ClassScheduleItem | null>(null);
|
||||
const [selectedCell, setSelectedCell] = useState<{
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
@@ -112,15 +132,21 @@ const SchedulesPage: React.FC = () => {
|
||||
const [syncModalOpen, setSyncModalOpen] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState<{
|
||||
activeSchedules: number; mappedClasses: number; totalClasses: number;
|
||||
activeSchedules: number;
|
||||
mappedClasses: number;
|
||||
totalClasses: number;
|
||||
} | null>(null);
|
||||
const [syncResult, setSyncResult] = useState<{
|
||||
scheduleCount: number; shiftCount: number; groupCount: number;
|
||||
syncedItems: number; skippedNoMapping: number;
|
||||
scheduleCount: number;
|
||||
shiftCount: number;
|
||||
groupCount: number;
|
||||
syncedItems: number;
|
||||
skippedNoMapping: number;
|
||||
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 () => {
|
||||
@@ -128,7 +154,8 @@ const SchedulesPage: React.FC = () => {
|
||||
setSyncResult(null);
|
||||
try {
|
||||
const res = await api.get<{
|
||||
success: boolean; data: { activeSchedules: number; mappedClasses: number; totalClasses: number };
|
||||
success: boolean;
|
||||
data: { activeSchedules: number; mappedClasses: number; totalClasses: number };
|
||||
}>('/sync/schedule/status');
|
||||
setSyncStatus(res.data);
|
||||
} catch {
|
||||
@@ -141,11 +168,13 @@ const SchedulesPage: React.FC = () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const res = await api.post<{
|
||||
success: boolean; data: ScheduleSyncResult;
|
||||
success: boolean;
|
||||
data: ScheduleSyncResult;
|
||||
}>('/sync/schedule/sync', null, {
|
||||
params: {
|
||||
dateFrom: syncDateFrom.format('YYYY-MM-DD'),
|
||||
days: syncDays,
|
||||
attendanceMachineOnly,
|
||||
},
|
||||
});
|
||||
setSyncResult(res.data);
|
||||
@@ -156,7 +185,7 @@ const SchedulesPage: React.FC = () => {
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [syncDateFrom, syncDays]);
|
||||
}, [syncDateFrom, syncDays, attendanceMachineOnly]);
|
||||
const [form] = Form.useForm<ScheduleFormValues>();
|
||||
|
||||
// Derived week/month info
|
||||
@@ -201,7 +230,6 @@ const SchedulesPage: React.FC = () => {
|
||||
return weekEnd.format('YYYY-MM-DD');
|
||||
}, [viewMode, weekEnd, calendarDays]);
|
||||
|
||||
|
||||
// ---- Data fetching ----
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -244,10 +272,6 @@ const SchedulesPage: React.FC = () => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<UserItem[]>('/rbac/users').then(setUsers).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ---- Filtered classrooms ----
|
||||
|
||||
const filteredClassrooms = useMemo(() => {
|
||||
@@ -304,7 +328,10 @@ const SchedulesPage: React.FC = () => {
|
||||
setModalOpen(true);
|
||||
} else {
|
||||
setSelectedSchedules([]);
|
||||
setEditingSchedule(null);
|
||||
setModalMode('create');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ classroomId, weekDay });
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
@@ -334,38 +361,75 @@ const SchedulesPage: React.FC = () => {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// ---- Create schedule ----
|
||||
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 (!selectedCell) return;
|
||||
if (modalMode === 'create' && !selectedCell) return;
|
||||
if (modalMode === 'edit' && !editingSchedule) return;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const values = (await form.validateFields()) as ScheduleFormValues;
|
||||
setSubmitting(true);
|
||||
const payload = buildSchedulePayload(values);
|
||||
|
||||
const payload = {
|
||||
classId: values.classId,
|
||||
subject: values.subject,
|
||||
teacherId: values.teacherId,
|
||||
classroomId: selectedCell.classroomId,
|
||||
weekDay: selectedCell.weekDay,
|
||||
startTime: values.timeRange[0].format('HH:mm'),
|
||||
endTime: values.timeRange[1].format('HH:mm'),
|
||||
startDate: values.dateRange[0].format('YYYY-MM-DD'),
|
||||
endDate: values.dateRange[1].format('YYYY-MM-DD'),
|
||||
};
|
||||
|
||||
await api.post('/class-schedules', payload);
|
||||
message.success('排课创建成功');
|
||||
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 || '创建排课失败');
|
||||
message.error(err?.message || (modalMode === 'edit' ? '更新排课失败' : '创建排课失败'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditSchedule = (schedule: ClassScheduleItem) => {
|
||||
if (schedule.scheduleType === 'RENTAL') {
|
||||
message.warning('租赁排课请在租赁订单中修改');
|
||||
return;
|
||||
}
|
||||
setEditingSchedule(schedule);
|
||||
setModalMode('edit');
|
||||
form.setFieldsValue(scheduleToFormValues(schedule));
|
||||
void loadClassTeachers(schedule.classId);
|
||||
};
|
||||
|
||||
// ---- Delete schedule ----
|
||||
|
||||
@@ -406,15 +470,6 @@ const SchedulesPage: React.FC = () => {
|
||||
[classes],
|
||||
);
|
||||
|
||||
const userOptions = useMemo(
|
||||
() =>
|
||||
users.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.name || u.username}${u.name ? ` (${u.username})` : ''}`,
|
||||
})),
|
||||
[users],
|
||||
);
|
||||
|
||||
// ---- Render ----
|
||||
|
||||
const selectedClassroom = selectedCell
|
||||
@@ -450,7 +505,12 @@ const SchedulesPage: React.FC = () => {
|
||||
{ label: '月视图', value: 'month' },
|
||||
]}
|
||||
/>
|
||||
<PermissionButton permission="sync:trigger" type="primary" icon={<CloudSyncOutlined />} onClick={openSyncModal}>
|
||||
<PermissionButton
|
||||
permission="sync:trigger"
|
||||
type="primary"
|
||||
icon={<CloudSyncOutlined />}
|
||||
onClick={openSyncModal}
|
||||
>
|
||||
同步到钉钉排班
|
||||
</PermissionButton>
|
||||
{viewMode === 'week' ? (
|
||||
@@ -467,10 +527,7 @@ const SchedulesPage: React.FC = () => {
|
||||
({startDateStr} ~ {endDateStr})
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={() => setViewDate(viewDate.add(7, 'day'))}
|
||||
>
|
||||
<Button icon={<RightOutlined />} onClick={() => setViewDate(viewDate.add(7, 'day'))}>
|
||||
下一周
|
||||
</Button>
|
||||
</>
|
||||
@@ -524,7 +581,7 @@ const SchedulesPage: React.FC = () => {
|
||||
<Spin spinning={loading}>
|
||||
{classrooms.length === 0 ? (
|
||||
<Empty description="暂无教室数据" />
|
||||
) : (viewMode === 'week' ? (
|
||||
) : viewMode === 'week' ? (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table
|
||||
style={{
|
||||
@@ -724,10 +781,14 @@ const SchedulesPage: React.FC = () => {
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = isCurrentMonth ? '#f0f5ff' : '#f0f0f0';
|
||||
(e.currentTarget as HTMLElement).style.background = isCurrentMonth
|
||||
? '#f0f5ff'
|
||||
: '#f0f0f0';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = isCurrentMonth ? '' : '#fafafa';
|
||||
(e.currentTarget as HTMLElement).style.background = isCurrentMonth
|
||||
? ''
|
||||
: '#fafafa';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -756,7 +817,7 @@ const SchedulesPage: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
{/* Modal */}
|
||||
@@ -764,24 +825,25 @@ const SchedulesPage: React.FC = () => {
|
||||
title={
|
||||
modalMode === 'create'
|
||||
? `新增排课 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
|
||||
: selectedDate
|
||||
? `排课详情 — ${selectedDate.format('YYYY-MM-DD')} ${WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1]}`
|
||||
: `排课详情 — ${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)}
|
||||
onOk={modalMode === 'create' ? handleSubmit : undefined}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditingSchedule(null);
|
||||
}}
|
||||
onOk={modalMode !== 'detail' ? handleSubmit : undefined}
|
||||
confirmLoading={submitting}
|
||||
okText={modalMode === 'create' ? '创建' : undefined}
|
||||
footer={
|
||||
modalMode === 'create'
|
||||
? undefined // use default ok/cancel
|
||||
: null // no footer for detail mode
|
||||
}
|
||||
okText={modalMode === 'edit' ? '保存' : modalMode === 'create' ? '创建' : undefined}
|
||||
footer={modalMode === 'detail' ? null : undefined}
|
||||
width={600}
|
||||
destroyOnHidden
|
||||
>
|
||||
{modalMode === 'create' ? (
|
||||
{modalMode !== 'detail' ? (
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="classId"
|
||||
@@ -793,6 +855,33 @@ const SchedulesPage: React.FC = () => {
|
||||
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>
|
||||
|
||||
@@ -801,30 +890,26 @@ const SchedulesPage: React.FC = () => {
|
||||
label="科目"
|
||||
rules={[{ required: true, message: '请输入科目' }]}
|
||||
>
|
||||
<Input placeholder="如:数学、语文" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="teacherId"
|
||||
label="教师(可选)"
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="搜索并选择教师"
|
||||
optionFilterProp="label"
|
||||
options={userOptions}
|
||||
<Input
|
||||
placeholder="如:数学、语文"
|
||||
onBlur={(event) => {
|
||||
const classId = form.getFieldValue('classId');
|
||||
if (classId) void applyClassTeacherDefaults(classId, event.target.value);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="教室">
|
||||
<Input value={selectedClassroom?.name || ''} disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="星期">
|
||||
<Input
|
||||
value={selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}
|
||||
disabled
|
||||
<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>
|
||||
|
||||
@@ -854,14 +939,28 @@ const SchedulesPage: React.FC = () => {
|
||||
</Form>
|
||||
) : (
|
||||
<div style={{ lineHeight: 2 }}>
|
||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>已有排课</span>
|
||||
<Button
|
||||
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,
|
||||
});
|
||||
}}
|
||||
>
|
||||
新增排课
|
||||
@@ -877,7 +976,13 @@ const SchedulesPage: React.FC = () => {
|
||||
style={{ marginBottom: 8 }}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<strong>科目:</strong>
|
||||
@@ -890,9 +995,9 @@ const SchedulesPage: React.FC = () => {
|
||||
{s.teacherId != null && (
|
||||
<div>
|
||||
<strong>教师:</strong>
|
||||
{users.find((u) => u.id === s.teacherId)?.name
|
||||
|| users.find((u) => u.id === s.teacherId)?.username
|
||||
|| `#${s.teacherId}`}
|
||||
{classTeachers.find((u) => u.userId === s.teacherId)?.name ||
|
||||
classTeachers.find((u) => u.userId === s.teacherId)?.username ||
|
||||
`#${s.teacherId}`}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@@ -916,21 +1021,33 @@ const SchedulesPage: React.FC = () => {
|
||||
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<Popconfirm
|
||||
title="确认删除该排课?"
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton
|
||||
permission="schedule:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
<Space>
|
||||
{s.scheduleType !== 'RENTAL' && (
|
||||
<PermissionButton
|
||||
permission="schedule:edit"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditSchedule(s)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认删除该排课?"
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
<PermissionButton
|
||||
permission="schedule:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
@@ -943,22 +1060,45 @@ const SchedulesPage: React.FC = () => {
|
||||
<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>,
|
||||
]}
|
||||
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 ? (
|
||||
@@ -1014,11 +1154,18 @@ const SchedulesPage: React.FC = () => {
|
||||
title="已就绪班级"
|
||||
value={syncStatus.mappedClasses}
|
||||
suffix={`/ ${syncStatus.totalClasses}`}
|
||||
valueStyle={{ color: syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600' }}
|
||||
valueStyle={{
|
||||
color:
|
||||
syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600',
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="无绑定学生班级" value={syncStatus.totalClasses - syncStatus.mappedClasses} suffix="个" />
|
||||
<Statistic
|
||||
title="无绑定学生班级"
|
||||
value={syncStatus.totalClasses - syncStatus.mappedClasses}
|
||||
suffix="个"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
{syncStatus.mappedClasses < syncStatus.totalClasses && (
|
||||
@@ -1031,9 +1178,13 @@ const SchedulesPage: React.FC = () => {
|
||||
)}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>同步参数</div>
|
||||
<Space>
|
||||
<Space wrap>
|
||||
<span>起始日期:</span>
|
||||
<DatePicker value={syncDateFrom} onChange={(d) => d && setSyncDateFrom(d)} allowClear={false} />
|
||||
<DatePicker
|
||||
value={syncDateFrom}
|
||||
onChange={(d) => d && setSyncDateFrom(d)}
|
||||
allowClear={false}
|
||||
/>
|
||||
<span>天数:</span>
|
||||
<Select
|
||||
value={syncDays}
|
||||
@@ -1048,9 +1199,32 @@ const SchedulesPage: React.FC = () => {
|
||||
]}
|
||||
/>
|
||||
</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 />
|
||||
<Alert
|
||||
type="info"
|
||||
message="当前没有活跃排课。请先在排课页面创建排课记录。"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import dayjs from 'dayjs';
|
||||
import { buildSchedulePayload, scheduleToFormValues } from './schedule-form';
|
||||
|
||||
describe('schedule edit form mapping', () => {
|
||||
it('fills an existing schedule into editable form values', () => {
|
||||
const values = scheduleToFormValues({
|
||||
id: 3,
|
||||
classId: 1,
|
||||
classroomId: 1,
|
||||
weekDay: 5,
|
||||
subject: '语文',
|
||||
teacherId: null,
|
||||
startTime: '14:00',
|
||||
endTime: '18:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
});
|
||||
|
||||
expect(values.classroomId).toBe(1);
|
||||
expect(values.weekDay).toBe(5);
|
||||
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
|
||||
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
|
||||
'2026-07-01',
|
||||
'2026-07-31',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds the update payload from edited form values', () => {
|
||||
expect(
|
||||
buildSchedulePayload({
|
||||
classId: 1,
|
||||
classroomId: 2,
|
||||
weekDay: 6,
|
||||
subject: '作文',
|
||||
teacherId: 4,
|
||||
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
||||
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
||||
}),
|
||||
).toEqual({
|
||||
classId: 1,
|
||||
classroomId: 2,
|
||||
weekDay: 6,
|
||||
subject: '作文',
|
||||
teacherId: 4,
|
||||
startTime: '13:30',
|
||||
endTime: '17:20',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-31',
|
||||
});
|
||||
});
|
||||
});
|
||||
46
apps/admin/src/pages/Schedules/schedule-form.ts
Normal file
46
apps/admin/src/pages/Schedules/schedule-form.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
|
||||
export interface ScheduleFormValues {
|
||||
classId: number;
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
subject: string;
|
||||
teacherId?: number;
|
||||
timeRange: [Dayjs, Dayjs];
|
||||
dateRange: [Dayjs, Dayjs];
|
||||
}
|
||||
|
||||
export interface EditableSchedule {
|
||||
id: number;
|
||||
classId: number;
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
subject: string;
|
||||
teacherId: number | null;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormValues => ({
|
||||
classId: schedule.classId,
|
||||
classroomId: schedule.classroomId,
|
||||
weekDay: schedule.weekDay,
|
||||
subject: schedule.subject,
|
||||
teacherId: schedule.teacherId ?? undefined,
|
||||
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
|
||||
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
|
||||
});
|
||||
|
||||
export const buildSchedulePayload = (values: ScheduleFormValues) => ({
|
||||
classId: values.classId,
|
||||
classroomId: values.classroomId,
|
||||
weekDay: values.weekDay,
|
||||
subject: values.subject,
|
||||
teacherId: values.teacherId,
|
||||
startTime: values.timeRange[0].format('HH:mm'),
|
||||
endTime: values.timeRange[1].format('HH:mm'),
|
||||
startDate: values.dateRange[0].format('YYYY-MM-DD'),
|
||||
endDate: values.dateRange[1].format('YYYY-MM-DD'),
|
||||
});
|
||||
Reference in New Issue
Block a user