1512 lines
57 KiB
Vue
1512 lines
57 KiB
Vue
<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" />
|
||
|
||
<!-- 筛选条件 -->
|
||
<div v-if="isRecommendTab" class="jobs-page__filters-bar" :style="showAiRecommendTip?'':'margin-bottom:0.2rem'">
|
||
<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根据你的个人背景建议的<template v-if="aiCategoryNames">岗位:{{ aiCategoryNames }}</template><template v-if="aiIndustryNames">;行业:{{ aiIndustryNames }}</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)" 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="matchRingColor(job.matchScore)" 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" />
|
||
|
||
<!-- 选择投递方式弹窗 -->
|
||
<div v-if="showAgentRemindDialog" class="jobs-page__apply-method-overlay" @click.self="closeAgentRemind">
|
||
<div class="jobs-page__apply-method-dialog">
|
||
<!-- 渐变背景层 -->
|
||
<div class="jobs-page__apply-method-bg"></div>
|
||
<!-- 标题 -->
|
||
<p class="jobs-page__apply-method-title">选择投递方式</p>
|
||
<!-- 关闭按钮 -->
|
||
<span class="jobs-page__apply-method-close" @click="closeAgentRemind">
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6l12 12" stroke="#6A7282" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||
</span>
|
||
<!-- 两个选项卡 -->
|
||
<div class="jobs-page__apply-method-options">
|
||
<!-- 直接前往官网 -->
|
||
<div class="jobs-page__apply-method-card" :class="{ 'jobs-page__apply-method-card--active': applyMethodChoice === 'direct' }" @click="applyMethodChoice = 'direct'">
|
||
<p class="jobs-page__apply-method-card-title">直接前往官网</p>
|
||
<p class="jobs-page__apply-method-card-desc">手动填写网申信息<br>自行记录投递进度</p>
|
||
</div>
|
||
<!-- 智能投递助手 -->
|
||
<div class="jobs-page__apply-method-card" :class="{ 'jobs-page__apply-method-card--active': applyMethodChoice === 'agent' }" @click="applyMethodChoice = 'agent'">
|
||
<!-- 左下角圆形装饰 -->
|
||
<div class="jobs-page__apply-method-card-circle"></div>
|
||
<div class="jobs-page__apply-method-card-header">
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 1L10 5.5L15 6.5L11.5 10L12.5 15L8 12.5L3.5 15L4.5 10L1 6.5L6 5.5L8 1Z" fill="#52CAD1"/></svg>
|
||
<span class="jobs-page__apply-method-card-title jobs-page__apply-method-card-title--accent">智能投递助手</span>
|
||
</div>
|
||
<p class="jobs-page__apply-method-card-desc">{{ agentCardDesc }}</p>
|
||
</div>
|
||
</div>
|
||
<!-- 底部操作按钮 -->
|
||
<button class="jobs-page__apply-method-action" :class="applyMethodActionClass" :disabled="!applyMethodChoice" @click="handleApplyMethodAction">
|
||
<svg v-if="applyMethodActionIcon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<g clip-path="url(#clip0_166_1554)">
|
||
<path d="M6.0714 3.63204C6.47007 2.46537 8.08207 2.43004 8.55474 3.52604L8.59474 3.63271L9.13274 5.20604C9.25603 5.56686 9.45527 5.89705 9.71702 6.17432C9.97877 6.4516 10.2969 6.66952 10.6501 6.81337L10.7947 6.86737L12.3681 7.40471C13.5347 7.80337 13.5701 9.41537 12.4747 9.88804L12.3681 9.92804L10.7947 10.466C10.4338 10.5893 10.1035 10.7885 9.82608 11.0502C9.54869 11.312 9.33067 11.6302 9.18674 11.9834L9.13274 12.1274L8.5954 13.7014C8.19674 14.868 6.58474 14.9034 6.11274 13.808L6.0714 13.7014L5.53407 12.128C5.41086 11.7671 5.21165 11.4368 4.9499 11.1594C4.68814 10.882 4.36993 10.664 4.01674 10.52L3.87274 10.466L2.2994 9.92871C1.13207 9.53004 1.09674 7.91804 2.19274 7.44604L2.2994 7.40471L3.87274 6.86737C4.23356 6.74408 4.56374 6.54484 4.84102 6.28309C5.11829 6.02134 5.33621 5.70317 5.48007 5.35004L5.53407 5.20604L6.0714 3.63204ZM7.3334 4.06271L6.79607 5.63604C6.60833 6.18624 6.30292 6.68891 5.90109 7.10904C5.49927 7.52917 5.0107 7.85666 4.4694 8.06871L4.30274 8.12937L2.7294 8.66671L4.30274 9.20404C4.85294 9.39178 5.35561 9.6972 5.77573 10.099C6.19586 10.5008 6.52335 10.9894 6.7354 11.5307L6.79607 11.6974L7.3334 13.2707L7.87074 11.6974C8.05848 11.1472 8.36389 10.6445 8.76571 10.2244C9.16753 9.80425 9.65611 9.47676 10.1974 9.26471L10.3641 9.20471L11.9374 8.66671L10.3641 8.12937C9.81387 7.94163 9.3112 7.63622 8.89107 7.2344C8.47094 6.83258 8.14345 6.34401 7.9314 5.80271L7.8714 5.63604L7.3334 4.06271ZM12.6667 1.33337C12.7915 1.33337 12.9137 1.36836 13.0195 1.43436C13.1253 1.50035 13.2105 1.59471 13.2654 1.70671L13.2974 1.78471L13.5307 2.46871L14.2154 2.70204C14.3404 2.7445 14.45 2.82312 14.5302 2.92792C14.6105 3.03272 14.6578 3.15899 14.6663 3.29072C14.6747 3.42246 14.6438 3.55373 14.5775 3.6679C14.5113 3.78207 14.4126 3.874 14.2941 3.93204L14.2154 3.96404L13.5314 4.19737L13.2981 4.88204C13.2555 5.00699 13.1769 5.11651 13.072 5.1967C12.9672 5.2769 12.8409 5.32417 12.7092 5.33252C12.5774 5.34088 12.4462 5.30994 12.3321 5.24363C12.2179 5.17732 12.1261 5.07862 12.0681 4.96004L12.0361 4.88204L11.8027 4.19804L11.1181 3.96471C10.9931 3.92224 10.8835 3.84363 10.8033 3.73883C10.723 3.63403 10.6756 3.50776 10.6672 3.37603C10.6588 3.24429 10.6897 3.11302 10.7559 2.99885C10.8222 2.88468 10.9208 2.79275 11.0394 2.73471L11.1181 2.70271L11.8021 2.46937L12.0354 1.78471C12.0804 1.65299 12.1654 1.53865 12.2786 1.45771C12.3918 1.37677 12.5276 1.33329 12.6667 1.33337Z" fill="white"/>
|
||
</g>
|
||
<defs>
|
||
<clipPath id="clip0_166_1554">
|
||
<rect width="16" height="16" fill="white"/>
|
||
</clipPath>
|
||
</defs>
|
||
</svg>
|
||
{{ applyMethodActionText }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 会员购买弹窗 -->
|
||
<MemberDialog v-model="showMemberDialog" />
|
||
|
||
</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 MemberDialog from '@/components/MemberDialog.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 { fetchIndustryTree, fetchJobCategoryTree } from '@/api/common'
|
||
import type { IndustryItem, JobCategoryItem } from '@/api/common'
|
||
import { fetchAgentConfig, applyJob } from '@/api/agent'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
|
||
// 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¬ify_url=http%3A%2F%2F39.108.134.218%3A10106%2Fapi%2F%2Fpublic%2FalipayNotify%2Fpay&version=1.0&app_id=2018062160374941&sign_type=RSA2×tamp=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 推荐提示条专用:完整分类树(含空分类) ====== */
|
||
const fullIndustryTree = ref<IndustryItem[]>([])
|
||
const fullJobCategoryTree = ref<JobCategoryItem[]>([])
|
||
|
||
/** 加载完整分类树数据(includeEmpty=true),仅用于 AI 推荐提示条 ID 转名称 */
|
||
async function loadFullCategoryTrees() {
|
||
const [indRes, catRes] = await Promise.all([
|
||
fetchIndustryTree({ includeEmpty: true }),
|
||
fetchJobCategoryTree({ includeEmpty: true }),
|
||
])
|
||
if (indRes && indRes.code === '0' && indRes.data) {
|
||
fullIndustryTree.value = indRes.data
|
||
}
|
||
if (catRes && catRes.code === '0' && catRes.data) {
|
||
fullJobCategoryTree.value = catRes.data
|
||
}
|
||
}
|
||
|
||
/** 从完整行业树中根据 ID 查找名称 */
|
||
function resolveFullIndustryName(id: string): string {
|
||
for (const parent of fullIndustryTree.value) {
|
||
if (String(parent.id) === id) return parent.name
|
||
for (const child of parent.children) {
|
||
if (String(child.id) === id) return child.name
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
|
||
/** 从完整岗位树中根据 ID 查找名称 */
|
||
function resolveFullCategoryName(id: string): string {
|
||
for (const lv1 of fullJobCategoryTree.value) {
|
||
if (String(lv1.id) === id) return lv1.name
|
||
for (const lv2 of lv1.children) {
|
||
if (String(lv2.id) === id) return lv2.name
|
||
if (lv2.children) {
|
||
for (const lv3 of lv2.children) {
|
||
if (String(lv3.id) === id) return lv3.name
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
|
||
/** AI 推荐的行业名称(ID 转文字,基于完整行业树) */
|
||
const aiIndustryNames = computed<string>(() => {
|
||
if (!aiIndustryIds.value.length || !fullIndustryTree.value.length) return ''
|
||
const idSet = new Set(aiIndustryIds.value.map(String))
|
||
const names: string[] = []
|
||
for (const id of aiIndustryIds.value) {
|
||
// 如果该 ID 是一级父节点且子节点也在列表中,跳过父级避免重复
|
||
const parent = fullIndustryTree.value.find(p => String(p.id) === id)
|
||
if (parent && parent.children?.some(c => idSet.has(String(c.id)))) {
|
||
continue
|
||
}
|
||
const name = resolveFullIndustryName(id)
|
||
if (name) names.push(name)
|
||
}
|
||
return names.join('、')
|
||
})
|
||
|
||
/** AI 推荐的岗位名称(ID 转文字,基于完整岗位树) */
|
||
const aiCategoryNames = computed<string>(() => {
|
||
if (!aiCategoryIds.value.length || !fullJobCategoryTree.value.length) return ''
|
||
const idSet = new Set(aiCategoryIds.value.map(String))
|
||
const names: string[] = []
|
||
for (const id of aiCategoryIds.value) {
|
||
// 如果该 ID 是一级父节点且下级子节点也在列表中,跳过
|
||
const lv1 = fullJobCategoryTree.value.find(p => String(p.id) === id)
|
||
if (lv1 && lv1.children?.some(c => idSet.has(String(c.id)) || c.children?.some(leaf => idSet.has(String(leaf.id))))) {
|
||
continue
|
||
}
|
||
// 如果该 ID 是二级节点且三级子节点也在列表中,跳过
|
||
let isParentWithChild = false
|
||
for (const cat of fullJobCategoryTree.value) {
|
||
const lv2 = cat.children?.find(c => String(c.id) === id)
|
||
if (lv2 && lv2.children?.some(leaf => idSet.has(String(leaf.id)))) {
|
||
isParentWithChild = true
|
||
break
|
||
}
|
||
}
|
||
if (isParentWithChild) continue
|
||
const name = resolveFullCategoryName(id)
|
||
if (name) names.push(name)
|
||
}
|
||
return names.join('、')
|
||
})
|
||
|
||
/** 是否显示 AI 推荐提示条 */
|
||
const showAiRecommendTip = computed(() => {
|
||
return isRecommendTab.value && (aiIndustryNames.value !== null || aiCategoryNames.value !== null)
|
||
})
|
||
|
||
/** 行业选择变更回调 */
|
||
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
|
||
}
|
||
}
|
||
|
||
/** 跳转到原链接 — 选择投递方式弹窗 */
|
||
/**
|
||
* 旧的提醒弹窗频率逻辑(已注销,保留不删):
|
||
* 1. 未登录用户:直接跳转外部链接,不弹窗
|
||
* 2. 已登录用户首次点击(无 ai_agent_remind_first 缓存):弹窗提醒,记录第一次提醒时间
|
||
* 3. 有第一次记录但无第二次记录:
|
||
* - 距第一次记录 ≤ 1小时:直接跳转,不弹窗
|
||
* - 距第一次记录 > 1小时:弹窗提醒,记录第二次提醒时间
|
||
* 4. 有第二次记录:
|
||
* - 距第二次记录 ≤ 7天:直接跳转,不弹窗
|
||
* - 距第二次记录 > 7天:弹窗提醒,更新第二次记录时间为当前时间
|
||
*/
|
||
// function handleReportOld(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) {
|
||
// setTimeEvent('ai_agent_remind_first', userId, timestampToLocalDateTime(now))
|
||
// showAgentRemindDialog.value = true; return
|
||
// }
|
||
// if (!secondTime) {
|
||
// const firstTimestamp = new Date(firstTime).getTime()
|
||
// const diffHours = (now - firstTimestamp) / (1000 * 60 * 60)
|
||
// if (diffHours <= 1) { window.open(url, '_blank') }
|
||
// else { setTimeEvent('ai_agent_remind_second', userId, timestampToLocalDateTime(now)); showAgentRemindDialog.value = true }
|
||
// return
|
||
// }
|
||
// const secondTimestamp = new Date(secondTime).getTime()
|
||
// const diffDays = (now - secondTimestamp) / (1000 * 60 * 60 * 24)
|
||
// if (diffDays <= 7) { window.open(url, '_blank') }
|
||
// else { setTimeEvent('ai_agent_remind_second', userId, timestampToLocalDateTime(now)); showAgentRemindDialog.value = true }
|
||
// }
|
||
|
||
/** 点击"去投递"按钮 — 每次都弹出选择投递方式弹窗 */
|
||
function handleReport(job: JobListItem) {
|
||
// 暂存当前岗位信息
|
||
pendingApplyUrl.value = job.sourceUrl
|
||
pendingApplyJobId.value = job.id
|
||
// 重置选项状态
|
||
applyMethodChoice.value = null
|
||
// 每次触发都展示弹窗
|
||
showAgentRemindDialog.value = true
|
||
// 异步加载 AI 助手配置状态
|
||
loadAgentConfigStatus()
|
||
}
|
||
|
||
// ==================== 选择投递方式弹窗状态 ====================
|
||
|
||
/** 弹窗是否显示 */
|
||
const showAgentRemindDialog = ref(false)
|
||
|
||
/** 暂存待跳转的外部链接 */
|
||
const pendingApplyUrl = ref('')
|
||
|
||
/** 暂存当前触发弹窗的岗位 ID */
|
||
const pendingApplyJobId = ref('')
|
||
|
||
/** 用户在弹窗中选择的投递方式:null=未选择, 'direct'=直接前往官网, 'agent'=智能投递助手 */
|
||
const applyMethodChoice = ref<'direct' | 'agent' | null>(null)
|
||
|
||
/** AI助手是否已完成配置(status === 1) */
|
||
const agentConfigReady = ref(false)
|
||
|
||
/** 是否正在加载 AI 助手配置 */
|
||
const agentConfigLoading = ref(false)
|
||
|
||
/** 会员购买弹窗显示状态 */
|
||
const showMemberDialog = ref(false)
|
||
|
||
/** 用户会员状态 — 从 store 读取 */
|
||
const memberStatus = computed(() => store.state.memberStatus)
|
||
|
||
/** 用户是否是会员(正式或试用) */
|
||
const isMember = computed(() => !!memberStatus.value?.isMember)
|
||
|
||
/** 智能投递助手选项卡内的描述文字 — 根据会员和配置状态动态显示 */
|
||
const agentCardDesc = computed(() => {
|
||
if (!isMember.value) {
|
||
return '自动填写网申信息\n统一管理投递进度'
|
||
}
|
||
if (!agentConfigReady.value) {
|
||
return '完成助手设置后\n可使用智能投递'
|
||
}
|
||
return '将该岗位加入待投递列表\n在投递助手中统一处理'
|
||
})
|
||
|
||
/** 底部操作按钮文字 */
|
||
const applyMethodActionText = computed(() => {
|
||
if (!applyMethodChoice.value) return '请选择投递方式'
|
||
if (applyMethodChoice.value === 'direct') return '去官网投递'
|
||
// agent 分支
|
||
if (!isMember.value) return '开通会员使用'
|
||
if (!agentConfigReady.value) return '去完成设置'
|
||
return '加入待投递列表'
|
||
})
|
||
|
||
/** 底部操作按钮是否显示 icon */
|
||
const applyMethodActionIcon = computed(() => {
|
||
if (!applyMethodChoice.value) return false
|
||
if (applyMethodChoice.value === 'direct') return false
|
||
// agent 分支都有 icon
|
||
return true
|
||
})
|
||
|
||
/** 底部操作按钮样式 class */
|
||
const applyMethodActionClass = computed(() => {
|
||
if (!applyMethodChoice.value) return 'jobs-page__apply-method-action--disabled'
|
||
return 'jobs-page__apply-method-action--active'
|
||
})
|
||
|
||
/** 加载 AI 助手配置状态 */
|
||
async function loadAgentConfigStatus() {
|
||
if (agentConfigLoading.value) return
|
||
agentConfigLoading.value = true
|
||
try {
|
||
const res = await fetchAgentConfig()
|
||
if (res.code === '0' && res.data && res.data.status === 1) {
|
||
agentConfigReady.value = true
|
||
} else {
|
||
agentConfigReady.value = false
|
||
}
|
||
} catch {
|
||
agentConfigReady.value = false
|
||
} finally {
|
||
agentConfigLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 关闭选择投递方式弹窗 */
|
||
function closeAgentRemind() {
|
||
showAgentRemindDialog.value = false
|
||
pendingApplyUrl.value = ''
|
||
pendingApplyJobId.value = ''
|
||
}
|
||
|
||
/** 底部操作按钮点击处理 */
|
||
async function handleApplyMethodAction() {
|
||
if (!applyMethodChoice.value) return
|
||
|
||
if (applyMethodChoice.value === 'direct') {
|
||
// 直接前往官网
|
||
showAgentRemindDialog.value = false
|
||
if (pendingApplyUrl.value) {
|
||
window.open(pendingApplyUrl.value, '_blank')
|
||
}
|
||
pendingApplyUrl.value = ''
|
||
pendingApplyJobId.value = ''
|
||
return
|
||
}
|
||
|
||
// 智能投递助手分支
|
||
if (!isMember.value) {
|
||
// 非会员 — 打开会员购买弹窗
|
||
showAgentRemindDialog.value = false
|
||
showMemberDialog.value = true
|
||
pendingApplyUrl.value = ''
|
||
pendingApplyJobId.value = ''
|
||
return
|
||
}
|
||
|
||
if (!agentConfigReady.value) {
|
||
// 有会员但没完成 AI 助手配置 — 跳转 Agent 页面
|
||
showAgentRemindDialog.value = false
|
||
pendingApplyUrl.value = ''
|
||
pendingApplyJobId.value = ''
|
||
router.push('/agent')
|
||
return
|
||
}
|
||
|
||
// 有会员且配置完成 — 加入待投递列表
|
||
try {
|
||
const res = await applyJob({ jobId: pendingApplyJobId.value, status: -1 })
|
||
if (res.code === '0') {
|
||
ElMessage.success('已加入待投递列表')
|
||
} else {
|
||
ElMessage.error(res.msg || '加入待投递列表失败')
|
||
}
|
||
} catch {
|
||
ElMessage.error('加入待投递列表失败')
|
||
}
|
||
showAgentRemindDialog.value = false
|
||
pendingApplyUrl.value = ''
|
||
pendingApplyJobId.value = ''
|
||
}
|
||
|
||
onMounted(async () => {
|
||
document.addEventListener('click', closeDropdownOnClickOutside)
|
||
// 窗口 resize 时更新尖角位置
|
||
window.addEventListener('resize', updateAiTipArrowPosition)
|
||
|
||
// 加载完整分类树(含空分类),用于 AI 推荐提示条 ID 转名称
|
||
loadFullCategoryTrees()
|
||
|
||
// 先判断是否有缓存(从详情页返回),在任何异步操作之前取出
|
||
const cache = store.state.jobListCache
|
||
const hasCache = !!(cache && cache.list.length > 0 && cache.tab === activeTab.value)
|
||
|
||
if (hasCache) {
|
||
// 立即恢复缓存数据,隐藏列表避免闪烁
|
||
restoring.value = true
|
||
jobList.value = cache!.list
|
||
pageNum.value = cache!.pageNum
|
||
total.value = cache!.total
|
||
savedScrollTop = cache!.scrollTop
|
||
}
|
||
// 无论是否命中缓存,都清除,避免下次非详情页返回时误用
|
||
if (cache) {
|
||
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
|
||
}
|
||
|
||
// 同步 tab 到 URL query,刷新浏览器时能恢复正确的 Tab
|
||
const query = { ...route.query }
|
||
if (newTab === 'recommend') {
|
||
delete query.tab
|
||
} else {
|
||
query.tab = newTab
|
||
}
|
||
router.replace({ query })
|
||
|
||
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) {
|
||
if (score >= 80) return 'jobs-page__job-match--high'
|
||
if (score >= 40) return 'jobs-page__job-match--medium'
|
||
return 'jobs-page__job-match--low'
|
||
}
|
||
|
||
/** 根据匹配分数返回环形颜色 */
|
||
function matchRingColor(score: number) {
|
||
if (score >= 80) return '#9FD051'
|
||
if (score >= 40) return '#F59E0B'
|
||
return '#FE6D52'
|
||
}
|
||
|
||
/** 根据匹配分数返回匹配等级文案 */
|
||
function matchLevelText(score: number) {
|
||
if (score >= 80) return '高匹配度'
|
||
if (score >= 40) return '中匹配度'
|
||
return '低匹配度'
|
||
}
|
||
|
||
/** 跳转到岗位详情页 — 未登录弹登录框,登录后再跳转 */
|
||
function goToDetail(job: JobItem) {
|
||
if (!isAuthenticated.value) {
|
||
store.dispatch('openLogin', `/jobs/${job.id}`)
|
||
return
|
||
}
|
||
// 保存当前列表状态到 store,返回时恢复
|
||
store.commit('SET_JOB_LIST_CACHE', {
|
||
tab: activeTab.value,
|
||
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>
|