Skip to content
Published at:

第 39 章:前端开发 —— SmartTodo UI

第 38 章我们完成了 SmartTodo 的后端——数据库、API、认证体系全部就绪。本章进入用户能直接看到和交互的部分:前端 UI

我们将用 Claude Code 从组件架构设计开始,逐一实现登录注册、任务看板、任务列表、详情编辑等页面,再搭建状态管理和 API 客户端,最后与后端进行完整的联调测试。和前几章一样,本章的核心不是 React 或 TypeScript 的语法细节,而是用 Claude Code 进行前端开发的完整工作流——如何让 AI 帮你规划组件树、如何通过迭代对话打磨 UI、如何在 Claude 的帮助下完成前后端联调。

本章目标:掌握用 Claude Code 开发前端应用的完整流程——组件架构设计→页面实现→状态管理→前后端联调。学会用迭代对话逐步打磨 UI,用 Bash 工具同时启动前后端进行端到端测试。

39.1 组件架构设计

在写任何一行前端代码之前,先让 Claude Code 帮你做架构规划。这一步和第 21 章的需求分析逻辑一样:先把组件树和页面结构想清楚,再动手写代码。跳过这一步,很容易写到一半发现组件拆分不合理,被迫重构。

39.1.1 让 Claude 规划组件树

打开 Claude Code,用 Plan 模式进行设计(Plan 模式只会分析和计划,不会修改任何文件,适合需求分析和架构设计阶段):

帮我规划 SmartTodo 前端的组件树。

技术栈:React 18 + TypeScript + React Router v6
UI 库:Tailwind CSS + shadcn/ui(项目已经预装)

后端 API 已就绪,基础路径 /api/v1,包含以下端点:
- POST /api/v1/auth/register, /api/v1/auth/login
- GET /api/v1/tasks, POST /api/v1/tasks
- GET /api/v1/tasks/:id, PUT /api/v1/tasks/:id, DELETE /api/v1/tasks/:id
- GET /api/v1/categories, POST /api/v1/categories
- GET /api/v1/tags, POST /api/v1/tags
- GET /api/v1/users/me

页面需求:
1. 登录页(邮箱 + 密码)
2. 注册页(用户名 + 邮箱 + 密码 + 确认密码)
3. 仪表盘(任务统计、完成率图表)
4. 任务列表(支持按分类/标签/状态筛选,按优先级/日期排序)
5. 任务详情(完整任务信息 + 编辑 + 删除)
6. 创建/编辑任务(标题、描述、优先级、截止日期、分类、标签)

请先输出组件树的层级结构,再列出每个组件的职责和需要的数据。

Claude Code 会输出类似这样的组件架构分析:

markdown
## SmartTodo 前端组件树

### 层级结构

App (BrowserRouter) ├── AuthProvider (Context) │ ├── PublicRoutes │ │ ├── LoginPage │ │ │ └── LoginForm │ │ └── RegisterPage │ │ └── RegisterForm │ └── ProtectedRoutes │ ├── Layout │ │ ├── Header │ │ │ ├── Logo │ │ │ ├── NavLinks │ │ │ └── UserMenu (头像 + 下拉菜单) │ │ └── Sidebar │ │ └── SidebarNav │ ├── DashboardPage │ │ ├── StatsPanel (任务总数、完成率、逾期数) │ │ ├── PriorityChart (优先级分布饼图) │ │ └── RecentTasks (最近创建的任务列表) │ ├── TaskListPage │ │ ├── CategoryFilter (分类筛选下拉) │ │ ├── StatusFilter (状态筛选:待办/进行中/已完成) │ │ ├── SortControls (排序:优先级/日期/标题) │ │ ├── TaskCard (单条任务卡片) × N │ │ └── Pagination │ ├── TaskDetailPage │ │ ├── TaskInfo (任务详细信息) │ │ ├── TaskActions (编辑 / 删除按钮) │ │ └── DeleteConfirmDialog │ └── TaskFormPage (创建 / 编辑共用) │ ├── TaskForm │ │ ├── TitleInput │ │ ├── DescriptionEditor │ │ ├── PrioritySelect │ │ ├── DueDatePicker │ │ ├── CategorySelect │ │ └── TagSelector (多选标签) │ └── FormActions (保存 / 取消)


### 各组件职责和数据需求

**页面级组件:**

| 组件 | 职责 | 需要的数据 |
|------|------|-----------|
| LoginPage | 渲染登录表单,处理后端认证 | 无(用户输入) |
| RegisterPage | 渲染注册表单,调用注册 API | 无(用户输入) |
| DashboardPage | 展示任务统计概览 | tasks (all), user info |
| TaskListPage | 任务列表 + 筛选 + 排序 + 分页 | tasks (filtered), categories, tags |
| TaskDetailPage | 单条任务的完整信息,支持编辑/删除 | single task |
| TaskFormPage | 创建/编辑任务的表单,根据 URL 参数区分模式 | categories, tags, task (编辑模式) |

