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