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.
This commit is contained in:
2026-07-20 11:53:43 +08:00
parent 4d48d33476
commit ed0ac7c4c2
17 changed files with 681 additions and 47 deletions

View File

@@ -223,6 +223,11 @@ export default [
name: 'company',
path: '/company',
routes: [
{
name: '企业信息',
path: '/company/info/index',
component: './Company/Info',
},
{
name: '企业资质审核详情',
path: '/company/qualification-review/detail/:id',

View File

@@ -117,7 +117,7 @@ const JobComplaintModal: React.FC<JobComplaintModalProps> = ({
open={open}
onCancel={onCancel}
width={600}
destroyOnClose
destroyOnHidden
footer={
<Space>
<Button onClick={onCancel}></Button>

View File

@@ -0,0 +1,174 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Form, Input, message, Select, Space, Spin, TreeSelect } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { getDictValueEnum } from '@/services/system/dict';
import { getCmsIndustryTreeList } from '@/services/classify/industry';
import { getCmsCurrentCompany, putCmsCurrentCompany } from '@/services/company/list';
import type { DictValueEnumObj } from '@/components/DictTag';
const CompanyInfoPage: React.FC = () => {
const [form] = Form.useForm<API.CompanyList.Company>();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [scaleEnum, setScaleEnum] = useState<DictValueEnumObj>({});
const [companyNatureEnum, setCompanyNatureEnum] = useState<DictValueEnumObj>({});
const [industryTree, setIndustryTree] = useState<any[]>([]);
useEffect(() => {
const loadCompanyInfo = async () => {
setLoading(true);
try {
const [companyRes, scaleData, companyNatureData, industryRes] = await Promise.all([
getCmsCurrentCompany(),
getDictValueEnum('scale', false, true),
getDictValueEnum('company_nature', false, true),
getCmsIndustryTreeList(),
]);
if (companyRes.code !== 200 || !companyRes.data) {
message.error(companyRes.msg || '获取企业信息失败');
return;
}
form.setFieldsValue(companyRes.data);
setScaleEnum(scaleData);
setCompanyNatureEnum(companyNatureData);
if (industryRes?.code === 200 && industryRes.data) {
setIndustryTree(industryRes.data);
}
} catch {
message.error('获取企业信息失败,请稍后重试');
} finally {
setLoading(false);
}
};
void loadCompanyInfo();
}, [form]);
const handleFinish = async (values: API.CompanyList.Company) => {
setSaving(true);
try {
const companyContactList = (values.companyContactList || [])
.filter((contact) => contact.contactPerson || contact.contactPersonPhone)
.map((contact) => ({
contactPerson: contact.contactPerson,
contactPersonPhone: contact.contactPersonPhone,
}));
const response = await putCmsCurrentCompany({
...values,
companyNature: values.companyNature || '',
companyContactList,
});
if (response.code === 200) {
message.success('企业信息保存成功');
form.setFieldsValue({ ...values, companyContactList });
} else {
message.error(response.msg || '保存失败');
}
} catch {
message.error('保存失败,请稍后重试');
} finally {
setSaving(false);
}
};
return (
<Card title="企业信息" style={{ margin: 24 }}>
<Spin spinning={loading}>
<Form<API.CompanyList.Company>
form={form}
layout="vertical"
onFinish={handleFinish}
style={{ maxWidth: 1100 }}
>
<Space align="start" size={24} style={{ display: 'flex' }}>
<Form.Item
name="name"
label="单位名称"
rules={[{ required: true, message: '请输入单位名称' }]}
style={{ flex: 1 }}
>
<Input placeholder="请输入单位名称" />
</Form.Item>
<Form.Item name="companyNature" label="企业性质" style={{ flex: 1 }}>
<Select allowClear options={Object.values(companyNatureEnum)} placeholder="请选择企业性质" />
</Form.Item>
<Form.Item
name="scale"
label="单位规模"
rules={[{ required: true, message: '请选择单位规模' }]}
style={{ flex: 1 }}
>
<Select options={Object.values(scaleEnum)} placeholder="请选择单位规模" />
</Form.Item>
</Space>
<Space align="start" size={24} style={{ display: 'flex' }}>
<Form.Item name="code" label="信用代码" style={{ flex: 1 }}>
<Input placeholder="请输入信用代码" />
</Form.Item>
<Form.Item name="industry" label="主要行业" style={{ flex: 1 }}>
<TreeSelect
allowClear
showSearch
treeData={industryTree}
treeNodeFilterProp="label"
fieldNames={{ label: 'label', value: 'id', children: 'children' }}
placeholder="请选择主要行业"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="location" label="单位地点" style={{ flex: 1 }}>
<Input placeholder="请输入单位地点" />
</Form.Item>
</Space>
<Form.Item name="registeredAddress" label="注册地址">
<Input placeholder="请输入注册地址" />
</Form.Item>
<Space align="start" size={24} style={{ display: 'flex' }}>
<Form.Item name="legalPerson" label="法人姓名" style={{ flex: 1 }}>
<Input placeholder="请输入法人姓名" />
</Form.Item>
<Form.Item name="legalPhone" label="法人联系方式" style={{ flex: 1 }}>
<Input placeholder="请输入法人联系方式" />
</Form.Item>
</Space>
<Form.Item name="description" label="单位介绍">
<Input.TextArea rows={6} placeholder="请输入单位介绍" />
</Form.Item>
<Form.Item label="企业联系人">
<Form.List name="companyContactList">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} align="baseline" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...restField} name={[name, 'contactPerson']}>
<Input placeholder="联系人姓名" />
</Form.Item>
<Form.Item {...restField} name={[name, 'contactPersonPhone']}>
<Input placeholder="联系电话" />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} />
</Space>
))}
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={saving}>
</Button>
</Form.Item>
</Form>
</Spin>
</Card>
);
};
export default CompanyInfoPage;

