From 49bcc1a38f3bf0dad6719e3fbc4df68a3cc48f38 Mon Sep 17 00:00:00 2001 From: gchust Date: Sun, 29 Jun 2025 16:06:24 +0800 Subject: [PATCH] fix: menus item not shown --- .../components/subModel/AddActionButton.tsx | 2 +- .../src/components/subModel/LazyDropdown.tsx | 449 +++++++++++------- 2 files changed, 273 insertions(+), 178 deletions(-) diff --git a/packages/core/flow-engine/src/components/subModel/AddActionButton.tsx b/packages/core/flow-engine/src/components/subModel/AddActionButton.tsx index deba0727ca..4187b19201 100644 --- a/packages/core/flow-engine/src/components/subModel/AddActionButton.tsx +++ b/packages/core/flow-engine/src/components/subModel/AddActionButton.tsx @@ -72,7 +72,7 @@ const AddActionButtonCore: React.FC = ({ onSubModelAdded, }) => { const defaultChildren = useMemo(() => { - return }>{model.translate('Configure fields')}; + return }>{model.translate('Configure actions')}; }, [model]); const allActionsItems = useMemo(() => { diff --git a/packages/core/flow-engine/src/components/subModel/LazyDropdown.tsx b/packages/core/flow-engine/src/components/subModel/LazyDropdown.tsx index 49fb6e0510..f3117ac730 100644 --- a/packages/core/flow-engine/src/components/subModel/LazyDropdown.tsx +++ b/packages/core/flow-engine/src/components/subModel/LazyDropdown.tsx @@ -7,63 +7,21 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Dropdown, DropdownProps, Input, Menu, Spin, Empty, InputProps } from 'antd'; +import { Dropdown, DropdownProps, Input, Spin, Empty, InputProps } from 'antd'; import React, { useEffect, useState, useMemo, useRef, FC } from 'react'; +import { css } from '@emotion/css'; import { useFlowModel } from '../../hooks'; -/** - * 通过鼠标的位置计算出最佳的 dropdown 的高度,以尽量避免出现滚动条 - * @param deps 类似于 useEffect 的第二个参数,如果不传则默认为 [] - */ -const useNiceDropdownMaxHeight = (deps: any[] = []) => { - const heightRef = useRef(0); +// ==================== Types ==================== - useEffect(() => { - const handler = (e: MouseEvent) => { - const { clientY } = e; - const h = Math.max(clientY, window.innerHeight - clientY); - heightRef.current = h; - }; - - window.addEventListener('mousemove', handler); - - return () => { - window.removeEventListener('mousemove', handler); - }; - }, []); - - return useMemo(() => heightRef.current - 40, deps); -}; - -/** - * 使搜索输入框在显示下拉菜单时自动聚焦,提高用户体验。 - * - * 注意:Input 组件的 autofocus 属性只会在第一次显示下拉菜单时有效,所以这里没有使用它。 - * @param props - * @returns - */ -const SearchInputWithAutoFocus: FC = (props) => { - const inputRef = useRef(null); - - useEffect(() => { - if (inputRef.current && props.visible) { - console.log('Focus input:', inputRef.current.input); - inputRef.current.input.focus(); - } - }, [props.visible]); - - return ; -}; - -// 菜单项类型定义 export type Item = { key?: string; type?: 'group' | 'divider'; label?: React.ReactNode; children?: Item[] | (() => Item[] | Promise); - searchable?: boolean; // group 是否支持搜索 - searchPlaceholder?: string; // 搜索占位符 - [key: string]: any; // 允许其他属性 + searchable?: boolean; + searchPlaceholder?: string; + [key: string]: any; }; export type ItemsType = Item[] | (() => Item[] | Promise); @@ -72,30 +30,24 @@ interface LazyDropdownMenuProps extends Omit { items: ItemsType; } -const LazyDropdown: React.FC & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => { - const model = useFlowModel(); +// ==================== Custom Hooks ==================== + +/** + * 计算合适的下拉菜单最大高度 + */ +const useNiceDropdownMaxHeight = () => { + return useMemo(() => { + const maxHeight = Math.min(window.innerHeight * 0.6, 400); + return maxHeight; + }, []); +}; + +/** + * 处理异步菜单项加载的逻辑 + */ +const useAsyncMenuItems = (menuVisible: boolean, rootItems: Item[]) => { const [loadedChildren, setLoadedChildren] = useState>({}); const [loadingKeys, setLoadingKeys] = useState>(new Set()); - const [menuVisible, setMenuVisible] = useState(false); - const [openKeys, setOpenKeys] = useState>(new Set()); - const [rootItems, setRootItems] = useState([]); - const [rootLoading, setRootLoading] = useState(false); - const [searchValues, setSearchValues] = useState>({}); - const dropdownMaxHeight = useNiceDropdownMaxHeight([menuVisible]); - const [isSearching, setIsSearching] = useState(false); - const searchTimeoutRef = useRef(null); - const t = model.translate; - - // 清理定时器,避免内存泄露 - useEffect(() => { - return () => { - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - }; - }, []); - - const getKeyPath = (path: string[], key: string) => [...path, key].join('/'); const handleLoadChildren = async (keyPath: string, loader: () => Item[] | Promise) => { if (loadedChildren[keyPath] || loadingKeys.has(keyPath)) return; @@ -131,6 +83,193 @@ const LazyDropdown: React.FC & { menu: LazyDropdownM return result; }; + // 自动加载所有 group 的异步 children + useEffect(() => { + if (!menuVisible || !rootItems.length) return; + const asyncGroups = collectAsyncGroups(rootItems); + for (const [keyPath, loader] of asyncGroups) { + if (!loadedChildren[keyPath] && !loadingKeys.has(keyPath)) { + handleLoadChildren(keyPath, loader); + } + } + }, [menuVisible, rootItems]); + + return { + loadedChildren, + loadingKeys, + handleLoadChildren, + }; +}; + +/** + * 处理菜单搜索状态 + */ +const useMenuSearch = () => { + const [searchValues, setSearchValues] = useState>({}); + const [isSearching, setIsSearching] = useState(false); + const searchTimeoutRef = useRef(null); + + const updateSearchValue = (key: string, value: string) => { + setIsSearching(true); + setSearchValues((prev) => ({ ...prev, [key]: value })); + + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + searchTimeoutRef.current = setTimeout(() => setIsSearching(false), 300); + }; + + useEffect(() => { + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, []); + + return { + searchValues, + isSearching, + updateSearchValue, + }; +}; + +/** + * 处理动态子菜单样式 + */ +const useSubmenuStyles = (menuVisible: boolean, dropdownMaxHeight: number) => { + useEffect(() => { + if (!menuVisible || dropdownMaxHeight <= 0) return; + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement; + + if (element.classList?.contains('ant-dropdown-menu-submenu-popup')) { + requestAnimationFrame(() => { + const menuContainer = element.querySelector('.ant-dropdown-menu'); + if (menuContainer) { + const container = menuContainer as HTMLElement; + container.style.maxHeight = `${dropdownMaxHeight}px`; + container.style.overflowY = 'auto'; + container.classList.add('submenu-ready'); + } + }); + } + } + }); + }); + }); + + observer.observe(document.body, { + childList: true, + subtree: false, + }); + + const intervalId = setInterval(() => { + const submenuPopups = document.querySelectorAll('.ant-dropdown-menu-submenu-popup .ant-dropdown-menu'); + submenuPopups.forEach((menu) => { + const container = menu as HTMLElement; + const rect = container.getBoundingClientRect(); + + if (rect.width > 0 && rect.height > 0 && !container.classList.contains('submenu-ready')) { + container.style.maxHeight = `${dropdownMaxHeight}px`; + container.style.overflowY = 'auto'; + container.classList.add('submenu-ready'); + } + }); + }, 200); + + return () => { + observer.disconnect(); + clearInterval(intervalId); + }; + }, [menuVisible, dropdownMaxHeight]); +}; + +// ==================== Components ==================== + +/** + * 使搜索输入框在显示下拉菜单时自动聚焦 + */ +const SearchInputWithAutoFocus: FC = (props) => { + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current && props.visible) { + inputRef.current.input.focus(); + } + }, [props.visible]); + + return ; +}; + +// ==================== Utility Functions ==================== + +const getKeyPath = (path: string[], key: string) => [...path, key].join('/'); + +const createSearchItem = ( + item: Item, + searchKey: string, + currentSearchValue: string, + menuVisible: boolean, + t: (key: string) => string, + updateSearchValue: (key: string, value: string) => void, +) => ({ + key: `${item.key}-search`, + label: ( +
+ { + e.stopPropagation(); + updateSearchValue(searchKey, e.target.value); + }} + onClick={(e) => e.stopPropagation()} + size="small" + style={{ + width: '100%', + paddingLeft: 0, + paddingRight: 0, + }} + /> +
+ ), + disabled: true, +}); + +const createEmptyItem = (itemKey: string, t: (key: string) => string) => ({ + key: `${itemKey}-empty`, + label: ( +
+ +
+ ), + disabled: true, +}); + +// ==================== Main Component ==================== + +const LazyDropdown: React.FC & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => { + const model = useFlowModel(); + const [menuVisible, setMenuVisible] = useState(false); + const [openKeys, setOpenKeys] = useState>(new Set()); + const [rootItems, setRootItems] = useState([]); + const [rootLoading, setRootLoading] = useState(false); + const dropdownMaxHeight = useNiceDropdownMaxHeight(); + const t = model.translate; + + // 使用自定义 hooks + const { loadedChildren, loadingKeys, handleLoadChildren } = useAsyncMenuItems(menuVisible, rootItems); + const { searchValues, isSearching, updateSearchValue } = useMenuSearch(); + useSubmenuStyles(menuVisible, dropdownMaxHeight); + // 加载根 items,支持同步/异步函数 useEffect(() => { const loadRootItems = async () => { @@ -154,17 +293,6 @@ const LazyDropdown: React.FC & { menu: LazyDropdownM } }, [menu.items, menuVisible]); - // 自动加载所有 group 的异步 children - useEffect(() => { - if (!menuVisible || !rootItems.length) return; - const asyncGroups = collectAsyncGroups(rootItems); - for (const [keyPath, loader] of asyncGroups) { - if (!loadedChildren[keyPath] && !loadingKeys.has(keyPath)) { - handleLoadChildren(keyPath, loader); - } - } - }, [menuVisible, rootItems]); - // 递归解析 items,支持 children 为同步/异步函数 const resolveItems = (items: Item[], path: string[] = []): any[] => { return items.map((item) => { @@ -217,61 +345,11 @@ const LazyDropdown: React.FC & { menu: LazyDropdownM // 重新解析过滤后的 children const resolvedFilteredChildren = resolveItems(filteredChildren, [...path, item.key]); - // 创建搜索框项 - const searchItem = { - key: `${item.key}-search`, - label: ( -
- { - e.stopPropagation(); - setIsSearching(true); - setSearchValues((prev) => ({ - ...prev, - [searchKey]: e.target.value, - })); - // 清理之前的定时器 - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - // 搜索完成后重置搜索状态 - searchTimeoutRef.current = setTimeout(() => setIsSearching(false), 300); - }} - onClick={(e) => e.stopPropagation()} - size="small" - style={{ - width: '100%', - paddingLeft: 0, - paddingRight: 0, - }} - /> -
- ), - disabled: true, // 搜索项不可点击 - }; + const searchItem = createSearchItem(item, searchKey, currentSearchValue, menuVisible, t, updateSearchValue); + const dividerItem = { key: `${item.key}-search-divider`, type: 'divider' as const }; - // 创建分割线项 - const dividerItem = { - key: `${item.key}-search-divider`, - type: 'divider' as const, - }; - - // 如果搜索后没有结果,显示 Empty if (currentSearchValue && resolvedFilteredChildren.length === 0) { - const emptyItem = { - key: `${item.key}-empty`, - label: ( -
- -
- ), - disabled: true, - }; + const emptyItem = createEmptyItem(item.key, t); groupChildren = [searchItem, dividerItem, emptyItem]; } else { groupChildren = [searchItem, dividerItem, ...resolvedFilteredChildren]; @@ -293,14 +371,14 @@ const LazyDropdown: React.FC & { menu: LazyDropdownM return { key: item.key, label: typeof item.label === 'string' ? t(item.label) : item.label, - onClick: (info) => { + onClick: (info: any) => { if (children) { return; } menu.onClick?.({ ...info, originalItem: item, - } as any); // 👈 强制扩展类型 + } as any); }, onMouseEnter: () => { setOpenKeys((prev) => { @@ -314,57 +392,74 @@ const LazyDropdown: React.FC & { menu: LazyDropdownM children && children.length > 0 ? resolveItems(children, [...path, item.key]) : children && children.length === 0 - ? [ - { - key: `${keyPath}-empty`, - label: ( -
- -
- ), - disabled: true, - }, - ] + ? [createEmptyItem(keyPath, t)] : undefined, }; }); }; + // 创建优化的 CSS 样式 + const overlayClassName = useMemo(() => { + return css` + .ant-dropdown-menu { + max-height: ${dropdownMaxHeight}px; + overflow-y: auto; + } + + /* 子菜单初始状态:透明且准备好样式 */ + .ant-dropdown-menu-submenu-popup .ant-dropdown-menu { + opacity: 0; + max-height: ${dropdownMaxHeight}px !important; + overflow-y: auto !important; + transition: opacity 0.15s ease-in-out; + } + + /* 样式设置完成后显示 */ + .ant-dropdown-menu-submenu-popup .ant-dropdown-menu.submenu-ready { + opacity: 1; + } + + /* 针对动态加载的深层菜单 */ + .ant-dropdown-menu-submenu-popup .ant-dropdown-menu-submenu-popup .ant-dropdown-menu { + opacity: 0; + max-height: ${dropdownMaxHeight}px !important; + overflow-y: auto !important; + transition: opacity 0.15s ease-in-out; + } + + .ant-dropdown-menu-submenu-popup .ant-dropdown-menu-submenu-popup .ant-dropdown-menu.submenu-ready { + opacity: 1; + } + `; + }, [dropdownMaxHeight]); + return ( - rootLoading && rootItems.length === 0 ? ( - , - disabled: true, - }, - ]} - style={{ - maxHeight: dropdownMaxHeight, - overflowY: 'auto', - }} - /> - ) : ( - {}} - items={resolveItems(rootItems)} - style={{ - maxHeight: dropdownMaxHeight, - overflowY: 'auto', - ...menu?.style, - }} - /> - ) - } + destroyPopupOnHide + overlayClassName={overlayClassName} + placement="bottomLeft" + menu={{ + ...menu, + items: + rootLoading && rootItems.length === 0 + ? [ + { + key: 'root-loading', + label: , + disabled: true, + }, + ] + : resolveItems(rootItems), + onClick: () => {}, + style: { + maxHeight: dropdownMaxHeight, + overflowY: 'auto', + ...menu?.style, + }, + }} onOpenChange={(visible) => { - // 如果正在搜索且菜单要关闭,阻止关闭 if (!visible && isSearching) { return; }