仓库初始化+岗位相关页面
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="ai-chat">
|
||||
<!-- 顶部会员横幅 — 点击打开会员购买弹窗 -->
|
||||
<div class="ai-chat__banner" @click="showMemberDialog = true">
|
||||
<span>解锁会员,送快上岸!</span>
|
||||
<span class="ai-chat__banner-arrow">›</span>
|
||||
</div>
|
||||
|
||||
<!-- AI 头像和名称 -->
|
||||
<div class="ai-chat__header">
|
||||
<div class="ai-chat__avatar">👤</div>
|
||||
<div class="ai-chat__info">
|
||||
<div class="ai-chat__name">Nova</div>
|
||||
<div class="ai-chat__desc">你的AI求职助手</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天消息区域 -->
|
||||
<div class="ai-chat__messages" ref="messagesRef">
|
||||
<!-- AI 欢迎消息 -->
|
||||
<div class="ai-chat__msg ai-chat__msg--ai">
|
||||
<div class="ai-chat__msg-bubble">
|
||||
<div class="ai-chat__msg-title">欢迎回来,李华!</div>
|
||||
<div class="ai-chat__msg-text">很高兴再次见到你,让我们继续您通往理想工作的旅程吧。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 动态消息列表 -->
|
||||
<div
|
||||
v-for="(msg, i) in messages"
|
||||
:key="i"
|
||||
class="ai-chat__msg"
|
||||
:class="msg.role === 'ai' ? 'ai-chat__msg--ai' : 'ai-chat__msg--user'"
|
||||
>
|
||||
<div class="ai-chat__msg-bubble">{{ msg.content }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷问题 -->
|
||||
<div class="ai-chat__quick-questions">
|
||||
<div
|
||||
v-for="(q, i) in quickQuestions"
|
||||
:key="i"
|
||||
class="ai-chat__quick-item"
|
||||
@click="sendQuickQuestion(q)"
|
||||
>
|
||||
{{ q }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部输入框 -->
|
||||
<div class="ai-chat__input-area">
|
||||
<input
|
||||
v-model="inputText"
|
||||
class="ai-chat__input"
|
||||
placeholder="搜索职位、公司或关键词..."
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<button class="ai-chat__send-btn" @click="sendMessage">
|
||||
<span>➤</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 会员购买弹窗 -->
|
||||
<MemberDialog v-model="showMemberDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import MemberDialog from '@/components/MemberDialog.vue'
|
||||
|
||||
// ==================== 状态 ====================
|
||||
|
||||
/** 会员购买弹窗的显示状态 */
|
||||
const showMemberDialog = ref(false)
|
||||
|
||||
/** 输入框内容 */
|
||||
const inputText = ref('')
|
||||
|
||||
/** 消息列表容器 DOM 引用(用于滚动控制) */
|
||||
const messagesRef = ref<HTMLElement>()
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/** 聊天消息类型 */
|
||||
interface ChatMsg {
|
||||
role: 'ai' | 'user'
|
||||
content: string
|
||||
}
|
||||
|
||||
// ==================== 数据 ====================
|
||||
|
||||
/** 聊天消息列表 */
|
||||
const messages = ref<ChatMsg[]>([])
|
||||
|
||||
/** 快捷问题列表(点击后自动发送) */
|
||||
const quickQuestions = [
|
||||
'你想知道关于这个岗位的什么信息?',
|
||||
'告诉我这个工作为什么适合我?',
|
||||
'我想修改这个岗位,怎么优化简历?',
|
||||
'帮我针对这个岗位生成一份面试攻略',
|
||||
]
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
/** 点击快捷问题,填入输入框并发送 */
|
||||
function sendQuickQuestion(question: string) {
|
||||
inputText.value = question
|
||||
sendMessage()
|
||||
}
|
||||
|
||||
/** 发送消息(回车或点击发送按钮触发) */
|
||||
function sendMessage() {
|
||||
if (!inputText.value.trim()) return
|
||||
messages.value.push({ role: 'user', content: inputText.value.trim() })
|
||||
// TODO: 接入AI聊天接口
|
||||
inputText.value = ''
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:show-close="true"
|
||||
width="4.8rem"
|
||||
class="dislike-dialog"
|
||||
:close-on-click-modal="true"
|
||||
>
|
||||
<!-- 弹窗标题 -->
|
||||
<div class="dislike-dialog__title">为了给您推荐更适合岗位,请告诉我们不匹配的原因:</div>
|
||||
|
||||
<!-- 原因选项列表 — 单选 -->
|
||||
<div class="dislike-dialog__options">
|
||||
<label
|
||||
v-for="option in dislikeOptions"
|
||||
:key="option.value"
|
||||
class="dislike-dialog__option"
|
||||
:class="{ 'dislike-dialog__option--active': dislikeReason === option.value }"
|
||||
>
|
||||
<el-radio v-model="dislikeReason" :value="option.value">{{ option.label }}</el-radio>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 补充描述输入框 -->
|
||||
<el-input
|
||||
v-model="dislikeDetail"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请描述您的原因"
|
||||
class="dislike-dialog__textarea"
|
||||
/>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="dislike-dialog__footer">
|
||||
<button class="dislike-dialog__btn dislike-dialog__btn--cancel" @click="visible = false">取消</button>
|
||||
<button class="dislike-dialog__btn dislike-dialog__btn--submit" @click="handleDislikeSubmit">提交</button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
jobId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val: boolean) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
/** 不感兴趣的原因(单选) */
|
||||
const dislikeReason = ref('')
|
||||
|
||||
/** 不感兴趣的补充描述 */
|
||||
const dislikeDetail = ref('')
|
||||
|
||||
/** 不感兴趣原因选项列表 */
|
||||
const dislikeOptions = [
|
||||
{ value: 'company', label: '对这家公司不感兴趣' },
|
||||
{ value: 'position', label: '对这个岗位不感兴趣' },
|
||||
{ value: 'location', label: '工作地点不合适' },
|
||||
{ value: 'other', label: '其他原因' },
|
||||
]
|
||||
|
||||
/** 提交不感兴趣反馈 */
|
||||
function handleDislikeSubmit() {
|
||||
if (!dislikeReason.value) {
|
||||
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
|
||||
}
|
||||
|
||||
/** 弹窗打开时重置表单 */
|
||||
function resetForm() {
|
||||
dislikeReason.value = ''
|
||||
dislikeDetail.value = ''
|
||||
}
|
||||
|
||||
defineExpose({ resetForm })
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:show-close="true"
|
||||
width="4.8rem"
|
||||
class="dislike-dialog"
|
||||
:close-on-click-modal="true"
|
||||
>
|
||||
<!-- 弹窗标题 -->
|
||||
<div class="dislike-dialog__title dislike-dialog__title--center">问题反馈</div>
|
||||
|
||||
<!-- 反馈原因选项列表 — 单选 -->
|
||||
<div class="dislike-dialog__options">
|
||||
<label
|
||||
v-for="option in feedbackOptions"
|
||||
:key="option.value"
|
||||
class="dislike-dialog__option"
|
||||
:class="{ 'dislike-dialog__option--active': feedbackReason === option.value }"
|
||||
>
|
||||
<el-radio v-model="feedbackReason" :value="option.value">{{ option.label }}</el-radio>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="dislike-dialog__footer">
|
||||
<button class="dislike-dialog__btn dislike-dialog__btn--cancel" @click="visible = false">取消</button>
|
||||
<button class="dislike-dialog__btn dislike-dialog__btn--submit" @click="handleFeedbackSubmit">提交</button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
jobId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val: boolean) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
/** 问题反馈的原因(单选) */
|
||||
const feedbackReason = ref('')
|
||||
|
||||
/** 问题反馈原因选项列表 */
|
||||
const feedbackOptions = [
|
||||
{ value: 'fraud', label: '怀疑是诈骗或虚假岗位' },
|
||||
{ value: 'inaccurate', label: '公司信息或职位描述有误' },
|
||||
{ value: 'expired', label: '该职位已停止招聘/职位已失效' },
|
||||
]
|
||||
|
||||
/** 提交问题反馈 */
|
||||
function handleFeedbackSubmit() {
|
||||
if (!feedbackReason.value) {
|
||||
ElMessage.warning('请选择一个反馈原因')
|
||||
return
|
||||
}
|
||||
// TODO: 调用接口提交问题反馈,参数:props.jobId, feedbackReason.value
|
||||
console.log('提交问题反馈', {
|
||||
jobId: props.jobId,
|
||||
reason: feedbackReason.value,
|
||||
})
|
||||
ElMessage.success('问题反馈已提交,感谢您的反馈')
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 弹窗打开时重置表单 */
|
||||
function resetForm() {
|
||||
feedbackReason.value = ''
|
||||
}
|
||||
|
||||
defineExpose({ resetForm })
|
||||
</script>
|
||||
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<!-- 求职目标设置弹窗 -->
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
width="480px"
|
||||
:show-close="false"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="true"
|
||||
:align-center="false"
|
||||
:style="{ marginLeft: 'auto', marginRight: '0.2rem' }"
|
||||
class="job-goal-dialog"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- 头部区域 -->
|
||||
<div class="job-goal-dialog__header">
|
||||
<!-- 关闭按钮 -->
|
||||
<button class="job-goal-dialog__close-btn" @click="handleClose">
|
||||
<el-icon><Close /></el-icon>
|
||||
</button>
|
||||
<!-- 弹窗标题 -->
|
||||
<span class="job-goal-dialog__title">求职目标设置</span>
|
||||
</div>
|
||||
|
||||
<!-- 岗位选择模块 -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 行业选择模块 -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 城市选择模块 -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 工作类型选择模块 -->
|
||||
<div class="job-goal-dialog__section ">
|
||||
<div class="job-goal-dialog__label">*工作类型</div>
|
||||
<!-- 工作类型按钮组 -->
|
||||
<div class="job-goal-dialog__type-group">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{{ t }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮区域 -->
|
||||
<div class="job-goal-dialog__footer mt10">
|
||||
<button class="job-goal-dialog__save-btn" @click="handleSave">保存</button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { Close } from '@element-plus/icons-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 visible = ref(props.modelValue)
|
||||
/** 监听外部传入的 modelValue 同步弹窗状态 */
|
||||
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: '全职',
|
||||
})
|
||||
|
||||
/** 新增岗位的绑定值 */
|
||||
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)
|
||||
}
|
||||
newPosition.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除岗位
|
||||
* @param index 要移除的岗位索引
|
||||
*/
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
*/
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="job-page-header">
|
||||
<h2 class="job-page-header__title">发现理想职位</h2>
|
||||
<p class="job-page-header__subtitle">找到最适合你的工作机会</p>
|
||||
</div>
|
||||
<div class="job-page-header__tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="job-page-header__tab"
|
||||
:class="{ 'job-page-header__tab--active': activeTab === tab.key }"
|
||||
@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 { 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
|
||||
}>()
|
||||
|
||||
/** 双向绑定事件,在 Jobs 页面内切换 Tab 时触发 */
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:activeTab', value: string): void
|
||||
}>()
|
||||
|
||||
// ==================== 路由相关 ====================
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// ==================== 常量数据 ====================
|
||||
|
||||
/** Tab 选项列表 */
|
||||
const tabs = [
|
||||
{ key: 'recommend', label: '推荐' },
|
||||
{ key: 'collected', label: '收藏(1)' },
|
||||
{ key: 'applied', label: '已投递(2)' },
|
||||
]
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
/**
|
||||
* Tab 点击处理:
|
||||
* - 在 Jobs 页面:直接切换 activeTab(通过 emit 通知父组件)
|
||||
* - 在其他页面(如 JobDetail):跳转到 Jobs 页面并携带 tab query 参数
|
||||
*/
|
||||
function handleTabClick(key: string) {
|
||||
if (route.name === 'Jobs') {
|
||||
emit('update:activeTab', key)
|
||||
} else {
|
||||
router.push({ name: 'Jobs', query: { tab: key } })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
width="480px"
|
||||
:show-close="true"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="true"
|
||||
class="login-dialog"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="login-page">
|
||||
<h1 class="login-title">欢迎使用Offer派</h1>
|
||||
|
||||
<div class="login-form">
|
||||
<el-input
|
||||
v-model="phone"
|
||||
placeholder="输入手机号"
|
||||
size="large"
|
||||
class="login-input mt20"
|
||||
/>
|
||||
<div class="code-row mt12 mb12">
|
||||
<el-input
|
||||
v-model="code"
|
||||
placeholder="输入验证码"
|
||||
size="large"
|
||||
class="login-input"
|
||||
/>
|
||||
<el-button
|
||||
class="send-code-btn"
|
||||
:disabled="countdown > 0"
|
||||
@click="sendCode"
|
||||
>
|
||||
{{ countdown > 0 ? countdownText : '发送验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="login-btn h50 border-ra20"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sendSmsCode, smsLogin } from '@/api/auth'
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
const countdown = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 倒计时显示文本,格式 m:ss */
|
||||
const countdownText = computed(() => {
|
||||
const m = Math.floor(countdown.value / 60)
|
||||
const s = countdown.value % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const visible = computed({
|
||||
get: () => store.state.showLogin,
|
||||
set: (val: boolean) => {
|
||||
if (!val) store.dispatch('closeLogin')
|
||||
},
|
||||
})
|
||||
|
||||
//发送验证码
|
||||
async function sendCode() {
|
||||
if (!phone.value) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await sendSmsCode(phone.value)
|
||||
if (res.code === '0' && res.data === true) {
|
||||
ElMessage.success('验证码已发送')
|
||||
countdown.value = 300
|
||||
timer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0 && timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
ElMessage.error(res.msg || '验证码发送失败')
|
||||
}
|
||||
} catch {
|
||||
// 错误已在 request 拦截器中统一处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录流程:
|
||||
* 1. 调 smsLogin 接口,后端成功会 Set-Cookie: Token=xxx
|
||||
* 2. Cookie 自动保持登录状态,不再存 localStorage
|
||||
* 3. 关闭弹窗,跳转到登录前想去的页面
|
||||
*/
|
||||
async function handleLogin() {
|
||||
if (!phone.value || !code.value) {
|
||||
ElMessage.warning('请输入手机号和验证码')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await smsLogin(phone.value, code.value)
|
||||
if (res.code !== '0') {
|
||||
ElMessage.error(res.msg || '登录失败')
|
||||
return
|
||||
}
|
||||
|
||||
// 登录成功,Cookie Token 已由后端 Set-Cookie 写入
|
||||
ElMessage.success(`欢迎回来,${res.data?.nick || ''}`)
|
||||
|
||||
// 清除验证码倒计时,登录成功后解除发送限制
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
countdown.value = 0
|
||||
|
||||
store.commit('SET_AUTHENTICATED', true)
|
||||
|
||||
const redirect = store.state.loginRedirect
|
||||
store.dispatch('closeLogin')
|
||||
if (redirect) {
|
||||
router.push(redirect)
|
||||
}
|
||||
} catch {
|
||||
// 错误已在 request 拦截器中统一处理
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
store.dispatch('closeLogin')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<!-- 会员购买弹窗 — 通过 Teleport 挂载到 body -->
|
||||
<Teleport to="body">
|
||||
<!-- 遮罩层 — 点击关闭弹窗 -->
|
||||
<div v-if="modelValue" class="member-dialog-overlay" @click="$emit('update:modelValue', false)">
|
||||
<!-- 弹窗主体 — 阻止点击冒泡 -->
|
||||
<div class="member-dialog" @click.stop>
|
||||
<!-- 右上角关闭按钮 -->
|
||||
<span class="member-dialog__close" @click="$emit('update:modelValue', false)">✕</span>
|
||||
|
||||
<!-- 顶部标语 -->
|
||||
<div class="member-dialog__slogan">每天不到一元,获得三倍面试机会!</div>
|
||||
|
||||
<!-- 套餐卡片区域 -->
|
||||
<div class="member-dialog__plans">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.key"
|
||||
class="member-dialog__plan-card"
|
||||
:class="{ 'member-dialog__plan-card--active': selectedPlan === plan.key }"
|
||||
@click="selectedPlan = plan.key"
|
||||
>
|
||||
<!-- 套餐名称 -->
|
||||
<div class="member-dialog__plan-name">{{ plan.name }}</div>
|
||||
<!-- 原价(划线) -->
|
||||
<div class="member-dialog__plan-original">{{ plan.originalPrice }}</div>
|
||||
<!-- 现价 -->
|
||||
<div class="member-dialog__plan-price">
|
||||
<span class="member-dialog__plan-price-num">¥{{ plan.price }}</span>
|
||||
<span class="member-dialog__plan-price-unit">/{{ plan.unit }}</span>
|
||||
<!-- 省钱标签 -->
|
||||
<span v-if="plan.discount" class="member-dialog__plan-discount">省{{ plan.discount }}</span>
|
||||
</div>
|
||||
<!-- 立刻升级按钮 -->
|
||||
<button class="member-dialog__plan-btn" @click.stop="handleUpgrade(plan)">立刻升级</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 权益对比区域 -->
|
||||
<div class="member-dialog__benefits">
|
||||
<!-- 左列:面试率提升工具 -->
|
||||
<div class="member-dialog__benefit-col">
|
||||
<div class="member-dialog__benefit-title">面试率提升工具</div>
|
||||
<div
|
||||
v-for="item in toolList"
|
||||
:key="item"
|
||||
class="member-dialog__benefit-item"
|
||||
>{{ item }}</div>
|
||||
<div class="member-dialog__benefit-other">
|
||||
其他渠道购买 <span class="member-dialog__benefit-highlight">¥210/月</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中列:会员权益 -->
|
||||
<div class="member-dialog__benefit-col member-dialog__benefit-col--center">
|
||||
<div class="member-dialog__benefit-title member-dialog__benefit-title--accent">会员权益</div>
|
||||
<div
|
||||
v-for="item in memberBenefits"
|
||||
:key="item.label"
|
||||
class="member-dialog__benefit-item"
|
||||
>
|
||||
<span class="member-dialog__benefit-badge">{{ item.badge }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<div class="member-dialog__benefit-other">
|
||||
打包价 <span class="member-dialog__benefit-highlight">¥19.99/月</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右列:免费权益(当前) -->
|
||||
<div class="member-dialog__benefit-col">
|
||||
<div class="member-dialog__benefit-title">免费权益(当前)</div>
|
||||
<div
|
||||
v-for="item in freeBenefits"
|
||||
:key="item"
|
||||
class="member-dialog__benefit-item"
|
||||
>{{ item }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
/** 组件 Props — 控制弹窗显示/隐藏 */
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
|
||||
/** 组件 Emits — 通知父组件更新 modelValue */
|
||||
defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/** 套餐项类型 */
|
||||
interface PlanItem {
|
||||
key: string
|
||||
name: string
|
||||
originalPrice: string
|
||||
price: string
|
||||
unit: string
|
||||
discount: string
|
||||
}
|
||||
|
||||
// ==================== 状态 ====================
|
||||
|
||||
/** 当前选中的套餐 */
|
||||
const selectedPlan = ref('monthly')
|
||||
|
||||
/** 监听弹窗开关 — 打开时锁定背景滚动,关闭时恢复 */
|
||||
watch(() => props.modelValue, (val) => {
|
||||
document.body.style.overflow = val ? 'hidden' : ''
|
||||
})
|
||||
|
||||
// ==================== 常量数据 ====================
|
||||
|
||||
/** 套餐列表 */
|
||||
const plans: PlanItem[] = [
|
||||
{ key: 'quarterly', name: '季度会员', originalPrice: '¥149.97 / 3个月', price: '49.99', unit: '3个月', discount: '67%' },
|
||||
{ key: 'monthly', name: '月度会员', originalPrice: '¥49.99 / 1个月', price: '19.99', unit: '月', discount: '60%' },
|
||||
{ key: 'weekly', name: '周会员', originalPrice: '¥71.96 / 1个月', price: '17.99', unit: '1周', discount: '' },
|
||||
]
|
||||
|
||||
/** 面试率提升工具列表 */
|
||||
const toolList = [
|
||||
'AI 求职助手',
|
||||
'AI 针对性简历优化',
|
||||
'1V1真人导师',
|
||||
'一键自动填写网申信息',
|
||||
'内推码',
|
||||
'实时岗位更新提醒',
|
||||
]
|
||||
|
||||
/** 会员权益列表 */
|
||||
const memberBenefits = [
|
||||
{ badge: '无限', label: '自动化网申流程' },
|
||||
{ badge: '无限', label: '简历过筛率提升' },
|
||||
{ badge: '✓', label: '找工作不孤单' },
|
||||
{ badge: '无限', label: '每一次网申节约15Min' },
|
||||
{ badge: '无限', label: '自动填写最新内推码' },
|
||||
{ badge: '无限', label: '第一时间投递高质量岗位' },
|
||||
]
|
||||
|
||||
/** 免费权益列表 */
|
||||
const freeBenefits = [
|
||||
'受限',
|
||||
'2次/天',
|
||||
'不支持',
|
||||
'4次/天',
|
||||
'4次/天',
|
||||
'1次/天',
|
||||
]
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
/** 点击立刻升级按钮 */
|
||||
function handleUpgrade(plan: PlanItem) {
|
||||
// TODO: 接入支付接口
|
||||
ElMessage.success(`正在跳转 ${plan.name} 支付页面...`)
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="profile-page-content">
|
||||
<!-- 个人信息 -->
|
||||
<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('info')">
|
||||
<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>
|
||||
<div class="profile-page-content__info-name">{{ profile.name }}</div>
|
||||
<div class="profile-page-content__info-contacts">
|
||||
<span class="profile-page-content__contact-item">📱 {{ profile.phone }}</span>
|
||||
<span class="profile-page-content__contact-item">✉️ {{ profile.email }}</span>
|
||||
<span class="profile-page-content__contact-item">📍 {{ resolveRegionName(profile.regionCode) }}</span>
|
||||
</div>
|
||||
<div class="profile-page-content__info-contacts" v-if="profile.wechat">
|
||||
<span class="profile-page-content__contact-item">💬 {{ profile.wechat }}</span>
|
||||
</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('education')">
|
||||
<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>
|
||||
<div v-for="(edu, i) in profile.education" :key="i" class="profile-page-content__section-item">
|
||||
<div class="profile-page-content__item-row">
|
||||
<div class="profile-page-content__item-left">
|
||||
<div class="profile-page-content__item-title">{{ edu.school }}</div>
|
||||
<div class="profile-page-content__item-sub">{{ getDegreeText(edu.studyType, edu.degree) }} · {{ edu.major }}</div>
|
||||
<div class="profile-page-content__item-date">{{ edu.startDate }} - {{ edu.endDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="profile-page-content__item-list" v-if="edu.description?.length && edu.description[0]?.text">
|
||||
<li v-for="desc in edu.description" :key="desc.id">{{ desc.text }}</li>
|
||||
</ul>
|
||||
</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('work')">
|
||||
<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>
|
||||
<div v-for="(exp, i) in profile.works" :key="i" class="profile-page-content__section-item">
|
||||
<div class="profile-page-content__item-row">
|
||||
<div class="profile-page-content__item-left">
|
||||
<div class="profile-page-content__item-title">{{ exp.companyName }}</div>
|
||||
<div class="profile-page-content__item-sub">{{ exp.position }}</div>
|
||||
</div>
|
||||
<div class="profile-page-content__item-period">{{ exp.startDate }} - {{ exp.endDate || '至今' }}</div>
|
||||
</div>
|
||||
<ul class="profile-page-content__item-list">
|
||||
<li v-for="desc in exp.description" :key="desc.id">{{ desc.text }}</li>
|
||||
</ul>
|
||||
</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('internship')">
|
||||
<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>
|
||||
<div v-for="(exp, i) in profile.internships" :key="i" class="profile-page-content__section-item">
|
||||
<div class="profile-page-content__item-row">
|
||||
<div class="profile-page-content__item-left">
|
||||
<div class="profile-page-content__item-title">{{ exp.companyName }}</div>
|
||||
<div class="profile-page-content__item-sub">{{ exp.position }}</div>
|
||||
</div>
|
||||
<div class="profile-page-content__item-period">{{ exp.startDate }} - {{ exp.endDate }}</div>
|
||||
</div>
|
||||
<ul class="profile-page-content__item-list">
|
||||
<li v-for="desc in exp.description" :key="desc.id">{{ desc.text }}</li>
|
||||
</ul>
|
||||
</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('project')">
|
||||
<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>
|
||||
<div v-for="(proj, i) in profile.projects" :key="i" class="profile-page-content__section-item">
|
||||
<div class="profile-page-content__item-row">
|
||||
<div class="profile-page-content__item-left">
|
||||
<div class="profile-page-content__item-title">{{ proj.projectName }}</div>
|
||||
<div class="profile-page-content__item-sub">{{ proj.role }}</div>
|
||||
</div>
|
||||
<div class="profile-page-content__item-period">{{ proj.startDate }} - {{ proj.endDate }}</div>
|
||||
</div>
|
||||
<ul class="profile-page-content__item-list">
|
||||
<li v-for="desc in proj.description" :key="desc.id">{{ desc.text }}</li>
|
||||
</ul>
|
||||
</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('skills')">
|
||||
<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>
|
||||
<div class="profile-page-content__tags">
|
||||
<span v-for="(skill, i) in profile.skills" :key="i" class="profile-page-content__tag">{{ skill }}</span>
|
||||
</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('competition')">
|
||||
<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>
|
||||
<div v-for="(comp, i) in profile.competitions" :key="i" class="profile-page-content__section-item">
|
||||
<div class="profile-page-content__item-row">
|
||||
<div class="profile-page-content__item-left">
|
||||
<div class="profile-page-content__item-title">{{ comp.competitionName }} · {{ comp.award }}</div>
|
||||
</div>
|
||||
<div class="profile-page-content__item-period">{{ comp.awardDate }}</div>
|
||||
</div>
|
||||
<ul class="profile-page-content__item-list" v-if="comp.description[0]?.text">
|
||||
<li v-for="desc in comp.description" :key="desc.id">{{ desc.text }}</li>
|
||||
</ul>
|
||||
</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('certificate')">
|
||||
<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>
|
||||
<div class="profile-page-content__tags">
|
||||
<span v-for="(cert, i) in profile.certificates" :key="i" class="profile-page-content__tag">{{ cert }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { resolveRegionName } from '@/utils/region'
|
||||
|
||||
interface ProfileData {
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
idNumber: string
|
||||
/** 所在城市编码 — 对应接口字段 regionCode */
|
||||
regionCode: string
|
||||
wechat?: string
|
||||
education: Array<{
|
||||
school: string
|
||||
major: string
|
||||
studyType: number
|
||||
degree: number
|
||||
startDate: string
|
||||
endDate: string
|
||||
description: Array<{ id: string; text: string }>
|
||||
}>
|
||||
works: Array<{
|
||||
companyName: string
|
||||
position: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
description: Array<{ id: string; text: string }>
|
||||
}>
|
||||
internships: Array<{
|
||||
companyName: string
|
||||
position: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
description: Array<{ id: string; text: string }>
|
||||
}>
|
||||
projects: Array<{
|
||||
projectName: string
|
||||
companyName: string
|
||||
role: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
description: Array<{ id: string; text: string }>
|
||||
}>
|
||||
skills: string[]
|
||||
competitions: Array<{
|
||||
competitionName: string
|
||||
award: string
|
||||
awardDate: string
|
||||
description: Array<{ id: string; text: string }>
|
||||
}>
|
||||
certificates: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
profile: ProfileData
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [section: string]
|
||||
}>()
|
||||
|
||||
/** 获取学历文本 — 根据学历类型和学历代码生成显示文本 */
|
||||
function getDegreeText(studyType: number, degree: number): string {
|
||||
const studyTypeText = studyType === 0 ? '全日制' : '非全日制'
|
||||
const degreeMap: Record<number, string> = {
|
||||
1: '大专',
|
||||
2: '本科',
|
||||
3: '硕士',
|
||||
4: '博士',
|
||||
}
|
||||
return `${studyTypeText}${degreeMap[degree] || ''}`
|
||||
}
|
||||
|
||||
function handleEdit(section: string) {
|
||||
emit('edit', section)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '../assets/styles/components/profile-page-content';
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<!-- 设置弹窗 — 通过 Teleport 挂载到 body,避免被父组件样式影响 -->
|
||||
<Teleport to="body">
|
||||
<!-- 遮罩层 — 点击关闭弹窗 -->
|
||||
<div v-if="modelValue" class="settings-dialog-overlay" @click="$emit('update:modelValue', false)">
|
||||
<!-- 弹窗主体 — 阻止点击冒泡到遮罩层 -->
|
||||
<div class="settings-dialog" @click.stop>
|
||||
<!-- 右上角关闭按钮 -->
|
||||
<span class="settings-dialog__close" @click="$emit('update:modelValue', false)">✕</span>
|
||||
|
||||
<!-- 左侧导航栏 -->
|
||||
<div class="settings-dialog__sidebar">
|
||||
<!-- 主导航 Tab 列表 -->
|
||||
<div class="settings-dialog__nav">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="settings-dialog__nav-item"
|
||||
:class="{ 'settings-dialog__nav-item--active': activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
<span class="settings-dialog__nav-icon">{{ tab.icon }}</span>
|
||||
<span>{{ tab.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作区 — 隐私协议 + 退出登录 -->
|
||||
<div class="settings-dialog__bottom-actions">
|
||||
<!-- 用户隐私协议按钮 — 点击切换到隐私协议 tab -->
|
||||
<button
|
||||
class="settings-dialog__bottom-btn"
|
||||
:class="{ 'settings-dialog__bottom-btn--active': activeTab === 'privacy' }"
|
||||
@click="activeTab = 'privacy'"
|
||||
>
|
||||
用户隐私协议
|
||||
</button>
|
||||
<!-- 退出登录按钮 — 点击弹出确认弹窗 -->
|
||||
<button class="settings-dialog__bottom-btn settings-dialog__bottom-btn--danger" @click="showLogout = true">
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容区 — 根据 activeTab 切换显示 -->
|
||||
<div class="settings-dialog__content">
|
||||
<!-- Tab: 账号与安全 — 显示手机号、注销账号 -->
|
||||
<template v-if="activeTab === 'account'">
|
||||
<h2 class="settings-dialog__content-title">账号与安全</h2>
|
||||
<div class="settings-dialog__section">
|
||||
<div class="settings-dialog__section-label">手机号</div>
|
||||
<p class="settings-dialog__section-value">130****2222</p>
|
||||
</div>
|
||||
<div class="settings-dialog__danger-section">
|
||||
<div class="settings-dialog__danger-title">注销我的账号</div>
|
||||
<div class="settings-dialog__danger-row">
|
||||
<p class="settings-dialog__danger-desc">永久删除你的账号及所有相关数据</p>
|
||||
<button class="settings-dialog__danger-btn" @click="handleDeleteAccount">注销账号</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tab: 会员 — 显示当前会员信息、订阅管理 -->
|
||||
<template v-if="activeTab === 'member'">
|
||||
<h2 class="settings-dialog__content-title">会员</h2>
|
||||
<div class="settings-dialog__section-label" style="margin-bottom: 0.12rem;">当前会员</div>
|
||||
<div class="settings-dialog__member-card">
|
||||
<div class="settings-dialog__member-header">
|
||||
<div class="settings-dialog__member-title-row">
|
||||
<span class="settings-dialog__member-name">月度会员</span>
|
||||
<span class="settings-dialog__member-badge">查看详情</span>
|
||||
</div>
|
||||
<span class="settings-dialog__member-terms" @click="handleMemberTerms">会员条款</span>
|
||||
</div>
|
||||
<div class="settings-dialog__member-info-row">
|
||||
<span class="settings-dialog__member-price">
|
||||
¥19.99/月<span>将于2026年3月27日续费</span>
|
||||
</span>
|
||||
<button class="settings-dialog__member-manage-btn" @click="handleManageSubscription">管理我的订阅</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-dialog__member-issue">
|
||||
<div class="settings-dialog__member-issue-title">订阅状态异常?</div>
|
||||
<p class="settings-dialog__member-issue-desc">
|
||||
如果你已经和完成了付款或更改了订阅但是没有看到最新状态,你可以尝试更新状态或联系我们获取帮助。
|
||||
</p>
|
||||
<div class="settings-dialog__member-issue-actions">
|
||||
<button class="settings-dialog__member-issue-btn" @click="handleRefreshStatus">更新状态</button>
|
||||
<button class="settings-dialog__member-issue-btn" @click="handleContactUs">联系我们</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tab: 岗位更新提醒 — 目标岗位、即时提醒开关、提醒频率 -->
|
||||
<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>
|
||||
<button class="settings-dialog__reminder-edit-btn" @click="handleEditTarget">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-dialog__reminder-block">
|
||||
<div class="settings-dialog__reminder-block-title">即时岗位提醒</div>
|
||||
<div class="settings-dialog__reminder-row">
|
||||
<div class="settings-dialog__reminder-info">
|
||||
<div class="settings-dialog__reminder-label">开启即时岗位更新提醒</div>
|
||||
<div class="settings-dialog__reminder-desc">
|
||||
抢先申请 —— 在岗位发布后一小时内,即可收到为你量身定制的最新职位提醒
|
||||
</div>
|
||||
</div>
|
||||
<el-switch v-model="reminders.instant" active-color="#4FC2C9" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-dialog__reminder-block">
|
||||
<div class="settings-dialog__reminder-block-title">岗位更新提醒频率</div>
|
||||
<div class="settings-dialog__reminder-row">
|
||||
<div class="settings-dialog__reminder-info">
|
||||
<div class="settings-dialog__reminder-desc">
|
||||
会员用户每天可接收无限次岗位更新提醒,免费用户每天最多接收 1 次。
|
||||
</div>
|
||||
</div>
|
||||
<el-select v-model="reminders.frequency" style="width: 1.2rem;">
|
||||
<el-option label="1次/天" value="1" />
|
||||
<el-option label="2次/天" value="2" />
|
||||
<el-option label="5次/天" value="5" />
|
||||
<el-option label="无限次" value="unlimited" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Tab: 用户隐私协议 — 长文本,可滚动查看 -->
|
||||
<template v-if="activeTab === 'privacy'">
|
||||
<h2 class="settings-dialog__content-title">用户隐私协议</h2>
|
||||
<div class="settings-dialog__privacy-content">
|
||||
<div class="settings-dialog__privacy-section">
|
||||
<h4>一、引言</h4>
|
||||
<p>欢迎使用 Offer派(以下简称"本平台"或"我们")。我们深知个人信息对您的重要性,并会尽全力保护您的个人信息安全。我们致力于维持您对我们的信任,恪守以下原则保护您的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则等。同时,我们承诺将按照业界成熟的安全标准,采取相应的安全保护措施来保护您的个人信息。请您在使用本平台服务前,仔细阅读并了解本隐私政策。</p>
|
||||
</div>
|
||||
<div class="settings-dialog__privacy-section">
|
||||
<h4>二、我们如何收集和使用您的个人信息</h4>
|
||||
<p>个人信息是指以电子或者其他方式记录的能够单独或者与其他信息结合识别特定自然人身份或者反映特定自然人活动情况的各种信息。我们仅会出于本政策所述的以下目的,收集和使用您的个人信息:</p>
|
||||
<p>1. 注册与登录:当您注册本平台账号时,我们会收集您的手机号码用于创建账号和身份验证。您也可以选择填写昵称、头像等个人资料来完善您的账户信息。手机号码属于敏感信息,收集此类信息是为了满足相关法律法规的网络实名制要求,如果您不提供手机号码,将无法使用本平台的服务。</p>
|
||||
<p>2. 简历管理:当您使用简历管理功能时,我们会收集您主动填写的简历信息,包括但不限于:姓名、性别、出生日期、教育经历、工作经历、项目经验、技能特长、求职意向、期望薪资、期望工作地点等。这些信息将用于为您提供精准的岗位推荐服务。您可以随时在个人中心修改或删除这些信息。</p>
|
||||
<p>3. 岗位推荐与搜索:当您使用岗位搜索和推荐功能时,我们会收集您的搜索关键词、浏览记录、收藏记录、投递记录等行为数据,以便为您提供更加精准和个性化的岗位推荐。我们也会根据您的求职意向和简历信息,通过算法模型为您匹配合适的职位。</p>
|
||||
<p>4. AI 助手服务:当您使用 AI 助手功能时,我们会收集您与 AI 的对话内容,用于提供智能问答、简历优化建议、面试辅导等服务。对话内容将被加密存储,并仅用于改善服务质量。我们不会将您的对话内容用于其他商业目的。</p>
|
||||
<p>5. 消息通知:为了及时向您推送岗位更新、申请状态变更等重要信息,我们可能会收集您的设备标识符、推送令牌等信息,用于实现消息推送功能。您可以在设置中随时关闭消息推送。</p>
|
||||
</div>
|
||||
<div class="settings-dialog__privacy-section">
|
||||
<h4>三、我们如何共享、转让、公开披露您的个人信息</h4>
|
||||
<p>1. 共享:我们不会与任何公司、组织和个人共享您的个人信息,但以下情况除外:(1)在获取明确同意的情况下共享:获得您的明确同意后,我们会与其他方共享您的个人信息。(2)我们可能会根据法律法规规定,或按政府主管部门的强制性要求,对外共享您的个人信息。(3)与授权合作伙伴共享:仅为实现本隐私政策中声明的目的,我们的某些服务将由授权合作伙伴提供。我们可能会与合作伙伴共享您的某些个人信息,以提供更好的客户服务和用户体验。我们仅会出于合法、正当、必要、特定、明确的目的共享您的个人信息,并且只会共享提供服务所必要的个人信息。</p>
|
||||
<p>2. 转让:我们不会将您的个人信息转让给任何公司、组织和个人,但以下情况除外:(1)在获取明确同意的情况下转让:获得您的明确同意后,我们会向其他方转让您的个人信息。(2)在涉及合并、收购或破产清算时,如涉及到个人信息转让,我们会在要求新的持有您个人信息的公司、组织继续受此隐私政策的约束,否则我们将要求该公司、组织重新向您征求授权同意。</p>
|
||||
<p>3. 公开披露:我们仅会在以下情况下,公开披露您的个人信息:(1)获得您明确同意后。(2)基于法律的披露:在法律、法律程序、诉讼或政府主管部门强制性要求的情况下,我们可能会公开披露您的个人信息。</p>
|
||||
</div>
|
||||
<div class="settings-dialog__privacy-section">
|
||||
<h4>四、我们如何保护您的个人信息</h4>
|
||||
<p>1. 我们已使用符合业界标准的安全防护措施保护您提供的个人信息,防止数据遭到未经授权的访问、公开披露、使用、修改、损坏或丢失。我们会采取一切合理可行的措施,保护您的个人信息。例如,在您的浏览器与服务之间交换数据时受 SSL 加密保护;我们同时对网站提供 HTTPS 安全浏览方式;我们会使用加密技术确保数据的保密性;我们会使用受信赖的保护机制防止数据遭到恶意攻击;我们会部署访问控制机制,确保只有授权人员才可访问个人信息;以及我们会举办安全和隐私保护培训课程,加强员工对于保护个人信息重要性的认识。</p>
|
||||
<p>2. 我们会采取一切合理可行的措施,确保未收集无关的个人信息。我们只会在达成本政策所述目的所需的期限内保留您的个人信息,除非需要延长保留期或受到法律的允许。</p>
|
||||
<p>3. 互联网并非绝对安全的环境,而且电子邮件、即时通讯、及与其他用户的交流方式并未加密,我们强烈建议您不要通过此类方式发送个人信息。请使用复杂密码,协助我们保证您的账号安全。</p>
|
||||
<p>4. 互联网环境并非百分之百安全,我们将尽力确保或担保您发送给我们的任何信息的安全性。如果我们的物理、技术、或管理防护设施遭到破坏,导致信息被非授权访问、公开披露、篡改、或毁坏,导致您的合法权益受损,我们将承担相应的法律责任。</p>
|
||||
<p>5. 在不幸发生个人信息安全事件后,我们将按照法律法规的要求,及时向您告知:安全事件的基本情况和可能的影响、我们已采取或将要采取的处置措施、您可自主防范和降低风险的建议、对您的补救措施等。我们将及时将事件相关情况以邮件、信函、电话、推送通知等方式告知您,难以逐一告知个人信息主体时,我们会采取合理、有效的方式发布公告。</p>
|
||||
</div>
|
||||
<div class="settings-dialog__privacy-section">
|
||||
<h4>五、您的权利</h4>
|
||||
<p>按照中国相关的法律、法规、标准,以及其他国家、地区的通行做法,我们保障您对自己的个人信息行使以下权利:</p>
|
||||
<p>1. 访问您的个人信息:您有权访问您的个人信息,法律法规规定的例外情况除外。如果您想行使数据访问权,可以通过以下方式自行访问:登录本平台,进入"个人资料"或"简历管理"页面,即可查看您的个人信息。</p>
|
||||
<p>2. 更正您的个人信息:当您发现我们处理的关于您的个人信息有错误时,您有权要求我们做出更正。您可以通过上述访问方式提出更正申请。</p>
|
||||
<p>3. 删除您的个人信息:在以下情形中,您可以向我们提出删除个人信息的请求:(1)如果我们处理个人信息的行为违反法律法规。(2)如果我们收集、使用您的个人信息,却未征得您的同意。(3)如果我们处理个人信息的行为违反了与您的约定。(4)如果您不再使用我们的产品或服务,或您注销了账号。(5)如果我们不再为您提供产品或服务。</p>
|
||||
<p>4. 注销账户:您随时可注销此前注册的账户,您可以通过"设置 - 账号与安全 - 注销账号"进行操作。在注销账户之后,我们将停止为您提供产品或服务,并依据您的要求,删除您的个人信息,法律法规另有规定的除外。</p>
|
||||
<p>5. 改变您授权同意的范围:每个业务功能需要一些基本的个人信息才能得以完成。对于额外收集的个人信息的收集和使用,您可以随时给予或收回您的授权同意。您可以通过关闭相应功能的方式来撤回授权。当您收回同意后,我们将不再处理相应的个人信息。但您收回同意的决定,不会影响此前基于您的授权而开展的个人信息处理。</p>
|
||||
</div>
|
||||
<div class="settings-dialog__privacy-section">
|
||||
<h4>六、我们如何处理未成年人的个人信息</h4>
|
||||
<p>我们的产品和服务主要面向成年人。如果没有父母或监护人的同意,未成年人不应创建自己的用户账户。如果我们发现在未事先获得可证实的父母或法定监护人同意的情况下收集了未成年人的个人信息,则会设法尽快删除相关数据。对于经父母或法定监护人同意而收集未成年人个人信息的情况,我们只会在受到法律允许、父母或监护人明确同意或者保护未成年人所必要的情况下使用或公开披露此信息。</p>
|
||||
</div>
|
||||
<div class="settings-dialog__privacy-section">
|
||||
<h4>七、本隐私政策如何更新</h4>
|
||||
<p>我们可能适时会对本隐私政策进行调整或变更,本隐私政策的任何更新将以标注更新时间的方式公布在本平台上,除法律法规或监管规定另有强制性规定外,经调整或变更的内容一经通知或公布后的7日后生效。如您在隐私政策调整或变更后继续使用我们提供的任一服务或访问我们相关网站的,我们相信这代表您已充分阅读、理解并接受修改后的隐私政策并受其约束。</p>
|
||||
</div>
|
||||
<div class="settings-dialog__privacy-section">
|
||||
<h4>八、如何联系我们</h4>
|
||||
<p>如果您对本隐私政策有任何疑问、意见或建议,可以通过以下方式与我们联系:发送邮件至 privacy@offerpai.com,或通过本平台内的"反馈"功能联系我们。一般情况下,我们将在15个工作日内回复。如果您对我们的回复不满意,特别是我们的个人信息处理行为损害了您的合法权益,您还可以向网信部门、电信主管部门、公安部门等监管部门进行投诉或举报,或通过向被告住所地有管辖权的法院提起诉讼来寻求解决方案。</p>
|
||||
<p>本隐私政策的最终解释权归本平台所有。</p>
|
||||
<p style="margin-top: 0.16rem; color: #999;">最后更新日期:2026年3月1日</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 退出登录确认弹窗 — 放在 Teleport 内部,确保层级在 overlay 之上 -->
|
||||
<el-dialog v-model="showLogout" title="退出登录" width="3.6rem" :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>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
import { logout } from '@/api/auth'
|
||||
|
||||
/** 组件 Props — 控制弹窗显示/隐藏 */
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
|
||||
/** 组件 Emits — 通知父组件更新 modelValue */
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
|
||||
|
||||
/** 路由实例 — 退出登录后跳转首页 */
|
||||
const router = useRouter()
|
||||
const store = useStore()
|
||||
|
||||
/** 左侧导航 Tab 配置 */
|
||||
const tabs = [
|
||||
{ key: 'account', label: '账号与安全', icon: '👤' },
|
||||
{ key: 'member', label: '会员', icon: '🏅' },
|
||||
{ key: 'reminder', label: '岗位更新提醒', icon: '🔔' },
|
||||
]
|
||||
|
||||
/** 当前选中的 Tab */
|
||||
const activeTab = ref('account')
|
||||
|
||||
/** 退出登录确认弹窗的显示状态 */
|
||||
const showLogout = ref(false)
|
||||
|
||||
/** 监听弹窗开关 — 打开时锁定背景页面滚动,关闭时恢复 */
|
||||
watch(() => props.modelValue, (val) => {
|
||||
document.body.style.overflow = val ? 'hidden' : ''
|
||||
})
|
||||
|
||||
/** 岗位更新提醒的配置项 */
|
||||
const reminders = reactive({
|
||||
instant: true, // 是否开启即时岗位提醒
|
||||
frequency: 'unlimited', // 提醒频率:1/2/5/unlimited
|
||||
})
|
||||
|
||||
/** 目标岗位标签列表 */
|
||||
const targetTags = ref(['产品经理', '全职', '北京'])
|
||||
|
||||
/** 编辑目标岗位 */
|
||||
const handleEditTarget = () => {
|
||||
ElMessage.info('编辑目标岗位功能开发中')
|
||||
}
|
||||
|
||||
/** 注销账号 — 弹出二次确认 */
|
||||
const handleDeleteAccount = () => {
|
||||
ElMessageBox.confirm('此操作将永久删除你的账号及所有数据,是否继续?', '注销账号', {
|
||||
confirmButtonText: '确认注销',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
ElMessage.success('账号已注销')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/** 管理订阅 */
|
||||
const handleManageSubscription = () => {
|
||||
ElMessage.info('管理订阅功能开发中')
|
||||
}
|
||||
|
||||
/** 查看会员条款 */
|
||||
const handleMemberTerms = () => {
|
||||
ElMessage.info('会员条款页面开发中')
|
||||
}
|
||||
|
||||
/** 刷新订阅状态 */
|
||||
const handleRefreshStatus = () => {
|
||||
ElMessage.success('状态已更新')
|
||||
}
|
||||
|
||||
/** 联系客服 */
|
||||
const handleContactUs = () => {
|
||||
ElMessage.info('联系客服功能开发中')
|
||||
}
|
||||
|
||||
/** 退出登录 — 调用接口,后端会清除 Cookie,关闭弹窗,跳转 jobs 页 */
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const res = await logout()
|
||||
if (res.code === '0') {
|
||||
ElMessage.success('已退出登录')
|
||||
}
|
||||
} catch {
|
||||
// 即使接口失败也关闭弹窗
|
||||
}
|
||||
store.commit('SET_AUTHENTICATED', false)
|
||||
showLogout.value = false
|
||||
emit('update:modelValue', false)
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="side-nav">
|
||||
<!-- 顶部 Logo -->
|
||||
<div class="side-nav__header">
|
||||
<div class="side-nav__avatar">
|
||||
<span class="side-nav__avatar-icon">👤</span>
|
||||
</div>
|
||||
<span class="side-nav__logo-text">Offer派</span>
|
||||
</div>
|
||||
|
||||
<!-- 主导航 -->
|
||||
<nav class="side-nav__menu">
|
||||
<a
|
||||
v-for="item in mainMenus"
|
||||
:key="item.name"
|
||||
href="javascript:void(0)"
|
||||
class="side-nav__item"
|
||||
:class="{ 'side-nav__item--active': isActive(item.name) }"
|
||||
@click="handleNav(item)"
|
||||
>
|
||||
<img class="side-nav__item-icon" :src="item.iconImg" :alt="item.label" />
|
||||
<span class="side-nav__item-label">{{ item.label }}</span>
|
||||
<span v-if="item.badge" class="side-nav__badge">{{ item.badge }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- 底部菜单 -->
|
||||
<div class="side-nav__footer">
|
||||
<div class="side-nav__item" v-for="item in footerMenus" :key="item.label" @click="item.action?.()">
|
||||
<img class="side-nav__item-icon" :src="item.iconImg" :alt="item.label" />
|
||||
<span class="side-nav__item-label">{{ item.label }}</span>
|
||||
<span v-if="item.badge" class="side-nav__badge">{{ item.badge }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息通知弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showMessageDialog" class="side-nav__dialog-overlay" @click="showMessageDialog = false">
|
||||
<div class="side-nav__dialog" @click.stop>
|
||||
<div class="side-nav__dialog-header">
|
||||
<span>消息通知</span>
|
||||
<span class="side-nav__dialog-close" @click="showMessageDialog = false">✕</span>
|
||||
</div>
|
||||
<div class="side-nav__dialog-body">
|
||||
<p>暂无新消息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 反馈弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showFeedbackDialog" class="side-nav__dialog-overlay" @click="showFeedbackDialog = false">
|
||||
<div class="side-nav__dialog feedback-dialog" @click.stop>
|
||||
<div class="feedback-dialog__title">反馈与帮助</div>
|
||||
|
||||
<div class="feedback-dialog__section">
|
||||
<div class="feedback-dialog__label">*你想向我们反馈什么问题?</div>
|
||||
<div class="feedback-dialog__options">
|
||||
<div
|
||||
v-for="opt in feedbackOptions"
|
||||
:key="opt"
|
||||
class="feedback-dialog__option"
|
||||
:class="{ 'feedback-dialog__option--active': feedbackType === opt }"
|
||||
@click="feedbackType = opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feedback-dialog__section">
|
||||
<div class="feedback-dialog__label">*能否提供更多细节?</div>
|
||||
<textarea
|
||||
v-model="feedbackDetail"
|
||||
class="feedback-dialog__textarea"
|
||||
placeholder="请描述你的使用体验或分享您的想法。描述越具体,我们越能更好地处理您的反馈。"
|
||||
rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="feedback-dialog__actions">
|
||||
<button class="feedback-dialog__btn feedback-dialog__btn--cancel" @click="showFeedbackDialog = false">取消</button>
|
||||
<button class="feedback-dialog__btn feedback-dialog__btn--submit" @click="handleFeedbackSubmit">确认提交</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<!-- 设置弹窗 -->
|
||||
<SettingsDialog v-model="showSettingsDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
import SettingsDialog from '@/components/SettingsDialog.vue'
|
||||
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'
|
||||
import navAgentIcon from '@/assets/images/nav/nav-agent-icon.png'
|
||||
import navMessageIcon from '@/assets/images/nav/nav-message-icon.png'
|
||||
import navSettingIcon from '@/assets/images/nav/nav-setting-icon.png'
|
||||
import navFeedbackIcon from '@/assets/images/nav/nav-feedback-icon.png'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useStore()
|
||||
|
||||
interface MenuItem {
|
||||
name: string
|
||||
path: string
|
||||
iconImg: string
|
||||
label: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 图标映射表
|
||||
* 后端 meta.icon 返回的是字符串 key,这里映射到实际的图片资源
|
||||
* 【新增图标时】在这里加一条即可
|
||||
*/
|
||||
const iconMap: Record<string, string> = {
|
||||
'nav-jobs-icon': navJobsIcon,
|
||||
'nav-resume-icon': navResumeIcon,
|
||||
'nav-profile-icon': navProfileIcon,
|
||||
'nav-agent-icon': navAgentIcon,
|
||||
'nav-setting-icon': navSettingIcon,
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态菜单 — 不需要登录就能看到的导航项(比如"职位")
|
||||
* 这些菜单始终显示,不依赖后端返回
|
||||
*/
|
||||
const staticMenus: MenuItem[] = [
|
||||
{ name: 'Jobs', path: '/jobs', iconImg: navJobsIcon, label: '职位' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 动态菜单 — 从 store.state.dynamicMenus(后端返回的数据)转换而来
|
||||
* 登录后才会有数据,登出后自动清空
|
||||
* 过滤掉 position === 'footer' 的项(如"设置"),它们显示在底部区域
|
||||
*/
|
||||
const dynamicMenuItems = computed<MenuItem[]>(() => {
|
||||
return store.state.dynamicMenus
|
||||
.filter((item: any) => item.meta?.position !== 'footer')
|
||||
.map((item: any) => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
iconImg: iconMap[item.meta?.icon] || '',
|
||||
label: item.meta?.label || item.name,
|
||||
badge: item.meta?.badge,
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* 最终渲染的主菜单 = 静态菜单 + 动态菜单
|
||||
*/
|
||||
const mainMenus = computed<MenuItem[]>(() => {
|
||||
return [...staticMenus, ...dynamicMenuItems.value]
|
||||
})
|
||||
|
||||
const showMessageDialog = ref(false)
|
||||
const showFeedbackDialog = ref(false)
|
||||
const showSettingsDialog = ref(false)
|
||||
const feedbackType = ref('')
|
||||
const feedbackDetail = ref('')
|
||||
|
||||
const feedbackOptions = ['Bug反馈', '功能建议', '使用体验', '订阅及会员权益相关问题', '其它']
|
||||
|
||||
function handleFeedbackSubmit() {
|
||||
// TODO: 调用反馈提交接口
|
||||
console.log('反馈类型:', feedbackType.value, '详情:', feedbackDetail.value)
|
||||
feedbackType.value = ''
|
||||
feedbackDetail.value = ''
|
||||
showFeedbackDialog.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部"设置"按钮 — 从动态菜单中取 position === 'footer' 的项
|
||||
* 未登录时使用默认图标和文字
|
||||
*/
|
||||
const settingsMenu = computed(() => {
|
||||
const item = store.state.dynamicMenus.find((m: any) => m.meta?.position === 'footer')
|
||||
if (item) {
|
||||
return {
|
||||
iconImg: iconMap[item.meta?.icon] || navSettingIcon,
|
||||
label: item.meta?.label || '设置',
|
||||
path: item.path,
|
||||
}
|
||||
}
|
||||
return { iconImg: navSettingIcon, label: '设置', path: '/settings' }
|
||||
})
|
||||
|
||||
const footerMenus = computed(() => [
|
||||
{ iconImg: navMessageIcon, label: '消息通知', badge: 'NEW', action: () => { showMessageDialog.value = true } },
|
||||
{ iconImg: settingsMenu.value.iconImg, label: settingsMenu.value.label, action: () => { handleSettingsNav() } },
|
||||
{ iconImg: navFeedbackIcon, label: '反馈', action: () => { showFeedbackDialog.value = true } },
|
||||
])
|
||||
|
||||
/**
|
||||
* 设置弹窗:需要登录,没 token 则弹登录框
|
||||
*/
|
||||
function handleSettingsNav() {
|
||||
if (store.state.isAuthenticated) {
|
||||
showSettingsDialog.value = true
|
||||
} else {
|
||||
store.dispatch('openLogin')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航点击处理:
|
||||
* - 静态页面(Jobs)直接跳转
|
||||
* - 动态页面需要 token,没有则弹登录框
|
||||
*/
|
||||
const staticNames = staticMenus.map(m => m.name)
|
||||
|
||||
function handleNav(item: MenuItem) {
|
||||
if (staticNames.includes(item.name) || store.state.isAuthenticated) {
|
||||
router.push(item.path)
|
||||
} else {
|
||||
store.dispatch('openLogin', item.path)
|
||||
}
|
||||
}
|
||||
|
||||
function isActive(name: string) {
|
||||
return route.name === name
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<!-- 行业选择器根容器 -->
|
||||
<div class="industry-selector" ref="selectorRef">
|
||||
|
||||
<!-- 触发按钮:显示已选行业名称或默认文字"行业" -->
|
||||
<div class="industry-selector__trigger" @click="toggleDropdown">
|
||||
<span class="industry-selector__display" :title="displayText">{{ displayText }}</span>
|
||||
<svg
|
||||
class="industry-selector__arrow"
|
||||
:class="{ 'industry-selector__arrow--open': visible }"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 下拉面板 -->
|
||||
<div v-if="visible" class="industry-selector__panel" @click.stop>
|
||||
|
||||
<!-- 选中区:小方块标签展示已选中的行业名称 -->
|
||||
<div class="industry-selector__selected-area" v-if="selectedItems.length">
|
||||
<span
|
||||
class="industry-selector__tag"
|
||||
v-for="item in selectedItems"
|
||||
:key="item.id"
|
||||
>
|
||||
{{ item.name }}
|
||||
<!-- 点击关闭图标移除该选中项 -->
|
||||
<svg class="industry-selector__tag-close" viewBox="0 0 12 12" @click="removeItem(item)">
|
||||
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 搜索输入框:输入超过 2 个字符时触发模糊匹配 -->
|
||||
<div class="industry-selector__search-wrap">
|
||||
<input
|
||||
v-model="searchText"
|
||||
class="industry-selector__search"
|
||||
placeholder="搜索行业"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果列表(有匹配结果时显示) -->
|
||||
<div v-if="searchText.length >= 2 && searchResults.length" class="industry-selector__search-results">
|
||||
<div
|
||||
class="industry-selector__search-item"
|
||||
v-for="r in searchResults"
|
||||
:key="r.child.id"
|
||||
@click="toggleItem(r.child)"
|
||||
>
|
||||
<!-- 格式:一级行业名 → 二级行业名 -->
|
||||
<span>{{ r.parentName }} → {{ r.child.name }}</span>
|
||||
<!-- 已选中项显示勾选图标 -->
|
||||
<svg v-if="isSelected(r.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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索无结果提示 -->
|
||||
<div v-else-if="searchText.length >= 2 && !searchResults.length" class="industry-selector__search-results">
|
||||
<div class="industry-selector__search-empty">无匹配结果</div>
|
||||
</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-item"
|
||||
:class="{ 'industry-selector__col-item--active': activeParentId === parent.id }"
|
||||
v-for="parent in industries"
|
||||
:key="parent.id"
|
||||
@click="selectParent(parent.id)"
|
||||
>
|
||||
{{ parent.name }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右栏:当前一级下的二级行业列表 -->
|
||||
<div 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)"
|
||||
>
|
||||
{{ 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>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="industry-selector__col-empty">请先选择左侧行业分类</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div class="industry-selector__actions">
|
||||
<button class="industry-selector__btn industry-selector__btn--reset" @click="handleReset">重置</button>
|
||||
<button class="industry-selector__btn industry-selector__btn--confirm" @click="handleConfirm">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import type { IndustryChild, IndustryItem } from '@/api/common'
|
||||
|
||||
// ==================== 事件与属性定义 ====================
|
||||
|
||||
/** 向父组件发送已选行业 ID 数组(integer[]) */
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:industryIds', ids: number[]): void
|
||||
}>()
|
||||
|
||||
/** 接收父组件传入的已选行业 ID 数组 */
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
industryIds?: number[]
|
||||
/** 最多可选数量 */
|
||||
maxSelect?: number
|
||||
}>(),
|
||||
{
|
||||
maxSelect: 3,
|
||||
}
|
||||
)
|
||||
|
||||
// ==================== 基础引用 ====================
|
||||
|
||||
/** Vuex store 实例 */
|
||||
const store = useStore()
|
||||
|
||||
/** 组件根元素引用,用于点击外部关闭判断 */
|
||||
const selectorRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// ==================== 响应式状态 ====================
|
||||
|
||||
/** 下拉面板是否可见 */
|
||||
const visible = ref(false)
|
||||
|
||||
/** 搜索关键词 */
|
||||
const searchText = ref('')
|
||||
|
||||
/** 当前选中的一级行业 ID(左栏高亮项) */
|
||||
const activeParentId = ref<string>('')
|
||||
|
||||
/** 已选中的末级(二级)行业列表(内部临时状态,点确认后才同步给父组件) */
|
||||
const selectedItems = ref<IndustryChild[]>([])
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
/** 从全局 store 获取行业树分类数据 */
|
||||
const industries = computed<IndustryItem[]>(() => store.state.industries)
|
||||
|
||||
/** 已选中行业 ID 集合,用于快速判断某项是否选中 */
|
||||
const selectedIdSet = computed(() => new Set(selectedItems.value.map(i => i.id)))
|
||||
|
||||
/** 触发按钮显示文字:无选中显示"行业",有选中则用逗号拼接名称 */
|
||||
const displayText = computed(() => {
|
||||
if (!selectedItems.value.length) return '行业'
|
||||
return selectedItems.value.map(i => i.name).join(',')
|
||||
})
|
||||
|
||||
/** 当前左栏选中的一级行业对应的二级子项列表 */
|
||||
const activeChildren = computed<IndustryChild[]>(() => {
|
||||
if (!activeParentId.value) return []
|
||||
const parent = industries.value.find(p => p.id === activeParentId.value)
|
||||
return parent ? parent.children : []
|
||||
})
|
||||
|
||||
/** 搜索结果:对末级行业 name 做模糊匹配,返回带一级父名称的结果列表 */
|
||||
const searchResults = computed(() => {
|
||||
if (searchText.value.length < 2) return []
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
const results: { parentName: string; child: IndustryChild }[] = []
|
||||
for (const parent of industries.value) {
|
||||
for (const child of parent.children) {
|
||||
if (child.name.toLowerCase().includes(keyword)) {
|
||||
results.push({ parentName: parent.name, child })
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
// ==================== 方法 ====================
|
||||
|
||||
/** 判断指定行业 ID 是否已被选中 */
|
||||
function isSelected(id: string) {
|
||||
return selectedIdSet.value.has(id)
|
||||
}
|
||||
|
||||
/** 点击左栏一级行业,切换右栏显示对应二级列表 */
|
||||
function selectParent(id: string) {
|
||||
activeParentId.value = id
|
||||
}
|
||||
|
||||
/** 切换某个末级行业的选中/取消状态,超过上限时提示 */
|
||||
function toggleItem(child: IndustryChild) {
|
||||
const idx = selectedItems.value.findIndex(i => i.id === child.id)
|
||||
if (idx >= 0) {
|
||||
selectedItems.value.splice(idx, 1)
|
||||
} else {
|
||||
if (selectedItems.value.length >= props.maxSelect) {
|
||||
ElMessage.warning(`最多只能选择${props.maxSelect}个行业`)
|
||||
return
|
||||
}
|
||||
selectedItems.value.push({ ...child })
|
||||
}
|
||||
}
|
||||
|
||||
/** 从选中区移除指定行业 */
|
||||
function removeItem(item: IndustryChild) {
|
||||
selectedItems.value = selectedItems.value.filter(i => i.id !== item.id)
|
||||
}
|
||||
|
||||
/** 重置:清空所有已选项 */
|
||||
function handleReset() {
|
||||
selectedItems.value = []
|
||||
}
|
||||
|
||||
/** 确认:将当前选中结果发送给父组件并关闭面板 */
|
||||
function handleConfirm() {
|
||||
emitIds()
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 向父组件发送当前选中的行业 ID 数组(转为整数) */
|
||||
function emitIds() {
|
||||
emit('update:industryIds', selectedItems.value.map(i => Number(i.id)))
|
||||
}
|
||||
|
||||
/** 切换下拉面板的显示/隐藏,打开时清空搜索词并默认选中第一个一级行业 */
|
||||
function toggleDropdown() {
|
||||
visible.value = !visible.value
|
||||
if (visible.value) {
|
||||
searchText.value = ''
|
||||
// 默认选中第一个一级行业
|
||||
if (industries.value.length && !activeParentId.value) {
|
||||
activeParentId.value = industries.value[0].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击组件外部时关闭下拉面板 */
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
|
||||
/** 同步外部传入的 industryIds 到内部选中状态 */
|
||||
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[]
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<!-- 岗位选择器根容器 -->
|
||||
<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>
|
||||
<svg
|
||||
class="job-category-selector__arrow"
|
||||
:class="{ 'job-category-selector__arrow--open': visible }"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 下拉面板 -->
|
||||
<div v-if="visible" class="job-category-selector__panel" @click.stop>
|
||||
|
||||
<!-- 选中区:小方块标签展示已选中的岗位名称 -->
|
||||
<div class="job-category-selector__selected-area" v-if="selectedItems.length">
|
||||
<span
|
||||
class="job-category-selector__tag"
|
||||
v-for="item in selectedItems"
|
||||
:key="item.id"
|
||||
>
|
||||
{{ item.name }}
|
||||
<!-- 点击关闭图标移除该选中项 -->
|
||||
<svg class="job-category-selector__tag-close" viewBox="0 0 12 12" @click="removeItem(item)">
|
||||
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 搜索输入框:输入超过 2 个字符时触发模糊匹配 -->
|
||||
<div class="job-category-selector__search-wrap">
|
||||
<input
|
||||
v-model="searchText"
|
||||
class="job-category-selector__search"
|
||||
placeholder="搜索岗位"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果列表 -->
|
||||
<div v-if="searchText.length >= 2 && searchResults.length" class="job-category-selector__search-results">
|
||||
<div
|
||||
class="job-category-selector__search-item"
|
||||
v-for="r in searchResults"
|
||||
:key="r.leaf.id"
|
||||
@click="toggleItem(r.leaf)"
|
||||
>
|
||||
<!-- 格式:一级 → 二级 → 三级 -->
|
||||
<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">
|
||||
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索无结果提示 -->
|
||||
<div v-else-if="searchText.length >= 2 && !searchResults.length" class="job-category-selector__search-results">
|
||||
<div class="job-category-selector__search-empty">无匹配结果</div>
|
||||
</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-item"
|
||||
:class="{ 'job-category-selector__col-item--active': activeLevel1Id === item.id }"
|
||||
v-for="item in categories"
|
||||
:key="item.id"
|
||||
@click="selectLevel1(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 中栏:二级岗位分类 -->
|
||||
<div class="job-category-selector__col job-category-selector__col--mid">
|
||||
<template v-if="level2List.length">
|
||||
<div
|
||||
class="job-category-selector__col-item"
|
||||
:class="{ 'job-category-selector__col-item--active': activeLevel2Id === item.id }"
|
||||
v-for="item in level2List"
|
||||
:key="item.id"
|
||||
@click="selectLevel2(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="job-category-selector__col-empty">请先选择左侧分类</div>
|
||||
</div>
|
||||
<!-- 右栏:三级岗位(末级,可选中) -->
|
||||
<div 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)"
|
||||
>
|
||||
{{ item.name }}
|
||||
<svg v-if="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>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div class="job-category-selector__actions">
|
||||
<button class="job-category-selector__btn job-category-selector__btn--reset" @click="handleReset">重置</button>
|
||||
<button class="job-category-selector__btn job-category-selector__btn--confirm" @click="handleConfirm">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import type { JobCategoryItem, JobCategoryChild, JobCategoryLeaf } from '@/api/common'
|
||||
|
||||
// ==================== 事件与属性定义 ====================
|
||||
|
||||
/** 向父组件发送已选岗位 ID 数组(integer[]) */
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:categoryIds', ids: number[]): void
|
||||
}>()
|
||||
|
||||
/** 接收父组件传入的已选岗位 ID 数组 */
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
categoryIds?: number[]
|
||||
/** 最多可选数量 */
|
||||
maxSelect?: number
|
||||
}>(),
|
||||
{
|
||||
maxSelect: 3,
|
||||
}
|
||||
)
|
||||
|
||||
// ==================== 基础引用 ====================
|
||||
|
||||
/** Vuex store 实例 */
|
||||
const store = useStore()
|
||||
|
||||
/** 组件根元素引用,用于点击外部关闭判断 */
|
||||
const selectorRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// ==================== 响应式状态 ====================
|
||||
|
||||
/** 下拉面板是否可见 */
|
||||
const visible = ref(false)
|
||||
|
||||
/** 搜索关键词 */
|
||||
const searchText = ref('')
|
||||
|
||||
/** 当前选中的一级分类 ID(左栏高亮项) */
|
||||
const activeLevel1Id = ref<string>('')
|
||||
|
||||
/** 当前选中的二级分类 ID(中栏高亮项) */
|
||||
const activeLevel2Id = ref<string>('')
|
||||
|
||||
/** 已选中的末级(三级)岗位列表(内部临时状态,点确认后才同步给父组件) */
|
||||
const selectedItems = ref<JobCategoryLeaf[]>([])
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
/** 从全局 store 获取岗位树分类数据 */
|
||||
const categories = computed<JobCategoryItem[]>(() => store.state.jobCategories)
|
||||
|
||||
/** 已选中岗位 ID 集合,用于快速判断某项是否选中 */
|
||||
const selectedIdSet = computed(() => new Set(selectedItems.value.map(i => i.id)))
|
||||
|
||||
/** 触发按钮显示文字:无选中显示"岗位",有选中则用逗号拼接名称 */
|
||||
const displayText = computed(() => {
|
||||
if (!selectedItems.value.length) return '岗位'
|
||||
return selectedItems.value.map(i => i.name).join(',')
|
||||
})
|
||||
|
||||
/** 当前一级分类下的二级列表 */
|
||||
const level2List = computed<JobCategoryChild[]>(() => {
|
||||
if (!activeLevel1Id.value) return []
|
||||
const parent = categories.value.find(p => p.id === activeLevel1Id.value)
|
||||
return parent ? parent.children : []
|
||||
})
|
||||
|
||||
/** 当前二级分类下的三级列表 */
|
||||
const level3List = computed<JobCategoryLeaf[]>(() => {
|
||||
if (!activeLevel2Id.value) return []
|
||||
const mid = level2List.value.find(m => m.id === activeLevel2Id.value)
|
||||
return mid ? mid.children : []
|
||||
})
|
||||
|
||||
/** 搜索结果:对三级岗位 name 做模糊匹配,返回带一级二级名称的完整路径 */
|
||||
const searchResults = computed(() => {
|
||||
if (searchText.value.length < 2) return []
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
const results: { level1Name: string; level2Name: string; leaf: JobCategoryLeaf }[] = []
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
// ==================== 方法 ====================
|
||||
|
||||
/** 判断指定岗位 ID 是否已被选中 */
|
||||
function isSelected(id: string) {
|
||||
return selectedIdSet.value.has(id)
|
||||
}
|
||||
|
||||
/** 点击左栏一级分类,切换中栏并清空右栏 */
|
||||
function selectLevel1(id: string) {
|
||||
activeLevel1Id.value = id
|
||||
activeLevel2Id.value = ''
|
||||
}
|
||||
|
||||
/** 点击中栏二级分类,切换右栏 */
|
||||
function selectLevel2(id: string) {
|
||||
activeLevel2Id.value = id
|
||||
}
|
||||
|
||||
/** 切换某个末级岗位的选中/取消状态,超过上限时提示 */
|
||||
function toggleItem(leaf: JobCategoryLeaf) {
|
||||
const idx = selectedItems.value.findIndex(i => i.id === leaf.id)
|
||||
if (idx >= 0) {
|
||||
selectedItems.value.splice(idx, 1)
|
||||
} else {
|
||||
if (selectedItems.value.length >= props.maxSelect) {
|
||||
ElMessage.warning(`最多只能选择${props.maxSelect}个岗位`)
|
||||
return
|
||||
}
|
||||
selectedItems.value.push({ ...leaf })
|
||||
}
|
||||
}
|
||||
|
||||
/** 从选中区移除指定岗位 */
|
||||
function removeItem(item: JobCategoryLeaf) {
|
||||
selectedItems.value = selectedItems.value.filter(i => i.id !== item.id)
|
||||
}
|
||||
|
||||
/** 重置:清空所有已选项 */
|
||||
function handleReset() {
|
||||
selectedItems.value = []
|
||||
}
|
||||
|
||||
/** 确认:将当前选中结果发送给父组件并关闭面板 */
|
||||
function handleConfirm() {
|
||||
emitIds()
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 向父组件发送当前选中的岗位 ID 数组(转为整数) */
|
||||
function emitIds() {
|
||||
emit('update:categoryIds', selectedItems.value.map(i => Number(i.id)))
|
||||
}
|
||||
|
||||
/** 切换下拉面板的显示/隐藏,打开时清空搜索词并默认选中第一个一级分类 */
|
||||
function toggleDropdown() {
|
||||
visible.value = !visible.value
|
||||
if (visible.value) {
|
||||
searchText.value = ''
|
||||
if (categories.value.length && !activeLevel1Id.value) {
|
||||
activeLevel1Id.value = categories.value[0].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击组件外部时关闭下拉面板 */
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
|
||||
/** 同步外部传入的 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)
|
||||
}
|
||||
}
|
||||
selectedItems.value = ids
|
||||
.map(id => allLeaves.find(l => l.id === String(id)))
|
||||
.filter(Boolean) as JobCategoryLeaf[]
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<!-- 地区选择器根容器 -->
|
||||
<div class="region-selector" ref="selectorRef">
|
||||
|
||||
<!-- 触发按钮:显示已选地区名称或默认文字"城市" -->
|
||||
<div class="region-selector__trigger" :style="triggerStyle" @click="toggleDropdown">
|
||||
<span class="region-selector__display" :title="displayText">{{ displayText }}</span>
|
||||
<svg
|
||||
class="region-selector__arrow"
|
||||
:class="{ 'region-selector__arrow--open': visible }"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 下拉面板(level=2 时两栏较窄,level=3 时三栏较宽) -->
|
||||
<div
|
||||
v-if="visible"
|
||||
class="region-selector__panel"
|
||||
:class="{ 'region-selector__panel--two-col': level === 2 }"
|
||||
@click.stop
|
||||
>
|
||||
|
||||
<!-- 选中区:小方块标签展示已选中的地区名称 -->
|
||||
<div class="region-selector__selected-area" v-if="selectedItems.length">
|
||||
<span
|
||||
class="region-selector__tag"
|
||||
v-for="item in selectedItems"
|
||||
:key="item.code"
|
||||
>
|
||||
{{ item.name }}
|
||||
<svg class="region-selector__tag-close" viewBox="0 0 12 12" @click="removeItem(item)">
|
||||
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 搜索输入框 -->
|
||||
<div class="region-selector__search-wrap">
|
||||
<input
|
||||
v-model="searchText"
|
||||
class="region-selector__search"
|
||||
placeholder="搜索地区"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果列表 -->
|
||||
<div v-if="searchText.length >= 2 && searchResults.length" class="region-selector__search-results">
|
||||
<div
|
||||
class="region-selector__search-item"
|
||||
v-for="r in searchResults"
|
||||
:key="r.item.code"
|
||||
@click="toggleItem(r.item)"
|
||||
>
|
||||
<span>{{ r.path }}</span>
|
||||
<svg v-if="isSelected(r.item.code)" class="region-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 v-else-if="searchText.length >= 2 && !searchResults.length" class="region-selector__search-results">
|
||||
<div class="region-selector__search-empty">无匹配结果</div>
|
||||
</div>
|
||||
|
||||
<!-- 分栏联动选择区(搜索关键词不足 2 字符时显示) -->
|
||||
<div v-if="searchText.length < 2" class="region-selector__columns">
|
||||
<!-- 左栏:省级列表 -->
|
||||
<div class="region-selector__col region-selector__col--left">
|
||||
<div
|
||||
class="region-selector__col-item"
|
||||
:class="{ 'region-selector__col-item--active': activeProvinceCode === item.code }"
|
||||
v-for="item in regions"
|
||||
:key="item.code"
|
||||
@click="selectProvince(item.code)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 中栏:市级列表(level=2 时为末级可选中,level=3 时为中间栏) -->
|
||||
<div class="region-selector__col region-selector__col--mid">
|
||||
<template v-if="cityList.length">
|
||||
<div
|
||||
class="region-selector__col-item"
|
||||
:class="cityItemClass(item)"
|
||||
v-for="item in cityList"
|
||||
:key="item.code"
|
||||
@click="onCityClick(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
<!-- level=2 时显示选中勾 -->
|
||||
<svg v-if="level === 2 && isSelected(item.code)" class="region-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="region-selector__col-empty">请先选择左侧省份</div>
|
||||
</div>
|
||||
<!-- 右栏:区县级列表(仅 level=3 时显示) -->
|
||||
<div v-if="level === 3" class="region-selector__col region-selector__col--right">
|
||||
<template v-if="districtList.length">
|
||||
<div
|
||||
class="region-selector__col-item"
|
||||
:class="{ 'region-selector__col-item--selected': isSelected(item.code) }"
|
||||
v-for="item in districtList"
|
||||
:key="item.code"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
<svg v-if="isSelected(item.code)" class="region-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="region-selector__col-empty">请先选择中间城市</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div class="region-selector__actions">
|
||||
<button class="region-selector__btn region-selector__btn--reset" @click="handleReset">重置</button>
|
||||
<button class="region-selector__btn region-selector__btn--confirm" @click="handleConfirm">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import type { RegionItem, RegionChild, RegionLeaf } from '@/api/common'
|
||||
|
||||
// ==================== 选中项的统一类型(code + name) ====================
|
||||
|
||||
/** 选中项统一结构 */
|
||||
interface SelectedRegion {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// ==================== 事件与属性定义 ====================
|
||||
|
||||
/** 向父组件发送已选地区编码数组(string[]) */
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:regionCodes', codes: string[]): void
|
||||
}>()
|
||||
|
||||
/** 接收父组件传入的属性 */
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 已选地区编码数组 */
|
||||
regionCodes?: string[]
|
||||
/** 选择级别:2=选到市级,3=选到区县级 */
|
||||
level?: 2 | 3
|
||||
/** 最多可选数量 */
|
||||
maxSelect?: number
|
||||
/** 父组件传入的触发按钮自定义样式,用于在不同场景下覆盖默认外观 */
|
||||
triggerStyle?: Record<string, string>
|
||||
}>(),
|
||||
{
|
||||
level: 2,
|
||||
maxSelect: 3,
|
||||
}
|
||||
)
|
||||
|
||||
// ==================== 基础引用 ====================
|
||||
|
||||
/** Vuex store 实例 */
|
||||
const store = useStore()
|
||||
|
||||
/** 组件根元素引用,用于点击外部关闭判断 */
|
||||
const selectorRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// ==================== 响应式状态 ====================
|
||||
|
||||
/** 下拉面板是否可见 */
|
||||
const visible = ref(false)
|
||||
|
||||
/** 搜索关键词 */
|
||||
const searchText = ref('')
|
||||
|
||||
/** 当前选中的省级编码(左栏高亮项) */
|
||||
const activeProvinceCode = ref<string>('')
|
||||
|
||||
/** 当前选中的市级编码(中栏高亮项,仅 level=3 时使用) */
|
||||
const activeCityCode = ref<string>('')
|
||||
|
||||
/** 已选中的地区列表 */
|
||||
const selectedItems = ref<SelectedRegion[]>([])
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
/** 从全局 store 获取地区树分类数据 */
|
||||
const regions = computed<RegionItem[]>(() => store.state.regions)
|
||||
|
||||
/** 已选中编码集合,用于快速判断 */
|
||||
const selectedCodeSet = computed(() => new Set(selectedItems.value.map(i => i.code)))
|
||||
|
||||
/** 触发按钮显示文字 */
|
||||
const displayText = computed(() => {
|
||||
if (!selectedItems.value.length) return '城市'
|
||||
return selectedItems.value.map(i => i.name).join(',')
|
||||
})
|
||||
|
||||
/** 当前省级下的市级列表 */
|
||||
const cityList = computed<RegionChild[]>(() => {
|
||||
if (!activeProvinceCode.value) return []
|
||||
const province = regions.value.find(p => p.code === activeProvinceCode.value)
|
||||
return province ? province.children : []
|
||||
})
|
||||
|
||||
/** 当前市级下的区县级列表(仅 level=3 时有意义) */
|
||||
const districtList = computed<RegionLeaf[]>(() => {
|
||||
if (!activeCityCode.value) return []
|
||||
const city = cityList.value.find(c => c.code === activeCityCode.value)
|
||||
return city?.children || []
|
||||
})
|
||||
|
||||
/** 搜索结果:模糊匹配目标级别的 name,返回带路径的结果 */
|
||||
const searchResults = computed(() => {
|
||||
if (searchText.value.length < 2) return []
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
const results: { path: string; item: SelectedRegion }[] = []
|
||||
|
||||
for (const province of regions.value) {
|
||||
if (props.level === 2) {
|
||||
// 选到市级:匹配市级 name
|
||||
for (const city of province.children) {
|
||||
if (city.name.toLowerCase().includes(keyword)) {
|
||||
results.push({
|
||||
path: `${province.name} → ${city.name}`,
|
||||
item: { code: city.code, name: city.name },
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 选到区县级:匹配区县级 name
|
||||
for (const city of province.children) {
|
||||
if (city.children) {
|
||||
for (const district of city.children) {
|
||||
if (district.name.toLowerCase().includes(keyword)) {
|
||||
results.push({
|
||||
path: `${province.name} → ${city.name} → ${district.name}`,
|
||||
item: { code: district.code, name: district.name },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
// ==================== 方法 ====================
|
||||
|
||||
/** 判断指定编码是否已被选中 */
|
||||
function isSelected(code: string) {
|
||||
return selectedCodeSet.value.has(code)
|
||||
}
|
||||
|
||||
/** 点击左栏省级,切换中栏并清空右栏 */
|
||||
function selectProvince(code: string) {
|
||||
activeProvinceCode.value = code
|
||||
activeCityCode.value = ''
|
||||
}
|
||||
|
||||
/** 点击中栏市级:level=2 时直接选中/取消,level=3 时切换右栏 */
|
||||
function onCityClick(city: RegionChild) {
|
||||
if (props.level === 2) {
|
||||
toggleItem({ code: city.code, name: city.name })
|
||||
} else {
|
||||
activeCityCode.value = city.code
|
||||
}
|
||||
}
|
||||
|
||||
/** 中栏行的 class:level=2 时用 selected 样式,level=3 时用 active 样式 */
|
||||
function cityItemClass(city: RegionChild) {
|
||||
if (props.level === 2) {
|
||||
return { 'region-selector__col-item--selected': isSelected(city.code) }
|
||||
}
|
||||
return { 'region-selector__col-item--active': activeCityCode.value === city.code }
|
||||
}
|
||||
|
||||
/** 切换某个地区的选中/取消状态 */
|
||||
function toggleItem(item: SelectedRegion) {
|
||||
const idx = selectedItems.value.findIndex(i => i.code === item.code)
|
||||
if (idx >= 0) {
|
||||
selectedItems.value.splice(idx, 1)
|
||||
} else {
|
||||
if (selectedItems.value.length >= props.maxSelect) {
|
||||
ElMessage.warning(`最多只能选择${props.maxSelect}个地区`)
|
||||
return
|
||||
}
|
||||
selectedItems.value.push({ ...item })
|
||||
}
|
||||
}
|
||||
|
||||
/** 从选中区移除指定地区 */
|
||||
function removeItem(item: SelectedRegion) {
|
||||
selectedItems.value = selectedItems.value.filter(i => i.code !== item.code)
|
||||
}
|
||||
|
||||
/** 重置:清空所有已选项并关闭面板 */
|
||||
function handleReset() {
|
||||
selectedItems.value = []
|
||||
emitCodes()
|
||||
setTimeout(() => {
|
||||
visible.value = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/** 确认:将当前选中结果发送给父组件并关闭面板 */
|
||||
function handleConfirm() {
|
||||
emitCodes()
|
||||
// 延迟关闭面板,避免 v-if 移除面板后同一个 click 事件落到 trigger 上触发 toggleDropdown
|
||||
setTimeout(() => {
|
||||
visible.value = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/** 向父组件发送当前选中的地区编码数组 */
|
||||
function emitCodes() {
|
||||
emit('update:regionCodes', selectedItems.value.map(i => i.code))
|
||||
}
|
||||
|
||||
/** 切换下拉面板的显示/隐藏 */
|
||||
function toggleDropdown() {
|
||||
visible.value = !visible.value
|
||||
if (visible.value) {
|
||||
searchText.value = ''
|
||||
if (regions.value.length && !activeProvinceCode.value) {
|
||||
activeProvinceCode.value = regions.value[0].code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击组件外部时关闭下拉面板 */
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
|
||||
/** 同步外部传入的 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedItems.value = codes
|
||||
.map(code => allNodes.find(n => n.code === code))
|
||||
.filter(Boolean) as SelectedRegion[]
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
Reference in New Issue
Block a user