Selaa lähdekoodia

feat: 设备诊断和物模型映射

sun-chaochao 3 vuotta sitten
vanhempi
commit
ae66cbd559
25 muutettua tiedostoa jossa 2011 lisäystä ja 49 poistoa
  1. BIN
      public/images/diagnose/status/error.png
  2. BIN
      public/images/diagnose/status/loading.png
  3. BIN
      public/images/diagnose/status/success.png
  4. BIN
      public/images/diagnose/status/warning.png
  5. 18 20
      src/components/ProTableCard/CardItems/ruleInstance.tsx
  6. 8 1
      src/pages/device/Instance/Detail/Config/Edit.tsx
  7. 78 0
      src/pages/device/Instance/Detail/Diagnose/Message/Dialog/index.less
  8. 61 0
      src/pages/device/Instance/Detail/Diagnose/Message/Dialog/index.tsx
  9. 56 0
      src/pages/device/Instance/Detail/Diagnose/Message/Log/index.less
  10. 56 0
      src/pages/device/Instance/Detail/Diagnose/Message/Log/index.tsx
  11. 22 0
      src/pages/device/Instance/Detail/Diagnose/Message/index.less
  12. 391 0
      src/pages/device/Instance/Detail/Diagnose/Message/index.tsx
  13. 73 0
      src/pages/device/Instance/Detail/Diagnose/Status/index.less
  14. 451 0
      src/pages/device/Instance/Detail/Diagnose/Status/index.tsx
  15. 27 0
      src/pages/device/Instance/Detail/Diagnose/index.less
  16. 184 0
      src/pages/device/Instance/Detail/Diagnose/index.tsx
  17. 285 0
      src/pages/device/Instance/Detail/MetadataMap/EditableTable/index.tsx
  18. 101 0
      src/pages/device/Instance/Detail/MetadataMap/index.tsx
  19. 27 1
      src/pages/device/Instance/Detail/index.tsx
  20. 79 0
      src/pages/device/Instance/service.ts
  21. 8 1
      src/pages/device/Product/Detail/Access/index.tsx
  22. 19 0
      src/pages/device/Product/Detail/index.tsx
  23. 12 5
      src/pages/device/components/Metadata/Base/Edit/index.tsx
  24. 43 21
      src/pages/rule-engine/Instance/index.tsx
  25. 12 0
      src/utils/util.ts

BIN
public/images/diagnose/status/error.png


BIN
public/images/diagnose/status/loading.png


BIN
public/images/diagnose/status/success.png


BIN
public/images/diagnose/status/warning.png


+ 18 - 20
src/components/ProTableCard/CardItems/ruleInstance.tsx

@@ -1,43 +1,41 @@
-import { Avatar, Card } from 'antd';
 import React from 'react';
-import { BadgeStatus } from '@/components';
+import { TableCard } from '@/components';
 import { StatusColorEnum } from '@/components/BadgeStatus';
 import '@/style/common.less';
 import type { InstanceItem } from '@/pages/rule-engine/Instance/typings';
 
 export interface RuleInstanceCardProps extends InstanceItem {
+  detail?: React.ReactNode;
   actions?: React.ReactNode[];
   avatarSize?: number;
 }
 
+const defaultImage = require('/public/images/device-type-3-big.png');
+
 export default (props: RuleInstanceCardProps) => {
   return (
-    <Card style={{ width: '100%' }} cover={null} actions={props.actions}>
+    <TableCard
+      detail={props.detail}
+      actions={props.actions}
+      status={props.state.value}
+      statusText={props.state.text}
+      statusNames={{
+        started: StatusColorEnum.success,
+        stopped: StatusColorEnum.error,
+        disable: StatusColorEnum.processing,
+      }}
+    >
       <div className={'pro-table-card-item'}>
         <div className={'card-item-avatar'}>
-          <Avatar
-            size={props.avatarSize || 64}
-            src={
-              'https://lf1-cdn-tos.bytegoofy.com/goofy/lark/passport/staticfiles/passport/OKR.png'
-            }
-          />
+          <img width={88} height={88} src={defaultImage} alt={''} />
         </div>
         <div className={'card-item-body'}>
           <div className={'card-item-header'}>
             <span className={'card-item-header-name ellipsis'}>{props.name}</span>
-            <BadgeStatus
-              status={props.state.value}
-              text={props.state.text}
-              statusNames={{
-                started: StatusColorEnum.success,
-                stopped: StatusColorEnum.error,
-                disable: StatusColorEnum.processing,
-              }}
-            />
           </div>
-          {props.description}
+          <div className={'card-item-content'}>{props.description}</div>
         </div>
       </div>
-    </Card>
+    </TableCard>
   );
 };

+ 8 - 1
src/pages/device/Instance/Detail/Config/Edit.tsx

@@ -100,7 +100,14 @@ const Edit = (props: Props) => {
               });
               if (resp.status === 200) {
                 message.success('操作成功!');
-                props.close();
+                if ((window as any).onTabSaveSuccess) {
+                  if (resp.result) {
+                    (window as any).onTabSaveSuccess(resp);
+                    setTimeout(() => window.close(), 300);
+                  }
+                } else {
+                  props.close();
+                }
               }
             }}
           >

+ 78 - 0
src/pages/device/Instance/Detail/Diagnose/Message/Dialog/index.less

@@ -0,0 +1,78 @@
+@import '~antd/es/style/themes/default.less';
+
+:root {
+  --dialog-primary-color: @primary-color;
+}
+
+.dialog-item {
+  display: flex;
+  justify-content: flex-start;
+  width: 100%;
+  padding-bottom: 12px;
+
+  .dialog-card {
+    display: flex;
+    width: 60%;
+    padding: 24px;
+    background-color: #fff;
+
+    .dialog-icon {
+      margin-right: 10px;
+      color: rgba(0, 0, 0, 0.75);
+      font-weight: 500;
+      font-size: 12px;
+    }
+
+    .dialog-box {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+      .dialog-header {
+        .dialog-title {
+          color: rgba(0, 0, 0, 0.75);
+          font-weight: 700;
+          font-size: 14px;
+        }
+        .dialog-time {
+          color: rgba(0, 0, 0, 0.65);
+          font-size: 12px;
+        }
+      }
+
+      .dialog-editor {
+        width: 100%;
+        margin-top: 10px;
+        color: rgba(0, 0, 0, 0.75);
+
+        textarea::-webkit-scrollbar {
+          width: 5px !important;
+        }
+      }
+    }
+  }
+}
+
+.dialog-active {
+  display: flex;
+  justify-content: flex-end;
+  .dialog-card {
+    background-color: @primary-color;
+    .dialog-icon {
+      color: #fff;
+    }
+    .dialog-box {
+      .dialog-header {
+        .dialog-title,
+        .dialog-time {
+          color: #fff;
+        }
+      }
+      .dialog-editor {
+        textarea {
+          color: #fff !important;
+          background-color: @primary-color !important;
+        }
+      }
+    }
+  }
+}

+ 61 - 0
src/pages/device/Instance/Detail/Diagnose/Message/Dialog/index.tsx

