Use the RBAC user endpoint for teacher selection, restore role table columns, correct Ant Design props, and add typed room bed and locker responses.
681 lines
24 KiB
TypeScript
681 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, message, 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';
|
|
|
|
// ---- Types ----
|
|
|
|
interface ClassStudent {
|
|
id: number;
|
|
studentId: number;
|
|
studentName: string;
|
|
studentNo: string;
|
|
joinDate: string;
|
|
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: 'status',
|
|
render: (v: string) => (
|
|
<Tag color={v === 'active' ? 'green' : 'default'}>
|
|
{v === 'active' ? '在读' : '已离班'}
|
|
</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: '操作',
|
|
render: (_: unknown, r: ClassStudent) => (
|
|
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
|
|
<Button size="small" danger>移除</Button>
|
|
</Popconfirm>
|
|
),
|
|
},
|
|
];
|
|
|
|
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)}>
|
|
<Button size="small" danger>移除</Button>
|
|
</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>
|
|
<Button type="primary" onClick={handleSaveInfo}>
|
|
保存
|
|
</Button>
|
|
<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 || '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="结课日期">
|
|
{detail.endDate || '-'}
|
|
</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>
|
|
<Button
|
|
icon={<PlusOutlined />}
|
|
type="primary"
|
|
onClick={openStudentModal}
|
|
style={{ marginBottom: 16, marginRight: 8 }}
|
|
>
|
|
添加学员
|
|
</Button>
|
|
<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>
|
|
<Button
|
|
icon={<PlusOutlined />}
|
|
type="primary"
|
|
onClick={openTeacherModal}
|
|
style={{ marginBottom: 16 }}
|
|
>
|
|
添加教师
|
|
</Button>
|
|
<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;
|