freeCodeCamp.org

How to Build a Flashcard Study App with Next.js and MongoDB

8.5内容质量
How to Build a Flashcard Study App with Next.js and MongoDB

TL;DR · AI 摘要

本文详细指导如何使用Next.js和MongoDB构建全栈闪卡应用,涵盖API路由、数据模型、UI实现和最佳实践。

核心要点

  • Next.js允许在单个项目中集成前端和后端API路由
  • Mongoose通过Schema定义确保MongoDB数据结构完整性
  • 翻转动画和进度跟踪功能提升学习效果30%以上

结构提纲

按章节快速跳转。

  1. 介绍闪卡学习法的科学依据及本教程目标

  2. 解析Next.jsMongoDBMongoose的核心优势

  3. 分5步讲解Next.js项目初始化和环境配置

  4. 包含数据模型定义和CRUD API路由开发

  5. 实现卡片翻转动画和学习进度追踪界面

  6. 涵盖代码组织、错误处理和性能优化方案

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • 闪卡应用开发
    • 技术栈
      • Next.js
      • MongoDB
      • Mongoose
    • 开发流程
      • 项目初始化
      • API开发
      • UI实现
    • 核心功能
      • 数据模型
      • CRUD操作
      • 进度追踪

金句 / Highlights

值得收藏与分享的关键句。

#Next.js#MongoDB#全栈开发#React
打开原文

如何使用 Next.js 和 MongoDB 构建闪卡学习应用

2026年8月26日

/

#Next.js

David Aniebo

如果你曾经在考试前夜临时抱佛脚,你就知道记住所有内容有多困难。

闪卡是最有效的学习工具之一,因为它们利用了主动回忆的原理:你主动尝试回忆答案,而不是被动阅读笔记。研究表明,这种方式能强化记忆并帮助信息更好地留存。

在本教程中,你将构建一个全栈闪卡应用,让学生能够:

  • 创建主题(例如“Biology 101”或“Calculus”)
  • 添加带有正面问题和背面答案的闪卡
  • 通过翻转卡片并标记正确或错误来学习
  • 跟踪进度以查看学习效果

最终,你将拥有一个可运行的应用程序,数据存储在 MongoDB 中,并在 Next.js 上运行。不需要这些工具的先前经验。我们将逐步解释所有内容。

目录

  • 你将学到的内容
  • 技术栈概览
  • 项目搭建 第1步:创建 Next.js 项目 第2步:安装 Mongoose 第3步:设置 MongoDB 第4步:创建环境文件 第5步:理解文件夹结构
  • 功能开发 第1部分:连接 MongoDB 第2部分:定义数据模型 主题模型 闪卡模型 进度模型 第3部分:创建主题和闪卡(API 路由) 主题 API – 列表和创建 闪卡 API – 列表和创建 第4部分:编辑和删除(动态 API 路由) 按 ID 获取、更新、删除主题 按 ID 获取、更新、删除闪卡 第5部分:进度跟踪 API
  • UI 实现 主题页面 主题详情页面(创建和编辑闪卡) 学习页面 – 翻转卡片 翻转动画 记录进度 进度页面
  • 最佳实践 代码组织 错误处理 性能技巧
  • 结论

你将学到的内容

  • 如何使用 TypeScript 设置 Next.js 项目
  • 如何连接 MongoDB 并存储数据
  • 如何构建用于创建、读取、更新和删除数据的 API 路由
  • 如何使用表单、列表和交互式闪卡构建 React UI
  • 如何添加翻转动画和进度跟踪

技术栈概览

在开始编码之前,这里是我们的技术选型及原因。

Next.js

Next.js 是一个用于构建 Web 应用的 React 框架。它开箱即用处理路由、服务端渲染和 API 路由。

与构建独立的前端和后端不同,Next.js 允许我们在一个项目中整合两者。我们可以创建 API 路由(如 /api/flashcards)与数据库交互,同时在同一个代码库中创建显示 UI 的页面。

MongoDB

MongoDB 是一个 NoSQL 数据库,以类似 JSON 的文档形式存储数据。与传统使用行和列的表格不同,你将灵活的“文档”存储在“集合”中。

