全栈 Todo 应用开发实践:从后端到双前端

项目概述

欢迎来到这个全栈 Todo 应用的开发实践教程!通过这个项目,你将学习如何使用现代技术栈从零开始构建一个功能完整的 Web 应用。

我们将涵盖以下技术:

  • 后端:

    • Go: 一门以其性能和并发性而闻名的静态类型语言。

    • Gin: 一个 Go 语言中流行的高性能 Web 框架。

    • GORM: Go 语言的 ORM 库,让数据库操作变得简单直观。

    • SQLite: 一个轻量级的、无服务器的数据库,非常适合本地开发和小型项目。

  • 前端:

    • React: 一个由 Facebook 开发的用于构建用户界面的 JavaScript 库。

    • Vue: 一套用于构建用户界面的渐进式框架。

    • Vite: 一个极速的现代前端构建工具。

    • TypeScript: JavaScript 的超集,增加了静态类型检查。

    • Tailwind CSS: 一个功能类优先的 CSS 框架,用于快速构建自定义设计。

最终,你将拥有一个 Go 驱动的 API 后端,以及两个功能完全相同的前端应用(一个使用 React,一个使用 Vue)来消费这些 API。

第一部分:构建 Go 后端

我们的第一步是构建为应用提供数据和逻辑的后端 API 服务。

  1. 初始化 Go 项目

首先,创建一个名为 go-todo 的新目录,并初始化 Go 模块。

mkdir go-todo
cd go-todo
go mod init todo

接着,获取我们项目所需的依赖包:

go get -u github.com/gin-gonic/gin
go get -u gorm.io/driver/sqlite
go get -u gorm.io/gorm

知识点:

  • go mod init 用于创建一个新的模块,初始化 go.mod 文件,该文件定义了模块的路径和依赖关系。

  • go get 用于下载和安装指定的包及其依赖项。

  1. 定义数据模型

我们需要定义 Todo 项目的数据结构,并设置数据库连接。

go-todo 目录下创建 models 文件夹,并在其中创建 todo.go 文件。

文件路径: go-todo/models/todo.go

package models // 定义 models 包,用于存放数据模型和数据库操作
import (
    "time" // 导入时间包,用于处理时间相关字段
    "gorm.io/driver/sqlite" // 导入 GORM 的 SQLite 驱动
    "gorm.io/gorm" // 导入 GORM ORM 框架
)
type Todo struct { // 定义 Todo 结构体,表示待办事项的数据模型
    ID          uint           `json:"id" gorm:"primaryKey"` // ID 字段,无符号整数类型,作为主键,JSON 序列化时字段名为 "id"
    Title       string         `json:"title" gorm:"not null"` // 标题字段,字符串类型,数据库中不允许为空
    Description string         `json:"description"` // 描述字段,字符串类型,可选内容
    Completed   bool           `json:"completed" gorm:"default:false"` // 完成状态字段,布尔类型,默认值为 false(未完成)
    CreatedAt   time.Time      `json:"created_at"` // 创建时间字段,GORM 会自动管理此字段
    UpdatedAt   time.Time      `json:"updated_at"` // 更新时间字段,GORM 会自动管理此字段
    DeletedAt   gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间字段,JSON 序列化时忽略("-"),并在数据库中创建索引,用于实现软删除功能
}
var DB *gorm.DB // 声明全局数据库连接对象,指向 GORM 数据库实例
func InitDB() { // 初始化数据库连接的函数
    var err error // 声明错误变量
    DB, err = gorm.Open(sqlite.Open("todo.db"), &gorm.Config{}) // 打开 SQLite 数据库连接,数据库文件名为 "todo.db",使用默认配置
    if err != nil { // 检查数据库连接是否出错
        panic("failed to connect database") // 如果连接失败,触发 panic 终止程序
    }
    DB.AutoMigrate(&Todo{}) // 自动迁移数据库结构,根据 Todo 结构体创建或更新数据库表
}

知识点:

  • GORM 模型: 在 GORM 中,一个标准的 Go 结构体可以映射成一个数据库表。结构体字段上的 gorm:"..." 标签用于定义列的属性,如 primaryKey(主键)、not null(非空)等。

  • gorm.DeletedAt: 这是一个特殊的 GORM 类型,用于实现“软删除”。当调用 DB.Delete() 时,GORM 不会从数据库中物理删除记录,而是将 deleted_at 字段设置为当前时间。在查询时,GORM 会自动忽略这些被软删除的记录。

  • InitDB 函数: 这个函数负责初始化数据库连接。它使用 GORM 打开一个 SQLite 数据库文件(如果 todo.db 不存在,GORM 会自动创建它)。DB.AutoMigrate(&Todo{}) 会自动检查 Todo 结构体对应的表是否存在,如果不存在,则会创建该表。

  1. 编写 API 处理器

处理器(Handlers)负责处理传入的 HTTP 请求,执行业务逻辑,并返回响应。

go-todo 目录下创建 handlers 文件夹,并在其中创建 todo.go 文件。

文件路径: go-todo/handlers/todo.go

