前言
本文是在该系列的基础上,针对前端的修改。其中前端采用vue3框架,脚手架为vite,ui组件库为ElementPlus,路由为vue-router,状态管理库为Pinia。
路由嵌套
整合模块数据文件(路由、菜单)
整合模块数据为路由、菜单所用数据
/** /src/router/modules.ts */
import type { ModulesItemType, MenuItemType } from "./modules.type"
//从 ./modules/ 目录中导入多个模块 适用于vite
const modules = import.meta.glob('./modules/*.ts', { eager: true }) as { [propName: string]: { default: ModulesItemType } }
const parentPath = "/";//所有模块的父路径path
/** 首字母大写方法 */
function __FirstWordUpperCase(letters: string[]) {
return letters.reduce((s: string, c: string) => {
return s += c.charAt(0).toUpperCase() + c.slice(1)
}, '')
}
/**
去掉字母前缀,并首字母小写方法
letters:要处理的字母
ignoredPre:要去掉的前缀
*/
function __FirstWordLowerCase(letters: string, ignoredPre = "") {
if (ignoredPre) {
const regx = new RegExp("^" + ignoredPre, "gim");
letters = letters.replace(regx, "")
}
return letters.charAt(0).toLowerCase() + letters.slice(1);
}
/**
整合单个模块数据为路由文件所需格式
nameSpace:模块的命名空间
item:模块中的数据
routes:用于存储路由数据
*/
function __makeModulesRoutesData(nameSpace: string, item: ModulesItemType, routes: any[] = []) {
//将模块数据中以menu为前缀的key过滤掉
const routeKeys = Object.keys(item).filter((key: string) => /^[^menu]/.test(key));
//模块数据中有key为path,则处理为路由数据
if ('path' in item) {
const routePath = `${nameSpace}/${item.path}`;//路由path:【命名空间】+【当前path值】
const routeName = __FirstWordUpperCase([nameSpace, item.path || '']);//路由name:【命名空间】+【当前path值】 (驼峰式)
//将过滤的key的值整合:除了path值是以【命名空间】+【当前path值】,其他值不变
const routeItem = routeKeys.reduce((m: any, key: string) => {
if (key === 'path') m['path'] = routePath;
else m[key] = item[key as keyof ModulesItemType];
return m
}, {});
//将整合后的路由存储到routes变量中
routes.push({ ...routeItem, name: routeName });
}
//模块数据还有子数据 - menuChildren 则递归再次重复以上动作
if (item['menuChildren']) {
for (let i in item['menuChildren']) {
__makeModulesRoutesData(nameSpace, item['menuChildren'][i], routes)
}
}
return routes;
}
/**
整合各个模块数据为路由文件所需格式
routes:用于存储路由数据
*/
function getModulesRoutes(routes: any[] = []) {
for (let key in modules) {
//命名空间:以模块文件与 ./modules/ 目录的相对路径为命名空间
//比如 ./modules/first.ts 命名空间为first
const nameSpace = key.replace(/^\.\/modules\//, "").replace(/\.\w+$/, "");
//模块文件中的数据
const modulesItem = modules[key].default;
__makeModulesRoutesData(nameSpace, modulesItem, routes)
}
return routes;
}
/**
整合单个模块数据为菜单所需格式
nameSpace:模块的命名空间
item:模块中的数据
menu:用于存储菜单数据
index:每个菜单数据中的key,相当于id
*/
function __makeModulesMenusData(nameSpace: string, item: ModulesItemType, menu: any = {}, index = "") {
//将模块数据中以非menu为前缀的key过滤掉
const menuKeys = Object.keys(item).filter((key: string) => /^menu/.test(key));
//模块数据中有key为menuName,则处理为菜单数据
if ('menuName' in item) {
//将过滤的key的值整合:去掉menu前缀并首字母小写。将菜单数据存储到menu中
for (let key of menuKeys) {
const mkey = __FirstWordLowerCase(key, 'menu');
menu[mkey] = item[key as keyof ModulesItemType];
}
//处理key为path的数据:【所有模块的父路径path】+【命名空间】+【当前path值】
if ('path' in item) {
menu['path'] = `${parentPath}${nameSpace}/${item.path}`;
}
//设置菜单数据key
menu['key'] = index;
}
//模块数据还有子数据 - menuChildren 则递归再次重复以上动作
if (item['menuChildren']) {
menu['children'] = [];//创建子数据children
let cIndex = 1;//子数据索引:用作设置菜单key
for (let i in item['menuChildren']) {
if ('menuName' in item) {
const menuItem = {}
__makeModulesMenusData(nameSpace, item['menuChildren'][i], menuItem, `${index}-${cIndex++}`)
menu['children'].push(menuItem)
}
}
}
return menu;
}
/**
整合各个模块数据为菜单所需格式
*/
function getModulesMenus(): MenuItemType[] {
const menus: MenuItemType[] = [];//menu:用于存储菜单数据
const MAX_ORDER = 999999;//最大顺序,用于模块间的菜单排序
//将各个模块asc排序:以menuOrder设置数字顺序,没有该值(或设置为0)则按最大值处理
const modulesKeys = Object.keys(modules).sort((key1: string, key2: string) => (modules[key1].default.menuOrder || MAX_ORDER) - (modules[key2].default.menuOrder || MAX_ORDER));
//模块间菜单数据的处理
modulesKeys.forEach((key: string, index: number) => {
//命名空间:以模块文件与 ./modules/ 目录的相对路径为命名空间
//比如 ./modules/first/index.ts 命名空间为first/index
const nameSpace = key.replace(/^\.\/modules\//, "").replace(/\.\w+$/, "");
//模块文件中的数据
const modulesItem = modules[key].default;
if (modulesItem['menuName']) {
const modulesMenuItem = __makeModulesMenusData(nameSpace, modulesItem, {}, String(index))
menus.push(modulesMenuItem);
}
})
return menus;
}
export {
getModulesRoutes,
getModulesMenus,
parentPath
}
模块接口文件
/** /src/router/modules.type.ts */
import { DefineComponent } from "vue";
type ComponentType = DefineComponent<{}, {}, {}, import("vue").ComputedOptions, import("vue").MethodOptions, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").VNodeProps & import("vue").AllowedComponentProps & import("vue").ComponentCustomProps, Readonly<import("vue").ExtractPropTypes<{}>>, {}>
/**各模块单个数据接口:凡是不带menu前缀的都是vue-route用的 */
export interface ModulesItemType {
menuName?: string;//菜单名称,有值则显示
menuOrder?: number;//菜单顺序
menuIcon?: string | ComponentType;//菜单图标
menuChildren?: ModulesItemType[];//子菜单
path?: string;
component?: () => Promise<typeof import("*.vue")>;
meta?: { [propName: string]: any }
}
/**菜单接口 */
export interface MenuItemType {
name?: string;//菜单名称,有值则显示
order?: number;//菜单顺序
icon?: string | ComponentType;//菜单图标
children?: MenuItemType[];//子菜单
path?: string;
}
模块文件
模块文件在都必须在 /src/router/modules/ 目录下,并且每个模块必须有导出默认,export default
/** /src/router/modules/first.ts */
import { Location } from "@element-plus/icons-vue";
import { type ModulesItemType } from "../modules.type"
export default {
menuName: '菜单1',
menuOrder:1,
path: 'index',
menuIcon: Location,//ElementPlus icon图标
component: () => import("@/pages/first/index.vue"),
} as ModulesItemType
/** /src/router/modules/second.ts */
import { type ModulesItemType } from "../modules.type"
export default {
menuName: '菜单2',
menuOrder: 2,
path: 'index',
menuIcon: 'iconfont icon-ding',//iconfont图标
component: () => import("@/pages/second/index.vue"),
} as ModulesItemType
/** /src/router/modules/third.ts */
import { Location } from "@element-plus/icons-vue";
import { type ModulesItemType } from "../modules.type"
export default {
menuName: '菜单3',
menuOrder: 3,
menuIcon: Location,
menuChildren: [
{
menuName: '菜单3-1',
menuIcon: Location,
menuChildren: [
{ menuName: '菜单3-1-1', path: 'third_1_1', component: () => import("@/pages/third/third_1_1.vue"), menuIcon: Location },
{ menuName: '菜单3-1-2', path: 'third_1_2', component: () => import("@/pages/third/third_1_2.vue"), menuIcon: '' }
]
},
{ menuName: '菜单3-2', path: 'third_2', component: () => import("@/pages/third/third_2.vue"), menuIcon: '' },
{ menuName: '菜单3-3', path: 'third_3', component: () => import("@/pages/third/third_3.vue"), menuIcon: '' }
]
} as ModulesItemType
/** /src/router/modules/four.ts */
import { type ModulesItemType } from "../modules.type"
export default {
menuName: '菜单4',
menuOrder: 4,
path: 'index',
menuIcon: '',
component: () => import("@/pages/four/index.vue"),
} as ModulesItemType
路由文件
/** /src/router/index.ts */
import {
createRouter,
createWebHistory,
RouteRecordRaw,
createWebHashHistory,
} from "vue-router";
import { getModulesRoutes, parentPath } from "./modules"//整合路由的文件
const routes: Array<RouteRecordRaw> = [
{
path: "/login",
name: "Login",
component: () => import("@/pages/login/login.vue"),
},
{
path: "/register",
name: "Register",
component: () => import("@/pages/register/register.vue"),
meta: {}
},
{
path: parentPath,//各模块的父路径
component: () => import("@/pages/index.vue"),
children: [
...getModulesRoutes(),//获取各模块的路由
{
path: "",
redirect: "first/index"
},
// 以上都未匹配,跳转404页面
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import("@/pages/not-found.vue"),
},
]
},
]
const router = createRouter({
history: createWebHashHistory(),
routes,
});
export default router;
getModulesRoutes()
最终生成的数据
[
{
"path": "first/index",
"name": "FirstIndex",
"component": () => import("@/pages/first/index.vue")
},
{
"path": "four/index",
"name": "FourIndex",
"component": () => import("@/pages/four/index.vue")
},
{
"path": "second/index",
"name": "SecondIndex",
"component": () => import("@/pages/second/index.vue")
},
{
"path": "third/third_1_1",
"name": "ThirdThird_1_1",
"component": () => import("@/pages/third/third_1_1.vue")
},
{
"path": "third/third_1_2",
"name": "ThirdThird_1_2",
"component": () => import("@/pages/third/third_1_2.vue")
},
{
"path": "third/third_2",
"name": "ThirdThird_2",
"component": () => import("@/pages/third/third_2.vue")
},
{
"path": "third/third_3",
"name": "ThirdThird_3",
"component": () => import("@/pages/third/third_3.vue")
}
]
嵌套路由index页面
<!-- /src/pages/index.vue -->
<script setup lang="ts">
import Menu from "./menu/menu.vue";//左侧菜单栏组件
import TopHeader from "./top-header/top-header.vue";//顶部组件
</script>
<template>
<div class="index">
<!--顶部显示区域-->
<header><TopHeader /></header>
<section>
<!--左侧菜单显示区域-->
<nav><Menu /></nav>
<!--中间内容显示区域-->
<main>
<!--嵌套路由(嵌套在App.vue中的路由下的路由)-->
<router-view v-slot="{ Component, route }">
<transition name="fade">
<component :is="Component" :key="route.path" />
</transition>
</router-view>
</main>
</section>
</div>
</template>
<style lang="less" scoped>
.index {
@headerH: 60px;
& > header {
height: @headerH;
box-sizing: border-box;
line-height: @headerH;
background: #000;
}
& > section {
height: calc(100vh - @headerH);
display: flex;
& > nav {
height: 100%;
}
& > main {
box-sizing: border-box;
flex-grow: 1;
height: 100%;
overflow: auto;
padding: 15px 15px 10px;
}
}
}
</style>
顶部TopHeader页面
<!-- /src/pages/top-header/top-header.vue -->
<template>
<div class="top-header">我是顶部区域</div>
</template>
菜单页面
左侧菜单Menu页面,由于菜单数据是一种树形结构,所以该页面采用vue的渲染函数(h函数)写法
<!-- /src/pages/menu/menu.vue -->
<script lang="ts">
import { h, watch, getCurrentInstance } from "vue";
import { RouteLocationNormalizedLoaded, useRoute } from "vue-router";
import { ElMenu, ElSubMenu, ElMenuItem, ElIcon } from "element-plus";
import { getModulesMenus } from "@/router/modules";
import { MenuItemType } from "@/router/modules.types";
export default {
setup() {
const internalInstance = getCurrentInstance();
const route = useRoute();
const menuList = getModulesMenus();
//ElMenu组件属性
const elMenuAttrs = {
class: "menu-component",
"active-text-color": "#ffd04b",
"background-color": "#545c64",
"default-active": route.path,
"text-color": "#fff",
router: true,
};
//生成ElMenuItem组件下面内容或ElSubMenu组件的title slot
const getMenuTitleVNode = (item: MenuItemType) => {
const v = [];
if (item.icon) {//如果数据中有icon属性有值
if (typeof item.icon === "string") {//icon属性值是string类型(适用于iconfont)
/** 相当于<el-icon :class="item.icon"></el-icon> */
v.push(h(ElIcon, { class: item.icon }));
} else {//icon属性值是导入的组件(适用于elementPlus图标组件)
/** 相当于
<el-icon :class="item.icon">
<component :is="item.icon"></component
</el-icon>
*/
v.push(h(ElIcon, null, { default: () => [h(item.icon)] }));
}
}
/**相当于<span>{{item.name}}</span>*/
v.push(h("span", item.name));
return v;
};
//生成ElMenu组件下面的ElMenuItem或 ElSubMenu组件
const getMenuVNode = (m: MenuItemType[]) => {
return m.map((item: MenuItemType) => {
let node: any = null;
if (item.children?.length) {//有下级菜单
/** 相当于
<el-sub-menu :index="item.key" :data-tree="item.key">
<template #title>....</template>
<template #default>...递归该函数</template>
</el-sub-menu>
*/
node = h(
ElSubMenu,
{
index: item.key,
"data-tree": item.key,
},
{
title: () => [...getMenuTitleVNode(item)],
default: () => [...getMenuVNode(item.children)],
}
);
} else {//没有下级菜单
/** 相当于
<el-menu-item :index="item.path" :route="item.path" :data-tree="item.key" :data-route="item.path">
<template #default>...</template>
</el-menu-item>
*/
node = h(
ElMenuItem,
{
index: item.path,
route: item.path,
"data-tree": item.key,
"data-route": item.path,
},
{
default: () => [...getMenuTitleVNode(item)],
}
);
}
return node;
});
};
/**监听路有变化:菜单重新渲染,自动高亮路由所在的菜单*/
watch(
route,
(route: RouteLocationNormalizedLoaded) => {
elMenuAttrs["default-active"] = route.path;
//强制刷新组件视图
internalInstance.proxy.$forceUpdate();
},
{ deep: true }
);
/**
<el-menu class="menu-componet" active-text-color="#ffd04b" background-color="#545c64" :default-active="route.path" text-color"="#fff" router>...</el-menu>
*/
return () => h(ElMenu, elMenuAttrs, { default: () => [...getMenuVNode(menuList)] });
},
};
</script>
<style lang="less" scoped>
.menu-component {
height: 100%;
width: 220px;
overflow: auto;
* {
user-select: none;
}
}
</style>
getModulesMenus()
最终生成的数据
/** Location 是引入的ElIcon */
[
{ "name": "菜单1", "order": 1, "icon": Location, "path": "/first/index", "key": "1" },
{ "name": "菜单2", "order": 2, "icon": "iconfont icon-ding", "path": "/second/index", "key": "2" },
{
"name": "菜单3",
"order": 3,
"icon": Location,
"key": "3",
"children": [
{
"name": "菜单3-1",
"icon": Location,
"key": "3-1",
"children": [
{ "name": "菜单3-1-1", "icon":Location, "path": "/third/third_1_1", "key": "3-1-1" },
{ "name": "菜单3-1-2", "icon": "", "path": "/third/third_1_2", "key": "3-1-2" }
]
},
{ "name": "菜单3-2", "icon": "", "path": "/third/third_2", "key": "3-2" },
{ "name": "菜单3-3", "icon": "", "path": "/third/third_3", "key": "3-3" }
]
},
{ "name": "菜单4", "order": 4, "icon": "", "path": "/four/index", "key": "4" }
]
最终生成的模板为
<el-menu class="menu-componet" active-text-color="#ffd04b" background-color="#545c64" :default-active="route.path" text-color"="#fff" router>
<el-menu-item index="/first/index" route="/first/index" data-tree="1" data-route="/first/index">
<el-icon><Location /></el-icon>
<span>菜单1</span>
</el-menu-item>
<el-menu-item index="/second/index" route="/second/index" data-tree="2" data-route="/second/index">
<el-icon class="iconfont icon-ding"></el-icon>
<span>菜单2</span>
</el-menu-item>
<el-sub-menu index="3" data-tree="3">
<template #title>
<el-icon><Location /></el-icon>
<span>菜单3</span>
</template>
<el-sub-menu index="3-1" data-tree="3-1">
<template #title>
<el-icon><Location /></el-icon>
<span>菜单3-1</span>
</template>
<el-menu-item index="/third/third_1_1" route="/third/third_1_1" data-tree="3-1-1" data-route="/third/third_1_1">
<el-icon><Location /></el-icon>
<span>菜单3-1-1</span>
</el-menu-item>
<el-menu-item index="/third/third_1_2" route="/third/third_1_2" data-tree="3-1-2" data-route="/third/third_1_2">
<span>菜单3-1-2</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item index="/third/third_2" route="/third/third_2" data-tree="3-2" data-route="/third/third_2">
<span>菜单3-2</span>
</el-menu-item>
<el-menu-item index="/third/third_3" route="/third/third_3" data-tree="3-3" data-route="/third/third_3">
<span>菜单3-3</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item index="/four/index" route="/four/index" data-tree="4" data-route="/four/index">
<span>菜单4</span>
</el-menu-item>
</el-menu>
路由显示的页面
即模块文件中component
字段import
的页面
<!-- /src/pages/first/index.vue -->
<script setup lang="ts">
import { ref } from "vue";
import http from "@/http";
import { ElMessage } from "element-plus";
import { useRouter } from "vue-router";
const isload = ref(false);
const list = ref([]);
const router = useRouter();
const logout = async () => {
window.localStorage.removeItem("secret_key");
window.localStorage.removeItem("token");
window.location.href = `/#/login`;
};
const lookUser = async () => {
const params = {};
isload.value = true;
await http
.post("users/look", params)
.then((data: any) => (list.value = data.list))
.catch((err: any) => {
ElMessage({
message: err.message,
type: "error",
});
});
isload.value = false;
};
const goToMenu2 = ()=>{
router.push("/second/index")
}
lookUser();
</script>
<template>
<div class="user-index">
<el-table :data="list" style="width: 100%" v-loading="isload">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="password" label="密码" />
<el-table-column prop="create_time" label="创建时间" />
</el-table>
<el-button class="refresh-btn" @click="lookUser">刷新列表</el-button>
<el-button class="refresh-btn" @click="logout">注销</el-button>
<el-button class="refresh-btn" @click="goToMenu2">跳转菜单2</el-button>
</div>
</template>
<style lang="less" scoped>
.user-index {
width: 100%;
.refresh-btn {
margin-top: 20px;
}
}
</style>
<!-- /src/pages/second/second.vue -->
<template>
<div class="user-second">user-second</div>
</template>
<!-- /src/pages/third/third_1_1.vue -->
<template>
<div class="user-third_1_1">third_1_1</div>
</template>
<!-- /src/pages/third/third_1_2.vue -->
<template>
<div class="user-third_1_2">third_1_2</div>
</template>
<!-- /src/pages/third/third_2.vue -->
<template>
<div class="user-third_2">third_2</div>
</template>
<!-- /src/pages/third/third_3.vue -->
<template>
<div class="user-third_3">third_3</div>
</template>
<!-- /src/pages/four/index.vue -->
<template>
<div class="user-third_3">user-four</div>
</template>
NotFound页面
<!-- /src/pages/not-found.vue -->
<script setup lang="ts">
import { useRouter } from "vue-router";
const router = useRouter();
const goBack = () => {
router.back();
};
</script>
<template>
<div class="user-not-found">
<h2>404</h2>
<div>未找到页面</div>
<el-button @click="goBack">返回</el-button>
</div>
</template>
<style lang="less" scoped>
.user-not-found {
text-align: center;
}
</style>
最后修改登录页 /src/pages/login/login.vue 登录成功后的路由跳转:将router.push("/index");
改为router.push("/");
最终页面效果:
总结一下:modules.ts文件是枢纽,用于分发路由和菜单。getModulesRoutes()
分发路由,getModulesMenus()
分发菜单。modules/目录是数据源,其下的模块文件是modules.ts各方法获取数据的资源库。若添加菜单menuName
+ [path
+ component
],若仅添加路由path
+ component
状态管理
Vue状态管理可以看成是用于设置或获取Vue下的全局变量,可以供整个Vue项目使用。以前Vue使用VUEX来管理状态,Vue3推荐Pinia。不管哪个,他们都是Vue项目状态的仓储。
安装Pinia
npm install pinia
VUE中应用Pinia
/** /src/main.ts */
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router/index'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import VueCookies from 'vue-cookies'
import { createPinia } from "pinia"
import "./assets/iconfont/iconfont.css" //iconfont
const pinia = createPinia();
createApp(App).use(router).use(ElementPlus).use(VueCookies).use(pinia).mount('#app')
创建Pinia仓储
/** /src/store/index.ts */
import { defineStore } from "pinia"
export const useUserStore = defineStore('user', {
state: () => ({
userId: 0,//存储登录用户ID
}),
actions: {
//设置登录用户ID方法
setUserId(id: number) {
this.userId = id;
}
}
})
使用仓储创建和获取信息
在登录页用户点击登录,成功后将后端返回的用户ID存储到Pinia仓储中
import { useUserStore } from "@/store/index";
//获取仓储
const userStore = useUserStore();
const submit = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
await formEl.validate((valid, fields) => {
if (valid) {
http
.post("login/loginIn", {
...formData,
password: md5(formData.password),
})
.then(async (data: any) => {
if (data.code == 0) {
//设置用户ID到Pinia仓储
userStore.setUserId(data.id);
/** 其他代码省略 */
} else {
ElMessage({
message: data.message,
type: "error",
});
}
})
.catch((err: any) => {
ElMessage({
message: err.message,
type: "error",
});
});
} else {
ElMessage({
message: "请按提示登录",
type: "error",
});
}
});
};
在需要的页面获取仓储中存储的信息
import { useUserStore } from "@/store/index";
//获取仓储
const userStore = useUserStore();
//获取仓储中的UserId
const userId = userStore.userId;
最后,本篇文章所构建的目录结构
参考资料
CSDN:在vue3中使用 forceUpdate()
简书:(十四)Vue3.x核心之getCurrentInstance
Vite Glob 导入
Pinia 中文文档