一、最外面的框架。
左侧菜单栏Sidebar + 【右侧上方布局:上方组件navBar(折叠展开图标+面包屑)+ 打开的页面标签组件tagsView】 + 右侧下方的主要内容(放不同的组件)

<template>
    <div :class="classObj">
        <div v-if="device === 'mobile' && sidebar.opened" @click="handleClickOutside" />

        <!-- 左边侧边栏组件 -->
        <Sidebar></Sidebar>
        <!-- 右侧布局 -->
        <div :class="{ hasTagsView: needTagsView }">
            <!-- 上面布局 -->
            <div :class="{'fixed-header': fixedHeader} ">
                <!-- 上方布局组件(包括面包屑) -->
                <navbar />
                <!-- 打开的页面标签 -->
                <tags-view v-if="needTagsView" />
            </div>
            <!-- 主要内容---放不同的组件 -->
            <app-main/>
        </div>

    </div>
</template>

<script setup lang="ts">
import { computed, watchEffect } from "vue";
import useStore from '@/store';
import Sidebar from './components/Sidebar/index.vue';
import { AppMain, Navbar, Settings, TagsView } from './components/index';

import { useWindowSize } from '@vueuse/core'
// 使用useWindowSize获取窗口大小
const { width } = useWindowSize();
const WIDTH = 992;

const { app, setting } =  useStore();
const sidebar = computed(() => app.Sidebar);
const device = computed(() => app.device)
const needTagsView = computed(() => setting.tagsView)
const fixedHeader = computed(() => setting.fixedHeader)

const classObj = computed(() => {
    hideSidebar: !sidebar.value.opened,
    openSidebar: sidebar.value.opened,
    withoutAnimation: sidebar.value.withoutAnimation,
    mobile: device.value === 'mobile',

})


//点击使侧边栏关闭
function handleClickOutside() {
   app.closeSideBar(false);
}


watchEffect(() => {
    if (width.value < WIDTH) {
        app.toggleDevice('mobile');
        app.closeSideBar(true);
    } else {
        app.toggleDevice('desktop');
    }
})


</script>

二、sideBar 左侧菜单:

<!-- 侧边菜单组件 -->
<template>
    <div :class="{ 'has-logo': showLogo}">
        <!-- logo组件---点击这个图标使左侧菜单折叠/展开 -->
        <logo v-if="showLogo" :collapse="isCollapse"/>

        <el-scrollbar wrap-class="scrollbar-wrapper">
            <!-- default-active	页面加载时默认激活菜单的 index;    mode: 菜单展示模式 -->
            <!-- collapse---折叠      unique-opened: 是否只保持一个子菜单的展开   collapse-transition: 是否开启折叠动画-->
            <el-menu
                 :default-active="activeMenu"
                 :collapse="isCollapse"
                 :background-color="variables.menuBg"
                 :text-color="variables.menuText"
                 :active-text-color="variables.menuActiveText"
                 :unique-opened="false"
                 :collapse-transition="false"
                 mode="vertical"
            >
               <!-- 侧边菜单项组件 -->
               <sidebar-item
                  v-for="route in routes"
                  :item="route"
                  :key="route.path"
                  :base-path="route.path"
                  :is-collapse="isCollapse"
                />

            </el-menu>
        </el-scrollbar>

    </div>
</template>

<script lang="ts" setup>
import SidebarItem from './SidebarItem.vue';
import Logo from './Logo.vue';
//引入样式文件
import variables from '@/styles/variables.module.scss';
import { useRoute } from 'vue-router';

import useStore from '@/store';
import { computed } from 'vue';

const { permission, setting, app } = useStore();


//useRoute相当于以前的this.$route
const route = useRoute();
const routes = computed(() => permission.routes)

// 是否显示logo
const showLogo = computed(() => setting.sidebarLogo)
// 左侧菜单折叠还是展开
const isCollapse = computed(() => !app.sidebar.opened)




//菜单加载时默认激活菜单的index
const activeMenu = computed(() => {
   // 拿到路径里面的meta,path
   const { meta, path } = route;
   if (meta.activeMenu) {
    return meta.activeMenu as string;
   }
   return path;
})
</script>

2.1、logo组件—点击这个图标使左侧菜单折叠/展开

