From bf056399c66def4fef82c002a2bd8de99c1c5ae1 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:36:42 +0800 Subject: [PATCH] feat(error-book): implement error book module with SM2 spaced repetition - Add SM2 algorithm implementation with tests for spaced repetition review scheduling - Add data-access, schema, types, and server actions for error book CRUD - Add components: add dialog, class overview, filters, item card, stats cards, review buttons, top wrong questions - Add error-book routes for admin, teacher, parent, and student roles - Add i18n messages (en, zh-CN) for error book module --- .../(dashboard)/admin/error-book/error.tsx | 19 + .../(dashboard)/admin/error-book/loading.tsx | 23 + src/app/(dashboard)/admin/error-book/page.tsx | 113 +++ .../(dashboard)/parent/error-book/error.tsx | 19 + .../(dashboard)/parent/error-book/loading.tsx | 18 + .../(dashboard)/parent/error-book/page.tsx | 138 +++ .../(dashboard)/student/error-book/error.tsx | 19 + .../student/error-book/loading.tsx | 30 + .../(dashboard)/teacher/error-book/error.tsx | 19 + .../teacher/error-book/loading.tsx | 26 + .../(dashboard)/teacher/error-book/page.tsx | 119 +++ src/modules/error-book/actions.ts | 341 +++++++ .../components/add-error-book-dialog.tsx | 177 ++++ .../components/class-error-overview.tsx | 198 ++++ .../components/error-book-filters.tsx | 79 ++ .../components/error-book-item-card.tsx | 136 +++ .../components/error-book-stats-cards.tsx | 60 ++ .../error-book/components/review-buttons.tsx | 98 ++ .../components/top-wrong-questions.tsx | 109 ++ src/modules/error-book/data-access.ts | 944 ++++++++++++++++++ src/modules/error-book/schema.ts | 51 + src/modules/error-book/sm2-algorithm.test.ts | 302 ++++++ src/modules/error-book/sm2-algorithm.ts | 177 ++++ src/modules/error-book/types.ts | 196 ++++ src/shared/i18n/messages/en/error-book.json | 101 ++ .../i18n/messages/zh-CN/error-book.json | 101 ++ 26 files changed, 3613 insertions(+) create mode 100644 src/app/(dashboard)/admin/error-book/error.tsx create mode 100644 src/app/(dashboard)/admin/error-book/loading.tsx create mode 100644 src/app/(dashboard)/admin/error-book/page.tsx create mode 100644 src/app/(dashboard)/parent/error-book/error.tsx create mode 100644 src/app/(dashboard)/parent/error-book/loading.tsx create mode 100644 src/app/(dashboard)/parent/error-book/page.tsx create mode 100644 src/app/(dashboard)/student/error-book/error.tsx create mode 100644 src/app/(dashboard)/student/error-book/loading.tsx create mode 100644 src/app/(dashboard)/teacher/error-book/error.tsx create mode 100644 src/app/(dashboard)/teacher/error-book/loading.tsx create mode 100644 src/app/(dashboard)/teacher/error-book/page.tsx create mode 100644 src/modules/error-book/actions.ts create mode 100644 src/modules/error-book/components/add-error-book-dialog.tsx create mode 100644 src/modules/error-book/components/class-error-overview.tsx create mode 100644 src/modules/error-book/components/error-book-filters.tsx create mode 100644 src/modules/error-book/components/error-book-item-card.tsx create mode 100644 src/modules/error-book/components/error-book-stats-cards.tsx create mode 100644 src/modules/error-book/components/review-buttons.tsx create mode 100644 src/modules/error-book/components/top-wrong-questions.tsx create mode 100644 src/modules/error-book/data-access.ts create mode 100644 src/modules/error-book/schema.ts create mode 100644 src/modules/error-book/sm2-algorithm.test.ts create mode 100644 src/modules/error-book/sm2-algorithm.ts create mode 100644 src/modules/error-book/types.ts create mode 100644 src/shared/i18n/messages/en/error-book.json create mode 100644 src/shared/i18n/messages/zh-CN/error-book.json diff --git a/src/app/(dashboard)/admin/error-book/error.tsx b/src/app/(dashboard)/admin/error-book/error.tsx new file mode 100644 index 0000000..f43b9c8 --- /dev/null +++ b/src/app/(dashboard)/admin/error-book/error.tsx @@ -0,0 +1,19 @@ +"use client" + +import { BarChart3 } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminErrorBookError() { + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/error-book/loading.tsx b/src/app/(dashboard)/admin/error-book/loading.tsx new file mode 100644 index 0000000..f53cf38 --- /dev/null +++ b/src/app/(dashboard)/admin/error-book/loading.tsx @@ -0,0 +1,23 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminErrorBookLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, idx) => ( + + ))} +
+
+ {Array.from({ length: 2 }).map((_, idx) => ( + + ))} +
+ +
+ ) +} diff --git a/src/app/(dashboard)/admin/error-book/page.tsx b/src/app/(dashboard)/admin/error-book/page.tsx new file mode 100644 index 0000000..427e467 --- /dev/null +++ b/src/app/(dashboard)/admin/error-book/page.tsx @@ -0,0 +1,113 @@ +import type { JSX } from "react" +import { BarChart3 } from "lucide-react" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { EmptyState } from "@/shared/components/ui/empty-state" + +import { + getStudentErrorBookSummaries, + getTopWrongQuestionsByStudentIds, + getKnowledgePointWeakness, + getSubjectErrorDistribution, + getStudentNameMap, + getAllStudentIds, +} from "@/modules/error-book/data-access" +import { ClassErrorBookOverview, StudentErrorTable } from "@/modules/error-book/components/class-error-overview" +import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions" + +export const dynamic = "force-dynamic" + +export default async function AdminErrorBookPage(): Promise { + const ctx = await requirePermission(Permissions.ERROR_BOOK_ANALYTICS_READ) + + if (ctx.dataScope.type !== "all") { + return ( +
+
+

