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;
|
||||
|
||||
@@ -41,7 +41,7 @@ const OutdoorFairDetail: React.FC = () => {
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'fair-info', label: '招聘会管理' },
|
||||
{ key: 'booth', label: '展位管理' },
|
||||
{ key: 'booth', label: '展位图' },
|
||||
{ key: 'company-review', label: '参会单位管理' },
|
||||
{ key: 'attendee', label: '参会人员管理' },
|
||||
{ key: 'device', label: '入场设备管理' },
|
||||
@@ -79,7 +79,7 @@ const OutdoorFairDetail: React.FC = () => {
|
||||
/>
|
||||
);
|
||||
case 'booth':
|
||||
return <BoothTab fairId={fairId} />;
|
||||
return <BoothTab fairId={fairId} fairInfo={fairInfo} />;
|
||||
case 'company-review':
|
||||
return <CompanyReviewTab fairId={fairId} />;
|
||||
case 'attendee':
|
||||
|
||||
@@ -1,17 +1,89 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { history, useSearchParams } from '@umijs/max';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { Button, Card, Descriptions, Spin, Tag, message } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { getVenueInfo } from '@/services/jobportal/venueInfo';
|
||||
import type { VenueInfoItem } from '@/services/jobportal/venueInfo';
|
||||
import { Rnd } from 'react-rnd';
|
||||
import {
|
||||
getVenueBoothList,
|
||||
getVenueInfo,
|
||||
saveVenueBoothList,
|
||||
} from '@/services/jobportal/venueInfo';
|
||||
import type { VenueBoothItem, VenueInfoItem } from '@/services/jobportal/venueInfo';
|
||||
|
||||
const BOOTH_WIDTH = 88;
|
||||
const BOOTH_HEIGHT = 44;
|
||||
const BOOTH_GAP_X = 24;
|
||||
const BOOTH_GAP_Y = 28;
|
||||
const BOOTH_START_X = 40;
|
||||
const BOOTH_START_Y = 40;
|
||||
const BOOTHS_PER_ROW = 10;
|
||||
|
||||
const VenueInfoDetail: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const venueId = Number(searchParams.get('id'));
|
||||
const [venueInfo, setVenueInfo] = useState<VenueInfoItem>();
|
||||
const [booths, setBooths] = useState<VenueBoothItem[]>([]);
|
||||
const [boothModalOpen, setBoothModalOpen] = useState(false);
|
||||
const [editingBoothKey, setEditingBoothKey] = useState<string>();
|
||||
const [boothNumber, setBoothNumber] = useState('');
|
||||
const [generateModalOpen, setGenerateModalOpen] = useState(false);
|
||||
const [generateRows, setGenerateRows] = useState(6);
|
||||
const [generateColumns, setGenerateColumns] = useState(10);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const getBoothKey = (booth: VenueBoothItem, index: number) =>
|
||||
String(booth.id || `${booth.boothNumber}-${index}`);
|
||||
|
||||
const canvasSize = useMemo(() => {
|
||||
const maxX = booths.reduce((max, booth) => Math.max(max, booth.positionX || 0), 0);
|
||||
const maxY = booths.reduce((max, booth) => Math.max(max, booth.positionY || 0), 0);
|
||||
return {
|
||||
width: Math.max(1200, maxX + BOOTH_WIDTH + BOOTH_START_X),
|
||||
height: Math.max(520, maxY + BOOTH_HEIGHT + BOOTH_START_Y),
|
||||
};
|
||||
}, [booths]);
|
||||
|
||||
const arrangeBooths = (items: VenueBoothItem[], columns = BOOTHS_PER_ROW) =>
|
||||
items.map((booth, index) => ({
|
||||
...booth,
|
||||
positionX: BOOTH_START_X + (index % columns) * (BOOTH_WIDTH + BOOTH_GAP_X),
|
||||
positionY: BOOTH_START_Y + Math.floor(index / columns) * (BOOTH_HEIGHT + BOOTH_GAP_Y),
|
||||
}));
|
||||
|
||||
const hasOverlappedBooths = (items: VenueBoothItem[]) => {
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
for (let j = i + 1; j < items.length; j += 1) {
|
||||
const first = items[i];
|
||||
const second = items[j];
|
||||
const firstX = first.positionX || 0;
|
||||
const firstY = first.positionY || 0;
|
||||
const secondX = second.positionX || 0;
|
||||
const secondY = second.positionY || 0;
|
||||
const separated =
|
||||
firstX + BOOTH_WIDTH + 4 <= secondX ||
|
||||
secondX + BOOTH_WIDTH + 4 <= firstX ||
|
||||
firstY + BOOTH_HEIGHT + 4 <= secondY ||
|
||||
secondY + BOOTH_HEIGHT + 4 <= firstY;
|
||||
if (!separated) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVenueInfo = async () => {
|
||||
if (!venueId) {
|
||||
@@ -23,6 +95,18 @@ const VenueInfoDetail: React.FC = () => {
|
||||
const res = await getVenueInfo(venueId);
|
||||
if (res.code === 200 && res.data) {
|
||||
setVenueInfo(res.data);
|
||||
if (res.data.floorCount) {
|
||||
const columns = Math.min(10, Math.max(1, res.data.floorCount));
|
||||
setGenerateColumns(columns);
|
||||
setGenerateRows(Math.max(1, Math.ceil(res.data.floorCount / columns)));
|
||||
}
|
||||
const boothRes = await getVenueBoothList(venueId);
|
||||
if (boothRes.code === 200) {
|
||||
const fetchedBooths = boothRes.data || [];
|
||||
setBooths(
|
||||
hasOverlappedBooths(fetchedBooths) ? arrangeBooths(fetchedBooths) : fetchedBooths,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
message.error(res.msg || '未找到该场地信息');
|
||||
}
|
||||
@@ -33,6 +117,99 @@ const VenueInfoDetail: React.FC = () => {
|
||||
fetchVenueInfo();
|
||||
}, [venueId]);
|
||||
|
||||
const handleGenerateBooths = () => {
|
||||
const rows = Math.max(1, generateRows || 1);
|
||||
const columns = Math.max(1, generateColumns || 1);
|
||||
const count = rows * columns;
|
||||
const generated = Array.from({ length: count }).map((_, index) => ({
|
||||
boothNumber: `KJ${String(index + 1).padStart(2, '0')}`,
|
||||
positionX: BOOTH_START_X + (index % columns) * (BOOTH_WIDTH + BOOTH_GAP_X),
|
||||
positionY: BOOTH_START_Y + Math.floor(index / columns) * (BOOTH_HEIGHT + BOOTH_GAP_Y),
|
||||
}));
|
||||
setBooths(generated);
|
||||
setGenerateModalOpen(false);
|
||||
message.success(
|
||||
`已生成 ${rows} 行 × ${columns} 列,共 ${count} 个展位,确认后请点击保存展位图`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleArrangeBooths = () => {
|
||||
setBooths(arrangeBooths(booths, Math.max(1, generateColumns || BOOTHS_PER_ROW)));
|
||||
message.success('已整理布局,确认后请点击保存展位图');
|
||||
};
|
||||
|
||||
const openBoothModal = (booth?: VenueBoothItem, index?: number) => {
|
||||
setEditingBoothKey(booth && index !== undefined ? getBoothKey(booth, index) : undefined);
|
||||
setBoothNumber(booth?.boothNumber || `KJ${String(booths.length + 1).padStart(2, '0')}`);
|
||||
setBoothModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveBoothNumber = () => {
|
||||
const trimmed = boothNumber.trim();
|
||||
if (!trimmed) {
|
||||
message.warning('请输入展位号');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
booths.some(
|
||||
(booth, index) =>
|
||||
booth.boothNumber === trimmed && getBoothKey(booth, index) !== editingBoothKey,
|
||||
)
|
||||
) {
|
||||
message.warning('展位号不能重复');
|
||||
return;
|
||||
}
|
||||
if (editingBoothKey) {
|
||||
setBooths(
|
||||
booths.map((booth, index) =>
|
||||
getBoothKey(booth, index) === editingBoothKey
|
||||
? { ...booth, boothNumber: trimmed }
|
||||
: booth,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setBooths([
|
||||
...booths,
|
||||
{
|
||||
boothNumber: trimmed,
|
||||
positionX: 40,
|
||||
positionY: 40,
|
||||
},
|
||||
]);
|
||||
}
|
||||
setBoothModalOpen(false);
|
||||
};
|
||||
|
||||
const handleMoveBooth = (key: string, x: number, y: number) => {
|
||||
setBooths((prev) =>
|
||||
prev.map((booth, index) =>
|
||||
getBoothKey(booth, index) === key
|
||||
? { ...booth, positionX: Math.round(x), positionY: Math.round(y) }
|
||||
: booth,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteBooth = (key: string) => {
|
||||
setBooths(booths.filter((booth, index) => getBoothKey(booth, index) !== key));
|
||||
};
|
||||
|
||||
const handleSaveBooths = async () => {
|
||||
if (!venueId) {
|
||||
return;
|
||||
}
|
||||
const res = await saveVenueBoothList(venueId, booths);
|
||||
if (res.code === 200) {
|
||||
message.success('展位图保存成功');
|
||||
const boothRes = await getVenueBoothList(venueId);
|
||||
if (boothRes.code === 200) {
|
||||
setBooths(boothRes.data || []);
|
||||
}
|
||||
} else {
|
||||
message.error(res.msg || '展位图保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
header={{
|
||||
@@ -75,7 +252,149 @@ const VenueInfoDetail: React.FC = () => {
|
||||
</Descriptions>
|
||||
</Card>
|
||||
)}
|
||||
{venueInfo && (
|
||||
<Card
|
||||
title="展位图维护"
|
||||
style={{ marginTop: 16 }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => openBoothModal()}>新增展位</Button>
|
||||
<Button onClick={() => setGenerateModalOpen(true)}>按行列生成</Button>
|
||||
<Button onClick={handleArrangeBooths}>一键整理布局</Button>
|
||||
<Button type="primary" onClick={handleSaveBooths}>
|
||||
保存展位图
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 560,
|
||||
border: '1px solid #f0f0f0',
|
||||
overflow: 'auto',
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
position: 'relative',
|
||||
backgroundSize: '20px 20px',
|
||||
backgroundImage:
|
||||
'linear-gradient(to right, rgba(0,0,0,0.04) 1px, transparent 1px), linear-gradient(to bottom, rgba(0,0,0,0.04) 1px, transparent 1px)',
|
||||
}}
|
||||
>
|
||||
{booths.map((booth, index) => {
|
||||
const key = getBoothKey(booth, index);
|
||||
return (
|
||||
<Rnd
|
||||
key={key}
|
||||
bounds="parent"
|
||||
size={{ width: BOOTH_WIDTH, height: BOOTH_HEIGHT }}
|
||||
position={{ x: booth.positionX || 0, y: booth.positionY || 0 }}
|
||||
dragGrid={[8, 8]}
|
||||
enableResizing={false}
|
||||
onDragStop={(_, data) => handleMoveBooth(key, data.x, data.y)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 6,
|
||||
color: '#fff',
|
||||
background: '#1890ff',
|
||||
fontSize: 12,
|
||||
cursor: 'move',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.16)',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<div>{booth.boothNumber}</div>
|
||||
<Space size={6}>
|
||||
<a
|
||||
style={{ color: '#fff', fontSize: 11 }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openBoothModal(booth, index);
|
||||
}}
|
||||
>
|
||||
改名
|
||||
</a>
|
||||
<a
|
||||
style={{ color: '#fff', fontSize: 11 }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleDeleteBooth(key);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</a>
|
||||
</Space>
|
||||
</div>
|
||||
</Rnd>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: '#999', marginTop: 12 }}>
|
||||
操作说明:拖动蓝色展位块即可调整位置;点击“改名”修改展位号;点击“保存展位图”后写入数据库。
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Spin>
|
||||
<Modal
|
||||
title="按行列生成展位图"
|
||||
open={generateModalOpen}
|
||||
onCancel={() => setGenerateModalOpen(false)}
|
||||
onOk={handleGenerateBooths}
|
||||
okText="生成"
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<span>行数</span>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={50}
|
||||
value={generateRows}
|
||||
onChange={(value) => setGenerateRows(Number(value) || 1)}
|
||||
/>
|
||||
<span>列数</span>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={50}
|
||||
value={generateColumns}
|
||||
onChange={(value) => setGenerateColumns(Number(value) || 1)}
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ color: '#999' }}>
|
||||
将生成 {Math.max(1, generateRows || 1)} 行 × {Math.max(1, generateColumns || 1)} 列,共{' '}
|
||||
{Math.max(1, generateRows || 1) * Math.max(1, generateColumns || 1)}{' '}
|
||||
个展位。生成后需要点击“保存展位图”才会写入数据库。
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={editingBoothKey ? '修改展位号' : '新增展位'}
|
||||
open={boothModalOpen}
|
||||
onCancel={() => setBoothModalOpen(false)}
|
||||
onOk={handleSaveBoothNumber}
|
||||
okText="确定"
|
||||
>
|
||||
<Input
|
||||
value={boothNumber}
|
||||
maxLength={50}
|
||||
placeholder="请输入展位号,如 KJ01"
|
||||
onChange={(event) => setBoothNumber(event.target.value)}
|
||||
onPressEnter={handleSaveBoothNumber}
|
||||
/>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAccess, useModel } from '@umijs/max';
|
||||
import { history, useAccess, useModel } from '@umijs/max';
|
||||
import { Button, message, Modal } from 'antd';
|
||||
import { ActionType, PageContainer, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { DeleteOutlined, FormOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { AppstoreOutlined, DeleteOutlined, FormOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
addVenueInfo,
|
||||
addVenueInfoDictOption,
|
||||
@@ -153,9 +153,19 @@ const VenueInfoList: React.FC = () => {
|
||||
{
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
width: 140,
|
||||
width: 220,
|
||||
fixed: 'right',
|
||||
render: (_, record) => [
|
||||
<Button
|
||||
key="boothMap"
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<AppstoreOutlined />}
|
||||
hidden={!access.hasPerms('cms:venueInfo:query')}
|
||||
onClick={() => history.push(`/jobfair/venue-info/detail?id=${record.id}`)}
|
||||
>
|
||||
展位图
|
||||
</Button>,
|
||||
<Button
|
||||
key="edit"
|
||||
type="link"
|
||||
|
||||
@@ -78,6 +78,19 @@ export interface VenueInfoDictForm {
|
||||
dictLabel: string;
|
||||
}
|
||||
|
||||
export interface VenueBoothItem {
|
||||
id?: number;
|
||||
boothId?: number;
|
||||
venueId?: number;
|
||||
boothNumber: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
bookingId?: number;
|
||||
companyId?: number;
|
||||
companyName?: string;
|
||||
status?: 'available' | 'reserved';
|
||||
}
|
||||
|
||||
const CMS_VENUE_INFO_BASE = '/api/cms/venue-info';
|
||||
|
||||
const formatDate = (value: any) => {
|
||||
@@ -156,3 +169,51 @@ export async function addVenueInfoDictOption(data: VenueInfoDictForm) {
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVenueBoothList(venueId: number) {
|
||||
return request<{ code: number; msg?: string; data: VenueBoothItem[] }>(
|
||||
`${CMS_VENUE_INFO_BASE}/${venueId}/booths`,
|
||||
{ method: 'GET' },
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveVenueBoothList(venueId: number, data: VenueBoothItem[]) {
|
||||
return request<VenueInfoCommonResult>(`${CMS_VENUE_INFO_BASE}/${venueId}/booths`, {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOutdoorFairBoothMap(fairId: number) {
|
||||
return request<{ code: number; msg?: string; data: VenueBoothItem[] }>(
|
||||
`${CMS_VENUE_INFO_BASE}/fair/${fairId}/booth-map`,
|
||||
{ method: 'GET' },
|
||||
);
|
||||
}
|
||||
|
||||
export async function bookOutdoorFairBooth(
|
||||
fairId: number,
|
||||
data: { boothId: number; companyId: number; companyName: string; status?: string },
|
||||
) {
|
||||
return request<VenueInfoCommonResult>(`${CMS_VENUE_INFO_BASE}/fair/${fairId}/booth-booking`, {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelOutdoorFairBooth(fairId: number, boothId: number) {
|
||||
return request<VenueInfoCommonResult>(
|
||||
`${CMS_VENUE_INFO_BASE}/fair/${fairId}/booth-booking/${boothId}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
export async function swapOutdoorFairBooth(
|
||||
fairId: number,
|
||||
data: { firstBoothId: number; secondBoothId: number },
|
||||
) {
|
||||
return request<VenueInfoCommonResult>(`${CMS_VENUE_INFO_BASE}/fair/${fairId}/booth-swap`, {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user