From a787bf2680c2b3f48a4470e9e1891c7e5379a0be Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 17:12:15 +0800 Subject: [PATCH] feat(fe-shell): 外壳注册被动401统一登出 REQ-USR-004 --- frontend/src/layouts/AppLayout/AppLayout.tsx | 14 +++++++++++++- frontend/tests/unit/AppLayout.unauthorized.test.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/unit/AppLayout.unauthorized.test.tsx diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index bcd1193..cc99ea9 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -5,11 +5,12 @@ 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 } from './shellMessages'; +import { LOGOUT_SUCCESS_TEXT, FEATURE_WIP_TEXT, SESSION_EXPIRED_TEXT } from './shellMessages'; import styles from './AppLayout.module.css'; /** 据当前路径推导应激活/应打开的业务标签 key(路由 → 标签反向同步) */ @@ -80,6 +81,17 @@ export default function AppLayout() { 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); }, []); diff --git a/frontend/tests/unit/AppLayout.unauthorized.test.tsx b/frontend/tests/unit/AppLayout.unauthorized.test.tsx new file mode 100644 index 0000000..9b7d457 --- /dev/null +++ b/frontend/tests/unit/AppLayout.unauthorized.test.tsx @@ -0,0 +1,67 @@ +// REQ-USR-004: AppLayout 注册 401 登出处理(壳层接线,BR10) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { screen, act } from '@testing-library/react'; +import { Routes, Route, useLocation } from 'react-router-dom'; +import { renderShell } from './renderShell'; +import AppLayout from '../../src/layouts/AppLayout/AppLayout'; +import { + registerUnauthorizedHandler, + TOKEN_STORAGE_KEY, +} from '../../src/api/request'; +import { SESSION_EXPIRED_TEXT } from '../../src/layouts/AppLayout/shellMessages'; +import type { AuthUser } from '../../src/api/types'; + +const ADMIN: AuthUser = { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }; + +function LocProbe() { + const loc = useLocation(); + return
{loc.pathname}
; +} + +describe('AppLayout 401 登出接线', () => { + let captured: (() => void) | null = null; + + beforeEach(() => { + captured = null; + }); + + afterEach(() => { + registerUnauthorizedHandler(null); + vi.restoreAllMocks(); + }); + + it('registers onUnauthorized on mount; invoking it clears auth + warns + navigates /login', async () => { + localStorage.setItem(TOKEN_STORAGE_KEY, 't'); + // 拦截真实注册:把回调存进 captured,同时透传给真实单例 + const origRegister = registerUnauthorizedHandler; + const restore = vi + .spyOn(await import('../../src/api/request'), 'registerUnauthorizedHandler') + .mockImplementation((fn) => { + if (fn) captured = fn as () => void; + origRegister(fn); + }); + + const { getState } = renderShell( + + }> + } /> + + } /> + , + { initialEntries: ['/'], preloadedAuth: { token: 't', user: ADMIN } }, + ); + + expect(captured).toBeTypeOf('function'); + + await act(async () => { + captured!(); + }); + + expect(getState().auth.token).toBeNull(); + expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull(); + expect(await screen.findByText(SESSION_EXPIRED_TEXT)).toBeInTheDocument(); + expect(screen.getByTestId('loc').textContent).toBe('/login'); + + restore.mockRestore(); + }); +}); -- libgit2 0.22.2