跳到主要内容

后台布局开发

后台主布局实现

主要使用elemnt-plusContainer容器:文档地址https://element-plus.gitee.io/zh-CN/component/container.html

我们在src下新建layouts目录,新建一个admin.vue后台的一个布局页面

<template>
<el-container>
<!-- 头部 -->
<el-header>
<f-header />
</el-header>
<el-container>
<el-aside>
<f-menu />
</el-aside>
<el-main>
<f-tag-list />
<router-view></router-view>
</el-main>
</el-container>
</el-container>
</template>

<script setup>
import FHeader from "./components/FHeader.vue";
import FMenu from "./components/FMenu.vue";
import FTagList from "./components/FTagList.vue";
</script>

我们分别在其目录下新建components,然后分离出单独的头部,左侧菜单以及标签导航栏,都是简单的页面,分别加上对应的名称

<template>
<div>头部</div>
</template>

<script setup>
</script>

<style scoped>
</style>

这里就只写一个了,别的都是复制粘贴,然后就头部两个字不一样


我们在路由中设置后台的路由,并添加子路由

import { createRouter, createWebHashHistory } from 'vue-router'
import Admin from '~/layouts/admin.vue'
import Index from '~/pages/index.vue'
import Login from '~/pages/login.vue'
import NotFound from '~/pages/404.vue'
const routes = [
{
path: '/',
component: Admin,
// 子路由
children: [
{
path: '/',
component: Index,
meta: {
title: '后台首页',
},
},
],
},
{
path: '/login',
component: Login,
meta: {
title: '登录页',
},
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound,
},
]

const router = createRouter({
history: createWebHashHistory(),
routes,
})

export default router

公共头部开发

下拉菜单文档地址https://element-plus.gitee.io/zh-CN/component/dropdown.html

头像组件https://element-plus.gitee.io/zh-CN/component/avatar.html

<template>
<!-- 水平方向 -->
<div class="f-header">
<span class="logo">
<el-icon class="mr-1"><Promotion /></el-icon>
无解的游戏
</span>
<!-- 收缩图标 -->
<el-icon class="icon-btn"><Fold /></el-icon>
<!-- 刷新图标 -->
<el-icon class="icon-btn"><RefreshRight /></el-icon>
<div class="ml-auto flex items-center">
<!-- 全屏图标 -->
<el-icon class="icon-btn"><FullScreen /></el-icon>
<el-dropdown class="dropdown">
<span class="flex items-center text-light-50">
<!-- 头像 -->
<el-avatar
class="mr-2"
:size="25"
:src="$store.state.user.avatar"
/>
<!-- 昵称 -->
{{ $store.state.user.username }}
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>修改密码</el-dropdown-item>
<el-dropdown-item>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>

<script setup>
</script>

<style scoped>
.f-header {
@apply flex bg-indigo-700 text-light-50 fixed top-0 left-0 right-0 items-center;
height: 64px;
}
.logo {
width: 250px;
@apply flex justify-center items-center text-xl font-thin;
}
.icon-btn {
@apply flex justify-center items-center;
width: 42px;
height: 64px;
cursor: pointer;
}
.icon-btn:hover {
@apply bg-indigo-600;
}

.f-header .dropdown {
height: 64px;
cursor: pointer;
@apply flex justify-center items-center mx-5;
}
</style>

效果图

刷新和全屏按钮

还需要使用到vueuse的一个核心包

npm install @vueuse/core

简单实现页面点击刷新

// 刷新
const handleRefresh = () => location.reload();

然后给刷新按钮加上点击事件即可

<el-tooltip effect="dark" content="刷新" placement="bottom">
<el-icon class="icon-btn" @click="handleRefresh"
><RefreshRight
/></el-icon>
</el-tooltip>

使用vueuse的包来实现全屏功能

引入包

import { useFullscreen } from "@vueuse/core";

// 是否全屏 全屏切换
const { isFullscreen, toggle } = useFullscreen();
<el-tooltip effect="dark" content="全屏" placement="bottom">
<el-icon class="icon-btn" @click="toggle">
// 判断不是全屏状态下显示全屏按钮
<FullScreen v-if="!isFullscreen" />
// 另一个图标
<Aim v-else />
</el-icon>
</el-tooltip>

修改密码

点击修改密码,弹出一个抽屉表单来进行提交修改。

抽屉文档地址:https://element-plus.gitee.io/zh-CN/component/drawer.html

<template>
<!-- 水平方向 -->
<div class="f-header">
<span class="logo">
<el-icon class="mr-1"><Promotion /></el-icon>
无解的游戏
</span>
<!-- 收缩图标 -->
<el-icon class="icon-btn"><Fold /></el-icon>
<!-- 刷新图标 -->
<el-tooltip effect="dark" content="刷新" placement="bottom">
<el-icon class="icon-btn" @click="handleRefresh"
><RefreshRight
/></el-icon>
</el-tooltip>
<div class="ml-auto flex items-center">
<!-- 全屏图标 -->
<el-tooltip effect="dark" content="全屏" placement="bottom">
<el-icon class="icon-btn" @click="toggle">
<FullScreen v-if="!isFullscreen" />
<Aim v-else />
</el-icon>
</el-tooltip>
<el-dropdown class="dropdown" @command="handleCommand">
<span class="flex items-center text-light-50">
<!-- 头像 -->
<el-avatar
class="mr-2"
:size="25"
:src="$store.state.user.avatar"
/>
<!-- 昵称 -->
{{ $store.state.user.username }}
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="rePassword"
>修改密码</el-dropdown-item
>
<el-dropdown-item command="logout"
>退出登录</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<el-drawer
v-model="showDrawer"
title="修改密码"
size="45%"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:rules="rules"
:model="form"
label-width="80px"
size="small"
>
<el-form-item prop="oldpassword" label="旧密码">
<el-input
v-model="form.oldpassword"
placeholder="请输入旧密码"
></el-input>
</el-form-item>
<el-form-item prop="password" label="新密码">
<el-input
type="password"
v-model="form.password"
placeholder="请输入密码"
show-password
></el-input>
</el-form-item>
<el-form-item prop="repassword" label="确认密码">
<el-input
type="password"
v-model="form.repassword"
placeholder="请输入确认密码"
show-password
></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit" :loading="loading"
>提交</el-button
>
</el-form-item>
</el-form>
</el-drawer>
</template>

