Skip to content

第10章 组件开发实战

作者

谭策 — 独立开发者 | AIOps 领域探索者

IT Online 微信公众号

许可证

MPL-2.0 © 谭策

本章导读

第7章我们学习了 React 和前端开发的基础知识。本章将深入组件开发的实战环节,基于项目的实际组件代码,讲解如何构建高质量、可复用的 React 组件。我们将从项目中最核心的 Layout、WebTerminal、ChatWidget 组件出发,深入分析组件组合模式、表单验证、数据可视化和错误处理等真实场景。

学习目标

阅读完本章后,你将能够:

  • 设计和开发可复用的业务组件
  • 理解并使用组件组合模式
  • 构建带验证的表单组件
  • 开发数据可视化组件(Canvas 图表)
  • 实现错误边界和加载状态
  • 使用 React Query 进行数据获取和缓存

10.1 前端组件架构总览

10.1.1 项目组件目录结构

frontend/src/
├── components/                  ← 通用组件
│   ├── layout/
│   │   └── Layout.tsx           ← 主布局(侧边栏 + 内容区)
│   ├── WebTerminal.tsx          ← SSH 终端组件
│   ├── ChatWidget.tsx           ← AI 对话悬浮组件
│   ├── ErrorBoundary.tsx        ← 错误边界
│   ├── ProtectedRoute.tsx       ← 路由守卫
│   ├── CircularProgress.tsx     ← 环形进度条
│   ├── AnimatedBarChart.tsx     ← 动态柱状图
│   ├── AnimatedLineChart.tsx    ← 动态折线图
│   ├── MarkdownOutput.tsx       ← Markdown 渲染
│   ├── ParticleBackground.tsx   ← 粒子背景
│   └── ImportExport.tsx         ← 导入导出组件

├── pages/                       ← 页面组件(路由级)
│   ├── Dashboard.tsx
│   ├── Servers.tsx
│   ├── Alerts.tsx
│   ├── WorkflowEditor.tsx
│   └── ...

├── contexts/                    ← 全局状态
│   └── AuthContext.tsx          ← 认证上下文

├── hooks/                       ← 自定义 Hook
│   └── useTheme.ts              ← 主题切换

└── lib/
    ├── api.ts                   ← API 客户端
    └── xss.ts                   ← XSS 防护

10.1.2 项目使用的核心技术栈

技术用途版本
React 18UI 框架^18.x
React Router v6路由管理v6
TanStack React Query数据获取与缓存v5
Tailwind CSS样式系统v3
Lucide React图标库最新版
XTerm.js终端渲染^5.x
Socket.IO ClientWebSocketv4
clsx条件类名最新版

10.1.3 组件分层模型

┌─────────────────────────────────────────────────────┐
│              页面组件 (Pages)                         │
│  Dashboard.tsx, Servers.tsx, Alerts.tsx ...         │
│  职责: 路由级别页面,组合业务组件,处理页面级状态      │
├─────────────────────────────────────────────────────┤
│              业务组件 (Business Components)            │
│  Layout.tsx, WebTerminal.tsx, ChatWidget.tsx        │
│  职责: 封装特定业务功能,包含状态管理和 API 调用      │
├─────────────────────────────────────────────────────┤
│              通用组件 (UI Components)                  │
│  AnimatedBarChart.tsx, CircularProgress.tsx         │
│  ErrorBoundary.tsx, ProtectedRoute.tsx              │
│  职责: 可复用 UI 元素,props 驱动,无业务逻辑          │
├─────────────────────────────────────────────────────┤
│              基础设施层                                │
│  AuthContext.tsx, api.ts, useTheme.ts               │
│  职责: 全局状态、API 客户端、工具函数                  │
└─────────────────────────────────────────────────────┘

10.2 Layout 组件:应用骨架

10.2.1 组件职责与设计

Layout 是应用的骨架组件,负责:

  • 侧边栏导航(21 个导航项)
  • 用户信息展示
  • 系统状态面板
  • 退出登录
  • 嵌入 ChatWidget 悬浮组件
  • 使用 <Outlet /> 渲染子页面

10.2.2 完整实现分析

typescript
// frontend/src/components/layout/Layout.tsx
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import {
  LayoutDashboard, Bot, GitBranch, Play, Bell, BookOpen,
  FileCode, Settings, Server, Shield, FileText, MessageSquare,
  Clock, Link2, Users, Search, LogOut, User as UserIcon,
  Terminal, Monitor, Wrench, ListChecks, BarChart3,
} from 'lucide-react';
import clsx from 'clsx';
import { useAuth } from '../../contexts/AuthContext';
import ChatWidget from '../ChatWidget';

const navigation = [
  { name: '仪表盘', href: '/dashboard', icon: LayoutDashboard },
  { name: '监控大屏', href: '/big-screen', icon: Monitor },
  { name: '服务器管理', href: '/servers', icon: Server },
  { name: 'Web 终端', href: '/terminal', icon: Terminal },
  { name: 'Agent管理', href: '/agents', icon: Bot },
  { name: '工作流', href: '/workflows', icon: GitBranch },
  { name: '任务执行', href: '/tasks', icon: Play },
  { name: '告警中心', href: '/alerts', icon: Bell },
  { name: '告警自动处理', href: '/alert-mappings', icon: Link2 },
  { name: '告警降噪', href: '/alert-noise', icon: Shield },
  { name: '自动修复策略', href: '/remediation-policies', icon: Wrench },
  { name: '修复效果仪表盘', href: '/remediation-dashboard', icon: BarChart3 },
  { name: '修复执行记录', href: '/remediation-executions', icon: ListChecks },
  { name: '根因分析', href: '/root-cause-analysis', icon: Search },
  { name: '知识库', href: '/knowledge', icon: BookOpen },
  { name: '脚本中心', href: '/scripts', icon: FileCode },
  { name: '定时任务', href: '/scheduled-tasks', icon: Clock },
  { name: '审计日志', href: '/audit', icon: Shield },
  { name: '通知系统', href: '/notifications', icon: MessageSquare },
  { name: '报告系统', href: '/reports', icon: FileText },
  { name: '用户管理', href: '/users', icon: Users },
  { name: '设置', href: '/settings', icon: Settings },
];

导航数据配置化

将导航项定义为纯数据数组,而非直接写 JSX,好处是:

  1. 数据与展示分离
  2. 可轻松实现基于权限的动态导航过滤
  3. 新增菜单只需添加一行配置
