feat: adapt styles for mobile devices (#6393)

* feat(layout): integrate ConfigProvider for theme management in admin layout

* feat(layout): add mobile layout context and transform utility for schema

* feat(toolbar): disable dragging in mobile layout for BlockSchemaToolbar

* test: add unit tests for transformMultiColumnToSingleColumn function

* feat: add mobile layout support to form schema transformation

* feat: support mobile layout in Tabs component schema transformation

* feat: integrate mobile layout support into designable components

* feat: refactor mobile layout hooks and integrate MobileComponentsProvider

* feat: update mobile layout styles and integrate global theme in ActionDrawer

* feat: update Mobile component to use CommonDrawer for popup handling in mobile layout

* feat: adjust padding in ActionDrawer style for improved layout on mobile

* feat: optimize scope usage in InternalPicker for improved performance

* feat: update Action.Page style to improve overflow handling

* style: remove minHeight from ActionDrawer style

* feat: add mobile layout support and transform schema for multi-column fields

* feat: support mobile components

* fix(mobile): issue with mobile flash/flicker

* feat: hide Plugin manager and Settings center

* feat: hide scroll bar in mobile

* feat(todos): support to collapse sider menu

* feat: enhance mobile layout theme support with dark mode
This commit is contained in:
Zeke Zhang 2025-03-12 07:51:11 +08:00 committed by GitHub
parent 37f8936781
commit c2786c1bee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 600 additions and 102 deletions

View File

@ -14,6 +14,7 @@ import { useCollection } from '../../data-source/collection/CollectionProvider';
import { useCompile } from '../../schema-component'; import { useCompile } from '../../schema-component';
import { SchemaToolbar } from '../../schema-settings/GeneralSchemaDesigner'; import { SchemaToolbar } from '../../schema-settings/GeneralSchemaDesigner';
import { useSchemaTemplate } from '../../schema-templates'; import { useSchemaTemplate } from '../../schema-templates';
import { useMobileLayout } from '../../route-switch/antd/admin-layout';
export const BlockSchemaToolbar = (props) => { export const BlockSchemaToolbar = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -22,6 +23,7 @@ export const BlockSchemaToolbar = (props) => {
const template = useSchemaTemplate(); const template = useSchemaTemplate();
const { association, collection } = useDataBlockProps() || {}; const { association, collection } = useDataBlockProps() || {};
const compile = useCompile(); const compile = useCompile();
const { isMobileLayout } = useMobileLayout();
if (association) { if (association) {
const [collectionName] = association.split('.'); const [collectionName] = association.split('.');
@ -51,7 +53,7 @@ export const BlockSchemaToolbar = (props) => {
].filter(Boolean); ].filter(Boolean);
}, [currentCollectionTitle, currentCollectionName, associationField, associationCollection, compile, templateName]); }, [currentCollectionTitle, currentCollectionName, associationField, associationCollection, compile, templateName]);
return <SchemaToolbar title={toolbarTitle} {...props} />; return <SchemaToolbar title={toolbarTitle} {...props} draggable={!isMobileLayout} />;
}; };
export function getCollectionTitle(arg: { export function getCollectionTitle(arg: {

View File

@ -13,6 +13,7 @@ import React, { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useApp, useNavigateNoUpdate } from '../application'; import { useApp, useNavigateNoUpdate } from '../application';
import { useMobileLayout } from '../route-switch/antd/admin-layout';
import { useCompile } from '../schema-component'; import { useCompile } from '../schema-component';
import { useToken } from '../style'; import { useToken } from '../style';
@ -20,6 +21,12 @@ export const PluginManagerLink = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigateNoUpdate(); const navigate = useNavigateNoUpdate();
const { token } = useToken(); const { token } = useToken();
const { isMobileLayout } = useMobileLayout();
if (isMobileLayout) {
return null;
}
return ( return (
<Tooltip title={t('Plugin manager')}> <Tooltip title={t('Plugin manager')}>
<Button <Button
@ -62,6 +69,12 @@ export const SettingsCenterDropdown = () => {
}; };
}, [app.pluginSettingsManager]); }, [app.pluginSettingsManager]);
const { isMobileLayout } = useMobileLayout();
if (isMobileLayout) {
return null;
}
return ( return (
<Dropdown <Dropdown
menu={{ menu={{
@ -75,7 +88,7 @@ export const SettingsCenterDropdown = () => {
<Button <Button
data-testid="plugin-settings-button" data-testid="plugin-settings-button"
icon={<SettingOutlined style={{ color: token.colorTextHeaderMenu }} />} icon={<SettingOutlined style={{ color: token.colorTextHeaderMenu }} />}
// title={t('All plugin settings')} // title={t('All plugin settings')}
/> />
</Dropdown> </Dropdown>
); );

View File

@ -11,7 +11,7 @@ import { EllipsisOutlined } from '@ant-design/icons';
import ProLayout, { RouteContext, RouteContextType } from '@ant-design/pro-layout'; import ProLayout, { RouteContext, RouteContextType } from '@ant-design/pro-layout';
import { HeaderViewProps } from '@ant-design/pro-layout/es/components/Header'; import { HeaderViewProps } from '@ant-design/pro-layout/es/components/Header';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { Popover, Tooltip } from 'antd'; import { theme as antdTheme, ConfigProvider, Popover, Tooltip } from 'antd';
import React, { createContext, FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import React, { createContext, FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { Link, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Link, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
@ -29,6 +29,7 @@ import {
RemoteSchemaTemplateManagerProvider, RemoteSchemaTemplateManagerProvider,
SortableItem, SortableItem,
useDesignable, useDesignable,
useGlobalTheme,
useMenuDragEnd, useMenuDragEnd,
useParseURLAndParams, useParseURLAndParams,
useRequest, useRequest,
@ -73,7 +74,7 @@ const AllAccessDesktopRoutesContext = createContext<{
refresh: () => void; refresh: () => void;
}>({ }>({
allAccessRoutes: emptyArray, allAccessRoutes: emptyArray,
refresh: () => {}, refresh: () => { },
}); });
AllAccessDesktopRoutesContext.displayName = 'AllAccessDesktopRoutesContext'; AllAccessDesktopRoutesContext.displayName = 'AllAccessDesktopRoutesContext';
@ -495,22 +496,44 @@ const headerRender = (props: HeaderViewProps, defaultDom: React.ReactNode) => {
return <headerContext.Provider value={headerContextValue}>{defaultDom}</headerContext.Provider>; return <headerContext.Provider value={headerContextValue}>{defaultDom}</headerContext.Provider>;
}; };
const IsMobileLayoutContext = React.createContext<{
isMobileLayout: boolean;
setIsMobileLayout: React.Dispatch<React.SetStateAction<boolean>>;
}>({
isMobileLayout: false,
setIsMobileLayout: () => { },
});
const MobileLayoutProvider: FC = (props) => {
const [isMobileLayout, setIsMobileLayout] = useState(false);
const value = useMemo(() => ({ isMobileLayout, setIsMobileLayout }), [isMobileLayout]);
return <IsMobileLayoutContext.Provider value={value}>{props.children}</IsMobileLayoutContext.Provider>;
};
export const useMobileLayout = () => {
const { isMobileLayout, setIsMobileLayout } = useContext(IsMobileLayoutContext);
return { isMobileLayout, setIsMobileLayout };
};
export const InternalAdminLayout = () => { export const InternalAdminLayout = () => {
const { allAccessRoutes } = useAllAccessDesktopRoutes(); const { allAccessRoutes } = useAllAccessDesktopRoutes();
const { designable } = useDesignable(); const { designable: _designable } = useDesignable();
const location = useLocation(); const location = useLocation();
const { onDragEnd } = useMenuDragEnd(); const { onDragEnd } = useMenuDragEnd();
const { token } = useToken(); const { token } = useToken();
const [isMobile, setIsMobile] = useState(false); const { isMobileLayout, setIsMobileLayout } = useMobileLayout();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(isMobileLayout);
const doNotChangeCollapsedRef = useRef(false); const doNotChangeCollapsedRef = useRef(false);
const { t } = useMenuTranslation(); const { t } = useMenuTranslation();
const designable = isMobileLayout ? false : _designable;
const route = useMemo(() => { const route = useMemo(() => {
return { return {
path: '/', path: '/',
children: convertRoutesToLayout(allAccessRoutes, { designable, isMobile, t }), children: convertRoutesToLayout(allAccessRoutes, { designable, isMobile: isMobileLayout, t }),
}; };
}, [allAccessRoutes, designable, isMobile, t]); }, [allAccessRoutes, designable, isMobileLayout, t]);
const layoutToken = useMemo(() => { const layoutToken = useMemo(() => {
return { return {
header: { header: {
@ -534,6 +557,21 @@ export const InternalAdminLayout = () => {
bgLayout: token.colorBgLayout, bgLayout: token.colorBgLayout,
}; };
}, [token]); }, [token]);
const { theme, isDarkTheme } = useGlobalTheme();
const mobileTheme = useMemo(() => {
return {
...theme,
token: {
...theme.token,
paddingPageHorizontal: 8, // Horizontal page padding
paddingPageVertical: 8, // Vertical page padding
marginBlock: 12, // Spacing between blocks
borderRadiusBlock: 8, // Block border radius
fontSize: 14, // Font size
},
algorithm: isDarkTheme ? [antdTheme.compactAlgorithm, antdTheme.darkAlgorithm] : antdTheme.compactAlgorithm, // Set mobile mode to always use compact algorithm
};
}, [theme, isDarkTheme]);
const onCollapse = useCallback((collapsed: boolean) => { const onCollapse = useCallback((collapsed: boolean) => {
if (doNotChangeCollapsedRef.current) { if (doNotChangeCollapsedRef.current) {
@ -575,11 +613,15 @@ export const InternalAdminLayout = () => {
{(value: RouteContextType) => { {(value: RouteContextType) => {
const { isMobile: _isMobile } = value; const { isMobile: _isMobile } = value;
if (_isMobile !== isMobile) { if (_isMobile !== isMobileLayout) {
setIsMobile(_isMobile); setIsMobileLayout(_isMobile);
} }
return <LayoutContent />; return (
<ConfigProvider theme={_isMobile ? mobileTheme : theme}>
<LayoutContent />
</ConfigProvider>
);
}} }}
</RouteContext.Consumer> </RouteContext.Consumer>
</ProLayout> </ProLayout>
@ -696,6 +738,7 @@ export class AdminLayoutPlugin extends Plugin {
async load() { async load() {
this.app.schemaSettingsManager.add(userCenterSettings); this.app.schemaSettingsManager.add(userCenterSettings);
this.app.addComponents({ AdminLayout, AdminDynamicPage }); this.app.addComponents({ AdminLayout, AdminDynamicPage });
this.app.use(MobileLayoutProvider);
} }
} }
@ -717,36 +760,6 @@ export function findRouteBySchemaUid(schemaUid: string, treeArray: any[]) {
return null; return null;
} }
const MenuItemIcon: FC<{ icon: string; title: string }> = (props) => {
const { inHeader } = useContext(headerContext);
return (
<RouteContext.Consumer>
{(value: RouteContextType) => {
const { collapsed } = value;
if (collapsed && !inHeader) {
return props.icon ? (
<Icon type={props.icon} />
) : (
<span
style={{
display: 'inline-block',
width: '100%',
textAlign: 'center',
}}
>
{props.title.charAt(0)}
</span>
);
}
return props.icon ? <Icon type={props.icon} /> : null;
}}
</RouteContext.Consumer>
);
};
const MenuDesignerButton: FC<{ testId: string }> = (props) => { const MenuDesignerButton: FC<{ testId: string }> = (props) => {
const { render: renderInitializer } = useSchemaInitializerRender(menuItemInitializer); const { render: renderInitializer } = useSchemaInitializerRender(menuItemInitializer);
@ -766,7 +779,7 @@ const MenuTitleWithIcon: FC<{ icon: any; title: string }> = (props) => {
); );
} }
return props.title; return <>{props.title}</>;
}; };
function convertRoutesToLayout( function convertRoutesToLayout(

View File

@ -20,7 +20,8 @@ export const useActionPageStyle = genStyleHook('nb-action-page', (token) => {
right: 0, right: 0,
bottom: 0, bottom: 0,
backgroundColor: token.colorBgLayout, backgroundColor: token.colorBgLayout,
overflow: 'auto', overflowX: 'hidden',
overflowY: 'auto',
'.ant-tabs-nav': { '.ant-tabs-nav': {
background: token.colorBgContainer, background: token.colorBgContainer,

View File

@ -13,6 +13,7 @@ import { differenceBy, unionBy } from 'lodash';
import React, { useContext, useMemo, useState } from 'react'; import React, { useContext, useMemo, useState } from 'react';
import { import {
FormProvider, FormProvider,
PopupSettingsProvider,
RecordPickerContext, RecordPickerContext,
RecordPickerProvider, RecordPickerProvider,
SchemaComponentOptions, SchemaComponentOptions,
@ -24,6 +25,7 @@ import {
NocoBaseRecursionField, NocoBaseRecursionField,
RecordProvider, RecordProvider,
useCollectionRecordData, useCollectionRecordData,
useMobileLayout,
} from '../../..'; } from '../../..';
import { useFormBlockContext } from '../../../block-provider/FormBlockProvider'; import { useFormBlockContext } from '../../../block-provider/FormBlockProvider';
import { import {
@ -35,6 +37,7 @@ import { ActionContextProvider } from '../action';
import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks'; import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks';
import schema from './schema'; import schema from './schema';
import { flatData, getLabelFormatValue, useLabelUiSchema } from './util'; import { flatData, getLabelFormatValue, useLabelUiSchema } from './util';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
export const useTableSelectorProps = () => { export const useTableSelectorProps = () => {
const field: any = useField(); const field: any = useField();
@ -117,6 +120,7 @@ export const InternalPicker = observer(
collectionField, collectionField,
currentFormCollection: collectionName, currentFormCollection: collectionName,
}; };
const { isMobileLayout } = useMobileLayout();
const getValue = () => { const getValue = () => {
if (multiple == null) return null; if (multiple == null) return null;
@ -147,8 +151,16 @@ export const InternalPicker = observer(
}, },
}; };
}; };
const scope = useMemo(
() => ({
usePickActionProps,
useTableSelectorProps,
}),
[],
);
return ( return (
<> <PopupSettingsProvider enableURL={false}>
<Space.Compact style={{ display: 'flex', lineHeight: '32px' }}> <Space.Compact style={{ display: 'flex', lineHeight: '32px' }}>
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<Select <Select
@ -213,16 +225,11 @@ export const InternalPicker = observer(
<CollectionProvider_deprecated name={collectionField?.target}> <CollectionProvider_deprecated name={collectionField?.target}>
<FormProvider> <FormProvider>
<TableSelectorParamsProvider params={{ filter: getFilter() }}> <TableSelectorParamsProvider params={{ filter: getFilter() }}>
<SchemaComponentOptions <SchemaComponentOptions scope={scope}>
scope={{
usePickActionProps,
useTableSelectorProps,
}}
>
<NocoBaseRecursionField <NocoBaseRecursionField
onlyRenderProperties onlyRenderProperties
basePath={field.address} basePath={field.address}
schema={fieldSchema} schema={isMobileLayout ? transformMultiColumnToSingleColumn(fieldSchema) : fieldSchema}
filterProperties={(s) => { filterProperties={(s) => {
return s['x-component'] === 'AssociationField.Selector'; return s['x-component'] === 'AssociationField.Selector';
}} }}
@ -233,7 +240,7 @@ export const InternalPicker = observer(
</CollectionProvider_deprecated> </CollectionProvider_deprecated>
</RecordPickerProvider> </RecordPickerProvider>
</ActionContextProvider> </ActionContextProvider>
</> </PopupSettingsProvider>
); );
}, },
{ displayName: 'InternalPicker' }, { displayName: 'InternalPicker' },

View File

@ -28,6 +28,8 @@ import { useLocalVariables, useVariables } from '../../../variables';
import { useProps } from '../../hooks/useProps'; import { useProps } from '../../hooks/useProps';
import { useFormBlockHeight } from './hook'; import { useFormBlockHeight } from './hook';
import { useApp } from '../../../application'; import { useApp } from '../../../application';
import { useMobileLayout } from '../../../route-switch/antd/admin-layout';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
export interface FormProps extends IFormLayoutProps { export interface FormProps extends IFormLayoutProps {
form?: FormilyForm; form?: FormilyForm;
@ -49,6 +51,8 @@ const FormComponent: React.FC<FormProps> = (props) => {
labelWidth = 120, labelWidth = 120,
labelWrap = true, labelWrap = true,
} = cardItemSchema?.['x-component-props'] || {}; } = cardItemSchema?.['x-component-props'] || {};
const { isMobileLayout } = useMobileLayout();
return ( return (
<FieldContext.Provider value={undefined}> <FieldContext.Provider value={undefined}>
<FormContext.Provider value={form}> <FormContext.Provider value={form}>
@ -76,7 +80,12 @@ const FormComponent: React.FC<FormProps> = (props) => {
} }
`} `}
> >
<NocoBaseRecursionField basePath={f.address} schema={fieldSchema} onlyRenderProperties isUseFormilyField /> <NocoBaseRecursionField
basePath={f.address}
schema={isMobileLayout ? transformMultiColumnToSingleColumn(fieldSchema) : fieldSchema}
onlyRenderProperties
isUseFormilyField
/>
</div> </div>
</FormLayout> </FormLayout>
</FormContext.Provider> </FormContext.Provider>
@ -93,6 +102,8 @@ const FormDecorator: React.FC<FormProps> = (props) => {
// TODO: component 里 useField 会与当前 field 存在偏差 // TODO: component 里 useField 会与当前 field 存在偏差
const f = useAttach(form.createVoidField({ ...field.props, basePath: '' })); const f = useAttach(form.createVoidField({ ...field.props, basePath: '' }));
const Component = useComponent(fieldSchema['x-component'], Def); const Component = useComponent(fieldSchema['x-component'], Def);
const { isMobileLayout } = useMobileLayout();
return ( return (
<FieldContext.Provider value={undefined}> <FieldContext.Provider value={undefined}>
<FormContext.Provider value={form}> <FormContext.Provider value={form}>
@ -101,7 +112,7 @@ const FormDecorator: React.FC<FormProps> = (props) => {
<Component {...field.componentProps}> <Component {...field.componentProps}>
<NocoBaseRecursionField <NocoBaseRecursionField
basePath={f.address} basePath={f.address}
schema={fieldSchema} schema={isMobileLayout ? transformMultiColumnToSingleColumn(fieldSchema) : fieldSchema}
onlyRenderProperties onlyRenderProperties
isUseFormilyField isUseFormilyField
/> />
@ -250,7 +261,7 @@ export const Form: React.FC<FormProps> & {
const theme: any = useMemo(() => { const theme: any = useMemo(() => {
return { return {
token: { token: {
// 这里是为了防止区块内部也到 marginBlock 的影响marginBlock区块之间的间距 // 这里是为了防止区块内部也到 marginBlock 的影响marginBlock区块之间的间距
// @ts-ignore // @ts-ignore
marginBlock: token.marginLG, marginBlock: token.marginLG,
}, },

View File

@ -25,6 +25,8 @@ import { ListDesigner } from './List.Designer';
import { ListItem } from './List.Item'; import { ListItem } from './List.Item';
import useStyles from './List.style'; import useStyles from './List.style';
import { useListActionBarProps, useListBlockHeight } from './hooks'; import { useListActionBarProps, useListBlockHeight } from './hooks';
import { useMobileLayout } from '../../../route-switch/antd/admin-layout';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
const InternalList = withSkeletonComponent( const InternalList = withSkeletonComponent(
(props) => { (props) => {
@ -121,6 +123,8 @@ const InternalList = withSkeletonComponent(
}; };
}; };
const paginationProps = usePagination(); const paginationProps = usePagination();
const { isMobileLayout } = useMobileLayout();
return wrapSSR( return wrapSSR(
<SchemaComponentOptions <SchemaComponentOptions
scope={{ scope={{
@ -165,7 +169,9 @@ const InternalList = withSkeletonComponent(
key={index} key={index}
name={index} name={index}
onlyRenderProperties onlyRenderProperties
schema={getSchema(index)} schema={
isMobileLayout ? transformMultiColumnToSingleColumn(getSchema(index)) : getSchema(index)
}
></NocoBaseRecursionField> ></NocoBaseRecursionField>
); );
}) })

View File

@ -37,6 +37,7 @@ import {
NocoBaseDesktopRouteType, NocoBaseDesktopRouteType,
NocoBaseRouteContext, NocoBaseRouteContext,
useCurrentRoute, useCurrentRoute,
useMobileLayout,
} from '../../../route-switch/antd/admin-layout'; } from '../../../route-switch/antd/admin-layout';
import { KeepAliveProvider, useKeepAlive } from '../../../route-switch/antd/admin-layout/KeepAlive'; import { KeepAliveProvider, useKeepAlive } from '../../../route-switch/antd/admin-layout/KeepAlive';
import { useGetAriaLabelOfSchemaInitializer } from '../../../schema-initializer/hooks/useGetAriaLabelOfSchemaInitializer'; import { useGetAriaLabelOfSchemaInitializer } from '../../../schema-initializer/hooks/useGetAriaLabelOfSchemaInitializer';
@ -50,6 +51,7 @@ import { useMenuDragEnd, useNocoBaseRoutes } from '../menu/Menu';
import { useStyles } from './Page.style'; import { useStyles } from './Page.style';
import { PageDesigner, PageTabDesigner } from './PageTabDesigner'; import { PageDesigner, PageTabDesigner } from './PageTabDesigner';
import { PopupRouteContextResetter } from './PopupRouteContextResetter'; import { PopupRouteContextResetter } from './PopupRouteContextResetter';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
interface PageProps { interface PageProps {
currentTabUid: string; currentTabUid: string;
@ -174,6 +176,7 @@ const displayNone = {
const TabPane = React.memo(({ active: tabActive, uid }: { active: boolean; uid: string }) => { const TabPane = React.memo(({ active: tabActive, uid }: { active: boolean; uid: string }) => {
const mountedRef = useRef(false); const mountedRef = useRef(false);
const { active: pageActive } = useKeepAlive(); const { active: pageActive } = useKeepAlive();
const { isMobileLayout } = useMobileLayout();
if (tabActive && !mountedRef.current) { if (tabActive && !mountedRef.current) {
mountedRef.current = true; mountedRef.current = true;
@ -186,7 +189,10 @@ const TabPane = React.memo(({ active: tabActive, uid }: { active: boolean; uid:
return ( return (
<div style={tabActive ? displayBlock : displayNone}> <div style={tabActive ? displayBlock : displayNone}>
<KeepAliveProvider active={pageActive && tabActive}> <KeepAliveProvider active={pageActive && tabActive}>
<RemoteSchemaComponent uid={uid} /> <RemoteSchemaComponent
uid={uid}
schemaTransform={isMobileLayout ? transformMultiColumnToSingleColumn : undefined}
/>
</KeepAliveProvider> </KeepAliveProvider>
</div> </div>
); );
@ -204,6 +210,7 @@ const InternalPageContent = (props: PageContentProps) => {
const currentRoute = useCurrentRoute(); const currentRoute = useCurrentRoute();
const navigate = useNavigateNoUpdate(); const navigate = useNavigateNoUpdate();
const location = useLocationNoUpdate(); const location = useLocationNoUpdate();
const { isMobileLayout } = useMobileLayout();
const children = currentRoute?.children || []; const children = currentRoute?.children || [];
const noTabs = children.every((tabRoute) => tabRoute.schemaUid !== activeKey && tabRoute.tabSchemaName !== activeKey); const noTabs = children.every((tabRoute) => tabRoute.schemaUid !== activeKey && tabRoute.tabSchemaName !== activeKey);
@ -244,7 +251,10 @@ const InternalPageContent = (props: PageContentProps) => {
return ( return (
<div className={className1}> <div className={className1}>
<NocoBaseRouteContext.Provider value={currentRoute?.children?.[0]}> <NocoBaseRouteContext.Provider value={currentRoute?.children?.[0]}>
<RemoteSchemaComponent uid={currentRoute?.children?.[0].schemaUid} /> <RemoteSchemaComponent
uid={currentRoute?.children?.[0].schemaUid}
schemaTransform={isMobileLayout ? transformMultiColumnToSingleColumn : undefined}
/>
</NocoBaseRouteContext.Provider> </NocoBaseRouteContext.Provider>
</div> </div>
); );

View File

@ -17,6 +17,7 @@ const PopupSettingsContext = React.createContext({
export const PopupSettingsProvider: FC<{ export const PopupSettingsProvider: FC<{
/** /**
* @default true * @default true
* Whether the popup should be controlled by URL
*/ */
enableURL?: boolean; enableURL?: boolean;
}> = (props) => { }> = (props) => {
@ -52,7 +53,7 @@ export const usePopupSettings = () => {
}, [enableURL, isInSettingsPage]); }, [enableURL, isInSettingsPage]);
return { return {
/** 弹窗窗口的显隐是否由 URL 控制 */ /** Whether the visibility of the popup window is controlled by URL */
isPopupVisibleControlledByURL, isPopupVisibleControlledByURL,
}; };
}; };

View File

@ -8,7 +8,7 @@
*/ */
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react'; import { observer, RecursionField, Schema, useField, useFieldSchema } from '@formily/react';
import { Tabs as AntdTabs, TabPaneProps, TabsProps } from 'antd'; import { Tabs as AntdTabs, TabPaneProps, TabsProps } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
@ -20,6 +20,8 @@ import { SchemaComponent } from '../../core';
import { useDesigner } from '../../hooks/useDesigner'; import { useDesigner } from '../../hooks/useDesigner';
import { useTabsContext } from './context'; import { useTabsContext } from './context';
import { TabsDesigner } from './Tabs.Designer'; import { TabsDesigner } from './Tabs.Designer';
import { useMobileLayout } from '../../../route-switch/antd/admin-layout';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
const MemoizeRecursionField = React.memo(RecursionField); const MemoizeRecursionField = React.memo(RecursionField);
MemoizeRecursionField.displayName = 'MemoizeRecursionField'; MemoizeRecursionField.displayName = 'MemoizeRecursionField';
@ -32,6 +34,7 @@ export const Tabs: any = React.memo((props: TabsProps) => {
const { render } = useSchemaInitializerRender(fieldSchema['x-initializer'], fieldSchema['x-initializer-props']); const { render } = useSchemaInitializerRender(fieldSchema['x-initializer'], fieldSchema['x-initializer-props']);
const contextProps = useTabsContext(); const contextProps = useTabsContext();
const { PaneRoot = React.Fragment as React.FC<any> } = contextProps; const { PaneRoot = React.Fragment as React.FC<any> } = contextProps;
const { isMobileLayout } = useMobileLayout();
const items = useMemo(() => { const items = useMemo(() => {
const result = fieldSchema.mapProperties((schema, key: string) => { const result = fieldSchema.mapProperties((schema, key: string) => {
@ -40,14 +43,19 @@ export const Tabs: any = React.memo((props: TabsProps) => {
label: <MemoizeRecursionField name={key} schema={schema} onlyRenderSelf />, label: <MemoizeRecursionField name={key} schema={schema} onlyRenderSelf />,
children: ( children: (
<PaneRoot key={key} {...(PaneRoot !== React.Fragment ? { active: key === contextProps.activeKey } : {})}> <PaneRoot key={key} {...(PaneRoot !== React.Fragment ? { active: key === contextProps.activeKey } : {})}>
<SchemaComponent name={key} schema={schema} onlyRenderProperties distributed /> <SchemaComponent
name={key}
schema={isMobileLayout ? new Schema(transformMultiColumnToSingleColumn(schema)) : schema}
onlyRenderProperties
distributed
/>
</PaneRoot> </PaneRoot>
), ),
}; };
}); });
return result; return result;
}, [fieldSchema]); }, [fieldSchema, isMobileLayout]);
const tabBarExtraContent = useMemo( const tabBarExtraContent = useMemo(
() => ({ () => ({

View File

@ -14,6 +14,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDesignable } from '..'; import { useDesignable } from '..';
import { useToken } from '../../style'; import { useToken } from '../../style';
import { useMobileLayout } from '../../route-switch/antd/admin-layout';
const designableStyle = { const designableStyle = {
backgroundColor: 'var(--colorSettings) !important', backgroundColor: 'var(--colorSettings) !important',
@ -28,10 +29,15 @@ export const DesignableSwitch = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { token } = useToken(); const { token } = useToken();
const style = designable ? designableStyle : unDesignableStyle; const style = designable ? designableStyle : unDesignableStyle;
const { isMobileLayout } = useMobileLayout();
// 快捷键切换编辑状态 // 快捷键切换编辑状态
useHotkeys('Ctrl+Shift+U', () => setDesignable(!designable), [designable]); useHotkeys('Ctrl+Shift+U', () => setDesignable(!designable), [designable]);
if (isMobileLayout) {
return null;
}
return ( return (
<Tooltip title={t('UI Editor')}> <Tooltip title={t('UI Editor')}>
<Button <Button

View File

@ -25,6 +25,7 @@ import { addAppVersion } from './addAppVersion';
// @ts-ignore // @ts-ignore
import clientPkg from '../../../package.json'; import clientPkg from '../../../package.json';
import { useMobileLayout } from '../../route-switch/antd/admin-layout';
interface CreateDesignableProps { interface CreateDesignableProps {
current: Schema; current: Schema;
@ -780,9 +781,11 @@ export function useDesignable() {
dn.loadAPIClientEvents(); dn.loadAPIClientEvents();
}, [dn]); }, [dn]);
const { isMobileLayout } = useMobileLayout();
return { return {
dn, dn,
designable, designable: isMobileLayout ? false : designable,
reset, reset,
refresh, refresh,
setDesignable, setDesignable,

View File

@ -9,9 +9,16 @@
import { observer, useField, useFieldSchema } from '@formily/react'; import { observer, useField, useFieldSchema } from '@formily/react';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { BlockTemplateProvider, CollectionDeletedPlaceholder, RemoteSchemaComponent, useDesignable } from '..'; import {
BlockTemplateProvider,
CollectionDeletedPlaceholder,
RemoteSchemaComponent,
useDesignable,
useMobileLayout,
} from '..';
import { useTemplateBlockContext } from '../block-provider/TemplateBlockProvider'; import { useTemplateBlockContext } from '../block-provider/TemplateBlockProvider';
import { useSchemaTemplateManager } from './SchemaTemplateManagerProvider'; import { useSchemaTemplateManager } from './SchemaTemplateManagerProvider';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
export const BlockTemplate = observer( export const BlockTemplate = observer(
(props: any) => { (props: any) => {
@ -22,6 +29,7 @@ export const BlockTemplate = observer(
const { dn } = useDesignable(); const { dn } = useDesignable();
const template = useMemo(() => getTemplateById(templateId), [templateId]); const template = useMemo(() => getTemplateById(templateId), [templateId]);
const { onTemplateSuccess } = useTemplateBlockContext(); const { onTemplateSuccess } = useTemplateBlockContext();
const { isMobileLayout } = useMobileLayout();
const onSuccess = (data) => { const onSuccess = (data) => {
fieldSchema['x-linkage-rules'] = data?.data?.['x-linkage-rules'] || []; fieldSchema['x-linkage-rules'] = data?.data?.['x-linkage-rules'] || [];
@ -30,7 +38,12 @@ export const BlockTemplate = observer(
}; };
return template ? ( return template ? (
<BlockTemplateProvider {...{ dn, field, fieldSchema, template }}> <BlockTemplateProvider {...{ dn, field, fieldSchema, template }}>
<RemoteSchemaComponent noForm uid={template?.uid} onSuccess={onSuccess} /> <RemoteSchemaComponent
noForm
uid={template?.uid}
onSuccess={onSuccess}
schemaTransform={isMobileLayout ? transformMultiColumnToSingleColumn : undefined}
/>
</BlockTemplateProvider> </BlockTemplateProvider>
) : ( ) : (
<CollectionDeletedPlaceholder type="Block template" name={templateId} /> <CollectionDeletedPlaceholder type="Block template" name={templateId} />

View File

@ -0,0 +1,207 @@
/**
* 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, it, expect, vi, beforeEach } from 'vitest';
import { transformMultiColumnToSingleColumn } from '../transformMultiColumnToSingleColumn';
/**
* 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.
*/
// Mock dependencies
vi.mock('../uid', () => ({
uid: vi.fn(() => 'mocked-uid'),
}));
// Mock package.json
vi.mock('../../package.json', () => ({
default: { version: '1.0.0' },
}));
describe('transformMultiColumnToSingleColumn', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return schema as is if null or undefined', () => {
expect(transformMultiColumnToSingleColumn(null)).toBeNull();
expect(transformMultiColumnToSingleColumn(undefined)).toBeUndefined();
});
it('should recursively process non-Grid schemas', () => {
const schema = {
'x-component': 'Form',
properties: {
field1: {
'x-component': 'Input',
},
grid1: {
'x-component': 'Grid',
properties: {
row1: {
'x-component': 'Grid.Row',
properties: {
col1: { 'x-component': 'Input' },
},
},
},
},
},
};
const result = transformMultiColumnToSingleColumn(schema);
// The non-Grid top level should remain, but child Grid should be transformed
expect(result['x-component']).toBe('Form');
expect(result.properties.grid1.properties.row1['x-index']).toBe(1);
});
it('should handle Grid with non-Grid.Row components', () => {
const schema = {
'x-component': 'Grid',
properties: {
nonRow: {
'x-component': 'Input',
},
},
};
const result = transformMultiColumnToSingleColumn(schema);
expect(result.properties.nonRow['x-index']).toBe(1);
});
it('should ignore Grid.Row without properties', () => {
const schema = {
'x-component': 'Grid',
properties: {
row1: {
'x-component': 'Grid.Row',
// No properties
},
},
};
const result = transformMultiColumnToSingleColumn(schema);
expect(Object.keys(result.properties).length).toBe(0);
});
it('should keep Grid.Row with single column as is', () => {
const schema = {
'x-component': 'Grid',
properties: {
row1: {
'x-component': 'Grid.Row',
properties: {
col1: { 'x-component': 'Input' },
},
},
},
};
const result = transformMultiColumnToSingleColumn(schema);
expect(result.properties.row1['x-index']).toBe(1);
expect(Object.keys(result.properties.row1.properties).length).toBe(1);
expect(result.properties.row1.properties.col1).toBeDefined();
});
it('should transform Grid.Row with multiple columns', () => {
const schema = {
'x-component': 'Grid',
properties: {
row1: {
'x-component': 'Grid.Row',
properties: {
col1: { 'x-component': 'Input' },
col2: { 'x-component': 'Select' },
},
},
},
};
const result = transformMultiColumnToSingleColumn(schema);
// Original row should keep only the first column
expect(result.properties.row1['x-index']).toBe(1);
expect(Object.keys(result.properties.row1.properties).length).toBe(1);
expect(result.properties.row1.properties.col1).toBeDefined();
// Second column should be transformed into a new row
const newRowKey = 'mocked-uid_col2';
expect(result.properties[newRowKey]).toBeDefined();
expect(result.properties[newRowKey]['x-component']).toBe('Grid.Row');
expect(result.properties[newRowKey]['x-index']).toBe(2);
expect(result.properties[newRowKey].properties.col2).toBeDefined();
expect(result.properties[newRowKey].properties.col2['x-component-props'].width).toBe(100);
});
it('should handle complex Grid schema with multiple rows and columns', () => {
const schema = {
'x-component': 'Grid',
properties: {
row1: {
'x-component': 'Grid.Row',
properties: {
col1: { 'x-component': 'Input' },
col2: { 'x-component': 'Select' },
col3: { 'x-component': 'Checkbox' },
},
},
row2: {
'x-component': 'Grid.Row',
properties: {
col4: { 'x-component': 'DatePicker' },
},
},
nonRow: {
'x-component': 'Divider',
},
shouldRemove: {
'x-component': 'Grid.Row',
// No properties
},
},
};
const result = transformMultiColumnToSingleColumn(schema);
// Check structure
expect(Object.keys(result.properties).length).toBe(5); // row1 + 2 new rows from col2,col3 + row2 + nonRow
// Check row1 (first column stays)
expect(result.properties.row1['x-index']).toBe(1);
expect(Object.keys(result.properties.row1.properties).length).toBe(1);
expect(result.properties.row1.properties.col1).toBeDefined();
// Check col2 became its own row
expect(result.properties['mocked-uid_col2'].properties.col2).toBeDefined();
expect(result.properties['mocked-uid_col2'].properties.col2['x-component-props'].width).toBe(100);
// Check col3 became its own row (second call to uid would return the same mocked value)
expect(result.properties['mocked-uid_col3'].properties.col3).toBeDefined();
// Check row2 stayed intact
expect(result.properties.row2['x-index']).toBe(4);
expect(Object.keys(result.properties.row2.properties).length).toBe(1);
expect(result.properties.row2.properties.col4).toBeDefined();
// Check nonRow was processed
expect(result.properties.nonRow['x-index']).toBe(5);
// Check shouldRemove was ignored
expect(Object.keys(result.properties).includes('shouldRemove')).toBe(false);
});
});

View File

@ -30,4 +30,5 @@ export * from './isPortalInBody';
export * from './parseHTML'; export * from './parseHTML';
export * from './uid'; export * from './uid';
export * from './url'; export * from './url';
export * from './transformMultiColumnToSingleColumn';
export { dayjs, lodash }; export { dayjs, lodash };

View File

@ -0,0 +1,97 @@
/**
* 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 { uid } from './uid';
// @ts-ignore
import pkg from '../package.json';
import _ from 'lodash';
import { ISchema, Schema } from '@formily/json-schema';
/**
*
* @param {Object} schema - JSON Schema
* @returns {Object} - JSON Schema
*/
export const transformMultiColumnToSingleColumn = (schema: any): any => {
if (!schema) return schema;
if (schema.toJSON) {
schema = schema.toJSON();
}
if (schema['x-component'] !== 'Grid') {
Object.keys(schema.properties || {}).forEach((key) => {
schema.properties[key] = transformMultiColumnToSingleColumn(schema.properties[key]);
});
return schema;
}
schema = _.cloneDeep(schema);
const newProperties: any = {};
const { properties = {} } = schema;
let index = 0;
Object.keys(properties).forEach((key, rowIndex) => {
const row = properties[key];
if (row['x-component'] !== 'Grid.Row') {
row['x-index'] = ++index;
newProperties[key] = row;
return;
}
// 忽略没有列的行
if (!row.properties) {
return;
}
// 如果一个行只有一列,那么无需展开
if (Object.keys(row.properties).length === 1) {
row['x-index'] = ++index;
newProperties[key] = row;
return;
}
// 如果一个行有多列,则保留第一列,其余的列需要放到外面形成新的行(每一行依然保持一列)
Object.keys(row.properties).forEach((columnKey, colIndex) => {
const column = row.properties[columnKey];
_.set(column, 'x-component-props.width', 100);
if (colIndex === 0) {
row['x-index'] = ++index;
newProperties[key] = row;
return;
}
delete row.properties[columnKey];
// 将列转换为行
newProperties[`${uid()}_${columnKey}`] = createRow(column, columnKey, ++index);
});
});
schema.properties = newProperties;
return schema;
};
function createRow(column: any, key: string, index: number) {
return {
type: 'void',
version: '2.0',
'x-component': 'Grid.Row',
'x-app-version': pkg.version,
'x-uid': uid(),
'x-async': false,
'x-index': index,
_isJSONSchemaObject: true,
properties: {
[key]: column,
},
};
}

View File

@ -1,9 +1,17 @@
/**
* 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 } from '@emotion/css'; import { css } from '@emotion/css';
import { ISchema, useFieldSchema } from '@formily/react'; import { ISchema, useFieldSchema } from '@formily/react';
import { Action, ActionContextProvider, SchemaComponent, useCompile } from '@nocobase/client'; import { Action, ActionContextProvider, PopupSettingsProvider, SchemaComponent, useCompile } from '@nocobase/client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { NAMESPACE } from './constants'; import { NAMESPACE } from './constants';
import { useTranslation } from 'react-i18next';
import { UploadOutlined } from '@ant-design/icons'; import { UploadOutlined } from '@ant-design/icons';
const importFormSchema: ISchema = { const importFormSchema: ISchema = {
@ -113,7 +121,6 @@ const importFormSchema: ISchema = {
export const ImportAction = (props) => { export const ImportAction = (props) => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { t } = useTranslation(NAMESPACE);
const compile = useCompile(); const compile = useCompile();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();

View File

@ -17,7 +17,6 @@ import {
useSchemaInitializerItem, useSchemaInitializerItem,
} from '@nocobase/client'; } from '@nocobase/client';
import React from 'react'; import React from 'react';
import { NAMESPACE } from './constants';
import { useImportTranslation } from './locale'; import { useImportTranslation } from './locale';
import { useFields } from './useFields'; import { useFields } from './useFields';
import { Alert } from 'antd'; import { Alert } from 'antd';

View File

@ -0,0 +1,68 @@
/**
* 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 { Action, OpenModeProvider, SchemaComponentOptions, useMobileLayout, usePopupSettings } from '@nocobase/client';
import { createGlobalStyle } from 'antd-style';
import React, { FC, useEffect } from 'react';
import { ActionDrawerUsedInMobile, useToAdaptActionDrawerToMobile } from './adaptor-of-desktop/ActionDrawer';
import { useToAdaptFilterActionToMobile } from './adaptor-of-desktop/FilterAction';
import { mobileComponents } from './pages/dynamic-page/MobilePage';
const GlobalStyle = createGlobalStyle`
::-webkit-scrollbar {
display: none;
}
`;
const CommonDrawer: FC = (props) => {
const { isMobileLayout } = useMobileLayout();
const { isPopupVisibleControlledByURL } = usePopupSettings();
// 在移动端布局中,只要弹窗是通过 URL 打开的,都需要显示成子页面的样子
if (isMobileLayout && isPopupVisibleControlledByURL()) {
return <Action.Page {...props} />;
}
return <ActionDrawerUsedInMobile {...props} />;
};
const openModeToComponent = {
page: Action.Page,
drawer: CommonDrawer,
modal: CommonDrawer,
};
const MobileAdapter: FC = (props) => {
useToAdaptFilterActionToMobile();
useToAdaptActionDrawerToMobile();
useEffect(() => {
document.body.style.setProperty('--nb-mobile-page-tabs-content-padding', '12px');
document.body.style.setProperty('--nb-mobile-page-header-height', '46px');
}, []);
return (
<>
<GlobalStyle />
<OpenModeProvider defaultOpenMode="page" isMobile={true} openModeToComponent={openModeToComponent}>
<SchemaComponentOptions components={mobileComponents}>{props.children}</SchemaComponentOptions>
</OpenModeProvider>
</>
);
};
export const MobileComponentsProvider: FC = (props) => {
const { isMobileLayout } = useMobileLayout();
if (!isMobileLayout) {
return <>{props.children} </>;
}
return <MobileAdapter>{props.children}</MobileAdapter>;
};

View File

@ -50,6 +50,23 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
overflowX: 'hidden', overflowX: 'hidden',
backgroundColor: token.colorBgLayout, backgroundColor: token.colorBgLayout,
// 不带 tab 页的半窗
'& > .nb-grid-container': {
padding: `${token.paddingPageVertical}px ${token.paddingPageHorizontal}px`,
},
// 带有 tab 页的半窗
'.ant-tabs-nav': {
marginBottom: '0px !important',
padding: `0 ${token.paddingPageHorizontal + token.borderRadiusBlock / 2}px`,
backgroundColor: token.colorBgContainer,
},
// 带有 tab 页的半窗
'.ant-tabs-content-holder': {
padding: `${token.paddingPageVertical}px ${token.paddingPageHorizontal}px`,
},
// clear the margin-bottom of the last block // clear the margin-bottom of the last block
'& > .nb-grid-container > .nb-grid > .nb-grid-warp > .nb-grid-row:nth-last-child(2) .noco-card-item': { '& > .nb-grid-container > .nb-grid > .nb-grid-warp > .nb-grid-row:nth-last-child(2) .noco-card-item': {
marginBottom: 0, marginBottom: 0,

View File

@ -13,6 +13,7 @@ import {
NocoBaseRecursionField, NocoBaseRecursionField,
SchemaComponent, SchemaComponent,
useActionContext, useActionContext,
useGlobalTheme,
useZIndexContext, useZIndexContext,
zIndexContext, zIndexContext,
} from '@nocobase/client'; } from '@nocobase/client';
@ -31,6 +32,7 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?:
const { popupContainerRef, visiblePopup } = usePopupContainer(visible); const { popupContainerRef, visiblePopup } = usePopupContainer(visible);
const { componentCls, hashId } = useMobileActionDrawerStyle(); const { componentCls, hashId } = useMobileActionDrawerStyle();
const parentZIndex = useZIndexContext(); const parentZIndex = useZIndexContext();
const { theme: globalTheme } = useGlobalTheme();
// this schema need to add padding in the content area of the popup // this schema need to add padding in the content area of the popup
const isSpecialSchema = isChangePasswordSchema(fieldSchema) || isEditProfileSchema(fieldSchema); const isSpecialSchema = isChangePasswordSchema(fieldSchema) || isEditProfileSchema(fieldSchema);
@ -55,7 +57,6 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?:
}); });
const title = field.title || ''; const title = field.title || '';
const marginBlock = 18;
const closePopup = useCallback(() => { const closePopup = useCallback(() => {
setVisible(false); setVisible(false);
@ -63,14 +64,14 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?:
const theme = useMemo(() => { const theme = useMemo(() => {
return { return {
...globalTheme,
token: { token: {
marginBlock, ...globalTheme.token,
borderRadiusBlock: 0, marginBlock: 12,
boxShadowTertiary: 'none',
zIndexPopupBase: newZIndex, zIndexPopupBase: newZIndex,
}, },
}; };
}, [newZIndex]); }, [globalTheme, newZIndex]);
return ( return (
<zIndexContext.Provider value={newZIndex}> <zIndexContext.Provider value={newZIndex}>

View File

@ -109,12 +109,12 @@ export const useToAdaptFilterActionToMobile = () => {
}; };
/** /**
* mobile-container transformhttps://nocobase.height.app/T-4959 * mobile-container transform
* @param visible * @param visible
* @returns * @returns
*/ */
export const usePopupContainer = (visible: boolean) => { export const usePopupContainer = (visible: boolean) => {
const [mobileContainer] = useState<HTMLElement>(() => document.querySelector('.mobile-container')); const [mobileContainer] = useState<HTMLElement>(() => document.querySelector('.mobile-container') || document.body);
const [visiblePopup, setVisiblePopup] = useState(false); const [visiblePopup, setVisiblePopup] = useState(false);
const popupContainerRef = React.useRef<HTMLDivElement>(null); const popupContainerRef = React.useRef<HTMLDivElement>(null);
const parentZIndex = useZIndexContext(); const parentZIndex = useZIndexContext();

View File

@ -53,6 +53,7 @@ import { MobileSettingsBlockInitializer } from './mobile-blocks/settings-block/M
import { MobileSettingsBlockSchemaSettings } from './mobile-blocks/settings-block/schemaSettings'; import { MobileSettingsBlockSchemaSettings } from './mobile-blocks/settings-block/schemaSettings';
// @ts-ignore // @ts-ignore
import pkg from './../../package.json'; import pkg from './../../package.json';
import { MobileComponentsProvider } from './MobileComponentsProvider';
export * from './desktop-mode'; export * from './desktop-mode';
export * from './mobile'; export * from './mobile';
@ -104,6 +105,7 @@ export class PluginMobileClient extends Plugin {
} }
async load() { async load() {
this.app.use(MobileComponentsProvider);
this.addComponents(); this.addComponents();
this.addAppRoutes(); this.addAppRoutes();
this.addRoutes(); this.addRoutes();

View File

@ -8,7 +8,6 @@
*/ */
import { import {
Action,
AdminProvider, AdminProvider,
AntdAppProvider, AntdAppProvider,
AssociationFieldMode, AssociationFieldMode,
@ -18,9 +17,10 @@ import {
OpenModeProvider, OpenModeProvider,
useAssociationFieldModeContext, useAssociationFieldModeContext,
usePlugin, usePlugin,
usePopupSettings,
zIndexContext, zIndexContext,
} from '@nocobase/client'; } from '@nocobase/client';
import React from 'react'; import React, { FC } from 'react';
import { isDesktop } from 'react-device-detect'; import { isDesktop } from 'react-device-detect';
import { theme } from 'antd'; import { theme } from 'antd';
@ -36,10 +36,21 @@ import { PluginMobileClient } from '../index';
import { MobileAppProvider } from './MobileAppContext'; import { MobileAppProvider } from './MobileAppContext';
import { useStyles } from './styles'; import { useStyles } from './styles';
const CommonDrawer: FC = (props) => {
const { isPopupVisibleControlledByURL } = usePopupSettings();
// 在移动端布局中,只要弹窗是通过 URL 打开的,都需要显示成子页面的样子。因为这样可以通过左滑返回
if (isPopupVisibleControlledByURL()) {
return <MobileActionPage {...(props as any)} />;
}
return <ActionDrawerUsedInMobile {...props} />;
};
const openModeToComponent = { const openModeToComponent = {
page: MobileActionPage, page: CommonDrawer,
drawer: ActionDrawerUsedInMobile, drawer: ActionDrawerUsedInMobile,
modal: Action.Modal, modal: ActionDrawerUsedInMobile,
}; };
export const Mobile = () => { export const Mobile = () => {

View File

@ -60,7 +60,7 @@ DatePickerMobile.FilterWithPicker = (props) => {
}; };
DatePickerMobile.RangePicker = MobileRangePicker; DatePickerMobile.RangePicker = MobileRangePicker;
const mobileComponents = { export const mobileComponents = {
Button: MobileButton, Button: MobileButton,
Select: (props) => { Select: (props) => {
const { designable } = useDesignable(); const { designable } = useDesignable();

View File

@ -285,5 +285,10 @@ const MobileTimePicker: ComposedMobileTimePicker = connect(
mapReadPretty(NBTimePicker.ReadPretty), mapReadPretty(NBTimePicker.ReadPretty),
); );
MobileDateTimePicker.displayName = 'MobileDateTimePicker';
MobileRangePicker.displayName = 'MobileRangePicker';
MobileDateFilterWithPicker.displayName = 'MobileDateFilterWithPicker';
MobileTimePicker.displayName = 'MobileTimePicker';
MobileTimePicker.RangePicker = NBTimePicker.RangePicker; MobileTimePicker.RangePicker = NBTimePicker.RangePicker;
export { MobileDateTimePicker, MobileRangePicker, MobileDateFilterWithPicker, MobileTimePicker }; export { MobileDateTimePicker, MobileRangePicker, MobileDateFilterWithPicker, MobileTimePicker };

View File

@ -108,4 +108,6 @@ const MobilePicker = connect(
mapProps({ dataSource: 'options' }), mapProps({ dataSource: 'options' }),
); );
MobilePicker.displayName = 'MobilePicker';
export { MobilePicker }; export { MobilePicker };

View File

@ -6,12 +6,12 @@
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { CheckCircleOutlined } from '@ant-design/icons';
import { PageHeader } from '@ant-design/pro-layout';
import { Badge, Button, Layout, Menu, Tabs, Tooltip } from 'antd';
import classnames from 'classnames';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Link, Outlet, useNavigate, useParams } from 'react-router-dom'; import { Link, Outlet, useNavigate, useParams } from 'react-router-dom';
import { Button, Layout, Menu, Badge, Tooltip, Tabs } from 'antd';
import { PageHeader } from '@ant-design/pro-layout';
import { CheckCircleOutlined } from '@ant-design/icons';
import classnames from 'classnames';
import { import {
css, css,
@ -36,19 +36,6 @@ const layoutClass = css`
overflow: hidden; overflow: hidden;
`; `;
const sideClass = css`
overflow: auto;
position: sticky;
top: 0;
bottom: 0;
height: 100%;
.ant-layout-sider-children {
width: 200px;
height: 100%;
}
`;
const contentClass = css` const contentClass = css`
padding: 24px; padding: 24px;
min-height: 280px; min-height: 280px;
@ -134,8 +121,8 @@ function StatusTabs() {
tabBarExtraContent={ tabBarExtraContent={
ExtraActions ExtraActions
? { ? {
right: <ExtraActions />, right: <ExtraActions />,
} }
: {} : {}
} }
/> />
@ -196,7 +183,7 @@ export function WorkflowTasks() {
return ( return (
<Layout className={layoutClass}> <Layout className={layoutClass}>
<Layout.Sider className={sideClass} theme="light"> <Layout.Sider theme="light" breakpoint="md" collapsedWidth="0" zeroWidthTriggerStyle={{ top: 38 }}>
<Menu mode="inline" selectedKeys={[typeKey]} items={items} style={{ height: '100%' }} /> <Menu mode="inline" selectedKeys={[typeKey]} items={items} style={{ height: '100%' }} />
</Layout.Sider> </Layout.Sider>
<Layout <Layout