<!-- logo组件: 点击这个图标使侧边菜单展开/折叠 -->
<template>
    <div :class="{ collapse: isCollapse }">
        <transition name="sidebarLogoFade" >
            <!-- 点击时跳转到首页 -->
            <!-- collapse--折叠; expand--展开 -->
            <!-- router-link 元素中的 key 属性用于为每个元素赋予唯一标识,这对于元素的高效呈现和重新排序很重要。 -->
            <!-- 如果没有 key 属性,如果您更改列表项的顺序,Vue.js 可能会不必要地重新渲染所有组件,因为它无法识别哪个顺序对应于哪个组件 -->
            <!-- 如果你给每个组件一个唯一的键值,那么 Vue.js 可以有效地识别哪些组件要重用,哪些要重新渲染。 -->
            <router-link to="/" v-if="collapse" key="collapse" >
                <h1>{{ title }}</h1>
            </router-link>

            <router-link v-else to="/" key="expand">
                <h1>{{ title }}</h1>
            </router-link>
        </transition>

    </div>

</template>

<script lang="ts" setup>
import { reactive , toRefs, ref, } from "vue"

//父传子--从父组件拿到collapse,看是否折叠
const props = defineProps({
    collapse: {
        type: Boolean,
        required: true,
    }

})
const title = ref('CE-LINK 智能家居');
const state = reactive({
    isCollapse: props.collapse
})
const { isCollapse } = toRefs(state)

</script>

<style lang="ts" scoped>
.sidebarLogoFade-enter-active {
    transition: opacity 1.5s;
}

.sidebarLogoFade-enter,
.sidebarLogoFade-leave-to {
    opacity: 0;
}
</style> 

2.2、菜单项sidebarItem:

<!-- 侧边菜单项组件 -->
<template>
    <div v-if="!item.meta || !item.meta.hidden">
        <!-- 没有子菜单 -->
        <template v-if="hasOneShowingChild(item.children, item) &&
                  (!onlyOneChild.children || onlyOneChild.noShowingChildren) &&
                  (!item.meta || !item.meta.alwaysShow)"
        >
           <!-- 组件:判断是外部还是内部链接 -->
           <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">
              <el-menu-item
                  :index="resolvePath(onlyOneChild.path)"
              >
                <svg-icon v-if="onlyOneChild.meta && onlyOneChild.meta.icon" :icon-class="onlyOneChild.meta.icon"/>
                <template #title>
                   {{ onlyOneChild.meta.title }}
                </template>

              </el-menu-item>
           </app-link>
        </template>

        <!-- 有子菜单 -->
        <el-sub-menu v-else :index="resolvePath(item.path)" popper-append-to-body >
            <template #title>
                <svg-icon
                   v-if="item.meta && item.meta.icon"
                   :icon-class="item.meta.icon"
                />
                <span v-if="item.meta && item.meta.title">{{ generateTitle(item.meta.title) }}</span>               
            </template>

            <!-- 重复调用这个组件 -->
            <sidebar-item
               v-for="child in item.children"
               :key="child.path"
               :item="child"
               :is-nest="true"
               :base-path="resolvePath(child.path)"
            />



        </el-sub-menu>

    </div>
</template>

<script lang="ts" setup>
import { generateTitle } from '@/utils/i18n';
// import { isExternal } from '@/utils/validate';
import appLink from '@/components/SideBar/Link.vue'


//父传子
const props = defineProps({
    //父组件传过来的routes里的每个route
    item: {
        type: Object,
        required: true,
    },
    isNest: {
        type:Boolean,
        required: false
    },
    basePath: {
        type: String,
        required: true,
    }
})

//没有子菜单
const onlyOneChild = ref();

// 菜单项没有子菜单     
// 此函数用于确定父对象是否只有一个“显示”子对象(即未隐藏的子对象)并相应地设置 onlyOneChild 值。
function hasOneShowingChild(children = [] as any, parent: any) {
    if(!children) {
        children = [];
    }
    const showingChildren = children.filter((item: any) => {
        if (item.meta && item.meta.hidden) {
            return false;
        } else {
            onlyOneChild.value = item;
            return true;
        }
    });

    if(showingChildren.length === 1) {
        return true;
    }
    if(showingChildren.length === 0) {
        onlyOneChild.value = { ...parent, path: '', noShowingChildren: true };
        return true;
    }
    return false;
}

