Vue3.5 企业级管理系统实战(二十一):菜单权限

有了菜单及角色管理后,我们还需要根据用户访问的token,去获取用户信息,根据用户的角色信息,拉取所有的菜单权限,进而生成左侧菜单树数据。

1 增加获取用户信息 api

在 src/api/user.ts 中,添加获取用户信息的 api(getUserInfo),代码如下:

TypeScript 复制代码
//src/api/user.ts
import request from "@/api/config/request";
// 从 "./type" 模块中导入 ApiResponse 类型,用于定义接口响应数据的结构
import type { ApiResponse } from "./type";
import type { IRole } from "./role";

/**
 * 定义用户登录所需的数据结构
 * @interface IUserLoginData
 * @property {string} username - 用户登录使用的用户名
 * @property {string} password - 用户登录使用的密码
 */
export interface IUserLoginData {
  username: string;
  password: string;
}

/**
 * 定义登录接口响应的数据结构
 * @interface ILoginResponseData
 * @property {string} token - 登录成功后返回的令牌,用于后续请求的身份验证
 */
export interface ILoginResponseData {
  token: string;
}

/**
 * 登录接口
 * @param {IUserLoginData} data - 用户登录所需的数据,包含用户名和密码
 * @returns {Promise<ApiResponse<ILoginResponseData>>} - 返回一个 Promise 对象,该对象解析为包含登录响应数据的 ApiResponse 类型
 */
export const login = (
  data: IUserLoginData
): Promise<ApiResponse<ILoginResponseData>> => {
  return request.post("/4642164-4292760-default/287017559", data);
};
//test
export const logout_error = (): Promise<ApiResponse<ILoginResponseData>> => {
  return request.post("/4642164-4292760-default/auth/401");
};

//个人中心接口
export interface Profile {
  id: number;
  username: string;
  email: string;
  mobile: string;
  isSuper: boolean;
  status: boolean;
  avatar: string;
  description: string;
  roles: IRole[];
  roleIds?: number[]; // 修改用户的时候,后端接受只要id
}

export interface IUsers {
  users: Profile[];
  count: number;
}

// 查询参数
export interface IUserQuery {
  pageNum?: number;
  pageSize?: number;
  mobile?: string;
  status?: boolean;
  username?: string;
  flag: number;
}

// 获取用户列表的接口
export const getUsers = (params: IUserQuery): Promise<ApiResponse<IUsers>> => {
  const {
    pageNum = 0,
    pageSize = 10,
    username = "",
    status,
    mobile = "",
    flag
  } = params;
  return request.get("http://127.0.0.1:4523/m1/4642164-4292760-default/user", {
    params: {
      pageNum,
      pageSize,
      username,
      status,
      mobile,
      flag
    }
  });
};

// 删除用户
export const removeUser = (id: number): Promise<ApiResponse> => {
  return request.delete(
    `http://127.0.0.1:4523/m1/4642164-4292760-default/user/`
  );
};

// 添加用户
export const addUser = (data: Profile): Promise<ApiResponse> => {
  return request.post(
    "http://127.0.0.1:4523/m1/4642164-4292760-default/user",
    data
  );
};

// 编辑用户
export const updateUser = (id: number, data: Profile): Promise<ApiResponse> => {
  return request.put(
    `http://127.0.0.1:4523/m1/4642164-4292760-default/user`,
    data
  );
};

// 获取用户信息
export const getUserInfo = (): Promise<ApiResponse<Profile>> => {
  return request.post("/auth/info");
};

2 修改用户 Store

在 src/stores/user.ts 中,增加获取当前用户信息的方法 getUserInfo,代码如下:

TypeScript 复制代码
//src/stores/user.ts
import type { IUserLoginData, IUserQuery, IUsers, Profile } from "@/api/user";
import {
  login as loginApi,
  getUsers as getUsersApi, // 获取用户
  addUser as addUserApi,
  removeUser as removeUserApi,
  updateUser as updateUserApi,
  getUserInfo as getUserInfoApi
} from "@/api/user";
import { setToken, removeToken } from "@/utils/auth";
import { useTagsView } from "./tagsView";
import type { IRole } from "@/api/role";

/**
 * 用户信息查询参数类型,继承自Profile并添加分页参数
 */
export type IProfileQuery = Profile & {
  pageNum?: number;
  pageSize?: number;
};

/**
 * 用户状态管理
 */
