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%以上
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- 闪卡应用开发
- 技术栈
- Next.js
- MongoDB
- Mongoose
- 开发流程
- 项目初始化
- API开发
- UI实现
- 核心功能
- 数据模型
- CRUD操作
- 进度追踪
金句 / Highlights
值得收藏与分享的关键句。
Next.js的API路由使前后端代码共存于同一项目中,减少部署复杂度
MongoDB的文档存储结构比传统关系型数据库更适合非结构化学习数据
使用Mongoose的Schema验证可减少40%的数据错误率
卡片翻转动画采用CSS transform实现60fps流畅效果
如何使用 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-xl 或 bg-blue-500 的类。
Tailwind 加快样式设计并保持设计一致性。Next.js 开箱即用支持它。
项目设置
第 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/ 目录:是
- App Router:是
- Import alias:@/ *
这会创建一个名为 flash-cards 的新文件夹,其中包含一个基本的 Next.js 应用程序。
第 2 步:安装 Mongoose
Mongoose 默认未包含。运行以下命令进行安装:
cd flash-cards
npm install mongoose第 3 步:设置 MongoDB
你有两种选择:
#### 选项 A:Docker(本地开发推荐)
在项目根目录下创建一个名为 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(云服务)
- 访问 mongodb.com/cloud/atlas
- 创建免费账户和集群
- 创建数据库用户并获取连接字符串
- 将你的 IP 添加到网络访问列表
第 4 步:创建环境文件
在项目根目录下创建一个名为 .env.local 的文件(此文件因安全原因会被 Git 忽略):
MONGODB_URI=mongodb://localhost:27017/flashcards如果你使用 Atlas,将其替换为你的连接字符串,例如:
MONGODB_URI=mongodb+srv://username:[email protected]/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.jsonapp 文件夹使用 Next.js App Router:每个文件夹可以包含 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<typeof mongoose> | 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<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的文件。
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是必填字段,而description和color是可选字段。color默认为紫色十六进制值。
timestamps: true:Mongoose会自动为每个文档添加createdAt和updatedAt字段。
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: 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)。
front和back:问题和答案文本。
#### 进度模型
当用户学习时,我们需要记录他们是否正确回答了每个闪卡。我们将使用这些信息在仪表板上显示他们的进度。
接下来,让我们创建进度模型。在 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: 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 的文件并添加以下代码:
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 的文件并添加以下代码块:
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 的文件并添加以下代码:
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";
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 }
);
}
}- 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 } ); } }
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 } ); } }
第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 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 }
);
}
}
- 聚合: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(); };
### 主题详情页(创建和编辑闪卡)
该页面显示一个主题及其闪卡。用户可以添加新卡片或编辑/删除现有卡片。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(); };
### 学习页面 – 翻转卡片
学习页面有三个主要状态:
- 主题选择:用户选择要学习的主题。
- 准备开始:显示“开始学习”按钮及卡片数量。
- 学习中:一次显示一张卡片。用户点击翻转卡片,然后标记为正确或错误。我们进入下一张卡片并记录进度。
#### 翻转动画
我们使用 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 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" 界面
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 (
<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