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

This commit is contained in:
2026-04-01 11:41:51 +08:00
parent 0468339d23
commit 821a950df2
42 changed files with 5935 additions and 748 deletions
+31 -18
View File
@@ -40,6 +40,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { dislikeJob } from '@/api/jobs'
const props = defineProps<{
modelValue: boolean
@@ -48,6 +49,7 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'disliked'): void
}>()
const visible = computed({
@@ -55,39 +57,50 @@ const visible = computed({
set: (val: boolean) => emit('update:modelValue', val),
})
/** 不感兴趣的原因(单选) */
const dislikeReason = ref('')
/** 不感兴趣的原因(单选0-5 */
const dislikeReason = ref<number | undefined>(undefined)
/** 不感兴趣的补充描述 */
const dislikeDetail = ref('')
/** 不感兴趣原因选项列表 */
/** 不感兴趣原因选项列表(与接口 reason 0-5 对应) */
const dislikeOptions = [
{ value: 'company', label: '对这家公司不感兴趣' },
{ value: 'position', label: '对这个岗位不感兴趣' },
{ value: 'location', label: '工作地点不合适' },
{ value: 'other', label: '其他原因' },
{ value: 0, label: '对公司不感兴趣' },
{ value: 1, label: '对岗位不感兴趣' },
{ value: 2, label: '对行业不感兴趣' },
// { value: 3, label: '技能不符合' },
{ value: 4, label: '地点不合适' },
{ value: 5, label: '其他' },
]
/** 是否正在提交 */
const submitting = ref(false)
/** 提交不感兴趣反馈 */
function handleDislikeSubmit() {
if (!dislikeReason.value) {
async function handleDislikeSubmit() {
if (dislikeReason.value === undefined) {
ElMessage.warning('请选择一个原因')
return
}
// TODO: 调用接口提交反馈,参数:props.jobId, dislikeReason.value, dislikeDetail.value
console.log('提交不感兴趣反馈', {
jobId: props.jobId,
reason: dislikeReason.value,
detail: dislikeDetail.value,
})
ElMessage.success('反馈已提交,感谢您的反馈')
visible.value = false
if (!props.jobId) return
submitting.value = true
try {
const res = await dislikeJob(props.jobId, dislikeReason.value)
if (res.code === '0') {
ElMessage.success('反馈已提交,感谢您的反馈')
visible.value = false
emit('disliked')
}
} catch (e) {
console.error('提交不感兴趣反馈失败', e)
} finally {
submitting.value = false
}
}
/** 弹窗打开时重置表单 */
function resetForm() {
dislikeReason.value = ''
dislikeReason.value = undefined
dislikeDetail.value = ''
}
+98 -175
View File
@@ -22,99 +22,44 @@
</div>
<!-- 岗位选择模块 -->
<div class="job-goal-dialog__section ">
<div class="job-goal-dialog__section">
<div class="job-goal-dialog__label">*岗位</div>
<!-- 已选岗位标签列表 -->
<div class="job-goal-dialog__tags">
<span
v-for="(item, index) in form.positions"
:key="index"
class="job-goal-dialog__tag"
>
{{ item }}
<!-- 删除岗位标签 -->
<el-icon class="job-goal-dialog__tag-close" @click="removePosition(index)"><Close /></el-icon>
</span>
</div>
<!-- 岗位下拉选择器 -->
<el-select
v-model="newPosition"
placeholder="新增岗位"
filterable
class="job-goal-dialog__select"
@change="addPosition"
>
<el-option
v-for="opt in positionOptions"
:key="opt"
:label="opt"
:value="opt"
/>
</el-select>
<JobCategorySelector
:categoryIds="selectedCategoryIds"
:maxSelect="3"
:level="3"
:allowParentSelect="false"
:triggerStyle="selectorTriggerStyle"
:displayStyle="selectorDisplayStyle"
@update:categoryIds="onCategoryChange"
/>
</div>
<!-- 行业选择模块 -->
<div class="job-goal-dialog__section ">
<div class="job-goal-dialog__section">
<div class="job-goal-dialog__label">*行业</div>
<!-- 已选行业标签列表 -->
<div class="job-goal-dialog__tags">
<span
v-for="(item, index) in form.industries"
:key="index"
class="job-goal-dialog__tag"
>
{{ item }}
<!-- 删除行业标签 -->
<el-icon class="job-goal-dialog__tag-close" @click="removeIndustry(index)"><Close /></el-icon>
</span>
</div>
<!-- 行业下拉选择器 -->
<el-select
v-model="newIndustry"
placeholder="新增行业"
filterable
class="job-goal-dialog__select"
@change="addIndustry"
>
<el-option
v-for="opt in industryOptions"
:key="opt"
:label="opt"
:value="opt"
/>
</el-select>
<IndustrySelector
:industryIds="selectedIndustryIds"
:maxSelect="3"
:level="2"
:allowParentSelect="false"
:triggerStyle="selectorTriggerStyle"
:displayStyle="selectorDisplayStyle"
@update:industryIds="onIndustryChange"
/>
</div>
<!-- 城市选择模块 -->
<div class="job-goal-dialog__section ">
<div class="job-goal-dialog__section">
<div class="job-goal-dialog__label">*城市</div>
<!-- 已选城市标签列表 -->
<div class="job-goal-dialog__tags">
<span
v-for="(item, index) in form.cities"
:key="index"
class="job-goal-dialog__tag"
>
{{ item }}
<!-- 删除城市标签 -->
<el-icon class="job-goal-dialog__tag-close" @click="removeCity(index)"><Close /></el-icon>
</span>
</div>
<!-- 城市下拉选择器 -->
<el-select
v-model="newCity"
placeholder="新增城市"
filterable
class="job-goal-dialog__select"
@change="addCity"
>
<el-option
v-for="opt in cityOptions"
:key="opt"
:label="opt"
:value="opt"
/>
</el-select>
<RegionSelector
:regionCodes="selectedRegionCodes"
:level="2"
:maxSelect="3"
:triggerStyle="selectorTriggerStyle"
:displayStyle="selectorDisplayStyle"
@update:regionCodes="onRegionChange"
/>
</div>
<!-- 工作类型选择模块 -->
@@ -126,8 +71,8 @@
v-for="t in jobTypes"
:key="t"
class="job-goal-dialog__type-btn"
:class="{ 'job-goal-dialog__type-btn--active': form.jobType === t }"
@click="form.jobType = t"
:class="{ 'job-goal-dialog__type-btn--active': selectedJobType === t }"
@click="selectedJobType = t"
>
{{ t }}
</button>
@@ -142,18 +87,23 @@
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ref, watch } from 'vue'
import { Close } from '@element-plus/icons-vue'
import { useStore } from 'vuex'
import RegionSelector from './tools/RegionSelector.vue'
import IndustrySelector from './tools/IndustrySelector.vue'
import JobCategorySelector from './tools/JobCategorySelector.vue'
/** 组件属性:控制弹窗显示/隐藏 */
const props = defineProps<{ modelValue: boolean }>()
/** 组件事件:更新弹窗状态、保存表单数据 */
/** 组件事件:更新弹窗状态 */
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'save', data: { positions: string[]; industries: string[]; cities: string[]; jobType: string }): void
}>()
const store = useStore()
/** 弹窗可见状态 */
const visible = ref(props.modelValue)
/** 监听外部传入的 modelValue 同步弹窗状态 */
@@ -161,102 +111,75 @@ watch(() => props.modelValue, (v) => { visible.value = v })
/** 监听弹窗状态变化并通知父组件 */
watch(visible, (v) => { emit('update:modelValue', v) })
/** 表单数据 */
const form = reactive({
/** 已选岗位列表 */
positions: ['产品经理'] as string[],
/** 已选行业列表 */
industries: ['互联网'] as string[],
/** 已选城市列表 */
cities: ['北京'] as string[],
/** 工作类型 */
jobType: '全职',
})
/** 本地编辑副本 — 打开弹窗时从 store 拷贝,保存时写回 store */
const selectedCategoryIds = ref<number[]>([])
const selectedIndustryIds = ref<number[]>([])
const selectedRegionCodes = ref<string[]>([])
const selectedJobType = ref('全职')
/** 新增岗位的绑定值 */
const newPosition = ref('')
/** 新增行业的绑定值 */
const newIndustry = ref('')
/** 新增城市的绑定值 */
const newCity = ref('')
/** 岗位选项列表 */
const positionOptions = ['产品经理', '前端工程师', '后端工程师', 'UI设计师', '数据分析师', '运营', '市场营销']
/** 行业选项列表 */
const industryOptions = ['互联网', '金融', '教育', '医疗健康', '电子商务', '人工智能', '游戏', '新能源', '房地产', '制造业']
/** 城市选项列表 */
const cityOptions = ['北京', '上海', '广州', '深圳', '杭州', '成都', '南京', '武汉']
/** 工作类型选项列表 */
const jobTypes = ['实习', '全职']
/**
* 添加岗位
* @param val 选中的岗位名称
*/
function addPosition(val: string) {
if (val && !form.positions.includes(val)) {
form.positions.push(val)
/** 弹窗打开时从 store 同步数据到本地编辑副本 */
watch(() => props.modelValue, (v) => {
if (v) {
const intention = store.state.jobIntention
selectedCategoryIds.value = [...(intention.categoryIds || [])]
selectedIndustryIds.value = [...(intention.industryIds || [])]
selectedRegionCodes.value = [...(intention.regionCodes || [])]
selectedJobType.value = intention.employmentType === 1 ? '实习' : '全职'
}
newPosition.value = ''
})
/** 选择器触发按钮的自定义样式,适配弹窗内布局(参考 ProfileEditDrawer 中 regionTriggerStyle */
const selectorTriggerStyle: Record<string, string> = {
width: '100%',
'box-sizing': 'border-box',
padding: '0.1rem 0.14rem',
'font-size': '0.13rem',
color: '#1a1a2e',
background: '#f6f6f9',
border: '1px solid transparent',
'border-radius': '0.06rem',
'max-width': 'none',
'justify-content': 'space-between',
}
/** 选择器显示文字的自定义样式,覆盖默认 max-width: 1.6rem */
const selectorDisplayStyle: Record<string, string> = {
'max-width': 'none',
}
/** 岗位选择变更回调 */
function onCategoryChange(ids: number[]) {
selectedCategoryIds.value = ids
}
/** 行业选择变更回调 */
function onIndustryChange(ids: number[]) {
selectedIndustryIds.value = ids
}
/** 地区选择变更回调 */
function onRegionChange(codes: string[]) {
selectedRegionCodes.value = codes
}
/**
* 移除岗位
* @param index 要移除的岗位索引
* 保存表单数据:调用 store action 保存到后端并同步 store,然后关闭弹窗
*/
function removePosition(index: number) {
form.positions.splice(index, 1)
}
/**
* 添加行业
* @param val 选中的行业名称
*/
function addIndustry(val: string) {
if (val && !form.industries.includes(val)) {
form.industries.push(val)
async function handleSave() {
try {
await store.dispatch('saveJobIntention', {
categoryIds: [...selectedCategoryIds.value],
industryIds: [...selectedIndustryIds.value],
regionCodes: [...selectedRegionCodes.value],
employmentType: selectedJobType.value === '实习' ? 1 : 0,
})
visible.value = false
} catch (e) {
console.error('保存求职意向失败', e)
}
newIndustry.value = ''
}
/**
* 移除行业
* @param index 要移除的行业索引
*/
function removeIndustry(index: number) {
form.industries.splice(index, 1)
}
/**
* 添加城市
* @param val 选中的城市名称
*/
function addCity(val: string) {
if (val && !form.cities.includes(val)) {
form.cities.push(val)
}
newCity.value = ''
}
/**
* 移除城市
* @param index 要移除的城市索引
*/
function removeCity(index: number) {
form.cities.splice(index, 1)
}
/**
* 保存表单数据并关闭弹窗
*/
function handleSave() {
emit('save', {
...form,
positions: [...form.positions],
industries: [...form.industries],
cities: [...form.cities],
})
visible.value = false
}
/**
+12 -19
View File
@@ -4,41 +4,34 @@
<h2 class="job-page-header__title">发现理想职位</h2>
<p class="job-page-header__subtitle">找到最适合你的工作机会</p>
</div>
<div class="job-page-header__tabs">
<div class="job-page-header__tabs mt20">
<div
v-for="tab in tabs"
v-for="(tab,index) in tabs"
:key="tab.key"
class="job-page-header__tab"
:class="{ 'job-page-header__tab--active': activeTab === tab.key }"
:style="index==0?'padding:0.06rem 0.30rem;':''"
@click="handleTabClick(tab.key)"
>
{{ tab.label }}
</div>
<button class="job-page-header__goal-btn" @click="showGoalDialog = true">
我的求职目标
</button>
</div>
<JobGoalDialog v-model="showGoalDialog" @save="onGoalSave" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import JobGoalDialog from './JobGoalDialog.vue'
const showGoalDialog = ref(false)
function onGoalSave(data: { positions: string[]; cities: string[]; jobType: string }) {
console.log('求职目标已保存', data)
}
// ==================== Props & Emits ====================
/** activeTab: 当前激活的 Tab key,由父组件通过 v-model 传入 */
const props = defineProps<{
activeTab: string
/** 收藏总数,由父组件传入 */
favoriteCount?: number
/** 投递总数,由父组件传入 */
applyCount?: number
}>()
/** 双向绑定事件,在 Jobs 页面内切换 Tab 时触发 */
@@ -54,11 +47,11 @@ const route = useRoute()
// ==================== 常量数据 ====================
/** Tab 选项列表 */
const tabs = [
const tabs = computed(() => [
{ key: 'recommend', label: '推荐' },
{ key: 'collected', label: '收藏(1' },
{ key: 'applied', label: '已投递(2' },
]
{ key: 'collected', label: `收藏(${props.favoriteCount ?? 0}` },
{ key: 'applied', label: `投递(${props.applyCount ?? 0}` },
])
// ==================== 事件处理 ====================
+602
View File
@@ -0,0 +1,602 @@
<template>
<!-- 岗位专属简历定制弹窗步骤1居中弹窗步骤2+右侧抽屉 -->
<div v-if="modelValue" class="job-resume-custom-dialog" :class="{ 'job-resume-custom-dialog--drawer': currentStep >= 2 }">
<div class="job-resume-custom-dialog__overlay" @click="handleClose"></div>
<!-- ===== 步骤一居中弹窗 ===== -->
<div v-if="currentStep === 1" class="job-resume-custom-dialog__panel">
<div class="job-resume-custom-dialog__header">
<h2 class="job-resume-custom-dialog__title">10s快速定制岗位专属简历</h2>
<button class="job-resume-custom-dialog__close-btn" @click="handleClose" aria-label="关闭">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__close-icon"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
<div class="job-resume-custom-dialog__tip-bar"><span>你与该岗位的匹配度较低简历可能无法通过机筛</span></div>
<div class="job-resume-custom-dialog__job-card">
<div class="job-resume-custom-dialog__job-left">
<div class="job-resume-custom-dialog__company-icon">
<img v-if="jobInfo.companyLogoUrl" :src="jobInfo.companyLogoUrl" :alt="jobInfo.company" class="job-resume-custom-dialog__company-logo-img" />
<svg v-else viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__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>
<div class="job-resume-custom-dialog__job-info">
<span class="job-resume-custom-dialog__job-title">{{ jobInfo.title }}</span>
<span class="job-resume-custom-dialog__job-sub">{{ jobInfo.location }} · {{ jobInfo.company }}</span>
</div>
</div>
<div class="job-resume-custom-dialog__match-area">
<div class="job-resume-custom-dialog__match-ring">
<svg viewBox="0 0 60 60" class="job-resume-custom-dialog__ring-svg"><circle cx="30" cy="30" r="24" stroke-width="4" stroke="#E8E8E8" fill="none" opacity="0.3"/><circle cx="30" cy="30" r="24" stroke-width="4" fill="none" stroke="#4FC2C9" stroke-linecap="round" :stroke-dasharray="2*Math.PI*24" :stroke-dashoffset="2*Math.PI*24*(1-jobInfo.matchScore/10)" transform="rotate(-90 30 30)"/></svg>
<span class="job-resume-custom-dialog__match-score">{{ jobInfo.matchScore }}</span>
</div>
<span class="job-resume-custom-dialog__match-label">{{ matchLevelText }}</span>
</div>
</div>
<div class="job-resume-custom-dialog__skills-section">
<p class="job-resume-custom-dialog__skills-title">缺少{{ missingSkills.length }}项技能</p>
<div class="job-resume-custom-dialog__skills-list">
<span v-for="skill in missingSkills" :key="skill" class="job-resume-custom-dialog__skill-tag">{{ skill }}</span>
</div>
</div>
<div class="job-resume-custom-dialog__footer">
<button class="job-resume-custom-dialog__primary-btn" @click="goToStep(2)">立即定制简历</button>
<span class="job-resume-custom-dialog__skip-link" @click="handleSkip">不优化直接投递</span>
</div>
</div>
<!-- ===== 步骤2+右侧抽屉 ===== -->
<div v-if="currentStep >= 2" class="job-resume-custom-dialog__drawer">
<!-- 抽屉头部 -->
<div class="job-resume-custom-dialog__drawer-header">
<button class="job-resume-custom-dialog__close-btn" @click="handleClose" aria-label="关闭">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__close-icon"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<h2 class="job-resume-custom-dialog__drawer-title">生成你的岗位专属简历</h2>
</div>
<!-- 返回按钮步骤四显示 -->
<button v-if="currentStep === 4" class="job-resume-custom-dialog__back-btn" @click="goToStep(3)">返回</button>
<!-- 步骤指示器 -->
<div class="job-resume-custom-dialog__steps">
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 2 }">
<span class="job-resume-custom-dialog__step-num">1</span><span>差距分析</span>
</div>
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 3 }">
<span class="job-resume-custom-dialog__step-num">2</span><span>定制简历</span>
</div>
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 4 }">
<span class="job-resume-custom-dialog__step-num">3</span><span>预览</span>
</div>
</div>
<!-- 抽屉内容区可滚动 -->
<div class="job-resume-custom-dialog__drawer-body">
<!-- 步骤二差距分析 -->
<template v-if="currentStep === 2">
<div class="job-resume-custom-dialog__gap-header">
<div class="job-resume-custom-dialog__gap-left">
<h3 class="job-resume-custom-dialog__gap-title">你的简历与该岗位的匹配度较低</h3>
<div class="job-resume-custom-dialog__gap-warn">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__warn-icon"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M8 5v3M8 10.5v.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
<span>匹配度低于 6.0 分的简历在筛选环节可能会被优先淘汰我们会帮你快速优化提升</span>
</div>
</div>
<div class="job-resume-custom-dialog__gap-score-area">
<div class="job-resume-custom-dialog__match-ring">
<svg viewBox="0 0 60 60" class="job-resume-custom-dialog__ring-svg"><circle cx="30" cy="30" r="24" stroke-width="4" stroke="#E8E8E8" fill="none" opacity="0.3"/><circle cx="30" cy="30" r="24" stroke-width="4" fill="none" stroke="#4FC2C9" stroke-linecap="round" :stroke-dasharray="2*Math.PI*24" :stroke-dashoffset="2*Math.PI*24*(1-jobInfo.matchScore/10)" transform="rotate(-90 30 30)"/></svg>
<span class="job-resume-custom-dialog__match-score">{{ jobInfo.matchScore }}</span>
</div>
<span class="job-resume-custom-dialog__match-label">{{ matchLevelText }}</span>
</div>
</div>
<!-- 对比卡片表格 -->
<div class="job-resume-custom-dialog__gap-table">
<!-- 第一行概览 -->
<div class="job-resume-custom-dialog__gap-row">
<!-- 标签列 -->
<div class="job-resume-custom-dialog__gap-cell job-resume-custom-dialog__gap-cell--label">
<span class="job-resume-custom-dialog__gap-cell-title">概览</span>
</div>
<!-- 岗位信息列 -->
<div class="job-resume-custom-dialog__gap-cell">
<div class="job-resume-custom-dialog__gap-job-info">
<div class="job-resume-custom-dialog__gap-company-icon">
<img v-if="jobInfo.companyLogoUrl" :src="jobInfo.companyLogoUrl" :alt="jobInfo.company" class="job-resume-custom-dialog__gap-company-logo" />
<svg v-else viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__gap-company-svg"><rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/></svg>
</div>
<div class="job-resume-custom-dialog__gap-job-text">
<span class="job-resume-custom-dialog__gap-job-title">{{ jobInfo.title }}</span>
<span class="job-resume-custom-dialog__gap-job-sub">{{ jobInfo.location }} · {{ jobInfo.company }}&nbsp;&nbsp;独角兽</span>
</div>
</div>
</div>
<!-- 简历选择列 -->
<div class="job-resume-custom-dialog__gap-cell">
<div class="job-resume-custom-dialog__resume-selector">
<div class="job-resume-custom-dialog__resume-info">
<svg viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__resume-file-icon"><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-linejoin="round"/><path d="M14 2v6h6" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
<div class="job-resume-custom-dialog__resume-text">
<span class="job-resume-custom-dialog__resume-label">你的简历</span>
<span class="job-resume-custom-dialog__resume-name">{{ selectedResume.name }}</span>
</div>
</div>
<button class="job-resume-custom-dialog__resume-select-btn" @click="toggleResumeDropdown">选择 <svg viewBox="0 0 12 12" fill="none" class="job-resume-custom-dialog__dropdown-arrow"><path d="M3 5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
<div v-if="showResumeDropdown" class="job-resume-custom-dialog__resume-dropdown">
<div v-for="r in resumeList" :key="r.id" class="job-resume-custom-dialog__resume-option" :class="{'job-resume-custom-dialog__resume-option--active': r.id === selectedResume.id}" @click="selectResume(r)">{{ r.name }}</div>
</div>
</div>
</div>
</div>
<!-- 第二行岗位名称 -->
<div class="job-resume-custom-dialog__gap-row">
<div class="job-resume-custom-dialog__gap-cell job-resume-custom-dialog__gap-cell--label">
<span>岗位名称</span>
</div>
<div class="job-resume-custom-dialog__gap-cell">
<span class="job-resume-custom-dialog__gap-value">{{ jobInfo.title }}</span>
</div>
<div class="job-resume-custom-dialog__gap-cell">
<span class="job-resume-custom-dialog__gap-value">{{ selectedResume.targetJob || '—' }}</span>
</div>
</div>
<!-- 第三行岗位关键词 -->
<div class="job-resume-custom-dialog__gap-row">
<div class="job-resume-custom-dialog__gap-cell job-resume-custom-dialog__gap-cell--label">
<span>岗位关键词</span>
</div>
<div class="job-resume-custom-dialog__gap-cell job-resume-custom-dialog__gap-cell--keywords">
<div class="job-resume-custom-dialog__gap-keywords">
<span v-for="kw in jobInfo.keywords" :key="kw" class="job-resume-custom-dialog__gap-kw-tag">{{ kw }}</span>
</div>
</div>
</div>
</div>
</template>
<!-- 步骤三定制简历 -->
<template v-if="currentStep === 3">
<div class="job-resume-custom-dialog__custom">
<!-- 左侧选择要优化的部分 -->
<div class="job-resume-custom-dialog__custom-panel">
<h3 class="job-resume-custom-dialog__custom-panel-title">1.选择你要优化的部分</h3>
<div class="job-resume-custom-dialog__custom-options">
<label v-for="item in optimizeSections" :key="item.key" class="job-resume-custom-dialog__custom-checkbox">
<input type="checkbox" v-model="item.checked" class="job-resume-custom-dialog__custom-input" />
<span class="job-resume-custom-dialog__custom-checkmark"></span>
<span class="job-resume-custom-dialog__custom-label">{{ item.label }}</span>
<!-- 技能和工作经验的问号提示使用 el-tooltip -->
<el-tooltip
v-if="item.tooltip"
:content="item.tooltip"
placement="right"
:show-arrow="true"
:popper-options="{ strategy: 'fixed' }"
effect="dark"
>
<span class="job-resume-custom-dialog__custom-tooltip-trigger">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__custom-tooltip-icon">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/>
<path d="M6.5 6.5a1.5 1.5 0 112.12 1.37c-.42.18-.62.5-.62.88V9.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
<circle cx="8" cy="11.5" r="0.6" fill="currentColor"/>
</svg>
</span>
</el-tooltip>
</label>
</div>
</div>
<!-- 右侧选择要新增的技能关键词 -->
<div class="job-resume-custom-dialog__custom-panel">
<h3 class="job-resume-custom-dialog__custom-panel-title">2.选择你要新增的技能关键词</h3>
<div class="job-resume-custom-dialog__custom-options">
<label v-for="skill in newSkillOptions" :key="skill.name" class="job-resume-custom-dialog__custom-checkbox">
<input type="checkbox" v-model="skill.checked" class="job-resume-custom-dialog__custom-input" />
<span class="job-resume-custom-dialog__custom-checkmark"></span>
<span class="job-resume-custom-dialog__custom-label">{{ skill.name }}</span>
</label>
</div>
</div>
</div>
</template>
<!-- 步骤四预览 -->
<template v-if="currentStep === 4">
<div class="job-resume-custom-dialog__preview">
<!-- 左侧简历模板预览 -->
<div class="job-resume-custom-dialog__preview-left">
<JobResumeTemplate :resumeData="resumeTemplateData" ref="resumeTemplateRef" />
</div>
<!-- 右侧AI帮写 / 编辑 tab -->
<div class="job-resume-custom-dialog__preview-right">
<!-- Tab 切换 -->
<div class="job-resume-custom-dialog__preview-tabs">
<button
class="job-resume-custom-dialog__preview-tab"
:class="{ 'job-resume-custom-dialog__preview-tab--active': previewTab === 'ai' }"
@click="previewTab = 'ai'"
>AI帮写</button>
<button
class="job-resume-custom-dialog__preview-tab"
:class="{ 'job-resume-custom-dialog__preview-tab--active': previewTab === 'edit' }"
@click="previewTab = 'edit'"
>编辑</button>
</div>
<!-- AI帮写内容 -->
<div v-if="previewTab === 'ai'" class="job-resume-custom-dialog__preview-ai">
<!-- 匹配度提升提示 -->
<div class="job-resume-custom-dialog__ai-result">
<div class="job-resume-custom-dialog__ai-result-text">
<p class="job-resume-custom-dialog__ai-result-title">恭喜你的简历匹配值从<br/>{{ jobInfo.matchScore }}分提升到了10分</p>
<div class="job-resume-custom-dialog__ai-result-detail">
<p class="job-resume-custom-dialog__ai-result-subtitle">做了哪些优化</p>
<ul class="job-resume-custom-dialog__ai-result-list">
<li v-for="(item, i) in aiOptimizeResults" :key="i">·{{ item }}</li>
</ul>
</div>
</div>
<div class="job-resume-custom-dialog__ai-result-score">
<div class="job-resume-custom-dialog__match-ring job-resume-custom-dialog__match-ring--large">
<svg viewBox="0 0 60 60" class="job-resume-custom-dialog__ring-svg">
<circle cx="30" cy="30" r="24" stroke-width="4" stroke="#E8E8E8" fill="none" opacity="0.3"/>
<circle cx="30" cy="30" r="24" stroke-width="4" fill="none" stroke="#4FC2C9" stroke-linecap="round" :stroke-dasharray="2*Math.PI*24" :stroke-dashoffset="0" transform="rotate(-90 30 30)"/>
</svg>
<span class="job-resume-custom-dialog__match-score">10.0</span>
</div>
<span class="job-resume-custom-dialog__match-label job-resume-custom-dialog__match-label--high">非常匹配</span>
</div>
</div>
<!-- 快捷操作按钮 -->
<div class="job-resume-custom-dialog__ai-quick-actions">
<button
v-for="(action, i) in aiQuickActions"
:key="i"
class="job-resume-custom-dialog__ai-quick-btn"
@click="sendAiMessage(action)"
>{{ action }}</button>
</div>
<!-- AI聊天消息区域 -->
<div class="job-resume-custom-dialog__ai-messages" ref="aiMessagesRef">
<div
v-for="(msg, i) in aiMessages"
:key="i"
class="job-resume-custom-dialog__ai-msg"
:class="msg.role === 'ai' ? 'job-resume-custom-dialog__ai-msg--ai' : 'job-resume-custom-dialog__ai-msg--user'"
>
<div class="job-resume-custom-dialog__ai-msg-bubble">{{ msg.content }}</div>
</div>
</div>
<!-- AI输入框 -->
<div class="job-resume-custom-dialog__ai-input-area">
<input
v-model="aiInputText"
class="job-resume-custom-dialog__ai-input"
placeholder="你要怎么优化"
@keyup.enter="sendAiMessage(aiInputText)"
/>
<button class="job-resume-custom-dialog__ai-send-btn" @click="sendAiMessage(aiInputText)">
<svg viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__ai-send-icon">
<path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
<!-- 编辑内容占位 -->
<div v-if="previewTab === 'edit'" class="job-resume-custom-dialog__preview-edit">
<div class="job-resume-custom-dialog__placeholder">编辑功能待开发</div>
</div>
</div>
</div>
</template>
</div>
<!-- 抽屉底部按钮 -->
<div v-if="currentStep < 4" class="job-resume-custom-dialog__drawer-footer">
<button class="job-resume-custom-dialog__primary-btn" @click="handleDrawerNext">立即定制简历</button>
</div>
<!-- 步骤四专属底部下载简历 + 立即去投递 -->
<div v-if="currentStep === 4" class="job-resume-custom-dialog__preview-footer">
<!-- 左侧下载简历按钮带下拉 -->
<div class="job-resume-custom-dialog__download-wrap">
<button class="job-resume-custom-dialog__download-btn" @click="toggleDownloadMenu">下载简历</button>
<div v-if="showDownloadMenu" class="job-resume-custom-dialog__download-menu">
<button class="job-resume-custom-dialog__download-option" @click="handleDownload('pdf')">下载PDF</button>
<button class="job-resume-custom-dialog__download-option" @click="handleDownload('word')">下载Word</button>
</div>
</div>
<!-- 右侧立即去投递按钮 -->
<button class="job-resume-custom-dialog__submit-btn" @click="handleSubmit">立即去投递</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
import { fetchProfile, fetchEducation, fetchWork, fetchInternship, fetchProject, fetchCompetition } from '@/api/profile'
// ==================== 类型定义 ====================
/** 简历选项 */
interface ResumeOption {
id: string
name: string
targetJob: string
}
/** 岗位信息 */
interface JobInfo {
title: string
company: string
companyLogoUrl: string
location: string
matchScore: number
missingSkills: string[]
keywords: string[]
sourceUrl: string
}
/** AI聊天消息 */
interface AiChatMsg {
role: 'ai' | 'user'
content: string
}
// ==================== Props & Emits ====================
const props = defineProps<{
/** 控制弹窗显隐 */
modelValue: boolean
/** 岗位信息 */
jobInfo: JobInfo
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'skip'): void
(e: 'submit'): void
}>()
// ==================== 步骤控制 ====================
/** 当前步骤:1-确认入口(居中弹窗) 2-差距分析(右侧抽屉) 3-定制简历 4-预览 */
const currentStep = ref(1)
/** 缺少的技能列表 */
const missingSkills = computed(() => props.jobInfo.missingSkills || [])
/** 匹配度等级文案 */
const matchLevelText = computed(() => {
const score = props.jobInfo.matchScore
if (score >= 8) return '高匹配度'
if (score >= 5) return '中匹配度'
return '低匹配度'
})
/** 跳转到指定步骤 */
function goToStep(step: number) {
if (step === 3) initSkillOptions()
if (step === 4) loadResumeData()
currentStep.value = step
}
/** 抽屉模式下一步 */
function handleDrawerNext() {
if (currentStep.value < 4) {
if (currentStep.value === 2) initSkillOptions()
if (currentStep.value === 3) loadResumeData()
currentStep.value++
}
}
/** 关闭弹窗并重置步骤 */
function handleClose() {
currentStep.value = 1
showResumeDropdown.value = false
showDownloadMenu.value = false
emit('update:modelValue', false)
}
/** 不优化,直接投递 */
function handleSkip() {
handleClose()
emit('skip')
}
// ==================== 定制简历选项(步骤三) ====================
/** 优化部分选项 */
interface OptimizeSection {
key: string
label: string
checked: boolean
tooltip?: string
}
/** 技能关键词选项 */
interface SkillOption {
name: string
checked: boolean
}
/** 左侧:可优化的简历部分 */
const optimizeSections = ref<OptimizeSection[]>([
{ key: 'summary', label: '个人概述', checked: false },
{ key: 'skills', label: '技能', checked: false, tooltip: '我们将把您勾选的技能补充进简历的技能模块。这对于简历能否通过ATS关键词筛选至关重要。' },
{ key: 'experience', label: '工作经验', checked: false, tooltip: '我们将把您选择的技能融入工作经历中,并对关键词进行润色,最大程度提升简历与岗位的匹配度。' },
])
/** 右侧:可新增的技能关键词 */
const newSkillOptions = ref<SkillOption[]>([])
/** 根据缺失技能初始化技能选项 */
function initSkillOptions() {
newSkillOptions.value = missingSkills.value.map(skill => ({
name: skill,
checked: false,
}))
}
// ==================== 简历选择(步骤二) ====================
/** 简历列表(模拟数据,后续对接接口) */
const resumeList = ref<ResumeOption[]>([
{ id: '1', name: '李华_产品经理', targetJob: '电商产品经理' },
{ id: '2', name: '李华_数据分析', targetJob: '数据分析师' },
])
/** 当前选中的简历 */
const selectedResume = ref<ResumeOption>(resumeList.value[0])
/** 简历下拉是否展开 */
const showResumeDropdown = ref(false)
/** 切换简历下拉 */
function toggleResumeDropdown() {
showResumeDropdown.value = !showResumeDropdown.value
}
/** 选择简历 */
function selectResume(r: ResumeOption) {
selectedResume.value = r
showResumeDropdown.value = false
}
// ==================== 步骤四:预览相关 ====================
/** 简历模板组件引用 */
const resumeTemplateRef = ref()
/** 简历模板数据 */
const resumeTemplateData = ref<ResumeTemplateData>({
name: '',
email: '',
mobileNumber: '',
wechatNumber: '',
summary: '',
educations: [],
workExperiences: [],
internships: [],
projects: [],
competitions: [],
skills: [],
certificates: [],
})
/** 从接口加载个人资料并组装简历数据 */
async function loadResumeData() {
try {
// 并行请求所有个人资料数据
const [profileRes, eduRes, workRes, internRes, projRes, compRes] = await Promise.all([
fetchProfile(),
fetchEducation(),
fetchWork(),
fetchInternship(),
fetchProject(),
fetchCompetition(),
])
// 组装简历模板数据
const profile = profileRes.code === '0' ? profileRes.data : null
resumeTemplateData.value = {
name: profile?.name || '未填写姓名',
email: profile?.email || '',
mobileNumber: profile?.mobileNumber || '',
wechatNumber: profile?.wechatNumber || '',
summary: '', // 个人概述字段,后续由AI生成或用户编辑
educations: eduRes.code === '0' && eduRes.data ? eduRes.data.map(e => ({
school: e.school || '',
major: e.major || '',
degree: e.degree || 2,
startDate: e.startDate || '',
endDate: e.endDate || '',
description: e.description,
})) : [],
workExperiences: workRes.code === '0' && workRes.data ? workRes.data.map(w => ({
companyName: w.companyName || '',
position: w.position || '',
startDate: w.startDate || '',
endDate: w.endDate || '',
description: w.description,
})) : [],
internships: internRes.code === '0' && internRes.data ? internRes.data.map(i => ({
companyName: i.companyName || '',
position: i.position || '',
startDate: i.startDate || '',
endDate: i.endDate || '',
description: i.description,
})) : [],
projects: projRes.code === '0' && projRes.data ? projRes.data.map(p => ({
projectName: p.projectName || '',
companyName: p.companyName || '',
role: p.role || '',
startDate: p.startDate || '',
endDate: p.endDate || '',
description: p.description,
})) : [],
competitions: compRes.code === '0' && compRes.data ? compRes.data.map(c => ({
competitionName: c.competitionName || '',
award: c.award || '',
awardDate: c.awardDate || '',
description: c.description,
})) : [],
skills: profile?.skills || [],
certificates: profile?.certificates || [],
}
} catch (err) {
console.error('[JobResumeCustomDialog] 加载简历数据失败', err)
}
}
/** 当前预览右侧tabai-AI帮写 / edit-编辑 */
const previewTab = ref<'ai' | 'edit'>('ai')
/** AI优化结果列表(模拟数据) */
const aiOptimizeResults = ref<string[]>([
'增加了个人概述',
'优化了5段经历描述',
])
/** AI快捷操作按钮 */
const aiQuickActions = ref<string[]>([
'精简一下第一段工作经历',
'帮我强化一下简历里面的量化成果',
'删掉和这个岗位不相关的技能',
])
/** AI聊天消息列表 */
const aiMessages = ref<AiChatMsg[]>([])
/** AI输入框内容 */
const aiInputText = ref('')
/** AI消息区域DOM引用 */
const aiMessagesRef = ref<HTMLElement>()
/** 发送AI消息 */
function sendAiMessage(text: string) {
if (!text.trim()) return
aiMessages.value.push({ role: 'user', content: text.trim() })
aiInputText.value = ''
// TODO: 接入AI聊天接口,获取AI回复
nextTick(() => {
if (aiMessagesRef.value) {
aiMessagesRef.value.scrollTop = aiMessagesRef.value.scrollHeight
}
})
}
/** 下载菜单是否展开 */
const showDownloadMenu = ref(false)
/** 切换下载菜单 */
function toggleDownloadMenu() {
showDownloadMenu.value = !showDownloadMenu.value
}
/** 处理下载(PDF/Word */
function handleDownload(type: 'pdf' | 'word') {
showDownloadMenu.value = false
// TODO: 实现简历HTML转PDF/Word下载
console.log(`[下载简历] 格式: ${type}`)
}
/** 立即去投递 */
function handleSubmit() {
handleClose()
emit('submit')
}
</script>
+238
View File
@@ -0,0 +1,238 @@
<template>
<!-- 简历HTML模板组件用于预览和后续导出PDF/Word -->
<div class="job-resume-template" ref="resumeRef">
<div class="resume-html">
<!-- 姓名 -->
<h1 class="resume-html__name">{{ resumeData.name }}</h1>
<!-- 联系方式 -->
<div class="resume-html__contact">
<span v-if="resumeData.email">邮箱{{ resumeData.email }}</span>
<div class="resume-html__contact-row">
<span v-if="resumeData.mobileNumber">手机{{ resumeData.mobileNumber }}</span>
<span v-if="resumeData.wechatNumber" class="resume-html__separator"></span>
<span v-if="resumeData.wechatNumber">微信号{{ resumeData.wechatNumber }}</span>
</div>
</div>
<!-- 个人概述 -->
<template v-if="resumeData.summary">
<div class="resume-html__section-title">个人概述</div>
<div class="resume-html__divider"></div>
<div class="resume-html__summary">{{ resumeData.summary }}</div>
</template>
<!-- 教育背景 -->
<template v-if="resumeData.educations && resumeData.educations.length">
<div class="resume-html__section-title">教育背景</div>
<div class="resume-html__divider"></div>
<div v-for="(edu, idx) in resumeData.educations" :key="'edu-' + idx" class="resume-html__item">
<div class="resume-html__item-header">
<div class="resume-html__item-left">
<span class="resume-html__item-main">{{ edu.school }}{{ edu.major }}{{ degreeText(edu.degree) }}</span>
<span v-if="edu.description && edu.description.length" class="resume-html__item-desc">
主修课程{{ edu.description.map(d => d.text).join('、') }}
</span>
</div>
<div class="resume-html__item-right">
<span class="resume-html__item-location" v-if="edu.location">{{ edu.location }}</span>
<span class="resume-html__item-date">{{ edu.startDate }} {{ edu.endDate || '至今' }}</span>
</div>
</div>
</div>
</template>
<!-- 工作经历 -->
<template v-if="resumeData.workExperiences && resumeData.workExperiences.length">
<div class="resume-html__section-title">工作经历</div>
<div class="resume-html__divider"></div>
<div v-for="(work, idx) in resumeData.workExperiences" :key="'work-' + idx" class="resume-html__item">
<div class="resume-html__item-header">
<span class="resume-html__item-main">{{ work.companyName }}{{ work.position }}</span>
<div class="resume-html__item-right">
<span class="resume-html__item-location" v-if="work.location">{{ work.location }}</span>
<span class="resume-html__item-date">{{ work.startDate }} {{ work.endDate || '至今' }}</span>
</div>
</div>
<ul v-if="work.description && work.description.length" class="resume-html__desc-list">
<li v-for="(desc, di) in work.description" :key="'wd-' + di">{{ desc.text }}</li>
</ul>
</div>
</template>
<!-- 实习经历 -->
<template v-if="resumeData.internships && resumeData.internships.length">
<div class="resume-html__section-title">实习经历</div>
<div class="resume-html__divider"></div>
<div v-for="(intern, idx) in resumeData.internships" :key="'intern-' + idx" class="resume-html__item">
<div class="resume-html__item-header">
<span class="resume-html__item-main">{{ intern.companyName }}{{ intern.position }}</span>
<div class="resume-html__item-right">
<span class="resume-html__item-location" v-if="intern.location">{{ intern.location }}</span>
<span class="resume-html__item-date">{{ intern.startDate }} {{ intern.endDate || '至今' }}</span>
</div>
</div>
<ul v-if="intern.description && intern.description.length" class="resume-html__desc-list">
<li v-for="(desc, di) in intern.description" :key="'id-' + di">{{ desc.text }}</li>
</ul>
</div>
</template>
<!-- 项目经历 -->
<template v-if="resumeData.projects && resumeData.projects.length">
<div class="resume-html__section-title">项目经历</div>
<div class="resume-html__divider"></div>
<div v-for="(proj, idx) in resumeData.projects" :key="'proj-' + idx" class="resume-html__item">
<div class="resume-html__item-header">
<span class="resume-html__item-main">{{ proj.projectName }}{{ proj.role ? '' + proj.role : '' }}</span>
<div class="resume-html__item-right">
<span class="resume-html__item-location" v-if="proj.companyName">{{ proj.companyName }}</span>
<span class="resume-html__item-date">{{ proj.startDate }} {{ proj.endDate || '至今' }}</span>
</div>
</div>
<ul v-if="proj.description && proj.description.length" class="resume-html__desc-list">
<li v-for="(desc, di) in proj.description" :key="'pd-' + di">{{ desc.text }}</li>
</ul>
</div>
</template>
<!-- 竞赛/获奖经历 -->
<template v-if="resumeData.competitions && resumeData.competitions.length">
<div class="resume-html__section-title">获奖经历</div>
<div class="resume-html__divider"></div>
<div v-for="(comp, idx) in resumeData.competitions" :key="'comp-' + idx" class="resume-html__item">
<div class="resume-html__item-header">
<span class="resume-html__item-main">{{ comp.competitionName }}{{ comp.award ? '' + comp.award : '' }}</span>
<span class="resume-html__item-date" v-if="comp.awardDate">{{ comp.awardDate }}</span>
</div>
<ul v-if="comp.description && comp.description.length" class="resume-html__desc-list">
<li v-for="(desc, di) in comp.description" :key="'cd-' + di">{{ desc.text }}</li>
</ul>
</div>
</template>
<!-- 专业技能 -->
<template v-if="hasSkillsSection">
<div class="resume-html__section-title">专业技能</div>
<div class="resume-html__divider"></div>
<div class="resume-html__skills">
<div v-if="resumeData.skills && resumeData.skills.length" class="resume-html__skill-row">
<span class="resume-html__skill-label">技能</span>
<span>{{ resumeData.skills.join('、') }}</span>
</div>
<div v-if="resumeData.certificates && resumeData.certificates.length" class="resume-html__skill-row">
<span class="resume-html__skill-label">证书</span>
<span>{{ resumeData.certificates.join('、') }}</span>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// ==================== 类型定义 ====================
/** 描述段落 */
interface DescParagraph {
id?: string
text: string
}
/** 教育经历 */
interface ResumeEducation {
school: string
major: string
degree: number
startDate: string
endDate: string
location?: string
description?: DescParagraph[]
}
/** 工作/实习经历 */
interface ResumeWork {
companyName: string
position: string
startDate: string
endDate?: string
location?: string
description?: DescParagraph[]
}
/** 项目经历 */
interface ResumeProject {
projectName: string
companyName?: string
role?: string
startDate: string
endDate?: string
description?: DescParagraph[]
}
/** 竞赛/获奖经历 */
interface ResumeCompetition {
competitionName: string
award?: string
awardDate?: string
description?: DescParagraph[]
}
/** 简历完整数据结构 */
export interface ResumeTemplateData {
/** 姓名 */
name: string
/** 邮箱 */
email?: string
/** 手机号 */
mobileNumber?: string
/** 微信号 */
wechatNumber?: string
/** 个人概述(新增字段) */
summary?: string
/** 教育背景 */
educations?: ResumeEducation[]
/** 工作经历 */
workExperiences?: ResumeWork[]
/** 实习经历 */
internships?: ResumeWork[]
/** 项目经历 */
projects?: ResumeProject[]
/** 竞赛/获奖经历 */
competitions?: ResumeCompetition[]
/** 技能标签 */
skills?: string[]
/** 证书标签 */
certificates?: string[]
}
// ==================== Props ====================
const props = defineProps<{
/** 简历数据 */
resumeData: ResumeTemplateData
}>()
// ==================== DOM引用(供父组件获取HTML内容) ====================
const resumeRef = ref<HTMLElement>()
/** 暴露DOM引用,方便后续导出PDF/Word */
defineExpose({ resumeRef })
// ==================== 工具方法 ====================
/** 学历数字转文字 */
function degreeText(degree: number): string {
const map: Record<number, string> = { 1: '大专', 2: '本科', 3: '硕士', 4: '博士' }
return map[degree] || ''
}
/** 是否有技能相关内容 */
const hasSkillsSection = computed(() => {
return (props.resumeData.skills && props.resumeData.skills.length > 0) ||
(props.resumeData.certificates && props.resumeData.certificates.length > 0)
})
</script>
+22
View File
@@ -657,6 +657,19 @@
</button>
</template>
<!-- ========== 作品集模块 ========== -->
<template v-else-if="module === 'portfolio'">
<div class="profile-drawer__field">
<label class="profile-drawer__label">作品集链接</label>
<textarea
class="profile-drawer__textarea"
placeholder="请输入/粘贴作品集链接"
v-model="portfolioUrl"
rows="6"
></textarea>
</div>
</template>
<!-- ========== 技能模块 ========== -->
<template v-else-if="module === 'skills'">
<!-- 技能标签列表 -->
@@ -859,6 +872,7 @@ const moduleTitleMap: Record<string, string> = {
work: '工作经历',
internship: '实习经历',
project: '项目经历',
portfolio: '作品集',
skills: '技能',
competition: '竞赛',
certificate: '证书',
@@ -1082,6 +1096,9 @@ const removeCompetition = (index: number) => {
/** 技能列表 — 技能模块使用 */
const skillsList = ref<string[]>([])
/** 作品集链接 — 作品集模块使用 */
const portfolioUrl = ref('')
/** 新技能输入框的值 */
const newSkillInput = ref('')
@@ -1185,6 +1202,9 @@ watch(() => props.modelValue, (visible) => {
} else {
competitionList.value = [createEmptyCompetition()]
}
} else if (props.module === 'portfolio') {
// 作品集:用初始数据填充链接
portfolioUrl.value = props.initialData?.portfolioUrl || ''
} else if (props.module === 'skills') {
// 技能:用初始数据填充列表,若无则创建空数组
skillsList.value = props.initialData?.skills ? [...props.initialData.skills] : []
@@ -1234,6 +1254,8 @@ const handleSave = () => {
...item,
description: item.description.map(d => ({ ...d })),
})) })
} else if (props.module === 'portfolio') {
emit('save', { portfolioUrl: portfolioUrl.value })
} else if (props.module === 'skills') {
emit('save', { skills: [...skillsList.value] })
} else if (props.module === 'certificate') {
+22
View File
@@ -19,6 +19,26 @@
</div>
</div>
<!-- 作品集 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">作品集</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('portfolio')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<!-- 有链接时显示可点击链接 -->
<a
v-if="profile.portfolioUrl"
:href="profile.portfolioUrl"
target="_blank"
rel="noopener noreferrer"
class="profile-page-content__portfolio-link"
>{{ profile.portfolioUrl }}</a>
<!-- 无链接时显示占位提示 -->
<span v-else class="profile-page-content__portfolio-empty">暂未添加作品集链接</span>
</div>
<!-- 教育经历 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
@@ -166,6 +186,8 @@ interface ProfileData {
idNumber: string
/** 所在城市编码 — 对应接口字段 regionCode */
regionCode: string
/** 作品集链接 — 对应接口字段 portfolioUrl */
portfolioUrl: string
wechat?: string
education: Array<{
school: string
+72 -11
View File
@@ -94,13 +94,36 @@
<template v-if="activeTab === 'reminder'">
<h2 class="settings-dialog__content-title">岗位更新提醒</h2>
<div class="settings-dialog__reminder-block">
<div class="settings-dialog__reminder-block-title">目标岗位</div>
<div class="settings-dialog__reminder-target">
<div class="settings-dialog__reminder-tags">
<span class="settings-dialog__reminder-tag" v-for="tag in targetTags" :key="tag">{{ tag }}</span>
</div>
<div class="settings-dialog__reminder-block-title-row">
<span class="settings-dialog__reminder-block-title">目标岗位</span>
<button class="settings-dialog__reminder-edit-btn" @click="handleEditTarget">编辑</button>
</div>
<div class="settings-dialog__reminder-target">
<div class="settings-dialog__reminder-group" v-if="intentionCategoryNames.length">
<span class="settings-dialog__reminder-group-label">岗位</span>
<div class="settings-dialog__reminder-tags">
<span class="settings-dialog__reminder-tag" v-for="name in intentionCategoryNames" :key="name">{{ name }}</span>
</div>
</div>
<div class="settings-dialog__reminder-group" v-if="intentionIndustryNames.length">
<span class="settings-dialog__reminder-group-label">行业</span>
<div class="settings-dialog__reminder-tags">
<span class="settings-dialog__reminder-tag" v-for="name in intentionIndustryNames" :key="name">{{ name }}</span>
</div>
</div>
<div class="settings-dialog__reminder-group" v-if="intentionRegionNames.length">
<span class="settings-dialog__reminder-group-label">地区</span>
<div class="settings-dialog__reminder-tags">
<span class="settings-dialog__reminder-tag" v-for="name in intentionRegionNames" :key="name">{{ name }}</span>
</div>
</div>
<div class="settings-dialog__reminder-group">
<span class="settings-dialog__reminder-group-label">类型</span>
<div class="settings-dialog__reminder-tags">
<span class="settings-dialog__reminder-tag">{{ intentionEmploymentLabel }}</span>
</div>
</div>
</div>
</div>
<div class="settings-dialog__reminder-block">
<div class="settings-dialog__reminder-block-title">即时岗位提醒</div>
@@ -192,21 +215,28 @@
</div>
<!-- 退出登录确认弹窗 放在 Teleport 内部确保层级在 overlay 之上 -->
<el-dialog v-model="showLogout" title="退出登录" width="3.6rem" :close-on-click-modal="true" :append-to-body="false" :z-index="2100">
<el-dialog v-model="showLogout" title="退出登录" width="3.6rem" style="line-height: 0.2rem" :close-on-click-modal="true" :append-to-body="false" :z-index="2100">
<p style="font-size: 0.14rem; color: #555; text-align: center;">确定要退出当前账号吗</p>
<template #footer>
<el-button @click="showLogout = false">取消</el-button>
<el-button type="danger" @click="handleLogout">确认退出</el-button>
</template>
</el-dialog>
<!-- 求职目标设置弹窗 -->
<JobGoalDialog v-model="showGoalDialog" />
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ref, reactive, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { logout } from '@/api/auth'
import JobGoalDialog from './JobGoalDialog.vue'
import { resolveRegionName } from '@/utils/region'
import { resolveIndustryName } from '@/utils/industry'
import { resolveJobCategoryName } from '@/utils/jobCategory'
/** 组件 Props — 控制弹窗显示/隐藏 */
const props = defineProps<{ modelValue: boolean }>()
@@ -242,14 +272,45 @@ const reminders = reactive({
frequency: 'unlimited', // 提醒频率:1/2/5/unlimited
})
/** 目标岗位标签列表 */
const targetTags = ref(['产品经理', '全职', '北京'])
/** 求职目标弹窗显示状态 */
const showGoalDialog = ref(false)
/** 编辑目标岗位 */
/** 岗位名称列表 */
const intentionCategoryNames = computed(() => {
const ids = store.state.jobIntention.categoryIds || []
return ids.map((id: number) => resolveJobCategoryName(id))
})
/** 行业名称列表 */
const intentionIndustryNames = computed(() => {
const ids = store.state.jobIntention.industryIds || []
return ids.map((id: number) => resolveIndustryName(id))
})
/** 地区名称列表 */
const intentionRegionNames = computed(() => {
const codes = store.state.jobIntention.regionCodes || []
return codes.map((code: string) => resolveRegionName(code))
})
/** 就业类型标签 */
const intentionEmploymentLabel = computed(() => {
return store.state.jobIntention.employmentType === 1 ? '实习' : '全职'
})
/** 编辑目标岗位 — 打开求职目标弹窗 */
const handleEditTarget = () => {
ElMessage.info('编辑目标岗位功能开发中')
showGoalDialog.value = true
}
/** 弹窗打开时加载求职意向数据 */
watch(() => props.modelValue, (val) => {
if (val && store.state.isAuthenticated) {
store.dispatch('loadCommonData')
store.dispatch('loadJobIntention')
}
})
/** 注销账号 — 弹出二次确认 */
const handleDeleteAccount = () => {
ElMessageBox.confirm('此操作将永久删除你的账号及所有数据,是否继续?', '注销账号', {
+30 -9
View File
@@ -96,6 +96,7 @@ import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
import SettingsDialog from '@/components/SettingsDialog.vue'
import { checkLogin } from '@/api/auth'
import navJobsIcon from '@/assets/images/nav/nav-jobs-icon.png'
import navResumeIcon from '@/assets/images/nav/nav-resume-icon.png'
import navProfileIcon from '@/assets/images/nav/nav-profile-icon.png'
@@ -200,12 +201,20 @@ const footerMenus = computed(() => [
])
/**
* 设置弹窗:需要登录,没 token 则弹登录框
* 设置弹窗:需要登录,通过 checkLogin 接口验证
*/
function handleSettingsNav() {
if (store.state.isAuthenticated) {
showSettingsDialog.value = true
} else {
async function handleSettingsNav() {
try {
const res = await checkLogin()
if (res.code === '0' && res.data === true) {
store.commit('SET_AUTHENTICATED', true)
showSettingsDialog.value = true
} else {
store.commit('SET_AUTHENTICATED', false)
store.dispatch('openLogin')
}
} catch {
store.commit('SET_AUTHENTICATED', false)
store.dispatch('openLogin')
}
}
@@ -213,14 +222,26 @@ function handleSettingsNav() {
/**
* 导航点击处理:
* - 静态页面(Jobs)直接跳转
* - 动态页面需要 token,没有则弹登录框
* - 动态页面通过 checkLogin 接口验证,未登录则弹登录框
*/
const staticNames = staticMenus.map(m => m.name)
function handleNav(item: MenuItem) {
if (staticNames.includes(item.name) || store.state.isAuthenticated) {
async function handleNav(item: MenuItem) {
if (staticNames.includes(item.name)) {
router.push(item.path)
} else {
return
}
try {
const res = await checkLogin()
if (res.code === '0' && res.data === true) {
store.commit('SET_AUTHENTICATED', true)
router.push(item.path)
} else {
store.commit('SET_AUTHENTICATED', false)
store.dispatch('openLogin', item.path)
}
} catch {
store.commit('SET_AUTHENTICATED', false)
store.dispatch('openLogin', item.path)
}
}
+105 -39
View File
@@ -3,8 +3,8 @@
<div class="industry-selector" ref="selectorRef">
<!-- 触发按钮显示已选行业名称或默认文字"行业" -->
<div class="industry-selector__trigger" @click="toggleDropdown">
<span class="industry-selector__display" :title="displayText">{{ displayText }}</span>
<div class="industry-selector__trigger" :style="triggerStyle" @click="toggleDropdown">
<span class="industry-selector__display" :style="displayStyle" :title="displayText">{{ displayText }}</span>
<svg
class="industry-selector__arrow"
:class="{ 'industry-selector__arrow--open': visible }"
@@ -16,7 +16,12 @@
</div>
<!-- 下拉面板 -->
<div v-if="visible" class="industry-selector__panel" @click.stop>
<div
v-if="visible"
class="industry-selector__panel"
:class="{ 'industry-selector__panel--one-col': level === 1 }"
@click.stop
>
<!-- 选中区小方块标签展示已选中的行业名称 -->
<div class="industry-selector__selected-area" v-if="selectedItems.length">
@@ -47,13 +52,12 @@
<div
class="industry-selector__search-item"
v-for="r in searchResults"
:key="r.child.id"
@click="toggleItem(r.child)"
:key="r.node.id"
@click="toggleItem(r.node)"
>
<!-- 格式一级行业名 二级行业名 -->
<span>{{ r.parentName }} {{ r.child.name }}</span>
<span>{{ r.path }}</span>
<!-- 已选中项显示勾选图标 -->
<svg v-if="isSelected(r.child.id)" class="industry-selector__check" viewBox="0 0 12 12">
<svg v-if="isSelected(r.node.id)" class="industry-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
@@ -64,32 +68,40 @@
<div class="industry-selector__search-empty">无匹配结果</div>
</div>
<!-- 提示双击选中一级 allowParentSelect 开启时显示 -->
<div v-if="searchText.length < 2 && allowParentSelect" class="industry-selector__hint">双击可选中一级行业分类</div>
<!-- 分栏联动选择区搜索关键词不足 2 字符时显示 -->
<div v-if="searchText.length < 2" class="industry-selector__columns">
<!-- 左栏一级行业列表 -->
<div class="industry-selector__col industry-selector__col--left">
<div class="industry-selector__col industry-selector__col--left" :class="{ 'industry-selector__col--full': level === 1 }">
<div
class="industry-selector__col-item"
:class="{ 'industry-selector__col-item--active': activeParentId === parent.id }"
:class="{
'industry-selector__col-item--active': level > 1 && activeParentId === parent.id,
'industry-selector__col-item--selected': (level === 1 || allowParentSelect) && isSelected(parent.id)
}"
v-for="parent in industries"
:key="parent.id"
@click="selectParent(parent.id)"
>
{{ parent.name }}
<svg v-if="(level === 1 || allowParentSelect) && isSelected(parent.id)" class="industry-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
</div>
<!-- 右栏当前一级下的二级行业列表 -->
<div class="industry-selector__col industry-selector__col--right">
<!-- 右栏当前一级下的二级行业列表 level=2 时显示 -->
<div v-if="level === 2" class="industry-selector__col industry-selector__col--right">
<template v-if="activeChildren.length">
<div
class="industry-selector__col-item"
:class="{ 'industry-selector__col-item--selected': isSelected(child.id) }"
v-for="child in activeChildren"
:key="child.id"
@click="toggleItem(child)"
@click="toggleItem({ id: child.id, name: child.name, level: child.level })"
>
{{ child.name }}
<!-- 已选中显示勾 -->
<svg v-if="isSelected(child.id)" class="industry-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
@@ -113,6 +125,9 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useStore } from 'vuex'
import type { IndustryChild, IndustryItem } from '@/api/common'
/** 统一的选中节点类型,兼容一二级 */
type SelectedNode = { id: string; name: string; level: number }
// ==================== 事件与属性定义 ====================
/** 向父组件发送已选行业 ID 数组(integer[] */
@@ -126,9 +141,19 @@ const props = withDefaults(
industryIds?: number[]
/** 最多可选数量 */
maxSelect?: number
/** 是否允许双击选中一级行业 */
allowParentSelect?: boolean
/** 展示/选择到第几级:1=只选一级,2=选到二级 */
level?: 1 | 2
/** 父组件传入的触发按钮自定义样式,用于在不同场景下覆盖默认外观 */
triggerStyle?: Record<string, string>
/** 父组件传入的显示文字自定义样式,用于覆盖 max-width 等默认样式 */
displayStyle?: Record<string, string>
}>(),
{
maxSelect: 3,
allowParentSelect: false,
level: 2,
}
)
@@ -151,8 +176,14 @@ const searchText = ref('')
/** 当前选中的一级行业 ID(左栏高亮项) */
const activeParentId = ref<string>('')
/** 已选中的末级(二级)行业列表(内部临时状态,点确认后才同步给父组件 */
const selectedItems = ref<IndustryChild[]>([])
/** 已选中的行业列表(支持一二级混合选择 */
const selectedItems = ref<SelectedNode[]>([])
/** 一级行业上次点击时间戳,用于双击检测 */
const level1LastClickTime = ref<Record<string, number>>({})
/** 双击判定间隔(毫秒) */
const DOUBLE_CLICK_DELAY = 500
// ==================== 计算属性 ====================
@@ -175,15 +206,21 @@ const activeChildren = computed<IndustryChild[]>(() => {
return parent ? parent.children : []
})
/** 搜索结果:对末级行业 name 做模糊匹配,返回带一级父名称的结果列表 */
/** 搜索结果:根据 level 和 allowParentSelect 匹配对应级别 */
const searchResults = computed(() => {
if (searchText.value.length < 2) return []
const keyword = searchText.value.toLowerCase()
const results: { parentName: string; child: IndustryChild }[] = []
const results: { path: string; node: SelectedNode }[] = []
for (const parent of industries.value) {
for (const child of parent.children) {
if (child.name.toLowerCase().includes(keyword)) {
results.push({ parentName: parent.name, child })
// 一级:level=1 时作为末级可搜索,或 allowParentSelect 且 level=2 时可搜索
if ((props.level === 1 || props.allowParentSelect) && parent.name.toLowerCase().includes(keyword)) {
results.push({ path: parent.name, node: { id: parent.id, name: parent.name, level: parent.level } })
}
if (props.level === 2) {
for (const child of parent.children) {
if (child.name.toLowerCase().includes(keyword)) {
results.push({ path: `${parent.name}${child.name}`, node: { id: child.id, name: child.name, level: child.level } })
}
}
}
}
@@ -197,14 +234,34 @@ function isSelected(id: string) {
return selectedIdSet.value.has(id)
}
/** 点击左栏一级行业,切换右栏显示对应二级列表 */
/** 点击左栏一级行业 */
function selectParent(id: string) {
activeParentId.value = id
// level=1 时,一级就是末级,单击直接选中
if (props.level === 1) {
const l1 = industries.value.find(c => c.id === id)
if (l1) toggleItem({ id: l1.id, name: l1.name, level: l1.level })
return
}
if (props.allowParentSelect) {
const now = Date.now()
const lastTime = level1LastClickTime.value[id] || 0
if (now - lastTime < DOUBLE_CLICK_DELAY) {
const l1 = industries.value.find(c => c.id === id)
if (l1) toggleItem({ id: l1.id, name: l1.name, level: l1.level })
level1LastClickTime.value[id] = 0
} else {
activeParentId.value = id
level1LastClickTime.value[id] = now
}
} else {
activeParentId.value = id
}
}
/** 切换某个末级行业的选中/取消状态,超过上限时提示 */
function toggleItem(child: IndustryChild) {
const idx = selectedItems.value.findIndex(i => i.id === child.id)
/** 切换某个行业节点的选中/取消状态(支持一二级),超过上限时提示 */
function toggleItem(node: SelectedNode) {
const idx = selectedItems.value.findIndex(i => i.id === node.id)
if (idx >= 0) {
selectedItems.value.splice(idx, 1)
} else {
@@ -212,12 +269,12 @@ function toggleItem(child: IndustryChild) {
ElMessage.warning(`最多只能选择${props.maxSelect}个行业`)
return
}
selectedItems.value.push({ ...child })
selectedItems.value.push({ ...node })
}
}
/** 从选中区移除指定行业 */
function removeItem(item: IndustryChild) {
function removeItem(item: SelectedNode) {
selectedItems.value = selectedItems.value.filter(i => i.id !== item.id)
}
@@ -268,19 +325,28 @@ onBeforeUnmount(() => {
// ==================== 监听器 ====================
/** 同步外部传入的 industryIds 到内部选中状态 */
/** 同步外部传入的 industryIds 到内部选中状态(支持一二级) */
function syncFromProps() {
const ids = props.industryIds
if (!ids || !industries.value.length) return
const nodeMap = new Map<string, SelectedNode>()
for (const p of industries.value) {
nodeMap.set(p.id, { id: p.id, name: p.name, level: p.level })
for (const c of p.children) {
nodeMap.set(c.id, { id: c.id, name: c.name, level: c.level })
}
}
selectedItems.value = ids
.map(id => nodeMap.get(String(id)))
.filter(Boolean) as SelectedNode[]
}
watch(
() => props.industryIds,
(ids) => {
if (!ids || !industries.value.length) return
const allChildren: IndustryChild[] = []
for (const p of industries.value) {
allChildren.push(...p.children)
}
selectedItems.value = ids
.map(id => allChildren.find(c => c.id === String(id)))
.filter(Boolean) as IndustryChild[]
},
() => syncFromProps(),
{ immediate: true }
)
/** 树数据加载完成后重新同步选中项 */
watch(industries, () => syncFromProps())
</script>
+155 -48
View File
@@ -3,8 +3,8 @@
<div class="job-category-selector" ref="selectorRef">
<!-- 触发按钮显示已选岗位名称或默认文字"岗位" -->
<div class="job-category-selector__trigger" @click="toggleDropdown">
<span class="job-category-selector__display" :title="displayText">{{ displayText }}</span>
<div class="job-category-selector__trigger" :style="triggerStyle" @click="toggleDropdown">
<span class="job-category-selector__display" :style="displayStyle" :title="displayText">{{ displayText }}</span>
<svg
class="job-category-selector__arrow"
:class="{ 'job-category-selector__arrow--open': visible }"
@@ -16,7 +16,15 @@
</div>
<!-- 下拉面板 -->
<div v-if="visible" class="job-category-selector__panel" @click.stop>
<div
v-if="visible"
class="job-category-selector__panel"
:class="{
'job-category-selector__panel--one-col': level === 1,
'job-category-selector__panel--two-col': level === 2
}"
@click.stop
>
<!-- 选中区小方块标签展示已选中的岗位名称 -->
<div class="job-category-selector__selected-area" v-if="selectedItems.length">
@@ -47,12 +55,11 @@
<div
class="job-category-selector__search-item"
v-for="r in searchResults"
:key="r.leaf.id"
@click="toggleItem(r.leaf)"
:key="r.node.id"
@click="toggleItem(r.node)"
>
<!-- 格式一级 二级 三级 -->
<span>{{ r.level1Name }} {{ r.level2Name }} {{ r.leaf.name }}</span>
<svg v-if="isSelected(r.leaf.id)" class="job-category-selector__check" viewBox="0 0 12 12">
<span>{{ r.path }}</span>
<svg v-if="isSelected(r.node.id)" class="job-category-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
@@ -63,44 +70,59 @@
<div class="job-category-selector__search-empty">无匹配结果</div>
</div>
<!-- 提示双击选中一二级 allowParentSelect 开启时显示 -->
<div v-if="searchText.length < 2 && allowParentSelect" class="job-category-selector__hint">双击可选中一级/二级岗位分类</div>
<!-- 三栏联动选择区搜索关键词不足 2 字符时显示 -->
<div v-if="searchText.length < 2" class="job-category-selector__columns">
<!-- 左栏一级岗位分类 -->
<div class="job-category-selector__col job-category-selector__col--left">
<div class="job-category-selector__col job-category-selector__col--left" :class="{ 'job-category-selector__col--full': level === 1 }">
<div
class="job-category-selector__col-item"
:class="{ 'job-category-selector__col-item--active': activeLevel1Id === item.id }"
:class="{
'job-category-selector__col-item--active': level > 1 && activeLevel1Id === item.id,
'job-category-selector__col-item--selected': (level === 1 || allowParentSelect) && isSelected(item.id)
}"
v-for="item in categories"
:key="item.id"
@click="selectLevel1(item.id)"
>
{{ item.name }}
<svg v-if="(level === 1 || allowParentSelect) && isSelected(item.id)" class="job-category-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
</div>
<!-- 中栏二级岗位分类 -->
<div class="job-category-selector__col job-category-selector__col--mid">
<!-- 中栏二级岗位分类level >= 2 时显示 -->
<div v-if="level >= 2" class="job-category-selector__col job-category-selector__col--mid" :class="{ 'job-category-selector__col--mid-end': level === 2 }">
<template v-if="level2List.length">
<div
class="job-category-selector__col-item"
:class="{ 'job-category-selector__col-item--active': activeLevel2Id === item.id }"
:class="{
'job-category-selector__col-item--active': level > 2 && activeLevel2Id === item.id,
'job-category-selector__col-item--selected': (level === 2 || allowParentSelect) && isSelected(item.id)
}"
v-for="item in level2List"
:key="item.id"
@click="selectLevel2(item.id)"
>
{{ item.name }}
<svg v-if="(level === 2 || allowParentSelect) && isSelected(item.id)" class="job-category-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
</template>
<div v-else class="job-category-selector__col-empty">请先选择左侧分类</div>
</div>
<!-- 右栏三级岗位末级可选中 -->
<div class="job-category-selector__col job-category-selector__col--right">
<!-- 右栏三级岗位 level=3 时显示 -->
<div v-if="level === 3" class="job-category-selector__col job-category-selector__col--right">
<template v-if="level3List.length">
<div
class="job-category-selector__col-item"
:class="{ 'job-category-selector__col-item--selected': isSelected(item.id) }"
v-for="item in level3List"
:key="item.id"
@click="toggleItem(item)"
@click="toggleItem({ id: item.id, name: item.name, level: item.level })"
>
{{ item.name }}
<svg v-if="isSelected(item.id)" class="job-category-selector__check" viewBox="0 0 12 12">
@@ -126,6 +148,9 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useStore } from 'vuex'
import type { JobCategoryItem, JobCategoryChild, JobCategoryLeaf } from '@/api/common'
/** 统一的选中节点类型,兼容一二三级 */
type SelectedNode = { id: string; name: string; level: number }
// ==================== 事件与属性定义 ====================
/** 向父组件发送已选岗位 ID 数组(integer[] */
@@ -139,9 +164,19 @@ const props = withDefaults(
categoryIds?: number[]
/** 最多可选数量 */
maxSelect?: number
/** 是否允许双击选中一二级分类 */
allowParentSelect?: boolean
/** 展示/选择到第几级:1=只选一级,2=选到二级,3=选到三级 */
level?: 1 | 2 | 3
/** 父组件传入的触发按钮自定义样式,用于在不同场景下覆盖默认外观 */
triggerStyle?: Record<string, string>
/** 父组件传入的显示文字自定义样式,用于覆盖 max-width 等默认样式 */
displayStyle?: Record<string, string>
}>(),
{
maxSelect: 3,
allowParentSelect: false,
level: 3,
}
)
@@ -167,8 +202,17 @@ const activeLevel1Id = ref<string>('')
/** 当前选中的二级分类 ID(中栏高亮项) */
const activeLevel2Id = ref<string>('')
/** 已选中的末级(三级)岗位列表(内部临时状态,点确认后才同步给父组件 */
const selectedItems = ref<JobCategoryLeaf[]>([])
/** 已选中的岗位列表(支持一二三级混合选择 */
const selectedItems = ref<SelectedNode[]>([])
/** 一级分类上次点击时间戳,用于双击检测 */
const level1LastClickTime = ref<Record<string, number>>({})
/** 二级分类上次点击时间戳,用于双击检测 */
const level2LastClickTime = ref<Record<string, number>>({})
/** 双击判定间隔(毫秒) */
const DOUBLE_CLICK_DELAY = 500
// ==================== 计算属性 ====================
@@ -198,16 +242,28 @@ const level3List = computed<JobCategoryLeaf[]>(() => {
return mid ? mid.children : []
})
/** 搜索结果:对三级岗位 name 做模糊匹配,返回带一级二级名称的完整路径 */
/** 搜索结果:根据 level 和 allowParentSelect 匹配对应级别 */
const searchResults = computed(() => {
if (searchText.value.length < 2) return []
const keyword = searchText.value.toLowerCase()
const results: { level1Name: string; level2Name: string; leaf: JobCategoryLeaf }[] = []
const results: { path: string; node: SelectedNode }[] = []
for (const l1 of categories.value) {
for (const l2 of l1.children) {
for (const l3 of l2.children) {
if (l3.name.toLowerCase().includes(keyword)) {
results.push({ level1Name: l1.name, level2Name: l2.name, leaf: l3 })
// 一级:level=1 时作为末级可搜索,或 allowParentSelect 且 level>1 时可搜索
if ((props.level === 1 || props.allowParentSelect) && l1.name.toLowerCase().includes(keyword)) {
results.push({ path: l1.name, node: { id: l1.id, name: l1.name, level: l1.level } })
}
if (props.level >= 2) {
for (const l2 of l1.children) {
// 二级:level=2 时作为末级可搜索,或 allowParentSelect 且 level=3 时可搜索
if ((props.level === 2 || props.allowParentSelect) && l2.name.toLowerCase().includes(keyword)) {
results.push({ path: `${l1.name}${l2.name}`, node: { id: l2.id, name: l2.name, level: l2.level } })
}
if (props.level === 3) {
for (const l3 of l2.children) {
if (l3.name.toLowerCase().includes(keyword)) {
results.push({ path: `${l1.name}${l2.name}${l3.name}`, node: { id: l3.id, name: l3.name, level: l3.level } })
}
}
}
}
}
@@ -222,20 +278,61 @@ function isSelected(id: string) {
return selectedIdSet.value.has(id)
}
/** 点击左栏一级分类,切换中栏并清空右栏 */
/** 点击左栏一级分类 */
function selectLevel1(id: string) {
activeLevel1Id.value = id
activeLevel2Id.value = ''
// level=1 时,一级就是末级,单击直接选中
if (props.level === 1) {
const l1 = categories.value.find(c => c.id === id)
if (l1) toggleItem({ id: l1.id, name: l1.name, level: l1.level })
return
}
if (props.allowParentSelect) {
const now = Date.now()
const lastTime = level1LastClickTime.value[id] || 0
if (now - lastTime < DOUBLE_CLICK_DELAY) {
const l1 = categories.value.find(c => c.id === id)
if (l1) toggleItem({ id: l1.id, name: l1.name, level: l1.level })
level1LastClickTime.value[id] = 0
} else {
activeLevel1Id.value = id
activeLevel2Id.value = ''
level1LastClickTime.value[id] = now
}
} else {
activeLevel1Id.value = id
activeLevel2Id.value = ''
}
}
/** 点击中栏二级分类,切换右栏 */
/** 点击中栏二级分类 */
function selectLevel2(id: string) {
activeLevel2Id.value = id
// level=2 时,二级就是末级,单击直接选中
if (props.level === 2) {
const l2 = level2List.value.find(c => c.id === id)
if (l2) toggleItem({ id: l2.id, name: l2.name, level: l2.level })
return
}
if (props.allowParentSelect) {
const now = Date.now()
const lastTime = level2LastClickTime.value[id] || 0
if (now - lastTime < DOUBLE_CLICK_DELAY) {
const l2 = level2List.value.find(c => c.id === id)
if (l2) toggleItem({ id: l2.id, name: l2.name, level: l2.level })
level2LastClickTime.value[id] = 0
} else {
activeLevel2Id.value = id
level2LastClickTime.value[id] = now
}
} else {
activeLevel2Id.value = id
}
}
/** 切换某个末级岗位的选中/取消状态,超过上限时提示 */
function toggleItem(leaf: JobCategoryLeaf) {
const idx = selectedItems.value.findIndex(i => i.id === leaf.id)
/** 切换某个岗位节点的选中/取消状态(支持一二三级),超过上限时提示 */
function toggleItem(node: SelectedNode) {
const idx = selectedItems.value.findIndex(i => i.id === node.id)
if (idx >= 0) {
selectedItems.value.splice(idx, 1)
} else {
@@ -243,12 +340,12 @@ function toggleItem(leaf: JobCategoryLeaf) {
ElMessage.warning(`最多只能选择${props.maxSelect}个岗位`)
return
}
selectedItems.value.push({ ...leaf })
selectedItems.value.push({ ...node })
}
}
/** 从选中区移除指定岗位 */
function removeItem(item: JobCategoryLeaf) {
function removeItem(item: SelectedNode) {
selectedItems.value = selectedItems.value.filter(i => i.id !== item.id)
}
@@ -298,22 +395,32 @@ onBeforeUnmount(() => {
// ==================== 监听器 ====================
/** 同步外部传入的 categoryIds 到内部选中状态 */
watch(
() => props.categoryIds,
(ids) => {
if (!ids || !categories.value.length) return
// 收集所有三级叶子节点
const allLeaves: JobCategoryLeaf[] = []
for (const l1 of categories.value) {
for (const l2 of l1.children) {
allLeaves.push(...l2.children)
/** 同步外部传入的 categoryIds 到内部选中状态(支持一二三级) */
function syncFromProps() {
const ids = props.categoryIds
if (!ids || !categories.value.length) return
// 收集所有级别节点到一个 map
const nodeMap = new Map<string, SelectedNode>()
for (const l1 of categories.value) {
nodeMap.set(l1.id, { id: l1.id, name: l1.name, level: l1.level })
for (const l2 of l1.children) {
nodeMap.set(l2.id, { id: l2.id, name: l2.name, level: l2.level })
for (const l3 of l2.children) {
nodeMap.set(l3.id, { id: l3.id, name: l3.name, level: l3.level })
}
}
selectedItems.value = ids
.map(id => allLeaves.find(l => l.id === String(id)))
.filter(Boolean) as JobCategoryLeaf[]
},
}
selectedItems.value = ids
.map(id => nodeMap.get(String(id)))
.filter(Boolean) as SelectedNode[]
}
watch(
() => props.categoryIds,
() => syncFromProps(),
{ immediate: true }
)
/** 树数据加载完成后重新同步选中项 */
watch(categories, () => syncFromProps())
</script>
+30 -22
View File
@@ -4,7 +4,7 @@
<!-- 触发按钮显示已选地区名称或默认文字"城市" -->
<div class="region-selector__trigger" :style="triggerStyle" @click="toggleDropdown">
<span class="region-selector__display" :title="displayText">{{ displayText }}</span>
<span class="region-selector__display" :style="displayStyle" :title="displayText">{{ displayText }}</span>
<svg
class="region-selector__arrow"
:class="{ 'region-selector__arrow--open': visible }"
@@ -157,6 +157,8 @@ const props = withDefaults(
maxSelect?: number
/** 父组件传入的触发按钮自定义样式,用于在不同场景下覆盖默认外观 */
triggerStyle?: Record<string, string>
/** 父组件传入的显示文字自定义样式,用于覆盖 max-width 等默认样式 */
displayStyle?: Record<string, string>
}>(),
{
level: 2,
@@ -356,31 +358,37 @@ onBeforeUnmount(() => {
// ==================== 监听器 ====================
/** 同步外部传入的 regionCodes 到内部选中状态 */
watch(
() => props.regionCodes,
(codes) => {
if (!codes || !regions.value.length) return
// 根据 level 收集对应级别的所有节点
const allNodes: SelectedRegion[] = []
for (const province of regions.value) {
if (props.level === 2) {
for (const city of province.children) {
allNodes.push({ code: city.code, name: city.name })
}
} else {
for (const city of province.children) {
if (city.children) {
for (const district of city.children) {
allNodes.push({ code: district.code, name: district.name })
}
function syncFromProps() {
const codes = props.regionCodes
if (!codes || !regions.value.length) return
// 根据 level 收集对应级别的所有节点
const allNodes: SelectedRegion[] = []
for (const province of regions.value) {
if (props.level === 2) {
for (const city of province.children) {
allNodes.push({ code: city.code, name: city.name })
}
} else {
for (const city of province.children) {
if (city.children) {
for (const district of city.children) {
allNodes.push({ code: district.code, name: district.name })
}
}
}
}
selectedItems.value = codes
.map(code => allNodes.find(n => n.code === code))
.filter(Boolean) as SelectedRegion[]
},
}
selectedItems.value = codes
.map(code => allNodes.find(n => n.code === code))
.filter(Boolean) as SelectedRegion[]
}
watch(
() => props.regionCodes,
() => syncFromProps(),
{ immediate: true }
)
/** 树数据加载完成后重新同步选中项(解决并行加载时树数据晚于 prop 到达的问题) */
watch(regions, () => syncFromProps())
</script>