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

View File

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

View File

@ -46,7 +46,7 @@ export function StatusTransitionDialog({
<StatusBadge status={targetStatus} /> <StatusBadge status={targetStatus} />
</div> </div>
<p className="text-xs text-muted-foreground"> <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> </p>
</div> </div>
</AlertDialogDescription> </AlertDialogDescription>

View File

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

View File

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

View File

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

View File

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

View File

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