diff --git a/apps/dsa-web/src/App.test.tsx b/apps/dsa-web/src/App.test.tsx new file mode 100644 index 000000000..bd0cd2b9f --- /dev/null +++ b/apps/dsa-web/src/App.test.tsx @@ -0,0 +1,161 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import App from './App'; +import * as AuthContext from './contexts/AuthContext'; + +type AuthState = ReturnType; + +const { chatPageShouldThrow, setCurrentRoute, useAgentChatStoreMock } = vi.hoisted(() => { + const setCurrentRoute = vi.fn(); + const chatPageShouldThrow = { value: false }; + const state = { completionBadge: false }; + const useAgentChatStoreMock = Object.assign( + vi.fn((selector?: (value: typeof state) => unknown) => (selector ? selector(state) : state)), + { getState: () => ({ setCurrentRoute }) }, + ); + return { chatPageShouldThrow, setCurrentRoute, useAgentChatStoreMock }; +}); + +vi.mock('./contexts/AuthContext', () => ({ + AuthProvider: ({ children }: { children: ReactNode }) => children, + useAuth: vi.fn(), +})); + +vi.mock('./stores/agentChatStore', () => ({ + useAgentChatStore: useAgentChatStoreMock, +})); + +vi.mock('./pages/HomePage', () => ({ + default: () =>
Home
, +})); + +vi.mock('./pages/ChatPage', () => ({ + default: () => { + if (chatPageShouldThrow.value) { + throw new Error('chunk load failed'); + } + return
Chat
; + }, +})); + +vi.mock('./pages/PortfolioPage', () => ({ + default: () =>
Portfolio
, +})); + +vi.mock('./pages/BacktestPage', () => ({ + default: () =>
Backtest
, +})); + +vi.mock('./pages/AlertsPage', () => ({ + default: () =>
Alerts
, +})); + +vi.mock('./pages/SettingsPage', () => ({ + default: () =>
Settings
, +})); + +vi.mock('./pages/NotFoundPage', () => ({ + default: () =>
Not Found
, +})); + +vi.mock('./pages/LoginPage', () => ({ + default: () =>
Login
, +})); + +function makeAuthState(overrides: Partial = {}): AuthState { + return { + authEnabled: false, + loggedIn: false, + passwordSet: false, + passwordChangeable: false, + setupState: 'no_password', + isLoading: false, + loadError: null, + login: vi.fn().mockResolvedValue({ success: true }), + changePassword: vi.fn().mockResolvedValue({ success: true }), + logout: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + chatPageShouldThrow.value = false; + window.history.pushState({}, '', '/'); + vi.mocked(AuthContext.useAuth).mockReturnValue(makeAuthState()); +}); + +describe('App routing behavior', () => { + it('shows loading fallback while auth status is initializing', () => { + vi.mocked(AuthContext.useAuth).mockReturnValue(makeAuthState({ isLoading: true })); + + const { container } = render(); + + expect(container.querySelector('.border-t-cyan')).toBeInTheDocument(); + }); + + it('redirects protected routes to login when auth is enabled but user is not logged in', async () => { + vi.mocked(AuthContext.useAuth).mockReturnValue(makeAuthState({ + authEnabled: true, + loggedIn: false, + setupState: 'enabled', + })); + window.history.pushState({}, '', '/portfolio'); + + render(); + + expect(await screen.findByTestId('login-page')).toBeInTheDocument(); + expect(window.location.pathname).toBe('/login'); + expect(window.location.search).toBe('?redirect=%2Fportfolio'); + }); + + it('renders the current route page after auth is ready', async () => { + window.history.pushState({}, '', '/chat'); + + render(); + + expect(await screen.findByTestId('chat-page')).toBeInTheDocument(); + expect(setCurrentRoute).toHaveBeenCalledWith('/chat'); + expect(screen.queryByTestId('login-page')).not.toBeInTheDocument(); + expect(screen.queryByTestId('home-page')).not.toBeInTheDocument(); + }); + + it('redirects authenticated login visits back to the home page', async () => { + vi.mocked(AuthContext.useAuth).mockReturnValue(makeAuthState({ + authEnabled: true, + loggedIn: true, + setupState: 'enabled', + })); + window.history.pushState({}, '', '/login'); + + render(); + + expect(await screen.findByTestId('home-page')).toBeInTheDocument(); + expect(screen.queryByTestId('login-page')).not.toBeInTheDocument(); + }); + + it('keeps the shell mounted and resets the route boundary after page render errors', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + chatPageShouldThrow.value = true; + window.history.pushState({}, '', '/chat'); + + try { + render(); + + expect(await screen.findByRole('heading', { name: '页面加载失败' })).toBeInTheDocument(); + expect(screen.getByRole('navigation', { name: '主导航' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '重新加载页面' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '返回首页' })).toBeInTheDocument(); + + chatPageShouldThrow.value = false; + fireEvent.click(screen.getByRole('link', { name: '持仓' })); + + expect(await screen.findByTestId('portfolio-page')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '页面加载失败' })).not.toBeInTheDocument(); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/apps/dsa-web/src/App.tsx b/apps/dsa-web/src/App.tsx index 758b97c3e..48a3af24f 100644 --- a/apps/dsa-web/src/App.tsx +++ b/apps/dsa-web/src/App.tsx @@ -1,19 +1,25 @@ import type React from 'react'; -import { useEffect } from 'react'; +import { lazy, useEffect } from 'react'; import { BrowserRouter as Router, Navigate, Route, Routes, useLocation } from 'react-router-dom'; -import HomePage from './pages/HomePage'; -import BacktestPage from './pages/BacktestPage'; -import SettingsPage from './pages/SettingsPage'; -import LoginPage from './pages/LoginPage'; -import NotFoundPage from './pages/NotFoundPage'; -import ChatPage from './pages/ChatPage'; -import PortfolioPage from './pages/PortfolioPage'; -import AlertsPage from './pages/AlertsPage'; import { ApiErrorAlert, Shell } from './components/common'; +import { + PageLoadingFallback, + RouteOutletBoundary, + StandaloneRouteBoundary, +} from './components/layout/RouteBoundary'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import { useAgentChatStore } from './stores/agentChatStore'; import './App.css'; +const HomePage = lazy(() => import('./pages/HomePage')); +const BacktestPage = lazy(() => import('./pages/BacktestPage')); +const SettingsPage = lazy(() => import('./pages/SettingsPage')); +const LoginPage = lazy(() => import('./pages/LoginPage')); +const NotFoundPage = lazy(() => import('./pages/NotFoundPage')); +const ChatPage = lazy(() => import('./pages/ChatPage')); +const PortfolioPage = lazy(() => import('./pages/PortfolioPage')); +const AlertsPage = lazy(() => import('./pages/AlertsPage')); + const AppContent: React.FC = () => { const location = useLocation(); const { authEnabled, loggedIn, isLoading, loadError, refreshStatus } = useAuth(); @@ -23,11 +29,7 @@ const AppContent: React.FC = () => { }, [location.pathname]); if (isLoading) { - return ( -
-
-
- ); + return ; } if (loadError) { @@ -49,7 +51,11 @@ const AppContent: React.FC = () => { if (authEnabled && !loggedIn) { if (location.pathname === '/login') { - return ; + return ( + + + + ); } const redirect = encodeURIComponent(location.pathname + location.search); return ; @@ -61,7 +67,13 @@ const AppContent: React.FC = () => { return ( - }> + + + + )} + > } /> } /> } /> @@ -70,7 +82,6 @@ const AppContent: React.FC = () => { } /> } /> - } /> ); }; diff --git a/apps/dsa-web/src/components/layout/RouteBoundary.tsx b/apps/dsa-web/src/components/layout/RouteBoundary.tsx new file mode 100644 index 000000000..f09a4f8a5 --- /dev/null +++ b/apps/dsa-web/src/components/layout/RouteBoundary.tsx @@ -0,0 +1,115 @@ +import type React from 'react'; +import { Component, Suspense } from 'react'; +import type { ErrorInfo } from 'react'; +import { Outlet, useLocation } from 'react-router-dom'; + +type PageLoadingFallbackProps = { + fullPage?: boolean; +}; + +export const PageLoadingFallback: React.FC = ({ fullPage = true }) => ( +
+
+
+); + +type RouteErrorBoundaryProps = { + children: React.ReactNode; + resetKey: string; + fullPage: boolean; +}; + +type RouteErrorBoundaryState = { + hasError: boolean; +}; + +class RouteErrorBoundary extends Component { + override state: RouteErrorBoundaryState = { + hasError: false, + }; + + static getDerivedStateFromError(): RouteErrorBoundaryState { + return { hasError: true }; + } + + override componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Route page failed to render or load', error, errorInfo); + } + + override componentDidUpdate(prevProps: RouteErrorBoundaryProps) { + if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) { + this.setState({ hasError: false }); + } + } + + override render() { + if (!this.state.hasError) { + return this.props.children; + } + + return ( +
+
+

页面加载失败

+

+ 当前页面资源或组件未能正常加载,可能是网络中断或页面版本已更新。请重新加载页面,或返回首页后再试。 +

+
+ + +
+
+
+ ); + } +} + +export const RouteBoundary: React.FC<{ children: React.ReactNode; fullPage?: boolean }> = ({ + children, + fullPage = true, +}) => { + const location = useLocation(); + const resetKey = `${location.pathname}${location.search}`; + + return ( + + }>{children} + + ); +}; + +export const RouteOutletBoundary: React.FC = () => ( + + + +); + +export const StandaloneRouteBoundary: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +); diff --git a/apps/dsa-web/src/components/layout/__tests__/RouteBoundary.test.tsx b/apps/dsa-web/src/components/layout/__tests__/RouteBoundary.test.tsx new file mode 100644 index 000000000..41cfa6854 --- /dev/null +++ b/apps/dsa-web/src/components/layout/__tests__/RouteBoundary.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { lazy } from 'react'; +import type React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { RouteOutletBoundary } from '../RouteBoundary'; +import { Shell } from '../Shell'; + +vi.mock('../../../contexts/AuthContext', () => ({ + useAuth: () => ({ + authEnabled: false, + logout: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock('../../../stores/agentChatStore', () => { + const state = { completionBadge: false }; + + return { + useAgentChatStore: (selector?: (value: typeof state) => unknown) => ( + selector ? selector(state) : state + ), + }; +}); + +describe('RouteOutletBoundary', () => { + it('catches rejected lazy route imports inside the shell and resets on navigation', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const BrokenLazyRoute = lazy(() => ( + Promise.reject(new Error('chunk load failed')) as Promise<{ default: React.ComponentType }> + )); + + try { + render( + + + + + + )} + > + } /> + Portfolio
} /> + + + , + ); + + expect(screen.getByRole('navigation', { name: '主导航' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: '页面加载失败' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '重新加载页面' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '返回首页' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('link', { name: '持仓' })); + + expect(await screen.findByTestId('portfolio-page')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '页面加载失败' })).not.toBeInTheDocument(); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d10eee99d..f47933737 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). +- [改进] Web 路由页面改为按需加载,降低首包体积并增加路由加载失败恢复提示。 ## [3.18.0] - 2026-05-21