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:
parent
a822e91c63
commit
37b936ec52
@ -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>
|
||||
</>
|
||||
|
||||
@ -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") ?? ""}
|
||||
/>
|
||||
|
||||
{/* 提交 */}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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: "题干" },
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user