1.创建项目 #

pnpm create vite vue3-auth --template vue-ts
cd vue3-auth
pnpm install vue-router pinia element-plus axios sass @element-plus/icons-vue path-browserify
pnpm install @types/node -D

2.集成vue-router #

本节内容概述了如何在 Vue.js 项目中集成 Vue Router。步骤包括:

  1. 安装 Vue Router:使用 pnpm install vue-router 命令进行安装。

  2. 配置 main.ts:在 src\main.ts 文件中,引入 Vue、Vue Router 和根组件 App.vue,创建 Vue 应用实例,并使用 .use(router) 方法注册路由器,最后挂载到 HTML 中的元素上。

  3. 修改 App.vue:在 src\App.vue 文件中,设置一个 <router-link> 组件导航到主页,和一个 <router-view> 组件来显示当前路由内容。

  4. 创建 Home 组件:在 src\views\Home\index.vue 文件中,定义一个简单的 Home 组件。

  5. 配置路由:在 src\router\index.ts 文件中,使用 createRoutercreateWebHistory 创建路由器实例,并定义路由规则。其中包括一个重定向到 /home 的路由和一个指向 Home 组件的路由。

2.1 安装 #

pnpm install vue-router

2.2 main.ts #

src\main.ts

import { createApp } from 'vue'
import router from "./router/index"
import App from './App.vue'
const app = createApp(App)
app.use(router)
app.mount('#app')

2.3 App.vue #

src\App.vue

<script setup lang="ts"></script>
<template>
  <router-link to="/home">首页</router-link>
  <router-view></router-view>
</template>
<style scoped></style>

2.4 Home\index.vue #

src\views\Home\index.vue

<template>
  <h1>Home</h1>
</template>

2.5 router\index.ts #

src\router\index.ts

import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
    {
      path: "/",
      redirect: "/home",
    },
    {
      path: "/home",
      component: () => import("../views/Home/index.vue")
    },
  ];
export default createRouter({
  history: createWebHistory(),
  routes,
});

3.配置vite路径别名 #

这一节内容介绍了如何在 Vite 配置中设置路径别名,以简化项目中的模块引用。首先,在 vite.config.ts 文件中,通过引入 path 模块并在 resolve 配置项中定义一个别名 "@",指向项目的 src 目录。接着,在 tsconfig.json 文件中,设置了 baseUrlpaths 选项,以支持 TypeScript 识别这个新的路径别名。最后,在 src\router\index.ts 文件中,演示了如何使用这个别名来引入一个 Vue 组件,展示了路径别名在项目中的应用。这些配置提高了代码的可读性和可维护性。

3.1 vite.config.ts #

vite.config.ts

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
+import path from "path";
export default defineConfig({
  plugins: [vue()],
+ resolve: {
+   alias: [
+     {
+       find: "@",
+       replacement: path.resolve(__dirname, "src"),
+     },
+   ],
+ },
});

3.2 tsconfig.json #

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "preserve",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
+   "baseUrl": ".",
+   "paths": {
+     "@/*": ["src/*"]
+   }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

3.3 router\index.ts #

src\router\index.ts

import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
    {
      path: "/",
      redirect: "/home",
    },
    {
      path: "/home",
+     component: () => import("@/views/Home/index.vue")
    },
  ];
export default createRouter({
  history: createWebHistory(),
  routes,
});

4.集成 Pinia #

本节内容涉及在 Vue 项目中集成 Pinia。首先,通过 pnpm install pinia 命令安装 Pinia。接着,在 main.ts 文件中,引入并使用 createPinia 方法,这允许 Vue 应用使用 Pinia。然后,创建一个名为 user.ts 的文件,在其中定义一个 Pinia store,用于管理用户状态。最后,在 Home/index.vue 组件中,通过引入并使用这个用户 store,展示了如何在组件中访问和展示 store 中的状态(例如用户的 token)。这些步骤共同构成了在 Vue 应用中集成和使用 Pinia 的基本流程。

4.1 安装 #

pnpm install pinia

4.2 main.ts #

src\main.ts

import { createApp } from 'vue'
+import {createPinia} from "pinia";
import router from "./router/index"
import App from './App.vue'
const app = createApp(App)
app.use(router)
+app.use(createPinia())
app.mount('#app')

4.3 user.ts #

src\stores\user.ts

import { defineStore } from "pinia";
import { reactive } from "vue";
export const useUserStore = defineStore("user", () => {
  const state = reactive({
    token: "token",
  });
  return {
    state,
  };
});

4.4 Home\index.vue #

src\views\Home\index.vue

<template>
  <h1>Home</h1>
+ <p>{{userStore.state.token}}</p>
</template>
+<script setup lang="ts">
+import {useUserStore} from '@/stores/user';
+const userStore = useUserStore();
+</script>

5.集成Element Plus #

本节内容介绍了在 Vue 项目中集成 Element Plus UI 框架的步骤。首先,通过 pnpm install element-plus 命令安装 Element Plus。接着,在 tsconfig.json 文件中添加 Element Plus 的类型声明,以便 TypeScript 识别。然后,在 main.ts 文件中引入 Element Plus 及其样式,并在 Vue 应用中使用它。最后,在 Home/index.vue 组件中展示了如何使用 Element Plus 提供的 UI 组件,例如使用 <el-button> 创建一个按钮。这些步骤共同构成了在 Vue 应用中集成 Element Plus UI 框架的基本流程。

5.1 安装 #

pnpm install element-plus

