Skip to content

在实现登录之前,需要说明几点。首次登录系统,需要有一个 super_adimin 角色,角色名是限制死的,默认具备全部菜单路由权限。另外,还需要创建用户,用户信息包括用户名、密码、邮箱、手机号、是否启用,用户关联的角色等。角色和用户都可以通过写接口去添加,或者直接在数据库 Roles 和 Users 表中添加(这里只说明前端实现流程,接口数据库需自己准备模拟,本文我是通过ApiFox mock 的接口,username:1234,password:1234,演示图中会使用)。

注册登录路由

在 src/router/index.ts 中添加登录页 Login 的路由配置,代码如下:

typescript
//src/router/index.ts
import {
  createRouter,
  createWebHistory,
  type 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: false //   不需要缓存
        }
      }
    ]
  },
  {
    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, ...asyncRoutes];
export default createRouter({
  routes, // 路由表
  history: createWebHistory() //  路由模式
});

接入登录接口

2.1 环境变量配置

在根目录下创建环境变量文件,如下(根据实际情况修改):

.env.development

json
VITE_BASE_API=/dev-api

.env.production

json
VITE_BASE_API=/prod-api

2.2 封装 token 方法

在 src/utils 下新建 auth.ts,封装 token 的存储操作方法,代码如下:

typescript
//src/utils/auth.ts
const tokenKey = "v3-admin";
export const setToken = (token: string) => {
  localStorage.setItem(tokenKey, token);
};
export const getToken = () => {
  return localStorage.getItem(tokenKey);
};
export const removeToken = () => {
  localStorage.removeItem(tokenKey);
};

2.3 创建 api

2.3.1 安装 axios 插件

通过 pnpm 安装 axios,代码如下:

json
pnpm install axios

图片

2.3.2 封装 request.ts

在 src 下新建 api 文件夹,新建 config/request.ts 文件,代码如下:

typescript
//src/api/config/request.ts
import { getToken } from "@/utils/auth";
import axios from "axios";
import { ElMessage } from "element-plus";
const config = {
  baseURL: import.meta.env.VITE_BASE_API,
  timeout: 3000
};
const service = axios.create(config);
service.interceptors.request.use(
  (config) => {
    const token = getToken();
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (err) => {
    return Promise.reject(err);
  }
);
service.interceptors.response.use(
  (response) => {
    // code
    const { code, message } = response.data;
    if (code !== 0) {
      ElMessage.error(message);
      return Promise.reject(message);
    }
    return response.data;
  },
  (err) => {
    return Promise.reject(err);
  }
);
export default service;

2.3.3 创建 user api

在 src/api 下新建 type.ts,封装响应类型,代码如下:

typescript
//src/api/type.ts
export interface ApiResponse<T = unknown> {
  code: number;
  data: T;
  message?: string;
}

在 src/api 下新建 user.ts,代码如下:

typescript
//src/api/user.ts
import request from "@/api/config/request";
// 从 "./type" 模块中导入 ApiResponse 类型,用于定义接口响应数据的结构
import type { ApiResponse } from "./type";
/**
 * 定义用户登录所需的数据结构
 * @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("/auth/login", data);
};

2.4 创建 userStore

在 src/stores 下新建 user.ts,定义登录相关状态和方法,这里为了简化没有进行加密,实际开发中可根据实际情况添加,代码如下:

typescript
// src/stores/user.ts
import { login as loginApi } from "@/api/user";
import { setToken } from "@/utils/auth";
export interface IUserInfo {
  username: string;
  password: string;
}
export const useUserStore = defineStore("user", () => {
  const state = reactive({
    token: ""
  });
  const login = async (userInfo: IUserInfo) => {
    try {
      const { username, password } = userInfo;
      const response = await loginApi({ username: username.trim(), password });
      const { data } = response;
      state.token = data.token;
      setToken(data.token);
    } catch (e) {
      return Promise.reject(e);
    }
  };
  return {
    login,
    state
  };
});

2.5 设置代理

修改 vite.config.ts,设置代理,代码如下:

typescript
//vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "path";
import UnoCSS from "unocss/vite";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
import ElementPlus from "unplugin-element-plus/vite";
// https://vite.dev/config/
export default defineConfig({
  resolve: {
    alias: [{ find: "@", replacement: path.resolve(__dirname, "src") }]
  },
  plugins: [
    vue(),
    UnoCSS({
      configFile: "./UnoCSS.config.ts"
    }),
    AutoImport({
      // api
      imports: ["vue", "vue-router", "pinia"],
      resolvers: [ElementPlusResolver()],
      eslintrc: { enabled: false } // 给eslint生产的配置 只需要一次,
    }),
    Components({
      //解析Element Plus组件
      resolvers: [ElementPlusResolver()],
      //所有的自定义组件可以自动加载
      dirs: [
        "src/components",
        "src/layout/components",
        "src/views/**/components"
      ]
    }),
    ElementPlus({}) // 导入样式 不需要引入
  ],
  server: {
    proxy: {
      "/dev-api": {
        target: "http://localhost:3000",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/dev-api/, "/api")
      }
    }
  }
});

