From 557a6f30f25ad4fbd07d2f468259d44b10e720f0 Mon Sep 17 00:00:00 2001 From: xuxin <15279969124@163.com> Date: Tue, 2 Jun 2026 14:21:13 +0800 Subject: [PATCH] =?UTF-8?q?sass=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kiro/steering/project-guidelines.md | 2 + components.d.ts | 1 + src/api/auth.ts | 20 ++++- src/api/menu.ts | 84 ++++++++++++++----- .../components/member-access-dialog.scss | 77 +++++++++++++++++ .../components/profile-welcome-dialog.scss | 5 +- .../components/resume-upload-dialog.scss | 3 +- .../settings-delete-account-dialog.scss | 5 +- src/assets/styles/index.scss | 1 + src/components/MemberAccessDialog.vue | 51 +++++++++++ src/components/SettingsInviteDialog.vue | 2 +- src/components/SideNav.vue | 39 ++++++++- src/router/dynamicRoutes.ts | 7 ++ src/router/index.ts | 8 +- src/stores/index.ts | 23 +++++ src/utils/auth.ts | 14 ---- src/views/Home.vue | 7 ++ src/views/JobDetail.vue | 27 +++++- src/views/ResumeDetail.vue | 27 +++++- 19 files changed, 354 insertions(+), 49 deletions(-) create mode 100644 src/assets/styles/components/member-access-dialog.scss create mode 100644 src/components/MemberAccessDialog.vue delete mode 100644 src/utils/auth.ts diff --git a/.kiro/steering/project-guidelines.md b/.kiro/steering/project-guidelines.md index 5dd226e..381d612 100644 --- a/.kiro/steering/project-guidelines.md +++ b/.kiro/steering/project-guidelines.md @@ -23,3 +23,5 @@ inclusion: always ## 注意事项 - 页面结构和ts的常量变量和方法都要加中文注释 +- 新建 SCSS 文件如果使用了 variables.scss 中的变量(如 `$bg-white`、`$accent` 等),必须在文件顶部加 `@use '../variables' as *;`,否则通过 `@use` 方式引入 index.scss 时变量作用域不会穿透,会报 `Undefined variable` 错误 +- 因为项目用了 rem(1rem=100px)适配方案,所有 Vue 页面和组件文件的最外层盒子都要加 `font-size: 0.14rem`,避免页面样式中受浏览器默认 rem 行高影响导致文字和布局异常 diff --git a/components.d.ts b/components.d.ts index 2ba3f2a..558fe2b 100644 --- a/components.d.ts +++ b/components.d.ts @@ -49,6 +49,7 @@ declare module 'vue' { JobResumeCustomEditPanel: typeof import('./src/components/JobResumeCustomEditPanel.vue')['default'] JobResumeTemplate: typeof import('./src/components/JobResumeTemplate.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'] ProfileEditDrawer: typeof import('./src/components/ProfileEditDrawer.vue')['default'] ProfilePageContent: typeof import('./src/components/ProfilePageContent.vue')['default'] diff --git a/src/api/auth.ts b/src/api/auth.ts index 7c563e3..2611c01 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,4 +1,5 @@ import request from '@/utils/request' +import store from '@/stores' /** 通用响应结构 */ export interface ApiResult { @@ -31,13 +32,26 @@ export function sendSmsCode(mobileNumber: string) { * POST /public/login/smsLogin * Body: { mobileNumber, code, inviteCode? } * 登录成功后后端会 Set-Cookie: Token=xxx + * + * inviteCode 优先使用传入参数,其次从全局 store 中读取(URL 邀请码) + * 登录成功后自动清空全局邀请码,避免重复发送 */ -export function smsLogin(mobileNumber: string, code: string, inviteCode?: string) { - return request.post>('/public/login/smsLogin', { +export async function smsLogin(mobileNumber: string, code: string, inviteCode?: string) { + // 优先使用显式传入的邀请码,否则从全局 store 取 + const finalInviteCode = inviteCode || store.state.inviteCode || '' + + const res = await request.post>('/public/login/smsLogin', { mobileNumber, code, - ...(inviteCode ? { inviteCode } : {}), + ...(finalInviteCode ? { inviteCode: finalInviteCode } : {}), }) + + // 登录成功后清空全局邀请码 + if (res.code === '0' && store.state.inviteCode) { + store.commit('SET_INVITE_CODE', '') + } + + return res } /** diff --git a/src/api/menu.ts b/src/api/menu.ts index 52bbfb7..b9af5ee 100644 --- a/src/api/menu.ts +++ b/src/api/menu.ts @@ -1,15 +1,35 @@ +import request from '@/utils/request' +import type { ApiResult } from '@/api/auth' + /** - * 获取当前用户有权限的路由列表 - * - * 【mock 阶段】直接返回写死的数据,模拟后端接口 - * 【对接真实接口时】把下面的 mock 数据替换成: - * return axios.get('/api/user/menus').then(res => res.data) - * - * 返回格式约定: - * path — 路由路径 - * name — 路由名称(唯一标识,也用于 removeRoute) - * component — 字符串,对应前端组件映射表的 key - * meta — 可选,传给路由的 meta 信息(图标、标题等) + * 路由菜单项 — 后端 /route/menu 接口返回的数据结构 + */ +export interface RouteMenuVo { + /** 主键 */ + id: string + /** 根节点ID */ + rootId: string + /** 父级路由ID */ + parentId: string + /** 路由名称(用于侧边栏显示) */ + 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 { path: string @@ -22,14 +42,40 @@ export interface MenuItemRaw { /** 'footer' 表示该菜单项显示在底部区域而非主导航 */ position?: string } + /** 排序字段 */ + sortOrder: number + /** 是否有使用权限 */ + accessible: boolean } -export async function fetchUserRoutes(): Promise { - // TODO: 替换为真实接口 → return axios.get('/api/user/menus').then(res => res.data) - return [ - { path: '/resume', name: 'Resume', component: 'Resume', meta: { label: '简历', icon: 'nav-resume-icon' } }, - { path: '/profile', name: 'Profile', component: 'Profile', meta: { label: '个人资料', icon: 'nav-profile-icon' } }, - { path: '/agent', name: 'Agent', component: 'Agent', meta: { label: 'AI助手', icon: 'nav-agent-icon', badge: 'NEW' } }, - { path: '/settings', name: 'Settings', component: 'Settings', meta: { label: '设置', icon: 'nav-setting-icon', position: 'footer' } }, - ] +/** + * 将后端返回的 RouteMenuVo 转换为前端使用的 MenuItemRaw + */ +function mapRouteMenuToMenuItem(item: RouteMenuVo): MenuItemRaw { + return { + path: item.routePath, + 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 { + const res = await request.get>('/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 [] } diff --git a/src/assets/styles/components/member-access-dialog.scss b/src/assets/styles/components/member-access-dialog.scss new file mode 100644 index 0000000..6472bbd --- /dev/null +++ b/src/assets/styles/components/member-access-dialog.scss @@ -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; +} diff --git a/src/assets/styles/components/profile-welcome-dialog.scss b/src/assets/styles/components/profile-welcome-dialog.scss index b1a0dd6..d666b43 100644 --- a/src/assets/styles/components/profile-welcome-dialog.scss +++ b/src/assets/styles/components/profile-welcome-dialog.scss @@ -1,3 +1,4 @@ +@use 'sass:color'; @use '../variables' as *; // ==================== 欢迎使用弹窗 ==================== @@ -56,12 +57,12 @@ margin-bottom: 0.14rem; &:hover { - background: darken(#F3F4F5, 3%); + background: color.adjust(#F3F4F5, $lightness: -3%); } // 拖拽悬停时背景色变化 &.is-dragover { - background: darken(#F3F4F5, 6%); + background: color.adjust(#F3F4F5, $lightness: -6%); } } diff --git a/src/assets/styles/components/resume-upload-dialog.scss b/src/assets/styles/components/resume-upload-dialog.scss index eaf15d6..d944aba 100644 --- a/src/assets/styles/components/resume-upload-dialog.scss +++ b/src/assets/styles/components/resume-upload-dialog.scss @@ -1,3 +1,4 @@ +@use 'sass:color'; @use '../variables' as *; // ==================== 简历上传弹窗 ==================== @@ -83,7 +84,7 @@ // 拖拽悬停状态 — 背景色加深 &.is-dragover { - background: darken(#F6FCFC, 4%); + background: color.adjust(#F6FCFC, $lightness: -4%); border-color: $accent; } diff --git a/src/assets/styles/components/settings-delete-account-dialog.scss b/src/assets/styles/components/settings-delete-account-dialog.scss index 9b6cfeb..190b978 100644 --- a/src/assets/styles/components/settings-delete-account-dialog.scss +++ b/src/assets/styles/components/settings-delete-account-dialog.scss @@ -1,3 +1,4 @@ +@use 'sass:color'; @use '../variables' as *; // ==================== 注销账号弹窗 ==================== @@ -247,7 +248,7 @@ color: $bg-white; &:hover:not(:disabled) { - background: darken(#DC2626, 8%); + background: color.adjust(#DC2626, $lightness: -8%); } } @@ -387,7 +388,7 @@ color: $bg-white; &:hover { - background: darken(#DC2626, 8%); + background: color.adjust(#DC2626, $lightness: -8%); } } } diff --git a/src/assets/styles/index.scss b/src/assets/styles/index.scss index 1cde998..22e2059 100644 --- a/src/assets/styles/index.scss +++ b/src/assets/styles/index.scss @@ -37,6 +37,7 @@ @use './components/settings-invite-dialog.scss'; @use './components/resume-upload-dialog.scss'; @use './components/agreement-preview-dialog.scss'; +@use './components/member-access-dialog.scss'; // 全局样式(优先级最高) @use './auto.scss'; diff --git a/src/components/MemberAccessDialog.vue b/src/components/MemberAccessDialog.vue new file mode 100644 index 0000000..9123480 --- /dev/null +++ b/src/components/MemberAccessDialog.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/components/SettingsInviteDialog.vue b/src/components/SettingsInviteDialog.vue index 714a1b5..fc24f7a 100644 --- a/src/components/SettingsInviteDialog.vue +++ b/src/components/SettingsInviteDialog.vue @@ -92,7 +92,7 @@ const inviteCode = computed(() => store.state.userInfo?.inviteCode || '') /** 邀请链接文案 */ const inviteText = computed(() => { const code = inviteCode.value - return `https://www.offerpai.com.cn/invite_code=${code}` + return `https://www.offerpai.com.cn?invite_code=${code}` }) /** 复制链接到剪贴板 */ diff --git a/src/components/SideNav.vue b/src/components/SideNav.vue index d250db3..27db1fc 100644 --- a/src/components/SideNav.vue +++ b/src/components/SideNav.vue @@ -123,6 +123,10 @@ + + + + @@ -131,6 +135,8 @@ import { computed, ref, watch, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useStore } from 'vuex' import SettingsDialog from '@/components/SettingsDialog.vue' +import MemberAccessDialog from '@/components/MemberAccessDialog.vue' +import MemberDialog from '@/components/MemberDialog.vue' import { checkLogin } from '@/api/auth' import { fetchMessageList, fetchUnreadCount, markMessageRead } from '@/api/message' import { timestampToLocalDateTime } from '@/utils/time' @@ -155,6 +161,10 @@ interface MenuItem { iconImg: string label: string badge?: string + /** 排序字段 */ + sortOrder: number + /** 是否有使用权限 */ + accessible: boolean } /** @@ -173,15 +183,17 @@ const iconMap: Record = { /** * 静态菜单 — 不需要登录就能看到的导航项(比如"职位") * 这些菜单始终显示,不依赖后端返回 + * sortOrder 设为 0 确保在未获取到后端数据时也能正常显示 */ const staticMenus: MenuItem[] = [ - { name: 'Jobs', path: '/jobs', iconImg: navJobsIcon, label: '职位' }, + { name: 'Jobs', path: '/jobs', iconImg: navJobsIcon, label: '职位', sortOrder: 0, accessible: true }, ] /** * 动态菜单 — 从 store.state.dynamicMenus(后端返回的数据)转换而来 * 登录后才会有数据,登出后自动清空 * 过滤掉 position === 'footer' 的项(如"设置"),它们显示在底部区域 + * 按 sortOrder 排序,routeName(meta.label)作为显示名称 */ const dynamicMenuItems = computed(() => { return store.state.dynamicMenus @@ -192,19 +204,30 @@ const dynamicMenuItems = computed(() => { iconImg: iconMap[item.meta?.icon] || '', label: item.meta?.label || item.name, 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(() => { - 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 showMessageDialog = ref(false) const showFeedbackDialog = ref(false) +/** 会员权限拦截弹窗 */ +const showMemberAccessDialog = ref(false) +/** 会员购买弹窗 */ +const showMemberDialog = ref(false) const showSettingsDialog = computed({ get: () => store.state.showSettings, set: (val: boolean) => store.commit('SET_SHOW_SETTINGS', val), @@ -420,15 +443,25 @@ async function handleSettingsNav() { /** * 导航点击处理: * - 静态页面(Jobs)直接跳转 + * - accessible 为 false 的菜单 → 弹出会员权限拦截弹窗 * - 动态页面通过 checkLogin 接口验证,未登录则弹登录框 */ const staticNames = staticMenus.map(m => m.name) async function handleNav(item: MenuItem) { + // 无权限菜单 — 弹出会员权限拦截弹窗 + if (!item.accessible) { + showMemberAccessDialog.value = true + return + } + + // Jobs 等静态页面 — 无需登录验证,直接跳转 if (staticNames.includes(item.name)) { router.push(item.path) return } + + // 动态页面 — 需要登录验证 try { const res = await checkLogin() if (res.code === '0' && res.data === true) { diff --git a/src/router/dynamicRoutes.ts b/src/router/dynamicRoutes.ts index e4e120d..c85410d 100644 --- a/src/router/dynamicRoutes.ts +++ b/src/router/dynamicRoutes.ts @@ -10,6 +10,7 @@ import type { MenuItemRaw } from '@/api/menu' * 【新增页面时】在这里加一条映射即可,后端返回对应的 key 就能自动注册路由 */ const componentMap: Record Promise> = { + 'Jobs': () => import('@/views/Jobs.vue'), 'Agent': () => import('@/views/Agent.vue'), 'Profile': () => import('@/views/Profile.vue'), 'Resume': () => import('@/views/Resume.vue'), @@ -19,11 +20,17 @@ const componentMap: Record Promise> = { * 把后端返回的菜单数据转换成 vue-router 能识别的 RouteRecordRaw * * 如果后端返回了一个 component 字符串在映射表里找不到,会跳过该条路由并打印警告 + * 已在静态路由中注册的路径(如 /jobs)不再重复注册 */ +const STATIC_PATHS = ['/', '/jobs', '/resume/:id', '/jobs/:id'] + export function buildDynamicRoutes(menus: MenuItemRaw[]): RouteRecordRaw[] { const routes: RouteRecordRaw[] = [] for (const item of menus) { + // 跳过已在静态路由中注册的路径 + if (STATIC_PATHS.includes(item.path)) continue + const comp = componentMap[item.component] if (!comp) { console.warn(`[dynamicRoutes] 组件映射表中找不到 "${item.component}",已跳过`) diff --git a/src/router/index.ts b/src/router/index.ts index 05cae5e..96c4575 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -37,10 +37,11 @@ const CHECK_INTERVAL = 5 * 60 * 1000 // 5 分钟 * 全局前置守卫 — 核心逻辑: * * 1. 动态路由未加载 → 先拉取并注册,再重新进入当前路由 - * 2. 需要鉴权的路由 → 调 checkLogin 接口验证 Cookie 是否有效 + * 2. 每次页面切换都刷新路由菜单数据(权限可能变化) + * 3. 需要鉴权的路由 → 调 checkLogin 接口验证 Cookie 是否有效 * - 有效:同步 isAuthenticated = true,放行 * - 无效:同步 isAuthenticated = false,弹登录框,阻止导航 - * 3. 不需要鉴权的路由 → 静默同步登录状态(不阻止导航、不弹登录框),5 分钟内节流 + * 4. 不需要鉴权的路由 → 静默同步登录状态(不阻止导航、不弹登录框),5 分钟内节流 */ router.beforeEach(async (to, _from, next) => { // 动态路由只需加载一次,与登录状态无关 @@ -55,6 +56,9 @@ router.beforeEach(async (to, _from, next) => { return } + // 每次页面切换时静默刷新路由菜单数据(更新权限状态),不阻塞导航 + store.dispatch('refreshDynamicMenus') + // 需要鉴权的路由,每次都通过接口校验登录状态 if (to.meta?.requiresAuth) { try { diff --git a/src/stores/index.ts b/src/stores/index.ts index a70c263..e7bc7ef 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -93,6 +93,12 @@ export interface RootState { */ showSettings: boolean settingsTab: string + + /** + * 邀请码 — 从 URL 参数 invite_code 中提取 + * 登录成功后自动清空,避免重复发送 + */ + inviteCode: string } export default createStore({ @@ -117,6 +123,7 @@ export default createStore({ userInfo: null, showSettings: false, settingsTab: 'account', + inviteCode: '', }, getters: { getAppName: (state) => state.appName, @@ -177,6 +184,9 @@ export default createStore({ SET_SETTINGS_TAB(state, tab: string) { state.settingsTab = tab }, + SET_INVITE_CODE(state, code: string) { + state.inviteCode = code + }, }, actions: { updateAppName({ commit }, name: string) { @@ -219,6 +229,19 @@ export default createStore({ 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 管理) * 注意:动态路由不清除,与登录状态无关 diff --git a/src/utils/auth.ts b/src/utils/auth.ts deleted file mode 100644 index 37c88b7..0000000 --- a/src/utils/auth.ts +++ /dev/null @@ -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') -} diff --git a/src/views/Home.vue b/src/views/Home.vue index 6e62294..bf9b034 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -566,6 +566,13 @@ function goSearchJobs() { } 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') }) diff --git a/src/views/JobDetail.vue b/src/views/JobDetail.vue index 0effd6d..701453f 100644 --- a/src/views/JobDetail.vue +++ b/src/views/JobDetail.vue @@ -276,6 +276,11 @@ + + + + + @@ -289,7 +294,10 @@ import JobPageHeader from '@/components/JobPageHeader.vue' import JobDislikeDialog from '@/components/JobDislikeDialog.vue' import JobFeedbackDialog from '@/components/JobFeedbackDialog.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 { fetchMemberStatus } from '@/api/member' import type { JobDetailData, SkillGapData } from '@/api/jobs' // ==================== 路由相关 ==================== @@ -554,6 +562,11 @@ function handleReport() { /** 简历定制弹窗显隐 */ const showResumeCustomDialog = ref(false) +/** 会员权限拦截弹窗 */ +const showMemberAccessDialog = ref(false) +/** 会员购买弹窗 */ +const showMemberDialog = ref(false) + /** 技能差距分析数据 */ const skillGapData = ref(null) @@ -571,8 +584,20 @@ const resumeCustomJobInfo = computed(() => ({ defaultResume: skillGapData.value?.resume || null, })) -/** 生成岗位专属简历 — 调用 skill-gap 接口后打开定制弹窗 */ +/** 生成岗位专属简历 — 先检查会员状态,非会员弹权限拦截弹窗 */ 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({ text: '正在分析岗位匹配度...', background: 'rgba(0, 0, 0, 0.5)', diff --git a/src/views/ResumeDetail.vue b/src/views/ResumeDetail.vue index d98adca..1566626 100644 --- a/src/views/ResumeDetail.vue +++ b/src/views/ResumeDetail.vue @@ -412,6 +412,11 @@ + + + + + @@ -424,6 +429,9 @@ import ResumeAnalysisReportDrawer from '@/components/ResumeAnalysisReportDrawer. import ResumeIssueFixDrawer from '@/components/ResumeIssueFixDrawer.vue' import ResumeExportDialog from '@/components/ResumeExportDialog.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 { fetchResumeMain, fetchResumeEducation, @@ -463,6 +471,11 @@ import { // ==================== 诊断数据相关 ==================== +/** 会员权限拦截弹窗 */ +const showMemberAccessDialog = ref(false) +/** 会员购买弹窗 */ +const showMemberDialog = ref(false) + /** 问题类型枚举值 */ type IssueType = 'urgent' | 'optimize' | 'expression' @@ -748,8 +761,20 @@ function handleClose_report() { showReportDrawer.value = false } -/** 重新诊断 / 开始诊断 — 调用 AI 诊断接口,完成后刷新诊断数据 */ +/** 重新诊断 / 开始诊断 — 先检查会员状态,非会员弹权限拦截弹窗 */ async function handleDiagnose() { + // 先检查会员状态 + try { + const statusRes = await fetchMemberStatus() + if (statusRes.code === '0' && statusRes.data && !statusRes.data.isMember) { + // 非会员 — 弹出会员权限拦截弹窗 + showMemberAccessDialog.value = true + return + } + } catch { + // 接口异常时不阻断(可能是网络问题),继续执行诊断 + } + // 全屏加载提示,AI 接口响应较慢 const loading = ElLoading.service({ lock: true,