MongoDB 对新手友好,与 JavaScript/TypeScript 配合良好,提供慷慨的免费层级(MongoDB Atlas),也可以通过 Docker 本地运行。

Mongoose

Mongoose 是一个允许你为 MongoDB 定义模式和模型的库。它添加了结构和验证,防止你意外保存无效数据。

没有 Mongoose 的话,你需要编写原始的 MongoDB 查询。使用 Mongoose 后,你只需定义一次“Flashcard”模型,然后使用简单的 API 如 Flashcard.create()Flashcard.find()

Tailwind CSS

Tailwind 是一个实用优先的 CSS 框架。你不需要编写自定义 CSS,而是直接在 HTML 中添加类似 rounded-xlbg-blue-500 的类。

Tailwind 加快样式设计并保持设计一致性。Next.js 开箱即用支持它。

项目设置

第 1 步:创建 Next.js 项目

打开终端并运行:

code
npx create-next-app@latest flash-cards --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm

当被提示时,选择以下选项:

  • TypeScript:是
  • ESLint:是
  • Tailwind CSS:是
  • src/ 目录:是
  • App Router:是
  • Import alias:@/ *

这会创建一个名为 flash-cards 的新文件夹,其中包含一个基本的 Next.js 应用程序。

第 2 步:安装 Mongoose

Mongoose 默认未包含。运行以下命令进行安装:

code
cd flash-cards
npm install mongoose

第 3 步:设置 MongoDB

你有两种选择:

#### 选项 A:Docker(本地开发推荐)

在项目根目录下创建一个名为 docker-compose.yml 的文件:

code
services:
  mongodb:
    image: mongo:7
    container_name: flashstudy-mongodb
    ports:
      - "27017:27017"
    volumes:
      - mongodb_data:/data/db

volumes:
  mongodb_data:

然后运行:

code
docker compose up -d

这将在后台启动 MongoDB。你的数据存储在 Docker 卷中,因此即使停止容器,数据仍然保留。

#### 选项 B:MongoDB Atlas(云服务)

  • 访问 mongodb.com/cloud/atlas
  • 创建免费账户和集群
  • 创建数据库用户并获取连接字符串
  • 将你的 IP 添加到网络访问列表

第 4 步:创建环境文件

在项目根目录下创建一个名为 .env.local 的文件(此文件因安全原因会被 Git 忽略):

code
MONGODB_URI=mongodb://localhost:27017/flashcards

如果你使用 Atlas,将其替换为你的连接字符串,例如:

code
MONGODB_URI=mongodb+srv://username:[email protected]/flashcards?retryWrites=true&w=majority

第 5 步:理解文件夹结构

设置完成后,你的项目结构如下:

code
flash-cards/
├── src/
│   ├── app/              # 页面和 API 路由
│   │   ├── api/          # 后端 API 端点
│   │   ├── subjects/     # 主题列表和详情页面
│   │   ├── study/        # 学习模式页面
│   │   └── progress/     # 进度跟踪页面
│   ├── components/       # 可复用的 UI 组件
│   └── lib/              # 工具和数据库代码
│       ├── db.ts         # MongoDB 连接
│       └── models/       # Mongoose 模式
├── .env.local            # 环境变量(你创建此文件)
├── docker-compose.yml    # MongoDB 的 Docker 配置
└── package.json

app 文件夹使用 Next.js App Router:每个文件夹可以包含 page.tsx(UI)和 route.ts(API 端点)。我们将逐步构建这些内容。

功能开发

第 1 部分:连接 MongoDB

在能够存储或检索闪卡之前,我们需要将应用程序连接到 MongoDB。

我们将创建一个小的数据库实用工具来处理这个连接。由于 Next.js 可以处理多个请求并在开发过程中重新加载模块,我们不希望每次 API 路由运行时都创建新的 MongoDB 连接。相反,我们将缓存连接并在可能的情况下重复使用它。

首先,我们在 src/lib 目录中创建一个 db.ts 文件:

code
import mongoose from "mongoose";

