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 (
+
+ );
+ }
+
+ 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