- Update attendance components and data-access for record management - Update audit log views, filters, and data-access - Update auth login and register forms - Update classes actions, components, and data-access (admin, schedule, stats) - Update course-plans actions, form, list, progress, and schema - Update exams actions, AI pipeline, preview components, and hooks - Update files components (icon, list, preview, upload) and data-access - Update homework assignment form, review view, auto-save hook, and stats-service - Update layout sidebar, header, and navigation config - Update proctoring actions, anti-cheat monitor, and data-access - Update questions actions, components (dialog, actions, columns, filters), and data-access - Update scheduling actions, auto-scheduler, components, and schema - Update textbooks constants and text-selection hook - Update users class-registration, import-dialog, data-access, and user-service
454 lines
17 KiB
TypeScript
454 lines
17 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { useForm, type SubmitHandler } from "react-hook-form"
|
|
import { zodResolver } from "@hookform/resolvers/zod"
|
|
import { z } from "zod"
|
|
import { Plus, Trash2, GripVertical } from "lucide-react"
|
|
import { useRouter } from "next/navigation"
|
|
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/shared/components/ui/dialog"
|
|
import {
|
|
Form,
|
|
FormLabel,
|
|
} from "@/shared/components/ui/form"
|
|
import { Input } from "@/shared/components/ui/input"
|
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
import { SelectField } from "@/shared/components/form-fields/select-field"
|
|
import { TextareaField } from "@/shared/components/form-fields/textarea-field"
|
|
import { BaseQuestionSchema } from "../schema"
|
|
import { createQuestionAction, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
|
|
import { toast } from "sonner"
|
|
import { Question } from "../types"
|
|
import { useActionQuery } from "@/shared/hooks/use-action-query"
|
|
|
|
const QuestionFormSchema = BaseQuestionSchema.extend({
|
|
difficulty: z.number().min(1).max(5),
|
|
content: z.string().min(1, "Question content is required"),
|
|
options: z
|
|
.array(
|
|
z.object({
|
|
label: z.string(),
|
|
value: z.string(),
|
|
isCorrect: z.boolean().default(false),
|
|
})
|
|
)
|
|
.optional(),
|
|
})
|
|
|
|
type QuestionFormValues = z.input<typeof QuestionFormSchema>
|
|
|
|
interface CreateQuestionDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
initialData?: Question | null
|
|
defaultKnowledgePointIds?: string[]
|
|
defaultContent?: string
|
|
defaultType?: "single_choice" | "multiple_choice" | "text" | "judgment" | "composite"
|
|
}
|
|
|
|
function getInitialTextFromContent(content: unknown) {
|
|
if (typeof content === "string") return content
|
|
if (content && typeof content === "object") {
|
|
const text = (content as { text?: unknown }).text
|
|
if (typeof text === "string") return text
|
|
}
|
|
if (content == null) return ""
|
|
return JSON.stringify(content)
|
|
}
|
|
|
|
function getInitialOptionsFromContent(content: unknown) {
|
|
if (!content || typeof content !== "object") return undefined
|
|
const rawOptions = (content as { options?: unknown }).options
|
|
if (!Array.isArray(rawOptions)) return undefined
|
|
|
|
const mapped = rawOptions
|
|
.map((opt) => {
|
|
if (!opt || typeof opt !== "object") return null
|
|
const id =
|
|
(opt as { id?: unknown; value?: unknown }).id ?? (opt as { value?: unknown }).value
|
|
const text =
|
|
(opt as { text?: unknown; label?: unknown }).text ??
|
|
(opt as { label?: unknown }).label
|
|
const isCorrect = (opt as { isCorrect?: unknown }).isCorrect
|
|
return {
|
|
value: typeof id === "string" ? id : "",
|
|
label: typeof text === "string" ? text : "",
|
|
isCorrect: typeof isCorrect === "boolean" ? isCorrect : false,
|
|
}
|
|
})
|
|
.filter((v): v is NonNullable<typeof v> => Boolean(v && v.value && v.label))
|
|
|
|
return mapped.length > 0 ? mapped : undefined
|
|
}
|
|
|
|
export function CreateQuestionDialog({
|
|
open,
|
|
onOpenChange,
|
|
initialData,
|
|
defaultKnowledgePointIds = [],
|
|
defaultContent = "",
|
|
defaultType = "single_choice"
|
|
}: CreateQuestionDialogProps) {
|
|
const router = useRouter()
|
|
const [isPending, setIsPending] = useState(false)
|
|
const isEdit = !!initialData
|
|
const [knowledgePointQuery, setKnowledgePointQuery] = useState("")
|
|
const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState<string[]>([])
|
|
|
|
const { data: knowledgePointOptionsData, loading: isLoadingKnowledgePoints } = useActionQuery(
|
|
() => getKnowledgePointOptionsAction(),
|
|
{ deps: [open], enabled: open, errorMessage: "Failed to load knowledge points" }
|
|
)
|
|
const knowledgePointOptions = knowledgePointOptionsData ?? []
|
|
|
|
const form = useForm<QuestionFormValues>({
|
|
resolver: zodResolver(QuestionFormSchema),
|
|
defaultValues: {
|
|
type: initialData?.type || defaultType,
|
|
difficulty: initialData?.difficulty || 1,
|
|
content: getInitialTextFromContent(initialData?.content) || defaultContent,
|
|
options:
|
|
getInitialOptionsFromContent(initialData?.content) ?? [
|
|
{ label: "Option A", value: "A", isCorrect: true },
|
|
{ label: "Option B", value: "B", isCorrect: false },
|
|
],
|
|
},
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (initialData) {
|
|
form.reset({
|
|
type: initialData.type,
|
|
difficulty: initialData.difficulty,
|
|
content: getInitialTextFromContent(initialData.content),
|
|
options:
|
|
getInitialOptionsFromContent(initialData.content) ?? [
|
|
{ label: "Option A", value: "A", isCorrect: true },
|
|
{ label: "Option B", value: "B", isCorrect: false },
|
|
],
|
|
})
|
|
} else {
|
|
form.reset({
|
|
type: defaultType,
|
|
difficulty: 1,
|
|
content: defaultContent,
|
|
options: [
|
|
{ label: "Option A", value: "A", isCorrect: true },
|
|
{ label: "Option B", value: "B", isCorrect: false },
|
|
],
|
|
})
|
|
}
|
|
}, [initialData, form, open, defaultContent, defaultType])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
if (initialData) {
|
|
const nextIds = initialData.knowledgePoints.map((kp) => kp.id)
|
|
setSelectedKnowledgePointIds((prev) => {
|
|
if (prev.length === nextIds.length && prev.every((id, idx) => id === nextIds[idx])) {
|
|
return prev
|
|
}
|
|
return nextIds
|
|
})
|
|
return
|
|
}
|
|
setSelectedKnowledgePointIds((prev) => {
|
|
if (
|
|
prev.length === defaultKnowledgePointIds.length &&
|
|
prev.every((id, idx) => id === defaultKnowledgePointIds[idx])
|
|
) {
|
|
return prev
|
|
}
|
|
return defaultKnowledgePointIds
|
|
})
|
|
}, [open, initialData, defaultKnowledgePointIds])
|
|
|
|
const questionType = form.watch("type")
|
|
const filteredKnowledgePoints = knowledgePointOptions.filter((kp) => {
|
|
const query = knowledgePointQuery.trim().toLowerCase()
|
|
if (!query) return true
|
|
const fullLabel = [
|
|
kp.textbookTitle,
|
|
kp.chapterTitle,
|
|
kp.name,
|
|
kp.subject,
|
|
kp.grade,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase()
|
|
return fullLabel.includes(query)
|
|
})
|
|
|
|
const buildContent = (data: QuestionFormValues) => {
|
|
const text = data.content.trim()
|
|
if (data.type === "single_choice" || data.type === "multiple_choice") {
|
|
const rawOptions = (data.options ?? []).filter((o) => o.label.trim().length > 0)
|
|
const base = rawOptions.map((o) => ({
|
|
id: o.value,
|
|
text: o.label.trim(),
|
|
isCorrect: o.isCorrect,
|
|
}))
|
|
|
|
if (base.length === 0) return { text }
|
|
|
|
if (data.type === "single_choice") {
|
|
let selectedIndex = base.findIndex((o) => o.isCorrect)
|
|
if (selectedIndex === -1) selectedIndex = 0
|
|
return {
|
|
text,
|
|
options: base.map((o, idx) => ({ ...o, isCorrect: idx === selectedIndex })),
|
|
}
|
|
}
|
|
|
|
const hasCorrect = base.some((o) => o.isCorrect)
|
|
const options = hasCorrect ? base : [{ ...base[0], isCorrect: true }, ...base.slice(1)]
|
|
return { text, options }
|
|
}
|
|
|
|
return { text }
|
|
}
|
|
|
|
const onSubmit: SubmitHandler<QuestionFormValues> = async (data) => {
|
|
setIsPending(true)
|
|
try {
|
|
if (isEdit && !initialData?.id) {
|
|
toast.error("Missing question id")
|
|
return
|
|
}
|
|
const payload = {
|
|
...(isEdit && initialData ? { id: initialData.id } : {}),
|
|
type: data.type,
|
|
difficulty: data.difficulty,
|
|
content: buildContent(data),
|
|
knowledgePointIds: selectedKnowledgePointIds,
|
|
}
|
|
const fd = new FormData()
|
|
fd.set("json", JSON.stringify(payload))
|
|
const res = isEdit
|
|
? await updateQuestionAction(undefined, fd)
|
|
: await createQuestionAction(undefined, fd)
|
|
if (res.success) {
|
|
toast.success(isEdit ? "Updated question" : "Created question")
|
|
onOpenChange(false)
|
|
router.refresh()
|
|
if (!isEdit) {
|
|
form.reset()
|
|
}
|
|
} else {
|
|
toast.error(res.message || "Operation failed")
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to submit question", e)
|
|
toast.error("Unexpected error")
|
|
} finally {
|
|
setIsPending(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{isEdit ? "Edit Question" : "Create New Question"}</DialogTitle>
|
|
<DialogDescription>
|
|
{isEdit ? "Update question details." : "Add a new question to the bank. Fill in the details below."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<SelectField
|
|
control={form.control}
|
|
name="type"
|
|
label="Question Type"
|
|
placeholder="Select type"
|
|
options={[
|
|
{ value: "single_choice", label: "Single Choice" },
|
|
{ value: "multiple_choice", label: "Multiple Choice" },
|
|
{ value: "judgment", label: "True/False" },
|
|
{ value: "text", label: "Short Answer" },
|
|
{ value: "composite", label: "Composite" },
|
|
]}
|
|
/>
|
|
<SelectField
|
|
control={form.control}
|
|
name="difficulty"
|
|
label="Difficulty (1-5)"
|
|
placeholder="Select difficulty"
|
|
toSelectValue={(v) => String(v)}
|
|
fromSelectValue={(val) => {
|
|
const n = parseInt(val, 10)
|
|
return Number.isFinite(n) ? n : 1
|
|
}}
|
|
options={[1, 2, 3, 4, 5].map((level) => ({
|
|
value: String(level),
|
|
label: `${level} - ${level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}`,
|
|
}))}
|
|
/>
|
|
</div>
|
|
|
|
<TextareaField
|
|
control={form.control}
|
|
name="content"
|
|
label="Question Content"
|
|
placeholder="Enter the question text here..."
|
|
description="Supports basic text. Rich text editor coming soon."
|
|
textareaClassName="min-h-[100px]"
|
|
/>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<FormLabel>Knowledge Points</FormLabel>
|
|
<span className="text-xs text-muted-foreground">
|
|
{selectedKnowledgePointIds.length > 0 ? `${selectedKnowledgePointIds.length} selected` : "Optional"}
|
|
</span>
|
|
</div>
|
|
<Input
|
|
placeholder="Search knowledge points..."
|
|
value={knowledgePointQuery}
|
|
onChange={(e) => setKnowledgePointQuery(e.target.value)}
|
|
/>
|
|
<div className="rounded-md border">
|
|
<ScrollArea className="h-48">
|
|
{isLoadingKnowledgePoints ? (
|
|
<div className="p-3 text-sm text-muted-foreground">Loading...</div>
|
|
) : filteredKnowledgePoints.length === 0 ? (
|
|
<div className="p-3 text-sm text-muted-foreground">No knowledge points found.</div>
|
|
) : (
|
|
<div className="space-y-1 p-2">
|
|
{filteredKnowledgePoints.map((kp) => {
|
|
const labelParts = [
|
|
kp.textbookTitle,
|
|
kp.chapterTitle,
|
|
kp.name,
|
|
].filter(Boolean)
|
|
const label = labelParts.join(" · ")
|
|
return (
|
|
<label key={kp.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
|
|
<Checkbox
|
|
checked={selectedKnowledgePointIds.includes(kp.id)}
|
|
onCheckedChange={(checked) => {
|
|
const isChecked = checked === true
|
|
setSelectedKnowledgePointIds((prev) => {
|
|
if (isChecked) {
|
|
if (prev.includes(kp.id)) return prev
|
|
return [...prev, kp.id]
|
|
}
|
|
return prev.filter((id) => id !== kp.id)
|
|
})
|
|
}}
|
|
/>
|
|
<span className="text-sm">{label}</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|
|
|
|
{(questionType === "single_choice" || questionType === "multiple_choice") && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<FormLabel>Options</FormLabel>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
const currentOptions = form.getValues("options") || []
|
|
const nextIndex = currentOptions.length
|
|
const nextChar = nextIndex < 26 ? String.fromCharCode(65 + nextIndex) : String(nextIndex + 1)
|
|
form.setValue("options", [
|
|
...currentOptions,
|
|
{
|
|
label: `Option ${nextChar}`,
|
|
value: nextChar,
|
|
isCorrect: false,
|
|
},
|
|
])
|
|
}}
|
|
>
|
|
<Plus className="mr-2 h-3 w-3" /> Add Option
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{form.watch("options")?.map((option, index) => (
|
|
<div key={option.value || `option-${index}`} className="flex items-center gap-2">
|
|
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
|
|
<GripVertical className="h-4 w-4" />
|
|
</div>
|
|
<Checkbox
|
|
checked={option.isCorrect}
|
|
onCheckedChange={(checked) => {
|
|
const next = [...(form.getValues("options") || [])]
|
|
if (!next[index]) return
|
|
|
|
const isChecked = checked === true
|
|
if (questionType === "single_choice" && isChecked) {
|
|
for (let i = 0; i < next.length; i++) next[i].isCorrect = i === index
|
|
} else {
|
|
next[index].isCorrect = isChecked
|
|
}
|
|
form.setValue("options", next)
|
|
}}
|
|
aria-label="Mark correct"
|
|
/>
|
|
<Input
|
|
value={option.label}
|
|
onChange={(e) => {
|
|
const next = [...(form.getValues("options") || [])]
|
|
if (!next[index]) return
|
|
next[index].label = e.target.value
|
|
form.setValue("options", next)
|
|
}}
|
|
placeholder={`Option ${index + 1}`}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-destructive hover:text-destructive/90"
|
|
onClick={() => {
|
|
const next = [...(form.getValues("options") || [])]
|
|
next.splice(index, 1)
|
|
form.setValue("options", next)
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={isPending}>
|
|
{isPending ? (isEdit ? "Updating..." : "Creating...") : (isEdit ? "Update Question" : "Create Question")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|