// 判断是否是外部链接
// 判断给定的 path 是否以 https://、http://、mailto: 或者 tel: 开头。如果是的话,就认为是一个外部链接,返回 true;否则不是,返回 false。
function isExternal(path: string) {
    const isExternal = /^(https?:|http?:|mailto:|tel:)/.test(path);
    return isExternal;
}
//解析路由路径,返回一个完整的路径
function resolvePath(routePath: string) {
    if(isExternal(routePath)) {
        return routePath;
    }
    // 如果routePath不是外部链接,则会判断props.basePath是否是外部链接,如果是,则返回props.basePath。
    if(isExternal(props.basePath)) {
        return props.basePath;
    }
    //如果他们两个都不是外部链接,会使用Node.js内置的path.resolve方法将他们(多个路径)拼接成一个完整的路径,并返回这个路径
    return path.resolve(props.basePath, routePath)
}
</script>

2.2.1 、 左侧菜单的sidebarItem里的link组件—判断是内部链接还是外部链接

<template>
    <!-- 是外部链接 -->
    <a :href="to" v-if="isExternal(to)" target="_blank" rel="noopener">
        <slot/>
    </a>
    <!-- 是内部链接 -->
    <div v-else @click="push">
        <slot/>
    </div>


</template>

<script lang="ts">
import { computed, defineComponent } from "vue";
import { useRouter } from "vue-router";
import SidebarItem from "./SidebarItem.vue";
import useStore from '@/store';

const { app } = useStore();
//通过computed拿到store中的数据
const device = computed(() => app.device)
const sidebar = computed(() => app.sidebar)


//检测是外部链接的方法
export function isExternal(path: string) {
    const isExternal = /^(https?:|http?:|mailto:|tel:)/.test(path);
    return isExternal;
}


export default defineComponent({
    props: {
        //从父组件获取要跳转的外部链接
        to: {
            type: String,
            required: true,
        }
    },
    setup(props) {
        const router = useRouter();
        //点击的是内部链接时
        const push = () => {
            if(device.value === 'mobile' && sidebar.value.opened == true) {
                 app.closeSideBar(false);
            }
            //路由数组里添加路径
            router.push(props.to).catch((err) => {
                 console.log(err);
            })
        }

        return {
            push,
            isExternal

        }

    }
})
</script>

三、上方布局组件navBar

<!-- 上方布局组件 -->
<template>
    <div>
        <!-- 点击图标使左侧菜单打开关闭组件 -->
        <!-- toggleClick: 父传子,子组件中toggleClick触发父组件的toggleSideBar方法 ;isActive:父传子的属性-->
        <hamburger  @toggleClick="toggleSideBar"  :is-active="sidebar.opened" id="hamburger-container" class="hamburger-container"/>

        <!-- 面包屑组件 -->
        <breadcrumb  id="breadcrumb-container" class="breadcrumb-container" />

        <!-- 右侧按钮菜单 -->
        <div >
            <template v-if="device !== 'mobile'">
                <!-- 点击全局展示组件 -->
                <screenfull  id="screenfull" class="right-menu-item hover-effect" ></screenfull>
                <!-- 布局大小组件 -->
                <el-tooltip content="布局大小" effect="dark" placement="bottom">
                    <size-select id="size-select" class="right-menu-item hover-effect"></size-select>
                </el-tooltip>
                <!-- 语言切换组件 -->
                <lang-select></lang-select>               
            </template>

            <el-dropdown trigger="click">
                <div>
                    <img :src="avatar" style="object-fit: cover;">
                    <!-- 向下的图标 -->
                    <CaretBottom style="width: 0.6em; height: 0.6em; margin-left: 5px"/>
                </div>
                <!-- 下拉的内容 -->
                <template #dropdown>
                    <el-dropdown-menu>
                        <router-link to="/profile/index">
                            <el-dropdown-item>个人中心</el-dropdown-item>
                        </router-link>

                        <router-link>
                            <el-dropdown-item to="/">首页</el-dropdown-item>
                        </router-link>
                        <!-- divided	是否显示分隔符 -->
                        <el-dropdown-item @click="logout" divided>退出登录</el-dropdown-item>
                    </el-dropdown-menu>                   
                </template>
            </el-dropdown>

        </div>

      
    </div>