登录验证

3.1 安装 nprogress 进度条插件

pnpm 安装 nprogress 插件及类型定义,代码如下:

json
pnpm install --save nprogress
#ts
pnpm install -D @types/nprogress

图片

3.2 添加路由验证

在 src 下新建文件 permission.ts,添加路由导航钩子,进行登录验证,代码如下:

typescript
//src/permission.ts
import router from "@/router";
import "nprogress/nprogress.css";
import { getToken } from "./utils/auth";
import nProgress from "nprogress";
nProgress.configure({ showSpinner: false });
//  配置哪些页面不需要做校验
const whiteList = ["/login"];
router.beforeEach(async (to) => {
  nProgress.start();
  const hasToken = getToken();
  if (hasToken) {
    // 有token代表已经登录
    if (to.path === "/login") {
      nProgress.done();
      return {
        path: "/",
        replace: true
      };
    }
    nProgress.done();
    return true;
  } else {
    if (whiteList.includes(to.path)) {
      nProgress.done();
      return true;
    }
    nProgress.done();
    return {
      path: "/login",
      query: {
        redirect: to.path,
        ...to.query
      }
    };
  }
});

在 src/main.ts 中引入 permission.ts,代码如下:

typescript
//src/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import "normalize.css/normalize.css";
import { createPinia } from "pinia";
import element from "./plugins/element";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import "./permission";
// import ElementPlus from "element-plus";
// import "element-plus/dist/index.css";
import "@/style/index.scss";
import "uno.css";
const app = createApp(App);
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate); // 安装持久化插件
app.use(router);
app.use(pinia);
app.use(element);
// app.use(ElementPlus);
app.mount("#app");

3.3 路由参数钩子

在 src/hooks 下创建 useRouteQuery.ts,获取 url query 并得到 redirect 参数,其中,登录成功后会跳转到 redirect 路径,代码如下:

typescript
//src/hooks/useRouteQuery.ts
// 从 "vue-router" 模块导入 LocationQuery 类型,用于表示路由查询参数的类型
import type { LocationQuery } from "vue-router";
/**
 * 自定义组合式函数,用于处理路由查询参数
 * 它将从当前路由中提取 "redirect" 参数和其他非 "redirect" 参数
 * @returns {Object} - 包含两个响应式引用的对象:
 *   - redirect: 存储 "redirect" 查询参数的值
 *   - otherQuery: 存储除 "redirect" 之外的其他查询参数
 */
