507 lines
15 KiB
TypeScript
507 lines
15 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
||
import {
|
||
Table,
|
||
Button,
|
||
Modal,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Select,
|
||
Space,
|
||
Tag,
|
||
Popconfirm,
|
||
Upload,
|
||
Tooltip,
|
||
Empty,
|
||
} from 'antd';
|
||
import {
|
||
PlusOutlined,
|
||
UploadOutlined,
|
||
DownloadOutlined,
|
||
UndoOutlined,
|
||
InboxOutlined,
|
||
} from '@ant-design/icons';
|
||
import api from '../../api';
|
||
import PermissionButton from '../../components/PermissionButton';
|
||
import EditableCell from '../../components/EditableCell';
|
||
import { message } from '../../ui/app-message';
|
||
import { usePermission } from '../../hooks/usePermission';
|
||
|
||
const statusMap: Record<string, { text: string; color: string }> = {
|
||
available: { text: '可用', color: 'green' },
|
||
in_use: { text: '使用中', color: 'blue' },
|
||
reserved: { text: '已预留', color: 'purple' },
|
||
maintenance: { text: '维护中', color: 'orange' },
|
||
archived: { text: '已归档', color: '#999' },
|
||
};
|
||
|
||
interface CurrentUsage {
|
||
type: 'schedule' | 'rental';
|
||
title: string;
|
||
startTime: string;
|
||
endTime: string;
|
||
}
|
||
|
||
const typeColor: Record<string, string> = {
|
||
大: 'volcano',
|
||
次大: 'geekblue',
|
||
小: 'cyan',
|
||
};
|
||
|
||
const ClassroomsPage: React.FC = () => {
|
||
const { hasPermission } = usePermission();
|
||
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 [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
const filteredData = useMemo(() => {
|
||
let result = data;
|
||
if (searchText) {
|
||
const s = searchText.toLowerCase();
|
||
result = result.filter(
|
||
(d: Record<string, unknown>) =>
|
||
(typeof d.name === 'string' && d.name.toLowerCase().includes(s)) ||
|
||
(typeof d.building === 'string' && d.building.toLowerCase().includes(s)),
|
||
);
|
||
}
|
||
if (filterStatus)
|
||
result = result.filter((d: Record<string, unknown>) => d.effectiveStatus === filterStatus);
|
||
return result;
|
||
}, [data, searchText, filterStatus]);
|
||
|
||
const fetchData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res: any = await api.get('/classrooms', { params: { includeArchived: showArchived } });
|
||
setData(res);
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载失败,请稍后重试');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
}, [showArchived]);
|
||
|
||
const handleSave = async () => {
|
||
const values = await form.validateFields();
|
||
setSaving(true);
|
||
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 || '操作失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const saveCell = async (record: any, field: string, value: unknown) => {
|
||
await api.put(`/classrooms/${record.id}`, { [field]: value });
|
||
message.success('已保存');
|
||
await fetchData();
|
||
};
|
||
|
||
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 = useMemo(
|
||
() => [
|
||
{
|
||
title: '教室名',
|
||
width: 120,
|
||
dataIndex: 'name',
|
||
sorter: (a: any, b: any) => a.name.localeCompare(b.name),
|
||
render: (v: string, r: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
required
|
||
permission="classroom:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveCell(r, 'name', next)}
|
||
>
|
||
{v}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '楼栋',
|
||
dataIndex: 'building',
|
||
width: 80,
|
||
render: (v: string, r: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
permission="classroom:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveCell(r, 'building', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '楼层',
|
||
dataIndex: 'floor',
|
||
width: 80,
|
||
render: (v: number, r: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="number"
|
||
permission="classroom:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveCell(r, 'floor', next)}
|
||
>
|
||
{v ?? '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '类型',
|
||
width: 90,
|
||
dataIndex: 'roomType',
|
||
render: (v: string, r: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="select"
|
||
options={['大', '次大', '小'].map((value) => ({ value, label: value }))}
|
||
permission="classroom:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveCell(r, 'roomType', next)}
|
||
>
|
||
<Tag color={typeColor[v] || 'default'}>{v || '-'}</Tag>
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '容量',
|
||
dataIndex: 'capacity',
|
||
width: 80,
|
||
render: (v: number, r: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="number"
|
||
min={0}
|
||
permission="classroom:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveCell(r, 'capacity', next)}
|
||
>
|
||
{v ?? '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
width: 100,
|
||
dataIndex: 'status',
|
||
render: (
|
||
_s: string,
|
||
record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null },
|
||
) => {
|
||
const effectiveStatus = record.effectiveStatus || record.status;
|
||
return (
|
||
<EditableCell
|
||
value={record.status}
|
||
editor="select"
|
||
options={[
|
||
{ value: 'available', label: '可用' },
|
||
{ value: 'maintenance', label: '维护中' },
|
||
]}
|
||
permission="classroom:edit"
|
||
disabled={record.status === 'archived'}
|
||
onSave={(next) => saveCell(record, 'status', next)}
|
||
>
|
||
<Tooltip
|
||
title={
|
||
record.currentUsage
|
||
? `${record.currentUsage.title} (${record.currentUsage.startTime}-${record.currentUsage.endTime})`
|
||
: undefined
|
||
}
|
||
>
|
||
<Tag color={statusMap[effectiveStatus]?.color}>
|
||
{statusMap[effectiveStatus]?.text || effectiveStatus}
|
||
</Tag>
|
||
</Tooltip>
|
||
</EditableCell>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 180,
|
||
render: (_: any, record: any) => (
|
||
<Space>
|
||
{record.status === 'archived' ? (
|
||
<Popconfirm title="确定恢复此教室?" onConfirm={() => handleRestore(record.id)}>
|
||
<PermissionButton
|
||
permission="classroom:edit"
|
||
size="small"
|
||
icon={<UndoOutlined />}
|
||
type="link"
|
||
>
|
||
恢复
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
) : (
|
||
<>
|
||
<PermissionButton
|
||
permission="classroom:edit"
|
||
size="small"
|
||
onClick={() => {
|
||
setEditing(record);
|
||
form.setFieldsValue(record);
|
||
setModalOpen(true);
|
||
}}
|
||
>
|
||
编辑
|
||
</PermissionButton>
|
||
<Popconfirm
|
||
title="归档后数据保留,可随时恢复。存在进行中的租赁将无法归档。"
|
||
onConfirm={() => handleArchive(record.id)}
|
||
okText="归档"
|
||
cancelText="取消"
|
||
>
|
||
<PermissionButton
|
||
permission="classroom:delete"
|
||
size="small"
|
||
icon={<InboxOutlined />}
|
||
>
|
||
归档
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
</>
|
||
)}
|
||
</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('');
|
||
}}
|
||
/>
|
||
<Select
|
||
placeholder="状态"
|
||
allowClear
|
||
style={{ width: 110 }}
|
||
value={filterStatus}
|
||
onChange={setFilterStatus}
|
||
options={[
|
||
{ value: 'available', label: '可用' },
|
||
{ value: 'in_use', label: '使用中' },
|
||
{ value: 'reserved', label: '已预留' },
|
||
{ value: 'maintenance', label: '维护中' },
|
||
{ value: 'archived', label: '已归档' },
|
||
]}
|
||
/>
|
||
<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:view"
|
||
icon={<DownloadOutlined />}
|
||
onClick={() => {
|
||
const baseURL = '/api';
|
||
const token = localStorage.getItem('token');
|
||
fetch(`${baseURL}/classrooms/export`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
.then((r) => r.blob())
|
||
.then((b) => {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(b);
|
||
a.download = '教室使用报表.xlsx';
|
||
a.click();
|
||
});
|
||
}}
|
||
>
|
||
导出报表
|
||
</PermissionButton>
|
||
{hasPermission('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>
|
||
) : null}
|
||
<PermissionButton
|
||
permission="classroom:view"
|
||
icon={<DownloadOutlined />}
|
||
onClick={handleDownloadTemplate}
|
||
>
|
||
下载模板
|
||
</PermissionButton>
|
||
</Space>
|
||
</div>
|
||
<Table
|
||
scroll={{ x: 1100 }}
|
||
columns={columns}
|
||
dataSource={filteredData}
|
||
rowKey="id"
|
||
loading={loading}
|
||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||
pagination={{
|
||
defaultPageSize: 20,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [20, 50, 100],
|
||
showTotal: (total) => `共 ${total} 条`,
|
||
}}
|
||
/>
|
||
<Modal
|
||
title={editing ? '编辑教室' : '添加教室'}
|
||
open={modalOpen}
|
||
onOk={handleSave}
|
||
onCancel={() => {
|
||
setModalOpen(false);
|
||
setEditing(null);
|
||
}}
|
||
confirmLoading={saving}
|
||
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>
|
||
{editing && (
|
||
<Form.Item name="status" label="基础状态">
|
||
<Select
|
||
options={[
|
||
{ value: 'available', label: '可用' },
|
||
{ value: 'maintenance', label: '维护中' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
<Form.Item name="notes" label="备注">
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ClassroomsPage;
|