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