This commit is contained in:
2026-04-24 20:30:52 +08:00
commit 704bc34625
152 changed files with 45612 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import { useModel } from '@umijs/max';
import { Badge } from 'antd';
// This component is used to display the unread alert count in the menu item.
import { memo } from 'react';
const AlertCountInMenuItem = memo(() => {
const { unreadCount } = useModel('useMenuNoticeNum');
return <Badge count={unreadCount} overflowCount={9999} />;
});
export default AlertCountInMenuItem;

View File

@@ -0,0 +1,83 @@
import { Switch, type SwitchProps } from 'antd';
import React, { useEffect, useState } from 'react';
interface AsyncSwitchProps extends Omit<SwitchProps, 'onChange'> {
// Execute the function asynchronously. If successful, the state will be switched. If failed, the state will remain unchanged.
onAsyncChange: (checked: boolean) => Promise<any>;
// Standard onChange, automatically injected in Form mode
onChange?: (checked: boolean) => void;
// Form.Item binds the value attribute to value by default, and the value attribute of Switch is checked. You can modify the bound value property through valuePropName.
/**
<Form.Item name="fieldA" valuePropName="checked">
<Switch />
</Form.Item>
*/
value?: boolean;
defaultChecked?: boolean;
// Successfully/unsuccessfully hook
onSuccess?: () => void;
onError?: (err: any) => void;
}
const AsyncSwitch: React.FC<AsyncSwitchProps> = (props) => {
const {
value,
defaultChecked,
onChange,
onAsyncChange,
onSuccess,
onError,
disabled,
...rest
} = props;
// 1. Internal maintenance status, the initial value is checked first, followed by defaultChecked
const [innerChecked, setInnerChecked] = useState<boolean>(
!!(value ?? defaultChecked),
);
const [loading, setLoading] = useState<boolean>(false);
// 2. When the external checked changes (such as Form reset or external manual modification), synchronize the internal state
useEffect(() => {
if (value !== void 0) {
setInnerChecked(value);
}
}, [value]);
const handleChange = async (val: boolean) => {
setLoading(true);
try {
// Execute asynchronous logic
await onAsyncChange(val);
// Processing after success:
// If checked is not passed externally (uncontrolled mode), the internal state is updated
if (value === void 0) {
setInnerChecked(val);
}
// Trigger external onChange (if it is Form, Form takes over the status update)
onChange?.(val);
onSuccess?.();
} catch (error) {
console.warn('AsyncSwitch Error:', error);
onError?.(error);
// Failure does nothing and leaves the Switch as is
} finally {
setLoading(false);
}
};
return (
<Switch
{...rest}
loading={loading}
// Always use innerChecked in preference as it is the final representation after synchronizing with the outside
checked={innerChecked}
onChange={handleChange}
disabled={disabled || loading}
/>
);
};
export default AsyncSwitch;

View File

@@ -0,0 +1,57 @@
import { Button, Result } from 'antd';
import React from 'react';
import { $t } from '@/utils/i18n';
const isDev = process.env.NODE_ENV === 'development';
type CustomErrorComponentProps = { error: Error };
const CustomErrorComponent = ({ error }: CustomErrorComponentProps) => {
const isChunkLoadFailed = error?.message?.includes('Loading chunk');
console.dir(error);
return (
<div style={{ padding: '100px 0', textAlign: 'center' }}>
<Result
status="500"
title={$t('pages.error.title')}
subTitle={
isChunkLoadFailed
? $t('pages.error.subTitle')
: $t('pages.error.subTitle2')
}
extra={[
<Button
type="primary"
key="refresh"
onClick={() => window.location.reload()}
>
{$t('pages.refresh')}
</Button>,
]}
/>
<pre style={{ marginTop: 24, color: isDev ? '#ccc' : 'transparent' }}>
{`${error.name}: ${error.message}`}
</pre>
</div>
);
};
type ErrorBoundaryProps = { children: React.ReactNode };
type ErrorBoundaryState = { hasError: boolean; error: Error };
class GlobalErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state = { hasError: false, error: new Error('') };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return <CustomErrorComponent error={this.state.error} />;
}
return this.props.children;
}
}
export default GlobalErrorBoundary;

View File

