/ easyshell-web / src / layouts / MainLayout.tsx
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;