export const useRouteQuery = () => {
  const redirect = ref("");
  const otherQuery = ref<LocationQuery>();
  // 获取当前路由对象
  const route = useRoute();
  /**
   * 从给定的查询参数对象中提取除 "redirect" 之外的其他查询参数
   * @param {LocationQuery} query - 包含所有查询参数的对象
   * @returns {LocationQuery} - 只包含除 "redirect" 之外的其他查询参数的对象
   */
  const getOtherQuery = (query: LocationQuery) => {
    // 使用 reduce 方法遍历查询参数对象的键
    return Object.keys(query).reduce((memo, key) => {
      // 如果当前键不是 "redirect"
      if (key !== "redirect") {
        // 将该键值对添加到结果对象中
        memo[key] = query[key];
      }
      // 返回累积的结果对象
      return memo;
      // 初始累积值为空对象,类型为 LocationQuery
    }, {} as LocationQuery);
  };
  // 监听路由查询参数的变化
  watchEffect(() => {
    // 获取当前路由的查询参数
    const query = route.query;
    if (query) {
      redirect.value = query.redirect as string;
      otherQuery.value = getOtherQuery(query);
    }
  });
  // 返回包含 redirect 和 otherQuery 响应式引用的对象
  return {
    redirect,
    otherQuery
  };
};

login 页面开发

在 src/views 下新建 login 文件夹,新建 index.vue 文件,代码如下:

html
//src/views/login/index.vue
<template>
  <divclass="login-container">
    <!-- ElementPlus 表单组件,绑定表单数据和验证规则 -->
    <el-form
      class="login-form"
      ref="form"
      :rules="loginRules"
      :model="loginForm"
    >
      <div class="admin-logo">
        <img class="logo"src="../../assets/vue.svg"alt="logo"size-80px />
        <h1 class="name">Vue3 Admin</h1>
      </div>
      <el-form-item prop="username">
        <el-input placeholder="请输入用户名"v-model="loginForm.username">
          <template #prepend>
            <span class="svg-container">
              <svg-icon icon-name="ant-design:user-outlined"></svg-icon>
            </span>
          </template>
        </el-input>
      </el-form-item>
      <el-form-item>
        <el-input
          type="password"
          placeholder="请输入密码"
          autocomplete="on"
          show-password
          v-model="loginForm.password"
          prop="password"
        >
          <template #prepend>
            <span class="svg-container">
              <svg-icon icon-name="ant-design:lock-outlined"></svg-icon>
            </span>
          </template>
        </el-input>
      </el-form-item>
      <el-button type="primary" @click="handleLogin"w-fullmb-30px
        >登录</el-button
      >
    </el-form>
  </div>
</template>
<scriptlang="ts"setup>
// 引入用户状态管理 store
import { useUserStore } from "@/stores/user";
// 引入 ElementPlus 表单实例类型
import type { FormInstance } from "element-plus";
// 引入自定义路由查询钩子
import { useRouteQuery } from "@/hooks/useRouteQuery";
// 从用户状态管理 store 中解构出登录方法
const { login } = useUserStore();
// 引入路由实例
const router = useRouter();
const { proxy } = getCurrentInstance()!;
// 使用自定义路由查询钩子获取重定向路径和其他查询参数
const { redirect, otherQuery } = useRouteQuery();
// 定义响应式登录状态对象
const loginState = reactive({
  // 登录表单数据
  loginForm: {
    username: "",
    password: ""
  },
  // 登录表单验证规则
  loginRules: {
    username: [{ required: true, trigger: "blur", message: "请输入用户名" }],
    password: [{ required: true, trigger: "blur", message: "请输入密码" }]
  }
});
// 获取表单实例引用
const loginFormInstance = useTemplateRef<FormInstance>("form");
// 解构出登录表单数据和验证规则
const { loginForm, loginRules } = loginState;
// 处理登录事件的函数
const handleLogin = () => {
  // 对表单进行验证
  loginFormInstance.value?.validate(async (valid) => {
    // 如果验证通过
    if (valid) {
      // 调用登录方法进行登录操作
      await login(loginForm);
      proxy?.$message.success("登录成功");
      // 解析出一个重定向的路径  + 其他的查询参数
      router.push({ path: redirect.value || "/", query: otherQuery.value });
    }
  });
};
</script>
<stylescopedlang="scss">
.login-container {
  @apply min-h-screen w-full;
  .login-form {
    @apply w-500px mx-auto py50px;
  }
  .admin-logo {
    @apply flex items-center justify-center my-20px;
  }
}
</style>

退出登录

5.1 修改 store

