Files
offerpai_web/src/views/Jobs.vue
T
2026-07-03 23:00:37 +08:00

1290 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="jobs-page">
<SideNav />
<div class="jobs-page__content">
<div v-if="false" class="fs18 p20 bgeee">
支付
<iframe
v-if="paymentFormHtml"
:srcdoc="paymentFormHtml"
frameborder="0"
style="width: 100%; height: 600px;"
scrolling="auto"
></iframe>
</div>
<!-- 页面标题 + Tab 切换 -->
<JobPageHeader v-model:activeTab="activeTab" :favoriteCount="favoriteTotal" :applyCount="applyTotal" />
<!-- 筛选条件 -->
<div v-if="isRecommendTab" class="jobs-page__filters-bar">
<div class="jobs-page__filters">
<div ref="filterGroupRef" class="jobs-page__filter-group">
<!-- 筛选条件按钮列表行业单独用组件 -->
<template v-for="filter in filters" :key="filter.label">
<!-- 城市筛选使用地区选择组件选到市级 -->
<RegionSelector
ref="regionSelectorRef"
v-if="filter.key === 'city'"
:regionCodes="selectedRegionCodes"
:level="2"
:maxSelect="3"
:triggerStyle="selectorTriggerStyle"
:hoverStyle="selectorHoverStyle"
:displayStyle="selectorDisplayStyle"
@update:regionCodes="onRegionChange"
/>
<!-- 岗位筛选使用岗位选择组件 -->
<JobCategorySelector
ref="jobCategorySelectorRef"
v-else-if="filter.key === 'position'"
:categoryIds="selectedCategoryIds"
:maxSelect="3"
:level="3"
:allowParentSelect="true"
:triggerStyle="selectorTriggerStyle"
:hoverStyle="selectorHoverStyle"
:displayStyle="selectorDisplayStyle"
@update:categoryIds="onCategoryChange"
/>
<!-- 行业筛选使用行业选择组件 -->
<IndustrySelector
ref="industrySelectorRef"
v-else-if="filter.key === 'industry'"
:industryIds="selectedIndustryIds"
:maxSelect="3"
:level="2"
:allowParentSelect="true"
:triggerStyle="selectorTriggerStyle"
:hoverStyle="selectorHoverStyle"
:displayStyle="selectorDisplayStyle"
@update:industryIds="onIndustryChange"
/>
<!-- 其他筛选条件 -->
<div
v-else
class="jobs-page__filter-item"
@click="handleFilterClick(filter)"
>
<span>{{ filter.selected || filter.label }}</span>
<svg class="jobs-page__filter-arrow-icon" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- 工作类型下拉菜单 -->
<div
v-if="filter.key === 'jobType' && showJobTypeDropdown"
class="jobs-page__filter-dropdown"
@click.stop
>
<div
class="jobs-page__filter-dropdown-item"
v-for="option in jobTypeOptions"
:key="option.value"
:class="{ 'jobs-page__filter-dropdown-item--active': filter.selected === option.label }"
@click.stop="selectJobType(filter, option)"
>
{{ option.label }}
</div>
</div>
</div>
</template>
</div>
<div class="jobs-page__search-box">
<svg class="jobs-page__search-svg" viewBox="0 0 16 16" fill="none" @click="reloadFirstPage">
<circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/>
<path d="M11 11L14 14" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
<input
v-model="keyword"
class="jobs-page__search-input"
placeholder="搜索职位、公司或关键词"
@keyup.enter="reloadFirstPage"
/>
</div>
</div>
</div>
<!-- 推荐的行业和岗位提示条 -->
<div v-if="showAiRecommendTip" class="jobs-page__ai-tip-wrapper">
<!-- 提示条主体 -->
<div class="jobs-page__ai-tip">
<!-- 尖角::before(边框色)::after(白色)双伪元素实现通过动态left定位 -->
<span class="jobs-page__ai-tip-arrow" :style="{ left: aiTipArrowLeft + 'px' }"></span>
<div class="jobs-page__ai-tip-content">
<img class="jobs-page__ai-tip-logo" src="@/assets/images/logo-offerpai-2.png" alt="Nova" />
<span class="jobs-page__ai-tip-text">
Nova根据你的个人背景建议的行业{{ aiIndustryNames }}<template v-if="aiCategoryNames">岗位{{ aiCategoryNames }}</template>
</span>
</div>
</div>
</div>
<!-- 职位列表 -->
<div ref="jobListRef" class="jobs-page__list pr5 scrollbar-thin" :style="restoring ? { visibility: 'hidden' } : {}" @scroll="onListScroll">
<div
v-for="(job, index) in jobList"
:key="index"
class="jobs-page__job-card"
:class="{ 'jobs-page__job-card--selected--none': selectedIndex === index }"
@click="goToDetail(job)"
>
<div class="jobs-page__job-main">
<!-- 左栏职位信息 -->
<div class="jobs-page__col-left">
<!-- 职位名称 + 收藏红心 -->
<div class="jobs-page__job-title-row">
<span class="jobs-page__job-name">{{ job.title }}</span>
<!-- 收藏红心 -->
<button v-if="isRecommendTab" class="jobs-page__favorite-btn" :class="{ 'jobs-page__favorite-btn--liked': job.isFavorite }" aria-label="收藏" @click.stop="toggleFavorite(job)">
<svg viewBox="0 0 16 16" :fill="job.isFavorite ? 'currentColor' : 'none'" class="jobs-page__favorite-svg">
<path d="M8 13.7l-1.1-1C3.6 9.8 1.5 7.9 1.5 5.7 1.5 3.9 2.9 2.5 4.7 2.5c1 0 2 .5 2.6 1.2h1.4c.6-.7 1.6-1.2 2.6-1.2 1.8 0 3.2 1.4 3.2 3.2 0 2.2-2.1 4.1-5.4 6.9L8 13.7z" stroke="currentColor" stroke-width="1"/>
</svg>
</button>
<!-- 收藏页关闭按钮 -->
<button v-if="activeTab === 'collected'" class="jobs-page__job-remove" aria-label="取消收藏" @click.stop="removeFavoriteFromList(job, index)">
<svg viewBox="0 0 16 16" fill="none" class="jobs-page__remove-svg">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- 职位标签 -->
<div class="jobs-page__job-tags">
<span v-for="(tag, ti) in job.tags" :key="ti" class="jobs-page__job-tag">{{ tag }}</span>
</div>
<!-- 底部地区 + 学历 + 招聘分类 -->
<div class="jobs-page__job-meta-bottom">
<span class="jobs-page__meta-location">
<!-- <svg viewBox="0 0 18 18" fill="none" class="jobs-page__location-icon">-->
<!-- <path d="M9 1.5C5.96 1.5 3.5 3.96 3.5 7c0 4.13 5.5 9.5 5.5 9.5s5.5-5.37 5.5-9.5c0-3.04-2.46-5.5-5.5-5.5zm0 7.5a2 2 0 110-4 2 2 0 010 4z" fill="currentColor"/>-->
<!-- </svg>-->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_217_327)">
<path d="M10.6666 6.66667C10.6666 5.19333 9.47325 4 7.99992 4C6.52659 4 5.33325 5.19333 5.33325 6.66667C5.33325 8.14 6.52659 9.33333 7.99992 9.33333C9.47325 9.33333 10.6666 8.14 10.6666 6.66667ZM6.66659 6.66667C6.66659 5.93333 7.26659 5.33333 7.99992 5.33333C8.73325 5.33333 9.33325 5.93333 9.33325 6.66667C9.33325 7.4 8.73325 8 7.99992 8C7.26659 8 6.66659 7.4 6.66659 6.66667Z" fill="#6A7282"/>
<path d="M7.61323 14.54C7.72657 14.62 7.86657 14.6667 7.9999 14.6667C8.13323 14.6667 8.27323 14.6267 8.38657 14.54C8.58657 14.3933 13.3532 10.96 13.3332 6.65999C13.3332 3.71999 10.9399 1.32666 7.9999 1.32666C5.0599 1.32666 2.66657 3.71999 2.66657 6.65999C2.64657 10.9533 7.41323 14.3933 7.61323 14.54ZM7.9999 2.66666C10.2066 2.66666 11.9999 4.45999 11.9999 6.66666C12.0132 9.62666 9.07323 12.2867 7.9999 13.16C6.92657 12.2867 3.98657 9.63333 3.9999 6.66666C3.9999 4.45999 5.79323 2.66666 7.9999 2.66666Z" fill="#6A7282"/>
</g>
<defs>
<clipPath id="clip0_217_327">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>
{{ job.regionName }}
</span>
<span v-if="formatEducation(job.education)" class="jobs-page__meta-edu">{{ formatEducation(job.education) }}</span>
<span v-if="formatEmploymentType(job.recruitCategory)" class="jobs-page__meta-recruit">{{ formatEmploymentType(job.recruitCategory) }}</span>
</div>
</div>
<!-- 中栏公司信息居中 -->
<div class="jobs-page__col-center">
<div class="jobs-page__company-info">
<!-- 公司logo + 公司名 -->
<div class="jobs-page__company-row">
<div class="jobs-page__company-logo">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="currentColor" d="M18 15h-2v2h2m0-6h-2v2h2m2 6h-8v-2h2v-2h-2v-2h2v-2h-2V9h8M10 7H8V5h2m0 6H8V9h2m0 6H8v-2h2m0 6H8v-2h2M6 7H4V5h2m0 6H4V9h2m0 6H4v-2h2m0 6H4v-2h2m6-10V3H2v18h20V7z" />
</svg>
</div>
<span class="jobs-page__company-name">{{ job.companyShortName || job.companyName }}</span>
</div>
<!-- 公司标签 -->
<div v-if="job.companyTags && job.companyTags.length" class="jobs-page__company-tags">
<span v-for="(ctag, ci) in job.companyTags" :key="ci" class="jobs-page__company-tag">{{ ctag }}</span>
</div>
</div>
</div>
<div class="dflex-end flex1">
<!-- 右栏操作按钮 + 不再显示 -->
<div class="jobs-page__col-right mr10">
<!-- 按钮行 -->
<div class="jobs-page__job-action-right">
<button class="jobs-page__job-helper" @click.stop="askAssistant(job)">问Nova</button>
<button @click.stop="handleReport(job.sourceUrl)" class="jobs-page__job-apply-btn" :class="{ 'jobs-page__job-apply-btn--active': job.applied }">去投递</button>
</div>
<!-- 不再显示 -->
<span v-if="isRecommendTab" class="jobs-page__hide-btn" @click.stop="openDislikeDialog(job)">不再显示</span>
</div>
<!-- 最右侧匹配度 -->
<div class="jobs-page__job-match" :class="isAuthenticated ? matchClass(job.matchScore) : 'jobs-page__job-match--locked'">
<template v-if="isAuthenticated">
<div class="jobs-page__match-ring">
<svg viewBox="0 0 80 80" class="jobs-page__ring-svg">
<circle cx="40" cy="40" r="34" stroke-width="5" stroke="#9FD051" fill="none" opacity="0.3"/>
<circle cx="40" cy="40" r="34" stroke-width="5" fill="none"
:stroke="matchRingColor(job.matchScore)"
stroke-linecap="round"
:stroke-dasharray="2 * Math.PI * 34"
:stroke-dashoffset="2 * Math.PI * 34 * (1 - job.matchScore / 100)"
transform="rotate(-90 40 40)"
/>
</svg>
<div class="jobs-page__match-score">{{ job.matchScore }}%</div>
</div>
<div class="jobs-page__match-level">{{ matchLevelText(job.matchScore) }}</div>
</template>
<template v-else>
<svg class="jobs-page__lock-icon" viewBox="0 0 24 24" fill="none">
<rect x="5" y="11" width="14" height="10" rx="2" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 11V7a4 4 0 118 0v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="jobs-page__match-lock-text">登录查看匹配值</div>
</template>
</div>
</div>
</div>
<!-- 弹出菜单 -->
<div v-if="isRecommendTab && job.showMenu" class="jobs-page__job-popup" @click.stop>
<div
class="jobs-page__job-popup-item"
@click.stop="handlePopupAction('从列表中移除', job)"
>不再显示</div>
</div>
</div>
<!-- 首次加载中提示 -->
<div v-if="loading" class="jobs-page__loading-box">
<div class="jobs-page__loading-spinner"></div>
<span>加载中...</span>
</div>
<!-- 暂无数据提示 -->
<div v-if="!loading && jobList.length === 0" class="jobs-page__empty">暂无数据</div>
<!-- 加载更多提示 -->
<div v-if="loadingMore" class="jobs-page__loading-box">
<div class="jobs-page__loading-spinner"></div>
<span>加载中...</span>
</div>
<div v-else-if="noMore && jobList.length > 0" class="jobs-page__loading-more">没有更多符合的职位了</div>
</div>
</div>
<AiChat :jobId="currentAskJobId" />
<!-- 职位不感兴趣反馈弹窗 -->
<JobDislikeDialog ref="dislikeDialogRef" v-model="showDislikeDialog" :job-id="dislikeJobId" @disliked="removeDislikedJob" />
<!-- 职位问题反馈弹窗 -->
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="feedbackJobId" />
<!-- AI助手投递提醒弹窗 -->
<div v-if="showAgentRemindDialog" class="jobs-page__agent-remind-overlay" @click.self="closeAgentRemind">
<div class="jobs-page__agent-remind-dialog">
<p class="jobs-page__agent-remind-text">去AI助手投递岗位效果更好哦</p>
<div class="jobs-page__agent-remind-actions">
<button class="jobs-page__agent-remind-btn jobs-page__agent-remind-btn--secondary" @click="directApply">直接投</button>
<button class="jobs-page__agent-remind-btn jobs-page__agent-remind-btn--primary" @click="goToAgent">去AI助手</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useStore } from 'vuex'
import { JOB_TYPE_OPTIONS, formatEmploymentType, formatEducation } from '@/stores/index'
import SideNav from '@/components/SideNav.vue'
import AiChat from '@/components/AiChat.vue'
import JobPageHeader from '@/components/JobPageHeader.vue'
import JobDislikeDialog from '@/components/JobDislikeDialog.vue'
import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
import IndustrySelector from '@/components/tools/IndustrySelector.vue'
import JobCategorySelector from '@/components/tools/JobCategorySelector.vue'
import RegionSelector from '@/components/tools/RegionSelector.vue'
import { fetchJobList, fetchFavoriteList, toggleJobFavorite, removeJobFavorite, fetchFavoriteCount, fetchApplyList, fetchApplyCount, removeJobFromList } from '@/api/jobs'
import type { JobListItem, JobListParams, FavoriteListParams, ApplyListParams, ApplyCountData } from '@/api/jobs'
import { getTimeEvent, setTimeEvent, timestampToLocalDateTime } from '@/utils/time'
import { resolveJobCategoryName } from '@/utils/jobCategory'
import { resolveIndustryName } from '@/utils/industry'
// 2. 注意:这里的字符串不需要再手动转义双引号了
const paymentFormHtml = ref(`
<form name="punchout_form" method="post" action="https://openapi-sandbox.dl.alipaydev.com/gateway.do?charset=UTF-8&method=alipay.trade.page.pay&sign=o8MQ7cEAh4yrf6ri5iT9gXbtR%2BTncX36p9JNZf8nmXxEp1WKO6dF1HDC6DPF8nxJbus6OG9E5UPSoiHgqrEiu3aeOs4LMcYzXXZP0UQzG%2F6pXyZrvlL70G8uwpnV6MgQojzhKheG0vYIJgSPaGsHKjwcp%2BT8yOZg3eEDIgFYEBf2a3c7kANc7%2FuOOGCSD9Ip9zqwX9LQ3b433NPH3AbGQkFSLsdGOTY62I7yI8hEF7EM7nNm%2BYryieqju21yN76JrV4k%2BgIGHn83I5oT3y7zurzdTqHLD4UawpPWdJ5B7p6wnJ4cN4uzO8JgRpNqogYqqKBPcHIlB9o4uax3bcRqhg%3D%3D&notify_url=http%3A%2F%2F39.108.134.218%3A10106%2Fapi%2F%2Fpublic%2FalipayNotify%2Fpay&version=1.0&app_id=2018062160374941&sign_type=RSA2&timestamp=2026-05-20+15%3A29%3A21&alipay_sdk=alipay-sdk-java-4.40.791.ALL&format=json">
<input type="hidden" name="biz_content" value='{"out_trade_no":"OP2057000709638877186","product_code":"FAST_INSTANT_TRADE_PAY","qr_pay_mode":"4","qrcode_width":200,"subject":"周会员","total_amount":"0.10"}'>
<input type="submit" value="立即支付" style="display:none">
</form>
<script>document.forms[0].submit();<\/script>
`)
// ==================== 路由相关 ====================
const router = useRouter()
const route = useRoute()
const store = useStore()
/** 登录状态 — 从 store 读取 */
const isAuthenticated = computed(() => store.state.isAuthenticated)
/** 用户昵称 — 从全局 store 读取 */
const nickName = computed(() => store.state.userInfo?.nick || '')
// ==================== 页面状态 ====================
/** 当前激活的 Tab,从 URL query 参数读取,默认"推荐" */
const activeTab = ref((route.query.tab as string) || 'recommend')
/** 是否为推荐列表 Tab */
const isRecommendTab = computed(() => activeTab.value === 'recommend')
/** 搜索框输入内容 */
const keyword = ref('')
/** 当前选中的职位卡片索引 */
const selectedIndex = ref(0)
/** 不感兴趣弹窗的显示状态 */
const showDislikeDialog = ref(false)
/** 当前操作的职位 ID(用于提交不感兴趣反馈) */
const dislikeJobId = ref<string | null>(null)
/** 问题反馈弹窗的显示状态 */
const showFeedbackDialog = ref(false)
/** 当前操作的职位 ID(用于提交问题反馈) */
const feedbackJobId = ref<string | null>(null)
/** 当前问助手的岗位 ID(传给 AiChat 组件) */
const currentAskJobId = ref<string>('')
/** 点击"问助手"按钮,传入岗位 ID 给 AiChat */
function askAssistant(job: JobItem) {
currentAskJobId.value = job.id
}
/** 选择器触发按钮的自定义样式,适配弹窗内布局(参考 ProfileEditDrawer 中 regionTriggerStyle */
const selectorTriggerStyle: Record<string, string> = {
width: '100%',
'box-sizing': 'border-box',
padding: '0.08rem 0.12rem',
'font-size': '0.13rem',
color: '#666',
background: '#fff',
border: '1px solid transparent',
'border-radius': '0.08rem',
'max-width': 'none',
'justify-content': 'space-between',
}
/** 选择器显示文字的自定义样式,覆盖默认 max-width: 1.6rem */
const selectorDisplayStyle: Record<string, string> = {
'max-width': 'none',
}
/** 选择器触发按钮 hover 时的样式 */
const selectorHoverStyle: Record<string, string> = {
background: '#12C7BE',
color: '#fff',
}
// ==================== 收藏统计 ====================
/** 收藏总数(用于 Tab 标签显示) */
const favoriteTotal = ref(0)
/** 有效收藏数 */
const favoriteValidCount = ref(0)
/** 失效收藏数 */
const favoriteInvalidCount = ref(0)
/** 加载收藏统计 */
async function loadFavoriteCount() {
try {
const res = await fetchFavoriteCount()
if (res.code === '0' && res.data) {
favoriteTotal.value = res.data.totalCount
favoriteValidCount.value = res.data.validCount
favoriteInvalidCount.value = res.data.invalidCount
}
} catch (e) {
console.error('加载收藏统计失败', e)
}
}
// ==================== 投递统计 ====================
/** 投递总数(用于 Tab 标签显示) */
const applyTotal = ref(0)
/** 投递统计详情 */
const applyCountData = ref<ApplyCountData>({
totalCount: 0,
appliedCount: 0,
interviewingCount: 0,
offerCount: 0,
rejectedCount: 0,
closedCount: 0,
})
/** 当前投递状态筛选(null=全部) */
const applyStatusFilter = ref<number | null>(null)
/** 投递状态 Tab 列表 */
const applyStatusTabs = computed(() => [
{ status: 0, label: '已投递', count: applyCountData.value.appliedCount },
{ status: 1, label: '面试中', count: applyCountData.value.interviewingCount },
{ status: 2, label: '有Offer', count: applyCountData.value.offerCount },
{ status: 3, label: '未通过', count: applyCountData.value.rejectedCount },
{ status: 4, label: '已结束', count: applyCountData.value.closedCount },
])
/** 切换投递状态筛选 */
function switchApplyStatus(status: number) {
applyStatusFilter.value = applyStatusFilter.value === status ? null : status
pageNum.value = 1
hasNoMoreData.value = false
jobList.value = []
total.value = 0
loadApplyList()
}
/** 加载投递统计 */
async function loadApplyCount() {
try {
const res = await fetchApplyCount()
if (res.code === '0' && res.data) {
applyCountData.value = res.data
applyTotal.value = res.data.totalCount
}
} catch (e) {
console.error('加载投递统计失败', e)
}
}
// ==================== 常量数据 ====================
/** 筛选条件项类型 */
interface FilterItem {
label: string
key: string
selected: string
}
/** 筛选条件列表 */
const filters = ref<FilterItem[]>([
{ label: '城市', key: 'city', selected: '' },
{ label: '岗位', key: 'position', selected: '' },
{ label: '行业', key: 'industry', selected: '' },
{ label: '招聘分类', key: 'jobType', selected: '' },
])
/** 招聘分类选项映射:从全局 store 常量统一引入 */
const jobTypeOptions = JOB_TYPE_OPTIONS
/** 招聘分类下拉菜单是否显示 */
const showJobTypeDropdown = ref(false)
/** 当前选中的招聘分类 — 直接读 store.jobIntention.recruitCategory */
const selectedEmploymentType = computed<number | null>(
() => store.state.jobIntention.recruitCategory ?? null,
)
/** 选中的行业 id 数组 — 直接读 store.jobIntention.industryIds */
const selectedIndustryIds = computed<number[]>(
() => store.state.jobIntention.industryIds || [],
)
/** 选中的岗位 id 数组 — 直接读 store.jobIntention.categoryIds */
const selectedCategoryIds = computed<number[]>(
() => store.state.jobIntention.categoryIds || [],
)
/** 选中的地区编码数组 — 直接读 store.jobIntention.regionCodes */
const selectedRegionCodes = computed<string[]>(
() => store.state.jobIntention.regionCodes || [],
)
/** AI 推荐的行业 ID 列表 */
const aiIndustryIds = computed<string[]>(
() => store.state.jobIntention.aiIndustryIds || [],
)
/** AI 推荐的岗位 ID 列表 */
const aiCategoryIds = computed<string[]>(
() => store.state.jobIntention.aiCategoryIds || [],
)
/** AI 推荐的行业名称(ID 转文字) */
const aiIndustryNames = computed<string>(() => {
if (!aiIndustryIds.value.length) return ''
return aiIndustryIds.value
.map(id => resolveIndustryName(Number(id)))
.filter(name => !!name)
.join('、')
})
/** AI 推荐的岗位名称(ID 转文字) */
const aiCategoryNames = computed<string>(() => {
if (!aiCategoryIds.value.length) return ''
return aiCategoryIds.value
.map(id => resolveJobCategoryName(Number(id)))
.filter(name => !!name)
.join('、')
})
/** 是否显示 AI 推荐提示条 */
const showAiRecommendTip = computed(() => {
return isRecommendTab.value && (aiIndustryNames.value || aiCategoryNames.value)
})
/** 行业选择变更回调 */
function onIndustryChange(ids: number[]) {
store.dispatch('saveJobIntention', {
...store.state.jobIntention,
industryIds: ids,
})
}
/** 岗位选择变更回调 */
function onCategoryChange(ids: number[]) {
store.dispatch('saveJobIntention', {
...store.state.jobIntention,
categoryIds: ids,
})
}
/** 地区选择变更回调 */
function onRegionChange(codes: string[]) {
store.dispatch('saveJobIntention', {
...store.state.jobIntention,
regionCodes: codes,
})
}
/** 点击筛选条件按钮 — 仅招聘分类展开下拉 */
function handleFilterClick(filter: FilterItem) {
if (filter.key === 'jobType') {
showJobTypeDropdown.value = !showJobTypeDropdown.value
}
}
/** 选中招聘分类选项 */
function selectJobType(filter: FilterItem, option: { label: string; value: number }) {
filter.selected = option.label
showJobTypeDropdown.value = false
store.dispatch('saveJobIntention', {
...store.state.jobIntention,
recruitCategory: option.value,
})
}
/** 监听 store 中 recruitCategory 变化,同步招聘分类筛选按钮的显示文字 */
watch(selectedEmploymentType, (val) => {
const jobTypeFilter = filters.value.find(f => f.key === 'jobType')
if (jobTypeFilter && val !== null) {
const matched = jobTypeOptions.find(o => o.value === val)
if (matched) jobTypeFilter.selected = matched.label
}
}, { immediate: true })
/** 点击页面其他区域时关闭下拉菜单 */
function closeDropdownOnClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.jobs-page__filter-item')) {
showJobTypeDropdown.value = false
}
}
/** 跳转到原链接 — 带 AI 助手提醒弹窗频率控制 */
/**
* 提醒弹窗频率逻辑说明:
* 1. 未登录用户:直接跳转外部链接,不弹窗
* 2. 已登录用户首次点击(无 ai_agent_remind_first 缓存):弹窗提醒,记录第一次提醒时间
* 3. 有第一次记录但无第二次记录:
* - 距第一次记录 ≤ 1小时:直接跳转,不弹窗
* - 距第一次记录 > 1小时:弹窗提醒,记录第二次提醒时间
* 4. 有第二次记录:
* - 距第二次记录 ≤ 7天:直接跳转,不弹窗
* - 距第二次记录 > 7天:弹窗提醒,更新第二次记录时间为当前时间
*/
function handleReport(url: string) {
// 暂存当前要跳转的链接
pendingApplyUrl.value = url
// 未登录直接跳转
if (!isAuthenticated.value) {
window.open(url, '_blank')
return
}
const userId = store.state.userInfo?.id
if (!userId) {
window.open(url, '_blank')
return
}
const now = Date.now()
const firstTime = getTimeEvent('ai_agent_remind_first', userId)
const secondTime = getTimeEvent('ai_agent_remind_second', userId)
if (!firstTime) {
// 情况1:从未弹过 → 弹窗,记录第一次时间
setTimeEvent('ai_agent_remind_first', userId, timestampToLocalDateTime(now))
showAgentRemindDialog.value = true
return
}
if (!secondTime) {
// 情况2:有第一次记录,无第二次记录 → 检查是否超过1小时
const firstTimestamp = new Date(firstTime).getTime()
const diffHours = (now - firstTimestamp) / (1000 * 60 * 60)
if (diffHours <= 1) {
// 1小时内不弹窗,直接跳转
window.open(url, '_blank')
} else {
// 超过1小时 → 弹窗,记录第二次时间
setTimeEvent('ai_agent_remind_second', userId, timestampToLocalDateTime(now))
showAgentRemindDialog.value = true
}
return
}
// 情况3:有第二次记录 → 检查是否超过7天
const secondTimestamp = new Date(secondTime).getTime()
const diffDays = (now - secondTimestamp) / (1000 * 60 * 60 * 24)
if (diffDays <= 7) {
// 7天内不弹窗,直接跳转
window.open(url, '_blank')
} else {
// 超过7天 → 弹窗,更新第二次记录时间
setTimeEvent('ai_agent_remind_second', userId, timestampToLocalDateTime(now))
showAgentRemindDialog.value = true
}
}
// ==================== AI助手提醒弹窗状态 ====================
/** AI助手提醒弹窗是否显示 */
const showAgentRemindDialog = ref(false)
/** 暂存待跳转的外部链接 */
const pendingApplyUrl = ref('')
/** 关闭AI助手提醒弹窗 */
function closeAgentRemind() {
showAgentRemindDialog.value = false
pendingApplyUrl.value = ''
}
/** 点击"直接投"按钮 — 关闭弹窗并跳转外部链接 */
function directApply() {
showAgentRemindDialog.value = false
if (pendingApplyUrl.value) {
window.open(pendingApplyUrl.value, '_blank')
}
pendingApplyUrl.value = ''
}
/** 点击"去AI助手"按钮 — 关闭弹窗并跳转到 Agent 页面 */
function goToAgent() {
showAgentRemindDialog.value = false
pendingApplyUrl.value = ''
router.push('/agent')
}
onMounted(async () => {
document.addEventListener('click', closeDropdownOnClickOutside)
// 窗口 resize 时更新尖角位置
window.addEventListener('resize', updateAiTipArrowPosition)
// 先判断是否有缓存(从详情页返回),在任何异步操作之前取出
const cache = store.state.jobListCache
const hasCache = !!(cache && cache.list.length > 0)
if (hasCache) {
// 立即恢复缓存数据,隐藏列表避免闪烁
restoring.value = true
jobList.value = cache!.list
pageNum.value = cache!.pageNum
total.value = cache!.total
savedScrollTop = cache!.scrollTop
// 清除缓存,避免下次非详情页返回时误用
store.commit('SET_JOB_LIST_CACHE', null)
}
// 进入 Jobs 页时加载公共工具数据(行业分类等)
store.dispatch('loadCommonData')
// 仅登录状态下才加载需要鉴权的数据
if (isAuthenticated.value) {
// 加载用户个人信息到全局 store
store.dispatch('loadUserInfo')
// 加载收藏统计(用于 Tab 标签显示)
loadFavoriteCount()
// 加载投递统计(用于 Tab 标签显示)
loadApplyCount()
// 加载求职意向数据,computed 会自动反映到筛选条件
await store.dispatch('loadJobIntention')
}
if (hasCache) {
// 从详情页返回,恢复滚动位置后再显示列表
nextTick(() => {
if (jobListRef.value) {
jobListRef.value.scrollTop = savedScrollTop
}
restoring.value = false
})
} else {
// 没有缓存,正常加载
loadCurrentTab()
}
// 初始化完成,允许 watcher 正常工作
// 使用双重 nextTick 确保 loadJobIntention 引起的所有响应式更新都已 flush
nextTick(() => {
nextTick(() => {
initializing = false
// 数据加载完成后计算尖角位置
updateAiTipArrowPosition()
})
})
})
onBeforeUnmount(() => {
document.removeEventListener('click', closeDropdownOnClickOutside)
window.removeEventListener('resize', updateAiTipArrowPosition)
})
// ==================== 职位列表项(扩展接口字段,增加前端交互状态) ====================
/** 职位列表项类型(在接口返回基础上扩展前端交互字段) */
interface JobItem extends JobListItem {
/** 是否已投递 */
applied: boolean
/** 是否显示弹出菜单 */
showMenu: boolean
}
// ==================== 分页与加载状态 ====================
/** 当前页码 */
const pageNum = ref(1)
/** 每页条数 */
const pageSize = ref(15)
/** 总记录数 */
const total = ref(0)
/** 是否正在加载(首次加载) */
const loading = ref(false)
/** 是否正在加载下一页 */
const loadingMore = ref(false)
/** 接口返回空列表时标记为无更多数据 */
const hasNoMoreData = ref(false)
/** 是否已加载全部数据 */
const noMore = computed(() => hasNoMoreData.value || (jobList.value.length >= total.value && total.value > 0))
/** 职位列表数据 */
const jobList = ref<JobItem[]>([])
/** 列表容器 ref,用于监听滚动和恢复滚动位置 */
const jobListRef = ref<HTMLElement | null>(null)
/** 岗位筛选组件 ref(保留给模板绑定) */
const jobCategorySelectorRef = ref<InstanceType<typeof JobCategorySelector> | null>(null)
/** 行业筛选组件 ref(保留给模板绑定) */
const industrySelectorRef = ref<InstanceType<typeof IndustrySelector> | null>(null)
/** 城市筛选组件 ref(保留给模板绑定) */
const regionSelectorRef = ref<InstanceType<typeof RegionSelector> | null>(null)
/** 筛选条件按钮容器 ref,用于 DOM 查询获取 trigger 宽度 */
const filterGroupRef = ref<HTMLElement | null>(null)
/** AI 提示条尖角的 left 偏移量(像素),动态对齐岗位和行业选择器中间 */
const aiTipArrowLeft = ref(120)
/** 计算尖角位置:从 filter-group 容器直接查找 trigger DOM 获取宽度 */
function updateAiTipArrowPosition() {
nextTick(() => {
const group = filterGroupRef.value
if (!group) return
const regionTrigger = group.querySelector('.region-selector__trigger') as HTMLElement | null
const categoryTrigger = group.querySelector('.job-category-selector__trigger') as HTMLElement | null
if (!regionTrigger || !categoryTrigger) return
// filter-group gap 为 0.08rem = 8px
const gap = 8
// 尖角 left = 城市trigger宽度 + 1gap + 岗位trigger宽度 + 半gap
const left = regionTrigger.offsetWidth + gap + categoryTrigger.offsetWidth + gap / 2
aiTipArrowLeft.value = left
})
}
/** 监听选择器数据变化和提示条显示状态,DOM 更新后重新计算尖角位置 */
watch(
[showAiRecommendTip, selectedCategoryIds, selectedRegionCodes,
() => store.state.jobCategories, () => store.state.regions],
() => {
updateAiTipArrowPosition()
},
{ flush: 'post', deep: true },
)
/** 记录离开页面前的滚动位置 */
let savedScrollTop = 0
/** 初始化标志 — onMounted 期间为 true,防止 watcher 误触发 reloadFirstPage */
let initializing = true
/** 恢复缓存中 — 为 true 时列表容器 visibility:hidden,避免滚动位置闪烁 */
const restoring = ref(false)
// ==================== 加载岗位列表 ====================
/** 组装请求参数 */
function buildParams(): JobListParams {
const params: JobListParams = {
pageNum: pageNum.value,
pageSize: pageSize.value,
}
// 地区筛选
if (selectedRegionCodes.value.length) {
params.regionCodes = selectedRegionCodes.value
}
// 岗位类型筛选
if (selectedCategoryIds.value.length) {
params.categoryIds = selectedCategoryIds.value
}
// 行业筛选
if (selectedIndustryIds.value.length) {
params.industryIds = selectedIndustryIds.value
}
// 招聘分类筛选
if (selectedEmploymentType.value !== null) {
params.recruitCategory = selectedEmploymentType.value
}
// 搜索关键词
if (keyword.value.trim()) {
params.keyword = keyword.value.trim()
}
return params
}
/** 加载岗位列表数据(首次加载,替换列表) */
async function loadJobList() {
loading.value = true
try {
const res = await fetchJobList(buildParams())
if (res.code === '0' && res.data) {
// 将接口数据映射为前端 JobItem,补充交互状态字段
jobList.value = res.data.list.map((item) => ({
...item,
applied: false,
showMenu: false,
}))
total.value = Number(res.data.total)
}
} catch (e) {
console.error('加载岗位列表失败', e)
} finally {
loading.value = false
}
}
/** 加载下一页数据(追加到列表末尾) */
async function loadNextPage() {
if (loadingMore.value || noMore.value) return
loadingMore.value = true
pageNum.value++
try {
const res = await fetchJobList(buildParams())
if (res.code === '0' && res.data) {
const newItems = res.data.list.map((item) => ({
...item,
applied: false,
showMenu: false,
}))
// 返回空列表时标记无更多数据,停止滚动加载
if (newItems.length === 0) {
hasNoMoreData.value = true
pageNum.value--
} else {
jobList.value.push(...newItems)
total.value = Number(res.data.total)
}
}
} catch (e) {
// 加载失败时回退页码
pageNum.value--
console.error('加载下一页失败', e)
} finally {
loadingMore.value = false
}
}
/** 列表滚动事件 — 滚动到底部时自动加载下一页 */
function onListScroll() {
const el = jobListRef.value
if (!el) return
// 距离底部小于 100px 时触发加载
const threshold = 100
if (el.scrollHeight - el.scrollTop - el.clientHeight < threshold) {
if (isRecommendTab.value) {
loadNextPage()
} else if (activeTab.value === 'collected') {
loadFavoriteNextPage()
} else if (activeTab.value === 'applied') {
loadApplyNextPage()
}
}
}
/** 筛选条件变化时重置到第一页并重新加载 */
function reloadFirstPage() {
pageNum.value = 1
hasNoMoreData.value = false
jobList.value = []
total.value = 0
// 筛选条件变化时清除缓存
store.commit('SET_JOB_LIST_CACHE', null)
loadCurrentTab()
}
/** 根据当前 Tab 加载对应列表 */
function loadCurrentTab() {
if (isRecommendTab.value) {
loadJobList()
} else if (activeTab.value === 'collected') {
loadFavoriteList()
} else if (activeTab.value === 'applied') {
loadApplyList()
}
}
/** 加载收藏列表 */
async function loadFavoriteList() {
loading.value = true
try {
const params: FavoriteListParams = {
pageNum: pageNum.value,
pageSize: pageSize.value,
valid: true,
}
const res = await fetchFavoriteList(params)
if (res.code === '0' && res.data) {
jobList.value = res.data.list.map((item) => ({
...item,
applied: false,
showMenu: false,
}))
total.value = Number(res.data.total)
}
} catch (e) {
console.error('加载收藏列表失败', e)
} finally {
loading.value = false
}
}
/** 加载收藏列表下一页 */
async function loadFavoriteNextPage() {
if (loadingMore.value || noMore.value) return
loadingMore.value = true
pageNum.value++
try {
const params: FavoriteListParams = {
pageNum: pageNum.value,
pageSize: pageSize.value,
valid: true,
}
const res = await fetchFavoriteList(params)
if (res.code === '0' && res.data) {
const newItems = res.data.list.map((item) => ({
...item,
applied: false,
showMenu: false,
}))
// 返回空列表时标记无更多数据,停止滚动加载
if (newItems.length === 0) {
hasNoMoreData.value = true
pageNum.value--
} else {
jobList.value.push(...newItems)
total.value = Number(res.data.total)
}
}
} catch (e) {
pageNum.value--
console.error('加载收藏列表下一页失败', e)
} finally {
loadingMore.value = false
}
}
// ==================== 投递列表加载 ====================
/** 加载投递列表 */
async function loadApplyList() {
loading.value = true
try {
const params: ApplyListParams = {
pageNum: pageNum.value,
pageSize: pageSize.value,
}
if (applyStatusFilter.value !== null) {
params.status = applyStatusFilter.value
}
const res = await fetchApplyList(params)
if (res.code === '0' && res.data) {
jobList.value = res.data.list.map((item) => ({
...item,
applied: true,
showMenu: false,
}))
total.value = Number(res.data.total)
}
} catch (e) {
console.error('加载投递列表失败', e)
} finally {
loading.value = false
}
}
/** 加载投递列表下一页 */
async function loadApplyNextPage() {
if (loadingMore.value || noMore.value) return
loadingMore.value = true
pageNum.value++
try {
const params: ApplyListParams = {
pageNum: pageNum.value,
pageSize: pageSize.value,
}
if (applyStatusFilter.value !== null) {
params.status = applyStatusFilter.value
}
const res = await fetchApplyList(params)
if (res.code === '0' && res.data) {
const newItems = res.data.list.map((item) => ({
...item,
applied: true,
showMenu: false,
}))
// 返回空列表时标记无更多数据,停止滚动加载
if (newItems.length === 0) {
hasNoMoreData.value = true
pageNum.value--
} else {
jobList.value.push(...newItems)
total.value = Number(res.data.total)
}
}
} catch (e) {
pageNum.value--
console.error('加载投递列表下一页失败', e)
} finally {
loadingMore.value = false
}
}
// 监听筛选条件变化,自动重新加载
watch(
[selectedRegionCodes, selectedCategoryIds, selectedIndustryIds, selectedEmploymentType],
() => {
// 初始化阶段(loadJobIntention 导致的变化)跳过,避免清掉缓存
if (initializing) return
reloadFirstPage()
// 岗位选择器宽度可能变化,重新计算尖角位置
updateAiTipArrowPosition()
},
{ deep: true },
)
// 监听 Tab 切换,重置列表状态并加载对应数据
watch(activeTab, (newTab, oldTab) => {
if (initializing) return
// 收藏和投递 Tab 需要登录
if ((newTab === 'collected' || newTab === 'applied') && !isAuthenticated.value) {
// 回退到之前的 Tab,弹登录框
nextTick(() => { activeTab.value = oldTab })
store.dispatch('openLogin')
return
}
jobList.value = []
keyword.value = ''
pageNum.value = 1
total.value = 0
hasNoMoreData.value = false
loadCurrentTab()
// 切换到收藏 Tab 时刷新统计
if (newTab === 'collected') {
loadFavoriteCount()
}
// 切换到投递 Tab 时刷新统计并重置筛选
if (newTab === 'applied') {
applyStatusFilter.value = null
loadApplyCount()
}
})
// ==================== 事件处理 ====================
/** 切换职位卡片的弹出菜单(同时关闭其他已打开的菜单) */
function toggleMenu(index: number) {
jobList.value.forEach((j, i) => {
j.showMenu = i === index ? !j.showMenu : false
})
}
/** 处理弹出菜单操作项点击 */
function handlePopupAction(action: string, job: JobItem) {
job.showMenu = false
if (action === '从列表中移除') {
handleRemoveFromList(job)
} else if (action === '复制链接') {
handleCopyLink(job)
} else if (action === '问题反馈') {
openFeedbackDialog(job)
}
}
/** 复制职位原链接到剪贴板 */
async function handleCopyLink(job: JobItem) {
try {
await navigator.clipboard.writeText(job.sourceUrl)
ElMessage.success('复制链接成功')
} catch {
ElMessage.error('复制链接失败')
}
}
/** 从推荐列表中移除职位(前端移除 + 预留接口调用) */
async function handleRemoveFromList(job: JobItem) {
try {
// TODO: 接口对接后此处会真正调用后端,当前为模拟成功
const res = await removeJobFromList(job.id)
if (res.code === '0') {
const index = jobList.value.findIndex(j => j.id === job.id)
if (index !== -1) {
jobList.value.splice(index, 1)
total.value = Math.max(0, total.value - 1)
}
ElMessage.success('已从列表中移除')
}
} catch (e) {
console.error('移除职位失败', e)
}
}
/** 根据匹配分数返回对应的 CSS 类名 */
function matchClass(score: number) {
return score >= 80 ? 'jobs-page__job-match--high' : 'jobs-page__job-match--low'
}
/** 根据匹配分数返回环形颜色 */
function matchRingColor(score: number) {
if (score >= 80) return '#9FD051'
return '#9FD051'
}
/** 根据匹配分数返回匹配等级文案 */
function matchLevelText(score: number) {
if (score >= 80) return '高匹配度'
if (score >= 60) return '一般匹配'
return '匹配度偏低'
}
/** 跳转到岗位详情页 — 未登录弹登录框,登录后再跳转 */
function goToDetail(job: JobItem) {
if (!isAuthenticated.value) {
store.dispatch('openLogin', `/jobs/${job.id}`)
return
}
// 保存当前列表状态到 store,返回时恢复
store.commit('SET_JOB_LIST_CACHE', {
list: jobList.value,
pageNum: pageNum.value,
total: total.value,
scrollTop: jobListRef.value?.scrollTop ?? 0,
})
router.push(`/jobs/${job.id}`)
}
/** 不感兴趣弹窗和问题反馈弹窗的组件引用 */
const dislikeDialogRef = ref<InstanceType<typeof JobDislikeDialog> | null>(null)
const feedbackDialogRef = ref<InstanceType<typeof JobFeedbackDialog> | null>(null)
/** 收藏/取消收藏岗位 */
async function toggleFavorite(job: JobItem) {
try {
const res = job.isFavorite
? await removeJobFavorite(job.id)
: await toggleJobFavorite(job.id)
if (res.code === '0') {
const wasFavorite = job.isFavorite
job.isFavorite = !job.isFavorite
ElMessage.success(wasFavorite ? '已取消收藏' : '收藏成功')
// 刷新收藏统计
loadFavoriteCount()
}
} catch (e) {
console.error('收藏操作失败', e)
}
}
/** 收藏列表中取消收藏并移除该项 */
async function removeFavoriteFromList(job: JobItem, index: number) {
try {
const res = await removeJobFavorite(job.id)
if (res.code === '0') {
jobList.value.splice(index, 1)
total.value = Math.max(0, total.value - 1)
ElMessage.success('已取消收藏')
loadFavoriteCount()
}
} catch (e) {
console.error('取消收藏失败', e)
}
}
/** 打开不感兴趣弹窗 — 记录当前职位 ID 并重置表单 */
function openDislikeDialog(job: JobItem) {
dislikeJobId.value = job.id
dislikeDialogRef.value?.resetForm()
showDislikeDialog.value = true
}
/** 不感兴趣提交成功后,从列表中移除该职位 */
function removeDislikedJob() {
if (!dislikeJobId.value) return
const index = jobList.value.findIndex(j => j.id === dislikeJobId.value)
if (index !== -1) {
jobList.value.splice(index, 1)
total.value = Math.max(0, total.value - 1)
}
}
/** 打开问题反馈弹窗 — 记录当前职位 ID 并重置表单 */
function openFeedbackDialog(job: JobItem) {
feedbackJobId.value = job.id
feedbackDialogRef.value?.resetForm()
showFeedbackDialog.value = true
}
</script>