sass警告
This commit is contained in:
@@ -23,3 +23,5 @@ inclusion: always
|
|||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 页面结构和ts的常量变量和方法都要加中文注释
|
- 页面结构和ts的常量变量和方法都要加中文注释
|
||||||
|
- 新建 SCSS 文件如果使用了 variables.scss 中的变量(如 `$bg-white`、`$accent` 等),必须在文件顶部加 `@use '../variables' as *;`,否则通过 `@use` 方式引入 index.scss 时变量作用域不会穿透,会报 `Undefined variable` 错误
|
||||||
|
- 因为项目用了 rem(1rem=100px)适配方案,所有 Vue 页面和组件文件的最外层盒子都要加 `font-size: 0.14rem`,避免页面样式中受浏览器默认 rem 行高影响导致文字和布局异常
|
||||||
|
|||||||
Vendored
+1
@@ -49,6 +49,7 @@ declare module 'vue' {
|
|||||||
JobResumeCustomEditPanel: typeof import('./src/components/JobResumeCustomEditPanel.vue')['default']
|
JobResumeCustomEditPanel: typeof import('./src/components/JobResumeCustomEditPanel.vue')['default']
|
||||||
JobResumeTemplate: typeof import('./src/components/JobResumeTemplate.vue')['default']
|
JobResumeTemplate: typeof import('./src/components/JobResumeTemplate.vue')['default']
|
||||||
LoginDialog: typeof import('./src/components/LoginDialog.vue')['default']
|
LoginDialog: typeof import('./src/components/LoginDialog.vue')['default']
|
||||||
|
MemberAccessDialog: typeof import('./src/components/MemberAccessDialog.vue')['default']
|
||||||
MemberDialog: typeof import('./src/components/MemberDialog.vue')['default']
|
MemberDialog: typeof import('./src/components/MemberDialog.vue')['default']
|
||||||
ProfileEditDrawer: typeof import('./src/components/ProfileEditDrawer.vue')['default']
|
ProfileEditDrawer: typeof import('./src/components/ProfileEditDrawer.vue')['default']
|
||||||
ProfilePageContent: typeof import('./src/components/ProfilePageContent.vue')['default']
|
ProfilePageContent: typeof import('./src/components/ProfilePageContent.vue')['default']
|
||||||
|
|||||||
+17
-3
@@ -1,4 +1,5 @@
|
|||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
import store from '@/stores'
|
||||||
|
|
||||||
/** 通用响应结构 */
|
/** 通用响应结构 */
|
||||||
export interface ApiResult<T = any> {
|
export interface ApiResult<T = any> {
|
||||||
@@ -31,13 +32,26 @@ export function sendSmsCode(mobileNumber: string) {
|
|||||||
* POST /public/login/smsLogin
|
* POST /public/login/smsLogin
|
||||||
* Body: { mobileNumber, code, inviteCode? }
|
* Body: { mobileNumber, code, inviteCode? }
|
||||||
* 登录成功后后端会 Set-Cookie: Token=xxx
|
* 登录成功后后端会 Set-Cookie: Token=xxx
|
||||||
|
*
|
||||||
|
* inviteCode 优先使用传入参数,其次从全局 store 中读取(URL 邀请码)
|
||||||
|
* 登录成功后自动清空全局邀请码,避免重复发送
|
||||||
*/
|
*/
|
||||||
export function smsLogin(mobileNumber: string, code: string, inviteCode?: string) {
|
export async function smsLogin(mobileNumber: string, code: string, inviteCode?: string) {
|
||||||
return request.post<any, ApiResult<LoginData>>('/public/login/smsLogin', {
|
// 优先使用显式传入的邀请码,否则从全局 store 取
|
||||||
|
const finalInviteCode = inviteCode || store.state.inviteCode || ''
|
||||||
|
|
||||||
|
const res = await request.post<any, ApiResult<LoginData>>('/public/login/smsLogin', {
|
||||||
mobileNumber,
|
mobileNumber,
|
||||||
code,
|
code,
|
||||||
...(inviteCode ? { inviteCode } : {}),
|
...(finalInviteCode ? { inviteCode: finalInviteCode } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 登录成功后清空全局邀请码
|
||||||
|
if (res.code === '0' && store.state.inviteCode) {
|
||||||
|
store.commit('SET_INVITE_CODE', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+65
-19
@@ -1,15 +1,35 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
import type { ApiResult } from '@/api/auth'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前用户有权限的路由列表
|
* 路由菜单项 — 后端 /route/menu 接口返回的数据结构
|
||||||
*
|
*/
|
||||||
* 【mock 阶段】直接返回写死的数据,模拟后端接口
|
export interface RouteMenuVo {
|
||||||
* 【对接真实接口时】把下面的 mock 数据替换成:
|
/** 主键 */
|
||||||
* return axios.get('/api/user/menus').then(res => res.data)
|
id: string
|
||||||
*
|
/** 根节点ID */
|
||||||
* 返回格式约定:
|
rootId: string
|
||||||
* path — 路由路径
|
/** 父级路由ID */
|
||||||
* name — 路由名称(唯一标识,也用于 removeRoute)
|
parentId: string
|
||||||
* component — 字符串,对应前端组件映射表的 key
|
/** 路由名称(用于侧边栏显示) */
|
||||||
* meta — 可选,传给路由的 meta 信息(图标、标题等)
|
routeName: string
|
||||||
|
/** 前端路径 */
|
||||||
|
routePath: string
|
||||||
|
/** 前端组件路径(对应 componentMap 的 key) */
|
||||||
|
component: string
|
||||||
|
/** 图标 key */
|
||||||
|
icon: string
|
||||||
|
/** 排序 */
|
||||||
|
sortOrder: number
|
||||||
|
/** 是否有使用权限:true=可用 false=无权限(需会员) */
|
||||||
|
accessible: boolean
|
||||||
|
/** 递归子菜单 */
|
||||||
|
children?: RouteMenuVo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容旧版 MenuItemRaw 类型(供 SideNav 和 store 使用)
|
||||||
|
* 在新接口基础上补充原有字段映射
|
||||||
*/
|
*/
|
||||||
export interface MenuItemRaw {
|
export interface MenuItemRaw {
|
||||||
path: string
|
path: string
|
||||||
@@ -22,14 +42,40 @@ export interface MenuItemRaw {
|
|||||||
/** 'footer' 表示该菜单项显示在底部区域而非主导航 */
|
/** 'footer' 表示该菜单项显示在底部区域而非主导航 */
|
||||||
position?: string
|
position?: string
|
||||||
}
|
}
|
||||||
|
/** 排序字段 */
|
||||||
|
sortOrder: number
|
||||||
|
/** 是否有使用权限 */
|
||||||
|
accessible: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUserRoutes(): Promise<MenuItemRaw[]> {
|
/**
|
||||||
// TODO: 替换为真实接口 → return axios.get('/api/user/menus').then(res => res.data)
|
* 将后端返回的 RouteMenuVo 转换为前端使用的 MenuItemRaw
|
||||||
return [
|
*/
|
||||||
{ path: '/resume', name: 'Resume', component: 'Resume', meta: { label: '简历', icon: 'nav-resume-icon' } },
|
function mapRouteMenuToMenuItem(item: RouteMenuVo): MenuItemRaw {
|
||||||
{ path: '/profile', name: 'Profile', component: 'Profile', meta: { label: '个人资料', icon: 'nav-profile-icon' } },
|
return {
|
||||||
{ path: '/agent', name: 'Agent', component: 'Agent', meta: { label: 'AI助手', icon: 'nav-agent-icon', badge: 'NEW' } },
|
path: item.routePath,
|
||||||
{ path: '/settings', name: 'Settings', component: 'Settings', meta: { label: '设置', icon: 'nav-setting-icon', position: 'footer' } },
|
name: item.component, // component 作为路由 name(与 componentMap 对应)
|
||||||
]
|
component: item.component,
|
||||||
|
meta: {
|
||||||
|
label: item.routeName,
|
||||||
|
icon: item.icon,
|
||||||
|
},
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
accessible: item.accessible,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户有权限的路由菜单列表
|
||||||
|
* GET /route/menu
|
||||||
|
* Cookie 自动携带 Token
|
||||||
|
*/
|
||||||
|
export async function fetchUserRoutes(): Promise<MenuItemRaw[]> {
|
||||||
|
const res = await request.get<any, ApiResult<RouteMenuVo[]>>('/route/menu')
|
||||||
|
if (res.code === '0' && res.data) {
|
||||||
|
// 按 sortOrder 排序后转换格式
|
||||||
|
const sorted = [...res.data].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
return sorted.map(mapRouteMenuToMenuItem)
|
||||||
|
}
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/* 会员权限拦截弹窗样式 */
|
||||||
|
@use '../variables' as *;
|
||||||
|
|
||||||
|
.member-access-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: $overlay-bg;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-access-dialog {
|
||||||
|
background: $bg-white;
|
||||||
|
border-radius: 0.12rem;
|
||||||
|
padding: 0.4rem 0.36rem 0.3rem;
|
||||||
|
width: 3.8rem;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0.04rem 0.2rem rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 锁图标 */
|
||||||
|
.member-access-dialog__icon {
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-access-dialog__lock-svg {
|
||||||
|
width: 0.48rem;
|
||||||
|
height: 0.48rem;
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提示文字 */
|
||||||
|
.member-access-dialog__text {
|
||||||
|
font-size: 0.15rem;
|
||||||
|
color: $text-dark;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮区域 */
|
||||||
|
.member-access-dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.16rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-access-dialog__btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 0.4rem;
|
||||||
|
border-radius: 0.06rem;
|
||||||
|
font-size: 0.14rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 灰色取消按钮 */
|
||||||
|
.member-access-dialog__btn--cancel {
|
||||||
|
background: $bg-main;
|
||||||
|
color: $text-middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题色确认按钮 */
|
||||||
|
.member-access-dialog__btn--confirm {
|
||||||
|
background: $btn-dark;
|
||||||
|
color: $bg-white;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@use 'sass:color';
|
||||||
@use '../variables' as *;
|
@use '../variables' as *;
|
||||||
|
|
||||||
// ==================== 欢迎使用弹窗 ====================
|
// ==================== 欢迎使用弹窗 ====================
|
||||||
@@ -56,12 +57,12 @@
|
|||||||
margin-bottom: 0.14rem;
|
margin-bottom: 0.14rem;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: darken(#F3F4F5, 3%);
|
background: color.adjust(#F3F4F5, $lightness: -3%);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖拽悬停时背景色变化
|
// 拖拽悬停时背景色变化
|
||||||
&.is-dragover {
|
&.is-dragover {
|
||||||
background: darken(#F3F4F5, 6%);
|
background: color.adjust(#F3F4F5, $lightness: -6%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@use 'sass:color';
|
||||||
@use '../variables' as *;
|
@use '../variables' as *;
|
||||||
|
|
||||||
// ==================== 简历上传弹窗 ====================
|
// ==================== 简历上传弹窗 ====================
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
|
|
||||||
// 拖拽悬停状态 — 背景色加深
|
// 拖拽悬停状态 — 背景色加深
|
||||||
&.is-dragover {
|
&.is-dragover {
|
||||||
background: darken(#F6FCFC, 4%);
|
background: color.adjust(#F6FCFC, $lightness: -4%);
|
||||||
border-color: $accent;
|
border-color: $accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@use 'sass:color';
|
||||||
@use '../variables' as *;
|
@use '../variables' as *;
|
||||||
|
|
||||||
// ==================== 注销账号弹窗 ====================
|
// ==================== 注销账号弹窗 ====================
|
||||||
@@ -247,7 +248,7 @@
|
|||||||
color: $bg-white;
|
color: $bg-white;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: darken(#DC2626, 8%);
|
background: color.adjust(#DC2626, $lightness: -8%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +388,7 @@
|
|||||||
color: $bg-white;
|
color: $bg-white;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: darken(#DC2626, 8%);
|
background: color.adjust(#DC2626, $lightness: -8%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
@use './components/settings-invite-dialog.scss';
|
@use './components/settings-invite-dialog.scss';
|
||||||
@use './components/resume-upload-dialog.scss';
|
@use './components/resume-upload-dialog.scss';
|
||||||
@use './components/agreement-preview-dialog.scss';
|
@use './components/agreement-preview-dialog.scss';
|
||||||
|
@use './components/member-access-dialog.scss';
|
||||||
|
|
||||||
// 全局样式(优先级最高)
|
// 全局样式(优先级最高)
|
||||||
@use './auto.scss';
|
@use './auto.scss';
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 会员权限拦截弹窗 — 无权限页面点击时弹出 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="modelValue" class="member-access-dialog-overlay" @click="$emit('update:modelValue', false)">
|
||||||
|
<div class="member-access-dialog" @click.stop>
|
||||||
|
<!-- 提示图标 -->
|
||||||
|
<div class="member-access-dialog__icon">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" class="member-access-dialog__lock-svg">
|
||||||
|
<rect x="10" y="22" width="28" height="20" rx="3" stroke="currentColor" stroke-width="2.5"/>
|
||||||
|
<path d="M16 22V16a8 8 0 1116 0v6" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="24" cy="33" r="3" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<!-- 提示文字 -->
|
||||||
|
<p class="member-access-dialog__text">该功能仅对会员开放,升级会员,享受更强大的AI求职功能</p>
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="member-access-dialog__actions">
|
||||||
|
<button class="member-access-dialog__btn member-access-dialog__btn--cancel" @click="$emit('update:modelValue', false)">
|
||||||
|
先不升级
|
||||||
|
</button>
|
||||||
|
<button class="member-access-dialog__btn member-access-dialog__btn--confirm" @click="handleViewDetail">
|
||||||
|
了解详情
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 会员权限拦截弹窗
|
||||||
|
* 当用户点击无权限(accessible=false)的菜单时弹出
|
||||||
|
* "了解详情"按钮打开会员购买组件 MemberDialog
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 组件 Props */
|
||||||
|
defineProps<{ modelValue: boolean }>()
|
||||||
|
|
||||||
|
/** 组件 Emits */
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'openMember'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** 点击了解详情 — 关闭当前弹窗,通知父组件打开会员购买弹窗 */
|
||||||
|
function handleViewDetail() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
emit('openMember')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -92,7 +92,7 @@ const inviteCode = computed(() => store.state.userInfo?.inviteCode || '')
|
|||||||
/** 邀请链接文案 */
|
/** 邀请链接文案 */
|
||||||
const inviteText = computed(() => {
|
const inviteText = computed(() => {
|
||||||
const code = inviteCode.value
|
const code = inviteCode.value
|
||||||
return `https://www.offerpai.com.cn/invite_code=${code}`
|
return `https://www.offerpai.com.cn?invite_code=${code}`
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 复制链接到剪贴板 */
|
/** 复制链接到剪贴板 */
|
||||||
|
|||||||
@@ -123,6 +123,10 @@
|
|||||||
<SettingsDialog v-model="showSettingsDialog" :initial-tab="store.state.settingsTab" />
|
<SettingsDialog v-model="showSettingsDialog" :initial-tab="store.state.settingsTab" />
|
||||||
<!-- 邀请注册送会员弹窗 -->
|
<!-- 邀请注册送会员弹窗 -->
|
||||||
<SettingsInviteDialog v-model="showShareDialog" />
|
<SettingsInviteDialog v-model="showShareDialog" />
|
||||||
|
<!-- 会员权限拦截弹窗 -->
|
||||||
|
<MemberAccessDialog v-model="showMemberAccessDialog" @open-member="showMemberDialog = true" />
|
||||||
|
<!-- 会员购买弹窗 -->
|
||||||
|
<MemberDialog v-model="showMemberDialog" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -131,6 +135,8 @@ import { computed, ref, watch, onMounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
import SettingsDialog from '@/components/SettingsDialog.vue'
|
import SettingsDialog from '@/components/SettingsDialog.vue'
|
||||||
|
import MemberAccessDialog from '@/components/MemberAccessDialog.vue'
|
||||||
|
import MemberDialog from '@/components/MemberDialog.vue'
|
||||||
import { checkLogin } from '@/api/auth'
|
import { checkLogin } from '@/api/auth'
|
||||||
import { fetchMessageList, fetchUnreadCount, markMessageRead } from '@/api/message'
|
import { fetchMessageList, fetchUnreadCount, markMessageRead } from '@/api/message'
|
||||||
import { timestampToLocalDateTime } from '@/utils/time'
|
import { timestampToLocalDateTime } from '@/utils/time'
|
||||||
@@ -155,6 +161,10 @@ interface MenuItem {
|
|||||||
iconImg: string
|
iconImg: string
|
||||||
label: string
|
label: string
|
||||||
badge?: string
|
badge?: string
|
||||||
|
/** 排序字段 */
|
||||||
|
sortOrder: number
|
||||||
|
/** 是否有使用权限 */
|
||||||
|
accessible: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,15 +183,17 @@ const iconMap: Record<string, string> = {
|
|||||||
/**
|
/**
|
||||||
* 静态菜单 — 不需要登录就能看到的导航项(比如"职位")
|
* 静态菜单 — 不需要登录就能看到的导航项(比如"职位")
|
||||||
* 这些菜单始终显示,不依赖后端返回
|
* 这些菜单始终显示,不依赖后端返回
|
||||||
|
* sortOrder 设为 0 确保在未获取到后端数据时也能正常显示
|
||||||
*/
|
*/
|
||||||
const staticMenus: MenuItem[] = [
|
const staticMenus: MenuItem[] = [
|
||||||
{ name: 'Jobs', path: '/jobs', iconImg: navJobsIcon, label: '职位' },
|
{ name: 'Jobs', path: '/jobs', iconImg: navJobsIcon, label: '职位', sortOrder: 0, accessible: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 动态菜单 — 从 store.state.dynamicMenus(后端返回的数据)转换而来
|
* 动态菜单 — 从 store.state.dynamicMenus(后端返回的数据)转换而来
|
||||||
* 登录后才会有数据,登出后自动清空
|
* 登录后才会有数据,登出后自动清空
|
||||||
* 过滤掉 position === 'footer' 的项(如"设置"),它们显示在底部区域
|
* 过滤掉 position === 'footer' 的项(如"设置"),它们显示在底部区域
|
||||||
|
* 按 sortOrder 排序,routeName(meta.label)作为显示名称
|
||||||
*/
|
*/
|
||||||
const dynamicMenuItems = computed<MenuItem[]>(() => {
|
const dynamicMenuItems = computed<MenuItem[]>(() => {
|
||||||
return store.state.dynamicMenus
|
return store.state.dynamicMenus
|
||||||
@@ -192,19 +204,30 @@ const dynamicMenuItems = computed<MenuItem[]>(() => {
|
|||||||
iconImg: iconMap[item.meta?.icon] || '',
|
iconImg: iconMap[item.meta?.icon] || '',
|
||||||
label: item.meta?.label || item.name,
|
label: item.meta?.label || item.name,
|
||||||
badge: item.meta?.badge,
|
badge: item.meta?.badge,
|
||||||
|
sortOrder: item.sortOrder ?? 99,
|
||||||
|
accessible: item.accessible !== false, // 默认为 true
|
||||||
}))
|
}))
|
||||||
|
.sort((a: MenuItem, b: MenuItem) => a.sortOrder - b.sortOrder)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 最终渲染的主菜单 = 静态菜单 + 动态菜单
|
* 最终渲染的主菜单 = 静态菜单 + 动态菜单,按 sortOrder 统一排序
|
||||||
|
* 如果后端返回了 Jobs 的数据,用后端数据覆盖静态菜单的 sortOrder
|
||||||
*/
|
*/
|
||||||
const mainMenus = computed<MenuItem[]>(() => {
|
const mainMenus = computed<MenuItem[]>(() => {
|
||||||
return [...staticMenus, ...dynamicMenuItems.value]
|
// 动态菜单中已包含后端返回的 Jobs(如果有的话),此时不再使用静态的
|
||||||
|
const dynamicNames = dynamicMenuItems.value.map(m => m.name)
|
||||||
|
const filteredStatic = staticMenus.filter(s => !dynamicNames.includes(s.name))
|
||||||
|
return [...filteredStatic, ...dynamicMenuItems.value].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
})
|
})
|
||||||
|
|
||||||
const showShareDialog = ref(false)
|
const showShareDialog = ref(false)
|
||||||
const showMessageDialog = ref(false)
|
const showMessageDialog = ref(false)
|
||||||
const showFeedbackDialog = ref(false)
|
const showFeedbackDialog = ref(false)
|
||||||
|
/** 会员权限拦截弹窗 */
|
||||||
|
const showMemberAccessDialog = ref(false)
|
||||||
|
/** 会员购买弹窗 */
|
||||||
|
const showMemberDialog = ref(false)
|
||||||
const showSettingsDialog = computed({
|
const showSettingsDialog = computed({
|
||||||
get: () => store.state.showSettings,
|
get: () => store.state.showSettings,
|
||||||
set: (val: boolean) => store.commit('SET_SHOW_SETTINGS', val),
|
set: (val: boolean) => store.commit('SET_SHOW_SETTINGS', val),
|
||||||
@@ -420,15 +443,25 @@ async function handleSettingsNav() {
|
|||||||
/**
|
/**
|
||||||
* 导航点击处理:
|
* 导航点击处理:
|
||||||
* - 静态页面(Jobs)直接跳转
|
* - 静态页面(Jobs)直接跳转
|
||||||
|
* - accessible 为 false 的菜单 → 弹出会员权限拦截弹窗
|
||||||
* - 动态页面通过 checkLogin 接口验证,未登录则弹登录框
|
* - 动态页面通过 checkLogin 接口验证,未登录则弹登录框
|
||||||
*/
|
*/
|
||||||
const staticNames = staticMenus.map(m => m.name)
|
const staticNames = staticMenus.map(m => m.name)
|
||||||
|
|
||||||
async function handleNav(item: MenuItem) {
|
async function handleNav(item: MenuItem) {
|
||||||
|
// 无权限菜单 — 弹出会员权限拦截弹窗
|
||||||
|
if (!item.accessible) {
|
||||||
|
showMemberAccessDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jobs 等静态页面 — 无需登录验证,直接跳转
|
||||||
if (staticNames.includes(item.name)) {
|
if (staticNames.includes(item.name)) {
|
||||||
router.push(item.path)
|
router.push(item.path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 动态页面 — 需要登录验证
|
||||||
try {
|
try {
|
||||||
const res = await checkLogin()
|
const res = await checkLogin()
|
||||||
if (res.code === '0' && res.data === true) {
|
if (res.code === '0' && res.data === true) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { MenuItemRaw } from '@/api/menu'
|
|||||||
* 【新增页面时】在这里加一条映射即可,后端返回对应的 key 就能自动注册路由
|
* 【新增页面时】在这里加一条映射即可,后端返回对应的 key 就能自动注册路由
|
||||||
*/
|
*/
|
||||||
const componentMap: Record<string, () => Promise<any>> = {
|
const componentMap: Record<string, () => Promise<any>> = {
|
||||||
|
'Jobs': () => import('@/views/Jobs.vue'),
|
||||||
'Agent': () => import('@/views/Agent.vue'),
|
'Agent': () => import('@/views/Agent.vue'),
|
||||||
'Profile': () => import('@/views/Profile.vue'),
|
'Profile': () => import('@/views/Profile.vue'),
|
||||||
'Resume': () => import('@/views/Resume.vue'),
|
'Resume': () => import('@/views/Resume.vue'),
|
||||||
@@ -19,11 +20,17 @@ const componentMap: Record<string, () => Promise<any>> = {
|
|||||||
* 把后端返回的菜单数据转换成 vue-router 能识别的 RouteRecordRaw
|
* 把后端返回的菜单数据转换成 vue-router 能识别的 RouteRecordRaw
|
||||||
*
|
*
|
||||||
* 如果后端返回了一个 component 字符串在映射表里找不到,会跳过该条路由并打印警告
|
* 如果后端返回了一个 component 字符串在映射表里找不到,会跳过该条路由并打印警告
|
||||||
|
* 已在静态路由中注册的路径(如 /jobs)不再重复注册
|
||||||
*/
|
*/
|
||||||
|
const STATIC_PATHS = ['/', '/jobs', '/resume/:id', '/jobs/:id']
|
||||||
|
|
||||||
export function buildDynamicRoutes(menus: MenuItemRaw[]): RouteRecordRaw[] {
|
export function buildDynamicRoutes(menus: MenuItemRaw[]): RouteRecordRaw[] {
|
||||||
const routes: RouteRecordRaw[] = []
|
const routes: RouteRecordRaw[] = []
|
||||||
|
|
||||||
for (const item of menus) {
|
for (const item of menus) {
|
||||||
|
// 跳过已在静态路由中注册的路径
|
||||||
|
if (STATIC_PATHS.includes(item.path)) continue
|
||||||
|
|
||||||
const comp = componentMap[item.component]
|
const comp = componentMap[item.component]
|
||||||
if (!comp) {
|
if (!comp) {
|
||||||
console.warn(`[dynamicRoutes] 组件映射表中找不到 "${item.component}",已跳过`)
|
console.warn(`[dynamicRoutes] 组件映射表中找不到 "${item.component}",已跳过`)
|
||||||
|
|||||||
+6
-2
@@ -37,10 +37,11 @@ const CHECK_INTERVAL = 5 * 60 * 1000 // 5 分钟
|
|||||||
* 全局前置守卫 — 核心逻辑:
|
* 全局前置守卫 — 核心逻辑:
|
||||||
*
|
*
|
||||||
* 1. 动态路由未加载 → 先拉取并注册,再重新进入当前路由
|
* 1. 动态路由未加载 → 先拉取并注册,再重新进入当前路由
|
||||||
* 2. 需要鉴权的路由 → 调 checkLogin 接口验证 Cookie 是否有效
|
* 2. 每次页面切换都刷新路由菜单数据(权限可能变化)
|
||||||
|
* 3. 需要鉴权的路由 → 调 checkLogin 接口验证 Cookie 是否有效
|
||||||
* - 有效:同步 isAuthenticated = true,放行
|
* - 有效:同步 isAuthenticated = true,放行
|
||||||
* - 无效:同步 isAuthenticated = false,弹登录框,阻止导航
|
* - 无效:同步 isAuthenticated = false,弹登录框,阻止导航
|
||||||
* 3. 不需要鉴权的路由 → 静默同步登录状态(不阻止导航、不弹登录框),5 分钟内节流
|
* 4. 不需要鉴权的路由 → 静默同步登录状态(不阻止导航、不弹登录框),5 分钟内节流
|
||||||
*/
|
*/
|
||||||
router.beforeEach(async (to, _from, next) => {
|
router.beforeEach(async (to, _from, next) => {
|
||||||
// 动态路由只需加载一次,与登录状态无关
|
// 动态路由只需加载一次,与登录状态无关
|
||||||
@@ -55,6 +56,9 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 每次页面切换时静默刷新路由菜单数据(更新权限状态),不阻塞导航
|
||||||
|
store.dispatch('refreshDynamicMenus')
|
||||||
|
|
||||||
// 需要鉴权的路由,每次都通过接口校验登录状态
|
// 需要鉴权的路由,每次都通过接口校验登录状态
|
||||||
if (to.meta?.requiresAuth) {
|
if (to.meta?.requiresAuth) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ export interface RootState {
|
|||||||
*/
|
*/
|
||||||
showSettings: boolean
|
showSettings: boolean
|
||||||
settingsTab: string
|
settingsTab: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请码 — 从 URL 参数 invite_code 中提取
|
||||||
|
* 登录成功后自动清空,避免重复发送
|
||||||
|
*/
|
||||||
|
inviteCode: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createStore<RootState>({
|
export default createStore<RootState>({
|
||||||
@@ -117,6 +123,7 @@ export default createStore<RootState>({
|
|||||||
userInfo: null,
|
userInfo: null,
|
||||||
showSettings: false,
|
showSettings: false,
|
||||||
settingsTab: 'account',
|
settingsTab: 'account',
|
||||||
|
inviteCode: '',
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
getAppName: (state) => state.appName,
|
getAppName: (state) => state.appName,
|
||||||
@@ -177,6 +184,9 @@ export default createStore<RootState>({
|
|||||||
SET_SETTINGS_TAB(state, tab: string) {
|
SET_SETTINGS_TAB(state, tab: string) {
|
||||||
state.settingsTab = tab
|
state.settingsTab = tab
|
||||||
},
|
},
|
||||||
|
SET_INVITE_CODE(state, code: string) {
|
||||||
|
state.inviteCode = code
|
||||||
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
updateAppName({ commit }, name: string) {
|
updateAppName({ commit }, name: string) {
|
||||||
@@ -219,6 +229,19 @@ export default createStore<RootState>({
|
|||||||
commit('SET_ROUTES_LOADED', true)
|
commit('SET_ROUTES_LOADED', true)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 静默刷新路由菜单数据(不重新注册路由,只更新菜单显示和权限状态)
|
||||||
|
* 每次页面切换时由路由守卫调用,用于更新 accessible 权限信息
|
||||||
|
*/
|
||||||
|
async refreshDynamicMenus({ commit }) {
|
||||||
|
try {
|
||||||
|
const menus = await fetchUserRoutes()
|
||||||
|
commit('SET_DYNAMIC_MENUS', menus)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[store] 刷新路由菜单数据失败', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登出:重置状态(不再操作 localStorage,登录状态由 Cookie 管理)
|
* 登出:重置状态(不再操作 localStorage,登录状态由 Cookie 管理)
|
||||||
* 注意:动态路由不清除,与登录状态无关
|
* 注意:动态路由不清除,与登录状态无关
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* 从 document.cookie 中提取指定 name 的值
|
|
||||||
*/
|
|
||||||
export function getCookie(name: string): string | null {
|
|
||||||
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
|
|
||||||
return match ? decodeURIComponent(match[1]) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断是否已登录 — 通过 Cookie 中是否存在 Token 来判断
|
|
||||||
*/
|
|
||||||
export function isLoggedIn(): boolean {
|
|
||||||
return !!getCookie('Token')
|
|
||||||
}
|
|
||||||
@@ -566,6 +566,13 @@ function goSearchJobs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 检测 URL 中的邀请码参数 invite_code,存入全局状态
|
||||||
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
|
const inviteCode = urlParams.get('invite_code')
|
||||||
|
if (inviteCode && inviteCode.length === 10) {
|
||||||
|
store.commit('SET_INVITE_CODE', inviteCode)
|
||||||
|
}
|
||||||
|
|
||||||
// 加载公共工具数据(行业分类、岗位分类、地区分类等)
|
// 加载公共工具数据(行业分类、岗位分类、地区分类等)
|
||||||
store.dispatch('loadCommonData')
|
store.dispatch('loadCommonData')
|
||||||
})
|
})
|
||||||
|
|||||||
+26
-1
@@ -276,6 +276,11 @@
|
|||||||
|
|
||||||
<!-- 岗位专属简历定制弹窗 -->
|
<!-- 岗位专属简历定制弹窗 -->
|
||||||
<JobResumeCustomDialog v-model="showResumeCustomDialog" :job-info="resumeCustomJobInfo" :job-id="jobId" @skip="handleSkipToApply" />
|
<JobResumeCustomDialog v-model="showResumeCustomDialog" :job-info="resumeCustomJobInfo" :job-id="jobId" @skip="handleSkipToApply" />
|
||||||
|
|
||||||
|
<!-- 会员权限拦截弹窗 -->
|
||||||
|
<MemberAccessDialog v-model="showMemberAccessDialog" @open-member="showMemberDialog = true" />
|
||||||
|
<!-- 会员购买弹窗 -->
|
||||||
|
<MemberDialog v-model="showMemberDialog" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -289,7 +294,10 @@ import JobPageHeader from '@/components/JobPageHeader.vue'
|
|||||||
import JobDislikeDialog from '@/components/JobDislikeDialog.vue'
|
import JobDislikeDialog from '@/components/JobDislikeDialog.vue'
|
||||||
import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
|
import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
|
||||||
import JobResumeCustomDialog from '@/components/JobResumeCustomDialog.vue'
|
import JobResumeCustomDialog from '@/components/JobResumeCustomDialog.vue'
|
||||||
|
import MemberAccessDialog from '@/components/MemberAccessDialog.vue'
|
||||||
|
import MemberDialog from '@/components/MemberDialog.vue'
|
||||||
import { fetchJobDetail, toggleJobFavorite, removeJobFavorite, fetchSkillGap } from '@/api/jobs'
|
import { fetchJobDetail, toggleJobFavorite, removeJobFavorite, fetchSkillGap } from '@/api/jobs'
|
||||||
|
import { fetchMemberStatus } from '@/api/member'
|
||||||
import type { JobDetailData, SkillGapData } from '@/api/jobs'
|
import type { JobDetailData, SkillGapData } from '@/api/jobs'
|
||||||
|
|
||||||
// ==================== 路由相关 ====================
|
// ==================== 路由相关 ====================
|
||||||
@@ -554,6 +562,11 @@ function handleReport() {
|
|||||||
/** 简历定制弹窗显隐 */
|
/** 简历定制弹窗显隐 */
|
||||||
const showResumeCustomDialog = ref(false)
|
const showResumeCustomDialog = ref(false)
|
||||||
|
|
||||||
|
/** 会员权限拦截弹窗 */
|
||||||
|
const showMemberAccessDialog = ref(false)
|
||||||
|
/** 会员购买弹窗 */
|
||||||
|
const showMemberDialog = ref(false)
|
||||||
|
|
||||||
/** 技能差距分析数据 */
|
/** 技能差距分析数据 */
|
||||||
const skillGapData = ref<SkillGapData | null>(null)
|
const skillGapData = ref<SkillGapData | null>(null)
|
||||||
|
|
||||||
@@ -571,8 +584,20 @@ const resumeCustomJobInfo = computed(() => ({
|
|||||||
defaultResume: skillGapData.value?.resume || null,
|
defaultResume: skillGapData.value?.resume || null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
/** 生成岗位专属简历 — 调用 skill-gap 接口后打开定制弹窗 */
|
/** 生成岗位专属简历 — 先检查会员状态,非会员弹权限拦截弹窗 */
|
||||||
async function handleGenerateResume() {
|
async function handleGenerateResume() {
|
||||||
|
// 先检查会员状态
|
||||||
|
try {
|
||||||
|
const statusRes = await fetchMemberStatus()
|
||||||
|
if (statusRes.code === '0' && statusRes.data && !statusRes.data.isMember) {
|
||||||
|
// 非会员 — 弹出会员权限拦截弹窗
|
||||||
|
showMemberAccessDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 接口异常时不阻断,继续执行
|
||||||
|
}
|
||||||
|
|
||||||
const loadingInstance = ElLoading.service({
|
const loadingInstance = ElLoading.service({
|
||||||
text: '正在分析岗位匹配度...',
|
text: '正在分析岗位匹配度...',
|
||||||
background: 'rgba(0, 0, 0, 0.5)',
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
|||||||
@@ -412,6 +412,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 会员权限拦截弹窗 -->
|
||||||
|
<MemberAccessDialog v-model="showMemberAccessDialog" @open-member="showMemberDialog = true" />
|
||||||
|
<!-- 会员购买弹窗 -->
|
||||||
|
<MemberDialog v-model="showMemberDialog" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -424,6 +429,9 @@ import ResumeAnalysisReportDrawer from '@/components/ResumeAnalysisReportDrawer.
|
|||||||
import ResumeIssueFixDrawer from '@/components/ResumeIssueFixDrawer.vue'
|
import ResumeIssueFixDrawer from '@/components/ResumeIssueFixDrawer.vue'
|
||||||
import ResumeExportDialog from '@/components/ResumeExportDialog.vue'
|
import ResumeExportDialog from '@/components/ResumeExportDialog.vue'
|
||||||
import ResumeEditNameDialog from '@/components/ResumeEditNameDialog.vue'
|
import ResumeEditNameDialog from '@/components/ResumeEditNameDialog.vue'
|
||||||
|
import MemberAccessDialog from '@/components/MemberAccessDialog.vue'
|
||||||
|
import MemberDialog from '@/components/MemberDialog.vue'
|
||||||
|
import { fetchMemberStatus } from '@/api/member'
|
||||||
import {
|
import {
|
||||||
fetchResumeMain,
|
fetchResumeMain,
|
||||||
fetchResumeEducation,
|
fetchResumeEducation,
|
||||||
@@ -463,6 +471,11 @@ import {
|
|||||||
|
|
||||||
// ==================== 诊断数据相关 ====================
|
// ==================== 诊断数据相关 ====================
|
||||||
|
|
||||||
|
/** 会员权限拦截弹窗 */
|
||||||
|
const showMemberAccessDialog = ref(false)
|
||||||
|
/** 会员购买弹窗 */
|
||||||
|
const showMemberDialog = ref(false)
|
||||||
|
|
||||||
/** 问题类型枚举值 */
|
/** 问题类型枚举值 */
|
||||||
type IssueType = 'urgent' | 'optimize' | 'expression'
|
type IssueType = 'urgent' | 'optimize' | 'expression'
|
||||||
|
|
||||||
@@ -748,8 +761,20 @@ function handleClose_report() {
|
|||||||
showReportDrawer.value = false
|
showReportDrawer.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重新诊断 / 开始诊断 — 调用 AI 诊断接口,完成后刷新诊断数据 */
|
/** 重新诊断 / 开始诊断 — 先检查会员状态,非会员弹权限拦截弹窗 */
|
||||||
async function handleDiagnose() {
|
async function handleDiagnose() {
|
||||||
|
// 先检查会员状态
|
||||||
|
try {
|
||||||
|
const statusRes = await fetchMemberStatus()
|
||||||
|
if (statusRes.code === '0' && statusRes.data && !statusRes.data.isMember) {
|
||||||
|
// 非会员 — 弹出会员权限拦截弹窗
|
||||||
|
showMemberAccessDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 接口异常时不阻断(可能是网络问题),继续执行诊断
|
||||||
|
}
|
||||||
|
|
||||||
// 全屏加载提示,AI 接口响应较慢
|
// 全屏加载提示,AI 接口响应较慢
|
||||||
const loading = ElLoading.service({
|
const loading = ElLoading.service({
|
||||||
lock: true,
|
lock: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user