typescript
// 动态过滤导航(示例:根据角色显示不同菜单)
const filteredNavigation = navigation.filter(item => {
  if (user.role === 'viewer') {
    return !['/users', '/settings'].includes(item.href);
  }
  return true;
});

10.2.3 侧边栏布局实现

typescript
export default function Layout() {
  const { user, logout } = useAuth();
  const navigate = useNavigate();

  // React Query 定时轮询 Agent 和工作流数量
  const { data: agentCount } = useQuery({
    queryKey: ['agents-count'],
    queryFn: async () => {
      const res = await api.get('/api/agents');
      return (res.data.data as Array<{ enabled: number }>)
        .filter((a) => a.enabled === 1).length;
    },
    refetchInterval: 60000,       // 每60秒重新请求
    staleTime: 5 * 60 * 1000,     // 5分钟内视为新鲜数据
  });

  const { data: workflowCount } = useQuery({
    queryKey: ['workflows-count'],
    queryFn: async () => {
      const res = await api.get('/api/workflows');
      return (res.data.data as Array<{ is_template: number }>)
        .filter((w) => w.is_template === 1).length;
    },
    refetchInterval: 60000,
    staleTime: 5 * 60 * 1000,
  });

  const handleLogout = () => {
    logout();
    navigate('/login');
  };

  const getRoleText = (role: string) => {
    const roleMap: Record<string, string> = {
      'admin': '管理员',
      'operator': '运维员',
      'viewer': '只读用户'
    };
    return roleMap[role] || role;
  };

  return (
    <div className="flex h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
      {/* 侧边栏 */}
      <aside className="w-56 bg-gradient-to-b from-slate-900/95 via-slate-900/90 to-slate-950/95 
                        border-r border-slate-700/50 flex flex-col backdrop-blur-xl shadow-2xl">
        {/* Logo 区域 */}
        <div className="p-4 border-b border-slate-700/50">
          <div className="flex items-center gap-3">
            <div className="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center 
                           bg-gradient-to-br from-blue-600 to-purple-600 shadow-lg shadow-blue-500/30 
                           flex-shrink-0">
              <img src="/logo.jpg" alt="Logo" className="w-full h-full object-contain" />
            </div>
            <div>
              <h1 className="text-base font-bold text-white tracking-tight">ITOps Agent</h1>
              <p className="text-[11px] text-slate-400">多Agent自动化平台</p>
            </div>
          </div>
        </div>

        {/* 导航菜单 */}
        <nav className="flex-1 p-3 space-y-0.5 overflow-y-auto scrollbar-thin">
          {navigation.map((item) => (
            <NavLink
              key={item.name}
              to={item.href}
              className={({ isActive }) =>
                clsx(
                  'flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group',
                  isActive
                    ? 'bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/25'
                    : 'text-slate-400 hover:bg-slate-800/80 hover:text-white'
                )
              }
            >
              <item.icon className="w-4 h-4 group-hover:scale-110 transition-transform flex-shrink-0" />
              {item.name}
            </NavLink>
          ))}
        </nav>

        {/* 底部用户信息 + 系统状态 */}
        <div className="border-t border-slate-700/50">
          {user && (
            <div className="p-3 border-b border-slate-700/30">
              <div className="flex items-center gap-2.5 p-2.5 bg-slate-800/50 rounded-lg">
                <div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500/20 to-purple-500/20 
                               flex items-center justify-center border border-blue-400/30 flex-shrink-0">
                  <UserIcon className="w-4 h-4 text-blue-400" />
                </div>
                <div className="flex-1 min-w-0">
                  <p className="text-xs font-semibold text-white truncate">{user.username}</p>
                  <p className="text-[10px] text-slate-400 truncate">{getRoleText(user.role)}</p>
                </div>
              </div>
            </div>
          )}

          <div className="p-3">
            {/* 系统状态面板 */}
            <div className="bg-gradient-to-br from-slate-800/80 to-slate-900/80 rounded-lg p-3 mb-2 
                           border border-slate-700/50">
              <div className="flex items-center gap-2 mb-1.5">
                <div className="w-2 h-2 rounded-full bg-gradient-to-r from-green-500 to-emerald-500 
                               animate-pulse shadow-lg shadow-green-500/30" />
                <span className="text-xs font-semibold text-white">系统正常</span>
              </div>
              <p className="text-[10px] text-slate-400">
                {agentCount ?? '...'}个Agent · {workflowCount ?? '...'}个工作流
              </p>
            </div>

            <button
              onClick={handleLogout}
              className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg 
                         text-xs font-medium text-slate-400 hover:bg-red-500/10 hover:text-red-400 
                         transition-all duration-200 border border-transparent hover:border-red-500/30"
            >
              <LogOut className="w-3.5 h-3.5" />
              退出登录
            </button>
          </div>
        </div>
      </aside>

      {/* 主内容区 */}
      <main className="flex-1 overflow-hidden">
        <Outlet />
      </main>

      {/* 悬浮 AI 助手 */}
      <ChatWidget />
    </div>
  );
}

10.2.4 关键技术点解析

1. NavLink 激活状态

typescript
<NavLink
  to={item.href}
  className={({ isActive }) =>
    clsx(
      '基础样式',
      isActive
        ? '激活样式: bg-gradient-to-r from-blue-600 to-blue-700 text-white'
        : '默认样式: text-slate-400 hover:bg-slate-800/80'
    )
  }
>

NavLink 的 className 可以接收一个函数,参数包含 isActiveisPending 等状态,实现动态样式切换。

2. React Query 轮询优化

typescript
useQuery({
  queryKey: ['agents-count'],
  queryFn: ...,
  refetchInterval: 60000,       // 定时轮询间隔
  staleTime: 5 * 60 * 1000,     // 新鲜数据时间
});
配置项含义效果
refetchInterval: 60000每 60 秒自动重新请求实现实时数据刷新
staleTime: 5*60*10005 分钟内数据视为新鲜避免组件切换时重复请求

3. clsx 条件类名

typescript
clsx(
  'flex items-center gap-2.5 px-3 py-2.5',   // 始终生效
  isActive && 'bg-blue-600 text-white',       // 条件生效
  disabled && 'opacity-50 cursor-not-allowed'  // 条件生效
)

10.2.5 Layout 结构图解

