个人资料和岗位列表联调,简历优化部分页面
This commit is contained in:
@@ -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 = ''
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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})` },
|
||||
])
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
|
||||
@@ -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 }} 独角兽</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)
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前预览右侧tab:ai-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>
|
||||
@@ -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>
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('此操作将永久删除你的账号及所有数据,是否继续?', '注销账号', {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user