update
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const BASE_NAME = isDev ? '' : '/root/perler-beads';
|
||||
export const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
const BASE_NAME = isDev ? '' : '/root/center';
|
||||
|
||||
export const basename = BASE_NAME;
|
||||
|
||||
export const wrapBasename = (path: string) => {
|
||||
const hasEnd = path.endsWith('/')
|
||||
let _basename = basename;
|
||||
@@ -14,5 +14,13 @@ export const wrapBasename = (path: string) => {
|
||||
if (isDev) {
|
||||
return _basename
|
||||
}
|
||||
return _basename + '.html';
|
||||
return !hasEnd ? _basename + '/' : _basename;
|
||||
}
|
||||
export const openLink = (path: string, target: string = '_self') => {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
window.open(path, target);
|
||||
return;
|
||||
}
|
||||
const url = wrapBasename(path);
|
||||
window.open(url, target);
|
||||
}
|
||||
9
src/modules/is-null.ts
Normal file
9
src/modules/is-null.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const isObjectNull = (value: any) => {
|
||||
if (value === null || value === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (JSON.stringify(value) === '{}') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
178
src/modules/layout/LayoutUser.tsx
Normal file
178
src/modules/layout/LayoutUser.tsx
Normal 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
104
src/modules/layout/Menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
147
src/modules/layout/index.tsx
Normal file
147
src/modules/layout/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
104
src/modules/layout/store/index.ts
Normal file
104
src/modules/layout/store/index.ts
Normal 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;
|
||||
},
|
||||
}));
|
||||
32
src/modules/query.ts
Normal file
32
src/modules/query.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
import { QueryClient } from '@kevisual/query';
|
||||
import { QueryLoginBrowser } from '@kevisual/api/login';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Only create instances in browser environment
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
export const query = isBrowser ? new QueryClient({}) : {} as QueryClient;
|
||||
|
||||
export const queryLogin = isBrowser
|
||||
? new QueryLoginBrowser({
|
||||
query: query as any,
|
||||
})
|
||||
: {} as QueryLoginBrowser;
|
||||
|
||||
if (isBrowser) {
|
||||
(query as any).afterResponse = async (res, ctx) => {
|
||||
const newRes = await queryLogin.run401Action(res, ctx, {
|
||||
afterAlso401: () => {},
|
||||
afterCheck: (res: any) => {
|
||||
if (res.code === 200) {
|
||||
toast.success('刷新登陆信息');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
});
|
||||
return newRes as any;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user