356 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|