diff --git a/src/App.tsx b/src/App.tsx index a54d269..36a9150 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 }, diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 04b922e..9dc967a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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 }, diff --git a/src/lib/api/knowledge-card-api.ts b/src/lib/api/knowledge-card-api.ts new file mode 100644 index 0000000..47f30d4 --- /dev/null +++ b/src/lib/api/knowledge-card-api.ts @@ -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> { + 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>() +} + +export interface UpdateKnowledgeCardData { + basic: string + deep?: string + sourceRef?: string +} + +export async function updateKnowledgeCard( + id: string, + data: UpdateKnowledgeCardData +): Promise> { + return apiClient + .put(`knowledge-cards/${id}`, { json: data }) + .json>() +} diff --git a/src/lib/api/question-api.ts b/src/lib/api/question-api.ts index c345cdf..afe48d0 100644 --- a/src/lib/api/question-api.ts +++ b/src/lib/api/question-api.ts @@ -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 }) diff --git a/src/routes/knowledge-cards/index.tsx b/src/routes/knowledge-cards/index.tsx new file mode 100644 index 0000000..eef18c8 --- /dev/null +++ b/src/routes/knowledge-cards/index.tsx @@ -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([]) + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState("") + const [statusFilter, setStatusFilter] = useState("all") + + // 编辑对话框 + const [editOpen, setEditOpen] = useState(false) + const [editingCard, setEditingCard] = useState(null) + const [editForm, setEditForm] = useState({ basic: "", deep: "", sourceRef: "" }) + const [submitting, setSubmitting] = useState(false) + + // 详情对话框 + const [detailOpen, setDetailOpen] = useState(false) + const [detailCard, setDetailCard] = useState(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[] = [ + { + accessorKey: "questionStem", + header: "关联题目", + cell: ({ row }) => { + const stem = row.original.questionStem + return ( + + {stem.length > 60 ? stem.slice(0, 60) + "..." : stem} + + ) + }, + }, + { + accessorKey: "categoryId", + header: "分类", + cell: ({ row }) => ( + + {getCategoryName(row.original.categoryId)} + + ), + }, + { + id: "basicStatus", + header: "基础卡", + cell: ({ row }) => { + const hasBasic = row.original.basic?.trim().length > 0 + return ( + + {hasBasic ? `${row.original.basic.length} 字` : "未填写"} + + ) + }, + }, + { + id: "deepStatus", + header: "深度卡", + cell: ({ row }) => { + const hasDeep = (row.original.deep?.trim().length ?? 0) > 0 + return ( + + {hasDeep ? `${row.original.deep!.length} 字` : "未填写"} + + ) + }, + }, + { + id: "completeness", + header: "完成度", + cell: ({ row }) => { + const status = getCardStatus(row.original) + return ( + + {status === "complete" ? "完整" : "待补充"} + + ) + }, + }, + { + accessorKey: "updatedAt", + header: "更新时间", + cell: ({ row }) => ( + + {new Date(row.original.updatedAt).toLocaleDateString("zh-CN")} + + ), + }, + { + id: "actions", + header: "操作", + cell: ({ row }) => ( +
+ + +
+ ), + }, + ] + + const table = useReactTable({ + data: cards, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + // 统计 + const totalCards = cards.length + const completeCards = cards.filter((c) => getCardStatus(c) === "complete").length + + return ( + <> +
+ {/* 页面头部 */} +
+
+

知识卡管理

+

+ 管理{totalCards} 张知识卡,{completeCards} 张已完成,{totalCards - completeCards} 张待补充 +

+
+
+ + {/* 筛选栏 */} +
+
+ + setSearch(e.target.value)} + /> +
+ + +
+ + {/* 表格 */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {loading ? ( + + + 加载中... + + + ) : cards.length === 0 ? ( + + + 暂无知识卡数据 + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} + +
+
+
+ + {/* 查看详情 */} + + + + 知识卡详情 + + {detailCard && ( +
+
+ +

{detailCard.questionStem}

+
+
+
+ + 所有用户 +
+

+ {detailCard.basic || 未填写} +

+
+ {detailCard.deep && ( +
+
+ + Pro 用户 +
+

+ {detailCard.deep} +

+
+ )} + {detailCard.sourceRef && ( +
+ +

{detailCard.sourceRef}

+
+ )} +
+ )} + + + + +
+
+ + {/* 编辑对话框 */} + + + + 编辑知识卡 + + 编辑知识卡内容。基础版所有用户可见,深度版仅 Pro 用户可见。 + + + +
+ {editingCard && ( +
+ +

{editingCard.questionStem}

+
+ )} + +
+
+ + BASIC_MAX ? "text-destructive font-medium" : "text-muted-foreground"}`}> + {editForm.basic.length}/{BASIC_MAX} + +
+