</template>

<script>
import Hamburger from 'components/Hamburger/index.vue'
import Breadcrumb from 'components/Breadcrumb/index.vue'
import SizeSelect from '@/components/SizeSelect/index.vue';
import { computed } from 'vue';
//图标依赖
import { CaretBottom } from '@element-plus/icons-vue'




const { app,user,tagsView } = useStore();
const device = computed(() => app.device);
const avatar = computed(() => user.avatar);
const sidebar = computed(() => app.sidebar);





function logout() {
    ElMessageBox.confirm('退出登录', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning',
    }).then(() => {
        user.logout().then(() => {
            tagsView.delAllViews();
        })
        .then(() => {
            router.push(`/login?redirect=${route.fullPath}`);
        })
    })


}


//让左侧菜单展开折叠的方法
function toggleSideBar() {
    app.toggleSideBar();
}
</script>

3.1、点击图标使左侧菜单打开关闭组件–hamburger

<template>
  <div style="padding: 0 15px" @click="toggleClick">
    <svg :class="{ 'is-active': isActive }" class="hamburger" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"
      width="64" height="64">
      <path
        d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
    </svg>
  </div>
</template>

<script setup lang="ts">

defineProps({
  isActive: {
    required: true,
    type: Boolean,
    default: false
  },
})

const emit = defineEmits(['toggleClick']);

function toggleClick() {
  emit('toggleClick')
}

</script>

<style scoped>
.hamburger {
  display: inline-block;
  vertical-align: middle;
  width: 20px;
  height: 20px;
}

.hamburger.is-active {
  transform: rotate(180deg);
}
</style>

3.2、面包屑组件–breadcrumb

<template>
  <el-breadcrumb class="app-breadcrumb" separator-class="el-icon-arrow-right">
    <transition-group name="breadcrumb">
      <el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path">
        <span
          v-if="
            item.redirect === 'noredirect' || index === breadcrumbs.length - 1
          "
          class="no-redirect"
          >{{ generateTitle(item.meta.title) }}</span
        >
        <a v-else @click.prevent="handleLink(item)">
          {{ generateTitle(item.meta.title) }}
        </a>
      </el-breadcrumb-item>
    </transition-group>
  </el-breadcrumb>
</template>

<script setup lang="ts">
import { onBeforeMount, ref, watch } from 'vue';
import { useRoute, RouteLocationMatched } from 'vue-router';
import { compile } from 'path-to-regexp';
import router from '@/router';
import { generateTitle } from '@/utils/i18n';

const currentRoute = useRoute();
const pathCompile = (path: string) => {
  const { params } = currentRoute;
  const toPath = compile(path);
  return toPath(params);
};

const breadcrumbs = ref([] as Array<RouteLocationMatched>);

function getBreadcrumb() {
  let matched = currentRoute.matched.filter(
    (item) => item.meta && item.meta.title
  );
  const first = matched[0];
  if (!isDashboard(first)) {
    matched = [
      { path: '/dashboard', meta: { title: 'dashboard' } } as any,
    ].concat(matched);
  }
  breadcrumbs.value = matched.filter((item) => {
    return item.meta && item.meta.title && item.meta.breadcrumb !== false;
  });
}

function isDashboard(route: RouteLocationMatched) {
  const name = route && route.name;
  if (!name) {
    return false;
  }
  return (
    name.toString().trim().toLocaleLowerCase() ===
    'Dashboard'.toLocaleLowerCase()
  );
}

function handleLink(item: any) {
  const { redirect, path } = item;
  if (redirect) {
    router.push(redirect).catch((err) => {
      console.warn(err);
    });
    return;
  }
  router.push(pathCompile(path)).catch((err) => {
    console.warn(err);
  });
}

watch(
  () => currentRoute.path,
  (path) => {
    if (path.startsWith('/redirect/')) {
      return;
    }
    getBreadcrumb();
  }
);

onBeforeMount(() => {
  getBreadcrumb();
});
</script>

<style lang="scss" scoped>
.el-breadcrumb__inner,
.el-breadcrumb__inner a {
  font-weight: 400 !important;
}

