feat: 添加 Sidebar 组件,支持动态导航和开发中提示

This commit is contained in:
xiongxiao
2026-03-20 22:58:14 +08:00
committed by cnb
parent bfcb5b6004
commit 02925f69f4
2 changed files with 291 additions and 10 deletions

View File

@@ -20,13 +20,13 @@
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"@kevisual/api": "^0.0.64", "@kevisual/api": "^0.0.64",
"@kevisual/context": "^0.0.8", "@kevisual/context": "^0.0.8",
"@kevisual/router": "0.1.2", "@kevisual/router": "0.1.6",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.91.3",
"@tanstack/react-router": "^1.167.3", "@tanstack/react-router": "^1.168.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"convex": "^1.33.1", "convex": "^1.34.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"es-toolkit": "^1.45.1", "es-toolkit": "^1.45.1",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
@@ -45,22 +45,22 @@
"devDependencies": { "devDependencies": {
"@kevisual/ai": "0.0.28", "@kevisual/ai": "0.0.28",
"@kevisual/kv-login": "^0.1.18", "@kevisual/kv-login": "^0.1.18",
"@kevisual/query": "0.0.53", "@kevisual/query": "0.0.55",
"@kevisual/types": "^0.0.12", "@kevisual/types": "^0.0.12",
"@kevisual/vite-html-plugin": "^0.0.1", "@kevisual/vite-html-plugin": "^0.0.1",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.2",
"@tanstack/react-router-devtools": "^1.166.9", "@tanstack/react-router-devtools": "^1.166.10",
"@tanstack/router-plugin": "^1.166.12", "@tanstack/router-plugin": "^1.167.1",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "v8.0.0", "vite": "v8.0.1",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.2.0"
} }
} }

View File

