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:
@@ -1,87 +1,132 @@
|
|||||||
# Question Bank Module Implementation
|
# 题库模块实现
|
||||||
|
|
||||||
## 1. Overview
|
## 1. 概述
|
||||||
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.
|
题库模块(`src/modules/questions`)是教师管理考试资源的核心组件,提供完整的 CRUD 能力,并支持搜索/筛选等常用管理能力。
|
||||||
|
|
||||||
**Status**: IMPLEMENTED
|
**状态**:已实现
|
||||||
**Date**: 2025-12-23
|
**日期**:2025-12-23
|
||||||
**Author**: Senior Frontend Engineer
|
**作者**:前端高级工程师
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Architecture & Tech Stack
|
## 2. 架构与技术栈
|
||||||
|
|
||||||
### 2.1 Vertical Slice Architecture
|
### 2.1 垂直切片(Vertical Slice)架构
|
||||||
Following the project's architectural guidelines, all question-related logic is encapsulated within `src/modules/questions`:
|
遵循项目的架构规范,所有与题库相关的逻辑都收敛在 `src/modules/questions` 下:
|
||||||
- `components/`: UI components (Data Table, Dialogs, Filters)
|
- `components/`:UI 组件(表格、弹窗、筛选器)
|
||||||
- `actions.ts`: Server Actions for data mutation
|
- `actions.ts`:Server Actions(数据变更)
|
||||||
- `data-access.ts`: Database query logic
|
- `data-access.ts`:数据库查询逻辑
|
||||||
- `schema.ts`: Zod schemas for validation
|
- `schema.ts`:Zod 校验 Schema
|
||||||
- `types.ts`: TypeScript interfaces
|
- `types.ts`:TypeScript 类型定义
|
||||||
|
|
||||||
### 2.2 Key Technologies
|
### 2.2 关键技术
|
||||||
- **Data Grid**: `@tanstack/react-table` for high-performance rendering.
|
- **数据表格**:`@tanstack/react-table`(高性能表格渲染)
|
||||||
- **State Management**: `nuqs` for URL-based state (filters, search).
|
- **状态管理**:`nuqs`(基于 URL Query 的筛选/搜索状态)
|
||||||
- **Forms**: `react-hook-form` + `zod` + `shadcn/ui` form components.
|
- **表单**:`react-hook-form` + `zod` + `shadcn/ui` 表单组件
|
||||||
- **Validation**: Strict server-side and client-side validation using Zod schemas.
|
- **校验**:Zod 提供严格的服务端/客户端校验
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Component Design
|
## 3. 组件设计
|
||||||
|
|
||||||
### 3.1 QuestionDataTable (`question-data-table.tsx`)
|
### 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).
|
- **性能**:尽可能采用与 `React.memo` 兼容的写法(`useReactTable` 本身不做 memo)
|
||||||
- **Responsiveness**: Mobile-first design with horizontal scroll for complex columns.
|
- **响应式**:移动端优先;复杂列支持横向滚动
|
||||||
|
|
||||||
### 3.2 QuestionColumns (`question-columns.tsx`)
|
### 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.).
|
- **题型 Badge**:不同题型的颜色/样式区分(单选、多选等)
|
||||||
- **Difficulty**: Visual indicator with color (Green -> Red) and numerical value.
|
- **难度展示**:难度标签 + 数值
|
||||||
- **Actions**: Dropdown menu for Edit, Delete, View Details, and Copy ID.
|
- **行操作**:下拉菜单(编辑、删除、查看详情、复制 ID)
|
||||||
|
|
||||||
### 3.3 Create/Edit Dialog (`create-question-dialog.tsx`)
|
### 3.3 创建/编辑弹窗(`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.
|
- **交互反馈**:提交中 Loading 状态
|
||||||
|
|
||||||
### 3.4 Filters (`question-filters.tsx`)
|
### 3.4 筛选器(`question-filters.tsx`)
|
||||||
- **URL Sync**: All filter states (Search, Type, Difficulty) are synced to URL parameters.
|
- **URL 同步**:搜索、题型、难度等筛选条件与 URL 参数保持同步
|
||||||
- **Debounce**: Search input uses debounce to prevent excessive requests.
|
- **无 Debounce(当前)**:搜索输入每次变更都会更新 URL
|
||||||
- **Server Filtering**: Filtering logic is executed on the server side (currently simulated in `page.tsx`, ready for DB integration).
|
- **服务端筛选**:在服务端组件中通过 `getQuestions` 执行筛选查询
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Implementation Details
|
## 4. 实现细节
|
||||||
|
|
||||||
### 4.1 Data Flow
|
### 4.1 数据流
|
||||||
1. **Read**: `page.tsx` (Server Component) fetches data based on `searchParams`.
|
1. **读取**:`page.tsx`(Server Component)根据 `searchParams` 拉取数据
|
||||||
2. **Write**: Client components invoke Server Actions (simulated) -> Revalidate Path -> UI Updates.
|
2. **写入**:客户端组件调用 Server Actions -> `revalidatePath` -> UI 更新
|
||||||
3. **Filter**: User interaction -> Update URL -> Server Component Re-render -> New Data.
|
3. **筛选**:用户操作 -> 更新 URL -> 服务端组件重新渲染 -> 返回新数据
|
||||||
|
|
||||||
|
### 4.2 类型安全
|
||||||
|
共享的 `Question` 类型用于保证前后端一致:
|
||||||
|
|
||||||
### 4.2 Type Safety
|
|
||||||
A shared `Question` interface ensures consistency across the stack:
|
|
||||||
```typescript
|
```typescript
|
||||||
export interface Question {
|
export interface Question {
|
||||||
id: string;
|
id: string
|
||||||
content: any; // Rich text structure
|
content: unknown
|
||||||
type: QuestionType;
|
type: QuestionType
|
||||||
difficulty: number;
|
difficulty: number
|
||||||
// ... relations
|
// ... 关联字段
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.3 UI/UX Standards
|
### 4.3 UI/UX 规范
|
||||||
- **Empty States**: Custom `EmptyState` component when no data matches.
|
- **空状态**:无数据时展示 `EmptyState`
|
||||||
- **Loading States**: Skeleton screens for table loading.
|
- **加载态**:表格加载使用 Skeleton
|
||||||
- **Feedback**: `Sonner` toasts for success/error notifications.
|
- **反馈**:`Sonner` toast 展示成功/失败提示
|
||||||
- **Confirmation**: `AlertDialog` for destructive actions (Delete).
|
- **确认弹窗**:删除等破坏性操作使用 `AlertDialog`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Next Steps
|
## 5. 后续计划
|
||||||
- [ ] Integrate with real Database (replace Mock Data).
|
- [x] 接入真实数据库(替换 Mock Data)
|
||||||
- [ ] Implement Rich Text Editor (Slate.js / Tiptap) for question content.
|
- [ ] 为题目内容引入富文本编辑器(Slate.js / Tiptap)
|
||||||
- [ ] Add "Batch Import" functionality.
|
- [ ] 增加“批量导入”能力
|
||||||
- [ ] Implement "Tags" management for Knowledge Points.
|
- [ ] 增加知识点“标签”管理能力
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`:通过
|
||||||
|
|||||||
@@ -42,11 +42,7 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
|||||||
image: q.author.image || null,
|
image: q.author.image || null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
knowledgePoints:
|
knowledgePoints: q.knowledgePoints ?? [],
|
||||||
q.questionsToKnowledgePoints?.map((kp) => ({
|
|
||||||
id: kp.knowledgePoint.id,
|
|
||||||
name: kp.knowledgePoint.name,
|
|
||||||
})) ?? [],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const questionOptionsById = new Map<string, Question>()
|
const questionOptionsById = new Map<string, Question>()
|
||||||
|
|||||||
30
src/app/(dashboard)/teacher/questions/loading.tsx
Normal file
30
src/app/(dashboard)/teacher/questions/loading.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-7 w-[200px]" />
|
||||||
|
<Skeleton className="h-4 w-[420px]" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-9 w-[140px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<div className="p-4">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 p-4 pt-0">
|
||||||
|
{Array.from({ length: 6 }).map((_, idx) => (
|
||||||
|
<Skeleton key={idx} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,51 +1,90 @@
|
|||||||
import { Suspense } from "react"
|
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 { QuestionDataTable } from "@/modules/questions/components/question-data-table"
|
||||||
import { columns } from "@/modules/questions/components/question-columns"
|
import { columns } from "@/modules/questions/components/question-columns"
|
||||||
import { QuestionFilters } from "@/modules/questions/components/question-filters"
|
import { QuestionFilters } from "@/modules/questions/components/question-filters"
|
||||||
import { CreateQuestionButton } from "@/modules/questions/components/create-question-button"
|
import { CreateQuestionButton } from "@/modules/questions/components/create-question-button"
|
||||||
import { MOCK_QUESTIONS } from "@/modules/questions/mock-data"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { Question } from "@/modules/questions/types"
|
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
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
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
|
|
||||||
|
|
||||||
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
|
async function QuestionBankResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const type = searchParams.type as string
|
const params = await searchParams
|
||||||
const difficulty = searchParams.difficulty as string
|
|
||||||
|
|
||||||
if (q) {
|
const q = getParam(params, "q")
|
||||||
filtered = filtered.filter((item) =>
|
const type = getParam(params, "type")
|
||||||
(typeof item.content === 'string' && item.content.toLowerCase().includes(q.toLowerCase())) ||
|
const difficulty = getParam(params, "difficulty")
|
||||||
(typeof item.content === 'object' && JSON.stringify(item.content).toLowerCase().includes(q.toLowerCase()))
|
|
||||||
|
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 (
|
||||||
|
<EmptyState
|
||||||
|
icon={ClipboardList}
|
||||||
|
title={hasFilters ? "No questions match your filters" : "No questions yet"}
|
||||||
|
description={
|
||||||
|
hasFilters
|
||||||
|
? "Try clearing filters or adjusting keywords."
|
||||||
|
: "Create your first question to start building exams and assignments."
|
||||||
|
}
|
||||||
|
action={hasFilters ? { label: "Clear filters", href: "/teacher/questions" } : undefined}
|
||||||
|
className="h-[360px] bg-card"
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type && type !== "all") {
|
return (
|
||||||
filtered = filtered.filter((item) => item.type === type)
|
<div className="rounded-md border bg-card">
|
||||||
}
|
<QuestionDataTable columns={columns} data={questions} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (difficulty && difficulty !== "all") {
|
function QuestionBankResultsFallback() {
|
||||||
filtered = filtered.filter((item) => item.difficulty === parseInt(difficulty))
|
return (
|
||||||
}
|
<div className="rounded-md border bg-card">
|
||||||
|
<div className="p-4">
|
||||||
return filtered
|
<Skeleton className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 p-4 pt-0">
|
||||||
|
{Array.from({ length: 6 }).map((_, idx) => (
|
||||||
|
<Skeleton key={idx} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function QuestionBankPage({
|
export default async function QuestionBankPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
searchParams: Promise<SearchParams>
|
||||||
}) {
|
}) {
|
||||||
const params = await searchParams
|
|
||||||
const questions = await getQuestions(params)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||||
@@ -62,12 +101,12 @@ export default async function QuestionBankPage({
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||||
<QuestionFilters />
|
<QuestionFilters />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<div className="rounded-md border bg-card">
|
<Suspense fallback={<QuestionBankResultsFallback />}>
|
||||||
<QuestionDataTable columns={columns} data={questions} />
|
<QuestionBankResults searchParams={searchParams} />
|
||||||
</div>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,18 +2,18 @@
|
|||||||
|
|
||||||
import { db } from "@/shared/db";
|
import { db } from "@/shared/db";
|
||||||
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
|
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 { ActionState } from "@/shared/types/action-state";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
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() {
|
async function getCurrentUser() {
|
||||||
// In production: const session = await auth(); return session?.user;
|
|
||||||
// Mocking a teacher user for this demonstration
|
|
||||||
return {
|
return {
|
||||||
id: "user_teacher_123",
|
id: "user_teacher_123",
|
||||||
role: "teacher", // or "admin"
|
role: "teacher",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,17 +25,14 @@ async function ensureTeacher() {
|
|||||||
return user;
|
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]
|
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||||
|
|
||||||
async function insertQuestionWithRelations(
|
async function insertQuestionWithRelations(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
input: CreateQuestionInput,
|
input: z.infer<typeof CreateQuestionSchema>,
|
||||||
authorId: string,
|
authorId: string,
|
||||||
parentId: string | null = null
|
parentId: string | null = null
|
||||||
) {
|
) {
|
||||||
// We generate ID explicitly here.
|
|
||||||
const newQuestionId = createId();
|
const newQuestionId = createId();
|
||||||
|
|
||||||
await tx.insert(questions).values({
|
await tx.insert(questions).values({
|
||||||
@@ -47,7 +44,6 @@ async function insertQuestionWithRelations(
|
|||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Link Knowledge Points
|
|
||||||
if (input.knowledgePointIds && input.knowledgePointIds.length > 0) {
|
if (input.knowledgePointIds && input.knowledgePointIds.length > 0) {
|
||||||
await tx.insert(questionsToKnowledgePoints).values(
|
await tx.insert(questionsToKnowledgePoints).values(
|
||||||
input.knowledgePointIds.map((kpId) => ({
|
input.knowledgePointIds.map((kpId) => ({
|
||||||
@@ -57,7 +53,6 @@ async function insertQuestionWithRelations(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Handle Sub-Questions (Recursion)
|
|
||||||
if (input.subQuestions && input.subQuestions.length > 0) {
|
if (input.subQuestions && input.subQuestions.length > 0) {
|
||||||
for (const subQ of input.subQuestions) {
|
for (const subQ of input.subQuestions) {
|
||||||
await insertQuestionWithRelations(tx, subQ, authorId, newQuestionId);
|
await insertQuestionWithRelations(tx, subQ, authorId, newQuestionId);
|
||||||
@@ -67,25 +62,16 @@ async function insertQuestionWithRelations(
|
|||||||
return newQuestionId;
|
return newQuestionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Main Server Action ---
|
|
||||||
|
|
||||||
export async function createNestedQuestion(
|
export async function createNestedQuestion(
|
||||||
prevState: ActionState<string> | undefined,
|
prevState: ActionState<string> | undefined,
|
||||||
formData: FormData | CreateQuestionInput // Support both FormData and JSON input
|
formData: FormData | CreateQuestionInput
|
||||||
): Promise<ActionState<string>> {
|
): Promise<ActionState<string>> {
|
||||||
try {
|
try {
|
||||||
// 1. Auth Check
|
|
||||||
const user = await ensureTeacher();
|
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;
|
let rawInput: unknown = formData;
|
||||||
|
|
||||||
if (formData instanceof 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");
|
const jsonString = formData.get("json");
|
||||||
if (typeof jsonString === "string") {
|
if (typeof jsonString === "string") {
|
||||||
rawInput = JSON.parse(jsonString) as unknown;
|
rawInput = JSON.parse(jsonString) as unknown;
|
||||||
@@ -106,13 +92,11 @@ export async function createNestedQuestion(
|
|||||||
|
|
||||||
const input = validatedFields.data;
|
const input = validatedFields.data;
|
||||||
|
|
||||||
// 3. Database Transaction
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await insertQuestionWithRelations(tx, input, user.id, null);
|
await insertQuestionWithRelations(tx, input, user.id, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Revalidate Cache
|
revalidatePath("/teacher/questions");
|
||||||
revalidatePath("/questions");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -122,10 +106,7 @@ export async function createNestedQuestion(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create question:", error);
|
console.error("Failed to create question:", error);
|
||||||
|
|
||||||
// Drizzle/DB Error Handling (Generic)
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
// Check for specific DB errors (constraints, etc.)
|
|
||||||
// e.g., if (error.message.includes("Duplicate entry")) ...
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error.message || "Database error occurred",
|
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 { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Plus, Trash2, GripVertical } from "lucide-react"
|
import { Plus, Trash2, GripVertical } from "lucide-react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -34,19 +36,22 @@ import {
|
|||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
import { BaseQuestionSchema } from "../schema"
|
import { BaseQuestionSchema } from "../schema"
|
||||||
import { createNestedQuestion } from "../actions"
|
import { createNestedQuestion, updateQuestionAction } from "../actions"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Question } from "../types"
|
import { Question } from "../types"
|
||||||
|
|
||||||
// Extend schema for form usage (e.g. handling options for choice questions)
|
|
||||||
const QuestionFormSchema = BaseQuestionSchema.extend({
|
const QuestionFormSchema = BaseQuestionSchema.extend({
|
||||||
difficulty: z.number().min(1).max(5),
|
difficulty: z.number().min(1).max(5),
|
||||||
content: z.string().min(1, "Question content is required"),
|
content: z.string().min(1, "Question content is required"),
|
||||||
options: z.array(z.object({
|
options: z
|
||||||
label: z.string(),
|
.array(
|
||||||
value: z.string(),
|
z.object({
|
||||||
isCorrect: z.boolean().default(false)
|
label: z.string(),
|
||||||
})).optional(),
|
value: z.string(),
|
||||||
|
isCorrect: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type QuestionFormValues = z.input<typeof QuestionFormSchema>
|
type QuestionFormValues = z.input<typeof QuestionFormSchema>
|
||||||
@@ -57,7 +62,43 @@ interface CreateQuestionDialogProps {
|
|||||||
initialData?: Question | null
|
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) {
|
export function CreateQuestionDialog({ open, onOpenChange, initialData }: CreateQuestionDialogProps) {
|
||||||
|
const router = useRouter()
|
||||||
const [isPending, setIsPending] = useState(false)
|
const [isPending, setIsPending] = useState(false)
|
||||||
const isEdit = !!initialData
|
const isEdit = !!initialData
|
||||||
|
|
||||||
@@ -66,63 +107,101 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
type: initialData?.type || "single_choice",
|
type: initialData?.type || "single_choice",
|
||||||
difficulty: initialData?.difficulty || 1,
|
difficulty: initialData?.difficulty || 1,
|
||||||
content: (typeof initialData?.content === 'string' ? initialData.content : "") || "",
|
content: getInitialTextFromContent(initialData?.content),
|
||||||
options: [
|
options:
|
||||||
{ label: "Option A", value: "A", isCorrect: true },
|
getInitialOptionsFromContent(initialData?.content) ?? [
|
||||||
{ label: "Option B", value: "B", isCorrect: false },
|
{ label: "Option A", value: "A", isCorrect: true },
|
||||||
],
|
{ label: "Option B", value: "B", isCorrect: false },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reset form when initialData changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
form.reset({
|
form.reset({
|
||||||
type: initialData.type,
|
type: initialData.type,
|
||||||
difficulty: initialData.difficulty,
|
difficulty: initialData.difficulty,
|
||||||
content: typeof initialData.content === 'string' ? initialData.content : JSON.stringify(initialData.content),
|
content: getInitialTextFromContent(initialData.content),
|
||||||
options: [
|
options:
|
||||||
{ label: "Option A", value: "A", isCorrect: true },
|
getInitialOptionsFromContent(initialData.content) ?? [
|
||||||
{ label: "Option B", value: "B", isCorrect: false },
|
{ label: "Option A", value: "A", isCorrect: true },
|
||||||
]
|
{ label: "Option B", value: "B", isCorrect: false },
|
||||||
})
|
],
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
form.reset({
|
form.reset({
|
||||||
type: "single_choice",
|
type: "single_choice",
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
content: "",
|
content: "",
|
||||||
options: [
|
options: [
|
||||||
{ label: "Option A", value: "A", isCorrect: true },
|
{ label: "Option A", value: "A", isCorrect: true },
|
||||||
{ label: "Option B", value: "B", isCorrect: false },
|
{ label: "Option B", value: "B", isCorrect: false },
|
||||||
]
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [initialData, form])
|
}, [initialData, form, open])
|
||||||
|
|
||||||
const questionType = form.watch("type")
|
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) => {
|
const onSubmit: SubmitHandler<QuestionFormValues> = async (data) => {
|
||||||
setIsPending(true)
|
setIsPending(true)
|
||||||
try {
|
try {
|
||||||
|
if (isEdit && !initialData?.id) {
|
||||||
|
toast.error("Missing question id")
|
||||||
|
return
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
|
...(isEdit && initialData ? { id: initialData.id } : {}),
|
||||||
type: data.type,
|
type: data.type,
|
||||||
difficulty: data.difficulty,
|
difficulty: data.difficulty,
|
||||||
content: data.content,
|
content: buildContent(data),
|
||||||
knowledgePointIds: [],
|
knowledgePointIds: [],
|
||||||
}
|
}
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.set("json", JSON.stringify(payload))
|
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) {
|
if (res.success) {
|
||||||
toast.success(isEdit ? "Updated question" : "Created question")
|
toast.success(isEdit ? "Updated question" : "Created question")
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
|
router.refresh()
|
||||||
if (!isEdit) {
|
if (!isEdit) {
|
||||||
form.reset()
|
form.reset()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Operation failed")
|
toast.error(res.message || "Operation failed")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error("Unexpected error")
|
toast.error("Unexpected error")
|
||||||
} finally {
|
} finally {
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
@@ -148,7 +227,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Question Type</FormLabel>
|
<FormLabel>Question Type</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select type" />
|
<SelectValue placeholder="Select type" />
|
||||||
@@ -159,6 +238,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
|||||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||||
<SelectItem value="judgment">True/False</SelectItem>
|
<SelectItem value="judgment">True/False</SelectItem>
|
||||||
<SelectItem value="text">Short Answer</SelectItem>
|
<SelectItem value="text">Short Answer</SelectItem>
|
||||||
|
<SelectItem value="composite">Composite</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -173,8 +253,8 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Difficulty (1-5)</FormLabel>
|
<FormLabel>Difficulty (1-5)</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
|
value={String(field.value)}
|
||||||
onValueChange={(val) => field.onChange(parseInt(val))}
|
onValueChange={(val) => field.onChange(parseInt(val))}
|
||||||
defaultValue={String(field.value)}
|
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -219,53 +299,76 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
|||||||
{(questionType === "single_choice" || questionType === "multiple_choice") && (
|
{(questionType === "single_choice" || questionType === "multiple_choice") && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FormLabel>Options</FormLabel>
|
<FormLabel>Options</FormLabel>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentOptions = form.getValues("options") || [];
|
const currentOptions = form.getValues("options") || []
|
||||||
form.setValue("options", [
|
const nextIndex = currentOptions.length
|
||||||
...currentOptions,
|
const nextChar = nextIndex < 26 ? String.fromCharCode(65 + nextIndex) : String(nextIndex + 1)
|
||||||
{ label: `Option ${String.fromCharCode(65 + currentOptions.length)}`, value: String.fromCharCode(65 + currentOptions.length), isCorrect: false }
|
form.setValue("options", [
|
||||||
]);
|
...currentOptions,
|
||||||
}}
|
{
|
||||||
>
|
label: `Option ${nextChar}`,
|
||||||
<Plus className="mr-2 h-3 w-3" /> Add Option
|
value: nextChar,
|
||||||
</Button>
|
isCorrect: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-3 w-3" /> Add Option
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{form.watch("options")?.map((option, index) => (
|
{form.watch("options")?.map((option, index) => (
|
||||||
<div key={index} className="flex items-center gap-2">
|
<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">
|
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
|
||||||
<GripVertical className="h-4 w-4" />
|
<GripVertical className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Checkbox
|
||||||
value={option.label}
|
checked={option.isCorrect}
|
||||||
onChange={(e) => {
|
onCheckedChange={(checked) => {
|
||||||
const newOptions = [...(form.getValues("options") || [])];
|
const next = [...(form.getValues("options") || [])]
|
||||||
newOptions[index].label = e.target.value;
|
if (!next[index]) return
|
||||||
form.setValue("options", newOptions);
|
|
||||||
}}
|
const isChecked = checked === true
|
||||||
placeholder={`Option ${index + 1}`}
|
if (questionType === "single_choice" && isChecked) {
|
||||||
/>
|
for (let i = 0; i < next.length; i++) next[i].isCorrect = i === index
|
||||||
<Button
|
} else {
|
||||||
type="button"
|
next[index].isCorrect = isChecked
|
||||||
variant="ghost"
|
}
|
||||||
size="icon"
|
form.setValue("options", next)
|
||||||
className="text-destructive hover:text-destructive/90"
|
}}
|
||||||
onClick={() => {
|
aria-label="Mark correct"
|
||||||
const newOptions = [...(form.getValues("options") || [])];
|
/>
|
||||||
newOptions.splice(index, 1);
|
<Input
|
||||||
form.setValue("options", newOptions);
|
value={option.label}
|
||||||
}}
|
onChange={(e) => {
|
||||||
>
|
const next = [...(form.getValues("options") || [])]
|
||||||
<Trash2 className="h-4 w-4" />
|
if (!next[index]) return
|
||||||
</Button>
|
next[index].label = e.target.value
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -275,7 +378,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{isPending ? "Creating..." : "Create Question"}
|
{isPending ? (isEdit ? "Updating..." : "Creating...") : (isEdit ? "Update Question" : "Create Question")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { MoreHorizontal, Pencil, Trash, Eye, Copy } from "lucide-react"
|
import { MoreHorizontal, Pencil, Trash, Eye, Copy } from "lucide-react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
|
|
||||||
import { Question } from "../types"
|
import { Question } from "../types"
|
||||||
|
import { deleteQuestionAction } from "../actions"
|
||||||
import { CreateQuestionDialog } from "./create-question-dialog"
|
import { CreateQuestionDialog } from "./create-question-dialog"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ interface QuestionActionsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionActions({ question }: QuestionActionsProps) {
|
export function QuestionActions({ question }: QuestionActionsProps) {
|
||||||
|
const router = useRouter()
|
||||||
const [showEditDialog, setShowEditDialog] = useState(false)
|
const [showEditDialog, setShowEditDialog] = useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||||
@@ -52,13 +55,17 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
|||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
try {
|
try {
|
||||||
// Simulate API call
|
const fd = new FormData()
|
||||||
console.log("Deleting question:", question.id)
|
fd.set("id", question.id)
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
const res = await deleteQuestionAction(undefined, fd)
|
||||||
toast.success("Question deleted successfully")
|
if (res.success) {
|
||||||
setShowDeleteDialog(false)
|
toast.success("Question deleted successfully")
|
||||||
} catch (error) {
|
setShowDeleteDialog(false)
|
||||||
console.error(error)
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to delete question")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
toast.error("Failed to delete question")
|
toast.error("Failed to delete question")
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
@@ -95,14 +102,12 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Edit Dialog */}
|
|
||||||
<CreateQuestionDialog
|
<CreateQuestionDialog
|
||||||
open={showEditDialog}
|
open={showEditDialog}
|
||||||
onOpenChange={setShowEditDialog}
|
onOpenChange={setShowEditDialog}
|
||||||
initialData={question}
|
initialData={question}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@@ -128,7 +133,6 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* View Details Dialog (Simple Read-only View) */}
|
|
||||||
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -138,7 +142,7 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
|||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<span className="font-medium">Type:</span>
|
<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>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<span className="font-medium">Difficulty:</span>
|
<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">
|
<div className="grid grid-cols-4 items-start gap-4">
|
||||||
<span className="font-medium pt-1">Content:</span>
|
<span className="font-medium pt-1">Content:</span>
|
||||||
<div className="col-span-3 rounded-md bg-muted p-2 text-sm">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/* Show Author if exists */}
|
|
||||||
{question.author && (
|
{question.author && (
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<span className="font-medium">Author:</span>
|
<span className="font-medium">Author:</span>
|
||||||
<span className="col-span-3">{question.author.name || "Unknown"}</span>
|
<span className="col-span-3">{question.author.name || "Unknown"}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Show Knowledge Points */}
|
|
||||||
{question.knowledgePoints && question.knowledgePoints.length > 0 && (
|
{question.knowledgePoints && question.knowledgePoints.length > 0 && (
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<span className="font-medium">Tags:</span>
|
<span className="font-medium">Tags:</span>
|
||||||
|
|||||||
@@ -5,34 +5,38 @@ import { ColumnDef } from "@tanstack/react-table"
|
|||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
import { Question, QuestionType } from "../types"
|
import { Question, QuestionType } from "../types"
|
||||||
import { cn } from "@/shared/lib/utils"
|
|
||||||
import { QuestionActions } from "./question-actions"
|
import { QuestionActions } from "./question-actions"
|
||||||
|
|
||||||
// Helper for Type Colors
|
|
||||||
const getTypeColor = (type: QuestionType) => {
|
const getTypeColor = (type: QuestionType) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "single_choice":
|
case "single_choice":
|
||||||
return "default"; // Primary
|
return "default"
|
||||||
case "multiple_choice":
|
case "multiple_choice":
|
||||||
return "secondary";
|
return "secondary"
|
||||||
case "judgment":
|
case "judgment":
|
||||||
return "outline";
|
return "outline"
|
||||||
case "text":
|
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:
|
default:
|
||||||
return "secondary";
|
return "secondary"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTypeLabel = (type: QuestionType) => {
|
const getTypeLabel = (type: QuestionType) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "single_choice": return "Single Choice";
|
case "single_choice":
|
||||||
case "multiple_choice": return "Multiple Choice";
|
return "Single Choice"
|
||||||
case "judgment": return "True/False";
|
case "multiple_choice":
|
||||||
case "text": return "Short Answer";
|
return "Multiple Choice"
|
||||||
case "composite": return "Composite";
|
case "judgment":
|
||||||
default: return type;
|
return "True/False"
|
||||||
}
|
case "text":
|
||||||
|
return "Short Answer"
|
||||||
|
case "composite":
|
||||||
|
return "Composite"
|
||||||
|
default:
|
||||||
|
return type
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const columns: ColumnDef<Question>[] = [
|
export const columns: ColumnDef<Question>[] = [
|
||||||
@@ -71,13 +75,19 @@ export const columns: ColumnDef<Question>[] = [
|
|||||||
accessorKey: "content",
|
accessorKey: "content",
|
||||||
header: "Content",
|
header: "Content",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const content = row.getValue("content");
|
const content = row.getValue("content") as unknown
|
||||||
let preview = "";
|
let preview = ""
|
||||||
if (typeof content === 'string') {
|
if (typeof content === "string") {
|
||||||
preview = content;
|
preview = content
|
||||||
} else if (content && typeof content === 'object') {
|
} else if (content && typeof content === "object") {
|
||||||
preview = JSON.stringify(content).slice(0, 50);
|
const text = (content as { text?: unknown }).text
|
||||||
|
if (typeof text === "string") {
|
||||||
|
preview = text
|
||||||
|
} else {
|
||||||
|
preview = JSON.stringify(content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
preview = preview.slice(0, 80)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[400px] truncate font-medium" title={preview}>
|
<div className="max-w-[400px] truncate font-medium" title={preview}>
|
||||||
@@ -90,17 +100,23 @@ export const columns: ColumnDef<Question>[] = [
|
|||||||
accessorKey: "difficulty",
|
accessorKey: "difficulty",
|
||||||
header: "Difficulty",
|
header: "Difficulty",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const diff = row.getValue("difficulty") as number;
|
const diff = row.getValue("difficulty") as number
|
||||||
// 1-5 scale
|
const label =
|
||||||
|
diff === 1
|
||||||
|
? "Easy"
|
||||||
|
: diff === 2
|
||||||
|
? "Easy-Med"
|
||||||
|
: diff === 3
|
||||||
|
? "Medium"
|
||||||
|
: diff === 4
|
||||||
|
? "Med-Hard"
|
||||||
|
: "Hard"
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn("font-medium",
|
<Badge variant="outline" className="tabular-nums">
|
||||||
diff <= 2 ? "text-green-600" :
|
{label}
|
||||||
diff === 3 ? "text-yellow-600" : "text-red-600"
|
</Badge>
|
||||||
)}>
|
<span className="text-xs text-muted-foreground tabular-nums">({diff})</span>
|
||||||
{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>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -109,22 +125,24 @@ export const columns: ColumnDef<Question>[] = [
|
|||||||
accessorKey: "knowledgePoints",
|
accessorKey: "knowledgePoints",
|
||||||
header: "Knowledge Points",
|
header: "Knowledge Points",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const kps = row.original.knowledgePoints;
|
const kps = row.original.knowledgePoints
|
||||||
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>;
|
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{kps.slice(0, 2).map(kp => (
|
{kps.slice(0, 2).map((kp) => (
|
||||||
<Badge key={kp.id} variant="outline" className="text-xs">
|
<Badge key={kp.id} variant="outline" className="text-xs">
|
||||||
{kp.name}
|
{kp.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{kps.length > 2 && (
|
{kps.length > 2 && (
|
||||||
<Badge variant="outline" className="text-xs">+{kps.length - 2}</Badge>
|
<Badge variant="outline" className="text-xs">
|
||||||
)}
|
+{kps.length - 2}
|
||||||
</div>
|
</Badge>
|
||||||
)
|
)}
|
||||||
}
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
@@ -132,7 +150,7 @@ export const columns: ColumnDef<Question>[] = [
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||||
{new Date(row.getValue("createdAt")).toLocaleDateString()}
|
{new Date(row.getValue("createdAt")).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ export function QuestionFilters() {
|
|||||||
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
|
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
|
||||||
const [difficulty, setDifficulty] = useQueryState("difficulty", 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 (
|
return (
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<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">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
@@ -44,6 +40,7 @@ export function QuestionFilters() {
|
|||||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||||
<SelectItem value="judgment">True/False</SelectItem>
|
<SelectItem value="judgment">True/False</SelectItem>
|
||||||
<SelectItem value="text">Short Answer</SelectItem>
|
<SelectItem value="text">Short Answer</SelectItem>
|
||||||
|
<SelectItem value="composite">Composite</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={difficulty} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
<Select value={difficulty} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
||||||
|
|||||||
@@ -2,41 +2,52 @@ import 'server-only';
|
|||||||
|
|
||||||
import { db } from "@/shared/db";
|
import { db } from "@/shared/db";
|
||||||
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
|
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 { cache } from "react";
|
||||||
|
import type { Question, QuestionType } from "./types";
|
||||||
|
|
||||||
// Types for filters
|
|
||||||
export type GetQuestionsParams = {
|
export type GetQuestionsParams = {
|
||||||
|
q?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
ids?: string[];
|
ids?: string[];
|
||||||
knowledgePointId?: string;
|
knowledgePointId?: string;
|
||||||
|
type?: QuestionType;
|
||||||
difficulty?: number;
|
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 ({
|
export const getQuestions = cache(async ({
|
||||||
|
q,
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 10,
|
pageSize = 50,
|
||||||
ids,
|
ids,
|
||||||
knowledgePointId,
|
knowledgePointId,
|
||||||
|
type,
|
||||||
difficulty,
|
difficulty,
|
||||||
}: GetQuestionsParams = {}) => {
|
}: GetQuestionsParams = {}) => {
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
// Build Where Conditions
|
const conditions: SQL[] = [];
|
||||||
const conditions = [];
|
|
||||||
|
|
||||||
if (ids && ids.length > 0) {
|
if (ids && ids.length > 0) {
|
||||||
conditions.push(inArray(questions.id, ids));
|
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) {
|
if (difficulty) {
|
||||||
conditions.push(eq(questions.difficulty, difficulty));
|
conditions.push(eq(questions.difficulty, difficulty));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by Knowledge Point (using subquery pattern for Many-to-Many)
|
|
||||||
if (knowledgePointId) {
|
if (knowledgePointId) {
|
||||||
const subQuery = db
|
const subQuery = db
|
||||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||||
@@ -52,29 +63,24 @@ export const getQuestions = cache(async ({
|
|||||||
|
|
||||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
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
|
const [totalResult] = await db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(questions)
|
.from(questions)
|
||||||
.where(whereClause);
|
.where(whereClause);
|
||||||
|
|
||||||
const total = totalResult?.count ?? 0;
|
const total = Number(totalResult?.count ?? 0);
|
||||||
|
|
||||||
// 2. Get Data with Relations
|
const rows = await db.query.questions.findMany({
|
||||||
const data = await db.query.questions.findMany({
|
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
orderBy: [desc(questions.createdAt)],
|
orderBy: [desc(questions.createdAt)],
|
||||||
with: {
|
with: {
|
||||||
// Preload Knowledge Points
|
|
||||||
questionsToKnowledgePoints: {
|
questionsToKnowledgePoints: {
|
||||||
with: {
|
with: {
|
||||||
knowledgePoint: true,
|
knowledgePoint: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Preload Author
|
|
||||||
author: {
|
author: {
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -82,13 +88,37 @@ export const getQuestions = cache(async ({
|
|||||||
image: true,
|
image: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Preload Child Questions (first level)
|
|
||||||
children: true,
|
children: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
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: {
|
meta: {
|
||||||
page,
|
page,
|
||||||
pageSize,
|
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({
|
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,
|
type: QuestionTypeEnum,
|
||||||
difficulty: z.number().min(1).max(5).default(1),
|
difficulty: z.number().min(1).max(5).default(1),
|
||||||
knowledgePointIds: z.array(z.string()).optional(),
|
knowledgePointIds: z.array(z.string()).optional(),
|
||||||
});
|
})
|
||||||
|
|
||||||
// Recursive Schema for Nested Questions (e.g. Composite -> Sub Questions)
|
|
||||||
export type CreateQuestionInput = z.infer<typeof BaseQuestionSchema> & {
|
export type CreateQuestionInput = z.infer<typeof BaseQuestionSchema> & {
|
||||||
subQuestions?: CreateQuestionInput[];
|
subQuestions?: CreateQuestionInput[]
|
||||||
};
|
}
|
||||||
|
|
||||||
export const CreateQuestionSchema: z.ZodType<CreateQuestionInput> = BaseQuestionSchema.extend({
|
export const CreateQuestionSchema: z.ZodType<CreateQuestionInput> = BaseQuestionSchema.extend({
|
||||||
subQuestions: z.lazy(() => CreateQuestionSchema.array().optional()),
|
subQuestions: z.lazy(() => CreateQuestionSchema.array().optional()),
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod"
|
||||||
import { QuestionTypeEnum } from "./schema";
|
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 {
|
export interface Question {
|
||||||
id: string;
|
id: string
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
content: unknown
|
||||||
content: any; // Rich text content
|
type: QuestionType
|
||||||
type: QuestionType;
|
difficulty: number
|
||||||
difficulty: number;
|
createdAt: Date
|
||||||
createdAt: Date;
|
updatedAt: Date
|
||||||
updatedAt: Date;
|
|
||||||
author: {
|
author: {
|
||||||
id: string;
|
id: string
|
||||||
name: string | null;
|
name: string | null
|
||||||
image: string | null;
|
image: string | null
|
||||||
} | null;
|
} | null
|
||||||
knowledgePoints: {
|
knowledgePoints: {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
}[];
|
}[]
|
||||||
// For UI display
|
childrenCount?: number
|
||||||
childrenCount?: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user