// 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 { registerUnauthorizedHandler } from '../../api/request';
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, SESSION_EXPIRED_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]);
// 被动 401 统一登出(BR10 / D11):壳层挂载时注册回调,拦截器捕获 HTTP 401 时调用之。
// 拦截器内无法用 React hooks,故由此处注入 clearCredentials + message.warning + 跳 /login。
useEffect(() => {
registerUnauthorizedHandler(() => {
dispatch(clearCredentials());
message.warning(SESSION_EXPIRED_TEXT);
navigate('/login', { replace: true });
});
return () => registerUnauthorizedHandler(null);
}, [dispatch, message, 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}
/>
);
}