6、解析路由表,获取结构化数据
# 6、解析路由表,获取结构化数据
获取到之前明确的结构化数据:
[
{
"title": "个人中心",
"path": ""
},
{
"title": "用户",
"children": [
{
"title": "员工管理",
"path": ""
},
{
"title": "角色列表",
"path": ""
},
{
"title": "权限列表",
"path": ""
}
]
},
{
"title": "文章",
"children": [
{
"title": "文章排名",
"path": ""
},
{
"title": "创建文章",
"path": ""
}
]
}
]
获取路由表数据,那么有两种方式: router.options.routes:初始路由列表(新增的路由 无法获取到) router.getRoutes():获取所有 路由记录 的完整列表
所以,我们此时使用 router.getRoutes()
在 layout/components/Sidebar/SidebarMenu 下写入以下代码:
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
console.log(router.getRoutes())
</script>
得到返回的数据:
[
{
"path":"/user/info/:id",
"name":"userInfo",
"meta":{
"title":"userInfo"
},
"children":[
]
},
{
"path":"/article/editor/:id",
"meta":{
"title":"articleEditor"
},
"children":[
]
},
{
"path":"/user/manage",
"meta":{
"title":"userManage",
"icon":"personnel-manage"
},
"children":[
]
},
{
"path":"/user/role",
"meta":{
"title":"roleList",
"icon":"role"
},
"children":[
]
},
{
"path":"/user/permission",
"meta":{
"title":"permissionList",
"icon":"permission"
},
"children":[
]
},
{
"path":"/user/import",
"name":"import",
"meta":{
"title":"excelImport"
},
"children":[
]
},
{
"path":"/article/ranking",
"meta":{
"title":"articleRanking",
"icon":"article-ranking"
},
"children":[
]
},
{
"path":"/article/create",
"meta":{
"title":"articleCreate",
"icon":"article-create"
},
"children":[
]
},
{
"path":"/article/:id",
"meta":{
"title":"articleDetail"
},
"children":[
]
},
{
"path":"/login",
"meta":{
},
"children":[
]
},
{
"path":"/profile",
"name":"profile",
"meta":{
"title":"profile",
"icon":"el-icon-user"
},
"children":[
]
},
{
"path":"/404",
"name":"404",
"meta":{
},
"children":[
]
},
{
"path":"/401",
"name":"401",
"meta":{
},
"children":[
]
},
{
"path":"/",
"redirect":"/profile",
"meta":{
},
"children":[
{
"path":"/profile",
"name":"profile",
"meta":{
"title":"profile",
"icon":"el-icon-user"
}
},
{
"path":"/404",
"name":"404"
},
{
"path":"/401",
"name":"401"
}
]
},
{
"path":"/user",
"redirect":"/user/manage",
"meta":{
"title":"user",
"icon":"personnel"
},
"children":[
{
"path":"/user/manage",
"meta":{
"title":"userManage",
"icon":"personnel-manage"
}
},
{
"path":"/user/role",
"meta":{
"title":"roleList",
"icon":"role"
}
},
{
"path":"/user/permission",
"meta":{
"title":"permissionList",
"icon":"permission"
}
},
{
"path":"/user/info/:id",
"name":"userInfo",
"meta":{
"title":"userInfo"
}
},
{
"path":"/user/import",
"name":"import",
"meta":{
"title":"excelImport"
}
}
]
},
{
"path":"/article",
"redirect":"/article/ranking",
"meta":{
"title":"article",
"icon":"article"
},
"children":[
{
"path":"/article/ranking",
"meta":{
"title":"articleRanking",
"icon":"article-ranking"
}
},
{
"path":"/article/:id",
"meta":{
"title":"articleDetail"
}
},
{
"path":"/article/create",
"meta":{
"title":"articleCreate",
"icon":"article-create"
}
},
{
"path":"/article/editor/:id",
"meta":{
"title":"articleEditor"
}
}
]
}
]
这个路由表距离我们想要的存在两个问题: 存在重复的路由数据 不满足该条件 meta && meta.title && meta.icon 的数据不应该存在
创建 utils/route 文件,创建两个方法分别处理对应的两个问题: filterRouters generateMenus
import path from 'path'
/**
* 返回所有子路由
*/
const getChildrenRoutes = routes => {
const result = []
routes.forEach(route => {
if (route.children && route.children.length > 0) {
result.push(...route.children)
}
})
return result
}
/**
* 处理脱离层级的路由:某个一级路由为其他子路由,则剔除该一级路由,保留路由层级
* @param {*} routes router.getRoutes()
*/
export const filterRouters = routes => {
const childrenRoutes = getChildrenRoutes(routes)
return routes.filter(route => {
return !childrenRoutes.find(childrenRoute => {
return childrenRoute.path === route.path
})
})
}
/**
* 判断数据是否为空值
*/
function isNull(data) {
if (!data) return true
if (JSON.stringify(data) === '{}') return true
if (JSON.stringify(data) === '[]') return true
return false
}
/**
* 根据 routes 数据,返回对应 menu 规则数组
*/
export function generateMenus(routes, basePath = '') {
const result = []
// 遍历路由表
routes.forEach(item => {
// 不存在 children && 不存在 meta 直接 return
if (isNull(item.meta) && isNull(item.children)) return
// 存在 children 不存在 meta,进入迭代
if (isNull(item.meta) && !isNull(item.children)) {
result.push(...generateMenus(item.children))
return
}
// 合并 path 作为跳转路径
const routePath = path.resolve(basePath, item.path)
// 路由分离之后,存在同名父路由的情况,需要单独处理
let route = result.find(item => item.path === routePath)
if (!route) {
route = {
...item,
path: routePath,
children: []
}
// icon 与 title 必须全部存在
if (route.meta.icon && route.meta.title) {
// meta 存在生成 route 对象,放入 arr
result.push(route)
}
}
// 存在 children 进入迭代到children
if (item.children) {
route.children.push(...generateMenus(item.children, route.path))
}
})
return result
}
在 SidebarMenu 中调用该方法
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { filterRouters, generateMenus } from '@/utils/route'
const router = useRouter()
const routes = computed(() => {
const filterRoutes = filterRouters(router.getRoutes())
return generateMenus(filterRoutes)
})
console.log(JSON.stringify(routes.value))
</script>
得到该数据结构
[
{
"path":"/profile",
"name":"profile",
"meta":{
"title":"profile",
"icon":"el-icon-user"
},
},
{
"path":"/user",
"redirect":"/user/manage",
"meta":{
"title":"user",
"icon":"personnel"
},
"props":{
"default":false
},
"children":[
{
"path":"/user/manage",
"name":"userManage",
"meta":{
"title":"userManage",
"icon":"personnel-manage"
},
"children":[
]
},
{
"path":"/user/role",
"name":"userRole",
"meta":{
"title":"roleList",
"icon":"role"
},
"children":[
]
},
{
"path":"/user/permission",
"name":"userPermission",
"meta":{
"title":"permissionList",
"icon":"permission"
},
"children":[
]
}
],
},
{
"path":"/article",
"redirect":"/article/ranking",
"meta":{
"title":"article",
"icon":"article"
},
"props":{
"default":false
},
"children":[
{
"path":"/article/ranking",
"name":"articleRanking",
"meta":{
"title":"articleRanking",
"icon":"article-ranking"
},
"children":[
]
},
{
"path":"/article/create",
"name":"articleCreate",
"meta":{
"title":"articleCreate",
"icon":"article-create"
},
"children":[
]
}
],
}
]
# 7、业务落地:生成动态 menu 菜单
整个 menu 菜单,我们将分成三个组件来进行处理: 1、SidebarMenu:处理数据,作为最顶层 menu 载体
<template>
<!-- 一级 menu 菜单 -->
<el-menu
...
>
<sidebar-item
v-for="item in routes"
:key="item.path"
:route="item"
></sidebar-item>
</el-menu>
</template>
SidebarItem:根据数据处理 当前项为 el-submenu || el-menu-item
<template>
<!-- 支持渲染多级 menu 菜单 -->
<el-submenu v-if="route.children.length > 0" :index="route.path">
<template #title>
<menu-item :title="route.meta.title" :icon="route.meta.icon"></menu-item>
</template>
<!-- 循环渲染 -->
<sidebar-item
v-for="item in route.children"
:key="item.path"
:route="item"
></sidebar-item>
</el-submenu>
<!-- 渲染 item 项 -->
<el-menu-item v-else :index="route.path">
<menu-item :title="route.meta.title" :icon="route.meta.icon"></menu-item>
</el-menu-item>
</template>
<script setup>
import MenuItem from './MenuItem'
import { defineProps } from 'vue'
// 定义 props
defineProps({
route: {
type: Object,
required: true
}
})
</script>
MenuItem:处理 el-menu-item 样式
<template>
<i v-if="icon.includes('el-icon')" class="sub-el-icon" :class="icon"></i>
<svg-icon v-else :icon="icon"></svg-icon>
<span>{{ title }}</span>
</template>
<script setup>
import { defineProps } from 'vue'
defineProps({
title: {
type: String,
required: true
},
icon: {
type: String,
required: true
}
})
</script>
<style lang="scss" scoped>
</style>
# 8、业务落地:修复最后残余问题
# 8.1 样式问题
我们需要处理 主题替换 ,所以此处我们不能把样式写死,在 store/getters 中创建一个新的 快捷访问
import variables from '@/styles/variables.scss'
const getters = {
...
cssVar: state => variables
}
export default getters
在 SidebarMenu 中写入如下样式:
<el-menu
:background-color="$store.getters.cssVar.menuBg"
:text-color="$store.getters.cssVar.menuText"
:active-text-color="$store.getters.cssVar.menuActiveText"
:unique-opened="true"
>
# 8.2 路由跳转问题
为 el-menu 指定 router
<el-menu
...
router
>
# 8.3 默认激活项
默认激活项:根据当前 url 进行判断即可
<el-menu
:default-active="activeMenu"
...
>
<script setup>
...
// 计算高亮 menu 的方法
const route = useRoute()
const activeMenu = computed(() => {
const { path } = route
return path
})
</script>
# 9、动画逻辑
左侧菜单伸缩 ,对于这个功能核心的点在于动画处理,样式的改变总是由数据进行驱动,所以首先我们去创建对应的数据
创建 store/app 模块,写入如下代码
export default {
namespaced: true,
state: () => ({
sidebarOpened: true
}),
mutations: {
triggerSidebarOpened(state) {
state.sidebarOpened = !state.sidebarOpened
}
},
actions: {}
}
在 store/index 中进行导入
...
import app from './modules/app'
export default createStore({
getters,
modules: {
...
app
}
})
在 store/getters 中创建快捷访问
sidebarOpened: state => state.app.sidebarOpened
创建 components/hamburger 组件,用来控制数据
<template>
<div class="hamburger-container" @click="toggleClick">
<svg-icon class="hamburger" :icon="icon"></svg-icon>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'
const store = useStore()
const toggleClick = () => {
store.commit('app/triggerSidebarOpened')
}
const icon = computed(() =>
store.getters.sidebarOpened ? 'hamburger-opened' : 'hamburger-closed'
)
</script>
<style lang="scss" scoped>
.hamburger-container {
padding: 0 16px;
.hamburger {
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
}
}
</style>
在 navbar 中使用该组件
<template>
<div class="navbar">
<hamburger class="hamburger-container" />
...
</div>
</template>
<script setup>
import Hamburger from '@/components/Hamburger'
...
</script>
<style lang="scss" scoped>
.navbar {
...
.hamburger-container {
line-height: 46px;
height: 100%;
float: left;
cursor: pointer;
// hover 动画
transition: background 0.5s;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
}
...
}
</style>
在 SidebarMenu 中,控制 el-menu 的 collapse 属性
<el-menu
:collapse="!$store.getters.sidebarOpened"
...
在 layout/index 中指定 整个侧边栏的宽度和缩放动画
<div
class="app-wrapper"
:class="[$store.getters.sidebarOpened ? 'openSidebar' : 'hideSidebar']"
>
...
在 layout/index 中 处理 navbar 的宽度
<style lang="scss" scoped>
...
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$sideBarWidth});
transition: width 0.28s;
}
.hideSidebar .fixed-header {
width: calc(100% - #{$hideSideBarWidth});
}
</style>
在 styles/variables.scss 中指定 hideSideBarWidth
$hideSideBarWidth: 54px;
# 10 SidebarHeader 处理
在 sidebar/index 中写入如下代码
<template>
<div class="">
<div class="logo-container">
<el-avatar
size="44"
shape="square"
src="https://m.imooc.com/static/wap/static/common/img/logo-small@2x.png"
/>
<h1 class="logo-title" v-if="$store.getters.sidebarOpened">
imooc-admin
</h1>
</div>
...
</div>
</template>
<style lang="scss" scoped>
.logo-container {
height: 44px;
padding: 10px 0 22px 0;
display: flex;
align-items: center;
justify-content: center;
.logo-title {
margin-left: 10px;
color: #fff;
font-weight: 600;
line-height: 50px;
font-size: 16px;
white-space: nowrap;
}
}
</style>
创建 styles/element.scss 文件,统一处理 el-avatar 的背景问题
.el-avatar {
--el-avatar-background-color: none;
}
在 styles/index.scss 中导入
...
@import './element.scss';
统一处理下动画时长的问题,在 styles/variables.scss 中,加入以下变量
$sideBarDuration: 0.28s;
为 styles/sidebar.scss 修改时长
.main-container {
transition: margin-left #{$sideBarDuration};
...
}
.sidebar-container {
transition: width #{$sideBarDuration};
...
}
为 layout/index 修改样式
.fixed-header {
...
transition: width #{$sideBarDuration};
}
# 11、动态面包屑方案分析
11.1 创建、渲染基本的面包屑组件: 创建 components/Breadcrumb/index,并写入如下代码:
<template>
<el-breadcrumb class="breadcrumb" separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item><a href="/">活动管理</a></el-breadcrumb-item>
<el-breadcrumb-item>活动列表</el-breadcrumb-item>
<!-- 面包屑的最后一项 -->
<el-breadcrumb-item>
<span class="no-redirect">活动详情</span>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<script setup>
import {} from 'vue'
</script>
<style lang="scss" scoped>
.breadcrumb {
display: inline-block;
font-size: 14px;
line-height: 50px;
margin-left: 8px;
::v-deep .no-redirect {
color: #97a8be;
cursor: text;
}
}
</style>
在 layout/components/Navbar 组件下导入
<template>
<div class="navbar">
<hamburger class="hamburger-container" />
<breadcrumb class="breadcrumb-container" />
...
</div>
</template>
...
<style lang="scss" scoped>
.navbar {
...
.breadcrumb-container {
float: left;
}
...
}
</style>
11.2 计算面包屑结构数据 el-breadcrumb:包裹性质的容器 el-breadcrumb-item:每个单独项 依据动态数据,渲染 el-breadcrumb-item
动态数据如何制作:制作出一个 数组,数组中每个 item 都表示一个 路由信息:创建一个方法,用来生成数组数据, 在这里我们要使用到 route.match 属性来:获取与给定路由地址匹配的标准化的路由记录数组
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 生成数组数据
const breadcrumbData = ref([])
const getBreadcrumbData = () => {
breadcrumbData.value = route.matched.filter(
item => item.meta && item.meta.title
)
console.log(breadcrumbData.value)
}
// 监听路由变化时触发
watch(
route,
() => {
getBreadcrumbData()
},
{
immediate: true
}
)
</script>
11.3 根据数据渲染动态面包屑内容
<template>
<el-breadcrumb class="breadcrumb" separator="/">
<el-breadcrumb-item
v-for="(item, index) in breadcrumbData"
:key="item.path"
>
<!-- 不可点击项 -->
<span v-if="index === breadcrumbData.length - 1" class="no-redirect">{{
item.meta.title
}}</span>
<!-- 可点击项 -->
<a v-else class="redirect" @click.prevent="onLinkClick(item)">{{
item.meta.title
}}</a>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<script setup>
...
// 处理点击事件
const router = useRouter()
const onLinkClick = item => {
console.log(item)
router.push(item.path)
}
// 将来需要进行主题替换,所以这里获取下动态样式
const store = useStore()
// eslint-disable-next-line
const linkHoverColor = ref(store.getters.cssVar.menuBg)
</script>
<style lang="scss" scoped>
.breadcrumb {
...
.redirect {
color: #666;
font-weight: 600;
}
.redirect:hover {
// 将来需要进行主题替换,所以这里不去写死样式
color: v-bind(linkHoverColor);
}
}
</style>
# 12、vue3动画
为面包屑增加一些动画样式: 在 Breadcrumb/index 中增加 transition-group
<template>
<el-breadcrumb class="breadcrumb" separator="/">
<transition-group name="breadcrumb">
...
</transition-group>
</el-breadcrumb>
</template>
新建 styles/transition 样式文件
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.5s;
}
.breadcrumb-enter-from,
.breadcrumb-leave-active {
opacity: 0;
transform: translateX(20px);
}
.breadcrumb-leave-active {
position: absolute;
}
在 styles/index 中导入
@import './transition.scss';