feat(teacher): 题库模块(QuestionBank)

工作内容

- 新增 /teacher/questions 页面,支持 Suspense/空状态/加载态

- 题库 CRUD Server Actions:创建/更新/递归删除子题,变更后 revalidatePath

- getQuestions 支持 q/type/difficulty/knowledgePointId 筛选与分页返回 meta

- UI:表格列/筛选器/创建编辑弹窗,content JSON 兼容组卷

- 更新中文设计文档:docs/design/004_question_bank_implementation.md
This commit is contained in:
SpecialX
2025-12-30 19:04:22 +08:00
parent f7ff018490
commit f8e39f518d
12 changed files with 680 additions and 325 deletions

View File

@@ -5,34 +5,38 @@ import { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/shared/components/ui/badge"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Question, QuestionType } from "../types"
import { cn } from "@/shared/lib/utils"
import { QuestionActions } from "./question-actions"
// Helper for Type Colors
const getTypeColor = (type: QuestionType) => {
switch (type) {
case "single_choice":
return "default"; // Primary
return "default"
case "multiple_choice":
return "secondary";
return "secondary"
case "judgment":
return "outline";
return "outline"
case "text":
return "secondary"; // Changed from 'accent' which might not be a valid badge variant key in standard shadcn, usually it's default, secondary, destructive, outline.
return "secondary"
default:
return "secondary";
return "secondary"
}
}
const getTypeLabel = (type: QuestionType) => {
switch (type) {
case "single_choice": return "Single Choice";
case "multiple_choice": return "Multiple Choice";
case "judgment": return "True/False";
case "text": return "Short Answer";
case "composite": return "Composite";
default: return type;
}
switch (type) {
case "single_choice":
return "Single Choice"
case "multiple_choice":
return "Multiple Choice"
case "judgment":
return "True/False"
case "text":
return "Short Answer"
case "composite":
return "Composite"
default:
return type
}
}
export const columns: ColumnDef<Question>[] = [
@@ -71,14 +75,20 @@ export const columns: ColumnDef<Question>[] = [
accessorKey: "content",
header: "Content",
cell: ({ row }) => {
const content = row.getValue("content");
let preview = "";
if (typeof content === 'string') {
preview = content;
} else if (content && typeof content === 'object') {
preview = JSON.stringify(content).slice(0, 50);
const content = row.getValue("content") as unknown
let preview = ""
if (typeof content === "string") {
preview = content
} else if (content && typeof content === "object") {
const text = (content as { text?: unknown }).text
if (typeof text === "string") {
preview = text
} else {
preview = JSON.stringify(content)
}
}
preview = preview.slice(0, 80)
return (
<div className="max-w-[400px] truncate font-medium" title={preview}>
{preview}
@@ -90,17 +100,23 @@ export const columns: ColumnDef<Question>[] = [
accessorKey: "difficulty",
header: "Difficulty",
cell: ({ row }) => {
const diff = row.getValue("difficulty") as number;
// 1-5 scale
const diff = row.getValue("difficulty") as number
const label =
diff === 1
? "Easy"
: diff === 2
? "Easy-Med"
: diff === 3
? "Medium"
: diff === 4
? "Med-Hard"
: "Hard"
return (
<div className="flex items-center">
<span className={cn("font-medium",
diff <= 2 ? "text-green-600" :
diff === 3 ? "text-yellow-600" : "text-red-600"
)}>
{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 className="flex items-center gap-2">
<Badge variant="outline" className="tabular-nums">
{label}
</Badge>
<span className="text-xs text-muted-foreground tabular-nums">({diff})</span>
</div>
)
},
@@ -109,22 +125,24 @@ export const columns: ColumnDef<Question>[] = [
accessorKey: "knowledgePoints",
header: "Knowledge Points",
cell: ({ row }) => {
const kps = row.original.knowledgePoints;
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex flex-wrap gap-1">
{kps.slice(0, 2).map(kp => (
<Badge key={kp.id} variant="outline" className="text-xs">
{kp.name}
</Badge>
))}
{kps.length > 2 && (
<Badge variant="outline" className="text-xs">+{kps.length - 2}</Badge>
)}
</div>
)
}
const kps = row.original.knowledgePoints
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>
return (
<div className="flex flex-wrap gap-1">
{kps.slice(0, 2).map((kp) => (
<Badge key={kp.id} variant="outline" className="text-xs">
{kp.name}
</Badge>
))}
{kps.length > 2 && (
<Badge variant="outline" className="text-xs">
+{kps.length - 2}
</Badge>
)}
</div>
)
},
},
{
accessorKey: "createdAt",
@@ -132,7 +150,7 @@ export const columns: ColumnDef<Question>[] = [
cell: ({ row }) => {
return (
<span className="text-muted-foreground text-xs whitespace-nowrap">
{new Date(row.getValue("createdAt")).toLocaleDateString()}
{new Date(row.getValue("createdAt")).toLocaleDateString()}
</span>
)
},