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