全校错题分析

+

查看全校学生的错题统计与薄弱知识点。

+
+ +
+ ) + } + + // 通过 data-access 层查询所有学生 ID(遵循三层架构,app 层不直接访问 DB) + const studentIds = await getAllStudentIds() + + if (studentIds.length === 0) { + return ( +
+
+

全校错题分析

+

查看全校学生的错题统计与薄弱知识点。

+
+ +
+ ) + } + + // 限制查询数量,避免性能问题(取最近活跃的 500 名学生) + const limitedStudentIds = studentIds.slice(0, 500) + + const [summaries, topWrongQuestions, weakKps, subjectDist, nameMap] = await Promise.all([ + getStudentErrorBookSummaries(limitedStudentIds), + getTopWrongQuestionsByStudentIds(limitedStudentIds, 10), + getKnowledgePointWeakness(limitedStudentIds, 10), + getSubjectErrorDistribution(limitedStudentIds), + getStudentNameMap(limitedStudentIds), + ]) + + const studentsWithErrorBook = summaries.filter((s) => s.totalCount > 0) + const totalErrorItems = summaries.reduce((sum, s) => sum + s.totalCount, 0) + const averageMasteryRate = studentsWithErrorBook.length > 0 + ? studentsWithErrorBook.reduce((sum, s) => sum + s.masteredRate, 0) / studentsWithErrorBook.length + : 0 + + const sortedSummaries = [...summaries] + .filter((s) => s.totalCount > 0) + .sort((a, b) => b.totalCount - a.totalCount) + .slice(0, 50) + + return ( +
+
+

全校错题分析

+

+ 全校错题统计与薄弱知识点分析,辅助教学决策。 +

+
+ + + +
+

错题最多的学生 Top 50