┌─────────────────────────────────────────────────────────────┐
│                        Layout.tsx                            │
├──────────────┬──────────────────────────────────────────────┤
│              │                                              │
│  侧边栏       │  <main> (flex-1)                             │
│  (w-56)      │                                              │
│              │  <Outlet />                                  │
│  ┌────────┐  │  根据当前路由渲染对应页面组件                  │
│  │ Logo   │  │                                              │
│  ├────────┤  │  例如: /dashboard → Dashboard.tsx             │
│  │ 导航项1 │  │        /servers    → Servers.tsx             │
│  │ 导航项2 │  │        /alerts     → Alerts.tsx              │
│  │ ...    │  │                                              │
│  │ 导航项21│  │                                              │
│  ├────────┤  │                                              │
│  │ 用户信息│  │                                              │
│  │ 状态面板│  │                                              │
│  │ 退出按钮│  │                                              │
│  └────────┘  │                                              │
│              │                                              │
└──────────────┴──────────────────────────────────────────────┘

                             ChatWidget (fixed定位)
                             悬浮在右下角

10.3 WebTerminal 组件:SSH 终端

10.3.1 组件职责

WebTerminal 是项目中最复杂的组件之一,负责:

  • 通过 xterm.js 在浏览器中渲染终端
  • 通过 Socket.IO 与后端建立 WebSocket 连接
  • 转发用户输入到远程 SSH Shell
  • 接收 Shell 输出并渲染到终端
  • 处理终端尺寸自适应(FitAddon)
  • 实现连接、重连、断开逻辑

10.3.2 组件 Props 接口

typescript
interface TerminalProps {
  serverId: string;     // 目标服务器 ID
  serverName: string;   // 服务器显示名称
  token: string;        // JWT Token(用于 WebSocket 认证)
  onClose: () => void;  // 关闭回调
}

10.3.3 Ref 管理

typescript
const terminalRef = useRef<HTMLDivElement>(null);         // 终端 DOM 容器
const xtermRef = useRef<Terminal | null>(null);           // xterm 实例
const fitAddonRef = useRef<FitAddon | null>(null);        // 尺寸适配插件
const socketRef = useRef<Socket | null>(null);            // Socket.IO 实例
const sessionIdRef = useRef<string | null>(null);         // 终端会话 ID
const terminalDataHandlerRef = useRef<((data) => void) | null>(null);  // 数据处理器
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);  // 重连定时器
const reconnectCountRef = useRef(0);                      // 重连计数
const maxReconnectAttempts = 3;                           // 最大重连次数
const [status, setStatus] = useState<
  'connecting' | 'connected' | 'error' | 'disconnected'
>('connecting');
const [error, setError] = useState<string>('');

为什么这么多 Ref?

state vs ref 的选择:

state: 值变化需要触发组件重新渲染
  ├── status (连接状态变化需要更新 UI)
  └── error (错误信息需要显示)

ref: 值变化不需要触发重新渲染
  ├── terminalRef (DOM 引用)
  ├── xtermRef (终端实例)
  ├── socketRef (Socket 连接)
  ├── sessionIdRef (会话 ID)
  └── reconnectCountRef (重连计数器)

10.3.4 终端初始化

typescript
useEffect(() => {
  if (!terminalRef.current) return;

  // 1. 创建 Terminal 实例
  const term = new Terminal({
    cursorBlink: true,
    fontSize: 14,
    fontFamily: 'Menlo, Monaco, "Courier New", monospace',
    theme: {
      background: '#1e1e1e',
      foreground: '#d4d4d4',
      cursor: '#d4d4d4',
      selectionBackground: '#264f78',
      black: '#000000', red: '#cd3131', green: '#0dbc79',
      yellow: '#e5e510', blue: '#2472c8', magenta: '#bc3fbc',
      cyan: '#11a8cd', white: '#e5e5e5',
      brightBlack: '#666666', brightRed: '#f14c4c',
      brightGreen: '#23d18b', brightYellow: '#f5f543',
      brightBlue: '#3b8eea', brightMagenta: '#d670d6',
      brightCyan: '#29b8db', brightWhite: '#e5e5e5'
    },
    allowProposedApi: true,
    scrollback: 5000
  });

  // 2. 加载插件
  const fitAddon = new FitAddon();
  const webLinksAddon = new WebLinksAddon();
  term.loadAddon(fitAddon);
  term.loadAddon(webLinksAddon);

  xtermRef.current = term;
  fitAddonRef.current = fitAddon;

  // 3. 挂载到 DOM
  term.open(terminalRef.current);
  fitAddon.fit();

  // 4. 建立 WebSocket 连接
  connect();

  // 5. 转发用户输入
  term.onData((data) => {
    if (socketRef.current?.connected && sessionIdRef.current) {
      socketRef.current.emit('terminal:data', {
        sessionId: sessionIdRef.current, data
      });
    }
  });

  // 6. 处理尺寸变化
  term.onResize(({ cols, rows }) => {
    if (socketRef.current?.connected && sessionIdRef.current) {
      socketRef.current.emit('terminal:resize', {
        sessionId: sessionIdRef.current, cols, rows
      });
    }
  });

  // 7. 窗口 resize 时自动 fit
  const handleResize = () => { fitAddon.fit(); };
  window.addEventListener('resize', handleResize);

  // 清理函数
  return () => {
    window.removeEventListener('resize', handleResize);
    cleanup();
  };
}, [serverId, token, cleanup, connect]);

10.3.5 WebSocket 连接与重连

typescript
const connect = useCallback(() => {
  if (!terminalRef.current || !xtermRef.current) return;

  // 创建 Socket 连接
  const socket = io(import.meta.env.VITE_API_URL || 'http://localhost:3001', {
    auth: { token },
    transports: ['websocket']  // 仅使用 WebSocket(降级由 Socket.IO 处理)
  });

  socketRef.current = socket;

  // 终端数据接收处理
  const terminalDataHandler = (data: { sessionId: string; data: string }) => {
    if (data.sessionId === sessionIdRef.current && xtermRef.current) {
      xtermRef.current.write(data.data);
    }
  };

  terminalDataHandlerRef.current = terminalDataHandler;
  socket.on('terminal:data', terminalDataHandler);

  // 连接成功 → 打开终端会话
  socket.on('connect', () => {
    const term = xtermRef.current;
    if (!term) return;
    const cols = term.cols;
    const rows = term.rows;

    reconnectCountRef.current = 0;
    socket.emit('terminal:open', { serverId, cols, rows },
      (result: { sessionId?: string; error?: string }) => {
        if (result.error) {
          setStatus('error');
          setError(result.error || 'Failed to open terminal');
          return;
        }
        sessionIdRef.current = result.sessionId || null;
        setStatus('connected');
      }
    );
  });

  // 连接失败
  socket.on('connect_error', () => {
    setStatus('error');
    setError('WebSocket connection failed');
  });

  // 断开处理(含指数退避重连)
  socket.on('disconnect', (reason) => {
    if (reason === 'io server disconnect') {
      socket.disconnect();
      setStatus('disconnected');
      return;
    }

    if (reconnectCountRef.current < maxReconnectAttempts) {
      setStatus('connecting');
      reconnectCountRef.current++;
      // 指数退避: 2s, 4s, 8s (最大5s)
      reconnectTimerRef.current = setTimeout(() => {
        socket.connect();
      }, Math.min(1000 * Math.pow(2, reconnectCountRef.current), 5000));
    } else {
      setStatus('disconnected');
      setError('Terminal connection lost');
    }
  });

  return socket;
}, [serverId, token]);