@@ -0,0 +1,281 @@
'use client'
import { useNavigate, useLocation } from '@tanstack/react-router'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'
import { Resizable } from 're-resizable'
export interface NavItem {
title: string
path: string
icon?: React.ReactNode
isDeveloping?: boolean
badge?: string
hidden?: boolean
children?: NavItem[]
external?: boolean
onClick?: () => void
}
export interface SidebarProps {
items: NavItem[]
className?: string
children?: React.ReactNode
logo?: React.ReactNode
title?: React.ReactNode
defaultCollapsed?: boolean
defaultWidth?: number
minWidth?: number
maxWidth?: number
}
export function Sidebar({
items,
className,
children,
logo,
title,
defaultCollapsed = false,
defaultWidth = 208,
minWidth = 120,
maxWidth = 400,
}: SidebarProps) {
const navigate = useNavigate()
const location = useLocation()
const currentPath = location.pathname
const [collapsed, setCollapsed] = useState(defaultCollapsed)
const [sidebarWidth, setSidebarWidth] = useState(defaultWidth)
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const [developingDialog, setDevelopingDialog] = useState<{ open: boolean; title: string }>({
open: false,
title: '',
})
const toggleGroup = (path: string) => {
const newExpanded = new Set(expandedGroups)
if (newExpanded.has(path)) {
newExpanded.delete(path)
} else {
newExpanded.add(path)
}
setExpandedGroups(newExpanded)
}
const handleNavClick = (item: NavItem) => {
// 优先执行 onClick 回调
if (item.onClick) {
item.onClick()
return
}
if (item.isDeveloping) {
setDevelopingDialog({ open: true, title: item.title })
} else if (item.external && item.path.startsWith('http')) {
window.open(item.path, '_blank')
} else if (item.path.startsWith('/')) {
navigate({ to: item.path })
} else {
navigate({ href: item.path })
}
}
// 判断当前路径是否激活(以导航路径开头)
const isActive = (path: string) => {
if (path === '/') {
return currentPath === '/'
}
return currentPath.startsWith(path);
}
// 渲染导航项
const renderNavItem = (item: NavItem, isChild = false) => {
if (item.hidden) return null
const hasChildren = item.children && item.children.length > 0
const isExpanded = expandedGroups.has(item.path)
const active = isActive(item.path)
return (
<li key={item.path} className='list-none'>
{hasChildren ? (
// 父菜单项(可展开)
<div>
<button
onClick={() => toggleGroup(item.path)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors cursor-pointer',
active
? 'bg-gray-200 text-gray-900 font-medium'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900',
collapsed && 'justify-center px-2'
)}
title={collapsed ? item.title : undefined}
>
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
{!collapsed && (
<>
<span className="flex-1 text-left">{item.title}</span>
<ChevronDown className={cn('w-4 h-4 transition-transform', isExpanded && 'rotate-180')} />
</>
)}
</button>
{/* 子菜单 */}
{!collapsed && isExpanded && item.children && (
<ul className="ml-6 mt-1 space-y-1 list-none">
{item.children.map(child => renderNavItem(child, true))}
</ul>
)}
</div>
) : (
// 普通菜单项
<button
onClick={() => handleNavClick(item)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors cursor-pointer',
active
? 'bg-gray-200 text-gray-900 font-medium'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900',
item.isDeveloping && 'opacity-60',
isChild && 'py-1.5',
collapsed && 'justify-center px-2'
)}
title={collapsed ? item.title : undefined}
>
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
{!collapsed && (
<>
<span className="flex-1 text-left">{item.title}</span>
{item.isDeveloping && (
<span className="text-xs bg-gray-200 text-gray-600 px-1.5 py-0.5 rounded">
dev
</span>
)}
{item.badge && !item.isDeveloping && (
<span className="text-xs bg-gray-200 text-gray-700 px-1.5 py-0.5 rounded">
{item.badge}
</span>
)}
</>
)}
</button>
)}
</li>
)
}
return (
<>
<div className={cn('flex h-full', className)}>
{/* 侧边栏 */}
{!collapsed ? (
<Resizable
defaultSize={{ width: sidebarWidth, height: '100%' }}
minWidth={minWidth}
maxWidth={maxWidth}
onResizeStop={(_e, _direction, ref, _d) => {
setSidebarWidth(ref.offsetWidth)
}}
enable={{
right: true,
}}
handleComponent={{
right: <div className="w-1 h-full cursor-col-resize hover:bg-blue-400 transition-colors" />,
}}
>
<aside
className={cn(
'border-r bg-white flex-shrink-0 flex flex-col'
)}
style={{ width: sidebarWidth }}
>
{/* Logo 区域 */}
<div className={cn(
'h-12 flex items-center border-b px-3'
)}>
{logo && (
<div className="flex-shrink-0 flex items-center gap-2">{logo}</div>
)}
{title && (
<span className="text-sm font-medium text-gray-900 truncate ml-1">{title}</span>
)}
<button
onClick={() => setCollapsed(true)}
className="ml-auto flex-shrink-0 p-1 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
title="收起"
>
<ChevronLeft className="w-4 h-4 text-gray-500" />
</button>
</div>
{/* 导航区域 */}
<nav className="flex-1 p-2 overflow-y-auto">
<ul className="space-y-1 list-none">
{items.map((item) => renderNavItem(item))}
</ul>
</nav>
</aside>
</Resizable>
) : (
// 收起状态
<aside className="w-14 border-r bg-white flex-shrink-0 flex flex-col">
{/* Logo 区域 */}
<div className="h-12 flex items-center justify-center border-b px-2">
<div className="group relative flex-shrink-0">
<button
onClick={() => setCollapsed(false)}
className="p-1 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
title="展开"
>
<span className="group-hover:hidden">{logo}</span>
<ChevronRight className="w-5 h-5 hidden group-hover:block text-gray-600" />
</button>
</div>
</div>
{/* 导航区域 */}
<nav className="flex-1 p-2 overflow-y-auto">
<ul className="space-y-1 list-none">
{items.map((item) => renderNavItem(item))}
</ul>
</nav>
</aside>
)}
{/* 主内容区域 */}
<main className="flex-1 overflow-auto h-full bg-gray-50">
{children}
</main>
</div>
{/* 开发中弹窗 */}
<Dialog
open={developingDialog.open}
onOpenChange={(open) => setDevelopingDialog({ open, title: '' })}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{developingDialog.title} - </DialogTitle>
<DialogDescription>
访
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setDevelopingDialog({ open: false, title: '' })}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}