路由优化

This commit is contained in:
2026-01-15 09:30:38 +08:00
parent 7065e5329a
commit bb1ed16d8b
9 changed files with 384 additions and 315 deletions

23
src/api/menu.js Normal file
View File

@@ -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'
})
}

View File

@@ -1,5 +1,7 @@
export default { export default {
app_title: 'vueadmin', app_title: 'vueadmin',
DASHBOARD_URL: '/home', DASHBOARD_URL: '/home',
baseURL: '' baseURL: '',
// 白名单路由(不需要登录即可访问)
whiteList: ['/login', '/register', '/reset-password']
} }

View File

@@ -1,5 +1,5 @@
/** /**
* 用户静态路由配置 * 静态路由配置
* 这些路由会根据用户角色进行过滤后添加到路由中 * 这些路由会根据用户角色进行过滤后添加到路由中
*/ */
const userRoutes = [ const userRoutes = [

View File

@@ -1,192 +1,139 @@
import { createRouter, createWebHistory } from 'vue-router' 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 NProgress from 'nprogress'
import tool from '@/utils/tool' import 'nprogress/nprogress.css'
import i18n from '@/i18n' import config from '../config'
import { beforeEach, afterEach } from './scrollBehavior' import userRoutes from '../config/routes'
import '@/assets/css/nprogress.css' import { useUserStore } from '../stores/modules/user'
import systemRoutes from './systemRoutes'
// 配置 NProgress // 配置 NProgress
NProgress.configure({ NProgress.configure({
easing: 'ease',
speed: 500,
showSpinner: false, showSpinner: false,
trickleSpeed: 200, trickleSpeed: 200,
minimum: 0.3 minimum: 0.3
}) })
// 匹配pages里面所有的.vue文件 /**
const modules = import.meta.glob('@/pages/**/*.vue') * 404 路由
*/
// 特殊路由模块 const notFoundRoute = {
const otherModules = { path: '/:pathMatch(.*)*',
'404': () => import('@/layouts/other/404.vue'), name: 'NotFound',
empty: () => import('@/layouts/other/empty.vue') 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({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: routes routes: systemRoutes
}) })
/** /**
* 设置页面标题 * 组件导入映射
* @param {Object} meta - 路由元信息
*/ */
const setPageTitle = (meta) => { const modules = import.meta.glob('../pages/**/*.vue')
const title = 'VueAdmin'
if (meta?.title) { /**
try { * 动态加载组件
const translatedTitle = i18n.global.te(meta.title) ? i18n.global.t(meta.title) : meta.title * @param {string} componentPath - 组件路径
document.title = `${translatedTitle} - ${title}` * @returns {Promise} 组件
} catch (error) { */
document.title = `${meta.title} - ${title}` function loadComponent(componentPath) {
} // 如果组件路径以 'views/' 或 'pages/' 开头,则从相应目录加载
} else { if (componentPath.startsWith('views/')) {
document.title = title const path = componentPath.replace('views/', '../pages/')
return modules[`${path}.vue`]
} }
// 如果是简单的组件名称,从 pages 目录加载
return modules[`../pages/${componentPath}/index.vue`]
} }
/** /**
* 检查路由是否需要认证 * 将后端菜单转换为路由格式
* @param {Object} to - 目标路由 * @param {Array} menus - 后端返回的菜单数据
* @returns {boolean} * @returns {Array} 路由数组
*/ */
const checkAuthRequired = (to) => { function transformMenusToRoutes(menus) {
return to.matched.some((record) => record.meta.requiresAuth !== false) if (!menus || !Array.isArray(menus)) {
} return []
/**
* 移除404路由
*/
const remove404Route = () => {
if (routes_404_r) {
router.removeRoute('404')
routes_404_r = null
} }
}
/** return menus
* 添加404路由 .filter(menu => menu && menu.path)
*/ .map(menu => {
const add404Route = () => { const route = {
if (!routes_404_r) { path: menu.path,
routes_404_r = router.addRoute({ name: menu.name || menu.path.replace(/\//g, '-'),
path: '/:pathMatch(.*)*', meta: {
name: '404', title: menu.meta?.title || menu.title,
hidden: true, icon: menu.meta?.icon || menu.icon,
component: otherModules['404'] 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 - 目标路由对象 * @param {Array} routes - 要添加的路由数组
* @returns {boolean} 是否加载成功
*/ */
const loadDynamicRoutes = async (to) => { function addRoutes(routes) {
try { routes.forEach(route => {
// 从 store 获取菜单和用户信息 router.addRoute(route)
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
}
} }
// 路由拦截器 - 全局前置守卫 /**
* 路由守卫
*/
let isDynamicRouteLoaded = false
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
// 开始进度条 // 开始进度条
NProgress.start() NProgress.start()
// 设置页面标题 // 设置页面标题
setPageTitle(to.meta) document.title = to.meta.title
? `${to.meta.title} - ${config.app_title}`
: config.app_title
const userStore = useUserStore() const userStore = useUserStore()
const isLoggedIn = userStore.isLoggedIn() const isLoggedIn = userStore.isLoggedIn()
const requiresAuth = checkAuthRequired(to) const whiteList = config.whiteList || []
// 处理404页面 // 1. 如果在白名单中,直接放行
if (to.name === '404') { if (whiteList.includes(to.path)) {
next() next()
return return
} }
// 处理登录页 // 2. 如果未登录,跳转到登录页
if (to.path === '/login') { if (!isLoggedIn) {
isGetRouter = false // 保存目标路由,登录后跳转
remove404Route()
next()
return
}
// 如果是系统路由,直接放行
if (routes.some((r) => r.path === to.path)) {
next()
return
}
// 需要认证但未登录,重定向到登录页
if (requiresAuth && !isLoggedIn) {
NProgress.done()
isGetRouter = false
userStore.clearMenu()
next({ next({
path: '/login', path: '/login',
query: { redirect: to.fullPath } query: { redirect: to.fullPath }
@@ -194,123 +141,105 @@ router.beforeEach(async (to, from, next) => {
return return
} }
// 整页路由处理 // 3. 已登录情况
if (to.meta.fullpage) { // 如果访问登录页,重定向到首页
to.matched = [to.matched[to.matched.length - 1]] if (to.path === '/login') {
next({ path: config.DASHBOARD_URL })
return
} }
// 调用前置守卫 // 4. 动态路由加载
beforeEach(to, from) if (!isDynamicRouteLoaded) {
try {
// 获取静态菜单配置
const staticMenus = userRoutes || []
// 加载动态/静态路由 // 获取后端返回的用户菜单
if (!isGetRouter) { const backendMenus = userStore.getMenu()
const loadSuccess = await loadDynamicRoutes(to)
// 如果路由加载失败跳转到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(() => {
router.afterEach((to, from) => {
// 调用后置钩子
afterEach(to, from)
// 结束进度条 // 结束进度条
NProgress.done() NProgress.done()
}) })
// 路由错误处理 /**
router.onError((error) => { * 重置路由(用于登出时)
NProgress.done() */
ElNotification.error({ export function resetRouter() {
title: '路由错误', // 移除所有动态添加的路由
message: error.message isDynamicRouteLoaded = false
// 重置为初始路由
const newRouter = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: systemRoutes
}) })
})
/** router.matcher = newRouter.matcher
* 动态加载组件
* @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)
})
} }
export default router export default router

View File

@@ -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
}
})
}

View File

@@ -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;

View File

@@ -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

View File

@@ -1,5 +1,6 @@
import { ref } from 'vue' import { ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { resetRouter } from '../../router'
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '') const token = ref(localStorage.getItem('token') || '')
@@ -52,6 +53,9 @@ export const useUserStore = defineStore('user', () => {
localStorage.removeItem('refreshToken') localStorage.removeItem('refreshToken')
localStorage.removeItem('userInfo') localStorage.removeItem('userInfo')
localStorage.removeItem('MENU') localStorage.removeItem('MENU')
// 重置路由
resetRouter()
} }
// 检查是否已登录 // 检查是否已登录

136
src/utils/menu.js Normal file
View File

@@ -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
}