From a822e91c63b4c779e78d06f78644fc58fb41de00 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Sat, 11 Apr 2026 23:40:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E6=8E=A5=E9=A2=98=E7=9B=AE?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=E6=8E=A5=E5=8F=A3=EF=BC=88?= =?UTF-8?q?JSON=20+=20CSV=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ImportQuestionItem / ImportSuccessResult / ImportValidationError 类型,匹配 API 规范 - importQuestions 改用 API 规范的 stem: { text }、contentType、嵌套 knowledgeCard 结构 - 新增 importQuestionsCsv 函数,POST text/plain 到 /admin/questions/import-csv - 重写 ImportQuestionsDialog:JSON/CSV 双模式 Tab 切换、预览、校验错误详情展示 --- .../question/ImportQuestionsDialog.tsx | 451 ++++++++++++++---- src/lib/api/question-api.ts | 38 +- src/types/question.ts | 29 ++ 3 files changed, 411 insertions(+), 107 deletions(-) diff --git a/src/components/question/ImportQuestionsDialog.tsx b/src/components/question/ImportQuestionsDialog.tsx index 99061b4..9ff88d6 100644 --- a/src/components/question/ImportQuestionsDialog.tsx +++ b/src/components/question/ImportQuestionsDialog.tsx @@ -10,23 +10,53 @@ import { 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" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + importQuestions, + importQuestionsCsv, +} from "@/lib/api/question-api" +import type { + ImportQuestionItem, + ImportSuccessResult, + ImportValidationError, +} 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(), +// ── JSON import schema (matches POST /admin/questions/import) ── + +const knowledgeCardSchema = z.object({ + summary: z.string().min(1, "知识点摘要不能为空"), + deepDive: z.string().optional(), + sourceRef: z.string().optional(), }) -const importArraySchema = z.array(importItemSchema).min(1, "至少导入 1 道题目") +const importItemSchema = z.object({ + stem: z.object({ text: z.string().min(1, "题干不能为空") }), + contentType: z.enum(["text", "image", "video", "audio"]).optional(), + correctAnswer: z.string().min(1, "正确答案不能为空"), + distractors: z + .array(z.string().min(1)) + .min(2, "至少 2 个干扰项") + .max(6, "最多 6 个干扰项"), + categoryId: z.string().min(1, "请填写分类 ID"), + difficulty: z.number().min(1).max(5).optional(), + knowledgeCard: knowledgeCardSchema.optional(), +}) +const importArraySchema = z + .array(importItemSchema) + .min(1, "至少导入 1 道题目") + .max(200, "单次最多导入 200 道题目") + +// ── CSV constants ── + +const CSV_HEADER = + "categoryId,contentType,difficulty,stemText,correctAnswer,distractor1,distractor2,distractor3,distractor4,distractor5,cardSummary,cardDeepDive,cardSourceRef" +const CSV_HEADER_COLS = CSV_HEADER.split(",") +const CSV_COLUMN_COUNT = CSV_HEADER_COLS.length + +// ── State types ── + +type ImportMode = "json" | "csv" type ImportStep = "input" | "preview" | "result" interface ImportQuestionsDialogProps { @@ -40,25 +70,39 @@ export function ImportQuestionsDialog({ onOpenChange, onSuccess, }: ImportQuestionsDialogProps) { - const fileInputRef = useRef(null) + const jsonFileRef = useRef(null) + const csvFileRef = useRef(null) + + const [mode, setMode] = useState("json") const [step, setStep] = useState("input") - const [rawJson, setRawJson] = useState("") const [parseError, setParseError] = useState(null) - const [parsedQuestions, setParsedQuestions] = useState([]) + + // JSON state + const [rawJson, setRawJson] = useState("") + const [parsedItems, setParsedItems] = useState([]) + + // CSV state + const [rawCsv, setRawCsv] = useState("") + const [csvFileName, setCsvFileName] = useState("") + + // Import state const [importing, setImporting] = useState(false) - const [result, setResult] = useState<{ - imported: number - failed: number - errors?: { index: number; error: string }[] - } | null>(null) + const [result, setResult] = useState(null) + const [validationErrors, setValidationErrors] = useState< + ImportValidationError[] + >([]) function reset() { setStep("input") + setMode("json") setRawJson("") + setRawCsv("") + setCsvFileName("") setParseError(null) - setParsedQuestions([]) + setParsedItems([]) setImporting(false) setResult(null) + setValidationErrors([]) } function handleClose(open: boolean) { @@ -66,7 +110,9 @@ export function ImportQuestionsDialog({ onOpenChange(open) } - function handleFileUpload(e: React.ChangeEvent) { + // ── JSON handlers ── + + function handleJsonFileUpload(e: React.ChangeEvent) { const file = e.target.files?.[0] if (!file) return const reader = new FileReader() @@ -75,11 +121,12 @@ export function ImportQuestionsDialog({ setRawJson(text) setParseError(null) } + reader.onerror = () => setParseError("文件读取失败,请重试") reader.readAsText(file) e.target.value = "" } - function handleParse() { + function handleJsonParse() { setParseError(null) let data: unknown try { @@ -97,149 +144,338 @@ export function ImportQuestionsDialog({ return } - const questions: QuestionFormData[] = parsed.data.map((item) => ({ - stem: item.stem, + const items: ImportQuestionItem[] = parsed.data.map((item) => ({ + stem: { text: item.stem.text }, + contentType: item.contentType ?? "text", 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, + difficulty: item.difficulty, + knowledgeCard: item.knowledgeCard, })) - setParsedQuestions(questions) + setParsedItems(items) setStep("preview") } + // ── CSV handlers ── + + function handleCsvFileUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + setCsvFileName(file.name) + const reader = new FileReader() + reader.onload = (event) => { + const text = event.target?.result as string + setRawCsv(text) + setParseError(null) + } + reader.onerror = () => setParseError("文件读取失败,请重试") + reader.readAsText(file) + e.target.value = "" + } + + /** + * Basic CSV validation: checks header and row count. + * Does not parse quoted fields with commas/newlines — + * the server handles full RFC 4180 parsing. + */ + function validateCsv(): string | null { + + const lines = rawCsv.trim().split("\n") + if (lines.length < 2) return "CSV 至少包含表头和 1 行数据" + + const header = lines[0]!.split(",").map((c) => c.trim()) + if (header.length !== CSV_COLUMN_COUNT) { + return `CSV 表头列数应为 ${CSV_COLUMN_COUNT},实际 ${header.length}` + } + + for (let i = 0; i < CSV_COLUMN_COUNT; i++) { + if (header[i] !== CSV_HEADER_COLS[i]) { + return `CSV 表头第 ${i + 1} 列应为 "${CSV_HEADER_COLS[i]}",实际 "${header[i]}"` + } + } + + // Count data rows (basic check, doesn't handle quoted newlines) + const dataRows = lines.length - 1 + if (dataRows > 200) return "单次最多导入 200 条" + + return null + } + + // ── Import handler ── + async function handleImport() { setImporting(true) + setValidationErrors([]) try { - const res = await importQuestions(parsedQuestions) - if (res.data) { + let res: { success: boolean; data: ImportSuccessResult | null; error: { code: string; message: string; details?: ImportValidationError[] } | null } + + if (mode === "json") { + res = await importQuestions(parsedItems) + } else { + res = await importQuestionsCsv(rawCsv) + } + + if (res.success && res.data) { setResult(res.data) setStep("result") - if (res.data.imported > 0) onSuccess() + if (res.data.succeeded > 0) onSuccess() + } else if (res.error) { + // VALIDATION_FAILED with details + if (res.error.details && res.error.details.length > 0) { + setValidationErrors(res.error.details) + setStep("result") + } else { + setStep("input") + setParseError(res.error.message || "导入失败") + } } - } catch { - setParseError("导入失败,请检查网络或联系管理员") + } catch (err) { + const message = + err instanceof Error ? err.message : "导入失败,请检查网络或联系管理员" + setStep("input") + setParseError(message) } finally { setImporting(false) } } + // ── Shared handlers ── + + function goToPreview() { + if (mode === "json") { + handleJsonParse() + } else { + const csvErr = validateCsv() + if (csvErr) { + setParseError(csvErr) + return + } + setStep("preview") + } + } + + function goBackToInput() { + setStep("input") + setParseError(null) + } + + const previewCount = + mode === "json" + ? parsedItems.length + : rawCsv.trim().split("\n").length - 1 + return ( 批量导入题目 - {step === "input" && "上传 JSON 文件或粘贴 JSON 内容"} - {step === "preview" && `已解析 ${parsedQuestions.length} 道题目,确认导入?`} + {step === "input" && "支持 JSON 和 CSV 两种格式导入"} + {step === "preview" && + `已解析 ${previewCount} 道题目,确认导入?`} {step === "result" && "导入完成"} + {/* ── Step: Input ── */} {step === "input" && (
-
- - - - 或直接粘贴 JSON - -
- -