From 6df646153f5e4470295d23b13128e357d10e420e Mon Sep 17 00:00:00 2001 From: lijisanxiong <1518062161@qq.com> Date: Fri, 27 Jun 2025 17:02:36 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=96=B0=E5=A2=9E=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E8=8F=9C=E5=8D=95=E6=8C=89=E9=92=AE=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../bottom-side-menu/bottom-side-menu.scss | 9 + .../common-extend-menu/common-extend-menu.tsx | 32 +- .../extend-button-menu.scss | 326 +++++++++ .../extend-button-menu/extend-button-menu.tsx | 299 ++++++++ .../extend-menu-base/extend-menu-base.util.ts | 661 ++++++++++++++++++ .../left-side-menu/left-side-menu.scss | 9 + .../right-side-menu/right-side-menu.scss | 9 + .../top-side-menu/top-side-menu.scss | 9 + 9 files changed, 1352 insertions(+), 3 deletions(-) create mode 100644 src/panel-component/app-extend-menu/extend-menu-base/extend-button-menu/extend-button-menu.scss create mode 100644 src/panel-component/app-extend-menu/extend-menu-base/extend-button-menu/extend-button-menu.tsx create mode 100644 src/panel-component/app-extend-menu/extend-menu-base/extend-menu-base.util.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 550302f4c..2499e1903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 编辑器无值时支持placeHolder绘制,适配无值显示模式全局参数(emptyShowMode) - 向导部件支持步骤配置标题样式及图标 - 树部件识别加载更多和分页数量配置 +- 新增扩展菜单按钮模式 ## [0.7.41-alpha.6] - 2025-06-24 diff --git a/src/panel-component/app-extend-menu/bottom-side-menu/bottom-side-menu.scss b/src/panel-component/app-extend-menu/bottom-side-menu/bottom-side-menu.scss index cc63b04d7..418668a02 100644 --- a/src/panel-component/app-extend-menu/bottom-side-menu/bottom-side-menu.scss +++ b/src/panel-component/app-extend-menu/bottom-side-menu/bottom-side-menu.scss @@ -1,4 +1,13 @@ @include b('bottom-side-menu') { width: 100%; height: 100%; +} + +@include b('col') { + @include m('self-align') { + &>.#{bem('bottom-side-menu')} { + width: 100%; + height: 100%; + } + } } \ No newline at end of file diff --git a/src/panel-component/app-extend-menu/extend-menu-base/common-extend-menu/common-extend-menu.tsx b/src/panel-component/app-extend-menu/extend-menu-base/common-extend-menu/common-extend-menu.tsx index 6c3edae4b..e9c0e9d1c 100644 --- a/src/panel-component/app-extend-menu/extend-menu-base/common-extend-menu/common-extend-menu.tsx +++ b/src/panel-component/app-extend-menu/extend-menu-base/common-extend-menu/common-extend-menu.tsx @@ -5,6 +5,7 @@ import { IAppMenuItem, ILayout } from '@ibiz/model-core'; import { IAppMenuItemProvider } from '@ibiz-template/runtime'; import { useNamespace } from '@ibiz-template/vue3-util'; import './common-extend-menu.scss'; +import { ExtendButtonMenu } from '../extend-button-menu/extend-button-menu'; /** * 扩展基础菜单 @@ -14,6 +15,9 @@ import './common-extend-menu.scss'; */ export const CommonExtendMenu = defineComponent({ name: 'IBizCommonExtendMenu', + components: { + ExtendButtonMenu, + }, props: { /** * @description 绘制模式,'BUTTON' | 'MENU': 按钮态(仅识别一层) | 常规菜单态 @@ -74,12 +78,34 @@ export const CommonExtendMenu = defineComponent({ */ menuItemClick: (item: IAppMenuItem, event: MouseEvent) => true, }, - setup(props) { + setup(props, { emit }) { const ns = useNamespace('common-extend-menu'); - return { ns }; + const handleMenuItemClick = ( + menuItem: IAppMenuItem, + event?: MouseEvent, + ) => { + if (!menuItem || menuItem?.itemType === 'RAWITEM') { + return; + } + emit('menuItemClick', menuItem, event!); + }; + + return { ns, handleMenuItemClick }; }, render() { - return
{this.position}通用菜单
; + return ( +
+ +
+ ); }, }); diff --git a/src/panel-component/app-extend-menu/extend-menu-base/extend-button-menu/extend-button-menu.scss b/src/panel-component/app-extend-menu/extend-menu-base/extend-button-menu/extend-button-menu.scss new file mode 100644 index 000000000..cf078e39b --- /dev/null +++ b/src/panel-component/app-extend-menu/extend-menu-base/extend-button-menu/extend-button-menu.scss @@ -0,0 +1,326 @@ +$extend-menu-button: ( + 'padding': getCssVar('spacing', 'extra-tight') 0, + 'font-size': getCssVar('font-size', 'header-6'), + 'rawitem-min-width': getCssVar('font-size', 'header-6'), + 'icon-width': 20px, + 'icon-height': 20px, + 'text-margin': 0 0 0 getCssVar('spacing', 'tight'), + 'content-padding': 0 getCssVar('spacing', 'tight'), + 'hover-color': getCssVar(color, primary, hover, text), + 'hover-bg-color': getCssVar(color, primary, hover), + 'active-color': getCssVar(color, primary, active, text), + 'active-bg-color': getCssVar(color, primary, active), +); +$extend-menu-button-item: ( + 'height': 42px, + 'horizontal-min-width': 200px, + 'horizontal-margin': 0 0 0 getCssVar('spacing', 'extra-tight'), + 'horizontal-separator-margin': 0 getCssVar('spacing', 'tight'), + 'vertical-margin': getCssVar('spacing', 'extra-tight') 0 0 0, + 'vertical-separator-margin': getCssVar('spacing', 'tight') 0, +); +$extend-menu-button-popover: ( + 'padding': getCssVar('spacing', 'super-tight') getCssVar('spacing', 'extra-tight'), + 'min-width': 200px, + 'z-index': 3, + 'background': getCssVar('color', 'primary'), +); +$extend-menu-button-placehold: ( + 'z-index': 3, +); + +@include b(extend-menu-button) { + @include set-component-css-var('extend-menu-button', $extend-menu-button); + @include set-component-css-var('extend-menu-button-item', $extend-menu-button-item); + + width: 100%; + height: 100%; + padding: getCssVar('extend-menu-button', 'padding'); + + @include e('menuitem') { + width: 100%; + height: 100%; + font-size: getCssVar('extend-menu-button', 'font-size'); + } + + @include e('rawitem') { + width: 100%; + height: 100%; + + .#{bem('rawitem')} { + min-width: getCssVar('extend-menu-button', 'rawitem-min-width'); + font-size: initial; + color: getCssVar(color,primary,text); + } + } + + @include e('separator') { + @include m('horizontal') { + margin: 0; + } + @include m('vertical') { + margin: 0; + } + } + + @include e('icon') { + display: flex; + align-items: center; + width: getCssVar('extend-menu-button', 'icon-width'); + height: getCssVar('extend-menu-button', 'icon-height'); + & + .#{bem('extend-menu-button__caption')} { + margin: getCssVar('extend-menu-button', 'text-margin'); + } + } + + @include e('content') { + display: flex; + flex-wrap: nowrap; + height: 100%; + padding: getCssVar('extend-menu-button', 'content-padding'); + overflow: auto; + @include m('item') { + display: flex; + align-items: center; + + + @include when('show-arrow') { + position: relative; + .#{bem('extend-menu-button__menuitem')}::after { + display: block; + width: 1em; + height: 1em; + margin-left: auto; + content: ''; + } + } + + @include when('rotate-arrow') { + svg { + transform: rotate(180deg); + } + } + } + + @include m('item-container') { + display: flex; + align-items: center; + height: 100%; + } + + @include m('menuitem') { + height: getCssVar('extend-menu-button-item', 'height'); + } + + @include m('rawitem') { + height: getCssVar('extend-menu-button-item', 'height'); + } + + @include m('item-arrow') { + position: absolute; + top: 50%; + right: 15px; + display: flex; + align-items: center; + justify-content: center; + color: getCssVar(color,primary,text); + transform: translateY(-50%); + + svg { + transition: transform 0.3s ease; + } + } + + @include when('horizontal') { + align-items: center; + @include e('content'){ + @include m('item') { + margin: getCssVar('extend-menu-button-item', 'horizontal-margin'); + + &:first-child { + margin-left: 0; + } + } + + @include m('item-container') { + &:not(:has(.el-divider)) { + min-width: getCssVar('extend-menu-button-item', 'horizontal-min-width'); + } + } + + @include m('seperator') { + width: auto; + margin: getCssVar('extend-menu-button-item', 'horizontal-separator-margin'); + + &+.#{bem('extend-menu-button__content--item')} { + margin-left: 0; + } + } + } + } + + @include when('vertical') { + flex-direction:column; + @include e('content'){ + @include m('item') { + margin: getCssVar('extend-menu-button-item', 'vertical-margin'); + + &:first-child { + margin-top: 0; + } + } + + @include m('seperator') { + margin: getCssVar('extend-menu-button-item', 'vertical-separator-margin'); + + &+.#{bem('extend-menu-button__content--item')} { + margin-top: 0; + } + } + } + + @include e('menuitem') { + justify-content: flex-start; + } + + @include e('rawitem') { + justify-content: flex-start; + } + } + + .el-button { + #{getCssVarName(color, primary, hover)}: getCssVar(extend-menu-button, active, bg, color); + #{getCssVarName(color, primary, hover, text)}: getCssVar(extend-menu-button, active, color); + #{getCssVarName(color, primary, active)}: getCssVar(extend-menu-button, hover, bg, color); + #{getCssVarName(color, primary, active, text)}: getCssVar(extend-menu-button, hover, color); + + &:focus { + background-color: #{getCssVar(color, primary, active)}; + } + + &:hover { + background-color: #{getCssVar(color, primary, hover)}; + } + } + } + + @include when('vertical') { + #{getCssVarName('extend-menu-button', 'padding')}: #{getCssVar(spacing, tight)} 0; + + .#{bem('extend-menu-button__content')}, + .#{bem('extend-menu-button__content--item')}, + .#{bem('extend-menu-button__content--item-container')} { + width: 100%; + } + } +} + +@mixin extend-menu-button-popover { + @include set-component-css-var('extend-menu-button', $extend-menu-button); + @include set-component-css-var('extend-menu-button-item', $extend-menu-button-item); + @include set-component-css-var('extend-menu-button-popover', $extend-menu-button-popover); + + #{getCssVarName('extend-menu-button', 'content-padding')}: 0; + #{getCssVarName('extend-menu-button-item', 'vertical-margin')}: 0; + z-index: getCssVar('extend-menu-button-popover', 'z-index'); + width: auto; + padding: getCssVar('extend-menu-button-popover', 'padding'); + background-color: getCssVar('extend-menu-button-popover', 'background'); + box-shadow: getCssVar(shadow, elevated); + + .#{bem('extend-menu-button__content')} { + min-width: getCssVar('extend-menu-button-popover', 'min-width'); + } + + .#{bem('extend-menu-button__separator--horizontal')} { + border-color: getCssVar(color, scroll, menu); + } + .#{bem('extend-menu-button__content')}, + .#{bem('extend-menu-button__content--item')}, + .#{bem('extend-menu-button__content--item-container')} { + width: 100%; + } +} + +@include b('extend-menu-button-cascader-popover') { + @include extend-menu-button-popover; +} + +@include b('extend-menu-button-border-popover') { + @include extend-menu-button-popover; + + .#{bem('extend-menu-button__content')}.is-horizontal { + .#{bem('extend-menu-button__content--item-arrow')} { + transform: translateY(-50%) rotate(90deg); + } + } +} + + +@include b('extend-menu-button-placehold') { + @include set-component-css-var('extend-menu-button-placehold', $extend-menu-button-placehold); + + position: fixed; + z-index: getCssVar('extend-menu-button-placehold', 'z-index'); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + + @include e('line') { + position: absolute; + } + + @include e('arrow') { + color: var(--ibiz-color-text-2); + } + + @include when('left') { + @include e('line') { + top: 0; + left: 0; + width: 1px; + height: 100%; + } + } + + @include when('right') { + @include e('line') { + top: 0; + right: 0; + width: 1px; + height: 100%; + } + + svg { + transform: rotate(180deg); + } + } + + @include when('top') { + @include e('line') { + top: 0; + left: 0; + width: 100%; + height: 1px; + } + + svg { + transform: rotate(90deg); + } + } + + @include when('bottom') { + @include e('line') { + bottom: 0; + left: 0; + width: 100%; + height: 1px; + } + + svg { + transform: rotate(-90deg); + } + } +} \ No newline at end of file diff --git a/src/panel-component/app-extend-menu/extend-menu-base/extend-button-menu/extend-button-menu.tsx b/src/panel-component/app-extend-menu/extend-menu-base/extend-button-menu/extend-button-menu.tsx new file mode 100644 index 000000000..6ffe99db4 --- /dev/null +++ b/src/panel-component/app-extend-menu/extend-menu-base/extend-button-menu/extend-button-menu.tsx @@ -0,0 +1,299 @@ +import { computed, defineComponent, PropType, ref, VNode } from 'vue'; +import { IAppMenuItem, IFlexLayoutPos, ILayout } from '@ibiz/model-core'; +import { useNamespace } from '@ibiz-template/vue3-util'; +import { IAppMenuItemProvider } from '@ibiz-template/runtime'; +import { showTitle } from '@ibiz-template/core'; +import { + findMenuItem, + getMenus, + IButtonMenuItem, + IExtendMenuProps, + IMenuContentParams, + IMenuItemParams, + useBorderLayout, + useCascaderPopover, +} from '../extend-menu-base.util'; +import './extend-button-menu.scss'; + +const rightArrow = () => ( + + + +); + +function renderMenuItem(params: IMenuItemParams): VNode | undefined { + const { ns, menu, menuAlign, menuItemsState } = params; + + if (!menu.id || !menuItemsState[menu.id]?.visible) return; + + if (menu.itemType === 'MENUITEM') { + return ( + + {menu.sysImage && ( + + )} + {menu.caption && ( + + {menu.caption} + + )} + + ); + } + + if (menu.itemType === 'SEPERATOR') { + const direction = menuAlign === 'horizontal' ? 'vertical' : 'horizontal'; + return ( + + ); + } + + if (menu.itemType === 'RAWITEM') { + return ( + + + + ); + } +} + +function renderMenuContent(_params: IMenuContentParams) { + const { + ns, + isLayout, + position, + menuAlign, + menus, + menuItemsState, + showCascaderArrow, + handleMenuItemClick, + handleMenuItemMouseEnter, + handleMenuItemMouseLeave, + } = _params; + return ( + + {{ + default: () => + menus.map(menu => { + const menuItem = renderMenuItem({ + menu, + ns, + menuAlign, + menuItemsState, + }); + if (!menuItem) return; + + const style = {}; + if (isLayout && menu.layoutPos?.layout === 'FLEX') { + const pos = menu.layoutPos as IFlexLayoutPos; + Object.assign(style, { + flexGrow: pos.grow, + flexShrink: pos.shrink === 1 ? undefined : pos.shrink, + flexBasis: pos.basis, + }); + } + + const isShowArrow = !!(showCascaderArrow && menu.children); + return ( +
+
+ handleMenuItemMouseEnter(menu, _e) + } + onMouseleave={(_e: MouseEvent) => + handleMenuItemMouseLeave(menu, _e) + } + onClick={(_e: MouseEvent) => handleMenuItemClick(menu, _e)} + > + {menuItem} +
+ { + + {isShowArrow && rightArrow()} + + } +
+ ); + }), + }} +
+ ); +} + +export const ExtendButtonMenu = defineComponent({ + name: 'IBizExtendButtonMenu', + props: { + items: { type: Object as PropType, required: true }, + menuItemsState: { + type: Object as PropType<{ + [p: string]: { visible: boolean; permitted: boolean }; + }>, + required: true, + }, + providers: { + type: Object as PropType<{ [key: string]: IAppMenuItemProvider }>, + required: true, + }, + position: { + type: String as PropType<'LEFT' | 'RIGHT' | 'TOP' | 'BOTTOM'>, + required: true, + }, + layoutMode: { + type: String as PropType< + 'TABLE' | 'TABLE_12COL' | 'TABLE_24COL' | 'FLEX' | 'BORDER' | 'ABSOLUTE' + >, + required: true, + }, + layout: { type: Object as PropType }, + }, + emits: { + menuItemClick: (item: IAppMenuItem, event: MouseEvent) => true, + }, + setup(props, { emit }) { + const ns = useNamespace('extend-menu-button'); + const buttonMenuRef = ref(); + const menuAlign = computed(() => + ['TOP', 'BOTTOM'].includes(props.position) ? 'horizontal' : 'vertical', + ); + const isLayout = computed( + () => + props.layoutMode !== 'BORDER' && + ['TOP', 'BOTTOM'].includes(props.position), + ); + const menus = ref(getMenus(props.items)); + + const renderCascaderContent = (_menu: IButtonMenuItem) => { + return renderMenuContent({ + ns, + menuAlign: 'vertical', + position: props.position, + menus: _menu.children!, + menuItemsState: props.menuItemsState, + handleMenuItemClick, + handleMenuItemMouseEnter, + handleMenuItemMouseLeave, + showCascaderArrow: true, + isLayout: false, + }); + }; + const renderBorderContent = () => { + return renderMenuContent({ + ns, + menuAlign: menuAlign.value, + position: props.position, + menus: menus.value, + menuItemsState: props.menuItemsState, + handleMenuItemClick, + handleMenuItemMouseEnter, + handleMenuItemMouseLeave, + isLayout: isLayout.value, + showCascaderArrow: true, + }); + }; + + const { + getOverlayNum, + clearAllCascader, + handleMenuItemMouseEnter, + handleMenuItemMouseLeave, + } = useCascaderPopover( + props as IExtendMenuProps, + ns, + menuAlign, + renderCascaderContent, + ); + let closeBorderPopover: () => void | undefined; + if (props.layoutMode === 'BORDER') { + const borderLayout = useBorderLayout( + buttonMenuRef, + ns, + props.position, + menuAlign, + getOverlayNum, + renderBorderContent, + ); + closeBorderPopover = borderLayout.closeBorderPopover; + } + + const handleMenuItemClick = async ( + _menu: IButtonMenuItem, + _event: MouseEvent, + ): Promise => { + if (_menu.children) return; + clearAllCascader(); + if (closeBorderPopover) closeBorderPopover(); + if (!_menu.appFuncId) { + ibiz.log.warn( + ibiz.i18n.t('runtime.controller.control.menu.noConfigured'), + ); + return; + } + + const menuItem = findMenuItem(_menu.id as string, props.items); + emit('menuItemClick', menuItem!, _event); + }; + + return { + ns, + menus, + menuAlign, + isLayout, + buttonMenuRef, + handleMenuItemClick, + handleMenuItemMouseEnter, + handleMenuItemMouseLeave, + }; + }, + render() { + return ( +
+ {this.layoutMode !== 'BORDER' && + renderMenuContent({ + ...(this as unknown as IMenuContentParams), + })} +
+ ); + }, +}); diff --git a/src/panel-component/app-extend-menu/extend-menu-base/extend-menu-base.util.ts b/src/panel-component/app-extend-menu/extend-menu-base/extend-menu-base.util.ts new file mode 100644 index 000000000..1f9de35fd --- /dev/null +++ b/src/panel-component/app-extend-menu/extend-menu-base/extend-menu-base.util.ts @@ -0,0 +1,661 @@ +import { h, ref, Ref, VNode, onMounted, onUnmounted } from 'vue'; +import { IAppMenuItem, ILayout } from '@ibiz/model-core'; +import { IAppMenuItemProvider } from '@ibiz-template/runtime'; +import { Namespace } from '@ibiz-template/core'; + +/** + * @description 组件传参类型定义 + * + * @export + * @interface IExtendMenuProps + */ +export interface IExtendMenuProps { + // 所有菜单项数据 + items: IAppMenuItem[]; + // 权限状态 + menuItemsState: { + [p: string]: { visible: boolean; permitted: boolean }; + }; + // 菜单项适配器集合 + providers: { [key: string]: IAppMenuItemProvider }; + // 菜单弹出方向 + position: 'LEFT' | 'RIGHT' | 'TOP' | 'BOTTOM'; + layoutMode: + | 'TABLE' + | 'TABLE_12COL' + | 'TABLE_24COL' + | 'FLEX' + | 'BORDER' + | 'ABSOLUTE'; // 布局模式 + layout: ILayout; // 布局模型 +} + +/** + * @description 菜单项绘制数据 + * + * @export + * @interface IButtonMenuItem + * @extends {IAppMenuItem} + */ +export interface IButtonMenuItem extends IAppMenuItem { + /** 数据层级 */ + level: number; + /** 主键 */ + value?: string; + /** 主文本 */ + label?: string; + /** 父主键 */ + parentId?: string; + /** 子项数据集合 */ + children?: IButtonMenuItem[]; +} + +/** + * @description 菜单绘制通用参数 + * + * @export + * @interface IMenuRenderParams + */ +export interface IMenuRenderParams { + /** 菜单样式处理命名空间 */ + ns: Namespace; + /** 菜单对齐 */ + menuAlign: 'horizontal' | 'vertical'; + /** 菜单项权限数据 */ + menuItemsState: { [p: string]: { visible: boolean; permitted: boolean } }; +} + +/** + * @description 绘制单个菜单项参数 + * + * @export + * @interface IMenuItemParams + * @extends {IMenuRenderParams} + */ +export interface IMenuItemParams extends IMenuRenderParams { + /** 菜单项 */ + menu: IButtonMenuItem; +} + +/** + * @description 多菜单项(子菜单内容)参数 + * + * @export + * @interface IMenuContentParams + * @extends {IMenuRenderParams} + */ +export interface IMenuContentParams extends IMenuRenderParams { + /** 是否支持布局 */ + isLayout: boolean; + /** 菜单方向 */ + position: string; + /** 菜单项绘制数据集合 */ + menus: IButtonMenuItem[]; + /** 显示级联箭头 */ + showCascaderArrow?: boolean; + /** 菜单项点击 */ + handleMenuItemClick: (_menu: IButtonMenuItem, event: MouseEvent) => void; + /** 鼠标移入菜单项,准备弹出子菜单 */ + handleMenuItemMouseEnter: (_menu: IButtonMenuItem, event: MouseEvent) => void; + /** 鼠标移出菜单项 */ + handleMenuItemMouseLeave: (_menu: IButtonMenuItem, event: MouseEvent) => void; +} + +/** + * @description 用于构建级联菜单的弹出层控制逻辑 + * + * - 支持多层级菜单结构; + * - 根据鼠标移入/移出控制子菜单弹出与关闭; + * - 菜单项移入时打开对应子菜单,移出时自动延迟关闭; + * - 子菜单弹出方向根据配置自动计算; + * - 支持箭头图标旋转动画; + * + * @param props - 外部传入的菜单属性,包含菜单项、权限、适配器、布局模式等 + * @param ns - 命名空间,用于生成 CSS BEM 类名 + * @param menuAlign - 菜单排列方向(如 vertical) + * @param onMenuItemClick - 菜单项点击事件处理器 + * @param renderCascader - 子菜单内容渲染函数(返回 VNode) + * + * @returns { + * getOverlayNum: 获取打开的级联数量; + * clearAllCascader: 清除所有层级弹窗和计时器; + * openCascaderPopover: 打开子菜单气泡; + * onMenuItemMouseEnter: 鼠标移入菜单项时的处理函数; + * onMenuItemMouseLeave: 鼠标移出菜单项时的处理函数; + * } + */ +export function useCascaderPopover( + props: IExtendMenuProps, + ns: Namespace, + menuAlign: Ref, + renderCascaderContent: (_menu: IButtonMenuItem) => VNode, +): { + getOverlayNum: () => number; + clearAllCascader: () => void; + openCascaderPopover: ( + menu: IButtonMenuItem, + evt: MouseEvent, + opts?: IData, + ) => void; + handleMenuItemMouseEnter: ( + _menu: IButtonMenuItem, + _event: MouseEvent, + ) => void; + handleMenuItemMouseLeave: ( + _menu: IButtonMenuItem, + _event: MouseEvent, + ) => void; +} { + // 当前鼠标位于弹窗区域内的计数器,用于控制是否关闭弹窗 + let hoverCount = 0; + // 弹出层延时关闭的计时器引用 + let closeTimer: ReturnType | null = null; + + // 存储每一级菜单对应的弹出层实例(Map) + const overlayInstances = new Map(); + // 存储当前激活的菜单项 ID 栈(用于判断哪些项是“当前激活”的) + const activeMenuIdStack = ref([]); + // 存储当前被旋转箭头元素(Map) + const rotatedArrowElements = new Map(); + + /** 获取打开的级联气泡数量 */ + const getOverlayNum = () => { + return overlayInstances.size; + }; + + /** 获取当前层级对应弹出方向 */ + const getPopoverPlacement = (level: number) => { + switch (props.position) { + case 'TOP': + return level > 0 ? 'right-start' : 'bottom-start'; + case 'BOTTOM': + return level > 0 ? 'right-end' : 'top-start'; + case 'RIGHT': + return 'left-start'; + case 'LEFT': + return 'right-start'; + default: + return undefined; + } + }; + + /** 清除定时器 */ + const clearCloseTimer = () => { + if (closeTimer) { + clearTimeout(closeTimer); + closeTimer = null; + } + }; + + /** 清除箭头旋转状态 */ + const resetArrowRotation = (ns: Namespace, key: number | string) => { + const arrow = rotatedArrowElements.get(key); + if (!arrow) return; + arrow.classList.remove(ns.is('rotate-arrow', true)); + rotatedArrowElements.delete(key); + }; + + /** 旋转箭头图标 */ + const rotateArrowIcon = ( + el: HTMLElement, + ns: Namespace, + key: number | string, + ) => { + if (el) { + el.classList.add(ns.is('rotate-arrow', true)); + rotatedArrowElements.set(key, el); + } + }; + + /** 关闭指定层级的级联气泡 */ + const closePopoverAtLevel = (key: string | number) => { + const overlay = overlayInstances.get(key); + if (overlay) { + overlay.dismiss(); + overlayInstances.delete(key); + resetArrowRotation(ns, key); + } + }; + + /** 关闭当前菜单项所在层级及其所有后续层级的弹出层。 */ + const closeSubsequentPopovers = (currentLevel: number) => { + let level = currentLevel; + + // 向下查找,找到当前层级后面最后存在的弹层 + while (overlayInstances.get(level + 1)) { + level++; + } + + // 从找到的最深层级往回关闭,直到当前层级 + for (let i = level; i >= currentLevel; i--) { + closePopoverAtLevel(i); + } + }; + + /** 关闭所有级联气泡 */ + const closeAllPopovers = () => { + for (const key of overlayInstances.keys()) { + closePopoverAtLevel(key); + } + overlayInstances.clear(); + activeMenuIdStack.value = []; + hoverCount = 0; + }; + + /** 计划延时关闭(鼠标移出时触发) */ + const scheduleDelayedClose = () => { + clearCloseTimer(); + closeTimer = setTimeout(() => { + if (hoverCount <= 0) { + closeAllPopovers(); + } + }, 300); + }; + + const clearAllCascader = () => { + closeAllPopovers(); + clearCloseTimer(); + }; + + /** 鼠标移入菜单项,准备弹出子菜单 */ + const handleMenuItemMouseEnter = ( + _menu: IButtonMenuItem, + event: MouseEvent, + ) => { + if (activeMenuIdStack.value.includes(_menu.id!)) return; + activeMenuIdStack.value.push(_menu.id!); + + // 清除当前层级及后续层级的气泡 + closeSubsequentPopovers(_menu.level); + + // 有子菜单则打开 + if (_menu.children) { + openCascaderPopover(_menu, event); + } + + clearCloseTimer(); + }; + + /** 鼠标移出菜单项 */ + const handleMenuItemMouseLeave = (_menu: IButtonMenuItem) => { + const index = activeMenuIdStack.value.indexOf(_menu.id!); + if (index !== -1) { + activeMenuIdStack.value.splice(index, 1); + } + scheduleDelayedClose(); + }; + + /** 级联气泡上监听鼠标移入 */ + const onPopoverMouseEnter = () => { + hoverCount++; + clearCloseTimer(); + }; + + /** 级联气泡上监听鼠标移出 */ + const onPopoverMouseLeave = (_menu: IButtonMenuItem, _event: MouseEvent) => { + hoverCount = Math.max(0, hoverCount - 1); + scheduleDelayedClose(); + }; + + /** 打开子菜单气泡 */ + const openCascaderPopover = ( + menu: IButtonMenuItem, + evt: MouseEvent, + opts?: IData, + ): void => { + const overlay = ibiz.overlay.createPopover( + () => + h(renderCascaderContent(menu), { + onMouseenter: onPopoverMouseEnter, + onMouseleave: onPopoverMouseLeave, + }), + undefined, + { + width: 'auto', + height: 'auto', + noArrow: true, + placement: getPopoverPlacement(menu.level), + offsetOpts: 10, + ...opts, + modalClass: `${ns.b('cascader-popover')} ${ns.is( + menuAlign.value, + true, + )} ${opts?.modalClass || ''}`, + }, + ); + + overlayInstances.set(menu.level, overlay); + overlay?.present(evt.currentTarget as HTMLElement); + rotateArrowIcon( + (evt.currentTarget as IData)?.parentElement as HTMLElement, + ns, + menu.level, + ); + }; + + return { + getOverlayNum, + openCascaderPopover, + clearAllCascader, + handleMenuItemMouseEnter, + handleMenuItemMouseLeave, + }; +} + +/** + * @description 用于构建边缘布局弹出层控制逻辑 + * + * - 创建 fixed 占位元素 + * - 占位块随目标元素变化而更新 + * - 鼠标悬停触发弹出层 + * - 移出占位层自动关闭弹层 + * - 组件卸载自动清理 + * + * @export + * @param {(Ref)} menuRef 菜单元素引用 + * @param {Namespace} ns 命名空间工具 + * @param {('LEFT' | 'RIGHT' | 'TOP' | 'BOTTOM')} position 元素的方向 + * @param {Ref} menuAlign 菜单对齐方式 + * @param {() => number} getOverlayNum 获取当前层级 Overlay 数量 + * @param {() => VNode} renderBorderContent 渲染边框内容 + * @returns { + * closeBorderPopover: 关闭边框弹出层 + * } + */ +export function useBorderLayout( + menuRef: Ref, + ns: Namespace, + position: 'LEFT' | 'RIGHT' | 'TOP' | 'BOTTOM', + menuAlign: Ref, + getOverlayNum: () => number, + renderBorderContent: () => VNode, +): { + closeBorderPopover: () => void; +} { + let overlay: IData | null; + + /** + * 获取元素相对于页面的位置与尺寸 + */ + const getElementAbsolutePosition = (element: HTMLElement) => { + let x = 0, + y = 0; + let current: HTMLElement | null = element; + + while (current) { + x += current.offsetLeft; + y += current.offsetTop; + current = current.offsetParent as HTMLElement; + } + + return { + x, + y, + width: element.offsetWidth, + height: element.offsetHeight, + }; + }; + + /** + * 根据位置返回对应的弹出方向 + */ + const resolvePopoverPlacement = () => { + switch (position) { + case 'TOP': + return 'bottom'; + case 'BOTTOM': + return 'top'; + case 'RIGHT': + return 'left'; + case 'LEFT': + return 'right'; + default: + return undefined; + } + }; + + let popoverEl: HTMLElement | undefined; + + /** + * 关闭边框弹出层 + */ + const closeBorderPopover = () => { + overlay?.dismiss(); + overlay = null; + document.removeEventListener('mousemove', handleMouseTrackOut); + popoverEl = undefined; + }; + + /** + * 鼠标移出边框弹层时关闭逻辑 + */ + const handleMouseTrackOut = (e: MouseEvent) => { + if (!popoverEl) { + popoverEl = document.querySelector( + `.${ns.b('border-popover')}`, + ) as HTMLElement; + } + + const pos = getElementAbsolutePosition(popoverEl); + const isInside = + e.pageX >= pos.x && + e.pageX <= pos.x + pos.width && + e.pageY >= pos.y && + e.pageY <= pos.y + pos.height; + + if (!isInside && getOverlayNum() <= 0) { + closeBorderPopover(); + } + }; + + /** + * 鼠标移入占位元素后显示弹出边框 + */ + const handlePlaceholderMouseEnter = async (evt: MouseEvent) => { + if (overlay) return; + + overlay = ibiz.overlay.createPopover( + () => h(renderBorderContent()), + undefined, + { + width: 'auto', + height: 'auto', + noArrow: true, + placement: resolvePopoverPlacement(), + offsetOpts: -1, + modalClass: `${ns.b('border-popover')} ${ns.is(menuAlign.value, true)}`, + }, + ); + + const triggerEl = (evt.currentTarget as HTMLElement)?.querySelector( + `.${ns.be('placehold', 'line')}`, + ); + await overlay?.present(triggerEl as HTMLElement); + + setTimeout(() => { + document.addEventListener('mousemove', handleMouseTrackOut); + }, 200); + }; + + let placeholderEl: HTMLElement | null = null; + let resizeObserver: ResizeObserver | null = null; + let frameLoopId: number | null = null; + + const minWidth = 20; + const minHeight = 20; + + const computeTop = (top: number, height: number) => { + return position === 'BOTTOM' ? top + height - minHeight : top; + }; + + const computeLeft = (left: number, width: number) => { + return position === 'RIGHT' ? left + width - minWidth : left; + }; + + const computeWidth = (width: number) => { + return ['RIGHT', 'LEFT'].includes(position) ? minWidth : width; + }; + + const computeHeight = (height: number) => { + return ['TOP', 'BOTTOM'].includes(position) ? minHeight : height; + }; + + /** + * 创建固定定位的跟随占位元素 + */ + function createFixedPlaceholder(el: HTMLElement) { + const container = document.createElement('div'); + container.classList.add(ns.b('placehold')); + container.classList.add(ns.is(position.toLowerCase(), !!position)); + + const line = document.createElement('div'); + line.classList.add(ns.be('placehold', 'line')); + + const arrow = document.createElement('div'); + arrow.classList.add(ns.be('placehold', 'arrow')); + + container.appendChild(line); + container.appendChild(arrow); + document.body.appendChild(container); + + placeholderEl = container; + placeholderEl.addEventListener('mouseenter', handlePlaceholderMouseEnter); + + // 添加 SVG 箭头图标 + arrow.innerHTML = ` + + + + + `; + updatePlaceholderPosition(el); + } + + /** + * 更新占位元素的位置和尺寸 + */ + function updatePlaceholderPosition(el: HTMLElement) { + if (!placeholderEl) return; + const rect = el.getBoundingClientRect(); + + placeholderEl.style.top = `${computeTop(rect.top, rect.height)}px`; + placeholderEl.style.left = `${computeLeft(rect.left, rect.width)}px`; + placeholderEl.style.width = `${computeWidth(rect.width)}px`; + placeholderEl.style.height = `${computeHeight(rect.height)}px`; + } + + /** + * 启动监听,创建并跟踪占位元素 + */ + function startTracking() { + const el = menuRef.value; + if (!el) return; + + createFixedPlaceholder(el); + + resizeObserver = new ResizeObserver(() => updatePlaceholderPosition(el)); + resizeObserver.observe(el); + + const updateLoop = () => { + updatePlaceholderPosition(el); + frameLoopId = requestAnimationFrame(updateLoop); + }; + frameLoopId = requestAnimationFrame(updateLoop); + } + + /** + * 停止跟踪并销毁占位元素与监听器 + */ + function stopTrackingAndDestroy() { + if (resizeObserver) { + const el = menuRef.value; + if (el) resizeObserver.unobserve(el); + resizeObserver.disconnect(); + resizeObserver = null; + } + + if (frameLoopId !== null) { + cancelAnimationFrame(frameLoopId); + frameLoopId = null; + } + + if (placeholderEl && placeholderEl.parentNode) { + placeholderEl.removeEventListener( + 'mouseenter', + handlePlaceholderMouseEnter, + ); + placeholderEl.parentNode.removeChild(placeholderEl); + placeholderEl = null; + } + } + + // 生命周期钩子注册 + onMounted(startTracking); + onUnmounted(stopTrackingAndDestroy); + + return { closeBorderPopover }; +} + +/** + * 递归生成菜单数据,递给 element 的 Menu 组件 + * + * @param {AppMenuItemModel[]} items + * @return {*} {IButtonMenuItem[]} + */ +export function getMenus( + items: IAppMenuItem[], + _parentItem?: IAppMenuItem, + level = 0, +): IButtonMenuItem[] { + return items.map(item => { + const data: IButtonMenuItem = { + ...item, + value: item.id, + label: item.caption, + parentId: _parentItem?.id, + level, + }; + if (item.appMenuItems?.length) { + data.children = getMenus(item.appMenuItems, item, level + 1); + } + return data; + }); +} + +/** + * @description 查询菜单项模型 + * + * @export + * @param {string} _id 菜单标识 + * @param {IAppMenuItem[]} items 菜单数据集合 + * @return {*} {(IAppMenuItem | undefined)} + */ +export function findMenuItem( + _id: string, + items: IAppMenuItem[], +): IAppMenuItem | undefined { + let temp: IAppMenuItem | undefined; + if (items) { + items.some((item: IAppMenuItem): boolean => { + if (!item.id) return true; + if (item.id === _id) { + temp = item; + return true; + } + if (item.appMenuItems && item.appMenuItems.length > 0) { + temp = findMenuItem(_id, item.appMenuItems); + if (!temp) { + return false; + } + return true; + } + return false; + }); + } + return temp; +} diff --git a/src/panel-component/app-extend-menu/left-side-menu/left-side-menu.scss b/src/panel-component/app-extend-menu/left-side-menu/left-side-menu.scss index fcc102ad4..65135a103 100644 --- a/src/panel-component/app-extend-menu/left-side-menu/left-side-menu.scss +++ b/src/panel-component/app-extend-menu/left-side-menu/left-side-menu.scss @@ -1,4 +1,13 @@ @include b('left-side-menu') { width: 100%; height: 100%; +} + +@include b('col') { + @include m('self-align') { + &>.#{bem('left-side-menu')} { + width: 100%; + height: 100%; + } + } } \ No newline at end of file diff --git a/src/panel-component/app-extend-menu/right-side-menu/right-side-menu.scss b/src/panel-component/app-extend-menu/right-side-menu/right-side-menu.scss index 75b759be4..c05261605 100644 --- a/src/panel-component/app-extend-menu/right-side-menu/right-side-menu.scss +++ b/src/panel-component/app-extend-menu/right-side-menu/right-side-menu.scss @@ -1,4 +1,13 @@ @include b('right-side-menu') { width: 100%; height: 100%; +} + +@include b('col') { + @include m('self-align') { + &>.#{bem('right-side-menu')} { + width: 100%; + height: 100%; + } + } } \ No newline at end of file diff --git a/src/panel-component/app-extend-menu/top-side-menu/top-side-menu.scss b/src/panel-component/app-extend-menu/top-side-menu/top-side-menu.scss index 2555088e7..a4a21f116 100644 --- a/src/panel-component/app-extend-menu/top-side-menu/top-side-menu.scss +++ b/src/panel-component/app-extend-menu/top-side-menu/top-side-menu.scss @@ -1,4 +1,13 @@ @include b('top-side-menu') { width: 100%; height: 100%; +} + +@include b('col') { + @include m('self-align') { + &>.#{bem('top-side-menu')} { + width: 100%; + height: 100%; + } + } } \ No newline at end of file -- Gitee