个人资料和岗位列表联调,简历优化部分页面

This commit is contained in:
2026-04-01 11:41:51 +08:00
parent 0468339d23
commit 821a950df2
42 changed files with 5935 additions and 748 deletions
+568 -129
View File
@@ -3,9 +3,28 @@
<SideNav />
<div class="jobs-page__content">
<!-- 页面标题 + Tab 切换 -->
<JobPageHeader v-model:activeTab="activeTab" />
<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 class="jobs-page__filters-bar">
<div v-if="isRecommendTab" class="jobs-page__filters-bar">
<div class="jobs-page__filters">
<div class="jobs-page__filter-group">
<!-- 筛选条件按钮列表行业单独用组件 -->
@@ -18,20 +37,24 @@
:maxSelect="3"
@update:regionCodes="onRegionChange"
/>
<!-- 行业筛选使用行业选择组件 -->
<IndustrySelector
v-else-if="filter.key === 'industry'"
:industryIds="selectedIndustryIds"
:maxSelect="3"
@update:industryIds="onIndustryChange"
/>
<!-- 岗位筛选使用岗位选择组件 -->
<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
@@ -62,21 +85,22 @@
</template>
</div>
<div class="jobs-page__search-box">
<svg class="jobs-page__search-svg" viewBox="0 0 16 16" fill="none">
<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="searchText"
v-model="keyword"
class="jobs-page__search-input"
placeholder="搜索职位、公司或关键词"
@keyup.enter="reloadFirstPage"
/>
</div>
</div>
</div>
<!-- 职位列表 -->
<div ref="jobListRef" class="jobs-page__list pr5" @scroll="onListScroll">
<div ref="jobListRef" class="jobs-page__list pr5" :style="restoring ? { visibility: 'hidden' } : {}" @scroll="onListScroll">
<div
v-for="(job, index) in jobList"
:key="index"
@@ -87,105 +111,124 @@
<div class="jobs-page__job-main">
<!-- 左侧公司图标 + 职位信息 -->
<div class="jobs-page__job-left">
<div class="jobs-page__job-icon">
<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 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>
</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"/>
<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>
{{ (job as any).tip }}
</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 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>
<!-- 底部操作栏 -->
<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="dflex-end mt10">
<div 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="收藏">
<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">
<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 class="jobs-page__job-apply-btn" :class="{ 'jobs-page__job-apply-btn--active': job.applied }">
自动投递
</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 v-if="job.showMenu" class="jobs-page__job-popup" @click.stop>
<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>
>{{ action }}</div>
</div>
</div>
</div>
<!-- 右侧匹配度 -->
<div class="jobs-page__job-match" :class="matchClass(job.matchScore)">
<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)"
/>
<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-score">{{ job.matchScore }}%</div>
</div>
<div class="jobs-page__match-label">匹配值</div>
<div class="jobs-page__match-level">{{ matchLevelText(job.matchScore) }}</div>
<div class="jobs-page__match-lock-text">登录查看匹配值</div>
</template>
</div>
</div>
</div>
@@ -197,7 +240,7 @@
<AiChat />
<!-- 职位不感兴趣反馈弹窗 -->
<JobDislikeDialog ref="dislikeDialogRef" v-model="showDislikeDialog" :job-id="dislikeJobId" />
<JobDislikeDialog ref="dislikeDialogRef" v-model="showDislikeDialog" :job-id="dislikeJobId" @disliked="removeDislikedJob" />
<!-- 职位问题反馈弹窗 -->
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="feedbackJobId" />
@@ -216,8 +259,8 @@ 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 } from '@/api/jobs'
import type { JobListItem, JobListParams } from '@/api/jobs'
import { fetchJobList, fetchFavoriteList, toggleJobFavorite, removeJobFavorite, fetchFavoriteCount, fetchApplyList, fetchApplyCount, removeJobFromList } from '@/api/jobs'
import type { JobListItem, JobListParams, FavoriteListParams, ApplyListParams, ApplyCountData } from '@/api/jobs'
// ==================== 路由相关 ====================
@@ -225,13 +268,19 @@ 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 searchText = ref('')
const keyword = ref('')
/** 当前选中的职位卡片索引 */
const selectedIndex = ref(0)
@@ -248,6 +297,78 @@ 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)
}
}
// ==================== 常量数据 ====================
/** 筛选条件项类型 */
@@ -274,31 +395,48 @@ const jobTypeOptions: { label: string; value: number }[] = [
/** 工作类型下拉菜单是否显示 */
const showJobTypeDropdown = ref(false)
/** 当前选中的工作类型integer,对应接口参数 employmentTypenull 表示未选) */
const selectedEmploymentType = ref<number | null>(null)
/** 当前选中的工作类型 — 直接读 store.jobIntention.employmentType */
const selectedEmploymentType = computed<number | null>(
() => store.state.jobIntention.employmentType ?? null,
)
/** 选中的行业 id 数组integer,对应接口参数 industryIds */
const selectedIndustryIds = ref<number[]>([])
/** 选中的行业 id 数组 — 直接读 store.jobIntention.industryIds */
const selectedIndustryIds = computed<number[]>(
() => store.state.jobIntention.industryIds || [],
)
/** 选中的岗位 id 数组integer,对应接口参数 categoryIds */
const selectedCategoryIds = ref<number[]>([])
/** 选中的岗位 id 数组 — 直接读 store.jobIntention.categoryIds */
const selectedCategoryIds = computed<number[]>(
() => store.state.jobIntention.categoryIds || [],
)
/** 选中的地区编码数组string,对应接口参数 regionCodes */
const selectedRegionCodes = ref<string[]>([])
/** 选中的地区编码数组 — 直接读 store.jobIntention.regionCodes */
const selectedRegionCodes = computed<string[]>(
() => store.state.jobIntention.regionCodes || [],
)
/** 行业选择变更回调 */
function onIndustryChange(ids: number[]) {
selectedIndustryIds.value = ids
store.dispatch('saveJobIntention', {
...store.state.jobIntention,
industryIds: ids,
})
}
/** 岗位选择变更回调 */
function onCategoryChange(ids: number[]) {
selectedCategoryIds.value = ids
store.dispatch('saveJobIntention', {
...store.state.jobIntention,
categoryIds: ids,
})
}
/** 地区选择变更回调 */
function onRegionChange(codes: string[]) {
selectedRegionCodes.value = codes
store.dispatch('saveJobIntention', {
...store.state.jobIntention,
regionCodes: codes,
})
}
/** 点击筛选条件按钮 — 仅工作类型展开下拉 */
@@ -311,10 +449,22 @@ function handleFilterClick(filter: FilterItem) {
/** 选中工作类型选项 */
function selectJobType(filter: FilterItem, option: { label: string; value: number }) {
filter.selected = option.label
selectedEmploymentType.value = option.value
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
@@ -323,30 +473,66 @@ function closeDropdownOnClickOutside(e: MouseEvent) {
}
}
onMounted(() => {
/** 跳转到原链接 */
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')
// 尝试从缓存恢复列表数据(从详情页返回时不重新请求)
const cache = store.state.jobListCache
if (cache && cache.list.length > 0) {
jobList.value = cache.list
pageNum.value = cache.pageNum
total.value = cache.total
savedScrollTop = cache.scrollTop
// 清除缓存,避免下次非详情页返回时误用
store.commit('SET_JOB_LIST_CACHE', null)
// 等 DOM 渲染完成后恢复滚动位置
// 仅登录状态下才加载需要鉴权的数据
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 {
// 没有缓存,正常加载
loadJobList()
loadCurrentTab()
}
// 初始化完成,允许 watcher 正常工作
// 使用双重 nextTick 确保 loadJobIntention 引起的所有响应式更新都已 flush
nextTick(() => {
nextTick(() => {
initializing = false
})
})
})
onBeforeUnmount(() => {
@@ -394,6 +580,12 @@ const jobListRef = ref<HTMLElement | null>(null)
/** 记录离开页面前的滚动位置 */
let savedScrollTop = 0
/** 初始化标志 — onMounted 期间为 true,防止 watcher 误触发 reloadFirstPage */
let initializing = true
/** 恢复缓存中 — 为 true 时列表容器 visibility:hidden,避免滚动位置闪烁 */
const restoring = ref(false)
// ==================== 加载岗位列表 ====================
/** 组装请求参数 */
@@ -418,6 +610,10 @@ function buildParams(): JobListParams {
if (selectedEmploymentType.value !== null) {
params.employmentType = selectedEmploymentType.value
}
// 搜索关键词
if (keyword.value.trim()) {
params.keyword = keyword.value.trim()
}
return params
}
@@ -474,7 +670,13 @@ function onListScroll() {
// 距离底部小于 100px 时触发加载
const threshold = 100
if (el.scrollHeight - el.scrollTop - el.clientHeight < threshold) {
loadNextPage()
if (isRecommendTab.value) {
loadNextPage()
} else if (activeTab.value === 'collected') {
loadFavoriteNextPage()
} else if (activeTab.value === 'applied') {
loadApplyNextPage()
}
}
}
@@ -483,15 +685,173 @@ function reloadFirstPage() {
pageNum.value = 1
// 筛选条件变化时清除缓存
store.commit('SET_JOB_LIST_CACHE', null)
loadJobList()
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],
() => reloadFirstPage(),
() => {
// 初始化阶段(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()
}
})
// ==================== 事件处理 ====================
/** 切换职位卡片的弹出菜单(同时关闭其他已打开的菜单) */
@@ -501,14 +861,46 @@ function toggleMenu(index: number) {
})
}
/** 处理弹出菜单操作项点击 — 问题反馈打开反馈弹窗,其他直接关闭菜单 */
/** 处理弹出菜单操作项点击 */
function handlePopupAction(action: string, job: JobItem) {
job.showMenu = false
if (action === '问题反馈') {
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'
@@ -521,8 +913,12 @@ function matchLevelText(score: number) {
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,
@@ -537,6 +933,39 @@ function goToDetail(job: JobItem) {
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
@@ -544,6 +973,16 @@ function openDislikeDialog(job: JobItem) {
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