指数退避重连策略

断线次数:   1      2      3      4(放弃)
等待时间:   2s     4s     5s     -
          ────────────────────────────────
时间轴:    断  ─2s─ 连  ─4s─ 连  ─5s─ 连  放弃

10.3.6 清理逻辑

typescript
const cleanup = useCallback(() => {
  // 1. 清除重连定时器
  if (reconnectTimerRef.current) {
    clearTimeout(reconnectTimerRef.current);
    reconnectTimerRef.current = null;
  }
  reconnectCountRef.current = 0;

  const socket = socketRef.current;
  const sessionId = sessionIdRef.current;
  const xterm = xtermRef.current;
  const handler = terminalDataHandlerRef.current;

  // 2. 移除数据监听器
  if (handler && socket) {
    socket.removeListener('terminal:data', handler);
    terminalDataHandlerRef.current = null;
  }

  // 3. 关闭 Socket 和终端会话
  if (socket && sessionId) {
    socket.emit('terminal:close', { sessionId });
    socket.disconnect();
    socketRef.current = null;
  }

  // 4. 销毁 xterm 实例
  if (xterm) {
    xterm.dispose();
    xtermRef.current = null;
  }
}, []);

10.3.7 数据流图解

┌─────────────────────────────────────────────────────────────┐
│                    WebTerminal 数据流                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  用户输入                                                     │
│    │                                                        │
│    ▼                                                        │
│  xterm.onData(data)                                         │
│    │                                                        │
│    │ emit('terminal:data', { sessionId, data })             │
│    ▼                                                        │
│  ┌──────────────┐     WebSocket      ┌──────────────────┐  │
│  │   Socket.IO  │ ◄───────────────►  │  后端 handler.ts │  │
│  │   Client     │                    │  terminalService │  │
│  └──────────────┘                    └────────┬─────────┘  │
│                                                │           │
│                                                ▼           │
│                                         SSH Shell (node-ssh)│
│                                                │           │
│                                                ▼           │
│                                         Shell 输出 (Buffer) │
│                                                │           │
│  终端渲染 ◄────────────────────────────────────┘           │
│    │                                                        │
│    │ emit('terminal:data', { sessionId, data })             │
│    ▼                                                        │
│  xterm.write(data)                                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

10.4 ChatWidget 组件:AI 对话悬浮窗

10.4.1 组件职责

ChatWidget 是悬浮在页面右下角的 AI 助手组件:

  • 支持最小化/展开/关闭三种状态
  • 管理多轮对话(创建、切换、删除对话)
  • 调用后端 Copilot API
  • 显示 Markdown 格式的回复
  • 提供快捷提问建议
  • 左侧对话列表 + 右侧聊天区域的双栏布局

10.4.2 状态管理

typescript
interface Message {
  role: 'user' | 'assistant';
  content: string;
  timestamp: Date;
}

interface Conversation {
  id: string;
  messages: Message[];
}

export default function ChatWidget() {
  const queryClient = useQueryClient();
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const [isOpen, setIsOpen] = useState(false);               // 是否展开
  const [isMinimized, setIsMinimized] = useState(false);      // 是否最小化
  const [inputValue, setInputValue] = useState('');           // 输入框内容
  const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
  // ...
}

10.4.3 React Query + Mutation 模式

typescript
// 查询:获取对话建议
const { data: suggestions } = useQuery({
  queryKey: ['copilot-suggestions'],
  queryFn: async () => {
    const res = await api.get('/api/copilot/suggestions');
    return res.data.data || [];
  }
});

// 查询:获取对话列表
const { data: conversations } = useQuery({
  queryKey: ['copilot-conversations'],
  queryFn: async () => {
    const res = await api.get('/api/copilot/conversations');
    return res.data.data || [];
  }
});

// 突变:发送消息
const sendMessageMutation = useMutation({
  mutationFn: async ({ conversationId, message }: { 
    conversationId?: string, message: string 
  }) => {
    const res = await api.post('/api/copilot/chat', {
      conversationId, message
    });
    return res.data;
  },
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['copilot-conversations'] });
    setInputValue('');
  },
  onError: () => {
    alert(`发送消息失败: 未知错误`);
  },
});

// 突变:创建对话
const createConversationMutation = useMutation({
  mutationFn: async () => {
    const res = await api.post('/api/copilot/conversations');
    return res.data;
  },
  onSuccess: (data) => {
    if (data.success) {
      setCurrentConversationId(data.data.id);
      queryClient.invalidateQueries({ queryKey: ['copilot-conversations'] });
    }
  },
});

// 突变:删除对话
const deleteConversationMutation = useMutation({
  mutationFn: async (id: string) => {
    await api.delete(`/api/copilot/conversations/${id}`);
  },
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['copilot-conversations'] });
    setCurrentConversationId(null);
  },
});

React Query 使用模式总结

Hook用途触发时机
useQuery获取数据组件挂载、queryKey 变化、手动 invalidate
useMutation修改数据手动调用 .mutate().mutateAsync()
queryClient.invalidateQueries刷新缓存mutation 成功后刷新相关查询

10.4.4 消息发送逻辑

typescript
const handleSend = async (msg?: string) => {
  const message = msg || inputValue;
  if (!message.trim()) return;

  if (!currentConversationId) {
    // 没有当前对话 → 先创建对话,再发送消息
    await createConversationMutation.mutateAsync().then((data) => {
      if (data && data.success) {
        sendMessageMutation.mutate({
          conversationId: data.data.id, message
        });
      }
    });
  } else {
    // 有当前对话 → 直接发送
    sendMessageMutation.mutate({
      conversationId: currentConversationId, message
    });
  }
};

