Files
shz-admin/src/pages/JobPortal/Detail/index.tsx

652 lines
22 KiB
TypeScript
Raw Normal View History

2026-06-04 16:25:38 +08:00
import React, { useState, useEffect, useMemo } from 'react';
2026-06-24 18:18:42 +08:00
import { useParams, history, useLocation, useSearchParams } from '@umijs/max';
2025-11-10 16:28:01 +08:00
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';
2026-06-04 16:25:38 +08:00
import {
getJobCompetitiveness,
getEmptyCompetitivenessRadar,
} from '@/services/jobportal/competitiveness';
import { isJobPortalLoggedIn } from '@/utils/jobPortalAuth';
2026-06-24 18:18:42 +08:00
import { favoriteJob, unfavoriteJob, applyJob, browseJob, getJobDetail } from '@/services/jobportal/user';
2026-06-04 18:47:59 +08:00
import { ensureJobPortalLogin, requireJobPortalUserId } from '@/utils/jobPortalAuth';
2026-06-04 17:15:41 +08:00
import { getDictLabel, resolveIndustryLabel } from '@/utils/jobPortalDict';
2026-06-04 16:25:38 +08:00
import { getDictValueEnum } from '@/services/system/dict';
import { getCmsIndustryTreeList } from '@/services/classify/industry';
import type { DictValueEnumObj } from '@/components/DictTag';
2025-11-10 16:28:01 +08:00
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 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: {
2026-06-04 17:15:41 +08:00
name: jobData.companyName || jobData.companyVo?.name || jobData.company,
scale: jobData.scale ?? '',
industry: jobData.industry ?? '',
2026-06-04 16:25:38 +08:00
description:
jobData.companyVo?.companyDescription
|| jobData.companyDescription
|| '暂无公司描述信息',
2025-11-10 16:28:01 +08:00
},
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. 使JavaSpring Boot等框架</p>
<p>3. </p>
<p>4. </p>
<p>5. </p>
<h3></h3>
<p>1. 3Java开发经验</p>
<p>2. Java基础SpringSpring BootMyBatis等框架</p>
<p>3. MySQLRedis等数据库SQL优化能力</p>
<p>4. </p>
<p>5. </p>
`,
companyInfo: {
name: '青岛科技发展有限公司',
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();
2026-06-24 18:18:42 +08:00
const [searchParams] = useSearchParams();
2025-11-10 16:28:01 +08:00
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 }
]);
2026-06-04 16:25:38 +08:00
const [scaleEnum, setScaleEnum] = useState<DictValueEnumObj>({});
const [industryTree, setIndustryTree] = useState<any[]>([]);
useEffect(() => {
Promise.all([
getDictValueEnum('scale', true, true),
getCmsIndustryTreeList(),
]).then(([scaleData, industryRes]) => {
setScaleEnum(scaleData);
if (industryRes?.code === 200 && industryRes?.data) {
setIndustryTree(industryRes.data);
}
});
}, []);
const companyMetaItems = useMemo(() => {
2026-06-04 17:15:41 +08:00
if (!originalJobData) {
return [];
}
const rawScale = originalJobData.scale;
const rawIndustry = originalJobData.industry;
2026-06-04 16:25:38 +08:00
const items: string[] = [];
2026-06-04 17:15:41 +08:00
if (rawScale != null && rawScale !== '') {
const scaleLabel = getDictLabel(scaleEnum, rawScale) || String(rawScale);
if (scaleLabel) items.push(scaleLabel);
}
2026-06-04 18:47:59 +08:00
2026-06-04 17:15:41 +08:00
const industryLabel = resolveIndustryLabel(industryTree, rawIndustry);
2026-06-04 18:47:59 +08:00
const rawStr = rawIndustry == null ? '' : String(rawIndustry).trim();
// 行业 id 未解析成名称时不展示裸数字,避免与行业名称重复
if (industryLabel && !(/^\d+$/.test(rawStr) && industryLabel === rawStr)) {
items.push(industryLabel);
}
2026-06-04 16:25:38 +08:00
return items;
2026-06-04 17:15:41 +08:00
}, [originalJobData, scaleEnum, industryTree]);
2025-11-10 16:28:01 +08:00
useEffect(() => {
// 首先尝试从传递的数据中获取职位信息
const passedJobData = (location.state as any)?.jobData;
if (passedJobData) {
// 保存原始数据引用
setOriginalJobData(passedJobData);
// 将传递过来的数据转换为详情页面需要的格式
const transformedJobData = transformJobData(passedJobData);
setJobDetail(transformedJobData);
// 根据 isCollection 字段设置收藏状态
setIsFavorited(passedJobData?.isCollection !== null && passedJobData?.isCollection !== 0 && passedJobData?.isCollection !== undefined);
} else {
2026-06-24 18:18:42 +08:00
// 直接刷新页面时,从 URL 参数或 route param 获取 jobId调 API 获取数据
const jobIdFromUrl = searchParams.get('jobId');
if (jobIdFromUrl) {
fetchJobDetailFromApi(jobIdFromUrl);
} else {
setOriginalJobData(null);
setIsFavorited(false);
}
2025-11-10 16:28:01 +08:00
}
}, [id, location.state]);
2026-06-24 18:18:42 +08:00
// 页面直接刷新时调接口获取岗位详情
const fetchJobDetailFromApi = async (jobId: string) => {
try {
setLoading(true);
const res = await getJobDetail(jobId);
if (res?.code === 200 && res?.data) {
const jobData = res.data;
setOriginalJobData(jobData);
const transformed = transformJobData(jobData);
setJobDetail(transformed);
setIsFavorited(jobData?.isCollection !== null && jobData?.isCollection !== 0 && jobData?.isCollection !== undefined);
} else {
setOriginalJobData(null);
setIsFavorited(false);
}
} catch (error) {
console.error('获取岗位详情失败:', error);
setOriginalJobData(null);
setIsFavorited(false);
} finally {
setLoading(false);
}
};
2026-06-04 18:47:59 +08:00
// 仅登录且有真实岗位 id 时请求竞争力
2025-11-10 16:28:01 +08:00
useEffect(() => {
2026-06-04 18:47:59 +08:00
if (!isJobPortalLoggedIn()) {
setOverallScore(0);
setRadarData(getEmptyCompetitivenessRadar());
return;
}
const jobIdRaw =
originalJobData?.jobId
?? originalJobData?.id
?? id;
const jobIdNum = Number(jobIdRaw);
if (!Number.isNaN(jobIdNum) && jobIdNum > 0) {
2025-11-10 16:28:01 +08:00
fetchCompetitiveness(jobIdNum);
}
2026-06-04 18:47:59 +08:00
}, [originalJobData?.jobId, originalJobData?.id, id]);
2025-11-10 16:28:01 +08:00
2026-06-16 19:11:36 +08:00
// 仅登录且有真实岗位 id 时上报浏览记录
useEffect(() => {
if (!isJobPortalLoggedIn()) {
return;
}
const jobIdRaw =
originalJobData?.jobId
?? originalJobData?.id
?? id;
const jobIdNum = Number(jobIdRaw);
if (!Number.isNaN(jobIdNum) && jobIdNum > 0) {
browseJob({ jobId: jobIdNum }).catch(() => {
// 浏览上报失败不阻塞页面
});
}
}, [originalJobData?.jobId, originalJobData?.id, id]);
2025-11-10 16:28:01 +08:00
const fetchCompetitiveness = async (jobIdNum: number) => {
2026-06-04 16:25:38 +08:00
if (!isJobPortalLoggedIn()) {
setOverallScore(0);
setRadarData(getEmptyCompetitivenessRadar());
return;
}
2025-11-10 16:28:01 +08:00
try {
const res = await getJobCompetitiveness(jobIdNum);
const data = res as any;
2026-06-04 16:25:38 +08:00
if (!data) {
setOverallScore(0);
setRadarData(getEmptyCompetitivenessRadar());
return;
}
2025-11-10 16:28:01 +08:00
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 {
2026-06-04 16:25:38 +08:00
setRadarData(getEmptyCompetitivenessRadar());
2025-11-10 16:28:01 +08:00
}
} catch (e) {
2026-06-04 16:25:38 +08:00
setOverallScore(0);
setRadarData(getEmptyCompetitivenessRadar());
2025-11-10 16:28:01 +08:00
}
};
const handleCollect = async () => {
2026-06-04 18:47:59 +08:00
const userId = await requireJobPortalUserId('收藏职位');
if (!userId) {
2025-11-10 16:28:01 +08:00
return;
}
// 如果是取消收藏,调用取消收藏接口
if (isFavorited) {
// 获取职位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 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 () => {
2026-06-04 18:47:59 +08:00
const userId = await requireJobPortalUserId('申请职位');
2025-11-10 16:28:01 +08:00
if (!userId) {
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('申请成功');
2026-06-16 19:11:36 +08:00
if (originalJobData) {
setOriginalJobData({ ...originalJobData, isApply: 1 });
}
2025-11-10 16:28:01 +08:00
} 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
2026-06-16 19:11:36 +08:00
disabled={originalJobData?.isApply === 1}
2025-11-10 16:28:01 +08:00
onClick={handleApply}
>
2026-06-16 19:11:36 +08:00
{originalJobData?.isApply === 1 ? '已投递' : '立即申请'}
2025-11-10 16:28:01 +08:00
</Button>
</div>
</Col>
</Row>
</Card>
<Row gutter={16}>
{/* 左侧内容区 */}
<Col span={16}>
{/* 职位描述 */}
<Card title="职位描述" className="info-card">
<div
dangerouslySetInnerHTML={{ __html: jobDetail.description }}
/>
</Card>
{/* 公司信息 */}
2026-06-04 18:47:59 +08:00
<Card title="公司信息" className="info-card company-info-card">
2025-11-10 16:28:01 +08:00
<div className="company-intro">
2026-06-04 18:47:59 +08:00
<div className="company-overview">
<Avatar size={56} className="company-avatar" src={jobDetail.companyLogo}>
</Avatar>
<div className="company-main">
<Title level={5} className="company-name">
2025-11-10 16:28:01 +08:00
{jobDetail.companyInfo.name}
</Title>
2026-06-04 18:47:59 +08:00
{companyMetaItems.length > 0 && (
<div className="company-meta-row">
{companyMetaItems.map((label, index) => (
<React.Fragment key={`${label}-${index}`}>
{index > 0 && <span className="meta-separator">|</span>}
<span>{label}</span>
</React.Fragment>
))}
</div>
)}
{(originalJobData?.jobLocation || jobDetail.location) && (
<div className="company-location">
<EnvironmentOutlined />
<span>{originalJobData?.jobLocation || jobDetail.location}</span>
</div>
)}
2025-11-10 16:28:01 +08:00
</div>
2026-06-04 18:47:59 +08:00
</div>
<Divider className="company-section-divider" />
<div className="company-description-section">
<div className="company-section-label"></div>
<Paragraph className="company-description">
{jobDetail.companyInfo.description}
</Paragraph>
</div>
2025-11-10 16:28:01 +08:00
</div>
</Card>
</Col>
{/* 右侧侧边栏 */}
<Col span={8}>
{/* 竞争力分析 */}
<Card
title={
<Space>
<TrophyOutlined />
</Space>
}
className="competitiveness-card"
>
2026-06-04 18:47:59 +08:00
{isJobPortalLoggedIn() ? (
<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,
},
}}
2026-06-09 16:08:39 +08:00
scale={{
y: {
domainMin: 0,
domainMax: 100,
2026-06-04 18:47:59 +08:00
nice: true,
},
}}
2026-06-09 16:08:39 +08:00
axis={{
y: {
label: false,
tick: false,
line: false,
},
2026-06-04 18:47:59 +08:00
}}
height={300}
/>
<div className="radar-summary">
<div className="summary-score">
<div className="score-value">{overallScore}</div>
<div className="score-label"></div>
</div>
2025-11-10 16:28:01 +08:00
</div>
</div>
2026-06-04 18:47:59 +08:00
) : (
<div className="competitiveness-login-placeholder">
<TrophyOutlined className="placeholder-icon" />
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
</Paragraph>
<Text className="placeholder-hint"></Text>
<Button
type="primary"
onClick={() => ensureJobPortalLogin('查看竞争力分析')}
>
</Button>
</div>
)}
2025-11-10 16:28:01 +08:00
</Card>
</Col>
</Row>
</div>
</div>
);
};
export default JobDetailPage;