refactor(components): split layout.tsx into layout/ directory
This commit is contained in:
135
dashboard/package-lock.json
generated
135
dashboard/package-lock.json
generated
@@ -55,7 +55,7 @@
|
||||
"dagre": "^0.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"jotai": "^2.16.0",
|
||||
"idb": "^8.0.3",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.556.0",
|
||||
"react": "^19.2.1",
|
||||
@@ -73,6 +73,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -3501,6 +3502,43 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"dev": true,
|
||||
@@ -3561,6 +3599,13 @@
|
||||
"version": "0.3.5",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"dev": true,
|
||||
@@ -4564,6 +4609,16 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"dev": true,
|
||||
@@ -6275,6 +6330,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"dev": true,
|
||||
@@ -6470,33 +6531,6 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jotai": {
|
||||
"version": "2.17.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": ">=7.0.0",
|
||||
"@babel/template": ">=7.0.0",
|
||||
"@types/react": ">=17.0.0",
|
||||
"react": ">=17.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@babel/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@babel/template": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"license": "MIT"
|
||||
@@ -6694,6 +6728,16 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"dev": true,
|
||||
@@ -8130,6 +8174,41 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
import { Menu, Moon, Sun, ChevronLeft, Home, Settings, LogOut, FileText, Server, Boxes, Smile, MessageSquare, UserCircle, FileSearch, Package, BookOpen, Search, Sliders, Network, Hash, LayoutGrid, Database, Activity, PieChart } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router'
|
||||
import { useTheme, toggleThemeWithTransition } from './use-theme'
|
||||
import { useAuthGuard } from '@/hooks/use-auth'
|
||||
import { logout } from '@/lib/fetch-with-auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { HttpWarningBanner } from '@/components/http-warning-banner'
|
||||
import { BackToTop } from '@/components/back-to-top'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatVersion } from '@/lib/version'
|
||||
import type { ReactNode, ComponentType } from 'react'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
import { BackgroundLayer } from '@/components/background-layer'
|
||||
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
icon: ComponentType<LucideProps>
|
||||
label: string
|
||||
path: string
|
||||
tourId?: string
|
||||
}
|
||||
|
||||
interface MenuSection {
|
||||
title: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const { checking } = useAuthGuard() // 检查认证状态
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [tooltipsEnabled, setTooltipsEnabled] = useState(false) // 控制 tooltip 启用状态
|
||||
const { theme, setTheme } = useTheme()
|
||||
const matchRoute = useMatchRoute()
|
||||
|
||||
// 侧边栏状态变化时,延迟启用/禁用 tooltip
|
||||
useEffect(() => {
|
||||
if (sidebarOpen) {
|
||||
// 侧边栏展开时,立即禁用 tooltip
|
||||
setTooltipsEnabled(false)
|
||||
} else {
|
||||
// 侧边栏收起时,等待动画完成后再启用 tooltip
|
||||
const timer = setTimeout(() => {
|
||||
setTooltipsEnabled(true)
|
||||
}, 350) // 稍大于 CSS transition duration (300ms)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [sidebarOpen])
|
||||
|
||||
// 搜索快捷键监听(Cmd/Ctrl + K)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setSearchOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// 认证检查中,显示加载状态
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-muted-foreground">正在验证登录状态...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 菜单项配置 - 分块结构
|
||||
const menuSections: MenuSection[] = [
|
||||
{
|
||||
title: '概览',
|
||||
items: [
|
||||
{ icon: Home, label: '首页', path: '/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '麦麦配置编辑',
|
||||
items: [
|
||||
{ icon: FileText, label: '麦麦主程序配置', path: '/config/bot' },
|
||||
{ icon: Server, label: 'AI模型厂商配置', path: '/config/modelProvider', tourId: 'sidebar-model-provider' },
|
||||
{ icon: Boxes, label: '模型管理与分配', path: '/config/model', tourId: 'sidebar-model-management' },
|
||||
{ icon: Sliders, label: '麦麦适配器配置', path: '/config/adapter' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '麦麦资源管理',
|
||||
items: [
|
||||
{ icon: Smile, label: '表情包管理', path: '/resource/emoji' },
|
||||
{ icon: MessageSquare, label: '表达方式管理', path: '/resource/expression' },
|
||||
{ icon: Hash, label: '黑话管理', path: '/resource/jargon' },
|
||||
{ icon: UserCircle, label: '人物信息管理', path: '/resource/person' },
|
||||
{ icon: Network, label: '知识库图谱可视化', path: '/resource/knowledge-graph' },
|
||||
{ icon: Database, label: '麦麦知识库管理', path: '/resource/knowledge-base' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '扩展与监控',
|
||||
items: [
|
||||
{ icon: Package, label: '插件市场', path: '/plugins' },
|
||||
{ icon: LayoutGrid, label: '配置模板市场', path: '/config/pack-market' },
|
||||
{ icon: Sliders, label: '插件配置', path: '/plugin-config' },
|
||||
{ icon: FileSearch, label: '日志查看器', path: '/logs' },
|
||||
{ icon: Activity, label: '计划器&回复器监控', path: '/planner-monitor' },
|
||||
{ icon: MessageSquare, label: '本地聊天室', path: '/chat' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
items: [
|
||||
{ icon: Settings, label: '系统设置', path: '/settings' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// 获取实际应用的主题(处理 system 情况)
|
||||
const getActualTheme = () => {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
const actualTheme = getActualTheme()
|
||||
|
||||
const pageBg = useBackground('page')
|
||||
const sidebarBg = useBackground('sidebar')
|
||||
const headerBg = useBackground('header')
|
||||
|
||||
// 登出处理
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 flex flex-col border-r bg-card transition-all duration-300 lg:relative lg:z-0',
|
||||
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
|
||||
'w-64 lg:w-auto',
|
||||
sidebarOpen ? 'lg:w-64' : 'lg:w-16',
|
||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}
|
||||
>
|
||||
<BackgroundLayer config={sidebarBg} layerId="sidebar" />
|
||||
{/* Logo 区域 */}
|
||||
<div className="flex h-16 items-center border-b px-4">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center flex-1 transition-all overflow-hidden',
|
||||
// 移动端始终完整显示,桌面端根据 sidebarOpen 切换
|
||||
'lg:flex-1',
|
||||
!sidebarOpen && 'lg:flex-none lg:w-8'
|
||||
)}
|
||||
>
|
||||
{/* 移动端始终显示完整 Logo,桌面端根据 sidebarOpen 切换 */}
|
||||
<div className={cn(
|
||||
"flex items-baseline gap-2",
|
||||
!sidebarOpen && "lg:hidden"
|
||||
)}>
|
||||
<span className="font-bold text-xl text-primary-gradient whitespace-nowrap">MaiBot WebUI</span>
|
||||
<span className="text-xs text-primary/60 whitespace-nowrap">
|
||||
{formatVersion()}
|
||||
</span>
|
||||
</div>
|
||||
{/* 折叠时的 Logo - 仅桌面端显示 */}
|
||||
{!sidebarOpen && (
|
||||
<span className="hidden lg:block font-bold text-primary-gradient text-2xl">M</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className={cn(
|
||||
"flex-1 overflow-x-hidden",
|
||||
!sidebarOpen && "lg:w-16"
|
||||
)}>
|
||||
<nav className={cn(
|
||||
"p-4",
|
||||
!sidebarOpen && "lg:p-2 lg:w-16"
|
||||
)}>
|
||||
<ul className={cn(
|
||||
// 移动端始终使用正常间距,桌面端根据 sidebarOpen 切换
|
||||
"space-y-6",
|
||||
!sidebarOpen && "lg:space-y-3 lg:w-full"
|
||||
)}>
|
||||
{menuSections.map((section, sectionIndex) => (
|
||||
<li key={section.title}>
|
||||
{/* 块标题 - 移动端始终可见,桌面端根据 sidebarOpen 切换 */}
|
||||
<div className={cn(
|
||||
"px-3 h-[1.25rem]",
|
||||
// 移动端始终显示,桌面端根据状态切换
|
||||
"mb-2",
|
||||
!sidebarOpen && "lg:mb-1 lg:invisible"
|
||||
)}>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60 whitespace-nowrap">
|
||||
{section.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 分割线 - 仅在桌面端折叠时显示 */}
|
||||
{!sidebarOpen && sectionIndex > 0 && (
|
||||
<div className="hidden lg:block mb-2 border-t border-border" />
|
||||
)}
|
||||
|
||||
{/* 菜单项列表 */}
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item) => {
|
||||
const isActive = matchRoute({ to: item.path })
|
||||
const Icon = item.icon
|
||||
|
||||
const menuItemContent = (
|
||||
<>
|
||||
{/* 左侧高亮条 */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary transition-opacity duration-300" />
|
||||
)}
|
||||
<div className={cn(
|
||||
'flex items-center transition-all duration-300',
|
||||
sidebarOpen ? 'gap-3' : 'gap-3 lg:gap-0'
|
||||
)}>
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-5 w-5 flex-shrink-0',
|
||||
isActive && 'text-primary'
|
||||
)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-sm font-medium whitespace-nowrap transition-all duration-300',
|
||||
isActive && 'font-semibold',
|
||||
sidebarOpen
|
||||
? 'opacity-100 max-w-[200px]'
|
||||
: 'opacity-100 max-w-[200px] lg:opacity-0 lg:max-w-0 lg:overflow-hidden'
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<li key={item.path} className="relative">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
to={item.path}
|
||||
data-tour={item.tourId}
|
||||
className={cn(
|
||||
'relative flex items-center rounded-lg py-2 transition-all duration-300',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
sidebarOpen ? 'px-3' : 'px-3 lg:px-0 lg:justify-center lg:w-12 lg:mx-auto'
|
||||
)}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{menuItemContent}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
{tooltipsEnabled && (
|
||||
<TooltipContent side="right" className="hidden lg:block">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* HTTP 安全警告横幅 */}
|
||||
<HttpWarningBanner />
|
||||
|
||||
{/* Topbar */}
|
||||
<header className="flex h-16 items-center justify-between border-b bg-card/80 backdrop-blur-md px-4 sticky top-0 z-10">
|
||||
<BackgroundLayer config={headerBg} layerId="header" />
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="rounded-lg p-2 hover:bg-accent lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 桌面端侧边栏收起/展开按钮 */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="hidden rounded-lg p-2 hover:bg-accent lg:block"
|
||||
title={sidebarOpen ? '收起侧边栏' : '展开侧边栏'}
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 年度总结入口 */}
|
||||
<Link to="/annual-report">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 bg-gradient-to-r from-pink-500/10 to-purple-500/10 hover:from-pink-500/20 hover:to-purple-500/20 border border-pink-500/20"
|
||||
title="查看年度总结"
|
||||
>
|
||||
<PieChart className="h-4 w-4 text-pink-500" />
|
||||
<span className="hidden sm:inline bg-gradient-to-r from-pink-500 to-purple-500 bg-clip-text text-transparent font-medium">2025 年度总结</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="relative hidden md:flex items-center w-64 h-9 pl-9 pr-16 bg-background/50 border rounded-md hover:bg-accent/50 transition-colors text-left"
|
||||
>
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">搜索...</span>
|
||||
<Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</Kbd>
|
||||
</button>
|
||||
|
||||
{/* 搜索对话框 */}
|
||||
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
||||
|
||||
{/* 麦麦文档链接 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open('https://docs.mai-mai.org', '_blank')}
|
||||
className="gap-2"
|
||||
title="查看麦麦文档"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">麦麦文档</span>
|
||||
</Button>
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
|
||||
toggleThemeWithTransition(newTheme, setTheme, e)
|
||||
}}
|
||||
className="rounded-lg p-2 hover:bg-accent"
|
||||
title={actualTheme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
|
||||
>
|
||||
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2"
|
||||
title="登出系统"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">登出</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="relative flex-1 overflow-hidden bg-background">
|
||||
<BackgroundLayer config={pageBg} layerId="page" />
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Back to Top Button */}
|
||||
<BackToTop />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
134
dashboard/src/components/layout/Header.tsx
Normal file
134
dashboard/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { BookOpen, ChevronLeft, LogOut, Menu, Moon, PieChart, Search, Sun } from 'lucide-react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { BackgroundLayer } from '@/components/background-layer'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
import { logout } from '@/lib/fetch-with-auth'
|
||||
import { toggleThemeWithTransition } from '@/components/use-theme'
|
||||
|
||||
interface HeaderProps {
|
||||
sidebarOpen: boolean
|
||||
mobileMenuOpen: boolean
|
||||
searchOpen: boolean
|
||||
actualTheme: 'light' | 'dark'
|
||||
onSidebarToggle: () => void
|
||||
onMobileMenuToggle: () => void
|
||||
onSearchOpenChange: (open: boolean) => void
|
||||
onThemeChange: (theme: 'light' | 'dark' | 'system') => void
|
||||
}
|
||||
|
||||
export function Header({
|
||||
sidebarOpen,
|
||||
|
||||
searchOpen,
|
||||
actualTheme,
|
||||
onSidebarToggle,
|
||||
onMobileMenuToggle,
|
||||
onSearchOpenChange,
|
||||
onThemeChange,
|
||||
}: HeaderProps) {
|
||||
const headerBg = useBackground('header')
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b bg-card/80 backdrop-blur-md px-4 sticky top-0 z-10">
|
||||
<BackgroundLayer config={headerBg} layerId="header" />
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
onClick={onMobileMenuToggle}
|
||||
className="rounded-lg p-2 hover:bg-accent lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 桌面端侧边栏收起/展开按钮 */}
|
||||
<button
|
||||
onClick={onSidebarToggle}
|
||||
className="hidden rounded-lg p-2 hover:bg-accent lg:block"
|
||||
title={sidebarOpen ? '收起侧边栏' : '展开侧边栏'}
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 年度总结入口 */}
|
||||
<Link to="/annual-report">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 bg-gradient-to-r from-pink-500/10 to-purple-500/10 hover:from-pink-500/20 hover:to-purple-500/20 border border-pink-500/20"
|
||||
title="查看年度总结"
|
||||
>
|
||||
<PieChart className="h-4 w-4 text-pink-500" />
|
||||
<span className="hidden sm:inline bg-gradient-to-r from-pink-500 to-purple-500 bg-clip-text text-transparent font-medium">2025 年度总结</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<button
|
||||
onClick={() => onSearchOpenChange(true)}
|
||||
className="relative hidden md:flex items-center w-64 h-9 pl-9 pr-16 bg-background/50 border rounded-md hover:bg-accent/50 transition-colors text-left"
|
||||
>
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">搜索...</span>
|
||||
<Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</Kbd>
|
||||
</button>
|
||||
|
||||
{/* 搜索对话框 */}
|
||||
<SearchDialog open={searchOpen} onOpenChange={onSearchOpenChange} />
|
||||
|
||||
{/* 麦麦文档链接 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open('https://docs.mai-mai.org', '_blank')}
|
||||
className="gap-2"
|
||||
title="查看麦麦文档"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">麦麦文档</span>
|
||||
</Button>
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
|
||||
toggleThemeWithTransition(newTheme, onThemeChange, e)
|
||||
}}
|
||||
className="rounded-lg p-2 hover:bg-accent"
|
||||
title={actualTheme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
|
||||
>
|
||||
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2"
|
||||
title="登出系统"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">登出</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
119
dashboard/src/components/layout/Layout.tsx
Normal file
119
dashboard/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { BackgroundLayer } from '@/components/background-layer'
|
||||
import { BackToTop } from '@/components/back-to-top'
|
||||
import { HttpWarningBanner } from '@/components/http-warning-banner'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useTheme } from '@/components/use-theme'
|
||||
import { useAuthGuard } from '@/hooks/use-auth'
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
|
||||
import { Header } from './Header'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import type { LayoutProps } from './types'
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const { checking } = useAuthGuard() // 检查认证状态
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [tooltipsEnabled, setTooltipsEnabled] = useState(false) // 控制 tooltip 启用状态
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
// 侧边栏状态变化时,延迟启用/禁用 tooltip
|
||||
useEffect(() => {
|
||||
if (sidebarOpen) {
|
||||
// 侧边栏展开时,立即禁用 tooltip
|
||||
setTooltipsEnabled(false)
|
||||
} else {
|
||||
// 侧边栏收起时,等待动画完成后再启用 tooltip
|
||||
const timer = setTimeout(() => {
|
||||
setTooltipsEnabled(true)
|
||||
}, 350) // 稍大于 CSS transition duration (300ms)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [sidebarOpen])
|
||||
|
||||
// 搜索快捷键监听(Cmd/Ctrl + K)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setSearchOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// 认证检查中,显示加载状态
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-muted-foreground">正在验证登录状态...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 获取实际应用的主题(处理 system 情况)
|
||||
const getActualTheme = () => {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
const actualTheme = getActualTheme()
|
||||
const pageBg = useBackground('page')
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
sidebarOpen={sidebarOpen}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
tooltipsEnabled={tooltipsEnabled}
|
||||
onMobileMenuClose={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* HTTP 安全警告横幅 */}
|
||||
<HttpWarningBanner />
|
||||
|
||||
{/* Topbar */}
|
||||
<Header
|
||||
sidebarOpen={sidebarOpen}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
searchOpen={searchOpen}
|
||||
actualTheme={actualTheme}
|
||||
onSidebarToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||
onMobileMenuToggle={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
onSearchOpenChange={setSearchOpen}
|
||||
onThemeChange={setTheme}
|
||||
/>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="relative flex-1 overflow-hidden bg-background">
|
||||
<BackgroundLayer config={pageBg} layerId="page" />
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Back to Top Button */}
|
||||
<BackToTop />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
36
dashboard/src/components/layout/LogoArea.tsx
Normal file
36
dashboard/src/components/layout/LogoArea.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatVersion } from '@/lib/version'
|
||||
|
||||
interface LogoAreaProps {
|
||||
sidebarOpen: boolean
|
||||
}
|
||||
|
||||
export function LogoArea({ sidebarOpen }: LogoAreaProps) {
|
||||
return (
|
||||
<div className="flex h-16 items-center border-b px-4">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center flex-1 transition-all overflow-hidden',
|
||||
// 移动端始终完整显示,桌面端根据 sidebarOpen 切换
|
||||
'lg:flex-1',
|
||||
!sidebarOpen && 'lg:flex-none lg:w-8'
|
||||
)}
|
||||
>
|
||||
{/* 移动端始终显示完整 Logo,桌面端根据 sidebarOpen 切换 */}
|
||||
<div className={cn(
|
||||
"flex items-baseline gap-2",
|
||||
!sidebarOpen && "lg:hidden"
|
||||
)}>
|
||||
<span className="font-bold text-xl text-primary-gradient whitespace-nowrap">MaiBot WebUI</span>
|
||||
<span className="text-xs text-primary/60 whitespace-nowrap">
|
||||
{formatVersion()}
|
||||
</span>
|
||||
</div>
|
||||
{/* 折叠时的 Logo - 仅桌面端显示 */}
|
||||
{!sidebarOpen && (
|
||||
<span className="hidden lg:block font-bold text-primary-gradient text-2xl">M</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
dashboard/src/components/layout/NavItem.tsx
Normal file
79
dashboard/src/components/layout/NavItem.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router'
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { MenuItem } from './types'
|
||||
|
||||
interface NavItemProps {
|
||||
item: MenuItem
|
||||
sidebarOpen: boolean
|
||||
tooltipsEnabled: boolean
|
||||
onMobileMenuClose: () => void
|
||||
}
|
||||
|
||||
export function NavItem({ item, sidebarOpen, tooltipsEnabled, onMobileMenuClose }: NavItemProps) {
|
||||
const matchRoute = useMatchRoute()
|
||||
const isActive = matchRoute({ to: item.path })
|
||||
const Icon = item.icon
|
||||
|
||||
const menuItemContent = (
|
||||
<>
|
||||
{/* 左侧高亮条 */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary transition-opacity duration-300" />
|
||||
)}
|
||||
<div className={cn(
|
||||
'flex items-center transition-all duration-300',
|
||||
sidebarOpen ? 'gap-3' : 'gap-3 lg:gap-0'
|
||||
)}>
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-5 w-5 flex-shrink-0',
|
||||
isActive && 'text-primary'
|
||||
)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-sm font-medium whitespace-nowrap transition-all duration-300',
|
||||
isActive && 'font-semibold',
|
||||
sidebarOpen
|
||||
? 'opacity-100 max-w-[200px]'
|
||||
: 'opacity-100 max-w-[200px] lg:opacity-0 lg:max-w-0 lg:overflow-hidden'
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<li className="relative">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
to={item.path}
|
||||
data-tour={item.tourId}
|
||||
className={cn(
|
||||
'relative flex items-center rounded-lg py-2 transition-all duration-300',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
sidebarOpen ? 'px-3' : 'px-3 lg:px-0 lg:justify-center lg:w-12 lg:mx-auto'
|
||||
)}
|
||||
onClick={onMobileMenuClose}
|
||||
>
|
||||
{menuItemContent}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
{tooltipsEnabled && (
|
||||
<TooltipContent side="right" className="hidden lg:block">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
91
dashboard/src/components/layout/Sidebar.tsx
Normal file
91
dashboard/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
import { BackgroundLayer } from '@/components/background-layer'
|
||||
|
||||
import { LogoArea } from './LogoArea'
|
||||
import { NavItem } from './NavItem'
|
||||
import { menuSections } from './constants'
|
||||
|
||||
interface SidebarProps {
|
||||
sidebarOpen: boolean
|
||||
mobileMenuOpen: boolean
|
||||
tooltipsEnabled: boolean
|
||||
onMobileMenuClose: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
sidebarOpen,
|
||||
mobileMenuOpen,
|
||||
tooltipsEnabled,
|
||||
onMobileMenuClose
|
||||
}: SidebarProps) {
|
||||
const sidebarBg = useBackground('sidebar')
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 flex flex-col border-r bg-card transition-all duration-300 lg:relative lg:z-0',
|
||||
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
|
||||
'w-64 lg:w-auto',
|
||||
sidebarOpen ? 'lg:w-64' : 'lg:w-16',
|
||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}
|
||||
>
|
||||
<BackgroundLayer config={sidebarBg} layerId="sidebar" />
|
||||
|
||||
{/* Logo 区域 */}
|
||||
<LogoArea sidebarOpen={sidebarOpen} />
|
||||
|
||||
<ScrollArea className={cn(
|
||||
"flex-1 overflow-x-hidden",
|
||||
!sidebarOpen && "lg:w-16"
|
||||
)}>
|
||||
<nav className={cn(
|
||||
"p-4",
|
||||
!sidebarOpen && "lg:p-2 lg:w-16"
|
||||
)}>
|
||||
<ul className={cn(
|
||||
// 移动端始终使用正常间距,桌面端根据 sidebarOpen 切换
|
||||
"space-y-6",
|
||||
!sidebarOpen && "lg:space-y-3 lg:w-full"
|
||||
)}>
|
||||
{menuSections.map((section, sectionIndex) => (
|
||||
<li key={section.title}>
|
||||
{/* 块标题 - 移动端始终可见,桌面端根据 sidebarOpen 切换 */}
|
||||
<div className={cn(
|
||||
"px-3 h-[1.25rem]",
|
||||
// 移动端始终显示,桌面端根据状态切换
|
||||
"mb-2",
|
||||
!sidebarOpen && "lg:mb-1 lg:invisible"
|
||||
)}>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60 whitespace-nowrap">
|
||||
{section.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 分割线 - 仅在桌面端折叠时显示 */}
|
||||
{!sidebarOpen && sectionIndex > 0 && (
|
||||
<div className="hidden lg:block mb-2 border-t border-border" />
|
||||
)}
|
||||
|
||||
{/* 菜单项列表 */}
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item) => (
|
||||
<NavItem
|
||||
key={item.path}
|
||||
item={item}
|
||||
sidebarOpen={sidebarOpen}
|
||||
tooltipsEnabled={tooltipsEnabled}
|
||||
onMobileMenuClose={onMobileMenuClose}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
49
dashboard/src/components/layout/constants.ts
Normal file
49
dashboard/src/components/layout/constants.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, LayoutGrid, MessageSquare, Network, Package, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react'
|
||||
|
||||
import type { MenuSection } from './types'
|
||||
|
||||
export const menuSections: MenuSection[] = [
|
||||
{
|
||||
title: '概览',
|
||||
items: [
|
||||
{ icon: Home, label: '首页', path: '/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '麦麦配置编辑',
|
||||
items: [
|
||||
{ icon: FileText, label: '麦麦主程序配置', path: '/config/bot' },
|
||||
{ icon: Server, label: 'AI模型厂商配置', path: '/config/modelProvider', tourId: 'sidebar-model-provider' },
|
||||
{ icon: Boxes, label: '模型管理与分配', path: '/config/model', tourId: 'sidebar-model-management' },
|
||||
{ icon: Sliders, label: '麦麦适配器配置', path: '/config/adapter' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '麦麦资源管理',
|
||||
items: [
|
||||
{ icon: Smile, label: '表情包管理', path: '/resource/emoji' },
|
||||
{ icon: MessageSquare, label: '表达方式管理', path: '/resource/expression' },
|
||||
{ icon: Hash, label: '黑话管理', path: '/resource/jargon' },
|
||||
{ icon: UserCircle, label: '人物信息管理', path: '/resource/person' },
|
||||
{ icon: Network, label: '知识库图谱可视化', path: '/resource/knowledge-graph' },
|
||||
{ icon: Database, label: '麦麦知识库管理', path: '/resource/knowledge-base' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '扩展与监控',
|
||||
items: [
|
||||
{ icon: Package, label: '插件市场', path: '/plugins' },
|
||||
{ icon: LayoutGrid, label: '配置模板市场', path: '/config/pack-market' },
|
||||
{ icon: Sliders, label: '插件配置', path: '/plugin-config' },
|
||||
{ icon: FileSearch, label: '日志查看器', path: '/logs' },
|
||||
{ icon: Activity, label: '计划器&回复器监控', path: '/planner-monitor' },
|
||||
{ icon: MessageSquare, label: '本地聊天室', path: '/chat' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
items: [
|
||||
{ icon: Settings, label: '系统设置', path: '/settings' },
|
||||
],
|
||||
},
|
||||
]
|
||||
2
dashboard/src/components/layout/index.ts
Normal file
2
dashboard/src/components/layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Layout } from './Layout'
|
||||
export type { LayoutProps, MenuItem, MenuSection } from './types'
|
||||
18
dashboard/src/components/layout/types.ts
Normal file
18
dashboard/src/components/layout/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
|
||||
export interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
icon: ComponentType<LucideProps>
|
||||
label: string
|
||||
path: string
|
||||
tourId?: string
|
||||
}
|
||||
|
||||
export interface MenuSection {
|
||||
title: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
@@ -2,8 +2,14 @@
|
||||
* Bot 配置页面相关 hooks
|
||||
*/
|
||||
|
||||
export { useAutoSave, useConfigAutoSave } from './useAutoSave'
|
||||
export type { UseAutoSaveOptions, UseAutoSaveReturn, AutoSaveState } from './useAutoSave'
|
||||
export { useAutoSave, useAutoSaveGeneric, useConfigAutoSave } from './useAutoSave'
|
||||
export type {
|
||||
UseAutoSaveOptions,
|
||||
UseAutoSaveReturn,
|
||||
AutoSaveState,
|
||||
UseAutoSaveConfig,
|
||||
UseAutoSaveReturnGeneric,
|
||||
} from './useAutoSave'
|
||||
export { ChatSectionHook } from './ChatSectionHook'
|
||||
export { PersonalitySectionHook } from './PersonalitySectionHook'
|
||||
export { DebugSectionHook } from './DebugSectionHook'
|
||||
|
||||
@@ -1,50 +1,178 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { updateBotConfigSection } from '@/lib/config-api'
|
||||
import type { ConfigSectionName } from '../types'
|
||||
|
||||
export interface UseAutoSaveOptions {
|
||||
/** 防抖延迟时间(毫秒),默认 2000ms */
|
||||
/**
|
||||
* Self-contained auto-save hook configuration
|
||||
* @template T The type of data being saved
|
||||
*/
|
||||
export interface UseAutoSaveConfig<T> {
|
||||
/** Function to save data, should return a promise */
|
||||
saveFn: (data: T) => Promise<void>
|
||||
/** Debounce delay in milliseconds, default 2000ms */
|
||||
debounceMs?: number
|
||||
/** 保存成功回调 */
|
||||
/** Callback when save succeeds */
|
||||
onSaveSuccess?: () => void
|
||||
/** 保存失败回调 */
|
||||
/** Callback when save fails */
|
||||
onSaveError?: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained auto-save hook return type (generic)
|
||||
*/
|
||||
export interface UseAutoSaveReturnGeneric<T> {
|
||||
/** Trigger auto-save (debounced) */
|
||||
save: (data: T) => void
|
||||
/** Save immediately without debounce */
|
||||
saveNow: (data: T) => Promise<void>
|
||||
/** Cancel pending auto-save */
|
||||
cancel: () => void
|
||||
/** Whether currently saving */
|
||||
isSaving: boolean
|
||||
/** Error from last save attempt, or null */
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained generic auto-save hook
|
||||
*
|
||||
* Manages debouncing, pending state, and error handling internally.
|
||||
* No external state dependencies required.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { save, isSaving } = useAutoSaveGeneric<MyConfig>({
|
||||
* saveFn: async (config) => {
|
||||
* await updateMyConfig(config)
|
||||
* },
|
||||
* debounceMs: 2000,
|
||||
* })
|
||||
*
|
||||
* useEffect(() => {
|
||||
* if (config) {
|
||||
* save(config)
|
||||
* }
|
||||
* }, [config, save])
|
||||
* ```
|
||||
*/
|
||||
export function useAutoSaveGeneric<T>(
|
||||
config: UseAutoSaveConfig<T>
|
||||
): UseAutoSaveReturnGeneric<T> {
|
||||
const { saveFn, debounceMs = 2000, onSaveSuccess, onSaveError } = config
|
||||
|
||||
// Internal state management
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Perform the actual save
|
||||
const performSave = useCallback(
|
||||
async (data: T) => {
|
||||
try {
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
await saveFn(data)
|
||||
onSaveSuccess?.()
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
setError(error)
|
||||
console.error('Auto-save failed:', error)
|
||||
onSaveError?.(error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
},
|
||||
[saveFn, onSaveSuccess, onSaveError]
|
||||
)
|
||||
|
||||
// Debounced save
|
||||
const save = useCallback(
|
||||
(data: T) => {
|
||||
// Clear existing timer
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
// Set new timer
|
||||
timerRef.current = setTimeout(() => {
|
||||
performSave(data)
|
||||
}, debounceMs)
|
||||
},
|
||||
[performSave, debounceMs]
|
||||
)
|
||||
|
||||
// Save immediately
|
||||
const saveNow = useCallback(
|
||||
async (data: T) => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
await performSave(data)
|
||||
},
|
||||
[performSave]
|
||||
)
|
||||
|
||||
// Cancel pending save
|
||||
const cancel = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
save,
|
||||
saveNow,
|
||||
cancel,
|
||||
isSaving,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy wrapper for backward compatibility with old API
|
||||
* Maintains external state for existing code
|
||||
*/
|
||||
export interface UseAutoSaveOptions {
|
||||
/** Debounce delay in milliseconds, default 2000ms */
|
||||
debounceMs?: number
|
||||
/** Save success callback */
|
||||
onSaveSuccess?: () => void
|
||||
/** Save error callback */
|
||||
onSaveError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export interface UseAutoSaveReturn {
|
||||
/** 触发自动保存 */
|
||||
/** Trigger auto-save */
|
||||
triggerAutoSave: (sectionName: ConfigSectionName, sectionData: unknown) => void
|
||||
/** 立即保存(不防抖) */
|
||||
/** Save immediately */
|
||||
saveNow: (sectionName: ConfigSectionName, sectionData: unknown) => Promise<void>
|
||||
/** 取消待处理的自动保存 */
|
||||
/** Cancel pending auto-save */
|
||||
cancelPendingAutoSave: () => void
|
||||
}
|
||||
|
||||
export interface AutoSaveState {
|
||||
/** 是否正在保存中 */
|
||||
/** Whether currently saving */
|
||||
isAutoSaving: boolean
|
||||
/** 是否有未保存的更改 */
|
||||
/** Whether has unsaved changes */
|
||||
hasUnsavedChanges: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动保存 hook
|
||||
*
|
||||
* 用于监听配置变化并自动防抖保存到后端
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { triggerAutoSave } = useAutoSave({
|
||||
* isInitialLoad,
|
||||
* setAutoSaving,
|
||||
* setHasUnsavedChanges,
|
||||
* })
|
||||
*
|
||||
* // 配置变化时触发
|
||||
* useEffect(() => {
|
||||
* if (config) triggerAutoSave('bot', config)
|
||||
* }, [config])
|
||||
* ```
|
||||
* Legacy auto-save hook for bot config
|
||||
* Maintains backward compatibility with external state management
|
||||
*
|
||||
* @deprecated Use the generic useAutoSaveGeneric<T> instead
|
||||
*/
|
||||
export function useAutoSave(
|
||||
isInitialLoad: boolean,
|
||||
@@ -55,7 +183,7 @@ export function useAutoSave(
|
||||
const { debounceMs = 2000, onSaveSuccess, onSaveError } = options
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// 执行保存操作
|
||||
// Execute save operation
|
||||
const saveSection = useCallback(
|
||||
async (sectionName: ConfigSectionName, sectionData: unknown) => {
|
||||
try {
|
||||
@@ -74,7 +202,7 @@ export function useAutoSave(
|
||||
[setAutoSaving, setHasUnsavedChanges, onSaveSuccess, onSaveError]
|
||||
)
|
||||
|
||||
// 触发自动保存(带防抖)
|
||||
// Trigger auto-save (with debounce)
|
||||
const triggerAutoSave = useCallback(
|
||||
(sectionName: ConfigSectionName, sectionData: unknown) => {
|
||||
if (isInitialLoad) return
|
||||
@@ -92,7 +220,7 @@ export function useAutoSave(
|
||||
[isInitialLoad, setHasUnsavedChanges, saveSection, debounceMs]
|
||||
)
|
||||
|
||||
// 立即保存(不防抖)
|
||||
// Save immediately (no debounce)
|
||||
const saveNow = useCallback(
|
||||
async (sectionName: ConfigSectionName, sectionData: unknown) => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
@@ -104,7 +232,7 @@ export function useAutoSave(
|
||||
[saveSection]
|
||||
)
|
||||
|
||||
// 取消待处理的自动保存
|
||||
// Cancel pending auto-save
|
||||
const cancelPendingAutoSave = useCallback(() => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current)
|
||||
@@ -112,7 +240,7 @@ export function useAutoSave(
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
@@ -130,22 +258,22 @@ export function useAutoSave(
|
||||
|
||||
/**
|
||||
* 创建配置自动保存 effect
|
||||
*
|
||||
*
|
||||
* 这是一个工厂函数,用于创建监听特定配置变化并触发自动保存的 effect
|
||||
* 简化重复的 useEffect 代码
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 使用方式 1: 直接在组件中调用
|
||||
* useConfigAutoSave(botConfig, 'bot', isInitialLoad, triggerAutoSave)
|
||||
* useConfigAutoSave(chatConfig, 'chat', isInitialLoad, triggerAutoSave)
|
||||
*
|
||||
*
|
||||
* // 使用方式 2: 批量配置
|
||||
* const configs = [
|
||||
* { config: botConfig, section: 'bot' },
|
||||
* { config: chatConfig, section: 'chat' },
|
||||
* ] as const
|
||||
*
|
||||
*
|
||||
* configs.forEach(({ config, section }) => {
|
||||
* useConfigAutoSave(config, section, isInitialLoad, triggerAutoSave)
|
||||
* })
|
||||
|
||||
Reference in New Issue
Block a user