目录结构
├── views/main
│ ├── ├── analysis
│ ├── ├── ├── dashboard
│ ├── ├── ├── overview
│ ├── ├── product
│ ├── ├── ├── category
│ ├── ├── ├── goods
│ ├── ├── story
│ ├── ├── ├── chat
│ ├── ├── ├── list
│ ├── ├── system
│ ├── ├── ├── department
│ ├── ├── ├── menu
│ ├── ├── ├── role
│ ├── ├── ├── user
│ ├── ├── main.vue
————————————————
├──components
│ ├── ├──main-header
│ ├── ├── ├── c-cpns
│ ├── ├── ├──├── header-crumb.vue
│ ├── ├── ├──├── header-info.vue
│ ├── ├── ├── main-header.vue
│ ├── ├── main-menu
│ ├── ├── ├── main-menu.vue
MainMenu左侧导航组件
Menu 菜单
Menu 属性
- default-active:默认激活菜单的 index
- collapse:是否水平折叠收起菜单(仅在 mode 为 vertical 时可用)
SubMenu 属性:可以展开
- index:唯一标志
Menu-Item 属性
- index:唯一标志
// components/main-menu/main-menu.vue
<template>
<div class="main-menu">
<!-- 1.logo -->
<div class="logo">
<img class="img" src="@/assets/img/logo.svg" alt="" />
<h2 v-show="!isFold" class="title">弘源管理系统</h2>
</div>
<!-- 2.menu -->
<div class="menu">
<el-menu
:default-active="defaultActive"
:collapse="isFold"
text-color="#b7bdc3"
active-text-color="#fff"
background-color="#001529"
>
<!-- 遍历整个菜单 -->
<template v-for="item in userMenus" :key="item.id">
<el-sub-menu :index="item.id + ''">
<template #title>
<!-- 字符串: el-icon-monitor => 组件 component动态组件 -->
<el-icon>
<component :is="item.icon.split('-icon-')[1]" />
</el-icon>
<span>{{ item.name }}</span>
</template>
<template v-for="subitem in item.children" :key="subitem.id">
<el-menu-item
:index="subitem.id + ''"
@click="handleItemClick(subitem)"
>
{{ subitem.name }}
</el-menu-item>
</template>
</el-sub-menu>
</template>
</el-menu>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import useLoginStore from '@/store/login/login'
import { useRoute, useRouter } from 'vue-router'
import { mapPathToMenu } from '@/utils/map-menus'
// 0.定义props
defineProps({
isFold: {
type: Boolean,
default: false
}
})
// 1.获取动态的菜单
const loginStore = useLoginStore()
const userMenus = loginStore.userMenus
// 2.监听item的点击
const router = useRouter()
function handleItemClick(item: any) {
const url = item.url
router.push(url)
}
// 3.ElMenu的默认菜单:根据路径匹配菜单
const route = useRoute()
const defaultActive = computed(() => {
const pathMenu = mapPathToMenu(route.path, userMenus)
return pathMenu.id + ''
})
</script>
<style lang="less" scoped>
.main-menu {
height: 100%;
background-color: #001529;
}
.logo {
display: flex;
height: 28px;
padding: 12px 10px 8px 10px;
flex-direction: row;
justify-content: flex-start;
align-items: center;
overflow: hidden;
.img {
height: 100%;
margin: 0 10px;
}
.title {
font-size: 16px;
font-weight: 700;
color: white;
white-space: nowrap;
}
}
.el-menu {
border-right: none;
user-select: none;
}
.el-sub-menu {
.el-menu-item {
padding-left: 50px !important;
background-color: #0c2135;
}
.el-menu-item:hover {
color: #fff;
}
.el-menu-item.is-active {
background-color: #0a60bd;
}
}
</style>
MainHeader头部组件
<template>
<div class="main-header">
<div class="menu-icon" @click="handleMenuIconClick">
<el-icon size="28px">
<component :is="isFold ? 'Expand' : 'Fold'" />
</el-icon>
</div>
<div class="content">
<header-crumb />
<header-info />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import HeaderInfo from './c-cpns/header-info.vue'
import HeaderCrumb from './c-cpns/header-crumb.vue'
// 0.内部自定义事件
const emit = defineEmits(['foldChange'])
// 1.记录状态
const isFold = ref(false)
function handleMenuIconClick() {
// 1.内部改变状态
isFold.value = !isFold.value
// 2.将事件和状态传递给父组件
emit('foldChange', isFold.value)
}
</script>
<style lang="less" scoped>
.main-header {
display: flex;
align-items: center;
flex: 1;
height: 100%;
.menu-icon {
display: flex;
align-items: center;
cursor: pointer;
}
.content {
display: flex;
justify-content: space-between;
align-items: center;
flex: 1;
padding: 0 18px;
}
}
</style>
header-crumb面包屑组件
Breadcrumb 面包屑
Breadcrumb 属性
- separator-icon:图标分隔符的组件或组件名
- separator:分隔符/
Breadcrumb Item 属性
- to:路由跳转目标,同 vue-router 的 to属性
<template>
<div class="curmb">
<el-breadcrumb separator-icon="CaretRight">
<template v-for="item in breadcrumbs" :key="item.name">
<el-breadcrumb-item :to="item.path">
{{ item.name }}
</el-breadcrumb-item>
</template>
</el-breadcrumb>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import useLoginStore from '@/store/login/login'
import { mapPathToBreadcrumbs } from '@/utils/map-menus'
const route = useRoute()
const userMenus = useLoginStore().userMenus
const breadcrumbs = computed(() => {
return mapPathToBreadcrumbs(route.path, userMenus)
})
</script>
<style lang="less" scoped>
.curmb {
color: red;
}
</style>
header-info信息组件
1.Dropdown 下拉菜单
Dropdown 插槽
- dropdown:下拉列表,通常是 组件
Dropdown-Item 属性
- icon:自定义图标
- divided:是否显示分隔符
2.Avatar 头像
Avatar 属性
- size:大小
- src:源地址
<template>
<div class="header-info">
<!-- 1.操作小图标 -->
<div class="operation">
<span>
<el-icon><Message /></el-icon>
</span>
<span>
<span class="dot"></span>
<el-icon><ChatDotRound /></el-icon>
</span>
<span>
<el-icon><Search /></el-icon>
</span>
</div>
<!-- 2.个人信息 -->
<div class="info">
<el-dropdown>
<span class="user-info">
<el-avatar
:size="30"
src="https://upload.jianshu.io/users/upload_avatars/1102036/c3628b478f06.jpeg"
/>
<span class="name">{{ loginStore.userInfo.name }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleExitClick">
<el-icon><CircleClose /></el-icon>
<span>退出系统</span>
</el-dropdown-item>
<el-dropdown-item divided>
<el-icon><InfoFilled /></el-icon>
<span>个人信息</span>
</el-dropdown-item>
<el-dropdown-item>
<el-icon><Unlock /></el-icon>
<span>修改密码</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { LOGIN_TOKEN } from '@/global/constants'
import { localCache } from '@/utils/cache'
import useLoginstore from '@/store/login/login'
const router = useRouter()
function handleExitClick() {
localCache.removeCache(LOGIN_TOKEN)
router.push('/login')
}
const loginStore = useLoginstore()
</script>
<style lang="less" scoped>
.header-info {
display: flex;
align-items: center;
}
.operation {
display: inline-flex;
margin-right: 20px;
span {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 35px;
&:hover {
background: #f2f2f2;
}
i {
font-size: 20px;
}
.dot {
position: absolute;
top: 3px;
right: 3px;
z-index: 10;
width: 6px;
height: 6px;
background: red;
border-radius: 100%;
}
}
}
.info {
.user-info {
display: flex;
align-items: center;
cursor: pointer;
.name {
margin-left: 5px;
}
}
}
.info {
:global(.el-dropdown-menu__item) {
line-height: 36px !important;
padding: 6px 22px;
}
}
</style>
Main首页
<template>
<div class="main">
<el-container class="main-content">
<el-aside :width="isFold ? '60px' : '210px'">
<main-menu :is-fold="isFold" />
</el-aside>
<el-container>
<el-header height="50px">
<main-header @fold-change="handleFoldChange" />
</el-header>
<el-main>
<router-view></router-view>
</el-main>
</el-container>
</el-container>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import MainMenu from '@/components/main-menu/main-menu.vue'
import MainHeader from '@/components/main-header/main-header.vue'
// 处理main-header中折叠的变化
const isFold = ref(false)
function handleFoldChange(flag: boolean) {
isFold.value = flag
}
</script>
<style lang="less" scoped>
.main {
height: 100%;
}
.main-content {
height: 100%;
.el-aside {
overflow-x: hidden;
overflow-y: auto;
line-height: 200px;
text-align: left;
cursor: pointer;
background-color: #001529;
scrollbar-width: none; /* firefox */
-ms-overflow-style: none; /* IE 10+ */
transition: width 0.3s ease;
&::-webkit-scrollbar {
display: none;
}
}
.el-main {
background-color: #f0f2f5;
}
}
</style>
动态路由
动态路由:根据用户的权限信息,动态的添加路由,而不是一次性将所有的路由注册
两种方案:
方案一:基于角色动态路由管理
const roles={
“superadmin”:[所有路由],
“admin”;[一部分路由]
}
弊端:每增加一个角色,都要增加一个key/value
方案二:基于菜单的动态路由管理
菜单映射成路由对象
创建动态路由对象
目录结构
├── router/main
│ ├── ├── analysis
│ ├── ├── ├── dashboard
│ ├── ├── ├── overview
│ ├── ├── product
│ ├── ├── ├── category
│ ├── ├── ├── goods
│ ├── ├── story
│ ├── ├── ├── chat
│ ├── ├── ├── list
│ ├── ├── system
│ ├── ├── ├── department
│ ├── ├── ├── menu
│ ├── ├── ├── role
│ ├── ├── ├── user
│ ├── ├── index.ts
方法一:手动添加
// router/main/analysis/overview/overview.ts
export default {
path: '/main/analysis/overview',
component: () => import('@/views/main/analysis/overview/overview.vue')
}
方法二:自动化工具
router文件和views页面一一对应
npm install coderwhy -g
coderwhy --version//3.2.2
coderwhy add3page_setup overview -d src/views/main/analysis/overview
封装菜单路由映射工具
// utils/map-menus.ts
import type { RouteRecordRaw } from 'vue-router'
//动态获取路由对象
function loadLocalRoutes() {
// 1.动态获取所有的路由对象, 放到数组中
// * 路由对象都在独立的文件中
// * 从文件中将所有路由对象先读取数组中
const localRoutes: RouteRecordRaw[] = []
// 1.1.读取router/main所有的ts文件
const files: Record<string, any> = import.meta.glob(
'../router/main/**/*.ts',
{
eager: true
}
)
// 1.2.将加载的对象放到localRoutes
for (const key in files) {
const module = files[key]
localRoutes.push(module.default)
}
return localRoutes
}
//根据菜单映射正确的路由
export let firstMenu: any = null
export function mapMenusToRoutes(userMenus: any[]) {
// 1.加载本地路由
const localRoutes = loadLocalRoutes()
// 2.根据菜单去匹配正确的路由
const routes: RouteRecordRaw[] = []
for (const menu of userMenus) {
for (const submenu of menu.children) {
const route = localRoutes.find((item) => item.path === submenu.url)
if (route) {
// 1.给route的顶层菜单增加重定向功能(但是只需要添加一次即可)
if (!routes.find((item) => item.path === menu.url)) {
routes.push({ path: menu.url, redirect: route.path })
}
// 2.将二级菜单对应的路径
routes.push(route)
}
// 记录第一个被匹配到的菜单
if (!firstMenu && route) firstMenu = submenu
}
}
return routes
}
动态添加路由及刷新保持路由注册功能
// store/login/login.ts
import { defineStore } from 'pinia'
import {
accountLoginRequest,
getUserInfoById,
getUserMenusByRoleId
} from '@/service/login/login'
import type { IAccount } from '@/types'
import { localCache } from '@/utils/cache'//本地缓存工具
import { mapMenusToPermissions, mapMenusToRoutes } from '@/utils/map-menus'//菜单映射路由工具
import router from '@/router'
import { LOGIN_TOKEN } from '@/global/constants'
import useMainStore from '../main/main'
interface ILoginState {
token: string
userInfo: any
userMenus: any
permissions: string[]
}
const useLoginStore = defineStore('login', {
// 如何制定state的类型
state: (): ILoginState => ({
token: '',
userInfo: {},
userMenus: [],
permissions: []
}),
actions: {
async loginAccountAction(account: IAccount) {
// 1.账号登录, 获取token等信息
const loginResult = await accountLoginRequest(account)
const id = loginResult.data.id
this.token = loginResult.data.token
localCache.setCache(LOGIN_TOKEN, this.token)
// 2.获取登录用户的详细信息(role信息)
const userInfoResult = await getUserInfoById(id)
const userInfo = userInfoResult.data
this.userInfo = userInfo
// 3.根据角色请求用户的权限(菜单menus)
const userMenusResult = await getUserMenusByRoleId(this.userInfo.role.id)
const userMenus = userMenusResult.data
this.userMenus = userMenus
// 4.进行本地缓存
localCache.setCache('userInfo', userInfo)
localCache.setCache('userMenus', userMenus)
// 5.请求所有roles/departments数据
// 重要: 获取登录用户的所有按钮的权限
// 重要: 动态的添加路由
const routes = mapMenusToRoutes(userMenus)
routes.forEach((route) => router.addRoute('main', route))
// 5.页面跳转(main页面)
router.push('/main')
}
//刷新保持路由注册
loadLocalCacheAction() {
// 1.用户进行刷新默认加载数据
const token = localCache.getCache(LOGIN_TOKEN)
const userInfo = localCache.getCache('userInfo')
const userMenus = localCache.getCache('userMenus')
if (token && userInfo && userMenus) {
this.token = token
this.userInfo = userInfo
this.userMenus = userMenus
// 1..请求所有roles/departments数据
// 2.获取按钮的权限
// 3.动态添加路由
const routes = mapMenusToRoutes(userMenus)
routes.forEach((route) => router.addRoute('main', route))
}
}
}
})
export default useLoginStore
//main.ts
app.use(pina)
const loginStore=useLoginStore()
loginStore.loadLocalCacheAction()
app.use(router)
第一个页面匹配显示
// router/index.ts
router.beforeEach((to) => {
// 只有登录成功(token), 才能真正进入到main页面
const token = localCache.getCache(LOGIN_TOKEN)
if (to.path.startsWith('/main') && !token) {
return '/login'
}
// 如果是进入到main
if (to.path === '/main') {
return firstMenu?.url
}
})
根据path匹配menu
封装路径匹配菜单工具
影响main-menu组件中default-active的id值
// utils/map-menus.ts
/**
* 根据路径去匹配需要显示的菜单
* @param path 需要匹配的路径
* @param userMenus 所有的菜单
*/
export function mapPathToMenu(path: string, userMenus: any[]) {
for (const menu of userMenus) {
for (const submenu of menu.children) {
if (submenu.url === path) {
return submenu
}
}
}
}
面包屑获取父子路径
封装根据路径匹配面包屑工具
// utils/map-menus.ts
interface IBreadcrumbs {
name: string
path: string
}
export function mapPathToBreadcrumbs(path: string, userMenus: any[]) {
// 1.定义面包屑
const breadcrumbs: IBreadcrumbs[] = []
// 2.遍历获取面包屑层级
for (const menu of userMenus) {
for (const submenu of menu.children) {
if (submenu.url === path) {
// 1.顶层菜单
breadcrumbs.push({ name: menu.name, path: menu.url })
// 2.匹配菜单
breadcrumbs.push({ name: submenu.name, path: submenu.url })
}
}
}
return breadcrumbs
}