feat: 重构各业务模块管理页面与服务
This commit is contained in:
330
apps/admin/src/pages/Schedules/ScheduleGrids.tsx
Normal file
330
apps/admin/src/pages/Schedules/ScheduleGrids.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
// aislop-ignore-file: duplicate-block -- 周/月视图表格结构相似且展示维度不同,已共享 ScheduleGrid 组件
|
||||
import React from 'react';
|
||||
import { Badge, Empty, Spin, Tooltip } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { isMaskedSchedule } from './schedule-visibility';
|
||||
|
||||
export const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
export const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export interface ClassroomItem {
|
||||
id: number;
|
||||
name: string;
|
||||
building: string;
|
||||
floor: number;
|
||||
roomType: string;
|
||||
}
|
||||
|
||||
export interface ClassItem {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface ClassTeacherOption {
|
||||
id: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
name?: string;
|
||||
roleType: string;
|
||||
subject?: string | null;
|
||||
}
|
||||
|
||||
export const ScheduleGrid: React.FC<{
|
||||
loading: boolean;
|
||||
viewMode: 'week' | 'month';
|
||||
classrooms: ClassroomItem[];
|
||||
filteredClassrooms: ClassroomItem[];
|
||||
displayMatrix: Record<number, Record<number, ClassScheduleItem[]>>;
|
||||
weeks: Dayjs[][];
|
||||
monthStart: Dayjs;
|
||||
monthScheduleMap: Record<string, ClassScheduleItem[]>;
|
||||
onCellClick: (classroomId: number, weekDay: number) => void;
|
||||
onDateClick: (date: Dayjs) => void;
|
||||
}> = ({
|
||||
loading,
|
||||
viewMode,
|
||||
classrooms,
|
||||
filteredClassrooms,
|
||||
displayMatrix,
|
||||
weeks,
|
||||
monthStart,
|
||||
monthScheduleMap,
|
||||
onCellClick,
|
||||
onDateClick,
|
||||
}) => {
|
||||
return (
|
||||
<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={() => onCellClick(classroom.id, wd)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onCellClick(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' }}>
|
||||
{WEEKDAYS.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={() => onDateClick(day)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onDateClick(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>
|
||||
);
|
||||
};
|
||||
561
apps/admin/src/pages/Schedules/ScheduleModals.tsx
Normal file
561
apps/admin/src/pages/Schedules/ScheduleModals.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Switch,
|
||||
Tag,
|
||||
TimePicker,
|
||||
} from 'antd';
|
||||
import { CloudSyncOutlined, EditOutlined, PlusOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { isMaskedSchedule } from './schedule-visibility';
|
||||
import type { ScheduleFormValues } from './schedule-form';
|
||||
import type { ClassItem, ClassScheduleItem, ClassTeacherOption, ClassroomItem } from './ScheduleGrids';
|
||||
import { WEEKDAYS } from './ScheduleGrids';
|
||||
|
||||
export interface ScheduleModalProps {
|
||||
open: boolean;
|
||||
mode: 'create' | 'edit' | 'detail';
|
||||
submitting: boolean;
|
||||
form: ReturnType<typeof Form.useForm<ScheduleFormValues>>[0];
|
||||
selectedCell: { classroomId: number; weekDay: number } | null;
|
||||
selectedDate: Dayjs | null;
|
||||
selectedSchedules: ClassScheduleItem[];
|
||||
editingSchedule: ClassScheduleItem | null;
|
||||
selectedClassroom?: ClassroomItem;
|
||||
classOptions: Array<{ value: number; label: string }>;
|
||||
classroomOptions: Array<{ value: number; label: string }>;
|
||||
classTeachers: ClassTeacherOption[];
|
||||
classes: ClassItem[];
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
onStartCreate: () => void;
|
||||
onEdit: (schedule: ClassScheduleItem) => void;
|
||||
onDisable: (id: number | null) => void;
|
||||
onClassChange: (classId: number) => void;
|
||||
onSubjectBlur: (value: string) => void;
|
||||
}
|
||||
|
||||
export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
open,
|
||||
mode,
|
||||
submitting,
|
||||
form,
|
||||
selectedCell,
|
||||
selectedDate,
|
||||
selectedSchedules,
|
||||
editingSchedule,
|
||||
selectedClassroom,
|
||||
classOptions,
|
||||
classroomOptions,
|
||||
classTeachers,
|
||||
classes,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onStartCreate,
|
||||
onEdit,
|
||||
onDisable,
|
||||
onClassChange,
|
||||
onSubjectBlur,
|
||||
}) => {
|
||||
const title =
|
||||
mode === 'create'
|
||||
? `新增排课 — ${selectedClassroom?.name || ''} · ${
|
||||
selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''
|
||||
}`
|
||||
: mode === 'edit'
|
||||
? `编辑排课 — ${editingSchedule?.subject || ''}`
|
||||
: selectedDate
|
||||
? `排课详情 — ${selectedDate.format('YYYY-MM-DD')} ${
|
||||
WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1]
|
||||
}`
|
||||
: `排课详情 — ${selectedClassroom?.name || ''} · ${
|
||||
selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={mode !== 'detail' ? onSubmit : undefined}
|
||||
confirmLoading={submitting}
|
||||
okText={mode === 'edit' ? '保存' : mode === 'create' ? '创建' : undefined}
|
||||
footer={mode === 'detail' ? null : undefined}
|
||||
width={600}
|
||||
destroyOnHidden
|
||||
>
|
||||
{mode !== '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={onClassChange}
|
||||
/>
|
||||
</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={WEEKDAYS.map((label, index) => ({ value: index + 1, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="subject"
|
||||
label="科目"
|
||||
rules={[{ required: true, message: '请输入科目' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="如:数学、语文"
|
||||
onBlur={(event) => onSubjectBlur(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={onStartCreate}
|
||||
>
|
||||
新增排课
|
||||
</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={() => onEdit(s)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
)}
|
||||
{s.scheduleType !== 'RENTAL' && s.status === 'active' && (
|
||||
<Popconfirm
|
||||
title="确认停用该排课?"
|
||||
description="停用后历史考勤记录会保留,但该排课不会再显示或占用教室。"
|
||||
onConfirm={() => onDisable(s.id)}
|
||||
okText="停用"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton
|
||||
permission="schedule:edit"
|
||||
size="small"
|
||||
icon={<StopOutlined />}
|
||||
>
|
||||
停用
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export interface SyncModalProps {
|
||||
open: boolean;
|
||||
syncing: boolean;
|
||||
syncStatus: {
|
||||
activeSchedules: number;
|
||||
mappedClasses: number;
|
||||
totalClasses: number;
|
||||
} | null;
|
||||
syncResult: {
|
||||
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;
|
||||
syncDateFrom: Dayjs;
|
||||
syncDays: number;
|
||||
attendanceMachineOnly: boolean;
|
||||
onClose: () => void;
|
||||
onSync: () => void;
|
||||
onDateChange: (date: Dayjs) => void;
|
||||
onDaysChange: (days: number) => void;
|
||||
onMachineOnlyChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const SyncModal: React.FC<SyncModalProps> = ({
|
||||
open,
|
||||
syncing,
|
||||
syncStatus,
|
||||
syncResult,
|
||||
syncDateFrom,
|
||||
syncDays,
|
||||
attendanceMachineOnly,
|
||||
onClose,
|
||||
onSync,
|
||||
onDateChange,
|
||||
onDaysChange,
|
||||
onMachineOnlyChange,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title="同步排课到钉钉考勤排班"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={
|
||||
syncResult
|
||||
? [
|
||||
<Button key="close" onClick={onClose}>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="sync"
|
||||
type="primary"
|
||||
icon={<CloudSyncOutlined />}
|
||||
loading={syncing}
|
||||
onClick={onSync}
|
||||
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"
|
||||
title={`${syncResult.skippedNoMapping} 条排课因班级无钉钉绑定学生而跳过`}
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
{syncResult.failedBatchCount > 0 && (
|
||||
<>
|
||||
<Alert
|
||||
type="error"
|
||||
title={`${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"
|
||||
title={`${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 && onDateChange(d)}
|
||||
allowClear={false}
|
||||
/>
|
||||
<span>天数:</span>
|
||||
<Select
|
||||
value={syncDays}
|
||||
onChange={onDaysChange}
|
||||
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={onMachineOnlyChange} />
|
||||
<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 description="查询同步状态..." />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user