forked from wangziqi/gongxue-base
195 lines
8.4 KiB
TypeScript
195 lines
8.4 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
||
import { Table, Button, Modal, Form, Input, InputNumber, Select, Space, message, Tag, Popconfirm, Upload } from 'antd';
|
||
import { PlusOutlined, UploadOutlined, DownloadOutlined, UndoOutlined, InboxOutlined } from '@ant-design/icons';
|
||
import api from '../../api';
|
||
import PermissionButton from '../../components/PermissionButton';
|
||
|
||
const statusMap: Record<string, { text: string; color: string }> = {
|
||
available: { text: '可用', color: 'green' },
|
||
archived: { text: '已归档', color: '#999' },
|
||
};
|
||
|
||
const typeColor: Record<string, string> = {
|
||
大: 'volcano',
|
||
次大: 'geekblue',
|
||
小: 'cyan',
|
||
};
|
||
|
||
const ClassroomsPage: React.FC = () => {
|
||
const [data, setData] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [editing, setEditing] = useState<any>(null);
|
||
const [showArchived, setShowArchived] = useState(false);
|
||
const [form] = Form.useForm();
|
||
const [searchText, setSearchText] = useState('');
|
||
|
||
const filteredData = useMemo(() => {
|
||
if (!searchText) return data;
|
||
const s = searchText.toLowerCase();
|
||
return data.filter((d: any) => d.name?.toLowerCase().includes(s) || d.building?.toLowerCase().includes(s));
|
||
}, [data, searchText]);
|
||
|
||
const fetchData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res: any = await api.get('/classrooms', { params: { includeArchived: showArchived } });
|
||
setData(res);
|
||
} catch (e) { console.error(e); }
|
||
setLoading(false);
|
||
};
|
||
|
||
useEffect(() => { fetchData(); }, [showArchived]);
|
||
|
||
const handleSave = async () => {
|
||
const values = await form.validateFields();
|
||
try {
|
||
if (editing) {
|
||
await api.put(`/classrooms/${editing.id}`, values);
|
||
message.success('更新成功');
|
||
} else {
|
||
await api.post('/classrooms', 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(`/classrooms/${id}`);
|
||
message.success('已归档');
|
||
fetchData();
|
||
} catch (e: any) { message.error(e?.message || '归档失败'); }
|
||
};
|
||
|
||
const handleRestore = async (id: number) => {
|
||
try {
|
||
await api.put(`/classrooms/${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}/classrooms/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 columns = [
|
||
{ title: '教室名', dataIndex: 'name', sorter: (a: any, b: any) => a.name.localeCompare(b.name) },
|
||
{ title: '楼栋', dataIndex: 'building' },
|
||
{ title: '楼层', dataIndex: 'floor' },
|
||
{ title: '类型', dataIndex: 'roomType', render: (v: string) => <Tag color={typeColor[v] || 'default'}>{v || '-'}</Tag> },
|
||
{ title: '容量', dataIndex: 'capacity' },
|
||
{ title: '课程类型', dataIndex: 'courseType', render: (v: string) => v || '-' },
|
||
{ title: '负责人', dataIndex: 'supervisor', render: (v: string) => v || '-' },
|
||
{
|
||
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="classroom:edit">
|
||
<Popconfirm title="确定恢复此教室?" onConfirm={() => handleRestore(record.id)}>
|
||
<Button size="small" icon={<UndoOutlined />} type="link">恢复</Button>
|
||
</Popconfirm>
|
||
</PermissionButton>
|
||
) : (
|
||
<>
|
||
<PermissionButton permission="classroom:edit" size="small" onClick={() => { setEditing(record); form.setFieldsValue(record); setModalOpen(true); }}>编辑</PermissionButton>
|
||
<PermissionButton permission="classroom: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', flexWrap: 'wrap', gap: 8 }}>
|
||
<Space wrap>
|
||
<Input.Search
|
||
placeholder="搜索教室名/楼栋"
|
||
allowClear
|
||
style={{ width: 180 }}
|
||
onSearch={v => setSearchText(v)}
|
||
onChange={e => { if (!e.target.value) setSearchText(''); }}
|
||
/>
|
||
<Button type={showArchived ? 'primary' : 'default'} onClick={() => setShowArchived(!showArchived)}>
|
||
{showArchived ? '隐藏已归档' : '显示已归档'}
|
||
</Button>
|
||
</Space>
|
||
<Space wrap>
|
||
<PermissionButton permission="classroom:create" type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); form.resetFields(); setModalOpen(true); }}>
|
||
添加教室
|
||
</PermissionButton>
|
||
<PermissionButton permission="classroom:create">
|
||
<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('/classrooms/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="classroom:view" icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>下载模板</PermissionButton>
|
||
</Space>
|
||
</div>
|
||
<Table columns={columns} dataSource={filteredData} rowKey="id" loading={loading} pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }} />
|
||
|
||
<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 placeholder="如:A201 / B301" /></Form.Item>
|
||
<Form.Item name="building" label="楼栋"><Input placeholder="如:A座 / B座" /></Form.Item>
|
||
<Form.Item name="floor" label="楼层"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||
<Form.Item name="roomType" label="类型" tooltip="大/次大/小 对应可容纳规模">
|
||
<Select options={[
|
||
{ value: '大', label: '大' },
|
||
{ value: '次大', label: '次大' },
|
||
{ value: '小', label: '小' },
|
||
]} placeholder="选择类型" />
|
||
</Form.Item>
|
||
<Form.Item name="capacity" label="容量"><InputNumber min={1} max={500} style={{ width: '100%' }} /></Form.Item>
|
||
<Form.Item name="courseType" label="课程类型" tooltip="如:尊享培优班 / 专业课集训班"><Input /></Form.Item>
|
||
<Form.Item name="supervisor" label="负责人/班主任"><Input /></Form.Item>
|
||
<Form.Item name="notes" label="备注"><Input.TextArea rows={2} /></Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ClassroomsPage;
|