This commit is contained in:
2026-01-23 02:35:52 +08:00
parent 9849f93b1e
commit 2db3868fcf
39 changed files with 3381 additions and 164 deletions

View File

@@ -0,0 +1,178 @@
'use strict';
import { useShallow } from 'zustand/react/shallow';
import { useLayoutStore } from './store';
import clsx from 'clsx';
import { toast as message } from 'sonner';
import { useMemo } from 'react';
import { queryLogin } from '../query';
import { LogOut, Map, SquareUser, Users, X, ArrowDownLeftFromSquareIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { openLink } from '../basename';
export const LayoutUser = () => {
const { open, setOpen, isAdmin, ...store } = useLayoutStore(
useShallow((state) => ({
open: state.openUser,
setOpen: state.setOpenUser,
me: state.me,
switchOrg: state.switchOrg,
isAdmin: state.isAdmin,
})),
);
const items = useMemo(() => {
const orgs = store.me?.orgs || [];
return orgs.map((item) => {
return {
label: item,
key: item,
icon: <Users size={16} />,
};
});
}, [store.me]);
const menu = useMemo(() => {
const orgs = store.me?.orgs || [];
const hasOrg = orgs.length > 0;
type MenuItem = {
title: string;
icon: React.ReactNode;
link?: string;
isOrg?: boolean;
isAdmin?: boolean;
}
const menuItems: MenuItem[] = [
// {
// title: '个人中心',
// icon: <SquareUser size={16} />,
// link: '/user/profile',
// },
// {
// title: '我的组织',
// icon: <Users size={16} />,
// link: '/org/edit/list',
// isOrg: true,
// },
// {
// title: '站点地图',
// icon: <Map size={16} />,
// link: '/map',
// },
{
title: '域名管理',
icon: <ArrowDownLeftFromSquareIcon size={16} />,
link: '/domain/',
isAdmin: true,
},
];
return menuItems.filter((item) => {
if (item.isOrg) {
return hasOrg;
}
if (item.isAdmin) {
return isAdmin;
}
return true;
});
}, [store.me]);
return (
<TooltipProvider>
<div className={clsx('w-full h-full absolute z-20 no-drag text-primary', !open && 'hidden')}>
<div
className='w-full absolute h-full opacity-60 z-0'
onClick={() => {
setOpen(false);
}}></div>
<div className='w-[400px] bg-white transition-all duration-300 h-full absolute top-0 right-0 rounded-l-lg'>
<div className='flex justify-between p-6 mt-4 font-bold items-center border-b'>
<div className='flex items-center gap-2'>
: <span className='text-primary'>{store.me?.username}</span>
</div>
<div className='flex gap-4'>
{items.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='icon'>
<Users />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{items.map((item, index) => (
<DropdownMenuItem
key={index}
onClick={() => {
store.switchOrg(item.key, 'org');
}}>
<div className='mr-2'>{item.icon}</div>
<div>{item.label}</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
)}
<Button variant='ghost' size='icon' onClick={() => setOpen(false)}>
<X />
</Button>
</div>
</div>
<div className='mt-3 font-medium'>
{menu.map((item, index) => {
return (
<div
key={index}
className='flex items-center p-4 hover:bg-secondary hover:text-white cursor-pointer'
onClick={() => {
if (item.link) {
openLink(item.link, '_self');
setOpen(false);
} else {
message.info('即将上线');
}
}}>
<div className='mr-4'>{item.icon}</div>
<div>{item.title}</div>
</div>
);
})}
</div>
<div
className='flex items-center p-4 hover:bg-secondary hover:text-white cursor-pointer'
onClick={async () => {
const res = await queryLogin.logout();
if (res.success) {
const url = new URL(location.origin);
url.pathname = '/root/login';
openLink(url.toString(), '_self');
} else {
message.error(res.message || '退出失败');
}
}}>
<div className='mr-4'>
<LogOut size={16} />
</div>
<div>退</div>
</div>
</div>
</div>
</TooltipProvider>
);
};

104
src/modules/layout/Menu.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { useShallow } from 'zustand/react/shallow';
import { useLayoutStore } from './store';
import clsx from 'clsx';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import HomeOutlined from '@ant-design/icons/HomeOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import FolderOutlined from '@ant-design/icons/FolderOutlined';
import CodeOutlined from '@ant-design/icons/CodeOutlined';
import SwitcherOutlined from '@ant-design/icons/SwitcherOutlined';
import SmileOutlined from '@ant-design/icons/SmileOutlined';
import { X, Settings } from 'lucide-react';
import { Map } from 'lucide-react';
import { openLink } from '../basename';
export const useQuickMenu = () => {
return [
{
title: '首页',
icon: <HomeOutlined />,
link: '/',
},
{
title: '应用',
icon: <AppstoreOutlined />,
link: '/apps/',
},
// {
// title: '文件',
// icon: <FolderOutlined />,
// link: '/file/edit/list',
// },
];
};
export const LayoutMenu = () => {
const meun = [
{
title: '首页',
icon: <HomeOutlined />,
link: '/',
},
// {
// title: '应用',
// icon: <AppstoreOutlined />,
// link: '/app/edit/list',
// },
// {
// title: '文件',
// icon: <FolderOutlined />,
// link: '/file/edit/list',
// },
// {
// title: '容器',
// icon: <CodeOutlined />,
// link: '/container/edit/list',
// },
// { title: '配置', icon: <Settings size={16} />, link: '/config/edit/list' },
// { title: '地图', icon: <Map size={16} />, link: '/map' },
// {
// title: '关于',
// icon: <SmileOutlined />,
// },
];
const { open, setOpen } = useLayoutStore(useShallow((state) => ({ open: state.open, setOpen: state.setOpen })));
return (
<div className={clsx('w-full h-full text-primary absolute z-20 no-drag', !open && 'hidden')}>
<div
className='bg-white w-full absolute h-full opacity-60 z-0'
onClick={() => {
setOpen(false);
}}></div>
<div className='w-[300px] h-full absolute top-0 left-0 '>
<div className='flex justify-between p-6 mt-4 font-bold items-center'>
Envision Center
<div>
<Button variant='ghost' size='icon' onClick={() => setOpen(false)}>
<X />
</Button>
</div>
</div>
<div className='mt-3 font-medium'>
{meun.map((item, index) => {
return (
<div
key={index}
className='flex items-center p-4 gap-3 cursor-pointer hover:bg-secondary hover:text-white rounded-md'
onClick={() => {
if (item.link) openLink(item.link, '_self');
else {
toast.info('关于 Envision Center');
}
setOpen(false);
}}>
<div className='w-6 h-6 flex items-center justify-center'>{item.icon}</div>
<div>{item.title}</div>
</div>
);
})}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,147 @@
import { MenuOutlined, SwapOutlined } from '@ant-design/icons';
import { LayoutMenu, useQuickMenu } from './Menu';
import { useLayoutStore, usePlatformStore } from './store';
import { useShallow } from 'zustand/react/shallow';
import { useEffect, useLayoutEffect, useState } from 'react';
import { LayoutUser } from './LayoutUser';
import PandaPNG from '@/assets/panda.jpg';
import QRCodePNG from '@/assets/qrcode-8x8.jpg';
import clsx from 'clsx';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
export const IconButton = (props: any) => {
return (
<button
className={clsx(
'inline-flex items-center justify-center rounded-md p-2 transition-colors hover:bg-slate-100 disabled:opacity-50 disabled:pointer-events-none',
props.className,
)}
{...props}>
{props.children}
</button>
);
};
import { QrCode } from 'lucide-react';
import { openLink } from '../basename';
type LayoutMainProps = {
title?: React.ReactNode;
children?: React.ReactNode;
};
export const LayoutMain = (props: LayoutMainProps) => {
const menuStore = useLayoutStore(
useShallow((state) => {
return {
open: state.open,
setOpen: state.setOpen, //
getMe: state.getMe,
me: state.me,
setOpenUser: state.setOpenUser,
switchOrg: state.switchOrg,
};
}),
);
const platformStore = usePlatformStore(
useShallow((state) => {
return {
isMac: state.isMac,
mount: state.mount,
isElectron: state.isElectron,
init: state.init,
};
}),
);
const { isMac, mount, isElectron } = platformStore;
const quickMenu = useQuickMenu();
useLayoutEffect(() => {
platformStore.init();
}, []);
useEffect(() => {
menuStore.getMe();
}, []);
return (
<div className='flex w-full h-full flex-col relative'>
<div
className={clsx('layout-menu items-center ', !mount && '!invisible')}
style={{
cursor: isElectron ? 'move' : 'default',
}}>
<div className='flex grow justify-between pl-4 py-2 items-center bg-gray-200'>
<div className='flex items-center gap-2'>
<div className='text-xl font-bold '>{props.title}</div>
<div className='flex items-center gap-2 text-sm '>
{quickMenu.map((item, index) => {
if (typeof window === 'undefined') return null;
const isActive = location?.pathname === item.link;
return (
<div
key={index}
className={clsx('flex items-center gap-2 px-1', isActive && 'border border-white')}
onClick={() => {
openLink(item.link, '_self');
}}>
<Tooltip>
<TooltipTrigger asChild>
<div className='cursor-pointer'>{item.icon}</div>
</TooltipTrigger>
<TooltipContent>{item.title}</TooltipContent>
</Tooltip>
</div>
);
})}
</div>
</div>
<div className='mr-4 flex gap-4 items-center no-drag'>
<div className='group relative'>
<IconButton>
<QrCode size={16} />
</IconButton>
<div className='absolute hidden group-hover:flex bg-white p-2 border shadow-md top-10 -left-15 w-40 z-[9999] flex-col items-center justify-center rounded-md'>
<img src={QRCodePNG.src} alt='QR Code' />
<div className='text-sm text-black'></div>
</div>
</div>
{menuStore.me?.type === 'org' && (
<div>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
onClick={() => {
menuStore.switchOrg('', 'user');
}}>
<SwapOutlined />
</IconButton>
</TooltipTrigger>
<TooltipContent>Switch To User</TooltipContent>
</Tooltip>
</div>
)}
<div className='w-8 h-8 rounded-full avatar cursor-pointer' onClick={() => menuStore.setOpenUser(true)}>
{menuStore.me?.avatar ? (
<img className='w-8 h-8 rounded-full' src={menuStore.me?.avatar} alt='avatar' />
) : (
<img className='w-8 h-8 rounded-full' src={PandaPNG.src} alt='avatar' />
)}
</div>
<div className='cursor-pointer' onClick={() => menuStore.setOpenUser(true)}>
{menuStore.me?.username}
</div>
</div>
</div>
<div
className='flex'
style={{
height: 'calc(100vh - 3rem)',
}}>
{props.children}
</div>
</div>
<LayoutUser />
</div>
);
};

View File

@@ -0,0 +1,104 @@
'use strict';
import { query, queryLogin } from '@/modules/query';
import { create } from 'zustand';
import { toast as message } from 'sonner';
export const getIsMac = async () => {
// @ts-ignore
const userAgentData = navigator.userAgentData;
if (userAgentData) {
const ua = await userAgentData.getHighEntropyValues(['platform']);
if (ua.platform === 'macOS') {
return true;
}
}
return false;
};
export const getIsElectron = () => {
// 检查 window.process 和 navigator.userAgent 中是否包含 Electron 信息
return (
// @ts-ignore
(typeof window !== 'undefined' && typeof window.process !== 'undefined' && window.process.type === 'renderer') ||
(typeof navigator === 'object' && typeof navigator.userAgent === 'string' && navigator.userAgent.indexOf('Electron') >= 0)
);
};
type PlatfromStore = {
isMac: boolean;
setIsMac: (mac: boolean) => void;
mount: boolean;
isElectron: boolean;
init: () => Promise<void>;
};
export const usePlatformStore = create<PlatfromStore>((set) => {
return {
isMac: false,
mount: false,
isElectron: false,
setIsMac: (mac) => set({ isMac: mac }),
init: async () => {
const mac = await getIsMac();
// @ts-ignore
const isElectron = getIsElectron();
set({ isMac: isElectron && mac, isElectron: isElectron, mount: true });
},
};
});
type Me = {
id?: string;
username?: string;
needChangePassword?: boolean;
role?: string;
description?: string;
type?: 'user' | 'org';
orgs?: string[];
avatar?: string;
};
export type LayoutStore = {
open: boolean;
setOpen: (open: boolean) => void;
me: Me;
setMe: (me: Me) => void;
getMe: () => Promise<void>;
openUser: boolean;
setOpenUser: (openUser: boolean) => void;
switchOrg: (username?: string, type?: 'user' | 'org') => Promise<void>;
isAdmin: boolean;
setIsAdmin: (isAdmin: boolean) => void;
checkHasOrg: () => boolean;
};
export const useLayoutStore = create<LayoutStore>((set, get) => ({
open: false,
setOpen: (open) => set({ open }),
me: {},
setMe: (me) => set({ me }),
getMe: async () => {
const res = await queryLogin.getMe();
if (res.code === 200) {
set({ me: res.data });
set({ isAdmin: res.data.orgs?.includes('admin') });
}
},
openUser: false,
setOpenUser: (openUser) => set({ openUser }),
switchOrg: async (username?: string, type?: string) => {
const res = await queryLogin.switchUser(username || '');
if (res.code === 200) {
message.success('Switch success');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
message.error(res.message || 'Request failed');
}
},
isAdmin: false,
setIsAdmin: (isAdmin) => set({ isAdmin }),
checkHasOrg: () => {
const user = get().me || {};
if (!user.orgs) {
return false;
}
return user?.orgs?.length > 0;
},
}));