diff --git a/src/api/menu.js b/src/api/menu.js new file mode 100644 index 0000000..3b5bcb2 --- /dev/null +++ b/src/api/menu.js @@ -0,0 +1,23 @@ +import request from '../utils/request' + +/** + * 获取用户菜单 + * @returns {Promise} 菜单数据 + */ +export function getUserMenu() { + return request({ + url: '/menu', + method: 'get' + }) +} + +/** + * 获取用户权限 + * @returns {Promise} 权限数据 + */ +export function getUserPermissions() { + return request({ + url: '/permissions', + method: 'get' + }) +} diff --git a/src/config/index.js b/src/config/index.js index 003977f..9af8d9d 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -1,5 +1,7 @@ export default { app_title: 'vueadmin', DASHBOARD_URL: '/home', - baseURL: '' + baseURL: '', + // 白名单路由(不需要登录即可访问) + whiteList: ['/login', '/register', '/reset-password'] } diff --git a/src/config/routes.js b/src/config/routes.js index cf2bb94..6b4a275 100644 --- a/src/config/routes.js +++ b/src/config/routes.js @@ -1,5 +1,5 @@ /** - * 用户静态路由配置 + * 静态路由配置 * 这些路由会根据用户角色进行过滤后添加到路由中 */ const userRoutes = [ diff --git a/src/router/index.js b/src/router/index.js index 7354a51..f9c11fa 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,192 +1,139 @@ import { createRouter, createWebHistory } from 'vue-router' -import { useUserStore } from '@/stores/modules/user' -import { ElNotification } from 'element-plus' -import systemRouter from './systemRouter' -import userRoutes from '@/config/routes' import NProgress from 'nprogress' -import tool from '@/utils/tool' -import i18n from '@/i18n' -import { beforeEach, afterEach } from './scrollBehavior' -import '@/assets/css/nprogress.css' +import 'nprogress/nprogress.css' +import config from '../config' +import userRoutes from '../config/routes' +import { useUserStore } from '../stores/modules/user' +import systemRoutes from './systemRoutes' // 配置 NProgress NProgress.configure({ - easing: 'ease', - speed: 500, showSpinner: false, trickleSpeed: 200, minimum: 0.3 }) -// 匹配pages里面所有的.vue文件 -const modules = import.meta.glob('@/pages/**/*.vue') - -// 特殊路由模块 -const otherModules = { - '404': () => import('@/layouts/other/404.vue'), - empty: () => import('@/layouts/other/empty.vue') +/** + * 404 路由 + */ +const notFoundRoute = { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('../layouts/other/404.vue'), + meta: { + title: '404', + hidden: true + } } -// 系统路由 -const routes = systemRouter - -// 是否已加载过动态/静态路由 -let isGetRouter = false - -// 404路由移除函数 -let routes_404_r = null - +// 创建路由实例 const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), - routes: routes + routes: systemRoutes }) /** - * 设置页面标题 - * @param {Object} meta - 路由元信息 + * 组件导入映射 */ -const setPageTitle = (meta) => { - const title = 'VueAdmin' - if (meta?.title) { - try { - const translatedTitle = i18n.global.te(meta.title) ? i18n.global.t(meta.title) : meta.title - document.title = `${translatedTitle} - ${title}` - } catch (error) { - document.title = `${meta.title} - ${title}` - } - } else { - document.title = title +const modules = import.meta.glob('../pages/**/*.vue') + +/** + * 动态加载组件 + * @param {string} componentPath - 组件路径 + * @returns {Promise} 组件 + */ +function loadComponent(componentPath) { + // 如果组件路径以 'views/' 或 'pages/' 开头,则从相应目录加载 + if (componentPath.startsWith('views/')) { + const path = componentPath.replace('views/', '../pages/') + return modules[`${path}.vue`] } + + // 如果是简单的组件名称,从 pages 目录加载 + return modules[`../pages/${componentPath}/index.vue`] } /** - * 检查路由是否需要认证 - * @param {Object} to - 目标路由 - * @returns {boolean} + * 将后端菜单转换为路由格式 + * @param {Array} menus - 后端返回的菜单数据 + * @returns {Array} 路由数组 */ -const checkAuthRequired = (to) => { - return to.matched.some((record) => record.meta.requiresAuth !== false) -} - -/** - * 移除404路由 - */ -const remove404Route = () => { - if (routes_404_r) { - router.removeRoute('404') - routes_404_r = null +function transformMenusToRoutes(menus) { + if (!menus || !Array.isArray(menus)) { + return [] } -} -/** - * 添加404路由 - */ -const add404Route = () => { - if (!routes_404_r) { - routes_404_r = router.addRoute({ - path: '/:pathMatch(.*)*', - name: '404', - hidden: true, - component: otherModules['404'] + return menus + .filter(menu => menu && menu.path) + .map(menu => { + const route = { + path: menu.path, + name: menu.name || menu.path.replace(/\//g, '-'), + meta: { + title: menu.meta?.title || menu.title, + icon: menu.meta?.icon || menu.icon, + hidden: menu.hidden || menu.meta?.hidden, + keepAlive: menu.meta?.keepAlive || false, + role: menu.meta?.role || [] + } + } + + // 处理组件 + if (menu.component) { + route.component = loadComponent(menu.component) + } + + // 处理子路由 + if (menu.children && menu.children.length > 0) { + route.children = transformMenusToRoutes(menu.children) + } + + // 处理重定向 + if (menu.redirect) { + route.redirect = menu.redirect + } + + return route }) - } } /** - * 加载动态路由 - * @param {Object} to - 目标路由对象 - * @returns {boolean} 是否加载成功 + * 添加路由到路由器 + * @param {Array} routes - 要添加的路由数组 */ -const loadDynamicRoutes = async (to) => { - try { - // 从 store 获取菜单和用户信息 - const userStore = useUserStore() - - const apiMenu = userStore.getMenu() || [] - const userInfo = userStore.userInfo - - // 如果没有用户信息,不做处理 - if (!userInfo) { - return false - } - - // 根据用户角色过滤静态路由 - const userMenu = treeFilter(userRoutes, (node) => { - return node.meta.role - ? node.meta.role.some((item) => userInfo.role?.includes(item)) - : true - }) - - // 合并静态路由和API菜单 - const menu = [...userMenu, ...apiMenu] - - // 转换异步路由 - const menuRouter = filterAsyncRouter(menu) - - // 将树形路由转平铺 - const flatMenuRouter = tool.tree_to_list(menuRouter) - - // 添加所有路由 - flatMenuRouter.forEach((item) => { - router.addRoute('layout', item) - }) - - // 添加404路由(必须在所有路由之后) - add404Route() - - isGetRouter = true - - // 检查目标路由是否存在 - const hasRoute = router.hasRoute(to.name) || to.matched.length > 0 - - if (!hasRoute && to.name !== '404') { - return false - } - - return true - } catch (error) { - console.error('加载动态路由失败:', error) - return false - } +function addRoutes(routes) { + routes.forEach(route => { + router.addRoute(route) + }) } -// 路由拦截器 - 全局前置守卫 +/** + * 路由守卫 + */ +let isDynamicRouteLoaded = false + router.beforeEach(async (to, from, next) => { // 开始进度条 NProgress.start() // 设置页面标题 - setPageTitle(to.meta) + document.title = to.meta.title + ? `${to.meta.title} - ${config.app_title}` + : config.app_title const userStore = useUserStore() const isLoggedIn = userStore.isLoggedIn() - const requiresAuth = checkAuthRequired(to) + const whiteList = config.whiteList || [] - // 处理404页面 - if (to.name === '404') { + // 1. 如果在白名单中,直接放行 + if (whiteList.includes(to.path)) { next() return } - // 处理登录页 - if (to.path === '/login') { - isGetRouter = false - remove404Route() - next() - return - } - - // 如果是系统路由,直接放行 - if (routes.some((r) => r.path === to.path)) { - next() - return - } - - // 需要认证但未登录,重定向到登录页 - if (requiresAuth && !isLoggedIn) { - NProgress.done() - isGetRouter = false - userStore.clearMenu() + // 2. 如果未登录,跳转到登录页 + if (!isLoggedIn) { + // 保存目标路由,登录后跳转 next({ path: '/login', query: { redirect: to.fullPath } @@ -194,123 +141,105 @@ router.beforeEach(async (to, from, next) => { return } - // 整页路由处理 - if (to.meta.fullpage) { - to.matched = [to.matched[to.matched.length - 1]] + // 3. 已登录情况 + // 如果访问登录页,重定向到首页 + if (to.path === '/login') { + next({ path: config.DASHBOARD_URL }) + return } - // 调用前置守卫 - beforeEach(to, from) + // 4. 动态路由加载 + if (!isDynamicRouteLoaded) { + try { + // 获取静态菜单配置 + const staticMenus = userRoutes || [] - // 加载动态/静态路由 - if (!isGetRouter) { - const loadSuccess = await loadDynamicRoutes(to) + // 获取后端返回的用户菜单 + const backendMenus = userStore.getMenu() - // 如果路由加载失败,跳转到404 - if (!loadSuccess) { - next({ name: '404', replace: true }) - return + // 合并静态菜单和后端菜单 + // 如果后端菜单为空,只使用静态菜单 + // 如果后端菜单不为空,合并两个菜单,后端菜单优先 + let mergedMenus = [...staticMenus] + + if (backendMenus && backendMenus.length > 0) { + // 创建菜单映射,用于去重(以路径为唯一标识) + const menuMap = new Map() + + // 先添加静态菜单 + staticMenus.forEach(menu => { + if (menu.path) { + menuMap.set(menu.path, menu) + } + }) + + // 添加后端菜单,如果路径重复则覆盖 + backendMenus.forEach(menu => { + if (menu.path) { + menuMap.set(menu.path, menu) + } + }) + + // 转换为数组 + mergedMenus = Array.from(menuMap.values()) + } + + if (mergedMenus && mergedMenus.length > 0) { + // 将合并后的菜单转换为路由 + const dynamicRoutes = transformMenusToRoutes(mergedMenus) + + // 添加动态路由到 Layout 的子路由 + dynamicRoutes.forEach(route => { + router.addRoute('Layout', route) + }) + + // 添加 404 路由(必须在最后添加) + router.addRoute(notFoundRoute) + + isDynamicRouteLoaded = true + + // 重新导航,确保新添加的路由被正确匹配 + next({ ...to, replace: true }) + } else { + // 如果没有菜单数据,重置并跳转到登录页 + userStore.logout() + next({ path: '/login', query: { redirect: to.fullPath } }) + } + } catch (error) { + console.error('动态路由加载失败:', error) + + // 加载失败,清除用户信息并跳转到登录页 + userStore.logout() + next({ + path: '/login', + query: { redirect: to.fullPath } + }) } + } else { + // 动态路由已加载,直接放行 + next() } - - // 检查路由是否存在(动态路由已加载后) - if (isGetRouter) { - const hasRoute = router.hasRoute(to.name) - // 如果路由不存在且不是404页面,跳转到404 - if (!hasRoute && to.name !== '404' && to.matched.length === 0) { - next({ name: '404', replace: true }) - return - } - } - - next() }) -// 全局后置钩子 -router.afterEach((to, from) => { - // 调用后置钩子 - afterEach(to, from) - +router.afterEach(() => { // 结束进度条 NProgress.done() }) -// 路由错误处理 -router.onError((error) => { - NProgress.done() - ElNotification.error({ - title: '路由错误', - message: error.message +/** + * 重置路由(用于登出时) + */ +export function resetRouter() { + // 移除所有动态添加的路由 + isDynamicRouteLoaded = false + + // 重置为初始路由 + const newRouter = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: systemRoutes }) -}) -/** - * 动态加载组件 - * @param {string} component - 组件路径 - * @returns {Function} - */ -function loadComponent(component) { - if (!component) { - return otherModules.empty - } - - for (const path in modules) { - const dir = path.split('pages/')[1]?.split('.vue')[0] - if (dir === component || dir === `${component}/index`) { - return () => modules[path]() - } - } - - return otherModules.empty -} - -/** - * 转换异步路由 - * @param {Array} routerMap - 路由映射 - * @returns {Array} 转换后的路由数组 - */ -function filterAsyncRouter(routerMap) { - if (!routerMap || !Array.isArray(routerMap)) { - return [] - } - - return routerMap.map((item) => { - const meta = item.meta || {} - - // 处理外部链接特殊路由 - if (meta.type === 'iframe') { - meta.url = item.path - item.path = `/i/${item.name}` - } - - return { - path: item.path, - name: item.name, - meta: meta, - redirect: item.redirect, - children: item.children ? filterAsyncRouter(item.children) : undefined, - component: loadComponent(item.component) - } - }) -} - -/** - * 过滤树结构 - * @param {Array} tree - 树结构数据 - * @param {Function} func - 过滤函数 - * @returns {Array} 过滤后的树结构 - */ -function treeFilter(tree, func) { - if (!tree || !Array.isArray(tree)) { - return [] - } - - return tree - .map((node) => ({ ...node })) - .filter((node) => { - node.children = node.children ? treeFilter(node.children, func) : undefined - return func(node) || (node.children && node.children.length > 0) - }) + router.matcher = newRouter.matcher } export default router diff --git a/src/router/scrollBehavior.js b/src/router/scrollBehavior.js deleted file mode 100644 index 62029bc..0000000 --- a/src/router/scrollBehavior.js +++ /dev/null @@ -1,30 +0,0 @@ -import { useLayoutStore } from '@/stores/modules/layout' -import { nextTick } from 'vue' - -export function beforeEach(to, from) { - const adminMain = document.querySelector('#adminui-main') - if (!adminMain) { - return false - } - - const layoutStore = useLayoutStore() - layoutStore.updateViewTags({ - fullPath: from.fullPath, - scrollTop: adminMain.scrollTop - }) -} - -export function afterEach(to) { - const adminMain = document.querySelector('#adminui-main') - if (!adminMain) { - return false - } - - nextTick(() => { - const layoutStore = useLayoutStore() - const beforeRoute = layoutStore.viewTags.find((v) => v.fullPath === to.fullPath) - if (beforeRoute) { - adminMain.scrollTop = beforeRoute.scrollTop || 0 - } - }) -} diff --git a/src/router/systemRouter.js b/src/router/systemRouter.js deleted file mode 100644 index 6af073f..0000000 --- a/src/router/systemRouter.js +++ /dev/null @@ -1,36 +0,0 @@ -import config from "@/config"; - -//系统路由 -const routes = [ - { - name: "layout", - path: "/", - component: () => import("@/layouts/index.vue"), - redirect: config.DASHBOARD_URL || "/dashboard", - children: [], - }, - { - path: "/login", - component: () => - import("@/pages/login/index.vue"), - meta: { - title: "登录", - }, - }, - { - path: "/register", - component: () => import("@/pages/login/userRegister.vue"), - meta: { - title: "注册", - }, - }, - { - path: "/reset-password", - component: () => import("@/pages/login/resetPassword.vue"), - meta: { - title: "重置密码", - }, - }, -]; - -export default routes; diff --git a/src/router/systemRoutes.js b/src/router/systemRoutes.js new file mode 100644 index 0000000..5289e34 --- /dev/null +++ b/src/router/systemRoutes.js @@ -0,0 +1,41 @@ +/** + * 基础路由(不需要登录) + */ +const systemRoutes = [ + { + path: '/login', + name: 'Login', + component: () => import('../pages/login/index.vue'), + meta: { + title: 'login', + hidden: true + } + }, + { + path: '/register', + name: 'Register', + component: () => import('../pages/login/userRegister.vue'), + meta: { + title: 'register', + hidden: true + } + }, + { + path: '/reset-password', + name: 'ResetPassword', + component: () => import('../pages/login/resetPassword.vue'), + meta: { + title: 'resetPassword', + hidden: true + } + }, + { + path: '/', + name: 'Layout', + component: () => import('@/layouts/index.vue'), + redirect: '/home', + children: [] + } +] + +export default systemRoutes diff --git a/src/stores/modules/user.js b/src/stores/modules/user.js index f4de056..5685e03 100644 --- a/src/stores/modules/user.js +++ b/src/stores/modules/user.js @@ -1,5 +1,6 @@ import { ref } from 'vue' import { defineStore } from 'pinia' +import { resetRouter } from '../../router' export const useUserStore = defineStore('user', () => { const token = ref(localStorage.getItem('token') || '') @@ -52,6 +53,9 @@ export const useUserStore = defineStore('user', () => { localStorage.removeItem('refreshToken') localStorage.removeItem('userInfo') localStorage.removeItem('MENU') + + // 重置路由 + resetRouter() } // 检查是否已登录 diff --git a/src/utils/menu.js b/src/utils/menu.js new file mode 100644 index 0000000..456ea55 --- /dev/null +++ b/src/utils/menu.js @@ -0,0 +1,136 @@ +import routesConfig from '../config/routes' + +/** + * 合并静态菜单和后端菜单 + * @param {Array} backendMenus - 后端返回的菜单 + * @returns {Array} 合并后的菜单 + */ +export function mergeMenus(backendMenus = []) { + // 深拷贝静态菜单 + const staticMenus = JSON.parse(JSON.stringify(routesConfig.userRoutes || [])) + + // 如果后端菜单为空,直接返回静态菜单 + if (!backendMenus || backendMenus.length === 0) { + return staticMenus + } + + // 创建菜单映射,用于去重 + const menuMap = new Map() + + // 添加静态菜单 + staticMenus.forEach(menu => { + menuMap.set(menu.path, menu) + }) + + // 添加后端菜单,如果路径重复则覆盖 + backendMenus.forEach(menu => { + if (menu.path) { + menuMap.set(menu.path, menu) + } + }) + + // 返回合并后的菜单数组 + return Array.from(menuMap.values()) +} + +/** + * 根据路由生成菜单树 + * @param {Array} routes - 路由数组 + * @returns {Array} 菜单树 + */ +export function generateMenuTree(routes) { + if (!routes || !Array.isArray(routes)) { + return [] + } + + return routes + .filter(route => { + // 过滤掉隐藏的路由和没有 meta 的路由 + return !route.meta?.hidden && route.meta?.title + }) + .map(route => { + const menu = { + path: route.path, + name: route.name, + meta: { + title: route.meta.title, + icon: route.meta.icon + } + } + + // 处理子路由 + if (route.children && route.children.length > 0) { + const children = generateMenuTree(route.children) + if (children.length > 0) { + menu.children = children + } + } + + return menu + }) +} + +/** + * 根据权限过滤菜单 + * @param {Array} menus - 菜单数组 + * @param {Array} roles - 用户角色 + * @returns {Array} 过滤后的菜单 + */ +export function filterMenusByRole(menus, roles = []) { + if (!menus || !Array.isArray(menus)) { + return [] + } + + return menus + .filter(menu => { + // 如果菜单没有角色要求,直接显示 + if (!menu.meta?.role || menu.meta.role.length === 0) { + return true + } + + // 检查用户是否有菜单要求的任一角色 + return menu.meta.role.some(role => roles.includes(role)) + }) + .map(menu => { + // 递归处理子菜单 + if (menu.children && menu.children.length > 0) { + const filteredChildren = filterMenusByRole(menu.children, roles) + menu.children = filteredChildren + + // 如果过滤后没有子菜单,且菜单本身没有组件,则隐藏此菜单 + if (filteredChildren.length === 0 && !menu.component) { + return null + } + } + + return menu + }) + .filter(menu => menu !== null) +} + +/** + * 根据路径查找菜单 + * @param {Array} menus - 菜单数组 + * @param {string} path - 路径 + * @returns {Object|null} 找到的菜单对象 + */ +export function findMenuByPath(menus, path) { + if (!menus || !Array.isArray(menus)) { + return null + } + + for (const menu of menus) { + if (menu.path === path) { + return menu + } + + if (menu.children && menu.children.length > 0) { + const found = findMenuByPath(menu.children, path) + if (found) { + return found + } + } + } + + return null +}