Compare commits

..

2 Commits

25 changed files with 1840 additions and 109 deletions

22
crop-x/next.config.js Normal file
View 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

View File

@@ -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
}
}
}
}
}

View File

@@ -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",

View File

@@ -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>
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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,16 +39,15 @@ export interface MenuItem {
items?: MenuItem[];
}
// Logo接口定义
export interface LogoConfig {
interface Navbar1Props {
logo?: {
url: string;
src: string;
alt: string;
title: string;
}
// 认证接口定义
export interface AuthConfig {
};
menu?: MenuItem[];
auth?: {
login: {
title: string;
url: string;
@@ -50,27 +56,14 @@ export interface AuthConfig {
title: string;
url: string;
};
};
}
// Navbar数据接口定义
export interface NavbarData {
logo?: LogoConfig;
menu?: MenuItem[];
auth?: AuthConfig;
}
// 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 };

View File

@@ -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,

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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))
}

View 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>
</>
);
}

View 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>
);
}

View 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;
}

View 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))
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View File

@@ -153,8 +153,8 @@ function SidebarProvider({
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "none",
variant = "inset",
collapsible = "offcanvas",
className,
children,
...props

View 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,
};
};

View 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,
};
};

View 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();