Files
gongxue-base/apps/admin/src/pages/Classes/detail.tsx
wangziqi 50b3608db4 fix(admin): repair class, role, and room views
Use the RBAC user endpoint for teacher selection, restore role table columns, correct Ant Design props, and add typed room bed and locker responses.
2026-07-10 14:12:08 +08:00

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;