From 7a21043dd847c81fecc6c8af9243264e6ce9c07b Mon Sep 17 00:00:00 2001 From: peng Date: Wed, 22 Oct 2025 15:18:36 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=9F=E4=BA=A7=E7=AE=A1=E7=90=86=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=89=8D=E7=AB=AF=201.=E4=BF=AE=E5=A4=8D=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=AF=BC=E8=88=AA=E8=BF=87=E9=95=BF=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98=202.=E5=88=A9=E7=94=A8=E6=97=A7=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=20=E5=BC=80=E5=8F=91=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crop-x/next.config.js | 22 ++ crop-x/package-lock.json | 54 ++-- crop-x/package.json | 3 +- .../src/app/(app)/central-config/layout.tsx | 5 +- crop-x/src/app/(app)/layout.tsx | 4 +- crop-x/src/components/app-sidebar.tsx | 15 +- crop-x/src/components/layouts/Navbar.tsx | 177 ++++++----- .../components/layouts/SideBar/SideBar.tsx | 2 +- .../components/layouts/SideBar/SideBarOld.tsx | 276 +++++++++++++++++ .../SideBar/components/LeftSidebar.tsx | 179 +++++++++++ .../SideBar/components/MainContent.tsx | 112 +++++++ .../layouts/SideBar/components/lib/utils.ts | 6 + .../layouts/components/MessageBell.tsx | 283 ++++++++++++++++++ .../layouts/components/UserProfile.tsx | 109 +++++++ .../layouts/components/auth/AuthContext.tsx | 185 ++++++++++++ .../layouts/components/lib/utils.ts | 6 + .../layouts/components/ui/badge.tsx | 36 +++ .../layouts/components/ui/button.tsx | 56 ++++ .../layouts/components/ui/dialog.tsx | 120 ++++++++ .../layouts/components/ui/popover.tsx | 29 ++ .../layouts/components/ui/scroll-area.tsx | 46 +++ crop-x/src/components/ui/sidebar.tsx | 4 +- crop-x/src/hooks/useElementHeight.ts | 101 +++++++ crop-x/src/hooks/useViewHeight.ts | 57 ++++ crop-x/src/stores/useLayoutStore.ts | 65 ++++ 25 files changed, 1843 insertions(+), 109 deletions(-) create mode 100644 crop-x/next.config.js create mode 100644 crop-x/src/components/layouts/SideBar/SideBarOld.tsx create mode 100644 crop-x/src/components/layouts/SideBar/components/LeftSidebar.tsx create mode 100644 crop-x/src/components/layouts/SideBar/components/MainContent.tsx create mode 100644 crop-x/src/components/layouts/SideBar/components/lib/utils.ts create mode 100644 crop-x/src/components/layouts/components/MessageBell.tsx create mode 100644 crop-x/src/components/layouts/components/UserProfile.tsx create mode 100644 crop-x/src/components/layouts/components/auth/AuthContext.tsx create mode 100644 crop-x/src/components/layouts/components/lib/utils.ts create mode 100644 crop-x/src/components/layouts/components/ui/badge.tsx create mode 100644 crop-x/src/components/layouts/components/ui/button.tsx create mode 100644 crop-x/src/components/layouts/components/ui/dialog.tsx create mode 100644 crop-x/src/components/layouts/components/ui/popover.tsx create mode 100644 crop-x/src/components/layouts/components/ui/scroll-area.tsx create mode 100644 crop-x/src/hooks/useElementHeight.ts create mode 100644 crop-x/src/hooks/useViewHeight.ts create mode 100644 crop-x/src/stores/useLayoutStore.ts diff --git a/crop-x/next.config.js b/crop-x/next.config.js new file mode 100644 index 0000000..7914547 --- /dev/null +++ b/crop-x/next.config.js @@ -0,0 +1,22 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + typescript: { + ignoreBuildErrors: false, + }, + eslint: { + ignoreDuringBuilds: false, + }, + experimental: { + turbo: { + rules: { + '*.svg': { + loaders: ['@svgr/webpack'], + as: '*.js', + }, + }, + }, + }, + transpilePackages: ['lucide-react'], +} + +export default nextConfig \ No newline at end of file diff --git a/crop-x/package-lock.json b/crop-x/package-lock.json index 7598c96..2def14b 100644 --- a/crop-x/package-lock.json +++ b/crop-x/package-lock.json @@ -56,7 +56,8 @@ "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", - "zod": "^4.1.12" + "zod": "^4.1.12", + "zustand": "^5.0.8" }, "devDependencies": { "@tailwindcss/vite": "^4.1.14", @@ -3727,7 +3728,6 @@ "integrity": "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3745,7 +3745,6 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3757,7 +3756,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3798,7 +3796,6 @@ "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", @@ -4018,7 +4015,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4214,7 +4210,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -4727,8 +4722,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -4856,7 +4850,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8680,7 +8673,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9089,7 +9081,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9202,7 +9193,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9233,7 +9223,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9246,7 +9235,6 @@ "resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.65.0.tgz", "integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -9827,8 +9815,7 @@ "version": "4.1.14", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -9915,7 +9902,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9974,7 +9960,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10124,7 +10109,6 @@ "integrity": "sha512-oLnWs9Hak/LOlKjeSpOwD6JMks8BeICEdYMJBf6P4Lac/pO9tKiv/XhXnAM7nNfSkZahjlCZu9sS50zL8fSnsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10218,7 +10202,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10475,6 +10458,35 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/crop-x/package.json b/crop-x/package.json index 6c0c60f..5990372 100644 --- a/crop-x/package.json +++ b/crop-x/package.json @@ -66,7 +66,8 @@ "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", - "zod": "^4.1.12" + "zod": "^4.1.12", + "zustand": "^5.0.8" }, "devDependencies": { "@tailwindcss/vite": "^4.1.14", diff --git a/crop-x/src/app/(app)/central-config/layout.tsx b/crop-x/src/app/(app)/central-config/layout.tsx index 91ffe44..e8d0b68 100644 --- a/crop-x/src/app/(app)/central-config/layout.tsx +++ b/crop-x/src/app/(app)/central-config/layout.tsx @@ -1,10 +1,9 @@ "use client" import { ReactNode } from 'react' -import SideBar from '@/components/layouts/SideBar/SideBar' +import {SideBarOld} from '@/components/layouts/SideBar/SideBarOld' // 中心配置路由数据 const centralConfigData = { - versions: ["1.0.0", "2.0.0"], navMain: [ { title: "租户管理", @@ -139,5 +138,5 @@ export default function CentralConfigLayout({ }: { children: ReactNode }) { - return {children} + return {children} } \ No newline at end of file diff --git a/crop-x/src/app/(app)/layout.tsx b/crop-x/src/app/(app)/layout.tsx index 711d823..6b74acb 100644 --- a/crop-x/src/app/(app)/layout.tsx +++ b/crop-x/src/app/(app)/layout.tsx @@ -1,4 +1,4 @@ -import {Navbar} from "@/components/layouts/Navbar" +import {Navbar1} from "@/components/layouts/NavBar" import '@/styles/globals.css' export default function DashboardLayout({ children, @@ -7,7 +7,7 @@ export default function DashboardLayout({ }) { return (
- + {/* 布局 UI */} {/* 将 children 放在您希望渲染页面或嵌套布局的位置 */}
{children}
diff --git a/crop-x/src/components/app-sidebar.tsx b/crop-x/src/components/app-sidebar.tsx index b5c2a77..9add399 100644 --- a/crop-x/src/components/app-sidebar.tsx +++ b/crop-x/src/components/app-sidebar.tsx @@ -21,7 +21,7 @@ import { SidebarMenuItem, SidebarRail, } from "@/components/ui/sidebar" - +import { useLayoutStore } from '@/stores/useLayoutStore'; // Define the interface for menu data interface MenuItem { title: string @@ -110,17 +110,10 @@ export interface AppSidebarProps extends React.ComponentProps { export function AppSidebar({ data, ...props }: AppSidebarProps) { // Use external data if provided, otherwise use default data const sidebarData = data || defaultData - + const { navigatorHeight } = useLayoutStore(); return ( - - - - - - + + {/* We create a collapsible SidebarGroup for each parent. */} {sidebarData.navMain.map((item) => ( { + const logo = navbarData.logo + const menu = navbarData.menu + const auth = navbarData.auth + const containerStyle = { + maxWidth:"100%",marginLeft:"0px",marginRight:"0px",paddingLeft:"1rem",paddingRight:"0rem" + } -// 新的Navbar组件,支持外部传入navbar参数 -export function Navbar({ navbar }: NavbarProps) { - // 使用外部传入的navbar数据,如果没有则使用默认数据 - const navbarData = navbar || defaultNavbar; - const logo = navbarData.logo || defaultNavbar.logo; - const menu = navbarData.menu || defaultNavbar.menu; - const auth = navbarData.auth || defaultNavbar.auth; + // 使用自定义 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 ( -
-
+ +
+
{/* Desktop Menu */} @@ -203,15 +241,12 @@ export function Navbar({ navbar }: NavbarProps) {
- - - +
+ +
+
+ +
@@ -220,16 +255,18 @@ export function Navbar({ navbar }: NavbarProps) {
+ ); -} - +}; + const renderMenuItem = (item: MenuItem) => { if (item.items) { return ( - {item.title} + {item.title} {item.items.map((subItem) => ( + @@ -243,9 +280,9 @@ const renderMenuItem = (item: MenuItem) => { - {item.icon} + {item.icon && {item.icon}} {item.title} @@ -256,7 +293,8 @@ const renderMobileMenuItem = (item: MenuItem) => { if (item.items) { return ( - + + {item.icon && {item.icon}} {item.title} @@ -269,7 +307,8 @@ const renderMobileMenuItem = (item: MenuItem) => { } return ( - + + {item.icon && {item.icon}} {item.title} ); @@ -293,3 +332,5 @@ const SubMenuLink = ({ item }: { item: MenuItem }) => { ); }; + +export { Navbar1 }; diff --git a/crop-x/src/components/layouts/SideBar/SideBar.tsx b/crop-x/src/components/layouts/SideBar/SideBar.tsx index 96da639..89a254f 100644 --- a/crop-x/src/components/layouts/SideBar/SideBar.tsx +++ b/crop-x/src/components/layouts/SideBar/SideBar.tsx @@ -1,7 +1,7 @@ "use client" import { ReactNode, useEffect, useState } from 'react' import { usePathname } from 'next/navigation' -import { AppSidebar, AppSidebarProps, SidebarData } from "@/components/app-sidebar" +import { AppSidebar, AppSidebarProps } from "@/components/app-sidebar" import { Breadcrumb, diff --git a/crop-x/src/components/layouts/SideBar/SideBarOld.tsx b/crop-x/src/components/layouts/SideBar/SideBarOld.tsx new file mode 100644 index 0000000..7327f68 --- /dev/null +++ b/crop-x/src/components/layouts/SideBar/SideBarOld.tsx @@ -0,0 +1,276 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { LeftSidebar } from './components/LeftSidebar'; +import { MainContent } from './components/MainContent'; + +// 菜单项数据结构定义 +interface NavItem { + title: string; + url: string; + icon: string; + items?: { + title: string; + url: string; + isActive?: boolean; + }[]; +} + +interface SideBarData { + navMain: NavItem[]; +} + +// 内部菜单项结构(用于LeftSidebar组件) +interface MenuItem { + id: string; + label: string; + icon?: React.ReactNode; + children?: { + id: string; + label: string; + path?: string; + }[]; +} + +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 + } + ] + } + ] +}; + +// 转换 SideBarData 为 MenuItem 格式的工具函数 +const convertSideBarDataToMenus = (sideBarData?: SideBarData): MenuItem[] => { + if (!sideBarData?.navMain) return []; + + return 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, + })), + })); +}; + +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 menus = convertSideBarDataToMenus(data || defaultSideBarData); + + // 检测是否为移动设备 + 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); + }; + + // 获取当前页面的面包屑 + const getCurrentBreadcrumb = () => { + const allItems: { label: string; path?: string }[] = []; + + menus.forEach(menu => { + if (menu.children?.some(child => child.path === currentPath)) { + allItems.push({ label: menu.label }); + const activeChild = menu.children.find(child => child.path === currentPath); + if (activeChild) { + allItems.push({ label: activeChild.label }); + } + } + }); + + return allItems; + }; + + return ( +
+ {/* 左侧导航栏 */} + setIsCollapsed(!isCollapsed)} + /> + + {/* 右侧主内容 */} + setIsCollapsed(!isCollapsed)} + > + {children} + +
+ ); +} \ No newline at end of file diff --git a/crop-x/src/components/layouts/SideBar/components/LeftSidebar.tsx b/crop-x/src/components/layouts/SideBar/components/LeftSidebar.tsx new file mode 100644 index 0000000..1ff44b5 --- /dev/null +++ b/crop-x/src/components/layouts/SideBar/components/LeftSidebar.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { ChevronDown, ChevronRight, Menu, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +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) { + // 根据activePath自动展开包含该路径的菜单 + const getInitialExpandedMenus = () => { + const expanded = new Set(); + menus.forEach(menu => { + if (menu.children?.some(child => child.path === activePath)) { + expanded.add(menu.id); + } + }); + // 如果没有匹配的,默认展开第一个 + if (expanded.size === 0 && menus.length > 0) { + expanded.add(menus[0].id); + } + return expanded; + }; + + const [expandedMenus, setExpandedMenus] = useState>(getInitialExpandedMenus()); + + // 当activePath或menus变化时,自动展开对应的菜单 + useEffect(() => { + menus.forEach(menu => { + if (menu.children?.some(child => child.path === activePath)) { + setExpandedMenus(prev => { + const newSet = new Set(prev); + newSet.add(menu.id); + return newSet; + }); + } + }); + }, [activePath, menus]); + + const toggleMenu = (menuId: string) => { + setExpandedMenus(prev => { + const newSet = new Set(prev); + if (newSet.has(menuId)) { + newSet.delete(menuId); + } else { + newSet.add(menuId); + } + return newSet; + }); + }; + + return ( +
+ {/* 头部 */} +
+
+

+ 导航菜单 +

+ {isMobile ? ( + + ) : ( + + )} +
+
+ + {/* 导航菜单 */} +
+ +
+ + {/* 底部 */} +
+
+ {isCollapsed ? "管理" : "管理系统"} +
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/components/layouts/SideBar/components/MainContent.tsx b/crop-x/src/components/layouts/SideBar/components/MainContent.tsx new file mode 100644 index 0000000..c7df2fa --- /dev/null +++ b/crop-x/src/components/layouts/SideBar/components/MainContent.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useState } from 'react'; +import { Menu, X, ChevronRight, Home, FileText, Settings } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface MainContentProps { + title?: string; + children: React.ReactNode; + isMobile?: boolean; + sidebarOpen?: boolean; + onToggleSidebar?: () => void; + breadcrumb?: { + label: string; + path?: string; + }[]; +} + +export function MainContent({ + title = "当前页面", + children, + isMobile = false, + sidebarOpen = false, + onToggleSidebar, + breadcrumb = [] +}: MainContentProps) { + const [showMobileSidebar, setShowMobileSidebar] = useState(false); + + const handleToggleSidebar = () => { + if (isMobile) { + setShowMobileSidebar(!showMobileSidebar); + } else { + onToggleSidebar?.(); + } + }; + + return ( + <> + {/* 移动端侧边栏遮罩 */} + {isMobile && showMobileSidebar && ( +
setShowMobileSidebar(false)} + /> + )} + + {/* 主内容区域 */} +
+ {/* 顶部导航栏 */} +
+
+
+ {/* 菜单按钮 */} + + + {/* 面包屑导航 */} +
+ + {breadcrumb.length > 0 ? ( + breadcrumb.map((item, index) => ( +
+ + {item.path ? ( + + {item.label} + + ) : ( + + {item.label} + + )} +
+ )) + ) : ( + {title} + )} +
+
+ +
+
+ + {/* 主内容区域 */} +
+
+ + {/* 页面内容 */} +
+ {children} +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/crop-x/src/components/layouts/SideBar/components/lib/utils.ts b/crop-x/src/components/layouts/SideBar/components/lib/utils.ts new file mode 100644 index 0000000..1a860ee --- /dev/null +++ b/crop-x/src/components/layouts/SideBar/components/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} \ No newline at end of file diff --git a/crop-x/src/components/layouts/components/MessageBell.tsx b/crop-x/src/components/layouts/components/MessageBell.tsx new file mode 100644 index 0000000..b3e2cdc --- /dev/null +++ b/crop-x/src/components/layouts/components/MessageBell.tsx @@ -0,0 +1,283 @@ +'use client'; + +import { useState } from 'react'; +import { Bell, CheckCircle, X } from 'lucide-react'; +import { useAuth } from './auth/AuthContext'; +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 { authState } = useAuth(); + const [showMessages, setShowMessages] = useState(false); + const [showMessageDetail, setShowMessageDetail] = useState(false); + const [selectedMessage, setSelectedMessage] = useState(null); + const [messages, setMessages] = useState([ + { + 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 ( + <> + + + + + +
+
+

消息通知

+
+ {unreadCount > 0 && ( + + )} + {unreadCount} 条未读 +
+
+
+ + {messages.length === 0 ? ( +
+ +

暂无消息

+
+ ) : ( + messages.map((msg) => ( +
handleMessageItemClick(msg)} + > +
+
+
+
+
+ {msg.title} +
+ {msg.type && ( + + {msg.type === 'warning' ? '预警' : + msg.type === 'error' ? '错误' : + msg.type === 'success' ? '成功' : '通知'} + + )} +
+

{msg.content}

+

{msg.time}

+
+
+
+ )) + )} + +
+ +
+ + + + {/* 消息详情对话框 */} + + + + +
+
+ + {selectedMessage?.title} +
+ {selectedMessage?.type && ( + + {selectedMessage.type === 'warning' ? '⚠️ 预警' : + selectedMessage.type === 'error' ? '❌ 错误' : + selectedMessage.type === 'success' ? '✅ 成功' : 'ℹ️ 通知'} + + )} +
+
+ + 查看消息详情 + +
+ {selectedMessage && ( +
+
+
+ + 发送时间:{selectedMessage.time} + +
+
+ +
+
+ {selectedMessage.fullContent || selectedMessage.content} +
+
+
+
+ + +
+
+ )} +
+
+ + ); +} \ No newline at end of file diff --git a/crop-x/src/components/layouts/components/UserProfile.tsx b/crop-x/src/components/layouts/components/UserProfile.tsx new file mode 100644 index 0000000..a844c3f --- /dev/null +++ b/crop-x/src/components/layouts/components/UserProfile.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState } from 'react'; +import { User, UserCircle, LogOut } from 'lucide-react'; +import { useAuth } from './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 { authState, logout } = useAuth(); + const [showUserMenu, setShowUserMenu] = useState(false); + + const handleProfileClick = () => { + if (onProfileClick) { + onProfileClick(); + } + }; + + const handleLogout = () => { + setShowUserMenu(false); + logout(); + toast.success('已安全退出登录'); + }; + + return ( + + + + + +
+
+
+ +
+
+

{authState.user?.realName}

+

{authState.user?.role === 'admin' ? '系统管理员' : '普通用户'}

+
+
+
+
+
+
+ 用户名: + {authState.user?.username} +
+
+ 手机号: + {authState.user?.phone} +
+ {authState.user?.enterpriseName && ( +
+ 所属企业: + + {authState.user?.enterpriseName} + +
+ )} + {authState.user?.department && ( +
+ 部门: + {authState.user?.department} +
+ )} + {authState.user?.lastLoginTime && ( +
+ 上次登录: + {authState.user?.lastLoginTime} +
+ )} +
+
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/components/layouts/components/auth/AuthContext.tsx b/crop-x/src/components/layouts/components/auth/AuthContext.tsx new file mode 100644 index 0000000..97efb45 --- /dev/null +++ b/crop-x/src/components/layouts/components/auth/AuthContext.tsx @@ -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; + logout: () => void; + updateUser: (userData: Partial) => void; +} + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [authState, setAuthState] = useState({ + 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 => { + 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) => { + if (authState.user) { + const updatedUser = { ...authState.user, ...userData }; + + // 更新本地存储 + localStorage.setItem('user_info', JSON.stringify(updatedUser)); + + setAuthState(prev => ({ + ...prev, + user: updatedUser, + })); + } + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} \ No newline at end of file diff --git a/crop-x/src/components/layouts/components/lib/utils.ts b/crop-x/src/components/layouts/components/lib/utils.ts new file mode 100644 index 0000000..1a860ee --- /dev/null +++ b/crop-x/src/components/layouts/components/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} \ No newline at end of file diff --git a/crop-x/src/components/layouts/components/ui/badge.tsx b/crop-x/src/components/layouts/components/ui/badge.tsx new file mode 100644 index 0000000..12daad7 --- /dev/null +++ b/crop-x/src/components/layouts/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } \ No newline at end of file diff --git a/crop-x/src/components/layouts/components/ui/button.tsx b/crop-x/src/components/layouts/components/ui/button.tsx new file mode 100644 index 0000000..c9c71de --- /dev/null +++ b/crop-x/src/components/layouts/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } \ No newline at end of file diff --git a/crop-x/src/components/layouts/components/ui/dialog.tsx b/crop-x/src/components/layouts/components/ui/dialog.tsx new file mode 100644 index 0000000..dd48c69 --- /dev/null +++ b/crop-x/src/components/layouts/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} \ No newline at end of file diff --git a/crop-x/src/components/layouts/components/ui/popover.tsx b/crop-x/src/components/layouts/components/ui/popover.tsx new file mode 100644 index 0000000..c5e7a64 --- /dev/null +++ b/crop-x/src/components/layouts/components/ui/popover.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } \ No newline at end of file diff --git a/crop-x/src/components/layouts/components/ui/scroll-area.tsx b/crop-x/src/components/layouts/components/ui/scroll-area.tsx new file mode 100644 index 0000000..6251548 --- /dev/null +++ b/crop-x/src/components/layouts/components/ui/scroll-area.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } \ No newline at end of file diff --git a/crop-x/src/components/ui/sidebar.tsx b/crop-x/src/components/ui/sidebar.tsx index 7113452..1d32d0b 100644 --- a/crop-x/src/components/ui/sidebar.tsx +++ b/crop-x/src/components/ui/sidebar.tsx @@ -153,8 +153,8 @@ function SidebarProvider({ function Sidebar({ side = "left", - variant = "sidebar", - collapsible = "none", + variant = "inset", + collapsible = "offcanvas", className, children, ...props diff --git a/crop-x/src/hooks/useElementHeight.ts b/crop-x/src/hooks/useElementHeight.ts new file mode 100644 index 0000000..6e2276b --- /dev/null +++ b/crop-x/src/hooks/useElementHeight.ts @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useLayoutStore } from '@/stores/useLayoutStore'; + +interface UseElementHeightOptions { + onUpdate?: (height: number) => void; + immediate?: boolean; // 是否立即计算 +} + +export const useElementHeight = (options: UseElementHeightOptions = {}) => { + const elementRef = useRef(null); + const [isClient, setIsClient] = useState(false); + const { setNavigatorHeight } = useLayoutStore(); + const lastHeightRef = useRef(0); + + // 确保在客户端执行 + useEffect(() => { + setIsClient(true); + }, []); + + // 使用 useCallback 来避免无限循环 + const calculateHeight = useCallback(() => { + if (!elementRef.current || !isClient) return 0; + + const height = elementRef.current.offsetHeight; + + // 只有当高度真正发生变化时才更新 + if (Math.abs(height - lastHeightRef.current) > 1) { // 允许1px的误差避免微小变化 + lastHeightRef.current = height; + setNavigatorHeight(height); + + // 调用自定义回调 + if (options.onUpdate) { + options.onUpdate(height); + } + } + + return height; + }, [isClient, setNavigatorHeight, options.onUpdate]); + + // 手动更新高度的函数 + const updateHeight = useCallback(() => { + return calculateHeight(); + }, [calculateHeight]); + + useEffect(() => { + if (!isClient) return; + + const element = elementRef.current; + if (!element) return; + + // 立即计算一次(如果需要) + if (options.immediate) { + // 使用 setTimeout 来避免在渲染过程中立即调用 + setTimeout(() => { + calculateHeight(); + }, 0); + } + + // 使用防抖来优化性能 + let debounceTimer: NodeJS.Timeout; + const debouncedCalculateHeight = () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(calculateHeight, 100); // 100ms 防抖 + }; + + // 创建 ResizeObserver 来监听元素大小变化 + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { height } = entry.contentRect; + if (Math.abs(height - lastHeightRef.current) > 1) { + debouncedCalculateHeight(); + } + } + }); + + // 开始观察元素 + resizeObserver.observe(element); + + // 监听窗口大小变化 + const handleResize = () => { + debouncedCalculateHeight(); + }; + + window.addEventListener('resize', handleResize, { passive: true }); + window.addEventListener('orientationchange', handleResize, { passive: true }); + + // 清理函数 + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', handleResize); + window.removeEventListener('orientationchange', handleResize); + clearTimeout(debounceTimer); + }; + }, [isClient, calculateHeight, options.immediate]); + + return { + elementRef, + updateHeight, + isClient, + }; +}; \ No newline at end of file diff --git a/crop-x/src/hooks/useViewHeight.ts b/crop-x/src/hooks/useViewHeight.ts new file mode 100644 index 0000000..43535cc --- /dev/null +++ b/crop-x/src/hooks/useViewHeight.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import { useLayoutStore } from '@/stores/useLayoutStore'; + +export const useViewHeight = () => { + const { setViewHeight, calculateMainBodyHeight } = useLayoutStore(); + const [isClient, setIsClient] = useState(false); + + // 确保在客户端执行 + useEffect(() => { + setIsClient(true); + }, []); + + const getViewHeight = () => { + if (!isClient) return 0; + + // 获取视口高度 + const height = window.innerHeight || document.documentElement.clientHeight; + return height; + }; + + useEffect(() => { + if (!isClient) return; + + // 立即计算一次 + const initialHeight = getViewHeight(); + setViewHeight(initialHeight); + + // 监听窗口大小变化 + const handleResize = () => { + const newHeight = getViewHeight(); + setViewHeight(newHeight); + }; + + // 监听方向变化 + const handleOrientationChange = () => { + // 方向变化时稍微延迟计算,确保浏览器已经完成调整 + setTimeout(() => { + const newHeight = getViewHeight(); + setViewHeight(newHeight); + }, 100); + }; + + window.addEventListener('resize', handleResize, { passive: true }); + window.addEventListener('orientationchange', handleOrientationChange, { passive: true }); + + // 清理函数 + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('orientationchange', handleOrientationChange); + }; + }, [isClient, setViewHeight]); + + return { + getViewHeight, + isClient, + }; +}; \ No newline at end of file diff --git a/crop-x/src/stores/useLayoutStore.ts b/crop-x/src/stores/useLayoutStore.ts new file mode 100644 index 0000000..82ab4aa --- /dev/null +++ b/crop-x/src/stores/useLayoutStore.ts @@ -0,0 +1,65 @@ +import { create } from 'zustand'; + +interface LayoutState { + // 布局高度相关状态 + navigatorHeight: number; // 导航栏高度(原 authProviderHeight) + viewHeight: number; // 页面总高度 + mainBodyHeight: number; // 主体内容高度 + + // 更新导航栏高度 + setNavigatorHeight: (height: number) => void; + + // 更新页面总高度 + setViewHeight: (height: number) => void; + + // 计算并更新主体内容高度 + calculateMainBodyHeight: () => void; + + // 重置所有高度 + resetHeights: () => void; +} + +export const useLayoutStore = create((set, get) => ({ + // 初始状态 + navigatorHeight: 72, // 默认导航栏高度 + viewHeight: 0, // 页面总高度 + mainBodyHeight: 0, // 主体内容高度 + + // 更新导航栏高度 + setNavigatorHeight: (height: number) => { + set({ navigatorHeight: height }); + }, + + // 更新页面总高度 + setViewHeight: (height: number) => { + const state = get(); + set({ viewHeight: height }); + + // 自动计算主体内容高度 + const newMainBodyHeight = height - state.navigatorHeight; + if (newMainBodyHeight >= 0) { + set({ mainBodyHeight: newMainBodyHeight }); + } + }, + + // 计算并更新主体内容高度 + calculateMainBodyHeight: () => { + const state = get(); + const newMainBodyHeight = state.viewHeight - state.navigatorHeight; + if (newMainBodyHeight >= 0) { + set({ mainBodyHeight: newMainBodyHeight }); + } + }, + + // 重置所有高度 + resetHeights: () => { + set({ + navigatorHeight: 72, + viewHeight: 0, + mainBodyHeight: 0 + }); + }, +})); + +// 获取当前布局状态的工具函数 +export const getLayoutState = () => useLayoutStore.getState(); \ No newline at end of file