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:
Wang Zhuoxuan 2026-04-07 12:10:25 +08:00
parent d176048172
commit 918ca279d6
13 changed files with 1021 additions and 10 deletions

View File

@ -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 },

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
},
},
]
}

View 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 }

View 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 }

View 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>>()
}

View File

@ -2,7 +2,7 @@ import type { CategoryStatus } from "@/types/category"
export const QUESTION_STATUSES = {
draft: "草稿",
review: "审核中",
reviewing: "审核中",
published: "已发布",
archived: "已下架",
} as const

View 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>
)
}

View File

@ -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">
{/* 页面头部 */}
<div className="flex items-center justify-between">
<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 + + +
<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>
)
}

View 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>
)
}

View File

@ -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
}