Files
offerpai_web/src/views/JobDetail.vue
T
2026-07-08 18:58:27 +08:00

575 lines
22 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="job-detail dflex">
<SideNav />
<div class="job-detail__content">
<!-- 页面标题 + Tab 切换 -->
<JobPageHeader :activeTab="''" />
<!-- 主内容卡片 -->
<div class="job-detail__main-card">
<!-- 返回按钮 -->
<div class="job-detail__back-row">
<button class="job-detail__back-btn" @click="goBack">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__back-icon">
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>返回</span>
</button>
</div>
<!-- 内容区域可滚动 -->
<div class="job-detail__body scrollbar-thin">
<!-- 加载中骨架屏 -->
<el-skeleton :loading="loading" animated :count="1">
<template #template>
<div class="job-detail__skeleton">
<!-- 模拟岗位标题行 -->
<el-skeleton-item variant="h1" style="width: 40%; height: 0.28rem; margin-bottom: 0.16rem;" />
<!-- 模拟标签行 -->
<div style="display: flex; gap: 0.1rem; margin-bottom: 0.2rem;">
<el-skeleton-item variant="text" style="width: 0.6rem; height: 0.2rem;" />
<el-skeleton-item variant="text" style="width: 0.8rem; height: 0.2rem;" />
<el-skeleton-item variant="text" style="width: 0.5rem; height: 0.2rem;" />
</div>
<!-- 模拟信息区 -->
<el-skeleton-item variant="text" style="width: 80%; height: 0.16rem; margin-bottom: 0.12rem;" />
<el-skeleton-item variant="text" style="width: 60%; height: 0.16rem; margin-bottom: 0.24rem;" />
<!-- 模拟描述区 -->
<el-skeleton-item variant="h3" style="width: 20%; height: 0.2rem; margin-bottom: 0.16rem;" />
<el-skeleton-item variant="text" style="width: 100%; height: 0.14rem; margin-bottom: 0.1rem;" />
<el-skeleton-item variant="text" style="width: 95%; height: 0.14rem; margin-bottom: 0.1rem;" />
<el-skeleton-item variant="text" style="width: 90%; height: 0.14rem; margin-bottom: 0.1rem;" />
<el-skeleton-item variant="text" style="width: 85%; height: 0.14rem; margin-bottom: 0.1rem;" />
<el-skeleton-item variant="text" style="width: 70%; height: 0.14rem; margin-bottom: 0.24rem;" />
<!-- 模拟公司信息区 -->
<el-skeleton-item variant="h3" style="width: 25%; height: 0.2rem; margin-bottom: 0.16rem;" />
<div style="display: flex; align-items: center; gap: 0.12rem; margin-bottom: 0.12rem;">
<el-skeleton-item variant="circle" style="width: 0.4rem; height: 0.4rem;" />
<el-skeleton-item variant="text" style="width: 1.2rem; height: 0.18rem;" />
</div>
<el-skeleton-item variant="text" style="width: 100%; height: 0.14rem; margin-bottom: 0.1rem;" />
<el-skeleton-item variant="text" style="width: 80%; height: 0.14rem;" />
</div>
</template>
<template #default>
<!-- 岗位基本信息区 -->
<div class="job-detail__header-section">
<!-- 左侧岗位信息 -->
<div class="job-detail__basic-info">
<!-- 岗位标题 + 收藏 -->
<div class="job-detail__title-row">
<h1 class="job-detail__job-title">{{ job.title }}</h1>
<button class="job-detail__fav-btn" :class="{ 'job-detail__fav-btn--active': job.isFavorite }" aria-label="收藏" @click="toggleFavorite">
<svg viewBox="0 0 16 16" class="job-detail__fav-icon">
<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" fill="none"/>
</svg>
</button>
</div>
<!-- 岗位 meta 信息 -->
<div class="job-detail__meta-row">
<span>{{ job.location }}</span>
<span class="job-detail__meta-dot">·</span>
<span>{{ job.experience }}</span>
<span class="job-detail__meta-dot">·</span>
<span>{{ job.education }}</span>
<span class="job-detail__meta-dot">·</span>
<span>{{ job.salary }}</span>
</div>
<!-- 操作按钮 -->
<div class="job-detail__action-btns">
<button class="job-detail__nova-btn" @click="handleAskNova">问Nova</button>
<button class="job-detail__apply-btn" @click="handleApply">去投递</button>
</div>
</div>
<!-- 右侧匹配度卡片 -->
<div class="job-detail__match-card" :class="matchCardClass">
<div class="job-detail__match-top">
<span class="job-detail__match-score">{{ job.matchScore }}%</span>
<span class="job-detail__match-label">{{ matchLevelText }}</span>
</div>
<div class="job-detail__match-list">
<div class="job-detail__match-row" v-for="m in matchItems" :key="m.label">
<span class="job-detail__match-row-label">{{ m.label }}</span>
<span class="job-detail__match-row-score">{{ m.score }}%</span>
</div>
</div>
</div>
</div>
<!-- 优化简历提示条 -->
<div class="job-detail__optimize-bar">
<span class="job-detail__optimize-text">优化简历大幅提升面试成功率</span>
<button class="job-detail__optimize-btn" @click="handleGenerateResume">生成岗位专属简历</button>
</div>
<!-- 岗位职责 -->
<div class="job-detail__section">
<h3 class="job-detail__section-title">岗位职责</h3>
<div class="job-detail__section-list">
<div class="job-detail__section-item" v-for="(item, i) in job.responsibilities" :key="i">{{ item }}</div>
</div>
</div>
<!-- 任职要求 -->
<div class="job-detail__section">
<h3 class="job-detail__section-title">任职要求</h3>
<!-- 技能标签 -->
<div class="job-detail__skill-tags">
<span
v-for="skill in job.requiredSkills"
:key="skill.name"
class="job-detail__skill-tag"
:class="{ 'job-detail__skill-tag--matched': skill.matched }"
>{{ skill.name }}</span>
</div>
<!-- 要求列表 -->
<div class="job-detail__section-list">
<div class="job-detail__section-item" v-for="(item, i) in job.requirements" :key="i">{{ item }}</div>
</div>
<!-- 加分项 -->
<div v-if="job.bonus" class="job-detail__bonus-item">加分项{{ job.bonus }}</div>
</div>
<!-- 公司概况 -->
<div class="job-detail__section">
<h3 class="job-detail__section-title">公司概况</h3>
<div class="job-detail__company-block">
<!-- 公司 logo + 名称 + 标签 -->
<div class="job-detail__company-head">
<div class="job-detail__company-logo">
<img v-if="job.companyLogoUrl" :src="job.companyLogoUrl" :alt="job.company" class="job-detail__company-logo-img" />
<svg v-else viewBox="0 0 32 32" fill="none" class="job-detail__company-svg">
<rect x="4" y="8" width="24" height="18" rx="3" stroke="currentColor" stroke-width="1.5"/>
<path d="M10 8V6a3 3 0 013-3h6a3 3 0 013 3v2" stroke="currentColor" stroke-width="1.5"/>
</svg>
</div>
<div class="job-detail__company-name-info">
<span class="job-detail__company-name">{{ job.company }}</span>
<span class="job-detail__company-sub">{{ job.companyInfo.industryName }}{{ job.companyInfo.size ? ' · ' + job.companyInfo.size : '' }}</span>
</div>
</div>
<!-- 公司描述 -->
<p class="job-detail__company-desc">{{ job.companyInfo.description }}</p>
</div>
</div>
</template>
</el-skeleton>
</div>
</div>
</div>
<AiChat ref="aiChatRef" :job-id="jobId" />
<!-- 岗位专属简历定制弹窗 -->
<JobResumeCustomDialog v-model="showResumeCustomDialog" :job-info="resumeCustomJobInfo" :job-id="jobId" @skip="handleSkipToApply" />
<!-- 会员权限拦截弹窗 -->
<MemberAccessDialog v-model="showMemberAccessDialog" @open-member="showMemberDialog = true" />
<!-- 会员购买弹窗 -->
<MemberDialog v-model="showMemberDialog" />
<!-- 全屏加载遮罩 -->
<FullscreenLoading :visible="fullLoading" :text="fullLoadingText" title="Nova 正在帮您努力处理~" />
<!-- 岗位简历生成步骤进度遮罩 -->
<StepProgressOverlay
v-if="showStepProgress"
:title="stepProgressTitle"
:subtitle="stepProgressSubtitle"
:steps="stepProgressSteps"
:durations="stepProgressDurations"
:done="stepProgressDone"
@finished="handleStepProgressFinished"
@close="handleStepProgressClose"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useStore } from 'vuex'
import { formatEmploymentType } from '@/stores/index'
import SideNav from '@/components/SideNav.vue'
import AiChat from '@/components/AiChat.vue'
import JobPageHeader from '@/components/JobPageHeader.vue'
import JobResumeCustomDialog from '@/components/JobResumeCustomDialog.vue'
import MemberAccessDialog from '@/components/MemberAccessDialog.vue'
import FullscreenLoading from '@/components/FullscreenLoading.vue'
import MemberDialog from '@/components/MemberDialog.vue'
import StepProgressOverlay from '@/components/StepProgressOverlay.vue'
import { fetchJobDetail, toggleJobFavorite, removeJobFavorite, fetchSkillGap } from '@/api/jobs'
import { fetchMemberStatus } from '@/api/member'
import type { JobDetailData, SkillGapData } from '@/api/jobs'
// ==================== 路由相关 ====================
const router = useRouter()
const route = useRoute()
const store = useStore()
// ==================== 全屏加载状态 ====================
/** 全屏加载是否显示 */
const fullLoading = ref(false)
/** 全屏加载文案 */
const fullLoadingText = ref('')
/** 当前岗位 ID,从路由参数中获取 */
const jobId = route.params.id as string
// ==================== 工具函数 ====================
/** 工作类型映射:使用全局统一的 formatEmploymentType */
/** 学历要求映射:数字 → 中文 */
function formatEducation(edu: number | undefined): string {
const map: Record<number, string> = { 0: '不限', 1: '大专', 2: '本科', 3: '硕士', 4: '博士' }
return map[edu ?? -1] ?? '未知'
}
/** 最低工作年限格式化 */
function formatExperience(min: number | undefined): string {
if (min === undefined || min === null) return '经验不限'
if (min === 0) return '经验不限'
return `${min}年以上`
}
/**
* 将带序号的文本拆分为列表项
* 例如 "1.xxx2.yyy3.zzz" → ['xxx', 'yyy', 'zzz']
*/
function splitNumberedText(text: string | undefined): string[] {
if (!text) return []
// 按 "数字." 或 "数字、" 分割,过滤空项
const items = text.split(/\d+[.、]/).filter((s) => s.trim())
return items.map((s) => s.trim().replace(/[;]$/, ''))
}
// ==================== 页面状态 ====================
/** 当前激活的内容区 Tab(保留兼容性但不再使用) */
/** 是否正在加载 */
const loading = ref(false)
// ==================== 岗位详情数据 ====================
/** 匹配度维度列表(岗位匹配环下方的子维度) */
const matchItems = ref([
{ label: '教育背景', score: 0 },
{ label: '核心技能', score: 0 },
{ label: '过往经历', score: 0 },
])
/** 匹配度卡片动态class:根据分数区间切换高/中/低样式 */
const matchCardClass = computed(() => {
const score = job.matchScore
if (score >= 80) return 'job-detail__match-card--high'
if (score >= 40) return 'job-detail__match-card--medium'
return 'job-detail__match-card--low'
})
/** 匹配度等级文字 */
const matchLevelText = computed(() => {
const score = job.matchScore
if (score >= 80) return '高匹配度'
if (score >= 40) return '中匹配度'
return '低匹配度'
})
/** 技能标签项(matched 表示用户是否具备该技能,可点击切换) */
interface SkillTag {
/** 技能名称 */
name: string
/** 是否匹配 */
matched: boolean
}
/** 岗位详情响应式数据 */
const job = reactive({
/** 岗位 ID */
id: jobId,
/** 公司名称(优先简称) */
company: '',
/** 公司 Logo URL */
companyLogoUrl: '',
/** 岗位标题 */
title: '',
/** 地区名称 */
location: '',
/** 工作经验要求(格式化后的文案) */
experience: '',
/** 工作类型(格式化后的文案) */
type: '',
/** 学历要求(格式化后的文案) */
education: '',
/** 薪资描述 */
salary: '',
/** 匹配总分 */
matchScore: 0,
/** 岗位描述(原始文本) */
description: '',
/** 岗位标签 */
tags: [] as string[],
/** 岗位职责列表(从 description 拆分) */
responsibilities: [] as string[],
/** 技能标签 */
requiredSkills: [] as SkillTag[],
/** 任职要求列表(从 requirement 拆分) */
requirements: [] as string[],
/** 加分项 */
bonus: '',
/** 来源链接 */
sourceUrl: '',
/** 是否已收藏 */
isFavorite: false,
/** 公司信息 */
companyInfo: {
/** 公司类型 */
type: '',
/** 公司所属行业 */
industryName: '',
/** 公司简介 */
summary: '',
/** 公司描述 */
description: '',
/** 成立时间 */
founded: '',
/** 公司地址 */
address: '',
/** 公司规模 */
size: '',
/** 公司官网 */
website: '',
/** 融资状态 */
fundingStage: '',
/** 最新估值 */
valuation: '',
/** 公司标签 */
tags: [] as string[],
/** 公司新闻列表 */
news: [] as string[],
},
})
/** 将接口返回数据填充到页面响应式对象 */
function fillJobData(data: JobDetailData) {
job.id = data.jobId
job.company = data.companyShortName || data.companyName || ''
job.companyLogoUrl = data.companyLogoUrl || ''
job.title = data.jobTitle || ''
job.location = data.regionName || ''
job.experience = formatExperience(data.minExperience)
job.type = formatEmploymentType(data.recruitCategory)
job.education = formatEducation(data.education)
job.salary = data.salary || ''
job.matchScore = data.matchScore ?? 0
job.description = data.description || ''
job.tags = data.tags || []
job.sourceUrl = data.sourceUrl || ''
job.isFavorite = data.isFavorite ?? false
job.bonus = data.bonus || ''
// 岗位职责:从 description 中按序号拆分
job.responsibilities = splitNumberedText(data.description)
// 任职要求:从 requirement 中按序号拆分
job.requirements = splitNumberedText(data.requirement)
// 技能标签:默认 matched 为 false
job.requiredSkills = (data.skillTags || []).map((name) => ({ name, matched: false }))
// 匹配度详情
if (data.matchDetail) {
matchItems.value = [
{ label: '教育背景', score: data.matchDetail.educationScore ?? 0 },
{ label: '核心技能', score: data.matchDetail.skillScore ?? 0 },
{ label: '过往经历', score: data.matchDetail.experienceScore ?? 0 },
]
}
// 公司信息
job.companyInfo.type = data.companyType || ''
job.companyInfo.industryName = data.companyIndustryName || ''
job.companyInfo.summary = data.companySummary || ''
job.companyInfo.description = data.companyDescription || ''
job.companyInfo.founded = data.companyFoundedYear ? `${data.companyFoundedYear}年` : ''
job.companyInfo.address = data.companyAddress || ''
job.companyInfo.size = data.companyScale || ''
job.companyInfo.website = data.companyWebsite || ''
job.companyInfo.fundingStage = data.companyFinancingStage || ''
job.companyInfo.valuation = data.companyLatestValuation || ''
job.companyInfo.tags = data.companyTags || []
job.companyInfo.news = data.companyNews || []
}
// ==================== 加载岗位详情 ====================
/** 调用接口获取岗位详情 */
async function loadJobDetail() {
if (!jobId) return
loading.value = true
try {
const res = await fetchJobDetail(jobId)
if (res.code === '0' && res.data) {
fillJobData(res.data)
}
} catch (e) {
console.error('加载岗位详情失败', e)
} finally {
loading.value = false
}
}
onMounted(() => {
loadJobDetail()
})
// ==================== 事件处理 ====================
/** 返回职位列表页 */
function goBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push('/jobs')
}
}
/** AiChat 组件引用 */
const aiChatRef = ref<InstanceType<typeof AiChat> | null>(null)
/** 问 Nova — 触发 AI 聊天,推送提示消息 */
function handleAskNova() {
aiChatRef.value?.triggerAsk()
}
// ==================== 岗位专属简历定制弹窗 ====================
/** 简历定制弹窗显隐 */
const showResumeCustomDialog = ref(false)
/** 会员权限拦截弹窗 */
const showMemberAccessDialog = ref(false)
/** 会员购买弹窗 */
const showMemberDialog = ref(false)
/** 技能差距分析数据 */
const skillGapData = ref<SkillGapData | null>(null)
/** 传递给简历定制弹窗的岗位信息(从 skill-gap 接口获取) */
const resumeCustomJobInfo = computed(() => ({
title: skillGapData.value?.job?.title || job.title,
company: job.company,
companyLogoUrl: job.companyLogoUrl,
location: job.location,
matchScore: skillGapData.value?.score ?? +(job.matchScore / 10).toFixed(1),
missingSkills: skillGapData.value?.missingSkills || skillGapData.value?.gaps?.map(g => g.keyword) || [],
keywords: skillGapData.value?.job?.skillTags || job.requiredSkills.map(s => s.name),
sourceUrl: job.sourceUrl,
/** 差距分析列表(来自 skill-gap 接口) */
gaps: skillGapData.value?.gaps || [],
/** 默认简历信息(来自 skill-gap 接口) */
defaultResume: skillGapData.value?.resume || null,
}))
/** 生成岗位专属简历 — 先检查会员状态,非会员弹权限拦截弹窗 */
async function handleGenerateResume() {
// 先检查会员状态
try {
const statusRes = await fetchMemberStatus()
if (statusRes.code === '0' && statusRes.data && !statusRes.data.isMember) {
// 非会员 — 弹出会员权限拦截弹窗
showMemberAccessDialog.value = true
return
}
} catch {
// 接口异常时不阻断,继续执行
}
// 显示步骤进度遮罩
showStepProgress.value = true
stepProgressDone.value = false
try {
const res = await fetchSkillGap(jobId)
if (res.code === 0 && res.data) {
skillGapData.value = res.data
// 通知组件接口已完成
stepProgressDone.value = true
} else {
// 接口返回异常,关闭遮罩提示错误
showStepProgress.value = false
ElMessage.error('分析失败,请稍后重试')
}
} catch (e) {
console.error('技能差距分析失败', e)
showStepProgress.value = false
ElMessage.error('分析失败,请稍后重试')
}
}
// ==================== 步骤进度遮罩相关 ====================
/** 步骤进度遮罩是否显示 */
const showStepProgress = ref(false)
/** 接口是否已完成 */
const stepProgressDone = ref(false)
/** 步骤进度大标题 */
const stepProgressTitle = '分析简历中,请耐心等待'
/** 步骤进度副标题 */
const stepProgressSubtitle = '正在根据岗位所需技能、简历详情发现问题'
/** 步骤名称 */
const stepProgressSteps = ['岗位技能解析...', '岗位职责解析...', '简历经历技能匹配...', '优化建议最终生成...']
/** 每步模拟时长(毫秒) */
const stepProgressDurations = [2000, 2000, 2000, 2000]
/** 步骤进度走完后打开简历定制弹窗 */
function handleStepProgressFinished() {
showStepProgress.value = false
showResumeCustomDialog.value = true
}
/** 关闭步骤进度遮罩 */
function handleStepProgressClose() {
showStepProgress.value = false
}
/** 不优化直接投递 */
function handleSkipToApply() {
if (job.sourceUrl) {
window.open(job.sourceUrl, '_blank')
}
}
/** 收藏/取消收藏 */
async function toggleFavorite() {
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 ? '已取消收藏' : '收藏成功')
// 同步收藏状态到 Jobs 页面缓存,返回时无需刷新列表即可看到最新状态
const cache = store.state.jobListCache
if (cache) {
const cached = cache.list.find((j: any) => j.id === job.id)
if (cached) cached.isFavorite = job.isFavorite
}
}
} catch (e) {
console.error('收藏操作失败', e)
}
}
/** 去投递 — 跳转到来源链接 */
function handleApply() {
if (job.sourceUrl) {
window.open(job.sourceUrl, '_blank')
}
}
</script>