Explorar o código

菜单样式优化和BUG修复

zyp hai 1 semana
pai
achega
b94d259a19

+ 38 - 0
docs/侧边栏菜单样式修复说明.md

@@ -0,0 +1,38 @@
+# saasadminui 侧边栏菜单修复说明
+
+> 修复日期:2026-05-27
+
+## 问题描述
+
+1. **侧栏收起后几乎看不见**:收起后只剩一条白边,图标和菜单名都看不到。
+2. **菜单区域背景为白色**:与右侧主内容区混在一起,没有层次。
+
+## 根因
+
+| 问题 | 原因 |
+|------|------|
+| 收起后不可见 | `Sidebar/index.vue` 内层 `.sidebar-wrapper` 写死 `width: 200px`,外层 topNav 布局收起为 54px 时被裁切 |
+| 无法再次展开 | `topNav: true` 时顶栏没有汉堡按钮,Cookie 若 `sidebarStatus=0` 会一直处于收起态 |
+| 白底 | 菜单与容器多处硬编码 `#fff` |
+
+## 修改文件
+
+| 文件 | 改动 |
+|------|------|
+| `src/layout/components/Sidebar/index.vue` | 背景改为 `#F0F2F5`;宽度 `100%`;折叠态图标居中;子菜单浅灰底 |
+| `src/layout/components/Navbar.vue` | topNav 模式增加汉堡按钮,可展开/收起侧栏 |
+| `src/layout/index.vue` | topNav 侧栏展开 200px / 收起 54px 与动画 |
+| `src/assets/styles/sidebar.scss` | 外层侧栏背景与折叠样式统一 |
+
+## 使用说明
+
+- **展开**:点击 Logo 左侧汉堡图标,侧栏显示图标 + 菜单名。
+- **收起**:再次点击汉堡;鼠标悬停图标会显示 **tooltip 菜单名**(Element UI 折叠菜单默认行为)。
+- 有子菜单的项:收起后点击/悬停图标会弹出子菜单浮层。
+
+## 验收
+
+1. 刷新 `npm run dev` 后进入首页。
+2. 侧栏背景为浅灰 `#F0F2F5`,与白色内容区有区分。
+3. 点击汉堡收起后能看到居中图标;悬停显示菜单名称。
+4. 再次点击可展开,菜单名称正常显示。

+ 10 - 0
src/assets/styles/admin-menu.scss

@@ -0,0 +1,10 @@
+/* adminUI AdminLayout menu tokens */
+
+$admin-top-height: 50px;
+$admin-top-active: #409eff;
+$admin-top-text: #606266;
+$admin-sidebar-bg: #fafbff;
+$admin-sidebar-active-bg: #ede9fe;
+$admin-sidebar-active-text: #4f46e5;
+$admin-sidebar-icon: #8b5cf6;
+$admin-border: #e8e8e8;

+ 7 - 172
src/assets/styles/sidebar.scss

@@ -1,113 +1,31 @@
 #app {
-
-  /* 注意:AdminLayout 使用 flex 布局,不依赖 margin-left 定位侧边栏,
-     以下规则已注释,避免与 AdminLayout.vue 的 .main-container flex 布局冲突 */
-  // .main-container {
-  //   min-height: 100%;
-  //   transition: margin-left .28s;
-  //   margin-left: $base-sidebar-width;
-  //   position: relative;
-  // }
-
   .sidebar-container {
-    -webkit-transition: width .28s;
     transition: width 0.28s;
     width: $base-sidebar-width !important;
-    background-color: $base-menu-background;
+    background-color: #fafbff;
     height: 100%;
-    position: fixed;
-    font-size: 0px;
-    top: 0;
-    bottom: 0;
-    left: 0;
-    z-index: 1001;
+    position: relative;
+    font-size: 0;
     overflow: hidden;
-    -webkit-box-shadow: 2px 0 6px rgba(0,21,41,.35);
-    box-shadow: 2px 0 6px rgba(0,21,41,.35);
-
-    // reset element-ui css
-    .horizontal-collapse-transition {
-      transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
-    }
+    box-shadow: none;
 
     .scrollbar-wrapper {
       overflow-x: hidden !important;
     }
 
-    .el-scrollbar__bar.is-vertical {
-      right: 0px;
-    }
-
     .el-scrollbar {
       height: 100%;
     }
 
-    &.has-logo {
-      .el-scrollbar {
-        height: calc(100% - 50px);
-      }
-    }
-
-    .is-horizontal {
-      display: none;
-    }
-
-    a {
-      display: inline-block;
-      width: 100%;
-      overflow: hidden;
-    }
-
-    .svg-icon {
-      margin-right: 16px;
-    }
-
     .el-menu {
       border: none;
       height: 100%;
       width: 100% !important;
     }
 
-    .el-menu-item, .el-submenu__title {
-      overflow: hidden !important;
-      text-overflow: ellipsis !important;
-      white-space: nowrap !important;
-    }
-
-    // menu hover — 白色主题
-    .submenu-title-noDropdown,
-    .el-submenu__title {
-      &:hover {
-        background-color: rgba(79, 70, 229, 0.04) !important;
-      }
-    }
-
-    & .theme-dark .is-active > .el-submenu__title {
-      color: $base-menu-color-active !important;
-    }
-
-    & .nest-menu .el-submenu>.el-submenu__title,
-    & .el-submenu .el-menu-item {
-      min-width: $base-sidebar-width !important;
-
-      &:hover {
-        background-color: rgba(79, 70, 229, 0.04) !important;
-      }
-    }
-
-    & .theme-dark .nest-menu .el-submenu>.el-submenu__title,
-    & .theme-dark .el-submenu .el-menu-item {
-      background-color: $base-sub-menu-background !important;
-
-      &:hover {
-        background-color: $base-sub-menu-hover !important;
-      }
-    }
-
-    // 白色主题下的激活样式
     .el-menu-item.is-active {
-      color: #4F46E5 !important;
-      background-color: #EDE9FE !important;
+      color: #4f46e5 !important;
+      background-color: #ede9fe !important;
     }
   }
 
@@ -115,64 +33,11 @@
     .sidebar-container {
       width: 54px !important;
     }
-
-    .main-container {
-      margin-left: 54px;
-    }
-
-    .submenu-title-noDropdown {
-      padding: 0 !important;
-      position: relative;
-
-      .el-tooltip {
-        padding: 0 !important;
-
-        .svg-icon {
-          margin-left: 20px;
-        }
-      }
-    }
-
-    .el-submenu {
-      overflow: hidden;
-
-      &>.el-submenu__title {
-        padding: 0 !important;
-
-        .svg-icon {
-          margin-left: 20px;
-        }
-
-      }
-    }
-
-    .el-menu--collapse {
-      .el-submenu {
-        &>.el-submenu__title {
-          &>span {
-            height: 0;
-            width: 0;
-            overflow: hidden;
-            visibility: hidden;
-            display: inline-block;
-          }
-        }
-      }
-    }
-  }
-
-  .el-menu--collapse .el-menu .el-submenu {
-    min-width: $base-sidebar-width !important;
   }
 
-  // mobile responsive
   .mobile {
-    .main-container {
-      margin-left: 0px;
-    }
-
     .sidebar-container {
-      transition: transform .28s;
+      transition: transform 0.28s;
       width: $base-sidebar-width !important;
     }
 
@@ -186,45 +51,15 @@
   }
 
   .withoutAnimation {
-
-    .main-container,
     .sidebar-container {
       transition: none;
     }
   }
 }
 
