Files
shz-admin/src/pages/JobPortal/index.tsx
lapuda ed0ac7c4c2 feat: Enhance Company and Job Portal functionality
- 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.
2026-07-20 11:53:43 +08:00

452 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 />&nbsp;{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')}>
&gt;
</span>
</div>
<Row gutter={[16, 16]}>{renderJobCards()}</Row>
</div>
</div>
</div>
);
};
export default JobPortalPage;