From f8e39f518d650db5e2bfba48816aac7ff58226e0 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:04:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(teacher):=20=E9=A2=98=E5=BA=93=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=88QuestionBank=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作内容 - 新增 /teacher/questions 页面,支持 Suspense/空状态/加载态 - 题库 CRUD Server Actions:创建/更新/递归删除子题,变更后 revalidatePath - getQuestions 支持 q/type/difficulty/knowledgePointId 筛选与分页返回 meta - UI:表格列/筛选器/创建编辑弹窗,content JSON 兼容组卷 - 更新中文设计文档:docs/design/004_question_bank_implementation.md --- .../004_question_bank_implementation.md | 165 +++++++---- .../teacher/exams/[id]/build/page.tsx | 6 +- .../(dashboard)/teacher/questions/loading.tsx | 30 ++ .../(dashboard)/teacher/questions/page.tsx | 105 ++++--- src/modules/questions/actions.ts | 154 ++++++++-- .../components/create-question-dialog.tsx | 267 ++++++++++++------ .../questions/components/question-actions.tsx | 32 ++- .../questions/components/question-columns.tsx | 116 ++++---- .../questions/components/question-filters.tsx | 11 +- src/modules/questions/data-access.ts | 64 +++-- src/modules/questions/schema.ts | 17 +- src/modules/questions/types.ts | 38 ++- 12 files changed, 680 insertions(+), 325 deletions(-) create mode 100644 src/app/(dashboard)/teacher/questions/loading.tsx diff --git a/docs/design/004_question_bank_implementation.md b/docs/design/004_question_bank_implementation.md index 8a8b827..a4f0a7f 100644 --- a/docs/design/004_question_bank_implementation.md +++ b/docs/design/004_question_bank_implementation.md @@ -1,87 +1,132 @@ -# Question Bank Module Implementation +# 题库模块实现 -## 1. Overview -The Question Bank module (`src/modules/questions`) is a core component for teachers to manage their examination resources. It implements a comprehensive CRUD interface with advanced filtering and batch operations. +## 1. 概述 +题库模块(`src/modules/questions`)是教师管理考试资源的核心组件,提供完整的 CRUD 能力,并支持搜索/筛选等常用管理能力。 -**Status**: IMPLEMENTED -**Date**: 2025-12-23 -**Author**: Senior Frontend Engineer +**状态**:已实现 +**日期**:2025-12-23 +**作者**:前端高级工程师 --- -## 2. Architecture & Tech Stack +## 2. 架构与技术栈 -### 2.1 Vertical Slice Architecture -Following the project's architectural guidelines, all question-related logic is encapsulated within `src/modules/questions`: -- `components/`: UI components (Data Table, Dialogs, Filters) -- `actions.ts`: Server Actions for data mutation -- `data-access.ts`: Database query logic -- `schema.ts`: Zod schemas for validation -- `types.ts`: TypeScript interfaces +### 2.1 垂直切片(Vertical Slice)架构 +遵循项目的架构规范,所有与题库相关的逻辑都收敛在 `src/modules/questions` 下: +- `components/`:UI 组件(表格、弹窗、筛选器) +- `actions.ts`:Server Actions(数据变更) +- `data-access.ts`:数据库查询逻辑 +- `schema.ts`:Zod 校验 Schema +- `types.ts`:TypeScript 类型定义 -### 2.2 Key Technologies -- **Data Grid**: `@tanstack/react-table` for high-performance rendering. -- **State Management**: `nuqs` for URL-based state (filters, search). -- **Forms**: `react-hook-form` + `zod` + `shadcn/ui` form components. -- **Validation**: Strict server-side and client-side validation using Zod schemas. +### 2.2 关键技术 +- **数据表格**:`@tanstack/react-table`(高性能表格渲染) +- **状态管理**:`nuqs`(基于 URL Query 的筛选/搜索状态) +- **表单**:`react-hook-form` + `zod` + `shadcn/ui` 表单组件 +- **校验**:Zod 提供严格的服务端/客户端校验 --- -## 3. Component Design +## 3. 组件设计 -### 3.1 QuestionDataTable (`question-data-table.tsx`) -- **Features**: Pagination, Sorting, Row Selection. -- **Performance**: Uses `React.memo` compatible patterns where possible (though `useReactTable` itself is not memoized). -- **Responsiveness**: Mobile-first design with horizontal scroll for complex columns. +### 3.1 QuestionDataTable(`question-data-table.tsx`) +- **能力**:分页、排序、行选择 +- **性能**:尽可能采用与 `React.memo` 兼容的写法(`useReactTable` 本身不做 memo) +- **响应式**:移动端优先;复杂列支持横向滚动 -### 3.2 QuestionColumns (`question-columns.tsx`) -Custom cell renderers for rich data display: -- **Type Badge**: Color-coded badges for different question types (Single Choice, Multiple Choice, etc.). -- **Difficulty**: Visual indicator with color (Green -> Red) and numerical value. -- **Actions**: Dropdown menu for Edit, Delete, View Details, and Copy ID. +### 3.2 QuestionColumns(`question-columns.tsx`) +用于增强单元格展示的自定义渲染: +- **题型 Badge**:不同题型的颜色/样式区分(单选、多选等) +- **难度展示**:难度标签 + 数值 +- **行操作**:下拉菜单(编辑、删除、查看详情、复制 ID) -### 3.3 Create/Edit Dialog (`create-question-dialog.tsx`) -A unified dialog component for both creating and editing questions. -- **Dynamic Fields**: Shows/hides "Options" field based on question type. -- **Interactive Options**: Allows adding/removing/reordering options for choice questions. -- **Optimistic UI**: Shows loading states during submission. +### 3.3 创建/编辑弹窗(`create-question-dialog.tsx`) +创建与编辑共用同一个弹窗组件: +- **动态字段**:根据题型显示/隐藏“选项”区域 +- **选项编辑**:支持添加/删除选项(选择题) +- **交互反馈**:提交中 Loading 状态 -### 3.4 Filters (`question-filters.tsx`) -- **URL Sync**: All filter states (Search, Type, Difficulty) are synced to URL parameters. -- **Debounce**: Search input uses debounce to prevent excessive requests. -- **Server Filtering**: Filtering logic is executed on the server side (currently simulated in `page.tsx`, ready for DB integration). +### 3.4 筛选器(`question-filters.tsx`) +- **URL 同步**:搜索、题型、难度等筛选条件与 URL 参数保持同步 +- **无 Debounce(当前)**:搜索输入每次变更都会更新 URL +- **服务端筛选**:在服务端组件中通过 `getQuestions` 执行筛选查询 --- -## 4. Implementation Details +## 4. 实现细节 -### 4.1 Data Flow -1. **Read**: `page.tsx` (Server Component) fetches data based on `searchParams`. -2. **Write**: Client components invoke Server Actions (simulated) -> Revalidate Path -> UI Updates. -3. **Filter**: User interaction -> Update URL -> Server Component Re-render -> New Data. +### 4.1 数据流 +1. **读取**:`page.tsx`(Server Component)根据 `searchParams` 拉取数据 +2. **写入**:客户端组件调用 Server Actions -> `revalidatePath` -> UI 更新 +3. **筛选**:用户操作 -> 更新 URL -> 服务端组件重新渲染 -> 返回新数据 + +### 4.2 类型安全 +共享的 `Question` 类型用于保证前后端一致: -### 4.2 Type Safety -A shared `Question` interface ensures consistency across the stack: ```typescript export interface Question { - id: string; - content: any; // Rich text structure - type: QuestionType; - difficulty: number; - // ... relations + id: string + content: unknown + type: QuestionType + difficulty: number + // ... 关联字段 } ``` -### 4.3 UI/UX Standards -- **Empty States**: Custom `EmptyState` component when no data matches. -- **Loading States**: Skeleton screens for table loading. -- **Feedback**: `Sonner` toasts for success/error notifications. -- **Confirmation**: `AlertDialog` for destructive actions (Delete). +### 4.3 UI/UX 规范 +- **空状态**:无数据时展示 `EmptyState` +- **加载态**:表格加载使用 Skeleton +- **反馈**:`Sonner` toast 展示成功/失败提示 +- **确认弹窗**:删除等破坏性操作使用 `AlertDialog` --- -## 5. Next Steps -- [ ] Integrate with real Database (replace Mock Data). -- [ ] Implement Rich Text Editor (Slate.js / Tiptap) for question content. -- [ ] Add "Batch Import" functionality. -- [ ] Implement "Tags" management for Knowledge Points. +## 5. 后续计划 +- [x] 接入真实数据库(替换 Mock Data) +- [ ] 为题目内容引入富文本编辑器(Slate.js / Tiptap) +- [ ] 增加“批量导入”能力 +- [ ] 增加知识点“标签”管理能力 + +--- + +## 6. 实现更新(2025-12-30) + +### 6.1 教师路由与加载态 +- 实现 `/teacher/questions` 页面(Suspense + 空状态) +- 新增路由级加载 UI:`/teacher/questions/loading.tsx` + +### 6.2 Content JSON 约定 +为与考试组卷/预览组件保持一致,`questions.content` 采用最小 JSON 结构: + +```typescript +type ChoiceOption = { + id: string + text: string + isCorrect?: boolean +} + +type QuestionContent = { + text: string + options?: ChoiceOption[] +} +``` + +### 6.3 数据访问层(`getQuestions`) +- 新增服务端筛选:`q`(content LIKE)、`type`、`difficulty`、`knowledgePointId` +- 默认仅返回根题(`parentId IS NULL`),除非显式按 `ids` 查询 +- 返回 `{ data, meta }`(包含分页统计),并为 UI 映射关联数据 + +### 6.4 Server Actions(CRUD) +- `createNestedQuestion` 支持 FormData(字段 `json`)与递归 `subQuestions` +- `updateQuestionAction` 更新题目与知识点关联 +- `deleteQuestionAction` 递归删除子题 +- 所有变更都会对 `/teacher/questions` 做缓存再验证 + +### 6.5 UI 集成 +- `CreateQuestionDialog` 提交 `QuestionContent` JSON,并支持选择题正确答案勾选 +- `QuestionActions` 在编辑/删除后刷新列表 +- 表格内容预览优先展示 `content.text` + +### 6.6 校验 +- `npm run lint`:0 errors(仓库其他位置仍存在 warnings) +- `npm run typecheck`:通过 diff --git a/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx b/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx index cfb273d..7588851 100644 --- a/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx +++ b/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx @@ -42,11 +42,7 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id: image: q.author.image || null, } : null, - knowledgePoints: - q.questionsToKnowledgePoints?.map((kp) => ({ - id: kp.knowledgePoint.id, - name: kp.knowledgePoint.name, - })) ?? [], + knowledgePoints: q.knowledgePoints ?? [], }) const questionOptionsById = new Map() diff --git a/src/app/(dashboard)/teacher/questions/loading.tsx b/src/app/(dashboard)/teacher/questions/loading.tsx new file mode 100644 index 0000000..e6e6d74 --- /dev/null +++ b/src/app/(dashboard)/teacher/questions/loading.tsx @@ -0,0 +1,30 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+ +
+ +
+
+ +
+
+ {Array.from({ length: 6 }).map((_, idx) => ( + + ))} +
+
+
+
+ ) +} + diff --git a/src/app/(dashboard)/teacher/questions/page.tsx b/src/app/(dashboard)/teacher/questions/page.tsx index b156047..51bd9ff 100644 --- a/src/app/(dashboard)/teacher/questions/page.tsx +++ b/src/app/(dashboard)/teacher/questions/page.tsx @@ -1,51 +1,90 @@ import { Suspense } from "react" -import { Plus } from "lucide-react" +import { ClipboardList } from "lucide-react" -import { Button } from "@/shared/components/ui/button" import { QuestionDataTable } from "@/modules/questions/components/question-data-table" import { columns } from "@/modules/questions/components/question-columns" import { QuestionFilters } from "@/modules/questions/components/question-filters" import { CreateQuestionButton } from "@/modules/questions/components/create-question-button" -import { MOCK_QUESTIONS } from "@/modules/questions/mock-data" -import { Question } from "@/modules/questions/types" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Skeleton } from "@/shared/components/ui/skeleton" +import { getQuestions } from "@/modules/questions/data-access" +import type { QuestionType } from "@/modules/questions/types" -// Simulate backend delay and filtering -async function getQuestions(searchParams: { [key: string]: string | string[] | undefined }) { - // In a real app, you would call your DB or API here - // await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate network latency +type SearchParams = { [key: string]: string | string[] | undefined } - let filtered = [...MOCK_QUESTIONS] +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} - const q = searchParams.q as string - const type = searchParams.type as string - const difficulty = searchParams.difficulty as string +async function QuestionBankResults({ searchParams }: { searchParams: Promise }) { + const params = await searchParams - if (q) { - filtered = filtered.filter((item) => - (typeof item.content === 'string' && item.content.toLowerCase().includes(q.toLowerCase())) || - (typeof item.content === 'object' && JSON.stringify(item.content).toLowerCase().includes(q.toLowerCase())) + const q = getParam(params, "q") + const type = getParam(params, "type") + const difficulty = getParam(params, "difficulty") + + const questionType: QuestionType | undefined = + type === "single_choice" || + type === "multiple_choice" || + type === "text" || + type === "judgment" || + type === "composite" + ? type + : undefined + + const { data: questions } = await getQuestions({ + q: q || undefined, + type: questionType, + difficulty: difficulty && difficulty !== "all" ? Number(difficulty) : undefined, + pageSize: 200, + }) + + const hasFilters = Boolean(q || (type && type !== "all") || (difficulty && difficulty !== "all")) + + if (questions.length === 0) { + return ( + ) } - if (type && type !== "all") { - filtered = filtered.filter((item) => item.type === type) - } + return ( +
+ +
+ ) +} - if (difficulty && difficulty !== "all") { - filtered = filtered.filter((item) => item.difficulty === parseInt(difficulty)) - } - - return filtered +function QuestionBankResultsFallback() { + return ( +
+
+ +
+
+ {Array.from({ length: 6 }).map((_, idx) => ( + + ))} +
+
+ ) } export default async function QuestionBankPage({ searchParams, }: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }> + searchParams: Promise }) { - const params = await searchParams - const questions = await getQuestions(params) - return (
@@ -62,12 +101,12 @@ export default async function QuestionBankPage({
}> - + + + + }> + - -
- -
) diff --git a/src/modules/questions/actions.ts b/src/modules/questions/actions.ts index d58a429..b8375a3 100644 --- a/src/modules/questions/actions.ts +++ b/src/modules/questions/actions.ts @@ -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[0]>[0] async function insertQuestionWithRelations( tx: Tx, - input: CreateQuestionInput, + input: z.infer, 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 | undefined, - formData: FormData | CreateQuestionInput // Support both FormData and JSON input + formData: FormData | CreateQuestionInput ): Promise> { 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 | undefined, + formData: FormData +): Promise> { + 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 | undefined, + formData: FormData +): Promise> { + 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" }; + } +} diff --git a/src/modules/questions/components/create-question-dialog.tsx b/src/modules/questions/components/create-question-dialog.tsx index d921f0f..4ee765a 100644 --- a/src/modules/questions/components/create-question-dialog.tsx +++ b/src/modules/questions/components/create-question-dialog.tsx @@ -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 @@ -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 => 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 = 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 }) => ( Question Type - @@ -159,6 +238,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create Multiple Choice True/False Short Answer + Composite @@ -173,8 +253,8 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create Difficulty (1-5) { - const newOptions = [...(form.getValues("options") || [])]; - newOptions[index].label = e.target.value; - form.setValue("options", newOptions); - }} - placeholder={`Option ${index + 1}`} - /> - -
- ))} + {form.watch("options")?.map((option, index) => ( +
+
+ +
+ { + 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" + /> + { + const next = [...(form.getValues("options") || [])] + if (!next[index]) return + next[index].label = e.target.value + form.setValue("options", next) + }} + placeholder={`Option ${index + 1}`} + /> + +
+ ))} )} @@ -275,7 +378,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create Cancel diff --git a/src/modules/questions/components/question-actions.tsx b/src/modules/questions/components/question-actions.tsx index 08b43e8..7f6a5fb 100644 --- a/src/modules/questions/components/question-actions.tsx +++ b/src/modules/questions/components/question-actions.tsx @@ -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) { - {/* Edit Dialog */} - {/* Delete Confirmation Dialog */} @@ -128,7 +133,6 @@ export function QuestionActions({ question }: QuestionActionsProps) { - {/* View Details Dialog (Simple Read-only View) */} @@ -138,7 +142,7 @@ export function QuestionActions({ question }: QuestionActionsProps) {
Type: - {question.type.replace('_', ' ')} + {question.type.replaceAll("_", " ")}
Difficulty: @@ -147,17 +151,17 @@ export function QuestionActions({ question }: QuestionActionsProps) {
Content:
- {typeof question.content === 'string' ? question.content : JSON.stringify(question.content, null, 2)} + {typeof question.content === "string" + ? question.content + : JSON.stringify(question.content, null, 2)}
- {/* Show Author if exists */} {question.author && (
Author: {question.author.name || "Unknown"}
)} - {/* Show Knowledge Points */} {question.knowledgePoints && question.knowledgePoints.length > 0 && (
Tags: diff --git a/src/modules/questions/components/question-columns.tsx b/src/modules/questions/components/question-columns.tsx index e3f3c3e..7220b30 100644 --- a/src/modules/questions/components/question-columns.tsx +++ b/src/modules/questions/components/question-columns.tsx @@ -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[] = [ @@ -71,14 +75,20 @@ export const columns: ColumnDef[] = [ 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 (
{preview} @@ -90,17 +100,23 @@ export const columns: ColumnDef[] = [ 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 ( -
- - {diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"} - - ({diff}) +
+ + {label} + + ({diff})
) }, @@ -109,22 +125,24 @@ export const columns: ColumnDef[] = [ accessorKey: "knowledgePoints", header: "Knowledge Points", cell: ({ row }) => { - const kps = row.original.knowledgePoints; - if (!kps || kps.length === 0) return -; - - return ( -
- {kps.slice(0, 2).map(kp => ( - - {kp.name} - - ))} - {kps.length > 2 && ( - +{kps.length - 2} - )} -
- ) - } + const kps = row.original.knowledgePoints + if (!kps || kps.length === 0) return - + + return ( +
+ {kps.slice(0, 2).map((kp) => ( + + {kp.name} + + ))} + {kps.length > 2 && ( + + +{kps.length - 2} + + )} +
+ ) + }, }, { accessorKey: "createdAt", @@ -132,7 +150,7 @@ export const columns: ColumnDef[] = [ cell: ({ row }) => { return ( - {new Date(row.getValue("createdAt")).toLocaleDateString()} + {new Date(row.getValue("createdAt")).toLocaleDateString()} ) }, diff --git a/src/modules/questions/components/question-filters.tsx b/src/modules/questions/components/question-filters.tsx index bb9b0f8..5b300ca 100644 --- a/src/modules/questions/components/question-filters.tsx +++ b/src/modules/questions/components/question-filters.tsx @@ -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 (
@@ -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)} />
{(search || type !== "all" || difficulty !== "all") && ( -