feat: add enterprise outdoor fair viewer
Some checks failed
Node CI / build (14.x, macOS-latest) (push) Has been cancelled
Node CI / build (14.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (14.x, windows-latest) (push) Has been cancelled
Node CI / build (16.x, macOS-latest) (push) Has been cancelled
Node CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (16.x, windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
coverage CI / build (push) Has been cancelled
Node pnpm CI / build (16.x, macOS-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, windows-latest) (push) Has been cancelled

This commit is contained in:
2026-07-23 10:52:38 +08:00
parent befc27adae
commit bdb1516570
8 changed files with 315 additions and 2 deletions

View File

@@ -231,6 +231,9 @@ const PortalJobFairDetailPage: React.FC = () => {
{detail.boothNum && ( {detail.boothNum && (
<Descriptions.Item label="展位数量">{detail.boothNum}</Descriptions.Item> <Descriptions.Item label="展位数量">{detail.boothNum}</Descriptions.Item>
)} )}
{channel === 'outdoor' && detail.boothMapCount !== undefined && (
<Descriptions.Item label="摊位数量">{detail.boothMapCount}</Descriptions.Item>
)}
{detail.jobFairPhone && ( {detail.jobFairPhone && (
<Descriptions.Item <Descriptions.Item
label={ label={

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { history, useAccess } from '@umijs/max'; import { history, useAccess } from '@umijs/max';
import { Button, Card, Descriptions, Image, Tag, message } from 'antd'; import { Button, Card, Descriptions, Divider, Image, Tag, message } from 'antd';
import { FormOutlined } from '@ant-design/icons'; import { FormOutlined } from '@ant-design/icons';
import type { OutdoorFairDictForm, OutdoorFairItem } from '@/services/jobportal/outdoorFair'; import type { OutdoorFairDictForm, OutdoorFairItem } from '@/services/jobportal/outdoorFair';
import { addOutdoorFairDictOption, updateOutdoorFair } from '@/services/jobportal/outdoorFair'; import { addOutdoorFairDictOption, updateOutdoorFair } from '@/services/jobportal/outdoorFair';
@@ -164,6 +164,17 @@ const FairInfoTab: React.FC<Props> = ({ fairInfo, onFairUpdate }) => {
<div style={{ whiteSpace: 'pre-wrap' }}>{fairInfo.fairProcess || '--'}</div> <div style={{ whiteSpace: 'pre-wrap' }}>{fairInfo.fairProcess || '--'}</div>
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
<Divider orientation="left" plain>
</Divider>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="需携带资料" span={3}>
<div style={{ whiteSpace: 'pre-wrap' }}>{fairInfo.requiredMaterials || '--'}</div>
</Descriptions.Item>
<Descriptions.Item label="到场须知" span={3}>
<div style={{ whiteSpace: 'pre-wrap' }}>{fairInfo.attendanceNotes || '--'}</div>
</Descriptions.Item>
</Descriptions>
</Card> </Card>
<Card title="场地信息" style={{ marginTop: 16 }}> <Card title="场地信息" style={{ marginTop: 16 }}>

View File

@@ -0,0 +1,152 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Empty, Modal, Space, Spin, Tag, Tooltip, message } from 'antd';
import {
getCurrentCompanyOutdoorFairBoothMap,
type OutdoorFairCompanyBoothMap,
} from '@/services/jobportal/outdoorFair';
import type { VenueBoothItem } from '@/services/jobportal/venueInfo';
interface Props {
fairId?: number;
open: boolean;
onCancel: () => void;
}
const BOOTH_WIDTH = 76;
const BOOTH_HEIGHT = 48;
const BOOTH_PADDING = 44;
const CompanyBoothMapModal: React.FC<Props> = ({ fairId, open, onCancel }) => {
const [loading, setLoading] = useState(false);
const [mapData, setMapData] = useState<OutdoorFairCompanyBoothMap>();
useEffect(() => {
if (!open || !fairId) {
return;
}
let cancelled = false;
const loadBooths = async () => {
setLoading(true);
try {
const res = await getCurrentCompanyOutdoorFairBoothMap(fairId);
if (cancelled) {
return;
}
if (res.code === 200 && res.data) {
setMapData(res.data);
} else {
setMapData(undefined);
message.error(res.msg || '展位图加载失败');
}
} catch {
if (!cancelled) {
setMapData(undefined);
message.error('展位图加载失败');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void loadBooths();
return () => {
cancelled = true;
};
}, [fairId, open]);
const canvasSize = useMemo(() => {
const booths = mapData?.booths || [];
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(920, maxX + BOOTH_WIDTH + BOOTH_PADDING),
height: Math.max(480, maxY + BOOTH_HEIGHT + BOOTH_PADDING),
};
}, [mapData?.booths]);
const renderBooth = (booth: VenueBoothItem, index: number) => {
const reserved = booth.status === 'reserved';
const isCurrentCompany = reserved && booth.companyId === mapData?.currentCompanyId;
const color = isCurrentCompany ? '#f5222d' : reserved ? '#52c41a' : '#1890ff';
const stateText = isCurrentCompany ? '本企业' : reserved ? '已预定' : '未预定';
const tooltip = reserved ? `${booth.boothNumber}${booth.companyName || stateText}` : `${booth.boothNumber}:未预定`;
return (
<Tooltip title={tooltip} key={booth.boothId || booth.id || `${booth.boothNumber}-${index}`}>
<div
style={{
position: 'absolute',
left: booth.positionX || 0,
top: booth.positionY || 0,
width: BOOTH_WIDTH,
height: BOOTH_HEIGHT,
padding: '4px 2px',
borderRadius: 4,
color: '#fff',
background: color,
boxShadow: isCurrentCompany ? '0 0 0 3px rgba(245, 34, 45, 0.22)' : undefined,
fontSize: 12,
userSelect: 'none',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'default',
}}
>
<div>{booth.boothNumber}</div>
<div style={{ fontSize: 11 }}>{stateText}</div>
</div>
</Tooltip>
);
};
return (
<Modal
title="展位图(红色为本企业展位)"
open={open}
width={1180}
footer={null}
destroyOnClose
onCancel={onCancel}
>
<Spin spinning={loading}>
<Space wrap size={12} style={{ marginBottom: 16 }}>
<Tag color="red"></Tag>
<Tag color="green"></Tag>
<Tag color="blue"></Tag>
<span style={{ color: '#999' }}></span>
</Space>
{mapData?.booths?.length ? (
<div
role="img"
aria-label="招聘会只读展位图"
style={{
maxHeight: '65vh',
overflow: 'auto',
border: '1px solid #f0f0f0',
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)',
}}
>
{mapData.booths.map(renderBooth)}
</div>
</div>
) : (
!loading && <Empty description="当前招聘会暂未配置展位图" />
)}
</Spin>
</Modal>
);
};
export default CompanyBoothMapModal;

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { Descriptions, Divider, Image, Modal, Tag } from 'antd';
import type { OutdoorFairItem } from '@/services/jobportal/outdoorFair';
interface Props {
fair?: OutdoorFairItem | null;
open: boolean;
onCancel: () => void;
}
const content = (value?: string) => <div style={{ whiteSpace: 'pre-wrap' }}>{value || '--'}</div>;
const CompanyFairDetailModal: React.FC<Props> = ({ fair, open, onCancel }) => (
<Modal
title="招聘会详情"
open={open}
width={920}
footer={null}
destroyOnClose
onCancel={onCancel}
>
{fair && (
<>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="招聘会标题" span={2}>
{fair.title || '--'}
</Descriptions.Item>
<Descriptions.Item label="举办单位">{fair.hostUnit || '--'}</Descriptions.Item>
<Descriptions.Item label="招聘会类型">{fair.fairType || '--'}</Descriptions.Item>
<Descriptions.Item label="举办区域">{fair.region || '--'}</Descriptions.Item>
<Descriptions.Item label="绑定场地">{fair.venueName || '--'}</Descriptions.Item>
<Descriptions.Item label="举办地址" span={2}>
{fair.address || '--'}
</Descriptions.Item>
<Descriptions.Item label="展位数量">{fair.boothCount ?? '--'}</Descriptions.Item>
<Descriptions.Item label="已预定摊位数">{fair.reservedBoothCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="举办时间">{fair.holdTime || '--'}</Descriptions.Item>
<Descriptions.Item label="截止时间">{fair.endTime || '--'}</Descriptions.Item>
<Descriptions.Item label="开放申请">{fair.applyStartTime || '--'}</Descriptions.Item>
<Descriptions.Item label="截止申请">{fair.applyEndTime || '--'}</Descriptions.Item>
<Descriptions.Item label="线上申请">
<Tag color={fair.onlineApply ? 'green' : 'default'}>{fair.onlineApply ? '是' : '否'}</Tag>
</Descriptions.Item>
<Descriptions.Item label="招聘会照片">
{fair.photoUrl ? (
<Image src={fair.photoUrl} alt={fair.title} width={180} style={{ objectFit: 'cover' }} />
) : (
'--'
)}
</Descriptions.Item>
<Descriptions.Item label="招聘会简介" span={2}>
{content(fair.fairIntroduction)}
</Descriptions.Item>
<Descriptions.Item label="招聘会整体流程" span={2}>
{content(fair.fairProcess)}
</Descriptions.Item>
</Descriptions>
<Divider orientation="left" plain>
</Divider>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="需携带资料">{content(fair.requiredMaterials)}</Descriptions.Item>
<Descriptions.Item label="到场须知">{content(fair.attendanceNotes)}</Descriptions.Item>
</Descriptions>
</>
)}
</Modal>
);
export default CompanyFairDetailModal;

View File

@@ -310,6 +310,21 @@ const EditModal: React.FC<EditModalProps> = ({
placeholder="请输入招聘会整体流程" placeholder="请输入招聘会整体流程"
fieldProps={{ maxLength: 5000, showCount: true, autoSize: { minRows: 4, maxRows: 8 } }} fieldProps={{ maxLength: 5000, showCount: true, autoSize: { minRows: 4, maxRows: 8 } }}
/> />
<Divider orientation="left" plain>
</Divider>
<ProFormTextArea
name="requiredMaterials"
label="需携带资料"
placeholder="请输入企业参加招聘会需携带的资料"
fieldProps={{ maxLength: 5000, showCount: true, autoSize: { minRows: 3, maxRows: 8 } }}
/>
<ProFormTextArea
name="attendanceNotes"
label="到场须知"
placeholder="请输入企业到场须知"
fieldProps={{ maxLength: 5000, showCount: true, autoSize: { minRows: 3, maxRows: 8 } }}
/>
<ProForm.Item label="招聘会照片" required> <ProForm.Item label="招聘会照片" required>
<Space direction="vertical" size={12}> <Space direction="vertical" size={12}>
<Upload <Upload

View File

@@ -31,6 +31,8 @@ import { getDictSelectOption, getDictValueEnum } from '@/services/system/dict';
import { getVenueInfoList } from '@/services/jobportal/venueInfo'; import { getVenueInfoList } from '@/services/jobportal/venueInfo';
import { normalizePublicUrl } from '@/utils/publicUrl'; import { normalizePublicUrl } from '@/utils/publicUrl';
import EditModal from './components/EditModal'; import EditModal from './components/EditModal';
import CompanyBoothMapModal from './components/CompanyBoothMapModal';
import CompanyFairDetailModal from './components/CompanyFairDetailModal';
import JobEditModal from './Detail/components/JobEditModal'; import JobEditModal from './Detail/components/JobEditModal';
const OUTDOOR_FAIR_TYPE_DICT = 'outdoor_fair_type'; const OUTDOOR_FAIR_TYPE_DICT = 'outdoor_fair_type';
@@ -51,6 +53,8 @@ const OutdoorFairList: React.FC = () => {
const [qrInfo, setQrInfo] = useState<OutdoorFairCompanyQrCodeInfo | null>(null); const [qrInfo, setQrInfo] = useState<OutdoorFairCompanyQrCodeInfo | null>(null);
const [rejectReasonModalOpen, setRejectReasonModalOpen] = useState(false); const [rejectReasonModalOpen, setRejectReasonModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
const [companyDetailFair, setCompanyDetailFair] = useState<OutdoorFairItem | null>(null);
const [companyBoothFair, setCompanyBoothFair] = useState<OutdoorFairItem | null>(null);
const [fairTypeValueEnum, setFairTypeValueEnum] = useState<Record<string, any>>({}); const [fairTypeValueEnum, setFairTypeValueEnum] = useState<Record<string, any>>({});
const [fairTypeOptions, setFairTypeOptions] = useState<any[]>([]); const [fairTypeOptions, setFairTypeOptions] = useState<any[]>([]);
const [regionValueEnum, setRegionValueEnum] = useState<Record<string, any>>({}); const [regionValueEnum, setRegionValueEnum] = useState<Record<string, any>>({});
@@ -202,6 +206,14 @@ const OutdoorFairList: React.FC = () => {
setRejectReasonModalOpen(true); setRejectReasonModalOpen(true);
}; };
const openCompanyDetail = (record: OutdoorFairItem) => {
setCompanyDetailFair(record);
};
const openCompanyBoothMap = (record: OutdoorFairItem) => {
setCompanyBoothFair(record);
};
const jobColumns: ProColumns<API.OutdoorFairDetail.PostedJob>[] = [ const jobColumns: ProColumns<API.OutdoorFairDetail.PostedJob>[] = [
{ title: '岗位名称', dataIndex: 'jobTitle', ellipsis: true }, { title: '岗位名称', dataIndex: 'jobTitle', ellipsis: true },
{ {
@@ -362,7 +374,7 @@ const OutdoorFairList: React.FC = () => {
{ {
title: '操作', title: '操作',
valueType: 'option', valueType: 'option',
width: isEnterprise ? 280 : 160, width: isEnterprise ? 520 : 160,
fixed: 'right', fixed: 'right',
render: (_, record) => [ render: (_, record) => [
isEnterprise && !record.signedUp ? ( isEnterprise && !record.signedUp ? (
@@ -397,6 +409,16 @@ const OutdoorFairList: React.FC = () => {
</Button> </Button>
) : null, ) : null,
isEnterprise ? (
<Button key="companyDetail" type="link" size="small" onClick={() => openCompanyDetail(record)}>
</Button>
) : null,
isEnterprise && record.signedUp && record.reviewStatus === '1' ? (
<Button key="boothMap" type="link" size="small" onClick={() => openCompanyBoothMap(record)}>
</Button>
) : null,
<Button <Button
key="detail" key="detail"
type="link" type="link"
@@ -544,6 +566,16 @@ const OutdoorFairList: React.FC = () => {
{rejectReason} {rejectReason}
</Typography.Paragraph> </Typography.Paragraph>
</Modal> </Modal>
<CompanyFairDetailModal
fair={companyDetailFair}
open={!!companyDetailFair}
onCancel={() => setCompanyDetailFair(null)}
/>
<CompanyBoothMapModal
fairId={companyBoothFair?.id}
open={!!companyBoothFair}
onCancel={() => setCompanyBoothFair(null)}
/>
<Modal <Modal
title={selectedFair ? `${selectedFair.title} - 报名岗位` : '报名岗位'} title={selectedFair ? `${selectedFair.title} - 报名岗位` : '报名岗位'}
open={jobListModalOpen} open={jobListModalOpen}

View File

@@ -20,6 +20,8 @@ export interface PortalJobFairItem {
jobFairImage?: string; jobFairImage?: string;
enterpriseNum?: string; enterpriseNum?: string;
boothNum?: string; boothNum?: string;
/** 户外招聘会展位图中的实际摊位数量 */
boothMapCount?: number;
divisionName?: string; divisionName?: string;
jobFairCategory?: string; jobFairCategory?: string;
jobFairRegion?: string; jobFairRegion?: string;
@@ -69,6 +71,7 @@ interface PortalOutdoorFairDetailData extends Partial<PortalJobFairItem> {
venueName?: string; venueName?: string;
address?: string; address?: string;
boothCount?: number; boothCount?: number;
boothMapCount?: number;
holdTime?: string; holdTime?: string;
endTime?: string; endTime?: string;
photoUrl?: string; photoUrl?: string;
@@ -206,6 +209,7 @@ export async function getPortalJobFairDetail(channel: JobFairChannel, jobFairId:
jobFairImage: fair.jobFairImage || fair.photoUrl, jobFairImage: fair.jobFairImage || fair.photoUrl,
boothNum: boothNum:
fair.boothNum || (fair.boothCount === undefined ? undefined : String(fair.boothCount)), fair.boothNum || (fair.boothCount === undefined ? undefined : String(fair.boothCount)),
boothMapCount: fair.boothMapCount,
jobFairRegion: fair.jobFairRegion || fair.region, jobFairRegion: fair.jobFairRegion || fair.region,
jobFairVenueName: fair.jobFairVenueName || fair.venueName, jobFairVenueName: fair.jobFairVenueName || fair.venueName,
}, },

View File

@@ -1,4 +1,5 @@
import { request } from '@umijs/max'; import { request } from '@umijs/max';
import type { VenueBoothItem } from './venueInfo';
/** 户外招聘会列表项 */ /** 户外招聘会列表项 */
export interface OutdoorFairItem { export interface OutdoorFairItem {
@@ -37,6 +38,10 @@ export interface OutdoorFairItem {
fairIntroduction?: string; fairIntroduction?: string;
/** 招聘会整体流程 */ /** 招聘会整体流程 */
fairProcess?: string; fairProcess?: string;
/** 企业参加招聘会需携带的资料 */
requiredMaterials?: string;
/** 企业到场须知 */
attendanceNotes?: string;
/** 当前企业ID */ /** 当前企业ID */
companyId?: number; companyId?: number;
/** 当前企业名称 */ /** 当前企业名称 */
@@ -96,6 +101,10 @@ export interface OutdoorFairForm {
fairIntroduction?: string; fairIntroduction?: string;
/** 招聘会整体流程 */ /** 招聘会整体流程 */
fairProcess?: string; fairProcess?: string;
/** 企业参加招聘会需携带的资料 */
requiredMaterials?: string;
/** 企业到场须知 */
attendanceNotes?: string;
/** 更换场地时确认清空旧展位图与预定,并按新模板重建 */ /** 更换场地时确认清空旧展位图与预定,并按新模板重建 */
resetBoothMap?: boolean; resetBoothMap?: boolean;
} }
@@ -125,6 +134,12 @@ export interface OutdoorFairCompanyQrCodeInfo {
qrCodeContent: string; qrCodeContent: string;
} }
/** 企业审核通过后可读取的只读展位图。 */
export interface OutdoorFairCompanyBoothMap {
currentCompanyId: number;
booths: VenueBoothItem[];
}
/** 户外招聘会字典新增表单 */ /** 户外招聘会字典新增表单 */
export interface OutdoorFairDictForm { export interface OutdoorFairDictForm {
dictType: 'outdoor_fair_type' | 'outdoor_fair_region'; dictType: 'outdoor_fair_type' | 'outdoor_fair_region';
@@ -169,6 +184,17 @@ export async function getOutdoorFairInfo(id: number) {
}); });
} }
/**
* 当前企业查看户外招聘会展位图。
* 服务端会校验当前企业已报名且审核通过,接口只提供读取能力。
*/
export async function getCurrentCompanyOutdoorFairBoothMap(fairId: number) {
return request<{ code: number; msg?: string; data?: OutdoorFairCompanyBoothMap }>(
`${CMS_OUTDOOR_FAIR_BASE}/${fairId}/company/booth-map`,
{ method: 'GET' },
);
}
/** 招聘会统计-时间桶序列项(曲线图用) */ /** 招聘会统计-时间桶序列项(曲线图用) */
export interface OutdoorFairStatisticsSeriesItem { export interface OutdoorFairStatisticsSeriesItem {
time: string; time: string;