refactor: 完全重构为 Anki 兼容数据结构

- 用 Deck 替换 Folder
- 用 Note + Card 替换 Pair (双向复习)
- 添加 NoteType (卡片模板)
- 添加 Revlog (复习历史)
- 实现 SM-2 间隔重复算法
- 更新所有前端页面
- 添加数据库迁移
This commit is contained in:
2026-03-10 19:20:46 +08:00
parent 9b78fd5215
commit 57ad1b8699
72 changed files with 7107 additions and 2430 deletions

View File

@@ -0,0 +1,327 @@
import { prisma } from "@/lib/db";
import {
RepoInputCreateDeck,
RepoInputUpdateDeck,
RepoInputGetDeckById,
RepoInputGetDecksByUserId,
RepoInputGetPublicDecks,
RepoInputDeleteDeck,
RepoOutputDeck,
RepoOutputPublicDeck,
RepoOutputDeckOwnership,
RepoInputToggleDeckFavorite,
RepoInputCheckDeckFavorite,
RepoInputSearchPublicDecks,
RepoInputGetPublicDeckById,
RepoOutputDeckFavorite,
RepoInputGetUserFavoriteDecks,
RepoOutputUserFavoriteDeck,
} from "./deck-repository-dto";
import { Visibility } from "../../../generated/prisma/enums";
export async function repoCreateDeck(data: RepoInputCreateDeck): Promise<number> {
const deck = await prisma.deck.create({
data: {
name: data.name,
desc: data.desc ?? "",
userId: data.userId,
visibility: data.visibility ?? Visibility.PRIVATE,
},
});
return deck.id;
}
export async function repoUpdateDeck(input: RepoInputUpdateDeck): Promise<void> {
const { id, ...updateData } = input;
await prisma.deck.update({
where: { id },
data: updateData,
});
}
export async function repoGetDeckById(input: RepoInputGetDeckById): Promise<RepoOutputDeck | null> {
const deck = await prisma.deck.findUnique({
where: { id: input.id },
include: {
_count: {
select: { cards: true },
},
},
});
if (!deck) return null;
return {
id: deck.id,
name: deck.name,
desc: deck.desc,
userId: deck.userId,
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
};
}
export async function repoGetDecksByUserId(input: RepoInputGetDecksByUserId): Promise<RepoOutputDeck[]> {
const decks = await prisma.deck.findMany({
where: { userId: input.userId },
include: {
_count: {
select: { cards: true },
},
},
orderBy: {
createdAt: "desc",
},
});
return decks.map((deck) => ({
id: deck.id,
name: deck.name,
desc: deck.desc,
userId: deck.userId,
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
}));
}
export async function repoGetPublicDecks(input: RepoInputGetPublicDecks = {}): Promise<RepoOutputPublicDeck[]> {
const { limit = 50, offset = 0, orderBy = "createdAt" } = input;
const decks = await prisma.deck.findMany({
where: { visibility: Visibility.PUBLIC },
include: {
_count: {
select: { cards: true, favorites: true },
},
user: {
select: { name: true, username: true },
},
},
orderBy: { [orderBy]: "desc" },
take: limit,
skip: offset,
});
return decks.map((deck) => ({
id: deck.id,
name: deck.name,
desc: deck.desc,
userId: deck.userId,
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
userName: deck.user?.name ?? null,
userUsername: deck.user?.username ?? null,
favoriteCount: deck._count?.favorites ?? 0,
}));
}
export async function repoDeleteDeck(input: RepoInputDeleteDeck): Promise<void> {
await prisma.deck.delete({
where: { id: input.id },
});
}
export async function repoGetUserIdByDeckId(deckId: number): Promise<string | null> {
const deck = await prisma.deck.findUnique({
where: { id: deckId },
select: { userId: true },
});
return deck?.userId ?? null;
}
export async function repoGetDeckOwnership(deckId: number): Promise<RepoOutputDeckOwnership | null> {
const deck = await prisma.deck.findUnique({
where: { id: deckId },
select: { userId: true },
});
return deck;
}
export async function repoGetPublicDeckById(input: RepoInputGetPublicDeckById): Promise<RepoOutputPublicDeck | null> {
const deck = await prisma.deck.findFirst({
where: {
id: input.deckId,
visibility: Visibility.PUBLIC,
},
include: {
_count: {
select: { cards: true, favorites: true },
},
user: {
select: { name: true, username: true },
},
},
});
if (!deck) return null;
return {
id: deck.id,
name: deck.name,
desc: deck.desc,
userId: deck.userId,
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
userName: deck.user?.name ?? null,
userUsername: deck.user?.username ?? null,
favoriteCount: deck._count?.favorites ?? 0,
};
}
export async function repoToggleDeckFavorite(input: RepoInputToggleDeckFavorite): Promise<RepoOutputDeckFavorite> {
const existing = await prisma.deckFavorite.findUnique({
where: {
userId_deckId: {
userId: input.userId,
deckId: input.deckId,
},
},
});
if (existing) {
await prisma.deckFavorite.delete({
where: { id: existing.id },
});
} else {
await prisma.deckFavorite.create({
data: {
userId: input.userId,
deckId: input.deckId,
},
});
}
const deck = await prisma.deck.findUnique({
where: { id: input.deckId },
include: {
_count: {
select: { favorites: true },
},
},
});
return {
isFavorited: !existing,
favoriteCount: deck?._count?.favorites ?? 0,
};
}
export async function repoCheckDeckFavorite(input: RepoInputCheckDeckFavorite): Promise<RepoOutputDeckFavorite> {
const favorite = await prisma.deckFavorite.findUnique({
where: {
userId_deckId: {
userId: input.userId,
deckId: input.deckId,
},
},
});
const deck = await prisma.deck.findUnique({
where: { id: input.deckId },
include: {
_count: {
select: { favorites: true },
},
},
});
return {
isFavorited: !!favorite,
favoriteCount: deck?._count?.favorites ?? 0,
};
}
export async function repoSearchPublicDecks(input: RepoInputSearchPublicDecks): Promise<RepoOutputPublicDeck[]> {
const { query, limit = 50, offset = 0 } = input;
const decks = await prisma.deck.findMany({
where: {
visibility: Visibility.PUBLIC,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ desc: { contains: query, mode: "insensitive" } },
],
},
include: {
_count: {
select: { cards: true, favorites: true },
},
user: {
select: { name: true, username: true },
},
},
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
});
return decks.map((deck) => ({
id: deck.id,
name: deck.name,
desc: deck.desc,
userId: deck.userId,
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
userName: deck.user?.name ?? null,
userUsername: deck.user?.username ?? null,
favoriteCount: deck._count?.favorites ?? 0,
}));
}
export async function repoGetUserFavoriteDecks(
input: RepoInputGetUserFavoriteDecks,
): Promise<RepoOutputUserFavoriteDeck[]> {
const favorites = await prisma.deckFavorite.findMany({
where: { userId: input.userId },
include: {
deck: {
include: {
_count: {
select: { cards: true, favorites: true },
},
user: {
select: { name: true, username: true },
},
},
},
},
orderBy: { createdAt: "desc" },
});
return favorites.map((fav) => ({
id: fav.deck.id,
name: fav.deck.name,
desc: fav.deck.desc,
userId: fav.deck.userId,
visibility: fav.deck.visibility,
collapsed: fav.deck.collapsed,
conf: fav.deck.conf,
createdAt: fav.deck.createdAt,
updatedAt: fav.deck.updatedAt,
cardCount: fav.deck._count?.cards ?? 0,
userName: fav.deck.user?.name ?? null,
userUsername: fav.deck.user?.username ?? null,
favoriteCount: fav.deck._count?.favorites ?? 0,
favoritedAt: fav.createdAt,
}));
}