<script setup>
import { ref, reactive } from "vue";
import { logout, updatepassword } from "~/api/manager";
import { showModal, toast } from "~/composables/util";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import { useFullscreen } from "@vueuse/core";

// 是否全屏 全屏切换
const { isFullscreen, toggle } = useFullscreen();

const store = useStore();
const router = useRouter();

// 修改密码抽屉是否弹出
const showDrawer = ref(false);

// do not use same name with ref
const form = reactive({
oldpassword: "",
password: "",
repassword: "",
});

// 定义登录验证规则
// 必须和上面表单属性一样
const rules = {
oldpassword: [
{
required: true,
message: "旧密码不能为空",
trigger: "blur",
},
],
password: [
{
required: true,
message: "新密码不能为空",
trigger: "blur",
},
],
repassword: [
{
required: true,
message: "确认密码不能为空",
trigger: "blur",
},
],
};

// 让formRef变成响应式
const formRef = ref(null);
const loading = ref(false);
const onSubmit = () => {
formRef.value.validate((valid) => {
if (!valid) {
return false;
}
loading.value = true;
updatepassword(form)
.then((res) => {
toast("修改密码成功,请重新登录");
store.dispatch("logout");
// 跳转回登录页
router.push("/login");
})
.finally(() => {
loading.value = false;
});
});
};

// 刷新
const handleRefresh = () => location.reload();

const handleCommand = (c) => {
switch (c) {
case "logout":
handleLogout();
break;
case "rePassword":
// 修改密码
showDrawer.value = true;
break;
default:
break;
}
};

function handleLogout() {
showModal("是否要退出登录?").then((res) => {
// console.log("退出登录");
logout().finally(() => {
// 不管成功,都要到这
store.dispatch("logout");
// 跳转回登录
router.push("/login");
// 提示退出成功
toast("退出成功");
});
});
}
</script>

<style scoped>
.f-header {
@apply flex bg-indigo-700 text-light-50 fixed top-0 left-0 right-0 items-center;
height: 64px;
}
.logo {
width: 250px;
@apply flex justify-center items-center text-xl font-thin;
}
.icon-btn {
@apply flex justify-center items-center;
width: 42px;
height: 64px;
cursor: pointer;
}
.icon-btn:hover {
@apply bg-indigo-600;
}

.f-header .dropdown {
height: 64px;
cursor: pointer;
@apply flex justify-center items-center mx-5;
}
</style>

注意

我们还需要在axio.js中的全局响应拦截器里添加判断,是否是token失效了,失效,虽然后端是退出了,但是前端的还保留着,我们还需要前端也清理一下数据。

import store from './store'

// 添加响应拦截器
service.interceptors.response.use(
function (response) {
// 对响应数据做点什么
return response.data.data
},
function (error) {
const msg = error.response.data.msg || '请求失败'
// todo 这里感觉使用文字来判断不可行,后期可以进行优化
if (msg == '非法token,请先登录!') {
store.dispatch('logout').finally(() => location.reload())
}
// 对响应错误做点什么
toast(msg, 'error')
return Promise.reject(error)
}
)

form表单抽屉组件封装

vue3 setup暴露出去属性文档:https://cn.vuejs.org/api/sfc-script-setup.html#defineexpose

vue3 defimeProps defineEmits 组件文档https://cn.vuejs.org/api/sfc-script-setup.html#defineprops-defineemits

<template>
<el-drawer
v-model="showDrawer"
:title="title"
:size="size"
:close-on-click-modal="false"
:destroy-on-close="destroyOnClose"
>
<div class="formDrawer">
<div class="body">
<slot></slot>
</div>
<div class="actions">
<el-button :loading="loading" type="primary" @click="submit">{{
confrimText
}}</el-button>
<el-button type="default" @click="close">取消</el-button>
</div>
</div>
</el-drawer>
</template>

<script setup>
import { ref } from "vue";
const showDrawer = ref(false);

// 打开抽屉
const open = () => (showDrawer.value = true);
// 关闭抽屉
const close = () => (showDrawer.value = false);

const loading = ref(false);

// 显示进度条
const showLoading = () => (loading.value = true);
// 隐藏进度条
const hideLoading = () => (loading.value = false);

const emit = defineEmits(["submit"]);
// 提交 执行通知父组件
const submit = () => emit("submit");

const props = defineProps({
title: String,
size: {
type: String,
default: "45%",
},
destroyOnClose: {
type: Boolean,
default: false,
},
confrimText: {
type: String,
default: "提交",
},
});

