- API 路径前缀改为 /v1/admin - 分类管理改用服务端分页(page/limit),移除未定义的 search/status 筛选 - 知识卡字段重命名:basic→summary、deep→deepDive - 各页面移除不必要的 limit 参数
421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
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.summary && item.summary.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>({ summary: "", deepDive: "", sourceRef: "" })
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
// 详情对话框
|
||
const [detailOpen, setDetailOpen] = useState(false)
|
||
const [detailCard, setDetailCard] = useState<KnowledgeCardItem | null>(null)
|
||
|
||
useEffect(() => {
|
||
fetchCategories({}).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({
|
||
summary: card.summary,
|
||
deepDive: card.deepDive || "",
|
||
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.summary?.trim().length > 0
|
||
return (
|
||
<Badge variant={hasBasic ? "default" : "outline"}>
|
||
{hasBasic ? `${row.original.summary.length} 字` : "未填写"}
|
||
</Badge>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
id: "deepStatus",
|
||
header: "深度卡",
|
||
cell: ({ row }) => {
|
||
const hasDeep = (row.original.deepDive?.trim().length ?? 0) > 0
|
||
return (
|
||
<Badge variant={hasDeep ? "secondary" : "outline"}>
|
||
{hasDeep ? `${row.original.deepDive!.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.summary || <span className="italic text-muted-foreground">未填写</span>}
|
||
</p>
|
||
</div>
|
||
{detailCard.deepDive && (
|
||
<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.deepDive}
|
||
</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.summary.length > BASIC_MAX ? "text-destructive font-medium" : "text-muted-foreground"}`}>
|
||
{editForm.summary.length}/{BASIC_MAX}
|
||
</span>
|
||
</div>
|
||
<Textarea
|
||
id="edit-basic"
|
||
value={editForm.summary}
|
||
onChange={(e) => setEditForm({ ...editForm, summary: 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.deepDive && editForm.deepDive.length > DEEP_MAX ? "text-destructive font-medium" : "text-muted-foreground"}`}>
|
||
{(editForm.deepDive?.length ?? 0)}/{DEEP_MAX}
|
||
</span>
|
||
</div>
|
||
<Textarea
|
||
id="edit-deep"
|
||
value={editForm.deepDive ?? ""}
|
||
onChange={(e) => setEditForm({ ...editForm, deepDive: 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>
|
||
</>
|
||
)
|
||
}
|