feat: 完善知识卡独立页面和题目列表排序

- 新增 /knowledge-cards 独立路由和列表页面
- 知识卡支持搜索、完成度筛选、详情查看和快速编辑
- 题目列表添加排序控件(创建时间/更新时间/难度 + 升降序)
- API 层支持 sort 和 order 参数

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wang Zhuoxuan 2026-04-08 18:41:45 +08:00
parent 87a1f39d51
commit 1fc27207e0
6 changed files with 512 additions and 2 deletions

View File

@ -6,6 +6,7 @@ import QuestionsPage from "@/routes/questions"
import NewQuestionPage from "@/routes/questions/new"
import EditQuestionPage from "@/routes/questions/$id.edit"
import CategoriesPage from "@/routes/categories"
import KnowledgeCardsPage from "@/routes/knowledge-cards"
import SkillTreePage from "@/routes/skill-tree"
import UsersPage from "@/routes/users"
import UserDetailPage from "@/routes/users/$id"
@ -33,6 +34,7 @@ const router = createBrowserRouter([
],
},
{ path: "categories", Component: CategoriesPage },
{ path: "knowledge-cards", Component: KnowledgeCardsPage },
{ path: "skill-tree", Component: SkillTreePage },
{ path: "feedback", Component: FeedbackPage },
{ path: "reports", Component: ReportsPage },

View File

@ -11,6 +11,7 @@ import {
FileCheck,
AlertCircle,
Shield,
BookMarked,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useAuth } from "@/hooks/use-auth"
@ -20,6 +21,7 @@ const navItems = [
{ to: "/questions", label: "题库管理", icon: BookOpen },
{ to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck },
{ to: "/categories", label: "分类管理", icon: FolderOpen },
{ to: "/knowledge-cards", label: "知识卡", icon: BookMarked },
{ to: "/skill-tree", label: "技能树", icon: TreePine },
{ to: "/users", label: "用户管理", icon: Users },
{ to: "/feedback", label: "用户反馈", icon: MessageSquare },

View File

@ -0,0 +1,49 @@
import { apiClient } from "@/lib/api-client"
import type { ApiResponse } from "@/types/api"
export interface KnowledgeCardItem {
id: string
questionId: string
questionStem: string
categoryId: string
basic: string
deep?: string
sourceRef?: string
updatedAt: string
}
export interface FetchKnowledgeCardsParams {
page?: number
limit?: number
search?: string
status?: "all" | "complete" | "incomplete"
}
export async function fetchKnowledgeCards(
params: FetchKnowledgeCardsParams = {}
): Promise<ApiResponse<KnowledgeCardItem[]>> {
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.status && params.status !== "all") searchParams.set("status", params.status)
return apiClient
.get("knowledge-cards", { searchParams })
.json<ApiResponse<KnowledgeCardItem[]>>()
}
export interface UpdateKnowledgeCardData {
basic: string
deep?: string
sourceRef?: string
}
export async function updateKnowledgeCard(
id: string,
data: UpdateKnowledgeCardData
): Promise<ApiResponse<KnowledgeCardItem>> {
return apiClient
.put(`knowledge-cards/${id}`, { json: data })
.json<ApiResponse<KnowledgeCardItem>>()
}

View File

