From 4bbdc590f4c041cc808556af592b8aad0b578d8d Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Tue, 7 Apr 2026 23:23:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E9=A2=98=E7=9B=AE=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?Phase=201b=20=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 ImportQuestionsDialog 三步导入对话框(输入→预览→结果) - 支持 JSON 文件上传和手动粘贴,Zod 格式校验 - 新增 importQuestions API 函数 + ImportResult 类型 - 题目列表页新增批量导入按钮 - Phase 1b 全部功能完成 --- CLAUDE.md | 4 +- .../question/ImportQuestionsDialog.tsx | 250 ++++++++++++++++++ src/lib/api/question-api.ts | 14 + src/routes/questions/index.tsx | 28 +- 4 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 src/components/question/ImportQuestionsDialog.tsx diff --git a/CLAUDE.md b/CLAUDE.md index afbf642..3aa9ec6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ ## Current Status -**Phase 1b in progress.** Category CRUD + Question CRUD + 知识卡编辑 done. Remaining: 题目状态流转 UI 完善、批量导入。 +**Phase 1b complete.** Category CRUD + Question CRUD + 知识卡编辑 + 状态流转 UI + 批量导入 all done. Next: Phase 1c. Development follows the phased roadmap in [dev-spec.md](./dev-spec.md) §九. @@ -38,7 +38,7 @@ src/ │ ├── layout/ # Sidebar, Header, AdminLayout │ ├── charts/ # StatsCard, chart wrappers │ ├── category/ # Category CRUD (columns, dialogs) -│ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor, KnowledgeCardFields) +│ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor, KnowledgeCardFields, StatusTransitionDialog, ImportQuestionsDialog) ├── lib/ │ ├── api-client.ts # HTTP client for /admin/* endpoints │ ├── auth.ts # Admin JWT token management diff --git a/src/components/question/ImportQuestionsDialog.tsx b/src/components/question/ImportQuestionsDialog.tsx new file mode 100644 index 0000000..1c3617f --- /dev/null +++ b/src/components/question/ImportQuestionsDialog.tsx @@ -0,0 +1,250 @@ +import { useRef, useState } from "react" +import { z } from "zod/v4" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { importQuestions } from "@/lib/api/question-api" +import type { QuestionFormData } from "@/types/question" + +const importItemSchema = z.object({ + stem: z.string().min(1, "题干不能为空"), + correctAnswer: z.string().min(1, "正确答案不能为空"), + distractors: z.array(z.string().min(1)).min(4, "至少 4 个干扰项").max(6), + categoryId: z.string().min(1, "请选择分类"), + difficulty: z.number().min(1).max(5), + status: z.enum(["draft", "reviewing", "published", "archived"]).optional(), + knowledgeCardBasic: z.string().min(1, "请填写基础知识卡").max(100).optional(), + knowledgeCardDeep: z.string().max(300).optional(), + sourceRef: z.string().max(500).optional(), +}) + +const importArraySchema = z.array(importItemSchema).min(1, "至少导入 1 道题目") + +type ImportStep = "input" | "preview" | "result" + +interface ImportQuestionsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function ImportQuestionsDialog({ + open, + onOpenChange, + onSuccess, +}: ImportQuestionsDialogProps) { + const fileInputRef = useRef(null) + const [step, setStep] = useState("input") + const [rawJson, setRawJson] = useState("") + const [parseError, setParseError] = useState(null) + const [parsedQuestions, setParsedQuestions] = useState([]) + const [importing, setImporting] = useState(false) + const [result, setResult] = useState<{ + imported: number + failed: number + errors?: { index: number; error: string }[] + } | null>(null) + + function reset() { + setStep("input") + setRawJson("") + setParseError(null) + setParsedQuestions([]) + setImporting(false) + setResult(null) + } + + function handleClose(open: boolean) { + if (!open) reset() + onOpenChange(open) + } + + function handleFileUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = (event) => { + const text = event.target?.result as string + setRawJson(text) + setParseError(null) + } + reader.readAsText(file) + e.target.value = "" + } + + function handleParse() { + setParseError(null) + let data: unknown + try { + data = JSON.parse(rawJson) + } catch { + setParseError("JSON 格式无效,请检查语法") + return + } + + const parsed = importArraySchema.safeParse(data) + if (!parsed.success) { + const firstError = parsed.error.issues[0] + const path = firstError.path.join(".") + setParseError(`第 ${path || "?"} 项: ${firstError.message}`) + return + } + + const questions: QuestionFormData[] = parsed.data.map((item) => ({ + stem: item.stem, + correctAnswer: item.correctAnswer, + distractors: item.distractors, + categoryId: item.categoryId, + difficulty: item.difficulty as QuestionFormData["difficulty"], + status: item.status ?? "draft", + knowledgeCardBasic: item.knowledgeCardBasic ?? "", + knowledgeCardDeep: item.knowledgeCardDeep, + sourceRef: item.sourceRef, + })) + + setParsedQuestions(questions) + setStep("preview") + } + + async function handleImport() { + setImporting(true) + try { + const res = await importQuestions(parsedQuestions) + setResult(res.data) + setStep("result") + if (res.data.imported > 0) onSuccess() + } catch { + setParseError("导入失败,请检查网络或联系管理员") + } finally { + setImporting(false) + } + } + + return ( + + + + 批量导入题目 + + {step === "input" && "上传 JSON 文件或粘贴 JSON 内容"} + {step === "preview" && `已解析 ${parsedQuestions.length} 道题目,确认导入?`} + {step === "result" && "导入完成"} + + + + {step === "input" && ( +
+
+ + + + 或直接粘贴 JSON + +
+ +