NewLife/cube-front

feat(layout): 实现布局切换功能,新增主题与布局切换组件

Co-authored-by: Copilot <copilot@github.com>
笑笑 authored at 2026-05-05 23:32:00
2ed462e
Tree
1 Parent(s) 74dc82b
Summary: 7 changed files with 357 additions and 119 deletions.
Added +30 -0
Added +191 -0
Added +30 -0
Added +62 -0
Modified +7 -7
Modified +24 -109
Modified +13 -3
Added +30 -0
diff --git a/core/components/LayoutSwitcher.vue b/core/components/LayoutSwitcher.vue
new file mode 100644
index 0000000..b914896
--- /dev/null
+++ b/core/components/LayoutSwitcher.vue
@@ -0,0 +1,30 @@
+<script setup lang="ts">
+import { useLayout } from '../composables/useLayout';
+import SwitcherDropdown from './SwitcherDropdown.vue';
+
+const { layouts, currentLayoutId, setLayout } = useLayout();
+</script>
+
+<template>
+  <!-- 只有注册了多个布局时才显示 -->
+  <SwitcherDropdown
+    v-if="layouts.length > 1"
+    v-model="currentLayoutId"
+    :options="layouts"
+    title="切换布局"
+    @update:model-value="setLayout"
+  >
+    <!-- 默认图标:布局/网格 -->
+    <template #icon>
+      <slot name="icon">
+        <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
+          <path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" />
+        </svg>
+      </slot>
+    </template>
+    <!-- 透传 option 插槽 -->
+    <template v-if="$slots['option']" #option="slotProps">
+      <slot name="option" v-bind="slotProps" />
+    </template>
+  </SwitcherDropdown>
+</template>
Added +191 -0
diff --git a/core/components/SwitcherDropdown.vue b/core/components/SwitcherDropdown.vue
new file mode 100644
index 0000000..94238f0
--- /dev/null
+++ b/core/components/SwitcherDropdown.vue
@@ -0,0 +1,191 @@
+<script setup lang="ts" generic="T extends { id: string; label: string; icon: string }">
+import { ref } from 'vue';
+
+/**
+ * SwitcherDropdown — 通用图标+下拉切换组件
+ *
+ * 用途:主题切换、布局切换,或任何需要"小图标 → 下拉选单"形式的场景。
+ *
+ * Props:
+ *   options     — 选项列表,每项需要 { id, label, icon }
+ *   modelValue  — 当前选中项的 id(支持 v-model)
+ *   title       — 按钮 tooltip 文字(可选)
+ *
+ * Slots:
+ *   #icon          — 自定义触发按钮内的图标(不覆盖则使用默认 SVG)
+ *   #option        — 自定义下拉选项内容(slot props: { option, active })
+ *
+ * Emits:
+ *   update:modelValue — 切换时触发,携带新 id
+ */
+
+const props = withDefaults(
+  defineProps<{
+    options: T[];
+    modelValue: string;
+    title?: string;
+  }>(),
+  { title: '' },
+);
+
+const emit = defineEmits<{
+  'update:modelValue': [id: string];
+}>();
+
+const open = ref(false);
+
+function select(id: string) {
+  emit('update:modelValue', id);
+  open.value = false;
+}
+
+function toggle() {
+  open.value = !open.value;
+}
+
+function close() {
+  open.value = false;
+}
+
+// 暴露给父组件
+defineExpose({ close });
+</script>
+
+<template>
+  <!-- @click.stop 防止冒泡到外层关闭逻辑 -->
+  <div class="sw-drop" @click.stop>
+    <!-- 触发按钮 -->
+    <button class="tn-btn sw-trigger" :title="title" @click="toggle">
+      <slot name="icon">
+        <!-- 默认:调色板图标 -->
+        <svg
+          width="15"
+          height="15"
+          viewBox="0 0 24 24"
+          fill="none"
+          stroke="currentColor"
+          stroke-width="2"
+        >
+          <circle cx="12" cy="12" r="10" />
+          <circle cx="12" cy="12" r="4" />
+        </svg>
+      </slot>
+    </button>
+
+    <!-- 下拉菜单 -->
+    <Transition name="sw-fade">
+      <div v-if="open" class="sw-menu">
+        <div
+          v-for="opt in options"
+          :key="opt.id"
+          class="sw-item"
+          :class="{ active: modelValue === opt.id }"
+          @click="select(opt.id)"
+        >
+          <!-- 支持通过 #option 插槽自定义每行内容 -->
+          <slot name="option" :option="opt" :active="modelValue === opt.id">
+            <span class="sw-i-icon">{{ opt.icon }}</span>
+            <span class="sw-i-label">{{ opt.label }}</span>
+            <svg
+              v-if="modelValue === opt.id"
+              class="sw-i-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>
+          </slot>
+        </div>
+      </div>
+    </Transition>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.sw-drop {
+  position: relative;
+}
+
+/* 触发按钮——复用 .tn-btn 样式,附加颜色覆盖 */
+.sw-trigger {
+  color: var(--tn-t, #74b898) !important;
+
+  &:hover {
+    background: rgba(255, 255, 255, 0.12) !important;
+    color: var(--tn-ac, #4ec685) !important;
+  }
+}
+
+/* 下拉菜单面板 */
+.sw-menu {
+  position: absolute;
+  right: 0;
+  top: calc(100% + 8px);
+  background: var(--card, #ffffff);
+  border: 1px solid var(--bd, #e0e6da);
+  border-radius: 10px;
+  box-shadow: var(--shadow-lg, 0 8px 24px rgba(25, 36, 24, 0.12));
+  min-width: 148px;
+  overflow: hidden;
+  z-index: 300;
+  padding: 4px;
+}
+
+/* 选项行 */
+.sw-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;
+  }
+}
+
+.sw-i-icon {
+  font-size: 14px;
+  flex-shrink: 0;
+}
+
+.sw-i-label {
+  flex: 1;
+}
+
+.sw-i-check {
+  color: var(--ac, #1d7040);
+  flex-shrink: 0;
+}
+
+/* 下拉动画 */
+.sw-fade-enter-active,
+.sw-fade-leave-active {
+  transition:
+    opacity 0.12s,
+    transform 0.12s;
+}
+
+.sw-fade-enter-from,
+.sw-fade-leave-to {
+  opacity: 0;
+  transform: translateY(-4px);
+}
+</style>
Added +30 -0
diff --git a/core/components/ThemeSwitcher.vue b/core/components/ThemeSwitcher.vue
new file mode 100644
index 0000000..1f25a1e
--- /dev/null
+++ b/core/components/ThemeSwitcher.vue
@@ -0,0 +1,30 @@
+<script setup lang="ts">
+import { useTheme, THEMES } from '../composables/useTheme';
+import SwitcherDropdown from './SwitcherDropdown.vue';
+
+const { currentTheme, setTheme } = useTheme();
+</script>
+
+<template>
+  <SwitcherDropdown
+    v-model="currentTheme"
+    :options="THEMES"
+    title="切换主题"
+    @update:model-value="(id: string) => setTheme(id as any)"
+  >
+    <!-- 默认图标:太阳/调色板 -->
+    <template #icon>
+      <slot name="icon">
+        <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
+          <path
+            d="M12 18a6 6 0 1 1 0-12 6 6 0 0 1 0 12zm0-2a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM11 1h2v3h-2V1zm0 19h2v3h-2v-3zM3.515 4.929l1.414-1.414L7.05 5.636 5.636 7.05 3.515 4.93zM16.95 18.364l1.414-1.414 2.121 2.121-1.414 1.414-2.121-2.121zm2.121-14.85l1.414 1.415-2.121 2.121-1.414-1.414 2.121-2.121zM5.636 16.95l1.414 1.414-2.121 2.121-1.414-1.414 2.121-2.121zM23 11v2h-3v-2h3zM4 11v2H1v-2h3z"
+          />
+        </svg>
+      </slot>
+    </template>
+    <!-- 透传 option 插槽 -->
+    <template v-if="$slots['option']" #option="slotProps">
+      <slot name="option" v-bind="slotProps" />
+    </template>
+  </SwitcherDropdown>
+</template>
Added +62 -0
diff --git a/core/composables/useLayout.ts b/core/composables/useLayout.ts
new file mode 100644
index 0000000..9b68b7e
--- /dev/null
+++ b/core/composables/useLayout.ts
@@ -0,0 +1,62 @@
+import { ref, computed, shallowRef, type Component } from 'vue';
+
+/**
+ * 布局选项描述
+ */
+export interface LayoutOption {
+  id: string;
+  label: string;
+  icon: string; // emoji 或 SVG 标识
+  description?: string;
+  component: Component;
+}
+
+const STORAGE_KEY = 'cube-layout';
+
+// 模块级响应式状态(全局单例)
+const registeredLayouts = ref<LayoutOption[]>([]);
+const currentLayoutId = ref<string>('');
+
+/**
+ * 注册一个布局
+ * 通常在 main.ts / initApp 回调中调用
+ * 第一个注册的布局自动成为默认布局
+ */
+export function registerLayout(option: LayoutOption): void {
+  if (registeredLayouts.value.some((l) => l.id === option.id)) return;
+
+  registeredLayouts.value.push(option);
+
+  // 第一次注册时,从 localStorage 恢复或设为第一个
+  if (!currentLayoutId.value) {
+    const stored = localStorage.getItem(STORAGE_KEY);
+    const valid = stored && registeredLayouts.value.some((l) => l.id === stored);
+    currentLayoutId.value = valid ? stored! : option.id;
+  }
+}
+
+/**
+ * 布局组合式函数
+ * 在 Topnav / RootLayout 等组件中使用
+ */
+export function useLayout() {
+  const currentLayout = computed<LayoutOption | undefined>(() =>
+    registeredLayouts.value.find((l) => l.id === currentLayoutId.value),
+  );
+
+  const currentComponent = computed<Component | undefined>(() => currentLayout.value?.component);
+
+  function setLayout(id: string): void {
+    if (!registeredLayouts.value.some((l) => l.id === id)) return;
+    currentLayoutId.value = id;
+    localStorage.setItem(STORAGE_KEY, id);
+  }
+
+  return {
+    layouts: registeredLayouts,
+    currentLayoutId,
+    currentLayout,
+    currentComponent,
+    setLayout,
+  };
+}
Modified +7 -7
diff --git a/core/layouts/RootLayout.vue b/core/layouts/RootLayout.vue
index d4ed85a..97cdc19 100644
--- a/core/layouts/RootLayout.vue
+++ b/core/layouts/RootLayout.vue
@@ -6,9 +6,8 @@ import { useRouter, useRoute } from 'vue-router';
 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 './TopMenu/index.vue';
+import { useLayout } from '../composables/useLayout';
+import TopMenuLayout from './TopMenu/index.vue'; // 兜底默认布局
 
 const router = useRouter();
 const route = useRoute();
@@ -16,8 +15,9 @@ const userStore = useUserStore();
 const menuStore = useMenuStore();
 console.log('routes', router.getRoutes());
 
-// 通过useLayoutRequired获取MainLayout组件,提供默认值作为后备
-const MainLayout = useLayoutRequired(DefaultMainLayout);
+// 通过 useLayout 获取当前注册的布局组件,无注册时回退到 TopMenuLayout
+const { currentComponent } = useLayout();
+const MainLayout = computed(() => currentComponent.value ?? TopMenuLayout);
 
 // 使用计算属性获取响应式的 meta 对象
 const meta = computed(() => route.meta);
@@ -75,7 +75,7 @@ onMounted(async () => {
 
   <!-- 布局 -->
   <template v-else>
-    <MainLayout>
+    <component :is="MainLayout">
       <!-- 组件缓存 -->
       <KeepAlive v-if="meta.keepAlive">
         <Transition
@@ -97,6 +97,6 @@ onMounted(async () => {
 
       <!-- 无缓存无过渡 -->
       <slot v-else />
-    </MainLayout>
+    </component>
   </template>
 </template>
Modified +24 -109
diff --git a/core/layouts/TopMenu/Topnav/index.vue b/core/layouts/TopMenu/Topnav/index.vue
index 5316c07..f03541a 100644
--- a/core/layouts/TopMenu/Topnav/index.vue
+++ b/core/layouts/TopMenu/Topnav/index.vue
@@ -5,7 +5,8 @@ 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';
+import ThemeSwitcher from '@core/components/ThemeSwitcher.vue';
+import LayoutSwitcher from '@core/components/LayoutSwitcher.vue';
 
 const menuStore = useMenuStore();
 const userStore = useUserStore();
@@ -66,17 +67,11 @@ const handleLogout = () => {
   userStore.logout();
 };
 
-const { currentTheme, setTheme } = useTheme();
-const themeOpen = ref(false);
 
-// 点击外部关闭主题下拉
-const handleOutsideClick = () => {
-  themeOpen.value = false;
-};
 </script>
 
 <template>
-  <header class="topnav" @click="handleOutsideClick">
+  <header class="topnav">
     <!-- Logo -->
     <div class="tn-logo">
       <div class="tn-mark">
@@ -165,12 +160,8 @@ const handleOutsideClick = () => {
     <!-- 右侧操作区 -->
     <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"
-        >
+      <ThemeSwitcher>
+        <template #icon>
           <svg
             width="15"
             height="15"
@@ -184,35 +175,26 @@ const handleOutsideClick = () => {
               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;
-            "
+        </template>
+      </ThemeSwitcher>
+
+      <!-- 布局切换(注册了多个布局时自动显示) -->
+      <LayoutSwitcher>
+        <template #icon>
+          <svg
+            width="15"
+            height="15"
+            viewBox="0 0 24 24"
+            fill="none"
+            stroke="currentColor"
+            stroke-width="2"
           >
-            <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>
+            <rect x="3" y="3" width="18" height="4" rx="1" />
+            <rect x="3" y="10" width="7" height="11" rx="1" />
+            <rect x="13" y="10" width="8" height="11" rx="1" />
+          </svg>
+        </template>
+      </LayoutSwitcher>
 
       <div class="tn-dvd"></div>
       <button class="tn-btn" title="全局搜索">
@@ -558,73 +540,6 @@ $tn-h: 60px;
   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;
Modified +13 -3
diff --git a/core/main.ts b/core/main.ts
index ba089b0..b9ea2df 100644
--- a/core/main.ts
+++ b/core/main.ts
@@ -1,7 +1,17 @@
 import { initApp } from './initApp';
 import TopMenuLayout from './layouts/TopMenu/index.vue';
-import { LayoutKey } from './composables/useProvideInject';
+import { registerLayout } from './composables/useLayout';
 
-initApp((app, { provide }) => {
-  provide(app, LayoutKey, TopMenuLayout, { override: true });
+// 注册内置布局(可在应用层继续 registerLayout 添加更多)
+registerLayout({
+  id: 'top-menu',
+  label: '顶部菜单',
+  icon: '⊟',
+  description: '顶部导航栏 + 内容区布局',
+  component: TopMenuLayout,
+});
+
+initApp((app) => {
+  // 应用初始化回调,可在此注册更多布局或进行其他配置
+  void app;
 });