feat(task1): restructure directories for turborepo monorepo
- Move backend/ to apps/server/ via git mv - Move frontend/ to apps/admin/ via git mv - Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
259
apps/admin/src/pages/Students/index.tsx
Normal file
259
apps/admin/src/pages/Students/index.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, Space, message, Tag, Popconfirm, Upload } from 'antd';
|
||||
import { PlusOutlined, UploadOutlined, DownloadOutlined, UndoOutlined, InboxOutlined, ExportOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
active: { text: '在读', color: 'green' },
|
||||
graduated: { text: '已毕业', color: 'blue' },
|
||||
withdrawn: { text: '已退训', color: 'red' },
|
||||
archived: { text: '已归档', color: '#999' },
|
||||
};
|
||||
|
||||
const StudentsPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [searchName, setSearchName] = useState('');
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archivedCount, setArchivedCount] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
try {
|
||||
const res: any = await api.post('/students/batch-delete', { ids: selectedRowKeys });
|
||||
message.success(res?.message || `已批量归档 ${selectedRowKeys.length} 人`);
|
||||
setSelectedRowKeys([]);
|
||||
fetchData();
|
||||
} catch (e: any) { message.error(e?.message || '批量归档失败'); }
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { name: searchName || undefined, includeArchived: 'true' };
|
||||
const res: any = await api.get('/students', { params });
|
||||
const archived = res.filter((r: any) => r.status === 'archived');
|
||||
setArchivedCount(archived.length);
|
||||
const filtered = showArchived ? res : res.filter((r: any) => r.status !== 'archived');
|
||||
setData(filtered);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { fetchData(); }, [searchName, showArchived]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editing) {
|
||||
await api.put(`/students/${editing.id}`, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await api.post('/students', values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
setEditing(null);
|
||||
fetchData();
|
||||
} catch (e: any) { message.error(e?.message || '操作失败'); }
|
||||
};
|
||||
|
||||
const handleArchive = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/students/${id}`);
|
||||
message.success('已归档');
|
||||
fetchData();
|
||||
} catch (e: any) { message.error(e?.message || '归档失败'); }
|
||||
};
|
||||
|
||||
const handleRestore = async (id: number) => {
|
||||
try {
|
||||
await api.put(`/students/${id}/restore`);
|
||||
message.success('已恢复');
|
||||
fetchData();
|
||||
} catch (e: any) { message.error(e?.message || '恢复失败'); }
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
const token = localStorage.getItem('token');
|
||||
fetch(`${baseURL}/students/template`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(res => res.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '学生导入模板.xlsx';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(() => message.error('下载失败'));
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
const token = localStorage.getItem('token');
|
||||
const params = showArchived ? '?includeArchived=true' : '';
|
||||
fetch(`${baseURL}/students/export${params}`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(res => res.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '学生名单.xlsx';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(() => message.error('导出失败'));
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '性别', dataIndex: 'gender', width: 60 },
|
||||
{ title: '电话', dataIndex: 'phone' },
|
||||
{ title: '学号/身份证', dataIndex: 'idNumber' },
|
||||
{ title: '民族', dataIndex: 'ethnicity', width: 80 },
|
||||
{ title: '紧急联系人', dataIndex: 'emergencyContact' },
|
||||
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone' },
|
||||
{ title: '所属机构', dataIndex: 'organization', render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
|
||||
{ title: '负责人', dataIndex: 'supervisor' },
|
||||
{
|
||||
title: '状态', dataIndex: 'status',
|
||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作', width: 180,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
{record.status === 'archived' ? (
|
||||
<PermissionButton permission="student:edit">
|
||||
<Popconfirm title="确定恢复此学生?恢复后将重新出现在学生列表中。" onConfirm={() => handleRestore(record.id)} okText="恢复" cancelText="取消">
|
||||
<Button size="small" icon={<UndoOutlined />} type="link">恢复</Button>
|
||||
</Popconfirm>
|
||||
</PermissionButton>
|
||||
) : (
|
||||
<>
|
||||
<PermissionButton permission="student:edit" size="small" onClick={() => { setEditing(record); form.setFieldsValue(record); setModalOpen(true); }}>编辑</PermissionButton>
|
||||
<PermissionButton permission="student:delete">
|
||||
<Popconfirm title="归档后不会删除数据,可随时恢复。确定归档?" onConfirm={() => handleArchive(record.id)} okText="归档" cancelText="取消">
|
||||
<Button size="small" icon={<InboxOutlined />}>归档</Button>
|
||||
</Popconfirm>
|
||||
</PermissionButton>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Input.Search placeholder="搜索学生姓名" onSearch={setSearchName} allowClear style={{ width: 250 }} />
|
||||
<Button type={showArchived ? 'primary' : 'default'} onClick={() => setShowArchived(!showArchived)}>
|
||||
{showArchived ? '隐藏已归档' : `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
|
||||
</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<PermissionButton permission="student:delete">
|
||||
<Popconfirm title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`} onConfirm={handleBatchDelete} okText="归档" cancelText="取消" disabled={selectedRowKeys.length === 0}>
|
||||
<Button danger icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0}>批量归档</Button>
|
||||
</Popconfirm>
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="student:create" type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); form.resetFields(); setModalOpen(true); }}>
|
||||
添加学生
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="student:import">
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
showUploadList={false}
|
||||
customRequest={async ({ file, onSuccess, onError }: any) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const res: any = await api.post('/students/import', formData, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
message.success(res.message);
|
||||
onSuccess?.(res);
|
||||
fetchData();
|
||||
} catch (e: any) { message.error(e?.message || '导入失败'); onError?.(e); }
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>导入Excel</Button>
|
||||
</Upload>
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="student:view" icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>下载模板</PermissionButton>
|
||||
<PermissionButton permission="student:export" icon={<ExportOutlined />} onClick={handleExport}>导出名单</PermissionButton>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 人` }}
|
||||
rowClassName={(record: any) => record.status === 'archived' ? 'archived-row' : ''}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||||
getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }),
|
||||
}}
|
||||
/>
|
||||
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
||||
<Modal
|
||||
title={editing ? '编辑学生' : '添加学生'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModalOpen(false); setEditing(null); }}
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="gender" label="性别">
|
||||
<Select allowClear options={[{ value: '男', label: '男' }, { value: '女', label: '女' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="idNumber" label="学号/身份证">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="ethnicity" label="民族">
|
||||
<Input placeholder="如:汉族" />
|
||||
</Form.Item>
|
||||
<Form.Item name="emergencyContact" label="紧急联系人">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="emergencyPhone" label="紧急联系人电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="organization" label="所属机构" tooltip="外部合作公司/机构名称,留空表示本机构">
|
||||
<Input placeholder="如:XXX教育科技公司" />
|
||||
</Form.Item>
|
||||
<Form.Item name="supervisor" label="负责人/班主任">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{editing && (
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[
|
||||
{ value: 'active', label: '在读' },
|
||||
{ value: 'graduated', label: '已毕业' },
|
||||
{ value: 'withdrawn', label: '已退训' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudentsPage;
|
||||
Reference in New Issue
Block a user