xyh 2 лет назад
Родитель
Сommit
845ba20691

+ 1 - 0
packages/app/src/components/footer/index.module.css

@@ -10,4 +10,5 @@
 .footer {
   padding: 0 50px 24px !important;
   margin-top: auto;
+  background-color: #fff !important;
 }

+ 0 - 1
packages/app/src/index.tsx

@@ -48,6 +48,5 @@ root.render(
         </Suspense>
       </ConfigProvider>
     </StrictMode>
-    <ReactQueryDevtools initialIsOpen={false} position='bottom-right' />
   </QueryClientProvider>,
 );

+ 63 - 0
packages/app/src/pages/home/context.ts

@@ -0,0 +1,63 @@
+import {tabStore} from '@stores';
+import {useReducer} from 'react';
+import {createContext} from 'use-context-selector';
+
+type State = {
+  key: string;
+  url: string;
+  label: string;
+}[];
+
+type Action =
+  | {type: 'ADD'; payload: State[0]}
+  | {type: 'REMOVE'; payload: string}
+  | {type: 'CLEAR'};
+
+const defaultTab: State[0] = {key: '-1', url: '/main', label: '首页'};
+
+function reducer(state: State, action: Action): State {
+  const {type} = action;
+  const {key, dispatch} = tabStore.getState();
+
+  switch (type) {
+    case 'ADD': {
+      const {payload} = action;
+
+      dispatch(payload.key);
+
+      const exist = state.find(val => val.key === payload.key);
+      if (exist) {
+        return state;
+      }
+
+      return [...state, payload];
+    }
+    case 'REMOVE': {
+      const {payload} = action;
+      const idx = state.findIndex(val => val.key === payload);
+      if (idx < 0) return state;
+
+      key === state[idx].key && dispatch(state[idx - 1].key);
+
+      const nextState = [...state];
+      nextState.splice(idx, 1);
+      return nextState;
+    }
+    case 'CLEAR': {
+      dispatch('-1');
+
+      return [{...defaultTab}];
+    }
+    default:
+      return state;
+  }
+}
+
+export function useContextReudcer() {
+  return useReducer(reducer, [defaultTab]);
+}
+
+export const context = createContext<ReturnType<typeof useContextReudcer>>([
+  [],
+  () => null,
+]);

+ 1 - 0
packages/app/src/pages/home/index.module.css

