后台布局开发
后台主布局实现
主要使用elemnt-plus
的Container
容器:文档地址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
- Yarn
npm install @vueuse/core
yarn add @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页面等,
别的则是根据菜单动态添加的。
就是需要指定一个父级路由的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
<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
页面还会警告。