feat(error-book): update components, actions, data-access, and add analytics

- Add data-access-analytics for error book analytics queries

- Update actions, data-access for improved error book operations

- Update components: add-error-book-dialog, analytics-stats-cards, chapter-weakness-chart, class-error-bar-chart, class-filter, error-book-detail-dialog, error-book-filters, error-book-item-card, error-book-list, error-book-stats-cards, grouped-student-error-table, knowledge-point-weakness-chart, review-buttons, subject-distribution-chart, subject-tabs, top-wrong-questions

- Remove class-error-overview (replaced by new components)
This commit is contained in:
SpecialX
2026-07-03 10:30:41 +08:00
parent 56c3f32e2d
commit 2dd8c2197c
20 changed files with 1090 additions and 1228 deletions

View File

@@ -3,6 +3,7 @@
import { useState, useTransition, useEffect } from "react"
import { Plus } from "lucide-react"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import {
Dialog,
@@ -23,53 +24,44 @@ import {
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() {
interface QuestionOption {
id: string
preview: string
}
interface AddErrorBookDialogProps {
/** 预加载的题目选项(若提供则直接使用,不再调用 onLoadQuestions */
questionOptions?: QuestionOption[]
/** 懒加载题目选项的回调(对话框打开时调用) */
onLoadQuestions?: () => Promise<QuestionOption[]>
}
export function AddErrorBookDialog({
questionOptions: questionOptionsProp,
onLoadQuestions,
}: AddErrorBookDialogProps) {
const t = useTranslations("error-book")
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
}>>([])
const [loadedOptions, setLoadedOptions] = useState<QuestionOption[]>([])
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 "题目"
}
const questionOptions = questionOptionsProp ?? loadedOptions
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),
}))
)
}
if (open && onLoadQuestions && loadedOptions.length === 0 && !questionOptionsProp) {
onLoadQuestions()
.then((options) => {
setLoadedOptions(options)
})
.catch(() => {})
}
}, [open, questionOptions.length])
}, [open, onLoadQuestions, loadedOptions.length, questionOptionsProp])
function toggleTag(tag: string) {
setErrorTags((prev) =>
@@ -79,7 +71,7 @@ export function AddErrorBookDialog() {
function handleSubmit() {
if (!questionId) {
toast.error("请选择题目")
toast.error(t("messages.selectQuestion"))
return
}
startTransition(async () => {
@@ -90,13 +82,13 @@ export function AddErrorBookDialog() {
)
const res = await createErrorBookItemAction(undefined, formData)
if (res.success) {
toast.success(res.message ?? "已添加")
toast.success(res.message ?? t("messages.addedShort"))
setOpen(false)
setQuestionId("")
setNote("")
setErrorTags([])
} else {
toast.error(res.message ?? "添加失败")
toast.error(res.message ?? t("messages.addFailed"))
}
})
}
@@ -106,23 +98,23 @@ export function AddErrorBookDialog() {
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" data-icon="inline-start" />
{t("actions.add")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("addDialog.title")}</DialogTitle>
<DialogDescription>
/
{t("addDialog.description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="question"></Label>
<Label htmlFor="question">{t("addDialog.selectQuestion")}</Label>
<Select value={questionId} onValueChange={setQuestionId}>
<SelectTrigger id="question">
<SelectValue placeholder="从题库中选择..." />
<SelectValue placeholder={t("addDialog.selectPlaceholder")} />
</SelectTrigger>
<SelectContent>
{questionOptions.map((q) => (
@@ -135,18 +127,18 @@ export function AddErrorBookDialog() {
</div>
<div className="space-y-2">
<Label htmlFor="note"></Label>
<Label htmlFor="note">{t("addDialog.noteLabel")}</Label>
<Textarea
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="记录错误原因、解题思路..."
placeholder={t("addDialog.notePlaceholder")}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Label>{t("addDialog.errorTagsLabel")}</Label>
<div className="flex flex-wrap gap-1">
{COMMON_ERROR_TAGS.map((tag) => (
<Button
@@ -165,10 +157,10 @@ export function AddErrorBookDialog() {
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{t("actions.cancel")}
</Button>
<Button disabled={isPending || !questionId} onClick={handleSubmit}>
{isPending ? "添加中..." : "添加"}
{isPending ? t("actions.adding") : t("actions.add")}
</Button>
</DialogFooter>
</DialogContent>