Commit c897b60dba3d28b0e621918cee2134a371c0342b

Authored by zichun
1 parent ffe35f1d

feat(fe-shell): 受保护嵌套路由表与错误边界 REQ-USR-003

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 4 import { Routes, Route, Navigate } from 'react-router-dom';
3 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 22 export default function AppRouter() {
11 23 return (
12 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 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 +});
... ...