feat: 实现知识卡编辑组件(Phase 1b)
- 新建 KnowledgeCardFields 组件:基础版/深度版编辑、字符计数器、来源参考、预览面板 - 提取 QuestionForm 中内联的知识卡字段为独立组件 - 新增 sourceRef 字段到类型定义和表单 schema
This commit is contained in:
parent
1f2581efe9
commit
9314dc8505
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
**Phase 1b in progress.** Category CRUD + Question CRUD done. Remaining: 知识卡编辑、题目状态流转 UI 完善、批量导入。
|
**Phase 1b in progress.** Category CRUD + Question CRUD + 知识卡编辑 done. Remaining: 题目状态流转 UI 完善、批量导入。
|
||||||
|
|
||||||
Development follows the phased roadmap in [dev-spec.md](./dev-spec.md) §九.
|
Development follows the phased roadmap in [dev-spec.md](./dev-spec.md) §九.
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ src/
|
|||||||
│ ├── layout/ # Sidebar, Header, AdminLayout
|
│ ├── layout/ # Sidebar, Header, AdminLayout
|
||||||
│ ├── charts/ # StatsCard, chart wrappers
|
│ ├── charts/ # StatsCard, chart wrappers
|
||||||
│ ├── category/ # Category CRUD (columns, dialogs)
|
│ ├── category/ # Category CRUD (columns, dialogs)
|
||||||
│ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor)
|
│ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor, KnowledgeCardFields)
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── api-client.ts # HTTP client for /admin/* endpoints
|
│ ├── api-client.ts # HTTP client for /admin/* endpoints
|
||||||
│ ├── auth.ts # Admin JWT token management
|
│ ├── auth.ts # Admin JWT token management
|
||||||
|
|||||||
178
src/components/question/KnowledgeCardFields.tsx
Normal file
178
src/components/question/KnowledgeCardFields.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import type { UseFormRegisterReturn } from "react-hook-form"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
const BASIC_MAX = 100
|
||||||
|
const DEEP_MAX = 300
|
||||||
|
|
||||||
|
interface KnowledgeCardFieldsProps {
|
||||||
|
basicRegister: UseFormRegisterReturn
|
||||||
|
deepRegister: UseFormRegisterReturn
|
||||||
|
sourceRefRegister: UseFormRegisterReturn
|
||||||
|
basicError?: string
|
||||||
|
deepError?: string
|
||||||
|
watchBasic: string
|
||||||
|
watchDeep: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeCardFields({
|
||||||
|
basicRegister,
|
||||||
|
deepRegister,
|
||||||
|
sourceRefRegister,
|
||||||
|
basicError,
|
||||||
|
deepError,
|
||||||
|
watchBasic,
|
||||||
|
watchDeep,
|
||||||
|
}: KnowledgeCardFieldsProps) {
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
const [deepExpanded, setDeepExpanded] = useState(!!watchDeep)
|
||||||
|
|
||||||
|
const basicCount = watchBasic.length
|
||||||
|
const deepCount = watchDeep.length
|
||||||
|
const basicOver = basicCount > BASIC_MAX
|
||||||
|
const deepOver = deepCount > DEEP_MAX
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 基础版 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="knowledgeCardBasic">知识卡(基础版)</Label>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
所有用户可见
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
2-3 句趣味解读,让用户答完题后学到新知识
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
id="knowledgeCardBasic"
|
||||||
|
placeholder="例:唐太宗李世民不仅是千古一帝,还是书法发烧友。他最爱王羲之的字,据说《兰亭集序》真迹被他带进了昭陵。"
|
||||||
|
rows={3}
|
||||||
|
{...basicRegister}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{basicError && (
|
||||||
|
<p className="text-sm text-destructive">{basicError}</p>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`ml-auto text-xs ${basicOver ? "text-destructive font-medium" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{basicCount}/{BASIC_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>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Pro 用户可见
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{deepExpanded ? "收起" : "展开"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{deepExpanded && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
扩展背景故事、趣味延伸,给 Pro 用户更深层的内容体验
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
placeholder="例:王羲之写《兰亭集序》时喝了点酒,一气呵成。后来他多次重写都不满意,感叹「此神助耳,何吾能力致」。唐太宗派萧翼用计从辩才和尚手中骗得真迹,临终遗命将真迹陪葬昭陵。不过近年有学者认为,真迹可能并未入昭陵,而是另有下落……"
|
||||||
|
rows={5}
|
||||||
|
{...deepRegister}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{deepError && (
|
||||||
|
<p className="text-sm text-destructive">{deepError}</p>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`ml-auto text-xs ${deepOver ? "text-destructive font-medium" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{deepCount}/{DEEP_MAX}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 来源参考 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sourceRef">
|
||||||
|
来源参考
|
||||||
|
<span className="ml-1 text-muted-foreground font-normal">选填</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="sourceRef"
|
||||||
|
placeholder="如:《旧唐书·太宗本纪》"
|
||||||
|
{...sourceRefRegister}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 预览切换 */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowPreview((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{showPreview ? "关闭预览" : "预览效果"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showPreview && (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">知识卡预览</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
用户答完题后看到的知识卡效果
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{watchBasic ? (
|
||||||
|
<p className="text-sm leading-relaxed">{watchBasic}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
(基础版内容为空)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{watchDeep && (
|
||||||
|
<>
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Pro
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
深度解读
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{watchDeep}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { DistractorEditor } from "@/components/question/DistractorEditor"
|
import { DistractorEditor } from "@/components/question/DistractorEditor"
|
||||||
|
import { KnowledgeCardFields } from "@/components/question/KnowledgeCardFields"
|
||||||
import { fetchCategories } from "@/lib/api/category-api"
|
import { fetchCategories } from "@/lib/api/category-api"
|
||||||
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
||||||
import type { Question, Difficulty, QuestionStatus } from "@/types/question"
|
import type { Question, Difficulty, QuestionStatus } from "@/types/question"
|
||||||
@ -33,6 +34,7 @@ const questionSchema = z.object({
|
|||||||
status: z.enum(["draft", "reviewing", "published", "archived"]),
|
status: z.enum(["draft", "reviewing", "published", "archived"]),
|
||||||
knowledgeCardBasic: z.string().min(1, "请填写基础知识卡").max(100),
|
knowledgeCardBasic: z.string().min(1, "请填写基础知识卡").max(100),
|
||||||
knowledgeCardDeep: z.string().max(300).optional(),
|
knowledgeCardDeep: z.string().max(300).optional(),
|
||||||
|
sourceRef: z.string().max(500).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type FormValues = z.infer<typeof questionSchema>
|
type FormValues = z.infer<typeof questionSchema>
|
||||||
@ -65,6 +67,7 @@ export function QuestionForm({ question }: QuestionFormProps) {
|
|||||||
status: question.status,
|
status: question.status,
|
||||||
knowledgeCardBasic: question.knowledgeCardBasic,
|
knowledgeCardBasic: question.knowledgeCardBasic,
|
||||||
knowledgeCardDeep: question.knowledgeCardDeep ?? "",
|
knowledgeCardDeep: question.knowledgeCardDeep ?? "",
|
||||||
|
sourceRef: question.sourceRef ?? "",
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
stem: "",
|
stem: "",
|
||||||
@ -75,6 +78,7 @@ export function QuestionForm({ question }: QuestionFormProps) {
|
|||||||
status: "draft",
|
status: "draft",
|
||||||
knowledgeCardBasic: "",
|
knowledgeCardBasic: "",
|
||||||
knowledgeCardDeep: "",
|
knowledgeCardDeep: "",
|
||||||
|
sourceRef: "",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -208,40 +212,16 @@ export function QuestionForm({ question }: QuestionFormProps) {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* 知识卡(基础版) */}
|
{/* 知识卡 */}
|
||||||
<div className="space-y-2">
|
<KnowledgeCardFields
|
||||||
<Label htmlFor="knowledgeCardBasic">知识卡(基础版)</Label>
|
basicRegister={register("knowledgeCardBasic")}
|
||||||
<Textarea
|
deepRegister={register("knowledgeCardDeep")}
|
||||||
id="knowledgeCardBasic"
|
sourceRefRegister={register("sourceRef")}
|
||||||
placeholder="2-3 句趣味解读,100 字以内"
|
basicError={errors.knowledgeCardBasic?.message}
|
||||||
rows={3}
|
deepError={errors.knowledgeCardDeep?.message}
|
||||||
{...register("knowledgeCardBasic")}
|
watchBasic={watch("knowledgeCardBasic") ?? ""}
|
||||||
/>
|
watchDeep={watch("knowledgeCardDeep") ?? ""}
|
||||||
{errors.knowledgeCardBasic && (
|
/>
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{errors.knowledgeCardBasic.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 知识卡(深度版) */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="knowledgeCardDeep">
|
|
||||||
知识卡(深度版)
|
|
||||||
<span className="ml-1 text-muted-foreground font-normal">选填</span>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="knowledgeCardDeep"
|
|
||||||
placeholder="扩展背景故事、趣味延伸,300 字以内"
|
|
||||||
rows={4}
|
|
||||||
{...register("knowledgeCardDeep")}
|
|
||||||
/>
|
|
||||||
{errors.knowledgeCardDeep && (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{errors.knowledgeCardDeep.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 提交 */}
|
{/* 提交 */}
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export interface Question {
|
|||||||
status: QuestionStatus
|
status: QuestionStatus
|
||||||
knowledgeCardBasic: string
|
knowledgeCardBasic: string
|
||||||
knowledgeCardDeep?: string
|
knowledgeCardDeep?: string
|
||||||
|
sourceRef?: string
|
||||||
source: "system" | "ugc"
|
source: "system" | "ugc"
|
||||||
stats: {
|
stats: {
|
||||||
timesAnswered: number
|
timesAnswered: number
|
||||||
@ -30,4 +31,5 @@ export interface QuestionFormData {
|
|||||||
status: QuestionStatus
|
status: QuestionStatus
|
||||||
knowledgeCardBasic: string
|
knowledgeCardBasic: string
|
||||||
knowledgeCardDeep?: string
|
knowledgeCardDeep?: string
|
||||||
|
sourceRef?: string
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user