View File

@@ -13,7 +13,6 @@ import {
PlusOutlined,
MinusCircleOutlined,
DeleteOutlined,
IdcardOutlined,
PhoneOutlined,
UserOutlined,
} from '@ant-design/icons';
@@ -34,6 +33,7 @@ export type ListFormProps = {
open: boolean;
values?: Partial<API.CompanyList.Company>;
scaleEnum?: DictValueEnumObj;
companyNatureEnum?: DictValueEnumObj;
};
const ListEdit: React.FC<ListFormProps> = (props) => {
@@ -80,7 +80,6 @@ const ListEdit: React.FC<ListFormProps> = (props) => {
name="name"
label="单位名称"
placeholder="请输入单位名称"
disabled={!!props.values}
/>
<ProFormSelect
width="md"
@@ -89,16 +88,22 @@ const ListEdit: React.FC<ListFormProps> = (props) => {
valueEnum={props.scaleEnum}
placeholder="请选择单位规模"
rules={[{ required: true, message: '请选择单位规模!' }]}
disabled={!!props.values}
/>
</ProForm.Group>
<ProFormSelect
width="md"
name="companyNature"
label="企业性质"
valueEnum={props.companyNatureEnum}
placeholder="请选择企业性质"
fieldProps={{ allowClear: true }}
/>
<ProForm.Group>
<ProFormText
width="md"
name="code"
label="信用代码"
placeholder="请输入信用代码"
disabled={!!props.values}
/>
<ProFormTreeSelect
name="industry"
@@ -106,7 +111,6 @@ const ListEdit: React.FC<ListFormProps> = (props) => {
placeholder="请输入主要行业"
allowClear
width="md"
disabled={!!props.values}
request={async () => {
return getCmsIndustryTreeList().then((res) => {
return res.data;
@@ -135,16 +139,34 @@ const ListEdit: React.FC<ListFormProps> = (props) => {
name="location"
label="单位地点"
placeholder="请输入单位地点"
disabled={!!props.values}
/>
<ProFormText
width="xl"
name="registeredAddress"
label="注册地址"
placeholder="请输入注册地址"
/>
</ProForm.Group>
<ProForm.Group>
<ProFormText
width="md"
name="legalPerson"
label="法人姓名"
placeholder="请输入法人姓名"
/>
<ProFormText
width="md"
name="legalPhone"
label="法人联系方式"
placeholder="请输入法人联系方式"
/>
</ProForm.Group>
<ProFormTextArea
width="xl"
name="description"
label="单位介绍"
placeholder="请输入单位介绍"
disabled={!!props.values}
/>
</ProForm.Group>
{/* 企业联系人动态表单 */}
<ProForm.Item label="企业联系人">
@@ -206,6 +228,7 @@ export const CompanyDetailView = ({
onCancel,
record,
scaleEnum,
companyNatureEnum,
industryEnum,
loading,
}: {
@@ -213,11 +236,12 @@ export const CompanyDetailView = ({
onCancel: () => void;
record?: API.CompanyList.Company;
scaleEnum?: DictValueEnumObj;
companyNatureEnum?: DictValueEnumObj;
industryEnum?: DictValueEnumObj;
loading?: boolean;
}) => {
const contacts = record?.companyContactList?.filter(
(c) => c.contactPerson || c.contactPersonPhone || c.position,
(c) => c.contactPerson || c.contactPersonPhone,
);
const statusTag =
@@ -254,6 +278,9 @@ export const CompanyDetailView = ({
{record?.scale != null && record.scale !== '' && (
<DictTag enums={scaleEnum} value={record.scale} />
)}
{record?.companyNature != null && record.companyNature !== '' && (
<DictTag enums={companyNatureEnum} value={record.companyNature} />
)}
{statusTag}
{record?.industry != null && record.industry !== '' ? (
<DictTag enums={industryEnum} value={record.industry} />
@@ -272,9 +299,25 @@ export const CompanyDetailView = ({
<Descriptions.Item label="公司位置" span={2}>
{renderDetailText(record?.location as string)}
</Descriptions.Item>
<Descriptions.Item label="企业性质">
{record?.companyNature ? (
<DictTag enums={companyNatureEnum} value={record.companyNature} />
) : (
renderDetailText()
)}
</Descriptions.Item>
<Descriptions.Item label="信用代码" span={2}>
{renderDetailText(record?.code)}
</Descriptions.Item>
<Descriptions.Item label="注册地址">
{renderDetailText(record?.registeredAddress)}
</Descriptions.Item>
<Descriptions.Item label="法人姓名">
{renderDetailText(record?.legalPerson)}
</Descriptions.Item>
<Descriptions.Item label="法人联系方式">
{renderDetailText(record?.legalPhone)}
</Descriptions.Item>
</Descriptions>
</div>
@@ -307,12 +350,6 @@ export const CompanyDetailView = ({
<PhoneOutlined />
<span>{renderDetailText(contact.contactPersonPhone)}</span>
</div>
{contact.position?.trim() ? (
<div className="company-detail__contact-row">
<IdcardOutlined />
<span>{contact.position}</span>
</div>
) : null}
</div>
</div>
))}

View File

@@ -68,6 +68,7 @@ function ManagementList() {
const [detailLoading, setDetailLoading] = useState<boolean>(false);
const [detailData, setDetailData] = useState<API.CompanyList.Company | undefined>();
const [scaleEnum, setScaleEnum] = useState<Record<string, any>>({});
const [companyNatureEnum, setCompanyNatureEnum] = useState<DictValueEnumObj>({});
const [industryEnum, setIndustryEnum] = useState<DictValueEnumObj>({});
const [approvalVisible, setApprovalVisible] = useState<boolean>(false);
const [approvalStatus, setApprovalStatus] = useState<1 | 2>(1);
@@ -110,6 +111,9 @@ function ManagementList() {
getDictValueEnum('scale', true, true).then((data) => {
setScaleEnum(data);
});
getDictValueEnum('company_nature', false, true).then((data) => {
setCompanyNatureEnum(data);
});
getCmsIndustryTreeList().then((res) => {
if (res?.data) {
setIndustryEnum(flattenIndustryTree(res.data));
@@ -158,6 +162,14 @@ function ManagementList() {
return <DictTag enums={scaleEnum} value={record.scale} />;
},
},
{
title: '企业性质',
dataIndex: 'companyNature',
valueType: 'select',
align: 'center',
valueEnum: companyNatureEnum,
render: (_, record) => <DictTag enums={companyNatureEnum} value={record.companyNature} />,
},
{
title: '公司位置',
dataIndex: 'location',
@@ -350,9 +362,15 @@ function ManagementList() {
onSubmit={async (values) => {
let resData;
if (values.companyId) {
resData = await putCmsCompanyList(values);
resData = await putCmsCompanyList({
...values,
companyNature: values.companyNature || '',
});
} else {
resData = await addCmsCompanyList(values);
resData = await addCmsCompanyList({
...values,
companyNature: values.companyNature || '',
});
}
if (resData.code === 200) {
setModalVisible(false);
@@ -367,6 +385,7 @@ function ManagementList() {
}}
values={currentRow}
scaleEnum={scaleEnum}
companyNatureEnum={companyNatureEnum}
/>
<CompanyDetailView
@@ -378,6 +397,7 @@ function ManagementList() {
}}
record={detailData}
scaleEnum={scaleEnum}
companyNatureEnum={companyNatureEnum}
industryEnum={industryEnum}
/>

View File

@@ -69,6 +69,34 @@
border: 1px solid @jp-primary-border;
}
.job-detail-tags-card {
.job-detail-tag-groups {
display: flex;
flex-direction: column;
gap: 18px;
}
.job-detail-tag-group {
display: grid;
grid-template-columns: 128px minmax(0, 1fr);
align-items: start;
gap: 16px;
}
.job-detail-tag-label {
padding-top: 2px;
color: @jp-text-secondary;
font-weight: 600;
line-height: 24px;
}
.ant-tag {
margin-inline-end: 0;
padding: 2px 10px;
border-radius: 999px;
}
}
.company-info-card {
.company-overview {
display: flex;
@@ -163,5 +191,10 @@
@media (max-width: 768px) {
.job-detail-page .job-detail-container {
padding: 0 12px;
.job-detail-tags-card .job-detail-tag-group {
grid-template-columns: 1fr;
gap: 8px;
}
}
}

View File

@@ -42,6 +42,7 @@ import JobComplaintModal from '@/components/JobComplaintModal';
import { getDictValueEnum } from '@/services/system/dict';
import { getCmsIndustryTreeList } from '@/services/classify/industry';
import type { DictValueEnumObj } from '@/components/DictTag';
import { parseCommaSeparatedTags } from '@/utils/jobDetailTags';
const { Title, Text, Paragraph } = Typography;
@@ -87,9 +88,13 @@ const transformJobData = (jobData: any) => {
? formatSalary(jobData.minSalary, jobData.maxSalary)
: (jobData.salary || '面议'),
location: jobData.jobLocation || jobData.location,
companyNature: jobData.companyNature || jobData.company?.companyNature || '',
experience: jobData.experience ? experienceMap[jobData.experience] || jobData.experience : '不限',
education: jobData.education ? educationMap[jobData.education] || jobData.education : '不限',
tags: jobData.tags || ['五险一金', '带薪年假', '年终奖'],
tags: Array.isArray(jobData.tags) ? jobData.tags : [],
salaryComposition: parseCommaSeparatedTags(jobData.salaryComposition),
welfareBenefits: parseCommaSeparatedTags(jobData.welfareBenefits),
workSchedule: parseCommaSeparatedTags(jobData.workSchedule),
publishTime: jobData.publishTime || new Date().toISOString().split('T')[0],
description: jobData.description || `
<h3>职位职责:</h3>
@@ -137,7 +142,11 @@ const mockJobDetail = {
location: '青岛·李沧区',
experience: '3-5年',
education: '本科',
tags: ['五险一金', '带薪年假', '年终奖', '定期体检'],
companyNature: '',
tags: [],
salaryComposition: [] as string[],
welfareBenefits: [] as string[],
workSchedule: [] as string[],
publishTime: '2024-03-15',
description: `
<h3>职位职责:</h3>
@@ -193,15 +202,27 @@ const JobDetailPage: React.FC = () => {
{ item: '工作地', score: 0 }
]);
const [scaleEnum, setScaleEnum] = useState<DictValueEnumObj>({});
const [companyNatureEnum, setCompanyNatureEnum] = useState<DictValueEnumObj>({});
const [industryTree, setIndustryTree] = useState<any[]>([]);
const [complaintVisible, setComplaintVisible] = useState(false);
const detailTagGroups = useMemo(
() => [
{ label: '薪资范围与构成', values: jobDetail.salaryComposition },
{ label: '福利待遇', values: jobDetail.welfareBenefits },
{ label: '工作时间安排', values: jobDetail.workSchedule },
].filter((group) => group.values.length > 0),
[jobDetail.salaryComposition, jobDetail.welfareBenefits, jobDetail.workSchedule],
);
useEffect(() => {
Promise.all([
getDictValueEnum('scale', true, true),
getDictValueEnum('company_nature', false, true),
getCmsIndustryTreeList(),
]).then(([scaleData, industryRes]) => {
]).then(([scaleData, companyNatureData, industryRes]) => {
setScaleEnum(scaleData);
setCompanyNatureEnum(companyNatureData);
if (industryRes?.code === 200 && industryRes?.data) {
setIndustryTree(industryRes.data);
}
@@ -243,6 +264,11 @@ const JobDetailPage: React.FC = () => {
setJobDetail(transformedJobData);
// 根据 isCollection 字段设置收藏状态
setIsFavorited(passedJobData?.isCollection !== null && passedJobData?.isCollection !== 0 && passedJobData?.isCollection !== undefined);
// 列表卡片不携带详情标签;进入详情页后始终请求详情接口补齐完整岗位数据。
const passedJobId = passedJobData.jobId || passedJobData.id || id;
if (passedJobId) {
fetchJobDetailFromApi(String(passedJobId));
}
} else {
// 直接刷新页面时,从 URL 参数或 route param 获取 jobId调 API 获取数据
const jobIdFromUrl = searchParams.get('jobId');
@@ -507,6 +533,11 @@ const JobDetailPage: React.FC = () => {
</Text>
</Space>
<Space wrap>
{jobDetail.companyNature && (
<Tag color="blue">
{getDictLabel(companyNatureEnum, jobDetail.companyNature)}
</Tag>
)}
{jobDetail.tags.map((tag, index) => (
<Tag key={index} color="blue">{tag}</Tag>
))}
@@ -549,6 +580,23 @@ const JobDetailPage: React.FC = () => {
<Row gutter={16}>
{/* 左侧内容区 */}
<Col span={16}>
{detailTagGroups.length > 0 && (
<Card title="岗位待遇与工作安排" className="info-card job-detail-tags-card">
<div className="job-detail-tag-groups">
{detailTagGroups.map((group) => (
<div className="job-detail-tag-group" key={group.label}>
<div className="job-detail-tag-label">{group.label}</div>
<Space wrap size={[8, 8]}>
{group.values.map((value) => (
<Tag color="blue" key={`${group.label}-${value}`}>{value}</Tag>
))}
</Space>
</div>
))}
</div>
</Card>
)}
{/* 职位描述 */}
<Card title="职位描述" className="info-card">
<div
@@ -697,4 +745,3 @@ const JobDetailPage: React.FC = () => {
};
export default JobDetailPage;

View File

@@ -209,6 +209,43 @@
margin: 20px 0 24px;
}
.job-detail-tag-section {
margin-bottom: 28px;
.section-title {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
color: @jp-text-primary;
}
.job-detail-tag-groups {
display: flex;
flex-direction: column;
gap: 14px;
}
.job-detail-tag-group {
display: grid;
grid-template-columns: 128px minmax(0, 1fr);
align-items: start;
gap: 16px;
}
.job-detail-tag-label {
color: @jp-text-secondary;
font-weight: 500;
line-height: 26px;
}
.detail-tag {
.jp-job-tag();
margin: 0;
padding: 2px 10px;
line-height: 22px;
}
}
.job-description {
.section-title {
margin: 0 0 16px;

View File

@@ -28,10 +28,18 @@ import {
import { history, useLocation } from '@umijs/max';
import { getJobList, getJobRecommend } from '@/services/common/jobTitle';
import JobPortalHeader from '@/components/JobPortalHeader';
import { favoriteJob, unfavoriteJob, applyJob, browseJob, blockCompany } from '@/services/jobportal/user';
import {
favoriteJob,
unfavoriteJob,
applyJob,
browseJob,
blockCompany,
getJobDetail,
} from '@/services/jobportal/user';
import { ensureJobPortalLogin, requireJobPortalUserId } from '@/utils/jobPortalAuth';
import { getDictValueEnum } from '@/services/system/dict';
import { getDictLabel, findTreeLabelById, resolveIndustryLabel } from '@/utils/jobPortalDict';
import { parseCommaSeparatedTags } from '@/utils/jobDetailTags';
import JobComplaintModal from '@/components/JobComplaintModal';
import { getJobTitleTreeSelect } from '@/services/common/jobTitle';
import { getCmsIndustryTreeList } from '@/services/classify/industry';
@@ -203,6 +211,7 @@ const JobListPage: React.FC = () => {
const [loading, setLoading] = useState<boolean>(false);
const [jobCategory, setJobCategory] = useState<string>('');
const [scaleEnum, setScaleEnum] = useState<DictValueEnumObj>({});
const [companyNatureEnum, setCompanyNatureEnum] = useState<DictValueEnumObj>({});
const [jobTitleTree, setJobTitleTree] = useState<any[]>([]);
const [industryTree, setIndustryTree] = useState<any[]>([]);
const [searchValue, setSearchValue] = useState<string>(''); // 搜索框的值
@@ -217,6 +226,30 @@ const JobListPage: React.FC = () => {
{ item: '工作地', score: 0 }
]);
const [complaintVisible, setComplaintVisible] = useState(false);
const selectedJobId = selectedJob?.jobId || selectedJob?.id;
const selectedJobDetailTagGroups = useMemo(
() => [
{
label: '薪资范围与构成',
values: parseCommaSeparatedTags(selectedJob?.salaryComposition),
},
{
label: '福利待遇',
values: parseCommaSeparatedTags(selectedJob?.welfareBenefits),
},
{
label: '工作时间安排',
values: parseCommaSeparatedTags(selectedJob?.workSchedule),
},
].filter((group) => group.values.length > 0),
[
selectedJob?.salaryComposition,
selectedJob?.welfareBenefits,
selectedJob?.workSchedule,
],
);
// 格式化薪资显示
const formatSalary = (minSalary: number, maxSalary: number) => {
if (minSalary && maxSalary) {
@@ -232,10 +265,12 @@ const JobListPage: React.FC = () => {
useEffect(() => {
Promise.all([
getDictValueEnum('scale', true, true),
getDictValueEnum('company_nature', false, true),
getCmsIndustryTreeList(),
getJobTitleTreeSelect(),
]).then(([scaleData, industryRes, jobTitleRes]) => {
]).then(([scaleData, companyNatureData, industryRes, jobTitleRes]) => {
setScaleEnum(scaleData);
setCompanyNatureEnum(companyNatureData);
if (industryRes?.code === 200 && industryRes?.data) {
setIndustryTree(industryRes.data);
}
@@ -302,6 +337,32 @@ const JobListPage: React.FC = () => {
}
}, [jobList, selectedJob]);
// 列表数据不包含岗位待遇字段,选中岗位后补取完整详情。
useEffect(() => {
if (!selectedJobId) return;
let cancelled = false;
const requestedJobId = selectedJobId;
getJobDetail(requestedJobId)
.then((response) => {
if (cancelled || response?.code !== 200 || !response?.data) return;
setSelectedJob((current: any) => {
const currentJobId = current?.jobId || current?.id;
if (String(currentJobId) !== String(requestedJobId)) return current;
return { ...current, ...response.data };
});
})
.catch(() => {
// 详情请求失败时保留列表已有信息,不影响岗位浏览。
});
return () => {
cancelled = true;
};
}, [selectedJobId]);
// 当 selectedJob 变化时,更新收藏状态
useEffect(() => {
if (selectedJob) {
@@ -313,31 +374,31 @@ const JobListPage: React.FC = () => {
// 当 selectedJob 变化时,获取竞争力分析数据(仅登录)
useEffect(() => {
if (!selectedJob) return;
if (!selectedJobId) return;
if (!isJobPortalLoggedIn()) {
setOverallScore(0);
setRadarData(getEmptyCompetitivenessRadar());
return;
}
const jobIdNum = Number(selectedJob?.jobId || selectedJob?.id);
const jobIdNum = Number(selectedJobId);
if (!Number.isNaN(jobIdNum) && jobIdNum) {
fetchCompetitiveness(jobIdNum);
}
}, [selectedJob]);
}, [selectedJobId]);
// 当 selectedJob 变化时,上报浏览记录(仅登录)
useEffect(() => {
if (!selectedJob) return;
if (!selectedJobId) return;
if (!isJobPortalLoggedIn()) {
return;
}
const jobIdNum = Number(selectedJob?.jobId || selectedJob?.id);
const jobIdNum = Number(selectedJobId);
if (!Number.isNaN(jobIdNum) && jobIdNum > 0) {
browseJob({ jobId: jobIdNum }).catch(() => {
// 浏览上报失败不阻塞页面
});
}
}, [selectedJob]);
}, [selectedJobId]);
const fetchCompetitiveness = async (jobIdNum: number) => {
if (!isJobPortalLoggedIn()) {
@@ -781,6 +842,11 @@ const JobListPage: React.FC = () => {
{job.jobCategory && (
<Tag className="job-tag">{resolveJobCategoryLabel(job)}</Tag>
)}
{(job.companyNature || job.company?.companyNature) && (
<Tag className="job-tag">
{getDictLabel(companyNatureEnum, job.companyNature || job.company?.companyNature)}
</Tag>
)}
{/* {job.dataSource && <Tag className="job-tag">{job.dataSource}</Tag>} */}
{job.vacancies && <Tag className="job-tag">{job.vacancies}</Tag>}
{/* {job.industry && <Tag className="job-tag">{job.industry}</Tag>} */}
@@ -847,6 +913,14 @@ const JobListPage: React.FC = () => {
{selectedJob?.jobCategory && (
<Tag className="meta-tag">{resolveJobCategoryLabel(selectedJob)}</Tag>
)}
{(selectedJob?.companyNature || selectedJob?.company?.companyNature) && (
<Tag className="meta-tag">
{getDictLabel(
companyNatureEnum,
selectedJob.companyNature || selectedJob.company?.companyNature,
)}
</Tag>
)}
{selectedJob?.vacancies && (
<Tag className="meta-tag">{selectedJob.vacancies}</Tag>
)}
@@ -891,6 +965,30 @@ const JobListPage: React.FC = () => {
<Row gutter={16}>
{/* 左侧内容区 */}
<Col span={16}>
{selectedJobDetailTagGroups.length > 0 && (
<div className="job-detail-tag-section">
<Title level={4} className="section-title">
</Title>
<div className="job-detail-tag-groups">
{selectedJobDetailTagGroups.map((group) => (
<div className="job-detail-tag-group" key={group.label}>
<div className="job-detail-tag-label">{group.label}</div>
<Space wrap size={[8, 8]}>
{group.values.map((value) => (
<Tag
className="detail-tag"
key={`${group.label}-${value}`}
>
{value}
</Tag>
))}
</Space>
</div>
))}
</div>
</div>
)}
<div className="job-description">
<Title level={4} className="section-title"></Title>
<Paragraph className="description-text">
@@ -1049,4 +1147,3 @@ const JobListPage: React.FC = () => {
};
export default JobListPage;

View File

@@ -19,6 +19,9 @@ 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;
@@ -128,6 +131,7 @@ const JobPortalPage: React.FC = () => {
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);
@@ -189,6 +193,9 @@ const JobPortalPage: React.FC = () => {
fetchJobTitleData();
fetchJobRecommendData();
getDictValueEnum('company_nature', false, true)
.then(setCompanyNatureEnum)
.catch(() => setCompanyNatureEnum({}));
}, [itemsPerPage]);
const handleIndustryHover = (industryId: string) => {
@@ -290,6 +297,11 @@ const JobPortalPage: React.FC = () => {
</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>

View File

@@ -8,7 +8,7 @@ import {
ProFormTextArea,
ProDescriptions,
} from '@ant-design/pro-components';
import { Form, Button, Input, TreeSelect, Switch } from 'antd';
import { Form, Button, Input, Tag, TreeSelect, Switch } from 'antd';
import { PlusOutlined, MinusCircleOutlined, EnvironmentOutlined } from '@ant-design/icons';
import React, { useEffect, useRef, useState } from 'react';
import { DictValueEnumObj } from '@/components/DictTag';
@@ -16,6 +16,37 @@ import { getCmsCompanyList } from '@/services/company/list';
import { getCmsJobDetail } from '@/services/Management/list';
import { getJobTitleTreeSelect } from '@/services/common/jobTitle';
import ProFromMap from '@/components/ProFromMap';
import {
parseCommaSeparatedTags,
serializeCommaSeparatedTags,
} from '@/utils/jobDetailTags';
type ManageFormValues = Omit<
API.ManagementList.Manage,
'salaryComposition' | 'welfareBenefits' | 'workSchedule'
> & {
salaryComposition?: string[];
welfareBenefits?: string[];
workSchedule?: string[];
};
const toManageFormValues = (
values: Partial<API.ManagementList.Manage>,
): Partial<ManageFormValues> => ({
...values,
jobLocationAreaCode: String(values.jobLocationAreaCode || ''),
salaryComposition: parseCommaSeparatedTags(values.salaryComposition),
welfareBenefits: parseCommaSeparatedTags(values.welfareBenefits),
workSchedule: parseCommaSeparatedTags(values.workSchedule),
});
const renderDetailTags = (value: unknown) => {
const tags = parseCommaSeparatedTags(value);
if (tags.length === 0) {
return <span style={{ color: '#999' }}></span>;
}
return tags.map((tag) => <Tag key={tag}>{tag}</Tag>);
};
export type ListFormProps = {
onCancel: (flag?: boolean, formVals?: unknown) => void;
@@ -26,6 +57,9 @@ export type ListFormProps = {
experienceEnum: DictValueEnumObj;
areaEnum: DictValueEnumObj;
jobTypeEnum: DictValueEnumObj;
salaryCompositionEnum: DictValueEnumObj;
welfareBenefitsEnum: DictValueEnumObj;
workScheduleEnum: DictValueEnumObj;
mode?: 'view' | 'edit' | 'create';
};
@@ -38,23 +72,28 @@ const waitTime = (time: number = 100) => {
};
const listEdit: React.FC<ListFormProps> = (props) => {
const [form] = Form.useForm<API.ManagementList.Manage>();
const [form] = Form.useForm<ManageFormValues>();
const companyNameMap = useRef<Record<number, string>>({});
const [jobDetail, setJobDetail] = useState<API.ManagementList.Manage | null>(null);
const [loading, setLoading] = useState(false);
const [jobCategoryTreeData, setJobCategoryTreeData] = useState<any[]>([]);
const [mapOpen, setMapOpen] = useState<boolean>(false);
const [mapViewInfo, setMapViewInfo] = useState<any>({});
const { educationEnum, experienceEnum, areaEnum, jobTypeEnum } = props;
const {
educationEnum,
experienceEnum,
areaEnum,
jobTypeEnum,
salaryCompositionEnum,
welfareBenefitsEnum,
workScheduleEnum,
} = props;
const { mode = props.values ? 'edit' : 'create' } = props;
useEffect(() => {
if(props.open){
form.resetFields();
if (props.values) {
form.setFieldsValue({
...props.values,
jobLocationAreaCode: String(props.values.jobLocationAreaCode || ''),
});
form.setFieldsValue(toManageFormValues(props.values));
// 初始化地图位置信息(编辑时回显)
if (props.values.latitude || props.values.longitude) {
setMapViewInfo({
@@ -92,10 +131,7 @@ const listEdit: React.FC<ListFormProps> = (props) => {
setJobDetail(response.data);
// 如果是编辑模式,将获取到的详情数据设置到表单中
if (mode === 'edit') {
form.setFieldsValue({
...response.data,
jobLocationAreaCode: String(response.data.jobLocationAreaCode || ''),
});
form.setFieldsValue(toManageFormValues(response.data));
}
}
} catch (error) {
@@ -139,6 +175,9 @@ const listEdit: React.FC<ListFormProps> = (props) => {
: companyNameMap.current[values.companyId] ?? '',
minSalary: values.minSalary,
maxSalary: values.maxSalary,
salaryComposition: serializeCommaSeparatedTags(values.salaryComposition),
welfareBenefits: serializeCommaSeparatedTags(values.welfareBenefits),
workSchedule: serializeCommaSeparatedTags(values.workSchedule),
education: values.education,
experience: values.experience,
jobLocationAreaCode: values.jobLocationAreaCode,
@@ -189,6 +228,24 @@ const listEdit: React.FC<ListFormProps> = (props) => {
/>
<ProDescriptions.Item dataIndex="minSalary" label="最低薪资(元/月)" />
<ProDescriptions.Item dataIndex="maxSalary" label="最高薪资(元/月)" />
<ProDescriptions.Item
dataIndex="salaryComposition"
label="薪资范围与构成"
span={2}
render={(value) => renderDetailTags(value)}
/>
<ProDescriptions.Item
dataIndex="welfareBenefits"
label="福利待遇"
span={2}
render={(value) => renderDetailTags(value)}
/>
<ProDescriptions.Item
dataIndex="workSchedule"
label="工作时间安排"
span={2}
render={(value) => renderDetailTags(value)}
/>
<ProDescriptions.Item
dataIndex="education"
label="学历要求"
@@ -341,7 +398,7 @@ const listEdit: React.FC<ListFormProps> = (props) => {
);
}
return (
<ModalForm<API.ManagementList.Manage>
<ModalForm<ManageFormValues>
title={mode === 'edit' ? '编辑岗位' : '新建岗位'}
form={form}
autoFocusFirstInput
@@ -411,6 +468,33 @@ const listEdit: React.FC<ListFormProps> = (props) => {
label="最大薪资(元/月)"
placeholder="请输入最大薪资(元)"
/>
<ProFormSelect
mode="tags"
width="md"
name="salaryComposition"
label="薪资范围与构成"
valueEnum={salaryCompositionEnum}
placeholder="请选择或手动输入薪资构成"
fieldProps={{ tokenSeparators: [',', ''], maxTagCount: 'responsive' }}
/>
<ProFormSelect
mode="tags"
width="md"
name="welfareBenefits"
label="福利待遇"
valueEnum={welfareBenefitsEnum}
placeholder="请选择或手动输入福利待遇"
fieldProps={{ tokenSeparators: [',', ''], maxTagCount: 'responsive' }}
/>
<ProFormSelect
mode="tags"
width="md"
name="workSchedule"
label="工作时间安排"
valueEnum={workScheduleEnum}
placeholder="请选择或手动输入工作时间安排"
fieldProps={{ tokenSeparators: [',', ''], maxTagCount: 'responsive' }}
/>
<ProFormSelect
width="md"
name="education"

View File

@@ -85,6 +85,9 @@ function ManagementList() {
const [isPublishEnum, setIsPublishEnum] = useState<any>([]);
const [jobTypeEnum, setJobTypeEnum] = useState<any>([]);
const [compensationEnum, setCompensationEnum] = useState<any>([]);
const [salaryCompositionEnum, setSalaryCompositionEnum] = useState<any>([]);
const [welfareBenefitsEnum, setWelfareBenefitsEnum] = useState<any>([]);
const [workScheduleEnum, setWorkScheduleEnum] = useState<any>([]);
const [currentRow, setCurrentRow] = useState<API.ManagementList.Manage>();
const [modalVisible, setModalVisible] = useState<boolean>(false);
const [mode, setMode] = useState<'view' | 'edit' | 'create'>('create');
@@ -107,6 +110,15 @@ function ManagementList() {
getDictValueEnum('job_type',true,true).then((data) => {
setJobTypeEnum(data)
})
getDictValueEnum('salary_composition', false, true).then((data) => {
setSalaryCompositionEnum(data);
});
getDictValueEnum('welfare_benefits', false, true).then((data) => {
setWelfareBenefitsEnum(data);
});
getDictValueEnum('work_schedule', false, true).then((data) => {
setWorkScheduleEnum(data);
});
}, []);
async function changeRelease(record: API.ManagementList.Manage) {
@@ -492,6 +504,9 @@ function ManagementList() {
experienceEnum={experienceEnum}
areaEnum={areaEnum}
jobTypeEnum={jobTypeEnum}
salaryCompositionEnum={salaryCompositionEnum}
welfareBenefitsEnum={welfareBenefitsEnum}
workScheduleEnum={workScheduleEnum}
></EditManageRow>
</Fragment>
);

View File

@@ -58,3 +58,22 @@ export async function getCmsCompanyDetail(companyId: number | string) {
method: 'GET',
});
}
/** 获取当前企业用户关联的企业信息 */
export async function getCmsCurrentCompany() {
return request<{
code: number;
msg?: string;
data?: API.CompanyList.Company;
}>('/api/cms/company/current', {
method: 'GET',
});
}
/** 修改当前企业用户关联的企业信息 */
export async function putCmsCurrentCompany(params: API.CompanyList.Params) {
return request<API.CompanyList.CompanyListResult>('/api/cms/company/current', {
method: 'PUT',
data: params,
});
}

View File

@@ -23,6 +23,9 @@ declare namespace API.ManagementList {
longitude?: number;
maxSalary?: number;
minSalary?: number;
salaryComposition?: string;
welfareBenefits?: string;
workSchedule?: string;
postingDate?: string;
vacancies?: number;
view?: number;
@@ -32,6 +35,7 @@ declare namespace API.ManagementList {
jobContactList?: ContactPerson[];
createTime?: string;
jobCategory?: string;
description?: string;
}
export interface AddParams {
@@ -52,6 +56,9 @@ declare namespace API.ManagementList {
longitude?: number;
maxSalary?: number;
minSalary?: number;
salaryComposition?: string;
welfareBenefits?: string;
workSchedule?: string;
postingDate?: string;
vacancies?: number;
view?: number;
@@ -60,6 +67,7 @@ declare namespace API.ManagementList {
jobType?: string;
jobContactList?: ContactPerson[];
jobCategory?: string;
description?: string;
}
export interface ListParams {

View File

@@ -9,7 +9,6 @@ declare namespace API.CompanyList {
export interface CompanyContact {
contactPerson: string;
contactPersonPhone: string;
position?: string;
}
export interface Company {
@@ -19,8 +18,12 @@ declare namespace API.CompanyList {
location?: any;
industry: string;
scale: string;
companyNature?: string;
code: string;
description: string;
registeredAddress?: string;
legalPerson?: string;
legalPhone?: string;
companyContactList?: CompanyContact[];
contactPerson?: string;
contactPersonPhone?: string;
@@ -36,7 +39,13 @@ declare namespace API.CompanyList {
location?: any;
industry?: string;
scale?: string;
companyNature?: string;
code?: string;
description?: string;
registeredAddress?: string;
legalPerson?: string;
legalPhone?: string;
companyContactList?: CompanyContact[];
status?: 0 | 1 | 2;
startDate?: string;
endDate?: string;

View File

@@ -0,0 +1,16 @@
export const parseCommaSeparatedTags = (value: unknown): string[] => {
const rawValues = Array.isArray(value)
? value
: typeof value === 'string'
? value.split(/[,]/)
: [];
return Array.from(new Set(rawValues.map((item) => String(item ?? '').trim()).filter(Boolean)));
};
export const serializeCommaSeparatedTags = (value: unknown): string | undefined => {
if (value === null || value === undefined) {
return undefined;
}
return parseCommaSeparatedTags(value).join(',');
};

View File

@@ -0,0 +1,21 @@
import { parseCommaSeparatedTags, serializeCommaSeparatedTags } from '@/utils/jobDetailTags';
describe('job detail tag helpers', () => {
it('parses English and Chinese commas, trims values, and removes duplicates', () => {
expect(parseCommaSeparatedTags('基本工资, 绩效工资,奖金,基本工资')).toEqual([
'基本工资',
'绩效工资',
'奖金',
]);
});
it('keeps dictionary selections and manual entries in one comma-separated value', () => {
expect(serializeCommaSeparatedTags(['五险一金', '带薪年假', '生日福利'])).toBe(
'五险一金,带薪年假,生日福利',
);
});
it('serializes a cleared selector as an empty string so an existing value can be removed', () => {
expect(serializeCommaSeparatedTags([])).toBe('');
});
});