// 向父组件暴露以下方法
defineExpose({
open,
close,
showLoading,
hideLoading,
});
</script>

<style scoped>
.formDrawer {
width: 100%;
height: 100%;
position: relative;
@apply flex flex-col;
}

.formDrawer .body {
flex: 1;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 50px;
overflow-y: auto;
}

.formDrawer .actions {
height: 50px;
@apply mt-auto flex items-center;
}
</style>

然后上面的修改密码的弹出框和表单就可以简化为

<form-drawer
ref="formDrawerRef"
title="修改密码"
destroyOnClose
@submit="onSubmit"
>
<el-form
ref="formRef"
:rules="rules"
:model="form"
label-width="80px"
size="small"
>
<el-form-item prop="oldpassword" label="旧密码">
<el-input
v-model="form.oldpassword"
placeholder="请输入旧密码"
></el-input>
</el-form-item>
<el-form-item prop="password" label="新密码">
<el-input
type="password"
v-model="form.password"
placeholder="请输入密码"
show-password
></el-input>
</el-form-item>
<el-form-item prop="repassword" label="确认密码">
<el-input
type="password"
v-model="form.repassword"
placeholder="请输入确认密码"
show-password
></el-input>
</el-form-item>
</el-form>
</form-drawer>
// ... 其他代码

// 引入组件
import FormDrawer from "~/components/FormDrawer.vue";

const formDrawerRef = ref(null);
// 让formRef变成响应式
const formRef = ref(null);
const onSubmit = () => {
formRef.value.validate((valid) => {
if (!valid) {
return false;
}
// 显示进度条
formDrawerRef.value.showLoading();
updatepassword(form)
.then((res) => {
toast("修改密码成功,请重新登录");
store.dispatch("logout");
// 跳转回登录页
router.push("/login");
})
.finally(() => {
// 隐藏进度条
formDrawerRef.value.hideLoading();
});
});
};

使用组合式api封装简化代码

我们可以将退出登录和修改密码的方法使用组合式api进行脱离出去

新建一个composables/useManager.js

import { ref, reactive } from 'vue'
import { logout, updatepassword } from '~/api/manager'
import { showModal, toast } from '~/composables/util'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'

export function useRepassword() {
const store = useStore()
const router = useRouter()
const formDrawerRef = ref(null)
const form = reactive({
oldpassword: '',
password: '',
repassword: '',
})

// 定义登录验证规则
// 必须和上面表单属性一样
const rules = {
oldpassword: [
{
required: true,
message: '旧密码不能为空',
trigger: 'blur',
},
],
password: [
{
required: true,
message: '新密码不能为空',
trigger: 'blur',
},
],
repassword: [
{
required: true,
message: '确认密码不能为空',
trigger: 'blur',
},
],
}
// 让formRef变成响应式
const formRef = ref(null)
const onSubmit = () => {
formRef.value.validate(valid => {
if (!valid) {
return false
}
formDrawerRef.value.showLoading()
updatepassword(form)
.then(res => {
toast('修改密码成功,请重新登录')
store.dispatch('logout')
// 跳转回登录页
router.push('/login')
})
.finally(() => {
formDrawerRef.value.hideLoading()
})
})
}

const openRePasswordForm = () => formDrawerRef.value.open()

return {
formDrawerRef,
form,
rules,
formRef,
onSubmit,
openRePasswordForm,
}
}

export function useLogout() {
// 这2个内容需要写在函数内部,否则报错
const store = useStore()
const router = useRouter()
function handleLogout() {
showModal('是否要退出登录?').then(res => {
// console.log("退出登录");
logout().finally(() => {
// 不管成功,都要到这
store.dispatch('logout')
// 跳转回登录
router.push('/login')
// 提示退出成功
toast('退出成功')
})
})
}
return {
handleLogout,
}
}

原先的FHeader.vue简化代码后

<script setup>
import { useFullscreen } from "@vueuse/core";
import FormDrawer from "~/components/FormDrawer.vue";
import { useRepassword, useLogout } from "~/composables/useManager";

// 是否全屏 全屏切换
const { isFullscreen, toggle } = useFullscreen();
const { formDrawerRef, form, rules, formRef, onSubmit, openRePasswordForm } =
useRepassword();
const { handleLogout } = useLogout();

// 刷新
const handleRefresh = () => location.reload();

const handleCommand = (c) => {
switch (c) {
case "logout":
handleLogout();
break;
case "rePassword":
// 修改密码
// showDrawer.value = true;
// formDrawerRef.value.open();
openRePasswordForm();
break;
default:
break;
}
};
</script>

侧边菜单开发-样式布局和路由跳转

我们需要使用到element-plus的菜单栏组件:文档地址https://element-plus.gitee.io/zh-CN/component/menu.html

