Commit a787bf2680c2b3f48a4470e9e1891c7e5379a0be

Authored by zichun
1 parent af6c72ec

feat(fe-shell): 外壳注册被动401统一登出 REQ-USR-004

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 +});