- 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
178 lines
5.2 KiB
TypeScript
178 lines
5.2 KiB
TypeScript
"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>
|
||
)
|
||
}
|