export const useUserStore = defineStore("user", () => {
  // 状态管理
  const state = reactive({
    token: "", // 用户令牌
    users: [] as IUsers["users"], // 用户列表
    count: 0, // 用户总数
    roles: [] as IRole[], // 用户角色列表
    userInfo: {} as Profile // 当前用户信息
  });

  // 引用标签视图模块
  const tagsViewStore = useTagsView();

  /**
   * 获取当前用户信息
   */
  const getUserInfo = async () => {
    const res = await getUserInfoApi();
    if (res.code === 0) {
      // 解构响应数据,分离角色和用户信息
      const { roles, ...info } = res.data;
      state.roles = roles; // 存储角色信息
      state.userInfo = info as Profile; // 存储用户信息
    }
  };

  /**
   * 用户登录
   * @param userInfo - 包含用户名和密码的登录信息
   */
  const login = async (userInfo: IUserLoginData) => {
    try {
      const { username, password } = userInfo;
      // 调用登录API,用户名去除首尾空格
      const response = await loginApi({ username: username.trim(), password });
      const { data } = response;
      state.token = data.token; // 存储令牌
      setToken(data.token); // 保存令牌到本地存储
    } catch (e) {
      return Promise.reject(e); // 登录失败时返回错误
    }
  };

  /**
   * 用户注销
   */
  const logout = () => {
    state.token = ""; // 清空令牌
    removeToken(); // 移除本地存储的令牌
    tagsViewStore.delAllView(); // 清除所有标签视图
  };

  /**
   * 获取全部用户列表
   * @param params - 查询参数,包含分页和筛选条件
   */
  const getAllUsers = async (params: IUserQuery) => {
    const res = await getUsersApi(params);
    const { data } = res;
    state.users = data.users; // 更新用户列表
    state.count = data.count; // 更新用户总数
  };

  /**
   * 添加新用户
   * @param data - 用户信息,包含分页参数(用于添加成功后刷新列表)
   */
  const addUser = async (data: IProfileQuery) => {
    // 分离分页参数和用户信息
    const { pageSize, pageNum, ...params } = data;
    const res = await addUserApi(params);
    if (res.code === 0) {
      // 添加成功后刷新用户列表
      getAllUsers({
        pageSize,
        pageNum
      });
    }
  };

  /**
   * 删除用户
   * @param data - 包含用户ID和分页信息(用于删除成功后刷新列表)
   */
  const removeUser = async (data: IProfileQuery) => {
    const { pageSize, pageNum, id } = data;
    const res = await removeUserApi(id);
    if (res.code === 0) {
      // 删除成功后刷新用户列表
      getAllUsers({
        pageSize,
        pageNum
      });
    }
  };

  /**
   * 编辑用户信息
   * @param data - 用户信息,包含分页参数(用于编辑成功后刷新列表)
   */
  const editUser = async (data: IProfileQuery) => {
    // 分离分页参数和用户信息
    const { pageSize, pageNum, ...params } = data;
    const res = await updateUserApi(params.id, params);
    if (res.code === 0) {
      // 编辑成功后刷新用户列表
      getAllUsers({
        pageSize,
        pageNum
      });
    }
  };

  // 导出可访问的方法和状态
  return {
    login,
    state,
    logout,
    getAllUsers,
    editUser,
    removeUser,
    addUser,
    getUserInfo
  };
});

3 新增 permission Store

新增 src/stores/permission.ts,代码如下:

TypeScript 复制代码
//src/stores/permission.ts
import type { RouteRecordRaw } from "vue-router";
import { useUserStore } from "./user";
import { asyncRoutes } from "@/router";
import { useMenuStore } from "./menu";
import type { MenuData } from "@/api/menu";
import path from "path-browserify";

/**
 * 递归生成路由配置
 * @param routes - 原始路由配置
 * @param routesPath - 需要保留的路由路径数组
 * @param basePath - 基础路径,用于解析子路由
 * @returns 过滤后的路由配置
 */
function generateRoutes(
  routes: RouteRecordRaw[],
  routesPath: string[],
  basePath = "/"
) {
  const routerData: RouteRecordRaw[] = [];

  routes.forEach((route) => {
    // 解析当前路由的完整路径
    const routePath = path.resolve(basePath, route.path);

    // 递归处理子路由
    if (route.children) {
      route.children = generateRoutes(route.children, routesPath, routePath);
    }

    // 路由过滤条件:
    // 1. 当前路由路径在允许列表中
    // 2. 或者当前路由有子路由
    if (
      routesPath.includes(routePath) ||
      (route.children && route.children.length >= 1)
    ) {
      routerData.push(route);
    }
  });

  return routerData;
}

