refactor(projects)!: refactor global menu & support reversed-horizontal-mix-menu. close #365

This commit is contained in:
Soybean 2024-07-22 00:01:52 +08:00
parent 00f41dd25e
commit 087e532613
24 changed files with 588 additions and 310 deletions

View File

@ -1,5 +1,9 @@
import { transformRecordToOption } from '@/utils/common'; import { transformRecordToOption } from '@/utils/common';
export const GLOBAL_HEADER_MENU_ID = '__GLOBAL_HEADER_MENU__';
export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__';
export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = { export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = {
light: 'theme.themeSchema.light', light: 'theme.themeSchema.light',
dark: 'theme.themeSchema.dark', dark: 'theme.themeSchema.dark',

View File

@ -30,17 +30,30 @@ export function useRouterPush(inSetup = true) {
name: key name: key
}; };
if (query) { if (Object.keys(query || {}).length) {
routeLocation.query = query; routeLocation.query = query;
} }
if (params) { if (Object.keys(params || {}).length) {
routeLocation.params = params; routeLocation.params = params;
} }
return routerPush(routeLocation); return routerPush(routeLocation);
} }
function routerPushByKeyWithMetaQuery(key: RouteKey) {
const allRoutes = router.getRoutes();
const meta = allRoutes.find(item => item.name === key)?.meta || null;
const query: Record<string, string> = {};
meta?.query?.forEach(item => {
query[item.key] = item.value;
});
return routerPushByKey(key, { query });
}
async function toHome() { async function toHome() {
return routerPushByKey('root'); return routerPushByKey('root');
} }
@ -95,6 +108,7 @@ export function useRouterPush(inSetup = true) {
routerPush, routerPush,
routerBack, routerBack,
routerPushByKey, routerPushByKey,
routerPushByKeyWithMetaQuery,
toLogin, toLogin,
toggleLoginModule, toggleLoginModule,
redirectFromLogin redirectFromLogin

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, defineAsyncComponent } from 'vue';
import { AdminLayout, LAYOUT_SCROLL_EL_ID } from '@sa/materials'; import { AdminLayout, LAYOUT_SCROLL_EL_ID } from '@sa/materials';
import type { LayoutMode } from '@sa/materials'; import type { LayoutMode } from '@sa/materials';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
@ -18,7 +18,9 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const { menus } = setupMixMenuContext(); const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = setupMixMenuContext();
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'));
const layoutMode = computed(() => { const layoutMode = computed(() => {
const vertical: LayoutMode = 'vertical'; const vertical: LayoutMode = 'vertical';
@ -26,30 +28,34 @@ const layoutMode = computed(() => {
return themeStore.layout.mode.includes(vertical) ? vertical : horizontal; return themeStore.layout.mode.includes(vertical) ? vertical : horizontal;
}); });
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = { const headerProps = computed(() => {
vertical: { const { mode, reverseHorizontalMix } = themeStore.layout;
showLogo: false,
showMenu: false,
showMenuToggler: true
},
'vertical-mix': {
showLogo: false,
showMenu: false,
showMenuToggler: false
},
horizontal: {
showLogo: true,
showMenu: true,
showMenuToggler: false
},
'horizontal-mix': {
showLogo: true,
showMenu: true,
showMenuToggler: false
}
};
const headerProps = computed(() => headerPropsConfig[themeStore.layout.mode]); const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
vertical: {
showLogo: false,
showMenu: false,
showMenuToggler: true
},
'vertical-mix': {
showLogo: false,
showMenu: false,
showMenuToggler: false
},
horizontal: {
showLogo: true,
showMenu: true,
showMenuToggler: false
},
'horizontal-mix': {
showLogo: true,
showMenu: true,
showMenuToggler: reverseHorizontalMix && isActiveFirstLevelMenuHasChildren.value
}
};
return headerPropsConfig[mode];
});
const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal'); const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal');
@ -62,11 +68,16 @@ const siderWidth = computed(() => getSiderWidth());
const siderCollapsedWidth = computed(() => getSiderCollapsedWidth()); const siderCollapsedWidth = computed(() => getSiderCollapsedWidth());
function getSiderWidth() { function getSiderWidth() {
const { reverseHorizontalMix } = themeStore.layout;
const { width, mixWidth, mixChildMenuWidth } = themeStore.sider; const { width, mixWidth, mixChildMenuWidth } = themeStore.sider;
if (isHorizontalMix.value && reverseHorizontalMix) {
return isActiveFirstLevelMenuHasChildren.value ? width : 0;
}
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width; let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width;
if (isVerticalMix.value && appStore.mixSiderFixed && menus.value.length) { if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
w += mixChildMenuWidth; w += mixChildMenuWidth;
} }
@ -78,7 +89,7 @@ function getSiderCollapsedWidth() {
let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth; let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth;
if (isVerticalMix.value && appStore.mixSiderFixed && menus.value.length) { if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
w += mixChildMenuWidth; w += mixChildMenuWidth;
} }
@ -116,6 +127,7 @@ function getSiderCollapsedWidth() {
<template #sider> <template #sider>
<GlobalSider /> <GlobalSider />
</template> </template>
<GlobalMenu />
<GlobalContent /> <GlobalContent />
<ThemeDrawer /> <ThemeDrawer />
<template #footer> <template #footer>

View File

@ -26,10 +26,30 @@ function useMixMenu() {
setActiveFirstLevelMenuKey(firstLevelRouteName); setActiveFirstLevelMenuKey(firstLevelRouteName);
} }
const menus = computed( const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
routeStore.menus.map(menu => {
const { children: _, ...rest } = menu;
return rest;
})
);
const childLevelMenus = computed<App.Global.Menu[]>(
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || [] () => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
); );
const isActiveFirstLevelMenuHasChildren = computed(() => {
if (!activeFirstLevelMenuKey.value) {
return false;
}
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
return Boolean(findItem?.children?.length);
});
watch( watch(
() => route.name, () => route.name,
() => { () => {
@ -39,9 +59,12 @@ function useMixMenu() {
); );
return { return {
allMenus,
firstLevelMenus,
childLevelMenus,
isActiveFirstLevelMenuHasChildren,
activeFirstLevelMenuKey, activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey, setActiveFirstLevelMenuKey,
getActiveFirstLevelMenuKey, getActiveFirstLevelMenuKey
menus
}; };
} }

View File

@ -1,14 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useFullscreen } from '@vueuse/core'; import { useFullscreen } from '@vueuse/core';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route'; import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
import HorizontalMenu from '../global-menu/base-menu.vue';
import GlobalLogo from '../global-logo/index.vue'; import GlobalLogo from '../global-logo/index.vue';
import GlobalBreadcrumb from '../global-breadcrumb/index.vue'; import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
import GlobalSearch from '../global-search/index.vue'; import GlobalSearch from '../global-search/index.vue';
import { useMixMenuContext } from '../../context';
import ThemeButton from './components/theme-button.vue'; import ThemeButton from './components/theme-button.vue';
import UserAvatar from './components/user-avatar.vue'; import UserAvatar from './components/user-avatar.vue';
@ -29,29 +26,15 @@ defineProps<Props>();
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { isFullscreen, toggle } = useFullscreen(); const { isFullscreen, toggle } = useFullscreen();
const { menus } = useMixMenuContext();
const headerMenus = computed(() => {
if (themeStore.layout.mode === 'horizontal') {
return routeStore.menus;
}
if (themeStore.layout.mode === 'horizontal-mix') {
return menus.value;
}
return [];
});
</script> </script>
<template> <template>
<DarkModeContainer class="h-full flex-y-center px-12px shadow-header"> <DarkModeContainer class="h-full flex-y-center px-12px shadow-header">
<GlobalLogo v-if="showLogo" class="h-full" :style="{ width: themeStore.sider.width + 'px' }" /> <GlobalLogo v-if="showLogo" class="h-full" :style="{ width: themeStore.sider.width + 'px' }" />
<HorizontalMenu v-if="showMenu" mode="horizontal" :menus="headerMenus" class="px-12px" /> <MenuToggler v-if="showMenuToggler" :collapsed="appStore.siderCollapse" @click="appStore.toggleSiderCollapse" />
<div v-if="showMenu" :id="GLOBAL_HEADER_MENU_ID" class="h-full flex-y-center flex-1-hidden"></div>
<div v-else class="h-full flex-y-center flex-1-hidden"> <div v-else class="h-full flex-y-center flex-1-hidden">
<MenuToggler v-if="showMenuToggler" :collapsed="appStore.siderCollapse" @click="appStore.toggleSiderCollapse" />
<GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" /> <GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" />
</div> </div>
<div class="h-full flex-y-center justify-end"> <div class="h-full flex-y-center justify-end">

View File

@ -1,96 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { MentionOption, MenuProps } from 'naive-ui';
import { SimpleScrollbar } from '@sa/materials';
import type { RouteKey } from '@elegant-router/types';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
defineOptions({
name: 'BaseMenu'
});
interface Props {
darkTheme?: boolean;
mode?: MenuProps['mode'];
menus: App.Global.Menu[];
}
const props = withDefaults(defineProps<Props>(), {
mode: 'vertical'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKey } = useRouterPush();
const naiveMenus = computed(() => props.menus as unknown as MentionOption[]);
const isHorizontal = computed(() => props.mode === 'horizontal');
const siderCollapse = computed(() => themeStore.layout.mode === 'vertical' && appStore.siderCollapse);
const headerHeight = computed(() => `${themeStore.header.height}px`);
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() {
if (isHorizontal.value || siderCollapse.value || !selectedKey.value) {
expandedKeys.value = [];
return;
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
}
function handleClickMenu(key: RouteKey) {
const query = routeStore.getRouteQueryOfMetaByKey(key);
routerPushByKey(key, { query });
}
watch(
() => route.name,
() => {
updateExpandedKeys();
},
{ immediate: true }
);
</script>
<template>
<SimpleScrollbar>
<NMenu
v-model:expanded-keys="expandedKeys"
:mode="mode"
:value="selectedKey"
:collapsed="siderCollapse"
:collapsed-width="themeStore.sider.collapsedWidth"
:collapsed-icon-size="22"
:options="naiveMenus"
:inverted="darkTheme"
:indent="18"
responsive
@update:value="handleClickMenu"
/>
</SimpleScrollbar>
</template>
<style scoped>
:deep(.n-menu--horizontal) {
--n-item-height: v-bind(headerHeight) !important;
}
</style>

View File

@ -3,31 +3,29 @@ import { computed } from 'vue';
import { createReusableTemplate } from '@vueuse/core'; import { createReusableTemplate } from '@vueuse/core';
import { SimpleScrollbar } from '@sa/materials'; import { SimpleScrollbar } from '@sa/materials';
import { transformColorWithOpacity } from '@sa/color'; import { transformColorWithOpacity } from '@sa/color';
import { useAppStore } from '@/store/modules/app';
import { useRouteStore } from '@/store/modules/route';
import { useThemeStore } from '@/store/modules/theme';
defineOptions({ defineOptions({
name: 'FirstLevelMenu' name: 'FirstLevelMenu'
}); });
interface Props { interface Props {
menus: App.Global.Menu[];
activeMenuKey?: string; activeMenuKey?: string;
inverted?: boolean; inverted?: boolean;
siderCollapse?: boolean;
darkMode?: boolean;
themeColor: string;
} }
defineProps<Props>(); const props = defineProps<Props>();
interface Emits { interface Emits {
(e: 'select', menu: App.Global.Menu): boolean; (e: 'select', menu: App.Global.Menu): boolean;
(e: 'toggleSiderCollapse'): void;
} }
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
interface MixMenuItemProps { interface MixMenuItemProps {
/** Menu item label */ /** Menu item label */
label: App.Global.Menu['label']; label: App.Global.Menu['label'];
@ -36,12 +34,12 @@ interface MixMenuItemProps {
/** Active menu item */ /** Active menu item */
active: boolean; active: boolean;
/** Mini size */ /** Mini size */
isMini: boolean; isMini?: boolean;
} }
const [DefineMixMenuItem, MixMenuItem] = createReusableTemplate<MixMenuItemProps>(); const [DefineMixMenuItem, MixMenuItem] = createReusableTemplate<MixMenuItemProps>();
const selectedBgColor = computed(() => { const selectedBgColor = computed(() => {
const { darkMode, themeColor } = themeStore; const { darkMode, themeColor } = props;
const light = transformColorWithOpacity(themeColor, 0.1, '#ffffff'); const light = transformColorWithOpacity(themeColor, 0.1, '#ffffff');
const dark = transformColorWithOpacity(themeColor, 0.3, '#000000'); const dark = transformColorWithOpacity(themeColor, 0.3, '#000000');
@ -52,6 +50,10 @@ const selectedBgColor = computed(() => {
function handleClickMixMenu(menu: App.Global.Menu) { function handleClickMixMenu(menu: App.Global.Menu) {
emit('select', menu); emit('select', menu);
} }
function toggleSiderCollapse() {
emit('toggleSiderCollapse');
}
</script> </script>
<template> <template>
@ -80,21 +82,21 @@ function handleClickMixMenu(menu: App.Global.Menu) {
<slot></slot> <slot></slot>
<SimpleScrollbar> <SimpleScrollbar>
<MixMenuItem <MixMenuItem
v-for="menu in routeStore.menus" v-for="menu in menus"
:key="menu.key" :key="menu.key"
:label="menu.label" :label="menu.label"
:icon="menu.icon" :icon="menu.icon"
:active="menu.key === activeMenuKey" :active="menu.key === activeMenuKey"
:is-mini="appStore.siderCollapse" :is-mini="siderCollapse"
@click="handleClickMixMenu(menu)" @click="handleClickMixMenu(menu)"
/> />
</SimpleScrollbar> </SimpleScrollbar>
<MenuToggler <MenuToggler
arrow-icon arrow-icon
:collapsed="appStore.siderCollapse" :collapsed="siderCollapse"
:z-index="99" :z-index="99"
:class="{ 'text-white:88 !hover:text-white': inverted }" :class="{ 'text-white:88 !hover:text-white': inverted }"
@click="appStore.toggleSiderCollapse" @click="toggleSiderCollapse"
/> />
</div> </div>
</template> </template>

View File

@ -1,28 +0,0 @@
<script setup lang="ts">
import { useRouterPush } from '@/hooks/common/router';
import { useMixMenuContext } from '../../context';
import FirstLevelMenu from './first-level-menu.vue';
defineOptions({
name: 'HorizontalMixMenu'
});
const { activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
const { routerPushByKey } = useRouterPush();
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (!menu.children?.length) {
routerPushByKey(menu.routeKey);
}
}
</script>
<template>
<FirstLevelMenu :active-menu-key="activeFirstLevelMenuKey" @select="handleSelectMixMenu">
<slot></slot>
</FirstLevelMenu>
</template>
<style scoped></style>

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import { useThemeStore } from '@/store/modules/theme';
import VerticalMenu from './modules/vertical-menu.vue';
import VerticalMixMenu from './modules/vertical-mix-menu.vue';
import HorizontalMenu from './modules/horizontal-menu.vue';
import HorizontalMixMenu from './modules/horizontal-mix-menu.vue';
import ReversedHorizontalMixMenu from './modules/reversed-horizontal-mix-menu.vue';
defineOptions({
name: 'GlobalMenu'
});
const themeStore = useThemeStore();
const activeMenu = computed(() => {
const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = {
vertical: VerticalMenu,
'vertical-mix': VerticalMixMenu,
horizontal: HorizontalMenu,
'horizontal-mix': themeStore.layout.reverseHorizontalMix ? ReversedHorizontalMixMenu : HorizontalMixMenu
};
return menuMap[themeStore.layout.mode];
});
</script>
<template>
<component :is="activeMenu" />
</template>
<style scoped></style>

View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
defineOptions({
name: 'HorizontalMenu'
});
const route = useRoute();
const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<NMenu
mode="horizontal"
:value="selectedKey"
:options="routeStore.menus"
:indent="18"
responsive
@update:value="routerPushByKeyWithMetaQuery"
/>
</Teleport>
</template>
<style scoped></style>

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
import FirstLevelMenu from '../components/first-level-menu.vue';
import { useMixMenuContext } from '../../../context';
defineOptions({
name: 'HorizontalMixMenu'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const { allMenus, childLevelMenus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (!menu.children?.length) {
routerPushByKeyWithMetaQuery(menu.routeKey);
}
}
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<NMenu
mode="horizontal"
:value="selectedKey"
:options="childLevelMenus"
:indent="18"
responsive
@update:value="routerPushByKeyWithMetaQuery"
/>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<FirstLevelMenu
:menus="allMenus"
:active-menu-key="activeFirstLevelMenuKey"
:inverted="inverted"
:sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor"
@select="handleSelectMixMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse"
>
<slot></slot>
</FirstLevelMenu>
</Teleport>
</template>
<style scoped></style>

View File

@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { RouteKey } from '@elegant-router/types';
import { SimpleScrollbar } from '@sa/materials';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { useMixMenuContext } from '../../../context';
defineOptions({
name: 'ReversedHorizontalMixMenu'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const {
firstLevelMenus,
childLevelMenus,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
isActiveFirstLevelMenuHasChildren
} = useMixMenuContext();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
function handleSelectMixMenu(key: RouteKey) {
setActiveFirstLevelMenuKey(key);
if (!isActiveFirstLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key);
}
}
const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = [];
return;
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
}
watch(
() => route.name,
() => {
updateExpandedKeys();
},
{ immediate: true }
);
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<NMenu
mode="horizontal"
:value="activeFirstLevelMenuKey"
:options="firstLevelMenus"
:indent="18"
responsive
@update:value="handleSelectMixMenu"
/>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<SimpleScrollbar>
<NMenu
v-model:expanded-keys="expandedKeys"
mode="vertical"
:value="selectedKey"
:collapsed="appStore.siderCollapse"
:collapsed-width="themeStore.sider.collapsedWidth"
:collapsed-icon-size="22"
:options="childLevelMenus"
:inverted="inverted"
:indent="18"
@update:value="routerPushByKeyWithMetaQuery"
/>
</SimpleScrollbar>
</Teleport>
</template>
<style scoped></style>

View File

@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { SimpleScrollbar } from '@sa/materials';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
defineOptions({
name: 'VerticalMenu'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = [];
return;
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
}
watch(
() => route.name,
() => {
updateExpandedKeys();
},
{ immediate: true }
);
</script>
<template>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<SimpleScrollbar>
<NMenu
v-model:expanded-keys="expandedKeys"
mode="vertical"
:value="selectedKey"
:collapsed="appStore.siderCollapse"
:collapsed-width="themeStore.sider.collapsedWidth"
:collapsed-icon-size="22"
:options="routeStore.menus"
:inverted="inverted"
:indent="18"
@update:value="routerPushByKeyWithMetaQuery"
/>
</SimpleScrollbar>
</Teleport>
</template>
<style scoped></style>

View File

@ -0,0 +1,135 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { SimpleScrollbar } from '@sa/materials';
import { useBoolean } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales';
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useMixMenuContext } from '../../../context';
import FirstLevelMenu from '../components/first-level-menu.vue';
import GlobalLogo from '../../global-logo/index.vue';
defineOptions({
name: 'VerticalMenuMix'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
const {
allMenus,
childLevelMenus,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
getActiveFirstLevelMenuKey
//
} = useMixMenuContext();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const hasChildMenus = computed(() => childLevelMenus.value.length > 0);
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (menu.children?.length) {
setDrawerVisible(true);
} else {
routerPushByKeyWithMetaQuery(menu.routeKey);
}
}
function handleResetActiveMenu() {
getActiveFirstLevelMenuKey();
setDrawerVisible(false);
}
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = [];
return;
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
}
watch(
() => route.name,
() => {
updateExpandedKeys();
},
{ immediate: true }
);
</script>
<template>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu
:menus="allMenus"
:active-menu-key="activeFirstLevelMenuKey"
:inverted="inverted"
:sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor"
@select="handleSelectMixMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse"
>
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
</FirstLevelMenu>
<div
class="relative h-full transition-width-300"
:style="{ width: appStore.mixSiderFixed && hasChildMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<DarkModeContainer
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
:inverted="inverted"
:style="{ width: showDrawer ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<header class="flex-y-center justify-between px-12px" :style="{ height: themeStore.header.height + 'px' }">
<h2 class="text-16px text-primary font-bold">{{ $t('system.title') }}</h2>
<PinToggler
:pin="appStore.mixSiderFixed"
:class="{ 'text-white:88 !hover:text-white': inverted }"
@click="appStore.toggleMixSiderFixed"
/>
</header>
<SimpleScrollbar>
<NMenu
v-model:expanded-keys="expandedKeys"
mode="vertical"
:options="childLevelMenus"
:collapsed="appStore.siderCollapse"
:collapsed-width="themeStore.sider.collapsedWidth"
:collapsed-icon-size="22"
:inverted="inverted"
:indent="18"
@update:value="routerPushByKeyWithMetaQuery"
/>
</SimpleScrollbar>
</DarkModeContainer>
</div>
</div>
</Teleport>
</template>
<style scoped></style>

View File

@ -1,72 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useBoolean } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales';
import { useMixMenuContext } from '../../context';
import FirstLevelMenu from './first-level-menu.vue';
import BaseMenu from './base-menu.vue';
defineOptions({
name: 'VerticalMixMenu'
});
const appStore = useAppStore();
const themeStore = useThemeStore();
const { routerPushByKey } = useRouterPush();
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
const { menus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey, getActiveFirstLevelMenuKey } = useMixMenuContext();
const siderInverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const hasMenus = computed(() => menus.value.length > 0);
const showDrawer = computed(() => hasMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (menu.children?.length) {
setDrawerVisible(true);
} else {
routerPushByKey(menu.routeKey);
}
}
function handleResetActiveMenu() {
getActiveFirstLevelMenuKey();
setDrawerVisible(false);
}
</script>
<template>
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu :active-menu-key="activeFirstLevelMenuKey" :inverted="siderInverted" @select="handleSelectMixMenu">
<slot></slot>
</FirstLevelMenu>
<div
class="relative h-full transition-width-300"
:style="{ width: appStore.mixSiderFixed && hasMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<DarkModeContainer
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
:inverted="siderInverted"
:style="{ width: showDrawer ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<header class="flex-y-center justify-between px-12px" :style="{ height: themeStore.header.height + 'px' }">
<h2 class="text-16px text-primary font-bold">{{ $t('system.title') }}</h2>
<PinToggler
:pin="appStore.mixSiderFixed"
:class="{ 'text-white:88 !hover:text-white': siderInverted }"
@click="appStore.toggleMixSiderFixed"
/>
</header>
<BaseMenu :dark-theme="siderInverted" :menus="menus" />
</DarkModeContainer>
</div>
</div>
</template>
<style scoped></style>

View File

@ -2,11 +2,8 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route'; import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import GlobalLogo from '../global-logo/index.vue'; import GlobalLogo from '../global-logo/index.vue';
import VerticalMenu from '../global-menu/base-menu.vue';
import VerticalMixMenu from '../global-menu/vertical-mix-menu.vue';
import HorizontalMixMenu from '../global-menu/horizontal-mix-menu.vue';
defineOptions({ defineOptions({
name: 'GlobalSider' name: 'GlobalSider'
@ -14,12 +11,12 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const routeStore = useRouteStore();
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix'); const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix'); const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
const darkMenu = computed(() => !themeStore.darkMode && !isHorizontalMix.value && themeStore.sider.inverted); const darkMenu = computed(() => !themeStore.darkMode && !isHorizontalMix.value && themeStore.sider.inverted);
const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value); const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value);
const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full'));
</script> </script>
<template> <template>
@ -29,11 +26,7 @@ const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value);
:show-title="!appStore.siderCollapse" :show-title="!appStore.siderCollapse"
:style="{ height: themeStore.header.height + 'px' }" :style="{ height: themeStore.header.height + 'px' }"
/> />
<VerticalMixMenu v-if="isVerticalMix"> <div :id="GLOBAL_SIDER_MENU_ID" :class="menuWrapperClass"></div>
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
</VerticalMixMenu>
<HorizontalMixMenu v-else-if="isHorizontalMix" />
<VerticalMenu v-else :dark-theme="darkMenu" :menus="routeStore.menus" />
</DarkModeContainer> </DarkModeContainer>
</template> </template>

View File

@ -3,6 +3,7 @@ import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales'; import { $t } from '@/locales';
import LayoutModeCard from '../components/layout-mode-card.vue'; import LayoutModeCard from '../components/layout-mode-card.vue';
import SettingItem from '../components/setting-item.vue';
defineOptions({ defineOptions({
name: 'LayoutMode' name: 'LayoutMode'
@ -10,6 +11,10 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
function handleReverseHorizontalMixChange(value: boolean) {
themeStore.setLayoutReverseHorizontalMix(value);
}
</script> </script>
<template> <template>
@ -44,6 +49,13 @@ const themeStore = useThemeStore();
</div> </div>
</template> </template>
</LayoutModeCard> </LayoutModeCard>
<SettingItem
v-if="themeStore.layout.mode === 'horizontal-mix'"
:label="$t('theme.layoutMode.reverseHorizontalMix')"
class="mt-16px"
>
<NSwitch :value="themeStore.layout.reverseHorizontalMix" @update:value="handleReverseHorizontalMixChange" />
</SettingItem>
</template> </template>
<style scoped> <style scoped>

View File

@ -70,7 +70,8 @@ const local: App.I18n.Schema = {
vertical: 'Vertical Menu Mode', vertical: 'Vertical Menu Mode',
horizontal: 'Horizontal Menu Mode', horizontal: 'Horizontal Menu Mode',
'vertical-mix': 'Vertical Mix Menu Mode', 'vertical-mix': 'Vertical Mix Menu Mode',
'horizontal-mix': 'Horizontal Mix menu Mode' 'horizontal-mix': 'Horizontal Mix menu Mode',
reverseHorizontalMix: 'Reverse first level menus and child level menus position'
}, },
recommendColor: 'Apply Recommended Color Algorithm', recommendColor: 'Apply Recommended Color Algorithm',
recommendColorDesc: 'The recommended color algorithm refers to', recommendColorDesc: 'The recommended color algorithm refers to',

View File

@ -70,7 +70,8 @@ const local: App.I18n.Schema = {
vertical: '左侧菜单模式', vertical: '左侧菜单模式',
'vertical-mix': '左侧菜单混合模式', 'vertical-mix': '左侧菜单混合模式',
horizontal: '顶部菜单模式', horizontal: '顶部菜单模式',
'horizontal-mix': '顶部菜单混合模式' 'horizontal-mix': '顶部菜单混合模式',
reverseHorizontalMix: '一级菜单与子级菜单位置反转'
}, },
recommendColor: '应用推荐算法的颜色', recommendColor: '应用推荐算法的颜色',
recommendColorDesc: '推荐颜色的算法参照', recommendColorDesc: '推荐颜色的算法参照',

View File

@ -354,34 +354,6 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
return getSelectedMenuKeyPathByKey(selectedKey, menus.value); return getSelectedMenuKeyPathByKey(selectedKey, menus.value);
} }
/**
* Get route meta by key
*
* @param key Route key
*/
function getRouteMetaByKey(key: string) {
const allRoutes = router.getRoutes();
return allRoutes.find(route => route.name === key)?.meta || null;
}
/**
* Get route query of meta by key
*
* @param key
*/
function getRouteQueryOfMetaByKey(key: string) {
const meta = getRouteMetaByKey(key);
const query: Record<string, string> = {};
meta?.query?.forEach(item => {
query[item.key] = item.value;
});
return query;
}
return { return {
resetStore, resetStore,
routeHome, routeHome,
@ -398,7 +370,6 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
isInitAuthRoute, isInitAuthRoute,
setIsInitAuthRoute, setIsInitAuthRoute,
getIsAuthRouteExist, getIsAuthRouteExist,
getSelectedMenuKeyPath, getSelectedMenuKeyPath
getRouteQueryOfMetaByKey
}; };
}); });

View File

@ -132,6 +132,14 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
); );
addThemeVarsToGlobal(themeTokens, darkThemeTokens); addThemeVarsToGlobal(themeTokens, darkThemeTokens);
} }
/**
* Set layout reverse horizontal mix
*
* @param reverse Reverse horizontal mix
*/
function setLayoutReverseHorizontalMix(reverse: boolean) {
settings.value.layout.reverseHorizontalMix = reverse;
}
/** Cache theme settings */ /** Cache theme settings */
function cacheThemeSettings() { function cacheThemeSettings() {
@ -193,6 +201,7 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
setThemeScheme, setThemeScheme,
toggleThemeScheme, toggleThemeScheme,
updateThemeColors, updateThemeColors,
setThemeLayout setThemeLayout,
setLayoutReverseHorizontalMix
}; };
}); });