**复用组件:**

| 组件 | 职责 | 复用页面 |
|------|------|---------|
| Header | 顶部导航栏,显示 logo 和用户菜单 | 所有受保护页面 |
| Sidebar | 侧边栏导航 | 所有受保护页面 |
| TaskCard | 任务卡片预览 | Dashboard, TaskList |
| StatusBadge | 状态标签(待办/进行中/已完成) | TaskCard, TaskDetail |
| PriorityBadge | 优先级标签(高/中/低) | TaskCard, TaskDetail |
| EmptyState | 空数据占位图 | Dashboard, TaskList |
| LoadingSpinner | 加载状态指示器 | 所有页面 |

拿到这个组件树之后,你会立刻发现几个原本可能需要写到一半才会意识到的问题:

  • TaskFormPage 可以同时处理创建和编辑:通过 URL 参数 /:id/edit vs /new 区分模式,组件代码共享,避免重复。
  • ProtectedRoutes 需要包裹整个认证区域:未登录用户访问受保护页面时自动重定向到登录页。
  • AuthProvider 应该放在最外层:所有组件都可能需要访问认证状态(Header 显示用户名、API 请求携带 token)。

39.1.2 用 Plan mode 先输出实现计划

组件树确定后,不要急于开始写代码。让 Claude Code 先输出一份实现计划,确认开发顺序和依赖关系:

用 Plan mode 帮我输出 SmartTodo 前端的实现计划。

要求:
1. 按依赖关系排序(先实现被依赖的模块)
2. 标注每个步骤涉及的文件
3. 标注关键的技术决策点
4. 估算每个步骤的复杂度(简单/中等/复杂)

Claude Code 会输出类似这样的计划:

markdown
## SmartTodo 前端实现计划

### Phase 1: 基础设施(被所有模块依赖)
| 步骤 | 内容 | 文件 | 复杂度 |
|------|------|------|--------|
| 1.1 | API 客户端 | `src/api/client.ts` | 中等 |
| 1.2 | API 接口层 | `src/api/auth.ts`, `src/api/tasks.ts` 等 | 简单 |
| 1.3 | AuthContext | `src/contexts/AuthContext.tsx` | 中等 |
| 1.4 | 路由配置 | `src/router/index.tsx` | 简单 |
| 1.5 | Layout 组件 | `src/components/Layout.tsx` | 简单 |

### Phase 2: 认证页面(不依赖任务功能)
| 步骤 | 内容 | 文件 | 复杂度 |
|------|------|------|--------|
| 2.1 | LoginPage | `src/pages/Login.tsx` | 简单 |
| 2.2 | RegisterPage | `src/pages/Register.tsx` | 简单 |

### Phase 3: 核心业务页面
| 步骤 | 内容 | 文件 | 复杂度 |
|------|------|------|--------|
| 3.1 | TaskCard + 子组件 | `src/components/TaskCard.tsx` 等 | 中等 |
| 3.2 | TaskListPage | `src/pages/TaskList.tsx` | 中等 |
| 3.3 | TaskFormPage | `src/pages/TaskForm.tsx` | 复杂 |
| 3.4 | TaskDetailPage | `src/pages/TaskDetail.tsx` | 中等 |
| 3.5 | DashboardPage | `src/pages/Dashboard.tsx` | 中等 |

### Phase 4: 联调与打磨
| 步骤 | 内容 | 复杂度 |
|------|------|--------|
| 4.1 | 启动前后端,测试完整流程 | 中等 |
| 4.2 | 修复 CORS、API 路径等联调问题 | 简单 |
| 4.3 | 错误处理与加载状态完善 | 简单 |

有了这份计划,你就可以按照依赖顺序一步步推进。每完成一个 Phase,让 Claude Code 检查一下当前进度和待办项,确保不偏离方向。

Plan mode 的价值:Plan mode 输出的是"计划"而不是"代码",它让你可以评审和调整开发顺序,而不会在还没有想清楚的时候就陷入代码细节。在正式进入编码之前,花 10 分钟评审计划,比你写到 Phase 3 发现需要重构 Phase 1 节省的时间多得多。

39.2 页面实现

有了架构和计划,我们开始逐页实现。这一节展示的不是"Claude 一次输出就完美"的神话,而是真实的迭代过程——Claude 的第一版代码往往需要调整,关键在于如何高效地对话和调整。