@@ -0,0 +1,61 @@
+import { DownOutlined, RightOutlined } from '@ant-design/icons';
+import { Badge, Input } from 'antd';
+import classNames from 'classnames';
+import moment from 'moment';
+import { useState } from 'react';
+// import ReactJson from 'react-json-view';
+import './index.less';
+
+interface Props {
+  data: any;
+}
+
+const Dialog = (props: Props) => {
+  const { data } = props;
+  const operationMap = new Map();
+  operationMap.set('connection', '连接');
+  operationMap.set('auth', '权限验证');
+  operationMap.set('decode', '解码');
+  operationMap.set('encode', '编码');
+  operationMap.set('request', '请求');
+  operationMap.set('response', '响应');
+  operationMap.set('downstream', '下行消息');
+  operationMap.set('upstream', '上行消息');
+
+  const statusColor = new Map();
+  statusColor.set('error', '#E50012');
+  statusColor.set('success', '#24B276');
+
+  const [visible, setVisible] = useState<boolean>(false);
+
+  return (
+    <div className={classNames('dialog-item', { 'dialog-active': !data.upstream })} key={data.key}>
+      <div className="dialog-card">
+        <div
+          className="dialog-icon"
+          onClick={() => {
+            setVisible(!visible);
+          }}
+        >
+          {visible ? <DownOutlined /> : <RightOutlined />}
+        </div>
+        <div className="dialog-box">
+          <div className="dialog-header">
+            <div className="dialog-title">
+              <Badge color={statusColor.get(data.error ? 'error' : 'success')} />
+              {operationMap.get(data.operation) || data?.operation}
+            </div>
+            <div className="dialog-time">{moment(data.endTime).format('YYYY-MM-DD HH:mm:ss')}</div>
+          </div>
+          {visible && (
+            <div className="dialog-editor">
+              <Input.TextArea autoSize bordered={false} value={data?.detail} />
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default Dialog;

+ 56 - 0
src/pages/device/Instance/Detail/Diagnose/Message/Log/index.less

@@ -0,0 +1,56 @@
+@import '~antd/es/style/themes/default.less';
+
+:root {
+  --dialog-primary-color: @primary-color;
+}
+
+.log-item {
+  display: flex;
+  justify-content: flex-start;
+  width: 100%;
+  padding-bottom: 12px;
+
+  .log-card {
+    display: flex;
+    width: 100%;
+    background-color: #fff;
+
+    .log-icon {
+      margin-right: 10px;
+      color: rgba(0, 0, 0, 0.75);
+      font-weight: 500;
+      font-size: 12px;
+    }
+
+    .log-box {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+      .log-header {
+        .log-title {
+          color: rgba(0, 0, 0, 0.75);
+          font-weight: 700;
+          font-size: 14px;
+        }
+        .log-time {
+          color: rgba(0, 0, 0, 0.65);
+          font-size: 12px;
+        }
+      }
+
+      .log-editor {
+        width: 100%;
+        margin-top: 10px;
+        color: rgba(0, 0, 0, 0.75);
+
+        textarea {
+          color: black !important;
+          background-color: #fafafa !important;
+        }
+        textarea::-webkit-scrollbar {
+          width: 5px !important;
+        }
+      }
+    }
+  }
+}

+ 56 - 0
src/pages/device/Instance/Detail/Diagnose/Message/Log/index.tsx

@@ -0,0 +1,56 @@
+import { DownOutlined, RightOutlined } from '@ant-design/icons';
+import { Input, Tag } from 'antd';
+import classNames from 'classnames';
+import moment from 'moment';
+import { useState } from 'react';
+import './index.less';
+
+interface Props {
+  data: any;
+}
+
+const Log = (props: Props) => {
+  const { data } = props;
+  const operationMap = new Map();
+  operationMap.set('connection', '连接');
+  operationMap.set('auth', '权限验证');
+  operationMap.set('decode', '解码');
+  operationMap.set('encode', '编码');
+  operationMap.set('request', '请求');
+  operationMap.set('response', '响应');
+  operationMap.set('downstream', '下行消息');
+  operationMap.set('upstream', '上行消息');
+
+  const [visible, setVisible] = useState<boolean>(false);
+
+  return (
+    <div className={classNames('log-item')} key={data.id}>
+      <div className="log-card">
+        <div
+          className="log-icon"
+          onClick={() => {
+            setVisible(!visible);
+          }}
+        >
+          {visible ? <DownOutlined /> : <RightOutlined />}
+        </div>
+        <div className="log-box">
+          <div className="log-header">
+            <div className="log-title">
+              <Tag color="error">ERROR</Tag>
+              {operationMap.get(data.operation)}
+            </div>
+            <div className="log-time">{moment(data.endTime).format('YYYY-MM-DD HH:mm:ss')}</div>
+          </div>
+          {visible && (
+            <div className="log-editor">
+              <Input.TextArea autoSize bordered={false} value={data?.detail} />
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default Log;

+ 22 - 0
src/pages/device/Instance/Detail/Diagnose/Message/index.less

@@ -0,0 +1,22 @@
+.content {
+  width: 100%;
+}
+
+.dialog {
+  width: 100%;
+  min-height: 300px;
+  max-height: 500px;
+  padding: 24px;
+  overflow: hidden;
+  overflow-y: auto;
+  background-color: #f2f5f7;
+}
+
+.function {
+  padding: 15px;
+  background-color: #e7eaec;
+
+  .parameter {
+    margin: 15px 0;
+  }
+}

+ 391 - 0
src/pages/device/Instance/Detail/Diagnose/Message/index.tsx

@@ -0,0 +1,391 @@
+import TitleComponent from '@/components/TitleComponent';
+import './index.less';
+import Dialog from './Dialog';
+import { Button, Col, Input, InputNumber, Row, Select, DatePicker, Empty } from 'antd';
+import { useEffect, useState } from 'react';
+import { InstanceModel, service } from '@/pages/device/Instance';
+import useSendWebsocketMessage from '@/hooks/websocket/useSendWebsocketMessage';
+import { map } from 'rxjs/operators';
+import type { Field } from '@formily/core';
+import { createForm, onFieldValueChange } from '@formily/core';
+import { createSchemaField, FormProvider } from '@formily/react';
+import {
+  ArrayTable,
+  FormItem,
+  Input as FInput,
+  PreviewText,
+  Select as FSelect,
+  DatePicker as FDatePicker,
+  Switch,
+} from '@formily/antd';
+import { randomString } from '@/utils/util';
+import Log from './Log';
+interface Props {
+  onChange: (type: string) => void;
+}
+
+const Message = (props: Props) => {
+  const [subscribeTopic] = useSendWebsocketMessage();
+  const [dialogList, setDialogList] = useState<any[]>([]);
+  const [logList, setLogList] = useState<any[]>([]);
+  const [type, setType] = useState<'property' | 'function'>('function');
+  const [input, setInput] = useState<any>({});
+  const [inputs, setInputs] = useState<any[]>([]);
+
+  const [propertyType, setPropertyType] = useState<'read' | 'setting'>('read');
+  const [property, setProperty] = useState<any>({});
+  const [propertyValue, setPropertyValue] = useState<any>('');
+
+  const metadata = JSON.parse(InstanceModel.detail?.metadata || '{}');
+
+  const subscribeLog = () => {
+    const id = `device-debug-${InstanceModel.detail?.id}`;
+    const topic = `/debug/device/${InstanceModel.detail?.id}/trace`;
+    subscribeTopic!(id, topic, {})
+      ?.pipe(map((res) => res.payload))
+      .subscribe((payload: any) => {
+        if (payload.error) {
+          props.onChange(!payload.upstream ? 'down-error' : 'up-error');
+        } else {
+          props.onChange(!payload.upstream ? 'down-success' : 'up-success');
+        }
+        if (payload.type === 'log') {
+          logList.push({
+            key: randomString(),
+            ...payload,
+          });
+          setLogList([...logList]);
+        } else {
+          dialogList.push({
+            key: randomString(),
+            ...payload,
+          });
+          setDialogList([...dialogList]);
+        }
+        const chatBox = document.getElementById('dialog');
+        if (chatBox) {
+          chatBox.scrollTop = chatBox.scrollHeight;
+        }
+      });
+  };
+
+  const getItemNode = (t: string) => {
+    switch (t) {
+      case 'boolean':
+        return (
+          <Select
+            style={{ width: '100%', textAlign: 'left' }}
+            options={[
+              { label: 'true', value: true },
+              { label: 'false', value: false },
+            ]}
+            placeholder={'请选择'}
+            onChange={(value) => {
+              setPropertyValue(value);
+            }}
+          />
+        );
+      case 'int':
+      case 'long':
+      case 'float':
+      case 'double':
+        return (
+          <InputNumber
+            style={{ width: '100%' }}
+            placeholder={'请输入'}
+            onChange={(value) => {
+              setPropertyValue(value);
+            }}
+          />
+        );
+      case 'date':
+        // @ts-ignore
+        return (
+          <DatePicker
+            style={{ width: '100%' }}
+            onChange={(value) => {
+              setPropertyValue(value);
+            }}
+          />
+        );
+      default:
+        return (
+          <Input
+            onChange={(e) => {
+              setPropertyValue(e.target.value);
+            }}
+            placeholder="填写属性值"
+            style={{ width: '100%' }}
+          />
+        );
+    }
+  };
+  useEffect(() => {
+    subscribeLog();
+  }, []);
+
+  const form = createForm({
+    initialValues: {
+      data: [...inputs],
+    },
+    effects() {
+      onFieldValueChange('data.*.valueType.type', (field) => {
+        const value = (field as Field).value;
+        const format = field.query('..value').take() as any;
+        switch (value) {
+          case 'date':
+            format.setComponent(FDatePicker);
+            break;
+          case 'boolean':
+            format.setComponent(Switch);
+            format.setDataSource = [
+              { label: '是', value: true },
+              { label: '否', value: false },
+            ];
+            break;
+          default:
+            format.setComponent(FInput);
+            break;
+        }
+      });
+    },
+  });
+
+  const dataRender = () => {
+    switch (type) {
+      case 'function':
+        return (
+          <Col span={5}>
+            <Select
+              style={{ width: '100%' }}
+              onChange={(value: any) => {
+                const data = (metadata?.functions || []).find((item: any) => item.id === value);
+                setInput(data);
+                setInputs(data?.inputs || []);
+                form.setValues({
+                  data: data?.inputs || [],
+                });
+              }}
+            >
+              {(metadata?.functions || []).map((i: any) => (
+                <Select.Option key={i.id} value={i.id}>
+                  {i.name}
+                </Select.Option>
+              ))}
+            </Select>
+          </Col>
+        );
+      case 'property':
+        return (
+          <>
+            <Col span={5}>
+              <Select
+                style={{ width: '100%' }}
+                value={propertyType}
+                placeholder="请选择"
+                onChange={(value: any) => {
+                  setPropertyType(value);
+                }}
+              >
+                <Select.Option value={'read'}>读取属性</Select.Option>
+                <Select.Option value={'setting'}>设置属性</Select.Option>
+              </Select>
+            </Col>
+            <Col span={5}>
+              <Select
+                style={{ width: '100%' }}
+                value={property}
+                placeholder="选择属性"
+                onChange={(value: any) => {
+                  setProperty(value);
+                }}
+              >
+                {(metadata?.properties || []).map((i: any) => (
+                  <Select.Option key={i.id} value={i.id}>
+                    {i.name}
+                  </Select.Option>
+                ))}
+              </Select>
+            </Col>
+            {!!property && propertyType === 'setting' && (
+              <Col span={5}>
+                {getItemNode(
+                  (metadata?.properties || []).find((it: any) => it.id === property)?.valueType
+                    ?.type || '',
+                )}
+              </Col>
+            )}
+          </>
+        );
+      default:
+        return null;
+    }
+  };
+
+  const SchemaField = createSchemaField({
+    components: {
+      FormItem,
+      FInput,
+      ArrayTable,
+      PreviewText,
+      FSelect,
+      FDatePicker,
+      Switch,
+    },
+  });
+
+  const schema = {
+    type: 'object',
+    properties: {
+      data: {
+        type: 'array',
+        'x-decorator': 'FormItem',
+        'x-component': 'ArrayTable',
+        'x-component-props': {
+          scroll: { x: '100%' },
+        },
+        items: {
+          type: 'object',
+          properties: {
+            column1: {
+              type: 'void',
+              'x-component': 'ArrayTable.Column',
+              'x-component-props': {
+                title: '参数名称',
+              },
+              properties: {
+                name: {
+                  type: 'string',
+                  'x-decorator': 'FormItem',
+                  'x-component': 'PreviewText.Input',
+                },
+              },
+            },
+            column2: {
+              type: 'void',
+              'x-component': 'ArrayTable.Column',
+              'x-component-props': {
+                title: '输入类型',
+              },
+              properties: {
+                'valueType.type': {
+                  type: 'string',
+                  'x-decorator': 'FormItem',
+                  'x-component': 'PreviewText.Input',
+                },
+              },
+            },
+            column3: {
+              type: 'void',
+              'x-component': 'ArrayTable.Column',
+              'x-component-props': {
+                title: '值',
+              },
+              properties: {
+                value: {
+                  type: 'string',
+                  'x-decorator': 'FormItem',
+                  'x-component': FInput,
+                },
+              },
+            },
+          },
+        },
+      },
+    },
+  };
+
+  return (
+    <Row gutter={24}>
+      <Col span={16}>
+        <TitleComponent data="调试" />
+        <div className="content">
+          <div className="dialog" id="dialog">
+            {dialogList.map((item) => (
+              <Dialog data={item} key={item.key} />
+            ))}
+          </div>
+        </div>
+        <div className="function">
+          <Row gutter={24}>
+            <Col span={5}>
+              <Select
+                value={type}
+                placeholder="请选择"
+                style={{ width: '100%' }}
+                onChange={(value) => {
+                  setType(value);
+                  setInputs([]);
+                  setInput({});
+                }}
+              >
+                <Select.Option value="function">调用功能</Select.Option>
+                <Select.Option value="property">操作属性</Select.Option>
+              </Select>
+            </Col>
+            {dataRender()}
+            <Col span={3}>
+              <Button
+                type="primary"
+                onClick={async () => {
+                  props.onChange('waiting');
+                  if (type === 'function') {
+                    const list = (form?.values?.data || []).filter((it) => !!it.value);
+                    const obj = {};
+                    list.map((it) => {
+                      obj[it.id] = it.value;
+                    });
+                    await service.executeFunctions(InstanceModel.detail?.id || '', input.id, {
+                      ...obj,
+                    });
+                  } else {
+                    if (propertyType === 'read') {
+                      await service.readProperties(InstanceModel.detail?.id || '', [property]);
+                    } else {
+                      await service.settingProperties(InstanceModel.detail?.id || '', {
+                        [property]: propertyValue,
+                      });
+                    }
+                  }
+                }}
+              >
+                发送
+              </Button>
+            </Col>
+          </Row>
+          {inputs.length > 0 && (
+            <div className="parameter">
+              <h4>功能参数</h4>
+              <FormProvider form={form}>
+                <SchemaField schema={schema} />
+              </FormProvider>
+            </div>
+          )}
+        </div>
+      </Col>
+      <Col span={8}>
+        <div
+          style={{
+            padding: 24,
+            border: '1px solid rgba(0, 0, 0, .09)',
+            overflow: 'hidden',
+            maxHeight: 600,
+            overflowY: 'auto',
+            minHeight: 400,
+          }}
+        >
+          <div style={{ color: 'rgba(0, 0, 0, .85)', fontWeight: 700 }}>日志</div>
+          <div style={{ marginTop: 10 }}>
+            {logList.length > 0 ? (
+              logList.map((item) => <Log data={item} key={item.key} />)
+            ) : (
+              <Empty />
+            )}
+          </div>
+        </div>
+      </Col>
+    </Row>
+  );
+};
+
+export default Message;

+ 73 - 0
src/pages/device/Instance/Detail/Diagnose/Status/index.less

@@ -0,0 +1,73 @@
+.statusBox {
+  width: 100%;
+  .statusHeader {
+    display: flex;
+  }
+  .statusContent {
+    width: 100%;
+    margin: 20px 0;
+    border: 1px solid #ececec;
+    border-bottom: none;
+    .statusItem {
+      display: flex;
+      justify-content: space-between;
+      padding: 20px;
+      border-bottom: 1px solid #ececec;
+      .statusLeft {
+        display: flex;
+        .statusImg {
+          width: 32px;
+          height: 32px;
+          margin: 15px 20px 0 0;
+        }
+        .statusContext {
+          .statusTitle {
+            color: rgba(0, 0, 0, 0.8);
+            font-weight: 700;
+            font-size: 18px;
+          }
+          .statusDesc {
+            color: rgba(0, 0, 0, 0.65);
+            font-size: 14px;
+          }
+          .info {
+            margin-top: 10px;
+            color: #646464;
+            font-size: 14px;
+
+            .infoItem {
+              width: 100%;
+            }
+          }
+        }
+      }
+      .statusRight {
+        margin-top: 10px;
+        font-weight: 700;
+        font-size: 18px;
+      }
+    }
+  }
+}
+
+.loading {
+  animation: loading 2s linear infinite;
+}
+
+@keyframes loading {
+  0% {
+    transform: rotate(0deg);
+  }
+  25% {
+    transform: rotate(90deg);
+  }
+  50% {
+    transform: rotate(180deg);
+  }
+  75% {
+    transform: rotate(270deg);
+  }
+  100% {
+    transform: rotate(360deg);
+  }
+}

+ 451 - 0
src/pages/device/Instance/Detail/Diagnose/Status/index.tsx

@@ -0,0 +1,451 @@
+import TitleComponent from '@/components/TitleComponent';
+import { Badge, Button, Col, message, Popconfirm, Row } from 'antd';
+import { useEffect, useState } from 'react';
+import styles from './index.less';
+import { InstanceModel, service } from '@/pages/device/Instance';
+import { getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
+
+interface Props {
+  onChange: (type: string) => void;
+}
+
+const Status = (props: Props) => {
+  const StatusMap = new Map();
+  StatusMap.set('error', require('/public/images/diagnose/status/error.png'));
+  StatusMap.set('success', require('/public/images/diagnose/status/success.png'));
+  StatusMap.set('warning', require('/public/images/diagnose/status/warning.png'));
+  StatusMap.set('loading', require('/public/images/diagnose/status/loading.png'));
+
+  const statusColor = new Map();
+  statusColor.set('error', '#E50012');
+  statusColor.set('success', '#24B276');
+  statusColor.set('warning', '#FF9000');
+  statusColor.set('loading', 'rgba(0, 0, 0, .8)');
+
+  const initStatus = {
+    product: {
+      status: 'loading',
+      text: '正在诊断中...',
+      info: null,
+    },
+    config: {
+      status: 'loading',
+      text: '正在诊断中...',
+      info: null,
+    },
+    device: {
+      status: 'loading',
+      text: '正在诊断中...',
+      info: null,
+    },
+    'device-config': {
+      status: 'loading',
+      text: '正在诊断中...',
+      info: null,
+    },
+    gateway: {
+      status: 'loading',
+      text: '正在诊断中...',
+      info: null,
+    },
+    network: {
+      status: 'loading',
+      text: '正在诊断中...',
+      info: null,
+    },
+  };
+
+  const initList = [
+    {
+      key: 'product',
+      name: '产品状态',
+      desc: '诊断产品状态是否已发布,未发布的状态将导致连接失败。',
+    },
+    {
+      key: 'config',
+      name: '设备接入配置',
+      desc: '诊断设备接入配置是否正确,配置错误将导致连接失败。',
+    },
+    {
+      key: 'device',
+      name: '设备状态',
+      desc: '诊断设备状态是否已启用,未启用的状态将导致连接失败。',
+    },
+  ];
+  const [list, setList] = useState<any[]>(initList);
+
+  const [status, setStatus] = useState<any>(initStatus);
+
+  const getDetail = (id: string) => {
+    service.detail(id).then((response) => {
+      InstanceModel.detail = response?.result;
+    });
+  };
+
+  const handleSearch = async () => {
+    props.onChange('loading');
+    setList(initList);
+    // 设备在线
+    if (InstanceModel.detail?.state?.value === 'online') {
+      setList([
+        ...initList,
+        {
+          key: 'device-config',
+          name: '实例信息配置',
+          desc: '诊断设备实例信息是否正确,配置错误将导致连接失败。',
+        },
+        {
+          key: 'gateway',
+          name: '设备接入网关状态',
+          desc: '诊断设备接入网关状态是否已启用,未启用的状态将导致连接失败',
+        },
+        {
+          key: 'network',
+          name: '网络信息',
+          desc: '诊断网络组件配置是否正确,配置错误将导致连接失败。',
+        },
+      ]);
+      setTimeout(() => {
+        status.product = { status: 'success', text: '已发布', info: null };
+        status.config = { status: 'success', text: '正常', info: null };
+        status.device = { status: 'success', text: '已启用', info: null };
+        status['device-config'] = { status: 'success', text: '正常', info: null };
+        status.gateway = { status: 'success', text: '已启用', info: null };
+        status.network = { status: 'success', text: '网络正常', info: null };
+        setStatus({ ...status });
+        props.onChange('success');
+      }, 1000);
+    } else if (InstanceModel.detail) {
+      const datalist = [...initList];
+      const product = await service.queryProductState(InstanceModel.detail?.productId || '');
+      status.product = {
+        status: product.result?.state === 1 ? 'success' : 'error',
+        text: product.result?.state === 1 ? '已发布' : '未发布',
+        info:
+          product.result?.state === 1 ? null : (
+            <div className={styles.infoItem}>
+              <Badge
+                status="default"
+                text={
+                  <span>
+                    产品未发布,请
+                    <Popconfirm
+                      title="确认发布"
+                      onConfirm={async () => {
+                        const resp = await service.deployProduct(
+                          InstanceModel.detail?.productId || '',
+                        );
+                        if (resp.status === 200) {
+                          message.success('操作成功!');
+                          status.product = { status: 'success', text: '已发布', info: null };
+                          setStatus({ ...status });
+                        }
+                      }}
+                    >
+                      <a>发布</a>
+                    </Popconfirm>
+                    产品
+                  </span>
+                }
+              />
+            </div>
+          ),
+      };
+      if (InstanceModel.detail?.state?.value === 'notActive') {
+        status.device = {
+          status: 'error',
+          text: '未启用',
+          info: (
+            <div className={styles.infoItem}>
+              <Badge
+                status="default"
+                text={
+                  <span>
+                    设备未启用,请
+                    <Popconfirm
+                      title="确认启用"
+                      onConfirm={async () => {
+                        const resp = await service.deployDevice(InstanceModel.detail?.id || '');
+                        if (resp.status === 200) {
+                          message.success('操作成功!');
+                          status.device = { status: 'success', text: '已启用', info: null };
+                          setStatus({ ...status });
+                          getDetail(InstanceModel.detail?.id || '');
+                        }
+                      }}
+                    >
+                      <a>启用</a>
+                    </Popconfirm>
+                    设备
+                  </span>
+                }
+              />
+            </div>
+          ),
+        };
+      } else {
+        status.device = { status: 'success', text: '已启用', info: null };
+      }
+      if (product.result?.accessId) {
+        const configuration = await service.queryProductConfig(
+          InstanceModel.detail?.productId || '',
+        );
+        if ((configuration?.result || []).length > 0) {
+          //实例信息
+          datalist.push({
+            key: 'device-config',
+            name: '实例信息配置',
+            desc: '诊断设备实例信息是否正确,配置错误将导致连接失败。',
+          });
+          status['device-config'] = {
+            status: 'warning',
+            text: '可能存在异常',
+            info: (
+              <div className={styles.infoItem}>
+                <Badge
+                  status="default"
+                  text={
+                    <span>
+                      请检查
+                      <a
+                        onClick={() => {
+                          //  跳转到设备实例页面
+                          const url = getMenuPathByParams(
+                            MENUS_CODE['device/Instance/Detail'],
+                            InstanceModel.detail?.id,
+                          );
+                          const tab: any = window.open(`${origin}/#${url}?key=detail`);
+                          tab!.onTabSaveSuccess = (value: any) => {
+                            if (value) {
+                              handleSearch();
+                            }
+                          };
+                        }}
+                      >
+                        设备实例信息
+                      </a>
+                      是否正确填写
+                    </span>
+                  }
+                />
+              </div>
+            ),
+          };
+        }
+        status.config = {
+          status: 'warning',
+          text: '可能存在异常',
+          info: (
+            <div className={styles.infoItem}>
+              <Badge
+                status="default"
+                text={
+                  <span>
+                    请检查
+                    <a
+                      onClick={() => {
+                        //跳转到产品设备接入配置
+                        const url = getMenuPathByParams(
+                          MENUS_CODE['device/Product/Detail'],
+                          InstanceModel.detail?.productId,
+                        );
+                        const tab: any = window.open(`${origin}/#${url}?key=access`);
+                        tab!.onTabSaveSuccess = (value: any) => {
+                          if (value) {
+                            handleSearch();
+                          }
+                        };
+                      }}
+                    >
+                      设备接入配置
+                    </a>
+                    是否正确填写
+                  </span>
+                }
+              />
+            </div>
+          ),
+        };
+        const deviceConfig = await service.queryGatewayState(product.result?.accessId);
+        status.gateway = {
+          status: deviceConfig.result?.state?.value === 'enabled' ? 'success' : 'error',
+          text: deviceConfig.result?.state?.value === 'enabled' ? '已启用' : '未启用',
+          info:
+            deviceConfig.result?.state?.value === 'enabled' ? null : (
+              <div className={styles.infoItem}>
+                <Badge
+                  status="default"
+                  text={
+                    <span>
+                      设备接入网关未启用,请
+                      <Popconfirm
+                        title="确认启用"
+                        onConfirm={async () => {
+                          const resp = await service.startGateway(product.result?.accessId || '');
+                          if (resp.status === 200) {
+                            message.success('操作成功!');
+                            status.gateway = { status: 'success', text: '已启用', info: null };
+                            setStatus({ ...status });
+                          }
+                        }}
+                      >
+                        <a>启用</a>
+                      </Popconfirm>
+                      设备接入网关
+                    </span>
+                  }
+                />
+              </div>
+            ),
+        };
+        datalist.push({
+          key: 'gateway',
+          name: '设备接入网关状态',
+          desc: '诊断设备接入网关状态是否已启用,未启用的状态将导致连接失败',
+        });
+        if (deviceConfig.result?.channel === 'network') {
+          const network = await service.queryNetworkState(deviceConfig.result?.channelId);
+          status.network = {
+            status: network.result?.state?.value === 'enabled' ? 'success' : 'error',
+            text: deviceConfig.result?.state?.value === 'enabled' ? '网络正常' : '网络异常',
+            info:
+              deviceConfig.result?.state?.value === 'enabled' ? null : (
+                <div>
+                  <div className={styles.infoItem}>
+                    <Badge
+                      status="default"
+                      text={
+                        <span>
+                          网络组件未启用, 请
+                          <Popconfirm
+                            title="确认启用"
+                            onConfirm={async () => {
+                              const resp = await service.startNetwork(
+                                deviceConfig.result?.channelId,
+                              );
+                              if (resp.status === 200) {
+                                message.success('操作成功!');
+                                status.gateway = { status: 'success', text: '已启用', info: null };
+                                setStatus({ ...status });
+                              }
+                            }}
+                          >
+                            <a>启用</a>
+                          </Popconfirm>
+                          网络组件
+                        </span>
+                      }
+                    />
+                  </div>
+                  <div className={styles.infoItem}>
+                    <Badge
+                      status="default"
+                      text="请检查服务器端口是否开放,如未开放,请开放后尝试重新连接"
+                    />
+                  </div>
+                  <div className={styles.infoItem}>
+                    <Badge
+                      status="default"
+                      text="请检查服务器防火策略,如有开启防火墙,请关闭防火墙或调整防火墙策略后重试"
+                    />
+                  </div>
+                </div>
+              ),
+          };
+          datalist.push({
+            key: 'network',
+            name: '网络信息',
+            desc: '诊断网络组件配置是否正确,配置错误将导致连接失败。',
+          });
+        }
+      } else {
+        status.config = {
+          status: 'error',
+          text: '未配置',
+          info: (
+            <div className={styles.infoItem}>
+              <Badge
+                status="default"
+                text={
+                  <span>
+                    请进行
+                    <a
+                      onClick={() => {
+                        const url = getMenuPathByParams(
+                          MENUS_CODE['device/Product/Detail'],
+                          InstanceModel.detail?.productId,
+                        );
+                        const tab: any = window.open(`${origin}/#${url}?key=access`);
+                        tab!.onTabSaveSuccess = (value: any) => {
+                          if (value) {
+                            handleSearch();
+                          }
+                        };
+                      }}
+                    >
+                      设备接入配置
+                    </a>
+                  </span>
+                }
+              />
+            </div>
+          ),
+        };
+      }
+      setList([...datalist]);
+      setStatus({ ...status });
+      props.onChange('error');
+    }
+  };
+
+  useEffect(() => {
+    handleSearch();
+  }, []);
+
+  return (
+    <Row gutter={24}>
+      <Col span={16}>
+        <div className={styles.statusBox}>
+          <div className={styles.statusHeader}>
+            <TitleComponent data={'连接详情'} />
+            <Button
+              onClick={() => {
+                handleSearch();
+              }}
+            >
+              重新诊断
+            </Button>
+          </div>
+          <div className={styles.statusContent}>
+            {list.map((item) => (
+              <div key={item.key} className={styles.statusItem}>
+                <div className={styles.statusLeft}>
+                  <div className={styles.statusImg}>
+                    <img
+                      style={{ height: 32 }}
+                      className={status[item.key]?.status === 'loading' ? styles.loading : {}}
+                      src={StatusMap.get(status[item.key]?.status) || 'loading'}
+                    />
+                  </div>
+                  <div className={styles.statusContext}>
+                    <div className={styles.statusTitle}>{item.name}</div>
+                    <div className={styles.statusDesc}>{item.desc}</div>
+                    <div className={styles.info}>{status[item.key]?.info}</div>
+                  </div>
+                </div>
+                <div
+                  className={styles.statusRight}
+                  style={{ color: statusColor.get(status[item.key]?.status) || 'loading' }}
+                >
+                  {status[item.key]?.text}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      </Col>
+    </Row>
+  );
+};
+
+export default Status;

+ 27 - 0
src/pages/device/Instance/Detail/Diagnose/index.less

@@ -0,0 +1,27 @@
+.container {
+  margin-top: 20px;
+}
+.item-box {
+  width: 100%;
+  padding: 10px;
+  background-repeat: no-repeat;
+  background-size: '100% 100%';
+  cursor: pointer;
+}
+
+.item-title {
+  font-weight: 700;
+  font-size: 14px;
+}
+
+.item-context {
+  height: 40px;
+  font-weight: 700;
+  font-size: 24px;
+}
+
+.item-message {
+  color: rgba(0, 0, 0, 0.85);
+  font-weight: 400;
+  font-size: 14px;
+}

+ 184 - 0
src/pages/device/Instance/Detail/Diagnose/index.tsx

@@ -0,0 +1,184 @@
+import { Badge, Card, Col, Row } from 'antd';
+import type { ReactNode } from 'react';
+import { useState } from 'react';
+import Message from './Message';
+import Status from './Status';
+import './index.less';
+import classNames from 'classnames';
+
+interface ListProps {
+  key: string;
+  tab: string;
+  component: ReactNode;
+}
+
+const bImageMap = new Map();
+bImageMap.set('m-error', require('/public/images/diagnose/message-error.png'));
+bImageMap.set('s-error', require('/public/images/diagnose/status-error.png'));
+bImageMap.set('s-success-active', require('/public/images/diagnose/status-success-active.png'));
+bImageMap.set('s-success', require('/public/images/diagnose/status-success.png'));
+bImageMap.set('waiting', require('/public/images/diagnose/waiting.png'));
+
+const statusColor = new Map();
+statusColor.set('m-error', '#E50012');
+statusColor.set('s-error', '#E50012');
+statusColor.set('error', '#E50012');
+statusColor.set('s-success-active', '#24B276');
+statusColor.set('s-success', '#24B276');
+statusColor.set('success', '#24B276');
+statusColor.set('waiting', '#FF9000');
+statusColor.set('diaabled', 'rgba(0, 0, 0, .8)');
+
+const statusText = new Map();
+statusText.set('s-error', '连接失败');
+statusText.set('s-success-active', '连接成功');
+statusText.set('s-success', '连接成功');
+statusText.set('waiting', '诊断中');
+statusText.set('diaabled', '诊断中');
+
+const Diagnose = () => {
+  const [current, setCurrent] = useState<string>('status');
+  const [status, setStatus] = useState<string>('waiting');
+  const [message, setMessage] = useState<string>('waiting');
+
+  const [up, setUp] = useState<'success' | 'error' | 'waiting'>('waiting');
+  const [down, setDown] = useState<'success' | 'error' | 'waiting'>('waiting');
+
+  const list = [
+    {
+      key: 'status',
+      tab: '连接状态',
+      component: (
+        <div
+          style={{ backgroundImage: `url(${bImageMap.get(status)}`, backgroundSize: '100% 100%' }}
+          className="item-box"
+        >
+          <div className="item-title">连接状态</div>
+          <div style={{ color: statusColor.get(status) }} className="item-context">
+            <Badge color={statusColor.get(status)} /> {statusText.get(status)}
+          </div>
+        </div>
+      ),
+    },
+    {
+      key: 'message',
+      tab: '消息通信',
+      component: (
+        <div
+          style={
+            message !== 'diaabled'
+              ? {
+                  backgroundImage: `url(${bImageMap.get(message)})`,
+                  backgroundSize: '100% 100%',
+                }
+              : {
+                  backgroundColor: 'rgba(0, 0, 0, .08)',
+                  borderLeft: '2px solid rgba(0, 0, 0, .8)',
+                }
+          }
+          className="item-box"
+        >
+          <div className="item-title">消息通信</div>
+          <div
+            className={classNames('item-context', message !== 'diaabled' ? 'item-message' : '')}
+            style={{ fontWeight: 400 }}
+          >
+            {message === 'diaabled' ? (
+              <span style={{ color: statusColor.get(message) }}>
+                <Badge color={statusColor.get(message)} /> 连接中
+              </span>
+            ) : (
+              <>
+                <div>
+                  <Badge
+                    color={statusColor.get(up)}
+                    text={
+                      up === 'waiting'
+                        ? `诊断中`
+                        : `上行消息通信${up === 'error' ? '异常' : '正常'}`
+                    }
+                  />
+                </div>
+                <div>
+                  <Badge
+                    color={statusColor.get(down)}
+                    text={
+                      down === 'waiting'
+                        ? `诊断中`
+                        : `下行消息通信${down === 'error' ? '异常' : '正常'}`
+                    }
+                  />
+                </div>
+              </>
+            )}
+          </div>
+        </div>
+      ),
+    },
+  ];
+  return (
+    <Card>
+      <Row gutter={24}>
+        {list.map((item: ListProps) => (
+          <Col
+            span={8}
+            key={item.key}
+            onClick={() => {
+              if (item.key === 'message' && status === 's-success-active') {
+                setCurrent(item.key);
+                setMessage('waiting');
+              }
+              if (item.key === 'status') {
+                setCurrent(item.key);
+              }
+            }}
+          >
+            {item.component}
+          </Col>
+        ))}
+      </Row>
+      <div className="container">
+        {current === 'status' ? (
+          <Status
+            onChange={(type: string) => {
+              if (type === 'success') {
+                setStatus('s-success-active');
+                setMessage('diaabled');
+              } else if (type === 'error') {
+                setStatus('s-error');
+                setMessage('diaabled');
+              } else if (type === 'loading') {
+                setStatus('waiting');
+                setMessage('diaabled');
+              }
+            }}
+          />
+        ) : (
+          <Message
+            onChange={(data: string) => {
+              if (data === 'waiting') {
+                setMessage('waiting');
+                setDown('waiting');
+                setUp('waiting');
+              } else if (data === 'down-error') {
+                setMessage('m-error');
+                setDown('error');
+              } else if (data === 'down-success') {
+                setMessage('s-success-active');
+                setDown('success');
+              } else if (data === 'up-success') {
+                setMessage('s-success-active');
+                setUp('success');
+              } else if (data === 'up-error') {
+                setMessage('m-error');
+                setUp('error');
+              }
+            }}
+          />
+        )}
+      </div>
+    </Card>
+  );
+};
+
+export default Diagnose;

+ 285 - 0
src/pages/device/Instance/Detail/MetadataMap/EditableTable/index.tsx

@@ -0,0 +1,285 @@
+import React, { useContext, useState, useEffect } from 'react';
+import { Table, Form, Select, Input, Pagination, message } from 'antd';
+import { service } from '@/pages/device/Instance';
+
+const EditableContext: any = React.createContext(null);
+
+const EditableRow = ({ ...props }) => {
+  const [form] = Form.useForm();
+
+  return (
+    <Form form={form} component={false}>
+      <EditableContext.Provider value={form}>
+        <tr {...props} />
+      </EditableContext.Provider>
+    </Form>
+  );
+};
+
+interface EditableCellProps {
+  title: React.ReactNode;
+  editable: boolean;
+  children: React.ReactNode;
+  dataIndex: string;
+  record: any;
+  list: any[];
+  handleSave: (record: any) => void;
+}
+
+const EditableCell = ({
+  title,
+  editable,
+  children,
+  dataIndex,
+  record,
+  list,
+  handleSave,
+  ...restProps
+}: EditableCellProps) => {
+  const form: any = useContext(EditableContext);
+
+  const save = async () => {
+    try {
+      const values = await form.validateFields();
+      handleSave({ ...record, ...values });
+    } catch (errInfo) {
+      console.log('Save failed:', errInfo);
+    }
+  };
+
+  useEffect(() => {
+    if (record) {
+      form.setFieldsValue({ [dataIndex]: record[dataIndex] });
+    }
+  }, [record]);
+
+  let childNode = children;
+
+  if (editable) {
+    childNode = (
+      <Form.Item style={{ margin: 0 }} name={dataIndex}>
+        <Select onChange={save}>
+          {list.map((item: any) => (
+            <Select.Option key={item?.id} value={item?.id}>
+              {item?.id}
+            </Select.Option>
+          ))}
+        </Select>
+      </Form.Item>
+    );
+  }
+  return <td {...restProps}>{childNode}</td>;
+};
+
+interface Props {
+  data: any;
+  type: 'device' | 'product';
+}
+
+const EditableTable = (props: Props) => {
+  const baseColumns = [
+    {
+      title: '物模型中属性名',
+      dataIndex: 'name',
+    },
+    {
+      title: '物模型中属性标识',
+      dataIndex: 'id',
+    },
+    {
+      title: '协议中属性标识',
+      dataIndex: 'metadataId',
+      width: '30%',
+      editable: true,
+    },
+  ];
+  const metadata = JSON.parse(props?.data?.metadata || '{}');
+  const [properties, setProperties] = useState<any[]>(metadata?.properties || []);
+  const [value, setValue] = useState<string>('');
+  const [dataSource, setDataSource] = useState<any>({
+    data: properties.slice(0, 10),
+    pageSize: 10,
+    pageIndex: 0,
+    total: properties.length,
+  });
+  const [protocolMetadata, setProtocolMetadata] = useState<any[]>([]);
+
+  const components = {
+    body: {
+      row: EditableRow,
+      cell: EditableCell,
+    },
+  };
+
+  const initData = async () => {
+    let resp = null;
+    if (props.type === 'device') {
+      resp = await service.queryDeviceMetadata(props.data.id);
+    } else {
+      resp = await service.queryProductMetadata(props.data.id);
+    }
+    if (resp.status === 200) {
+      const data = resp.result;
+      const obj: any = {};
+      data.map((i: any) => {
+        obj[i?.originalId] = i?.metadataId || '';
+      });
+      const list = properties.map((item) => {
+        return {
+          ...item,
+          metadataId: obj[item.id] || '',
+        };
+      });
+      setProperties([...list]);
+      setDataSource({
+        data: list.slice(
+          dataSource.pageIndex * dataSource.pageSize,
+          (dataSource.pageIndex + 1) * dataSource.pageSize,
+        ),
+        pageSize: dataSource.pageSize,
+        pageIndex: dataSource.pageIndex,
+        total: list.length,
+      });
+    }
+  };
+
+  useEffect(() => {
+    service
+      .queryProtocolMetadata(
+        props.type === 'device' ? props.data?.protocol : props.data?.messageProtocol,
+        props.type === 'device' ? props.data?.transport : props.data?.transportProtocol,
+      )
+      .then((resp) => {
+        if (resp.status === 200) {
+          setProtocolMetadata(JSON.parse(resp.result || '{}')?.properties || []);
+          initData();
+        }
+      });
+  }, []);
+
+  const handleSave = async (row: any) => {
+    const newData = [...dataSource.data];
+    const index = newData.findIndex((item) => row.id === item.id);
+    const item = newData[index];
+    newData.splice(index, 1, { ...item, ...row });
+    setDataSource({
+      ...dataSource,
+      data: [...newData],
+    });
+    if (item?.metadataId !== row?.metadataId) {
+      const resp = await service[
+        props.type === 'device' ? 'saveDeviceMetadata' : 'saveProductMetadata'
+      ](props.data?.id, [
+        {
+          metadataType: 'property',
+          metadataId: row.metadataId,
+          originalId: row.id,
+          others: {},
+        },
+      ]);
+      if (resp.status === 200) {
+        message.success('操作成功!');
+        // 刷新
+        initData();
+      }
+    }
+  };
+
+  const handleSearch = (params: any) => {
+    if (params.name) {
+      const data = properties.filter((i: any) => {
+        return i?.name.indexOf(params?.nmae) !== -1;
+      });
+      setDataSource({
+        data: data.slice(
+          params.pageIndex * params.pageSize,
+          (params.pageIndex + 1) * params.pageSize,
+        ),
+        pageSize: params.pageSize,
+        pageIndex: params.pageIndex,
+        total: data.length,
+      });
+    } else {
+      setDataSource({
+        data: properties.slice(
+          params.pageIndex * params.pageSize,
+          (params.pageIndex + 1) * params.pageSize,
+        ),
+        pageSize: params.pageSize,
+        pageIndex: params.pageIndex,
+        total: properties.length,
+      });
+    }
+  };
+
+  const columns = baseColumns.map((col) => {
+    if (!col.editable) {
+      return col;
+    }
+    return {
+      ...col,
+      onCell: (record: any) => ({
+        record,
+        editable: col.editable,
+        dataIndex: col.dataIndex,
+        title: col.title,
+        list: protocolMetadata,
+        handleSave: handleSave,
+      }),
+    };
+  });
+
+  return (
+    <div>
+      <Input.Search
+        placeholder="请输入物模型属性名"
+        allowClear
+        style={{ width: 300, marginBottom: 20 }}
+        onSearch={(e: string) => {
+          setValue(e);
+          handleSearch({
+            name: e,
+            pageIndex: 0,
+            pageSize: 10,
+          });
+        }}
+      />
+      <Table
+        components={components}
+        rowClassName={() => 'editable-row'}
+        bordered
+        rowKey="id"
+        pagination={false}
+        dataSource={dataSource?.data || []}
+        columns={columns}
+      />
+      {dataSource.data.length > 0 && (
+        <div style={{ display: 'flex', marginTop: 20, justifyContent: 'flex-end' }}>
+          <Pagination
+            showSizeChanger
+            size="small"
+            className={'pro-table-card-pagination'}
+            total={dataSource?.total || 0}
+            current={dataSource?.pageIndex + 1}
+            onChange={(page, size) => {
+              handleSearch({
+                name: value,
+                pageIndex: page - 1,
+                pageSize: size,
+              });
+            }}
+            pageSizeOptions={[10, 20, 50, 100]}
+            pageSize={dataSource?.pageSize}
+            showTotal={(num) => {
+              const minSize = dataSource?.pageIndex * dataSource?.pageSize + 1;
+              const MaxSize = (dataSource?.pageIndex + 1) * dataSource?.pageSize;
+              return `第 ${minSize} - ${MaxSize > num ? num : MaxSize} 条/总共 ${num} 条`;
+            }}
+          />
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default EditableTable;

+ 101 - 0
src/pages/device/Instance/Detail/MetadataMap/index.tsx

@@ -0,0 +1,101 @@
+import { Card, Empty } from 'antd';
+import { useEffect, useState } from 'react';
+import { InstanceModel, service } from '@/pages/device/Instance';
+import { productModel } from '@/pages/device/Product';
+import EditableTable from './EditableTable';
+import { getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
+import type { ProductItem } from '@/pages/device/Product/typings';
+
+interface Props {
+  type: 'device' | 'product';
+}
+
+const MetadataMap = (props: Props) => {
+  const { type } = props;
+  const [product, setProduct] = useState<Partial<ProductItem>>();
+  const [data, setData] = useState<any>({});
+
+  const handleSearch = () => {
+    service
+      .queryProductState(InstanceModel.detail?.productId || productModel.current?.id || '')
+      .then((resp) => {
+        if (resp.status === 200) {
+          setProduct(resp.result);
+          if (type === 'product') {
+            setData(resp.result);
+          } else {
+            setData(InstanceModel.detail);
+          }
+        }
+      });
+  };
+
+  const checkUrl = (str: string) => {
+    const url = getMenuPathByParams(MENUS_CODE['device/Product/Detail'], product?.id);
+    const tab: any = window.open(`${origin}/#${url}?key=${str}`);
+    tab!.onTabSaveSuccess = (value: any) => {
+      if (value.status === 200) {
+        handleSearch();
+      }
+    };
+  };
+
+  const renderComponent = () => {
+    if (product) {
+      if (!product.accessId) {
+        return (
+          <Empty
+            description={
+              <span>
+                请配置对应产品的
+                <a
+                  onClick={() => {
+                    checkUrl('access');
+                  }}
+                >
+                  设备接入方式
+                </a>
+              </span>
+            }
+          />
+        );
+      } else {
+        const metadata = JSON.parse(product?.metadata || '{}');
+        const dmetadata = JSON.parse(data?.metadata || '{}');
+        if (
+          (type === 'device' &&
+            (metadata?.properties || []).length === 0 &&
+            (dmetadata?.properties || []).length === 0) ||
+          (type === 'product' && (dmetadata?.properties || []).length === 0)
+        ) {
+          return (
+            <Empty
+              description={
+                <span>
+                  请先配置对应产品的
+                  <a
+                    onClick={() => {
+                      checkUrl('metadata');
+                    }}
+                  >
+                    物模型属性
+                  </a>
+                </span>
+              }
+            />
+          );
+        }
+        return <EditableTable data={data} type={type} />;
+      }
+    }
+    return <Empty />;
+  };
+
+  useEffect(() => {
+    handleSearch();
+  }, []);
+
+  return <Card bordered={false}>{renderComponent()}</Card>;
+};
+
+export default MetadataMap;

+ 27 - 1
src/pages/device/Instance/Detail/index.tsx

@@ -2,7 +2,8 @@ import { PageContainer } from '@ant-design/pro-layout';
 import { InstanceModel, service } from '@/pages/device/Instance';
 import { history, useParams } from 'umi';
 import { Badge, Button, Card, Descriptions, Divider, message, Tooltip } from 'antd';
-import { ReactNode, useEffect, useState } from 'react';
+import type { ReactNode } from 'react';
+import { useEffect, useState } from 'react';
 import { observer } from '@formily/react';
 import Log from '@/pages/device/Instance/Detail/Log';
 // import Alarm from '@/pages/device/components/Alarm';
@@ -10,6 +11,8 @@ import Info from '@/pages/device/Instance/Detail/Info';
 import Functions from '@/pages/device/Instance/Detail/Functions';
 import Running from '@/pages/device/Instance/Detail/Running';
 import ChildDevice from '@/pages/device/Instance/Detail/ChildDevice';
+import Diagnose from '@/pages/device/Instance/Detail/Diagnose';
+import MetadataMap from '@/pages/device/Instance/Detail/MetadataMap';
 import { useIntl } from '@@/plugin-locale/localeExports';
 import Metadata from '../../components/Metadata';
 import type { DeviceMetadata } from '@/pages/device/Product/typings';
@@ -92,6 +95,16 @@ const InstanceDetail = observer(() => {
       }),
       component: <Log />,
     },
+    {
+      key: 'diagnose',
+      tab: '设备诊断',
+      component: <Diagnose />,
+    },
+    {
+      key: 'metadata-map',
+      tab: '物模型映射',
+      component: <MetadataMap type="device" />,
+    },
   ];
   const [list, setList] = useState<{ key: string; tab: string; component: ReactNode }[]>(baseList);
 
@@ -158,6 +171,19 @@ const InstanceDetail = observer(() => {
     };
   }, [params.id]);
 
+  useEffect(() => {
+    if ((location as any).query?.key) {
+      setTab((location as any).query?.key || 'detail');
+    }
+    const subscription = Store.subscribe(SystemConst.BASE_UPDATE_DATA, (data) => {
+      if ((window as any).onTabSaveSuccess) {
+        (window as any).onTabSaveSuccess(data);
+        setTimeout(() => window.close(), 300);
+      }
+    });
+    return () => subscription.unsubscribe();
+  }, []);
+
   return (
     <PageContainer
       className={'page-title-show'}

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

@@ -159,6 +159,85 @@ class Service extends BaseService<DeviceInstance> {
       method: 'PATCH',
       data,
     });
+  //产品状态
+  public queryProductState = (id: string) =>
+    request(`/${SystemConst.API_BASE}/device/product/${id}`, {
+      method: 'GET',
+    });
+  // 发布产品
+  public deployProduct = (productId: string) =>
+    request(`/${SystemConst.API_BASE}/device/product/${productId}/deploy`, {
+      method: 'POST',
+    });
+  // 产品配置
+  public queryProductConfig = (id: string) =>
+    request(`/${SystemConst.API_BASE}/device/product/${id}/config-metadata`, {
+      method: 'GET',
+    });
+  // 设备接入网关状态
+  public queryGatewayState = (id: string) =>
+    request(`/${SystemConst.API_BASE}/gateway/device/${id}/detail`, {
+      method: 'GET',
+    });
+  public startGateway = (id: string) =>
+    request(`/${SystemConst.API_BASE}/gateway/device/${id}/_startup`, {
+      method: 'POST',
+    });
+  //网络组件状态
+  public queryNetworkState = (id: string) =>
+    request(`/${SystemConst.API_BASE}/network/config/${id}`, {
+      method: 'GET',
+    });
+  //网络组件启用
+  public startNetwork = (id: string) =>
+    request(`/${SystemConst.API_BASE}/network/config/${id}/_start`, {
+      method: 'POST',
+    });
+  // 执行功能
+  public executeFunctions = (deviceId: string, functionId: string, data: any) =>
+    request(`/${SystemConst.API_BASE}device/invoked/${deviceId}/function/${functionId}`, {
+      method: 'POST',
+      data,
+    });
+  // 读取属性
+  public readProperties = (deviceId: string, data: any) =>
+    request(`/${SystemConst.API_BASE}/device/instance/${deviceId}/properties/_read`, {
+      method: 'POST',
+      data,
+    });
+  // 设置属性
+  public settingProperties = (deviceId: string, data: any) =>
+    request(`/${SystemConst.API_BASE}/device/setting/${deviceId}/property`, {
+      method: 'POST',
+      data,
+    });
+  //获取协议设置的默认物模型
+  public queryProtocolMetadata = (id: string, transport: string) =>
+    request(`/${SystemConst.API_BASE}/protocol/${id}/${transport}/metadata`, {
+      method: 'GET',
+    });
+  // 保存设备物模型映射
+  public saveDeviceMetadata = (deviceId: string, data: any) =>
+    request(`/${SystemConst.API_BASE}/device/metadata/mapping/device/${deviceId}`, {
+      method: 'PATCH',
+      data,
+    });
+  //保存产品物模型映射
+  public saveProductMetadata = (productId: string, data: any) =>
+    request(`/${SystemConst.API_BASE}/device/metadata/mapping/product/${productId}`, {
+      method: 'PATCH',
+      data,
+    });
+  //查询设备物模型映射
+  public queryDeviceMetadata = (deviceId: string) =>
+    request(`/${SystemConst.API_BASE}/device/metadata/mapping/device/${deviceId}`, {
+      method: 'GET',
+    });
+  //查询产品物模型映射
+  public queryProductMetadata = (productId: string) =>
+    request(`/${SystemConst.API_BASE}/device/metadata/mapping/product/${productId}`, {
+      method: 'GET',
+    });
 }
 
 export default Service;

+ 8 - 1
src/pages/device/Product/Detail/Access/index.tsx

@@ -326,7 +326,14 @@ const Access = () => {
                   });
                   if (resp.status === 200) {
                     message.success('操作成功!');
-                    getDetailInfo();
+                    if ((window as any).onTabSaveSuccess) {
+                      if (resp.result) {
+                        (window as any).onTabSaveSuccess(resp);
+                        setTimeout(() => window.close(), 300);
+                      }
+                    } else {
+                      getDetailInfo();
+                    }
                   }
                 }}
               >

+ 19 - 0
src/pages/device/Product/Detail/index.tsx

@@ -25,6 +25,7 @@ import { Store } from 'jetlinks-store';
 import MetadataAction from '@/pages/device/components/Metadata/DataBaseAction';
 import { getMenuPathByCode, MENUS_CODE } from '@/utils/menu';
 import encodeQuery from '@/utils/encodeQuery';
+import MetadataMap from '@/pages/device/Instance/Detail/MetadataMap';
 import SystemConst from '@/utils/const';
 
 export const ModelEnum = {
@@ -168,8 +169,26 @@ const ProductDetail = observer(() => {
       tab: '设备接入',
       component: <Access />,
     },
+    {
+      key: 'metadata-map',
+      tab: '物模型映射',
+      component: <MetadataMap type="product" />,
+    },
   ];
 
+  useEffect(() => {
+    if ((location as any).query?.key) {
+      setMode((location as any).query?.key || 'base');
+    }
+    const subscription = Store.subscribe(SystemConst.BASE_UPDATE_DATA, (data) => {
+      if ((window as any).onTabSaveSuccess) {
+        (window as any).onTabSaveSuccess(data);
+        setTimeout(() => window.close(), 300);
+      }
+    });
+    return () => subscription.unsubscribe();
+  }, []);
+
   return (
     <PageContainer
       className={'page-title-show'}

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

@@ -793,12 +793,19 @@ const Edit = observer((props: Props) => {
     const result = await asyncUpdateMedata(props.type, _data);
     if (result.status === 200) {
       message.success('操作成功!');
-      Store.set(SystemConst.REFRESH_METADATA_TABLE, true);
-      if (deploy) {
-        Store.set('product-deploy', deploy);
+      if ((window as any).onTabSaveSuccess) {
+        if (result) {
+          (window as any).onTabSaveSuccess(result);
+          setTimeout(() => window.close(), 300);
+        }
+      } else {
+        Store.set(SystemConst.REFRESH_METADATA_TABLE, true);
+        if (deploy) {
+          Store.set('product-deploy', deploy);
+        }
+        MetadataModel.edit = false;
+        MetadataModel.item = {};
       }
-      MetadataModel.edit = false;
-      MetadataModel.item = {};
     } else {
       message.error('操作失败!');
     }

+ 43 - 21
src/pages/rule-engine/Instance/index.tsx

@@ -32,6 +32,7 @@ const Instance = () => {
 
   const tools = (record: InstanceItem) => [
     <Button
+      key={'edit'}
       type={'link'}
       style={{ padding: 0 }}
       onClick={() => {
@@ -50,25 +51,26 @@ const Instance = () => {
         <EditOutlined />
       </Tooltip>
     </Button>,
+    // <Button key={'view'}
+    //   disabled={getButtonPermission('rule-engine/Instance', ['view'])}
+    //   type={'link'}
+    //   style={{ padding: 0 }}
+    //   onClick={() => {
+    //     window.open(`/${SystemConst.API_BASE}/rule-editor/index.html#flow/${record.id}`);
+    //   }}
+    // >
+    //   <Tooltip
+    //     title={intl.formatMessage({
+    //       id: 'pages.data.option.detail',
+    //       defaultMessage: '查看',
+    //     })}
+    //     key={'detail'}
+    //   >
+    //     <EyeOutlined />
+    //   </Tooltip>
+    // </Button>,
     <Button
-      disabled={getButtonPermission('rule-engine/Instance', ['view'])}
-      type={'link'}
-      style={{ padding: 0 }}
-      onClick={() => {
-        window.open(`/${SystemConst.API_BASE}/rule-editor/index.html#flow/${record.id}`);
-      }}
-    >
-      <Tooltip
-        title={intl.formatMessage({
-          id: 'pages.data.option.detail',
-          defaultMessage: '查看',
-        })}
-        key={'detail'}
-      >
-        <EyeOutlined />
-      </Tooltip>
-    </Button>,
-    <Button
+      key={'operate'}
       disabled={getButtonPermission('rule-engine/Instance', ['action'])}
       type={'link'}
       style={{ padding: 0 }}
@@ -104,9 +106,9 @@ const Instance = () => {
         </Tooltip>
       </Popconfirm>
     </Button>,
-
     <Button
       type={'link'}
+      key={'delete'}
       style={{ padding: 0 }}
       disabled={getButtonPermission('rule-engine/Instance', ['delete'])}
     >
@@ -233,6 +235,7 @@ const Instance = () => {
         <Button
           type={'link'}
           style={{ padding: 0 }}
+          key={'operate'}
           disabled={getButtonPermission('rule-engine/Instance', ['action'])}
         >
           <Popconfirm
@@ -273,6 +276,7 @@ const Instance = () => {
         <Button
           disabled={getButtonPermission('rule-engine/Instance', ['delete'])}
           type={'link'}
+          key={'delete'}
           style={{ padding: 0 }}
         >
           <Popconfirm
@@ -313,7 +317,6 @@ const Instance = () => {
         field={columns}
         target="device-instance"
         onSearch={(data) => {
-          console.log(data);
           // 重置分页数据
           actionRef.current?.reset?.();
           setSearchParams(data);
@@ -356,7 +359,26 @@ const Instance = () => {
             })}
           </Button>,
         ]}
-        cardRender={(record) => <RuleInstanceCard {...record} actions={tools(record)} />}
+        cardRender={(record) => (
+          <RuleInstanceCard
+            {...record}
+            actions={tools(record)}
+            detail={
+              <div
+                style={{ padding: 8, fontSize: 24 }}
+                onClick={() => {
+                  if (!getButtonPermission('rule-engine/Instance', ['view'])) {
+                    window.open(
+                      `/${SystemConst.API_BASE}/rule-editor/index.html#flow/${record.id}`,
+                    );
+                  }
+                }}
+              >
+                <EyeOutlined />
+              </div>
+            }
+          />
+        )}
       />
       {visible && (
         <Save

+ 12 - 0
src/utils/util.ts

@@ -88,3 +88,15 @@ export const testIP = (str: string) => {
     /^([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])$/;
   return re.test(str);
 };
+
+// 生成随机数
+export const randomString = (length?: number) => {
+  const tempLength = length || 32;
+  const chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
+  const maxPos = chars.length;
+  let pwd = '';
+  for (let i = 0; i < tempLength; i += 1) {
+    pwd += chars.charAt(Math.floor(Math.random() * maxPos));
+  }
+  return pwd;
+};