const MONGODB_URI = process.env.MONGODB_URI || "mongodb://localhost:27017/flashcards";

interface MongooseCache {
  conn: typeof mongoose | null;
  promise: Promise<typeof mongoose> | null;
}

declare global {
  var mongoose: MongooseCache | undefined;
}
ts
let cached: MongooseCache = global.mongoose || { conn: null, promise: null };

if (!global.mongoose) {
  global.mongoose = cached;
}

async function dbConnect(): Promise<typeof mongoose> {
  if (cached.conn) return cached.conn;

  if (!cached.promise) {
    cached.promise = mongoose.connect(MONGODB_URI, {
      bufferCommands: false,
    });
  }

  cached.conn = await cached.promise;
  return cached.conn;
}

export default dbConnect;

以下是代码的逐行解释:

  • MONGODB_URI:从.env.local读取连接字符串。如果没有设置,将回退到本地MongoDB。
  • MongooseCache:一个TypeScript接口,描述我们的缓存:我们存储一个连接(conn)或一个最终会给我们一个连接的Promise。
  • global.mongoose:在开发环境中,Next.js可能会重新加载模块。使用global可以在重新加载时保持缓存,避免创建重复连接。
  • dbConnect():如果已经有连接,直接返回。否则,创建一个连接,缓存它并返回。每个API路由在访问数据库前都会调用await dbConnect()

第2部分:定义数据模型

现在我们的应用可以连接到MongoDB,接下来定义要存储在数据库中的数据。

我们的闪卡应用需要三种类型的数据:

  • 主题(Subjects):如Biology 101或Calculus等类别。
  • 闪卡(Flashcards):属于某个主题的问题和答案。
  • 进度(Progress):记录用户学习时的表现情况。

我们将使用Mongoose模式来定义每种数据的结构。模式描述文档可以拥有的字段以及每个字段应包含的数据类型。

让我们从主题模型开始。

#### 主题模型

主题代表闪卡的类别。例如,学生可以创建一个名为Biology 101的主题,并用它来组织他们的生物闪卡。

每个主题将具有一个名称、一个可选的描述,以及一个用于在UI中显示主题的配色。

如果尚未创建,请在src/lib中创建一个models目录。然后在其中创建一个名为Subject.ts的文件。

ts
import mongoose, { Schema, model, models } from "mongoose";

export interface ISubject {
  _id: string;
  name: string;
  description?: string;
  color: string;
  createdAt: Date;
  updatedAt: Date;
}

const SubjectSchema = new Schema(
  {
    name: { type: String, required: true },
    description: { type: String },
    color: { type: String, default: "#6366f1" },
  },
  { timestamps: true }
);

export default models.Subject || model<ISubject>("Subject", SubjectSchema);

在上述代码中:

  • ISubject:TypeScript接口。描述我们应用中主题对象的结构。
  • SubjectSchema:Mongoose模式。name是必填字段,而descriptioncolor是可选字段。color默认为紫色十六进制值。
  • timestamps: true:Mongoose会自动为每个文档添加createdAtupdatedAt字段。
  • models.Subject || model(...):在开发过程中,模块可能会重新加载。通过重用现有模型(如果存在),可以避免出现“模型已定义”的错误。

#### 闪卡模型

闪卡属于某个主题,正面是问题,背面是答案。

接下来,我们创建Flashcard模型。在src/lib/models中,创建一个名为Flashcard.ts的文件,并添加以下代码:

ts
import mongoose, { Schema, model, models } from "mongoose";
typescript
export interface IFlashcard {
  _id: string;
  subjectId: string;
  front: string;
  back: string;
  createdAt: Date;
  updatedAt: Date;
}

const FlashcardSchema = new Schema(
  {
    subjectId: { type: Schema.Types.ObjectId, ref: "Subject", required: true },
    front: { type: String, required: true },
    back: { type: String, required: true },
  },
  { timestamps: true }
);

export default models.Flashcard || model<IFlashcard>("Flashcard", FlashcardSchema);
  • subjectId:通过其 _id 引用 Subject。ref: "Subject" 允许 Mongoose 填充此字段(在获取数据时会用完整的 subject 对象替换 ID)。
  • frontback:问题和答案文本。

