494 lines
12 KiB
Vue
494 lines
12 KiB
Vue
<template>
|
||
<div class="layout-container">
|
||
<el-container>
|
||
<!-- 侧边栏 -->
|
||
<el-aside :width="sidebarWidth" v-show="!isHidden" :class="['sidebar-container', { resizing: isResizing }]">
|
||
<div class="sidebar">
|
||
<div class="logo">
|
||
<template v-if="autoCollapse">
|
||
<el-icon size="24" style="color: white;">
|
||
<Setting />
|
||
</el-icon>
|
||
</template>
|
||
<template v-else>
|
||
<h3>Admin System</h3>
|
||
</template>
|
||
</div>
|
||
|
||
<el-menu
|
||
:default-active="activeMenu"
|
||
class="sidebar-menu"
|
||
router
|
||
:collapse="autoCollapse"
|
||
background-color="#304156"
|
||
text-color="#bfcbd9"
|
||
active-text-color="#409eff"
|
||
>
|
||
<!-- 首页固定菜单 -->
|
||
<el-menu-item index="/dashboard">
|
||
<el-icon><House /></el-icon>
|
||
<template #title>首页</template>
|
||
</el-menu-item>
|
||
|
||
<!-- 动态菜单 -->
|
||
<DynamicMenu
|
||
v-if="isMenuLoaded && visibleMenus.length > 0"
|
||
:menu-list="visibleMenus"
|
||
/>
|
||
|
||
<!-- 加载中状态 -->
|
||
<div v-else-if="!isMenuLoaded" class="menu-loading">
|
||
<el-icon class="is-loading"><Loading /></el-icon>
|
||
<span>加载菜单中...</span>
|
||
</div>
|
||
|
||
<!-- 无菜单状态 -->
|
||
<div v-else class="no-menu">
|
||
<span>暂无菜单权限</span>
|
||
</div>
|
||
</el-menu>
|
||
</div>
|
||
|
||
<!-- 拖拽调整手柄 -->
|
||
<div class="resize-handle" @mousedown="startResize"></div>
|
||
|
||
<!-- 拖拽时的宽度提示 -->
|
||
<div v-if="isResizing" class="resize-tooltip">
|
||
{{ sidebarWidthValue }}px
|
||
</div>
|
||
</el-aside>
|
||
|
||
<!-- 主内容区域 -->
|
||
<el-container>
|
||
<!-- 头部 -->
|
||
<el-header class="header">
|
||
<div class="header-left">
|
||
<el-button
|
||
type="text"
|
||
class="sidebar-toggle-btn"
|
||
@click="toggleSidebar"
|
||
>
|
||
<el-icon size="20">
|
||
<template v-if="isHidden">
|
||
<Menu />
|
||
</template>
|
||
<template v-else-if="autoCollapse">
|
||
<Expand />
|
||
</template>
|
||
<template v-else>
|
||
<Fold />
|
||
</template>
|
||
</el-icon>
|
||
</el-button>
|
||
|
||
<el-breadcrumb separator="/">
|
||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
|
||
{{ item.meta?.title || item.name }}
|
||
</el-breadcrumb-item>
|
||
</el-breadcrumb>
|
||
</div>
|
||
|
||
<div class="header-right">
|
||
<el-dropdown>
|
||
<div class="user-info">
|
||
<el-avatar
|
||
:src="userInfo?.avatar"
|
||
:size="32"
|
||
>
|
||
{{ userInfo?.nickname?.charAt(0) }}
|
||
</el-avatar>
|
||
<span class="username">{{ userInfo?.nickname || userInfo?.username }}</span>
|
||
<el-icon><ArrowDown /></el-icon>
|
||
</div>
|
||
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item @click="handleProfile">
|
||
<el-icon><User /></el-icon>
|
||
个人信息
|
||
</el-dropdown-item>
|
||
<el-dropdown-item @click="handleChangePassword">
|
||
<el-icon><Lock /></el-icon>
|
||
修改密码
|
||
</el-dropdown-item>
|
||
<el-dropdown-item divided @click="handleLogout">
|
||
<el-icon><SwitchButton /></el-icon>
|
||
退出登录
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
</el-header>
|
||
|
||
<!-- 标签页 -->
|
||
<TabsView />
|
||
|
||
<!-- 主内容 -->
|
||
<el-main class="main-content">
|
||
<router-view v-slot="{ Component }">
|
||
<keep-alive :include="cachedViews">
|
||
<component :is="Component" />
|
||
</keep-alive>
|
||
</router-view>
|
||
</el-main>
|
||
</el-container>
|
||
</el-container>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import DynamicMenu from '@/components/DynamicMenu.vue'
|
||
import TabsView from '@/components/TabsView.vue'
|
||
import { useMenuStore } from '@/stores/menu'
|
||
import { useTabsStore } from '@/stores/tabs'
|
||
import { useUserStore } from '@/stores/user'
|
||
import {
|
||
ArrowDown,
|
||
Expand,
|
||
Fold,
|
||
House,
|
||
Loading,
|
||
Lock,
|
||
Menu,
|
||
Setting,
|
||
SwitchButton,
|
||
User
|
||
} from '@element-plus/icons-vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { storeToRefs } from 'pinia'
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
|
||
const route = useRoute()
|
||
const userStore = useUserStore()
|
||
const tabsStore = useTabsStore()
|
||
const menuStore = useMenuStore()
|
||
const { userInfo } = storeToRefs(userStore)
|
||
const { visibleMenus, isMenuLoaded } = storeToRefs(menuStore)
|
||
|
||
// 缓存的视图
|
||
const cachedViews = computed(() => {
|
||
return tabsStore.tabs
|
||
.filter(tab => tab.keepAlive)
|
||
.map(tab => tab.name)
|
||
})
|
||
|
||
// 侧边栏状态
|
||
const isCollapse = ref(false)
|
||
const isHidden = ref(false)
|
||
const sidebarWidthValue = ref(200) // 实际宽度值
|
||
const isResizing = ref(false)
|
||
|
||
// 最小和最大宽度
|
||
const MIN_WIDTH = 64
|
||
const MAX_WIDTH = 400
|
||
const COLLAPSE_THRESHOLD = 120 // 小于此宽度自动折叠
|
||
|
||
// 侧边栏宽度
|
||
const sidebarWidth = computed(() => {
|
||
if (isHidden.value) return '0px'
|
||
return `${sidebarWidthValue.value}px`
|
||
})
|
||
|
||
// 根据宽度自动判断是否折叠
|
||
const autoCollapse = computed(() => {
|
||
return sidebarWidthValue.value < COLLAPSE_THRESHOLD
|
||
})
|
||
|
||
// 当前激活的菜单
|
||
const activeMenu = computed(() => route.path)
|
||
|
||
// 面包屑导航
|
||
const breadcrumbs = computed(() => {
|
||
const matched = route.matched.filter(item => item.meta && item.meta.title)
|
||
return matched.slice(1) // 移除根路由
|
||
})
|
||
|
||
// 切换侧边栏
|
||
const toggleSidebar = () => {
|
||
if (!isHidden.value && !autoCollapse.value) {
|
||
// 展开 → 折叠
|
||
sidebarWidthValue.value = MIN_WIDTH
|
||
} else if (!isHidden.value && autoCollapse.value) {
|
||
// 折叠 → 隐藏
|
||
isHidden.value = true
|
||
} else {
|
||
// 隐藏 → 展开
|
||
isHidden.value = false
|
||
sidebarWidthValue.value = 200
|
||
}
|
||
}
|
||
|
||
// 拖拽调整宽度
|
||
const startResize = (e: MouseEvent) => {
|
||
isResizing.value = true
|
||
document.addEventListener('mousemove', handleResize)
|
||
document.addEventListener('mouseup', stopResize)
|
||
e.preventDefault()
|
||
}
|
||
|
||
const handleResize = (e: MouseEvent) => {
|
||
if (!isResizing.value) return
|
||
|
||
const newWidth = e.clientX
|
||
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
||
sidebarWidthValue.value = newWidth
|
||
}
|
||
}
|
||
|
||
const stopResize = () => {
|
||
isResizing.value = false
|
||
document.removeEventListener('mousemove', handleResize)
|
||
document.removeEventListener('mouseup', stopResize)
|
||
|
||
// 拖拽结束时,如果宽度接近边界值,自动调整到边界
|
||
if (sidebarWidthValue.value < MIN_WIDTH + 10) {
|
||
sidebarWidthValue.value = MIN_WIDTH
|
||
}
|
||
}
|
||
|
||
// 监听宽度变化,保存到localStorage
|
||
watch(sidebarWidthValue, (newWidth) => {
|
||
localStorage.setItem('sidebarWidth', newWidth.toString())
|
||
})
|
||
|
||
// 处理个人信息
|
||
const handleProfile = () => {
|
||
ElMessageBox.alert('个人信息功能待开发', '提示', {
|
||
confirmButtonText: '确定'
|
||
})
|
||
}
|
||
|
||
// 处理修改密码
|
||
const handleChangePassword = () => {
|
||
ElMessage.info('修改密码功能待开发')
|
||
}
|
||
|
||
// 处理退出登录
|
||
const handleLogout = async () => {
|
||
try {
|
||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
center: true
|
||
})
|
||
|
||
await userStore.logout()
|
||
ElMessage.success('退出成功')
|
||
} catch {
|
||
// 用户取消操作
|
||
}
|
||
}
|
||
|
||
// 组件挂载时加载菜单和恢复宽度
|
||
onMounted(async () => {
|
||
// 恢复侧边栏宽度
|
||
const savedWidth = localStorage.getItem('sidebarWidth')
|
||
if (savedWidth) {
|
||
const width = parseInt(savedWidth)
|
||
if (width >= MIN_WIDTH && width <= MAX_WIDTH) {
|
||
sidebarWidthValue.value = width
|
||
}
|
||
}
|
||
|
||
// 只有在用户已登录但菜单未加载时才加载菜单
|
||
if (userStore.token && !menuStore.isMenuLoaded) {
|
||
try {
|
||
await menuStore.loadUserMenus()
|
||
} catch (error) {
|
||
console.error('加载用户菜单失败:', error)
|
||
ElMessage.error('加载菜单失败,请刷新页面重试')
|
||
}
|
||
}
|
||
})
|
||
|
||
// 监听路由变化,自动展开对应菜单
|
||
watch(
|
||
() => route.path,
|
||
() => {
|
||
// 可以在这里添加菜单自动展开逻辑
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.layout-container {
|
||
height: 100vh;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar-container {
|
||
position: relative;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.sidebar-container:not(.resizing) {
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.sidebar-container.resizing {
|
||
transition: none;
|
||
}
|
||
|
||
.sidebar {
|
||
height: 100vh;
|
||
background-color: #304156;
|
||
width: 100%;
|
||
}
|
||
|
||
/* 拖拽手柄样式 */
|
||
.resize-handle {
|
||
position: absolute;
|
||
top: 0;
|
||
right: -2px;
|
||
width: 4px;
|
||
height: 100%;
|
||
background-color: transparent;
|
||
cursor: col-resize;
|
||
z-index: 10;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.resize-handle:hover {
|
||
background-color: #409eff;
|
||
}
|
||
|
||
.resize-handle:active {
|
||
background-color: #337ecc;
|
||
}
|
||
|
||
/* 拖拽提示样式 */
|
||
.resize-tooltip {
|
||
position: absolute;
|
||
bottom: 20px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background-color: rgba(0, 0, 0, 0.8);
|
||
color: white;
|
||
padding: 6px 10px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
z-index: 100;
|
||
pointer-events: none;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.logo {
|
||
height: 60px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-color: #2c3e50;
|
||
color: white;
|
||
margin-bottom: 0;
|
||
overflow: hidden;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.logo h3 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.sidebar-menu {
|
||
border-right: none;
|
||
height: calc(100vh - 60px);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.header {
|
||
background-color: white;
|
||
border-bottom: 1px solid #e4e7ed;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 20px;
|
||
}
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20px;
|
||
}
|
||
|
||
.sidebar-toggle-btn {
|
||
color: #606266 !important;
|
||
padding: 8px !important;
|
||
border-radius: 4px !important;
|
||
}
|
||
|
||
.sidebar-toggle-btn:hover {
|
||
background-color: #f5f7fa !important;
|
||
color: #409eff !important;
|
||
}
|
||
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.user-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
padding: 5px 10px;
|
||
border-radius: 4px;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.user-info:hover {
|
||
background-color: #f5f7fa;
|
||
}
|
||
|
||
.username {
|
||
font-size: 14px;
|
||
color: #333;
|
||
}
|
||
|
||
.main-content {
|
||
background-color: #f0f2f5;
|
||
padding: 20px;
|
||
overflow: hidden !important; /* 禁止主内容区域滚动 */
|
||
height: calc(100vh - 60px); /* 减去header高度 */
|
||
box-sizing: border-box;
|
||
position: relative;
|
||
}
|
||
|
||
/* 菜单加载状态 */
|
||
.menu-loading,
|
||
.no-menu {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 20px;
|
||
color: #bfcbd9;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.menu-loading .el-icon {
|
||
font-size: 16px;
|
||
}
|
||
|
||
|
||
|
||
/* 响应式设计 */
|
||
@media (max-width: 768px) {
|
||
.header-left .el-breadcrumb {
|
||
display: none;
|
||
}
|
||
|
||
.username {
|
||
display: none;
|
||
}
|
||
}
|
||
</style> |