5.2 tsconfig.json #

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "preserve",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
+   "types": ["element-plus/global"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

5.3 main.ts #

src\main.ts

import { createApp } from 'vue'
import {createPinia} from "pinia";
+import ElementPlus from "element-plus";
+import "element-plus/dist/index.css";
import './style.css'
import App from './App.vue'
import router from "./router/index";
const app = createApp(App);
+app.use(ElementPlus);
app.use(router);
app.use(createPinia());
app.mount('#app')

5.4 Home\index.vue #

src\views\Home\index.vue

<template>
  <h1>Home</h1>
  <p>{{userStore.state.token}}</p>
+ <el-button type="primary">点击</el-button>
</template>
<script setup lang="ts">
import {useUserStore} from '@/stores/user';
const userStore = useUserStore();
</script>

6.绘制登录页 #

本节内容讲解了如何在 Vue 项目中绘制登录页面。首先,在 router/index.ts 文件中添加一个新的路由配置,为登录页面 /login 设置一个路由。然后,在 views/Login/index.vue 文件中创建登录表单,使用 Element Plus 的表单组件 (el-form) 和输入框组件 (el-input)。表单包含用户名和密码输入框,并有一个用于提交的按钮 (el-button)。在脚本部分,使用 Vue 的 reactivetoRefs 来创建和管理登录表单的响应式状态。最后,定义一个 handleLogin 函数来处理登录逻辑,目前只是打印表单数据。这些步骤展示了如何使用 Vue Router 和 Element Plus 框架来创建一个基础的登录界面。

6.1 router\index.ts #

src\router\index.ts

import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
  {
    path: "/",
    redirect: "/home",
  },
  {
    path: "/home",
    component: () => import("@/views/Home/index.vue"),
  },
+ {
+   path: "/login",
+   name: "login",
+   component: () => import("@/views/Login/index.vue"),
+ },
];
export default createRouter({
  history: createWebHistory(),
  routes,
});

6.2 Login\index.vue #

src\views\Login\index.vue

<template>
  <el-form class="login-form">
    <el-form-item prop="username">
      <el-input placeholder="用户名" v-model="loginForm.username" />
    </el-form-item>
    <el-form-item prop="password">
      <el-input placeholder="密码" v-model="loginForm.password" />
    </el-form-item>
    <el-button type="primary" @click="handleLogin">Login</el-button>
  </el-form>
</template>
<script lang="ts" setup>
import { reactive, toRefs } from "vue";
const loginState = reactive({
  loginForm: { username: "", password: "" },
});
const { loginForm } = toRefs(loginState);
const handleLogin = () => {
  console.log(loginState.loginForm);
};
</script>

7.接入登录接口 #

本节内容详细介绍了如何在 Vue 项目中接入登录接口。这包括了以下几个主要步骤:

  1. 修改 TypeScript 配置 (tsconfig.json): 对 TypeScript 的严格模式进行了调整,关闭了部分严格检查。

  2. 设置代理 (vite.config.ts): 在 Vite 配置中添加了代理设置,使得 /api 路径的请求被代理到指定的后端服务器。

  3. 更新用户 Store (user.ts): 在 Pinia 的用户 store 中添加了 toLogin 方法,用于处理登录逻辑。这个方法调用了新定义的登录 API,并在成功登录后保存 token。

  4. 修改登录组件 (Login/index.vue): 在登录页面组件中,调用了用户 store 的 toLogin 方法,并在成功登录后跳转到主页。

  5. 认证工具方法 (auth.ts): 定义了用于处理 token 的工具方法,包括获取、设置和移除本地存储中的 token。

  6. 设置请求拦截器 (request.ts): 创建并配置了 Axios 实例,包括请求和响应拦截器。请求拦截器用于在请求头中添加 token,而响应拦截器处理了响应数据和错误信息。

  7. 定义登录 API (user.ts): 实现了登录 API 的请求方法,使用 Axios 发送 POST 请求到后端登录接口。

这些步骤共同构成了在 Vue 项目中接入并处理登录逻辑的完整流程,涵盖了前端请求配置、状态管理、用户交互和后端通信等关键方面。

7.1 tsconfig.json #

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "preserve",

    /* Linting */
+   "strict": false,
+   "noImplicitAny": false,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
    "types": ["element-plus/global"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

7.2 vite.config.ts #

vite.config.ts

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "path";
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: [
      {
        find: "@",
        replacement: path.resolve(__dirname, "src"),
      },
    ],
  },
+ server: {
+   proxy: {
+     "/api": {
+       target: "http://localhost:3000",
+       ws: true,
+       changeOrigin: true,
+     },
+   },
+ },
});

7.3 user.ts #

src\stores\user.ts

import { defineStore } from "pinia";
import { reactive } from "vue";
+import { login } from "@/api/user";
+import { setToken } from "@/utils/auth";
export const useUserStore = defineStore("user", () => {
  const state = reactive({
    token: "token",
  });
+ const toLogin = async (loginInfo) => {
+   try {
+     const response = await login(loginInfo);
+     const { token } = response.data;
+     state.token = token;
+     setToken(token);
+   } catch (error) {
+     console.error(`error: ${error}`);
+     return Promise.reject(error);
+   }
+ };
  return {
    state,
+   toLogin,
  };
});

7.4 Login\index.vue #

src\views\Login\index.vue

<template>
  <el-form class="login-form">
    <el-form-item prop="username">
      <el-input placeholder="用户名" v-model="loginForm.username" />
    </el-form-item>
    <el-form-item prop="password">
      <el-input placeholder="密码" v-model="loginForm.password" />
    </el-form-item>
    <el-button type="primary" @click="handleLogin">Login</el-button>
  </el-form>