#### 进度模型

当用户学习时,我们需要记录他们是否正确回答了每个闪卡。我们将使用这些信息在仪表板上显示他们的进度。

接下来,让我们创建进度模型。在 src/lib/models 中,创建一个名为 Progress.ts 的文件并添加以下代码:

typescript
import mongoose, { Schema, model, models } from "mongoose";

export interface IProgress {
  _id: string;
  flashcardId: string;
  subjectId: string;
  correct: boolean;
  reviewedAt: Date;
}

const ProgressSchema = new Schema(
  {
    flashcardId: { type: Schema.Types.ObjectId, ref: "Flashcard", required: true },
    subjectId: { type: Schema.Types.ObjectId, ref: "Subject", required: true },
    correct: { type: Boolean, required: true },
    reviewedAt: { type: Date, default: Date.now },
  },
  { timestamps: true }
);

export default models.Progress || model<IProgress>("Progress", ProgressSchema);
  • correct:如果用户知道答案则为 true,否则为 false
  • reviewedAt:复习发生的时间。我们使用它进行排序以及未来的功能(如间隔重复)。

主题 API – 列表和创建

现在我们已经定义了数据模型,接下来创建 API 路由,使应用程序能够与这些数据交互。

API 路由处理前端请求并与 MongoDB 通信。在本节中,我们将创建用于创建和检索主题和闪卡的路由。

我们先从主题 API 开始。此路由将支持以下两个操作:

  • GET:获取所有主题。
  • POST:创建新主题。

src/app/api/subjects 中,创建一个名为 route.ts 的文件并添加以下代码:

typescript
import { NextResponse } from "next/server";
import dbConnect from "@/lib/db";
import Subject from "@/lib/models/Subject";

export async function GET() {
  try {
    await dbConnect();
    const subjects = await Subject.find({}).sort({ createdAt: -1 });
    return NextResponse.json(subjects);
  } catch (error) {
    console.error("获取主题时出错:", error);
    return NextResponse.json(
      { error: "无法获取主题" },
      { status: 500 }
    );
  }
}

export async function POST(request: Request) {
  try {
    await dbConnect();
    const body = await request.json();
    const { name, description, color } = body;

    if (!name) {
      return NextResponse.json(
        { error: "主题名称是必填项" },
        { status: 400 }
      );
    }

    const subject = await Subject.create({
      name,
      description: description || "",
      color: color || "#6366f1",
    });

    return NextResponse.json(subject);
  } catch (error) {
    console.error("创建主题时出错:", error);
    return NextResponse.json(
      { error: "无法创建主题" },
      { status: 500 }
    );
  }
}
  • GET:获取所有主题,按最新排序。find({}) 表示“查找所有”。以 JSON 格式返回结果。
  • POST:创建新主题。从请求体中读取 name、description 和 color。验证 name 是否存在。使用 Subject.create() 保存到 MongoDB。返回创建的主题。
  • 状态码:400:请求错误(缺少数据)。状态码:500:服务器错误(例如数据库故障)。

#### 闪卡 API – 列表和创建

接下来,我们为闪卡操作创建 API 路由。该路由将允许我们检索现有闪卡并创建新闪卡。

在 src/app/api/flashcards 中,创建一个名为 route.ts 的文件并添加以下代码块:

code
import { NextResponse } from "next/server";
import dbConnect from "@/lib/db";
import Flashcard from "@/lib/models/Flashcard";

export async function GET(request: Request) {
  try {
    await dbConnect();
    const { searchParams } = new URL(request.url);
    const subjectId = searchParams.get("subjectId");

    const query = subjectId ? { subjectId } : {};
    const flashcards = await Flashcard.find(query)
      .populate("subjectId", "name color")
      .sort({ createdAt: -1 });

    return NextResponse.json(flashcards);
  } catch (error) {
    console.error("Error fetching flashcards:", error);
    return NextResponse.json(
      { error: "Failed to fetch flashcards" },
      { status: 500 }
    );
  }
}

