MainLayout.vue 5.4 KB
<template>
  <el-container class="layout">
    <el-aside width="220px" class="aside">
      <div class="logo">稻城亚丁</div>
      <el-scrollbar>
        <el-menu
          :default-active="activeMenu"
          router
          background-color="#0f172a"
          text-color="#94a3b8"
          active-text-color="#fff"
        >
          <SideMenuItem v-for="item in visibleMenus" :key="item.id" :item="item" />
        </el-menu>
      </el-scrollbar>
    </el-aside>
    <el-container>
      <el-header class="header">
        <span class="breadcrumb">{{ currentTitle }}</span>
        <div class="header-right">
          <span class="nick">{{ store.profile?.nickname || store.profile?.username }}</span>
          <el-tag size="small" type="info">{{ store.profile?.role_name || "—" }}</el-tag>
          <el-button type="danger" link @click="logout">退出</el-button>
        </div>
      </el-header>
      <el-main class="main">
        <router-view v-slot="{ Component }">
          <transition name="fade" mode="out-in">
            <component :is="Component" />
          </transition>
        </router-view>
      </el-main>
    </el-container>
  </el-container>
</template>

<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import SideMenuItem from "@/components/SideMenuItem.vue";
import { useUserStore } from "@/stores/user";
import type { MenuNode } from "@/api/admin";
import {
  ensureContentBeforeSystem,
  flattenContentMenus,
  flattenMpUserMenu,
  withDownloadAssetsMenu,
  withMpUserMenu,
} from "@/utils/menuExtras";

const store = useUserStore();
const route = useRoute();
const router = useRouter();

const activeMenu = computed(() => route.path);

const currentTitle = computed(() => (route.meta.title as string) || "");

const fallbackMenus: MenuNode[] = [
  {
    id: 0,
    parent_id: 0,
    name: "工作台",
    type: 2,
    path: "/dashboard",
    component: "",
    perms: "",
    icon: "odometer",
    sort: 0,
  },
  {
    id: -12,
    parent_id: 0,
    name: "轮播图",
    type: 2,
    path: "/content/banner",
    component: "",
    perms: "",
    icon: "image",
    sort: 11,
  },
  {
    id: -13,
    parent_id: 0,
    name: "下载素材",
    type: 2,
    path: "/content/download-assets",
    component: "",
    perms: "",
    icon: "picture",
    sort: 11,
  },
  {
    id: -11,
    parent_id: 0,
    name: "作品审核",
    type: 2,
    path: "/content/work",
    component: "",
    perms: "",
    icon: "document",
    sort: 12,
  },
  {
    id: -24,
    parent_id: 0,
    name: "用户管理",
    type: 2,
    path: "/system/mp-user",
    component: "",
    perms: "",
    icon: "avatar",
    sort: 15,
  },
  {
    id: -2,
    parent_id: 0,
    name: "系统管理",
    type: 1,
    path: "/system",
    component: "",
    perms: "",
    icon: "setting",
    sort: 20,
    children: [
      {
        id: -21,
        parent_id: -2,
        name: "管理员",
        type: 2,
        path: "/system/admin",
        component: "",
        perms: "",
        icon: "user",
        sort: 1,
      },
      {
        id: -22,
        parent_id: -2,
        name: "角色管理",
        type: 2,
        path: "/system/role",
        component: "",
        perms: "",
        icon: "team",
        sort: 2,
      },
    ],
  },
];

const visibleMenus = computed(() => {
  const list = store.menus;
  const base = list && list.length > 0 ? list : fallbackMenus;
  let nodes = normalizePaths(base);
  nodes = flattenContentMenus(nodes);
  nodes = flattenMpUserMenu(nodes);
  nodes = withMpUserMenu(nodes);
  nodes = withDownloadAssetsMenu(nodes);
  nodes = ensureContentBeforeSystem(nodes);
  return stripHiddenMenus(nodes);
});

/** 侧边栏不展示「菜单管理」(仍可通过地址 /system/menu 访问) */
function stripHiddenMenus(nodes: MenuNode[]): MenuNode[] {
  const hidden = new Set(["/system/menu", "/content/map"]);
  return nodes
    .filter((n) => {
      const p = n.path?.startsWith("/") ? n.path : `/${n.path || ""}`.replace(/^\/+/, "/");
      return !hidden.has(p);
    })
    .map((n) => {
      const children = n.children?.length ? stripHiddenMenus(n.children) : undefined;
      return { ...n, children };
    });
}

function normalizePaths(nodes: MenuNode[]): MenuNode[] {
  return nodes.map((n) => {
    const path = n.path?.startsWith("/") ? n.path : `/${n.path || ""}`.replace(/^\/+/, "/");
    const children = n.children?.length ? normalizePaths(n.children) : undefined;
    return { ...n, path, children };
  });
}

function logout() {
  store.clearProfile();
  router.replace("/login");
}
</script>

<style scoped>
.layout {
  height: 100%;
}

.aside {
  display: flex;
  flex-direction: column;
  background: #0f172a;
}

.logo {
  flex-shrink: 0;
  padding: 20px 16px;
  font-size: 18px;
  font-weight: 700;
  color: #fff;
  letter-spacing: 0.08em;
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-bottom: 1px solid #e2e8f0;
  background: #fff;
}

.breadcrumb {
  font-size: 16px;
  font-weight: 600;
  color: #0f172a;
}

.header-right {
  display: flex;
  align-items: center;
  gap: 12px;
}

.nick {
  font-size: 14px;
  color: #475569;
}

.main {
  min-height: calc(100vh - 60px);
  padding: 20px;
  background: #f1f5f9;
}

.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.15s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>