Jelajahi Sumber

feat: 设备列表

sun-chaochao 3 tahun lalu
induk
melakukan
466fdcb6bb

+ 2 - 0
package.json

@@ -78,6 +78,7 @@
     "braft-editor": "^2.3.9",
     "classnames": "^2.2.6",
     "dexie": "^3.0.3",
+    "event-source-polyfill": "^1.0.25",
     "isomorphic-form-data": "^2.0.0",
     "jetlinks-store": "^0.0.3",
     "lodash": "^4.17.11",
@@ -101,6 +102,7 @@
   },
   "devDependencies": {
     "@ant-design/pro-cli": "^2.0.2",
+    "@types/event-source-polyfill": "^1.0.0",
     "@types/express": "^4.17.0",
     "@types/history": "^4.7.2",
     "@types/jest": "^26.0.0",

+ 141 - 0
src/pages/device/Instance/Export/index.tsx

@@ -0,0 +1,141 @@
+import { FormItem, Select, FormLayout } from '@formily/antd';
+import { createForm } from '@formily/core';
+import { createSchemaField, FormProvider } from '@formily/react';
+import { Alert, Modal, Radio } from 'antd';
+import 'antd/lib/tree-select/style/index.less';
+import { useEffect, useState } from 'react';
+import { service } from '@/pages/device/Instance';
+import type { DeviceInstance } from '../typings';
+import SystemConst from '@/utils/const';
+import Token from '@/utils/token';
+import encodeQuery from '@/utils/encodeQuery';
+
+interface Props {
+  visible: boolean;
+  close: () => void;
+  data?: DeviceInstance;
+}
+
+const Export = (props: Props) => {
+  const { visible, close } = props;
+  const [productList, setProductList] = useState<any[]>([]);
+  const SchemaField = createSchemaField({
+    components: {
+      Radio,
+      Select,
+      FormItem,
+      FormLayout,
+    },
+  });
+
+  useEffect(() => {
+    service.getProductList({ paging: false }).then((resp) => {
+      if (resp.status === 200) {
+        const list = resp.result.map((item: { name: any; id: any }) => ({
+          label: item.name,
+          value: item.id,
+        }));
+        console.log(list);
+        setProductList(list);
+      }
+    });
+  }, []);
+
+  const form = createForm();
+  const schema = {
+    type: 'object',
+    properties: {
+      layout: {
+        type: 'void',
+        'x-component': 'FormLayout',
+        'x-component-props': {
+          labelCol: 4,
+          wrapperCol: 18,
+          labelAlign: 'right',
+        },
+        properties: {
+          product: {
+            type: 'string',
+            title: '产品',
+            'x-decorator': 'FormItem',
+            'x-component': 'Select',
+            enum: [...productList],
+          },
+          fileType: {
+            type: 'number',
+            title: '文件格式',
+            default: 'xlsx',
+            enum: [
+              {
+                label: 'xlsx',
+                value: 'xlsx',
+              },
+              {
+                label: 'csv',
+                value: 'csv',
+              },
+            ],
+            'x-decorator': 'FormItem',
+            'x-component': 'Radio.Group',
+            'x-component-props': {
+              optionType: 'button',
+              buttonStyle: 'solid',
+            },
+          },
+        },
+      },
+    },
+  };
+  const downloadTemplate = async () => {
+    const values = (await form.submit()) as any;
+    const formElement = document.createElement('form');
+    formElement.style.display = 'display:none;';
+    formElement.method = 'GET';
+    if (values.product) {
+      formElement.action = `/${SystemConst.API_BASE}/device/instance/${values.product}/export.${values.fileType}`;
+    } else {
+      formElement.action = `/${SystemConst.API_BASE}/device/instance/export.${values.fileType}`;
+    }
+    const params = encodeQuery(props.data);
+    Object.keys(params).forEach((key: string) => {
+      const inputElement = document.createElement('input');
+      inputElement.type = 'hidden';
+      inputElement.name = key;
+      inputElement.value = params[key];
+      formElement.appendChild(inputElement);
+    });
+    const inputElement = document.createElement('input');
+    inputElement.type = 'hidden';
+    inputElement.name = ':X_Access_Token';
+    inputElement.value = Token.get();
+    formElement.appendChild(inputElement);
+
+    document.body.appendChild(formElement);
+    formElement.submit();
+    document.body.removeChild(formElement);
+  };
+  return (
+    <Modal
+      visible={visible}
+      onCancel={() => close()}
+      width="35vw"
+      title="导出"
+      onOk={() => {
+        downloadTemplate();
+      }}
+    >
+      <Alert
+        message="不勾选产品,默认导出所有设备的基础数据,勾选单个产品可导出下属的详细数据"
+        type="warning"
+        showIcon
+        closable
+      />
+      <div style={{ marginTop: '20px' }}>
+        <FormProvider form={form}>
+          <SchemaField schema={schema} />
+        </FormProvider>
+      </div>
+    </Modal>
+  );
+};
+export default Export;

+ 275 - 0
src/pages/device/Instance/Import/index.tsx

@@ -0,0 +1,275 @@
+import { FormItem, Select, FormLayout } from '@formily/antd';
+import { createForm, onFieldValueChange } from '@formily/core';
+import { createSchemaField, FormProvider } from '@formily/react';
+import { Modal, Radio, Checkbox, Space, Upload, Button, Badge, message } from 'antd';
+import 'antd/lib/tree-select/style/index.less';
+import { useEffect, useState } from 'react';
+import { service } from '@/pages/device/Instance';
+import type { DeviceInstance } from '../typings';
+import FUpload from '@/components/Upload';
+import { UploadOutlined } from '@ant-design/icons';
+import SystemConst from '@/utils/const';
+import Token from '@/utils/token';
+import { EventSourcePolyfill } from 'event-source-polyfill';
+
+interface Props {
+  visible: boolean;
+  close: () => void;
+  data?: DeviceInstance;
+}
+
+const FileFormat = (props: any) => {
+  const [data, setData] = useState<{ autoDeploy: boolean; fileType: 'xlsx' | 'csv' }>({
+    autoDeploy: false,
+    fileType: 'xlsx',
+  });
+  return (
+    <Space>
+      <Radio.Group
+        defaultValue="xlsx"
+        buttonStyle="solid"
+        onChange={(e) => {
+          setData({
+            ...data,
+            fileType: e.target.value,
+          });
+          props.onChange({
+            ...data,
+            fileType: e.target.value,
+          });
+        }}
+      >
+        <Radio.Button value="xlsx">xlsx</Radio.Button>
+        <Radio.Button value="csv">csv</Radio.Button>
+      </Radio.Group>
+      <Checkbox
+        onChange={(e) => {
+          setData({
+            ...data,
+            autoDeploy: e.target.checked,
+          });
+          props.onChange({
+            ...data,
+            autoDeploy: e.target.checked,
+          });
+        }}
+      >
+        自动启用
+      </Checkbox>
+    </Space>
+  );
+};
+
+const downloadTemplate = (type: string, product: string) => {
+  const formElement = document.createElement('form');
+  formElement.style.display = 'display:none;';
+  formElement.method = 'GET';
+  formElement.action = `/${SystemConst.API_BASE}/device-instance/${product}/template.${type}`;
+  const inputElement = document.createElement('input');
+  inputElement.type = 'hidden';
+  inputElement.name = ':X_Access_Token';
+  inputElement.value = Token.get();
+  formElement.appendChild(inputElement);
+  document.body.appendChild(formElement);
+  formElement.submit();
+  document.body.removeChild(formElement);
+};
+
+const NormalUpload = (props: any) => {
+  return (
+    <div>
+      <Space>
+        <Upload
+          action={`/${SystemConst.API_BASE}/file/static`}
+          headers={{
+            'X-Access-Token': Token.get(),
+          }}
+          onChange={(info) => {
+            if (info.file.status === 'done') {
+              message.success('上传成功');
+              props.onChange(info?.file?.response?.result || '');
+            }
+          }}
+          showUploadList={false}
+        >
+          <Button icon={<UploadOutlined />}>上传文件</Button>
+        </Upload>
+        <div style={{ marginLeft: 20 }}>
+          下载模板
+          <a
+            style={{ marginLeft: 10 }}
+            onClick={() => {
+              downloadTemplate('xlsx', props.product);
+            }}
+          >
+            .xlsx
+          </a>
+          <a
+            style={{ marginLeft: 10 }}
+            onClick={() => {
+              downloadTemplate('csv', props.product);
+            }}
+          >
+            .csv
+          </a>
+        </div>
+      </Space>
+    </div>
+  );
+};
+
+const Import = (props: Props) => {
+  const { visible, close } = props;
+  const [productList, setProductList] = useState<any[]>([]);
+  const [importLoading, setImportLoading] = useState(false);
+  const [flag, setFlag] = useState<boolean>(true);
+  const [count, setCount] = useState<number>(0);
+  const [errMessage, setErrMessage] = useState<string>('');
+
+  const SchemaField = createSchemaField({
+    components: {
+      Radio,
+      Select,
+      FormItem,
+      FormLayout,
+      FUpload,
+      FileFormat,
+      NormalUpload,
+    },
+  });
+
+  useEffect(() => {
+    service.getProductList({ paging: false }).then((resp) => {
+      if (resp.status === 200) {
+        const list = resp.result.map((item: { name: any; id: any }) => ({
+          label: item.name,
+          value: item.id,
+        }));
+        setProductList(list);
+      }
+    });
+  }, []);
+
+  const form = createForm({
+    effects() {
+      onFieldValueChange('product', (field) => {
+        form.setFieldState('*(fileType, upload)', (state) => {
+          state.visible = !!field.value;
+        });
+        form.setFieldState('*(fileType)', (state) => {
+          state.componentProps = {
+            product: field.value,
+          };
+        });
+      });
+    },
+  });
+
+  const schema = {
+    type: 'object',
+    properties: {
+      layout: {
+        type: 'void',
+        'x-component': 'FormLayout',
+        'x-component-props': {
+          labelCol: 4,
+          wrapperCol: 18,
+          labelAlign: 'right',
+        },
+        properties: {
+          product: {
+            type: 'string',
+            title: '产品',
+            required: true,
+            'x-decorator': 'FormItem',
+            'x-component': 'Select',
+            enum: [...productList],
+          },
+          fileType: {
+            title: '文件格式',
+            'x-visible': false,
+            'x-decorator': 'FormItem',
+            'x-component': 'FileFormat',
+          },
+          upload: {
+            type: 'string',
+            title: '文件上传',
+            'x-visible': false,
+            'x-decorator': 'FormItem',
+            'x-component': 'NormalUpload',
+          },
+        },
+      },
+    },
+  };
+
+  const submitData = () => {
+    const values = form.getFormState().values;
+    if (!!values?.upload) {
+      setCount(0);
+      setErrMessage('');
+      setFlag(true);
+      const autoDeploy = !!values?.fileType?.autoDeploy || false;
+      setImportLoading(true);
+      let dt = 0;
+      const source = new EventSourcePolyfill(
+        `/${SystemConst.API_BASE}/device/instance/${values.product}/import?fileUrl=${
+          values?.upload
+        }&autoDeploy=${autoDeploy}&:X_Access_Token=${Token.get()}`,
+      );
+      source.onmessage = (e: any) => {
+        const res = JSON.parse(e.data);
+        if (res.success) {
+          close();
+          const temp = res.result.total;
+          dt += temp;
+          setCount(dt);
+        } else {
+          setErrMessage(res.message);
+        }
+      };
+      source.onerror = () => {
+        setFlag(false);
+        source.close();
+      };
+      source.onopen = () => {};
+    } else {
+      message.error('请先上传文件');
+    }
+  };
+  return (
+    <Modal
+      visible={visible}
+      onCancel={() => close()}
+      width="35vw"
+      title="导出"
+      onOk={() => submitData()}
+      footer={[
+        <Button key="cancel" onClick={() => close()}>
+          取消
+        </Button>,
+        <Button key="ok" type="primary" onClick={() => submitData()}>
+          确认
+        </Button>,
+      ]}
+    >
+      <div style={{ marginTop: '20px' }}>
+        <FormProvider form={form}>
+          <SchemaField schema={schema} />
+        </FormProvider>
+      </div>
+      {importLoading && (
+        <div style={{ marginLeft: 20 }}>
+          {flag ? (
+            <Badge status="processing" text="进行中" />
+          ) : (
+            <Badge status="success" text="已完成" />
+          )}
+          <span style={{ marginLeft: 15 }}>总数量:{count}</span>
+          <p style={{ color: 'red' }}>{errMessage}</p>
+        </div>
+      )}
+    </Modal>
+  );
+};
+export default Import;

+ 91 - 0
src/pages/device/Instance/Process/index.tsx

@@ -0,0 +1,91 @@
+import { Modal, Badge } from 'antd';
+import { useState, useEffect } from 'react';
+import { EventSourcePolyfill } from 'event-source-polyfill';
+
+interface Props {
+  api: string;
+  closeVisible: () => void;
+  action: string;
+}
+interface State {
+  source: any;
+}
+const Process = (props: Props) => {
+  const initState: State = {
+    source: {},
+  };
+  const [eventSource, setSource] = useState<any>(initState.source);
+  const [count, setCount] = useState<number>(0);
+  const [flag, setFlag] = useState<boolean>(true);
+  const [errMessage, setErrMessage] = useState<string>('');
+  const { action } = props;
+
+  const getData = () => {
+    let dt = 0;
+
+    const source = new EventSourcePolyfill(props.api);
+    setSource(source);
+    source.onmessage = (e: any) => {
+      const res = JSON.parse(e.data);
+      switch (action) {
+        case 'active':
+          if (res.success) {
+            dt += res.total;
+            setCount(dt);
+          }
+          break;
+        case 'sync':
+          dt += res;
+          setCount(dt);
+          break;
+        case 'import':
+          if (res.success) {
+            const temp = res.result.total;
+            dt += temp;
+            setCount(dt);
+          } else {
+            setErrMessage(res.message);
+          }
+          break;
+        default:
+          break;
+      }
+    };
+    source.onerror = () => {
+      setFlag(false);
+      source.close();
+    };
+    source.onopen = () => {};
+  };
+  useEffect(() => {
+    getData();
+  }, []);
+  return (
+    <Modal
+      title="当前进度"
+      visible
+      confirmLoading={flag}
+      okText="确认"
+      onOk={() => {
+        props.closeVisible();
+        setCount(0);
+        eventSource.close();
+      }}
+      cancelText="关闭"
+      onCancel={() => {
+        props.closeVisible();
+        setCount(0);
+        eventSource.close();
+      }}
+    >
+      {flag ? (
+        <Badge status="processing" text="进行中" />
+      ) : (
+        <Badge status="success" text="已完成" />
+      )}
+      <p>总数量:{count}</p>
+      <p style={{ color: 'red' }}>{errMessage}</p>
+    </Modal>
+  );
+};
+export default Process;

+ 140 - 0
src/pages/device/Instance/Save/index.tsx

@@ -0,0 +1,140 @@
+import { message, Modal } from 'antd';
+import { createForm } from '@formily/core';
+import { Form, FormItem, FormLayout, Input, Radio, Select } from '@formily/antd';
+import { createSchemaField } from '@formily/react';
+import type { ISchema } from '@formily/json-schema';
+import FUpload from '@/components/Upload';
+import { service } from '@/pages/device/Instance';
+import 'antd/lib/tree-select/style/index.less';
+import type { DeviceInstance } from '../typings';
+import { useEffect, useState } from 'react';
+
+interface Props {
+  visible: boolean;
+  close: () => void;
+  data?: DeviceInstance;
+}
+
+const Save = (props: Props) => {
+  const { visible, close, data } = props;
+  const [productList, setProductList] = useState<any[]>([]);
+  const form = createForm({
+    initialValues: data,
+  });
+
+  useEffect(() => {
+    service.getProductList({ paging: false }).then((resp) => {
+      if (resp.status === 200) {
+        const list = resp.result.map((item: { name: any; id: any }) => ({
+          label: item.name,
+          value: item.id,
+        }));
+        setProductList(list);
+      }
+    });
+  }, []);
+  const SchemaField = createSchemaField({
+    components: {
+      FormItem,
+      Input,
+      Select,
+      Radio,
+      FUpload,
+      FormLayout,
+    },
+  });
+
+  const handleSave = async () => {
+    const values = (await form.submit()) as any;
+    const productId = values.productId;
+    if (productId) {
+      const product = productList.find((i) => i.value === productId);
+      values.productName = product.label;
+    }
+    const resp = (await service.update(values)) as any;
+    if (resp.status === 200) {
+      message.success('保存成功');
+      props.close();
+    }
+  };
+  const schema: ISchema = {
+    type: 'object',
+    properties: {
+      layout: {
+        type: 'void',
+        'x-component': 'FormLayout',
+        'x-component-props': {
+          labelCol: 4,
+          wrapperCol: 18,
+        },
+        properties: {
+          id: {
+            title: 'ID',
+            'x-disabled': !!data?.id,
+            'x-component': 'Input',
+            'x-decorator': 'FormItem',
+            'x-decorator-props': {
+              tooltip: <div>若不填写,系统将自动生成唯一ID</div>,
+            },
+            // 'x-validator': `{{(value) => {
+            //     return service.isExists(value).then(resp => {
+            //         if(resp.status === 200){
+            //             if(!resp.result){
+            //                 resolve('已存在该设备ID')
+            //             }
+            //         } else {
+            //             resolve('')
+            //         }
+            //     })
+            //   }}}`
+          },
+          name: {
+            title: '名称',
+            'x-component': 'Input',
+            'x-decorator': 'FormItem',
+            'x-validator': [
+              {
+                required: true,
+                message: '请输入名称',
+              },
+              {
+                max: 64,
+                message: '最多可输入64个字符',
+              },
+            ],
+          },
+          productId: {
+            title: '所属产品',
+            required: true,
+            'x-component': 'Select',
+            'x-decorator': 'FormItem',
+            enum: [...productList],
+          },
+          describe: {
+            title: '描述',
+            'x-component': 'Input.TextArea',
+            'x-decorator': 'FormItem',
+            'x-component-props': {
+              showCount: true,
+              maxLength: 200,
+            },
+          },
+        },
+      },
+    },
+  };
+  return (
+    <Modal
+      visible={visible}
+      onCancel={() => close()}
+      width="30vw"
+      title={data?.id ? '编辑' : '新增'}
+      onOk={handleSave}
+    >
+      <Form form={form}>
+        <SchemaField schema={schema} />
+      </Form>
+    </Modal>
+  );
+};
+export default Save;

+ 253 - 34
src/pages/device/Instance/index.tsx

@@ -2,21 +2,32 @@ import { PageContainer } from '@ant-design/pro-layout';
 import type { ProColumns, ActionType } from '@jetlinks/pro-table';
 import type { DeviceInstance } from '@/pages/device/Instance/typings';
 import moment from 'moment';
-import { Badge, message, Popconfirm, Tooltip } from 'antd';
-import { useRef } from 'react';
-import BaseCrud from '@/components/BaseCrud';
+import { Badge, Button, Dropdown, Menu, message, Popconfirm, Tooltip } from 'antd';
+import { useRef, useState } from 'react';
 import { Link } from 'umi';
 import {
-  CloseCircleOutlined,
-  EditOutlined,
-  EyeOutlined,
-  PlayCircleOutlined,
+  CheckCircleOutlined,
+  DeleteOutlined,
+  ExportOutlined,
+  ImportOutlined,
+  PlusOutlined,
+  SearchOutlined,
+  StopOutlined,
+  SyncOutlined,
 } from '@ant-design/icons';
-import { CurdModel } from '@/components/BaseCrud/model';
 import { model } from '@formily/reactive';
 import Service from '@/pages/device/Instance/service';
 import type { MetadataItem } from '@/pages/device/Product/typings';
 import { useIntl } from '@@/plugin-locale/localeExports';
+import Save from './Save';
+import Export from './Export';
+import Import from './Import';
+import Process from './Process';
+import ProTable from '@jetlinks/pro-table';
+import encodeQuery from '@/utils/encodeQuery';
+import SearchComponent from '@/components/SearchComponent';
+import SystemConst from '@/utils/const';
+import Token from '@/utils/token';
 
 const statusMap = new Map();
 statusMap.set('在线', 'success');
@@ -43,7 +54,17 @@ export const InstanceModel = model<{
 export const service = new Service('device/instance');
 const Instance = () => {
   const actionRef = useRef<ActionType>();
+  const [visible, setVisible] = useState<boolean>(false);
+  const [exportVisible, setExportVisible] = useState<boolean>(false);
+  const [importVisible, setImportVisible] = useState<boolean>(false);
+  const [operationVisible, setOperationVisible] = useState<boolean>(false);
+  const [type, setType] = useState<'active' | 'sync'>('active');
+  const [api, setApi] = useState<string>('');
+  const [current, setCurrent] = useState<DeviceInstance>();
+  const [searchParams, setSearchParams] = useState<any>({});
+  const [bindKeys, setBindKeys] = useState<any[]>([]);
   const intl = useIntl();
+
   const columns: ProColumns<DeviceInstance>[] = [
     {
       dataIndex: 'index',
@@ -146,19 +167,22 @@ const Instance = () => {
             })}
             key={'detail'}
           >
-            <EyeOutlined />
+            <SearchOutlined />
           </Tooltip>
         </Link>,
-        <a key="editable" onClick={() => CurdModel.update(record)}>
-          <Tooltip
-            title={intl.formatMessage({
-              id: 'pages.data.option.edit',
-              defaultMessage: '编辑',
-            })}
-          >
-            <EditOutlined />
-          </Tooltip>
-        </a>,
+        // <a key="editable" onClick={() => {
+        //   setVisible(true)
+        //   setCurrent(record)
+        // }}>
+        //   <Tooltip
+        //     title={intl.formatMessage({
+        //       id: 'pages.data.option.edit',
+        //       defaultMessage: '编辑',
+        //     })}
+        //   >
+        //     <EditOutlined />
+        //   </Tooltip>
+        // </a>,
 
         <a href={record.id} target="_blank" rel="noopener noreferrer" key="view">
           <Popconfirm
@@ -167,10 +191,11 @@ const Instance = () => {
               defaultMessage: '确认禁用?',
             })}
             onConfirm={async () => {
-              await service.update({
-                id: record.id,
-                // status: record.state?.value ? 0 : 1,
-              });
+              if (record.state.value !== 'notActive') {
+                await service.undeployDevice(record.id);
+              } else {
+                await service.deployDevice(record.id);
+              }
               message.success(
                 intl.formatMessage({
                   id: 'pages.data.option.success',
@@ -182,11 +207,33 @@ const Instance = () => {
           >
             <Tooltip
               title={intl.formatMessage({
-                id: `pages.data.option.${record.state.value ? 'disabled' : 'enabled'}`,
-                defaultMessage: record.state.value ? '禁用' : '启用',
+                id: `pages.data.option.${
+                  record.state.value !== 'notActive' ? 'disabled' : 'enabled'
+                }`,
+                defaultMessage: record.state.value !== 'notActive' ? '禁用' : '启用',
               })}
             >
-              {record.state.value ? <CloseCircleOutlined /> : <PlayCircleOutlined />}
+              {record.state.value !== 'notActive' ? <StopOutlined /> : <CheckCircleOutlined />}
+            </Tooltip>
+          </Popconfirm>
+        </a>,
+
+        <a key={'delete'}>
+          <Popconfirm
+            title="确认删除"
+            onConfirm={async () => {
+              await service.remove(record.id);
+              message.success(
+                intl.formatMessage({
+                  id: 'pages.data.option.success',
+                  defaultMessage: '操作成功!',
+                }),
+              );
+              actionRef.current?.reload();
+            }}
+          >
+            <Tooltip title={'删除'}>
+              <DeleteOutlined />
             </Tooltip>
           </Popconfirm>
         </a>,
@@ -194,20 +241,192 @@ const Instance = () => {
     },
   ];
 
-  const schema = {};
+  const menu = (
+    <Menu>
+      <Menu.Item key="1">
+        <Button
+          icon={<ExportOutlined />}
+          type="default"
+          onClick={() => {
+            setExportVisible(true);
+          }}
+        >
+          批量导出设备
+        </Button>
+      </Menu.Item>
+      <Menu.Item key="2">
+        <Button
+          icon={<ImportOutlined />}
+          onClick={() => {
+            setImportVisible(true);
+          }}
+        >
+          批量导入设备
+        </Button>
+      </Menu.Item>
+      <Menu.Item key="4">
+        <Popconfirm
+          title={'确认激活全部设备?'}
+          onConfirm={() => {
+            setType('active');
+            const activeAPI = `/${
+              SystemConst.API_BASE
+            }/device-instance/deploy?:X_Access_Token=${Token.get()}`;
+            setApi(activeAPI);
+            setOperationVisible(true);
+          }}
+        >
+          <Button icon={<CheckCircleOutlined />} type="primary" ghost>
+            激活全部设备
+          </Button>
+        </Popconfirm>
+      </Menu.Item>
+      <Menu.Item key="5">
+        <Button
+          icon={<SyncOutlined />}
+          type="primary"
+          onClick={() => {
+            setType('sync');
+            const syncAPI = `/${
+              SystemConst.API_BASE
+            }/device-instance/state/_sync?:X_Access_Token=${Token.get()}`;
+            setApi(syncAPI);
+            setOperationVisible(true);
+          }}
+        >
+          同步设备状态
+        </Button>
+      </Menu.Item>
+      {bindKeys.length > 0 && (
+        <Menu.Item key="3">
+          <Popconfirm
+            title="确认删除选中设备?"
+            onConfirm={() => {
+              service.batchDeleteDevice(bindKeys).then((resp) => {
+                if (resp.status === 200) {
+                  message.success('操作成功');
+                  actionRef.current?.reset?.();
+                }
+              });
+            }}
+            okText="确认"
+            cancelText="取消"
+          >
+            <Button icon={<DeleteOutlined />} type="primary" danger>
+              删除选中设备
+            </Button>
+          </Popconfirm>
+        </Menu.Item>
+      )}
+      {bindKeys.length > 0 && (
+        <Menu.Item key="6">
+          <Popconfirm
+            title="确认禁用选中设备?"
+            onConfirm={() => {
+              service.batchUndeployDevice(bindKeys).then((resp) => {
+                if (resp.status === 200) {
+                  message.success('操作成功');
+                  actionRef.current?.reset?.();
+                }
+              });
+            }}
+            okText="确认"
+            cancelText="取消"
+          >
+            <Button icon={<StopOutlined />} type="primary" danger>
+              禁用选中设备
+            </Button>
+          </Popconfirm>
+        </Menu.Item>
+      )}
+    </Menu>
+  );
 
   return (
     <PageContainer>
-      <BaseCrud
+      <SearchComponent<DeviceInstance>
+        field={columns}
+        target="device-instance"
+        onSearch={(data) => {
+          // 重置分页数据
+          actionRef.current?.reset?.();
+          setSearchParams(data);
+        }}
+        onReset={() => {
+          // 重置分页及搜索参数
+          actionRef.current?.reset?.();
+          setSearchParams({});
+        }}
+      />
+      <ProTable<DeviceInstance>
         columns={columns}
-        service={service}
-        title={intl.formatMessage({
-          id: 'pages.device.instance',
-          defaultMessage: '设备管理',
-        })}
-        schema={schema}
         actionRef={actionRef}
+        params={searchParams}
+        options={{ fullScreen: true }}
+        request={(params) => service.query(encodeQuery({ ...params, sorts: { id: 'ascend' } }))}
+        rowKey="id"
+        search={false}
+        pagination={{ pageSize: 10 }}
+        rowSelection={{
+          selectedRowKeys: bindKeys,
+          onChange: (selectedRowKeys, selectedRows) => {
+            setBindKeys(selectedRows.map((item) => item.id));
+          },
+        }}
+        toolBarRender={() => [
+          <Button
+            onClick={() => {
+              setVisible(true);
+              setCurrent(undefined);
+            }}
+            key="button"
+            icon={<PlusOutlined />}
+            type="primary"
+          >
+            {intl.formatMessage({
+              id: 'pages.data.option.add',
+              defaultMessage: '新增',
+            })}
+          </Button>,
+          <Dropdown key={'more'} overlay={menu} placement="bottom">
+            <Button>批量操作</Button>
+          </Dropdown>,
+        ]}
+      />
+      <Save
+        data={current}
+        close={() => {
+          setVisible(false);
+          actionRef.current?.reload();
+        }}
+        visible={visible}
       />
+      <Export
+        data={searchParams}
+        close={() => {
+          setExportVisible(false);
+          actionRef.current?.reload();
+        }}
+        visible={exportVisible}
+      />
+      <Import
+        data={current}
+        close={() => {
+          setImportVisible(false);
+          actionRef.current?.reload();
+        }}
+        visible={importVisible}
+      />
+      {operationVisible && (
+        <Process
+          api={api}
+          action={type}
+          closeVisible={() => {
+            setOperationVisible(false);
+            actionRef.current?.reload();
+          }}
+        />
+      )}
     </PageContainer>
   );
 };

+ 51 - 0
src/pages/device/Instance/service.ts

@@ -8,6 +8,57 @@ import { filter, groupBy, map } from 'rxjs/operators';
 class Service extends BaseService<DeviceInstance> {
   public detail = (id: string) => request(`${this.uri}/${id}/detail`, { method: 'GET' });
 
+  // 查询产品列表
+  public getProductList = (params: any) =>
+    request(`/${SystemConst.API_BASE}/device/product/_query/no-paging`, { method: 'GET', params });
+
+  // 批量删除设备
+  public batchDeleteDevice = (params: any) =>
+    request(`/${SystemConst.API_BASE}/device-instance/batch/_delete`, {
+      method: 'PUT',
+      data: params,
+    });
+
+  // 启用设备
+  public deployDevice = (deviceId: string, params?: any) =>
+    request(`/${SystemConst.API_BASE}/device-instance/${deviceId}/deploy`, {
+      method: 'POST',
+      data: params,
+    });
+
+  // 禁用设备
+  public undeployDevice = (deviceId: string, params?: any) =>
+    request(`/${SystemConst.API_BASE}/device-instance/${deviceId}/undeploy`, {
+      method: 'POST',
+      data: params,
+    });
+
+  // 批量激活设备
+  public batchDeployDevice = (params: any) =>
+    request(`/${SystemConst.API_BASE}/device-instance/batch/_deploy`, {
+      method: 'PUT',
+      data: params,
+    });
+
+  // 批量注销设备
+  public batchUndeployDevice = (params: any) =>
+    request(`/${SystemConst.API_BASE}/device-instance/batch/_undeploy`, {
+      method: 'PUT',
+      data: params,
+    });
+
+  // 激活所有设备
+  public deployAllDevice = (params?: any) =>
+    request(`/${SystemConst.API_BASE}/device-instance/deploy`, { method: 'GET', params });
+
+  // 同步设备
+  public syncDevice = () =>
+    request(`/${SystemConst.API_BASE}/device-instance/state/_sync`, { method: 'GET' });
+
+  //验证设备ID是否重复
+  public isExists = (id: string) =>
+    request(`/${SystemConst.API_BASE}/device-instance/${id}/exists`, { method: 'GET' });
+
   public getConfigMetadata = (id: string) =>
     request(`${this.uri}/${id}/config-metadata`, { method: 'GET' });
 

+ 27 - 2
src/pages/system/Permission/index.tsx

@@ -258,7 +258,19 @@ const Permission: React.FC = observer(() => {
         'x-decorator': 'FormItem',
         'x-component': 'Input',
         name: 'id',
-        required: true,
+        'x-decorator-props': {
+          tooltip: <div>标识ID需与代码中的标识ID一致</div>,
+        },
+        'x-validator': [
+          {
+            max: 64,
+            message: '最多可输入64个字符',
+          },
+          {
+            required: true,
+            message: '请输入标识(ID)',
+          },
+        ],
       },
       name: {
         title: intl.formatMessage({
@@ -269,7 +281,16 @@ const Permission: React.FC = observer(() => {
         'x-decorator': 'FormItem',
         'x-component': 'Input',
         name: 'name',
-        required: true,
+        'x-validator': [
+          {
+            max: 64,
+            message: '最多可输入64个字符',
+          },
+          {
+            required: true,
+            message: '请输入名称',
+          },
+        ],
       },
       status: {
         title: '状态',
@@ -290,6 +311,9 @@ const Permission: React.FC = observer(() => {
         name: 'properties.assetTypes',
         required: false,
         enum: PermissionModel.assetsTypesList,
+        'x-decorator-props': {
+          tooltip: <div>关联资产为角色权限中的权限分配提供数据支持</div>,
+        },
         'x-component-props': {
           showSearch: true,
           mode: 'multiple',
@@ -416,6 +440,7 @@ const Permission: React.FC = observer(() => {
         actionRef={actionRef}
         columns={columns}
         service={service}
+        defaultParams={{ sorts: [{ name: 'modifyTime', order: 'desc' }] }}
         title={intl.formatMessage({
           id: 'pages.system.permission',
           defaultMessage: '',

+ 2 - 2
src/pages/system/Permission/service.ts

@@ -25,8 +25,8 @@ class Service extends BaseService<PermissionItem> {
   public batchAdd = (data: any) =>
     defer(() =>
       from(
-        request(`/${SystemConst.API_BASE}/dimension/_batch`, {
-          method: 'POST',
+        request(`/${SystemConst.API_BASE}/permission`, {
+          method: 'PATCH',
           data,
         }),
       ),