feat: 实现批量导入题目功能,Phase 1b 完成
- 新建 ImportQuestionsDialog 三步导入对话框(输入→预览→结果) - 支持 JSON 文件上传和手动粘贴,Zod 格式校验 - 新增 importQuestions API 函数 + ImportResult 类型 - 题目列表页新增批量导入按钮 - Phase 1b 全部功能完成
This commit is contained in:
parent
a5025e633e
commit
4bbdc590f4
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
## Current Status
|
## 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) §九.
|
Development follows the phased roadmap in [dev-spec.md](./dev-spec.md) §九.
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ src/
|
|||||||
│ ├── layout/ # Sidebar, Header, AdminLayout
|
│ ├── layout/ # Sidebar, Header, AdminLayout
|
||||||
│ ├── charts/ # StatsCard, chart wrappers
|
│ ├── charts/ # StatsCard, chart wrappers
|
||||||
│ ├── category/ # Category CRUD (columns, dialogs)
|
│ ├── category/ # Category CRUD (columns, dialogs)
|
||||||
│ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor, KnowledgeCardFields)
|
│ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor, KnowledgeCardFields, StatusTransitionDialog, ImportQuestionsDialog)
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── api-client.ts # HTTP client for /admin/* endpoints
|
│ ├── api-client.ts # HTTP client for /admin/* endpoints
|
||||||
│ ├── auth.ts # Admin JWT token management
|
│ ├── auth.ts # Admin JWT token management
|
||||||
|
|||||||
250
src/components/question/ImportQuestionsDialog.tsx
Normal file
250
src/components/question/ImportQuestionsDialog.tsx
Normal file
@ -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<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -54,3 +54,17 @@ export async function updateQuestionStatus(
|
|||||||
.patch(`questions/${id}/status`, { json: { status } })
|
.patch(`questions/${id}/status`, { json: { status } })
|
||||||
.json<ApiResponse<Question>>()
|
.json<ApiResponse<Question>>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
imported: number
|
||||||
|
failed: number
|
||||||
|
errors?: { index: number; error: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importQuestions(
|
||||||
|
questions: QuestionFormData[]
|
||||||
|
): Promise<ApiResponse<ImportResult>> {
|
||||||
|
return apiClient
|
||||||
|
.post("questions/import", { json: { questions } })
|
||||||
|
.json<ApiResponse<ImportResult>>()
|
||||||
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { getColumns } from "@/components/question/columns"
|
import { getColumns } from "@/components/question/columns"
|
||||||
import { StatusTransitionDialog } from "@/components/question/StatusTransitionDialog"
|
import { StatusTransitionDialog } from "@/components/question/StatusTransitionDialog"
|
||||||
|
import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDialog"
|
||||||
import { fetchQuestions, deleteQuestion, updateQuestionStatus } from "@/lib/api/question-api"
|
import { fetchQuestions, deleteQuestion, updateQuestionStatus } from "@/lib/api/question-api"
|
||||||
import { fetchCategories } from "@/lib/api/category-api"
|
import { fetchCategories } from "@/lib/api/category-api"
|
||||||
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
||||||
@ -63,6 +64,9 @@ export default function QuestionsPage() {
|
|||||||
const [statusTarget, setStatusTarget] = useState<Question | null>(null)
|
const [statusTarget, setStatusTarget] = useState<Question | null>(null)
|
||||||
const [statusTargetState, setStatusTargetState] = useState<QuestionStatus | null>(null)
|
const [statusTargetState, setStatusTargetState] = useState<QuestionStatus | null>(null)
|
||||||
|
|
||||||
|
// 批量导入对话框
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
// 加载分类列表(用于筛选和列显示)
|
// 加载分类列表(用于筛选和列显示)
|
||||||
@ -141,12 +145,17 @@ export default function QuestionsPage() {
|
|||||||
{/* 页面头部 */}
|
{/* 页面头部 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">题库管理</h1>
|
<h1 className="text-2xl font-bold">题库管理</h1>
|
||||||
<Button asChild>
|
<div className="flex gap-2">
|
||||||
<Link to="/questions/new">
|
<Button variant="outline" onClick={() => setImportOpen(true)}>
|
||||||
<Plus className="size-4" />
|
批量导入
|
||||||
新建题目
|
</Button>
|
||||||
</Link>
|
<Button asChild>
|
||||||
</Button>
|
<Link to="/questions/new">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
新建题目
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 筛选栏 */}
|
{/* 筛选栏 */}
|
||||||
@ -337,6 +346,13 @@ export default function QuestionsPage() {
|
|||||||
targetStatus={statusTargetState}
|
targetStatus={statusTargetState}
|
||||||
onConfirm={confirmStatusChange}
|
onConfirm={confirmStatusChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 批量导入 */}
|
||||||
|
<ImportQuestionsDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
onSuccess={loadQuestions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user