子仓库提交
This commit is contained in:
16
src/components/layouts/Main.tsx
Normal file
16
src/components/layouts/Main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import {Navbar1} from '@/components/layouts/Navbar.tsx'
|
||||
import Page from './SideBar/SideBar'
|
||||
import './index.css'
|
||||
function Main() {
|
||||
return (
|
||||
<div className = "parent-flex">
|
||||
<Navbar1></Navbar1>
|
||||
<div>
|
||||
<Page ></Page>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Main
|
||||
352
src/components/layouts/Navbar.tsx
Normal file
352
src/components/layouts/Navbar.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
'use client';
|
||||
|
||||
import { Book, Menu, Sunset, Trees, Zap } from "lucide-react";
|
||||
import { Sprout, Map, Clipboard, Package, Brain, Droplets, Settings } from 'lucide-react';
|
||||
import { MessageBell } from './components/MessageBell';
|
||||
import { UserProfile } from './components/UserProfile';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { useElementHeight } from '@/hooks/useElementHeight';
|
||||
import { useViewHeight } from '@/hooks/useViewHeight';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
// 注释掉 Accordion 相关导入,因为不再需要二级菜单
|
||||
// import {
|
||||
// Accordion,
|
||||
// AccordionContent,
|
||||
// AccordionItem,
|
||||
// AccordionTrigger,
|
||||
// } from "@/components/ui/accordion";
|
||||
import { useLayoutStore } from '@/stores/useLayoutStore';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
interface MenuItem {
|
||||
title: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
items?: MenuItem[];
|
||||
}
|
||||
|
||||
interface Navbar1Props {
|
||||
navbarData: {
|
||||
logo: {
|
||||
url: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
title: string;
|
||||
};
|
||||
menu: MenuItem[];
|
||||
auth: {
|
||||
login: {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
signup: {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
const Navbar1 = ({ navbarData }: Navbar1Props) => {
|
||||
const logo = navbarData.logo
|
||||
const menu = navbarData.menu
|
||||
const auth = navbarData.auth
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const containerStyle = {
|
||||
maxWidth:"100%",marginLeft:"0px",marginRight:"0px",paddingLeft:"1rem",paddingRight:"0rem"
|
||||
}
|
||||
|
||||
// 检查当前路径是否匹配菜单项
|
||||
const isMenuActive = (url: string) => {
|
||||
// 精确匹配
|
||||
if (pathname === url) return true;
|
||||
// 检查是否是该菜单下的子路径
|
||||
if (pathname.startsWith(url + '/')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 使用自定义 Hook 计算高度
|
||||
const { elementRef, updateHeight } = useElementHeight({
|
||||
immediate: true, // 立即计算高度
|
||||
onUpdate: (height: number) => {
|
||||
// 更新 Zustand store 中的状态
|
||||
const { setNavigatorHeight } = useLayoutStore.getState();
|
||||
setNavigatorHeight(height);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 监听页面高度变化
|
||||
useViewHeight();
|
||||
|
||||
const handleMessageClick = () => {
|
||||
// 处理消息点击事件,可以跳转到消息中心页面
|
||||
console.log('Navigate to message center');
|
||||
};
|
||||
|
||||
const handleProfileClick = () => {
|
||||
// 处理个人中心点击事件
|
||||
console.log('Navigate to profile page');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-4" ref={elementRef}>
|
||||
<div className="container" style = {containerStyle}>
|
||||
{/* Desktop Menu */}
|
||||
<nav className="hidden justify-between lg:flex">
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Logo */}
|
||||
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center transition-colors">
|
||||
<Sprout className="w-6 h-6 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-primary transition-colors" style={{ color: 'var(--primary)' }}>智慧农业生产管理系统</h1>
|
||||
<p className="text-xs text-muted-foreground">Smart Agriculture Management System</p>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<div className="flex items-center gap-1 overflow-x-auto flex-1 min-w-0" style={{ maxWidth: '70vw' }}>
|
||||
<style jsx>{`
|
||||
div::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
div::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
div::-webkit-scrollbar-thumb {
|
||||
background-color: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
div::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
div {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d1d5db transparent;
|
||||
}
|
||||
.navigation-menu-container {
|
||||
max-width: 70vw;
|
||||
}
|
||||
`}</style>
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList className="flex gap-1 min-w-max">
|
||||
{menu.map((item) => renderMenuItem(item, isMenuActive, router))}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2" style = {{alignItems:"center"}}>
|
||||
<ThemeToggle />
|
||||
<MessageBell onMessageClick={handleMessageClick} />
|
||||
<UserProfile onProfileClick={handleProfileClick} />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div className="block lg:hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<a href={logo.url} className="flex items-center gap-2">
|
||||
<img
|
||||
src={logo.src}
|
||||
className="max-h-8 dark:invert"
|
||||
alt={logo.alt}
|
||||
/>
|
||||
</a>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Menu className="size-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<a href={logo.url} className="flex items-center gap-2">
|
||||
<img
|
||||
src={logo.src}
|
||||
className="max-h-8 dark:invert"
|
||||
alt={logo.alt}
|
||||
/>
|
||||
</a>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
{/* 简化移动端菜单,不再使用 Accordion */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{menu.map((item) => renderMobileMenuItem(item, isMenuActive, router))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex justify-center">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<MessageBell onMessageClick={handleMessageClick} />
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<UserProfile onProfileClick={handleProfileClick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMenuItem = (item: MenuItem, isMenuActive: (url: string) => boolean, router: any) => {
|
||||
// 注释掉二级菜单相关代码,项目不需要二级菜单
|
||||
// if (item.items) {
|
||||
// return (
|
||||
// <NavigationMenuItem key={item.title}>
|
||||
// <NavigationMenuTrigger className="whitespace-nowrap">{item.title}</NavigationMenuTrigger>
|
||||
// <NavigationMenuContent className="bg-popover text-popover-foreground">
|
||||
// {item.items.map((subItem) => (
|
||||
//
|
||||
// <NavigationMenuLink asChild key={subItem.title} className="w-80">
|
||||
// <SubMenuLink item={subItem} />
|
||||
// </NavigationMenuLink>
|
||||
// ))}
|
||||
// </NavigationMenuContent>
|
||||
// </NavigationMenuItem>
|
||||
// );
|
||||
// }
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
router.push(item.url);
|
||||
};
|
||||
|
||||
return (
|
||||
<NavigationMenuItem key={item.title}>
|
||||
<NavigationMenuLink
|
||||
href={item.url}
|
||||
onClick={handleClick}
|
||||
data-menu-item="true"
|
||||
data-menu-url={item.url}
|
||||
className={`
|
||||
inline-flex h-10 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium gap-2 whitespace-nowrap relative cursor-pointer
|
||||
${isMenuActive(item.url)
|
||||
? 'bg-primary/10'
|
||||
: 'bg-background hover:bg-muted hover:text-accent-foreground'
|
||||
}
|
||||
[&:not([data-active])]:text-foreground
|
||||
`}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className={`
|
||||
shrink-0
|
||||
${isMenuActive(item.url)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
hover:text-primary
|
||||
[&.group-data-[state=open]]:text-muted-foreground
|
||||
`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className={isMenuActive(item.url) ? 'text-primary' : ''}>
|
||||
{item.title}
|
||||
</span>
|
||||
{isMenuActive(item.url) &&<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-600 dark:bg-green-400 rounded-full"></div>}
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMobileMenuItem = (item: MenuItem, isMenuActive: (url: string) => boolean, router: any) => {
|
||||
// 注释掉移动端二级菜单相关代码
|
||||
// if (item.items) {
|
||||
// return (
|
||||
// <AccordionItem key={item.title} value={item.title} className="border-b-0">
|
||||
// <AccordionTrigger className="text-md py-0 font-semibold hover:no-underline gap-2">
|
||||
// {item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||
// {item.title}
|
||||
// </AccordionTrigger>
|
||||
// <AccordionContent className="mt-2">
|
||||
// {item.items.map((subItem) => (
|
||||
// <SubMenuLink key={subItem.title} item={subItem} />
|
||||
// ))}
|
||||
// </AccordionContent>
|
||||
// </AccordionItem>
|
||||
// );
|
||||
// }
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
router.push(item.url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.title}
|
||||
onClick={handleClick}
|
||||
className={`
|
||||
text-md font-semibold flex items-center gap-2 p-2 rounded-md transition-colors cursor-pointer
|
||||
${isMenuActive(item.url)
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted hover:text-accent-foreground'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className={`
|
||||
shrink-0
|
||||
${isMenuActive(item.url) ? 'text-primary' : 'text-muted-foreground'}
|
||||
`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className={isMenuActive(item.url) ? 'text-primary' : ''}>
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 注释掉 SubMenuLink 组件,因为不再需要二级菜单
|
||||
// const SubMenuLink = ({ item }: { item: MenuItem }) => {
|
||||
// return (
|
||||
// <a
|
||||
// className="hover:bg-muted hover:text-accent-foreground flex min-w-80 select-none flex-row gap-4 rounded-md p-3 leading-none no-underline outline-none transition-colors"
|
||||
// href={item.url}
|
||||
// >
|
||||
// <div className="text-foreground">{item.icon}</div>
|
||||
// <div>
|
||||
// <div className="text-sm font-semibold">{item.title}</div>
|
||||
// {item.description && (
|
||||
// <p className="text-muted-foreground text-sm leading-snug">
|
||||
// {item.description}
|
||||
// </p>
|
||||
// )}
|
||||
// </div>
|
||||
// </a>
|
||||
// );
|
||||
// };
|
||||
|
||||
export { Navbar1 };
|
||||
113
src/components/layouts/SideBar/SideBar.tsx
Normal file
113
src/components/layouts/SideBar/SideBar.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { AppSidebar, AppSidebarProps } from "@/components/app-sidebar"
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar"
|
||||
import React from 'react'
|
||||
export interface SideBarProps {
|
||||
children: ReactNode
|
||||
data?: AppSidebarProps['data']
|
||||
}
|
||||
|
||||
export default function SideBar({ children, data }: SideBarProps) {
|
||||
const pathname = usePathname()
|
||||
const [breadcrumbItems, setBreadcrumbItems] = useState<Array<{ title: string, url?: string, isPage?: boolean }>>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !data.navMain) return
|
||||
|
||||
const generateBreadcrumb = () => {
|
||||
const items = [{ title: "首页", url: "/" }]
|
||||
|
||||
// 解析当前路径
|
||||
const pathSegments = pathname.split('/').filter(Boolean)
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
// 首页
|
||||
items.push({ title: "首页", isPage: true })
|
||||
} else if (pathSegments[0] === 'central-config') {
|
||||
// 中心配置模块
|
||||
items.push({ title: "中心配置", url: "/central-config" })
|
||||
|
||||
if (pathSegments.length === 1) {
|
||||
// 中心配置主页
|
||||
items.push({ title: "中心配置", isPage: true })
|
||||
} else if (pathSegments.length >= 2) {
|
||||
// 查找匹配的一级菜单
|
||||
const mainModule = data.navMain.find(item =>
|
||||
item.url === `/central-config/${pathSegments[1]}`
|
||||
)
|
||||
|
||||
if (mainModule) {
|
||||
items.push({ title: mainModule.title, url: mainModule.url })
|
||||
|
||||
if (pathSegments.length === 2) {
|
||||
// 一级菜单页面
|
||||
items.push({ title: mainModule.title, isPage: true })
|
||||
} else if (pathSegments.length >= 3) {
|
||||
// 查找匹配的二级菜单
|
||||
const subModule = mainModule.items.find(item =>
|
||||
item.url === pathname
|
||||
)
|
||||
|
||||
if (subModule) {
|
||||
items.push({ title: subModule.title, isPage: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBreadcrumbItems(items)
|
||||
}
|
||||
|
||||
generateBreadcrumb()
|
||||
}, [pathname, data])
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar data={data} />
|
||||
<SidebarInset>
|
||||
<header className="flex sticky top-0 bg-background h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{breadcrumbItems.map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && <BreadcrumbSeparator className="hidden md:block" />}
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
{item.isPage ? (
|
||||
<BreadcrumbPage>{item.title}</BreadcrumbPage>
|
||||
) : item.url ? (
|
||||
<BreadcrumbLink href={item.url}>{item.title}</BreadcrumbLink>
|
||||
) : (
|
||||
<span>{item.title}</span>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
267
src/components/layouts/SideBar/SideBarOld.tsx
Normal file
267
src/components/layouts/SideBar/SideBarOld.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { LeftSidebar } from './components/LeftSidebar';
|
||||
import { MainContent } from './components/MainContent';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// 菜单项数据结构定义
|
||||
interface NavItem {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
isActive?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface SideBarData {
|
||||
navMain: NavItem[];
|
||||
}
|
||||
|
||||
|
||||
interface SideBarOldProps {
|
||||
children: React.ReactNode;
|
||||
activePath?: string;
|
||||
onNavigate?: (path: string) => void;
|
||||
data?: SideBarData;
|
||||
}
|
||||
|
||||
const defaultSideBarData: SideBarData = {
|
||||
navMain: [
|
||||
{
|
||||
title: "租户管理",
|
||||
url: "/central-config/tenant",
|
||||
icon: "🏢",
|
||||
items: [
|
||||
{
|
||||
title: "企业审核",
|
||||
url: "/central-config/tenant/enterprise-audit",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "审核历史",
|
||||
url: "/central-config/tenant/audit-history",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "企业信息",
|
||||
url: "/central-config/tenant/enterprise-info",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "用户管理",
|
||||
url: "/central-config/tenant/user-management",
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "用户管理",
|
||||
url: "/central-config/user",
|
||||
icon: "👥",
|
||||
items: [
|
||||
{
|
||||
title: "员工管理",
|
||||
url: "/central-config/user/employee",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "角色管理",
|
||||
url: "/central-config/user/role",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "菜单管理",
|
||||
url: "/central-config/user/menu",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "权限配置管理",
|
||||
url: "/central-config/user/permission",
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "系统参数",
|
||||
url: "/central-config/system",
|
||||
icon: "🔧",
|
||||
items: [
|
||||
{
|
||||
title: "系统设置",
|
||||
url: "/central-config/system/settings",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "分类字典",
|
||||
url: "/central-config/system/category",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "数据字典",
|
||||
url: "/central-config/system/dictionary",
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "系统监控",
|
||||
url: "/central-config/monitor",
|
||||
icon: "📈",
|
||||
items: [
|
||||
{
|
||||
title: "登录日志",
|
||||
url: "/central-config/monitor/login-log",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "操作日志",
|
||||
url: "/central-config/monitor/operation-log",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "性能监控",
|
||||
url: "/central-config/monitor/performance",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "网络日志",
|
||||
url: "/central-config/monitor/network-log",
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "消息中心",
|
||||
url: "/central-config/message",
|
||||
icon: "📨",
|
||||
items: [
|
||||
{
|
||||
title: "消息发送",
|
||||
url: "/central-config/message/send",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "消息模版",
|
||||
url: "/central-config/message/template",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "消息日志",
|
||||
url: "/central-config/message/log",
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
export function SideBarOld({
|
||||
children,
|
||||
activePath,
|
||||
onNavigate,
|
||||
data
|
||||
}: SideBarOldProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState(pathname || activePath || '/machinery/list');
|
||||
|
||||
// 使用传入的数据或默认数据
|
||||
const sidebarData = data || defaultSideBarData;
|
||||
|
||||
// 转换为 MenuItem 格式以兼容 LeftSidebar 组件
|
||||
const menus = sidebarData.navMain.map(item => ({
|
||||
id: item.url.replace(/\/[^\/]+/g, '').replace('/', '') || item.title.replace(/\s+/g, '-').toLowerCase(),
|
||||
label: item.title,
|
||||
icon: item.icon,
|
||||
children: item.items?.map(child => ({
|
||||
id: child.url.split('/').pop() || child.title.replace(/\s+/g, '-').toLowerCase(),
|
||||
label: child.title,
|
||||
path: child.url,
|
||||
})),
|
||||
}));
|
||||
|
||||
// 检测是否为移动设备
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkIsMobile();
|
||||
window.addEventListener('resize', checkIsMobile);
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
}, []);
|
||||
|
||||
// 监听路由变化,同步当前路径
|
||||
useEffect(() => {
|
||||
if (pathname) {
|
||||
setCurrentPath(pathname);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
// 移动端时自动展开侧边栏
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
setCurrentPath(path);
|
||||
onNavigate?.(path);
|
||||
// 使用 Next.js 标准路由跳转
|
||||
router.push(path);
|
||||
};
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex h-full bg-background",
|
||||
"min-h-0" // 确保父容器有正确的高度约束
|
||||
)}>
|
||||
{/* 左侧导航栏 - 独立滚动 */}
|
||||
{!isMobile && (
|
||||
<div className="sidebarScroll">
|
||||
<LeftSidebar
|
||||
menus={menus}
|
||||
activePath={currentPath}
|
||||
onNavigate={handleNavigate}
|
||||
isMobile={isMobile}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右侧主内容 - 独立滚动 */}
|
||||
<div className="flex-1 contentScroll">
|
||||
<MainContent
|
||||
isMobile={isMobile}
|
||||
sidebarOpen={!isCollapsed}
|
||||
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{children}
|
||||
</MainContent>
|
||||
</div>
|
||||
|
||||
{/* 移动端侧边栏 */}
|
||||
{isMobile && (
|
||||
<div className="fixed inset-y-0 left-0 z-50 w-64 sidebarScroll">
|
||||
<LeftSidebar
|
||||
menus={menus}
|
||||
activePath={currentPath}
|
||||
onNavigate={handleNavigate}
|
||||
isMobile={isMobile}
|
||||
isCollapsed={false}
|
||||
onToggleCollapse={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
src/components/layouts/SideBar/components/LeftSidebar.tsx
Normal file
232
src/components/layouts/SideBar/components/LeftSidebar.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Menu, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
children?: {
|
||||
id: string;
|
||||
label: string;
|
||||
path?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface LeftSidebarProps {
|
||||
menus: MenuItem[];
|
||||
activePath: string;
|
||||
onNavigate: (path: string) => void;
|
||||
isMobile?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function LeftSidebar({
|
||||
menus,
|
||||
activePath,
|
||||
onNavigate,
|
||||
isMobile = false,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse
|
||||
}: LeftSidebarProps) {
|
||||
// 初始状态下所有菜单都折叠
|
||||
const getInitialExpandedMenus = () => {
|
||||
return new Set<string>();
|
||||
};
|
||||
|
||||
// 检查菜单是否有子菜单被选中或是否应该高亮
|
||||
const isMenuActive = (menu: MenuItem) => {
|
||||
// 检查是否有子菜单被选中
|
||||
if (menu.children?.some(child => child.path === activePath)) {
|
||||
return true;
|
||||
}
|
||||
// 如果当前路径匹配菜单的URL(无子菜单的情况)
|
||||
if (activePath && activePath.includes(menu.id)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(getInitialExpandedMenus());
|
||||
|
||||
// 不自动展开菜单,由用户手动控制
|
||||
|
||||
// 当侧边栏状态改变时,折叠所有菜单
|
||||
useEffect(() => {
|
||||
setExpandedMenus(new Set());
|
||||
}, [isCollapsed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background border-r transition-all duration-300 flex flex-col h-full",
|
||||
isMobile ? "fixed inset-y-0 left-0 z-50" : "relative",
|
||||
isCollapsed ? "w-16" : "w-64"
|
||||
)}
|
||||
>
|
||||
{/* 头部 - 缩小高度 */}
|
||||
<div className="px-2 py-1 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={cn(
|
||||
"font-medium text-sm transition-all duration-300",
|
||||
isCollapsed ? "hidden" : "block"
|
||||
)}>
|
||||
导航菜单
|
||||
</h2>
|
||||
{isMobile ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
/* 根据侧边栏状态显示不同按钮 */
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleCollapse}
|
||||
className="h-8 w-8"
|
||||
title={isCollapsed ? "展开菜单" : "收起菜单"}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<div className={cn(
|
||||
"flex-1 overflow-y-auto p-4",
|
||||
isCollapsed ? "p-2" : "p-4"
|
||||
)}>
|
||||
<nav className="space-y-2">
|
||||
{menus.map((menu) => (
|
||||
<div key={menu.id}>
|
||||
{/* 一级菜单 */}
|
||||
{menu.children ? (
|
||||
<Collapsible
|
||||
open={expandedMenus.has(menu.id)}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setExpandedMenus(prev => new Set(prev).add(menu.id));
|
||||
} else {
|
||||
setExpandedMenus(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(menu.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-between text-sm font-normal group",
|
||||
isCollapsed ? "justify-center px-2 py-3" : "px-3 py-2",
|
||||
isMenuActive(menu) && "text-primary"
|
||||
)}
|
||||
title={isCollapsed ? menu.label : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{menu.icon && (
|
||||
<span className={cn(
|
||||
"flex-shrink-0",
|
||||
isMenuActive(menu) ? "text-primary" : "text-muted-foreground group-hover:text-primary"
|
||||
)}>
|
||||
{menu.icon}
|
||||
</span>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<span>{menu.label}</span>
|
||||
)}
|
||||
</div>
|
||||
{menu.children && (
|
||||
isCollapsed ? null : (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 transition-transform duration-200",
|
||||
expandedMenus.has(menu.id) && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{/* 二级菜单 */}
|
||||
{!isCollapsed && (
|
||||
<div className="ml-4 mt-1 space-y-1">
|
||||
{menu.children.map((child) => (
|
||||
<Button
|
||||
key={child.id}
|
||||
variant={activePath === child.path ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start text-xs font-normal h-8",
|
||||
activePath === child.path
|
||||
? "bg-primary/10 text-primary font-medium hover:bg-primary/10"
|
||||
: "hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
onClick={() => child.path && onNavigate(child.path)}
|
||||
>
|
||||
{child.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start text-sm font-normal group",
|
||||
isCollapsed ? "justify-center px-2 py-3" : "px-3 py-2",
|
||||
isMenuActive(menu) && "text-primary"
|
||||
)}
|
||||
title={isCollapsed ? menu.label : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{menu.icon && (
|
||||
<span className={cn(
|
||||
"flex-shrink-0",
|
||||
isMenuActive(menu) ? "text-primary" : "text-muted-foreground group-hover:text-primary"
|
||||
)}>
|
||||
{menu.icon}
|
||||
</span>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<span>{menu.label}</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="p-4 border-t">
|
||||
<div className={cn(
|
||||
"text-xs text-muted-foreground",
|
||||
isCollapsed ? "text-center" : "text-left"
|
||||
)}>
|
||||
{isCollapsed ? "管理" : "管理系统"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/components/layouts/SideBar/components/MainContent.tsx
Normal file
71
src/components/layouts/SideBar/components/MainContent.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
|
||||
interface MainContentProps {
|
||||
children: React.ReactNode;
|
||||
isMobile?: boolean;
|
||||
sidebarOpen?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
}
|
||||
|
||||
export function MainContent({
|
||||
children,
|
||||
isMobile = false,
|
||||
sidebarOpen = false,
|
||||
onToggleSidebar,
|
||||
}: MainContentProps) {
|
||||
const [showMobileSidebar, setShowMobileSidebar] = useState(false);
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
if (isMobile) {
|
||||
setShowMobileSidebar(!showMobileSidebar);
|
||||
} else {
|
||||
onToggleSidebar?.();
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{/* 移动端菜单按钮 */}
|
||||
<div className="flex items-center justify-between p-4 border-b bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowMobileSidebar(true)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 移动端侧边栏 */}
|
||||
<Sheet open={showMobileSidebar} onOpenChange={setShowMobileSidebar}>
|
||||
<SheetContent side="left" className="p-0 w-64">
|
||||
{/* 这里应该渲染LeftSidebar内容,但需要通过props传递 */}
|
||||
<div className="p-4">
|
||||
<p className="text-muted-foreground">侧边栏内容</p>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className="flex-1 p-6 bg-background">
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-background h-full">
|
||||
<div className="flex-1 p-6 min-h-0 content-container relative">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/layouts/ThemeToggle.tsx
Normal file
50
src/components/layouts/ThemeToggle.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Button } from '../ui/button';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 避免服务端渲染和客户端渲染不匹配
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
// 在组件挂载前不渲染,避免闪烁
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled
|
||||
className="transition-colors h-10 w-10"
|
||||
>
|
||||
<Sun className="size-5" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'light' ? '切换到深色模式' : '切换到浅色模式'}
|
||||
className="transition-colors h-10 w-10"
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Moon className="size-5" />
|
||||
) : (
|
||||
<Sun className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
282
src/components/layouts/components/MessageBell.tsx
Normal file
282
src/components/layouts/components/MessageBell.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Bell, CheckCircle, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
fullContent?: string;
|
||||
time: string;
|
||||
read: boolean;
|
||||
type?: 'info' | 'warning' | 'success' | 'error';
|
||||
}
|
||||
|
||||
interface MessageBellProps {
|
||||
onMessageClick?: () => void;
|
||||
}
|
||||
|
||||
export function MessageBell({ onMessageClick }: MessageBellProps) {
|
||||
const [showMessages, setShowMessages] = useState(false);
|
||||
const [showMessageDetail, setShowMessageDetail] = useState(false);
|
||||
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
title: '系统维护通知',
|
||||
content: '系统将于今晚22:00进行维护升级',
|
||||
fullContent: '尊敬的用户:\n\n为了提升系统性能和用户体验,我们将于今晚22:00-23:00进行系统维护升级。\n\n维护期间,系统将暂时无法访问。维护完成后,系统将自动恢复正常。\n\n维护内容:\n1. 数据库性能优化\n2. 新功能上线\n3. 安全补丁更新\n\n给您带来的不便,敬请谅解。\n\n智慧农业管理系统',
|
||||
time: '10分钟前',
|
||||
read: false,
|
||||
type: 'info',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '作业任务提醒',
|
||||
content: '小麦播种作业任务已分配',
|
||||
fullContent: '您好!\n\n新的作业任务已分配给您:\n\n任务名称:小麦播种作业\n作业地块:1号地块(东区)\n作业面积:50亩\n计划时间:2024年10月15日 08:00\n负责驾驶员:张三\n使用设备:约翰迪尔拖拉机 JD-001\n\n请您及时查看任务详情,做好作业准备工作。如有问题,请及时联系调度中心。\n\n祝工作顺利!',
|
||||
time: '1小时前',
|
||||
read: false,
|
||||
type: 'success',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '设备预警',
|
||||
content: '拖拉机JD-001需要保养维护',
|
||||
fullContent: '设备预警通知\n\n设备名称:约翰迪尔拖拉机\n设备编号:JD-001\n当前工作时长:498小时\n\n该设备即将达到保养周期(500小时),建议尽快安排保养维护。\n\n保养项目:\n- 更换机油和机滤\n- 检查空气滤清器\n- 检查轮胎气压\n- 检查制动系统\n- 润滑各运动部件\n\n请及时联系维修部门预约保养时间。定期保养可以延长设备使用寿命,确保作业安全。\n\n设备管理中心',
|
||||
time: '2小时前',
|
||||
read: false,
|
||||
type: 'warning',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: '消息日志通知',
|
||||
content: '新的消息日志记录已生成',
|
||||
fullContent: '消息日志通知\n\n系统已记录以下消息日志:\n\n1. 短信发送记录\n - 接收人:张三\n - 内容:任务分配通知\n - 状态:发送成功\n - 时间:2024-10-14 09:30:00\n\n2. 邮件发送记录\n - 接收人:wangwu@example.com\n - 内容:设备保养提醒\n - 状态:发送成功\n - 时间:2024-10-14 14:00:00\n\n3. 站内信记录\n - 接收人:李四\n - 内容:系统通知\n - 状态:已读\n - 时间:2024-10-14 15:30:00\n\n请查看消息日志页面了解详细信息。',
|
||||
time: '30分钟前',
|
||||
read: true,
|
||||
type: 'info',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: '推送消息失败',
|
||||
content: '部分推送消息发送失败',
|
||||
fullContent: '推送消息失败通知\n\n以下推送消息发送失败:\n\n失败原因:设备离线\n- 接收人:赵六\n- 内容:天气预警通知\n- 失败时间:2024-10-14 16:00:00\n- 重试次数:3次\n\n处理建议:\n1. 检查设备网络连接\n2. 确认推送服务状态\n3. 联系用户确认设备状态\n\n系统将在24小时后自动重试发送。\n\n技术支持团队',
|
||||
time: '45分钟前',
|
||||
read: false,
|
||||
type: 'error',
|
||||
},
|
||||
]);
|
||||
|
||||
const unreadCount = messages.filter(m => !m.read).length;
|
||||
|
||||
const handleMessageItemClick = (message: Message) => {
|
||||
// 标记消息为已读
|
||||
if (!message.read) {
|
||||
setMessages(messages.map(m =>
|
||||
m.id === message.id ? { ...m, read: true } : m
|
||||
));
|
||||
}
|
||||
// 显示消息详情
|
||||
setSelectedMessage(message);
|
||||
setShowMessageDetail(true);
|
||||
setShowMessages(false);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
setMessages(messages.map(m => ({ ...m, read: true })));
|
||||
};
|
||||
|
||||
const handleViewAllMessages = () => {
|
||||
setShowMessages(false);
|
||||
if (onMessageClick) {
|
||||
onMessageClick();
|
||||
}
|
||||
};
|
||||
|
||||
const getMessageTypeColor = (type?: string) => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return 'text-orange-600';
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
case 'success':
|
||||
return 'text-green-600';
|
||||
default:
|
||||
return 'text-blue-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getMessageTypeBg = (type?: string) => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return 'bg-orange-50';
|
||||
case 'error':
|
||||
return 'bg-red-50';
|
||||
case 'success':
|
||||
return 'bg-green-50';
|
||||
default:
|
||||
return 'bg-blue-50';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={showMessages} onOpenChange={setShowMessages}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell
|
||||
className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 bg-red-500 text-white text-xs"
|
||||
>
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96 p-0" align="end">
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4>消息通知</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleMarkAllRead}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
全部已读
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="outline">{unreadCount} 条未读</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="max-h-96">
|
||||
{messages.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<Bell className="w-12 h-12 mx-auto mb-2 opacity-20" />
|
||||
<p className="text-sm">暂无消息</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="p-4 border-b hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => handleMessageItemClick(msg)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-2 h-2 rounded-full mt-2 flex-shrink-0 ${msg.read ? 'bg-gray-300' : 'bg-blue-500'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h5 className={`text-sm ${!msg.read ? '' : 'text-muted-foreground'}`}>
|
||||
{msg.title}
|
||||
</h5>
|
||||
{msg.type && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${getMessageTypeColor(msg.type)} border-current`}
|
||||
>
|
||||
{msg.type === 'warning' ? '预警' :
|
||||
msg.type === 'error' ? '错误' :
|
||||
msg.type === 'success' ? '成功' : '通知'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{msg.content}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{msg.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
<div className="p-2 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleViewAllMessages}
|
||||
>
|
||||
查看全部消息
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 消息详情对话框 */}
|
||||
<Dialog open={showMessageDetail} onOpenChange={setShowMessageDetail}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className={`w-5 h-5 ${selectedMessage ? getMessageTypeColor(selectedMessage.type) : ''}`} />
|
||||
<span>{selectedMessage?.title}</span>
|
||||
</div>
|
||||
{selectedMessage?.type && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${getMessageTypeColor(selectedMessage.type)} border-current`}
|
||||
>
|
||||
{selectedMessage.type === 'warning' ? '⚠️ 预警' :
|
||||
selectedMessage.type === 'error' ? '❌ 错误' :
|
||||
selectedMessage.type === 'success' ? '✅ 成功' : 'ℹ️ 通知'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
查看消息详情
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedMessage && (
|
||||
<div className="space-y-4">
|
||||
<div className={`p-4 rounded-lg ${getMessageTypeBg(selectedMessage.type)}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className={`text-sm ${getMessageTypeColor(selectedMessage.type)}`}>
|
||||
发送时间:{selectedMessage.time}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="max-h-96">
|
||||
<div className="space-y-3">
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{selectedMessage.fullContent || selectedMessage.content}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowMessageDetail(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowMessageDetail(false);
|
||||
handleViewAllMessages();
|
||||
}}
|
||||
>
|
||||
查看更多消息
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
109
src/components/layouts/components/UserProfile.tsx
Normal file
109
src/components/layouts/components/UserProfile.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { User, UserCircle, LogOut } from 'lucide-react';
|
||||
import { useAuth } from '@/components/auth/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
|
||||
interface UserProfileProps {
|
||||
onProfileClick?: () => void;
|
||||
}
|
||||
|
||||
export function UserProfile({ onProfileClick }: UserProfileProps) {
|
||||
const { user, logout } = useAuth();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
const handleProfileClick = () => {
|
||||
if (onProfileClick) {
|
||||
onProfileClick();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setShowUserMenu(false);
|
||||
logout();
|
||||
toast.success('已安全退出登录');
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={showUserMenu} onOpenChange={setShowUserMenu}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
<span className="text-sm hidden md:inline">{user?.realName || '用户'}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-0" align="end">
|
||||
<div className="p-4 border-b bg-gradient-to-r from-green-50 to-blue-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-green-600 rounded-full flex items-center justify-center text-white">
|
||||
<UserCircle className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="mb-1">{user?.realName}</h4>
|
||||
<p className="text-xs text-muted-foreground">{user?.is_superuser ? '系统管理员' : '普通用户'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<div className="p-3 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">用户名:</span>
|
||||
<span>{user?.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">手机号:</span>
|
||||
<span>{user?.phone}</span>
|
||||
</div>
|
||||
{user?.enterpriseName && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">所属企业:</span>
|
||||
<span className="truncate max-w-[140px]" title={user?.enterpriseName}>
|
||||
{user?.enterpriseName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{user?.department && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">部门:</span>
|
||||
<span>{user?.department}</span>
|
||||
</div>
|
||||
)}
|
||||
{user?.lastLoginTime && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">上次登录:</span>
|
||||
<span className="text-muted-foreground">{user?.lastLoginTime}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t pt-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm"
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
handleProfileClick();
|
||||
}}
|
||||
>
|
||||
<User className="w-4 h-4 mr-2" />
|
||||
个人中心
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
handleLogout();
|
||||
}}
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
185
src/components/layouts/components/auth/AuthContext.tsx
Normal file
185
src/components/layouts/components/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
realName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role: 'admin' | 'user';
|
||||
enterpriseName?: string;
|
||||
department?: string;
|
||||
lastLoginTime?: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
authState: AuthState;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
updateUser: (userData: Partial<User>) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
});
|
||||
|
||||
// 在组件挂载时检查本地存储的认证信息
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const userStr = localStorage.getItem('user_info');
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user,
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse user info:', error);
|
||||
// 清除无效数据
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('user_info');
|
||||
// 如果数据无效,自动登录默认用户
|
||||
autoLoginDefaultUser();
|
||||
}
|
||||
} else {
|
||||
// 如果没有本地存储数据,自动登录默认用户
|
||||
autoLoginDefaultUser();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 自动登录默认用户
|
||||
const autoLoginDefaultUser = () => {
|
||||
const defaultUser: User = {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
realName: '系统管理员',
|
||||
email: 'admin@smartagriculture.com',
|
||||
phone: '13800138000',
|
||||
role: 'admin',
|
||||
enterpriseName: '智慧农业科技有限公司',
|
||||
department: '技术部',
|
||||
lastLoginTime: new Date().toLocaleString('zh-CN'),
|
||||
};
|
||||
|
||||
const token = 'mock-jwt-token-default';
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('user_info', JSON.stringify(defaultUser));
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: defaultUser,
|
||||
token,
|
||||
});
|
||||
};
|
||||
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
// 模拟登录请求
|
||||
if (username === 'admin' && password === 'admin123') {
|
||||
const user: User = {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
realName: '系统管理员',
|
||||
email: 'admin@smartagriculture.com',
|
||||
phone: '13800138000',
|
||||
role: 'admin',
|
||||
enterpriseName: '智慧农业科技有限公司',
|
||||
department: '技术部',
|
||||
lastLoginTime: new Date().toLocaleString('zh-CN'),
|
||||
};
|
||||
|
||||
const token = 'mock-jwt-token-' + Date.now();
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('user_info', JSON.stringify(user));
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user,
|
||||
token,
|
||||
});
|
||||
|
||||
toast.success('登录成功');
|
||||
return true;
|
||||
} else {
|
||||
toast.error('用户名或密码错误');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
toast.error('登录失败,请重试');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('user_info');
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
});
|
||||
|
||||
toast.success('已安全退出');
|
||||
};
|
||||
|
||||
const updateUser = (userData: Partial<User>) => {
|
||||
if (authState.user) {
|
||||
const updatedUser = { ...authState.user, ...userData };
|
||||
|
||||
// 更新本地存储
|
||||
localStorage.setItem('user_info', JSON.stringify(updatedUser));
|
||||
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
user: updatedUser,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
authState,
|
||||
login,
|
||||
logout,
|
||||
updateUser,
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
6
src/components/layouts/components/lib/utils.ts
Normal file
6
src/components/layouts/components/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
36
src/components/layouts/components/ui/badge.tsx
Normal file
36
src/components/layouts/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
56
src/components/layouts/components/ui/button.tsx
Normal file
56
src/components/layouts/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
120
src/components/layouts/components/ui/dialog.tsx
Normal file
120
src/components/layouts/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
29
src/components/layouts/components/ui/popover.tsx
Normal file
29
src/components/layouts/components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
46
src/components/layouts/components/ui/scroll-area.tsx
Normal file
46
src/components/layouts/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
6
src/components/layouts/index.css
Normal file
6
src/components/layouts/index.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.parent-flex {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem; /* 控制子元素间距 */
|
||||
width: 100%; /* 默认宽度 */
|
||||
}
|
||||
Reference in New Issue
Block a user