+
+
{refreshing ? null : }
@@ -29,4 +33,12 @@ const ConsoleLayout: FC = withAuth(() => {
);
});
-export default ConsoleLayout;
+const ConsoleLayoutStoreProvider = withAuth(() => {
+ return (
+
+
+
+ );
+});
+
+export default ConsoleLayoutStoreProvider;
diff --git a/src/layouts/ConsoleLayout/store/Provider.tsx b/src/layouts/ConsoleLayout/store/Provider.tsx
new file mode 100644
index 0000000..2a45655
--- /dev/null
+++ b/src/layouts/ConsoleLayout/store/Provider.tsx
@@ -0,0 +1,58 @@
+import { useMemo, useRef, useState } from 'react';
+import { generateMenuItems } from '@/layouts/SideMenu/utils';
+import { useModel } from '@zhangsai/model';
+import { withAuthModel } from '@/models/withAuth';
+import { baseModel } from '@/models/base';
+import router, { useRouter } from '@/router';
+import { useLocation } from 'react-router-dom';
+import { Context, StoreContextType } from './index';
+import { createProvider } from '@/components/store';
+
+const Provider = createProvider
({
+ Context,
+ useValue: () => {
+ const permissions = useModel(withAuthModel, 'permissions');
+ const language = useModel(baseModel, 'language');
+ const { routes } = useRouter(router);
+ /** 根据权限和语言生成菜单数据 */
+ const { menuItems, flattenMenuItems, allFlattenMenuItems } = useMemo(() => {
+ const ret = generateMenuItems(routes, permissions);
+ // console.log('flattenMenuItems: ', ret.flattenMenuItems);
+ // console.log('allFlattenMenuItems: ', ret.allFlattenMenuItems);
+ return ret;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [permissions, language, routes]);
+ /** 展开菜单 */
+ const [collapsed, setCollapsed] = useState(false);
+
+ const location = useLocation();
+
+ const curRoutePath = useMemo(() => {
+ return router.getRoutePath(location.pathname);
+ }, [location.pathname]);
+
+ const curMenuItem = allFlattenMenuItems.get(curRoutePath);
+
+ /** 移动端 */
+ const [mobileCollapsed, setMobileCollapsed] = useState(false);
+
+ /** 引导 */
+ const ref1 = useRef(null);
+ const ref2 = useRef(null);
+ const ref3 = useRef(null);
+
+ const value = {
+ menuItems,
+ flattenMenuItems,
+ allFlattenMenuItems,
+ collapsed, setCollapsed,
+ curMenuItem,
+ mobileCollapsed, setMobileCollapsed,
+ ref1, ref2, ref3,
+ };
+
+ return value;
+ },
+});
+
+export default Provider;
diff --git a/src/layouts/ConsoleLayout/store/index.ts b/src/layouts/ConsoleLayout/store/index.ts
new file mode 100644
index 0000000..2c59fe5
--- /dev/null
+++ b/src/layouts/ConsoleLayout/store/index.ts
@@ -0,0 +1,26 @@
+import { ItemType } from '@/layouts/SideMenu/utils';
+import { createStore } from '@/components/store';
+
+export interface StoreContextType {
+ /** 菜单(树型) */
+ menuItems: ItemType[];
+ /** 菜单(一维) */
+ flattenMenuItems: Map;
+ /** 菜单(一维), 包含被hidden的 */
+ allFlattenMenuItems: Map;
+ /** 菜单收起 */
+ collapsed: boolean;
+ setCollapsed: React.Dispatch>;
+ /** 移动端菜单收起 */
+ mobileCollapsed: boolean;
+ setMobileCollapsed: React.Dispatch>;
+ ref1: React.MutableRefObject;
+ ref2: React.MutableRefObject;
+ ref3: React.MutableRefObject;
+}
+
+const store = createStore();
+const { useStore, Context } = store;
+export { Context };
+export default useStore;
+
diff --git a/src/layouts/SideMenu/index.tsx b/src/layouts/SideMenu/index.tsx
index 4a92e1e..0b5aed6 100644
--- a/src/layouts/SideMenu/index.tsx
+++ b/src/layouts/SideMenu/index.tsx
@@ -4,12 +4,10 @@ import router, { useRouter } from '@/router';
import { history } from '@/router';
import type { MenuInfo } from 'rc-menu/lib/interface.d';
import { useMenuStatus } from './hooks';
-import { useMemo, useState } from 'react';
import { baseModel } from '@/models/base';
-import { withAuthModel } from '@/models/withAuth';
import { useModel } from '@zhangsai/model';
-import { generateMenuItems } from './utils';
import SvgIcon from '@/components/SvgIcon';
+import useStore from '@/layouts/ConsoleLayout/store';
import './index.less';
/**
@@ -17,16 +15,8 @@ import './index.less';
*/
const SideMenu = () => {
const logo = useModel(baseModel, 'logo');
- const permissions = useModel(withAuthModel, 'permissions');
- const language = useModel(baseModel, 'language');
- const { routes, flattenRoutes } = useRouter(router);
- /** 根据权限和语言生成菜单数据 */
- const menuItems = useMemo(() => {
- return generateMenuItems(routes, permissions);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [permissions, language, routes]);
- /** 展开菜单 */
- const [collapsed, setCollapsed] = useState(false);
+ const { flattenRoutes } = useRouter(router);
+ const { menuItems, collapsed, setCollapsed } = useStore();
const {
openKeys, setOpenKeys,
diff --git a/src/layouts/SideMenu/utils.ts b/src/layouts/SideMenu/utils.ts
index 12f6625..a6f6f52 100644
--- a/src/layouts/SideMenu/utils.ts
+++ b/src/layouts/SideMenu/utils.ts
@@ -14,33 +14,54 @@ export interface ItemType extends MenuItemType {
/**
* 根据RouteConfig, 生成antd Menu组件的item属性所需的数据
*/
-export function generateMenuItems(_routes: RouteConfig[], _permissions: Record, parent?: ItemType): ItemType[] {
- const ret: ItemType[] = [];
- for (let i = 0; i < _routes.length; i++) {
- const { collecttedPathname = [], icon, name, hidden, flatten, children, redirect, permission, external } = _routes[i];
- if (redirect || (permission && !_permissions?.[permission])) {
- continue;
- }
- if (flatten) {
- const menuChildren = generateMenuItems(children ?? [], _permissions, parent);
- ret.push(...menuChildren);
- continue;
- }
- const itemRet: ItemType = {
- key: collecttedPathname[collecttedPathname.length - 1], // key作为了收集的路由
- label: i18n.t(`menu:${name}`),
- icon,
- parent,
- external,
- popupClassName: 'side-menu__antd-submenu',
- };
- if (children) {
- const menuChildren = generateMenuItems(children ?? [], _permissions, itemRet);
- itemRet.children = menuChildren;
- }
- if (!hidden) {
- ret.push(itemRet);
+export function generateMenuItems(routes: RouteConfig[], permissions: Record): {
+ menuItems: ItemType[];
+ allFlattenMenuItems: Map;
+ flattenMenuItems: Map;
+} {
+ const allFlattenMenuItems: Map = new Map();
+ const flattenMenuItems: Map = new Map();
+ function _generateMenuItems(_routes: RouteConfig[], _permissions: Record, parent?: ItemType): ItemType[] {
+ const ret: ItemType[] = [];
+ for (let i = 0; i < _routes.length; i++) {
+ const { collecttedPathname = [], icon, name, hidden, flatten, children, redirect, permission, external } = _routes[i];
+ if (redirect || (permission && !_permissions?.[permission])) {
+ continue;
+ }
+ if (flatten) {
+ const menuChildren = _generateMenuItems(children ?? [], _permissions, parent);
+ ret.push(...menuChildren);
+ menuChildren.forEach(item => allFlattenMenuItems.set(item.key!, item));
+ // allFlattenMenuItems.push(...menuChildren);
+ continue;
+ }
+ const itemRet: ItemType = {
+ key: collecttedPathname[collecttedPathname.length - 1], // key作为了收集的路由
+ label: i18n.t(`menu:${name}`),
+ icon,
+ parent,
+ external,
+ popupClassName: 'side-menu__antd-submenu',
+ };
+ if (children) {
+ const menuChildren = _generateMenuItems(children ?? [], _permissions, itemRet);
+ itemRet.children = menuChildren;
+ }
+ if (!hidden) {
+ ret.push(itemRet);
+ }
+ // flattenMenuItems.push(itemRet);
+ allFlattenMenuItems.set(itemRet.key, itemRet);
+ if (!hidden) {
+ flattenMenuItems.set(itemRet.key, itemRet);
+ }
}
+ return ret;
}
- return ret;
+ const menuItems = _generateMenuItems(routes, permissions);
+ return {
+ menuItems,
+ flattenMenuItems,
+ allFlattenMenuItems,
+ };
}
diff --git a/src/layouts/Tabs/ContextMenu/const.ts b/src/layouts/Tabs/ContextMenu/const.ts
new file mode 100644
index 0000000..11abd87
--- /dev/null
+++ b/src/layouts/Tabs/ContextMenu/const.ts
@@ -0,0 +1 @@
+export const MENU_ID = 'menu-id';
diff --git a/src/layouts/Tabs/ContextMenu/index.less b/src/layouts/Tabs/ContextMenu/index.less
new file mode 100644
index 0000000..629082f
--- /dev/null
+++ b/src/layouts/Tabs/ContextMenu/index.less
@@ -0,0 +1,8 @@
+.console-layout__context-menu {
+ .anticon {
+ margin-right: 2px;
+ }
+ svg {
+ margin-right: 10px;
+ }
+}
\ No newline at end of file
diff --git a/src/layouts/Tabs/ContextMenu/index.tsx b/src/layouts/Tabs/ContextMenu/index.tsx
new file mode 100644
index 0000000..c724d0d
--- /dev/null
+++ b/src/layouts/Tabs/ContextMenu/index.tsx
@@ -0,0 +1,84 @@
+import { Menu, Item, Separator, ItemParams } from 'react-contexify';
+import { baseModel } from '@/models/base';
+import { tabsModel } from '@/models/tabs';
+import { history } from '@/router';
+import { useCloseTab } from '../useTabUtils';
+import { requestFullscreen } from '@/layouts/FullScreen/utils';
+import { ClassName__ConsoleLayout_RightSideMain } from '@/layouts/ConsoleLayout/consts';
+import { MENU_ID } from './const';
+import { useTranslation } from 'react-i18next';
+import SvgIcon from '@/components/SvgIcon';
+import { theme } from 'antd';
+import { useEffect } from 'react';
+import { setContextMenuPrimaryColor } from './style';
+
+import 'react-contexify/dist/ReactContexify.css';
+import './index.less';
+
+const ContextMenu: React.FC = () => {
+ const { t: t_layout } = useTranslation('layout');
+ const { colorPrimary } = theme.useToken().token;
+
+ function onClickRefresh() {
+ baseModel.refresh();
+ }
+
+ const closeTab = useCloseTab();
+ function onClickCloseSelf({ props }: ItemParams) {
+ closeTab(props.pathname);
+ }
+
+ function onClickCloseOther({ props }: ItemParams) {
+ tabsModel.removeOther(props.pathname);
+ history.push(props.pathname);
+ }
+
+ function onClickCloseRight({ props }: ItemParams) {
+ tabsModel.removeRight(props.pathname);
+ history.push(props.pathname);
+ }
+
+ function onClickCloseLeft({ props }: ItemParams) {
+ const removed = tabsModel.removeLeft(props.pathname);
+ // 如果右击的是当前tab的右侧,则跳转到被右击的tab
+ if (removed.some(({ key }) => key === location.pathname)) {
+ history.push(props.pathname);
+ }
+ }
+
+ function onClickFullscreen() {
+ requestFullscreen(`.${ClassName__ConsoleLayout_RightSideMain}`);
+ }
+
+ useEffect(() => {
+ setContextMenuPrimaryColor(colorPrimary);
+ }, [colorPrimary]);
+
+ return (
+
+ );
+};
+
+export default ContextMenu;
diff --git a/src/layouts/Tabs/ContextMenu/style.ts b/src/layouts/Tabs/ContextMenu/style.ts
new file mode 100644
index 0000000..eb1023f
--- /dev/null
+++ b/src/layouts/Tabs/ContextMenu/style.ts
@@ -0,0 +1,7 @@
+import { setCssVar } from '@/utils/setCssVar';
+
+export function setContextMenuPrimaryColor(color: string) {
+ setCssVar({
+ '--contexify-activeItem-bgColor': color,
+ });
+}
diff --git a/src/layouts/Tabs/ContextMenu/useContextMenu.ts b/src/layouts/Tabs/ContextMenu/useContextMenu.ts
new file mode 100644
index 0000000..275118c
--- /dev/null
+++ b/src/layouts/Tabs/ContextMenu/useContextMenu.ts
@@ -0,0 +1,29 @@
+import { useContextMenu as useContextMenuByReactContexify } from 'react-contexify';
+import { ItemType } from '@/layouts/SideMenu/utils';
+import { MouseEvent } from 'react';
+import { MENU_ID } from './const';
+
+interface Params {
+ item?: ItemType;
+ pathname: string;
+}
+
+export function useContextMenu({ item, pathname }: Params) {
+ const { show } = useContextMenuByReactContexify({
+ id: MENU_ID,
+ });
+
+ function onContextMenu(event: MouseEvent) {
+ show({
+ event,
+ props: {
+ item,
+ pathname,
+ },
+ });
+ }
+
+ return {
+ onContextMenu,
+ };
+}
diff --git a/src/layouts/Tabs/FixAntdTabTranslate/index.less b/src/layouts/Tabs/FixAntdTabTranslate/index.less
new file mode 100644
index 0000000..5de9804
--- /dev/null
+++ b/src/layouts/Tabs/FixAntdTabTranslate/index.less
@@ -0,0 +1,5 @@
+.console-layout-fix-antd-tab-translate {
+ position: absolute;
+ visibility: hidden;
+ pointer-events: none;
+}
diff --git a/src/layouts/Tabs/FixAntdTabTranslate/index.tsx b/src/layouts/Tabs/FixAntdTabTranslate/index.tsx
new file mode 100644
index 0000000..7a633b5
--- /dev/null
+++ b/src/layouts/Tabs/FixAntdTabTranslate/index.tsx
@@ -0,0 +1,24 @@
+import { JSXElementConstructor, PropsWithChildren, ReactElement } from 'react';
+import './index.less';
+
+interface Props {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ node: ReactElement>;
+}
+
+/**
+ * 自定义tab的包裹容器
+ * 用于继承antd tab的位移定位功能
+ */
+const FixAntdTabTranslate = ({ node, children }: PropsWithChildren) => {
+ return (
+
+ {children}
+
+ {node}
+
+
+ );
+};
+
+export default FixAntdTabTranslate;
diff --git a/src/layouts/Tabs/TabChrome/index.less b/src/layouts/Tabs/TabChrome/index.less
new file mode 100644
index 0000000..1343260
--- /dev/null
+++ b/src/layouts/Tabs/TabChrome/index.less
@@ -0,0 +1,25 @@
+.console-layout-tab__label {
+ margin-left: 6px;
+}
+
+.console-layout-tab__icon {
+ display: flex;
+ align-items: center;
+}
+
+.console-layout-tab__close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ // padding: 2px;
+ margin-left: 6px;
+ &:hover {
+ border-radius: 50%;
+ cursor: pointer;
+ }
+ .anticon {
+ font-size: 12px;
+ }
+}
diff --git a/src/layouts/Tabs/TabChrome/index.tsx b/src/layouts/Tabs/TabChrome/index.tsx
new file mode 100644
index 0000000..4a344e5
--- /dev/null
+++ b/src/layouts/Tabs/TabChrome/index.tsx
@@ -0,0 +1,157 @@
+import useDraggable, { DraggableTabPaneProps } from '../useDraggable';
+import useStore from '@/layouts/ConsoleLayout/store';
+import classNames from 'classnames';
+import { useLocation } from 'react-router-dom';
+import { useContextMenu } from '../ContextMenu/useContextMenu';
+import { history } from '@/router';
+import { useCloseTab } from '../useTabUtils';
+import styled from 'styled-components';
+import Hover from '@/components/Hover';
+import { useModel } from '@zhangsai/model';
+import { JSXElementConstructor, MouseEvent, ReactElement, useMemo } from 'react';
+import FixAntdTabTranslate from '../FixAntdTabTranslate';
+import { omit } from '@/utils';
+import router from '@/router';
+import { tabsModel } from '@/models/tabs';
+import SvgIcon from '@/components/SvgIcon';
+import './index.less';
+
+const WrapDiv = styled.div<{
+ $isActive: boolean;
+ $isHovering: boolean;
+ $darkMode: boolean;
+ $colorPrimaryBgHover: string;
+ $colorPrimaryText: string;
+ $colorBgLayout: string;
+ $colorBgContainer: string;
+}>`
+ display: flex;
+ align-items: center;
+ padding: 4px 8px;
+ border-radius: 10px;
+ cursor: pointer;
+ margin-right: 4px;
+ margin-left: 4px;
+ background: ${props => props.$colorBgLayout};
+ &.isHovering {
+ background: ${props => props.$colorPrimaryBgHover};
+ border-radius: 10px;
+ }
+ &.isActive {
+
+ position: relative;
+ background-color: ${props => props.$colorBgContainer};
+ box-shadow: 0px 8px 0px 0px ${props => props.$colorBgContainer}, 0 8px 0 0 ${props => props.$colorBgContainer};
+ border-radius: 10px 10px 0 0;
+ margin-bottom: 4px;
+
+ &:before, &:after {
+ position: absolute;
+ bottom: -4px;
+ content: '';
+ width: 20px;
+ height: 20px;
+ border-radius: 100%;
+ box-shadow: 0 0 0 40px #fff;
+ }
+
+ &:before {
+ left: -20px;
+ clip-path: inset(50% 0 0 50%);
+ }
+ &:after {
+ right: -20px;
+ clip-path: inset(50% 50% 0 0);
+ }
+ }
+`;
+
+interface Props extends DraggableTabPaneProps {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ node: ReactElement>;
+}
+
+const TabChrome = (props: Props) => {
+ const propsMenuKey = props['data-node-key'];
+ const tabItems = useModel(tabsModel, 'items');
+ const tabItem = useMemo(() => {
+ return tabItems.find(item => item.key === propsMenuKey);
+ }, [propsMenuKey, tabItems]);
+ const location = useLocation();
+ const { draggableProps, isDragging } = useDraggable(props);
+ const memoedDraggableProps = useMemo(() => {
+ return omit(draggableProps, ['key']);
+ }, [draggableProps]);
+ const isActive = location.pathname === propsMenuKey;
+ const colorPrimaryText = '#1677ff';
+ const colorText = 'rgba(0, 0, 0, 0.88)';
+ const colorBgTextHover = 'rgba(0, 0, 0, 0.06)';
+ const colorBgContainer = '#fff';
+ const colorBgLayout = '#edeff0';
+ const curDarkMode = false;
+
+ const tabsIconShow = true;
+
+ const { allFlattenMenuItems } = useStore();
+ const propsRoutePath = router.getRoutePath(propsMenuKey);
+ const menuItem = allFlattenMenuItems.get(propsRoutePath);
+ const { onContextMenu } = useContextMenu({
+ item: menuItem,
+ pathname: propsMenuKey,
+ });
+
+ function onClickTab() {
+ history.push(propsMenuKey);
+ }
+
+ const closeTab = useCloseTab(propsMenuKey);
+ function onClickClose(e: MouseEvent) {
+ e.stopPropagation();
+ closeTab();
+ }
+
+ return (
+
+ {(isHovering: boolean) => (
+
+ {tabsIconShow && (
+ {menuItem?.icon}
+ )}
+ {tabItem?.label ? `${tabItem.label} - ${menuItem?.label}` : menuItem?.label}
+ {(isHovering: boolean) => (
+
+
+
+ )}
+
+ )}
+
+ );
+};
+
+export default TabChrome;
diff --git a/src/layouts/Tabs/index.less b/src/layouts/Tabs/index.less
new file mode 100644
index 0000000..21864ca
--- /dev/null
+++ b/src/layouts/Tabs/index.less
@@ -0,0 +1,14 @@
+.console-layout-tabs {
+ padding: 8px 8px 0 16px;
+ user-select: none;
+ &.CHROME {
+ .ant-tabs-top > .ant-tabs-nav {
+ &:before {
+ border-bottom: none;
+ }
+ }
+ }
+ .ant-tabs-nav {
+ margin-bottom: 0;
+ }
+}
diff --git a/src/layouts/Tabs/index.tsx b/src/layouts/Tabs/index.tsx
new file mode 100644
index 0000000..510f73b
--- /dev/null
+++ b/src/layouts/Tabs/index.tsx
@@ -0,0 +1,133 @@
+import { Tabs as AntdTabs } from 'antd';
+import { useEffect, useMemo } from 'react';
+import useStore from '../ConsoleLayout/store';
+import { TabItem, tabsModel } from '@/models/tabs';
+import { useModel } from '@zhangsai/model';
+import { ItemType } from '../SideMenu/utils';
+import { history } from '@/router';
+import { DndContext, DragEndEvent, PointerSensor, useSensor } from '@dnd-kit/core';
+import { SortableContext, arrayMove, horizontalListSortingStrategy } from '@dnd-kit/sortable';
+import TabChrome from './TabChrome';
+import { useContextMenu } from './ContextMenu/useContextMenu';
+import ContextMenu from './ContextMenu';
+import { useLocation } from 'react-router-dom';
+import router from '@/router';
+import { getUrlQuery } from '@/utils';
+import { tab_title } from '@/consts';
+import './index.less';
+
+interface Props {
+ item: ItemType;
+ label?: string;
+ pathname: string;
+ tabsIconShow?: boolean;
+}
+
+export const Label: React.FC = ({ item, label, pathname, tabsIconShow }) => {
+ const { onContextMenu } = useContextMenu({
+ item,
+ pathname,
+ });
+ return (
+
+ {tabsIconShow ? item.icon : ''} {label ? `${label} - ${item.label}` : item.label}
+
+ );
+};
+
+function getTabsItemsByMenuItemKey(items: TabItem[], allFlattenMenuItems: Map, tabsIconShow: boolean) {
+ return items.filter(({ key }) => {
+ const routePath = router.getRoutePath(key);
+ return allFlattenMenuItems.get(routePath);
+ }).map(({ key, label }) => {
+ const routePath = router.getRoutePath(key);
+ const menuItem = allFlattenMenuItems.get(routePath)!;
+ return {
+ key,
+ label: ,
+ };
+ });
+}
+
+const Tabs = () => {
+ const items = useModel(tabsModel, 'items');
+ const { allFlattenMenuItems } = useStore();
+ const tabsItems = useMemo(() => {
+ return getTabsItemsByMenuItemKey(items, allFlattenMenuItems, true);
+ }, [allFlattenMenuItems, items]);
+ const location = useLocation();
+ const { ref3 } = useStore();
+
+ function onChange(activeKey: string) {
+ history.push(activeKey);
+ }
+
+ function onEdit(
+ targetKey: React.MouseEvent | React.KeyboardEvent | string,
+ action: 'add' | 'remove',
+ ) {
+ if (action === 'remove') {
+ const isSelf = location.pathname === targetKey;
+ const nextTab = tabsModel.removeTab(targetKey as string, isSelf);
+ if (isSelf && nextTab) {
+ history.push(nextTab.key);
+ }
+ }
+ }
+
+ useEffect(() => {
+ const url_tab_title = getUrlQuery(tab_title);
+ const tabItem = {
+ key: location.pathname,
+ label: url_tab_title ?? '',
+ };
+ tabsModel.add(tabItem);
+ }, [location.pathname]);
+
+ const sensor = useSensor(PointerSensor, {
+ activationConstraint: { distance: 10 },
+ });
+
+ function onDragEnd({ active, over }: DragEndEvent) {
+ if (active.id !== over?.id) {
+ const activeIndex = items.findIndex((item) => item.key === active.id);
+ const overIndex = items.findIndex((item) => item.key === over?.id);
+ const ret = arrayMove(items, activeIndex, overIndex);
+ tabsModel.set({ items: ret });
+ }
+ }
+
+ return (
+
+
(
+
+ i.key)} // onDragEnd -> active.id/over?.id
+ strategy={horizontalListSortingStrategy}
+ >
+
+ {(node) => {
+ return ;
+ }}
+
+
+
+ )}
+ />
+
+
+ );
+};
+
+export default Tabs;
diff --git a/src/layouts/Tabs/useDraggable.ts b/src/layouts/Tabs/useDraggable.ts
new file mode 100644
index 0000000..c79dc31
--- /dev/null
+++ b/src/layouts/Tabs/useDraggable.ts
@@ -0,0 +1,35 @@
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+
+export interface DraggableTabPaneProps extends React.HTMLAttributes {
+ 'data-node-key': string;
+}
+
+const useDraggable = ({ ...props }: DraggableTabPaneProps) => {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
+ useSortable({
+ id: props['data-node-key'],
+ transition: null,
+ });
+
+ const style: React.CSSProperties = {
+ ...props.style,
+ transform: CSS.Transform.toString(transform && { ...transform, y: 0, scaleX: 1 }),
+ transition: isDragging ? 'none' : transition,
+ cursor: isDragging ? 'grabbing' : 'default',
+ zIndex: isDragging ? 2 : 1,
+ };
+
+ return {
+ draggableProps: {
+ key: props['data-node-key'],
+ ref: setNodeRef,
+ style,
+ ...attributes,
+ ...listeners,
+ },
+ isDragging,
+ };
+};
+
+export default useDraggable;
diff --git a/src/layouts/Tabs/useTabUtils.ts b/src/layouts/Tabs/useTabUtils.ts
new file mode 100644
index 0000000..fba489d
--- /dev/null
+++ b/src/layouts/Tabs/useTabUtils.ts
@@ -0,0 +1,19 @@
+import { tabsModel } from '@/models/tabs';
+import { history } from '@/router';
+import { useCallback } from 'react';
+import { useLocation } from 'react-router-dom';
+
+export function useCloseTab(menuKey?: string) {
+ const location = useLocation();
+
+ const closeTab = useCallback((_menuKey?: string) => {
+ const finallyMenuKey = menuKey ?? _menuKey;
+ if (!finallyMenuKey) return;
+ const nextTab = tabsModel.removeTab(finallyMenuKey, location.pathname === finallyMenuKey);
+ if (/** menuItem?.key === finallyMenuKey && */nextTab) {
+ history.push(nextTab.key);
+ }
+ }, [location.pathname, menuKey]);
+
+ return closeTab;
+}
diff --git a/src/models/tabs/index.ts b/src/models/tabs/index.ts
new file mode 100644
index 0000000..64560fb
--- /dev/null
+++ b/src/models/tabs/index.ts
@@ -0,0 +1,109 @@
+import { Model, INITIAL_STATE, Persist, persist } from '@zhangsai/model';
+import router from '@/router';
+
+export interface TabItem {
+ key: string;
+ label?: string;
+}
+
+export class InitialState extends INITIAL_STATE {
+ @persist
+ items: TabItem[] = [];
+}
+
+@Persist()
+class TabsModel extends Model {
+ static className?: string = 'tabs';
+ constructor(initialState: InitialState) {
+ super(initialState);
+ }
+ init() {}
+ destroy() {}
+
+ set(state: Partial | ((draft: InitialState) => void)) {
+ this.setState(state);
+ }
+
+ /** 新增tab */
+ add(newItem: TabItem) {
+ const routePath = router.getRoutePath(newItem.key);
+ const route = router.flattenRoutes.get(routePath);
+ // redirect 路由不add
+ if (route?.redirect) {
+ return;
+ }
+ const { items } = this.state;
+ if (!items?.find(item => item.key === newItem.key)) {
+ this.setState(draft => {
+ draft.items.push(newItem);
+ });
+ }
+ }
+
+ /**
+ * @returns 删除后应该去往的tab
+ * 1. 删别人
+ * 返回null
+ * 2. 删自己
+ * 删除最后一个,返回被删的前一个
+ * 删除非最后一个,返回被删的下一个
+ * 删的不存在返回null
+ */
+ /**
+ *
+ * @param key
+ * @param isSelf
+ *
+ */
+ removeTab(key: string, isSelf: boolean) {
+ if (!isSelf) {
+ this.setState(draft => {
+ draft.items = draft.items.filter((item) => item.key !== `${key}`);
+ });
+ return null;
+ }
+
+ let nextIndex = -1;
+ const { items } = this.state;
+ if (items.length === 1) return items[0];
+ this.setState(draft => {
+ draft.items = draft.items.filter((item, _index) => {
+ const pass = item.key !== `${key}`;
+ if (!pass) {
+ nextIndex = (_index === items.length - 1) ?
+ (_index - 1) :
+ (_index + 0);
+ }
+ return pass;
+ });
+ });
+ if (nextIndex === -1) {
+ return null;
+ } else {
+ return this.state.items[nextIndex];
+ }
+ }
+ removeOther(key: string) {
+ this.setState(draft => {
+ draft.items = draft.items.filter((item) => {
+ return item.key === `${key}`;
+ });
+ });
+ }
+ removeRight(key: string) {
+ this.setState(draft => {
+ const index = draft.items.findIndex(item => item.key === key);
+ draft.items.splice(index + 1, draft.items.length - index - 1);
+ });
+ }
+ removeLeft(key: string) {
+ let removed: TabItem[] = [];
+ this.setState(draft => {
+ const index = draft.items.findIndex(item => item.key === key);
+ removed = JSON.parse(JSON.stringify(draft.items.splice(0, index)));
+ });
+ return removed;
+ }
+}
+
+export const tabsModel = new TabsModel(new InitialState());
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 27e4d5f..ceb9461 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -28,3 +28,46 @@ export function getRandomString(options?: {
const stringChar = Math.random().toString(36).slice(-length);
return `${prefixChar}${timestampChar}${stringChar}`;
}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function omit, K extends keyof T>(source: T, keys: K[]): Omit {
+ // return Object.keys(source).reduce((target: Partial, nowKey: K) => {
+ // if (!keys.includes(nowKey)) {
+ // target[nowKey] = source[nowKey];
+ // }
+ // return target;
+ // }, {} as Partial);
+ const ret = {} as Omit;
+ Object.keys(source).forEach(key => {
+ if (!(keys as string[]).includes(key)) {
+ // @ts-expect-error pass
+ ret[key] = source[key];
+ }
+ });
+ return ret;
+}
+
+export function omitBy>(
+ source: T,
+ filterFn: (v: unknown) => boolean,
+) {
+ return Object.keys(source).reduce((target: T, nowKey: keyof T) => {
+ if (!filterFn(source[nowKey])) target[nowKey] = source[nowKey];
+ return target;
+ }, {} as T);
+}
+
+export function getUrlQuery(key: string): string | undefined;
+export function getUrlQuery(): Record;
+export function getUrlQuery(key?: string) {
+ const queryStr = window.location.search.replace(/^\?/, '');
+ const queryAry = queryStr ? queryStr.split('&').map(item => item.split('=')) : [];
+ // @ts-expect-error pass
+ const queryMap = new Map(queryAry);
+ if (key) {
+ const val = queryMap.get(key);
+ return val ? decodeURIComponent(val) : undefined;
+ } else {
+ return Object.fromEntries(queryMap);
+ }
+}
diff --git a/src/utils/setCssVar.ts b/src/utils/setCssVar.ts
new file mode 100644
index 0000000..63e29a8
--- /dev/null
+++ b/src/utils/setCssVar.ts
@@ -0,0 +1,5 @@
+export function setCssVar(vars: Record) {
+ Object.keys(vars).forEach(key => {
+ document.documentElement.style.setProperty(key, vars[key]);
+ });
+}
diff --git a/vite.config.ts b/vite.config.ts
index add039e..119c613 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -37,5 +37,65 @@ export default defineConfig({
},
build: {
target: 'es2015',
+ rollupOptions: {
+ output: {
+ manualChunks(id/* , { getModuleInfo } */) {
+ if (id.includes('node_modules/react')) {
+ return 'vendor';
+ }
+ // if (id.includes('node_modules/echarts')) {
+ // return 'echarts';
+ // }
+ if (id.includes('node_modules/antd')) {
+ return 'antd';
+ }
+ const ret = resolveManualChunks(id, {
+ echarts: [
+ 'node_modules/echarts',
+ ],
+ svg: [
+ 'src/assets/svg',
+ ],
+ common: [
+ 'main.tsx',
+ 'App.tsx',
+ ['src/router/', '!/config/'],
+ 'src/layouts/',
+ 'src/http/',
+ 'src/lib/',
+ ],
+ pages: [
+ 'src/pages/',
+ ],
+ });
+ return ret;
+ },
+ },
+ },
},
});
+
+function resolveManualChunks(id: string, configs: Record): string | void {
+ for (const key in configs) {
+ const val = configs[key];
+ for (let i = 0; i < val.length; i++) {
+ const item = val[i];
+ if (typeof item === 'string') {
+ if (id.includes(item)) {
+ return key;
+ }
+ } else {
+ const condition = item.every(innerItem => {
+ if (innerItem.startsWith('!')) {
+ return !id.includes(innerItem.slice(1));
+ } else {
+ return id.includes(innerItem);
+ }
+ });
+ if (condition) {
+ return key;
+ }
+ }
+ }
+ }
+}