ai-admin/admin-frontend/src/layout/LayoutView.vue

494 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>