NewLife/cube-front

feat(layout): 新增 TopMenu 顶部导航布局

- 新增 TopMenu 顶部导航布局(替换原 MainLayout 为默认布局)
- Topnav:顶部导航栏,支持主题切换图标+下拉菜单、mega 菜单
- IconRail:已从布局中移除(不再显示左侧图标轨道)
- Content:内容区 padding 缩小至 16px
- 面包屑子头部高度缩小至 40px,padding 缩小至 16px
- 布局使用 CSS 变量适配多主题
笑笑 authored at 2026-05-05 22:32:23
880d08c
Tree
1 Parent(s) 0cc74d4
Summary: 5 changed files with 913 additions and 1 deletions.
Modified +2 -1
Added +26 -0
Added +111 -0
Added +110 -0
Added +664 -0
Modified +2 -1
diff --git a/core/layouts/RootLayout.vue b/core/layouts/RootLayout.vue
index a89674b..d4ed85a 100644
--- a/core/layouts/RootLayout.vue
+++ b/core/layouts/RootLayout.vue
@@ -7,7 +7,8 @@ import { useUserStore } from '../stores/user';
 import { useMenuStore } from '../stores/menu';
 import { getUrlHashToken } from '../utils/token';
 import { useLayoutRequired } from '../composables/useProvideInject';
-import DefaultMainLayout from './MainLayout/index.vue';
+// import DefaultMainLayout from './MainLayout/index.vue';
+import DefaultMainLayout from './TopMenu/index.vue';
 
 const router = useRouter();
 const route = useRoute();
