diff --git a/src/App.tsx b/src/App.tsx
index 20121c4..5d589f3 100644
--- a/src/App.tsx
+++ b/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 },
diff --git a/src/components/question/DistractorEditor.tsx b/src/components/question/DistractorEditor.tsx
new file mode 100644
index 0000000..32379bc
--- /dev/null
+++ b/src/components/question/DistractorEditor.tsx
@@ -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 (
+
+
+ 干扰项
+
+ ({MIN_DISTRACTORS}-{MAX_DISTRACTORS} 个)
+
+
+
+
+ {value.map((item, index) => (
+
+ updateItem(index, e.target.value)}
+ placeholder={`干扰项 ${index + 1}`}
+ />
+
+
+ ))}
+
+
+ {value.length < MAX_DISTRACTORS && (
+
+ )}
+
+ {errors && (
+
{errors.join("、")}
+ )}
+
+ )
+}
diff --git a/src/components/question/QuestionForm.tsx b/src/components/question/QuestionForm.tsx
new file mode 100644
index 0000000..ced8e81
--- /dev/null
+++ b/src/components/question/QuestionForm.tsx
@@ -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
+
+interface QuestionFormProps {
+ question?: Question
+}
+
+export function QuestionForm({ question }: QuestionFormProps) {
+ const navigate = useNavigate()
+ const isEditing = !!question
+ const [submitting, setSubmitting] = useState(false)
+ const [categories, setCategories] = useState([])
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ setValue,
+ watch,
+ } = useForm({
+ 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 (
+
+ )
+}
diff --git a/src/components/question/StatusBadge.tsx b/src/components/question/StatusBadge.tsx
new file mode 100644
index 0000000..0f8c405
--- /dev/null
+++ b/src/components/question/StatusBadge.tsx
@@ -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 = {
+ draft: "outline",
+ reviewing: "secondary",
+ published: "default",
+ archived: "destructive",
+}
+
+export function StatusBadge({ status }: { status: QuestionStatus }) {
+ return (
+
+ {QUESTION_STATUSES[status]}
+
+ )
+}
diff --git a/src/components/question/columns.tsx b/src/components/question/columns.tsx
new file mode 100644
index 0000000..52f5380
--- /dev/null
+++ b/src/components/question/columns.tsx
@@ -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[] {
+ return [
+ {
+ accessorKey: "stem",
+ header: "题干",
+ cell: ({ row }) => {
+ const stem = row.getValue("stem") as string
+ return (
+
+ {stem.length > 60 ? stem.slice(0, 60) + "..." : stem}
+
+ )
+ },
+ },
+ {
+ accessorKey: "categoryId",
+ header: "分类",
+ cell: ({ row }) => {
+ const catId = row.getValue("categoryId") as string
+ const cat = ctx.categories.find((c) => c.id === catId)
+ return (
+
+ {cat?.name ?? catId}
+
+ )
+ },
+ },
+ {
+ accessorKey: "difficulty",
+ header: "难度",
+ cell: ({ row }) => {
+ const d = row.getValue("difficulty") as number
+ return (
+
+ {DIFFICULTY_LABELS[d] ?? d}
+
+ )
+ },
+ },
+ {
+ accessorKey: "status",
+ header: "状态",
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: "distractors",
+ header: "干扰项",
+ cell: ({ row }) => {
+ const count = (row.getValue("distractors") as string[]).length
+ return {count} 个
+ },
+ },
+ {
+ id: "stats",
+ header: "统计",
+ cell: ({ row }) => {
+ const { timesAnswered, correctRate } = row.original.stats
+ return (
+
+ {timesAnswered} 次 · {(correctRate * 100).toFixed(0)}%
+
+ )
+ },
+ },
+ {
+ accessorKey: "updatedAt",
+ header: "更新时间",
+ cell: ({ row }) => (
+
+ {new Date(row.getValue("updatedAt") as string).toLocaleDateString("zh-CN")}
+
+ ),
+ },
+ {
+ id: "actions",
+ header: "",
+ cell: ({ row }) => {
+ const question = row.original
+ const navigate = useNavigate()
+ const transitions = getQuestionStatusesForTransition(question.status)
+
+ return (
+
+
+
+
+
+ navigate(`/questions/${question.id}/edit`)}>
+ 编辑
+
+
+ {transitions.length > 0 && (
+ <>
+
+
+ 状态流转
+
+ {transitions.map((status) => (
+ ctx.onStatusChange(question, status)}
+ >
+ {QUESTION_STATUSES[status]}
+
+ ))}
+
+
+ >
+ )}
+
+
+ ctx.onDelete(question)}
+ >
+ 删除
+
+
+
+ )
+ },
+ },
+ ]
+}
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..db91e97
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -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) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..e67d8fe
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/src/lib/api/question-api.ts b/src/lib/api/question-api.ts
new file mode 100644
index 0000000..89d36fb
--- /dev/null
+++ b/src/lib/api/question-api.ts
@@ -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> {
+ 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>()
+}
+
+export async function fetchQuestion(id: string): Promise> {
+ return apiClient.get(`questions/${id}`).json>()
+}
+
+export async function createQuestion(data: QuestionFormData): Promise> {
+ return apiClient.post("questions", { json: data }).json>()
+}
+
+export async function updateQuestion(
+ id: string,
+ data: Partial
+): Promise> {
+ return apiClient.put(`questions/${id}`, { json: data }).json>()
+}
+
+export async function deleteQuestion(id: string): Promise> {
+ return apiClient.delete(`questions/${id}`).json>()
+}
+
+export async function updateQuestionStatus(
+ id: string,
+ status: QuestionStatus
+): Promise> {
+ return apiClient
+ .patch(`questions/${id}/status`, { json: { status } })
+ .json>()
+}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 9a24075..cda3402 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -2,7 +2,7 @@ import type { CategoryStatus } from "@/types/category"
export const QUESTION_STATUSES = {
draft: "草稿",
- review: "审核中",
+ reviewing: "审核中",
published: "已发布",
archived: "已下架",
} as const
diff --git a/src/routes/questions/$id.edit.tsx b/src/routes/questions/$id.edit.tsx
new file mode 100644
index 0000000..90a4ab4
--- /dev/null
+++ b/src/routes/questions/$id.edit.tsx
@@ -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(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 加载中...
+ }
+
+ if (!question) {
+ return 题目不存在
+ }
+
+ return (
+
+
+
+
编辑题目
+
+
+
+
+ )
+}
diff --git a/src/routes/questions/index.tsx b/src/routes/questions/index.tsx
index 56cdf91..9d955c2 100644
--- a/src/routes/questions/index.tsx
+++ b/src/routes/questions/index.tsx
@@ -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([])
+ const [categories, setCategories] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [total, setTotal] = useState(0)
+ const [page, setPage] = useState(1)
+ const [search, setSearch] = useState("")
+ const [statusFilter, setStatusFilter] = useState("all")
+ const [categoryFilter, setCategoryFilter] = useState("all")
+ const [difficultyFilter, setDifficultyFilter] = useState("all")
+
+ // 删除对话框
+ const [deleteOpen, setDeleteOpen] = useState(false)
+ const [deletingQuestion, setDeletingQuestion] = useState(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 (
-
题库管理
-
- Phase 1b — 题目列表(DataTable + 筛选 + 搜索 + 分页)
+ {/* 页面头部 */}
+
+
+ {/* 筛选栏 */}
+
+
+
+ {
+ setSearch(e.target.value)
+ setPage(1)
+ }}
+ />
+
+
+
+
+
+
+
+
+
+ {/* 表格 */}
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ ))}
+
+ ))}
+
+
+ {loading ? (
+
+
+ 加载中...
+
+
+ ) : questions.length === 0 ? (
+
+
+ 暂无题目数据
+
+
+ ) : (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+
+ ))}
+
+ ))
+ )}
+
+
+
+
+ {/* 分页 */}
+ {totalPages > 1 && (
+
+
+ 共 {total} 条,第 {page}/{totalPages} 页
+
+
+
+
+
+
+ )}
+
+ {/* 删除确认 */}
+
+
+
+ 确认删除
+
+ 确定要删除这道题目吗?此操作不可撤销。
+
+
+
+ 取消
+
+ 删除
+
+
+
+
)
}
diff --git a/src/routes/questions/new.tsx b/src/routes/questions/new.tsx
new file mode 100644
index 0000000..46be83a
--- /dev/null
+++ b/src/routes/questions/new.tsx
@@ -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 (
+
+
+
+
新建题目
+
+
+
+
+ )
+}
diff --git a/src/types/question.ts b/src/types/question.ts
index 8dc4a81..9c243ca 100644
--- a/src/types/question.ts
+++ b/src/types/question.ts
@@ -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
}