+ +
+ + +
+ ) +} diff --git a/src/app/(dashboard)/parent/error-book/error.tsx b/src/app/(dashboard)/parent/error-book/error.tsx new file mode 100644 index 0000000..394e954 --- /dev/null +++ b/src/app/(dashboard)/parent/error-book/error.tsx @@ -0,0 +1,19 @@ +"use client" + +import { BookX } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function ParentErrorBookError() { + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/parent/error-book/loading.tsx b/src/app/(dashboard)/parent/error-book/loading.tsx new file mode 100644 index 0000000..6b513ed --- /dev/null +++ b/src/app/(dashboard)/parent/error-book/loading.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function ParentErrorBookLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 5 }).map((_, idx) => ( + + ))} +
+ +
+ ) +} diff --git a/src/app/(dashboard)/parent/error-book/page.tsx b/src/app/(dashboard)/parent/error-book/page.tsx new file mode 100644 index 0000000..8b4a3b0 --- /dev/null +++ b/src/app/(dashboard)/parent/error-book/page.tsx @@ -0,0 +1,138 @@ +import type { JSX } from "react" +import { Users } from "lucide-react" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { Progress } from "@/shared/components/ui/progress" +import { formatNumber } from "@/shared/lib/utils" + +import { + getErrorBookStats, + getStudentNameMap, + getTopWrongQuestionsByStudentIds, + getKnowledgePointWeakness, +} from "@/modules/error-book/data-access" +import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards" +import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions" + +export const dynamic = "force-dynamic" + +export default async function ParentErrorBookPage(): Promise { + const ctx = await requirePermission(Permissions.ERROR_BOOK_READ) + + if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) { + return ( +
+
+

子女错题本

+

查看子女的错题情况与学习进度。

+
+ +
+ ) + } + + const childrenIds = ctx.dataScope.childrenIds + + const [nameMap, ...childStatsList] = await Promise.all([ + getStudentNameMap(childrenIds), + ...childrenIds.map((id) => getErrorBookStats(id)), + ]) + + // 汇总所有子女的错题 + const [topWrongQuestions, weakKps] = await Promise.all([ + getTopWrongQuestionsByStudentIds(childrenIds, 5), + getKnowledgePointWeakness(childrenIds, 5), + ]) + + return ( +
+
+

子女错题本

+

查看子女的错题情况与学习进度。

