仓库初始化+岗位相关页面

This commit is contained in:
2026-03-24 21:06:00 +08:00
commit 0468339d23
113 changed files with 18644 additions and 0 deletions
+434
View File
@@ -0,0 +1,434 @@
<template>
<!-- 求职助手页面 准备阶段 -->
<div class="agent-page dflex">
<!-- 左侧导航栏 -->
<SideNav />
<!-- 主内容区域 -->
<div class="agent-page__content">
<!-- 个人资料编辑抽屉 -->
<ProfileEditDrawer
v-model="showEditDrawer"
:module="editModule"
:initial-data="editInitialData"
@save="handleSaveEdit"
/>
<!-- 顶部步骤导航条 -->
<div class="agent-page__steps">
<template v-for="(step, index) in steps" :key="index">
<!-- 单个步骤项 -->
<div
class="agent-page__step"
:class="{ 'agent-page__step--active': currentStep === index + 1 }"
>
<span class="agent-page__step-number">{{ index + 1 }}</span>
<span class="agent-page__step-label">{{ step }}</span>
</div>
<!-- 步骤间分隔箭头 -->
<span v-if="index < steps.length - 1" class="agent-page__step-arrow">
<svg viewBox="0 0 16 16" fill="none">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</template>
</div>
<!-- 主体内容区域 左右两栏布局 -->
<div class="agent-page__main">
<!-- ========== 第1步确认个人资料 ========== -->
<template v-if="currentStep === 1">
<!-- 左侧说明引导卡片 -->
<div class="agent-page__left">
<div class="agent-page__intro-card">
<!-- 图标 + 标题 -->
<div class="agent-page__intro-header">
<div class="agent-page__intro-icon">
<svg viewBox="0 0 24 24" fill="none">
<path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/>
</svg>
</div>
<h2 class="agent-page__intro-title">在开始之前请先确认你的个人资料是否无误</h2>
</div>
<!-- 说明文字 -->
<p class="agent-page__intro-desc">
请仔细对你的资料评估包括个人信息教育背景工作履历及技能确保资料齐全让我能顺利为你开启求职之旅
</p>
<!-- 导入个人资料入口 -->
<div class="agent-page__import-row">
<div class="agent-page__import-icon">
<svg viewBox="0 0 24 24" fill="none">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<span class="agent-page__import-text">李华的个人资料</span>
</div>
<!-- 确认并进入按钮 -->
<button class="agent-page__confirm-btn" @click="handleNext">
确认并进入
</button>
</div>
</div>
<!-- 右侧个人档案预览 -->
<div class="agent-page__right">
<div class="agent-page__profile-wrapper">
<!-- 个人档案标题 -->
<div class="agent-page__profile-title">个人档案</div>
<!-- 引用 ProfilePageContent 组件 -->
<ProfilePageContent
:profile="profile"
@edit="handleEdit"
/>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import SideNav from '@/components/SideNav.vue'
import ProfilePageContent from '@/components/ProfilePageContent.vue'
import ProfileEditDrawer from '@/components/ProfileEditDrawer.vue'
// ==================== 步骤导航数据 ====================
/** 求职助手准备阶段的5个步骤名称 */
const steps = [
'确认个人资料',
'确认目标',
'竞争力评估',
'开启自动申请',
'配置求职助手',
]
/** 当前激活的步骤序号(1-5) */
const currentStep = ref(1)
// ==================== 编辑抽屉状态 ====================
/** 编辑抽屉是否显示 */
const showEditDrawer = ref(false)
/** 当前编辑的模块名称(info / education / work 等) */
const editModule = ref('info')
/** 当前编辑模块的初始数据 */
const editInitialData = ref<Record<string, any>>({})
// ==================== 个人档案数据 ====================
// ==================== 模拟数据 ====================
const profile = ref({
name: '李华',
phone: '13600008888',
email: '[email]',
idNumber: '510201420040328',
location: '北京',
wechat: '15100001232',
/** 教育经历 — 对应数据库 bg_user_profile_education */
education: [
{
/** 学校名称 */
school: '华南科技大学',
/** 专业 */
major: '会计学/工商管理',
/** 学历类型(0=全日制 1=非全日制) */
studyType: 0,
/** 学历(1=大专 2=本科 3=硕士 4=博士) */
degree: 2,
/** 入学年份 */
startYear: 2017,
/** 毕业年份 */
endYear: 2021,
/** 描述段落,格式:[{id, text}] */
description: [
{ id: 'e1d1', text: 'GPA 3.8/4.0,主要课程包括金融工程、风险管理、量化投资等。' },
],
},
{
school: '北京理工大学',
major: '金融学/应用经济学',
studyType: 0,
degree: 3,
startYear: 2021,
endYear: 2024,
description: [
{ id: 'e2d1', text: 'GPA 3.7/4.0,研究方向为金融科技与量化交易。' },
],
},
],
/** 工作经历 — 对应数据库 bg_user_profile_work */
works: [
{
/** 公司名称 */
companyName: '华泰证券',
/** 职位 */
position: '投资经理',
/** 开始时间 */
startDate: '2024-07-01',
/** 结束时间 */
endDate: '',
/** 描述段落,格式:[{id, text}] */
description: [
{ id: 'w1d1', text: '负责新能源行业投资研究,独立完成多份行业深度研究报告,为投资决策提供数据支持。' },
{ id: 'w1d2', text: '管理投资组合,跟踪市场动态,定期输出投资策略建议和风险评估报告。' },
],
},
],
internships: [
{
/** 公司名称 */
companyName: '中信证券',
/** 职位 */
position: '投资分析师实习生',
/** 开始时间 */
startDate: '2022-06-01',
/** 结束时间 */
endDate: '2022-09-30',
/** 描述段落,格式:[{id, text}] */
description: [
{ id: 'i1d1', text: '参与编制行业研究报告,协助产品经理进行市场分析,完成多项行业深度研究报告。' },
{ id: 'i1d2', text: '参与公司新产品的市场调研,独立完成3份产品市场调研报告,为新产品策略提供数据支持。' },
{ id: 'i1d3', text: '参与团队投资研究项目的数据清洗工作,运用Python进行数据分析和可视化。' },
],
},
{
companyName: '招商证券',
position: '研究部实习生',
startDate: '2022-01-01',
endDate: '2022-05-31',
description: [
{ id: 'i2d1', text: '协助分析师研究TMT行业趋势,参与公司研究报告撰写,深入了解行业发展动态。' },
{ id: 'i2d2', text: '参与公司研究部门的数据整理,负责维护和更新行业数据库及金融模型数据。' },
{ id: 'i2d3', text: '参与量化模型的搭建与优化测试,加深了对研究分析、定量分析的理解和应用能力。' },
],
},
],
projects: [
{
/** 项目名称 */
projectName: '中石化项目',
/** 所属公司 */
companyName: '中石化集团',
/** 担任角色 */
role: '数据分析师',
/** 开始时间 */
startDate: '2023-04-01',
/** 结束时间 */
endDate: '2023-09-30',
/** 描述段落,格式:[{id, text}] */
description: [
{ id: 'p1d1', text: '参与石化行业数据分析研究,搭建行业数据监控系统,完善数据可视化展示方案。' },
{ id: 'p1d2', text: '负责行业关键指标监测,数据驱动业务决策,协助完成3份产品研究报告。' },
{ id: 'p1d3', text: '运用Python进行数据分析与可视化,加深了对行业研究、数据分析的理解。' },
],
},
{
projectName: '综合运营',
companyName: '',
role: 'TMTV运营实习',
startDate: '2022-07-01',
endDate: '2022-12-31',
description: [
{ id: 'p2d1', text: '分析市场数据并进行TMT行业深度分析,参与产品运营策略制定,完善运营数据分析体系。' },
{ id: 'p2d2', text: '负责公司新媒体平台内容策划,负责维护和更新行业数据及金融模型数据。' },
{ id: 'p2d3', text: '参与运营团队的数据分析和报告撰写,进行了深度分析,深刻认识到行业趋势的重要性。' },
],
},
],
skills: ['CET 6', 'Photoshop', 'Python'],
/** 竞赛经历 — 对应数据库 bg_user_profile_competition */
competitions: [
{
/** 竞赛名称 */
competitionName: '全国大学生创新创业大赛',
/** 获奖情况 */
award: '全国二等奖',
/** 获奖时间 */
awardDate: '2023.07.12',
/** 描述段落,格式:[{id, text}] */
description: [
{ id: 'c1d1', text: '负责中央财经大学TMT产业研究,参与研究报告撰写,负责数据分析与模型搭建。参与公司新媒体平台内容策划,与公司媒体部门协作完成多个品牌营销方案。' },
],
},
{
competitionName: '商业分析大赛',
award: '全国一等奖',
awardDate: '2023.07.12',
description: [
{ id: 'c2d1', text: '' },
],
},
],
certificates: ['CFA', 'CMA'],
})
// ==================== 事件处理方法 ====================
/** 打开编辑抽屉 — 根据模块名提取对应的初始数据传给抽屉 */
function handleEdit(section: string) {
editModule.value = section
if (section === 'info') {
// 基本信息
editInitialData.value = {
name: profile.value.name,
email: profile.value.email,
phone: profile.value.phone,
location: profile.value.location,
wechat: profile.value.wechat,
}
} else if (section === 'education') {
// 教育经历 — 深拷贝 description 避免引用污染
editInitialData.value = {
education: profile.value.education.map(edu => ({
school: edu.school,
major: edu.major,
studyType: edu.studyType,
degree: edu.degree,
startYear: edu.startYear,
endYear: edu.endYear,
description: edu.description.map(d => ({ ...d })),
})),
}
} else if (section === 'work') {
// 工作经历
editInitialData.value = {
works: profile.value.works.map(exp => ({
companyName: exp.companyName,
position: exp.position,
startDate: exp.startDate,
endDate: exp.endDate,
description: exp.description.map(d => ({ ...d })),
})),
}
} else if (section === 'internship') {
// 实习经历
editInitialData.value = {
internships: (profile.value.internships || []).map(exp => ({
companyName: exp.companyName,
position: exp.position,
startDate: exp.startDate,
endDate: exp.endDate,
description: exp.description.map(d => ({ ...d })),
})),
}
} else if (section === 'project') {
// 项目经历
editInitialData.value = {
projects: (profile.value.projects || []).map(proj => ({
projectName: proj.projectName,
companyName: proj.companyName,
role: proj.role,
startDate: proj.startDate,
endDate: proj.endDate,
description: proj.description.map(d => ({ ...d })),
})),
}
} else if (section === 'competition') {
// 竞赛经历
editInitialData.value = {
competitions: profile.value.competitions.map(comp => ({
competitionName: comp.competitionName,
award: comp.award,
awardDate: comp.awardDate,
description: comp.description.map(d => ({ ...d })),
})),
}
} else if (section === 'skills') {
// 技能
editInitialData.value = {
skills: [...profile.value.skills],
}
} else if (section === 'certificate') {
// 证书
editInitialData.value = {
certificates: [...(profile.value.certificates || [])],
}
} else {
editInitialData.value = {}
}
showEditDrawer.value = true
}
/** 保存编辑数据 — 将抽屉返回的数据按模块回写到 profile */
function handleSaveEdit(data: Record<string, any>) {
if (editModule.value === 'info') {
// 基本信息 — 直接合并
Object.assign(profile.value, data)
} else if (editModule.value === 'education') {
// 教育经历
profile.value.education = data.education.map((edu: any) => ({
school: edu.school,
major: edu.major,
studyType: edu.studyType,
degree: edu.degree,
startYear: edu.startYear,
endYear: edu.endYear,
description: edu.description.map((d: any) => ({ ...d })),
}))
} else if (editModule.value === 'work') {
// 工作经历
profile.value.works = data.works.map((work: any) => ({
companyName: work.companyName,
position: work.position,
startDate: work.startDate,
endDate: work.endDate,
description: work.description.map((d: any) => ({ ...d })),
}))
} else if (editModule.value === 'internship') {
// 实习经历
profile.value.internships = data.internships.map((intern: any) => ({
companyName: intern.companyName,
position: intern.position,
startDate: intern.startDate,
endDate: intern.endDate,
description: intern.description.map((d: any) => ({ ...d })),
}))
} else if (editModule.value === 'project') {
// 项目经历
profile.value.projects = data.projects.map((proj: any) => ({
projectName: proj.projectName,
companyName: proj.companyName,
role: proj.role,
startDate: proj.startDate,
endDate: proj.endDate,
description: proj.description.map((d: any) => ({ ...d })),
}))
} else if (editModule.value === 'competition') {
// 竞赛经历
profile.value.competitions = data.competitions.map((comp: any) => ({
competitionName: comp.competitionName,
award: comp.award,
awardDate: comp.awardDate,
description: comp.description.map((d: any) => ({ ...d })),
}))
} else if (editModule.value === 'skills') {
// 技能
profile.value.skills = [...data.skills]
} else if (editModule.value === 'certificate') {
// 证书
profile.value.certificates = [...data.certificates]
}
}
/** 处理"确认并进入"按钮点击 — 进入下一步 */
function handleNext() {
if (currentStep.value < steps.length) {
currentStep.value++
}
}
</script>
<style scoped lang="scss">
@use '../assets/styles/pages/agent';
</style>
+20
View File
@@ -0,0 +1,20 @@
<template>
<div class="home-page">
<h1>首页</h1>
<el-button type="primary" size="large" @click="router.push('/jobs')">浏览职位</el-button>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
const router = useRouter()
const store = useStore()
onMounted(() => {
// 进入首页时加载公共工具数据(行业分类等)
store.dispatch('loadCommonData')
})
</script>
+546
View File
@@ -0,0 +1,546 @@
<template>
<div class="job-detail dflex">
<SideNav />
<div class="job-detail__content">
<!-- 页面标题 + Tab 切换 -->
<JobPageHeader :activeTab="''" />
<!-- 顶部操作栏 -->
<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="收藏">
<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.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">
<svg viewBox="0 0 14 14" fill="none" class="job-detail__hint-icon">
<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>
查看您的技能与岗位要求的匹配情况
</span>
</div>
<div class="job-detail__skill-tags">
<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 }"
@click="skill.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">
<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>
<AiChat />
<!-- 职位不感兴趣反馈弹窗 -->
<JobDislikeDialog ref="dislikeDialogRef" v-model="showDislikeDialog" :job-id="jobId" />
<!-- 职位问题反馈弹窗 -->
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="jobId" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
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 { fetchJobDetail } from '@/api/jobs'
import type { JobDetailData } from '@/api/jobs'
// ==================== 路由相关 ====================
const router = useRouter()
const route = useRoute()
/** 当前岗位 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.industryScore ?? 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() {
router.push('/jobs')
}
/** 问题反馈 */
function handleFeedback() {
feedbackDialogRef.value?.resetForm()
showFeedbackDialog.value = true
}
/** 跳转到原链接 */
function handleReport() {
if (job.sourceUrl) {
window.open(job.sourceUrl, '_blank')
}
}
/** 生成岗位专属简历 */
function handleGenerateResume() {
console.log('生成岗位专属简历')
}
/** 去投递 — 跳转到来源链接 */
function handleApply() {
if (job.sourceUrl) {
window.open(job.sourceUrl, '_blank')
}
}
</script>
+553
View File
@@ -0,0 +1,553 @@
<template>
<div class="jobs-page dflex">
<SideNav />
<div class="jobs-page__content">
<!-- 页面标题 + Tab 切换 -->
<JobPageHeader v-model:activeTab="activeTab" />
<!-- 筛选条件 -->
<div class="jobs-page__filters-bar">
<div class="jobs-page__filters">
<div class="jobs-page__filter-group">
<!-- 筛选条件按钮列表行业单独用组件 -->
<template v-for="filter in filters" :key="filter.label">
<!-- 城市筛选使用地区选择组件选到市级 -->
<RegionSelector
v-if="filter.key === 'city'"
:regionCodes="selectedRegionCodes"
:level="2"
:maxSelect="3"
@update:regionCodes="onRegionChange"
/>
<!-- 行业筛选使用行业选择组件 -->
<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"
@update:categoryIds="onCategoryChange"
/>
<!-- 其他筛选条件 -->
<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">
<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"
class="jobs-page__search-input"
placeholder="搜索职位、公司或关键词"
/>
</div>
</div>
</div>
<!-- 职位列表 -->
<div ref="jobListRef" class="jobs-page__list pr5" @scroll="onListScroll">
<div
v-for="(job, index) in jobList"
:key="index"
class="jobs-page__job-card"
:class="{ 'jobs-page__job-card--selected--none': selectedIndex === index }"
@click="goToDetail(job)"
>
<div class="jobs-page__job-main">
<!-- 左侧公司图标 + 职位信息 -->
<div class="jobs-page__job-left">
<div class="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"/>
</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 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>
</div>
<!-- 弹出菜单 -->
<div v-if="job.showMenu" class="jobs-page__job-popup" @click.stop>
<div
class="jobs-page__job-popup-item"
v-for="action in popupActions"
:key="action"
@click="handlePopupAction(action, job)"
>{{ action }}</div>
</div>
</div>
</div>
</div>
<!-- 右侧匹配度 -->
<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)"
/>
</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>
</div>
</div>
<!-- 加载更多提示 -->
<div v-if="loadingMore" class="jobs-page__loading-more">加载中...</div>
<div v-else-if="noMore && jobList.length > 0" class="jobs-page__loading-more">没有更多了</div>
</div>
</div>
<AiChat />
<!-- 职位不感兴趣反馈弹窗 -->
<JobDislikeDialog ref="dislikeDialogRef" v-model="showDislikeDialog" :job-id="dislikeJobId" />
<!-- 职位问题反馈弹窗 -->
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="feedbackJobId" />
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useStore } from 'vuex'
import SideNav from '@/components/SideNav.vue'
import AiChat from '@/components/AiChat.vue'
import JobPageHeader from '@/components/JobPageHeader.vue'
import JobDislikeDialog from '@/components/JobDislikeDialog.vue'
import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
import IndustrySelector from '@/components/tools/IndustrySelector.vue'
import JobCategorySelector from '@/components/tools/JobCategorySelector.vue'
import RegionSelector from '@/components/tools/RegionSelector.vue'
import { fetchJobList } from '@/api/jobs'
import type { JobListItem, JobListParams } from '@/api/jobs'
// ==================== 路由相关 ====================
const router = useRouter()
const route = useRoute()
const store = useStore()
// ==================== 页面状态 ====================
/** 当前激活的 Tab,从 URL query 参数读取,默认"推荐" */
const activeTab = ref((route.query.tab as string) || 'recommend')
/** 搜索框输入内容 */
const searchText = 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)
// ==================== 常量数据 ====================
/** 筛选条件项类型 */
interface FilterItem {
label: string
key: string
selected: string
}
/** 筛选条件列表 */
const filters = ref<FilterItem[]>([
{ label: '城市', key: 'city', selected: '' },
{ label: '岗位', key: 'position', selected: '' },
{ label: '行业', key: 'industry', selected: '' },
{ label: '工作类型', key: 'jobType', selected: '' },
])
/** 工作类型选项映射:label → 接口参数 employmentType0=全职 1=实习) */
const jobTypeOptions: { label: string; value: number }[] = [
{ label: '全职', value: 0 },
{ label: '实习', value: 1 },
]
/** 工作类型下拉菜单是否显示 */
const showJobTypeDropdown = ref(false)
/** 当前选中的工作类型(integer,对应接口参数 employmentTypenull 表示未选) */
const selectedEmploymentType = ref<number | null>(null)
/** 选中的行业 id 数组(integer,对应接口参数 industryIds */
const selectedIndustryIds = ref<number[]>([])
/** 选中的岗位 id 数组(integer,对应接口参数 categoryIds */
const selectedCategoryIds = ref<number[]>([])
/** 选中的地区编码数组(string,对应接口参数 regionCodes */
const selectedRegionCodes = ref<string[]>([])
/** 行业选择变更回调 */
function onIndustryChange(ids: number[]) {
selectedIndustryIds.value = ids
}
/** 岗位选择变更回调 */
function onCategoryChange(ids: number[]) {
selectedCategoryIds.value = ids
}
/** 地区选择变更回调 */
function onRegionChange(codes: string[]) {
selectedRegionCodes.value = 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
selectedEmploymentType.value = option.value
showJobTypeDropdown.value = false
}
/** 点击页面其他区域时关闭下拉菜单 */
function closeDropdownOnClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.jobs-page__filter-item')) {
showJobTypeDropdown.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeDropdownOnClickOutside)
// 进入 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 渲染完成后恢复滚动位置
nextTick(() => {
if (jobListRef.value) {
jobListRef.value.scrollTop = savedScrollTop
}
})
} else {
// 没有缓存,正常加载
loadJobList()
}
})
onBeforeUnmount(() => {
document.removeEventListener('click', closeDropdownOnClickOutside)
})
/** 弹出菜单操作项 */
const popupActions = ['从列表中移除', '已投递', '复制链接', '问题反馈']
// ==================== 职位列表项(扩展接口字段,增加前端交互状态) ====================
/** 职位列表项类型(在接口返回基础上扩展前端交互字段) */
interface JobItem extends JobListItem {
/** 是否已投递 */
applied: boolean
/** 是否显示弹出菜单 */
showMenu: boolean
}
// ==================== 分页与加载状态 ====================
/** 当前页码 */
const pageNum = ref(1)
/** 每页条数 */
const pageSize = ref(15)
/** 总记录数 */
const total = ref(0)
/** 是否正在加载(首次加载) */
const loading = ref(false)
/** 是否正在加载下一页 */
const loadingMore = ref(false)
/** 是否已加载全部数据 */
const noMore = computed(() => jobList.value.length >= total.value && total.value > 0)
/** 职位列表数据 */
const jobList = ref<JobItem[]>([])
/** 列表容器 ref,用于监听滚动和恢复滚动位置 */
const jobListRef = ref<HTMLElement | null>(null)
/** 记录离开页面前的滚动位置 */
let savedScrollTop = 0
// ==================== 加载岗位列表 ====================
/** 组装请求参数 */
function buildParams(): JobListParams {
const params: JobListParams = {
pageNum: pageNum.value,
pageSize: pageSize.value,
}
// 地区筛选
if (selectedRegionCodes.value.length) {
params.regionCodes = selectedRegionCodes.value
}
// 岗位类型筛选
if (selectedCategoryIds.value.length) {
params.categoryIds = selectedCategoryIds.value
}
// 行业筛选
if (selectedIndustryIds.value.length) {
params.industryIds = selectedIndustryIds.value
}
// 工作类型筛选
if (selectedEmploymentType.value !== null) {
params.employmentType = selectedEmploymentType.value
}
return params
}
/** 加载岗位列表数据(首次加载,替换列表) */
async function loadJobList() {
loading.value = true
try {
const res = await fetchJobList(buildParams())
if (res.code === '0' && res.data) {
// 将接口数据映射为前端 JobItem,补充交互状态字段
jobList.value = res.data.list.map((item) => ({
...item,
applied: false,
showMenu: false,
}))
total.value = Number(res.data.total)
}
} catch (e) {
console.error('加载岗位列表失败', e)
} finally {
loading.value = false
}
}
/** 加载下一页数据(追加到列表末尾) */
async function loadNextPage() {
if (loadingMore.value || noMore.value) return
loadingMore.value = true
pageNum.value++
try {
const res = await fetchJobList(buildParams())
if (res.code === '0' && res.data) {
const newItems = res.data.list.map((item) => ({
...item,
applied: false,
showMenu: false,
}))
jobList.value.push(...newItems)
total.value = Number(res.data.total)
}
} catch (e) {
// 加载失败时回退页码
pageNum.value--
console.error('加载下一页失败', e)
} finally {
loadingMore.value = false
}
}
/** 列表滚动事件 — 滚动到底部时自动加载下一页 */
function onListScroll() {
const el = jobListRef.value
if (!el) return
// 距离底部小于 100px 时触发加载
const threshold = 100
if (el.scrollHeight - el.scrollTop - el.clientHeight < threshold) {
loadNextPage()
}
}
/** 筛选条件变化时重置到第一页并重新加载 */
function reloadFirstPage() {
pageNum.value = 1
// 筛选条件变化时清除缓存
store.commit('SET_JOB_LIST_CACHE', null)
loadJobList()
}
// 监听筛选条件变化,自动重新加载
watch(
[selectedRegionCodes, selectedCategoryIds, selectedIndustryIds, selectedEmploymentType],
() => reloadFirstPage(),
)
// ==================== 事件处理 ====================
/** 切换职位卡片的弹出菜单(同时关闭其他已打开的菜单) */
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 === '问题反馈') {
openFeedbackDialog(job)
}
}
/** 根据匹配分数返回对应的 CSS 类名 */
function matchClass(score: number) {
return score >= 80 ? 'jobs-page__job-match--high' : 'jobs-page__job-match--low'
}
/** 根据匹配分数返回匹配等级文案 */
function matchLevelText(score: number) {
if (score >= 80) return '匹配'
if (score >= 60) return '一般匹配'
return '匹配度偏低'
}
/** 跳转到岗位详情页 — 先缓存当前列表和滚动位置 */
function goToDetail(job: JobItem) {
// 保存当前列表状态到 store,返回时恢复
store.commit('SET_JOB_LIST_CACHE', {
list: jobList.value,
pageNum: pageNum.value,
total: total.value,
scrollTop: jobListRef.value?.scrollTop ?? 0,
})
router.push(`/jobs/${job.id}`)
}
/** 不感兴趣弹窗和问题反馈弹窗的组件引用 */
const dislikeDialogRef = ref<InstanceType<typeof JobDislikeDialog> | null>(null)
const feedbackDialogRef = ref<InstanceType<typeof JobFeedbackDialog> | null>(null)
/** 打开不感兴趣弹窗 — 记录当前职位 ID 并重置表单 */
function openDislikeDialog(job: JobItem) {
dislikeJobId.value = job.id
dislikeDialogRef.value?.resetForm()
showDislikeDialog.value = true
}
/** 打开问题反馈弹窗 — 记录当前职位 ID 并重置表单 */
function openFeedbackDialog(job: JobItem) {
feedbackJobId.value = job.id
feedbackDialogRef.value?.resetForm()
showFeedbackDialog.value = true
}
</script>
+579
View File
@@ -0,0 +1,579 @@
<template>
<div class="profile-page dflex">
<SideNav />
<div class="profile-page__content">
<!-- 个人资料编辑抽屉 -->
<ProfileEditDrawer
v-model="showEditDrawer"
:module="editModule"
:initial-data="editInitialData"
@save="handleSaveEdit"
/>
<!-- 页面标题 -->
<div class="profile-page__header">
<h2 class="profile-page__title">个人资料 <span class="profile-page__title-tip"></span></h2>
<p class="profile-page__subtitle">
设定个人资料以匹配岗位需求包括教育背景实习经历等大部分信息可以从简历中自动获取也可以手动修改
</p>
</div>
<!-- 主体区域左侧详情 + 右侧入口 -->
<div class="profile-page__body">
<!-- 个人资料内容组件 -->
<ProfilePageContent
:profile="profile"
@edit="handleEdit"
/>
<!-- 右侧入口 -->
<div class="profile-page__sidebar">
<!-- 提示卡片 -->
<div class="profile-page__tip-card">
<p class="profile-page__tip-text">
非常棒你的个人资料已经被完善了自动填写信息也已变换现在可以轻松开始投递简历啦
</p>
<button class="profile-page__tip-btn" @click="goToJobs">去投递</button>
</div>
<!-- 管理简历入口 -->
<div class="profile-page__nav-entry" @click="goToResume">
<span class="profile-page__nav-entry-icon">📄</span>
<span class="profile-page__nav-entry-text">管理我的简历</span>
<svg viewBox="0 0 16 16" fill="none" class="profile-page__nav-arrow">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import SideNav from '@/components/SideNav.vue'
import ProfileEditDrawer from '@/components/ProfileEditDrawer.vue'
import ProfilePageContent from '@/components/ProfilePageContent.vue'
import { saveProfile, fetchProfile, fetchEducation, saveEducation, fetchWork, saveWork, fetchInternship, saveInternship, fetchProject, saveProject, fetchCompetition, saveCompetition } from '@/api/profile'
import type { SaveEducationItem, SaveWorkItem, SaveProjectItem, SaveCompetitionItem } from '@/api/profile'
const router = useRouter()
const store = useStore()
/** 页面挂载时加载公共分类数据和个人资料 */
onMounted(async () => {
if (!store.state.regions.length) {
store.dispatch('loadCommonData')
}
// 请求个人资料主表数据,填充非数组字段
await loadProfile()
// 请求教育经历数据
await loadEducation()
// 请求工作经历数据
await loadWork()
// 请求实习经历数据
await loadInternship()
// 请求项目经历数据
await loadProject()
// 请求竞赛经历数据
await loadCompetition()
})
/** 加载个人资料主表数据 */
async function loadProfile() {
try {
const res = await fetchProfile()
if (res.code === '0' && res.data) {
const d = res.data
profile.value.name = d.name || ''
profile.value.phone = d.mobileNumber || ''
profile.value.email = d.email || ''
profile.value.idNumber = d.idCard || ''
profile.value.regionCode = d.regionCode || ''
profile.value.wechat = d.wechatNumber || ''
profile.value.skills = d.skills || []
profile.value.certificates = d.certificates || []
}
} catch {
console.error('[Profile] 加载个人资料失败')
}
}
/** 加载教育经历数据 */
async function loadEducation() {
try {
const res = await fetchEducation()
if (res.code === '0' && res.data) {
profile.value.education = res.data.map(item => ({
school: item.school || '',
major: item.major || '',
studyType: item.studyType ?? 0,
degree: item.degree ?? 2,
startDate: item.startDate || '',
endDate: item.endDate || '',
description: (item.description || []).map(d => ({
id: d.id || '',
text: d.text || '',
})),
}))
}
} catch {
console.error('[Profile] 加载教育经历失败')
}
}
/** 加载工作经历数据 */
async function loadWork() {
try {
const res = await fetchWork()
if (res.code === '0' && res.data) {
profile.value.works = res.data.map(item => ({
companyName: item.companyName || '',
position: item.position || '',
startDate: item.startDate || '',
endDate: item.endDate || '',
description: (item.description || []).map(d => ({
id: d.id || '',
text: d.text || '',
})),
}))
}
} catch {
console.error('[Profile] 加载工作经历失败')
}
}
/** 加载实习经历数据 */
async function loadInternship() {
try {
const res = await fetchInternship()
if (res.code === '0' && res.data) {
profile.value.internships = res.data.map(item => ({
companyName: item.companyName || '',
position: item.position || '',
startDate: item.startDate || '',
endDate: item.endDate || '',
description: (item.description || []).map(d => ({
id: d.id || '',
text: d.text || '',
})),
}))
}
} catch {
console.error('[Profile] 加载实习经历失败')
}
}
/** 加载项目经历数据 */
async function loadProject() {
try {
const res = await fetchProject()
if (res.code === '0' && res.data) {
profile.value.projects = res.data.map(item => ({
projectName: item.projectName || '',
companyName: item.companyName || '',
role: item.role || '',
startDate: item.startDate || '',
endDate: item.endDate || '',
description: (item.description || []).map(d => ({
id: d.id || '',
text: d.text || '',
})),
}))
}
} catch {
console.error('[Profile] 加载项目经历失败')
}
}
/** 加载竞赛经历数据 */
async function loadCompetition() {
try {
const res = await fetchCompetition()
if (res.code === '0' && res.data) {
profile.value.competitions = res.data.map(item => ({
competitionName: item.competitionName || '',
award: item.award || '',
awardDate: item.awardDate || '',
description: (item.description || []).map(d => ({
id: d.id || '',
text: d.text || '',
})),
}))
}
} catch {
console.error('[Profile] 加载竞赛经历失败')
}
}
/** 编辑抽屉的显示状态 */
const showEditDrawer = ref(false)
/** 当前编辑的模块名称 */
const editModule = ref('info')
/** 当前编辑模块的初始数据 */
const editInitialData = ref<Record<string, any>>({})
/** 保存中的加载状态 */
const saving = ref(false)
// ==================== 个人资料数据(非数组字段由接口填充,数组字段暂用模拟数据) ====================
const profile = ref({
/** 真实姓名 — 接口字段 name */
name: '',
/** 手机号码 — 接口字段 mobileNumber */
phone: '',
/** 邮箱 — 接口字段 email */
email: '',
/** 身份证号 — 接口字段 idCard */
idNumber: '',
/** 所在城市编码 — 接口字段 regionCode */
regionCode: '',
/** 微信号 — 接口字段 wechatNumber */
wechat: '',
/** 技能标签列表 — 接口字段 skills */
skills: [] as string[],
/** 证书标签列表 — 接口字段 certificates */
certificates: [] as string[],
/** 教育经历 — 对应数据库 bg_user_profile_education */
education: [] as Array<{
school: string
major: string
studyType: number
degree: number
startDate: string
endDate: string
description: Array<{ id: string; text: string }>
}>,
/** 工作经历 — 对应数据库 bg_user_profile_work */
works: [] as Array<{
companyName: string
position: string
startDate: string
endDate: string
description: Array<{ id: string; text: string }>
}>,
internships: [] as Array<{
companyName: string
position: string
startDate: string
endDate: string
description: Array<{ id: string; text: string }>
}>,
projects: [] as Array<{
projectName: string
companyName: string
role: string
startDate: string
endDate: string
description: Array<{ id: string; text: string }>
}>,
/** 竞赛经历 — 对应数据库 bg_user_profile_competition */
competitions: [] as Array<{
competitionName: string
award: string
awardDate: string
description: Array<{ id: string; text: string }>
}>,
})
// ==================== 事件处理 ====================
/** 打开编辑抽屉 — 根据模块名设置初始数据 */
function handleEdit(section: string) {
editModule.value = section
// 根据模块名提取对应的初始数据
if (section === 'info') {
editInitialData.value = {
name: profile.value.name,
email: profile.value.email,
phone: profile.value.phone,
/** 城市编码 — 传给编辑抽屉的 RegionSelector */
location: profile.value.regionCode,
wechat: profile.value.wechat,
}
} else if (section === 'education') {
editInitialData.value = {
education: profile.value.education.map(edu => ({
school: edu.school,
major: edu.major,
studyType: edu.studyType,
degree: edu.degree,
startDate: edu.startDate,
endDate: edu.endDate,
description: edu.description.map(d => ({ ...d })),
})),
}
} else if (section === 'work') {
editInitialData.value = {
works: profile.value.works.map(exp => ({
companyName: exp.companyName,
position: exp.position,
startDate: exp.startDate,
endDate: exp.endDate,
description: exp.description.map(d => ({ ...d })),
})),
}
} else if (section === 'internship') {
editInitialData.value = {
internships: profile.value.internships.map(exp => ({
companyName: exp.companyName,
position: exp.position,
startDate: exp.startDate,
endDate: exp.endDate,
description: exp.description.map(d => ({ ...d })),
})),
}
} else if (section === 'project') {
editInitialData.value = {
projects: profile.value.projects.map(proj => ({
projectName: proj.projectName,
companyName: proj.companyName,
role: proj.role,
startDate: proj.startDate,
endDate: proj.endDate,
description: proj.description.map(d => ({ ...d })),
})),
}
} else if (section === 'competition') {
editInitialData.value = {
competitions: profile.value.competitions.map(comp => ({
competitionName: comp.competitionName,
award: comp.award,
awardDate: comp.awardDate,
description: comp.description.map(d => ({ ...d })),
})),
}
} else if (section === 'skills') {
editInitialData.value = {
skills: [...profile.value.skills],
}
} else if (section === 'certificate') {
editInitialData.value = {
certificates: [...profile.value.certificates],
}
} else {
editInitialData.value = {}
}
showEditDrawer.value = true
}
/** 保存编辑数据 — 将抽屉返回的数据合并到 profile,并调用对应接口持久化 */
async function handleSaveEdit(data: Record<string, any>) {
if (editModule.value === 'info') {
// ---- 基本信息:调用主表接口保存 ----
try {
saving.value = true
await saveProfile({
name: data.name,
email: data.email,
mobileNumber: data.phone,
regionCode: data.location,
wechatNumber: data.wechat,
})
// 接口成功后更新本地数据
profile.value.name = data.name
profile.value.email = data.email
profile.value.phone = data.phone
profile.value.wechat = data.wechat
/** 将城市编码存入 regionCode,先直接显示编码,后续联调获取接口再做名称转换 */
profile.value.regionCode = data.location || ''
ElMessage.success('个人信息保存成功')
} catch {
ElMessage.error('个人信息保存失败,请重试')
} finally {
saving.value = false
}
} else if (editModule.value === 'education') {
// ---- 教育经历:调用教育经历接口保存 ----
try {
saving.value = true
const payload: SaveEducationItem[] = data.education.map((edu: any) => ({
school: edu.school,
major: edu.major,
degree: edu.degree,
studyType: edu.studyType,
startDate: edu.startDate,
endDate: edu.endDate,
description: edu.description.map((d: any) => ({ id: d.id, text: d.text })),
}))
await saveEducation(payload)
// 接口成功后更新本地数据
profile.value.education = data.education.map((edu: any) => ({
school: edu.school,
major: edu.major,
studyType: edu.studyType,
degree: edu.degree,
startDate: edu.startDate,
endDate: edu.endDate,
description: edu.description.map((d: any) => ({ ...d })),
}))
ElMessage.success('教育经历保存成功')
} catch {
ElMessage.error('教育经历保存失败,请重试')
} finally {
saving.value = false
}
} else if (editModule.value === 'work') {
// ---- 工作经历:调用工作经历接口保存 ----
try {
saving.value = true
const payload: SaveWorkItem[] = data.works.map((work: any) => ({
companyName: work.companyName,
position: work.position,
startDate: work.startDate,
endDate: work.endDate || '',
description: work.description.map((d: any) => ({ id: d.id, text: d.text })),
}))
await saveWork(payload)
// 接口成功后更新本地数据
profile.value.works = data.works.map((work: any) => ({
companyName: work.companyName,
position: work.position,
startDate: work.startDate,
endDate: work.endDate,
description: work.description.map((d: any) => ({ ...d })),
}))
ElMessage.success('工作经历保存成功')
} catch {
ElMessage.error('工作经历保存失败,请重试')
} finally {
saving.value = false
}
} else if (editModule.value === 'internship') {
// ---- 实习经历:调用实习经历接口保存 ----
try {
saving.value = true
const payload: SaveWorkItem[] = data.internships.map((intern: any) => ({
companyName: intern.companyName,
position: intern.position,
startDate: intern.startDate,
endDate: intern.endDate || '',
description: intern.description.map((d: any) => ({ id: d.id, text: d.text })),
}))
await saveInternship(payload)
// 接口成功后更新本地数据
profile.value.internships = data.internships.map((intern: any) => ({
companyName: intern.companyName,
position: intern.position,
startDate: intern.startDate,
endDate: intern.endDate,
description: intern.description.map((d: any) => ({ ...d })),
}))
ElMessage.success('实习经历保存成功')
} catch {
ElMessage.error('实习经历保存失败,请重试')
} finally {
saving.value = false
}
} else if (editModule.value === 'project') {
// ---- 项目经历:调用项目经历接口保存 ----
try {
saving.value = true
const payload: SaveProjectItem[] = data.projects.map((proj: any) => ({
projectName: proj.projectName,
companyName: proj.companyName || '',
role: proj.role || '',
startDate: proj.startDate,
endDate: proj.endDate || '',
description: proj.description.map((d: any) => ({ id: d.id, text: d.text })),
}))
await saveProject(payload)
// 接口成功后更新本地数据
profile.value.projects = data.projects.map((proj: any) => ({
projectName: proj.projectName,
companyName: proj.companyName,
role: proj.role,
startDate: proj.startDate,
endDate: proj.endDate,
description: proj.description.map((d: any) => ({ ...d })),
}))
ElMessage.success('项目经历保存成功')
} catch {
ElMessage.error('项目经历保存失败,请重试')
} finally {
saving.value = false
}
} else if (editModule.value === 'competition') {
// ---- 竞赛经历:调用竞赛经历接口保存 ----
try {
saving.value = true
const payload: SaveCompetitionItem[] = data.competitions.map((comp: any) => ({
competitionName: comp.competitionName,
award: comp.award || '',
awardDate: comp.awardDate || '',
description: comp.description.map((d: any) => ({ id: d.id, text: d.text })),
}))
await saveCompetition(payload)
// 接口成功后更新本地数据
profile.value.competitions = data.competitions.map((comp: any) => ({
competitionName: comp.competitionName,
award: comp.award,
awardDate: comp.awardDate,
description: comp.description.map((d: any) => ({ ...d })),
}))
ElMessage.success('竞赛经历保存成功')
} catch {
ElMessage.error('竞赛经历保存失败,请重试')
} finally {
saving.value = false
}
} else if (editModule.value === 'skills') {
// ---- 技能:调用主表接口保存,需要传递完整的个人信息数据 ----
try {
saving.value = true
await saveProfile({
name: profile.value.name,
email: profile.value.email,
mobileNumber: profile.value.phone,
idCard: profile.value.idNumber,
regionCode: profile.value.regionCode,
wechatNumber: profile.value.wechat,
skills: [...data.skills],
certificates: profile.value.certificates,
})
profile.value.skills = [...data.skills]
ElMessage.success('技能保存成功')
} catch {
ElMessage.error('技能保存失败,请重试')
} finally {
saving.value = false
}
} else if (editModule.value === 'certificate') {
// ---- 证书:调用主表接口保存,需要传递完整的个人信息数据 ----
try {
saving.value = true
await saveProfile({
name: profile.value.name,
email: profile.value.email,
mobileNumber: profile.value.phone,
idCard: profile.value.idNumber,
regionCode: profile.value.regionCode,
wechatNumber: profile.value.wechat,
skills: profile.value.skills,
certificates: [...data.certificates],
})
profile.value.certificates = [...data.certificates]
ElMessage.success('证书保存成功')
} catch {
ElMessage.error('证书保存失败,请重试')
} finally {
saving.value = false
}
}
}
function goToJobs() {
router.push('/jobs')
}
function goToResume() {
router.push('/resume')
}
</script>
+166
View File
@@ -0,0 +1,166 @@
<template>
<div class="resume-page dflex">
<SideNav />
<div class="resume-page__content">
<!-- 页面标题 + 上传按钮 -->
<div class="resume-page__header">
<h2 class="resume-page__title">我的简历</h2>
<button class="resume-page__upload-btn" @click="handleUpload">
<svg viewBox="0 0 16 16" fill="none" class="resume-page__upload-icon">
<path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
上传简历
</button>
</div>
<!-- 简历表格 -->
<div class="resume-page__table-wrap">
<table class="resume-page__table">
<thead>
<tr>
<th class="resume-page__th">简历</th>
<th class="resume-page__th">目标岗位</th>
<th class="resume-page__th">最近修改</th>
<th class="resume-page__th">创建时间</th>
<th class="resume-page__th resume-page__th--action"></th>
</tr>
</thead>
<tbody>
<tr
v-for="item in resumeList"
:key="item.id"
class="resume-page__row"
@click="goDetail(item.id)"
>
<td class="resume-page__td">
<div class="resume-page__name-cell">
<span class="resume-page__avatar" :style="{ background: item.avatarColor }">
{{ item.avatarLetter }}
</span>
<span class="resume-page__name">{{ item.name }}</span>
<span v-if="item.isDefault" class="resume-page__default-tag">默认</span>
</div>
</td>
<td class="resume-page__td">{{ item.targetJob }}</td>
<td class="resume-page__td">{{ item.updatedAt }}</td>
<td class="resume-page__td">{{ item.createdAt }}</td>
<td class="resume-page__td resume-page__td--action">
<button
class="resume-page__more-btn"
aria-label="更多操作"
@click.stop="toggleMenu(item.id)"
>
<svg viewBox="0 0 16 16" fill="currentColor" class="resume-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 v-if="activeMenuId === item.id" class="resume-page__popup" @click.stop>
<div
v-for="action in popupActions"
:key="action"
class="resume-page__popup-item"
@click="handleAction(action, item.id)"
>
{{ action }}
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import SideNav from '@/components/SideNav.vue'
const router = useRouter()
// ==================== 类型定义 ====================
/** 简历列表项类型 */
interface ResumeItem {
id: string
name: string
avatarLetter: string
avatarColor: string
isDefault: boolean
targetJob: string
updatedAt: string
createdAt: string
}
// ==================== 数据(模拟数据,后续对接接口) ====================
/** 简历列表 */
const resumeList = ref<ResumeItem[]>([
{
id: '1',
name: '李华_产品经理',
avatarLetter: 'D',
avatarColor: '#1A1A2E',
isDefault: true,
targetJob: '产品经理',
updatedAt: '1个月前',
createdAt: '1个月前',
},
{
id: '2',
name: '李华_产品运营',
avatarLetter: 'C',
avatarColor: '#4FC2C9',
isDefault: false,
targetJob: '产品运营',
updatedAt: '1个月前',
createdAt: '1个月前',
},
{
id: '3',
name: '李华_产品经理',
avatarLetter: '?',
avatarColor: '#BFBFBF',
isDefault: false,
targetJob: '',
updatedAt: '1个月前',
createdAt: '1个月前',
},
])
// ==================== 页面状态 ====================
/** 当前打开弹出菜单的简历 ID */
const activeMenuId = ref<string | null>(null)
/** 弹出菜单操作项 */
const popupActions = ['设为默认简历', '编辑', '导出简历', '删除']
// ==================== 事件处理 ====================
/** 切换弹出菜单的显示/隐藏 */
function toggleMenu(id: string) {
activeMenuId.value = activeMenuId.value === id ? null : id
}
/** 弹出菜单操作点击 */
function handleAction(action: string, id: string) {
activeMenuId.value = null
console.log(action, id)
}
/** 上传简历 */
function handleUpload() {
console.log('上传简历')
}
/** 跳转到简历详情页 */
function goDetail(id: string) {
router.push(`/resume/${id}`)
}
</script>
+230
View File
@@ -0,0 +1,230 @@
<template>
<div class="resume-detail dflex">
<SideNav />
<div class="resume-detail__content">
<!-- 顶部标题 -->
<h2 class="resume-detail__page-title">我的简历</h2>
<!-- 顶部操作栏 -->
<div class="resume-detail__toolbar">
<div class="resume-detail__toolbar-left">
<button class="resume-detail__back-btn" @click="goBack" aria-label="返回">
<svg viewBox="0 0 16 16" fill="none" class="resume-detail__back-icon">
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<span class="resume-detail__tab-name">{{ resume.name }}</span>
</div>
<div class="resume-detail__toolbar-right">
<button class="resume-detail__tool-btn" @click="handleFeedback">问题反馈</button>
<button class="resume-detail__tool-btn" @click="handleEdit">
<svg viewBox="0 0 16 16" fill="none" class="resume-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="resume-detail__tool-btn" @click="handleExport">
<svg viewBox="0 0 16 16" fill="none" class="resume-detail__tool-icon">
<path d="M3 10v3h10v-3M8 2v8M5 5l3-3 3 3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
导出
</button>
<button class="resume-detail__tool-btn resume-detail__tool-btn--danger" @click="handleDelete">
<svg viewBox="0 0 16 16" fill="none" class="resume-detail__tool-icon">
<path d="M4 4h8l-.5 9H4.5L4 4zM6 4V2.5h4V4M2.5 4h11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
删除
</button>
</div>
</div>
<!-- 简历评分区域 -->
<div class="resume-detail__score-bar">
<div class="resume-detail__score-left">
<span class="resume-detail__score-avatar" :style="{ background: resume.avatarColor }">
{{ resume.avatarLetter }}
</span>
<span class="resume-detail__score-badge">良好</span>
<button class="resume-detail__score-link" @click="handleViewReport">查看评估报告 ></button>
</div>
<div class="resume-detail__score-right">
<div class="resume-detail__score-item">
<span class="resume-detail__score-num">{{ resume.urgentCount }}</span>
<span class="resume-detail__score-label">紧急修复项</span>
</div>
<div class="resume-detail__score-item">
<span class="resume-detail__score-num">{{ resume.severeCount }}</span>
<span class="resume-detail__score-label">严重问题</span>
</div>
<div class="resume-detail__score-item">
<span class="resume-detail__score-num">{{ resume.optionalCount }}</span>
<span class="resume-detail__score-label">可选修复项</span>
</div>
<button class="resume-detail__diagnose-btn" @click="handleDiagnose">重新诊断</button>
</div>
</div>
<!-- 简历主体内容 -->
<div class="resume-detail__body">
<!-- 个人信息卡片 -->
<div class="resume-detail__card">
<div class="resume-detail__card-header">
<div>
<h3 class="resume-detail__user-name">{{ resume.realName }}</h3>
<p class="resume-detail__user-title">{{ resume.jobTitle }}</p>
</div>
<div class="resume-detail__card-actions">
<button class="resume-detail__card-btn resume-detail__card-btn--outline" @click="handleUrgentFix">紧急修复项</button>
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handlePolish">修复</button>
</div>
</div>
<div class="resume-detail__contact">
<span class="resume-detail__contact-item">
<svg viewBox="0 0 16 16" fill="none" class="resume-detail__contact-icon">
<rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
<path d="M2 5.5l6 4 6-4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
{{ resume.email }}
</span>
<span class="resume-detail__contact-item">
<svg viewBox="0 0 16 16" fill="none" class="resume-detail__contact-icon">
<path d="M4 2h2.5l1.5 3-1.5 1.5a8 8 0 003 3L11 8l3 1.5V12a2 2 0 01-2 2C6.5 14 2 9.5 2 4a2 2 0 012-2z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
</svg>
{{ resume.phone }}
</span>
<span class="resume-detail__contact-item">
<svg viewBox="0 0 16 16" fill="none" class="resume-detail__contact-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>
{{ resume.location }}
</span>
</div>
</div>
<!-- 教育背景 -->
<div class="resume-detail__card">
<div class="resume-detail__section-header">
<h3 class="resume-detail__section-title">教育背景</h3>
<div class="resume-detail__card-actions">
<button class="resume-detail__card-btn resume-detail__card-btn--outline">紧急修复项</button>
<button class="resume-detail__card-btn resume-detail__card-btn--dark">修复</button>
</div>
</div>
<div v-for="(edu, i) in resume.education" :key="i" class="resume-detail__edu-item">
<div class="resume-detail__edu-degree">{{ edu.degree }}</div>
<div class="resume-detail__edu-meta">{{ edu.school }} · {{ edu.period }}</div>
<div v-if="edu.gpa" class="resume-detail__edu-meta">GPA: {{ edu.gpa }}</div>
</div>
</div>
<!-- 工作经验 -->
<div class="resume-detail__card">
<h3 class="resume-detail__section-title">工作经验</h3>
<div v-for="(exp, i) in resume.experience" :key="i" class="resume-detail__exp-item">
<div class="resume-detail__exp-title">{{ exp.title }}</div>
<div class="resume-detail__exp-meta">{{ exp.company }} · {{ exp.period }}</div>
<p class="resume-detail__exp-desc">{{ exp.description }}</p>
</div>
</div>
<!-- 技能 -->
<div class="resume-detail__card">
<h3 class="resume-detail__section-title">技能</h3>
<div class="resume-detail__skills">
<span v-for="(skill, i) in resume.skills" :key="i" class="resume-detail__skill-tag">
{{ skill }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import SideNav from '@/components/SideNav.vue'
// ==================== 路由相关 ====================
const router = useRouter()
const route = useRoute()
/** 当前简历 ID,从路由参数中获取 */
const resumeId = route.params.id as string
// ==================== 简历数据(模拟数据,后续对接接口) ====================
const resume = ref({
id: resumeId,
name: '李华_产品经理',
avatarLetter: 'B',
avatarColor: '#1A1A2E',
realName: '李华',
jobTitle: '数据产品经理',
email: '[email]',
phone: '+1 (555) 123-4567',
location: '北京',
/** 紧急修复项数量 */
urgentCount: 8,
/** 严重问题数量 */
severeCount: 0,
/** 可选修复项数量 */
optionalCount: 1,
/** 教育背景 */
education: [
{ degree: '计算机科学学士', school: '斯坦福大学', period: '2018 - 2022', gpa: '3.8/4.0' },
{ degree: '高中毕业', school: '加州理工学院附属中学', period: '2014 - 2018', gpa: '' },
],
/** 工作经验 */
experience: [
{
title: '高级软件工程师',
company: 'Google',
period: '2022 - 至今',
description: '负责开发和维护大规模分布式系统,优化系统性能和可靠性。',
},
{
title: '软件工程师实习生',
company: 'Microsoft',
period: '2021 - 2022',
description: '参与云服务平台的开发,协助团队完成多个核心功能模块。',
},
],
/** 技能标签 */
skills: ['JavaScript', 'Python', 'React', 'Node.js', 'SQL', 'AWS', 'Docker', 'Kubernetes'],
})
// ==================== 事件处理 ====================
/** 返回简历列表页 */
function goBack() {
router.push('/resume')
}
/** 问题反馈 */
function handleFeedback() { console.log('问题反馈') }
/** 编辑简历信息 */
function handleEdit() { console.log('编辑简历信息') }
/** 导出简历 */
function handleExport() { console.log('导出') }
/** 删除简历 */
function handleDelete() { console.log('删除') }
/** 查看评估报告 */
function handleViewReport() { console.log('查看评估报告') }
/** 重新诊断简历 */
function handleDiagnose() { console.log('重新诊断') }
/** 紧急修复项 */
function handleUrgentFix() { console.log('紧急修复项') }
/** 修复简历问题 */
function handlePolish() { console.log('修复') }
</script>