Files
2026-07-15 16:13:27 +08:00

558 lines
21 KiB
Vue
Raw Permalink 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="admin-shell">
<!-- ===== 侧边栏树形菜单 ===== -->
<aside class="sidebar" :class="{ open: mobileSidebarOpen }">
<!-- Logo -->
<div class="sidebar-logo" @click="router.push('/admin/dashboard')">
<svg class="logo-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<span class="logo-text">Mokee</span>
</div>
<!-- 菜单树 -->
<nav class="sidebar-nav">
<!-- 首页 -->
<div
class="menu-item single"
:class="{ active: activeMenu === '/admin/dashboard' }"
@click="openPage('/admin/dashboard', '首页')"
>
<el-icon :size="18"><HomeFilled /></el-icon>
<span>首页</span>
</div>
<!-- 系统管理 -->
<div class="menu-group" :class="{ open: menuOpen.system }">
<div class="menu-item parent" :class="{ active: activeGroup === 'system' }" @click="menuOpen.system = !menuOpen.system">
<el-icon :size="18"><Monitor /></el-icon>
<span>系统管理</span>
<el-icon :size="14" class="arrow" :class="{ rotated: menuOpen.system }"><ArrowRight /></el-icon>
</div>
<div class="sub-items" v-show="menuOpen.system">
<div class="menu-item child" :class="{ active: activeMenu === '/admin/system' }" @click="openPage('/admin/system', '系统列表')">
<span>系统列表</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/api' }" @click="openPage('/admin/api', '接口管理')">
<span>接口管理</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/dict' }" @click="openPage('/admin/dict', '数据字典')">
<span>数据字典</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/file' }" @click="openPage('/admin/file', '文件管理')">
<span>文件管理</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/email' }" @click="openPage('/admin/email', '邮件管理')">
<span>邮件管理</span>
</div>
</div>
</div>
<!-- 用户权限 -->
<div class="menu-group" :class="{ open: menuOpen.user }">
<div class="menu-item parent" :class="{ active: activeGroup === 'user' }" @click="menuOpen.user = !menuOpen.user">
<el-icon :size="18"><UserFilled /></el-icon>
<span>用户权限</span>
<el-icon :size="14" class="arrow" :class="{ rotated: menuOpen.user }"><ArrowRight /></el-icon>
</div>
<div class="sub-items" v-show="menuOpen.user">
<div class="menu-item child" :class="{ active: activeMenu === '/admin/user' }" @click="openPage('/admin/user', '用户管理')">
<span>用户管理</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/role' }" @click="openPage('/admin/role', '角色管理')">
<span>角色管理</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/menu' }" @click="openPage('/admin/menu', '菜单管理')">
<span>菜单管理</span>
</div>
</div>
</div>
<!-- 日志审计 -->
<div class="menu-group" :class="{ open: menuOpen.log }">
<div class="menu-item parent" :class="{ active: activeGroup === 'log' }" @click="menuOpen.log = !menuOpen.log">
<el-icon :size="18"><Document /></el-icon>
<span>日志审计</span>
<el-icon :size="14" class="arrow" :class="{ rotated: menuOpen.log }"><ArrowRight /></el-icon>
</div>
<div class="sub-items" v-show="menuOpen.log">
<div class="menu-item child" :class="{ active: activeMenu === '/admin/log/login' }" @click="openPage('/admin/log/login', '登录日志')">
<span>登录日志</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/log/operation' }" @click="openPage('/admin/log/operation', '操作日志')">
<span>操作日志</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/log/token' }" @click="openPage('/admin/log/token', 'Token日志')">
<span>Token日志</span>
</div>
</div>
</div>
</nav>
<!-- 底部 -->
<div class="sidebar-footer">
<div class="menu-item single" @click="router.push('/select')">
<el-icon :size="18"><Switch /></el-icon>
<span>切换系统</span>
</div>
</div>
</aside>
<!-- 移动端遮罩 -->
<div :class="['sidebar-overlay', { show: mobileSidebarOpen }]" @click="mobileSidebarOpen = false" />
<!-- ===== 右侧主体 ===== -->
<div class="main-area">
<!-- 顶栏 -->
<header class="topbar">
<div class="topbar-left">
<span class="hamburger" @click="mobileSidebarOpen = !mobileSidebarOpen">
<el-icon :size="20"><component :is="mobileSidebarOpen ? Fold : Expand" /></el-icon>
</span>
<el-breadcrumb separator="/" class="breadcrumb-desk">
<el-breadcrumb-item :to="{ path: '/admin/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="breadcrumbParent">{{ breadcrumbParent }}</el-breadcrumb-item>
<el-breadcrumb-item v-if="breadcrumbCurrent">{{ breadcrumbCurrent }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="topbar-right">
<el-dropdown trigger="hover" placement="bottom-end">
<div class="user-chip">
<el-avatar :size="28" :src="authStore.userInfo?.avatar">
{{ authStore.userInfo?.realName?.charAt(0) || 'U' }}
</el-avatar>
<span class="user-name">{{ authStore.userInfo?.realName || authStore.userInfo?.username || '用户' }}</span>
<el-icon :size="12" class="user-chevron"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/admin/personal')">
<el-icon><User /></el-icon>个人中心
</el-dropdown-item>
<el-dropdown-item @click="router.push('/select')">
<el-icon><Switch /></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>
</header>
<!-- 标签栏 -->
<div class="tab-bar" v-if="tabsStore.tabs.length > 0">
<div class="tab-list" ref="tabListRef">
<div
v-for="tab in tabsStore.tabs"
:key="tab.path"
:class="['tab-chip', { active: tabsStore.activeTab === tab.path }]"
@click="switchTab(tab)"
@contextmenu.prevent="showMenu($event, tab)"
>
<span class="tab-title">{{ tab.title }}</span>
<el-icon
v-if="tabsStore.tabs.length > 1"
class="tab-close"
:size="12"
@click.stop="closeTab(tab.path)"
><Close /></el-icon>
</div>
</div>
<el-dropdown trigger="click" @command="handleTabCmd" class="tab-more">
<span class="tab-more-btn">
<el-icon :size="14"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="closeCurrent">
<el-icon><Close /></el-icon>关闭当前
</el-dropdown-item>
<el-dropdown-item command="closeOthers">
<el-icon><Remove /></el-icon>关闭其他
</el-dropdown-item>
<el-dropdown-item command="closeAll">
<el-icon><CircleClose /></el-icon>关闭全部
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- 右键菜单 -->
<Teleport to="body">
<div
v-if="ctxMenu.visible"
class="ctx-overlay"
@click="ctxMenu.visible = false"
>
<div
class="ctx-menu"
:style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }"
@click.stop
>
<div class="ctx-item" @click="closeTab(ctxMenu.tab?.path); ctxMenu.visible = false">
<el-icon :size="14"><Close /></el-icon>关闭
</div>
<div class="ctx-item" @click="closeOthers(ctxMenu.tab?.path); ctxMenu.visible = false">
<el-icon :size="14"><Remove /></el-icon>关闭其他
</div>
<div class="ctx-item" @click="closeAll(); ctxMenu.visible = false">
<el-icon :size="14"><CircleClose /></el-icon>关闭全部
</div>
</div>
</div>
</Teleport>
<!-- 内容区 -->
<main class="content">
<router-view :key="tabsStore.activeTab" />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
HomeFilled, Monitor, UserFilled, Document,
ArrowRight, Switch, ArrowDown, SwitchButton,
User, Close, Remove, CircleClose, Expand, Fold,
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useTabsStore } from '@/stores/tabs'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const tabsStore = useTabsStore()
const tabListRef = ref<HTMLElement>()
const mobileSidebarOpen = ref(false)
// 菜单展开状态
const menuOpen = reactive({ system: true, user: true, log: true })
// 当前高亮
const activeMenu = computed(() => {
const p = route.path
const known = ['/admin/dashboard', '/admin/system', '/admin/api', '/admin/dict', '/admin/file', '/admin/email',
'/admin/user', '/admin/role', '/admin/menu', '/admin/log/login', '/admin/log/operation', '/admin/log/token']
return known.find(k => p.startsWith(k)) || p
})
const activeGroup = computed(() => {
const p = route.path
if (p.startsWith('/admin/system') || p.startsWith('/admin/api') || p.startsWith('/admin/dict') || p.startsWith('/admin/file') || p.startsWith('/admin/email')) return 'system'
if (p.startsWith('/admin/user') || p.startsWith('/admin/role') || p.startsWith('/admin/menu')) return 'user'
if (p.startsWith('/admin/log')) return 'log'
return ''
})
// 面包屑
const breadcrumbMap: Record<string, { parent?: string; current: string }> = {
'/admin/dashboard': { current: '首页' },
'/admin/system': { parent: '系统管理', current: '系统列表' },
'/admin/api': { parent: '系统管理', current: '接口管理' },
'/admin/dict': { parent: '系统管理', current: '数据字典' },
'/admin/file': { parent: '系统管理', current: '文件管理' },
'/admin/email': { parent: '系统管理', current: '邮件管理' },
'/admin/user': { parent: '用户权限', current: '用户管理' },
'/admin/role': { parent: '用户权限', current: '角色管理' },
'/admin/menu': { parent: '用户权限', current: '菜单管理' },
'/admin/log/login': { parent: '日志审计', current: '登录日志' },
'/admin/log/operation': { parent: '日志审计', current: '操作日志' },
'/admin/log/token': { parent: '日志审计', current: 'Token日志' },
'/admin/user/form': { parent: '用户权限', current: '用户表单' },
'/admin/role/form': { parent: '用户权限', current: '角色表单' },
'/admin/menu/form': { parent: '用户权限', current: '菜单表单' },
'/admin/system/form': { parent: '系统管理', current: '系统表单' },
'/admin/api/form': { parent: '系统管理', current: '接口表单' },
'/admin/dict/form': { parent: '系统管理', current: '字典表单' },
'/admin/personal': { current: '个人中心' },
}
const breadcrumbParent = computed(() => {
const key = Object.keys(breadcrumbMap).find(k => route.path.startsWith(k))
return key ? breadcrumbMap[key].parent : undefined
})
const breadcrumbCurrent = computed(() => {
const key = Object.keys(breadcrumbMap).find(k => route.path.startsWith(k))
return key ? breadcrumbMap[key].current : undefined
})
// ===== 标签页 =====
function openPage(path: string, title: string) {
tabsStore.addTab({ path, meta: { title }, query: {} } as any)
router.push(path)
mobileSidebarOpen.value = false
}
function switchTab(tab: any) {
router.push(tab.path)
}
function closeTab(path: string) {
tabsStore.removeTab(path)
if (tabsStore.activeTab) {
router.push(tabsStore.activeTab)
} else {
router.push('/admin/dashboard')
tabsStore.addTab({ path: '/admin/dashboard', meta: { title: '首页' }, query: {} } as any)
}
}
function closeOthers(path?: string) {
if (path) tabsStore.closeOthers(path)
}
function closeAll() {
tabsStore.closeAll()
router.push('/admin/dashboard')
tabsStore.addTab({ path: '/admin/dashboard', meta: { title: '首页' }, query: {} } as any)
}
function handleTabCmd(cmd: string) {
const cur = tabsStore.activeTab
if (cmd === 'closeCurrent' && cur) closeTab(cur)
else if (cmd === 'closeOthers' && cur) closeOthers(cur)
else if (cmd === 'closeAll') closeAll()
}
// 右键菜单
const ctxMenu = reactive({ visible: false, x: 0, y: 0, tab: null as any })
function showMenu(e: MouseEvent, tab: any) {
ctxMenu.visible = true
ctxMenu.x = e.clientX
ctxMenu.y = e.clientY
ctxMenu.tab = tab
}
// 路由变化自动加标签
watch(() => route.path, (p) => {
if (p.startsWith('/admin') && p !== '/admin') {
tabsStore.addTab(route)
}
})
onMounted(async () => {
// 跨域名跳转后 Pinia 状态丢失,重新从服务端加载用户信息
await authStore.fetchUserInfo()
if (route.path !== '/admin' && route.path !== '/admin/dashboard') {
tabsStore.addTab(route)
}
// 首页默认标签
if (!tabsStore.tabs.find((t: any) => t.path === '/admin/dashboard')) {
tabsStore.addTab({ path: '/admin/dashboard', meta: { title: '首页' }, query: {} } as any)
}
if (route.path === '/admin') {
router.push('/admin/dashboard')
}
})
async function handleLogout() {
try { await authStore.logout() } catch {}
tabsStore.closeAll()
// 直接完整跳转登录页,避免 router.push 触发组件 API 请求
window.location.href = import.meta.env.VITE_LOGIN_URL || '/login'
}
</script>
<style scoped>
/* ===== 整体布局 ===== */
.admin-shell { display:flex; height:100vh; overflow:hidden; background:var(--bg-page); }
/* ===== 侧边栏 ===== */
.sidebar {
width: 240px; flex-shrink:0;
background: var(--bg-surface);
border-right: 1px solid var(--border-color);
display:flex; flex-direction:column; overflow:hidden; z-index:50;
}
.sidebar-logo {
display:flex; align-items:center; gap:10px;
height: 58px; padding:0 20px;
border-bottom: 1px solid var(--border-light);
cursor:pointer; flex-shrink:0;
}
.logo-svg { width:22px; height:22px; color:var(--color-primary); flex-shrink:0; }
.logo-text { font-size:18px; font-weight:700; color:var(--text-primary); letter-spacing:-0.3px; }
.sidebar-nav { flex:1; overflow-y:auto; padding:16px 12px; }
.sidebar-footer { padding:8px 12px 16px; border-top:1px solid var(--border-light); flex-shrink:0; }
/* ===== 菜单项 ===== */
.menu-item {
display:flex; align-items:center; gap:10px;
height:42px; padding:0 14px;
border-radius: var(--radius-md);
cursor:pointer; font-size:14px; font-weight:500;
color: var(--text-secondary);
transition: all var(--transition-fast);
white-space:nowrap;
}
.menu-item:hover { background:var(--bg-hover); color:var(--text-primary); }
.menu-item.active { background:var(--color-primary-bg); color:var(--color-primary); font-weight:600; }
.menu-item.single { margin-bottom:4px; }
.menu-item.parent {
font-weight:600; font-size:14px; color:var(--text-primary);
margin-top:2px;
}
.menu-item.child {
padding-left:48px; height:38px;
font-size:14px; font-weight:400;
color: var(--text-muted);
margin:1px 0;
}
.menu-item.child:hover { color:var(--text-primary); }
.menu-item.child.active { color:var(--color-primary); font-weight:500; }
/* 展开箭头 */
.arrow { margin-left:auto; transition:transform var(--transition-fast); color:var(--text-muted); flex-shrink:0; }
.arrow.rotated { transform:rotate(90deg); }
.sub-items { overflow:hidden; padding-bottom:4px; }
/* ===== 分隔线(菜单组之间) ===== */
.menu-group { padding:4px 0; }
.menu-group + .menu-group { border-top:1px solid var(--border-light); }
/* ===== 主体区域 ===== */
.main-area { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
/* ===== 顶栏 ===== */
.topbar {
display:flex; align-items:center; justify-content:space-between;
height:58px; background:var(--bg-surface);
border-bottom:1px solid var(--border-color);
padding:0 24px; flex-shrink:0;
}
.topbar-left { display:flex; align-items:center; }
.topbar-right { display:flex; align-items:center; }
.user-chip {
display:flex; align-items:center; gap:8px;
padding:5px 12px 5px 5px; border-radius:24px;
cursor:pointer; transition:all var(--transition-fast);
border:1px solid transparent;
}
.user-chip:hover { background:var(--bg-hover); border-color:var(--border-color); }
.user-name {
font-size:14px; font-weight:500; color:var(--text-secondary);
max-width:100px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.user-chevron { color:var(--text-muted); flex-shrink:0; }
/* ===== 标签栏 ===== */
.tab-bar {
display:flex; align-items:center;
height:42px; background:var(--bg-surface);
border-bottom:1px solid var(--border-color);
padding:0 12px; flex-shrink:0; gap:4px;
}
.tab-list { display:flex; flex:1; overflow-x:auto; overflow-y:hidden; height:100%; align-items:center; gap:2px; }
.tab-list::-webkit-scrollbar { height:2px; }
.tab-chip {
display:flex; align-items:center; gap:6px;
height:34px; padding:0 12px;
border-radius:var(--radius-sm);
cursor:pointer; font-size:13px;
color:var(--text-muted);
transition:all var(--transition-fast);
white-space:nowrap; flex-shrink:0;
}
.tab-chip:hover { background:var(--bg-hover); color:var(--text-secondary); }
.tab-chip.active { background:var(--color-primary-bg); color:var(--color-primary); font-weight:500; }
.tab-title { max-width:120px; overflow:hidden; text-overflow:ellipsis; }
.tab-close { border-radius:3px; padding:1px; opacity:0; transition:all var(--transition-fast); }
.tab-chip:hover .tab-close { opacity:1; }
.tab-close:hover { background:var(--border-color); color:var(--text-primary); }
.tab-more { flex-shrink:0; }
.tab-more-btn {
display:flex; align-items:center; justify-content:center;
width:30px; height:30px; border-radius:var(--radius-sm);
cursor:pointer; color:var(--text-muted);
transition:all var(--transition-fast);
}
.tab-more-btn:hover { background:var(--bg-hover); color:var(--text-secondary); }
/* ===== 右键菜单 ===== */
.ctx-overlay { position:fixed; inset:0; z-index:9998; }
.ctx-menu {
position:fixed; z-index:9999;
background:var(--bg-surface); border:1px solid var(--border-color);
border-radius:var(--radius-md); box-shadow:var(--shadow-lg);
min-width:150px; padding:4px;
}
.ctx-item {
display:flex; align-items:center; gap:8px;
padding:9px 14px; font-size:14px; cursor:pointer;
color:var(--text-secondary); border-radius:var(--radius-sm);
transition:all var(--transition-fast);
}
.ctx-item:hover { background:var(--bg-hover); color:var(--text-primary); }
/* ===== 内容区 ===== */
.content { flex:1; overflow-y:auto; padding:24px; }
/* ===== 汉堡按钮 ===== */
.hamburger {
display: none; align-items: center; justify-content: center;
width: 36px; height: 36px; border-radius: var(--radius-md);
cursor: pointer; color: var(--text-secondary);
margin-right: 8px; flex-shrink: 0;
}
.hamburger:hover { background: var(--bg-hover); color: var(--color-primary); }
/* ===== 侧边栏遮罩 ===== */
.sidebar-overlay {
display: none;
position: fixed; inset: 0; z-index: 99;
background: rgba(0,0,0,0.4);
}
/* ===== 移动端适配 ===== */
@media (max-width: 768px) {
.hamburger { display: flex; }
.breadcrumb-desk { display: none; }
/* 侧边栏滑出 */
.sidebar {
position: fixed; top: 0; left: 0; bottom: 0; z-index: 100;
width: 260px !important;
transform: translateX(-100%);
transition: transform 0.25s ease;
box-shadow: none;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay.show { display: block; }
/* 主体全宽 */
.main-area { width: 100%; }
/* 顶栏 */
.topbar { padding: 0 12px; height: 50px; }
.user-chip { padding: 4px 8px 4px 4px; }
.user-name { display: none; }
/* 标签栏 */
.tab-bar { height: 36px; padding: 0 6px; }
.tab-chip { height: 28px; padding: 0 8px; font-size: 12px; }
.tab-title { max-width: 80px; }
/* 内容区 */
.content { padding: 12px; }
}
</style>