feat(teacher): 题库模块(QuestionBank)

工作内容

- 新增 /teacher/questions 页面,支持 Suspense/空状态/加载态

- 题库 CRUD Server Actions:创建/更新/递归删除子题,变更后 revalidatePath

- getQuestions 支持 q/type/difficulty/knowledgePointId 筛选与分页返回 meta

- UI:表格列/筛选器/创建编辑弹窗,content JSON 兼容组卷

- 更新中文设计文档:docs/design/004_question_bank_implementation.md
This commit is contained in:
SpecialX
2025-12-30 19:04:22 +08:00
parent f7ff018490
commit f8e39f518d
12 changed files with 680 additions and 325 deletions

View File

@@ -2,18 +2,18 @@
import { db } from "@/shared/db";
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { CreateQuestionInput, CreateQuestionSchema } from "./schema";
import { CreateQuestionSchema } from "./schema";
import type { CreateQuestionInput } from "./schema";
import { ActionState } from "@/shared/types/action-state";
import { revalidatePath } from "next/cache";
import { createId } from "@paralleldrive/cuid2";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
// --- Mock Auth Helper (Replace with actual Auth.js call) ---
async function getCurrentUser() {
// In production: const session = await auth(); return session?.user;
// Mocking a teacher user for this demonstration
return {
id: "user_teacher_123",
role: "teacher", // or "admin"
role: "teacher",
};
}
@@ -25,17 +25,14 @@ async function ensureTeacher() {
return user;
}
// --- Recursive Insert Helper ---
// We pass 'tx' to ensure all operations run within the same transaction
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
async function insertQuestionWithRelations(
tx: Tx,
input: CreateQuestionInput,
input: z.infer<typeof CreateQuestionSchema>,
authorId: string,
parentId: string | null = null
) {
// We generate ID explicitly here.
const newQuestionId = createId();
await tx.insert(questions).values({
@@ -47,7 +44,6 @@ async function insertQuestionWithRelations(
parentId: parentId,
});
// 2. Link Knowledge Points
if (input.knowledgePointIds && input.knowledgePointIds.length > 0) {
await tx.insert(questionsToKnowledgePoints).values(
input.knowledgePointIds.map((kpId) => ({
@@ -57,7 +53,6 @@ async function insertQuestionWithRelations(
);
}
// 3. Handle Sub-Questions (Recursion)
if (input.subQuestions && input.subQuestions.length > 0) {
for (const subQ of input.subQuestions) {
await insertQuestionWithRelations(tx, subQ, authorId, newQuestionId);
@@ -67,25 +62,16 @@ async function insertQuestionWithRelations(
return newQuestionId;
}
// --- Main Server Action ---
export async function createNestedQuestion(
prevState: ActionState<string> | undefined,
formData: FormData | CreateQuestionInput // Support both FormData and JSON input
formData: FormData | CreateQuestionInput
): Promise<ActionState<string>> {
try {
// 1. Auth Check
const user = await ensureTeacher();
// 2. Parse Input
// If formData is actual FormData, we need to convert it.
// For complex nested structures, frontend usually sends JSON string or pure JSON object if using `useServerAction` with arguments.
// Here we assume the client might send a raw object (if using direct function call) or we parse FormData.
let rawInput: unknown = formData;
if (formData instanceof FormData) {
// Parsing complex nested JSON from FormData is messy.
// We assume one field 'data' contains the JSON, or we expect direct object usage (common in modern Next.js RPC).
const jsonString = formData.get("json");
if (typeof jsonString === "string") {
rawInput = JSON.parse(jsonString) as unknown;
@@ -106,13 +92,11 @@ export async function createNestedQuestion(
const input = validatedFields.data;
// 3. Database Transaction
await db.transaction(async (tx) => {
await insertQuestionWithRelations(tx, input, user.id, null);
});
// 4. Revalidate Cache
revalidatePath("/questions");
revalidatePath("/teacher/questions");
return {
success: true,
@@ -122,10 +106,7 @@ export async function createNestedQuestion(
} catch (error) {
console.error("Failed to create question:", error);
// Drizzle/DB Error Handling (Generic)
if (error instanceof Error) {
// Check for specific DB errors (constraints, etc.)
// e.g., if (error.message.includes("Duplicate entry")) ...
return {
success: false,
message: error.message || "Database error occurred",
@@ -138,3 +119,122 @@ export async function createNestedQuestion(
};
}
}
const UpdateQuestionSchema = z.object({
id: z.string().min(1),
type: z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]),
difficulty: z.number().min(1).max(5),
content: z.any(),
knowledgePointIds: z.array(z.string()).optional(),
});
export async function updateQuestionAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureTeacher();
const canEditAll = user.role === "admin";
const jsonString = formData.get("json");
if (typeof jsonString !== "string") {
return { success: false, message: "Invalid submission format. Expected JSON." };
}
const parsed = UpdateQuestionSchema.safeParse(JSON.parse(jsonString));
if (!parsed.success) {
return {
success: false,
message: "Validation failed",
errors: parsed.error.flatten().fieldErrors,
};
}
const input = parsed.data;
await db.transaction(async (tx) => {
await tx
.update(questions)
.set({
type: input.type,
difficulty: input.difficulty,
content: input.content,
updatedAt: new Date(),
})
.where(canEditAll ? eq(questions.id, input.id) : and(eq(questions.id, input.id), eq(questions.authorId, user.id)));
await tx
.delete(questionsToKnowledgePoints)
.where(eq(questionsToKnowledgePoints.questionId, input.id));
if (input.knowledgePointIds && input.knowledgePointIds.length > 0) {
await tx.insert(questionsToKnowledgePoints).values(
input.knowledgePointIds.map((kpId) => ({
questionId: input.id,
knowledgePointId: kpId,
}))
);
}
});
revalidatePath("/teacher/questions");
return { success: true, message: "Question updated successfully" };
} catch (error) {
if (error instanceof Error) {
return { success: false, message: error.message };
}
return { success: false, message: "An unexpected error occurred" };
}
}
async function deleteQuestionRecursive(tx: Tx, questionId: string) {
const children = await tx
.select({ id: questions.id })
.from(questions)
.where(eq(questions.parentId, questionId));
for (const child of children) {
await deleteQuestionRecursive(tx, child.id);
}
await tx.delete(questions).where(eq(questions.id, questionId));
}
export async function deleteQuestionAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureTeacher();
const canEditAll = user.role === "admin";
const id = formData.get("id");
if (typeof id !== "string" || id.length === 0) {
return { success: false, message: "Missing question id" };
}
await db.transaction(async (tx) => {
const [owned] = await tx
.select({ id: questions.id })
.from(questions)
.where(canEditAll ? eq(questions.id, id) : and(eq(questions.id, id), eq(questions.authorId, user.id)))
.limit(1);
if (!owned) {
throw new Error("Unauthorized");
}
await deleteQuestionRecursive(tx, id);
});
revalidatePath("/teacher/questions");
return { success: true, message: "Question deleted successfully" };
} catch (error) {
if (error instanceof Error) {
return { success: false, message: error.message };
}
return { success: false, message: "An unexpected error occurred" };
}
}

View File

@@ -5,8 +5,10 @@ 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,
@@ -34,19 +36,22 @@ import {
} from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { BaseQuestionSchema } from "../schema"
import { createNestedQuestion } from "../actions"
import { createNestedQuestion, updateQuestionAction } from "../actions"
import { toast } from "sonner"
import { Question } from "../types"
// Extend schema for form usage (e.g. handling options for choice questions)
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(),
options: z
.array(
z.object({
label: z.string(),
value: z.string(),
isCorrect: z.boolean().default(false),
})
)
.optional(),
})
type QuestionFormValues = z.input<typeof QuestionFormSchema>
@@ -57,7 +62,43 @@ interface CreateQuestionDialogProps {
initialData?: Question | null
}
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 }: CreateQuestionDialogProps) {
const router = useRouter()
const [isPending, setIsPending] = useState(false)
const isEdit = !!initialData
@@ -66,63 +107,101 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
defaultValues: {
type: initialData?.type || "single_choice",
difficulty: initialData?.difficulty || 1,
content: (typeof initialData?.content === 'string' ? initialData.content : "") || "",
options: [
{ label: "Option A", value: "A", isCorrect: true },
{ label: "Option B", value: "B", isCorrect: false },
],
content: getInitialTextFromContent(initialData?.content),
options:
getInitialOptionsFromContent(initialData?.content) ?? [
{ label: "Option A", value: "A", isCorrect: true },
{ label: "Option B", value: "B", isCorrect: false },
],
},
})
// Reset form when initialData changes
useEffect(() => {
if (initialData) {
form.reset({
type: initialData.type,
difficulty: initialData.difficulty,
content: typeof initialData.content === 'string' ? initialData.content : JSON.stringify(initialData.content),
options: [
{ label: "Option A", value: "A", isCorrect: true },
{ label: "Option B", value: "B", isCorrect: false },
]
})
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: "single_choice",
difficulty: 1,
content: "",
options: [
{ label: "Option A", value: "A", isCorrect: true },
{ label: "Option B", value: "B", isCorrect: false },
]
})
form.reset({
type: "single_choice",
difficulty: 1,
content: "",
options: [
{ label: "Option A", value: "A", isCorrect: true },
{ label: "Option B", value: "B", isCorrect: false },
],
})
}
}, [initialData, form])
}, [initialData, form, open])
const questionType = form.watch("type")
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: data.content,
content: buildContent(data),
knowledgePointIds: [],
}
const fd = new FormData()
fd.set("json", JSON.stringify(payload))
const res = await createNestedQuestion(undefined, fd)
const res = isEdit
? await updateQuestionAction(undefined, fd)
: await createNestedQuestion(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 (error) {
} catch {
toast.error("Unexpected error")
} finally {
setIsPending(false)
@@ -148,7 +227,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
render={({ field }) => (
<FormItem>
<FormLabel>Question Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select type" />
@@ -159,6 +238,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
<SelectItem value="judgment">True/False</SelectItem>
<SelectItem value="text">Short Answer</SelectItem>
<SelectItem value="composite">Composite</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -173,8 +253,8 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
<FormItem>
<FormLabel>Difficulty (1-5)</FormLabel>
<Select
value={String(field.value)}
onValueChange={(val) => field.onChange(parseInt(val))}
defaultValue={String(field.value)}
>
<FormControl>
<SelectTrigger>
@@ -219,53 +299,76 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
{(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") || [];
form.setValue("options", [
...currentOptions,
{ label: `Option ${String.fromCharCode(65 + currentOptions.length)}`, value: String.fromCharCode(65 + currentOptions.length), isCorrect: false }
]);
}}
>
<Plus className="mr-2 h-3 w-3" /> Add Option
</Button>
<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={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>
<Input
value={option.label}
onChange={(e) => {
const newOptions = [...(form.getValues("options") || [])];
newOptions[index].label = e.target.value;
form.setValue("options", newOptions);
}}
placeholder={`Option ${index + 1}`}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive/90"
onClick={() => {
const newOptions = [...(form.getValues("options") || [])];
newOptions.splice(index, 1);
form.setValue("options", newOptions);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{form.watch("options")?.map((option, index) => (
<div key={option.value || 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>
)}
@@ -275,7 +378,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Question"}
{isPending ? (isEdit ? "Updating..." : "Creating...") : (isEdit ? "Update Question" : "Create Question")}
</Button>
</DialogFooter>
</form>

View File

@@ -2,6 +2,7 @@
import { useState } from "react"
import { MoreHorizontal, Pencil, Trash, Eye, Copy } from "lucide-react"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
@@ -31,6 +32,7 @@ import {
} from "@/shared/components/ui/dialog"
import { Question } from "../types"
import { deleteQuestionAction } from "../actions"
import { CreateQuestionDialog } from "./create-question-dialog"
import { toast } from "sonner"
@@ -39,6 +41,7 @@ interface QuestionActionsProps {
}
export function QuestionActions({ question }: QuestionActionsProps) {
const router = useRouter()
const [showEditDialog, setShowEditDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showViewDialog, setShowViewDialog] = useState(false)
@@ -52,13 +55,17 @@ export function QuestionActions({ question }: QuestionActionsProps) {
const handleDelete = async () => {
setIsDeleting(true)
try {
// Simulate API call
console.log("Deleting question:", question.id)
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success("Question deleted successfully")
setShowDeleteDialog(false)
} catch (error) {
console.error(error)
const fd = new FormData()
fd.set("id", question.id)
const res = await deleteQuestionAction(undefined, fd)
if (res.success) {
toast.success("Question deleted successfully")
setShowDeleteDialog(false)
router.refresh()
} else {
toast.error(res.message || "Failed to delete question")
}
} catch {
toast.error("Failed to delete question")
} finally {
setIsDeleting(false)
@@ -95,14 +102,12 @@ export function QuestionActions({ question }: QuestionActionsProps) {
</DropdownMenuContent>
</DropdownMenu>
{/* Edit Dialog */}
<CreateQuestionDialog
open={showEditDialog}
onOpenChange={setShowEditDialog}
initialData={question}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -128,7 +133,6 @@ export function QuestionActions({ question }: QuestionActionsProps) {
</AlertDialogContent>
</AlertDialog>
{/* View Details Dialog (Simple Read-only View) */}
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
<DialogContent>
<DialogHeader>
@@ -138,7 +142,7 @@ export function QuestionActions({ question }: QuestionActionsProps) {
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Type:</span>
<span className="col-span-3 capitalize">{question.type.replace('_', ' ')}</span>
<span className="col-span-3 capitalize">{question.type.replaceAll("_", " ")}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Difficulty:</span>
@@ -147,17 +151,17 @@ export function QuestionActions({ question }: QuestionActionsProps) {
<div className="grid grid-cols-4 items-start gap-4">
<span className="font-medium pt-1">Content:</span>
<div className="col-span-3 rounded-md bg-muted p-2 text-sm">
{typeof question.content === 'string' ? question.content : JSON.stringify(question.content, null, 2)}
{typeof question.content === "string"
? question.content
: JSON.stringify(question.content, null, 2)}
</div>
</div>
{/* Show Author if exists */}
{question.author && (
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Author:</span>
<span className="col-span-3">{question.author.name || "Unknown"}</span>
</div>
)}
{/* Show Knowledge Points */}
{question.knowledgePoints && question.knowledgePoints.length > 0 && (
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Tags:</span>

View File

@@ -5,34 +5,38 @@ import { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/shared/components/ui/badge"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Question, QuestionType } from "../types"
import { cn } from "@/shared/lib/utils"
import { QuestionActions } from "./question-actions"
// Helper for Type Colors
const getTypeColor = (type: QuestionType) => {
switch (type) {
case "single_choice":
return "default"; // Primary
return "default"
case "multiple_choice":
return "secondary";
return "secondary"
case "judgment":
return "outline";
return "outline"
case "text":
return "secondary"; // Changed from 'accent' which might not be a valid badge variant key in standard shadcn, usually it's default, secondary, destructive, outline.
return "secondary"
default:
return "secondary";
return "secondary"
}
}
const getTypeLabel = (type: QuestionType) => {
switch (type) {
case "single_choice": return "Single Choice";
case "multiple_choice": return "Multiple Choice";
case "judgment": return "True/False";
case "text": return "Short Answer";
case "composite": return "Composite";
default: return type;
}
switch (type) {
case "single_choice":
return "Single Choice"
case "multiple_choice":
return "Multiple Choice"
case "judgment":
return "True/False"
case "text":
return "Short Answer"
case "composite":
return "Composite"
default:
return type
}
}
export const columns: ColumnDef<Question>[] = [
@@ -71,14 +75,20 @@ export const columns: ColumnDef<Question>[] = [
accessorKey: "content",
header: "Content",
cell: ({ row }) => {
const content = row.getValue("content");
let preview = "";
if (typeof content === 'string') {
preview = content;
} else if (content && typeof content === 'object') {
preview = JSON.stringify(content).slice(0, 50);
const content = row.getValue("content") as unknown
let preview = ""
if (typeof content === "string") {
preview = content
} else if (content && typeof content === "object") {
const text = (content as { text?: unknown }).text
if (typeof text === "string") {
preview = text
} else {
preview = JSON.stringify(content)
}
}
preview = preview.slice(0, 80)
return (
<div className="max-w-[400px] truncate font-medium" title={preview}>
{preview}
@@ -90,17 +100,23 @@ export const columns: ColumnDef<Question>[] = [
accessorKey: "difficulty",
header: "Difficulty",
cell: ({ row }) => {
const diff = row.getValue("difficulty") as number;
// 1-5 scale
const diff = row.getValue("difficulty") as number
const label =
diff === 1
? "Easy"
: diff === 2
? "Easy-Med"
: diff === 3
? "Medium"
: diff === 4
? "Med-Hard"
: "Hard"
return (
<div className="flex items-center">
<span className={cn("font-medium",
diff <= 2 ? "text-green-600" :
diff === 3 ? "text-yellow-600" : "text-red-600"
)}>
{diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
</span>
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="tabular-nums">
{label}
</Badge>
<span className="text-xs text-muted-foreground tabular-nums">({diff})</span>
</div>
)
},
@@ -109,22 +125,24 @@ export const columns: ColumnDef<Question>[] = [
accessorKey: "knowledgePoints",
header: "Knowledge Points",
cell: ({ row }) => {
const kps = row.original.knowledgePoints;
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex flex-wrap gap-1">
{kps.slice(0, 2).map(kp => (
<Badge key={kp.id} variant="outline" className="text-xs">
{kp.name}
</Badge>
))}
{kps.length > 2 && (
<Badge variant="outline" className="text-xs">+{kps.length - 2}</Badge>
)}
</div>
)
}
const kps = row.original.knowledgePoints
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>
return (
<div className="flex flex-wrap gap-1">
{kps.slice(0, 2).map((kp) => (
<Badge key={kp.id} variant="outline" className="text-xs">
{kp.name}
</Badge>
))}
{kps.length > 2 && (
<Badge variant="outline" className="text-xs">
+{kps.length - 2}
</Badge>
)}
</div>
)
},
},
{
accessorKey: "createdAt",
@@ -132,7 +150,7 @@ export const columns: ColumnDef<Question>[] = [
cell: ({ row }) => {
return (
<span className="text-muted-foreground text-xs whitespace-nowrap">
{new Date(row.getValue("createdAt")).toLocaleDateString()}
{new Date(row.getValue("createdAt")).toLocaleDateString()}
</span>
)
},

View File

@@ -18,10 +18,6 @@ export function QuestionFilters() {
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all"))
// Debounce could be added here for search, but for simplicity we rely on 'Enter' or blur or just let it update (nuqs handles some updates well, but for text input usually we want debounce or on change).
// Actually nuqs with shallow: false (default) triggers server re-render.
// For text input, it's better to use local state and update URL on debounce or enter.
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 items-center gap-2">
@@ -31,7 +27,7 @@ export function QuestionFilters() {
placeholder="Search questions..."
className="pl-8"
value={search}
onChange={(e) => setSearch(e.target.value || null)}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
@@ -44,6 +40,7 @@ export function QuestionFilters() {
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
<SelectItem value="judgment">True/False</SelectItem>
<SelectItem value="text">Short Answer</SelectItem>
<SelectItem value="composite">Composite</SelectItem>
</SelectContent>
</Select>
<Select value={difficulty} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
@@ -61,8 +58,8 @@ export function QuestionFilters() {
</Select>
{(search || type !== "all" || difficulty !== "all") && (
<Button
variant="ghost"
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setType(null)

View File

@@ -2,41 +2,52 @@ import 'server-only';
import { db } from "@/shared/db";
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { and, eq, inArray, count, desc, sql } from "drizzle-orm";
import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
import { cache } from "react";
import type { Question, QuestionType } from "./types";
// Types for filters
export type GetQuestionsParams = {
q?: string;
page?: number;
pageSize?: number;
ids?: string[];
knowledgePointId?: string;
type?: QuestionType;
difficulty?: number;
};
// Cached Data Access Function
// Using React's cache() to deduplicate requests if called multiple times in one render pass
export const getQuestions = cache(async ({
q,
page = 1,
pageSize = 10,
pageSize = 50,
ids,
knowledgePointId,
type,
difficulty,
}: GetQuestionsParams = {}) => {
const offset = (page - 1) * pageSize;
// Build Where Conditions
const conditions = [];
const conditions: SQL[] = [];
if (ids && ids.length > 0) {
conditions.push(inArray(questions.id, ids));
}
if (q && q.trim().length > 0) {
const needle = `%${q.trim().toLowerCase()}%`;
conditions.push(
sql`LOWER(CAST(${questions.content} AS CHAR)) LIKE ${needle}`
);
}
if (type) {
conditions.push(eq(questions.type, type));
}
if (difficulty) {
conditions.push(eq(questions.difficulty, difficulty));
}
// Filter by Knowledge Point (using subquery pattern for Many-to-Many)
if (knowledgePointId) {
const subQuery = db
.select({ questionId: questionsToKnowledgePoints.questionId })
@@ -52,29 +63,24 @@ export const getQuestions = cache(async ({
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
// 1. Get Total Count (for Pagination)
// Optimization: separate count query is often faster than fetching all data
const [totalResult] = await db
.select({ count: count() })
.from(questions)
.where(whereClause);
const total = totalResult?.count ?? 0;
const total = Number(totalResult?.count ?? 0);
// 2. Get Data with Relations
const data = await db.query.questions.findMany({
const rows = await db.query.questions.findMany({
where: whereClause,
limit: pageSize,
offset: offset,
orderBy: [desc(questions.createdAt)],
with: {
// Preload Knowledge Points
questionsToKnowledgePoints: {
with: {
knowledgePoint: true,
},
},
// Preload Author
author: {
columns: {
id: true,
@@ -82,13 +88,37 @@ export const getQuestions = cache(async ({
image: true,
},
},
// Preload Child Questions (first level)
children: true,
},
});
return {
data,
data: rows.map((row) => {
const knowledgePoints =
row.questionsToKnowledgePoints?.map((rel) => rel.knowledgePoint) ?? [];
const author = row.author
? {
id: row.author.id,
name: row.author.name,
image: row.author.image,
}
: null;
const mapped: Question = {
id: row.id,
content: row.content,
type: row.type,
difficulty: row.difficulty ?? 1,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
author,
knowledgePoints: knowledgePoints.map((kp) => ({ id: kp.id, name: kp.name })),
childrenCount: row.children?.length ?? 0,
};
return mapped;
}),
meta: {
page,
pageSize,

View File

@@ -1,21 +1,18 @@
import { z } from "zod";
import { z } from "zod"
// Enum for Question Types matching DB
export const QuestionTypeEnum = z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]);
export const QuestionTypeEnum = z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"])
// Base Question Schema
export const BaseQuestionSchema = z.object({
content: z.any().describe("JSON content for the question (e.g. Slate nodes)"), // Using any for JSON flexibility, strict schemas can be applied if structure is known
content: z.any().describe("JSON content for the question (e.g. Slate nodes)"),
type: QuestionTypeEnum,
difficulty: z.number().min(1).max(5).default(1),
knowledgePointIds: z.array(z.string()).optional(),
});
})
// Recursive Schema for Nested Questions (e.g. Composite -> Sub Questions)
export type CreateQuestionInput = z.infer<typeof BaseQuestionSchema> & {
subQuestions?: CreateQuestionInput[];
};
subQuestions?: CreateQuestionInput[]
}
export const CreateQuestionSchema: z.ZodType<CreateQuestionInput> = BaseQuestionSchema.extend({
subQuestions: z.lazy(() => CreateQuestionSchema.array().optional()),
});
})

View File

@@ -1,27 +1,23 @@
import { z } from "zod";
import { QuestionTypeEnum } from "./schema";
import { z } from "zod"
import { QuestionTypeEnum } from "./schema"
// Infer types from Zod Schema
export type QuestionType = z.infer<typeof QuestionTypeEnum>;
export type QuestionType = z.infer<typeof QuestionTypeEnum>
// UI Model for Question (matching the structure returned by data-access or mock)
export interface Question {
id: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: any; // Rich text content
type: QuestionType;
difficulty: number;
createdAt: Date;
updatedAt: Date;
id: string
content: unknown
type: QuestionType
difficulty: number
createdAt: Date
updatedAt: Date
author: {
id: string;
name: string | null;
image: string | null;
} | null;
id: string
name: string | null
image: string | null
} | null
knowledgePoints: {
id: string;
name: string;
}[];
// For UI display
childrenCount?: number;
id: string
name: string
}[]
childrenCount?: number
}