-// when menu collapsed
 .el-menu--vertical {
-  &>.el-menu {
-    .svg-icon {
-      margin-right: 16px;
-    }
-  }
-
-  .nest-menu .el-submenu>.el-submenu__title,
-  .el-menu-item {
-    &:hover {
-      background-color: rgba(79, 70, 229, 0.04) !important;
-    }
-  }
-
-  // 激活菜单项弹出子菜单
   .el-menu--popup {
     max-height: 100vh;
     overflow-y: auto;
-
-    &::-webkit-scrollbar-track-piece {
-      background: #d3dce6;
-    }
-
-    &::-webkit-scrollbar {
-      width: 6px;
-    }
-
-    &::-webkit-scrollbar-thumb {
-      background: #99a9bf;
-      border-radius: 20px;
-    }
   }
 }

+ 5 - 0
src/assets/styles/variables.scss

@@ -13,6 +13,11 @@ $primary: #4F46E5;
 $primary-light: #EDE9FE;
 $primary-dark: #4338CA;
 
+// 顶栏
+$nav-top-bg: #FFFFFF;
+$nav-chrome-bg: #FFFFFF;
+$nav-chrome-border: #f0f0f0;
+
 // 页面背景色
 $page-bg: #F5F6FA;
 $border-color: #F0F0F0;

+ 117 - 92
src/components/TopNav/index.vue

@@ -3,6 +3,7 @@
     :default-active="activeMenu"
     mode="horizontal"
     @select="handleSelect"
+    class="topmenu-container"
   >
     <template v-for="(item, index) in topMenus">
       <el-menu-item :style="{'--theme': theme}" :index="item.path" :key="index" v-if="index < visibleNumber"
@@ -11,7 +12,6 @@
       >
     </template>
 
-    <!-- 顶部菜单超出数量折叠 -->
     <el-submenu :style="{'--theme': theme}" index="more" v-if="topMenus.length > visibleNumber">
       <template slot="title">更多菜单</template>
       <template v-for="(item, index) in topMenus">
@@ -28,83 +28,75 @@
 </template>
 
 <script>
-import { constantRoutes } from "@/router";
+import { constantRoutes } from '@/router'
 
 export default {
   data() {
     return {
-      // 顶部栏初始数
-      visibleNumber: 8,
-      // 是否为首次加载
+      visibleNumber: 12,
       isFrist: false,
-      // 当前激活菜单的 index
       currentIndex: undefined
-    };
+    }
   },
   computed: {
     theme() {
-      return this.$store.state.settings.theme;
+      return this.$store.state.settings.theme
     },
-    // 顶部显示菜单
     topMenus() {
-      let topMenus = [];
+      const topMenus = []
       this.routers.map((menu) => {
         if (menu.hidden !== true) {
-          // 兼容顶部栏一级菜单内部跳转
-          if (menu.path === "/") {
-              topMenus.push(menu.children[0]);
+          if (menu.path === '/') {
+            topMenus.push(menu.children[0])
           } else {
-              topMenus.push(menu);
+            topMenus.push(menu)
           }
         }
-      });
-      return topMenus;
+      })
+      return topMenus
     },
-    // 所有的路由信息
     routers() {
-      return this.$store.state.permission.topbarRouters;
+      return this.$store.state.permission.topbarRouters
     },
-    // 设置子路由
     childrenMenus() {
-      var childrenMenus = [];
+      var childrenMenus = []
       this.routers.map((router) => {
         for (var item in router.children) {
           if (router.children[item].parentPath === undefined) {
-            if(router.path === "/") {
-              router.children[item].path = "/redirect/" + router.children[item].path;
+            if (router.path === '/') {
+              router.children[item].path = '/redirect/' + router.children[item].path
             } else {
-              if(!this.ishttp(router.children[item].path)) {
-                router.children[item].path = router.path + "/" + router.children[item].path;
+              if (!this.ishttp(router.children[item].path)) {
+                router.children[item].path = router.path + '/' + router.children[item].path
               }
             }
-            router.children[item].parentPath = router.path;
+            router.children[item].parentPath = router.path
           }
-          childrenMenus.push(router.children[item]);
+          childrenMenus.push(router.children[item])
         }
-      });
-      return constantRoutes.concat(childrenMenus);
+      })
+      return constantRoutes.concat(childrenMenus)
     },
-    // 默认激活的菜单
     activeMenu() {
-      const path = this.$route.path;
-      let activePath = this.defaultRouter();
-      if (path.lastIndexOf("/") > 0) {
-        const tmpPath = path.substring(1, path.length);
-        activePath = "/" + tmpPath.substring(0, tmpPath.indexOf("/"));
-      } else if ("/index" == path || "" == path) {
+      const path = this.$route.path
+      let activePath = this.defaultRouter()
+      if (path.lastIndexOf('/') > 0) {
+        const tmpPath = path.substring(1, path.length)
+        activePath = '/' + tmpPath.substring(0, tmpPath.indexOf('/'))
+      } else if (path === '/index' || path === '') {
         if (!this.isFrist) {
-          this.isFrist = true;
+          this.isFrist = true
         } else {
-          activePath = "index";
+          activePath = 'index'
         }
       }
-      var routes = this.activeRoutes(activePath);
+      var routes = this.activeRoutes(activePath)
       if (routes.length === 0) {
         activePath = this.currentIndex || this.defaultRouter()
-        this.activeRoutes(activePath);
+        this.activeRoutes(activePath)
       }
-      return activePath;
-    },
+      return activePath
+    }
   },
   beforeMount() {
     window.addEventListener('resize', this.setVisibleNumber)
@@ -113,83 +105,116 @@ export default {
     window.removeEventListener('resize', this.setVisibleNumber)
   },
   mounted() {
-    //this.setVisibleNumber();
+    this.setVisibleNumber()
   },
   methods: {
-    // 根据宽度计算设置显示栏数
     setVisibleNumber() {
-      const width = document.body.getBoundingClientRect().width / 3;
-      this.visibleNumber = parseInt(width / 85);
+      const width = document.body.getBoundingClientRect().width / 3
+      this.visibleNumber = parseInt(width / 85)
     },
-    // 默认激活的路由
     defaultRouter() {
-      let router;
+      let router
       Object.keys(this.routers).some((key) => {
         if (!this.routers[key].hidden) {
-          router = this.routers[key].path;
-          return true;
+          router = this.routers[key].path
+          return true
         }
-      });
-      return router;
+      })
+      return router
     },