.app-breadcrumb.el-breadcrumb {
  display: inline-block;
  font-size: 14px;
  line-height: 50px;
  margin-left: 8px;

  .no-redirect {
    color: #97a8be;
    cursor: text;
  }
}
</style>

3.3、打开的页面标签–TagsView

<!-- 上方所点菜单标签展示组件---点哪个出现哪个标签 -->
<template>
    <div>
        <!-- 滚动条组件 -->
        <scroll-pane ref="scrollPaneRef" @scroll="handleScroll" class="tags-view__wrapper">
            <!-- 在<router-link>组件中添加一个名为data-path的属性 -->
            <!-- click.middle是<router-link>的一个修饰符,它指定了当用户使用中间鼠标按钮(通常是滚轮)单击链接时应该发生什么 -->
            <!-- contextmenu.prevent: 用于阻止浏览器默认的上下文菜单事件---即当右键单击router-link元素时,不会出现默认的上下文菜单 -->
            <router-link v-for="tag in visitedViews" :to="{ path: tag.path, query: tag.query }" :key="tag.path"
                :data-path="tag.path" :class="isActive(tag) ? 'active' : ''" class="tags-view__item"
                @click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''" @contextmenu.prevent="openMenu(tag, $event)">
                {{ generateTitle(tag.meta.title) }}
                <!-- x号图标 -->
                <!-- @click.prevent会阻止元素的默认行为,例如阻止表单提交或超链接的跳转。@click.stop会阻止事件继续传播到父元素 -->
                <span v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)"><svg-icon
                        icon-class="close" /></span>

            </router-link>
        </scroll-pane>

        <!-- 右键出现的菜单标签 -->
        <ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="tags-view__menu">
            <li @click="refreshSelectedTag(selectedTag)">
                <svg-icon icon-class="refresh" />刷新
            </li>
            <li @click="closeSelectedTag(selectedTag)" v-if="!isAffix(selectedTag)">
                <svg-icon icon-class="close"/>关闭
            </li>
            <li @click="closeOtherTags">
                <svg-icon icon-class="close_other"/>关闭其他
            </li>
            <li v-if="!isFirstView()" @click="closeLeftTags">
                <svg-icon icon-class="close_left"/>关闭左侧
            </li>
            <li v-if="!isLastView()" @click="closeRightTags">
                <svg-icon icon-class="close_right"/>关闭右侧
            </li>
            <li @click="closeAllTags(selectedTag)">
                <svg-icon  icon-class="close_all" />关闭所有
            </li>

        </ul>
    </div>
</template>
<script setup lang="ts">
import ScrollPane from './ScrollPane.vue';
import { ComponentInternalInstance, computed, getCurrentInstance, ref, watch } from 'vue';
import { TagView } from '@/types/store/tagsview';
import { generateTitle } from '@/utils/i18n';


import useStore from '@/store';
const { tagsView } = useStore();

import { RouteRecordRaw, useRoute, useRouter } from 'vue-router';
import { nextTick } from 'process';
const route = useRoute();//useRoute()相当于以前的this.$route
const router = useRouter();

//菜单标签的展示与否
const visible = ref(false);
//拿到滚动实例
const scrollPaneRef = ref();
//展示出的标签数据们
const visitedViews = computed<any>(() => tagsView.visitedViews)

const top = ref(0);//菜单的顶部位置
const left = ref(0);//菜单的左侧放置位置
const selectedTag = ref({}); //所选中的标签

//proxy变量现在引用当前组件实例的代理对象.getCurrentInstance()函数返回当前组件实例的内部实例对象. 代理对象是一个包含组件实例属性和方法的对象,可用于在组件之间传递数据和调用方法
const { proxy } = getCurrentInstance() as ComponentInternalInstance;//作用是: 获取当前组件实例的代理对象

//监听路径变化时,自动添加标签,移除标签
watch(route, () => {
    addTags();
    moveToCurrentTag();
},
    //初始化立即执行
    { immediate: true, }
)