package handlers // 定义 handlers 包,用于存放 HTTP 请求处理函数
import (
    "strconv" // 导入字符串转换包,用于将字符串转换为数字类型
    "todo/models" // 导入 models 包,用于访问数据模型和数据库
    "github.com/gin-gonic/gin" // 导入 Gin 框架,用于处理 HTTP 请求和响应
)
func CreatePost(c *gin.Context) { // 创建新的待办事项的处理函数,c 为 Gin 上下文对象
    var todo models.Todo // 声明一个 Todo 结构体变量,用于接收请求数据
    if err := c.ShouldBindJSON(&todo); err != nil { // 尝试将请求体的 JSON 数据绑定到 todo 变量,检查绑定是否出错
        c.JSON(400, gin.H{"error": err.Error()}) // 如果绑定失败,返回 400 错误状态码和错误信息
        return // 函数返回,不继续执行
    }
    result := models.DB.Create(&todo) // 使用 GORM 将 todo 对象插入数据库
    if result.Error != nil { // 检查数据库操作是否出错
        c.JSON(500, gin.H{"error": result.Error.Error()}) // 如果出错,返回 500 服务器错误和错误信息
        return // 函数返回
    }
    c.JSON(201, todo) // 如果成功,返回 201 创建成功状态码和创建的 todo 对象
}
func GetPosts(c *gin.Context) { // 获取所有待办事项的处理函数
    var todos []models.Todo // 声明一个 Todo 切片,用于存储查询结果
    result := models.DB.Find(&todos) // 使用 GORM 从数据库查询所有 Todo 记录
    if result.Error != nil { // 检查数据库查询是否出错
        c.JSON(500, gin.H{"error": result.Error.Error()}) // 如果出错,返回 500 错误和错误信息
        return // 函数返回
    }
    c.JSON(200, todos) // 如果成功,返回 200 成功状态码和所有 todo 对象的列表
}
func GetPost(c *gin.Context) { // 根据 ID 获取单个待办事项的处理函数
    id, err := strconv.ParseUint(c.Param("id"), 10, 32) // 从 URL 参数中获取 ID,将字符串转换为无符号 32 位整数
    if err != nil { // 检查字符串转换是否出错
        c.JSON(400, gin.H{"error": "Invalid ID"}) // 如果 ID 格式无效,返回 400 错误
        return // 函数返回
    }
    var todo models.Todo // 声明一个 Todo 结构体变量,用于存储查询结果
    result := models.DB.First(&todo, id) // 使用 GORM 根据 ID 从数据库查询单条记录
    if result.Error != nil { // 检查查询是否出错(如记录不存在)
        c.JSON(404, gin.H{"error": "Todo not found"}) // 如果记录不存在,返回 404 未找到错误
        return // 函数返回
    }
    c.JSON(200, todo) // 如果成功,返回 200 成功状态码和查询到的 todo 对象
}
func UpdatePost(c *gin.Context) { // 更新待办事项的处理函数
    id, err := strconv.ParseUint(c.Param("id"), 10, 32) // 从 URL 参数中获取 ID,转换为无符号整数
    if err != nil { // 检查 ID 转换是否出错
        c.JSON(400, gin.H{"error": "Invalid ID"}) // 如果 ID 无效,返回 400 错误
        return // 函数返回
    }
    var updates map[string]interface{} // 声明一个 map 变量,用于存储需要更新的字段(键为字符串,值为任意类型)
    if err := c.ShouldBindJSON(&updates); err != nil { // 将请求体的 JSON 数据绑定到 updates map,检查是否出错
        c.JSON(400, gin.H{"error": err.Error()}) // 如果绑定失败,返回 400 错误和错误信息
        return // 函数返回
    }
    result := models.DB.Model(&models.Todo{}).Where("id = ?", id).Updates(updates) // 使用 GORM 根据 ID 更新指定 Todo 记录的字段
    if result.Error != nil { // 检查数据库更新操作是否出错
        c.JSON(500, gin.H{"error": result.Error.Error()}) // 如果出错,返回 500 服务器错误和错误信息
        return // 函数返回
    }
    if result.RowsAffected == 0 { // 检查是否有记录被更新(RowsAffected 表示受影响的行数)
        c.JSON(404, gin.H{"error": "Todo not found"}) // 如果没有记录被更新,说明记录不存在,返回 404 错误
        return // 函数返回
    }
    var todo models.Todo // 声明一个 Todo 变量,用于获取更新后的最新数据
    models.DB.First(&todo, id) // 根据 ID 重新查询最新的 Todo 记录
    c.JSON(200, todo) // 返回 200 成功状态码和更新后的 todo 对象
}
func DeletePost(c *gin.Context) { // 删除待办事项的处理函数
    id, err := strconv.ParseUint(c.Param("id"), 10, 32) // 从 URL 参数中获取 ID,转换为无符号整数
    if err != nil { // 检查 ID 转换是否出错
        c.JSON(400, gin.H{"error": "Invalid ID"}) // 如果 ID 无效,返回 400 错误
        return // 函数返回
    }
    result := models.DB.Delete(&models.Todo{}, id) // 使用 GORM 根据 ID 删除 Todo 记录(执行软删除)
    if result.Error != nil { // 检查数据库删除操作是否出错
        c.JSON(500, gin.H{"error": result.Error.Error()}) // 如果出错,返回 500 服务器错误和错误信息
        return // 函数返回
    }
    if result.RowsAffected == 0 { // 检查是否有记录被删除
        c.JSON(404, gin.H{"error": "Todo not found"}) // 如果没有记录被删除,说明记录不存在,返回 404 错误
        return // 函数返回
    }
    c.JSON(200, gin.H{"message": "Todo deleted"}) // 如果成功,返回 200 成功状态码和删除成功的消息
}

知识点:

  • Gin 上下文 (*gin.Context): Gin 的核心是 Context 对象,它封装了 HTTP 请求和响应的所有信息。我们可以从中获取请求参数、路径、头信息,并用它来返回 JSON、HTML 等格式的响应。

  • CRUD 操作:

    • CreatePost: 使用 c.ShouldBindJSON 将请求体中的 JSON 数据绑定到 Todo 结构体。然后使用 DB.Create 将其保存到数据库。

    • GetPosts: 使用 DB.Find 查询 todos 表中的所有记录。

    • GetPost: 从 URL 路径中获取 id 参数,并使用 DB.First 按 ID 查询单个记录。

    • UpdatePost: 获取 id 参数和请求体中的更新数据。使用 DB.Model(&models.Todo{}).Where("id = ?", id).Updates(updates) 来更新特定字段,而不是替换整个对象。

    • DeletePost: 根据 id 参数使用 DB.Delete 来软删除记录。

  • 错误处理: 在每个数据库操作后,我们都检查 result.Error 是否为 nil。如果不是,就意味着操作出错了,我们会向客户端返回一个 500(服务器内部错误)或 404(未找到)的响应。

  1. 设置主程序和路由

