初始化

This commit is contained in:
zg
2026-07-15 16:13:27 +08:00
parent 6d6f559793
commit c4023c1623
276 changed files with 42622 additions and 0 deletions

View File

@@ -0,0 +1,557 @@
<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>