feat: exam actions and data safety fixes

This commit is contained in:
SpecialX
2025-12-30 17:48:22 +08:00
parent e7c902e8e1
commit f7ff018490
27 changed files with 896 additions and 194 deletions

View File

@@ -12,7 +12,7 @@
- **SubmissionAnswers**: 链接到特定题目的单个答案。
### 2.2 `structure` 字段
为了支持层级布局(如章节/分组),我们在 `exams` 表中引入了一个 JSON 列 `structure`作为考试布局的“单一事实来源Source of Truth
为了支持层级布局(如章节/分组),我们在 `exams` 表中引入了一个 JSON 列 `structure`作为“布局/呈现层”的单一事实来源Source of Truth,用于渲染分组与排序;而 `exam_questions` 仍然承担题目关联、外键完整性与索引查询职责
**JSON Schema:**
```typescript
@@ -26,6 +26,9 @@ type ExamNode = {
}
```
### 2.3 `description` 元数据字段(当前实现)
当前版本将部分元数据(如 `subject/grade/difficulty/totalScore/durationMin/tags/scheduledAt`)以 JSON 字符串形式存入 `exams.description`,并在数据访问层解析后提供给列表页展示与筛选。
## 3. 组件架构
### 3.1 组卷(构建器)
@@ -52,6 +55,13 @@ type ExamNode = {
- **状态**: 在提交前管理本地更改。
- **Actions**: `gradeSubmissionAction` 更新 `submissionAnswers` 并将总分聚合到 `examSubmissions`
### 3.3 列表页All Exams
位于 `/teacher/exams/all`
- **Page (RSC)**: 负责解析 query`q/status/difficulty`)并调用数据访问层获取 exams。
- **`ExamFilters` (客户端组件)**: 使用 URL query 驱动筛选条件。
- **`ExamDataTable` (客户端组件)**: 基于 TanStack Table 渲染列表,并在 actions 列中渲染 `ExamActions`
## 4. 关键工作流
### 4.1 创建与构建考试
@@ -72,6 +82,27 @@ type ExamNode = {
- 教师输入分数(上限为满分)和反馈。
4. **提交**: 服务器更新单个答案记录并重新计算提交总分。
### 4.3 考试管理All Exams Actions
位于 `/teacher/exams/all` 的表格行级菜单。
1. **Publish / Move to Draft / Archive**
- 客户端组件 `ExamActions` 触发 `updateExamAction`,传入 `examId` 与目标 `status`
- 服务器更新 `exams.status`,并对 `/teacher/exams/all` 执行缓存再验证。
2. **Duplicate**
- 客户端组件 `ExamActions` 触发 `duplicateExamAction`,传入 `examId`
- 服务器复制 `exams` 记录并复制关联的 `exam_questions`
- 新考试以 `draft` 状态创建,复制结构(`exams.structure`),并清空排期信息(`startTime/endTime`,以及 description 中的 `scheduledAt`)。
- 成功后跳转到新考试的构建页 `/teacher/exams/[id]/build`
3. **Delete**
- 客户端组件 `ExamActions` 触发 `deleteExamAction`,传入 `examId`
- 服务器删除 `exams` 记录;相关表(如 `exam_questions``exam_submissions``submission_answers`)通过外键级联删除。
- 成功后刷新列表。
4. **Edit / Build**
- 当前统一跳转到 `/teacher/exams/[id]/build`
## 5. 技术决策
### 5.1 混合存储策略
@@ -87,4 +118,46 @@ type ExamNode = {
- 面向未来(现代 React Hooks 模式)。
### 5.3 Server Actions
所有变更操作(保存草稿、发布、评分)均使用 Next.js Server Actions以确保类型安全并自动重新验证缓存。
所有变更操作(保存草稿、发布、复制、删除、评分)均使用 Next.js Server Actions以确保类型安全并自动重新验证缓存。
已落地的 Server Actions
- `createExamAction`
- `updateExamAction`
- `duplicateExamAction`
- `deleteExamAction`
- `gradeSubmissionAction`
## 6. 接口与数据影响
### 6.1 `updateExamAction`
- **入参FormData**: `examId`(必填),`status`可选draft/published/archived`questionsJson`(可选),`structureJson`(可选)
- **行为**:
- 若传入 `questionsJson`:先清空 `exam_questions` 再批量写入,`order` 由数组顺序决定;未传入则不触碰 `exam_questions`
- 若传入 `structureJson`:写入 `exams.structure`;未传入则不更新该字段
- 若传入 `status`:写入 `exams.status`
- **缓存**: `revalidatePath("/teacher/exams/all")`
### 6.2 `duplicateExamAction`
- **入参FormData**: `examId`(必填)
- **行为**:
- 复制一条 `exams`(新 id、新 title追加 “(Copy)”、`status` 强制为 `draft`
- `startTime/endTime` 置空;同时尝试从 `description` JSON 中移除 `scheduledAt`
- 复制 `exam_questions`(保留 questionId/score/order
- 复制 `exams.structure`
- **缓存**: `revalidatePath("/teacher/exams/all")`
### 6.3 `deleteExamAction`
- **入参FormData**: `examId`(必填)
- **行为**:
- 删除 `exams` 记录
- 依赖外键级联清理关联数据:`exam_questions``exam_submissions``submission_answers`
- **缓存**:
- `revalidatePath("/teacher/exams/all")`
- `revalidatePath("/teacher/exams/grading")`
### 6.4 数据访问层Data Access
位于 `src/modules/exams/data-access.ts`,对外提供与页面/组件解耦的查询函数。
- `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤
- `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate
- `getExamSubmissions()`: 为阅卷列表提供 submissions 数据

View File

@@ -16,8 +16,18 @@ async function reset() {
`)
// Drop each table
for (const row of (tables[0] as unknown as any[])) {
const tableName = row.TABLE_NAME || row.table_name
const rows = (tables as unknown as [unknown])[0]
if (!Array.isArray(rows)) return
for (const row of rows) {
const record = row as Record<string, unknown>
const tableName =
typeof record.TABLE_NAME === "string"
? record.TABLE_NAME
: typeof record.table_name === "string"
? record.table_name
: null
if (!tableName) continue
console.log(`Dropping table: ${tableName}`)
await db.execute(sql.raw(`DROP TABLE IF EXISTS \`${tableName}\`;`))
}

View File

@@ -80,7 +80,12 @@ async function seed() {
const qId = createId()
questionIds.push(qId)
const type = faker.helpers.arrayElement(["single_choice", "multiple_choice", "text", "judgment"])
const type = faker.helpers.arrayElement([
"single_choice",
"multiple_choice",
"text",
"judgment",
] as const)
await db.insert(questions).values({
id: qId,
@@ -93,7 +98,7 @@ async function seed() {
{ id: "D", text: faker.lorem.sentence(), isCorrect: false },
] : undefined
},
type: type as any,
type,
difficulty: faker.helpers.arrayElement(DIFFICULTY),
authorId: teacherId,
})
@@ -105,7 +110,7 @@ async function seed() {
const examId = createId()
const subject = faker.helpers.arrayElement(SUBJECTS)
const grade = faker.helpers.arrayElement(GRADES)
const status = faker.helpers.arrayElement(["draft", "published", "archived"])
const status = faker.helpers.arrayElement(["draft", "published", "archived"] as const)
const scheduledAt = faker.date.soon({ days: 30 })
@@ -126,7 +131,7 @@ async function seed() {
description: JSON.stringify(meta),
creatorId: teacherId,
startTime: scheduledAt,
status: status as any,
status,
})
// Link some questions to this exam (random 5 questions)

View File

@@ -5,6 +5,11 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
"react-hooks/incompatible-library": "off",
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:

View File

@@ -211,7 +211,7 @@ async function seed() {
creatorId: "user_teacher_math",
status: "published",
startTime: new Date(),
structure: examStructure as any // Bypass strict typing for seed
structure: examStructure as unknown
});
// Link questions physically (Source of Truth)

View File

@@ -16,38 +16,93 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
// In a real app, this might be paginated or filtered by exam subject/grade
const { data: questionsData } = await getQuestions({ pageSize: 100 })
const questionOptions: Question[] = questionsData.map((q) => ({
id: q.id,
content: q.content as any,
type: q.type as any,
difficulty: q.difficulty ?? 1,
createdAt: new Date(q.createdAt),
updatedAt: new Date(q.updatedAt),
author: q.author ? {
id: q.author.id,
name: q.author.name || "Unknown",
image: q.author.image || null
} : null,
knowledgePoints: (q.questionsToKnowledgePoints || []).map((kp) => ({
id: kp.knowledgePoint.id,
name: kp.knowledgePoint.name
}))
}))
const initialSelected = (exam.questions || []).map(q => ({
id: q.id,
score: q.score || 0
}))
// Prepare initialStructure on server side to avoid hydration mismatch with random IDs
let initialStructure: ExamNode[] = exam.structure as ExamNode[] || []
const selectedQuestionIds = initialSelected.map((s) => s.id)
const { data: selectedQuestionsData } = selectedQuestionIds.length
? await getQuestions({ ids: selectedQuestionIds, pageSize: Math.max(10, selectedQuestionIds.length) })
: { data: [] as typeof questionsData }
type RawQuestion = (typeof questionsData)[number]
const toQuestionOption = (q: RawQuestion): Question => ({
id: q.id,
content: q.content as Question["content"],
type: q.type as Question["type"],
difficulty: q.difficulty ?? 1,
createdAt: new Date(q.createdAt),
updatedAt: new Date(q.updatedAt),
author: q.author
? {
id: q.author.id,
name: q.author.name || "Unknown",
image: q.author.image || null,
}
: null,
knowledgePoints:
q.questionsToKnowledgePoints?.map((kp) => ({
id: kp.knowledgePoint.id,
name: kp.knowledgePoint.name,
})) ?? [],
})
const questionOptionsById = new Map<string, Question>()
for (const q of questionsData) questionOptionsById.set(q.id, toQuestionOption(q))
for (const q of selectedQuestionsData) questionOptionsById.set(q.id, toQuestionOption(q))
const questionOptions = Array.from(questionOptionsById.values())
const normalizeStructure = (nodes: unknown): ExamNode[] => {
const seen = new Set<string>()
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null
const normalize = (raw: unknown[]): ExamNode[] => {
return raw
.map((n) => {
if (!isRecord(n)) return null
const type = n.type
if (type !== "group" && type !== "question") return null
let id = typeof n.id === "string" && n.id.length > 0 ? n.id : createId()
while (seen.has(id)) id = createId()
seen.add(id)
if (type === "group") {
return {
id,
type: "group",
title: typeof n.title === "string" ? n.title : undefined,
children: normalize(Array.isArray(n.children) ? n.children : []),
} satisfies ExamNode
}
if (typeof n.questionId !== "string" || n.questionId.length === 0) return null
return {
id,
type: "question",
questionId: n.questionId,
score: typeof n.score === "number" ? n.score : undefined,
} satisfies ExamNode
})
.filter(Boolean) as ExamNode[]
}
if (!Array.isArray(nodes)) return []
return normalize(nodes)
}
let initialStructure: ExamNode[] = normalizeStructure(exam.structure)
if (initialStructure.length === 0 && initialSelected.length > 0) {
initialStructure = initialSelected.map(s => ({
id: createId(), // Generate stable ID on server
type: 'question',
initialStructure = initialSelected.map((s) => ({
id: createId(),
type: "question",
questionId: s.id,
score: s.score
score: s.score,
}))
}

View File

@@ -1,24 +1,137 @@
import { Suspense } from "react"
import Link from "next/link"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { ExamDataTable } from "@/modules/exams/components/exam-data-table"
import { examColumns } from "@/modules/exams/components/exam-columns"
import { ExamFilters } from "@/modules/exams/components/exam-filters"
import { getExams } from "@/modules/exams/data-access"
import { FileText, PlusCircle } from "lucide-react"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
async function ExamsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const q = getParam(params, "q")
const status = getParam(params, "status")
const difficulty = getParam(params, "difficulty")
const exams = await getExams({
q,
status,
difficulty,
})
const hasFilters = Boolean(q || (status && status !== "all") || (difficulty && difficulty !== "all"))
const counts = exams.reduce(
(acc, e) => {
acc.total += 1
if (e.status === "draft") acc.draft += 1
if (e.status === "published") acc.published += 1
if (e.status === "archived") acc.archived += 1
return acc
},
{ total: 0, draft: 0, published: 0, archived: 0 }
)
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-md border bg-card px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Showing</span>
<span className="text-sm font-medium">{counts.total}</span>
<span className="text-sm text-muted-foreground">exams</span>
<Badge variant="outline" className="ml-0 md:ml-2">
Draft {counts.draft}
</Badge>
<Badge variant="outline">Published {counts.published}</Badge>
<Badge variant="outline">Archived {counts.archived}</Badge>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/teacher/exams/grading">Go to Grading</Link>
</Button>
<Button asChild size="sm">
<Link href="/teacher/exams/create" className="inline-flex items-center gap-2">
<PlusCircle className="h-4 w-4" />
Create Exam
</Link>
</Button>
</div>
</div>
{exams.length === 0 ? (
<EmptyState
icon={FileText}
title={hasFilters ? "No exams match your filters" : "No exams yet"}
description={
hasFilters
? "Try clearing filters or adjusting keywords."
: "Create your first exam to start assigning and grading."
}
action={
hasFilters
? {
label: "Clear filters",
href: "/teacher/exams/all",
}
: {
label: "Create Exam",
href: "/teacher/exams/create",
}
}
className="h-[360px] bg-card"
/>
) : (
<ExamDataTable columns={examColumns} data={exams} />
)}
</div>
)
}
function ExamsResultsFallback() {
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-md border bg-card px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-4 w-[160px]" />
<Skeleton className="h-5 w-[92px]" />
<Skeleton className="h-5 w-[112px]" />
<Skeleton className="h-5 w-[106px]" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-[120px]" />
<Skeleton className="h-9 w-[132px]" />
</div>
</div>
<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>
)
}
export default async function AllExamsPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
searchParams: Promise<SearchParams>
}) {
const params = await searchParams
const exams = await getExams({
q: params.q as string,
status: params.status as string,
difficulty: params.difficulty as string,
})
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">
@@ -26,11 +139,6 @@ export default async function AllExamsPage({
<h2 className="text-2xl font-bold tracking-tight">All Exams</h2>
<p className="text-muted-foreground">View and manage all your exams.</p>
</div>
<div className="flex items-center space-x-2">
<Button asChild>
<Link href="/teacher/exams/create">Create Exam</Link>
</Button>
</div>
</div>
<div className="space-y-4">
@@ -38,9 +146,9 @@ export default async function AllExamsPage({
<ExamFilters />
</Suspense>
<div className="rounded-md border bg-card">
<ExamDataTable columns={examColumns} data={exams} />
</div>
<Suspense fallback={<ExamsResultsFallback />}>
<ExamsResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)

View File

@@ -8,7 +8,7 @@ import { Label } from "@/shared/components/ui/label"
import { cn } from "@/shared/lib/utils"
import { Loader2, Github } from "lucide-react"
interface LoginFormProps extends React.HTMLAttributes<HTMLDivElement> {}
type LoginFormProps = React.HTMLAttributes<HTMLDivElement>
export function LoginForm({ className, ...props }: LoginFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(false)

View File

@@ -8,7 +8,7 @@ import { Label } from "@/shared/components/ui/label"
import { cn } from "@/shared/lib/utils"
import { Loader2, Github } from "lucide-react"
interface RegisterFormProps extends React.HTMLAttributes<HTMLDivElement> {}
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement>
export function RegisterForm({ className, ...props }: RegisterFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(false)

View File

@@ -40,9 +40,9 @@ export function TeacherSchedule() {
const hasSchedule = MOCK_SCHEDULE.length > 0;
return (
<Card className="col-span-3">
<Card className="col-span-3">
<CardHeader>
<CardTitle>Today's Schedule</CardTitle>
<CardTitle>Today&apos;s Schedule</CardTitle>
</CardHeader>
<CardContent>
{!hasSchedule ? (

View File

@@ -101,8 +101,8 @@ const ExamUpdateSchema = z.object({
score: z.coerce.number().int().min(0),
})
)
.default([]),
structure: z.any().optional(), // Accept structure JSON
.optional(),
structure: z.unknown().optional(),
status: z.enum(["draft", "published", "archived"]).optional(),
})
@@ -110,13 +110,15 @@ export async function updateExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const rawQuestions = formData.get("questionsJson") as string | null
const rawStructure = formData.get("structureJson") as string | null
const rawQuestions = formData.get("questionsJson")
const rawStructure = formData.get("structureJson")
const hasQuestions = typeof rawQuestions === "string"
const hasStructure = typeof rawStructure === "string"
const parsed = ExamUpdateSchema.safeParse({
examId: formData.get("examId"),
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
structure: rawStructure ? JSON.parse(rawStructure) : undefined,
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
status: formData.get("status") ?? undefined,
})
@@ -131,22 +133,24 @@ export async function updateExamAction(
const { examId, questions, structure, status } = parsed.data
try {
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
if (questions.length > 0) {
await db.insert(examQuestions).values(
questions.map((q, idx) => ({
examId,
questionId: q.id,
score: q.score ?? 0,
order: idx,
}))
)
if (questions) {
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
if (questions.length > 0) {
await db.insert(examQuestions).values(
questions.map((q, idx) => ({
examId,
questionId: q.id,
score: q.score ?? 0,
order: idx,
}))
)
}
}
// Prepare update object
const updateData: any = {}
const updateData: Partial<typeof exams.$inferInsert> = {}
if (status) updateData.status = status
if (structure) updateData.structure = structure
if (structure !== undefined) updateData.structure = structure
if (Object.keys(updateData).length > 0) {
await db.update(exams).set(updateData).where(eq(exams.id, examId))
@@ -169,6 +173,143 @@ export async function updateExamAction(
}
}
const ExamDeleteSchema = z.object({
examId: z.string().min(1),
})
export async function deleteExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const parsed = ExamDeleteSchema.safeParse({
examId: formData.get("examId"),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid delete data",
errors: parsed.error.flatten().fieldErrors,
}
}
const { examId } = parsed.data
try {
await db.delete(exams).where(eq(exams.id, examId))
} catch (error) {
console.error("Failed to delete exam:", error)
return {
success: false,
message: "Database error: Failed to delete exam",
}
}
revalidatePath("/teacher/exams/all")
revalidatePath("/teacher/exams/grading")
return {
success: true,
message: "Exam deleted",
data: examId,
}
}
const ExamDuplicateSchema = z.object({
examId: z.string().min(1),
})
const omitScheduledAtFromDescription = (description: string | null) => {
if (!description) return null
try {
const parsed: unknown = JSON.parse(description)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return description
const meta = parsed as Record<string, unknown>
if ("scheduledAt" in meta) delete meta.scheduledAt
return JSON.stringify(meta)
} catch {
return description
}
}
export async function duplicateExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const parsed = ExamDuplicateSchema.safeParse({
examId: formData.get("examId"),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid duplicate data",
errors: parsed.error.flatten().fieldErrors,
}
}
const { examId } = parsed.data
const source = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
},
},
})
if (!source) {
return {
success: false,
message: "Exam not found",
}
}
const newExamId = createId()
const user = await getCurrentUser()
try {
await db.transaction(async (tx) => {
await tx.insert(exams).values({
id: newExamId,
title: `${source.title} (Copy)`,
description: omitScheduledAtFromDescription(source.description),
creatorId: user?.id ?? "user_teacher_123",
startTime: null,
endTime: null,
status: "draft",
structure: source.structure,
})
if (source.questions.length > 0) {
await tx.insert(examQuestions).values(
source.questions.map((q) => ({
examId: newExamId,
questionId: q.questionId,
score: q.score ?? 0,
order: q.order ?? 0,
}))
)
}
})
} catch (error) {
console.error("Failed to duplicate exam:", error)
return {
success: false,
message: "Database error: Failed to duplicate exam",
}
}
revalidatePath("/teacher/exams/all")
return {
success: true,
message: "Exam duplicated",
data: newExamId,
}
}
const GradingSchema = z.object({
submissionId: z.string().min(1),
answers: z.array(z.object({

View File

@@ -0,0 +1,141 @@
"use client"
import React from "react"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import { Eye, Printer } from "lucide-react"
import type { ExamNode } from "./selected-question-list"
type ChoiceOption = {
id: string
text: string
}
type QuestionContent = {
text?: string
options?: ChoiceOption[]
}
type ExamPaperPreviewProps = {
title: string
subject: string
grade: string
durationMin: number
totalScore: number
nodes: ExamNode[]
}
export function ExamPaperPreview({ title, subject, grade, durationMin, totalScore, nodes }: ExamPaperPreviewProps) {
// Helper to flatten questions for continuous numbering
let questionCounter = 0
const renderNode = (node: ExamNode, depth: number = 0) => {
if (node.type === 'group') {
return (
<div key={node.id} className="space-y-4 mb-6">
<div className="flex items-center gap-2">
<h3 className={`font-bold ${depth === 0 ? 'text-lg' : 'text-md'} text-foreground/90`}>
{node.title || "Section"}
</h3>
{/* Optional: Show section score if needed */}
</div>
<div className="pl-0">
{node.children?.map(child => renderNode(child, depth + 1))}
</div>
</div>
)
}
if (node.type === 'question' && node.question) {
questionCounter++
const q = node.question
const content = q.content as QuestionContent
return (
<div key={node.id} className="mb-6 break-inside-avoid">
<div className="flex gap-2">
<span className="font-semibold text-foreground min-w-[24px]">{questionCounter}.</span>
<div className="flex-1 space-y-2">
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
{content.text ?? ""}
<span className="text-muted-foreground text-sm ml-2">({node.score})</span>
</div>
{/* Options for Choice Questions */}
{(q.type === 'single_choice' || q.type === 'multiple_choice') && content.options && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-2 gap-x-4 mt-2 pl-2">
{content.options.map((opt) => (
<div key={opt.id} className="flex gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<span className="font-medium">{opt.id}.</span>
<span>{opt.text}</span>
</div>
))}
</div>
)}
{/* Space for written answers */}
{q.type === 'text' && (
<div className="mt-4 h-24 border-b border-dashed border-muted-foreground/30 w-full"></div>
)}
</div>
</div>
</div>
)
}
return null
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary" size="sm" className="gap-2">
<Eye className="h-4 w-4" />
Preview Exam
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="p-6 pb-2 border-b shrink-0">
<div className="flex items-center justify-between">
<DialogTitle>Exam Preview</DialogTitle>
<Button variant="outline" size="sm" onClick={() => window.print()} className="hidden">
<Printer className="h-4 w-4 mr-2" />
Print
</Button>
</div>
</DialogHeader>
<ScrollArea className="flex-1 p-8 bg-white/50 dark:bg-zinc-950/50">
<div className="max-w-3xl mx-auto bg-card shadow-sm border p-12 min-h-[1000px] print:shadow-none print:border-none">
{/* Header */}
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
<div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
<span>Subject: {subject}</span>
<span>Grade: {grade}</span>
<span>Time: {durationMin} mins</span>
<span>Total: {totalScore} pts</span>
</div>
<div className="flex justify-center gap-12 text-sm pt-4">
<div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
</div>
</div>
{/* Content */}
<div className="space-y-2">
{nodes.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
Empty Exam Paper
</div>
) : (
nodes.map(node => renderNode(node))
)}
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -5,7 +5,6 @@ import {
DndContext,
pointerWithin,
rectIntersection,
getFirstCollision,
CollisionDetection,
KeyboardSensor,
PointerSensor,
@@ -34,7 +33,6 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/co
import { Trash2, GripVertical, ChevronDown, ChevronRight, Calculator } from "lucide-react"
import { cn } from "@/shared/lib/utils"
import type { ExamNode } from "./selected-question-list"
import type { Question } from "@/modules/questions/types"
// --- Types ---
@@ -47,6 +45,15 @@ type StructureEditorProps = {
onAddGroup: () => void
}
function cloneExamNodes(nodes: ExamNode[]): ExamNode[] {
return nodes.map((node) => {
if (node.type === "group") {
return { ...node, children: cloneExamNodes(node.children || []) }
}
return { ...node }
})
}
// --- Components ---
function SortableItem({
@@ -201,10 +208,20 @@ function StructureRenderer({ nodes, ...props }: {
onScoreChange: (id: string, score: number) => void
onGroupTitleChange: (id: string, title: string) => void
}) {
// Deduplicate nodes to prevent React key errors
const uniqueNodes = useMemo(() => {
const seen = new Set()
return nodes.filter(n => {
if (seen.has(n.id)) return false
seen.add(n.id)
return true
})
}, [nodes])
return (
<SortableContext items={nodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
{nodes.map(node => (
<React.Fragment key={node.id}>
<SortableContext items={uniqueNodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
{uniqueNodes.map(node => (
<div key={node.id}>
{node.type === 'group' ? (
<SortableGroup
id={node.id}
@@ -232,7 +249,7 @@ function StructureRenderer({ nodes, ...props }: {
onScoreChange={(val) => props.onScoreChange(node.id, val)}
/>
)}
</React.Fragment>
</div>
))}
</SortableContext>
)
@@ -362,7 +379,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
if (activeContainerId !== overNode.id) {
// ... implementation continues ...
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
const newItems = cloneExamNodes(items)
// Remove active from old location
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
@@ -386,7 +403,10 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
for (const node of list) {
if (node.id === overId) {
if (!node.children) node.children = []
node.children.push(movedItem)
// Extra safety: Check if movedItem.id is already in children
if (!node.children.some(c => c.id === movedItem.id)) {
node.children.push(movedItem)
}
return true
}
if (node.children) {
@@ -404,8 +424,12 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
// Scenario 2: Moving between different lists (e.g. from Root to Group A, or Group A to Group B)
if (activeContainerId !== overContainerId) {
// FIX: If we are already inside the group we are hovering (i.e. activeContainerId IS overId),
// do not try to move "next to" the group (which would move us out).
if (activeContainerId === overId) return
// Standard Sortable Move
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
const newItems = cloneExamNodes(items)
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
const idx = list.findIndex(i => i.id === activeId)
@@ -484,7 +508,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
if (activeContainerId === overContainerId) {
// Same container reorder
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
const newItems = cloneExamNodes(items)
const getMutableList = (groupId?: string): ExamNode[] => {
if (groupId === 'root') return newItems
@@ -560,7 +584,9 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
) : (
<div className="rounded-md border bg-background p-3 shadow-lg opacity-80 w-[300px] flex items-center gap-3">
<GripVertical className="h-4 w-4" />
<p className="text-sm line-clamp-1">{(activeItem.question?.content as any)?.text || "Question"}</p>
<p className="text-sm line-clamp-1">
{(activeItem.question?.content as { text?: string } | undefined)?.text || "Question"}
</p>
</div>
)
) : null}

View File

@@ -32,6 +32,7 @@ import {
DialogTitle,
} from "@/shared/components/ui/dialog"
import { deleteExamAction, duplicateExamAction, updateExamAction } from "../actions"
import { Exam } from "../types"
interface ExamActionsProps {
@@ -42,31 +43,70 @@ export function ExamActions({ exam }: ExamActionsProps) {
const router = useRouter()
const [showViewDialog, setShowViewDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isWorking, setIsWorking] = useState(false)
const copyId = () => {
navigator.clipboard.writeText(exam.id)
toast.success("Exam ID copied to clipboard")
}
const publishExam = async () => {
toast.success("Exam published")
const setStatus = async (status: Exam["status"]) => {
setIsWorking(true)
try {
const formData = new FormData()
formData.set("examId", exam.id)
formData.set("status", status)
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success(status === "published" ? "Exam published" : status === "archived" ? "Exam archived" : "Exam moved to draft")
router.refresh()
} else {
toast.error(result.message || "Failed to update exam")
}
} catch {
toast.error("Failed to update exam")
} finally {
setIsWorking(false)
}
}
const unpublishExam = async () => {
toast.success("Exam moved to draft")
}
const archiveExam = async () => {
toast.success("Exam archived")
const duplicateExam = async () => {
setIsWorking(true)
try {
const formData = new FormData()
formData.set("examId", exam.id)
const result = await duplicateExamAction(null, formData)
if (result.success && result.data) {
toast.success("Exam duplicated")
router.push(`/teacher/exams/${result.data}/build`)
router.refresh()
} else {
toast.error(result.message || "Failed to duplicate exam")
}
} catch {
toast.error("Failed to duplicate exam")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
setIsWorking(true)
try {
await new Promise((r) => setTimeout(r, 800))
toast.success("Exam deleted successfully")
setShowDeleteDialog(false)
} catch (e) {
const formData = new FormData()
formData.set("examId", exam.id)
const result = await deleteExamAction(null, formData)
if (result.success) {
toast.success("Exam deleted successfully")
setShowDeleteDialog(false)
router.refresh()
} else {
toast.error(result.message || "Failed to delete exam")
}
} catch {
toast.error("Failed to delete exam")
} finally {
setIsWorking(false)
}
}
@@ -88,25 +128,39 @@ export function ExamActions({ exam }: ExamActionsProps) {
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
<Eye className="mr-2 h-4 w-4" /> View
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<MoreHorizontal className="mr-2 h-4 w-4" /> Build
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={publishExam}>
<DropdownMenuItem onClick={duplicateExam} disabled={isWorking}>
<Copy className="mr-2 h-4 w-4" /> Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setStatus("published")}
disabled={isWorking || exam.status === "published"}
>
<UploadCloud className="mr-2 h-4 w-4" /> Publish
</DropdownMenuItem>
<DropdownMenuItem onClick={unpublishExam}>
<DropdownMenuItem
onClick={() => setStatus("draft")}
disabled={isWorking || exam.status === "draft"}
>
<Undo2 className="mr-2 h-4 w-4" /> Move to Draft
</DropdownMenuItem>
<DropdownMenuItem onClick={archiveExam}>
<DropdownMenuItem
onClick={() => setStatus("archived")}
disabled={isWorking || exam.status === "archived"}
>
<Archive className="mr-2 h-4 w-4" /> Archive
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={isWorking}
>
<Trash className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
@@ -159,6 +213,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
e.preventDefault()
handleDelete()
}}
disabled={isWorking}
>
Delete
</AlertDialogAction>

View File

@@ -1,6 +1,6 @@
"use client"
import { useMemo, useState } from "react"
import { useDeferredValue, useMemo, useState } from "react"
import { useFormStatus } from "react-dom"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
@@ -19,6 +19,7 @@ import { updateExamAction } from "@/modules/exams/actions"
import { StructureEditor } from "./assembly/structure-editor"
import { QuestionBankList } from "./assembly/question-bank-list"
import type { ExamNode } from "./assembly/selected-question-list"
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
import { createId } from "@paralleldrive/cuid2"
type ExamAssemblyProps = {
@@ -48,17 +49,20 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<string>("all")
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
const deferredSearch = useDeferredValue(search)
// Initialize structure state
const [structure, setStructure] = useState<ExamNode[]>(() => {
// Hydrate structure with full question objects
const questionById = new Map<string, Question>()
for (const q of props.questionOptions) questionById.set(q.id, q)
const hydrate = (nodes: ExamNode[]): ExamNode[] => {
return nodes.map(node => {
if (node.type === 'question') {
const q = props.questionOptions.find(opt => opt.id === node.questionId)
return nodes.map((node) => {
if (node.type === "question") {
const q = node.questionId ? questionById.get(node.questionId) : undefined
return { ...node, question: q }
}
if (node.type === 'group') {
if (node.type === "group") {
return { ...node, children: hydrate(node.children || []) }
}
return node
@@ -77,8 +81,8 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const filteredQuestions = useMemo(() => {
let list: Question[] = [...props.questionOptions]
if (search) {
const lower = search.toLowerCase()
if (deferredSearch) {
const lower = deferredSearch.toLowerCase()
list = list.filter(q => {
const content = q.content as { text?: string }
return content.text?.toLowerCase().includes(lower)
@@ -93,7 +97,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
list = list.filter((q) => q.difficulty === d)
}
return list
}, [search, typeFilter, difficultyFilter, props.questionOptions])
}, [deferredSearch, typeFilter, difficultyFilter, props.questionOptions])
// Recursively calculate total score
const assignedTotal = useMemo(() => {
@@ -109,17 +113,40 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const progress = Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
const handleAdd = (question: Question) => {
setStructure(prev => [
...prev,
{
id: createId(),
type: 'question',
questionId: question.id,
score: 10,
question
const addedQuestionIds = useMemo(() => {
const ids = new Set<string>()
const walk = (nodes: ExamNode[]) => {
for (const n of nodes) {
if (n.type === "question" && n.questionId) ids.add(n.questionId)
if (n.type === "group" && n.children) walk(n.children)
}
])
}
walk(structure)
return ids
}, [structure])
const handleAdd = (question: Question) => {
setStructure((prev) => {
const has = (nodes: ExamNode[]): boolean => {
return nodes.some((n) => {
if (n.type === "question") return n.questionId === question.id
if (n.type === "group" && n.children) return has(n.children)
return false
})
}
if (has(prev)) return prev
return [
...prev,
{
id: createId(),
type: "question",
questionId: question.id,
score: 10,
question,
},
]
})
}
const handleAddGroup = () => {
@@ -171,10 +198,14 @@ export function ExamAssembly(props: ExamAssemblyProps) {
// Helper to extract flat list for DB examQuestions table
const getFlatQuestions = () => {
const list: Array<{ id: string; score: number }> = []
const seen = new Set<string>()
const traverse = (nodes: ExamNode[]) => {
nodes.forEach(n => {
if (n.type === 'question' && n.questionId) {
list.push({ id: n.questionId, score: n.score || 0 })
if (!seen.has(n.questionId)) {
seen.add(n.questionId)
list.push({ id: n.questionId, score: n.score || 0 })
}
}
if (n.type === 'group') {
traverse(n.children || [])
@@ -187,9 +218,12 @@ export function ExamAssembly(props: ExamAssemblyProps) {
// Helper to strip runtime question objects for DB structure storage
const getCleanStructure = () => {
const clean = (nodes: ExamNode[]): any[] => {
type CleanExamNode = Omit<ExamNode, "question"> & { children?: CleanExamNode[] }
const clean = (nodes: ExamNode[]): CleanExamNode[] => {
return nodes.map(n => {
const { question, ...rest } = n
void question
if (n.type === 'group') {
return { ...rest, children: clean(n.children || []) }
}
@@ -233,7 +267,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
<Card className="lg:col-span-3 flex flex-col overflow-hidden border-2 border-primary/10">
<CardHeader className="bg-muted/30 pb-4">
<div className="flex items-center justify-between">
<CardTitle>Exam Structure</CardTitle>
<div className="flex items-center gap-3">
<CardTitle>Exam Structure</CardTitle>
<ExamPaperPreview
title={props.title}
subject={props.subject}
grade={props.grade}
durationMin={props.durationMin}
totalScore={props.totalScore}
nodes={structure}
/>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex flex-col items-end">
<span className="font-medium">{assignedTotal} / {props.totalScore}</span>
@@ -324,17 +368,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
<QuestionBankList
questions={filteredQuestions}
onAdd={handleAdd}
isAdded={(id) => {
// Check if question is added anywhere in the structure
const isAddedRecursive = (nodes: ExamNode[]): boolean => {
return nodes.some(n => {
if (n.type === 'question' && n.questionId === id) return true
if (n.type === 'group' && n.children) return isAddedRecursive(n.children)
return false
})
}
return isAddedRecursive(structure)
}}
isAdded={(id) => addedQuestionIds.has(id)}
/>
</ScrollArea>
</Card>

View File

@@ -2,7 +2,7 @@
import { ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Badge } from "@/shared/components/ui/badge"
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
import { cn, formatDate } from "@/shared/lib/utils"
import { Exam } from "../types"
import { ExamActions } from "./exam-actions"
@@ -36,8 +36,8 @@ export const examColumns: ColumnDef<Exam>[] = [
<span className="font-medium">{row.original.title}</span>
{row.original.tags && row.original.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{row.original.tags.slice(0, 2).map((t) => (
<Badge key={t} variant="outline" className="text-xs">
{row.original.tags.slice(0, 2).map((t, idx) => (
<Badge key={`${t}-${idx}`} variant="outline" className="text-xs">
{t}
</Badge>
))}
@@ -65,9 +65,14 @@ export const examColumns: ColumnDef<Exam>[] = [
header: "Status",
cell: ({ row }) => {
const status = row.original.status
const variant = status === "published" ? "secondary" : status === "archived" ? "destructive" : "outline"
const variant: BadgeProps["variant"] =
status === "published"
? "secondary"
: status === "archived"
? "destructive"
: "outline"
return (
<Badge variant={variant as any} className="capitalize">
<Badge variant={variant} className="capitalize">
{status}
</Badge>
)
@@ -134,4 +139,3 @@ export const examColumns: ColumnDef<Exam>[] = [
cell: ({ row }) => <ExamActions exam={row.original} />,
},
]

View File

@@ -13,13 +13,17 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator"
import { gradeSubmissionAction } from "../actions"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
type QuestionContent = { text?: string } & Record<string, unknown>
type Answer = {
id: string
questionId: string
questionContent: any
questionContent: QuestionContent | null
questionType: string
maxScore: number
studentAnswer: any
studentAnswer: unknown
score: number | null
feedback: string | null
order: number
@@ -105,7 +109,7 @@ export function GradingView({
<div className="rounded-md bg-muted/50 p-4">
<Label className="mb-2 block text-xs text-muted-foreground">Student Answer</Label>
<p className="text-sm font-medium">
{typeof ans.studentAnswer?.answer === 'string'
{isRecord(ans.studentAnswer) && typeof ans.studentAnswer.answer === "string"
? ans.studentAnswer.answer
: JSON.stringify(ans.studentAnswer)}
</p>

View File

@@ -32,7 +32,7 @@ export const submissionColumns: ColumnDef<ExamSubmission>[] = [
cell: ({ row }) => {
const status = row.original.status
const variant = status === "graded" ? "secondary" : "outline"
return <Badge variant={variant as any} className="capitalize">{status}</Badge>
return <Badge variant={variant} className="capitalize">{status}</Badge>
},
},
{
@@ -60,4 +60,3 @@ export const submissionColumns: ColumnDef<ExamSubmission>[] = [
),
},
]

View File

@@ -1,9 +1,9 @@
import { db } from "@/shared/db"
import { exams, examQuestions, examSubmissions, submissionAnswers, users } from "@/shared/db/schema"
import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
import { eq, desc, like, and, or } from "drizzle-orm"
import { cache } from "react"
import type { ExamStatus } from "./types"
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
export type GetExamsParams = {
q?: string
@@ -13,6 +13,40 @@ export type GetExamsParams = {
pageSize?: number
}
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const parseExamMeta = (description: string | null): Record<string, unknown> => {
if (!description) return {}
try {
const parsed: unknown = JSON.parse(description)
return isRecord(parsed) ? parsed : {}
} catch {
return {}
}
}
const getString = (obj: Record<string, unknown>, key: string): string | undefined => {
const v = obj[key]
return typeof v === "string" ? v : undefined
}
const getNumber = (obj: Record<string, unknown>, key: string): number | undefined => {
const v = obj[key]
return typeof v === "number" ? v : undefined
}
const getStringArray = (obj: Record<string, unknown>, key: string): string[] | undefined => {
const v = obj[key]
if (!Array.isArray(v)) return undefined
const items = v.filter((x): x is string => typeof x === "string")
return items.length === v.length ? items : undefined
}
const toExamDifficulty = (n: number | undefined): ExamDifficulty => {
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) return n
return 1
}
export const getExams = cache(async (params: GetExamsParams) => {
const conditions = []
@@ -23,7 +57,7 @@ export const getExams = cache(async (params: GetExamsParams) => {
}
if (params.status && params.status !== "all") {
conditions.push(eq(exams.status, params.status as any))
conditions.push(eq(exams.status, params.status))
}
// Note: Difficulty is stored in JSON description field in current schema,
@@ -37,25 +71,23 @@ export const getExams = cache(async (params: GetExamsParams) => {
})
// Transform and Filter (especially for JSON fields)
let result = data.map((exam) => {
let meta: any = {}
try {
meta = JSON.parse(exam.description || "{}")
} catch { }
let result: Exam[] = data.map((exam) => {
const meta = parseExamMeta(exam.description || null)
return {
id: exam.id,
title: exam.title,
status: (exam.status as ExamStatus) || "draft",
subject: meta.subject || "General",
grade: meta.grade || "General",
difficulty: meta.difficulty || 1,
totalScore: meta.totalScore || 100,
durationMin: meta.durationMin || 60,
questionCount: meta.questionCount || 0,
subject: getString(meta, "subject") || "General",
grade: getString(meta, "grade") || "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
totalScore: getNumber(meta, "totalScore") || 100,
durationMin: getNumber(meta, "durationMin") || 60,
questionCount: getNumber(meta, "questionCount") || 0,
scheduledAt: exam.startTime?.toISOString(),
createdAt: exam.createdAt.toISOString(),
tags: meta.tags || [],
updatedAt: exam.updatedAt?.toISOString(),
tags: getStringArray(meta, "tags") || [],
}
})
@@ -82,30 +114,27 @@ export const getExamById = cache(async (id: string) => {
if (!exam) return null
let meta: any = {}
try {
meta = JSON.parse(exam.description || "{}")
} catch { }
const meta = parseExamMeta(exam.description || null)
return {
id: exam.id,
title: exam.title,
status: (exam.status as ExamStatus) || "draft",
subject: meta.subject || "General",
grade: meta.grade || "General",
difficulty: meta.difficulty || 1,
totalScore: meta.totalScore || 100,
durationMin: meta.durationMin || 60,
subject: getString(meta, "subject") || "General",
grade: getString(meta, "grade") || "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
totalScore: getNumber(meta, "totalScore") || 100,
durationMin: getNumber(meta, "durationMin") || 60,
scheduledAt: exam.startTime?.toISOString(),
createdAt: exam.createdAt.toISOString(),
tags: meta.tags || [],
structure: exam.structure as any, // Return structure
questions: exam.questions.map(eq => ({
id: eq.questionId,
score: eq.score,
order: eq.order,
// ... include question details if needed
}))
updatedAt: exam.updatedAt?.toISOString(),
tags: getStringArray(meta, "tags") || [],
structure: exam.structure as unknown,
questions: exam.questions.map((eqRel) => ({
id: eqRel.questionId,
score: eqRel.score ?? 0,
order: eqRel.order ?? 0,
})),
}
})
@@ -154,13 +183,20 @@ export const getSubmissionDetails = cache(async (submissionId: string) => {
orderBy: [desc(examQuestions.order)],
})
type QuestionContent = { text?: string } & Record<string, unknown>
const toQuestionContent = (v: unknown): QuestionContent | null => {
if (!isRecord(v)) return null
return v as QuestionContent
}
// Map answers with question details
const answersWithDetails = answers.map(ans => {
const eqRel = examQ.find(q => q.questionId === ans.questionId)
return {
id: ans.id,
questionId: ans.questionId,
questionContent: ans.question.content,
questionContent: toQuestionContent(ans.question.content),
questionType: ans.question.type,
maxScore: eqRel?.score || 0,
studentAnswer: ans.answerContent,

View File

@@ -2,7 +2,6 @@ import {
BarChart,
BookOpen,
Calendar,
GraduationCap,
LayoutDashboard,
Settings,
Users,
@@ -15,10 +14,11 @@ import {
Library,
PenTool
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
export type NavItem = {
title: string
icon: any
icon: LucideIcon
href: string
items?: { title: string; href: string }[]
}

View File

@@ -5,8 +5,6 @@ import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { CreateQuestionInput, CreateQuestionSchema } from "./schema";
import { ActionState } from "@/shared/types/action-state";
import { revalidatePath } from "next/cache";
import { ZodError } from "zod";
import { eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
// --- Mock Auth Helper (Replace with actual Auth.js call) ---
@@ -29,8 +27,10 @@ async function ensureTeacher() {
// --- Recursive Insert Helper ---
// We pass 'tx' to ensure all operations run within the same transaction
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
async function insertQuestionWithRelations(
tx: any, // using any or strict Drizzle Transaction type if imported
tx: Tx,
input: CreateQuestionInput,
authorId: string,
parentId: string | null = null
@@ -81,14 +81,14 @@ export async function createNestedQuestion(
// 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: any = 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);
rawInput = JSON.parse(jsonString) as unknown;
} else {
return { success: false, message: "Invalid submission format. Expected JSON." };
}

View File

@@ -9,6 +9,7 @@ import { cache } from "react";
export type GetQuestionsParams = {
page?: number;
pageSize?: number;
ids?: string[];
knowledgePointId?: string;
difficulty?: number;
};
@@ -18,6 +19,7 @@ export type GetQuestionsParams = {
export const getQuestions = cache(async ({
page = 1,
pageSize = 10,
ids,
knowledgePointId,
difficulty,
}: GetQuestionsParams = {}) => {
@@ -26,6 +28,10 @@ export const getQuestions = cache(async ({
// Build Where Conditions
const conditions = [];
if (ids && ids.length > 0) {
conditions.push(inArray(questions.id, ids));
}
if (difficulty) {
conditions.push(eq(questions.difficulty, difficulty));
}
@@ -40,9 +46,9 @@ export const getQuestions = cache(async ({
conditions.push(inArray(questions.id, subQuery));
}
// Only fetch top-level questions (parent questions)
// Assuming we only want to list "root" questions, not sub-questions
conditions.push(sql`${questions.parentId} IS NULL`);
if (!ids || ids.length === 0) {
conditions.push(sql`${questions.parentId} IS NULL`)
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;

View File

@@ -40,8 +40,7 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
if (result.success) {
toast.success(result.message)
setIsEditing(false)
// Update local state to reflect change immediately (optimistic-like)
selectedChapter.content = editContent
setSelectedChapter((prev) => (prev ? { ...prev, content: editContent } : prev))
} else {
toast.error(result.message)
}

View File

@@ -61,7 +61,7 @@ export function TextbookFormDialog() {
<DialogHeader>
<DialogTitle>Add New Textbook</DialogTitle>
<DialogDescription>
Create a new digital textbook. Click save when you're done.
Create a new digital textbook. Click save when you&apos;re done.
</DialogDescription>
</DialogHeader>
<form action={handleSubmit}>

View File

@@ -65,7 +65,7 @@ let MOCK_KNOWLEDGE_POINTS: KnowledgePoint[] = [
export async function getTextbooks(query?: string, subject?: string, grade?: string): Promise<Textbook[]> {
await new Promise((resolve) => setTimeout(resolve, 500));
let results = [...MOCK_TEXTBOOKS];
const results = [...MOCK_TEXTBOOKS];
// ... (filtering logic)
return results;
}

View File

@@ -28,7 +28,7 @@ const FormFieldContext = React.createContext<FormFieldContextValue>(
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
TTransformedValues = any
TTransformedValues = unknown
>({
...props
}: ControllerProps<TFieldValues, TName, TTransformedValues>) => {

View File

@@ -1,4 +1,5 @@
import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate";
const config: Config = {
darkMode: "class",
@@ -70,7 +71,7 @@ const config: Config = {
},
},
},
plugins: [require("tailwindcss-animate")],
plugins: [tailwindcssAnimate],
};
export default config;