AppLayout.tsx 4.03 KB
// REQ-USR-003 / REQ-USR-004: 应用外壳(TopBar + NavOverlay + <Outlet/> + 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 (
    <div className={styles.app}>
      <TopBar
        user={user}
        tabs={stableTabs}
        activeKey={activeKey}
        navOverlayOpen={navOverlayOpen}
        onToggleNav={handleNavToggle}
        onSelectTab={handleSelectTab}
        onCloseTab={handleCloseTab}
        onLogout={handleLogout}
        onLogoHome={handleLogoHome}
      />
      <div className={styles.stage}>
        <NavOverlay
          open={navOverlayOpen}
          onClose={() => setNavOverlayOpen(false)}
          onNavigate={handleOverlayNavigate}
          onPlaceholder={handleOverlayPlaceholder}
        />
        <Outlet />
      </div>
    </div>
  );
}