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:
2026-06-25 14:42:14 +08:00
parent 7fe22c3dfb
commit 50372da241
7 changed files with 735 additions and 193 deletions

View File

@@ -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;