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:
177
src/modules/error-book/components/add-error-book-dialog.tsx
Normal file
177
src/modules/error-book/components/add-error-book-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user