Files
Next_Knowledge_Base/components/knowledge-dashboard.tsx
T
jiawei 73b9c9fb7a
knowledge-base / deploy (push) Failing after 11s
feat: 初版
2026-06-23 19:09:11 +08:00

356 lines
14 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import { toast } from "sonner"
import { Plus, Search, Boxes, X, Settings, Sparkles } 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 { Badge } from "@/components/ui/badge"
import type { KnowledgeItem, KnowledgeItemInput } from "@/lib/types"
import { CATEGORIES, MASTERY_LEVELS, MASTERY_META } from "@/lib/types"
import {
createItem,
deleteItem,
reviewItem,
toggleFavorite,
updateItem,
} from "@/lib/knowledge"
import { StatsBar } from "@/components/stats-bar"
import { KnowledgeCard } from "@/components/knowledge-card"
import { ItemDialog } from "@/components/item-dialog"
import { DetailDialog } from "@/components/detail-dialog"
import { AISettingsDialog } from "@/components/ai/ai-settings-dialog"
import { AIBatchImportDialog } from "@/components/ai/ai-batch-import-dialog"
type SortKey = "recent" | "review" | "title"
type SelectOption<T extends string = string> = { value: T; label: string }
// 通过对象定义 value 与展示文案,下方循环渲染,便于维护
const CATEGORY_OPTIONS: SelectOption[] = [
{ value: "all", label: "全部分类" },
...CATEGORIES.map((c) => ({ value: c, label: c })),
]
const MASTERY_OPTIONS: SelectOption[] = [
{ value: "all", label: "全部状态" },
...MASTERY_LEVELS.map((m) => ({ value: m, label: MASTERY_META[m].label })),
]
const SORT_OPTIONS: SelectOption<SortKey>[] = [
{ value: "recent", label: "最近添加" },
{ value: "review", label: "待复习" },
{ value: "title", label: "按标题" },
]
export function KnowledgeDashboard({ initialItems }: { initialItems: KnowledgeItem[] }) {
const [items, setItems] = useState<KnowledgeItem[]>(initialItems)
const [query, setQuery] = useState("")
const [category, setCategory] = useState<string>("all")
const [mastery, setMastery] = useState<string>("all")
const [favoritesOnly, setFavoritesOnly] = useState(false)
const [sort, setSort] = useState<SortKey>("recent")
const [activeTag, setActiveTag] = useState<string | null>(null)
const [formOpen, setFormOpen] = useState(false)
const [editing, setEditing] = useState<KnowledgeItem | null>(null)
const [detail, setDetail] = useState<KnowledgeItem | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [batchOpen, setBatchOpen] = useState(false)
const allTags = useMemo(() => {
const map = new Map<string, number>()
for (const it of items) for (const t of it.tags) map.set(t, (map.get(t) ?? 0) + 1)
return [...map.entries()].sort((a, b) => b[1] - a[1])
}, [items])
const filtered = useMemo(() => {
let list = items.filter((it) => {
if (category !== "all" && it.category !== category) return false
if (mastery !== "all" && it.mastery !== mastery) return false
if (favoritesOnly && !it.is_favorite) return false
if (activeTag && !it.tags.includes(activeTag)) return false
if (query) {
const q = query.toLowerCase()
const hay = [it.title, it.summary, it.content, it.notes, it.tags.join(" ")]
.filter(Boolean)
.join(" ")
.toLowerCase()
if (!hay.includes(q)) return false
}
return true
})
list = [...list].sort((a, b) => {
if (sort === "title") return a.title.localeCompare(b.title, "zh")
if (sort === "review") {
const av = a.next_review_at ? new Date(a.next_review_at).getTime() : Infinity
const bv = b.next_review_at ? new Date(b.next_review_at).getTime() : Infinity
return av - bv
}
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
return list
}, [items, category, mastery, favoritesOnly, activeTag, query, sort])
function openCreate() {
setEditing(null)
setFormOpen(true)
}
function openEdit(item: KnowledgeItem) {
setEditing(item)
setDetail(null)
setFormOpen(true)
}
async function handleSubmit(input: KnowledgeItemInput) {
try {
if (editing) {
const updated = await updateItem(editing.id, input)
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
toast.success("知识点已更新")
} else {
const created = await createItem(input)
setItems((prev) => [created, ...prev])
toast.success("知识点已添加")
}
setFormOpen(false)
setEditing(null)
} catch (e) {
toast.error("操作失败,请重试")
console.log("[v0] submit error:", e)
}
}
function handleBatchInserted(created: KnowledgeItem[]) {
setItems((prev) => [...created, ...prev])
}
async function handleDelete(id: string) {
try {
await deleteItem(id)
setItems((prev) => prev.filter((it) => it.id !== id))
setDetail(null)
toast.success("已删除")
} catch (e) {
toast.error("删除失败")
console.log("[v0] delete error:", e)
}
}
async function handleToggleFav(item: KnowledgeItem) {
const value = !item.is_favorite
setItems((prev) => prev.map((it) => (it.id === item.id ? { ...it, is_favorite: value } : it)))
try {
await toggleFavorite(item.id, value)
} catch (e) {
setItems((prev) => prev.map((it) => (it.id === item.id ? { ...it, is_favorite: !value } : it)))
toast.error("收藏失败")
console.log("[v0] fav error:", e)
}
}
async function handleReview(item: KnowledgeItem) {
try {
const updated = await reviewItem(item.id, item.review_count, item.mastery)
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
setDetail((d) => (d && d.id === updated.id ? updated : d))
toast.success(`已复习 · 累计 ${updated.review_count} 次`)
} catch (e) {
toast.error("记录复习失败")
console.log("[v0] review error:", e)
}
}
const hasFilters = category !== "all" || mastery !== "all" || favoritesOnly || activeTag || query
return (
<main className="flex min-h-screen flex-col">
{/* Header */}
<header className="sticky top-0 z-30 border-b border-border/60 bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg border border-primary/30 bg-primary/10 text-primary glow-ring">
<Boxes className="size-5" />
</div>
<div>
<h1 className="text-balance text-lg font-semibold tracking-tight">前端知识库</h1>
<p className="font-mono text-xs text-muted-foreground">DevVault · 收集 · 整理 · 复习</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setBatchOpen(true)}
className="gap-2 border-primary/30 text-primary hover:text-primary"
>
<Sparkles className="size-4" />
<span className="hidden sm:inline">AI 批量整理</span>
<span className="sm:hidden">AI</span>
</Button>
<Button onClick={openCreate} className="gap-2">
<Plus className="size-4" />
<span className="hidden sm:inline">新增知识点</span>
<span className="sm:hidden">新增</span>
</Button>
<Button variant="ghost" size="icon" onClick={() => setSettingsOpen(true)} aria-label="设置">
<Settings className="size-5" />
</Button>
</div>
</div>
</header>
<div className="tech-grid-bg flex-1">
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6 sm:py-8">
<StatsBar items={items} />
{/* Toolbar */}
<div className="mt-6 flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索标题、内容、标签..."
className="pl-9"
/>
</div>
<div className="flex flex-wrap gap-2">
<Select value={category} onValueChange={setCategory} items={CATEGORY_OPTIONS}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="分类" />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={mastery} onValueChange={setMastery} items={MASTERY_OPTIONS}>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="掌握度" />
</SelectTrigger>
<SelectContent>
{MASTERY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sort} onValueChange={(v) => setSort(v as SortKey)} items={SORT_OPTIONS}>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="排序" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant={favoritesOnly ? "default" : "outline"}
onClick={() => setFavoritesOnly((v) => !v)}
>
收藏
</Button>
</div>
</div>
{/* Tag cloud */}
{allTags.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{allTags.slice(0, 16).map(([tag, count]) => (
<button key={tag} onClick={() => setActiveTag((t) => (t === tag ? null : tag))}>
<Badge
variant={activeTag === tag ? "default" : "secondary"}
className="cursor-pointer font-mono text-xs"
>
#{tag}
<span className="ml-1 opacity-60">{count}</span>
</Badge>
</button>
))}
{hasFilters && (
<button
onClick={() => {
setQuery("")
setCategory("all")
setMastery("all")
setFavoritesOnly(false)
setActiveTag(null)
}}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<X className="size-3" /> 清除筛选
</button>
)}
</div>
)}
</div>
{/* Grid */}
<div className="mt-6">
<p className="mb-3 font-mono text-xs text-muted-foreground">
{filtered.length} / {items.length} 条记录
</p>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
<Boxes className="mb-3 size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{items.length === 0 ? "还没有知识点,点击「新增」开始收集吧" : "没有匹配的结果"}
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((item) => (
<KnowledgeCard
key={item.id}
item={item}
onOpen={() => setDetail(item)}
onToggleFav={() => handleToggleFav(item)}
onTagClick={(t) => setActiveTag((cur) => (cur === t ? null : t))}
/>
))}
</div>
)}
</div>
</div>
</div>
<ItemDialog
open={formOpen}
onOpenChange={setFormOpen}
initial={editing}
onSubmit={handleSubmit}
/>
<DetailDialog
item={detail}
onOpenChange={(o) => !o && setDetail(null)}
onEdit={openEdit}
onDelete={handleDelete}
onReview={handleReview}
onToggleFav={handleToggleFav}
onItemUpdated={(updated) => {
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
setDetail((d) => (d && d.id === updated.id ? updated : d))
}}
/>
<AISettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<AIBatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} onInserted={handleBatchInserted} />
</main>
)
}