第7章 前端开发基础
作者
谭策 — 独立开发者 | AIOps 领域探索者
- 🌐 项目官网:ITOpsAgentinfo
- 📝 博客:zjzwfw.cloud
- 📧 邮箱:huawei_network@foxmail.com
- 💬 微信公众号:IT Online

许可证
MPL-2.0 © 谭策
本章导读
在上一章中,我们学习了后端开发的完整流程。本章将进入前端开发,学习如何使用 React 构建用户界面。本章涵盖组件开发、样式编写、API 调用、状态管理等前端开发核心技能。
本章定位:这是你第一次真正动手写前端代码。我们会按照项目的实际编码风格来写,让你写出的代码能直接融入项目。
学习目标
阅读完本章后,你将能够:
- 独立创建新的页面组件并配置路由
- 编写可复用的通用组件
- 使用 Tailwind CSS 编写响应式样式
- 通过 API 客户端与后端通信
- 使用 React Query 管理服务器数据
- 使用 Zustand 管理全局 UI 状态
- 实现路由保护和认证状态管理
- 处理表单输入和表单验证
7.1 前端开发环境
7.1.1 启动前端开发服务器
# 进入前端目录
cd frontend
# 安装依赖(首次)
npm install
# 启动开发模式(带热重载)
npm run dev看到以下输出表示启动成功:
VITE v5.0.8 ready in 520 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose7.1.2 开发模式特点
| 特性 | 说明 |
|---|---|
| 热模块替换(HMR) | 修改代码后自动刷新,保持组件状态 |
| 按需编译 | 只编译当前访问的模块,启动速度极快 |
| TypeScript 支持 | 实时类型检查,错误在终端显示 |
| 代理配置 | /api 请求自动转发到后端 localhost:3001 |
7.2 创建第一个页面组件
7.2.1 页面组件结构
以创建一个 备忘录页面 为例:
步骤 1:创建页面文件
touch frontend/src/pages/Notes.tsx步骤 2:编写页面代码
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../lib/api';
// 定义数据类型
interface Note {
id: string;
title: string;
content: string;
created_at: string;
updated_at: string;
}
export default function Notes() {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
// 查询备忘录列表
const { data: notes, isLoading } = useQuery({
queryKey: ['notes'],
queryFn: async () => {
const res = await api.get('/notes');
return res.data.data as Note[];
}
});
// 创建备忘录
const createMutation = useMutation({
mutationFn: async (data: { title: string; content: string }) => {
const res = await api.post('/notes', data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notes'] });
setShowForm(false);
setTitle('');
setContent('');
}
});
// 删除备忘录
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const res = await api.delete(`/notes/${id}`);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notes'] });
}
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
createMutation.mutate({ title, content });
};
if (isLoading) {
return <div className="p-6 text-gray-500">加载中...</div>;
}
return (
<div className="p-6 space-y-6">
{/* 页面标题 + 添加按钮 */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">备忘录</h1>
<button
onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{showForm ? '取消' : '添加备忘录'}
</button>
</div>
{/* 添加表单 */}
{showForm && (
<form onSubmit={handleSubmit} className="bg-slate-800/50 rounded-lg p-4 space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">标题</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-slate-700/50 text-white rounded-lg px-3 py-2
border border-slate-600 focus:border-blue-500 focus:outline-none"
placeholder="请输入标题"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">内容</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full bg-slate-700/50 text-white rounded-lg px-3 py-2
border border-slate-600 focus:border-blue-500 focus:outline-none
min-h-[100px] resize-y"
placeholder="请输入内容"
/>
</div>
<button
type="submit"
disabled={createMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700
transition-colors disabled:opacity-50"
>
{createMutation.isPending ? '保存中...' : '保存'}
</button>
</form>
)}
{/* 备忘录列表 */}
<div className="space-y-3">
{notes?.map((note) => (
<div
key={note.id}
className="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50
hover:border-slate-600 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-white">{note.title}</h3>
<p className="text-sm text-slate-400 mt-1">{note.content}</p>
<p className="text-xs text-slate-500 mt-2">
{new Date(note.created_at).toLocaleString('zh-CN')}
</p>
</div>
<button
onClick={() => deleteMutation.mutate(note.id)}
className="px-3 py-1 text-red-400 hover:bg-red-500/10 rounded-lg
transition-colors text-sm"
>
删除
</button>
</div>
</div>
))}
{notes?.length === 0 && (
<div className="text-center py-12 text-slate-500">
暂无备忘录,点击上方按钮添加第一条
</div>
)}
</div>
</div>
);
}步骤 3:配置路由
在 App.tsx 中添加路由:
import Notes from './pages/Notes';
// 在 Layout 内部添加
<Route path="notes" element={<Notes />} />7.2.2 页面组件结构图
Notes 页面
├── 页面标题栏
│ ├── "备忘录" 标题
│ └── "添加备忘录" 按钮
│
├── 添加表单(点击按钮后显示)
│ ├── 标题输入框
│ ├── 内容文本域
│ └── 保存按钮
│
└── 备忘录列表
├── 卡片1:标题 + 内容 + 时间 + 删除按钮
├── 卡片2:...
└── 空状态提示(当列表为空时)7.3 通用组件开发
7.3.1 什么时候需要通用组件?
当一个 UI 元素在多个页面中重复出现时,就应该提取为通用组件。
| 重复 UI | 提取为组件 | 使用场景 |
|---|---|---|
| 状态标签 | StatusBadge | 服务器状态、任务状态 |
| 加载动画 | LoadingSpinner | 所有异步操作 |
| 空状态提示 | EmptyState | 列表为空时 |
| 确认对话框 | ConfirmDialog | 删除操作 |
7.3.2 通用组件示例:状态徽章
// frontend/src/components/StatusBadge.tsx
import clsx from 'clsx';
interface StatusBadgeProps {
status: 'online' | 'offline' | 'warning' | 'error';
label?: string;
}
const statusConfig = {
online: {
color: 'bg-green-500',
text: '在线',
bg: 'bg-green-500/10 text-green-400 border-green-500/30'
},
offline: {
color: 'bg-gray-500',
text: '离线',
bg: 'bg-gray-500/10 text-gray-400 border-gray-500/30'
},
warning: {
color: 'bg-yellow-500',
text: '警告',
bg: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/30'
},
error: {
color: 'bg-red-500',
text: '错误',
bg: 'bg-red-500/10 text-red-400 border-red-500/30'
}
};
export default function StatusBadge({ status, label }: StatusBadgeProps) {
const config = statusConfig[status];
return (
<span className={clsx(
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border',
config.bg
)}>
<span className={clsx('w-1.5 h-1.5 rounded-full', config.color)} />
{label || config.text}
</span>
);
}使用方式:
import StatusBadge from '../components/StatusBadge';
function ServerCard({ server }) {
return (
<div>
<h3>{server.name}</h3>
<StatusBadge status={server.status} />
{/* 显示 "在线"、"离线"、"警告"、"错误" */}
</div>
);
}7.3.3 通用组件示例:空状态
// frontend/src/components/EmptyState.tsx
import { Inbox } from 'lucide-react';
interface EmptyStateProps {
title?: string;
description?: string;
action?: React.ReactNode;
}
export default function EmptyState({
title = '暂无数据',
description = '当前没有任何记录',
action
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
<Inbox className="w-16 h-16 mb-4 opacity-50" />
<h3 className="text-lg font-medium text-slate-400">{title}</h3>
<p className="text-sm mt-1">{description}</p>
{action && <div className="mt-4">{action}</div>}
</div>
);
}7.4 Tailwind CSS 实战
7.4.1 布局模式
项目中使用最多的几种布局模式:
1. 垂直堆叠布局
<div className="space-y-4"> {/* 子元素间距 1rem */}
<div>项目 1</div>
<div>项目 2</div>
<div>项目 3</div>
</div>2. 响应式网格布局
<div className="grid
grid-cols-1 {/* 手机:1 列 */}
md:grid-cols-2 {/* 平板:2 列 */}
lg:grid-cols-4 {/* 桌面:4 列 */}
gap-6"> {/* 间距 1.5rem */}
{servers.map(server => (
<ServerCard key={server.id} server={server} />
))}
</div>3. Flex 布局
{/* 两端对齐 */}
<div className="flex items-center justify-between">
<h1>标题</h1>
<button>操作</button>
</div>
{/* 水平居中排列 */}
<div className="flex items-center gap-3">
<Icon />
<span>文本</span>
<Badge />
</div>
{/* 垂直堆叠居中 */}
<div className="flex flex-col items-center justify-center">
<Image />
<Text />
</div>7.4.2 项目中的实际样式示例
项目使用深色主题 + 渐变 + 半透明背景的设计风格:
// 侧边栏样式
<aside className="
w-56 {/* 固定宽度 */}
bg-gradient-to-b {/* 垂直渐变 */}
from-slate-900/95 {/* 顶部:95% 透明度的深色 */}
via-slate-900/90 {/* 中间:90% 透明度的深色 */}
to-slate-950/95 {/* 底部:95% 透明的更深色 */}
border-r border-slate-700/50 {/* 右边框,50% 透明度 */}
flex flex-col {/* 垂直弹性布局 */}
backdrop-blur-xl {/* 背景模糊 */}
shadow-2xl {/* 大阴影 */}
">7.4.3 交互状态样式
<button className="
px-4 py-2
bg-blue-600 {/* 默认背景色 */}
text-white {/* 文字颜色 */}
rounded-lg {/* 圆角 */}
hover:bg-blue-700 {/* 鼠标悬停时 */}
active:scale-95 {/* 点击时缩小 */}
disabled:opacity-50 {/* 禁用时半透明 */}
transition-all duration-200 {/* 过渡动画 */}
">
按钮
</button>7.4.4 条件样式(使用 clsx)
项目中广泛使用 clsx 库来组合条件类名:
import clsx from 'clsx';
function NavItem({ isActive, label }: { isActive: boolean; label: string }) {
return (
<div className={clsx(
'px-3 py-2 rounded-lg text-sm font-medium transition-all',
isActive
? 'bg-blue-600 text-white shadow-lg' // 激活状态
: 'text-slate-400 hover:bg-slate-800' // 非激活状态
)}>
{label}
</div>
);
}7.5 API 调用实战
7.5.1 API 客户端封装
项目使用 Axios 封装了统一的 API 客户端:
// frontend/src/lib/api.ts
import axios from 'axios';
const api = axios.create({
baseURL: '/api', // 所有请求都加 /api 前缀
timeout: 30000, // 30 秒超时
});
// 请求拦截器:自动附加 Token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:统一处理错误
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token 过期,清除并跳转登录
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error.response?.data || error);
}
);
export default api;7.5.2 API 调用方式
项目中有三种 API 调用方式:
方式 1:直接使用 api 对象(简单场景)
import api from '../lib/api';
async function fetchServers() {
const res = await api.get('/servers');
return res.data.data;
}
async function createServer(data: CreateServerInput) {
const res = await api.post('/servers', data);
return res.data;
}方式 2:使用 React Query(推荐,大多数场景)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../lib/api';
function ServerList() {
const queryClient = useQueryClient();
// 查询
const { data: servers, isLoading } = useQuery({
queryKey: ['servers'],
queryFn: () => api.get('/servers').then(res => res.data.data)
});
// 创建
const createMutation = useMutation({
mutationFn: (data: CreateServerInput) =>
api.post('/servers', data).then(res => res.data),
onSuccess: () => {
// 使缓存失效,自动重新请求
queryClient.invalidateQueries({ queryKey: ['servers'] });
}
});
// 删除
const deleteMutation = useMutation({
mutationFn: (id: string) =>
api.delete(`/servers/${id}`).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['servers'] });
}
});
}方式 3:在 Layout 中查询统计信息
// Layout.tsx 中统计 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 分钟内缓存有效
});7.5.3 常见的 API 调用模式
| 场景 | 使用 Hook | 说明 |
|---|---|---|
| 列表查询 | useQuery | 获取数据,自动缓存 |
| 详情查询 | useQuery | 带参数的查询 |
| 创建数据 | useMutation | POST 请求 |
| 更新数据 | useMutation | PUT/PATCH 请求 |
| 删除数据 | useMutation | DELETE 请求 |
| 上传文件 | useMutation | FormData 请求 |
| 轮询查询 | useQuery + refetchInterval | 定时自动刷新 |
7.6 路由配置
7.6.1 路由结构
项目使用 React Router v6 的嵌套路由:
// frontend/src/App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './contexts/AuthContext';
import Layout from './components/layout/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Servers from './pages/Servers';
import Agents from './pages/Agents';
import Workflows from './pages/Workflows';
import WorkflowEditor from './pages/WorkflowEditor';
import Alerts from './pages/Alerts';
import Tasks from './pages/Tasks';
import Reports from './pages/Reports';
import Knowledge from './pages/Knowledge';
import AuditLogs from './pages/AuditLogs';
import Settings from './pages/Settings';
import Users from './pages/Users';
import Scripts from './pages/Scripts';
import Notifications from './pages/Notifications';
import ScheduledTasks from './pages/ScheduledTasks';
import TerminalPage from './pages/TerminalPage';
import NotFound from './pages/NotFound';
import ProtectedRoute from './components/ProtectedRoute';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 2,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<Routes>
{/* 公开路由 */}
<Route path="/login" element={<Login />} />
{/* 受保护路由 - 需要登录 */}
<Route path="/" element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}>
<Route index element={<Dashboard />} />
<Route path="servers" element={<Servers />} />
<Route path="agents" element={<Agents />} />
<Route path="workflows" element={<Workflows />} />
<Route path="workflow-editor" element={<WorkflowEditor />} />
<Route path="alerts" element={<Alerts />} />
<Route path="tasks" element={<Tasks />} />
<Route path="reports" element={<Reports />} />
<Route path="knowledge" element={<Knowledge />} />
<Route path="terminal" element={<TerminalPage />} />
<Route path="audit" element={<AuditLogs />} />
<Route path="settings" element={<Settings />} />
<Route path="users" element={<Users />} />
<Route path="scripts" element={<Scripts />} />
<Route path="notifications" element={<Notifications />} />
<Route path="scheduled-tasks" element={<ScheduledTasks />} />
</Route>
{/* 404 页面 */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
);
}
export default App;7.6.2 路由守卫实现
// frontend/src/components/ProtectedRoute.tsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
// 未登录,重定向到登录页
return <Navigate to="/login" replace />;
}
return children;
}7.7 认证上下文
7.7.1 AuthContext 实现
项目使用 React Context 管理认证状态:
// frontend/src/contexts/AuthContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import api from '../lib/api';
interface User {
id: string;
username: string;
email: string;
role: string;
}
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
login: (token: string, user: User) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
// 初始化时从 localStorage 恢复用户信息
useEffect(() => {
const storedUser = localStorage.getItem('user');
const token = localStorage.getItem('token');
if (storedUser && token) {
setUser(JSON.parse(storedUser));
}
}, []);
const login = (token: string, user: User) => {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
setUser(user);
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setUser(null);
};
return (
<AuthContext.Provider value={<!-- -->{
user,
isAuthenticated: !!user,
login,
logout
}<!-- -->}>
{children}
</AuthContext.Provider>
);
}
// 自定义 Hook
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth 必须在 AuthProvider 内部使用');
}
return context;
}7.7.2 在组件中使用认证
import { useAuth } from '../contexts/AuthContext';
function UserProfile() {
const { user, logout } = useAuth();
return (
<div>
<p>用户名:{user?.username}</p>
<p>邮箱:{user?.email}</p>
<p>角色:{user?.role}</p>
<button onClick={logout}>退出登录</button>
</div>
);
}7.8 表单处理
7.8.1 基础表单
import { useState } from 'react';
function ServerForm() {
const [formData, setFormData] = useState({
name: '',
host: '',
port: 22,
username: '',
password: ''
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name === 'port' ? Number(value) : value
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('提交:', formData);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">服务器名称</label>
<input
name="name"
value={formData.name}
onChange={handleChange}
className="w-full bg-slate-700/50 text-white rounded-lg px-3 py-2
border border-slate-600 focus:border-blue-500 focus:outline-none"
placeholder="例如:生产服务器"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">服务器地址</label>
<input
name="host"
value={formData.host}
onChange={handleChange}
className="w-full bg-slate-700/50 text-white rounded-lg px-3 py-2
border border-slate-600 focus:border-blue-500 focus:outline-none"
placeholder="例如:192.168.1.1"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">端口</label>
<input
name="port"
type="number"
value={formData.port}
onChange={handleChange}
className="w-full bg-slate-700/50 text-white rounded-lg px-3 py-2
border border-slate-600 focus:border-blue-500 focus:outline-none"
/>
</div>
<button
type="submit"
className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700
transition-colors"
>
添加服务器
</button>
</form>
);
}7.8.2 表单验证
function ServerForm() {
const [formData, setFormData] = useState({ name: '', host: '', port: 22 });
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = () => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = '服务器名称不能为空';
}
if (!formData.host.trim()) {
newErrors.host = '服务器地址不能为空';
} else if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(formData.host)) {
newErrors.host = '请输入有效的 IP 地址';
}
if (formData.port < 1 || formData.port > 65535) {
newErrors.port = '端口号必须在 1-65535 之间';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
// 提交逻辑
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">服务器名称</label>
<input
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className={clsx(
'w-full bg-slate-700/50 text-white rounded-lg px-3 py-2 border focus:outline-none',
errors.name ? 'border-red-500' : 'border-slate-600 focus:border-blue-500'
)}
/>
{errors.name && (
<p className="text-xs text-red-400 mt-1">{errors.name}</p>
)}
</div>
{/* 更多字段... */}
</form>
);
}7.9 响应式设计
7.9.1 移动优先策略
Tailwind 使用移动优先策略:默认样式针对小屏幕,使用 md:、lg: 等断点前缀适配大屏幕。
<div className="
grid grid-cols-1 {/* 默认(手机):1 列 */}
sm:grid-cols-2 {/* >=640px:2 列 */}
md:grid-cols-3 {/* >=768px:3 列 */}
lg:grid-cols-4 {/* >=1024px:4 列 */}
gap-4
">
{items.map(item => (
<Card key={item.id} {...item} />
))}
</div>7.9.2 响应式布局示例
项目中的统计卡片布局:
<div className="p-6 space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard title="服务器总数" value={stats.serverCount} icon={Server} />
<StatCard title="Agent 数量" value={stats.agentCount} icon={Bot} />
<StatCard title="工作流数量" value={stats.workflowCount} icon={GitBranch} />
<StatCard title="告警数量" value={stats.alertCount} icon={Bell} />
</div>
{/* 图表区域 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ChartCard title="服务器状态分布" />
<ChartCard title="任务执行趋势" />
</div>
</div>7.10 布局组件详解
7.10.1 Layout 组件
项目的 Layout 组件是整个应用的框架,包含侧边栏、主内容区和 AI Copilot:
// frontend/src/components/layout/Layout.tsx 结构分析
export default function Layout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
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 ... flex flex-col">
{/* Logo 区域 */}
<div className="p-4 border-b ...">
<img src="/logo.jpg" alt="Logo" />
<h1>ITOps Agent</h1>
<p>多Agent自动化平台</p>
</div>
{/* 导航菜单 */}
<nav className="flex-1 p-3 space-y-0.5 overflow-y-auto">
{navigation.map((item) => (
<NavLink key={item.name} to={item.href}>
<item.icon className="w-4 h-4" />
{item.name}
</NavLink>
))}
</nav>
{/* 底部区域 */}
<div className="border-t ...">
{/* 用户信息 */}
<div className="p-3 ...">
<UserIcon />
<p>{user?.username}</p>
<p>{user?.role}</p>
</div>
{/* 系统状态 */}
<div className="p-3 ...">
<span>系统正常</span>
<p>{agentCount}个Agent · {workflowCount}个工作流</p>
</div>
{/* 退出按钮 */}
<button onClick={() => { logout(); navigate('/login'); }<!-- -->}>
<LogOut /> 退出登录
</button>
</div>
</aside>
{/* 主内容区 */}
<main className="flex-1 overflow-hidden">
<Outlet /> {/* 当前路由对应的页面组件 */}
</main>
{/* AI Copilot 聊天组件 */}
<ChatWidget />
</div>
);
}7.10.2 NavLink 组件
NavLink 是 React Router 提供的特殊 Link 组件,会自动根据当前 URL 添加激活状态:
import { NavLink } from 'react-router-dom';
import clsx from 'clsx';
<NavLink
to="/servers"
className={({ isActive }) =>
clsx(
'flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm font-medium transition-all',
isActive
? 'bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg'
: 'text-slate-400 hover:bg-slate-800/80 hover:text-white'
)
}
>
<Server className="w-4 h-4" />
服务器管理
</NavLink>7.11 错误边界
7.11.1 什么是错误边界?
错误边界是 React 组件,可以捕获子组件树中的 JavaScript 错误,防止整个应用崩溃。
// frontend/src/components/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: 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 };
}
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 this.props.fallback || (
<div className="p-6 bg-red-500/10 border border-red-500/30 rounded-lg">
<h2 className="text-lg font-bold text-red-400">出错了</h2>
<p className="text-sm text-red-300 mt-2">{this.state.error?.message}</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg"
>
重试
</button>
</div>
);
}
return this.props.children;
}
}本章小结
本章涵盖了前端开发的完整流程:
- 页面组件:创建页面、配置路由、处理加载和空状态
- 通用组件:提取可复用的 UI 元素(状态徽章、空状态等)
- Tailwind CSS:布局模式、深色主题、交互状态、条件样式
- API 调用:Axios 封装、React Query 使用、三种调用方式
- 路由配置:React Router v6、嵌套路由、路由守卫
- 认证管理:AuthContext、useAuth Hook、ProtectedRoute
- 表单处理:表单数据管理、表单验证
- 响应式设计:移动优先策略、断点适配
- 布局组件:Layout 框架、侧边栏导航、用户信息区
- 错误边界:防止应用崩溃,提供友好的错误提示
核心原则:组件化、可复用、响应式、类型安全。
本章练习
基础练习
创建备忘录页面:按照本章示例,完整实现
Notes.tsx页面,包括列表、创建、删除功能添加编辑功能:为备忘录页面添加编辑功能,点击卡片上的"编辑"按钮后,显示编辑表单
创建状态徽章:编写
StatusBadge组件,支持online、offline、warning、error四种状态
进阶练习
实现搜索功能:在备忘录页面添加搜索框,根据标题和内容过滤列表
添加确认对话框:删除备忘录前,弹出一个确认对话框(创建
ConfirmDialog组件)优化表单验证:实现实时表单验证(输入时即时显示错误信息),而非只在提交时验证
思考题
项目为什么选择 React Query 管理服务器状态,而不是把所有状态都放在 Zustand 中?
useQuery的staleTime和refetchInterval有什么区别?在什么场景下你会使用refetchInterval?为什么项目使用 React Context 管理认证状态,而不是 Zustand?两者在这个场景下各有什么优劣?
延伸阅读
本章回顾:你已经掌握了前端开发的基础技能!在下一章中,我们将深入数据库设计,理解 44 张表的结构和关系。