main.go 是我们应用的入口点。在这里,我们将初始化数据库、创建 Gin 引擎、配置路由和中间件,并启动服务器。

go-todo 项目的根目录下创建 main.go 文件。

文件路径: go-todo/main.go

package main // 定义 main 包,Go 程序的入口包
import (
    "todo/handlers" // 导入 handlers 包,包含所有 HTTP 请求处理函数
    "todo/models" // 导入 models 包,用于数据库初始化和数据模型
    "github.com/gin-gonic/gin" // 导入 Gin Web 框架
)
func main() { // 主函数,程序的入口点
    models.InitDB() // 调用数据库初始化函数,连接数据库并执行自动迁移
    r := gin.Default() // 创建一个默认的 Gin 路由引擎,包含 Logger 和 Recovery 中间件
    r.Use(func(c *gin.Context) { // 添加一个全局中间件,用于处理跨域资源共享(CORS)
        c.Writer.Header().Set("Access-Control-Allow-Origin", "*") // 设置允许所有来源的跨域请求("*" 表示任意域名)
        c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") // 设置允许的 HTTP 请求方法
        c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") // 设置允许的请求头字段
        if c.Request.Method == "OPTIONS" { // 检查是否是预检请求(OPTIONS 方法)
            c.AbortWithStatus(204) // 如果是预检请求,返回 204 无内容状态码,并终止后续处理
            return // 函数返回
        }
        c.Next() // 继续执行下一个中间件或处理函数
    })
    api := r.Group("/api") // 创建一个路由组,所有路由都以 "/api" 为前缀
    { // 在路由组中定义 API 端点
        api.GET("/todos", handlers.GetPosts) // GET /api/todos - 获取所有待办事项列表
        api.POST("/todos", handlers.CreatePost) // POST /api/todos - 创建新的待办事项
        api.GET("/todos/:id", handlers.GetPost) // GET /api/todos/:id - 根据 ID 获取单个待办事项
        api.PUT("/todos/:id", handlers.UpdatePost) // PUT /api/todos/:id - 根据 ID 更新待办事项
        api.DELETE("/todos/:id", handlers.DeletePost) // DELETE /api/todos/:id - 根据 ID 删除待办事项
    }
    r.Run(":8080") // 启动 HTTP 服务器,监听 8080 端口
}

知识点:

  • CORS 中间件: 由于我们的前端和后端将运行在不同的端口上(开发环境下),浏览器会阻止跨域请求。我们通过一个自定义的 Gin 中间件来设置 Access-Control-Allow-Origin 等响应头,从而允许来自任何源的请求。

  • 路由分组: r.Group("/api") 创建了一个路由组。组内的所有路由都会自动加上 /api 前缀,这有助于组织和版本化我们的 API。

  • r.Run(":8080"): 启动 HTTP 服务器并监听在 8080 端口。

至此,后端部分已经完成!你可以在 go-todo 目录下运行 go run main.go 来启动它。

第二部分:构建 React 前端

现在我们来构建第一个前端应用。

  1. 初始化 React 项目

在与 go-todo 同级的目录下,使用 Vite 创建一个新的 React + TypeScript 项目。

Bash

pnpm create vite react-todo --template react-ts
cd react-todo

然后,安装我们需要的额外依赖:tailwindcss 和它的 Vite 插件。

Bash

pnpm install -D tailwindcss @tailwindcss/vite

知识点:

  • Vite: Vite 使用原生 ES 模块和 esbuild 来提供极快的冷启动和热更新(HMR),大大提升了开发体验。

  • pnpm: 一个快速、节省磁盘空间的包管理工具。

  1. 配置 Tailwind CSS

为了让 Tailwind CSS 工作,我们需要进行一些配置。

首先,在 react-todo 根目录下创建 vite.config.ts 文件。

文件路径: react-todo/vite.config.ts

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
  plugins: [react(),tailwindcss()],
})

然后,在 src 目录下创建一个 index.css 文件。

文件路径: react-todo/src/index.css

@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

最后,在 src/main.tsx 中引入这个 CSS 文件,确保它在应用中生效。

文件路径: react-todo/src/main.tsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

知识点:

  • @tailwindcss/vite 插件简化了在 Vite 项目中集成 Tailwind CSS 的过程。

  • @import "tailwindcss"; 是 Tailwind v4+ 的新语法,用于在 CSS 文件中引入 Tailwind 的所有功能类、基础样式和组件。

  1. 构建应用组件

我们将把所有逻辑都放在 App.tsx 这个主组件中。

文件路径: react-todo/src/App.tsx

