Commit ffe35f1dee8877899de5facf7c9a67885ed3c982
1 parent
db875405
feat(fe-shell): 应用外壳装配与标签路由同步 REQ-USR-003
Showing
2 changed files
with
226 additions
and
0 deletions
frontend/src/layouts/AppLayout/AppLayout.tsx
0 → 100644
| 1 | +// REQ-USR-003 / REQ-USR-004: 应用外壳(TopBar + NavOverlay + <Outlet/> + AppFooter)。 | |
| 2 | +// 持有标签栈 / overlay 开关本地态(D3);据路由反向同步激活标签;登录态复用 Redux authSlice。 | |
| 3 | +import { useCallback, useEffect, useMemo, useState } from 'react'; | |
| 4 | +import { Outlet, useLocation, useNavigate, matchPath } from 'react-router-dom'; | |
| 5 | +import { App as AntdApp } from 'antd'; | |
| 6 | +import { useAppDispatch, useAppSelector } from '../../store/hooks'; | |
| 7 | +import { clearCredentials } from '../../store/slices/authSlice'; | |
| 8 | +import TopBar from './TopBar'; | |
| 9 | +import NavOverlay from './NavOverlay'; | |
| 10 | +import { useTabStack, BIZ_TABS } from './useTabStack'; | |
| 11 | +import type { BizTabKey } from './useTabStack'; | |
| 12 | +import { LOGOUT_SUCCESS_TEXT, FEATURE_WIP_TEXT } from './shellMessages'; | |
| 13 | +import styles from './AppLayout.module.css'; | |
| 14 | + | |
| 15 | +/** 据当前路径推导应激活/应打开的业务标签 key(路由 → 标签反向同步) */ | |
| 16 | +function deriveBizTab(pathname: string): BizTabKey | null { | |
| 17 | + if (matchPath('/usr/users/new', pathname) || matchPath('/usr/users/:id', pathname)) { | |
| 18 | + return 'userdetail'; | |
| 19 | + } | |
| 20 | + if (matchPath('/usr/users', pathname)) { | |
| 21 | + return 'userlist'; | |
| 22 | + } | |
| 23 | + return null; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export default function AppLayout() { | |
| 27 | + const user = useAppSelector((s) => s.auth.user); | |
| 28 | + const dispatch = useAppDispatch(); | |
| 29 | + const navigate = useNavigate(); | |
| 30 | + const location = useLocation(); | |
| 31 | + const { message } = AntdApp.useApp(); | |
| 32 | + | |
| 33 | + const { tabs, activeKey, openTab, closeTab, setActive } = useTabStack(); | |
| 34 | + const [navOverlayOpen, setNavOverlayOpen] = useState(false); | |
| 35 | + | |
| 36 | + // 路由 → 标签反向同步:进入业务路由时确保对应标签打开并激活;回主页激活 home | |
| 37 | + useEffect(() => { | |
| 38 | + const biz = deriveBizTab(location.pathname); | |
| 39 | + if (biz) { | |
| 40 | + openTab(biz); | |
| 41 | + } else if (matchPath('/', location.pathname)) { | |
| 42 | + setActive('home'); | |
| 43 | + } | |
| 44 | + // openTab/setActive 为稳定回调;仅在路径变化时同步 | |
| 45 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 46 | + }, [location.pathname]); | |
| 47 | + | |
| 48 | + const handleSelectTab = useCallback( | |
| 49 | + (key: string) => { | |
| 50 | + const tab = tabs.find((t) => t.key === key); | |
| 51 | + if (tab) { | |
| 52 | + setActive(key); | |
| 53 | + navigate(tab.routePath); | |
| 54 | + } | |
| 55 | + }, | |
| 56 | + [tabs, setActive, navigate], | |
| 57 | + ); | |
| 58 | + | |
| 59 | + const handleCloseTab = useCallback( | |
| 60 | + (key: string) => { | |
| 61 | + closeTab(key); | |
| 62 | + // 关闭联动后跳转到对应路由(BR5/BR6) | |
| 63 | + if (key === 'userlist') { | |
| 64 | + navigate('/'); | |
| 65 | + } else if (key === 'userdetail') { | |
| 66 | + navigate(BIZ_TABS.userlist.routePath); | |
| 67 | + } | |
| 68 | + }, | |
| 69 | + [closeTab, navigate], | |
| 70 | + ); | |
| 71 | + | |
| 72 | + const handleLogout = useCallback(() => { | |
| 73 | + dispatch(clearCredentials()); | |
| 74 | + message.success(LOGOUT_SUCCESS_TEXT); | |
| 75 | + navigate('/login', { replace: true }); | |
| 76 | + }, [dispatch, message, navigate]); | |
| 77 | + | |
| 78 | + const handleLogoHome = useCallback(() => { | |
| 79 | + setActive('home'); | |
| 80 | + navigate('/'); | |
| 81 | + }, [setActive, navigate]); | |
| 82 | + | |
| 83 | + const handleNavToggle = useCallback(() => { | |
| 84 | + setNavOverlayOpen((v) => !v); | |
| 85 | + }, []); | |
| 86 | + | |
| 87 | + const handleOverlayNavigate = useCallback( | |
| 88 | + (routePath: string) => { | |
| 89 | + setNavOverlayOpen(false); | |
| 90 | + if (routePath === BIZ_TABS.userlist.routePath) { | |
| 91 | + openTab('userlist'); | |
| 92 | + } | |
| 93 | + navigate(routePath); | |
| 94 | + }, | |
| 95 | + [openTab, navigate], | |
| 96 | + ); | |
| 97 | + | |
| 98 | + const handleOverlayPlaceholder = useCallback(() => { | |
| 99 | + setNavOverlayOpen(false); | |
| 100 | + message.info(FEATURE_WIP_TEXT); | |
| 101 | + }, [message]); | |
| 102 | + | |
| 103 | + const stableTabs = useMemo(() => tabs, [tabs]); | |
| 104 | + | |
| 105 | + return ( | |
| 106 | + <div className={styles.app}> | |
| 107 | + <TopBar | |
| 108 | + user={user} | |
| 109 | + tabs={stableTabs} | |
| 110 | + activeKey={activeKey} | |
| 111 | + navOverlayOpen={navOverlayOpen} | |
| 112 | + onToggleNav={handleNavToggle} | |
| 113 | + onSelectTab={handleSelectTab} | |
| 114 | + onCloseTab={handleCloseTab} | |
| 115 | + onLogout={handleLogout} | |
| 116 | + onLogoHome={handleLogoHome} | |
| 117 | + /> | |
| 118 | + <div className={styles.stage}> | |
| 119 | + <NavOverlay | |
| 120 | + open={navOverlayOpen} | |
| 121 | + onClose={() => setNavOverlayOpen(false)} | |
| 122 | + onNavigate={handleOverlayNavigate} | |
| 123 | + onPlaceholder={handleOverlayPlaceholder} | |
| 124 | + /> | |
| 125 | + <Outlet /> | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | + ); | |
| 129 | +} | ... | ... |
frontend/tests/unit/AppLayout.shell.test.tsx
0 → 100644
| 1 | +// REQ-USR-003: AppLayout 外壳装配 + 标签↔路由同步(ready / navOverlayOpen / tabOpen 态) | |
| 2 | +import { describe, it, expect } from 'vitest'; | |
| 3 | +import { screen } from '@testing-library/react'; | |
| 4 | +import userEvent from '@testing-library/user-event'; | |
| 5 | +import { Routes, Route, useLocation } from 'react-router-dom'; | |
| 6 | +import { renderShell } from './renderShell'; | |
| 7 | +import AppLayout from '../../src/layouts/AppLayout/AppLayout'; | |
| 8 | +import type { AuthUser } from '../../src/api/types'; | |
| 9 | + | |
| 10 | +const ADMIN: AuthUser = { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }; | |
| 11 | + | |
| 12 | +function LocProbe() { | |
| 13 | + const loc = useLocation(); | |
| 14 | + return <div data-testid="loc">{loc.pathname}</div>; | |
| 15 | +} | |
| 16 | + | |
| 17 | +function renderLayout(initialEntries: string[]) { | |
| 18 | + return renderShell( | |
| 19 | + <Routes> | |
| 20 | + <Route element={<AppLayout />}> | |
| 21 | + <Route | |
| 22 | + path="/" | |
| 23 | + element={ | |
| 24 | + <> | |
| 25 | + <LocProbe /> | |
| 26 | + <div data-testid="home-outlet">home-outlet</div> | |
| 27 | + </> | |
| 28 | + } | |
| 29 | + /> | |
| 30 | + <Route | |
| 31 | + path="/usr/users" | |
| 32 | + element={ | |
| 33 | + <> | |
| 34 | + <LocProbe /> | |
| 35 | + <div data-testid="users-outlet">users-outlet</div> | |
| 36 | + </> | |
| 37 | + } | |
| 38 | + /> | |
| 39 | + </Route> | |
| 40 | + </Routes>, | |
| 41 | + { initialEntries, preloadedAuth: { token: 't', user: ADMIN } }, | |
| 42 | + ); | |
| 43 | +} | |
| 44 | + | |
| 45 | +describe('AppLayout shell', () => { | |
| 46 | + it('renders TopBar + Outlet when ready', () => { | |
| 47 | + renderLayout(['/']); | |
| 48 | + // 顶栏(全部导航 + 当前用户) | |
| 49 | + expect(screen.getByRole('button', { name: '全部导航' })).toBeInTheDocument(); | |
| 50 | + expect(screen.getByText('朱子纯(超级管理员)')).toBeInTheDocument(); | |
| 51 | + // Outlet 子内容 | |
| 52 | + expect(screen.getByTestId('home-outlet')).toBeInTheDocument(); | |
| 53 | + }); | |
| 54 | + | |
| 55 | + it('toggle 全部导航 opens/closes overlay', async () => { | |
| 56 | + renderLayout(['/']); | |
| 57 | + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument(); | |
| 58 | + await userEvent.click(screen.getByRole('button', { name: '全部导航' })); | |
| 59 | + expect(screen.getByTestId('nav-overlay')).toBeInTheDocument(); | |
| 60 | + // 点遮罩关闭 | |
| 61 | + await userEvent.click(screen.getByTestId('nav-overlay-mask')); | |
| 62 | + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument(); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it('nav overlay 用户列表 navigates and opens tab', async () => { | |
| 66 | + renderLayout(['/']); | |
| 67 | + await userEvent.click(screen.getByRole('button', { name: '全部导航' })); | |
| 68 | + await userEvent.click(screen.getByRole('button', { name: /用户列表/ })); | |
| 69 | + // overlay 关闭 | |
| 70 | + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument(); | |
| 71 | + // URL 到 /usr/users | |
| 72 | + expect(screen.getByTestId('loc').textContent).toBe('/usr/users'); | |
| 73 | + // 顶栏出现「用户列表」标签并激活 | |
| 74 | + const tab = screen.getByTestId('tab-userlist'); | |
| 75 | + expect(tab).toHaveTextContent('用户列表'); | |
| 76 | + expect(tab.getAttribute('aria-pressed')).toBe('true'); | |
| 77 | + }); | |
| 78 | + | |
| 79 | + it('clicking home tab navigates back to /', async () => { | |
| 80 | + renderLayout(['/']); | |
| 81 | + // 先打开用户列表标签 | |
| 82 | + await userEvent.click(screen.getByRole('button', { name: '全部导航' })); | |
| 83 | + await userEvent.click(screen.getByRole('button', { name: /用户列表/ })); | |
| 84 | + expect(screen.getByTestId('loc').textContent).toBe('/usr/users'); | |
| 85 | + // 点主页标签回 / | |
| 86 | + await userEvent.click(screen.getByTestId('tab-home')); | |
| 87 | + expect(screen.getByTestId('loc').textContent).toBe('/'); | |
| 88 | + expect(screen.getByTestId('tab-home').getAttribute('aria-pressed')).toBe('true'); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it('active tab syncs with current route', () => { | |
| 92 | + renderLayout(['/usr/users']); | |
| 93 | + // 直接进 /usr/users,用户列表标签应存在且激活 | |
| 94 | + const tab = screen.getByTestId('tab-userlist'); | |
| 95 | + expect(tab.getAttribute('aria-pressed')).toBe('true'); | |
| 96 | + }); | |
| 97 | +}); | ... | ... |