diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..6536b7d --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import { Outlet, useNavigate } from 'react-router-dom' +import { useAppSelector, useAppDispatch } from '../store/hooks' +import { closeTab, activateTab } from '../store/slices/tabsSlice' +import NavOverlay from './NavOverlay' + +const ANTLER_PATHS = [ + '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', + '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', + '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', +] + +export default function AppShell() { + const dispatch = useAppDispatch() + const navigate = useNavigate() + const { tabs, activeId } = useAppSelector(s => s.tabs) + const userInfo = useAppSelector(s => s.auth.userInfo) + const [navOpen, setNavOpen] = useState(false) + + function handleTabClick(id: string, path: string) { + dispatch(activateTab(id)) + navigate(path) + } + + function handleTabClose(e: React.MouseEvent, id: string) { + e.stopPropagation() + const idx = tabs.findIndex(t => t.id === id) + dispatch(closeTab(id)) + const remaining = tabs.filter(t => t.id !== id) + const newIdx = Math.min(idx, remaining.length - 1) + if (remaining[newIdx]) navigate(remaining[newIdx].path) + } + + return ( +
+ {/* Topbar */} +
+ {/* Logo */} +
+ + {ANTLER_PATHS.map((d, i) => )} + +
+ {/* Nav toggle */} + + {/* Tab list */} +
+ {tabs.map(tab => ( +
handleTabClick(tab.id, tab.path)} + style={{ + display: 'flex', alignItems: 'center', gap: 8, padding: '0 18px', cursor: 'pointer', fontSize: 14, height: '100%', + color: tab.id === activeId ? 'var(--color-tab-active)' : 'var(--color-tab-text)', + borderBottom: tab.id === activeId ? '2px solid var(--color-tab-active)' : 'none', + }} + > + {tab.title} + {tab.closable && ( + + )} +
+ ))} +
+ {/* User info */} +
+ {userInfo?.username}({userInfo?.userType}) +
+
+ {/* Stage */} +
+ {navOpen && setNavOpen(false)} />} +
+ +
+
+
+ ) +} diff --git a/frontend/src/test/AppShell.test.tsx b/frontend/src/test/AppShell.test.tsx new file mode 100644 index 0000000..4678a00 --- /dev/null +++ b/frontend/src/test/AppShell.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Provider } from 'react-redux' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { configureStore } from '@reduxjs/toolkit' +import authReducer from '../store/slices/authSlice' +import tabsReducer, { openTab } from '../store/slices/tabsSlice' +import AppShell from '../components/AppShell' + +vi.mock('../components/NavOverlay', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
NavOverlay
+ ), +})) + +function makeStore(extraTabs = false) { + const store = configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } }) + store.dispatch({ + type: 'auth/setCredentials', + payload: { accessToken: 'tok', refreshToken: 'ref', userInfo: { userId: 'u0', username: 'admin', userType: '超级管理员', language: '中文', brandId: 'b1' } }, + }) + if (extraTabs) { + store.dispatch(openTab({ id: 'userlist', title: '用户列表', path: '/usr/users', closable: true })) + } + return store +} + +function renderShell(path = '/', extraTabs = false) { + const store = makeStore(extraTabs) + return { + store, + ...render( + + + + }> + MainPage} /> + UserListPage} /> + + + + + ), + } +} + +describe('AppShell', () => { + it('renders_mainTabAndUserInfo', () => { + renderShell() + expect(screen.getByText('主页')).toBeInTheDocument() + expect(screen.getByText(/admin/)).toBeInTheDocument() + }) + + it('renders_openTabs', () => { + renderShell('/', true) + expect(screen.getByText('用户列表')).toBeInTheDocument() + }) + + it('clickTab_navigatesToTabPath', async () => { + renderShell('/', true) + await userEvent.click(screen.getByText('用户列表')) + await waitFor(() => expect(screen.getByText('UserListPage')).toBeInTheDocument()) + }) + + it('closeTab_removesTabAndNavigates', async () => { + renderShell('/usr/users', true) + await userEvent.click(screen.getByRole('button', { name: '关闭 用户列表' })) + await waitFor(() => expect(screen.queryByText('用户列表')).not.toBeInTheDocument()) + expect(screen.getByText('MainPage')).toBeInTheDocument() + }) + + it('navToggle_showsNavOverlay', async () => { + renderShell() + await userEvent.click(screen.getByRole('button', { name: /全部导航/ })) + expect(screen.getByTestId('nav-overlay')).toBeInTheDocument() + }) + + it('navToggle_secondClick_hidesOverlay', async () => { + renderShell() + await userEvent.click(screen.getByRole('button', { name: /全部导航/ })) + await userEvent.click(screen.getByRole('button', { name: /全部导航/ })) + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument() + }) +})