-    // 菜单选择事件
-    handleSelect(key, keyPath) {
-      this.currentIndex = key;
+    handleSelect(key) {
+      this.currentIndex = key
       if (this.ishttp(key)) {
-        // http(s):// 路径新窗口打开
-        window.open(key, "_blank");
-      } else if (key.indexOf("/redirect") !== -1) {
-        // /redirect 路径内部打开
-        this.$router.push({ path: key.replace("/redirect", "") });
+        window.open(key, '_blank')
+      } else if (key.indexOf('/redirect') !== -1) {
+        this.$router.push({ path: key.replace('/redirect', '') })
       } else {
-        // 显示左侧联动菜单
-        this.activeRoutes(key);
+        this.activeRoutes(key)
       }
     },
-    // 当前激活的路由
     activeRoutes(key) {
-      var routes = [];
+      var routes = []
       if (this.childrenMenus && this.childrenMenus.length > 0) {
         this.childrenMenus.map((item) => {
-          if (key == item.parentPath || (key == "index" && "" == item.path)) {
-            routes.push(item);
+          if (key === item.parentPath || (key === 'index' && item.path === '')) {
+            routes.push(item)
           }
-        });
+        })
       }
-      if(routes.length > 0) {
-        this.$store.commit("SET_SIDEBAR_ROUTERS", routes);
+      if (routes.length > 0) {
+        this.$store.commit('SET_SIDEBAR_ROUTERS', routes)
       }
-      return routes;
+      return routes
     },
-	ishttp(url) {
+    ishttp(url) {
       return url.indexOf('http://') !== -1 || url.indexOf('https://') !== -1
     }
-  },
-};
+  }
+}
 </script>
 
 <style lang="scss">
-.topmenu-container.el-menu--horizontal > .el-menu-item {
-  float: left;
-  height: 50px !important;
-  line-height: 50px !important;
-  color: #999093 !important;
-  padding: 0 5px !important;
-  margin: 0 10px !important;
-}
+@import '~@/assets/styles/admin-menu.scss';
 
