MainLayout.tsx
1 import { useState, useEffect, useMemo, type CSSProperties } from 'react'; 2 import { Outlet, useNavigate, useLocation, Link } from 'react-router-dom'; 3 import { Layout, Menu, Dropdown, Space, Avatar, Tooltip, Breadcrumb, theme, Divider, Drawer } from 'antd'; 4 import { 5 DashboardOutlined, 6 DesktopOutlined, 7 CodeOutlined, 8 PlayCircleOutlined, 9 ClusterOutlined, 10 AuditOutlined, 11 MenuFoldOutlined, 12 MenuUnfoldOutlined, 13 UserOutlined, 14 LogoutOutlined, 15 SettingOutlined, 16 TeamOutlined, 17 ToolOutlined, 18 ThunderboltOutlined, 19 SunOutlined, 20 MoonOutlined, 21 RobotOutlined, 22 SafetyCertificateOutlined, 23 MessageOutlined, 24 ClockCircleOutlined, 25 FileTextOutlined, 26 CheckSquareOutlined, 27 HomeOutlined, 28 GlobalOutlined, 29 BulbOutlined, 30 FileProtectOutlined, 31 MenuOutlined, 32 QuestionCircleOutlined, 33 LinkOutlined, 34 ApiOutlined, 35 } from '@ant-design/icons'; 36 import type { MenuProps } from 'antd'; 37 import { useTheme } from '../contexts/ThemeContext'; 38 import { useTranslation } from 'react-i18next'; 39 import { getMe } from '../api/auth'; 40 import { useResponsive } from '../hooks/useResponsive'; 41 import { useVersion } from '../hooks/useVersion'; 42 43 const { Header, Sider, Content } = Layout; 44 45 const getSelectedKeys = (pathname: string): string[] => { 46 if (pathname === '/') return ['/']; 47 if (pathname.startsWith('/system/')) return [pathname]; 48 if (pathname.startsWith('/host/')) return ['/host']; 49 if (pathname.startsWith('/terminal/')) return ['/host']; 50 if (pathname.startsWith('/script/')) return ['/script']; 51 if (pathname === '/ai/approval') return ['/ai/approval']; 52 if (pathname.startsWith('/ai/')) return [pathname]; 53 return ['/' + pathname.split('/')[1]]; 54 }; 55 56 const getOpenKeys = (pathname: string): string[] => { 57 const keys: string[] = []; 58 if (pathname.startsWith('/system')) keys.push('/system'); 59 if (pathname.startsWith('/ai') && pathname !== '/ai/approval') keys.push('/ai'); 60 return keys; 61 }; 62 63 const MainLayout: React.FC = () => { 64 const [collapsed, setCollapsed] = useState(false); 65 const [drawerVisible, setDrawerVisible] = useState(false); 66 const [currentUser, setCurrentUser] = useState<{ username: string; role: string }>({ username: '', role: '' }); 67 const navigate = useNavigate(); 68 const location = useLocation(); 69 const [openKeys, setOpenKeys] = useState<string[]>(getOpenKeys(location.pathname)); 70 const { isDark, toggleTheme } = useTheme(); 71 const { token } = theme.useToken(); 72 const { t, i18n } = useTranslation(); 73 const { isMobile } = useResponsive(); 74 const versionInfo = useVersion(); 75 76 const routeLabelMap: Record<string, string> = useMemo(() => ({ 77 '/': t('nav.dashboard'), 78 '/host': t('nav.host'), 79 '/script': t('nav.script'), 80 '/script/new': t('script.createScript'), 81 '/script/edit': t('script.editScript'), 82 '/task': t('nav.task'), 83 '/cluster': t('nav.cluster'), 84 '/audit': t('nav.audit'), 85 '/ai': t('nav.ai'), 86 '/ai/chat': t('nav.ai_chat'), 87 '/ai/scheduled': t('nav.ai_scheduled'), 88 '/ai/reports': t('nav.ai_reports'), 89 '/ai/approval': t('nav.ai_approval'), 90 '/system': t('nav.system'), 91 '/system/users': t('nav.system_users'), 92 '/system/config': t('nav.system_config'), 93 '/system/ai': t('nav.system_ai'), 94 '/system/bot': t('nav.system_bot'), 95 '/system/risk': t('nav.system_risk'), 96 '/system/agents': t('nav.system_agents'), 97 '/system/memory': t('nav.system_memory'), 98 '/system/sop': t('nav.system_sop'), 99 '/terminal': t('nav.terminal'), 100 }), [t]); 101 102 const menuItems: MenuProps['items'] = useMemo(() => { 103 const groupLabelStyle: CSSProperties = { 104 fontSize: 10, 105 fontWeight: 700, 106 letterSpacing: '0.08em', 107 textTransform: 'uppercase' as const, 108 color: isDark ? 'rgba(255,255,255,0.28)' : 'rgba(0,0,0,0.3)', 109 padding: '20px 16px 6px', 110 lineHeight: '16px', 111 userSelect: 'none', 112 }; 113 114 return [ 115 { type: 'group', label: <div style={groupLabelStyle}>{t('nav.group.overview')}</div>, children: [ 116 { key: '/', icon: <DashboardOutlined />, label: t('nav.dashboard') }, 117 ]}, 118 { type: 'group', label: <div style={groupLabelStyle}>{t('nav.group.infrastructure')}</div>, children: [ 119 { key: '/host', icon: <DesktopOutlined />, label: t('nav.host') }, 120 { key: '/cluster', icon: <ClusterOutlined />, label: t('nav.cluster') }, 121 ]}, 122 { type: 'group', label: <div style={groupLabelStyle}>{t('nav.group.devops')}</div>, children: [ 123 { key: '/script', icon: <CodeOutlined />, label: t('nav.script') }, 124 { key: '/task', icon: <PlayCircleOutlined />, label: t('nav.task') }, 125 { key: '/audit', icon: <AuditOutlined />, label: t('nav.audit') }, 126 ]}, 127 { type: 'group', label: <div style={groupLabelStyle}>{t('nav.group.approval')}</div>, children: [ 128 { key: '/ai/approval', icon: <CheckSquareOutlined />, label: t('nav.ai_approval') }, 129 ]}, 130 { type: 'group', label: <div style={groupLabelStyle}>{t('nav.group.intelligence')}</div>, children: [ 131 { 132 key: '/ai', 133 icon: <RobotOutlined />, 134 label: t('nav.ai'), 135 children: [ 136 { key: '/ai/chat', icon: <MessageOutlined />, label: t('nav.ai_chat') }, 137 { key: '/ai/scheduled', icon: <ClockCircleOutlined />, label: t('nav.ai_scheduled') }, 138 { key: '/ai/reports', icon: <FileTextOutlined />, label: t('nav.ai_reports') }, 139 ], 140 }, 141 ]}, 142 { type: 'group', label: <div style={groupLabelStyle}>{t('nav.group.administration')}</div>, children: [ 143 { 144 key: '/system', 145 icon: <SettingOutlined />, 146 label: t('nav.system'), 147 children: [ 148 { key: '/system/users', icon: <TeamOutlined />, label: t('nav.system_users') }, 149 { key: '/system/config', icon: <ToolOutlined />, label: t('nav.system_config') }, 150 { key: '/system/ai', icon: <RobotOutlined />, label: t('nav.system_ai') }, 151 { key: '/system/bot', icon: <ApiOutlined />, label: t('nav.system_bot') }, 152 { key: '/system/risk', icon: <SafetyCertificateOutlined />, label: t('nav.system_risk') }, 153 { key: '/system/agents', icon: <RobotOutlined />, label: t('nav.system_agents') }, 154 { key: '/system/memory', icon: <BulbOutlined />, label: t('nav.system_memory') }, 155 { key: '/system/sop', icon: <FileProtectOutlined />, label: t('nav.system_sop') }, 156 ], 157 }, 158 ]}, 159 ]; 160 }, [t, isDark]); 161 162 const breadcrumbItems = useMemo(() => { 163 const items: Array<{ title: React.ReactNode; key: string }> = [ 164 { title: <Link to="/"><HomeOutlined /></Link>, key: 'home' }, 165 ]; 166 if (location.pathname === '/') return items; 167 168 const segments = location.pathname.split('/').filter(Boolean); 169 let currentPath = ''; 170 171 for (let i = 0; i < segments.length; i++) { 172 currentPath += '/' + segments[i]; 173 const label = routeLabelMap[currentPath]; 174 if (label) { 175 const isLast = i === segments.length - 1; 176 items.push({ 177 title: isLast ? label : <Link to={currentPath}>{label}</Link>, 178 key: currentPath, 179 }); 180 } else { 181 const parentPath = '/' + segments.slice(0, i).join('/'); 182 if (parentPath === '/host') { 183 items.push({ title: t('nav.host_detail'), key: currentPath }); 184 } else { 185 items.push({ title: segments[i], key: currentPath }); 186 } 187 } 188 } 189 return items; 190 }, [location.pathname, routeLabelMap, t]); 191 192 useEffect(() => { 193 const authToken = localStorage.getItem('token'); 194 if (!authToken) { 195 navigate('/login'); 196 return; 197 } 198 getMe().then((res) => { 199 if (res.code === 200 && res.data) { 200 setCurrentUser({ username: res.data.username, role: res.data.role }); 201 } 202 }).catch(() => {}); 203 }, [navigate]); 204 205 useEffect(() => { 206 setOpenKeys(getOpenKeys(location.pathname)); 207 }, [location.pathname]); 208 209 const userMenuItems: MenuProps['items'] = useMemo(() => [ 210 { 211 key: 'logout', 212 icon: <LogoutOutlined />, 213 label: t('header.logout'), 214 onClick: () => { 215 localStorage.removeItem('token'); 216 navigate('/login'); 217 }, 218 }, 219 ], [t, navigate]); 220 221 const accentGlow = isDark 222 ? `0 0 80px ${token.colorPrimary}15, 0 0 160px ${token.colorPrimary}08` 223 : 'none'; 224 225 const iconBtnStyle: CSSProperties = { 226 width: 34, 227 height: 34, 228 borderRadius: 8, 229 display: 'flex', 230 alignItems: 'center', 231 justifyContent: 'center', 232 cursor: 'pointer', 233 transition: 'all 200ms ease', 234 background: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)', 235 border: `1px solid ${isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, 236 }; 237 238 const sidebarBackground = isDark 239 ? 'linear-gradient(180deg, #111113 0%, #0c0c0e 100%)' 240 : 'linear-gradient(180deg, #ffffff 0%, #fafafa 100%)'; 241 242 const handleMenuClick = ({ key }: { key: string }) => { 243 navigate(key); 244 if (isMobile) { 245 setDrawerVisible(false); 246 } 247 }; 248 249 // Shared sidebar content for both desktop Sider and mobile Drawer 250 const sidebarContent = ( 251 <> 252 <div 253 className="sidebar-header" 254 style={{ 255 justifyContent: collapsed && !isMobile ? 'center' : 'flex-start', 256 padding: collapsed && !isMobile ? 0 : '0 20px', 257 gap: 10, 258 borderBottom: `1px solid ${isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'}`, 259 }} 260 > 261 <div style={{ 262 width: 34, 263 height: 34, 264 borderRadius: 10, 265 background: `linear-gradient(135deg, ${token.colorPrimary}, ${token.colorPrimaryActive || '#1d4ed8'})`, 266 display: 'flex', 267 alignItems: 'center', 268 justifyContent: 'center', 269 flexShrink: 0, 270 boxShadow: `0 2px 12px ${token.colorPrimary}40`, 271 }}> 272 <ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} /> 273 </div> 274 {(isMobile || !collapsed) && ( 275 <span style={{ 276 fontSize: 18, 277 fontWeight: 800, 278 color: token.colorText, 279 letterSpacing: '-0.02em', 280 whiteSpace: 'nowrap', 281 background: isDark 282 ? `linear-gradient(135deg, #fff, ${token.colorPrimary})` 283 : `linear-gradient(135deg, #111, ${token.colorPrimary})`, 284 WebkitBackgroundClip: 'text', 285 WebkitTextFillColor: 'transparent', 286 }}> 287 EasyShell 288 </span> 289 )} 290 </div> 291 <div className="sidebar-menu-wrapper"> 292 <Menu 293 theme={isDark ? 'dark' : 'light'} 294 mode="inline" 295 selectedKeys={getSelectedKeys(location.pathname)} 296 openKeys={collapsed && !isMobile ? [] : openKeys} 297 onOpenChange={setOpenKeys} 298 items={menuItems} 299 onClick={handleMenuClick} 300 style={{ 301 background: 'transparent', 302 border: 'none', 303 marginTop: 4, 304 padding: '0 8px', 305 }} 306 /> 307 </div> 308 {(isMobile || !collapsed) && ( 309 <div className="sidebar-footer"> 310 <div style={{ 311 padding: '10px 12px', 312 borderRadius: 10, 313 background: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)', 314 border: `1px solid ${isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)'}`, 315 fontSize: 11, 316 color: isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)', 317 textAlign: 'center', 318 }}> 319 EasyShell {versionInfo ? `v${versionInfo.agentVersion}` : ''} 320 </div> 321 </div> 322 )} 323 </> 324 ); 325 326 return ( 327 <Layout style={{ minHeight: '100vh' }} data-testid="main-layout"> 328 {/* Desktop Sider - hidden on mobile */} 329 {!isMobile && ( 330 <Sider 331 trigger={null} 332 collapsible 333 collapsed={collapsed} 334 width={240} 335 className="main-layout-sider" 336 style={{ 337 background: sidebarBackground, 338 borderRight: `1px solid ${isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'}`, 339 boxShadow: accentGlow, 340 }} 341 aria-label="Sidebar navigation" 342 > 343 {sidebarContent} 344 </Sider> 345 )} 346 347 {/* Mobile Drawer */} 348 {isMobile && ( 349 <Drawer 350 placement="left" 351 open={drawerVisible} 352 onClose={() => setDrawerVisible(false)} 353 width={280} 354 styles={{ 355 body: { padding: 0 }, 356 header: { display: 'none' }, 357 }} 358 className="mobile-nav-drawer" 359 > 360 <div 361 style={{ 362 height: '100%', 363 display: 'flex', 364 flexDirection: 'column', 365 background: sidebarBackground, 366 }} 367 > 368 {sidebarContent} 369 </div> 370 </Drawer> 371 )} 372 373 <Layout style={{ background: isDark ? '#09090b' : '#f5f5f7' }}> 374 <Header 375 style={{ 376 height: 64, 377 lineHeight: '64px', 378 padding: isMobile ? '0 16px' : '0 24px', 379 background: isDark 380 ? 'rgba(14,14,16,0.8)' 381 : 'rgba(255,255,255,0.8)', 382 backdropFilter: 'blur(12px) saturate(180%)', 383 WebkitBackdropFilter: 'blur(12px) saturate(180%)', 384 borderBottom: `1px solid ${isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'}`, 385 display: 'flex', 386 alignItems: 'center', 387 justifyContent: 'space-between', 388 position: 'sticky', 389 top: 0, 390 zIndex: 10, 391 }} 392 role="banner" 393 aria-label="Top navigation bar" 394 > 395 <Space size={isMobile ? 12 : 16} align="center"> 396 {isMobile ? ( 397 <MenuOutlined 398 onClick={() => setDrawerVisible(true)} 399 style={{ fontSize: 18, color: token.colorTextSecondary, cursor: 'pointer' }} 400 aria-label={t('header.open_menu')} 401 /> 402 ) : collapsed ? ( 403 <MenuUnfoldOutlined 404 onClick={() => setCollapsed(false)} 405 style={{ fontSize: 16, color: token.colorTextSecondary, cursor: 'pointer' }} 406 aria-label={t('header.expand_sidebar')} 407 /> 408 ) : ( 409 <MenuFoldOutlined 410 onClick={() => setCollapsed(true)} 411 style={{ fontSize: 16, color: token.colorTextSecondary, cursor: 'pointer' }} 412 aria-label={t('header.collapse_sidebar')} 413 /> 414 )} 415 <Divider type="vertical" style={{ height: 20, margin: 0 }} /> 416 {!isMobile && <Breadcrumb items={breadcrumbItems} style={{ margin: 0 }} />} 417 </Space> 418 <Space size={8}> 419 <Tooltip title={t('header.website')}> 420 <a href="https://easyshell.ai" target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }}> 421 <div style={iconBtnStyle} role="button" aria-label="Website"> 422 <LinkOutlined style={{ fontSize: 15, color: token.colorTextSecondary }} /> 423 </div> 424 </a> 425 </Tooltip> 426 <Tooltip title={t('header.docs')}> 427 <a href="https://docs.easyshell.ai" target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }}> 428 <div style={iconBtnStyle} role="button" aria-label="Documentation"> 429 <QuestionCircleOutlined style={{ fontSize: 15, color: token.colorTextSecondary }} /> 430 </div> 431 </a> 432 </Tooltip> 433 <Tooltip title={t('header.discord')}> 434 <a href="https://discord.gg/akQqRgNB6t" target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }}> 435 <div style={iconBtnStyle} role="button" aria-label="Discord"> 436 <svg viewBox="0 0 24 24" width="15" height="15" fill={token.colorTextSecondary}><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg> 437 </div> 438 </a> 439 </Tooltip> 440 <div style={{ width: 1, height: 20, background: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', margin: '0 4px' }} /> 441 <Tooltip title={i18n.language === 'zh-CN' ? t('header.switch_to_en') : t('header.switch_to_zh')}> 442 <div 443 onClick={() => { 444 const newLang = i18n.language === 'zh-CN' ? 'en-US' : 'zh-CN'; 445 i18n.changeLanguage(newLang); 446 localStorage.setItem('locale', newLang); 447 }} 448 style={iconBtnStyle} 449 aria-label="Toggle language" 450 role="button" 451 > 452 <GlobalOutlined style={{ fontSize: 15, color: token.colorPrimary }} /> 453 </div> 454 </Tooltip> 455 <Tooltip title={isDark ? t('header.theme_light') : t('header.theme_dark')}> 456 <div 457 onClick={toggleTheme} 458 style={iconBtnStyle} 459 aria-label="Toggle theme" 460 role="button" 461 data-testid="theme-toggle" 462 > 463 {isDark 464 ? <SunOutlined style={{ fontSize: 15, color: '#faad14' }} /> 465 : <MoonOutlined style={{ fontSize: 15, color: token.colorPrimary }} /> 466 } 467 </div> 468 </Tooltip> 469 <div style={{ width: 1, height: 20, background: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', margin: '0 4px' }} /> 470 <Dropdown menu={{ items: userMenuItems }} placement="bottomRight"> 471 <Space style={{ cursor: 'pointer', padding: '4px 8px', borderRadius: 8, transition: 'all 200ms', background: 'transparent' }} data-testid="user-menu"> 472 <Avatar 473 size={30} 474 icon={<UserOutlined />} 475 style={{ 476 background: `linear-gradient(135deg, ${token.colorPrimary}, ${token.colorPrimaryActive || '#1d4ed8'})`, 477 boxShadow: `0 2px 8px ${token.colorPrimary}30`, 478 }} 479 /> 480 {!isMobile && ( 481 <span style={{ color: token.colorText, fontWeight: 600, fontSize: 13 }}> 482 {currentUser.username || '...'} 483 </span> 484 )} 485 </Space> 486 </Dropdown> 487 </Space> 488 </Header> 489 <Content 490 className="main-layout-content" 491 role="main" 492 aria-label="Main content area" 493 > 494 <Outlet /> 495 </Content> 496 </Layout> 497 </Layout> 498 ); 499 }; 500 501 export default MainLayout;