Files
PolyHermes/frontend/src/components/Layout.tsx
T
WrBug 46c32df421 fix(notification): 优化移动端消息设置页面交互体验
- 修复移动端 Tooltip 与点击事件冲突导致变量无法复制的问题
- 添加移动端备用复制方法(textarea + execCommand)
- 移除变量标签中的复制图标
- 移动端模板输入框调整为 15 行
- 调整系统设置菜单顺序

Made-with: Cursor
2026-03-02 23:32:26 +08:00

508 lines
14 KiB
TypeScript

import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { Layout as AntLayout, Menu, Drawer, Button, Modal, Tag } from 'antd'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import {
WalletOutlined,
UserOutlined,
UnorderedListOutlined,
BarChartOutlined,
MenuOutlined,
FileTextOutlined,
LinkOutlined,
AppstoreOutlined,
TeamOutlined,
LogoutOutlined,
SettingOutlined,
GithubOutlined,
TwitterOutlined,
CheckCircleOutlined,
SendOutlined,
ApiOutlined,
NotificationOutlined,
LineChartOutlined,
RocketOutlined,
DashboardOutlined
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
import { removeToken, getVersionText, getVersionInfo } from '../utils'
import { wsManager } from '../services/websocket'
import { apiClient } from '../services/api'
import Logo from './Logo'
const { Header, Content, Sider } = AntLayout
// 添加动画样式
const style = document.createElement('style')
style.textContent = `
@keyframes versionUpdatePulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
`
if (!document.head.querySelector('style[data-version-update-animation]')) {
style.setAttribute('data-version-update-animation', 'true')
document.head.appendChild(style)
}
interface LayoutProps {
children: ReactNode
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [hasUpdate, setHasUpdate] = useState(false)
// 获取当前选中的菜单项
const getSelectedKeys = (): string[] => {
return [location.pathname]
}
// 获取当前应该打开的父菜单
const getInitialOpenKeys = (): string[] => {
const path = location.pathname
const keys: string[] = []
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
keys.push('/copy-trading-management')
}
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
keys.push('/crypto-tail-management')
}
if (path.startsWith('/system-settings')) {
keys.push('/system-settings')
}
return keys
}
const [openKeys, setOpenKeys] = useState<string[]>(getInitialOpenKeys())
// 当路径变化时,自动打开对应的父菜单
useEffect(() => {
const path = location.pathname
const keys: string[] = []
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
keys.push('/copy-trading-management')
}
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
keys.push('/crypto-tail-management')
}
if (path.startsWith('/system-settings')) {
keys.push('/system-settings')
}
setOpenKeys(keys)
}, [location.pathname])
// 检查是否有新版本
useEffect(() => {
const checkUpdate = async () => {
try {
const response = await apiClient.get('/update/check')
if (response.data.code === 0 && response.data.data) {
setHasUpdate(response.data.data.hasUpdate || false)
}
} catch (error) {
// 静默失败,不影响页面使用
console.debug('检查更新失败:', error)
}
}
// 页面加载时检查一次
checkUpdate()
// 每5分钟检查一次
const interval = setInterval(checkUpdate, 5 * 60 * 1000)
return () => clearInterval(interval)
}, [])
const menuItems: MenuProps['items'] = [
{
key: '/announcements',
icon: <NotificationOutlined />,
label: t('menu.announcements') || '公告'
},
{
key: '/accounts',
icon: <WalletOutlined />,
label: t('menu.accounts')
},
{
key: '/copy-trading-management',
icon: <AppstoreOutlined />,
label: t('menu.copyTrading'),
children: [
{
key: '/copy-trading',
icon: <LinkOutlined />,
label: t('menu.copyTradingConfig')
},
{
key: '/leaders',
icon: <UserOutlined />,
label: t('menu.leaders')
},
{
key: '/templates',
icon: <FileTextOutlined />,
label: t('menu.templates')
},
{
key: '/backtest',
icon: <LineChartOutlined />,
label: t('menu.backtest') || '回测'
}
]
},
{
key: '/crypto-tail-management',
icon: <LineChartOutlined />,
label: t('menu.cryptoSpreadStrategy'),
children: [
{
key: '/crypto-tail-strategy',
icon: <RocketOutlined />,
label: t('menu.cryptoTailStrategy')
},
{
key: '/crypto-tail-monitor',
icon: <DashboardOutlined />,
label: t('menu.cryptoTailMonitor')
}
]
},
{
key: '/positions',
icon: <UnorderedListOutlined />,
label: t('menu.positions')
},
{
key: '/statistics',
icon: <BarChartOutlined />,
label: t('menu.statistics')
},
{
key: '/users',
icon: <TeamOutlined />,
label: t('menu.users')
},
{
key: '/system-settings',
icon: <SettingOutlined />,
label: t('menu.systemSettings') || '系统管理',
children: [
{
key: '/system-settings',
icon: <SettingOutlined />,
label: t('menu.systemOverview') || '通用设置'
},
{
key: '/system-settings/notification',
icon: <NotificationOutlined />,
label: t('menu.notifications') || '消息推送设置'
},
{
key: '/system-settings/rpc-nodes',
icon: <ApiOutlined />,
label: t('menu.rpcNodes') || 'RPC节点管理'
},
{
key: '/system-settings/api-health',
icon: <CheckCircleOutlined />,
label: t('menu.apiHealth') || 'API健康'
}
]
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: t('menu.logout')
}
]
const handleLogout = () => {
removeToken()
// 断开 WebSocket 连接
wsManager.disconnect()
navigate('/login', { replace: true })
}
const handleLogoutConfirm = () => {
Modal.confirm({
title: t('menu.logoutConfirm'),
content: t('menu.logoutConfirmDesc'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: () => {
handleLogout()
if (isMobile) {
setMobileMenuOpen(false)
}
}
})
}
const handleMenuClick = ({ key }: { key: string }) => {
// 如果是父菜单,不导航(但 /system-settings 作为子菜单项时可以导航)
if (key === '/copy-trading-management' || key === '/crypto-tail-management') {
return
}
// 处理退出登录
if (key === 'logout') {
handleLogoutConfirm()
return
}
navigate(key)
if (isMobile) {
setMobileMenuOpen(false)
}
}
const handleOpenChange = (keys: string[]) => {
setOpenKeys(keys)
}
if (isMobile) {
// 移动端布局
return (
<AntLayout style={{ minHeight: '100vh' }}>
<Header style={{
background: '#001529',
padding: '0 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Logo
size="normal"
darkMode={true}
/>
<Tag
color={hasUpdate ? 'warning' : 'success'}
onClick={() => {
if (hasUpdate) {
navigate('/system-settings')
}
}}
bordered={false}
style={{
cursor: hasUpdate ? 'pointer' : 'default',
fontSize: '8px',
padding: '1px 6px',
margin: 0,
background: 'transparent',
border: `1px solid ${hasUpdate ? '#faad14' : '#52c41a'}`,
borderRadius: '4px',
color: hasUpdate ? '#faad14' : '#52c41a',
lineHeight: '1.4',
display: 'inline-flex',
alignItems: 'center',
verticalAlign: 'middle'
}}
title={hasUpdate ? t('systemUpdate.versionTooltipNew') : t('systemUpdate.versionTooltipLatest')}
>
{getVersionInfo().gitTag || `v${getVersionText()}`}
</Tag>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<a
href="https://github.com/WrBug/PolyHermes"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '16px', display: 'flex', alignItems: 'center' }}
title="GitHub"
>
<GithubOutlined />
</a>
<a
href="https://x.com/polyhermes"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '16px', display: 'flex', alignItems: 'center' }}
title="Twitter"
>
<TwitterOutlined />
</a>
<a
href="https://t.me/polyhermes"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '16px', display: 'flex', alignItems: 'center' }}
title="Telegram 交流群"
>
<SendOutlined />
</a>
<Button
type="text"
icon={<MenuOutlined />}
style={{ color: '#fff', marginLeft: '4px' }}
onClick={() => setMobileMenuOpen(true)}
/>
</div>
</Header>
<Content style={{
padding: '12px 8px',
background: '#f0f2f5',
minHeight: 'calc(100vh - 64px)'
}}>
{children}
</Content>
<Drawer
title={t('menu.navigation')}
placement="left"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
bodyStyle={{ padding: 0 }}
>
<Menu
mode="inline"
selectedKeys={getSelectedKeys()}
openKeys={openKeys}
onOpenChange={handleOpenChange}
items={menuItems}
onClick={handleMenuClick}
style={{ border: 'none' }}
/>
</Drawer>
</AntLayout>
)
}
// 桌面端布局
return (
<AntLayout style={{ height: '100vh', overflow: 'hidden' }}>
<Sider
width={200}
style={{
background: '#001529',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
overflow: 'hidden'
}}
>
<div style={{
padding: '16px',
color: '#fff',
flexShrink: 0,
borderBottom: '1px solid rgba(255, 255, 255, 0.1)'
}}>
<div style={{
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '12px',
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px'
}}>
<span>PolyHermes</span>
<Tag
color={hasUpdate ? 'warning' : 'success'}
onClick={() => {
if (hasUpdate) {
navigate('/system-settings')
}
}}
bordered={false}
style={{
cursor: hasUpdate ? 'pointer' : 'default',
fontSize: '8px',
padding: '1px 6px',
margin: 0,
background: 'transparent',
border: `1px solid ${hasUpdate ? '#faad14' : '#52c41a'}`,
borderRadius: '4px',
color: hasUpdate ? '#faad14' : '#52c41a',
lineHeight: '1.4',
display: 'inline-flex',
alignItems: 'center',
verticalAlign: 'middle'
}}
title={hasUpdate ? t('systemUpdate.versionTooltipNew') : t('systemUpdate.versionTooltipLatest')}
>
{getVersionInfo().gitTag || `v${getVersionText()}`}
</Tag>
</div>
<div style={{
display: 'flex',
gap: '12px',
alignItems: 'center',
justifyContent: 'center'
}}>
<a
href="https://github.com/WrBug/PolyHermes"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '18px', display: 'flex', alignItems: 'center' }}
title="GitHub"
>
<GithubOutlined />
</a>
<a
href="https://x.com/polyhermes"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '18px', display: 'flex', alignItems: 'center' }}
title="Twitter"
>
<TwitterOutlined />
</a>
<a
href="https://t.me/polyhermes"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '18px', display: 'flex', alignItems: 'center' }}
title="Telegram 交流群"
>
<SendOutlined />
</a>
</div>
</div>
<Menu
mode="inline"
selectedKeys={getSelectedKeys()}
openKeys={openKeys}
onOpenChange={handleOpenChange}
items={menuItems}
onClick={handleMenuClick}
style={{
height: 'calc(100vh - 100px)',
borderRight: 0,
overflowY: 'auto'
}}
/>
</Sider>
<AntLayout style={{ marginLeft: 200, height: '100vh' }}>
<Content style={{
padding: '24px',
background: '#f0f2f5',
height: '100vh',
overflowY: 'auto'
}}>
{children}
</Content>
</AntLayout>
</AntLayout>
)
}
export default Layout