10.4.5 组件 UI 结构

ChatWidget
├── 未展开状态
│   └── 浮动按钮 (Bot 图标, fixed定位右下角)

└── 展开状态
    └── 对话面板 (420x600px)
        ├── 头部栏
        │   ├── IT运维助手标题
        │   ├── 最小化按钮
        │   └── 关闭按钮

        ├── 左侧边栏 (w-32)
        │   ├── 新对话按钮
        │   └── 对话列表
        │       └── 每个对话项 (前12字符 + 删除按钮)

        └── 右侧聊天区
            ├── 无对话 → 欢迎页 + 快捷建议

            └── 有对话 → 消息列表
                ├── 用户消息 (右侧, 蓝色背景)
                ├── AI消息 (左侧, 深色背景, Markdown渲染)
                ├── 加载中动画 (思考中...)
                └── 输入区域 (input + 发送按钮)

10.4.6 最小化状态处理

typescript
if (!isOpen) {
  // 未展开 → 只显示浮动按钮
  return (
    <button
      onClick={() => setIsOpen(true)}
      className="fixed bottom-6 right-6 w-14 h-14 bg-gradient-to-r 
                 from-blue-600 to-purple-600 rounded-full shadow-lg 
                 hover:shadow-xl transition-all duration-300 flex 
                 items-center justify-center text-white hover:scale-110 z-50"
    >
      <Bot className="w-7 h-7" />
    </button>
  );
}

// 展开状态 → 完整对话面板
return (
  <div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
    {!isMinimized && (
      // 完整对话面板
      <div className="w-[420px] h-[600px] bg-slate-900 rounded-xl ...">
        {/* 头部、侧边栏、聊天区 */}
      </div>
    )}
    
    {/* 最小化状态按钮 */}
    {isMinimized && (
      <div className="flex gap-2">
        <button onClick={() => setIsMinimized(false)} ...>
          <MessageSquare className="w-7 h-7" />
        </button>
        <button onClick={() => setIsOpen(false)} ...>
          <X className="w-7 h-7" />
        </button>
      </div>
    )}
  </div>
);

10.5 ErrorBoundary 组件:错误边界

10.5.1 为什么需要错误边界

React 中,如果某个组件在渲染时抛出错误,整个组件树会被卸载,页面变成空白。错误边界可以:

  • 捕获子组件树中的 JavaScript 错误
  • 显示降级 UI(而不是空白页面)
  • 记录错误信息用于排查

10.5.2 Class 组件实现

错误边界必须使用 Class 组件,因为函数组件不支持 componentDidCatchgetDerivedStateFromError 生命周期:

typescript
// frontend/src/components/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';

interface Props {
  children: ReactNode;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

export default class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  // 更新 state 使下次渲染时显示降级 UI
  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  // 记录错误信息
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('ErrorBoundary caught:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="min-h-screen flex items-center justify-center 
                       bg-gray-50 dark:bg-gray-900 p-4">
          <div className="max-w-md w-full bg-white dark:bg-gray-800 
                         rounded-xl shadow-lg p-8 text-center">
            <div className="flex justify-center mb-4">
              <div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 
                             rounded-full flex items-center justify-center">
                <AlertTriangle className="w-8 h-8 text-red-600 dark:text-red-400" />
              </div>
            </div>
            <h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
              系统出现异常
            </h2>
            <p className="text-gray-600 dark:text-gray-400 mb-6 text-sm">
              页面渲染时发生错误,请尝试刷新页面。
            </p>
            {this.state.error && (
              <div className="mb-6 p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-left">
                <p className="text-xs text-gray-500 dark:text-gray-400 
                             font-mono break-all">
                  {this.state.error.message}
                </p>
              </div>
            )}
            <button
              onClick={() => window.location.reload()}
              className="inline-flex items-center gap-2 px-6 py-2.5 
                         bg-blue-600 hover:bg-blue-700 text-white 
                         rounded-lg transition-colors text-sm font-medium"
            >
              <RefreshCw className="w-4 h-4" />
              刷新页面
            </button>
          </div>
        </div>
      );
    }

    return this.props.children;
  }
}

10.5.3 使用方式

typescript
// App.tsx 中使用
<ErrorBoundary>
  <BrowserRouter>
    <Routes>
      {/* 所有路由 */}
    </Routes>
  </BrowserRouter>
</ErrorBoundary>

// 也可以在单个组件周围使用
<ErrorBoundary>
  <ComplexChart />
</ErrorBoundary>

错误边界捕获范围

捕获:
  ✓ 子组件渲染时的错误
  ✓ 子组件生命周期方法中的错误
  ✓ 子组件构造函数中的错误

不捕获:
  ✗ 事件处理器中的错误 (用 try-catch)
  ✗ 异步代码中的错误 (Promise.reject)
  ✗ 服务端渲染错误
  ✗ 错误边界自身的错误

10.6 ProtectedRoute 组件:路由守卫

10.6.1 组件职责

ProtectedRoute 保护需要认证的路由,未登录用户自动跳转到登录页:

typescript
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';

interface ProtectedRouteProps {
  children: React.ReactNode;
}

export default function ProtectedRoute({ children }: ProtectedRouteProps) {
  const { token, loading } = useAuth();

  if (loading) {
    return <div className="min-h-screen flex items-center justify-center">
      <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500" />
    </div>;
  }

  if (!token) {
    return <Navigate to="/login" replace />;
  }

  return <>{children}</>;
}

10.6.2 在路由中使用

typescript
// App.tsx
<Routes>
  <Route path="/login" element={<Login />} />
  <Route path="*" element={
    <ProtectedRoute>
      <Layout />
    </ProtectedRoute>
  }>
    <Route path="dashboard" element={<Dashboard />} />
    <Route path="servers" element={<Servers />} />
    <Route path="alerts" element={<Alerts />} />
    {/* 更多路由 */}
  </Route>
</Routes>

10.7 数据可视化组件:Canvas 图表

10.7.1 为什么用 Canvas 而非 SVG 图表库

项目中的图表组件使用原生 Canvas API 绘制,而不是 ECharts、Chart.js 等库。原因:

  1. 轻量:不需要引入几十 KB 的图表库
  2. 定制:完全控制绘制细节(渐变、动画、主题)
  3. 性能:大数据量下 Canvas 性能优于 SVG

