feat: enhance booth management functionality in outdoor fair
- Added `react-rnd` for draggable booth positioning in the booth management interface. - Implemented booth booking, cancellation, and swapping features. - Updated `BoothTab` component to fetch and display booth data with enhanced UI for booth interactions. - Introduced modal dialogs for booking and managing booth assignments. - Enhanced venue information service to support booth-related API calls. - Updated `VenueInfoDetail` to allow for booth layout management and generation. - Improved overall user experience with better state management and loading indicators.
This commit is contained in:
@@ -1,200 +1,296 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useAccess } from '@umijs/max';
|
||||
import { Button, message, Modal, Tag, Dropdown } from 'antd';
|
||||
import { ActionType, ProColumns, ProTable, ModalForm, ProFormText, ProFormSelect } from '@ant-design/pro-components';
|
||||
import { PlusOutlined, DeleteOutlined, FormOutlined, CheckOutlined, CloseOutlined, SendOutlined, EllipsisOutlined, ApartmentOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Col, Empty, Modal, Row, Select, Space, Tag, Tooltip, message } from 'antd';
|
||||
import { ReloadOutlined, SwapOutlined } from '@ant-design/icons';
|
||||
import { getCmsCompanyList } from '@/services/company/list';
|
||||
import {
|
||||
getBoothList,
|
||||
addBooth,
|
||||
updateBooth,
|
||||
deleteBooth,
|
||||
reviewBooth,
|
||||
assignBooth,
|
||||
} from '@/services/jobportal/outdoorFairDetail';
|
||||
import { getCompanyRegistrationList } from '@/services/jobportal/outdoorFairDetail';
|
||||
bookOutdoorFairBooth,
|
||||
cancelOutdoorFairBooth,
|
||||
getOutdoorFairBoothMap,
|
||||
swapOutdoorFairBooth,
|
||||
} from '@/services/jobportal/venueInfo';
|
||||
import type { VenueBoothItem } from '@/services/jobportal/venueInfo';
|
||||
import type { OutdoorFairItem } from '@/services/jobportal/outdoorFair';
|
||||
|
||||
interface Props {
|
||||
fairId: number;
|
||||
fairInfo: OutdoorFairItem;
|
||||
}
|
||||
|
||||
const reviewStatusMap: Record<string, { text: string; color: string }> = {
|
||||
pending: { text: '待审核', color: 'default' },
|
||||
approved: { text: '已通过', color: 'green' },
|
||||
rejected: { text: '已拒绝', color: 'red' },
|
||||
};
|
||||
|
||||
const BoothTab: React.FC<Props> = ({ fairId }) => {
|
||||
const BoothMapTab: React.FC<Props> = ({ fairId, fairInfo }) => {
|
||||
const access = useAccess();
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingBooth, setEditingBooth] = useState<any>(null);
|
||||
const [assignModalOpen, setAssignModalOpen] = useState(false);
|
||||
const [assigningBooth, setAssigningBooth] = useState<any>(null);
|
||||
const [booths, setBooths] = useState<VenueBoothItem[]>([]);
|
||||
const [companies, setCompanies] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedBooth, setSelectedBooth] = useState<VenueBoothItem>();
|
||||
const [bookingModalOpen, setBookingModalOpen] = useState(false);
|
||||
const [companyId, setCompanyId] = useState<number>();
|
||||
const [swapBoothIds, setSwapBoothIds] = useState<number[]>([]);
|
||||
|
||||
const columns: ProColumns<any>[] = [
|
||||
{
|
||||
title: '展位号',
|
||||
dataIndex: 'boothNumber',
|
||||
width: 100,
|
||||
hideInTable: true,
|
||||
fieldProps: { placeholder: '请输入展位号' },
|
||||
},
|
||||
{
|
||||
title: '审核状态',
|
||||
dataIndex: 'reviewStatus',
|
||||
width: 100,
|
||||
hideInTable: true,
|
||||
valueType: 'select',
|
||||
options: [
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
],
|
||||
},
|
||||
{ title: '展位号', dataIndex: 'boothNumber', width: 100, hideInSearch: true },
|
||||
{ title: '企业名称', dataIndex: 'companyName', ellipsis: true, hideInSearch: true, render: (_, r) => r.companyName || '--' },
|
||||
{ title: '岗位数', dataIndex: 'jobCount', width: 80, hideInSearch: true },
|
||||
{ title: '审核状态', dataIndex: 'reviewStatus', width: 100,
|
||||
render: (_, r) => {
|
||||
const s = reviewStatusMap[r.reviewStatus] || { text: r.reviewStatus, color: 'default' };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '分配时间', dataIndex: 'assignedTime', valueType: 'dateTime', width: 170, hideInSearch: true, render: (_, r) => r.assignedTime || '--' },
|
||||
{
|
||||
title: '操作', valueType: 'option', width: 150,
|
||||
render: (_, record) => {
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'assign',
|
||||
icon: <ApartmentOutlined />,
|
||||
label: '分配展位',
|
||||
onClick: () => { setAssigningBooth(record); setAssignModalOpen(true); },
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
icon: <FormOutlined />,
|
||||
label: '编辑',
|
||||
onClick: () => { setEditingBooth(record); setModalOpen(true); },
|
||||
},
|
||||
{
|
||||
key: 'notify',
|
||||
icon: <SendOutlined />,
|
||||
label: '发送通知',
|
||||
disabled: !record.companyName,
|
||||
onClick: () => {
|
||||
if (!record.companyName) { message.warning('该展位尚未分配企业'); return; }
|
||||
message.success(`已向「${record.companyName}」发送展位信息通知`);
|
||||
},
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <DeleteOutlined />,
|
||||
label: '删除',
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: '确认删除', content: `确定删除展位「${record.boothNumber}」吗?`,
|
||||
onOk: async () => {
|
||||
const res = await deleteBooth(record.id);
|
||||
if (res.code === 200) { message.success('删除成功'); actionRef.current?.reload(); }
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
return [
|
||||
record.reviewStatus === 'pending' && (
|
||||
<Button key="approve" type="link" size="small" style={{ color: 'green' }}
|
||||
onClick={() => handleReview(record.id, 'approved')}
|
||||
>通过</Button>
|
||||
),
|
||||
record.reviewStatus === 'pending' && (
|
||||
<Button key="reject" type="link" size="small" danger
|
||||
onClick={() => handleReview(record.id, 'rejected')}
|
||||
>拒绝</Button>
|
||||
),
|
||||
<Dropdown key="more" menu={{ items: menuItems }} trigger={['click']}>
|
||||
<Button type="link" size="small" icon={<EllipsisOutlined />} />
|
||||
</Dropdown>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [boothRes, companyRes] = await Promise.all([
|
||||
getOutdoorFairBoothMap(fairId),
|
||||
getCmsCompanyList({ current: 1, pageSize: 999, status: 1 } as API.CompanyList.Params),
|
||||
]);
|
||||
if (boothRes.code === 200) {
|
||||
setBooths(boothRes.data || []);
|
||||
}
|
||||
if (companyRes.code === 200) {
|
||||
setCompanies(companyRes.rows || []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReview = async (id: number, status: string) => {
|
||||
const res = await reviewBooth(id, status);
|
||||
if (res.code === 200) { message.success(res.msg); actionRef.current?.reload(); }
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fairId]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const reserved = booths.filter((item) => item.status === 'reserved').length;
|
||||
return {
|
||||
reserved,
|
||||
available: booths.length - reserved,
|
||||
total: booths.length,
|
||||
};
|
||||
}, [booths]);
|
||||
|
||||
const approvedCompanyOptions = useMemo(
|
||||
() =>
|
||||
companies.map((item) => ({
|
||||
label: item.name || item.companyName,
|
||||
value: item.companyId,
|
||||
})),
|
||||
[companies],
|
||||
);
|
||||
|
||||
const openBookingModal = (booth: VenueBoothItem) => {
|
||||
setSelectedBooth(booth);
|
||||
setCompanyId(booth.companyId);
|
||||
setBookingModalOpen(true);
|
||||
};
|
||||
|
||||
const handleBookBooth = async () => {
|
||||
if (!selectedBooth?.boothId && !selectedBooth?.id) {
|
||||
return;
|
||||
}
|
||||
if (!companyId) {
|
||||
message.warning('请选择预定单位');
|
||||
return;
|
||||
}
|
||||
const selectedCompany = companies.find((item) => item.companyId === companyId);
|
||||
if (!selectedCompany) {
|
||||
message.warning('请选择审核通过的企业');
|
||||
return;
|
||||
}
|
||||
const boothId = Number(selectedBooth.boothId || selectedBooth.id);
|
||||
const res = await bookOutdoorFairBooth(fairId, {
|
||||
boothId,
|
||||
companyId,
|
||||
companyName: selectedCompany.name || selectedCompany.companyName || '',
|
||||
status: 'reserved',
|
||||
});
|
||||
if (res.code === 200) {
|
||||
message.success('预定成功');
|
||||
setBookingModalOpen(false);
|
||||
fetchData();
|
||||
} else {
|
||||
message.error(res.msg || '预定失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelBooth = async (booth: VenueBoothItem) => {
|
||||
const boothId = Number(booth.boothId || booth.id);
|
||||
const res = await cancelOutdoorFairBooth(fairId, boothId);
|
||||
if (res.code === 200) {
|
||||
message.success('已取消预定');
|
||||
fetchData();
|
||||
} else {
|
||||
message.error(res.msg || '取消失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwap = async () => {
|
||||
if (swapBoothIds.length !== 2) {
|
||||
message.warning('请选择两个已预定展位');
|
||||
return;
|
||||
}
|
||||
const res = await swapOutdoorFairBooth(fairId, {
|
||||
firstBoothId: swapBoothIds[0],
|
||||
secondBoothId: swapBoothIds[1],
|
||||
});
|
||||
if (res.code === 200) {
|
||||
message.success('调换成功');
|
||||
setSwapBoothIds([]);
|
||||
fetchData();
|
||||
} else {
|
||||
message.error(res.msg || '调换失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSwapBooth = (booth: VenueBoothItem) => {
|
||||
if (booth.status !== 'reserved') {
|
||||
message.warning('只能选择已预定展位调换');
|
||||
return;
|
||||
}
|
||||
const boothId = Number(booth.boothId || booth.id);
|
||||
if (swapBoothIds.includes(boothId)) {
|
||||
setSwapBoothIds(swapBoothIds.filter((item) => item !== boothId));
|
||||
return;
|
||||
}
|
||||
if (swapBoothIds.length >= 2) {
|
||||
setSwapBoothIds([swapBoothIds[1], boothId]);
|
||||
return;
|
||||
}
|
||||
setSwapBoothIds([...swapBoothIds, boothId]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ProTable
|
||||
headerTitle="展位列表"
|
||||
actionRef={actionRef}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
search={{ labelWidth: 100 }}
|
||||
request={async (params) => {
|
||||
const res = await getBoothList(fairId, {
|
||||
current: params.current, pageSize: params.pageSize,
|
||||
boothNumber: params.boothNumber, reviewStatus: params.reviewStatus,
|
||||
});
|
||||
return { data: res.rows, total: res.total, success: true };
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />}
|
||||
hidden={!access.hasPerms('cms:outdoorFair:add')}
|
||||
onClick={() => { setEditingBooth(null); setModalOpen(true); }}
|
||||
>新增展位</Button>,
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 新增/编辑展位 */}
|
||||
<ModalForm
|
||||
title={editingBooth ? '编辑展位' : '新增展位'}
|
||||
open={modalOpen}
|
||||
width={400}
|
||||
modalProps={{ destroyOnClose: true, onCancel: () => setModalOpen(false) }}
|
||||
onFinish={async (values) => {
|
||||
const res = editingBooth
|
||||
? await updateBooth({ id: editingBooth.id, ...values })
|
||||
: await addBooth({ fairId, ...values });
|
||||
if (res.code === 200) { message.success(res.msg); setModalOpen(false); actionRef.current?.reload(); return true; }
|
||||
message.error(res.msg); return false;
|
||||
}}
|
||||
initialValues={editingBooth}
|
||||
<Row gutter={16}>
|
||||
<Col flex="auto">
|
||||
<Card
|
||||
title={`展位图${fairInfo.venueName ? ` - ${fairInfo.venueName}` : ''}`}
|
||||
loading={loading}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<SwapOutlined />}
|
||||
disabled={swapBoothIds.length !== 2}
|
||||
onClick={handleSwap}
|
||||
>
|
||||
调换位置
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{!fairInfo.venueId ? (
|
||||
<Empty description="当前招聘会未绑定场地" />
|
||||
) : booths.length === 0 ? (
|
||||
<Empty description="当前场地还没有维护展位图,请先到场地信息维护里编辑展位图" />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
minHeight: 520,
|
||||
position: 'relative',
|
||||
overflow: 'auto',
|
||||
background: '#fafafa',
|
||||
border: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
{booths.map((booth) => {
|
||||
const boothId = Number(booth.boothId || booth.id);
|
||||
const reserved = booth.status === 'reserved';
|
||||
const selectedForSwap = swapBoothIds.includes(boothId);
|
||||
return (
|
||||
<Tooltip
|
||||
key={boothId}
|
||||
title={reserved ? booth.companyName || '已预定' : '未预定'}
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
onClick={() => toggleSwapBooth(booth)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: booth.positionX,
|
||||
top: booth.positionY,
|
||||
width: 52,
|
||||
minHeight: 30,
|
||||
padding: '4px 2px',
|
||||
textAlign: 'center',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
background: reserved ? '#52c41a' : '#1890ff',
|
||||
outline: selectedForSwap ? '3px solid #fa8c16' : undefined,
|
||||
boxShadow: selectedForSwap ? '0 0 0 2px #fff inset' : undefined,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div>{booth.boothNumber}</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
style={{ color: '#fff', padding: 0, height: 16, fontSize: 11 }}
|
||||
hidden={!access.hasPerms('cms:outdoorFair:edit')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openBookingModal(booth);
|
||||
}}
|
||||
>
|
||||
{reserved ? '改单位' : '预定'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col flex="300px">
|
||||
<Card title="展位概况">
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
<Tag color="green" style={{ width: '100%', textAlign: 'center', padding: 8 }}>
|
||||
已预定({stats.reserved})
|
||||
</Tag>
|
||||
<Tag color="blue" style={{ width: '100%', textAlign: 'center', padding: 8 }}>
|
||||
未安排({stats.available})
|
||||
</Tag>
|
||||
<Tag style={{ width: '100%', textAlign: 'center', padding: 8 }}>
|
||||
总展位({stats.total})
|
||||
</Tag>
|
||||
</Space>
|
||||
<div style={{ marginTop: 16, color: '#999' }}>
|
||||
提示:点击两个绿色已预定展位后,可点击“调换位置”交换预定单位。
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Modal
|
||||
title={`预定展位 - ${selectedBooth?.boothNumber || ''}`}
|
||||
open={bookingModalOpen}
|
||||
onCancel={() => setBookingModalOpen(false)}
|
||||
onOk={handleBookBooth}
|
||||
okText="确认预定"
|
||||
footer={(_, { OkBtn, CancelBtn }) => (
|
||||
<Space>
|
||||
{selectedBooth?.status === 'reserved' && (
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
if (selectedBooth) {
|
||||
handleCancelBooth(selectedBooth);
|
||||
setBookingModalOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
取消预定
|
||||
</Button>
|
||||
)}
|
||||
<CancelBtn />
|
||||
<OkBtn />
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<ProFormText name="boothNumber" label="展位号" rules={[{ required: true, message: '请输入展位号' }]} placeholder="如:A01" />
|
||||
</ModalForm>
|
||||
|
||||
{/* 分配展位给企业 */}
|
||||
<ModalForm
|
||||
title={`分配展位 — ${assigningBooth?.boothNumber || ''}`}
|
||||
open={assignModalOpen}
|
||||
width={500}
|
||||
modalProps={{ destroyOnClose: true, onCancel: () => setAssignModalOpen(false) }}
|
||||
onFinish={async (values) => {
|
||||
if (!assigningBooth) return false;
|
||||
const res = await assignBooth({
|
||||
fairId, boothId: assigningBooth.id, companyId: values.companyId, boothNumber: assigningBooth.boothNumber,
|
||||
});
|
||||
if (res.code === 200) { message.success(res.msg); setAssignModalOpen(false); actionRef.current?.reload(); return true; }
|
||||
message.error(res.msg); return false;
|
||||
}}
|
||||
>
|
||||
<ProFormSelect name="companyId" label="选择企业" width="md"
|
||||
rules={[{ required: true, message: '请选择企业' }]}
|
||||
request={async () => {
|
||||
const res = await getCompanyRegistrationList(fairId, { reviewStatus: 'approved' });
|
||||
return res.rows.map((r: any) => ({ label: r.companyName, value: r.companyId }));
|
||||
}}
|
||||
placeholder="请搜索并选择企业"
|
||||
<Select
|
||||
showSearch
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请选择审核通过的参会单位"
|
||||
optionFilterProp="label"
|
||||
value={companyId}
|
||||
onChange={setCompanyId}
|
||||
options={approvedCompanyOptions}
|
||||
/>
|
||||
</ModalForm>
|
||||
</div>
|
||||
</Modal>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoothTab;
|
||||
export default BoothMapTab;
|
||||
|
||||
Reference in New Issue
Block a user