diff --git a/SuperAdmin/app/dashboard/layout.tsx b/SuperAdmin/app/dashboard/layout.tsx index aa73fed2..af35df0a 100644 --- a/SuperAdmin/app/dashboard/layout.tsx +++ b/SuperAdmin/app/dashboard/layout.tsx @@ -1,14 +1,41 @@ "use client" import type React from "react" -import { useState, useEffect } from "react" -import { useRouter } from "next/navigation" +import { useState, useEffect, createContext, useContext } from "react" +import { useRouter, usePathname } from "next/navigation" import { Button } from "@/components/ui/button" import { Menu, X } from "lucide-react" import { Sidebar } from "@/components/layout/sidebar" import { Header } from "@/components/layout/header" import { getAdminInfo } from "@/lib/utils" +// 全局标签页管理上下文 +interface TabData { + id: string + label: string + path: string + closable: boolean +} + +interface TabContextType { + tabs: TabData[] + activeTab: string + addTab: (tab: Omit) => string + closeTab: (tabId: string) => void + setActiveTab: (tabId: string) => void + findTabByPath: (path: string) => TabData | undefined +} + +const TabContext = createContext(undefined); + +export function useTabContext() { + const context = useContext(TabContext); + if (!context) { + throw new Error("useTabContext must be used within a TabProvider"); + } + return context; +} + export default function DashboardLayout({ children, }: { @@ -16,6 +43,122 @@ export default function DashboardLayout({ }) { const [sidebarOpen, setSidebarOpen] = useState(true) const router = useRouter() + const pathname = usePathname() + + // 标签页状态管理 + const [tabs, setTabs] = useState([ + { id: "dashboard", label: "仪表盘", path: "/dashboard", closable: false } + ]) + const [activeTab, setActiveTab] = useState("dashboard") + + // 添加标签页 + const addTab = (tabData: Omit) => { + const id = `tab-${Date.now()}` + const newTab = { id, ...tabData } + + // 检查是否已存在类似标签(基于路径) + const existingTab = findTabByPath(tabData.path) + + if (existingTab) { + // 如果已存在,激活它 + setActiveTab(existingTab.id) + // 确保导航到该路径(即使路径匹配也强制导航) + router.push(tabData.path) + return existingTab.id + } else { + // 如果不存在,添加新标签 + setTabs(prev => [...prev, newTab]) + setActiveTab(id) + // 确保导航到该路径(即使路径匹配也强制导航) + router.push(tabData.path) + return id + } + } + + // 设置激活标签(并导航到对应路径) + const setActiveTabAndNavigate = (tabId: string) => { + setActiveTab(tabId) + // 找到标签对应的路径并导航 + const tab = tabs.find(tab => tab.id === tabId) + if (tab) { + router.push(tab.path) + } + } + + // 关闭标签页 + const closeTab = (tabId: string) => { + // 找到要关闭的标签的索引 + const tabIndex = tabs.findIndex(tab => tab.id === tabId) + + // 如果标签不存在或者是不可关闭的标签,直接返回 + if (tabIndex === -1 || !tabs[tabIndex].closable) return + + // 创建新的标签数组,移除要关闭的标签 + const newTabs = tabs.filter(tab => tab.id !== tabId) + setTabs(newTabs) + + // 如果关闭的是当前活动标签,需要激活另一个标签 + if (activeTab === tabId) { + // 优先激活关闭标签左侧的标签,如果没有则激活默认的仪表盘标签 + const newActiveTab = newTabs[tabIndex - 1]?.id || "dashboard" + setActiveTab(newActiveTab) + + // 路由跳转到新激活的标签对应的路径 + const newActivePath = newTabs.find(tab => tab.id === newActiveTab)?.path || "/dashboard" + router.push(newActivePath) + } + } + + // 根据路径查找标签 + const findTabByPath = (path: string): TabData | undefined => { + return tabs.find(tab => tab.path === path) + } + + // 监听路径变化,自动添加标签 + useEffect(() => { + // 不触发/dashboard路径,已有默认标签 + if (pathname === "/dashboard") { + setActiveTab("dashboard") + return + } + + // 检查当前路径是否已有对应标签 + const existingTab = findTabByPath(pathname) + + if (existingTab) { + // 如果存在,激活它 + setActiveTab(existingTab.id) + } else { + // 如果不存在,添加新标签 + // 生成标签标题 + let label = "新标签" + + // 根据路径生成更友好的标签名 + if (pathname.includes("/projects")) { + if (pathname === "/dashboard/projects") { + label = "项目列表" + } else if (pathname.includes("/new")) { + label = "新建项目" + } else if (pathname.includes("/edit")) { + label = "编辑项目" + } else { + label = "项目详情" + } + } else if (pathname.includes("/admins")) { + label = "管理员" + } else if (pathname.includes("/customers")) { + label = "客户池" + } else if (pathname.includes("/settings")) { + label = "系统设置" + } + + addTab({ + label, + path: pathname, + closable: true + }) + } + }, [pathname]) // 认证检查 useEffect(() => { @@ -31,29 +174,72 @@ export default function DashboardLayout({ }, [router]) return ( -
- {/* Mobile sidebar toggle */} -
- -
+ +
+ {/* Mobile sidebar toggle */} +
+ +
- {/* Sidebar */} -
- -
+ {/* Sidebar */} +
+ +
- {/* Main content */} -
-
-
{children}
+ {/* Main content */} +
+
+ + {/* 标签栏 */} +
+
+ {tabs.map(tab => ( +
{ + setActiveTabAndNavigate(tab.id) + }} + > + {tab.label} + {tab.closable && ( + + )} +
+ ))} +
+
+ + {/* 内容区域 */} +
+ {children} +
+
-
+
) } diff --git a/SuperAdmin/app/dashboard/projects/page.tsx b/SuperAdmin/app/dashboard/projects/page.tsx index 6caca435..4b93d9eb 100644 --- a/SuperAdmin/app/dashboard/projects/page.tsx +++ b/SuperAdmin/app/dashboard/projects/page.tsx @@ -1,7 +1,6 @@ "use client" import { useState, useEffect } from "react" -import Link from "next/link" import { useSearchParams, useRouter, usePathname } from "next/navigation" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -19,6 +18,7 @@ import { } from "@/components/ui/dialog" import { Badge } from "@/components/ui/badge" import { PaginationControls } from "@/components/ui/pagination-controls" +import { useTabContext } from "@/app/dashboard/layout" interface Project { id: number @@ -36,6 +36,8 @@ export default function ProjectsPage() { const searchParams = useSearchParams() const router = useRouter() const pathname = usePathname() + const { addTab } = useTabContext() + const [searchTerm, setSearchTerm] = useState("") const [projects, setProjects] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -136,6 +138,7 @@ export default function ProjectsPage() { if (data.code === 200) { toast.success("删除成功") + // Fetch projects again after delete const fetchProjects = async () => { setIsLoading(true) @@ -168,14 +171,39 @@ export default function ProjectsPage() { } } + // 打开项目详情 + const handleViewProject = (project: Project) => { + addTab({ + label: `项目 #${project.id} - 详情`, + path: `/dashboard/projects/${project.id}`, + closable: true + }); + } + + // 打开编辑项目 + const handleEditProject = (project: Project) => { + addTab({ + label: `项目 #${project.id} - 编辑`, + path: `/dashboard/projects/${project.id}/edit`, + closable: true + }); + } + + // 打开新建项目 + const handleNewProject = () => { + addTab({ + label: "新建项目", + path: "/dashboard/projects/new", + closable: true + }); + } + return (

项目列表

-
@@ -207,7 +235,7 @@ export default function ProjectsPage() { {isLoading ? ( - + 加载中... @@ -232,15 +260,11 @@ export default function ProjectsPage() { - - - 查看详情 - + handleViewProject(project)}> + 查看详情 - - - 编辑项目 - + handleEditProject(project)}> + 编辑项目 - + 未找到项目 diff --git a/SuperAdmin/components/layout/sidebar.tsx b/SuperAdmin/components/layout/sidebar.tsx index 73993682..1297ff6b 100644 --- a/SuperAdmin/components/layout/sidebar.tsx +++ b/SuperAdmin/components/layout/sidebar.tsx @@ -1,37 +1,178 @@ "use client" -import { useEffect, useState } from "react" -import Link from "next/link" +import { useState, useEffect } from "react" import { usePathname } from "next/navigation" -import { getMenus, type MenuItem } from "@/lib/menu-api" import * as LucideIcons from "lucide-react" -import { ChevronDown, ChevronRight } from "lucide-react" +import { cn } from "@/lib/utils" +import { useTabContext } from "@/app/dashboard/layout" +import { getMenus } from "@/lib/menu-api" + +// 适配后端返回的菜单项格式 +interface MenuItem { + id: number + parentId?: number | null + parent_id?: number // 后端返回的字段 + name?: string + title?: string // 后端返回的字段 + path: string + icon?: string + order?: number + sort?: number // 后端返回的字段 + status?: number + children?: MenuItem[] +} export function Sidebar() { const pathname = usePathname() const [menus, setMenus] = useState([]) const [loading, setLoading] = useState(true) - // 使用Set来存储已展开的菜单ID const [expandedMenus, setExpandedMenus] = useState>(new Set()) + const [collapsed, setCollapsed] = useState(false) // 添加折叠状态 + const { addTab } = useTabContext() + // 字段适配:将后端返回的菜单数据格式转换为前端需要的格式 + const adaptMenuItem = (item: MenuItem): MenuItem => { + return { + id: item.id, + parentId: item.parent_id || null, + name: item.title || item.name, + path: item.path, + icon: item.icon, + order: item.sort || item.order || 0, + children: item.children ? item.children.map(adaptMenuItem) : [] + }; + }; + + // 切换折叠状态 + const toggleCollapsed = () => { + setCollapsed(prev => !prev); + }; + + // 获取菜单数据 useEffect(() => { const fetchMenus = async () => { setLoading(true) + try { - const data = await getMenus() - setMenus(data || []) + // 从后端API获取菜单数据 + const menuData = await getMenus(true); - // 自动展开当前活动菜单的父菜单 - autoExpandActiveMenuParent(data || []); + // 适配数据格式 + const adaptedMenus = menuData.map(adaptMenuItem); + + // 构建菜单树 + const menuTree = buildMenuTree(adaptedMenus); + setMenus(menuTree); + + // 初始自动展开当前活动菜单的父菜单 + autoExpandActiveMenuParent(adaptedMenus); } catch (error) { - console.error("获取菜单失败:", error) + console.error("获取菜单数据失败:", error); + // 获取失败时使用空菜单 + setMenus([]); } finally { - setLoading(false) + setLoading(false); } + }; + + fetchMenus(); + }, []); // 仅在组件挂载时执行一次,移除pathname依赖 + + // 监听路径变化以更新菜单展开状态 + useEffect(() => { + if (menus.length > 0) { + // 只在菜单数据存在且路径变化时更新展开状态 + // 获取当前路径所需展开的所有父菜单ID + const pathMenuItems = menus.reduce((allItems, item) => { + const flattenMenu = (menuItem: MenuItem, items: MenuItem[] = []) => { + items.push(menuItem); + if (menuItem.children && menuItem.children.length > 0) { + menuItem.children.forEach(child => flattenMenu(child, items)); + } + return items; + }; + return [...allItems, ...flattenMenu(item)]; + }, [] as MenuItem[]); + + // 保存当前展开状态 + setExpandedMenus(prev => { + // 创建新集合,保留所有已展开的菜单 + const newExpanded = new Set(prev); + + // 将需要展开的菜单添加到集合中 + const currentPath = pathname === "/" ? "/dashboard" : pathname; + + // 查找当前路径对应的菜单项和所有父菜单 + const findActiveMenuParents = (items: MenuItem[], parentIds: number[] = []): number[] => { + for (const item of items) { + // 如果是"#"路径的菜单,检查其子菜单 + if (item.path === "#" && item.children && item.children.length > 0) { + const found = findActiveMenuParents(item.children, [...parentIds, item.id]); + if (found.length > 0) { + return [...found, item.id]; + } + } + // 检查菜单路径是否匹配当前路径 + else if (currentPath === item.path || currentPath.startsWith(item.path + "/")) { + return [...parentIds]; + } + + // 递归检查子菜单 + if (item.children && item.children.length > 0) { + const found = findActiveMenuParents(item.children, [...parentIds, item.id]); + if (found.length > 0) { + return found; + } + } + } + return []; + }; + + // 获取需要自动展开的菜单ID + const parentsToExpand = findActiveMenuParents(menus); + + // 添加到展开集合中 + parentsToExpand.forEach(id => newExpanded.add(id)); + + return newExpanded; + }); } + }, [pathname, menus]); - fetchMenus() - }, []) + // 构建菜单树结构 + const buildMenuTree = (items: MenuItem[]) => { + const map = new Map(); + const roots: MenuItem[] = []; + + // 先创建所有菜单项的映射 + items.forEach(item => { + map.set(item.id, { ...item, children: item.children || [] }); + }); + + // 构建树结构 + items.forEach(item => { + if (!item.parentId || item.parentId === 0) { + // 根菜单 + roots.push(map.get(item.id)!); + } else { + // 子菜单 + const parent = map.get(item.parentId); + if (parent && parent.children) { + parent.children.push(map.get(item.id)!); + } + } + }); + + // 排序 + roots.sort((a, b) => (a.order || 0) - (b.order || 0)); + roots.forEach(root => { + if (root.children) { + root.children.sort((a, b) => (a.order || 0) - (b.order || 0)); + } + }); + + return roots; + }; // 自动展开当前活动菜单的父菜单 const autoExpandActiveMenuParent = (menuItems: MenuItem[]) => { @@ -40,10 +181,23 @@ export function Sidebar() { // 递归查找当前路径匹配的菜单项 const findActiveMenu = (items: MenuItem[], parentIds: number[] = []) => { for (const item of items) { - const currentPath = pathname === "/" ? "/dashboard" : pathname; - const itemPath = item.path; + // 如果是"#"路径的菜单,跳过路径检查 + if (item.path === "#") { + if (item.children && item.children.length > 0) { + const found = findActiveMenu(item.children, [...parentIds, item.id]); + if (found) { + // 将所有父菜单ID添加到展开集合 + parentIds.forEach(id => newExpandedMenus.add(id)); + newExpandedMenus.add(item.id); // 确保当前菜单也被展开 + return true; + } + } + continue; + } - if (currentPath === itemPath || currentPath.startsWith(itemPath + "/")) { + const currentPath = pathname === "/" ? "/dashboard" : pathname; + + if (currentPath === item.path || currentPath.startsWith(item.path + "/")) { // 将所有父菜单ID添加到展开集合 parentIds.forEach(id => newExpandedMenus.add(id)); return true; @@ -60,11 +214,16 @@ export function Sidebar() { }; findActiveMenu(menuItems); + + // 将新的展开菜单集合设置到状态 setExpandedMenus(newExpandedMenus); }; // 切换菜单展开状态 - const toggleMenu = (menuId: number) => { + const toggleMenu = (menuId: number, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setExpandedMenus(prev => { const newExpanded = new Set(prev); if (newExpanded.has(menuId)) { @@ -83,82 +242,207 @@ export function Sidebar() { return Icon ? : null; }; - // 递归渲染菜单项 + // 渲染菜单项 const renderMenuItem = (item: MenuItem) => { + // 修改子菜单项活动状态判断逻辑 + const isMenuPathActive = (menuPath: string, currentPath: string) => { + // 对于精确匹配的情况,直接返回true + if (currentPath === menuPath) { + return true; + } + + // 特殊处理项目列表路径 + if (menuPath === "/dashboard/projects" && currentPath !== "/dashboard/projects") { + // 如果当前路径不是精确匹配项目列表,则项目列表不高亮 + return false; + } + + // 对于其他情况,保持原来的前缀匹配逻辑 + // 但要确保父级路径后有斜杠再做前缀匹配 + return currentPath.startsWith(menuPath + "/"); + }; + + const currentPath = pathname === "/" ? "/dashboard" : pathname; + const isActive = isMenuPathActive(item.path, currentPath); const hasChildren = item.children && item.children.length > 0; const isExpanded = expandedMenus.has(item.id); - const isActive = pathname === item.path; - const isChildActive = hasChildren && item.children!.some(child => - pathname === child.path || pathname.startsWith(child.path + "/") - ); - + const name = item.name || item.title || ""; + + // 折叠状态下的菜单项 + if (collapsed) { + return ( +
  • +
    { + e.preventDefault(); + e.stopPropagation(); + if (!hasChildren) { + const tabId = addTab({ + label: name, + path: item.path, + closable: item.path !== "/dashboard" + }); + } + }} + > + {getLucideIcon(item.icon || "")} + + {/* 悬浮提示 */} + {hasChildren ? ( + + ) : ( +
    + {name} +
    + )} +
    +
  • + ); + } + + // 展开状态下的菜单项 return (
  • {hasChildren ? ( -
    + <> - {isExpanded && hasChildren && ( + {isExpanded && ( )} -
    + ) : ( - { + e.preventDefault(); + e.stopPropagation(); + + // 使用addTab返回的标签ID,在TabContext中处理,确保标签被激活并导航 + const tabId = addTab({ + label: name, + path: item.path, + closable: item.path !== "/dashboard" // 仪表盘不可关闭 + }); + }} + className={cn( + "flex items-center py-2 px-3 rounded-md transition-colors", isActive - ? "text-white font-semibold" - : "hover:bg-blue-600" - }`} + ? "text-white font-medium" + : "text-blue-100 hover:bg-blue-700/30" + )} > - {item.icon && getLucideIcon(item.icon)} - {item.title} - + {getLucideIcon(item.icon || "")} + {name} + )}
  • ); }; return ( -
    -
    -

    超级管理员

    +
    +
    + {!collapsed &&

    超级管理员

    } +
    diff --git a/SuperAdmin/components/projects/project-create.tsx b/SuperAdmin/components/projects/project-create.tsx new file mode 100644 index 00000000..85cb575d --- /dev/null +++ b/SuperAdmin/components/projects/project-create.tsx @@ -0,0 +1,244 @@ +"use client" + +import { useState } from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import * as z from "zod" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { toast } from "sonner" + +const formSchema = z.object({ + name: z.string().min(2, "项目名称至少需要2个字符"), + account: z.string().min(3, "账号至少需要3个字符"), + password: z.string().min(6, "密码至少需要6个字符"), + confirmPassword: z.string().min(6, "确认密码至少需要6个字符"), + phone: z.string().optional(), + realname: z.string().optional(), + nickname: z.string().optional(), + memo: z.string().optional(), +}).refine((data) => data.password === data.confirmPassword, { + message: "两次输入的密码不一致", + path: ["confirmPassword"], +}); + +interface ProjectCreateProps { + onSuccess?: () => void +} + +export default function ProjectCreate({ onSuccess }: ProjectCreateProps) { + const [isLoading, setIsLoading] = useState(false) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + account: "", + password: "", + confirmPassword: "", + phone: "", + realname: "", + nickname: "", + memo: "", + }, + }) + + const onSubmit = async (values: z.infer) => { + setIsLoading(true) + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/create`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: values.name, + account: values.account, + password: values.password, + phone: values.phone || null, + realname: values.realname || null, + nickname: values.nickname || null, + memo: values.memo || null, + }), + }) + + const data = await response.json() + + if (data.code === 200) { + toast.success("项目创建成功") + form.reset() + if (onSuccess) { + onSuccess() + } + } else { + toast.error(data.msg || "创建项目失败") + } + } catch (error) { + toast.error("网络错误,请稍后重试") + } finally { + setIsLoading(false) + } + } + + return ( + + + 新建项目 + 创建一个新的项目并设置基本信息 + + +
    + + ( + + 项目名称 + + + + + + )} + /> + +
    + ( + + 账号 + + + + + + )} + /> + ( + + 手机号 + + + + + + )} + /> +
    + +
    + ( + + 密码 + + + + + + )} + /> + ( + + 确认密码 + + + + + + )} + /> +
    + +
    + ( + + 真实姓名 + + + + + + )} + /> + ( + + 昵称 + + + + + + )} + /> +
    + + ( + + 项目描述 + +