Files
NextEdu/src/modules/error-book/components/error-book-detail-dialog.tsx
SpecialX d7017f0e30 feat(modules-classes): update classes, course-plans, dashboard, diagnostic, elective, error-book
- classes: update actions-shared, admin-classes-view, class-detail, class-invitation-manager,

  grade-classes-view, my-classes-grid, schedule dialogs, students-table,

  data-access-admin, data-access-students, data-access-teacher, data-access

- course-plans: update course-plan-detail, course-plan-form, course-plan-item-editor,

  template-picker-dialog, data-access

- dashboard: update dashboard-time-range-filter, use-dashboard-preferences,

  use-dashboard-realtime

- diagnostic: update class-diagnostic-view, report-list, data-access

- elective: update elective-course-form, elective-course-list, student-selection-view,

  data-access-operations, data-access-selections, data-access

- error-book: update add-error-book-dialog, error-book-detail-dialog, review-buttons
2026-07-07 16:19:08 +08:00

337 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useTransition } from "react"
import { useTranslations } from "next-intl"
import { Archive, Trash2, FileText, Calendar, History, Target } from "lucide-react"
import { notify } from "@/shared/lib/notify"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { Separator } from "@/shared/components/ui/separator"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { formatDate, formatDateTime } from "@/shared/lib/utils"
import {
archiveErrorBookItemAction,
deleteErrorBookItemAction,
updateErrorBookNoteAction,
} from "../actions"
import {
ERROR_BOOK_SOURCE_LABEL,
ERROR_BOOK_SOURCE_VARIANT,
ERROR_BOOK_STATUS_LABEL,
ERROR_BOOK_STATUS_VARIANT,
REVIEW_RESULT_LABEL,
REVIEW_RESULT_VARIANT,
COMMON_ERROR_TAGS,
type ErrorBookItemDetail,
type ErrorBookItem,
} from "../types"
import { ReviewButtons } from "./review-buttons"
interface ErrorBookDetailDialogProps {
item: ErrorBookItemDetail | (Omit<ErrorBookItemDetail, "reviews"> & { reviews?: ErrorBookItemDetail["reviews"] })
trigger: React.ReactNode
/** 当前学生 ID保留接口兼容AI 分析已通过 aiAnalysisSlot 注入) */
studentId?: string
/** 全部错题列表保留接口兼容AI 分析已通过 aiAnalysisSlot 注入) */
errorItems?: ErrorBookItem[]
/** AI 分析区域插槽(由父组件注入,未提供则不渲染) */
aiAnalysisSlot?: React.ReactNode
/** 发起变式练习回调(未提供则不渲染变式练习入口) */
onStartVariantPractice?: () => void
}
export function ErrorBookDetailDialog({
item,
trigger,
aiAnalysisSlot,
onStartVariantPractice,
}: ErrorBookDetailDialogProps) {
const [open, setOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const [note, setNote] = useState(item.note ?? "")
const [errorTags, setErrorTags] = useState<string[]>(item.errorTags ?? [])
const t = useTranslations("errorBook")
function handleSaveNote() {
startTransition(async () => {
const formData = new FormData()
formData.append(
"json",
JSON.stringify({ itemId: item.id, note, errorTags })
)
const res = await updateErrorBookNoteAction(undefined, formData)
if (res.success) {
notify.success(t("messages.noteSaved"))
} else {
notify.error(res.message ?? t("messages.saveFailed"))
}
})
}
function handleArchive() {
startTransition(async () => {
const formData = new FormData()
formData.append("itemId", item.id)
const res = await archiveErrorBookItemAction(undefined, formData)
if (res.success) {
notify.success(t("messages.archived"))
setOpen(false)
} else {
notify.error(res.message ?? t("messages.archiveFailed"))
}
})
}
function handleDelete() {
startTransition(async () => {
const formData = new FormData()
formData.append("itemId", item.id)
const res = await deleteErrorBookItemAction(undefined, formData)
if (res.success) {
notify.success(t("messages.deleted"))
setOpen(false)
} else {
notify.error(res.message ?? t("messages.deleteFailed"))
}
})
}
function toggleTag(tag: string) {
setErrorTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 flex-wrap">
<StatusBadge
status={item.status}
variantMap={ERROR_BOOK_STATUS_VARIANT}
labelMap={ERROR_BOOK_STATUS_LABEL}
capitalize={false}
/>
<StatusBadge
status={item.sourceType}
variantMap={ERROR_BOOK_SOURCE_VARIANT}
labelMap={ERROR_BOOK_SOURCE_LABEL}
capitalize={false}
/>
{item.subjectName ? (
<Badge variant="outline">{item.subjectName}</Badge>
) : null}
</DialogTitle>
<DialogDescription className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{t("itemCard.addedAt", { date: formatDate(item.createdAt) })}
</span>
<span>{t("itemCard.masteryOutOf", { level: item.masteryLevel })}</span>
<span>{t("itemCard.reviewTimes", { count: item.reviewCount })}</span>
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 -mx-6 px-6">
<div className="space-y-4 pb-4">
{/* 题目内容 */}
<section>
<h4 className="mb-2 text-sm font-medium">{t("detailDialog.question")}</h4>
<div className="rounded-md border bg-muted/30 p-3 text-sm">
{item.question ? (
<pre className="whitespace-pre-wrap break-words font-sans">
{typeof item.question.content === "string"
? item.question.content
: JSON.stringify(item.question.content, null, 2)}
</pre>
) : (
<span className="text-muted-foreground">
{t("detailDialog.questionDeleted")}
</span>
)}
</div>
</section>
{/* 作答对比 */}
{(item.studentAnswer !== null && item.studentAnswer !== undefined) || (item.correctAnswer !== null && item.correctAnswer !== undefined) ? (
<section className="grid gap-3 sm:grid-cols-2">
{item.studentAnswer !== null && item.studentAnswer !== undefined ? (
<div>
<h4 className="mb-2 text-sm font-medium text-rose-600 dark:text-rose-400">
{t("detailDialog.myAnswer")}
</h4>
<div className="rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm dark:border-rose-900 dark:bg-rose-950/20">
<pre className="whitespace-pre-wrap break-words font-sans">
{typeof item.studentAnswer === "string"
? item.studentAnswer
: JSON.stringify(item.studentAnswer, null, 2)}
</pre>
</div>
</div>
) : null}
{item.correctAnswer !== null && item.correctAnswer !== undefined ? (
<div>
<h4 className="mb-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
{t("detailDialog.correctAnswer")}
</h4>
<div className="rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
<pre className="whitespace-pre-wrap break-words font-sans">
{typeof item.correctAnswer === "string"
? item.correctAnswer
: JSON.stringify(item.correctAnswer, null, 2)}
</pre>
</div>
</div>
) : null}
</section>
) : null}
{/* AI 分析区(由父组件注入) */}
{aiAnalysisSlot ? (
<section>
<h4 className="mb-2 text-sm font-medium">{t("detailDialog.aiAnalysis")}</h4>
{aiAnalysisSlot}
</section>
) : null}
{/* 变式练习入口(由父组件注入回调) */}
{onStartVariantPractice ? (
<section>
<Button
onClick={onStartVariantPractice}
disabled={isPending}
className="w-full"
variant="default"
>
<Target className="h-4 w-4" />
{t("detailDialog.variantPractice")}
</Button>
<p className="mt-1 text-xs text-muted-foreground text-center">
{t("detailDialog.variantPracticeDesc")}
</p>
</section>
) : null}
{/* 复习区 */}
{item.status !== "mastered" && item.status !== "archived" ? (
<section>
<h4 className="mb-2 text-sm font-medium">{t("detailDialog.reviewSelf")}</h4>
<ReviewButtons
itemId={item.id}
onReviewed={() => setOpen(false)}
/>
</section>
) : null}
{/* 笔记编辑 */}
<section>
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
<FileText className="h-4 w-4" />
{t("detailDialog.studyNote")}
</h4>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("detailDialog.notePlaceholder")}
className="w-full min-h-20 rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
maxLength={2000}
/>
<div className="mt-2">
<p className="mb-1 text-xs text-muted-foreground">
{t("detailDialog.errorTagsLabel")}
</p>
<div className="flex flex-wrap gap-1">
{COMMON_ERROR_TAGS.map((tag) => (
<Badge
key={tag}
variant={errorTags.includes(tag) ? "default" : "outline"}
className="cursor-pointer"
onClick={() => toggleTag(tag)}
>
{tag}
</Badge>
))}
</div>
</div>
<Button
size="sm"
variant="outline"
className="mt-2"
disabled={isPending}
onClick={handleSaveNote}
>
{t("actions.saveNote")}
</Button>
</section>
{/* 复习历史 */}
{item.reviews && item.reviews.length > 0 ? (
<section>
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
<History className="h-4 w-4" />
{t("detailDialog.reviewHistory")}
</h4>
<div className="space-y-1">
{item.reviews.slice(0, 10).map((r) => (
<div
key={r.id}
className="flex items-center justify-between rounded-md border px-3 py-1.5 text-xs"
>
<StatusBadge
status={r.result}
variantMap={REVIEW_RESULT_VARIANT}
labelMap={REVIEW_RESULT_LABEL}
capitalize={false}
/>
<span className="text-muted-foreground">
{formatDateTime(r.reviewedAt)}
</span>
</div>
))}
</div>
</section>
) : null}
<Separator />
{/* 操作按钮 */}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
disabled={isPending}
onClick={handleArchive}
>
<Archive className="h-4 w-4" data-icon="inline-start" />
{t("actions.archive")}
</Button>
<Button
variant="ghost"
size="sm"
disabled={isPending}
onClick={handleDelete}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" data-icon="inline-start" />
{t("actions.delete")}
</Button>
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}