import { useEffect, useState } from "react" // 导入 React hooks:useEffect 用于副作用操作,useState 用于状态管理
interface Todo { // 定义 Todo 接口,描述待办事项的数据结构
  id: number // 待办事项的唯一标识符
  title: string // 待办事项的标题
  completed: boolean // 待办事项的完成状态
}
function App() { // 定义 App 组件,应用的主组件
  const [title, setTitle] = useState("") // 声明 title 状态,用于存储新增待办事项的标题输入
  const [content, setContent] = useState<Todo[]>([]) // 声明 content 状态,用于存储所有待办事项的数组
  const [loading, setLoading] = useState(false) // 声明 loading 状态,用于标识是否正在添加新待办事项
  const [editingId, setEditingId] = useState<number | null>(null) // 声明 editingId 状态,用于记录当前正在编辑的待办事项 ID,null 表示没有正在编辑的项
  const [editTitle, setEditTitle] = useState("") // 声明 editTitle 状态,用于存储编辑模式下的标题临时值
  const [operatingId, setOperatingId] = useState<number | null>(null) // 声明 operatingId 状态,用于记录当前正在执行操作(删除/更新/保存)的待办事项 ID
  const fetchTodos = () => { // 定义 fetchTodos 函数,用于从后端获取所有待办事项
    fetch("http://localhost:8080/api/todos") // 发送 GET 请求到后端 API
      .then(response => response.json()) // 将响应转换为 JSON 格式
      .then(data => setContent(data || [])) // 更新 content 状态,如果 data 为 null/undefined 则使用空数组
      .catch(error => console.error("获取数据失败:", error)) // 捕获并打印错误信息
  }
  useEffect(() => { // 使用 useEffect hook,在组件挂载时执行
    fetchTodos() // 组件首次渲染时获取待办事项列表
  }, []) // 空依赖数组表示只在组件挂载时执行一次
  const handleAdd = () => { // 定义 handleAdd 函数,用于添加新的待办事项
    if (!title.trim()) return // 如果标题为空或只包含空格,直接返回不执行添加操作
    setLoading(true) // 设置 loading 状态为 true,显示加载中状态
    fetch("http://localhost:8080/api/todos", { // 发送 POST 请求到后端 API
      method: "POST", // 指定 HTTP 方法为 POST
      headers: { "Content-Type": "application/json" }, // 设置请求头,告知服务器发送的是 JSON 数据
      body: JSON.stringify({ title, completed: false }) // 将待办事项对象转换为 JSON 字符串作为请求体
    })
      .then(response => response.json()) // 将响应转换为 JSON 格式
      .then(() => { // 添加成功后执行的操作
        setTitle("") // 清空输入框
        fetchTodos() // 重新获取待办事项列表以更新显示
      })
      .catch(error => console.error("添加失败:", error)) // 捕获并打印错误信息
      .finally(() => setLoading(false)) // 无论成功还是失败,最终都将 loading 状态设为 false
  }
  const handleDelete = (id: number) => { // 定义 handleDelete 函数,用于删除指定 ID 的待办事项
    setOperatingId(id) // 设置当前操作的 ID,用于显示加载状态
    fetch(`http://localhost:8080/api/todos/${id}`, { // 发送 DELETE 请求到后端 API,URL 中包含待删除项的 ID
      method: "DELETE" // 指定 HTTP 方法为 DELETE
    })
      .then(() => fetchTodos()) // 删除成功后重新获取待办事项列表
      .catch(error => console.error("删除失败:", error)) // 捕获并打印错误信息
      .finally(() => setOperatingId(null)) // 操作完成后清除操作 ID
  }
  const handleToggle = (id: number, completed: boolean) => { // 定义 handleToggle 函数,用于切换待办事项的完成状态
    setOperatingId(id) // 设置当前操作的 ID
    fetch(`http://localhost:8080/api/todos/${id}`, { // 发送 PUT 请求到后端 API
      method: "PUT", // 指定 HTTP 方法为 PUT
      headers: { "Content-Type": "application/json" }, // 设置请求头
      body: JSON.stringify({ completed: !completed }) // 将完成状态取反后发送到服务器
    })
      .then(() => fetchTodos()) // 更新成功后重新获取待办事项列表
      .catch(error => console.error("更新失败:", error)) // 捕获并打印错误信息
      .finally(() => setOperatingId(null)) // 操作完成后清除操作 ID
  }
  const handleEdit = (id: number, currentTitle: string) => { // 定义 handleEdit 函数,用于进入编辑模式
    setEditingId(id) // 设置正在编辑的项的 ID
    setEditTitle(currentTitle) // 将当前标题填充到编辑输入框
  }
  const handleSaveEdit = (id: number) => { // 定义 handleSaveEdit 函数,用于保存编辑后的标题
    if (!editTitle.trim()) return // 如果编辑后的标题为空或只包含空格,直接返回不保存
    setOperatingId(id) // 设置当前操作的 ID
    fetch(`http://localhost:8080/api/todos/${id}`, { // 发送 PUT 请求到后端 API
      method: "PUT", // 指定 HTTP 方法为 PUT
      headers: { "Content-Type": "application/json" }, // 设置请求头
      body: JSON.stringify({ title: editTitle }) // 将新标题发送到服务器
    })
      .then(() => { // 保存成功后执行的操作
        fetchTodos() // 重新获取待办事项列表
        setEditingId(null) // 清除编辑状态
        setEditTitle("") // 清空编辑输入框
      })
      .catch(error => console.error("更新失败:", error)) // 捕获并打印错误信息
      .finally(() => setOperatingId(null)) // 操作完成后清除操作 ID
  }
  const handleCancelEdit = () => { // 定义 handleCancelEdit 函数,用于取消编辑操作
    setEditingId(null) // 清除编辑状态
    setEditTitle("") // 清空编辑输入框
  }
  return (
    <div className="min-h-screen bg-sky-300"> {/* 最外层容器,最小高度为屏幕高度,天蓝色背景 */}
      <div className="max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl mx-auto px-4"> {/* 响应式容器,不同屏幕尺寸下有不同的最大宽度,水平居中,左右内边距 */}
        <nav className="bg-sky-400 -mx-4 sm:mx-0 sm:rounded-b-lg"> {/* 导航栏,深天蓝色背景,小屏幕全宽,大屏幕有圆角 */}
          <h1 className="text-2xl sm:text-3xl font-bold text-center text-gray-800 py-4">Todo List</h1> {/* 标题,响应式字体大小,粗体,居中,深灰色文字,上下内边距 */}
        </nav>
        <main className="py-4 sm:py-6"> {/* 主内容区域,响应式上下内边距 */}
          <div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center mb-4 bg-white p-3 rounded-md shadow"> {/* 添加待办事项的输入区域,小屏幕垂直布局,大屏幕水平布局,白色背景,圆角,阴影 */}
          <input
            type="text" // 文本输入框
            value={title} // 绑定 title 状态
            onChange={(e) => setTitle(e.target.value)} // 输入变化时更新 title 状态
            onKeyDown={(e) => e.key === 'Enter' && handleAdd()} // 按下 Enter 键时触发添加操作
            placeholder="输入待办事项..." // 占位符文本
            className="flex-1 p-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-sky-500" // 样式:占据剩余空间,内边距,圆角,灰色边框,聚焦时显示天蓝色环形轮廓
          />
          <button
            onClick={handleAdd} // 点击时触发添加操作
            disabled={loading || !title.trim()} // 当正在加载或标题为空时禁用按钮
            className="bg-sky-500 px-4 py-2 rounded-md text-white hover:bg-sky-600 disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto" // 样式:天蓝色背景,白色文字,悬停时变深,禁用时半透明且鼠标显示禁止图标,小屏幕全宽,大屏幕自适应宽度
          >
            {loading ? "添加中..." : "添加"} {/* 根据 loading 状态显示不同文本 */}
          </button>
        </div>
        <div className="space-y-2"> {/* 待办事项列表容器,子元素之间垂直间距 */}
          {content.length === 0 ? ( // 如果待办事项列表为空
            <p className="text-center text-gray-600 mt-8">暂无待办事项</p> // 显示空状态提示
          ) : (
            content.map((item) => ( // 遍历待办事项数组,为每个项渲染一个卡片
              <div
                key={item.id} // React 列表渲染必需的 key 属性,使用 ID 作为唯一标识
                className="flex flex-col sm:flex-row sm:items-center gap-3 bg-white p-3 rounded-md shadow hover:shadow-md transition-shadow" // 样式:响应式布局,白色背景,圆角,阴影,悬停时阴影加深,过渡动画
              >
                <div className="flex items-center gap-3 flex-1 min-w-0"> {/* 待办事项内容区域,垂直居中对齐,占据剩余空间 */}
                  <input
                    type="checkbox" // 复选框
                    checked={item.completed} // 根据 completed 状态设置选中状态
                    onChange={() => handleToggle(item.id, item.completed)} // 改变时切换完成状态
                    disabled={operatingId === item.id} // 如果正在操作此项则禁用
                    className="w-5 h-5 cursor-pointer flex-shrink-0 disabled:opacity-50" // 样式:固定宽高,鼠标指针,不缩小,禁用时半透明
                  />
                  {editingId === item.id ? ( // 如果当前项正在编辑
                    <input
                      type="text" // 文本输入框
                      value={editTitle} // 绑定 editTitle 状态
                      onChange={(e) => setEditTitle(e.target.value)} // 输入变化时更新 editTitle 状态
                      onKeyDown={(e) => { // 键盘事件处理
                        if (e.key === 'Enter') handleSaveEdit(item.id) // 按 Enter 键保存
                        if (e.key === 'Escape') handleCancelEdit() // 按 Escape 键取消
                      }}
                      autoFocus // 自动聚焦到此输入框
                      className="flex-1 p-2 rounded border border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-500 min-w-0" // 样式:占据剩余空间,内边距,圆角,天蓝色边框,聚焦时显示天蓝色环形轮廓
                    />
                  ) : (
                    <p className={`flex-1 text-gray-800 break-words ${item.completed ? 'line-through text-gray-400' : ''}`}> {/* 待办事项标题,如果已完成则显示删除线和浅灰色 */}
                      {item.title} {/* 显示标题文本 */}
                    </p>
                  )}
                </div>
                <div className="flex gap-2 flex-shrink-0"> {/* 操作按钮区域,按钮之间有间距,不缩小 */}
                  {editingId === item.id ? ( // 如果当前项正在编辑,显示保存和取消按钮
                    <>
                      <button
                        onClick={() => handleSaveEdit(item.id)} // 点击保存编辑
                        disabled={!editTitle.trim() || operatingId === item.id} // 如果标题为空或正在操作则禁用
                        className="bg-green-500 px-3 py-1 rounded-md text-white hover:bg-green-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed" // 样式:绿色背景,白色文字,悬停变深,小字体,禁用时半透明
                      >
                        {operatingId === item.id ? "保存中..." : "保存"} {/* 根据操作状态显示不同文本 */}
                      </button>
                      <button
                        onClick={handleCancelEdit} // 点击取消编辑
                        disabled={operatingId === item.id} // 如果正在操作则禁用
                        className="bg-gray-500 px-3 py-1 rounded-md text-white hover:bg-gray-600 text-sm disabled:opacity-50" // 样式:灰色背景,白色文字,悬停变深
                      >
                        取消
                      </button>
                    </>
                  ) : ( // 如果未在编辑模式,显示编辑和删除按钮
                    <>
                      <button
                        onClick={() => handleEdit(item.id, item.title)} // 点击进入编辑模式
                        disabled={operatingId === item.id || editingId !== null} // 如果正在操作此项或有其他项正在编辑则禁用
                        className="bg-blue-500 px-3 py-1 rounded-md text-white hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed" // 样式:蓝色背景,白色文字,悬停变深
                      >
                        编辑
                      </button>
                      <button
                        onClick={() => handleDelete(item.id)} // 点击删除此项
                        disabled={operatingId === item.id || editingId !== null} // 如果正在操作此项或有其他项正在编辑则禁用
                        className="bg-red-500 px-3 py-1 rounded-md text-white hover:bg-red-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed" // 样式:红色背景,白色文字,悬停变深
                      >
                        {operatingId === item.id ? "删除中..." : "删除"} {/* 根据操作状态显示不同文本 */}
                      </button>
                    </>
                  )}
                </div>
              </div>
            ))
          )}
        </div>
        </main>
      </div>
    </div>
  )
}
export default App // 导出 App 组件作为默认导出

