虽然写了很多,但是我们依然跑不起来项目,看不到页面。这部分将实现页面的整体布局,并通过跳过网络请求部分,查看项目。

后台管理的基本布局如下图:

侧边栏

侧边栏的菜单是由路由动态动态生成的

generatorMenu

修改src/utils/index.js,添加如下的方法:

utils/index.js
/**
* 递归组装菜单格式
*/
export function generatorMenu(routerMap) {
return filterRouter(routerMap).map((item) => {
const isRoot = isRootRouter(item);
const info = isRoot ? item.children[0] : item;
const currentMenu = {
...info,
...info.meta,
label: info.meta?.title,
key: info.name,
icon: isRoot ? item.meta?.icon : info.meta?.icon,
};
// 是否有子菜单,并递归处理
if (info.children && info.children.length > 0) {
// 递归
currentMenu.children = generatorMenu(info.children);
}
return currentMenu;
});
}

/**
* 判断根路由 Router
* */
export function isRootRouter(item) {
return (
item.meta?.alwaysShow != true &&
item?.children?.filter((item) => !item?.meta?.hidden)?.length === 1
);
}
/**
* 排除Router
*/
export function filterRouter(routerMap) {
return routerMap.filter((item) => {
return (
(item.meta?.hidden || false) != true &&
!['/:path(.*)*', '/', PageEnum.REDIRECT, PageEnum.BASE_LOGIN].includes(
item.path
)
);
});
}

index.js

创建layout/components/Menu/index.js文件夹,写入以下内容:

components/Menu/index.js
import AsideMenu from './index.vue'

export { AsideMenu }

index.vue

创建layout/components/Menu/index.vue文件夹,写入以下内容:

components/Menu/index.vue
<template>
<NMenu
:options="menus"
:inverted="inverted"
:mode="mode"
:collapsed="collapsed"
:collapsed-width="64"
:collapsed-icon-size="20"
:indent="24"
:expanded-keys="openKeys"
:value="getSelectedKeys"
@update:value="clickMenuItem"
@update:expanded-keys="menuExpanded"
/>
</template>
<script>
import {
computed,
defineComponent,
onMounted,
reactive,
ref,
toRefs,
unref,
watch,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAsyncRouteStore } from '@/store/modules/asyncRoute';
import { generatorMenu } from '@/utils';
import { useProjectSettingStore } from '@/store/modules/projectSetting';

export default defineComponent({
props: {
mode: {
// 菜单模式
type: String,
default: 'vertical',
},
collapsed: {
// 侧边栏菜单是否收起
type: Boolean,
},
//位置
location: {
type: String,
default: 'left',
},
},
emits: ['update:collapsed', 'clickMenuItem'],
setup(props, { emit }) {
// 当前路由
const currentRoute = useRoute();
const router = useRouter();
const asyncRouteStore = useAsyncRouteStore();
const settingStore = useProjectSettingStore();
const menus = ref([]);
const selectedKeys = ref(currentRoute.name);
const headerMenuSelectKey = ref('');

// 获取当前打开的子菜单
const matched = currentRoute.matched;
const getOpenKeys =
matched && matched.length ? matched.map((item) => item.name) : [];
const state = reactive({
openKeys: getOpenKeys,
});

const inverted = computed(() => {
return ['dark', 'header-dark'].includes(settingStore.navTheme);
});
const getSelectedKeys = computed(() => {
let location = props.location;
return location === 'left' ||
(location === 'header' && settingStore.getNavMode === 'horizontal')
? unref(selectedKeys)
: unref(headerMenuSelectKey);
});

// 监听分割菜单
watch(
() => currentRoute.fullPath,
() => {
updateMenu();
}
);

function updateSelectedKeys() {
const matched = currentRoute.matched;
state.openKeys = matched.map((item) => item.name);
const activeMenu = currentRoute.meta?.activeMenu || '';
selectedKeys.value = activeMenu ? activeMenu : currentRoute.name;
}

function updateMenu() {
if (!settingStore.menuSetting.mixMenu) {
menus.value = generatorMenu(asyncRouteStore.getMenus);
} else {
// 混合菜单,不予实现
}
updateSelectedKeys();
}

// 点击菜单
function clickMenuItem(key) {
if (/http(s)?:/.test(key)) {
window.open(key);
} else {
router.push({ name: key });
}
emit('clickMenuItem', key);
}
// 展开菜单
function menuExpanded(openKeys) {
if (!openKeys) return;
const latestOpenKey = openKeys.find(
(key) => state.openKeys.indexOf(key) === -1
);
const isExistChildren = findChildrenLen(latestOpenKey);
state.openKeys = isExistChildren
? latestOpenKey
? [latestOpenKey]
: []
: openKeys;
}
//查找是否存在子路由
function findChildrenLen(key) {
if (!key) return false;
const subRouteChildren = [];
for (const { children, key } of unref(menus)) {
if (children && children.length) {
subRouteChildren.push(key);
}
}
return subRouteChildren.includes(key);
}

onMounted(() => {
updateMenu();
});

return {
...toRefs(state),
menus,
inverted,
clickMenuItem,
menuExpanded,
getSelectedKeys,
headerMenuSelectKey,
};
},
});
</script>