</template>
<script lang="ts" setup>
import { reactive, toRefs } from "vue";
+import { useUserStore } from "@/stores/user";
+import { useRouter } from "vue-router";
+const router = useRouter();
+const userStore = useUserStore();
const loginState = reactive({
  loginForm: { username: "", password: "" },
});
const { loginForm } = toRefs(loginState);
+const handleLogin = async () => {
+  await userStore.toLogin(loginState.loginForm);
+  router.push({ path: "/" });
+};
</script>

7.5 auth.ts #

src\utils\auth.ts

const TOKEN = "token";
export const getToken = (): string | null => {
  return localStorage.getItem(TOKEN);
};
export const setToken = (token: string): void => {
  return localStorage.setItem(TOKEN, token);
};
export const removeToken = (): void => {
  return localStorage.removeItem(TOKEN);
};

7.6 request.ts #

src\api\request.ts

import axios from "axios";
import { getToken } from "@/utils/auth";
import { ElMessage } from "element-plus";
const request = axios.create({
  timeout: 100000,
  baseURL: '/api',
});
request.interceptors.request.use(
  (config) => {
    const token = getToken();
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    console.error(`Request error: ${error}`);
    return Promise.reject(error);
  }
);
request.interceptors.response.use(
  (response) => {
    const { code, message } = response.data;
    if (code !== 0) {
      ElMessage.error(message);
      return Promise.reject(new Error(message));
    }
    return response.data;
  },
  (error) => {
    console.error(`Response error: ${error}`);
    ElMessage.error(error.response?.data?.message || error.message || "发生错误");
    return Promise.reject(error);
  }
);
export default request;

7.7 user.ts #

src\api\user.ts

import request from "./request";
export const login = (data) => {
  return request.post("/auth/login", data);
};

8.登录验证 #

本节内容介绍了如何在 Vue 项目中实现登录验证。主要步骤包括:

  1. 创建路由守卫 (permission.ts): 在这个文件中,使用 Vue Router 的 beforeEach 钩子创建了一个全局前置守卫。这个守卫首先检查用户是否拥有有效的 token。如果用户已登录(即拥有有效的 token),并且尝试访问登录页面,它们会被重定向到主页。如果用户未登录且尝试访问非白名单路径,将会被重定向到登录页面,并附带原本尝试访问的路径信息。

  2. 在主要入口文件中引入守卫 (main.ts): 在 Vue 应用的主入口文件 main.ts 中引入了 permission.ts,确保在应用启动时激活路由守卫。

通过这些设置,项目能够确保只有经过身份验证的用户才能访问特定路由,未经验证的用户将被重定向到登录页面,从而在应用中实现了基本的登录验证机制。

8.1 permission.ts #

src\permission.ts

import router from "@/router";
import { getToken } from "./utils/auth";
const LOGIN_PATH = "/login";
const HOME_PATH = "/home";
const WHITE_LIST = [LOGIN_PATH];
router.beforeEach((to) => {
  const token = getToken();
  if (token) {
    if (to.path === LOGIN_PATH) {
      return { path: HOME_PATH, replace: true };
    }
    return true;
  }
  if (WHITE_LIST.includes(to.path)) {
    return true;
  }
  return {
    path: LOGIN_PATH,
    query: { redirect: to.path, ...to.query },
  };
});

8.2 main.ts #

src\main.ts

import { createApp } from 'vue'
import {createPinia} from "pinia";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import router from "./router/index"
import App from './App.vue'
+import './permission';
const app = createApp(App)
app.use(router)
app.use(ElementPlus);
app.use(createPinia())
app.mount('#app')

9.登录后跳转 #

本节内容围绕在 Vue 项目中实现登录后的跳转功能。关键步骤包括:

  1. 创建自定义钩子 (useRouteQuery.ts): 开发了一个名为 useRouteQuery 的自定义钩子,用于处理和解析路由查询参数。这个钩子使用 Vue Router 的 useRoute 来访问当前路由的查询参数,并提取出重定向地址(redirect)和其他查询参数(otherQuery)。

  2. 更新登录组件 (Login/index.vue): 在登录页面组件中,引入并使用了 useRouteQuery 钩子。在用户登录成功后,根据从钩子中获取的 redirect 值来决定跳转目的地。如果存在重定向地址,则跳转到该地址,否则默认跳转到主页。这个过程也考虑了其他查询参数,确保跳转时不会丢失这些参数。

通过这些步骤,项目能够在用户登录后根据之前尝试访问的页面或者默认主页进行跳转,提高了用户体验,使得登录流程更加连贯和直观。

9.1 useRouteQuery.ts #

src\hooks\useRouteQuery.ts

import { ref, computed, watchEffect } from "vue";
import { useRoute } from "vue-router";
const useRouteQuery = () => {
  const route = useRoute();
  const redirect = ref("");
  const otherQuery = ref({});
  const getOtherQuery = (query) => {
    const { redirect, ...other } = query;
    return other;
  };
  const otherQueryComputed = computed(() => getOtherQuery(route.query));
  const redirectComputed = computed(() => route.query.redirect || "");
  watchEffect(() => {
    otherQuery.value = otherQueryComputed.value;
    redirect.value = redirectComputed.value as string;
  });
  return {
    redirect,
    otherQuery,
  };
};
export default useRouteQuery;

9.2 Login\index.vue #

src\views\Login\index.vue

<template>
  <el-form class="login-form">
    <el-form-item prop="username">
      <el-input placeholder="用户名" v-model="loginForm.username" />
    </el-form-item>
    <el-form-item prop="password">
      <el-input placeholder="密码" v-model="loginForm.password" />
    </el-form-item>
    <el-button type="primary" @click="handleLogin">Login</el-button>
  </el-form>
