Commit ffe35f1dee8877899de5facf7c9a67885ed3c982

Authored by zichun
1 parent db875405

feat(fe-shell): 应用外壳装配与标签路由同步 REQ-USR-003

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