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;

View File

@@ -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':

View File

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

View File

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

View File

@@ -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,
});
}