</template>
<script lang="ts" setup>
import { reactive, toRefs } from "vue";
import { useUserStore } from "@/stores/user";
import { useRouter } from "vue-router";
+import useRouteQuery from "@/hooks/useRouteQuery";
+const { redirect, otherQuery } = useRouteQuery();
const router = useRouter();
const userStore = useUserStore();
const loginState = reactive({
  loginForm: { username: "", password: "" },
});
const { loginForm } = toRefs(loginState);
const handleLogin = async () => {
  await userStore.toLogin(loginState.loginForm);
+ router.push({ path: redirect.value || "/home", query: otherQuery.value });
};
</script>

10.无痕刷新Token #

本节内容讲述了如何在 Vue 项目中实现无痕刷新 Token 的功能。这个功能主要通过以下步骤实现:

  1. 更新请求拦截器 (request.ts): 在 Axios 的请求拦截器中,自动在请求头添加 Token。在响应拦截器中,对 401 错误(即 Token 失效)进行处理,尝试使用刷新 Token 来获取新的访问 Token,并重试原始请求。

  2. 实现 Token 刷新逻辑 (request.ts): 定义了一个 refreshToken 函数,它会在访问 Token 失效时被调用。这个函数使用刷新 Token 向后端发送请求以获取新的访问 Token 和刷新 Token,然后更新本地存储中的 Token,并重试失败的请求。

  3. 扩展用户 API (user.ts): 添加了新的 API 函数 fetchUserList,用于请求用户列表,这个请求将会触发 Token 刷新逻辑(如果需要的话)。

  4. 更新用户 Store (user.ts): 在登录逻辑中保存刷新 Token,并将其存储在用户 store 和本地存储中。

  5. 扩展认证工具方法 (auth.ts): 添加了用于处理刷新 Token 的工具方法,包括获取、设置、移除刷新 Token 的函数。

  6. 更新首页组件 (Home/index.vue): 在首页组件中添加了一个按钮,用于触发请求用户列表的操作,这个操作可以用于测试无痕刷新 Token 功能。

通过这些改进,项目能够在用户 Token 过期时自动尝试刷新 Token,从而提供无缝的用户体验,避免了需要用户手动重新登录的情况。

10.1 request.ts #

src\api\request.ts

import axios from "axios";
+import {
+  getToken,
+  setToken,
+  removeToken,
+  getRefreshToken,
+  setRefreshToken,
+} from "@/utils/auth";
import { ElMessage } from "element-plus";
const request = axios.create({
  timeout: 100000,
  baseURL: "/api",
});
request.interceptors.request.use(
  (config) => {
    const token = getToken();
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    console.error(`Request error: ${error}`);
    return Promise.reject(error);
  }
);
request.interceptors.response.use(
  (response) => {
    const { code, message } = response.data;
    if (code !== 0) {
      ElMessage.error(message);
      return Promise.reject(new Error(message));
    }
    return response.data;
  },
  (error) => {
    console.error(`Response error: ${error}`);
+   if (error.response && error.response.status === 401) {
+     if (!error.config._retry) {
+       error.config._retry = true;
+       removeToken();
+       return refreshToken(error.config);
+     }
+   }
    ElMessage.error(
      error.response?.data?.message || error.message || "发生错误"
    );
    return Promise.reject(error);
  }
);
+async function refreshToken(config) {
+  const refreshToken = getRefreshToken();
+  if (!refreshToken) {
+    return Promise.reject(new Error("No refresh token available"));
+  }
+  try {
+    const response = await axios.post(
+      "/api/auth/refresh-token",
+      {},
+      {
+        headers: { Authorization: `Bearer ${refreshToken}` },
+      }
+    );
+    const { token, refreshToken: newRefreshToken } = response.data?.data;
+    if (token && newRefreshToken) {
+      setToken(token);
+      setRefreshToken(newRefreshToken);
+      config.headers.Authorization = `Bearer ${token}`;
+      console.log("config._retry", config._retry);
+      return request(config);
+    } else {
+      return Promise.reject(new Error("refresh token failed"));
+    }
+  } catch (refreshError) {
+    return Promise.reject(refreshError);
+  }
+}
export default request;

10.2 user.ts #

src\api\user.ts

import request from "./request";
export const login = (data) => {
  return request.post("/auth/login", data);
};
+export const fetchUserList = () => {
+  return request.get("/user");
+};

10.3 user.ts #

src\stores\user.ts

import { defineStore } from "pinia";
import { reactive } from "vue";
import { login } from "@/api/user";
+import { setToken,setRefreshToken} from "@/utils/auth";
export const useUserStore = defineStore("user", () => {
  const state = reactive({
    token: "token",
+   refreshToken:''
  });
  const toLogin = async (loginInfo) => {
    try {
      const response = await login(loginInfo);
      const { token,refreshToken } = response.data;
      state.token = token;
+     state.refreshToken = refreshToken;
      setToken(token);
+     setRefreshToken(refreshToken);
    } catch (error) {
      console.error(`error: ${error}`);
      return Promise.reject(error);
    }
  };
  return {
    state,
    toLogin,
  };
});

10.4 auth.ts #

src\utils\auth.ts

const TOKEN = "token";
+const REFRESH_TOKEN = "refresh_token";
export const getToken = (): string | null => {
  return localStorage.getItem(TOKEN);
};
export const setToken = (token: string): void => {
  return localStorage.setItem(TOKEN, token);
};
export const removeToken = (): void => {
  return localStorage.removeItem(TOKEN);
};
+export const getRefreshToken = (): string | null => {
+  return localStorage.getItem(REFRESH_TOKEN);
+};
+export const setRefreshToken = (refresh_token: string): void => {
+  return localStorage.setItem(REFRESH_TOKEN, refresh_token);
+};
+export const removeRefreshToken = (): void => {
+  return localStorage.removeItem(REFRESH_TOKEN);
+};

