Procházet zdrojové kódy

feat(menu): dynamic router

Next xyh
Lind před 3 roky
rodič
revize
2c67d4f8e6

+ 60 - 383
config/routes.ts

@@ -21,424 +21,101 @@
   //   icon: 'smile',
   //   component: './Analysis',
   // },
-  {
-    path: '/system',
-    name: 'system',
-    icon: 'crown',
-    routes: [
-      {
-        path: '/system',
-        redirect: '/system/user',
-      },
-      {
-        path: '/system/user',
-        name: 'user',
-        icon: 'smile',
-        access: 'user',
-        component: './system/User',
-      },
-      {
-        path: '/system/department',
-        name: 'department',
-        icon: 'smile',
-        component: './system/Department',
-      },
-      {
-        hideInMenu: true,
-        path: '/system/department/:id/assets',
-        name: 'assets',
-        icon: 'smile',
-        component: './system/Department/Assets',
-      },
-      {
-        hideInMenu: true,
-        path: '/system/department/:id/user',
-        name: 'member',
-        icon: 'smile',
-        component: './system/Department/Member',
-      },
-      {
-        path: '/system/role',
-        name: 'role',
-        icon: 'smile',
-        access: 'role',
-        component: './system/Role',
-      },
-      {
-        hideInMenu: true,
-        path: '/system/role/edit/:id',
-        name: 'role-edit',
-        icon: 'smile',
-        component: './system/Role/Edit',
-      },
-      {
-        path: '/system/permission',
-        name: 'permission',
-        icon: 'smile',
-        component: './system/Permission',
-      },
-      // {
-      //   path: '/system/open-api',
-      //   name: 'open-api',
-      //   icon: 'smile',
-      //   component: './system/OpenAPI',
-      // },
-      // {
-      //   path: '/system/tenant',
-      //   name: 'tenant',
-      //   icon: 'smile',
-      //   component: './system/Tenant',
-      // },
-      {
-        hideInMenu: true,
-        path: '/system/tenant/detail/:id',
-        name: 'tenant-detail',
-        icon: 'smile',
-        component: './system/Tenant/Detail',
-      },
-      // {
-      //   path: '/system/datasource',
-      //   name: 'datasource',
-      //   icon: 'smile',
-      //   component: './system/DataSource',
-      // },
-      //
-    ],
-  },
-  {
-    path: '/device',
-    name: 'device',
-    icon: 'crown',
-    routes: [
-      {
-        path: '/device',
-        redirect: '/device/product',
-      },
-      {
-        path: '/device/product',
-        name: 'product',
-        icon: 'smile',
-        component: './device/Product',
-      },
-      {
-        path: '/device/category',
-        name: 'category',
-        icon: 'smile',
-        component: './device/Category',
-      },
-      {
-        hideInMenu: true,
-        path: '/device/product/detail/:id',
-        name: 'product-detail',
-        icon: 'smile',
-        component: './device/Product/Detail',
-      },
-      {
-        path: '/device/instance',
-        name: 'instance',
-        icon: 'smile',
-        component: './device/Instance',
-      },
-      {
-        hideInMenu: true,
-        path: '/device/instance/detail/:id',
-        name: 'instance-detail',
-        icon: 'smile',
-        component: './device/Instance/Detail',
-      },
-      {
-        path: '/device/command',
-        name: 'command',
-        icon: 'smile',
-        component: './device/Command',
-      },
-      {
-        path: '/device/firmware',
-        name: 'firmware',
-        icon: 'smile',
-        component: './device/Firmware',
-      },
-      {
-        hideInMenu: true,
-        path: '/device/firmware/detail/:id',
-        name: 'firmware-detail',
-        icon: 'smile',
-        component: './device/Firmware/Detail',
-      },
-      {
-        path: '/device/alarm',
-        name: 'alarm',
-        icon: 'smile',
-        component: './device/Alarm',
-      },
-      {
-        path: '/device/location',
-        name: 'location',
-        icon: 'smile',
-        component: './device/Location',
-      },
-    ],
-  },
-  // {
-  //   path: '/link',
-  //   name: 'link',
-  //   icon: 'crown',
-  //   routes: [
-  //     {
-  //       path: '/link',
-  //       redirect: '/link/certificate',
-  //     },
-  //     {
-  //       path: '/link/certificate',
-  //       name: 'certificate',
-  //       icon: 'smile',
-  //       component: './link/Certificate',
-  //     },
-  //     {
-  //       path: '/link/protocol',
-  //       name: 'protocol',
-  //       icon: 'smile',
-  //       component: './link/Protocol',
-  //     },
-  //     {
-  //       path: 'link/type',
-  //       name: 'type',
-  //       icon: 'smile',
-  //       component: './link/Type',
-  //     },
-  //     {
-  //       path: '/link/gateway',
-  //       name: 'gateway',
-  //       icon: 'smile',
-  //       component: './link/Gateway',
-  //     },
-  //     {
-  //       path: '/link/opcua',
-  //       name: 'opcua',
-  //       icon: 'smile',
-  //       component: './link/Opcua',
-  //     },
-  //   ],
-  // },
-  // {
-  //   path: '/notice',
-  //   name: 'notice',
-  //   icon: 'crown',
-  //   routes: [
-  //     {
-  //       path: '/notice',
-  //       redirect: '/notice/config',
-  //     },
-  //     {
-  //       path: '/notice/config',
-  //       name: 'config',
-  //       icon: 'smile',
-  //       component: './notice/Config',
-  //     },
-  //     {
-  //       path: '/notice/template',
-  //       name: 'template',
-  //       icon: 'smile',
-  //       component: './notice/Template',
-  //     },
-  //   ],
-  // },
-  // {
-  //   path: '/rule-engine',
-  //   name: 'rule-engine',
-  //   icon: 'crown',
-  //   routes: [
-  //     {
-  //       path: '/rule-engine',
-  //       redirect: '/rule-engine/instance',
-  //     },
-  //     {
-  //       path: '/rule-engine/instance',
-  //       name: 'instance',
-  //       icon: 'smile',
-  //       component: './rule-engine/Instance',
-  //     },
-  //     {
-  //       path: '/rule-engine/sqlRule',
-  //       name: 'sqlRule',
-  //       icon: 'smile',
-  //       component: './rule-engine/SQLRule',
-  //     },
-  //     {
-  //       path: '/rule-engine/scene',
-  //       name: 'scene',
-  //       icon: 'smile',
-  //       component: './rule-engine/Scene',
-  //     },
-  //   ],
-  // },
-  // {
-  //   path: '/visualization',
-  //   name: 'visualization',
-  //   icon: 'crown',
-  //   routes: [
-  //     {
-  //       path: '/visualization',
-  //       redirect: '/visualization/category',
-  //     },
-  //     {
-  //       path: '/visualization/category',
-  //       name: 'category',
-  //       icon: 'smile',
-  //       component: './visualization/Category',
-  //     },
-  //     {
-  //       path: '/visualization/screen',
-  //       name: 'screen',
-  //       icon: 'smile',
-  //       component: './visualization/Screen',
-  //     },
-  //     {
-  //       path: '/visualization/configuration',
-  //       name: 'configuration',
-  //       icon: 'smile',
-  //       component: './visualization/Configuration',
-  //     },
-  //   ],
-  // },
-  // {
-  //   path: '/simulator',
-  //   name: 'simulator',
-  //   icon: 'crown',
-  //   routes: [
-  //     {
-  //       path: '/simulator',
-  //       redirect: '/simulator/device',
-  //     },
-  //     {
-  //       path: '/simulator/device',
-  //       name: 'device',
-  //       icon: 'smile',
-  //       component: './simulator/Device',
-  //     },
-  //   ],
-  // },
   // {
-  //   path: '/log',
-  //   name: 'log',
+  //   path: '/system',
+  //   name: 'system',
   //   icon: 'crown',
   //   routes: [
   //     {
-  //       path: '/log',
-  //       redirect: '/log/access',
-  //     },
-  //     {
-  //       path: '/log/access',
-  //       name: 'access',
+  //       path: '/system/user',
+  //       name: 'user',
   //       icon: 'smile',
-  //       component: './log/Access',
+  //       access: 'user',
+  //       component: './system/User',
   //     },
   //     {
