mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
chore: split web route bundles (#1410)
This commit is contained in:
161
apps/dsa-web/src/App.test.tsx
Normal file
161
apps/dsa-web/src/App.test.tsx
Normal file
@@ -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<typeof AuthContext.useAuth>;
|
||||
|
||||
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: () => <div data-testid="home-page">Home</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./pages/ChatPage', () => ({
|
||||
default: () => {
|
||||
if (chatPageShouldThrow.value) {
|
||||
throw new Error('chunk load failed');
|
||||
}
|
||||
return <div data-testid="chat-page">Chat</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./pages/PortfolioPage', () => ({
|
||||
default: () => <div data-testid="portfolio-page">Portfolio</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./pages/BacktestPage', () => ({
|
||||
default: () => <div data-testid="backtest-page">Backtest</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./pages/AlertsPage', () => ({
|
||||
default: () => <div data-testid="alerts-page">Alerts</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./pages/SettingsPage', () => ({
|
||||
default: () => <div data-testid="settings-page">Settings</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./pages/NotFoundPage', () => ({
|
||||
default: () => <div data-testid="not-found-page">Not Found</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./pages/LoginPage', () => ({
|
||||
default: () => <div data-testid="login-page">Login</div>,
|
||||
}));
|
||||
|
||||
function makeAuthState(overrides: Partial<AuthState> = {}): 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(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-base">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-cyan/20 border-t-cyan" />
|
||||
</div>
|
||||
);
|
||||
return <PageLoadingFallback />;
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
@@ -49,7 +51,11 @@ const AppContent: React.FC = () => {
|
||||
|
||||
if (authEnabled && !loggedIn) {
|
||||
if (location.pathname === '/login') {
|
||||
return <LoginPage />;
|
||||
return (
|
||||
<StandaloneRouteBoundary>
|
||||
<LoginPage />
|
||||
</StandaloneRouteBoundary>
|
||||
);
|
||||
}
|
||||
const redirect = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={`/login?redirect=${redirect}`} replace />;
|
||||
@@ -61,7 +67,13 @@ const AppContent: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Shell />}>
|
||||
<Route
|
||||
element={(
|
||||
<Shell>
|
||||
<RouteOutletBoundary />
|
||||
</Shell>
|
||||
)}
|
||||
>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/portfolio" element={<PortfolioPage />} />
|
||||
@@ -70,7 +82,6 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
115
apps/dsa-web/src/components/layout/RouteBoundary.tsx
Normal file
115
apps/dsa-web/src/components/layout/RouteBoundary.tsx
Normal file
@@ -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<PageLoadingFallbackProps> = ({ fullPage = true }) => (
|
||||
<div
|
||||
className={
|
||||
fullPage
|
||||
? 'flex min-h-screen items-center justify-center bg-base'
|
||||
: 'flex min-h-[60vh] items-center justify-center'
|
||||
}
|
||||
>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-cyan/20 border-t-cyan" />
|
||||
</div>
|
||||
);
|
||||
|
||||
type RouteErrorBoundaryProps = {
|
||||
children: React.ReactNode;
|
||||
resetKey: string;
|
||||
fullPage: boolean;
|
||||
};
|
||||
|
||||
type RouteErrorBoundaryState = {
|
||||
hasError: boolean;
|
||||
};
|
||||
|
||||
class RouteErrorBoundary extends Component<RouteErrorBoundaryProps, RouteErrorBoundaryState> {
|
||||
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 (
|
||||
<div
|
||||
className={
|
||||
this.props.fullPage
|
||||
? 'flex min-h-screen items-center justify-center bg-base px-4'
|
||||
: 'flex min-h-[60vh] items-center justify-center px-2 py-8'
|
||||
}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card/94 p-6 text-center shadow-soft-card">
|
||||
<h1 className="text-xl font-semibold text-foreground">页面加载失败</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-secondary-text">
|
||||
当前页面资源或组件未能正常加载,可能是网络中断或页面版本已更新。请重新加载页面,或返回首页后再试。
|
||||
</p>
|
||||
<div className="mt-5 flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重新加载页面
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-border/70 bg-card px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-hover"
|
||||
onClick={() => window.location.assign('/')}
|
||||
>
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const RouteBoundary: React.FC<{ children: React.ReactNode; fullPage?: boolean }> = ({
|
||||
children,
|
||||
fullPage = true,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const resetKey = `${location.pathname}${location.search}`;
|
||||
|
||||
return (
|
||||
<RouteErrorBoundary resetKey={resetKey} fullPage={fullPage}>
|
||||
<Suspense fallback={<PageLoadingFallback fullPage={fullPage} />}>{children}</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export const RouteOutletBoundary: React.FC = () => (
|
||||
<RouteBoundary fullPage={false}>
|
||||
<Outlet />
|
||||
</RouteBoundary>
|
||||
);
|
||||
|
||||
export const StandaloneRouteBoundary: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<RouteBoundary fullPage>
|
||||
{children}
|
||||
</RouteBoundary>
|
||||
);
|
||||
@@ -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(
|
||||
<MemoryRouter initialEntries={['/chat']}>
|
||||
<Routes>
|
||||
<Route
|
||||
element={(
|
||||
<Shell>
|
||||
<RouteOutletBoundary />
|
||||
</Shell>
|
||||
)}
|
||||
>
|
||||
<Route path="/chat" element={<BrokenLazyRoute />} />
|
||||
<Route path="/portfolio" element={<div data-testid="portfolio-page">Portfolio</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||
- [改进] Web 路由页面改为按需加载,降低首包体积并增加路由加载失败恢复提示。
|
||||
|
||||
## [3.18.0] - 2026-05-21
|
||||
|
||||
|
||||
Reference in New Issue
Block a user