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,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>
)
}