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
+116 -22
View File
@@ -1,11 +1,12 @@
import type { QueryClient } from '@tanstack/react-query'
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute, Link } from '@tanstack/react-router'
import { Settings } from 'lucide-react'
import * as icons from 'lucide-react'
import { ArrowRight, Compass, Plus } from 'lucide-react'
import * as motion from 'motion/react-client'
import { orpc } from '@/client/orpc'
import { CategoryGrid } from '@/modules/bookmarks/components/CategoryGrid'
import { GreetingHeader } from '@/modules/bookmarks/components/GreetingHeader'
import { SearchBar } from '@/modules/bookmarks/components/SearchBar'
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
export const Route = createFileRoute('/_protected/' as never)({
loader: async ({ context }: { context: { queryClient: QueryClient } }) => {
@@ -14,31 +15,124 @@ export const Route = createFileRoute('/_protected/' as never)({
component: DashboardPage,
})
const getGreeting = (hour: number): string => {
if (hour >= 5 && hour < 12) return '早上好'
if (hour >= 12 && hour < 14) return '中午好'
if (hour >= 14 && hour < 18) return '下午好'
return '晚上好'
}
const formatDate = (date: Date): string => {
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const weekday = weekdays[date.getDay()]
return `${year}${month}${day}${weekday}`
}
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.06, delayChildren: 0.1 },
},
}
const itemVariants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] as const } },
}
function DashboardPage() {
const { data: categories } = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions())
const now = new Date()
const totalBookmarks = categories.reduce(
(sum: number, cat: { bookmarks: Array<{ id: string }> }) => sum + cat.bookmarks.length,
0,
)
const topBookmarks = categories
.flatMap((cat: { bookmarks: Array<{ id: string; name: string; url: string; icon: string | null }> }) =>
cat.bookmarks.slice(0, 4),
)
.slice(0, 8)
return (
<div className="min-h-screen bg-stone-50/50 px-4 py-12 font-sans sm:px-6">
<div className="mx-auto max-w-5xl space-y-12">
<div className="flex items-center justify-between">
<GreetingHeader />
<motion.div className="flex-1 px-6 pb-8" variants={containerVariants} initial="hidden" animate="visible">
<motion.div variants={itemVariants} className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">{getGreeting(now.getHours())}</h1>
<p className="mt-1 text-muted-foreground">{formatDate(now)}</p>
</motion.div>
{topBookmarks.length > 0 && (
<motion.div variants={itemVariants} className="mb-8">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-medium text-muted-foreground"></h2>
<Link
to={'/bookmarks' as never}
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowRight className="size-3" />
</Link>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{topBookmarks.map((bookmark: { id: string; name: string; url: string; icon: string | null }) => {
const Icon = (bookmark.icon && allIcons[bookmark.icon]) || icons.Globe
return (
<motion.a
key={bookmark.id}
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-3 rounded-xl border bg-card px-3.5 py-3 transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/10 hover:shadow-md"
variants={itemVariants}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted/60 transition-colors group-hover:bg-muted">
<Icon className="size-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<span className="min-w-0 truncate text-sm font-medium">{bookmark.name}</span>
</motion.a>
)
})}
</div>
</motion.div>
)}
<motion.div variants={itemVariants}>
<h2 className="mb-4 text-sm font-medium text-muted-foreground"></h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Link
to={'/admin' as never}
className="rounded-full p-2.5 text-stone-400 transition-colors hover:bg-stone-100 hover:text-stone-600"
title="管理后台"
to={'/bookmarks' as never}
className="group flex items-center gap-4 rounded-xl border bg-card p-5 transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/10 hover:shadow-md"
>
<Settings className="h-5 w-5" />
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted/60 transition-colors group-hover:bg-muted">
<Compass className="size-5 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground">
{categories.length} · {totalBookmarks}
</p>
</div>
<ArrowRight className="size-4 text-muted-foreground opacity-0 transition-all group-hover:opacity-100" />
</Link>
<Link
to={'/bookmarks' as never}
className="group flex items-center gap-4 rounded-xl border border-dashed bg-card p-5 transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/10 hover:shadow-md"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted/60 transition-colors group-hover:bg-muted">
<Plus className="size-5 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground"></p>
</div>
</Link>
</div>
<div className="mx-auto max-w-2xl py-4">
<SearchBar />
</div>
<main>
<CategoryGrid categories={categories} />
</main>
</div>
</div>
</motion.div>
</motion.div>
)
}