@@ -0,0 +1,49 @@
import { Card } from 'antd';
export type CardInfo = {
count: number;
icon: string;
iconColor: string;
iconBgColor: string;
leftColor: string;
description: string;
};
type Props = {
cardInfo: CardInfo;
};
export default ({ cardInfo }: Props) => (
<>
<Card
hoverable
variant="borderless"
style={{
borderLeftColor: cardInfo.leftColor,
borderLeftWidth: 4,
borderLeftStyle: 'solid',
}}
>
<div
style={{
color: cardInfo.iconColor,
marginBottom: 12,
}}
>
<div
className="flex align-center justify-center"
style={{
width: 50,
height: 50,
backgroundColor: cardInfo.iconBgColor,
// padding: 12,
borderRadius: 10,
fontSize: 20,
}}
>
{cardInfo.icon}
</div>
</div>
<div style={{ fontSize: 34, fontWeight: 'bold' }}>{cardInfo.count}</div>
<div>{cardInfo.description}</div>
</Card>
</>
);

View File

@@ -0,0 +1,41 @@
import { Dropdown } from 'antd';
import type { DropDownProps } from 'antd/es/dropdown';
import { createStyles } from 'antd-style';
import classNames from 'classnames';
import React from 'react';
const useStyles = createStyles(({ token }) => {
return {
dropdown: {
[`@media screen and (max-width: ${token.screenXS}px)`]: {
width: '100%',
},
},
};
});
export type HeaderDropdownProps = {
overlayClassName?: string;
placement?:
| 'bottomLeft'
| 'bottomRight'
| 'topLeft'
| 'topCenter'
| 'topRight'
| 'bottomCenter';
} & Omit<DropDownProps, 'overlay'>;
const HeaderDropdown: React.FC<HeaderDropdownProps> = ({
overlayClassName: cls,
...restProps
}) => {
const { styles } = useStyles();
return (
<Dropdown
overlayClassName={classNames(styles.dropdown, cls)}
{...restProps}
/>
);
};
export default HeaderDropdown;

View File

@@ -0,0 +1,34 @@
import { Tag, type TagProps } from 'antd';
import { hexToRgba } from '@/utils';
/**
* Custom tag component
* @param color - Tag color
* @param bordered - Whether to display a border
* @param round - Whether to display a rounded tag
* @param children - Tag content
* @param restProps - Other props
*/
export default ({
color = '#3b82f6',
bordered = false,
round = true,
children,
...restProps
}: TagProps & { round?: boolean }) => (
<>
<Tag
style={{
borderRadius: round ? '12px' : 'none',
fontWeight: 500,
color,
padding: '2px 10px',
backgroundColor: hexToRgba(color as string, 0.25),
}}
bordered={bordered}
{...restProps}
>
{children}
</Tag>
</>
);

View File

@@ -0,0 +1,78 @@
import { BellOutlined } from '@ant-design/icons';
import { history, Link } from '@umijs/max';
import { Alert, Badge, Modal, Popover } from 'antd';
import { useCallback, useEffect } from 'react';
import useUserInfo from '@/hooks/useUserInfo';
import { $t } from '@/utils/i18n';
const DEFAULT_DELAY_TIME = 6 * 60 * 60 * 1000;
const ChangePassTips: React.FC = () => {
const { userInfo } = useUserInfo();
const [modal, contextHolder] = Modal.useModal();
const editPassword = userInfo?.editPassword;
const changePassTimestamp = localStorage.getItem('changePassTimestamp');
const isProfilePage = window.location.pathname.indexOf('/profile') !== -1;
const isDelayOutTime = changePassTimestamp
? Date.now() - Number(changePassTimestamp) > 0
: false;
useEffect(() => {
if (editPassword === 0 && (!changePassTimestamp || isDelayOutTime)) {
showModal();
}
}, [editPassword, changePassTimestamp, isDelayOutTime]);
const showModal = useCallback(() => {
modal.confirm({
title: $t('pages.changePassTips'),
okText: $t('pages.profile.changePassword'),
cancelText: $t('pages.laterHandle'),
okType: 'danger',
onOk: () => {
if (!isProfilePage) {
history.push('/profile');
}
localStorage.setItem(
'changePassTimestamp',
String(Date.now() + DEFAULT_DELAY_TIME),
);
},
onCancel: (close) => {
localStorage.setItem(
'changePassTimestamp',
String(Date.now() + DEFAULT_DELAY_TIME),
);
close();
},
});
}, []);
const tipsNode = (
<>
<Link to="/profile">
<Alert
message={$t('pages.changePassTips')}
type="warning"
showIcon
style={{ width: 375 }}
/>
</Link>
</>
);
return (
<>
<Popover
content={tipsNode}
styles={{
body: { padding: 0 },
}}
>
<Badge dot={true} offset={[-5, 5]}>
<BellOutlined style={{ fontSize: 20 }} />
</Badge>
</Popover>
{contextHolder}
</>
);
};
export default ChangePassTips;