//添加标签
function addTags() {
    if (route.name) {
        tagsView.addTags(route);
    }
}
//作用: 将当前路由对象滚动到可视区域,并更新tagsView 组件中的visitedViews 数组
function moveToCurrentTag() {
    // nextTick() 函数是一个异步方法:用于在DOM更新后执行回调函数.这里使用 nextTick() 的目的是确保在更新 tagsView 组件之前,scrollPaneRef 对象已经滚动到了正确的位置。
    nextTick(() => {
        for (const r of visitedViews.value) {
            // 如果 r.path 等于 route.path,则将 scrollPaneRef 对象滚动到 r 对应的位置
            if (r.path === route.path) {
                scrollPaneRef.value.moveToTarget(r);
                //如果 r.fullPath 不等于 route.fullPath,则说明当前路由对象与 r 对象不同,需要更新 tagsView 组件中的 visitedViews 数组。
                if (r.fullPath !== route.fullPath) {
                    tagsView.updateVisitedView(route);
                }
            }
        }
    })

}






//作用:在visible变量的值发生变化时,控制是否监听click事件. 当visible变量的值为true时,它会在document.body上添加一个click事件监听器; 当visible变量的值为false时,它会从document.body上移除click事件监听器.
watch(visible, (value) => {
    if (value) {
        document.body.addEventListener('click', closeMenu);
    } else {
        document.body.removeEventListener('click', closeMenu);
    }
})



//滚动时触发的事件
function handleScroll() {
    closeMenu();
}
//关闭菜单标签
function closeMenu() {
    visible.value = false;
}

//判断标签被选中
function isActive(tag: TagView) {
    return tag.path === route.path;

}
//affix是meta对象的一个属性,用于指示路由是否应该在导航菜单中固定。
function isAffix(tag: TagView) {
    return tag.meta && tag.meta.affix;
}
//点击x号关闭标签/ 菜单标签里的关闭
function closeSelectedTag(view: TagView) {
    //该函数从tagsView中删除view,然后检查view是否处于活动状态.如果是,则将其移到最后一个访问的试图.
    tagsView.delView(view).then((res: any) => {
        if (isActive(view)) {
            toLastView(res.visitedViews, view);
        }
    })
}
//作用:将用户重定向到最后一个访问的页面
// slice(-1):它返回序列的最后一个元素
function toLastView(visitedViews: TagView[], view?: any) {
    const latestView = visitedViews.slice(-1)[0];
    if (latestView && latestView.fullPath) {
        router.push(latestView.fullPath);
    } else {
        if (view.name === 'Dashboard') {
            router.replace({ path: '/redirect' + view.fullPath });
        } else {
            router.push('/')
        }
    }
}

//该函数作用:打开一个菜单,该菜单的位置取决于鼠标点击的位置和容器的宽度
function openMenu(tag: TagView, e: MouseEvent) {
    //定义菜单的最小宽度和容器的偏移量
    const menuMinWidth = 105;
    // proxy?.$el: 会返回proxy元素的DOM节点,然后调用getBoundingClientRect()方法获取该节点的位置信息; 其中包括left属性,表示该节点相对于视口左边缘的距离
    const offsetLeft = proxy?.$el.getBoundingClientRect().left;//容器的margin-left   
    const offsetWidth = proxy?.$el.offsetWidth;//容器的宽度
    //计算左边界
    const maxLeft = offsetWidth - menuMinWidth;
    //计算菜单的左侧位置
    const l = e.clientX - offsetLeft + 15; //15: margin-right

    //如果菜单的左侧位置超出了左边界,则将其设置为左边界
    if (l > maxLeft) {
        left.value = maxLeft;
    } else {
        left.value = l;

    }

    //将菜单的顶部位置设置为鼠标点击的位置
    top.value = e.clientY;
    //显示菜单
    visible.value = true;
    //设置选中的标签
    selectedTag.value = tag;

}



