From c863d5a3beff1b1cd48e00b7c1da1de4396b41aa Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 17:07:38 +0800 Subject: [PATCH] refactor(components): split layout.tsx into layout/ directory --- dashboard/package-lock.json | 135 ++++-- dashboard/src/components/layout.tsx | 421 ------------------ dashboard/src/components/layout/Header.tsx | 134 ++++++ dashboard/src/components/layout/Layout.tsx | 119 +++++ dashboard/src/components/layout/LogoArea.tsx | 36 ++ dashboard/src/components/layout/NavItem.tsx | 79 ++++ dashboard/src/components/layout/Sidebar.tsx | 91 ++++ dashboard/src/components/layout/constants.ts | 49 ++ dashboard/src/components/layout/index.ts | 2 + dashboard/src/components/layout/types.ts | 18 + .../src/routes/config/bot/hooks/index.ts | 10 +- .../routes/config/bot/hooks/useAutoSave.ts | 200 +++++++-- 12 files changed, 807 insertions(+), 487 deletions(-) delete mode 100644 dashboard/src/components/layout.tsx create mode 100644 dashboard/src/components/layout/Header.tsx create mode 100644 dashboard/src/components/layout/Layout.tsx create mode 100644 dashboard/src/components/layout/LogoArea.tsx create mode 100644 dashboard/src/components/layout/NavItem.tsx create mode 100644 dashboard/src/components/layout/Sidebar.tsx create mode 100644 dashboard/src/components/layout/constants.ts create mode 100644 dashboard/src/components/layout/index.ts create mode 100644 dashboard/src/components/layout/types.ts diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 23a1c0e6..b23bcf79 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -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", diff --git a/dashboard/src/components/layout.tsx b/dashboard/src/components/layout.tsx deleted file mode 100644 index 8eea4550..00000000 --- a/dashboard/src/components/layout.tsx +++ /dev/null @@ -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 - 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 ( -
-
正在验证登录状态...
-
- ) - } - - // 菜单项配置 - 分块结构 - 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 ( - -
- {/* Sidebar */} - - - {/* Mobile overlay */} - {mobileMenuOpen && ( -
setMobileMenuOpen(false)} - /> - )} - - {/* Main content */} -
- {/* HTTP 安全警告横幅 */} - - - {/* Topbar */} -
- -
- {/* 移动端菜单按钮 */} - - - {/* 桌面端侧边栏收起/展开按钮 */} - -
- -
- {/* 年度总结入口 */} - - - - - {/* 搜索框 */} - - - {/* 搜索对话框 */} - - - {/* 麦麦文档链接 */} - - - {/* 主题切换按钮 */} - - - {/* 分隔线 */} -
- - {/* 登出按钮 */} - -
-
- - {/* Page content */} -
- - {children} -
- - {/* Back to Top Button */} - -
-
- - ) -} diff --git a/dashboard/src/components/layout/Header.tsx b/dashboard/src/components/layout/Header.tsx new file mode 100644 index 00000000..efacb33d --- /dev/null +++ b/dashboard/src/components/layout/Header.tsx @@ -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 ( +
+ +
+ {/* 移动端菜单按钮 */} + + + {/* 桌面端侧边栏收起/展开按钮 */} + +
+ +
+ {/* 年度总结入口 */} + + + + + {/* 搜索框 */} + + + {/* 搜索对话框 */} + + + {/* 麦麦文档链接 */} + + + {/* 主题切换按钮 */} + + + {/* 分隔线 */} +
+ + {/* 登出按钮 */} + +
+
+ ) +} diff --git a/dashboard/src/components/layout/Layout.tsx b/dashboard/src/components/layout/Layout.tsx new file mode 100644 index 00000000..0fcb28be --- /dev/null +++ b/dashboard/src/components/layout/Layout.tsx @@ -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 ( +
+
正在验证登录状态...
+
+ ) + } + + // 获取实际应用的主题(处理 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 ( + +
+ {/* Sidebar */} + setMobileMenuOpen(false)} + /> + + {/* Mobile overlay */} + {mobileMenuOpen && ( +
setMobileMenuOpen(false)} + /> + )} + + {/* Main content */} +
+ {/* HTTP 安全警告横幅 */} + + + {/* Topbar */} +
setSidebarOpen(!sidebarOpen)} + onMobileMenuToggle={() => setMobileMenuOpen(!mobileMenuOpen)} + onSearchOpenChange={setSearchOpen} + onThemeChange={setTheme} + /> + + {/* Page content */} +
+ + {children} +
+ + {/* Back to Top Button */} + +
+
+ + ) +} diff --git a/dashboard/src/components/layout/LogoArea.tsx b/dashboard/src/components/layout/LogoArea.tsx new file mode 100644 index 00000000..27e0441a --- /dev/null +++ b/dashboard/src/components/layout/LogoArea.tsx @@ -0,0 +1,36 @@ +import { cn } from '@/lib/utils' +import { formatVersion } from '@/lib/version' + +interface LogoAreaProps { + sidebarOpen: boolean +} + +export function LogoArea({ sidebarOpen }: LogoAreaProps) { + return ( +
+
+ {/* 移动端始终显示完整 Logo,桌面端根据 sidebarOpen 切换 */} +
+ MaiBot WebUI + + {formatVersion()} + +
+ {/* 折叠时的 Logo - 仅桌面端显示 */} + {!sidebarOpen && ( + M + )} +
+
+ ) +} diff --git a/dashboard/src/components/layout/NavItem.tsx b/dashboard/src/components/layout/NavItem.tsx new file mode 100644 index 00000000..d5158137 --- /dev/null +++ b/dashboard/src/components/layout/NavItem.tsx @@ -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 && ( +
+ )} +
+ + + {item.label} + +
+ + ) + + return ( +
  • + + + + {menuItemContent} + + + {tooltipsEnabled && ( + +

    {item.label}

    +
    + )} +
    +
  • + ) +} diff --git a/dashboard/src/components/layout/Sidebar.tsx b/dashboard/src/components/layout/Sidebar.tsx new file mode 100644 index 00000000..3e5538ce --- /dev/null +++ b/dashboard/src/components/layout/Sidebar.tsx @@ -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 ( + + ) +} diff --git a/dashboard/src/components/layout/constants.ts b/dashboard/src/components/layout/constants.ts new file mode 100644 index 00000000..09bf7cc6 --- /dev/null +++ b/dashboard/src/components/layout/constants.ts @@ -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' }, + ], + }, +] diff --git a/dashboard/src/components/layout/index.ts b/dashboard/src/components/layout/index.ts new file mode 100644 index 00000000..46b1bd8e --- /dev/null +++ b/dashboard/src/components/layout/index.ts @@ -0,0 +1,2 @@ +export { Layout } from './Layout' +export type { LayoutProps, MenuItem, MenuSection } from './types' diff --git a/dashboard/src/components/layout/types.ts b/dashboard/src/components/layout/types.ts new file mode 100644 index 00000000..0be17225 --- /dev/null +++ b/dashboard/src/components/layout/types.ts @@ -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 + label: string + path: string + tourId?: string +} + +export interface MenuSection { + title: string + items: MenuItem[] +} diff --git a/dashboard/src/routes/config/bot/hooks/index.ts b/dashboard/src/routes/config/bot/hooks/index.ts index 492caa7c..1dc90961 100644 --- a/dashboard/src/routes/config/bot/hooks/index.ts +++ b/dashboard/src/routes/config/bot/hooks/index.ts @@ -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' diff --git a/dashboard/src/routes/config/bot/hooks/useAutoSave.ts b/dashboard/src/routes/config/bot/hooks/useAutoSave.ts index 1744383f..27bfbb70 100644 --- a/dashboard/src/routes/config/bot/hooks/useAutoSave.ts +++ b/dashboard/src/routes/config/bot/hooks/useAutoSave.ts @@ -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 { + /** Function to save data, should return a promise */ + saveFn: (data: T) => Promise + /** 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 { + /** Trigger auto-save (debounced) */ + save: (data: T) => void + /** Save immediately without debounce */ + saveNow: (data: T) => Promise + /** 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({ + * saveFn: async (config) => { + * await updateMyConfig(config) + * }, + * debounceMs: 2000, + * }) + * + * useEffect(() => { + * if (config) { + * save(config) + * } + * }, [config, save]) + * ``` + */ +export function useAutoSaveGeneric( + config: UseAutoSaveConfig +): UseAutoSaveReturnGeneric { + const { saveFn, debounceMs = 2000, onSaveSuccess, onSaveError } = config + + // Internal state management + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + const timerRef = useRef | 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 - /** 取消待处理的自动保存 */ + /** 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 instead */ export function useAutoSave( isInitialLoad: boolean, @@ -55,7 +183,7 @@ export function useAutoSave( const { debounceMs = 2000, onSaveSuccess, onSaveError } = options const autoSaveTimerRef = useRef | 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) * })