feat: implement participating companies and jobs tabs in outdoor fair detail

- Add ParticipatingCompaniesTab component to manage participating companies.
- Add ParticipatingJobsTab component to manage jobs associated with participating companies.
- Remove StatisticsTab component as it is no longer needed.
- Update OutdoorFairDetail to include new tabs for participating companies and jobs.
- Modify API services to support fetching and managing participating companies and jobs.
- Update types to reflect changes in job and company data structures.
This commit is contained in:
2026-06-25 23:13:42 +08:00
parent 5a8aaccb6c
commit b684b4e12d
16 changed files with 1069 additions and 1126 deletions

View File

@@ -150,6 +150,9 @@ export default defineConfig({
],
mfsu: {
strategy: 'normal',
// react-rnd 由招聘会展位图按需加载。避免旧的 MFSU 容器缺少该模块时,
// 详情页运行时报 “Module ./react-rnd does not exist in container”。
exclude: ['react-rnd'],
},
outputPath: 'shihezi',
base: '/shihezi/',

View File

@@ -236,6 +236,11 @@ export default [
path: '/jobfair/venue-info',
component: './Jobfair/Venueinfo',
},
{
name: '场地信息详情',
path: '/jobfair/venue-info/detail',
component: './Jobfair/Venueinfo/Detail',
},
{
name: '户外招聘会详情',
path: '/jobfair/outdoor-fair/detail',

View File

@@ -1,218 +0,0 @@
import React, { useRef, useState } from 'react';
import { useAccess } from '@umijs/max';
import { Button, message, Modal, Tag, Drawer, Space } from 'antd';
import { ActionType, ProColumns, ProTable, ModalForm, ProFormText, ProFormSelect, ProFormDateTimePicker, ProFormTextArea } from '@ant-design/pro-components';
import { ScanOutlined, EditOutlined, ScheduleOutlined, ExportOutlined } from '@ant-design/icons';
import {
getAttendeeList,
scanAttendeeEntry,
updateAttendeeResume,
trackInterview,
} from '@/services/jobportal/outdoorFairDetail';
interface Props {
fairId: number;
}
const checkInMethodMap: Record<string, string> = {
qr_scan: '扫码入场', id_card: '刷身份证', manual: '手动登记',
};
const interviewStatusMap: Record<string, { text: string; color: string }> = {
none: { text: '未面试', color: 'default' },
scheduled: { text: '已预约', color: 'blue' },
in_progress: { text: '面试中', color: 'processing' },
completed: { text: '已完成', color: 'green' },
passed: { text: '已通过', color: 'green' },
failed: { text: '未通过', color: 'red' },
};
const AttendeeTab: React.FC<Props> = ({ fairId }) => {
const access = useAccess();
const actionRef = useRef<ActionType>();
const [scanModalOpen, setScanModalOpen] = useState(false);
const [resumeDrawerOpen, setResumeDrawerOpen] = useState(false);
const [currentAttendee, setCurrentAttendee] = useState<any>(null);
const [interviewModalOpen, setInterviewModalOpen] = useState(false);
const columns: ProColumns<any>[] = [
{
title: '姓名',
dataIndex: 'name',
width: 100,
hideInTable: true,
fieldProps: { placeholder: '请输入姓名' },
},
{
title: '手机号',
dataIndex: 'phone',
width: 130,
hideInTable: true,
fieldProps: { placeholder: '请输入手机号' },
},
{
title: '面试状态',
dataIndex: 'interviewStatus',
hideInTable: true,
valueType: 'select',
options: [
{ label: '未面试', value: 'none' },
{ label: '已预约', value: 'scheduled' },
{ label: '面试中', value: 'in_progress' },
{ label: '已完成', value: 'completed' },
{ label: '已通过', value: 'passed' },
{ label: '未通过', value: 'failed' },
],
},
{ title: '姓名', dataIndex: 'name', width: 100, hideInSearch: true },
{ title: '性别', dataIndex: 'gender', width: 60, hideInSearch: true },
{ title: '手机号', dataIndex: 'phone', width: 130, hideInSearch: true },
{ title: '身份证号', dataIndex: 'idCard', width: 180, hideInSearch: true },
{ title: '学历', dataIndex: 'education', width: 80, hideInSearch: true },
{ title: '毕业院校', dataIndex: 'graduationSchool', width: 150, hideInSearch: true, ellipsis: true },
{ title: '入场时间', dataIndex: 'checkInTime', valueType: 'dateTime', width: 170, hideInSearch: true, render: (_, r) => r.checkInTime || '未入场' },
{ title: '入场方式', dataIndex: 'checkInMethod', width: 100, hideInSearch: true,
render: (_, r) => checkInMethodMap[r.checkInMethod] || r.checkInMethod },
{ title: '意向企业', dataIndex: 'targetCompanies', width: 180, ellipsis: true, hideInSearch: true },
{ title: '面试状态', dataIndex: 'interviewStatus', width: 100, hideInSearch: true,
render: (_, r) => {
const s = interviewStatusMap[r.interviewStatus] || { text: r.interviewStatus, color: 'default' };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作', valueType: 'option', width: 220,
render: (_, record) => [
<Button key="scan" type="link" size="small" icon={<ScanOutlined />}
onClick={() => {
if (record.checkInTime) { message.info('该人员已入场'); return; }
Modal.confirm({
title: '确认入场', content: `确认${record.name}扫码入场?`,
onOk: async () => {
const res = await scanAttendeeEntry({
name: record.name, phone: record.phone, idCard: record.idCard, checkInMethod: 'qr_scan',
});
if (res.code === 200) { message.success('扫码入场成功'); actionRef.current?.reload(); }
},
});
}}
>{record.checkInTime ? '已入场' : '扫码入场'}</Button>,
<Button key="resume" type="link" size="small" icon={<EditOutlined />}
onClick={() => { setCurrentAttendee(record); setResumeDrawerOpen(true); }}
></Button>,
<Button key="interview" type="link" size="small" icon={<ScheduleOutlined />}
onClick={() => { setCurrentAttendee(record); setInterviewModalOpen(true); }}
></Button>,
],
},
];
return (
<div>
<ProTable
headerTitle="参会人员列表"
actionRef={actionRef}
rowKey="id"
columns={columns}
search={{ labelWidth: 80 }}
request={async (params) => {
const res = await getAttendeeList(fairId, {
current: params.current, pageSize: params.pageSize,
name: params.name, phone: params.phone, interviewStatus: params.interviewStatus,
});
return { data: res.rows, total: res.total, success: true };
}}
toolBarRender={() => [
<Button key="simulate" type="primary" icon={<ScanOutlined />}
onClick={() => setScanModalOpen(true)}
></Button>,
<Button key="export" icon={<ExportOutlined />}></Button>,
]}
/>
{/* 扫码入场弹窗 */}
<ModalForm
title="扫码入场"
open={scanModalOpen}
width={450}
modalProps={{ destroyOnClose: true, onCancel: () => setScanModalOpen(false) }}
onFinish={async (values) => {
const res = await scanAttendeeEntry(values);
if (res.code === 200) { message.success(res.msg); setScanModalOpen(false); actionRef.current?.reload(); return true; }
message.error(res.msg); return false;
}}
>
<ProFormText name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]} placeholder="请输入姓名" />
<ProFormText name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]} placeholder="请输入手机号" />
<ProFormText name="idCard" label="身份证号" rules={[{ required: true }]} placeholder="请输入身份证号" />
<ProFormSelect name="checkInMethod" label="入场方式" initialValue="qr_scan"
options={[
{ label: '扫码入场', value: 'qr_scan' },
{ label: '刷身份证', value: 'id_card' },
{ label: '手动登记', value: 'manual' },
]}
rules={[{ required: true }]}
/>
</ModalForm>
{/* 简历编辑抽屉 */}
<Drawer
title={`编辑简历 — ${currentAttendee?.name || ''}`}
width={500}
open={resumeDrawerOpen}
onClose={() => setResumeDrawerOpen(false)}
destroyOnClose
>
<ModalForm
title={null}
open
submitter={{ searchConfig: { submitText: '保存简历' } }}
modalProps={{ footer: null }}
initialValues={currentAttendee}
onFinish={async (values) => {
if (!currentAttendee) return;
const res = await updateAttendeeResume(currentAttendee.id, values);
if (res.code === 200) { message.success('简历更新成功'); setResumeDrawerOpen(false); actionRef.current?.reload(); return true; }
message.error(res.msg); return false;
}}
>
<ProFormSelect name="education" label="学历" width="md"
options={['高中', '大专', '本科', '硕士', '博士'].map(v => ({ label: v, value: v }))}
/>
<ProFormText name="graduationSchool" label="毕业院校" width="md" placeholder="请输入毕业院校" />
<ProFormText name="major" label="专业" width="md" placeholder="请输入专业" />
<ProFormTextArea name="resumeContent" label="简历内容" placeholder="请输入简历内容" />
</ModalForm>
</Drawer>
{/* 面试跟踪弹窗 */}
<ModalForm
title={`面试跟踪 — ${currentAttendee?.name || ''}`}
open={interviewModalOpen}
width={500}
modalProps={{ destroyOnClose: true, onCancel: () => setInterviewModalOpen(false) }}
initialValues={currentAttendee}
onFinish={async (values) => {
if (!currentAttendee) return;
const res = await trackInterview({ attendeeId: currentAttendee.id, ...values });
if (res.code === 200) { message.success('面试信息更新成功'); setInterviewModalOpen(false); actionRef.current?.reload(); return true; }
message.error(res.msg); return false;
}}
>
<ProFormSelect name="interviewStatus" label="面试状态" width="md"
options={[
{ label: '未面试', value: 'none' }, { label: '已预约', value: 'scheduled' },
{ label: '面试中', value: 'in_progress' }, { label: '已完成', value: 'completed' },
{ label: '已通过', value: 'passed' }, { label: '未通过', value: 'failed' },
]}
rules={[{ required: true }]}
/>
<ProFormText name="interviewCompany" label="面试企业" width="md" placeholder="请输入面试企业" />
<ProFormDateTimePicker name="interviewTime" label="面试时间" width="md" fieldProps={{ format: 'YYYY-MM-DD HH:mm:ss' }} />
<ProFormTextArea name="interviewResult" label="面试结果" placeholder="请输入面试结果或备注" />
</ModalForm>
</div>
);
};
export default AttendeeTab;