View File

@@ -0,0 +1,98 @@
import { Form, Progress } from 'antd';
import { useMemo } from 'react';
import { $t } from '@/utils/i18n';
type ScoreItem = {
text: string;
color: string;
percent: number;
level: number;
};
type PasswordLevelProps = {
passwordStr?: string;
};
/**
* Password strength component
* @param passwordStr - The name of the password field in the form, default is 'password'
*/
const PasswordLevel: React.FC<PasswordLevelProps> = ({
passwordStr = 'password',
}) => {
const tips = useMemo(
() => (
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
{$t('form.password.tips')}
</div>
),
[],
);
const checkPasswordStrength = (value: string) => {
if (!value) return null;
// 1. Less than 6 digits: directly judged as extremely weak (gear 0)
if (value.length < 6) {
return {
level: 0,
text: $t('form.password.level0'),
color: '#ff4d4f',
percent: 25,
};
}
// 2. Statistic character types
let typeCount = 0;
if (/\d/.test(value)) typeCount++; // Contains numbers
if (/[a-zA-Z]/.test(value)) typeCount++; // Contains letters
if (/[^A-Za-z0-9]/.test(value)) typeCount++; // Contains special characters
// 3. Based on the number of character types, judge the password strength
if (typeCount <= 1) {
// Contains only numbers or letters
return {
level: 1,
text: $t('form.password.level1'),
color: '#ff7a45',
percent: 50,
};
} else if (typeCount === 2) {
// Two types of combinations
return {
level: 2,
text: $t('form.password.level2'),
color: '#ffec3d',
percent: 75,
};
} else {
// Three types of combinations or more
return {
level: 3,
text: $t('form.password.level3'),
color: '#52c41a',
percent: 100,
};
}
};
// Listen for changes in the password field
const value = Form.useWatch(passwordStr);
if (!value || value.length === 0) return tips;
const { text, color, percent } = checkPasswordStrength(value) as ScoreItem;
return (
<div style={{ marginTop: 8 }}>
<div style={{ marginBottom: 4 }}>
{$t('form.password.strength')}<span style={{ color }}>{text}</span>
</div>
<Progress
percent={percent}
strokeColor={color}
showInfo={false}
steps={4}
/>
{tips}
</div>
);
};
export default PasswordLevel;

View File

@@ -0,0 +1,93 @@
import { LogoutOutlined, UserOutlined } from '@ant-design/icons';
import { history, useModel } from '@umijs/max';
import type { MenuProps } from 'antd';
import { Spin } from 'antd';
import React from 'react';
import { logoutAndToLoginPage } from '@/access';
import useUserInfo from '@/hooks/useUserInfo';
import { $t } from '@/utils/i18n';
// import { closeSSEWithApi } from '@/services/api';
import HeaderDropdown from '../HeaderDropdown';
export type GlobalHeaderRightProps = {
menu?: boolean;
children?: React.ReactNode;
};
export const AvatarName = () => {
const { userInfo } = useUserInfo();
const name = userInfo?.nickName || '';
return <span className="anticon">{name}</span>;
};
/**
* Avatar dropdown component in the global header
* @param children - The content to be displayed inside the dropdown trigger
*/
export const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({
children,
}) => {
const { setUserInfo, userInfo } = useUserInfo();
const { setUnreadCountWithSign } = useModel('useMenuNoticeNum');
// const { status } = useModel('useSSE');
const loginOut = async () => {
try {
setUnreadCountWithSign(0);
// if (status === 'open') {
// await closeSSEWithApi();
// }
} catch (error) {
console.error('Failed to close SSE connection:', error);
}
await logoutAndToLoginPage();
setUserInfo(undefined);
};
const onMenuClick: MenuProps['onClick'] = (event) => {
const { key } = event;
if (key === 'logout') {
loginOut();
return;
}
history.push(`/${key}`);
};
const loading = (
<Spin
size="small"
style={{
marginLeft: 8,
marginRight: 8,
}}
/>
);
if (!userInfo) {
return loading;
}
const menuItems = [
{
key: 'profile',
icon: <UserOutlined />,
label: $t('pages.profile'),
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: $t('pages.logout'),
},
];
return (
<HeaderDropdown
menu={{
selectedKeys: [],
onClick: onMenuClick,
items: menuItems,
}}
>
{children}
</HeaderDropdown>
);
};

View File