+
+ + {childrenIds.length === 1 ? ( + // 单子女:直接展示统计卡片 + + ) : ( + // 多子女:每个子女一张卡片 +
+ {childrenIds.map((childId, idx) => { + const stats = childStatsList[idx] + const name = nameMap.get(childId) ?? "未知" + return ( + + + + {name} + + {formatNumber(stats.masteredRate * 100, 0)}% 掌握 + + + + +
+
+
错题总数
+
{stats.totalCount}
+
+
+
待复习
+
+ {stats.dueReviewCount} +
+
+
+
待学习
+
{stats.newCount}
+
+
+
已掌握
+
{stats.masteredCount}
+
+
+ +
+
+ ) + })} +
+ )} + + {/* 薄弱知识点 */} + {weakKps.length > 0 ? ( + + + 薄弱知识点 + + +
+ {weakKps.map((kp) => ( +
+
+ {kp.knowledgePointName} + + {kp.errorCount} 错 · {formatNumber(kp.masteryRate * 100, 0)}% 掌握 + +
+ +
+ ))} +
+
+
+ ) : null} + + +
+ ) +} diff --git a/src/app/(dashboard)/student/error-book/error.tsx b/src/app/(dashboard)/student/error-book/error.tsx new file mode 100644 index 0000000..8d3e742 --- /dev/null +++ b/src/app/(dashboard)/student/error-book/error.tsx @@ -0,0 +1,19 @@ +"use client" + +import { BookX } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function StudentErrorBookError() { + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/error-book/loading.tsx b/src/app/(dashboard)/student/error-book/loading.tsx new file mode 100644 index 0000000..b2aecfd --- /dev/null +++ b/src/app/(dashboard)/student/error-book/loading.tsx @@ -0,0 +1,30 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function StudentErrorBookLoading() { + return ( +
+
+
+ + +
+ +
+ +
+ {Array.from({ length: 5 }).map((_, idx) => ( + + ))} +
+ +
+ +
+ {Array.from({ length: 4 }).map((_, idx) => ( + + ))} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/error-book/error.tsx b/src/app/(dashboard)/teacher/error-book/error.tsx new file mode 100644 index 0000000..4d78434 --- /dev/null +++ b/src/app/(dashboard)/teacher/error-book/error.tsx @@ -0,0 +1,19 @@ +"use client" + +import { BarChart3 } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function TeacherErrorBookError() { + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/error-book/loading.tsx b/src/app/(dashboard)/teacher/error-book/loading.tsx new file mode 100644 index 0000000..e1fab6d --- /dev/null +++ b/src/app/(dashboard)/teacher/error-book/loading.tsx @@ -0,0 +1,26 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function TeacherErrorBookLoading() { + return ( +
+
+ + +
+ +
+ {Array.from({ length: 4 }).map((_, idx) => ( + + ))} +
+ +
+ {Array.from({ length: 2 }).map((_, idx) => ( + + ))} +
+ + +
+ ) +} diff --git a/src/app/(dashboard)/teacher/error-book/page.tsx b/src/app/(dashboard)/teacher/error-book/page.tsx new file mode 100644 index 0000000..6da026d --- /dev/null +++ b/src/app/(dashboard)/teacher/error-book/page.tsx @@ -0,0 +1,119 @@ +import type { JSX } from "react" +import { BarChart3 } from "lucide-react" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { getStudentIdsByClassIds, getClassIdsByGradeIds } from "@/modules/classes/data-access" + +import { + getStudentErrorBookSummaries, + getTopWrongQuestionsByStudentIds, + getKnowledgePointWeakness, + getSubjectErrorDistribution, + getStudentNameMap, +} from "@/modules/error-book/data-access" +import { ClassErrorBookOverview, StudentErrorTable } from "@/modules/error-book/components/class-error-overview" +import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions" + +export const dynamic = "force-dynamic" + +export default async function TeacherErrorBookPage(): Promise { + const ctx = await requirePermission(Permissions.ERROR_BOOK_ANALYTICS_READ) + + // 教师的 dataScope 为 class_taught,年级主任/教研组长为 grade_managed,管理员为 all + const classIds = ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : [] + const gradeIds = ctx.dataScope.type === "grade_managed" ? ctx.dataScope.gradeIds : [] + + if (classIds.length === 0 && gradeIds.length === 0 && ctx.dataScope.type !== "all") { + return ( +
+
+

错题分析

+

查看班级学生的错题统计与薄弱知识点。

+
+ +
+ ) + } + + // 年级主任/教研组长:先根据 gradeIds 查询班级,再查询学生 + let targetClassIds = classIds + if (gradeIds.length > 0) { + const gradeClassIds = await getClassIdsByGradeIds(gradeIds) + targetClassIds = [...new Set([...classIds, ...gradeClassIds])] + } + + const studentIds = await getStudentIdsByClassIds(targetClassIds) + + if (studentIds.length === 0) { + return ( +
+
+

错题分析

+

查看班级学生的错题统计与薄弱知识点。

+
+ +
+ ) + } + + // 并行查询所有统计数据 + const [summaries, topWrongQuestions, weakKps, subjectDist, nameMap] = await Promise.all([ + getStudentErrorBookSummaries(studentIds), + getTopWrongQuestionsByStudentIds(studentIds, 10), + getKnowledgePointWeakness(studentIds, 10), + getSubjectErrorDistribution(studentIds), + getStudentNameMap(studentIds), + ]) + + const studentsWithErrorBook = summaries.filter((s) => s.totalCount > 0) + const totalErrorItems = summaries.reduce((sum, s) => sum + s.totalCount, 0) + const averageMasteryRate = studentsWithErrorBook.length > 0 + ? studentsWithErrorBook.reduce((sum, s) => sum + s.masteredRate, 0) / studentsWithErrorBook.length + : 0 + + // 按错题数降序排列 + const sortedSummaries = [...summaries].sort((a, b) => b.totalCount - a.totalCount) + + return ( +
+
+