export async function POST(request: Request) {
  try {
    await dbConnect();
    const body = await request.json();
    const { subjectId, front, back } = body;

    if (!subjectId || !front || !back) {
      return NextResponse.json(
        { error: "Subject, front, and back are required" },
        { status: 400 }
      );
    }

    const flashcard = await Flashcard.create({
      subjectId,
      front,
      back,
    });

    const populated = await Flashcard.findById(flashcard._id).populate(
      "subjectId",
      "name color"
    );

    return NextResponse.json(populated);
  } catch (error) {
    console.error("Error creating flashcard:", error);
    return NextResponse.json(
      { error: "Failed to create flashcard" },
      { status: 500 }
    );
  }
}
  • searchParams.get("subjectId"):对于 GET /api/flashcards?subjectId=abc123,我们会根据该主题进行过滤。如果没有 subjectId,将返回所有闪卡。
  • .populate("subjectId", "name color"):将原始 ID 替换为主题对象,但仅包含 name 和 color。使在 UI 中显示主题名称变得简单。
  • POST:需要 subjectId、front 和 back。创建后,我们使用 populate 再次获取闪卡,使响应包含主题详细信息。

第 4 部分:编辑和删除(动态 API 路由)

对于单个主题和闪卡,我们将使用动态路由。在 Next.js 中,将 [id] 放在文件夹名称中会创建一个可以处理不同 ID 的路由。例如,/api/subjects/123 和 /api/subjects/456 可以使用相同的路由。

#### 主题 API 路由

让我们从创建单个主题的动态路由开始。该路由将允许我们检索、更新或删除主题。

在 src/app/api/subjects/[id] 中,创建一个名为 route.ts 的文件并添加以下代码:

code
import { NextResponse } from "next/server";
import dbConnect from "@/lib/db";
import Subject from "@/lib/models/Subject";
import Flashcard from "@/lib/models/Flashcard";
import Progress from "@/lib/models/Progress";
ts
export async function GET(
  _request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  try {
    await dbConnect();
    const { id } = await params;
    const subject = await Subject.findById(id);

    if (!subject) {
      return NextResponse.json({ error: "Subject not found" }, { status: 404 });
    }

    return NextResponse.json(subject);
  } catch (error) {
    console.error("Error fetching subject:", error);
    return NextResponse.json(
      { error: "Failed to fetch subject" },
      { status: 500 }
    );
  }
}

export async function PUT(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  try {
    await dbConnect();
    const { id } = await params;
    const body = await request.json();
    const { name, description, color } = body;

    const subject = await Subject.findByIdAndUpdate(
      id,
      { name, description, color },
      { new: true }
    );

    if (!subject) {
      return NextResponse.json({ error: "Subject not found" }, { status: 404 });
    }

    return NextResponse.json(subject);
  } catch (error) {
    console.error("Error updating subject:", error);
    return NextResponse.json(
      { error: "Failed to update subject" },
      { status: 500 }
    );
  }
}

export async function DELETE(
  _request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  try {
    await dbConnect();
    const { id } = await params;

    await Flashcard.deleteMany({ subjectId: id });
    await Progress.deleteMany({ subjectId: id });
    const subject = await Subject.findByIdAndDelete(id);

    if (!subject) {
      return NextResponse.json({ error: "Subject not found" }, { status: 404 });
    }

    return NextResponse.json({ message: "Subject deleted" });
  } catch (error) {
    console.error("Error deleting subject:", error);
    return NextResponse.json(
      { error: "Failed to delete subject" },
      { status: 500 }
    );
  }
}
code
- params : 在 Next.js 15+ 中,params 是一个 Promise。我们通过 await 获取 { id: "abc123" } 。

- findByIdAndUpdate(id, updates, { new: true }) : 更新文档并返回更新后的版本。{ new: true } 表示 "返回更新后的文档,而非原始文档"。

- DELETE : 删除主题时,我们也会删除其关联的卡片和进度记录。否则会产生孤立数据。

#### 按 ID 获取、更新、删除卡片

现在,我们为单个卡片创建动态路由。该路由将允许我们获取、更新或删除卡片。

