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

This commit is contained in:
xuxin
2026-03-24 21:06:00 +08:00
commit 0468339d23
113 changed files with 18644 additions and 0 deletions
+120
View File
@@ -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>
+41
View File
@@ -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>
+95
View File
@@ -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>
+80
View File
@@ -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>
+268
View File
@@ -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>
+77
View File
@@ -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>
+148
View File
@@ -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>
+162
View File
@@ -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
+238
View File
@@ -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>
+299
View File
@@ -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>
+231
View File
@@ -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>
+286
View File
@@ -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>
+386
View File
@@ -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
}
}
/** 中栏行的 classlevel=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>