//菜单的刷新按钮---刷新选定的标签
function refreshSelectedTag(view: TagView) {
    // 在 tagsView 中删除缓存的视图
    tagsView.delCachedView(view);
    const { fullPath } = view;
    nextTick(() => {
        router.replace({ path: '/redirect' + fullPath }).catch((err) => {
            console.warn(err);
        });
    });

}
//菜单标签关闭其他
function closeOtherTags() {
    router.push(selectedTag.value);
    tagsView.delOtherViews(selectedTag.value).then(() => {
        moveToCurrentTag();
    });
}
//是第一个菜单标签
function isFirstView() {
    try {
        return (
            (selectedTag.value as TagView).fullPath === visitedViews.value[1].fullPath ||
            (selectedTag.value as TagView).fullPath === '/index'
        );
    } catch (err) {
        return false;
    }
}
//是最后一个标签
function isLastView() {
    try {
        // 检查当前选定的标签是否是最后一个访问的试图: 通过比较选定标签的完整路径和最后一个访问视图的完整路径
        return (
            (selectedTag.value as TagView).fullPath === visitedViews.value[visitedViews.value.length - 1].fullPath
        )
    } catch (err) {
        return false;
    }
}
//关闭左侧菜单标签
function closeLeftTags() {
    tagsView.delLeftViews(selectedTag.value).then((res:any) => {
        //使用 tagsView.delLeftViews()方法删除选定标签左侧的所有视图. 然后检查是否仍然存在当前路由的试图--如果不存在,则将用户重定向到最后一个访问的视图
        if (!res.visitedViews.find((item: any) => item.fullPath === route.fullPath )) {
            toLastView(res.visitedViews) 
        }
    })

}
//关闭右侧菜单标签
function closeRightTags() {
    tagsView.delRightViews(selectedTag.value).then((res: any) => {
        // 检查是否仍然存在当前路由的试图--如果不存在,则将用户重定向到最后一个访问的视图
        if (!res.visitedViews.find((item: any) => item.fullPath === route.fullPath )) {
            toLastView(res.visitedViews) 
        } 
    })

}
//关闭所有标签
function closeAllTags(view: TagView) {
    tagsView.delAllViews().then((res: any) => {
        toLastView(res.visitedViews, view);
    })
}
</script>

3.3.1、 打开的标签过多,这时需要左右滚动----滚动条组件ScrollPane

<template>
    <!-- wheel属性是用于控制滚动条滚动的速度的。具体来说,当wheel属性设置为true时,滚动条的滚动速度将会更快,而当设置为false时,滚动条的滚动速度将会更慢。 -->
    <!-- vertical属性用于指定滚动条的方向。如果设置为true,则滚动条将垂直显示,如果设置为false,则滚动条将水平显示。 -->
    <el-scrollbar ref="scrollContainer" :vertical="false" @wheel.prevent="handleScroll">
        <slot />
    </el-scrollbar>
</template>

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from "vue";
import useStore from '@/store';

//在子组件中触发父组件的函数scroll
const emits = defineEmits(['scroll']);
//滚动时触发的函数
const emitScroll = () => {
    emits('scroll')

}
const { tagsView } = useStore();
const visitedViews = computed(() => tagsView.visitedViews);

// $refs.scrollContainer是一个组件的引用,它包含一个名为wrap$的子元素或子组件。$refs.scrollContainer.$refs.wrap$是对该子元素或子组件的引用。
const scrollWrapper = computed(() => proxy?.$refs.scrollContainer.$refs.wrap$);

onMounted(() => {
    //在scrollWrapper实例元素身上添加了一个滚动事件监听器,当该元素滚动时,会触发emitScroll函数---调用父组件中的函数,第三个参数true表示在捕获阶段触发事件
    scrollWrapper.value.addEventListener('scroll', emitScroll, true);
})
onBeforeUnmount(() => {
    scrollWrapper.value.removeEventListener('scroll', emitScroll)
})

//控制滚动条速度
function handleScroll(e: WheelEvent) {
    // 用来计算鼠标滚轮事件的滚动距离
    //e.deltaY:鼠标滚轮事件的滚动距离,乘以40是为了将其转换为像素值
    const eventDelta = (e as any).wheelDelta || -e.deltaY * 40;
    // 意思是将滚动区域的左侧滚动条位置增加滚动事件的滚动量的四分之一。
    // 这个操作会使滚动区域向右滚动,因为 scrollLeft 增加了。
    scrollWrapper.value.scrollLeft = scrollWrapper.value.scrollLeft + eventDelta / 4;

}