@@ -0,0 +1,56 @@
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
import { useModel } from '@umijs/max';
import { Segmented } from 'antd';
import React from 'react';
interface ThemeDarkProps {
collapsed?: boolean;
isMobile?: boolean;
navTheme?: NavTheme;
}
/** * Theme switch component for toggling between light and dark themes
* @param props - Component props including collapsed state, mobile state, and current theme
*/
const ThemeSwitch: React.FC<ThemeDarkProps> = (props: ThemeDarkProps) => {
const { initialState, setInitialState } = useModel('@@initialState');
const changeTheme = (value: NavTheme) => {
const settings = initialState?.settings || {};
localStorage.setItem('navTheme', value);
setInitialState((s) => ({
...s,
settings: {
...settings,
navTheme: value,
},
}));
try {
const html = document.documentElement;
if (value === 'realDark') {
html.dataset.theme = 'dark';
} else {
html.removeAttribute('data-theme');
}
} catch (error) {
console.log(error);
}
};
return (
<Segmented
size="small"
defaultValue={props.navTheme}
shape="round"
onChange={changeTheme}
// vertical={!props.isMobile && props.collapsed}
options={[
{ value: 'light', icon: <SunOutlined /> },
{ value: 'realDark', icon: <MoonOutlined /> },
]}
/>
);
};
export default ThemeSwitch;

View File

@@ -0,0 +1,52 @@
import { DownOutlined, GlobalOutlined } from '@ant-design/icons';
import { getLocale, setLocale } from '@umijs/max';
import { Button, Dropdown, type MenuProps, Space } from 'antd';
import { getAccessToken } from '@/access';
import { userUpdateLang } from '@/services/api/api';
export type SiderTheme = 'light' | 'dark';
const localMap = [
{
key: 'en_US',
lang: 'en_US',
label: 'English',
title: 'Language',
},
{
key: 'zh_CN',
lang: 'zh_CN',
label: '简体中文',
title: '语言',
},
];
const langLabels: Record<string, string> = {
zh_CN: '简体中文',
en_US: 'English',
zh_TW: '繁体中文',
};
const onClick: MenuProps['onClick'] = async ({ key }) => {
const token = await getAccessToken();
if (token) {
await userUpdateLang({ lang: key });
}
setLocale(key);
};
export const SelectLang: React.FC = () => {
const selectedLang = getLocale();
return (
<Dropdown menu={{ items: localMap, onClick }} arrow>
<Button
color="default"
variant="filled"
style={{ paddingInline: '14px' }}
>
<Space>
<GlobalOutlined />
<span>{langLabels?.[selectedLang]}</span>
<DownOutlined />
</Space>
</Button>
</Dropdown>
);
};

View File

@@ -0,0 +1,64 @@
import { useModel } from '@umijs/max';
import React, { useEffect, useRef } from 'react';
import { useMessageBuffer } from '@/hooks/useMessage';
import useUserInfo from '@/hooks/useUserInfo';
import { getAlertPages } from '@/services/api';
import { isMobileDevice } from '@/utils';
import bus from '@/utils/eventBus';
const TIMER_INTERVAL = 60 * 1000 * 2;
const SSEController: React.FC = () => {
const { disconnectSSE } = useModel('useSSE');
const { startConnection } = useMessageBuffer();
const { userInfo } = useUserInfo();
const userConfig = userInfo?.userConfig;
const timerRef = useRef<NodeJS.Timeout | null>(null);
const { setUnreadCountWithSign } = useModel('useMenuNoticeNum');
const refreshCount = async () => {
try {
const { data } = await getAlertPages({
pageNum: 1,
pageSize: 1,
});
setUnreadCountWithSign(data?.pending || 0);
} catch (error) {
console.error('Failed to fetch unread count', error);
}
bus.emit('check-voice-permission');
};
const startPolling = () => {
stopPolling();
timerRef.current = setInterval(() => {
refreshCount();
}, TIMER_INTERVAL);
};
const stopPolling = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
useEffect(() => {
const isMobile = isMobileDevice();
if (
!isMobile &&
process.env.NODE_ENV === 'production' &&
userConfig?.desktopPush
) {
startConnection();
}
startPolling();
return () => {
disconnectSSE();
stopPolling();
};
}, [userInfo?.userId]);
return null;
};
export default SSEController;

View File

