Skip to content

第7章 前端开发基础

作者

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

IT Online 微信公众号

许可证

MPL-2.0 © 谭策

本章导读

在上一章中,我们学习了后端开发的完整流程。本章将进入前端开发,学习如何使用 React 构建用户界面。本章涵盖组件开发、样式编写、API 调用、状态管理等前端开发核心技能。

本章定位:这是你第一次真正动手写前端代码。我们会按照项目的实际编码风格来写,让你写出的代码能直接融入项目。

学习目标

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

  • 独立创建新的页面组件并配置路由
  • 编写可复用的通用组件
  • 使用 Tailwind CSS 编写响应式样式
  • 通过 API 客户端与后端通信
  • 使用 React Query 管理服务器数据
  • 使用 Zustand 管理全局 UI 状态
  • 实现路由保护和认证状态管理
  • 处理表单输入和表单验证

7.1 前端开发环境

7.1.1 启动前端开发服务器

bash
# 进入前端目录
cd frontend

# 安装依赖(首次)
npm install

# 启动开发模式(带热重载)
npm run dev

看到以下输出表示启动成功:

  VITE v5.0.8  ready in 520 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

7.1.2 开发模式特点

特性说明
热模块替换(HMR)修改代码后自动刷新,保持组件状态
按需编译只编译当前访问的模块,启动速度极快
TypeScript 支持实时类型检查,错误在终端显示
代理配置/api 请求自动转发到后端 localhost:3001

7.2 创建第一个页面组件

7.2.1 页面组件结构

以创建一个 备忘录页面 为例:

步骤 1:创建页面文件

bash
touch frontend/src/pages/Notes.tsx

步骤 2:编写页面代码

tsx
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 中添加路由:

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 通用组件示例:状态徽章

tsx
// 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>
  );
}

使用方式

tsx
import StatusBadge from '../components/StatusBadge';

function ServerCard({ server }) {
  return (
    <div>
      <h3>{server.name}</h3>
      <StatusBadge status={server.status} />
      {/* 显示 "在线"、"离线"、"警告"、"错误" */}
    </div>
  );
}

7.3.3 通用组件示例:空状态

tsx
// 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. 垂直堆叠布局

tsx
<div className="space-y-4">  {/* 子元素间距 1rem */}
  <div>项目 1</div>
  <div>项目 2</div>
  <div>项目 3</div>
</div>

2. 响应式网格布局

tsx
<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 布局

tsx
{/* 两端对齐 */}
<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 项目中的实际样式示例

项目使用深色主题 + 渐变 + 半透明背景的设计风格:

tsx
// 侧边栏样式
<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 交互状态样式

tsx
<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 库来组合条件类名:

tsx
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 客户端:

typescript
// 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 对象(简单场景)

tsx
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(推荐,大多数场景)

tsx
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 中查询统计信息

tsx
// 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带参数的查询
创建数据useMutationPOST 请求
更新数据useMutationPUT/PATCH 请求
删除数据useMutationDELETE 请求
上传文件useMutationFormData 请求
轮询查询useQuery + refetchInterval定时自动刷新

7.6 路由配置

7.6.1 路由结构

项目使用 React Router v6 的嵌套路由:

tsx
// 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 路由守卫实现

tsx
// 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 管理认证状态:

tsx
// 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 在组件中使用认证

tsx
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 基础表单

tsx
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 表单验证

tsx
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: 等断点前缀适配大屏幕。

tsx
<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 响应式布局示例

项目中的统计卡片布局:

tsx
<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:

tsx
// 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>
  );
}

NavLink 是 React Router 提供的特殊 Link 组件,会自动根据当前 URL 添加激活状态:

tsx
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 错误,防止整个应用崩溃。

tsx
// 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;
  }
}

本章小结

本章涵盖了前端开发的完整流程:

  1. 页面组件:创建页面、配置路由、处理加载和空状态
  2. 通用组件:提取可复用的 UI 元素(状态徽章、空状态等)
  3. Tailwind CSS:布局模式、深色主题、交互状态、条件样式
  4. API 调用:Axios 封装、React Query 使用、三种调用方式
  5. 路由配置:React Router v6、嵌套路由、路由守卫
  6. 认证管理:AuthContext、useAuth Hook、ProtectedRoute
  7. 表单处理:表单数据管理、表单验证
  8. 响应式设计:移动优先策略、断点适配
  9. 布局组件:Layout 框架、侧边栏导航、用户信息区
  10. 错误边界:防止应用崩溃,提供友好的错误提示

核心原则:组件化、可复用、响应式、类型安全。


本章练习

基础练习

  1. 创建备忘录页面:按照本章示例,完整实现 Notes.tsx 页面,包括列表、创建、删除功能

  2. 添加编辑功能:为备忘录页面添加编辑功能,点击卡片上的"编辑"按钮后,显示编辑表单

  3. 创建状态徽章:编写 StatusBadge 组件,支持 onlineofflinewarningerror 四种状态

进阶练习

  1. 实现搜索功能:在备忘录页面添加搜索框,根据标题和内容过滤列表

  2. 添加确认对话框:删除备忘录前,弹出一个确认对话框(创建 ConfirmDialog 组件)

  3. 优化表单验证:实现实时表单验证(输入时即时显示错误信息),而非只在提交时验证

思考题

  1. 项目为什么选择 React Query 管理服务器状态,而不是把所有状态都放在 Zustand 中?

  2. useQuerystaleTimerefetchInterval 有什么区别?在什么场景下你会使用 refetchInterval

  3. 为什么项目使用 React Context 管理认证状态,而不是 Zustand?两者在这个场景下各有什么优劣?


延伸阅读


本章回顾:你已经掌握了前端开发的基础技能!在下一章中,我们将深入数据库设计,理解 44 张表的结构和关系。

基于 MPL-2.0 许可证发布