// 把这个方法暴露出去给父组件用
defineExpose({
    moveToTarget,
})
function moveToTarget(currentTag: TagView) {
    const $container = proxy.$refs.scrollContainer.$el;//拿到scrollbar这个组件中的滚动容器
    const $containerWidth = $container.offsetWidth;//拿到滚动容器的宽度,以像素为单位
    const $scrollWrapper = scrollWrapper.value;//这是一个响应式对象,它包含scrollbar组件中的滚动条的状态和方法

    let firstTag = null;
    let lastTag = null;

    if (visitedViews.value.length > 0) {
        firstTag = visitedViews.value[0];
        lastTag = visitedViews.value[visitedViews.value.length - 1];
    }

    if (firstTag === currentTag) {
        $scrollWrapper.scrollLeft = 0;
    } else if (lastTag === currentTag) {
        $scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth;

    } else {
        const tagListDom = document.getElementsByClassName('tags-view__item');
        const currentIndex = visitedViews.value.findIndex(
            (item) => item === currentTag
        );
        let prevTag = null;
        let nextTag = null;

        //这是一个用于查找前一个和后一个标签的循环。在循环中,它遍历了tagListDom对象的所有属性
        for (const k in tagListDom) {
            // Object.hasOwnProperty.call()方法用于检查对象是否具有指定的属性
            if (k !== 'length' && Object.hasOwnProperty.call(tagListDom, k)) {
                if (
                    // 这个值被用来检查当前活动的视图是否是最近访问的视图列表中的前一个视图。
                    // tagListDom 数组中的每个元素都是一个 DOM 元素
                    (tagListDom[k] as any).dataset.path ===
                    visitedViews.value[currentIndex - 1].path
                ) {
                    prevTag = tagListDom[k];
                }
                if (
                    (tagListDom[k] as any).dataset.path ===
                    visitedViews.value[currentIndex + 1].path
                ) {
                    nextTag = tagListDom[k];
                }
            }
        }


        //这段代码用于处理一个scrollbar的滚动条组件的滚动逻辑。它检查下一个标签的位置是否超出了滚动容器的可见范围,
        // 如果是,则将滚动条向右滚动到下一个标签的位置;同样,它还检查前一个标签的位置是否超出了滚动容器的可见范围,如果是,则将滚动条向左滚动到前一个标签的位置
       
        // afterNextTagOffsetLeft:表示下一个标签的位置
        const afterNextTagOffsetLeft =
            (nextTag as any).offsetLeft +
            (nextTag as any).offsetWidth +
            tagAndTagSpacing.value;

        // beforePrevTagOffsetLeft:这个变量表示前一个标签的位置
        const beforePrevTagOffsetLeft =
            (prevTag as any).offsetLeft - tagAndTagSpacing.value;
        if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {//如果afterNextTagOffsetLeft超出了可见范围,则将滚动条向右滚动到afterNextTagOffsetLeft - $containerWidth的位置
            $scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth;
        } else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {//如果beforePrevTagOffsetLeft超出了可见范围,则将滚动条向左滚动到beforePrevTagOffsetLeft的位置。
            $scrollWrapper.scrollLeft = beforePrevTagOffsetLeft;
        }



    }

}
</script>

四、右侧下方放主要展示内容组件AppMain:

<!-- 放主要内容的组件 -->
<template>
    <section>
        <!-- 这里用于渲染路由组件. v-slot指令用于将路由组件的属性传递给父组件 -->
        <router-view  v-slot="{ Component, route }">  
             <transition name="router-fade" mode="out-in">
                <!-- 缓存组件--以便在组件之间切换时保留状态,而不是每次重新渲染,可以提高应用程序的性能和响应速度 -->
                <!-- 这个标签可以用于包裹需要缓存的组件.include属性是一个字符串或正则表达式,用于匹配需要缓存的组件名称。如果组件名称与include属性匹配,则该组件将被缓存。如果未提供include属性,则所有动态组件都将被缓存。 -->
                <keep-alive :include="cachedViews">
                    <!-- 放不同的路由组件 -->
                    <component :is="Component" :key="route.fullPath"/>
                 </keep-alive>
             </transition>
        </router-view>
    </section>
</template>

<script setup lang="ts">
import { computed } from 'vue';

import useStore from '@/store';
const { tagsView } = useStore();
//匹配需要缓存的组件
const cachedViews = computed(() => tagsView.cachedViews);
</script> 



Logo

前往低代码交流专区

更多推荐