Files
gongxue-base/apps/admin/src/pages/Classes/detail.tsx

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;