Module Update
Some checks failed
CI / build-and-test (push) Failing after 1m31s
CI / deploy (push) Has been skipped

This commit is contained in:
SpecialX
2025-12-30 14:42:30 +08:00
parent f1797265b2
commit e7c902e8e1
148 changed files with 19317 additions and 113 deletions

View File

@@ -0,0 +1,343 @@
"use client"
import { useMemo, useState } from "react"
import { useFormStatus } from "react-dom"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Search } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator"
import { Badge } from "@/shared/components/ui/badge"
import type { Question } from "@/modules/questions/types"
import { updateExamAction } from "@/modules/exams/actions"
import { StructureEditor } from "./assembly/structure-editor"
import { QuestionBankList } from "./assembly/question-bank-list"
import type { ExamNode } from "./assembly/selected-question-list"
import { createId } from "@paralleldrive/cuid2"
type ExamAssemblyProps = {
examId: string
title: string
subject: string
grade: string
difficulty: number
totalScore: number
durationMin: number
initialSelected?: Array<{ id: string; score: number }>
initialStructure?: ExamNode[] // New prop
questionOptions: Question[]
}
function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Saving..." : label}
</Button>
)
}
export function ExamAssembly(props: ExamAssemblyProps) {
const router = useRouter()
const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<string>("all")
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
// Initialize structure state
const [structure, setStructure] = useState<ExamNode[]>(() => {
// Hydrate structure with full question objects
const hydrate = (nodes: ExamNode[]): ExamNode[] => {
return nodes.map(node => {
if (node.type === 'question') {
const q = props.questionOptions.find(opt => opt.id === node.questionId)
return { ...node, question: q }
}
if (node.type === 'group') {
return { ...node, children: hydrate(node.children || []) }
}
return node
})
}
// Use initialStructure if provided (Server generated or DB stored)
if (props.initialStructure && props.initialStructure.length > 0) {
return hydrate(props.initialStructure)
}
// Fallback logic removed as Server Component handles initial migration
return []
})
const filteredQuestions = useMemo(() => {
let list: Question[] = [...props.questionOptions]
if (search) {
const lower = search.toLowerCase()
list = list.filter(q => {
const content = q.content as { text?: string }
return content.text?.toLowerCase().includes(lower)
})
}
if (typeFilter !== "all") {
list = list.filter((q) => q.type === (typeFilter as Question["type"]))
}
if (difficultyFilter !== "all") {
const d = parseInt(difficultyFilter)
list = list.filter((q) => q.difficulty === d)
}
return list
}, [search, typeFilter, difficultyFilter, props.questionOptions])
// Recursively calculate total score
const assignedTotal = useMemo(() => {
const calc = (nodes: ExamNode[]): number => {
return nodes.reduce((sum, node) => {
if (node.type === 'question') return sum + (node.score || 0)
if (node.type === 'group') return sum + calc(node.children || [])
return sum
}, 0)
}
return calc(structure)
}, [structure])
const progress = Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
const handleAdd = (question: Question) => {
setStructure(prev => [
...prev,
{
id: createId(),
type: 'question',
questionId: question.id,
score: 10,
question
}
])
}
const handleAddGroup = () => {
setStructure(prev => [
...prev,
{
id: createId(),
type: 'group',
title: 'New Section',
children: []
}
])
}
const handleRemove = (id: string) => {
const removeRecursive = (nodes: ExamNode[]): ExamNode[] => {
return nodes.filter(n => n.id !== id).map(n => {
if (n.type === 'group') {
return { ...n, children: removeRecursive(n.children || []) }
}
return n
})
}
setStructure(prev => removeRecursive(prev))
}
const handleScoreChange = (id: string, score: number) => {
const updateRecursive = (nodes: ExamNode[]): ExamNode[] => {
return nodes.map(n => {
if (n.id === id) return { ...n, score }
if (n.type === 'group') return { ...n, children: updateRecursive(n.children || []) }
return n
})
}
setStructure(prev => updateRecursive(prev))
}
const handleGroupTitleChange = (id: string, title: string) => {
const updateRecursive = (nodes: ExamNode[]): ExamNode[] => {
return nodes.map(n => {
if (n.id === id) return { ...n, title }
if (n.type === 'group') return { ...n, children: updateRecursive(n.children || []) }
return n
})
}
setStructure(prev => updateRecursive(prev))
}
// Helper to extract flat list for DB examQuestions table
const getFlatQuestions = () => {
const list: Array<{ id: string; score: number }> = []
const traverse = (nodes: ExamNode[]) => {
nodes.forEach(n => {
if (n.type === 'question' && n.questionId) {
list.push({ id: n.questionId, score: n.score || 0 })
}
if (n.type === 'group') {
traverse(n.children || [])
}
})
}
traverse(structure)
return list
}
// Helper to strip runtime question objects for DB structure storage
const getCleanStructure = () => {
const clean = (nodes: ExamNode[]): any[] => {
return nodes.map(n => {
const { question, ...rest } = n
if (n.type === 'group') {
return { ...rest, children: clean(n.children || []) }
}
return rest
})
}
return clean(structure)
}
const handleSave = async (formData: FormData) => {
formData.set("examId", props.examId)
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
formData.set("structureJson", JSON.stringify(getCleanStructure()))
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Saved draft")
} else {
toast.error(result.message || "Save failed")
}
}
const handlePublish = async (formData: FormData) => {
formData.set("examId", props.examId)
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
formData.set("structureJson", JSON.stringify(getCleanStructure()))
formData.set("status", "published")
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Published exam")
router.push("/teacher/exams/all")
} else {
toast.error(result.message || "Publish failed")
}
}
return (
<div className="grid h-[calc(100vh-12rem)] gap-6 lg:grid-cols-5">
{/* Left: Preview (3 cols) */}
<Card className="lg:col-span-3 flex flex-col overflow-hidden border-2 border-primary/10">
<CardHeader className="bg-muted/30 pb-4">
<div className="flex items-center justify-between">
<CardTitle>Exam Structure</CardTitle>
<div className="flex items-center gap-4 text-sm">
<div className="flex flex-col items-end">
<span className="font-medium">{assignedTotal} / {props.totalScore}</span>
<span className="text-xs text-muted-foreground">Total Score</span>
</div>
<div className="h-2 w-24 rounded-full bg-secondary">
<div
className={`h-full rounded-full transition-all ${
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
}`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
</CardHeader>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
<div className="grid grid-cols-3 gap-4 text-sm text-muted-foreground bg-muted/20 p-3 rounded-md">
<div><span className="font-medium text-foreground">{props.subject}</span></div>
<div><span className="font-medium text-foreground">{props.grade}</span></div>
<div>Duration: <span className="font-medium text-foreground">{props.durationMin} min</span></div>
</div>
<StructureEditor
items={structure}
onChange={setStructure}
onScoreChange={handleScoreChange}
onGroupTitleChange={handleGroupTitleChange}
onRemove={handleRemove}
onAddGroup={handleAddGroup}
/>
</div>
</ScrollArea>
<div className="border-t p-4 bg-muted/30 flex gap-3 justify-end">
<form action={handleSave} className="flex-1">
<SubmitButton label="Save Draft" />
</form>
<form action={handlePublish} className="flex-1">
<SubmitButton label="Publish Exam" />
</form>
</div>
</Card>
{/* Right: Question Bank (2 cols) */}
<Card className="lg:col-span-2 flex flex-col overflow-hidden">
<CardHeader className="pb-3 space-y-3">
<CardTitle className="text-base">Question Bank</CardTitle>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search questions..."
className="pl-8"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="flex-1 h-8 text-xs"><SelectValue placeholder="Type" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="single_choice">Single Choice</SelectItem>
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
<SelectItem value="judgment">True/False</SelectItem>
<SelectItem value="text">Short Answer</SelectItem>
</SelectContent>
</Select>
<Select value={difficultyFilter} onValueChange={setDifficultyFilter}>
<SelectTrigger className="w-[80px] h-8 text-xs"><SelectValue placeholder="Diff" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="1">Lvl 1</SelectItem>
<SelectItem value="2">Lvl 2</SelectItem>
<SelectItem value="3">Lvl 3</SelectItem>
<SelectItem value="4">Lvl 4</SelectItem>
<SelectItem value="5">Lvl 5</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<Separator />
<ScrollArea className="flex-1 p-4 bg-muted/10">
<QuestionBankList
questions={filteredQuestions}
onAdd={handleAdd}
isAdded={(id) => {
// Check if question is added anywhere in the structure
const isAddedRecursive = (nodes: ExamNode[]): boolean => {
return nodes.some(n => {
if (n.type === 'question' && n.questionId === id) return true
if (n.type === 'group' && n.children) return isAddedRecursive(n.children)
return false
})
}
return isAddedRecursive(structure)
}}
/>
</ScrollArea>
</Card>
</div>
)
}