From ce9bde35eb5829458ecefbf534533113a7290a21 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 10 Aug 2026 09:42:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E4=BF=AE=E5=A4=8D=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E8=BF=9B=E5=85=A5=E7=B3=BB=E7=BB=9F=E5=90=8E=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E4=BB=BB=E6=84=8F=E5=AF=BC=E8=88=AA=E8=A2=AB=E6=8B=89?= =?UTF-8?q?=E5=9B=9E=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:RouteKeeper 保活缓存了首页 index 节点,DefaultRoute 中的声明式 会随缓存节点在后续路由变化时反复触发 replace,导致所有导航 被强制拉回落地页(dashboard),刷新后因不再经过 / 而恢复正常。 修复:DefaultRoute 改为 effect 导航,仅在确实处于 / 且已算出落地页时 跳转一次;navigate 通过 ref 持有避免 effect 空转。新增集成测试覆盖 「首次进入 / → 跳转一次 → 后续导航不被劫持 → 回到 / 仍能重定向」。 --- .../DefaultRoute.integration.test.tsx | 105 ++++++++++++++++++ apps/admin/src/components/DefaultRoute.tsx | 31 ++++-- 2 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 apps/admin/src/components/DefaultRoute.integration.test.tsx diff --git a/apps/admin/src/components/DefaultRoute.integration.test.tsx b/apps/admin/src/components/DefaultRoute.integration.test.tsx new file mode 100644 index 00000000..a1942a79 --- /dev/null +++ b/apps/admin/src/components/DefaultRoute.integration.test.tsx @@ -0,0 +1,105 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import DefaultRoute from './DefaultRoute'; +import { RouteKeeper } from './RouteKeeper'; +import { usePermissionStore } from '../store/permission/permissionStore'; +import { useUserStore } from '../store/user/userStore'; + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +beforeAll(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +beforeEach(() => { + // 清掉可能由其他测试文件遗留的持久化数据,保证会话状态可控 + Object.keys(localStorage).forEach((key) => localStorage.removeItem(key)); + useUserStore.setState({ token: null, user: null }); + usePermissionStore.setState({ permissions: [], status: 'unknown' }); + useUserStore.getState().setSession('test-token', { + id: 1, + username: 'admin', + roles: ['超级管理员'], + }); + usePermissionStore.getState().writePermissions(['dashboard:view', 'student:view']); +}); + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +function PageDashboard() { + const navigate = useNavigate(); + return ( +
+
数据面板
+ +
+ ); +} + +function PageStudents() { + const navigate = useNavigate(); + return ( +
+
学生管理
+ +
+ ); +} + +function Harness() { + return ( + + + }> + } /> + } /> + } /> + + + + ); +} + +async function renderHarness() { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render(); + }); +} + +describe('DefaultRoute keep-alive regression', () => { + it('redirects to landing page once and does not hijack later navigations', async () => { + await renderHarness(); + + // 首次进入 '/' 应跳转到落地页 /dashboard,且只跳一次 + expect(document.querySelector('[data-testid="page-dashboard"]')).not.toBeNull(); + + // 再导航到 /students,不应被保活的首页节点拉回 /dashboard + await act(async () => { + (document.querySelector('[data-testid="go-students"]') as HTMLButtonElement).click(); + }); + expect(document.querySelector('[data-testid="page-students"]')).not.toBeNull(); + + // 回到 '/' 时仍应再次跳转到落地页 + await act(async () => { + (document.querySelector('[data-testid="go-home"]') as HTMLButtonElement).click(); + }); + expect(document.querySelector('[data-testid="page-dashboard"]')).not.toBeNull(); + }); +}); diff --git a/apps/admin/src/components/DefaultRoute.tsx b/apps/admin/src/components/DefaultRoute.tsx index af76a1bf..04cbf22f 100644 --- a/apps/admin/src/components/DefaultRoute.tsx +++ b/apps/admin/src/components/DefaultRoute.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Navigate } from 'react-router'; +import React, { useEffect, useRef } from 'react'; +import { useLocation, useNavigate } from 'react-router'; import { Result, Spin } from 'antd'; import { usePermission } from '../hooks/usePermission'; import { findRoleAwareLandingPath } from '../auth/menu-policy'; @@ -8,14 +8,31 @@ import { useUserStore } from '../store/user/userStore'; const DefaultRoute: React.FC = () => { const { permissions, permissionsReady } = usePermission(); const roles = useUserStore((state) => state.user?.roles ?? []); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const firstPath = permissionsReady ? findRoleAwareLandingPath(roles, permissions) : null; + + // 用 effect 导航替代声明式 :RouteKeeper 会把首页(index)节点保活缓存, + // 声明式 在缓存节点随路由变化重渲染时会反复触发,导致首次进入系统后 + // 点击任何按钮都被拉回 dashboard,必须刷新页面才能恢复。这里仅在确实处于首页 + // 且已计算出落点时跳转;navigate 通过 ref 持有,避免其每次渲染变化导致 effect 空转。 + const navigateRef = useRef(navigate); + navigateRef.current = navigate; + useEffect(() => { + if (pathname === '/' && firstPath) { + navigateRef.current(firstPath, { replace: true }); + } + }, [pathname, firstPath]); + if (!permissionsReady) { return ; } - const firstPath = findRoleAwareLandingPath(roles, permissions); - if (firstPath) return ; - return ( - - ); + if (!firstPath) { + return ( + + ); + } + return null; }; export default DefaultRoute;