10.7.2 AnimatedBarChart 柱状图

typescript
// frontend/src/components/AnimatedBarChart.tsx
interface AnimatedBarChartProps {
  data: Array<{ label: string; value: number; color: string }>;
  height?: number;
  maxValue?: number;
}

export default function AnimatedBarChart({
  data, height = 200, maxValue,
}: AnimatedBarChartProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || data.length === 0) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    // 1. 高 DPI 适配
    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.getBoundingClientRect();
    canvas.width = rect.width * dpr;
    canvas.height = height * dpr;
    ctx.scale(dpr, dpr);

    const width = rect.width;
    const padding = { top: 20, right: 20, bottom: 40, left: 40 };
    const chartWidth = width - padding.left - padding.right;
    const chartHeight = height - padding.top - padding.bottom;

    ctx.clearRect(0, 0, width, height);

    const max = maxValue || Math.max(...data.map(d => d.value)) * 1.1;
    const barWidth = (chartWidth / data.length) * 0.6;
    const gap = (chartWidth / data.length) * 0.4;

    // 2. 绘制每个柱形
    data.forEach((item, index) => {
      const barHeight = (item.value / max) * chartHeight;
      const x = padding.left + index * (barWidth + gap) + gap / 2;
      const y = padding.top + chartHeight - barHeight;

      // 渐变填充
      const gradient = ctx.createLinearGradient(x, y, x, padding.top + chartHeight);
      gradient.addColorStop(0, item.color);
      gradient.addColorStop(1, item.color + '40');

      // 圆角柱形
      const radius = 4;
      ctx.beginPath();
      ctx.moveTo(x + radius, y);
      ctx.lineTo(x + barWidth - radius, y);
      ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + radius);
      ctx.lineTo(x + barWidth, padding.top + chartHeight);
      ctx.lineTo(x, padding.top + chartHeight);
      ctx.lineTo(x, y + radius);
      ctx.quadraticCurveTo(x, y, x + radius, y);
      ctx.fillStyle = gradient;
      ctx.fill();

      // 标签
      ctx.font = '11px Inter, system-ui, sans-serif';
      ctx.fillStyle = '#94a3b8';
      ctx.textAlign = 'center';
      ctx.fillText(item.label, x + barWidth / 2, padding.top + chartHeight + 20);

      // 数值
      ctx.fillStyle = '#ffffff';
      ctx.fillText(item.value.toFixed(0), x + barWidth / 2, y - 8);
    });

    // 3. 基线
    ctx.beginPath();
    ctx.moveTo(padding.left, padding.top + chartHeight);
    ctx.lineTo(width - padding.right, padding.top + chartHeight);
    ctx.strokeStyle = 'rgba(51, 65, 85, 0.5)';
    ctx.lineWidth = 1;
    ctx.stroke();
  }, [data, height, maxValue]);

  return (
    <canvas ref={canvasRef} className="w-full" style={<!-- -->{ height }<!-- -->} />
  );
}

10.7.3 AnimatedLineChart 折线图

typescript
// frontend/src/components/AnimatedLineChart.tsx
interface DataPoint {
  timestamp: number;
  value: number;
}

interface AnimatedLineChartProps {
  data: DataPoint[];
  color?: string;
  height?: number;
  showArea?: boolean;
  lineWidth?: number;
}

export default function AnimatedLineChart({
  data, color = '#3b82f6', height = 200,
  showArea = true, lineWidth = 2,
}: AnimatedLineChartProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || data.length === 0) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    // 高 DPI 适配
    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.getBoundingClientRect();
    canvas.width = rect.width * dpr;
    canvas.height = height * dpr;
    ctx.scale(dpr, dpr);

    const width = rect.width;
    const padding = { top: 20, right: 20, bottom: 30, left: 40 };
    const chartWidth = width - padding.left - padding.right;
    const chartHeight = height - padding.top - padding.bottom;

    ctx.clearRect(0, 0, width, height);

    const maxValue = Math.max(...data.map(d => d.value)) * 1.1;
    const minValue = Math.min(0, Math.min(...data.map(d => d.value)));
    const range = maxValue - minValue;

    // 坐标映射函数
    const getX = (index: number) => 
      padding.left + (index / (data.length - 1)) * chartWidth;
    const getY = (value: number) => 
      padding.top + chartHeight - ((value - minValue) / range) * chartHeight;

    // 贝塞尔曲线平滑
    ctx.beginPath();
    ctx.moveTo(getX(0), getY(data[0].value));
    for (let i = 1; i < data.length; i++) {
      const prevX = getX(i - 1);
      const prevY = getY(data[i - 1].value);
      const currX = getX(i);
      const currY = getY(data[i].value);
      const cpX = (prevX + currX) / 2;
      ctx.bezierCurveTo(cpX, prevY, cpX, currY, currX, currY);
    }

    // 面积填充
    if (showArea) {
      const gradient = ctx.createLinearGradient(0, padding.top, 0, padding.top + chartHeight);
      gradient.addColorStop(0, color + '40');
      gradient.addColorStop(0.5, color + '20');
      gradient.addColorStop(1, color + '00');

      ctx.lineTo(getX(data.length - 1), padding.top + chartHeight);
      ctx.lineTo(getX(0), padding.top + chartHeight);
      ctx.closePath();
      ctx.fillStyle = gradient;
      ctx.fill();
    }

    // 线条
    ctx.beginPath();
    ctx.moveTo(getX(0), getY(data[0].value));
    for (let i = 1; i < data.length; i++) {
      const prevX = getX(i - 1);
      const prevY = getY(data[i - 1].value);
      const currX = getX(i);
      const currY = getY(data[i].value);
      const cpX = (prevX + currX) / 2;
      ctx.bezierCurveTo(cpX, prevY, cpX, currY, currX, currY);
    }
    ctx.strokeStyle = color;
    ctx.lineWidth = lineWidth;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    ctx.stroke();

    // 末端光点
    if (data.length > 0) {
      const lastX = getX(data.length - 1);
      const lastY = getY(data[data.length - 1].value);

      const gradient = ctx.createRadialGradient(lastX, lastY, 0, lastX, lastY, 8);
      gradient.addColorStop(0, color);
      gradient.addColorStop(1, color + '00');

      ctx.beginPath();
      ctx.arc(lastX, lastY, 8, 0, Math.PI * 2);
      ctx.fillStyle = gradient;
      ctx.fill();

      ctx.beginPath();
      ctx.arc(lastX, lastY, 4, 0, Math.PI * 2);
      ctx.fillStyle = '#ffffff';
      ctx.fill();
    }

    // Y 轴刻度
    ctx.font = '11px Inter, system-ui, sans-serif';
    ctx.fillStyle = '#94a3b8';
    ctx.textAlign = 'right';
    for (let i = 0; i <= 4; i++) {
      const value = minValue + (range / 4) * i;
      const y = getY(value);
      ctx.fillText(value.toFixed(0), padding.left - 8, y + 4);

      ctx.beginPath();
      ctx.moveTo(padding.left, y);
      ctx.lineTo(width - padding.right, y);
      ctx.strokeStyle = 'rgba(51, 65, 85, 0.3)';
      ctx.lineWidth = 1;
      ctx.stroke();
    }
  }, [data, color, height, showArea, lineWidth]);

  return (
    <canvas ref={canvasRef} className="w-full" style={<!-- -->{ height }<!-- -->} />
  );
}