知识点:

  • React Hooks:

    • useState: 用于在函数组件中添加和管理状态。我们用它来管理输入框内容 (title)、todo 列表 (content)、加载状态 (loading) 等。

    • useEffect: 用于处理副作用,如数据获取。useEffect(..., []) 中的空依赖数组意味着这个 effect 只会在组件首次挂载时运行一次,非常适合用来初始化数据。

  • 数据获取: 我们使用浏览器原生的 fetch API 与后端进行通信。所有 API 请求都指向我们之前在 Go 后端中定义的 http://localhost:8080/api/todos 端点。

  • 条件渲染: 我们使用三元运算符来根据不同状态显示不同的 UI。例如,content.length === 0 ? <p>暂无...</p> : content.map(...) 用于在列表为空时显示提示信息。同样,我们用它来切换显示模式和编辑模式。

  • 事件处理: 通过 onClickonChangeonKeyDown 等事件处理器来响应用户交互,并调用相应的函数(如 handleAdd, handleDelete)来更新状态和调用 API。

  • Tailwind CSS 类: 你可以看到组件的 className 中使用了大量的 Tailwind CSS 功能类(如 min-h-screen, bg-sky-300, flex, rounded-md),这些类直接在 HTML 中定义了元素的样式,无需编写单独的 CSS 文件。

