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

View File

@ -11,6 +11,7 @@ import {
FileCheck, FileCheck,
AlertCircle, AlertCircle,
Shield, Shield,
BookMarked,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAuth } from "@/hooks/use-auth" import { useAuth } from "@/hooks/use-auth"
@ -20,6 +21,7 @@ const navItems = [
{ to: "/questions", label: "题库管理", icon: BookOpen }, { to: "/questions", label: "题库管理", icon: BookOpen },
{ to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck }, { to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck },
{ to: "/categories", label: "分类管理", icon: FolderOpen }, { to: "/categories", label: "分类管理", icon: FolderOpen },
{ to: "/knowledge-cards", label: "知识卡", icon: BookMarked },
{ to: "/skill-tree", label: "技能树", icon: TreePine }, { to: "/skill-tree", label: "技能树", icon: TreePine },
{ to: "/users", label: "用户管理", icon: Users }, { to: "/users", label: "用户管理", icon: Users },
{ to: "/feedback", label: "用户反馈", icon: MessageSquare }, { 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 categoryId?: string
difficulty?: Difficulty difficulty?: Difficulty
source?: "system" | "ugc" source?: "system" | "ugc"
sort?: "createdAt" | "difficulty" | "updatedAt"
order?: "asc" | "desc"
} }
export async function fetchQuestions( export async function fetchQuestions(
@ -23,6 +25,8 @@ export async function fetchQuestions(
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.order) searchParams.set("order", params.order)
return apiClient return apiClient
.get("questions", { searchParams }) .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, flexRender,
type ColumnDef, type ColumnDef,
} from "@tanstack/react-table" } 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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
@ -68,6 +68,8 @@ export default function QuestionsPage() {
const [statusFilter, setStatusFilter] = useState<QuestionStatus | "all">("all") const [statusFilter, setStatusFilter] = useState<QuestionStatus | "all">("all")
const [categoryFilter, setCategoryFilter] = useState<string>("all") const [categoryFilter, setCategoryFilter] = useState<string>("all")
const [difficultyFilter, setDifficultyFilter] = 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" // 从 URL 查询参数读取 source如果没有则默认为 "all"
const [sourceTab, setSourceTab] = useState<SourceTab>( const [sourceTab, setSourceTab] = useState<SourceTab>(
@ -127,6 +129,8 @@ export default function QuestionsPage() {
? (Number(difficultyFilter) as Difficulty) ? (Number(difficultyFilter) as Difficulty)
: undefined, : undefined,
source: sourceTab !== "all" ? sourceTab : undefined, source: sourceTab !== "all" ? sourceTab : undefined,
sort: sortField,
order: sortOrder,
}) })
setQuestions(res.data) setQuestions(res.data)
setTotal(res.pagination.total) setTotal(res.pagination.total)
@ -135,7 +139,7 @@ export default function QuestionsPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab]) }, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab, sortField, sortOrder])
useEffect(() => { useEffect(() => {
loadQuestions() loadQuestions()
@ -262,6 +266,8 @@ export default function QuestionsPage() {
difficulty: difficultyFilter !== "all" difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty) ? (Number(difficultyFilter) as Difficulty)
: undefined, : undefined,
sort: sortField,
order: sortOrder,
}) })
exportToCsv("questions.csv", [ exportToCsv("questions.csv", [
{ key: "stem", label: "题干" }, { key: "stem", label: "题干" },
@ -392,6 +398,33 @@ export default function QuestionsPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </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> </div>
{/* 批量操作栏 */} {/* 批量操作栏 */}