为侧边栏上方的LOGO组件

index.js

创建layout/components/Logo/index.js文件夹,写入以下内容:

components/Logo/index.js
import Logo from './index.vue';

export { Logo };

index.vue

创建layout/components/Logo/index.vue文件夹,写入以下内容:

components/Logo/index.vue
<template>
<div class="logo">
<img :src="websiteConfig.logo" alt="" :class="{ 'mr-2': !collapsed }" />
<h2 v-show="!collapsed" class="title">{{ websiteConfig.title }}</h2>
</div>
</template>
<script>
import { defineComponent } from 'vue';
import { websiteConfig } from '@/config/website.config';
export default defineComponent({
props: {
collapsed: {
type: Boolean,
},
},
setup() {
return {
websiteConfig,
};
},
});
</script>

<style lang="less" scoped>
.logo {
display: flex;
align-items: center;
justify-content: center;
height: 64px;
line-height: 64px;
overflow: hidden;
white-space: nowrap;

img {
width: auto;
height: 32px;
}

.title {
margin: 0;
}
}
</style>

头部份

这部分是后台的上方头部份,这里也是有很多小的功能:

  • 左侧菜单收起
  • 面包屑
  • 切换全屏
  • 个人中心
  • 项目配置

components.js

导入需要用到的图标

创建src/layout/components/Header/components.js

layout/components/Header/components.js
import {
SettingOutlined,
SearchOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
PoweroffOutlined,
GithubOutlined,
LockOutlined,
ReloadOutlined,
LogoutOutlined,
UserOutlined,
CheckOutlined,
} from '@vicons/antd';

export default {
SettingOutlined,
LockOutlined,
GithubOutlined,
SearchOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
PoweroffOutlined,
ReloadOutlined,
LogoutOutlined,
UserOutlined,
CheckOutlined,
};

index.js

创建layout/components/Header/index.js文件夹,写入以下内容:

components/Header/index.js
import PageHeader from './index.vue';

export { PageHeader };

index.vue

创建layout/components/Header/index.vue文件夹,写入以下内容:

components/Header/index.vue
<template>
<div class="layout-header">
<!--顶部菜单-->
<div
class="layout-header-left"
v-if="navMode === 'horizontal' || navMode === 'horizontal-mix'"
>
<div class="logo" v-if="navMode === 'horizontal'">
<img :src="websiteConfig.logo" alt="" />
<h2 v-show="!collapsed" class="title">{{ websiteConfig.title }}</h2>
</div>
<AsideMenu
v-model:collapsed="collapsed"
v-model:location="getMenuLocation"
:inverted="getInverted"
mode="horizontal"
/>
</div>
<!--左侧菜单-->
<div class="layout-header-left" v-else>
<!-- 菜单收起 -->
<div
class="ml-1 layout-header-trigger layout-header-trigger-min"
@click="() => $emit('update:collapsed', !collapsed)"
>
<n-icon size="18" v-if="collapsed">
<MenuUnfoldOutlined />
</n-icon>
<n-icon size="18" v-else>
<MenuFoldOutlined />
</n-icon>
</div>
<!-- 刷新 -->
<div
class="mr-1 layout-header-trigger layout-header-trigger-min"
v-if="headerSetting.isReload"
@click="reloadPage"
>
<n-icon size="18">
<ReloadOutlined />
</n-icon>
</div>
<!-- 面包屑 -->
<n-breadcrumb v-if="crumbsSetting.show">
<template v-for="routeItem in breadcrumbList" :key="routeItem.name">
<n-breadcrumb-item v-if="routeItem.meta.title">
<n-dropdown
v-if="routeItem.children.length"
:options="routeItem.children"
@select="dropdownSelect"
>
<span class="link-text">
<component
v-if="crumbsSetting.showIcon && routeItem.meta.icon"
:is="routeItem.meta.icon"
/>
{{ routeItem.meta.title }}
</span>
</n-dropdown>
<span class="link-text" v-else>
<component
v-if="crumbsSetting.showIcon && routeItem.meta.icon"
:is="routeItem.meta.icon"
/>
{{ routeItem.meta.title }}
</span>
</n-breadcrumb-item>
</template>
</n-breadcrumb>
</div>
<div class="layout-header-right">
<div
class="layout-header-trigger layout-header-trigger-min"
v-for="item in iconList"
:key="item.icon.name"
>
<n-tooltip placement="bottom">
<template #trigger>
<n-icon size="18">
<component :is="item.icon" v-on="item.eventObject || {}" />
</n-icon>
</template>
<span>{{ item.tips }}</span>
</n-tooltip>
</div>
<!--切换全屏-->
<div class="layout-header-trigger layout-header-trigger-min">
<n-tooltip placement="bottom">
<template #trigger>
<n-icon size="18">
<component :is="fullscreenIcon" @click="toggleFullScreen" />
</n-icon>
</template>
<span>全屏</span>
</n-tooltip>
</div>
<!-- 个人中心 -->
<div class="layout-header-trigger layout-header-trigger-min">
<n-dropdown
trigger="hover"
@select="avatarSelect"
:options="avatarOptions"
>
<div class="avatar">
<n-avatar round>
{{ username }}
<template #icon>
<UserOutlined />
</template>
</n-avatar>
</div>
</n-dropdown>
</div>
<!--设置-->
<div
class="layout-header-trigger layout-header-trigger-min"
@click="openSetting"
>
<n-tooltip placement="bottom-end">
<template #trigger>
<n-icon size="18" style="font-weight: bold">
<SettingOutlined />
</n-icon>
</template>
<span>项目配置</span>
</n-tooltip>
</div>
</div>
</div>
</template>

