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" };
}
}