超管后台 - 调整页面路由为动态路由请求,修改页面渲染为tabs标签形式

This commit is contained in:
柳清爽
2025-04-24 12:17:09 +08:00
parent f48c12f1ec
commit 4a5b2eaa1e
6 changed files with 1463 additions and 109 deletions

View File

@@ -1,14 +1,41 @@
"use client" "use client"
import type React from "react" import type React from "react"
import { useState, useEffect } from "react" import { useState, useEffect, createContext, useContext } from "react"
import { useRouter } from "next/navigation" import { useRouter, usePathname } from "next/navigation"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Menu, X } from "lucide-react" import { Menu, X } from "lucide-react"
import { Sidebar } from "@/components/layout/sidebar" import { Sidebar } from "@/components/layout/sidebar"
import { Header } from "@/components/layout/header" import { Header } from "@/components/layout/header"
import { getAdminInfo } from "@/lib/utils" import { getAdminInfo } from "@/lib/utils"
// 全局标签页管理上下文
interface TabData {
id: string
label: string
path: string
closable: boolean
}
interface TabContextType {
tabs: TabData[]
activeTab: string
addTab: (tab: Omit<TabData, "id">) => string
closeTab: (tabId: string) => void
setActiveTab: (tabId: string) => void
findTabByPath: (path: string) => TabData | undefined
}
const TabContext = createContext<TabContextType | undefined>(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({ export default function DashboardLayout({
children, children,
}: { }: {
@@ -16,6 +43,122 @@ export default function DashboardLayout({
}) { }) {
const [sidebarOpen, setSidebarOpen] = useState(true) const [sidebarOpen, setSidebarOpen] = useState(true)
const router = useRouter() const router = useRouter()
const pathname = usePathname()
// 标签页状态管理
const [tabs, setTabs] = useState<TabData[]>([
{ id: "dashboard", label: "仪表盘", path: "/dashboard", closable: false }
])
const [activeTab, setActiveTab] = useState("dashboard")
// 添加标签页
const addTab = (tabData: Omit<TabData, "id">) => {
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(() => { useEffect(() => {
@@ -31,29 +174,72 @@ export default function DashboardLayout({
}, [router]) }, [router])
return ( return (
<div className="flex h-screen overflow-hidden"> <TabContext.Provider value={{
{/* Mobile sidebar toggle */} tabs,
<div className="fixed top-4 left-4 z-50 md:hidden"> activeTab,
<Button variant="outline" size="icon" onClick={() => setSidebarOpen(!sidebarOpen)}> addTab,
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />} closeTab,
</Button> setActiveTab: setActiveTabAndNavigate,
</div> findTabByPath
}}>
<div className="flex h-screen overflow-hidden bg-background">
{/* Mobile sidebar toggle */}
<div className="fixed top-4 left-4 z-50 md:hidden">
<Button variant="outline" size="icon" onClick={() => setSidebarOpen(!sidebarOpen)}>
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
</div>
{/* Sidebar */} {/* Sidebar */}
<div <div
className={`bg-background border-r w-64 flex-shrink-0 transition-all duration-300 ease-in-out ${ className={`bg-background flex-shrink-0 transition-all duration-300 ease-in-out ${
sidebarOpen ? "translate-x-0" : "-translate-x-full" sidebarOpen ? "translate-x-0" : "-translate-x-full"
} md:translate-x-0 fixed md:relative z-40 h-full`} } md:translate-x-0 fixed md:relative z-40 h-full`}
> >
<Sidebar /> <Sidebar />
</div> </div>
{/* Main content */} {/* Main content */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<Header /> <Header />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
{/* 标签栏 */}
<div className="border-b border-border">
<div className="flex overflow-x-auto">
{tabs.map(tab => (
<div
key={tab.id}
className={`flex items-center px-4 py-2 border-r border-border cursor-pointer ${
activeTab === tab.id ? "bg-muted font-medium" : "hover:bg-muted/50"
}`}
onClick={() => {
setActiveTabAndNavigate(tab.id)
}}
>
<span className="truncate max-w-[200px]">{tab.label}</span>
{tab.closable && (
<button
className="ml-2 p-1 rounded-full hover:bg-muted-foreground/20"
onClick={(e) => {
e.stopPropagation()
closeTab(tab.id)
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
{/* 内容区域 */}
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div> </div>
</div> </TabContext.Provider>
) )
} }

View File

@@ -1,7 +1,6 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import Link from "next/link"
import { useSearchParams, useRouter, usePathname } from "next/navigation" import { useSearchParams, useRouter, usePathname } from "next/navigation"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -19,6 +18,7 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { PaginationControls } from "@/components/ui/pagination-controls" import { PaginationControls } from "@/components/ui/pagination-controls"
import { useTabContext } from "@/app/dashboard/layout"
interface Project { interface Project {
id: number id: number
@@ -36,6 +36,8 @@ export default function ProjectsPage() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
const { addTab } = useTabContext()
const [searchTerm, setSearchTerm] = useState("") const [searchTerm, setSearchTerm] = useState("")
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -136,6 +138,7 @@ export default function ProjectsPage() {
if (data.code === 200) { if (data.code === 200) {
toast.success("删除成功") toast.success("删除成功")
// Fetch projects again after delete // Fetch projects again after delete
const fetchProjects = async () => { const fetchProjects = async () => {
setIsLoading(true) 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between"> <div className="flex justify-between">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
<Button asChild> <Button onClick={handleNewProject}>
<Link href="/dashboard/projects/new"> <Plus className="mr-2 h-4 w-4" />
<Plus className="mr-2 h-4 w-4" />
</Link>
</Button> </Button>
</div> </div>
@@ -207,7 +235,7 @@ export default function ProjectsPage() {
<TableBody> <TableBody>
{isLoading ? ( {isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="h-24 text-center"> <TableCell colSpan={6} className="h-24 text-center">
... ...
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -232,15 +260,11 @@ export default function ProjectsPage() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem asChild> <DropdownMenuItem onClick={() => handleViewProject(project)}>
<Link href={`/dashboard/projects/${project.id}`}> <Eye className="mr-2 h-4 w-4" />
<Eye className="mr-2 h-4 w-4" />
</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem onClick={() => handleEditProject(project)}>
<Link href={`/dashboard/projects/${project.id}/edit`}> <Edit className="mr-2 h-4 w-4" />
<Edit className="mr-2 h-4 w-4" />
</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-red-600" className="text-red-600"
@@ -255,7 +279,7 @@ export default function ProjectsPage() {
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={5} className="h-24 text-center"> <TableCell colSpan={6} className="h-24 text-center">
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -1,37 +1,178 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useState, useEffect } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { getMenus, type MenuItem } from "@/lib/menu-api"
import * as LucideIcons from "lucide-react" 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() { export function Sidebar() {
const pathname = usePathname() const pathname = usePathname()
const [menus, setMenus] = useState<MenuItem[]>([]) const [menus, setMenus] = useState<MenuItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// 使用Set来存储已展开的菜单ID
const [expandedMenus, setExpandedMenus] = useState<Set<number>>(new Set()) const [expandedMenus, setExpandedMenus] = useState<Set<number>>(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(() => { useEffect(() => {
const fetchMenus = async () => { const fetchMenus = async () => {
setLoading(true) setLoading(true)
try { try {
const data = await getMenus() // 从后端API获取菜单数据
setMenus(data || []) const menuData = await getMenus(true);
// 自动展开当前活动菜单的父菜单 // 适配数据格式
autoExpandActiveMenuParent(data || []); const adaptedMenus = menuData.map(adaptMenuItem);
// 构建菜单树
const menuTree = buildMenuTree(adaptedMenus);
setMenus(menuTree);
// 初始自动展开当前活动菜单的父菜单
autoExpandActiveMenuParent(adaptedMenus);
} catch (error) { } catch (error) {
console.error("获取菜单失败:", error) console.error("获取菜单数据失败:", error);
// 获取失败时使用空菜单
setMenus([]);
} finally { } 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<number, MenuItem>();
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[]) => { const autoExpandActiveMenuParent = (menuItems: MenuItem[]) => {
@@ -40,10 +181,23 @@ export function Sidebar() {
// 递归查找当前路径匹配的菜单项 // 递归查找当前路径匹配的菜单项
const findActiveMenu = (items: MenuItem[], parentIds: number[] = []) => { const findActiveMenu = (items: MenuItem[], parentIds: number[] = []) => {
for (const item of items) { 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添加到展开集合 // 将所有父菜单ID添加到展开集合
parentIds.forEach(id => newExpandedMenus.add(id)); parentIds.forEach(id => newExpandedMenus.add(id));
return true; return true;
@@ -60,11 +214,16 @@ export function Sidebar() {
}; };
findActiveMenu(menuItems); findActiveMenu(menuItems);
// 将新的展开菜单集合设置到状态
setExpandedMenus(newExpandedMenus); setExpandedMenus(newExpandedMenus);
}; };
// 切换菜单展开状态 // 切换菜单展开状态
const toggleMenu = (menuId: number) => { const toggleMenu = (menuId: number, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setExpandedMenus(prev => { setExpandedMenus(prev => {
const newExpanded = new Set(prev); const newExpanded = new Set(prev);
if (newExpanded.has(menuId)) { if (newExpanded.has(menuId)) {
@@ -83,82 +242,207 @@ export function Sidebar() {
return Icon ? <Icon className="h-4 w-4 mr-2" /> : null; return Icon ? <Icon className="h-4 w-4 mr-2" /> : null;
}; };
// 递归渲染菜单项 // 渲染菜单项
const renderMenuItem = (item: MenuItem) => { 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 hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedMenus.has(item.id); const isExpanded = expandedMenus.has(item.id);
const isActive = pathname === item.path; const name = item.name || item.title || "";
const isChildActive = hasChildren && item.children!.some(child =>
pathname === child.path || pathname.startsWith(child.path + "/") // 折叠状态下的菜单项
); if (collapsed) {
return (
<li key={item.id} className="relative group">
<div
className={cn(
"flex justify-center items-center py-2 rounded-md transition-colors cursor-pointer",
isActive
? "text-white"
: "text-blue-100 hover:bg-blue-700/30"
)}
title={name}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!hasChildren) {
const tabId = addTab({
label: name,
path: item.path,
closable: item.path !== "/dashboard"
});
}
}}
>
{getLucideIcon(item.icon || "")}
{/* 悬浮提示 */}
{hasChildren ? (
<div className="absolute left-full ml-2 hidden group-hover:block z-50 bg-blue-800 rounded-md shadow-lg py-1 min-w-40">
<div className="font-medium px-3 py-1 border-b border-blue-700">{name}</div>
<ul className="py-1">
{item.children!.map((child) => (
<li key={child.id}>
<a
href={child.path}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const tabId = addTab({
label: child.name || child.title || "",
path: child.path,
closable: true
});
}}
className={cn(
"flex items-center px-3 py-1 transition-colors",
isMenuPathActive(child.path, currentPath)
? "bg-blue-700 text-white font-medium"
: "text-blue-100 hover:bg-blue-700/30"
)}
>
{getLucideIcon(child.icon || "")}
<span>{child.name || child.title}</span>
</a>
</li>
))}
</ul>
</div>
) : (
<div className="absolute left-full ml-2 hidden group-hover:block z-50 bg-blue-800 rounded-md shadow-lg px-3 py-1 whitespace-nowrap">
{name}
</div>
)}
</div>
</li>
);
}
// 展开状态下的菜单项
return ( return (
<li key={item.id}> <li key={item.id}>
{hasChildren ? ( {hasChildren ? (
<div className="flex flex-col"> <>
<button <button
onClick={() => toggleMenu(item.id)} onClick={(e) => toggleMenu(item.id, e)}
className={`flex items-center justify-between px-4 py-2 rounded-md text-sm w-full text-left ${ className={cn(
isActive || isChildActive "flex items-center w-full py-2 px-3 rounded-md transition-colors",
? "text-white font-semibold" isActive
: "hover:bg-blue-600" ? "text-white font-medium"
}`} : "text-blue-100 hover:bg-blue-700/30"
>
<div className="flex items-center">
{item.icon && getLucideIcon(item.icon)}
{item.title}
</div>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)} )}
>
{getLucideIcon(item.icon || "")}
<span>{name}</span>
<span className="ml-auto">
{isExpanded ? (
<LucideIcons.ChevronDown className="h-4 w-4" />
) : (
<LucideIcons.ChevronRight className="h-4 w-4" />
)}
</span>
</button> </button>
{isExpanded && hasChildren && ( {isExpanded && (
<ul className="ml-4 mt-1 space-y-1"> <ul className="ml-4 mt-1 space-y-1">
{item.children!.map(child => { {item.children!.map((child) => (
const isChildItemActive = pathname === child.path; <li key={child.id}>
return ( <a
<li key={child.id}> href={child.path}
<Link onClick={(e) => {
href={child.path} e.preventDefault();
className={`flex items-center px-4 py-2 rounded-md text-sm ${ e.stopPropagation();
isChildItemActive
? "text-white font-semibold bg-blue-700" // 使用addTab返回的标签ID在TabContext中处理确保标签被激活并导航
: "hover:bg-blue-600" const tabId = addTab({
}`} label: child.name || child.title || "",
> path: child.path,
{child.icon && getLucideIcon(child.icon)} closable: true
{child.title} });
</Link> }}
</li> className={cn(
); "flex items-center py-2 px-3 rounded-md transition-colors",
})} isMenuPathActive(child.path, currentPath)
? "bg-blue-700 text-white font-medium"
: "text-blue-100 hover:bg-blue-700/30"
)}
>
{getLucideIcon(child.icon || "")}
<span>{child.name || child.title}</span>
</a>
</li>
))}
</ul> </ul>
)} )}
</div> </>
) : ( ) : (
<Link <a
href={item.path} href={item.path}
className={`flex items-center px-4 py-2 rounded-md text-sm ${ onClick={(e) => {
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 isActive
? "text-white font-semibold" ? "text-white font-medium"
: "hover:bg-blue-600" : "text-blue-100 hover:bg-blue-700/30"
}`} )}
> >
{item.icon && getLucideIcon(item.icon)} {getLucideIcon(item.icon || "")}
{item.title} <span>{name}</span>
</Link> </a>
)} )}
</li> </li>
); );
}; };
return ( return (
<div className="w-64 border-r bg-[#2563eb] h-full flex flex-col text-white"> <div className={cn(
<div className="p-4 border-b border-blue-500"> "border-r bg-[#2563eb] h-full flex flex-col text-white transition-all duration-300 ease-in-out",
<h2 className="text-lg font-bold"></h2> collapsed ? "w-16" : "w-64"
)}>
<div className={cn(
"border-b border-blue-500 flex items-center justify-between",
collapsed ? "p-2" : "p-4"
)}>
{!collapsed && <h2 className="text-lg font-bold"></h2>}
<button
onClick={toggleCollapsed}
className="p-1 rounded-md hover:bg-blue-700 transition-colors"
title={collapsed ? "展开菜单" : "折叠菜单"}
>
{collapsed ? (
<LucideIcons.ChevronRight className="h-5 w-5" />
) : (
<LucideIcons.ChevronLeft className="h-5 w-5" />
)}
</button>
</div> </div>
<nav className="flex-1 overflow-auto p-2"> <nav className="flex-1 overflow-auto p-2">
@@ -176,8 +460,11 @@ export function Sidebar() {
</ul> </ul>
) : ( ) : (
// 无菜单数据 // 无菜单数据
<div className="text-center py-8 text-blue-200"> <div className={cn(
<p></p> "text-center py-8 text-blue-200",
collapsed && "text-xs px-0"
)}>
<p>{collapsed ? "无菜单" : "暂无菜单数据"}</p>
</div> </div>
)} )}
</nav> </nav>

View File

@@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
account: "",
password: "",
confirmPassword: "",
phone: "",
realname: "",
nickname: "",
memo: "",
},
})
const onSubmit = async (values: z.infer<typeof formSchema>) => {
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 (
<Card className="w-full">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form id="create-project-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入项目名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="account"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入账号" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入手机号" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="请输入密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="请再次输入密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="realname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入真实姓名(可选)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入昵称(可选)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="memo"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea placeholder="请输入项目描述(可选)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={() => form.reset()}
disabled={isLoading}
>
</Button>
<Button
type="submit"
form="create-project-form"
disabled={isLoading}
>
{isLoading ? "创建中..." : "创建项目"}
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,349 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { ArrowLeft, Edit } from "lucide-react"
import { toast } from "sonner"
import Image from "next/image"
import { Badge } from "@/components/ui/badge"
interface ProjectProfile {
id: number
name: string
memo: string
companyId: number
createTime: string
account: string
phone: string | null
deviceCount: number
friendCount: number
userCount: number
}
interface Device {
id: number
memo: string
phone: string
model: string
brand: string
alive: number
deviceId: number
wechatId: string
friendCount: number
wAlive: number
imei: string
}
interface SubUser {
id: number
account: string
username: string
phone: string
avatar: string
status: number
createTime: string
typeId: number
}
interface ProjectDetailProps {
projectId: string
onEdit?: (projectId: string) => void
}
export default function ProjectDetail({ projectId, onEdit }: ProjectDetailProps) {
const [isLoading, setIsLoading] = useState(true)
const [isDevicesLoading, setIsDevicesLoading] = useState(false)
const [isSubUsersLoading, setIsSubUsersLoading] = useState(false)
const [profile, setProfile] = useState<ProjectProfile | null>(null)
const [devices, setDevices] = useState<Device[]>([])
const [subUsers, setSubUsers] = useState<SubUser[]>([])
const [activeTab, setActiveTab] = useState("overview")
useEffect(() => {
const fetchProjectProfile = async () => {
setIsLoading(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/profile/${projectId}`)
const data = await response.json()
if (data.code === 200) {
setProfile(data.data)
} else {
toast.error(data.msg || "获取项目信息失败")
}
} catch (error) {
toast.error("网络错误,请稍后重试")
} finally {
setIsLoading(false)
}
}
fetchProjectProfile()
}, [projectId])
useEffect(() => {
const fetchDevices = async () => {
if (activeTab === "devices") {
setIsDevicesLoading(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/devices?companyId=${projectId}`)
const data = await response.json()
if (data.code === 200) {
setDevices(data.data)
} else {
toast.error(data.msg || "获取设备列表失败")
}
} catch (error) {
toast.error("网络错误,请稍后重试")
} finally {
setIsDevicesLoading(false)
}
}
}
fetchDevices()
}, [activeTab, projectId])
useEffect(() => {
const fetchSubUsers = async () => {
if (activeTab === "accounts") {
setIsSubUsersLoading(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/subusers?companyId=${projectId}`)
const data = await response.json()
if (data.code === 200) {
setSubUsers(data.data)
} else {
toast.error(data.msg || "获取子账号列表失败")
}
} catch (error) {
toast.error("网络错误,请稍后重试")
} finally {
setIsSubUsersLoading(false)
}
}
}
fetchSubUsers()
}, [activeTab, projectId])
if (isLoading) {
return <div className="flex items-center justify-center min-h-64">...</div>
}
if (!profile) {
return <div className="flex items-center justify-center min-h-64"></div>
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{profile.name}</h1>
{onEdit && (
<Button onClick={() => onEdit(projectId)}>
<Edit className="mr-2 h-4 w-4" />
</Button>
)}
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="devices"></TabsTrigger>
<TabsTrigger value="accounts"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm">{profile.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm">{profile.phone || "未设置"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm">{profile.account}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm">{profile.createTime}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm">{profile.memo}</dd>
</div>
</dl>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{profile.deviceCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{profile.userCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{profile.friendCount}</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="devices">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{isDevicesLoading ? (
<div className="flex items-center justify-center py-8">...</div>
) : devices.length === 0 ? (
<div className="flex items-center justify-center py-8 text-muted-foreground"></div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>IMEI</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices.map((device) => (
<TableRow key={device.id}>
<TableCell className="font-medium">{device.memo}</TableCell>
<TableCell>{device.model}</TableCell>
<TableCell>{device.brand}</TableCell>
<TableCell>{device.imei}</TableCell>
<TableCell>
<Badge variant={device.alive === 1 ? "success" : "destructive"}>
{device.alive === 1 ? "在线" : "离线"}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
device.wAlive === 1
? "success"
: device.wAlive === 0
? "destructive"
: "secondary"
}
>
{device.wAlive === 1 ? "已登录" : device.wAlive === 0 ? "已登出" : "未登录微信"}
</Badge>
</TableCell>
<TableCell className="text-right">{device.friendCount || 0}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 text-sm text-muted-foreground">
{devices.length}
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="accounts">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{isSubUsersLoading ? (
<div className="flex items-center justify-center py-8">...</div>
) : subUsers.length === 0 ? (
<div className="flex items-center justify-center py-8 text-muted-foreground"></div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subUsers.map((user) => (
<TableRow key={user.id}>
<TableCell>
<Image
src={user.avatar}
alt={user.username}
width={32}
height={32}
className="rounded-full"
/>
</TableCell>
<TableCell>{user.id}</TableCell>
<TableCell>{user.account}</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.phone}</TableCell>
<TableCell>
<Badge variant={user.status === 1 ? "success" : "destructive"}>
{user.status === 1 ? "启用" : "禁用"}
</Badge>
</TableCell>
<TableCell>{user.typeId === 1 ? "操盘手" : "门店顾问"}</TableCell>
<TableCell className="text-right">{user.createTime}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 text-sm text-muted-foreground">
{subUsers.length}
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,264 @@
"use client"
import { useState, useEffect } 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().optional(),
confirmPassword: z.string().optional(),
phone: z.string().optional(),
memo: z.string().optional(),
})
interface ProjectEditProps {
projectId: string
onSuccess?: () => void
}
interface ProjectData {
id: number
name: string
account: string
memo?: string
phone?: string
}
export default function ProjectEdit({ projectId, onSuccess }: ProjectEditProps) {
const [isLoading, setIsLoading] = useState(false)
const [isFetching, setIsFetching] = useState(true)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
account: "",
password: "",
confirmPassword: "",
phone: "",
memo: "",
},
})
// 获取项目数据
useEffect(() => {
const fetchProject = async () => {
setIsFetching(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/profile/${projectId}`)
const data = await response.json()
if (data.code === 200) {
const project = data.data
form.reset({
name: project.name || "",
account: project.account || "",
password: "",
confirmPassword: "",
phone: project.phone || "",
memo: project.memo || "",
})
} else {
toast.error(data.msg || "获取项目信息失败")
}
} catch (error) {
toast.error("网络错误,请稍后重试")
} finally {
setIsFetching(false)
}
}
fetchProject()
}, [projectId, form])
const onSubmit = async (values: z.infer<typeof formSchema>) => {
// 检查密码是否匹配
if (values.password && values.password !== values.confirmPassword) {
toast.error("两次输入的密码不一致")
return
}
setIsLoading(true)
try {
// 准备请求数据,根据需要添加或移除字段
const updateData: Record<string, any> = {
id: parseInt(projectId),
name: values.name,
account: values.account,
memo: values.memo,
phone: values.phone,
}
// 如果提供了密码,则包含密码字段
if (values.password) {
updateData.password = values.password
}
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/update`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updateData),
})
const data = await response.json()
if (data.code === 200) {
toast.success("项目更新成功")
if (onSuccess) {
onSuccess()
}
} else {
toast.error(data.msg || "更新项目失败")
}
} catch (error) {
toast.error("网络错误,请稍后重试")
} finally {
setIsLoading(false)
}
}
if (isFetching) {
return <div className="flex items-center justify-center min-h-64">...</div>
}
return (
<Card className="w-full">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form id="edit-project-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入项目名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="account"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入账号" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入手机号" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="不修改请留空" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="请再次输入密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="memo"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea placeholder="请输入项目描述" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={() => form.reset()}
disabled={isLoading}
>
</Button>
<Button
type="submit"
form="edit-project-form"
disabled={isLoading}
>
{isLoading ? "保存中..." : "保存更改"}
</Button>
</CardFooter>
</Card>
)
}