Commit a787bf2680c2b3f48a4470e9e1891c7e5379a0be
1 parent
af6c72ec
feat(fe-shell): 外壳注册被动401统一登出 REQ-USR-004
Showing
2 changed files
with
80 additions
and
1 deletions
frontend/src/layouts/AppLayout/AppLayout.tsx
| @@ -5,11 +5,12 @@ import { Outlet, useLocation, useNavigate, matchPath } from 'react-router-dom'; | @@ -5,11 +5,12 @@ import { Outlet, useLocation, useNavigate, matchPath } from 'react-router-dom'; | ||
| 5 | import { App as AntdApp } from 'antd'; | 5 | import { App as AntdApp } from 'antd'; |
| 6 | import { useAppDispatch, useAppSelector } from '../../store/hooks'; | 6 | import { useAppDispatch, useAppSelector } from '../../store/hooks'; |
| 7 | import { clearCredentials } from '../../store/slices/authSlice'; | 7 | import { clearCredentials } from '../../store/slices/authSlice'; |
| 8 | +import { registerUnauthorizedHandler } from '../../api/request'; | ||
| 8 | import TopBar from './TopBar'; | 9 | import TopBar from './TopBar'; |
| 9 | import NavOverlay from './NavOverlay'; | 10 | import NavOverlay from './NavOverlay'; |
| 10 | import { useTabStack, BIZ_TABS } from './useTabStack'; | 11 | import { useTabStack, BIZ_TABS } from './useTabStack'; |
| 11 | import type { BizTabKey } from './useTabStack'; | 12 | import type { BizTabKey } from './useTabStack'; |
| 12 | -import { LOGOUT_SUCCESS_TEXT, FEATURE_WIP_TEXT } from './shellMessages'; | 13 | +import { LOGOUT_SUCCESS_TEXT, FEATURE_WIP_TEXT, SESSION_EXPIRED_TEXT } from './shellMessages'; |
| 13 | import styles from './AppLayout.module.css'; | 14 | import styles from './AppLayout.module.css'; |
| 14 | 15 | ||
| 15 | /** 据当前路径推导应激活/应打开的业务标签 key(路由 → 标签反向同步) */ | 16 | /** 据当前路径推导应激活/应打开的业务标签 key(路由 → 标签反向同步) */ |
| @@ -80,6 +81,17 @@ export default function AppLayout() { | @@ -80,6 +81,17 @@ export default function AppLayout() { | ||
| 80 | navigate('/'); | 81 | navigate('/'); |
| 81 | }, [setActive, navigate]); | 82 | }, [setActive, navigate]); |
| 82 | 83 | ||
| 84 | + // 被动 401 统一登出(BR10 / D11):壳层挂载时注册回调,拦截器捕获 HTTP 401 时调用之。 | ||
| 85 | + // 拦截器内无法用 React hooks,故由此处注入 clearCredentials + message.warning + 跳 /login。 | ||
| 86 | + useEffect(() => { | ||
| 87 | + registerUnauthorizedHandler(() => { | ||
| 88 | + dispatch(clearCredentials()); | ||
| 89 | + message.warning(SESSION_EXPIRED_TEXT); | ||
| 90 | + navigate('/login', { replace: true }); | ||
| 91 | + }); | ||
| 92 | + return () => registerUnauthorizedHandler(null); | ||
| 93 | + }, [dispatch, message, navigate]); | ||
| 94 | + | ||
| 83 | const handleNavToggle = useCallback(() => { | 95 | const handleNavToggle = useCallback(() => { |
| 84 | setNavOverlayOpen((v) => !v); | 96 | setNavOverlayOpen((v) => !v); |
| 85 | }, []); | 97 | }, []); |
frontend/tests/unit/AppLayout.unauthorized.test.tsx
0 → 100644
| 1 | +// REQ-USR-004: AppLayout 注册 401 登出处理(壳层接线,BR10) | ||
| 2 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||
| 3 | +import { screen, act } from '@testing-library/react'; | ||
| 4 | +import { Routes, Route, useLocation } from 'react-router-dom'; | ||
| 5 | +import { renderShell } from './renderShell'; | ||
| 6 | +import AppLayout from '../../src/layouts/AppLayout/AppLayout'; | ||
| 7 | +import { | ||
| 8 | + registerUnauthorizedHandler, | ||
| 9 | + TOKEN_STORAGE_KEY, | ||
| 10 | +} from '../../src/api/request'; | ||
| 11 | +import { SESSION_EXPIRED_TEXT } from '../../src/layouts/AppLayout/shellMessages'; | ||
| 12 | +import type { AuthUser } from '../../src/api/types'; | ||
| 13 | + | ||
| 14 | +const ADMIN: AuthUser = { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }; | ||
| 15 | + | ||
| 16 | +function LocProbe() { | ||
| 17 | + const loc = useLocation(); | ||
| 18 | + return <div data-testid="loc">{loc.pathname}</div>; | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +describe('AppLayout 401 登出接线', () => { | ||
| 22 | + let captured: (() => void) | null = null; | ||
| 23 | + | ||
| 24 | + beforeEach(() => { | ||
| 25 | + captured = null; | ||
| 26 | + }); | ||
| 27 | + | ||
| 28 | + afterEach(() => { | ||
| 29 | + registerUnauthorizedHandler(null); | ||
| 30 | + vi.restoreAllMocks(); | ||
| 31 | + }); | ||
| 32 | + | ||
| 33 | + it('registers onUnauthorized on mount; invoking it clears auth + warns + navigates /login', async () => { | ||
| 34 | + localStorage.setItem(TOKEN_STORAGE_KEY, 't'); | ||
| 35 | + // 拦截真实注册:把回调存进 captured,同时透传给真实单例 | ||
| 36 | + const origRegister = registerUnauthorizedHandler; | ||
| 37 | + const restore = vi | ||
| 38 | + .spyOn(await import('../../src/api/request'), 'registerUnauthorizedHandler') | ||
| 39 | + .mockImplementation((fn) => { | ||
| 40 | + if (fn) captured = fn as () => void; | ||
| 41 | + origRegister(fn); | ||
| 42 | + }); | ||
| 43 | + | ||
| 44 | + const { getState } = renderShell( | ||
| 45 | + <Routes> | ||
| 46 | + <Route element={<AppLayout />}> | ||
| 47 | + <Route path="/" element={<LocProbe />} /> | ||
| 48 | + </Route> | ||
| 49 | + <Route path="/login" element={<LocProbe />} /> | ||
| 50 | + </Routes>, | ||
| 51 | + { initialEntries: ['/'], preloadedAuth: { token: 't', user: ADMIN } }, | ||
| 52 | + ); | ||
| 53 | + | ||
| 54 | + expect(captured).toBeTypeOf('function'); | ||
| 55 | + | ||
| 56 | + await act(async () => { | ||
| 57 | + captured!(); | ||
| 58 | + }); | ||
| 59 | + | ||
| 60 | + expect(getState().auth.token).toBeNull(); | ||
| 61 | + expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull(); | ||
| 62 | + expect(await screen.findByText(SESSION_EXPIRED_TEXT)).toBeInTheDocument(); | ||
| 63 | + expect(screen.getByTestId('loc').textContent).toBe('/login'); | ||
| 64 | + | ||
| 65 | + restore.mockRestore(); | ||
| 66 | + }); | ||
| 67 | +}); |