@@ -12,6 +12,7 @@
 
 .logo {
   z-index: 2;
+  cursor: pointer;
 
   & img {
     width: 100px;

+ 24 - 12
packages/app/src/pages/home/index.tsx

@@ -4,20 +4,35 @@ import {Layout} from 'antd';
 import logo from '@assets/images/logo.png';
 import Menu from './menu';
 import User from './user';
-import {Link, Outlet} from 'react-router-dom';
-import {Auth, ErrorBoundary, Footer} from '@components';
+import {Auth, ErrorBoundary} from '@components';
 import HomeSkeleton from './Skeleton';
-import {HOME_PATH} from '@routes';
 import BtnGroup from './btn-group';
 import FullScreenBtn from './fullscreen';
+import Main from './main';
+import {ChildrenFC} from '@utils';
+import {context, useContextReudcer} from './context';
+import {useStore} from 'zustand';
+import {tabStore} from '@stores';
+
+const TabProvider: ChildrenFC = function ({children}) {
+  const state = useContextReudcer();
+  const {Provider} = context;
+
+  return <Provider value={state}>{children}</Provider>;
+};
 
 const Home: FC = function () {
+  const dispatch = useStore(tabStore, state => state.dispatch);
+  function toHome() {
+    dispatch('-1');
+  }
+
   return (
     <Layout className='container'>
       <Layout.Header className={css.header}>
-        <Link to={HOME_PATH} className={css.logo}>
+        <section onClick={toHome} className={css.logo}>
           <img src={logo} alt='logo' />
-        </Link>
+        </section>
         <section className={css.btnGroup}>
           <BtnGroup />
           <User />
@@ -26,13 +41,10 @@ const Home: FC = function () {
       </Layout.Header>
 
       <Layout hasSider>
-        <Menu />
-        <Layout.Content className='layout-content-custom'>
-          <Suspense>
-            <Outlet />
-            <Footer color='#666' />
-          </Suspense>
-        </Layout.Content>
+        <TabProvider>
+          <Menu />
+          <Main />
+        </TabProvider>
       </Layout>
     </Layout>
   );

+ 46 - 0
packages/app/src/pages/home/main/hooks.tsx

@@ -0,0 +1,46 @@
+import {useContextSection} from '@hooks';
+import {context} from '../context';
+import {TabPaneProps, TabsProps} from 'antd';
+import {ReactNode} from 'react';
+import css from './index.module.css';
+import {useStore} from 'zustand';
+import {tabStore} from '@stores';
+
+export type Tab = {
+  key: string;
+  label: ReactNode;
+} & Omit<TabPaneProps, 'tab'>;
+
+export function useTabItems() {
+  const {host} = location;
+
+  return useContextSection(context, function ([tabs]) {
+    return tabs.map<Tab>(function (tab) {
+      return {
+        ...tab,
+        closable: tab.key !== '-1',
+        animated: true,
+        children: (
+          <iframe src={`http://${host}${tab.url}`} className={css.iframe} />
+        ),
+      };
+    });
+  });
+}
+
+export function useTabActive() {
+  const dispatch = useContextSection(context, state => state[1]);
+  const {key: activeKey, dispatch: setActiveKey} = useStore(tabStore);
+
+  function onClear() {
+    dispatch({type: 'CLEAR'});
+  }
+
+  const onEdit: TabsProps['onEdit'] = function (target, action) {
+    if (action === 'remove' && typeof target === 'string') {
+      dispatch({type: 'REMOVE', payload: target});
+    }
+  };
+
+  return [activeKey, {onChange: setActiveKey, onClear, onEdit}] as const;
+}

+ 31 - 0
packages/app/src/pages/home/main/index.module.css

@@ -0,0 +1,31 @@
+.clear-btn {
+  margin: 0 12px;
+}
+
+.tabs {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding-top: 2px;
+
+  & :global(.ant-tabs-nav) {
+    padding-left: 2px;
+    margin-bottom: 0 !important;
+  }
+
+  & :global(.ant-tabs-content-holder) {
+    flex: 1;
+    overflow: hidden;
+  }
+
+  & :global(.ant-tabs-content),
+  & :global(.ant-tabs-tabpane) {
+    height: 100%;
+  }
+}
+
+.iframe {
+  width: 100%;
+  height: 100%;
+  border: unset;
+}

+ 31 - 0
packages/app/src/pages/home/main/index.tsx

@@ -0,0 +1,31 @@
+import {Button, Layout, Tabs} from 'antd';
+import {FC} from 'react';
+import css from './index.module.css';
+import {useTabActive, useTabItems} from './hooks';
+
+const Main: FC = function () {
+  const tabItems = useTabItems();
+  const [activeKey, {onChange, onClear, onEdit}] = useTabActive();
+
+  return (
+    <Layout.Content className='layout-content-custom'>
+      <Tabs
+        size='small'
+        hideAdd
+        activeKey={activeKey}
+        onChange={onChange}
+        type='editable-card'
+        items={tabItems}
+        className={css.tabs}
+        onEdit={onEdit}
+        tabBarExtraContent={
+          <Button danger type='link' className={css.clearBtn} onClick={onClear}>
+            清除
+          </Button>
+        }
+      />
+    </Layout.Content>
+  );
+};
+
+export default Main;

+ 25 - 0
packages/app/src/pages/home/menu/hooks.tsx

@@ -1,4 +1,5 @@
 import {getRoleMenu} from '@apis';
+import {useContextSection} from '@hooks';
 import {HOME_PATH} from '@routes';
 import {menuStore} from '@stores';
 import {useQuery} from '@tanstack/react-query';
@@ -7,6 +8,7 @@ import {useBoolean, useLocalStorageState} from 'ahooks';
 import {useEffect, useState} from 'react';
 import {useLocation, useNavigate} from 'react-router-dom';
 import {useStore} from 'zustand';
+import {context} from '../context';
 
 export function useMenu() {
   const setMenus = useStore(menuStore, state => state.setMenu);
@@ -94,6 +96,29 @@ export function useOpenKey(menus: ParseMenuType[]) {
   ] as const;
 }
 
+export function useMenuState() {
+  const [openKeys, setOpenKey] = useState<string[]>(['-1']);
+
+  function onOpenChange(keys: string[]) {
+    setOpenKey([keys[keys.length - 1]]);
+  }
+
+  const pages = useStore(menuStore, state => state.menus);
+  const dispatch = useContextSection(context, state => state[1]);
+  function onClick(e: {key: string; keyPath: string[]}) {
+    setOpenKey(e.keyPath);
+    const data = pages.find(val => val.id === e.key);
+    if (!data) return;
+
+    dispatch({
+      type: 'ADD',
+      payload: {key: data.id, url: data.url, label: data.name},
+    });
+  }
+
+  return [{openKeys}, {onOpenChange, onClick}] as const;
+}
+
 export function useCollapsedMenu() {
   const [storage, setStorage] = useLocalStorageState<'0' | '1' | undefined>(
     MENU_COLLAPSED_STORAGE,

+ 1 - 0
packages/app/src/pages/home/menu/index.module.css

@@ -4,6 +4,7 @@
   overflow: hidden;
   background-color: #fff !important;
   border: unset !important;
+  border-right: 1px solid #f5f5f5 !important;
 
   & :global(.ant-layout-sider-children) {
     display: flex;

+ 4 - 4
packages/app/src/pages/home/menu/index.tsx

@@ -1,6 +1,6 @@
 import {FC} from 'react';
 import {Menu as AntdMenu, Layout} from 'antd';
-import {useCollapsedMenu, useMenu, useOpenKey} from './hooks';
+import {useCollapsedMenu, useMenu, useMenuState} from './hooks';
 import css from './index.module.css';
 import {MenuUnfoldOne} from '@icon-park/react';
 import classNames from 'classnames';
@@ -9,7 +9,7 @@ import lottieData from '@assets/json/wave.json';
 
 const Menu: FC = function () {
   const menus = useMenu();
-  const [{openKeys, current}, {onOpenChange, onClick}] = useOpenKey(menus);
+  const [{openKeys}, {onOpenChange, onClick}] = useMenuState();
   const [collapsed, toggle] = useCollapsedMenu();
 
   return (
@@ -33,11 +33,11 @@ const Menu: FC = function () {
         data-testid='menu'
         mode='inline'
         items={menus}
+        defaultSelectedKeys={['-1']}
         onOpenChange={onOpenChange}
         openKeys={openKeys}
-        onClick={onClick}
-        selectedKeys={current}
         className={css.sliderMenus}
+        onClick={onClick}
       />
 
       <Lottie animationData={lottieData} className={css.lottie} />

+ 37 - 39
packages/app/src/routes/index.tsx

@@ -85,46 +85,44 @@ export const routes: RouteObject[] = [
   {
     path: HOME_PATH,
     element: <Home />,
-    children: [
-      {path: MAIN_PATH, element: <Main />},
-      {path: MENU_PATH, element: <Menu />},
-      {path: ROLE_PATH, element: <Role />},
-      {path: USER_PATH, element: <User />},
-      {path: STORAGE_PATH, element: <Storage />},
-      {path: GOODS_PATH, element: <Goods />},
-      {path: PDA_MENU_PATH, element: <Pda />},
-      {path: CONTAINER_PATH, element: <Container />},
-      {path: MATTER_PATH, element: <Matter />},
-      {path: QUALITY_PATH, element: <Quality />},
-      {path: RAW_IN_STREAM_PATH, element: <RawInStream />},
-      {path: RAW_OUT_STREAM_PATH, element: <RawOutStream />},
-      {path: SEMI_REPORT_PATH, element: <SemiReport />},
-      {path: SEMI_DRAW_PATH, element: <SemiDraw />},
-      {path: SEMI_IN_STREAM_PATH, element: <SemiInStream />},
-      {path: SEMI_OUT_STREAM_PATH, element: <SemiOutStream />},
-      {path: FINISH_PRODUCT_IN_STREAM_PATH, element: <ProductInStream />},
-      {path: FINISH_PRODUCT_OUT_STREAM_PATH, element: <ProductOutStream />},
-      {path: DEAD_PRODUCT_PATH, element: <DeadProduct />},
-      {path: RESERVE_WARNING_PATH, element: <ReserveWarning />},
-      {path: PURCHASE_PATH, element: <PurchaseOrder />},
-      {path: MATERIAL_BIND_PATH, element: <MaterialBind />},
-      {path: STOCK_PATH, element: <Stock />},
-      {path: DICTIONARY_PATH, element: <Dictionary />},
-      {path: GS_INTERFACE_LOG_PATH, element: <GSInterfaceLog />},
-      {
-        path: PRODUCTION_REQUISITION_PATH,
-        element: <ProductionRequisitionOrder />,
-      },
-      {path: SELL_ORDER_PATH, element: <SellOrder />},
-      {path: RELOCAT_ORDER_PATH, element: <RelcationOrder />},
-      {path: PRODUCT_REPORT_PATH, element: <ProductReport />},
-      {path: ORDER_LOG_PATH, element: <OrderDeleteLog />},
-      {path: INVENTORY_PATH, element: <Inventory />},
-      {path: GS_ERROR_LOG_PATH, element: <GSErrorLog />},
-      {path: OTHER_IN_PATH, element: <OtherIn />},
-      {path: OTHER_OUT_PATH, element: <OtherOut />},
-    ],
   },
+  {path: MAIN_PATH, element: <Main />},
+  {path: MENU_PATH, element: <Menu />},
+  {path: ROLE_PATH, element: <Role />},
+  {path: USER_PATH, element: <User />},
+  {path: STORAGE_PATH, element: <Storage />},
+  {path: GOODS_PATH, element: <Goods />},
+  {path: PDA_MENU_PATH, element: <Pda />},
+  {path: CONTAINER_PATH, element: <Container />},
+  {path: MATTER_PATH, element: <Matter />},
+  {path: QUALITY_PATH, element: <Quality />},
+  {path: RAW_IN_STREAM_PATH, element: <RawInStream />},
+  {path: RAW_OUT_STREAM_PATH, element: <RawOutStream />},
+  {path: SEMI_REPORT_PATH, element: <SemiReport />},
+  {path: SEMI_DRAW_PATH, element: <SemiDraw />},
+  {path: SEMI_IN_STREAM_PATH, element: <SemiInStream />},
+  {path: SEMI_OUT_STREAM_PATH, element: <SemiOutStream />},
+  {path: FINISH_PRODUCT_IN_STREAM_PATH, element: <ProductInStream />},
+  {path: FINISH_PRODUCT_OUT_STREAM_PATH, element: <ProductOutStream />},
+  {path: DEAD_PRODUCT_PATH, element: <DeadProduct />},
+  {path: RESERVE_WARNING_PATH, element: <ReserveWarning />},
+  {path: PURCHASE_PATH, element: <PurchaseOrder />},
+  {path: MATERIAL_BIND_PATH, element: <MaterialBind />},
+  {path: STOCK_PATH, element: <Stock />},
+  {path: DICTIONARY_PATH, element: <Dictionary />},
+  {path: GS_INTERFACE_LOG_PATH, element: <GSInterfaceLog />},
+  {
+    path: PRODUCTION_REQUISITION_PATH,
+    element: <ProductionRequisitionOrder />,
+  },
+  {path: SELL_ORDER_PATH, element: <SellOrder />},
+  {path: RELOCAT_ORDER_PATH, element: <RelcationOrder />},
+  {path: PRODUCT_REPORT_PATH, element: <ProductReport />},
+  {path: ORDER_LOG_PATH, element: <OrderDeleteLog />},
+  {path: INVENTORY_PATH, element: <Inventory />},
+  {path: GS_ERROR_LOG_PATH, element: <GSErrorLog />},
+  {path: OTHER_IN_PATH, element: <OtherIn />},
+  {path: OTHER_OUT_PATH, element: <OtherOut />},
   {path: NO_PERMISSION_PATH, element: <NoPermision />},
   {path: '*', element: <NotFound />},
 ];

+ 1 - 1
packages/app/src/routes/name.ts

@@ -1,6 +1,6 @@
 /** 首页 */
 export const HOME_PATH = '/';
-export const MAIN_PATH = '/';
+export const MAIN_PATH = '/main';
 /** 登录页面 */
 export const LOGIN_PATH = '/login';
 /** 404 */

+ 1 - 0
packages/app/src/stores/index.ts

@@ -2,3 +2,4 @@ export type ZustandDevtools = [['zustand/devtools', never]];
 
 export * from './user';
 export * from './menu';
+export * from './tab';

+ 17 - 0
packages/app/src/stores/tab.ts

@@ -0,0 +1,17 @@
+import {createStore} from 'zustand/vanilla';
+
+type State = {key: string};
+type Action = {
+  dispatch: (key: string | ((prev: string) => string)) => void;
+};
+
+export const tabStore = createStore<State & Action>(function (set) {
+  return {
+    key: '-1',
+    dispatch(key) {
+      set(function (prev) {
+        return {key: typeof key === 'string' ? key : key(prev.key)};
+      });
+    },
+  };
+});

+ 1 - 0
packages/app/src/styles/index.css

@@ -88,6 +88,7 @@ img {
 .layout-content-custom {
   display: flex;
   flex-direction: column;
+  background-color: #fff;
 }
 
 .flex-center {