forked from wangziqi/gongxue-base
687 lines
24 KiB
TypeScript
687 lines
24 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import {
|
|
Card, Tabs, Descriptions, Table, Button, Space, Select, Modal, Tag,
|
|
Popconfirm, Form, Input, DatePicker, InputNumber, Row, Col, Statistic,
|
|
} from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import { ArrowLeftOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons';
|
|
import dayjs from 'dayjs';
|
|
import api from '../../api';
|
|
import PermissionButton from '../../components/PermissionButton';
|
|
import { message } from '../../ui/app-message';
|
|
|
|
// ---- Types ----
|
|
|
|
interface ClassStudent {
|
|
id: number;
|
|
studentId: number;
|
|
studentName: string;
|
|
studentNo: string;
|
|
joinDate: string;
|
|
leaveDate: string | null;
|
|
status: string;
|
|
}
|
|
|
|
interface ClassTeacher {
|
|
id: number;
|
|
userId: number;
|
|
username: string;
|
|
roleType: string;
|
|
subject: string | null;
|
|
}
|
|
|
|
interface ClassScheduleItem {
|
|
id: number;
|
|
classId: number;
|
|
classroomId: number;
|
|
classroomName: string;
|
|
weekDay: number;
|
|
startTime: string;
|
|
endTime: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
subject: string;
|
|
teacherId: number | null;
|
|
scheduleType: string;
|
|
status: string;
|
|
notes: string | null;
|
|
}
|
|
|
|
interface AttendanceSummary {
|
|
total: number;
|
|
present: number;
|
|
late: number;
|
|
absent: number;
|
|
leave: number;
|
|
presentRate: number;
|
|
absentRate: number;
|
|
lateRate: number;
|
|
leaveRate: number;
|
|
}
|
|
|
|
interface ClassDetail {
|
|
id: number;
|
|
name: string;
|
|
code: string;
|
|
classType: string;
|
|
startDate: string | null;
|
|
endDate: string | null;
|
|
status: string;
|
|
maxStudents: number;
|
|
notes: string | null;
|
|
studentCount: number;
|
|
students?: ClassStudent[];
|
|
teachers?: ClassTeacher[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface StudentItem {
|
|
id: number;
|
|
name: string;
|
|
studentNo?: string;
|
|
}
|
|
|
|
interface UserItem {
|
|
id: number;
|
|
username: string;
|
|
}
|
|
|
|
// ---- Constants ----
|
|
|
|
const STATUS_MAP: Record<string, { color: string; text: string }> = {
|
|
enrolling: { color: 'blue', text: '招生中' },
|
|
active: { color: 'green', text: '在读' },
|
|
ended: { color: 'default', text: '结课' },
|
|
suspended: { color: 'orange', text: '停课' },
|
|
};
|
|
|
|
const TYPE_MAP: Record<string, string> = {
|
|
culture: '文化课',
|
|
professional: '专业课',
|
|
bootcamp: '集训营',
|
|
sprint: '冲刺营',
|
|
};
|
|
|
|
const ROLE_MAP: Record<string, string> = {
|
|
subject_teacher: '任课老师',
|
|
head_teacher: '班主任',
|
|
life_teacher: '生活老师',
|
|
academic_teacher: '学服老师',
|
|
};
|
|
|
|
const WEEK_DAY_MAP: Record<number, string> = {
|
|
1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日',
|
|
};
|
|
|
|
const SCHEDULE_TYPE_MAP: Record<string, string> = {
|
|
INTERNAL: '内部排课',
|
|
RENTAL: '租赁',
|
|
};
|
|
|
|
// ---- Component ----
|
|
|
|
const ClassDetailPage: React.FC = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [detail, setDetail] = useState<ClassDetail | null>(null);
|
|
const [students, setStudents] = useState<ClassStudent[]>([]);
|
|
const [teachers, setTeachers] = useState<ClassTeacher[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [editForm] = Form.useForm();
|
|
const [editingInfo, setEditingInfo] = useState(false);
|
|
|
|
// Student modal state
|
|
const [studentModalOpen, setStudentModalOpen] = useState(false);
|
|
const [allStudents, setAllStudents] = useState<StudentItem[]>([]);
|
|
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
|
|
|
// Teacher modal state
|
|
const [teacherModalOpen, setTeacherModalOpen] = useState(false);
|
|
const [allUsers, setAllUsers] = useState<UserItem[]>([]);
|
|
const [teacherRole, setTeacherRole] = useState('subject_teacher');
|
|
const [teacherSubject, setTeacherSubject] = useState('');
|
|
const [teacherUserId, setTeacherUserId] = useState<number>();
|
|
|
|
// Schedule & attendance state
|
|
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
|
|
const [scheduleDateRange, setScheduleDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
|
const [attendanceSummary, setAttendanceSummary] = useState<AttendanceSummary | null>(null);
|
|
const [attendanceDateRange, setAttendanceDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
|
|
|
const fetchDetail = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await api.get(`/classes/${id}`) as ClassDetail;
|
|
setDetail(res);
|
|
setStudents(res.students || []);
|
|
setTeachers(res.teachers || []);
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '加载失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [id]);
|
|
|
|
useEffect(() => { fetchDetail(); }, [fetchDetail]);
|
|
|
|
const fetchSchedules = useCallback(async () => {
|
|
if (!id) return;
|
|
try {
|
|
const params: Record<string, string> = {};
|
|
if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD');
|
|
if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD');
|
|
const res = await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params });
|
|
setSchedules(res || []);
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '加载课表失败');
|
|
}
|
|
}, [id, scheduleDateRange]);
|
|
|
|
useEffect(() => { fetchSchedules(); }, [fetchSchedules]);
|
|
|
|
const fetchAttendanceSummary = useCallback(async () => {
|
|
if (!id) return;
|
|
try {
|
|
const params: Record<string, string> = {};
|
|
if (attendanceDateRange?.[0]) params.startDate = attendanceDateRange[0].format('YYYY-MM-DD');
|
|
if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD');
|
|
const res = await api.get<AttendanceSummary>(`/classes/${id}/attendance-summary`, { params });
|
|
setAttendanceSummary(res || null);
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '加载出勤汇总失败');
|
|
}
|
|
}, [id, attendanceDateRange]);
|
|
|
|
useEffect(() => { fetchAttendanceSummary(); }, [fetchAttendanceSummary]);
|
|
|
|
const handleSaveInfo = async () => {
|
|
try {
|
|
const values = await editForm.validateFields();
|
|
await api.put(`/classes/${id}`, {
|
|
name: values.name,
|
|
code: values.code,
|
|
classType: values.classType,
|
|
startDate: values.startDate?.format('YYYY-MM-DD'),
|
|
endDate: values.endDate?.format('YYYY-MM-DD'),
|
|
maxStudents: values.maxStudents,
|
|
status: values.status,
|
|
notes: values.notes,
|
|
});
|
|
setEditingInfo(false);
|
|
fetchDetail();
|
|
message.success('已更新');
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '更新失败');
|
|
}
|
|
};
|
|
|
|
const handleRemoveStudent = async (studentId: number) => {
|
|
try {
|
|
await api.delete(`/classes/${id}/students/${studentId}`);
|
|
fetchDetail();
|
|
message.success('已移除');
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '移除失败');
|
|
}
|
|
};
|
|
|
|
const handleAddStudents = async () => {
|
|
if (!selectedStudentIds.length) return;
|
|
try {
|
|
await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds });
|
|
setStudentModalOpen(false);
|
|
setSelectedStudentIds([]);
|
|
fetchDetail();
|
|
message.success('已添加');
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '添加失败');
|
|
}
|
|
};
|
|
|
|
const handleAddTeacher = async () => {
|
|
if (!teacherUserId) return;
|
|
try {
|
|
await api.post(`/classes/${id}/teachers`, {
|
|
userId: teacherUserId,
|
|
roleType: teacherRole,
|
|
subject: teacherSubject || undefined,
|
|
});
|
|
setTeacherModalOpen(false);
|
|
fetchDetail();
|
|
message.success('已添加');
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '添加失败');
|
|
}
|
|
};
|
|
|
|
const handleRemoveTeacher = async (userId: number) => {
|
|
try {
|
|
await api.delete(`/classes/${id}/teachers/${userId}`);
|
|
fetchDetail();
|
|
message.success('已移除');
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '移除失败');
|
|
}
|
|
};
|
|
|
|
const openStudentModal = async () => {
|
|
try {
|
|
const res = await api.get('/students', { params: { includeArchived: 'false' } }) as StudentItem[];
|
|
setAllStudents(res || []);
|
|
setSelectedStudentIds([]);
|
|
setStudentModalOpen(true);
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '加载学员列表失败');
|
|
}
|
|
};
|
|
|
|
const openTeacherModal = async () => {
|
|
try {
|
|
const res = await api.get('/rbac/users') as UserItem[];
|
|
setAllUsers(res || []);
|
|
setTeacherUserId(undefined);
|
|
setTeacherRole('subject_teacher');
|
|
setTeacherSubject('');
|
|
setTeacherModalOpen(true);
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '加载用户列表失败');
|
|
}
|
|
};
|
|
|
|
if (!detail) return null;
|
|
|
|
const studentColumns: ColumnsType<ClassStudent> = [
|
|
{ title: '姓名', dataIndex: 'studentName' },
|
|
{ title: '学号', dataIndex: 'studentNo' },
|
|
{ title: '加入日期', dataIndex: 'joinDate' },
|
|
{ title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' },
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
render: (v: string) => (
|
|
<Tag color={v === 'active' ? 'green' : 'default'}>
|
|
{v === 'active' ? '在读' : '已离班'}
|
|
</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: '操作',
|
|
render: (_: unknown, r: ClassStudent) =>
|
|
r.status === 'active' ? (
|
|
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
|
|
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
|
</Popconfirm>
|
|
) : null,
|
|
},
|
|
];
|
|
|
|
const teacherColumns: ColumnsType<ClassTeacher> = [
|
|
{ title: '姓名', dataIndex: 'username' },
|
|
{
|
|
title: '角色',
|
|
dataIndex: 'roleType',
|
|
render: (v: string) => <Tag>{ROLE_MAP[v] || v}</Tag>,
|
|
},
|
|
{
|
|
title: '科目',
|
|
dataIndex: 'subject',
|
|
render: (v: string | null) => v || '-',
|
|
},
|
|
{
|
|
title: '操作',
|
|
render: (_: unknown, r: ClassTeacher) => (
|
|
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveTeacher(r.userId)}>
|
|
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
|
</Popconfirm>
|
|
),
|
|
},
|
|
];
|
|
|
|
const scheduleColumns: ColumnsType<ClassScheduleItem> = [
|
|
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
|
|
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
|
|
{ title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` },
|
|
{ title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` },
|
|
{ title: '科目', dataIndex: 'subject' },
|
|
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '启用' : v}</Tag>,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Card
|
|
title={
|
|
<Space>
|
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/classes')} />
|
|
<span>{detail.name} ({detail.code})</span>
|
|
<Tag color={STATUS_MAP[detail.status]?.color}>{STATUS_MAP[detail.status]?.text}</Tag>
|
|
</Space>
|
|
}
|
|
loading={loading}
|
|
>
|
|
<Tabs
|
|
defaultActiveKey="info"
|
|
items={[
|
|
{
|
|
key: 'info',
|
|
label: '基本信息',
|
|
children: (
|
|
<div>
|
|
{editingInfo ? (
|
|
<Form
|
|
form={editForm}
|
|
layout="vertical"
|
|
initialValues={{
|
|
name: detail.name,
|
|
code: detail.code,
|
|
classType: detail.classType,
|
|
startDate: detail.startDate ? dayjs(detail.startDate) : undefined,
|
|
endDate: detail.endDate ? dayjs(detail.endDate) : undefined,
|
|
maxStudents: detail.maxStudents,
|
|
status: detail.status,
|
|
notes: detail.notes,
|
|
}}
|
|
>
|
|
<Space wrap>
|
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="code" label="编码">
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="classType" label="班型">
|
|
<Select
|
|
options={Object.entries(TYPE_MAP).map(([k, v]) => ({
|
|
value: k,
|
|
label: v,
|
|
}))}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="startDate" label="开班">
|
|
<DatePicker />
|
|
</Form.Item>
|
|
<Form.Item name="endDate" label="结课">
|
|
<DatePicker />
|
|
</Form.Item>
|
|
<Form.Item name="maxStudents" label="人数上限">
|
|
<InputNumber min={1} />
|
|
</Form.Item>
|
|
<Form.Item name="status" label="状态">
|
|
<Select
|
|
options={Object.entries(STATUS_MAP).map(([k, v]) => ({
|
|
value: k,
|
|
label: v.text,
|
|
}))}
|
|
/>
|
|
</Form.Item>
|
|
</Space>
|
|
<Form.Item name="notes" label="备注">
|
|
<Input.TextArea rows={3} />
|
|
</Form.Item>
|
|
<Space>
|
|
<PermissionButton permission="class:edit" type="primary" onClick={handleSaveInfo}>
|
|
保存
|
|
</PermissionButton>
|
|
<Button onClick={() => setEditingInfo(false)}>取消</Button>
|
|
</Space>
|
|
</Form>
|
|
) : (
|
|
<div>
|
|
<Descriptions column={3} bordered size="small">
|
|
<Descriptions.Item label="班型">
|
|
{TYPE_MAP[detail.classType]}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="开班日期">
|
|
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="结课日期">
|
|
{detail.endDate ? dayjs(detail.endDate).format('YYYY-MM-DD') : '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="学员">
|
|
{detail.studentCount}/{detail.maxStudents || '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="班主任">
|
|
{teachers.find((t) => t.roleType === 'head_teacher')?.username || '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="备注">
|
|
{detail.notes || '-'}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
<PermissionButton
|
|
permission="class:edit"
|
|
style={{ marginTop: 16 }}
|
|
onClick={() => {
|
|
editForm.setFieldsValue({
|
|
name: detail.name,
|
|
code: detail.code,
|
|
classType: detail.classType,
|
|
startDate: detail.startDate ? dayjs(detail.startDate) : undefined,
|
|
endDate: detail.endDate ? dayjs(detail.endDate) : undefined,
|
|
maxStudents: detail.maxStudents,
|
|
status: detail.status,
|
|
notes: detail.notes,
|
|
});
|
|
setEditingInfo(true);
|
|
}}
|
|
>
|
|
编辑
|
|
</PermissionButton>
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'students',
|
|
label: `花名册 (${students.filter((s) => s.status === 'active').length})`,
|
|
children: (
|
|
<div>
|
|
<PermissionButton
|
|
permission="class:edit"
|
|
icon={<PlusOutlined />}
|
|
type="primary"
|
|
onClick={openStudentModal}
|
|
style={{ marginBottom: 16, marginRight: 8 }}
|
|
>
|
|
添加学员
|
|
</PermissionButton>
|
|
<PermissionButton
|
|
permission="class:view"
|
|
icon={<DownloadOutlined />}
|
|
onClick={() => {
|
|
const token = localStorage.getItem('token');
|
|
fetch(`/api/classes/${id}/roster/export`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
})
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error('导出失败');
|
|
return res.blob();
|
|
})
|
|
.then((blob) => {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `班级花名册-${detail?.name || id}.xlsx`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
message.success('花名册导出成功');
|
|
})
|
|
.catch(() => message.error('花名册导出失败'));
|
|
}}
|
|
>
|
|
导出花名册
|
|
</PermissionButton>
|
|
<Table<ClassStudent>
|
|
columns={studentColumns}
|
|
dataSource={students}
|
|
rowKey="id"
|
|
pagination={{ pageSize: 20 }}
|
|
/>
|
|
<Modal
|
|
title="添加学员"
|
|
open={studentModalOpen}
|
|
onOk={handleAddStudents}
|
|
onCancel={() => setStudentModalOpen(false)}
|
|
>
|
|
<Select
|
|
mode="multiple"
|
|
style={{ width: '100%' }}
|
|
placeholder="选择学员"
|
|
value={selectedStudentIds}
|
|
onChange={setSelectedStudentIds}
|
|
options={allStudents.map((s) => ({
|
|
value: s.id,
|
|
label: `${s.name} (${s.studentNo || s.id})`,
|
|
}))}
|
|
filterOption={(input, option) =>
|
|
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
|
}
|
|
/>
|
|
</Modal>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'teachers',
|
|
label: `教师 (${teachers.length})`,
|
|
children: (
|
|
<div>
|
|
<PermissionButton
|
|
permission="class:edit"
|
|
icon={<PlusOutlined />}
|
|
type="primary"
|
|
onClick={openTeacherModal}
|
|
style={{ marginBottom: 16 }}
|
|
>
|
|
添加教师
|
|
</PermissionButton>
|
|
<Table<ClassTeacher>
|
|
columns={teacherColumns}
|
|
dataSource={teachers}
|
|
rowKey="id"
|
|
pagination={{ pageSize: 20 }}
|
|
/>
|
|
<Modal
|
|
title="添加教师"
|
|
open={teacherModalOpen}
|
|
onOk={handleAddTeacher}
|
|
onCancel={() => setTeacherModalOpen(false)}
|
|
>
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
<Select
|
|
style={{ width: '100%' }}
|
|
placeholder="选择教师"
|
|
value={teacherUserId}
|
|
onChange={setTeacherUserId}
|
|
options={allUsers.map((u) => ({
|
|
value: u.id,
|
|
label: u.username,
|
|
}))}
|
|
filterOption={(input, option) =>
|
|
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
|
}
|
|
/>
|
|
<Select
|
|
style={{ width: '100%' }}
|
|
value={teacherRole}
|
|
onChange={setTeacherRole}
|
|
options={Object.entries(ROLE_MAP).map(([k, v]) => ({
|
|
value: k,
|
|
label: v,
|
|
}))}
|
|
/>
|
|
{teacherRole === 'subject_teacher' && (
|
|
<Input
|
|
placeholder="任教科目"
|
|
value={teacherSubject}
|
|
onChange={(e) => setTeacherSubject(e.target.value)}
|
|
/>
|
|
)}
|
|
</Space>
|
|
</Modal>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'schedule',
|
|
label: '课表',
|
|
children: (
|
|
<div>
|
|
<Space style={{ marginBottom: 16 }}>
|
|
<DatePicker.RangePicker
|
|
value={scheduleDateRange}
|
|
onChange={(dates) => setScheduleDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])}
|
|
placeholder={['开始日期', '结束日期']}
|
|
/>
|
|
</Space>
|
|
<Table<ClassScheduleItem>
|
|
columns={scheduleColumns}
|
|
dataSource={schedules}
|
|
rowKey="id"
|
|
pagination={{ pageSize: 20 }}
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'attendance-summary',
|
|
label: '出勤汇总',
|
|
children: (
|
|
<div>
|
|
<Space style={{ marginBottom: 16 }}>
|
|
<DatePicker.RangePicker
|
|
value={attendanceDateRange}
|
|
onChange={(dates) => setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])}
|
|
placeholder={['开始日期', '结束日期']}
|
|
/>
|
|
</Space>
|
|
{attendanceSummary && (
|
|
<Row gutter={16}>
|
|
<Col span={6}>
|
|
<Card bordered={false}>
|
|
<Statistic title="总记录" value={attendanceSummary.total} />
|
|
</Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card bordered={false}>
|
|
<Statistic title="出勤率" value={attendanceSummary.presentRate} suffix="%" />
|
|
</Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card bordered={false}>
|
|
<Statistic title="缺勤率" value={attendanceSummary.absentRate} suffix="%" />
|
|
</Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card bordered={false}>
|
|
<Statistic title="迟到率" value={attendanceSummary.lateRate} suffix="%" />
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default ClassDetailPage;
|