381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
|
import { useParams, useNavigate } from 'react-router';
|
|
import {Button, Card, Form, Space, Tabs, Tag, Skeleton} from 'antd';
|
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
|
import dayjs from 'dayjs';
|
|
import api from '../../api';
|
|
import { message } from '../../ui/app-message';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import type { TeacherCandidateUser } from './teacher-candidate';
|
|
import { getErrorMessage } from '../../utils/error';
|
|
import { QueryErrorState } from '../../components/QueryState';
|
|
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 [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[]>([]);
|
|
const [addingStudents, setAddingStudents] = useState(false);
|
|
const [addingTeacher, setAddingTeacher] = useState(false);
|
|
|
|
// Teacher modal state
|
|
const [teacherModalOpen, setTeacherModalOpen] = useState(false);
|
|
const [teacherRole, setTeacherRole] = useState('subject_teacher');
|
|
const [teacherSubject, setTeacherSubject] = useState('');
|
|
const [teacherUserId, setTeacherUserId] = useState<number>();
|
|
|
|
// Schedule & attendance state
|
|
const [scheduleDateRange, setScheduleDateRange] = useState<
|
|
[dayjs.Dayjs | null, dayjs.Dayjs | null]
|
|
>([null, null]);
|
|
const [attendanceDateRange, setAttendanceDateRange] = useState<
|
|
[dayjs.Dayjs | null, dayjs.Dayjs | null]
|
|
>([null, null]);
|
|
|
|
const {
|
|
data: detailResult = { detail: null, students: [], teachers: [] },
|
|
isLoading: detailLoading,
|
|
isFetching: detailFetching,
|
|
isError: detailError,
|
|
refetch: refetchDetail,
|
|
} = useQuery<{
|
|
detail: ClassDetail | null;
|
|
students: ClassDetail['students'];
|
|
teachers: ClassDetail['teachers'];
|
|
}>({
|
|
queryKey: ['classes', 'detail', id],
|
|
queryFn: async () => {
|
|
const res = (await api.get(`/classes/${id}`)) as ClassDetail;
|
|
return { detail: res, students: res.students || [], teachers: res.teachers || [] };
|
|
},
|
|
});
|
|
const detail = detailResult.detail;
|
|
const students = detailResult.students ?? [];
|
|
const teachers = detailResult.teachers ?? [];
|
|
const loading = detailLoading || detailFetching;
|
|
const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]);
|
|
|
|
const {
|
|
data: allUsers = [],
|
|
isError: allUsersError,
|
|
refetch: refetchUsers,
|
|
} = useQuery<TeacherCandidateUser[]>({
|
|
queryKey: ['rbac', 'users', 'all'],
|
|
queryFn: async () => {
|
|
return (await api.get('/rbac/users')) as TeacherCandidateUser[];
|
|
},
|
|
});
|
|
const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]);
|
|
|
|
const {
|
|
data: schedules = [],
|
|
isError: schedulesError,
|
|
refetch: refetchSchedules,
|
|
} = useQuery<ClassScheduleItem[]>({
|
|
queryKey: ['classes', 'schedule', id, scheduleDateRange],
|
|
queryFn: async () => {
|
|
if (!id) return [];
|
|
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 })) || [];
|
|
},
|
|
});
|
|
|
|
const {
|
|
data: attendanceSummary = null,
|
|
isError: attendanceSummaryError,
|
|
refetch: refetchAttendanceSummary,
|
|
} = useQuery<AttendanceSummary | null>({
|
|
queryKey: ['classes', 'attendance-summary', id, attendanceDateRange],
|
|
queryFn: async () => {
|
|
if (!id) return null;
|
|
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
|
|
);
|
|
},
|
|
});
|
|
|
|
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) {
|
|
message.error(getErrorMessage(e, '更新失败'));
|
|
}
|
|
};
|
|
|
|
const handleRemoveStudent = async (studentId: number) => {
|
|
try {
|
|
await api.delete(`/classes/${id}/students/${studentId}`);
|
|
fetchDetail();
|
|
message.success('已移除');
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e, '移除失败'));
|
|
}
|
|
};
|
|
|
|
const handleAddStudents = async () => {
|
|
if (!selectedStudentIds.length) return;
|
|
setAddingStudents(true);
|
|
try {
|
|
await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds });
|
|
setStudentModalOpen(false);
|
|
setSelectedStudentIds([]);
|
|
fetchDetail();
|
|
message.success('已添加');
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e, '添加失败'));
|
|
} finally {
|
|
setAddingStudents(false);
|
|
}
|
|
};
|
|
|
|
const handleAddTeacher = async () => {
|
|
if (!teacherUserId) return;
|
|
setAddingTeacher(true);
|
|
try {
|
|
await api.post(`/classes/${id}/teachers`, {
|
|
userId: teacherUserId,
|
|
roleType: teacherRole,
|
|
subject: teacherSubject || undefined,
|
|
});
|
|
setTeacherModalOpen(false);
|
|
fetchDetail();
|
|
message.success('已添加');
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e, '添加失败'));
|
|
} finally {
|
|
setAddingTeacher(false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveTeacher = async (userId: number) => {
|
|
try {
|
|
await api.delete(`/classes/${id}/teachers/${userId}`);
|
|
fetchDetail();
|
|
message.success('已移除');
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e, '移除失败'));
|
|
}
|
|
};
|
|
|
|
const openStudentModal = async () => {
|
|
try {
|
|
const res = (await api.get('/students', {
|
|
params: { includeArchived: 'false' },
|
|
})) as StudentItem[];
|
|
setAllStudents(res || []);
|
|
setSelectedStudentIds([]);
|
|
setStudentModalOpen(true);
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e, '加载学员列表失败'));
|
|
}
|
|
};
|
|
|
|
const openTeacherModal = async () => {
|
|
try {
|
|
await fetchUsers();
|
|
setTeacherUserId(undefined);
|
|
setTeacherRole('subject_teacher');
|
|
setTeacherSubject('');
|
|
setTeacherModalOpen(true);
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e, '加载用户列表失败'));
|
|
}
|
|
};
|
|
|
|
const getTeacherName = (teacher: ClassTeacher) =>
|
|
allUsers.find((user) => user.id === teacher.userId)?.name?.trim() || '-';
|
|
|
|
if (!detail) {
|
|
if (loading) {
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
<Skeleton active paragraph={{ rows: 8 }} />
|
|
</div>
|
|
);
|
|
}
|
|
if (detailError) {
|
|
return (
|
|
<QueryErrorState
|
|
title="班级详情加载失败"
|
|
description="请检查网络后重试。"
|
|
onRetry={() => void refetchDetail()}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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: (
|
|
<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: (
|
|
<ClassStudentsTab
|
|
id={id}
|
|
detail={detail}
|
|
students={students}
|
|
allStudents={allStudents}
|
|
selectedStudentIds={selectedStudentIds}
|
|
modalOpen={studentModalOpen}
|
|
onOpen={openStudentModal}
|
|
onAdd={handleAddStudents}
|
|
onClose={() => setStudentModalOpen(false)}
|
|
onRemove={handleRemoveStudent}
|
|
onSelect={setSelectedStudentIds}
|
|
adding={addingStudents}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'teachers',
|
|
label: `教师 (${teachers.length})`,
|
|
children: allUsersError ? (
|
|
<QueryErrorState
|
|
title="可添加教师加载失败"
|
|
description="请检查网络后重试。"
|
|
onRetry={() => void fetchUsers()}
|
|
/>
|
|
) : (
|
|
<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}
|
|
adding={addingTeacher}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'schedule',
|
|
label: '课表',
|
|
children: schedulesError ? (
|
|
<QueryErrorState
|
|
title="课表加载失败"
|
|
description="请检查网络后重试。"
|
|
onRetry={() => void refetchSchedules()}
|
|
/>
|
|
) : (
|
|
<ClassScheduleTab
|
|
schedules={schedules}
|
|
scheduleDateRange={scheduleDateRange}
|
|
onRangeChange={setScheduleDateRange}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'attendance-summary',
|
|
label: '出勤汇总',
|
|
children: attendanceSummaryError ? (
|
|
<QueryErrorState
|
|
title="出勤汇总加载失败"
|
|
description="请检查网络后重试。"
|
|
onRetry={() => void refetchAttendanceSummary()}
|
|
/>
|
|
) : (
|
|
<ClassAttendanceTab
|
|
attendanceSummary={attendanceSummary}
|
|
attendanceDateRange={attendanceDateRange}
|
|
onRangeChange={setAttendanceDateRange}
|
|
/>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default ClassDetailPage;
|