手把手教你实现一个vue3+ts+nodeJS后台管理系统(十九)
前言
上文用前端写的路由实现了侧边栏的展示,但是除了首页、登录页及错误页面(404页面等),其它页面在后台管理系统中应该都是要受到权限的控制的。所以前端我们只留一些静态的、不受权限控制的路由,其它由后端返回给我们来拼接。由于很多组件都可能要用到这些路由信息,所以这些后端返回的路由信息应该存储在vuex中。还有vuex主要是对全局的状态进行管理,我们所需要全局管理的数据有登录用户的用户信息、权限、按钮等。
设置前端静态路由
静态路由除了登录页、首页及错误页面外,还应该设置默认路由重定向至首页。除此之外此系统的默认跳转都是到达首页或者用户通过其它有效url跳转的页面,我们还应当在路由拦截时判断是否有token存在客户端中,有则直接跳转,无则跳转登录页并在query参数中带上未跳转的路由path。然后在登录页面中我们要获得query信息,然后跳转至重定向的页面。
不能直接将路由path为'/'的路由直接设为首页例如{path:'/',component:'xxx',name:'xxx'},要设置重定向至首页。原因主要为直接设component为首页跳转地址的url只会是'xxx:xx/xxxx/'只显示/会有问题。
router/index.ts
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router';
import Layout from '@/components/layout/index.vue';
//静态路由
export const constantRoutes: Array<RouteRecordRaw> = [
{
path: '/login',
component: () => import('@/views/login.vue'),
meta: { hidden: true }
},
{
path: '/',
component: Layout,
redirect: '/home',
children: [
{
path: '/home',
component: () => import('@/views/home/index.vue'),
name: 'Home',
meta: { title: '首页', icon: 'dashboard' }
}
]
},
{
path: '/404',
component: () => import('@/views/errorPage/404.vue'),
meta: { hidden: true }
},
{
path: '/401',
component: () => import('@/views/errorPage/401.vue'),
meta: { hidden: true }
}
];
const router = createRouter({
history: createWebHashHistory(),
routes: constantRoutes,
// 刷新时,滚动条位置还原
scrollBehavior: () => ({ left: 0, top: 0 })
});
// 重置路由
export function resetRouter() {
store.state.permission.routes.forEach((route: any) => {
const name = route.name;
if (name) {
router.hasRoute(name) && router.removeRoute(name);
}
});
}
export default router;
上面的resetRouter
方法中用到了vuex的permission模块会报错,接下来我们在vuex中将这些静态路由拼接上后端路由即为完整的路由。
vuex全局状态管理
首先是登录用户的信息,我们从后端得到已登录用户的信息后,存储下用户名、昵称、邮箱、用户角色、权限菜单、权限按钮等信息,然后在vuex
的mutations属性设置对应的set函数以便修改。同时,登录、退出登录的操作也应该放在vuex
的actions属性中以便进行全局状态的操作,登录主要是获取token等、退出登录清空所有用户信息。
由于用的是TypeScript
来编写vue
组件,所以需要遵循一些步骤提供正确的类型声明。
参考
vuex
官方文档TypeScript支持主要的做法就是1.提供类型化的key 2.定义自己的useStore组合式函数来检索类型化的store,这样每次vue组件导入store时就不用类型化的key
-
首先我们先编写各个store模块的类型。在types目录下创建store文件夹,创建user、permission模块的和整体store的类型文件。并在store目录也建立对应的模块文件夹及文件。如下图所示目录结构
然后编写各自store模块的类型
types/store/user.d.ts
/** * 用户类型声明 */ export interface userState { token: string | undefined; refreshToken: string | undefined; roles: Array<string>; menus: Array<MenuItem>; buttons: Array<string>; user_id: number; username: string; nickname: string; email: string; user_pic: string; }
types/store/permission.d.ts
/** * 权限类型声明 */ export interface permissionState { routes: RouteRecordRaw[]; addRoutes: RouteRecordRaw[]; } /** * 菜单meta类型 */ export interface meta { hidden?: boolean; title: string; icon?: string | null; }
types/store/root.d.ts
// 定义state类型,将所有模块的类型导入 export interface rootState { user: userState; permission: permissionState; }
-
编写user模块,主要是在mutations中设置各个state属性值的set函数,在actions中编写登录、获取用户信息和退出登录的异步函数,如下述代码 store/module/user.ts
import { Module } from 'vuex'; // 导入api import { userLogin } from '@/utils/API/user/user'; import { getUserInfo } from '@/utils/Api/user/personalCenter'; // 导入重置路由的函数 import { resetRouter } from '@/router'; // 导入操作token的方法 import { getToken, setToken, removeToken, setRefreshToken, removeRefreshToken, getRefreshToken } from '@/utils/auth'; import { ElMessage } from 'element-plus'; // 导入store类型 import { userState } from '@/types/store/user'; import { rootState } from '@/types/store/root'; export const user: Module<userState, rootState> = { // 开启命名空间 namespaced: true, state: (): userState => ({ token: getToken() || '', refreshToken: getRefreshToken() || '', roles: [], menus: [], buttons: [], user_id: 0, username: '', nickname: '', email: '', user_pic: '' }), // 同步操作函数 mutations: { SET_TOKEN: (state, token) => { state.token = token; }, SET_REFRESHTOKEN: (state, refreshToken) => { state.refreshToken = refreshToken; }, SET_ROLES: (state, roles) => { state.roles = roles; }, SET_MENUS: (state, menus) => { state.menus = menus; }, SET_BUTTONS: (state, buttons) => { state.buttons = buttons; }, SET_USERID: (state, user_id) => { state.user_id = user_id; }, SET_USERNAME: (state, username) => { state.username = username; }, SET_NICKNAME: (state, nickname) => { state.nickname = nickname; }, SET_EMAIL: (state, email) => { state.email = email; }, SET_USER_PIC: (state, user_pic) => { state.user_pic = user_pic; } }, // 异步操作函数 actions: { login({ commit }, data) { return new Promise((resolve, reject) => { userLogin(data) .then((res) => { commit('SET_TOKEN', res.data.token); commit('SET_REFRESHTOKEN', res.data.refreshToken); setToken(res.data.token); setRefreshToken(res.data.refreshToken); ElMessage.success('登录成功'); resolve(null); }) .catch((error) => { reject(error); }); }); }, // 获取用户信息 GetInfo({ commit, state }) { return new Promise((resolve, reject) => { getUserInfo() .then((res) => { const { data } = res; if (!data || data.length <= 0) { reject('验证失败,请重新登录'); } // 解构后端值 const { roles, user_id, name, nickname, email, avatar, menus, buttons } = data; // 角色数组必须有值 if (!roles || roles.length <= 0) { reject('此用户无分配角色或角色不可用,请重新登录'); } commit('SET_ROLES', roles); commit('SET_USERID', user_id); commit('SET_USERNAME', name); commit('SET_NICKNAME', nickname); commit('SET_EMAIL', email); commit( 'SET_USER_PIC', avatar ? 'http://127.0.0.1:3007/' + avatar : '/src/assets/avatar/default_avatar.jpg' ); commit('SET_MENUS', menus); commit('SET_BUTTONS', buttons); resolve(data); }) .catch((error) => { reject(error); }); }); }, // 退出登录 FedLogOut({ commit }) { return new Promise((resolve) => { commit('SET_TOKEN', ''); commit('SET_REFRESHTOKEN', ''); commit('SET_ROLES', []); commit('SET_MENUS', []); commit('SET_BUTTONS', []); commit('SET_USERID', -1); commit('SET_USERNAME', ''); commit('SET_NICKNAME', ''); commit('SET_EMAIL', ''); commit('SET_USER_PIC', ''); resetRouter(); removeToken(); removeRefreshToken(); resolve(null); }); }, // 重置token resetToken({ commit }) { return new Promise((resolve) => { commit('SET_TOKEN', ''); commit('SET_REFRESHTOKEN', ''); removeToken(); removeRefreshToken(); resolve(null); }); } } };
-
编写permission模块,主要是为了将后端提供的菜单转为前端的router结构并拼接静态路由。有一个重点就是vue2路由导入文件一般是通过require,但vite不支持require,所以官网中提供了一个从文件中导入模块的函数
import.meta.glob
,参考vite官方文档glob导入import { Module } from 'vuex'; import { constantRoutes } from '@/router'; import { RouteRecordRaw } from 'vue-router'; import Layout from '@/layout/index.vue'; import { meta, permissionState } from '@/types/store/permission'; import { rootState } from '@/types/store/root'; // 懒加载view文件夹下的vue文件 const modules = import.meta.glob('../../views/**/**.vue'); // 得到后端路由经转换后的路由结构 export function filterAsyncRoutes(routes: any) { const res: RouteRecordRaw[] = []; // 遍历得到的路由转换为前端router结构 routes.forEach((route: any) => { const component = route.component; const tmp: any = { path: route.path, // 通过import.meta.glob懒加载获取文件模块,注意文件结构!!! component: route.component === 'Layout' ? Layout : modules[/* @vite-ignore */ `../../views${component}.vue`], redirect: route.redirect || undefined, name: route.name, meta: {} as meta, children: route.children || undefined }; tmp.meta.title = route.title; tmp.meta.hidden = !!route.hidden; if (route.icon) { tmp.meta.icon = route.icon; } if (tmp.children && tmp.children.length) { tmp.children = filterAsyncRoutes(tmp.children); } res.push(tmp); }); return res; } export const permission: Module<permissionState, rootState> = { // 开启命名空间 namespaced: true, state: (): permissionState => ({ routes: [], addRoutes: [] }), mutations: { // 前端的静态路由拼接转换后的路由 SET_ROUTES: (state, routes) => { state.addRoutes = routes; state.routes = constantRoutes.concat(routes); } }, actions: { // 生成路由 generateRoutes({ commit }, menus) { return new Promise((resolve) => { const accessedRoutes = filterAsyncRoutes(menus); commit('SET_ROUTES', accessedRoutes); resolve(accessedRoutes); }); } } };
-
编写整体的store文件。要提供正确的类型声明,步骤如上说的是1.提供类型化的key 2.定义自己的useStore组合式函数来检索类型化的store,这样每次vue组件导入store时就不用类型化的key
import { createStore, Store, useStore as baseUseStore } from 'vuex'; // 定义类型化的key import { InjectionKey } from 'vue'; // 导入整体模块store的类型 import { rootState } from '@/types/store/root'; // 导入模块 import { user } from './module/user'; import { permission } from './module/permission'; // 提供 injection key export const key: InjectionKey<Store<rootState>> = Symbol(); export const store: Store<rootState> = createStore({ // 模块 modules: { user, permission } }); // 定义自己的useStore组合式函数 export function useStore() { return baseUseStore(key); }
vuex模块到这里就封装完毕了。
测试
通过引入store模块下主文件的useStore方法,就能够有正确的类型声明,且能够获得全局状态
路由拦截
在src目录下新建一个permission路由模块,主要是进行路由拦截。
步骤有
- 看vuex中是否存在token及角色,如果有的话判断是跳转到登录页还是其它页面。如果是登录页让用户跳转到首页,其它页面判断是否有此角色是否存在此路由的权限,无则跳到404页面,有则继续跳转。但是若不存在角色却有token的话就是还没获取用户信息,通过vuex用户模块的GetInfo的异步方法获取信息继续跳转即可。
- 没有token的话判断是否是白名单页面(无需token也可访问的页面),是可跳转,不是跳转登录页再重定向到要去的页面。
代码如下:
由于页面有各自的标题存在路由元信息meta中所以先封装了一个公共获取标题的方法
src/utils/get-title.ts
const title = 'Vue3后台管理系统';
export default function getPageTitle(pageTitle: string) {
if (pageTitle) {
return `${pageTitle} - ${title}`;
}
return `${title}`;
}
src/permission.ts
import router from '@/router';
import { ElMessage } from 'element-plus';
// 不是vue组件直接引入store即可,不用带key
import { store } from '@/store';
// 进度条组件
import NProgress from 'nprogress';
import getPageTitle from '@/utils/get-title';
import 'nprogress/nprogress.css';
NProgress.configure({ showSpinner: false }); // 进度环显示/隐藏
// 白名单路由
const whiteList = ['/login'];
router.beforeEach(async (to, from, next) => {
NProgress.start();
// 设置页面标题
document.title = getPageTitle(to.meta?.title);
// 判断是否有token
const hasToken = store.state.user.token;
if (hasToken) {
// console.log('有token');
// 登录成功,跳转到首页
if (to.path === '/login') {
next({ path: '/' });
NProgress.done();
} else {
const hasGetUserInfo = store.state.user.roles.length > 0;
if (hasGetUserInfo) {
if (to.matched.length === 0) {
// from.name ? next({ name: from.name as any }) : next('/404');
next('/404');
} else {
next();
}
} else {
try {
await store.dispatch('user/GetInfo');
const menus = store.state.user.menus;
const accessRoutes: any = await store.dispatch('permission/generateRoutes', menus);
accessRoutes.forEach((route: any) => {
router.addRoute(route);
});
next({ ...to, replace: true });
} catch (error) {
console.log(error);
// 移除 token 并跳转登录页
await store.dispatch('resetToken');
ElMessage.error((error as any) || '系统异常');
next(`/login?redirect=${to.path}`);
NProgress.done();
}
}
}
} else {
// 未登录可以访问白名单页面(登录页面)
if (whiteList.indexOf(to.path) !== -1) {
next();
} else {
next(`/login?redirect=${to.path}`);
NProgress.done();
}
}
});
router.afterEach(() => {
NProgress.done();
});
测试一下发现能正常跳到登录页(因为还没有写登录模块),是正常的
转载自:https://juejin.cn/post/7177301578720739385