Commit 2a548826b0f6648e27d8eea6cd5e9cca8d2bf43d

Authored by zichun
1 parent ae88da4c

feat(usr): add AppShell component with tab navigation

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 +})