← 返回蜂巢洞察

如何使用Next.js和MongoDB构建一个用于学习闪卡的应用程序

如果你曾经在考试前夜临时抱佛脚地复习过,你就知道要记住所有内容是多么困难。 闪卡是极其有效的学习工具,因为它们运用了 主动回忆法 :你需要主动尝试去记住答案,而不仅仅是被动地阅读笔记。研究表明,这种方法能够增强记忆力,帮助信息更好地被留存下来。 在这个教程中,你将制作一个全栈功能的闪卡应用,该应用能让学生: 创建学习主题 (比如“生物101”或“微积分”) 添加闪卡 ,卡片正面是问题,背面是答案 通过翻动卡片来学习 ,并标记答案是对还是错 跟踪学习进度 ,了解自己的学习情况 完成这个教程后,你将拥有一个可以正常运行的应用。该应用会使用MongoDB存储数据,并基于Next.js框架进行开发。学

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

闪卡是极其有效的学习工具,因为它们运用了主动回忆法:你需要主动尝试去记住答案,而不仅仅是被动地阅读笔记。研究表明,这种方法能够增强记忆力,帮助信息更好地被留存下来。

在这个教程中,你将制作一个全栈功能的闪卡应用,该应用能让学生:

  • 创建学习主题(比如“生物101”或“微积分”)

  • 添加闪卡,卡片正面是问题,背面是答案

  • 通过翻动卡片来学习

    ,并标记答案是对还是错

  • 跟踪学习进度

    ,了解自己的学习情况

完成这个教程后,你将拥有一个可以正常运行的应用。该应用会使用MongoDB存储数据,并基于Next.js框架进行开发。学习这些工具之前不需要有任何相关经验,我们会逐步讲解所有步骤。

目录

你将学到的内容

  • 如何使用TypeScript搭建Next.js项目

  • 如何连接MongoDB并存储数据

  • 如何创建用于创建、读取、更新和删除数据的API路由

  • 如何使用表单、列表和交互式闪卡构建React用户界面

  • 如何添加翻页动画及进度跟踪功能

技术栈概述

在开始编写代码之前,让我们先了解一下我们会使用哪些技术以及为什么选择它们。

Next.js

Next.js是一个用于构建Web应用程序的React框架。它能够自动处理路由、服务器端渲染以及API路由的配置。

Next.js允许我们将前端和后端代码集成到同一个项目中。我们可以创建与数据库交互的API路由(例如/api/flashcards),同时也可以编写用于展示用户界面的页面,所有这些功能都可以在同一个代码库中实现。

MongoDB

MongoDB是一种NoSQL数据库,它以类似JSON的结构来存储数据。与传统具有行和列的表格不同,MongoDB允许你将数据存储在“集合”中。

MongoDB非常适合初学者使用,与JavaScript/TypeScript配合得非常好,并且提供了免费的试用版(MongoDB Atlas),或者也可以通过Docker在本地运行。

Mongoose

Mongoose是一个库,它可以帮助你为MongoDB定义数据结构模型。该库能够确保数据的正确性,防止你意外地保存无效的数据。

如果没有Mongoose,你就需要直接编写原始的MongoDB查询语句;而使用Mongoose后,你只需要定义一次“Flashcard”模型,然后就可以通过Flashcard.create()Flashcard.find()这样的简单方法来操作数据了。

Tailwind CSS

Tailwind是一个以实用功能为核心的CSS框架。你无需编写自定义的CSS代码,只需在HTML中直接添加像rounded-xlbg-blue-500这样的类名即可。

Tailwind能够显著提升样式设置的效率,并确保设计的一致性。Next.js也支持使用Tailwind CSS。

项目设置

步骤1:创建Next.js项目

打开终端并运行以下命令:

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