第三部分:构建 Vue 前端

接下来,我们用 Vue 来实现一个功能完全相同的前端应用。

  1. 初始化 Vue 项目

同样,在与 go-todo 同级的目录下,使用 Vite 创建一个新的 Vue + TypeScript 项目。

pnpm create vite vue-todo --template vue-ts
cd vue-todo

安装 tailwindcss 依赖:

pnpm install -D tailwindcss @tailwindcss/vite
  1. 配置 Tailwind CSS

配置过程与 React 项目几乎完全相同,再次不做过多解释。

  1. 构建应用组件

在 Vue 中,我们同样将所有逻辑放在主组件 App.vue 中。

文件路径: vue-todo/src/App.vue

<script setup lang="ts"> // 使用 Vue 3 Composition API 的 setup 语法糖,启用 TypeScript
import { ref, onMounted } from 'vue' // 导入 Vue 3 的响应式 API:ref 用于创建响应式数据,onMounted 用于组件挂载后的生命周期钩子
interface Todo { // 定义 Todo 接口,描述待办事项的数据结构
  id: number // 待办事项的唯一标识符
  title: string // 待办事项的标题
  completed: boolean // 待办事项的完成状态
}
const title = ref('') // 创建响应式引用,用于存储新增待办事项的标题输入
const content = ref<Todo[]>([]) // 创建响应式引用,用于存储所有待办事项的数组,类型为 Todo 数组
const loading = ref(false) // 创建响应式引用,用于标识是否正在添加新待办事项
const editingId = ref<number | null>(null) // 创建响应式引用,用于记录当前正在编辑的待办事项 ID,null 表示没有正在编辑的项
const editTitle = ref('') // 创建响应式引用,用于存储编辑模式下的标题临时值
const operatingId = ref<number | null>(null) // 创建响应式引用,用于记录当前正在执行操作(删除/更新/保存)的待办事项 ID
const fetchTodos = () => { // 定义 fetchTodos 函数,用于从后端获取所有待办事项
  fetch('http://localhost:8080/api/todos') // 发送 GET 请求到后端 API
    .then(response => response.json()) // 将响应转换为 JSON 格式
    .then(data => content.value = data || []) // 更新 content 的值,如果 data 为 null/undefined 则使用空数组(注意:Vue 3 的 ref 需要通过 .value 访问)
    .catch(error => console.error('获取数据失败:', error)) // 捕获并打印错误信息
}
onMounted(() => { // 使用 onMounted 生命周期钩子,在组件挂载到 DOM 后执行
  fetchTodos() // 组件挂载后立即获取待办事项列表
})
const handleAdd = () => { // 定义 handleAdd 函数,用于添加新的待办事项
  if (!title.value.trim()) return // 如果标题为空或只包含空格,直接返回不执行添加操作
  loading.value = true // 设置 loading 状态为 true,显示加载中状态
  fetch('http://localhost:8080/api/todos', { // 发送 POST 请求到后端 API
    method: 'POST', // 指定 HTTP 方法为 POST
    headers: { 'Content-Type': 'application/json' }, // 设置请求头,告知服务器发送的是 JSON 数据
    body: JSON.stringify({ title: title.value, completed: false }) // 将待办事项对象转换为 JSON 字符串作为请求体
  })
    .then(response => response.json()) // 将响应转换为 JSON 格式
    .then(() => { // 添加成功后执行的操作
      title.value = '' // 清空输入框
      fetchTodos() // 重新获取待办事项列表以更新显示
    })
    .catch(error => console.error('添加失败:', error)) // 捕获并打印错误信息
    .finally(() => loading.value = false) // 无论成功还是失败,最终都将 loading 状态设为 false
}
const handleDelete = (id: number) => { // 定义 handleDelete 函数,用于删除指定 ID 的待办事项
  operatingId.value = id // 设置当前操作的 ID,用于显示加载状态
  fetch(`http://localhost:8080/api/todos/${id}`, { // 发送 DELETE 请求到后端 API,URL 中包含待删除项的 ID
    method: 'DELETE' // 指定 HTTP 方法为 DELETE
  })
    .then(() => fetchTodos()) // 删除成功后重新获取待办事项列表
    .catch(error => console.error('删除失败:', error)) // 捕获并打印错误信息
    .finally(() => operatingId.value = null) // 操作完成后清除操作 ID
}
const handleToggle = (id: number, completed: boolean) => { // 定义 handleToggle 函数,用于切换待办事项的完成状态
  operatingId.value = id // 设置当前操作的 ID
  fetch(`http://localhost:8080/api/todos/${id}`, { // 发送 PUT 请求到后端 API
    method: 'PUT', // 指定 HTTP 方法为 PUT
    headers: { 'Content-Type': 'application/json' }, // 设置请求头
    body: JSON.stringify({ completed: !completed }) // 将完成状态取反后发送到服务器
  })
    .then(() => fetchTodos()) // 更新成功后重新获取待办事项列表
    .catch(error => console.error('更新失败:', error)) // 捕获并打印错误信息
    .finally(() => operatingId.value = null) // 操作完成后清除操作 ID
}
const handleEdit = (id: number, currentTitle: string) => { // 定义 handleEdit 函数,用于进入编辑模式
  editingId.value = id // 设置正在编辑的项的 ID
  editTitle.value = currentTitle // 将当前标题填充到编辑输入框
}
const handleSaveEdit = (id: number) => { // 定义 handleSaveEdit 函数,用于保存编辑后的标题
  if (!editTitle.value.trim()) return // 如果编辑后的标题为空或只包含空格,直接返回不保存
  operatingId.value = id // 设置当前操作的 ID
  fetch(`http://localhost:8080/api/todos/${id}`, { // 发送 PUT 请求到后端 API
    method: 'PUT', // 指定 HTTP 方法为 PUT
    headers: { 'Content-Type': 'application/json' }, // 设置请求头
    body: JSON.stringify({ title: editTitle.value }) // 将新标题发送到服务器
  })
    .then(() => { // 保存成功后执行的操作
      fetchTodos() // 重新获取待办事项列表
      editingId.value = null // 清除编辑状态
      editTitle.value = '' // 清空编辑输入框
    })
    .catch(error => console.error('更新失败:', error)) // 捕获并打印错误信息
    .finally(() => operatingId.value = null) // 操作完成后清除操作 ID
}
const handleCancelEdit = () => { // 定义 handleCancelEdit 函数,用于取消编辑操作
  editingId.value = null // 清除编辑状态
  editTitle.value = '' // 清空编辑输入框
}
</script>
<template> <!-- Vue 模板部分,定义组件的 HTML 结构 -->
  <div class="min-h-screen bg-sky-300"> <!-- 最外层容器,最小高度为屏幕高度,天蓝色背景 -->
    <div class="max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl mx-auto px-4"> <!-- 响应式容器,不同屏幕尺寸下有不同的最大宽度,水平居中,左右内边距 -->
      <nav class="bg-sky-400 -mx-4 sm:mx-0 sm:rounded-b-lg"> <!-- 导航栏,深天蓝色背景,小屏幕全宽,大屏幕有圆角 -->
        <h1 class="text-2xl sm:text-3xl font-bold text-center text-gray-800 py-4">Todo List</h1> <!-- 标题,响应式字体大小,粗体,居中,深灰色文字,上下内边距 -->
      </nav>
      <main class="py-4 sm:py-6"> <!-- 主内容区域,响应式上下内边距 -->
        <div class="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center mb-4 bg-white p-3 rounded-md shadow"> <!-- 添加待办事项的输入区域,小屏幕垂直布局,大屏幕水平布局,白色背景,圆角,阴影 -->
          <input
            type="text" <!-- 文本输入框 -->
            v-model="title" <!-- 使用 v-model 双向绑定 title 响应式数据 -->
            @keydown.enter="handleAdd" <!-- 监听 Enter 键按下事件,触发 handleAdd 函数 -->
            placeholder="输入待办事项..." <!-- 占位符文本 -->
            class="flex-1 p-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-sky-500" <!-- 样式:占据剩余空间,内边距,圆角,灰色边框,聚焦时显示天蓝色环形轮廓 -->
          />
          <button
            @click="handleAdd" <!-- 监听点击事件,触发 handleAdd 函数 -->
            :disabled="loading || !title.trim()" <!-- 使用 v-bind 绑定 disabled 属性,当正在加载或标题为空时禁用按钮 -->
            class="bg-sky-500 px-4 py-2 rounded-md text-white hover:bg-sky-600 disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto" <!-- 样式:天蓝色背景,白色文字,悬停时变深,禁用时半透明且鼠标显示禁止图标,小屏幕全宽,大屏幕自适应宽度 -->
          >
            {{ loading ? '添加中...' : '添加' }} <!-- 使用插值表达式,根据 loading 状态显示不同文本 -->
          </button>
        </div>
        <div class="space-y-2"> <!-- 待办事项列表容器,子元素之间垂直间距 -->
          <p v-if="content.length === 0" class="text-center text-gray-600 mt-8">暂无待办事项</p> <!-- 使用 v-if 条件渲染,如果列表为空则显示空状态提示 -->
          <div
            v-else <!-- 如果列表不为空,则执行下面的渲染 -->
            v-for="item in content" <!-- 使用 v-for 遍历 content 数组,为每个待办事项渲染一个卡片 -->
            :key="item.id" <!-- 绑定 key 属性,Vue 列表渲染必需,使用 ID 作为唯一标识 -->
            class="flex flex-col sm:flex-row sm:items-center gap-3 bg-white p-3 rounded-md shadow hover:shadow-md transition-shadow" <!-- 样式:响应式布局,白色背景,圆角,阴影,悬停时阴影加深,过渡动画 -->
          >
            <div class="flex items-center gap-3 flex-1 min-w-0"> <!-- 待办事项内容区域,垂直居中对齐,占据剩余空间 -->
              <input
                type="checkbox" <!-- 复选框 -->
                :checked="item.completed" <!-- 使用 v-bind 绑定 checked 属性,根据 completed 状态设置选中状态 -->
                @change="handleToggle(item.id, item.completed)" <!-- 监听 change 事件,改变时切换完成状态 -->
                :disabled="operatingId === item.id" <!-- 如果正在操作此项则禁用复选框 -->
                class="w-5 h-5 cursor-pointer flex-shrink-0 disabled:opacity-50" <!-- 样式:固定宽高,鼠标指针,不缩小,禁用时半透明 -->
              />
              <input
                v-if="editingId === item.id" <!-- 使用 v-if 条件渲染,如果当前项正在编辑则显示输入框 -->
                type="text" <!-- 文本输入框 -->
                v-model="editTitle" <!-- 使用 v-model 双向绑定 editTitle 响应式数据 -->
                @keydown.enter="handleSaveEdit(item.id)" <!-- 监听 Enter 键按下事件,保存编辑 -->
                @keydown.esc="handleCancelEdit" <!-- 监听 Escape 键按下事件,取消编辑 -->
                autofocus <!-- 自动聚焦到此输入框 -->
                class="flex-1 p-2 rounded border border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-500 min-w-0" <!-- 样式:占据剩余空间,内边距,圆角,天蓝色边框,聚焦时显示天蓝色环形轮廓 -->
              />
              <p
                v-else <!-- 如果未在编辑模式,则显示标题文本 -->
                :class="['flex-1 text-gray-800 break-words', item.completed ? 'line-through text-gray-400' : '']" <!-- 使用 v-bind 绑定 class,动态添加样式类,如果已完成则显示删除线和浅灰色 -->
              >
                {{ item.title }} <!-- 使用插值表达式显示标题文本 -->
              </p>
            </div>
            <div class="flex gap-2 flex-shrink-0"> <!-- 操作按钮区域,按钮之间有间距,不缩小 -->
              <template v-if="editingId === item.id"> <!-- 使用 template 和 v-if 条件渲染,如果当前项正在编辑,显示保存和取消按钮 -->
                <button
                  @click="handleSaveEdit(item.id)" <!-- 点击保存编辑 -->
                  :disabled="!editTitle.trim() || operatingId === item.id" <!-- 如果标题为空或正在操作则禁用 -->
                  class="bg-green-500 px-3 py-1 rounded-md text-white hover:bg-green-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed" <!-- 样式:绿色背景,白色文字,悬停变深,小字体,禁用时半透明 -->
                >
                  {{ operatingId === item.id ? '保存中...' : '保存' }} <!-- 根据操作状态显示不同文本 -->
                </button>
                <button
                  @click="handleCancelEdit" <!-- 点击取消编辑 -->
                  :disabled="operatingId === item.id" <!-- 如果正在操作则禁用 -->
                  class="bg-gray-500 px-3 py-1 rounded-md text-white hover:bg-gray-600 text-sm disabled:opacity-50" <!-- 样式:灰色背景,白色文字,悬停变深 -->
                >
                  取消
                </button>
              </template>
              <template v-else> <!-- 如果未在编辑模式,显示编辑和删除按钮 -->
                <button
                  @click="handleEdit(item.id, item.title)" <!-- 点击进入编辑模式 -->
                  :disabled="operatingId === item.id || editingId !== null" <!-- 如果正在操作此项或有其他项正在编辑则禁用 -->
                  class="bg-blue-500 px-3 py-1 rounded-md text-white hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed" <!-- 样式:蓝色背景,白色文字,悬停变深 -->
                >
                  编辑
                </button>
                <button
                  @click="handleDelete(item.id)" <!-- 点击删除此项 -->
                  :disabled="operatingId === item.id || editingId !== null" <!-- 如果正在操作此项或有其他项正在编辑则禁用 -->
                  class="bg-red-500 px-3 py-1 rounded-md text-white hover:bg-red-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed" <!-- 样式:红色背景,白色文字,悬停变深 -->
                >
                  {{ operatingId === item.id ? '删除中...' : '删除' }} <!-- 根据操作状态显示不同文本 -->
                </button>
              </template>
            </div>
          </div>
        </div>
      </main>
    </div>
  </div>
