职业推荐
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:
francis-fh
2026-06-05 16:27:19 +08:00
parent fdd80c6cd1
commit 56dce1e5d3
5 changed files with 824 additions and 0 deletions

View File

@@ -80,6 +80,10 @@ export default [
path: '/job-portal/message',// 消息通知页面
component: './JobPortal/Message',
},
{
path: '/job-portal/career-recommendation',// 职业推荐
component: './JobPortal/CareerRecommendation',
},
{
path: '/job-portal/policy',// 政策列表
component: './JobPortal/Policy',

View File

@@ -15,6 +15,7 @@ import {
BellOutlined,
ReadOutlined,
LoginOutlined,
FundProjectionScreenOutlined,
} from '@ant-design/icons';
import { history, useLocation, useModel } from '@umijs/max';
import {
@@ -100,6 +101,7 @@ const JobPortalHeader: React.FC<JobPortalHeaderProps> = ({
const isMine = location.pathname.startsWith('/job-portal/personal-center') || location.pathname.startsWith('/job-portal/profile');
const isMessage = location.pathname.startsWith('/job-portal/message');
const isPolicy = location.pathname.startsWith('/job-portal/policy');
const isCareerRecommend = location.pathname.startsWith('/job-portal/career-recommendation');
// 获取未读消息数量(仅登录用户)
useEffect(() => {
@@ -277,6 +279,14 @@ const JobPortalHeader: React.FC<JobPortalHeaderProps> = ({
>
</Button>
<Button
type="text"
icon={<FundProjectionScreenOutlined />}
onClick={() => handleNavClick('/job-portal/career-recommendation')}
className={`nav-btn${isCareerRecommend ? ' active' : ''}`}
>
</Button>
<Button
type="text"
icon={<ReadOutlined />}

View File

@@ -0,0 +1,337 @@
@import '../theme.less';
.career-recommend-page {
min-height: 100vh;
background: #eef4fa;
.career-content {
padding: 20px 20px 40px;
.career-container {
.jp-page-container();
max-width: 1400px;
}
}
.career-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
@media (max-width: 1200px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.career-card {
.jp-card-base();
border-radius: @jp-radius-lg;
padding: 16px;
min-height: 260px;
display: flex;
flex-direction: column;
.card-title {
display: flex;
align-items: center;
font-size: 15px;
font-weight: 600;
color: @jp-text-primary;
margin-bottom: 12px;
flex-shrink: 0;
&::before {
content: '';
display: inline-block;
width: 4px;
height: 16px;
background: @jp-primary;
border-radius: 2px;
margin-right: 8px;
}
}
.card-body {
flex: 1;
min-height: 0;
}
}
// 当前职位
.position-card {
.position-profile {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
.position-info {
flex: 1;
.position-name {
font-size: 20px;
font-weight: 600;
color: @jp-text-primary;
margin-bottom: 8px;
}
.position-salary {
font-size: 14px;
color: @jp-text-secondary;
}
}
.position-tag {
align-self: flex-start;
background: @jp-primary-light;
border: 1px solid @jp-primary-border;
color: @jp-primary;
border-radius: 4px;
padding: 2px 10px;
font-size: 12px;
}
}
}
// 学历时间轴
.education-timeline {
padding: 16px 8px 8px;
.timeline-track {
position: relative;
height: 6px;
background: linear-gradient(90deg, #91caff 0%, #1890ff 100%);
border-radius: 3px;
margin-bottom: 32px;
}
.timeline-marker {
position: absolute;
top: -10px;
transform: translateX(-50%);
width: 24px;
height: 24px;
background: #fff;
border: 3px solid #ff7a45;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(255, 122, 69, 0.4);
}
.timeline-labels {
display: flex;
justify-content: space-between;
.timeline-item {
text-align: center;
flex: 1;
.level-name {
font-size: 13px;
color: @jp-text-primary;
margin-bottom: 4px;
&.active {
color: #ff7a45;
font-weight: 600;
}
}
.level-salary {
font-size: 12px;
color: @jp-text-muted;
}
}
}
}
// 薪酬分析滑块
.salary-gauge {
padding: 16px 8px 8px;
.gauge-track-wrap {
position: relative;
padding-top: 36px;
}
.gauge-track {
position: relative;
height: 8px;
background: #e8ecf0;
border-radius: 4px;
overflow: visible;
.gauge-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: #1890ff;
border-radius: 4px;
pointer-events: none;
}
}
.gauge-marker {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
z-index: 1;
.marker-bubble {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
background: #ff7a45;
color: #fff;
font-size: 12px;
padding: 4px 10px;
border-radius: 4px;
white-space: nowrap;
&::after {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid #ff7a45;
}
}
.marker-dot {
width: 14px;
height: 14px;
background: #ff7a45;
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(255, 122, 69, 0.5);
flex-shrink: 0;
}
}
.gauge-value-row {
position: relative;
height: 22px;
margin-top: 10px;
.marker-value {
position: absolute;
transform: translateX(-50%);
font-size: 13px;
color: @jp-text-secondary;
white-space: nowrap;
line-height: 1;
}
}
.gauge-labels {
display: flex;
justify-content: space-between;
margin-top: 4px;
font-size: 12px;
color: @jp-text-muted;
}
}
// 学历分析底部
.education-footer {
display: flex;
justify-content: space-around;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed @jp-border;
font-size: 12px;
color: @jp-text-secondary;
span strong {
color: @jp-primary;
margin-left: 4px;
}
}
// 技能分析
.skill-analysis {
display: flex;
gap: 12px;
height: 200px;
.skill-categories {
width: 120px;
flex-shrink: 0;
border-right: 1px solid @jp-border;
overflow-y: auto;
.category-item {
padding: 10px 12px;
font-size: 13px;
cursor: pointer;
color: @jp-text-secondary;
border-left: 3px solid transparent;
transition: all 0.2s;
&:hover {
background: @jp-primary-light;
}
&.active {
color: @jp-primary;
background: @jp-primary-light;
border-left-color: @jp-primary;
font-weight: 600;
}
}
}
.skill-children {
flex: 1;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 8px;
padding: 4px;
.skill-tag {
background: #f5f5f5;
border: 1px solid @jp-border;
border-radius: 4px;
padding: 6px 14px;
font-size: 13px;
color: @jp-text-primary;
}
}
}
// 差距分析
.gap-list {
.gap-item {
margin-bottom: 14px;
.gap-label {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-bottom: 4px;
color: @jp-text-primary;
}
.ant-progress-inner {
background: #e8ecf0;
}
}
}
.chart-wrap {
height: 200px;
width: 100%;
}
}

View File

@@ -0,0 +1,369 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Avatar, Progress, Spin } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import { Bar, Heatmap, Line, Pie } from '@ant-design/charts';
import JobPortalHeader from '@/components/JobPortalHeader';
import defaultAvatar from '@/assets/avatar.jpg';
import { getAppUserInfo } from '@/services/jobportal/user';
import { isJobPortalLoggedIn } from '@/utils/jobPortalAuth';
import {
DEFAULT_EDUCATION,
DEFAULT_POSITION,
DEFAULT_SALARY_K,
educationLevels,
educationPieData,
educationSummary,
fixedHeatmapData,
gapAnalysisData,
positionExperienceData,
salaryLineData,
skillCategories,
} from './mockData';
import './index.less';
interface UserProfile {
positionName: string;
salaryK: number;
education: string;
avatar: string;
}
const educationDictMap: Record<string, string> = {
'0': '不限',
'1': '专科及以下',
'2': '本科',
'3': '硕士',
'4': '博士',
};
const educationKeyMap: Record<string, number> = {
专科: 0,
专科及以下: 0,
本科: 1,
硕士: 2,
博士: 3,
};
const parseSalaryK = (min?: string, max?: string): number => {
const val = min || max;
if (!val) return DEFAULT_SALARY_K;
const num = parseInt(String(val).replace(/[^\d]/g, ''), 10);
if (Number.isNaN(num)) return DEFAULT_SALARY_K;
const k = num >= 1000 ? Math.round(num / 1000) : num;
return Math.min(30, Math.max(0, k));
};
const CareerCard: React.FC<{ title: string; className?: string; children: React.ReactNode }> = ({
title,
className = '',
children,
}) => (
<div className={`career-card ${className}`}>
<div className="card-title">{title}</div>
<div className="card-body">{children}</div>
</div>
);
const CareerRecommendationPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [profile, setProfile] = useState<UserProfile>({
positionName: DEFAULT_POSITION,
salaryK: DEFAULT_SALARY_K,
education: DEFAULT_EDUCATION,
avatar: '',
});
const [activeSkillKey, setActiveSkillKey] = useState(skillCategories[0].key);
useEffect(() => {
if (!isJobPortalLoggedIn()) return;
const fetchProfile = async () => {
setLoading(true);
try {
const res = await getAppUserInfo();
if (res?.code === 200 && res.data) {
const data = res.data as Record<string, string>;
const positionName =
data.jobTitle || data.field_19 || data.desiredTitle || DEFAULT_POSITION;
setProfile({
positionName,
salaryK: parseSalaryK(data.salaryMin, data.salaryMax),
education: educationDictMap[data.education] || data.education || DEFAULT_EDUCATION,
avatar: data.avatar || '',
});
}
} catch {
// 使用 mock 默认值
} finally {
setLoading(false);
}
};
fetchProfile();
}, []);
const educationIndex = useMemo(() => {
const edu = profile.education;
for (const [key, idx] of Object.entries(educationKeyMap)) {
if (edu.includes(key)) return idx;
}
return 1;
}, [profile.education]);
const educationMarkerLeft = `${(educationIndex / (educationLevels.length - 1)) * 100}%`;
const salaryMarkerLeft = `${(profile.salaryK / 30) * 100}%`;
const highlightSalary = `${profile.salaryK}k`;
const activeSkill = skillCategories.find((c) => c.key === activeSkillKey) || skillCategories[0];
const barConfig = {
data: positionExperienceData,
xField: 'range',
yField: 'count',
height: 200,
autoFit: true,
legend: false,
axis: {
x: { title: false },
y: { title: false, grid: true },
},
style: {
fill: (datum: { range: string }) =>
datum.range === '5-8年' ? '#ff7a45' : '#91caff',
radiusTopLeft: 4,
radiusTopRight: 4,
},
label: {
text: (d: { count: number; range: string }) =>
d.range === '5-8年' ? `当前\n${d.count}` : String(d.count),
position: 'top' as const,
style: { fill: '#595959', fontSize: 11 },
},
};
const pieConfig = {
data: educationPieData,
angleField: 'value',
colorField: 'type',
height: 160,
autoFit: true,
innerRadius: 0.6,
legend: {
color: {
position: 'right' as const,
layout: { justifyContent: 'center' },
},
},
label: {
text: (d: { type: string; value: number }) => `${d.type} ${d.value}%`,
style: { fontSize: 10 },
},
color: ['#91caff', '#69b1ff', '#1890ff', '#d9d9d9'],
};
const lineConfig = {
data: salaryLineData,
xField: 'salary',
yField: 'percent',
height: 200,
autoFit: true,
smooth: true,
point: {
shapeField: 'point',
sizeField: 3,
style: (datum: { salary: string }) => ({
fill: datum.salary === highlightSalary ? '#ff7a45' : '#1890ff',
stroke: datum.salary === highlightSalary ? '#ff7a45' : '#1890ff',
lineWidth: datum.salary === highlightSalary ? 2 : 0,
}),
},
style: { stroke: '#1890ff', lineWidth: 2 },
axis: {
y: { title: false, labelFormatter: (v: number) => `${v}%` },
x: { title: false },
},
};
const heatmapConfig = {
data: fixedHeatmapData,
xField: 'position',
yField: 'skill',
colorField: 'value',
height: 200,
autoFit: true,
mark: 'cell',
style: { inset: 2 },
scale: {
color: { range: ['#fff1f0', '#ffccc7', '#ffa39e', '#ff7875', '#f5222d'] },
},
axis: {
x: { title: false },
y: { title: false },
},
legend: false,
};
return (
<div className="career-recommend-page">
<JobPortalHeader showSearch={false} showHotJobs={false} />
<div className="career-content">
<Spin spinning={loading}>
<div className="career-container">
<div className="career-grid">
{/* 当前职位 */}
<CareerCard title="当前职位" className="position-card">
<div className="position-profile">
<Avatar
size={64}
src={profile.avatar || defaultAvatar}
icon={<UserOutlined />}
/>
<div className="position-info">
<div className="position-name">{profile.positionName}</div>
<div className="position-salary">: {profile.salaryK}/</div>
</div>
<span className="position-tag"></span>
</div>
</CareerCard>
{/* 学历情况 */}
<CareerCard title="学历情况">
<div className="education-timeline">
<div className="timeline-track">
<div
className="timeline-marker"
style={{ left: educationMarkerLeft }}
/>
</div>
<div className="timeline-labels">
{educationLevels.map((item, idx) => (
<div key={item.key} className="timeline-item">
<div className={`level-name${idx === educationIndex ? ' active' : ''}`}>
{item.label}
</div>
<div className="level-salary">{item.salaryRange}</div>
</div>
))}
</div>
</div>
</CareerCard>
{/* 薪酬分析 */}
<CareerCard title="薪酬分析">
<div className="salary-gauge">
<div className="gauge-track-wrap">
<div className="gauge-track">
<div className="gauge-fill" style={{ width: salaryMarkerLeft }} />
<div className="gauge-marker" style={{ left: salaryMarkerLeft }}>
<div className="marker-bubble"></div>
<div className="marker-dot" />
</div>
</div>
<div className="gauge-value-row">
<span
className="marker-value"
style={{ left: salaryMarkerLeft }}
>
{profile.salaryK}
</span>
</div>
</div>
<div className="gauge-labels">
<span>0</span>
<span>30</span>
</div>
</div>
</CareerCard>
{/* 职位情况 */}
<CareerCard title="职位情况">
<div className="chart-wrap">
<Bar {...barConfig} />
</div>
</CareerCard>
{/* 学历分析 */}
<CareerCard title="学历分析">
<div className="chart-wrap">
<Pie {...pieConfig} />
</div>
<div className="education-footer">
<span>
: <strong>{educationSummary.entryThreshold}</strong>
</span>
<span>
: <strong>{educationSummary.averageEducation}</strong>
</span>
<span>
: <strong>{educationSummary.premium}</strong>
</span>
</div>
</CareerCard>
{/* 薪资情况 */}
<CareerCard title="薪资情况">
<div className="chart-wrap">
<Line {...lineConfig} />
</div>
</CareerCard>
{/* 技能热力图 */}
<CareerCard title="目标职位技能热力图分析">
<div className="chart-wrap">
<Heatmap {...heatmapConfig} />
</div>
</CareerCard>
{/* 技能分析 */}
<CareerCard title="技能分析">
<div className="skill-analysis">
<div className="skill-categories">
{skillCategories.map((cat) => (
<div
key={cat.key}
className={`category-item${activeSkillKey === cat.key ? ' active' : ''}`}
onClick={() => setActiveSkillKey(cat.key)}
>
{cat.label}
</div>
))}
</div>
<div className="skill-children">
{activeSkill.children.map((skill) => (
<span key={skill} className="skill-tag">
{skill}
</span>
))}
</div>
</div>
</CareerCard>
{/* 差距分析 */}
<CareerCard title="目标职位差距分析">
<div className="gap-list">
{gapAnalysisData.map((item) => (
<div key={item.name} className="gap-item">
<div className="gap-label">
<span>{item.name}</span>
<span>{item.percent}%</span>
</div>
<Progress
percent={item.percent}
showInfo={false}
strokeColor="#1890ff"
size="small"
/>
</div>
))}
</div>
</CareerCard>
</div>
</div>
</Spin>
</div>
</div>
);
};
export default CareerRecommendationPage;

View File

@@ -0,0 +1,104 @@
/** 职业推荐页 mock 数据 */
export const DEFAULT_POSITION = '人力资源总监';
export const DEFAULT_SALARY_K = 7;
export const DEFAULT_EDUCATION = '本科';
export const educationLevels = [
{ key: 'junior', label: '专科及以下', salaryRange: '7-10K' },
{ key: 'bachelor', label: '本科', salaryRange: '8-20K' },
{ key: 'master', label: '硕士', salaryRange: '12-15K' },
{ key: 'doctor', label: '博士', salaryRange: '15-30K' },
];
export const positionExperienceData = [
{ range: '1-3年', count: 34 },
{ range: '3-5年', count: 37 },
{ range: '5-8年', count: 369, isCurrent: true },
{ range: '8-10年', count: 16 },
{ range: '10年以上', count: 3 },
];
export const educationPieData = [
{ type: '专科及以下', value: 18 },
{ type: '硕士', value: 2 },
{ type: '本科', value: 76 },
{ type: '不详', value: 5 },
];
export const educationSummary = {
entryThreshold: '本科',
averageEducation: '本科',
premium: '0',
};
export const salaryLineData = [
{ salary: '5k', percent: 2 },
{ salary: '7k', percent: 8 },
{ salary: '9k', percent: 12 },
{ salary: '11k', percent: 15 },
{ salary: '13k', percent: 18 },
{ salary: '15k', percent: 16 },
{ salary: '17k', percent: 12 },
{ salary: '19k', percent: 8 },
{ salary: '21k', percent: 5 },
{ salary: '25k', percent: 3 },
{ salary: '30k', percent: 1 },
];
export const fixedHeatmapData: { skill: string; position: string; value: number }[] = [
{ skill: '沟通协作', position: 'COO', value: 72 },
{ skill: '沟通协作', position: '副总经理', value: 85 },
{ skill: '沟通协作', position: 'HRM', value: 90 },
{ skill: '沟通协作', position: 'HRD', value: 88 },
{ skill: '沟通协作', position: '人事经理', value: 78 },
{ skill: '沟通协作', position: '招聘经理', value: 65 },
{ skill: '团队协作', position: 'COO', value: 68 },
{ skill: '团队协作', position: '副总经理', value: 82 },
{ skill: '团队协作', position: 'HRM', value: 95 },
{ skill: '团队协作', position: 'HRD', value: 92 },
{ skill: '团队协作', position: '人事经理', value: 80 },
{ skill: '团队协作', position: '招聘经理', value: 70 },
{ skill: '跨部门协作', position: 'COO', value: 90 },
{ skill: '跨部门协作', position: '副总经理', value: 88 },
{ skill: '跨部门协作', position: 'HRM', value: 75 },
{ skill: '跨部门协作', position: 'HRD', value: 82 },
{ skill: '跨部门协作', position: '人事经理', value: 68 },
{ skill: '跨部门协作', position: '招聘经理', value: 55 },
{ skill: '沟通表达', position: 'COO', value: 78 },
{ skill: '沟通表达', position: '副总经理', value: 80 },
{ skill: '沟通表达', position: 'HRM', value: 88 },
{ skill: '沟通表达', position: 'HRD', value: 85 },
{ skill: '沟通表达', position: '人事经理', value: 72 },
{ skill: '沟通表达', position: '招聘经理', value: 60 },
];
export const skillCategories = [
{
key: 'strategy',
label: '战略管理',
children: ['战略规划', '企业管理', '风险管理', '组织发展'],
},
{
key: 'admin',
label: '行政事务',
children: ['行政管理', '后勤保障', '会务接待', '档案管理'],
},
{
key: 'finance',
label: '财务分析',
children: ['预算管理', '成本控制', '财务报告', '税务筹划'],
},
{
key: 'hr',
label: '人力资源',
children: ['招聘配置', '绩效管理', '薪酬福利', '员工关系'],
},
];
export const gapAnalysisData = [
{ name: '人力资源管理', percent: 100 },
{ name: '信息化管理', percent: 40 },
{ name: '战略与业务发展', percent: 89 },
{ name: '组织文化建设', percent: 72 },
{ name: '劳动法规合规', percent: 95 },
];