This commit is contained in:
dap
2025-05-04 17:23:32 +08:00
60 changed files with 498 additions and 210 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -35,6 +35,8 @@ interface AppPreferences {
contentCompact: ContentCompactType;
// /** 应用默认头像 */
defaultAvatar: string;
/** 默认首页地址 */
defaultHomePath: string;
// /** 开启动态标题 */
dynamicTitle: boolean;
/** 是否开启检查更新 */

View File

@@ -38,6 +38,7 @@
},
"dependencies": {
"@vben-core/composables": "workspace:*",
"@vben-core/icons": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben-core/typings": "workspace:*",

View File

@@ -5,6 +5,7 @@ import type { FormSchema, MaybeComponentProps } from '../types';
import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue';
import { CircleAlert } from '@vben-core/icons';
import {
FormControl,
FormDescription,
@@ -12,6 +13,7 @@ import {
FormItem,
FormMessage,
VbenRenderContent,
VbenTooltip,
} from '@vben-core/shadcn-ui';
import { cn, isFunction, isObject, isString } from '@vben-core/shared/utils';
@@ -354,6 +356,24 @@ onUnmounted(() => {
</template>
<!-- <slot></slot> -->
</component>
<VbenTooltip
v-if="compact && isInValid"
:delay-duration="300"
side="left"
>
<template #trigger>
<slot name="trigger">
<CircleAlert
:class="
cn(
'text-foreground/80 hover:text-foreground inline-flex size-5 cursor-pointer',
)
"
/>
</slot>
</template>
<FormMessage />
</VbenTooltip>
</slot>
</FormControl>
<!-- 自定义后缀 -->
@@ -365,7 +385,7 @@ onUnmounted(() => {
</FormDescription>
</div>
<Transition name="slide-up">
<Transition name="slide-up" v-if="!compact">
<FormMessage class="absolute bottom-1" />
</Transition>
</div>

View File

@@ -31,8 +31,8 @@ export function useVbenForm<
h(VbenUseForm, { ...props, ...attrs, formApi: extendedApi }, slots);
},
{
inheritAttrs: false,
name: 'VbenUseForm',
inheritAttrs: false,
},
);
// Add reactivity support

View File

@@ -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

View 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,
};
}

View File

@@ -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>

View File

@@ -41,6 +41,12 @@ interface MenuProps {
*/
rounded?: boolean;
/**
* @zh_CN 是否自动滚动到激活的菜单项
* @default false
*/
scrollToActive?: boolean;
/**
* @zh_CN 菜单主题
* @default dark

View File

@@ -35,7 +35,7 @@ interface Props extends DrawerProps {
const props = withDefaults(defineProps<Props>(), {
appendToMain: false,
closeIconPlacement: 'right',
destroyOnClose: true,
destroyOnClose: false,
drawerApi: undefined,
submitting: false,
zIndex: 1000,

View File

@@ -21,9 +21,7 @@ import VbenDrawer from './drawer.vue';
const USER_DRAWER_INJECT_KEY = Symbol('VBEN_DRAWER_INJECT');
const DEFAULT_DRAWER_PROPS: Partial<DrawerProps> = {
destroyOnClose: true,
};
const DEFAULT_DRAWER_PROPS: Partial<DrawerProps> = {};
export function setDefaultDrawerProps(props: Partial<DrawerProps>) {
Object.assign(DEFAULT_DRAWER_PROPS, props);
@@ -66,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;
@@ -107,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);

View File

@@ -34,7 +34,7 @@ interface Props extends ModalProps {
const props = withDefaults(defineProps<Props>(), {
appendToMain: false,
destroyOnClose: true,
destroyOnClose: false,
modalApi: undefined,
});

View File

@@ -17,9 +17,7 @@ import VbenModal from './modal.vue';
const USER_MODAL_INJECT_KEY = Symbol('VBEN_MODAL_INJECT');
const DEFAULT_MODAL_PROPS: Partial<ModalProps> = {
destroyOnClose: true,
};
const DEFAULT_MODAL_PROPS: Partial<ModalProps> = {};
export function setDefaultModalProps(props: Partial<ModalProps>) {
Object.assign(DEFAULT_MODAL_PROPS, props);
@@ -65,9 +63,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;
@@ -114,9 +113,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);

View File

@@ -21,6 +21,7 @@ interface Props extends PopoverRootProps {
class?: ClassType;
contentClass?: ClassType;
contentProps?: PopoverContentProps;
triggerClass?: ClassType;
}
const props = withDefaults(defineProps<Props>(), {});
@@ -32,6 +33,7 @@ const delegatedProps = computed(() => {
class: _cls,
contentClass: _,
contentProps: _cProps,
triggerClass: _tClass,
...delegated
} = props;
@@ -43,7 +45,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template>
<PopoverRoot v-bind="forwarded">
<PopoverTrigger>
<PopoverTrigger :class="triggerClass">
<slot name="trigger"></slot>
<PopoverContent

View File

@@ -12,7 +12,6 @@ interface Props extends TabsProps {}
defineOptions({
name: 'VbenTabsChrome',
// eslint-disable-next-line perfectionist/sort-objects
inheritAttrs: false,
});

View File

@@ -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>(), {

View File

@@ -15,5 +15,5 @@ pnpm add @vben/constants
### 使用
```ts
import { DEFAULT_HOME_PATH } from '@vben/constants';
import { LOGIN_PATH } from '@vben/constants';
```

View File

@@ -3,11 +3,6 @@
*/
export const LOGIN_PATH = '/auth/login';
/**
* @zh_CN 默认首页地址
*/
export const DEFAULT_HOME_PATH = '/analytics';
export interface LanguageOption {
label: string;
value: 'en-US' | 'zh-CN';

View File

@@ -74,7 +74,7 @@ async function generateAccessible(
}
// 生成菜单
const accessibleMenus = await generateMenus(accessibleRoutes, options.router);
const accessibleMenus = generateMenus(accessibleRoutes, options.router);
return { accessibleMenus, accessibleRoutes };
}

View File

@@ -165,13 +165,18 @@ const searchInputProps = computed(() => {
};
});
function updateCurrentSelect(v: string) {
currentSelect.value = v;
}
defineExpose({ toggleOpenState, open, close });
</script>
<template>
<VbenPopover
v-model:open="visible"
:content-props="{ align: 'end', alignOffset: -11, sideOffset: 8 }"
content-class="p-0 pt-3"
content-class="p-0 pt-3 w-full"
trigger-class="w-full"
>
<template #trigger>
<template v-if="props.type === 'input'">
@@ -183,6 +188,7 @@ defineExpose({ toggleOpenState, open, close });
role="combobox"
:aria-label="$t('ui.iconPicker.placeholder')"
aria-expanded="visible"
:[`onUpdate:${modelValueProp}`]="updateCurrentSelect"
v-bind="$attrs"
>
<template #[iconSlot]>

View File

@@ -35,6 +35,16 @@ const getZIndex = computed(() => {
return props.zIndex || calcZIndex();
});
/**
* 排除ant-message和loading:9999的z-index
*/
const zIndexExcludeClass = ['ant-message', 'loading'];
function isZIndexExcludeClass(element: Element) {
return zIndexExcludeClass.some((className) =>
element.classList.contains(className),
);
}
/**
* 获取最大的zIndex值
*/
@@ -44,7 +54,11 @@ function calcZIndex() {
[...elements].forEach((element) => {
const style = window.getComputedStyle(element);
const zIndex = style.getPropertyValue('z-index');
if (zIndex && !Number.isNaN(Number.parseInt(zIndex))) {
if (
zIndex &&
!Number.isNaN(Number.parseInt(zIndex)) &&
!isZIndexExcludeClass(element)
) {
maxZ = Math.max(maxZ, Number.parseInt(zIndex));
}
});

View File

@@ -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"

View File

@@ -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 };

View File

@@ -11,7 +11,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: {

View File

@@ -36,7 +36,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,

View File

@@ -79,14 +79,14 @@ const handleCheckboxChange = () => {
</SwitchItem>
<CheckboxItem
:items="[
{ label: '收缩按钮', value: 'collapsed' },
{ label: '固定按钮', value: 'fixed' },
{ label: $t('preferences.sidebar.buttonCollapsed'), value: 'collapsed' },
{ label: $t('preferences.sidebar.buttonFixed'), value: 'fixed' },
]"
multiple
v-model="sidebarButtons"
:on-btn-click="handleCheckboxChange"
>
按钮配置
{{ $t('preferences.sidebar.buttons') }}
</CheckboxItem>
<NumberFieldItem
v-model="sidebarWidth"

View File

@@ -24,7 +24,7 @@ withDefaults(defineProps<{ shouldOnHover?: boolean }>(), {
shouldOnHover: false,
});
function handleChange(isDark: boolean) {
function handleChange(isDark: boolean | undefined) {
updatePreferences({
theme: { mode: isDark ? 'dark' : 'light' },
});

View File

@@ -45,6 +45,9 @@
"fixed": "Fixed"
},
"sidebar": {
"buttons": "Show Buttons",
"buttonFixed": "Fixed",
"buttonCollapsed": "Collapsed",
"title": "Sidebar",
"width": "Width",
"visible": "Show Sidebar",

View File

@@ -45,6 +45,9 @@
"fixed": "固定"
},
"sidebar": {
"buttons": "显示按钮",
"buttonFixed": "固定按钮",
"buttonCollapsed": "折叠按钮",
"title": "侧边栏",
"width": "宽度",
"visible": "显示侧边栏",

View File

@@ -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([]);
});
});

View File

@@ -1,30 +1,38 @@
import type { Router, RouteRecordRaw } from 'vue-router';
import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben-core/typings';
import type {
ExRouteRecordRaw,
MenuRecordRaw,
RouteMeta,
} from '@vben-core/typings';
import { filterTree, 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,
@@ -35,24 +43,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,
@@ -63,19 +74,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);
}
export { generateMenus };