From 9694258fb928d044aa01d2a7fe77b04dc514f46f Mon Sep 17 00:00:00 2001 From: Katherine Date: Tue, 10 Feb 2026 09:30:00 +0800 Subject: [PATCH] feat: support configurable block height (#8583) * feat: block height * feat: table block support block height settings * feat: form block support block height settings * refactor: table blcok & form block support full height settings * fix: bug * fix: bug * feat: detail block support block height settings * feat: list block support block height settings * feat: grid card block support block height settings * feat: map block support block height settings * fix: bug * feat: iframe support block height settings * fix: bug * fix: bug * feat: table block support block height settings * feat: map block support block height settings * test: block height * fix: bug * fix: bug * fix: test * test: detail block height test * test: listBlock height --------- Co-authored-by: chenos --- .../client/src/flow/actions/blockHeight.tsx | 80 +++++ .../core/client/src/flow/actions/index.ts | 1 + .../src/flow/components/BlockItemCard.tsx | 158 ++++++++- .../src/flow/models/base/BlockGridModel.tsx | 7 +- .../src/flow/models/base/BlockModel.tsx | 51 +-- .../blocks/details/DetailsBlockModel.tsx | 110 ++++++- .../blocks/details/DetailsGridModel.tsx | 24 ++ .../DetailsBlockModel.blockHeight.test.tsx | 139 ++++++++ .../src/flow/models/blocks/details/utils.ts | 67 ++++ .../models/blocks/form/CreateFormModel.tsx | 56 ++-- .../flow/models/blocks/form/EditFormModel.tsx | 94 +++--- .../models/blocks/form/FormBlockModel.tsx | 138 +++++++- .../flow/models/blocks/form/FormGridModel.tsx | 24 ++ .../form/__tests__/FormBlockModel.test.tsx | 119 ++++++- .../models/blocks/table/TableBlockModel.tsx | 88 ++++- .../TableBlockModel.blockHeight.test.tsx | 120 +++++++ .../src/flow/models/blocks/table/utils.ts | 47 ++- packages/core/client/src/locale/zh-CN.json | 7 +- .../src/client/models/GridCardBlockModel.tsx | 309 ++++++++++++------ .../src/client/models/IframeBlockModel.tsx | 27 +- .../src/client/models/ListBlockModel.tsx | 129 ++++++-- .../ListBlockModel.blockHeight.test.tsx | 144 ++++++++ .../src/client/models/utils.ts | 68 ++++ .../src/client/models/MapBlockModel.tsx | 217 ++++++++---- 24 files changed, 1842 insertions(+), 382 deletions(-) create mode 100644 packages/core/client/src/flow/actions/blockHeight.tsx create mode 100644 packages/core/client/src/flow/models/blocks/details/__tests__/DetailsBlockModel.blockHeight.test.tsx create mode 100644 packages/core/client/src/flow/models/blocks/details/utils.ts create mode 100644 packages/core/client/src/flow/models/blocks/table/__tests__/TableBlockModel.blockHeight.test.tsx create mode 100644 packages/plugins/@nocobase/plugin-block-list/src/client/models/__tests__/ListBlockModel.blockHeight.test.tsx create mode 100644 packages/plugins/@nocobase/plugin-block-list/src/client/models/utils.ts diff --git a/packages/core/client/src/flow/actions/blockHeight.tsx b/packages/core/client/src/flow/actions/blockHeight.tsx new file mode 100644 index 00000000000..ecdde7614c6 --- /dev/null +++ b/packages/core/client/src/flow/actions/blockHeight.tsx @@ -0,0 +1,80 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { defineAction, tExpr } from '@nocobase/flow-engine'; +import { InputNumber } from 'antd'; +import _ from 'lodash'; + +const HeightMode = { + DEFAULT: 'defaultHeight', + SPECIFY_VALUE: 'specifyValue', + FULL_HEIGHT: 'fullHeight', +}; + +export const blockHeight = defineAction({ + name: 'blockHeight', + title: tExpr('Block Height'), + uiMode: { + type: 'dialog', + props: { + width: 800, + }, + }, + uiSchema: (ctx) => { + const { t } = ctx; + return { + heightMode: { + type: 'string', + enum: [ + { label: t('Default'), value: HeightMode.DEFAULT }, + { label: t('Specify height'), value: HeightMode.SPECIFY_VALUE }, + { label: t('Full height'), value: HeightMode.FULL_HEIGHT }, + ], + required: true, + 'x-decorator': 'FormItem', + 'x-component': 'Radio.Group', + }, + height: { + title: t('Height'), + type: 'number', + required: true, + 'x-decorator': 'FormItem', + 'x-component': InputNumber, + 'x-component-props': { + addonAfter: 'px', + }, + 'x-validator': [ + { + minimum: 40, + }, + ], + 'x-reactions': { + dependencies: ['heightMode'], + fulfill: { + state: { + hidden: '{{ $deps[0]==="fullHeight"||$deps[0]==="defaultHeight"}}', + value: '{{$deps[0]!=="specifyValue"?null:$self.value}}', + }, + }, + }, + }, + }; + }, + defaultParams(ctx) { + return { + heightMode: HeightMode.DEFAULT, + }; + }, + async handler(ctx, params) { + ctx.model.setDecoratorProps({ + heightMode: params.heightMode, + height: params.height, + }); + }, +}); diff --git a/packages/core/client/src/flow/actions/index.ts b/packages/core/client/src/flow/actions/index.ts index 69e16e880e7..344059eb964 100644 --- a/packages/core/client/src/flow/actions/index.ts +++ b/packages/core/client/src/flow/actions/index.ts @@ -31,6 +31,7 @@ export * from './pattern'; export * from './validation'; export * from './columnFixed'; export * from './linkageRulesRefresh'; +export * from './blockHeight'; export { fieldLinkageRules, subFormFieldLinkageRules, diff --git a/packages/core/client/src/flow/components/BlockItemCard.tsx b/packages/core/client/src/flow/components/BlockItemCard.tsx index 15b6715193e..9120b22c087 100644 --- a/packages/core/client/src/flow/components/BlockItemCard.tsx +++ b/packages/core/client/src/flow/components/BlockItemCard.tsx @@ -8,25 +8,163 @@ */ import { Card, CardProps, theme } from 'antd'; -import _ from 'lodash'; -import React from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { NAMESPACE_UI_SCHEMA } from '../../i18n/constant'; import { DisplayMarkdown } from '../internal/components/Markdown/DisplayMarkdown'; +import { useFlowContext } from '@nocobase/flow-engine'; -const useBlockHeight = ({ height, heightMode }) => { - if (heightMode !== 'specifyValue') { - return null; +const getRootElement = (element: HTMLElement | null) => { + if (!element) return document.documentElement; + return ( + (element.closest('.nb-block-grid') as HTMLElement | null) || + (element.closest('.nb-page-wrapper') as HTMLElement | null) || + (element.closest('.nb-page') as HTMLElement | null) || + document.documentElement + ); +}; + +const getOuterHeight = (element?: HTMLElement | null) => { + if (!element) return 0; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const marginTop = parseFloat(style.marginTop) || 0; + const marginBottom = parseFloat(style.marginBottom) || 0; + return rect.height + marginTop + marginBottom; +}; + +const getPadding = (element: HTMLElement | null) => { + if (!element || element === document.documentElement) { + return { top: 0, bottom: 0 }; } - return height; + const style = window.getComputedStyle(element); + return { + top: parseFloat(style.paddingTop) || 0, + bottom: parseFloat(style.paddingBottom) || 0, + }; +}; + +const getPageHeader = (root: HTMLElement) => { + const page = root.closest('.nb-page') as HTMLElement | null; + if (!page) return null; + return ( + (page.querySelector('.ant-page-header') as HTMLElement | null) || + (page.querySelector('.pageHeaderCss') as HTMLElement | null) + ); +}; + +const getAddBlockContainer = (root: HTMLElement) => { + const button = root.querySelector('[data-flow-add-block]') as HTMLElement | null; + if (!button) return null; + return (button.parentElement as HTMLElement | null) || button; +}; + +function getValidPageTop(a, b) { + const aValid = a > 0; + const bValid = b > 0; + + if (aValid) return a; + if (bValid) return b; + return 0; // 都不是正数 +} + +const useBlockHeight = ({ + height, + heightMode, + cardRef, +}: { + height?: number; + heightMode?: string; + cardRef: React.RefObject; +}) => { + const [fullHeight, setFullHeight] = useState(); + const ctx = useFlowContext(); + const updateFullHeight = useCallback(() => { + if (heightMode !== 'fullHeight' || typeof window === 'undefined') { + setFullHeight((prev) => (prev === undefined ? prev : undefined)); + return; + } + const cardEl = cardRef.current; + if (!cardEl) return; + const root = getRootElement(cardEl); + const cardRect = cardEl.getBoundingClientRect(); + const rootRect = root === document.documentElement ? { top: 0 } : root.getBoundingClientRect(); + const padding = getPadding(root); + const addBlockContainer = getAddBlockContainer(root); + const pageTop = rootRect.top + padding.top; + const topOffset = Math.min(Math.max(0, cardRect.top - pageTop), 0); + let bottomOffset = padding.bottom + ctx.themeToken.marginBlock; + if (addBlockContainer) { + const gapBetween = ctx.themeToken.marginBlock; + bottomOffset = gapBetween + getOuterHeight(addBlockContainer) + padding.bottom; + } + const nextHeight = Math.max( + 0, + Math.floor(window.innerHeight - getValidPageTop(pageTop, 110) - topOffset - bottomOffset), + ); + setFullHeight((prev) => (prev === nextHeight ? prev : nextHeight)); + }, [heightMode, cardRef]); + + useLayoutEffect(() => { + updateFullHeight(); + }, [updateFullHeight]); + + useEffect(() => { + if (heightMode !== 'fullHeight' || typeof window === 'undefined') return; + const cardEl = cardRef.current; + if (!cardEl || typeof ResizeObserver === 'undefined') return; + const root = getRootElement(cardEl); + const pageHeader = getPageHeader(root); + const addBlockContainer = getAddBlockContainer(root); + const observer = new ResizeObserver(() => updateFullHeight()); + observer.observe(cardEl); + if (root instanceof HTMLElement) { + observer.observe(root); + } + if (pageHeader) observer.observe(pageHeader); + if (addBlockContainer) observer.observe(addBlockContainer); + window.addEventListener('resize', updateFullHeight); + return () => { + observer.disconnect(); + window.removeEventListener('resize', updateFullHeight); + }; + }, [heightMode, cardRef, updateFullHeight]); + + if (heightMode === 'specifyValue') { + return height; + } + if (heightMode === 'fullHeight') { + return fullHeight; + } + return null; }; export const BlockItemCard = React.forwardRef( - (props: CardProps & { beforeContent?: React.ReactNode; afterContent?: React.ReactNode; description?: any }, ref) => { + ( + props: CardProps & { + beforeContent?: React.ReactNode; + afterContent?: React.ReactNode; + description?: any; + heightMode?: string; + }, + ref, + ) => { const { t } = useTranslation(); const { token } = theme.useToken(); - const { title: blockTitle, description, children, className, ...rest } = props; - const height = useBlockHeight(props as any); + const { title: blockTitle, description, children, className, heightMode, ...rest } = props; + const cardRef = useRef(null); + const setCardRef = useCallback( + (node: HTMLDivElement | null) => { + cardRef.current = node; + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + }, + [ref], + ); + const height = useBlockHeight({ ...(props as any), cardRef }); const title = (blockTitle || description) && (
{t(blockTitle as any, { ns: NAMESPACE_UI_SCHEMA })} @@ -46,7 +184,7 @@ export const BlockItemCard = React.forwardRef( ); return ( - }>{this.context.t('Add block')} + } data-flow-add-block> + {this.context.t('Add block')} + ); } @@ -70,10 +72,11 @@ export class BlockGridModel extends GridModel { render() { return (
{super.render()} diff --git a/packages/core/client/src/flow/models/base/BlockModel.tsx b/packages/core/client/src/flow/models/base/BlockModel.tsx index 5a7ecaf1808..ada7fa0b7a3 100644 --- a/packages/core/client/src/flow/models/base/BlockModel.tsx +++ b/packages/core/client/src/flow/models/base/BlockModel.tsx @@ -138,54 +138,9 @@ BlockModel.registerFlow({ linkageRules: { use: 'blockLinkageRules', }, - // setBlockHeight: { - // title: tval('Set block height'), - // uiSchema: { - // heightMode: { - // type: 'string', - // enum: [ - // { label: tval('Default'), value: HeightMode.DEFAULT }, - // { label: tval('Specify height'), value: HeightMode.SPECIFY_VALUE }, - // { label: tval('Full height'), value: HeightMode.FULL_HEIGHT }, - // ], - // required: true, - // 'x-decorator': 'FormItem', - // 'x-component': 'Radio.Group', - // }, - // height: { - // title: tval('Height'), - // type: 'string', - // required: true, - // 'x-decorator': 'FormItem', - // 'x-component': 'NumberPicker', - // 'x-component-props': { - // addonAfter: 'px', - // }, - // 'x-validator': [ - // { - // minimum: 40, - // }, - // ], - // 'x-reactions': { - // dependencies: ['heightMode'], - // fulfill: { - // state: { - // hidden: '{{ $deps[0]==="fullHeight"||$deps[0]==="defaultHeight"}}', - // value: '{{$deps[0]!=="specifyValue"?null:$self.value}}', - // }, - // }, - // }, - // }, - // }, - // defaultParams: () => { - // return { - // heightMode: HeightMode.DEFAULT, - // }; - // }, - // handler(ctx, params) { - // ctx.model.setDecoratorProps({ heightMode: params.heightMode, height: params.height }); - // }, - // }, + blockHeight: { + use: 'blockHeight', + }, }, }); diff --git a/packages/core/client/src/flow/models/blocks/details/DetailsBlockModel.tsx b/packages/core/client/src/flow/models/blocks/details/DetailsBlockModel.tsx index 14b8993f752..ae1bd91eb6e 100644 --- a/packages/core/client/src/flow/models/blocks/details/DetailsBlockModel.tsx +++ b/packages/core/client/src/flow/models/blocks/details/DetailsBlockModel.tsx @@ -23,12 +23,12 @@ import { tExpr, } from '@nocobase/flow-engine'; import { Pagination, Space } from 'antd'; -import _ from 'lodash'; -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { BlockGridModel, BlockSceneEnum, CollectionBlockModel, RecordActionModel } from '../../base'; import { FormComponent } from '../form/FormBlockModel'; import { DetailsGridModel } from './DetailsGridModel'; import { dispatchEventDeep } from '../../../utils'; +import { useDetailsGridHeight } from './utils'; export class DetailsBlockModel extends CollectionBlockModel<{ parent?: BlockGridModel; @@ -141,16 +141,17 @@ export class DetailsBlockModel extends CollectionBlockModel<{ renderComponent() { const { colon, labelAlign, labelWidth, labelWrap, layout } = this.props; const isConfigMode = !!this.context.flowSettingsEnabled; + const { heightMode, height } = this.decoratorProps; return ( - <> - -
+ {this.mapSubModels('actions', (action) => { if (action.hidden && !isConfigMode) { @@ -174,17 +175,90 @@ export class DetailsBlockModel extends CollectionBlockModel<{ })} {this.renderConfigureActions()} -
-
- - - - {this.renderPagination()} - + + } + /> ); } } +const DetailsBlockContent = ({ + model, + gridModel, + isConfigMode, + heightMode, + height, + layoutProps, + actions, +}: { + model: DetailsBlockModel; + gridModel: DetailsGridModel; + isConfigMode: boolean; + heightMode?: string; + height?: number; + layoutProps?: any; + actions?: React.ReactNode; +}) => { + const containerRef = useRef(null); + const actionsRef = useRef(null); + const paginationRef = useRef(null); + const isFixedHeight = heightMode === 'specifyValue' || heightMode === 'fullHeight'; + const gridHeight = useDetailsGridHeight({ + heightMode, + containerRef, + actionsRef, + paginationRef, + deps: [height], + }); + + useEffect(() => { + if (!gridModel) return; + const nextHeight = isFixedHeight ? gridHeight : undefined; + if (gridModel.props?.height === nextHeight && gridModel.props?.heightMode === heightMode) return; + gridModel.setProps({ height: nextHeight, heightMode }); + }, [gridModel, gridHeight, isFixedHeight, heightMode]); + + const formStyle = isFixedHeight + ? { + display: 'flex', + flexDirection: 'column', + minHeight: 0, + height: '100%', + } + : undefined; + const containerStyle: any = isFixedHeight + ? { + display: 'flex', + flexDirection: 'column', + minHeight: 0, + flex: 1, + } + : undefined; + + return ( + +
+
+ {actions} +
+ +
{model.renderPagination()}
+
+
+ ); +}; + DetailsBlockModel.registerFlow({ key: 'detailsSettings', title: tExpr('Details settings'), diff --git a/packages/core/client/src/flow/models/blocks/details/DetailsGridModel.tsx b/packages/core/client/src/flow/models/blocks/details/DetailsGridModel.tsx index f14ac336983..29482ae2317 100644 --- a/packages/core/client/src/flow/models/blocks/details/DetailsGridModel.tsx +++ b/packages/core/client/src/flow/models/blocks/details/DetailsGridModel.tsx @@ -67,6 +67,30 @@ export class DetailsGridModel extends GridModel<{ ); } + + render() { + const height = this.props?.height; + const heightMode = this.props?.heightMode; + const token = this.context.themeToken; + const content = super.render(); + if (heightMode === 'defaultHeight') { + return content; + } + return ( +
+ {content} +
+ ); + } } DetailsGridModel.registerFlow({ diff --git a/packages/core/client/src/flow/models/blocks/details/__tests__/DetailsBlockModel.blockHeight.test.tsx b/packages/core/client/src/flow/models/blocks/details/__tests__/DetailsBlockModel.blockHeight.test.tsx new file mode 100644 index 00000000000..b228ad4b58d --- /dev/null +++ b/packages/core/client/src/flow/models/blocks/details/__tests__/DetailsBlockModel.blockHeight.test.tsx @@ -0,0 +1,139 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React, { useRef } from 'react'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { useDetailsGridHeight } from '../utils'; + +const createRect = (height: number) => ({ + x: 0, + y: 0, + width: 100, + height, + top: 0, + left: 0, + right: 100, + bottom: height, + toJSON: () => {}, +}); + +const setRect = (node: HTMLElement | null, height: number) => { + if (!node) return; + node.getBoundingClientRect = () => createRect(height); +}; + +const HeightProbe = ({ + heightMode, + containerHeight, + actionsHeight, + paginationHeight, + depsKey, +}: { + heightMode?: string; + containerHeight: number; + actionsHeight: number; + paginationHeight: number; + depsKey: number; +}) => { + const containerRef = useRef(null); + const actionsRef = useRef(null); + const paginationRef = useRef(null); + const gridHeight = useDetailsGridHeight({ + heightMode, + containerRef, + actionsRef, + paginationRef, + deps: [depsKey], + }); + + return ( +
+
{ + containerRef.current = node; + setRect(node, containerHeight); + }} + > +
{ + actionsRef.current = node; + setRect(node, actionsHeight); + }} + /> +
+
{ + paginationRef.current = node; + setRect(node, paginationHeight); + }} + /> +
+ {gridHeight === undefined ? 'undefined' : String(gridHeight)} +
+ ); +}; + +describe('DetailsBlockModel block height', () => { + const originalResizeObserver = globalThis.ResizeObserver; + + beforeAll(() => { + if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as any; + } + }); + + afterAll(() => { + globalThis.ResizeObserver = originalResizeObserver; + }); + + it('calculates grid height when heightMode is fixed', async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('grid-height').textContent).toBe('340'); + }); + }); + + it('clears grid height when heightMode is not fixed', async () => { + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('grid-height').textContent).toBe('340'); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('grid-height').textContent).toBe('undefined'); + }); + }); +}); diff --git a/packages/core/client/src/flow/models/blocks/details/utils.ts b/packages/core/client/src/flow/models/blocks/details/utils.ts new file mode 100644 index 00000000000..b21ef345b10 --- /dev/null +++ b/packages/core/client/src/flow/models/blocks/details/utils.ts @@ -0,0 +1,67 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; + +const getOuterHeight = (element?: HTMLElement | null) => { + if (!element) return 0; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const marginTop = parseFloat(style.marginTop) || 0; + const marginBottom = parseFloat(style.marginBottom) || 0; + return rect.height + marginTop + marginBottom; +}; + +export const useDetailsGridHeight = ({ + heightMode, + containerRef, + actionsRef, + paginationRef, + deps = [], +}: { + heightMode?: string; + containerRef: React.RefObject; + actionsRef: React.RefObject; + paginationRef: React.RefObject; + deps?: React.DependencyList; +}) => { + const [gridHeight, setGridHeight] = useState(); + const calcGridHeight = useCallback(() => { + if (heightMode !== 'specifyValue' && heightMode !== 'fullHeight') { + setGridHeight((prev) => (prev === undefined ? prev : undefined)); + return; + } + const container = containerRef.current; + if (!container) return; + const containerHeight = container.getBoundingClientRect().height; + if (!containerHeight) return; + const actionsHeight = getOuterHeight(actionsRef.current); + const paginationHeight = getOuterHeight(paginationRef.current); + const nextHeight = Math.max(0, Math.floor(containerHeight - actionsHeight - paginationHeight)); + setGridHeight((prev) => (prev === nextHeight ? prev : nextHeight)); + }, [heightMode, containerRef, actionsRef, paginationRef]); + + useLayoutEffect(() => { + calcGridHeight(); + }, [calcGridHeight, ...deps]); + + useEffect(() => { + if (!containerRef.current || typeof ResizeObserver === 'undefined') return; + const container = containerRef.current; + const actions = actionsRef.current; + const pagination = paginationRef.current; + const observer = new ResizeObserver(() => calcGridHeight()); + observer.observe(container); + if (actions) observer.observe(actions); + if (pagination) observer.observe(pagination); + return () => observer.disconnect(); + }, [calcGridHeight, containerRef, actionsRef, paginationRef, ...deps]); + + return gridHeight; +}; diff --git a/packages/core/client/src/flow/models/blocks/form/CreateFormModel.tsx b/packages/core/client/src/flow/models/blocks/form/CreateFormModel.tsx index 5b95f485e5a..bc682d2a890 100644 --- a/packages/core/client/src/flow/models/blocks/form/CreateFormModel.tsx +++ b/packages/core/client/src/flow/models/blocks/form/CreateFormModel.tsx @@ -19,7 +19,7 @@ import { import { Space } from 'antd'; import React from 'react'; import { BlockSceneEnum } from '../../base/BlockModel'; -import { FormBlockModel, FormComponent } from './FormBlockModel'; +import { FormBlockContent, FormBlockModel } from './FormBlockModel'; import { submitHandler } from './submitHandler'; // CreateFormModel - 专门用于新增记录 @@ -56,30 +56,38 @@ export class CreateFormModel extends FormBlockModel { renderComponent() { const { colon, labelAlign, labelWidth, labelWrap, layout } = this.props; const isConfigMode = !!this.context.flowSettingsEnabled; + const { heightMode, height } = this.decoratorProps; return ( - - - - - {this.mapSubModels('actions', (action) => { - if (action.hidden && !isConfigMode) { - return; - } - return ( - - - - ); - })} - {this.renderConfigureActions()} - - - + } + actions={ + + + {this.mapSubModels('actions', (action) => { + if (action.hidden && !isConfigMode) { + return; + } + return ( + + + + ); + })} + {this.renderConfigureActions()} + + + } + /> ); } } diff --git a/packages/core/client/src/flow/models/blocks/form/EditFormModel.tsx b/packages/core/client/src/flow/models/blocks/form/EditFormModel.tsx index 8a9d4d09ddd..ad01b7e9c88 100644 --- a/packages/core/client/src/flow/models/blocks/form/EditFormModel.tsx +++ b/packages/core/client/src/flow/models/blocks/form/EditFormModel.tsx @@ -22,7 +22,7 @@ import { Pagination, Space } from 'antd'; import { isEqual } from 'lodash'; import React from 'react'; import { BlockSceneEnum } from '../../base'; -import { FormBlockModel, FormComponent } from './FormBlockModel'; +import { FormBlockContent, FormBlockModel } from './FormBlockModel'; import { submitHandler } from './submitHandler'; import { dispatchEventDeep } from '../../../utils'; @@ -87,49 +87,59 @@ export class EditFormModel extends FormBlockModel { renderComponent() { const { colon, labelAlign, labelWidth, labelWrap, layout } = this.props; const isConfigMode = !!this.context.flowSettingsEnabled; + const { heightMode, height } = this.decoratorProps; + const footer = + this.isMultiRecordResource() && this.resource.getMeta('count') > 1 ? ( +
+ +
+ ) : null; return ( - - - - - {this.mapSubModels('actions', (action) => { - if (action.hidden && !isConfigMode) { - return; - } - return ( - - - - ); - })} - {this.renderConfigureActions()} - - - {this.isMultiRecordResource() && this.resource.getMeta('count') > 1 && ( -
- -
- )} -
+ } + actions={ + + + {this.mapSubModels('actions', (action) => { + if (action.hidden && !isConfigMode) { + return; + } + return ( + + + + ); + })} + {this.renderConfigureActions()} + + + } + footer={footer} + /> ); } } diff --git a/packages/core/client/src/flow/models/blocks/form/FormBlockModel.tsx b/packages/core/client/src/flow/models/blocks/form/FormBlockModel.tsx index cdc329da0bc..c68a2ba5610 100644 --- a/packages/core/client/src/flow/models/blocks/form/FormBlockModel.tsx +++ b/packages/core/client/src/flow/models/blocks/form/FormBlockModel.tsx @@ -18,7 +18,7 @@ import { } from '@nocobase/flow-engine'; import { Form, FormInstance } from 'antd'; import { omit } from 'lodash'; -import React from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { commonConditionHandler, ConditionBuilder } from '../../../components/ConditionBuilder'; import { BlockGridModel } from '../../base/BlockGridModel'; import { CollectionBlockModel } from '../../base/CollectionBlockModel'; @@ -338,6 +338,7 @@ export function FormComponent({ layoutProps?: any; initialValues?: any; onFinish?: (values: any) => void; + [key: string]: any; }) { return (
; + actionsRef?: React.RefObject; + footerRef?: React.RefObject; + deps?: React.DependencyList; +}; + +const getOuterHeight = (element?: HTMLElement | null) => { + if (!element) return 0; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const marginTop = parseFloat(style.marginTop) || 0; + const marginBottom = parseFloat(style.marginBottom) || 0; + return rect.height + marginTop + marginBottom; +}; + +const useFormGridHeight = ({ + heightMode, + containerRef, + actionsRef, + footerRef, + deps = [], +}: UseFormGridHeightOptions) => { + const [gridHeight, setGridHeight] = useState(); + + const calcGridHeight = useCallback(() => { + if (heightMode !== 'specifyValue' && heightMode !== 'fullHeight') { + setGridHeight((prev) => (prev === undefined ? prev : undefined)); + return; + } + const container = containerRef.current; + if (!container) return; + const containerHeight = container.getBoundingClientRect().height; + if (!containerHeight) return; + const actionsHeight = getOuterHeight(actionsRef?.current || null); + const footerHeight = getOuterHeight(footerRef?.current || null); + const nextHeight = Math.max(0, Math.floor(containerHeight - actionsHeight - footerHeight)); + setGridHeight((prev) => (prev === nextHeight ? prev : nextHeight)); + }, [heightMode, containerRef, actionsRef, footerRef]); + + useLayoutEffect(() => { + calcGridHeight(); + }, [calcGridHeight, ...deps]); + + useEffect(() => { + if (!containerRef.current || typeof ResizeObserver === 'undefined') return; + const container = containerRef.current; + const actions = actionsRef?.current || null; + const footer = footerRef?.current || null; + const observer = new ResizeObserver(() => calcGridHeight()); + observer.observe(container); + if (actions) observer.observe(actions); + if (footer) observer.observe(footer); + return () => observer.disconnect(); + }, [calcGridHeight, containerRef, actionsRef, footerRef, ...deps]); + + return gridHeight; +}; + +type FormBlockContentProps = { + model: FormBlockModel; + gridModel: FormGridModel; + layoutProps?: any; + onFinish?: (values: any) => void; + grid: React.ReactNode; + actions?: React.ReactNode; + footer?: React.ReactNode; + heightMode?: string; + height?: number; +}; + +export const FormBlockContent = ({ + model, + gridModel, + layoutProps, + onFinish, + grid, + actions, + footer, + heightMode, + height, +}: FormBlockContentProps) => { + const containerRef = useRef(null); + const actionsRef = useRef(null); + const footerRef = useRef(null); + const isFixedHeight = heightMode === 'specifyValue' || heightMode === 'fullHeight'; + const gridHeight = useFormGridHeight({ + heightMode, + containerRef, + actionsRef: actions ? actionsRef : undefined, + footerRef: footer ? footerRef : undefined, + deps: [height], + }); + + useEffect(() => { + if (!gridModel) return; + const nextHeight = isFixedHeight ? gridHeight : undefined; + if (gridModel.props?.height === nextHeight) return; + gridModel.setProps({ height: nextHeight }); + }, [gridModel, gridHeight, isFixedHeight]); + + const formStyle = isFixedHeight + ? { + display: 'flex', + flexDirection: 'column', + minHeight: 0, + height: '100%', + } + : undefined; + + const containerStyle: any = isFixedHeight + ? { + display: 'flex', + flexDirection: 'column', + minHeight: 0, + flex: 1, + } + : undefined; + + return ( + +
+ {grid} + {actions ? ( +
+ {actions} +
+ ) : null} + {footer ?
{footer}
: null} +
+
+ ); +}; + FormBlockModel.define({ hide: true, }); diff --git a/packages/core/client/src/flow/models/blocks/form/FormGridModel.tsx b/packages/core/client/src/flow/models/blocks/form/FormGridModel.tsx index 0685b8b455e..e3822eb34e5 100644 --- a/packages/core/client/src/flow/models/blocks/form/FormGridModel.tsx +++ b/packages/core/client/src/flow/models/blocks/form/FormGridModel.tsx @@ -65,6 +65,30 @@ export class FormGridModel ); } + + render() { + const height = this.props?.height; + const heightModel = this.props?.heightModel; + const token = this.context.themeToken; + const content = super.render(); + if (heightModel === 'defaultHeight') { + return content; + } + return ( +
+ {content} +
+ ); + } } FormGridModel.registerFlow({ diff --git a/packages/core/client/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx b/packages/core/client/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx index b828353d4a4..5ba2b6be858 100644 --- a/packages/core/client/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +++ b/packages/core/client/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx @@ -7,12 +7,12 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React from 'react'; -import { render } from '@testing-library/react'; +import React, { useRef } from 'react'; +import { render, waitFor } from '@testing-library/react'; import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; import { FlowEngine, FlowModel, SingleRecordResource } from '@nocobase/flow-engine'; // 直接从 models 聚合导入,避免局部文件相互引用顺序导致的循环依赖 -import { FormBlockModel } from '../../../..'; +import { FormBlockContent, FormBlockModel } from '../../../..'; import { Application } from '../../../../../application/Application'; import { InputFieldInterface, @@ -21,6 +21,7 @@ import { M2OFieldInterface, NumberFieldInterface, } from '../../../../../collection-manager/interfaces'; +import { Form } from 'antd'; // ----------------------------- // Helpers // ----------------------------- @@ -558,3 +559,115 @@ describe('FormBlockModel (form/formValues injection & server resolve anchors)', expect(out).toEqual({ who: 'L1' }); }); }); + +const createRect = (height: number) => ({ + x: 0, + y: 0, + width: 100, + height, + top: 0, + left: 0, + right: 100, + bottom: height, + toJSON: () => {}, +}); + +const setRect = (node: HTMLElement | null, height: number) => { + if (!node) return; + node.getBoundingClientRect = () => createRect(height); +}; + +const FormContentHarness = ({ + heightMode, + height, + gridModel, +}: { + heightMode?: string; + height?: number; + gridModel: any; +}) => { + const [form] = Form.useForm(); + const modelRef = useRef(); + if (!modelRef.current) { + modelRef.current = { + form, + context: { view: { inputArgs: {} }, record: {} }, + markUserModifiedFields: vi.fn(), + dispatchEvent: vi.fn(), + emitter: { emit: vi.fn() }, + }; + } + + return ( + } + actions={
} + footer={
} + /> + ); +}; + +describe('FormBlockModel block height', () => { + const originalResizeObserver = globalThis.ResizeObserver; + + beforeEach(() => { + if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as any; + } + }); + + afterEach(() => { + globalThis.ResizeObserver = originalResizeObserver; + }); + + it('updates grid height when heightMode is fixed', async () => { + const gridModel: any = { + props: {}, + setProps: vi.fn(), + }; + gridModel.setProps = vi.fn((next) => Object.assign(gridModel.props, next)); + + const { container, rerender } = render( + , + ); + + gridModel.setProps.mockClear(); + + const gridEl = container.querySelector('[data-testid="grid"]') as HTMLElement; + const containerEl = gridEl?.parentElement as HTMLElement; + const actionsWrapper = container.querySelector('[data-testid="actions"]')?.parentElement as HTMLElement; + const footerWrapper = container.querySelector('[data-testid="footer"]')?.parentElement as HTMLElement; + + setRect(containerEl, 400); + setRect(actionsWrapper, 40); + setRect(footerWrapper, 20); + + rerender(); + + await waitFor(() => { + expect(gridModel.setProps).toHaveBeenLastCalledWith({ height: 340 }); + }); + }); + + it('clears grid height when heightMode is not fixed', async () => { + const gridModel: any = { + props: { height: 120 }, + setProps: vi.fn(), + }; + gridModel.setProps = vi.fn((next) => Object.assign(gridModel.props, next)); + + render(); + + await waitFor(() => { + expect(gridModel.setProps).toHaveBeenCalledWith({ height: undefined }); + }); + }); +}); diff --git a/packages/core/client/src/flow/models/blocks/table/TableBlockModel.tsx b/packages/core/client/src/flow/models/blocks/table/TableBlockModel.tsx index 9ceab14ee15..f09e31bda78 100644 --- a/packages/core/client/src/flow/models/blocks/table/TableBlockModel.tsx +++ b/packages/core/client/src/flow/models/blocks/table/TableBlockModel.tsx @@ -34,7 +34,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActionModel, BlockSceneEnum, CollectionBlockModel } from '../../base'; import { QuickEditFormModel } from '../form/QuickEditFormModel'; import { TableColumnModel } from './TableColumnModel'; -import { extractIndex, adjustColumnOrder, setNestedValue, extractIds, getRowKey } from './utils'; +import { extractIndex, adjustColumnOrder, setNestedValue, extractIds, getRowKey, useBlockHeight } from './utils'; import { commonConditionHandler, ConditionBuilder } from '../../../components/ConditionBuilder'; import { HighPerformanceSpin } from '../../../../schema-component/common/high-performance-spin/HighPerformanceSpin'; @@ -444,6 +444,7 @@ export class TableBlockModel extends CollectionBlockModel ) : ( @@ -506,24 +507,80 @@ export class TableBlockModel extends CollectionBlockModel
- - - + ); } } +const TableBlockContent = (props: { + model: TableBlockModel; + size: any; + virtual: boolean; + dataSource: any; + columns: any; + pagination: any; + highlightedRowKey: string; + defaultExpandAllRows?: boolean; + expandedRowKeys?: any[]; + heightMode?: string; + height?: number; +}) => { + const { + model, + size, + virtual, + dataSource, + columns, + pagination, + highlightedRowKey, + defaultExpandAllRows, + expandedRowKeys, + heightMode, + height, + } = props; + const tableAreaRef = useRef(null); + const scrollY = useBlockHeight({ + heightMode, + tableAreaRef, + deps: [height, heightMode], + }); + const tableScroll = useMemo(() => { + const y = scrollY && scrollY > 0 && dataSource?.length ? scrollY : undefined; + return { x: 'max-content', y }; + }, [scrollY, dataSource?.length]); + return ( +
+ + + +
+ ); +}; + TableBlockModel.registerFlow({ key: 'resourceSettings2', steps: {}, @@ -713,7 +770,6 @@ TableBlockModel.define({ sort: 300, }); -const tableScroll = { x: 'max-content' }; const HighPerformanceTable = React.memo( (props: { model: TableBlockModel; @@ -725,6 +781,7 @@ const HighPerformanceTable = React.memo( highlightedRowKey: string; defaultExpandAllRows?: boolean; expandedRowKeys?: any[]; + tableScroll; }) => { const { model, @@ -736,6 +793,7 @@ const HighPerformanceTable = React.memo( highlightedRowKey, defaultExpandAllRows, expandedRowKeys, + tableScroll, } = props; const [selectedRowKeys, setSelectedRowKeys] = React.useState(() => model.resource.getSelectedRows().map((row) => getRowKey(row, model.collection.filterTargetKey)), diff --git a/packages/core/client/src/flow/models/blocks/table/__tests__/TableBlockModel.blockHeight.test.tsx b/packages/core/client/src/flow/models/blocks/table/__tests__/TableBlockModel.blockHeight.test.tsx new file mode 100644 index 00000000000..e1f6b3fb00a --- /dev/null +++ b/packages/core/client/src/flow/models/blocks/table/__tests__/TableBlockModel.blockHeight.test.tsx @@ -0,0 +1,120 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React, { useRef } from 'react'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { useBlockHeight } from '../utils'; + +const createRect = (height: number) => ({ + x: 0, + y: 0, + width: 100, + height, + top: 0, + left: 0, + right: 100, + bottom: height, + toJSON: () => {}, +}); + +const setRect = (node: HTMLElement | null, height: number) => { + if (!node) return; + node.getBoundingClientRect = () => createRect(height); +}; + +const HeightProbe = ({ + heightMode, + areaHeight, + headerHeight, + paginationHeight, + depsKey, +}: { + heightMode?: string; + areaHeight: number; + headerHeight: number; + paginationHeight: number; + depsKey: number; +}) => { + const tableAreaRef = useRef(null); + const scrollY = useBlockHeight({ + heightMode, + tableAreaRef, + deps: [depsKey], + }); + + return ( +
+
{ + tableAreaRef.current = node; + setRect(node, areaHeight); + }} + > +
{ + setRect(node, headerHeight); + }} + /> +
{ + setRect(node, paginationHeight); + }} + /> +
+ {scrollY === undefined ? 'undefined' : String(scrollY)} +
+ ); +}; + +describe('TableBlockModel block height', () => { + const originalResizeObserver = globalThis.ResizeObserver; + + beforeAll(() => { + if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as any; + } + }); + + afterAll(() => { + globalThis.ResizeObserver = originalResizeObserver; + }); + + it('calculates scrollY when heightMode is fixed', async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('scroll').textContent).toBe('230'); + }); + }); + + it('clears scrollY when heightMode is not fixed', async () => { + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('scroll').textContent).toBe('230'); + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByTestId('scroll').textContent).toBe('undefined'); + }); + }); +}); diff --git a/packages/core/client/src/flow/models/blocks/table/utils.ts b/packages/core/client/src/flow/models/blocks/table/utils.ts index 302fb23290d..edc4ce14186 100644 --- a/packages/core/client/src/flow/models/blocks/table/utils.ts +++ b/packages/core/client/src/flow/models/blocks/table/utils.ts @@ -6,7 +6,7 @@ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. * For more information, please refer to: https://www.nocobase.com/agreement. */ - +import { useState, useEffect, useLayoutEffect, useCallback } from 'react'; export function extractIndex(str) { const numbers = []; str?.split('.').forEach(function (element) { @@ -78,3 +78,48 @@ export function getRowKey(record: any, key: string | string[]) { } return record?.[key] ?? ''; } + +type UseBlockHeightOptions = { + heightMode?: string; + tableAreaRef: React.RefObject; + deps?: React.DependencyList; +}; + +export const useBlockHeight = ({ heightMode, tableAreaRef, deps = [] }: UseBlockHeightOptions) => { + const [scrollY, setScrollY] = useState(); + + const calcScrollY = useCallback(() => { + if (heightMode !== 'specifyValue' && heightMode !== 'fullHeight') { + setScrollY((prev) => (prev === undefined ? prev : undefined)); + return; + } + const tableArea = tableAreaRef.current; + if (!tableArea) return; + const areaHeight = tableArea.getBoundingClientRect().height; + if (!areaHeight) return; + const headerEl = tableArea.querySelector('.ant-table-header') || tableArea.querySelector('.ant-table-thead'); + const paginationEl = tableArea.querySelector('.ant-table-pagination'); + const headerHeight = headerEl?.getBoundingClientRect().height ?? 0; + const paginationHeight = paginationEl?.getBoundingClientRect().height ?? 0; + const nextScrollY = Math.max(0, Math.floor(areaHeight - headerHeight - paginationHeight)); + setScrollY((prev) => (prev === nextScrollY ? prev : nextScrollY)); + }, [heightMode, tableAreaRef]); + + useLayoutEffect(() => { + calcScrollY(); + }, [calcScrollY, ...deps]); + + useEffect(() => { + if (!tableAreaRef.current || typeof ResizeObserver === 'undefined') return; + const tableArea = tableAreaRef.current; + const headerEl = tableArea.querySelector('.ant-table-header') || tableArea.querySelector('.ant-table-thead'); + const paginationEl = tableArea.querySelector('.ant-table-pagination'); + const observer = new ResizeObserver(() => calcScrollY()); + observer.observe(tableArea); + if (headerEl) observer.observe(headerEl); + if (paginationEl) observer.observe(paginationEl); + return () => observer.disconnect(); + }, [calcScrollY, tableAreaRef, ...deps]); + + return scrollY; +}; diff --git a/packages/core/client/src/locale/zh-CN.json b/packages/core/client/src/locale/zh-CN.json index a157bda640c..188acad1647 100644 --- a/packages/core/client/src/locale/zh-CN.json +++ b/packages/core/client/src/locale/zh-CN.json @@ -1593,10 +1593,11 @@ "Cascader": "级联选择", "Subform":"子表单", "Edit popup (Add new)":"编辑弹窗(添加)", - "Edit popup (Select record)":"编辑弹窗(选择记录)", - "Remove record":"移除记录", "Are you sure you want to remove it?":"你确定要移除吗?", "Create record":"创建记录", "Duplicate record":"复制记录", - "Are you sure you want to duplicate it?":"你确定要复制吗?" + "Are you sure you want to duplicate it?":"你确定要复制吗?", + "Edit popup (Select record)":"编辑弹窗(选择纪录)", + "Remove record":"移除记录", + "Block Height":"区块高度" } diff --git a/packages/plugins/@nocobase/plugin-block-grid-card/src/client/models/GridCardBlockModel.tsx b/packages/plugins/@nocobase/plugin-block-grid-card/src/client/models/GridCardBlockModel.tsx index c29644cb310..2a3edfab856 100644 --- a/packages/plugins/@nocobase/plugin-block-grid-card/src/client/models/GridCardBlockModel.tsx +++ b/packages/plugins/@nocobase/plugin-block-grid-card/src/client/models/GridCardBlockModel.tsx @@ -16,10 +16,11 @@ import { AddSubModelButton, FlowSettingsButton, FlowModel, + observer, } from '@nocobase/flow-engine'; import { SettingOutlined } from '@ant-design/icons'; import { CollectionBlockModel, BlockSceneEnum, ActionModel } from '@nocobase/client'; -import React from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { List, Space, Slider, Grid, InputNumber, Col } from 'antd'; import { css } from '@emotion/css'; import { GridCardItemModel } from './GridCardItemModel'; @@ -137,103 +138,221 @@ export class GridCardBlockModel extends CollectionBlockModel - -
- - {this.mapSubModels('actions', (action) => { - // @ts-ignore - if (action.props.position === 'left') { - return ( - - ); - } - - return null; - })} - {/* 占位 */} - - - - {this.mapSubModels('actions', (action) => { - if (action.hidden && !isConfigMode) { - return; - } - // @ts-ignore - if (action.props.position !== 'left') { - return ( - - - - ); - } - - return null; - })} - {this.renderConfiguireActions()} - -
-
- { - const model = this.subModels.item.createFork({}, `${index}`); - model.context.defineProperty('record', { - get: () => item, - cache: false, - resolveOnServer: true, - }); - model.context.defineProperty('index', { - get: () => index, - cache: false, - resolveOnServer: true, - }); - return ( - div { - height: 100%; - } - `} - > - - - ); - }} - /> - - ); + const { heightMode, height } = this.decoratorProps; + return ; } } +const getOuterHeight = (element?: HTMLElement | null) => { + if (!element) return 0; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const marginTop = parseFloat(style.marginTop) || 0; + const marginBottom = parseFloat(style.marginBottom) || 0; + return rect.height + marginTop + marginBottom; +}; + +const useGridCardHeight = ({ + heightMode, + containerRef, + actionsRef, + listRef, + deps = [], +}: { + heightMode?: string; + containerRef: React.RefObject; + actionsRef: React.RefObject; + listRef: React.RefObject; + deps?: React.DependencyList; +}) => { + const [listHeight, setListHeight] = useState(); + const calcListHeight = useCallback(() => { + if (heightMode !== 'specifyValue' && heightMode !== 'fullHeight') { + setListHeight((prev) => (prev === undefined ? prev : undefined)); + return; + } + const container = containerRef.current; + if (!container) return; + const containerHeight = container.getBoundingClientRect().height; + if (!containerHeight) return; + const actionsHeight = getOuterHeight(actionsRef.current); + const paginationEl = listRef.current?.querySelector('.ant-list-pagination') as HTMLElement | null; + const paginationHeight = getOuterHeight(paginationEl); + const nextHeight = Math.max(0, Math.floor(containerHeight - actionsHeight - paginationHeight)); + setListHeight((prev) => (prev === nextHeight ? prev : nextHeight)); + }, [heightMode, containerRef, actionsRef, listRef]); + + useLayoutEffect(() => { + calcListHeight(); + }, [calcListHeight, ...deps]); + + useEffect(() => { + if (!containerRef.current || typeof ResizeObserver === 'undefined') return; + const container = containerRef.current; + const actions = actionsRef.current; + const paginationEl = listRef.current?.querySelector('.ant-list-pagination') as HTMLElement | null; + const observer = new ResizeObserver(() => calcListHeight()); + observer.observe(container); + if (actions) observer.observe(actions); + if (paginationEl) observer.observe(paginationEl); + return () => observer.disconnect(); + }, [calcListHeight, containerRef, actionsRef, listRef, ...deps]); + + return listHeight; +}; + +const GridCardBlockContent = observer( + ({ model, heightMode, height }: { model: GridCardBlockModel; heightMode?: string; height?: number }) => { + const containerRef = useRef(null); + const actionsRef = useRef(null); + const listRef = useRef(null); + const isFixedHeight = heightMode === 'specifyValue' || heightMode === 'fullHeight'; + const ctx = model.context; + const token = ctx.themeToken; + const listHeight = useGridCardHeight({ + heightMode, + containerRef, + actionsRef, + listRef, + deps: [height], + }); + const listClassName = useMemo( + () => css` + .ant-spin-nested-loading { + height: var(--nb-grid-card-height); + overflow: auto; + margin-left: -${token.marginLG}px; + margin-right: -${token.marginLG}px; + padding-left: ${token.marginLG}px; + padding-right: ${token.marginLG}px; + } + .ant-spin-nested-loading > .ant-spin-container { + min-height: 100%; + } + `, + [], + ); + const listStyle = useMemo(() => { + if (listHeight == null) return model.props?.style; + return { + ...(model.props?.style || {}), + ['--nb-grid-card-height' as any]: `${listHeight}px`, + }; + }, [listHeight, model.props?.style]); + + const isConfigMode = !!model.context.flowSettingsEnabled; + const columnCount = model.props.columnCount; + const containerStyle: any = isFixedHeight + ? { + display: 'flex', + flexDirection: 'column', + minHeight: 0, + height: '100%', + } + : undefined; + + return ( +
+
+ +
+ + {model.mapSubModels('actions', (action) => { + // @ts-ignore + if (action.props.position === 'left') { + return ( + + ); + } + + return null; + })} + {/* 占位 */} + + + + {model.mapSubModels('actions', (action) => { + if (action.hidden && !isConfigMode) { + return; + } + // @ts-ignore + if (action.props.position !== 'left') { + return ( + + + + ); + } + + return null; + })} + {model.renderConfiguireActions()} + +
+
+
+
+ { + const itemModel = model.subModels.item.createFork({}, `${index}`); + itemModel.context.defineProperty('record', { + get: () => item, + cache: false, + resolveOnServer: true, + }); + itemModel.context.defineProperty('index', { + get: () => index, + cache: false, + resolveOnServer: true, + }); + return ( + div { + height: 100%; + } + `} + > + + + ); + }} + /> +
+
+ ); + }, +); + GridCardBlockModel.registerFlow({ key: 'resourceSettings2', steps: {}, diff --git a/packages/plugins/@nocobase/plugin-block-iframe/src/client/models/IframeBlockModel.tsx b/packages/plugins/@nocobase/plugin-block-iframe/src/client/models/IframeBlockModel.tsx index cf753acead1..36b66651a41 100644 --- a/packages/plugins/@nocobase/plugin-block-iframe/src/client/models/IframeBlockModel.tsx +++ b/packages/plugins/@nocobase/plugin-block-iframe/src/client/models/IframeBlockModel.tsx @@ -52,8 +52,8 @@ const HtmlEditorBase: React.FC = (props) => { }; const Iframe: any = observer( - (props: IIframe & { html?: string; htmlId?: number; mode: string; params?: any }) => { - const { url, htmlId, mode = 'url', html, params, height, ...others } = props; + (props: IIframe & { html?: string; htmlId?: number; mode: string; params?: any; heightMode?: string }) => { + const { url, htmlId, mode = 'url', html, params, heightMode, ...others } = props; const { token } = theme.useToken(); const compile = useCompile(); const ctx = useFlowContext(); @@ -91,7 +91,6 @@ const Iframe: any = observer( } else { try { const targetUrl = joinUrlSearch(url, params); - console.log(targetUrl); if (active) setSrc(targetUrl); } catch (error) { console.error('Error fetching target URL:', error); @@ -107,13 +106,11 @@ const Iframe: any = observer( active = false; }; }, [htmlContent, mode, url, params, html, htmlId]); - console.log(src); if (loading && !src) { return (
@@ -129,8 +126,7 @@ const Iframe: any = observer( display="block" position="relative" styles={{ - height: height || '60vh', - marginBottom: '24px', + height: heightMode === 'defaultHeight' ? '60vh' : '100%', border: 0, }} {...others} @@ -141,14 +137,15 @@ const Iframe: any = observer( ); export class IframeBlockModel extends BlockModel { - render() { + renderComponent() { const { url, htmlId, mode = 'url', html, params, ...others } = this.props; + const { heightMode } = this.decoratorProps; const token = this.context.themeToken; const t = this.context.t; if ((mode === 'url' && !url) || (mode === 'html' && !htmlId)) { return {t('Please fill in the iframe URL')}; } - return