在 src/stores/users.ts 中,添加 logout 方法,退出登录时,调用 logout,清空 store、token、tagsView 刷新跳转到登录页,代码如下:

typescript
//src/stores/users.ts
import { login as loginApi } from "@/api/user";
import { setToken, removeToken } from "@/utils/auth";
import { useTagsView } from "./tagsView";
export interface IUserInfo {
  username: string;
  password: string;
}
const tagsViewStore = useTagsView();
export const useUserStore = defineStore("user", () => {
  const state = reactive({
    token: ""
  });
  const resetToken = () => {
    state.token = "";
    removeToken();
  };
  const login = async (userInfo: IUserInfo) => {
    try {
      const { username, password } = userInfo;
      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();
  };
  return {
    state,
    login,
    logout,
    resetToken
  };
});

5.2 Avatar 组件开发

在 src/components 下新建 Avatar 文件夹,新建 index.vue,添加退出登录入口,代码如下:

html
//src/components/Avatar/index.vue
<template>
  <el-dropdown>
    <img:src="avatar"size-40pxrounded-smcursor-pointeroutline-none />
    <template #dropdown>
      <el-dropdown-menu>
        <router-linkto="/">
          <el-dropdown-item>首页</el-dropdown-item>
        </router-link>
        <el-dropdown-item divided @click="logout">
          <span block @click="logout">退出登录</span>
        </el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>
<scriptlang="ts"setup>
import avatar from "@/assets/vue.svg";
import { useUserStore } from "@/stores/user";
const store = useUserStore();
const { proxy } = getCurrentInstance()!;
const logout = () => {
  store.logout();
  proxy?.$message.success("退出成功");
  window.location.reload();
};
</script>

别忘了在 src/layout/components/Navbar.vue 中引入,代码如下:

html
//src/layout/components/Navbar.vue
<template>
  <divclass="navbar"flex>
    <hamburger
      @toggleCollapse="toggleSidebar"
      :collapse="sidebar.opened"
    ></hamburger>
    <BreadCrumb></BreadCrumb>
    <divflexjustify-endflex-1items-centermr-20px>
      <screenfullmx-5px></screenfull>
      <el-tooltipcontent="ChangeSize"placement="bottom">
        <size-select></size-select>
      </el-tooltip>
      <svg-icon
        icon-name="ant-design:setting-outlined"
        size-2em
        @click="openShowSetting"
      ></svg-icon>
      <avatar></avatar>
    </div>
  </div>
</template>
<stylescopedlang="scss">
.navbar {
  @apply h-[var(--navbar-height)];
}
</style>
<scriptlang="ts"setup>
import { useAppStore } from "@/stores/app";
// 在解构的时候要考虑值是不是对象,如果非对象解构出来就 丧失响应式了
const { toggleSidebar, sidebar } = useAppStore();
const emit = defineEmits<{
  (event: "showSetting", isShow: boolean): void;
}>();
const openShowSetting = () => {
  emit("showSetting", true);
};
</script>

token 失效处理

修改 src/api/config/request.ts,拦截 401 token 错误,代码如下:

typescript
//src/api/config/request.ts
import { useUserStore } from "@/stores/user";
import { getToken } from "@/utils/auth";
import axios from "axios";
import { ElMessage } from "element-plus";
const config = {
  baseURL: import.meta.env.VITE_BASE_API,
  timeout: 3000
};
const service = axios.create(config);
service.interceptors.request.use(
  (config) => {
    const token = getToken();
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (err) => {
    return Promise.reject(err);
  }
);
service.interceptors.response.use(
  (response) => {
    // code
    const { code, message } = response.data;
    if (code !== 0) {
      ElMessage.error(message);
      return Promise.reject(message);
    }
    return response.data;
  },
  (err) => {
    const store = useUserStore();
    const res = err.response;
    if (res.status === 401) {
      // 说明token不正确,
      store.logout(); // 移除token
      window.location.reload();
    }
    return Promise.reject(err);
  }
);
export default service;

npm run dev 启动后,页面效果如下:

图片

以上,就是登录登出及鉴权的全部内容。