<script>
import { defineComponent, reactive, toRefs, computed, unref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import components from './components';
import { NDialogProvider, useDialog, useMessage } from 'naive-ui';
import { TABS_ROUTES } from '@/store/mutation-types';
import { useUserStore } from '@/store/modules/user';
// import ProjectSetting from './ProjectSetting.vue'; // 界面设置功能,暂时不予实现
import { AsideMenu } from '@/layout/components/Menu';
import { useProjectSetting } from '@/hooks/setting/useProjectSetting';
import { websiteConfig } from '@/config/website.config';

export default defineComponent({
name: 'PageHeader',
components: { ...components, NDialogProvider, AsideMenu },
props: {
collapsed: {
type: Boolean,
},
inverted: {
type: Boolean,
},
},
setup(props) {
const userStore = useUserStore();
const message = useMessage();
const dialog = useDialog();
const {
getNavMode,
getNavTheme,
getHeaderSetting,
getMenuSetting,
getCrumbsSetting,
} = useProjectSetting();

const { username } = userStore?.info || {};

const state = reactive({
username: username || '',
fullscreenIcon: 'FullscreenOutlined',
navMode: getNavMode,
navTheme: getNavTheme,
headerSetting: getHeaderSetting,
crumbsSetting: getCrumbsSetting,
});

const getInverted = computed(() => {
const navTheme = unref(getNavTheme);
return ['light', 'header-dark'].includes(navTheme)
? props.inverted
: !props.inverted;
});

const getChangeStyle = computed(() => {
const { collapsed } = props;
const { minMenuWidth, menuWidth } = unref(getMenuSetting);
return {
left: collapsed ? `${minMenuWidth}px` : `${menuWidth}px`,
width: `calc(100% - ${
collapsed ? `${minMenuWidth}px` : `${menuWidth}px`
})`,
};
});

const getMenuLocation = computed(() => {
return 'header';
});

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

const generator = (routerMap) => {
return routerMap.map((item) => {
const currentMenu = {
...item,
label: item.meta.title,
key: item.name,
disabled: item.path === '/',
};
// 是否有子菜单,并递归处理
if (item.children && item.children.length > 0) {
// Recursion
currentMenu.children = generator(item.children, currentMenu);
}
return currentMenu;
});
};

const breadcrumbList = computed(() => {
return generator(route.matched);
});

const dropdownSelect = (key) => {
router.push({ name: key });
};

// 刷新页面
const reloadPage = () => {
router.push({
path: '/redirect' + unref(route).fullPath,
});
};

// 退出登录
const doLogout = () => {
dialog.info({
title: '提示',
content: '您确定要退出登录吗',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
userStore.logout().then(() => {
message.success('成功退出登录');
// 移除标签页
localStorage.removeItem(TABS_ROUTES);
router
.replace({
name: 'Login',
query: {
redirect: route.fullPath,
},
})
.finally(() => location.reload());
});
},
onNegativeClick: () => {},
});
};

// 切换全屏图标
const toggleFullscreenIcon = () =>
(state.fullscreenIcon =
document.fullscreenElement !== null
? 'FullscreenExitOutlined'
: 'FullscreenOutlined');

// 监听全屏切换事件
document.addEventListener('fullscreenchange', toggleFullscreenIcon);

// 全屏切换
const toggleFullScreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
};

// 图标列表
const iconList = [
{
icon: 'SearchOutlined',
tips: '搜索',
},
{
icon: 'GithubOutlined',
tips: 'github',
eventObject: {
click: () => window.open('https://github.com/jekip/naive-ui-admin'),
},
},
];
const avatarOptions = [
{
label: '个人设置',
key: 1,
},
{
label: '退出登录',
key: 2,
},
];

//头像下拉菜单
const avatarSelect = (key) => {
switch (key) {
case 1:
router.push({ name: 'Setting' });
break;
case 2:
doLogout();
break;
}
};

return {
...toRefs(state),
iconList,
toggleFullScreen,
doLogout,
route,
dropdownSelect,
avatarOptions,
getChangeStyle,
avatarSelect,
breadcrumbList,
reloadPage,
getInverted,
getMenuLocation,
websiteConfig,
};
},
});
</script>

<style lang="less" scoped>
@import '@/styles/var.less';
.layout-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0;
height: @header-height;
box-shadow: 0 1px 4px rgb(0 21 41 / 8%);
transition: all 0.2s ease-in-out;
width: 100%;
z-index: 11;

&-left {
display: flex;
align-items: center;

.logo {
display: flex;
align-items: center;
justify-content: center;
height: 64px;
line-height: 64px;
overflow: hidden;
white-space: nowrap;
padding-left: 10px;

img {
width: auto;
height: 32px;
margin-right: 10px;
}

.title {
margin-bottom: 0;
}
}

::v-deep(.ant-breadcrumb span:last-child .link-text) {
color: #515a6e;
}

.n-breadcrumb {
display: inline-block;
}

&-menu {
color: var(--text-color);
}
}

