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
This commit is contained in:
SpecialX
2026-06-23 17:36:42 +08:00
parent 396c2c568d
commit bf056399c6
26 changed files with 3613 additions and 0 deletions

View File

@@ -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<ActionState<ErrorBookListResult>> {
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<ActionState<ErrorBookItemDetail>> {
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<ActionState<ErrorBookStatsType>> {
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<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
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<void> | undefined,
formData: FormData
): Promise<ActionState<void>> {
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<void> | undefined,
formData: FormData
): Promise<ActionState<void>> {
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<void> | undefined,
formData: FormData
): Promise<ActionState<void>> {
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<void> | undefined,
formData: FormData
): Promise<ActionState<void>> {
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<number> | undefined,
formData: FormData
): Promise<ActionState<number>> {
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 }
}
}

View File

@@ -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<string[]>([])
const [questionOptions, setQuestionOptions] = useState<Array<{
id: string
preview: string
}>>([])
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<string, unknown>
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" data-icon="inline-start" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
/
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="question"></Label>
<Select value={questionId} onValueChange={setQuestionId}>
<SelectTrigger id="question">
<SelectValue placeholder="从题库中选择..." />
</SelectTrigger>
<SelectContent>
{questionOptions.map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.preview}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="note"></Label>
<Textarea
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="记录错误原因、解题思路..."
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-1">
{COMMON_ERROR_TAGS.map((tag) => (
<Button
key={tag}
type="button"
variant={errorTags.includes(tag) ? "default" : "outline"}
size="sm"
onClick={() => toggleTag(tag)}
>
{tag}
</Button>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button disabled={isPending || !questionId} onClick={handleSubmit}>
{isPending ? "添加中..." : "添加"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -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 (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="覆盖学生"
value={`${studentsWithErrorBook}/${totalStudents}`}
icon={Users}
description="有错题记录的学生数"
/>
<StatCard
title="错题总数"
value={totalErrorItems}
icon={AlertTriangle}
color="text-rose-500"
description="班级累计错题"
/>
<StatCard
title="平均掌握率"
value={`${formatNumber(averageMasteryRate * 100, 0)}%`}
icon={TrendingUp}
color="text-emerald-500"
description="已掌握错题占比"
/>
<StatCard
title="薄弱知识点"
value={topWeakKnowledgePoints.length}
icon={Target}
color="text-amber-500"
description="需重点讲解"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* 薄弱知识点 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Target className="h-4 w-4" />
Top 10
</CardTitle>
</CardHeader>
<CardContent>
{topWeakKnowledgePoints.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
</p>
) : (
<div className="space-y-3">
{topWeakKnowledgePoints.map((kp, idx) => (
<div key={kp.knowledgePointId} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2">
<Badge variant="outline" className="w-6 justify-center">
{idx + 1}
</Badge>
<span className="line-clamp-1">{kp.knowledgePointName}</span>
</span>
<span className="text-muted-foreground">
{kp.errorCount} · {formatNumber(kp.masteryRate * 100, 0)}%
</span>
</div>
<Progress value={kp.masteryRate * 100} className="h-1.5" />
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 学科分布 */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{subjectDistribution.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
</p>
) : (
<div className="space-y-3">
{subjectDistribution.map((s) => (
<div key={s.subjectId ?? "none"} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{s.subjectName}</span>
<span className="text-muted-foreground">
{s.errorCount} · {formatNumber(s.masteryRate * 100, 0)}%
</span>
</div>
<Progress value={s.masteryRate * 100} className="h-1.5" />
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}
interface StudentErrorTableProps {
students: StudentErrorBookSummary[]
studentNames: Map<string, string>
basePath: string
}
export function StudentErrorTable({ students, studentNames, basePath }: StudentErrorTableProps) {
if (students.length === 0) {
return (
<EmptyState
icon={Users}
title="暂无学生错题数据"
description="学生完成作业或考试后,错题数据会自动汇总到这里。"
className="h-[300px] bg-card"
/>
)
}
return (
<div className="rounded-md border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{students.map((s) => {
const name = studentNames.get(s.studentId) ?? "未知"
return (
<tr key={s.studentId} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<Link
href={`${basePath}?studentId=${s.studentId}`}
className="font-medium hover:underline"
>
{name}
</Link>
</td>
<td className="px-4 py-3 text-right">{s.totalCount}</td>
<td className="px-4 py-3 text-right text-blue-600 dark:text-blue-400">{s.newCount}</td>
<td className="px-4 py-3 text-right text-amber-600 dark:text-amber-400">{s.learningCount}</td>
<td className="px-4 py-3 text-right text-emerald-600 dark:text-emerald-400">{s.masteredCount}</td>
<td className="px-4 py-3 text-right text-rose-600 dark:text-rose-400">{s.dueReviewCount}</td>
<td className="px-4 py-3 text-right">
{formatNumber(s.masteredRate * 100, 0)}%
</td>
<td className="px-4 py-3 text-right text-muted-foreground text-xs">
{s.lastActivityAt ? formatDate(s.lastActivityAt) : "-"}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -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 (
<FilterBar
layout="between"
gapClassName="gap-4"
hasFilters={hasFilters}
onReset={() => {
setSearch(null)
setStatus(null)
setSourceType(null)
setDueOnly(null)
}}
>
<div className="flex flex-1 flex-wrap items-center gap-2">
<FilterSearchInput
value={search}
onChange={(v) => setSearch(v || null)}
placeholder="搜索笔记内容..."
className="flex-1 md:max-w-sm"
inputClassName="border-muted-foreground/20 pl-8"
/>
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="new"></SelectItem>
<SelectItem value="learning"></SelectItem>
<SelectItem value="mastered"></SelectItem>
<SelectItem value="archived"></SelectItem>
</SelectContent>
</Select>
<Select value={sourceType} onValueChange={(val) => setSourceType(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="exam"></SelectItem>
<SelectItem value="homework"></SelectItem>
<SelectItem value="manual"></SelectItem>
</SelectContent>
</Select>
<Select value={dueOnly} onValueChange={(val) => setDueOnly(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="复习" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="due"></SelectItem>
</SelectContent>
</Select>
</div>
</FilterBar>
)
}

View File

@@ -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<string, unknown>
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<string, unknown>
if (typeof c.text === "string") return c.text
}
return "(题目内容)"
}
const MASTERY_LEVEL_LABELS: Record<number, string> = {
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 (
<Card
className={cn(
"transition-colors hover:bg-accent/50",
isDue && !isMastered && "border-rose-200 bg-rose-50/30 dark:border-rose-900 dark:bg-rose-950/10"
)}
>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
status={item.status}
variantMap={ERROR_BOOK_STATUS_VARIANT}
labelMap={ERROR_BOOK_STATUS_LABEL}
capitalize={false}
/>
<StatusBadge
status={item.sourceType}
variantMap={ERROR_BOOK_SOURCE_VARIANT}
labelMap={ERROR_BOOK_SOURCE_LABEL}
capitalize={false}
/>
{item.subjectName ? (
<Badge variant="outline" className="gap-1">
<BookMarked className="h-3 w-3" />
{item.subjectName}
</Badge>
) : null}
{item.question?.difficulty ? (
<Badge variant="secondary">
{item.question.difficulty}
</Badge>
) : null}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
{formatDate(item.createdAt)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="line-clamp-2 text-sm">
{preview}
</div>
{item.errorTags && item.errorTags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{item.errorTags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
) : null}
{item.note ? (
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-2 text-xs text-muted-foreground">
<FileText className="mt-0.5 h-3 w-3 shrink-0" />
<span className="line-clamp-2">{item.note}</span>
</div>
) : null}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>: {MASTERY_LEVEL_LABELS[item.masteryLevel] ?? item.masteryLevel}</span>
<span> {item.reviewCount} </span>
{item.nextReviewAt && !isMastered ? (
<span className={cn(isDue && "font-medium text-rose-600 dark:text-rose-400")}>
{isDue ? "需复习" : `下次 ${formatDate(item.nextReviewAt)}`}
</span>
) : null}
</div>
{children}
</div>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard
title="错题总数"
value={stats.totalCount}
icon={BookX}
description="累计收录的错题"
isLoading={isLoading}
/>
<StatCard
title="待学习"
value={stats.newCount}
icon={Sparkles}
color="text-blue-500"
description="尚未开始复习"
isLoading={isLoading}
/>
<StatCard
title="学习中"
value={stats.learningCount}
icon={Repeat}
color="text-amber-500"
description="正在复习掌握"
isLoading={isLoading}
/>
<StatCard
title="已掌握"
value={stats.masteredCount}
icon={GraduationCap}
color="text-emerald-500"
description={`掌握率 ${masteredPercent}%`}
isLoading={isLoading}
/>
<StatCard
title="待复习"
value={stats.dueReviewCount}
icon={Clock}
color="text-rose-500"
description="今日到期复习"
highlight={stats.dueReviewCount > 0}
isLoading={isLoading}
/>
</div>
)
}

View File

@@ -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<ErrorBookReviewResultValue | null>(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 (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{REVIEW_OPTIONS.map((opt) => {
const Icon = opt.icon
const isLoading = isPending && selected === opt.result
return (
<Button
key={opt.result}
variant={opt.variant}
size="sm"
disabled={isPending}
onClick={() => handleReview(opt.result)}
className="flex flex-col items-center gap-1 h-auto py-3"
>
<Icon className="h-4 w-4" data-icon="inline-start" />
<span className="font-medium">{opt.label}</span>
<span className="text-[10px] font-normal text-muted-foreground">
{opt.description}
</span>
{isLoading ? "..." : null}
</Button>
)
})}
</div>
)
}

View File

@@ -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<string, unknown>
if (typeof n.text === "string") texts.push(n.text)
}
}
return texts.join("").slice(0, 120)
}
return "题目内容"
}
const QUESTION_TYPE_LABEL: Record<string, string> = {
single_choice: "单选",
multiple_choice: "多选",
judgment: "判断",
text: "简答",
composite: "复合",
}
export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
if (questions.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Flame className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<EmptyState
icon={Flame}
title="暂无高频错题"
description="学生完成作业或考试后,错频统计会显示在这里。"
className="h-[200px]"
/>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Flame className="h-4 w-4" />
Top 10
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{questions.map((q, idx) => {
const masteryRate = q.errorCount > 0 ? q.masteredCount / q.errorCount : 0
return (
<div
key={q.questionId}
className="flex items-start gap-3 rounded-md border p-3"
>
<Badge variant="outline" className="mt-0.5 shrink-0">
#{idx + 1}
</Badge>
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm line-clamp-2">
{extractPreview(q.questionContent)}
</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="text-xs">
{QUESTION_TYPE_LABEL[q.questionType] ?? q.questionType}
</Badge>
<span>{q.errorCount} </span>
<span>·</span>
<span className="text-emerald-600 dark:text-emerald-400">
{q.masteredCount}
</span>
<span>·</span>
<span> {Math.round(masteryRate * 100)}%</span>
</div>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}

View File

@@ -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<ErrorBookListResult> => {
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<typeof mapRowToItem>[0])),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
}
})
// ---------------------------------------------------------------------------
// 查询:错题详情
// ---------------------------------------------------------------------------
export const getErrorBookItemById = cache(async (
itemId: string,
studentId: string
): Promise<ErrorBookItemDetail | null> => {
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<typeof mapRowToItem>[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<ErrorBookStats> => {
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<string> {
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<void> {
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<void> {
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<void> {
await db
.delete(errorBookItems)
.where(
and(
eq(errorBookItems.id, itemId),
eq(errorBookItems.studentId, studentId)
)
)
}
// ---------------------------------------------------------------------------
// 写入:归档错题
// ---------------------------------------------------------------------------
export async function archiveErrorBookItem(itemId: string, studentId: string): Promise<void> {
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<number> {
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<string, string[]>()
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<number> {
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<string, string[]>()
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<Array<{
studentId: string
totalCount: number
newCount: number
learningCount: number
masteredCount: number
dueReviewCount: number
masteredRate: number
lastActivityAt: Date | null
}>> {
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<string, {
totalCount: number
newCount: number
learningCount: number
masteredCount: number
dueReviewCount: number
lastActivityAt: Date | null
}>()
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<Array<{
questionId: string
questionContent: unknown
questionType: string
errorCount: number
masteredCount: number
}>> {
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<string, { questionContent: unknown; questionType: string; errorCount: number; masteredCount: number }>()
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<string[]> {
return await getStudentIdsByClassIds(classIds)
}
/**
* 查询所有学生用户 ID管理员视图
* 通过 usersToRoles + roles 表关联查询 role === "student" 的用户。
* 此函数封装了 DB 访问,避免 app 层直接查询 DB遵循三层架构
*/
export async function getAllStudentIds(): Promise<string[]> {
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<Array<{
knowledgePointId: string
knowledgePointName: string
errorCount: number
masteredCount: number
totalCount: number
masteryRate: number
}>> {
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<string, { errorCount: number; masteredCount: number }>()
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<Array<{
subjectId: string | null
subjectName: string
errorCount: number
masteredCount: number
masteryRate: number
}>> {
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<string | null, { errorCount: number; masteredCount: number }>()
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<string, string>()
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<Map<string, string>> {
if (studentIds.length === 0) return new Map()
const rows = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, studentIds))
return new Map(rows.map((r) => [r.id, r.name ?? "未知"]))
}

View File

@@ -0,0 +1,51 @@
import { z } from "zod"
/** 错题来源类型 */
export const ErrorBookSourceTypeEnum = z.enum(["exam", "homework", "manual"])
export type ErrorBookSourceType = z.infer<typeof ErrorBookSourceTypeEnum>
/** 错题状态 */
export const ErrorBookStatusEnum = z.enum(["new", "learning", "mastered", "archived"])
export type ErrorBookStatus = z.infer<typeof ErrorBookStatusEnum>
/** 复习自评结果SM-2 算法评级) */
export const ErrorBookReviewResultEnum = z.enum(["again", "hard", "good", "easy"])
export type ErrorBookReviewResult = z.infer<typeof ErrorBookReviewResultEnum>
/** 手动添加错题的输入 schema */
export const CreateErrorBookItemSchema = z.object({
questionId: z.string().min(1, "请选择题目"),
studentAnswer: z.unknown().optional(),
correctAnswer: z.unknown().optional(),
subjectId: z.string().optional(),
knowledgePointIds: z.array(z.string()).optional(),
note: z.string().max(2000, "笔记不能超过 2000 字").optional(),
errorTags: z.array(z.string()).optional(),
})
export type CreateErrorBookItemInput = z.infer<typeof CreateErrorBookItemSchema>
/** 更新错题笔记的 schema */
export const UpdateErrorBookNoteSchema = z.object({
itemId: z.string().min(1),
note: z.string().max(2000, "笔记不能超过 2000 字").optional(),
errorTags: z.array(z.string()).optional(),
})
export type UpdateErrorBookNoteInput = z.infer<typeof UpdateErrorBookNoteSchema>
/** 记录复习结果的 schema */
export const ReviewErrorBookItemSchema = z.object({
itemId: z.string().min(1),
result: ErrorBookReviewResultEnum,
})
export type ReviewErrorBookItemInput = z.infer<typeof ReviewErrorBookItemSchema>
/** 从考试/作业提交自动采集错题的 schema */
export const CollectFromSubmissionSchema = z.object({
submissionId: z.string().min(1),
sourceType: z.enum(["exam", "homework"]),
})
export type CollectFromSubmissionInput = z.infer<typeof CollectFromSubmissionSchema>

View File

@@ -0,0 +1,302 @@
import { describe, it, expect } from "vitest"
import {
calculateNewInterval,
calculateNewMastery,
deriveStatus,
calculateNextReviewAt,
calculateNewCorrectStreak,
calculateSm2Result,
REVIEW_INTERVALS,
INTERVAL_MULTIPLIERS,
MAX_MASTERY_LEVEL,
MIN_MASTERY_LEVEL,
MASTERED_REQUIRED_STREAK,
MASTERED_REQUIRED_MASTERY,
} from "./sm2-algorithm"
import type { ErrorBookReviewResult } from "./schema"
describe("SM-2 间隔重复算法", () => {
describe("REVIEW_INTERVALS 常量", () => {
it("应该包含 4 种评级的映射", () => {
expect(REVIEW_INTERVALS.again).toBeDefined()
expect(REVIEW_INTERVALS.hard).toBeDefined()
expect(REVIEW_INTERVALS.good).toBeDefined()
expect(REVIEW_INTERVALS.easy).toBeDefined()
})
it("again 应该重置连续答对", () => {
expect(REVIEW_INTERVALS.again.streakDelta).toBeLessThan(0)
})
it("good 和 easy 应该增加连续答对", () => {
expect(REVIEW_INTERVALS.good.streakDelta).toBe(1)
expect(REVIEW_INTERVALS.easy.streakDelta).toBe(1)
})
})
describe("INTERVAL_MULTIPLIERS 常量", () => {
it("easy 的倍率应该最高", () => {
expect(INTERVAL_MULTIPLIERS.easy).toBeGreaterThan(INTERVAL_MULTIPLIERS.good)
expect(INTERVAL_MULTIPLIERS.good).toBeGreaterThan(INTERVAL_MULTIPLIERS.hard)
})
it("所有倍率应该 >= 1", () => {
for (const multiplier of Object.values(INTERVAL_MULTIPLIERS)) {
expect(multiplier).toBeGreaterThanOrEqual(1)
}
})
})
describe("calculateNewInterval", () => {
it("again 总是返回 1 天", () => {
expect(calculateNewInterval(10, "again", 5)).toBe(1)
expect(calculateNewInterval(100, "again", 10)).toBe(1)
})
it("首次复习reviewCount=0使用基础间隔", () => {
expect(calculateNewInterval(0, "hard", 0)).toBe(REVIEW_INTERVALS.hard.interval)
expect(calculateNewInterval(0, "good", 0)).toBe(REVIEW_INTERVALS.good.interval)
expect(calculateNewInterval(0, "easy", 0)).toBe(REVIEW_INTERVALS.easy.interval)
})
it("good 评级应该按 ×1.5 增长", () => {
const result = calculateNewInterval(10, "good", 1)
expect(result).toBe(Math.max(REVIEW_INTERVALS.good.interval, Math.round(10 * 1.5)))
expect(result).toBe(15)
})
it("easy 评级应该按 ×2 增长", () => {
const result = calculateNewInterval(10, "easy", 1)
expect(result).toBe(Math.max(REVIEW_INTERVALS.easy.interval, Math.round(10 * 2)))
expect(result).toBe(20)
})
it("hard 评级应该按 ×1.2 增长", () => {
const result = calculateNewInterval(10, "hard", 1)
expect(result).toBe(Math.max(REVIEW_INTERVALS.hard.interval, Math.round(10 * 1.2)))
expect(result).toBe(12)
})
it("新间隔不应该小于基础间隔", () => {
expect(calculateNewInterval(1, "good", 1)).toBeGreaterThanOrEqual(REVIEW_INTERVALS.good.interval)
expect(calculateNewInterval(1, "easy", 1)).toBeGreaterThanOrEqual(REVIEW_INTERVALS.easy.interval)
})
})
describe("calculateNewMastery", () => {
it("again 应该降低掌握度", () => {
expect(calculateNewMastery(3, "again", 0)).toBe(2)
expect(calculateNewMastery(5, "again", 0)).toBe(4)
})
it("hard 应该保持掌握度不变", () => {
expect(calculateNewMastery(3, "hard", 0)).toBe(3)
expect(calculateNewMastery(0, "hard", 0)).toBe(0)
})
it("good 应该增加掌握度", () => {
expect(calculateNewMastery(2, "good", 0)).toBe(3)
expect(calculateNewMastery(4, "good", 0)).toBe(5)
})
it("easy 应该大幅增加掌握度", () => {
expect(calculateNewMastery(1, "easy", 0)).toBe(3)
expect(calculateNewMastery(3, "easy", 0)).toBe(5)
})
it("掌握度不应该超过上限", () => {
expect(calculateNewMastery(5, "easy", 0)).toBe(MAX_MASTERY_LEVEL)
expect(calculateNewMastery(5, "good", 0)).toBe(MAX_MASTERY_LEVEL)
})
it("掌握度不应该低于下限", () => {
expect(calculateNewMastery(0, "again", 0)).toBe(MIN_MASTERY_LEVEL)
expect(calculateNewMastery(0, "hard", 0)).toBe(MIN_MASTERY_LEVEL)
})
it("连续 3 次答对应该直接达到已掌握", () => {
expect(calculateNewMastery(2, "good", 3)).toBe(MAX_MASTERY_LEVEL)
expect(calculateNewMastery(1, "easy", 3)).toBe(MAX_MASTERY_LEVEL)
})
})
describe("deriveStatus", () => {
it("掌握度 0 应该返回 new", () => {
expect(deriveStatus(0, 0)).toBe("new")
})
it("掌握度 1-4 应该返回 learning", () => {
expect(deriveStatus(1, 0)).toBe("learning")
expect(deriveStatus(2, 0)).toBe("learning")
expect(deriveStatus(3, 0)).toBe("learning")
expect(deriveStatus(4, 0)).toBe("learning")
})
it("掌握度 5 应该返回 mastered", () => {
expect(deriveStatus(5, 0)).toBe("mastered")
})
it("连续答对 3 次应该返回 mastered", () => {
expect(deriveStatus(1, 3)).toBe("mastered")
expect(deriveStatus(2, 3)).toBe("mastered")
})
it("连续答对 2 次且掌握度 < 5 应该返回 learning", () => {
expect(deriveStatus(2, 2)).toBe("learning")
})
})
describe("calculateNextReviewAt", () => {
it("应该返回正确的日期(+intervalDays 天)", () => {
const now = new Date("2026-01-15T10:00:00Z")
const result = calculateNextReviewAt(7, now)
expect(result.getDate()).toBe(now.getDate() + 7)
})
it("应该设置时间为早上 9 点", () => {
const now = new Date("2026-01-15T22:00:00Z")
const result = calculateNextReviewAt(1, now)
expect(result.getHours()).toBe(9)
expect(result.getMinutes()).toBe(0)
expect(result.getSeconds()).toBe(0)
})
it("间隔为 0 天时应该返回当天", () => {
const now = new Date("2026-01-15T10:00:00Z")
const result = calculateNextReviewAt(0, now)
expect(result.getDate()).toBe(now.getDate())
})
})
describe("calculateNewCorrectStreak", () => {
it("again 应该重置连续答对为 0", () => {
expect(calculateNewCorrectStreak(5, "again")).toBe(0)
expect(calculateNewCorrectStreak(0, "again")).toBe(0)
})
it("hard 应该保持连续答对不变", () => {
expect(calculateNewCorrectStreak(3, "hard")).toBe(3)
expect(calculateNewCorrectStreak(0, "hard")).toBe(0)
})
it("good 应该增加连续答对", () => {
expect(calculateNewCorrectStreak(2, "good")).toBe(3)
expect(calculateNewCorrectStreak(0, "good")).toBe(1)
})
it("easy 应该增加连续答对", () => {
expect(calculateNewCorrectStreak(2, "easy")).toBe(3)
expect(calculateNewCorrectStreak(0, "easy")).toBe(1)
})
})
describe("calculateSm2Result", () => {
it("应该返回完整的计算结果", () => {
const result = calculateSm2Result(
{
currentInterval: 4,
currentMastery: 2,
currentStreak: 1,
reviewCount: 2,
},
"good"
)
expect(result).toHaveProperty("newInterval")
expect(result).toHaveProperty("newMasteryLevel")
expect(result).toHaveProperty("newCorrectStreak")
expect(result).toHaveProperty("newStatus")
expect(result).toHaveProperty("nextReviewAt")
})
it("good 评级应该正确计算所有字段", () => {
const result = calculateSm2Result(
{
currentInterval: 4,
currentMastery: 2,
currentStreak: 1,
reviewCount: 2,
},
"good"
)
expect(result.newInterval).toBe(6) // max(4, round(4 * 1.5)) = 6
expect(result.newMasteryLevel).toBe(3) // 2 + 1 = 3
expect(result.newCorrectStreak).toBe(2) // 1 + 1 = 2
expect(result.newStatus).toBe("learning") // mastery 3, streak 2
})
it("连续 3 次 good 应该达到 mastered", () => {
const result = calculateSm2Result(
{
currentInterval: 4,
currentMastery: 2,
currentStreak: 2,
reviewCount: 2,
},
"good"
)
expect(result.newCorrectStreak).toBe(3)
expect(result.newMasteryLevel).toBe(MAX_MASTERY_LEVEL) // 连续 3 次直接到 5
expect(result.newStatus).toBe("mastered")
})
it("again 应该重置所有进度", () => {
const result = calculateSm2Result(
{
currentInterval: 20,
currentMastery: 4,
currentStreak: 2,
reviewCount: 5,
},
"again"
)
expect(result.newInterval).toBe(1)
expect(result.newMasteryLevel).toBe(3) // 4 - 1 = 3
expect(result.newCorrectStreak).toBe(0)
expect(result.newStatus).toBe("learning") // mastery 3
})
it("所有评级都应该产生有效结果", () => {
const ratings: ErrorBookReviewResult[] = ["again", "hard", "good", "easy"]
for (const rating of ratings) {
const result = calculateSm2Result(
{
currentInterval: 4,
currentMastery: 2,
currentStreak: 1,
reviewCount: 2,
},
rating
)
expect(result.newInterval).toBeGreaterThan(0)
expect(result.newMasteryLevel).toBeGreaterThanOrEqual(MIN_MASTERY_LEVEL)
expect(result.newMasteryLevel).toBeLessThanOrEqual(MAX_MASTERY_LEVEL)
expect(result.newCorrectStreak).toBeGreaterThanOrEqual(0)
expect(["new", "learning", "mastered"]).toContain(result.newStatus)
expect(result.nextReviewAt).toBeInstanceOf(Date)
}
})
})
describe("常量一致性", () => {
it("MASTERED_REQUIRED_STREAK 应该是 3", () => {
expect(MASTERED_REQUIRED_STREAK).toBe(3)
})
it("MASTERED_REQUIRED_MASTERY 应该是 5", () => {
expect(MASTERED_REQUIRED_MASTERY).toBe(5)
})
it("MAX_MASTERY_LEVEL 应该是 5", () => {
expect(MAX_MASTERY_LEVEL).toBe(5)
})
it("MIN_MASTERY_LEVEL 应该是 0", () => {
expect(MIN_MASTERY_LEVEL).toBe(0)
})
})
})

View File

@@ -0,0 +1,177 @@
/**
* SM-2 间隔重复算法(简化版)
*
* 基于 SuperMemo SM-2 算法的简化实现,适用于 K12 错题本场景。
* 4 级评级again重来/ hard困难/ good良好/ easy简单
*
* 设计原则:
* - 纯函数,无副作用,易于测试
* - 算法独立于数据访问层,可替换为其他算法(如 FSRS
* - 所有函数可单独测试
*/
import type { ErrorBookReviewResult } from "./schema"
import type { ErrorBookStatusValue } from "./types"
/** SM-2 评级 → 间隔天数 & 掌握度变化映射 */
export const REVIEW_INTERVALS: Record<
ErrorBookReviewResult,
{ interval: number; masteryDelta: number; streakDelta: number }
> = {
again: { interval: 1, masteryDelta: -1, streakDelta: -999 }, // 重来:重置连续答对
hard: { interval: 2, masteryDelta: 0, streakDelta: 0 },
good: { interval: 4, masteryDelta: 1, streakDelta: 1 },
easy: { interval: 7, masteryDelta: 2, streakDelta: 1 },
}
/** 间隔增长倍率 */
export const INTERVAL_MULTIPLIERS: Record<ErrorBookReviewResult, number> = {
again: 1,
hard: 1.2,
good: 1.5,
easy: 2,
}
/** 掌握度上限 */
export const MAX_MASTERY_LEVEL = 5
/** 掌握度下限 */
export const MIN_MASTERY_LEVEL = 0
/** 已掌握所需的连续答对次数 */
export const MASTERED_REQUIRED_STREAK = 3
/** 已掌握所需的掌握度等级 */
export const MASTERED_REQUIRED_MASTERY = 5
/**
* 根据当前间隔和评级计算新间隔(指数增长)
*
* @param currentInterval 当前间隔天数
* @param result 复习评级
* @param reviewCount 已复习次数(首次复习使用基础间隔)
* @returns 新的间隔天数
*/
export function calculateNewInterval(
currentInterval: number,
result: ErrorBookReviewResult,
reviewCount: number
): number {
const base = REVIEW_INTERVALS[result]
if (result === "again") return 1
if (reviewCount === 0) return base.interval
const multiplier = INTERVAL_MULTIPLIERS[result]
return Math.max(base.interval, Math.round(currentInterval * multiplier))
}
/**
* 计算新的掌握度0-5
*
* @param currentMastery 当前掌握度
* @param result 复习评级
* @param correctStreak 连续答对次数
* @returns 新的掌握度
*/
export function calculateNewMastery(
currentMastery: number,
result: ErrorBookReviewResult,
correctStreak: number
): number {
const delta = REVIEW_INTERVALS[result].masteryDelta
const newMastery = Math.max(MIN_MASTERY_LEVEL, Math.min(MAX_MASTERY_LEVEL, currentMastery + delta))
// 连续 3 次答对则标记为已掌握
if (correctStreak >= MASTERED_REQUIRED_STREAK) return Math.max(newMastery, MASTERED_REQUIRED_MASTERY)
return newMastery
}
/**
* 根据掌握度推断状态
*
* @param masteryLevel 掌握度等级
* @param correctStreak 连续答对次数
* @returns 错题状态
*/
export function deriveStatus(masteryLevel: number, correctStreak: number): ErrorBookStatusValue {
if (masteryLevel >= MASTERED_REQUIRED_MASTERY || correctStreak >= MASTERED_REQUIRED_STREAK) return "mastered"
if (masteryLevel >= 1) return "learning"
return "new"
}
/**
* 计算下次复习时间
*
* @param intervalDays 间隔天数
* @param now 当前时间(可选,用于测试注入)
* @returns 下次复习时间(当天早上 9 点)
*/
export function calculateNextReviewAt(intervalDays: number, now: Date = new Date()): Date {
const date = new Date(now)
date.setDate(date.getDate() + intervalDays)
date.setHours(9, 0, 0, 0) // 早上 9 点复习
return date
}
/**
* 计算新的连续答对次数
*
* @param currentStreak 当前连续答对次数
* @param result 复习评级
* @returns 新的连续答对次数
*/
export function calculateNewCorrectStreak(
currentStreak: number,
result: ErrorBookReviewResult
): number {
const streakDelta = REVIEW_INTERVALS[result].streakDelta
if (streakDelta < 0) return 0 // again 重置
return currentStreak + streakDelta
}
/**
* SM-2 算法完整计算结果
*/
export interface Sm2CalculationResult {
/** 新的间隔天数 */
newInterval: number
/** 新的掌握度 */
newMasteryLevel: number
/** 新的连续答对次数 */
newCorrectStreak: number
/** 推导出的新状态 */
newStatus: ErrorBookStatusValue
/** 下次复习时间 */
nextReviewAt: Date
}
/**
* 一次性计算所有 SM-2 相关字段
*
* @param params 当前错题状态参数
* @param result 复习评级
* @param now 当前时间(可选,用于测试注入)
* @returns 完整计算结果
*/
export function calculateSm2Result(
params: {
currentInterval: number
currentMastery: number
currentStreak: number
reviewCount: number
},
result: ErrorBookReviewResult,
now: Date = new Date()
): Sm2CalculationResult {
const newCorrectStreak = calculateNewCorrectStreak(params.currentStreak, result)
const newInterval = calculateNewInterval(params.currentInterval, result, params.reviewCount)
const newMasteryLevel = calculateNewMastery(params.currentMastery, result, newCorrectStreak)
const newStatus = deriveStatus(newMasteryLevel, newCorrectStreak)
const nextReviewAt = calculateNextReviewAt(newInterval, now)
return {
newInterval,
newMasteryLevel,
newCorrectStreak,
newStatus,
nextReviewAt,
}
}

View File

@@ -0,0 +1,196 @@
import type { StatusVariantMap, StatusLabelMap } from "@/shared/components/ui/status-badge"
import type { ErrorBookSourceType, ErrorBookStatus, ErrorBookReviewResult } from "./schema"
export type ErrorBookSourceTypeValue = ErrorBookSourceType
export type ErrorBookStatusValue = ErrorBookStatus
export type ErrorBookReviewResultValue = ErrorBookReviewResult
/** 错题来源 → Badge variant 映射 */
export const ERROR_BOOK_SOURCE_VARIANT: StatusVariantMap<ErrorBookSourceTypeValue> = {
exam: "default",
homework: "secondary",
manual: "outline",
}
/** 错题来源 → 展示文本映射 */
export const ERROR_BOOK_SOURCE_LABEL: StatusLabelMap<ErrorBookSourceTypeValue> = {
exam: "考试",
homework: "作业",
manual: "手动添加",
}
/** 错题状态 → Badge variant 映射 */
export const ERROR_BOOK_STATUS_VARIANT: StatusVariantMap<ErrorBookStatusValue> = {
new: "secondary",
learning: "default",
mastered: "outline",
archived: "outline",
}
/** 错题状态 → 展示文本映射 */
export const ERROR_BOOK_STATUS_LABEL: StatusLabelMap<ErrorBookStatusValue> = {
new: "待学习",
learning: "学习中",
mastered: "已掌握",
archived: "已归档",
}
/** 复习自评结果 → Badge variant 映射 */
export const REVIEW_RESULT_VARIANT: StatusVariantMap<ErrorBookReviewResultValue> = {
again: "destructive",
hard: "secondary",
good: "default",
easy: "outline",
}
/** 复习自评结果 → 展示文本映射 */
export const REVIEW_RESULT_LABEL: StatusLabelMap<ErrorBookReviewResultValue> = {
again: "重来",
hard: "困难",
good: "良好",
easy: "简单",
}
/** 常见错误原因标签 */
export const COMMON_ERROR_TAGS = [
"概念不清",
"计算错误",
"粗心大意",
"审题不清",
"方法不当",
"记忆错误",
"时间不足",
] as const
/** 错题本列表条目 */
export interface ErrorBookItem {
id: string
studentId: string
questionId: string
sourceType: ErrorBookSourceTypeValue
sourceId: string | null
studentAnswer: unknown
correctAnswer: unknown
subjectId: string | null
knowledgePointIds: string[] | null
status: ErrorBookStatusValue
masteryLevel: number
nextReviewAt: Date | null
reviewInterval: number
reviewCount: number
correctStreak: number
note: string | null
errorTags: string[] | null
createdAt: Date
updatedAt: Date
/** 关联题目(联表查询) */
question: {
id: string
content: unknown
type: string
difficulty: number | null
} | null
/** 关联学科名称 */
subjectName: string | null
}
/** 错题本列表查询参数 */
export type GetErrorBookItemsParams = {
studentId: string
q?: string
page?: number
pageSize?: number
status?: ErrorBookStatusValue
sourceType?: ErrorBookSourceTypeValue
subjectId?: string
/** 仅查询到期需要复习的条目 */
dueOnly?: boolean
}
/** 错题本列表结果 */
export type ErrorBookListResult = {
data: ErrorBookItem[]
meta: {
page: number
pageSize: number
total: number
totalPages: number
}
}
/** 错题详情(含复习历史) */
export interface ErrorBookItemDetail extends ErrorBookItem {
reviews: ErrorBookReviewRecord[]
}
/** 复习记录 */
export interface ErrorBookReviewRecord {
id: string
result: ErrorBookReviewResultValue
reviewedAt: Date
newInterval: number | null
newMasteryLevel: number | null
}
/** 错题本统计概览 */
export interface ErrorBookStats {
totalCount: number
newCount: number
learningCount: number
masteredCount: number
archivedCount: number
dueReviewCount: number
masteredRate: number
}
/** 知识点薄弱度统计 */
export interface KnowledgePointWeakness {
knowledgePointId: string
knowledgePointName: string
errorCount: number
masteredCount: number
totalCount: number
/** 掌握率 0-1 */
masteryRate: number
}
/** 学科错题分布 */
export interface SubjectErrorDistribution {
subjectId: string | null
subjectName: string
errorCount: number
masteredCount: number
masteryRate: number
}
/** 班级学生错题统计(教师视图) */
export interface StudentErrorBookSummary {
studentId: string
totalCount: number
newCount: number
learningCount: number
masteredCount: number
dueReviewCount: number
masteredRate: number
lastActivityAt: Date | null
}
/** 班级错题统计(教师视图) */
export interface ClassErrorBookStats {
totalStudents: number
studentsWithErrorBook: number
totalErrorItems: number
averageErrorPerStudent: number
averageMasteryRate: number
topWeakKnowledgePoints: KnowledgePointWeakness[]
subjectDistribution: SubjectErrorDistribution[]
topStudents: StudentErrorBookSummary[]
}
/** 错题趋势数据点 */
export interface ErrorBookTrendPoint {
date: string
addedCount: number
masteredCount: number
reviewedCount: number
}