feat(layout): 实现布局切换功能,新增主题与布局切换组件 Co-authored-by: Copilot <copilot@github.com>笑笑 authored at 2026-05-05 23:32:00
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>
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>
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>
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,
+ };
+}
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>
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;
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;
});