-.topmenu-container.el-menu--horizontal > .el-menu-item.is-active, .el-menu--horizontal > .el-submenu.is-active .el-submenu__title {
-  border-bottom: 2px solid #{'var(--theme)'} !important;
-  color: #303133;
-}
+.topmenu-container.el-menu--horizontal {
+  border-bottom: none !important;
+
+  & > .el-menu-item {
+    float: left;
+    height: $admin-top-height !important;
+    line-height: $admin-top-height !important;
+    color: $admin-top-text !important;
+    font-size: 14px;
+    font-weight: 500;
+    padding: 0 18px !important;
+    margin: 0 !important;
+    border-bottom: 2px solid transparent !important;
+    background: transparent !important;
 
-/* submenu item */
-.topmenu-container.el-menu--horizontal > .el-submenu .el-submenu__title {
-  float: left;
-  height: 50px !important;
-  line-height: 50px !important;
-  color: #999093 !important;
-  padding: 0 5px !important;
-  margin: 0 10px !important;
+    &:hover {
+      color: $admin-top-active !important;
+      background: transparent !important;
+    }
+
+    &.is-active {
+      color: $admin-top-active !important;
+      border-bottom: 2px solid $admin-top-active !important;
+      background: transparent !important;
+    }
+
+    .svg-icon {
+      margin-right: 6px;
+      font-size: 16px;
+    }
+  }
+
+  & > .el-submenu {
+    .el-submenu__title {
+      height: $admin-top-height !important;
+      line-height: $admin-top-height !important;
+      color: $admin-top-text !important;
+      font-size: 14px;
+      font-weight: 500;
+      padding: 0 18px !important;
+      margin: 0 !important;
+      border-bottom: 2px solid transparent !important;
+      background: transparent !important;
+
+      &:hover {
+        color: $admin-top-active !important;
+        background: transparent !important;
+      }
+
+      .svg-icon {
+        margin-right: 6px;
+        font-size: 16px;
+      }
+    }
+
+    &.is-active .el-submenu__title {
+      color: $admin-top-active !important;
+      border-bottom: 2px solid $admin-top-active !important;
+    }
+  }
 }
 </style>

+ 718 - 0
src/layout/AdminLayout.vue

@@ -0,0 +1,718 @@
+<template>
+  <div class="admin-layout">
+    <header class="top-nav">
+      <div class="nav-logo" @click="goHome" title="返回首页">
+        <img v-if="logImg" :src="logImg" class="logo-img" alt="" />
+        <div v-else class="logo-icon">
+          <i class="el-icon-s-platform" />
+        </div>
+        <span class="logo-text">{{ title }}</span>
+        <div class="logo-home-badge" @click.stop="goHome">
+          <i class="el-icon-s-home" />
+        </div>
+      </div>
+      <div class="nav-divider" />
+
+      <nav class="nav-menu">
+        <div
+          v-for="(menu, index) in visibleTopMenus"
+          :key="menu.path + index"
+          class="nav-item"
+          :class="{ active: activeTopIndex === index }"
+          @click="switchTopMenu(index)"
+        >
+          <svg-icon v-if="menu.meta && menu.meta.icon" :icon-class="menu.meta.icon" />
+          <span>{{ menu.meta.title }}</span>
+        </div>
+        <el-dropdown v-if="overflowTopMenus.length" trigger="click" @command="onMoreMenuCommand">
+          <div class="nav-item nav-item-more" :class="{ active: activeTopIndex >= visibleTopMenus.length }">
+            <span>更多菜单</span>
+            <i class="el-icon-arrow-down el-icon--right" />
+          </div>
+          <el-dropdown-menu slot="dropdown">
+            <el-dropdown-item
+              v-for="(menu, idx) in overflowTopMenus"
+              :key="menu.path + idx"
+              :command="visibleTopMenus.length + idx"
+            >
+              <svg-icon v-if="menu.meta && menu.meta.icon" :icon-class="menu.meta.icon" />
+              {{ menu.meta.title }}
+            </el-dropdown-item>
+          </el-dropdown-menu>
+        </el-dropdown>
+      </nav>
+
+      <div class="user-area">
+        <template v-if="device !== 'mobile'">
+          <search class="header-tool" />
+          <screenfull class="header-tool" />
+          <el-tooltip content="布局大小" effect="dark" placement="bottom">
+            <size-select class="header-tool" />
+          </el-tooltip>
+        </template>
+        <div class="user-info">
+          <div class="user-avatar">{{ userInitial }}</div>
+          <span class="user-role">管理员</span>
+        </div>
+        <el-dropdown trigger="click" @command="handleCommand">
+          <div class="nav-user">
+            <span class="nav-username">{{ name }}</span>
+            <i class="el-icon-arrow-down dropdown-arrow" />
+          </div>
+          <el-dropdown-menu slot="dropdown">
+            <router-link to="/user/profile">
+              <el-dropdown-item command="profile">
+                <i class="el-icon-user" /> 个人中心
+              </el-dropdown-item>
+            </router-link>
+            <el-dropdown-item command="logout" divided>
+              <i class="el-icon-switch-button" /> 退出登录
+            </el-dropdown-item>
+          </el-dropdown-menu>
+        </el-dropdown>
+      </div>
+    </header>
+
+    <div class="main-container">
+      <aside class="sidebar" :class="{ 'sidebar-dashboard': isDashboard }">
+        <div v-if="isDashboard" class="dashboard-shortcuts">
+          <div class="shortcuts-title">
+            <i class="el-icon-s-grid" />
+            <span>快捷入口</span>
+          </div>
+          <div
+            v-for="(menu, index) in topMenus"
+            :key="menu.path + index"
+            class="shortcut-item"
+            @click="switchTopMenu(index)"
+          >
+            <svg-icon v-if="menu.meta && menu.meta.icon" :icon-class="menu.meta.icon" />
+            <span>{{ menu.meta.title }}</span>
+          </div>
+        </div>
+        <saas-sidebar v-else class="saas-sidebar-panel" />
+      </aside>
+
+      <main class="content-area">
+        <tags-view v-if="needTagsView" />
+        <app-main />
+      </main>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapGetters, mapState } from 'vuex'
+import { constantRoutes } from '@/router'
+import SaasSidebar from './components/Sidebar/index.vue'
+import TagsView from './components/TagsView/index.vue'
+import AppMain from './components/AppMain'
+import Search from '@/components/HeaderSearch'
+import Screenfull from '@/components/Screenfull'
+import SizeSelect from '@/components/SizeSelect'
+
+export default {
+  name: 'AdminLayout',
+  components: {
+    SaasSidebar,
+    TagsView,
+    AppMain,
+    Search,
+    Screenfull,
+    SizeSelect
+  },
+  data() {
+    return {
+      activeTopIndex: 0,
+      visibleNumber: 12
+    }
+  },
+  computed: {
+    ...mapGetters(['name', 'device', 'sidebarRouters']),
+    ...mapState({
+      needTagsView: state => state.settings.tagsView,
+      topbarRouters: state => state.permission.topbarRouters
+    }),
+    title() {
+      return process.env.VUE_APP_TITLE_INDEX || '云联融智SAAS'
+    },
+    logImg() {
+      try {
+        return require(process.env.VUE_APP_LOG_URL)
+      } catch (e) {
+        return ''
+      }
+    },
+    userInitial() {
+      const name = this.name || '管'
+      return name.charAt(0).toUpperCase()
+    },
+    topMenus() {
+      const list = []
+      this.topbarRouters.forEach(menu => {
+        if (menu.hidden !== true) {
+          if (menu.path === '/') {
+            list.push(menu.children[0])
+          } else {
+            list.push(menu)
+          }
+        }
+      })
+      return list
+    },
+    visibleTopMenus() {
+      return this.topMenus.slice(0, this.visibleNumber)
+    },
+    overflowTopMenus() {
+      return this.topMenus.slice(this.visibleNumber)
+    },
+    isDashboard() {
+      const p = this.$route.path
+      return p === '/index' || p === '/' || p === ''
+    }
+  },
+  watch: {
+    '$route.path'() {
+      this.syncActiveTopFromRoute()
+    },
+    topMenus: {
+      handler() {
+        this.syncActiveTopFromRoute()
+      },
+      immediate: true
+    }
+  },
+  beforeMount() {
+    window.addEventListener('resize', this.setVisibleNumber)
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.setVisibleNumber)
+  },
+  mounted() {
+    this.setVisibleNumber()
+    this.syncActiveTopFromRoute()
+  },
+  methods: {
+    setVisibleNumber() {
+      const width = document.body.getBoundingClientRect().width / 3
+      this.visibleNumber = Math.max(4, parseInt(width / 85))
+    },
+    goHome() {
+      if (this.$route.path !== '/index') {
+        this.$router.push('/index').catch(() => {})
+      }
+    },
+    switchTopMenu(index) {
+      this.activeTopIndex = index
+      const menu = this.topMenus[index]
+      if (!menu) return
+      const key = this.resolveTopMenuKey(menu)
+      if (this.ishttp(key)) {
+        window.open(key, '_blank')
+        return
+      }
+      if (key.indexOf('/redirect') !== -1) {
+        this.$router.push({ path: key.replace('/redirect', '') }).catch(() => {})
+        return
+      }
+      const routes = this.activeRoutes(key)
+      const firstPath = this.getFirstRoutePath(routes)
+      if (firstPath && this.$route.path !== firstPath) {
+        this.$router.push(firstPath).catch(() => {})
+      }
+    },
+    onMoreMenuCommand(index) {
+      this.switchTopMenu(index)
+    },
+    normPath(p) {
+      if (!p) return ''
+      const s = String(p)
+      if (this.ishttp(s)) return s
+      return s.startsWith('/') ? s : '/' + s
+    },
+    resolveTopMenuKey(menu) {
+      if (!menu) return ''
+      for (const router of this.topbarRouters) {
+        if (router.hidden) continue
+        if (this.normPath(router.path) === this.normPath(menu.path) && router.path !== '/') {
+          return router.path
+        }
+        if (!router.children || !router.children.length) continue
+        const matched = router.children.some(child => {
+          if (child.hidden) return false
+          return child === menu || this.normPath(child.path) === this.normPath(menu.path)
+        })
+        if (matched) {
+          return router.path
+        }
+      }
+      return menu.path
+    },
+    activeRoutes(key) {
+      const routes = []
+      const normalizedKey = this.normPath(key)
+      const childrenMenus = this.buildChildrenMenus()
+      childrenMenus.forEach(item => {
+        if (this.normPath(item.parentPath) === normalizedKey) {
+          routes.push(item)
+        }
+      })
+      if (routes.length === 0) {
+        const router = this.topbarRouters.find(r => !r.hidden && this.normPath(r.path) === normalizedKey)
+        if (router && router.children) {
+          router.children.forEach(child => {
+            if (!child.hidden) routes.push(child)
+          })
+        }
+      }
+      if (routes.length > 0) {
+        this.$store.commit('SET_SIDEBAR_ROUTERS', routes)
+      }
+      return routes
+    },
+    getFirstRoutePath(routes) {
+      if (!routes || !routes.length) return null
+      for (let i = 0; i < routes.length; i++) {
+        const route = routes[i]
+        if (route.hidden) continue
+        if (route.children && route.children.length) {
+          const childPath = this.getFirstRoutePath(route.children)
+          if (childPath) return childPath
+        }
+        if (!route.path || this.ishttp(route.path)) continue
+        if (route.path.indexOf('/redirect') !== -1) {
+          return route.path.replace('/redirect', '')
+        }
+        if (route.path.startsWith('/')) {
+          return route.path
+        }
+      }
+      return null
+    },
+    buildChildrenMenus() {
+      const childrenMenus = []
+      this.topbarRouters.forEach(router => {
+        if (!router.children) return
+        for (const item in router.children) {
+          const child = router.children[item]
+          if (child.parentPath === undefined) {
+            if (router.path === '/') {
+              const seg = (child.path || '').replace(/^\//, '')
+              if (seg.indexOf('redirect/') !== 0) {
+                child.path = '/redirect/' + seg
+              }
+            } else if (!this.ishttp(child.path)) {
+              const seg = (child.path || '').replace(/^\//, '')
+              if (!child.path || !child.path.startsWith('/')) {
+                child.path = this.normPath(router.path) + '/' + seg
+              }
+            }
+            child.parentPath = router.path
+          }
+          childrenMenus.push(child)
+        }
+      })
+      return constantRoutes.concat(childrenMenus)
+    },
+    syncActiveTopFromRoute() {
+      if (!this.topMenus.length) return
+      const path = this.$route.path
+      let topKey = ''
+      if (path.lastIndexOf('/') > 0) {
+        const tmp = path.substring(1)
+        topKey = '/' + tmp.substring(0, tmp.indexOf('/'))
+      } else if (path === '/index' || path === '/') {
+        topKey = '/'
+      }
+      let found = -1
+      this.topMenus.forEach((menu, index) => {
+        const key = this.resolveTopMenuKey(menu)
+        if (topKey && this.normPath(key) === this.normPath(topKey)) {
+          found = index
+        }
+      })
+      if (found >= 0) {
+        this.activeTopIndex = found
+        this.activeRoutes(this.resolveTopMenuKey(this.topMenus[found]))
+      }
+    },
+    ishttp(url) {
+      return url && (url.indexOf('http://') !== -1 || url.indexOf('https://') !== -1)
+    },
+    handleCommand(cmd) {
+      if (cmd === 'logout') {
+        this.$confirm('确定注销并退出系统吗?', '提示', {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+          type: 'warning'
+        }).then(() => {
+          this.$store.dispatch('LogOut').then(() => {
+            location.href = '/index'
+          })
+        }).catch(() => {})
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.admin-layout {
+  height: 100vh;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  margin: 0 !important;
+  padding: 0 !important;
+  position: relative;
+}
+
+.top-nav {
+  height: 50px;
+  background: #fff;
+  display: flex;
+  align-items: center;
+  padding: 0 20px 0 0;
+  flex-shrink: 0;
+  z-index: 100;
+  border-bottom: 1px solid #e8e8e8;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+}
+
+.nav-logo {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  cursor: pointer;
+  margin-right: 8px;
+  flex-shrink: 0;
+  padding: 0 16px;
+}
+
+.logo-img {
+  width: 32px;
+  height: 32px;
+  object-fit: contain;
+  flex-shrink: 0;
+}
+
+.logo-icon {
+  width: 32px;
+  height: 32px;
+  background: linear-gradient(135deg, #409eff, #1890ff);
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+  font-size: 17px;
+  flex-shrink: 0;
+}
+
+.logo-text {
+  font-size: 17px;
+  font-weight: 600;
+  color: #303133;
+  white-space: nowrap;
+}
+
+.logo-home-badge {
+  width: 20px;
+  height: 20px;
+  border-radius: 4px;
+  background: rgba(64, 158, 255, 0.1);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 4px;
+  flex-shrink: 0;
+}
+
+.logo-home-badge i {
+  font-size: 13px;
+  color: #409eff;
+}
+
+.nav-divider {
+  width: 1px;
+  height: 24px;
+  background: #e8e8e8;
+  margin-right: 8px;
+  flex-shrink: 0;
+}
+
+.nav-menu {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  overflow: hidden;
+  min-width: 0;
+}
+
+.nav-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 0 18px;
+  height: 50px;
+  cursor: pointer;
+  color: #606266;
+  font-size: 14px;
+  white-space: nowrap;
+  flex-shrink: 0;
+  border-bottom: 2px solid transparent;
+  box-sizing: border-box;
+}
+
+.nav-item:hover {
+  color: #409eff;
+}
+
+.nav-item.active {
+  color: #409eff;
+  border-bottom-color: #409eff;
+  font-weight: 500;
+}
+
+.nav-item .svg-icon {
+  font-size: 16px;
+}
+
+.user-area {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-left: auto;
+  flex-shrink: 0;
+  padding-left: 12px;
+}
+
+.header-tool {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 6px;
+  font-size: 18px;
+  color: #5a5e66;
+  cursor: pointer;
+}
+
+.header-tool:hover {
+  color: #409eff;
+}
+
+.user-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.user-avatar {
+  width: 30px;
+  height: 30px;
+  border-radius: 8px;
+  background: linear-gradient(135deg, #409eff, #1890ff);
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.user-role {
+  font-size: 13px;
+  color: #909399;
+}
+
+.nav-user {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  cursor: pointer;
+  padding: 4px 8px;
+  border-radius: 6px;
+}
+
+.nav-user:hover {
+  background: rgba(64, 158, 255, 0.08);
+}
+
+.nav-username {
+  font-size: 14px;
+  color: #303133;
+}
+
+.dropdown-arrow {
+  color: #909399;
+  font-size: 12px;
+}
+
+.main-container {
+  flex: 1;
+  display: flex;
+  overflow: hidden;
+  margin: 0 !important;
+  padding: 0 !important;
+}
+
+.sidebar {
+  width: 200px;
+  flex-shrink: 0;
+  background: #fafbff;
+  border-right: 1px solid #e8e8e8;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.sidebar-dashboard {
+  background: #fafbff;
+}
+
+.saas-sidebar-panel {
+  flex: 1;
+  min-height: 0;
+  width: 100% !important;
+  border-right: none !important;
+  background: transparent !important;
+}
+
+.dashboard-shortcuts {
+  padding: 16px 12px;
+}
+
+.shortcuts-title {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  font-size: 12px;
+  font-weight: 600;
+  color: #999;
+  padding: 0 4px 10px;
+  border-bottom: 1px solid #f0f0f0;
+  margin-bottom: 8px;
+}
+
+.shortcut-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 12px;
+  border-radius: 8px;
+  cursor: pointer;
+  color: #555;
+  font-size: 14px;
+  margin-bottom: 2px;
+}
+
+.shortcut-item:hover {
+  background: #ede9fe;
+  color: #4f46e5;
+}
+
+.shortcut-item .svg-icon {
+  color: #8b5cf6;
+}
+
+.content-area {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  background: #f5f6fa;
+  min-width: 0;
+}
+
+.content-area >>> .tags-view-container {
+  flex-shrink: 0;
+}
+
+.content-area >>> .app-main {
+  flex: 1;
+  overflow: auto;
+  padding: 16px 24px 24px;
+}
+</style>
+
+<style scoped>
+/* 侧栏菜单样式 — 与 adminUI AdminLayout 一致 */
+.sidebar >>> .sidebar-wrapper {
+  width: 100%;
+  height: 100%;
+  background: transparent;
+  border: none;
+}
+
+.sidebar >>> .sidebar-header {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  font-size: 12px;
+  font-weight: 600;
+  color: #999;
+  letter-spacing: 0.5px;
+  padding: 16px 16px 10px;
+  border-bottom: 1px solid #f0f0f0;
+  background: #fafbff;
+}
+
+.sidebar >>> .sidebar-menu {
+  border: none;
+  padding: 8px 12px;
+  background-color: #fafbff !important;
+}
+
+.sidebar >>> .el-menu-item {
+  height: 50px !important;
+  line-height: 50px !important;
+  font-size: 14px !important;
+  padding-left: 24px !important;
+  color: #555 !important;
+  border-radius: 8px;
+  margin-bottom: 2px;
+}
+
+.sidebar >>> .el-submenu__title {
+  height: 50px !important;
+  line-height: 50px !important;
+  font-size: 14px !important;
+  font-weight: 500;
+  color: #555 !important;
+  border-radius: 8px;
+  margin-bottom: 2px;
+}
+
+.sidebar >>> .el-menu-item .svg-icon,
+.sidebar >>> .el-submenu__title .svg-icon {
+  margin-right: 8px;
+  color: #8b5cf6 !important;
+}
+
+.sidebar >>> .el-submenu .el-menu-item {
+  padding-left: 44px !important;
+  height: 46px !important;
+  line-height: 46px !important;
+  font-size: 13px !important;
+  min-width: auto !important;
+}
+
+.sidebar >>> .el-menu-item.is-active {
+  background-color: #ede9fe !important;
+  color: #4f46e5 !important;
+  font-weight: 600;
+  border-left: 3px solid #4f46e5 !important;
+  padding-left: 21px !important;
+}
+
+.sidebar >>> .el-menu-item:hover,
+.sidebar >>> .el-submenu__title:hover {
+  background-color: #ede9fe !important;
+  color: #4f46e5 !important;
+}
+
+.sidebar >>> .el-submenu .el-menu-item:hover {
+  background-color: #ede9fe !important;
+  color: #4f46e5 !important;
+}
+</style>

+ 62 - 66
src/layout/components/Navbar.vue

@@ -1,28 +1,24 @@
 <template>
   <div class="navbar" :class="{'topnav-mode': topNav}">
-    <!-- Logo 区域 -->
     <div class="navbar-left">
       <router-link to="/" class="logo-link">
-        <img v-if="logImg" :src="logImg" class="logo-img" />
+        <img v-if="logImg" :src="logImg" class="logo-img" alt="" />
         <span class="logo-title">{{ title }}</span>
       </router-link>
+      <div class="nav-divider" />
     </div>
 
-    <!-- 顶部一级菜单 (topNav模式) -->
     <top-nav v-if="topNav" class="topmenu-container" />
 
-    <!-- 面包屑 (经典模式) -->
     <template v-else>
       <hamburger id="hamburger-container" :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
       <breadcrumb id="breadcrumb-container" class="breadcrumb-container" />
     </template>
 
-    <!-- 右侧用户区域 -->
     <div class="right-menu">
       <template v-if="device!=='mobile'">
         <search id="header-search" class="right-menu-item" />
         <screenfull id="screenfull" class="right-menu-item hover-effect" />
-
         <el-tooltip content="布局大小" effect="dark" placement="bottom">
           <size-select id="size-select" class="right-menu-item hover-effect" />
         </el-tooltip>
@@ -58,7 +54,6 @@ import Screenfull from '@/components/Screenfull'
 import SizeSelect from '@/components/SizeSelect'
 import Search from '@/components/HeaderSearch'
 
-
 export default {
   components: {
     Breadcrumb,
@@ -66,39 +61,28 @@ export default {
     Hamburger,
     Screenfull,
     SizeSelect,
-    Search,
+    Search
   },
   computed: {
-    ...mapGetters([
-      'sidebar',
-      'avatar',
-      'device'
-    ]),
+    ...mapGetters(['sidebar', 'avatar', 'device']),
     topNav() {
       return this.$store.state.settings.topNav
     },
     title() {
-      return process.env.VUE_APP_TITLE_INDEX || '云联融智'
+      return process.env.VUE_APP_TITLE_INDEX || '云联融智SAAS'
     },
     nickName() {
-      return this.$store.getters && this.$store.getters.nickName || '管理员'
+      return (this.$store.getters && this.$store.getters.nickName) || '管理员'
     },
     userNameFirst() {
       const name = this.nickName
       return name ? name.charAt(0) : '管'
     },
     logImg() {
-      return require(process.env.VUE_APP_LOG_URL)
-    },
-    setting: {
-      get() {
-        return this.$store.state.settings.showSettings
-      },
-      set(val) {
-        this.$store.dispatch('settings/changeSetting', {
-          key: 'showSettings',
-          value: val
-        })
+      try {
+        return require(process.env.VUE_APP_LOG_URL)
+      } catch (e) {
+        return ''
       }
     }
   },
@@ -113,61 +97,73 @@ export default {
         type: 'warning'
       }).then(() => {
         this.$store.dispatch('LogOut').then(() => {
-          location.href = '/index';
+          location.href = '/index'
         })
-      }).catch(() => {});
+      }).catch(() => {})
     }
   }
 }
 </script>
 
 <style lang="scss" scoped>
+@import '~@/assets/styles/admin-menu.scss';
+
 .navbar {
-  height: 56px;
+  height: $admin-top-height;
   overflow: hidden;
   position: relative;
   background: #fff;
-  border-bottom: 1px solid #f0f0f0;
-  box-shadow: 0 1px 4px rgba(0,21,41,.04);
+  border-bottom: 1px solid $admin-border;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
   display: flex;
   align-items: center;
-  padding: 0 20px;
+  padding: 0 20px 0 0;
 
   .navbar-left {
     flex-shrink: 0;
     display: flex;
     align-items: center;
+    padding-left: 16px;
 
     .logo-link {
       display: flex;
       align-items: center;
+      gap: 10px;
       text-decoration: none;
       outline: none;
+      margin-right: 8px;
 
       .logo-img {
-        width: 30px;
-        height: 30px;
-        margin-right: 10px;
+        width: 32px;
+        height: 32px;
+        object-fit: contain;
       }
 
       .logo-title {
-        font-size: 15px;
+        font-size: 17px;
         font-weight: 600;
         color: #303133;
         white-space: nowrap;
       }
     }
+
+    .nav-divider {
+      width: 1px;
+      height: 24px;
+      background: $admin-border;
+      margin-right: 8px;
+      flex-shrink: 0;
+    }
   }
 
   .hamburger-container {
-    line-height: 46px;
+    line-height: $admin-top-height;
     height: 100%;
-    float: left;
     cursor: pointer;
-    transition: background .3s;
+    transition: background 0.3s;
 
     &:hover {
-      background: rgba(0, 0, 0, .025)
+      background: rgba(0, 0, 0, 0.025);
     }
   }
 
@@ -185,10 +181,8 @@ export default {
     height: 100%;
     display: flex;
     align-items: center;
-
-    &:focus {
-      outline: none;
-    }
+    margin-left: auto;
+    padding-left: 20px;
 
     .right-menu-item {
       display: inline-flex;
@@ -200,10 +194,10 @@ export default {
 
       &.hover-effect {
         cursor: pointer;
-        transition: background .3s;
+        transition: background 0.3s;
 
         &:hover {
-          background: rgba(0, 0, 0, .025)
+          background: rgba(64, 158, 255, 0.08);
         }
       }
     }
@@ -220,7 +214,7 @@ export default {
           width: 30px;
           height: 30px;
           border-radius: 8px;
-          background: linear-gradient(135deg, #4F46E5, #7C3AED);
+          background: linear-gradient(135deg, #409eff, #1890ff);
           display: flex;
           align-items: center;
           justify-content: center;
@@ -242,39 +236,40 @@ export default {
         .el-icon-caret-bottom {
           margin-left: 4px;
           font-size: 12px;
-          color: #999;
+          color: #909399;
         }
       }
     }
   }
 }
 
-/* topNav 模式下的菜单样式覆盖 */
 .navbar.topnav-mode {
   ::v-deep .topmenu-container.el-menu--horizontal {
     border-bottom: none !important;
-    height: 56px;
+    height: $admin-top-height;
     display: flex;
     align-items: center;
+    background: transparent !important;
 
     & > .el-menu-item {
-      height: 56px !important;
-      line-height: 56px !important;
-      color: #555 !important;
+      height: $admin-top-height !important;
+      line-height: $admin-top-height !important;
+      color: $admin-top-text !important;
       font-size: 14px;
       font-weight: 500;
-      padding: 0 16px !important;
-      margin: 0 4px !important;
+      padding: 0 18px !important;
+      margin: 0 !important;
       border-bottom: 2px solid transparent !important;
+      background: transparent !important;
 
       &:hover {
-        color: #303133 !important;
+        color: $admin-top-active !important;
         background: transparent !important;
       }
 
       &.is-active {
-        color: #4F46E5 !important;
-        border-bottom: 2px solid #4F46E5 !important;
+        color: $admin-top-active !important;
+        border-bottom-color: $admin-top-active !important;
         background: transparent !important;
       }
 
@@ -286,17 +281,18 @@ export default {
 
     & > .el-submenu {
       .el-submenu__title {
-        height: 56px !important;
-        line-height: 56px !important;
-        color: #555 !important;
+        height: $admin-top-height !important;
+        line-height: $admin-top-height !important;
+        color: $admin-top-text !important;
         font-size: 14px;
         font-weight: 500;
-        padding: 0 16px !important;
-        margin: 0 4px !important;
+        padding: 0 18px !important;
+        margin: 0 !important;
         border-bottom: 2px solid transparent !important;
+        background: transparent !important;
 
         &:hover {
-          color: #303133 !important;
+          color: $admin-top-active !important;
           background: transparent !important;
         }
 
@@ -307,8 +303,8 @@ export default {
       }
 
       &.is-active .el-submenu__title {
-        color: #4F46E5 !important;
-        border-bottom: 2px solid #4F46E5 !important;
+        color: $admin-top-active !important;
+        border-bottom-color: $admin-top-active !important;
       }
     }
   }

+ 54 - 56
src/layout/components/Sidebar/Logo.vue

@@ -1,21 +1,13 @@
 <template>
-  <div class="sidebar-logo-container" :class="{'collapse':collapse}" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
-    <transition name="sidebarLogoFade">
-      <router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
-        <img v-if="logImg" :src="logImg" class="sidebar-logo" />
-        <h1 v-else class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
-      </router-link>
-      <router-link v-else key="expand" class="sidebar-logo-link" to="/">
-        <img v-if="logImg" :src="logImg" class="sidebar-logo" />
-        <h1 class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
-      </router-link>
-    </transition>
+  <div class="sidebar-logo-container" :class="{ collapse: collapse }">
+    <router-link class="sidebar-logo-link" to="/">
+      <img v-if="logImg" :src="logImg" class="sidebar-logo" alt="" />
+      <span v-if="!collapse" class="sidebar-title">{{ title }}</span>
+    </router-link>
   </div>
 </template>
 
 <script>
-import variables from '@/assets/styles/variables.scss'
-
 export default {
   name: 'SidebarLogo',
   props: {
@@ -24,67 +16,73 @@ export default {
       required: true
     }
   },
-  computed: {
-    variables() {
-      return variables;
-    },
-	sideTheme() {
-      return this.$store.state.settings.sideTheme
-    }
-  },
   data() {
     return {
-      title: process.env.VUE_APP_TITLE_INDEX || "互联网医院SCRM",
+      title: process.env.VUE_APP_TITLE_INDEX || '云联融智SAAS'
+    }
+  },
+  computed: {
+    logImg() {
+      try {
+        return require(process.env.VUE_APP_LOG_URL)
+      } catch (e) {
+        return ''
+      }
     }
   }
 }
 </script>
 
 <style lang="scss" scoped>
-.sidebarLogoFade-enter-active {
-  transition: opacity 1.5s;
-}
-
-.sidebarLogoFade-enter,
-.sidebarLogoFade-leave-to {
-  opacity: 0;
-}
+@import '~@/assets/styles/variables.scss';
 
+/* 与右侧顶栏同高,垂直居中;右侧竖线与 adminUI 一致 */
 .sidebar-logo-container {
-  position: relative;
+  display: flex;
+  align-items: center;
   width: 100%;
-  height: 50px;
-  line-height: 50px;
-  background: #2b2f3a;
-  text-align: center;
-  overflow: hidden;
+  height: 56px;
+  flex-shrink: 0;
+  background: $nav-top-bg;
+  border-bottom: 1px solid $sidebar-border;
+  border-right: 1px solid $sidebar-border;
+  box-sizing: border-box;
 
-  & .sidebar-logo-link {
-    height: 100%;
+  .sidebar-logo-link {
+    display: flex;
+    align-items: center;
+    justify-content: flex-start;
     width: 100%;
+    height: 100%;
+    padding: 0 16px;
+    text-decoration: none;
+    box-sizing: border-box;
+  }
 
-    & .sidebar-logo {
-      width: 32px;
-      height: 32px;
-      vertical-align: middle;
-      margin-right: 12px;
-    }
+  .sidebar-logo {
+    display: block;
+    width: 30px;
+    height: 30px;
+    flex-shrink: 0;
+    object-fit: contain;
+  }
 
-    & .sidebar-title {
-      display: inline-block;
-      margin: 0;
-      color: #fff;
-      font-weight: bold;
-      line-height: 50px;
-      font-size: 16px;
-      font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
-      vertical-align: middle;
-    }
+  .sidebar-title {
+    margin: 0 0 0 10px;
+    padding: 0;
+    color: #303133;
+    font-weight: 600;
+    font-size: 15px;
+    line-height: 1;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
   }
 
   &.collapse {
-    .sidebar-logo {
-      margin-right: 0px;
+    .sidebar-logo-link {
+      justify-content: center;
+      padding: 0;
     }
   }
 }

+ 19 - 4
src/layout/components/Sidebar/SidebarItem.vue

@@ -25,7 +25,6 @@
 </template>
 
 <script>
-import path from 'path'
 import { isExternal } from '@/utils/validate'
 import Item from './Item'
 import AppLink from './Link'
@@ -89,11 +88,27 @@ export default {
       if (isExternal(this.basePath)) {
         return this.basePath
       }
+      const fullPath = this.joinPath(this.basePath, routePath)
       if (routeQuery) {
-        let query = JSON.parse(routeQuery);
-        return { path: path.resolve(this.basePath, routePath), query: query }
+        const query = JSON.parse(routeQuery)
+        return { path: fullPath, query: query }
       }
-      return path.resolve(this.basePath, routePath)
+      return fullPath
+    },
+    joinPath(base, routePath) {
+      if (!routePath) {
+        return base || ''
+      }
+      const seg = String(routePath)
+      if (seg.startsWith('/') || seg.startsWith('http://') || seg.startsWith('https://')) {
+        return seg
+      }
+      const basePath = base || ''
+      if (!basePath) {
+        return '/' + seg.replace(/^\//, '')
+      }
+      const left = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath
+      return left + '/' + seg.replace(/^\//, '')
     }
   }
 }

+ 54 - 77
src/layout/components/Sidebar/index.vue

@@ -1,22 +1,26 @@
 <template>
-  <div class="sidebar-wrapper" :style="{ backgroundColor: '#fff' }">
+  <div class="sidebar-wrapper">
+    <div class="sidebar-header">
+      <i class="el-icon-menu" />
+      <span>菜单入口</span>
+    </div>
     <el-scrollbar wrap-class="scrollbar-wrapper">
       <el-menu
         :default-active="activeMenu"
         :collapse="isCollapse"
-        :background-color="'#fff'"
+        :background-color="'#fafbff'"
         :text-color="'#555'"
         :unique-opened="true"
-        :active-text-color="'#4F46E5'"
+        :active-text-color="'#4f46e5'"
         :collapse-transition="false"
         mode="vertical"
         class="sidebar-menu"
       >
         <sidebar-item
           v-for="(route, index) in sidebarRouters"
-          :key="route.path  + index"
+          :key="route.path + index"
           :item="route"
-          :base-path="route.path"
+          :base-path="sidebarBasePath(route)"
         />
       </el-menu>
     </el-scrollbar>
@@ -24,99 +28,72 @@
 </template>
 
 <script>
-import { mapGetters, mapState } from "vuex";
-import SidebarItem from "./SidebarItem";
+import { mapGetters } from 'vuex'
+import SidebarItem from './SidebarItem'
 
 export default {
   components: { SidebarItem },
   computed: {
-    ...mapState(["settings"]),
-    ...mapGetters(["sidebarRouters", "sidebar"]),
+    ...mapGetters(['sidebarRouters', 'sidebar']),
     activeMenu() {
-      const route = this.$route;
-      const { meta, path } = route;
+      const route = this.$route
+      const { meta, path } = route
       if (meta.activeMenu) {
-        return meta.activeMenu;
+        return meta.activeMenu
       }
-      return path;
+      return path
     },
     isCollapse() {
-      return !this.sidebar.opened;
+      return !this.sidebar.opened
+    }
+  },
+  methods: {
+    sidebarBasePath(route) {
+      const p = route && route.path
+      if (p && p.startsWith('/')) {
+        return ''
+      }
+      return p || ''
     }
   }
-};
+}
 </script>
+
 <style lang="scss" scoped>
 .sidebar-wrapper {
-  width: 200px;
+  width: 100%;
   height: 100%;
-  background: #fff;
-  border-right: 1px solid #f0f0f0;
-
-  .sidebar-menu {
-    border: none;
-    height: 100%;
-    width: 100% !important;
-
-    ::v-deep .el-menu-item {
-      height: 44px;
-      line-height: 44px;
-      font-size: 14px;
-      color: #555;
-      padding-left: 20px !important;
-
-      &:hover {
-        background-color: rgba(79, 70, 229, 0.04) !important;
-      }
-
-      &.is-active {
-        color: #4F46E5 !important;
-        background-color: #EDE9FE !important;
-        border-radius: 0;
-        font-weight: 500;
-      }
-    }
-
-    ::v-deep .el-submenu__title {
-      height: 44px;
-      line-height: 44px;
-      font-size: 14px;
-      color: #555;
-      padding-left: 20px !important;
-      font-weight: 500;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
 
-      &:hover {
-        background-color: rgba(79, 70, 229, 0.04) !important;
-      }
-
-      .svg-icon {
-        margin-right: 10px;
-        font-size: 16px;
-      }
-    }
+  .sidebar-header {
+    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+    gap: 7px;
+    font-size: 12px;
+    font-weight: 600;
+    color: #999;
+    letter-spacing: 0.5px;
+    padding: 16px 16px 10px;
+    border-bottom: 1px solid #f0f0f0;
+    background: #fafbff;
 
-    ::v-deep .el-submenu .el-menu-item {
-      padding-left: 20px !important;
+    .el-icon-menu {
       font-size: 14px;
-      font-weight: 400;
-      min-width: auto !important;
-      max-width: 100%;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
+      color: #bbb;
     }
+  }
 
-    ::v-deep .el-submenu .el-submenu .el-menu-item {
-      padding-left: 36px !important;
-    }
+  .el-scrollbar {
+    flex: 1;
+    min-height: 0;
+  }
 
-    ::v-deep .el-menu-item,
-    ::v-deep .el-submenu__title {
-      max-width: 100%;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
+  .sidebar-menu {
+    border: none;
+    width: 100% !important;
   }
 }
 </style>

+ 6 - 4
src/layout/components/TagsView/index.vue

@@ -239,11 +239,13 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import '~@/assets/styles/variables.scss';
+
 .tags-view-container {
   height: 34px;
   width: 100%;
-  background: #fff;
-  border-bottom: 1px solid #d8dce5;
+  background: $nav-chrome-bg;
+  border-bottom: 1px solid $nav-chrome-border;
   box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
   .tags-view-wrapper {
     .tags-view-item {
@@ -266,9 +268,9 @@ export default {
         margin-right: 15px;
       }
       &.active {
-        background-color: #42b983;
+        background-color: #4F46E5;
         color: #fff;
-        border-color: #42b983;
+        border-color: #4F46E5;
         &::before {
           content: '';
           background: #fff;

+ 7 - 18
src/layout/index.vue

@@ -1,19 +1,7 @@
 <template>
   <div :class="classObj" class="app-wrapper" :style="{'--current-color': theme}">
-    <!-- topNav 模式:全宽顶部栏 + 左侧子菜单 -->
-    <template v-if="topNav">
-      <div class="topnav-layout">
-        <navbar class="full-navbar" />
-        <div class="topnav-body">
-          <sidebar v-if="hasSidebarRoutes" class="sidebar-container" />
-          <div :class="{hasTagsView:needTagsView}" class="main-container">
-            <tags-view v-if="needTagsView" />
-            <app-main />
-          </div>
-        </div>
-      </div>
-    </template>
-    <!-- 经典模式:左侧全高侧栏 -->
+    <!-- topNav:与 adminUI 一致,使用 AdminLayout -->
+    <admin-layout v-if="topNav" />
     <template v-else>
       <div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
       <sidebar class="sidebar-container"/>
@@ -33,6 +21,7 @@
 
 <script>
 import RightPanel from '@/components/RightPanel'
+import AdminLayout from './AdminLayout'
 import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
 import ResizeMixin from './mixin/ResizeHandler'
 import { mapState } from 'vuex'
@@ -41,6 +30,7 @@ import variables from '@/assets/styles/variables.scss'
 export default {
   name: 'Layout',
   components: {
+    AdminLayout,
     AppMain,
     Navbar,
     RightPanel,
@@ -74,7 +64,7 @@ export default {
       }
     },
     variables() {
-      return variables;
+      return variables
     }
   },
   methods: {
@@ -121,14 +111,13 @@ export default {
 }
 
 .hideSidebar .fixed-header {
-  width: calc(100% - 54px)
+  width: calc(100% - 54px);
 }
 
 .mobile .fixed-header {
   width: 100%;
 }
 
-/* topNav 全宽布局 */
 .topnav-layout {
   display: flex;
   flex-direction: column;
@@ -151,7 +140,7 @@ export default {
       top: auto !important;
       height: 100% !important;
       box-shadow: none !important;
-      border-right: 1px solid #f0f0f0;
+      border-right: 1px solid #e8e8e8;
     }
 
     .main-container {

+ 1 - 1
src/settings.js

@@ -27,7 +27,7 @@ module.exports = {
   /**
    * 是否显示logo
    */
-  sidebarLogo: false,
+  sidebarLogo: true,
 
   /**
    * 是否显示动态标题

+ 3 - 0
src/store/modules/permission.js

@@ -1,6 +1,7 @@
 import { constantRoutes } from '@/router'
 import { getRouters } from '@/api/menu'
 import Layout from '@/layout/index'
+import AdminLayout from '@/layout/AdminLayout'
 import ParentView from '@/components/ParentView';
 import InnerLink from '@/layout/components/InnerLink'
 
@@ -82,6 +83,8 @@ function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
       // Layout ParentView 组件特殊处理
       if (route.component === 'Layout') {
         route.component = Layout
+      } else if (route.component === 'AdminLayout') {
+        route.component = AdminLayout
       } else if (route.component === 'ParentView') {
         route.component = ParentView
       } else if (route.component === 'InnerLink') {