feat: manage venue booth templates in job fair admin
Some checks are pending
Node CI / build (14.x, macOS-latest) (push) Waiting to run
Node CI / build (14.x, ubuntu-latest) (push) Waiting to run
Node CI / build (14.x, windows-latest) (push) Waiting to run
Node CI / build (16.x, macOS-latest) (push) Waiting to run
Node CI / build (16.x, ubuntu-latest) (push) Waiting to run
Node CI / build (16.x, windows-latest) (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
coverage CI / build (push) Waiting to run
Node pnpm CI / build (16.x, macOS-latest) (push) Waiting to run
Node pnpm CI / build (16.x, ubuntu-latest) (push) Waiting to run
Node pnpm CI / build (16.x, windows-latest) (push) Waiting to run

This commit is contained in:
2026-07-22 17:06:13 +08:00
parent 48325cefa4
commit 9d1dfe962f
6 changed files with 491 additions and 3 deletions

View File

@@ -147,7 +147,30 @@ const EditModal: React.FC<EditModalProps> = ({
width={700}
modalProps={{ destroyOnClose: true, onCancel }}
onFinish={async (formValues) => {
await onSubmit({ ...formValues, id: values?.id } as OutdoorFairForm);
const payload = { ...formValues, id: values?.id } as OutdoorFairForm;
const venueChanged =
!!values?.id &&
values.venueId !== undefined &&
Number(values.venueId) !== Number(payload.venueId);
if (venueChanged && (values?.boothCount || 0) > 0) {
return new Promise<boolean>((resolve) => {
Modal.confirm({
title: '确认更换场地并重建展位图?',
content:
'当前招聘会已有展位图。确认后将删除原展位图和所有企业预定,再按新场地的展位模板重新生成;此操作不可恢复。',
okText: '确认重建',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
await onSubmit({ ...payload, resetBoothMap: true });
resolve(true);
},
onCancel: () => resolve(false),
});
});
}
await onSubmit(payload);
return true;
}}
>
<ProFormText name="id" hidden />

View File

@@ -286,6 +286,12 @@ const OutdoorFairList: React.FC = () => {
dataIndex: 'boothCount',
hideInSearch: true,
},
{
title: '已预定摊位数',
dataIndex: 'reservedBoothCount',
hideInSearch: true,
render: (_, record) => record.reservedBoothCount ?? 0,
},
{
title: '举办时间',
dataIndex: 'holdTime',

View File

@@ -0,0 +1,421 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button,
Card,
Col,
Empty,
Input,
InputNumber,
List,
Modal,
Popover,
Row,
Space,
Tag,
message,
} from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { Rnd } from 'react-rnd';
import { getVenueBoothMap, saveVenueBoothMap } from '@/services/jobportal/venueInfo';
import type { VenueBoothItem, VenueInfoItem } from '@/services/jobportal/venueInfo';
interface Props {
open: boolean;
venue?: VenueInfoItem;
onCancel: () => void;
onSaved: () => void;
}
const BOOTH_WIDTH = 70;
const BOOTH_HEIGHT = 44;
const BOOTH_GAP_X = 42;
const BOOTH_GAP_Y = 54;
const BOOTH_START_X = 40;
const BOOTH_START_Y = 40;
const BOOTHS_PER_ROW = 10;
/**
* 场地模板模式的平面编辑器。
* 它只处理布局数据,刻意不加载企业、不展示预定/调换/取消预定等招聘会副本交互。
*/
const VenueBoothMapModal: React.FC<Props> = ({ open, venue, onCancel, onSaved }) => {
const [booths, setBooths] = useState<VenueBoothItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [activeBoothKey, setActiveBoothKey] = useState<string>();
const [boothModalOpen, setBoothModalOpen] = useState(false);
const [editingBoothKey, setEditingBoothKey] = useState<string>();
const [boothNumber, setBoothNumber] = useState('');
const [generateModalOpen, setGenerateModalOpen] = useState(false);
const [generateRows, setGenerateRows] = useState(2);
const [generateColumns, setGenerateColumns] = useState(10);
const getBoothKey = (booth: VenueBoothItem, index: number) =>
String(booth.id || booth.boothId || `${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(960, maxX + BOOTH_WIDTH + BOOTH_START_X),
height: Math.max(520, maxY + BOOTH_HEIGHT + BOOTH_START_Y),
};
}, [booths]);
const boothList = useMemo(
() =>
[...booths].sort((a, b) =>
String(a.boothNumber || '').localeCompare(String(b.boothNumber || ''), 'zh-CN', {
numeric: true,
}),
),
[booths],
);
const fetchData = async () => {
if (!venue?.id) {
return;
}
setLoading(true);
try {
const res = await getVenueBoothMap(venue.id);
if (res.code === 200) {
const fetchedBooths = res.data || [];
setBooths(fetchedBooths);
if (fetchedBooths.length > 0) {
const columns = Math.min(BOOTHS_PER_ROW, fetchedBooths.length);
setGenerateColumns(columns);
setGenerateRows(Math.max(1, Math.ceil(fetchedBooths.length / columns)));
}
} else {
message.error(res.msg || '场地展位图加载失败');
}
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) {
setActiveBoothKey(undefined);
fetchData();
}
}, [open, venue?.id]);
const getNextBoothNumber = () => {
const existingNumbers = new Set(booths.map((item) => item.boothNumber));
let next = booths.length + 1;
while (existingNumbers.has(`KJ${String(next).padStart(2, '0')}`)) {
next += 1;
}
return `KJ${String(next).padStart(2, '0')}`;
};
const getNextBoothPosition = () => {
const columns = Math.max(1, generateColumns || BOOTHS_PER_ROW);
const index = booths.length;
return {
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 openBoothModal = (booth?: VenueBoothItem, index?: number) => {
setEditingBoothKey(booth && index !== undefined ? getBoothKey(booth, index) : undefined);
setBoothNumber(booth?.boothNumber || getNextBoothNumber());
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((prev) =>
prev.map((booth, index) =>
getBoothKey(booth, index) === editingBoothKey
? { ...booth, boothNumber: trimmed }
: booth,
),
);
} else {
const nextPosition = getNextBoothPosition();
setBooths((prev) => [...prev, { boothNumber: trimmed, ...nextPosition }]);
}
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((prev) => prev.filter((booth, index) => getBoothKey(booth, index) !== key));
setActiveBoothKey(undefined);
};
const handleGenerateBooths = () => {
const rows = Math.max(1, generateRows || 1);
const columns = Math.max(1, generateColumns || 1);
const count = rows * columns;
const existingByNumber = new Map(booths.map((item) => [item.boothNumber, item]));
const generated = Array.from({ length: count }).map((_, index) => {
const generatedNumber = `KJ${String(index + 1).padStart(2, '0')}`;
return {
...existingByNumber.get(generatedNumber),
boothNumber: generatedNumber,
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 = () => {
const columns = Math.max(1, generateColumns || BOOTHS_PER_ROW);
setBooths((prev) =>
prev.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),
})),
);
message.success('已整理布局;请点击保存场地模板');
};
const handleSave = async () => {
if (!venue?.id) {
return;
}
const invalid = booths.find((booth) => !booth.boothNumber?.trim());
if (invalid) {
message.warning('展位号不能为空');
return;
}
setSaving(true);
try {
const res = await saveVenueBoothMap(
venue.id,
booths.map((booth) => ({
id: booth.id,
boothNumber: booth.boothNumber.trim(),
positionX: booth.positionX || 0,
positionY: booth.positionY || 0,
})),
);
if (res.code === 200) {
message.success('场地展位模板保存成功');
await fetchData();
onSaved();
} else {
message.error(res.msg || '场地展位模板保存失败');
}
} finally {
setSaving(false);
}
};
return (
<Modal
title={venue ? `${venue.venueName} - 展位管理` : '展位管理'}
open={open}
onCancel={onCancel}
width={1240}
destroyOnClose
footer={null}
>
<Row gutter={16} wrap={false} align="top">
<Col flex="1 1 0" style={{ minWidth: 0 }}>
<Card loading={loading} bodyStyle={{ padding: 16 }}>
<Space wrap size={12} style={{ marginBottom: 16 }}>
<Button onClick={() => openBoothModal()}></Button>
<Button onClick={() => setGenerateModalOpen(true)}></Button>
<Button onClick={handleArrangeBooths}></Button>
<Button type="primary" loading={saving} onClick={handleSave}>
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchData}>
</Button>
</Space>
{booths.length === 0 ? (
<Empty description="当前场地还没有展位模板,请点击“按行列生成”或“新增展位”创建" />
) : (
<div
style={{
minHeight: 520,
position: 'relative',
overflow: 'auto',
background: '#fafafa',
border: '1px solid #f0f0f0',
}}
>
<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 (
<Popover
key={key}
trigger="click"
placement="rightTop"
open={activeBoothKey === key}
onOpenChange={(visible) => setActiveBoothKey(visible ? key : undefined)}
title={`${booth.boothNumber}:场地模板展位`}
content={
<Space direction="vertical" size={8} style={{ width: 160 }}>
<Button
block
size="small"
onClick={() => {
setActiveBoothKey(undefined);
openBoothModal(booth, index);
}}
>
</Button>
<Button
block
danger
size="small"
onClick={() => handleDeleteBooth(key)}
>
</Button>
</Space>
}
>
<Rnd
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%',
padding: '4px 2px',
textAlign: 'center',
borderRadius: 4,
cursor: 'move',
color: '#fff',
background: '#1890ff',
fontSize: 12,
userSelect: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{booth.boothNumber}
</div>
</Rnd>
</Popover>
);
})}
</div>
</div>
)}
</Card>
</Col>
<Col flex="260px">
<Card title="模板概况" size="small">
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Tag color="blue" style={{ width: '100%', textAlign: 'center', padding: 8 }}>
{booths.length}
</Tag>
<div style={{ color: '#999', fontSize: 12, lineHeight: '20px' }}>
</div>
<List
size="small"
style={{ maxHeight: 420, overflow: 'auto' }}
dataSource={boothList}
locale={{ emptyText: '暂无模板展位' }}
renderItem={(booth) => <List.Item>{booth.boothNumber}</List.Item>}
/>
</Space>
</Card>
</Col>
</Row>
<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>
</Modal>
);
};
export default VenueBoothMapModal;

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { 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 { ApartmentOutlined, DeleteOutlined, FormOutlined, PlusOutlined } from '@ant-design/icons';
import {
addVenueInfo,
addVenueInfoDictOption,
@@ -19,6 +19,7 @@ import type {
import { getCmsLineList } from '@/services/area/subway';
import { getDictSelectOption, getDictValueEnum } from '@/services/system/dict';
import EditModal from './components/EditModal';
import VenueBoothMapModal from './components/VenueBoothMapModal';
const VENUE_TYPE_DICT = 'venue_info_type';
@@ -28,6 +29,7 @@ const VenueInfoList: React.FC = () => {
const actionRef = useRef<ActionType>();
const [modalVisible, setModalVisible] = useState(false);
const [currentRow, setCurrentRow] = useState<VenueInfoItem>();
const [templateVenue, setTemplateVenue] = useState<VenueInfoItem>();
const [venueTypeValueEnum, setVenueTypeValueEnum] = useState<Record<string, any>>({});
const [venueTypeOptions, setVenueTypeOptions] = useState<any[]>([]);
const [lineOptions, setLineOptions] = useState<any[]>([]);
@@ -153,9 +155,19 @@ const VenueInfoList: React.FC = () => {
{
title: '操作',
valueType: 'option',
width: 140,
width: 220,
fixed: 'right',
render: (_, record) => [
<Button
key="booths"
type="link"
size="small"
icon={<ApartmentOutlined />}
hidden={!access.hasPerms('cms:venueInfo:edit')}
onClick={() => setTemplateVenue(record)}
>
</Button>,
<Button
key="edit"
type="link"
@@ -240,6 +252,12 @@ const VenueInfoList: React.FC = () => {
}
}}
/>
<VenueBoothMapModal
open={!!templateVenue}
venue={templateVenue}
onCancel={() => setTemplateVenue(undefined)}
onSaved={() => actionRef.current?.reload()}
/>
</PageContainer>
);
};

View File

@@ -19,6 +19,8 @@ export interface OutdoorFairItem {
address: string;
/** 展位数量 */
boothCount: number;
/** 已预定摊位数 */
reservedBoothCount?: number;
/** 举办时间 */
holdTime: string;
/** 截止时间 */
@@ -86,6 +88,8 @@ export interface OutdoorFairForm {
applyEndTime: string;
onlineApply: boolean;
photoUrl: string;
/** 更换场地时确认清空旧展位图与预定,并按新模板重建 */
resetBoothMap?: boolean;
}
/** 通用响应 */

View File

@@ -170,6 +170,22 @@ export async function addVenueInfoDictOption(data: VenueInfoDictForm) {
});
}
/** 查询场地的展位图模板。模板不含企业及预定状态。 */
export async function getVenueBoothMap(venueId: number) {
return request<{ code: number; msg?: string; data: VenueBoothItem[] }>(
`${CMS_VENUE_INFO_BASE}/${venueId}/booth-map`,
{ method: 'GET' },
);
}
/** 完整覆盖保存场地的展位图模板。 */
export async function saveVenueBoothMap(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`,