feat: 对接题目查询接口,统一数据模型与 API 规范

- Question.stem 从 string 改为 { text: string } 对象,匹配 API 多语言题干格式
- 新增 contentType 字段(text/image/video/audio)
- knowledgeCard 从扁平字段重组为嵌套对象 { summary, deepDive?, sourceRef? }
- source/stats 改为可选字段,UI 添加空值回退
- 查询参数对齐:search→keyword, sort→sortBy, order→sortOrder
- QuestionForm 接入 createQuestion/updateQuestion API
- KnowledgeCardFields 组件重命名 props 匹配新结构
This commit is contained in:
Wang Zhuoxuan 2026-04-12 00:34:09 +08:00
parent a822e91c63
commit 37b936ec52
8 changed files with 201 additions and 126 deletions

View File

@ -13,42 +13,42 @@ import {
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
const BASIC_MAX = 100
const DEEP_MAX = 300
const SUMMARY_MAX = 2000
const DEEP_DIVE_MAX = 300
interface KnowledgeCardFieldsProps {
basicRegister: UseFormRegisterReturn
deepRegister: UseFormRegisterReturn
summaryRegister: UseFormRegisterReturn
deepDiveRegister: UseFormRegisterReturn
sourceRefRegister: UseFormRegisterReturn
basicError?: string
deepError?: string
watchBasic: string
watchDeep: string
summaryError?: string
deepDiveError?: string
watchSummary: string
watchDeepDive: string
}
export function KnowledgeCardFields({
basicRegister,
deepRegister,
summaryRegister,
deepDiveRegister,
sourceRefRegister,
basicError,
deepError,
watchBasic,
watchDeep,
summaryError,
deepDiveError,
watchSummary,
watchDeepDive,
}: KnowledgeCardFieldsProps) {
const [showPreview, setShowPreview] = useState(false)
const [deepExpanded, setDeepExpanded] = useState(!!watchDeep)
const [deepExpanded, setDeepExpanded] = useState(!!watchDeepDive)
const basicCount = watchBasic.length
const deepCount = watchDeep.length
const basicOver = basicCount > BASIC_MAX
const deepOver = deepCount > DEEP_MAX
const summaryCount = watchSummary.length
const deepCount = watchDeepDive.length
const summaryOver = summaryCount > SUMMARY_MAX
const deepOver = deepCount > DEEP_DIVE_MAX
return (
<div className="space-y-4">
{/* 基础版 */}
{/* 摘要 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="knowledgeCardBasic"></Label>
<Label htmlFor="cardSummary"></Label>
<Badge variant="secondary" className="text-xs">
</Badge>
@ -57,31 +57,31 @@ export function KnowledgeCardFields({
2-3
</p>
<Textarea
id="knowledgeCardBasic"
id="cardSummary"
placeholder="例:唐太宗李世民不仅是千古一帝,还是书法发烧友。他最爱王羲之的字,据说《兰亭集序》真迹被他带进了昭陵。"
rows={3}
{...basicRegister}
{...summaryRegister}
/>
<div className="flex items-center justify-between">
{basicError && (
<p className="text-sm text-destructive">{basicError}</p>
{summaryError && (
<p className="text-sm text-destructive">{summaryError}</p>
)}
<span
className={`ml-auto text-xs ${basicOver ? "text-destructive font-medium" : "text-muted-foreground"}`}
className={`ml-auto text-xs ${summaryOver ? "text-destructive font-medium" : "text-muted-foreground"}`}
>
{basicCount}/{BASIC_MAX}
{summaryCount}/{SUMMARY_MAX}
</span>
</div>
</div>
{/* 深度 */}
{/* 深度解析 */}
<div className="space-y-2">
<button
type="button"
className="flex items-center gap-2"
onClick={() => setDeepExpanded((prev) => !prev)}
>
<Label className="cursor-pointer"></Label>
<Label className="cursor-pointer"></Label>
<Badge variant="outline" className="text-xs">
Pro
</Badge>
@ -98,16 +98,16 @@ export function KnowledgeCardFields({
<Textarea
placeholder="例:王羲之写《兰亭集序》时喝了点酒,一气呵成。后来他多次重写都不满意,感叹「此神助耳,何吾能力致」。唐太宗派萧翼用计从辩才和尚手中骗得真迹,临终遗命将真迹陪葬昭陵。不过近年有学者认为,真迹可能并未入昭陵,而是另有下落……"
rows={5}
{...deepRegister}
{...deepDiveRegister}
/>
<div className="flex items-center justify-between">
{deepError && (
<p className="text-sm text-destructive">{deepError}</p>
{deepDiveError && (
<p className="text-sm text-destructive">{deepDiveError}</p>
)}
<span
className={`ml-auto text-xs ${deepOver ? "text-destructive font-medium" : "text-muted-foreground"}`}
>
{deepCount}/{DEEP_MAX}
{deepCount}/{DEEP_DIVE_MAX}
</span>
</div>
</>
@ -116,12 +116,12 @@ export function KnowledgeCardFields({
{/* 来源参考 */}
<div className="space-y-2">
<Label htmlFor="sourceRef">
<Label htmlFor="cardSourceRef">
<span className="ml-1 text-muted-foreground font-normal"></span>
</Label>
<Input
id="sourceRef"
id="cardSourceRef"
placeholder="如:《旧唐书·太宗本纪》"
{...sourceRefRegister}
/>
@ -146,14 +146,14 @@ export function KnowledgeCardFields({
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{watchBasic ? (
<p className="text-sm leading-relaxed">{watchBasic}</p>
{watchSummary ? (
<p className="text-sm leading-relaxed">{watchSummary}</p>
) : (
<p className="text-sm text-muted-foreground italic">
</p>
)}
{watchDeep && (
{watchDeepDive && (
<>
<div className="border-t pt-3">
<div className="flex items-center gap-1 mb-1">
@ -165,7 +165,7 @@ export function KnowledgeCardFields({
</span>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">
{watchDeep}
{watchDeepDive}
</p>
</div>
</>

View File

@ -18,12 +18,14 @@ import {
import { DistractorEditor } from "@/components/question/DistractorEditor"
import { KnowledgeCardFields } from "@/components/question/KnowledgeCardFields"
import { fetchCategories } from "@/lib/api/category-api"
import { createQuestion, updateQuestion } from "@/lib/api/question-api"
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
import type { Question, Difficulty, QuestionStatus } from "@/types/question"
import type { Question, Difficulty, QuestionStatus, QuestionContentType } from "@/types/question"
import type { Category } from "@/types/category"
const questionSchema = z.object({
stem: z.string().min(1, "请输入题干").max(500),
stemText: z.string().min(1, "请输入题干").max(500),
contentType: z.enum(["text", "image", "video", "audio"]),
correctAnswer: z.string().min(1, "请输入正确答案"),
distractors: z
.array(z.string().min(1, "干扰项不能为空"))
@ -32,9 +34,9 @@ const questionSchema = z.object({
categoryId: z.string().min(1, "请选择分类"),
difficulty: z.number().min(1).max(5),
status: z.enum(["draft", "reviewing", "published", "archived"]),
knowledgeCardBasic: z.string().min(1, "请填写基础知识卡").max(100),
knowledgeCardDeep: z.string().max(300).optional(),
sourceRef: z.string().max(500).optional(),
cardSummary: z.string().max(2000).optional(),
cardDeepDive: z.string().max(300).optional(),
cardSourceRef: z.string().max(500).optional(),
})
type FormValues = z.infer<typeof questionSchema>
@ -59,26 +61,28 @@ export function QuestionForm({ question }: QuestionFormProps) {
resolver: zodResolver(questionSchema),
defaultValues: question
? {
stem: question.stem,
stemText: (question.stem as { text: string }).text,
contentType: question.contentType ?? "text" as QuestionContentType,
correctAnswer: question.correctAnswer,
distractors: question.distractors,
categoryId: question.categoryId,
difficulty: question.difficulty,
status: question.status,
knowledgeCardBasic: question.knowledgeCardBasic,
knowledgeCardDeep: question.knowledgeCardDeep ?? "",
sourceRef: question.sourceRef ?? "",
cardSummary: question.knowledgeCard?.summary ?? "",
cardDeepDive: question.knowledgeCard?.deepDive ?? "",
cardSourceRef: question.knowledgeCard?.sourceRef ?? "",
}
: {
stem: "",
stemText: "",
contentType: "text" as QuestionContentType,
correctAnswer: "",
distractors: ["", "", "", ""],
categoryId: "",
difficulty: 3,
status: "draft",
knowledgeCardBasic: "",
knowledgeCardDeep: "",
sourceRef: "",
cardSummary: "",
cardDeepDive: "",
cardSourceRef: "",
},
})
@ -89,8 +93,27 @@ export function QuestionForm({ question }: QuestionFormProps) {
async function onSubmit(data: FormValues) {
setSubmitting(true)
try {
// TODO: 接入 API
console.log("submit", isEditing ? "update" : "create", data)
const payload = {
stem: { text: data.stemText },
contentType: data.contentType,
correctAnswer: data.correctAnswer,
distractors: data.distractors,
categoryId: data.categoryId,
difficulty: data.difficulty as Difficulty,
knowledgeCard: data.cardSummary
? {
summary: data.cardSummary,
deepDive: data.cardDeepDive || undefined,
sourceRef: data.cardSourceRef || undefined,
}
: undefined,
}
if (isEditing && question) {
await updateQuestion(question.id, payload)
} else {
await createQuestion(payload)
}
navigate("/questions")
} finally {
setSubmitting(false)
@ -103,18 +126,37 @@ export function QuestionForm({ question }: QuestionFormProps) {
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
{/* 题干 */}
<div className="space-y-2">
<Label htmlFor="stem"></Label>
<Label htmlFor="stemText"></Label>
<Textarea
id="stem"
id="stemText"
placeholder="输入题目文字"
rows={3}
{...register("stem")}
{...register("stemText")}
/>
{errors.stem && (
<p className="text-sm text-destructive">{errors.stem.message}</p>
{errors.stemText && (
<p className="text-sm text-destructive">{errors.stemText.message}</p>
)}
</div>
{/* 内容类型 */}
<div className="space-y-2">
<Label></Label>
<Select
value={watch("contentType")}
onValueChange={(val) => setValue("contentType", val as QuestionContentType)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="image"></SelectItem>
<SelectItem value="video"></SelectItem>
<SelectItem value="audio"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 正确答案 */}
<div className="space-y-2">
<Label htmlFor="correctAnswer"></Label>
@ -214,13 +256,13 @@ export function QuestionForm({ question }: QuestionFormProps) {
{/* 知识卡 */}
<KnowledgeCardFields
basicRegister={register("knowledgeCardBasic")}
deepRegister={register("knowledgeCardDeep")}
sourceRefRegister={register("sourceRef")}
basicError={errors.knowledgeCardBasic?.message}
deepError={errors.knowledgeCardDeep?.message}
watchBasic={watch("knowledgeCardBasic") ?? ""}
watchDeep={watch("knowledgeCardDeep") ?? ""}
summaryRegister={register("cardSummary")}
deepDiveRegister={register("cardDeepDive")}
sourceRefRegister={register("cardSourceRef")}
summaryError={errors.cardSummary?.message}
deepDiveError={errors.cardDeepDive?.message}
watchSummary={watch("cardSummary") ?? ""}
watchDeepDive={watch("cardDeepDive") ?? ""}
/>
{/* 提交 */}

View File

@ -46,7 +46,7 @@ export function StatusTransitionDialog({
<StatusBadge status={targetStatus} />
</div>
<p className="text-xs text-muted-foreground">
{question.stem.length > 40 ? question.stem.slice(0, 40) + "..." : question.stem}
{question.stem.text.length > 40 ? question.stem.text.slice(0, 40) + "..." : question.stem.text}
</p>
</div>
</AlertDialogDescription>

View File

@ -95,7 +95,7 @@ export function UgcReviewDialog({
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="font-medium">{QUESTION_SOURCE_LABELS[question.source]}</p>
<p className="font-medium">{question.source ? QUESTION_SOURCE_LABELS[question.source] : "—"}</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
@ -108,7 +108,7 @@ export function UgcReviewDialog({
{/* 题干 */}
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm leading-relaxed">{question.stem}</p>
<p className="mt-1 text-sm leading-relaxed">{(question.stem as { text: string }).text}</p>
</div>
{/* 正确答案 */}
@ -130,23 +130,35 @@ export function UgcReviewDialog({
</div>
{/* 知识卡 */}
{question.knowledgeCard && (
<>
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm leading-relaxed">{question.knowledgeCardBasic}</p>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm leading-relaxed">{question.knowledgeCard.summary}</p>
</div>
{question.knowledgeCardDeep && (
{question.knowledgeCard.deepDive && (
<div>
<Label className="text-muted-foreground">Pro</Label>
<Label className="text-muted-foreground">Pro</Label>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{question.knowledgeCardDeep}
{question.knowledgeCard.deepDive}
</p>
</div>
)}
{question.knowledgeCard.sourceRef && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm text-muted-foreground">{question.knowledgeCard.sourceRef}</p>
</div>
)}
</>
)}
<Separator />
{/* 统计信息 */}
{question.stats && (
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<Label className="text-muted-foreground"></Label>
@ -161,6 +173,7 @@ export function UgcReviewDialog({
<p className="font-medium">{(question.stats.avgTimeMs / 1000).toFixed(1)}</p>
</div>
</div>
)}
<Separator />

View File

@ -57,10 +57,11 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
accessorKey: "stem",
header: "题干",
cell: ({ row }) => {
const stem = row.getValue("stem") as string
const stem = row.getValue("stem") as { text: string } | undefined
const text = stem?.text ?? ""
return (
<span className="line-clamp-2 max-w-xs" title={stem}>
{stem.length > 60 ? stem.slice(0, 60) + "..." : stem}
<span className="line-clamp-2 max-w-xs" title={text}>
{text.length > 60 ? text.slice(0, 60) + "..." : text}
</span>
)
},
@ -82,7 +83,8 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
accessorKey: "source",
header: "来源",
cell: ({ row }) => {
const source = row.getValue("source") as "system" | "ugc"
const source = row.getValue("source") as "system" | "ugc" | undefined
if (!source) return <span className="text-muted-foreground"></span>
return (
<Badge variant={source === "system" ? "default" : "secondary"}>
{QUESTION_SOURCE_LABELS[source]}
@ -136,10 +138,11 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
id: "stats",
header: "统计",
cell: ({ row }) => {
const { timesAnswered, correctRate } = row.original.stats
const stats = row.original.stats
if (!stats) return <span className="text-muted-foreground text-xs"></span>
return (
<span className="text-muted-foreground text-xs">
{timesAnswered} · {(correctRate * 100).toFixed(0)}%
{stats.timesAnswered} · {(stats.correctRate * 100).toFixed(0)}%
</span>
)
},

View File

@ -13,13 +13,13 @@ import type {
export interface FetchQuestionsParams {
page?: number
limit?: number
search?: string
keyword?: string
status?: QuestionStatus
categoryId?: string
difficulty?: Difficulty
source?: "system" | "ugc"
sort?: "createdAt" | "difficulty" | "updatedAt"
order?: "asc" | "desc"
sortBy?: "createdAt" | "difficulty" | "updatedAt"
sortOrder?: "asc" | "desc"
}
export async function fetchQuestions(
@ -28,13 +28,13 @@ export async function fetchQuestions(
const searchParams = new URLSearchParams()
if (params.page) searchParams.set("page", String(params.page))
if (params.limit) searchParams.set("limit", String(params.limit))
if (params.search) searchParams.set("search", params.search)
if (params.keyword) searchParams.set("keyword", params.keyword)
if (params.status) searchParams.set("status", params.status)
if (params.categoryId) searchParams.set("categoryId", params.categoryId)
if (params.difficulty) searchParams.set("difficulty", String(params.difficulty))
if (params.source) searchParams.set("source", params.source)
if (params.sort) searchParams.set("sort", params.sort)
if (params.order) searchParams.set("order", params.order)
if (params.sortBy) searchParams.set("sortBy", params.sortBy)
if (params.sortOrder) searchParams.set("sortOrder", params.sortOrder)
return apiClient
.get("questions", { searchParams })

View File

@ -126,15 +126,15 @@ export default function QuestionsPage() {
const res = await fetchQuestions({
page,
limit: PAGE_SIZE,
search: search || undefined,
keyword: search || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
categoryId: categoryFilter !== "all" ? categoryFilter : undefined,
difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty)
: undefined,
source: sourceTab !== "all" ? sourceTab : undefined,
sort: sortField,
order: sortOrder,
sortBy: sortField,
sortOrder,
})
setQuestions(res.data)
setTotal(res.pagination.total)
@ -289,14 +289,14 @@ export default function QuestionsPage() {
try {
const res = await fetchQuestions({
limit: 10000,
search: search || undefined,
keyword: search || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
categoryId: categoryFilter !== "all" ? categoryFilter : undefined,
difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty)
: undefined,
sort: sortField,
order: sortOrder,
sortBy: sortField,
sortOrder,
})
exportToCsv("questions.csv", [
{ key: "stem", label: "题干" },

View File

@ -1,43 +1,60 @@
export type QuestionStatus = "draft" | "reviewing" | "published" | "archived"
export type Difficulty = 1 | 2 | 3 | 4 | 5
export type QuestionContentType = "text" | "image" | "video" | "audio"
/** Stem structure matching duoqi-api — at least `text` field */
export interface QuestionStem {
text: string
[key: string]: unknown
}
/** Knowledge card nested object matching duoqi-api */
export interface KnowledgeCard {
id?: string
summary: string
deepDive?: string
sourceRef?: string
}
export interface Question {
id: string
stem: string
stem: QuestionStem
contentType: QuestionContentType
correctAnswer: string
distractors: string[]
categoryId: string
difficulty: Difficulty
status: QuestionStatus
knowledgeCardBasic: string
knowledgeCardDeep?: string
sourceRef?: string
source: "system" | "ugc"
stats: {
knowledgeCard?: KnowledgeCard
/** Optional — not returned by list endpoint, may be present in detail */
source?: "system" | "ugc"
/** Optional — not currently returned by API, reserved for future use */
stats?: {
timesAnswered: number
correctRate: number
avgTimeMs: number
}
createdAt: string
createdAt?: string
updatedAt: string
}
/** Payload for creating / updating a question */
export interface QuestionFormData {
stem: string
stem: { text: string }
contentType: QuestionContentType
correctAnswer: string
distractors: string[]
categoryId: string
difficulty: Difficulty
status: QuestionStatus
knowledgeCardBasic: string
knowledgeCardDeep?: string
knowledgeCard?: {
summary: string
deepDive?: 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