</template>

知识点:

  • Vue Composition API (<script setup>): 这是 Vue 3 中推荐的编写组件逻辑的方式。它让我们能更灵活地组织代码。

    • ref: 用于创建响应式变量。当 ref.value 属性改变时,Vue 会自动更新模板中所有用到它的地方。

    • onMounted: 一个生命周期钩子,在组件被挂载到 DOM 后调用。我们在这里调用 fetchTodos 来初始化数据,类似于 React 的 useEffect(..., [])

  • 模板语法:

    • v-model: 用于在表单输入和应用状态之间创建双向绑定。

    • @click, @keydown.enter: 是 v-on:clickv-on:keydown.enter 的缩写,用于监听 DOM 事件。

    • :disabled: 是 v-bind:disabled 的缩写,用于动态地绑定一个属性。

    • v-if, v-else: 用于条件渲染,根据表达式的真假来渲染一个块。

    • v-for: 用于循环渲染一个列表。

  • 逻辑对比: 你会发现 Vue 组件中的逻辑(如 handleAdd, handleDelete 等函数)与 React 组件中的几乎完全一样。主要的区别在于状态管理和模板更新的方式:React 使用 useState 和显式的 set 函数,而 Vue 使用 ref 和自动的响应式系统。

第四部分:运行整个应用

