Compare commits
2 Commits
9afc680833
...
04d61ae3b9
| Author | SHA1 | Date | |
|---|---|---|---|
| 04d61ae3b9 | |||
| 7a21043dd8 |
22
crop-x/next.config.js
Normal file
22
crop-x/next.config.js
Normal file
@@ -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
|
||||
54
crop-x/package-lock.json
generated
54
crop-x/package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <SideBar data={centralConfigData}>{children}</SideBar>
|
||||
return <SideBarOld data={centralConfigData}>{children}</SideBarOld>
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<Navbar></Navbar>
|
||||
<Navbar1></Navbar1>
|
||||
{/* 布局 UI */}
|
||||
{/* 将 children 放在您希望渲染页面或嵌套布局的位置 */}
|
||||
<main>{children}</main>
|
||||
|
||||
@@ -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<typeof Sidebar> {
|
||||
export function AppSidebar({ data, ...props }: AppSidebarProps) {
|
||||
// Use external data if provided, otherwise use default data
|
||||
const sidebarData = data || defaultData
|
||||
|
||||
const { navigatorHeight } = useLayoutStore();
|
||||
return (
|
||||
<Sidebar {...props}>
|
||||
<SidebarHeader>
|
||||
<VersionSwitcher
|
||||
versions={sidebarData.versions || defaultData.versions}
|
||||
defaultVersion={sidebarData.versions?.[0] || defaultData.versions[0]}
|
||||
/>
|
||||
<SearchForm />
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="gap-0">
|
||||
<Sidebar {...props} style = {{top: navigatorHeight + 'px'}}>
|
||||
<SidebarContent className="gap-0" >
|
||||
{/* We create a collapsible SidebarGroup for each parent. */}
|
||||
{sidebarData.navMain.map((item) => (
|
||||
<Collapsible
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Menu } from "lucide-react";
|
||||
'use client';
|
||||
|
||||
import { Book, Menu, Sunset, Trees, Zap } from "lucide-react";
|
||||
import { Tractor, Map, Clipboard, Package, Brain, Droplets, Settings } from 'lucide-react';
|
||||
import { MessageBell } from './components/MessageBell';
|
||||
import { UserProfile } from './components/UserProfile';
|
||||
import { AuthProvider } from './components/auth/AuthContext';
|
||||
import { useElementHeight } from '@/hooks/useElementHeight';
|
||||
import { useViewHeight } from '@/hooks/useViewHeight';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { useLayoutStore } from '@/stores/useLayoutStore';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
NavigationMenu,
|
||||
@@ -23,8 +31,7 @@ import {
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
// 菜单项接口定义
|
||||
export interface MenuItem {
|
||||
interface MenuItem {
|
||||
title: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
@@ -32,45 +39,31 @@ export interface MenuItem {
|
||||
items?: MenuItem[];
|
||||
}
|
||||
|
||||
// Logo接口定义
|
||||
export interface LogoConfig {
|
||||
url: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// 认证接口定义
|
||||
export interface AuthConfig {
|
||||
login: {
|
||||
title: string;
|
||||
interface Navbar1Props {
|
||||
logo?: {
|
||||
url: string;
|
||||
};
|
||||
signup: {
|
||||
src: string;
|
||||
alt: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Navbar数据接口定义
|
||||
export interface NavbarData {
|
||||
logo?: LogoConfig;
|
||||
menu?: MenuItem[];
|
||||
auth?: AuthConfig;
|
||||
auth?: {
|
||||
login: {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
signup: {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Navbar组件Props接口定义
|
||||
export interface NavbarProps {
|
||||
navbar?: NavbarData;
|
||||
}
|
||||
|
||||
// 默认Navbar数据
|
||||
const defaultNavbar: NavbarData = {
|
||||
const navbarData = {
|
||||
logo: {
|
||||
url: "/",
|
||||
src: "https://deifkwefumgah.cloudfront.net/shadcnblocks/block/logos/shadcnblockscom-icon.svg",
|
||||
alt: "Crop-X Logo",
|
||||
title: "Crop-X 智慧农业",
|
||||
title: "智慧农业生产管理系统",
|
||||
},
|
||||
menu: [
|
||||
{
|
||||
@@ -121,22 +114,47 @@ const defaultNavbar: NavbarData = {
|
||||
signup: { title: "注册", url: "/register" },
|
||||
},
|
||||
};
|
||||
const Navbar1 = () => {
|
||||
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 (
|
||||
<section className="py-4">
|
||||
<div className="container">
|
||||
<AuthProvider>
|
||||
<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-green-600 rounded-lg flex items-center justify-center">
|
||||
<Tractor className="w-6 h-6 text-white" />
|
||||
@@ -146,21 +164,41 @@ export function Navbar({ navbar }: NavbarProps) {
|
||||
<p className="text-xs text-muted-foreground">Smart Agriculture Management System</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
</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>
|
||||
<NavigationMenuList className="flex gap-1 min-w-max">
|
||||
|
||||
{menu.map((item) => renderMenuItem(item))}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={auth.login.url}>{auth.login.title}</a>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<a href={auth.signup.url}>{auth.signup.title}</a>
|
||||
</Button>
|
||||
<div className="flex gap-2" style = {{alignItems:"center"}}>
|
||||
<MessageBell onMessageClick={handleMessageClick} />
|
||||
<UserProfile onProfileClick={handleProfileClick} />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -203,15 +241,12 @@ export function Navbar({ navbar }: NavbarProps) {
|
||||
</Accordion>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<a href={auth.login.url}>{auth.login.title}</a>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<a href={auth.signup.url}>{auth.signup.title}</a>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
系统管理员
|
||||
</Button>
|
||||
<div className="flex justify-center">
|
||||
<MessageBell onMessageClick={handleMessageClick} />
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<UserProfile onProfileClick={handleProfileClick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
@@ -220,16 +255,18 @@ export function Navbar({ navbar }: NavbarProps) {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMenuItem = (item: MenuItem) => {
|
||||
if (item.items) {
|
||||
return (
|
||||
<NavigationMenuItem key={item.title}>
|
||||
<NavigationMenuTrigger>{item.title}</NavigationMenuTrigger>
|
||||
<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>
|
||||
@@ -243,9 +280,9 @@ const renderMenuItem = (item: MenuItem) => {
|
||||
<NavigationMenuItem key={item.title}>
|
||||
<NavigationMenuLink
|
||||
href={item.url}
|
||||
className="bg-background hover:bg-muted hover:text-accent-foreground group inline-flex h-10 w-max items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
||||
className="bg-background hover:bg-muted hover:text-accent-foreground group inline-flex h-10 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors gap-2 whitespace-nowrap"
|
||||
>
|
||||
{item.icon}
|
||||
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||
{item.title}
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
@@ -256,7 +293,8 @@ const renderMobileMenuItem = (item: MenuItem) => {
|
||||
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">
|
||||
<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">
|
||||
@@ -269,7 +307,8 @@ const renderMobileMenuItem = (item: MenuItem) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<a key={item.title} href={item.url} className="text-md font-semibold">
|
||||
<a key={item.title} href={item.url} className="text-md font-semibold flex items-center gap-2">
|
||||
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||
{item.title}
|
||||
</a>
|
||||
);
|
||||
@@ -293,3 +332,5 @@ const SubMenuLink = ({ item }: { item: MenuItem }) => {
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export { Navbar1 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
273
crop-x/src/components/layouts/SideBar/SideBarOld.tsx
Normal file
273
crop-x/src/components/layouts/SideBar/SideBarOld.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
'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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
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: <span className="text-lg">{item.icon}</span>,
|
||||
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);
|
||||
};
|
||||
|
||||
// 获取当前页面的面包屑
|
||||
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 (
|
||||
<div className="flex h-screen bg-gray-100" style={{ height: '100vh' }}>
|
||||
{/* 左侧导航栏 */}
|
||||
<LeftSidebar
|
||||
menus={menus}
|
||||
activePath={currentPath}
|
||||
onNavigate={handleNavigate}
|
||||
isMobile={isMobile}
|
||||
isCollapsed={!isMobile && isCollapsed}
|
||||
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
||||
/>
|
||||
|
||||
{/* 右侧主内容 */}
|
||||
<MainContent
|
||||
breadcrumb={getCurrentBreadcrumb()}
|
||||
isMobile={isMobile}
|
||||
sidebarOpen={!isCollapsed}
|
||||
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{children}
|
||||
</MainContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
crop-x/src/components/layouts/SideBar/components/LeftSidebar.tsx
Normal file
179
crop-x/src/components/layouts/SideBar/components/LeftSidebar.tsx
Normal file
@@ -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<string>();
|
||||
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<Set<string>>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white border-r border-gray-200 transition-all duration-300 flex flex-col",
|
||||
isMobile ? "fixed inset-y-0 left-0 z-50" : "relative",
|
||||
isCollapsed ? "w-16" : "w-64"
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={cn(
|
||||
"font-semibold text-gray-900 transition-all duration-300",
|
||||
isCollapsed ? "hidden" : "block"
|
||||
)}>
|
||||
导航菜单
|
||||
</h2>
|
||||
{isMobile ? (
|
||||
<X className="w-5 h-5 text-gray-600" />
|
||||
) : (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-1 rounded-md hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Menu className="w-5 h-5 text-gray-600" />
|
||||
</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}>
|
||||
{/* 一级菜单 */}
|
||||
<button
|
||||
onClick={() => toggleMenu(menu.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2 rounded-md transition-colors text-sm",
|
||||
"hover:bg-gray-100 hover:text-gray-900",
|
||||
isCollapsed ? "justify-center px-2 py-3" : "px-3 py-2"
|
||||
)}
|
||||
title={isCollapsed ? menu.label : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{menu.icon && (
|
||||
<span className="flex-shrink-0">
|
||||
{menu.icon}
|
||||
</span>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<span className="text-gray-700">{menu.label}</span>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && menu.children && (
|
||||
expandedMenus.has(menu.id) ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 二级菜单 */}
|
||||
{!isCollapsed && menu.children && expandedMenus.has(menu.id) && (
|
||||
<div className="ml-4 mt-1 space-y-1">
|
||||
{menu.children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
onClick={() => child.path && onNavigate(child.path)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded-md transition-colors text-xs",
|
||||
activePath === child.path
|
||||
? "bg-green-50 text-green-700 font-medium border-l-2 border-green-600"
|
||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
||||
)}
|
||||
>
|
||||
{child.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<div className={cn(
|
||||
"text-xs text-gray-500",
|
||||
isCollapsed ? "text-center" : "text-left"
|
||||
)}>
|
||||
{isCollapsed ? "管理" : "管理系统"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
crop-x/src/components/layouts/SideBar/components/MainContent.tsx
Normal file
112
crop-x/src/components/layouts/SideBar/components/MainContent.tsx
Normal file
@@ -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 && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40"
|
||||
onClick={() => setShowMobileSidebar(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className="flex-1 flex flex-col min-h-screen bg-gray-50">
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="bg-white border-b border-gray-200 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 菜单按钮 */}
|
||||
<button
|
||||
onClick={handleToggleSidebar}
|
||||
className="p-2 rounded-md hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{isMobile ? (
|
||||
showMobileSidebar ? (
|
||||
<X className="w-5 h-5 text-gray-600" />
|
||||
) : (
|
||||
<Menu className="w-5 h-5 text-gray-600" />
|
||||
)
|
||||
) : (
|
||||
<Menu className="w-5 h-5 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 面包屑导航 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Home className="w-4 h-4 text-gray-500" />
|
||||
{breadcrumb.length > 0 ? (
|
||||
breadcrumb.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
{item.path ? (
|
||||
<a
|
||||
href={item.path}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-gray-900 font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-gray-900 font-medium">{title}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-6">
|
||||
|
||||
{/* 页面内容 */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 shadow-sm">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
283
crop-x/src/components/layouts/components/MessageBell.tsx
Normal file
283
crop-x/src/components/layouts/components/MessageBell.tsx
Normal file
@@ -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<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
crop-x/src/components/layouts/components/UserProfile.tsx
Normal file
109
crop-x/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 './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 (
|
||||
<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">{authState.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">{authState.user?.realName}</h4>
|
||||
<p className="text-xs text-muted-foreground">{authState.user?.role === 'admin' ? '系统管理员' : '普通用户'}</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>{authState.user?.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">手机号:</span>
|
||||
<span>{authState.user?.phone}</span>
|
||||
</div>
|
||||
{authState.user?.enterpriseName && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">所属企业:</span>
|
||||
<span className="truncate max-w-[140px]" title={authState.user?.enterpriseName}>
|
||||
{authState.user?.enterpriseName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{authState.user?.department && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">部门:</span>
|
||||
<span>{authState.user?.department}</span>
|
||||
</div>
|
||||
)}
|
||||
{authState.user?.lastLoginTime && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">上次登录:</span>
|
||||
<span className="text-muted-foreground">{authState.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
crop-x/src/components/layouts/components/auth/AuthContext.tsx
Normal file
185
crop-x/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
crop-x/src/components/layouts/components/lib/utils.ts
Normal file
6
crop-x/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
crop-x/src/components/layouts/components/ui/badge.tsx
Normal file
36
crop-x/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
crop-x/src/components/layouts/components/ui/button.tsx
Normal file
56
crop-x/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
crop-x/src/components/layouts/components/ui/dialog.tsx
Normal file
120
crop-x/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
crop-x/src/components/layouts/components/ui/popover.tsx
Normal file
29
crop-x/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
crop-x/src/components/layouts/components/ui/scroll-area.tsx
Normal file
46
crop-x/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 }
|
||||
@@ -153,8 +153,8 @@ function SidebarProvider({
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "none",
|
||||
variant = "inset",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
|
||||
101
crop-x/src/hooks/useElementHeight.ts
Normal file
101
crop-x/src/hooks/useElementHeight.ts
Normal file
@@ -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<HTMLElement>(null);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const { setNavigatorHeight } = useLayoutStore();
|
||||
const lastHeightRef = useRef<number>(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,
|
||||
};
|
||||
};
|
||||
57
crop-x/src/hooks/useViewHeight.ts
Normal file
57
crop-x/src/hooks/useViewHeight.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
65
crop-x/src/stores/useLayoutStore.ts
Normal file
65
crop-x/src/stores/useLayoutStore.ts
Normal file
@@ -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<LayoutState>((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();
|
||||
Reference in New Issue
Block a user