10.5 Home\index.vue #

src\views\Home\index.vue

<template>
  <h1>Home</h1>
  <p>{{userStore.state.token}}</p>
+ <el-button type="primary" @click="requestUserList">请求用户列表</el-button>
</template>
<script setup lang="ts">
import {useUserStore} from '@/stores/user';
+import { fetchUserList } from "@/api/user";
const userStore = useUserStore();
+const requestUserList = async () => {
+  try {
+    const response = await fetchUserList();
+    console.log("用户列表:", response);
+  } catch (error) {
+    console.error("请求用户列表失败:", error);
+  }
+};
</script>

11.实现退出登录 #

本节内容描述了如何在 Vue 项目中实现退出登录的功能。这主要包括以下步骤:

  1. 更新用户 Store (user.ts): 在 Pinia 的用户 store 中添加了 toLogout 方法。这个方法会重置用户的 token 和 refreshToken 状态,并调用工具方法 removeTokenremoveRefreshToken 从本地存储中移除 token 和 refreshToken,实现用户的注销操作。

  2. 更新首页组件 (Home/index.vue): 在首页组件中添加了一个“退出登录”按钮,并绑定点击事件到 logout 方法。这个 logout 方法调用了用户 store 中的 toLogout 方法,并通过 window.location.reload() 刷新页面,实现用户注销后的界面更新。

通过这些步骤,项目能够提供一个简单直接的用户退出登录功能,清除用户的登录状态和相关数据,从而确保注销后用户无法访问需要认证的数据和界面。

11.1 user.ts #

src\stores\user.ts

import { defineStore } from "pinia";
import { reactive } from "vue";
import { login } from "@/api/user";
+import { setToken, setRefreshToken,removeToken ,removeRefreshToken } from "@/utils/auth";
export const useUserStore = defineStore("user", () => {
  const state = reactive({
    token: "token",
    refreshToken: "",
  });
  const toLogin = async (loginInfo) => {
    try {
      const response = await login(loginInfo);
+     const { token, refreshToken } = response.data;
      state.token = token;
      state.refreshToken = refreshToken;
      setToken(token);
      setRefreshToken(refreshToken);
    } catch (error) {
      console.error(`error: ${error}`);
      return Promise.reject(error);
    }
  };
+ const toLogout = () => {
+   state.token = "";
+   state.refreshToken = "";
+   removeToken();
+   removeRefreshToken();
+ };
  return {
    state,
    toLogin,
+   toLogout,
  };
});

11.2 Home\index.vue #

src\views\Home\index.vue

<template>
  <h1>Home</h1>
  <p>{{userStore.state.token}}</p>
  <el-button type="primary" @click="requestUserList">请求用户列表</el-button>
+ <el-button type="primary" @click="logout">退出登录</el-button>
</template>
<script setup lang="ts">
import {useUserStore} from '@/stores/user';
import { fetchUserList } from "@/api/user";
const userStore = useUserStore();
const requestUserList = async () => {
  try {
    const response = await fetchUserList();
    console.log("用户列表:", response);
  } catch (error) {
    console.error("请求用户列表失败:", error);
  }
};
+const logout = () => {
+  userStore.toLogout();
+  window.location.reload();
+};
</script>

12.首页布局 #

本节内容介绍了如何在 Vue 项目中设置首页的布局。这包括了以下关键步骤:

  1. 更新路由配置 (router/index.ts): 对路由配置进行了修改,引入了一个名为 Layout 的新组件作为主布局容器,并设置了子路由。这里的子路由 home 被嵌套在 Layout 组件下,意味着访问 /home 将会渲染 Layout 组件及其子组件 Home/index.vue

  2. 创建布局组件 (layout/index.vue): 开发了 Layout 组件,它定义了应用的主体布局。这个组件包含两个主要部分:侧边栏 (sidebar-container) 和主内容区 (main-container)。使用 CSS Flexbox 实现了水平布局,侧边栏和主内容区并排显示。

通过这些步骤,项目能够实现一个基本的页面布局,其中包括一个侧边栏和一个主内容区。侧边栏用于放置导航元素,而主内容区则用于展示不同页面的视图内容。这种布局是许多现代 web 应用的常见结构。

12.1 router\index.ts #

src\router\index.ts

import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import Layout from "@/layout/index.vue";
const routes: RouteRecordRaw[] = [
  {
    path:'/',
    redirect:'/home'
  },
+ {
+   path: "/",
+   component: Layout,
+   children: [
+     {
+       path: "home",
+       name: "home",
+       component: () => import("@/views/Home/index.vue"),
+       meta: {
+         title: "home",
+         icon:"Aim"
+       },
+     },
+   ],
+ },
  {
    path: "/login",
    name: "login",
    component: () => import("@/views/Login/index.vue"),
  },
];
export default createRouter({
  history: createWebHistory(),
  routes,
});

12.2 layout\index.vue #

src\layout\index.vue

<template>
  <div class="app-wrapper">
    <div class="sidebar-container">sidebar</div>
    <div class="main-container">
      <router-view></router-view>
    </div>
  </div>
</template>
<style lang="scss" scoped>
.app-wrapper {
  display: flex;
  width: 100%;
  height: 100vh;
  .sidebar-container {
    min-width: 210px;
    background-color: orange;
  }
  .main-container {
    flex: 1;
    background: green;
  }
}
</style>

13.获取用户信息 #