-  //       path: '/log/system',
-  //       name: 'system',
+  //       path: '/system/department',
+  //       name: 'department',
   //       icon: 'smile',
-  //       component: './log/System',
+  //       component: './system/Department',
   //     },
-  //   ],
-  // },
-  // {
-  //   path: '/cloud',
-  //   name: 'cloud',
-  //   icon: 'crown',
-  //   routes: [
   //     {
-  //       path: '/cloud',
-  //       redirect: '/cloud/duer',
-  //     },
-  //     {
-  //       path: '/cloud/dueros',
-  //       name: 'DuerOS',
+  //       hideInMenu: true,
+  //       path: '/system/department/:id/assets',
+  //       name: 'assets',
   //       icon: 'smile',
-  //       component: './cloud/DuerOS',
+  //       component: './system/Department/Assets',
   //     },
   //     {
-  //       path: '/cloud/aliyun',
-  //       name: 'aliyun',
+  //       hideInMenu: true,
+  //       path: '/system/department/:id/user',
+  //       name: 'member',
   //       icon: 'smile',
-  //       component: './cloud/Aliyun',
+  //       component: './system/Department/Member',
   //     },
   //     {
-  //       path: '/cloud/onenet',
-  //       name: 'onenet',
+  //       path: '/system/role',
+  //       name: 'role',
   //       icon: 'smile',
-  //       component: './cloud/Onenet',
+  //       access: 'role',
+  //       component: './system/Role',
   //     },
   //     {
-  //       path: '/cloud/ctwing',
-  //       name: 'ctwing',
+  //       hideInMenu: true,
+  //       path: '/system/role/edit/:id',
+  //       name: 'role-edit',
   //       icon: 'smile',
-  //       component: './cloud/Ctwing',
+  //       component: './system/Role/Edit',
   //     },
-  //   ],
-  // },
-  // {
-  //   path: '/media',
-  //   name: 'media',
-  //   icon: 'crown',
-  //   routes: [
   //     {
-  //       path: '/media',
-  //       redirect: '/media/config',
-  //     },
-  //     {
-  //       path: '/media/config',
-  //       name: 'config',
+  //       path: '/system/permission',
+  //       name: 'permission',
   //       icon: 'smile',
-  //       component: './media/Config',
+  //       component: './system/Permission',
   //     },
   //     {
-  //       path: '/media/device',
-  //       name: 'device',
+  //       path: '/system/menu',
+  //       name: 'menu',
   //       icon: 'smile',
-  //       component: './media/Device',
+  //       component: './system/Menu',
   //     },
   //     {
-  //       path: '/media/reveal',
-  //       name: 'reveal',
+  //       path: '/system/menu/detail',
+  //       name: 'menuDetail',
   //       icon: 'smile',
-  //       component: './media/Reveal',
+  //       hideInMenu: true,
+  //       component: './system/Menu/Detail',
   //     },
+  //     // {
+  //     //   path: '/system/open-api',
+  //     //   name: 'open-api',
+  //     //   icon: 'smile',
+  //     //   component: './system/OpenAPI',
+  //     // },
+  //     // {
+  //     //   path: '/system/tenant',
+  //     //   name: 'tenant',
+  //     //   icon: 'smile',
+  //     //   component: './system/Tenant',
+  //     // },
   //     {
-  //       path: '/media/cascade',
-  //       name: 'cascade',
+  //       hideInMenu: true,
+  //       path: '/system/tenant/detail/:id',
+  //       name: 'tenant-detail',
   //       icon: 'smile',
-  //       component: './media/Cascade',
+  //       component: './system/Tenant/Detail',
   //     },
+  //     // {
+  //     //   path: '/system/datasource',
+  //     //   name: 'datasource',
+  //     //   icon: 'smile',
+  //     //   component: './system/DataSource',
+  //     // },
+  //     //
   //   ],
   // },
   // {
-  //   path: '/edge',
-  //   name: 'edge',
-  //   icon: 'crown',
-  //   routes: [
-  //     {
-  //       path: '/edge',
-  //       redirect: '/edge/product',
-  //     },
-  //     {
-  //       path: '/edge/product',
-  //       name: 'product',
-  //       icon: 'smile',
-  //       component: './edge/Product',
-  //     },
-  //     {
-  //       path: '/edge/device',
-  //       name: 'device',
-  //       icon: 'smile',
-  //       component: './edge/Device',
-  //     },
-  //   ],
+  //   path: '/',
+  //   redirect: '/system',
   // },
-  {
-    path: '/',
-    redirect: '/system',
-  },
-  {
-    component: './404',
-  },
 ];

+ 1 - 0
package.json

@@ -108,6 +108,7 @@
     "@types/react": "^17.0.0",
     "@types/react-dom": "^17.0.0",
     "@types/react-helmet": "^6.1.0",
+    "@types/webpack-env": "^1.16.3",
     "@umijs/fabric": "^2.6.2",
     "@umijs/openapi": "^1.1.14",
     "@umijs/plugin-blocks": "^2.0.5",

+ 28 - 2
src/app.tsx

@@ -11,9 +11,12 @@ import Token from '@/utils/token';
 import type { RequestOptionsInit } from 'umi-request';
 import ReconnectingWebSocket from 'reconnecting-websocket';
 import SystemConst from '@/utils/const';
+import { service as MenuService } from '@/pages/system/Menu';
+import getRoutes, { getMenus, saveMenusCache } from '@/utils/menu';
 
 const isDev = process.env.NODE_ENV === 'development';
 const loginPath = '/user/login';
+let extraRoutes: any[] = [];
 
 /** 获取用户信息比较慢的时候会展示一个 loading */
 export const initialStateConfig = {
@@ -183,13 +186,16 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
         history.push(loginPath);
       }
     },
+    menuDataRender: () => {
+      return getMenus(extraRoutes);
+    },
     links: isDev
       ? [
-          <Link to="/umi/plugin/openapi" target="_blank">
+          <Link key={1} to="/umi/plugin/openapi" target="_blank">
             <LinkOutlined />
             <span>OpenAPI 文档</span>
           </Link>,
-          <Link to="/~docs">
+          <Link key={2} to="/~docs">
             <BookOutlined />
             <span>业务组件文档</span>
           </Link>,
@@ -201,3 +207,23 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
     ...initialState?.settings,
   };
 };
+
+export function patchRoutes(routes: any) {
+  if (extraRoutes && extraRoutes.length) {
+    routes.routes[1].routes = [...routes.routes[1].routes, ...getRoutes(extraRoutes)];
+  }
+}
+
+export function render(oldRender: any) {
+  if (history.location.pathname !== loginPath && history.location.pathname !== '/') {
+    MenuService.queryMenuThree({ paging: false }).then((res) => {
+      if (res.status === 200) {
+        extraRoutes = res.result;
+        saveMenusCache(res.result);
+      }
+      oldRender();
+    });
+  } else {
+    oldRender();
+  }
+}

+ 2 - 0
src/locales/zh-CN/menu.ts

