NewLife/cube-front

feat(layout): CyberLayout 侧边栏折叠飞出菜单与顶部导航栏重构

- Sidebar 支持折叠模式:图标视图 + 飞出菜单面板
- 菜单分组默认折叠,点击展开/收起
- 菜单组标签字体优化:移除 uppercase,改善中文显示
- 新增 UserProfile 组件,支持侧边栏和顶栏两种变体
- 修复飞出面板 z-index 溢出问题
- 修复侧边栏折叠时 UserProfile 下拉宽度问题
笑笑 authored at 2026-05-17 09:41:05
106d51f
Tree
1 Parent(s) 0a0593c
Summary: 4 changed files with 982 additions and 135 deletions.
Added +456 -0
Modified +3 -18
Modified +3 -3
Modified +520 -114
Added +456 -0
diff --git a/core/components/UserProfile.vue b/core/components/UserProfile.vue
new file mode 100644
index 0000000..c1ab488
--- /dev/null
+++ b/core/components/UserProfile.vue
@@ -0,0 +1,456 @@
+<script setup lang="ts">
+/**
+ * UserProfile - 统一用户头像/资料组件
+ *
+ * 两种变体:
+ *   navbar  - 顶部栏模式:全高触发按钮,头像 + 用户名 + 箭头,下拉向下
+ *   sidebar - 侧边栏模式:底部紧凑触发,头像 + 用户名 + 箭头,下拉向上
+ *
+ * Slots:
+ *   #extra-options — 下拉菜单额外选项(渲染在用户信息卡之前),可嵌入功能按钮
+ *
+ * Props:
+ *   variant  — 显示变体,'navbar' | 'sidebar'
+ *   dropup   — 是否向上弹出,侧边栏底部使用时设 true
+ */
+import { ref, computed, onMounted, onUnmounted } from 'vue';
+import { useRouter } from 'vue-router';
+import { useUserStore } from 'cube-front/core/stores/user';
+import { getConfig } from 'cube-front/core/configure';
+
+interface Props {
+  /** 显示变体 */
+  variant?: 'navbar' | 'sidebar';
+  /** 下拉方向:默认向下,sidebar 底部使用时设 true 向上弹出 */
+  dropup?: boolean;
+}
+
+withDefaults(defineProps<Props>(), {
+  variant: 'navbar',
+  dropup: false,
+});
+
+const router = useRouter();
+const userStore = useUserStore();
+
+const baseUrl = getConfig().request.baseUrl ?? '';
+
+const menuRef = ref<HTMLElement | null>(null);
+const menuOpen = ref(false);
+const avatarError = ref(false);
+
+const currentUser = computed(() => userStore.userInfo);
+const userId = computed(() => currentUser.value?.id);
+const userName = computed(
+  () => currentUser.value?.displayName || currentUser.value?.name || '用户',
+);
+const userInitial = computed(() => userName.value.charAt(0).toUpperCase());
+const avatarUrl = computed(() => (userId.value ? `${baseUrl}/Cube/Avatar/${userId.value}` : ''));
+
+function toggleMenu() {
+  menuOpen.value = !menuOpen.value;
+}
+
+function closeMenu() {
+  menuOpen.value = false;
+}
+
+function handleAvatarError() {
+  avatarError.value = true;
+}
+
+function handleProfile() {
+  closeMenu();
+  router.push('/profile').catch(() => {});
+}
+
+function handleLogout() {
+  closeMenu();
+  userStore.logout();
+}
+
+function handleClickOutside(e: MouseEvent) {
+  if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
+    closeMenu();
+  }
+}
+
+onMounted(() => {
+  document.addEventListener('click', handleClickOutside);
+});
+
+onUnmounted(() => {
+  document.removeEventListener('click', handleClickOutside);
+});
+</script>
+
+<template>
+  <div ref="menuRef" class="user-profile" :class="[`variant-${variant}`, { dropup }]">
+    <!-- 触发按钮 -->
+    <button class="profile-trigger" :class="{ active: menuOpen }" @click="toggleMenu">
+      <!-- 头像 -->
+      <div class="avatar-wrapper">
+        <img
+          v-if="avatarUrl && !avatarError"
+          :src="avatarUrl"
+          :alt="userName"
+          class="avatar-img"
+          @error="handleAvatarError"
+        />
+        <span v-else class="avatar-initial">{{ userInitial }}</span>
+      </div>
+      <!-- 用户名 -->
+      <span class="profile-username">{{ userName }}</span>
+      <!-- 展开箭头 -->
+      <svg
+        class="profile-chevron"
+        :class="{ open: menuOpen }"
+        width="12"
+        height="12"
+        viewBox="0 0 24 24"
+        fill="none"
+        stroke="currentColor"
+        stroke-width="2.5"
+      >
+        <path d="M6 9l6 6 6-6" />
+      </svg>
+    </button>
+
+    <!-- 下拉面板 -->
+    <Transition name="profile-fade">
+      <div v-if="menuOpen" class="profile-dropdown">
+        <!-- 额外选项插槽(在用户信息卡之前) -->
+        <template v-if="$slots['extra-options']">
+          <div class="extra-options">
+            <slot name="extra-options" />
+          </div>
+          <div class="dropdown-divider" />
+        </template>
+
+        <!-- 用户信息卡 -->
+        <div class="dropdown-header">
+          <div class="dropdown-avatar">
+            <img
+              v-if="avatarUrl && !avatarError"
+              :src="avatarUrl"
+              :alt="userName"
+              class="dropdown-avatar-img"
+              @error="handleAvatarError"
+            />
+            <span v-else class="dropdown-avatar-initial">{{ userInitial }}</span>
+          </div>
+          <div class="dropdown-info">
+            <div class="dropdown-name">{{ userName }}</div>
+            <div class="dropdown-role">{{ currentUser?.roleName || '用户' }}</div>
+          </div>
+        </div>
+        <div class="dropdown-divider" />
+
+        <!-- 个人中心 -->
+        <button class="dropdown-item" @click="handleProfile">
+          <svg
+            width="14"
+            height="14"
+            viewBox="0 0 24 24"
+            fill="none"
+            stroke="currentColor"
+            stroke-width="2"
+          >
+            <path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
+            <circle cx="12" cy="7" r="4" />
+          </svg>
+          <span>个人中心</span>
+        </button>
+
+        <!-- 退出登录 -->
+        <button class="dropdown-item danger" @click="handleLogout">
+          <svg
+            width="14"
+            height="14"
+            viewBox="0 0 24 24"
+            fill="none"
+            stroke="currentColor"
+            stroke-width="2"
+            stroke-linecap="round"
+          >
+            <path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
+            <polyline points="16 17 21 12 16 7" />
+            <line x1="21" y1="12" x2="9" y2="12" />
+          </svg>
+          <span>退出登录</span>
+        </button>
+      </div>
+    </Transition>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+/* ───────── 容器 ───────── */
+.user-profile {
+  position: relative;
+
+  &.variant-navbar {
+    height: 100%;
+    display: flex;
+    align-items: center;
+  }
+
+  &.variant-sidebar {
+    width: 100%;
+  }
+}
+
+/* ───────── 触发按钮 ───────── */
+.profile-trigger {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  border: none;
+  background: transparent;
+  cursor: pointer;
+  font-family: inherit;
+  color: var(--navbar-text, var(--text-secondary));
+  transition:
+    background 0.15s,
+    color 0.15s;
+  white-space: nowrap;
+
+  &:hover,
+  &.active {
+    background: var(--navbar-hover-bg, rgba(0, 0, 0, 0.06));
+    color: var(--navbar-text-hover, var(--text-primary));
+  }
+}
+
+/* navbar 变体:撑满顶部栏高度 */
+.variant-navbar .profile-trigger {
+  height: 100%;
+  padding: 0 16px;
+  border-radius: 0;
+}
+
+/* sidebar 变体:宽度撑满,带圆角 */
+.variant-sidebar .profile-trigger {
+  width: 100%;
+  padding: 12px 16px;
+  border-radius: var(--radius-sm, 8px);
+}
+
+/* ───────── 头像 ───────── */
+.avatar-wrapper {
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  overflow: hidden;
+  flex-shrink: 0;
+  background: linear-gradient(135deg, var(--accent, #4ec685) 0%, var(--accent-hover, #3db873) 100%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+
+.avatar-initial {
+  font-size: 13px;
+  font-weight: 700;
+  color: #fff;
+  line-height: 1;
+}
+
+/* ───────── 用户名 ───────── */
+.profile-username {
+  font-size: 13px;
+  font-weight: 500;
+  color: inherit;
+}
+
+/* ───────── 箭头 ───────── */
+.profile-chevron {
+  color: var(--text-muted);
+  transition: transform 0.2s;
+  flex-shrink: 0;
+  margin-left: auto;
+
+  &.open {
+    transform: rotate(180deg);
+  }
+}
+
+/* sidebar 变体箭头旋转方向相反(默认向上,打开后向下) */
+.dropup .profile-chevron {
+  transform: rotate(180deg);
+
+  &.open {
+    transform: rotate(0deg);
+  }
+}
+
+/* ───────── 下拉面板 ───────── */
+.profile-dropdown {
+  position: absolute;
+  min-width: 220px;
+  background: var(--bg-elevated, #fff);
+  border: 1px solid var(--border-subtle, #e2e0dc);
+  border-radius: var(--radius-md, 10px);
+  box-shadow: var(--shadow-lg, 0 10px 40px rgba(0, 0, 0, 0.12));
+  z-index: 300;
+  overflow: visible;
+}
+
+/* navbar 变体:右对齐向下弹出 */
+.variant-navbar .profile-dropdown {
+  right: 0;
+  top: 100%;
+}
+
+/* sidebar 变体:左对齐向上弹出,宽度与触发区一致 */
+.variant-sidebar .profile-dropdown {
+  left: 0;
+  right: 0;
+  min-width: 0; // 不覆盖 left/right 确定的宽度
+  bottom: 100%;
+  margin-bottom: 4px;
+}
+
+/* dropup 属性覆盖方向 */
+.dropup .profile-dropdown {
+  bottom: 100%;
+  top: auto;
+  margin-bottom: 4px;
+}
+
+/* ───────── 额外选项插槽区域 ───────── */
+.extra-options {
+  padding: 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+
+  // 侧边栏上下文中,子下拉菜单向右展开(左对齐),避免超出屏幕左侧
+  :deep(.sw-menu) {
+    right: auto;
+    left: 0;
+  }
+}
+
+/* ───────── 下拉头部 ───────── */
+.dropdown-header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 14px 16px;
+}
+
+.dropdown-avatar {
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  overflow: hidden;
+  background: linear-gradient(135deg, var(--accent, #4ec685) 0%, var(--accent-hover, #3db873) 100%);
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.dropdown-avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+
+.dropdown-avatar-initial {
+  font-size: 16px;
+  font-weight: 700;
+  color: #fff;
+}
+
+.dropdown-info {
+  flex: 1;
+  min-width: 0;
+}
+
+.dropdown-name {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--text-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.dropdown-role {
+  font-size: 12px;
+  color: var(--text-muted);
+  margin-top: 2px;
+}
+
+.dropdown-divider {
+  height: 1px;
+  background: var(--border-subtle);
+}
+
+/* ───────── 默认下拉选项 ───────── */
+.dropdown-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+  padding: 11px 16px;
+  border: none;
+  background: transparent;
+  color: var(--text-secondary);
+  font-size: 13px;
+  font-family: inherit;
+  cursor: pointer;
+  transition:
+    background 0.12s,
+    color 0.12s;
+  text-align: left;
+
+  &:hover {
+    background: var(--accent-muted);
+    color: var(--text-primary);
+  }
+
+  &.danger {
+    color: var(--color-danger, #dc2626);
+
+    &:hover {
+      background: var(--color-danger-bg, #fef2f2);
+    }
+  }
+}
+
+/* ───────── 过渡动画 ───────── */
+.profile-fade-enter-active,
+.profile-fade-leave-active {
+  transition:
+    opacity 0.15s,
+    transform 0.15s;
+}
+
+.variant-navbar {
+  .profile-fade-enter-from,
+  .profile-fade-leave-to {
+    opacity: 0;
+    transform: translateY(-6px);
+  }
+}
+
+.variant-sidebar,
+.dropup {
+  .profile-fade-enter-from,
+  .profile-fade-leave-to {
+    opacity: 0;
+    transform: translateY(6px);
+  }
+}
+</style>
Modified +3 -18
diff --git a/core/layouts/CyberLayout/index.vue b/core/layouts/CyberLayout/index.vue
index 5da182d..8dbe3be 100644
--- a/core/layouts/CyberLayout/index.vue
+++ b/core/layouts/CyberLayout/index.vue
@@ -1,6 +1,5 @@
 <script setup lang="ts">
 import Sidebar from './Sidebar/index.vue';
-import Navbar from './Navbar/index.vue';
 </script>
 
 <template>
@@ -9,15 +8,9 @@ import Navbar from './Navbar/index.vue';
     <Sidebar />
 
     <!-- 主内容区 -->
-    <div class="cyber-main">
-      <!-- 顶部导航 -->
-      <Navbar />
-
-      <!-- 页面内容 -->
-      <main class="cyber-content">
-        <slot></slot>
-      </main>
-    </div>
+    <main class="cyber-content">
+      <slot></slot>
+    </main>
   </div>
 </template>
 
@@ -31,14 +24,6 @@ import Navbar from './Navbar/index.vue';
   font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
 }
 
-.cyber-main {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  min-width: 0;
-  overflow: hidden;
-}
-
 .cyber-content {
   flex: 1;
   min-width: 0;
Modified +3 -3
diff --git a/core/layouts/CyberLayout/Navbar/index.vue b/core/layouts/CyberLayout/Navbar/index.vue
index 5759e97..2d0dad4 100644
--- a/core/layouts/CyberLayout/Navbar/index.vue
+++ b/core/layouts/CyberLayout/Navbar/index.vue
@@ -6,7 +6,7 @@ import ModeSwitcher from 'cube-front/core/components/ModeSwitcher.vue';
 import { useUserStore } from 'cube-front/core/stores/user';
 import { useMenuStore } from 'cube-front/core/stores/menu';
 import NotificationBell from 'cube-front/core/components/NotificationBell.vue';
-import LogoutButton from 'cube-front/core/components/LogoutButton.vue';
+import NavbarUserProfile from 'cube-front/core/components/NavbarUserProfile.vue';
 
 const userStore = useUserStore();
 const menuStore = useMenuStore();
@@ -60,8 +60,8 @@ const currentDate = computed(() => {
       <!-- 通知按钮 -->
       <NotificationBell show-label />
 
-      <!-- 退出登录 -->
-      <LogoutButton show-label />
+      <!-- 用户头像资料(含退出登录) -->
+      <NavbarUserProfile />
     </div>
   </header>
 </template>
Modified +520 -114
diff --git a/core/layouts/CyberLayout/Sidebar/index.vue b/core/layouts/CyberLayout/Sidebar/index.vue
index 4bf64f1..2b214c8 100644
--- a/core/layouts/CyberLayout/Sidebar/index.vue
+++ b/core/layouts/CyberLayout/Sidebar/index.vue
@@ -1,33 +1,34 @@
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue';
+import { ref, computed, provide, readonly, watch } from 'vue';
 import { storeToRefs } from 'pinia';
 import { getConfig } from 'cube-front/core/configure';
 import { useMenuStore, type TreeMenuItem } from 'cube-front/core/stores/menu';
-import { useUserStore } from 'cube-front/core/stores/user';
 import MenuItem from 'cube-front/core/components/MenuItem.vue';
+import UserProfile from 'cube-front/core/components/UserProfile.vue';
+import ModeSwitcher from 'cube-front/core/components/ModeSwitcher.vue';
+import ThemeSwitcher from 'cube-front/core/components/ThemeSwitcher.vue';
+import LayoutSwitcher from 'cube-front/core/components/LayoutSwitcher.vue';
+import NotificationBell from 'cube-front/core/components/NotificationBell.vue';
+import SearchBar from 'cube-front/core/components/SearchBar.vue';
+import { renderMenuTitle } from 'cube-front/core/utils/menuHelpers';
+import { openMenuTab } from 'cube-front/core/utils/menuTab';
 
 const config = getConfig();
 const menuStore = useMenuStore();
-const userStore = useUserStore();
-
 const { treeMenus, activeMenu } = storeToRefs(menuStore);
 
 // Logo配置
 const logo = computed(() => config.base.logo);
 const title = computed(() => config.base.title);
 
-// 检测logo宽高比
 const logoAspectRatio = ref<number | null>(null);
 const logoImgRef = ref<HTMLImageElement | null>(null);
-
 const onLogoLoad = (e: Event) => {
   const img = e.target as HTMLImageElement;
   if (img.naturalWidth && img.naturalHeight) {
     logoAspectRatio.value = img.naturalWidth / img.naturalHeight;
   }
 };
-
-// 是否显示标题(logo为正方形时显示)
 const showLogoTitle = computed(() => {
   if (!logo.value) return true;
   if (logoAspectRatio.value === null) return true;
@@ -35,129 +36,305 @@ const showLogoTitle = computed(() => {
   return ratio > 0.9 && ratio < 1.1;
 });
 
-// 用户信息
-const currentUser = computed(() => userStore.userInfo);
-const userInitial = computed(() =>
-  (currentUser.value?.displayName || currentUser.value?.name || 'U').charAt(0).toUpperCase(),
-);
-const userName = computed(() => currentUser.value?.displayName || currentUser.value?.name || '');
-const userEmail = computed(() => currentUser.value?.mail || '');
-
-// 对菜单进行分组(按顶层菜单分组)
-const menuGroups = computed(() => {
-  const groups: { title: string; menus: TreeMenuItem[] }[] = [];
-
-  if (treeMenus.value && treeMenus.value.length > 0) {
-    treeMenus.value.forEach(menu => {
-      groups.push({
-        title: menu.title || menu.name,
-        menus: menu.children || [],
-      });
+// 菜单分组(包含 source 以便折叠模式读取图标)
+type MenuGroup = { source: TreeMenuItem; title: string; menus: TreeMenuItem[] };
+const menuGroups = computed((): MenuGroup[] => {
+  if (!treeMenus.value?.length) return [];
+  return treeMenus.value.map((menu) => ({
+    source: menu,
+    title: menu.title || menu.name,
+    menus: menu.children || [],
+  }));
+});
+
+// 侧边栏整体折叠(220px ↔ 64px)
+const collapsed = ref(false);
+const toggleCollapse = () => { collapsed.value = !collapsed.value; };
+
+// 各分组独立折叠(默认全部折叠)
+const groupCollapsed = ref<Record<string, boolean>>({});
+watch(
+  menuGroups,
+  (groups) => {
+    groups.forEach((g) => {
+      if (groupCollapsed.value[g.title] === undefined) {
+        groupCollapsed.value[g.title] = true; // 默认收起
+      }
     });
+  },
+  { immediate: true },
+);
+const toggleGroup = (title: string) => {
+  groupCollapsed.value[title] = !groupCollapsed.value[title];
+};
+
+provide('sidebarCollapsed', readonly(collapsed));
+
+// ─── 折叠模式:飞出面板 ───
+const flyoutGroup = ref<MenuGroup | null>(null);
+const flyoutL1 = ref<TreeMenuItem | null>(null); // 当前展开 L2 的 L1 项
+let _ftTimer: ReturnType<typeof setTimeout> | null = null;
+
+function startFlyout(group: MenuGroup) {
+  if (_ftTimer) { clearTimeout(_ftTimer); _ftTimer = null; }
+  if (flyoutGroup.value?.title !== group.title) flyoutL1.value = null;
+  flyoutGroup.value = group;
+}
+function keepFlyout() {
+  if (_ftTimer) { clearTimeout(_ftTimer); _ftTimer = null; }
+}
+function endFlyout() {
+  _ftTimer = setTimeout(() => {
+    flyoutGroup.value = null;
+    flyoutL1.value = null;
+  }, 200);
+}
+
+function onFlyoutL1Enter(item: TreeMenuItem) {
+  if (item.children?.length) flyoutL1.value = item;
+  else flyoutL1.value = null;
+}
+function handleFlyoutL1Click(item: TreeMenuItem) {
+  if (item.children?.length) {
+    flyoutL1.value = flyoutL1.value?.id === item.id ? null : item;
+  } else {
+    openMenuTab({ url: item.path, title: item.name });
+    menuStore.setActiveMenu(item);
+    flyoutGroup.value = null;
+    flyoutL1.value = null;
+  }
+}
+function handleFlyoutL2Click(item: TreeMenuItem) {
+  if (!item.children?.length) {
+    openMenuTab({ url: item.path, title: item.name });
+    menuStore.setActiveMenu(item);
+    flyoutGroup.value = null;
+    flyoutL1.value = null;
   }
+}
 
-  return groups;
-});
+// 判断某 L0 分组是否含有当前激活项
+function isGroupActive(group: MenuGroup): boolean {
+  if (!activeMenu.value) return false;
+  function check(m: TreeMenuItem): boolean {
+    return m.id === activeMenu.value?.id || (m.children?.some(check) ?? false);
+  }
+  return group.menus.some(check);
+}
+// 判断某菜单项(或其后代)是否激活
+function isAncestorActive(menu: TreeMenuItem): boolean {
+  if (!activeMenu.value) return false;
+  function check(m: TreeMenuItem): boolean {
+    return m.id === activeMenu.value?.id || (m.children?.some(check) ?? false);
+  }
+  return check(menu);
+}
 </script>
 
 <template>
-  <aside class="cyber-sidebar">
-    <!-- 分割线发光效果 -->
+  <aside class="cyber-sidebar" :class="{ collapsed }">
+    <!-- 侧边发光线 -->
     <div class="sidebar-glow" />
 
     <!-- Logo 区域 -->
-    <div class="sidebar-logo" :class="{ 'logo-only': !showLogoTitle }">
-      <div v-if="logo" class="logo-icon" :class="{ 'logo-large': !showLogoTitle }">
+    <div class="sidebar-logo">
+      <!-- 有 logo 时显示图标 -->
+      <div v-if="logo" class="logo-icon">
         <img ref="logoImgRef" :src="logo" :alt="title" @load="onLogoLoad" />
       </div>
-      <div v-if="showLogoTitle" class="logo-text">
+      <!-- 标题:无 logo 时显示;折叠时隐藏 -->
+      <div v-if="(showLogoTitle || !logo) && !collapsed" class="logo-text">
         <h1>{{ title }}</h1>
       </div>
     </div>
 
+    <!-- 搜索框:折叠时隐藏 -->
+    <div v-show="!collapsed" class="sidebar-search">
+      <SearchBar mode="box" placeholder="搜索菜单..." />
+    </div>
+
     <!-- 导航菜单 -->
     <nav class="sidebar-nav">
-      <template v-for="group in menuGroups" :key="group.title">
-        <div v-if="group.menus.length > 0" class="nav-group">
-          <div class="nav-label">{{ group.title }}</div>
-          <MenuItem
-            v-for="menu in group.menus"
-            :key="menu.id"
-            :menu="menu"
-            :activeMenu="activeMenu"
-          />
+
+      <!-- ── 折叠模式:每组一个图标按钮 + 飞出面板 ── -->
+      <template v-if="collapsed">
+        <div
+          v-for="group in menuGroups"
+          :key="group.title"
+          class="ci-group"
+          :class="{ active: isGroupActive(group), open: flyoutGroup?.title === group.title }"
+          @mouseenter="startFlyout(group)"
+          @mouseleave="endFlyout()"
+        >
+          <!-- 图标按钮 -->
+          <div class="ci-btn" :title="group.title">
+            <!-- 已知图标 -->
+            <svg v-if="group.source.icon === 'dashboard'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
+            <svg v-else-if="group.source.icon === 'device'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
+            <svg v-else-if="group.source.icon === 'layers'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
+            <svg v-else-if="group.source.icon === 'settings'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
+            <svg v-else-if="group.source.icon === 'users'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
+            <svg v-else-if="group.source.icon === 'bell'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>
+            <svg v-else-if="group.source.icon === 'activity'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
+            <!-- 无图标:首字符 -->
+            <span v-else class="ci-char">{{ group.title.charAt(0) }}</span>
+          </div>
+
+          <!-- 飞出面板 -->
+          <div
+            v-if="flyoutGroup?.title === group.title"
+            class="flyout-panel"
+            @mouseenter="keepFlyout()"
+            @mouseleave="endFlyout()"
+          >
+            <div class="flyout-header">{{ group.title }}</div>
+            <template v-for="item in group.menus" :key="item.id">
+              <div
+                class="flyout-item"
+                :class="{
+                  active: activeMenu?.id === item.id || isAncestorActive(item),
+                  'has-sub': item.children?.length,
+                  open: flyoutL1?.id === item.id,
+                }"
+                @mouseenter="onFlyoutL1Enter(item)"
+                @click="handleFlyoutL1Click(item)"
+              >
+                <span class="fi-text">{{ renderMenuTitle(item) }}</span>
+                <svg v-if="item.children?.length" class="fi-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
+                  <path d="M9 18l6-6-6-6" />
+                </svg>
+              </div>
+              <!-- L2 展开(inline) -->
+              <template v-if="flyoutL1?.id === item.id && item.children?.length">
+                <div
+                  v-for="child in item.children"
+                  :key="child.id"
+                  class="flyout-item flyout-item--l2"
+                  :class="{ active: activeMenu?.id === child.id }"
+                  @click.stop="handleFlyoutL2Click(child)"
+                >
+                  {{ renderMenuTitle(child) }}
+                </div>
+              </template>
+            </template>
+          </div>
         </div>
       </template>
+
+      <!-- ── 展开模式:分组 + MenuItem ── -->
+      <template v-else>
+        <template v-for="group in menuGroups" :key="group.title">
+          <div v-if="group.menus.length > 0" class="nav-group">
+            <!-- 分组标签:可点击展开/收起 -->
+            <div class="nav-label" @click="toggleGroup(group.title)">
+              <span class="nav-label-text">{{ group.title }}</span>
+              <svg
+                class="nav-label-arrow"
+                :class="{ closed: groupCollapsed[group.title] }"
+                width="10" height="10" viewBox="0 0 24 24"
+                fill="none" stroke="currentColor" stroke-width="2.5"
+              >
+                <path d="M6 9l6 6 6-6" />
+              </svg>
+            </div>
+            <div v-show="!groupCollapsed[group.title]" class="nav-group-items">
+              <MenuItem
+                v-for="menu in group.menus"
+                :key="menu.id"
+                :menu="menu"
+                :activeMenu="activeMenu"
+              />
+            </div>
+          </div>
+        </template>
+      </template>
+
     </nav>
 
-    <!-- 用户信息 -->
-    <div class="sidebar-user">
-      <div class="user-avatar">{{ userInitial }}</div>
-      <div class="user-info">
-        <h3>{{ userName }}</h3>
-        <p>{{ userEmail }}</p>
+    <!-- 底部用户区域 -->
+    <div class="sidebar-user" :class="{ collapsed }">
+      <!-- 侧边栏整体折叠切换按钮 -->
+      <button
+        class="sidebar-collapse-btn"
+        :class="{ collapsed }"
+        :title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
+        @click="toggleCollapse"
+      >
+        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
+          <path d="M15 18l-6-6 6-6" />
+        </svg>
+        <span v-if="!collapsed" class="collapse-label">折叠侧边栏</span>
+      </button>
+
+      <div class="user-profile-wrap">
+        <UserProfile variant="sidebar" dropup>
+          <template #extra-options>
+            <div class="tool-row">
+              <ModeSwitcher />
+              <ThemeSwitcher />
+              <LayoutSwitcher />
+              <NotificationBell />
+            </div>
+          </template>
+        </UserProfile>
       </div>
     </div>
   </aside>
 </template>
 
 <style lang="scss" scoped>
+/* —— 侧边栏容器 —— */
 .cyber-sidebar {
   position: relative;
   display: flex;
   flex-direction: column;
   height: 100vh;
+  width: 220px;
+  flex-shrink: 0;
+  overflow: visible; // 不裁剪搜索结果下拉
   background: var(--sidebar-bg);
   border-right: 1px solid var(--sidebar-border);
+  transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+
+  &.collapsed {
+    width: 64px;
+  }
 }
 
+/* —— 发光线 —— */
 .sidebar-glow {
   position: absolute;
   top: 0;
   right: 0;
   width: 1px;
   height: 100%;
-  background: linear-gradient(
-    180deg,
-    var(--accent),
-    transparent 50%,
-    var(--accent-secondary)
-  );
+  background: linear-gradient(180deg, var(--accent), transparent 50%, var(--accent-secondary));
   opacity: 0.3;
+  pointer-events: none;
 }
 
-// Logo 区域
+/* —— Logo 区域 —— */
 .sidebar-logo {
   display: flex;
   align-items: center;
-  gap: 12px;
-  padding: 0 16px;
+  gap: 10px;
+  padding: 0 12px;
   border-bottom: 1px solid var(--sidebar-border);
   height: var(--layout-nav-height, 64px);
-
-  // 长条形 logo:高度固定,宽度自适应
-  &.logo-only {
-    justify-content: center;
-    padding: 0 12px;
-
-    .logo-icon {
-      height: 48px;
-      width: auto;
-      border-radius: var(--radius-sm);
-    }
-  }
+  flex-shrink: 0;
+  overflow: hidden;
 }
 
 .logo-icon {
   width: 32px;
   height: 32px;
-  background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
-  border-radius: var(--radius-sm);
+  flex-shrink: 0;
   display: flex;
   align-items: center;
   justify-content: center;
-  flex-shrink: 0;
+  border-radius: var(--radius-sm);
   overflow: hidden;
+  background: transparent; // 不给 logo 加背景色
 
   img {
     width: 100%;
@@ -167,29 +344,59 @@ const menuGroups = computed(() => {
 }
 
 .logo-text {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+
   h1 {
     font-size: 14px;
     font-weight: 600;
     letter-spacing: -0.2px;
     margin: 0;
     color: var(--text-primary);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
   }
 }
 
-// 导航菜单
+/* —— 搜索框区域 —— */
+.sidebar-search {
+  padding: 8px 10px;
+  border-bottom: 1px solid var(--sidebar-border);
+  flex-shrink: 0;
+  overflow: visible;
+
+  // 覆盖 SearchBar 的输入框样式,适配侧边栏暗色主题
+  :deep(.sb-box) {
+    background: rgba(255, 255, 255, 0.04);
+    border-color: var(--sidebar-border);
+
+    &.focused,
+    &:focus-within {
+      border-color: var(--accent);
+      background: rgba(255, 255, 255, 0.07);
+    }
+  }
+}
+
+/* —— 导航菜单 —— */
 .sidebar-nav {
   flex: 1;
-  padding: 20px 12px;
+  padding: 16px 8px;
   overflow-y: auto;
+  overflow-x: hidden;
   scrollbar-width: thin;
-  scrollbar-color: var(--border-subtle) transparent;
 
-  &::-webkit-scrollbar {
-    width: 4px;
-  }
-  &::-webkit-scrollbar-track {
-    background: transparent;
+  // 折叠模式:允许飞出面板超出侧边栏范围
+  .cyber-sidebar.collapsed & {
+    overflow: visible;
+    padding: 8px 0;
   }
+  scrollbar-color: var(--border-subtle) transparent;
+
+  &::-webkit-scrollbar { width: 4px; }
+  &::-webkit-scrollbar-track { background: transparent; }
   &::-webkit-scrollbar-thumb {
     background: var(--border-subtle);
     border-radius: 4px;
@@ -197,62 +404,261 @@ const menuGroups = computed(() => {
 }
 
 .nav-group {
-  margin-bottom: 24px;
+  margin-bottom: 20px;
 }
 
 .nav-label {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 12px;
+  font-weight: 500;
+  letter-spacing: 0.3px;
+  color: var(--text-secondary);
+  padding: 0 12px;
+  margin-bottom: 6px;
+  white-space: nowrap;
+  overflow: hidden;
+  cursor: pointer;
+  user-select: none;
+  border-radius: 4px;
+  transition: color 0.15s;
+
+  &:hover {
+    color: var(--text-primary);
+  }
+}
+
+.nav-label-text {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.nav-label-arrow {
+  flex-shrink: 0;
+  color: var(--text-muted);
+  transition: transform 0.2s;
+  transform: rotate(0deg); // 默认展开(v 向下)
+
+  &.closed {
+    transform: rotate(-90deg); // 收起(> 向右)
+  }
+}
+
+/* —— 折叠模式:组图标 + 飞出面板 —— */
+.ci-group {
+  position: relative;
+  display: flex;
+  justify-content: center;
+  padding: 2px 8px;
+
+  &.active .ci-btn,
+  &.open .ci-btn {
+    background: var(--sidebar-item-active);
+    color: var(--accent);
+  }
+}
+
+.ci-btn {
+  width: 40px;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: var(--radius-sm);
+  cursor: pointer;
+  color: var(--text-secondary);
+  transition: background 0.15s, color 0.15s;
+
+  &:hover {
+    background: var(--sidebar-item-hover);
+    color: var(--text-primary);
+  }
+}
+
+.ci-char {
+  font-size: 14px;
+  font-weight: 700;
+  line-height: 1;
+}
+
+/* 飞出面板 */
+.flyout-panel {
+  position: absolute;
+  left: calc(100% + 4px);
+  top: 0;
+  min-width: 180px;
+  max-width: 240px;
+  max-height: 70vh;
+  overflow-y: auto;
+  background: var(--bg-elevated);
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-md);
+  box-shadow: var(--shadow-lg);
+  z-index: 500;
+  padding: 4px;
+  scrollbar-width: thin;
+  scrollbar-color: var(--border-subtle) transparent;
+
+  &::-webkit-scrollbar { width: 4px; }
+  &::-webkit-scrollbar-track { background: transparent; }
+  &::-webkit-scrollbar-thumb {
+    background: var(--border-subtle);
+    border-radius: 4px;
+  }
+}
+
+.flyout-header {
   font-size: 10px;
+  font-weight: 700;
   text-transform: uppercase;
   letter-spacing: 1.5px;
   color: var(--text-muted);
-  padding: 0 12px;
-  margin-bottom: 10px;
+  padding: 6px 10px 4px;
+  white-space: nowrap;
 }
 
-// 用户信息
-.sidebar-user {
+.flyout-item {
   display: flex;
   align-items: center;
-  gap: 12px;
-  padding: 16px;
+  justify-content: space-between;
+  padding: 7px 10px;
+  border-radius: var(--radius-sm);
+  cursor: pointer;
+  color: var(--text-secondary);
+  font-size: 13px;
+  font-weight: 500;
+  transition: background 0.12s, color 0.12s;
+  white-space: nowrap;
+
+  &:hover {
+    background: var(--sidebar-item-hover);
+    color: var(--text-primary);
+  }
+
+  &.active {
+    color: var(--accent);
+    font-weight: 600;
+  }
+}
+
+.fi-text {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.fi-arrow {
+  flex-shrink: 0;
+  transition: transform 0.2s;
+  color: var(--text-muted);
+  margin-left: 4px;
+}
+
+.flyout-item.open .fi-arrow {
+  transform: rotate(90deg);
+}
+
+/* L2 缩进子项 */
+.flyout-item--l2 {
+  padding-left: 22px;
+  font-size: 12px;
+  color: var(--text-muted);
+  font-weight: 400;
+  justify-content: flex-start;
+
+  &:hover {
+    color: var(--text-primary);
+  }
+
+  &.active {
+    color: var(--accent);
+    font-weight: 500;
+  }
+}
+
+/* —— 用户信息区域 —— */
+.sidebar-user {
   border-top: 1px solid var(--sidebar-border);
+  flex-shrink: 0;
+  overflow: visible;
+
+  // UserProfile CSS 变量覆盖——适配侧边栏主题
+  --navbar-text: var(--text-primary);
+  --navbar-text-hover: var(--accent);
+  --navbar-hover-bg: rgba(255, 255, 255, 0.06);
+
+  // 折叠时隐藏用户名和箭头
+  &.collapsed {
+    :deep(.profile-username),
+    :deep(.profile-chevron) {
+      display: none;
+    }
+
+    :deep(.profile-trigger) {
+      justify-content: center;
+      padding: 8px;
+    }
+
+    .sidebar-collapse-btn {
+      justify-content: center;
+      padding: 8px 0;
+    }
+
+    .user-profile-wrap {
+      padding: 4px;
+    }
+  }
+
+  :deep(.tool-row) {
+    display: flex;
+    align-items: center;
+    justify-content: space-around;
+    padding: 4px 0;
+  }
 }
 
-.user-avatar {
-  width: 32px;
-  height: 32px;
-  border-radius: var(--radius-sm);
-  background: linear-gradient(135deg, var(--violet-400), var(--rose-400));
+/* UserProfile 内边距包装层 */
+.user-profile-wrap {
+  padding: 6px 8px 8px;
+}
+
+/* 侧边栏整体折叠/展开按钮 */
+.sidebar-collapse-btn {
   display: flex;
   align-items: center;
-  justify-content: center;
-  font-weight: 600;
+  gap: 8px;
+  width: 100%;
+  padding: 8px 16px;
+  background: transparent;
+  border: none;
+  border-bottom: 1px solid var(--sidebar-border);
+  cursor: pointer;
+  color: var(--text-muted);
   font-size: 12px;
-  flex-shrink: 0;
-  color: white;
-}
+  font-family: inherit;
+  transition:
+    background 0.15s,
+    color 0.15s;
 
-.user-info {
-  flex: 1;
-  min-width: 0;
+  svg {
+    flex-shrink: 0;
+    transition: transform 0.25s;
+  }
 
-  h3 {
-    font-size: 12px;
-    font-weight: 600;
-    margin: 0;
-    white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
+  &.collapsed svg {
+    transform: rotate(180deg);
+  }
+
+  &:hover {
+    background: var(--navbar-hover-bg, rgba(255, 255, 255, 0.06));
     color: var(--text-primary);
   }
 
-  p {
-    font-size: 11px;
-    color: var(--text-muted);
-    margin: 0;
+  .collapse-label {
+    font-size: 12px;
     white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
   }
 }
-</style>
\ No newline at end of file
+</style>