feat: 重构各业务模块管理页面与服务
This commit is contained in:
@@ -1,155 +1,30 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
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 React, { useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { Button, Card, Form, Space, Tabs, Tag } from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
|
||||
|
||||
// ---- 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;
|
||||
attendanceAdvanceMinutes: number;
|
||||
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;
|
||||
}
|
||||
|
||||
type UserItem = TeacherCandidateUser;
|
||||
|
||||
// ---- 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 ----
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { TeacherCandidateUser } from './teacher-candidate';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
import {
|
||||
ClassAttendanceTab,
|
||||
ClassInfoTab,
|
||||
ClassScheduleTab,
|
||||
ClassStudentsTab,
|
||||
ClassTeachersTab,
|
||||
STATUS_MAP,
|
||||
type ClassDetail,
|
||||
type ClassTeacher,
|
||||
type StudentItem,
|
||||
type AttendanceSummary,
|
||||
type ClassScheduleItem,
|
||||
} from './ClassDetailTabs';
|
||||
|
||||
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);
|
||||
|
||||
@@ -160,85 +35,93 @@ const ClassDetailPage: React.FC = () => {
|
||||
|
||||
// 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]);
|
||||
const {
|
||||
data: detailResult = { detail: null, students: [], teachers: [] },
|
||||
isLoading: detailLoading,
|
||||
isFetching: detailFetching,
|
||||
refetch: refetchDetail,
|
||||
} = useQuery<{
|
||||
detail: ClassDetail | null;
|
||||
students: ClassDetail['students'];
|
||||
teachers: ClassDetail['teachers'];
|
||||
}>({
|
||||
queryKey: ['classes', 'detail', id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const res = (await api.get(`/classes/${id}`)) as ClassDetail;
|
||||
return { detail: res, students: res.students || [], teachers: res.teachers || [] };
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e, '加载失败'));
|
||||
return { detail: null, students: [], teachers: [] };
|
||||
}
|
||||
},
|
||||
});
|
||||
const detail = detailResult.detail;
|
||||
const students = detailResult.students ?? [];
|
||||
const teachers = detailResult.teachers ?? [];
|
||||
const loading = detailLoading || detailFetching;
|
||||
const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const res = (await api.get('/rbac/users')) as UserItem[];
|
||||
setAllUsers(res || []);
|
||||
} catch {
|
||||
setAllUsers([]);
|
||||
}
|
||||
}, []);
|
||||
const { data: allUsers = [], refetch: refetchUsers } = useQuery<TeacherCandidateUser[]>({
|
||||
queryKey: ['rbac', 'users', 'all'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return (await api.get('/rbac/users')) as TeacherCandidateUser[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDetail();
|
||||
fetchUsers();
|
||||
}, [fetchDetail, fetchUsers]);
|
||||
const { data: schedules = [] } = useQuery<ClassScheduleItem[]>({
|
||||
queryKey: ['classes', 'schedule', id, scheduleDateRange],
|
||||
queryFn: 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');
|
||||
return (await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params })) || [];
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e, '加载课表失败'));
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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 { data: attendanceSummary = null } = useQuery<AttendanceSummary | null>({
|
||||
queryKey: ['classes', 'attendance-summary', id, attendanceDateRange],
|
||||
queryFn: async () => {
|
||||
if (!id) return null;
|
||||
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');
|
||||
return (
|
||||
(await api.get<AttendanceSummary>(`/classes/${id}/attendance-summary`, {
|
||||
params,
|
||||
})) || null
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e, '加载出勤汇总失败'));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSaveInfo = async () => {
|
||||
try {
|
||||
@@ -257,8 +140,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
fetchDetail();
|
||||
message.success('已更新');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '更新失败');
|
||||
message.error(getErrorMessage(e, '更新失败'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -268,8 +150,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
fetchDetail();
|
||||
message.success('已移除');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '移除失败');
|
||||
message.error(getErrorMessage(e, '移除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -282,8 +163,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
fetchDetail();
|
||||
message.success('已添加');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '添加失败');
|
||||
message.error(getErrorMessage(e, '添加失败'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -299,8 +179,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
fetchDetail();
|
||||
message.success('已添加');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '添加失败');
|
||||
message.error(getErrorMessage(e, '添加失败'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -310,8 +189,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
fetchDetail();
|
||||
message.success('已移除');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '移除失败');
|
||||
message.error(getErrorMessage(e, '移除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -324,8 +202,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
setSelectedStudentIds([]);
|
||||
setStudentModalOpen(true);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载学员列表失败');
|
||||
message.error(getErrorMessage(e, '加载学员列表失败'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -337,8 +214,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
setTeacherSubject('');
|
||||
setTeacherModalOpen(true);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载用户列表失败');
|
||||
message.error(getErrorMessage(e, '加载用户列表失败'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -347,82 +223,6 @@ const ClassDetailPage: React.FC = () => {
|
||||
|
||||
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: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) },
|
||||
{
|
||||
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.attendanceAdvanceMinutes ?? 30} 分钟至下课`,
|
||||
},
|
||||
{
|
||||
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={
|
||||
@@ -443,325 +243,91 @@ const ClassDetailPage: React.FC = () => {
|
||||
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="班主任">
|
||||
{(() => {
|
||||
const headTeacher = teachers.find(
|
||||
(teacher) => teacher.roleType === 'head_teacher',
|
||||
);
|
||||
return headTeacher ? getTeacherName(headTeacher) : '-';
|
||||
})()}
|
||||
</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>
|
||||
<ClassInfoTab
|
||||
detail={detail}
|
||||
teachers={teachers}
|
||||
editingInfo={editingInfo}
|
||||
editForm={editForm}
|
||||
onSave={handleSaveInfo}
|
||||
onEdit={() => {
|
||||
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);
|
||||
}}
|
||||
onCancel={() => setEditingInfo(false)}
|
||||
getTeacherName={getTeacherName}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 = useUserStore.getState().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={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<ClassStudentsTab
|
||||
id={id}
|
||||
detail={detail}
|
||||
students={students}
|
||||
allStudents={allStudents}
|
||||
selectedStudentIds={selectedStudentIds}
|
||||
modalOpen={studentModalOpen}
|
||||
onOpen={openStudentModal}
|
||||
onAdd={handleAddStudents}
|
||||
onClose={() => setStudentModalOpen(false)}
|
||||
onRemove={handleRemoveStudent}
|
||||
onSelect={setSelectedStudentIds}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加教师"
|
||||
open={teacherModalOpen}
|
||||
onOk={handleAddTeacher}
|
||||
onCancel={() => setTeacherModalOpen(false)}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="搜索姓名、用户名、角色或学科"
|
||||
value={teacherUserId}
|
||||
onChange={setTeacherUserId}
|
||||
options={buildTeacherCandidateOptions(allUsers)}
|
||||
notFoundContent="没有可分配的工作人员账号"
|
||||
/>
|
||||
<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>
|
||||
<ClassTeachersTab
|
||||
teachers={teachers}
|
||||
allUsers={allUsers}
|
||||
teacherRole={teacherRole}
|
||||
teacherSubject={teacherSubject}
|
||||
teacherUserId={teacherUserId}
|
||||
modalOpen={teacherModalOpen}
|
||||
onOpen={openTeacherModal}
|
||||
onAdd={handleAddTeacher}
|
||||
onClose={() => setTeacherModalOpen(false)}
|
||||
onRemove={handleRemoveTeacher}
|
||||
onRoleChange={setTeacherRole}
|
||||
onSubjectChange={setTeacherSubject}
|
||||
onUserChange={setTeacherUserId}
|
||||
getTeacherName={getTeacherName}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'schedule',
|
||||
label: '课表',
|
||||
children: (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, maxWidth: '100%' }} wrap>
|
||||
<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"
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ClassScheduleTab
|
||||
schedules={schedules}
|
||||
scheduleDateRange={scheduleDateRange}
|
||||
onRangeChange={setScheduleDateRange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attendance-summary',
|
||||
label: '出勤汇总',
|
||||
children: (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, maxWidth: '100%' }} wrap>
|
||||
<DatePicker.RangePicker
|
||||
value={attendanceDateRange}
|
||||
onChange={(dates) =>
|
||||
setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])
|
||||
}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Space>
|
||||
{attendanceSummary && (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="总记录" value={attendanceSummary.total} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic
|
||||
title="出勤率"
|
||||
value={attendanceSummary.presentRate}
|
||||
suffix="%"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="缺勤率" value={attendanceSummary.absentRate} suffix="%" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="迟到率" value={attendanceSummary.lateRate} suffix="%" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
<ClassAttendanceTab
|
||||
attendanceSummary={attendanceSummary}
|
||||
attendanceDateRange={attendanceDateRange}
|
||||
onRangeChange={setAttendanceDateRange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
Reference in New Issue
Block a user