Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
This commit is contained in:
@@ -11,6 +11,7 @@ exports[`defaultPreferences immutability test > should not modify the config obj
|
||||
"compact": false,
|
||||
"contentCompact": "wide",
|
||||
"defaultAvatar": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp",
|
||||
"defaultHomePath": "/analytics",
|
||||
"dynamicTitle": true,
|
||||
"enableCheckUpdates": true,
|
||||
"enablePreferences": true,
|
||||
|
||||
@@ -11,6 +11,7 @@ const defaultPreferences: Preferences = {
|
||||
contentCompact: 'wide',
|
||||
defaultAvatar:
|
||||
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp',
|
||||
defaultHomePath: '/analytics',
|
||||
dynamicTitle: true,
|
||||
enableCheckUpdates: true,
|
||||
enablePreferences: true,
|
||||
|
||||
@@ -35,6 +35,8 @@ interface AppPreferences {
|
||||
contentCompact: ContentCompactType;
|
||||
// /** 应用默认头像 */
|
||||
defaultAvatar: string;
|
||||
/** 默认首页地址 */
|
||||
defaultHomePath: string;
|
||||
// /** 开启动态标题 */
|
||||
dynamicTitle: boolean;
|
||||
/** 是否开启检查更新 */
|
||||
|
||||
@@ -31,8 +31,8 @@ export function useVbenForm<
|
||||
h(VbenUseForm, { ...props, ...attrs, formApi: extendedApi }, slots);
|
||||
},
|
||||
{
|
||||
inheritAttrs: false,
|
||||
name: 'VbenUseForm',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
// Add reactivity support
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
createSubMenuContext,
|
||||
useMenuStyle,
|
||||
} from '../hooks';
|
||||
import { useMenuScroll } from '../hooks/use-menu-scroll';
|
||||
import { flattedChildren } from '../utils';
|
||||
import SubMenu from './sub-menu.vue';
|
||||
|
||||
@@ -44,6 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
mode: 'vertical',
|
||||
rounded: true,
|
||||
theme: 'dark',
|
||||
scrollToActive: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -206,15 +208,19 @@ function handleResize() {
|
||||
isFirstTimeRender = false;
|
||||
}
|
||||
|
||||
function getActivePaths() {
|
||||
const activeItem = activePath.value && items.value[activePath.value];
|
||||
const enableScroll = computed(
|
||||
() => props.scrollToActive && props.mode === 'vertical' && !props.collapse,
|
||||
);
|
||||
|
||||
if (!activeItem || props.mode === 'horizontal' || props.collapse) {
|
||||
return [];
|
||||
}
|
||||
const { scrollToActiveItem } = useMenuScroll(activePath, {
|
||||
enable: enableScroll,
|
||||
delay: 320,
|
||||
});
|
||||
|
||||
return activeItem.parentPaths;
|
||||
}
|
||||
// 监听 activePath 变化,自动滚动到激活项
|
||||
watch(activePath, () => {
|
||||
scrollToActiveItem();
|
||||
});
|
||||
|
||||
// 默认展开菜单
|
||||
function initMenu() {
|
||||
@@ -318,6 +324,16 @@ function removeSubMenu(subMenu: MenuItemRegistered) {
|
||||
function removeMenuItem(item: MenuItemRegistered) {
|
||||
Reflect.deleteProperty(items.value, item.path);
|
||||
}
|
||||
|
||||
function getActivePaths() {
|
||||
const activeItem = activePath.value && items.value[activePath.value];
|
||||
|
||||
if (!activeItem || props.mode === 'horizontal' || props.collapse) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return activeItem.parentPaths;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ul
|
||||
|
||||
46
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts
Normal file
46
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { watch } from 'vue';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
|
||||
interface UseMenuScrollOptions {
|
||||
delay?: number;
|
||||
enable?: boolean | Ref<boolean>;
|
||||
}
|
||||
|
||||
export function useMenuScroll(
|
||||
activePath: Ref<string | undefined>,
|
||||
options: UseMenuScrollOptions = {},
|
||||
) {
|
||||
const { enable = true, delay = 320 } = options;
|
||||
|
||||
function scrollToActiveItem() {
|
||||
const isEnabled = typeof enable === 'boolean' ? enable : enable.value;
|
||||
if (!isEnabled) return;
|
||||
|
||||
const activeElement = document.querySelector(
|
||||
`aside li[role=menuitem].is-active`,
|
||||
);
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedScroll = useDebounceFn(scrollToActiveItem, delay);
|
||||
|
||||
watch(activePath, () => {
|
||||
const isEnabled = typeof enable === 'boolean' ? enable : enable.value;
|
||||
if (!isEnabled) return;
|
||||
|
||||
debouncedScroll();
|
||||
});
|
||||
|
||||
return {
|
||||
scrollToActiveItem,
|
||||
};
|
||||
}
|
||||
@@ -18,15 +18,9 @@ defineOptions({
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
collapse: false,
|
||||
// theme: 'dark',
|
||||
});
|
||||
|
||||
const forward = useForwardProps(props);
|
||||
|
||||
// const emit = defineEmits<{
|
||||
// 'update:openKeys': [key: Key[]];
|
||||
// 'update:selectedKeys': [key: Key[]];
|
||||
// }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -42,6 +42,12 @@ interface MenuProps {
|
||||
*/
|
||||
rounded?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 是否自动滚动到激活的菜单项
|
||||
* @default false
|
||||
*/
|
||||
scrollToActive?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 菜单主题
|
||||
* @default dark
|
||||
|
||||
@@ -64,9 +64,10 @@ export function useVbenDrawer<
|
||||
slots,
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
{
|
||||
inheritAttrs: false,
|
||||
name: 'VbenParentDrawer',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
return [Drawer, extendedApi as ExtendedDrawerApi] as const;
|
||||
@@ -105,9 +106,10 @@ export function useVbenDrawer<
|
||||
return () =>
|
||||
h(VbenDrawer, { ...props, ...attrs, drawerApi: extendedApi }, slots);
|
||||
},
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
{
|
||||
inheritAttrs: false,
|
||||
name: 'VbenDrawer',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
injectData.extendApi?.(extendedApi);
|
||||
|
||||
@@ -49,9 +49,10 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
|
||||
slots,
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
{
|
||||
inheritAttrs: false,
|
||||
name: 'VbenParentModal',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
return [Modal, extendedApi as ExtendedModalApi] as const;
|
||||
@@ -98,9 +99,10 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
|
||||
slots,
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
{
|
||||
inheritAttrs: false,
|
||||
name: 'VbenModal',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
injectData.extendApi?.(extendedApi);
|
||||
|
||||
@@ -12,7 +12,6 @@ interface Props extends TabsProps {}
|
||||
|
||||
defineOptions({
|
||||
name: 'VbenTabsChrome',
|
||||
// eslint-disable-next-line perfectionist/sort-objects
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ interface Props extends TabsProps {}
|
||||
|
||||
defineOptions({
|
||||
name: 'VbenTabs',
|
||||
// eslint-disable-next-line perfectionist/sort-objects
|
||||
|
||||
inheritAttrs: false,
|
||||
});
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
@@ -15,5 +15,5 @@ pnpm add @vben/constants
|
||||
### 使用
|
||||
|
||||
```ts
|
||||
import { DEFAULT_HOME_PATH } from '@vben/constants';
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
```
|
||||
|
||||
@@ -3,11 +3,6 @@
|
||||
*/
|
||||
export const LOGIN_PATH = '/auth/login';
|
||||
|
||||
/**
|
||||
* @zh_CN 默认首页地址
|
||||
*/
|
||||
export const DEFAULT_HOME_PATH = '/workspace';
|
||||
|
||||
export interface LanguageOption {
|
||||
label: string;
|
||||
value: 'en-US' | 'zh-CN';
|
||||
|
||||
@@ -66,7 +66,7 @@ async function generateAccessible(
|
||||
}
|
||||
|
||||
// 生成菜单
|
||||
const accessibleMenus = await generateMenus(accessibleRoutes, options.router);
|
||||
const accessibleMenus = generateMenus(accessibleRoutes, options.router);
|
||||
|
||||
return { accessibleMenus, accessibleRoutes };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { VNode } from 'vue';
|
||||
|
||||
import { computed, ref, watch, watchEffect } from 'vue';
|
||||
import { computed, ref, useAttrs, watch, watchEffect } from 'vue';
|
||||
|
||||
import { usePagination } from '@vben/hooks';
|
||||
import { EmptyIcon, Grip, listIcons } from '@vben/icons';
|
||||
@@ -22,8 +22,9 @@ import {
|
||||
VbenIconButton,
|
||||
VbenPopover,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
import { refDebounced, watchDebounced } from '@vueuse/core';
|
||||
import { objectOmit, refDebounced, watchDebounced } from '@vueuse/core';
|
||||
|
||||
import { fetchIconsData } from './icons';
|
||||
|
||||
@@ -64,6 +65,8 @@ const emit = defineEmits<{
|
||||
change: [string];
|
||||
}>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const modelValue = defineModel({ default: '', type: String });
|
||||
|
||||
const visible = ref(false);
|
||||
@@ -167,7 +170,14 @@ const searchInputProps = computed(() => {
|
||||
|
||||
function updateCurrentSelect(v: string) {
|
||||
currentSelect.value = v;
|
||||
const eventKey = `onUpdate:${props.modelValueProp}`;
|
||||
if (attrs[eventKey] && isFunction(attrs[eventKey])) {
|
||||
attrs[eventKey](v);
|
||||
}
|
||||
}
|
||||
const getBindAttrs = computed(() => {
|
||||
return objectOmit(attrs, [`onUpdate:${props.modelValueProp}`]);
|
||||
});
|
||||
|
||||
defineExpose({ toggleOpenState, open, close });
|
||||
</script>
|
||||
@@ -189,7 +199,7 @@ defineExpose({ toggleOpenState, open, close });
|
||||
:aria-label="$t('ui.iconPicker.placeholder')"
|
||||
aria-expanded="visible"
|
||||
:[`onUpdate:${modelValueProp}`]="updateCurrentSelect"
|
||||
v-bind="$attrs"
|
||||
v-bind="getBindAttrs"
|
||||
>
|
||||
<template #[iconSlot]>
|
||||
<VbenIcon
|
||||
|
||||
@@ -37,6 +37,7 @@ function handleMenuOpen(key: string, path: string[]) {
|
||||
:menus="menus"
|
||||
:mode="mode"
|
||||
:rounded="rounded"
|
||||
scroll-to-active
|
||||
:theme="theme"
|
||||
@open="handleMenuOpen"
|
||||
@select="handleMenuSelect"
|
||||
|
||||
@@ -6,39 +6,55 @@ import { isHttpUrl, openRouteInNewWindow, openWindow } from '@vben/utils';
|
||||
|
||||
function useNavigation() {
|
||||
const router = useRouter();
|
||||
const routes = router.getRoutes();
|
||||
|
||||
const routeMetaMap = new Map<string, RouteRecordNormalized>();
|
||||
|
||||
routes.forEach((route) => {
|
||||
routeMetaMap.set(route.path, route);
|
||||
// 初始化路由映射
|
||||
const initRouteMetaMap = () => {
|
||||
const routes = router.getRoutes();
|
||||
routes.forEach((route) => {
|
||||
routeMetaMap.set(route.path, route);
|
||||
});
|
||||
};
|
||||
|
||||
initRouteMetaMap();
|
||||
|
||||
// 监听路由变化
|
||||
router.afterEach(() => {
|
||||
initRouteMetaMap();
|
||||
});
|
||||
|
||||
const navigation = async (path: string) => {
|
||||
const route = routeMetaMap.get(path);
|
||||
const { openInNewWindow = false, query = {} } = route?.meta ?? {};
|
||||
// 检查是否应该在新窗口打开
|
||||
const shouldOpenInNewWindow = (path: string): boolean => {
|
||||
if (isHttpUrl(path)) {
|
||||
openWindow(path, { target: '_blank' });
|
||||
} else if (openInNewWindow) {
|
||||
openRouteInNewWindow(path);
|
||||
} else {
|
||||
await router.push({
|
||||
path,
|
||||
query,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const route = routeMetaMap.get(path);
|
||||
return route?.meta?.openInNewWindow ?? false;
|
||||
};
|
||||
|
||||
const navigation = async (path: string) => {
|
||||
try {
|
||||
const route = routeMetaMap.get(path);
|
||||
const { openInNewWindow = false, query = {} } = route?.meta ?? {};
|
||||
|
||||
if (isHttpUrl(path)) {
|
||||
openWindow(path, { target: '_blank' });
|
||||
} else if (openInNewWindow) {
|
||||
openRouteInNewWindow(path);
|
||||
} else {
|
||||
await router.push({
|
||||
path,
|
||||
query,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Navigation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const willOpenedByWindow = (path: string) => {
|
||||
const route = routeMetaMap.get(path);
|
||||
const { openInNewWindow = false } = route?.meta ?? {};
|
||||
if (isHttpUrl(path)) {
|
||||
return true;
|
||||
} else if (openInNewWindow) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return shouldOpenInNewWindow(path);
|
||||
};
|
||||
|
||||
return { navigation, willOpenedByWindow };
|
||||
|
||||
@@ -12,7 +12,8 @@ defineOptions({
|
||||
name: 'LanguageToggle',
|
||||
});
|
||||
|
||||
async function handleUpdate(value: string) {
|
||||
async function handleUpdate(value: string | undefined) {
|
||||
if (!value) return;
|
||||
const locale = value as SupportedLanguagesType;
|
||||
updatePreferences({
|
||||
app: {
|
||||
|
||||
@@ -39,7 +39,8 @@ const menus = computed((): VbenDropdownMenuItem[] => [
|
||||
|
||||
const { authPanelCenter, authPanelLeft, authPanelRight } = usePreferences();
|
||||
|
||||
function handleUpdate(value: string) {
|
||||
function handleUpdate(value: string | undefined) {
|
||||
if (!value) return;
|
||||
updatePreferences({
|
||||
app: {
|
||||
authPageLayout: value as AuthPageLayoutType,
|
||||
|
||||
@@ -25,7 +25,7 @@ withDefaults(defineProps<{ shouldOnHover?: boolean }>(), {
|
||||
shouldOnHover: false,
|
||||
});
|
||||
|
||||
function handleChange(isDark: boolean) {
|
||||
function handleChange(isDark: boolean | undefined) {
|
||||
updatePreferences({
|
||||
theme: { mode: isDark ? 'dark' : 'light' },
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('generateMenus', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const menus = await generateMenus(mockRoutes, mockRouter as any);
|
||||
const menus = generateMenus(mockRoutes, mockRouter as any);
|
||||
expect(menus).toEqual(expectedMenus);
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('generateMenus', () => {
|
||||
},
|
||||
] as RouteRecordRaw[];
|
||||
|
||||
const menus = await generateMenus(mockRoutesWithMeta, mockRouter as any);
|
||||
const menus = generateMenus(mockRoutesWithMeta, mockRouter as any);
|
||||
expect(menus).toEqual([
|
||||
{
|
||||
badge: undefined,
|
||||
@@ -109,7 +109,7 @@ describe('generateMenus', () => {
|
||||
},
|
||||
] as RouteRecordRaw[];
|
||||
|
||||
const menus = await generateMenus(mockRoutesWithParams, mockRouter as any);
|
||||
const menus = generateMenus(mockRoutesWithParams, mockRouter as any);
|
||||
expect(menus).toEqual([
|
||||
{
|
||||
badge: undefined,
|
||||
@@ -141,10 +141,7 @@ describe('generateMenus', () => {
|
||||
},
|
||||
] as RouteRecordRaw[];
|
||||
|
||||
const menus = await generateMenus(
|
||||
mockRoutesWithRedirect,
|
||||
mockRouter as any,
|
||||
);
|
||||
const menus = generateMenus(mockRoutesWithRedirect, mockRouter as any);
|
||||
expect(menus).toEqual([
|
||||
// Assuming your generateMenus function excludes redirect routes from the menu
|
||||
{
|
||||
@@ -195,7 +192,7 @@ describe('generateMenus', () => {
|
||||
});
|
||||
|
||||
it('should generate menu list with correct order', async () => {
|
||||
const menus = await generateMenus(routes, router);
|
||||
const menus = generateMenus(routes, router);
|
||||
const expectedMenus = [
|
||||
{
|
||||
badge: undefined,
|
||||
@@ -230,7 +227,7 @@ describe('generateMenus', () => {
|
||||
|
||||
it('should handle empty routes', async () => {
|
||||
const emptyRoutes: any[] = [];
|
||||
const menus = await generateMenus(emptyRoutes, router);
|
||||
const menus = generateMenus(emptyRoutes, router);
|
||||
expect(menus).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AppRouteRecordRaw,
|
||||
ExRouteRecordRaw,
|
||||
MenuRecordRaw,
|
||||
RouteMeta,
|
||||
RouteRecordStringComponent,
|
||||
} from '@vben-core/typings';
|
||||
|
||||
@@ -11,25 +12,29 @@ import { filterTree, isHttpUrl, mapTree } from '@vben-core/shared/utils';
|
||||
|
||||
/**
|
||||
* 根据 routes 生成菜单列表
|
||||
* @param routes
|
||||
* @param routes - 路由配置列表
|
||||
* @param router - Vue Router 实例
|
||||
* @returns 生成的菜单列表
|
||||
*/
|
||||
async function generateMenus(
|
||||
function generateMenus(
|
||||
routes: RouteRecordRaw[],
|
||||
router: Router,
|
||||
): Promise<MenuRecordRaw[]> {
|
||||
): MenuRecordRaw[] {
|
||||
// 将路由列表转换为一个以 name 为键的对象映射
|
||||
// 获取所有router最终的path及name
|
||||
const finalRoutesMap: { [key: string]: string } = Object.fromEntries(
|
||||
router.getRoutes().map(({ name, path }) => [name, path]),
|
||||
);
|
||||
|
||||
let menus = mapTree<ExRouteRecordRaw, MenuRecordRaw>(routes, (route) => {
|
||||
// 路由表的路径写法有多种,这里从router获取到最终的path并赋值
|
||||
const path = finalRoutesMap[route.name as string] ?? route.path;
|
||||
// 获取最终的路由路径
|
||||
const path = finalRoutesMap[route.name as string] ?? route.path ?? '';
|
||||
|
||||
// 转换为菜单结构
|
||||
// const path = matchRoute?.path ?? route.path;
|
||||
const { meta, name: routeName, redirect, children } = route;
|
||||
const {
|
||||
meta = {} as RouteMeta,
|
||||
name: routeName,
|
||||
redirect,
|
||||
children = [],
|
||||
} = route;
|
||||
const {
|
||||
activeIcon,
|
||||
badge,
|
||||
@@ -40,24 +45,27 @@ async function generateMenus(
|
||||
link,
|
||||
order,
|
||||
title = '',
|
||||
} = meta || {};
|
||||
} = meta;
|
||||
|
||||
// 确保菜单名称不为空
|
||||
const name = (title || routeName || '') as string;
|
||||
|
||||
// 隐藏子菜单
|
||||
// 处理子菜单
|
||||
const resultChildren = hideChildrenInMenu
|
||||
? []
|
||||
: (children as MenuRecordRaw[]);
|
||||
|
||||
// 将菜单的所有父级和父级菜单记录到菜单项内
|
||||
if (resultChildren && resultChildren.length > 0) {
|
||||
// 设置子菜单的父子关系
|
||||
if (resultChildren.length > 0) {
|
||||
resultChildren.forEach((child) => {
|
||||
child.parents = [...(route.parents || []), path];
|
||||
child.parents = [...(route.parents ?? []), path];
|
||||
child.parent = path;
|
||||
});
|
||||
}
|
||||
// 隐藏子菜单
|
||||
|
||||
// 确定最终路径
|
||||
const resultPath = hideChildrenInMenu ? redirect || path : link || path;
|
||||
|
||||
return {
|
||||
activeIcon,
|
||||
badge,
|
||||
@@ -68,19 +76,17 @@ async function generateMenus(
|
||||
order,
|
||||
parent: route.parent,
|
||||
parents: route.parents,
|
||||
path: resultPath as string,
|
||||
show: !route?.meta?.hideInMenu,
|
||||
children: resultChildren || [],
|
||||
path: resultPath,
|
||||
show: !meta.hideInMenu,
|
||||
children: resultChildren,
|
||||
};
|
||||
});
|
||||
|
||||
// 对菜单进行排序,避免order=0时被替换成999的问题
|
||||
menus = menus.sort((a, b) => (a?.order ?? 999) - (b?.order ?? 999));
|
||||
|
||||
const finalMenus = filterTree(menus, (menu) => {
|
||||
return !!menu.show;
|
||||
});
|
||||
return finalMenus;
|
||||
// 过滤掉隐藏的菜单项
|
||||
return filterTree(menus, (menu) => !!menu.show);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user