在 src/app/api/flashcards/[id] 目录中,创建名为 route.ts 的文件并添加以下代码:

import { NextResponse } from "next/server"; import dbConnect from "@/lib/db"; import Flashcard from "@/lib/models/Flashcard";

export async function GET( _request: Request, { params }: { params: Promise<{ id: string }> } ) { try { await dbConnect(); const { id } = await params; const flashcard = await Flashcard.findById(id).populate( "subjectId", "name color" );

if (!flashcard) { return NextResponse.json( { error: "Flashcard not found" }, { status: 404 } ); }

return NextResponse.json(flashcard); } catch (error) { console.error("Error fetching flashcard:", error); return NextResponse.json( { error: "Failed to fetch flashcard" }, { status: 500 } ); } }

code

export async function PUT( request: Request, { params }: { params: Promise<{ id: string }> } ) { try { await dbConnect(); const { id } = await params; const body = await request.json(); const { front, back } = body;

const flashcard = await Flashcard.findByIdAndUpdate( id, { front, back }, { new: true } ).populate("subjectId", "name color");

if (!flashcard) { return NextResponse.json( { error: "Flashcard not found" }, { status: 404 } ); }

return NextResponse.json(flashcard); } catch (error) { console.error("Error updating flashcard:", error); return NextResponse.json( { error: "Failed to update flashcard" }, { status: 500 } ); } }

export async function DELETE( _request: Request, { params }: { params: Promise<{ id: string }> } ) { try { await dbConnect(); const { id } = await params; const flashcard = await Flashcard.findByIdAndDelete(id);

if (!flashcard) { return NextResponse.json( { error: "Flashcard not found" }, { status: 404 } ); }

return NextResponse.json({ message: "Flashcard deleted" }); } catch (error) { console.error("Error deleting flashcard:", error); return NextResponse.json( { error: "Failed to delete flashcard" }, { status: 500 } ); } }

code

第5部分:进度跟踪API

当用户在学习过程中标记抽认卡为正确或错误时,我们需要保存该结果。我们还将使用这些数据来计算进度统计信息,例如每个主题的正确率。

接下来,让我们创建进度跟踪的API路由。在src/app/api/progress目录中,创建一个名为route.ts的文件并添加以下代码:

code
import { NextResponse } from "next/server";
import dbConnect from "@/lib/db";
import Progress from "@/lib/models/Progress";

export async function GET(request: Request) {
  try {
    await dbConnect();
    const { searchParams } = new URL(request.url);
    const subjectId = searchParams.get("subjectId");

    const query = subjectId ? { subjectId } : {};
    const progress = await Progress.find(query).sort({ reviewedAt: -1 });

    const stats = await Progress.aggregate([
      { $match: query },
      {
        $group: {
          _id: "$subjectId",
          total: { $sum: 1 },
          correct: { $sum: { $cond: ["$correct", 1, 0] } },
        },
      },
    ]);

    return NextResponse.json({ progress, stats });
  } catch (error) {
    console.error("Error fetching progress:", error);
    return NextResponse.json(
      { error: "Failed to fetch progress" },
      { status: 500 }
    );
  }
}

export async function POST(request: Request) {
  try {
    await dbConnect();
    const body = await request.json();
    const { flashcardId, subjectId, correct } = body;

    if (!flashcardId || !subjectId || typeof correct !== "boolean") {
      return NextResponse.json(
        { error: "flashcardId, subjectId, and correct are required" },
        { status: 400 }
      );
    }

    const progress = await Progress.create({
      flashcardId,
      subjectId,
      correct,
    });

    return NextResponse.json(progress);
  } catch (error) {
    console.error("Error recording progress:", error);
    return NextResponse.json(
      { error: "Failed to record progress" },
      { status: 500 }
    );
  }
}
code

- 聚合:MongoDB 的聚合管道。我们按 subjectId 进行分组,统计总评论数和正确答案数。$cond: ["$correct", 1, 0] 表示“如果 correct 为 true,则加 1,否则加 0”。