@ -10,6 +10,8 @@ export interface FetchQuestionsParams {
categoryId?: string
difficulty?: Difficulty
source?: "system" | "ugc"
sort?: "createdAt" | "difficulty" | "updatedAt"
order?: "asc" | "desc"
}
export async function fetchQuestions(
@ -23,6 +25,8 @@ export async function fetchQuestions(
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)
return apiClient
.get("questions", { searchParams })

View File

@ -0,0 +1,420 @@
import { useState, useEffect, useCallback } from "react"
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
} from "@tanstack/react-table"
import { Search, Pencil, Eye } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { fetchKnowledgeCards, updateKnowledgeCard } from "@/lib/api/knowledge-card-api"
import type { KnowledgeCardItem, UpdateKnowledgeCardData } from "@/lib/api/knowledge-card-api"
import { fetchCategories } from "@/lib/api/category-api"
import type { Category } from "@/types/category"
const BASIC_MAX = 100
const DEEP_MAX = 300
type CardStatus = "all" | "complete" | "incomplete"
function getCardStatus(item: KnowledgeCardItem): "complete" | "incomplete" {
return item.basic && item.basic.trim().length > 0 ? "complete" : "incomplete"
}
export default function KnowledgeCardsPage() {
const [cards, setCards] = useState<KnowledgeCardItem[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<CardStatus>("all")
// 编辑对话框
const [editOpen, setEditOpen] = useState(false)
const [editingCard, setEditingCard] = useState<KnowledgeCardItem | null>(null)
const [editForm, setEditForm] = useState<UpdateKnowledgeCardData>({ basic: "", deep: "", sourceRef: "" })
const [submitting, setSubmitting] = useState(false)
// 详情对话框
const [detailOpen, setDetailOpen] = useState(false)
const [detailCard, setDetailCard] = useState<KnowledgeCardItem | null>(null)
useEffect(() => {
fetchCategories({ limit: 100 }).then((res) => setCategories(res.data))
}, [])
const loadCards = useCallback(async () => {
setLoading(true)
try {
const res = await fetchKnowledgeCards({
search: search || undefined,
status: statusFilter,
})
setCards(res.data)
} catch {
setCards([])
} finally {
setLoading(false)
}
}, [search, statusFilter])
useEffect(() => {
loadCards()
}, [loadCards])
function getCategoryName(categoryId: string): string {
return categories.find((c) => c.id === categoryId)?.name ?? categoryId
}
function openEdit(card: KnowledgeCardItem) {
setEditingCard(card)
setEditForm({
basic: card.basic,
deep: card.deep || "",
sourceRef: card.sourceRef || "",
})
setEditOpen(true)
}
function openDetail(card: KnowledgeCardItem) {
setDetailCard(card)
setDetailOpen(true)
}
async function handleSave() {
if (!editingCard) return
setSubmitting(true)
try {
await updateKnowledgeCard(editingCard.id, editForm)
setEditOpen(false)
await loadCards()
} finally {
setSubmitting(false)
}
}
const columns: ColumnDef<KnowledgeCardItem>[] = [
{
accessorKey: "questionStem",
header: "关联题目",
cell: ({ row }) => {
const stem = row.original.questionStem
return (
<span className="line-clamp-2 max-w-xs" title={stem}>
{stem.length > 60 ? stem.slice(0, 60) + "..." : stem}
</span>
)
},
},
{
accessorKey: "categoryId",
header: "分类",
cell: ({ row }) => (
<span className="text-muted-foreground">
{getCategoryName(row.original.categoryId)}
</span>
),
},
{
id: "basicStatus",
header: "基础卡",
cell: ({ row }) => {
const hasBasic = row.original.basic?.trim().length > 0
return (
<Badge variant={hasBasic ? "default" : "outline"}>
{hasBasic ? `${row.original.basic.length}` : "未填写"}
</Badge>
)
},
},
{
id: "deepStatus",
header: "深度卡",
cell: ({ row }) => {
const hasDeep = (row.original.deep?.trim().length ?? 0) > 0
return (
<Badge variant={hasDeep ? "secondary" : "outline"}>
{hasDeep ? `${row.original.deep!.length}` : "未填写"}
</Badge>
)
},
},
{
id: "completeness",
header: "完成度",
cell: ({ row }) => {
const status = getCardStatus(row.original)
return (
<Badge variant={status === "complete" ? "default" : "destructive"}>
{status === "complete" ? "完整" : "待补充"}
</Badge>
)
},
},
{
accessorKey: "updatedAt",
header: "更新时间",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
{new Date(row.original.updatedAt).toLocaleDateString("zh-CN")}
</span>
),
},
{
id: "actions",
header: "操作",
cell: ({ row }) => (
<div className="flex gap-2">
<Button variant="ghost" size="icon-xs" onClick={() => openDetail(row.original)} title="查看">
<Eye className="size-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" onClick={() => openEdit(row.original)} title="编辑">
<Pencil className="size-3.5" />
</Button>
</div>
),
},
]
const table = useReactTable({
data: cards,
columns,
getCoreRowModel: getCoreRowModel(),
})
// 统计
const totalCards = cards.length
const completeCards = cards.filter((c) => getCardStatus(c) === "complete").length
return (
<>
<div className="space-y-6">
{/* 页面头部 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground">
{totalCards} {completeCards} {totalCards - completeCards}
</p>
</div>
</div>
{/* 筛选栏 */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative max-w-xs flex-1">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="搜索关联题目..."
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={(val) => setStatusFilter(val as CardStatus)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="complete"></SelectItem>
<SelectItem value="incomplete"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 表格 */}
<div className="rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
...
</TableCell>
</TableRow>
) : cards.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
{/* 查看详情 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{detailCard && (
<div className="space-y-4 py-4">
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm">{detailCard.questionStem}</p>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<Label></Label>
<Badge variant="secondary" className="text-xs"></Badge>
</div>
<p className="text-sm leading-relaxed bg-muted/30 rounded-md p-3">
{detailCard.basic || <span className="italic text-muted-foreground"></span>}
</p>
</div>
{detailCard.deep && (
<div>
<div className="flex items-center gap-2 mb-1">
<Label></Label>
<Badge variant="outline" className="text-xs">Pro </Badge>
</div>
<p className="text-sm leading-relaxed bg-muted/30 rounded-md p-3 text-muted-foreground">
{detailCard.deep}
</p>
</div>
)}
{detailCard.sourceRef && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="text-sm">{detailCard.sourceRef}</p>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDetailOpen(false)}></Button>
<Button onClick={() => { setDetailOpen(false); if (detailCard) openEdit(detailCard) }}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 编辑对话框 */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
Pro
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{editingCard && (
<div className="rounded-md bg-muted/30 p-3">
<Label className="text-muted-foreground text-xs"></Label>
<p className="text-sm mt-1 line-clamp-2">{editingCard.questionStem}</p>
</div>
)}
<div>
<div className="flex items-center justify-between mb-2">
<Label htmlFor="edit-basic"></Label>
<span className={`text-xs ${editForm.basic.length > BASIC_MAX ? "text-destructive font-medium" : "text-muted-foreground"}`}>
{editForm.basic.length}/{BASIC_MAX}
</span>
</div>
<Textarea
id="edit-basic"
value={editForm.basic}
onChange={(e) => setEditForm({ ...editForm, basic: e.target.value })}
rows={3}
placeholder="2-3 句趣味解读..."
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<Label htmlFor="edit-deep">Pro</Label>
<span className={`text-xs ${editForm.deep && editForm.deep.length > DEEP_MAX ? "text-destructive font-medium" : "text-muted-foreground"}`}>
{(editForm.deep?.length ?? 0)}/{DEEP_MAX}
</span>
</div>
<Textarea
id="edit-deep"
value={editForm.deep ?? ""}
onChange={(e) => setEditForm({ ...editForm, deep: e.target.value })}
rows={5}
placeholder="扩展背景故事、趣味延伸..."
/>
</div>
<div>
<Label htmlFor="edit-sourceRef"></Label>
<Input
id="edit-sourceRef"
value={editForm.sourceRef ?? ""}
onChange={(e) => setEditForm({ ...editForm, sourceRef: e.target.value })}
placeholder="如:《旧唐书·太宗本纪》"
className="mt-2"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={submitting}>
</Button>
<Button onClick={handleSave} disabled={submitting}>
{submitting ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -6,7 +6,7 @@ import {
flexRender,
type ColumnDef,
} from "@tanstack/react-table"
import { Plus, Search, Trash2, EyeOff, Send, Download, FileCheck } from "lucide-react"
import { Plus, Search, Trash2, EyeOff, Send, Download, FileCheck, ArrowUpDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@ -68,6 +68,8 @@ export default function QuestionsPage() {
const [statusFilter, setStatusFilter] = useState<QuestionStatus | "all">("all")
const [categoryFilter, setCategoryFilter] = useState<string>("all")
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
const [sortField, setSortField] = useState<"createdAt" | "difficulty" | "updatedAt">("createdAt")
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc")
// 从 URL 查询参数读取 source如果没有则默认为 "all"
const [sourceTab, setSourceTab] = useState<SourceTab>(
@ -127,6 +129,8 @@ export default function QuestionsPage() {
? (Number(difficultyFilter) as Difficulty)
: undefined,
source: sourceTab !== "all" ? sourceTab : undefined,
sort: sortField,
order: sortOrder,
})
setQuestions(res.data)
setTotal(res.pagination.total)
@ -135,7 +139,7 @@ export default function QuestionsPage() {
} finally {
setLoading(false)
}
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab])
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab, sortField, sortOrder])
useEffect(() => {
loadQuestions()
@ -262,6 +266,8 @@ export default function QuestionsPage() {
difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty)
: undefined,
sort: sortField,
order: sortOrder,
})
exportToCsv("questions.csv", [
{ key: "stem", label: "题干" },
@ -392,6 +398,33 @@ export default function QuestionsPage() {
))}
</SelectContent>
</Select>
<Select
value={sortField}
onValueChange={(val) => {
setSortField(val as "createdAt" | "difficulty" | "updatedAt")
setPage(1)
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="createdAt"></SelectItem>
<SelectItem value="updatedAt"></SelectItem>
<SelectItem value="difficulty"></SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
className="size-9"
onClick={() => setSortOrder((prev) => prev === "asc" ? "desc" : "asc")}
title={sortOrder === "asc" ? "升序" : "降序"}
>
<ArrowUpDown className={`size-4 ${sortOrder === "asc" ? "rotate-180" : ""} transition-transform`} />
</Button>
</div>
{/* 批量操作栏 */}