feat: add job fair region type functionality and improve job fair detail pages
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

- Introduced job fair region type selection in EditModal and PublicJobFairList components.
- Enhanced JobFairDetail page to display job fair region type using DictTag.
- Updated API types to include jobFairRegionType for better data handling.
- Created new JobPortal pages for displaying job fair details with improved styling.
- Added CSS styles for job fair detail and list pages to enhance user experience.
- Implemented loading states and error handling for job fair data fetching.
This commit is contained in:
2026-07-21 14:18:58 +08:00
parent f2a81d8867
commit b8965fd241
13 changed files with 1230 additions and 24 deletions

View File

@@ -43,6 +43,8 @@ export default {
// 配置了这个可以从 http 代理到 https
// 依赖 origin 的功能可能需要这个,比如 cookie
changeOrigin: true,
// 测试环境目前使用 IP 访问 HTTPS证书域名不匹配仅影响本地开发代理。
secure: false,
},
},

View File

@@ -48,6 +48,14 @@ export default [
path: '/job-portal/list', // 职位列表
component: './JobPortal/List',
},
{
path: '/job-portal/job-fair', // 招聘会(线上、户外)
component: './JobPortal/JobFair',
},
{
path: '/job-portal/job-fair/detail', // 招聘会详情
component: './JobPortal/JobFair/Detail',
},
{
path: '/job-portal/detail', // 职位详情
component: './JobPortal/Detail',

View File

@@ -14,6 +14,7 @@ import {
FileTextOutlined,
HomeOutlined,
BellOutlined,
CalendarOutlined,
ReadOutlined,
LoginOutlined,
FundProjectionScreenOutlined,
@@ -102,6 +103,7 @@ const JobPortalHeader: React.FC<JobPortalHeaderProps> = ({
// 判断激活导航(简化为 startsWith 匹配)
const isHome = /^\/job-portal(\/(list|detail))?$/.test(location.pathname);
const isJobFair = location.pathname.startsWith('/job-portal/job-fair');
const isResume = location.pathname.startsWith('/job-portal/resume');
const isMine = location.pathname.startsWith('/job-portal/personal-center') || location.pathname.startsWith('/job-portal/profile');
const isMessage = location.pathname.startsWith('/job-portal/message');
@@ -318,6 +320,14 @@ const JobPortalHeader: React.FC<JobPortalHeaderProps> = ({
>
</Button>
<Button
type="text"
icon={<CalendarOutlined />}
onClick={() => handleNavClick('/job-portal/job-fair')}
className={`nav-btn${isJobFair ? ' active' : ''}`}
>
</Button>
<Button
type="text"
icon={<FileTextOutlined />}

View File

@@ -0,0 +1,62 @@
@import '../../theme.less';
.portal-job-fair-detail-page {
.job-fair-detail-content {
.jp-page-container();
padding-top: 24px;
padding-bottom: 56px;
}
.job-fair-back {
margin: 0 0 12px;
padding: 0;
}
.job-fair-detail-card {
.jp-card-base();
.ant-card-body {
padding: 32px;
}
}
.job-fair-detail-title-row {
margin-bottom: 26px;
.ant-tag {
margin-bottom: 12px;
}
.ant-typography {
margin: 0;
color: @jp-text-primary;
}
}
.job-fair-detail-introduction {
margin-top: 28px;
.ant-typography {
color: @jp-text-primary;
}
p {
margin-bottom: 0;
white-space: pre-wrap;
color: @jp-text-secondary;
line-height: 1.85;
}
}
}
@media (max-width: 576px) {
.portal-job-fair-detail-page {
.job-fair-detail-content {
padding: 16px 12px 36px;
}
.job-fair-detail-card .ant-card-body {
padding: 20px;
}
}
}

View File

@@ -0,0 +1,182 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Descriptions, Empty, Spin, Tag, Typography } from 'antd';
import {
ArrowLeftOutlined,
CalendarOutlined,
EnvironmentOutlined,
HomeOutlined,
PhoneOutlined,
TeamOutlined,
} from '@ant-design/icons';
import { history, useLocation } from '@umijs/max';
import dayjs from 'dayjs';
import JobPortalHeader from '@/components/JobPortalHeader';
import {
getPortalJobFairDetail,
type JobFairChannel,
type PortalJobFairItem,
} from '@/services/jobportal/jobFair';
import '../index.less';
import './index.less';
const { Title, Paragraph } = Typography;
interface JobFairDetailLocationState {
fair?: PortalJobFairItem;
}
function formatDateTime(value?: string) {
if (!value) {
return '暂未公布';
}
const date = dayjs(value.replace(' ', 'T'));
return date.isValid() ? date.format('YYYY年MM月DD日 HH:mm') : value;
}
const PortalJobFairDetailPage: React.FC = () => {
const location = useLocation();
const fallbackDetail = (location.state as JobFairDetailLocationState | null)?.fair;
const { channel, jobFairId } = useMemo(() => {
const params = new URLSearchParams(location.search);
return {
channel: params.get('channel') === 'outdoor' ? 'outdoor' : 'online',
jobFairId: params.get('jobFairId') || '',
} as { channel: JobFairChannel; jobFairId: string };
}, [location.search]);
const [detail, setDetail] = useState<PortalJobFairItem>();
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!jobFairId) {
setLoading(false);
return;
}
let cancelled = false;
const loadDetail = async () => {
setLoading(true);
try {
const response = await getPortalJobFairDetail(channel, jobFairId);
if (!cancelled && response?.code === 200) {
setDetail(response.data || fallbackDetail);
} else if (!cancelled) {
setDetail(fallbackDetail);
}
} catch (error) {
if (!cancelled) {
setDetail(fallbackDetail);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void loadDetail();
return () => {
cancelled = true;
};
}, [channel, fallbackDetail, jobFairId]);
const backToList = () => history.push('/job-portal/job-fair');
return (
<div className="portal-job-fair-page portal-job-fair-detail-page job-portal-page-root">
<JobPortalHeader showSearch={false} showHotJobs={false} />
<main className="job-fair-detail-content">
<Button
className="job-fair-back"
icon={<ArrowLeftOutlined />}
onClick={backToList}
type="link"
>
</Button>
<Spin spinning={loading}>
{detail ? (
<Card className="job-fair-detail-card" variant="borderless">
<div className="job-fair-detail-title-row">
<div>
<Tag color={channel === 'outdoor' ? 'cyan' : 'blue'}>
{channel === 'outdoor' ? '户外招聘会' : '线上招聘会'}
</Tag>
<Title level={2}>{detail.jobFairTitle}</Title>
</div>
</div>
<Descriptions bordered column={{ lg: 2, md: 2, sm: 1, xs: 1 }}>
<Descriptions.Item
label={
<>
<CalendarOutlined />
</>
}
>
{formatDateTime(detail.jobFairStartTime)} {' '}
{formatDateTime(detail.jobFairEndTime)}
</Descriptions.Item>
<Descriptions.Item
label={
<>
<EnvironmentOutlined />
</>
}
>
{detail.jobFairAddress || detail.jobFairVenueName || '暂未公布'}
</Descriptions.Item>
<Descriptions.Item
label={
<>
<TeamOutlined />
</>
}
>
{detail.jobFairHostUnit || detail.jobFairOrganizeUnit || '暂未公布'}
</Descriptions.Item>
{detail.jobFairRegion && (
<Descriptions.Item
label={
<>
<HomeOutlined />
</>
}
>
{detail.jobFairRegion}
</Descriptions.Item>
)}
{detail.boothNum && (
<Descriptions.Item label="展位数量">{detail.boothNum}</Descriptions.Item>
)}
{detail.jobFairPhone && (
<Descriptions.Item
label={
<>
<PhoneOutlined />
</>
}
>
{detail.jobFairPhone}
</Descriptions.Item>
)}
</Descriptions>
<section className="job-fair-detail-introduction">
<Title level={4}></Title>
<Paragraph>{detail.jobFairIntroduction || '暂未提供招聘会简介。'}</Paragraph>
</section>
</Card>
) : (
!loading && (
<Empty description={jobFairId ? '未找到该招聘会信息' : '缺少招聘会参数'}>
<Button onClick={backToList} type="primary">
</Button>
</Empty>
)
)}
</Spin>
</main>
</div>
);
};
export default PortalJobFairDetailPage;

View File

@@ -0,0 +1,333 @@
@import '../theme.less';
.portal-job-fair-page {
min-height: 100vh;
.job-fair-content {
.jp-page-container();
padding-top: 28px;
padding-bottom: 56px;
}
.job-fair-hero {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 128px;
padding: 24px 38px;
border-radius: @jp-radius-lg;
color: #fff;
background: linear-gradient(120deg, #0d6efd, #21a3ee);
box-shadow: 0 10px 28px rgba(24, 144, 255, 0.22);
.ant-typography {
color: #fff;
margin-bottom: 6px;
}
.job-fair-hero-icon {
opacity: 0.18;
font-size: 90px;
}
}
.job-fair-panel,
.fair-calendar-card,
.fair-summary-card,
.fair-card {
.jp-card-base();
}
.job-fair-panel {
margin-top: 22px;
padding: 0 24px 20px;
.job-fair-tabs {
.ant-tabs-nav {
margin-bottom: 16px;
}
.ant-tabs-tab {
padding: 16px 8px;
font-size: 16px;
}
}
}
.job-fair-toolbar {
display: flex;
align-items: center;
gap: 12px;
.ant-input-search {
max-width: 440px;
}
.ant-tag {
margin: 0;
padding: 5px 9px;
border-radius: 5px;
}
}
.job-fair-supporting {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
margin-top: 20px;
.fair-calendar-card {
height: 100%;
min-height: 0;
.ant-card-head,
.ant-card-body {
border: 0;
}
.ant-picker-calendar {
background: transparent;
}
.fair-calendar-date {
position: relative;
height: 24px;
margin: 0 3px;
border-radius: 4px;
color: @jp-text-primary;
line-height: 24px;
text-align: center;
}
.ant-picker-cell-selected .fair-calendar-date {
color: #fff;
background: @jp-primary;
}
.fair-calendar-marker {
position: absolute;
right: 4px;
bottom: 1px;
width: 5px;
height: 5px;
border-radius: 50%;
background: #fa8c16;
}
.clear-date-button {
padding: 0;
}
}
}
.fair-summary-grid {
display: grid;
grid-column: span 2;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
height: 100%;
.fair-summary-card {
height: 100%;
min-height: 0;
.ant-card-head {
border-bottom-color: @jp-border;
}
}
}
.fair-summary-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.fair-summary-item {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: 10px;
width: 100%;
padding: 10px;
border: 0;
border-radius: 6px;
color: @jp-text-primary;
text-align: left;
background: #f8fbff;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: @jp-primary-light;
}
.fair-summary-date {
color: @jp-primary;
font-size: 12px;
line-height: 20px;
white-space: nowrap;
}
.fair-summary-title {
overflow: hidden;
font-size: 14px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.fair-summary-empty {
display: block;
padding-top: 24px;
text-align: center;
}
.job-fair-list-section {
margin-top: 28px;
}
.job-fair-list-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16px;
.ant-typography {
.jp-section-title();
margin: 0;
}
}
.fair-card {
height: 100%;
transition:
box-shadow 0.2s,
transform 0.2s;
&:hover {
box-shadow: @jp-shadow-hover;
transform: translateY(-2px);
}
.ant-card-body {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
padding: 20px;
}
}
.fair-card-tags {
display: flex;
gap: 8px;
margin-bottom: 12px;
.ant-tag {
margin: 0;
}
}
.fair-card-title {
min-height: 48px;
margin-bottom: 14px !important;
color: @jp-text-primary;
line-height: 24px !important;
}
.fair-card-meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 9px;
min-height: 82px;
color: @jp-text-secondary;
font-size: 13px;
> div {
display: flex;
align-items: flex-start;
line-height: 20px;
.anticon {
flex: 0 0 auto;
margin: 3px 8px 0 0;
color: @jp-primary;
}
}
}
.fair-card-introduction {
min-height: 42px;
margin: 16px 0 0 !important;
padding-top: 14px;
border-top: 1px solid @jp-border;
color: @jp-text-muted;
font-size: 13px;
line-height: 21px;
}
.job-fair-pagination {
display: flex;
justify-content: flex-end;
margin-top: 28px;
}
}
@media (max-width: 900px) {
.portal-job-fair-page {
.job-fair-supporting,
.fair-summary-grid {
grid-template-columns: 1fr;
}
.fair-summary-grid {
grid-column: auto;
}
.fair-summary-card {
min-height: auto !important;
}
}
}
@media (max-width: 576px) {
.portal-job-fair-page {
.job-fair-content {
padding: 16px 12px 36px;
}
.job-fair-hero {
min-height: 112px;
padding: 20px;
.job-fair-hero-icon {
font-size: 62px;
}
}
.job-fair-panel {
padding: 0 16px 16px;
}
.job-fair-toolbar {
align-items: stretch;
flex-direction: column;
.ant-input-search {
max-width: none;
}
}
.fair-summary-grid {
gap: 12px;
}
.job-fair-pagination {
justify-content: center;
}
}
}

View File

@@ -0,0 +1,380 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
Button,
Calendar,
Card,
Col,
ConfigProvider,
Empty,
Input,
Pagination,
Row,
Spin,
Tabs,
Tag,
Typography,
} from 'antd';
import {
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
SearchOutlined,
TeamOutlined,
} from '@ant-design/icons';
import { history } from '@umijs/max';
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn';
import zhCN from 'antd/locale/zh_CN';
import JobPortalHeader from '@/components/JobPortalHeader';
import {
getPortalCurrentMonthJobFairs,
getPortalCurrentQuarterJobFairs,
getPortalJobFairDates,
getPortalJobFairPage,
type JobFairChannel,
type PortalJobFairItem,
} from '@/services/jobportal/jobFair';
import './index.less';
const { Title, Text, Paragraph } = Typography;
const PAGE_SIZE = 9;
const fairTabs: Array<{ key: JobFairChannel; label: string }> = [
{ key: 'online', label: '线上招聘会' },
{ key: 'outdoor', label: '户外招聘会' },
];
function formatDateTime(value?: string) {
if (!value) {
return '时间待定';
}
const date = dayjs(value.replace(' ', 'T'));
return date.isValid() ? date.format('YYYY年MM月DD日 HH:mm') : value;
}
function formatSummaryDate(value?: string) {
if (!value) {
return '时间待定';
}
const date = dayjs(value.replace(' ', 'T'));
return date.isValid() ? date.format('MM月DD日') : value.slice(0, 10);
}
function getFairStatus(fair: PortalJobFairItem) {
const start = fair.jobFairStartTime ? dayjs(fair.jobFairStartTime.replace(' ', 'T')) : null;
const end = fair.jobFairEndTime ? dayjs(fair.jobFairEndTime.replace(' ', 'T')) : null;
const now = dayjs();
if (start?.isValid() && now.isBefore(start)) {
return { label: '待开始', color: 'gold' };
}
if (end?.isValid() && now.isAfter(end)) {
return { label: '已结束', color: 'default' };
}
if (start?.isValid() && end?.isValid()) {
return { label: '进行中', color: 'green' };
}
return fair.jobFairStatus ? { label: fair.jobFairStatus, color: 'blue' } : null;
}
const PortalJobFairPage: React.FC = () => {
const [channel, setChannel] = useState<JobFairChannel>('online');
const [searchInput, setSearchInput] = useState('');
const [keyword, setKeyword] = useState('');
const [selectedDate, setSelectedDate] = useState<string>();
const [calendarValue, setCalendarValue] = useState<Dayjs>(() => dayjs().locale('zh-cn'));
const [fairDates, setFairDates] = useState<Set<string>>(new Set());
const [fairs, setFairs] = useState<PortalJobFairItem[]>([]);
const [monthFairs, setMonthFairs] = useState<PortalJobFairItem[]>([]);
const [quarterFairs, setQuarterFairs] = useState<PortalJobFairItem[]>([]);
const [pageNum, setPageNum] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [summaryLoading, setSummaryLoading] = useState(false);
const loadFairs = useCallback(async () => {
setLoading(true);
try {
const response = await getPortalJobFairPage(channel, {
pageNum,
pageSize: PAGE_SIZE,
jobFairTitle: keyword || undefined,
zphjbsj: selectedDate,
});
if (response?.code !== 200) {
throw new Error(response?.msg || '招聘会列表加载失败');
}
setFairs(response.data?.list || []);
setTotal(response.data?.total || 0);
} catch (error) {
setFairs([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [channel, keyword, pageNum, selectedDate]);
useEffect(() => {
void loadFairs();
}, [loadFairs]);
useEffect(() => {
let cancelled = false;
const loadSupportingData = async () => {
setSummaryLoading(true);
try {
const [datesResponse, monthResponse, quarterResponse] = await Promise.all([
getPortalJobFairDates(channel),
getPortalCurrentMonthJobFairs(channel),
getPortalCurrentQuarterJobFairs(channel),
]);
if (cancelled) {
return;
}
setFairDates(new Set(Array.isArray(datesResponse?.data) ? datesResponse.data : []));
setMonthFairs(Array.isArray(monthResponse?.data) ? monthResponse.data : []);
setQuarterFairs(Array.isArray(quarterResponse?.data) ? quarterResponse.data : []);
} catch (error) {
if (!cancelled) {
setFairDates(new Set());
setMonthFairs([]);
setQuarterFairs([]);
}
} finally {
if (!cancelled) {
setSummaryLoading(false);
}
}
};
void loadSupportingData();
return () => {
cancelled = true;
};
}, [channel]);
const handleChannelChange = (nextChannel: string) => {
setChannel(nextChannel as JobFairChannel);
setPageNum(1);
setSelectedDate(undefined);
};
const handleSearch = (value?: string) => {
setKeyword((value ?? searchInput).trim());
setPageNum(1);
};
const handleSelectDate = (value: Dayjs) => {
const localizedValue = value.locale('zh-cn');
const nextDate = localizedValue.format('YYYY-MM-DD');
setCalendarValue(localizedValue);
setSelectedDate(nextDate);
setPageNum(1);
};
const handleSummaryFairClick = (fair: PortalJobFairItem) => {
if (!fair.jobFairId) {
return;
}
history.push(
`/job-portal/job-fair/detail?channel=${channel}&jobFairId=${encodeURIComponent(fair.jobFairId)}`,
{ fair },
);
};
const renderSummary = (title: string, fairsToRender: PortalJobFairItem[]) => (
<Card className="fair-summary-card" title={title} variant="borderless">
<Spin spinning={summaryLoading}>
{fairsToRender.length ? (
<div className="fair-summary-list">
{fairsToRender.slice(0, 3).map((fair) => (
<button
className="fair-summary-item"
key={fair.jobFairId}
onClick={() => handleSummaryFairClick(fair)}
title={`查看${fair.jobFairTitle}详情`}
type="button"
>
<span className="fair-summary-date">
{formatSummaryDate(fair.jobFairStartTime)}
</span>
<span className="fair-summary-title">{fair.jobFairTitle}</span>
</button>
))}
</div>
) : (
<Text type="secondary" className="fair-summary-empty">
</Text>
)}
</Spin>
</Card>
);
return (
<div className="portal-job-fair-page job-portal-page-root">
<JobPortalHeader showSearch={false} showHotJobs={false} />
<main className="job-fair-content">
<section className="job-fair-hero">
<div>
<Title level={2}></Title>
<Text>线</Text>
</div>
<CalendarOutlined className="job-fair-hero-icon" aria-hidden="true" />
</section>
<section className="job-fair-panel" aria-label="招聘会筛选">
<Tabs
activeKey={channel}
className="job-fair-tabs"
items={fairTabs}
onChange={handleChannelChange}
/>
<div className="job-fair-toolbar">
<Input.Search
allowClear
enterButton={
<>
<SearchOutlined />
</>
}
onChange={(event) => setSearchInput(event.target.value)}
onSearch={handleSearch}
placeholder="搜索招聘会名称"
value={searchInput}
/>
{selectedDate && (
<Tag
closable
color="blue"
onClose={() => {
setSelectedDate(undefined);
setPageNum(1);
}}
>
{selectedDate}
</Tag>
)}
</div>
</section>
<section className="job-fair-supporting" aria-label="招聘会日历与近期安排">
<Card className="fair-calendar-card" title="招聘会日历" variant="borderless">
<ConfigProvider locale={zhCN}>
<Calendar
fullCellRender={(current, info) => {
if (info.type !== 'date') {
return info.originNode;
}
const date = current.format('YYYY-MM-DD');
return (
<div className="fair-calendar-date">
{current.date()}
{fairDates.has(date) && (
<span className="fair-calendar-marker" title="当天有招聘会" />
)}
</div>
);
}}
fullscreen={false}
onPanelChange={(value) => setCalendarValue(value.locale('zh-cn'))}
onSelect={handleSelectDate}
value={calendarValue}
/>
</ConfigProvider>
<Button
className="clear-date-button"
disabled={!selectedDate}
onClick={() => {
setSelectedDate(undefined);
setPageNum(1);
}}
type="link"
>
</Button>
</Card>
<div className="fair-summary-grid">
{renderSummary(`本月${channel === 'outdoor' ? '户外' : ''}招聘会`, monthFairs)}
{renderSummary(`本季度${channel === 'outdoor' ? '户外' : ''}招聘会`, quarterFairs)}
</div>
</section>
<section className="job-fair-list-section" aria-live="polite">
<div className="job-fair-list-heading">
<Title level={3}>{channel === 'outdoor' ? '户外招聘会' : '线上招聘会'}</Title>
<Text type="secondary"> {total} </Text>
</div>
<Spin spinning={loading}>
{fairs.length ? (
<>
<Row gutter={[20, 20]}>
{fairs.map((fair) => {
const status = getFairStatus(fair);
return (
<Col key={fair.jobFairId} lg={8} md={12} sm={24} xs={24}>
<Card className="fair-card" hoverable>
<div className="fair-card-tags">
<Tag color={channel === 'outdoor' ? 'cyan' : 'blue'}>
{channel === 'outdoor' ? '户外招聘会' : '线上招聘会'}
</Tag>
{status && <Tag color={status.color}>{status.label}</Tag>}
</div>
<Title
className="fair-card-title"
ellipsis={{ rows: 2 }}
level={4}
title={fair.jobFairTitle}
>
{fair.jobFairTitle}
</Title>
<div className="fair-card-meta">
<div>
<ClockCircleOutlined />
{formatDateTime(fair.jobFairStartTime)}
</div>
<div>
<EnvironmentOutlined />
{fair.jobFairAddress || '地点待定'}
</div>
<div>
<TeamOutlined />
{fair.jobFairHostUnit || fair.jobFairOrganizeUnit || '主办单位待定'}
</div>
</div>
<Paragraph className="fair-card-introduction" ellipsis={{ rows: 2 }}>
{fair.jobFairIntroduction || '暂未提供招聘会简介。'}
</Paragraph>
</Card>
</Col>
);
})}
</Row>
{total > PAGE_SIZE && (
<div className="job-fair-pagination">
<Pagination
current={pageNum}
onChange={(nextPage) => setPageNum(nextPage)}
pageSize={PAGE_SIZE}
showSizeChanger={false}
showTotal={(count) => `${count} 场招聘会`}
total={total}
/>
</div>
)}
</>
) : (
!loading && <Empty description="暂未找到符合条件的招聘会" />
)}
</Spin>
</section>
</main>
</div>
);
};
export default PortalJobFairPage;

View File

@@ -23,6 +23,8 @@ import {
removeJobFromJobFair,
reviewPublicJobFairCompany,
} from '@/services/jobfair/publicJobFair';
import { getDictValueEnum } from '@/services/system/dict';
import DictTag from '@/components/DictTag';
import CompanySelectModal from '../components/CompanySelectModal';
import JobSelectModal from '../components/JobSelectModal';
@@ -51,6 +53,7 @@ const JobFairDetail: React.FC = () => {
const [reviewModalOpen, setReviewModalOpen] = useState(false);
const [reviewCompany, setReviewCompany] = useState<API.PublicJobFair.CompanyInfo>();
const [reviewSubmitting, setReviewSubmitting] = useState(false);
const [jobFairRegionTypeEnum, setJobFairRegionTypeEnum] = useState<any>({});
const [reviewForm] = Form.useForm<ReviewFormValues>();
const reviewStatus = Form.useWatch('reviewStatus', reviewForm);
@@ -72,6 +75,10 @@ const JobFairDetail: React.FC = () => {
}
}, [jobFairId, isEnterprise]);
useEffect(() => {
getDictValueEnum('job_fair_region_type').then(setJobFairRegionTypeEnum);
}, []);
const handleRemoveCompany = (company: API.PublicJobFair.CompanyInfo) => {
Modal.confirm({
title: '确认移除',
@@ -174,6 +181,13 @@ const JobFairDetail: React.FC = () => {
{jobFairTypeMap[detail?.jobFairType || '']}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="区域类型">
{detail?.jobFairRegionType ? (
<DictTag enums={jobFairRegionTypeEnum} value={detail.jobFairRegionType} />
) : (
'-'
)}
</Descriptions.Item>
<Descriptions.Item label="地址">{detail?.jobFairAddress}</Descriptions.Item>
<Descriptions.Item label="联系电话">{detail?.jobFairPhone}</Descriptions.Item>
<Descriptions.Item label="招聘会时间">

View File

@@ -18,6 +18,7 @@ interface EditModalProps {
open: boolean;
values?: API.PublicJobFair.JobFairItem;
jobFairTypeEnum: any;
jobFairRegionTypeEnum: any;
onCancel: () => void;
onSubmit: (values: API.PublicJobFair.JobFairForm) => Promise<void>;
}
@@ -69,6 +70,7 @@ const EditModal: React.FC<EditModalProps> = ({
open,
values,
jobFairTypeEnum,
jobFairRegionTypeEnum,
onCancel,
onSubmit,
}) => {
@@ -175,6 +177,12 @@ const EditModal: React.FC<EditModalProps> = ({
valueEnum={jobFairTypeEnum}
rules={[{ required: true, message: '请选择招聘会类型' }]}
/>
<ProFormRadio.Group
name="jobFairRegionType"
label="区域类型"
valueEnum={jobFairRegionTypeEnum}
rules={[{ required: true, message: '请选择区域类型' }]}
/>
<ProFormRadio.Group name="isCrossDomain" label="是否跨域" valueEnum={yesNoEnum} />
{isCrossDomain === 'y' && (
<ProFormSelect

View File

@@ -33,11 +33,13 @@ const PublicJobFairList: React.FC = () => {
const [companyJobModalOpen, setCompanyJobModalOpen] = useState(false);
const [currentJobFair, setCurrentJobFair] = useState<API.PublicJobFair.JobFairItem>();
const [jobFairTypeEnum, setJobFairTypeEnum] = useState<any>({});
const [jobFairRegionTypeEnum, setJobFairRegionTypeEnum] = useState<any>({});
const [yesNoEnum, setYesNoEnum] = useState<any>({});
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
useEffect(() => {
getDictValueEnum('job_fair_type', true).then(setJobFairTypeEnum);
getDictValueEnum('job_fair_region_type').then(setJobFairRegionTypeEnum);
getDictValueEnum('sys_yes_no').then(setYesNoEnum);
}, []);
@@ -129,6 +131,26 @@ const PublicJobFairList: React.FC = () => {
valueEnum: jobFairTypeEnum,
render: (_, record) => <DictTag enums={jobFairTypeEnum} value={record.jobFairType} />,
},
{
title: '区域类型',
dataIndex: 'jobFairRegionType',
valueType: 'select',
valueEnum: jobFairRegionTypeEnum,
render: (_, record) => (
<DictTag enums={jobFairRegionTypeEnum} value={record.jobFairRegionType} />
),
},
{
title: '举办状态',
dataIndex: 'jobFairStatus',
hideInSearch: true,
render: (_, record) => {
const status = record.jobFairStatus;
if (!status) return '-';
const color = status === '未举办' ? 'blue' : status === '正在举办' ? 'green' : 'default';
return <Tag color={color}>{status}</Tag>;
},
},
{
title: '报名审核状态',
dataIndex: 'enterpriseReviewStatus',
@@ -284,6 +306,7 @@ const PublicJobFairList: React.FC = () => {
pageSize: params.pageSize,
jobFairTitle: params.jobFairTitle,
jobFairType: params.jobFairType,
jobFairRegionType: params.jobFairRegionType,
startDate: params.startDate,
endDate: params.endDate,
});
@@ -322,6 +345,7 @@ const PublicJobFairList: React.FC = () => {
open={modalVisible}
values={currentRow}
jobFairTypeEnum={jobFairTypeEnum}
jobFairRegionTypeEnum={jobFairRegionTypeEnum}
onCancel={() => {
setModalVisible(false);
setCurrentRow(undefined);

View File

@@ -10,6 +10,7 @@ import {
} from '@ant-design/pro-components';
import { Form, Button, Input, Tag, TreeSelect, Switch } from 'antd';
import { PlusOutlined, MinusCircleOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { useModel } from '@umijs/max';
import React, { useEffect, useRef, useState } from 'react';
import { DictValueEnumObj } from '@/components/DictTag';
import { getCmsCompanyList } from '@/services/company/list';
@@ -72,6 +73,8 @@ const waitTime = (time: number = 100) => {
};
const listEdit: React.FC<ListFormProps> = (props) => {
const { initialState } = useModel('@@initialState');
const isEnterprise = initialState?.currentUser?.userType === 'view';
const [form] = Form.useForm<ManageFormValues>();
const companyNameMap = useRef<Record<number, string>>({});
const [jobDetail, setJobDetail] = useState<API.ManagementList.Manage | null>(null);
@@ -265,7 +268,7 @@ const listEdit: React.FC<ListFormProps> = (props) => {
<ProDescriptions.Item dataIndex="jobLocation" label="工作地点" />
<ProDescriptions.Item
dataIndex="jobType"
label="岗位类型"
label="区域"
valueEnum={jobTypeEnum}
/>
<ProDescriptions.Item dataIndex="jobCategory" label="岗位标签" />
@@ -434,26 +437,28 @@ const listEdit: React.FC<ListFormProps> = (props) => {
initialValue={0}
fieldProps={{ defaultChecked: false }}
/>
<ProFormSelect
showSearch
width="md"
name="companyId"
request={async ({ keyWords }) => {
const resData = await getCmsCompanyList({ name: keyWords });
return resData.rows.map((item) => {
companyNameMap.current[item.companyId] = item.name;
return { label: item.name, value: item.companyId };
});
}}
fieldProps={{
onChange: (companyId: number) => {
form.setFieldValue('companyName', companyNameMap.current[companyId] ?? '');
},
}}
placeholder="请输入公司名称选择公司"
rules={[{ required: true, message: '请输入公司名称选择公司!' }]}
label="招聘公司"
/>
{!isEnterprise && (
<ProFormSelect
showSearch
width="md"
name="companyId"
request={async ({ keyWords }) => {
const resData = await getCmsCompanyList({ name: keyWords });
return resData.rows.map((item) => {
companyNameMap.current[item.companyId] = item.name;
return { label: item.name, value: item.companyId };
});
}}
fieldProps={{
onChange: (companyId: number) => {
form.setFieldValue('companyName', companyNameMap.current[companyId] ?? '');
},
}}
placeholder="请输入公司名称选择公司"
rules={[{ required: true, message: '请输入公司名称选择公司!' }]}
label="招聘公司"
/>
)}
<ProFormDigit
name="minSalary"
width="md"
@@ -529,10 +534,10 @@ const listEdit: React.FC<ListFormProps> = (props) => {
<ProFormSelect
width="md"
name="jobType"
label={'岗位类型'}
label={'区域'}
valueEnum={jobTypeEnum}
placeholder="请选择类型"
rules={[{ required: true, message: '请选择类型!' }]}
placeholder="请选择区域"
rules={[{ required: true, message: '请选择区域!' }]}
/>
<ProForm.Item name="jobCategory" label="岗位标签" rules={[{ required: true, message: '请选择岗位标签!' }]}>
<TreeSelect

View File

@@ -0,0 +1,174 @@
import { request } from '@umijs/max';
export type JobFairChannel = 'online' | 'outdoor';
/** 招聘会公共列表项;线上与户外接口统一为该结构。 */
export interface PortalJobFairItem {
jobFairId: string;
jobFairTitle: string;
jobFairAddress?: string;
/** 线上接口通常为 1/2户外接口固定返回 outdoor。 */
jobFairType?: string;
jobFairStartTime?: string;
jobFairEndTime?: string;
jobFairStatus?: string;
jobFairHostUnit?: string;
jobFairOrganizeUnit?: string;
jobFairIntroduction?: string;
jobFairPhone?: string;
jobFairImage?: string;
enterpriseNum?: string;
boothNum?: string;
divisionName?: string;
jobFairCategory?: string;
jobFairRegion?: string;
jobFairVenueName?: string;
}
export interface PortalJobFairPageParams {
pageNum?: number;
pageSize?: number;
jobFairTitle?: string;
/** 招聘会举办日期,格式 yyyy-MM-dd。 */
zphjbsj?: string;
}
export interface PortalJobFairPageData {
total: number;
list: PortalJobFairItem[];
pageNum?: number;
pageSize?: number;
pages?: number;
}
export interface PortalJobFairPageResult {
code: number;
msg?: string;
data?: PortalJobFairPageData;
}
export interface PortalJobFairListResult {
code: number;
msg?: string;
data?: PortalJobFairItem[];
}
export interface PortalJobFairDatesResult {
code: number;
msg?: string;
data?: string[];
}
interface PortalOutdoorFairDetailData extends Partial<PortalJobFairItem> {
id?: number;
title?: string;
hostUnit?: string;
fairType?: string;
region?: string;
venueName?: string;
address?: string;
boothCount?: number;
holdTime?: string;
endTime?: string;
photoUrl?: string;
}
interface PortalOutdoorFairDetailResult {
code: number;
msg?: string;
/**
* 旧接口返回 { fair: {...} },当前公共接口直接返回招聘会对象。
* 两种格式均保留兼容,避免接口升级后户外详情页显示为空。
*/
data?: PortalOutdoorFairDetailData | { fair?: PortalOutdoorFairDetailData };
}
function isOutdoorFairDetailWrapper(
data: NonNullable<PortalOutdoorFairDetailResult['data']>,
): data is { fair?: PortalOutdoorFairDetailData } {
return 'fair' in data;
}
export interface PortalJobFairDetailResult {
code: number;
msg?: string;
data?: PortalJobFairItem;
}
const FAIR_API = {
online: '/app/jobfair/public/jobfair',
outdoor: '/app/outdoor-fair',
} as const;
/** 获取线上或户外招聘会分页列表。 */
export async function getPortalJobFairPage(
channel: JobFairChannel,
params?: PortalJobFairPageParams,
) {
return request<PortalJobFairPageResult>(`${FAIR_API[channel]}/page`, {
method: 'GET',
params,
});
}
/** 获取有招聘会的日期,用于与小程序一致的日期筛选提示。 */
export async function getPortalJobFairDates(channel: JobFairChannel) {
return request<PortalJobFairDatesResult>(`${FAIR_API[channel]}/dates`, {
method: 'GET',
});
}
/** 获取当前月招聘会摘要。 */
export async function getPortalCurrentMonthJobFairs(channel: JobFairChannel) {
return request<PortalJobFairListResult>(`${FAIR_API[channel]}/currentMonth`, {
method: 'GET',
});
}
/** 获取当前季度招聘会摘要。 */
export async function getPortalCurrentQuarterJobFairs(channel: JobFairChannel) {
return request<PortalJobFairListResult>(`${FAIR_API[channel]}/currentQuarter`, {
method: 'GET',
});
}
/** 获取招聘会详情,并把户外招聘会详情转换为与列表一致的公共字段。 */
export async function getPortalJobFairDetail(channel: JobFairChannel, jobFairId: string) {
if (channel === 'online') {
return request<PortalJobFairDetailResult>(`${FAIR_API.online}/detail`, {
method: 'GET',
params: { jobFairId },
});
}
const response = await request<PortalOutdoorFairDetailResult>(
`${FAIR_API.outdoor}/${encodeURIComponent(jobFairId)}`,
{ method: 'GET' },
);
const payload = response.data;
const fair = payload && (isOutdoorFairDetailWrapper(payload) ? payload.fair : payload);
if (!fair) {
return { code: response.code, msg: response.msg } as PortalJobFairDetailResult;
}
return {
code: response.code,
msg: response.msg,
data: {
jobFairId: fair.jobFairId || String(fair.id ?? jobFairId),
jobFairTitle: fair.jobFairTitle || fair.title || '户外招聘会',
jobFairAddress: fair.jobFairAddress || fair.address || fair.venueName,
jobFairType: fair.jobFairType || fair.fairType || 'outdoor',
jobFairStartTime: fair.jobFairStartTime || fair.holdTime,
jobFairEndTime: fair.jobFairEndTime || fair.endTime,
jobFairHostUnit: fair.jobFairHostUnit || fair.hostUnit,
jobFairOrganizeUnit: fair.jobFairOrganizeUnit,
jobFairIntroduction: fair.jobFairIntroduction,
jobFairPhone: fair.jobFairPhone,
jobFairImage: fair.jobFairImage || fair.photoUrl,
boothNum:
fair.boothNum || (fair.boothCount === undefined ? undefined : String(fair.boothCount)),
jobFairRegion: fair.jobFairRegion || fair.region,
jobFairVenueName: fair.jobFairVenueName || fair.venueName,
},
} as PortalJobFairDetailResult;
}

View File

@@ -8,6 +8,7 @@ declare namespace API.PublicJobFair {
pageSize?: number;
jobFairTitle?: string;
jobFairType?: string;
jobFairRegionType?: string;
startDate?: string;
endDate?: string;
}
@@ -18,8 +19,10 @@ declare namespace API.PublicJobFair {
jobFairTitle: string;
jobFairAddress: string;
jobFairType: string;
jobFairRegionType?: string;
jobFairStartTime: string;
jobFairEndTime: string;
jobFairStatus?: '未举办' | '正在举办' | '已举办';
jobFairSignUpStartTime?: string;
jobFairSignUpEndTime?: string;
jobFairHostUnit?: string;
@@ -101,6 +104,7 @@ declare namespace API.PublicJobFair {
jobFairTitle: string;
jobFairAddress: string;
jobFairType: string;
jobFairRegionType: string;
jobFairStartTime: string;
jobFairEndTime: string;
jobFairSignUpStartTime?: string;