39.2.1 登录页面:从基础到完善的三轮迭代

第一轮:基础骨架

帮我创建 Login 页面,包含邮箱和密码输入框、登录按钮,还有一个"没有账号?去注册"的链接。

技术细节:
- 使用 React Hook Form + zod 做表单验证
- 调用 /api/v1/auth/login 接口
- 登录成功后把 token 存到 localStorage,跳转到 /dashboard
- 使用 Tailwind CSS,风格简洁现代

Claude Code 会创建登录页面。第一版的典型输出会包含基础的 JSX 结构、表单验证规则和 API 调用逻辑,但视觉上往往比较"朴素"——居中的白色卡片,蓝色按钮,标准的输入框。

第二轮的典型输出——登录页面骨架代码:

tsx
// src/pages/Login.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useState } from 'react';
import { login } from '@/api/auth';
import { useAuth } from '@/contexts/AuthContext';

const loginSchema = z.object({
  email: z.string().email('请输入有效的邮箱地址'),
  password: z.string().min(6, '密码至少 6 位'),
});

type LoginForm = z.infer<typeof loginSchema>;

export default function LoginPage() {
  const navigate = useNavigate();
  const { setToken } = useAuth();
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<LoginForm>({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = async (data: LoginForm) => {
    setIsLoading(true);
    setError('');
    try {
      const { token } = await login(data.email, data.password);
      setToken(token);
      navigate('/dashboard');
    } catch (err: any) {
      setError(err.response?.data?.message || '登录失败,请重试');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
        <h2 className="text-3xl font-bold text-center">登录 SmartTodo</h2>

        {error && (
          <div className="bg-red-50 text-red-600 p-3 rounded text-sm">
            {error}
          </div>
        )}

        <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
          <div>
            <label className="block text-sm font-medium text-gray-700">
              邮箱
            </label>
            <input
              type="email"
              {...register('email')}
              className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-blue-500"
              placeholder="you@example.com"
            />
            {errors.email && (
              <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
            )}
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700">
              密码
            </label>
            <input
              type="password"
              {...register('password')}
              className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-blue-500"
              placeholder="请输入密码"
            />
            {errors.password && (
              <p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
            )}
          </div>

          <button
            type="submit"
            disabled={isLoading}
            className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
          >
            {isLoading ? '登录中...' : '登录'}
          </button>
        </form>

        <p className="text-center text-sm text-gray-600">
          没有账号?{' '}
          <Link to="/register" className="text-blue-600 hover:underline">
            去注册
          </Link>
        </p>
      </div>
    </div>
  );
}

审视第一版:功能完整——表单验证、API 调用、错误处理、加载状态、路由跳转都齐了。但视觉上中规中矩,而且缺少一些用户友好的细节。

第二轮:视觉微调

拿到第一版之后,你可以像和设计师同事对话一样,提出视觉调整:

把登录按钮改成蓝色渐变,给输入框增加一个 focus 时的环状效果(ring),
登录页背景加一点浅色图案或装饰,让页面不那么单调。

另外,给邮箱输入框前面加一个信封图标(用 lucide-react 的 Mail 图标)。
密码框前面加锁的图标。

Claude Code 会在第一版的基础上做增量修改——不会重写整个组件,而是精确替换需要改动的样式和 JSX 片段。

第三轮:体验完善

现在加上以下交互优化:
1. 提交时如果接口返回 401,在邮箱输入框下方显示"账号或密码错误"而不是页顶的红色横幅
2. 密码框右边加一个"显示/隐藏密码"的小按钮
3. 登录成功后加一个 0.5 秒的过渡动画再跳转
4. 支持按 Enter 键提交表单(默认行为应该已经支持,确认一下)

经过三轮迭代,一个从功能到细节都经得起推敲的登录页面就完成了。这就是用 Claude Code 做前端开发的真实节奏——不是一次 prompt 输出完美结果,而是迭代打磨。你负责判断"哪里还不够好",Claude 负责"改到什么程度才算好"。

39.2.2 注册页面:复用风格,关注差异

注册页面的逻辑和登录类似,你可以直接告诉 Claude 参考已有代码:

参考 src/pages/Login.tsx 的风格,帮我创建注册页面。

与登录的区别:
1. 增加"用户名"字段(2-20 个字符,只允许字母、数字、下划线)
2. 增加"确认密码"字段,需要和密码一致(zod 的 refine 校验)
3. 调用 /api/v1/auth/register 接口
4. 注册成功后弹出一个提示"注册成功,请登录",然后跳转到登录页
5. 整体视觉风格和 Login 保持一致

Claude Code 会读取 Login.tsx 的代码,然后生成风格一致但逻辑有差异的 Register 页面。因为复用了 Login 的样式模式,注册页面几乎不需要额外的视觉迭代——你只需要确认表单验证逻辑是否完善即可。

39.2.3 仪表盘:统计面板和数据可视化

仪表盘是用户登录后看到的第一个页面,它需要展示任务统计数据和快速操作入口。

帮我创建 Dashboard 页面。

数据来源:GET /api/v1/tasks(返回当前用户的所有任务)

页面布局:
1. 顶部:三张统计卡片——总任务数、已完成数、逾期数(用不同颜色区分)
2. 左侧:优先级分布(高/中/低的饼图,用 recharts 库)
3. 右侧:最近创建的 5 条任务列表(点击可跳转到详情)
4. 底部:一个"创建新任务"的大按钮

统计卡片需要有数字跳动动画(从 0 到目标数字的效果)。
如果没有任务(新用户),显示"还没有任务,创建第一个吧!"的引导提示。

Claude Code 会生成仪表盘组件。这里有两个值得关注的技术点:

关于 recharts 的使用:如果你没有在项目中使用过 recharts,Claude Code 会自动 npm install recharts 并创建图表配置。你不需要自己去查 recharts 的 API 文档——Claude 会处理好 PieChartPieCellTooltip 的配置。

关于计数动画:Claude 会用 useEffect + requestAnimationFrame 或者 useState + setInterval 实现数字从 0 跳到目标值的动画效果。你可以检查它的实现,如果觉得性能不理想,直接说"用 CSS transition 做,不要用 JS 定时器"——Claude 会替换实现方案。

数据可视化小技巧:如果你不确定用什么图表类型呈现什么数据,直接问 Claude:"任务完成趋势适合用折线图还是柱状图?为什么?"它会给分析理由,你拍板就行。工具选择的决策交给 AI,审美和业务判断留给自己。

39.2.4 任务列表:筛选、排序、分页的组合拳

任务列表是 SmartTodo 最复杂的页面之一,因为它同时涉及筛选排序分页三种数据操作。

帮我创建 TaskList 页面。

功能要求:
1. 顶部筛选栏:
   - 按分类筛选(下拉选择,支持"全部分类")
   - 按标签筛选(多选标签,选中多个时显示交集)
   - 按状态筛选(待办 / 进行中 / 已完成 / 全部)
2. 排序:支持按优先级(高→低)、创建日期(新→旧)、标题(A→Z)
3. 每个任务卡片显示:标题、优先级徽章、状态徽章、分类名、截止日期、标签列表
4. 分页:每页 10 条,底部显示分页控件
5. 搜索框:按标题模糊搜索(前端过滤,不需要调接口)
6. 空状态:没有匹配结果时显示"没有找到符合条件的任务"

这个页面的筛选参数比较多,建议把筛选状态统一管理(URL 查询参数或者一个 filter state 对象)。

这个需求的复杂度较高,建议让 Claude Code 先输出实现方案(Plan mode),你确认方案后再让它写代码:

我先帮你分析实现方案:

1. 状态管理:使用 URL search params 管理筛选状态。好处是:
   - 浏览器前进/后退按钮原生支持
   - 可以直接分享带筛选条件的 URL
   - 不需要额外的状态管理库

2. 数据流:
   - 页面加载时从 URL 读取筛选参数
   - 调用 GET /api/v1/tasks 获取数据(筛选已在后端实现?还是前端过滤?)
   - 如果后端支持 query params 筛选,直接传参
   - 如果后端返回全量数据,前端做过滤、排序、分页

3. 组件拆分:
   - TaskListPage:容器组件,管理筛选状态
   - FilterBar:顶部筛选栏
   - TaskCard:单任务卡片
   - Pagination:分页组件

你希望筛选在后端做还是前端做?建议:
- 数据量小(< 500 条):前端过滤,响应更快
- 数据量大(> 500 条):后端分页 + 筛选,性能更好

等你的决定,我再开始写代码。

你在确认后端方案后,Claude 会一次性生成整套组件。这里的关键经验是:复杂页面的开发顺序是先确认数据流方案,再写 UI。如果反过来的话,你可能会写出一个看起来很漂亮的页面,但一到联调阶段发现数据流设计不合理,需要大改。

39.2.5 任务详情与编辑:增删改查的最后一环

任务详情页和编辑表单是增删改查操作的核心。因为创建和编辑共享相同的表单结构,可以设计为共用 TaskForm 组件。

帮我创建任务详情页和任务编辑页。

详情页(/tasks/:id):
- 显示任务的所有字段:标题、描述、优先级、状态、截止日期、分类、标签、创建时间、更新时间
- 右上角有两个按钮:编辑(跳转到 /tasks/:id/edit)和删除(弹窗确认后调用 DELETE 接口)
- 底部有一个"返回列表"的链接

编辑页(/tasks/:id/edit):
- 复用 TaskForm 组件(和创建页面共用)
- 表单预填当前任务的数据
- 提交时调用 PUT /api/v1/tasks/:id
- 保存成功后跳转回详情页

创建页(/tasks/new):
- 使用同一个 TaskForm 组件
- 表单为空
- 提交时调用 POST /api/v1/tasks
- 创建成功后跳转到新任务的详情页

另外,删除操作需要二次确认:弹出一个 Modal,标题"确定要删除这个任务吗?",
内容显示任务标题,两个按钮"取消"和"删除"(红色)。

Claude Code 会生成三个页面文件 + 一个共享的 TaskForm 组件。这里值得关注的是 TaskForm 的共用设计——Claude 会自动识别创建和编辑的相似性,把表单逻辑抽成一个组件,通过 props 接收初始数据和提交回调。

TaskForm 组件的核心结构会是这样:

tsx
// src/components/TaskForm.tsx
interface TaskFormProps {
  initialData?: TaskFormData;   // 编辑模式时传入已有数据
  onSubmit: (data: TaskFormData) => Promise<void>;
  isSubmitting: boolean;
}

const taskSchema = z.object({
  title: z.string().min(1, '标题不能为空').max(100, '标题最多 100 个字符'),
  description: z.string().max(2000, '描述最多 2000 个字符').optional(),
  priority: z.enum(['high', 'medium', 'low']),
  status: z.enum(['todo', 'in_progress', 'done']),
  due_date: z.string().optional(),
  category_id: z.string().optional(),
  tag_ids: z.array(z.string()).optional(),
});

创建页传 initialData={undefined},编辑页传 initialData={task}——同一个组件,两种模式。

39.2.6 迭代细化的实战技巧

回顾本节六个页面/组件的开发过程,总结几条在 Claude Code 中做前端 UI 迭代的实用技巧:

1. 从小改动开始,不要重写

UI 调整和小功能添加时,给 Claude 的指令越具体越好。坏指令:"把这个页面改好看点"。好指令:"把登录按钮的颜色从 bg-blue-600 改成 bg-gradient-to-r from-blue-500 to-indigo-600"。

2. 参考已有代码保持一致性

"参考 src/pages/Login.tsx 的样式"比"用 Tailwind 写一个好看的页面"效果好得多。Claude 会读 Login.tsx 的代码,精确复用其设计语言。这在新项目中最能保证视觉一致性。

3. 用自然语言描述视觉变化

你不是在写 CSS,你是在和设计师沟通。"把卡片阴影加大一点"、"让标题和内容之间间距大一些"、"这个按钮太突兀了,让它低调一点"——Claude 能理解这些描述,并映射到具体的 Tailwind class。

4. 逐层添加复杂度

不要一次性把登录页的所有功能和细节都塞到一个 prompt 里。先做基础骨架(表单 + API 调用),再做视觉打磨(颜色 + 图标),最后做交互优化(动画 + 边界条件)。每个 prompt 只加一层复杂度,Claude 出错时你也能很快定位到是哪个需求导致的。

5. 检查边界状态

养成习惯,每完成一个页面就问 Claude:"这个组件在加载中、空数据、接口报错这三种状态下分别会显示什么?"Claude 会帮你检查并补充缺失的状态处理。

39.3 状态管理

页面搭好后,接下来把状态管理的"管道"铺设好。SmartTodo 前端的状态分为三类:服务端状态(任务数据、分类列表)、认证状态(用户登录信息、token)、UI 状态(筛选条件、表单草稿)。不同类型的状态用不同的工具管理。

39.3.1 API 客户端:统一管理请求

所有的前端请求都需要经过一个统一的 API 客户端。这个客户端负责三件事:设置 base URL、自动附加 Authorization header、统一处理 401 未授权响应。

帮我创建 API 客户端,封装 fetch 请求。

文件:src/api/client.ts

功能:
1. base URL 读取环境变量 VITE_API_BASE_URL,默认 'http://localhost:3000/api/v1'
2. 每个请求自动附加 Authorization: Bearer <token>(token 从 localStorage 读取)
3. 响应拦截:
   - 如果状态码 401,清除 localStorage 中的 token,跳转到 /login
   - 如果状态码 >= 400,抛出包含后端错误信息的异常
4. 提供 get / post / put / del 四个方法,返回类型泛型
5. 请求和响应都使用 JSON 格式

使用 TypeScript,不要用 axios,用原生 fetch 封装。

Claude Code 会生成一个干净的 API 客户端。核心结构如下:

typescript
// src/api/client.ts
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';

class ApiError extends Error {
  constructor(
    public status: number,
    message: string,
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
  const token = localStorage.getItem('token');

  const response = await fetch(`${API_BASE}${path}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...options.headers,
    },
  });

  if (response.status === 401) {
    localStorage.removeItem('token');
    window.location.href = '/login';
    throw new ApiError(401, '未授权,请重新登录');
  }

  if (!response.ok) {
    const body = await response.json().catch(() => ({}));
    throw new ApiError(response.status, body.message || `请求失败 (${response.status})`);
  }

  // 204 No Content
  if (response.status === 204) return undefined as T;

  return response.json();
}

export const api = {
  get: <T>(path: string) => request<T>(path),
  post: <T>(path: string, body: unknown) =>
    request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
  put: <T>(path: string, body: unknown) =>
    request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
  del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};

export { ApiError };

有了这个客户端,各 API 模块就可以整洁地定义:

typescript
// src/api/auth.ts
import { api } from './client';

export function login(email: string, password: string) {
  return api.post<{ token: string; user: User }>('/auth/login', { email, password });
}

export function register(data: RegisterData) {
  return api.post<{ message: string }>('/auth/register', data);
}

// src/api/tasks.ts
import { api } from './client';

export function getTasks(params?: TaskQueryParams) {
  const query = params ? '?' + new URLSearchParams(params as any).toString() : '';
  return api.get<Task[]>(`/tasks${query}`);
}

export function getTask(id: string) {
  return api.get<Task>(`/tasks/${id}`);
}

export function createTask(data: CreateTaskData) {
  return api.post<Task>('/tasks', data);
}

export function updateTask(id: string, data: Partial<CreateTaskData>) {
  return api.put<Task>(`/tasks/${id}`, data);
}

export function deleteTask(id: string) {
  return api.del<void>(`/tasks/${id}`);
}

关于 axios vs fetch:许多前端项目默认使用 axios,但现代浏览器对 fetch 的支持已经足够好。使用 fetch 的好处是零依赖、包体积更小。如果你偏好 axios 的拦截器语法,直接告诉 Claude "换成 axios 实现"即可——它会重写整个 client.ts,API 接口层的调用方式不需要改动。

39.3.2 AuthContext:认证状态全局管理

认证状态(用户是否登录、当前用户信息、token)需要在整个应用中共享,最适合用 React Context。

创建 AuthContext 管理登录状态。

文件:src/contexts/AuthContext.tsx

功能:
1. 提供 token, user, isAuthenticated, login, logout, register 方法
2. 应用初始化时从 localStorage 读取 token,如果有 token 则调用 GET /api/v1/users/me 获取用户信息
3. login 方法:调用登录 API → 存储 token 到 localStorage → 设置 user
4. logout 方法:清除 token → 清除 user → 跳转到 /login
5. 提供一个 useAuth hook 供组件使用
6. 在获取用户信息期间显示一个全局 Loading 状态(防止闪现登录页)

Claude Code 生成的 AuthContext 会包含一个关键的状态机设计:loading → authenticated | unauthenticated。这个设计避免了"刷新页面先闪现登录页再跳到内容页"的常见问题——在确认认证状态之前,只显示 Loading,不做路由决策。

39.3.3 TanStack Query:服务端数据的管理

任务列表、任务详情、分类和标签列表——这些来自后端的数据属于服务端状态。用 useState + useEffect 手动管理这些数据的获取、缓存、刷新是繁琐且容易出 Bug 的。这里引入 TanStack Query(原 React Query),让它来管理服务端数据的生命周期。

用 TanStack Query(@tanstack/react-query)管理 SmartTodo 的服务端数据。

1. 安装 @tanstack/react-query
2. 创建 QueryClient 并包裹 App
3. 定义查询键(query keys):
   - ['tasks'] — 任务列表
   - ['tasks', taskId] — 单条任务
   - ['categories'] — 分类列表
   - ['tags'] — 标签列表
4. 创建自定义 hooks:
   - useTasks(filters):任务列表查询,支持筛选参数
   - useTask(id):单条任务查询
   - useCategories():分类列表查询
   - useTags():标签列表查询
   - useCreateTask():创建任务的 mutation(成功后 invalidate ['tasks'])
   - useUpdateTask():更新任务的 mutation
   - useDeleteTask():删除任务的 mutation
5. 配置:
   - staleTime: 30 秒(任务列表不需要实时更新)
   - 错误重试 2 次

引入 TanStack Query 后,任务列表页面的数据获取逻辑从手动管理变成了声明式的:

typescript
// 之前的写法(手动管理):
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
  getTasks(filters)
    .then(setTasks)
    .catch(setError)
    .finally(() => setIsLoading(false));
}, [filters]);

// 之后的写法(TanStack Query):
const { data: tasks, isLoading, error } = useTasks(filters);

从 12 行变成 1 行——不只是代码量的减少,更是关注点分离:你的组件不再关心"数据怎么获取、怎么缓存、什么时候重新获取",它只关心"给我最新的任务列表"。缓存策略、后台刷新、乐观更新这些复杂的事情由 TanStack Query 和 Claude 生成的 hooks 处理。

什么时候用 TanStack Query:简单页面(比如静态的 About 页)不需要。但任何涉及列表、详情、增删改的数据页面,TanStack Query 带来的价值远超引入它的复杂度。SmartTodo 的任务、分类、标签都属于这种场景。如果页面特别简单,让 Claude 直接用 fetch 就行,不必过度设计。

39.4 与后端联调

前后端都开发完成后,进入联调阶段。这是最容易出现意外问题的环节——CORS 报错、API 路径不匹配、token 格式不一致、环境变量没配好。Claude Code 可以直接帮你启动两个服务、执行 API 测试、排查问题。

39.4.1 同时启动前后端

帮我同时启动前后端,检查是否正常运行。

后端:cd server && npm run dev(端口 3000)
前端:cd client && npm run dev(端口 5173)

用 Claude Code 的 Bash 工具在后台启动这两个服务,然后检查:
1. 后端 /api/v1/health 是否返回 200
2. 前端 dev server 是否正常监听 5173 端口

Claude Code 会使用 Bash 工具后台启动两个服务,然后 curl 检查端口是否正常响应。如果服务启动失败(比如缺少依赖),Claude 会帮你排查并修复。

39.4.2 修复 CORS 问题

前后端联调中最常见的问题是 CORS(跨域资源共享)。前端在 localhost:5173,后端在 localhost:3000,浏览器会阻止跨域请求。

前端调用登录接口时报错 CORS。帮我配置后端的 CORS 中间件。

要求:
1. 开发环境允许 localhost:5173 的跨域请求
2. 允许 Authorization header
3. 允许 GET/POST/PUT/DELETE 方法
4. 生产环境从环境变量读取允许的域名列表

Claude Code 会根据你后端的框架(Express / Fastify / Koa)生成对应的 CORS 配置。对于 Express 后端:

typescript
// server/src/middleware/cors.ts(或直接在 app.ts 中)
import cors from 'cors';

const allowedOrigins = process.env.NODE_ENV === 'production'
  ? process.env.ALLOWED_ORIGINS?.split(',') || []
  : ['http://localhost:5173'];

app.use(cors({
  origin: allowedOrigins,
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

排查 CORS 问题的小技巧:如果你配置了 CORS 但还是报错,直接告诉 Claude:"CORS 配置后还是报错,帮我检查。报错信息是:xxx"。Claude 会帮你逐项排查——allowed origins 是否匹配(注意 localhost 和 127.0.0.1 的区别)、preflight 请求是否正确处理、credentials 和 allowedHeaders 是否与前端请求一致。

39.4.3 端到端流程测试

服务都跑起来之后,做一次完整的端到端测试,确保整个用户流程没有中断。

帮我在前后端同时运行的情况下,测试 SmartTodo 的完整使用流程。

用 curl 模拟以下操作序列:
1. 注册新用户(邮箱 test@example.com,密码 123456)
2. 登录(用刚才注册的账号)
3. 创建一个分类"工作"
4. 创建一个标签"紧急"
5. 创建一条任务:"完成 Q3 报告",优先级 high,分类"工作",标签"紧急"
6. 查询任务列表(验证刚才创建的任务在列表中)
7. 更新任务状态为"进行中"
8. 查询该任务详情(验证状态已更新)
9. 删除该任务
10. 查询任务列表(验证任务已被删除)

每个步骤都要检查 HTTP 状态码和响应内容是否正确。
如果有任何一步失败,停下来排查原因。

Claude Code 会一步步执行这些 curl 命令,每个命令的输出都会展示给你,让你直观地看到整个流程是否顺畅。这种方式比在浏览器中手动点击测试快得多,而且可复现——下次后端改了 API,直接重新跑一遍这套 curl 测试就能确认有没有回归。

端到端测试的典型输出:

Step 1: 注册新用户
$ curl -X POST http://localhost:3000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"testuser","email":"test@example.com","password":"123456"}'
→ 201 Created {"message": "注册成功"}

Step 2: 登录
$ curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"123456"}'
→ 200 OK {"token":"eyJhbGciOi...","user":{"id":"...","username":"testuser",...}}

Step 3: 创建分类
$ curl -X POST http://localhost:3000/api/v1/categories \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGciOi..." \
  -d '{"name":"工作","color":"#3B82F6"}'
→ 201 Created {"id":"cat_001","name":"工作",...}

... (依次完成所有步骤)

Step 10: 验证删除
$ curl -X GET http://localhost:3000/api/v1/tasks \
  -H "Authorization: Bearer eyJhbGciOi..."
→ 200 OK []  ← 任务列表为空,删除成功 ✅

全部 10 个步骤测试通过!

如果中途有步骤失败——比如创建任务时返回 400,Claude 会帮你分析原因(可能是必填字段缺失、数据格式不对、JWT token 过期),并提供修复方案。

39.4.4 浏览器中的视觉验证

curl 测试验证了 API 层的正确性,但 UI 层的视觉验证还是需要在浏览器中进行。

帮我在前端项目根目录创建一个简单的 E2E 测试清单文档。

内容:
1. 打开 http://localhost:5173,确认跳转到登录页
2. 输入错误的邮箱格式,确认显示验证错误提示
3. 点击"去注册"链接,确认跳转到注册页
4. 注册一个新账号,确认注册成功后跳转到登录页
5. 登录,确认跳转到仪表盘
6. 仪表盘显示 0 个任务(新用户),显示引导提示
7. 点击"创建任务",填写表单,创建第一条任务
8. 回到任务列表,确认任务卡片显示正常(标题、优先级、状态、日期)
9. 点击任务卡片,进入详情页,确认所有字段显示正确
10. 编辑任务(修改标题和优先级),保存后确认详情页已更新
11. 删除任务,确认弹出确认框,确认后回到列表且任务消失
12. 检查移动端响应式:在 375px 宽度下页面布局是否正常

用 Markdown 格式,每项前面加一个 [ ] 勾选框,方便手动勾选。

Claude Code 会生成一份结构清晰的检查清单。你可以打开浏览器,按照清单逐项手动验证,勾选通过的项,标记有问题的项,然后截图或描述问题给 Claude 让它修复。

这套工作流的关键是各司其职:curl 测试负责 API 层的快速回归验证(自动化、可复现),浏览器手动测试负责 UI 层的视觉和交互验证(人眼判断、不可自动化)。两者互补,覆盖了前后端联调的主要风险点。

39.5 本章小结

本章完成了 SmartTodo 前端从零到完整可用的全过程。回顾一下我们走过的路:

  • 39.1 组件架构设计:先让 Claude 输出组件树和实现计划,评审确认后再动手。Plan mode 让你在设计阶段就发现潜在的架构问题,而不是写到一半被迫重构。
  • 39.2 页面实现:从登录到任务详情,六个页面逐页开发。每个页面都经历了"基础骨架→视觉打磨→交互完善"的迭代过程,而不是期待 Claude 一次输出完美结果。对于复杂页面(如 TaskList),先确认数据流方案再写 UI。
  • 39.3 状态管理:搭建了 API 客户端(统一请求封装)、AuthContext(认证状态)、TanStack Query(服务端数据缓存)。不同类型的状态用不同的工具——UI 状态用 useState,认证状态用 Context,服务端数据用 TanStack Query。
  • 39.4 与后端联调:用 Bash 工具同时启动前后端服务,curl 测试 API 流程,浏览器手动检查清单验证 UI。CORS 配置等常见问题由 Claude 直接排查修复。

贯穿项目的进度:截至目前,SmartTodo 已经是一个具备完整前后端功能的全栈应用——后端有数据库、API、认证,前端有组件架构、页面实现、状态管理、API 集成。前端和后端通过 HTTP 通信,端到端流程已经跑通。下一章也是最后一章,我们将把这个本地运行的应用部署上线,让它可以被真实的用户访问。

本章学到的工作流习惯

习惯做法
先设计再编码用 Plan mode 输出组件树和实现计划,评审确认后再让 Claude 写代码
迭代打磨 UI不要一次 prompt 塞满所有需求,骨架→视觉→交互逐层叠加
参考已有代码"参考 Login.tsx 的风格"比"写一个好看的页面"效果好得多
确认数据流方案复杂页面的筛选/排序/分页逻辑,先和 Claude 讨论方案再动手
检查边界状态每完成一个页面,确认加载中、空数据、报错三种状态的处理
curl 测试 + 浏览器验证curl 负责 API 层快速回归,浏览器负责 UI 层视觉验证,各司其职

SmartTodo 已经站在上线的起跑线上,让我们进入最后一章——部署与运维。