feat: 对接题目批量导入接口(JSON + CSV)
- 新增 ImportQuestionItem / ImportSuccessResult / ImportValidationError 类型,匹配 API 规范
- importQuestions 改用 API 规范的 stem: { text }、contentType、嵌套 knowledgeCard 结构
- 新增 importQuestionsCsv 函数,POST text/plain 到 /admin/questions/import-csv
- 重写 ImportQuestionsDialog:JSON/CSV 双模式 Tab 切换、预览、校验错误详情展示
This commit is contained in:
parent
4cb26daa02
commit
a822e91c63
@ -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<HTMLInputElement>(null)
|
||||
const jsonFileRef = useRef<HTMLInputElement>(null)
|
||||
const csvFileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [mode, setMode] = useState<ImportMode>("json")
|
||||
const [step, setStep] = useState<ImportStep>("input")
|
||||
const [rawJson, setRawJson] = useState("")
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
const [parsedQuestions, setParsedQuestions] = useState<QuestionFormData[]>([])
|
||||
|
||||
// JSON state
|
||||
const [rawJson, setRawJson] = useState("")
|
||||
const [parsedItems, setParsedItems] = useState<ImportQuestionItem[]>([])
|
||||
|
||||
// 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<ImportSuccessResult | null>(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<HTMLInputElement>) {
|
||||
// ── JSON handlers ──
|
||||
|
||||
function handleJsonFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
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<HTMLInputElement>) {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量导入题目</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "input" && "上传 JSON 文件或粘贴 JSON 内容"}
|
||||
{step === "preview" && `已解析 ${parsedQuestions.length} 道题目,确认导入?`}
|
||||
{step === "input" && "支持 JSON 和 CSV 两种格式导入"}
|
||||
{step === "preview" &&
|
||||
`已解析 ${previewCount} 道题目,确认导入?`}
|
||||
{step === "result" && "导入完成"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ── Step: Input ── */}
|
||||
{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)
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(v) => {
|
||||
setMode(v as ImportMode)
|
||||
setParseError(null)
|
||||
setParsedItems([])
|
||||
if (v === "json") {
|
||||
setRawCsv("")
|
||||
setCsvFileName("")
|
||||
} else {
|
||||
setRawJson("")
|
||||
}
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="json" className="flex-1">
|
||||
JSON
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="csv" className="flex-1">
|
||||
CSV
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* JSON tab */}
|
||||
<TabsContent value="json" className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => jsonFileRef.current?.click()}
|
||||
>
|
||||
选择 JSON 文件
|
||||
</Button>
|
||||
<input
|
||||
ref={jsonFileRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleJsonFileUpload}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground self-center">
|
||||
或直接粘贴 JSON
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder={JSON_PLACEHOLDER}
|
||||
rows={12}
|
||||
value={rawJson}
|
||||
onChange={(e) => {
|
||||
setRawJson(e.target.value)
|
||||
setParseError(null)
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* CSV tab */}
|
||||
<TabsContent value="csv" className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => csvFileRef.current?.click()}
|
||||
>
|
||||
选择 CSV 文件
|
||||
</Button>
|
||||
<input
|
||||
ref={csvFileRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={handleCsvFileUpload}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground self-center">
|
||||
{csvFileName || "或直接粘贴 CSV 内容"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder={CSV_PLACEHOLDER}
|
||||
rows={8}
|
||||
value={rawCsv}
|
||||
onChange={(e) => {
|
||||
setRawCsv(e.target.value)
|
||||
setCsvFileName("")
|
||||
setParseError(null)
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
固定 13 列:categoryId, contentType, difficulty, stemText,
|
||||
correctAnswer, distractor1-5, cardSummary, cardDeepDive,
|
||||
cardSourceRef。至少填写 2 个 distractor。
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{parseError && (
|
||||
<p className="text-sm text-destructive">{parseError}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleParse} disabled={!rawJson.trim()}>
|
||||
解析 JSON
|
||||
<Button
|
||||
onClick={goToPreview}
|
||||
disabled={mode === "json" ? !rawJson.trim() : !rawCsv.trim()}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Preview ── */}
|
||||
{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>
|
||||
<Badge variant="secondary">{previewCount} 道</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">导入格式:</span>
|
||||
<Badge variant="outline">
|
||||
{mode === "json" ? "JSON" : "CSV"}
|
||||
</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>
|
||||
|
||||
{/* JSON mode: show parsed items */}
|
||||
{mode === "json" && (
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{parsedItems.map((q, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
>
|
||||
{i + 1}. {q.stem.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setStep("input")}>
|
||||
<Button variant="outline" onClick={goBackToInput}>
|
||||
返回修改
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={importing}>
|
||||
{importing ? "导入中..." : `确认导入 ${parsedQuestions.length} 道题目`}
|
||||
{importing
|
||||
? "导入中..."
|
||||
: `确认导入 ${previewCount} 道题目`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "result" && result && (
|
||||
{/* ── Step: Result ── */}
|
||||
{step === "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 && (
|
||||
{/* Success result */}
|
||||
{result && (
|
||||
<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="destructive">{result.failed} 道</Badge>
|
||||
<span className="text-muted-foreground">提交:</span>
|
||||
<Badge variant="secondary">{result.total} 道</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) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">成功导入:</span>
|
||||
<Badge variant="default">{result.succeeded} 道</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation errors */}
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="rounded-lg border border-destructive/50 p-3 space-y-2">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
校验失败({validationErrors.length} 条错误)
|
||||
</p>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{validationErrors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-destructive">
|
||||
第 {err.index + 1} 题: {err.error}
|
||||
第 {err.index + 1} 题: {err.errors.join(";")}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => handleClose(false)}>完成</Button>
|
||||
@ -250,3 +486,24 @@ export function ImportQuestionsDialog({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Placeholder text ──
|
||||
|
||||
const JSON_PLACEHOLDER = `[
|
||||
{
|
||||
"stem": { "text": "秦始皇统一六国是在哪一年?" },
|
||||
"contentType": "text",
|
||||
"correctAnswer": "公元前221年",
|
||||
"distractors": ["公元前206年", "公元前256年", "公元前230年"],
|
||||
"categoryId": "分类ID",
|
||||
"difficulty": 3,
|
||||
"knowledgeCard": {
|
||||
"summary": "秦始皇嬴政于公元前221年完成统一",
|
||||
"deepDive": "建立了中国历史上第一个大一统王朝",
|
||||
"sourceRef": "《史记·秦始皇本纪》"
|
||||
}
|
||||
}
|
||||
]`
|
||||
|
||||
const CSV_PLACEHOLDER = `categoryId,contentType,difficulty,stemText,correctAnswer,distractor1,distractor2,distractor3,distractor4,distractor5,cardSummary,cardDeepDive,cardSourceRef
|
||||
<分类ID>,text,3,秦始皇统一六国是在哪一年?,公元前221年,公元前206年,公元前256年,公元前230年,,,秦始皇嬴政于公元前221年完成统一,建立了中国历史上第一个大一统王朝,《史记·秦始皇本纪》`
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
import type { PaginatedResponse, ApiResponse } from "@/types/api"
|
||||
import type { Question, QuestionFormData, QuestionStatus, Difficulty } from "@/types/question"
|
||||
import type {
|
||||
Question,
|
||||
QuestionFormData,
|
||||
QuestionStatus,
|
||||
Difficulty,
|
||||
ImportQuestionItem,
|
||||
ImportSuccessResult,
|
||||
ImportValidationError,
|
||||
} from "@/types/question"
|
||||
|
||||
export interface FetchQuestionsParams {
|
||||
page?: number
|
||||
@ -96,16 +104,26 @@ export async function batchDeleteQuestions(
|
||||
.json<ApiResponse<BatchResult>>()
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number
|
||||
failed: number
|
||||
errors?: { index: number; error: string }[]
|
||||
}
|
||||
|
||||
/** JSON 批量导入 — 全有或全无 */
|
||||
export async function importQuestions(
|
||||
questions: QuestionFormData[]
|
||||
): Promise<ApiResponse<ImportResult>> {
|
||||
questions: ImportQuestionItem[]
|
||||
): Promise<ApiResponse<ImportSuccessResult>> {
|
||||
return apiClient
|
||||
.post("questions/import", { json: { questions } })
|
||||
.json<ApiResponse<ImportResult>>()
|
||||
.json<ApiResponse<ImportSuccessResult>>()
|
||||
}
|
||||
|
||||
/** CSV 批量导入 — 全有或全无 */
|
||||
export async function importQuestionsCsv(
|
||||
csvText: string
|
||||
): Promise<ApiResponse<ImportSuccessResult>> {
|
||||
return apiClient
|
||||
.post("questions/import-csv", {
|
||||
body: csvText,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
})
|
||||
.json<ApiResponse<ImportSuccessResult>>()
|
||||
}
|
||||
|
||||
/** 导入失败的校验错误详情(VALIDATION_FAILED 响应中的 error.details) */
|
||||
export type { ImportValidationError }
|
||||
|
||||
@ -33,3 +33,32 @@ export interface QuestionFormData {
|
||||
knowledgeCardDeep?: string
|
||||
sourceRef?: string
|
||||
}
|
||||
|
||||
// ── Import API types (match duoqi-api spec) ──
|
||||
|
||||
export type QuestionContentType = "text" | "image" | "video" | "audio"
|
||||
|
||||
export interface ImportQuestionItem {
|
||||
stem: { text: string }
|
||||
contentType: QuestionContentType
|
||||
correctAnswer: string
|
||||
distractors: string[]
|
||||
categoryId: string
|
||||
difficulty?: number
|
||||
knowledgeCard?: {
|
||||
summary: string
|
||||
deepDive?: string
|
||||
sourceRef?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImportSuccessResult {
|
||||
total: number
|
||||
succeeded: number
|
||||
ids: string[]
|
||||
}
|
||||
|
||||
export interface ImportValidationError {
|
||||
index: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user