- 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
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
"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>
|
||
)
|
||
}
|