feat: add job fair position functionality with detailed card display and data fetching
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-21 16:17:06 +08:00
parent b8965fd241
commit 13256db26d
3 changed files with 285 additions and 1 deletions

View File

@@ -47,6 +47,90 @@
line-height: 1.85; line-height: 1.85;
} }
} }
.job-fair-position-section {
margin-top: 28px;
}
.job-fair-position-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16px;
.ant-typography {
margin: 0;
color: @jp-text-primary;
}
}
.job-fair-position-card {
height: 100%;
.jp-card-base();
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: @jp-primary-border;
box-shadow: @jp-shadow-hover;
}
.job-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
.ant-typography {
flex: 1;
margin: 0;
color: @jp-text-primary;
font-size: 16px;
font-weight: 600;
}
.job-salary {
.jp-salary-text();
flex-shrink: 0;
font-size: 15px;
white-space: nowrap;
}
}
.job-company {
display: block;
margin-bottom: 10px;
color: @jp-text-muted;
font-size: 13px;
}
.job-info {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
.job-location,
.job-experience,
.job-education {
color: @jp-text-muted;
font-size: 12px;
}
}
.job-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
.job-tag {
.jp-job-tag();
padding: 2px 8px;
font-size: 12px;
}
}
}
} }
@media (max-width: 576px) { @media (max-width: 576px) {

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Descriptions, Empty, Spin, Tag, Typography } from 'antd'; import { Button, Card, Col, Descriptions, Empty, Row, Spin, Tag, Typography } from 'antd';
import { import {
ArrowLeftOutlined, ArrowLeftOutlined,
CalendarOutlined, CalendarOutlined,
@@ -13,8 +13,10 @@ import dayjs from 'dayjs';
import JobPortalHeader from '@/components/JobPortalHeader'; import JobPortalHeader from '@/components/JobPortalHeader';
import { import {
getPortalJobFairDetail, getPortalJobFairDetail,
getPortalJobFairPositions,
type JobFairChannel, type JobFairChannel,
type PortalJobFairItem, type PortalJobFairItem,
type PortalJobFairPosition,
} from '@/services/jobportal/jobFair'; } from '@/services/jobportal/jobFair';
import '../index.less'; import '../index.less';
import './index.less'; import './index.less';
@@ -33,6 +35,45 @@ function formatDateTime(value?: string) {
return date.isValid() ? date.format('YYYY年MM月DD日 HH:mm') : value; return date.isValid() ? date.format('YYYY年MM月DD日 HH:mm') : value;
} }
const educationMap: Record<string, string> = {
'0': '学历不限',
'1': '大专',
'2': '本科',
'3': '硕士',
'4': '博士',
};
const experienceMap: Record<string, string> = {
'0': '经验不限',
'1': '1年以下',
'2': '1-3年',
'3': '3-5年',
'4': '5-10年',
'5': '10年以上',
};
function formatSalary(position: PortalJobFairPosition) {
const { minSalary, maxSalary } = position;
if (minSalary && maxSalary) {
return `${minSalary / 1000}K-${maxSalary / 1000}K`;
}
if (minSalary) {
return `${minSalary / 1000}K起`;
}
if (maxSalary) {
return `${maxSalary / 1000}K以下`;
}
return '薪资面议';
}
function formatRequirement(
value: string | undefined,
map: Record<string, string>,
fallback: string,
) {
return value ? map[value] || value : fallback;
}
const PortalJobFairDetailPage: React.FC = () => { const PortalJobFairDetailPage: React.FC = () => {
const location = useLocation(); const location = useLocation();
const fallbackDetail = (location.state as JobFairDetailLocationState | null)?.fair; const fallbackDetail = (location.state as JobFairDetailLocationState | null)?.fair;
@@ -45,6 +86,8 @@ const PortalJobFairDetailPage: React.FC = () => {
}, [location.search]); }, [location.search]);
const [detail, setDetail] = useState<PortalJobFairItem>(); const [detail, setDetail] = useState<PortalJobFairItem>();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [positions, setPositions] = useState<PortalJobFairPosition[]>([]);
const [positionsLoading, setPositionsLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (!jobFairId) { if (!jobFairId) {
@@ -79,8 +122,49 @@ const PortalJobFairDetailPage: React.FC = () => {
}; };
}, [channel, fallbackDetail, jobFairId]); }, [channel, fallbackDetail, jobFairId]);
useEffect(() => {
if (!jobFairId || channel === 'outdoor') {
setPositions([]);
setPositionsLoading(false);
return;
}
let cancelled = false;
const loadPositions = async () => {
setPositionsLoading(true);
try {
const response = await getPortalJobFairPositions(jobFairId);
if (!cancelled) {
setPositions(response?.code === 200 ? response.data || [] : []);
}
} catch (error) {
if (!cancelled) {
setPositions([]);
}
} finally {
if (!cancelled) {
setPositionsLoading(false);
}
}
};
void loadPositions();
return () => {
cancelled = true;
};
}, [channel, jobFairId]);
const backToList = () => history.push('/job-portal/job-fair'); const backToList = () => history.push('/job-portal/job-fair');
const handlePositionClick = (position: PortalJobFairPosition) => {
if (!position.jobId) {
return;
}
history.push(`/job-portal/detail?jobId=${encodeURIComponent(String(position.jobId))}`, {
jobData: position,
});
};
return ( return (
<div className="portal-job-fair-page portal-job-fair-detail-page job-portal-page-root"> <div className="portal-job-fair-page portal-job-fair-detail-page job-portal-page-root">
<JobPortalHeader showSearch={false} showHotJobs={false} /> <JobPortalHeader showSearch={false} showHotJobs={false} />
@@ -174,6 +258,65 @@ const PortalJobFairDetailPage: React.FC = () => {
) )
)} )}
</Spin> </Spin>
{channel === 'online' && detail && (
<section className="job-fair-position-section" aria-live="polite">
<div className="job-fair-position-heading">
<Title level={3}></Title>
<Typography.Text type="secondary"> {positions.length} </Typography.Text>
</div>
<Spin spinning={positionsLoading}>
{positions.length ? (
<Row gutter={[16, 16]}>
{positions.map((position, index) => (
<Col
key={position.fairRelationId || position.jobId || index}
md={6}
sm={12}
xs={24}
>
<Card
className="job-fair-position-card"
hoverable
onClick={() => handlePositionClick(position)}
>
<div className="job-header">
<Title level={4}>{position.jobTitle || '未命名岗位'}</Title>
<Typography.Text className="job-salary">
{formatSalary(position)}
</Typography.Text>
</div>
<Typography.Text className="job-company">
{position.companyName || '招聘企业暂未公布'}
</Typography.Text>
<div className="job-info">
<Typography.Text className="job-location">
<EnvironmentOutlined />{' '}
{position.jobLocation || position.jobAddress || '地点不限'}
</Typography.Text>
<Typography.Text className="job-experience">
{formatRequirement(position.experience, experienceMap, '经验不限')}
</Typography.Text>
<Typography.Text className="job-education">
{formatRequirement(position.education, educationMap, '学历不限')}
</Typography.Text>
</div>
{position.vacancies && (
<div className="job-tags">
<Tag className="job-tag"> {position.vacancies} </Tag>
</div>
)}
</Card>
</Col>
))}
</Row>
) : (
!positionsLoading && (
<Empty description="暂未发布招聘岗位" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)
)}
</Spin>
</section>
)}
</main> </main>
</div> </div>
); );

