vue3后台管理系统
后面可参考下:vue系列(三)——手把手教你搭建一个vue3管理后台基础模板
以下代码项目gitee地址
文章目录
- 1. 初始化前端项目
- 初始化项目
- 添加加载效果
- 配置 vite.config.js
- 2. 使用路由
- 安装路由
- 配置路由
- 配置@别名和跳转
- 安装path
- vite.config.js
- jsconfig.json
- main.js中使用路由
- 3. 使用elment-plus
- 安装elment-plus
- main.js中使用elment-plus
- 4. 使用pinia
- 安装pinia
- 配置pinia
- 创建store/index.js
- 创建store/counter.js
- main.js中引入
- 组件中使用
- 5. 使用axios
- 安装axios
- 编写request.js
- 编写api请求接口
- 组件中使用axios
- 6. 使用nprogress
- 安装nprogress
- 封装nprogress.js
- 路由中使用nprogress
- 7. 引入iconfont
- 下载iconfont
- main.js中引入
- 8. 封装ELMessage
- 9. 登录功能
- 配置登录的路由
- login.vue
- store/user.js
- api/loginApi.js
- 10.后台页面布局
- 配置登录成功后的路由
- 拆分组件
- 创建layout/index.vue
- 创建store/layout.js
- 创建layout/components/Sider.vue
- 创建layout/Main.vue
- 创建layout/Breadcrumb.vue
- 创建layout/TagsView组件
- 创建layout/components/Demo.vue
- 11. 菜单
- 搭建静态菜单路由
- 配置主页/用户/角色/菜单路由
- 使用el-menu创建侧边栏菜单
- 创建views/Home.vue
- 创建views/404/NotFound.vue
- 实现动态路由菜单
- 调整路由和菜单
- 调整路由
- 调整菜单
- 后台菜单和路由数据返回示例
- menu.json
- router.json
- 修改loginApi.js
- 修改request.js
- 修改router/index.js
- 修改user.js
- 创建store/menu.js
- 修改菜单栏组件Sider.vue
- 创建TreeMenu.vue递归组件
- 解决地址栏刷新问题
- 修改router/index.js
- 修改menu.js
- 12.全屏功能
- 安装screenfull
- 使用screenfull
- 13. 面包屑
- 数据
- 修改menus.js
- 修改Breadcrumb.vue
- 14. tagsView
- TagsView.vue
- TagsView.js
- 15. vue指令控制权限按钮显示
- 后台返回权限数据
- 创建指令文件perms.js
- main.js中注册该指令
- loginApi.js中添加接口
- 修改store/menu.js
- User.vue中使用
- 16.添加过渡效果
- 面包屑过渡效果
- 路由过渡效果
1. 初始化前端项目
初始化项目
可参考:vite官网 https://vitejs.cn/guide/#scaffolding-your-first-vite-project
npm init vite@latest mushan-vue3-admin
npm install
npm run dev
添加加载效果
在index.html中的id为app中,写入
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
<style>
body {
padding: 0px;
margin: 0px;
}
.loading {
display: flex;
height: 100vh;
width: 100vw;
background: #92b1d7;
justify-content: center;
align-items: center;
}
.loading .content {
position: relative;
display: flex;
justify-content: space-around;
align-items: center;
margin: 15px;
border-radius: 4px;
padding: 10px;
}
.circle-3 {
width: 60px;
height: 60px;
border-radius: 50%;
display: inline-block;
position: relative;
border: 3px solid;
border-color: #fff #fff transparent transparent;
animation: rotation 1s linear infinite;
}
.circle-3::after,
.circle-3::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
border-radius: 50%;
border: 3px solid;
animation: rotation-back 0.5s linear infinite;
}
.circle-3::after {
border-color: transparent #f6b352 #f6b352 transparent;
width: 52px;
height: 52px;
}
.circle-3::before {
border-color: transparent transparent #fff #fff;
width: 44px;
height: 44px;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes rotation-back {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-360deg);
}
}
</style>
</head>
<body>
<div id="app">
<div class="loading">
<div class="content">
<div class="circle-3"></div>
</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
配置 vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
hmr: true,
port: 5174,
},
resolve: {
alias: {
'@':path.resolve(__dirname,'./src')
}
}
})
2. 使用路由
安装路由
npm i vue-router@4 -S
配置路由
import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";
// 路由信息
const routes = [
{
path: '/',
name: 'home',
component: ()=>import('@/views/index.vue')
},
{
path: "/login",
name: "login",
component: () => import('@/views/login/index.vue'),
}
]
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to,from,next)=>{
nprogress.start()
next()
})
router.afterEach((to,from,next)=>{
nprogress.done()
})
// 导出路由
export default router;
配置@别名和跳转
安装path
npm i path
vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@':path.resolve(__dirname,'./src')
}
}
})
jsconfig.json
与vite.config.js在同一级目录下
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
],
}
},
"exclude": [
"node_modules",
"dist"
],
"include": [
"src/**/*"
]
}
main.js中使用路由
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from '@/router'
const app = createApp(App)
app.mount('#app')
app.use(router)
3. 使用elment-plus
安装elment-plus
npm install element-plus --save
main.js中使用elment-plus
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import router from '@/router'
const app = createApp(App)
app.mount('#app')
app.use(router)
app.use(ElementPlus)
4. 使用pinia
可参考:Vue3中的pinia使用(收藏版)
安装pinia
npm install pinia --save
配置pinia
创建store/index.js
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
创建store/counter.js
import { defineStore } from 'pinia'
export const useCounter = defineStore('counter',{
state: () => ({
count:99
}),
getters: {
},
actions: {
}
})
main.js中引入
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import router from '@/router'
import pinia from '@/store'
const app = createApp(App)
app.use(router)
app.use(pinia)
app.use(ElementPlus)
app.mount('#app')
组件中使用
<template>
{{ counterStore.count }}
<el-button @click="visitStore">你好</el-button>
</template>
<script setup>
import {useCounter} from '@/store/counter'
const counterStore = useCounter()
function visitStore() {
console.log(counterStore.count);
}
</script>
<style lang="scss">
</style>
5. 使用axios
可参考:Vue3使用axios的配置教程
安装axios
npm install axios --save
编写request.js
import axios from 'axios'
import Messager from './messager'; // 在下面封装了
const instance = axios.create({
baseURL: 'http://127.0.0.1:8080/api',
timeout: 10000
})
instance.interceptors.request.use((config)=>{
return config;
})
instance.interceptors.response.use(response=>{
if(response.data.errno == 0) {
return Promise.resolve(response.data.data)
} else {
if(response.data.errno == 501) {
Messager.error('请重新登录')
window.location.href = '/login'
} else {
Messager.error(response.data.errmsg)
return Promise.reject(new Error(response.data.errmsg))
}
}
})
export default instance
编写api请求接口
import request from '@/utils/request'
export function getCaptchaImage() {
return request({
url: 'captchaImage',
})
}
export function login(data) {
return request({
method:'post',
url: 'user/login',
data
})
}
组件中使用axios
<template>
<el-button @click="refreshCaptchaImage">验证码</el-button>
</template>
<script setup>
import {getCaptchaImage} from '@/api/loginApi'
async function refreshCaptchaImage() {
let result = await getCaptchaImage()
console.log(result);
}
</script>
<style lang="scss">
</style>
6. 使用nprogress
安装nprogress
npm i nprogress -S
封装nprogress.js
import Nprogress from 'nprogress'
import 'nprogress/nprogress.css'
const nprogress = Nprogress.configure({
easing: 'ease', // 动画方式
speed: 1000, // 递增进度条的速度
showSpinner: false, // 是否显示加载ico
trickleSpeed: 200, // 自动递增间隔
minimum: 0.3, // 更改启动时使用的最小百分比
parent: 'body', //指定进度条的父容器
})
export default nprogress
路由中使用nprogress
import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";
// 路由信息
const routes = [
{
path: '/',
name: 'home',
component: ()=>import('@/views/index.vue')
},
{
path: "/login",
name: "login",
component: () => import('@/views/login/index.vue'),
}
]
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to,from,next)=>{
nprogress.start()
next()
})
router.afterEach((to,from,next)=>{
nprogress.done()
})
// 导出路由
export default router;
7. 引入iconfont
下载iconfont
下载iconfont相关资源到本地,添加到assets/iconfont目录下
main.js中引入
import { createApp } from 'vue'
import './style.css'
import '@/assets/iconfont/iconfont.css' // 引入iconfont的css文件
import App from './App.vue'
8. 封装ELMessage
import { ElMessage } from "element-plus";
const Messager = {
ok(msg){
ElMessage.success(msg)
},
error(msg) {
ElMessage.error(msg)
}
}
export default Messager
9. 登录功能
配置登录的路由
import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";
// 路由信息
const routes = [
{
path: '/',
name: 'home',
component: ()=>import('@/views/index.vue')
},
{
path: "/login",
name: "login",
component: () => import('@/views/login/index.vue'),
}
]
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to,from,next)=>{
nprogress.start()
next()
})
router.afterEach((to,from,next)=>{
nprogress.done()
})
// 导出路由
export default router;
login.vue
<template>
<div class="login-page">
<div class="login-container">
<h1 class="login-title">登录</h1>
<el-form ref="loginFormRef" :model="loginFormData" :rules="loginFormRules" class="login-form">
<el-form-item prop="username">
<el-input v-model="loginFormData.username" prop="username">
<template #prefix>
<i class="iconfont icon-yonghu"></i>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginFormData.password">
<template #prefix>
<i class="iconfont icon-mima"></i>
</template>
</el-input>
</el-form-item>
<el-form-item prop="code">
<div class="login-code">
<el-input v-model="loginFormData.code" prop="password">
<template #prefix>
<i class="iconfont icon-yanzhengma"></i>
</template>
</el-input>
<div class="code-img">
<img :src="codeImg" @click="getCodeImg">
</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" style="width:100%;" @click="submitLoginForm">登录</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import {getCaptchaImage} from '@/api/loginApi'
import useUser from '@/store/user'
import { ref, reactive,getCurrentInstance, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const { proxy } = getCurrentInstance()
const userStore = useUser()
const router = useRouter()
const codeImg = ref('')
const loginFormData = reactive({
username: 'admin',
password: '123456',
uuid: '',
code: ''
})
const loginFormRules = {
username: [
{required:true,message: '用户名不能为空',trigger: 'blur'}
],
password: [
{required:true,message: '密码不能为空',trigger: 'blur'}
],
code: [
{required:true,message: '验证码不能为空',trigger: 'blur'}
],
}
const loginFormRef = ref(null)
function submitLoginForm() {
loginFormRef.value.validate(async(valid,fields)=>{
if(!valid) {
proxy.Messager.error('请填写完整')
return
}
console.log(userStore);
let result = await userStore.doLogin(loginFormData)
router.replace('/')
})
}
function getCodeImg() {
getCaptchaImage().then(res=>{
codeImg.value = "data:image/gif;base64," + res.img
loginFormData.uuid = res.uuid
})
}
onMounted(()=>{
getCodeImg()
})
</script>
<style lang="scss" scoped>
.iconfont {
font-size: 16px;
}
.login-page {
height: 100vh;
background-image: url(@/assets/bg.jpg);
background-position: center;
background-size: cover;
display: flex;
justify-content: center;
align-items: center;
.login-container {
width: 350px;
padding: 20px;
background: rgba(255, 255, 255, 1);
border-radius: 5px;
.login-title {
font-size: 26px;
text-align: center;
margin-bottom: 15px;
}
.login-code {
display: flex;
.code-img {
height: 34px;
width: 180px;
margin-left: 10px;
border-radius: 5px;
cursor: pointer;
background-color: pink;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.2);
}
}
}
}
}
</style>
store/user.js
将登录获取的token存入localStorage
import { defineStore } from 'pinia'
import { login } from '@/api/loginApi'
function retrieveLocalToken() {
return localStorage.getItem('token') || ''
}
export default defineStore('user',{
state: () => {
return {
token: retrieveLocalToken() // 当刷新页面时, 这个会加载一次
}
},
getters: {
},
actions: {
doLogin(data) {
return new Promise((resolve, reject) => {
login(data).then(res=>{
this.token = res // 同样先存入到pinia中
localStorage.setItem('token', res)
console.log('login',res);
resolve(data)
}).catch(err=>{
reject(err)
})
})
}
}
})
api/loginApi.js
import request from '@/utils/request'
export function getCaptchaImage() {
return request({
url: 'captchaImage',
})
}
export function login(data) {
return request({
method:'post',
url: 'user/login',
data
})
}
10.后台页面布局
登录成功之后,会跳到主页,主页大概如下布局,可以先参考vue3 + elment-plus实现后台布局的静态页面布局,然后把它划分成不同的组件,不同组件的数据共享通过pinia这个store来管理。
配置登录成功后的路由
import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";
// 路由信息
const routes = [
{
path: "/login",
name: "login",
component: () => import('@/views/login/index.vue'),
},
{
path: '/',
name: 'home',
component: ()=>import('@/layout/index.vue')
},
]
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to,from,next)=>{
nprogress.start()
next()
})
router.afterEach((to,from,next)=>{
nprogress.done()
})
// 导出路由
export default router;
拆分组件
创建layout/index.vue
Layout组件引入Sider和Main组件
<template>
<div class="layout">
<Sider/>
<Main></Main>
</div>
</template>
<script setup>
import Sider from './components/Sider.vue';
import Main from './components/Main.vue';
import { ref, reactive } from 'vue'
</script>
<style lang="scss" scoped>
.layout {
display: flex;
}
</style>
创建store/layout.js
将组件的共享数据存入pinia
import { defineStore } from 'pinia'
export default defineStore('layout', {
state: ()=> {
return {
isExpand: true, // 侧边栏是否展开
}
},
getters: {
},
actions: {
// 切换侧边栏
toggleSider() {
console.log('切换侧边栏', this.isExpand);
this.isExpand = !this.isExpand
}
}
})
创建layout/components/Sider.vue
isExpand是存放在pinia中的数据
<template>
<div class="sider" :style="{ 'width': isExpand ? '220px' : '80px', transition: 'all 0.28s' }">
<div class="sider-top">
<h3 v-if="isExpand" class="site-title">PSCOOL管理系统</h3>
<i v-else class="iconfont icon-graphcool site-icon"></i>
</div>
<div class="sider-body">
<el-scrollbar>
<ul>
<li class="li-item">1</li>
<li class="li-item">2</li>
<li class="li-item">3</li>
<li class="li-item">4</li>
<li class="li-item">5</li>
<li class="li-item">6</li>
<li class="li-item">7</li>
<li class="li-item">8</li>
<li class="li-item">9</li>
<li class="li-item">9</li>
<li class="li-item">9</li>
<li class="li-item">9</li>
<li class="li-item">9</li>
</ul>
</el-scrollbar>
</div>
</div>
</template>
<script setup>
import useLayout from '@/store/layout'
import { storeToRefs } from 'pinia'
const layoutStore = useLayout()
const { isExpand } = storeToRefs(layoutStore) // 需要使用storeToRefs, 才能保持响应式
</script>
<style lang="scss">
.sider {
width: 220px;
height: 100vh;
background-color: #294256;
position: relative;
box-shadow: 2px 0 8px 0 rgba(0, 0, 0, 0.3);
flex-shrink: 0;
.sider-top {
height: 50px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.site-title {
white-space: nowrap;
font-weight: bold;
color: #fff;
}
.site-icon {
font-size: 20px;
color: #27ae60;
}
}
.sider-body {
position: absolute;
top: 50px;
left: 0;
right: 0;
bottom: 0;
background-color: #294256;
.li-item {
height: 50px;
margin: 10px;
background-color: #294256;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
}
}
</style>
创建layout/Main.vue
<template>
<div class="main">
<div class="main-header">
<div class="main-header-top">
<div class="main-header-top-left">
<div class="hamburger" @click="layoutStore.toggleSider">
<i :class="['iconfont', { 'icon-shousuocaidan': isExpand }, { 'icon-shousuocaidan-copy': !isExpand }]"></i>
</div>
<Breadcrumb />
</div>
<div class="main-header-top-right">
<div class="gitee mlr8 pointer">
<i class="iconfont icon-gitee"></i>
</div>
<div class="fullscreen mlr8">
<i :class="['iconfont', 'pointer', { 'icon-quanping_o': !isFullScreen, 'icon-quxiaoquanping_o': isFullScreen }]"></i>
</div>
<div class="theme-mode mlr8">
<el-switch inline-prompt :active-icon="Check" :inactive-icon="Close" />
</div>
<div class="avatar-box mlr8 pointer">
<el-dropdown>
<span class="el-dropdown-link">
<img class="avatar" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" alt="">
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>个人中心</el-dropdown-item>
<el-dropdown-item divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<TagsView/>
</div>
<div class="main-body">
<Demo/>
<!-- <router-view></router-view> -->
</div>
</div>
</template>
<script setup>
import Breadcrumb from './Breadcrumb.vue'
import useLayout from '@/store/layout'
import Demo from './Demo.vue'
import { storeToRefs } from 'pinia'
import { ref, reactive } from 'vue'
import TagsView from './TagsView.vue'
const isFullScreen = ref(false)
const layoutStore = useLayout()
const { isExpand } = storeToRefs(layoutStore)
</script>
<style lang="scss">
.main {
flex: 1;
overflow: hidden;
position: relative;
.main-header {
border-bottom: 1px solid #ccc;
box-shadow: 0 3px 5px 0 rgb(0 0 0 / 10%);
.main-header-top {
height: 50px;
box-shadow: 0 3px 10px 0 rgb(0 0 0 / 6%);
background: #fff;
border-bottom: 1px solid rgba(0, 0, 0, .1);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
.main-header-top-left {
display: flex;
align-items: center;
.hamburger {
cursor: pointer;
padding: 8px;
margin: 5px;
i {
font-size: 1.2em;
}
}
}
.main-header-top-right {
display: flex;
align-items: center;
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.gitee {
color: #c71d23;
}
}
}
}
.main-body {
position: absolute;
top: 83px;
left: 0;
right: 0;
bottom: 0;
}
}
i.iconfont {
font-size: 1.6em;
}
.mlr8 {
margin-left: 8px;
margin-right: 8px;
}
</style>
创建layout/Breadcrumb.vue
<template>
<el-breadcrumb separator="/" stsyle="color: #303133;">
<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>
</template>
<script setup>
</script>
<style lang="scss">
</style>
创建layout/TagsView组件
<template>
<div class="main-header-tags-wrapper">
<el-scrollbar>
<div class="main-header-tags">
<div class="tag-item">1</div>
<div class="tag-item">2</div>
<div class="tag-item">3</div>
<div class="tag-item">4</div>
<div class="tag-item">5</div>
<div class="tag-item">6</div>
<div class="tag-item">7</div>
<div class="tag-item">8</div>
<div class="tag-item">9</div>
</div>
</el-scrollbar>
</div>
</template>
<script setup>
</script>
<style lang="scss">
.main-header-tags-wrapper {
padding: 0 10px;
.main-header-tags {
height: 32px;
display: flex;
align-items: center;
.tag-item {
width: 160px;
height: 26px;
margin-right: 10px;
border: 1px solid #ccc;
background-color: #fff;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
}
}
</style>
创建layout/components/Demo.vue
<template>
<div class="main-content-wrapper">
<div class="content">
<el-scrollbar>
<el-timeline>
<el-timeline-item v-for="(activity, index) in activities" :key="index" :icon="activity.icon"
:type="activity.type" :color="activity.color" :size="activity.size" :hollow="activity.hollow"
:timestamp="activity.timestamp">
{{ activity.content }}
</el-timeline-item>
</el-timeline>
</el-scrollbar>
</div>
</div>
</template>
<script setup>
import { Expand, Fold, MoreFilled } from '@element-plus/icons-vue'
const activities = [
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
}, {
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
]
</script>
<style lang="scss"></style>
11. 菜单
搭建静态菜单路由
这一步,我们将获得如下的效果
配置主页/用户/角色/菜单路由
import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";
// 路由信息
const routes = [
{
path: "/login",
name: "login",
component: () => import('@/views/login/index.vue'),
},
{
path: '/',
name: 'layout',
redirect:'/home',
component: ()=>import('@/layout/index.vue'),
children: [
{
path: 'home',
name: 'home',
component: ()=>import('@/views/Home.vue'),
},
{
path: 'user',
name: 'user',
component: ()=>import('@/views/sys/user.vue'),
},
{
path: 'role',
name: 'role',
component: ()=>import('@/views/sys/role.vue'),
},
{
path: 'menu',
name: 'menu',
component: ()=>import('@/views/sys/menu.vue'),
}
]
},
// 匹配404页面
{
path:'/:pathMatch(.*)*',
name: 'notFound',
component: ()=>import('@/views/404/NotFound.vue')
},
]
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to,from,next)=>{
nprogress.start()
next()
})
router.afterEach((to,from,next)=>{
nprogress.done()
})
// 导出路由
export default router;
使用el-menu创建侧边栏菜单
- el-menu就是一个ul,而el-subm-menu和el-menu-item都是一个li,其中el-sub-menu这个li中里面会嵌套一个div和一个ul>li,里面的这个div(使用title插槽)会显示出来作为菜单,里面的ul>li会作为收缩菜单
- 当收缩的时候,会给el-menu生成的ul(也就是最外面的ul)加上一个el-menu–collapse的类名,它会把菜单中span的文字给隐藏掉,这样就只会显示图标了
<template>
<div class="sider" :style="{ 'width': isExpand ? '220px' : '80px', transition: 'all 0.28s' }">
<div class="sider-top">
<h3 v-if="isExpand" class="site-title">PSCOOL管理系统</h3>
<i v-else class="iconfont icon-graphcool site-icon"></i>
</div>
<div class="sider-body">
<el-scrollbar>
<el-menu
:collapse="isCollapse"
router
collapse-transition text-color="#eee"
:default-openeds="['/sys']"
default-active="/home"
background-color="#294256" class="menu-bar">
<el-menu-item index="/home">
<i class="iconfont icon-home-line"></i>
<span>主页</span>
</el-menu-item>
<el-sub-menu index="/sys">
<template #title>
<i class="iconfont icon-shezhi"></i>
<span>系统管理</span>
</template>
<el-menu-item index="/user">
<i class="iconfont icon-yonghuguanli"></i>
<span>用户管理</span>
</el-menu-item>
<el-menu-item index="/role">
<i class="iconfont icon-jiaoseguanli"></i>
<span>角色管理</span>
</el-menu-item>
<el-menu-item index="/menu">
<i class="iconfont icon-icon_caidanguanli"></i>
<span>菜单管理</span>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/test">
<template #title>
<i class="iconfont icon-graphcool"></i>
<span>多级菜单</span>
</template>
<el-menu-item index="/test-1">
<i class="iconfont icon-graphcool"></i>
<span>test-1</span>
</el-menu-item>
<el-sub-menu index="test-2" class="nested-sub-menu">
<template #title>
<i class="iconfont icon-graphcool"></i>
<span>test-2</span>
</template>
<el-menu-item index="/test-2-1">
<i class="iconfont icon-graphcool"></i>
<span>test-2-1</span>
</el-menu-item>
<el-menu-item index="/test-2-2">
<i class="iconfont icon-graphcool"></i>
<span>test-2-2</span>
</el-menu-item>
</el-sub-menu>
</el-sub-menu>
</el-menu>
</el-scrollbar>
</div>
</div>
</template>
<script setup>
import useLayout from '@/store/layout'
import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue'
const layoutStore = useLayout()
const { isExpand } = storeToRefs(layoutStore) // 需要使用storeToRefs, 才能保持响应式
const isCollapse = computed({
get() {
return !isExpand.value
}
})
watch(isExpand, (newVal, oldVal) => {
// console.log('监听到变化');
})
</script>
<style lang="scss">
.sider {
width: 220px;
height: 100vh;
background-color: #294256;
position: relative;
box-shadow: 2px 0 8px 0 rgba(0, 0, 0, 0.3);
flex-shrink: 0;
.sider-top {
height: 50px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.site-title {
white-space: nowrap;
font-weight: bold;
color: #fff;
}
.site-icon {
font-size: 20px;
color: #27ae60;
}
}
.sider-body {
position: absolute;
top: 50px;
left: 0;
right: 0;
bottom: 0;
background-color: #294256;
.menu-bar {
.iconfont {
margin-right: 10px;
}
}
}
}
.el-menu {
border-right: none; // 修复边缘白边
}
ul.el-menu--inline, .nested-sub-menu div {
background-color: #1f2d3d !important; // 二级菜单深暗色
}
</style>
创建views/Home.vue
Home.vue可作为其它组件放入到Main.vue组件的main-body中的路由出口的模板,这样就不会让右边整体出现垂直滚动条(如下图),其它组件可以自定义布局方式,
<template>
<div class="main-content-wrapper">
<div class="content">
<el-scrollbar>
<el-timeline>
<el-timeline-item v-for="(activity, index) in activities" :key="index" :icon="activity.icon"
:type="activity.type" :color="activity.color" :size="activity.size" :hollow="activity.hollow"
:timestamp="activity.timestamp">
{{ activity.content }}
</el-timeline-item>
</el-timeline>
</el-scrollbar>
</div>
</div>
</template>
<script setup>
import { Expand, Fold, MoreFilled } from '@element-plus/icons-vue'
const activities = [
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
{
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
}, {
content: 'Custom icon',
timestamp: '2018-04-12 20:46',
size: 'large',
type: 'primary',
icon: MoreFilled,
},
]
</script>
<style lang="scss">
.main-content-wrapper {
overflow: auto;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 20px;
background-clip: content-box;
.content {
width: 100%;
height: 100%;
overflow: auto;
background-color: #fff;
border-radius: 8px;
padding: 10px 0 10px 10px;
box-sizing: border-box;
border: 1px solid red;
}
}
</style>
创建views/404/NotFound.vue
这里就展示简单的返回下
<template>
<div class="main-content-wrapper">
<div>
<h1>页面找丢了。。。</h1>
<el-button type="primary" @click="goBack">返回</el-button>
</div>
</div>
</template>
<script setup>
function goBack() {
window.history.go(-1)
}
</script>
<style lang="scss">
.main-content-wrapper {
overflow: auto;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 20px;
background-clip: content-box;
display: flex;
align-items: center;
justify-content: center;
}
</style>
实现动态路由菜单
不同用户登录进来,需要根据当前用户拥有的菜单显示在侧边栏,并且动态添加路由到router中。也就是说,用户一登陆完成,我们就应该请求后台去拿到用户拥有的所有菜单,组装侧边栏菜单,并且要动态的添加路由。
调整路由和菜单
我们需要做如下的事情,但是在做下面的事情之前,我们先调整一下我们的菜单,确保这样是可用的,然后再接入后台数据。
- 需要获取左侧菜单栏的数据,然后递归遍历出来
- 将路由添加到router里面
调整路由
- 我们注意到,vue里面的路由,如果是以/直接开头,它就会忽略父路由的路径,而直接去匹配,而如果不是以/开头,则会拼接上父路径去匹配,为了方便,就全部以/开头。
- 我们把所有的路由都作为layout的子路由,所以后面我们就直接添加到layout的路由下面就行了
import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";
// 路由信息
const routes = [
{
path: "/login",
name: "login",
component: () => import('@/views/login/index.vue'),
},
{
path: '/',
name: 'layout',
component: ()=>import('@/layout/index.vue'),
children: [
{
path: '/home', // 最好以/开头, 如果不以/开头,那么路由到这个组件就需要拼接上父路径
name: 'home',
component: ()=>import('@/views/Home.vue'),
},
{
path: '/sys/user',
name: 'user',
component: ()=>import('@/views/sys/user.vue'),
},
{
path: '/sys/role',
name: 'role',
component: ()=>import('@/views/sys/role.vue'),
},
{
path: '/sys/menu',
name: 'menu',
component: ()=>import('@/views/sys/menu.vue'),
},
{
path: '/test/test_1',
name: 'test_1',
component: ()=>import('@/views/test/test_1.vue'),
},
{
path: '/test/test2/test_2_1',
name: 'test_2_1',
component: ()=>import('@/views/test/test2/test_2_1.vue'),
},
{
path: '/test/test2/test_2_2',
name: 'test_2_2',
component: ()=>import('@/views/test/test2/test_2_2.vue'),
},
]
},
{
path:'/:pathMatch(.*)*',
name: 'notFound',
component: ()=>import('@/views/404/NotFound.vue')
},
]
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to,from,next)=>{
nprogress.start()
next()
})
router.afterEach((to,from,next)=>{
nprogress.done()
})
// 导出路由
export default router;
调整菜单
这里我们只需要对着路由写index的路径即可。还有注意的是,如果用户是直接在地址栏输入的路径而跳转的话,我们也需要让对应的菜单高亮,我们监听路由即可。
<template>
<div class="sider" :style="{ 'width': isExpand ? '220px' : '80px', transition: 'all 0.28s' }">
<div class="sider-top">
<h3 v-if="isExpand" class="site-title">PSCOOL管理系统</h3>
<i v-else class="iconfont icon-graphcool site-icon"></i>
</div>
<div class="sider-body">
<el-scrollbar>
<el-menu
:collapse="isCollapse"
router
collapse-transition text-color="#eee"
:default-openeds="['/sys']"
:default-active="activeMenu"
background-color="#294256" class="menu-bar">
<el-menu-item index="/home">
<i class="iconfont icon-home-line"></i>
<span>主页</span>
</el-menu-item>
<el-sub-menu index="/sys">
<template #title>
<i class="iconfont icon-shezhi"></i>
<span>系统管理</span>
</template>
<el-menu-item index="/sys/user">
<i class="iconfont icon-yonghuguanli"></i>
<span>用户管理</span>
</el-menu-item>
<el-menu-item index="/sys/role">
<i class="iconfont icon-jiaoseguanli"></i>
<span>角色管理</span>
</el-menu-item>
<el-menu-item index="/sys/menu">
<i class="iconfont icon-icon_caidanguanli"></i>
<span>菜单管理</span>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/test">
<template #title>
<i class="iconfont icon-graphcool"></i>
<span>多级菜单</span>
</template>
<el-menu-item index="/test/test_1">
<i class="iconfont icon-graphcool"></i>
<span>test_1</span>
</el-menu-item>
<el-sub-menu index="/test/test2" class="nested-sub-menu">
<template #title>
<i class="iconfont icon-graphcool"></i>
<span>test_2</span>
</template>
<el-menu-item index="/test/test2/test_2_1">
<i class="iconfont icon-graphcool"></i>
<span>test_2_1</span>
</el-menu-item>
<el-menu-item index="/test/test2/test_2_2">
<i class="iconfont icon-graphcool"></i>
<span>test_2_2</span>
</el-menu-item>
</el-sub-menu>
</el-sub-menu>
</el-menu>
</el-scrollbar>
</div>
</div>
</template>
<script setup>
import { ref,reactive } from 'vue'
import useLayout from '@/store/layout'
import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue'
import { useRouter,useRoute } from 'vue-router'
const layoutStore = useLayout()
const { isExpand } = storeToRefs(layoutStore) // 需要使用storeToRefs, 才能保持响应式
const isCollapse = computed({
get() {
return !isExpand.value
}
})
watch(isExpand, (newVal, oldVal) => {
// console.log('监听到变化');
})
const activeMenu = ref('/home') // 需要监听到当前的路由, 让它高亮, 因为用户有可能手动地址栏输入
const router = useRouter()
const route = useRoute()
watch(()=>route.fullPath, (newVal,oldVal)=>{
console.log('监听到当前的路由', newVal);
activeMenu.value = newVal;
}, {immediate:true})
</script>
<style lang="scss">
.sider {
width: 220px;
height: 100vh;
background-color: #294256;
position: relative;
box-shadow: 2px 0 8px 0 rgba(0, 0, 0, 0.3);
flex-shrink: 0;
.sider-top {
height: 50px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.site-title {
white-space: nowrap;
font-weight: bold;
color: #fff;
}
.site-icon {
font-size: 20px;
color: #27ae60;
}
}
.sider-body {
position: absolute;
top: 50px;
left: 0;
right: 0;
bottom: 0;
background-color: #294256;
.menu-bar {
.iconfont {
margin-right: 10px;
font-size: 1.4em;
}
}
}
}
.el-menu {
border-right: none; // 修复边缘白边
}
ul.el-menu--inline, .nested-sub-menu div {
background-color: #1f2d3d !important; // 二级菜单深暗色
}
</style>
后台菜单和路由数据返回示例
menu.json
{
"errno": 0,
"errmsg": "成功",
"data": [
{
"id": 1,
"parentId": 0,
"title":"主页",
"icon":"iconfont icon-home-line",
"url":"/home",
"menuType": "C",
"component":"@/views/Home.vue"
},
{
"id": 2,
"parentId": 0,
"title":"系统设置",
"icon":"iconfont icon-shezhi",
"url":"/sys",
"menuType": "M",
"component":"",
"children": [
{
"id": 3,
"parentId": 2,
"title":"用户管理",
"icon":"iconfont icon-yonghuguanli",
"url":"/sys/user",
"menuType": "C",
"component":"@/views/sys/user.vue"
},
{
"id": 4,
"parentId": 2,
"title":"角色管理",
"icon":"iconfont icon-jiaoseguanli",
"url":"/sys/role",
"menuType":"C",
"component":"@/views/sys/role.vue"
},
{
"id": 5,
"parentId": 2,
"title":"菜单管理",
"icon":"iconfont icon-icon_caidanguanli",
"url":"/sys/menu",
"menuType":"C",
"component":"@/views/sys/menu.vue"
}
]
},
{
"id": 6,
"parentId": 0,
"title":"多级菜单",
"icon":"iconfont icon-graphcool",
"url":"/test",
"component":"",
"menuType":"M",
"children": [
{
"id": 7,
"parentId": 6,
"title":"test_1",
"icon":"iconfont icon-graphcool",
"url":"/test/test_1",
"menuType":"C",
"component":"@/views/test/test_1.vue"
},
{
"id": 8,
"parentId": 2,
"title":"test_2",
"icon":"iconfont icon-graphcool",
"url":"/test/test_2",
"menuType":"M",
"component":"",
"children":[
{
"id": 9,
"parentId": 8,
"title":"test_2_1",
"icon":"iconfont icon-graphcool",
"menuType":"C",
"url":"/test/test_2/test_2_1",
"component":"@/views/test/test_2_1.vue"
},
{
"id": 10,
"parentId": 8,
"title":"test_2_2",
"icon":"iconfont icon-graphcool",
"menuType":"C",
"url":"/test/test_2/test_2_2",
"component":"@/views/test/test_2_2.vue"
}
]
}
]
}
]
}
router.json
{
"errno": 0,
"errmsg": "成功",
"data": [
{
"path": "/home",
"name": "home",
"component": "@/views/Home.vue"
},
{
"path": "/sys/user",
"name": "user",
"component": "@/views/sys/user.vue"
},
{
"path": "/sys/role",
"name": "role",
"component": "@/views/sys/role.vue"
},
{
"path": "/sys/menu",
"name": "menu",
"component": "@/views/sys/menu.vue"
},
{
"path": "/test/test_1",
"name": "test_1",
"component": "@/views/test/test_1.vue"
},
{
"path": "/test/test_2/test_2_1",
"name": "test_2_1",
"component": "@/views/test/test2/test_2_1.vue"
},
{
"path": "/test/test_2/test_2_2",
"name": "test_2_2",
"component": "@/views/test/test2/test_2_2.vue"
}
]
}
修改loginApi.js
import request from '@/utils/request'
export function getCaptchaImage() {
return request({
url: 'captchaImage',
})
}
export function login(data) {
return request({
method:'post',
url: 'user/login',
data
})
}
export function getMenus() { // 获取菜单
return request({
method:'get',
url: 'test/getMenus'
})
}
export function getRoutes() { // 获取路由
return request({
method:'get',
url: 'test/getRoutes'
})
}
修改request.js
因为需要添加请求头,才能访问获取菜单路由接口
import axios from 'axios'
import Messager from './messager';
import pinia from '@/store'
import useUser from '@/store/user'
const instance = axios.create({
baseURL: 'http://127.0.0.1:8080/api',
timeout: 10000
})
instance.interceptors.request.use((config)=>{
// debugger
let userStore = useUser()
if(userStore.token) {
console.log('userStore.token',userStore.token);
config.headers['Authorization'] = userStore.token
}
return config;
})
instance.interceptors.response.use(response=>{
if(response.data.errno == 0) {
return Promise.resolve(response.data.data)
} else {
if(response.data.errno == 501) {
Messager.error('请重新登录')
window.location.href = '/login'
} else {
Messager.error(response.data.errmsg)
return Promise.reject(new Error(response.data.errmsg))
}
}
})
export default instance
修改router/index.js
将原本配置的静态路由删掉,这部分路由由后端返回,并添加前置守卫逻辑
import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";
import Messager from '@/utils/messager';
import useMenu from '@/store/menu'
import pinia from '@/store'
import useUser from '@/store/user'
const userStore = useUser(pinia) // 此处不能像在组件里使用userStore一样直接useUser()调用,而是要先引入pinia,再传入。参考: https://blog.csdn.net/qq_21473443/article/details/126405859
console.log('router->userStore',userStore);
const menuStore = useMenu(pinia)
// 路由信息
const routes = [
{
path: "/login",
name: "login",
component: () => import('@/views/login/index.vue'),
},
{
path: '/',
name: 'layout',
component: ()=>import('@/layout/index.vue'),
children: []
},
{
path:'/:pathMatch(.*)*',
name: 'notFound',
component: ()=>import('@/views/404/NotFound.vue')
},
]
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to,from,next)=>{
nprogress.start()
// debugger
let token = userStore.token
if(!token) {
if(to.path == '/login') {
next()
} else {
next('/login')
}
} else {
if(!menuStore.routesMenusLoaded) {
menuStore.loadRoutesMenus().then(res=>{
next()
}).catch(err=>{
// 加载出错,跳回到登录页去
userStore.clearUserInfo()
next('/login')
})
} else {
if(to.path == '/login') {
Messager.warn('你已登录!')
next('/home')
} else {
next()
}
}
}
})
router.afterEach((to,from,next)=>{
nprogress.done()
})
// 导出路由
export default router;
修改user.js
import { defineStore } from 'pinia'
import { login } from '@/api/loginApi'
function retrieveLocalToken() {
console.log('read token');
return localStorage.getItem('token') || ''
}
function clearLocalToken() {
return localStorage.clear('token')
}
export default defineStore('user',{
state: () => {
return {
token: retrieveLocalToken() // 当刷新页面时, 这个会加载一次
}
},
getters: {
},
actions: {
doLogin(data) {
return new Promise((resolve, reject) => {
login(data).then(res=>{
this.token = res // 同样先存入到pinia中
localStorage.setItem('token', res)
resolve(data)
}).catch(err=>{
reject(err)
})
})
},
clearUserInfo() {
this.token = null
clearLocalToken()
}
}
})
创建store/menu.js
创建menu.js用来存储后台返回的数据
import { defineStore } from 'pinia'
import {getMenus,getRoutes} from '@/api/loginApi'
import router from '@/router'
export default defineStore('menu', {
state: ()=> {
return {
routesMenusLoaded: false, // 路由菜单是否已加载
menus: [], // 菜单
routes: [] // 路由
}
},
getters: {
},
actions: {
loadRoutesMenus() {
return new Promise(async (resolve,reject)=>{
try {
let menus = await getMenus()
let routes = await getRoutes()
// 保存路由
this.routes = routes
// 保存菜单
this.menus = menus
// 动态加载路由
routes.forEach(route=>{
router.addRoute(
'layout',
{
path: route.path,
name: route.name,
component: ()=>import(route.component.replace('@',"../")) // 好像直接以@开头,会报错,所以这干脆替换成相对路径吧
}
)
})
console.log(router.getRoutes(),'finished');
resolve()
} catch (err) {
reject(err)
}
})
}
}
})
修改菜单栏组件Sider.vue
<template>
<div class="sider" :style="{ 'width': isExpand ? '220px' : '80px', transition: 'all 0.28s' }">
<div class="sider-top">
<h3 v-if="isExpand" class="site-title">PSCOOL管理系统</h3>
<i v-else class="iconfont icon-graphcool site-icon"></i>
</div>
<div class="sider-body">
<el-scrollbar>
<el-menu
:collapse="isCollapse"
router
collapse-transition text-color="#eee"
:default-openeds="['/sys']"
:default-active="activeMenu"
background-color="#294256" class="menu-bar">
<TreeMenu v-for="menuData,index in menuList" :menu="menuData" :key="index"></TreeMenu>
</el-menu>
</el-scrollbar>
</div>
</div>
</template>
<script setup>
import TreeMenu from './TreeMenu.vue'
import { ref,reactive } from 'vue'
import useLayout from '@/store/layout'
import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue'
import { useRouter,useRoute } from 'vue-router'
import useMenu from '@/store/menu'
const layoutStore = useLayout()
const { isExpand } = storeToRefs(layoutStore) // 需要使用storeToRefs, 才能保持响应式
const isCollapse = computed({
get() {
return !isExpand.value
}
})
watch(isExpand, (newVal, oldVal) => {
// console.log('监听到变化');
})
const activeMenu = ref('/home') // 需要监听到当前的路由, 让它高亮, 因为用户有可能手动地址栏输入
const router = useRouter()
const route = useRoute()
watch(()=>route.fullPath, (newVal,oldVal)=>{
console.log('监听到当前的路由', newVal);
activeMenu.value = newVal;
}, {immediate:true})
const menuStore = useMenu()
const menuList = computed({
get() {
return menuStore.menus
}
})
</script>
<style lang="scss">
.sider {
width: 220px;
height: 100vh;
background-color: #294256;
position: relative;
box-shadow: 2px 0 8px 0 rgba(0, 0, 0, 0.3);
flex-shrink: 0;
.sider-top {
height: 50px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.site-title {
white-space: nowrap;
font-weight: bold;
color: #fff;
}
.site-icon {
font-size: 20px;
color: #27ae60;
}
}
.sider-body {
position: absolute;
top: 50px;
left: 0;
right: 0;
bottom: 0;
background-color: #294256;
.menu-bar {
.iconfont {
margin-right: 10px;
font-size: 1.4em;
}
}
}
}
.el-menu {
border-right: none; // 修复边缘白边
}
ul.el-menu--inline, .nested-sub-menu div {
background-color: #1f2d3d !important; // 二级菜单深暗色
}
</style>
创建TreeMenu.vue递归组件
<template>
<template v-if="!menu.children && menu.menuType == 'C'">
<el-menu-item :index="menu.url">
<i :class="menu.icon"></i>
<span>{{ menu.title }}</span>
</el-menu-item>
</template>
<template v-if="menu.children && menu.menuType == 'M'">
<el-sub-menu :index="menu.url" :class="{'nested-sub-menu': menu.parentId != 0}">
<template #title>
<i :class="menu.icon"></i>
<span>{{ menu.title }}</span>
</template>
<TreeMenu v-for="childMenu,index in menu.children" :menu="childMenu" :key="index"></TreeMenu>
</el-sub-menu>
</template>
</template>
<script setup>
defineProps({
menu: {
type: Object
}
})
</script>
<style lang="scss"></style>
解决地址栏刷新问题
上面犯了一个错误,我把404路由作为静态路由,直接给放到了router里面了,这样404的路由就排在了前面,它不是精确匹配,导致我刷新页面的时候,直接就跳404页面了,所以把404的路由改到获取完后端的全部路由数据之后
修改router/index.js
import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";
import Messager from '@/utils/messager';
import useMenu from '@/store/menu'
import pinia from '@/store'
import useUser from '@/store/user'
const userStore = useUser(pinia) // 此处不能像在组件里使用userStore一样直接useUser()调用,而是要先引入pinia,再传入。参考: https://blog.csdn.net/qq_21473443/article/details/126405859
console.log('router->userStore',userStore);
const menuStore = useMenu(pinia)
// 路由信息
const routes = [
{
path: "/login",
name: "login",
component: () => import('@/views/login/index.vue'),
},
{
path: '/',
name: 'layout',
component: ()=>import('@/layout/index.vue'),
children: []
}
]
const router = createRouter({
history: createWebHistory(),
routes
});
function existRoutePath(path) {
let routes = router.getRoutes()
let routePathArr = []
routes.forEach((route) => {
routePathArr.push(route.path)
})
return routePathArr.indexOf(path)
}
router.beforeEach((to,from,next)=>{
nprogress.start()
// console.log(router.getRoutes(),existRoutePath(to.path),'router hasRoute-3',to);
// debugger
let token = userStore.token
if(!token) {
if(to.path == '/login') {
next()
} else {
next('/login')
}
} else {
if(!menuStore.routesMenusLoaded) {
// console.log(router.getRoutes(),existRoutePath(to.name),'router hasRoute-1',to);
menuStore.loadRoutesMenus().then(res=>{
// console.log(router.getRoutes(),existRoutePath(to.name),'router hasRoute-2',to);
next({...to})
}).catch(err=>{
// 加载出错,跳回到登录页去
userStore.clearUserInfo()
next('/login')
})
} else {
if(to.path == '/login') {
Messager.warn('你已登录!')
next('/home')
} else {
// console.log(router.getRoutes(),existRoutePath(to.name),'router hasRoute-4');
next()
}
}
}
})
router.afterEach((to,from,next)=>{
nprogress.done()
})
// 导出路由
export default router;
修改menu.js
import { defineStore } from 'pinia'
import {getMenus,getRoutes} from '@/api/loginApi'
import router from '@/router'
export default defineStore('menu', {
state: ()=> {
return {
routesMenusLoaded: false, // 路由菜单是否已加载
menus: [], // 菜单
routes: [] // 路由
}
},
getters: {
},
actions: {
loadRoutesMenus() {
return new Promise(async (resolve,reject)=>{
try {
let menus = await getMenus()
let routes = await getRoutes()
// 保存路由
this.routes = routes
// 保存菜单
this.menus = menus
// 动态加载路由
routes.forEach(route=>{
router.addRoute(
'layout',
{
path: route.path,
name: route.name,
component: ()=>import(route.component.replace('@',"../")) // 好像直接以@开头,会报错,所以这干脆替换成相对路径吧
}
)
})
router.addRoute({
path:'/:pathMatch(.*)*',
name: 'notFound',
component: ()=>import('../views/404/NotFound.vue')
})
console.log(router.getRoutes(),'加载路由 finished');
this.routesMenusLoaded = true
resolve()
} catch (err) {
reject(err)
}
})
}
}
})
12.全屏功能
安装screenfull
npm i screenfull -S
使用screenfull
<template>
<div class="fullscreen mlr8" @click="toggleFullScreen">
<i :class="['iconfont', 'pointer', { 'icon-quanping_o': !isFullScreen, 'icon-quxiaoquanping_o': isFullScreen }]"></i>
</div>
</template>
<script>
import { ref} from 'vue'
const isFullScreen = ref(false)
function toggleFullScreen() {
screenfull.toggle()
isFullScreen.value = !isFullScreen.value
}
</script>
13. 面包屑
我们需要展示当前路由的菜单的面包屑,先约定下数据,路由的name唯一且对应到菜单里的name且唯一,这样当我们切换到某一个路由的时候,就可以根据name到菜单里面递归的找到它所对应的所有父级。
数据
{
"errno": 0,
"errmsg": "成功",
"data": [
{
"path": "/home",
"name": "home",
"component": "@/views/Home.vue"
},
{
"path": "/sys/user",
"name": "user",
"component": "@/views/sys/user.vue"
},
{
"path": "/sys/role",
"name": "role",
"component": "@/views/sys/role.vue"
},
{
"path": "/sys/menu",
"name": "menu",
"component": "@/views/sys/menu.vue"
},
{
"path": "/test/test_1",
"name": "test_1",
"component": "@/views/test/test_1.vue"
},
{
"path": "/test/test_2/test_2_1",
"name": "test_2_1",
"component": "@/views/test/test2/test_2_1.vue"
},
{
"path": "/test/test_2/test_2_2",
"name": "test_2_2",
"component": "@/views/test/test2/test_2_2.vue"
}
]
}
{
"errno": 0,
"errmsg": "成功",
"data": [
{
"id": 1,
"parentId": 0,
"name": "home",
"title":"主页",
"icon":"iconfont icon-home-line",
"url":"/home",
"menuType": "C",
"component":"@/views/Home.vue"
},
{
"id": 2,
"parentId": 0,
"name": "sys",
"title":"系统设置",
"icon":"iconfont icon-shezhi",
"url":"/sys",
"menuType": "M",
"component":"",
"children": [
{
"id": 3,
"parentId": 2,
"name": "user",
"title":"用户管理",
"icon":"iconfont icon-yonghuguanli",
"url":"/sys/user",
"menuType": "C",
"component":"@/views/sys/user.vue"
},
{
"id": 4,
"parentId": 2,
"name": "role",
"title":"角色管理",
"icon":"iconfont icon-jiaoseguanli",
"url":"/sys/role",
"menuType":"C",
"component":"@/views/sys/role.vue"
},
{
"id": 5,
"parentId": 2,
"name": "menu",
"title":"菜单管理",
"icon":"iconfont icon-icon_caidanguanli",
"url":"/sys/menu",
"menuType":"C",
"component":"@/views/sys/menu.vue"
}
]
},
{
"id": 6,
"parentId": 0,
"name": "test",
"title":"多级菜单",
"icon":"iconfont icon-graphcool",
"url":"/test",
"component":"",
"menuType":"M",
"children": [
{
"id": 7,
"parentId": 6,
"name": "test_1",
"title":"test_1",
"icon":"iconfont icon-graphcool",
"url":"/test/test_1",
"menuType":"C",
"component":"@/views/test/test_1.vue"
},
{
"id": 8,
"parentId": 2,
"name": "test_2",
"title":"test_2",
"icon":"iconfont icon-graphcool",
"url":"/test/test_2",
"menuType":"M",
"component":"",
"children":[
{
"id": 9,
"parentId": 8,
"name": "test_2_1",
"title":"test_2_1",
"icon":"iconfont icon-graphcool",
"menuType":"C",
"url":"/test/test_2/test_2_1",
"component":"@/views/test/test_2_1.vue"
},
{
"id": 10,
"parentId": 8,
"name": "test_2_2",
"title":"test_2_2",
"icon":"iconfont icon-graphcool",
"menuType":"C",
"url":"/test/test_2/test_2_2",
"component":"@/views/test/test_2_2.vue"
}
]
}
]
}
]
}
修改menus.js
根据后台返回的菜单,递归出所有路由对应的带层级的菜单标题,放入路由的meta中
import { defineStore } from 'pinia'
import {getMenus,getRoutes} from '@/api/loginApi'
import router from '@/router'
import { dropdownMenuProps } from 'element-plus'
function generateNameMap(menus) {
const nameMap = {}
menus.forEach(menu => {
handleMenu(menu,nameMap,[])
})
return nameMap
}
function handleMenu(menu,nameMap,titleArr) {
titleArr.push(menu.title)
nameMap[menu.name] = JSON.parse(JSON.stringify(titleArr))
if(menu.children && menu.children.length > 0) {
menu.children.forEach(menu => {
let newTitleArr = JSON.parse(JSON.stringify(titleArr))
handleMenu(menu,nameMap,newTitleArr)
})
}
}
export default defineStore('menu', {
state: ()=> {
return {
routesMenusLoaded: false, // 路由菜单是否已加载
menus: [], // 菜单
routes: [] // 路由
}
},
getters: {
},
actions: {
loadRoutesMenus() {
return new Promise(async (resolve,reject)=>{
try {
let menus = await getMenus()
let routes = await getRoutes()
// 保存路由
this.routes = routes
// 保存菜单
this.menus = menus
// debugger
const nameMap = generateNameMap(menus)
// 动态加载路由
routes.forEach(route=>{
router.addRoute(
'layout',
{
path: route.path,
name: route.name,
meta: {
titleArr: nameMap[route.name]
},
// 好像直接以@开头,会报错,所以这干脆替换成相对路径吧
component: ()=>import(route.component.replace('@',"../"))
}
)
})
router.addRoute({
path:'/:pathMatch(.*)*',
name: 'notFound',
component: ()=>import('../views/404/NotFound.vue')
})
console.log(router.getRoutes(),'加载路由 finished');
this.routesMenusLoaded = true
resolve()
} catch (err) {
reject(err)
}
})
},
}
})
修改Breadcrumb.vue
监听路由变化,从路由的meta中获取缓存的面包屑数据
<template>
<el-breadcrumb separator="/" style="color: #303133;display: flex;white-space: nowrap;">
<el-breadcrumb-item v-for="(title,index) in titleArr" :key="index">{{ title }}</el-breadcrumb-item>
</el-breadcrumb>
</template>
<script setup>
import { ref,reactive,watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const titleArr = ref([])
watch(()=>route, (newRoute,oldRoute)=>{
console.log('路由更新了',newRoute);
titleArr.value = newRoute.meta.titleArr
},{immediate: true,deep:true})
</script>
<style lang="scss">
</style>
14. tagsView
这一步主要实现tagsView功能,tagsView中记录用户访问过的菜单,并且能够根据需要关闭它,但是主页这个tag要一直保留。
我们把tag存放在pinia里面,tagsView组件通过计算属性引用pinia里面的tags,通过监听事件触发方法调用pinia里的方法
里面有个坑:点击关闭按钮的时候,需要阻止事件冒泡,否则,不仅会触发i这个icon的关闭的事件,又会触发div的点击事件,使用@click.stop去绑定
TagsView.vue
<template>
<div class="main-header-tags-wrapper">
<el-scrollbar>
<div class="main-header-tags" id="main-header-tags">
<div :class="['tag-item',{'active':tag.isActive}]" @click="selectSpecifiedTag(tag)" v-for="(tag,index) in tags" :key="index">
<span>{{ tag.title }}</span>
<i class="close-ico iconfont icon-guanbi" v-show="tag.name != 'home'" @click.stop="closeTag(tag)"></i>
</div>
</div>
</el-scrollbar>
</div>
</template>
<script setup>
import useTagsView from '@/store/tagsView'
import { computed, watch } from 'vue'
import { useRoute,useRouter } from 'vue-router'
const tagsViewStore = useTagsView()
const tags = computed({
get() {
return tagsViewStore.tags
}
})
const route = useRoute()
const router = useRouter()
watch(()=>route, (newRoute,oldRoute)=>{
tagsViewStore.doOnrouteChange(newRoute)
},{immediate:true,deep:true})
function selectSpecifiedTag(tag) {
debugger
tagsViewStore.selectSpecifiedTag(tag)
router.push({name:tag.name})
}
function closeTag(tag) {
// 关闭的是不是当前激活的tag, 如果是当前激活的tag的话,就选择最后一个tag;如果不是当前激活的tag的话,就关掉它就行了
let isCurrTagActiveClose = tag.isActive
tagsViewStore.closeSpecifiedTag(tag)
if(isCurrTagActiveClose) {
// 选择最后面的tag
debugger
console.log('选择最后面的tag', tagsViewStore.tags[tagsViewStore.tags.length - 1]);
selectSpecifiedTag(tagsViewStore.tags[tagsViewStore.tags.length - 1])
}
}
</script>
<style lang="scss">
.main-header-tags-wrapper {
padding: 0 10px;
.main-header-tags {
height: 32px;
display: flex;
align-items: center;
.tag-item {
height: 26px;
padding: 0 20px;
margin-right: 8px;
font-size: 13px;
cursor: pointer;
color: #495060;
border: 1px solid #ccc;
background-color: #fff;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
i.close-ico {
font-size: 12px;
position: absolute;
right: 2px;
top: 4.5px;
transform: scale(0.6);
cursor: pointer;
padding: 3px;
border-radius: 50%;
&:hover {
background: #b4bccc;
}
}
&.active {
background-color: #409eff;
border: #409eff;
color: #fff;
&::before {
content: '';
position: absolute;
width: 6px;
height: 6px;
background-color: #fff;
border-radius: 50%;
left: 8px;
top: 10.5px;
}
}
}
}
}
</style>
TagsView.js
import { defineStore } from 'pinia'
export default defineStore('tagsView', {
state: ()=> {
return {
tags: [
{
title: '主页',
name: 'home',
path: '/home',
isActive: false
}
],
}
},
getters: {
},
actions: {
doOnrouteChange(route) {
debugger
console.log('doOnrouteChange->新路由', route.name);
let currRouteName = route.name
let tagNameArr = []
let flag = false
this.tags.forEach(tag=>{
tag.isActive = false
if(tag.name == currRouteName) {
flag = true
tag.isActive = true
}
})
if(!flag) {
console.log('原先没有这个路由,现在添加tag', route.name);
this.tags.push({
title: route.meta.title,
name: route.name,
path: route.path,
isActive: true
})
}
},
closeSpecifiedTag(tag){
debugger
let index = -1;
for(let i=0;i<this.tags.length;i++) {
if(this.tags[i].name === tag.name) {
index = i
break
}
}
if(index > -1) {
this.tags.splice(index,1)
}
},
selectSpecifiedTag(tag) {
debugger
this.tags.forEach(t=>{
t.isActive = false
if(t.name == tag.name) {
t.isActive = true
}
})
}
}
})
15. vue指令控制权限按钮显示
通过vue的directive指令方式,当用户拥有指定的权限时,才显示按钮
后台返回权限数据
{
"errno": 0,
"errmsg": "成功",
"data": {
"perms": [
"user:list",
"user:add",
"user:remove",
"role:list",
"role:add",
"role:remove"
]
}
}
创建指令文件perms.js
import useMenu from '@/store/menu'
import { toRaw } from '@vue/reactivity'
export default {
hasPerms: {
mounted(el,binding) {
const menuStore = useMenu()
let perms1 = menuStore.perms
console.log(el,binding,perms1);
let perms2 = toRaw(perms1)
let perms3 = JSON.parse(JSON.stringify(perms1))
console.log(perms2.perms);
console.log(perms3.perms);
// 有任一指定的权限, 即可显示指定的dom, 否则移除
if(!perms2.perms.some(p=>binding.value.includes(p))) {
el.parentNode.removeChild(el)
}
},
}
}
main.js中注册该指令
import { createApp } from 'vue'
import './style.css'
import '@/assets/iconfont/iconfont.css'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import Messager from '@/utils/messager'
import router from '@/router'
import pinia from '@/store'
import perm from '@/directive/perm'
const app = createApp(App)
app.config.globalProperties.Messager = Messager
app.use(pinia)
app.use(router)
app.use(ElementPlus)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 注册指令
for(let key in perm) {
app.directive(key, perm[key])
}
app.mount('#app')
loginApi.js中添加接口
// ...省略
export function getPerms() {
return request({
method:'get',
url: 'test/getPerms'
})
}
修改store/menu.js
把获取权限的部分加进去
import { defineStore } from 'pinia'
import {getMenus,getRoutes,getPerms} from '@/api/loginApi'
import router from '@/router'
import { dropdownMenuProps } from 'element-plus'
function generateNameMap(menus) {
const nameMap = {}
menus.forEach(menu => {
handleMenu(menu,nameMap,[])
})
return nameMap
}
function handleMenu(menu,nameMap,titleArr) {
titleArr.push(menu.title)
nameMap[menu.name] = JSON.parse(JSON.stringify(titleArr))
if(menu.children && menu.children.length > 0) {
menu.children.forEach(menu => {
let newTitleArr = JSON.parse(JSON.stringify(titleArr))
handleMenu(menu,nameMap,newTitleArr)
})
}
}
export default defineStore('menu', {
state: ()=> {
return {
routesMenusLoaded: false, // 路由菜单是否已加载
menus: [], // 菜单
routes: [], // 路由,
perms: [], // 权限
}
},
getters: {
},
actions: {
loadRoutesMenus() {
return new Promise(async (resolve,reject)=>{
try {
let menus = await getMenus()
let routes = await getRoutes()
let perms = await getPerms()
// 保存路由
this.routes = routes
// 保存菜单
this.menus = menus
// 保存权限
this.perms = perms
// debugger
const nameMap = generateNameMap(menus)
// 动态加载路由
routes.forEach(route=>{
router.addRoute(
'layout',
{
path: route.path,
name: route.name,
meta: {
titleArr: nameMap[route.name],
title: nameMap[route.name][nameMap[route.name].length-1]
},
// 好像直接以@开头,会报错,所以这干脆替换成相对路径吧
component: ()=>import(route.component.replace('@',"../"))
}
)
})
router.addRoute({
path:'/:pathMatch(.*)*',
name: 'notFound',
component: ()=>import('../views/404/NotFound.vue')
})
console.log(router.getRoutes(),'加载路由 finished');
this.routesMenusLoaded = true
resolve()
} catch (err) {
reject(err)
}
})
},
}
})
User.vue中使用
<template>
用户管理
<el-button type="danger" v-hasPerms="['user:list']">查看</el-button>
<el-button type="primary" v-hasPerms="['user:add']">添加</el-button>
<el-button type="primary" v-hasPerms="['user:update']">修改</el-button>
<el-button type="primary" v-hasPerms="['user:remove']">删除</el-button>
</template>
<script setup>
</script>
<style lang="scss">
</style>
如下效果
16.添加过渡效果
面包屑和路由的切换过程,看上去特别的生硬,我们需要给它们添加过渡效果,就像下面这样
面包屑过渡效果
- 下面的过渡效果代码,是直接拷贝的官网,因为是用v-for遍历出来的,所以要用transition-group。
- 还要注意的是,元素绑定的key要必须唯一(不能使用索引当key哦),这是vue的要求,不然不会有动画效果的
<template>
<el-breadcrumb separator="/" style="color: #303133;display: flex;white-space: nowrap;">
<transition-group name="list">
<el-breadcrumb-item v-for="title in titleArr" :key="title">{{ title }}</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>
<script setup>
import { ref,reactive,watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const titleArr = ref([])
watch(()=>route, (newRoute,oldRoute)=>{
// console.log('路由更新了',newRoute);
titleArr.value = newRoute.meta.titleArr
},{immediate: true,deep:true})
</script>
<style lang="scss">
/* breadcrumb transition */
.list-move, /* 对移动中的元素应用的过渡 */
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* 确保将离开的元素从布局流中删除
以便能够正确地计算移动的动画。 */
.list-leave-active {
position: absolute;
}
</style>
路由过渡效果
修改Main.vue,以下仅把修改代码粘贴出来,其它部分省略了
<template>
<div class="main-header">
...
</div>
<div class="main-body">
<router-view v-slot:="{Component,route}">
<transition name="slide-fade" mode="out-in">
<component :is="Component" :key="route.path"/>
</transition>
</router-view>
</div>
</template>
<style lang="scss" scoped>
/*
进入和离开动画可以使用不同
持续时间和速度曲线。
*/
.slide-fade-enter-active {
transition: all 0.5s ease-out;
}
.slide-fade-leave-active {
transition: all 0.5s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(20px);
opacity: 0;
}
...
</style>