Added +26 -0
diff --git a/core/layouts/TopMenu/Content/index.vue b/core/layouts/TopMenu/Content/index.vue
new file mode 100644
index 0000000..e7abd8a
--- /dev/null
+++ b/core/layouts/TopMenu/Content/index.vue
@@ -0,0 +1,26 @@
+<script setup lang="ts">
+// 无需特殊逻辑
+</script>
+
+<template>
+  <div class="tm-content">
+    <slot></slot>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.tm-content {
+  height: 100%;
+  overflow: auto;
+  padding: 16px;
+
+  &::-webkit-scrollbar {
+    width: 6px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: var(--scrollbar-thumb, #c8d4c8);
+    border-radius: 3px;
+  }
+}
+</style>
Added +111 -0
diff --git a/core/layouts/TopMenu/IconRail/index.vue b/core/layouts/TopMenu/IconRail/index.vue
new file mode 100644
index 0000000..fe510e1
--- /dev/null
+++ b/core/layouts/TopMenu/IconRail/index.vue
@@ -0,0 +1,111 @@
+<script setup lang="ts">
+import { storeToRefs } from 'pinia';
+import { useMenuStore, type TreeMenuItem } from '@core/stores/menu';
+import { openMenuTab } from '@core/utils/menuTab';
+
+const menuStore = useMenuStore();
+const { treeMenus, topLevelActiveMenu } = storeToRefs(menuStore);
+
+const isActive = (menu: TreeMenuItem) => topLevelActiveMenu.value?.id === menu.id;
+
+const findFirstLeaf = (m: TreeMenuItem): TreeMenuItem => {
+  if (!m.children || m.children.length === 0) return m;
+  return findFirstLeaf(m.children[0]);
+};
+
+const handleClick = (menu: TreeMenuItem) => {
+  const target = findFirstLeaf(menu);
+  openMenuTab({ url: target.path, title: target.name });
+  menuStore.setActiveMenu(target);
+};
+</script>
+
+<template>
+  <aside class="icon-rail">
+    <template v-for="menu in treeMenus" :key="menu.id">
+      <button
+        class="ir-btn"
+        :class="{ on: isActive(menu) }"
+        :title="menu.title || menu.name"
+        @click="handleClick(menu)"
+      >
+        <span class="ir-tip">{{ menu.title || menu.name }}</span>
+        <span class="ir-letter">{{ (menu.title || menu.name || '?').charAt(0) }}</span>
+      </button>
+    </template>
+  </aside>
+</template>
+
+<style lang="scss" scoped>
+.icon-rail {
+  width: 52px;
+  background: #fff;
+  border-right: 1px solid #e0e6da;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 14px 0;
+  gap: 4px;
+  flex-shrink: 0;
+  overflow-y: auto;
+
+  &::-webkit-scrollbar {
+    width: 0;
+  }
+}
+
+.ir-btn {
+  width: 36px;
+  height: 36px;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #87a080;
+  cursor: pointer;
+  border: none;
+  background: transparent;
+  transition:
+    background 0.14s,
+    color 0.14s;
+  position: relative;
+  flex-shrink: 0;
+
+  &:hover {
+    background: #e8f5ee;
+    color: #1d7040;
+
+    .ir-tip {
+      opacity: 1;
+    }
+  }
+
+  &.on {
+    background: #e8f5ee;
+    color: #1d7040;
+  }
+}
+
+.ir-tip {
+  position: absolute;
+  left: 44px;
+  top: 50%;
+  transform: translateY(-50%);
+  background: #111;
+  color: #fff;
+  font-size: 11px;
+  padding: 3px 8px;
+  border-radius: 4px;
+  white-space: nowrap;
+  pointer-events: none;
+  opacity: 0;
+  transition: opacity 0.1s;
+  z-index: 300;
+}
+
+.ir-letter {
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 1;
+}
+</style>
Added +110 -0
diff --git a/core/layouts/TopMenu/index.vue b/core/layouts/TopMenu/index.vue
new file mode 100644
index 0000000..9241d04
--- /dev/null
+++ b/core/layouts/TopMenu/index.vue
@@ -0,0 +1,110 @@
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useMenuStore, type TreeMenuItem } from '@core/stores/menu';
+import Topnav from './Topnav/index.vue';
+import Content from './Content/index.vue';
+
+const menuStore = useMenuStore();
+const activeMenu = computed(() => menuStore.activeMenu);
+
+// 面包屑路径
+const breadcrumbPath = computed(() => {
+  if (!activeMenu.value) return [];
+  const path: Array<{ name: string; title?: string }> = [];
+  let current: TreeMenuItem | undefined = activeMenu.value;
+  while (current) {
+    path.unshift({ name: current.name, title: current.title });
+    current = current.parentMenu;
+  }
+  return path;
+});
+</script>
+
+<template>
+  <div class="tm-layout">
+    <!-- 顶部导航 -->
+    <Topnav />
+
+    <!-- 主体区域 -->
+    <div class="body-wrap">
+      <!-- 主内容 -->
+      <div class="main-wrap">
+        <!-- 面包屑子头部 -->
+        <div class="sub-hd">
+          <nav class="bc">
+            <template v-for="(item, idx) in breadcrumbPath" :key="idx">
+              <span v-if="idx < breadcrumbPath.length - 1" class="bc-s">
+                {{ item.title || item.name }}
+              </span>
+              <span v-if="idx < breadcrumbPath.length - 1" class="bc-sep">›</span>
+              <span v-else class="bc-cur">{{ item.title || item.name }}</span>
+            </template>
+          </nav>
+        </div>
+
+        <!-- 内容区 -->
+        <Content>
+          <slot></slot>
+        </Content>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+$tn-h: 60px;
+
+.tm-layout {
+  width: 100%;
+  height: 100vh;
+  display: flex;
+  flex-direction: column;
+  background: var(--bg, #f4f5f1);
+  overflow: hidden;
+}
+
+.body-wrap {
+  display: flex;
+  height: calc(100vh - #{$tn-h});
+  overflow: hidden;
+}
+
+.main-wrap {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.sub-hd {
+  background: var(--card, #fff);
+  border-bottom: 1px solid var(--bd, #e0e6da);
+  padding: 0 16px;
+  display: flex;
+  align-items: center;
+  height: 40px;
+  gap: 6px;
+  flex-shrink: 0;
+}
+
+.bc {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 13px;
+}
+
+.bc-s {
+  color: #87a080;
+}
+
+.bc-cur {
+  color: #192418;
+  font-weight: 500;
+}
+
+.bc-sep {
+  color: #87a080;
+  font-size: 11px;
+}
+</style>
Added +664 -0
diff --git a/core/layouts/TopMenu/Topnav/index.vue b/core/layouts/TopMenu/Topnav/index.vue
new file mode 100644
index 0000000..5316c07
--- /dev/null
+++ b/core/layouts/TopMenu/Topnav/index.vue
@@ -0,0 +1,664 @@
+<script setup lang="ts">
+import { ref, computed } from 'vue';
+import { storeToRefs } from 'pinia';
+import { useMenuStore, type TreeMenuItem } from '@core/stores/menu';
+import { useUserStore } from '@core/stores/user';
+import { getConfig } from '@core/configure';
+import { openMenuTab } from '@core/utils/menuTab';
+import { useTheme, THEMES } from '@core/composables/useTheme';
+
+const menuStore = useMenuStore();
+const userStore = useUserStore();
+const { treeMenus, topLevelActiveMenu } = storeToRefs(menuStore);
+
+const title = getConfig().base.title;
+const openMenuId = ref<string | null>(null);
+
+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 isMenuActive = (menu: TreeMenuItem) => topLevelActiveMenu.value?.id === menu.id;
+const hasChildren = (menu: TreeMenuItem) =>
+  Array.isArray(menu.children) && menu.children.length > 0;
+
+const closeMenu = () => {
+  openMenuId.value = null;
+};
+
+const handleNavItemClick = (menu: TreeMenuItem) => {
+  if (!hasChildren(menu)) {
+    openMenuTab({ url: menu.path, title: menu.name });
+    menuStore.setActiveMenu(menu);
+    closeMenu();
+  } else {
+    openMenuId.value = openMenuId.value === menu.id ? null : menu.id;
+  }
+};
+
+const handleSubItemClick = (menu: TreeMenuItem) => {
+  openMenuTab({ url: menu.path, title: menu.name });
+  menuStore.setActiveMenu(menu);
+  closeMenu();
+};
+
+const handleMouseEnter = (menu: TreeMenuItem) => {
+  if (hasChildren(menu)) {
+    openMenuId.value = menu.id;
+  }
+};
+
+const handleMouseLeave = () => {
+  openMenuId.value = null;
+};
+
+const handleMegaMouseEnter = () => {
+  // 保持在菜单展开状态
+};
+
+const handleMegaMouseLeave = () => {
+  openMenuId.value = null;
+};
+
+const handleLogout = () => {
+  userStore.logout();
+};
+
+const { currentTheme, setTheme } = useTheme();
+const themeOpen = ref(false);
+
+// 点击外部关闭主题下拉
+const handleOutsideClick = () => {
+  themeOpen.value = false;
+};
+</script>
+
+<template>
+  <header class="topnav" @click="handleOutsideClick">
+    <!-- Logo -->
+    <div class="tn-logo">
+      <div class="tn-mark">
+        <svg viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg">
+          <path
+            d="M2 1C1.45 1 1 1.45 1 2v5h3V4h3V1H2zm6 0v3h3v3h3V2c0-.55-.45-1-1-1H8zM1 8v6c0 .55.45 1 1 1h6v-3H5V8H1zm11 3v3H9v3h5c.55 0 1-.45 1-1v-5h-3z"
+          />
+        </svg>
+      </div>
+      <div class="tn-name">{{ title }}</div>
+    </div>
+
+    <!-- 导航菜单 -->
+    <nav class="tn-nav">
+      <div
+        v-for="menu in treeMenus"
+        :key="menu.id"
+        class="tn-item"
+        :class="{ open: openMenuId === menu.id }"
+        @mouseenter="handleMouseEnter(menu)"
+        @mouseleave="handleMouseLeave"
+      >
+        <!-- 导航链接包装器 -->
+        <div class="tn-link-wrap">
+          <span
+            class="tn-link"
+            :class="{ active: isMenuActive(menu) && openMenuId !== menu.id }"
+            @click="handleNavItemClick(menu)"
+          >
+            {{ menu.title || menu.name }}
+            <svg
+              v-if="hasChildren(menu)"
+              class="chv"
+              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>
+          </span>
+
+          <!-- 悬浮桥梁 - 连接导航和下拉菜单 -->
+          <div
+            v-if="hasChildren(menu) && openMenuId === menu.id"
+            class="mega-bridge"
+            @mouseenter="handleMegaMouseEnter"
+            @mouseleave="handleMegaMouseLeave"
+          ></div>
+
+          <!-- 巨型下拉菜单 -->
+          <div
+            v-show="hasChildren(menu) && openMenuId === menu.id"
+            class="mega"
+            @mouseenter="handleMegaMouseEnter"
+            @mouseleave="handleMegaMouseLeave"
+          >
+            <div class="mega-cols">
+              <template v-for="(col, colIdx) in menu.children" :key="col.id">
+                <div v-if="colIdx > 0" class="mega-col-sep"></div>
+                <div class="mega-col">
+                  <div class="mc-title">{{ col.title || col.name }}</div>
+                  <template v-if="col.children && col.children.length > 0">
+                    <div
+                      v-for="item in col.children"
+                      :key="item.id"
+                      class="mc-item"
+                      @click="handleSubItemClick(item)"
+                    >
+                      {{ item.title || item.name }}
+                    </div>
+                  </template>
+                  <div v-else class="mc-item" @click="handleSubItemClick(col)">
+                    {{ col.title || col.name }}
+                  </div>
+                </div>
+              </template>
+            </div>
+          </div>
+        </div>
+      </div>
+    </nav>
+
+    <!-- 右侧操作区 -->
+    <div class="tn-acts">
+      <!-- 主题切换 -->
+      <div class="tn-theme" @click.stop>
+        <button
+          class="tn-btn tn-theme-icon-btn"
+          :title="'当前主题:' + THEMES.find((t) => t.id === currentTheme)?.label"
+          @click="themeOpen = !themeOpen"
+        >
+          <svg
+            width="15"
+            height="15"
+            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>
+        </button>
+        <div v-if="themeOpen" class="tn-theme-dropdown">
+          <div
+            v-for="theme in THEMES"
+            :key="theme.id"
+            class="tn-theme-item"
+            :class="{ active: currentTheme === theme.id }"
+            @click="
+              setTheme(theme.id);
+              themeOpen = false;
+            "
+          >
+            <span class="tn-ti-icon">{{ theme.icon }}</span>
+            <span class="tn-ti-label">{{ theme.label }}</span>
+            <svg
+              v-if="currentTheme === theme.id"
+              class="tn-ti-check"
+              width="13"
+              height="13"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="2.5"
+            >
+              <polyline points="20 6 9 17 4 12" />
+            </svg>
+          </div>
+        </div>
+      </div>
+
+      <div class="tn-dvd"></div>
+      <button class="tn-btn" title="全局搜索">
+        <svg
+          width="14"
+          height="14"
+          viewBox="0 0 24 24"
+          fill="none"
+          stroke="currentColor"
+          stroke-width="2"
+        >
+          <circle cx="11" cy="11" r="8" />
+          <path d="m21 21-4.35-4.35" />
+        </svg>
+      </button>
+      <button class="tn-btn" title="消息通知">
+        <svg
+          width="14"
+          height="14"
+          viewBox="0 0 24 24"
+          fill="none"
+          stroke="currentColor"
+          stroke-width="2"
+        >
+          <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
+          <path d="M13.73 21a2 2 0 0 1-3.46 0" />
+        </svg>
+      </button>
+      <div class="tn-dvd"></div>
+      <div class="tn-user">
+        <div class="tn-av">{{ userInitial }}</div>
+        <span class="tn-un">{{ userName }}</span>
+      </div>
+      <button class="tn-btn tn-logout" title="退出登录" @click="handleLogout">
+        <svg
+          width="14"
+          height="14"
+          viewBox="0 0 24 24"
+          fill="none"
+          stroke="currentColor"
+          stroke-width="2"
+        >
+          <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
+          <polyline points="16 17 21 12 16 7" />
+          <line x1="21" y1="12" x2="9" y2="12" />
+        </svg>
+      </button>
+    </div>
+  </header>
+
+  <!-- 遮罩层 -->
+  <div v-if="openMenuId" class="mega-overlay" @click="closeMenu"></div>
+</template>
+
+<style lang="scss" scoped>
+$tn-h: 60px;
+
+.topnav {
+  height: $tn-h;
+  background: var(--tn, #1a3328);
+  display: flex;
+  align-items: center;
+  padding: 0 18px 0 14px;
+  position: relative;
+  z-index: 100;
+  box-shadow: 0 2px 20px rgba(0, 0, 0, 0.22);
+}
+
+// Logo
+.tn-logo {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding-right: 18px;
+  border-right: 1px solid var(--tn-b, #224538);
+  margin-right: 6px;
+  flex-shrink: 0;
+}
+
+.tn-mark {
+  width: 32px;
+  height: 32px;
+  background: var(--tn-ac, #4ec685);
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+
+  svg {
+    width: 17px;
+    height: 17px;
+    fill: var(--tn, #1a3328);
+  }
+}
+
+.tn-name {
+  font-size: 17px;
+  font-weight: 700;
+  color: #fff;
+  letter-spacing: 0.01em;
+  white-space: nowrap;
+}
+
+// 导航菜单
+.tn-nav {
+  display: flex;
+  align-items: stretch;
+  flex: 1;
+  height: 100%;
+  overflow: hidden;
+}
+
+.tn-item {
+  position: relative;
+  display: flex;
+  align-items: center;
+  height: 100%;
+
+  &:hover {
+    .tn-link {
+      color: var(--tn-ac, #4ec685);
+      background: rgba(255, 255, 255, 0.06);
+    }
+  }
+
+  &.open {
+    .tn-link {
+      color: var(--tn-ac, #4ec685);
+      background: rgba(255, 255, 255, 0.08);
+    }
+  }
+}
+
+.tn-link {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  padding: 0 13px;
+  height: 100%;
+  cursor: pointer;
+  user-select: none;
+  color: var(--tn-t, #74b898);
+  font-size: 13.5px;
+  font-weight: 500;
+  white-space: nowrap;
+  border-bottom: 2px solid transparent;
+  transition:
+    color 0.15s,
+    background 0.15s;
+
+  &:hover {
+    color: var(--tn-ac, #4ec685);
+    background: rgba(255, 255, 255, 0.06);
+  }
+
+  &.active {
+    color: var(--tn-ac, #4ec685);
+    border-bottom-color: var(--tn-ac, #4ec685);
+  }
+}
+
+.tn-item.open .tn-link {
+  color: var(--tn-ac, #4ec685);
+  background: rgba(255, 255, 255, 0.08);
+}
+
+.chv {
+  transition: transform 0.2s;
+  opacity: 0.6;
+}
+
+.tn-item.open .chv {
+  transform: rotate(180deg);
+}
+
+// 巨型下拉菜单
+.mega {
+  position: fixed;
+  left: 0;
+  right: 0;
+  top: $tn-h;
+  background: #fff;
+  border-top: 2px solid var(--tn-ac, #4ec685);
+  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12);
+  padding: 22px 28px 24px;
+  display: flex;
+  z-index: 200;
+  opacity: 0;
+  transform: translateY(-8px);
+  pointer-events: none;
+  transition:
+    opacity 0.2s ease,
+    transform 0.2s ease;
+}
+
+.tn-item.open .mega,
+.tn-item:hover .mega {
+  opacity: 1;
+  transform: translateY(0);
+  pointer-events: auto;
+}
+
+// 导航链接包装器
+.tn-link-wrap {
+  position: relative;
+  height: 100%;
+  display: flex;
+  align-items: center;
+}
+
+// 悬浮桥梁 - 连接导航和下拉菜单
+.mega-bridge {
+  position: absolute;
+  left: 0;
+  right: 0;
+  top: 100%;
+  height: 20px;
+  background: transparent;
+  z-index: 199;
+  pointer-events: auto;
+}
+
+.mega-cols {
+  display: flex;
+  flex-wrap: wrap;
+  flex: 1;
+  gap: 0;
+}
+
+.mega-col {
+  min-width: 150px;
+  padding: 0 24px 0 0;
+  margin: 0 0 14px;
+
+  &:first-child {
+    padding-left: 0;
+  }
+}
+
+.mega-col-sep {
+  width: 1px;
+  background: #e0e6da;
+  margin: 0 10px 14px;
+  flex-shrink: 0;
+  align-self: stretch;
+}
+
+.mc-title {
+  font-size: 10.5px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.1em;
+  color: #87a080;
+  margin-bottom: 9px;
+  padding-bottom: 7px;
+  border-bottom: 1px solid #e0e6da;
+}
+
+.mc-item {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  padding: 5px 8px;
+  border-radius: 5px;
+  color: #4a604a;
+  font-size: 13px;
+  cursor: pointer;
+  transition:
+    background 0.12s,
+    color 0.12s;
+  white-space: nowrap;
+
+  &::before {
+    content: '';
+    width: 4px;
+    height: 4px;
+    border-radius: 50%;
+    background: #e0e6da;
+    flex-shrink: 0;
+    transition: background 0.12s;
+  }
+
+  &:hover {
+    background: #e8f5ee;
+    color: #1d7040;
+
+    &::before {
+      background: #1d7040;
+    }
+  }
+}
+
+// 遮罩
+.mega-overlay {
+  position: fixed;
+  inset: 0;
+  top: $tn-h;
+  background: rgba(0, 0, 0, 0.06);
+  z-index: 150;
+  pointer-events: none;
+}
+
+// 右侧操作区
+.tn-acts {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-left: auto;
+  flex-shrink: 0;
+}
+
+.tn-btn {
+  width: 34px;
+  height: 34px;
+  border-radius: 8px;
+  border: none;
+  background: rgba(255, 255, 255, 0.06);
+  color: var(--tn-t, #74b898);
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition:
+    background 0.15s,
+    color 0.15s;
+
+  &:hover {
+    background: rgba(255, 255, 255, 0.12);
+    color: var(--tn-ac, #4ec685);
+  }
+}
+
+.tn-logout:hover {
+  background: rgba(220, 38, 38, 0.15) !important;
+  color: #f87171 !important;
+}
+
+.tn-dvd {
+  width: 1px;
+  height: 20px;
+  background: var(--tn-b, #224538);
+  margin: 0 3px;
+}
+
+// 主题切换
+.tn-theme {
+  position: relative;
+}
+
+.tn-theme-icon-btn {
+  color: var(--tn-t, #74b898) !important;
+
+  &:hover {
+    background: rgba(255, 255, 255, 0.12) !important;
+    color: var(--tn-ac, #4ec685) !important;
+  }
+}
+
+.tn-theme-dropdown {
+  position: absolute;
+  right: 0;
+  top: calc(100% + 8px);
+  background: #ffffff;
+  border: 1px solid var(--bd, #e0e6da);
+  border-radius: 10px;
+  box-shadow: 0 8px 24px rgba(25, 36, 24, 0.12);
+  min-width: 140px;
+  overflow: hidden;
+  z-index: 300;
+  padding: 4px;
+}
+
+.tn-theme-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  border-radius: 7px;
+  cursor: pointer;
+  color: var(--t2, #4a604a);
+  font-size: 13px;
+  font-weight: 500;
+  transition:
+    background 0.12s,
+    color 0.12s;
+
+  &:hover {
+    background: var(--ac-l, #e8f5ee);
+    color: var(--ac, #1d7040);
+  }
+
+  &.active {
+    color: var(--ac, #1d7040);
+    font-weight: 600;
+  }
+}
+
+.tn-ti-icon {
+  font-size: 14px;
+  flex-shrink: 0;
+}
+
+.tn-ti-label {
+  flex: 1;
+}
+
+.tn-ti-check {
+  color: var(--ac, #1d7040);
+  flex-shrink: 0;
+}
+
+.tn-user {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 10px 4px 8px;
+  border-radius: 8px;
+  cursor: pointer;
+  color: var(--tn-t, #74b898);
+  transition:
+    background 0.15s,
+    color 0.15s;
+
+  &:hover {
+    background: rgba(255, 255, 255, 0.07);
+    color: var(--tn-ac, #4ec685);
+  }
+}
+
+.tn-av {
+  width: 28px;
+  height: 28px;
+  border-radius: 50%;
+  background: linear-gradient(135deg, var(--tn-ac, #4ec685) 0%, #a8e6c8 100%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 11px;
+  font-weight: 700;
+  color: var(--tn, #1a3328);
+  flex-shrink: 0;
+}
+
+.tn-un {
+  font-size: 13px;
+  font-weight: 500;
+}
+</style>