feat: 实现知识卡编辑组件(Phase 1b)

- 新建 KnowledgeCardFields 组件:基础版/深度版编辑、字符计数器、来源参考、预览面板
- 提取 QuestionForm 中内联的知识卡字段为独立组件
- 新增 sourceRef 字段到类型定义和表单 schema
This commit is contained in:
Wang Zhuoxuan 2026-04-07 23:16:01 +08:00
parent 1f2581efe9
commit 9314dc8505
4 changed files with 196 additions and 36 deletions

View File

@ -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

View 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>
)
}

View File

@ -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">

View File

@ -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
}