This commit is contained in:
asp_ly 2024-12-27 21:08:38 +08:00
parent 02e795fead
commit 72de966d0c
7 changed files with 357 additions and 86 deletions

View File

@ -27,7 +27,9 @@
"@monaco-editor/react": "^4.6.0",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
@ -2137,6 +2139,34 @@
}
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.4",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.4.tgz",
"integrity": "sha512-iXU1Ab5ecM+yEepGAWK8ZhMyKX4ubFdCNtol4sT9D0OVErG9PNElfx3TQhjw7n7BC5nFVz68/5//clWy+8TXzA==",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-menu": "2.1.4",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz",
@ -2218,6 +2248,80 @@
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.4",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.1.4.tgz",
"integrity": "sha512-BnOgVoL6YYdHAG6DtXONaR29Eq4nvbi8rutrV/xlr3RQCMMb3yqP85Qiw/3NReozrSW+4dfLkK+rc1hb4wPU/A==",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collection": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.3",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.1",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-popper": "1.2.1",
"@radix-ui/react-portal": "1.1.3",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-roving-focus": "1.1.1",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "^2.6.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-navigation-menu": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.3.tgz",
"integrity": "sha512-IQWAsQ7dsLIYDrn0WqPU+cdM7MONTv9nqrLVYoie3BPiabSfUVDe6Fr+oEt0Cofsr9ONDcDe9xhmJbL1Uq1yKg==",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collection": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.3",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-visually-hidden": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
@ -4173,7 +4277,6 @@
"resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
@ -4196,7 +4299,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
@ -8392,7 +8494,6 @@
"resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
"integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"

View File

@ -29,7 +29,9 @@
"@monaco-editor/react": "^4.6.0",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",

View File

@ -0,0 +1,66 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useNavigate, useLocation } from 'react-router-dom';
import { Sidebar, SidebarHeader, SidebarContent } from './ui/sidebar';
import { MenuItem, MenuGroup } from './ui/sidebar-menu';
import type { RootState } from '@/store';
import { MenuResponse, MenuTypeEnum } from '@/pages/System/Menu/types';
import { getIconComponent } from '@/config/icons';
interface AppMenuProps {
openKeys: string[];
onOpenChange: (keys: string[]) => void;
}
export function AppMenu({ openKeys, onOpenChange }: AppMenuProps) {
const navigate = useNavigate();
const location = useLocation();
const menus = useSelector((state: RootState) => state.user.menus);
return (
<Sidebar>
<SidebarHeader>
<div className="text-lg font-semibold">Deploy Ease</div>
</SidebarHeader>
<SidebarContent>
<MenuGroup>
{menus?.filter(menu => menu.type !== MenuTypeEnum.BUTTON && !menu.hidden)
.map(menu => (
<MenuItem
key={menu.path || String(menu.id)}
icon={getIconComponent(menu.icon)}
title={menu.name}
active={location.pathname === menu.path}
expanded={openKeys.includes(menu.path || String(menu.id))}
onClick={() => {
if (menu.children?.length) {
onOpenChange(
openKeys.includes(menu.path || String(menu.id))
? openKeys.filter(key => key !== (menu.path || String(menu.id)))
: [...openKeys, menu.path || String(menu.id)]
);
} else if (menu.path) {
navigate(menu.path);
}
}}
>
{menu.children?.map(child => (
<MenuItem
key={child.path || String(child.id)}
icon={getIconComponent(child.icon)}
title={child.name}
active={location.pathname === child.path}
onClick={() => {
if (child.path) {
navigate(child.path);
}
}}
/>
))}
</MenuItem>
))}
</MenuGroup>
</SidebarContent>
</Sidebar>
);
}

View File

@ -0,0 +1,81 @@
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
icon?: React.ReactNode;
title: string;
active?: boolean;
expanded?: boolean;
disabled?: boolean;
children?: React.ReactNode;
onClick?: () => void;
}
const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
({ className, icon, title, active, expanded, disabled, children, onClick, ...props }, ref) => {
return (
<div ref={ref} className={cn("relative", className)} {...props}>
<div
className={cn(
"group flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm font-medium",
"transition-all duration-200 ease-in-out",
"hover:bg-accent/50 hover:text-accent-foreground",
"dark:hover:bg-slate-800 dark:hover:text-slate-100",
active && "bg-accent text-accent-foreground dark:bg-slate-800 dark:text-slate-100",
disabled && "pointer-events-none opacity-50",
className
)}
onClick={onClick}
>
{icon && (
<span className={cn(
"mr-2 transition-colors",
"text-muted-foreground group-hover:text-current",
active && "text-current"
)}>
{icon}
</span>
)}
<span className="flex-1">{title}</span>
{children && (
<ChevronDown
className={cn(
"ml-1 h-4 w-4 transition-transform duration-200",
"text-muted-foreground group-hover:text-current",
active && "text-current",
expanded && "rotate-180"
)}
/>
)}
</div>
{expanded && children && (
<div className={cn(
"mt-1 space-y-1 pl-4",
"animate-in slide-in-from-left-5 duration-200"
)}>
{children}
</div>
)}
</div>
);
}
);
MenuItem.displayName = "MenuItem";
const MenuGroup = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"space-y-1.5 px-3",
className
)}
{...props}
/>
));
MenuGroup.displayName = "MenuGroup";
export { MenuItem, MenuGroup };