&-right {
display: flex;
align-items: center;
margin-right: 20px;

.avatar {
display: flex;
align-items: center;
height: 64px;
}

> * {
cursor: pointer;
}
}

&-trigger {
display: inline-block;
width: 64px;
height: 64px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease-in-out;

.n-icon {
display: flex;
align-items: center;
height: 64px;
line-height: 64px;
}

&:hover {
background: hsla(0, 0%, 100%, 0.08);
}

.anticon {
font-size: 16px;
color: #515a6e;
}
}

&-trigger-min {
width: auto;
padding: 0 12px;
}
}

.layout-header-light {
background: #fff;
color: #515a6e;

.n-icon {
color: #515a6e;
}

.layout-header-left {
::v-deep(
.n-breadcrumb .n-breadcrumb-item:last-child .n-breadcrumb-item__link
) {
color: #515a6e;
}
}

.layout-header-trigger {
&:hover {
background: #f8f8f9;
}
}
}

.layout-header-fix {
position: fixed;
top: 0;
right: 0;
left: 200px;
z-index: 11;
}
</style>

中间部分

这部分是用来处理项目中变化部分的,即加载不同的路由组件渲染不同的子页面。

自定义了RouterView,方便路由渲染页面。

index.js

创建layout/components/Main/index.js文件夹,写入以下内容:

components/Main/index.js
import MainView from './index.vue';

export { MainView };

index.vue

创建layout/components/Main/index.vue文件夹,写入以下内容:

components/Main/index.vue
<template>
<RouterView>
<template #default="{ Component, route }">
<transition :name="getTransitionName" mode="out-in" appear>
<keep-alive v-if="keepAliveComponents" :include="keepAliveComponents">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
<component v-else :is="Component" :key="route.fullPath" />
</transition>
</template>
</RouterView>
</template>

<script>
import { defineComponent, computed, unref } from 'vue';
import { useAsyncRouteStore } from '@/store/modules/asyncRoute';
import { useProjectSetting } from '@/hooks/setting/useProjectSetting';

export default defineComponent({
name: 'MainView',
components: {},
props: {
notNeedKey: {
type: Boolean,
default: false,
},
animate: {
type: Boolean,
default: true,
},
},
setup() {
const { getIsPageAnimate, getPageAnimateType } = useProjectSetting();
const asyncRouteStore = useAsyncRouteStore();
// 需要缓存的路由组件
const keepAliveComponents = computed(
() => asyncRouteStore.keepAliveComponents
);

const getTransitionName = computed(() => {
return unref(getIsPageAnimate) ? unref(getPageAnimateType) : '';
});

return {
keepAliveComponents,
getTransitionName,
};
},
});
</script>

<style lang="less" scoped></style>

标签页

index.js

创建layout/components/TabsView/index.js文件夹,写入以下内容:

components/TabsView/index.js
import TabsView from './index.vue';

export { TabsView };

index.vue

创建layout/components/TabsView/index.vue文件夹,写入以下内容:

components/TabsView/index.vue
<template>
<div
class="tabs-view box-border"
:class="{
'tabs-view-fix': multiTabsSetting.fixed,
'tabs-view-fixed-header': isMultiHeaderFixed,
'tabs-view-default-background': getDarkTheme === false,
'tabs-view-dark-background': getDarkTheme === true,
}"
:style="getChangeStyle"
>
<div class="tabs-view-main">
<div
ref="navWrap"
class="tabs-card"
:class="{ 'tabs-card-scrollable': scrollable }"
>
<span
class="tabs-card-prev"
:class="{ 'tabs-card-prev-hide': !scrollable }"
@click="scrollPrev"
>
<n-icon size="16" color="#515a6e">
<LeftOutlined />
</n-icon>
</span>
<span
class="tabs-card-next"
:class="{ 'tabs-card-next-hide': !scrollable }"
@click="scrollNext"
>
<n-icon size="16" color="#515a6e">
<RightOutlined />
</n-icon>
</span>
<div ref="navScroll" class="tabs-card-scroll">
<Draggable
:list="tabsList"
animation="300"
item-key="fullPath"
class="flex"
>
<template #item="{ element }">
<div
:id="`tag${element.fullPath.split('/').join('\/')}`"
class="tabs-card-scroll-item"
:class="{ 'active-item': activeKey === element.path }"
@click.stop="goPage(element)"
@contextmenu="handleContextMenu($event, element)"
>
<span>{{ element.meta.title }}</span>
<n-icon
size="14"
@click.stop="closeTabItem(element)"
v-if="!element.meta.affix"
>
<CloseOutlined />
</n-icon>
</div>
</template>
</Draggable>
</div>
</div>
<div class="tabs-close">
<n-dropdown
trigger="hover"
@select="closeHandleSelect"
placement="bottom-end"
:options="TabsMenuOptions"
>
<div class="tabs-close-btn">
<n-icon size="16" color="#515a6e">
<DownOutlined />
</n-icon>
</div>
</n-dropdown>
</div>
<n-dropdown
:show="showDropdown"
:x="dropdownX"
:y="dropdownY"
@clickoutside="onClickOutside"
placement="bottom-start"
@select="closeHandleSelect"
:options="TabsMenuOptions"
/>
</div>
</div>
</template>