本节内容讲解了在 Vue 项目中获取用户信息的过程。这涉及以下关键步骤:

  1. 更新路由守卫 (permission.ts): 在全局前置路由守卫中加入了获取用户信息的逻辑。当用户已经拥有 token 并尝试访问一个新路由时,首先检查用户的角色信息是否已加载。如果未加载,则调用 getUserInfo 方法从后端获取用户信息,并在获取信息后继续路由跳转。

  2. 扩展用户 Store (user.ts): 在 Pinia 的用户 store 中添加了 getUserInfo 方法。此方法调用了新定义的 API getAuthInfo 来从后端获取用户信息,并更新 store 中的用户信息和角色状态。

  3. 定义获取用户信息的 API (user.ts): 在用户相关的 API 文件中添加了 getAuthInfo 方法,用于向后端发送请求,获取当前认证用户的信息。

通过这些步骤,项目能够在用户登录后自动获取并存储用户的详细信息,包括用户角色等。这对于实现基于角色的路由守卫、权限控制和用户信息展示等功能至关重要。

13.1 permission.ts #

src\permission.ts

import router from "@/router";
import { getToken } from "./utils/auth";
+import { useUserStore } from "./stores/user";
const LOGIN_PATH = "/login";
const HOME_PATH = "/home";
const WHITE_LIST = [LOGIN_PATH];
+router.beforeEach(async (to) => {
+ const userStore = useUserStore();
  const token = getToken();
  if (token) {
    if (to.path === LOGIN_PATH) {
      return { path: HOME_PATH, replace: true };
    }
+   const roles = userStore.state.roles && userStore.state.roles.length > 0;
+   if (roles) {
+     return true;
+   }
+   await userStore.getUserInfo();
+   return router.push(to.path);
  }
  if (WHITE_LIST.includes(to.path)) {
    return true;
  }
  return {
    path: LOGIN_PATH,
    query: { redirect: to.path, ...to.query },
  };
});

13.2 user.ts #

src\stores\user.ts

import { defineStore } from "pinia";
import { reactive } from "vue";
+import { login ,getAuthInfo} from "@/api/user";
import { setToken, setRefreshToken,removeToken ,removeRefreshToken } from "@/utils/auth";
export const useUserStore = defineStore("user", () => {
  const state = reactive({
    token: "token",
    refreshToken: "",
+   userInfo: {},
+   roles:[]
  });
  const toLogin = async (loginInfo) => {
    try {
      const response = await login(loginInfo);
      const { token, refreshToken } = response.data;
      state.token = token;
      state.refreshToken = refreshToken;
      setToken(token);
      setRefreshToken(refreshToken);
    } catch (error) {
      console.error(`error: ${error}`);
      return Promise.reject(error);
    }
  };
  const toLogout = () => {
    state.token = "";
    state.refreshToken = "";
    removeToken();
    removeRefreshToken();
  };
+ const getUserInfo = async () => {
+   const result = await getAuthInfo()
+   const { roles, ...info } = result.data
+   state.userInfo = info;
+   state.roles = roles
+ }
  return {
    state,
    toLogin,
    toLogout,
+   getUserInfo
  };
});

13.3 user.ts #

src\api\user.ts

import request from "./request";
export const login = (data) => {
  return request.post("/auth/login", data);
};
export const fetchUserList = () => {
  return request.get("/user");
};
+export const getAuthInfo = ()=> {
+  return request.post("/auth/info")
+}

14.获取权限菜单 #

本节内容详细介绍了如何在 Vue 项目中获取并处理基于用户权限的菜单数据。这涉及以下几个关键步骤:

  1. 更新路由守卫 (permission.ts): 在全局前置路由守卫中增加了获取权限菜单的逻辑。在用户登录并获取到用户信息后,调用 getAccessByRoles 方法来基于用户角色获取相应的权限菜单。

  2. 创建菜单 Store (menu.ts): 定义了一个名为 menu 的 Pinia store,用于管理权限菜单数据。getAccessByRoles 方法用于从后端获取特定角色的权限菜单,并将其转换为树形结构以便于前端展示。

  3. 定义获取角色权限接口 (roleAccess.ts): 在 API 文件中添加了 getRoleAccesses 方法,该方法负责向后端发送请求,根据提供的角色 ID 获取相应的权限菜单数据。

  4. 实现生成树形结构的工具方法 (generateTree.ts): 开发了 generateMenuTree 工具函数,用于将平面的权限数据转换为树形结构,方便在前端以菜单的形式展示。

通过这些步骤,项目能够基于用户的角色动态生成权限菜单,从而实现更灵活和安全的导航控制。这对于创建基于角色的权限管理系统至关重要,确保不同用户只能访问其有权限的功能和页面。

14.1 permission.ts #

src\permission.ts

import router from "@/router";
import { getToken } from "./utils/auth";
import { useUserStore } from "./stores/user";
+import { useMenuStore } from "./stores/menu";
const LOGIN_PATH = "/login";
const HOME_PATH = "/home";
const WHITE_LIST = [LOGIN_PATH];
router.beforeEach(async (to) => {
  const userStore = useUserStore();
+ const menuStore = useMenuStore();
  const token = getToken();
  if (token) {
    if (to.path === LOGIN_PATH) {
      return { path: HOME_PATH, replace: true };
    }
    const roles = userStore.state.roles && userStore.state.roles.length > 0;
    if (roles) {
      return true;
    }
    await userStore.getUserInfo();
+   await menuStore.getAccessByRoles(userStore.state.roles.map((item) => item.id));
    return router.push(to.path);
  }
  if (WHITE_LIST.includes(to.path)) {
    return true;
  }
  return {
    path: LOGIN_PATH,
    query: { redirect: to.path, ...to.query },
  };
});

src\stores\menu.ts

