Files
offerpai_web/src/views/JobDetail.vue
T
2026-05-06 15:07:44 +08:00

631 lines
25 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="bg-white p20 border-ra20">
<div class="bg-main p20 border-ra20">
<!-- 顶部操作栏 -->
<div class="job-detail__toolbar">
<button class="job-detail__close-btn" @click="goBack" aria-label="关闭">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__close-icon">
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<div class="job-detail__toolbar-right">
<button class="job-detail__tool-btn" aria-label="编辑" @click="openDislikeDialog">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__tool-icon">
<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="{ 'job-detail__tool-btn--liked': job.isFavorite }" aria-label="收藏" @click="toggleFavorite">
<svg viewBox="0 0 16 16" :fill="job.isFavorite ? 'currentColor' : 'none'" class="job-detail__tool-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"/>
</svg>
</button>
<button class="job-detail__apply-btn" @click="handleApply">去投递</button>
</div>
</div>
<!-- 内容区域可滚动 -->
<div class="job-detail__body">
<!-- 导航 Tab岗位详情 / 公司概况 -->
<div class="job-detail__nav-tabs">
<div
class="job-detail__nav-tab"
:class="{ 'job-detail__nav-tab--active': activeSection === 'job' }"
@click="activeSection = 'job'"
>岗位详情</div>
<div
class="job-detail__nav-tab"
:class="{ 'job-detail__nav-tab--active': activeSection === 'company' }"
@click="scrollToCompany"
>公司概况</div>
<div class="job-detail__nav-tab-right">
<span class="job-detail__link-btn" @click="handleFeedback">问题反馈</span>
<span class="job-detail__link-btn" @click="handleReport">原链接</span>
</div>
</div>
<!-- 岗位详情内容 -->
<template v-if="activeSection === 'job'">
<!-- 公司 & 职位头部 -->
<div class="job-detail__card">
<div class="job-detail__card-top">
<div class="job-detail__card-left">
<div class="job-detail__company-row">
<div class="job-detail__company-icon">
<img v-if="job.companyLogoUrl" :src="job.companyLogoUrl" :alt="job.company" class="job-detail__company-logo-img" />
<svg v-else viewBox="0 0 24 24" fill="none" class="job-detail__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"/>
</svg>
</div>
<span class="job-detail__company-name">{{ job.company }}</span>
</div>
<h3 class="job-detail__job-title">{{ job.title }}</h3>
<div class="job-detail__job-meta">
<span class="job-detail__meta-item">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__meta-icon">
<circle cx="8" cy="6.5" r="2.5" stroke="currentColor" stroke-width="1.2"/>
<path d="M8 14s-5-4-5-7.5a5 5 0 0110 0C13 10 8 14 8 14z" stroke="currentColor" stroke-width="1.2"/>
</svg>
{{ job.location }}
</span>
<span class="job-detail__meta-item">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__meta-icon">
<rect x="2" y="3" width="12" height="11" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
<path d="M2 6.5h12" stroke="currentColor" stroke-width="1.2"/>
</svg>
{{ job.experience }}
</span>
<span class="job-detail__meta-item">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__meta-icon">
<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="1.2"/>
<path d="M5 8h6M8 5v6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
{{ job.type }}
</span>
<span v-if="job.education" class="job-detail__meta-item">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__meta-icon">
<path d="M8 2L1 6l7 4 7-4-7-4z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M3 7.5v4l5 3 5-3v-4" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
</svg>
{{ job.education }}
</span>
<span v-if="job.salary" class="job-detail__meta-item">
<svg viewBox="0 0 16 16" fill="none" class="job-detail__meta-icon">
<path d="M8 1v14M4 4h8M3 8h10M5 12h6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
{{ job.salary }}
</span>
</div>
</div>
<!-- 匹配度环 -->
<div class="job-detail__match-area">
<div class="job-detail__match-ring">
<svg viewBox="0 0 80 80" class="job-detail__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="#4FC2C9"
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="job-detail__match-score">{{ job.matchScore }}%</div>
</div>
<div class="job-detail__match-label">岗位匹配值</div>
<div class="job-detail__match-details">
<div class="job-detail__match-item" v-for="m in matchItems" :key="m.label">
<div class="job-detail__match-mini-ring">
<svg viewBox="0 0 40 40" class="job-detail__mini-ring-svg">
<circle cx="20" cy="20" r="16" stroke-width="3" stroke="#E8E8E8" fill="none" opacity="0.3"/>
<circle cx="20" cy="20" r="16" stroke-width="3" fill="none"
stroke="#BFBFBF"
stroke-linecap="round"
:stroke-dasharray="2 * Math.PI * 16"
:stroke-dashoffset="2 * Math.PI * 16 * (1 - m.score / 100)"
transform="rotate(-90 20 20)"
/>
</svg>
<span class="job-detail__mini-score">{{ m.score }}%</span>
</div>
<span class="job-detail__match-item-label">{{ m.label }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 优化简历提示 -->
<div class="job-detail__optimize-bar">
<span>优化简历大幅提升面试成功率</span>
<button class="job-detail__optimize-btn" @click="handleGenerateResume">生成岗位专属简历</button>
</div>
<!-- 岗位描述 -->
<div class="job-detail__card">
<p class="job-detail__desc-text">{{ job.companyInfo.summary }}</p>
<div class="job-detail__tag-list">
<span v-for="tag in job.companyInfo.tags" :key="tag" class="job-detail__tag">{{ tag }}</span>
</div>
</div>
<!-- 岗位职责 -->
<div class="job-detail__card">
<h3 class="job-detail__section-title">岗位职责</h3>
<ol class="job-detail__list">
<li v-for="(item, i) in job.responsibilities" :key="i">{{ item }}</li>
</ol>
</div>
<!-- 任职要求 -->
<div class="job-detail__card">
<div class="job-detail__section-header">
<h3 class="job-detail__section-title">任职要求</h3>
<span class="job-detail__skill-hint">
<!-- 深色为你拥有的技能-->
</span>
</div>
<div class="job-detail__skill-tags">
<!-- 先不加点击选中 @click="skill.matched = !skill.matched"-->
<span
v-for="skill in job.requiredSkills"
:key="skill.name"
class="job-detail__skill-tag cursor-po"
:class="{ 'job-detail__skill-tag--matched': skill.matched }"
>
{{ skill.name }}
<svg v-if="skill.matched" viewBox="0 0 12 12" fill="none" class="job-detail__skill-close">
<path d="M9 3L3 9M3 3l6 6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</span>
</div>
<ol class="job-detail__list">
<li v-for="(item, i) in job.requirements" :key="i">{{ item }}</li>
</ol>
</div>
<!-- 加分项 -->
<div class="job-detail__card">
<h3 class="job-detail__section-title">加分项</h3>
<p class="job-detail__desc-text">{{ job.bonus }}</p>
</div>
<!-- 公司概况 -->
<div ref="companySectionRef" class="job-detail__card">
<h3 class="job-detail__section-title">公司概况</h3>
<div class="job-detail__company-info">
<div class="job-detail__company-info-left">
<div class="job-detail__company-header">
<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 24 24" fill="none" class="job-detail__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"/>
</svg>
</div>
<span class="job-detail__company-info-name">{{ job.company }}</span>
</div>
<p class="job-detail__company-desc">{{ job.companyInfo.description }}</p>
</div>
<div class="job-detail__company-info-right pr50">
<div class="job-detail__company-meta-item">
<span class="job-detail__meta-label">成立时间</span>
<span>{{ job.companyInfo.founded }}</span>
</div>
<div class="job-detail__company-meta-item">
<span class="job-detail__meta-label">公司地址</span>
<span>{{ job.companyInfo.address }}</span>
</div>
<div class="job-detail__company-meta-item">
<span class="job-detail__meta-label">企业规模</span>
<span>{{ job.companyInfo.size }}</span>
</div>
<div class="job-detail__company-meta-item">
<span class="job-detail__meta-label">官网</span>
<a :href="job.companyInfo.website" target="_blank" class="job-detail__company-link">{{ job.companyInfo.website }}</a>
</div>
</div>
</div>
</div>
<!-- 融资 -->
<div class="job-detail__card">
<h3 class="job-detail__section-title">融资</h3>
<p class="job-detail__desc-text">
<span class="job-detail__funding-label">当前融资阶段</span>{{ job.companyInfo.fundingStage }}
<span class="job-detail__funding-label" style="margin-left: 0.3rem;">最新估值</span>{{ job.companyInfo.valuation }}
</p>
</div>
<!-- 最新动态 -->
<div class="job-detail__card" v-if="job.companyInfo.news.length">
<h3 class="job-detail__section-title">最新动态</h3>
<div class="job-detail__news-list">
<div v-for="(news, i) in job.companyInfo.news" :key="i" class="job-detail__news-item">
<p class="job-detail__news-desc">{{ news }}</p>
</div>
</div>
</div>
</template>
<!-- 公司概况 Tab 已移除点击直接滚动到岗位详情中的公司概况部分 -->
</div>
</div>
</div>
</div>
<AiChat :job-id="jobId" />
<!-- 职位不感兴趣反馈弹窗 -->
<JobDislikeDialog ref="dislikeDialogRef" v-model="showDislikeDialog" :job-id="jobId" />
<!-- 职位问题反馈弹窗 -->
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="jobId" />
<!-- 岗位专属简历定制弹窗 -->
<JobResumeCustomDialog v-model="showResumeCustomDialog" :job-info="resumeCustomJobInfo" :job-id="jobId" @skip="handleSkipToApply" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useStore } from 'vuex'
import SideNav from '@/components/SideNav.vue'
import AiChat from '@/components/AiChat.vue'
import JobPageHeader from '@/components/JobPageHeader.vue'
import JobDislikeDialog from '@/components/JobDislikeDialog.vue'
import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
import JobResumeCustomDialog from '@/components/JobResumeCustomDialog.vue'
import { fetchJobDetail, toggleJobFavorite, removeJobFavorite, fetchSkillGap } from '@/api/jobs'
import type { JobDetailData, SkillGapData } from '@/api/jobs'
// ==================== 路由相关 ====================
const router = useRouter()
const route = useRoute()
const store = useStore()
/** 当前岗位 ID,从路由参数中获取 */
const jobId = route.params.id as string
// ==================== 工具函数 ====================
/** 工作类型映射:数字 → 中文 */
function formatEmploymentType(type: number | undefined): string {
const map: Record<number, string> = { 0: '全职', 1: '兼职' }
return map[type ?? -1] ?? '未知'
}
/** 学历要求映射:数字 → 中文 */
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(/[;]$/, ''))
}
// ==================== 页面状态 ====================
/** 当前激活的内容区 Tabjob-岗位详情 / company-公司概况 */
const activeSection = ref<'job' | 'company'>('job')
/** 公司概况卡片 ref,用于滚动定位 */
const companySectionRef = ref<HTMLElement | null>(null)
/** 是否正在加载 */
const loading = ref(false)
/** 点击公司概况 tab 时,确保显示岗位详情内容并滚动到公司概况卡片 */
function scrollToCompany() {
activeSection.value = 'job'
nextTick(() => {
companySectionRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
// ==================== 岗位详情数据 ====================
/** 匹配度维度列表(岗位匹配环下方的子维度) */
const matchItems = ref([
{ label: '教育背景', score: 0 },
{ label: '核心技能', score: 0 },
{ label: '过往经历', score: 0 },
])
/** 技能标签项(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.employmentType)
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()
})
// ==================== 事件处理 ====================
/** 不感兴趣弹窗状态 */
const showDislikeDialog = ref(false)
const dislikeDialogRef = ref<InstanceType<typeof JobDislikeDialog> | null>(null)
/** 问题反馈弹窗状态 */
const showFeedbackDialog = ref(false)
const feedbackDialogRef = ref<InstanceType<typeof JobFeedbackDialog> | null>(null)
/** 打开不感兴趣弹窗 */
function openDislikeDialog() {
dislikeDialogRef.value?.resetForm()
showDislikeDialog.value = true
}
/** 返回职位列表页 */
function goBack() {
// 使用 back() 回退,保证 Jobs 页能正确恢复缓存
// 如果没有历史记录(直接访问详情页),则 push 到列表页
if (window.history.length > 1) {
router.back()
} else {
router.push('/jobs')
}
}
/** 问题反馈 */
function handleFeedback() {
feedbackDialogRef.value?.resetForm()
showFeedbackDialog.value = true
}
/** 跳转到原链接 */
function handleReport() {
if (job.sourceUrl) {
window.open(job.sourceUrl, '_blank')
}
}
// ==================== 岗位专属简历定制弹窗 ====================
/** 简历定制弹窗显隐 */
const showResumeCustomDialog = 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 || [],
keywords: skillGapData.value?.job?.skillTags || job.requiredSkills.map(s => s.name),
sourceUrl: job.sourceUrl,
/** 默认简历信息(来自 skill-gap 接口) */
defaultResume: skillGapData.value?.resume || null,
}))
/** 生成岗位专属简历 — 调用 skill-gap 接口后打开定制弹窗 */
async function handleGenerateResume() {
const loadingInstance = ElLoading.service({
text: '正在分析岗位匹配度...',
background: 'rgba(0, 0, 0, 0.5)',
})
try {
const res = await fetchSkillGap(jobId)
if (res.code === 0 && res.data) {
skillGapData.value = res.data
}
} catch (e) {
console.error('技能差距分析失败', e)
ElMessage.error('分析失败,请稍后重试')
return
} finally {
loadingInstance.close()
}
showResumeCustomDialog.value = true
}
/** 不优化直接投递 */
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>