feat: Enhance Outdoor Fair and Public Job Fair features

- Added QR code functionality for companies signing up for outdoor fairs.
- Implemented job management for outdoor fairs, including job listing and editing.
- Updated Public Job Fair detail view to reflect new data structure.
- Refactored EditModal to support dynamic lists for host, co-organizer, and organizing units.
- Introduced cross-domain city selection for job fairs.
- Enhanced API services for managing cross-domain jobs and cities.
- Improved data handling for outdoor fair items, including review status and QR code content.
- Updated type definitions to accommodate new fields and structures.
This commit is contained in:
2026-06-27 22:45:16 +08:00
parent 7b9e8426de
commit 9c8bfb617b
16 changed files with 1613 additions and 758 deletions

View File

@@ -6,13 +6,13 @@ import {
ProColumns,
ProTable,
ModalForm,
ProFormDigit,
ProFormText,
ProFormSelect,
} from '@ant-design/pro-components';
import { PlusOutlined, DeleteOutlined, LinkOutlined, DisconnectOutlined } from '@ant-design/icons';
import {
getOutdoorFairDeviceList,
batchGenerateOutdoorFairDevice,
addOutdoorFairDevice,
bindOutdoorFairDevice,
deleteOutdoorFairDevice,
getOutdoorFairCompanyOptions,
@@ -26,7 +26,7 @@ interface Props {
const DeviceTab: React.FC<Props> = ({ fairId }) => {
const access = useAccess();
const actionRef = useRef<ActionType>();
const [batchVisible, setBatchVisible] = useState(false);
const [addVisible, setAddVisible] = useState(false);
const [bindVisible, setBindVisible] = useState(false);
const [currentRow, setCurrentRow] = useState<OutdoorFairDeviceItem>();
@@ -145,13 +145,13 @@ const DeviceTab: React.FC<Props> = ({ fairId }) => {
scroll={{ x: 'max-content' }}
toolBarRender={() => [
<Button
key="batch"
key="add"
type="primary"
icon={<PlusOutlined />}
hidden={!access.hasPerms('cms:outdoorFair:add')}
onClick={() => setBatchVisible(true)}
onClick={() => setAddVisible(true)}
>
</Button>,
]}
request={async (params) => {
@@ -167,34 +167,60 @@ const DeviceTab: React.FC<Props> = ({ fairId }) => {
columns={columns}
/>
<ModalForm<{ quantity: number }>
title="批量生成设备码"
open={batchVisible}
modalProps={{ destroyOnClose: true, onCancel: () => setBatchVisible(false) }}
initialValues={{ quantity: 10 }}
<ModalForm<{ deviceCode: string; companyId?: number }>
title="新增设备码"
open={addVisible}
modalProps={{ destroyOnClose: true, onCancel: () => setAddVisible(false) }}
onFinish={async (values) => {
const res = await batchGenerateOutdoorFairDevice({
const res = await addOutdoorFairDevice({
fairId,
quantity: Number(values.quantity),
deviceCode: values.deviceCode.trim(),
companyId: values.companyId,
});
if (res.code === 200) {
message.success(`成功生成 ${res.data?.length || values.quantity} 个设备码`);
setBatchVisible(false);
message.success('新增成功');
setAddVisible(false);
actionRef.current?.reload();
return true;
}
message.error(res.msg || '生成失败');
message.error(res.msg || '新增失败');
return false;
}}
>
<ProFormDigit
name="quantity"
label="生成数量"
placeholder="请输入需要生成的设备码数量"
min={1}
max={1000}
fieldProps={{ precision: 0 }}
rules={[{ required: true, message: '请输入生成数量' }]}
<ProFormText
name="deviceCode"
label="设备码"
placeholder="请输入设备码"
fieldProps={{ maxLength: 64 }}
rules={[
{ required: true, message: '请输入设备码' },
{ max: 64, message: '设备码长度不能超过64个字符' },
]}
/>
<ProFormSelect
name="companyId"
label="绑定企业"
placeholder="选填,不选则创建未绑定的设备码"
showSearch
allowClear
request={async () => {
const [companies, devicesRes] = await Promise.all([
getOutdoorFairCompanyOptions(fairId),
getOutdoorFairDeviceList({ fairId, current: 1, pageSize: 1000 }),
]);
// 排除已绑定到其他设备的企业
const boundIds = new Set(
(devicesRes.rows || [])
.map((d) => d.companyId)
.filter((cid): cid is number => cid != null),
);
return companies
.filter((item) => !boundIds.has(item.companyId))
.map((item) => ({
label: item.companyName,
value: item.companyId,
}));
}}
/>
</ModalForm>

View File

@@ -10,7 +10,11 @@ import {
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';
import {
addCurrentCompanyJob,
addJobForCompany,
updateJobForCompany,
} from '@/services/jobportal/outdoorFairDetail';
interface Props {
open: boolean;
@@ -21,6 +25,7 @@ interface Props {
experienceEnum: DictValueEnumObj;
areaEnum: DictValueEnumObj;
jobTypeEnum: DictValueEnumObj;
companyScoped?: boolean;
onCancel: () => void;
onSuccess: () => void;
}
@@ -34,6 +39,7 @@ const JobEditModal: React.FC<Props> = ({
experienceEnum,
areaEnum,
jobTypeEnum,
companyScoped,
onCancel,
onSuccess,
}) => {
@@ -82,7 +88,9 @@ const JobEditModal: React.FC<Props> = ({
};
const res = job
? await updateJobForCompany(fairId, { ...job, ...payload })
: await addJobForCompany({ fairId, companyId: company!.companyId, ...payload });
: companyScoped
? await addCurrentCompanyJob({ fairId, ...payload })
: await addJobForCompany({ fairId, companyId: company!.companyId, ...payload });
if (res.code === 200) {
message.success(job ? '岗位修改成功' : '岗位添加成功');
onSuccess();
@@ -163,7 +171,13 @@ const JobEditModal: React.FC<Props> = ({
{fields.map(({ key, name, ...restField }) => (
<div
key={key}
style={{ display: 'flex', width: '100%', marginBottom: 8, alignItems: 'center', gap: 8 }}
style={{
display: 'flex',
width: '100%',
marginBottom: 8,
alignItems: 'center',
gap: 8,
}}
>
<Form.Item
{...restField}
@@ -195,7 +209,12 @@ const JobEditModal: React.FC<Props> = ({
>
<Input placeholder="请输入职务" />
</Form.Item>
<Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => remove(name)} />
<Button
type="text"
danger
icon={<MinusCircleOutlined />}
onClick={() => remove(name)}
/>
</div>
))}
<Form.Item>

View File

@@ -1,15 +1,18 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Modal, Tag, message } from 'antd';
import { Button, Form, Input, Modal, QRCode, Select, Tag, Typography, 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 {
getParticipatingCompanyQrCode,
getParticipatingCompanies,
getParticipatingJobDetail,
getParticipatingJobs,
removeJobFromCompany,
removeParticipatingCompany,
reviewParticipatingCompany,
reviewParticipatingJob,
} from '@/services/jobportal/outdoorFairDetail';
import CompanyAddModal from './CompanyAddModal';
import JobEditModal from './JobEditModal';
@@ -18,20 +21,46 @@ interface Props {
fairId: number;
}
type ReviewTarget =
| {
type: 'company';
record: API.OutdoorFairDetail.ParticipatingCompany;
}
| {
type: 'job';
record: API.OutdoorFairDetail.PostedJob;
};
const ParticipatingCompaniesTab: React.FC<Props> = ({ fairId }) => {
const companyActionRef = useRef<ActionType>();
const jobActionRef = useRef<ActionType>();
const [reviewForm] = Form.useForm();
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 [qrModalOpen, setQrModalOpen] = useState(false);
const [qrInfo, setQrInfo] = useState<API.OutdoorFairDetail.CompanyQrCodeInfo | null>(null);
const [qrCompanyName, setQrCompanyName] = useState('');
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>({});
const [reviewModalOpen, setReviewModalOpen] = useState(false);
const [reviewTarget, setReviewTarget] = useState<ReviewTarget | null>(null);
const renderReviewStatus = (status?: string) => {
const map: Record<string, { color: string; text: string }> = {
'0': { color: 'processing', text: '待审核' },
'1': { color: 'success', text: '已通过' },
'2': { color: 'error', text: '已驳回' },
};
const item = map[status || '1'] || map['1'];
return <Tag color={item.color}>{item.text}</Tag>;
};
useEffect(() => {
Promise.all([
@@ -63,6 +92,67 @@ const ParticipatingCompaniesTab: React.FC<Props> = ({ fairId }) => {
setJobEditModalOpen(true);
};
const openQrCodeModal = async (record: API.OutdoorFairDetail.ParticipatingCompany) => {
const res = await getParticipatingCompanyQrCode(fairId, record.companyId);
if (res.code !== 200) {
message.error(res.msg || '二维码内容获取失败');
return;
}
setQrInfo(res.data);
setQrCompanyName(record.companyName);
setQrModalOpen(true);
};
const openReviewModal = (target: ReviewTarget) => {
setReviewTarget(target);
reviewForm.setFieldsValue({
reviewStatus:
target.type === 'company'
? target.record.reviewStatus || '0'
: target.record.fairReviewStatus || '0',
reviewRemark:
target.type === 'company'
? target.record.reviewRemark || ''
: target.record.fairReviewRemark || '',
});
setReviewModalOpen(true);
};
const submitReview = async () => {
const values = await reviewForm.validateFields();
if (!reviewTarget) return;
const payload = {
reviewStatus: values.reviewStatus as '0' | '1' | '2',
reviewRemark: values.reviewRemark,
};
const res =
reviewTarget.type === 'company'
? await reviewParticipatingCompany({
fairId,
id: reviewTarget.record.id,
...payload,
})
: await reviewParticipatingJob({
fairId,
jobId: reviewTarget.record.jobId!,
...payload,
});
if (res.code === 200) {
message.success('审核成功');
setReviewModalOpen(false);
setReviewTarget(null);
reviewForm.resetFields();
if (reviewTarget.type === 'company') {
companyActionRef.current?.reload();
} else {
jobActionRef.current?.reload();
companyActionRef.current?.reload();
}
} else {
message.error(res.msg || '审核失败');
}
};
const jobColumns: ProColumns<API.OutdoorFairDetail.PostedJob>[] = [
{ title: '岗位名称', dataIndex: 'jobTitle', ellipsis: true },
{
@@ -85,6 +175,13 @@ const ParticipatingCompaniesTab: React.FC<Props> = ({ fairId }) => {
width: 110,
},
{ title: '招聘人数', dataIndex: 'vacancies', hideInSearch: true, width: 90 },
{
title: '审核状态',
dataIndex: 'fairReviewStatus',
hideInSearch: true,
width: 100,
render: (_, record) => renderReviewStatus(record.fairReviewStatus),
},
{
title: '发布状态',
dataIndex: 'isPublish',
@@ -99,11 +196,19 @@ const ParticipatingCompaniesTab: React.FC<Props> = ({ fairId }) => {
{
title: '操作',
valueType: 'option',
width: 130,
width: 240,
render: (_, record) => [
<Button key="edit" type="link" size="small" onClick={() => openJobEditor(record)}>
</Button>,
<Button
key="review"
type="link"
size="small"
onClick={() => openReviewModal({ type: 'job', record })}
>
</Button>,
<Button
key="delete"
type="link"
@@ -140,7 +245,12 @@ const ParticipatingCompaniesTab: React.FC<Props> = ({ fairId }) => {
options={false}
pagination={{ pageSize: 10 }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCompanyModalOpen(true)}>
<Button
key="add"
type="primary"
icon={<PlusOutlined />}
onClick={() => setCompanyModalOpen(true)}
>
</Button>,
]}
@@ -180,6 +290,12 @@ const ParticipatingCompaniesTab: React.FC<Props> = ({ fairId }) => {
},
{ title: '联系人', dataIndex: 'contactPerson', hideInSearch: true },
{ title: '联系电话', dataIndex: 'contactPhone', hideInSearch: true },
{
title: '审核状态',
dataIndex: 'reviewStatus',
hideInSearch: true,
render: (_, record) => renderReviewStatus(record.reviewStatus),
},
{
title: '展位号',
dataIndex: 'boothNumber',
@@ -190,7 +306,7 @@ const ParticipatingCompaniesTab: React.FC<Props> = ({ fairId }) => {
{
title: '操作',
valueType: 'option',
width: 220,
width: 460,
render: (_, record) => [
<Button
key="jobs"
@@ -214,6 +330,17 @@ const ParticipatingCompaniesTab: React.FC<Props> = ({ fairId }) => {
>
</Button>,
<Button key="qrcode" type="link" size="small" onClick={() => openQrCodeModal(record)}>
</Button>,
<Button
key="review"
type="link"
size="small"
onClick={() => openReviewModal({ type: 'company', record })}
>
</Button>,
<Button
key="remove"
type="link"
@@ -256,7 +383,12 @@ const ParticipatingCompaniesTab: React.FC<Props> = ({ fairId }) => {
options={false}
pagination={{ pageSize: 10 }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => openJobEditor()}>
<Button
key="add"
type="primary"
icon={<PlusOutlined />}
onClick={() => openJobEditor()}
>
</Button>,
]}
@@ -276,6 +408,97 @@ const ParticipatingCompaniesTab: React.FC<Props> = ({ fairId }) => {
)}
</Modal>
<Modal
title={qrCompanyName ? `${qrCompanyName} — 企业二维码` : '企业二维码'}
open={qrModalOpen}
width={420}
footer={[
<Button
key="open"
type="primary"
onClick={() => qrInfo?.h5Url && window.open(qrInfo.h5Url)}
>
</Button>,
]}
destroyOnClose
onCancel={() => {
setQrModalOpen(false);
setQrInfo(null);
setQrCompanyName('');
}}
>
{qrInfo?.qrCodeContent && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<QRCode value={qrInfo.qrCodeContent} size={220} />
<Typography.Paragraph
copyable={{ text: qrInfo.qrCodeContent }}
style={{
width: '100%',
marginBottom: 0,
wordBreak: 'break-all',
textAlign: 'center',
}}
>
{qrInfo.qrCodeContent}
</Typography.Paragraph>
{!qrInfo.deviceCode && (
<Typography.Text type="secondary">
使
</Typography.Text>
)}
</div>
)}
</Modal>
<Modal
title={
reviewTarget?.type === 'company'
? `审核报名 — ${reviewTarget.record.companyName}`
: `审核岗位 — ${reviewTarget?.record.jobTitle || ''}`
}
open={reviewModalOpen}
destroyOnClose
onCancel={() => {
setReviewModalOpen(false);
setReviewTarget(null);
reviewForm.resetFields();
}}
onOk={submitReview}
>
<Form form={reviewForm} layout="vertical">
<Form.Item
name="reviewStatus"
label="审核状态"
rules={[{ required: true, message: '请选择审核状态' }]}
>
<Select
options={[
{ label: '待审核', value: '0' },
{ label: '已通过', value: '1' },
{ label: '已驳回', value: '2' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.reviewStatus !== cur.reviewStatus}>
{({ getFieldValue }) => (
<Form.Item
name="reviewRemark"
label="审核原因"
rules={[
{
required: getFieldValue('reviewStatus') === '2',
message: '驳回时请填写原因',
},
]}
>
<Input.TextArea rows={4} placeholder="驳回时请输入原因" maxLength={500} showCount />
</Form.Item>
)}
</Form.Item>
</Form>
</Modal>
<CompanyAddModal
open={companyModalOpen}
fairId={fairId}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Modal, Tag, message } from 'antd';
import { Button, Form, Input, Modal, Select, 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';
@@ -8,6 +8,7 @@ import {
getParticipatingJobDetail,
getParticipatingJobs,
removeJobFromCompany,
reviewParticipatingJob,
} from '@/services/jobportal/outdoorFairDetail';
import JobEditModal from './JobEditModal';
@@ -17,15 +18,28 @@ interface Props {
const ParticipatingJobsTab: React.FC<Props> = ({ fairId }) => {
const actionRef = useRef<ActionType>();
const [reviewForm] = Form.useForm();
const [editingJob, setEditingJob] = useState<API.OutdoorFairDetail.PostedJob | null>(null);
const [editingCompany, setEditingCompany] =
useState<API.OutdoorFairDetail.ParticipatingCompany | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [reviewModalOpen, setReviewModalOpen] = useState(false);
const [reviewingJob, setReviewingJob] = 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 renderReviewStatus = (status?: string) => {
const map: Record<string, { color: string; text: string }> = {
'0': { color: 'processing', text: '待审核' },
'1': { color: 'success', text: '已通过' },
'2': { color: 'error', text: '已驳回' },
};
const item = map[status || '1'] || map['1'];
return <Tag color={item.color}>{item.text}</Tag>;
};
useEffect(() => {
Promise.all([
getDictValueEnum('education', true, true),
@@ -61,6 +75,35 @@ const ParticipatingJobsTab: React.FC<Props> = ({ fairId }) => {
setModalOpen(true);
};
const openReviewModal = (record: API.OutdoorFairDetail.PostedJob) => {
setReviewingJob(record);
reviewForm.setFieldsValue({
reviewStatus: record.fairReviewStatus || '0',
reviewRemark: record.fairReviewRemark || '',
});
setReviewModalOpen(true);
};
const submitReview = async () => {
const values = await reviewForm.validateFields();
if (!reviewingJob) return;
const res = await reviewParticipatingJob({
fairId,
jobId: reviewingJob.jobId!,
reviewStatus: values.reviewStatus,
reviewRemark: values.reviewRemark,
});
if (res.code === 200) {
message.success('审核成功');
setReviewModalOpen(false);
setReviewingJob(null);
reviewForm.resetFields();
actionRef.current?.reload();
} else {
message.error(res.msg || '审核失败');
}
};
const columns: ProColumns<API.OutdoorFairDetail.PostedJob>[] = [
{ title: '岗位名称', dataIndex: 'jobTitle', ellipsis: true },
{
@@ -111,6 +154,13 @@ const ParticipatingJobsTab: React.FC<Props> = ({ fairId }) => {
},
{ title: '招聘人数', dataIndex: 'vacancies', hideInSearch: true, width: 90 },
{ title: '工作地点', dataIndex: 'jobLocation', hideInSearch: true, ellipsis: true },
{
title: '审核状态',
dataIndex: 'fairReviewStatus',
hideInSearch: true,
width: 100,
render: (_, record) => renderReviewStatus(record.fairReviewStatus),
},
{
title: '发布状态',
dataIndex: 'isPublish',
@@ -125,11 +175,14 @@ const ParticipatingJobsTab: React.FC<Props> = ({ fairId }) => {
{
title: '操作',
valueType: 'option',
width: 130,
width: 240,
render: (_, record) => [
<Button key="edit" type="link" size="small" onClick={() => editJob(record)}>
</Button>,
<Button key="review" type="link" size="small" onClick={() => openReviewModal(record)}>
</Button>,
<Button
key="delete"
type="link"
@@ -193,6 +246,49 @@ const ParticipatingJobsTab: React.FC<Props> = ({ fairId }) => {
actionRef.current?.reload();
}}
/>
<Modal
title={`审核岗位 — ${reviewingJob?.jobTitle || ''}`}
open={reviewModalOpen}
destroyOnClose
onCancel={() => {
setReviewModalOpen(false);
setReviewingJob(null);
reviewForm.resetFields();
}}
onOk={submitReview}
>
<Form form={reviewForm} layout="vertical">
<Form.Item
name="reviewStatus"
label="审核状态"
rules={[{ required: true, message: '请选择审核状态' }]}
>
<Select
options={[
{ label: '待审核', value: '0' },
{ label: '已通过', value: '1' },
{ label: '已驳回', value: '2' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.reviewStatus !== cur.reviewStatus}>
{({ getFieldValue }) => (
<Form.Item
name="reviewRemark"
label="审核原因"
rules={[
{
required: getFieldValue('reviewStatus') === '2',
message: '驳回时请填写原因',
},
]}
>
<Input.TextArea rows={4} placeholder="驳回时请输入原因" maxLength={500} showCount />
</Form.Item>
)}
</Form.Item>
</Form>
</Modal>
</>
);
};