现在,你已经完成了所有编码工作!要运行整个应用,你需要:

  1. 启动后端:

    1. 打开一个终端,进入 go-todo 目录。

    2. 运行 go run main.go

    3. 你的 API 服务器现在应该运行在 http://localhost:8080

  2. 启动 React 前端:

    1. 打开第二个终端,进入 react-todo 目录。

    2. 运行 pnpm dev

    3. 你的 React 应用现在应该运行在(通常是)http://localhost:5173

  3. 启动 Vue 前端:

    1. 打开第三个终端,进入 vue-todo 目录。

    2. 运行 pnpm dev

    3. 你的 Vue 应用现在应该运行在(通常是)http://localhost:5174

现在你可以打开浏览器访问 React 或 Vue 应用的地址,并开始添加、编辑和删除你的待办事项了!

总结

恭喜你!你已经成功构建了一个包含 Go 后端和 React/Vue 双前端的全栈应用。通过这个项目,你实践了:

  • 使用 Gin 和 GORM 构建 RESTful API。

  • 使用 Vite 快速搭建现代前端开发环境。

  • 在 React (Hooks) 和 Vue (Composition API) 中进行状态管理、数据获取和事件处理。

  • 使用 Tailwind CSS 高效地构建 UI。

希望这个教程能帮助你更好地理解和掌握这些技术。你可以此为基础,继续探索更多功能,例如用户认证、更复杂的数据模型或部署到生产环境。

posted @ 2025-10-11 14:27  二见原莉莉子  阅读(21)  评论(0)    收藏  举报