refactor: 统一应用架构 — 消除前后台割裂,引入全局侧边栏、命令面板和 Motion 动效

- 移除独立的 /admin 路由层,路由扁平化为 /bookmarks
- 用 AppSidebar 替代 AdminSidebar,SidebarProvider 提升至 _protected 全局布局
- 新增 ⌘K 命令面板(cmdk + @tanstack/react-hotkeys),支持书签搜索、搜索引擎跳转和页面导航
- 书签页查看/管理一体化,通过编辑模式开关切换,AnimatePresence 平滑过渡
- 总览驾驶舱:Motion stagger 入场动画、常用书签快捷区、模块概览卡片
- 统一设计语言:BookmarkCard/CategoryGrid 用 design token 替代硬编码 stone 色
- ModuleMetadata.adminRoute 重命名为 route
- 同步更新 AGENTS.md 文档
This commit is contained in:
2026-03-31 21:36:44 +08:00
parent 588df9f143
commit a369fe853e
19 changed files with 973 additions and 283 deletions
@@ -0,0 +1,161 @@
import type { QueryClient } from '@tanstack/react-query'
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Pencil, X } from 'lucide-react'
import { AnimatePresence } from 'motion/react'
import * as motion from 'motion/react-client'
import { useEffect, useState } from 'react'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import { BookmarkCard } from '@/modules/bookmarks/components/BookmarkCard'
import { BookmarkManager } from '@/modules/bookmarks/components/BookmarkManager'
import { CategoryManager } from '@/modules/bookmarks/components/CategoryManager'
export const Route = createFileRoute('/_protected/bookmarks' as never)({
loader: async ({ context }: { context: { queryClient: QueryClient } }) => {
await context.queryClient.fetchQuery(orpc.bookmarks.category.list.queryOptions())
},
component: BookmarksPage,
})
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.05, delayChildren: 0.08 },
},
}
const itemVariants = {
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0, transition: { duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] as const } },
}
function BookmarksPage() {
const { data: categories } = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions())
const [editing, setEditing] = useState(false)
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
useEffect(() => {
if (selectedCategoryId === null && categories.length > 0) {
setSelectedCategoryId(categories[0]?.id ?? null)
}
}, [categories, selectedCategoryId])
useEffect(() => {
if (selectedCategoryId && !categories.some((c: { id: string }) => c.id === selectedCategoryId)) {
setSelectedCategoryId(categories[0]?.id ?? null)
}
}, [categories, selectedCategoryId])
const selectedCategory = categories.find((c: { id: string }) => c.id === selectedCategoryId)
return (
<div className="flex min-h-0 flex-1 flex-col px-6 pb-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{editing ? '书签管理' : '书签导航'}</h1>
<p className="mt-1 text-sm text-muted-foreground">
{editing ? '管理你的书签分类、图标和排序' : '常用链接和网站的快速导航'}
</p>
</div>
<Button variant={editing ? 'default' : 'outline'} size="sm" onClick={() => setEditing(!editing)}>
{editing ? (
<>
<X className="size-4" />
</>
) : (
<>
<Pencil className="size-4" />
</>
)}
</Button>
</div>
<AnimatePresence mode="wait">
{editing ? (
<motion.div
key="edit"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] as const }}
className="flex min-h-0 flex-1 gap-6"
>
<div className="w-80 shrink-0">
<CategoryManager
categories={categories}
selectedCategoryId={selectedCategoryId}
onSelectCategory={setSelectedCategoryId}
/>
</div>
<div className="min-w-0 flex-1">
{selectedCategory ? (
<BookmarkManager category={selectedCategory} />
) : (
<div className="flex h-full items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">
</div>
)}
</div>
</motion.div>
) : (
<motion.div
key="view"
variants={containerVariants}
initial="hidden"
animate="visible"
exit={{ opacity: 0 }}
className="min-h-0 flex-1 overflow-y-auto"
>
{categories.length === 0 ? (
<motion.div
variants={itemVariants}
className="flex flex-col items-center justify-center py-32 text-center"
>
<div className="mb-4 flex size-16 items-center justify-center rounded-2xl bg-muted">
<span className="text-2xl"></span>
</div>
<h3 className="mb-2 text-lg font-medium"></h3>
<p className="mb-6 text-sm text-muted-foreground"></p>
<Button onClick={() => setEditing(true)}>
<Pencil className="size-4" />
</Button>
</motion.div>
) : (
<div className="grid grid-cols-1 gap-10 md:grid-cols-2 lg:grid-cols-3">
{categories.map(
(category: {
id: string
name: string
bookmarks: Array<{ id: string; name: string; url: string; icon: string | null }>
}) => (
<motion.div key={category.id} variants={itemVariants} className="flex flex-col gap-3.5">
<h2 className="px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{category.name}
</h2>
{category.bookmarks.length === 0 ? (
<div className="rounded-xl border border-dashed py-6 text-center">
<span className="text-sm text-muted-foreground"></span>
</div>
) : (
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-1 xl:grid-cols-2">
{category.bookmarks.map((bookmark) => (
<BookmarkCard key={bookmark.id} bookmark={bookmark} />
))}
</div>
)}
</motion.div>
),
)}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
)
}