feat: 添加 Sidebar 组件,支持动态导航和开发中提示
This commit is contained in:
20
package.json
20
package.json
@@ -20,13 +20,13 @@
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@kevisual/api": "^0.0.64",
|
||||
"@kevisual/context": "^0.0.8",
|
||||
"@kevisual/router": "0.1.2",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.167.3",
|
||||
"@kevisual/router": "0.1.6",
|
||||
"@tanstack/react-query": "^5.91.3",
|
||||
"@tanstack/react-router": "^1.168.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"convex": "^1.33.1",
|
||||
"convex": "^1.34.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
@@ -45,22 +45,22 @@
|
||||
"devDependencies": {
|
||||
"@kevisual/ai": "0.0.28",
|
||||
"@kevisual/kv-login": "^0.1.18",
|
||||
"@kevisual/query": "0.0.53",
|
||||
"@kevisual/query": "0.0.55",
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@kevisual/vite-html-plugin": "^0.0.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-router-devtools": "^1.166.9",
|
||||
"@tanstack/router-plugin": "^1.166.12",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/react-router-devtools": "^1.166.10",
|
||||
"@tanstack/router-plugin": "^1.167.1",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "v8.0.0",
|
||||
"vite": "v8.0.1",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
281
src/components/a/Sidebar.tsx
Normal file
281
src/components/a/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user