duoqi-admin/src/routes/knowledge-cards/index.tsx
Wang Zhuoxuan 2c2fc952f9 refactor: 对接 duoqi-api 文档规范
- API 路径前缀改为 /v1/admin
- 分类管理改用服务端分页(page/limit),移除未定义的 search/status 筛选
- 知识卡字段重命名:basic→summary、deep→deepDive
- 各页面移除不必要的 limit 参数
2026-04-11 15:10:44 +08:00

421 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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