111
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-04 18:47:59 +08:00
parent 971e2b8be5
commit 134c1ecece
13 changed files with 646 additions and 20186 deletions

View File

@@ -68,6 +68,7 @@ export function clearAllClientStorage() {
clearTokenCache();
const knownKeys = [
'userInfo',
'getInfoCache',
'appUserId',
'resume_userId',
'seesionId',

View File

@@ -10,6 +10,7 @@ import {
exchangeThirdPartyCredential,
isThirdPartyTransitionPage,
} from './utils/thirdPartyLogin';
import { saveGetInfoCache } from './utils/jobPortalAuth';
import {
getRemoteMenu,
getRoutersInfo,
@@ -59,6 +60,7 @@ export async function getInitialState(): Promise<{
response.user.avatar =
'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png';
}
saveGetInfoCache(response as unknown as Record<string, unknown>);
return {
...response.user,
permissions: response.permissions,

View File

@@ -19,8 +19,10 @@ import {
import { history, useLocation, useModel } from '@umijs/max';
import {
ensureJobPortalLogin,
getGetInfoCache,
isJobPortalLoggedIn,
PORTAL_LOGIN_URL,
prefetchJobPortalUserInfo,
} from '@/utils/jobPortalAuth';
import { getJobRecommend } from '@/services/common/jobTitle';
import { getMessageTotal } from '@/services/jobportal/user';
@@ -56,12 +58,18 @@ const JobPortalHeader: React.FC<JobPortalHeaderProps> = ({
const { initialState } = useModel('@@initialState');
const currentUser = initialState?.currentUser;
// 获取用户名,优先使用 initialState其次使用 localStorage 中的 userInfo
// 获取用户名,优先 initialState其次 getInfo 缓存,最后 appUser 缓存
const getUserName = (): string => {
if (currentUser?.nickName) {
return currentUser.nickName;
}
// 尝试从 localStorage 获取用户信息
const getInfoUser = getGetInfoCache()?.user;
if (getInfoUser?.nickName) {
return String(getInfoUser.nickName);
}
if (getInfoUser?.userName) {
return String(getInfoUser.userName);
}
try {
const cachedUserInfo = localStorage.getItem('userInfo');
if (cachedUserInfo) {
@@ -79,6 +87,13 @@ const JobPortalHeader: React.FC<JobPortalHeaderProps> = ({
const userName = getUserName();
const loggedIn = isJobPortalLoggedIn();
// SSO 仅有 token、本地尚无 userInfo 时预拉求职者资料
useEffect(() => {
if (loggedIn) {
prefetchJobPortalUserInfo();
}
}, [loggedIn]);
// 判断激活导航(简化为 startsWith 匹配)
const isHome = /^\/job-portal(\/(list|detail))?$/.test(location.pathname);
const isResume = location.pathname.startsWith('/job-portal/resume');

View File

@@ -26,9 +26,11 @@ export default function useAppUserModel() {
const response = await getAppUserInfo();
if (response?.code === 200 && response?.data) {
setUserInfo(response.data);
// 保存到缓存
try {
localStorage.setItem('userInfo', JSON.stringify(response.data));
if (response.data.userId) {
localStorage.setItem('appUserId', String(response.data.userId));
}
} catch (error) {
console.error('保存用户信息到缓存失败:', error);
}

View File

@@ -57,6 +57,10 @@
.score-label {
color: @jp-text-muted;
}
.competitiveness-login-placeholder {
.jp-competitiveness-login-placeholder();
}
}
.skills-card .skill-item {
@@ -64,6 +68,89 @@
border-radius: @jp-radius;
border: 1px solid @jp-primary-border;
}
.company-info-card {
.company-overview {
display: flex;
gap: 14px;
align-items: flex-start;
}
.company-avatar {
flex-shrink: 0;
background: @jp-primary-light !important;
color: @jp-primary;
font-size: 16px;
font-weight: 600;
}
.company-main {
flex: 1;
min-width: 0;
}
.company-name {
margin: 0 0 10px !important;
font-size: 16px !important;
font-weight: 600;
line-height: 1.45;
color: @jp-text-primary;
}
.company-meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 10px;
font-size: 13px;
color: @jp-text-muted;
line-height: 1.5;
.meta-separator {
margin: 0 10px;
color: @jp-border;
user-select: none;
}
}
.company-location {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 13px;
color: @jp-text-secondary;
line-height: 1.55;
.anticon {
flex-shrink: 0;
margin-top: 3px;
color: @jp-text-muted;
}
}
.company-section-divider {
margin: 18px 0 16px;
}
.company-description-section {
.company-section-label {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: @jp-text-primary;
line-height: 1.4;
}
.company-description {
margin: 0 !important;
font-size: 14px;
line-height: 1.85;
color: @jp-text-secondary;
white-space: pre-line;
word-break: break-word;
}
}
}
}
}

View File

@@ -33,7 +33,7 @@ import {
} from '@/services/jobportal/competitiveness';
import { isJobPortalLoggedIn } from '@/utils/jobPortalAuth';
import { favoriteJob, unfavoriteJob, applyJob } from '@/services/jobportal/user';
import { ensureJobPortalLogin, PORTAL_LOGIN_URL } from '@/utils/jobPortalAuth';
import { ensureJobPortalLogin, requireJobPortalUserId } from '@/utils/jobPortalAuth';
import { getDictLabel, resolveIndustryLabel } from '@/utils/jobPortalDict';
import { getDictValueEnum } from '@/services/system/dict';
import { getCmsIndustryTreeList } from '@/services/classify/industry';
@@ -60,20 +60,6 @@ const experienceMap: { [key: string]: string } = {
'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) {
@@ -228,8 +214,13 @@ const JobDetailPage: React.FC = () => {
const scaleLabel = getDictLabel(scaleEnum, rawScale) || String(rawScale);
if (scaleLabel) items.push(scaleLabel);
}
const industryLabel = resolveIndustryLabel(industryTree, rawIndustry);
if (industryLabel) items.push(industryLabel);
const rawStr = rawIndustry == null ? '' : String(rawIndustry).trim();
// 行业 id 未解析成名称时不展示裸数字,避免与行业名称重复
if (industryLabel && !(/^\d+$/.test(rawStr) && industryLabel === rawStr)) {
items.push(industryLabel);
}
return items;
}, [originalJobData, scaleEnum, industryTree]);
@@ -257,13 +248,22 @@ const JobDetailPage: React.FC = () => {
}, [id, location.state]);
// 基于详情中的 id 再拉一次(避免从路由未传 id 的情况)
// 仅登录且有真实岗位 id 时请求竞争力
useEffect(() => {
const jobIdNum = Number((jobDetail as any)?.id);
if (!Number.isNaN(jobIdNum) && jobIdNum) {
if (!isJobPortalLoggedIn()) {
setOverallScore(0);
setRadarData(getEmptyCompetitivenessRadar());
return;
}
const jobIdRaw =
originalJobData?.jobId
?? originalJobData?.id
?? id;
const jobIdNum = Number(jobIdRaw);
if (!Number.isNaN(jobIdNum) && jobIdNum > 0) {
fetchCompetitiveness(jobIdNum);
}
}, [jobDetail?.id]);
}, [originalJobData?.jobId, originalJobData?.id, id]);
const fetchCompetitiveness = async (jobIdNum: number) => {
if (!isJobPortalLoggedIn()) {
@@ -306,18 +306,13 @@ const JobDetailPage: React.FC = () => {
};
const handleCollect = async () => {
if (!ensureJobPortalLogin('收藏职位')) {
const userId = await requireJobPortalUserId('收藏职位');
if (!userId) {
return;
}
// 如果是取消收藏,调用取消收藏接口
if (isFavorited) {
const userId = getUserIdFromCache();
if (!userId) {
message.warning('无法获取用户信息,请重新登录');
window.location.href = PORTAL_LOGIN_URL;
return;
}
// 获取职位ID从原始数据或转换后的数据中获取
const jobId = originalJobData?.jobId || originalJobData?.id || (jobDetail as any)?.id || id;
@@ -345,13 +340,6 @@ const JobDetailPage: React.FC = () => {
return;
}
const userId = getUserIdFromCache();
if (!userId) {
message.warning('无法获取用户信息,请重新登录');
window.location.href = PORTAL_LOGIN_URL;
return;
}
// 获取职位ID
const jobId = originalJobData?.jobId || originalJobData?.id || (jobDetail as any)?.id || id;
if (!jobId) {
@@ -378,14 +366,8 @@ const JobDetailPage: React.FC = () => {
};
const handleApply = async () => {
if (!ensureJobPortalLogin('申请职位')) {
return;
}
const userId = getUserIdFromCache();
const userId = await requireJobPortalUserId('申请职位');
if (!userId) {
message.warning('无法获取用户信息,请重新登录');
window.location.href = PORTAL_LOGIN_URL;
return;
}
@@ -494,23 +476,41 @@ const JobDetailPage: React.FC = () => {
</Card>
{/* 公司信息 */}
<Card title="公司信息" className="info-card">
<Card title="公司信息" className="info-card company-info-card">
<div className="company-intro">
<Space size={16}>
<Avatar size={64} src={jobDetail.companyLogo} />
<div>
<Title level={4} style={{ margin: 0 }}>
<div className="company-overview">
<Avatar size={56} className="company-avatar" src={jobDetail.companyLogo}>
</Avatar>
<div className="company-main">
<Title level={5} className="company-name">
{jobDetail.companyInfo.name}
</Title>
<Space className="company-meta" split={<Divider type="vertical" />}>
{companyMetaItems.map((label) => (
<Text key={label}>{label}</Text>
))}
</Space>
{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>
)}
</div>
</Space>
<Divider />
<Paragraph>{jobDetail.companyInfo.description}</Paragraph>
</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>
</div>
</Card>
</Col>
@@ -527,53 +527,69 @@ const JobDetailPage: React.FC = () => {
}
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>
{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,
},
}}
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>
</div>
) : (
<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>
)}
</Card>
</Col>

View File

@@ -110,13 +110,10 @@
}
}
.job-detail-card,
.company-info-card {
.job-detail-card {
.jp-card-base();
min-height: 600px;
}
.job-detail-card {
.job-detail-header {
display: flex;
justify-content: space-between;
@@ -235,6 +232,130 @@
.score-value {
color: @jp-primary;
}
.competitiveness-login-placeholder {
.jp-competitiveness-login-placeholder();
}
}
}
.company-info-card {
.jp-card-base();
margin-top: 16px;
.ant-card-head {
min-height: 48px;
padding: 0 20px;
border-bottom: 1px solid @jp-border;
}
.ant-card-head-title {
font-size: 16px;
font-weight: 600;
color: @jp-text-primary;
}
.ant-card-body {
padding: 20px 24px;
}
.company-overview {
display: flex;
gap: 14px;
align-items: flex-start;
}
.company-avatar {
flex-shrink: 0;
background: @jp-primary-light !important;
color: @jp-primary;
font-size: 16px;
font-weight: 600;
}
.company-main {
flex: 1;
min-width: 0;
}
.company-name {
margin: 0 0 10px !important;
font-size: 16px !important;
font-weight: 600;
line-height: 1.45;
color: @jp-text-primary;
}
.company-meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 10px;
font-size: 13px;
color: @jp-text-muted;
line-height: 1.5;
.meta-separator {
margin: 0 10px;
color: @jp-border;
user-select: none;
}
}
.company-location {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 13px;
color: @jp-text-secondary;
line-height: 1.55;
.anticon {
flex-shrink: 0;
margin-top: 3px;
color: @jp-text-muted;
}
}
.company-section-divider {
margin: 18px 0 16px;
}
.company-description-section {
.company-section-label {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: @jp-text-primary;
line-height: 1.4;
}
.company-description {
margin: 0 !important;
font-size: 14px;
line-height: 1.85;
color: @jp-text-secondary;
white-space: pre-line;
word-break: break-word;
max-height: 260px;
overflow-y: auto;
padding-right: 4px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 2px;
}
}
.company-description-empty {
margin: 0;
font-size: 14px;
color: @jp-text-muted;
}
}
}
@@ -250,18 +371,6 @@
}
}
}
.company-info-card {
margin-top: 16px;
.company-name {
color: @jp-text-primary;
}
.company-meta .ant-typography {
color: @jp-text-muted;
}
}
}
}

View File

@@ -25,10 +25,10 @@ import {
} from '@ant-design/icons';
import { history, useLocation } from '@umijs/max';
import { getJobList } from '@/services/common/jobTitle';
import { getDictValueEnum } from '@/services/system/dict';
import JobPortalHeader from '@/components/JobPortalHeader';
import { favoriteJob, unfavoriteJob, applyJob } from '@/services/jobportal/user';
import { ensureJobPortalLogin, PORTAL_LOGIN_URL } from '@/utils/jobPortalAuth';
import { ensureJobPortalLogin, requireJobPortalUserId } from '@/utils/jobPortalAuth';
import { getDictValueEnum } from '@/services/system/dict';
import { getDictLabel, findTreeLabelById, resolveIndustryLabel } from '@/utils/jobPortalDict';
import { getJobTitleTreeSelect } from '@/services/common/jobTitle';
import { getCmsIndustryTreeList } from '@/services/classify/industry';
@@ -64,19 +64,6 @@ const experienceMap: { [key: string]: string } = {
};
// 从缓存获取用户信息
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 mockJobs = [
{
@@ -264,8 +251,12 @@ const JobListPage: React.FC = () => {
const scaleLabel = getDictLabel(scaleEnum, rawScale) || String(rawScale);
if (scaleLabel) items.push(scaleLabel);
}
const industryLabel = resolveIndustryLabel(industryTree, rawIndustry);
if (industryLabel) items.push(industryLabel);
const rawStr = rawIndustry == null ? '' : String(rawIndustry).trim();
if (industryLabel && !(/^\d+$/.test(rawStr) && industryLabel === rawStr)) {
items.push(industryLabel);
}
return items;
}, [selectedJob, scaleEnum, industryTree]);
@@ -316,13 +307,17 @@ const JobListPage: React.FC = () => {
}
}, [selectedJob]);
// 当 selectedJob 变化时,获取竞争力分析数据
// 当 selectedJob 变化时,获取竞争力分析数据(仅登录)
useEffect(() => {
if (selectedJob) {
const jobIdNum = Number(selectedJob?.jobId || selectedJob?.id);
if (!Number.isNaN(jobIdNum) && jobIdNum) {
fetchCompetitiveness(jobIdNum);
}
if (!selectedJob) return;
if (!isJobPortalLoggedIn()) {
setOverallScore(0);
setRadarData(getEmptyCompetitivenessRadar());
return;
}
const jobIdNum = Number(selectedJob?.jobId || selectedJob?.id);
if (!Number.isNaN(jobIdNum) && jobIdNum) {
fetchCompetitiveness(jobIdNum);
}
}, [selectedJob]);
@@ -404,17 +399,12 @@ const JobListPage: React.FC = () => {
};
const handleCollect = async () => {
if (!ensureJobPortalLogin('收藏职位')) {
const userId = await requireJobPortalUserId('收藏职位');
if (!userId) {
return;
}
if (favorited) {
const userId = getUserIdFromCache();
if (!userId) {
message.warning('无法获取用户信息,请重新登录');
window.location.href = PORTAL_LOGIN_URL;
return;
}
// 获取职位ID
const jobId = selectedJob?.jobId || selectedJob?.id;
@@ -449,13 +439,6 @@ const JobListPage: React.FC = () => {
return;
}
const userId = getUserIdFromCache();
if (!userId) {
message.warning('无法获取用户信息,请重新登录');
window.location.href = PORTAL_LOGIN_URL;
return;
}
// 获取职位ID
const jobId = selectedJob?.jobId || selectedJob?.id;
if (!jobId) {
@@ -489,14 +472,8 @@ const JobListPage: React.FC = () => {
};
const handleApply = async () => {
if (!ensureJobPortalLogin('申请职位')) {
return;
}
const userId = getUserIdFromCache();
const userId = await requireJobPortalUserId('申请职位');
if (!userId) {
message.warning('无法获取用户信息,请重新登录');
window.location.href = PORTAL_LOGIN_URL;
return;
}
@@ -854,53 +831,69 @@ const JobListPage: React.FC = () => {
}
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>
{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,
},
}}
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>
</div>
) : (
<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>
)}
</Card>
</Col>
</Row>
@@ -909,37 +902,43 @@ const JobListPage: React.FC = () => {
{/* 公司信息卡片 */}
{(selectedJob?.companyInfo || selectedJob?.companyName || selectedJob?.company) && (
<Card className="company-info-card">
<Title level={4} style={{ marginBottom: 16 }}></Title>
<Card className="company-info-card" title="公司信息">
<div className="company-overview">
<Avatar size={80} style={{ backgroundColor: '#f0f0f0' }}>
<Avatar size={56} className="company-avatar">
</Avatar>
<div className="company-info-right">
<Title level={4} className="company-name">
<div className="company-main">
<Title level={5} className="company-name">
{selectedJob?.companyInfo?.name || selectedJob?.companyName || selectedJob?.company}
</Title>
<div className="company-meta">
{selectedCompanyMetaItems.length > 0 ? (
selectedCompanyMetaItems.map((label, index) => (
{selectedCompanyMetaItems.length > 0 && (
<div className="company-meta-row">
{selectedCompanyMetaItems.map((label, index) => (
<React.Fragment key={`${label}-${index}`}>
{index > 0 && <Divider type="vertical" />}
<Text>{label}</Text>
{index > 0 && <span className="meta-separator">|</span>}
<span>{label}</span>
</React.Fragment>
))
) : (
<Text type="secondary"></Text>
)}
</div>
<div className="company-meta">
<Text><EnvironmentOutlined /> {selectedJob?.jobLocation || '暂无地址信息'}</Text>
</div>
))}
</div>
)}
{selectedJob?.jobLocation && (
<div className="company-location">
<EnvironmentOutlined />
<span>{selectedJob.jobLocation}</span>
</div>
)}
</div>
</div>
<Divider className="company-section-divider" />
<div className="company-description-section">
<Paragraph className="company-description">
{selectedJob?.companyVo?.companyDescription || '暂无公司描述'}
</Paragraph>
<div className="company-section-label"></div>
{selectedJob?.companyVo?.companyDescription ? (
<Paragraph className="company-description">
{selectedJob.companyVo.companyDescription}
</Paragraph>
) : (
<Paragraph className="company-description-empty"></Paragraph>
)}
</div>
</Card>
)}

View File

@@ -49,6 +49,28 @@
margin: 0;
}
.jp-competitiveness-login-placeholder() {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 280px;
padding: 32px 24px;
text-align: center;
gap: 12px;
.placeholder-icon {
font-size: 48px;
color: #d9d9d9;
margin-bottom: 4px;
}
.placeholder-hint {
font-size: 12px;
color: @jp-text-muted;
}
}
// 门户页面根容器
.job-portal-page-root {
min-height: 100vh;

View File

@@ -1,23 +1,163 @@
import { Modal } from 'antd';
import { Modal, message } from 'antd';
import { getAccessToken } from '@/access';
import { getAppUserInfo } from '@/services/jobportal/user';
/** 人社门户登录页 */
export const PORTAL_LOGIN_URL = 'http://218.31.252.15:9081/hrss-web-vue/login';
/** /api/getInfo 缓存结构(不含 msg、code */
export interface GetInfoCache {
permissions?: string[];
roles?: string[];
user?: Record<string, unknown>;
}
const GET_INFO_CACHE_KEY = 'getInfoCache';
const DEFAULT_AVATAR =
'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png';
let appUserInfoPromise: Promise<number | null> | null = null;
/** 读取 /api/getInfo 完整缓存 */
export function getGetInfoCache(): GetInfoCache | null {
try {
const cached = localStorage.getItem(GET_INFO_CACHE_KEY);
if (cached) {
return JSON.parse(cached) as GetInfoCache;
}
} catch {
// ignore
}
return null;
}
/** 将 /api/getInfo 返回体(除 msg、code写入 localStorage */
export function saveGetInfoCache(response: Record<string, unknown>): void {
try {
const { msg: _msg, code: _code, ...rest } = response;
const user = rest.user
? { ...(rest.user as Record<string, unknown>) }
: undefined;
if (user && (user.avatar === '' || user.avatar == null)) {
user.avatar = DEFAULT_AVATAR;
}
const payload: GetInfoCache = {
permissions: (rest.permissions as string[]) ?? [],
roles: (rest.roles as string[]) ?? [],
user,
};
localStorage.setItem(GET_INFO_CACHE_KEY, JSON.stringify(payload));
if (user) {
const raw = user.appUserId ?? user.userId;
if (raw != null && raw !== '') {
const n = Number(raw);
if (!Number.isNaN(n) && n > 0) {
localStorage.setItem('appUserId', String(n));
}
}
}
} catch (error) {
console.error('保存 getInfo 缓存失败:', error);
}
}
/** 清除 getInfo 缓存 */
export function clearGetInfoCache(): void {
localStorage.removeItem(GET_INFO_CACHE_KEY);
}
/** 从本地缓存读取求职者 userId同步 */
export const getJobPortalUserId = (): number | null => {
try {
const cached = localStorage.getItem('userInfo');
if (cached) {
const userInfo = JSON.parse(cached);
const raw = userInfo?.userId ?? userInfo?.id;
if (raw != null && raw !== '') {
const n = Number(raw);
if (!Number.isNaN(n) && n > 0) return n;
}
}
} catch {
// ignore
}
const getInfo = getGetInfoCache();
const sessionUser = getInfo?.user;
if (sessionUser) {
const raw = sessionUser.appUserId ?? sessionUser.userId;
if (raw != null && raw !== '') {
const n = Number(raw);
if (!Number.isNaN(n) && n > 0) return n;
}
}
try {
const appUserId = localStorage.getItem('appUserId');
if (appUserId) {
const n = Number(appUserId);
if (!Number.isNaN(n) && n > 0) return n;
}
} catch {
// ignore
}
return null;
};
export const isJobPortalLoggedIn = (): boolean => {
if (getAccessToken()) {
return true;
}
try {
const cached = localStorage.getItem('userInfo');
if (cached) {
const userInfo = JSON.parse(cached);
return !!userInfo?.userId;
}
} catch {
// ignore
return !!getJobPortalUserId();
};
/**
* 已登录但本地无 userId 时,拉取 appUser 信息并写入 userInfo
*/
export const ensureJobPortalUserId = async (): Promise<number | null> => {
const cached = getJobPortalUserId();
if (cached) return cached;
if (!getAccessToken()) {
return null;
}
if (!appUserInfoPromise) {
appUserInfoPromise = (async () => {
try {
const response = await getAppUserInfo();
if (response?.code === 200 && response?.data) {
localStorage.setItem('userInfo', JSON.stringify(response.data));
const raw = response.data.userId ?? (response.data as { id?: number }).id;
if (raw != null && raw !== '') {
const n = Number(raw);
if (!Number.isNaN(n) && n > 0) {
localStorage.setItem('appUserId', String(n));
return n;
}
}
}
} catch (error) {
console.error('获取求职者用户信息失败:', error);
}
return null;
})().finally(() => {
appUserInfoPromise = null;
});
}
return appUserInfoPromise;
};
/** 门户进入后预拉求职者信息,避免有 token 无 userId */
export const prefetchJobPortalUserInfo = (): void => {
if (getAccessToken() && !getJobPortalUserId()) {
void ensureJobPortalUserId();
}
return false;
};
/** 未登录时弹窗提示并跳转登录页,已登录返回 true */
@@ -36,3 +176,16 @@ export const ensureJobPortalLogin = (actionHint = '该操作'): boolean => {
});
return false;
};
/** 收藏/申请等需要 userId 的操作:先校验登录,再确保 userId 可用 */
export const requireJobPortalUserId = async (actionHint: string): Promise<number | null> => {
if (!ensureJobPortalLogin(actionHint)) {
return null;
}
const userId = await ensureJobPortalUserId();
if (!userId) {
message.warning('无法获取用户信息,请稍后重试');
return null;
}
return userId;
};