duoqi-admin/src/components/question/QuestionForm.tsx
Wang Zhuoxuan 9314dc8505 feat: 实现知识卡编辑组件(Phase 1b)
- 新建 KnowledgeCardFields 组件:基础版/深度版编辑、字符计数器、来源参考、预览面板
- 提取 QuestionForm 中内联的知识卡字段为独立组件
- 新增 sourceRef 字段到类型定义和表单 schema
2026-04-07 23:16:01 +08:00

246 lines
7.3 KiB
TypeScript

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 { KnowledgeCardFields } from "@/components/question/KnowledgeCardFields"
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(),
sourceRef: z.string().max(500).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 ?? "",
sourceRef: question.sourceRef ?? "",
}
: {
stem: "",
correctAnswer: "",
distractors: ["", "", "", ""],
categoryId: "",
difficulty: 3,
status: "draft",
knowledgeCardBasic: "",
knowledgeCardDeep: "",
sourceRef: "",
},
})
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 />
{/* 知识卡 */}
<KnowledgeCardFields
basicRegister={register("knowledgeCardBasic")}
deepRegister={register("knowledgeCardDeep")}
sourceRefRegister={register("sourceRef")}
basicError={errors.knowledgeCardBasic?.message}
deepError={errors.knowledgeCardDeep?.message}
watchBasic={watch("knowledgeCardBasic") ?? ""}
watchDeep={watch("knowledgeCardDeep") ?? ""}
/>
{/* 提交 */}
<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>
)
}