import { reactive } from "vue";
import { defineStore } from "pinia";
import { getRoleAccesses } from "@/api/roleAccess";
import { generateMenuTree } from "@/utils/generateTree";
export const useMenuStore = defineStore("menu", () => {
  const state = reactive({
    authMenus: [],
  });
  const getAccessByRoles = async (roles) => {
    const response = await getRoleAccesses(roles);
    const { access } = response.data;
    state.authMenus = generateMenuTree([...response.data.access]);
    console.log('authMenus',state.authMenus);
    return [...access];
  };
  return { state, getAccessByRoles };
});

14.3 roleAccess.ts #

src\api\roleAccess.ts

import request from "./request";
export const getRoleAccesses = (roles) => {
  return request.post("/role_access/role/access", {
    roles,
  });
};

14.4 generateTree.ts #

src\utils\generateTree.ts

const generateMenuTree = (list) => {
  const map = list.reduce((prev, current) => {
    const temp = { ...current };
    prev[current.id] = temp;
    return prev;
  }, {});
  const tree = [];
  list.forEach((item) => {
    const temp = map[item.id];
    const pid = temp.parent_id;
    if (pid && map[pid]) {
      const parent = map[pid];
      parent.children = parent.children || [];
      parent.children.push(temp);
    } else {
      tree.push(temp);
    }
  });
  return tree;
};
export { generateMenuTree };

15.渲染权限菜单 #

本节内容讲述了如何在 Vue 项目中渲染基于权限的菜单。这包括以下几个关键步骤:

  1. 引入 Element Plus 图标 (main.ts): 在主入口文件 main.ts 中,导入并注册了 Element Plus 的所有图标组件,以便在整个应用中使用这些图标。

  2. 更新布局组件 (layout/index.vue): 在布局组件中加入了 Sidebar 组件,用于展示侧边导航菜单。

  3. 创建侧边栏组件 (Sidebar/index.vue): 开发了 Sidebar 组件,使用 Element Plus 的 el-menu 组件来渲染垂直菜单。这个组件利用从菜单 store (useMenuStore) 获取的权限菜单数据 (authMenus) 来动态生成菜单项。

  4. 开发菜单项组件 (Sidebar/SidebarItem.vue): 创建了 SidebarItem 组件,用于渲染单个菜单项。这个组件可以处理没有子菜单的普通菜单项(使用 el-menu-item)和包含子菜单的菜单项(使用 el-sub-menu)。对于每个菜单项,根据菜单数据中的 icontitle 属性来显示图标和标题。

通过这些步骤,项目能够根据用户的权限动态渲染侧边栏菜单,实现了一个灵活且响应用户权限变化的导航系统。这种方式可以确保不同角色的用户只看到他们有权限访问的菜单项,提高了应用的安全性和用户体验。

15.1 main.ts #

src\main.ts

import { createApp } from 'vue'
import {createPinia} from "pinia";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
+import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import router from "./router/index"
import App from './App.vue'
import './permission';
const app = createApp(App)
+for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+    console.log(key);
+    app.component(key, component)
+}
app.use(router)
app.use(ElementPlus);
app.use(createPinia())
app.mount('#app')

15.2 layout\index.vue #

src\layout\index.vue

<template>
  <div class="app-wrapper">
+   <div class="sidebar-container"><sidebar/></div>
    <div class="main-container">
      <router-view></router-view>
    </div>
  </div>
</template>
+<script setup lang="ts">
+import Sidebar from "./Sidebar/index.vue"
+</script>
<style lang="scss" scoped>
.app-wrapper {
  display: flex;
  width: 100%;
  height: 100vh;
  .sidebar-container {
    min-width: 210px;
    background-color: orange;
  }
  .main-container {
    flex: 1;
    background: green;
  }
}
</style>

15.3 Sidebar\index.vue #

src\layout\Sidebar\index.vue

<template>
  <el-menu mode="vertical">
    <sidebar-item v-for="menu in menus" :key="menu.path" :item="menu" />
  </el-menu>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { useMenuStore } from "@/stores/menu";
import SidebarItem from "./SidebarItem.vue";
const menuStore = useMenuStore();
const menus = computed(() => menuStore.state.authMenus);
</script>

15.4 SidebarItem.vue #

src\layout\Sidebar\SidebarItem.vue

<template>
  <router-link
    v-if="!item.children || item.children.length === 0"
    :to="item.path"
  >
    <el-menu-item :index="item.path">
      <el-icon v-if="item.icon">
        <component :is="item.icon"></component>
      </el-icon>
      <template #title>
        <span>{{ item.title }}</span>
      </template>
    </el-menu-item>
  </router-link>
  <el-sub-menu v-else :index="item.path" teleported>
    <template #title>
      <el-icon v-if="item.icon">
        <component :is="item.icon"></component>
      </el-icon>
      <span>{{ item.title }}</span>
    </template>
    <sidebar-item
      v-for="child in item.children"
      :key="child.path"
      :is-nest="true"
      :item="child"
    >
    </sidebar-item>
  </el-sub-menu>
</template>
<script setup lang="ts">
import { RouterLink } from "vue-router";
defineProps({
  item: {
    type: Object,
    required: true,
  },
});
</script>

16.权限路由 #

