duoqi-admin/src/components/question/ImportQuestionsDialog.tsx
Wang Zhuoxuan 4bbdc590f4 feat: 实现批量导入题目功能,Phase 1b 完成
- 新建 ImportQuestionsDialog 三步导入对话框(输入→预览→结果)
- 支持 JSON 文件上传和手动粘贴,Zod 格式校验
- 新增 importQuestions API 函数 + ImportResult 类型
- 题目列表页新增批量导入按钮
- Phase 1b 全部功能完成
2026-04-07 23:23:57 +08:00

251 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLInputElement>(null)
const [step, setStep] = useState<ImportStep>("input")
const [rawJson, setRawJson] = useState("")
const [parseError, setParseError] = useState<string | null>(null)
const [parsedQuestions, setParsedQuestions] = useState<QuestionFormData[]>([])
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<HTMLInputElement>) {
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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{step === "input" && "上传 JSON 文件或粘贴 JSON 内容"}
{step === "preview" && `已解析 ${parsedQuestions.length} 道题目,确认导入?`}
{step === "result" && "导入完成"}
</DialogDescription>
</DialogHeader>
{step === "input" && (
<div className="space-y-4">
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
JSON
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleFileUpload}
/>
<span className="text-xs text-muted-foreground self-center">
JSON
</span>
</div>
<Textarea
placeholder={`[\n {\n "stem": "题干",\n "correctAnswer": "正确答案",\n "distractors": ["干扰项1", "干扰项2", "干扰项3", "干扰项4"],\n "categoryId": "分类 ID",\n "difficulty": 3\n }\n]`}
rows={12}
value={rawJson}
onChange={(e) => {
setRawJson(e.target.value)
setParseError(null)
}}
className="font-mono text-xs"
/>
{parseError && (
<p className="text-sm text-destructive">{parseError}</p>
)}
<div className="flex justify-end">
<Button onClick={handleParse} disabled={!rawJson.trim()}>
JSON
</Button>
</div>
</div>
)}
{step === "preview" && (
<div className="space-y-4">
<div className="rounded-lg border p-3 space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground"></span>
<Badge variant="secondary">{parsedQuestions.length} </Badge>
</div>
<div className="text-muted-foreground">
稿
</div>
<div className="max-h-40 overflow-y-auto space-y-1">
{parsedQuestions.map((q, i) => (
<div key={i} className="text-xs text-muted-foreground truncate">
{i + 1}. {q.stem}
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setStep("input")}>
</Button>
<Button onClick={handleImport} disabled={importing}>
{importing ? "导入中..." : `确认导入 ${parsedQuestions.length} 道题目`}
</Button>
</div>
</div>
)}
{step === "result" && result && (
<div className="space-y-4">
<div className="rounded-lg border p-4 space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground"></span>
<Badge variant="default">{result.imported} </Badge>
</div>
{result.failed > 0 && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground"></span>
<Badge variant="destructive">{result.failed} </Badge>
</div>
)}
{result.errors && result.errors.length > 0 && (
<div className="mt-2 max-h-32 overflow-y-auto space-y-1">
{result.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive">
{err.index + 1} : {err.error}
</p>
))}
</div>
)}
</div>
<div className="flex justify-end">
<Button onClick={() => handleClose(false)}></Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}