diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore index 9de0f16..d20c0fe 100644 --- a/.codegraph/.gitignore +++ b/.codegraph/.gitignore @@ -1,16 +1,5 @@ -# CodeGraph data files -# These are local to each machine and should not be committed - -# Database -*.db -*.db-wal -*.db-shm - -# Cache -cache/ - -# Logs -*.log - -# Hook markers -.dirty +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/config/proxy.ts b/config/proxy.ts index 6a91cb1..3253cc7 100644 --- a/config/proxy.ts +++ b/config/proxy.ts @@ -44,6 +44,8 @@ export default { // 配置了这个可以从 http 代理到 https // 依赖 origin 的功能可能需要这个,比如 cookie changeOrigin: true, + // 测试环境目前使用 IP 访问 HTTPS,证书域名不匹配;仅影响本地开发代理。 + secure: false, }, '/resumegenerator': { target: 'http://test.xjshzly.longbiosphere.com', diff --git a/config/routes.ts b/config/routes.ts index effdcfc..49833fc 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', @@ -251,6 +259,11 @@ export default [ name: 'company', path: '/company', routes: [ + { + name: '企业信息', + path: '/company/info/index', + component: './Company/Info', + }, { name: '企业资质审核详情', path: '/company/qualification-review/detail/:id', @@ -265,21 +278,21 @@ 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: '户外招聘会管理', + name: '线下招聘会管理', path: '/jobfair/outdoor-fair', component: './Jobfair/Outdoorfair', }, @@ -294,7 +307,7 @@ export default [ component: './Jobfair/Venueinfo/Detail', }, { - name: '户外招聘会详情', + name: '线下招聘会详情', path: '/jobfair/outdoor-fair/detail', component: './Jobfair/Outdoorfair/Detail', }, 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/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/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 = ({ > 找工作 + + + )} + + + + + + + + + + ); +}; + +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/JobFair/Detail/index.less b/src/pages/JobPortal/JobFair/Detail/index.less new file mode 100644 index 0000000..2192560 --- /dev/null +++ b/src/pages/JobPortal/JobFair/Detail/index.less @@ -0,0 +1,146 @@ +@import '../../theme.less'; + +.portal-job-fair-detail-page { + .job-fair-detail-content { + .jp-page-container(); + padding-top: 24px; + padding-bottom: 56px; + } + + .job-fair-back { + margin: 0 0 12px; + padding: 0; + } + + .job-fair-detail-card { + .jp-card-base(); + + .ant-card-body { + padding: 32px; + } + } + + .job-fair-detail-title-row { + margin-bottom: 26px; + + .ant-tag { + margin-bottom: 12px; + } + + .ant-typography { + margin: 0; + color: @jp-text-primary; + } + } + + .job-fair-detail-introduction { + margin-top: 28px; + + .ant-typography { + color: @jp-text-primary; + } + + p { + margin-bottom: 0; + white-space: pre-wrap; + color: @jp-text-secondary; + 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) { + .portal-job-fair-detail-page { + .job-fair-detail-content { + padding: 16px 12px 36px; + } + + .job-fair-detail-card .ant-card-body { + padding: 20px; + } + } +} diff --git a/src/pages/JobPortal/JobFair/Detail/index.tsx b/src/pages/JobPortal/JobFair/Detail/index.tsx new file mode 100644 index 0000000..119ea61 --- /dev/null +++ b/src/pages/JobPortal/JobFair/Detail/index.tsx @@ -0,0 +1,344 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Button, Card, Col, Descriptions, Empty, Row, Spin, Tag, Typography } from 'antd'; +import { + ArrowLeftOutlined, + CalendarOutlined, + EnvironmentOutlined, + HomeOutlined, + PhoneOutlined, + TeamOutlined, +} from '@ant-design/icons'; +import { history, useLocation } from '@umijs/max'; +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'; + +const { Title, Paragraph } = Typography; + +interface JobFairDetailLocationState { + fair?: PortalJobFairItem; +} + +function formatDateTime(value?: string) { + if (!value) { + return '暂未公布'; + } + const date = dayjs(value.replace(' ', 'T')); + 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; + const { channel, jobFairId } = useMemo(() => { + const params = new URLSearchParams(location.search); + return { + channel: params.get('channel') === 'outdoor' ? 'outdoor' : 'online', + jobFairId: params.get('jobFairId') || '', + } as { channel: JobFairChannel; jobFairId: string }; + }, [location.search]); + const [detail, setDetail] = useState(); + const [loading, setLoading] = useState(true); + const [positions, setPositions] = useState([]); + const [positionsLoading, setPositionsLoading] = useState(false); + + useEffect(() => { + if (!jobFairId) { + setLoading(false); + return; + } + + let cancelled = false; + const loadDetail = async () => { + setLoading(true); + try { + const response = await getPortalJobFairDetail(channel, jobFairId); + if (!cancelled && response?.code === 200) { + setDetail(response.data || fallbackDetail); + } else if (!cancelled) { + setDetail(fallbackDetail); + } + } catch (error) { + if (!cancelled) { + setDetail(fallbackDetail); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void loadDetail(); + return () => { + cancelled = true; + }; + }, [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 ( +
+ +
+ + + {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} + )} + {channel === 'outdoor' && detail.boothMapCount !== undefined && ( + {detail.boothMapCount} + )} + {detail.jobFairPhone && ( + + 联系电话 + + } + > + {detail.jobFairPhone} + + )} + +
+ 招聘会简介 + {detail.jobFairIntroduction || '暂未提供招聘会简介。'} +
+ {channel === 'outdoor' && ( +
+ 招聘会整体流程 + {detail.jobFairProcess || '暂未提供招聘会整体流程。'} +
+ )} +
+ ) : ( + !loading && ( + + + + ) + )} +
+ {channel === 'online' && detail && ( +
+
+ 招聘岗位 + 共 {positions.length} 个岗位 +
+ + {positions.length ? ( + + {positions.map((position, index) => ( + + handlePositionClick(position)} + > +
+ + {position.isUrgent === 1 && ( + <Tag + color="red" + style={{ marginRight: 8, fontSize: 12, lineHeight: '20px' }} + > + 急聘 + </Tag> + )} + {position.jobTitle || '未命名岗位'} + + + {formatSalary(position)} + +
+ + {position.companyName || '招聘企业暂未公布'} + +
+ + {' '} + {position.jobLocation || position.jobAddress || '地点不限'} + + + {formatRequirement(position.experience, experienceMap, '经验不限')} + + + {formatRequirement(position.education, educationMap, '学历不限')} + +
+ {position.vacancies && ( +
+ 招聘 {position.vacancies} 人 +
+ )} +
+ + ))} +
+ ) : ( + !positionsLoading && ( + + ) + )} +
+
+ )} +
+
+ ); +}; + +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..4e4b926 --- /dev/null +++ b/src/pages/JobPortal/JobFair/index.less @@ -0,0 +1,353 @@ +@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%; + 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; + 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; + } + + .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; + 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..7cef9b9 --- /dev/null +++ b/src/pages/JobPortal/JobFair/index.tsx @@ -0,0 +1,402 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + Button, + Calendar, + Card, + Col, + ConfigProvider, + Empty, + Input, + Pagination, + Row, + Spin, + Tabs, + Tag, + Typography, +} from 'antd'; +import { + ArrowRightOutlined, + 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 handleFairClick = (fair: PortalJobFairItem) => { + if (!fair.jobFairId) { + return; + } + history.push( + `/job-portal/job-fair/detail?channel=${channel}&jobFairId=${encodeURIComponent(fair.jobFairId)}`, + { fair }, + ); + }; + + const handleFairCardKeyDown = ( + event: React.KeyboardEvent, + fair: PortalJobFairItem, + ) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleFairClick(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 ( + + handleFairClick(fair)} + onKeyDown={(event) => handleFairCardKeyDown(event, fair)} + role="link" + tabIndex={fair.jobFairId ? 0 : -1} + > +
+ + {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/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..1884681 100644 --- a/src/pages/JobPortal/List/index.tsx +++ b/src/pages/JobPortal/List/index.tsx @@ -12,26 +12,33 @@ import { Select, Input, message, - Modal + Modal, } from 'antd'; import { EnvironmentOutlined, HeartOutlined, HeartFilled, - QrcodeOutlined, FlagOutlined, ArrowLeftOutlined, SearchOutlined, TrophyOutlined, - StopOutlined + StopOutlined, } from '@ant-design/icons'; 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'; @@ -47,15 +54,6 @@ import './index.less'; const { Title, Text, Paragraph } = Typography; const { Option } = Select; -// 学历字典映射 -const educationMap: { [key: string]: string } = { - '1': '大专', - '2': '本科', - '3': '硕士', - '4': '博士', - '0': '不限' -}; - // 工作经验字典映射 const experienceMap: { [key: string]: string } = { '1': '1年以下', @@ -63,7 +61,14 @@ const experienceMap: { [key: string]: string } = { '3': '3-5年', '4': '5-10年', '5': '10年以上', - '0': '不限' + '0': '不限', +}; + +const formatVacancyCount = (vacancies: unknown): string | null => { + if (vacancies === null || vacancies === undefined || vacancies === '') { + return null; + } + return String(vacancies) === '-1' ? '若干' : String(vacancies); }; // 从缓存获取用户信息 @@ -78,18 +83,20 @@ const mockJobs = [ company: '博凡科技', companyInfo: { name: '博凡科技', - description: '博凡科技是一家专注于企业级应用软件开发的高新技术企业,成立于2015年。公司致力于为客户提供专业的IT解决方案,业务涵盖云计算、大数据、人工智能、物联网等领域。公司拥有一支经验丰富、技术精湛的研发团队,始终坚持技术创新和服务至上,为客户创造价值。' + description: + '博凡科技是一家专注于企业级应用软件开发的高新技术企业,成立于2015年。公司致力于为客户提供专业的IT解决方案,业务涵盖云计算、大数据、人工智能、物联网等领域。公司拥有一支经验丰富、技术精湛的研发团队,始终坚持技术创新和服务至上,为客户创造价值。', }, location: '北京·东城区·东四', tags: ['Java', 'SpringCloud', 'MySQL'], - description: '岗位职责:\n1. 负责公司项目的研发工作\n2. 负责后端系统的设计与开发\n3. 参与技术方案的讨论和制定\n\n任职要求:\n1. 5年以上工作经验;本科学历及以上\n2. 熟练掌握Java基础,SpringCloud、SpringBoot\n3. 熟悉Redis、SQL/NoSQL数据库\n4. 熟悉消息队列:Kafka、RabbitMQ、RocketMQ\n5. 熟悉Elasticsearch、Linux运维\n6. 具备良好的问题解决能力和责任心\n7. 具备良好的团队合作精神', + description: + '岗位职责:\n1. 负责公司项目的研发工作\n2. 负责后端系统的设计与开发\n3. 参与技术方案的讨论和制定\n\n任职要求:\n1. 5年以上工作经验;本科学历及以上\n2. 熟练掌握Java基础,SpringCloud、SpringBoot\n3. 熟悉Redis、SQL/NoSQL数据库\n4. 熟悉消息队列:Kafka、RabbitMQ、RocketMQ\n5. 熟悉Elasticsearch、Linux运维\n6. 具备良好的问题解决能力和责任心\n7. 具备良好的团队合作精神', benefits: [ '完善的社会保险制度;购买五险一金', '节假日礼品', '带薪年假,法定节假日休息', '团建活动:KTV、别墅轰趴', - '舒适的工作环境' - ] + '舒适的工作环境', + ], }, { id: 2, @@ -100,17 +107,14 @@ const mockJobs = [ company: '新华三技术有限公司', companyInfo: { name: '新华三技术有限公司', - description: '新华三技术有限公司是业界领先的IT解决方案提供商,专注于云计算、大数据、信息安全等领域。公司秉承"客户至上、持续创新"的理念,为全球客户提供优质的IT产品和服务。新华三拥有一流的研发团队和完善的人才培养体系,致力于成为最受信赖的IT服务提供商。' + description: + '新华三技术有限公司是业界领先的IT解决方案提供商,专注于云计算、大数据、信息安全等领域。公司秉承"客户至上、持续创新"的理念,为全球客户提供优质的IT产品和服务。新华三拥有一流的研发团队和完善的人才培养体系,致力于成为最受信赖的IT服务提供商。', }, location: '北京·石景山区·古城', tags: ['Java', 'Spring Boot', 'MySQL'], - description: '岗位职责:\n1. 参与公司核心业务系统的设计开发\n2. 优化系统性能,提升用户体验\n3. 编写技术文档\n\n任职要求:\n1. 3年以上Java开发经验\n2. 熟悉Spring Boot等主流框架\n3. 熟悉MySQL数据库设计和优化', - benefits: [ - '五险一金', - '带薪年假', - '年终奖', - '定期体检' - ] + description: + '岗位职责:\n1. 参与公司核心业务系统的设计开发\n2. 优化系统性能,提升用户体验\n3. 编写技术文档\n\n任职要求:\n1. 3年以上Java开发经验\n2. 熟悉Spring Boot等主流框架\n3. 熟悉MySQL数据库设计和优化', + benefits: ['五险一金', '带薪年假', '年终奖', '定期体检'], }, { id: 3, @@ -121,17 +125,14 @@ const mockJobs = [ company: '合肥森蕴', companyInfo: { name: '合肥森蕴科技有限公司', - description: '合肥森蕴科技有限公司是一家专业从事软件开发和系统集成的高科技企业,成立于2010年。公司主要业务包括企业信息化建设、互联网应用开发、移动互联网解决方案等。森蕴科技拥有一支年轻富有活力的技术团队,注重技术研发和创新,为客户提供全面、专业的IT服务。公司以"技术驱动创新,服务创造价值"为使命,致力于成为行业领先的IT服务提供商。' + description: + '合肥森蕴科技有限公司是一家专业从事软件开发和系统集成的高科技企业,成立于2010年。公司主要业务包括企业信息化建设、互联网应用开发、移动互联网解决方案等。森蕴科技拥有一支年轻富有活力的技术团队,注重技术研发和创新,为客户提供全面、专业的IT服务。公司以"技术驱动创新,服务创造价值"为使命,致力于成为行业领先的IT服务提供商。', }, location: '北京·海淀区·苏州桥', tags: ['Java', 'Spring', 'Redis'], - description: '岗位职责:\n1. 负责架构设计与技术选型\n2. 指导团队成员进行开发\n3. 解决复杂技术问题\n\n任职要求:\n1. 5年以上Java开发经验\n2. 精通Spring框架\n3. 熟悉分布式系统设计', - benefits: [ - '五险一金', - '股票期权', - '带薪年假', - '技术培训' - ] + description: + '岗位职责:\n1. 负责架构设计与技术选型\n2. 指导团队成员进行开发\n3. 解决复杂技术问题\n\n任职要求:\n1. 5年以上Java开发经验\n2. 精通Spring框架\n3. 熟悉分布式系统设计', + benefits: ['五险一金', '股票期权', '带薪年假', '技术培训'], }, { id: 4, @@ -142,16 +143,14 @@ const mockJobs = [ company: '创新科技', companyInfo: { name: '创新科技有限公司', - description: '创新科技有限公司专注于互联网金融和数据分析领域,致力于为金融机构提供专业的技术解决方案。公司成立于2018年,拥有一支在金融科技、云计算、人工智能等领域经验丰富的技术团队。公司秉承"创新驱动发展"的理念,通过技术创新为客户创造价值,推动金融行业的数字化转型。' + description: + '创新科技有限公司专注于互联网金融和数据分析领域,致力于为金融机构提供专业的技术解决方案。公司成立于2018年,拥有一支在金融科技、云计算、人工智能等领域经验丰富的技术团队。公司秉承"创新驱动发展"的理念,通过技术创新为客户创造价值,推动金融行业的数字化转型。', }, location: '北京·朝阳区·望京', tags: ['Python', 'Django', 'MySQL'], - description: '岗位职责:\n1. 负责Python后端开发\n2. 参与系统架构设计\n3. 优化系统性能\n\n任职要求:\n1. 3年以上Python开发经验\n2. 熟悉Django、Flask框架\n3. 熟悉MySQL、Redis', - benefits: [ - '五险一金', - '带薪年假', - '年终奖' - ] + description: + '岗位职责:\n1. 负责Python后端开发\n2. 参与系统架构设计\n3. 优化系统性能\n\n任职要求:\n1. 3年以上Python开发经验\n2. 熟悉Django、Flask框架\n3. 熟悉MySQL、Redis', + benefits: ['五险一金', '带薪年假', '年终奖'], }, { id: 5, @@ -162,16 +161,14 @@ const mockJobs = [ company: '互联网科技', companyInfo: { name: '互联网科技有限公司', - description: '互联网科技有限公司是一家专注于电商平台和移动应用开发的企业,成立于2012年。公司致力于为用户提供优质的产品和服务,在电商、O2O、社交等领域积累了丰富的经验。公司拥有一支年轻、富有创造力的团队,注重用户体验和技术创新,致力于打造行业领先的互联网产品。' + description: + '互联网科技有限公司是一家专注于电商平台和移动应用开发的企业,成立于2012年。公司致力于为用户提供优质的产品和服务,在电商、O2O、社交等领域积累了丰富的经验。公司拥有一支年轻、富有创造力的团队,注重用户体验和技术创新,致力于打造行业领先的互联网产品。', }, location: '北京·海淀区·中关村', tags: ['Vue', 'React', 'TypeScript'], - description: '岗位职责:\n1. 负责前端页面开发\n2. 优化用户体验\n3. 与后端协作\n\n任职要求:\n1. 2年以上前端开发经验\n2. 熟悉Vue、React等框架\n3. 熟悉TypeScript', - benefits: [ - '五险一金', - '股票期权', - '技术培训' - ] + description: + '岗位职责:\n1. 负责前端页面开发\n2. 优化用户体验\n3. 与后端协作\n\n任职要求:\n1. 2年以上前端开发经验\n2. 熟悉Vue、React等框架\n3. 熟悉TypeScript', + benefits: ['五险一金', '股票期权', '技术培训'], }, { id: 6, @@ -182,16 +179,14 @@ const mockJobs = [ company: '质量科技', companyInfo: { name: '质量科技有限公司', - description: '质量科技有限公司是一家专业的软件质量保证服务提供商,成立于2016年。公司专注于为各行业客户提供专业的测试服务和质量保障方案,在自动化测试、性能测试、安全测试等领域具有丰富的经验。公司拥有一支专业的测试团队和完善的测试体系,致力于帮助客户提升产品质量,降低运营风险。' + description: + '质量科技有限公司是一家专业的软件质量保证服务提供商,成立于2016年。公司专注于为各行业客户提供专业的测试服务和质量保障方案,在自动化测试、性能测试、安全测试等领域具有丰富的经验。公司拥有一支专业的测试团队和完善的测试体系,致力于帮助客户提升产品质量,降低运营风险。', }, location: '北京·大兴区·亦庄', tags: ['测试', '自动化', 'Selenium'], - description: '岗位职责:\n1. 编写测试用例\n2. 执行功能测试\n3. 参与自动化测试\n\n任职要求:\n1. 1年以上测试经验\n2. 熟悉测试工具\n3. 有责任心', - benefits: [ - '五险一金', - '带薪年假', - '节日福利' - ] + description: + '岗位职责:\n1. 编写测试用例\n2. 执行功能测试\n3. 参与自动化测试\n\n任职要求:\n1. 1年以上测试经验\n2. 熟悉测试工具\n3. 有责任心', + benefits: ['五险一金', '带薪年假', '节日福利'], }, ]; @@ -202,7 +197,9 @@ const JobListPage: React.FC = () => { const [jobList, setJobList] = useState([]); const [loading, setLoading] = useState(false); const [jobCategory, setJobCategory] = useState(''); + const [educationEnum, setEducationEnum] = useState({}); const [scaleEnum, setScaleEnum] = useState({}); + const [companyNatureEnum, setCompanyNatureEnum] = useState({}); const [jobTitleTree, setJobTitleTree] = useState([]); const [industryTree, setIndustryTree] = useState([]); const [searchValue, setSearchValue] = useState(''); // 搜索框的值 @@ -214,9 +211,30 @@ const JobListPage: React.FC = () => { { item: '学历', score: 0 }, { item: '薪资', score: 0 }, { item: '年龄', score: 0 }, - { item: '工作地', score: 0 } + { 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) { @@ -231,11 +249,15 @@ const JobListPage: React.FC = () => { useEffect(() => { Promise.all([ + getDictValueEnum('education', true, true), getDictValueEnum('scale', true, true), + getDictValueEnum('company_nature', false, true), getCmsIndustryTreeList(), getJobTitleTreeSelect(), - ]).then(([scaleData, industryRes, jobTitleRes]) => { + ]).then(([educationData, scaleData, companyNatureData, industryRes, jobTitleRes]) => { + setEducationEnum(educationData); setScaleEnum(scaleData); + setCompanyNatureEnum(companyNatureData); if (industryRes?.code === 200 && industryRes?.data) { setIndustryTree(industryRes.data); } @@ -302,42 +324,72 @@ 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) { // 根据 isCollection 字段设置收藏状态 // isCollection 为 null 或 0 表示未收藏,其他值表示已收藏 - setFavorited(selectedJob?.isCollection !== null && selectedJob?.isCollection !== 0 && selectedJob?.isCollection !== undefined); + setFavorited( + selectedJob?.isCollection !== null && + selectedJob?.isCollection !== 0 && + selectedJob?.isCollection !== undefined, + ); } }, [selectedJob]); // 当 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()) { @@ -367,8 +419,8 @@ const JobListPage: React.FC = () => { { item: '学历', score: r.education ?? 0 }, { item: '薪资', score: r.salary ?? 0 }, { item: '年龄', score: r.age ?? 0 }, - { item: '工作地', score: r.location ?? 0 } - ].map(d => ({ ...d, score: Math.max(0, Math.min(100, d.score)) })); + { item: '工作地', score: r.location ?? 0 }, + ].map((d) => ({ ...d, score: Math.max(0, Math.min(100, d.score)) })); setRadarData(mapped); } else { setRadarData(getEmptyCompetitivenessRadar()); @@ -413,7 +465,9 @@ const JobListPage: React.FC = () => { setSelectedJob(job); // 根据 isCollection 字段设置收藏状态 // isCollection 为 null 或 0 表示未收藏,其他值表示已收藏 - setFavorited(job?.isCollection !== null && job?.isCollection !== 0 && job?.isCollection !== undefined); + setFavorited( + job?.isCollection !== null && job?.isCollection !== 0 && job?.isCollection !== undefined, + ); }; const handleCollect = async () => { @@ -423,7 +477,6 @@ const JobListPage: React.FC = () => { } if (favorited) { - // 获取职位ID const jobId = selectedJob?.jobId || selectedJob?.id; if (!jobId) { @@ -439,12 +492,16 @@ const JobListPage: React.FC = () => { if (selectedJob) { setSelectedJob({ ...selectedJob, isCollection: 0 }); // 同时更新列表中的数据 - setJobList(jobList.map(job => - (job.jobId === jobId || job.id === jobId) ? { ...job, isCollection: 0 } : job - )); - setFilteredJobList(filteredJobList.map(job => - (job.jobId === jobId || job.id === jobId) ? { ...job, isCollection: 0 } : job - )); + setJobList( + jobList.map((job) => + job.jobId === jobId || job.id === jobId ? { ...job, isCollection: 0 } : job, + ), + ); + setFilteredJobList( + filteredJobList.map((job) => + job.jobId === jobId || job.id === jobId ? { ...job, isCollection: 0 } : job, + ), + ); } message.success('取消收藏成功'); } else { @@ -472,12 +529,16 @@ const JobListPage: React.FC = () => { if (selectedJob) { setSelectedJob({ ...selectedJob, isCollection: 1 }); // 同时更新列表中的数据 - setJobList(jobList.map(job => - (job.jobId === jobId || job.id === jobId) ? { ...job, isCollection: 1 } : job - )); - setFilteredJobList(filteredJobList.map(job => - (job.jobId === jobId || job.id === jobId) ? { ...job, isCollection: 1 } : job - )); + setJobList( + jobList.map((job) => + job.jobId === jobId || job.id === jobId ? { ...job, isCollection: 1 } : job, + ), + ); + setFilteredJobList( + filteredJobList.map((job) => + job.jobId === jobId || job.id === jobId ? { ...job, isCollection: 1 } : job, + ), + ); } message.success('收藏成功'); } else { @@ -505,18 +566,22 @@ const JobListPage: React.FC = () => { try { const response = await applyJob({ jobId: String(jobId), - userId: String(userId) + userId: String(userId), }); if (response?.code === 200) { message.success('申请成功'); if (selectedJob) { setSelectedJob({ ...selectedJob, isApply: 1 }); - setJobList(jobList.map(job => - (job.jobId === jobId || job.id === jobId) ? { ...job, isApply: 1 } : job - )); - setFilteredJobList(filteredJobList.map(job => - (job.jobId === jobId || job.id === jobId) ? { ...job, isApply: 1 } : job - )); + setJobList( + jobList.map((job) => + job.jobId === jobId || job.id === jobId ? { ...job, isApply: 1 } : job, + ), + ); + setFilteredJobList( + filteredJobList.map((job) => + job.jobId === jobId || job.id === jobId ? { ...job, isApply: 1 } : job, + ), + ); } } else { message.error(response?.msg || '申请失败'); @@ -718,15 +783,17 @@ const JobListPage: React.FC = () => {
{filteredJobList.length === 0 ? ( -
+
暂无相关职位信息
@@ -737,55 +804,73 @@ const JobListPage: React.FC = () => {
) : ( filteredJobList.map((job, index) => ( - handleJobClick(job)} - bodyStyle={{ padding: '16px' }} - loading={loading} - > -
- - {job.isUrgent === 1 && ( - <Tag color="red" style={{ marginRight: 8, fontSize: 12, lineHeight: '20px' }}>急聘</Tag> + <Card + key={job.jobId || job.id || index} + className={`job-card ${ + selectedJob && + ((selectedJob.jobId && selectedJob.jobId === job.jobId) || + (selectedJob.id && selectedJob.id === job.id) || + selectedJob === job) + ? 'active' + : '' + }`} + hoverable + onClick={() => handleJobClick(job)} + bodyStyle={{ padding: '16px' }} + loading={loading} + > + <div className="job-card-header"> + <Title level={4} className="job-title"> + {job.isUrgent === 1 && ( + <Tag + color="red" + style={{ marginRight: 8, fontSize: 12, lineHeight: '20px' }} + > + 急聘 + </Tag> + )} + {job.jobTitle || job.title} + + + {job.minSalary && job.maxSalary + ? formatSalary(job.minSalary, job.maxSalary) + : job.salary || '面议'} + +
+
+ {job.companyName || job.company} +
+
+ + {job.jobLocation || job.location} + + + {job.experience ? experienceMap[job.experience] || job.experience : '不限'} + + + {job.education ? getDictLabel(educationEnum, job.education) : '不限'} + +
+ {/* 添加标签区域,与热门岗位保持一致 */} +
+ {job.jobCategory && ( + {resolveJobCategoryLabel(job)} )} - {job.jobTitle || job.title} - - - {job.minSalary && job.maxSalary ? formatSalary(job.minSalary, job.maxSalary) : (job.salary || '面议')} - -
-
- {job.companyName || job.company} -
-
- - {job.jobLocation || job.location} - - - {job.experience ? experienceMap[job.experience] || job.experience : '不限'} - - - {job.education ? educationMap[job.education] || job.education : '不限'} - -
- {/* 添加标签区域,与热门岗位保持一致 */} -
- {job.jobCategory && ( - {resolveJobCategoryLabel(job)} - )} - {/* {job.dataSource && {job.dataSource}} */} - {job.vacancies && 招聘{job.vacancies}人} - {/* {job.industry && {job.industry}} */} -
-
+ {(job.companyNature || job.company?.companyNature) && ( + + {getDictLabel( + companyNatureEnum, + job.companyNature || job.company?.companyNature, + )} + + )} + {/* {job.dataSource && {job.dataSource}} */} + {formatVacancyCount(job.vacancies) && ( + 招聘{formatVacancyCount(job.vacancies)}人 + )} + {/* {job.industry && {job.industry}} */} +
+ )) )}
@@ -795,15 +880,17 @@ const JobListPage: React.FC = () => {
{!selectedJob ? ( -
+
暂无职位详情信息
@@ -815,173 +902,214 @@ const JobListPage: React.FC = () => { ) : ( <> -
-
- - {selectedJob?.isUrgent === 1 && ( - <Tag color="red" style={{ marginRight: 8, fontSize: 12, lineHeight: '20px' }}>急聘</Tag> - )} - {selectedJob?.jobTitle || selectedJob?.title || '请选择职位'} - - - {selectedJob?.minSalary && selectedJob?.maxSalary - ? formatSalary(selectedJob.minSalary, selectedJob.maxSalary) - : (selectedJob?.salary || '面议')} - -
- {(selectedJob?.jobLocation || selectedJob?.location) && ( +
+
+ + {selectedJob?.isUrgent === 1 && ( + <Tag + color="red" + style={{ marginRight: 8, fontSize: 12, lineHeight: '20px' }} + > + 急聘 + </Tag> + )} + {selectedJob?.jobTitle || selectedJob?.title || '请选择职位'} + + + {selectedJob?.minSalary && selectedJob?.maxSalary + ? formatSalary(selectedJob.minSalary, selectedJob.maxSalary) + : selectedJob?.salary || '面议'} + +
+ {(selectedJob?.jobLocation || selectedJob?.location) && ( + + {' '} + {selectedJob?.jobLocation || selectedJob?.location} + + )} - {selectedJob?.jobLocation || selectedJob?.location} + {selectedJob?.experience + ? experienceMap[selectedJob.experience] || selectedJob.experience + : '经验不限'} - )} - - {selectedJob?.experience - ? experienceMap[selectedJob.experience] || selectedJob.experience - : '经验不限'} - - - {selectedJob?.education - ? educationMap[selectedJob.education] || selectedJob.education - : '学历不限'} - - {selectedJob?.jobCategory && ( - {resolveJobCategoryLabel(selectedJob)} - )} - {selectedJob?.vacancies && ( - 招聘{selectedJob.vacancies}人 - )} - {selectedJob?.tags?.map((tag: string, index: number) => ( - {tag} - ))} + + {selectedJob?.education + ? getDictLabel(educationEnum, selectedJob.education) + : '学历不限'} + + {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) && (
@@ -989,7 +1117,9 @@ const JobListPage: React.FC = () => {
- {selectedJob?.companyInfo?.name || selectedJob?.companyName || selectedJob?.company} + {selectedJob?.companyInfo?.name || + selectedJob?.companyName || + selectedJob?.company} {selectedCompanyMetaItems.length > 0 && (
@@ -1009,11 +1139,7 @@ const JobListPage: React.FC = () => { )}
-
@@ -1031,8 +1157,8 @@ const JobListPage: React.FC = () => {
)} - - )} + + )}
@@ -1049,4 +1175,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/Jobfair/Outdoorfair/Detail/components/FairInfoTab.tsx b/src/pages/Jobfair/Outdoorfair/Detail/components/FairInfoTab.tsx index 534d2a5..3062542 100644 --- a/src/pages/Jobfair/Outdoorfair/Detail/components/FairInfoTab.tsx +++ b/src/pages/Jobfair/Outdoorfair/Detail/components/FairInfoTab.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { history, useAccess } from '@umijs/max'; -import { Button, Card, Descriptions, Image, Tag, message } from 'antd'; +import { Button, Card, Descriptions, Divider, Image, Tag, message } from 'antd'; import { FormOutlined } from '@ant-design/icons'; import type { OutdoorFairDictForm, OutdoorFairItem } from '@/services/jobportal/outdoorFair'; import { addOutdoorFairDictOption, updateOutdoorFair } from '@/services/jobportal/outdoorFair'; @@ -157,6 +157,23 @@ const FairInfoTab: React.FC = ({ fairInfo, onFairUpdate }) => { '--' )} + +
{fairInfo.fairIntroduction || '--'}
+
+ +
{fairInfo.fairProcess || '--'}
+
+ + + 企业参会专属信息(仅报名企业可见) + + + +
{fairInfo.requiredMaterials || '--'}
+
+ +
{fairInfo.attendanceNotes || '--'}
+
diff --git a/src/pages/Jobfair/Outdoorfair/components/CompanyBoothMapModal.tsx b/src/pages/Jobfair/Outdoorfair/components/CompanyBoothMapModal.tsx new file mode 100644 index 0000000..c61fd0f --- /dev/null +++ b/src/pages/Jobfair/Outdoorfair/components/CompanyBoothMapModal.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Empty, Modal, Space, Spin, Tag, Tooltip, message } from 'antd'; +import { + getCurrentCompanyOutdoorFairBoothMap, + type OutdoorFairCompanyBoothMap, +} from '@/services/jobportal/outdoorFair'; +import type { VenueBoothItem } from '@/services/jobportal/venueInfo'; + +interface Props { + fairId?: number; + open: boolean; + onCancel: () => void; +} + +const BOOTH_WIDTH = 76; +const BOOTH_HEIGHT = 48; +const BOOTH_PADDING = 44; + +const CompanyBoothMapModal: React.FC = ({ fairId, open, onCancel }) => { + const [loading, setLoading] = useState(false); + const [mapData, setMapData] = useState(); + + useEffect(() => { + if (!open || !fairId) { + return; + } + let cancelled = false; + const loadBooths = async () => { + setLoading(true); + try { + const res = await getCurrentCompanyOutdoorFairBoothMap(fairId); + if (cancelled) { + return; + } + if (res.code === 200 && res.data) { + setMapData(res.data); + } else { + setMapData(undefined); + message.error(res.msg || '展位图加载失败'); + } + } catch { + if (!cancelled) { + setMapData(undefined); + message.error('展位图加载失败'); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + void loadBooths(); + return () => { + cancelled = true; + }; + }, [fairId, open]); + + const canvasSize = useMemo(() => { + const booths = mapData?.booths || []; + const maxX = booths.reduce((max, booth) => Math.max(max, booth.positionX || 0), 0); + const maxY = booths.reduce((max, booth) => Math.max(max, booth.positionY || 0), 0); + return { + width: Math.max(920, maxX + BOOTH_WIDTH + BOOTH_PADDING), + height: Math.max(480, maxY + BOOTH_HEIGHT + BOOTH_PADDING), + }; + }, [mapData?.booths]); + + const renderBooth = (booth: VenueBoothItem, index: number) => { + const reserved = booth.status === 'reserved'; + const isCurrentCompany = reserved && booth.companyId === mapData?.currentCompanyId; + const color = isCurrentCompany ? '#f5222d' : reserved ? '#52c41a' : '#1890ff'; + const stateText = isCurrentCompany ? '本企业' : reserved ? '已预定' : '未预定'; + const tooltip = reserved ? `${booth.boothNumber}:${booth.companyName || stateText}` : `${booth.boothNumber}:未预定`; + return ( + +
+
{booth.boothNumber}
+
{stateText}
+
+
+ ); + }; + + return ( + + + + 本企业展位 + 其他已预定展位 + 未预定展位 + 展位图仅供查看,不能编辑或调整。 + + {mapData?.booths?.length ? ( +
+
+ {mapData.booths.map(renderBooth)} +
+
+ ) : ( + !loading && + )} +
+
+ ); +}; + +export default CompanyBoothMapModal; diff --git a/src/pages/Jobfair/Outdoorfair/components/CompanyFairDetailModal.tsx b/src/pages/Jobfair/Outdoorfair/components/CompanyFairDetailModal.tsx new file mode 100644 index 0000000..cd3312b --- /dev/null +++ b/src/pages/Jobfair/Outdoorfair/components/CompanyFairDetailModal.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Descriptions, Divider, Image, Modal, Tag } from 'antd'; +import type { OutdoorFairItem } from '@/services/jobportal/outdoorFair'; + +interface Props { + fair?: OutdoorFairItem | null; + open: boolean; + onCancel: () => void; +} + +const content = (value?: string) =>
{value || '--'}
; + +const CompanyFairDetailModal: React.FC = ({ fair, open, onCancel }) => ( + + {fair && ( + <> + + + {fair.title || '--'} + + {fair.hostUnit || '--'} + {fair.fairType || '--'} + {fair.region || '--'} + {fair.venueName || '--'} + + {fair.address || '--'} + + {fair.boothCount ?? '--'} + {fair.reservedBoothCount ?? 0} + {fair.holdTime || '--'} + {fair.endTime || '--'} + {fair.applyStartTime || '--'} + {fair.applyEndTime || '--'} + + {fair.onlineApply ? '是' : '否'} + + + {fair.photoUrl ? ( + {fair.title} + ) : ( + '--' + )} + + + {content(fair.fairIntroduction)} + + + {content(fair.fairProcess)} + + + + 企业参会专属信息 + + + {content(fair.requiredMaterials)} + {content(fair.attendanceNotes)} + + + )} + +); + +export default CompanyFairDetailModal; diff --git a/src/pages/Jobfair/Outdoorfair/components/EditModal.tsx b/src/pages/Jobfair/Outdoorfair/components/EditModal.tsx index cd5d6bf..7a9e10a 100644 --- a/src/pages/Jobfair/Outdoorfair/components/EditModal.tsx +++ b/src/pages/Jobfair/Outdoorfair/components/EditModal.tsx @@ -6,6 +6,7 @@ import { ProFormSwitch, ProFormSelect, ProFormText, + ProFormTextArea, } from '@ant-design/pro-components'; import { Button, Divider, Form, Image, Input, message, Modal, Space, Upload } from 'antd'; import { PlusOutlined, UploadOutlined } from '@ant-design/icons'; @@ -141,13 +142,36 @@ const EditModal: React.FC = ({ return ( { - await onSubmit({ ...formValues, id: values?.id } as OutdoorFairForm); + const payload = { ...formValues, id: values?.id } as OutdoorFairForm; + const venueChanged = + !!values?.id && + values.venueId !== undefined && + Number(values.venueId) !== Number(payload.venueId); + if (venueChanged && (values?.boothCount || 0) > 0) { + return new Promise((resolve) => { + Modal.confirm({ + title: '确认更换场地并重建展位图?', + content: + '当前招聘会已有展位图。确认后将删除原展位图和所有企业预定,再按新场地的展位模板重新生成;此操作不可恢复。', + okText: '确认重建', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + await onSubmit({ ...payload, resetBoothMap: true }); + resolve(true); + }, + onCancel: () => resolve(false), + }); + }); + } + await onSubmit(payload); + return true; }} >