- Added company nature selection to ManagementList and JobDetailPage. - Implemented detailed tags for job benefits and schedules in JobDetailPage and JobListPage. - Introduced CompanyInfoPage for managing company details, including nature, scale, and contacts. - Enhanced ManagementList to support salary composition, welfare benefits, and work schedule fields. - Created utility functions for parsing and serializing comma-separated tags. - Updated API services to fetch and update current company information. - Added tests for new utility functions to ensure correct parsing and serialization of tags.
452 lines
15 KiB
TypeScript
452 lines
15 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
Card,
|
||
Row,
|
||
Col,
|
||
Tag,
|
||
Typography,
|
||
Button,
|
||
message
|
||
} from 'antd';
|
||
import {
|
||
RightOutlined,
|
||
LeftOutlined,
|
||
EnvironmentOutlined
|
||
} from '@ant-design/icons';
|
||
import { history } from '@umijs/max';
|
||
import { getJobTitleTreeSelect, getJobRecommend } from '@/services/common/jobTitle';
|
||
import { getGetInfoCache, saveGetInfoCache } from '@/utils/jobPortalAuth';
|
||
import { getAccessToken } from '@/access';
|
||
import { getUserInfo } from '@/services/session';
|
||
import JobPortalHeader from '@/components/JobPortalHeader';
|
||
import { getDictValueEnum } from '@/services/system/dict';
|
||
import { getDictLabel } from '@/utils/jobPortalDict';
|
||
import type { DictValueEnumObj } from '@/components/DictTag';
|
||
import './index.less';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
const educationMap: { [key: string]: string } = {
|
||
'1': '大专',
|
||
'2': '本科',
|
||
'3': '硕士',
|
||
'4': '博士',
|
||
'0': '不限'
|
||
};
|
||
|
||
const experienceMap: { [key: string]: string } = {
|
||
'1': '1年以下',
|
||
'2': '1-3年',
|
||
'3': '3-5年',
|
||
'4': '5-10年',
|
||
'5': '10年以上',
|
||
'0': '不限'
|
||
};
|
||
|
||
const defaultJobs = [
|
||
{
|
||
id: 1,
|
||
title: 'Java高级开发工程师',
|
||
company: '青岛科技发展有限公司',
|
||
salary: '15k-25k',
|
||
location: '青岛·李沧区',
|
||
experience: '3-5年',
|
||
education: '本科',
|
||
tags: ['五险一金', '带薪年假', '年终奖']
|
||
},
|
||
{
|
||
id: 2,
|
||
title: '前端开发工程师',
|
||
company: '青岛智能科技有限公司',
|
||
salary: '12k-20k',
|
||
location: '青岛·市南区',
|
||
experience: '2-4年',
|
||
education: '本科',
|
||
tags: ['双休', '弹性工作', '股票期权']
|
||
},
|
||
{
|
||
id: 3,
|
||
title: 'Python开发工程师',
|
||
company: '青岛数据科技公司',
|
||
salary: '14k-22k',
|
||
location: '青岛·崂山区',
|
||
experience: '3-5年',
|
||
education: '本科',
|
||
tags: ['五险一金', '技术培训', '团建活动']
|
||
},
|
||
{
|
||
id: 4,
|
||
title: '产品经理',
|
||
company: '青岛互联网科技有限公司',
|
||
salary: '16k-30k',
|
||
location: '青岛·市北区',
|
||
experience: '3-6年',
|
||
education: '本科',
|
||
tags: ['五险一金', '双休', '带薪年假']
|
||
},
|
||
{
|
||
id: 5,
|
||
title: '测试工程师',
|
||
company: '青岛软件科技有限公司',
|
||
salary: '10k-18k',
|
||
location: '青岛·黄岛区',
|
||
experience: '2-5年',
|
||
education: '本科',
|
||
tags: ['五险一金', '带薪年假', '节日福利']
|
||
},
|
||
{
|
||
id: 6,
|
||
title: 'UI设计师',
|
||
company: '青岛设计有限公司',
|
||
salary: '11k-20k',
|
||
location: '青岛·市南区',
|
||
experience: '2-4年',
|
||
education: '大专',
|
||
tags: ['五险一金', '年终奖', '员工旅游']
|
||
},
|
||
{
|
||
id: 7,
|
||
title: '数据分析师',
|
||
company: '青岛大数据科技公司',
|
||
salary: '13k-25k',
|
||
location: '青岛·崂山区',
|
||
experience: '2-5年',
|
||
education: '本科',
|
||
tags: ['五险一金', '双休', '技能培训']
|
||
},
|
||
{
|
||
id: 8,
|
||
title: '算法工程师',
|
||
company: '青岛人工智能科技公司',
|
||
salary: '20k-35k',
|
||
location: '青岛·崂山区',
|
||
experience: '3-7年',
|
||
education: '硕士',
|
||
tags: ['五险一金', '股票期权', '技术交流']
|
||
}
|
||
];
|
||
|
||
const JobPortalPage: React.FC = () => {
|
||
const [hoveredIndustry, setHoveredIndustry] = useState<string>('');
|
||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||
const [jobTitleData, setJobTitleData] = useState<any>(null);
|
||
const [jobRecommendData, setJobRecommendData] = useState<any>(null);
|
||
const [companyNatureEnum, setCompanyNatureEnum] = useState<DictValueEnumObj>({});
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
const [itemsPerPage] = useState<number>(6);
|
||
const [totalPages, setTotalPages] = useState<number>(1);
|
||
const [activeJobTitle, setActiveJobTitle] = useState<string>('');
|
||
const [jobTitles, setJobTitles] = useState<string[]>(() => {
|
||
// 初始化时先尝试从缓存读取
|
||
return (getGetInfoCache()?.user as any)?.jobTitles || [];
|
||
});
|
||
|
||
// 缓存可能因 SSO 登录时序问题尚未写入,主动拉取一次
|
||
useEffect(() => {
|
||
if (jobTitles.length > 0) return;
|
||
const cached = (getGetInfoCache()?.user as any)?.jobTitles;
|
||
if (cached?.length) {
|
||
setJobTitles(cached);
|
||
return;
|
||
}
|
||
// 未登录用户不请求 /api/getInfo,避免 401 触发强制跳转登录页
|
||
if (!getAccessToken()) return;
|
||
getUserInfo({ skipErrorHandler: true })
|
||
.then((res: any) => {
|
||
saveGetInfoCache(res as Record<string, unknown>);
|
||
const titles = (res?.user as any)?.jobTitles;
|
||
if (titles?.length) {
|
||
setJobTitles(titles);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// 静默处理
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const fetchJobTitleData = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const response = await getJobTitleTreeSelect();
|
||
setJobTitleData(response);
|
||
if (response?.data && response.data.length > 0) {
|
||
const pages = Math.ceil(response.data.length / itemsPerPage);
|
||
setTotalPages(pages);
|
||
setHoveredIndustry(response.data[0].id.toString());
|
||
}
|
||
} catch (error) {
|
||
message.error('获取职位数据失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchJobRecommendData = async (jobTitle?: string) => {
|
||
try {
|
||
const response = await getJobRecommend(jobTitle ? { jobTitle } : undefined);
|
||
setJobRecommendData(response);
|
||
} catch {
|
||
// 静默处理
|
||
}
|
||
};
|
||
|
||
fetchJobTitleData();
|
||
fetchJobRecommendData();
|
||
getDictValueEnum('company_nature', false, true)
|
||
.then(setCompanyNatureEnum)
|
||
.catch(() => setCompanyNatureEnum({}));
|
||
}, [itemsPerPage]);
|
||
|
||
const handleIndustryHover = (industryId: string) => {
|
||
setHoveredIndustry(industryId);
|
||
};
|
||
|
||
const getCurrentProfessions = () => {
|
||
if (!jobTitleData?.data) return [];
|
||
const industry = jobTitleData.data.find((item: any) => item.id.toString() === hoveredIndustry);
|
||
return industry?.children || [];
|
||
};
|
||
|
||
const getCurrentPageIndustries = () => {
|
||
if (!jobTitleData?.data) return [];
|
||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||
return jobTitleData.data.slice(startIndex, startIndex + itemsPerPage);
|
||
};
|
||
|
||
const getHoveredIndustryLabel = () => {
|
||
return jobTitleData?.data?.find((item: any) => item.id.toString() === hoveredIndustry)?.label || '';
|
||
};
|
||
|
||
const formatSalary = (minSalary: number, maxSalary: number) => {
|
||
if (minSalary && maxSalary) {
|
||
return `${(minSalary / 1000).toFixed(0)}k-${(maxSalary / 1000).toFixed(0)}k`;
|
||
} else if (minSalary) {
|
||
return `${(minSalary / 1000).toFixed(0)}k+`;
|
||
} else if (maxSalary) {
|
||
return `面议-${(maxSalary / 1000).toFixed(0)}k`;
|
||
}
|
||
return '面议';
|
||
};
|
||
|
||
const handlePageChange = (direction: 'prev' | 'next') => {
|
||
let nextPage = currentPage;
|
||
if (direction === 'prev' && currentPage > 1) {
|
||
nextPage = currentPage - 1;
|
||
} else if (direction === 'next' && currentPage < totalPages) {
|
||
nextPage = currentPage + 1;
|
||
} else {
|
||
return;
|
||
}
|
||
setCurrentPage(nextPage);
|
||
if (jobTitleData?.data) {
|
||
const firstOnPage = jobTitleData.data[(nextPage - 1) * itemsPerPage];
|
||
if (firstOnPage) {
|
||
setHoveredIndustry(firstOnPage.id.toString());
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleJobClick = (job: any) => {
|
||
history.push(`/job-portal/detail?jobId=${job.jobId || job.id}`, { jobData: job });
|
||
};
|
||
|
||
const handleProfessionTagClick = (name: string) => {
|
||
history.push(`/job-portal/list`, { queryParams: { name } });
|
||
};
|
||
|
||
const handleExpectationTagClick = (jobTitle: string) => {
|
||
const newActive = activeJobTitle === jobTitle ? '' : jobTitle;
|
||
setActiveJobTitle(newActive);
|
||
const fetchJobRecommendData = async () => {
|
||
try {
|
||
const response = await getJobRecommend(newActive ? { jobTitle: newActive } : undefined);
|
||
setJobRecommendData(response);
|
||
} catch {
|
||
// 静默处理
|
||
}
|
||
};
|
||
fetchJobRecommendData();
|
||
};
|
||
|
||
const renderJobCards = () => {
|
||
const jobs = jobRecommendData?.data;
|
||
if (jobs?.length) {
|
||
return jobs.map((job: any) => (
|
||
<Col xs={24} sm={12} md={6} key={job.jobId}>
|
||
<Card
|
||
className="job-card"
|
||
hoverable
|
||
bodyStyle={{ padding: '20px' }}
|
||
onClick={() => handleJobClick(job)}
|
||
>
|
||
<div className="job-header">
|
||
<Title level={4} className="job-title">
|
||
{job.isUrgent === 1 && (
|
||
<Tag color="red" style={{ marginRight: 8, fontSize: 12, lineHeight: '20px' }}>急聘</Tag>
|
||
)}
|
||
{job.jobTitle}
|
||
</Title>
|
||
<Text className="job-salary">{formatSalary(job.minSalary, job.maxSalary)}</Text>
|
||
</div>
|
||
<Text className="job-company">{job.companyName}</Text>
|
||
<div className="job-info">
|
||
<Text className="job-location"><EnvironmentOutlined /> {job.jobLocation}</Text>
|
||
<Text className="job-experience">{experienceMap[job.experience] || '不限'}</Text>
|
||
<Text className="job-education">{educationMap[job.education] || '不限'}</Text>
|
||
</div>
|
||
<div className="job-tags">
|
||
{job.jobCategory && <Tag className="job-tag">{job.jobCategory}</Tag>}
|
||
{(job.companyNature || job.company?.companyNature) && (
|
||
<Tag className="job-tag">
|
||
{getDictLabel(companyNatureEnum, job.companyNature || job.company?.companyNature)}
|
||
</Tag>
|
||
)}
|
||
{job.vacancies && <Tag className="job-tag">招聘{job.vacancies}人</Tag>}
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
));
|
||
}
|
||
return defaultJobs.map(job => (
|
||
<Col xs={24} sm={12} md={6} key={job.id}>
|
||
<Card
|
||
className="job-card"
|
||
hoverable
|
||
bodyStyle={{ padding: '20px' }}
|
||
onClick={() => handleJobClick(job)}
|
||
>
|
||
<div className="job-header">
|
||
<Title level={4} className="job-title">{job.title}</Title>
|
||
<Text className="job-salary">{job.salary}</Text>
|
||
</div>
|
||
<Text className="job-company">{job.company}</Text>
|
||
<div className="job-info">
|
||
<Text className="job-location">{job.location}</Text>
|
||
<Text className="job-experience">{job.experience}</Text>
|
||
<Text className="job-education">{job.education}</Text>
|
||
</div>
|
||
<div className="job-tags">
|
||
{job.tags.map((tag, index) => (
|
||
<Tag key={index} className="job-tag">{tag}</Tag>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
));
|
||
};
|
||
|
||
return (
|
||
<div className="job-portal">
|
||
<JobPortalHeader showHotJobs={false} />
|
||
|
||
<div className="main-content">
|
||
<Row gutter={16} className="category-layout" align="stretch">
|
||
<Col xs={24} lg={6}>
|
||
<Card className="industry-panel" bodyStyle={{ padding: 0 }} loading={loading}>
|
||
<div className="panel-header">
|
||
<Title level={5} className="panel-title">行业分类</Title>
|
||
</div>
|
||
<div className="industry-list">
|
||
{getCurrentPageIndustries().map((industry: any) => (
|
||
<div
|
||
key={industry.id}
|
||
className={`industry-item ${
|
||
hoveredIndustry === industry.id.toString() ? 'active' : ''
|
||
}`}
|
||
onMouseEnter={() => handleIndustryHover(industry.id.toString())}
|
||
>
|
||
<span>{industry.label}</span>
|
||
<RightOutlined />
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="pagination">
|
||
<Button
|
||
type="text"
|
||
icon={<LeftOutlined />}
|
||
disabled={currentPage === 1}
|
||
onClick={() => handlePageChange('prev')}
|
||
/>
|
||
<span>{currentPage}/{totalPages}</span>
|
||
<Button
|
||
type="text"
|
||
icon={<RightOutlined />}
|
||
disabled={currentPage === totalPages}
|
||
onClick={() => handlePageChange('next')}
|
||
/>
|
||
</div>
|
||
{jobTitleData?.data && (
|
||
<div className="industry-count">共 {jobTitleData.data.length} 个行业</div>
|
||
)}
|
||
</Card>
|
||
</Col>
|
||
|
||
<Col xs={24} lg={18}>
|
||
<Card className="profession-panel" bodyStyle={{ padding: '20px 24px' }}>
|
||
<div className="panel-header">
|
||
<Title level={5} className="panel-title">
|
||
{getHoveredIndustryLabel() ? `${getHoveredIndustryLabel()} · 职位分类` : '职位分类'}
|
||
</Title>
|
||
<Text type="secondary" className="panel-tip">点击标签查看相关职位</Text>
|
||
</div>
|
||
<div className="profession-content">
|
||
{getCurrentProfessions().length > 0 ? (
|
||
getCurrentProfessions().map((profession: any) => (
|
||
<div key={profession.id} className="profession-category">
|
||
<div className="category-title">{profession.label}</div>
|
||
<div className="profession-tags">
|
||
{profession.children?.map((subProfession: any) => (
|
||
<Tag
|
||
key={subProfession.id}
|
||
className="profession-tag"
|
||
onClick={() => handleProfessionTagClick(subProfession.label)}
|
||
>
|
||
{subProfession.label}
|
||
</Tag>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="profession-empty">
|
||
<Text type="secondary">请从左侧选择行业查看职位分类</Text>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
<div className="jobs-section">
|
||
<div className="jobs-section-header">
|
||
<div className="jobs-section-header-left">
|
||
<Title level={3} className="jobs-title">热门职位推荐</Title>
|
||
{jobTitles.length > 0 && (
|
||
<div className="expectation-tags">
|
||
<span className="expectation-label">求职期望</span>
|
||
{jobTitles.map((title) => (
|
||
<Tag
|
||
key={title}
|
||
className={`expectation-tag ${activeJobTitle === title ? 'active' : ''}`}
|
||
onClick={() => handleExpectationTagClick(title)}
|
||
>
|
||
{title}
|
||
</Tag>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<span className="view-more" onClick={() => history.push('/job-portal/list')}>
|
||
查看更多 >
|
||
</span>
|
||
</div>
|
||
<Row gutter={[16, 16]}>{renderJobCards()}</Row>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default JobPortalPage;
|