在提示时,请做出如下选择:

  • 是否使用TypeScript:

  • 是否启用ESLint检查:

  • 是否使用Tailwind CSS:

  • 是否将src/目录设为项目源代码目录:

  • 是否启用应用路由功能:

  • 是否设置导入别名:@/*

执行此命令后,会创建一个名为flash-cards的新文件夹,并在其中生成一个基本的Next.js项目。

步骤2:安装Mongoose

Mongoose并非默认包含在Next.js项目中。请运行以下命令来安装它。

cd flash-cards
npm install mongoose

步骤 3:配置 MongoDB

您有两种选择:

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

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

volumes:
  mongodb_data:

然后运行以下命令:

docker compose up -d

这样 MongoDB 就会在后台启动。您的数据会存储在 Docker 容器中,因此即使关闭容器,数据也不会丢失。

选项 B:MongoDB Atlas(云服务)

  1. 访问 mongodb.com/cloud/atlas

  2. 创建一个免费账户并搭建集群

  3. 创建数据库用户并获取连接字符串

  4. 将您的 IP 地址添加到网络访问列表中

步骤 4:创建环境文件

在项目根目录下创建一个名为 .env.local 的文件(出于安全考虑,Git 会忽略这个文件):

MONGODB_URI=mongodb://localhost:27017/flashcards

如果您使用的是 MongoDB Atlas,请将此内容替换为实际的连接字符串,例如:

MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/flashcards?retryWrites=true&w=majority

步骤 5:了解文件夹结构

配置完成后,您的项目结构如下所示:

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 的应用路由系统:每个文件夹都可以包含 page.tsx(用于编写 UI 代码)和 route.ts(用于定义 API 接口)。我们会逐步构建这些组件。

开发功能模块

第 1 部分:连接 MongoDB

在能够存储或检索闪卡信息之前,我们必须先让应用程序连接到 MongoDB。

我们将创建一个小型数据库工具,来帮助我们处理这种连接需求。由于Next.js在开发过程中能够同时处理多个请求并重新加载相关模块,因此我们并不希望每次有API请求被触发时都去建立新的MongoDB连接。相反,我们会将已经建立的连接缓存起来,并在可能的情况下重复使用这些连接。

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

import mongoose from "mongoose";

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

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

declare global {
  var mongoose: MongooseCache | undefined;
}

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

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

async function dbConnect(): Promise {
  if (cached.conn) return cached_conn;

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

  cached(conn = await cachedpromise;
  return cached_conn;
}

export default dbConnect;

以下是这段代码的逐行解释:

  • MONGODB URI:从`.env.local`文件中读取连接字符串;如果没有设置,则使用本地MongoDB服务器。

  • MongooseCache:这是一个TypeScript接口,用于描述我们的缓存机制:我们存储的要么是连接对象,要么是一个会最终获取到连接的Promise对象。

  • global.mongoose:在开发过程中,Next.js可能会重新加载模块。使用`global`变量可以确保缓存数据在页面重新加载后仍然有效,从而避免创建重复的数据库连接。

  • dbConnect():如果已经存在连接对象,则直接返回它;否则创建一个新的连接对象、将其保存到缓存中后再返回。所有API请求在访问数据库之前都会调用`await dbConnect()`函数。

第二部分:定义数据模型

既然我们的应用程序已经能够连接到MongoDB了,接下来我们就来定义要存储在数据库中的数据结构吧。

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

  • 科目:例如“生物101”或“微积分”之类的分类。

  • 闪卡内容:属于某个科目的问题与答案。

  • 学习进度记录:用于记录用户学习过程中的表现情况。

我们将使用Mongoose的schema来定义这些数据的结构。Schema规定了文档可以包含哪些字段,以及每个字段应该存储什么类型的数据。

首先我们来定义Subject模型。

科目模型

一个“科目”代表一类闪卡内容。例如,学生可以创建一个名为“生物101”的科目,用来整理他们的生物学科目相关闪卡。

每个科目都会包含名称、可选的描述信息,以及用于在用户界面中显示该科目的颜色。

如果还没有的话,请先在`src/lib`目录下创建一个`models`文件夹,然后再在其中创建一个名为`Subject.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("Subject", SubjectSchema);
在这段代码中:
  • ISubject:TypeScript接口。用于描述我们应用程序中“Subject”对象的具体结构。

  • SubjectSchema——Mongoose模式。其中name是必填字段,而descriptioncolor则是可选字段。color的默认值为紫色十六进制代码。

  • timestamps: true:Mongoose会自动为每份文档添加createdAtupdatedAt字段。

  • models.Subject || model(...):在开发过程中,模块可以重新加载。这样,如果现有的模型已经存在,系统就会重用它,从而避免出现“模型已定义”之类的错误。

闪卡模型

一张闪卡属于某个特定的主题,其正面包含问题,背面包含答案。

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

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

export interface IFlashcard {
  _id: string;
  subjectId: string;
  front: string;
  back: string;
 createdAt: Date;
  updatedAt: Date;
}

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

export default models.Flashcard || model("Flashcard", FlashcardSchema);

在这段代码中:

  • subjectId:通过_id字段引用对应的“Subject”对象。ref: "Subject"这一设置使得Mongoose在获取数据时能够将ID替换为完整的主题对象。

  • frontback:分别用于存储闪卡的正面内容和背面答案。

进度模型

当用户进行学习时,我们需要记录他们对每张闪卡的回答是正确还是错误。我们可以利用这些信息在仪表板上显示他们的学习进度。

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

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: SchemaTypesObjectId, ref: "Flashcard", required: true },
    subjectId: { type: SchemaTypes ObjectId, ref: "Subject", required: true },
    correct: { type: Boolean, required: true },
    reviewedAt: { type: Date, default: Date.now },
  },
  { timestamps: true }
);

export default models.Progress || model("Progress", ProgressSchema);

在这段代码中:

  • correct:如果用户知道答案,则值为true;否则为false

  • reviewedAt:表示复习发生的时间。我们利用这个字段进行排序,也会用于未来实现间隔重复学习等功能。

科目API——列表与创建

既然我们已经定义了数据模型,接下来就让我们创建那些能够让应用程序处理这些数据的API路由吧。

API路由负责处理来自前端的请求,并与MongoDB进行交互。在这一节中,我们将创建用于创建和检索科目以及抽认卡的路由。

我们先从科目API开始。这个路由将支持两种操作:

  • GET:检索所有科目。

  • POST:创建一个新的科目。

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

import { NextResponse } from "next/server";
import dbConnect from"f/lib/db";
import Subject from"f/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:创建一个新的科目。从请求体中读取namedescriptioncolor字段,检查name是否有效,然后使用Subject.create()方法将新科目保存到MongoDB中,最后返回创建成功的科目信息。

  • status: 400:请求格式错误(缺少必要数据)。status: 500:服务器出现故障(例如数据库连接失败)。

抽认卡API——列表与创建

接下来,我们再创建用于处理抽认卡的API路由。通过这些路由,我们可以检索现有的抽认卡并创建新的抽认卡。

在 `src/app/api/flashcards` 目录下,创建一个名为 `route.ts` 的文件,并添加以下代码块:
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);
    return NextResponse.json(
      { error: "无法获取闪卡信息" },
      { 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: "需要提供subjectId、front和back字段" },
        { 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);
    return NextResponse.json(
      { error: "无法创建闪卡信息" },
      { status: 500 }
    );
  }
}

在这段代码中:

  • searchParams.get("subjectId"):对于请求GET /api/flashcards?subjectId=abc123,我们会根据这个参数来过滤数据;如果没有提供subjectId,则会返回所有闪卡信息。

  • .populate("subjectId", "name color"):用包含namecolor字段的对象替换原始的ID,这样在用户界面中显示时就能方便地显示出科目的名称了。

  • POST:这个接口需要提供subjectIdfrontback字段。创建闪卡后,我们会再次使用populate方法来更新这些信息,这样响应中就会包含科目的详细内容。

Flashcard-create-study-form

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

对于具体的科目和闪卡,我们将使用动态路由。在Next.js中,如果在文件夹名中添加[id],就可以创建一个能够处理不同ID的路由。例如,/api/subjects/123/api/subjects/456可以使用同一个路由。

科目API路由

首先,我们来创建针对具体科目的动态路由。这个路由允许我们检索、更新或删除某个科目。

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

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

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: "未找到该主题" }, { status: 404 });
    }

    return NextResponse.json(subject);
  } catch (error) {
    console.error("获取主题时出现错误:", error);
    return NextResponse.json(
      { error: "无法获取该主题" },
      { 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: "未找到该主题" }, { status: 404 });
    }

    return NextResponse.json(subject);
  } catch (error) {
    console.error("更新主题时出现错误:", error);
    return NextResponse.json(
      { error: "无法更新该主题" },
      { 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: "未找到该主题" }, { status: 404 });
    }

    return NextResponse.json({ message: "该主题已删除" });
  } catch (error) {
    console.error("删除主题时出现错误:", error);
    return NextResponse.json(
      { error: "无法删除该主题" },
      { status: 500 }
    );
  }
}

在这段代码中:

  • params:在 Next.js 1.5 及更高版本中,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: "未找到该闪卡" },
        { status: 404 }
      );
    }

    return NextResponse.json(flashcard);
  } catch (error) {
    console.error("获取闪卡信息时出现错误:", error);
    return NextResponse.json(
      { error: "无法获取闪卡信息" },
      { 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 { front, back } = body;

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

    if (!flashcard) {
      return NextResponse.json(
        { error: "未找到该闪卡" },
        { status: 404 }
      );
    }

    return NextResponse.json(flashcard);
  } catch (error) {
    console.error("更新闪卡信息时出现错误:", error);
    return NextResponse.json(
      { error: "无法更新闪卡信息" },
      { 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: "未找到该闪卡" },
        { status: 404 }
      );
    }

    return NextResponse.json({ message: "闪卡已删除" });
  } catch (error) {
    console.error("删除闪卡信息时出现错误:", error);
    return NextResponse.json(
      { error: "无法删除闪卡信息" },
      { status: 500 }
    );
  }
}

第5部分:进度跟踪API

当用户在学习过程中将某张闪卡标记为正确或错误时,我们需要保存这一结果。此外,我们还会利用这些数据来计算各种进度统计信息,例如每门科目的正确答案所占的比例。

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

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); return NextResponse.json({ error: "无法获取进度数据", 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和correct字段", status: 400 }); } const progress = await Progress.create({ flashcardId, subjectId, correct, }); return NextResponse.json(progress); } catch (error) { console.error("在记录进度数据时出现错误:", error); return NextResponse.json({ error: "无法记录进度数据", status: 500 }); } }

在这段代码中:

  • aggregate:MongoDB提供的聚合管道。我们按照subjectId对数据进行处理,统计每个主题的总评论数和正确答案的数量。$cond: ["$correct", 1, 0]表示“如果答案为‘正确’,则计数为1;否则计数为0”。

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

用户界面实现

现在我们将构建用户所看到的页面。我们会使用React钩子(useStateuseEffect)来管理数据,并通过fetch函数来调用API。

主题页面

在页面加载时,我们会从API中获取主题信息。同时会提供一个用于创建新主题的表单。每个主题都会以卡片的形式显示在页面上,点击卡片可以进入其详细页面。

关键逻辑:

  1. useEffect会在组件挂载时执行一次,它会调用fetch("/api/subjects")来获取主题数据。

  2. 表单的onSubmit事件会触发fetch("/api/subjects", { method: "POST", ... })请求,用于创建新的主题。

  3. 当新主题创建成功后,我们会清空表单内容,并再次调用fetchSubjects()来更新主题列表。

// 简化后的代码结构——完整代码请参见src/app/subjects/page.tsx
const [subjects, setSubjects] = useState:> [];
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(); 
};
Flashcard-study-list

主题详细页面(创建和编辑闪卡)

这个页面会显示一个主题及其对应的闪卡。用户可以添加新的闪卡,或者编辑或删除现有的闪卡。该页面的URL格式为/subjects/[id],因此我们使用useParams()来获取主题ID。

关键逻辑:

  1. useParams()会从URL中提取出id参数。

  2. 在组件挂载时,我们会从API中获取该主题及其所有的闪卡信息。

  3. “添加闪卡”功能会显示一个表单。用户提交表单后,系统会向/api/flashcards发送POST请求,其中会包含subjectIdfrontback这些参数。

  4. 每张闪卡都会提供“编辑”和“删除”按钮。点击“编辑”按钮会弹出一个内联表单,而点击“删除”按钮则会触发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(); // 刷新页面
};
学习详情页面

学习页面 – 翻卡功能

学习页面共有三种状态:

  1. 选择学习科目:用户可自行选择要学习的科目。

  2. 准备开始学习:界面会显示“开始学习”以及当前剩余的卡片数量。

  3. 正在学习:系统会一次显示一张卡片,用户点击卡片即可翻面,并标记答案的正确与否。完成后系统会自动进入下一张卡片并记录学习进度。

翻卡动画效果

我们利用CSS 3D变换技术来实现翻卡动画效果。这种闪卡具有两面:正面用于显示问题,背面用于显示答案。当`flipped`属性的值为`true`时,卡片容器会旋转180度;同时我们使用`backface-visibility: hidden`属性,以确保在旋转过程中只有正确的面能够被显示出来。

若要为翻卡动画添加样式,请打开`src/app/globals.css`文件,并添加以下代码:

/* 翻卡动画效果 */
.perspective-1000 {
  perspective: 1000px;
}

