Commit 2a548826b0f6648e27d8eea6cd5e9cca8d2bf43d
1 parent
ae88da4c
feat(usr): add AppShell component with tab navigation
Showing
2 changed files
with
181 additions
and
0 deletions
frontend/src/components/AppShell.tsx
0 → 100644
| 1 | +import { useState } from 'react' | ||
| 2 | +import { Outlet, useNavigate } from 'react-router-dom' | ||
| 3 | +import { useAppSelector, useAppDispatch } from '../store/hooks' | ||
| 4 | +import { closeTab, activateTab } from '../store/slices/tabsSlice' | ||
| 5 | +import NavOverlay from './NavOverlay' | ||
| 6 | + | ||
| 7 | +const ANTLER_PATHS = [ | ||
| 8 | + 'M14 10c2 4 1 8-1 11 3-1 7 0 10 3 1-4 4-7 8-7-3 3-4 7-3 11l4 1c-1 3 0 6 3 8-3 0-6 1-8 4-1-3-4-5-8-5 2-3 2-7 0-10-3 1-7 0-10-3 3 0 5-2 6-5l-1-8z', | ||
| 9 | + 'M48 14c-2 3-2 6-1 9-2-2-5-2-8-1 1 3 1 6-1 9 3 0 5 2 6 5 1-3 4-5 7-5-2-3-2-6 0-9 2 1 5 1 7-1-2 0-4-1-5-3-1-2-3-4-5-4z', | ||
| 10 | + 'M28 38c2 3 5 5 9 5 1 4 4 7 8 8-3 2-5 5-5 9-3-2-7-3-11-2 1-3 1-7-1-10-3 0-6-1-8-4 3-1 6-3 8-6z', | ||
| 11 | +] | ||
| 12 | + | ||
| 13 | +export default function AppShell() { | ||
| 14 | + const dispatch = useAppDispatch() | ||
| 15 | + const navigate = useNavigate() | ||
| 16 | + const { tabs, activeId } = useAppSelector(s => s.tabs) | ||
| 17 | + const userInfo = useAppSelector(s => s.auth.userInfo) | ||
| 18 | + const [navOpen, setNavOpen] = useState(false) | ||
| 19 | + | ||
| 20 | + function handleTabClick(id: string, path: string) { | ||
| 21 | + dispatch(activateTab(id)) | ||
| 22 | + navigate(path) | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + function handleTabClose(e: React.MouseEvent, id: string) { | ||
| 26 | + e.stopPropagation() | ||
| 27 | + const idx = tabs.findIndex(t => t.id === id) | ||
| 28 | + dispatch(closeTab(id)) | ||
| 29 | + const remaining = tabs.filter(t => t.id !== id) | ||
| 30 | + const newIdx = Math.min(idx, remaining.length - 1) | ||
| 31 | + if (remaining[newIdx]) navigate(remaining[newIdx].path) | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + return ( | ||
| 35 | + <div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}> | ||
| 36 | + {/* Topbar */} | ||
| 37 | + <div style={{ display: 'flex', alignItems: 'stretch', height: 44, background: 'var(--color-topbar-bg)', color: '#fff', position: 'relative', zIndex: 30, flexShrink: 0 }}> | ||
| 38 | + {/* Logo */} | ||
| 39 | + <div style={{ width: 54, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> | ||
| 40 | + <svg viewBox="0 0 64 64" width={30} height={30} fill="#0e1216"> | ||
| 41 | + {ANTLER_PATHS.map((d, i) => <path key={i} d={d} />)} | ||
| 42 | + </svg> | ||
| 43 | + </div> | ||
| 44 | + {/* Nav toggle */} | ||
| 45 | + <button | ||
| 46 | + aria-label="全部导航" | ||
| 47 | + onClick={() => setNavOpen(v => !v)} | ||
| 48 | + style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 18px', color: '#fff', cursor: 'pointer', fontSize: 14, border: 'none', background: navOpen ? 'var(--color-primary)' : 'transparent', height: '100%', fontFamily: 'inherit' }} | ||
| 49 | + > | ||
| 50 | + <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}> | ||
| 51 | + <line x1={4} y1={7} x2={20} y2={7} /><line x1={4} y1={12} x2={20} y2={12} /><line x1={4} y1={17} x2={20} y2={17} /> | ||
| 52 | + </svg> | ||
| 53 | + 全部导航 | ||
| 54 | + </button> | ||
| 55 | + {/* Tab list */} | ||
| 56 | + <div style={{ display: 'flex', alignItems: 'stretch', flex: 1 }}> | ||
| 57 | + {tabs.map(tab => ( | ||
| 58 | + <div | ||
| 59 | + key={tab.id} | ||
| 60 | + role="tab" | ||
| 61 | + aria-selected={tab.id === activeId} | ||
| 62 | + onClick={() => handleTabClick(tab.id, tab.path)} | ||
| 63 | + style={{ | ||
| 64 | + display: 'flex', alignItems: 'center', gap: 8, padding: '0 18px', cursor: 'pointer', fontSize: 14, height: '100%', | ||
| 65 | + color: tab.id === activeId ? 'var(--color-tab-active)' : 'var(--color-tab-text)', | ||
| 66 | + borderBottom: tab.id === activeId ? '2px solid var(--color-tab-active)' : 'none', | ||
| 67 | + }} | ||
| 68 | + > | ||
| 69 | + {tab.title} | ||
| 70 | + {tab.closable && ( | ||
| 71 | + <button | ||
| 72 | + aria-label={`关闭 ${tab.title}`} | ||
| 73 | + onClick={e => handleTabClose(e, tab.id)} | ||
| 74 | + style={{ marginLeft: 6, width: 14, height: 14, borderRadius: '50%', border: 'none', background: 'transparent', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: '#9aa0a8', cursor: 'pointer', padding: 0 }} | ||
| 75 | + > | ||
| 76 | + ✕ | ||
| 77 | + </button> | ||
| 78 | + )} | ||
| 79 | + </div> | ||
| 80 | + ))} | ||
| 81 | + </div> | ||
| 82 | + {/* User info */} | ||
| 83 | + <div style={{ display: 'flex', alignItems: 'center', gap: 6, paddingRight: 14, fontSize: 14 }}> | ||
| 84 | + {userInfo?.username}({userInfo?.userType}) | ||
| 85 | + </div> | ||
| 86 | + </div> | ||
| 87 | + {/* Stage */} | ||
| 88 | + <div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}> | ||
| 89 | + {navOpen && <NavOverlay onClose={() => setNavOpen(false)} />} | ||
| 90 | + <div style={{ position: 'absolute', inset: 0, overflow: 'auto' }}> | ||
| 91 | + <Outlet /> | ||
| 92 | + </div> | ||
| 93 | + </div> | ||
| 94 | + </div> | ||
| 95 | + ) | ||
| 96 | +} |
frontend/src/test/AppShell.test.tsx
0 → 100644
| 1 | +import { describe, it, expect, vi } from 'vitest' | ||
| 2 | +import { render, screen, waitFor } from '@testing-library/react' | ||
| 3 | +import userEvent from '@testing-library/user-event' | ||
| 4 | +import { Provider } from 'react-redux' | ||
| 5 | +import { MemoryRouter, Routes, Route } from 'react-router-dom' | ||
| 6 | +import { configureStore } from '@reduxjs/toolkit' | ||
| 7 | +import authReducer from '../store/slices/authSlice' | ||
| 8 | +import tabsReducer, { openTab } from '../store/slices/tabsSlice' | ||
| 9 | +import AppShell from '../components/AppShell' | ||
| 10 | + | ||
| 11 | +vi.mock('../components/NavOverlay', () => ({ | ||
| 12 | + default: ({ onClose }: { onClose: () => void }) => ( | ||
| 13 | + <div data-testid="nav-overlay" onClick={onClose}>NavOverlay</div> | ||
| 14 | + ), | ||
| 15 | +})) | ||
| 16 | + | ||
| 17 | +function makeStore(extraTabs = false) { | ||
| 18 | + const store = configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } }) | ||
| 19 | + store.dispatch({ | ||
| 20 | + type: 'auth/setCredentials', | ||
| 21 | + payload: { accessToken: 'tok', refreshToken: 'ref', userInfo: { userId: 'u0', username: 'admin', userType: '超级管理员', language: '中文', brandId: 'b1' } }, | ||
| 22 | + }) | ||
| 23 | + if (extraTabs) { | ||
| 24 | + store.dispatch(openTab({ id: 'userlist', title: '用户列表', path: '/usr/users', closable: true })) | ||
| 25 | + } | ||
| 26 | + return store | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +function renderShell(path = '/', extraTabs = false) { | ||
| 30 | + const store = makeStore(extraTabs) | ||
| 31 | + return { | ||
| 32 | + store, | ||
| 33 | + ...render( | ||
| 34 | + <Provider store={store}> | ||
| 35 | + <MemoryRouter initialEntries={[path]}> | ||
| 36 | + <Routes> | ||
| 37 | + <Route element={<AppShell />}> | ||
| 38 | + <Route path="/" element={<div>MainPage</div>} /> | ||
| 39 | + <Route path="/usr/users" element={<div>UserListPage</div>} /> | ||
| 40 | + </Route> | ||
| 41 | + </Routes> | ||
| 42 | + </MemoryRouter> | ||
| 43 | + </Provider> | ||
| 44 | + ), | ||
| 45 | + } | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +describe('AppShell', () => { | ||
| 49 | + it('renders_mainTabAndUserInfo', () => { | ||
| 50 | + renderShell() | ||
| 51 | + expect(screen.getByText('主页')).toBeInTheDocument() | ||
| 52 | + expect(screen.getByText(/admin/)).toBeInTheDocument() | ||
| 53 | + }) | ||
| 54 | + | ||
| 55 | + it('renders_openTabs', () => { | ||
| 56 | + renderShell('/', true) | ||
| 57 | + expect(screen.getByText('用户列表')).toBeInTheDocument() | ||
| 58 | + }) | ||
| 59 | + | ||
| 60 | + it('clickTab_navigatesToTabPath', async () => { | ||
| 61 | + renderShell('/', true) | ||
| 62 | + await userEvent.click(screen.getByText('用户列表')) | ||
| 63 | + await waitFor(() => expect(screen.getByText('UserListPage')).toBeInTheDocument()) | ||
| 64 | + }) | ||
| 65 | + | ||
| 66 | + it('closeTab_removesTabAndNavigates', async () => { | ||
| 67 | + renderShell('/usr/users', true) | ||
| 68 | + await userEvent.click(screen.getByRole('button', { name: '关闭 用户列表' })) | ||
| 69 | + await waitFor(() => expect(screen.queryByText('用户列表')).not.toBeInTheDocument()) | ||
| 70 | + expect(screen.getByText('MainPage')).toBeInTheDocument() | ||
| 71 | + }) | ||
| 72 | + | ||
| 73 | + it('navToggle_showsNavOverlay', async () => { | ||
| 74 | + renderShell() | ||
| 75 | + await userEvent.click(screen.getByRole('button', { name: /全部导航/ })) | ||
| 76 | + expect(screen.getByTestId('nav-overlay')).toBeInTheDocument() | ||
| 77 | + }) | ||
| 78 | + | ||
| 79 | + it('navToggle_secondClick_hidesOverlay', async () => { | ||
| 80 | + renderShell() | ||
| 81 | + await userEvent.click(screen.getByRole('button', { name: /全部导航/ })) | ||
| 82 | + await userEvent.click(screen.getByRole('button', { name: /全部导航/ })) | ||
| 83 | + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument() | ||
| 84 | + }) | ||
| 85 | +}) |