@@ -13,6 +13,8 @@ export default {
   'menu.system.tenant': '租户管理',
   'menu.system.datasource': '数据源管理',
   'menu.system.department': '部门管理',
+  'menu.system.menu': '菜单管理',
+  'menu.system.menuDetail': '菜单详情',
   'menu.system.assets': '资产分配',
   'menu.system.member': '用户',
   'menu.device': '设备管理',

+ 5 - 0
src/locales/zh-CN/pages.ts

@@ -131,6 +131,11 @@ export default {
   'pages.system.role.option.unBind': '是否解除绑定',
   'pages.system.role.option.unBinds': '是否批量解除绑定?',
   'pages.system.role.option.delete': '确定要删除吗?',
+  // 系统设置-菜单管理
+  'pages.system.menu.option.addChildren': '新增子菜单',
+  'pages.system.menu.detail': '基本信息',
+  'pages.system.menu.buttons': '按钮管理',
+  'pages.system.menu.root': '菜单权限',
   // 系统设置-第三方平台
   'pages.system.openApi': '第三方平台',
   'pages.system.openApi.username': '用户名',

+ 2 - 2
src/pages/device/components/Metadata/Base/Edit/index.tsx

@@ -42,7 +42,7 @@ import DB from '@/db';
 import _ from 'lodash';
 import { useParams } from 'umi';
 import { InstanceModel } from '@/pages/device/Instance';
-import FRuleEditor from '@/components/FRuleEditor';
+// import FRuleEditor from '@/components/FRuleEditor';
 
 interface Props {
   type: 'product' | 'device';
@@ -97,7 +97,7 @@ const Edit = (props: Props) => {
       EnumParam,
       BooleanEnum,
       ConfigParam,
-      FRuleEditor,
+      // FRuleEditor,
     },
     scope: {
       async asyncOtherConfig(field: Field) {

+ 2 - 2
src/pages/system/Department/index.tsx

@@ -176,8 +176,8 @@ export default observer(() => {
         'x-component': 'Input',
         'x-validator': [
           {
-            max: 50,
-            message: '最多可输入50个字符',
+            max: 64,
+            message: '最多可输入64个字符',
           },
           {
             required: true,

+ 337 - 0
src/pages/system/Menu/Detail/buttons.tsx

@@ -0,0 +1,337 @@
+import { Form, Input, Button, message, Modal, Popconfirm, Tooltip } from 'antd';
+import { useIntl } from '@@/plugin-locale/localeExports';
+import { useCallback, useEffect, useState } from 'react';
+import { service } from '@/pages/system/Menu';
+import ProTable from '@jetlinks/pro-table';
+import type { ProColumns } from '@jetlinks/pro-table';
+import { SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
+import type { MenuButtonInfo, MenuItem } from '@/pages/system/Menu/typing';
+import Permission from '@/pages/system/Menu/components/permission';
+import { useRequest } from '@@/plugin-request/request';
+import { debounce } from 'lodash';
+
+type ButtonsProps = {
+  data: MenuItem;
+  onLoad: () => void;
+};
+
+export default (props: ButtonsProps) => {
+  const intl = useIntl();
+
+  const [disabled, setDisabled] = useState(false); // 是否为查看
+  const [buttonItems, setButtonItems] = useState<MenuButtonInfo[]>([]); // button Table数据源
+  const [visible, setVisible] = useState(false); // Modal显示影藏
+  const [id, setId] = useState(''); // 缓存ID
+  const [form] = Form.useForm();
+
+  const { data: permissions, run: queryPermissions } = useRequest(service.queryPermission, {
+    manual: true,
+    formatResult: (response) => response.result.data,
+  });
+
+  useEffect(() => {
+    if (visible) {
+      // 每次打开Modal获取最新权限
+      queryPermissions({ paging: false });
+    }
+    /* eslint-disable */
+  }, [visible]);
+
+  useEffect(() => {
+    if (props.data) {
+      setButtonItems(props.data.buttons || []);
+    }
+  }, [props.data]);
+
+  const resetForm = () => {
+    form.resetFields();
+    setId('');
+    setDisabled(false);
+  };
+
+  const filterThree = (e: any) => {
+    const _data: any = {
+      paging: false,
+    };
+    if (e.target.value) {
+      _data.terms = [{ column: 'name', value: e.target.value }];
+    }
+    queryPermissions(_data);
+  };
+
+  /**
+   * 更新菜单信息
+   * @param data
+   */
+  const updateMenuInfo = useCallback(
+    async (data: MenuButtonInfo[]) => {
+      const response = await service.update({
+        ...props.data,
+        buttons: data,
+      });
+      if (response.status === 200) {
+        message.success('操作成功!');
+        props.onLoad();
+        resetForm();
+        setVisible(false);
+      } else {
+        message.error('操作失败!');
+      }
+      /* eslint-disable */
+    },
+    [props.data],
+  );
+
+  /**
+   * 删除单个按钮
+   */
+  const deleteItem = useCallback(
+    (buttonId) => {
+      const filterButtons = buttonItems.filter((item) => item.id !== buttonId);
+      setButtonItems(filterButtons);
+      updateMenuInfo(filterButtons);
+      /* eslint-disable */
+    },
+    [buttonItems],
+  );
+
+  /**
+   * Model title处理,默认新增
+   * @default 'pages.data.option.add'
+   */
+  const handleTitle = useCallback((): string => {
+    let intlId = 'pages.data.option.add';
+    if (disabled && id) {
+      // 查看
+      intlId = 'pages.data.option.view';
+    } else if (!disabled && id) {
+      // 编辑
+      intlId = 'pages.data.option.edit';
+    }
+    return intl.formatMessage({
+      id: intlId,
+    });
+    /* eslint-disable */
+  }, [disabled, id]);
+
+  /**
+   * 获取表单数据
+   */
+  const saveData = useCallback(async () => {
+    const formData = await form.validateFields();
+    if (formData) {
+      if (buttonItems.some((item) => item.id === formData.id)) {
+        // 编辑
+        updateMenuInfo(buttonItems.map((item) => (item.id === formData.id ? formData : item)));
+      } else {
+        updateMenuInfo([formData, ...buttonItems]);
+      }
+    }
+    /* eslint-disable */
+  }, [buttonItems]);
+
+  const columns: ProColumns<MenuButtonInfo>[] = [
+    {
+      title: intl.formatMessage({
+        id: 'page.system.menu.encoding',
+        defaultMessage: '编码',
+      }),
+      width: 220,
+      dataIndex: 'id',
+    },
+    {
+      title: intl.formatMessage({
+        id: 'page.system.menu.name',
+        defaultMessage: '名称',
+      }),
+      width: 300,
+      dataIndex: 'name',
+    },
+    {
+      title: intl.formatMessage({
+        id: 'page.system.menu.describe',
+        defaultMessage: '备注说明',
+      }),
+      dataIndex: 'describe',
+    },
+    {
+      title: intl.formatMessage({
+        id: 'pages.data.option',
+        defaultMessage: '操作',
+      }),
+      valueType: 'option',
+      align: 'center',
+      width: 240,
+      render: (_, record) => [
+        <a
+          key="edit"
+          onClick={() => {
+            form.setFieldsValue(record);
+            setId(record.id);
+            setDisabled(false);
+            setVisible(true);
+          }}
+        >
+          <Tooltip
+            title={intl.formatMessage({
+              id: 'pages.data.option.edit',
+              defaultMessage: '编辑',
+            })}
+          >
+            <EditOutlined />
+          </Tooltip>
+        </a>,
+        <a
+          key="view"
+          onClick={() => {
+            form.setFieldsValue(record);
+            setId(record.id);
+            setDisabled(true);
+            setVisible(true);
+          }}
+        >
+          <Tooltip
+            title={intl.formatMessage({
+              id: 'pages.data.option.view',
+              defaultMessage: '查看',
+            })}
+          >
+            <SearchOutlined />
+          </Tooltip>
+        </a>,
+        <Popconfirm
+          key="unBindUser"
+          title={intl.formatMessage({
+            id: 'page.system.menu.table.delete',
+            defaultMessage: '是否删除该按钮',
+          })}
+          onConfirm={() => {
+            deleteItem(record.id);
+          }}
+        >
+          <Tooltip
+            title={intl.formatMessage({
+              id: 'pages.data.option.delete',
+              defaultMessage: '删除',
+            })}
+          >
+            <a key="delete">
+              <DeleteOutlined />
+            </a>
+          </Tooltip>
+        </Popconfirm>,
+      ],
+    },
+  ];
+
+  return (
+    <>
+      <ProTable<MenuButtonInfo>
+        columns={columns}
+        dataSource={buttonItems}
+        search={false}
+        pagination={false}
+        toolBarRender={() => [
+          <Button
+            onClick={() => {
+              form.resetFields();
+              setVisible(true);
+            }}
+            key="button"
+            icon={<PlusOutlined />}
+            type="primary"
+          >
+            {intl.formatMessage({
+              id: 'pages.data.option.add',
+              defaultMessage: '新增',
+            })}
+          </Button>,
+        ]}
+      />
+      <Modal
+        width={660}
+        visible={visible}
+        title={handleTitle()}
+        onOk={() => {
+          saveData();
+        }}
+        onCancel={() => {
+          resetForm();
+          setVisible(false);
+        }}
+      >
+        <Form form={form} labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
+          <Form.Item
+            name="id"
+            label={intl.formatMessage({
+              id: 'pages.system.org.encoding',
+              defaultMessage: '编码',
+            })}
+            required={true}
+            rules={[
+              { required: true, message: '请输入编码' },
+              { max: 64, message: '最多可输入64个字符' },
+              {
+                pattern: /^[a-zA-Z0-9`!@#$%^&*()_+\-={}|\\\]\[;':",.\/<>?]+$/,
+                message: '请输入英文+数字+特殊字符(`!@#$%^&*()_+-={}|\\][;\':",./<>?)',
+              },
+              {
+                validator: (_, value, callback) => {
+                  if (!(!disabled && id) && buttonItems.some((item) => item.id === value)) {
+                    // 判断是否为新增
+                    callback('重复编码');
+                  }
+                  callback();
+                },
+              },
+            ]}
+          >
+            <Input disabled={!!(!disabled && id)} />
+          </Form.Item>
+          <Form.Item
+            name="name"
+            label={intl.formatMessage({
+              id: 'pages.table.name',
+              defaultMessage: '名称',
+            })}
+            required={true}
+            rules={[
+              { required: true, message: '请输入名称' },
+              { max: 64, message: '最多可输入64个字符' },
+            ]}
+          >
+            <Input disabled={disabled} />
+          </Form.Item>
+          <Form.Item
+            label={intl.formatMessage({
+              id: 'page.system.menu.permissions',
+              defaultMessage: '权限',
+            })}
+            required={true}
+          >
+            <Input disabled={disabled} onChange={debounce(filterThree, 300)} />
+            <Form.Item name="permissions" rules={[{ required: true, message: '请选择权限' }]}>
+              <Permission
+                title={intl.formatMessage({
+                  id: 'page.system.menu.permissions.operate',
+                  defaultMessage: '操作权限',
+                })}
+                disabled={disabled}
+                data={permissions}
+              />
+            </Form.Item>
+          </Form.Item>
+          <Form.Item
+            name="describe"
+            label={intl.formatMessage({
+              id: 'pages.table.describe',
+              defaultMessage: '描述',
+            })}
+          >
+            <Input.TextArea disabled={disabled} />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </>
+  );
+};

+ 154 - 0
src/pages/system/Menu/Detail/edit.tsx

@@ -0,0 +1,154 @@
+import { Form, Input, InputNumber, Button, message } from 'antd';
+import Permission from '@/pages/system/Menu/components/permission';
+import { useIntl } from '@@/plugin-locale/localeExports';
+import { useEffect, useState } from 'react';
+import { service } from '@/pages/system/Menu';
+import { useRequest } from 'umi';
+import type { MenuItem } from '@/pages/system/Menu/typing';
+import { debounce } from 'lodash';
+
+type EditProps = {
+  data: MenuItem;
+  onLoad: () => void;
+};
+
+export default (props: EditProps) => {
+  const intl = useIntl();
+  const [disabled, setDisabled] = useState(true);
+  const [form] = Form.useForm();
+
+  const { data: permissions, run: queryPermissions } = useRequest(service.queryPermission, {
+    manual: true,
+    formatResult: (response) => response.result.data,
+  });
+
+  const saveData = async () => {
+    const formData = await form.validateFields();
+    if (formData) {
+      const response: any = await service.update(formData);
+      if (response.status === 200) {
+        message.success('操作成功!');
+        setDisabled(true);
+        props.onLoad();
+      } else {
+        message.error('操作失败!');
+      }
+    }
+  };
+
+  const filterThree = (e: any) => {
+    const _data: any = {
+      paging: false,
+    };
+    if (e.target.value) {
+      _data.terms = [{ column: 'name', value: e.target.value }];
+    }
+    queryPermissions(_data);
+  };
+
+  useEffect(() => {
+    queryPermissions({ paging: false });
+    /* eslint-disable */
+  }, []);
+
+  useEffect(() => {
+    if (form) {
+      form.setFieldsValue(props.data);
+    }
+    /* eslint-disable */
+  }, [props.data]);
+
+  return (
+    <div>
+      <Form form={form} labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
+        <Form.Item
+          name="code"
+          label={intl.formatMessage({
+            id: 'page.system.menu.encoding',
+            defaultMessage: '编码',
+          })}
+          required={true}
+          rules={[{ required: true, message: '该字段是必填字段' }]}
+        >
+          <Input disabled={disabled} />
+        </Form.Item>
+        <Form.Item
+          name="name"
+          label={intl.formatMessage({
+            id: 'pages.table.name',
+            defaultMessage: '名称',
+          })}
+          required={true}
+          rules={[{ required: true, message: '该字段是必填字段' }]}
+        >
+          <Input disabled={disabled} />
+        </Form.Item>
+        <Form.Item
+          name="url"
+          label={intl.formatMessage({
+            id: 'page.system.menu.url',
+            defaultMessage: '页面地址',
+          })}
+          required={true}
+          rules={[{ required: true, message: '该字段是必填字段' }]}
+        >
+          <Input disabled={disabled} />
+        </Form.Item>
+        <Form.Item
+          label={intl.formatMessage({
+            id: 'page.system.menu.permissions',
+            defaultMessage: '权限',
+          })}
+        >
+          <Input disabled={disabled} onChange={debounce(filterThree, 300)} />
+          <Form.Item name="permissions">
+            <Permission
+              title={intl.formatMessage({
+                id: 'page.system.menu.permissions.operate',
+                defaultMessage: '操作权限',
+              })}
+              disabled={disabled}
+              data={permissions}
+            />
+          </Form.Item>
+        </Form.Item>
+        <Form.Item
+          name="sortIndex"
+          label={intl.formatMessage({
+            id: 'page.system.menu.sort',
+            defaultMessage: '排序说明',
+          })}
+        >
+          <InputNumber style={{ width: '100%' }} disabled={disabled} />
+        </Form.Item>
+        <Form.Item
+          name="describe"
+          label={intl.formatMessage({
+            id: 'pages.table.describe',
+            defaultMessage: '描述',
+          })}
+        >
+          <Input.TextArea disabled={disabled} />
+        </Form.Item>
+        <Form.Item name="id" hidden={true}>
+          <Input />
+        </Form.Item>
+      </Form>
+      <Button
+        type="primary"
+        onClick={() => {
+          if (disabled) {
+            setDisabled(false);
+          } else {
+            saveData();
+          }
+        }}
+      >
+        {intl.formatMessage({
+          id: `pages.data.option.${disabled ? 'edit' : 'save'}`,
+          defaultMessage: '编辑',
+        })}
+      </Button>
+    </div>
+  );
+};

+ 77 - 0
src/pages/system/Menu/Detail/index.tsx

@@ -0,0 +1,77 @@
+// 菜单管理-详情
+import { PageContainer } from '@ant-design/pro-layout';
+import { useIntl } from '@@/plugin-locale/localeExports';
+import { useEffect, useState } from 'react';
+import BaseDetail from './edit';
+import Buttons from './buttons';
+import { useLocation } from 'umi';
+import { service } from '@/pages/system/Menu';
+import { useRequest } from 'umi';
+
+type LocationType = {
+  id?: string;
+};
+
+export default () => {
+  const intl = useIntl();
+  const [tabKey, setTabKey] = useState('detail');
+  const location = useLocation<LocationType>();
+
+  const { data, run: queryData } = useRequest(service.queryDetail, {
+    manual: true,
+    formatResult: (response) => {
+      return response.result;
+    },
+  });
+
+  /**
+   * 获取当前菜单详情
+   */
+  const queryDetail = () => {
+    const params = new URLSearchParams(location.search);
+    const id = params.get('id');
+    if (id) {
+      queryData(id);
+    }
+  };
+
+  useEffect(() => {
+    queryDetail();
+    /* eslint-disable */
+  }, []);
+
+  return (
+    <PageContainer
+      tabList={[
+        {
+          tab: intl.formatMessage({
+            id: 'pages.system.menu.detail',
+            defaultMessage: '基本详情',
+          }),
+          key: 'detail',
+        },
+        {
+          tab: intl.formatMessage({
+            id: 'pages.system.menu.buttons',
+            defaultMessage: '按钮管理',
+          }),
+          key: 'buttons',
+        },
+      ]}
+      onTabChange={(key) => {
+        setTabKey(key);
+      }}
+    >
+      {tabKey === 'detail' ? (
+        <div style={{ background: '#fff', padding: '16px 24px' }}>
+          <div style={{ width: 660 }}>
+            {' '}
+            <BaseDetail data={data} onLoad={queryDetail} />{' '}
+          </div>
+        </div>
+      ) : (
+        <Buttons data={data} onLoad={queryDetail} />
+      )}
+    </PageContainer>
+  );
+};

+ 42 - 0
src/pages/system/Menu/components/permission.less

@@ -0,0 +1,42 @@
+@import '~antd/lib/style/themes/variable';
+@border: 1px solid @border-color-base;
+
+.permission-container {
+  margin-top: 20px;
+  border: @border;
+
+  .permission-header {
+    padding: @padding-sm;
+    background-color: @border-color-base;
+  }
+
+  .permission-content {
+    max-height: 400px;
+    overflow-y: auto;
+
+    .permission-items {
+      display: flex;
+      border-bottom: @border;
+
+      &:last-child {
+        border-bottom: none;
+      }
+
+      > div {
+        padding: @padding-xs 0 @padding-xs @padding-sm;
+      }
+
+      .permission-parent {
+        display: flex;
+        align-items: center;
+        width: 180px;
+      }
+
+      .permission-children-checkbox {
+        flex-grow: 1;
+        width: 0;
+        border-left: @border;
+      }
+    }
+  }
+}

+ 273 - 0
src/pages/system/Menu/components/permission.tsx

@@ -0,0 +1,273 @@
+import React, { useEffect, useState, useRef } from 'react';
+import { Checkbox } from 'antd';
+import './permission.less';
+import type { CheckboxChangeEvent } from 'antd/es/checkbox';
+import type { PermissionInfo } from '../typing';
+import { useIntl } from 'umi';
+
+type PermissionDataType = {
+  action: string;
+  id: string;
+  name: string;
+  checked?: boolean;
+  actions: PermissionDataType[];
+};
+
+type PermissionType = {
+  value?: {
+    permission: string;
+    actions: string[];
+  }[];
+  data: PermissionDataType[];
+  title?: React.ReactNode | string;
+  onChange?: (data: PermissionInfo[]) => void;
+  disabled?: boolean;
+};
+
+type ParentNodeChange = { checkedAll: boolean; list: string[]; id: string; state: boolean };
+
+type ParentNodeType = {
+  id: string;
+  name: string;
+  actions: PermissionDataType[];
+  onChange?: (value: ParentNodeChange) => void;
+  disabled?: boolean;
+  checked?: boolean;
+  state?: boolean;
+};
+
+type CheckItem = Omit<ParentNodeType, 'onChange'>;
+
+const ParentNode = (props: ParentNodeType) => {
+  const { actions, checked } = props;
+
+  const [checkedList, setCheckedList] = useState<string[]>([]);
+  const [indeterminate, setIndeterminate] = useState(false);
+  const [checkAll, setCheckAll] = useState(false);
+
+  const submitData = (checkedAll: boolean, list: string[], state: boolean) => {
+    if (props.onChange) {
+      props.onChange({
+        checkedAll,
+        list,
+        id: props.id,
+        state,
+      });
+    }
+  };
+
+  const onChange = (list: any) => {
+    const _indeterminate = !!list.length && list.length < props.actions.length;
+    setCheckedList(list);
+    setIndeterminate(_indeterminate);
+    setCheckAll(list.length === props.actions.length);
+    submitData(list.length === props.actions.length, list, _indeterminate);
+  };
+
+  const onChangeAll = (e: CheckboxChangeEvent) => {
+    const _list = e.target.checked ? props.actions.map((item) => item.action) : [];
+    setCheckedList(e.target.checked ? _list : []);
+    setIndeterminate(false);
+    setCheckAll(e.target.checked);
+    submitData(e.target.checked, _list, false);
+  };
+
+  useEffect(() => {
+    onChangeAll({
+      target: {
+        checked: !!props.checked,
+      },
+    } as CheckboxChangeEvent);
+    /* eslint-disable */
+  }, [checked]);
+
+  useEffect(() => {
+    // 通过父级传入checked来控制节点状态
+    const _list = props.actions.filter((a) => a.checked).map((a) => a.action);
+    onChange(_list);
+    /* eslint-disable */
+  }, [actions]);
+
+  return (
+    <div className="permission-items">
+      <div className="permission-parent">
+        <Checkbox
+          id={props.id}
+          onChange={onChangeAll}
+          indeterminate={indeterminate}
+          checked={checkAll}
+          disabled={props.disabled}
+        >
+          {props.name}
+        </Checkbox>
+      </div>
+      <div className="permission-children-checkbox">
+        <Checkbox.Group
+          onChange={onChange}
+          value={checkedList}
+          disabled={props.disabled}
+          options={props.actions.map((item: any) => {
+            return {
+              label: item.name,
+              value: item.action,
+            };
+          })}
+        />
+      </div>
+    </div>
+  );
+};
+
+export default (props: PermissionType) => {
+  const [indeterminate, setIndeterminate] = useState(false);
+  const [checkAll, setCheckAll] = useState(false);
+  const [nodes, setNodes] = useState<React.ReactNode>([]);
+  const checkListRef = useRef<CheckItem[]>([]);
+  const intl = useIntl();
+
+  const onChange = (list: CheckItem[]) => {
+    if (props.onChange) {
+      const _list = list
+        .filter((a) => a.checked || a.actions.filter((b) => b.checked).length)
+        .map((item) => ({
+          permission: item.id,
+          actions: item.actions.filter((b) => b.checked).map((b) => b.action),
+        }));
+      props.onChange(_list);
+    }
+  };
+
+  /**
+   * 全选或者全部取消
+   * @param e
+   */
+  const onChangeAll = (e: CheckboxChangeEvent) => {
+    const _list = props.data.map((item) => {
+      return {
+        ...item,
+        actions: item.actions.map((a) => ({ ...a, checked: e.target.checked })),
+        state: false,
+        checked: e.target.checked,
+      };
+    });
+    setIndeterminate(false);
+    setCheckAll(e.target.checked);
+    // setCheckedList(_list)
+    checkListRef.current = _list;
+    onChange(_list);
+    setNodes(createContentNode(_list));
+  };
+
+  const parentChange = (value: ParentNodeChange) => {
+    let indeterminateCount = 0;
+    let _checkAll = 0;
+    const list = checkListRef.current.map((item) => {
+      const _checked = item.id === value.id ? value.checkedAll : item.checked;
+      const _state = item.id === value.id ? value.state : item.state;
+      const actions =
+        item.id === value.id
+          ? item.actions.map((a) => ({ ...a, checked: value.list.includes(a.action) }))
+          : item.actions;
+      if (_checked) {
+        // 父checkbox为全选或者有子节点被选中
+        _checkAll += 1;
+        indeterminateCount += 1;
+      } else if (_state) {
+        // 父checkbox下
+        indeterminateCount += 1;
+      }
+
+      return {
+        ...item,
+        actions,
+        state: _state,
+        checked: _checked,
+      };
+    });
+    // 如果全部选中,则取消半选状态
+    const isIndeterminate =
+      _checkAll === list.length && _checkAll !== 0 ? false : !!indeterminateCount;
+    setIndeterminate(isIndeterminate);
+    setCheckAll(_checkAll === list.length && _checkAll !== 0);
+    // setCheckedList(list)
+    checkListRef.current = list;
+    onChange(list);
+  };
+
+  /**
+   * 创建节点
+   */
+  function createContentNode(data: CheckItem[]): React.ReactNode[] {
+    const NodeArr: React.ReactNode[] = [];
+    if (data && data.length) {
+      data.forEach((item) => {
+        if (item.actions) {
+          // 父节点
+          NodeArr.push(
+            <ParentNode
+              {...item}
+              key={item.id}
+              disabled={props.disabled}
+              onChange={parentChange}
+            />,
+          );
+        }
+      });
+    }
+    return NodeArr;
+  }
+
+  /**
+   * 初始化树形节点数据格式
+   * @param data
+   */
+  const initialState = (data: PermissionDataType[]) => {
+    const _list = data.map((item) => {
+      const propsPermission =
+        props.value && props.value.length
+          ? props.value.find((p) => p.permission === item.id)
+          : undefined;
+      const propsActions = propsPermission ? propsPermission.actions : [];
+      return {
+        ...item,
+        actions: item.actions.map((a) => ({ ...a, checked: propsActions.includes(a.action) })),
+        state: false, // 是否为半选中状态
+        checked: false, // 是否为全选
+      };
+    });
+    // setCheckedList(_list)
+    checkListRef.current = _list;
+    setNodes(createContentNode(_list));
+  };
+
+  useEffect(() => {
+    if (props.data) {
+      initialState(props.data);
+    }
+    /* eslint-disable */
+  }, [props.data, props.disabled]);
+
+  return (
+    <div className="permission-container">
+      <div className="permission-header">{props.title}</div>
+      <div className="permission-content">
+        <div className="permission-items">
+          <div className="permission-parent">
+            <Checkbox
+              onChange={onChangeAll}
+              indeterminate={indeterminate}
+              checked={checkAll}
+              disabled={props.disabled}
+            >
+              {intl.formatMessage({
+                id: 'pages.system.menu.root',
+                defaultMessage: '菜单权限',
+              })}
+            </Checkbox>
+          </div>
+        </div>
+        {nodes}
+      </div>
+    </div>
+  );
+};

+ 314 - 0
src/pages/system/Menu/index.tsx

@@ -0,0 +1,314 @@
+// 菜单管理
+import { PageContainer } from '@ant-design/pro-layout';
+import ProTable from '@jetlinks/pro-table';
+import type { ActionType, ProColumns } from '@jetlinks/pro-table';
+import { useRef, useState } from 'react';
+import { useIntl } from '@@/plugin-locale/localeExports';
+import { Button, message, Popconfirm, Tooltip, Card, Divider, Modal, Form, Input } from 'antd';
+import {
+  SearchOutlined,
+  PlusOutlined,
+  PlusCircleOutlined,
+  DeleteOutlined,
+} from '@ant-design/icons';
+import { observer } from '@formily/react';
+import { model } from '@formily/reactive';
+import { useHistory } from 'umi';
+import SearchComponent from '@/components/SearchComponent';
+import Service from './service';
+import type { MenuItem } from './typing';
+import moment from 'moment';
+import { getMenuPathBuCode, MENUS_CODE } from '@/utils/menu';
+
+export const service = new Service('menu');
+
+type ModelType = {
+  visible: boolean;
+  current: Partial<MenuItem>;
+  parentId: string | undefined;
+};
+
+export const State = model<ModelType>({
+  visible: false,
+  current: {},
+  parentId: undefined,
+});
+
+export default observer(() => {
+  const actionRef = useRef<ActionType>();
+  const intl = useIntl();
+
+  const [param, setParam] = useState({});
+  const [form] = Form.useForm();
+  const history = useHistory();
+
+  const deleteItem = async (id: string) => {
+    const response: any = await service.remove(id);
+    if (response.status === 200) {
+      message.success(
+        intl.formatMessage({
+          id: 'pages.data.option.success',
+          defaultMessage: '操作成功!',
+        }),
+      );
+    }
+    actionRef.current?.reload();
+  };
+
+  /**
+   * 跳转详情页
+   * @param id
+   */
+  const pageJump = (id: string) => {
+    // 跳转详情
+    history.push(`${getMenuPathBuCode(MENUS_CODE['system/Menu/Detail'])}?id=${id}`);
+  };
+
+  const columns: ProColumns<MenuItem>[] = [
+    {
+      title: intl.formatMessage({
+        id: 'page.system.menu.encoding',
+        defaultMessage: '编码',
+      }),
+      width: 300,
+      dataIndex: 'code',
+    },
+    {
+      title: intl.formatMessage({
+        id: 'page.system.menu.name',
+        defaultMessage: '名称',
+      }),
+      width: 220,
+      dataIndex: 'name',
+    },
+    {
+      title: intl.formatMessage({
+        id: 'page.system.menu.url',
+        defaultMessage: '页面地址',
+      }),
+      dataIndex: 'url',
+    },
+    {
+      title: intl.formatMessage({
+        id: 'page.system.menu.sort',
+        defaultMessage: '排序',
+      }),
+      width: 80,
+      dataIndex: 'sortIndex',
+    },
+    {
+      title: intl.formatMessage({
+        id: 'page.system.menu.describe',
+        defaultMessage: '备注说明',
+      }),
+      width: 200,
+      dataIndex: 'describe',
+    },
+    {
+      title: intl.formatMessage({
+        id: 'pages.table.createTime',
+        defaultMessage: '创建时间',
+      }),
+      width: 180,
+      dataIndex: 'createTime',
+      render: (_, record) => {
+        return record.createTime ? moment(record.createTime).format('YYYY-MM-DD HH:mm:ss') : '-';
+      },
+    },
+    {
+      title: intl.formatMessage({
+        id: 'pages.data.option',
+        defaultMessage: '操作',
+      }),
+      valueType: 'option',
+      align: 'center',
+      width: 240,
+      render: (_, record) => [
+        <a
+          key="view"
+          onClick={() => {
+            pageJump(record.id);
+          }}
+        >
+          <Tooltip
+            title={intl.formatMessage({
+              id: 'pages.data.option.view',
+              defaultMessage: '查看',
+            })}
+          >
+            <SearchOutlined />
+          </Tooltip>
+        </a>,
+        <a
+          key="editable"
+          onClick={() => {
+            State.current = {
+              parentId: record.id,
+            };
+            State.visible = true;
+          }}
+        >
+          <Tooltip
+            title={intl.formatMessage({
+              id: 'page.system.menu.table.addChildren',
+              defaultMessage: '新增子菜单',
+            })}
+          >
+            <PlusCircleOutlined />
+          </Tooltip>
+        </a>,
+        <Popconfirm
+          key="unBindUser"
+          title={intl.formatMessage({
+            id: 'page.system.menu.table.delete',
+            defaultMessage: '是否删除该菜单',
+          })}
+          onConfirm={() => {
+            deleteItem(record.id);
+          }}
+        >
+          <Tooltip
+            title={intl.formatMessage({
+              id: 'pages.data.option.delete',
+              defaultMessage: '删除',
+            })}
+          >
+            <a key="delete">
+              <DeleteOutlined />
+            </a>
+          </Tooltip>
+        </Popconfirm>,
+      ],
+    },
+  ];
+
+  /**
+   * table 查询参数
+   * @param data
+   */
+  const searchFn = (data: any) => {
+    setParam({
+      terms: data,
+    });
+  };
+
+  const modalCancel = () => {
+    State.current = {};
+    State.visible = false;
+    form.resetFields();
+  };
+
+  const saveData = async () => {
+    const formData = await form.validateFields();
+    if (formData) {
+      const _data = {
+        ...formData,
+        parentId: State.current.parentId,
+      };
+      const response: any = await service.save(_data);
+      if (response.status === 200) {
+        message.success('操作成功!');
+        modalCancel();
+        pageJump(response.result.id);
+      } else {
+        message.error('操作成功!');
+      }
+    }
+  };
+
+  return (
+    <PageContainer>
+      <Card>
+        <SearchComponent field={columns} onSearch={searchFn} />
+      </Card>
+      <Divider />
+      <ProTable<MenuItem>
+        columns={columns}
+        actionRef={actionRef}
+        rowKey="id"
+        pagination={false}
+        search={false}
+        params={param}
+        request={async (params) => {
+          const response = await service.queryMenuThree({ ...params, paging: false });
+          return {
+            code: response.message,
+            result: {
+              data: response.result,
+              pageIndex: 0,
+              pageSize: 0,
+              total: 0,
+            },
+            status: response.status,
+          };
+        }}
+        toolBarRender={() => [
+          <Button
+            onClick={() => {
+              State.visible = true;
+            }}
+            key="button"
+            icon={<PlusOutlined />}
+            type="primary"
+          >
+            {intl.formatMessage({
+              id: 'pages.data.option.add',
+              defaultMessage: '新增',
+            })}
+          </Button>,
+        ]}
+        headerTitle={intl.formatMessage({
+          id: 'pages.system.menu',
+          defaultMessage: '菜单列表',
+        })}
+      />
+      <Modal
+        title={intl.formatMessage({
+          id: State.current.parentId
+            ? 'pages.system.menu.option.addChildren'
+            : 'pages.data.option.add',
+          defaultMessage: '新增',
+        })}
+        visible={State.visible}
+        width={660}
+        onOk={saveData}
+        onCancel={modalCancel}
+      >
+        <Form form={form} labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
+          <Form.Item
+            name="code"
+            label={intl.formatMessage({
+              id: 'page.system.menu.encoding',
+              defaultMessage: '编码',
+            })}
+            required={true}
+            rules={[
+              { required: true, message: '请输入编码' },
+              { max: 64, message: '最多可输入64个字符' },
+              {
+                pattern: /^[a-zA-Z0-9`!@#$%^&*()_+\-={}|\\\]\[;':",.\/<>?]+$/,
+                message: '请输入英文+数字+特殊字符(`!@#$%^&*()_+-={}|\\][;\':",./<>?)',
+              },
+            ]}
+          >
+            <Input />
+          </Form.Item>
+          <Form.Item
+            name="name"
+            label={intl.formatMessage({
+              id: 'pages.table.name',
+              defaultMessage: '名称',
+            })}
+            required={true}
+            rules={[
+              { required: true, message: '请输入名称' },
+              { max: 64, message: '最多可输入64个字符' },
+            ]}
+          >
+            <Input />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </PageContainer>
+  );
+});

+ 117 - 0
src/pages/system/Menu/save.tsx

@@ -0,0 +1,117 @@
+// Modal 弹窗,用于新增、修改数据
+import React from 'react';
+import { createForm } from '@formily/core';
+import { createSchemaField } from '@formily/react';
+import {
+  ArrayTable,
+  Editable,
+  Form,
+  FormGrid,
+  FormItem,
+  FormTab,
+  Input,
+  NumberPicker,
+  Password,
+  Select,
+  Switch,
+  Upload,
+  Checkbox,
+} from '@formily/antd';
+import { message, Modal } from 'antd';
+import { useIntl } from '@@/plugin-locale/localeExports';
+import type { ISchema } from '@formily/json-schema';
+import type { ModalProps } from 'antd/lib/modal/Modal';
+import FUpload from '@/components/Upload';
+import * as ICONS from '@ant-design/icons';
+import type BaseService from '@/utils/BaseService';
+
+export interface SaveModalProps<T> extends Omit<ModalProps, 'onOk' | 'onCancel'> {
+  service: BaseService<T>;
+  data?: Partial<T>;
+  /**
+   * Model关闭事件
+   * @param type 是否为请求接口后关闭,用于外部table刷新数据
+   */
+  onCancel?: (type: boolean) => void;
+  schema: ISchema;
+}
+
+const Save = <T extends object>(props: SaveModalProps<T>) => {
+  const { data, schema, onCancel, service } = props;
+  const intl = useIntl();
+
+  const SchemaField = createSchemaField({
+    components: {
+      FormItem,
+      FormTab,
+      Input,
+      Password,
+      Upload,
+      Select,
+      ArrayTable,
+      Switch,
+      FormGrid,
+      Editable,
+      NumberPicker,
+      FUpload,
+      Checkbox,
+    },
+    scope: {
+      icon(name: any) {
+        return React.createElement(ICONS[name]);
+      },
+    },
+  });
+
+  const form = createForm({
+    validateFirst: true,
+    initialValues: data || {},
+  });
+
+  /**
+   * 关闭Modal
+   * @param type 是否需要刷新外部table数据
+   */
+  const modalClose = (type: boolean) => {
+    if (typeof onCancel === 'function') {
+      onCancel(type);
+    }
+  };
+
+  /**
+   * 新增、修改数据
+   */
+  const saveData = async () => {
+    const formData: T = await form.submit();
+
+    const response =
+      data && 'id' in data ? await service.update(formData) : await service.save(formData);
+
+    if (response.status === 200) {
+      message.success('操作成功!');
+      modalClose(true);
+    } else {
+      message.error('操作成功!');
+    }
+  };
+
+  return (
+    <Modal
+      title={intl.formatMessage({
+        id: `pages.data.option.${data && 'id' in data ? 'edit' : 'add'}`,
+        defaultMessage: '新增',
+      })}
+      visible={props.visible}
+      onOk={saveData}
+      onCancel={() => {
+        modalClose(false);
+      }}
+    >
+      <Form form={form} labelCol={5} wrapperCol={16}>
+        <SchemaField schema={schema} />
+      </Form>
+    </Modal>
+  );
+};
+
+export default Save;

+ 23 - 0
src/pages/system/Menu/service.ts

@@ -0,0 +1,23 @@
+import BaseService from '@/utils/BaseService';
+import { request } from '@@/plugin-request/request';
+import type { MenuItem } from './typing';
+import SystemConst from '@/utils/const';
+
+class Service extends BaseService<MenuItem> {
+  /**
+   * 获取当前用户可访问菜单
+   * @param data
+   */
+  queryMenuThree = (data: any) => request(`${this.uri}/_all/tree`, { method: 'POST', data });
+
+  /**
+   * 查询权限管理
+   * @param data
+   */
+  queryPermission = (data: any) =>
+    request(`${SystemConst.API_BASE}/permission/_query`, { method: 'POST', data });
+
+  queryDetail = (id: string) => request(`${this.uri}/${id}`, { method: 'GET' });
+}
+
+export default Service;

+ 81 - 0
src/pages/system/Menu/typing.d.ts

@@ -0,0 +1,81 @@
+export type MenuItem = {
+  id: string;
+  /**
+   * 名称
+   */
+  name: string;
+  /**
+   * 编码
+   */
+  code: string;
+  /**
+   * 所属应用
+   */
+  application: string;
+  /**
+   * 描述
+   */
+  describe: string;
+  /**
+   * url,路由
+   */
+  url: string;
+  /**
+   * 图标
+   */
+  icon: string;
+  /**
+   * 状态, 0为禁用,1为启用
+   */
+  status: number;
+  /**
+   * 绑定权限信息
+   */
+  permissions: PermissionInfo[];
+  /**
+   * 按钮定义信息
+   */
+  buttons: MenuButtonInfo[];
+  /**
+   * 其他配置信息
+   */
+  options: Record<string, any>;
+  /**
+   * 父级ID
+   */
+  parentId: string;
+  /**
+   * 树结构路径
+   */
+  path: string;
+  /**
+   * 排序序号
+   */
+  sortIndex: number;
+  /**
+   * 树层级
+   */
+  level: number;
+  createTime: number;
+  redirect?: string;
+  children?: MenuItem[];
+};
+
+/**
+ * 权限信息
+ */
+export type PermissionInfo = {
+  permission: string;
+  actions: string[];
+};
+
+/**
+ * 按钮信息
+ */
+export type MenuButtonInfo = {
+  id: string;
+  name: string;
+  permissions: PermissionInfo;
+  createTime: number;
+  options: Record<string, any>;
+};

+ 204 - 26
src/utils/menu.ts

@@ -1,27 +1,205 @@
 // 路由components映射
-// const findComponents = (files: __WebpackModuleApi.RequireContext) => {
-//   const modules = {};
-//   files.keys().forEach((key) => {
-//     // 删除路径开头的./ 以及结尾的 /index;
-//     const str = key.replace(/(\.\/|\.tsx)/g, '').replace('/index', '');
-//     modules[str] = files(key).default;
-//   });
-//   return modules;
-// };
-//
-// /**
-//  * 处理为正确的路由格式
-//  * @param extraRoutes 后端菜单数据
-//  */
-// const getRoutes = (extraRoutes: any[]) => {
-//   const allComponents = findComponents(require.context('@/pages', true, /index(\.tsx)$/));
-//   return extraRoutes.map((route) => {
-//     const component = allComponents[route.key];
-//     return {
-//       ...route,
-//       component,
-//     };
-//   });
-// };
-//
-// export default getRoutes;
+import type { IRouteProps } from 'umi';
+import type { MenuItem } from '@/pages/system/Menu/typing';
+
+/** localStorage key */
+export const MENUS_DATA_CACHE = 'MENUS_DATA_CACHE';
+
+/** 路由Code */
+export const MENUS_CODE = {
+  'Analysis/CPU': 'Analysis/CPU',
+  'Analysis/DeviceChart': 'Analysis/DeviceChart',
+  'Analysis/DeviceMessage': 'Analysis/DeviceMessage',
+  'Analysis/Jvm': 'Analysis/Jvm',
+  'Analysis/MessageChart': 'Analysis/MessageChart',
+  Analysis: 'Analysis',
+  'cloud/Aliyun': 'cloud/Aliyun',
+  'cloud/Ctwing': 'cloud/Ctwing',
+  'cloud/DuerOS': 'cloud/DuerOS',
+  'cloud/Onenet': 'cloud/Onenet',
+  'device/Alarm': 'device/Alarm',
+  'device/Category/Save': 'device/Category/Save',
+  'device/Category': 'device/Category',
+  'device/Command': 'device/Command',
+  'device/DataSource': 'device/DataSource',
+  'device/Firmware/Detail/History': 'device/Firmware/Detail/History',
+  'device/Firmware/Detail/Task/Detail': 'device/Firmware/Detail/Task/Detail',
+  'device/Firmware/Detail/Task/Release': 'device/Firmware/Detail/Task/Release',
+  'device/Firmware/Detail/Task/Save': 'device/Firmware/Detail/Task/Save',
+  'device/Firmware/Detail/Task': 'device/Firmware/Detail/Task',
+  'device/Firmware/Detail': 'device/Firmware/Detail',
+  'device/Firmware/Save': 'device/Firmware/Save',
+  'device/Firmware': 'device/Firmware',
+  'device/Instance/Detail/Config/Tags': 'device/Instance/Detail/Config/Tags',
+  'device/Instance/Detail/Config': 'device/Instance/Detail/Config',
+  'device/Instance/Detail/Functions': 'device/Instance/Detail/Functions',
+  'device/Instance/Detail/Info': 'device/Instance/Detail/Info',
+  'device/Instance/Detail/Log': 'device/Instance/Detail/Log',
+  'device/Instance/Detail/MetadataLog/Event': 'device/Instance/Detail/MetadataLog/Event',
+  'device/Instance/Detail/MetadataLog/Property': 'device/Instance/Detail/MetadataLog/Property',
+  'device/Instance/Detail/Running': 'device/Instance/Detail/Running',
+  'device/Instance/Detail': 'device/Instance/Detail',
+  'device/Instance': 'device/Instance',
+  'device/Location': 'device/Location',
+  'device/Product/Detail/BaseInfo': 'device/Product/Detail/BaseInfo',
+  'device/Product/Detail': 'device/Product/Detail',
+  'device/Product/Save': 'device/Product/Save',
+  'device/Product': 'device/Product',
+  'device/components/Alarm/Edit': 'device/components/Alarm/Edit',
+  'device/components/Alarm/Record': 'device/components/Alarm/Record',
+  'device/components/Alarm/Setting': 'device/components/Alarm/Setting',
+  'device/components/Alarm': 'device/components/Alarm',
+  'device/components/Metadata/Base/Edit': 'device/components/Metadata/Base/Edit',
+  'device/components/Metadata/Base': 'device/components/Metadata/Base',
+  'device/components/Metadata/Cat': 'device/components/Metadata/Cat',
+  'device/components/Metadata/Import': 'device/components/Metadata/Import',
+  'device/components/Metadata': 'device/components/Metadata',
+  'edge/Device': 'edge/Device',
+  'edge/Product': 'edge/Product',
+  'link/Certificate': 'link/Certificate',
+  'link/Gateway': 'link/Gateway',
+  'link/Opcua': 'link/Opcua',
+  'link/Protocol/Debug': 'link/Protocol/Debug',
+  'link/Protocol': 'link/Protocol',
+  'link/Type': 'link/Type',
+  'log/Access': 'log/Access',
+  'log/System': 'log/System',
+  'media/Cascade': 'media/Cascade',
+  'media/Config': 'media/Config',
+  'media/Device': 'media/Device',
+  'media/Reveal': 'media/Reveal',
+  'notice/Config': 'notice/Config',
+  'notice/Template': 'notice/Template',
+  'rule-engine/Instance': 'rule-engine/Instance',
+  'rule-engine/SQLRule': 'rule-engine/SQLRule',
+  'rule-engine/Scene': 'rule-engine/Scene',
+  'simulator/Device': 'simulator/Device',
+  'system/DataSource': 'system/DataSource',
+  'system/Department/Assets': 'system/Department/Assets',
+  'system/Department/Member': 'system/Department/Member',
+  'system/Department': 'system/Department',
+  'system/Menu/Detail': 'system/Menu/Detail',
+  'system/Menu': 'system/Menu',
+  'system/OpenAPI': 'system/OpenAPI',
+  'system/Permission': 'system/Permission',
+  'system/Role/Edit/Info': 'system/Role/Edit/Info',
+  'system/Role/Edit/UserManage': 'system/Role/Edit/UserManage',
+  'system/Role/Edit': 'system/Role/Edit',
+  'system/Role': 'system/Role',
+  'system/Tenant/Detail/Assets': 'system/Tenant/Detail/Assets',
+  'system/Tenant/Detail/Info': 'system/Tenant/Detail/Info',
+  'system/Tenant/Detail/Member': 'system/Tenant/Detail/Member',
+  'system/Tenant/Detail/Permission': 'system/Tenant/Detail/Permission',
+  'system/Tenant/Detail': 'system/Tenant/Detail',
+  'system/Tenant': 'system/Tenant',
+  'system/User/Save': 'system/User/Save',
+  'system/User': 'system/User',
+  'user/Login': 'user/Login',
+  'visualization/Category': 'visualization/Category',
+  'visualization/Configuration': 'visualization/Configuration',
+  'visualization/Screen': 'visualization/Screen',
+};
+
+/**
+ * 根据url获取映射的组件
+ * @param files
+ */
+const findComponents = (files: __WebpackModuleApi.RequireContext) => {
+  const modules = {};
+  files.keys().forEach((key) => {
+    // 删除路径开头的./ 以及结尾的 /index;
+    const str = key.replace(/(\.\/|\.tsx)/g, '').replace('/index', '');
+    modules[str] = files(key).default;
+  });
+  return modules;
+};
+
+/**
+ * 扁平化路由树
+ * @param routes
+ */
+export const flatRoute = (routes: MenuItem[]): MenuItem[] => {
+  return routes.reduce<MenuItem[]>((pValue, currValue) => {
+    const menu: MenuItem[] = [];
+    const { children, ...extraRoute } = currValue;
+    menu.push(extraRoute);
+    return [...pValue, ...menu, ...flatRoute(children || [])];
+  }, []);
+};
+
+/**
+ * 处理为正确的路由格式
+ * @param extraRoutes 后端菜单数据
+ * @param level 路由层级
+ */
+const getRoutes = (extraRoutes: MenuItem[], level: number = 1) => {
+  const allComponents = findComponents(require.context('@/pages', true, /index(\.tsx)$/));
+  const Menus: IRouteProps[] = [];
+  extraRoutes.forEach((route) => {
+    const component = allComponents[route.code] || null;
+    if (level === 1) {
+      Menus.push({
+        key: route.url,
+        name: route.name,
+        path: route.url,
+        children: getRoutes(flatRoute(route.children || []), level + 1),
+      });
+    }
+    if (component) {
+      Menus.push({
+        key: route.url,
+        name: route.name,
+        path: route.url,
+        exact: true,
+        component,
+      });
+    }
+  });
+  return Menus;
+};
+
+export const getMenus = (extraRoutes: MenuItem[], level: number = 1): any[] => {
+  return extraRoutes.map((route) => {
+    const children =
+      route.children && route.children.length ? getMenus(route.children, level + 1) : [];
+    return {
+      key: route.url,
+      name: route.name,
+      path: route.url,
+      hideChildrenInMenu: level === 2,
+      exact: true,
+      state: {
+        params: {
+          id: 123,
+        },
+      },
+      children: children,
+    };
+  });
+};
+
+/** 缓存路由数据,格式为 [{ code: url }] */
+export const saveMenusCache = (routes: MenuItem[]) => {
+  const list: MenuItem[] = flatRoute(routes);
+  const listObject = {};
+  list.forEach((route) => {
+    listObject[route.code] = route.url;
+  });
+  try {
+    localStorage.setItem(MENUS_DATA_CACHE, JSON.stringify(listObject));
+  } catch (e) {
+    console.log(e);
+  }
+};
+
+/**
+ * 通过缓存的数据取出相应的路由url
+ * @param code
+ */
+export const getMenuPathBuCode = (code: string): string => {
+  const menusStr = localStorage.getItem(MENUS_DATA_CACHE) || '{}';
+  const menusData = JSON.parse(menusStr);
+  return menusData[code];
+};
+
+export default getRoutes;