.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`类通过重复渐变效果在卡片背景上生成横向的灰色线条,从而使卡片看起来更像传统的笔记本页面,更加适合学习使用。

卡片的实际结构如下:

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

当用户点击卡片时,`flipped`属性的值会发生变化,从而导致卡片容器旋转,正确答案面就会显示出来。

a92a03c7-c562-4600-ba2c-ecd6e85ab00c

const handleKnow = () => { recordProgress(true); setFlipped(false); if (currentIndex < flashcards.length - 1) { setCurrentIndex((i) => i + 1); } else { setStudyStarted(false); setCurrentIndex(0); } };

进度页面

在这里,我们会获取各个科目的学习进度数据。对于每个科目,我们会查看其具体的学习数据(总复习次数、正确答案数量),并计算出相应的百分比。我们会将整体统计信息显示在页面顶部,而各科目的详细数据则显示在下方。

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组件:

// src/components/PaperclipIcon.tsx export default function PaperclipIcon({ className }: { className?: string }) { return ( ); }

请将这个图标放置在闪卡的顶部中央位置。通过为卡片内容区域添加lined-paper类,就可以实现横线纸的效果。

最佳实践

代码组织结构

  • 模型文件应保存在lib/models/目录下:每个模型对应一个文件,这样就可以将所有模型的结构信息集中存放在一起。

  • API路由应放在app/api/目录中:根据资源类型进行分类(如科目、闪卡、学习进度等),对于动态生成的路由,请使用[id]进行标识。

  • 可复用的组件:像PaperclipIcon这样的组件应该放在components/目录中。对于任何需要重复使用的UI元素,都应采用这种命名和组织方式。

错误处理

  • API请求:务必使用try/catch结构来处理逻辑异常。在出现错误时,要返回相应的状态码(例如400表示输入错误,404表示资源未找到,500表示服务器故障)。

  • 前端代码:在使用res.json()之前,先检查res.ok的值。要向用户显示加载中或出错的状态信息。

性能优化建议

  • 数据库连接:请重用已建立的数据库连接(我们的dbConnect函数就是这么实现的)。不要在每次请求时都重新建立连接。

  • 仅加载必要的数据:只调用.populate()方法来填充那些真正需要的字段。同时要明确指定需要填充的字段,例如.populate("subjectId", "name color")

  • 显示加载提示:在数据加载过程中,请为用户显示加载指示器。这样既能防止页面布局发生突然变化,也能让用户了解当前的数据加载进度。

结论

你已经使用以下技术构建了一个全栈闪卡应用:

  • Next.js 用于开发应用程序及API路由

  • MongoDB + Mongoose 用于存储学习主题、闪卡信息以及学习进度

  • React 用于构建包含表单、列表以及翻页动画的用户界面

  • Tailwind CSS 用于应用样式设计

可进行的改进

你还可以添加一些功能来进一步完善这个应用。

首先,可以加入认证机制,让用户登录后能够使用属于自己的学习资料。可以考虑使用 NextAuth.js 或 Clerk 这类工具来实现这一功能。

其次,可以添加间隔重复学习的功能。根据用户的学习进度,以最合适的间隔展示需要复习的闪卡(例如那些被错误回答次数较多的卡片)。

此外,还可以加入一些动画效果,比如在切换闪卡时添加过渡动画,或者在完成一次学习后显示庆祝效果。

同时,也可以优化应用的移动端适配性。目前的布局在桌面设备上使用效果良好,但你可以进一步调整界面设计,使其更适合手机屏幕。

最后,添加导出/导入功能也会非常有用:用户可以将自己的闪卡数据以 JSON 或 CSV 格式保存出来,以便备份。

下一步计划

若想进一步开发这个应用,请运行 npm run dev 然后尝试使用它。你可以先添加一些学习主题和闪卡,然后体验学习模式。

之后,可以使用 MongoDB Compass 或 Atlas 工具来查看数据。也可以随意修改代码:调整颜色、添加新字段,或优化翻页动画效果。

祝学习顺利!

相关文章

技术实践

如何在SQL中使用子查询

每当你在SQL中看到一个查询嵌套在另一个查询内部时,这就是子查询。子查询也被称为内查询,而包含它的那个查询则被称为主查询或外查询。 子查询的作用是为主查询提供额外的数据,这些数据可以以派生列或派生表的形式出现,或者它们也可以用来过滤主查询返回的行。 对于刚开始学习SQL的初学者来说,子查询可能相当难以理解。本文将帮助大家简化这一概念,使其更易于理解。读完这篇文章后,你应该能够更加熟练地使用子查询来解决问题了。 目录 先决条件 子查询的工作原理 执行顺序 子查询的类型 非相关子查询 相关子查询 结论 先决条件: 子查询属于高级SQL概念,因此,必须牢固掌握SQL的基础知识,包括SELECT、FR

阅读全文
技术实践

如何在Flutter开发中运用各种技能:开发者手册

关于人工智能辅助开发,最大的误解之一就是认为使用人工智能就意味着要放弃多年来积累的工程经验。其实并非如此。 你可以将自己所学到的架构模式、犯过的错误、团队遵循的规范,以及作为Flutter工程师所制定的标准,通过特定的技能教给人工智能编码助手。这样一来,你就不必在自身经验与人工智能之间做出选择,而是可以将两者结合起来使用。 然而,几乎每一位Flutter开发者,在第一次在实际项目中使用人工智能编码助手时,都会遇到一些令人沮丧的情况。 比如,当你让助手生成一个个人资料页面时,它虽然能生成能够正常运行的代码,但却没有在`widgets/`文件夹中创建一个结构清晰、可重复使用的`ProfileCar

