Sfoglia il codice sorgente

Update dependencies in package.json and enhance layout styling. Added new packages for drag-and-drop functionality and state management. Refactored global styles for improved UI, including custom scrollbar and tab item effects. Updated dashboard layout to utilize menu and tab stores for better state management and navigation.

0es 4 mesi fa
parent
commit
e06853676c

+ 5 - 1
package.json

@@ -12,11 +12,15 @@
   "dependencies": {
     "@ant-design/nextjs-registry": "^1.2.0",
     "@ant-design/v5-patch-for-react-19": "^1.0.3",
+    "@dnd-kit/core": "^6.3.1",
+    "@dnd-kit/sortable": "^10.0.0",
+    "@dnd-kit/utilities": "^3.2.2",
     "antd": "^5.28.0",
     "js-sha256": "^0.11.1",
     "next": "16.0.1",
     "react": "19.2.0",
-    "react-dom": "19.2.0"
+    "react-dom": "19.2.0",
+    "zustand": "^5.0.8"
   },
   "devDependencies": {
     "@biomejs/biome": "2.2.0",

+ 110 - 67
src/app/(dashboard)/layout.tsx

@@ -1,11 +1,6 @@
 "use client";
 
-import {
-  HomeOutlined,
-  LogoutOutlined,
-  TeamOutlined,
-  UserOutlined,
-} from "@ant-design/icons";
+import { LogoutOutlined, UserOutlined } from "@ant-design/icons";
 import type { MenuProps } from "antd";
 import {
   Avatar,
@@ -19,69 +14,35 @@ import {
 } from "antd";
 import { usePathname, useRouter } from "next/navigation";
 import type React from "react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo } from "react";
+import MenuSearch from "@/components/MenuSearch";
+import TabBar from "@/components/TabBar";
 import { useAuth } from "@/contexts/AuthContext";
+import { useMenuStore } from "@/stores/menuStore";
+import { useTabStore } from "@/stores/tabStore";
+import type { MenuItem as MenuItemType } from "@/types/menu";
+import { getParentKeys } from "@/utils/menuUtils";
 
 const { Text } = Typography;
-
 const { Header, Content, Footer, Sider } = Layout;
 
-type MenuItem = Required<MenuProps>["items"][number];
-
-function getItem(
-  label: React.ReactNode,
-  key: React.Key,
-  icon?: React.ReactNode,
-  children?: MenuItem[],
-): MenuItem {
-  return {
-    key,
-    icon,
-    children,
-    label,
-  } as MenuItem;
-}
+type AntMenuItem = Required<MenuProps>["items"][number];
 
-const items: MenuItem[] = [
-  getItem("首页", "home", <HomeOutlined />),
-  getItem("陪玩管理", "user", <UserOutlined />, [
-    getItem("陪玩列表", "user-list"),
-  ]),
-  getItem("后台用户管理", "user-admin", <TeamOutlined />, [
-    getItem("用户列表", "user-admin-list"),
-  ]),
-];
-
-// Path mapping
-const pathToKeyMap: Record<string, string> = {
-  "/home": "home",
-  "/user/list": "user-list",
-  "/user-admin/list": "user-admin-list",
-};
-
-const keyToPathMap: Record<string, string> = {
-  home: "/home",
-  "user-list": "/user/list",
-  "user-admin-list": "/user-admin/list",
-};
-
-const keyToOpenKeysMap: Record<string, string[]> = {
-  "user-list": ["user"],
-  "user-admin-list": ["user-admin"],
-};
-
-const pathToBreadcrumb: Record<string, { title: string }[]> = {
-  "/home": [{ title: "首页" }],
-  "/user/list": [{ title: "陪玩管理" }, { title: "陪玩列表" }],
-  "/user-admin/list": [{ title: "后台用户管理" }, { title: "用户列表" }],
-};
+// Convert our MenuItem to Ant Design MenuItem format
+function convertToAntdMenu(items: MenuItemType[]): AntMenuItem[] {
+  return items.map((item) => ({
+    key: item.key,
+    icon: item.icon,
+    label: item.label,
+    children: item.children ? convertToAntdMenu(item.children) : undefined,
+  }));
+}
 
 export default function DashboardLayout({
   children,
 }: {
   children: React.ReactNode;
 }) {
-  const [collapsed, setCollapsed] = useState(false);
   const router = useRouter();
   const pathname = usePathname();
   const { user, logout, isAuthenticated, isLoading } = useAuth();
@@ -89,6 +50,33 @@ export default function DashboardLayout({
     token: { colorBgContainer, borderRadiusLG },
   } = theme.useToken();
 
+  // Menu store
+  const menus = useMenuStore((state) => state.menus);
+  const collapsed = useMenuStore((state) => state.collapsed);
+  const setCollapsed = useMenuStore((state) => state.setCollapsed);
+  const initMenus = useMenuStore((state) => state.initMenus);
+  const setActiveMenuByPath = useMenuStore(
+    (state) => state.setActiveMenuByPath,
+  );
+  const getBreadcrumb = useMenuStore((state) => state.getBreadcrumb);
+
+  // Tab store
+  const tabs = useTabStore((state) => state.tabs);
+  const initTabs = useTabStore((state) => state.initTabs);
+
+  // Initialize menus and tabs on mount
+  useEffect(() => {
+    if (menus.length === 0) {
+      initMenus();
+    }
+  }, [menus.length, initMenus]);
+
+  useEffect(() => {
+    if (isAuthenticated && tabs.length === 0) {
+      initTabs();
+    }
+  }, [isAuthenticated, tabs.length, initTabs]);
+
   // Redirect to login if not authenticated
   useEffect(() => {
     if (!isLoading && !isAuthenticated) {
@@ -96,21 +84,62 @@ export default function DashboardLayout({
     }
   }, [isAuthenticated, isLoading, router]);
 
-  // Get current selected key and open keys based on pathname
+  // Update active menu when pathname changes
+  useEffect(() => {
+    if (pathname) {
+      setActiveMenuByPath(pathname);
+    }
+  }, [pathname, setActiveMenuByPath]);
+
+  // Convert menus to Ant Design format
+  const antdMenuItems = useMemo(() => {
+    return convertToAntdMenu(menus);
+  }, [menus]);
+
+  // Get current selected key based on pathname
   const selectedKey = useMemo(() => {
-    return pathToKeyMap[pathname] || "home";
-  }, [pathname]);
+    // Find menu item by path
+    function findKeyByPath(items: MenuItemType[], path: string): string | null {
+      for (const item of items) {
+        if (item.path === path) {
+          return item.key;
+        }
+        if (item.children) {
+          const found = findKeyByPath(item.children, path);
+          if (found) return found;
+        }
+      }
+      return null;
+    }
+    return findKeyByPath(menus, pathname) || "home";
+  }, [pathname, menus]);
 
+  // Get open keys for current path
   const defaultOpenKeys = useMemo(() => {
-    return keyToOpenKeysMap[selectedKey] || [];
-  }, [selectedKey]);
+    return getParentKeys(menus, selectedKey);
+  }, [menus, selectedKey]);
 
+  // Get breadcrumb items
   const breadcrumbItems = useMemo(() => {
-    return pathToBreadcrumb[pathname] || [{ title: "首页" }];
-  }, [pathname]);
+    const breadcrumb = getBreadcrumb(pathname);
+    return breadcrumb.map((title) => ({ title }));
+  }, [pathname, getBreadcrumb]);
 
   const handleMenuClick = (e: { key: string }) => {
-    const path = keyToPathMap[e.key];
+    // Find menu item by key
+    function findPathByKey(items: MenuItemType[], key: string): string | null {
+      for (const item of items) {
+        if (item.key === key) {
+          return item.path;
+        }
+        if (item.children) {
+          const found = findPathByKey(item.children, key);
+          if (found) return found;
+        }
+      }
+      return null;
+    }
+    const path = findPathByKey(menus, e.key);
     if (path) {
       router.push(path);
     }
@@ -137,14 +166,27 @@ export default function DashboardLayout({
         collapsible
         collapsed={collapsed}
         onCollapse={(value) => setCollapsed(value)}
+        theme="dark"
       >
-        <div className="demo-logo-vertical" />
+        <div
+          className="flex items-center justify-center"
+          style={{
+            height: "32px",
+            margin: "16px",
+            fontSize: "18px",
+            color: "#fff",
+            fontWeight: "bold",
+          }}
+        >
+          {!collapsed && "Lanu OP"}
+        </div>
+        <MenuSearch collapsed={collapsed} />
         <Menu
           theme="dark"
           selectedKeys={[selectedKey]}
           defaultOpenKeys={defaultOpenKeys}
           mode="inline"
-          items={items}
+          items={antdMenuItems}
           onClick={handleMenuClick}
         />
       </Sider>
@@ -168,6 +210,7 @@ export default function DashboardLayout({
             </Space>
           </Dropdown>
         </Header>
+        <TabBar />
         <Content style={{ margin: "0 16px" }}>
           <Breadcrumb style={{ margin: "16px 0" }} items={breadcrumbItems} />
           <div

+ 64 - 14
src/app/globals.css

@@ -1,26 +1,76 @@
 @import "tailwindcss";
 
 :root {
-  --background: #ffffff;
-  --foreground: #171717;
-}
-
-@theme inline {
-  --color-background: var(--background);
-  --color-foreground: var(--foreground);
-  --font-sans: var(--font-geist-sans);
-  --font-mono: var(--font-geist-mono);
+  --foreground-rgb: 0, 0, 0;
+  --background-start-rgb: 214, 219, 220;
+  --background-end-rgb: 255, 255, 255;
 }
 
 @media (prefers-color-scheme: dark) {
   :root {
-    --background: #0a0a0a;
-    --foreground: #ededed;
+    --foreground-rgb: 255, 255, 255;
+    --background-start-rgb: 0, 0, 0;
+    --background-end-rgb: 0, 0, 0;
   }
 }
 
 body {
-  background: var(--background);
-  color: var(--foreground);
-  font-family: Arial, Helvetica, sans-serif;
+  color: rgb(var(--foreground-rgb));
+  background: linear-gradient(
+      to bottom,
+      transparent,
+      rgb(var(--background-end-rgb))
+    )
+    rgb(var(--background-start-rgb));
+}
+
+@layer utilities {
+  .text-balance {
+    text-wrap: balance;
+  }
+}
+
+/* Custom scrollbar for tab bar */
+.tab-bar::-webkit-scrollbar {
+  height: 4px;
+}
+
+.tab-bar::-webkit-scrollbar-track {
+  background: #f0f0f0;
+}
+
+.tab-bar::-webkit-scrollbar-thumb {
+  background: #bfbfbf;
+  border-radius: 2px;
+}
+
+.tab-bar::-webkit-scrollbar-thumb:hover {
+  background: #8c8c8c;
+}
+
+/* Tab item hover effect */
+.tab-item:hover {
+  opacity: 0.8;
+}
+
+.tab-item:focus {
+  outline: 2px solid #1890ff;
+  outline-offset: 2px;
+}
+
+/* Tab item dragging state */
+.tab-item.dragging {
+  opacity: 0.5;
+  cursor: grabbing !important;
+}
+
+.tab-item:active {
+  cursor: grabbing;
+}
+
+/* Remove default button styles for tab items */
+.tab-item {
+  font-family: inherit;
+  font-size: inherit;
+  line-height: inherit;
 }

+ 4 - 1
src/app/layout.tsx

@@ -4,6 +4,7 @@ import { AntdRegistry } from "@ant-design/nextjs-registry";
 import type { Metadata } from "next";
 import { Geist, Geist_Mono } from "next/font/google";
 import "./globals.css";
+import { ConfigProvider } from "antd";
 import { AuthProvider } from "@/contexts/AuthContext";
 
 const geistSans = Geist({
@@ -32,7 +33,9 @@ export default function RootLayout({
         className={`${geistSans.variable} ${geistMono.variable} antialiased`}
       >
         <AntdRegistry>
-          <AuthProvider>{children}</AuthProvider>
+          <ConfigProvider>
+            <AuthProvider>{children}</AuthProvider>
+          </ConfigProvider>
         </AntdRegistry>
       </body>
     </html>

+ 1 - 8
src/app/page.tsx

@@ -20,14 +20,7 @@ export default function RootPage() {
   }, [isAuthenticated, isLoading, router]);
 
   return (
-    <div
-      style={{
-        display: "flex",
-        alignItems: "center",
-        justifyContent: "center",
-        minHeight: "100vh",
-      }}
-    >
+    <div className="flex items-center justify-center min-h-screen">
       <Spin size="large" />
     </div>
   );

+ 79 - 0
src/components/MenuSearch.tsx

@@ -0,0 +1,79 @@
+"use client";
+
+import { AutoComplete, Input } from "antd";
+import { useRouter } from "next/navigation";
+import type React from "react";
+import { useState } from "react";
+import { useMenuStore } from "@/stores/menuStore";
+
+interface MenuSearchProps {
+  collapsed?: boolean;
+}
+
+const MenuSearch: React.FC<MenuSearchProps> = ({ collapsed = false }) => {
+  const [searchValue, setSearchValue] = useState("");
+  const router = useRouter();
+  const searchMenus = useMenuStore((state) => state.searchMenus);
+
+  // If sidebar is collapsed, don't show search
+  if (collapsed) {
+    return null;
+  }
+
+  const handleSearch = (value: string) => {
+    setSearchValue(value);
+  };
+
+  const handleSelect = (value: string, option: any) => {
+    // Navigate to the selected menu
+    if (option?.path) {
+      router.push(option.path);
+      setSearchValue("");
+    }
+  };
+
+  const searchResults = searchMenus(searchValue);
+  const options = searchResults.map((result) => ({
+    value: result.value,
+    label: (
+      <div
+        style={{
+          display: "flex",
+          justifyContent: "space-between",
+          alignItems: "center",
+          gap: "8px",
+        }}
+      >
+        <span style={{ fontWeight: 500 }}>{result.label}</span>
+        <span
+          style={{
+            fontSize: "12px",
+            color: "#999",
+            whiteSpace: "nowrap",
+            overflow: "hidden",
+            textOverflow: "ellipsis",
+          }}
+        >
+          {result.breadcrumb.join(" / ")}
+        </span>
+      </div>
+    ),
+    path: result.path,
+  }));
+
+  return (
+    <div className="p-2 mb-2">
+      <AutoComplete
+        value={searchValue}
+        options={options}
+        onSearch={handleSearch}
+        onSelect={handleSelect}
+        style={{ width: "100%" }}
+      >
+        <Input placeholder="搜索菜单..." />
+      </AutoComplete>
+    </div>
+  );
+};
+
+export default MenuSearch;

+ 171 - 0
src/components/TabBar/Tab.tsx

@@ -0,0 +1,171 @@
+"use client";
+
+import { CloseOutlined } from "@ant-design/icons";
+import type { MenuProps } from "antd";
+import { Dropdown } from "antd";
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { useRouter } from "next/navigation";
+import type React from "react";
+import { useMemo } from "react";
+import { useTabStore } from "@/stores/tabStore";
+import type { TabItem } from "@/types/menu";
+
+interface TabProps {
+  tab: TabItem;
+  active: boolean;
+}
+
+const Tab: React.FC<TabProps> = ({ tab, active }) => {
+  const router = useRouter();
+  const removeTab = useTabStore((state) => state.removeTab);
+  const removeTabsByPosition = useTabStore(
+    (state) => state.removeTabsByPosition,
+  );
+  const setActiveTab = useTabStore((state) => state.setActiveTab);
+
+  // Setup drag and drop (disable for home tab)
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({
+    id: tab.key,
+    disabled: !tab.closable, // Home tab cannot be dragged
+  });
+
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+    opacity: isDragging ? 0.5 : 1,
+  };
+
+  const handleClick = () => {
+    if (!active) {
+      setActiveTab(tab.key);
+      router.push(tab.path);
+    }
+  };
+
+  const handleClose = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    e.preventDefault();
+    const nextTab = removeTab(tab.key);
+    if (nextTab) {
+      router.push(nextTab.path);
+    }
+  };
+
+  const menuItems: MenuProps["items"] = useMemo(() => {
+    const items: MenuProps["items"] = [];
+
+    // Close current (only if closable)
+    if (tab.closable) {
+      items.push({
+        key: "close-current",
+        label: "关闭当前",
+        onClick: () => {
+          const nextTab = removeTab(tab.key);
+          if (nextTab) {
+            router.push(nextTab.path);
+          }
+        },
+      });
+    }
+
+    items.push(
+      {
+        key: "close-left",
+        label: "关闭左侧",
+        onClick: () => {
+          removeTabsByPosition("left", tab.key);
+        },
+      },
+      {
+        key: "close-right",
+        label: "关闭右侧",
+        onClick: () => {
+          removeTabsByPosition("right", tab.key);
+        },
+      },
+      {
+        key: "close-others",
+        label: "关闭其他",
+        onClick: () => {
+          removeTabsByPosition("others", tab.key);
+          if (!active) {
+            setActiveTab(tab.key);
+            router.push(tab.path);
+          }
+        },
+      },
+      {
+        key: "close-all",
+        label: "关闭所有",
+        onClick: () => {
+          removeTabsByPosition("all", tab.key);
+          router.push("/home");
+        },
+      },
+    );
+
+    return items;
+  }, [tab, active, removeTab, removeTabsByPosition, setActiveTab, router]);
+
+  return (
+    <div ref={setNodeRef} style={style}>
+      <Dropdown menu={{ items: menuItems }} trigger={["contextMenu"]}>
+        <button
+          type="button"
+          onClick={handleClick}
+          className={`tab-item ${active ? "active" : ""} ${isDragging ? "dragging" : ""}`}
+          style={{
+            display: "inline-flex",
+            alignItems: "center",
+            padding: "6px 12px",
+            marginRight: "4px",
+            cursor: tab.closable ? "grab" : "pointer",
+            borderRadius: "4px",
+            backgroundColor: active ? "#1890ff" : "#fff",
+            color: active ? "#fff" : "#000",
+            border: "1px solid",
+            borderColor: active ? "#1890ff" : "#d9d9d9",
+            transition: "all 0.3s",
+            userSelect: "none",
+            whiteSpace: "nowrap",
+          }}
+          {...attributes}
+          {...listeners}
+        >
+          <span style={{ marginRight: tab.closable ? "8px" : "0" }}>
+            {tab.label}
+          </span>
+          {tab.closable && (
+            <CloseOutlined
+              onClick={handleClose}
+              style={{
+                fontSize: "12px",
+                padding: "2px",
+                borderRadius: "2px",
+                transition: "all 0.2s",
+              }}
+              onMouseEnter={(e) => {
+                e.currentTarget.style.backgroundColor = active
+                  ? "rgba(255, 255, 255, 0.2)"
+                  : "rgba(0, 0, 0, 0.1)";
+              }}
+              onMouseLeave={(e) => {
+                e.currentTarget.style.backgroundColor = "transparent";
+              }}
+            />
+          )}
+        </button>
+      </Dropdown>
+    </div>
+  );
+};
+
+export default Tab;

+ 147 - 0
src/components/TabBar/index.tsx

@@ -0,0 +1,147 @@
+"use client";
+
+import type { DragEndEvent } from "@dnd-kit/core";
+import {
+  closestCenter,
+  DndContext,
+  KeyboardSensor,
+  PointerSensor,
+  useSensor,
+  useSensors,
+} from "@dnd-kit/core";
+import {
+  horizontalListSortingStrategy,
+  SortableContext,
+  sortableKeyboardCoordinates,
+} from "@dnd-kit/sortable";
+import { usePathname } from "next/navigation";
+import type React from "react";
+import { useEffect, useRef } from "react";
+import { useMenuStore } from "@/stores/menuStore";
+import { useTabStore } from "@/stores/tabStore";
+import Tab from "./Tab";
+
+const TabBar: React.FC = () => {
+  const pathname = usePathname();
+  const tabs = useTabStore((state) => state.tabs);
+  const activeTab = useTabStore((state) => state.activeTab);
+  const addTab = useTabStore((state) => state.addTab);
+  const setActiveTab = useTabStore((state) => state.setActiveTab);
+  const getTabByPath = useTabStore((state) => state.getTabByPath);
+  const getBreadcrumb = useMenuStore((state) => state.getBreadcrumb);
+  const reorderTabs = useTabStore((state) => state.reorderTabs);
+
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  // Setup sensors for drag and drop
+  const sensors = useSensors(
+    useSensor(PointerSensor, {
+      activationConstraint: {
+        distance: 8, // 8px movement required before drag starts
+      },
+    }),
+    useSensor(KeyboardSensor, {
+      coordinateGetter: sortableKeyboardCoordinates,
+    }),
+  );
+
+  // Handle drag end
+  const handleDragEnd = (event: DragEndEvent) => {
+    const { active, over } = event;
+
+    if (over && active.id !== over.id) {
+      reorderTabs(active.id as string, over.id as string);
+    }
+  };
+
+  // Sync tabs with route changes
+  useEffect(() => {
+    if (!pathname || pathname === "/") return;
+
+    const existingTab = getTabByPath(pathname);
+    if (existingTab) {
+      // Tab exists, just activate it
+      setActiveTab(existingTab.key);
+    } else {
+      // Create new tab
+      const breadcrumb = getBreadcrumb(pathname);
+      const label = breadcrumb[breadcrumb.length - 1] || "未命名";
+
+      // Generate key from path
+      const key = pathname.replace(/\//g, "-").slice(1) || "home";
+
+      addTab({
+        key,
+        label,
+        path: pathname,
+        closable: pathname !== "/home",
+      });
+    }
+  }, [pathname, getTabByPath, setActiveTab, addTab, getBreadcrumb]);
+
+  // Auto scroll to active tab
+  useEffect(() => {
+    if (!activeTab || !containerRef.current) return;
+
+    const container = containerRef.current;
+    const activeElement = container.querySelector(
+      ".tab-item.active",
+    ) as HTMLElement;
+
+    if (activeElement) {
+      const containerRect = container.getBoundingClientRect();
+      const elementRect = activeElement.getBoundingClientRect();
+
+      // Check if element is outside viewport
+      if (
+        elementRect.left < containerRect.left ||
+        elementRect.right > containerRect.right
+      ) {
+        activeElement.scrollIntoView({
+          behavior: "smooth",
+          block: "nearest",
+          inline: "center",
+        });
+      }
+    }
+  }, [activeTab]);
+
+  if (tabs.length === 0) {
+    return null;
+  }
+
+  return (
+    <DndContext
+      sensors={sensors}
+      collisionDetection={closestCenter}
+      onDragEnd={handleDragEnd}
+    >
+      <SortableContext
+        items={tabs.map((tab) => tab.key)}
+        strategy={horizontalListSortingStrategy}
+      >
+        <div
+          ref={containerRef}
+          style={{
+            display: "flex",
+            alignItems: "center",
+            padding: "8px 16px",
+            backgroundColor: "#f0f0f0",
+            borderBottom: "1px solid #d9d9d9",
+            overflowX: "auto",
+            overflowY: "hidden",
+            whiteSpace: "nowrap",
+            scrollBehavior: "smooth",
+          }}
+          className="tab-bar"
+        >
+          {tabs.map((tab) => (
+            <Tab key={tab.key} tab={tab} active={activeTab?.key === tab.key} />
+          ))}
+        </div>
+      </SortableContext>
+    </DndContext>
+  );
+};
+
+export default TabBar;

+ 43 - 0
src/config/menus.tsx

@@ -0,0 +1,43 @@
+import { HomeOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
+import type { MenuItem } from "@/types/menu";
+
+// Menu configuration based on the existing routes
+export const menuConfig: MenuItem[] = [
+  {
+    key: "home",
+    label: "首页",
+    path: "/home",
+    icon: <HomeOutlined />,
+    sortOrder: 1,
+  },
+  {
+    key: "user",
+    label: "陪玩管理",
+    path: "/user",
+    icon: <UserOutlined />,
+    sortOrder: 2,
+    children: [
+      {
+        key: "user-list",
+        label: "陪玩列表",
+        path: "/user/list",
+        sortOrder: 1,
+      },
+    ],
+  },
+  {
+    key: "user-admin",
+    label: "后台用户管理",
+    path: "/user-admin",
+    icon: <TeamOutlined />,
+    sortOrder: 3,
+    children: [
+      {
+        key: "user-admin-list",
+        label: "用户列表",
+        path: "/user-admin/list",
+        sortOrder: 1,
+      },
+    ],
+  },
+];

+ 72 - 0
src/stores/menuStore.ts

@@ -0,0 +1,72 @@
+import { create } from "zustand";
+import { menuConfig } from "@/config/menus";
+import type { MenuItem, MenuSearchResult } from "@/types/menu";
+import {
+  findMenuByPath,
+  getBreadcrumbByPath,
+  searchMenus,
+} from "@/utils/menuUtils";
+
+interface MenuStore {
+  // State
+  menus: MenuItem[];
+  collapsed: boolean;
+  activeMenu: MenuItem | null;
+  activePath: string;
+
+  // Actions
+  setCollapsed: (collapsed: boolean) => void;
+  toggleCollapsed: () => void;
+  setActiveMenu: (menu: MenuItem | null) => void;
+  setActiveMenuByPath: (path: string) => void;
+  searchMenus: (keyword: string) => MenuSearchResult[];
+  getBreadcrumb: (path: string) => string[];
+  initMenus: () => void;
+}
+
+export const useMenuStore = create<MenuStore>((set, get) => ({
+  // Initial state
+  menus: [],
+  collapsed: false,
+  activeMenu: null,
+  activePath: "",
+
+  // Set collapsed state
+  setCollapsed: (collapsed: boolean) => {
+    set({ collapsed });
+  },
+
+  // Toggle collapsed state
+  toggleCollapsed: () => {
+    set((state) => ({ collapsed: !state.collapsed }));
+  },
+
+  // Set active menu
+  setActiveMenu: (menu: MenuItem | null) => {
+    set({ activeMenu: menu, activePath: menu?.path || "" });
+  },
+
+  // Set active menu by path
+  setActiveMenuByPath: (path: string) => {
+    const { menus } = get();
+    const menu = findMenuByPath(menus, path);
+    set({ activeMenu: menu, activePath: path });
+  },
+
+  // Search menus
+  searchMenus: (keyword: string) => {
+    const { menus } = get();
+    return searchMenus(menus, keyword);
+  },
+
+  // Get breadcrumb by path
+  getBreadcrumb: (path: string) => {
+    const { menus } = get();
+    return getBreadcrumbByPath(menus, path);
+  },
+
+  // Initialize menus from config
+  initMenus: () => {
+    set({ menus: menuConfig });
+  },
+}));

+ 176 - 0
src/stores/tabStore.ts

@@ -0,0 +1,176 @@
+import { create } from "zustand";
+import type { TabItem } from "@/types/menu";
+
+interface TabStore {
+  // State
+  tabs: TabItem[];
+  activeTab: TabItem | null;
+
+  // Actions
+  addTab: (tab: Omit<TabItem, "index">) => void;
+  removeTab: (key: string) => TabItem | null; // Returns the next active tab
+  removeTabsByPosition: (position: "left" | "right" | "others" | "all", currentKey: string) => void;
+  setActiveTab: (key: string) => void;
+  getTabByPath: (path: string) => TabItem | null;
+  reorderTabs: (activeKey: string, overKey: string) => void;
+  initTabs: () => void;
+  clearTabs: () => void;
+}
+
+export const useTabStore = create<TabStore>((set, get) => ({
+  // Initial state
+  tabs: [],
+  activeTab: null,
+
+  // Add a new tab
+  addTab: (tab) => {
+    const { tabs, getTabByPath } = get();
+    
+    // Check if tab already exists
+    const existingTab = getTabByPath(tab.path);
+    if (existingTab) {
+      // If tab exists, just activate it
+      set({ activeTab: existingTab });
+      return;
+    }
+
+    // Add new tab with index
+    const newTab: TabItem = {
+      ...tab,
+      index: tabs.length,
+    };
+
+    const newTabs = [...tabs, newTab];
+    // Update all indices
+    newTabs.forEach((t, i) => {
+      t.index = i;
+    });
+
+    set({ tabs: newTabs, activeTab: newTab });
+  },
+
+  // Remove a tab by key
+  removeTab: (key) => {
+    const { tabs, activeTab } = get();
+    const tabIndex = tabs.findIndex((t) => t.key === key);
+
+    if (tabIndex === -1) return null;
+
+    const removedTab = tabs[tabIndex];
+    const newTabs = tabs.filter((t) => t.key !== key);
+
+    // Update all indices
+    newTabs.forEach((t, i) => {
+      t.index = i;
+    });
+
+    // Determine next active tab if the removed tab was active
+    let nextActiveTab = activeTab;
+    if (activeTab?.key === key) {
+      // Try to activate the tab to the right, or left if no right tab
+      nextActiveTab = newTabs[tabIndex] || newTabs[tabIndex - 1] || null;
+    }
+
+    set({ tabs: newTabs, activeTab: nextActiveTab });
+    return nextActiveTab;
+  },
+
+  // Remove tabs by position relative to current tab
+  removeTabsByPosition: (position, currentKey) => {
+    const { tabs } = get();
+    const currentIndex = tabs.findIndex((t) => t.key === currentKey);
+
+    if (currentIndex === -1) return;
+
+    let newTabs: TabItem[] = [];
+
+    switch (position) {
+      case "left":
+        // Keep first tab (home) and tabs from current onwards
+        newTabs = tabs.filter((t, i) => i === 0 || i >= currentIndex);
+        break;
+      case "right":
+        // Keep tabs up to and including current
+        newTabs = tabs.filter((t, i) => i <= currentIndex);
+        break;
+      case "others":
+        // Keep only first tab (home) and current tab
+        newTabs = tabs.filter((t, i) => i === 0 || t.key === currentKey);
+        break;
+      case "all":
+        // Keep only first tab (home)
+        newTabs = tabs.filter((t, i) => i === 0);
+        break;
+    }
+
+    // Update all indices
+    newTabs.forEach((t, i) => {
+      t.index = i;
+    });
+
+    // If active tab was removed, activate the current tab or first tab
+    const activeTab = get().activeTab;
+    const activeExists = newTabs.some((t) => t.key === activeTab?.key);
+    const nextActiveTab = activeExists
+      ? activeTab
+      : newTabs.find((t) => t.key === currentKey) || newTabs[0] || null;
+
+    set({ tabs: newTabs, activeTab: nextActiveTab });
+  },
+
+  // Set active tab by key
+  setActiveTab: (key) => {
+    const { tabs } = get();
+    const tab = tabs.find((t) => t.key === key);
+    if (tab) {
+      set({ activeTab: tab });
+    }
+  },
+
+  // Get tab by path
+  getTabByPath: (path) => {
+    const { tabs } = get();
+    return tabs.find((t) => t.path === path) || null;
+  },
+
+  // Reorder tabs (for drag and drop)
+  reorderTabs: (activeKey: string, overKey: string) => {
+    const { tabs } = get();
+    const oldIndex = tabs.findIndex((t) => t.key === activeKey);
+    const newIndex = tabs.findIndex((t) => t.key === overKey);
+
+    if (oldIndex === -1 || newIndex === -1) return;
+    
+    // Don't allow dragging the first tab (home)
+    if (oldIndex === 0 || newIndex === 0) return;
+
+    const newTabs = [...tabs];
+    const [movedTab] = newTabs.splice(oldIndex, 1);
+    newTabs.splice(newIndex, 0, movedTab);
+
+    // Update all indices
+    newTabs.forEach((t, i) => {
+      t.index = i;
+    });
+
+    set({ tabs: newTabs });
+  },
+
+  // Initialize with home tab
+  initTabs: () => {
+    const homeTab: TabItem = {
+      key: "home",
+      label: "首页",
+      path: "/home",
+      closable: false,
+      index: 0,
+    };
+    set({ tabs: [homeTab], activeTab: homeTab });
+  },
+
+  // Clear all tabs (for logout)
+  clearTabs: () => {
+    set({ tabs: [], activeTab: null });
+  },
+}));
+

+ 33 - 0
src/types/menu.ts

@@ -0,0 +1,33 @@
+// Menu item interface
+export interface MenuItem {
+  key: string;
+  label: string;
+  path: string;
+  icon?: React.ReactNode;
+  children?: MenuItem[];
+  sortOrder?: number;
+  permission?: string;
+}
+
+// Tab item interface
+export interface TabItem {
+  key: string;
+  label: string;
+  path: string;
+  closable: boolean;
+  index: number;
+}
+
+// Breadcrumb item interface
+export interface BreadcrumbItem {
+  title: string;
+}
+
+// Menu search result
+export interface MenuSearchResult {
+  value: string;
+  label: string;
+  path: string;
+  breadcrumb: string[];
+}
+

+ 141 - 0
src/utils/menuUtils.ts

@@ -0,0 +1,141 @@
+import type { MenuItem, MenuSearchResult } from "@/types/menu";
+
+/**
+ * Get all leaf menus (menus without children) for search
+ */
+export function getLeafMenus(menus: MenuItem[]): MenuItem[] {
+  const leafMenus: MenuItem[] = [];
+
+  function traverse(items: MenuItem[], parentPath: string[] = []) {
+    for (const item of items) {
+      const currentPath = [...parentPath, item.label];
+      
+      if (item.children && item.children.length > 0) {
+        traverse(item.children, currentPath);
+      } else {
+        leafMenus.push({
+          ...item,
+          // Store breadcrumb path for display
+          sortOrder: item.sortOrder || 0,
+        });
+      }
+    }
+  }
+
+  traverse(menus);
+  return leafMenus;
+}
+
+/**
+ * Search menus by keyword (fuzzy match)
+ */
+export function searchMenus(
+  menus: MenuItem[],
+  keyword: string,
+): MenuSearchResult[] {
+  if (!keyword || keyword.trim() === "") {
+    return [];
+  }
+
+  const results: MenuSearchResult[] = [];
+  const lowerKeyword = keyword.toLowerCase();
+
+  function traverse(items: MenuItem[], breadcrumb: string[] = []) {
+    for (const item of items) {
+      const currentBreadcrumb = [...breadcrumb, item.label];
+
+      if (item.children && item.children.length > 0) {
+        traverse(item.children, currentBreadcrumb);
+      } else {
+        // Search in label
+        if (item.label.toLowerCase().includes(lowerKeyword)) {
+          results.push({
+            value: item.label,
+            label: item.label,
+            path: item.path,
+            breadcrumb: currentBreadcrumb,
+          });
+        }
+      }
+    }
+  }
+
+  traverse(menus);
+  return results;
+}
+
+/**
+ * Find menu by path
+ */
+export function findMenuByPath(
+  menus: MenuItem[],
+  path: string,
+): MenuItem | null {
+  for (const menu of menus) {
+    if (menu.path === path) {
+      return menu;
+    }
+    if (menu.children) {
+      const found = findMenuByPath(menu.children, path);
+      if (found) {
+        return found;
+      }
+    }
+  }
+  return null;
+}
+
+/**
+ * Get breadcrumb for a path
+ */
+export function getBreadcrumbByPath(
+  menus: MenuItem[],
+  path: string,
+): string[] {
+  function traverse(items: MenuItem[], breadcrumb: string[] = []): string[] | null {
+    for (const item of items) {
+      const currentBreadcrumb = [...breadcrumb, item.label];
+      
+      if (item.path === path) {
+        return currentBreadcrumb;
+      }
+      
+      if (item.children) {
+        const found = traverse(item.children, currentBreadcrumb);
+        if (found) {
+          return found;
+        }
+      }
+    }
+    return null;
+  }
+
+  return traverse(menus) || [];
+}
+
+/**
+ * Get parent menu keys for opening menu items
+ */
+export function getParentKeys(menus: MenuItem[], targetKey: string): string[] {
+  const parentKeys: string[] = [];
+
+  function traverse(items: MenuItem[], parents: string[] = []): boolean {
+    for (const item of items) {
+      if (item.key === targetKey) {
+        parentKeys.push(...parents);
+        return true;
+      }
+      
+      if (item.children) {
+        if (traverse(item.children, [...parents, item.key])) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  traverse(menus);
+  return parentKeys;
+}
+

+ 37 - 1
yarn.lock

@@ -157,6 +157,37 @@
   resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.0.tgz#5d2523b421d847b13fac146cf745436ea8a72b95"
   integrity sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww==
 
+"@dnd-kit/accessibility@^3.1.1":
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz#3b4202bd6bb370a0730f6734867785919beac6af"
+  integrity sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==
+  dependencies:
+    tslib "^2.0.0"
+
+"@dnd-kit/core@^6.3.1":
+  version "6.3.1"
+  resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.3.1.tgz#4c36406a62c7baac499726f899935f93f0e6d003"
+  integrity sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==
+  dependencies:
+    "@dnd-kit/accessibility" "^3.1.1"
+    "@dnd-kit/utilities" "^3.2.2"
+    tslib "^2.0.0"
+
+"@dnd-kit/sortable@^10.0.0":
+  version "10.0.0"
+  resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz#1f9382b90d835cd5c65d92824fa9dafb78c4c3e8"
+  integrity sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==
+  dependencies:
+    "@dnd-kit/utilities" "^3.2.2"
+    tslib "^2.0.0"
+
+"@dnd-kit/utilities@^3.2.2":
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz#5a32b6af356dc5f74d61b37d6f7129a4040ced7b"
+  integrity sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==
+  dependencies:
+    tslib "^2.0.0"
+
 "@emnapi/core@^1.5.0", "@emnapi/core@^1.6.0":
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.7.0.tgz#135de4e8858763989112281bdf38ca02439db7c3"
@@ -1377,7 +1408,7 @@ toggle-selection@^1.0.6:
   resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
   integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
 
-tslib@^2.4.0, tslib@^2.8.0:
+tslib@^2.0.0, tslib@^2.4.0, tslib@^2.8.0:
   version "2.8.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
   integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
@@ -1391,3 +1422,8 @@ undici-types@~6.21.0:
   version "6.21.0"
   resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
   integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
+
+zustand@^5.0.8:
+  version "5.0.8"
+  resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.8.tgz#b998a0c088c7027a20f2709141a91cb07ac57f8a"
+  integrity sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==