本节内容讲解了如何在 Vue 项目中实现基于权限的动态路由配置。这个功能的实现包括以下几个关键步骤:

  1. 更新路由守卫 (permission.ts): 在全局前置路由守卫中增加了动态路由的生成逻辑。在用户登录并获取到用户信息及相应权限后,调用 generateRoutes 方法来生成用户权限对应的动态路由,并添加到 Vue Router 中。

  2. 定义静态和动态路由 (router/index.ts): 在路由配置中区分了静态路由和动态路由 (asyncRoutes)。动态路由是基于用户权限动态加载的路由,而静态路由则是所有用户都可以访问的路由。

  3. 创建权限 Store (permission.ts): 定义了一个名为 permission 的 Pinia store,用于管理权限相关的状态。generateRoutes 方法负责根据用户的权限筛选出可访问的动态路由。

  4. 实现路由生成逻辑 (permission.ts): 使用 generateRoutes 函数来递归地过滤和构建符合用户权限的路由列表。这个函数基于用户的菜单权限来决定哪些路由可以被添加到路由器中。

  5. 添加 404 页面 (404.vue): 创建了一个简单的 404 页面,用于处理未匹配到的路由。

  6. 创建示例页面 (Menu.vue, Role.vue, User.vue): 创建了一些示例页面,它们作为动态路由的组成部分,根据用户的角色权限进行展示。

通过这些步骤,项目能够根据用户的角色和权限动态生成路由,从而确保用户只能访问他们被授权的页面。这样的机制显著提高了应用的安全性和用户体验。

16.1 permission.ts #

src\permission.ts

import router from "@/router";
import { getToken } from "./utils/auth";
import { useUserStore } from "./stores/user";
+import { usePermissionStore } from "./stores/permission"
const LOGIN_PATH = "/login";
const HOME_PATH = "/home";
const WHITE_LIST = [LOGIN_PATH];
router.beforeEach(async (to) => {
  const userStore = useUserStore();
+ const permissionStore = usePermissionStore();
  const token = getToken();
  if (token) {
    if (to.path === LOGIN_PATH) {
      return { path: HOME_PATH, replace: true };
    }
    const roles = userStore.state.roles && userStore.state.roles.length > 0;
    if (roles) {
      return true;
    }
    await userStore.getUserInfo();
+   const accessRoutes = await permissionStore.generateRoutes()
+   accessRoutes.forEach(router.addRoute)
    return router.push(to.path);
  }
  if (WHITE_LIST.includes(to.path)) {
    return true;
  }
  return {
    path: LOGIN_PATH,
    query: { redirect: to.path, ...to.query },
  };
});

16.2 router\index.ts #

src\router\index.ts

import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import Layout from "@/layout/index.vue";
const routes: RouteRecordRaw[] = [
  {
    path: "/",
    redirect: "/home",
  },
  {
    path: "/login",
    name: "login",
    component: () => import("@/views/Login/index.vue"),
  },
+ {
+   path: "/:pathMatch(.*)*",
+   component: () => import("@/views/404.vue"),
+ }
];
+export const asyncRoutes: RouteRecordRaw[] = [
+  {
+    path: "/",
+    component: Layout,
+    children: [
+      {
+        path: "home",
+        name: "home",
+        component: () => import("@/views/Home/index.vue"),
+        meta: {
+          title: "home",
+          icon: "Aim",
+        },
+      },
+      {
+        path: "system",
+        name: "System",
+        meta: {
+          title: "System",
+          icon: "Setting",
+        },
+        children: [
+          {
+            path: "menu",
+            name: "menu",
+            component: () => import("@/views/System/Menu.vue"),
+            meta: {
+              title: "Menu",
+              icon: "Menu",
+            },
+          },
+          {
+            path: "role",
+            name: "role",
+            component: () => import("@/views/System/Role.vue"),
+            meta: {
+              title: "Role",
+              icon: "Key",
+            },
+          },
+          {
+            path: "user",
+            name: "user",
+            component: () => import("@/views/System/User.vue"),
+            meta: {
+              title: "User",
+              icon: "User",
+            },
+          },
+        ],
+      },
+    ],
+  },
+];
export default createRouter({
  history: createWebHistory(),
  routes,
});

16.3 permission.ts #

src\stores\permission.ts

import { reactive, computed } from "vue";
import { defineStore } from "pinia";
import path from "path-browserify";
import { useMenuStore } from "./menu";
import { useUserStore } from "./user";
import { asyncRoutes } from "@/router";
const generateRoutes = (menuPaths, routes, basePath = "/") => {
  const routerData = [];
  routes.forEach((route) => {
    const routePath = path.resolve(basePath, route.path);
    if (route.children) {
      route.children = generateRoutes(menuPaths, route.children, routePath);
    }
    if (
      menuPaths.includes(routePath) ||
      (route.children && route.children.length >= 1)
    ) {
      routerData.push(route);
    }
  });
  return routerData;
};
const filterAsyncRoutes = (menus, routes) => {
  const menuPaths = menus.map((menu) => menu.path);
  return generateRoutes(menuPaths, routes);
};
export const usePermissionStore = defineStore("permission", () => {
  const state = reactive({
    accessRoutes: [],
  });
  const menuStore = useMenuStore();
  const userStore = useUserStore();
  const generateRoutes = async () => {
    const roleIds = computed(() => {
      return userStore.state.roles.map((item) => item.id);
    });
    const menus = await menuStore.getAccessByRoles(roleIds.value);
    const accessedRoutes = filterAsyncRoutes(menus, asyncRoutes);
    return accessedRoutes;
  };
  return { generateRoutes, state };
});

16.4 404.vue #

src\views\404.vue

<template>404</template>

src\views\System\Menu.vue

<template>
    <div>Menu</div>
</template>

16.6 Role.vue #

src\views\System\Role.vue

<template>
    <div>Role</div>
</template>

16.7 User.vue #

src\views\System\User.vue

<template>
    <div>User</div>
</template>

参考 #

API接口 #

1. 访问权限(Access) #

2. 认证(Auth) #

3. 角色访问权限(Role Access) #

4. 用户(User) #