feat: add Class detail page with student roster and teacher tabs

This commit is contained in:
2026-07-05 19:16:02 +08:00
parent 9a3b92bf8e
commit 20a4dfad84

View File

@@ -1,9 +1,505 @@
import React from 'react';
import { Card } from 'antd';
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,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ArrowLeftOutlined, PlusOutlined } 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 ClassDetail {
id: number;
name: string;
code: string;
departmentId: number | null;
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: '学服老师',
};
// ---- Component ----
// Placeholder — Task 1.5 will implement the full detail page.
const ClassDetailPage: React.FC = () => {
return <Card> </Card>;
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>();
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 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('/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>
),
},
];
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 }}
>
</Button>
<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>
),
},
]}
/>
</Card>
);
};
export default ClassDetailPage;