@@ -0,0 +1,26 @@
import { ConfigProvider, Layout, type LayoutProps, theme } from 'antd';
import React from 'react';
const { Content } = Layout;
/** * ThemeWrapper component that wraps the application with Ant Design's ConfigProvider to apply the selected theme
* @param children - The content to be wrapped by the theme provider
*/
const ThemeWrapper: React.FC<LayoutProps> = ({ children, ...restProps }) => {
const localTheme = localStorage.getItem('navTheme');
return (
<ConfigProvider
theme={{
algorithm:
localTheme === 'realDark'
? theme.darkAlgorithm
: theme.defaultAlgorithm,
}}
>
<Layout>
<Content {...restProps}>{children}</Content>
</Layout>
</ConfigProvider>
);
};
export default ThemeWrapper;

View File

@@ -0,0 +1,157 @@
import { ChromeOutlined, SoundOutlined } from '@ant-design/icons';
import { getLocale, Icon } from '@umijs/max';
import { Alert, Divider, Image, Modal, Space, Tabs, Typography } from 'antd';
import { useCallback, useEffect, useState } from 'react';
import useRichI18n from '@/hooks/useRichI18n';
import { checkAudioPermission } from '@/utils';
import bus from '@/utils/eventBus';
import { $t } from '@/utils/i18n';
const { Paragraph } = Typography;
const ChangePassTips: React.FC = () => {
const { richFormat } = useRichI18n();
const [visible, setVisible] = useState(false);
const [hasAudioPermission, setHasAudioPermission] = useState(true);
const checkAudio = useCallback((showPopup?: boolean) => {
checkAudioPermission().then((hasPermission) => {
setHasAudioPermission(hasPermission);
});
showPopup && setVisible(true);
}, []);
const openPopup = useCallback(() => setVisible(true), []);
useEffect(() => {
checkAudio();
bus.on('check-voice-permission', checkAudio);
bus.on('open-voice-popup', openPopup);
// document.addEventListener('click', () => checkAudio());
return () => {
bus.off('check-voice-permission', checkAudio);
bus.off('open-voice-popup', openPopup);
// document.removeEventListener('click', () => checkAudio());
};
}, []);
const browserGuides = [
{
key: 'chrome',
label: (
<span>
<ChromeOutlined /> Chrome
</span>
),
children: (
<div>
<Paragraph>1. {richFormat('pages.voiceCheck.chrome.tips')}</Paragraph>
<Paragraph>
2. {richFormat('pages.voiceCheck.chrome.tips2')}
</Paragraph>
<Paragraph>
3. {richFormat('pages.voiceCheck.chrome.tips3')}
</Paragraph>
<Paragraph>
4. {richFormat('pages.voiceCheck.chrome.tips4')}
</Paragraph>
<Paragraph>
5. {richFormat('pages.voiceCheck.chrome.tips5')}
</Paragraph>
<Image
width="100%"
src={require(
`@/assets/imgs/voice_permission_tips_${getLocale()}.png`,
)}
/>
</div>
),
},
];
return (
<>
<Space align="center" onClick={() => setVisible(true)}>
{!hasAudioPermission && (
<>
<Alert
message={$t('pages.voiceCheck.alertMessage')}
type="warning"
showIcon
/>
<Icon
icon="local:silence"
height="20px"
fill="#f59e0b"
className="flex align-center"
/>
</>
)}
{/* {hasAudioPermission ? (
<Icon
icon="local:voice"
height="20px"
fill="#34d399"
className="flex align-center"
/>
) : (
<Icon
icon="local:silence"
height="20px"
fill="#f59e0b"
className="flex align-center"
/>
)} */}
</Space>
<Modal
title={
<Space>
<SoundOutlined /> {$t('pages.voiceCheck.title')}
</Space>
}
open={visible}
onCancel={() => setVisible(false)}
width={800}
footer={null}
centered
styles={{
body: {
maxHeight: '85vh',
overflowY: 'auto',
overflowX: 'hidden',
marginRight: -20,
paddingRight: 8,
},
}}
>
<Space direction="vertical" size="middle">
<Alert
message={$t('pages.voiceCheck.alertMessage2')}
description={$t('pages.voiceCheck.alertMessage3')}
type="warning"
banner
/>
{/* <div>
<Space style={{ marginBottom: 10 }} align="center">
<InfoCircleOutlined />
<Text strong>{$t('pages.voiceCheck.tips')}</Text>
</Space>
<div>{$t('pages.voiceCheck.tips2')}</div>
</div> */}
<Divider style={{ margin: 0 }}>
{$t('pages.voiceCheck.tips3')}
</Divider>
<Tabs defaultActiveKey="chrome" items={browserGuides} size="small" />
<Alert type="info" message={$t('pages.voiceCheck.tips4')} banner />
</Space>
</Modal>
</>
);
};
export default ChangePassTips;

