From 918ca279d683a52b548f681cb797f8af29c16571 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Tue, 7 Apr 2026 12:10:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=A2=98=E5=BA=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20CRUD=EF=BC=88Phase=201b=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 题目列表页:TanStack Table + 多维度筛选(搜索/状态/分类/难度)+ 分页 + 状态流转 + 删除 - 新建/编辑页:独立路由页面,含题干、正确答案、干扰项编辑器(4-6个)、分类选择、难度、状态、知识卡 - API 封装:question-api.ts 6 个函数(CRUD + 状态流转) - 组件:StatusBadge、DistractorEditor、QuestionForm、columns - 修正 QUESTION_STATUSES key: review → reviewing - 新增 shadcn/ui 组件:textarea、separator --- src/App.tsx | 11 +- src/components/question/DistractorEditor.tsx | 80 +++++ src/components/question/QuestionForm.tsx | 265 ++++++++++++++++ src/components/question/StatusBadge.tsx | 18 ++ src/components/question/columns.tsx | 165 ++++++++++ src/components/ui/separator.tsx | 26 ++ src/components/ui/textarea.tsx | 18 ++ src/lib/api/question-api.ts | 56 ++++ src/lib/constants.ts | 2 +- src/routes/questions/$id.edit.tsx | 43 +++ src/routes/questions/index.tsx | 313 ++++++++++++++++++- src/routes/questions/new.tsx | 21 ++ src/types/question.ts | 13 +- 13 files changed, 1021 insertions(+), 10 deletions(-) create mode 100644 src/components/question/DistractorEditor.tsx create mode 100644 src/components/question/QuestionForm.tsx create mode 100644 src/components/question/StatusBadge.tsx create mode 100644 src/components/question/columns.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/lib/api/question-api.ts create mode 100644 src/routes/questions/$id.edit.tsx create mode 100644 src/routes/questions/new.tsx 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 ( +
+ {/* 题干 */} +
+ +