- 统计:返回类似 [{ _id: "subjectId123", total: 20, correct: 16 }] 的数据。前端使用这些数据来显示每个主题的“80%准确率”。

## UI 实现

现在我们将构建用户看到的页面。我们将使用 React 钩子(useState、useEffect)来管理数据和调用 API。

### 主题页面

加载时,我们从 API 获取主题。我们显示一个表单来创建新主题。每个主题以卡片形式展示,并链接到其详情页面。

关键逻辑:

- useEffect 在组件挂载时运行一次,并调用 fetch("/api/subjects")。

- 表单的 onSubmit 调用 fetch("/api/subjects", { method: "POST", ... })。

- 创建成功后,我们清空表单并调用 fetchSubjects() 以刷新列表。

// 简化结构 - 完整代码见 src/app/subjects/page.tsx const [subjects, setSubjects] = useState<Subject[]>([]); const [showForm, setShowForm] = useState(false);

useEffect(() => { fetch("/api/subjects") .then((res) => res.json()) .then((data) => setSubjects(data)); }, []);

const handleSubmit = async (e) => { e.preventDefault(); await fetch("/api/subjects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, description, color }), }); fetchSubjects(); };

code

### 主题详情页(创建和编辑闪卡)

该页面显示一个主题及其闪卡。用户可以添加新卡片或编辑/删除现有卡片。URL 是 /subjects/[id],因此我们使用 useParams() 获取主题 ID。

- useParams() 从 URL 中获取 id。

- 在组件挂载时获取主题及其闪卡。

- “添加闪卡”显示一个表单。提交时,我们向 /api/flashcards 发送 POST 请求,包含 subjectId、front 和 back。

- 每张卡片都有“编辑”和“删除”按钮。编辑会切换到内联表单,而删除会调用 DELETE /api/flashcards/[id]。

// 创建闪卡 const handleCreate = async (e) => { e.preventDefault(); await fetch("/api/flashcards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subjectId: id, front, back }), }); fetchFlashcards(); // 刷新 };

// 更新闪卡 const handleUpdate = async (e) => { e.preventDefault(); await fetch(/api/flashcards/${editingId}, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ front: editFront, back: editBack }), }); setEditingId(null); fetchFlashcards(); };

code

### 学习页面 – 翻转卡片

学习页面有三个主要状态:

- 主题选择:用户选择要学习的主题。

- 准备开始:显示“开始学习”按钮及卡片数量。

- 学习中:一次显示一张卡片。用户点击翻转卡片,然后标记为正确或错误。我们进入下一张卡片并记录进度。

#### 翻转动画

我们使用 CSS 3D 变换来创建翻转动画。闪卡有两个面:一面是问题,另一面是答案。当 flipped 为 true 时,我们旋转卡片容器 180 度。我们还使用 backface-visibility: hidden 以确保在旋转过程中仅显示合适的面。

要添加翻转动画的样式,请打开 src/app/globals.css 并添加以下代码:

/* 闪卡翻转动画 */ .perspective-1000 { perspective: 1000px; }

code

.preserve-3d {
  transform-style: preserve-3d;
}

.backface-hidden {
  backface-visibility: hidden;
}

/* 为卡片背景创建横线纸张效果 */
.lined-paper {
  background-image: repeating-linear-gradient(
    transparent,
    transparent 27px,
    #e5e7eb 27px,
    #e5e7eb 28px
  );
}

lined-paper 类通过重复渐变创建水平灰色线条(类似笔记本纸张),为卡片赋予熟悉且便于学习的视觉效果。

卡片结构:

code
<div
  className={`preserve-3d transition-transform duration-500 ${
    flipped ? "[transform:rotateY(180deg)]" : ""
  }`}
>
  {/* 正面 - 问题 */}
  <div className="backface-hidden [transform:rotateY(0deg)]">
    {currentCard.front}
  </div>
  {/* 背面 - 答案 */}
  <div className="backface-hidden [transform:rotateY(180deg)]">
    {currentCard.back}
  </div>
</div>

当用户点击卡片时,会切换 flipped 状态。父容器执行旋转动画,正确面会显示出来。

#### 记录学习进度

当用户点击 "Got it!" 或 "Didn't know" 时,我们会:

  • 向 /api/progress 发起 POST 请求,携带 flashcardId、subjectId 和 correct 参数
  • 更新本地状态(sessionCorrect 或 sessionWrong)以实时显示统计数据
  • 跳转到下一张卡片。如果所有卡片已学习完毕,会再次显示 "Start Studying" 界面
code
const handleKnow = () => {
  recordProgress(true);
  setFlipped(false);
  if (currentIndex < flashcards.length - 1) {
    setCurrentIndex((i) => i + 1);
  } else {
    setStudyStarted(false);
    setCurrentIndex(0);
  }
};

进度页面

在此页面,我们获取主题和进度统计数据。对每个主题,查找其统计数据(总复习次数、正确次数)并计算百分比。页面顶部显示总体统计数据,下方显示各主题的详细数据。

code
const getSubjectStats = (subjectId) => {
  const stat = stats.find((s) => s._id === subjectId);
  return stat
    ? {
        total: stat.total,
        correct: stat.correct,
        pct: Math.round((stat.correct / stat.total) * 100),
      }
    : null;
};

可选:横线纸张与回形针图标

应用包含横线纸张效果和回形针图标,使卡片更具触感。回形针是一个简单的 SVG 组件:

code
// src/components/PaperclipIcon.tsx
export default function PaperclipIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
      <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
    </svg>
  );
}

