后台布局开发
后台主布局实现
主要使用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页面还会警告。