View File

@ -0,0 +1,67 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
children?: React.ReactNode;
}
const Sidebar = React.forwardRef<HTMLDivElement, SidebarProps>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"flex h-full flex-col border-r bg-background transition-all duration-300",
"min-w-[240px] w-60",
"dark:border-slate-700 dark:bg-slate-900",
className
)}
{...props}
>
{children}
</div>
);
}
);
Sidebar.displayName = "Sidebar";
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"flex h-14 items-center border-b px-4",
"dark:border-slate-700 dark:bg-slate-900/50",
"bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60",
className
)}
{...props}
/>
));
SidebarHeader.displayName = "SidebarHeader";
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"flex-1 overflow-auto py-2",
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-200",
"dark:scrollbar-thumb-slate-700",
className
)}
{...props}
/>
));
SidebarContent.displayName = "SidebarContent";
export {
Sidebar,
SidebarHeader,
SidebarContent,
};

View File

@ -1,11 +1,10 @@
import React, {useEffect, useState, useCallback} from 'react';
import {Layout, Menu, Dropdown, Modal, message, Spin, Space, Tooltip} from 'antd';
import {Layout, Dropdown, Modal, message, Spin, Space, Tooltip} from 'antd';
import {useNavigate, useLocation, Outlet} from 'react-router-dom';
import {useDispatch, useSelector} from 'react-redux';
import {
UserOutlined,
LogoutOutlined,
DownOutlined,
ExclamationCircleOutlined,
ClockCircleOutlined,
CloudOutlined
@ -17,10 +16,9 @@ import {getWeather} from '../services/weather';
import type {RootState} from '../store';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import {MenuResponse, MenuTypeEnum} from "@/pages/System/Menu/types";
import { getIconComponent } from '@/config/icons.tsx';
import { AppMenu } from '@/components/AppMenu';
const {Header, Content, Sider} = Layout;
const {Header, Content} = Layout;
const {confirm} = Modal;
// 设置中文语言
@ -31,7 +29,6 @@ const BasicLayout: React.FC = () => {
const location = useLocation();
const dispatch = useDispatch();
const userInfo = useSelector((state: RootState) => state.user.userInfo);
const menus = useSelector((state: RootState) => state.user.menus);
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(dayjs());
const [weather, setWeather] = useState({temp: '--', weather: '未知', city: '未知'});
@ -149,26 +146,8 @@ const BasicLayout: React.FC = () => {
}
];
// 获取图标组件
const getIcon = getIconComponent;
// 将菜单数据转换为antd Menu需要的格式
const getMenuItems = (menuList: MenuResponse[]): MenuProps['items'] => {
return menuList
?.filter(menu => menu.type !== MenuTypeEnum.BUTTON && !menu.hidden)
.map(menu => {
return {
key: menu.path || String(menu.id),
icon: getIconComponent(menu.icon),
label: menu.name,
children: menu.children ? getMenuItems(menu.children) : undefined
};
});
};
// 处理菜单展开/收起
const handleOpenChange = (keys: string[]) => {
console.log('Menu open change:', keys);
setOpenKeys(keys);
};
@ -197,25 +176,10 @@ const BasicLayout: React.FC = () => {
);
}
return (
<Layout style={{minHeight: '100vh'}}>
<Sider>
<div style={{height: 32, margin: 16, background: 'rgba(255, 255, 255, 0.2)'}}/>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
openKeys={openKeys}
onOpenChange={handleOpenChange}
items={getMenuItems(menus)}
onClick={({key}) => {
console.log('Menu click:', key);
navigate(key);
}}
/>
</Sider>
<Layout>
<Layout style={{minHeight: '100vh', display: 'flex', flexDirection: 'row'}}>
<AppMenu openKeys={openKeys} onOpenChange={handleOpenChange} />
<Layout style={{flex: 1, overflow: 'hidden'}}>
<Header style={{
padding: '0 24px',
background: '#fff',
@ -248,35 +212,25 @@ const BasicLayout: React.FC = () => {
</Tooltip>
</Space>
<Dropdown menu={{items: userMenuItems}} trigger={['hover']}>
<span style={{
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
height: '100%',
transition: 'all 0.3s',
padding: '0 4px',
borderRadius: 4
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.025)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
<UserOutlined style={{fontSize: 16, marginRight: 8}}/>
<Space style={{cursor: 'pointer'}}>
<UserOutlined/>
<span>{userInfo?.nickname || userInfo?.username}</span>
<DownOutlined style={{fontSize: 12, marginLeft: 6}}/>
</span>
</Space>
</Dropdown>
</Space>
</Header>
<Content style={{ margin: '24px 16px', padding: 24, background: '#fff', minHeight: 280 }}>
<Content style={{
margin: '24px 16px',
padding: 24,
background: '#fff',
minHeight: 280,
overflow: 'auto'
}}>
<Outlet/>
</Content>
</Layout>
</Layout>
);
};
}
export default BasicLayout;

View File

@ -1,4 +1,4 @@
import { clsx, type ClassValue } from "clsx"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {