From b8a767d70465dbd24ad4196f17932c4ce086b661 Mon Sep 17 00:00:00 2001 From: hooke Date: Wed, 3 Dec 2025 07:25:46 -0700 Subject: [PATCH 1/7] feat(projects): support pinning and unpinning of tabs --- .../modules/global-tab/context-menu.vue | 25 ++++++++++- src/locales/langs/en-us.ts | 4 +- src/locales/langs/zh-cn.ts | 4 +- src/store/modules/tab/index.ts | 45 +++++++++++++++++++ src/store/modules/tab/shared.ts | 12 +++++ src/typings/app.d.ts | 2 +- 6 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/layouts/modules/global-tab/context-menu.vue b/src/layouts/modules/global-tab/context-menu.vue index 18d57f98..3997bae6 100644 --- a/src/layouts/modules/global-tab/context-menu.vue +++ b/src/layouts/modules/global-tab/context-menu.vue @@ -26,7 +26,7 @@ const props = withDefaults(defineProps(), { const visible = defineModel('visible'); -const { removeTab, clearTabs, clearLeftTabs, clearRightTabs } = useTabStore(); +const { removeTab, clearTabs, clearLeftTabs, clearRightTabs, fixTab, unfixTab, isTabRetain } = useTabStore(); const { SvgIconVNode } = useSvgIcon(); type DropdownOption = { @@ -64,6 +64,23 @@ const options = computed(() => { icon: SvgIconVNode({ icon: 'ant-design:line-outlined', fontSize: 18 }) } ]; + + if (props.tabId !== '/home') { + if (isTabRetain(props.tabId)) { + opts.push({ + key: 'unpin', + label: $t('dropdown.unpin'), + icon: SvgIconVNode({ icon: 'mdi:pin-off-outline', fontSize: 18 }) + }); + } else { + opts.push({ + key: 'pin', + label: $t('dropdown.pin'), + icon: SvgIconVNode({ icon: 'mdi:pin-outline', fontSize: 18 }) + }); + } + } + const { excludeKeys, disabledKeys } = props; const result = opts.filter(opt => !excludeKeys.includes(opt.key)); @@ -98,6 +115,12 @@ const dropdownAction: Record void> = { }, closeAll() { clearTabs(); + }, + pin() { + fixTab(props.tabId); + }, + unpin() { + unfixTab(props.tabId); } }; diff --git a/src/locales/langs/en-us.ts b/src/locales/langs/en-us.ts index e84906b2..a7b383fd 100644 --- a/src/locales/langs/en-us.ts +++ b/src/locales/langs/en-us.ts @@ -336,7 +336,9 @@ const local: App.I18n.Schema = { closeOther: 'Close Other', closeLeft: 'Close Left', closeRight: 'Close Right', - closeAll: 'Close All' + closeAll: 'Close All', + pin: 'Pin Tab', + unpin: 'Unpin Tab' }, icon: { themeConfig: 'Theme Configuration', diff --git a/src/locales/langs/zh-cn.ts b/src/locales/langs/zh-cn.ts index 11a5817c..7de2ee34 100644 --- a/src/locales/langs/zh-cn.ts +++ b/src/locales/langs/zh-cn.ts @@ -333,7 +333,9 @@ const local: App.I18n.Schema = { closeOther: '关闭其它', closeLeft: '关闭左侧', closeRight: '关闭右侧', - closeAll: '关闭所有' + closeAll: '关闭所有', + pin: '固定标签', + unpin: '取消固定' }, icon: { themeConfig: '主题配置', diff --git a/src/store/modules/tab/index.ts b/src/store/modules/tab/index.ts index 84d96902..ee47f454 100644 --- a/src/store/modules/tab/index.ts +++ b/src/store/modules/tab/index.ts @@ -18,6 +18,7 @@ import { getTabByRoute, getTabIdByRoute, isTabInTabs, + reorderFixedTabs, updateTabByI18nKey, updateTabsByI18nKey } from './shared'; @@ -248,6 +249,48 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => { await clearTabs(excludes); } + /** + * Fix tab + * + * @param tabId + */ + function fixTab(tabId: string) { + const tabIndex = tabs.value.findIndex(t => t.id === tabId); + if (tabIndex === -1) return; + + const tab = tabs.value[tabIndex]; + const fixedCount = getFixedTabIds(tabs.value).length; + tab.fixedIndex = fixedCount; + + if (tabIndex !== fixedCount) { + tabs.value.splice(tabIndex, 1); + tabs.value.splice(fixedCount, 0, tab); + } + + reorderFixedTabs(tabs.value); + } + + /** + * Unfix tab + * + * @param tabId + */ + function unfixTab(tabId: string) { + const tabIndex = tabs.value.findIndex(t => t.id === tabId); + if (tabIndex === -1) return; + + const tab = tabs.value[tabIndex]; + tab.fixedIndex = undefined; + + const fixedCount = getFixedTabIds(tabs.value).length; + if (tabIndex !== fixedCount) { + tabs.value.splice(tabIndex, 1); + tabs.value.splice(fixedCount, 0, tab); + } + + reorderFixedTabs(tabs.value); + } + /** * Set new label of tab * @@ -328,6 +371,8 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => { clearTabs, clearLeftTabs, clearRightTabs, + fixTab, + unfixTab, switchRouteByTab, setTabLabel, resetTabLabel, diff --git a/src/store/modules/tab/shared.ts b/src/store/modules/tab/shared.ts index 5a287002..48880a3a 100644 --- a/src/store/modules/tab/shared.ts +++ b/src/store/modules/tab/shared.ts @@ -198,6 +198,18 @@ export function getFixedTabIds(tabs: App.Global.Tab[]) { return fixedTabs.map(tab => tab.id); } +/** + * Reorder fixed tabs fixedIndex + * + * @param tabs + */ +export function reorderFixedTabs(tabs: App.Global.Tab[]) { + const fixedTabs = getFixedTabs(tabs); + fixedTabs.forEach((t, i) => { + t.fixedIndex = i; + }); +} + /** * Update tabs label * diff --git a/src/typings/app.d.ts b/src/typings/app.d.ts index 215d6ac0..6778fc7b 100644 --- a/src/typings/app.d.ts +++ b/src/typings/app.d.ts @@ -282,7 +282,7 @@ declare namespace App { type FormRule = import('naive-ui').FormItemRule; /** The global dropdown key */ - type DropdownKey = 'closeCurrent' | 'closeOther' | 'closeLeft' | 'closeRight' | 'closeAll'; + type DropdownKey = 'closeCurrent' | 'closeOther' | 'closeLeft' | 'closeRight' | 'closeAll' | 'pin' | 'unpin'; } /** From 9401925f411de9b8553fb6ad7108e30c415193f5 Mon Sep 17 00:00:00 2001 From: paynezhuang Date: Wed, 3 Dec 2025 23:12:38 +0800 Subject: [PATCH 2/7] feat(projects): hybrid layout mode auto select first deepest child menu --- .../modules/global-menu/context/index.ts | 43 ++++++++++++++++++- .../modules/top-hybrid-header-first.vue | 23 ++++++++-- .../modules/top-hybrid-sidebar-first.vue | 23 ++++++++-- .../modules/vertical-hybrid-header-first.vue | 37 +++++++++++++--- .../modules/layout/modules/sider-settings.vue | 7 +++ src/locales/langs/en-us.ts | 5 ++- src/locales/langs/zh-cn.ts | 4 +- src/theme/settings.ts | 3 +- src/typings/app.d.ts | 4 ++ 9 files changed, 132 insertions(+), 17 deletions(-) diff --git a/src/layouts/modules/global-menu/context/index.ts b/src/layouts/modules/global-menu/context/index.ts index b83b71b3..48313a34 100644 --- a/src/layouts/modules/global-menu/context/index.ts +++ b/src/layouts/modules/global-menu/context/index.ts @@ -3,6 +3,7 @@ import { useRoute } from 'vue-router'; import { useContext } from '@sa/hooks'; import type { RouteKey } from '@elegant-router/types'; import { useRouteStore } from '@/store/modules/route'; +import { useThemeStore } from '@/store/modules/theme'; import { useRouterPush } from '@/hooks/common/router'; export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu); @@ -10,6 +11,7 @@ export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', function useMixMenu() { const route = useRoute(); const routeStore = useRouteStore(); + const themeStore = useThemeStore(); const { selectedKey } = useMenu(); const { routerPushByKeyWithMetaQuery } = useRouterPush(); @@ -100,10 +102,46 @@ function useMixMenu() { () => secondLevelMenus.value.find(menu => menu.key === activeSecondLevelMenuKey.value)?.children || [] ); + const hasChildLevelMenus = computed(() => childLevelMenus.value.length > 0); + + function getDeepestLevelMenuKey(): RouteKey | null { + if (!secondLevelMenus.value.length || !themeStore.sider.autoSelectFirstMenu) { + return null; + } + + const secondLevelFirstMenu = secondLevelMenus.value[0]; + + if (!secondLevelFirstMenu) { + return null; + } + + function findDeepest(menu: App.Global.Menu): RouteKey { + if (!menu.children?.length) { + return menu.routeKey; + } + + return findDeepest(menu.children[0]); + } + + return findDeepest(secondLevelFirstMenu); + } + + function activeDeepestLevelMenuKey() { + const deepestLevelMenuKey = getDeepestLevelMenuKey(); + if (!deepestLevelMenuKey) return; + + // select the deepest second level menu + handleSelectSecondLevelMenu(deepestLevelMenuKey); + } + watch( () => route.name, () => { getActiveFirstLevelMenuKey(); + // if there are child level menus, get the active second level menu key + if (hasChildLevelMenus.value) { + getActiveSecondLevelMenuKey(); + } }, { immediate: true } ); @@ -121,7 +159,10 @@ function useMixMenu() { isActiveSecondLevelMenuHasChildren, handleSelectSecondLevelMenu, getActiveSecondLevelMenuKey, - childLevelMenus + childLevelMenus, + hasChildLevelMenus, + getDeepestLevelMenuKey, + activeDeepestLevelMenuKey }; } diff --git a/src/layouts/modules/global-menu/modules/top-hybrid-header-first.vue b/src/layouts/modules/global-menu/modules/top-hybrid-header-first.vue index 937e5933..b312f421 100644 --- a/src/layouts/modules/global-menu/modules/top-hybrid-header-first.vue +++ b/src/layouts/modules/global-menu/modules/top-hybrid-header-first.vue @@ -2,6 +2,7 @@ import { ref, watch } from 'vue'; import { useRoute } from 'vue-router'; import { SimpleScrollbar } from '@sa/materials'; +import type { RouteKey } from '@elegant-router/types'; import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'; import { useAppStore } from '@/store/modules/app'; import { useThemeStore } from '@/store/modules/theme'; @@ -18,12 +19,28 @@ const appStore = useAppStore(); const themeStore = useThemeStore(); const routeStore = useRouteStore(); const { routerPushByKeyWithMetaQuery } = useRouterPush(); -const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } = - useMixMenuContext('TopHybridHeaderFirst'); +const { + firstLevelMenus, + secondLevelMenus, + activeFirstLevelMenuKey, + handleSelectFirstLevelMenu, + activeDeepestLevelMenuKey +} = useMixMenuContext('TopHybridHeaderFirst'); const { selectedKey } = useMenu(); const expandedKeys = ref([]); +/** + * Handle first level menu select + * @param key RouteKey + */ +function handleSelectMenu(key: RouteKey) { + handleSelectFirstLevelMenu(key); + + // if there are second level menus, select the deepest one by default + activeDeepestLevelMenuKey(); +} + function updateExpandedKeys() { if (appStore.siderCollapse || !selectedKey.value) { expandedKeys.value = []; @@ -49,7 +66,7 @@ watch( :options="firstLevelMenus" :indent="18" responsive - @update:value="handleSelectFirstLevelMenu" + @update:value="handleSelectMenu" /> diff --git a/src/layouts/modules/global-menu/modules/top-hybrid-sidebar-first.vue b/src/layouts/modules/global-menu/modules/top-hybrid-sidebar-first.vue index ff037723..d12189e8 100644 --- a/src/layouts/modules/global-menu/modules/top-hybrid-sidebar-first.vue +++ b/src/layouts/modules/global-menu/modules/top-hybrid-sidebar-first.vue @@ -1,4 +1,5 @@