AppLayout.tsx
4.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
// 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 { 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 (
<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>
);
}