Vue3后台管理系统(五):布局系统
一、自适应布局实现
1.1 布局系统设计思路
- 响应式设计:需要适配不同尺寸的屏幕
- 可折叠侧边栏:提供更多内容显示空间
- 多级菜单支持:适应复杂的导航需求
- 内容区域自适应:保持良好的内容展示
布局设计参考了经典的Admin布局,分为三个主要区域:
- 侧边栏(Sidebar):显示导航菜单
- 顶部栏(AppHeader):显示面包屑、用户信息等
- 主内容区(AppMain):显示页面内容
1.2 基础布局组件实现
vue
<!-- src/layout/index.vue -->
<template>
<div class="app-wrapper">
<Sidebar class="sidebar-container" />
<div class="main-container">
<div class="header">
<AppHeader />
</div>
<div class="app-main" v-loading="loading" element-loading-text="加载中..." element-loading-background="rgba(0, 0, 0, 0)">
<router-view v-slot="{ Component }">
<transition name="el-fade-in">
<keep-alive :include="keepAliveRoutes">
<component :is="Component" :key="$route.path" />
</keep-alive>
</transition>
</router-view>
</div>
</div>
</div>
</template>
<script setup>
import Sidebar from './components/Sidebar.vue' // 侧边栏组件
import AppHeader from './components/AppHeader.vue' // 顶部导航栏组件
import { useLoadingStore } from '@/stores/loading' // 加载状态管理
import { storeToRefs } from 'pinia' // 保持store属性的响应性
import { ref, computed, provide } from 'vue' // Vue3组合式API
// 从loading store中提取loading状态,保持响应性
const { loading } = storeToRefs(useLoadingStore())
// 路由历史记录 - 存储访问过的路由信息
const routes = ref([])
// 需要缓存的路由组件名列表 - 用于keep-alive
const keepAliveRoutes = computed(() => {
return routes.value.map((item) => item.name).filter(Boolean)
})
// 提供给AppHeader使用的方法和数据
provide('routeHistory', {
routes,
keepAliveRoutes,
})
// 使用依赖注入共享数据
// 1. 通过provide/inject在组件树中共享状态,避免props逐级传递
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *; // 导入全局样式变量
// 主布局样式
.app-wrapper {
height: 100%;
width: 100%;
display: flex;
// flex布局实现左右分栏
// 1. 简单高效地实现侧边栏+主内容区的布局
// 2. 自动适应内容高度,无需手动计算
// 侧边栏容器
.sidebar-container {
height: 100%;
background-color: #304156;
transition: width 0.3s; // 添加过渡效果,平滑宽度变化
}
// 主内容区容器
.main-container {
flex: 1; // 占据剩余空间
display: flex;
flex-direction: column; // 垂直方向flex布局
overflow: hidden; // 防止内容溢出
// 嵌套flex布局
// 1. 外层flex实现左右布局
// 2. 内层flex实现上下布局
// 3. 完美适配各种屏幕尺寸,自动处理内容溢出
// 顶部导航栏
.header {
height: $header-height; // 使用变量控制高度,便于统一管理
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); // 添加阴影,增强层次感
// 使用SCSS变量
// 1. 从变量文件导入尺寸,便于统一修改
// 2. 使用轻微阴影分隔顶栏与内容区,增强视觉体验
}
// 主内容区
.app-main {
flex: 1; // 占据剩余空间
padding: 10px; // 内容区内边距
overflow-y: auto; // 内容超出时显示纵向滚动条
background-color: $background-color; // 使用变量控制背景色
// 自定义滚动条样式
&::-webkit-scrollbar {
width: 6px; // 窄滚动条,美观且不占空间
}
// 内容区自适应
// 1. 使用flex: 1自动占据剩余空间
// 2. 只在内容区显示滚动条,保持整体布局稳定
// 3. 自定义滚动条样式,提升用户体验
}
}
}
</style>
1.3 使用组合式API管理布局状态
使用Pinia与组合式API来管理布局状态:
javascript
// src/stores/layout.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
/**
* @description 布局状态管理
* @Author Wangkaibing
* @Date 2025-04-15
*
* Usage:
* import { useLayoutStore } from '@/stores/layout'
*
* const layoutStore = useLayoutStore()
* console.log(layoutStore.scale) // 获取当前缩放比例
* layoutStore.setScale(0.8) // 设置缩放比例
*
* // 在组件中结合计算属性使用
* const isCollapse = computed(() => layoutStore.scale < 0.75)
*/
export const useLayoutStore = defineStore('layout', () => {
// 页面缩放比例,默认为1(原始大小)
const scale = ref(1)
// 设置缩放比例的方法
const setScale = (value) => {
scale.value = value
}
// 提供修改状态的方法
// 1. 使用方法封装状态修改,遵循单一职责原则
// 返回状态和方法
return {
scale,
setScale,
}
})
二、侧边栏实现
2.1 侧边栏组件
vue
<!-- src/layout/components/Sidebar.vue -->
<template>
<div class="sidebar" :class="{ 'is-collapse': isCollapse }">
<div class="logo-container">
<div class="logo-content">
<img class="logo" src="https://cdn.xjy0.cn/filling/top_logo.png" />
<div class="title" v-show="!isCollapse">畜牧产业数据填报系统</div>
<!-- 根据折叠状态条件显示标题
1. 使用v-show而非v-if,避免频繁创建销毁DOM
2. 折叠时隐藏标题,节省空间
-->
</div>
</div>
<div class="menu-container">
<el-scrollbar>
<!-- 使用Element Plus滚动条组件,确保内容超出时可滚动 -->
<el-menu
:default-active="route.path"
class="el-menu-vertical"
:collapse="isCollapse"
:unique-opened="true"
router
>
<!-- 菜单配置
1. default-active绑定当前路由路径,自动高亮当前菜单
2. collapse控制菜单折叠状态,响应式更新
3. unique-opened确保同时只有一个子菜单展开
4. router属性启用路由模式,点击菜单项自动跳转到对应路由
-->
<template v-for="item in menuRoutes" :key="item.path">
<el-sub-menu :index="item.path" popper-class="sidebar-popper">
<template #title>
<!-- 菜单图标 -->
<img
class="menu-icon"
:src="`https://cdn.xjy0.cn/filling/nav_icon${item.meta?.icon || '5'}.png`"
/>
<span>{{ item.meta?.title }}</span>
<el-icon class="menu-arrow-icon">
<CaretBottom />
</el-icon>
</template>
<!-- 子菜单遍历 -->
<el-menu-item
v-for="child in item.children"
:key="child.path"
:index="item.path + '/' + child.path"
>
<span>{{ child.meta?.title }}</span>
</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
</el-scrollbar>
<!-- 折叠/展开按钮 -->
<div class="collapse-btn" @click="toggleCollapse">
<el-icon class="collapse-icon">
<Expand v-if="isCollapse" />
<Fold v-else />
<!-- 动态图标
1. 根据当前状态显示不同图标
2. 展开时显示折叠图标,折叠时显示展开图标
3. 图标直观表示点击后的状态变化
-->
</el-icon>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue' // Vue3 组合式API
import { useRoute } from 'vue-router' // Vue Router组合式API
import { usePermissionStore } from '@/composables/usePermission' // 权限相关组合式API
import { useLayoutStore } from '@/stores/layout' // 布局状态管理
const route = useRoute() // 获取当前路由实例
const permissionStore = usePermissionStore() // 获取权限状态管理
const layoutStore = useLayoutStore() // 获取布局状态管理
// 手动控制折叠状态
const manualCollapse = ref(false)
// 计算侧边栏是否折叠
const isCollapse = computed(() => {
// 如果是手动控制,优先使用手动状态
if (manualCollapse.value) {
return true
}
// 否则根据scale自动计算
return layoutStore.scale < 0.75
})
// 计算显示的路由菜单
const menuRoutes = computed(() => {
return permissionStore.routes.filter((route) =>
route.meta?.sidebarVisible !== false && // 过滤不应在侧边栏显示的路由
route.children?.length > 0 // 确保有子路由才显示
)
// 菜单数据处理
// 1. 基于权限路由动态生成菜单
// 2. 使用computed自动响应路由变化
// 3. 使用可选链操作符安全访问属性
// 4. 通过meta.sidebarVisible控制菜单项显隐
})
// 手动切换菜单展开/收起
const toggleCollapse = () => {
if (isCollapse.value) {
// 展开
manualCollapse.value = false
layoutStore.setScale(1) // 恢复原始比例
} else {
// 收起
manualCollapse.value = true
layoutStore.setScale(0.8) // 设置较小比例
}
}
</script>
<style lang="scss" scoped>
.sidebar {
height: 100%;
width: $sidebar-width; // 使用SCSS变量
transition: all 0.3s; // 过渡效果
position: relative;
z-index: 10; // 确保侧边栏位于顶层
// 折叠状态样式
&.is-collapse {
width: $sidebar-collapse-width;
.logo-container {
padding: 20px 0;
.logo-content {
justify-content: center;
.logo {
margin: 0;
width: 35px;
height: 30px;
}
}
}
.collapse-btn {
right: calc(50% - 20px);
}
}
}
// LOGO容器样式
.logo-container {
height: $logo-height;
padding: 20px;
background-color: $sidebar-bg-color;
position: relative;
.logo-content {
height: 100%;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center; // 垂直居中排列
.logo {
width: $logo-width;
height: 43px;
margin-bottom: 10px;
}
.title {
color: $sidebar-text-color;
font-size: 22px;
letter-spacing: 3px;
font-weight: 900;
line-height: 38px;
margin: 0;
white-space: nowrap; // 防止文字换行
}
}
}
// 菜单容器样式
.menu-container {
height: calc(100% - #{$logo-height}); // 动态计算高度
background-color: $primary-light-color;
box-shadow: 0 2px 4px rgba(0, 47, 141, 0.05); // 轻微阴影
// SCSS插值语法
// 1. 使用calc(100% - #{$logo-height})动态计算菜单容器高度
// 2. 通过SCSS变量确保高度计算与实际logo高度保持同步
}
// 自定义菜单图标样式
.menu-icon {
width: 20px;
height: 20px;
margin-right: 14px;
vertical-align: middle;
}
:deep(.el-menu) {
border: none; // 去除默认边框
background-color: transparent; // 背景透明
// 菜单项和子菜单标题样式
.el-menu-item,
.el-sub-menu__title {
height: 65px; // 较大的菜单高度
line-height: 65px;
color: $sidebar-text-color;
background-color: transparent;
font-size: $menu-text-size;
padding: 0 20px !important; // 强制覆盖默认padding
&:hover {
color: $sidebar-active-text-color;
background-color: $primary-active-color;
}
}
// 菜单项样式
.el-menu-item {
padding-left: 20px !important;
&.is-active {
color: $sidebar-active-text-color;
background-color: $primary-active-color;
}
}
// 子菜单样式
.el-sub-menu {
&.is-active {
> .el-sub-menu__title {
color: $sidebar-active-text-color;
}
}
// 子菜单列表样式
.el-menu {
background-color: $primary-dark-color !important; // 强制深色背景
.el-menu-item {
padding-left: 55px !important; // 子菜单项缩进
height: 65px;
line-height: 65px;
&:hover {
color: $sidebar-active-text-color;
background-color: $primary-active-color;
}
&.is-active {
color: $sidebar-active-text-color;
background-color: $primary-active-color;
}
span {
width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; // 文字溢出显示省略号
}
// 文本溢出处理
// 1. 宽度限制为200px,超出宽度文本自动截断
// 2. 使用text-overflow: ellipsis显示省略号
// 3. 保持布局整洁,防止长文本破坏UI
}
}
}
}
// 子菜单标题样式
:deep(.el-sub-menu) {
.el-sub-menu__title {
position: relative;
.el-sub-menu__icon-arrow {
display: none; // 隐藏默认箭头图标
}
}
}
// 自定义菜单箭头图标
.menu-arrow-icon {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%); // 垂直居中
font-size: 28px;
opacity: 50%; // 半透明
color: currentColor; // 继承父元素颜色
transition: transform 0.3s;
transform-origin: center 25%;
// 自定义箭头图标
// 1. 使用绝对定位将箭头放在菜单项右侧
// 2. 使用transform垂直居中
// 3. 半透明效果使界面更柔和
// 4. 使用currentColor继承父元素文字颜色
}
// 折叠菜单样式
:deep(.el-menu--collapse) {
width: $sidebar-collapse-width; // 折叠宽度
.el-menu-item,
.el-sub-menu__title {
padding: 0 !important;
justify-content: center; // 居中对齐
.menu-icon {
margin: 0; // 移除图标右侧间距
}
.menu-arrow-icon {
display: none; // 隐藏箭头
}
}
// 折叠状态优化
// 1. 固定宽度与变量保持一致
// 2. 内容居中显示,只保留图标
// 3. 隐藏不必要的元素如箭头,简化界面
}
// 折叠按钮样式
.collapse-btn {
position: absolute;
right: 20px;
bottom: 20px; // 定位在侧边栏底部
cursor: pointer; // 鼠标指针样式
.collapse-icon {
font-size: 45px;
color: #ffffff;
}
// 折叠控制按钮
// 1. 使用绝对定位,固定在侧边栏底部
// 2. 较大图标尺寸,提高可点击性
// 3. 白色图标在深色背景上对比度高,易于识别
}
</style>
2.2 侧边栏折叠功能
根据屏幕尺寸和用户操作来控制侧边栏的折叠:
- 当屏幕缩放比例低于0.75时自动折叠
- 用户可以通过底部按钮手动折叠/展开
三、顶部导航栏实现
顶部导航栏通常包含面包屑导航、用户信息和操作菜单:
vue
<!-- src/layout/components/AppHeader.vue -->
<template>
<div class="navbar">
<div class="left-menu">
<div class="breadcrumb-tags">
<router-link v-for="(item, index) in routeHistory.routes.value" :key="item.path" :to="item.path" class="tag-item" :class="{ active: route.path === item.path }">
{{ item.title }}
<el-icon class="close-icon" @click.prevent="closePage(item, index)" v-if="routeHistory.routes.value.length > 1">
<Close />
</el-icon>
</router-link>
</div>
</div>
<div class="right-menu">
<el-button class="right-menu-item" link @click="handChangeRole" v-role="'ROLE_SWITCH_USER'">
<el-image style="width: 24px; height: 24px; margin-right: 5px" src="https://cdn.xjy0.cn/filling/changeRole.png" />
切换账号
</el-button>
<el-dropdown class="avatar-container" trigger="click">
<div class="avatar-wrapper">
<el-avatar shape="square" :size="32" src="https://cdn.xjy0.cn/filling/Group.png" />
<span class="name">{{ authStore.state.userInfo.fullName }}</span>
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="user-dropdown">
<router-link to="/my/account">
<el-dropdown-item>我的账户</el-dropdown-item>
</router-link>
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-dialog v-model="dialogVisible" title="切换账号" width="600px" :close-on-click-modal="false" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="130px">
<el-form-item label="请输入账号" prop="mobile">
<el-input v-model="formData.mobile" placeholder="请输入手机号" maxlength="11" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer flex-between">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import { useLogin } from '@/composables/useLogin.js'
import { usePost } from '@/api/request.js'
import { TokenService } from '@/utils/token.js'
import { invalidateCache } from 'alova'
const router = useRouter()
const route = useRoute()
const { logout } = useLogin()
const authStore = useAuthStore()
// 注入路由历史相关数据和方法
const routeHistory = inject('routeHistory')
const dialogVisible = ref(false)
const formRef = ref(null)
const formData = reactive({
mobile: '',
})
watch(
() => route.path,
() => {
// 排除登录页和其他不需要记录的页面
const excludePaths = ['/login', '/404']
if (excludePaths.includes(route.path)) return
const currentRoute = {
path: route.path,
name: route.name,
title: route.meta.title,
}
// 检查是否已存在
const index = routeHistory.routes.value.findIndex((item) => item.path === currentRoute.path)
if (index === -1) {
// 不存在则添加
if (routeHistory.routes.value.length >= 6) {
routeHistory.routes.value.shift()
}
routeHistory.routes.value.push(currentRoute)
}
},
{ immediate: true },
)
// 校验
const rules = {
mobile: [{ required: true, message: '请输入手机号码', trigger: 'blur' }],
}
// 切换账号
const handChangeRole = () => {
formData.mobile = ''
dialogVisible.value = true
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
usePost('/api/xxxx', { mobile: formData.mobile, token: TokenService.getRefreshToken() }).then((res) => {
if (res && res.data) {
const { token_type, access_token, refresh_token, expires_in } = res
TokenService.setTokens(token_type + ' ' + access_token, refresh_token, expires_in * 1000)
dialogVisible.value = false
router.push('/')
setTimeout(() => {
window.location.reload()
}, 500)
}
})
}
})
}
// 关闭页面
const closePage = (item, index) => {
routeHistory.routes.value.splice(index, 1)
// 如果关闭的是当前页面,需要跳转
if (item.path === route.path) {
if (index === 0) {
// 如果关闭的是第一个,跳转到新的第一个
if (routeHistory.routes.value.length > 0) {
router.push(routeHistory.routes.value[0].path)
} else {
router.push('/my/account')
}
} else {
// 否则跳转到前一个
router.push(routeHistory.routes.value[index - 1].path)
}
}
}
// 退出
const handleLogout = () => {
setTimeout(() => {
// 清除接口缓存
invalidateCache()
routeHistory.routes.value = []
logout()
}, 500)
}
</script>
<style lang="scss" scoped>
.navbar {
height: 60px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
}
.left-menu {
.breadcrumb-tags {
display: flex;
align-items: center;
height: 60px;
.tag-item {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 8px;
margin-right: 8px;
color: #666;
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 2px;
text-decoration: none;
transition: all 0.3s;
.close-icon {
margin-left: 4px;
font-size: 12px;
cursor: pointer;
&:hover {
color: #1f75e3;
}
}
&:hover {
color: #1f75e3;
border-color: #1f75e3;
background: #ecf5ff;
}
&.active {
color: #1f75e3;
border-color: #1f75e3;
background: #ecf5ff;
}
}
}
}
.right-menu {
display: flex;
align-items: center;
gap: 8px;
.right-menu-item {
display: inline-flex;
align-items: center;
justify-content: center;
height: 60px;
padding: 0 12px;
color: #666;
vertical-align: text-bottom;
background: transparent;
border-radius: 0;
&:hover {
background: rgba(0, 0, 0, 0.025);
color: #1f75e3;
}
.icon {
width: 16px;
height: 16px;
margin-right: 4px;
}
}
}
.avatar-container {
margin-left: 8px;
.avatar-wrapper {
display: flex;
align-items: center;
padding: 0 12px;
height: 60px;
cursor: pointer;
.el-avatar--square {
background-color: transparent;
}
&:hover {
background: rgba(0, 0, 0, 0.025);
}
.name {
margin: 0 4px 0 8px;
color: #666;
}
.el-icon--right {
color: #666;
font-size: 12px;
}
}
}
:deep(.user-dropdown) {
.el-dropdown-menu__item {
padding: 8px 16px;
line-height: 1.5;
color: #666;
&:not(.is-disabled):hover {
background-color: #f5f7fa;
color: #1f75e3;
}
}
}
</style>
3.1 标签式导航
在顶部导航栏中实现了类似浏览器标签页的导航功能,方便快速在多个访问过的页面间切换。每个标签都对应一个路由,包含以下特点:
- 自动记录访问历史
- 点击标签可直接跳转到对应页面
- 可以关闭不需要的标签页
- 限制最大标签数量,防止过多标签影响用户体验
四、多级菜单与路由整合
4.1 动态菜单与权限
基于权限系统生成动态菜单:
- 通过
permissionStore.routes
获取当前用户有权限访问的路由 - 使用
computed
属性过滤出需要在侧边栏显示的菜单项 - 结合路由元数据(
meta
)展示菜单图标和标题
菜单与路由的关系非常紧密,通过在路由配置中添加元数据来控制菜单的显示:
javascript
// src/router/routes.js 示例
{
path: '/platform',
component: Layout,
redirect: '/platform/user',
meta: {
title: '平台管理',
icon: '6',
sidebarVisible: true, // 是否在侧边栏显示
},
children: [
{
path: 'user',
name: 'PlatformUsers',
component: () => import('@/views/platform/user.vue'),
meta: {
title: '用户管理',
},
},
{
path: 'role',
name: 'PlatformRole',
component: () => import('@/views/platform/role.vue'),
meta: {
title: '角色管理',
},
},
],
}
五、响应式设计与自适应布局
5.1 使用SCSS变量统一布局尺寸
为了保持布局的一致性和可维护性,使用SCSS变量定义关键的布局尺寸:
scss
// src/styles/variables.scss
// 布局相关变量
$sidebar-width: 210px; // 侧边栏宽度
$sidebar-collapse-width: 54px; // 侧边栏折叠后宽度
$header-height: 60px; // 顶部导航栏高度
$logo-height: 130px; // Logo区域高度
$logo-width: 80px; // Logo宽度
// 颜色变量
$primary-color: #1f75e3; // 主色调
$primary-light-color: #304156; // 侧边栏背景色
$primary-dark-color: #1f2d3d; // 侧边栏子菜单背景色
$primary-active-color: rgba(0, 0, 0, 0.2); // 菜单激活背景色
$sidebar-bg-color: #263445; // 侧边栏Logo区域背景色
$sidebar-text-color: #f5f5f5; // 侧边栏文字颜色
$sidebar-active-text-color: #fff; // 侧边栏激活文字颜色
$background-color: #f0f2f5; // 页面背景色
// 字体尺寸变量
$menu-text-size: 15px; // 菜单文字大小
5.2 缩放自适应处理
使用缩放比例来处理不同屏幕尺寸的适应:
javascript
// 在合适的组件中导入并使用自适应逻辑
import { useLayoutStore } from '@/stores/layout'
import autofit from 'autofit.js'
const layoutStore = useLayoutStore()
// 监听缩放比例变化
const resizeHandler = () => {
// 根据屏幕宽度计算缩放比例
const scale = autofit.getScale()
layoutStore.setScale(scale)
}
// 挂载时添加监听
onMounted(() => {
window.addEventListener('resize', resizeHandler)
// 初始化时调用一次
resizeHandler()
})
// 卸载时移除监听
onUnmounted(() => {
window.removeEventListener('resize', resizeHandler)
})
5.3 自动折叠侧边栏
当屏幕宽度过窄或用户手动切换时,侧边栏会自动折叠:
javascript
// 在Sidebar.vue中
const isCollapse = computed(() => {
// 如果是手动控制,优先使用手动状态
if (manualCollapse.value) {
return true
}
// 否则根据scale自动计算
return layoutStore.scale < 0.75
})