错题分析

+

+ 查看班级学生的错题统计与薄弱知识点,辅助精准教学。 +

+
+ + + +
+

学生错题详情

+ +
+ + +
+ ) +} diff --git a/src/modules/error-book/actions.ts b/src/modules/error-book/actions.ts new file mode 100644 index 0000000..639daf6 --- /dev/null +++ b/src/modules/error-book/actions.ts @@ -0,0 +1,341 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { z } from "zod" + +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import type { ActionState } from "@/shared/types/action-state" + +import { + CreateErrorBookItemSchema, + ReviewErrorBookItemSchema, + UpdateErrorBookNoteSchema, + CollectFromSubmissionSchema, +} from "./schema" +import { + archiveErrorBookItem, + collectFromExamSubmission, + collectFromHomeworkSubmission, + createErrorBookItem, + deleteErrorBookItem, + getErrorBookItemById, + getErrorBookItems, + getErrorBookStats, + recordReview, + updateErrorBookNote, +} from "./data-access" +import type { ErrorBookListResult, ErrorBookStats as ErrorBookStatsType, ErrorBookItemDetail } from "./types" + +// --------------------------------------------------------------------------- +// 学生端 Actions +// --------------------------------------------------------------------------- + +export async function getErrorBookItemsAction( + params: { + studentId?: string + q?: string + page?: number + pageSize?: number + status?: string + sourceType?: string + subjectId?: string + dueOnly?: boolean + } +): Promise> { + try { + const ctx = await requirePermission(Permissions.ERROR_BOOK_READ) + + // 学生只能看自己的错题本;家长查看子女的错题本(通过 dataScope.childrenIds 校验) + const studentId = ctx.dataScope.type === "children" + ? params.studentId ?? ctx.dataScope.childrenIds[0] ?? ctx.userId + : ctx.userId + + // 如果传入 studentId 且不是自己,需要校验权限(家长查看子女) + if (params.studentId && params.studentId !== ctx.userId) { + if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(params.studentId)) { + throw new PermissionDeniedError(Permissions.ERROR_BOOK_READ) + } + } + + const status = params.status && params.status !== "all" + ? (z.enum(["new", "learning", "mastered", "archived"]).safeParse(params.status).success + ? (params.status as "new" | "learning" | "mastered" | "archived") + : undefined) + : undefined + + const sourceType = params.sourceType && params.sourceType !== "all" + ? (z.enum(["exam", "homework", "manual"]).safeParse(params.sourceType).success + ? (params.sourceType as "exam" | "homework" | "manual") + : undefined) + : undefined + + const data = await getErrorBookItems({ + studentId, + q: params.q, + page: params.page, + pageSize: params.pageSize, + status, + sourceType, + subjectId: params.subjectId && params.subjectId !== "all" ? params.subjectId : undefined, + dueOnly: params.dueOnly, + }) + + return { success: true, data } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + const message = e instanceof Error ? e.message : "获取错题列表失败" + return { success: false, message } + } +} + +export async function getErrorBookItemDetailAction( + itemId: string +): Promise> { + try { + const ctx = await requirePermission(Permissions.ERROR_BOOK_READ) + + const studentId = ctx.dataScope.type === "children" + ? ctx.dataScope.childrenIds[0] ?? ctx.userId + : ctx.userId + + const data = await getErrorBookItemById(itemId, studentId) + if (!data) { + return { success: false, message: "错题不存在或无权访问" } + } + return { success: true, data } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + const message = e instanceof Error ? e.message : "获取错题详情失败" + return { success: false, message } + } +} + +export async function getErrorBookStatsAction( + studentId?: string +): Promise> { + try { + const ctx = await requirePermission(Permissions.ERROR_BOOK_READ) + + let targetStudentId = ctx.userId + if (studentId && studentId !== ctx.userId) { + if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) { + throw new PermissionDeniedError(Permissions.ERROR_BOOK_READ) + } + targetStudentId = studentId + } else if (ctx.dataScope.type === "children") { + targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId + } + + const data = await getErrorBookStats(targetStudentId) + return { success: true, data } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + const message = e instanceof Error ? e.message : "获取错题统计失败" + return { success: false, message } + } +} + +export async function createErrorBookItemAction( + prevState: ActionState | undefined, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.ERROR_BOOK_MANAGE) + + const jsonString = formData.get("json") + if (typeof jsonString !== "string") { + return { success: false, message: "提交格式错误,需要 JSON 字段" } + } + + const parsed = CreateErrorBookItemSchema.safeParse(JSON.parse(jsonString)) + if (!parsed.success) { + return { + success: false, + message: "输入验证失败", + errors: parsed.error.flatten().fieldErrors, + } + } + + const id = await createErrorBookItem(ctx.userId, parsed.data) + revalidatePath("/student/error-book") + + return { success: true, message: "错题已添加", data: id } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + const message = e instanceof Error ? e.message : "添加错题失败" + return { success: false, message } + } +} + +export async function updateErrorBookNoteAction( + prevState: ActionState | undefined, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.ERROR_BOOK_MANAGE) + + const jsonString = formData.get("json") + if (typeof jsonString !== "string") { + return { success: false, message: "提交格式错误" } + } + + const parsed = UpdateErrorBookNoteSchema.safeParse(JSON.parse(jsonString)) + if (!parsed.success) { + return { + success: false, + message: "输入验证失败", + errors: parsed.error.flatten().fieldErrors, + } + } + + const { itemId, ...input } = parsed.data + await updateErrorBookNote(itemId, ctx.userId, input) + + revalidatePath("/student/error-book") + + return { success: true, message: "笔记已更新" } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + const message = e instanceof Error ? e.message : "更新笔记失败" + return { success: false, message } + } +} + +export async function reviewErrorBookItemAction( + prevState: ActionState | undefined, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.ERROR_BOOK_MANAGE) + + const jsonString = formData.get("json") + if (typeof jsonString !== "string") { + return { success: false, message: "提交格式错误" } + } + + const parsed = ReviewErrorBookItemSchema.safeParse(JSON.parse(jsonString)) + if (!parsed.success) { + return { + success: false, + message: "输入验证失败", + errors: parsed.error.flatten().fieldErrors, + } + } + + await recordReview(parsed.data.itemId, ctx.userId, parsed.data.result) + + revalidatePath("/student/error-book") + + return { success: true, message: "复习结果已记录" } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + const message = e instanceof Error ? e.message : "记录复习结果失败" + return { success: false, message } + } +} + +export async function deleteErrorBookItemAction( + prevState: ActionState | undefined, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.ERROR_BOOK_MANAGE) + + const itemId = formData.get("itemId") + if (typeof itemId !== "string") { + return { success: false, message: "无效的错题 ID" } + } + + await deleteErrorBookItem(itemId, ctx.userId) + + revalidatePath("/student/error-book") + + return { success: true, message: "错题已删除" } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + const message = e instanceof Error ? e.message : "删除错题失败" + return { success: false, message } + } +} + +export async function archiveErrorBookItemAction( + prevState: ActionState | undefined, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.ERROR_BOOK_MANAGE) + + const itemId = formData.get("itemId") + if (typeof itemId !== "string") { + return { success: false, message: "无效的错题 ID" } + } + + await archiveErrorBookItem(itemId, ctx.userId) + + revalidatePath("/student/error-book") + + return { success: true, message: "错题已归档" } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + const message = e instanceof Error ? e.message : "归档错题失败" + return { success: false, message } + } +} + +export async function collectFromSubmissionAction( + prevState: ActionState | undefined, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.ERROR_BOOK_MANAGE) + + const jsonString = formData.get("json") + if (typeof jsonString !== "string") { + return { success: false, message: "提交格式错误" } + } + + const parsed = CollectFromSubmissionSchema.safeParse(JSON.parse(jsonString)) + if (!parsed.success) { + return { + success: false, + message: "输入验证失败", + errors: parsed.error.flatten().fieldErrors, + } + } + + const collected = parsed.data.sourceType === "exam" + ? await collectFromExamSubmission(parsed.data.submissionId, ctx.userId) + : await collectFromHomeworkSubmission(parsed.data.submissionId, ctx.userId) + + revalidatePath("/student/error-book") + + return { + success: true, + message: collected > 0 ? `已采集 ${collected} 道错题` : "没有新的错题需要采集", + data: collected, + } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + const message = e instanceof Error ? e.message : "采集错题失败" + return { success: false, message } + } +} diff --git a/src/modules/error-book/components/add-error-book-dialog.tsx b/src/modules/error-book/components/add-error-book-dialog.tsx new file mode 100644 index 0000000..40326ed --- /dev/null +++ b/src/modules/error-book/components/add-error-book-dialog.tsx @@ -0,0 +1,177 @@ +"use client" + +import { useState, useTransition, useEffect } from "react" +import { Plus } from "lucide-react" +import { toast } from "sonner" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, +} from "@/shared/components/ui/dialog" +import { Button } from "@/shared/components/ui/button" +import { Label } from "@/shared/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" +import { Textarea } from "@/shared/components/ui/textarea" +import { getQuestionsAction } from "@/modules/questions/actions" +import { createErrorBookItemAction } from "../actions" +import { COMMON_ERROR_TAGS } from "../types" + +export function AddErrorBookDialog() { + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() + const [questionId, setQuestionId] = useState("") + const [note, setNote] = useState("") + const [errorTags, setErrorTags] = useState([]) + const [questionOptions, setQuestionOptions] = useState>([]) + + function extractPreview(content: unknown): string { + if (typeof content === "string") return content.slice(0, 60) + if (Array.isArray(content)) { + const texts: string[] = [] + for (const node of content) { + if (typeof node === "string") texts.push(node) + else if (typeof node === "object" && node !== null) { + const n = node as Record + if (typeof n.text === "string") texts.push(n.text) + } + } + return texts.join("").slice(0, 60) + } + return "题目" + } + + useEffect(() => { + if (open && questionOptions.length === 0) { + getQuestionsAction({ pageSize: 100 }) + .then((res) => { + if (res.success && res.data) { + setQuestionOptions( + res.data.data.map((q) => ({ + id: q.id, + preview: extractPreview(q.content), + })) + ) + } + }) + .catch(() => {}) + } + }, [open, questionOptions.length]) + + function toggleTag(tag: string) { + setErrorTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag] + ) + } + + function handleSubmit() { + if (!questionId) { + toast.error("请选择题目") + return + } + startTransition(async () => { + const formData = new FormData() + formData.append( + "json", + JSON.stringify({ questionId, note, errorTags }) + ) + const res = await createErrorBookItemAction(undefined, formData) + if (res.success) { + toast.success(res.message ?? "已添加") + setOpen(false) + setQuestionId("") + setNote("") + setErrorTags([]) + } else { + toast.error(res.message ?? "添加失败") + } + }) + } + + return ( + + + + + + + 添加错题 + + 从题库中选择题目,添加到你的错题本。你也可以在完成作业/考试后自动采集。 + + + +
+
+ + +
+ +
+ +