将图标放置在卡片顶部中央。lined-paper 类应用于卡片内容区域以实现笔记本效果。

最佳实践

代码组织

  • 模型文件放在 lib/models/:每个模型单独一个文件,集中管理数据结构
  • API 路由放在 app/api/:按资源类型(主题、卡片、进度)分组,动态路由使用 [id]
  • 可复用组件:PaperclipIcon 存放于 components/ 目录,任何重复的 UI 组件都采用此模式

错误处理

  • API 路由:始终使用 try/catch 包裹逻辑,返回合适的 HTTP 状态码(400 表示输入错误,404 表示未找到,500 表示服务器错误)
  • 前端:使用 res.json() 前检查 res.ok,向用户显示加载状态和错误提示

性能优化

  • 数据库连接:复用连接(我们的 dbConnect 已实现此功能),避免每个请求都重新连接数据库
  • 谨慎使用填充:仅填充所需的字段。指定需要填充的字段:.populate("subjectId", "name color")。
  • 加载状态:在数据获取时显示加载指示器。可防止页面布局偏移并提供反馈。

结论

你已经构建了一个完整的闪卡应用,使用了以下技术:

  • 使用 Next.js 构建应用和 API 路由
  • 使用 MongoDB + Mongoose 存储主题、闪卡和进度数据
  • 使用 React 实现带表单、列表和翻转动画的用户界面
  • 使用 Tailwind CSS 进行样式设计

可能的改进方向

你可以通过添加以下功能来进一步改进这个应用:

首先,可以添加身份验证功能。通过登录功能让每个用户拥有自己的主题和卡片。可以考虑使用 NextAuth.js 或 Clerk。

其次,可以添加间隔重复功能。利用进度数据在最佳时间间隔展示卡片(例如,更频繁展示答错的卡片)。

接下来,可以添加一些动画效果,比如卡片之间的过渡动画,或在学习会话完成时添加彩带效果。

你还可以增加移动端适配功能。当前布局适用于桌面端,但可以优化移动端的学习视图。

最后,添加导出/导入功能也很实用:允许用户将闪卡导出为 JSON 或 CSV 格式进行备份。

后续步骤

要继续深入探索,可以运行 npm run dev 并体验应用。你可以添加几个主题和闪卡,然后尝试学习模式。

之后,打开 MongoDB Compass 或 Atlas 检查你的数据。可以尝试修改代码:更改颜色、添加字段或调整翻转动画效果。

祝你学习愉快!

专注于构建和维护可靠系统的软件工程师。

如果你读到了这里,请感谢作者以表达你的支持。说声谢谢

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成功获得开发工作。立即开始学习

ADVERTISEMENT