View File

@ -13,7 +13,8 @@ export const themeSettings: App.Theme.ThemeSetting = {
isInfoFollowPrimary: true, isInfoFollowPrimary: true,
layout: { layout: {
mode: 'vertical', mode: 'vertical',
scrollMode: 'content' scrollMode: 'content',
reverseHorizontalMix: false
}, },
page: { page: {
animate: true, animate: true,

12
src/typings/app.d.ts vendored
View File

@ -24,6 +24,12 @@ declare namespace App {
mode: UnionKey.ThemeLayoutMode; mode: UnionKey.ThemeLayoutMode;
/** Scroll mode */ /** Scroll mode */
scrollMode: UnionKey.ThemeScrollMode; scrollMode: UnionKey.ThemeScrollMode;
/**
* Whether to reverse the horizontal mix
*
* if true, the vertical child level menus in left and horizontal first level menus in top
*/
reverseHorizontalMix?: boolean;
}; };
/** Page */ /** Page */
page: { page: {
@ -164,7 +170,7 @@ declare namespace App {
} }
/** The global menu */ /** The global menu */
interface Menu { type Menu = {
/** /**
* The menu key * The menu key
* *
@ -183,7 +189,7 @@ declare namespace App {
icon?: () => VNode; icon?: () => VNode;
/** The menu children */ /** The menu children */
children?: Menu[]; children?: Menu[];
} };
type Breadcrumb = Omit<Menu, 'children'> & { type Breadcrumb = Omit<Menu, 'children'> & {
options?: Breadcrumb[]; options?: Breadcrumb[];
@ -326,7 +332,7 @@ declare namespace App {
theme: { theme: {
themeSchema: { title: string } & Record<UnionKey.ThemeScheme, string>; themeSchema: { title: string } & Record<UnionKey.ThemeScheme, string>;
grayscale: string; grayscale: string;
layoutMode: { title: string } & Record<UnionKey.ThemeLayoutMode, string>; layoutMode: { title: string; reverseHorizontalMix: string } & Record<UnionKey.ThemeLayoutMode, string>;
recommendColor: string; recommendColor: string;
recommendColorDesc: string; recommendColorDesc: string;
themeColor: { themeColor: {

View File

@ -20,7 +20,7 @@ declare namespace UnionKey {
* - vertical: the vertical menu in left * - vertical: the vertical menu in left
* - horizontal: the horizontal menu in top * - horizontal: the horizontal menu in top
* - vertical-mix: two vertical mixed menus in left * - vertical-mix: two vertical mixed menus in left
* - horizontal-mix: the vertical menu in left and horizontal menu in top * - horizontal-mix: the vertical first level menus in left and horizontal child level menus in top
*/ */
type ThemeLayoutMode = 'vertical' | 'horizontal' | 'vertical-mix' | 'horizontal-mix'; type ThemeLayoutMode = 'vertical' | 'horizontal' | 'vertical-mix' | 'horizontal-mix';