feat: 实现题库管理 CRUD(Phase 1b)
- 题目列表页:TanStack Table + 多维度筛选(搜索/状态/分类/难度)+ 分页 + 状态流转 + 删除 - 新建/编辑页:独立路由页面,含题干、正确答案、干扰项编辑器(4-6个)、分类选择、难度、状态、知识卡 - API 封装:question-api.ts 6 个函数(CRUD + 状态流转) - 组件:StatusBadge、DistractorEditor、QuestionForm、columns - 修正 QUESTION_STATUSES key: review → reviewing - 新增 shadcn/ui 组件:textarea、separator
This commit is contained in:
parent
d176048172
commit
918ca279d6
11
src/App.tsx
11
src/App.tsx
@ -3,6 +3,8 @@ import RootLayout from "@/routes/__root"
|
||||
import DashboardPage from "@/routes/dashboard"
|
||||
import LoginPage from "@/routes/login"
|
||||
import QuestionsPage from "@/routes/questions"
|
||||
import NewQuestionPage from "@/routes/questions/new"
|
||||
import EditQuestionPage from "@/routes/questions/$id.edit"
|
||||
import CategoriesPage from "@/routes/categories"
|
||||
import UsersPage from "@/routes/users"
|
||||
import SettingsPage from "@/routes/settings"
|
||||
@ -17,7 +19,14 @@ const router = createBrowserRouter([
|
||||
Component: RootLayout,
|
||||
children: [
|
||||
{ index: true, Component: DashboardPage },
|
||||
{ path: "questions", Component: QuestionsPage },
|
||||
{
|
||||
path: "questions",
|
||||
children: [
|
||||
{ index: true, Component: QuestionsPage },
|
||||
{ path: "new", Component: NewQuestionPage },
|
||||
{ path: ":id/edit", Component: EditQuestionPage },
|
||||
],
|
||||
},
|
||||
{ path: "categories", Component: CategoriesPage },
|
||||
{ path: "users", Component: UsersPage },
|
||||
{ path: "settings", Component: SettingsPage },
|
||||
|
||||
80
src/components/question/DistractorEditor.tsx
Normal file
80
src/components/question/DistractorEditor.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { X, Plus } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
const MIN_DISTRACTORS = 4
|
||||
const MAX_DISTRACTORS = 6
|
||||
|
||||
interface DistractorEditorProps {
|
||||
value: string[]
|
||||
onChange: (items: string[]) => void
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export function DistractorEditor({ value, onChange, errors }: DistractorEditorProps) {
|
||||
function addItem() {
|
||||
if (value.length < MAX_DISTRACTORS) {
|
||||
onChange([...value, ""])
|
||||
}
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
onChange(value.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function updateItem(index: number, text: string) {
|
||||
const next = [...value]
|
||||
next[index] = text
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
干扰项
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
({MIN_DISTRACTORS}-{MAX_DISTRACTORS} 个)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={item}
|
||||
onChange={(e) => updateItem(index, e.target.value)}
|
||||
placeholder={`干扰项 ${index + 1}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeItem(index)}
|
||||
disabled={value.length <= MIN_DISTRACTORS}
|
||||
className="shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{value.length < MAX_DISTRACTORS && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addItem}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
添加干扰项
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{errors && (
|
||||
<p className="text-sm text-destructive">{errors.join("、")}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
265
src/components/question/QuestionForm.tsx
Normal file
265
src/components/question/QuestionForm.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod/v4"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useNavigate } from "react-router"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { DistractorEditor } from "@/components/question/DistractorEditor"
|
||||
import { fetchCategories } from "@/lib/api/category-api"
|
||||
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
||||
import type { Question, Difficulty, QuestionStatus } from "@/types/question"
|
||||
import type { Category } from "@/types/category"
|
||||
|
||||
const questionSchema = z.object({
|
||||
stem: z.string().min(1, "请输入题干").max(500),
|
||||
correctAnswer: z.string().min(1, "请输入正确答案"),
|
||||
distractors: z
|
||||
.array(z.string().min(1, "干扰项不能为空"))
|
||||
.min(4, "至少 4 个干扰项")
|
||||
.max(6, "最多 6 个干扰项"),
|
||||
categoryId: z.string().min(1, "请选择分类"),
|
||||
difficulty: z.number().min(1).max(5),
|
||||
status: z.enum(["draft", "reviewing", "published", "archived"]),
|
||||
knowledgeCardBasic: z.string().min(1, "请填写基础知识卡").max(100),
|
||||
knowledgeCardDeep: z.string().max(300).optional(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof questionSchema>
|
||||
|
||||
interface QuestionFormProps {
|
||||
question?: Question
|
||||
}
|
||||
|
||||
export function QuestionForm({ question }: QuestionFormProps) {
|
||||
const navigate = useNavigate()
|
||||
const isEditing = !!question
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(questionSchema),
|
||||
defaultValues: question
|
||||
? {
|
||||
stem: question.stem,
|
||||
correctAnswer: question.correctAnswer,
|
||||
distractors: question.distractors,
|
||||
categoryId: question.categoryId,
|
||||
difficulty: question.difficulty,
|
||||
status: question.status,
|
||||
knowledgeCardBasic: question.knowledgeCardBasic,
|
||||
knowledgeCardDeep: question.knowledgeCardDeep ?? "",
|
||||
}
|
||||
: {
|
||||
stem: "",
|
||||
correctAnswer: "",
|
||||
distractors: ["", "", "", ""],
|
||||
categoryId: "",
|
||||
difficulty: 3,
|
||||
status: "draft",
|
||||
knowledgeCardBasic: "",
|
||||
knowledgeCardDeep: "",
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories({ limit: 100 }).then((res) => setCategories(res.data))
|
||||
}, [])
|
||||
|
||||
async function onSubmit(data: FormValues) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// TODO: 接入 API
|
||||
console.log("submit", isEditing ? "update" : "create", data)
|
||||
navigate("/questions")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const distractorsValue = watch("distractors")
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
|
||||
{/* 题干 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stem">题干</Label>
|
||||
<Textarea
|
||||
id="stem"
|
||||
placeholder="输入题目文字"
|
||||
rows={3}
|
||||
{...register("stem")}
|
||||
/>
|
||||
{errors.stem && (
|
||||
<p className="text-sm text-destructive">{errors.stem.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 正确答案 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="correctAnswer">正确答案</Label>
|
||||
<Input
|
||||
id="correctAnswer"
|
||||
placeholder="正确选项文本"
|
||||
{...register("correctAnswer")}
|
||||
/>
|
||||
{errors.correctAnswer && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.correctAnswer.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 干扰项 */}
|
||||
<DistractorEditor
|
||||
value={distractorsValue}
|
||||
onChange={(items) =>
|
||||
setValue("distractors", items, { shouldValidate: true })
|
||||
}
|
||||
errors={
|
||||
errors.distractors?.message ? [errors.distractors.message] : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 分类 + 难度 + 状态 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>分类</Label>
|
||||
<Select
|
||||
value={watch("categoryId")}
|
||||
onValueChange={(val) => setValue("categoryId", val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.categoryId && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.categoryId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>难度</Label>
|
||||
<Select
|
||||
value={String(watch("difficulty"))}
|
||||
onValueChange={(val) =>
|
||||
setValue("difficulty", Number(val) as Difficulty)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(DIFFICULTY_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>状态</Label>
|
||||
<Select
|
||||
value={watch("status")}
|
||||
onValueChange={(val) => setValue("status", val as QuestionStatus)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(QUESTION_STATUSES).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 知识卡(基础版) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="knowledgeCardBasic">知识卡(基础版)</Label>
|
||||
<Textarea
|
||||
id="knowledgeCardBasic"
|
||||
placeholder="2-3 句趣味解读,100 字以内"
|
||||
rows={3}
|
||||
{...register("knowledgeCardBasic")}
|
||||
/>
|
||||
{errors.knowledgeCardBasic && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.knowledgeCardBasic.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 知识卡(深度版) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="knowledgeCardDeep">
|
||||
知识卡(深度版)
|
||||
<span className="ml-1 text-muted-foreground font-normal">选填</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="knowledgeCardDeep"
|
||||
placeholder="扩展背景故事、趣味延伸,300 字以内"
|
||||
rows={4}
|
||||
{...register("knowledgeCardDeep")}
|
||||
/>
|
||||
{errors.knowledgeCardDeep && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.knowledgeCardDeep.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提交 */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/questions")}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting
|
||||
? "保存中..."
|
||||
: isEditing
|
||||
? "更新题目"
|
||||
: "创建题目"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
18
src/components/question/StatusBadge.tsx
Normal file
18
src/components/question/StatusBadge.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { QUESTION_STATUSES } from "@/lib/constants"
|
||||
import type { QuestionStatus } from "@/types/question"
|
||||
|
||||
const statusVariants: Record<QuestionStatus, "default" | "secondary" | "outline" | "destructive"> = {
|
||||
draft: "outline",
|
||||
reviewing: "secondary",
|
||||
published: "default",
|
||||
archived: "destructive",
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: QuestionStatus }) {
|
||||
return (
|
||||
<Badge variant={statusVariants[status]}>
|
||||
{QUESTION_STATUSES[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
165
src/components/question/columns.tsx
Normal file
165
src/components/question/columns.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useNavigate } from "react-router"
|
||||
import { MoreHorizontal } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { StatusBadge } from "@/components/question/StatusBadge"
|
||||
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
||||
import type { Question, QuestionStatus } from "@/types/question"
|
||||
import type { Category } from "@/types/category"
|
||||
|
||||
interface ColumnContext {
|
||||
categories: Category[]
|
||||
onDelete: (question: Question) => void
|
||||
onStatusChange: (question: Question, status: QuestionStatus) => void
|
||||
}
|
||||
|
||||
function getQuestionStatusesForTransition(current: QuestionStatus): QuestionStatus[] {
|
||||
switch (current) {
|
||||
case "draft":
|
||||
return ["reviewing"]
|
||||
case "reviewing":
|
||||
return ["published", "draft"]
|
||||
case "published":
|
||||
return ["archived"]
|
||||
case "archived":
|
||||
return ["draft"]
|
||||
}
|
||||
}
|
||||
|
||||
export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "stem",
|
||||
header: "题干",
|
||||
cell: ({ row }) => {
|
||||
const stem = row.getValue("stem") as string
|
||||
return (
|
||||
<span className="line-clamp-2 max-w-xs" title={stem}>
|
||||
{stem.length > 60 ? stem.slice(0, 60) + "..." : stem}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "categoryId",
|
||||
header: "分类",
|
||||
cell: ({ row }) => {
|
||||
const catId = row.getValue("categoryId") as string
|
||||
const cat = ctx.categories.find((c) => c.id === catId)
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{cat?.name ?? catId}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "difficulty",
|
||||
header: "难度",
|
||||
cell: ({ row }) => {
|
||||
const d = row.getValue("difficulty") as number
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
{DIFFICULTY_LABELS[d] ?? d}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "状态",
|
||||
cell: ({ row }) => <StatusBadge status={row.getValue("status") as QuestionStatus} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "distractors",
|
||||
header: "干扰项",
|
||||
cell: ({ row }) => {
|
||||
const count = (row.getValue("distractors") as string[]).length
|
||||
return <span className="text-muted-foreground">{count} 个</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
header: "统计",
|
||||
cell: ({ row }) => {
|
||||
const { timesAnswered, correctRate } = row.original.stats
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{timesAnswered} 次 · {(correctRate * 100).toFixed(0)}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: "更新时间",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(row.getValue("updatedAt") as string).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
const question = row.original
|
||||
const navigate = useNavigate()
|
||||
const transitions = getQuestionStatusesForTransition(question.status)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/questions/${question.id}/edit`)}>
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
|
||||
{transitions.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>状态流转</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{transitions.map((status) => (
|
||||
<DropdownMenuItem
|
||||
key={status}
|
||||
onClick={() => ctx.onStatusChange(question, status)}
|
||||
>
|
||||
{QUESTION_STATUSES[status]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => ctx.onDelete(question)}
|
||||
>
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
26
src/components/ui/separator.tsx
Normal file
26
src/components/ui/separator.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
56
src/lib/api/question-api.ts
Normal file
56
src/lib/api/question-api.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
import type { PaginatedResponse, ApiResponse } from "@/types/api"
|
||||
import type { Question, QuestionFormData, QuestionStatus, Difficulty } from "@/types/question"
|
||||
|
||||
export interface FetchQuestionsParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
status?: QuestionStatus
|
||||
categoryId?: string
|
||||
difficulty?: Difficulty
|
||||
}
|
||||
|
||||
export async function fetchQuestions(
|
||||
params: FetchQuestionsParams = {}
|
||||
): Promise<PaginatedResponse<Question>> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.page) searchParams.set("page", String(params.page))
|
||||
if (params.limit) searchParams.set("limit", String(params.limit))
|
||||
if (params.search) searchParams.set("search", params.search)
|
||||
if (params.status) searchParams.set("status", params.status)
|
||||
if (params.categoryId) searchParams.set("categoryId", params.categoryId)
|
||||
if (params.difficulty) searchParams.set("difficulty", String(params.difficulty))
|
||||
|
||||
return apiClient
|
||||
.get("questions", { searchParams })
|
||||
.json<PaginatedResponse<Question>>()
|
||||
}
|
||||
|
||||
export async function fetchQuestion(id: string): Promise<ApiResponse<Question>> {
|
||||
return apiClient.get(`questions/${id}`).json<ApiResponse<Question>>()
|
||||
}
|
||||
|
||||
export async function createQuestion(data: QuestionFormData): Promise<ApiResponse<Question>> {
|
||||
return apiClient.post("questions", { json: data }).json<ApiResponse<Question>>()
|
||||
}
|
||||
|
||||
export async function updateQuestion(
|
||||
id: string,
|
||||
data: Partial<QuestionFormData>
|
||||
): Promise<ApiResponse<Question>> {
|
||||
return apiClient.put(`questions/${id}`, { json: data }).json<ApiResponse<Question>>()
|
||||
}
|
||||
|
||||
export async function deleteQuestion(id: string): Promise<ApiResponse<{ id: string }>> {
|
||||
return apiClient.delete(`questions/${id}`).json<ApiResponse<{ id: string }>>()
|
||||
}
|
||||
|
||||
export async function updateQuestionStatus(
|
||||
id: string,
|
||||
status: QuestionStatus
|
||||
): Promise<ApiResponse<Question>> {
|
||||
return apiClient
|
||||
.patch(`questions/${id}/status`, { json: { status } })
|
||||
.json<ApiResponse<Question>>()
|
||||
}
|
||||
@ -2,7 +2,7 @@ import type { CategoryStatus } from "@/types/category"
|
||||
|
||||
export const QUESTION_STATUSES = {
|
||||
draft: "草稿",
|
||||
review: "审核中",
|
||||
reviewing: "审核中",
|
||||
published: "已发布",
|
||||
archived: "已下架",
|
||||
} as const
|
||||
|
||||
43
src/routes/questions/$id.edit.tsx
Normal file
43
src/routes/questions/$id.edit.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useNavigate, useParams } from "react-router"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { QuestionForm } from "@/components/question/QuestionForm"
|
||||
import { fetchQuestion } from "@/lib/api/question-api"
|
||||
import type { Question } from "@/types/question"
|
||||
|
||||
export default function EditQuestionPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [question, setQuestion] = useState<Question | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
fetchQuestion(id)
|
||||
.then((res) => setQuestion(res.data))
|
||||
.catch(() => navigate("/questions"))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id, navigate])
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-muted-foreground py-12 text-center">加载中...</div>
|
||||
}
|
||||
|
||||
if (!question) {
|
||||
return <div className="text-muted-foreground py-12 text-center">题目不存在</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/questions")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">编辑题目</h1>
|
||||
</div>
|
||||
|
||||
<QuestionForm question={question} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,10 +1,317 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
} from "@tanstack/react-table"
|
||||
import { Plus, Search } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { getColumns } from "@/components/question/columns"
|
||||
import { fetchQuestions, deleteQuestion, updateQuestionStatus } from "@/lib/api/question-api"
|
||||
import { fetchCategories } from "@/lib/api/category-api"
|
||||
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
||||
import type { Question, QuestionStatus, Difficulty } from "@/types/question"
|
||||
import type { Category } from "@/types/category"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default function QuestionsPage() {
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<QuestionStatus | "all">("all")
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("all")
|
||||
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
||||
|
||||
// 删除对话框
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deletingQuestion, setDeletingQuestion] = useState<Question | null>(null)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
// 加载分类列表(用于筛选和列显示)
|
||||
useEffect(() => {
|
||||
fetchCategories({ limit: 100 }).then((res) => setCategories(res.data))
|
||||
}, [])
|
||||
|
||||
const loadQuestions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchQuestions({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
search: search || undefined,
|
||||
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||
categoryId: categoryFilter !== "all" ? categoryFilter : undefined,
|
||||
difficulty: difficultyFilter !== "all"
|
||||
? (Number(difficultyFilter) as Difficulty)
|
||||
: undefined,
|
||||
})
|
||||
setQuestions(res.data)
|
||||
setTotal(res.pagination.total)
|
||||
} catch {
|
||||
setQuestions([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, statusFilter, categoryFilter, difficultyFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions()
|
||||
}, [loadQuestions])
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deletingQuestion) return
|
||||
await deleteQuestion(deletingQuestion.id)
|
||||
setDeleteOpen(false)
|
||||
setDeletingQuestion(null)
|
||||
await loadQuestions()
|
||||
}
|
||||
|
||||
async function handleStatusChange(question: Question, status: QuestionStatus) {
|
||||
await updateQuestionStatus(question.id, status)
|
||||
await loadQuestions()
|
||||
}
|
||||
|
||||
function openDelete(question: Question) {
|
||||
setDeletingQuestion(question)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
const columns = getColumns({
|
||||
categories,
|
||||
onDelete: openDelete,
|
||||
onStatusChange: handleStatusChange,
|
||||
})
|
||||
|
||||
const table = useReactTable({
|
||||
data: questions,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">题库管理</h1>
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed text-muted-foreground">
|
||||
Phase 1b — 题目列表(DataTable + 筛选 + 搜索 + 分页)
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">题库管理</h1>
|
||||
<Button asChild>
|
||||
<Link to="/questions/new">
|
||||
<Plus className="size-4" />
|
||||
新建题目
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative max-w-xs flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索题干..."
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(val) => {
|
||||
setStatusFilter(val as QuestionStatus | "all")
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
{Object.entries(QUESTION_STATUSES).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={categoryFilter}
|
||||
onValueChange={(val) => {
|
||||
setCategoryFilter(val)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部分类</SelectItem>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={difficultyFilter}
|
||||
onValueChange={(val) => {
|
||||
setDifficultyFilter(val)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue placeholder="全部难度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部难度</SelectItem>
|
||||
{Object.entries(DIFFICULTY_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : questions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
暂无题目数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
共 {total} 条,第 {page}/{totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除这道题目吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
21
src/routes/questions/new.tsx
Normal file
21
src/routes/questions/new.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { useNavigate } from "react-router"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { QuestionForm } from "@/components/question/QuestionForm"
|
||||
|
||||
export default function NewQuestionPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/questions")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">新建题目</h1>
|
||||
</div>
|
||||
|
||||
<QuestionForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
export type QuestionStatus = "draft" | "review" | "published" | "archived"
|
||||
export type QuestionStatus = "draft" | "reviewing" | "published" | "archived"
|
||||
export type Difficulty = 1 | 2 | 3 | 4 | 5
|
||||
|
||||
export interface Question {
|
||||
@ -11,8 +11,12 @@ export interface Question {
|
||||
status: QuestionStatus
|
||||
knowledgeCardBasic: string
|
||||
knowledgeCardDeep?: string
|
||||
mediaType?: "text" | "image" | "audio" | "video"
|
||||
mediaUrl?: string
|
||||
source: "system" | "ugc"
|
||||
stats: {
|
||||
timesAnswered: number
|
||||
correctRate: number
|
||||
avgTimeMs: number
|
||||
}
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@ -23,8 +27,7 @@ export interface QuestionFormData {
|
||||
distractors: string[]
|
||||
categoryId: string
|
||||
difficulty: Difficulty
|
||||
status: QuestionStatus
|
||||
knowledgeCardBasic: string
|
||||
knowledgeCardDeep?: string
|
||||
mediaType?: "text" | "image" | "audio" | "video"
|
||||
mediaUrl?: string
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user