feat(notification): adapt the style of the notification pop-up window for mobile (#6557)

* refactor: improve message detail navigation

* refactor: extract InboxPopup component for better readability

* refactor: restructure ActionDrawer to use MobilePopup for improved modularity

* feat: enhance MobilePopup and related components for better mobile experience

* feat: add onClick handler to PinnedPluginList for better user interaction

* fix: update InboxPopup visibility logic and enhance message navigation handling
This commit is contained in:
Zeke Zhang 2025-03-26 15:27:35 +08:00 committed by GitHub
parent d429835215
commit 4ee9ccebfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 231 additions and 148 deletions

View File

@ -88,7 +88,7 @@ const dividerTheme = {
},
};
export const PinnedPluginList = React.memo(() => {
export const PinnedPluginList = React.memo((props: { onClick?: () => void }) => {
const { allowAll, snippets } = useACLRoleContext();
const getSnippetsAllow = (aclKey) => {
return allowAll || aclKey === '*' || snippets?.includes(aclKey);
@ -98,6 +98,7 @@ export const PinnedPluginList = React.memo(() => {
return (
<div className={pinnedPluginListClassName}>
<div onClick={props.onClick}>
{Object.keys(ctx.items)
.sort((a, b) => ctx.items[a].order - ctx.items[b].order)
.filter((key) => getSnippetsAllow(ctx.items[key].snippet))
@ -105,6 +106,7 @@ export const PinnedPluginList = React.memo(() => {
const Action = get(components, ctx.items[key].component);
return Action ? <Action key={key} /> : null;
})}
</div>
<ConfigProvider theme={dividerTheme}>
<Divider type="vertical" />
</ConfigProvider>

View File

@ -12,6 +12,7 @@ import ProLayout, { RouteContext, RouteContextType } from '@ant-design/pro-layou
import { HeaderViewProps } from '@ant-design/pro-layout/es/components/Header';
import { css } from '@emotion/css';
import { theme as antdTheme, ConfigProvider, Popover, Tooltip } from 'antd';
import { createStyles } from 'antd-style';
import React, { createContext, FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { Link, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
@ -52,7 +53,6 @@ import { KeepAlive } from './KeepAlive';
import { NocoBaseDesktopRoute, NocoBaseDesktopRouteType } from './convertRoutesToSchema';
import { MenuSchemaToolbar, ResetThemeTokenAndKeepAlgorithm } from './menuItemSettings';
import { userCenterSettings } from './userCenterSettings';
import { createGlobalStyle, createStyles } from 'antd-style';
export { KeepAlive, NocoBaseDesktopRouteType };
@ -75,7 +75,7 @@ const AllAccessDesktopRoutesContext = createContext<{
refresh: () => void;
}>({
allAccessRoutes: emptyArray,
refresh: () => {},
refresh: () => { },
});
AllAccessDesktopRoutesContext.displayName = 'AllAccessDesktopRoutesContext';
@ -418,9 +418,22 @@ const popoverStyle = css`
const MobileActions: FC = (props) => {
const { token } = useToken();
const [open, setOpen] = useState(false);
// 点击时立即关闭 Popover避免影响用户操作
const handleContentClick = useCallback(() => {
setOpen(false);
}, []);
return (
<Popover rootClassName={popoverStyle} content={<PinnedPluginList />} color={token.colorBgHeader}>
<Popover
rootClassName={popoverStyle}
content={<PinnedPluginList onClick={handleContentClick} />}
color={token.colorBgHeader}
trigger="click"
open={open}
onOpenChange={setOpen}
>
<div style={{ padding: '0 16px', display: 'flex', alignItems: 'center', height: '100%', marginRight: -16 }}>
<EllipsisOutlined
style={{
@ -535,7 +548,7 @@ const IsMobileLayoutContext = React.createContext<{
setIsMobileLayout: React.Dispatch<React.SetStateAction<boolean>>;
}>({
isMobileLayout: false,
setIsMobileLayout: () => {},
setIsMobileLayout: () => { },
});
const MobileLayoutProvider: FC = (props) => {

View File

@ -20,55 +20,54 @@ import {
import { ConfigProvider } from 'antd';
import { Popup } from 'antd-mobile';
import { CloseOutline } from 'antd-mobile-icons';
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { FC, ReactNode, useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useMobileActionDrawerStyle } from './ActionDrawer.style';
import { usePopupContainer } from './FilterAction';
import { MIN_Z_INDEX_INCREMENT } from './zIndex';
export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?: string }) => {
const fieldSchema = useFieldSchema();
const field = useField();
const { visible, setVisible } = useActionContext();
const { popupContainerRef, visiblePopup } = usePopupContainer(visible);
export interface MobilePopupProps {
title?: string;
visible: boolean;
minHeight?: number | string;
onClose: () => void;
children: ReactNode;
}
export const MobilePopup: FC<MobilePopupProps> = (props) => {
const {
title,
visible,
onClose: closePopup,
children,
minHeight,
} = props;
const { t } = useTranslation();
const { popupContainerRef } = usePopupContainer(visible);
const { componentCls, hashId } = useMobileActionDrawerStyle();
const parentZIndex = useZIndexContext();
const { theme: globalTheme } = useGlobalTheme();
// this schema need to add padding in the content area of the popup
const isSpecialSchema = isChangePasswordSchema(fieldSchema) || isEditProfileSchema(fieldSchema);
const footerNodeName = isSpecialSchema ? 'Action.Drawer.Footer' : props.footerNodeName;
const specialStyle = isSpecialSchema ? { backgroundColor: 'white' } : {};
const newZIndex = parentZIndex + MIN_Z_INDEX_INCREMENT;
const zIndexStyle = useMemo(() => {
return {
zIndex: newZIndex,
minHeight,
};
}, [newZIndex]);
const footerSchema = fieldSchema.reduceProperties((buf, s) => {
if (s['x-component'] === footerNodeName) {
return s;
}
return buf;
});
const title = field.title || '';
const closePopup = useCallback(() => {
setVisible(false);
}, [setVisible]);
}, [newZIndex, minHeight]);
const theme = useMemo(() => {
return {
...globalTheme,
token: {
...globalTheme.token,
marginBlock: 12,
zIndexPopupBase: newZIndex,
paddingPageHorizontal: 8,
paddingPageVertical: 8,
marginBlock: 12,
borderRadiusBlock: 8,
fontSize: 14,
},
};
}, [globalTheme, newZIndex]);
@ -78,7 +77,7 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?:
<ConfigProvider theme={theme}>
<Popup
className={`${componentCls} ${hashId}`}
visible={visiblePopup}
visible={visible}
onClose={closePopup}
onMaskClick={closePopup}
getContainer={() => popupContainerRef.current}
@ -93,11 +92,50 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?:
<CloseOutline />
</span>
<span>{title}</span>
<span className="nb-mobile-action-drawer-close-icon" onClick={closePopup}>
<span
className="nb-mobile-action-drawer-close-icon"
onClick={closePopup}
role="button"
tabIndex={0}
aria-label={t("Close")}
>
<CloseOutline />
</span>
</div>
{isSpecialSchema ? (
{children}
</Popup>
</ConfigProvider>
</zIndexContext.Provider>
)
}
export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?: string }) => {
const fieldSchema = useFieldSchema();
const field = useField();
const { visible, setVisible } = useActionContext();
const { visiblePopup } = usePopupContainer(visible);
// this schema need to add padding in the content area of the popup
const isSpecialSchema = isChangePasswordSchema(fieldSchema) || isEditProfileSchema(fieldSchema);
const footerNodeName = isSpecialSchema ? 'Action.Drawer.Footer' : props.footerNodeName;
const specialStyle = isSpecialSchema ? { backgroundColor: 'white' } : {};
const footerSchema = fieldSchema.reduceProperties((buf, s) => {
if (s['x-component'] === footerNodeName) {
return s;
}
return buf;
});
const title = field.title || '';
const closePopup = useCallback(() => {
setVisible(false);
}, [setVisible]);
const popupContent = isSpecialSchema ? (
<div style={{ padding: 12, ...specialStyle }}>
<SchemaComponent
schema={fieldSchema}
@ -114,8 +152,9 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?:
return s['x-component'] !== footerNodeName;
}}
/>
)}
{footerSchema ? (
);
const footerContent = footerSchema ? (
<div className="nb-mobile-action-drawer-footer" style={isSpecialSchema ? specialStyle : null}>
<NocoBaseRecursionField
basePath={field.address}
@ -126,10 +165,17 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?:
}}
/>
</div>
) : null}
</Popup>
</ConfigProvider>
</zIndexContext.Provider>
) : null;
return (
<MobilePopup
title={title}
visible={visiblePopup}
onClose={closePopup}
>
{popupContent}
{footerContent}
</MobilePopup>
);
});

View File

@ -55,6 +55,7 @@ import { MobileSettingsBlockSchemaSettings } from './mobile-blocks/settings-bloc
import pkg from './../../package.json';
import { MobileComponentsProvider } from './MobileComponentsProvider';
export { MobilePopup } from './adaptor-of-desktop/ActionDrawer';
export * from './desktop-mode';
export * from './mobile';
export * from './mobile-layout';

View File

@ -12,7 +12,7 @@ import _ from 'lodash';
import React, { FC, useEffect } from 'react';
import { PageBackgroundColor } from '../../../constants';
export const MobilePageContentContainer: FC<{ hideTabBar?: boolean }> = ({ children, hideTabBar }) => {
export const MobilePageContentContainer: FC<{ hideTabBar?: boolean; displayPageHeader?: boolean }> = ({ children, hideTabBar, displayPageHeader = true }) => {
const [mobileTabBarHeight, setMobileTabBarHeight] = React.useState(0);
const [mobilePageHeader, setMobilePageHeader] = React.useState(0);
const { token } = useToken();
@ -29,7 +29,7 @@ export const MobilePageContentContainer: FC<{ hideTabBar?: boolean }> = ({ child
});
return (
<>
{mobilePageHeader ? <div style={{ height: mobilePageHeader }}></div> : null}
{(mobilePageHeader && displayPageHeader) ? <div style={{ height: mobilePageHeader }}></div> : null}
<div
className="mobile-page-content"
data-testid="mobile-page-content"

View File

@ -12,13 +12,13 @@ import { cx, NocoBaseRecursionField, SchemaToolbarProvider } from '@nocobase/cli
import { NavBar } from 'antd-mobile';
import React, { FC } from 'react';
import { useRouteTranslation } from '../../../../locale';
import { useMobileTitle } from '../../../../mobile-providers';
import { useMobilePage } from '../../context';
import { useStyles } from './styles';
import { useRouteTranslation } from '../../../../locale';
export const MobilePageNavigationBar: FC = () => {
const { title } = useMobileTitle();
const { title } = useMobileTitle() || {};
const { displayNavigationBar = true, displayPageTitle = true } = useMobilePage();
const fieldSchema = useFieldSchema();
const { componentCls, hashId } = useStyles();

View File

@ -9,7 +9,6 @@
export const mobilePageHeaderStyle: any = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
borderBottom: '1px solid var(--adm-color-border)',

View File

@ -16,27 +16,23 @@
* For more information, please rwefer to: https://www.nocobase.com/agreement.
*/
import React, { useEffect, useCallback } from 'react';
import { reaction } from '@formily/reactive';
import { Badge, Button, ConfigProvider, Drawer, Tooltip, notification, theme } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { createStyles } from 'antd-style';
import { Icon } from '@nocobase/client';
import { InboxContent } from './InboxContent';
import { useLocalTranslation } from '../../locale';
import { fetchChannels } from '../observables';
import { observer } from '@formily/reactive-react';
import { useCurrentUserContext } from '@nocobase/client';
import { Icon, useCurrentUserContext, useMobileLayout } from '@nocobase/client';
import { MobilePopup } from '@nocobase/plugin-mobile/client';
import { Badge, Button, ConfigProvider, Drawer, notification, theme, Tooltip } from 'antd';
import { createStyles } from 'antd-style';
import React, { FC, useCallback, useEffect, useState } from 'react';
import { useLocalTranslation } from '../../locale';
import { Channel } from '../../types';
import {
updateUnreadMsgsCount,
unreadMsgsCountObs,
startMsgSSEStreamWithRetry,
inboxVisible,
userIdObs,
liveSSEObs,
fetchChannels, inboxVisible, liveSSEObs,
messageMapObs,
selectedChannelNameObs,
selectedChannelNameObs, startMsgSSEStreamWithRetry, unreadMsgsCountObs, updateUnreadMsgsCount, userIdObs
} from '../observables';
import { InboxContent } from './InboxContent';
import { MobileChannelPage } from './mobile/ChannelPage';
import { MobileMessagePage } from './mobile/MessagePage';
const useStyles = createStyles(({ token }) => {
return {
button: {
@ -46,6 +42,42 @@ const useStyles = createStyles(({ token }) => {
};
});
const InboxPopup: FC<{ title: string; visible: boolean; onClose: () => void }> = (props) => {
const { token } = theme.useToken();
const { isMobileLayout } = useMobileLayout();
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
if (isMobileLayout) {
return (
<>
<MobilePopup title={props.title} visible={props.visible} onClose={props.onClose} minHeight={'60vh'}>
<MobileChannelPage displayNavigationBar={false} onClickItem={setSelectedChannel} />
</MobilePopup>
<MobilePopup title={selectedChannel?.title} visible={props.visible && !!selectedChannel} onClose={() => setSelectedChannel(null)} minHeight={'60vh'}>
<MobileMessagePage displayPageHeader={false} />
</MobilePopup>
</>
)
}
return (
<Drawer
title={<div style={{ padding: '0', paddingLeft: token.padding }}>{props.title}</div>}
open={props.visible}
width={900}
onClose={props.onClose}
styles={{
header: {
paddingLeft: token.paddingMD,
},
}}
>
<InboxContent />
</Drawer>
)
}
const InnerInbox = (props) => {
const { t } = useLocalTranslation();
const { styles } = useStyles();
@ -89,7 +121,6 @@ const InnerInbox = (props) => {
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, []);
const DrawerTitle = <div style={{ padding: '0', paddingLeft: token.padding }}>{t('Message')}</div>;
useEffect(() => {
const dispose = reaction(
() => liveSSEObs.value,
@ -138,21 +169,7 @@ const InnerInbox = (props) => {
</Badge>
</Button>
</Tooltip>
<Drawer
title={DrawerTitle}
open={inboxVisible.value}
width={900}
onClose={() => {
inboxVisible.value = false;
}}
styles={{
header: {
paddingLeft: token.paddingMD,
},
}}
>
<InboxContent />
</Drawer>
<InboxPopup title={t('Message')} visible={inboxVisible.value} onClose={() => { inboxVisible.value = false; }} />
</ConfigProvider>
);
};

View File

@ -7,18 +7,18 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useRef, useEffect, useState } from 'react';
import { observer } from '@formily/reactive-react';
import { reaction } from '@formily/reactive';
import { List, Badge, InfiniteScroll, ListRef } from 'antd-mobile';
import { useNavigate } from 'react-router-dom';
import { dayjs } from '@nocobase/utils/client';
import InfiniteScrollContent from './InfiniteScrollContent';
import { channelListObs, channelStatusFilterObs, showChannelLoadingMoreObs, fetchChannels } from '../../observables';
import { Schema } from '@formily/react';
import { reaction } from '@formily/reactive';
import { observer } from '@formily/reactive-react';
import { useApp } from '@nocobase/client';
import { dayjs } from '@nocobase/utils/client';
import { Badge, InfiniteScroll, List, ListRef } from 'antd-mobile';
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { channelListObs, channelStatusFilterObs, fetchChannels, showChannelLoadingMoreObs } from '../../observables';
import InfiniteScrollContent from './InfiniteScrollContent';
const InternalChannelList = () => {
const InternalChannelList = (props: { onClickItem?: (item: any) => void }) => {
const app = useApp();
const navigate = useNavigate();
const channels = channelListObs.value;
@ -66,7 +66,11 @@ const InternalChannelList = () => {
<List.Item
key={item.name}
onClick={() => {
if (props.onClickItem) {
props.onClickItem(item);
} else {
navigate(`/page/in-app-message/messages?channel=${item.name}`);
}
}}
description={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>

View File

@ -7,20 +7,20 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useEffect, useCallback } from 'react';
import { Tabs } from 'antd-mobile';
import { observer } from '@formily/reactive-react';
import { useCurrentUserContext, css } from '@nocobase/client';
import { css, useCurrentUserContext } from '@nocobase/client';
import {
MobilePageContentContainer,
MobilePageHeader,
MobilePageNavigationBar,
MobilePageProvider,
MobilePageContentContainer,
} from '@nocobase/plugin-mobile/client';
import { userIdObs, fetchChannels, ChannelStatus, channelStatusFilterObs } from '../../observables';
import { ChannelList } from './ChannelList';
import { Tabs } from 'antd-mobile';
import React, { useEffect } from 'react';
import { useLocalTranslation } from '../../../locale';
const MobileMessageBoxInner = () => {
import { ChannelStatus, channelStatusFilterObs, fetchChannels, userIdObs } from '../../observables';
import { ChannelList } from './ChannelList';
const MobileMessageBoxInner = (props: { displayNavigationBar?: boolean; onClickItem?: (item: any) => void; }) => {
const { t } = useLocalTranslation();
const ctx = useCurrentUserContext();
const currUserId = ctx.data?.data?.id;
@ -31,7 +31,7 @@ const MobileMessageBoxInner = () => {
fetchChannels({});
}, []);
return (
<MobilePageProvider>
<MobilePageProvider displayNavigationBar={props.displayNavigationBar}>
<MobilePageHeader>
<MobilePageNavigationBar />
<Tabs
@ -57,7 +57,7 @@ const MobileMessageBoxInner = () => {
</Tabs>
</MobilePageHeader>
<MobilePageContentContainer>
<ChannelList />
<ChannelList onClickItem={props.onClickItem} />
</MobilePageContentContainer>
</MobilePageProvider>
);

View File

@ -7,35 +7,34 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useEffect, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { List, Badge, InfiniteScroll, NavBar, DotLoading } from 'antd-mobile';
import { observer } from '@formily/reactive-react';
import { useCurrentUserContext, css, useApp } from '@nocobase/client';
import { useSearchParams } from 'react-router-dom';
import { css, useApp, useCurrentUserContext } from '@nocobase/client';
import { dayjs } from '@nocobase/utils/client';
import { Badge, InfiniteScroll, List, NavBar } from 'antd-mobile';
import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Schema } from '@formily/react';
import {
MobilePageHeader,
MobilePageProvider,
MobilePageContentContainer,
useMobileTitle,
MobilePageHeader,
MobilePageProvider
} from '@nocobase/plugin-mobile/client';
import { useLocalTranslation } from '../../../locale';
import {
userIdObs,
fetchChannels,
fetchMessages,
inboxVisible,
selectedChannelNameObs,
selectedChannelObs,
selectedMessageListObs,
fetchChannels,
updateMessage,
fetchMessages,
showMsgLoadingMoreObs,
updateMessage,
userIdObs,
} from '../../observables';
import { useLocalTranslation } from '../../../locale';
import InfiniteScrollContent from './InfiniteScrollContent';
import { Schema } from '@formily/react';
const MobileMessagePageInner = () => {
const MobileMessagePageInner = (props: { displayPageHeader?: boolean }) => {
const app = useApp();
const { t } = useLocalTranslation();
const navigate = useNavigate();
@ -58,11 +57,13 @@ const MobileMessagePageInner = () => {
}, [currUserId]);
const messages = selectedMessageListObs.value;
const viewMessageDetail = (message) => {
const url = message.options?.mobileUrl;
const url = message.options?.mobileUrl || message.options?.url;
if (url) {
if (url.startsWith('/m/')) navigate(url.substring(2));
else if (url.startsWith('/')) navigate(url);
else {
else if (url.startsWith('/')) {
navigate(url);
inboxVisible.value = false;
} else {
window.location.href = url;
}
}
@ -101,13 +102,13 @@ const MobileMessagePageInner = () => {
const title = Schema.compile(selectedChannelObs.value?.title, { t: app.i18n.t }) || t('Message');
return (
<MobilePageProvider>
<MobilePageProvider displayPageHeader={props.displayPageHeader}>
<MobilePageHeader>
<NavBar className="nb-message-back-action" onBack={() => navigate('/page/in-app-message')}>
{title}
</NavBar>
</MobilePageHeader>
<MobilePageContentContainer>
<MobilePageContentContainer displayPageHeader={props.displayPageHeader}>
<div
style={{ height: '100%', overflowY: 'auto' }}
className={css({