From ffe35f1dee8877899de5facf7c9a67885ed3c982 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 17:08:48 +0800 Subject: [PATCH] feat(fe-shell): 应用外壳装配与标签路由同步 REQ-USR-003 --- frontend/src/layouts/AppLayout/AppLayout.tsx | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/tests/unit/AppLayout.shell.test.tsx | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 0 deletions(-) create mode 100644 frontend/src/layouts/AppLayout/AppLayout.tsx create mode 100644 frontend/tests/unit/AppLayout.shell.test.tsx diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx new file mode 100644 index 0000000..bcd1193 --- /dev/null +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -0,0 +1,129 @@ +// REQ-USR-003 / REQ-USR-004: 应用外壳(TopBar + NavOverlay + + AppFooter)。 +// 持有标签栈 / overlay 开关本地态(D3);据路由反向同步激活标签;登录态复用 Redux authSlice。 +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Outlet, useLocation, useNavigate, matchPath } from 'react-router-dom'; +import { App as AntdApp } from 'antd'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { clearCredentials } from '../../store/slices/authSlice'; +import TopBar from './TopBar'; +import NavOverlay from './NavOverlay'; +import { useTabStack, BIZ_TABS } from './useTabStack'; +import type { BizTabKey } from './useTabStack'; +import { LOGOUT_SUCCESS_TEXT, FEATURE_WIP_TEXT } from './shellMessages'; +import styles from './AppLayout.module.css'; + +/** 据当前路径推导应激活/应打开的业务标签 key(路由 → 标签反向同步) */ +function deriveBizTab(pathname: string): BizTabKey | null { + if (matchPath('/usr/users/new', pathname) || matchPath('/usr/users/:id', pathname)) { + return 'userdetail'; + } + if (matchPath('/usr/users', pathname)) { + return 'userlist'; + } + return null; +} + +export default function AppLayout() { + const user = useAppSelector((s) => s.auth.user); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + const { message } = AntdApp.useApp(); + + const { tabs, activeKey, openTab, closeTab, setActive } = useTabStack(); + const [navOverlayOpen, setNavOverlayOpen] = useState(false); + + // 路由 → 标签反向同步:进入业务路由时确保对应标签打开并激活;回主页激活 home + useEffect(() => { + const biz = deriveBizTab(location.pathname); + if (biz) { + openTab(biz); + } else if (matchPath('/', location.pathname)) { + setActive('home'); + } + // openTab/setActive 为稳定回调;仅在路径变化时同步 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.pathname]); + + const handleSelectTab = useCallback( + (key: string) => { + const tab = tabs.find((t) => t.key === key); + if (tab) { + setActive(key); + navigate(tab.routePath); + } + }, + [tabs, setActive, navigate], + ); + + const handleCloseTab = useCallback( + (key: string) => { + closeTab(key); + // 关闭联动后跳转到对应路由(BR5/BR6) + if (key === 'userlist') { + navigate('/'); + } else if (key === 'userdetail') { + navigate(BIZ_TABS.userlist.routePath); + } + }, + [closeTab, navigate], + ); + + const handleLogout = useCallback(() => { + dispatch(clearCredentials()); + message.success(LOGOUT_SUCCESS_TEXT); + navigate('/login', { replace: true }); + }, [dispatch, message, navigate]); + + const handleLogoHome = useCallback(() => { + setActive('home'); + navigate('/'); + }, [setActive, navigate]); + + const handleNavToggle = useCallback(() => { + setNavOverlayOpen((v) => !v); + }, []); + + const handleOverlayNavigate = useCallback( + (routePath: string) => { + setNavOverlayOpen(false); + if (routePath === BIZ_TABS.userlist.routePath) { + openTab('userlist'); + } + navigate(routePath); + }, + [openTab, navigate], + ); + + const handleOverlayPlaceholder = useCallback(() => { + setNavOverlayOpen(false); + message.info(FEATURE_WIP_TEXT); + }, [message]); + + const stableTabs = useMemo(() => tabs, [tabs]); + + return ( +
+ +
+ setNavOverlayOpen(false)} + onNavigate={handleOverlayNavigate} + onPlaceholder={handleOverlayPlaceholder} + /> + +
+
+ ); +} diff --git a/frontend/tests/unit/AppLayout.shell.test.tsx b/frontend/tests/unit/AppLayout.shell.test.tsx new file mode 100644 index 0000000..2ff61a8 --- /dev/null +++ b/frontend/tests/unit/AppLayout.shell.test.tsx @@ -0,0 +1,97 @@ +// REQ-USR-003: AppLayout 外壳装配 + 标签↔路由同步(ready / navOverlayOpen / tabOpen 态) +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Routes, Route, useLocation } from 'react-router-dom'; +import { renderShell } from './renderShell'; +import AppLayout from '../../src/layouts/AppLayout/AppLayout'; +import type { AuthUser } from '../../src/api/types'; + +const ADMIN: AuthUser = { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }; + +function LocProbe() { + const loc = useLocation(); + return
{loc.pathname}
; +} + +function renderLayout(initialEntries: string[]) { + return renderShell( + + }> + + +
home-outlet
+ + } + /> + + +
users-outlet
+ + } + /> +
+
, + { initialEntries, preloadedAuth: { token: 't', user: ADMIN } }, + ); +} + +describe('AppLayout shell', () => { + it('renders TopBar + Outlet when ready', () => { + renderLayout(['/']); + // 顶栏(全部导航 + 当前用户) + expect(screen.getByRole('button', { name: '全部导航' })).toBeInTheDocument(); + expect(screen.getByText('朱子纯(超级管理员)')).toBeInTheDocument(); + // Outlet 子内容 + expect(screen.getByTestId('home-outlet')).toBeInTheDocument(); + }); + + it('toggle 全部导航 opens/closes overlay', async () => { + renderLayout(['/']); + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: '全部导航' })); + expect(screen.getByTestId('nav-overlay')).toBeInTheDocument(); + // 点遮罩关闭 + await userEvent.click(screen.getByTestId('nav-overlay-mask')); + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument(); + }); + + it('nav overlay 用户列表 navigates and opens tab', async () => { + renderLayout(['/']); + await userEvent.click(screen.getByRole('button', { name: '全部导航' })); + await userEvent.click(screen.getByRole('button', { name: /用户列表/ })); + // overlay 关闭 + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument(); + // URL 到 /usr/users + expect(screen.getByTestId('loc').textContent).toBe('/usr/users'); + // 顶栏出现「用户列表」标签并激活 + const tab = screen.getByTestId('tab-userlist'); + expect(tab).toHaveTextContent('用户列表'); + expect(tab.getAttribute('aria-pressed')).toBe('true'); + }); + + it('clicking home tab navigates back to /', async () => { + renderLayout(['/']); + // 先打开用户列表标签 + await userEvent.click(screen.getByRole('button', { name: '全部导航' })); + await userEvent.click(screen.getByRole('button', { name: /用户列表/ })); + expect(screen.getByTestId('loc').textContent).toBe('/usr/users'); + // 点主页标签回 / + await userEvent.click(screen.getByTestId('tab-home')); + expect(screen.getByTestId('loc').textContent).toBe('/'); + expect(screen.getByTestId('tab-home').getAttribute('aria-pressed')).toBe('true'); + }); + + it('active tab syncs with current route', () => { + renderLayout(['/usr/users']); + // 直接进 /usr/users,用户列表标签应存在且激活 + const tab = screen.getByTestId('tab-userlist'); + expect(tab.getAttribute('aria-pressed')).toBe('true'); + }); +}); -- libgit2 0.22.2