Files
offerpai_web/src/views/Jobs.vue
T
2026-04-03 17:52:07 +08:00

995 lines
34 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 dflex">
<SideNav />
<div class="jobs-page__content">
<!-- 页面标题 + Tab 切换 -->
<JobPageHeader v-model:activeTab="activeTab" :favoriteCount="favoriteTotal" :applyCount="applyTotal" />
<!-- 收藏统计栏 -->
<div v-if="activeTab === 'collected'" class="jobs-page__filters-bar">
<div class="jobs-page__fav-stats">
<span class="jobs-page__fav-stats-item">有效{{ favoriteValidCount }}</span>
<span class="jobs-page__fav-stats-item">失效{{ favoriteInvalidCount }}</span>
</div>
</div>
<!-- 投递状态筛选栏 -->
<div v-if="activeTab === 'applied'" class="jobs-page__filters-bar">
<div class="jobs-page__fav-stats">
<span
v-for="tab in applyStatusTabs"
:key="tab.status"
class="jobs-page__fav-stats-item"
:class="{ 'jobs-page__fav-stats-item--active': applyStatusFilter === tab.status }"
@click="switchApplyStatus(tab.status)"
>{{ tab.label }}{{ tab.count }}</span>
</div>
</div>
<!-- 筛选条件 -->
<div v-if="isRecommendTab" class="jobs-page__filters-bar">
<div class="jobs-page__filters">
<div class="jobs-page__filter-group">
<!-- 筛选条件按钮列表行业单独用组件 -->
<template v-for="filter in filters" :key="filter.label">
<!-- 城市筛选使用地区选择组件选到市级 -->
<RegionSelector
v-if="filter.key === 'city'"
:regionCodes="selectedRegionCodes"
:level="2"
:maxSelect="3"
@update:regionCodes="onRegionChange"
/>
<!-- 岗位筛选使用岗位选择组件 -->
<JobCategorySelector
v-else-if="filter.key === 'position'"
:categoryIds="selectedCategoryIds"
:maxSelect="3"
:level="3"
:allowParentSelect="false"
@update:categoryIds="onCategoryChange"
/>
<!-- 行业筛选使用行业选择组件 -->
<IndustrySelector
v-else-if="filter.key === 'industry'"
:industryIds="selectedIndustryIds"
:maxSelect="3"
:level="2"
:allowParentSelect="false"
@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 ref="jobListRef" v-loading="loading" class="jobs-page__list pr5" :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__job-left">
<div class="dflex ">
<div class="jobs-page__job-icon mr16">
<svg viewBox="0 0 24 24" fill="none" class="jobs-page__company-svg">
<rect x="3" y="7" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
<path d="M7 7V5a2 2 0 012-2h6a2 2 0 012 2v2" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 13h18" stroke="currentColor" stroke-width="1.5"/>
<rect x="10" y="11" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1"/>
</svg>
</div>
<div class="jobs-page__job-info">
<div class="jobs-page__job-title-row">
<span class="jobs-page__job-name">{{ job.title }}</span>
<button v-if="isRecommendTab" class="jobs-page__job-more" aria-label="更多操作" @click.stop="toggleMenu(index)">
<svg viewBox="0 0 16 16" fill="currentColor" class="jobs-page__more-svg">
<circle cx="3" cy="8" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="13" cy="8" r="1.5"/>
</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-meta pt5">
<span>{{ job.regionName }}</span>
<span class="jobs-page__job-dot">·</span>
<span>{{ job.companyShortName || job.companyName }}</span>
<span class="jobs-page__job-dot">·</span>
<span>{{ job.categoryName }}</span>
</div>
<!-- 提示信息 -->
<div v-if="(job as any).tip" class="jobs-page__job-tip">
<svg viewBox="0 0 14 14" fill="none" class="jobs-page__tip-svg">
<circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1"/>
<path d="M7 6.5V10" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
<circle cx="7" cy="4.5" r="0.5" fill="currentColor"/>
</svg>
{{ (job as any).tip }}
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div class="jobs-page__job-actions pt20">
<div class="dflex fgrow2 aliite-c flex-warp" >
<!-- 标签 -->
<div class="jobs-page__job-tags mt10">
<span v-for="(tag, ti) in job.tags" :key="ti" class="jobs-page__job-tag">{{ tag }}</span>
</div>
<div class="dflex-end mt10">
<div v-if="isRecommendTab" class="jobs-page__job-action-left">
<button class="jobs-page__action-icon-btn" aria-label="不感兴趣" @click.stop="openDislikeDialog(job)">
<svg viewBox="0 0 16 16" fill="none" class="jobs-page__action-svg">
<path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
</svg>
</button>
<button class="jobs-page__action-icon-btn" :class="{ 'jobs-page__action-icon-btn--liked': job.isFavorite }" aria-label="收藏" @click.stop="toggleFavorite(job)">
<svg viewBox="0 0 16 16" :fill="job.isFavorite ? 'currentColor' : 'none'" class="jobs-page__action-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>
</div>
<div class="jobs-page__job-action-right ">
<button class="jobs-page__job-helper ml5">
<svg viewBox="0 0 14 14" fill="none" class="jobs-page__helper-svg">
<circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1"/>
<path d="M5.5 5.5a1.5 1.5 0 113 0c0 .8-.7 1-1.5 1.5V9" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
<circle cx="7" cy="11" r="0.5" fill="currentColor"/>
</svg>
问助手
</button>
<button @click="handleReport(job.sourceUrl)" class="jobs-page__job-apply-btn" :class="{ 'jobs-page__job-apply-btn--active': job.applied }">
去投递
</button>
</div>
</div>
</div>
<!-- 弹出菜单 -->
<div v-if="isRecommendTab && job.showMenu" class="jobs-page__job-popup" @click.stop>
<div
class="jobs-page__job-popup-item"
v-for="action in popupActions"
:key="action"
@click="handlePopupAction(action, job)"
>{{ action }}</div>
</div>
</div>
</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="#E8E8E8" fill="none" opacity="0.3"/>
<circle cx="40" cy="40" r="34" stroke-width="5" fill="none"
:stroke="job.matchScore >= 80 ? '#4FC2C9' : '#BFBFBF'"
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-label">匹配值</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="!loading && jobList.length === 0" class="jobs-page__empty">暂无数据</div>
<!-- 加载更多提示 -->
<div v-if="loadingMore" class="jobs-page__loading-more">加载中...</div>
<div v-else-if="noMore && jobList.length > 0" class="jobs-page__loading-more">没有更多了</div>
</div>
</div>
<AiChat />
<!-- 职位不感兴趣反馈弹窗 -->
<JobDislikeDialog ref="dislikeDialogRef" v-model="showDislikeDialog" :job-id="dislikeJobId" @disliked="removeDislikedJob" />
<!-- 职位问题反馈弹窗 -->
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="feedbackJobId" />
</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 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'
// ==================== 路由相关 ====================
const router = useRouter()
const route = useRoute()
const store = useStore()
/** 登录状态 — 从 store 读取 */
const isAuthenticated = computed(() => store.state.isAuthenticated)
// ==================== 页面状态 ====================
/** 当前激活的 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)
// ==================== 收藏统计 ====================
/** 收藏总数(用于 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
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: '' },
])
/** 工作类型选项映射:label → 接口参数 employmentType0=全职 1=实习) */
const jobTypeOptions: { label: string; value: number }[] = [
{ label: '全职', value: 0 },
{ label: '实习', value: 1 },
]
/** 工作类型下拉菜单是否显示 */
const showJobTypeDropdown = ref(false)
/** 当前选中的工作类型 — 直接读 store.jobIntention.employmentType */
const selectedEmploymentType = computed<number | null>(
() => store.state.jobIntention.employmentType ?? 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 || [],
)
/** 行业选择变更回调 */
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,
employmentType: option.value,
})
}
/** 监听 store 中 employmentType 变化,同步工作类型筛选按钮的显示文字 */
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
}
}
/** 跳转到原链接 */
function handleReport( url:string ) {
if (url) {
window.open(url, '_blank')
}
}
onMounted(async () => {
document.addEventListener('click', closeDropdownOnClickOutside)
// 先判断是否有缓存(从详情页返回),在任何异步操作之前取出
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) {
// 加载收藏统计(用于 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
})
})
})
onBeforeUnmount(() => {
document.removeEventListener('click', closeDropdownOnClickOutside)
})
/** 弹出菜单操作项 */
const popupActions = ['从列表中移除', '已投递', '复制链接', '问题反馈']
// ==================== 职位列表项(扩展接口字段,增加前端交互状态) ====================
/** 职位列表项类型(在接口返回基础上扩展前端交互字段) */
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 noMore = computed(() => jobList.value.length >= total.value && total.value > 0)
/** 职位列表数据 */
const jobList = ref<JobItem[]>([])
/** 列表容器 ref,用于监听滚动和恢复滚动位置 */
const jobListRef = ref<HTMLElement | null>(null)
/** 记录离开页面前的滚动位置 */
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.employmentType = 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,
}))
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
// 筛选条件变化时清除缓存
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,
}))
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,
}))
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()
},
{ 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
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 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>