/**
 * 根据菜单数据过滤异步路由
 * @param menus - 菜单数据
 * @param routes - 原始异步路由配置
 * @returns 过滤后的路由配置
 */
function filterAsyncRoutes(menus: MenuData[], routes: RouteRecordRaw[]) {
  // 提取菜单中的路径信息
  const routesPath = menus.map((item) => item.path);

  // 调用递归生成函数
  return generateRoutes(routes, routesPath);
}

/**
 * 权限管理模块
 */
export const usePermissionStore = defineStore("permission", () => {
  const userStore = useUserStore();
  const menuStore = useMenuStore();

  // 存储最终生成的可访问路由
  let accessMenuRoutes: RouteRecordRaw[] = [];

  /**
   * 生成可访问的路由配置
   * @returns 基于用户角色的可访问路由配置
   */
  const generateRoutes = async () => {
    // 计算属性:获取用户角色名称列表
    const rolesNames = computed(() =>
      userStore.state.roles.map((item) => item.name)
    );

    // 计算属性:获取用户角色ID列表
    const roleIds = computed(() =>
      userStore.state.roles.map((item) => item.id)
    );

    // 超级管理员角色处理
    if (rolesNames.value.includes("super_admin")) {
      // 超级管理员拥有全部路由访问权限
      accessMenuRoutes = asyncRoutes;

      // 获取全部菜单列表
      await menuStore.getAllMenuListByAdmin();

      return accessMenuRoutes;
    } else {
      // 普通角色处理:根据角色ID获取对应菜单
      await menuStore.getMenuListByRoles(roleIds.value);

      // 获取当前用户权限下的菜单列表
      const menus = menuStore.state.authMenuList;

      // 根据菜单权限过滤路由
      accessMenuRoutes = filterAsyncRoutes(menus, asyncRoutes);

      return accessMenuRoutes;
    }
  };

  return {
    generateRoutes
  };
}); 

4 修改 permission.ts

修改 src/permission.ts,代码如下:

TypeScript 复制代码
//src/permission.ts
// 路由鉴权配置 - 控制用户访问权限和页面导航逻辑
import router from "@/router";
import NProgress from "nprogress"; // 进度条插件
import "nprogress/nprogress.css"; // 进度条样式
import { getToken } from "./utils/auth"; // 获取token工具函数
import { useUserStore } from "./stores/user"; // 用户状态管理
import { usePermissionStore } from "./stores/permission"; // 权限状态管理

// 配置进度条选项,不显示旋转加载图标
NProgress.configure({ showSpinner: false });

// 白名单路由 - 无需登录即可访问的页面
const whiteList = ["/login"];

/**
 * 全局前置守卫 - 路由切换前的权限校验
 * 1. 检查Token有效性
 * 2. 判断用户权限
 * 3. 动态生成路由
 */
router.beforeEach(async (to, from) => {
  // 开始进度条
  NProgress.start();

  // 获取本地Token
  const hasToken = getToken();
  const userStore = useUserStore();
  const permissionStore = usePermissionStore();

  // 情况1:已登录状态
  if (hasToken) {
    // 已登录但访问登录页,重定向到首页
    if (to.path === "/login") {
      NProgress.done(); // 结束进度条
      return {
        path: "/",
        replace: true // 替换历史记录,禁止回退
      };
    } else {
      // 已登录且访问非登录页,校验用户权限(有可能token是伪造的,无效的)
      try {
        // 检查是否已有用户角色信息
        const hasRoles = userStore.state.roles.length > 0;

        // 已有角色信息,直接放行
        if (hasRoles) {
          NProgress.done();
          return true;
        }

        // 没有角色信息,重新获取用户信息
        await userStore.getUserInfo();

        // 根据用户角色动态生成可访问的路由配置
        const routes = await permissionStore.generateRoutes();

        // 动态添加路由到路由器
        routes.forEach((route) => router.addRoute(route));

        // 确保路由添加完成,重新访问目标路径
        return router.push({ path: to.path, replace: true });
      } catch (error) {
        // 获取用户信息失败,可能Token过期或无效
        console.error("获取用户信息失败:", error);

        // 清除用户状态并跳转到登录页
        userStore.logout();
        NProgress.done();

        // 携带当前路径作为重定向参数
        return `/login?redirect=${to.path}`;
      }
    }
  }

  // 情况2:未登录状态
  else {
    // 访问白名单页面,直接放行
    if (whiteList.includes(to.path)) {
      NProgress.done();
      return true;
    }

    // 非白名单页面,重定向到登录页并记录原始路径
    return {
      path: "/login",
      query: {
        redirect: to.path,
        ...to.query // 保留原路径的查询参数
      }
    };
  }
});