10.7.4 Canvas 绘图关键概念

Canvas 坐标系:

  (0,0) ────────────────────────► x




    y

高 DPI 适配流程:
  1. canvas.width = rect.width * dpr    ← 物理像素
  2. canvas.height = height * dpr
  3. ctx.scale(dpr, dpr)                 ← 缩放坐标系
  4. CSS width/height 保持逻辑尺寸        ← display size

圆角绘制:
  ┌──────┐
  │      │  四角使用 quadraticCurveTo 实现
  │      │  ctx.quadraticCurveTo(cpX, cpY, endX, endY)
  │      │
  └──────┘

贝塞尔曲线平滑:
  P0 ──────── CP1 ──── CP2 ──────── P1
  
  ctx.bezierCurveTo(cp1X, cp1Y, cp2X, cp2Y, endX, endY)
  
  CP = 两个数据点的中点 X, 保持 Y 不变
  产生平滑的 S 形曲线

10.8 组件组合模式

10.8.1 Props 传递模式

最基本的组件通信方式,父组件通过 props 向子组件传递数据:

typescript
// 父组件
function Dashboard() {
  const { data: alerts } = useQuery({
    queryKey: ['alerts'],
    queryFn: () => api.get('/api/alerts').then(r => r.data.data)
  });

  return <AlertList alerts={alerts} onAck={handleAck} />;
}

// 子组件
function AlertList({ alerts, onAck }: { 
  alerts: Alert[]; 
  onAck: (id: string) => void 
}) {
  return alerts.map(a => <AlertItem key={a.id} alert={a} onAck={onAck} />);
}

10.8.2 Context 模式

跨层级共享状态,避免 props drilling:

typescript
// contexts/AuthContext.tsx
interface AuthContextType {
  user: User | null;
  token: string | null;
  loading: boolean;
  login: (username: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [token, setToken] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  // 初始化时从 localStorage 恢复 token
  useEffect(() => {
    const savedToken = localStorage.getItem('token');
    if (savedToken) {
      setToken(savedToken);
      api.defaults.headers.common['Authorization'] = `Bearer ${savedToken}`;
    }
    setLoading(false);
  }, []);

  const login = async (username: string, password: string) => {
    const res = await api.post('/api/auth/login', { username, password });
    if (res.data.success) {
      const { token, user } = res.data.data;
      setToken(token);
      setUser(user);
      localStorage.setItem('token', token);
      api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    }
  };

  const logout = () => {
    setToken(null);
    setUser(null);
    localStorage.removeItem('token');
    delete api.defaults.headers.common['Authorization'];
  };

  return (
    <AuthContext.Provider value={<!-- -->{ user, token, loading, login, logout }<!-- -->}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error('useAuth must be used within AuthProvider');
  return context;
}

Context 使用场景

适合不适合
认证状态频繁变化的数据(用 state)
主题配置只有父子两层的数据传递
多语言需要精细性能控制的场景
全局配置大量不相关状态(拆多个 Context)

10.8.3 组合组件模式 (Composition)

通过 children prop 实现灵活的组件嵌套:

typescript
// Layout 组件使用组合模式
function Layout() {
  return (
    <div className="flex h-screen">
      <Sidebar />
      <main className="flex-1 overflow-hidden">
        <Outlet />  {/* 子页面通过 children 注入 */}
      </main>
      <ChatWidget />
    </div>
  );
}

// ErrorBoundary 使用组合模式
<ErrorBoundary>
  <AnyComponent />  {/* 任意子组件 */}
</ErrorBoundary>

10.8.4 渲染 Props 模式

typescript
// 带加载状态的高阶组件
function withLoading<P extends object>(
  Component: React.ComponentType<P>
) {
  return function WithLoadingComponent(
    props: P & { isLoading: boolean }
  ) {
    const { isLoading, ...rest } = props;
    if (isLoading) {
      return <div className="animate-pulse">加载中...</div>;
    }
    return <Component {...(rest as P)} />;
  };
}

// 使用
const ServerListWithLoading = withLoading(ServerList);
<ServerListWithLoading data={data} isLoading={isLoading} />;

10.9 表单组件模式

10.9.1 受控组件模式

项目中的表单主要使用 React 受控组件:

typescript
function ServerForm() {
  const [formData, setFormData] = useState({
    name: '',
    hostname: '',
    port: 22,
    username: '',
    password: '',
    description: '',
  });

  const handleChange = (field: string, value: string | number) => {
    setFormData(prev => ({ ...prev, [field]: value }));
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await api.post('/api/servers', formData);
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label className="block text-sm font-medium text-text-secondary mb-2">
          服务器名称
        </label>
        <input
          type="text"
          value={formData.name}
          onChange={(e) => handleChange('name', e.target.value)}
          className="w-full px-3 py-2 rounded-lg bg-background border 
                     border-border focus:border-primary focus:outline-none"
          required
        />
      </div>

      <div>
        <label className="block text-sm font-medium text-text-secondary mb-2">
          主机名
        </label>
        <input
          type="text"
          value={formData.hostname}
          onChange={(e) => handleChange('hostname', e.target.value)}
          className="w-full px-3 py-2 rounded-lg bg-background border 
                     border-border focus:border-primary focus:outline-none"
          required
        />
      </div>

      <div>
        <label className="block text-sm font-medium text-text-secondary mb-2">
          端口
        </label>
        <input
          type="number"
          value={formData.port}
          onChange={(e) => handleChange('port', parseInt(e.target.value))}
          className="w-full px-3 py-2 rounded-lg bg-background border 
                     border-border focus:border-primary focus:outline-none"
          min={1}
          max={65535}
        />
      </div>

      <button
        type="submit"
        className="px-4 py-2 bg-primary text-white rounded-lg 
                   hover:bg-primary/90 transition-colors"
      >
        创建服务器
      </button>
    </form>
  );
}

10.9.2 表单验证模式

typescript
function UserForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});

  const validate = () => {
    const newErrors: Record<string, string> = {};

    if (!formData.username.trim()) {
      newErrors.username = '用户名不能为空';
    } else if (formData.username.length < 2) {
      newErrors.username = '用户名至少2个字符';
    }

    if (!formData.email.trim()) {
      newErrors.email = '邮箱不能为空';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
      newErrors.email = '邮箱格式不正确';
    }

    if (!formData.password) {
      newErrors.password = '密码不能为空';
    } else if (formData.password.length < 8) {
      newErrors.password = '密码至少8个字符';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!validate()) return;

    await api.post('/api/users', formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      {Object.entries(errors).map(([field, msg]) => (
        <div key={field} className="text-red-500 text-xs mt-1">{msg}</div>
      ))}
    </form>
  );
}