阅读全文
技术实践

OpenTelemetry的工作原理:一份全面的指南

如果你是一名软件开发人员或DevOps工程师,那么你很可能已经听说过OpenTelemetry。在讨论可观测性、监控或分布式系统的调试时,这个术语经常会被提及。 你可能也知道它的基本定义,但了解OpenTelemetry是什么与真正理解它的运作原理其实是两回事。 读完本指南后,你将能够明白OpenTelemetry是如何从端到端工作的——从请求进入你的应用程序的那一刻起,直到这些数据被显示在可观测性后端系统中。你还会了解到追踪信息、时间跨度、上下文传播机制以及数据导出工具是如何共同构成一个完整的处理流程的。 如果你完全不了解OpenTelemetry,也别担心:接下来的部分会帮助你快速掌握相关

阅读全文
技术实践

了解人工智能软件开发生命周期流程——构建智能代理功能的完整指南

也许你可以理解这样的场景:本周,你用了同样的说明四次向别人解释人工智能模型的使用方法。 你反复讲解过团队是如何构建演示文稿的框架的,哪些检查步骤需要在部署之前完成,以及为什么测试数据库并不是文档中提到的那个。 每次你都要把这些内容重新写一遍,每次智能助手也能完成得不错,但每次新的会话开始时,一切都得从零开始。 而这正是 智能助手技能 所要解决的问题。 技能 实际上就是一个文件夹,其中只包含一个名为 Skill.md 的文件。智能助手在启动时会阅读其中的一行总结内容,而只有当真正需要时才会打开完整的说明文件。你只需把解释内容编写一次,将其与代码一起提交,那么团队中的每个智能助手就能访问这些信息,

阅读全文