new
This commit is contained in:
11
src/components/AlertCountInMenuItem/index.tsx
Normal file
11
src/components/AlertCountInMenuItem/index.tsx
Normal 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;
|
||||
83
src/components/AsyncSwitch/index.tsx
Normal file
83
src/components/AsyncSwitch/index.tsx
Normal 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;
|
||||
57
src/components/GlobalErrorBoundary/index.tsx
Normal file
57
src/components/GlobalErrorBoundary/index.tsx
Normal 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;
|
||||
49
src/components/HeadStatisticCard/index.tsx
Normal file
49
src/components/HeadStatisticCard/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
41
src/components/HeaderDropdown/index.tsx
Normal file
41
src/components/HeaderDropdown/index.tsx
Normal 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;
|
||||
34
src/components/MyTag/index.tsx
Normal file
34
src/components/MyTag/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
78
src/components/NeedChangePassTips/index.tsx
Normal file
78
src/components/NeedChangePassTips/index.tsx
Normal 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;
|
||||
98
src/components/PasswordLevel/index.tsx
Normal file
98
src/components/PasswordLevel/index.tsx
Normal 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;
|
||||
93
src/components/RightContent/AvatarDropdown.tsx
Normal file
93
src/components/RightContent/AvatarDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
56
src/components/RightContent/ThemeSwitch.tsx
Normal file
56
src/components/RightContent/ThemeSwitch.tsx
Normal 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;
|
||||
52
src/components/RightContent/index.tsx
Normal file
52
src/components/RightContent/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
64
src/components/SSEController/index.tsx
Normal file
64
src/components/SSEController/index.tsx
Normal 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;
|
||||
26
src/components/ThemeWrapper/index.tsx
Normal file
26
src/components/ThemeWrapper/index.tsx
Normal 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;
|
||||
157
src/components/VoiceCheck/index.tsx
Normal file
157
src/components/VoiceCheck/index.tsx
Normal 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;
|
||||
153
src/components/WebNoticeStatus/index.tsx
Normal file
153
src/components/WebNoticeStatus/index.tsx
Normal 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
11
src/components/index.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user