5 修改路由配置

修改 src/router/index.ts,代码如下:

TypeScript 复制代码
//src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import Layout from "@/layout/index.vue";
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: "/",
    component: Layout,
    redirect: "/dashboard",
    children: [
      {
        path: "dashboard",
        name: "dashboard",
        component: () => import("@/views/dashboard/index.vue"),
        meta: {
          icon: "ant-design:bank-outlined",
          title: "dashboard",
          affix: true, // 固定在tagsViews中
          noCache: true //   不需要缓存
        }
      }
    ]
  },
  {
    path: "/redirect",
    component: Layout,
    meta: {
      hidden: true
    },
    // 当跳转到  /redirect/a/b/c/d?query=1
    children: [
      {
        path: "/redirect/:path(.*)",
        component: () => import("@/views/redirect/index.vue")
      }
    ]
  },
  {
    path: "/login",
    name: "Login",
    meta: {
      hidden: true
    },
    component: () => import("@/views/login/index.vue")
  }
];
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: "/documentation",
    component: Layout,
    redirect: "/documentation/index",
    children: [
      {
        path: "index",
        name: "documentation",
        component: () => import("@/views/documentation/index.vue"),
        meta: {
          icon: "ant-design:database-filled",
          title: "documentation"
        }
      }
    ]
  },

  {
    path: "/guide",
    component: Layout,
    redirect: "/guide/index",
    children: [
      {
        path: "index",
        name: "guide",
        component: () => import("@/views/guide/index.vue"),
        meta: {
          icon: "ant-design:car-twotone",
          title: "guide"
        }
      }
    ]
  },
  {
    path: "/system",
    component: Layout,
    redirect: "/system/menu",
    meta: {
      icon: "ant-design:unlock-filled",
      title: "system",
      alwaysShow: true
      // breadcrumb: false
      // 作为父文件夹一直显示
    },
    children: [
      {
        path: "menu",
        name: "menu",
        component: () => import("@/views/system/menu/index.vue"),
        meta: {
          icon: "ant-design:unlock-filled",
          title: "menu"
        }
      },

      {
        path: "role",
        name: "role",
        component: () => import("@/views/system/role/index.vue"),
        meta: {
          icon: "ant-design:unlock-filled",
          title: "role"
        }
      },
      {
        path: "user",
        name: "user",
        component: () => import("@/views/system/user/index.vue"),
        meta: {
          icon: "ant-design:unlock-filled",
          title: "user"
        }
      }
    ]
  },

  {
    path: "/external-link",
    component: Layout,
    children: [
      {
        path: "http://www.baidu.com",
        redirect: "/",
        meta: {
          icon: "ant-design:link-outlined",
          title: "link Baidu"
        }
      }
    ]
  }
];
// 需要根据用户赋予的权限来动态添加异步路由
export const routes = [...constantRoutes];
export default createRouter({
  routes, // 路由表
  history: createWebHistory() //  路由模式
});

以上就是菜单权限相关内容。

下一篇将继续探讨 动态菜单的实现,敬请期待~

相关推荐
AiMan9 分钟前
豆包爱学还是不可以导出左侧所有对话,只能单容导出,方法不对吗
前端
无懈可击25 分钟前
Vue 可视化AI表单设计器 FcDesigner v3.6 版本发布,支持导出 SQL功能!
vue.js·开源·ai编程
AiMan31 分钟前
Grok生成的内容能否下载吗?
前端
徐小夕1 小时前
JitArchives,一款基于JitWord协同AI文档打造的企业级电子档案系统
前端·vue.js·github
陈随易1 小时前
Node.js 2026上半年都有哪些性能提升?
前端·后端·程序员
墨林陌2 小时前
AI 热点日报(2026-11-09):苹果代理式Siri曝光,Anthropic推进IPO,编码智能体实测翻车
前端
vipxieliang2 小时前
JavaScript 迭代器与生成器:控制异步与惰性求值
前端·javascript
Hilaku2 小时前
Event Loop 这篇你看完就不用再看别的了!
前端·javascript·程序员
恋猫de小郭2 小时前
Flutter 下 AOT 一个你怎么都想不出来的 Bug ,JIT 正常的计算,在 AOT 居然算错了
android·前端·flutter