Commit c897b60dba3d28b0e621918cee2134a371c0342b
1 parent
ffe35f1d
feat(fe-shell): 受保护嵌套路由表与错误边界 REQ-USR-003
Showing
4 changed files
with
167 additions
and
7 deletions
frontend/src/router/AppErrorBoundary.tsx
0 → 100644
| 1 | +// REQ-USR-003: 路由级 ErrorBoundary(子路由渲染抛错兜底 + 返回主页入口,spec § 3 error / D7)。 | ||
| 2 | +import { Component, type ErrorInfo, type ReactNode } from 'react'; | ||
| 3 | +import { Button, Result } from 'antd'; | ||
| 4 | + | ||
| 5 | +interface AppErrorBoundaryProps { | ||
| 6 | + children: ReactNode; | ||
| 7 | +} | ||
| 8 | + | ||
| 9 | +interface AppErrorBoundaryState { | ||
| 10 | + hasError: boolean; | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +export default class AppErrorBoundary extends Component< | ||
| 14 | + AppErrorBoundaryProps, | ||
| 15 | + AppErrorBoundaryState | ||
| 16 | +> { | ||
| 17 | + state: AppErrorBoundaryState = { hasError: false }; | ||
| 18 | + | ||
| 19 | + static getDerivedStateFromError(): AppErrorBoundaryState { | ||
| 20 | + return { hasError: true }; | ||
| 21 | + } | ||
| 22 | + | ||
| 23 | + componentDidCatch(_error: Error, _info: ErrorInfo): void { | ||
| 24 | + // 兜底:捕获即进入降级 UI;此处可接入日志上报(MVP 不上报)。 | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + private handleGoHome = () => { | ||
| 28 | + this.setState({ hasError: false }); | ||
| 29 | + // 直接回主页(不依赖 hooks,class 组件用 location) | ||
| 30 | + window.location.assign('/'); | ||
| 31 | + }; | ||
| 32 | + | ||
| 33 | + render() { | ||
| 34 | + if (this.state.hasError) { | ||
| 35 | + return ( | ||
| 36 | + <Result | ||
| 37 | + status="error" | ||
| 38 | + title="页面出错,请刷新或返回主页" | ||
| 39 | + extra={ | ||
| 40 | + <Button type="primary" onClick={this.handleGoHome}> | ||
| 41 | + 返回主页 | ||
| 42 | + </Button> | ||
| 43 | + } | ||
| 44 | + /> | ||
| 45 | + ); | ||
| 46 | + } | ||
| 47 | + return this.props.children; | ||
| 48 | + } | ||
| 49 | +} |
frontend/src/router/index.tsx
| 1 | -// REQ-USR-004: 路由表(FE 共享骨架)。/login → LoginPage;'/' 占位待 FE-02 落地。 | 1 | +// REQ-USR-003 / REQ-USR-004: 路由表(FE 共享骨架)。 |
| 2 | +// FE-02 将 '/' 占位替换为应用外壳 + 受保护嵌套路由 + 错误边界。 | ||
| 3 | +// 子路由目标内容(FE-03 用户列表 / FE-04 用户单据)由后续 FE 落地,本处仅留可挂载占位。 | ||
| 2 | import { Routes, Route, Navigate } from 'react-router-dom'; | 4 | import { Routes, Route, Navigate } from 'react-router-dom'; |
| 3 | import LoginPage from '../pages/usr/Login/LoginPage'; | 5 | import LoginPage from '../pages/usr/Login/LoginPage'; |
| 6 | +import RequireAuth from './RequireAuth'; | ||
| 7 | +import RedirectIfAuthed from './RedirectIfAuthed'; | ||
| 8 | +import AppErrorBoundary from './AppErrorBoundary'; | ||
| 9 | +import AppLayout from '../layouts/AppLayout/AppLayout'; | ||
| 10 | +import HomePage from '../pages/home/HomePage/HomePage'; | ||
| 4 | 11 | ||
| 5 | -// 主页占位:FE-02 将替换为真实应用壳。本 REQ 仅需 '/' 可达(登录成功 navigate('/'))。 | ||
| 6 | -function HomePlaceholder() { | ||
| 7 | - return <div id="home-placeholder" />; | 12 | +// FE-03 用户列表容器占位(本 FE 仅提供导航入口与标签挂载位) |
| 13 | +function UserListPlaceholder() { | ||
| 14 | + return <div data-testid="fe03-userlist-placeholder" />; | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +// FE-04 用户单据容器占位(新增 / 修改) | ||
| 18 | +function UserDetailPlaceholder() { | ||
| 19 | + return <div data-testid="fe04-userdetail-placeholder" />; | ||
| 8 | } | 20 | } |
| 9 | 21 | ||
| 10 | export default function AppRouter() { | 22 | export default function AppRouter() { |
| 11 | return ( | 23 | return ( |
| 12 | <Routes> | 24 | <Routes> |
| 13 | - <Route path="/login" element={<LoginPage />} /> | ||
| 14 | - <Route path="/" element={<HomePlaceholder />} /> | ||
| 15 | - <Route path="*" element={<Navigate to="/login" replace />} /> | 25 | + {/* 登录页:放行,包 RedirectIfAuthed(已登录回主页,BR2),不包 AppLayout */} |
| 26 | + <Route | ||
| 27 | + path="/login" | ||
| 28 | + element={ | ||
| 29 | + <RedirectIfAuthed> | ||
| 30 | + <LoginPage /> | ||
| 31 | + </RedirectIfAuthed> | ||
| 32 | + } | ||
| 33 | + /> | ||
| 34 | + | ||
| 35 | + {/* 受保护区:RequireAuth > AppLayout(外壳),外壳内套 ErrorBoundary 兜底子路由抛错 */} | ||
| 36 | + <Route element={<RequireAuth />}> | ||
| 37 | + <Route | ||
| 38 | + element={ | ||
| 39 | + <AppErrorBoundary> | ||
| 40 | + <AppLayout /> | ||
| 41 | + </AppErrorBoundary> | ||
| 42 | + } | ||
| 43 | + > | ||
| 44 | + <Route index element={<HomePage />} /> | ||
| 45 | + <Route path="/usr/users" element={<UserListPlaceholder />} /> | ||
| 46 | + <Route path="/usr/users/new" element={<UserDetailPlaceholder />} /> | ||
| 47 | + <Route path="/usr/users/:id" element={<UserDetailPlaceholder />} /> | ||
| 48 | + {/* 受保护区内未匹配 → 回主页(D7) */} | ||
| 49 | + <Route path="*" element={<Navigate to="/" replace />} /> | ||
| 50 | + </Route> | ||
| 51 | + </Route> | ||
| 16 | </Routes> | 52 | </Routes> |
| 17 | ); | 53 | ); |
| 18 | } | 54 | } |
frontend/tests/unit/AppErrorBoundary.test.tsx
0 → 100644
| 1 | +// REQ-USR-003: 路由级 ErrorBoundary 子组件抛错兜底(spec § 3 error / D7) | ||
| 2 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||
| 3 | +import { render, screen } from '@testing-library/react'; | ||
| 4 | +import { MemoryRouter } from 'react-router-dom'; | ||
| 5 | +import AppErrorBoundary from '../../src/router/AppErrorBoundary'; | ||
| 6 | + | ||
| 7 | +function Boom(): React.ReactElement { | ||
| 8 | + throw new Error('boom'); | ||
| 9 | +} | ||
| 10 | + | ||
| 11 | +describe('AppErrorBoundary', () => { | ||
| 12 | + // 抑制 React 在 ErrorBoundary 测试时打印的 error 噪声 | ||
| 13 | + let spy: ReturnType<typeof vi.spyOn>; | ||
| 14 | + beforeEach(() => { | ||
| 15 | + spy = vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
| 16 | + }); | ||
| 17 | + afterEach(() => { | ||
| 18 | + spy.mockRestore(); | ||
| 19 | + }); | ||
| 20 | + | ||
| 21 | + it('renders fallback with 返回主页 when child throws', () => { | ||
| 22 | + render( | ||
| 23 | + <MemoryRouter> | ||
| 24 | + <AppErrorBoundary> | ||
| 25 | + <Boom /> | ||
| 26 | + </AppErrorBoundary> | ||
| 27 | + </MemoryRouter>, | ||
| 28 | + ); | ||
| 29 | + expect(screen.getByText('页面出错,请刷新或返回主页')).toBeInTheDocument(); | ||
| 30 | + expect(screen.getByRole('button', { name: '返回主页' })).toBeInTheDocument(); | ||
| 31 | + }); | ||
| 32 | +}); |
frontend/tests/unit/router.test.tsx
0 → 100644
| 1 | +// REQ-USR-003: 路由表接线(替换 / 占位,含守卫与重定向,BR1/BR2/D7) | ||
| 2 | +import { describe, it, expect } from 'vitest'; | ||
| 3 | +import { screen } from '@testing-library/react'; | ||
| 4 | +import { renderShell } from './renderShell'; | ||
| 5 | +import AppRouter from '../../src/router'; | ||
| 6 | +import type { AuthUser } from '../../src/api/types'; | ||
| 7 | + | ||
| 8 | +const ADMIN: AuthUser = { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }; | ||
| 9 | + | ||
| 10 | +function renderRouter(initialEntries: string[], preloadedAuth?: Parameters<typeof renderShell>[1]['preloadedAuth']) { | ||
| 11 | + return renderShell(<AppRouter />, { initialEntries, preloadedAuth }); | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +describe('AppRouter', () => { | ||
| 15 | + it('unauthenticated / redirects to /login', () => { | ||
| 16 | + renderRouter(['/'], { token: null, user: null }); | ||
| 17 | + // 登录页含「用户登录」标题 | ||
| 18 | + expect(screen.getByText('用户登录')).toBeInTheDocument(); | ||
| 19 | + }); | ||
| 20 | + | ||
| 21 | + it('authenticated / renders HomePage shell', () => { | ||
| 22 | + renderRouter(['/'], { token: 't', user: ADMIN }); | ||
| 23 | + expect(screen.getByText('KPI监控')).toBeInTheDocument(); | ||
| 24 | + expect(screen.getByRole('button', { name: '全部导航' })).toBeInTheDocument(); | ||
| 25 | + }); | ||
| 26 | + | ||
| 27 | + it('authenticated /usr/users renders under AppLayout', () => { | ||
| 28 | + renderRouter(['/usr/users'], { token: 't', user: ADMIN }); | ||
| 29 | + // 外壳顶栏在;用户列表标签激活(占位内容由 FE-03 落地) | ||
| 30 | + expect(screen.getByRole('button', { name: '全部导航' })).toBeInTheDocument(); | ||
| 31 | + expect(screen.getByTestId('tab-userlist').getAttribute('aria-pressed')).toBe('true'); | ||
| 32 | + }); | ||
| 33 | + | ||
| 34 | + it('authenticated /login redirects to /', () => { | ||
| 35 | + renderRouter(['/login'], { token: 't', user: ADMIN }); | ||
| 36 | + expect(screen.getByText('KPI监控')).toBeInTheDocument(); | ||
| 37 | + }); | ||
| 38 | + | ||
| 39 | + it('unknown protected path redirects to /', () => { | ||
| 40 | + renderRouter(['/no/such/path'], { token: 't', user: ADMIN }); | ||
| 41 | + expect(screen.getByText('KPI监控')).toBeInTheDocument(); | ||
| 42 | + }); | ||
| 43 | +}); |