第10章 组件开发实战
作者
谭策 — 独立开发者 | AIOps 领域探索者
- 🌐 项目官网:ITOpsAgentinfo
- 📝 博客:zjzwfw.cloud
- 📧 邮箱:huawei_network@foxmail.com
- 💬 微信公众号: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 18 | UI 框架 | ^18.x |
| React Router v6 | 路由管理 | v6 |
| TanStack React Query | 数据获取与缓存 | v5 |
| Tailwind CSS | 样式系统 | v3 |
| Lucide React | 图标库 | 最新版 |
| XTerm.js | 终端渲染 | ^5.x |
| Socket.IO Client | WebSocket | v4 |
| 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 完整实现分析
// 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,好处是:
- 数据与展示分离
- 可轻松实现基于权限的动态导航过滤
- 新增菜单只需添加一行配置
// 动态过滤导航(示例:根据角色显示不同菜单)
const filteredNavigation = navigation.filter(item => {
if (user.role === 'viewer') {
return !['/users', '/settings'].includes(item.href);
}
return true;
});10.2.3 侧边栏布局实现
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 激活状态:
<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 可以接收一个函数,参数包含 isActive、isPending 等状态,实现动态样式切换。
2. React Query 轮询优化:
useQuery({
queryKey: ['agents-count'],
queryFn: ...,
refetchInterval: 60000, // 定时轮询间隔
staleTime: 5 * 60 * 1000, // 新鲜数据时间
});| 配置项 | 含义 | 效果 |
|---|---|---|
refetchInterval: 60000 | 每 60 秒自动重新请求 | 实现实时数据刷新 |
staleTime: 5*60*1000 | 5 分钟内数据视为新鲜 | 避免组件切换时重复请求 |
3. clsx 条件类名:
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 接口
interface TerminalProps {
serverId: string; // 目标服务器 ID
serverName: string; // 服务器显示名称
token: string; // JWT Token(用于 WebSocket 认证)
onClose: () => void; // 关闭回调
}10.3.3 Ref 管理
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 终端初始化
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 连接与重连
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 清理逻辑
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 状态管理
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 模式
// 查询:获取对话建议
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 消息发送逻辑
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 最小化状态处理
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 组件,因为函数组件不支持 componentDidCatch 和 getDerivedStateFromError 生命周期:
// 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 使用方式
// App.tsx 中使用
<ErrorBoundary>
<BrowserRouter>
<Routes>
{/* 所有路由 */}
</Routes>
</BrowserRouter>
</ErrorBoundary>
// 也可以在单个组件周围使用
<ErrorBoundary>
<ComplexChart />
</ErrorBoundary>错误边界捕获范围:
捕获:
✓ 子组件渲染时的错误
✓ 子组件生命周期方法中的错误
✓ 子组件构造函数中的错误
不捕获:
✗ 事件处理器中的错误 (用 try-catch)
✗ 异步代码中的错误 (Promise.reject)
✗ 服务端渲染错误
✗ 错误边界自身的错误10.6 ProtectedRoute 组件:路由守卫
10.6.1 组件职责
ProtectedRoute 保护需要认证的路由,未登录用户自动跳转到登录页:
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 在路由中使用
// 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 等库。原因:
- 轻量:不需要引入几十 KB 的图表库
- 定制:完全控制绘制细节(渐变、动画、主题)
- 性能:大数据量下 Canvas 性能优于 SVG
10.7.2 AnimatedBarChart 柱状图
// 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 折线图
// 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 向子组件传递数据:
// 父组件
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:
// 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 实现灵活的组件嵌套:
// 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 模式
// 带加载状态的高阶组件
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 受控组件:
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 表单验证模式
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 加载状态模式
项目中使用多种加载状态模式:
// 模式一: 骨架屏(加载占位)
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 空状态处理
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 → 正常渲染本章小结
本章系统讲解了项目的组件开发实践:
- Layout 组件:展示了配置化导航、React Query 轮询、clsx 条件样式、NavLink 激活状态
- WebTerminal 组件:展示了 xterm.js 集成、WebSocket 连接管理、指数退避重连、Ref 管理
- ChatWidget 组件:展示了 React Query + Mutation 模式、三状态切换(展开/最小化/关闭)、双栏布局
- ErrorBoundary:展示了 Class 组件错误捕获、降级 UI
- ProtectedRoute:展示了路由守卫模式
- Canvas 图表:展示了原生 Canvas API 绘制柱状图和折线图,包含高 DPI 适配、贝塞尔曲线平滑、渐变填充
- 组件组合模式:展示了 Props 传递、Context、组合组件、渲染 Props
- 表单模式:展示了受控组件、表单验证、状态机
- 加载状态:展示了骨架屏、Loading Spinner、按钮加载状态、空状态
核心原则:Props 向下 Flow,状态就近管理,UI 与逻辑分离,错误有兜底,加载有反馈。
本章练习
基础练习
创建 CircularProgress 环形进度条组件:实现一个 Canvas 绘制的环形进度条组件,接受
percentage(0-100)、size、color三个 props。使用stroke-dasharray和stroke-dashoffset实现动画效果创建 MarkdownOutput 组件:实现一个将 Markdown 渲染为 HTML 的组件,使用
marked库解析 Markdown,用DOMPurify做 XSS 防护。支持代码块高亮扩展 Layout 导航权限:在 Layout 组件中实现基于用户角色的导航过滤。
viewer角色不显示"用户管理"和"设置"菜单
进阶练习
创建通用的 DataTable 组件:实现一个可复用的数据表格组件,支持排序、分页、列配置、行选择。参考项目的 Servers 页面实现
创建 Toast 通知组件:实现一个全局的 Toast 通知系统,支持 success/error/warning/info 四种类型,自动消失,多个 Toast 堆叠显示。使用 Context + Portal 实现
为 Canvas 图表添加动画:为 AnimatedBarChart 添加进入动画(柱子从底部向上生长),使用
requestAnimationFrame和缓动函数实现
思考题
WebTerminal 组件中使用了大量 Ref 而不是 State。在什么情况下应该使用 Ref?如果在重连逻辑中使用了
setState更新reconnectCount,会产生什么问题?ErrorBoundary 为什么不使用函数组件实现?React 19 中有什么替代方案?
Canvas 图表组件使用
useEffect+ 命令式 Canvas API 绘制。如果用 SVG 实现会有什么优缺点?在数据量达到 10000 条时会出现什么问题?
