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:
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user