Merge branch 'main' into next

This commit is contained in:
nocobase[bot]
2026-02-24 10:06:13 +00:00
9 changed files with 384 additions and 60 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ const { pick } = require('lodash');
exports.getAccessKeyPair = async function () {
const keyFile = resolve(process.cwd(), 'storage/.license/license-key');
if (!fs.existsSync(keyFile)) {
logger.error('License key not found');
logger.info('License key not found');
return {};
}
logger.info('License key found');
@@ -28,7 +28,6 @@ import {
VIEW_ACTIVATED_EVENT,
} from '@nocobase/flow-engine';
import { Tabs } from 'antd';
import _ from 'lodash';
import React, { ReactNode } from 'react';
import { TextAreaWithContextSelector } from '../../../components/TextAreaWithContextSelector';
import { BasePageTabModel } from './PageTabModel';
@@ -335,9 +334,18 @@ export class PageModel extends FlowModel<PageModelStructure> {
}
render() {
const token = this.context.themeToken;
const headerStyle = { ...this.props.headerStyle } as Record<string, any>;
if (token) {
headerStyle.paddingBlock = token.paddingSM;
headerStyle.paddingInline = token.paddingLG;
}
if (this.props.enableTabs) {
headerStyle.paddingBottom = 0;
}
return (
<>
{this.props.displayTitle && <PageHeader title={this.props.title} style={this.props.headerStyle} />}
{this.props.displayTitle && <PageHeader title={this.props.title} style={headerStyle} />}
{this.props.enableTabs ? this.renderTabs() : this.renderFirstTab()}
</>
);
@@ -398,6 +406,8 @@ PageModel.registerFlow({
};
},
async handler(ctx, params) {
const token = ctx.themeToken;
const tabPaddingInline = token?.paddingLG ?? 16;
ctx.model.setProps('displayTitle', params.displayTitle);
if (ctx.model.context.closable) {
ctx.model.setProps('title', ctx.t(params.title, { ns: 'lm-desktop-routes' }));
@@ -413,7 +423,7 @@ PageModel.registerFlow({
});
ctx.model.setProps('tabBarStyle', {
backgroundColor: 'var(--colorBgContainer)',
paddingInline: 16,
paddingInline: tabPaddingInline,
marginBottom: 0,
});
} else {
@@ -422,7 +432,7 @@ PageModel.registerFlow({
});
ctx.model.setProps('tabBarStyle', {
backgroundColor: 'var(--colorBgLayout)',
paddingInline: 16,
paddingInline: tabPaddingInline,
marginBottom: 0,
});
}
@@ -182,6 +182,41 @@ describe('PageModel', () => {
});
});
describe('render header spacing with tabs', () => {
it('should compact page header bottom spacing when tabs are enabled', () => {
pageModel.props = {
displayTitle: true,
enableTabs: true,
title: 'Title',
headerStyle: { backgroundColor: 'var(--colorBgLayout)' },
} as any;
pageModel.renderTabs = vi.fn(() => null);
const result = pageModel.render() as any;
const header = result.props.children[0];
expect(header.props.style).toMatchObject({
backgroundColor: 'var(--colorBgLayout)',
paddingBottom: 0,
});
});
it('should keep original header style when tabs are disabled', () => {
pageModel.props = {
displayTitle: true,
enableTabs: false,
title: 'Title',
headerStyle: { backgroundColor: 'var(--colorBgLayout)' },
} as any;
pageModel.renderFirstTab = vi.fn(() => null);
const result = pageModel.render() as any;
const header = result.props.children[0];
expect(header.props.style).toEqual({ backgroundColor: 'var(--colorBgLayout)' });
});
});
describe('dirty refresh signal', () => {
it('should invoke current tab onActive when dataSource:dirty is emitted and page is active', async () => {
const listeners: Record<string, any> = {};
@@ -36,6 +36,13 @@ import { QuickEditFormModel } from '../form/QuickEditFormModel';
import { TableColumnModel } from './TableColumnModel';
import { extractIndex, adjustColumnOrder, setNestedValue, extractIds, getRowKey, useBlockHeight } from './utils';
import { commonConditionHandler, ConditionBuilder } from '../../../components/ConditionBuilder';
import {
applyMobilePaginationProps,
createCompactSimpleItemRender,
getSimpleModePaginationClassName,
getUnknownCountPaginationTotal,
mergePaginationClassName,
} from '../../../utils';
import { HighPerformanceSpin } from '../../../../schema-component/common/high-performance-spin/HighPerformanceSpin';
import {
SortHandle,
@@ -434,8 +441,9 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
const hasNext = this.resource.getMeta('hasNext');
const current = this.resource.getPage();
const data = this.resource.getData();
const isMobileLayout = !!this.context.isMobileLayout;
if (totalCount) {
return {
const result = {
current,
pageSize,
total: totalCount,
@@ -444,39 +452,30 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
},
showSizeChanger: true,
};
return applyMobilePaginationProps(result, isMobileLayout);
} else {
return {
const nextPageSize = pageSize || 10;
const nextCurrent = current || 1;
const result = {
// showTotal: false,
simple: true,
showTitle: false,
showSizeChanger: true,
hideOnSinglePage: false,
pageSize,
total: data?.length < pageSize || !hasNext ? pageSize * current : pageSize * current + 1,
className: css`
.ant-pagination-simple-pager {
display: none !important;
}
`,
itemRender: (_, type, originalElement) => {
if (type === 'prev') {
return (
<div
style={{ display: 'flex' }}
className={css`
.ant-pagination-item-link {
min-width: ${this.context.themeToken.controlHeight}px;
}
`}
>
{originalElement} <div>{this.resource.getPage()}</div>
</div>
);
} else {
return originalElement;
}
},
pageSize: nextPageSize,
total: getUnknownCountPaginationTotal({
dataLength: data?.length,
pageSize: nextPageSize,
current: nextCurrent,
hasNext,
}),
className: mergePaginationClassName(getSimpleModePaginationClassName(), undefined),
itemRender: createCompactSimpleItemRender({
current: nextCurrent,
controlHeight: this.context.themeToken.controlHeight,
}),
};
return applyMobilePaginationProps(result, isMobileLayout);
}
}
@@ -0,0 +1,64 @@
/**
* 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 { describe, expect, it } from 'vitest';
import {
applyMobilePaginationProps,
createCompactSimpleItemRender,
getSimpleModePaginationClassName,
getUnknownCountPaginationTotal,
} from '../pagination';
describe('flow pagination utils', () => {
it('未知总数场景应按 hasNext 估算 total', () => {
expect(
getUnknownCountPaginationTotal({
dataLength: 12,
pageSize: 12,
current: 1,
hasNext: true,
}),
).toBe(13);
expect(
getUnknownCountPaginationTotal({
dataLength: 9,
pageSize: 12,
current: 2,
hasNext: true,
}),
).toBe(24);
});
it('移动端分页应隐藏 total 与 size changer', () => {
const result = applyMobilePaginationProps(
{
total: 43,
current: 1,
pageSize: 20,
showTotal: () => 'Total 43 items',
showSizeChanger: true,
},
true,
) as any;
expect(result.showTotal).toBe(false);
expect(result.showSizeChanger).toBe(false);
expect(result.showLessItems).toBe(true);
expect(typeof result.className).toBe('string');
});
it('simple 模式工具应提供 className 与 itemRender', () => {
const className = getSimpleModePaginationClassName(true);
const itemRender = createCompactSimpleItemRender({ current: 2, controlHeight: 32 });
expect(typeof className).toBe('string');
expect(typeof itemRender).toBe('function');
});
});
@@ -9,3 +9,4 @@
export * from './blockUtils';
export * from './dispatchEventDeep';
export * from './pagination';
@@ -0,0 +1,110 @@
/**
* 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 { css, cx } from '@emotion/css';
import type { PaginationProps } from 'antd';
import React from 'react';
/**
* 计算未知总数场景下的 Pagination total
* - 本页不足 pageSize 或 hasNext=false,说明到末页
* - 否则用 +1 触发“还有下一页”
*/
export const getUnknownCountPaginationTotal = (options: {
dataLength?: number;
pageSize?: number;
current?: number;
hasNext?: boolean;
}) => {
const dataLength = options.dataLength || 0;
const pageSize = options.pageSize || 10;
const current = options.current || 1;
if (dataLength < pageSize || !options.hasNext) {
return pageSize * current;
}
return pageSize * current + 1;
};
export const getSimpleModePaginationClassName = (withLineHeight = false) => {
return css`
.ant-pagination-simple-pager {
display: none !important;
}
${withLineHeight
? `
li {
line-height: 32px !important;
}
`
: ''}
`;
};
const mobileCompactPaginationClassName = css`
justify-content: flex-end !important;
.ant-pagination-total-text,
.ant-pagination-options,
.ant-pagination-jump-prev,
.ant-pagination-jump-next {
display: none !important;
}
`;
export const getMobileCompactPaginationClassName = () => mobileCompactPaginationClassName;
export const mergePaginationClassName = (...classNames: Array<string | undefined | false>) => {
return cx(...classNames.filter(Boolean));
};
export const createCompactSimpleItemRender = (options: {
current?: number;
controlHeight?: number;
currentTextMarginLeft?: number;
}): PaginationProps['itemRender'] => {
const current = options.current || 1;
const controlHeight = options.controlHeight || 32;
const currentTextStyle = options.currentTextMarginLeft
? { marginLeft: `${options.currentTextMarginLeft}px` }
: undefined;
return (_, type, originalElement) => {
if (type === 'prev') {
return React.createElement(
'div',
{
style: { display: 'flex' },
className: css`
.ant-pagination-item-link {
min-width: ${controlHeight}px;
}
`,
},
originalElement,
React.createElement('div', { style: currentTextStyle }, current),
);
}
return originalElement;
};
};
export const applyMobilePaginationProps = <T extends PaginationProps>(
pagination: T,
isMobileLayout: boolean,
): PaginationProps => {
if (!isMobileLayout) {
return pagination;
}
return {
...pagination,
showTotal: false,
showSizeChanger: false,
showLessItems: true,
className: mergePaginationClassName(pagination.className, getMobileCompactPaginationClassName()),
};
};
@@ -19,7 +19,16 @@ import {
observer,
} from '@nocobase/flow-engine';
import { SettingOutlined } from '@ant-design/icons';
import { CollectionBlockModel, BlockSceneEnum, ActionModel } from '@nocobase/client';
import {
CollectionBlockModel,
BlockSceneEnum,
ActionModel,
getUnknownCountPaginationTotal,
getSimpleModePaginationClassName,
createCompactSimpleItemRender,
applyMobilePaginationProps,
mergePaginationClassName,
} from '@nocobase/client';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { List, Space, Slider, Grid, InputNumber, Col } from 'antd';
import { css } from '@emotion/css';
@@ -79,13 +88,14 @@ export class GridCardBlockModel extends CollectionBlockModel<GridBlockModelStruc
const data = this.resource.getData();
const columns = this.props.columnCount?.[this._screens] || 1;
const rowCount = this.props.rowCount || 1;
const isMobileLayout = !!this.context.isMobileLayout;
const multiples = [1, 2, 3, 5, 10];
const pageSizeOptions = multiples.map((m) => columns * rowCount * m);
if (totalCount) {
return {
const result = {
current,
pageSize,
total: totalCount,
@@ -101,39 +111,30 @@ export class GridCardBlockModel extends CollectionBlockModel<GridBlockModelStruc
this.resource.refresh();
},
};
return applyMobilePaginationProps(result, isMobileLayout);
} else {
return {
const nextPageSize = pageSize || 10;
const nextCurrent = current || 1;
const result = {
// showTotal: false,
simple: true,
showTitle: false,
showSizeChanger: true,
hideOnSinglePage: false,
pageSize,
total: data?.length < pageSize || !hasNext ? pageSize * current : pageSize * current + 1,
className: css`
.ant-pagination-simple-pager {
display: none !important;
}
`,
itemRender: (_, type, originalElement) => {
if (type === 'prev') {
return (
<div
style={{ display: 'flex' }}
className={css`
.ant-pagination-item-link {
min-width: ${this.context.themeToken.controlHeight}px;
}
`}
>
{originalElement} <div>{this.resource.getPage()}</div>
</div>
);
} else {
return originalElement;
}
},
pageSize: nextPageSize,
total: getUnknownCountPaginationTotal({
dataLength: data?.length,
pageSize: nextPageSize,
current: nextCurrent,
hasNext,
}),
className: mergePaginationClassName(getSimpleModePaginationClassName(true), undefined),
itemRender: createCompactSimpleItemRender({
current: nextCurrent,
controlHeight: this.context.themeToken.controlHeight,
}),
};
return applyMobilePaginationProps(result, isMobileLayout);
}
}
@@ -0,0 +1,104 @@
/**
* 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 { describe, expect, it, vi } from 'vitest';
import { GridCardBlockModel } from '../GridCardBlockModel';
function createGridCardModel(options: {
count?: number;
page?: number;
pageSize?: number;
hasNext?: boolean;
dataLength?: number;
isMobileLayout?: boolean;
}) {
const count = options.count ?? 0;
const page = options.page ?? 1;
const pageSize = options.pageSize ?? 12;
const hasNext = options.hasNext ?? false;
const dataLength = options.dataLength ?? pageSize;
const data = Array.from({ length: dataLength }, (_, i) => ({ id: i + 1 }));
const setPage = vi.fn();
const setPageSize = vi.fn();
const refresh = vi.fn();
const model = Object.create(GridCardBlockModel.prototype) as GridCardBlockModel;
Object.defineProperty(model, 'resource', {
configurable: true,
value: {
getMeta: (key: string) => (key === 'count' ? count : key === 'hasNext' ? hasNext : undefined),
getPageSize: () => pageSize,
getPage: () => page,
getData: () => data,
setPage,
setPageSize,
refresh,
loading: false,
},
});
Object.defineProperty(model, 'context', {
configurable: true,
value: {
isMobileLayout: !!options.isMobileLayout,
themeToken: { controlHeight: 32 },
},
});
(model as any).props = {
columnCount: { xs: 1, md: 2, lg: 3, xxl: 4 },
rowCount: 3,
};
(model as any)._screens = 'lg';
Object.defineProperty(model, 'translate', {
configurable: true,
value: (key: string, vars?: any) => {
if (key === 'Total {{count}} items') {
return `Total ${vars?.count ?? ''} items`;
}
return key;
},
});
return { model, setPage, setPageSize, refresh };
}
describe('GridCardBlockModel pagination', () => {
it('移动端已知总数时隐藏 total 与 size changer', () => {
const { model } = createGridCardModel({
count: 43,
page: 2,
pageSize: 12,
isMobileLayout: true,
dataLength: 12,
});
const pagination = model.pagination() as any;
expect(pagination.current).toBe(2);
expect(pagination.total).toBe(43);
expect(pagination.showTotal).toBe(false);
expect(pagination.showSizeChanger).toBe(false);
expect(pagination.showLessItems).toBe(true);
});
it('移动端未知总数时保持 simple 并隐藏 size changer', () => {
const { model } = createGridCardModel({
count: 0,
page: 1,
pageSize: 12,
hasNext: true,
isMobileLayout: true,
dataLength: 12,
});
const pagination = model.pagination() as any;
expect(pagination.simple).toBe(true);
expect(pagination.showSizeChanger).toBe(false);
expect(pagination.showTotal).toBe(false);
expect(pagination.total).toBe(13);
expect(typeof pagination.itemRender).toBe('function');
});
});