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 (
+
+ )
+}
diff --git a/src/modules/error-book/components/class-error-overview.tsx b/src/modules/error-book/components/class-error-overview.tsx
new file mode 100644
index 0000000..04e91e8
--- /dev/null
+++ b/src/modules/error-book/components/class-error-overview.tsx
@@ -0,0 +1,198 @@
+import Link from "next/link"
+import { Users, AlertTriangle, TrendingUp, Target } from "lucide-react"
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
+import { StatCard } from "@/shared/components/ui/stat-card"
+import { Badge } from "@/shared/components/ui/badge"
+import { Progress } from "@/shared/components/ui/progress"
+import { EmptyState } from "@/shared/components/ui/empty-state"
+import { formatDate, formatNumber } from "@/shared/lib/utils"
+import type {
+ StudentErrorBookSummary,
+ KnowledgePointWeakness,
+ SubjectErrorDistribution,
+} from "../types"
+
+interface ClassErrorBookOverviewProps {
+ totalStudents: number
+ studentsWithErrorBook: number
+ totalErrorItems: number
+ averageMasteryRate: number
+ topWeakKnowledgePoints: KnowledgePointWeakness[]
+ subjectDistribution: SubjectErrorDistribution[]
+}
+
+export function ClassErrorBookOverview({
+ totalStudents,
+ studentsWithErrorBook,
+ totalErrorItems,
+ averageMasteryRate,
+ topWeakKnowledgePoints,
+ subjectDistribution,
+}: ClassErrorBookOverviewProps) {
+ return (
+
+
+
+
+
+
+
+
+
+ {/* 薄弱知识点 */}
+
+
+
+
+ 薄弱知识点 Top 10
+
+
+
+ {topWeakKnowledgePoints.length === 0 ? (
+
+ 暂无数据
+
+ ) : (
+
+ {topWeakKnowledgePoints.map((kp, idx) => (
+
+
+
+
+ {idx + 1}
+
+ {kp.knowledgePointName}
+
+
+ {kp.errorCount} 错 · {formatNumber(kp.masteryRate * 100, 0)}% 掌握
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* 学科分布 */}
+
+
+ 学科错题分布
+
+
+ {subjectDistribution.length === 0 ? (
+
+ 暂无数据
+
+ ) : (
+
+ {subjectDistribution.map((s) => (
+
+
+ {s.subjectName}
+
+ {s.errorCount} 错 · {formatNumber(s.masteryRate * 100, 0)}% 掌握
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ )
+}
+
+interface StudentErrorTableProps {
+ students: StudentErrorBookSummary[]
+ studentNames: Map
+ basePath: string
+}
+
+export function StudentErrorTable({ students, studentNames, basePath }: StudentErrorTableProps) {
+ if (students.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+ | 学生 |
+ 错题总数 |
+ 待学习 |
+ 学习中 |
+ 已掌握 |
+ 待复习 |
+ 掌握率 |
+ 最近活动 |
+
+
+
+ {students.map((s) => {
+ const name = studentNames.get(s.studentId) ?? "未知"
+ return (
+
+ |
+
+ {name}
+
+ |
+ {s.totalCount} |
+ {s.newCount} |
+ {s.learningCount} |
+ {s.masteredCount} |
+ {s.dueReviewCount} |
+
+ {formatNumber(s.masteredRate * 100, 0)}%
+ |
+
+ {s.lastActivityAt ? formatDate(s.lastActivityAt) : "-"}
+ |
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/modules/error-book/components/error-book-filters.tsx b/src/modules/error-book/components/error-book-filters.tsx
new file mode 100644
index 0000000..695eaaa
--- /dev/null
+++ b/src/modules/error-book/components/error-book-filters.tsx
@@ -0,0 +1,79 @@
+"use client"
+
+import { useQueryState, parseAsString } from "nuqs"
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/shared/components/ui/select"
+import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
+
+export function ErrorBookFilters() {
+ const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
+ const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
+ const [sourceType, setSourceType] = useQueryState("source", parseAsString.withDefault("all"))
+ const [dueOnly, setDueOnly] = useQueryState("due", parseAsString.withDefault("all"))
+
+ const hasFilters = Boolean(
+ search || status !== "all" || sourceType !== "all" || dueOnly !== "all",
+ )
+
+ return (
+ {
+ setSearch(null)
+ setStatus(null)
+ setSourceType(null)
+ setDueOnly(null)
+ }}
+ >
+
+ setSearch(v || null)}
+ placeholder="搜索笔记内容..."
+ className="flex-1 md:max-w-sm"
+ inputClassName="border-muted-foreground/20 pl-8"
+ />
+
+
+
+
+
+ )
+}
diff --git a/src/modules/error-book/components/error-book-item-card.tsx b/src/modules/error-book/components/error-book-item-card.tsx
new file mode 100644
index 0000000..cf3128b
--- /dev/null
+++ b/src/modules/error-book/components/error-book-item-card.tsx
@@ -0,0 +1,136 @@
+import { Calendar, FileText, BookMarked } from "lucide-react"
+
+import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
+import { Badge } from "@/shared/components/ui/badge"
+import { StatusBadge } from "@/shared/components/ui/status-badge"
+import { cn } from "@/shared/lib/utils"
+import { formatDate } from "@/shared/lib/utils"
+
+import {
+ ERROR_BOOK_SOURCE_LABEL,
+ ERROR_BOOK_SOURCE_VARIANT,
+ ERROR_BOOK_STATUS_LABEL,
+ ERROR_BOOK_STATUS_VARIANT,
+ type ErrorBookItem,
+} from "../types"
+
+interface ErrorBookItemCardProps {
+ item: ErrorBookItem
+ children?: React.ReactNode
+}
+
+/** 从题目内容中提取纯文本预览 */
+function extractQuestionPreview(content: unknown): string {
+ if (typeof content === "string") return content
+ 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)
+ if (Array.isArray(n.children)) {
+ texts.push(extractQuestionPreview(n.children))
+ }
+ }
+ }
+ return texts.join("")
+ }
+ if (typeof content === "object" && content !== null) {
+ const c = content as Record
+ if (typeof c.text === "string") return c.text
+ }
+ return "(题目内容)"
+}
+
+const MASTERY_LEVEL_LABELS: Record = {
+ 0: "未学习",
+ 1: "入门",
+ 2: "了解",
+ 3: "熟悉",
+ 4: "熟练",
+ 5: "掌握",
+}
+
+export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
+ const preview = item.question ? extractQuestionPreview(item.question.content) : "(题目已删除)"
+ const isDue = item.nextReviewAt ? item.nextReviewAt <= new Date() : false
+ const isMastered = item.status === "mastered"
+
+ return (
+
+
+
+
+
+ {item.subjectName ? (
+
+
+ {item.subjectName}
+
+ ) : null}
+ {item.question?.difficulty ? (
+
+ 难度 {item.question.difficulty}
+
+ ) : null}
+
+
+
+ {formatDate(item.createdAt)}
+
+
+
+
+ {preview}
+
+
+ {item.errorTags && item.errorTags.length > 0 ? (
+
+ {item.errorTags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ ) : null}
+
+ {item.note ? (
+
+
+ {item.note}
+
+ ) : null}
+
+
+
+ 掌握度: {MASTERY_LEVEL_LABELS[item.masteryLevel] ?? item.masteryLevel}
+ 复习 {item.reviewCount} 次
+ {item.nextReviewAt && !isMastered ? (
+
+ {isDue ? "需复习" : `下次 ${formatDate(item.nextReviewAt)}`}
+
+ ) : null}
+
+ {children}
+
+
+
+ )
+}
diff --git a/src/modules/error-book/components/error-book-stats-cards.tsx b/src/modules/error-book/components/error-book-stats-cards.tsx
new file mode 100644
index 0000000..4b055b6
--- /dev/null
+++ b/src/modules/error-book/components/error-book-stats-cards.tsx
@@ -0,0 +1,60 @@
+import { BookX, Clock, GraduationCap, Repeat, Sparkles } from "lucide-react"
+
+import { StatCard } from "@/shared/components/ui/stat-card"
+import type { ErrorBookStats } from "../types"
+
+interface ErrorBookStatsCardsProps {
+ stats: ErrorBookStats
+ isLoading?: boolean
+}
+
+export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsProps) {
+ const masteredPercent = stats.totalCount > 0
+ ? Math.round(stats.masteredRate * 100)
+ : 0
+
+ return (
+
+
+
+
+
+ 0}
+ isLoading={isLoading}
+ />
+
+ )
+}
diff --git a/src/modules/error-book/components/review-buttons.tsx b/src/modules/error-book/components/review-buttons.tsx
new file mode 100644
index 0000000..d2cff28
--- /dev/null
+++ b/src/modules/error-book/components/review-buttons.tsx
@@ -0,0 +1,98 @@
+"use client"
+
+import { useState, useTransition } from "react"
+import { RotateCcw, ThumbsUp, Check, Zap } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/shared/components/ui/button"
+import { reviewErrorBookItemAction } from "../actions"
+import type { ErrorBookReviewResultValue } from "../types"
+
+interface ReviewButtonsProps {
+ itemId: string
+ onReviewed?: () => void
+}
+
+const REVIEW_OPTIONS: Array<{
+ result: ErrorBookReviewResultValue
+ label: string
+ description: string
+ icon: typeof RotateCcw
+ variant: "destructive" | "secondary" | "default" | "outline"
+}> = [
+ {
+ result: "again",
+ label: "重来",
+ description: "完全不会,明天再复习",
+ icon: RotateCcw,
+ variant: "destructive",
+ },
+ {
+ result: "hard",
+ label: "困难",
+ description: "勉强答对,2 天后复习",
+ icon: Zap,
+ variant: "secondary",
+ },
+ {
+ result: "good",
+ label: "良好",
+ description: "正常答对,4 天后复习",
+ icon: ThumbsUp,
+ variant: "default",
+ },
+ {
+ result: "easy",
+ label: "简单",
+ description: "轻松答对,7 天后复习",
+ icon: Check,
+ variant: "outline",
+ },
+]
+
+export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
+ const [isPending, startTransition] = useTransition()
+ const [selected, setSelected] = useState(null)
+
+ function handleReview(result: ErrorBookReviewResultValue) {
+ setSelected(result)
+ startTransition(async () => {
+ const formData = new FormData()
+ formData.append("json", JSON.stringify({ itemId, result }))
+ const res = await reviewErrorBookItemAction(undefined, formData)
+ if (res.success) {
+ toast.success(res.message ?? "复习结果已记录")
+ onReviewed?.()
+ } else {
+ toast.error(res.message ?? "记录失败")
+ setSelected(null)
+ }
+ })
+ }
+
+ return (
+
+ {REVIEW_OPTIONS.map((opt) => {
+ const Icon = opt.icon
+ const isLoading = isPending && selected === opt.result
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/modules/error-book/components/top-wrong-questions.tsx b/src/modules/error-book/components/top-wrong-questions.tsx
new file mode 100644
index 0000000..f910245
--- /dev/null
+++ b/src/modules/error-book/components/top-wrong-questions.tsx
@@ -0,0 +1,109 @@
+import { Flame } from "lucide-react"
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
+import { Badge } from "@/shared/components/ui/badge"
+import { EmptyState } from "@/shared/components/ui/empty-state"
+
+interface TopWrongQuestion {
+ questionId: string
+ questionContent: unknown
+ questionType: string
+ errorCount: number
+ masteredCount: number
+}
+
+interface TopWrongQuestionsProps {
+ questions: TopWrongQuestion[]
+}
+
+function extractPreview(content: unknown): string {
+ if (typeof content === "string") return content.slice(0, 120)
+ 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, 120)
+ }
+ return "题目内容"
+}
+
+const QUESTION_TYPE_LABEL: Record = {
+ single_choice: "单选",
+ multiple_choice: "多选",
+ judgment: "判断",
+ text: "简答",
+ composite: "复合",
+}
+
+export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
+ if (questions.length === 0) {
+ return (
+
+
+
+
+ 高频错题
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ 高频错题 Top 10
+
+
+
+
+ {questions.map((q, idx) => {
+ const masteryRate = q.errorCount > 0 ? q.masteredCount / q.errorCount : 0
+ return (
+
+
+ #{idx + 1}
+
+
+
+ {extractPreview(q.questionContent)}
+
+
+
+ {QUESTION_TYPE_LABEL[q.questionType] ?? q.questionType}
+
+ {q.errorCount} 人错
+ ·
+
+ {q.masteredCount} 人已掌握
+
+ ·
+ 掌握率 {Math.round(masteryRate * 100)}%
+
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/modules/error-book/data-access.ts b/src/modules/error-book/data-access.ts
new file mode 100644
index 0000000..1c3ec4d
--- /dev/null
+++ b/src/modules/error-book/data-access.ts
@@ -0,0 +1,944 @@
+import "server-only"
+
+import { cache } from "react"
+import { and, count, desc, eq, inArray, isNull, lte, or, sql, type SQL } from "drizzle-orm"
+import { createId } from "@paralleldrive/cuid2"
+
+import { db } from "@/shared/db"
+import {
+ errorBookItems,
+ errorBookReviews,
+ examSubmissions,
+ submissionAnswers,
+ homeworkSubmissions,
+ homeworkAnswers,
+ questions,
+ questionsToKnowledgePoints,
+ knowledgePoints,
+ subjects,
+ examQuestions,
+ homeworkAssignmentQuestions,
+ users,
+} from "@/shared/db/schema"
+import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
+import {
+ calculateNewInterval,
+ calculateNewMastery,
+ deriveStatus,
+ calculateNextReviewAt,
+ calculateNewCorrectStreak,
+} from "./sm2-algorithm"
+
+import type {
+ ErrorBookItem,
+ ErrorBookItemDetail,
+ ErrorBookListResult,
+ ErrorBookReviewRecord,
+ ErrorBookStats,
+ ErrorBookStatusValue,
+ GetErrorBookItemsParams,
+} from "./types"
+import type { ErrorBookReviewResult } from "./schema"
+
+// ---------------------------------------------------------------------------
+// SM-2 间隔重复算法(简化版)
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// 类型守卫
+// ---------------------------------------------------------------------------
+
+const isReviewResult = (v: unknown): v is ErrorBookReviewResult =>
+ v === "again" || v === "hard" || v === "good" || v === "easy"
+
+const toReviewResult = (v: string | null | undefined): ErrorBookReviewResult =>
+ isReviewResult(v) ? v : "again"
+
+const isStatus = (v: unknown): v is ErrorBookStatusValue =>
+ v === "new" || v === "learning" || v === "mastered" || v === "archived"
+
+const toStatus = (v: string | null | undefined): ErrorBookStatusValue =>
+ isStatus(v) ? v : "new"
+
+// ---------------------------------------------------------------------------
+// 行映射
+// ---------------------------------------------------------------------------
+
+function mapRowToItem(row: typeof errorBookItems.$inferSelect & {
+ question?: typeof questions.$inferSelect | null
+ subject?: typeof subjects.$inferSelect | null
+}): ErrorBookItem {
+ return {
+ id: row.id,
+ studentId: row.studentId,
+ questionId: row.questionId,
+ sourceType: row.sourceType as ErrorBookItem["sourceType"],
+ sourceId: row.sourceId,
+ studentAnswer: row.studentAnswer,
+ correctAnswer: row.correctAnswer,
+ subjectId: row.subjectId,
+ knowledgePointIds: row.knowledgePointIds as string[] | null,
+ status: toStatus(row.status),
+ masteryLevel: row.masteryLevel,
+ nextReviewAt: row.nextReviewAt,
+ reviewInterval: row.reviewInterval,
+ reviewCount: row.reviewCount,
+ correctStreak: row.correctStreak,
+ note: row.note,
+ errorTags: row.errorTags as string[] | null,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ question: row.question
+ ? {
+ id: row.question.id,
+ content: row.question.content,
+ type: row.question.type,
+ difficulty: row.question.difficulty,
+ }
+ : null,
+ subjectName: row.subject?.name ?? null,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// 查询:错题本列表
+// ---------------------------------------------------------------------------
+
+export const getErrorBookItems = cache(async (params: GetErrorBookItemsParams): Promise => {
+ const { studentId, q, page = 1, pageSize = 20, status, sourceType, subjectId, dueOnly } = params
+ const offset = (page - 1) * pageSize
+
+ const conditions: SQL[] = [eq(errorBookItems.studentId, studentId)]
+
+ if (status) {
+ conditions.push(eq(errorBookItems.status, status))
+ }
+
+ if (sourceType) {
+ conditions.push(eq(errorBookItems.sourceType, sourceType))
+ }
+
+ if (subjectId) {
+ conditions.push(eq(errorBookItems.subjectId, subjectId))
+ }
+
+ if (dueOnly) {
+ const now = new Date()
+ conditions.push(
+ or(
+ isNull(errorBookItems.nextReviewAt),
+ lte(errorBookItems.nextReviewAt, now)
+ )!
+ )
+ }
+
+ if (q && q.trim().length > 0) {
+ const needle = `%${q.trim().toLowerCase()}%`
+ conditions.push(sql`LOWER(CAST(${errorBookItems.note} AS CHAR)) LIKE ${needle}`)
+ }
+
+ const whereClause = and(...conditions)
+
+ const [totalResult] = await db
+ .select({ value: count() })
+ .from(errorBookItems)
+ .where(whereClause)
+
+ const total = Number(totalResult?.value ?? 0)
+
+ const rows = await db.query.errorBookItems.findMany({
+ where: whereClause,
+ limit: pageSize,
+ offset,
+ orderBy: [desc(errorBookItems.createdAt)],
+ with: {
+ question: {
+ columns: {
+ id: true,
+ content: true,
+ type: true,
+ difficulty: true,
+ },
+ },
+ subject: {
+ columns: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ })
+
+ return {
+ data: rows.map((row) => mapRowToItem(row as unknown as Parameters[0])),
+ meta: {
+ page,
+ pageSize,
+ total,
+ totalPages: Math.ceil(total / pageSize),
+ },
+ }
+})
+
+// ---------------------------------------------------------------------------
+// 查询:错题详情
+// ---------------------------------------------------------------------------
+
+export const getErrorBookItemById = cache(async (
+ itemId: string,
+ studentId: string
+): Promise => {
+ const row = await db.query.errorBookItems.findFirst({
+ where: and(
+ eq(errorBookItems.id, itemId),
+ eq(errorBookItems.studentId, studentId)
+ ),
+ with: {
+ question: {
+ columns: {
+ id: true,
+ content: true,
+ type: true,
+ difficulty: true,
+ },
+ },
+ subject: {
+ columns: {
+ id: true,
+ name: true,
+ },
+ },
+ reviews: {
+ orderBy: [desc(errorBookReviews.reviewedAt)],
+ limit: 20,
+ },
+ },
+ })
+
+ if (!row) return null
+
+ const base = mapRowToItem(row as unknown as Parameters[0])
+ const reviews: ErrorBookReviewRecord[] = (row.reviews ?? []).map((r) => ({
+ id: r.id,
+ result: toReviewResult(r.result),
+ reviewedAt: r.reviewedAt,
+ newInterval: r.newInterval,
+ newMasteryLevel: r.newMasteryLevel,
+ }))
+
+ return { ...base, reviews }
+})
+
+// ---------------------------------------------------------------------------
+// 查询:错题本统计
+// ---------------------------------------------------------------------------
+
+export const getErrorBookStats = cache(async (studentId: string): Promise => {
+ const now = new Date()
+
+ const rows = await db
+ .select({
+ status: errorBookItems.status,
+ nextReviewAt: errorBookItems.nextReviewAt,
+ })
+ .from(errorBookItems)
+ .where(eq(errorBookItems.studentId, studentId))
+
+ const total = rows.length
+ let newCount = 0
+ let learningCount = 0
+ let masteredCount = 0
+ let archivedCount = 0
+ let dueReviewCount = 0
+
+ for (const row of rows) {
+ const status = toStatus(row.status)
+ if (status === "new") newCount++
+ else if (status === "learning") learningCount++
+ else if (status === "mastered") masteredCount++
+ else if (status === "archived") archivedCount++
+
+ if (status !== "mastered" && status !== "archived") {
+ if (!row.nextReviewAt || row.nextReviewAt <= now) {
+ dueReviewCount++
+ }
+ }
+ }
+
+ return {
+ totalCount: total,
+ newCount,
+ learningCount,
+ masteredCount,
+ archivedCount,
+ dueReviewCount,
+ masteredRate: total > 0 ? masteredCount / total : 0,
+ }
+})
+
+// ---------------------------------------------------------------------------
+// 写入:手动添加错题
+// ---------------------------------------------------------------------------
+
+export async function createErrorBookItem(
+ studentId: string,
+ input: {
+ questionId: string
+ studentAnswer?: unknown
+ correctAnswer?: unknown
+ subjectId?: string
+ knowledgePointIds?: string[]
+ note?: string
+ errorTags?: string[]
+ }
+): Promise {
+ const newId = createId()
+ const now = new Date()
+
+ // 如果未提供知识点,从题目关联中查询
+ let knowledgePointIds = input.knowledgePointIds
+ if (!knowledgePointIds || knowledgePointIds.length === 0) {
+ const kps = await db
+ .select({ id: questionsToKnowledgePoints.knowledgePointId })
+ .from(questionsToKnowledgePoints)
+ .where(eq(questionsToKnowledgePoints.questionId, input.questionId))
+ knowledgePointIds = kps.map((k) => k.id)
+ }
+
+ // 如果未提供学科,从题目关联中查询(暂留空,学科由调用方提供)
+
+ await db.insert(errorBookItems).values({
+ id: newId,
+ studentId,
+ questionId: input.questionId,
+ sourceType: "manual",
+ sourceId: null,
+ studentAnswer: input.studentAnswer ?? null,
+ correctAnswer: input.correctAnswer ?? null,
+ subjectId: input.subjectId ?? null,
+ knowledgePointIds: knowledgePointIds ?? null,
+ status: "new",
+ masteryLevel: 0,
+ nextReviewAt: now, // 立即可复习
+ reviewInterval: 1,
+ reviewCount: 0,
+ correctStreak: 0,
+ note: input.note ?? null,
+ errorTags: input.errorTags ?? null,
+ })
+
+ return newId
+}
+
+// ---------------------------------------------------------------------------
+// 写入:更新笔记
+// ---------------------------------------------------------------------------
+
+export async function updateErrorBookNote(
+ itemId: string,
+ studentId: string,
+ input: { note?: string; errorTags?: string[] }
+): Promise {
+ await db
+ .update(errorBookItems)
+ .set({
+ ...(input.note !== undefined ? { note: input.note } : {}),
+ ...(input.errorTags !== undefined ? { errorTags: input.errorTags } : {}),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(errorBookItems.id, itemId),
+ eq(errorBookItems.studentId, studentId)
+ )
+ )
+}
+
+// ---------------------------------------------------------------------------
+// 写入:记录复习结果(SM-2 算法)
+// ---------------------------------------------------------------------------
+
+export async function recordReview(
+ itemId: string,
+ studentId: string,
+ result: ErrorBookReviewResult
+): Promise {
+ const item = await db.query.errorBookItems.findFirst({
+ where: and(
+ eq(errorBookItems.id, itemId),
+ eq(errorBookItems.studentId, studentId)
+ ),
+ })
+
+ if (!item) throw new Error("错题条目不存在")
+
+ const newInterval = calculateNewInterval(item.reviewInterval, result, item.reviewCount)
+ const newStreak = calculateNewCorrectStreak(item.correctStreak, result)
+ const newMastery = calculateNewMastery(item.masteryLevel, result, newStreak)
+ const newStatus = deriveStatus(newMastery, newStreak)
+ const nextReviewAt = newStatus === "mastered" ? null : calculateNextReviewAt(newInterval)
+
+ await db.transaction(async (tx) => {
+ await tx.insert(errorBookReviews).values({
+ id: createId(),
+ itemId,
+ studentId,
+ result,
+ reviewedAt: new Date(),
+ newInterval,
+ newMasteryLevel: newMastery,
+ })
+
+ await tx
+ .update(errorBookItems)
+ .set({
+ status: newStatus,
+ masteryLevel: newMastery,
+ reviewInterval: newInterval,
+ reviewCount: item.reviewCount + 1,
+ correctStreak: newStreak,
+ nextReviewAt,
+ updatedAt: new Date(),
+ })
+ .where(eq(errorBookItems.id, itemId))
+ })
+}
+
+// ---------------------------------------------------------------------------
+// 写入:删除错题
+// ---------------------------------------------------------------------------
+
+export async function deleteErrorBookItem(itemId: string, studentId: string): Promise {
+ await db
+ .delete(errorBookItems)
+ .where(
+ and(
+ eq(errorBookItems.id, itemId),
+ eq(errorBookItems.studentId, studentId)
+ )
+ )
+}
+
+// ---------------------------------------------------------------------------
+// 写入:归档错题
+// ---------------------------------------------------------------------------
+
+export async function archiveErrorBookItem(itemId: string, studentId: string): Promise {
+ await db
+ .update(errorBookItems)
+ .set({ status: "archived", updatedAt: new Date() })
+ .where(
+ and(
+ eq(errorBookItems.id, itemId),
+ eq(errorBookItems.studentId, studentId)
+ )
+ )
+}
+
+// ---------------------------------------------------------------------------
+// 自动采集:从考试提交中收集错题
+// ---------------------------------------------------------------------------
+
+export async function collectFromExamSubmission(
+ submissionId: string,
+ studentId: string
+): Promise {
+ const submission = await db.query.examSubmissions.findFirst({
+ where: and(
+ eq(examSubmissions.id, submissionId),
+ eq(examSubmissions.studentId, studentId)
+ ),
+ })
+
+ if (!submission) throw new Error("考试提交记录不存在")
+
+ // 查询该提交的所有作答
+ const answers = await db
+ .select({
+ answerId: submissionAnswers.id,
+ questionId: submissionAnswers.questionId,
+ answerContent: submissionAnswers.answerContent,
+ score: submissionAnswers.score,
+ feedback: submissionAnswers.feedback,
+ })
+ .from(submissionAnswers)
+ .where(eq(submissionAnswers.submissionId, submissionId))
+
+ // 查询题目满分(用于判断是否答错)
+ const questionIds = answers.map((a) => a.questionId)
+ const examQuestionScores = await db
+ .select({
+ questionId: examQuestions.questionId,
+ maxScore: examQuestions.score,
+ })
+ .from(examQuestions)
+ .where(
+ and(
+ eq(examQuestions.examId, submission.examId),
+ inArray(examQuestions.questionId, questionIds)
+ )
+ )
+
+ const maxScoreMap = new Map(examQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0]))
+
+ // 筛选错题:得分为 0 或低于满分
+ const wrongAnswers = answers.filter((a) => {
+ const max = maxScoreMap.get(a.questionId) ?? 0
+ return (a.score ?? 0) < max
+ })
+
+ if (wrongAnswers.length === 0) return 0
+
+ // 查询已存在的错题,避免重复
+ const existing = await db
+ .select({ questionId: errorBookItems.questionId })
+ .from(errorBookItems)
+ .where(
+ and(
+ eq(errorBookItems.studentId, studentId),
+ inArray(
+ errorBookItems.questionId,
+ wrongAnswers.map((a) => a.questionId)
+ )
+ )
+ )
+ const existingSet = new Set(existing.map((e) => e.questionId))
+
+ // 查询题目关联的知识点
+ const kpRows = await db
+ .select({
+ questionId: questionsToKnowledgePoints.questionId,
+ knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
+ })
+ .from(questionsToKnowledgePoints)
+ .where(
+ inArray(
+ questionsToKnowledgePoints.questionId,
+ wrongAnswers.map((a) => a.questionId)
+ )
+ )
+ const kpMap = new Map()
+ for (const kp of kpRows) {
+ const list = kpMap.get(kp.questionId) ?? []
+ list.push(kp.knowledgePointId)
+ kpMap.set(kp.questionId, list)
+ }
+
+ // 批量插入
+ const now = new Date()
+ const toInsert = wrongAnswers
+ .filter((a) => !existingSet.has(a.questionId))
+ .map((a) => ({
+ id: createId(),
+ studentId,
+ questionId: a.questionId,
+ sourceType: "exam" as const,
+ sourceId: submissionId,
+ studentAnswer: a.answerContent,
+ correctAnswer: null,
+ subjectId: null,
+ knowledgePointIds: kpMap.get(a.questionId) ?? null,
+ status: "new" as const,
+ masteryLevel: 0,
+ nextReviewAt: now,
+ reviewInterval: 1,
+ reviewCount: 0,
+ correctStreak: 0,
+ note: a.feedback ?? null,
+ errorTags: null,
+ }))
+
+ if (toInsert.length > 0) {
+ await db.insert(errorBookItems).values(toInsert)
+ }
+
+ return toInsert.length
+}
+
+// ---------------------------------------------------------------------------
+// 自动采集:从作业提交中收集错题
+// ---------------------------------------------------------------------------
+
+export async function collectFromHomeworkSubmission(
+ submissionId: string,
+ studentId: string
+): Promise {
+ const submission = await db.query.homeworkSubmissions.findFirst({
+ where: eq(homeworkSubmissions.id, submissionId),
+ })
+
+ if (!submission) throw new Error("作业提交记录不存在")
+
+ const answers = await db
+ .select({
+ answerId: homeworkAnswers.id,
+ questionId: homeworkAnswers.questionId,
+ answerContent: homeworkAnswers.answerContent,
+ score: homeworkAnswers.score,
+ feedback: homeworkAnswers.feedback,
+ })
+ .from(homeworkAnswers)
+ .where(eq(homeworkAnswers.submissionId, submissionId))
+
+ // 查询题目满分
+ const questionIds = answers.map((a) => a.questionId)
+ const hwQuestionScores = await db
+ .select({
+ questionId: homeworkAssignmentQuestions.questionId,
+ maxScore: homeworkAssignmentQuestions.score,
+ })
+ .from(homeworkAssignmentQuestions)
+ .where(
+ and(
+ eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
+ inArray(homeworkAssignmentQuestions.questionId, questionIds)
+ )
+ )
+
+ const maxScoreMap = new Map(hwQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0]))
+
+ const wrongAnswers = answers.filter((a) => {
+ const max = maxScoreMap.get(a.questionId) ?? 0
+ return (a.score ?? 0) < max
+ })
+
+ if (wrongAnswers.length === 0) return 0
+
+ // 去重
+ const existing = await db
+ .select({ questionId: errorBookItems.questionId })
+ .from(errorBookItems)
+ .where(
+ and(
+ eq(errorBookItems.studentId, studentId),
+ inArray(
+ errorBookItems.questionId,
+ wrongAnswers.map((a) => a.questionId)
+ )
+ )
+ )
+ const existingSet = new Set(existing.map((e) => e.questionId))
+
+ // 查询知识点
+ const kpRows = await db
+ .select({
+ questionId: questionsToKnowledgePoints.questionId,
+ knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
+ })
+ .from(questionsToKnowledgePoints)
+ .where(
+ inArray(
+ questionsToKnowledgePoints.questionId,
+ wrongAnswers.map((a) => a.questionId)
+ )
+ )
+ const kpMap = new Map()
+ for (const kp of kpRows) {
+ const list = kpMap.get(kp.questionId) ?? []
+ list.push(kp.knowledgePointId)
+ kpMap.set(kp.questionId, list)
+ }
+
+ const now = new Date()
+ const toInsert = wrongAnswers
+ .filter((a) => !existingSet.has(a.questionId))
+ .map((a) => ({
+ id: createId(),
+ studentId,
+ questionId: a.questionId,
+ sourceType: "homework" as const,
+ sourceId: submissionId,
+ studentAnswer: a.answerContent,
+ correctAnswer: null,
+ subjectId: null,
+ knowledgePointIds: kpMap.get(a.questionId) ?? null,
+ status: "new" as const,
+ masteryLevel: 0,
+ nextReviewAt: now,
+ reviewInterval: 1,
+ reviewCount: 0,
+ correctStreak: 0,
+ note: a.feedback ?? null,
+ errorTags: null,
+ }))
+
+ if (toInsert.length > 0) {
+ await db.insert(errorBookItems).values(toInsert)
+ }
+
+ return toInsert.length
+}
+
+// ---------------------------------------------------------------------------
+// 跨模块查询接口:供教师/家长视图使用
+// ---------------------------------------------------------------------------
+
+/** 查询多个学生的错题统计(教师视图) */
+export async function getStudentErrorBookSummaries(
+ studentIds: string[]
+): Promise> {
+ if (studentIds.length === 0) return []
+
+ const now = new Date()
+ const rows = await db
+ .select({
+ studentId: errorBookItems.studentId,
+ status: errorBookItems.status,
+ nextReviewAt: errorBookItems.nextReviewAt,
+ updatedAt: errorBookItems.updatedAt,
+ })
+ .from(errorBookItems)
+ .where(inArray(errorBookItems.studentId, studentIds))
+
+ const map = new Map()
+
+ for (const row of rows) {
+ const stat = map.get(row.studentId) ?? {
+ totalCount: 0,
+ newCount: 0,
+ learningCount: 0,
+ masteredCount: 0,
+ dueReviewCount: 0,
+ lastActivityAt: null,
+ }
+ stat.totalCount++
+ const status = toStatus(row.status)
+ if (status === "new") stat.newCount++
+ else if (status === "learning") stat.learningCount++
+ else if (status === "mastered") stat.masteredCount++
+
+ if (status !== "mastered" && status !== "archived") {
+ if (!row.nextReviewAt || row.nextReviewAt <= now) {
+ stat.dueReviewCount++
+ }
+ }
+
+ if (!stat.lastActivityAt || row.updatedAt > stat.lastActivityAt) {
+ stat.lastActivityAt = row.updatedAt
+ }
+
+ map.set(row.studentId, stat)
+ }
+
+ return Array.from(map.entries()).map(([studentId, stat]) => ({
+ studentId,
+ ...stat,
+ masteredRate: stat.totalCount > 0 ? stat.masteredCount / stat.totalCount : 0,
+ }))
+}
+
+/** 查询班级内错题最多的题目(教师视图:高频错题) */
+export async function getTopWrongQuestionsByStudentIds(
+ studentIds: string[],
+ limit = 10
+): Promise> {
+ if (studentIds.length === 0) return []
+
+ const rows = await db
+ .select({
+ questionId: errorBookItems.questionId,
+ status: errorBookItems.status,
+ content: questions.content,
+ type: questions.type,
+ })
+ .from(errorBookItems)
+ .innerJoin(questions, eq(questions.id, errorBookItems.questionId))
+ .where(inArray(errorBookItems.studentId, studentIds))
+
+ const map = new Map()
+
+ for (const row of rows) {
+ const stat = map.get(row.questionId) ?? {
+ questionContent: row.content,
+ questionType: row.type,
+ errorCount: 0,
+ masteredCount: 0,
+ }
+ stat.errorCount++
+ if (toStatus(row.status) === "mastered") stat.masteredCount++
+ map.set(row.questionId, stat)
+ }
+
+ return Array.from(map.entries())
+ .map(([questionId, stat]) => ({ questionId, ...stat }))
+ .sort((a, b) => b.errorCount - a.errorCount)
+ .slice(0, limit)
+}
+
+/** 按班级 ID 查询学生 ID 列表(委托给 classes 模块) */
+export async function getStudentIdsByClassIdList(classIds: string[]): Promise {
+ return await getStudentIdsByClassIds(classIds)
+}
+
+/**
+ * 查询所有学生用户 ID(管理员视图)。
+ * 通过 usersToRoles + roles 表关联查询 role === "student" 的用户。
+ * 此函数封装了 DB 访问,避免 app 层直接查询 DB(遵循三层架构)。
+ */
+export async function getAllStudentIds(): Promise {
+ const { usersToRoles, roles } = await import("@/shared/db/schema")
+ const studentRole = await db
+ .select({ id: roles.id })
+ .from(roles)
+ .where(eq(roles.name, "student"))
+ .limit(1)
+
+ if (studentRole.length === 0) return []
+
+ const userRoleRows = await db
+ .select({ userId: usersToRoles.userId })
+ .from(usersToRoles)
+ .where(eq(usersToRoles.roleId, studentRole[0].id))
+
+ return userRoleRows.map((r) => r.userId)
+}
+
+// ---------------------------------------------------------------------------
+// 统计:知识点薄弱度 & 学科分布(教师/管理员视图)
+// ---------------------------------------------------------------------------
+
+/** 查询多个学生的知识点薄弱度统计 */
+export async function getKnowledgePointWeakness(
+ studentIds: string[],
+ limit = 10
+): Promise> {
+ if (studentIds.length === 0) return []
+
+ // 查询这些学生的所有错题条目(含知识点)
+ const rows = await db
+ .select({
+ itemId: errorBookItems.id,
+ status: errorBookItems.status,
+ knowledgePointIds: errorBookItems.knowledgePointIds,
+ })
+ .from(errorBookItems)
+ .where(inArray(errorBookItems.studentId, studentIds))
+
+ // 展开知识点并统计
+ const kpMap = new Map()
+
+ for (const row of rows) {
+ const kps = (row.knowledgePointIds as string[] | null) ?? []
+ for (const kpId of kps) {
+ const stat = kpMap.get(kpId) ?? { errorCount: 0, masteredCount: 0 }
+ stat.errorCount++
+ if (toStatus(row.status) === "mastered") stat.masteredCount++
+ kpMap.set(kpId, stat)
+ }
+ }
+
+ if (kpMap.size === 0) return []
+
+ // 查询知识点名称
+ const kpIds = Array.from(kpMap.keys())
+ const kpRows = await db
+ .select({ id: knowledgePoints.id, name: knowledgePoints.name })
+ .from(knowledgePoints)
+ .where(inArray(knowledgePoints.id, kpIds))
+ const kpNameMap = new Map(kpRows.map((k) => [k.id, k.name]))
+
+ return Array.from(kpMap.entries())
+ .map(([kpId, stat]) => ({
+ knowledgePointId: kpId,
+ knowledgePointName: kpNameMap.get(kpId) ?? "未知知识点",
+ errorCount: stat.errorCount,
+ masteredCount: stat.masteredCount,
+ totalCount: stat.errorCount,
+ masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
+ }))
+ .sort((a, b) => {
+ // 按错误数降序,掌握率升序(最薄弱的在前)
+ if (b.errorCount !== a.errorCount) return b.errorCount - a.errorCount
+ return a.masteryRate - b.masteryRate
+ })
+ .slice(0, limit)
+}
+
+/** 查询多个学生的学科错题分布 */
+export async function getSubjectErrorDistribution(
+ studentIds: string[]
+): Promise> {
+ if (studentIds.length === 0) return []
+
+ const rows = await db
+ .select({
+ subjectId: errorBookItems.subjectId,
+ status: errorBookItems.status,
+ })
+ .from(errorBookItems)
+ .where(inArray(errorBookItems.studentId, studentIds))
+
+ const subjectMap = new Map()
+
+ for (const row of rows) {
+ const key = row.subjectId
+ const stat = subjectMap.get(key) ?? { errorCount: 0, masteredCount: 0 }
+ stat.errorCount++
+ if (toStatus(row.status) === "mastered") stat.masteredCount++
+ subjectMap.set(key, stat)
+ }
+
+ // 查询学科名称
+ const subjectIds = Array.from(subjectMap.keys()).filter((k): k is string => k !== null)
+ let subjectNameMap = new Map()
+ if (subjectIds.length > 0) {
+ const subjectRows = await db
+ .select({ id: subjects.id, name: subjects.name })
+ .from(subjects)
+ .where(inArray(subjects.id, subjectIds))
+ subjectNameMap = new Map(subjectRows.map((s) => [s.id, s.name]))
+ }
+
+ return Array.from(subjectMap.entries()).map(([sid, stat]) => ({
+ subjectId: sid,
+ subjectName: sid ? (subjectNameMap.get(sid) ?? "未知学科") : "未分类",
+ errorCount: stat.errorCount,
+ masteredCount: stat.masteredCount,
+ masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
+ }))
+}
+
+/** 查询学生姓名映射 */
+export async function getStudentNameMap(studentIds: string[]): Promise