first commit
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
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:
558
src/pages/JobPortal/Detail/index.tsx
Normal file
558
src/pages/JobPortal/Detail/index.tsx
Normal file
@@ -0,0 +1,558 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, history, useLocation } from '@umijs/max';
|
||||
import {
|
||||
Card,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Space,
|
||||
Button,
|
||||
Progress,
|
||||
Divider,
|
||||
Avatar,
|
||||
message
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
HeartOutlined,
|
||||
HeartFilled,
|
||||
EnvironmentOutlined,
|
||||
ClockCircleOutlined,
|
||||
UserOutlined,
|
||||
TrophyOutlined,
|
||||
FireOutlined,
|
||||
StarOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { Radar } from '@ant-design/charts';
|
||||
import JobPortalHeader from '@/components/JobPortalHeader';
|
||||
import './index.less';
|
||||
import { getJobCompetitiveness } from '@/services/jobportal/competitiveness';
|
||||
import { favoriteJob, unfavoriteJob, applyJob } from '@/services/jobportal/user';
|
||||
import { getAccessToken } from '@/access';
|
||||
import { PageEnum } from '@/enums/pagesEnums';
|
||||
|
||||
const { Title, Text, Paragraph } = 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 getUserIdFromCache = (): number | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem('userInfo');
|
||||
if (cached) {
|
||||
const userInfo = JSON.parse(cached);
|
||||
return userInfo?.userId || null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('读取缓存用户信息失败:', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 格式化薪资显示
|
||||
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 transformJobData = (jobData: any) => {
|
||||
return {
|
||||
id: jobData.jobId || jobData.id,
|
||||
title: jobData.jobTitle || jobData.title,
|
||||
company: jobData.companyName || jobData.company,
|
||||
companyLogo: jobData.companyLogo || 'https://via.placeholder.com/80',
|
||||
salary: jobData.minSalary && jobData.maxSalary
|
||||
? formatSalary(jobData.minSalary, jobData.maxSalary)
|
||||
: (jobData.salary || '面议'),
|
||||
location: jobData.jobLocation || jobData.location,
|
||||
experience: jobData.experience ? experienceMap[jobData.experience] || jobData.experience : '不限',
|
||||
education: jobData.education ? educationMap[jobData.education] || jobData.education : '不限',
|
||||
tags: jobData.tags || ['五险一金', '带薪年假', '年终奖'],
|
||||
publishTime: jobData.publishTime || new Date().toISOString().split('T')[0],
|
||||
description: jobData.description || `
|
||||
<h3>职位职责:</h3>
|
||||
<p>1. 负责相关业务系统的设计与开发;</p>
|
||||
<p>2. 参与系统架构设计和技术选型;</p>
|
||||
<p>3. 与团队协作完成项目开发;</p>
|
||||
|
||||
<h3>任职要求:</h3>
|
||||
<p>1. 具备相关工作经验;</p>
|
||||
<p>2. 熟悉相关技术栈;</p>
|
||||
<p>3. 具备良好的团队协作能力;</p>
|
||||
`,
|
||||
companyInfo: {
|
||||
name: jobData.companyName || jobData.company,
|
||||
type: jobData.industry || '互联网/电子商务',
|
||||
scale: jobData.scale || '100-499人',
|
||||
industry: jobData.industry || '计算机/互联网',
|
||||
description: jobData.companyVo?.companyDescription || jobData.companyDescription || '暂无公司描述信息'
|
||||
},
|
||||
competitiveness: {
|
||||
overall: 75,
|
||||
requirements: 70,
|
||||
salary: 80,
|
||||
company: 75,
|
||||
benefits: 80
|
||||
},
|
||||
marketTrend: [
|
||||
{ name: 'Java', demand: 85, salary: 90 },
|
||||
{ name: 'Spring Boot', demand: 75, salary: 85 },
|
||||
{ name: 'MySQL', demand: 70, salary: 75 },
|
||||
{ name: 'Redis', demand: 75, salary: 80 }
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
// 职位详情数据(模拟数据)
|
||||
const mockJobDetail = {
|
||||
id: 1,
|
||||
title: 'Java高级开发工程师',
|
||||
company: '青岛科技发展有限公司',
|
||||
companyLogo: 'https://via.placeholder.com/80',
|
||||
salary: '15k-25k',
|
||||
location: '青岛·李沧区',
|
||||
experience: '3-5年',
|
||||
education: '本科',
|
||||
tags: ['五险一金', '带薪年假', '年终奖', '定期体检'],
|
||||
publishTime: '2024-03-15',
|
||||
description: `
|
||||
<h3>职位职责:</h3>
|
||||
<p>1. 负责后端系统架构设计与优化,提升系统性能和稳定性;</p>
|
||||
<p>2. 参与核心业务模块的设计与开发,使用Java、Spring Boot等框架;</p>
|
||||
<p>3. 协作完成微服务架构,解决分布式系统相关问题;</p>
|
||||
<p>4. 进行代码审查,确保代码质量和规范性;</p>
|
||||
<p>5. 与前端、产品、测试等团队协作,推动项目快速迭代;</p>
|
||||
|
||||
<h3>任职要求:</h3>
|
||||
<p>1. 本科及以上学历,计算机相关专业,3年以上Java开发经验;</p>
|
||||
<p>2. 扎实的Java基础,熟悉Spring、Spring Boot、MyBatis等框架;</p>
|
||||
<p>3. 熟悉MySQL、Redis等数据库,具备SQL优化能力;</p>
|
||||
<p>4. 了解分布式系统设计,有微服务开发经验;</p>
|
||||
<p>5. 具备良好的团队协作能力和学习能力;</p>
|
||||
`,
|
||||
companyInfo: {
|
||||
name: '青岛科技发展有限公司',
|
||||
type: '互联网/电子商务',
|
||||
scale: '500-999人',
|
||||
industry: '计算机/互联网',
|
||||
description: '青岛科技发展有限公司是一家专注于软件开发、技术服务的科技企业。公司致力于为客户提供优质的IT解决方案,业务涵盖企业信息化、移动应用开发等领域。我们拥有一支年轻、专业的团队,注重技术创新和人才培养,为员工提供良好的发展平台。'
|
||||
},
|
||||
competitiveness: {
|
||||
overall: 75,
|
||||
requirements: 70,
|
||||
salary: 80,
|
||||
company: 75,
|
||||
benefits: 80
|
||||
},
|
||||
marketTrend: [
|
||||
{ name: 'Java', demand: 85, salary: 90 },
|
||||
{ name: 'Spring Boot', demand: 75, salary: 85 },
|
||||
{ name: 'MySQL', demand: 70, salary: 75 },
|
||||
{ name: 'Redis', demand: 75, salary: 80 }
|
||||
]
|
||||
};
|
||||
|
||||
const JobDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
const [jobDetail, setJobDetail] = useState(mockJobDetail);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [originalJobData, setOriginalJobData] = useState<any>(null); // 保存原始数据引用
|
||||
const [overallScore, setOverallScore] = useState<number>(0);
|
||||
const [radarData, setRadarData] = useState<{ item: string; score: number }[]>([
|
||||
{ item: '技能', score: 0 },
|
||||
{ item: '工作经验', score: 0 },
|
||||
{ item: '学历', score: 0 },
|
||||
{ item: '薪资', score: 0 },
|
||||
{ item: '年龄', score: 0 },
|
||||
{ item: '工作地', score: 0 }
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// 首先尝试从传递的数据中获取职位信息
|
||||
const passedJobData = (location.state as any)?.jobData;
|
||||
|
||||
if (passedJobData) {
|
||||
// 保存原始数据引用
|
||||
setOriginalJobData(passedJobData);
|
||||
// 将传递过来的数据转换为详情页面需要的格式
|
||||
const transformedJobData = transformJobData(passedJobData);
|
||||
setJobDetail(transformedJobData);
|
||||
// 根据 isCollection 字段设置收藏状态
|
||||
// isCollection 为 null 或 0 表示未收藏,其他值表示已收藏
|
||||
setIsFavorited(passedJobData?.isCollection !== null && passedJobData?.isCollection !== 0 && passedJobData?.isCollection !== undefined);
|
||||
} else {
|
||||
// 如果没有传递数据,则根据id从API获取数据
|
||||
// fetchJobDetail(id);
|
||||
// 默认未收藏
|
||||
setOriginalJobData(null);
|
||||
setIsFavorited(false);
|
||||
}
|
||||
|
||||
}, [id, location.state]);
|
||||
|
||||
// 基于详情中的 id 再拉一次(避免从路由未传 id 的情况)
|
||||
useEffect(() => {
|
||||
const jobIdNum = Number((jobDetail as any)?.id);
|
||||
if (!Number.isNaN(jobIdNum) && jobIdNum) {
|
||||
fetchCompetitiveness(jobIdNum);
|
||||
}
|
||||
}, [jobDetail?.id]);
|
||||
|
||||
const fetchCompetitiveness = async (jobIdNum: number) => {
|
||||
try {
|
||||
const res = await getJobCompetitiveness(jobIdNum);
|
||||
const data = res as any;
|
||||
if (!data) return;
|
||||
if (typeof data.matchScore === 'number') {
|
||||
const clamped = Math.max(0, Math.min(100, data.matchScore));
|
||||
setOverallScore(clamped);
|
||||
} else {
|
||||
setOverallScore(0);
|
||||
}
|
||||
if (data.radarChart) {
|
||||
const r = data.radarChart as any;
|
||||
const mapped = [
|
||||
{ item: '技能', score: r.skill ?? 0 },
|
||||
{ item: '工作经验', score: r.experience ?? 0 },
|
||||
{ item: '学历', score: r.education ?? 0 },
|
||||
{ item: '薪资', score: r.salary ?? 0 },
|
||||
{ item: '年龄', score: r.age ?? 0 },
|
||||
{ item: '工作地', score: r.location ?? 0 }
|
||||
].map(d => ({ ...d, score: Math.max(0, Math.min(100, d.score)) }));
|
||||
setRadarData(mapped);
|
||||
} else {
|
||||
setRadarData([
|
||||
{ item: '技能', score: 0 },
|
||||
{ item: '工作经验', score: 0 },
|
||||
{ item: '学历', score: 0 },
|
||||
{ item: '薪资', score: 0 },
|
||||
{ item: '年龄', score: 0 },
|
||||
{ item: '工作地', score: 0 }
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
// 保持默认mock数据
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollect = async () => {
|
||||
// 检查用户是否登录
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
message.warning('请先登录');
|
||||
history.push(PageEnum.LOGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是取消收藏,调用取消收藏接口
|
||||
if (isFavorited) {
|
||||
// 获取用户ID
|
||||
const userId = getUserIdFromCache();
|
||||
if (!userId) {
|
||||
message.warning('无法获取用户信息,请重新登录');
|
||||
history.push(PageEnum.LOGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取职位ID(从原始数据或转换后的数据中获取)
|
||||
const jobId = originalJobData?.jobId || originalJobData?.id || (jobDetail as any)?.id || id;
|
||||
if (!jobId) {
|
||||
message.error('职位信息不完整,无法取消收藏');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await unfavoriteJob({ jobId: Number(jobId), userId });
|
||||
if (response?.code === 200) {
|
||||
setIsFavorited(false);
|
||||
// 更新原始数据的 isCollection 字段
|
||||
if (originalJobData) {
|
||||
setOriginalJobData({ ...originalJobData, isCollection: 0 });
|
||||
}
|
||||
message.success('取消收藏成功');
|
||||
} else {
|
||||
message.error(response?.msg || '取消收藏失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消收藏失败:', error);
|
||||
message.error('取消收藏失败,请重试');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是收藏,调用收藏接口
|
||||
// 获取用户ID
|
||||
const userId = getUserIdFromCache();
|
||||
if (!userId) {
|
||||
message.warning('无法获取用户信息,请重新登录');
|
||||
history.push(PageEnum.LOGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取职位ID
|
||||
const jobId = originalJobData?.jobId || originalJobData?.id || (jobDetail as any)?.id || id;
|
||||
if (!jobId) {
|
||||
message.error('职位信息不完整');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await favoriteJob({ jobId: Number(jobId), userId });
|
||||
if (response?.code === 200) {
|
||||
setIsFavorited(true);
|
||||
// 更新原始数据的 isCollection 字段
|
||||
if (originalJobData) {
|
||||
setOriginalJobData({ ...originalJobData, isCollection: 1 });
|
||||
}
|
||||
message.success('收藏成功');
|
||||
} else {
|
||||
message.error(response?.msg || '收藏失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('收藏操作失败:', error);
|
||||
message.error('收藏操作失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
// 检查用户是否登录
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
message.warning('请先登录');
|
||||
history.push(PageEnum.LOGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取用户ID
|
||||
const userId = getUserIdFromCache();
|
||||
if (!userId) {
|
||||
message.warning('无法获取用户信息,请重新登录');
|
||||
history.push(PageEnum.LOGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取职位ID(从原始数据或转换后的数据中获取)
|
||||
const jobId = originalJobData?.jobId || originalJobData?.id || (jobDetail as any)?.id || id;
|
||||
if (!jobId) {
|
||||
message.error('职位信息不完整,无法申请');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await applyJob({
|
||||
jobId: String(jobId),
|
||||
userId: String(userId)
|
||||
});
|
||||
if (response?.code === 200) {
|
||||
message.success('申请成功');
|
||||
} else {
|
||||
message.error(response?.msg || '申请失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('申请失败:', error);
|
||||
message.error('申请失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="job-detail-page">
|
||||
<JobPortalHeader showSearch={false} showHotJobs={false} />
|
||||
|
||||
<div className="job-detail-container">
|
||||
|
||||
{/* 职位头部信息 */}
|
||||
<Card className="job-header-card">
|
||||
<Row gutter={24}>
|
||||
<Col span={18}>
|
||||
<div className="job-title-section">
|
||||
<Space direction="vertical" size={8}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px', flexWrap: 'wrap' }}>
|
||||
<Space align="baseline" size={16}>
|
||||
<Title level={2} className="job-title" style={{ marginBottom: 0 }}>{jobDetail.title}</Title>
|
||||
<Text className="job-salary-display">{jobDetail.salary}</Text>
|
||||
</Space>
|
||||
<Button
|
||||
icon={isFavorited ? <HeartFilled /> : <HeartOutlined />}
|
||||
danger={isFavorited}
|
||||
onClick={handleCollect}
|
||||
size="middle"
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: 500,
|
||||
height: '32px',
|
||||
padding: '0 12px',
|
||||
fontSize: '14px',
|
||||
...(isFavorited ? {} : {
|
||||
borderColor: '#1890ff',
|
||||
color: '#1890ff'
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isFavorited ? '已收藏' : '收藏'}
|
||||
</Button>
|
||||
</div>
|
||||
<Space size={16}>
|
||||
<Text className="job-meta">
|
||||
<EnvironmentOutlined /> {jobDetail.location}
|
||||
</Text>
|
||||
<Text className="job-meta">
|
||||
<ClockCircleOutlined /> {jobDetail.experience}
|
||||
</Text>
|
||||
<Text className="job-meta">
|
||||
<UserOutlined /> {jobDetail.education}
|
||||
</Text>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
{jobDetail.tags.map((tag, index) => (
|
||||
<Tag key={index} color="blue">{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<div className="job-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
onClick={handleApply}
|
||||
>
|
||||
立即申请
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Row gutter={16}>
|
||||
{/* 左侧内容区 */}
|
||||
<Col span={16}>
|
||||
{/* 职位描述 */}
|
||||
<Card title="职位描述" className="info-card">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: jobDetail.description }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 公司信息 */}
|
||||
<Card title="公司信息" className="info-card">
|
||||
<div className="company-intro">
|
||||
<Space size={16}>
|
||||
<Avatar size={64} src={jobDetail.companyLogo} />
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{jobDetail.companyInfo.name}
|
||||
</Title>
|
||||
<Space className="company-meta">
|
||||
<Text>{jobDetail.companyInfo.type}</Text>
|
||||
<Divider type="vertical" />
|
||||
<Text>{jobDetail.companyInfo.scale}</Text>
|
||||
<Divider type="vertical" />
|
||||
<Text>{jobDetail.companyInfo.industry}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
</Space>
|
||||
<Divider />
|
||||
<Paragraph>{jobDetail.companyInfo.description}</Paragraph>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 右侧侧边栏 */}
|
||||
<Col span={8}>
|
||||
{/* 竞争力分析 */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<TrophyOutlined />
|
||||
竞争力分析
|
||||
</Space>
|
||||
}
|
||||
className="competitiveness-card"
|
||||
>
|
||||
<div className="radar-chart-container">
|
||||
<Radar
|
||||
data={radarData}
|
||||
xField="item"
|
||||
yField="score"
|
||||
area={{
|
||||
style: {
|
||||
fill: 'rgba(24, 144, 255, 0.25)',
|
||||
fillOpacity: 0.5,
|
||||
},
|
||||
}}
|
||||
point={{
|
||||
size: 3,
|
||||
style: {
|
||||
fill: '#1890ff',
|
||||
stroke: '#1890ff',
|
||||
},
|
||||
}}
|
||||
line={{
|
||||
style: {
|
||||
stroke: '#1890ff',
|
||||
lineWidth: 2,
|
||||
},
|
||||
}}
|
||||
meta={{
|
||||
score: {
|
||||
alias: '评分',
|
||||
min: 0,
|
||||
max: 100,
|
||||
nice: true,
|
||||
},
|
||||
}}
|
||||
yAxis={{
|
||||
label: false,
|
||||
tickLine: null,
|
||||
line: null,
|
||||
grid: null,
|
||||
}}
|
||||
height={300}
|
||||
/>
|
||||
<div className="radar-summary">
|
||||
<div className="summary-score">
|
||||
<div className="score-value">{overallScore}</div>
|
||||
<div className="score-label">综合评分</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobDetailPage;
|
||||
|
||||
Reference in New Issue
Block a user