Compare commits

..

7 Commits

Author SHA1 Message Date
Azir-11
62a43c3957 fix(projects): fix the incorrect judgment of home by pin tab. 2025-12-04 16:45:11 +08:00
Azir-11
64226d9bb8 fix(hooks): update pagination pageSize after data fetch. 2025-12-04 15:50:34 +08:00
Soybean
e675474b43 chore(projects): release v2.0.1 2025-12-04 14:07:46 +08:00
Soybean
098cd50e23 chore(styles): format code 2025-12-04 14:07:03 +08:00
Soybean
7cf4083b22 chore(deps): update deps 2025-12-04 14:06:44 +08:00
paynezhuang
9401925f41 feat(projects): hybrid layout mode auto select first deepest child menu 2025-12-04 14:01:07 +08:00
hooke
b8a767d704 feat(projects): support pinning and unpinning of tabs 2025-12-04 13:59:01 +08:00
27 changed files with 1291 additions and 749 deletions

View File

@@ -1,6 +1,50 @@
# Changelog # Changelog
## [v2.0.1](https://github.com/soybeanjs/soybean-admin/compare/v2.0.0...v2.0.1) (2025-12-04)
###    🚀 Features
- **docs**:
- update QQ group image in README &nbsp;-&nbsp; by @soybeanjs [<samp>(46081)</samp>](https://github.com/soybeanjs/soybean-admin/commit/46081c36)
- **projects**:
- support theme presets to only set partial content. &nbsp;-&nbsp; by **Azir-11** [<samp>(9da84)</samp>](https://github.com/soybeanjs/soybean-admin/commit/9da847fb)
- support theme perset to override component library presets. &nbsp;-&nbsp; by **Azir-11** [<samp>(60517)</samp>](https://github.com/soybeanjs/soybean-admin/commit/605173a1)
- support pinning and unpinning of tabs &nbsp;-&nbsp; by **hooke** [<samp>(b8a76)</samp>](https://github.com/soybeanjs/soybean-admin/commit/b8a767d7)
- hybrid layout mode auto select first deepest child menu &nbsp;-&nbsp; by @paynezhuang [<samp>(94019)</samp>](https://github.com/soybeanjs/soybean-admin/commit/9401925f)
### &nbsp;&nbsp;&nbsp;🐞 Bug Fixes
- **docs**: update project name in ecosystem section of README &nbsp;-&nbsp; by @soybeanjs [<samp>(bb232)</samp>](https://github.com/soybeanjs/soybean-admin/commit/bb232bf8)
- **types**: add missing property in theme presets &nbsp;-&nbsp; by **刘璐** [<samp>(4a9cf)</samp>](https://github.com/soybeanjs/soybean-admin/commit/4a9cf6c3)
### &nbsp;&nbsp;&nbsp;🛠 Optimizations
- **projects**: simplify some theme preset configurations. &nbsp;-&nbsp; by **Azir-11** [<samp>(c6d97)</samp>](https://github.com/soybeanjs/soybean-admin/commit/c6d97dba)
### &nbsp;&nbsp;&nbsp;📖 Documentation
- **projects**: add link to ecosystem document. &nbsp;-&nbsp; by **Azir-11** [<samp>(c472a)</samp>](https://github.com/soybeanjs/soybean-admin/commit/c472a943)
### &nbsp;&nbsp;&nbsp;🏡 Chore
- **deps**:
- update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(f8dc6)</samp>](https://github.com/soybeanjs/soybean-admin/commit/f8dc639e)
- update deps &nbsp;-&nbsp; by @soybeanjs [<samp>(7cf40)</samp>](https://github.com/soybeanjs/soybean-admin/commit/7cf4083b)
- **other**:
- remove Prettier's recommendation. &nbsp;-&nbsp; by **Azir-11** [<samp>(73e9a)</samp>](https://github.com/soybeanjs/soybean-admin/commit/73e9a0fe)
- **styles**:
- format code &nbsp;-&nbsp; by @soybeanjs [<samp>(098cd)</samp>](https://github.com/soybeanjs/soybean-admin/commit/098cd50e)
### &nbsp;&nbsp;&nbsp;🎨 Styles
- **projects**: modify homepage prompt title to tip. &nbsp;-&nbsp; by **Azir-11** [<samp>(91a26)</samp>](https://github.com/soybeanjs/soybean-admin/commit/91a261c1)
### &nbsp;&nbsp;&nbsp;❤️ Contributors
[![soybeanjs](https://github.com/soybeanjs.png?size=48)](https://github.com/soybeanjs)&nbsp;&nbsp;[![paynezhuang](https://github.com/paynezhuang.png?size=48)](https://github.com/paynezhuang)&nbsp;&nbsp;
[hooke](mailto:hellohooke@foxmail.com),&nbsp;[Azir-11](mailto:2075125282@qq.com),&nbsp;[刘璐](mailto:hi.alue@qq.com)
## [v2.0.0](https://github.com/soybeanjs/soybean-admin/compare/v1.3.15...v2.0.0) (2025-11-02) ## [v2.0.0](https://github.com/soybeanjs/soybean-admin/compare/v1.3.15...v2.0.0) (2025-11-02)
### &nbsp;&nbsp;&nbsp;🚨 Breaking Changes ### &nbsp;&nbsp;&nbsp;🚨 Breaking Changes

View File

@@ -1,7 +1,7 @@
{ {
"name": "soybean-admin", "name": "soybean-admin",
"type": "module", "type": "module",
"version": "2.0.0", "version": "2.0.1",
"description": "A fresh and elegant admin template, based on Vue3、Vite7、TypeScript、NaiveUI and UnoCSS. 一个基于Vue3、Vite7、TypeScript、NaiveUI and UnoCSS的清新优雅的中后台模版。", "description": "A fresh and elegant admin template, based on Vue3、Vite7、TypeScript、NaiveUI and UnoCSS. 一个基于Vue3、Vite7、TypeScript、NaiveUI and UnoCSS的清新优雅的中后台模版。",
"author": { "author": {
"name": "Soybean", "name": "Soybean",
@@ -54,53 +54,53 @@
"@sa/hooks": "workspace:*", "@sa/hooks": "workspace:*",
"@sa/materials": "workspace:*", "@sa/materials": "workspace:*",
"@sa/utils": "workspace:*", "@sa/utils": "workspace:*",
"@vueuse/core": "14.0.0", "@vueuse/core": "14.1.0",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"dayjs": "1.11.19", "dayjs": "1.11.19",
"defu": "6.1.4", "defu": "6.1.4",
"echarts": "6.0.0", "echarts": "6.0.0",
"json5": "2.2.3", "json5": "2.2.3",
"naive-ui": "2.43.1", "naive-ui": "2.43.2",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"pinia": "3.0.4", "pinia": "3.0.4",
"tailwind-merge": "3.4.0", "tailwind-merge": "3.4.0",
"vue": "3.5.24", "vue": "3.5.25",
"vue-draggable-plus": "0.6.0", "vue-draggable-plus": "0.6.0",
"vue-i18n": "11.1.12", "vue-i18n": "11.2.2",
"vue-router": "4.6.3" "vue-router": "4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@elegant-router/vue": "0.3.8", "@elegant-router/vue": "0.3.8",
"@iconify/json": "2.2.407", "@iconify/json": "2.2.414",
"@sa/scripts": "workspace:*", "@sa/scripts": "workspace:*",
"@sa/uno-preset": "workspace:*", "@sa/uno-preset": "workspace:*",
"@soybeanjs/eslint-config": "1.7.3", "@soybeanjs/eslint-config": "1.7.4",
"@types/node": "24.10.1", "@types/node": "24.10.1",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@unocss/eslint-config": "66.5.6", "@unocss/eslint-config": "66.5.10",
"@unocss/preset-icons": "66.5.6", "@unocss/preset-icons": "66.5.10",
"@unocss/preset-uno": "66.5.6", "@unocss/preset-uno": "66.5.10",
"@unocss/transformer-directives": "66.5.6", "@unocss/transformer-directives": "66.5.10",
"@unocss/transformer-variant-group": "66.5.6", "@unocss/transformer-variant-group": "66.5.10",
"@unocss/vite": "66.5.6", "@unocss/vite": "66.5.10",
"@vitejs/plugin-vue": "6.0.1", "@vitejs/plugin-vue": "6.0.2",
"@vitejs/plugin-vue-jsx": "5.1.1", "@vitejs/plugin-vue-jsx": "5.1.2",
"consola": "3.4.2", "consola": "3.4.2",
"eslint": "9.39.1", "eslint": "9.39.1",
"eslint-plugin-vue": "10.5.1", "eslint-plugin-vue": "10.6.2",
"kolorist": "1.8.0", "kolorist": "1.8.0",
"sass": "1.94.0", "sass": "1.94.2",
"simple-git-hooks": "2.13.1", "simple-git-hooks": "2.13.1",
"tsx": "4.20.6", "tsx": "4.21.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"unplugin-icons": "22.5.0", "unplugin-icons": "22.5.0",
"unplugin-vue-components": "30.0.0", "unplugin-vue-components": "30.0.0",
"vite": "7.2.2", "vite": "7.2.6",
"vite-plugin-progress": "0.0.7", "vite-plugin-progress": "0.0.7",
"vite-plugin-svg-icons": "2.0.1", "vite-plugin-svg-icons": "2.0.1",
"vite-plugin-vue-devtools": "8.0.3", "vite-plugin-vue-devtools": "8.0.5",
"vue-eslint-parser": "10.2.0", "vue-eslint-parser": "10.2.0",
"vue-tsc": "3.1.4" "vue-tsc": "3.1.5"
}, },
"simple-git-hooks": { "simple-git-hooks": {
"commit-msg": "pnpm sa git-commit-verify", "commit-msg": "pnpm sa git-commit-verify",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/alova", "name": "@sa/alova",
"version": "2.0.0", "version": "2.0.1",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./fetch": "./src/fetch.ts", "./fetch": "./src/fetch.ts",
@@ -15,6 +15,6 @@
"dependencies": { "dependencies": {
"@alova/mock": "2.0.17", "@alova/mock": "2.0.17",
"@sa/utils": "workspace:*", "@sa/utils": "workspace:*",
"alova": "3.3.4" "alova": "3.4.0"
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/axios", "name": "@sa/axios",
"version": "2.0.0", "version": "2.0.1",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

@@ -119,8 +119,11 @@ export type FlatResponseData<ResponseData, ApiData> =
| FlatResponseSuccessData<ResponseData, ApiData> | FlatResponseSuccessData<ResponseData, ApiData>
| FlatResponseFailData<ResponseData>; | FlatResponseFailData<ResponseData>;
export interface FlatRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>> export interface FlatRequestInstance<
extends RequestInstanceCommon<State> { ResponseData,
ApiData,
State extends Record<string, unknown>
> extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>( <T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R> config: CustomAxiosRequestConfig<R>
): Promise<FlatResponseData<ResponseData, MappedType<R, T>>>; ): Promise<FlatResponseData<ResponseData, MappedType<R, T>>>;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/color", "name": "@sa/color",
"version": "2.0.0", "version": "2.0.1",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/hooks", "name": "@sa/hooks",
"version": "2.0.0", "version": "2.0.1",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

@@ -26,8 +26,11 @@ export type HookRequestInstanceResponseData<ResponseData, ApiData> = {
loading: Ref<boolean>; loading: Ref<boolean>;
} & (HookRequestInstanceResponseSuccessData<ApiData> | HookRequestInstanceResponseFailData<ResponseData>); } & (HookRequestInstanceResponseSuccessData<ApiData> | HookRequestInstanceResponseFailData<ResponseData>);
export interface HookRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>> export interface HookRequestInstance<
extends RequestInstanceCommon<State> { ResponseData,
ApiData,
State extends Record<string, unknown>
> extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>( <T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig config: CustomAxiosRequestConfig
): HookRequestInstanceResponseData<ResponseData, MappedType<R, T>>; ): HookRequestInstanceResponseData<ResponseData, MappedType<R, T>>;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/materials", "name": "@sa/materials",
"version": "2.0.0", "version": "2.0.1",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

@@ -146,7 +146,8 @@ export type LayoutScrollMode = 'wrapper' | 'content';
/** Admin layout props */ /** Admin layout props */
export interface AdminLayoutProps export interface AdminLayoutProps
extends AdminLayoutHeaderConfig, extends
AdminLayoutHeaderConfig,
AdminLayoutTabConfig, AdminLayoutTabConfig,
AdminLayoutSiderConfig, AdminLayoutSiderConfig,
AdminLayoutContentConfig, AdminLayoutContentConfig,

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/scripts", "name": "@sa/scripts",
"version": "2.0.0", "version": "2.0.1",
"bin": { "bin": {
"sa": "./bin.ts" "sa": "./bin.ts"
}, },
@@ -14,15 +14,15 @@
}, },
"devDependencies": { "devDependencies": {
"@soybeanjs/changelog": "0.3.25", "@soybeanjs/changelog": "0.3.25",
"bumpp": "10.3.1", "bumpp": "10.3.2",
"c12": "3.3.2", "c12": "3.3.2",
"cac": "6.7.14", "cac": "6.7.14",
"consola": "3.4.2", "consola": "3.4.2",
"enquirer": "2.4.1", "enquirer": "2.4.1",
"execa": "9.6.0", "execa": "9.6.1",
"kolorist": "1.8.0", "kolorist": "1.8.0",
"npm-check-updates": "19.1.2", "npm-check-updates": "19.1.2",
"picomatch": "4.0.3", "picomatch": "4.0.3",
"rimraf": "6.1.0" "rimraf": "6.1.2"
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/uno-preset", "name": "@sa/uno-preset",
"version": "2.0.0", "version": "2.0.1",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@sa/utils", "name": "@sa/utils",
"version": "2.0.0", "version": "2.0.1",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

1670
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -131,6 +131,7 @@ export function useNaivePaginatedTable<ResponseData, ApiData>(
getColumns, getColumns,
onFetched: data => { onFetched: data => {
pagination.itemCount = data.total; pagination.itemCount = data.total;
pagination.pageSize = data.pageSize;
} }
}); });

View File

@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router';
import { useContext } from '@sa/hooks'; import { useContext } from '@sa/hooks';
import type { RouteKey } from '@elegant-router/types'; import type { RouteKey } from '@elegant-router/types';
import { useRouteStore } from '@/store/modules/route'; import { useRouteStore } from '@/store/modules/route';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu); export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu);
@@ -10,6 +11,7 @@ export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu',
function useMixMenu() { function useMixMenu() {
const route = useRoute(); const route = useRoute();
const routeStore = useRouteStore(); const routeStore = useRouteStore();
const themeStore = useThemeStore();
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
const { routerPushByKeyWithMetaQuery } = useRouterPush(); const { routerPushByKeyWithMetaQuery } = useRouterPush();
@@ -100,10 +102,46 @@ function useMixMenu() {
() => secondLevelMenus.value.find(menu => menu.key === activeSecondLevelMenuKey.value)?.children || [] () => 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( watch(
() => route.name, () => route.name,
() => { () => {
getActiveFirstLevelMenuKey(); getActiveFirstLevelMenuKey();
// if there are child level menus, get the active second level menu key
if (hasChildLevelMenus.value) {
getActiveSecondLevelMenuKey();
}
}, },
{ immediate: true } { immediate: true }
); );
@@ -121,7 +159,10 @@ function useMixMenu() {
isActiveSecondLevelMenuHasChildren, isActiveSecondLevelMenuHasChildren,
handleSelectSecondLevelMenu, handleSelectSecondLevelMenu,
getActiveSecondLevelMenuKey, getActiveSecondLevelMenuKey,
childLevelMenus childLevelMenus,
hasChildLevelMenus,
getDeepestLevelMenuKey,
activeDeepestLevelMenuKey
}; };
} }

View File

@@ -2,6 +2,7 @@
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { SimpleScrollbar } from '@sa/materials'; 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 { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
@@ -18,12 +19,28 @@ const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const routeStore = useRouteStore(); const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush(); const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } = const {
useMixMenuContext('TopHybridHeaderFirst'); firstLevelMenus,
secondLevelMenus,
activeFirstLevelMenuKey,
handleSelectFirstLevelMenu,
activeDeepestLevelMenuKey
} = useMixMenuContext('TopHybridHeaderFirst');
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
const expandedKeys = ref<string[]>([]); const expandedKeys = ref<string[]>([]);
/**
* 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() { function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) { if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = []; expandedKeys.value = [];
@@ -49,7 +66,7 @@ watch(
:options="firstLevelMenus" :options="firstLevelMenus"
:indent="18" :indent="18"
responsive responsive
@update:value="handleSelectFirstLevelMenu" @update:value="handleSelectMenu"
/> />
</Teleport> </Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { RouteKey } from '@elegant-router/types';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'; import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
@@ -13,9 +14,25 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush(); const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } = const {
useMixMenuContext('TopHybridSidebarFirst'); firstLevelMenus,
secondLevelMenus,
activeFirstLevelMenuKey,
handleSelectFirstLevelMenu,
activeDeepestLevelMenuKey
} = useMixMenuContext('TopHybridSidebarFirst');
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
/**
* 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();
}
</script> </script>
<template> <template>
@@ -37,7 +54,7 @@ const { selectedKey } = useMenu();
:sider-collapse="appStore.siderCollapse" :sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode" :dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor" :theme-color="themeStore.themeColor"
@select="handleSelectFirstLevelMenu" @select="handleSelectMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse" @toggle-sider-collapse="appStore.toggleSiderCollapse"
/> />
</div> </div>

View File

@@ -33,15 +33,15 @@ const {
isActiveSecondLevelMenuHasChildren, isActiveSecondLevelMenuHasChildren,
handleSelectSecondLevelMenu, handleSelectSecondLevelMenu,
getActiveSecondLevelMenuKey, getActiveSecondLevelMenuKey,
childLevelMenus childLevelMenus,
hasChildLevelMenus,
activeDeepestLevelMenuKey
} = useMixMenuContext('VerticalHybridHeaderFirst'); } = useMixMenuContext('VerticalHybridHeaderFirst');
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted); const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const hasChildMenus = computed(() => childLevelMenus.value.length > 0); const showDrawer = computed(() => hasChildLevelMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
function handleSelectMixMenu(key: RouteKey) { function handleSelectMixMenu(key: RouteKey) {
handleSelectSecondLevelMenu(key); handleSelectSecondLevelMenu(key);
@@ -51,12 +51,33 @@ function handleSelectMixMenu(key: RouteKey) {
} }
} }
/**
* Handle second level menu selection based on autoSelectFirstMenu setting:
* - When disabled: Activate first second-level menu for display only, expand third-level menu if exists
* - When enabled: Navigate to the deepest menu automatically
*/
function handleSelectMenu(key: RouteKey) { function handleSelectMenu(key: RouteKey) {
handleSelectFirstLevelMenu(key); handleSelectFirstLevelMenu(key);
if (secondLevelMenus.value.length > 0) { if (secondLevelMenus.value.length === 0) return;
handleSelectMixMenu(secondLevelMenus.value[0].routeKey);
const secondFirstMenuKey = secondLevelMenus.value[0].routeKey;
// Case 1: autoSelectFirstMenu disabled - only activate menu for display
if (!themeStore.sider.autoSelectFirstMenu) {
// Check if there are third-level menus
const hasChildren = secondLevelMenus.value.find(menu => menu.key === secondFirstMenuKey)?.children?.length;
// If there are third-level menus, expand them
if (hasChildren) {
handleSelectMixMenu(secondFirstMenuKey);
}
return;
} }
// Case 2: autoSelectFirstMenu enabled - navigate to deepest menu
activeDeepestLevelMenuKey();
setDrawerVisible(false);
} }
function handleResetActiveMenu() { function handleResetActiveMenu() {
@@ -114,7 +135,9 @@ watch(
</FirstLevelMenu> </FirstLevelMenu>
<div <div
class="relative h-full transition-width-300" class="relative h-full transition-width-300"
:style="{ width: appStore.mixSiderFixed && hasChildMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }" :style="{
width: appStore.mixSiderFixed && hasChildLevelMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px'
}"
> >
<DarkModeContainer <DarkModeContainer
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300" class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"

View File

@@ -26,7 +26,7 @@ const props = withDefaults(defineProps<Props>(), {
const visible = defineModel<boolean>('visible'); const visible = defineModel<boolean>('visible');
const { removeTab, clearTabs, clearLeftTabs, clearRightTabs } = useTabStore(); const { removeTab, clearTabs, clearLeftTabs, clearRightTabs, fixTab, unfixTab, isTabRetain, homeTab } = useTabStore();
const { SvgIconVNode } = useSvgIcon(); const { SvgIconVNode } = useSvgIcon();
type DropdownOption = { type DropdownOption = {
@@ -64,6 +64,23 @@ const options = computed(() => {
icon: SvgIconVNode({ icon: 'ant-design:line-outlined', fontSize: 18 }) icon: SvgIconVNode({ icon: 'ant-design:line-outlined', fontSize: 18 })
} }
]; ];
if (props.tabId !== homeTab?.id) {
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 { excludeKeys, disabledKeys } = props;
const result = opts.filter(opt => !excludeKeys.includes(opt.key)); const result = opts.filter(opt => !excludeKeys.includes(opt.key));
@@ -98,6 +115,12 @@ const dropdownAction: Record<App.Global.DropdownKey, () => void> = {
}, },
closeAll() { closeAll() {
clearTabs(); clearTabs();
},
pin() {
fixTab(props.tabId);
},
unpin() {
unfixTab(props.tabId);
} }
}; };

View File

@@ -12,6 +12,7 @@ const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode); const layoutMode = computed(() => themeStore.layout.mode);
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix') || layoutMode.value.includes('hybrid')); const isMixLayoutMode = computed(() => layoutMode.value.includes('mix') || layoutMode.value.includes('hybrid'));
const isHybridLayoutMode = computed(() => layoutMode.value.includes('hybrid'));
</script> </script>
<template> <template>
@@ -32,6 +33,12 @@ const isMixLayoutMode = computed(() => layoutMode.value.includes('mix') || layou
<SettingItem v-if="layoutMode === 'vertical-mix'" key="5" :label="$t('theme.layout.sider.mixChildMenuWidth')"> <SettingItem v-if="layoutMode === 'vertical-mix'" key="5" :label="$t('theme.layout.sider.mixChildMenuWidth')">
<NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" /> <NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
</SettingItem> </SettingItem>
<SettingItem v-if="isHybridLayoutMode" key="6" :label="$t('theme.layout.sider.autoSelectFirstMenu')">
<template #suffix>
<IconTooltip :desc="$t('theme.layout.sider.autoSelectFirstMenuTip')" />
</template>
<NSwitch v-model:value="themeStore.sider.autoSelectFirstMenu" />
</SettingItem>
</TransitionGroup> </TransitionGroup>
</template> </template>

View File

@@ -160,7 +160,10 @@ const local: App.I18n.Schema = {
collapsedWidth: 'Sider Collapsed Width', collapsedWidth: 'Sider Collapsed Width',
mixWidth: 'Mix Sider Width', mixWidth: 'Mix Sider Width',
mixCollapsedWidth: 'Mix Sider Collapse Width', mixCollapsedWidth: 'Mix Sider Collapse Width',
mixChildMenuWidth: 'Mix Child Menu Width' mixChildMenuWidth: 'Mix Child Menu Width',
autoSelectFirstMenu: 'Auto Select First Submenu',
autoSelectFirstMenuTip:
'When a first-level menu is clicked, the first submenu is automatically selected and navigated to the deepest level'
}, },
footer: { footer: {
title: 'Footer Settings', title: 'Footer Settings',
@@ -336,7 +339,9 @@ const local: App.I18n.Schema = {
closeOther: 'Close Other', closeOther: 'Close Other',
closeLeft: 'Close Left', closeLeft: 'Close Left',
closeRight: 'Close Right', closeRight: 'Close Right',
closeAll: 'Close All' closeAll: 'Close All',
pin: 'Pin Tab',
unpin: 'Unpin Tab'
}, },
icon: { icon: {
themeConfig: 'Theme Configuration', themeConfig: 'Theme Configuration',

View File

@@ -157,7 +157,9 @@ const local: App.I18n.Schema = {
collapsedWidth: '侧边栏折叠宽度', collapsedWidth: '侧边栏折叠宽度',
mixWidth: '混合布局侧边栏宽度', mixWidth: '混合布局侧边栏宽度',
mixCollapsedWidth: '混合布局侧边栏折叠宽度', mixCollapsedWidth: '混合布局侧边栏折叠宽度',
mixChildMenuWidth: '混合布局子菜单宽度' mixChildMenuWidth: '混合布局子菜单宽度',
autoSelectFirstMenu: '自动选择第一个子菜单',
autoSelectFirstMenuTip: '点击一级菜单时,自动选择并导航到第一个子菜单的最深层级'
}, },
footer: { footer: {
title: '底部设置', title: '底部设置',
@@ -333,7 +335,9 @@ const local: App.I18n.Schema = {
closeOther: '关闭其它', closeOther: '关闭其它',
closeLeft: '关闭左侧', closeLeft: '关闭左侧',
closeRight: '关闭右侧', closeRight: '关闭右侧',
closeAll: '关闭所有' closeAll: '关闭所有',
pin: '固定标签',
unpin: '取消固定'
}, },
icon: { icon: {
themeConfig: '主题配置', themeConfig: '主题配置',

View File

@@ -18,6 +18,7 @@ import {
getTabByRoute, getTabByRoute,
getTabIdByRoute, getTabIdByRoute,
isTabInTabs, isTabInTabs,
reorderFixedTabs,
updateTabByI18nKey, updateTabByI18nKey,
updateTabsByI18nKey updateTabsByI18nKey
} from './shared'; } from './shared';
@@ -248,6 +249,48 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
await clearTabs(excludes); 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 * Set new label of tab
* *
@@ -318,6 +361,7 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
/** All tabs */ /** All tabs */
tabs: allTabs, tabs: allTabs,
activeTabId, activeTabId,
homeTab,
initHomeTab, initHomeTab,
initTabStore, initTabStore,
addTab, addTab,
@@ -328,6 +372,8 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
clearTabs, clearTabs,
clearLeftTabs, clearLeftTabs,
clearRightTabs, clearRightTabs,
fixTab,
unfixTab,
switchRouteByTab, switchRouteByTab,
setTabLabel, setTabLabel,
resetTabLabel, resetTabLabel,

View File

@@ -198,6 +198,18 @@ export function getFixedTabIds(tabs: App.Global.Tab[]) {
return fixedTabs.map(tab => tab.id); 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 * Update tabs label
* *

View File

@@ -48,7 +48,8 @@ export const themeSettings: App.Theme.ThemeSetting = {
collapsedWidth: 64, collapsedWidth: 64,
mixWidth: 90, mixWidth: 90,
mixCollapsedWidth: 64, mixCollapsedWidth: 64,
mixChildMenuWidth: 200 mixChildMenuWidth: 200,
autoSelectFirstMenu: false
}, },
footer: { footer: {
visible: true, visible: true,

View File

@@ -96,6 +96,8 @@ declare namespace App {
mixCollapsedWidth: number; mixCollapsedWidth: number;
/** Child menu width when the layout is 'vertical-mix', 'top-hybrid-sidebar-first', or 'top-hybrid-header-first' */ /** Child menu width when the layout is 'vertical-mix', 'top-hybrid-sidebar-first', or 'top-hybrid-header-first' */
mixChildMenuWidth: number; mixChildMenuWidth: number;
/** Whether to auto select the first submenu */
autoSelectFirstMenu: boolean;
}; };
/** Footer */ /** Footer */
footer: { footer: {
@@ -282,7 +284,7 @@ declare namespace App {
type FormRule = import('naive-ui').FormItemRule; type FormRule = import('naive-ui').FormItemRule;
/** The global dropdown key */ /** The global dropdown key */
type DropdownKey = 'closeCurrent' | 'closeOther' | 'closeLeft' | 'closeRight' | 'closeAll'; type DropdownKey = 'closeCurrent' | 'closeOther' | 'closeLeft' | 'closeRight' | 'closeAll' | 'pin' | 'unpin';
} }
/** /**
@@ -429,6 +431,8 @@ declare namespace App {
mixWidth: string; mixWidth: string;
mixCollapsedWidth: string; mixCollapsedWidth: string;
mixChildMenuWidth: string; mixChildMenuWidth: string;
autoSelectFirstMenu: string;
autoSelectFirstMenuTip: string;
}; };
footer: { footer: {
title: string; title: string;