Appearance
在实现登录之前,需要说明几点。首次登录系统,需要有一个 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-api2.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 启动后,页面效果如下:

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