feat: 实现知识卡编辑组件(Phase 1b)
- 新建 KnowledgeCardFields 组件:基础版/深度版编辑、字符计数器、来源参考、预览面板 - 提取 QuestionForm 中内联的知识卡字段为独立组件 - 新增 sourceRef 字段到类型定义和表单 schema
This commit is contained in:
parent
1f2581efe9
commit
9314dc8505
@ -5,7 +5,7 @@
|
||||
|
||||
## 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) §九.
|
||||
|
||||
@ -38,7 +38,7 @@ src/
|
||||
│ ├── layout/ # Sidebar, Header, AdminLayout
|
||||
│ ├── charts/ # StatsCard, chart wrappers
|
||||
│ ├── category/ # Category CRUD (columns, dialogs)
|
||||
│ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor)
|
||||
│ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor, KnowledgeCardFields)
|
||||
├── lib/
|
||||
│ ├── api-client.ts # HTTP client for /admin/* endpoints
|
||||
│ ├── 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,
|
||||
} from "@/components/ui/select"
|
||||
import { DistractorEditor } from "@/components/question/DistractorEditor"
|
||||
import { KnowledgeCardFields } from "@/components/question/KnowledgeCardFields"
|
||||
import { fetchCategories } from "@/lib/api/category-api"
|
||||
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
||||
import type { Question, Difficulty, QuestionStatus } from "@/types/question"
|
||||
@ -33,6 +34,7 @@ const questionSchema = z.object({
|
||||
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(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof questionSchema>
|
||||
@ -65,6 +67,7 @@ export function QuestionForm({ question }: QuestionFormProps) {
|
||||
status: question.status,
|
||||
knowledgeCardBasic: question.knowledgeCardBasic,
|
||||
knowledgeCardDeep: question.knowledgeCardDeep ?? "",
|
||||
sourceRef: question.sourceRef ?? "",
|
||||
}
|
||||
: {
|
||||
stem: "",
|
||||
@ -75,6 +78,7 @@ export function QuestionForm({ question }: QuestionFormProps) {
|
||||
status: "draft",
|
||||
knowledgeCardBasic: "",
|
||||
knowledgeCardDeep: "",
|
||||
sourceRef: "",
|
||||
},
|
||||
})
|
||||
|
||||
@ -208,40 +212,16 @@ export function QuestionForm({ question }: QuestionFormProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 知识卡(基础版) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="knowledgeCardBasic">知识卡(基础版)</Label>
|
||||
<Textarea
|
||||
id="knowledgeCardBasic"
|
||||
placeholder="2-3 句趣味解读,100 字以内"
|
||||
rows={3}
|
||||
{...register("knowledgeCardBasic")}
|
||||
{/* 知识卡 */}
|
||||
<KnowledgeCardFields
|
||||
basicRegister={register("knowledgeCardBasic")}
|
||||
deepRegister={register("knowledgeCardDeep")}
|
||||
sourceRefRegister={register("sourceRef")}
|
||||
basicError={errors.knowledgeCardBasic?.message}
|
||||
deepError={errors.knowledgeCardDeep?.message}
|
||||
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">
|
||||
|
||||
@ -11,6 +11,7 @@ export interface Question {
|
||||
status: QuestionStatus
|
||||
knowledgeCardBasic: string
|
||||
knowledgeCardDeep?: string
|
||||
sourceRef?: string
|
||||
source: "system" | "ugc"
|
||||
stats: {
|
||||
timesAnswered: number
|
||||
@ -30,4 +31,5 @@ export interface QuestionFormData {
|
||||
status: QuestionStatus
|
||||
knowledgeCardBasic: string
|
||||
knowledgeCardDeep?: string
|
||||
sourceRef?: string
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user