From ed0ac7c4c2a3b6c91d6578b70c1ac35627897409 Mon Sep 17 00:00:00 2001 From: lapuda <577732344@qq.com> Date: Mon, 20 Jul 2026 11:53:43 +0800 Subject: [PATCH 01/13] 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. --- config/routes.ts | 5 + src/components/JobComplaintModal/index.tsx | 2 +- src/pages/Company/Info/index.tsx | 174 +++++++++++++++++++++ src/pages/Company/List/edit.tsx | 73 ++++++--- src/pages/Company/List/index.tsx | 24 ++- src/pages/JobPortal/Detail/index.less | 33 ++++ src/pages/JobPortal/Detail/index.tsx | 55 ++++++- src/pages/JobPortal/List/index.less | 37 +++++ src/pages/JobPortal/List/index.tsx | 115 ++++++++++++-- src/pages/JobPortal/index.tsx | 12 ++ src/pages/Management/List/edit.tsx | 108 +++++++++++-- src/pages/Management/List/index.tsx | 15 ++ src/services/company/list.ts | 19 +++ src/types/Management/list.d.ts | 8 + src/types/company/list.d.ts | 11 +- src/utils/jobDetailTags.ts | 16 ++ tests/utils/jobDetailTags.test.ts | 21 +++ 17 files changed, 681 insertions(+), 47 deletions(-) create mode 100644 src/pages/Company/Info/index.tsx create mode 100644 src/utils/jobDetailTags.ts create mode 100644 tests/utils/jobDetailTags.test.ts diff --git a/config/routes.ts b/config/routes.ts index c75e63b..565eb4b 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -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', diff --git a/src/components/JobComplaintModal/index.tsx b/src/components/JobComplaintModal/index.tsx index 4a2b168..30f3a80 100644 --- a/src/components/JobComplaintModal/index.tsx +++ b/src/components/JobComplaintModal/index.tsx @@ -117,7 +117,7 @@ const JobComplaintModal: React.FC = ({ open={open} onCancel={onCancel} width={600} - destroyOnClose + destroyOnHidden footer={ diff --git a/src/pages/Company/Info/index.tsx b/src/pages/Company/Info/index.tsx new file mode 100644 index 0000000..d77eb2d --- /dev/null +++ b/src/pages/Company/Info/index.tsx @@ -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(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [scaleEnum, setScaleEnum] = useState({}); + const [companyNatureEnum, setCompanyNatureEnum] = useState({}); + const [industryTree, setIndustryTree] = useState([]); + + 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 ( + + + + form={form} + layout="vertical" + onFinish={handleFinish} + style={{ maxWidth: 1100 }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }) => ( + + + + + + + + remove(name)} /> + + ))} + + + )} + + + + + + + + + + ); +}; + +export default CompanyInfoPage; diff --git a/src/pages/Company/List/edit.tsx b/src/pages/Company/List/edit.tsx index b41ee75..0aad5fa 100644 --- a/src/pages/Company/List/edit.tsx +++ b/src/pages/Company/List/edit.tsx @@ -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; scaleEnum?: DictValueEnumObj; + companyNatureEnum?: DictValueEnumObj; }; const ListEdit: React.FC = (props) => { @@ -80,7 +80,6 @@ const ListEdit: React.FC = (props) => { name="name" label="单位名称" placeholder="请输入单位名称" - disabled={!!props.values} /> = (props) => { valueEnum={props.scaleEnum} placeholder="请选择单位规模" rules={[{ required: true, message: '请选择单位规模!' }]} - disabled={!!props.values} /> + = (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 = (props) => { name="location" label="单位地点" placeholder="请输入单位地点" - disabled={!!props.values} /> - + + + + + {/* 企业联系人动态表单 */} @@ -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 !== '' && ( )} + {record?.companyNature != null && record.companyNature !== '' && ( + + )} {statusTag} {record?.industry != null && record.industry !== '' ? ( @@ -272,9 +299,25 @@ export const CompanyDetailView = ({ {renderDetailText(record?.location as string)} + + {record?.companyNature ? ( + + ) : ( + renderDetailText() + )} + {renderDetailText(record?.code)} + + {renderDetailText(record?.registeredAddress)} + + + {renderDetailText(record?.legalPerson)} + + + {renderDetailText(record?.legalPhone)} + @@ -307,12 +350,6 @@ export const CompanyDetailView = ({ {renderDetailText(contact.contactPersonPhone)} - {contact.position?.trim() ? ( -
- - {contact.position} -
- ) : null} ))} diff --git a/src/pages/Company/List/index.tsx b/src/pages/Company/List/index.tsx index c45dd35..40ee021 100644 --- a/src/pages/Company/List/index.tsx +++ b/src/pages/Company/List/index.tsx @@ -68,6 +68,7 @@ function ManagementList() { const [detailLoading, setDetailLoading] = useState(false); const [detailData, setDetailData] = useState(); const [scaleEnum, setScaleEnum] = useState>({}); + const [companyNatureEnum, setCompanyNatureEnum] = useState({}); const [industryEnum, setIndustryEnum] = useState({}); const [approvalVisible, setApprovalVisible] = useState(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 ; }, }, + { + title: '企业性质', + dataIndex: 'companyNature', + valueType: 'select', + align: 'center', + valueEnum: companyNatureEnum, + render: (_, record) => , + }, { 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} /> diff --git a/src/pages/JobPortal/Detail/index.less b/src/pages/JobPortal/Detail/index.less index e309ea7..30f11dd 100644 --- a/src/pages/JobPortal/Detail/index.less +++ b/src/pages/JobPortal/Detail/index.less @@ -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; + } } } diff --git a/src/pages/JobPortal/Detail/index.tsx b/src/pages/JobPortal/Detail/index.tsx index 339f835..45c1d55 100644 --- a/src/pages/JobPortal/Detail/index.tsx +++ b/src/pages/JobPortal/Detail/index.tsx @@ -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 || `

职位职责:

@@ -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: `

职位职责:

@@ -193,15 +202,27 @@ const JobDetailPage: React.FC = () => { { item: '工作地', score: 0 } ]); const [scaleEnum, setScaleEnum] = useState({}); + const [companyNatureEnum, setCompanyNatureEnum] = useState({}); const [industryTree, setIndustryTree] = useState([]); 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 = () => {
+ {jobDetail.companyNature && ( + + {getDictLabel(companyNatureEnum, jobDetail.companyNature)} + + )} {jobDetail.tags.map((tag, index) => ( {tag} ))} @@ -549,6 +580,23 @@ const JobDetailPage: React.FC = () => { {/* 左侧内容区 */} + {detailTagGroups.length > 0 && ( + +
+ {detailTagGroups.map((group) => ( +
+
{group.label}
+ + {group.values.map((value) => ( + {value} + ))} + +
+ ))} +
+
+ )} + {/* 职位描述 */}
{ }; export default JobDetailPage; - diff --git a/src/pages/JobPortal/List/index.less b/src/pages/JobPortal/List/index.less index 4e84c2e..33ca9d3 100644 --- a/src/pages/JobPortal/List/index.less +++ b/src/pages/JobPortal/List/index.less @@ -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; diff --git a/src/pages/JobPortal/List/index.tsx b/src/pages/JobPortal/List/index.tsx index 6f77075..bf5c156 100644 --- a/src/pages/JobPortal/List/index.tsx +++ b/src/pages/JobPortal/List/index.tsx @@ -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(false); const [jobCategory, setJobCategory] = useState(''); const [scaleEnum, setScaleEnum] = useState({}); + const [companyNatureEnum, setCompanyNatureEnum] = useState({}); const [jobTitleTree, setJobTitleTree] = useState([]); const [industryTree, setIndustryTree] = useState([]); const [searchValue, setSearchValue] = useState(''); // 搜索框的值 @@ -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 && ( {resolveJobCategoryLabel(job)} )} + {(job.companyNature || job.company?.companyNature) && ( + + {getDictLabel(companyNatureEnum, job.companyNature || job.company?.companyNature)} + + )} {/* {job.dataSource && {job.dataSource}} */} {job.vacancies && 招聘{job.vacancies}人} {/* {job.industry && {job.industry}} */} @@ -847,6 +913,14 @@ const JobListPage: React.FC = () => { {selectedJob?.jobCategory && ( {resolveJobCategoryLabel(selectedJob)} )} + {(selectedJob?.companyNature || selectedJob?.company?.companyNature) && ( + + {getDictLabel( + companyNatureEnum, + selectedJob.companyNature || selectedJob.company?.companyNature, + )} + + )} {selectedJob?.vacancies && ( 招聘{selectedJob.vacancies}人 )} @@ -891,6 +965,30 @@ const JobListPage: React.FC = () => { {/* 左侧内容区 */} + {selectedJobDetailTagGroups.length > 0 && ( +
+ + 岗位待遇与工作安排 + +
+ {selectedJobDetailTagGroups.map((group) => ( +
+
{group.label}
+ + {group.values.map((value) => ( + + {value} + + ))} + +
+ ))} +
+
+ )}
职位描述 @@ -1049,4 +1147,3 @@ const JobListPage: React.FC = () => { }; export default JobListPage; - diff --git a/src/pages/JobPortal/index.tsx b/src/pages/JobPortal/index.tsx index cceab6c..513da20 100644 --- a/src/pages/JobPortal/index.tsx +++ b/src/pages/JobPortal/index.tsx @@ -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(1); const [jobTitleData, setJobTitleData] = useState(null); const [jobRecommendData, setJobRecommendData] = useState(null); + const [companyNatureEnum, setCompanyNatureEnum] = useState({}); const [loading, setLoading] = useState(false); const [itemsPerPage] = useState(6); const [totalPages, setTotalPages] = useState(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 = () => {
{job.jobCategory && {job.jobCategory}} + {(job.companyNature || job.company?.companyNature) && ( + + {getDictLabel(companyNatureEnum, job.companyNature || job.company?.companyNature)} + + )} {job.vacancies && 招聘{job.vacancies}人}
diff --git a/src/pages/Management/List/edit.tsx b/src/pages/Management/List/edit.tsx index 0662de6..1c9de25 100644 --- a/src/pages/Management/List/edit.tsx +++ b/src/pages/Management/List/edit.tsx @@ -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, +): Partial => ({ + ...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 暂无; + } + return tags.map((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 = (props) => { - const [form] = Form.useForm(); + const [form] = Form.useForm(); const companyNameMap = useRef>({}); const [jobDetail, setJobDetail] = useState(null); const [loading, setLoading] = useState(false); const [jobCategoryTreeData, setJobCategoryTreeData] = useState([]); const [mapOpen, setMapOpen] = useState(false); const [mapViewInfo, setMapViewInfo] = useState({}); - 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 = (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 = (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 = (props) => { /> + renderDetailTags(value)} + /> + renderDetailTags(value)} + /> + renderDetailTags(value)} + /> = (props) => { ); } return ( - + title={mode === 'edit' ? '编辑岗位' : '新建岗位'} form={form} autoFocusFirstInput @@ -411,6 +468,33 @@ const listEdit: React.FC = (props) => { label="最大薪资(元/月)" placeholder="请输入最大薪资(元)" /> + + + ([]); const [jobTypeEnum, setJobTypeEnum] = useState([]); const [compensationEnum, setCompensationEnum] = useState([]); + const [salaryCompositionEnum, setSalaryCompositionEnum] = useState([]); + const [welfareBenefitsEnum, setWelfareBenefitsEnum] = useState([]); + const [workScheduleEnum, setWorkScheduleEnum] = useState([]); const [currentRow, setCurrentRow] = useState(); const [modalVisible, setModalVisible] = useState(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} > ); diff --git a/src/services/company/list.ts b/src/services/company/list.ts index f8572cf..3e38b30 100644 --- a/src/services/company/list.ts +++ b/src/services/company/list.ts @@ -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/cms/company/current', { + method: 'PUT', + data: params, + }); +} diff --git a/src/types/Management/list.d.ts b/src/types/Management/list.d.ts index b21b59f..7715495 100644 --- a/src/types/Management/list.d.ts +++ b/src/types/Management/list.d.ts @@ -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 { diff --git a/src/types/company/list.d.ts b/src/types/company/list.d.ts index 691575b..3d2a274 100644 --- a/src/types/company/list.d.ts +++ b/src/types/company/list.d.ts @@ -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; diff --git a/src/utils/jobDetailTags.ts b/src/utils/jobDetailTags.ts new file mode 100644 index 0000000..4d2d078 --- /dev/null +++ b/src/utils/jobDetailTags.ts @@ -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(','); +}; diff --git a/tests/utils/jobDetailTags.test.ts b/tests/utils/jobDetailTags.test.ts new file mode 100644 index 0000000..0e801b2 --- /dev/null +++ b/tests/utils/jobDetailTags.test.ts @@ -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(''); + }); +}); From 4aa30fc59dd1a0c3919de6afd356bef95cfb31cd Mon Sep 17 00:00:00 2001 From: lapuda <577732344@qq.com> Date: Mon, 20 Jul 2026 12:08:21 +0800 Subject: [PATCH 02/13] fix: correct component naming in routes and remove unused icon in JobListPage --- config/routes.ts | 6 +++--- src/pages/JobPortal/List/index.tsx | 4 ---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/config/routes.ts b/config/routes.ts index 1ec0c8a..565eb4b 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -242,18 +242,18 @@ export default [ { name: '公共招聘会列表', path: '/jobfair/public-job-fair', - component: './Jobfair/Publicjobfair', + component: './Jobfair/PublicJobFair', }, { name: '招聘会详情', path: '/jobfair/public-job-fair/detail', - component: './Jobfair/Publicjobfair/Detail', + component: './Jobfair/PublicJobFair/Detail', access: 'canViewPublicJobFairDetail', }, { name: '报名人员', path: '/jobfair/public-job-fair/signups', - component: './Jobfair/Publicjobfair/Signups', + component: './Jobfair/PublicJobFair/Signups', }, { name: '户外招聘会管理', diff --git a/src/pages/JobPortal/List/index.tsx b/src/pages/JobPortal/List/index.tsx index bf5c156..6c3630c 100644 --- a/src/pages/JobPortal/List/index.tsx +++ b/src/pages/JobPortal/List/index.tsx @@ -18,7 +18,6 @@ import { EnvironmentOutlined, HeartOutlined, HeartFilled, - QrcodeOutlined, FlagOutlined, ArrowLeftOutlined, SearchOutlined, @@ -954,9 +953,6 @@ const JobListPage: React.FC = () => { 投诉 - - -
From f2a81d88678515e740b8f1b525b39d951893b74b Mon Sep 17 00:00:00 2001 From: lapuda <577732344@qq.com> Date: Mon, 20 Jul 2026 15:43:50 +0800 Subject: [PATCH 03/13] feat: Implement News Info management features including CRUD operations and rich text editor --- scripts/deploy-production-frontend.sh | 135 ++++++++++ src/pages/NewsInfo/components/DetailModal.tsx | 54 ++++ src/pages/NewsInfo/components/EditModal.tsx | 111 ++++++++ .../NewsInfo/components/RichTextEditor.less | 35 +++ .../NewsInfo/components/RichTextEditor.tsx | 229 +++++++++++++++++ src/pages/NewsInfo/index.tsx | 243 ++++++++++++++++++ src/services/cms/newsInfo.ts | 68 +++++ src/services/session.ts | 11 +- src/types/news-info.d.ts | 54 ++++ src/utils/newsImageUrl.ts | 65 +++++ tests/utils/newsImageUrl.test.ts | 50 ++++ 11 files changed, 1054 insertions(+), 1 deletion(-) create mode 100755 scripts/deploy-production-frontend.sh create mode 100644 src/pages/NewsInfo/components/DetailModal.tsx create mode 100644 src/pages/NewsInfo/components/EditModal.tsx create mode 100644 src/pages/NewsInfo/components/RichTextEditor.less create mode 100644 src/pages/NewsInfo/components/RichTextEditor.tsx create mode 100644 src/pages/NewsInfo/index.tsx create mode 100644 src/services/cms/newsInfo.ts create mode 100644 src/types/news-info.d.ts create mode 100644 src/utils/newsImageUrl.ts create mode 100644 tests/utils/newsImageUrl.test.ts diff --git a/scripts/deploy-production-frontend.sh b/scripts/deploy-production-frontend.sh new file mode 100755 index 0000000..8160f7b --- /dev/null +++ b/scripts/deploy-production-frontend.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +# 正式环境部署参数固定在脚本中,避免通过环境变量误传到测试或其他服务器。 +readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly BUILD_DIR="${ROOT_DIR}/shihezi" +readonly DEPLOY_HOST="47.111.103.66" +readonly DEPLOY_USER="root" +readonly DEPLOY_SSH_PORT="22" +readonly DEPLOY_PATH="/opt/service/project/shz-admin/dist" + +DRY_RUN=0 + +info() { + printf '\033[1;32m%s\033[0m\n' "$*" +} + +error() { + printf '\033[1;31m%s\033[0m\n' "$*" >&2 +} + +die() { + error "$*" + exit 1 +} + +usage() { + cat <<'EOF' +用法: + ./scripts/deploy-production-frontend.sh + ./scripts/deploy-production-frontend.sh --dry-run + +功能: + 1. 使用 npm run build 构建当前 PC 前端 + 2. 将 shihezi/ 上传到正式服务器的 Nginx 静态目录 + 3. 校验远程 index.html,并比对本地与远程 SHA-256 + +正式环境参数(固定): + SSH 主机:root@47.111.103.66:22 + 静态目录:/opt/service/project/shz-admin/dist + 访问前缀:/shihezi/ + +参数: + --dry-run 只构建并检查产物,不连接服务器、不上传文件 + -h, --help 显示帮助 + +说明: + 脚本不会自动安装依赖。若 node_modules 不存在,请先执行 npm install。 + 上传使用 rsync --delete,远程静态目录中本地没有的文件会被删除。 + 脚本使用当前用户已有的 SSH key/agent,不保存密码。 +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "未知参数:$1(使用 --help 查看用法)" + ;; + esac + shift + done +} + +validate_local_environment() { + command -v npm >/dev/null 2>&1 || die "未找到 npm" + command -v ssh >/dev/null 2>&1 || die "未找到 ssh" + command -v rsync >/dev/null 2>&1 || die "未找到 rsync" + command -v shasum >/dev/null 2>&1 || die "未找到 shasum,无法校验构建产物" + + [[ -f "${ROOT_DIR}/package.json" ]] || die "未找到 ${ROOT_DIR}/package.json" + [[ -d "${ROOT_DIR}/node_modules" ]] || die "未找到 node_modules,请先执行 npm install" +} + +build_frontend() { + info "开始构建正式前端:npm run build" + npm run build + [[ -s "${BUILD_DIR}/index.html" ]] || die "构建完成但未找到 ${BUILD_DIR}/index.html" + info "本地构建完成:${BUILD_DIR}" +} + +check_production_access() { + local target="${DEPLOY_USER}@${DEPLOY_HOST}" + info "检查正式服务器目录:${target}:${DEPLOY_PATH}/" + ssh -o BatchMode=yes -o ConnectTimeout=10 -p "${DEPLOY_SSH_PORT}" "${target}" \ + "test -d '${DEPLOY_PATH}' && test -w '${DEPLOY_PATH}'" +} + +upload_frontend() { + local target="${DEPLOY_USER}@${DEPLOY_HOST}" + local ssh_command="ssh -o BatchMode=yes -o ConnectTimeout=10 -p ${DEPLOY_SSH_PORT}" + + if [[ "${DRY_RUN}" == "1" ]]; then + info "DRY RUN:不会连接服务器或上传文件" + info "目标:${target}:${DEPLOY_PATH}/" + return + fi + + check_production_access + + info "上传构建产物:${BUILD_DIR}/ -> ${target}:${DEPLOY_PATH}/" + rsync -az --delete --delay-updates \ + -e "${ssh_command}" \ + "${BUILD_DIR}/" "${target}:${DEPLOY_PATH}/" + + local local_hash + local remote_hash + local_hash="$(shasum -a 256 "${BUILD_DIR}/index.html" | awk '{print $1}')" + remote_hash="$(ssh -o BatchMode=yes -o ConnectTimeout=10 -p "${DEPLOY_SSH_PORT}" "${target}" \ + "test -s '${DEPLOY_PATH}/index.html' && sha256sum '${DEPLOY_PATH}/index.html' | awk '{print \$1}'")" + + [[ -n "${remote_hash}" ]] || die "远程 index.html 不存在或为空" + [[ "${local_hash}" == "${remote_hash}" ]] || die "远程 index.html 校验失败:本地 ${local_hash},远程 ${remote_hash}" + + info "正式前端部署完成:http://${DEPLOY_HOST}/shihezi/" + info "index.html SHA-256 校验通过:${local_hash}" +} + +main() { + parse_args "$@" + cd "${ROOT_DIR}" + validate_local_environment + build_frontend + upload_frontend +} + +main "$@" diff --git a/src/pages/NewsInfo/components/DetailModal.tsx b/src/pages/NewsInfo/components/DetailModal.tsx new file mode 100644 index 0000000..1d2f196 --- /dev/null +++ b/src/pages/NewsInfo/components/DetailModal.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Descriptions, Modal, Tag, Typography } from 'antd'; +import DictTag from '@/components/DictTag'; +import type { DictValueEnumObj } from '@/components/DictTag'; +import { normalizeNewsContent } from '@/utils/newsImageUrl'; + +interface DetailModalProps { + open: boolean; + data?: API.NewsInfo.NewsInfoItem; + moduleEnum: DictValueEnumObj; + onCancel: () => void; +} + +const DetailModal: React.FC = ({ open, data, moduleEnum, onCancel }) => ( + + + + {data?.module ? : '-'} + + + + {data?.status === '0' ? '上架' : '下架'} + + + + {data?.title || '-'} + + {data?.createTime || '-'} + {data?.updateTime || '-'} + + {data?.content ? ( + + ) : ( + '-' + )} + + + {data?.remark || '-'} + + + +); + +export default DetailModal; diff --git a/src/pages/NewsInfo/components/EditModal.tsx b/src/pages/NewsInfo/components/EditModal.tsx new file mode 100644 index 0000000..db58c77 --- /dev/null +++ b/src/pages/NewsInfo/components/EditModal.tsx @@ -0,0 +1,111 @@ +import React, { useEffect } from 'react'; +import { useAccess } from '@umijs/max'; +import { Col, Form, Input, Modal, Row, Select, Switch } from 'antd'; +import type { DictValueEnumObj } from '@/components/DictTag'; +import { normalizeNewsContent } from '@/utils/newsImageUrl'; +import RichTextEditor from './RichTextEditor'; + +interface EditModalProps { + open: boolean; + values?: API.NewsInfo.NewsInfoItem; + moduleEnum: DictValueEnumObj; + onCancel: () => void; + onSubmit: (values: API.NewsInfo.NewsInfoPayload) => Promise | void; +} + +const EditModal: React.FC = ({ open, values, moduleEnum, onCancel, onSubmit }) => { + const [form] = Form.useForm(); + const access = useAccess(); + const canChangeStatus = access.hasPerms('cms:newsInfo:status'); + + useEffect(() => { + if (!open) return; + form.setFieldsValue({ + module: values?.module, + title: values?.title, + content: normalizeNewsContent(values?.content || ''), + remark: values?.remark, + published: values?.status === '0', + }); + }, [form, open, values]); + + const handleOk = async () => { + const formValues = await form.validateFields(); + await onSubmit({ + id: values?.id, + module: formValues.module, + title: formValues.title, + content: normalizeNewsContent(formValues.content), + remark: formValues.remark, + status: canChangeStatus ? (formValues.published ? '0' : '1') : values?.status || '1', + }); + }; + + const moduleOptions = Object.values(moduleEnum).map((item) => ({ + label: item.label, + value: String(item.value), + })); + + return ( + +
+ + + + + + + + + + + + {canChangeStatus && ( + + + + + + )} + + + + + + +
+
+ ); +}; + +export default EditModal; diff --git a/src/pages/NewsInfo/components/RichTextEditor.less b/src/pages/NewsInfo/components/RichTextEditor.less new file mode 100644 index 0000000..c07ae5b --- /dev/null +++ b/src/pages/NewsInfo/components/RichTextEditor.less @@ -0,0 +1,35 @@ +.news-rich-editor { + border: 1px solid #d9d9d9; + border-radius: 6px; + overflow: hidden; + background: #fff; + + &__toolbar { + padding: 6px 8px; + border-bottom: 1px solid #f0f0f0; + background: #fafafa; + } + + &__content { + min-height: 280px; + padding: 12px; + overflow-y: auto; + line-height: 1.75; + outline: none; + + &:focus { + box-shadow: inset 0 0 0 1px #1677ff; + } + + img { + max-width: 100%; + } + + blockquote { + margin: 8px 0; + padding: 4px 12px; + border-left: 4px solid #d9d9d9; + color: #666; + } + } +} diff --git a/src/pages/NewsInfo/components/RichTextEditor.tsx b/src/pages/NewsInfo/components/RichTextEditor.tsx new file mode 100644 index 0000000..e4475f3 --- /dev/null +++ b/src/pages/NewsInfo/components/RichTextEditor.tsx @@ -0,0 +1,229 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Button, Input, Modal, Select, Space, Tooltip, message } from 'antd'; +import { + BoldOutlined, + CodeOutlined, + FontSizeOutlined, + ItalicOutlined, + LinkOutlined, + OrderedListOutlined, + PictureOutlined, + StrikethroughOutlined, + UnderlineOutlined, + UnorderedListOutlined, +} from '@ant-design/icons'; +import { uploadNewsImage } from '@/services/cms/newsInfo'; +import { normalizeNewsContent, normalizeNewsImageUrl } from '@/utils/newsImageUrl'; +import './RichTextEditor.less'; + +interface RichTextEditorProps { + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; +} + +const RichTextEditor: React.FC = ({ value = '', onChange, disabled }) => { + const editorRef = useRef(null); + const fileInputRef = useRef(null); + const lastValueRef = useRef(value); + const [uploading, setUploading] = useState(false); + const [linkModalOpen, setLinkModalOpen] = useState(false); + const [linkUrl, setLinkUrl] = useState(''); + + useEffect(() => { + const normalizedValue = normalizeNewsContent(value); + if ( + editorRef.current && + value !== lastValueRef.current && + normalizedValue !== editorRef.current.innerHTML + ) { + editorRef.current.innerHTML = normalizedValue; + } + lastValueRef.current = normalizedValue; + }, [value]); + + const emitChange = () => { + const html = editorRef.current?.innerHTML || ''; + lastValueRef.current = html; + onChange?.(html); + }; + + const exec = (command: string, commandValue?: string) => { + if (disabled || !editorRef.current) return; + editorRef.current.focus(); + document.execCommand(command, false, commandValue); + emitChange(); + }; + + const insertLink = () => { + const url = linkUrl.trim(); + if (!url) { + message.warning('请输入链接地址'); + return; + } + exec('createLink', url); + setLinkUrl(''); + setLinkModalOpen(false); + }; + + const handleImageSelected = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + if (!file.type.startsWith('image/')) { + message.error('只支持上传图片'); + return; + } + setUploading(true); + try { + const result = await uploadNewsImage(file); + const url = normalizeNewsImageUrl( + result.url || result.data?.url, + result.fileName || result.data?.fileName, + ); + if (result.code !== 200 || !url) { + throw new Error(result.msg || '图片上传失败'); + } + exec('insertImage', url); + } catch (error) { + message.error(error instanceof Error ? error.message : '图片上传失败'); + } finally { + setUploading(false); + } + }; + + return ( +
+
+ + +
+
+ setLinkModalOpen(false)} + onOk={insertLink} + destroyOnClose + > + setLinkUrl(event.target.value)} + onPressEnter={insertLink} + /> + +
+ ); +}; + +export default RichTextEditor; diff --git a/src/pages/NewsInfo/index.tsx b/src/pages/NewsInfo/index.tsx new file mode 100644 index 0000000..8555e22 --- /dev/null +++ b/src/pages/NewsInfo/index.tsx @@ -0,0 +1,243 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useAccess } from '@umijs/max'; +import { Button, message, Modal, Switch, Tag } from 'antd'; +import { ActionType, PageContainer, ProColumns, ProTable } from '@ant-design/pro-components'; +import { DeleteOutlined, EyeOutlined, FormOutlined, PlusOutlined } from '@ant-design/icons'; +import { + addNewsInfo, + changeNewsInfoStatus, + deleteNewsInfo, + getNewsInfoDetail, + getNewsInfoList, + updateNewsInfo, +} from '@/services/cms/newsInfo'; +import { getDictValueEnum } from '@/services/system/dict'; +import DictTag, { DictValueEnumObj } from '@/components/DictTag'; +import EditModal from './components/EditModal'; +import DetailModal from './components/DetailModal'; + +const NewsInfoPage: React.FC = () => { + const access = useAccess(); + const actionRef = useRef(); + const [moduleEnum, setModuleEnum] = useState({}); + const [editOpen, setEditOpen] = useState(false); + const [detailOpen, setDetailOpen] = useState(false); + const [currentRow, setCurrentRow] = useState(); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + const loadModules = async () => { + setModuleEnum(await getDictValueEnum('news_module', false, true)); + }; + + useEffect(() => { + loadModules(); + }, []); + + const handleDelete = (ids: string) => { + Modal.confirm({ + title: '确认删除', + content: '删除后新闻资讯将不再出现在列表和公开接口中,确定继续吗?', + onOk: async () => { + const result = await deleteNewsInfo(ids); + if (result.code === 200) { + message.success('删除成功'); + setSelectedRowKeys([]); + actionRef.current?.reload(); + } else { + message.error(result.msg || '删除失败'); + } + }, + }); + }; + + const handleStatus = (record: API.NewsInfo.NewsInfoItem, checked: boolean) => { + const status: '0' | '1' = checked ? '0' : '1'; + const label = checked ? '上架' : '下架'; + Modal.confirm({ + title: `确认${label}`, + content: `确定要${label}“${record.title}”吗?`, + onOk: async () => { + const result = await changeNewsInfoStatus(record.id, status); + if (result.code === 200) { + message.success(`${label}成功`); + actionRef.current?.reload(); + } else { + message.error(result.msg || `${label}失败`); + } + }, + }); + }; + + const columns: ProColumns[] = [ + { + title: '标题', + dataIndex: 'title', + ellipsis: true, + formItemProps: { rules: [{ max: 200 }] }, + }, + { + title: '资讯模块', + dataIndex: 'module', + valueType: 'select', + valueEnum: moduleEnum, + render: (_, record) => , + }, + { + title: '上下架', + dataIndex: 'status', + valueType: 'select', + width: 110, + valueEnum: { + '0': { text: '上架', status: 'Success' }, + '1': { text: '下架', status: 'Default' }, + }, + render: (_, record) => + access.hasPerms('cms:newsInfo:status') ? ( + handleStatus(record, checked)} + /> + ) : ( + + {record.status === '0' ? '上架' : '下架'} + + ), + }, + { title: '创建时间', dataIndex: 'createTime', hideInSearch: true, width: 170 }, + { + title: '操作', + valueType: 'option', + fixed: 'right', + width: 220, + render: (_, record) => [ + , + , + , + ], + }, + ]; + + return ( + + + headerTitle="新闻资讯列表" + actionRef={actionRef} + rowKey="id" + columns={columns} + scroll={{ x: 'max-content' }} + rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }} + request={async (params) => { + const result = await getNewsInfoList({ + pageNum: params.current, + pageSize: params.pageSize, + title: params.title, + module: params.module, + status: params.status, + }); + return { + data: result.rows || [], + total: result.total || 0, + success: result.code === 200, + }; + }} + toolBarRender={() => [ + , + selectedRowKeys.length > 0 && ( + + ), + ]} + /> + { + setEditOpen(false); + setCurrentRow(undefined); + }} + onSubmit={async (values) => { + const result = values.id ? await updateNewsInfo(values) : await addNewsInfo(values); + if (result.code !== 200) { + message.error(result.msg || '保存失败'); + return; + } + message.success(values.id ? '修改成功' : '新增成功'); + setEditOpen(false); + setCurrentRow(undefined); + actionRef.current?.reload(); + }} + /> + { + setDetailOpen(false); + setCurrentRow(undefined); + }} + /> + + ); +}; + +export default NewsInfoPage; diff --git a/src/services/cms/newsInfo.ts b/src/services/cms/newsInfo.ts new file mode 100644 index 0000000..ac6f99f --- /dev/null +++ b/src/services/cms/newsInfo.ts @@ -0,0 +1,68 @@ +import { request } from '@umijs/max'; +import { normalizeNewsContent } from '@/utils/newsImageUrl'; + +const BASE_URL = '/api/cms/newsInfo'; + +export async function getNewsInfoList(params?: API.NewsInfo.NewsInfoListParams) { + return request(`${BASE_URL}/list`, { + method: 'GET', + params, + }); +} + +export async function getNewsInfoDetail(id: number) { + return request(`${BASE_URL}/${id}`, { + method: 'GET', + }); +} + +export async function addNewsInfo(data: API.NewsInfo.NewsInfoPayload) { + return request(BASE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json;charset=UTF-8' }, + data: { ...data, content: normalizeNewsContent(data.content) }, + }); +} + +export async function updateNewsInfo(data: API.NewsInfo.NewsInfoPayload) { + return request(BASE_URL, { + method: 'PUT', + headers: { 'Content-Type': 'application/json;charset=UTF-8' }, + data: { ...data, content: normalizeNewsContent(data.content) }, + }); +} + +export async function deleteNewsInfo(ids: string) { + return request(`${BASE_URL}/${ids}`, { + method: 'DELETE', + }); +} + +export async function changeNewsInfoStatus(id: number, status: '0' | '1') { + return request(`${BASE_URL}/changeStatus`, { + method: 'PUT', + params: { id, status }, + }); +} + +export async function uploadNewsImage(file: File) { + const formData = new FormData(); + formData.append('file', file); + return request('/api/common/upload', { + method: 'POST', + data: formData, + }); +} + +export async function getPublishedNewsInfoList(params?: API.NewsInfo.NewsInfoListParams) { + return request('/api/app/newsInfo/list', { + method: 'GET', + params, + }); +} + +export async function getPublishedNewsInfoDetail(id: number) { + return request(`/api/app/newsInfo/${id}`, { + method: 'GET', + }); +} diff --git a/src/services/session.ts b/src/services/session.ts index 8b4e0d3..256d15d 100644 --- a/src/services/session.ts +++ b/src/services/session.ts @@ -6,6 +6,11 @@ import React, { lazy } from 'react'; let remoteMenu: any = null; +// Remote CMS menus are returned by the backend at runtime. Keep newly added +// pages with an explicit import so production webpack builds cannot tree-shake +// them out of the dynamic import context before the remote menu is available. +const newsInfoRemoteComponent = lazy(() => import('@/pages/NewsInfo/index')); + export function getRemoteMenu() { return remoteMenu; } @@ -60,7 +65,11 @@ function patchRouteItems(route: any, menu: any, parentPath: string) { route.children = []; } const newRoute = { - element: React.createElement(lazy(() => import('@/pages/' + path))), + element: React.createElement( + path === 'NewsInfo/index.tsx' + ? newsInfoRemoteComponent + : lazy(() => import('@/pages/' + path)), + ), path: parentPath + menuItem.path, } route.children.push(newRoute); diff --git a/src/types/news-info.d.ts b/src/types/news-info.d.ts new file mode 100644 index 0000000..06f7313 --- /dev/null +++ b/src/types/news-info.d.ts @@ -0,0 +1,54 @@ +declare namespace API.NewsInfo { + export interface NewsInfoItem { + id: number; + module: string; + moduleName?: string; + title: string; + content?: string; + status: '0' | '1'; + createBy?: string; + createTime?: string; + updateBy?: string; + updateTime?: string; + remark?: string; + } + + export interface NewsInfoPayload { + id?: number; + module: string; + title: string; + content: string; + status?: '0' | '1'; + remark?: string; + } + + export interface NewsInfoListParams { + pageNum?: number; + pageSize?: number; + title?: string; + keyword?: string; + module?: string; + status?: '0' | '1'; + } + + export interface NewsInfoPageResult { + code: number; + msg?: string; + total: number; + rows: NewsInfoItem[]; + } + + export interface NewsInfoDetailResult { + code: number; + msg?: string; + data: NewsInfoItem; + } + + export interface NewsInfoUploadResult { + code: number; + msg?: string; + url?: string; + fileName?: string; + data?: { url?: string; fileName?: string }; + } +} diff --git a/src/utils/newsImageUrl.ts b/src/utils/newsImageUrl.ts new file mode 100644 index 0000000..96274c0 --- /dev/null +++ b/src/utils/newsImageUrl.ts @@ -0,0 +1,65 @@ +function getPublicApiPrefix(): string { + // 开发服务器的 /api 代理会自动补上 /api/shihezi;生产 Nginx 则需要完整前缀。 + return process.env.NODE_ENV === 'development' ? '/api' : '/api/shihezi'; +} + +function getBrowserOrigin(): string { + return typeof window === 'undefined' ? '' : window.location.origin; +} + +function isProfileResource(pathname: string): boolean { + return /^\/(?:api\/(?:shihezi\/)?)?profile(?:\/|$)/i.test(pathname); +} + +/** + * 将通用上传接口返回的本地资源地址转换为浏览器可访问的公开地址。 + * + * 生产反向代理把后端暴露在 /api/shihezi/ 下,而后端默认返回的地址可能 + * 带有 127.0.0.1:9091 或直接使用 /profile 前缀。图片必须走公开代理地址, + * 否则 CMS 浏览器会把服务器内网地址当成图片地址并显示破图。 + */ +export function normalizeNewsImageUrl( + fileUrl?: string, + fileName?: string, + currentOrigin = getBrowserOrigin(), +): string { + const value = (fileUrl || fileName || '').trim(); + if (!value) { + return ''; + } + + let parsed: URL; + try { + parsed = new URL(value, currentOrigin || 'http://localhost'); + } catch { + return value; + } + + if (!isProfileResource(parsed.pathname) || !currentOrigin) { + return value; + } + + const profileIndex = parsed.pathname.toLowerCase().indexOf('/profile'); + const resourcePath = parsed.pathname.slice(profileIndex); + return `${currentOrigin.replace(/\/+$/, '')}${getPublicApiPrefix()}${resourcePath}${ + parsed.search + }${parsed.hash}`; +} + +/** + * 修复历史正文中由后端返回的 127.0.0.1/profile 图片地址。 + * 只改写 img[src],不改变正文的其他 HTML 结构。 + */ +export function normalizeNewsContent(content?: string, currentOrigin = getBrowserOrigin()): string { + if (!content) { + return content || ''; + } + + return content.replace( + /(]*\bsrc\s*=\s*)(["'])(.*?)\2/gi, + (match, prefix: string, quote: string, source: string) => { + const normalized = normalizeNewsImageUrl(source, undefined, currentOrigin); + return `${prefix}${quote}${normalized || source}${quote}`; + }, + ); +} diff --git a/tests/utils/newsImageUrl.test.ts b/tests/utils/newsImageUrl.test.ts new file mode 100644 index 0000000..801c4f8 --- /dev/null +++ b/tests/utils/newsImageUrl.test.ts @@ -0,0 +1,50 @@ +import { normalizeNewsContent, normalizeNewsImageUrl } from '@/utils/newsImageUrl'; + +describe('news image URL normalization', () => { + const origin = 'http://47.111.103.66'; + + it('maps the backend internal host to the public API resource path', () => { + expect( + normalizeNewsImageUrl( + 'http://127.0.0.1:9091/profile/upload/2026/07/20/news.png', + undefined, + origin, + ), + ).toBe('http://47.111.103.66/api/shihezi/profile/upload/2026/07/20/news.png'); + }); + + it('supports the fileName returned by the upload endpoint', () => { + expect(normalizeNewsImageUrl(undefined, 'profile/upload/2026/07/20/news.png', origin)).toBe( + 'http://47.111.103.66/api/shihezi/profile/upload/2026/07/20/news.png', + ); + }); + + it('normalizes existing API-prefixed URLs to an absolute browser URL', () => { + expect(normalizeNewsImageUrl('/api/shihezi/profile/upload/news.png', undefined, origin)).toBe( + 'http://47.111.103.66/api/shihezi/profile/upload/news.png', + ); + }); + + it('uses the development proxy prefix in development builds', () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + try { + expect( + normalizeNewsImageUrl('/profile/upload/news.png', undefined, 'http://localhost:8000'), + ).toBe('http://localhost:8000/api/profile/upload/news.png'); + } finally { + process.env.NODE_ENV = originalNodeEnv; + } + }); + + it('rewrites only image sources in existing rich text', () => { + expect( + normalizeNewsContent( + '

正文

', + origin, + ), + ).toBe( + '

正文

', + ); + }); +}); From b8965fd24136ca874e6ae80753a0febbdba79db6 Mon Sep 17 00:00:00 2001 From: lapuda <577732344@qq.com> Date: Tue, 21 Jul 2026 14:18:58 +0800 Subject: [PATCH 04/13] feat: add job fair region type functionality and improve job fair detail pages - Introduced job fair region type selection in EditModal and PublicJobFairList components. - Enhanced JobFairDetail page to display job fair region type using DictTag. - Updated API types to include jobFairRegionType for better data handling. - Created new JobPortal pages for displaying job fair details with improved styling. - Added CSS styles for job fair detail and list pages to enhance user experience. - Implemented loading states and error handling for job fair data fetching. --- config/proxy.ts | 2 + config/routes.ts | 8 + src/components/JobPortalHeader/index.tsx | 10 + src/pages/JobPortal/JobFair/Detail/index.less | 62 +++ src/pages/JobPortal/JobFair/Detail/index.tsx | 182 +++++++++ src/pages/JobPortal/JobFair/index.less | 333 +++++++++++++++ src/pages/JobPortal/JobFair/index.tsx | 380 ++++++++++++++++++ .../Jobfair/PublicJobFair/Detail/index.tsx | 14 + .../PublicJobFair/components/EditModal.tsx | 8 + src/pages/Jobfair/PublicJobFair/index.tsx | 24 ++ src/pages/Management/List/edit.tsx | 53 +-- src/services/jobportal/jobFair.ts | 174 ++++++++ src/types/jobfair/publicJobFair.d.ts | 4 + 13 files changed, 1230 insertions(+), 24 deletions(-) create mode 100644 src/pages/JobPortal/JobFair/Detail/index.less create mode 100644 src/pages/JobPortal/JobFair/Detail/index.tsx create mode 100644 src/pages/JobPortal/JobFair/index.less create mode 100644 src/pages/JobPortal/JobFair/index.tsx create mode 100644 src/services/jobportal/jobFair.ts diff --git a/config/proxy.ts b/config/proxy.ts index b0897be..730ada7 100644 --- a/config/proxy.ts +++ b/config/proxy.ts @@ -43,6 +43,8 @@ export default { // 配置了这个可以从 http 代理到 https // 依赖 origin 的功能可能需要这个,比如 cookie changeOrigin: true, + // 测试环境目前使用 IP 访问 HTTPS,证书域名不匹配;仅影响本地开发代理。 + secure: false, }, }, diff --git a/config/routes.ts b/config/routes.ts index 565eb4b..35ccb17 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -48,6 +48,14 @@ export default [ path: '/job-portal/list', // 职位列表 component: './JobPortal/List', }, + { + path: '/job-portal/job-fair', // 招聘会(线上、户外) + component: './JobPortal/JobFair', + }, + { + path: '/job-portal/job-fair/detail', // 招聘会详情 + component: './JobPortal/JobFair/Detail', + }, { path: '/job-portal/detail', // 职位详情 component: './JobPortal/Detail', diff --git a/src/components/JobPortalHeader/index.tsx b/src/components/JobPortalHeader/index.tsx index deea569..983a39f 100644 --- a/src/components/JobPortalHeader/index.tsx +++ b/src/components/JobPortalHeader/index.tsx @@ -14,6 +14,7 @@ import { FileTextOutlined, HomeOutlined, BellOutlined, + CalendarOutlined, ReadOutlined, LoginOutlined, FundProjectionScreenOutlined, @@ -102,6 +103,7 @@ const JobPortalHeader: React.FC = ({ // 判断激活导航(简化为 startsWith 匹配) const isHome = /^\/job-portal(\/(list|detail))?$/.test(location.pathname); + const isJobFair = location.pathname.startsWith('/job-portal/job-fair'); const isResume = location.pathname.startsWith('/job-portal/resume'); const isMine = location.pathname.startsWith('/job-portal/personal-center') || location.pathname.startsWith('/job-portal/profile'); const isMessage = location.pathname.startsWith('/job-portal/message'); @@ -318,6 +320,14 @@ const JobPortalHeader: React.FC = ({ > 找工作 + + + {detail ? ( + +
+
+ + {channel === 'outdoor' ? '户外招聘会' : '线上招聘会'} + + {detail.jobFairTitle} +
+
+ + + 举办时间 + + } + > + {formatDateTime(detail.jobFairStartTime)} 至{' '} + {formatDateTime(detail.jobFairEndTime)} + + + 举办地点 + + } + > + {detail.jobFairAddress || detail.jobFairVenueName || '暂未公布'} + + + 主办单位 + + } + > + {detail.jobFairHostUnit || detail.jobFairOrganizeUnit || '暂未公布'} + + {detail.jobFairRegion && ( + + 举办区域 + + } + > + {detail.jobFairRegion} + + )} + {detail.boothNum && ( + {detail.boothNum} + )} + {detail.jobFairPhone && ( + + 联系电话 + + } + > + {detail.jobFairPhone} + + )} + +
+ 招聘会简介 + {detail.jobFairIntroduction || '暂未提供招聘会简介。'} +
+
+ ) : ( + !loading && ( + + + + ) + )} +
+ +
+ ); +}; + +export default PortalJobFairDetailPage; diff --git a/src/pages/JobPortal/JobFair/index.less b/src/pages/JobPortal/JobFair/index.less new file mode 100644 index 0000000..95e27dc --- /dev/null +++ b/src/pages/JobPortal/JobFair/index.less @@ -0,0 +1,333 @@ +@import '../theme.less'; + +.portal-job-fair-page { + min-height: 100vh; + + .job-fair-content { + .jp-page-container(); + padding-top: 28px; + padding-bottom: 56px; + } + + .job-fair-hero { + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 128px; + padding: 24px 38px; + border-radius: @jp-radius-lg; + color: #fff; + background: linear-gradient(120deg, #0d6efd, #21a3ee); + box-shadow: 0 10px 28px rgba(24, 144, 255, 0.22); + + .ant-typography { + color: #fff; + margin-bottom: 6px; + } + + .job-fair-hero-icon { + opacity: 0.18; + font-size: 90px; + } + } + + .job-fair-panel, + .fair-calendar-card, + .fair-summary-card, + .fair-card { + .jp-card-base(); + } + + .job-fair-panel { + margin-top: 22px; + padding: 0 24px 20px; + + .job-fair-tabs { + .ant-tabs-nav { + margin-bottom: 16px; + } + + .ant-tabs-tab { + padding: 16px 8px; + font-size: 16px; + } + } + } + + .job-fair-toolbar { + display: flex; + align-items: center; + gap: 12px; + + .ant-input-search { + max-width: 440px; + } + + .ant-tag { + margin: 0; + padding: 5px 9px; + border-radius: 5px; + } + } + + .job-fair-supporting { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; + margin-top: 20px; + + .fair-calendar-card { + height: 100%; + min-height: 0; + + .ant-card-head, + .ant-card-body { + border: 0; + } + + .ant-picker-calendar { + background: transparent; + } + + .fair-calendar-date { + position: relative; + height: 24px; + margin: 0 3px; + border-radius: 4px; + color: @jp-text-primary; + line-height: 24px; + text-align: center; + } + + .ant-picker-cell-selected .fair-calendar-date { + color: #fff; + background: @jp-primary; + } + + .fair-calendar-marker { + position: absolute; + right: 4px; + bottom: 1px; + width: 5px; + height: 5px; + border-radius: 50%; + background: #fa8c16; + } + + .clear-date-button { + padding: 0; + } + } + } + + .fair-summary-grid { + display: grid; + grid-column: span 2; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + height: 100%; + + .fair-summary-card { + height: 100%; + min-height: 0; + + .ant-card-head { + border-bottom-color: @jp-border; + } + } + } + + .fair-summary-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .fair-summary-item { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + gap: 10px; + width: 100%; + padding: 10px; + border: 0; + border-radius: 6px; + color: @jp-text-primary; + text-align: left; + background: #f8fbff; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: @jp-primary-light; + } + + .fair-summary-date { + color: @jp-primary; + font-size: 12px; + line-height: 20px; + white-space: nowrap; + } + + .fair-summary-title { + overflow: hidden; + font-size: 14px; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .fair-summary-empty { + display: block; + padding-top: 24px; + text-align: center; + } + + .job-fair-list-section { + margin-top: 28px; + } + + .job-fair-list-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 16px; + + .ant-typography { + .jp-section-title(); + margin: 0; + } + } + + .fair-card { + height: 100%; + transition: + box-shadow 0.2s, + transform 0.2s; + + &:hover { + box-shadow: @jp-shadow-hover; + transform: translateY(-2px); + } + + .ant-card-body { + display: flex; + flex-direction: column; + height: 100%; + box-sizing: border-box; + padding: 20px; + } + } + + .fair-card-tags { + display: flex; + gap: 8px; + margin-bottom: 12px; + + .ant-tag { + margin: 0; + } + } + + .fair-card-title { + min-height: 48px; + margin-bottom: 14px !important; + color: @jp-text-primary; + line-height: 24px !important; + } + + .fair-card-meta { + display: flex; + flex: 1; + flex-direction: column; + gap: 9px; + min-height: 82px; + color: @jp-text-secondary; + font-size: 13px; + + > div { + display: flex; + align-items: flex-start; + line-height: 20px; + + .anticon { + flex: 0 0 auto; + margin: 3px 8px 0 0; + color: @jp-primary; + } + } + } + + .fair-card-introduction { + min-height: 42px; + margin: 16px 0 0 !important; + padding-top: 14px; + border-top: 1px solid @jp-border; + color: @jp-text-muted; + font-size: 13px; + line-height: 21px; + } + + .job-fair-pagination { + display: flex; + justify-content: flex-end; + margin-top: 28px; + } +} + +@media (max-width: 900px) { + .portal-job-fair-page { + .job-fair-supporting, + .fair-summary-grid { + grid-template-columns: 1fr; + } + + .fair-summary-grid { + grid-column: auto; + } + + .fair-summary-card { + min-height: auto !important; + } + } +} + +@media (max-width: 576px) { + .portal-job-fair-page { + .job-fair-content { + padding: 16px 12px 36px; + } + + .job-fair-hero { + min-height: 112px; + padding: 20px; + + .job-fair-hero-icon { + font-size: 62px; + } + } + + .job-fair-panel { + padding: 0 16px 16px; + } + + .job-fair-toolbar { + align-items: stretch; + flex-direction: column; + + .ant-input-search { + max-width: none; + } + } + + .fair-summary-grid { + gap: 12px; + } + + .job-fair-pagination { + justify-content: center; + } + } +} diff --git a/src/pages/JobPortal/JobFair/index.tsx b/src/pages/JobPortal/JobFair/index.tsx new file mode 100644 index 0000000..a781ca2 --- /dev/null +++ b/src/pages/JobPortal/JobFair/index.tsx @@ -0,0 +1,380 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + Button, + Calendar, + Card, + Col, + ConfigProvider, + Empty, + Input, + Pagination, + Row, + Spin, + Tabs, + Tag, + Typography, +} from 'antd'; +import { + CalendarOutlined, + ClockCircleOutlined, + EnvironmentOutlined, + SearchOutlined, + TeamOutlined, +} from '@ant-design/icons'; +import { history } from '@umijs/max'; +import dayjs, { Dayjs } from 'dayjs'; +import 'dayjs/locale/zh-cn'; +import zhCN from 'antd/locale/zh_CN'; +import JobPortalHeader from '@/components/JobPortalHeader'; +import { + getPortalCurrentMonthJobFairs, + getPortalCurrentQuarterJobFairs, + getPortalJobFairDates, + getPortalJobFairPage, + type JobFairChannel, + type PortalJobFairItem, +} from '@/services/jobportal/jobFair'; +import './index.less'; + +const { Title, Text, Paragraph } = Typography; + +const PAGE_SIZE = 9; + +const fairTabs: Array<{ key: JobFairChannel; label: string }> = [ + { key: 'online', label: '线上招聘会' }, + { key: 'outdoor', label: '户外招聘会' }, +]; + +function formatDateTime(value?: string) { + if (!value) { + return '时间待定'; + } + const date = dayjs(value.replace(' ', 'T')); + return date.isValid() ? date.format('YYYY年MM月DD日 HH:mm') : value; +} + +function formatSummaryDate(value?: string) { + if (!value) { + return '时间待定'; + } + const date = dayjs(value.replace(' ', 'T')); + return date.isValid() ? date.format('MM月DD日') : value.slice(0, 10); +} + +function getFairStatus(fair: PortalJobFairItem) { + const start = fair.jobFairStartTime ? dayjs(fair.jobFairStartTime.replace(' ', 'T')) : null; + const end = fair.jobFairEndTime ? dayjs(fair.jobFairEndTime.replace(' ', 'T')) : null; + const now = dayjs(); + + if (start?.isValid() && now.isBefore(start)) { + return { label: '待开始', color: 'gold' }; + } + if (end?.isValid() && now.isAfter(end)) { + return { label: '已结束', color: 'default' }; + } + if (start?.isValid() && end?.isValid()) { + return { label: '进行中', color: 'green' }; + } + return fair.jobFairStatus ? { label: fair.jobFairStatus, color: 'blue' } : null; +} + +const PortalJobFairPage: React.FC = () => { + const [channel, setChannel] = useState('online'); + const [searchInput, setSearchInput] = useState(''); + const [keyword, setKeyword] = useState(''); + const [selectedDate, setSelectedDate] = useState(); + const [calendarValue, setCalendarValue] = useState(() => dayjs().locale('zh-cn')); + const [fairDates, setFairDates] = useState>(new Set()); + const [fairs, setFairs] = useState([]); + const [monthFairs, setMonthFairs] = useState([]); + const [quarterFairs, setQuarterFairs] = useState([]); + const [pageNum, setPageNum] = useState(1); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [summaryLoading, setSummaryLoading] = useState(false); + + const loadFairs = useCallback(async () => { + setLoading(true); + try { + const response = await getPortalJobFairPage(channel, { + pageNum, + pageSize: PAGE_SIZE, + jobFairTitle: keyword || undefined, + zphjbsj: selectedDate, + }); + if (response?.code !== 200) { + throw new Error(response?.msg || '招聘会列表加载失败'); + } + setFairs(response.data?.list || []); + setTotal(response.data?.total || 0); + } catch (error) { + setFairs([]); + setTotal(0); + } finally { + setLoading(false); + } + }, [channel, keyword, pageNum, selectedDate]); + + useEffect(() => { + void loadFairs(); + }, [loadFairs]); + + useEffect(() => { + let cancelled = false; + + const loadSupportingData = async () => { + setSummaryLoading(true); + try { + const [datesResponse, monthResponse, quarterResponse] = await Promise.all([ + getPortalJobFairDates(channel), + getPortalCurrentMonthJobFairs(channel), + getPortalCurrentQuarterJobFairs(channel), + ]); + if (cancelled) { + return; + } + setFairDates(new Set(Array.isArray(datesResponse?.data) ? datesResponse.data : [])); + setMonthFairs(Array.isArray(monthResponse?.data) ? monthResponse.data : []); + setQuarterFairs(Array.isArray(quarterResponse?.data) ? quarterResponse.data : []); + } catch (error) { + if (!cancelled) { + setFairDates(new Set()); + setMonthFairs([]); + setQuarterFairs([]); + } + } finally { + if (!cancelled) { + setSummaryLoading(false); + } + } + }; + + void loadSupportingData(); + return () => { + cancelled = true; + }; + }, [channel]); + + const handleChannelChange = (nextChannel: string) => { + setChannel(nextChannel as JobFairChannel); + setPageNum(1); + setSelectedDate(undefined); + }; + + const handleSearch = (value?: string) => { + setKeyword((value ?? searchInput).trim()); + setPageNum(1); + }; + + const handleSelectDate = (value: Dayjs) => { + const localizedValue = value.locale('zh-cn'); + const nextDate = localizedValue.format('YYYY-MM-DD'); + setCalendarValue(localizedValue); + setSelectedDate(nextDate); + setPageNum(1); + }; + + const handleSummaryFairClick = (fair: PortalJobFairItem) => { + if (!fair.jobFairId) { + return; + } + history.push( + `/job-portal/job-fair/detail?channel=${channel}&jobFairId=${encodeURIComponent(fair.jobFairId)}`, + { fair }, + ); + }; + + const renderSummary = (title: string, fairsToRender: PortalJobFairItem[]) => ( + + + {fairsToRender.length ? ( +
+ {fairsToRender.slice(0, 3).map((fair) => ( + + ))} +
+ ) : ( + + 暂无招聘会安排 + + )} +
+
+ ); + + return ( +
+ + +
+
+
+ 招聘会 + 汇集线上与户外招聘会信息,选择日期即可查看当天的活动安排。 +
+
+ +
+ +
+ + 搜索 + + } + onChange={(event) => setSearchInput(event.target.value)} + onSearch={handleSearch} + placeholder="搜索招聘会名称" + value={searchInput} + /> + {selectedDate && ( + { + setSelectedDate(undefined); + setPageNum(1); + }} + > + 举办日期:{selectedDate} + + )} +
+
+ +
+ + + { + if (info.type !== 'date') { + return info.originNode; + } + const date = current.format('YYYY-MM-DD'); + return ( +
+ {current.date()} + {fairDates.has(date) && ( + + )} +
+ ); + }} + fullscreen={false} + onPanelChange={(value) => setCalendarValue(value.locale('zh-cn'))} + onSelect={handleSelectDate} + value={calendarValue} + /> +
+ +
+
+ {renderSummary(`本月${channel === 'outdoor' ? '户外' : ''}招聘会`, monthFairs)} + {renderSummary(`本季度${channel === 'outdoor' ? '户外' : ''}招聘会`, quarterFairs)} +
+
+ +
+
+ {channel === 'outdoor' ? '户外招聘会' : '线上招聘会'} + 共 {total} 场 +
+ + + {fairs.length ? ( + <> + + {fairs.map((fair) => { + const status = getFairStatus(fair); + return ( + + +
+ + {channel === 'outdoor' ? '户外招聘会' : '线上招聘会'} + + {status && {status.label}} +
+ + {fair.jobFairTitle} + +
+
+ + {formatDateTime(fair.jobFairStartTime)} +
+
+ + {fair.jobFairAddress || '地点待定'} +
+
+ + {fair.jobFairHostUnit || fair.jobFairOrganizeUnit || '主办单位待定'} +
+
+ + {fair.jobFairIntroduction || '暂未提供招聘会简介。'} + +
+ + ); + })} +
+ {total > PAGE_SIZE && ( +
+ setPageNum(nextPage)} + pageSize={PAGE_SIZE} + showSizeChanger={false} + showTotal={(count) => `共 ${count} 场招聘会`} + total={total} + /> +
+ )} + + ) : ( + !loading && + )} +
+
+
+
+ ); +}; + +export default PortalJobFairPage; diff --git a/src/pages/Jobfair/PublicJobFair/Detail/index.tsx b/src/pages/Jobfair/PublicJobFair/Detail/index.tsx index 3a10486..d717ca1 100644 --- a/src/pages/Jobfair/PublicJobFair/Detail/index.tsx +++ b/src/pages/Jobfair/PublicJobFair/Detail/index.tsx @@ -23,6 +23,8 @@ import { removeJobFromJobFair, reviewPublicJobFairCompany, } from '@/services/jobfair/publicJobFair'; +import { getDictValueEnum } from '@/services/system/dict'; +import DictTag from '@/components/DictTag'; import CompanySelectModal from '../components/CompanySelectModal'; import JobSelectModal from '../components/JobSelectModal'; @@ -51,6 +53,7 @@ const JobFairDetail: React.FC = () => { const [reviewModalOpen, setReviewModalOpen] = useState(false); const [reviewCompany, setReviewCompany] = useState(); const [reviewSubmitting, setReviewSubmitting] = useState(false); + const [jobFairRegionTypeEnum, setJobFairRegionTypeEnum] = useState({}); const [reviewForm] = Form.useForm(); const reviewStatus = Form.useWatch('reviewStatus', reviewForm); @@ -72,6 +75,10 @@ const JobFairDetail: React.FC = () => { } }, [jobFairId, isEnterprise]); + useEffect(() => { + getDictValueEnum('job_fair_region_type').then(setJobFairRegionTypeEnum); + }, []); + const handleRemoveCompany = (company: API.PublicJobFair.CompanyInfo) => { Modal.confirm({ title: '确认移除', @@ -174,6 +181,13 @@ const JobFairDetail: React.FC = () => { {jobFairTypeMap[detail?.jobFairType || '']} + + {detail?.jobFairRegionType ? ( + + ) : ( + '-' + )} + {detail?.jobFairAddress} {detail?.jobFairPhone} diff --git a/src/pages/Jobfair/PublicJobFair/components/EditModal.tsx b/src/pages/Jobfair/PublicJobFair/components/EditModal.tsx index dd39ea7..cd0858b 100644 --- a/src/pages/Jobfair/PublicJobFair/components/EditModal.tsx +++ b/src/pages/Jobfair/PublicJobFair/components/EditModal.tsx @@ -18,6 +18,7 @@ interface EditModalProps { open: boolean; values?: API.PublicJobFair.JobFairItem; jobFairTypeEnum: any; + jobFairRegionTypeEnum: any; onCancel: () => void; onSubmit: (values: API.PublicJobFair.JobFairForm) => Promise; } @@ -69,6 +70,7 @@ const EditModal: React.FC = ({ open, values, jobFairTypeEnum, + jobFairRegionTypeEnum, onCancel, onSubmit, }) => { @@ -175,6 +177,12 @@ const EditModal: React.FC = ({ valueEnum={jobFairTypeEnum} rules={[{ required: true, message: '请选择招聘会类型' }]} /> + {isCrossDomain === 'y' && ( { const [companyJobModalOpen, setCompanyJobModalOpen] = useState(false); const [currentJobFair, setCurrentJobFair] = useState(); const [jobFairTypeEnum, setJobFairTypeEnum] = useState({}); + const [jobFairRegionTypeEnum, setJobFairRegionTypeEnum] = useState({}); const [yesNoEnum, setYesNoEnum] = useState({}); const [selectedRowKeys, setSelectedRowKeys] = useState([]); useEffect(() => { getDictValueEnum('job_fair_type', true).then(setJobFairTypeEnum); + getDictValueEnum('job_fair_region_type').then(setJobFairRegionTypeEnum); getDictValueEnum('sys_yes_no').then(setYesNoEnum); }, []); @@ -129,6 +131,26 @@ const PublicJobFairList: React.FC = () => { valueEnum: jobFairTypeEnum, render: (_, record) => , }, + { + title: '区域类型', + dataIndex: 'jobFairRegionType', + valueType: 'select', + valueEnum: jobFairRegionTypeEnum, + render: (_, record) => ( + + ), + }, + { + title: '举办状态', + dataIndex: 'jobFairStatus', + hideInSearch: true, + render: (_, record) => { + const status = record.jobFairStatus; + if (!status) return '-'; + const color = status === '未举办' ? 'blue' : status === '正在举办' ? 'green' : 'default'; + return {status}; + }, + }, { title: '报名审核状态', dataIndex: 'enterpriseReviewStatus', @@ -284,6 +306,7 @@ const PublicJobFairList: React.FC = () => { pageSize: params.pageSize, jobFairTitle: params.jobFairTitle, jobFairType: params.jobFairType, + jobFairRegionType: params.jobFairRegionType, startDate: params.startDate, endDate: params.endDate, }); @@ -322,6 +345,7 @@ const PublicJobFairList: React.FC = () => { open={modalVisible} values={currentRow} jobFairTypeEnum={jobFairTypeEnum} + jobFairRegionTypeEnum={jobFairRegionTypeEnum} onCancel={() => { setModalVisible(false); setCurrentRow(undefined); diff --git a/src/pages/Management/List/edit.tsx b/src/pages/Management/List/edit.tsx index 1c9de25..65ae041 100644 --- a/src/pages/Management/List/edit.tsx +++ b/src/pages/Management/List/edit.tsx @@ -10,6 +10,7 @@ import { } from '@ant-design/pro-components'; import { Form, Button, Input, Tag, TreeSelect, Switch } from 'antd'; import { PlusOutlined, MinusCircleOutlined, EnvironmentOutlined } from '@ant-design/icons'; +import { useModel } from '@umijs/max'; import React, { useEffect, useRef, useState } from 'react'; import { DictValueEnumObj } from '@/components/DictTag'; import { getCmsCompanyList } from '@/services/company/list'; @@ -72,6 +73,8 @@ const waitTime = (time: number = 100) => { }; const listEdit: React.FC = (props) => { + const { initialState } = useModel('@@initialState'); + const isEnterprise = initialState?.currentUser?.userType === 'view'; const [form] = Form.useForm(); const companyNameMap = useRef>({}); const [jobDetail, setJobDetail] = useState(null); @@ -265,7 +268,7 @@ const listEdit: React.FC = (props) => { @@ -434,26 +437,28 @@ const listEdit: React.FC = (props) => { initialValue={0} fieldProps={{ defaultChecked: false }} /> - { - const resData = await getCmsCompanyList({ name: keyWords }); - return resData.rows.map((item) => { - companyNameMap.current[item.companyId] = item.name; - return { label: item.name, value: item.companyId }; - }); - }} - fieldProps={{ - onChange: (companyId: number) => { - form.setFieldValue('companyName', companyNameMap.current[companyId] ?? ''); - }, - }} - placeholder="请输入公司名称选择公司" - rules={[{ required: true, message: '请输入公司名称选择公司!' }]} - label="招聘公司" - /> + {!isEnterprise && ( + { + const resData = await getCmsCompanyList({ name: keyWords }); + return resData.rows.map((item) => { + companyNameMap.current[item.companyId] = item.name; + return { label: item.name, value: item.companyId }; + }); + }} + fieldProps={{ + onChange: (companyId: number) => { + form.setFieldValue('companyName', companyNameMap.current[companyId] ?? ''); + }, + }} + placeholder="请输入公司名称选择公司" + rules={[{ required: true, message: '请输入公司名称选择公司!' }]} + label="招聘公司" + /> + )} = (props) => { { + id?: number; + title?: string; + hostUnit?: string; + fairType?: string; + region?: string; + venueName?: string; + address?: string; + boothCount?: number; + holdTime?: string; + endTime?: string; + photoUrl?: string; +} + +interface PortalOutdoorFairDetailResult { + code: number; + msg?: string; + /** + * 旧接口返回 { fair: {...} },当前公共接口直接返回招聘会对象。 + * 两种格式均保留兼容,避免接口升级后户外详情页显示为空。 + */ + data?: PortalOutdoorFairDetailData | { fair?: PortalOutdoorFairDetailData }; +} + +function isOutdoorFairDetailWrapper( + data: NonNullable, +): data is { fair?: PortalOutdoorFairDetailData } { + return 'fair' in data; +} + +export interface PortalJobFairDetailResult { + code: number; + msg?: string; + data?: PortalJobFairItem; +} + +const FAIR_API = { + online: '/app/jobfair/public/jobfair', + outdoor: '/app/outdoor-fair', +} as const; + +/** 获取线上或户外招聘会分页列表。 */ +export async function getPortalJobFairPage( + channel: JobFairChannel, + params?: PortalJobFairPageParams, +) { + return request(`${FAIR_API[channel]}/page`, { + method: 'GET', + params, + }); +} + +/** 获取有招聘会的日期,用于与小程序一致的日期筛选提示。 */ +export async function getPortalJobFairDates(channel: JobFairChannel) { + return request(`${FAIR_API[channel]}/dates`, { + method: 'GET', + }); +} + +/** 获取当前月招聘会摘要。 */ +export async function getPortalCurrentMonthJobFairs(channel: JobFairChannel) { + return request(`${FAIR_API[channel]}/currentMonth`, { + method: 'GET', + }); +} + +/** 获取当前季度招聘会摘要。 */ +export async function getPortalCurrentQuarterJobFairs(channel: JobFairChannel) { + return request(`${FAIR_API[channel]}/currentQuarter`, { + method: 'GET', + }); +} + +/** 获取招聘会详情,并把户外招聘会详情转换为与列表一致的公共字段。 */ +export async function getPortalJobFairDetail(channel: JobFairChannel, jobFairId: string) { + if (channel === 'online') { + return request(`${FAIR_API.online}/detail`, { + method: 'GET', + params: { jobFairId }, + }); + } + + const response = await request( + `${FAIR_API.outdoor}/${encodeURIComponent(jobFairId)}`, + { method: 'GET' }, + ); + const payload = response.data; + const fair = payload && (isOutdoorFairDetailWrapper(payload) ? payload.fair : payload); + if (!fair) { + return { code: response.code, msg: response.msg } as PortalJobFairDetailResult; + } + return { + code: response.code, + msg: response.msg, + data: { + jobFairId: fair.jobFairId || String(fair.id ?? jobFairId), + jobFairTitle: fair.jobFairTitle || fair.title || '户外招聘会', + jobFairAddress: fair.jobFairAddress || fair.address || fair.venueName, + jobFairType: fair.jobFairType || fair.fairType || 'outdoor', + jobFairStartTime: fair.jobFairStartTime || fair.holdTime, + jobFairEndTime: fair.jobFairEndTime || fair.endTime, + jobFairHostUnit: fair.jobFairHostUnit || fair.hostUnit, + jobFairOrganizeUnit: fair.jobFairOrganizeUnit, + jobFairIntroduction: fair.jobFairIntroduction, + jobFairPhone: fair.jobFairPhone, + jobFairImage: fair.jobFairImage || fair.photoUrl, + boothNum: + fair.boothNum || (fair.boothCount === undefined ? undefined : String(fair.boothCount)), + jobFairRegion: fair.jobFairRegion || fair.region, + jobFairVenueName: fair.jobFairVenueName || fair.venueName, + }, + } as PortalJobFairDetailResult; +} diff --git a/src/types/jobfair/publicJobFair.d.ts b/src/types/jobfair/publicJobFair.d.ts index 76980c0..dd02d17 100644 --- a/src/types/jobfair/publicJobFair.d.ts +++ b/src/types/jobfair/publicJobFair.d.ts @@ -8,6 +8,7 @@ declare namespace API.PublicJobFair { pageSize?: number; jobFairTitle?: string; jobFairType?: string; + jobFairRegionType?: string; startDate?: string; endDate?: string; } @@ -18,8 +19,10 @@ declare namespace API.PublicJobFair { jobFairTitle: string; jobFairAddress: string; jobFairType: string; + jobFairRegionType?: string; jobFairStartTime: string; jobFairEndTime: string; + jobFairStatus?: '未举办' | '正在举办' | '已举办'; jobFairSignUpStartTime?: string; jobFairSignUpEndTime?: string; jobFairHostUnit?: string; @@ -101,6 +104,7 @@ declare namespace API.PublicJobFair { jobFairTitle: string; jobFairAddress: string; jobFairType: string; + jobFairRegionType: string; jobFairStartTime: string; jobFairEndTime: string; jobFairSignUpStartTime?: string; From 13256db26d58c6c7ce4bbddadf37a2f3c11ba617 Mon Sep 17 00:00:00 2001 From: lapuda <577732344@qq.com> Date: Tue, 21 Jul 2026 16:17:06 +0800 Subject: [PATCH 05/13] feat: add job fair position functionality with detailed card display and data fetching --- src/pages/JobPortal/JobFair/Detail/index.less | 84 ++++++++++ src/pages/JobPortal/JobFair/Detail/index.tsx | 145 +++++++++++++++++- src/services/jobportal/jobFair.ts | 57 +++++++ 3 files changed, 285 insertions(+), 1 deletion(-) diff --git a/src/pages/JobPortal/JobFair/Detail/index.less b/src/pages/JobPortal/JobFair/Detail/index.less index f06e381..2192560 100644 --- a/src/pages/JobPortal/JobFair/Detail/index.less +++ b/src/pages/JobPortal/JobFair/Detail/index.less @@ -47,6 +47,90 @@ line-height: 1.85; } } + + .job-fair-position-section { + margin-top: 28px; + } + + .job-fair-position-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 16px; + + .ant-typography { + margin: 0; + color: @jp-text-primary; + } + } + + .job-fair-position-card { + height: 100%; + .jp-card-base(); + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: @jp-primary-border; + box-shadow: @jp-shadow-hover; + } + + .job-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; + + .ant-typography { + flex: 1; + margin: 0; + color: @jp-text-primary; + font-size: 16px; + font-weight: 600; + } + + .job-salary { + .jp-salary-text(); + flex-shrink: 0; + font-size: 15px; + white-space: nowrap; + } + } + + .job-company { + display: block; + margin-bottom: 10px; + color: @jp-text-muted; + font-size: 13px; + } + + .job-info { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; + + .job-location, + .job-experience, + .job-education { + color: @jp-text-muted; + font-size: 12px; + } + } + + .job-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + + .job-tag { + .jp-job-tag(); + padding: 2px 8px; + font-size: 12px; + } + } + } } @media (max-width: 576px) { diff --git a/src/pages/JobPortal/JobFair/Detail/index.tsx b/src/pages/JobPortal/JobFair/Detail/index.tsx index 3d279a3..28dbc1e 100644 --- a/src/pages/JobPortal/JobFair/Detail/index.tsx +++ b/src/pages/JobPortal/JobFair/Detail/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Button, Card, Descriptions, Empty, Spin, Tag, Typography } from 'antd'; +import { Button, Card, Col, Descriptions, Empty, Row, Spin, Tag, Typography } from 'antd'; import { ArrowLeftOutlined, CalendarOutlined, @@ -13,8 +13,10 @@ import dayjs from 'dayjs'; import JobPortalHeader from '@/components/JobPortalHeader'; import { getPortalJobFairDetail, + getPortalJobFairPositions, type JobFairChannel, type PortalJobFairItem, + type PortalJobFairPosition, } from '@/services/jobportal/jobFair'; import '../index.less'; import './index.less'; @@ -33,6 +35,45 @@ function formatDateTime(value?: string) { return date.isValid() ? date.format('YYYY年MM月DD日 HH:mm') : value; } +const educationMap: Record = { + '0': '学历不限', + '1': '大专', + '2': '本科', + '3': '硕士', + '4': '博士', +}; + +const experienceMap: Record = { + '0': '经验不限', + '1': '1年以下', + '2': '1-3年', + '3': '3-5年', + '4': '5-10年', + '5': '10年以上', +}; + +function formatSalary(position: PortalJobFairPosition) { + const { minSalary, maxSalary } = position; + if (minSalary && maxSalary) { + return `${minSalary / 1000}K-${maxSalary / 1000}K`; + } + if (minSalary) { + return `${minSalary / 1000}K起`; + } + if (maxSalary) { + return `${maxSalary / 1000}K以下`; + } + return '薪资面议'; +} + +function formatRequirement( + value: string | undefined, + map: Record, + fallback: string, +) { + return value ? map[value] || value : fallback; +} + const PortalJobFairDetailPage: React.FC = () => { const location = useLocation(); const fallbackDetail = (location.state as JobFairDetailLocationState | null)?.fair; @@ -45,6 +86,8 @@ const PortalJobFairDetailPage: React.FC = () => { }, [location.search]); const [detail, setDetail] = useState(); const [loading, setLoading] = useState(true); + const [positions, setPositions] = useState([]); + const [positionsLoading, setPositionsLoading] = useState(false); useEffect(() => { if (!jobFairId) { @@ -79,8 +122,49 @@ const PortalJobFairDetailPage: React.FC = () => { }; }, [channel, fallbackDetail, jobFairId]); + useEffect(() => { + if (!jobFairId || channel === 'outdoor') { + setPositions([]); + setPositionsLoading(false); + return; + } + + let cancelled = false; + const loadPositions = async () => { + setPositionsLoading(true); + try { + const response = await getPortalJobFairPositions(jobFairId); + if (!cancelled) { + setPositions(response?.code === 200 ? response.data || [] : []); + } + } catch (error) { + if (!cancelled) { + setPositions([]); + } + } finally { + if (!cancelled) { + setPositionsLoading(false); + } + } + }; + + void loadPositions(); + return () => { + cancelled = true; + }; + }, [channel, jobFairId]); + const backToList = () => history.push('/job-portal/job-fair'); + const handlePositionClick = (position: PortalJobFairPosition) => { + if (!position.jobId) { + return; + } + history.push(`/job-portal/detail?jobId=${encodeURIComponent(String(position.jobId))}`, { + jobData: position, + }); + }; + return (
@@ -174,6 +258,65 @@ const PortalJobFairDetailPage: React.FC = () => { ) )} + {channel === 'online' && detail && ( +
+
+ 招聘岗位 + 共 {positions.length} 个岗位 +
+ + {positions.length ? ( + + {positions.map((position, index) => ( + + handlePositionClick(position)} + > +
+ {position.jobTitle || '未命名岗位'} + + {formatSalary(position)} + +
+ + {position.companyName || '招聘企业暂未公布'} + +
+ + {' '} + {position.jobLocation || position.jobAddress || '地点不限'} + + + {formatRequirement(position.experience, experienceMap, '经验不限')} + + + {formatRequirement(position.education, educationMap, '学历不限')} + +
+ {position.vacancies && ( +
+ 招聘 {position.vacancies} 人 +
+ )} +
+ + ))} +
+ ) : ( + !positionsLoading && ( + + ) + )} +
+
+ )}
); diff --git a/src/services/jobportal/jobFair.ts b/src/services/jobportal/jobFair.ts index cb46e1b..91da573 100644 --- a/src/services/jobportal/jobFair.ts +++ b/src/services/jobportal/jobFair.ts @@ -95,6 +95,40 @@ export interface PortalJobFairDetailResult { data?: PortalJobFairItem; } +/** 招聘会关联岗位;由公共接口中的企业岗位列表展平得到。 */ +export interface PortalJobFairPosition { + jobId?: number | string; + fairRelationId?: string; + jobTitle?: string; + minSalary?: number; + maxSalary?: number; + education?: string; + experience?: string; + companyId?: number | string; + companyName?: string; + jobLocation?: string; + jobAddress?: string; + vacancies?: number | string; +} + +interface PortalJobFairCompanyWithJobs { + companyId?: number | string; + companyName?: string; + jobInfoList?: PortalJobFairPosition[]; +} + +interface PortalJobFairCompaniesWithJobsResult { + code: number; + msg?: string; + data?: PortalJobFairCompanyWithJobs[]; +} + +export interface PortalJobFairPositionResult { + code: number; + msg?: string; + data?: PortalJobFairPosition[]; +} + const FAIR_API = { online: '/app/jobfair/public/jobfair', outdoor: '/app/outdoor-fair', @@ -172,3 +206,26 @@ export async function getPortalJobFairDetail(channel: JobFairChannel, jobFairId: }, } as PortalJobFairDetailResult; } + +/** 获取线上招聘会的关联岗位,并将企业分组数据转换为岗位卡片数据。 */ +export async function getPortalJobFairPositions(jobFairId: string) { + const response = await request( + `${FAIR_API.online}/enterprises-with-jobs-by-job-fair-id`, + { + method: 'GET', + params: { jobFairId }, + }, + ); + + return { + code: response.code, + msg: response.msg, + data: (response.data || []).flatMap((company) => + (company.jobInfoList || []).map((position) => ({ + ...position, + companyId: position.companyId || company.companyId, + companyName: position.companyName || company.companyName, + })), + ), + } as PortalJobFairPositionResult; +} From 13fdce8104709600642668eca544b417f26d6de8 Mon Sep 17 00:00:00 2001 From: lapuda <577732344@qq.com> Date: Tue, 21 Jul 2026 18:01:32 +0800 Subject: [PATCH 06/13] feat: show job region and website source separately --- src/pages/Management/List/index.tsx | 10 +++++++++- src/types/Management/list.d.ts | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/pages/Management/List/index.tsx b/src/pages/Management/List/index.tsx index 0d4f0d9..303b400 100644 --- a/src/pages/Management/List/index.tsx +++ b/src/pages/Management/List/index.tsx @@ -275,7 +275,7 @@ function ManagementList() { hideInSearch: true, }, { - title: '岗位来源', + title: '岗位区域', dataIndex: 'jobType', valueType: 'text', align: 'center', @@ -299,6 +299,14 @@ function ManagementList() { ); }, }, + { + title: '岗位来源', + dataIndex: 'externalSourceName', + valueType: 'text', + align: 'center', + hideInSearch: true, + render: (_, record) => record.externalSourceName || record.dataSource || '本地岗位', + }, { title: '浏览量', dataIndex: 'view', diff --git a/src/types/Management/list.d.ts b/src/types/Management/list.d.ts index 7715495..edcb68c 100644 --- a/src/types/Management/list.d.ts +++ b/src/types/Management/list.d.ts @@ -32,6 +32,9 @@ declare namespace API.ManagementList { release?: number; isPublish?: number; jobType?: string; + dataSource?: string; + externalSourceCode?: string; + externalSourceName?: string; jobContactList?: ContactPerson[]; createTime?: string; jobCategory?: string; From 7e01bbca2f39400419297397981e95927c74b82c Mon Sep 17 00:00:00 2001 From: lapuda <577732344@qq.com> Date: Tue, 21 Jul 2026 18:02:59 +0800 Subject: [PATCH 07/13] fix: label local jobs in source column --- src/pages/Management/List/index.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/Management/List/index.tsx b/src/pages/Management/List/index.tsx index 303b400..46cdc15 100644 --- a/src/pages/Management/List/index.tsx +++ b/src/pages/Management/List/index.tsx @@ -305,7 +305,12 @@ function ManagementList() { valueType: 'text', align: 'center', hideInSearch: true, - render: (_, record) => record.externalSourceName || record.dataSource || '本地岗位', + render: (_, record) => { + if (record.externalSourceName) { + return record.externalSourceName; + } + return record.dataSource && record.dataSource !== '5' ? record.dataSource : '本地岗位'; + }, }, { title: '浏览量', From b4c92cb27dcff282fbd813a111e0a2910e5963da Mon Sep 17 00:00:00 2001 From: lapuda <577732344@qq.com> Date: Wed, 22 Jul 2026 10:23:24 +0800 Subject: [PATCH 08/13] refactor: clean up code formatting and improve vacancy count handling in Management List - Consolidated import statements for better readability. - Added a new function `formatVacancyCount` to handle vacancy count display logic. - Improved conditional rendering and state management in the edit form. - Enhanced table rendering for contact information with consistent styling. - Streamlined button rendering in action columns for better clarity. - Ensured consistent formatting across the Management List component. --- src/pages/JobPortal/JobFair/index.less | 20 + src/pages/JobPortal/JobFair/index.tsx | 28 +- src/pages/JobPortal/List/index.tsx | 752 +++++++++++++------------ src/pages/Management/List/edit.tsx | 230 ++++---- src/pages/Management/List/index.tsx | 205 +++---- 5 files changed, 674 insertions(+), 561 deletions(-) diff --git a/src/pages/JobPortal/JobFair/index.less b/src/pages/JobPortal/JobFair/index.less index 95e27dc..4e4b926 100644 --- a/src/pages/JobPortal/JobFair/index.less +++ b/src/pages/JobPortal/JobFair/index.less @@ -203,15 +203,24 @@ .fair-card { height: 100%; + cursor: pointer; transition: + border-color 0.2s, box-shadow 0.2s, transform 0.2s; &:hover { + border-color: @jp-primary-border; box-shadow: @jp-shadow-hover; transform: translateY(-2px); } + &:focus-visible { + border-color: @jp-primary; + outline: none; + box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.18); + } + .ant-card-body { display: flex; flex-direction: column; @@ -270,6 +279,17 @@ line-height: 21px; } + .fair-card-action { + display: flex; + align-items: center; + gap: 6px; + margin-top: 16px; + color: @jp-primary; + font-size: 14px; + font-weight: 500; + line-height: 20px; + } + .job-fair-pagination { display: flex; justify-content: flex-end; diff --git a/src/pages/JobPortal/JobFair/index.tsx b/src/pages/JobPortal/JobFair/index.tsx index a781ca2..a986582 100644 --- a/src/pages/JobPortal/JobFair/index.tsx +++ b/src/pages/JobPortal/JobFair/index.tsx @@ -15,6 +15,7 @@ import { Typography, } from 'antd'; import { + ArrowRightOutlined, CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined, @@ -174,7 +175,7 @@ const PortalJobFairPage: React.FC = () => { setPageNum(1); }; - const handleSummaryFairClick = (fair: PortalJobFairItem) => { + const handleFairClick = (fair: PortalJobFairItem) => { if (!fair.jobFairId) { return; } @@ -184,6 +185,16 @@ const PortalJobFairPage: React.FC = () => { ); }; + const handleFairCardKeyDown = ( + event: React.KeyboardEvent, + fair: PortalJobFairItem, + ) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleFairClick(fair); + } + }; + const renderSummary = (title: string, fairsToRender: PortalJobFairItem[]) => ( @@ -193,7 +204,7 @@ const PortalJobFairPage: React.FC = () => { - - -
- - - - - - - {/* 左侧内容区 */} - - {selectedJobDetailTagGroups.length > 0 && ( -
- - 岗位待遇与工作安排 - -
- {selectedJobDetailTagGroups.map((group) => ( -
-
{group.label}
- - {group.values.map((value) => ( - - {value} - - ))} - -
- ))} -
+ {selectedJob?.jobCategory && ( + {resolveJobCategoryLabel(selectedJob)} + )} + {(selectedJob?.companyNature || selectedJob?.company?.companyNature) && ( + + {getDictLabel( + companyNatureEnum, + selectedJob.companyNature || selectedJob.company?.companyNature, + )} + + )} + {formatVacancyCount(selectedJob?.vacancies) && ( + + 招聘{formatVacancyCount(selectedJob?.vacancies)}人 + + )} + {selectedJob?.tags?.map((tag: string, index: number) => ( + + {tag} + + ))}
- )} -
- 职位描述 - - {selectedJob?.description || '暂无职位描述'} -
- +
+ + + + + +
+ - {/* 右侧侧边栏 */} - - {/* 竞争力分析 */} - - - 竞争力分析 - - } - className="competitiveness-card" - > - {isJobPortalLoggedIn() ? ( -
- -
-
-
{overallScore}
-
综合评分
-
+ + + + {/* 左侧内容区 */} + + {selectedJobDetailTagGroups.length > 0 && ( +
+ + 岗位待遇与工作安排 + +
+ {selectedJobDetailTagGroups.map((group) => ( +
+
{group.label}
+ + {group.values.map((value) => ( + + {value} + + ))} + +
+ ))}
- ) : ( -
- - - 登录后可查看您与该岗位的匹配度分析 - - 基于您的简历信息进行多维度对比 - -
)} - - -
+
+ + 职位描述 + + + {selectedJob?.description || '暂无职位描述'} + +
+ - + {/* 右侧侧边栏 */} + + {/* 竞争力分析 */} + + + 竞争力分析 + + } + className="competitiveness-card" + > + {isJobPortalLoggedIn() ? ( +
+ +
+
+
{overallScore}
+
综合评分
+
+
+
+ ) : ( +
+ + + 登录后可查看您与该岗位的匹配度分析 + + + 基于您的简历信息进行多维度对比 + + +
+ )} +
+ + + {/* 公司信息卡片 */} - {(selectedJob?.companyInfo || selectedJob?.companyName || selectedJob?.company) && ( + {(selectedJob?.companyInfo || + selectedJob?.companyName || + selectedJob?.company) && (
@@ -1083,7 +1117,9 @@ const JobListPage: React.FC = () => {
- {selectedJob?.companyInfo?.name || selectedJob?.companyName || selectedJob?.company} + {selectedJob?.companyInfo?.name || + selectedJob?.companyName || + selectedJob?.company} {selectedCompanyMetaItems.length > 0 && (
@@ -1103,11 +1139,7 @@ const JobListPage: React.FC = () => { )}
-
@@ -1125,8 +1157,8 @@ const JobListPage: React.FC = () => {
)} - - )} + + )}
diff --git a/src/pages/Management/List/edit.tsx b/src/pages/Management/List/edit.tsx index 65ae041..ead7237 100644 --- a/src/pages/Management/List/edit.tsx +++ b/src/pages/Management/List/edit.tsx @@ -17,10 +17,7 @@ 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'; +import { parseCommaSeparatedTags, serializeCommaSeparatedTags } from '@/utils/jobDetailTags'; type ManageFormValues = Omit< API.ManagementList.Manage, @@ -49,6 +46,13 @@ const renderDetailTags = (value: unknown) => { return tags.map((tag) => {tag}); }; +const formatVacancyCount = (vacancies: unknown): string => { + if (vacancies === null || vacancies === undefined || vacancies === '') { + return '-'; + } + return String(vacancies) === '-1' ? '若干' : String(vacancies); +}; + export type ListFormProps = { onCancel: (flag?: boolean, formVals?: unknown) => void; onSubmit: (values: API.ManagementList.Manage) => Promise; @@ -93,29 +97,31 @@ const listEdit: React.FC = (props) => { } = props; const { mode = props.values ? 'edit' : 'create' } = props; useEffect(() => { - if(props.open){ + if (props.open) { form.resetFields(); - if (props.values) { - form.setFieldsValue(toManageFormValues(props.values)); - // 初始化地图位置信息(编辑时回显) - if (props.values.latitude || props.values.longitude) { - setMapViewInfo({ - address: props.values.jobLocation || '', - latitude: props.values.latitude, - longitude: props.values.longitude, - }); + if (props.values) { + form.setFieldsValue(toManageFormValues(props.values)); + // 初始化地图位置信息(编辑时回显) + if (props.values.latitude || props.values.longitude) { + setMapViewInfo({ + address: props.values.jobLocation || '', + latitude: props.values.latitude, + longitude: props.values.longitude, + }); + } else { + setMapViewInfo({}); + } } else { setMapViewInfo({}); } - } else { - setMapViewInfo({}); + // 加载岗位标签树数据 + getJobTitleTreeSelect() + .then((res) => { + setJobCategoryTreeData(res.data || []); + }) + .catch(() => {}); } - // 加载岗位标签树数据 - getJobTitleTreeSelect().then((res) => { - setJobCategoryTreeData(res.data || []); - }).catch(() => {}); - } - }, [form, props.values?.jobId,props.open]); + }, [form, props.values?.jobId, props.open]); // 在查看模式和编辑模式下调用API获取详情 useEffect(() => { @@ -175,7 +181,7 @@ const listEdit: React.FC = (props) => { companyName: typeof values.companyName === 'string' ? values.companyName - : companyNameMap.current[values.companyId] ?? '', + : (companyNameMap.current[values.companyId] ?? ''), minSalary: values.minSalary, maxSalary: values.maxSalary, salaryComposition: serializeCommaSeparatedTags(values.salaryComposition), @@ -188,11 +194,13 @@ const listEdit: React.FC = (props) => { jobType: values.jobType, jobLocation: values.jobLocation, description: values.description, - jobContactList: (values.jobContactList ?? []).map((item: API.ManagementList.ContactPerson) => ({ - contactPerson: item.contactPerson, - contactPersonPhone: item.contactPersonPhone, - position: item.position, - })), + jobContactList: (values.jobContactList ?? []).map( + (item: API.ManagementList.ContactPerson) => ({ + contactPerson: item.contactPerson, + contactPersonPhone: item.contactPersonPhone, + position: item.position, + }), + ), jobCategory: values.jobCategory, isUrgent: values.isUrgent ? 1 : 0, latitude: values.latitude, @@ -217,7 +225,7 @@ const listEdit: React.FC = (props) => { }} submitter={false} > - + column={2} dataSource={jobDetail || props.values || {}} loading={loading} @@ -249,11 +257,7 @@ const listEdit: React.FC = (props) => { span={2} render={(value) => renderDetailTags(value)} /> - + = (props) => { label="工作区县" valueEnum={areaEnum} /> - + formatVacancyCount(value)} + /> - + = (props) => { } return (
- +
- - - {contactList.map((contact: any, index: number) => ( - - + - - @@ -539,7 +560,11 @@ const listEdit: React.FC = (props) => { placeholder="请选择区域" rules={[{ required: true, message: '请选择区域!' }]} /> - + = (props) => {
- +
{mapViewInfo.address ? ( <> @@ -574,18 +601,8 @@ const listEdit: React.FC = (props) => {
-
+ + + + + + + + + {booths.length === 0 ? ( + + ) : ( +
+
+ {booths.map((booth, index) => { + const key = getBoothKey(booth, index); + return ( + setActiveBoothKey(visible ? key : undefined)} + title={`${booth.boothNumber}:场地模板展位`} + content={ + + + + + } + > + handleMoveBooth(key, data.x, data.y)} + > +
+ {booth.boothNumber} +
+
+
+ ); + })} +
+
+ )} +
+ + + + + + 模板展位({booths.length}) + +
+ 此处只维护场地布局。创建户外招聘会并绑定该场地时,系统会复制当前模板为招聘会独立展位图,后续双方互不影响。 +
+ {booth.boothNumber}} + /> +
+
+ + + setGenerateModalOpen(false)} + onOk={handleGenerateBooths} + okText="生成" + > + + + 行数 + setGenerateRows(Number(value) || 1)} + /> + 列数 + setGenerateColumns(Number(value) || 1)} + /> + +
+ 将生成 {Math.max(1, generateRows || 1)} 行 × {Math.max(1, generateColumns || 1)} 列,共{' '} + {Math.max(1, generateRows || 1) * Math.max(1, generateColumns || 1)}{' '} + 个展位。生成后需要保存才会写入场地模板。 +
+
+
+ setBoothModalOpen(false)} + onOk={handleSaveBoothNumber} + okText="确定" + > + setBoothNumber(event.target.value)} + onPressEnter={handleSaveBoothNumber} + /> + + + ); +}; + +export default VenueBoothMapModal; diff --git a/src/pages/Jobfair/Venueinfo/index.tsx b/src/pages/Jobfair/Venueinfo/index.tsx index a116601..7486af6 100644 --- a/src/pages/Jobfair/Venueinfo/index.tsx +++ b/src/pages/Jobfair/Venueinfo/index.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAccess, useModel } from '@umijs/max'; import { Button, message, Modal } from 'antd'; import { ActionType, PageContainer, ProColumns, ProTable } from '@ant-design/pro-components'; -import { DeleteOutlined, FormOutlined, PlusOutlined } from '@ant-design/icons'; +import { ApartmentOutlined, DeleteOutlined, FormOutlined, PlusOutlined } from '@ant-design/icons'; import { addVenueInfo, addVenueInfoDictOption, @@ -19,6 +19,7 @@ import type { import { getCmsLineList } from '@/services/area/subway'; import { getDictSelectOption, getDictValueEnum } from '@/services/system/dict'; import EditModal from './components/EditModal'; +import VenueBoothMapModal from './components/VenueBoothMapModal'; const VENUE_TYPE_DICT = 'venue_info_type'; @@ -28,6 +29,7 @@ const VenueInfoList: React.FC = () => { const actionRef = useRef(); const [modalVisible, setModalVisible] = useState(false); const [currentRow, setCurrentRow] = useState(); + const [templateVenue, setTemplateVenue] = useState(); const [venueTypeValueEnum, setVenueTypeValueEnum] = useState>({}); const [venueTypeOptions, setVenueTypeOptions] = useState([]); const [lineOptions, setLineOptions] = useState([]); @@ -153,9 +155,19 @@ const VenueInfoList: React.FC = () => { { title: '操作', valueType: 'option', - width: 140, + width: 220, fixed: 'right', render: (_, record) => [ + , ) : null, + isEnterprise ? ( + + ) : null, + isEnterprise && record.signedUp && record.reviewStatus === '1' ? ( + + ) : null,
+ 联系人姓名 + 职务 + 联系电话
+
{contact.contactPerson} + {contact.position} + {contact.contactPersonPhone}