<template>
<div class="f-menu">
<el-menu @select="handleSelect" default-active="2" class="border-0">
<template v-for="(item, index) in asideMenus" :key="index">
<el-sub-menu
v-if="item.child && item.child.length > 0"
:index="item.name"
>
<template #title>
<el-icon>
<component :is="item.icon"></component>
</el-icon>
<span>{{ item.name }}</span>
</template>
<el-menu-item
v-for="(item2, index2) in item.child"
:key="index2"
:index="item2.frontpath"
>
<el-icon>
<component :is="item2.icon"></component>
</el-icon>
<span>{{ item2.name }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="item.frontpath">
<el-icon>
<component :is="item.icon"></component>
</el-icon>
<span>{{ item.name }}</span>
</el-menu-item>
</template>
</el-menu>
</div>
</template>

<script setup>
import { useRouter } from "vue-router";
const router = useRouter();
const asideMenus = [
{
name: "后台面板",
icon: "help",
child: [
{
name: "主控台",
frontpath: "/",
icon: "home-filled",
},
],
},
{
name: "商城管理",
icon: "shopping-bag",
child: [
{
name: "商品管理",
frontpath: "/goods/list",
icon: "shopping-cart-full",
},
],
},
];

const handleSelect = (e) => {
// 二级菜单点击跳转
router.push(e);
};
</script>

<style scope>
.f-menu {
width: 250px;
top: 64px;
bottom: 0;
left: 0;
overflow: auto;
@apply shadow-md fixed bg-light-50;
}
</style>

菜单展开和收起

我们需要涉及头部组件,菜单组件以及中间布局组件的宽度的变化,所以我们需要在vuex里定义一个状态用于存储

const store = createStore({
state() {
return {
user: {}, // 默认空对象 用户信息

// 侧边宽度
asideWidth: '250px',
}
},
mutations: {
// 记录用户信息
SET_USERINFO(state, user) {
state.user = user
},
// 展开或缩起侧边
handleAsideWidth(state) {
state.asideWidth = state.asideWidth === '250px' ? '64px' : '250px'
},
},
});
  • 菜单栏默认宽度设置为250px
  • 收缩设置宽度为64px

然后我们就得对菜单组件设置动态宽度

<template>
<el-container>
<!-- 头部 -->
<el-header>
<f-header />
</el-header>
<el-container>
<el-aside :width="$store.state.asideWidth">
<f-menu />
</el-aside>
<el-main>
<f-tag-list />
<router-view></router-view>
</el-main>
</el-container>
</el-container>
</template>

<script setup>
import FHeader from "./components/FHeader.vue";
import FMenu from "./components/FMenu.vue";
import FTagList from "./components/FTagList.vue";
</script>

<style scope>
// 动画效果 收缩展开不闪
.el-aside {
transition: all 0.2s;
}
</style>

还需要对el-menu组件进行设置

<template>
<div class="f-menu" :style="{ width: $store.state.asideWidth }">
<el-menu
:collapse="isCollapse"
@select="handleSelect"
default-active="2"
class="border-0"
:collapse-transition="false"
unique-opened="true"
>
<template v-for="(item, index) in asideMenus" :key="index">
<el-sub-menu
v-if="item.child && item.child.length > 0"
:index="item.name"
>
<template #title>
<el-icon>
<component :is="item.icon"></component>
</el-icon>
<span>{{ item.name }}</span>
</template>
<el-menu-item
v-for="(item2, index2) in item.child"
:key="index2"
:index="item2.frontpath"
>
<el-icon>
<component :is="item2.icon"></component>
</el-icon>
<span>{{ item2.name }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="item.frontpath">
<el-icon>
<component :is="item.icon"></component>
</el-icon>
<span>{{ item.name }}</span>
</el-menu-item>
</template>
</el-menu>
</div>
</template>

<script setup>
import { computed } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
const router = useRouter();
const store = useStore();

// 是否折叠
const isCollapse = computed(() => !(store.state.asideWidth == "250px"));
const asideMenus = [
{
name: "后台面板",
icon: "help",
child: [
{
name: "主控台",
frontpath: "/",
icon: "home-filled",
},
],
},
{
name: "商城管理",
icon: "shopping-bag",
child: [
{
name: "商品管理",
frontpath: "/goods/list",
icon: "shopping-cart-full",
},
],
},
];

const handleSelect = (e) => {
router.push(e);
};
</script>

<style scope>
.f-menu {
transition: all 0.2s;
top: 64px;
bottom: 0;
left: 0;
overflow-y: auto;
overflow-x: hidden;
@apply shadow-md fixed bg-light-50;
}
</style>

主要内容
<div class="f-menu" :style="{ width: $store.state.asideWidth }">
<el-menu
:collapse="isCollapse"
@select="handleSelect"
default-active="2"
class="border-0"
:collapse-transition="false"
unique-opened="true"
>
  • 动态绑定宽度样式
  • 使用collapse属性来决定是否展开,下面使用isCollapse判断宽度是否是250px来返回布尔值
  • collapse-transition取消它本身的一个动画效果,会比较慢
  • unique-opened让菜单栏只能有一个点击展开

设置菜单选中和路由关联

即菜单的一个属性:default-active要和当前路由相等

<el-menu
:collapse="isCollapse"
@select="handleSelect"
:default-active="defaultActive"
class="border-0"
:collapse-transition="false"
unique-opened="true"
>

给菜单动态绑定一个属性值默认等于当前路由

我们需要使用useRoute来获取

import { computed, ref } from "vue";
import { useRouter, useRoute } from "vue-router";

const route = useRoute();

// 当前路由路径 默认选中
const defaultActive = ref(route.path);

菜单数据前后端交互

我们在前面有一个获取后台用户信息的接口,调用里面存储了菜单信息和一些权限信息,我们需要在vuex里进行存储调用

import { createStore } from 'vuex'
import { login, getInfo } from '~/api/manager'
import { setToken, removeToken } from '~/composables/auth'
// 创建一个新的 store 实例
const store = createStore({
state() {
return {
user: {}, // 默认空对象 用户信息
// 侧边宽度
asideWidth: '250px',
// 菜单数据
menus: [],
// 权限菜单相关数据
ruleNames: [],
}
},
mutations: {
// ... 其他代码

SET_MENUS(state, menus) {
state.menus = menus
},
SET_RULENAMES(state, ruleNames) {
state.ruleNames = ruleNames
},
},
// 异步
actions: {
// 获取当前登录用户信息
getAdminUserInfo({ commit }) {
return new Promise((resolve, reject) => {
getInfo()
.then(res => {
commit('SET_USERINFO', res)
commit('SET_MENUS', res.menus)
commit('SET_RULENAMES', res.ruleNames)
resolve(res)
})
.catch(err => reject(err))
})
},
// ... 其他代码
},
})

export default store

然后需要将我们菜单组件里的设置的假的菜单数组变成从vuex里获取菜单数据

import { useStore } from "vuex";
const store = useStore();

const asideMenus = computed(() => store.state.menus);

同时我们将菜单栏的一个下拉的宽度的设置掉,否则比较难看

.f-menu::-webkit-scrollbar {
width: 0px;
}

根据菜单动态添加路由

我们需要拆分出2个路由数组,一个是默认路由,比如首页、登录、404页面等,

别的则是根据菜单动态添加的。

添加嵌套路由文档:https://router.vuejs.org/zh/guide/advanced/dynamic-routing.html#%E6%B7%BB%E5%8A%A0%E5%B5%8C%E5%A5%97%E8%B7%AF%E7%94%B1

就是需要指定一个父级路由的name

router/index.js

import { createRouter, createWebHashHistory } from 'vue-router'
import Admin from '~/layouts/admin.vue'
import Index from '~/pages/index.vue'
import Login from '~/pages/login.vue'
import NotFound from '~/pages/404.vue'
import GoodList from '~/pages/goods/list.vue'
import CategoryList from '~/pages/category/list.vue'

// 默认路由 所有用户共享
const routes = [
{
path: '/',
component: Admin,
name: 'admin',
},
{
path: '/login',
component: Login,
meta: {
title: '登录页',
},
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound,
},
]

// 动态路由,用于匹配菜单动态添加路由
const asyncRoutes = [
{
path: '/',
component: Index,
name: '/',
meta: {
title: '后台首页',
},
},
{
path: '/goods/list',
name: '/goods/list',
component: GoodList,
meta: {
title: '商品管理',
},
},
{
path: '/category/list',
name: '/category/list',
component: CategoryList,
meta: {
title: '分类列表',
},
},
]

// const routes = [
// {
// path: '/',
// component: Admin,
// // 子路由
// children: [
// {
// path: '/',
// component: Index,
// meta: {
// title: '后台首页',
// },
// },
// {
// path: '/goods/list',
// component: GoodList,
// meta: {
// title: '商品管理',
// },
// },
// {
// path: '/category/list',
// component: CategoryList,
// meta: {
// title: '分类列表',
// },
// },
// ],
// },
// {
// path: '/login',
// component: Login,
// meta: {
// title: '登录页',
// },
// },
// {
// path: '/:pathMatch(.*)*',
// name: 'NotFound',
// component: NotFound,
// },
// ]

export const router = createRouter({
history: createWebHashHistory(),
routes,
})

// 动态添加路由的方法
// 接收后端的一个菜单
export function addRoutes(menus) {
// 是否有新的路由
let hasNewRoutes = false
const findAndAddRoutesByMenus = arr => {
arr.forEach(e => {
// e 菜单的每个对象
// e.frontpath 菜单路径
let item = asyncRoutes.find(o => o.path == e.frontpath)
// 判断是否相同 且检查路由是否存在 传入的是一个name值 先设置和path一样
if (item && !router.hasRoute(item.path)) {
router.addRoute('admin', item)
hasNewRoutes = true
}
// 是否存在子菜单
if (e.child && e.child.length > 0) {
// 递归调用
findAndAddRoutesByMenus(e.child)
}
})
}
// 外部执行一下
findAndAddRoutesByMenus(menus)

// 查看现有路由
console.log(router.getRoutes())

return hasNewRoutes
}

permission.js

// 处理权限相关的内容

import { router, addRoutes } from './router'
import { getToken } from '~/composables/auth'
import { toast, showFullLoading, hideFullLoading } from '~/composables/util'
import store from './store'

// 全局前置守卫
router.beforeEach(async (to, from, next) => {
// 显示loading
showFullLoading()

// console.log('全局前置守卫')
const token = getToken()
// 没有登录强制跳转回登录页
if (!token && to.path != '/login') {
toast('请先登录', 'error')
return next({ path: '/login' })
}
// 防止重复登录判断
if (token && to.path == '/login') {
toast('请务重复登录', 'error')
// 从哪里来就从哪里去
return next({ path: from.path ? from.path : '/' })
}
let hasNewRoutes = false
// 如果用户登录了就自动获取用户信息,并存储在vuex中
if (token) {
// 异步 resolve(res) 的res 进行解构出菜单
let { menus } = await store.dispatch('getAdminUserInfo')
// 动态添加路由
hasNewRoutes = addRoutes(menus)
}
// 设置页面标题
// console.log(to.meta.title) // 拿到标题
let title = (to.meta.title ? to.meta.title : '') + '-无解的管理后台'
document.title = title
// 如果有新的路由走指定的 否则直接 next()
hasNewRoutes ? next(to.fullPath) : next() // 放行
})

// 全局后置守卫
router.afterEach((to, from) => hideFullLoading())

标签导航组件实现

样式布局

使用element-plus的动态标签:https://element-plus.gitee.io/zh-CN/component/tabs.html

https://element-plus.gitee.io/zh-CN/component/tabs.html#%E8%87%AA%E5%AE%9A%E4%B9%89%E5%A2%9E%E5%8A%A0%E6%A0%87%E7%AD%BE%E9%A1%B5%E8%A7%A6%E5%8F%91%E5%99%A8

<template>
<div class="f-tag-list" :style="{ left: $store.state.asideWidth }">
<el-tabs
v-model="editableTabsValue"
type="card"
class="flex-1"
closable
@tab-remove="removeTab"
style="min-width: 100px"
>
<el-tab-pane
v-for="item in editableTabs"
:key="item.name"
:label="item.title"
:name="item.name"
>
</el-tab-pane>
</el-tabs>
<span class="tag-btn">
<el-dropdown>
<span class="el-dropdown-link">
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>Action 1</el-dropdown-item>
<el-dropdown-item>Action 2</el-dropdown-item>
<el-dropdown-item>Action 3</el-dropdown-item>
<el-dropdown-item disabled>Action 4</el-dropdown-item>
<el-dropdown-item divided>Action 5</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</span>
</div>
</template>

<script setup>
import { ref } from "vue";

let tabIndex = 2;
const editableTabsValue = ref("2");
const editableTabs = ref([
{
title: "Tab 1",
name: "1",
content: "Tab 1 content",
},
{
title: "Tab 2",
name: "2",
content: "Tab 2 content",
},
{
title: "Tab 2",
name: "2",
content: "Tab 2 content",
},
{
title: "Tab 2",
name: "2",
content: "Tab 2 content",
},
{
title: "Tab 2",
name: "2",
content: "Tab 2 content",
},
{
title: "Tab 2",
name: "2",
content: "Tab 2 content",
},
{
title: "Tab 2",
name: "2",
content: "Tab 2 content",
},
{
title: "Tab 2",
name: "2",
content: "Tab 2 content",
},
{
title: "Tab 2",
name: "2",
content: "Tab 2 content",
},
{
title: "Tab 2",
name: "2",
content: "Tab 2 content",
},
]);

const addTab = (targetName) => {
const newTabName = `${++tabIndex}`;
editableTabs.value.push({
title: "New Tab",
name: newTabName,
content: "New Tab content",
});
editableTabsValue.value = newTabName;
};
const removeTab = (targetName) => {
const tabs = editableTabs.value;
let activeName = editableTabsValue.value;
if (activeName === targetName) {
tabs.forEach((tab, index) => {
if (tab.name === targetName) {
const nextTab = tabs[index + 1] || tabs[index - 1];
if (nextTab) {
activeName = nextTab.name;
}
}
});
}

editableTabsValue.value = activeName;
editableTabs.value = tabs.filter((tab) => tab.name !== targetName);
};
</script>

<style scoped>
.f-tag-list {
top: 65px;
right: 0;
height: 44px;
@apply fixed bg-gray-100 flex items-center px-2;
z-index: 100;
}
.tag-btn {
@apply bg-white rounded ml-auto flex items-center justify-center px-2;
height: 32px;
}
:deep(.el-tabs__header) {
@apply mb-0;
}
:deep(.el-tabs__nav) {
border: 0 !important;
}
:deep(.el-tabs__item) {
border: 0 !important;
@apply bg-white mx-1 rounded;
height: 32px;
line-height: 32px;
}
:deep(.el-tabs__nav-next),
:deep(.el-tabs__nav-prev) {
line-height: 32px;
height: 32px;
}
:deep(.is-disabled) {
cursor: not-allowed;
@apply text-gray-300;
}
</style>

效果图

同步路由和存储

<template>
<div class="f-tag-list" :style="{ left: $store.state.asideWidth }">
<el-tabs
v-model="activeTab"
type="card"
class="flex-1"
@tab-remove="removeTab"
style="min-width: 100px"
@tab-change="changeTab"
>
<!-- 如果是首页不显示关闭按钮 -->
<el-tab-pane
v-for="item in tabList"
:key="item.path"
:label="item.title"
:name="item.path"
:closable="item.path != '/'"
>
</el-tab-pane>
</el-tabs>
<span class="tag-btn">
<el-dropdown>
<span class="el-dropdown-link">
<el-icon>
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>Action 1</el-dropdown-item>
<el-dropdown-item>Action 2</el-dropdown-item>
<el-dropdown-item>Action 3</el-dropdown-item>
<el-dropdown-item disabled>Action 4</el-dropdown-item>
<el-dropdown-item divided>Action 5</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</span>
</div>
<div style="height: 44px"></div>
</template>

<script setup>
import { ref } from "vue";
import { useRoute, onBeforeRouteUpdate, useRouter } from "vue-router";
import { useCookies } from "@vueuse/integrations/useCookies";

const route = useRoute();
const cookie = useCookies();
const router = useRouter();

const activeTab = ref(route.path);
const tabList = ref([
{
title: "后台首页",
path: "/",
},
{
title: "商城管理",
path: "/goods/list",
},
]);

const changeTab = (t) => {
activeTab.value = t;
router.push(t);
};

function addTab(tab) {
let notTab = tabList.value.findIndex((t) => t.path == tab.path) === -1;
if (notTab) {
tabList.value.push(tab);
}
cookie.set("tabList", tabList.value);
}

// 初始化标签导航列表
function initTabList() {
let tabs = cookie.get("tabList");
if (tabs) {
tabList.value = tabs;
}
}

initTabList();

onBeforeRouteUpdate((to, from) => {
// 设置tab激活
activeTab.value = to.path;
// console.log(to, from);
addTab({
title: to.meta.title,
path: to.path,
});
});

const removeTab = (targetName) => {};
</script>

<style scoped>
.f-tag-list {
top: 65px;
right: 0;
height: 44px;
@apply fixed bg-gray-100 flex items-center px-2;
z-index: 100;
}
.tag-btn {
@apply bg-white rounded ml-auto flex items-center justify-center px-2;
height: 32px;
}
:deep(.el-tabs__header) {
border: 0 !important;
@apply mb-0;
}
:deep(.el-tabs__nav) {
border: 0 !important;
}
:deep(.el-tabs__item) {
border: 0 !important;
@apply bg-white mx-1 rounded;
height: 32px;
line-height: 32px;
}
:deep(.el-tabs__nav-next),
:deep(.el-tabs__nav-prev) {
line-height: 32px;
height: 32px;
}
:deep(.is-disabled) {
cursor: not-allowed;
@apply text-gray-300;
}
</style>

关闭当前标签处理

<template>
<div class="f-tag-list" :style="{ left: $store.state.asideWidth }">
<el-tabs
v-model="activeTab"
type="card"
class="flex-1"
@tab-remove="removeTab"
style="min-width: 100px"
@tab-change="changeTab"
>
<!-- 如果是首页不显示关闭按钮 -->
<el-tab-pane
v-for="item in tabList"
:key="item.path"
:label="item.title"
:name="item.path"
:closable="item.path != '/'"
>
</el-tab-pane>
</el-tabs>
<span class="tag-btn">
<el-dropdown>
<span class="el-dropdown-link">
<el-icon>
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>Action 1</el-dropdown-item>
<el-dropdown-item>Action 2</el-dropdown-item>
<el-dropdown-item>Action 3</el-dropdown-item>
<el-dropdown-item disabled>Action 4</el-dropdown-item>
<el-dropdown-item divided>Action 5</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</span>
</div>
<div style="height: 44px"></div>
</template>

<script setup>
import { ref } from "vue";
import { useRoute, onBeforeRouteUpdate, useRouter } from "vue-router";
import { useCookies } from "@vueuse/integrations/useCookies";

const route = useRoute();
const cookie = useCookies();
const router = useRouter();

const activeTab = ref(route.path);
const tabList = ref([
{
title: "后台首页",
path: "/",
},
{
title: "商城管理",
path: "/goods/list",
},
]);

const changeTab = (t) => {
activeTab.value = t;
router.push(t);
};

function addTab(tab) {
let notTab = tabList.value.findIndex((t) => t.path == tab.path) === -1;
if (notTab) {
tabList.value.push(tab);
}
cookie.set("tabList", tabList.value);
}

// 初始化标签导航列表
function initTabList() {
let tabs = cookie.get("tabList");
if (tabs) {
tabList.value = tabs;
}
}

initTabList();

onBeforeRouteUpdate((to, from) => {
// 设置tab激活
activeTab.value = to.path;
// console.log(to, from);
addTab({
title: to.meta.title,
path: to.path,
});
});

const removeTab = (t) => {
// console.log(t);
// 1. 判断关闭的是否是当前激活的
let tabs = tabList.value;
let a = activeTab.value;
if (a == t) {
tabs.forEach((tab, index) => {
if (tab.path == t) {
const nextTab = tabs[index + 1] || tabs[index - 1]; // 如果没有下一个就去拿上一个
if (nextTab) {
// 设置下一个激活的值
a = nextTab.path;
}
}
});
}
activeTab.value = a;
// 只有不等于当前关闭的留下来
tabList.value = tabList.value.filter((tab) => tab.path != t);
// 更新存储的cookie
cookie.set("tabList", tabList.value);
};
</script>

<style scoped>
.f-tag-list {
top: 65px;
right: 0;
height: 44px;
@apply fixed bg-gray-100 flex items-center px-2;
z-index: 100;
}
.tag-btn {
@apply bg-white rounded ml-auto flex items-center justify-center px-2;
height: 32px;
}
:deep(.el-tabs__header) {
border: 0 !important;
@apply mb-0;
}
:deep(.el-tabs__nav) {
border: 0 !important;
}
:deep(.el-tabs__item) {
border: 0 !important;
@apply bg-white mx-1 rounded;
height: 32px;
line-height: 32px;
}
:deep(.el-tabs__nav-next),
:deep(.el-tabs__nav-prev) {
line-height: 32px;
height: 32px;
}
:deep(.is-disabled) {
cursor: not-allowed;
@apply text-gray-300;
}
</style>

实现关闭全部标签功能

我们使用组合式api来简化页面的script部分

<script setup>
import { useTabList } from "~/composables/useTabList.js";
const { activeTab, tabList, changeTab, removeTab, handleClose } = useTabList();
</script>
import { ref } from 'vue'
import { useRoute, onBeforeRouteUpdate, useRouter } from 'vue-router'
import { useCookies } from '@vueuse/integrations/useCookies'

export function useTabList() {
const route = useRoute()
const cookie = useCookies()
const router = useRouter()

const activeTab = ref(route.path)
const tabList = ref([
{
title: '后台首页',
path: '/',
},
{
title: '商城管理',
path: '/goods/list',
},
])

const changeTab = t => {
activeTab.value = t
router.push(t)
}

function addTab(tab) {
let notTab = tabList.value.findIndex(t => t.path == tab.path) === -1
if (notTab) {
tabList.value.push(tab)
}
cookie.set('tabList', tabList.value)
}

// 初始化标签导航列表
function initTabList() {
let tabs = cookie.get('tabList')
if (tabs) {
tabList.value = tabs
}
}

initTabList()

onBeforeRouteUpdate((to, from) => {
// 设置tab激活
activeTab.value = to.path
// console.log(to, from);
addTab({
title: to.meta.title,
path: to.path,
})
})

const removeTab = t => {
// console.log(t);
// 1. 判断关闭的是否是当前激活的
let tabs = tabList.value
let a = activeTab.value
if (a == t) {
tabs.forEach((tab, index) => {
if (tab.path == t) {
const nextTab = tabs[index + 1] || tabs[index - 1] // 如果没有下一个就去拿上一个
if (nextTab) {
// 设置下一个激活的值
a = nextTab.path
}
}
})
}
activeTab.value = a
// 只有不等于当前关闭的留下来
tabList.value = tabList.value.filter(tab => tab.path != t)
// 更新存储的cookie
cookie.set('tabList', tabList.value)
}

const handleClose = c => {
console.log(c)
if (c == 'clearAll') {
// 清除所有,切换回首页
activeTab.value = '/'
// 过滤只想剩下首页
tabList.value = [
{
title: '后台首页',
paht: '/',
},
]
} else if (c == 'clearOther') {
// 过滤只剩下首页和当前激活
tabList.value = tabList.value.filter(tab => tab.path == '/' || tab.path == activeTab.value)
}
cookie.set('tabList', tabList.value)
}

return {
activeTab,
tabList,
changeTab,
removeTab,
handleClose,
}
}

优化加载菜单的速度

这里会发现,点击一个菜单,会触发2次调用getInfo接口,就会导致页面加载比较慢。

我们在全局前置守卫里进行设置

// 防止重复加载 getinfo
let hasGetInfo = false

// 全局前置守卫
router.beforeEach(async (to, from, next) => {
// 显示loading
showFullLoading()

// console.log('全局前置守卫')
const token = getToken()
// 没有登录强制跳转回登录页
if (!token && to.path != '/login') {
toast('请先登录', 'error')
return next({ path: '/login' })
}
// 防止重复登录判断
if (token && to.path == '/login') {
toast('请务重复登录', 'error')
// 从哪里来就从哪里去
return next({ path: from.path ? from.path : '/' })
}
let hasNewRoutes = false
// 如果用户登录了就自动获取用户信息,并存储在vuex中
if (token && !hasGetInfo) {
// 异步 resolve(res) 的res 进行解构出菜单
let { menus } = await store.dispatch('getAdminUserInfo')
hasGetInfo = true
// 动态添加路由
hasNewRoutes = addRoutes(menus)
}
// 设置页面标题
// console.log(to.meta.title) // 拿到标题
let title = (to.meta.title ? to.meta.title : '') + '-无解的管理后台'
document.title = title
// 如果有新的路由走指定的 否则直接 next()
hasNewRoutes ? next(to.fullPath) : next() // 放行
})

使用keep-alive页面缓存

vue3相关文档地址:https://cn.vuejs.org/guide/built-ins/keep-alive.html#keepalive

<template>
<el-container>
<!-- 头部 -->
<el-header>
<f-header />
</el-header>
<el-container>
<el-aside :width="$store.state.asideWidth">
<f-menu />
</el-aside>
<el-main>
<f-tag-list />
<router-view v-slot="{ Component }">
<!-- 缓存10个 -->
<keep-alive :max="10">
<component :is="Component"></component>
</keep-alive>
</router-view>
</el-main>
</el-container>
</el-container>
</template>

使用transition全局过渡动画

<template>
<el-container>
<!-- 头部 -->
<el-header>
<f-header />
</el-header>
<el-container>
<el-aside :width="$store.state.asideWidth">
<f-menu />
</el-aside>
<el-main>
<f-tag-list />
<router-view v-slot="{ Component }">
<transition name="fade">
<!-- 缓存10-->
<keep-alive :max="10">
<component :is="Component"></component>
</keep-alive>
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>

<script setup>
import FHeader from "./components/FHeader.vue";
import FMenu from "./components/FMenu.vue";
import FTagList from "./components/FTagList.vue";
</script>

<style scoped>
.el-aside {
transition: all 0.2s;
}

/* 进入之前 */
.fade-enter-from {
opacity: 0;
}
/* 进入之后 */
.fade-enter-to {
opacity: 1;
}
/* 离开之前 */
.fade-leave-from {
opacity: 1;
}
/* 离开之后 */
.fade-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s;
}
/* 进入动画延迟 */
.fade-enter-active {
transition-delay: 0.3s;
}
</style>

注意

如果使用transition,每个页面的根节点只能有一个,也就是说template下的div只能有一个,否则就会失效,而且console页面还会警告。