feat: add Classes list page with CRUD modal
This commit is contained in:
@@ -15,6 +15,8 @@ import OperationLogsPage from './pages/OperationLogs';
|
|||||||
import UsersPage from './pages/Users';
|
import UsersPage from './pages/Users';
|
||||||
import DepositsPage from './pages/Deposits';
|
import DepositsPage from './pages/Deposits';
|
||||||
import ClassroomsPage from './pages/Classrooms';
|
import ClassroomsPage from './pages/Classrooms';
|
||||||
|
import ClassesPage from './pages/Classes';
|
||||||
|
import ClassDetailPage from './pages/Classes/detail';
|
||||||
import TenantsPage from './pages/Tenants';
|
import TenantsPage from './pages/Tenants';
|
||||||
import ClassroomRentalsPage from './pages/ClassroomRentals';
|
import ClassroomRentalsPage from './pages/ClassroomRentals';
|
||||||
import ClassroomSchedulePage from './pages/ClassroomSchedule';
|
import ClassroomSchedulePage from './pages/ClassroomSchedule';
|
||||||
@@ -118,6 +120,22 @@ const App: React.FC = () => {
|
|||||||
</PermissionRoute>
|
</PermissionRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="classes"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="class:view">
|
||||||
|
<ClassesPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="classes/:id"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="class:view">
|
||||||
|
<ClassDetailPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operation-logs"
|
path="operation-logs"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const allMenuItems: MenuItemType[] = [
|
|||||||
{ key: '/expenses', icon: <DollarOutlined />, label: '费用录入', permission: 'expense:view' },
|
{ key: '/expenses', icon: <DollarOutlined />, label: '费用录入', permission: 'expense:view' },
|
||||||
{ key: '/deposits', icon: <WalletOutlined />, label: '押金管理', permission: 'deposit:view' },
|
{ key: '/deposits', icon: <WalletOutlined />, label: '押金管理', permission: 'deposit:view' },
|
||||||
{ key: '/bills', icon: <FileTextOutlined />, label: '账单管理', permission: 'bill:view' },
|
{ key: '/bills', icon: <FileTextOutlined />, label: '账单管理', permission: 'bill:view' },
|
||||||
|
{ key: '/classes', icon: <TeamOutlined />, label: '班级管理', permission: 'class:view' },
|
||||||
{
|
{
|
||||||
key: 'classroom-group',
|
key: 'classroom-group',
|
||||||
icon: <ReadOutlined />,
|
icon: <ReadOutlined />,
|
||||||
|
|||||||
9
apps/admin/src/pages/Classes/detail.tsx
Normal file
9
apps/admin/src/pages/Classes/detail.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card } from 'antd';
|
||||||
|
|
||||||
|
// Placeholder — Task 1.5 will implement the full detail page.
|
||||||
|
const ClassDetailPage: React.FC = () => {
|
||||||
|
return <Card>班级详情 — 待实现</Card>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClassDetailPage;
|
||||||
267
apps/admin/src/pages/Classes/index.tsx
Normal file
267
apps/admin/src/pages/Classes/index.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber,
|
||||||
|
DatePicker, Popconfirm, message, Card,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import api from '../../api';
|
||||||
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
|
||||||
|
// ---- Types ----
|
||||||
|
|
||||||
|
interface ClassItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
departmentId: number | null;
|
||||||
|
classType: string;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
status: string;
|
||||||
|
headTeacherId: number | null;
|
||||||
|
lifeTeacherId: number | null;
|
||||||
|
academicTeacherId: number | null;
|
||||||
|
maxStudents: number;
|
||||||
|
notes: string | null;
|
||||||
|
studentCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassFormValues {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
classType: string;
|
||||||
|
startDate?: dayjs.Dayjs;
|
||||||
|
endDate?: dayjs.Dayjs;
|
||||||
|
maxStudents?: number;
|
||||||
|
status?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassQueryParams {
|
||||||
|
status?: string;
|
||||||
|
classType?: 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: '冲刺营',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Component ----
|
||||||
|
|
||||||
|
const ClassesPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<ClassItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<ClassItem | null>(null);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>();
|
||||||
|
const [filterType, setFilterType] = useState<string>();
|
||||||
|
const [form] = Form.useForm<ClassFormValues>();
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: ClassQueryParams = {};
|
||||||
|
if (filterStatus) params.status = filterStatus;
|
||||||
|
if (filterType) params.classType = filterType;
|
||||||
|
const res = await api.get('/classes', { params });
|
||||||
|
setData(res as ClassItem[]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterStatus, filterType]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!searchText) return data;
|
||||||
|
const q = searchText.toLowerCase();
|
||||||
|
return data.filter(
|
||||||
|
(c) => c.name?.toLowerCase().includes(q) || c.code?.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [data, searchText]);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (record: ClassItem) => {
|
||||||
|
setEditing(record);
|
||||||
|
form.setFieldsValue({
|
||||||
|
...record,
|
||||||
|
startDate: record.startDate ? dayjs(record.startDate) : undefined,
|
||||||
|
endDate: record.endDate ? dayjs(record.endDate) : undefined,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const payload = {
|
||||||
|
...values,
|
||||||
|
startDate: values.startDate?.format('YYYY-MM-DD'),
|
||||||
|
endDate: values.endDate?.format('YYYY-MM-DD'),
|
||||||
|
};
|
||||||
|
if (editing) {
|
||||||
|
await api.put(`/classes/${editing.id}`, payload);
|
||||||
|
message.success('更新成功');
|
||||||
|
} else {
|
||||||
|
await api.post('/classes', payload);
|
||||||
|
message.success('创建成功');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
await api.delete(`/classes/${id}`);
|
||||||
|
message.success('已删除');
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<ClassItem> = [
|
||||||
|
{
|
||||||
|
title: '班级名称', dataIndex: 'name',
|
||||||
|
sorter: (a, b) => a.name.localeCompare(b.name),
|
||||||
|
},
|
||||||
|
{ title: '编码', dataIndex: 'code', width: 140 },
|
||||||
|
{
|
||||||
|
title: '班型', dataIndex: 'classType', width: 100,
|
||||||
|
render: (v: string) => <Tag>{TYPE_MAP[v] || v}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '开班日期', dataIndex: 'startDate', width: 110,
|
||||||
|
render: (v: string | null) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '学员', width: 100,
|
||||||
|
render: (_: unknown, r: ClassItem) => `${r.studentCount || 0}/${r.maxStudents || '-'}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态', dataIndex: 'status', width: 90,
|
||||||
|
render: (v: string) => {
|
||||||
|
const cfg = STATUS_MAP[v] || { color: 'default', text: v };
|
||||||
|
return <Tag color={cfg.color}>{cfg.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', width: 200,
|
||||||
|
render: (_: unknown, r: ClassItem) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<TeamOutlined />} onClick={() => navigate(`/classes/${r.id}`)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
<PermissionButton permission="class:edit" size="small" onClick={() => handleEdit(r)}>
|
||||||
|
编辑
|
||||||
|
</PermissionButton>
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
|
||||||
|
<PermissionButton permission="class:delete" size="small" danger>
|
||||||
|
删除
|
||||||
|
</PermissionButton>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索名称/编码"
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="班型"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
value={filterType}
|
||||||
|
onChange={setFilterType}
|
||||||
|
options={Object.entries(TYPE_MAP).map(([k, v]) => ({ value: k, label: v }))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="状态"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={setFilterStatus}
|
||||||
|
options={Object.entries(STATUS_MAP).map(([k, v]) => ({ value: k, label: v.text }))}
|
||||||
|
/>
|
||||||
|
<PermissionButton permission="class:create" type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||||
|
创建班级
|
||||||
|
</PermissionButton>
|
||||||
|
</Space>
|
||||||
|
<Table<ClassItem>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filtered}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑班级' : '创建班级'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="班级名称" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="code" label="班级编码" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="classType" label="班型" rules={[{ required: true }]}>
|
||||||
|
<Select options={Object.entries(TYPE_MAP).map(([k, v]) => ({ value: k, label: v }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<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>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name="status" label="状态" initialValue="enrolling">
|
||||||
|
<Select options={Object.entries(STATUS_MAP).map(([k, v]) => ({ value: k, label: v.text }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="notes" label="备注">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClassesPage;
|
||||||
Reference in New Issue
Block a user