View File

@@ -1,196 +0,0 @@
import React, { useRef, useState } from 'react';
import { useAccess } from '@umijs/max';
import { Button, message, Modal, Tag, Space } from 'antd';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { CheckOutlined, CloseOutlined, QrcodeOutlined, SendOutlined } from '@ant-design/icons';
import {
getCompanyRegistrationList,
reviewCompanyRegistration,
reviewRegisteredJob,
qrCheckIn,
} from '@/services/jobportal/outdoorFairDetail';
interface Props {
fairId: number;
}
const reviewStatusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待审核', color: 'default' },
approved: { text: '已通过', color: 'green' },
rejected: { text: '已拒绝', color: 'red' },
};
const CompanyReviewTab: React.FC<Props> = ({ fairId }) => {
const access = useAccess();
const actionRef = useRef<ActionType>();
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectTarget, setRejectTarget] = useState<any>(null);
const handleReviewCompany = async (id: number, status: string) => {
if (status === 'rejected') {
Modal.confirm({
title: '确认拒绝', content: '确定拒绝该单位的参会申请吗?',
onOk: async () => {
const res = await reviewCompanyRegistration(id, status);
if (res.code === 200) { message.success(res.msg); actionRef.current?.reload(); }
},
});
} else {
const res = await reviewCompanyRegistration(id, status);
if (res.code === 200) { message.success(res.msg); actionRef.current?.reload(); }
}
};
const handleReviewJob = async (regId: number, jobId: number, status: string) => {
const res = await reviewRegisteredJob(regId, jobId, status);
if (res.code === 200) { message.success(res.msg); actionRef.current?.reload(); }
};
const handleQrCheckIn = async (id: number) => {
Modal.confirm({
title: '扫码签到',
content: '确认该单位已完成现场扫码签到吗?',
onOk: async () => {
const res = await qrCheckIn(id);
if (res.code === 200) { message.success(res.msg); actionRef.current?.reload(); }
},
});
};
const handleNotify = (companyName: string, method: string) => {
message.success(`已通过${method}向「${companyName}」发送审核结果通知`);
};
const columns: ProColumns<any>[] = [
{
title: '企业名称',
dataIndex: 'companyName',
ellipsis: true,
hideInTable: true,
fieldProps: { placeholder: '请输入企业名称' },
},
{
title: '审核状态',
dataIndex: 'reviewStatus',
hideInTable: true,
valueType: 'select',
options: [
{ label: '待审核', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已拒绝', value: 'rejected' },
],
},
{ title: '企业名称', dataIndex: 'companyName', ellipsis: true, hideInSearch: true },
{ title: '行业', dataIndex: 'industry', width: 80, hideInSearch: true, render: (_, r) => <Tag>{r.industry}</Tag> },
{ title: '规模', dataIndex: 'scale', width: 100, hideInSearch: true },
{ title: '联系人', dataIndex: 'contactPerson', width: 100, hideInSearch: true },
{ title: '联系电话', dataIndex: 'contactPhone', width: 130, hideInSearch: true },
{ title: '审核状态', dataIndex: 'reviewStatus', width: 100, hideInSearch: true,
render: (_, r) => {
const s = reviewStatusMap[r.reviewStatus] || { text: r.reviewStatus, color: 'default' };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{ title: '签到状态', dataIndex: 'qrCheckInStatus', width: 100, hideInSearch: true,
render: (_, r) => <Tag color={r.qrCheckInStatus === 'checked_in' ? 'green' : 'default'}>
{r.qrCheckInStatus === 'checked_in' ? '已签到' : '未签到'}
</Tag>,
},
{ title: '签到时间', dataIndex: 'checkInTime', valueType: 'dateTime', width: 170, hideInSearch: true, render: (_, r) => r.checkInTime || '--' },
{
title: '操作', valueType: 'option', width: 300,
render: (_, record) => [
record.reviewStatus === 'pending' && (
<Button key="approve" type="link" size="small" icon={<CheckOutlined />} style={{ color: 'green' }}
onClick={() => handleReviewCompany(record.id, 'approved')}
></Button>
),
record.reviewStatus === 'pending' && (
<Button key="reject" type="link" size="small" danger icon={<CloseOutlined />}
onClick={() => handleReviewCompany(record.id, 'rejected')}
></Button>
),
record.reviewStatus === 'approved' && (
<>
<Button key="notifySms" type="link" size="small" icon={<SendOutlined />}
onClick={() => handleNotify(record.companyName, '短信')}
></Button>
<Button key="notifyMsg" type="link" size="small" icon={<SendOutlined />}
onClick={() => handleNotify(record.companyName, '站内信')}
></Button>
{record.qrCheckInStatus !== 'checked_in' && (
<Button key="qrcode" type="link" size="small" icon={<QrcodeOutlined />}
onClick={() => handleQrCheckIn(record.id)}
></Button>
)}
</>
),
],
},
];
// 展开行:岗位审核
const expandedRowRender = (record: any) => {
if (!record.registeredJobs || record.registeredJobs.length === 0) {
return <div style={{ padding: '16px 24px', color: '#999' }}></div>;
}
return (
<ProTable
rowKey="id"
headerTitle={false}
search={false}
options={false}
pagination={false}
dataSource={record.registeredJobs}
columns={[
{ title: '岗位名称', dataIndex: 'jobTitle', ellipsis: true },
{ title: '薪资范围', width: 150,
render: (_, r: any) => `${r.minSalary}K-${r.maxSalary}K` },
{ title: '学历要求', dataIndex: 'education', width: 80 },
{ title: '招聘人数', dataIndex: 'vacancies', width: 80 },
{ title: '审核状态', dataIndex: 'reviewStatus', width: 100,
render: (_, r: any) => {
const s = reviewStatusMap[r.reviewStatus] || { text: r.reviewStatus, color: 'default' };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作', valueType: 'option', width: 150,
render: (_, jobRecord: any) => [
jobRecord.reviewStatus === 'pending' && (
<Button key="approveJob" type="link" size="small" icon={<CheckOutlined />} style={{ color: 'green' }}
onClick={() => handleReviewJob(record.id, jobRecord.id, 'approved')}
></Button>
),
jobRecord.reviewStatus === 'pending' && (
<Button key="rejectJob" type="link" size="small" danger icon={<CloseOutlined />}
onClick={() => handleReviewJob(record.id, jobRecord.id, 'rejected')}
></Button>
),
],
},
]}
/>
);
};
return (
<ProTable
headerTitle="参会单位审核"
actionRef={actionRef}
rowKey="id"
columns={columns}
search={{ labelWidth: 100 }}
expandable={{ expandedRowRender }}
request={async (params) => {
const res = await getCompanyRegistrationList(fairId, {
current: params.current, pageSize: params.pageSize,
companyName: params.companyName, reviewStatus: params.reviewStatus,
});
return { data: res.rows, total: res.total, success: true };
}}
/>
);
};
export default CompanyReviewTab;

View File

@@ -1,291 +0,0 @@
import React, { useRef, useState, useEffect } from 'react';
import { useAccess } from '@umijs/max';
import { Button, message, Modal, Tag, Row, Col, Card, Switch, Statistic, Badge, Dropdown, Space } from 'antd';
import { ActionType, ProColumns, ProTable, ModalForm, ProFormText, ProFormSelect } from '@ant-design/pro-components';
import { PlusOutlined, DeleteOutlined, FormOutlined, QrcodeOutlined, SwapOutlined, ApartmentOutlined, ScanOutlined, EllipsisOutlined } from '@ant-design/icons';
import {
getDeviceList,
addDevice,
updateDevice,
deleteDevice,
assignDevice,
toggleDeviceStatus,
getGateStatuses,
toggleGate,
getGateEntryLogs,
simulateGateScan,
} from '@/services/jobportal/outdoorFairDetail';
import { getBoothList } from '@/services/jobportal/outdoorFairDetail';
interface Props {
fairId: number;
}
const deviceTypeMap: Record<string, string> = {
mobile_terminal: '移动终端', qr_sticker: '展位二维码',
};
const DeviceTab: React.FC<Props> = ({ fairId }) => {
const access = useAccess();
const mobileActionRef = useRef<ActionType>();
const qrActionRef = useRef<ActionType>();
const gateLogActionRef = useRef<ActionType>();
const [deviceModalOpen, setDeviceModalOpen] = useState(false);
const [editingDevice, setEditingDevice] = useState<any>(null);
const [defaultType, setDefaultType] = useState('mobile_terminal');
const [assignModalOpen, setAssignModalOpen] = useState(false);
const [assigningDevice, setAssigningDevice] = useState<any>(null);
const [gateStatuses, setGateStatuses] = useState<any[]>([]);
const [scanModalOpen, setScanModalOpen] = useState(false);
const [scanGate, setScanGate] = useState<any>(null);
const fetchGateStatuses = async () => {
const res = await getGateStatuses(fairId);
setGateStatuses(res.rows || []);
};
useEffect(() => {
fetchGateStatuses();
}, [fairId]);
const handleToggleGate = async (id: number) => {
const res = await toggleGate(id);
if (res.code === 200) { message.success(res.msg); fetchGateStatuses(); }
};
const deviceColumns = (type: string): ProColumns<any>[] => [
{ title: '设备名称', dataIndex: 'deviceName', width: 120, ellipsis: true },
{ title: '设备编号', dataIndex: 'deviceCode', width: 180, hideInSearch: true },
{ title: '分配展位', dataIndex: 'assignedBoothNumber', width: 100, hideInSearch: true, render: (_, r) => r.assignedBoothNumber || '--' },
{ title: '分配企业', dataIndex: 'assignedCompanyName', width: 180, ellipsis: true, hideInSearch: true, render: (_, r) => r.assignedCompanyName || '--' },
{ title: '状态', dataIndex: 'status', width: 80, hideInSearch: true,
render: (_, r) => <Badge status={r.status === 'online' ? 'success' : 'default'} text={r.status === 'online' ? '在线' : '离线'} />,
},
{ title: '最后活跃', dataIndex: 'lastActiveTime', width: 170, hideInSearch: true, render: (_, r) => r.lastActiveTime || '--' },
{
title: '操作', valueType: 'option', width: 120,
render: (_, record) => {
const isQrType = type === 'qr_sticker';
const menuItems = [
{
key: 'toggle',
icon: record.status === 'online' ? <DeleteOutlined /> : <PlusOutlined />,
label: record.status === 'online' ? '禁用' : '启用',
onClick: async () => {
const res = await toggleDeviceStatus(record.id);
if (res.code === 200) { message.success(res.msg); type === 'mobile_terminal' ? mobileActionRef.current?.reload() : qrActionRef.current?.reload(); }
},
},
{
key: 'assign',
icon: <ApartmentOutlined />,
label: '分配展位',
onClick: () => { setAssigningDevice(record); setAssignModalOpen(true); },
},
...(isQrType ? [{
key: 'qrcode',
icon: <QrcodeOutlined />,
label: '重新生成二维码',
onClick: () => message.success(`已为「${record.deviceName}」重新生成二维码`),
}] : []),
{
key: 'edit',
icon: <FormOutlined />,
label: '编辑',
onClick: () => { setEditingDevice(record); setDefaultType(record.type); setDeviceModalOpen(true); },
},
{ type: 'divider' as const },
{
key: 'delete',
icon: <DeleteOutlined />,
label: '删除',
danger: true,
onClick: () => {
Modal.confirm({
title: '确认删除', content: `确定删除「${record.deviceName}」吗?`,
onOk: async () => {
const res = await deleteDevice(record.id);
if (res.code === 200) { message.success('删除成功'); type === 'mobile_terminal' ? mobileActionRef.current?.reload() : qrActionRef.current?.reload(); }
},
});
},
},
];
return (
<Dropdown menu={{ items: menuItems }} trigger={['click']}>
<Button type="link" size="small" icon={<EllipsisOutlined />}></Button>
</Dropdown>
);
},
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={24}>
<Card
title="移动终端设备"
extra={
<Button type="primary" size="small" icon={<PlusOutlined />}
onClick={() => { setEditingDevice(null); setDefaultType('mobile_terminal'); setDeviceModalOpen(true); }}
></Button>
}
bodyStyle={{ padding: 0 }}
>
<ProTable
actionRef={mobileActionRef}
rowKey="id"
search={false}
options={false}
pagination={{ pageSize: 5 }}
columns={deviceColumns('mobile_terminal')}
request={async (params) => {
const res = await getDeviceList(fairId, { current: params.current, pageSize: params.pageSize, type: 'mobile_terminal' });
return { data: res.rows, total: res.total, success: true };
}}
/>
</Card>
</Col>
<Col span={24}>
<Card
title="展位二维码"
extra={
<Button type="primary" size="small" icon={<PlusOutlined />}
onClick={() => { setEditingDevice(null); setDefaultType('qr_sticker'); setDeviceModalOpen(true); }}
></Button>
}
bodyStyle={{ padding: 0 }}
>
<ProTable
actionRef={qrActionRef}
rowKey="id"
search={false}
options={false}
pagination={{ pageSize: 5 }}
columns={deviceColumns('qr_sticker')}
request={async (params) => {
const res = await getDeviceList(fairId, { current: params.current, pageSize: params.pageSize, type: 'qr_sticker' });
return { data: res.rows, total: res.total, success: true };
}}
/>
</Card>
</Col>
</Row>
{/* 闸机管理 */}
<Card title="闸机管理" style={{ marginBottom: 16 }}>
<Row gutter={16} style={{ marginBottom: 16 }}>
{gateStatuses.map((gate: any) => (
<Col span={12} key={gate.id}>
<Card size="small">
<Space direction="vertical" style={{ width: '100%' }}>
<Space align="center">
<span style={{ fontWeight: 'bold' }}>{gate.gateName}</span>
<Switch checked={gate.isOpen} onChange={() => handleToggleGate(gate.id)}
checkedChildren="开" unCheckedChildren="关" />
<Tag color={gate.isOpen ? 'green' : 'default'}>{gate.isOpen ? '已开启' : '已关闭'}</Tag>
</Space>
<Statistic title="累计通过人数" value={gate.totalScans} />
<Button type="primary" size="small" icon={<ScanOutlined />}
disabled={!gate.isOpen}
onClick={() => { setScanGate(gate); setScanModalOpen(true); }}
></Button>
</Space>
</Card>
</Col>
))}
</Row>
<ProTable
headerTitle="入场记录"
actionRef={gateLogActionRef}
rowKey="id"
search={false}
options={false}
pagination={{ pageSize: 8 }}
columns={[
{ title: '人员姓名', dataIndex: 'attendeeName' },
{ title: '通过时间', dataIndex: 'scanTime', valueType: 'dateTime', width: 170 },
{ title: '闸机名称', dataIndex: 'gateName', width: 130 },
{ title: '扫码方式', dataIndex: 'scanMethod', width: 100,
render: (_, r) => r.scanMethod === 'qr' ? '二维码' : '身份证' },
]}
request={async (params) => {
const res = await getGateEntryLogs(fairId, { current: params.current, pageSize: params.pageSize });
return { data: res.rows, total: res.total, success: true };
}}
/>
</Card>
{/* 新增/编辑设备 */}
<ModalForm
title={editingDevice ? '编辑设备' : '新增设备'}
open={deviceModalOpen}
width={450}
modalProps={{ destroyOnClose: true, onCancel: () => setDeviceModalOpen(false) }}
initialValues={editingDevice || { type: defaultType }}
onFinish={async (values) => {
const res = editingDevice
? await updateDevice({ id: editingDevice.id, ...values })
: await addDevice({ fairId, ...values });
if (res.code === 200) { message.success(res.msg); setDeviceModalOpen(false); mobileActionRef.current?.reload(); qrActionRef.current?.reload(); return true; }
message.error(res.msg); return false;
}}
>
<ProFormSelect name="type" label="设备类型" width="md"
options={[{ label: '移动终端', value: 'mobile_terminal' }, { label: '展位二维码', value: 'qr_sticker' }]}
disabled={!!editingDevice}
rules={[{ required: true }]}
/>
<ProFormText name="deviceName" label="设备名称" rules={[{ required: true }]} placeholder="如:移动终端-01" />
<ProFormText name="deviceCode" label="设备编号" rules={[{ required: true }]} placeholder="如MT-20260315-001" />
</ModalForm>
{/* 分配展位 */}
<ModalForm
title={`分配展位 — ${assigningDevice?.deviceName || ''}`}
open={assignModalOpen}
width={500}
modalProps={{ destroyOnClose: true, onCancel: () => setAssignModalOpen(false) }}
onFinish={async (values) => {
if (!assigningDevice) return;
const booth = (await getBoothList(fairId, { current: 1, pageSize: 100 })).rows.find((b: any) => b.id === values.boothId);
const res = await assignDevice(assigningDevice.id, values.boothId, booth?.boothNumber || '', booth?.companyName || '');
if (res.code === 200) { message.success(res.msg); setAssignModalOpen(false); mobileActionRef.current?.reload(); qrActionRef.current?.reload(); return true; }
message.error(res.msg); return false;
}}
>
<ProFormSelect name="boothId" label="选择展位" width="md"
rules={[{ required: true, message: '请选择展位' }]}
request={async () => {
const res = await getBoothList(fairId);
return res.rows.map((b: any) => ({ label: `${b.boothNumber} (${b.companyName || '未分配'})`, value: b.id }));
}}
placeholder="请搜索并选择展位"
/>
</ModalForm>
{/* 闸机扫码 */}
<ModalForm
title={`闸机扫码 — ${scanGate?.gateName || ''}`}
open={scanModalOpen}
width={400}
modalProps={{ destroyOnClose: true, onCancel: () => setScanModalOpen(false) }}
onFinish={async (values) => {
if (!scanGate) return;
const res = await simulateGateScan({ fairId, gateName: scanGate.gateName, ...values });
if (res.code === 200) { message.success(res.msg); setScanModalOpen(false); gateLogActionRef.current?.reload(); fetchGateStatuses(); return true; }
message.error(res.msg); return false;
}}
>
<ProFormText name="attendeeName" label="人员姓名" rules={[{ required: true }]} placeholder="请输入姓名" />
<ProFormSelect name="scanMethod" label="扫码方式" initialValue="qr"
options={[{ label: '二维码扫码', value: 'qr' }, { label: '身份证刷卡', value: 'id_card' }]}
rules={[{ required: true }]}
/>
</ModalForm>
</div>
);
};
export default DeviceTab;

View File

@@ -1,54 +1,43 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { history, useAccess } from '@umijs/max';
import { Button, Card, Descriptions, List, Tag, message, Modal, Space } from 'antd';
import { ActionType, ProTable } from '@ant-design/pro-components';
import { PlusOutlined, DeleteOutlined, FormOutlined } from '@ant-design/icons';
import { Button, Card, Descriptions, Image, Tag, message } from 'antd';
import { FormOutlined } from '@ant-design/icons';
import type { OutdoorFairDictForm, OutdoorFairItem } from '@/services/jobportal/outdoorFair';
import { addOutdoorFairDictOption, updateOutdoorFair } from '@/services/jobportal/outdoorFair';
import { getVenueInfoList } from '@/services/jobportal/venueInfo';
import { getDictSelectOption } from '@/services/system/dict';
import {
getParticipatingCompanies,
addParticipatingCompany,
removeParticipatingCompany,
addJobForCompany,
updateJobForCompany,
removeJobFromCompany,
} from '@/services/jobportal/outdoorFairDetail';
import { getVenueInfo, getVenueInfoList } from '@/services/jobportal/venueInfo';
import type { VenueInfoItem } from '@/services/jobportal/venueInfo';
import { getDictSelectOption, getDictValueEnum } from '@/services/system/dict';
import EditModal from '@/pages/Jobfair/Outdoorfair/components/EditModal';
import CompanyAddModal from './CompanyAddModal';
import JobEditModal from './JobEditModal';
const OUTDOOR_FAIR_TYPE_DICT = 'outdoor_fair_type';
const OUTDOOR_FAIR_REGION_DICT = 'outdoor_fair_region';
const VENUE_INFO_TYPE_DICT = 'venue_info_type';
interface Props {
fairId: number;
fairInfo: OutdoorFairItem;
refreshKey?: number;
onFairUpdate: () => void;
}
const FairInfoTab: React.FC<Props> = ({ fairId, fairInfo, refreshKey, onFairUpdate }) => {
const FairInfoTab: React.FC<Props> = ({ fairInfo, onFairUpdate }) => {
const access = useAccess();
const actionRef = useRef<ActionType>();
const [editModalOpen, setEditModalOpen] = useState(false);
const [companyModalOpen, setCompanyModalOpen] = useState(false);
const [jobModalOpen, setJobModalOpen] = useState(false);
const [selectedCompany, setSelectedCompany] = useState<any>(null);
const [editingJob, setEditingJob] = useState<any>(null);
const [fairTypeOptions, setFairTypeOptions] = useState<any[]>([]);
const [regionOptions, setRegionOptions] = useState<any[]>([]);
const [venueOptions, setVenueOptions] = useState<any[]>([]);
const [venueDetail, setVenueDetail] = useState<VenueInfoItem>();
const [venueTypeValueEnum, setVenueTypeValueEnum] = useState<Record<string, any>>({});
const refreshEditModalOptions = useCallback(async () => {
const [typeOptions, regionSelectOptions, venueRes] = await Promise.all([
const [typeOptions, regionSelectOptions, venueRes, venueTypeValueEnum] = await Promise.all([
getDictSelectOption(OUTDOOR_FAIR_TYPE_DICT),
getDictSelectOption(OUTDOOR_FAIR_REGION_DICT),
getVenueInfoList({ current: 1, pageSize: 999 }),
getDictValueEnum(VENUE_INFO_TYPE_DICT),
]);
setFairTypeOptions(typeOptions);
setRegionOptions(regionSelectOptions);
setVenueTypeValueEnum(venueTypeValueEnum);
if (venueRes.code === 200) {
setVenueOptions(
(venueRes.rows || []).map((item) => ({
@@ -65,9 +54,26 @@ const FairInfoTab: React.FC<Props> = ({ fairId, fairInfo, refreshKey, onFairUpda
refreshEditModalOptions();
}, [refreshEditModalOptions]);
// 拉取绑定场地的详细信息
useEffect(() => {
actionRef.current?.reload();
}, [refreshKey]);
const fetchVenueDetail = async () => {
if (!fairInfo.venueId) {
setVenueDetail(undefined);
return;
}
try {
const res = await getVenueInfo(fairInfo.venueId);
if (res.code === 200 && res.data) {
setVenueDetail(res.data);
} else {
setVenueDetail(undefined);
}
} catch {
setVenueDetail(undefined);
}
};
fetchVenueDetail();
}, [fairInfo.venueId]);
const handleCreateDictOption = async (values: OutdoorFairDictForm) => {
const res = await addOutdoorFairDictOption(values);
@@ -91,8 +97,7 @@ const FairInfoTab: React.FC<Props> = ({ fairId, fairInfo, refreshKey, onFairUpda
};
return (
<div>
{/* 基本信息卡片 */}
<>
<Card
title="基本信息"
extra={
@@ -106,7 +111,6 @@ const FairInfoTab: React.FC<Props> = ({ fairId, fairInfo, refreshKey, onFairUpda
</Button>
}
style={{ marginBottom: 16 }}
>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="招聘会标题" span={3}>
@@ -141,171 +145,66 @@ const FairInfoTab: React.FC<Props> = ({ fairId, fairInfo, refreshKey, onFairUpda
<Descriptions.Item label="线上申请">
{fairInfo.onlineApply ? '是' : '否'}
</Descriptions.Item>
<Descriptions.Item label="招聘会照片" span={3}>
{fairInfo.photoUrl ? (
<Image
src={fairInfo.photoUrl}
alt={fairInfo.title}
width={240}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
) : (
'--'
)}
</Descriptions.Item>
</Descriptions>
</Card>
{/* 参会企业及岗位 */}
<Card
title="参会企业及岗位"
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCompanyModalOpen(true)}>
</Button>
}
>
<ProTable
actionRef={actionRef}
rowKey="id"
search={false}
options={false}
pagination={{ pageSize: 10 }}
request={async (params) => {
const res = await getParticipatingCompanies(fairId, {
current: params.current,
pageSize: params.pageSize,
companyName: params.companyName,
});
return { data: res.rows, total: res.total, success: true };
}}
expandable={{
expandedRowRender: (record) => (
<div style={{ padding: '0 24px' }}>
{record.jobList.length === 0 ? (
<p style={{ color: '#999' }}></p>
) : (
<List
dataSource={record.jobList}
renderItem={(job: any) => (
<List.Item
actions={[
<Button
key="edit"
type="link"
size="small"
onClick={() => {
setEditingJob(job);
setSelectedCompany(record);
setJobModalOpen(true);
}}
>
</Button>,
<Button
key="delete"
type="link"
size="small"
danger
onClick={() => {
Modal.confirm({
title: '确认删除',
content: `确定删除岗位「${job.jobTitle}」吗?`,
onOk: async () => {
const res = await removeJobFromCompany(job.id);
if (res.code === 200) {
message.success('删除成功');
actionRef.current?.reload();
}
},
});
}}
>
</Button>,
]}
>
<List.Item.Meta
title={job.jobTitle}
description={
<Space>
<Tag color="blue">
{job.minSalary}-{job.maxSalary}K
</Tag>
<Tag>{job.education}</Tag>
<Tag>{job.experience}</Tag>
<Tag> {job.vacancies} </Tag>
<Tag color={job.status === 'active' ? 'green' : 'red'}>
{job.status === 'active' ? '在招' : '已关闭'}
</Tag>
</Space>
}
/>
</List.Item>
)}
/>
)}
</div>
),
}}
columns={[
{ title: '企业名称', dataIndex: 'companyName', ellipsis: true },
{
title: '行业',
dataIndex: 'industry',
width: 100,
hideInSearch: true,
render: (_, r: any) => <Tag>{r.industry}</Tag>,
},
{ title: '规模', dataIndex: 'scale', width: 110, hideInSearch: true },
{ title: '联系人', dataIndex: 'contactPerson', width: 100, hideInSearch: true },
{ title: '联系电话', dataIndex: 'contactPhone', width: 130, hideInSearch: true },
{
title: '展位号',
dataIndex: 'boothNumber',
width: 80,
hideInSearch: true,
render: (_, r: any) => r.boothNumber || '--',
},
{
title: '岗位数',
width: 80,
hideInSearch: true,
render: (_, r: any) => r.jobList?.length || 0,
},
{
title: '操作',
valueType: 'option',
width: 200,
render: (_, record: any) => [
<Button
key="addJob"
type="link"
size="small"
onClick={() => {
setEditingJob(null);
setSelectedCompany(record);
setJobModalOpen(true);
}}
>
</Button>,
<Button
key="remove"
type="link"
size="small"
danger
onClick={() => {
Modal.confirm({
title: '确认移除',
content: `确定移除「${record.companyName}」吗?`,
onOk: async () => {
const res = await removeParticipatingCompany(record.id, fairId);
if (res.code === 200) {
message.success('移除成功');
actionRef.current?.reload();
}
},
});
}}
>
</Button>,
],
},
]}
/>
<Card title="场地信息" style={{ marginTop: 16 }}>
{venueDetail ? (
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="场地名称" span={3}>
{venueDetail.venueName || '--'}
</Descriptions.Item>
<Descriptions.Item label="场地类型">
{venueTypeValueEnum[venueDetail.venueType]?.text ||
venueTypeValueEnum[venueDetail.venueType]?.label ||
venueDetail.venueType ||
'--'}
</Descriptions.Item>
<Descriptions.Item label="场地面积">
{venueDetail.venueArea != null ? `${venueDetail.venueArea}` : '--'}
</Descriptions.Item>
<Descriptions.Item label="展位数量">
{venueDetail.floorCount ?? '--'}
</Descriptions.Item>
<Descriptions.Item label="场地地址" span={2}>
{venueDetail.venueAddress || '--'}
</Descriptions.Item>
<Descriptions.Item label="联系电话">
{venueDetail.contactPhone || '--'}
</Descriptions.Item>
<Descriptions.Item label="乘坐线路" span={3}>
{venueDetail.routeLineNames || '--'}
</Descriptions.Item>
<Descriptions.Item label="经办日期">
{venueDetail.handleDate || '--'}
</Descriptions.Item>
<Descriptions.Item label="经办机构">
{venueDetail.handleOrg || '--'}
</Descriptions.Item>
<Descriptions.Item label="经办人">
{venueDetail.handler || '--'}
</Descriptions.Item>
<Descriptions.Item label="备注" span={3}>
{venueDetail.remark || '--'}
</Descriptions.Item>
</Descriptions>
) : (
<div style={{ color: '#999' }}></div>
)}
</Card>
{/* 编辑招聘会基本信息弹窗 */}
<EditModal
open={editModalOpen}
values={fairInfo}
@@ -325,36 +224,7 @@ const FairInfoTab: React.FC<Props> = ({ fairId, fairInfo, refreshKey, onFairUpda
}
}}
/>
{/* 添加企业弹窗 */}
<CompanyAddModal
open={companyModalOpen}
fairId={fairId}
onCancel={() => setCompanyModalOpen(false)}
onSuccess={() => {
setCompanyModalOpen(false);
actionRef.current?.reload();
}}
/>
{/* 添加/编辑岗位弹窗 */}
<JobEditModal
open={jobModalOpen}
fairId={fairId}
company={selectedCompany}
job={editingJob}
onCancel={() => {
setJobModalOpen(false);
setEditingJob(null);
setSelectedCompany(null);
}}
onSuccess={() => {
setJobModalOpen(false);
setEditingJob(null);
actionRef.current?.reload();
}}
/>
</div>
</>
);
};

View File

@@ -1,26 +1,54 @@
import React, { useEffect } from 'react';
import { ModalForm, ProForm, ProFormText, ProFormDigit, ProFormSelect } from '@ant-design/pro-components';
import { Form } from 'antd';
import {
ModalForm,
ProForm,
ProFormDigit,
ProFormSelect,
ProFormText,
ProFormTextArea,
} from '@ant-design/pro-components';
import { Button, Form, Input, message } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { DictValueEnumObj } from '@/components/DictTag';
import { addJobForCompany, updateJobForCompany } from '@/services/jobportal/outdoorFairDetail';
interface Props {
open: boolean;
fairId: number;
company: any;
job: any;
company: API.OutdoorFairDetail.ParticipatingCompany | null;
job: API.OutdoorFairDetail.PostedJob | null;
educationEnum: DictValueEnumObj;
experienceEnum: DictValueEnumObj;
areaEnum: DictValueEnumObj;
jobTypeEnum: DictValueEnumObj;
onCancel: () => void;
onSuccess: () => void;
}
const JobEditModal: React.FC<Props> = ({ open, fairId, company, job, onCancel, onSuccess }) => {
const JobEditModal: React.FC<Props> = ({
open,
fairId,
company,
job,
educationEnum,
experienceEnum,
areaEnum,
jobTypeEnum,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
useEffect(() => {
if (open) {
form.resetFields();
if (job) {
form.setFieldsValue(job);
}
if (!open) return;
form.resetFields();
if (job) {
form.setFieldsValue({
...job,
jobLocationAreaCode: String(job.jobLocationAreaCode || ''),
});
} else {
form.setFieldsValue({ isPublish: 1, jobContactList: [{}] });
}
}, [open, job, form]);
@@ -29,35 +57,156 @@ const JobEditModal: React.FC<Props> = ({ open, fairId, company, job, onCancel, o
title={job ? '编辑岗位' : `为「${company?.companyName || ''}」添加岗位`}
form={form}
open={open}
width={500}
modalProps={{ destroyOnClose: true, onCancel }}
width={900}
grid
rowProps={{ gutter: [16, 16] }}
colProps={{ span: 12 }}
modalProps={{
destroyOnClose: true,
onCancel,
style: { height: '80vh' },
styles: {
body: {
height: 'calc(80vh - 120px)',
overflowY: 'auto',
padding: '16px 24px',
},
},
}}
onFinish={async (values) => {
if (job) {
const res = await updateJobForCompany({ ...job, ...values });
if (res.code === 200) { onSuccess(); return true; }
} else if (company) {
const res = await addJobForCompany({ fairId, companyId: company.id, ...values });
if (res.code === 200) { onSuccess(); return true; }
const payload = {
...values,
companyId: company?.companyId,
companyName: company?.companyName,
jobContactList: values.jobContactList || [],
};
const res = job
? await updateJobForCompany(fairId, { ...job, ...payload })
: await addJobForCompany({ fairId, companyId: company!.companyId, ...payload });
if (res.code === 200) {
message.success(job ? '岗位修改成功' : '岗位添加成功');
onSuccess();
return true;
}
message.error(res.msg || '操作失败');
return false;
}}
>
<ProFormText name="jobTitle" label="岗位名称" rules={[{ required: true, message: '请输入岗位名称' }]} placeholder="请输入岗位名称" />
<ProForm.Group>
<ProFormDigit name="minSalary" label="最低薪资(K)" width="sm" min={0} rules={[{ required: true }]} fieldProps={{ precision: 0 }} />
<ProFormDigit name="maxSalary" label="最高薪资(K)" width="sm" min={0} rules={[{ required: true }]} fieldProps={{ precision: 0 }} />
</ProForm.Group>
<ProForm.Group>
<ProFormSelect name="education" label="学历要求" width="sm"
options={['高中', '大专', '本科', '硕士', '博士', '不限'].map(v => ({ label: v, value: v }))}
rules={[{ required: true }]}
/>
<ProFormSelect name="experience" label="经验要求" width="sm"
options={['不限', '应届生', '1-3年', '3-5年', '5-10年', '10年以上'].map(v => ({ label: v, value: v }))}
rules={[{ required: true }]}
/>
</ProForm.Group>
<ProFormDigit name="vacancies" label="招聘人数" width="md" min={1} rules={[{ required: true }]} fieldProps={{ precision: 0 }} />
<ProFormText
name="jobTitle"
label="岗位名称"
placeholder="请输入岗位名称"
rules={[{ required: true, message: '请输入岗位名称' }]}
/>
<ProFormDigit
name="minSalary"
min={0}
label="最小薪资(元/月)"
placeholder="请输入最小薪资"
rules={[{ required: true, message: '请输入最小薪资' }]}
/>
<ProFormDigit
name="maxSalary"
min={0}
label="最大薪资(元/月)"
placeholder="请输入最大薪资"
rules={[{ required: true, message: '请输入最大薪资' }]}
/>
<ProFormSelect
name="education"
label="学历要求"
valueEnum={educationEnum}
placeholder="请选择学历要求"
rules={[{ required: true, message: '请选择学历要求' }]}
/>
<ProFormSelect
name="experience"
label="工作经验"
valueEnum={experienceEnum}
placeholder="请选择工作经验"
rules={[{ required: true, message: '请选择工作经验' }]}
/>
<ProFormSelect
name="jobLocationAreaCode"
label="工作区县"
valueEnum={areaEnum}
placeholder="请选择区县"
rules={[{ required: true, message: '请选择区县' }]}
/>
<ProFormDigit
name="vacancies"
min={0}
label="招聘人数"
placeholder="请输入招聘人数"
rules={[{ required: true, message: '请输入招聘人数' }]}
/>
<ProFormSelect
name="jobType"
label="岗位类型"
valueEnum={jobTypeEnum}
placeholder="请选择岗位类型"
rules={[{ required: true, message: '请选择岗位类型' }]}
/>
<ProFormText name="jobLocation" label="工作地点" placeholder="请输入工作地点" />
<ProFormTextArea
name="description"
label="岗位描述"
placeholder="请输入岗位描述"
colProps={{ span: 24 }}
/>
<div style={{ width: '100%', marginBottom: 16, gridColumn: '1 / -1' }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Form.List name="jobContactList">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<div
key={key}
style={{ display: 'flex', width: '100%', marginBottom: 8, alignItems: 'center', gap: 8 }}
>
<Form.Item
{...restField}
name={[name, 'contactPerson']}
label="联系人姓名"
rules={[{ required: true, message: '请输入联系人姓名' }]}
style={{ marginBottom: 0, flex: 1 }}
>
<Input placeholder="请输入联系人姓名" />
</Form.Item>
<Form.Item
{...restField}
name={[name, 'contactPersonPhone']}
label="联系电话"
rules={[
{ required: true, message: '请输入联系电话' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
]}
style={{ marginBottom: 0, flex: 1 }}
>
<Input placeholder="请输入联系电话" />
</Form.Item>
<Form.Item
{...restField}
name={[name, 'position']}
label="职务"
rules={[{ required: true, message: '请输入职务' }]}
style={{ marginBottom: 0, flex: 1 }}
>
<Input placeholder="请输入职务" />
</Form.Item>
<Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => remove(name)} />
</div>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
</Button>
</Form.Item>
</>
)}
</Form.List>
</div>
</ModalForm>
);
};

View File

@@ -0,0 +1,313 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Modal, Tag, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { ProTable } from '@ant-design/pro-components';
import { getDictValueEnum } from '@/services/system/dict';
import {
getParticipatingCompanies,
getParticipatingJobDetail,
getParticipatingJobs,
removeJobFromCompany,
removeParticipatingCompany,
} from '@/services/jobportal/outdoorFairDetail';
import CompanyAddModal from './CompanyAddModal';
import JobEditModal from './JobEditModal';
interface Props {
fairId: number;
}
const ParticipatingCompaniesTab: React.FC<Props> = ({ fairId }) => {
const companyActionRef = useRef<ActionType>();
const jobActionRef = useRef<ActionType>();
const [companyModalOpen, setCompanyModalOpen] = useState(false);
const [jobListModalOpen, setJobListModalOpen] = useState(false);
const [jobEditModalOpen, setJobEditModalOpen] = useState(false);
const [selectedCompany, setSelectedCompany] =
useState<API.OutdoorFairDetail.ParticipatingCompany | null>(null);
const [editingJob, setEditingJob] = useState<API.OutdoorFairDetail.PostedJob | null>(null);
const [educationEnum, setEducationEnum] = useState<any>({});
const [experienceEnum, setExperienceEnum] = useState<any>({});
const [areaEnum, setAreaEnum] = useState<any>({});
const [jobTypeEnum, setJobTypeEnum] = useState<any>({});
const [scaleEnum, setScaleEnum] = useState<any>({});
useEffect(() => {
Promise.all([
getDictValueEnum('education', true, true),
getDictValueEnum('experience', true, true),
getDictValueEnum('area', true, true),
getDictValueEnum('job_type', true, true),
getDictValueEnum('scale', true, true),
]).then(([education, experience, area, jobType, scale]) => {
setEducationEnum(education);
setExperienceEnum(experience);
setAreaEnum(area);
setJobTypeEnum(jobType);
setScaleEnum(scale);
});
}, []);
const openJobEditor = async (job?: API.OutdoorFairDetail.PostedJob) => {
if (job?.jobId) {
const res = await getParticipatingJobDetail(fairId, job.jobId);
if (res.code !== 200) {
message.error(res.msg || '岗位详情加载失败');
return;
}
setEditingJob(res.data);
} else {
setEditingJob(null);
}
setJobEditModalOpen(true);
};
const jobColumns: ProColumns<API.OutdoorFairDetail.PostedJob>[] = [
{ title: '岗位名称', dataIndex: 'jobTitle', ellipsis: true },
{
title: '薪资范围',
hideInSearch: true,
render: (_, record) => `${record.minSalary}-${record.maxSalary} 元/月`,
},
{
title: '学历要求',
dataIndex: 'education',
valueType: 'select',
valueEnum: educationEnum,
width: 110,
},
{
title: '工作经验',
dataIndex: 'experience',
valueType: 'select',
valueEnum: experienceEnum,
width: 110,
},
{ title: '招聘人数', dataIndex: 'vacancies', hideInSearch: true, width: 90 },
{
title: '发布状态',
dataIndex: 'isPublish',
hideInSearch: true,
width: 90,
render: (_, record) => (
<Tag color={record.isPublish === 1 ? 'green' : 'red'}>
{record.isPublish === 1 ? '已发布' : '未发布'}
</Tag>
),
},
{
title: '操作',
valueType: 'option',
width: 130,
render: (_, record) => [
<Button key="edit" type="link" size="small" onClick={() => openJobEditor(record)}>
</Button>,
<Button
key="delete"
type="link"
size="small"
danger
onClick={() =>
Modal.confirm({
title: '确认删除',
content: `确定删除岗位「${record.jobTitle}」吗?`,
onOk: async () => {
const res = await removeJobFromCompany(fairId, record.jobId!);
if (res.code === 200) {
message.success('删除成功');
jobActionRef.current?.reload();
companyActionRef.current?.reload();
}
},
})
}
>
</Button>,
],
},
];
return (
<>
<ProTable<API.OutdoorFairDetail.ParticipatingCompany>
actionRef={companyActionRef}
rowKey="id"
headerTitle="参会企业"
search={{ labelWidth: 'auto' }}
options={false}
pagination={{ pageSize: 10 }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCompanyModalOpen(true)}>
</Button>,
]}
request={async (params) => {
const res = await getParticipatingCompanies(fairId, {
current: params.current,
pageSize: params.pageSize,
companyName: params.companyName,
});
return { data: res.rows || [], total: res.total || 0, success: res.code === 200 };
}}
columns={[
{ title: '企业名称', dataIndex: 'companyName', ellipsis: true },
{
title: '行业',
dataIndex: 'industry',
hideInSearch: true,
render: (_, record) => {
const v = record.industry;
if (!v) return '--';
// 行业分类 id 均为 6 位编号,纯数字裸码(如 4、6无对应分类项兜底为“其他”
if (/^\d+$/.test(String(v))) return <Tag></Tag>;
return <Tag>{v}</Tag>;
},
},
{
title: '规模',
dataIndex: 'scale',
hideInSearch: true,
render: (_, record) => {
const v = record.scale;
if (v === undefined || v === null || v === '') return '其他';
// 规模为字典码,渲染为标签;匹配不到时兜底“其他”,不显示裸数字
const label = scaleEnum[v]?.label || scaleEnum[v]?.text;
return label ? <Tag>{label}</Tag> : '其他';
},
},
{ title: '联系人', dataIndex: 'contactPerson', hideInSearch: true },
{ title: '联系电话', dataIndex: 'contactPhone', hideInSearch: true },
{
title: '展位号',
dataIndex: 'boothNumber',
hideInSearch: true,
render: (_, record) => record.boothNumber || '--',
},
{ title: '岗位数', dataIndex: 'jobCount', hideInSearch: true, width: 80 },
{
title: '操作',
valueType: 'option',
width: 220,
render: (_, record) => [
<Button
key="jobs"
type="link"
size="small"
onClick={() => {
setSelectedCompany(record);
setJobListModalOpen(true);
}}
>
</Button>,
<Button
key="addJob"
type="link"
size="small"
onClick={() => {
setSelectedCompany(record);
openJobEditor();
}}
>
</Button>,
<Button
key="remove"
type="link"
size="small"
danger
onClick={() =>
Modal.confirm({
title: '确认移除',
content: `确定移除「${record.companyName}」吗?`,
onOk: async () => {
const res = await removeParticipatingCompany(record.id, fairId);
if (res.code === 200) {
message.success('移除成功');
companyActionRef.current?.reload();
}
},
})
}
>
</Button>,
],
},
]}
/>
<Modal
title={selectedCompany ? `${selectedCompany.companyName} — 发布岗位` : '企业岗位'}
open={jobListModalOpen}
width={1100}
footer={null}
destroyOnClose
onCancel={() => setJobListModalOpen(false)}
>
{selectedCompany && (
<ProTable<API.OutdoorFairDetail.PostedJob>
actionRef={jobActionRef}
rowKey="jobId"
search={{ labelWidth: 'auto' }}
options={false}
pagination={{ pageSize: 10 }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => openJobEditor()}>
</Button>,
]}
request={async (params) => {
const res = await getParticipatingJobs(fairId, {
companyId: selectedCompany.companyId,
current: params.current,
pageSize: params.pageSize,
jobTitle: params.jobTitle,
education: params.education,
experience: params.experience,
});
return { data: res.rows || [], total: res.total || 0, success: res.code === 200 };
}}
columns={jobColumns}
/>
)}
</Modal>
<CompanyAddModal
open={companyModalOpen}
fairId={fairId}
onCancel={() => setCompanyModalOpen(false)}
onSuccess={() => {
setCompanyModalOpen(false);
companyActionRef.current?.reload();
}}
/>
<JobEditModal
open={jobEditModalOpen}
fairId={fairId}
company={selectedCompany}
job={editingJob}
educationEnum={educationEnum}
experienceEnum={experienceEnum}
areaEnum={areaEnum}
jobTypeEnum={jobTypeEnum}
onCancel={() => {
setJobEditModalOpen(false);
setEditingJob(null);
}}
onSuccess={() => {
setJobEditModalOpen(false);
setEditingJob(null);
jobActionRef.current?.reload();
companyActionRef.current?.reload();
}}
/>
</>
);
};
export default ParticipatingCompaniesTab;

View File

@@ -0,0 +1,200 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Modal, Tag, message } from 'antd';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { ProTable } from '@ant-design/pro-components';
import { getDictValueEnum } from '@/services/system/dict';
import {
getParticipatingCompanies,
getParticipatingJobDetail,
getParticipatingJobs,
removeJobFromCompany,
} from '@/services/jobportal/outdoorFairDetail';
import JobEditModal from './JobEditModal';
interface Props {
fairId: number;
}
const ParticipatingJobsTab: React.FC<Props> = ({ fairId }) => {
const actionRef = useRef<ActionType>();
const [editingJob, setEditingJob] = useState<API.OutdoorFairDetail.PostedJob | null>(null);
const [editingCompany, setEditingCompany] =
useState<API.OutdoorFairDetail.ParticipatingCompany | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [educationEnum, setEducationEnum] = useState<any>({});
const [experienceEnum, setExperienceEnum] = useState<any>({});
const [areaEnum, setAreaEnum] = useState<any>({});
const [jobTypeEnum, setJobTypeEnum] = useState<any>({});
useEffect(() => {
Promise.all([
getDictValueEnum('education', true, true),
getDictValueEnum('experience', true, true),
getDictValueEnum('area', true, true),
getDictValueEnum('job_type', true, true),
]).then(([education, experience, area, jobType]) => {
setEducationEnum(education);
setExperienceEnum(experience);
setAreaEnum(area);
setJobTypeEnum(jobType);
});
}, []);
const editJob = async (record: API.OutdoorFairDetail.PostedJob) => {
const res = await getParticipatingJobDetail(fairId, record.jobId!);
if (res.code !== 200) {
message.error(res.msg || '岗位详情加载失败');
return;
}
setEditingJob(res.data);
setEditingCompany({
id: 0,
fairId,
companyId: record.companyId,
companyName: record.companyName || '',
industry: '',
scale: '',
contactPerson: '',
contactPhone: '',
jobCount: 0,
});
setModalOpen(true);
};
const columns: ProColumns<API.OutdoorFairDetail.PostedJob>[] = [
{ title: '岗位名称', dataIndex: 'jobTitle', ellipsis: true },
{
title: '企业名称',
dataIndex: 'companyId',
valueType: 'select',
request: async ({ keyWords }) => {
const res = await getParticipatingCompanies(fairId, {
current: 1,
pageSize: 100,
companyName: keyWords,
});
return Array.from(
new Map(
(res.rows || []).map((company) => [
company.companyId,
{ label: company.companyName, value: company.companyId },
]),
).values(),
);
},
fieldProps: {
showSearch: true,
optionFilterProp: 'label',
placeholder: '请选择或搜索企业',
},
render: (_, record) => record.companyName || '--',
ellipsis: true,
},
{
title: '薪资范围',
hideInSearch: true,
render: (_, record) => `${record.minSalary}-${record.maxSalary} 元/月`,
},
{
title: '学历要求',
dataIndex: 'education',
valueType: 'select',
valueEnum: educationEnum,
width: 110,
},
{
title: '工作经验',
dataIndex: 'experience',
valueType: 'select',
valueEnum: experienceEnum,
width: 110,
},
{ title: '招聘人数', dataIndex: 'vacancies', hideInSearch: true, width: 90 },
{ title: '工作地点', dataIndex: 'jobLocation', hideInSearch: true, ellipsis: true },
{
title: '发布状态',
dataIndex: 'isPublish',
hideInSearch: true,
width: 90,
render: (_, record) => (
<Tag color={record.isPublish === 1 ? 'green' : 'red'}>
{record.isPublish === 1 ? '已发布' : '未发布'}
</Tag>
),
},
{
title: '操作',
valueType: 'option',
width: 130,
render: (_, record) => [
<Button key="edit" type="link" size="small" onClick={() => editJob(record)}>
</Button>,
<Button
key="delete"
type="link"
size="small"
danger
onClick={() =>
Modal.confirm({
title: '确认删除',
content: `确定删除岗位「${record.jobTitle}」吗?`,
onOk: async () => {
const res = await removeJobFromCompany(fairId, record.jobId!);
if (res.code === 200) {
message.success('删除成功');
actionRef.current?.reload();
}
},
})
}
>
</Button>,
],
},
];
return (
<>
<ProTable<API.OutdoorFairDetail.PostedJob>
actionRef={actionRef}
rowKey="jobId"
headerTitle="参会岗位"
search={{ labelWidth: 'auto', defaultCollapsed: false }}
options={false}
pagination={{ pageSize: 10 }}
request={async (params) => {
const res = await getParticipatingJobs(fairId, {
current: params.current,
pageSize: params.pageSize,
companyId: params.companyId,
jobTitle: params.jobTitle,
education: params.education,
experience: params.experience,
});
return { data: res.rows || [], total: res.total || 0, success: res.code === 200 };
}}
columns={columns}
/>
<JobEditModal
open={modalOpen}
fairId={fairId}
company={editingCompany}
job={editingJob}
educationEnum={educationEnum}
experienceEnum={experienceEnum}
areaEnum={areaEnum}
jobTypeEnum={jobTypeEnum}
onCancel={() => setModalOpen(false)}
onSuccess={() => {
setModalOpen(false);
actionRef.current?.reload();
}}
/>
</>
);
};
export default ParticipatingJobsTab;

View File

@@ -1,108 +0,0 @@
import React from 'react';
import { Card, Col, Row, Statistic, Empty, Spin, Table } from 'antd';
import { TeamOutlined, FileTextOutlined, UserOutlined, LinkOutlined } from '@ant-design/icons';
interface Props {
fairId: number;
}
const StatisticsTab: React.FC<Props> = () => {
// 静态演示数据
const companyCount = 25;
const jobCount = 86;
const demandCount = 520;
const intentionCount = 180;
const dailyData = [
{ key: '1', date: '2026-03-15', count: 320 },
];
const industryData = [
{ key: '1', industry: '化工', count: 5 },
{ key: '2', industry: '政府', count: 2 },
{ key: '3', industry: '农业', count: 4 },
{ key: '4', industry: '医疗', count: 3 },
{ key: '5', industry: '教育', count: 3 },
{ key: '6', industry: '制造', count: 4 },
];
const jobTypeData = [
{ key: '1', type: '技术类', count: 30 },
{ key: '2', type: '管理类', count: 15 },
{ key: '3', type: '销售类', count: 12 },
{ key: '4', type: '行政类', count: 10 },
{ key: '5', type: '一线工人', count: 14 },
{ key: '6', type: '其他', count: 5 },
];
const timeSlotData = [
{ key: '1', slot: '08:00-09:00', count: 80 },
{ key: '2', slot: '09:00-10:00', count: 120 },
{ key: '3', slot: '10:00-11:00', count: 65 },
{ key: '4', slot: '11:00-12:00', count: 30 },
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic title="参会单位数" value={companyCount} prefix={<TeamOutlined />} suffix="家" />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="岗位数量" value={jobCount} prefix={<FileTextOutlined />} suffix="个" />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="需求人数" value={demandCount} prefix={<UserOutlined />} suffix="人" />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="意向达成数" value={intentionCount} prefix={<LinkOutlined />} suffix="人" />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={12}>
<Card title="每日入场人数趋势">
<Table rowKey="key" columns={[
{ title: '日期', dataIndex: 'date' },
{ title: '入场人数', dataIndex: 'count', render: (v: number) => `${v}` },
]} dataSource={dailyData} pagination={false} size="small" />
</Card>
</Col>
<Col span={12}>
<Card title="行业分布">
<Table rowKey="key" columns={[
{ title: '行业', dataIndex: 'industry' },
{ title: '企业数', dataIndex: 'count', render: (v: number) => `${v}` },
]} dataSource={industryData} pagination={false} size="small" />
</Card>
</Col>
<Col span={12}>
<Card title="岗位类型分布">
<Table rowKey="key" columns={[
{ title: '岗位类型', dataIndex: 'type' },
{ title: '数量', dataIndex: 'count', render: (v: number) => `${v}` },
]} dataSource={jobTypeData} pagination={false} size="small" />
</Card>
</Col>
<Col span={12}>
<Card title="时段人流量分布">
<Table rowKey="key" columns={[
{ title: '时段', dataIndex: 'slot' },
{ title: '人数', dataIndex: 'count', render: (v: number) => `${v}` },
]} dataSource={timeSlotData} pagination={false} size="small" />
</Card>
</Col>
</Row>
</div>
);
};
export default StatisticsTab;

View File

@@ -7,10 +7,8 @@ import { getOutdoorFairInfo } from '@/services/jobportal/outdoorFair';
import type { OutdoorFairItem } from '@/services/jobportal/outdoorFair';
import FairInfoTab from './components/FairInfoTab';
import BoothTab from './components/BoothTab';
import CompanyReviewTab from './components/CompanyReviewTab';
import AttendeeTab from './components/AttendeeTab';
import DeviceTab from './components/DeviceTab';
import StatisticsTab from './components/StatisticsTab';
import ParticipatingCompaniesTab from './components/ParticipatingCompaniesTab';
import ParticipatingJobsTab from './components/ParticipatingJobsTab';
const OutdoorFairDetail: React.FC = () => {
const [searchParams] = useSearchParams();
@@ -18,7 +16,6 @@ const OutdoorFairDetail: React.FC = () => {
const [fairInfo, setFairInfo] = useState<OutdoorFairItem | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('fair-info');
const [fairInfoRefreshKey, setFairInfoRefreshKey] = useState(0);
const fetchFairInfo = async () => {
setLoading(true);
@@ -42,18 +39,13 @@ const OutdoorFairDetail: React.FC = () => {
const tabItems = [
{ key: 'fair-info', label: '招聘会管理' },
{ key: 'participating-companies', label: '参会企业' },
{ key: 'participating-jobs', label: '参会岗位' },
{ key: 'booth', label: '展位图' },
{ key: 'company-review', label: '参会单位管理' },
{ key: 'attendee', label: '参会人员管理' },
{ key: 'device', label: '入场设备管理' },
{ key: 'statistics', label: '数据统计' },
];
const handleTabChange = (key: string) => {
setActiveTab(key);
if (key === 'fair-info') {
setFairInfoRefreshKey((prev) => prev + 1);
}
};
return (
@@ -83,20 +75,15 @@ const OutdoorFairDetail: React.FC = () => {
<FairInfoTab
fairId={fairId}
fairInfo={fairInfo}
refreshKey={fairInfoRefreshKey}
onFairUpdate={fetchFairInfo}
/>
);
case 'booth':
return <BoothTab fairId={fairId} fairInfo={fairInfo} />;
case 'company-review':
return <CompanyReviewTab fairId={fairId} />;
case 'attendee':
return <AttendeeTab fairId={fairId} />;
case 'device':
return <DeviceTab fairId={fairId} />;
case 'statistics':
return <StatisticsTab fairId={fairId} />;
case 'participating-companies':
return <ParticipatingCompaniesTab fairId={fairId} />;
case 'participating-jobs':
return <ParticipatingJobsTab fairId={fairId} />;
default:
return null;
}

View File

@@ -125,7 +125,16 @@ const OutdoorFairList: React.FC = () => {
title: '绑定场地',
dataIndex: 'venueName',
ellipsis: true,
hideInSearch: true,
valueType: 'select',
fieldProps: {
options: venueOptions,
allowClear: true,
showSearch: true,
optionFilterProp: 'label',
},
search: {
transform: (value) => ({ venueId: value }),
},
render: (_, record) =>
record.venueId && record.venueName ? (
<Button
@@ -177,7 +186,11 @@ const OutdoorFairList: React.FC = () => {
{
title: '线上申请',
dataIndex: 'onlineApply',
hideInSearch: true,
valueType: 'select',
valueEnum: {
'true': { text: '是' },
'false': { text: '否' },
},
render: (_, record) => (record.onlineApply ? '是' : '否'),
},
{
@@ -255,6 +268,7 @@ const OutdoorFairList: React.FC = () => {
fairType: params.fairType,
region: params.region,
venueId: params.venueId,
onlineApply: params.onlineApply,
} as OutdoorFairListParams);
return { data: res.rows, total: res.total, success: true };
}}

View File

@@ -0,0 +1,175 @@
import React, { useCallback, useEffect, useState } from 'react';
import { history, useAccess, useSearchParams } from '@umijs/max';
import { PageContainer } from '@ant-design/pro-components';
import { Button, Descriptions, Spin, message } from 'antd';
import { ArrowLeftOutlined, FormOutlined } from '@ant-design/icons';
import {
addVenueInfoDictOption,
getVenueInfo,
updateVenueInfo,
} from '@/services/jobportal/venueInfo';
import type { VenueInfoDictForm, VenueInfoForm, VenueInfoItem } from '@/services/jobportal/venueInfo';
import { getCmsLineList } from '@/services/area/subway';
import { getDictSelectOption, getDictValueEnum } from '@/services/system/dict';
import EditModal from '../components/EditModal';
const VENUE_TYPE_DICT = 'venue_info_type';
const VenueInfoDetail: React.FC = () => {
const access = useAccess();
const [searchParams] = useSearchParams();
const venueId = Number(searchParams.get('id'));
const [loading, setLoading] = useState(true);
const [venueInfo, setVenueInfo] = useState<VenueInfoItem>();
const [editModalOpen, setEditModalOpen] = useState(false);
const [venueTypeOptions, setVenueTypeOptions] = useState<any[]>([]);
const [venueTypeValueEnum, setVenueTypeValueEnum] = useState<Record<string, any>>({});
const [lineOptions, setLineOptions] = useState<any[]>([]);
const fetchVenueInfo = useCallback(async () => {
if (!venueId) {
setLoading(false);
return;
}
setLoading(true);
try {
const res = await getVenueInfo(venueId);
if (res.code === 200 && res.data) {
setVenueInfo(res.data);
} else {
message.error(res.msg || '未找到该场地信息');
}
} finally {
setLoading(false);
}
}, [venueId]);
const refreshOptions = useCallback(async () => {
const [valueEnum, options, lineRes] = await Promise.all([
getDictValueEnum(VENUE_TYPE_DICT),
getDictSelectOption(VENUE_TYPE_DICT),
getCmsLineList({ current: '1', pageSize: '999' } as any),
]);
setVenueTypeValueEnum(valueEnum);
setVenueTypeOptions(options);
if (lineRes.code === 200) {
setLineOptions(
(lineRes.rows || []).map((item) => ({
label: item.lineName,
value: String(item.lineId),
})),
);
}
}, []);
useEffect(() => {
fetchVenueInfo();
refreshOptions();
}, [fetchVenueInfo, refreshOptions]);
const handleCreateDictOption = async (values: VenueInfoDictForm) => {
const res = await addVenueInfoDictOption(values);
if (res.code === 200) {
await refreshOptions();
message.success('场地类型新增成功');
return true;
}
message.error(res.msg || '场地类型新增失败');
return false;
};
const venueTypeLabel = (val?: string) => {
if (!val) return '--';
return (
venueTypeValueEnum[val]?.text ||
venueTypeValueEnum[val]?.label ||
val
);
};
return (
<PageContainer
header={{
title: venueInfo ? `场地信息详情 — ${venueInfo.venueName}` : '加载中...',
onBack: () => history.back(),
extra: [
<Button key="back" icon={<ArrowLeftOutlined />} onClick={() => history.back()}>
</Button>,
<Button
key="edit"
type="primary"
icon={<FormOutlined />}
hidden={!access.hasPerms('cms:venueInfo:edit')}
onClick={() => setEditModalOpen(true)}
>
</Button>,
],
}}
>
<Spin spinning={loading}>
{venueInfo ? (
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="场地名称" span={3}>
{venueInfo.venueName || '--'}
</Descriptions.Item>
<Descriptions.Item label="场地类型">
{venueTypeLabel(venueInfo.venueType)}
</Descriptions.Item>
<Descriptions.Item label="场地面积">
{venueInfo.venueArea != null ? `${venueInfo.venueArea}` : '--'}
</Descriptions.Item>
<Descriptions.Item label="展位数量">
{venueInfo.floorCount ?? '--'}
</Descriptions.Item>
<Descriptions.Item label="场地地址" span={2}>
{venueInfo.venueAddress || '--'}
</Descriptions.Item>
<Descriptions.Item label="联系电话">
{venueInfo.contactPhone || '--'}
</Descriptions.Item>
<Descriptions.Item label="乘坐线路" span={3}>
{venueInfo.routeLineNames || '--'}
</Descriptions.Item>
<Descriptions.Item label="经办日期">
{venueInfo.handleDate || '--'}
</Descriptions.Item>
<Descriptions.Item label="经办机构">
{venueInfo.handleOrg || '--'}
</Descriptions.Item>
<Descriptions.Item label="经办人">
{venueInfo.handler || '--'}
</Descriptions.Item>
<Descriptions.Item label="备注" span={3}>
{venueInfo.remark || '--'}
</Descriptions.Item>
</Descriptions>
) : (
!loading && <div style={{ color: '#999' }}></div>
)}
</Spin>
<EditModal
open={editModalOpen}
values={venueInfo}
venueTypeOptions={venueTypeOptions}
lineOptions={lineOptions}
onCreateDictOption={handleCreateDictOption}
onCancel={() => setEditModalOpen(false)}
onSubmit={async (values: VenueInfoForm) => {
const res = await updateVenueInfo(values);
if (res.code === 200) {
message.success('修改成功');
setEditModalOpen(false);
fetchVenueInfo();
} else {
message.error(res.msg || '操作失败');
}
}}
/>
</PageContainer>
);
};
export default VenueInfoDetail;

View File

@@ -42,6 +42,7 @@ export interface OutdoorFairListParams {
fairType?: string;
region?: string;
venueId?: number;
onlineApply?: boolean;
current?: number;
pageSize?: number;
}

View File

@@ -1011,7 +1011,11 @@ function paginate<T>(arr: T[], current: number, pageSize: number) {
/** 获取参会企业列表 */
export async function getParticipatingCompanies(
fairId: number,
params?: { current?: number; pageSize?: number; companyName?: string },
params?: {
current?: number;
pageSize?: number;
companyName?: string;
},
) {
return request<{ code: number; msg?: string; total: number; rows: ParticipatingCompany[] }>(
`/api/cms/outdoor-fair/${fairId}/companies`,
@@ -1022,6 +1026,36 @@ export async function getParticipatingCompanies(
);
}
/** 分页获取招聘会参会岗位,可按企业懒加载。 */
export async function getParticipatingJobs(
fairId: number,
params?: {
current?: number;
pageSize?: number;
companyId?: number;
companyName?: string;
jobTitle?: string;
education?: string;
experience?: string;
},
) {
return request<{ code: number; msg?: string; total: number; rows: PostedJob[] }>(
`/api/cms/outdoor-fair/${fairId}/jobs`,
{
method: 'GET',
params,
},
);
}
/** 获取招聘会岗位详情(编辑时才加载联系人等完整字段)。 */
export async function getParticipatingJobDetail(fairId: number, jobId: number) {
return request<{ code: number; msg?: string; data: PostedJob }>(
`/api/cms/outdoor-fair/${fairId}/jobs/${jobId}`,
{ method: 'GET' },
);
}
/** 添加参会企业 */
export async function addParticipatingCompany(data: {
fairId: number;
@@ -1058,46 +1092,40 @@ export async function removeParticipatingCompany(id: number, fairId?: number) {
export async function addJobForCompany(data: {
fairId: number;
companyId: number;
jobTitle: string;
minSalary: number;
maxSalary: number;
education: string;
experience: string;
vacancies: number;
[key: string]: any;
}) {
const company = MOCK_COMPANIES.find((c) => c.id === data.companyId);
if (!company) return { code: 404, msg: '企业未找到' };
const job: PostedJob = {
...data,
id: nextPostedJobId++,
status: 'active',
};
company.jobList.push(job);
return { code: 200, msg: '岗位添加成功', data: job };
const { fairId, companyId, ...job } = data;
return request<{ code: number; msg?: string; data?: PostedJob }>(
`/api/cms/outdoor-fair/${fairId}/companies/${companyId}/jobs`,
{
method: 'POST',
data: job,
},
);
}
/** 编辑岗位 */
export async function updateJobForCompany(data: PostedJob) {
for (const c of MOCK_COMPANIES) {
const idx = c.jobList.findIndex((j) => j.id === data.id);
if (idx !== -1) {
c.jobList[idx] = { ...c.jobList[idx], ...data };
return { code: 200, msg: '修改成功' };
}
export async function updateJobForCompany(fairId: number, data: PostedJob) {
if (!data.jobId) {
return { code: 400, msg: '缺少岗位ID' };
}
return { code: 404, msg: '未找到' };
return request<{ code: number; msg?: string }>(
`/api/cms/outdoor-fair/${fairId}/jobs/${data.jobId}`,
{
method: 'PUT',
data,
},
);
}
/** 删除岗位 */
export async function removeJobFromCompany(jobId: number) {
for (const c of MOCK_COMPANIES) {
const idx = c.jobList.findIndex((j) => j.id === jobId);
if (idx !== -1) {
c.jobList.splice(idx, 1);
return { code: 200, msg: '删除成功' };
}
}
return { code: 404, msg: '未找到' };
export async function removeJobFromCompany(fairId: number, jobId: number) {
return request<{ code: number; msg?: string }>(
`/api/cms/outdoor-fair/${fairId}/jobs/${jobId}`,
{
method: 'DELETE',
},
);
}
// ==================== 模块2: 展位管理 API ====================

View File

@@ -17,21 +17,32 @@ declare namespace API.OutdoorFairDetail {
contactPhone: string;
boothId?: number;
boothNumber?: string;
jobList: PostedJob[];
jobCount?: number;
/** 仅兼容旧 mock 数据,真实企业接口不再返回岗位列表。 */
jobList?: PostedJob[];
}
/** 已发布岗位 */
export interface PostedJob {
id: number;
fairId: number;
/** 真实岗位主键;旧 mock 数据仅使用 id。 */
jobId?: number;
id?: number;
fairId?: number;
companyId: number;
companyName?: string;
jobTitle: string;
minSalary: number;
maxSalary: number;
education: string;
experience: string;
vacancies: number;
status: string; // 'active' | 'closed'
jobLocationAreaCode?: string;
jobType?: string;
jobLocation?: string;
description?: string;
isPublish?: number;
status?: string;
jobContactList?: API.ManagementList.ContactPerson[];
}
// ==================== 模块2: 展位管理 ====================