"use client" import { useState } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy } from "lucide-react" import { toast } from "sonner" import { Button } from "@/shared/components/ui/button" import { ScrollArea } from "@/shared/components/ui/scroll-area" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/shared/components/ui/alert-dialog" import { Dialog, DialogContent, DialogTitle, } from "@/shared/components/ui/dialog" import { deleteExamAction, duplicateExamAction, updateExamAction, getExamPreviewAction } from "../actions" import { Exam } from "../types" import { ExamPaperPreview } from "./assembly/exam-paper-preview" import type { ExamNode } from "./assembly/selected-question-list" // Raw structure node shape returned from the DB before hydration type RawStructureNode = { id?: string type?: string questionId?: string score?: number title?: string children?: RawStructureNode[] } // Type guard to narrow unknown structure payload to raw nodes const isRawStructureNode = (v: unknown): v is RawStructureNode => { if (typeof v !== "object" || v === null) return false // 从 unknown 收窄为 Record 以进行字段检查 const obj = v as Record return typeof obj.type === "string" } const isRawStructureArray = (v: unknown): v is RawStructureNode[] => Array.isArray(v) && v.every((item) => isRawStructureNode(item)) interface ExamActionsProps { exam: Exam } export function ExamActions({ exam }: ExamActionsProps) { const router = useRouter() const t = useTranslations("examHomework") const [showViewDialog, setShowViewDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isWorking, setIsWorking] = useState(false) const [previewNodes, setPreviewNodes] = useState(null) const [loadingPreview, setLoadingPreview] = useState(false) const handleView = async () => { setLoadingPreview(true) setShowViewDialog(true) try { const result = await getExamPreviewAction(exam.id) if (result.success && result.data) { const { structure } = result.data const hydrate = (nodes: RawStructureNode[]): ExamNode[] => { return nodes.map((node) => { if (node.type === "question") { return { id: node.id ?? node.questionId ?? "", type: "question" as const, questionId: node.questionId, score: node.score, // Question content is not available in preview payload; left undefined } } if (node.type === "group") { return { id: node.id ?? "", type: "group" as const, title: node.title, score: node.score, children: hydrate(node.children ?? []), } } // Unknown node type: treat as group with no children to avoid runtime crash return { id: node.id ?? "", type: "group" as const, title: node.title, children: [], } }) } const nodes = isRawStructureArray(structure) ? hydrate(structure) : [] setPreviewNodes(nodes) } else { toast.error(t("exam.actions.previewFailed")) setShowViewDialog(false) } } catch { toast.error(t("exam.actions.previewFailed")) setShowViewDialog(false) } finally { setLoadingPreview(false) } } const copyId = () => { navigator.clipboard.writeText(exam.id) toast.success(t("exam.actions.idCopied")) } const setStatus = async (status: Exam["status"]) => { setIsWorking(true) try { const formData = new FormData() formData.set("examId", exam.id) formData.set("status", status) const result = await updateExamAction(null, formData) if (result.success) { toast.success(status === "published" ? t("exam.actions.publishSuccess") : status === "archived" ? t("exam.actions.archiveSuccess") : t("exam.actions.draftSuccess")) router.refresh() } else { toast.error(result.message || t("exam.actions.updateFailed")) } } catch { toast.error(t("exam.actions.updateFailed")) } finally { setIsWorking(false) } } const duplicateExam = async () => { setIsWorking(true) try { const formData = new FormData() formData.set("examId", exam.id) const result = await duplicateExamAction(null, formData) if (result.success && result.data) { toast.success(t("exam.actions.duplicateSuccess")) router.push(`/teacher/exams/${result.data}/build`) router.refresh() } else { toast.error(result.message || t("exam.actions.duplicateFailed")) } } catch { toast.error(t("exam.actions.duplicateFailed")) } finally { setIsWorking(false) } } const handleDelete = async () => { setIsWorking(true) try { const formData = new FormData() formData.set("examId", exam.id) const result = await deleteExamAction(null, formData) if (result.success) { toast.success(t("exam.actions.deleteSuccess")) setShowDeleteDialog(false) router.refresh() } else { toast.error(result.message || t("exam.actions.deleteFailed")) } } catch { toast.error(t("exam.actions.deleteFailed")) } finally { setIsWorking(false) } } return ( <>
{t("exam.actions.copyId")} {t("exam.actions.copyId")} router.push(`/teacher/exams/${exam.id}/build`)}> {t("exam.actions.edit")} router.push(`/teacher/exams/${exam.id}/build`)}> {t("exam.actions.build")} {t("exam.actions.duplicate")} setStatus("published")} disabled={isWorking || exam.status === "published"} > {t("exam.actions.publish")} setStatus("draft")} disabled={isWorking || exam.status === "draft"} > {t("exam.actions.moveToDraft")} setStatus("archived")} disabled={isWorking || exam.status === "archived"} > {t("exam.actions.archive")} setShowDeleteDialog(true)} disabled={isWorking} > {t("exam.actions.delete")}
{t("exam.actions.deleteConfirmTitle")} {t("exam.actions.deleteConfirmDescription", { title: exam.title })} {t("exam.actions.cancel")} { e.preventDefault() handleDelete() }} disabled={isWorking} > {t("exam.actions.delete")}
{exam.title}
{loadingPreview ? (
{t("exam.actions.loadingPreview")}
) : previewNodes && previewNodes.length > 0 ? (
) : (
{t("exam.actions.noQuestions")}
)}
) }