BookSystem/frontend/src/modules/admin/layout/AdminLayout.vue
jayhgq 12a577f9a3 feat: 实现动态菜单功能,替换硬编码菜单
1.  后端新增menu字段接口和route权限字段,重构权限接口返回菜单树
2.  前端替换硬编码侧边菜单为动态渲染,新增菜单store和图标映射逻辑
3.  调整权限路由元信息和初始化权限数据
2026-06-09 15:57:23 +08:00

170 lines
4.1 KiB
Vue

<template>
<el-container class="admin-root">
<el-aside width="220px" class="aside">
<div class="brand">{{sysTitle}}</div>
<el-menu
:default-active="active"
router
background-color="#0f172a"
text-color="#e2e8f0"
active-text-color="#38bdf8"
>
<!-- 动态渲染菜单 -->
<template v-for="menu in auth.menu" :key="menu.id">
<!-- 有子菜单的顶级菜单 -->
<el-sub-menu
v-if="menu.children && menu.children.length > 0"
:index="menu.route"
>
<template #title>
<el-icon><component :is="getIcon(menu.code)" /></el-icon>
<span>{{ menu.name }}</span>
</template>
<el-menu-item
v-for="child in menu.children"
:key="child.id"
:index="child.route"
>
<span>{{ child.name }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 无子菜单的顶级菜单 -->
<el-menu-item
v-else
:index="menu.route"
>
<el-icon><component :is="getIcon(menu.code)" /></el-icon>
<span>{{ menu.name }}</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="right">
<span class="nick">{{ auth.user?.nickname || auth.user?.username }}</span>
<el-button type="primary" link @click="onLogout">退出</el-button>
</div>
</el-header>
<el-main class="main">
<div class="page-title-bar">
<h1 class="page-title">{{ pageTitle }}</h1>
</div>
<div class="page-content">
<router-view />
</div>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, markRaw } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Key, Setting, Folder } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { useAuthStore } from "@/modules/admin/stores/auth";
import axios from "axios";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const sysTitle = ref("");
const active = computed(() => route.path);
const pageTitle = computed(() => (route.meta.title as string) || "");
// 图标映射
const iconMap: Record<string, any> = {
"perm": markRaw(Key),
"system": markRaw(Setting),
};
function getIcon(code: string) {
// 查找匹配的图标
for (const key in iconMap) {
if (code.startsWith(key)) {
return iconMap[key];
}
}
return Folder;
}
async function getSysTitle() {
const baseURL = import.meta.env.VITE_API_BASE || "/api";
let res = await axios.get(`${baseURL}/settings/public?setting_id=1`)
if (res.data) {
sysTitle.value = res.data.value;
}
}
onMounted(async () => {
try {
await getSysTitle();
if (!auth.user) {
await auth.fetchProfile();
}
await auth.fetchPermissions();
} catch {
ElMessage.error("获取用户信息失败,请重新登录");
await auth.logout();
router.replace({ name: "admin-login", query: { redirect: route.fullPath } });
}
});
async function onLogout() {
await auth.logout();
router.push({ name: "admin-login" });
}
</script>
<style scoped>
.admin-root {
height: 100vh;
}
.aside {
background: #0f172a;
color: #e2e8f0;
}
.brand {
padding: 18px 16px;
font-weight: 600;
letter-spacing: 0.02em;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.header {
display: flex;
align-items: center;
justify-content: flex-end;
border-bottom: 1px solid var(--el-border-color-lighter);
background: #fff;
}
.right {
display: flex;
align-items: center;
gap: 12px;
}
.page-title-bar {
flex-shrink: 0;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.nick {
color: var(--el-text-color-secondary);
font-size: 14px;
}
.main {
background: #f8fafc;
padding: 16px;
display: flex;
flex-direction: column;
min-width: 0;
}
</style>