10.9.3 表单状态机图解

初始状态 → 用户输入 → 提交 → 验证
                               ├── 失败 → 显示错误 → 修改 → 重新提交
                               └── 成功 → 提交 API → 完成
                                              ├── 成功 → 刷新列表 → 关闭表单
                                              └── 失败 → 显示错误 → 修改

10.10 加载状态与用户体验

10.10.1 加载状态模式

项目中使用多种加载状态模式:

typescript
// 模式一: 骨架屏(加载占位)
function ServerList() {
  const { data, isLoading } = useQuery({ ... });

  if (isLoading) {
    return (
      <div className="space-y-4 animate-pulse">
        {[1, 2, 3].map(i => (
          <div key={i} className="h-16 bg-slate-800 rounded-lg" />
        ))}
      </div>
    );
  }

  return <div>{data?.map(...)}</div>;
}

// 模式二: Loading Spinner
function LoadingSpinner() {
  return (
    <div className="flex items-center justify-center">
      <div className="animate-spin rounded-full h-8 w-8 border-t-2 
                      border-b-2 border-blue-500" />
    </div>
  );
}

// 模式三: 按钮加载状态
<button
  onClick={handleSave}
  disabled={saveMutation.isPending}
  className="px-4 py-2 bg-primary text-white rounded-lg"
>
  {saveMutation.isPending ? '保存中...' : '保存'}
</button>

10.10.2 空状态处理

typescript
function AlertList({ alerts }: { alerts: Alert[] }) {
  if (alerts.length === 0) {
    return (
      <div className="flex flex-col items-center justify-center py-16">
        <Bell className="w-16 h-16 text-slate-600 mb-4" />
        <h3 className="text-lg font-semibold text-white mb-2">暂无告警</h3>
        <p className="text-slate-400 text-sm">
          当前没有活跃的告警,系统运行正常
        </p>
      </div>
    );
  }

  return alerts.map(alert => <AlertItem key={alert.id} alert={alert} />);
}

10.10.3 状态枚举总结

组件状态矩阵:

加载状态:    isLoading | isFetching | isPending
数据状态:    data      | undefined  | null
错误状态:    isError   | error
空状态:      data?.length === 0

最佳渲染逻辑:

if (isLoading)          → 骨架屏
if (isError)            → 错误页面
if (!data || data.length === 0) → 空状态
else                    → 正常渲染

本章小结

本章系统讲解了项目的组件开发实践:

  1. Layout 组件:展示了配置化导航、React Query 轮询、clsx 条件样式、NavLink 激活状态
  2. WebTerminal 组件:展示了 xterm.js 集成、WebSocket 连接管理、指数退避重连、Ref 管理
  3. ChatWidget 组件:展示了 React Query + Mutation 模式、三状态切换(展开/最小化/关闭)、双栏布局
  4. ErrorBoundary:展示了 Class 组件错误捕获、降级 UI
  5. ProtectedRoute:展示了路由守卫模式
  6. Canvas 图表:展示了原生 Canvas API 绘制柱状图和折线图,包含高 DPI 适配、贝塞尔曲线平滑、渐变填充
  7. 组件组合模式:展示了 Props 传递、Context、组合组件、渲染 Props
  8. 表单模式:展示了受控组件、表单验证、状态机
  9. 加载状态:展示了骨架屏、Loading Spinner、按钮加载状态、空状态

核心原则:Props 向下 Flow,状态就近管理,UI 与逻辑分离,错误有兜底,加载有反馈。


本章练习

基础练习

  1. 创建 CircularProgress 环形进度条组件:实现一个 Canvas 绘制的环形进度条组件,接受 percentage(0-100)、sizecolor 三个 props。使用 stroke-dasharraystroke-dashoffset 实现动画效果

  2. 创建 MarkdownOutput 组件:实现一个将 Markdown 渲染为 HTML 的组件,使用 marked 库解析 Markdown,用 DOMPurify 做 XSS 防护。支持代码块高亮

  3. 扩展 Layout 导航权限:在 Layout 组件中实现基于用户角色的导航过滤。viewer 角色不显示"用户管理"和"设置"菜单

进阶练习

  1. 创建通用的 DataTable 组件:实现一个可复用的数据表格组件,支持排序、分页、列配置、行选择。参考项目的 Servers 页面实现

  2. 创建 Toast 通知组件:实现一个全局的 Toast 通知系统,支持 success/error/warning/info 四种类型,自动消失,多个 Toast 堆叠显示。使用 Context + Portal 实现

  3. 为 Canvas 图表添加动画:为 AnimatedBarChart 添加进入动画(柱子从底部向上生长),使用 requestAnimationFrame 和缓动函数实现

思考题

  1. WebTerminal 组件中使用了大量 Ref 而不是 State。在什么情况下应该使用 Ref?如果在重连逻辑中使用了 setState 更新 reconnectCount,会产生什么问题?

  2. ErrorBoundary 为什么不使用函数组件实现?React 19 中有什么替代方案?

  3. Canvas 图表组件使用 useEffect + 命令式 Canvas API 绘制。如果用 SVG 实现会有什么优缺点?在数据量达到 10000 条时会出现什么问题?


延伸阅读

基于 MPL-2.0 许可证发布