View File

@@ -0,0 +1,153 @@
import {
CloseCircleOutlined,
ExclamationCircleOutlined,
InfoCircleOutlined,
SyncOutlined,
} from '@ant-design/icons';
import { useModel } from '@umijs/max';
import { Alert, Badge, Modal, Popover, Space, Typography } from 'antd';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useMessageBuffer } from '@/hooks/useMessage';
import { $t } from '@/utils/i18n';
interface WebNotificationProps {
collapsed?: boolean;
}
let hasOpened = false;
const { Title } = Typography;
/**
* WebNotification component for displaying web notifications
* @param collapsed - Whether the menu is collapsed
*/
const WebNotification: React.FC<WebNotificationProps> = ({ collapsed }) => {
const { status, retryCount } = useModel('useSSE');
const { startConnection } = useMessageBuffer();
const [isModalOpen, setIsModalOpen] = useState(false);
// State mapping configuration
const statusConfig = useMemo(() => {
const map = {
idle: {
color: 'default',
text: $t('pages.notice.status.default'),
title: $t('pages.notice.status.default.title'),
icon: <ExclamationCircleOutlined />,
},
connecting: {
color: 'processing',
text: $t('pages.notice.status.processing', { retryCount }),
title: $t('pages.notice.status.processing.title'),
icon: <SyncOutlined spin />,
},
open: {
color: 'success',
text: $t('pages.notice.status.success'),
title: $t('pages.notice.status.success.title'),
icon: null,
},
error: {
color: 'error',
text: $t('pages.notice.status.error'),
title: $t('pages.notice.status.error.title'),
icon: <CloseCircleOutlined style={{ color: 'red' }} />,
},
closed: {
color: 'default',
text: $t('pages.notice.status.closed'),
title: $t('pages.notice.status.closed.title'),
icon: <ExclamationCircleOutlined style={{ color: 'orange' }} />,
},
replaced: {
color: 'default',
text: $t('pages.notice.status.replaced'),
title: $t('pages.notice.status.replaced.title'),
icon: <ExclamationCircleOutlined style={{ color: 'orange' }} />,
},
};
return map[status];
}, [status, retryCount]);
const alertNode = (
<Alert
type="warning"
style={{ cursor: 'pointer' }}
message={
<Space
onClick={() => {
if (status === 'open') return;
setIsModalOpen(!isModalOpen);
}}
>
<Badge status={statusConfig.color as any} text={statusConfig.text} />
{statusConfig.icon}
</Space>
}
/>
);
const handleModalVisible = useCallback(() => {
if (status === 'open') return;
setIsModalOpen(!isModalOpen);
}, [isModalOpen]);
useEffect(() => {
if (['error', 'replaced'].includes(status) && !hasOpened) {
setIsModalOpen(true);
hasOpened = true;
} else if (status === 'open') {
hasOpened = false;
setIsModalOpen(false);
}
}, [status]);
return (
<>
<Modal
okText={$t('pages.notice.button.reconnect')}
open={isModalOpen}
cancelButtonProps={{
style: { display: 'none' },
}}
onCancel={handleModalVisible}
onOk={startConnection}
okButtonProps={{
style: { display: status === 'open' ? 'none' : 'inline-flex' },
}}
mask={false}
maskClosable={false}
getContainer=".ant-pro-layout-container"
style={{ top: 20, marginLeft: 20 }}
styles={{
wrapper: { pointerEvents: 'none' },
content: { width: '400px' },
}}
>
<Space align="start">
<Title level={4}>{statusConfig.icon}</Title>
<div>
<Title level={5} style={{ marginTop: 3 }}>
{statusConfig.title}
</Title>
<div>{statusConfig.text}</div>
</div>
</Space>
</Modal>
{collapsed ? (
<Popover content={alertNode} styles={{ body: { padding: 0 } }}>
<div className="text-center">
<InfoCircleOutlined
style={{
fontSize: 22,
color: status === 'open' ? '#10b981' : 'orange',
}}
onClick={handleModalVisible}
/>
</div>
</Popover>
) : (
alertNode
)}
</>
);
};
export default WebNotification;

11
src/components/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* This file serves as the directory for the component
* The purpose is to uniformly manage external output components and facilitate classification.
*/
/**
* layout component
*/
import { SelectLang } from './RightContent';
import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
export { AvatarDropdown, AvatarName, SelectLang };