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" };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user