View File

@@ -95,6 +95,40 @@ export interface PortalJobFairDetailResult {
data?: PortalJobFairItem; data?: PortalJobFairItem;
} }
/** 招聘会关联岗位;由公共接口中的企业岗位列表展平得到。 */
export interface PortalJobFairPosition {
jobId?: number | string;
fairRelationId?: string;
jobTitle?: string;
minSalary?: number;
maxSalary?: number;
education?: string;
experience?: string;
companyId?: number | string;
companyName?: string;
jobLocation?: string;
jobAddress?: string;
vacancies?: number | string;
}
interface PortalJobFairCompanyWithJobs {
companyId?: number | string;
companyName?: string;
jobInfoList?: PortalJobFairPosition[];
}
interface PortalJobFairCompaniesWithJobsResult {
code: number;
msg?: string;
data?: PortalJobFairCompanyWithJobs[];
}
export interface PortalJobFairPositionResult {
code: number;
msg?: string;
data?: PortalJobFairPosition[];
}
const FAIR_API = { const FAIR_API = {
online: '/app/jobfair/public/jobfair', online: '/app/jobfair/public/jobfair',
outdoor: '/app/outdoor-fair', outdoor: '/app/outdoor-fair',
@@ -172,3 +206,26 @@ export async function getPortalJobFairDetail(channel: JobFairChannel, jobFairId:
}, },
} as PortalJobFairDetailResult; } as PortalJobFairDetailResult;
} }
/** 获取线上招聘会的关联岗位,并将企业分组数据转换为岗位卡片数据。 */
export async function getPortalJobFairPositions(jobFairId: string) {
const response = await request<PortalJobFairCompaniesWithJobsResult>(
`${FAIR_API.online}/enterprises-with-jobs-by-job-fair-id`,
{
method: 'GET',
params: { jobFairId },
},
);
return {
code: response.code,
msg: response.msg,
data: (response.data || []).flatMap((company) =>
(company.jobInfoList || []).map((position) => ({
...position,
companyId: position.companyId || company.companyId,
companyName: position.companyName || company.companyName,
})),
),
} as PortalJobFairPositionResult;
}