feat: exam actions and data safety fixes

This commit is contained in:
SpecialX
2025-12-30 17:48:22 +08:00
parent e7c902e8e1
commit f7ff018490
27 changed files with 896 additions and 194 deletions

View File

@@ -16,38 +16,93 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
// In a real app, this might be paginated or filtered by exam subject/grade
const { data: questionsData } = await getQuestions({ pageSize: 100 })
const questionOptions: Question[] = questionsData.map((q) => ({
id: q.id,
content: q.content as any,
type: q.type as any,
difficulty: q.difficulty ?? 1,
createdAt: new Date(q.createdAt),
updatedAt: new Date(q.updatedAt),
author: q.author ? {
id: q.author.id,
name: q.author.name || "Unknown",
image: q.author.image || null
} : null,
knowledgePoints: (q.questionsToKnowledgePoints || []).map((kp) => ({
id: kp.knowledgePoint.id,
name: kp.knowledgePoint.name
}))
}))
const initialSelected = (exam.questions || []).map(q => ({
id: q.id,
score: q.score || 0
}))
// Prepare initialStructure on server side to avoid hydration mismatch with random IDs
let initialStructure: ExamNode[] = exam.structure as ExamNode[] || []
const selectedQuestionIds = initialSelected.map((s) => s.id)
const { data: selectedQuestionsData } = selectedQuestionIds.length
? await getQuestions({ ids: selectedQuestionIds, pageSize: Math.max(10, selectedQuestionIds.length) })
: { data: [] as typeof questionsData }
type RawQuestion = (typeof questionsData)[number]
const toQuestionOption = (q: RawQuestion): Question => ({
id: q.id,
content: q.content as Question["content"],
type: q.type as Question["type"],
difficulty: q.difficulty ?? 1,
createdAt: new Date(q.createdAt),
updatedAt: new Date(q.updatedAt),
author: q.author
? {
id: q.author.id,
name: q.author.name || "Unknown",
image: q.author.image || null,
}
: null,
knowledgePoints:
q.questionsToKnowledgePoints?.map((kp) => ({
id: kp.knowledgePoint.id,
name: kp.knowledgePoint.name,
})) ?? [],
})
const questionOptionsById = new Map<string, Question>()
for (const q of questionsData) questionOptionsById.set(q.id, toQuestionOption(q))
for (const q of selectedQuestionsData) questionOptionsById.set(q.id, toQuestionOption(q))
const questionOptions = Array.from(questionOptionsById.values())
const normalizeStructure = (nodes: unknown): ExamNode[] => {
const seen = new Set<string>()
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null
const normalize = (raw: unknown[]): ExamNode[] => {
return raw
.map((n) => {
if (!isRecord(n)) return null
const type = n.type
if (type !== "group" && type !== "question") return null
let id = typeof n.id === "string" && n.id.length > 0 ? n.id : createId()
while (seen.has(id)) id = createId()
seen.add(id)
if (type === "group") {
return {
id,
type: "group",
title: typeof n.title === "string" ? n.title : undefined,
children: normalize(Array.isArray(n.children) ? n.children : []),
} satisfies ExamNode
}
if (typeof n.questionId !== "string" || n.questionId.length === 0) return null
return {
id,
type: "question",
questionId: n.questionId,
score: typeof n.score === "number" ? n.score : undefined,
} satisfies ExamNode
})
.filter(Boolean) as ExamNode[]
}
if (!Array.isArray(nodes)) return []
return normalize(nodes)
}
let initialStructure: ExamNode[] = normalizeStructure(exam.structure)
if (initialStructure.length === 0 && initialSelected.length > 0) {
initialStructure = initialSelected.map(s => ({
id: createId(), // Generate stable ID on server
type: 'question',
initialStructure = initialSelected.map((s) => ({
id: createId(),
type: "question",
questionId: s.id,
score: s.score
score: s.score,
}))
}

View File

@@ -1,24 +1,137 @@
import { Suspense } from "react"
import Link from "next/link"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { ExamDataTable } from "@/modules/exams/components/exam-data-table"
import { examColumns } from "@/modules/exams/components/exam-columns"
import { ExamFilters } from "@/modules/exams/components/exam-filters"
import { getExams } from "@/modules/exams/data-access"
import { FileText, PlusCircle } from "lucide-react"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
async function ExamsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const q = getParam(params, "q")
const status = getParam(params, "status")
const difficulty = getParam(params, "difficulty")
const exams = await getExams({
q,
status,
difficulty,
})
const hasFilters = Boolean(q || (status && status !== "all") || (difficulty && difficulty !== "all"))
const counts = exams.reduce(
(acc, e) => {
acc.total += 1
if (e.status === "draft") acc.draft += 1
if (e.status === "published") acc.published += 1
if (e.status === "archived") acc.archived += 1
return acc
},
{ total: 0, draft: 0, published: 0, archived: 0 }
)
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-md border bg-card px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Showing</span>
<span className="text-sm font-medium">{counts.total}</span>
<span className="text-sm text-muted-foreground">exams</span>
<Badge variant="outline" className="ml-0 md:ml-2">
Draft {counts.draft}
</Badge>
<Badge variant="outline">Published {counts.published}</Badge>
<Badge variant="outline">Archived {counts.archived}</Badge>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/teacher/exams/grading">Go to Grading</Link>
</Button>
<Button asChild size="sm">
<Link href="/teacher/exams/create" className="inline-flex items-center gap-2">
<PlusCircle className="h-4 w-4" />
Create Exam
</Link>
</Button>
</div>
</div>
{exams.length === 0 ? (
<EmptyState
icon={FileText}
title={hasFilters ? "No exams match your filters" : "No exams yet"}
description={
hasFilters
? "Try clearing filters or adjusting keywords."
: "Create your first exam to start assigning and grading."
}
action={
hasFilters
? {
label: "Clear filters",
href: "/teacher/exams/all",
}
: {
label: "Create Exam",
href: "/teacher/exams/create",
}
}
className="h-[360px] bg-card"
/>
) : (
<ExamDataTable columns={examColumns} data={exams} />
)}
</div>
)
}
function ExamsResultsFallback() {
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-md border bg-card px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-4 w-[160px]" />
<Skeleton className="h-5 w-[92px]" />
<Skeleton className="h-5 w-[112px]" />
<Skeleton className="h-5 w-[106px]" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-[120px]" />
<Skeleton className="h-9 w-[132px]" />
</div>
</div>
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 6 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
)
}
export default async function AllExamsPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
searchParams: Promise<SearchParams>
}) {
const params = await searchParams
const exams = await getExams({
q: params.q as string,
status: params.status as string,
difficulty: params.difficulty as string,
})
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
@@ -26,21 +139,16 @@ export default async function AllExamsPage({
<h2 className="text-2xl font-bold tracking-tight">All Exams</h2>
<p className="text-muted-foreground">View and manage all your exams.</p>
</div>
<div className="flex items-center space-x-2">
<Button asChild>
<Link href="/teacher/exams/create">Create Exam</Link>
</Button>
</div>
</div>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ExamFilters />
</Suspense>
<div className="rounded-md border bg-card">
<ExamDataTable columns={examColumns} data={exams} />
</div>
<Suspense fallback={<ExamsResultsFallback />}>
<ExamsResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)