<script>
import {
defineComponent,
reactive,
computed,
ref,
toRefs,
unref,
provide,
watch,
onMounted,
nextTick,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { storage } from '@/utils/Storage';
import { TABS_ROUTES } from '@/store/mutation-types';
import { useTabsViewStore } from '@/store/modules/tabsView';
import { useAsyncRouteStore } from '@/store/modules/asyncRoute';
import { useProjectSetting } from '@/hooks/setting/useProjectSetting';
import { useMessage } from 'naive-ui';
import Draggable from 'vuedraggable';
import { PageEnum } from '@/enums/pageEnum';
import {
DownOutlined,
ReloadOutlined,
CloseOutlined,
ColumnWidthOutlined,
MinusOutlined,
LeftOutlined,
RightOutlined,
} from '@vicons/antd';
import { renderIcon } from '@/utils';
import elementResizeDetectorMaker from 'element-resize-detector';
import { useDesignSetting } from '@/hooks/setting/useDesignSetting';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { useThemeVars } from 'naive-ui';
import { useGo } from '@/hooks/web/usePage';

export default defineComponent({
name: 'TabsView',
components: {
DownOutlined,
CloseOutlined,
LeftOutlined,
RightOutlined,
Draggable,
},
props: {
collapsed: {
type: Boolean,
},
},
setup(props) {
const { getDarkTheme, getAppTheme } = useDesignSetting();
const {
getNavMode,
getHeaderSetting,
getMenuSetting,
getMultiTabsSetting,
getIsMobile,
} = useProjectSetting();
const settingStore = useProjectSettingStore();

const message = useMessage();
const route = useRoute();
const router = useRouter();
const tabsViewStore = useTabsViewStore();
const asyncRouteStore = useAsyncRouteStore();
const navScroll = ref(null);
const navWrap = ref(null);
const isCurrent = ref(false);
const go = useGo();

const themeVars = useThemeVars();

const getCardColor = computed(() => {
return themeVars.value.cardColor;
});

const getBaseColor = computed(() => {
return themeVars.value.textColor1;
});

const state = reactive({
activeKey: route.fullPath,
scrollable: false,
dropdownX: 0,
dropdownY: 0,
showDropdown: false,
isMultiHeaderFixed: false,
multiTabsSetting: getMultiTabsSetting,
});

// 获取简易的路由对象
const getSimpleRoute = (route) => {
const { fullPath, hash, meta, name, params, path, query } = route;
return { fullPath, hash, meta, name, params, path, query };
};

const isMixMenuNoneSub = computed(() => {
const mixMenu = settingStore.menuSetting.mixMenu;
const currentRoute = useRoute();
const navMode = unref(getNavMode);
if (unref(navMode) != 'horizontal-mix') return true;
return !(
unref(navMode) === 'horizontal-mix' &&
mixMenu &&
currentRoute.meta.isRoot
);
});

//动态组装样式 菜单缩进
const getChangeStyle = computed(() => {
const { collapsed } = props;
const navMode = unref(getNavMode);
const { minMenuWidth, menuWidth } = unref(getMenuSetting);
const { fixed } = unref(getMultiTabsSetting);
let lenNum =
navMode === 'horizontal' || !isMixMenuNoneSub.value
? '0px'
: collapsed
? `${minMenuWidth}px`
: `${menuWidth}px`;

if (getIsMobile.value) {
return {
left: '0px',
width: '100%',
};
}
return {
left: lenNum,
width: `calc(100% - ${!fixed ? '0px' : lenNum})`,
};
});

//tags 右侧下拉菜单
const TabsMenuOptions = computed(() => {
const isDisabled = unref(tabsList).length <= 1;
return [
{
label: '刷新当前',
key: '1',
icon: renderIcon(ReloadOutlined),
},
{
label: `关闭当前`,
key: '2',
disabled: unref(isCurrent) || isDisabled,
icon: renderIcon(CloseOutlined),
},
{
label: '关闭其他',
key: '3',
disabled: isDisabled,
icon: renderIcon(ColumnWidthOutlined),
},
{
label: '关闭全部',
key: '4',
disabled: isDisabled,
icon: renderIcon(MinusOutlined),
},
];
});

let cacheRoutes = [];
const simpleRoute = getSimpleRoute(route);
try {
const routesStr = storage.get(TABS_ROUTES);
cacheRoutes = routesStr ? JSON.parse(routesStr) : [simpleRoute];
} catch (e) {
cacheRoutes = [simpleRoute];
}

// 将最新的路由信息同步到 localStorage 中
const routes = router.getRoutes();
cacheRoutes.forEach((cacheRoute) => {
const route = routes.find((route) => route.path === cacheRoute.path);
if (route) {
cacheRoute.meta = route.meta || cacheRoute.meta;
cacheRoute.name = route.name || cacheRoute.name;
}
});

// 初始化标签页
tabsViewStore.initTabs(cacheRoutes);

//监听滚动条
function onScroll(e) {
let scrollTop =
e.target.scrollTop ||
document.documentElement.scrollTop ||
window.pageYOffset ||
document.body.scrollTop; // 滚动条偏移量
state.isMultiHeaderFixed = !!(
!getHeaderSetting.value.fixed &&
getMultiTabsSetting.value.fixed &&
scrollTop >= 64
);
}

window.addEventListener('scroll', onScroll, true);

// 移除缓存组件名称
const delKeepAliveCompName = () => {
if (route.meta.keepAlive) {
const name = router.currentRoute.value.matched.find(
(item) => item.name == route.name
)?.components?.default.name;
if (name) {
asyncRouteStore.keepAliveComponents =
asyncRouteStore.keepAliveComponents.filter((item) => item != name);
}
}
};

// 标签页列表
const tabsList = computed(() => tabsViewStore.tabsList);
const whiteList = [
PageEnum.BASE_LOGIN_NAME,
PageEnum.REDIRECT_NAME,
PageEnum.ERROR_PAGE_NAME,
];

watch(
() => route.fullPath,
(to) => {
if (whiteList.includes(route.name)) return;
state.activeKey = to;
tabsViewStore.addTabs(getSimpleRoute(route));
updateNavScroll(true);
},
{ immediate: true }
);

// 在页面关闭或刷新之前,保存数据
window.addEventListener('beforeunload', () => {
storage.set(TABS_ROUTES, JSON.stringify(tabsList.value));
});

// 关闭当前页面
const removeTab = (route) => {
if (tabsList.value.length === 1) {
return message.warning('这已经是最后一页,不能再关闭了!');
}
delKeepAliveCompName();
tabsViewStore.closeCurrentTab(route);
// 如果关闭的是当前页
if (state.activeKey === route.fullPath) {
const currentRoute =
tabsList.value[Math.max(0, tabsList.value.length - 1)];
state.activeKey = currentRoute.fullPath;
router.push(currentRoute);
}
updateNavScroll();
};

// 刷新页面
const reloadPage = () => {
delKeepAliveCompName();
router.push({
path: '/redirect' + unref(route).fullPath,
});
};

// 注入刷新页面方法
provide('reloadPage', reloadPage);

// 关闭左侧
const closeLeft = (route) => {
tabsViewStore.closeLeftTabs(route);
state.activeKey = route.fullPath;
router.replace(route.fullPath);
updateNavScroll();
};

// 关闭右侧
const closeRight = (route) => {
tabsViewStore.closeRightTabs(route);
state.activeKey = route.fullPath;
router.replace(route.fullPath);
updateNavScroll();
};

// 关闭其他
const closeOther = (route) => {
tabsViewStore.closeOtherTabs(route);
state.activeKey = route.fullPath;
router.replace(route.fullPath);
updateNavScroll();
};

// 关闭全部
const closeAll = () => {
tabsViewStore.closeAllTabs();
router.replace(PageEnum.BASE_HOME);
updateNavScroll();
};

//tab 操作
const closeHandleSelect = (key) => {
switch (key) {
//刷新
case '1':
reloadPage();
break;
//关闭
case '2':
removeTab(route);
break;
//关闭其他
case '3':
closeOther(route);
break;
//关闭所有
case '4':
closeAll();
break;
}
updateNavScroll();
state.showDropdown = false;
};

/**
* @param value 要滚动到的位置
* @param amplitude 每次滚动的长度
*/
function scrollTo(value, amplitude) {
const currentScroll = navScroll.value.scrollLeft;
const scrollWidth =
(amplitude > 0 && currentScroll + amplitude >= value) ||
(amplitude < 0 && currentScroll + amplitude <= value)
? value
: currentScroll + amplitude;
navScroll.value && navScroll.value.scrollTo(scrollWidth, 0);
if (scrollWidth === value) return;
return window.requestAnimationFrame(() => scrollTo(value, amplitude));
}

function scrollPrev() {
const containerWidth = navScroll.value.offsetWidth;
const currentScroll = navScroll.value.scrollLeft;

if (!currentScroll) return;
const scrollLeft =
currentScroll > containerWidth ? currentScroll - containerWidth : 0;
scrollTo(scrollLeft, (scrollLeft - currentScroll) / 20);
}

function scrollNext() {
const containerWidth = navScroll.value.offsetWidth;
const navWidth = navScroll.value.scrollWidth;
const currentScroll = navScroll.value.scrollLeft;

if (navWidth - currentScroll <= containerWidth) return;
const scrollLeft =
navWidth - currentScroll > containerWidth * 2
? currentScroll + containerWidth
: navWidth - containerWidth;
scrollTo(scrollLeft, (scrollLeft - currentScroll) / 20);
}

/**
* @param autoScroll 是否开启自动滚动功能
*/
async function updateNavScroll(autoScroll) {
await nextTick();
if (!navScroll.value) return;
const containerWidth = navScroll.value.offsetWidth;
const navWidth = navScroll.value.scrollWidth;

if (containerWidth < navWidth) {
state.scrollable = true;
if (autoScroll) {
let tagList =
navScroll.value.querySelectorAll('.tabs-card-scroll-item') || [];
[...tagList].forEach((tag) => {
// fix SyntaxError
if (tag.id === `tag${state.activeKey.split('/').join('/')}`) {
// eslint-disable-line
tag.scrollIntoView && tag.scrollIntoView();
}
});
}
} else {
state.scrollable = false;
}
}

function handleResize() {
updateNavScroll(true);
}

function handleContextMenu(e, item) {
e.preventDefault();
isCurrent.value = PageEnum.BASE_HOME_REDIRECT === item.path;
state.showDropdown = false;
nextTick().then(() => {
state.showDropdown = true;
state.dropdownX = e.clientX;
state.dropdownY = e.clientY;
});
}

function onClickOutside() {
state.showDropdown = false;
}

//tags 跳转页面
function goPage(e) {
const { fullPath } = e;
if (fullPath === route.fullPath) return;
state.activeKey = fullPath;
go(e, true);
}

//删除tab
function closeTabItem(e) {
const { fullPath } = e;
const routeInfo = tabsList.value.find(
(item) => item.fullPath == fullPath
);
removeTab(routeInfo);
}

onMounted(() => {
onElementResize();
});

function onElementResize() {
let observer;
observer = elementResizeDetectorMaker();
observer.listenTo(navWrap.value, handleResize);
}

return {
...toRefs(state),
navWrap,
navScroll,
route,
tabsList,
goPage,
closeTabItem,
closeLeft,
closeRight,
closeOther,
closeAll,
reloadPage,
getChangeStyle,
TabsMenuOptions,
closeHandleSelect,
scrollNext,
scrollPrev,
handleContextMenu,
onClickOutside,
getDarkTheme,
getAppTheme,
getCardColor,
getBaseColor,
};
},
});
</script>

<style lang="less" scoped>
.tabs-view {
width: 100%;
padding: 6px 0;
display: flex;
transition: all 0.2s ease-in-out;

&-main {
height: 32px;
display: flex;
max-width: 100%;
min-width: 100%;

.tabs-card {
-webkit-box-flex: 1;
flex-grow: 1;
flex-shrink: 1;
overflow: hidden;
position: relative;

.tabs-card-prev,
.tabs-card-next {
width: 32px;
text-align: center;
position: absolute;
line-height: 32px;
cursor: pointer;

.n-icon {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
}
}

.tabs-card-prev {
left: 0;
}

.tabs-card-next {
right: 0;
}

.tabs-card-next-hide,
.tabs-card-prev-hide {
display: none;
}

&-scroll {
white-space: nowrap;
overflow: hidden;

&-item {
background: v-bind(getCardColor);
color: v-bind(getBaseColor);
height: 32px;
padding: 6px 16px 4px;
border-radius: 3px;
margin-right: 6px;
cursor: pointer;
display: inline-block;
position: relative;
flex: 0 0 auto;

span {
float: left;
vertical-align: middle;
}

&:hover {
color: #515a6e;
}

.n-icon {
height: 22px;
width: 21px;
margin-right: -6px;
position: relative;
vertical-align: middle;
text-align: center;
color: #808695;

&:hover {
color: #515a6e !important;
}

svg {
height: 21px;
display: inline-block;
}
}
}

.active-item {
color: v-bind(getAppTheme);
}
}
}

.tabs-card-scrollable {
padding: 0 32px;
overflow: hidden;
}
}

.tabs-close {
min-width: 32px;
width: 32px;
height: 32px;
line-height: 32px;
text-align: center;
background: var(--color);
border-radius: 2px;
cursor: pointer;

&-btn {
color: var(--color);
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
}

.tabs-view-default-background {
background: #f5f7f9;
}

.tabs-view-dark-background {
background: #101014;
}

.tabs-view-fix {
position: fixed;
z-index: 5;
padding: 6px 10px 6px 10px;
left: 200px;
}

.tabs-view-fixed-header {
top: 0;
}
</style>

组装组件

创建src/layout/index.vue,写入以下内容:

layout/index.vue
<template>
<n-layout class="layout" :position="fixedMenu" has-sider>
<n-layout-sider
v-if="!isMobile && navMode === 'vertical'"
show-trigger="bar"
@collapse="collapsed = true"
:position="fixedMenu"
@expand="collapsed = false"
:collapsed="collapsed"
collapsed-mode="width"
:collapsed-width="64"
:width="leftMenuWidth"
:native-scrollbar="false"
:inverted="inverted"
class="layout-sider"
>
<Logo :collapsed="collapsed" />
<AsideMenu
v-model:collapsed="collapsed"
v-model:location="getMenuLocation"
/>
</n-layout-sider>
<!-- 移动端侧边栏 -->
<n-drawer
v-model:show="showSideDrawder"
:width="menuWidth"
:placement="'left'"
class="layout-side-drawer"
>
<Logo :collapsed="collapsed" />
<AsideMenu @clickMenuItem="collapsed = false" />
</n-drawer>
<!-- 头部 -->
<n-layout :inverted="inverted">
<n-layout-header :inverted="getHeaderInverted" :position="fixedHeader">
<PageHeader v-model:collapsed="collapsed" :inverted="inverted" />
</n-layout-header>
<!-- 中间部分 -->
<n-layout-content
class="layout-content"
:class="{ 'layout-default-background': getDarkTheme === false }"
>
<div
class="layout-content-main"
:class="{
'layout-content-main-fix': fixedMulti,
'fluid-header': fixedHeader === 'static',
}"
>
<TabsView v-if="isMultiTabs" v-model:collapsed="collapsed" />
<div
class="main-view"
:class="{
'main-view-fix': fixedMulti,
noMultiTabs: !isMultiTabs,
'mt-3': !isMultiTabs,
}"
>
<MainView />
</div>
</div>
</n-layout-content>
<n-back-top :right="100" />
</n-layout>
</n-layout>
</template>
<script>
import { computed, defineComponent, onMounted, ref, unref } from 'vue';
import { Logo } from './components/Logo';
import { PageHeader } from './components/Header';
import { MainView } from './components/Main';
import { TabsView } from './components/TabsView';
import { AsideMenu } from './components/Menu';
import { useProjectSetting } from '@/hooks/setting/useProjectSetting';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { useDesignSetting } from '@/hooks/setting/useDesignSetting';

export default defineComponent({
components: { Logo, PageHeader, MainView, TabsView, AsideMenu },
setup() {
const {
getNavMode,
getNavTheme,
getHeaderSetting,
getMenuSetting,
getMultiTabsSetting,
} = useProjectSetting();
const settingStore = useProjectSettingStore();
const { getDarkTheme } = useDesignSetting();
const navMode = getNavMode;

const collapsed = ref(false);

const { mobileWidth, menuWidth } = unref(getMenuSetting);

const isMobile = computed({
get: () => settingStore.getIsMobile,
set: (val) => settingStore.setIsMobile(val),
});
// 固定头部
const fixedHeader = computed(() => {
const { fixed } = unref(getHeaderSetting);
return fixed ? 'absolute' : 'static';
});
// 固定菜单
const fixedMenu = computed(() => {
const { fixed } = unref(getHeaderSetting);
return fixed ? 'absolute' : 'static';
});
// 多标签页
const isMultiTabs = computed(() => {
return unref(getMultiTabsSetting).show;
});
const fixedMulti = computed(() => {
return unref(getMultiTabsSetting).fixed;
});
const leftMenuWidth = computed(() => {
const { minMenuWidth, menuWidth } = unref(getMenuSetting);
return collapsed.value ? minMenuWidth : menuWidth;
});

const inverted = computed(() => {
return ['dark', 'header-dark'].includes(unref(getNavTheme));
});

const getHeaderInverted = computed(() => {
const navTheme = unref(getNavTheme);
return ['light', 'header-dark'].includes(navTheme)
? unref(inverted)
: !unref(inverted);
});

const getMenuLocation = computed(() => {
return 'left';
});

// 控制显示或隐藏移动端侧边栏
const showSideDrawder = computed({
get: () => isMobile.value && collapsed.value,
set: (val) => (collapsed.value = val),
});

//判断是否触发移动端模式
const checkMobileMode = () => {
if (document.body.clientWidth <= mobileWidth) {
isMobile.value = true;
} else {
isMobile.value = false;
}
collapsed.value = false;
};
// 监听宽度变化
const watchWidth = () => {
const Width = document.body.clientWidth;
if (Width <= 950) {
collapsed.value = true;
} else collapsed.value = false;

checkMobileMode();
};

onMounted(() => {
checkMobileMode();
window.addEventListener('resize', watchWidth);
});

return {
navMode,
isMultiTabs,
menuWidth,
isMobile,
fixedMulti,
fixedMenu,
fixedHeader,
collapsed,
inverted,
checkMobileMode,
getDarkTheme,
getHeaderInverted,
leftMenuWidth,
getMenuLocation,
showSideDrawder,
};
},
});
</script>
<style lang="less">
.layout-side-drawer {
background-color: rgb(0, 20, 40);

.layout-sider {
min-height: 100vh;
box-shadow: 2px 0 8px 0 rgb(29 35 41 / 5%);
position: relative;
z-index: 13;
transition: all 0.2s ease-in-out;
}
}
</style>
<style lang="less" scoped>
.layout {
display: flex;
flex-direction: row;
flex: auto;

&-default-background {
background: #f5f7f9;
}

.layout-sider {
min-height: 100vh;
box-shadow: 2px 0 8px 0 rgb(29 35 41 / 5%);
position: relative;
z-index: 13;
transition: all 0.2s ease-in-out;
}

.layout-sider-fix {
position: fixed;
top: 0;
left: 0;
}

.ant-layout {
overflow: hidden;
}

.layout-right-fix {
overflow-x: hidden;
padding-left: 200px;
min-height: 100vh;
transition: all 0.2s ease-in-out;
}

.layout-content {
flex: auto;
min-height: 100vh;
}

.n-layout-header.n-layout-header--absolute-positioned {
z-index: 11;
}

.n-layout-footer {
background: none;
}
}

.layout-content-main {
margin: 0 10px 10px;
position: relative;
padding-top: 64px;
}

.layout-content-main-fix {
padding-top: 64px;
}

.fluid-header {
padding-top: 0;
}

.main-view-fix {
padding-top: 44px;
}

.noMultiTabs {
padding-top: 0;
}
</style>

至此,我们完成了Layout的编写,当页面需要被加载到中间部分时,只需要在定义其路由时指定

component: Layout