diff --git a/frontend/src/router/AppErrorBoundary.tsx b/frontend/src/router/AppErrorBoundary.tsx new file mode 100644 index 0000000..24c6da9 --- /dev/null +++ b/frontend/src/router/AppErrorBoundary.tsx @@ -0,0 +1,49 @@ +// REQ-USR-003: 路由级 ErrorBoundary(子路由渲染抛错兜底 + 返回主页入口,spec § 3 error / D7)。 +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { Button, Result } from 'antd'; + +interface AppErrorBoundaryProps { + children: ReactNode; +} + +interface AppErrorBoundaryState { + hasError: boolean; +} + +export default class AppErrorBoundary extends Component< + AppErrorBoundaryProps, + AppErrorBoundaryState +> { + state: AppErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): AppErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(_error: Error, _info: ErrorInfo): void { + // 兜底:捕获即进入降级 UI;此处可接入日志上报(MVP 不上报)。 + } + + private handleGoHome = () => { + this.setState({ hasError: false }); + // 直接回主页(不依赖 hooks,class 组件用 location) + window.location.assign('/'); + }; + + render() { + if (this.state.hasError) { + return ( + + 返回主页 + + } + /> + ); + } + return this.props.children; + } +} diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 5dc09a7..9510baf 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -1,18 +1,54 @@ -// REQ-USR-004: 路由表(FE 共享骨架)。/login → LoginPage;'/' 占位待 FE-02 落地。 +// REQ-USR-003 / REQ-USR-004: 路由表(FE 共享骨架)。 +// FE-02 将 '/' 占位替换为应用外壳 + 受保护嵌套路由 + 错误边界。 +// 子路由目标内容(FE-03 用户列表 / FE-04 用户单据)由后续 FE 落地,本处仅留可挂载占位。 import { Routes, Route, Navigate } from 'react-router-dom'; import LoginPage from '../pages/usr/Login/LoginPage'; +import RequireAuth from './RequireAuth'; +import RedirectIfAuthed from './RedirectIfAuthed'; +import AppErrorBoundary from './AppErrorBoundary'; +import AppLayout from '../layouts/AppLayout/AppLayout'; +import HomePage from '../pages/home/HomePage/HomePage'; -// 主页占位:FE-02 将替换为真实应用壳。本 REQ 仅需 '/' 可达(登录成功 navigate('/'))。 -function HomePlaceholder() { - return
; +// FE-03 用户列表容器占位(本 FE 仅提供导航入口与标签挂载位) +function UserListPlaceholder() { + return
; +} + +// FE-04 用户单据容器占位(新增 / 修改) +function UserDetailPlaceholder() { + return
; } export default function AppRouter() { return ( - } /> - } /> - } /> + {/* 登录页:放行,包 RedirectIfAuthed(已登录回主页,BR2),不包 AppLayout */} + + + + } + /> + + {/* 受保护区:RequireAuth > AppLayout(外壳),外壳内套 ErrorBoundary 兜底子路由抛错 */} + }> + + + + } + > + } /> + } /> + } /> + } /> + {/* 受保护区内未匹配 → 回主页(D7) */} + } /> + + ); } diff --git a/frontend/tests/unit/AppErrorBoundary.test.tsx b/frontend/tests/unit/AppErrorBoundary.test.tsx new file mode 100644 index 0000000..b1f626c --- /dev/null +++ b/frontend/tests/unit/AppErrorBoundary.test.tsx @@ -0,0 +1,32 @@ +// REQ-USR-003: 路由级 ErrorBoundary 子组件抛错兜底(spec § 3 error / D7) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import AppErrorBoundary from '../../src/router/AppErrorBoundary'; + +function Boom(): React.ReactElement { + throw new Error('boom'); +} + +describe('AppErrorBoundary', () => { + // 抑制 React 在 ErrorBoundary 测试时打印的 error 噪声 + let spy: ReturnType; + beforeEach(() => { + spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { + spy.mockRestore(); + }); + + it('renders fallback with 返回主页 when child throws', () => { + render( + + + + + , + ); + expect(screen.getByText('页面出错,请刷新或返回主页')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '返回主页' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/unit/router.test.tsx b/frontend/tests/unit/router.test.tsx new file mode 100644 index 0000000..f509540 --- /dev/null +++ b/frontend/tests/unit/router.test.tsx @@ -0,0 +1,43 @@ +// REQ-USR-003: 路由表接线(替换 / 占位,含守卫与重定向,BR1/BR2/D7) +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderShell } from './renderShell'; +import AppRouter from '../../src/router'; +import type { AuthUser } from '../../src/api/types'; + +const ADMIN: AuthUser = { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }; + +function renderRouter(initialEntries: string[], preloadedAuth?: Parameters[1]['preloadedAuth']) { + return renderShell(, { initialEntries, preloadedAuth }); +} + +describe('AppRouter', () => { + it('unauthenticated / redirects to /login', () => { + renderRouter(['/'], { token: null, user: null }); + // 登录页含「用户登录」标题 + expect(screen.getByText('用户登录')).toBeInTheDocument(); + }); + + it('authenticated / renders HomePage shell', () => { + renderRouter(['/'], { token: 't', user: ADMIN }); + expect(screen.getByText('KPI监控')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '全部导航' })).toBeInTheDocument(); + }); + + it('authenticated /usr/users renders under AppLayout', () => { + renderRouter(['/usr/users'], { token: 't', user: ADMIN }); + // 外壳顶栏在;用户列表标签激活(占位内容由 FE-03 落地) + expect(screen.getByRole('button', { name: '全部导航' })).toBeInTheDocument(); + expect(screen.getByTestId('tab-userlist').getAttribute('aria-pressed')).toBe('true'); + }); + + it('authenticated /login redirects to /', () => { + renderRouter(['/login'], { token: 't', user: ADMIN }); + expect(screen.getByText('KPI监控')).toBeInTheDocument(); + }); + + it('unknown protected path redirects to /', () => { + renderRouter(['/no/such/path'], { token: 't', user: ADMIN }); + expect(screen.getByText('KPI监控')).toBeInTheDocument(); + }); +});