Ver código fonte

feat: 数据采集

100011797 3 anos atrás
pai
commit
20772cfce5

BIN
public/images/DataCollect/dashboard/channel.png


BIN
public/images/DataCollect/dashboard/collector.png


BIN
public/images/DataCollect/dashboard/point.png


+ 61 - 0
src/pages/link/DataCollect/Dashboard/index.less

@@ -0,0 +1,61 @@
+.device-dash-board {
+  .top-card-items {
+    margin-bottom: 12px;
+
+    .top-card-item {
+      display: flex;
+      flex-direction: column;
+      justify-content: space-between;
+      width: 25%;
+      padding: 6px 24px;
+      border: 1px solid #e3e3e3;
+
+      .top-card-top {
+        display: flex;
+        padding: 12px 0;
+
+        .top-card-top-left {
+          width: 80px;
+        }
+
+        .top-card-top-right {
+          .top-card-total {
+            font-weight: bold;
+            font-size: 20px;
+            line-height: 50px;
+          }
+        }
+
+        .top-card-top-charts {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+
+          .top-card-top-charts-total {
+            font-weight: bold;
+            font-size: 18px;
+          }
+        }
+      }
+
+      .top-card-bottom {
+        display: flex;
+        justify-content: space-between;
+        padding: 12px 0;
+        border-top: 1px solid #e3e3e3;
+      }
+    }
+  }
+
+  .amap-marker-label {
+    top: -26px !important;
+    padding: 3px;
+    font-size: 14px;
+    line-height: 18px;
+    background-color: #666;
+    border: none;
+    .amap {
+      color: #e3e3e3;
+    }
+  }
+}

+ 236 - 2
src/pages/link/DataCollect/Dashboard/index.tsx

@@ -1,5 +1,239 @@
 import { PageContainer } from '@ant-design/pro-layout';
+import { useEffect, useRef, useState } from 'react';
+import './index.less';
+import service from '../service';
+import DashBoard, { DashBoardTopCard } from '@/components/DashBoard';
+import type { EChartsOption } from 'echarts';
 
-export default () => {
-  return <PageContainer>dashboard</PageContainer>;
+type RefType = {
+  getValues: Function;
 };
+
+const DeviceBoard = () => {
+  const [channel, setChannel] = useState(0);
+  const [errorChannel, setErrorChannel] = useState(0);
+  const [collector, setCollector] = useState(0);
+  const [errorCollector, setErrorCollector] = useState(0);
+  const [point, setPoint] = useState(0);
+  const [errorPoint, setErrorPoint] = useState(0);
+  const ref = useRef<RefType>();
+  const [options, setOptions] = useState<EChartsOption>({});
+  const [timeToolOptions] = useState([
+    { label: '最近1小时', value: 'hour' },
+    { label: '今日', value: 'today' },
+    { label: '近一周', value: 'week' },
+  ]);
+
+  //通道数量
+  const channelStatus = async () => {
+    const channelRes = await service.queryChannelCount({});
+    if (channelRes.status === 200) {
+      setChannel(channelRes.result);
+    }
+    const errorChannelRes = await service.queryChannelCount({
+      terms: [
+        {
+          column: 'runningState',
+          termType: 'not',
+          value: 'running',
+        },
+      ],
+    });
+    if (errorChannelRes.status === 200) {
+      setErrorChannel(errorChannelRes.result);
+    }
+  };
+
+  //采集器数量
+  const collectorStatus = async () => {
+    const collectorRes = await service.queryCollectorCount({});
+    if (collectorRes.status === 200) {
+      setCollector(collectorRes.result);
+    }
+    const errorCollectorRes = await service.queryCollectorCount({
+      terms: [
+        {
+          column: 'runningState',
+          termType: 'not',
+          value: 'running',
+        },
+      ],
+    });
+    if (errorCollectorRes.status === 200) {
+      setErrorCollector(errorCollectorRes.result);
+    }
+  };
+
+  // 点位
+  const pointStatus = async () => {
+    const pointRes = await service.queryPointCount({});
+    if (pointRes.status === 200) {
+      setPoint(pointRes.result);
+    }
+    const errorPointRes = await service.queryPointCount({
+      terms: [
+        {
+          column: 'runningState',
+          termType: 'not',
+          value: 'running',
+        },
+      ],
+    });
+    if (errorPointRes.status === 200) {
+      setErrorPoint(errorPointRes.result);
+    }
+  };
+  const getInterval = (type: string) => {
+    switch (type) {
+      case 'year':
+        return '30d';
+      case 'month':
+      case 'week':
+        return '1d';
+      case 'hour':
+        return '1m';
+      default:
+        return '1h';
+    }
+  };
+
+  const getEcharts = async () => {
+    const data = ref.current!.getValues();
+    const res = await service.dashboard([
+      {
+        dashboard: 'collector',
+        object: 'pointData',
+        measurement: 'quantity',
+        dimension: 'agg',
+        params: {
+          limit: 15,
+          from: data.time.start,
+          to: data.time.end,
+          interval: getInterval(data.time.type),
+          format: 'HH:mm',
+        },
+      },
+    ]);
+    if (res.status === 200) {
+      const x = res.result.map((item: any) => item.data.timeString).reverse();
+      const y = res.result.map((item: any) => item.data.value).reverse();
+      setOptions({
+        xAxis: {
+          type: 'category',
+          boundaryGap: false,
+          data: x,
+        },
+        yAxis: {
+          type: 'value',
+        },
+        tooltip: {
+          trigger: 'axis',
+        },
+        grid: {
+          top: '2%',
+          bottom: '5%',
+          left: '50px',
+          right: '50px',
+        },
+        series: [
+          {
+            name: '消息量',
+            data: y,
+            type: 'line',
+            smooth: true,
+            color: '#60DFC7',
+            areaStyle: {
+              color: {
+                type: 'linear',
+                x: 0,
+                y: 0,
+                x2: 0,
+                y2: 1,
+                colorStops: [
+                  {
+                    offset: 0,
+                    color: '#60DFC7', // 100% 处的颜色
+                  },
+                  {
+                    offset: 1,
+                    color: '#FFFFFF', //   0% 处的颜色
+                  },
+                ],
+                global: false, // 缺省为 false
+              },
+            },
+          },
+        ],
+      });
+    }
+  };
+
+  useEffect(() => {
+    channelStatus();
+    collectorStatus();
+    pointStatus();
+  }, []);
+
+  return (
+    <PageContainer>
+      <div className={'device-dash-board'}>
+        <DashBoardTopCard>
+          <DashBoardTopCard.Item
+            title={'通道数量'}
+            value={channel}
+            footer={[
+              {
+                title: '异常通道',
+                value: errorChannel,
+                status: 'error',
+              },
+            ]}
+            span={8}
+          >
+            <img src={require('/public/images/DataCollect/dashboard/channel.png')} />
+          </DashBoardTopCard.Item>
+          <DashBoardTopCard.Item
+            title={'采集器数量'}
+            value={collector}
+            footer={[
+              {
+                title: '异常采集器',
+                value: errorCollector,
+                status: 'error',
+              },
+            ]}
+            span={8}
+          >
+            <img src={require('/public/images/DataCollect/dashboard/collector.png')} />
+          </DashBoardTopCard.Item>
+          <DashBoardTopCard.Item
+            title={'采集点位'}
+            value={point}
+            footer={[
+              {
+                title: '异常点位',
+                value: errorPoint,
+                status: 'error',
+              },
+            ]}
+            span={8}
+          >
+            <img src={require('/public/images/DataCollect/dashboard/point.png')} />
+          </DashBoardTopCard.Item>
+        </DashBoardTopCard>
+        <DashBoard
+          title={'点位数据量'}
+          options={options}
+          ref={ref}
+          height={500}
+          defaultTime={'hour'}
+          timeToolOptions={timeToolOptions}
+          showTime={true}
+          showTimeTool={true}
+          onParamsChange={getEcharts}
+        />
+      </div>
+    </PageContainer>
+  );
+};
+export default DeviceBoard;

+ 75 - 0
src/pages/link/DataCollect/components/Point/CollectorCard/WritePoint.tsx

@@ -0,0 +1,75 @@
+import { Modal } from 'antd';
+import { FormItem, Input } from '@formily/antd';
+import { createForm } from '@formily/core';
+import { createSchemaField, FormProvider } from '@formily/react';
+import service from '../../../service';
+import { onlyMessage } from '@/utils/util';
+
+interface Props {
+  data: Partial<PointItem>;
+  onCancel: () => void;
+}
+
+const WritePoint = (props: Props) => {
+  const { data } = props;
+
+  const SchemaField = createSchemaField({
+    components: {
+      Input,
+      FormItem,
+    },
+  });
+
+  const form = createForm();
+  const schema = {
+    type: 'object',
+    properties: {
+      propertyValue: {
+        type: 'string',
+        title: data?.name || '自定义属性',
+        required: true,
+        'x-decorator': 'FormItem',
+        'x-component': 'Input',
+      },
+    },
+  };
+
+  const handleSetPropertyValue = async (propertyValue: string) => {
+    if (data?.collectorId && data?.id) {
+      const resp = await service.writePoint(data.collectorId, [
+        {
+          pointId: data.id,
+          value: propertyValue,
+        },
+      ]);
+      if (resp.status === 200) {
+        onlyMessage('操作成功');
+      }
+      props.onCancel();
+    }
+  };
+  return (
+    <Modal
+      maskClosable={false}
+      title="编辑"
+      visible
+      onOk={async () => {
+        const values: any = await form.submit();
+        if (!!values) {
+          handleSetPropertyValue(values?.propertyValue);
+        }
+      }}
+      onCancel={() => {
+        props.onCancel();
+      }}
+    >
+      <div style={{ marginTop: '30px' }}>
+        <FormProvider form={form}>
+          <SchemaField schema={schema} />
+        </FormProvider>
+      </div>
+    </Modal>
+  );
+};
+
+export default WritePoint;

+ 10 - 1
src/pages/link/DataCollect/components/Point/CollectorCard/index.less

@@ -55,7 +55,16 @@
         flex-direction: row-reverse;
         align-items: flex-start;
         justify-content: space-around;
-        width: calc(50% - 20px);
+        min-width: calc(50% - 40px);
+        max-width: calc(50% - 20px);
+
+        .card-item-content-item-empty {
+          margin-top: 10px;
+          color: rgba(0, 0, 0, 0.45);
+          .action {
+            color: rgba(0, 0, 0, 0.45);
+          }
+        }
 
         .card-item-content-item-header {
           display: flex;

+ 154 - 86
src/pages/link/DataCollect/components/Point/CollectorCard/index.tsx

@@ -1,24 +1,46 @@
 import { useState } from 'react';
 import { Ellipsis } from '@/components';
 import './index.less';
-import { Badge, Popconfirm } from 'antd';
-import { DeleteOutlined, EditOutlined, FormOutlined, RedoOutlined } from '@ant-design/icons';
+import { Badge, Popconfirm, Spin } from 'antd';
+import {
+  DeleteOutlined,
+  EditOutlined,
+  FormOutlined,
+  MinusOutlined,
+  RedoOutlined,
+} from '@ant-design/icons';
 import OpcSave from '../Save/opc-ua';
 import ModbusSave from '../Save/modbus';
 import service from '@/pages/link/DataCollect/service';
 import { onlyMessage } from '@/utils/util';
+import moment from 'moment';
+import WritePoint from '@/pages/link/DataCollect/components/Point/CollectorCard/WritePoint';
 
 export interface PointCardProps {
   item: Partial<PointItem>;
   reload: () => void;
+  wsValue: any;
 }
 
 const opcImage = require('/public/images/DataCollect/device-opcua.png');
 const modbusImage = require('/public/images/DataCollect/device-modbus.png');
 
 export default (props: PointCardProps) => {
-  const { item } = props;
+  const { item, wsValue } = props;
   const [editVisible, setEditVisible] = useState<boolean>(false);
+  const [spinning, setSpinning] = useState<boolean>(false);
+  const [writeVisible, setWriteVisible] = useState<boolean>(false);
+
+  const read = async () => {
+    if (item?.collectorId && item?.id) {
+      setSpinning(true);
+      const resp = await service.readPoint(item?.collectorId, [item.id]);
+      if (resp.status === 200) {
+        onlyMessage('操作成功');
+      }
+      setSpinning(false);
+    }
+  };
 
   const saveComponent = () => {
     if (item.provider === 'OPC_UA') {
@@ -47,106 +69,152 @@ export default (props: PointCardProps) => {
     );
   };
   return (
-    <div className={'card-item'}>
-      <div className={'card-item-left'}>
-        <div className={'card-item-status'}>
-          <div className={'card-item-status-content'}>
-            <Badge
-              status={item.state?.value === 'enabled' ? 'success' : 'error'}
-              text={item.state?.text}
-            />
-          </div>
-        </div>
-        <div className={'card-item-avatar'}>
-          <img
-            width={88}
-            height={88}
-            src={item.provider === 'OPC_UA' ? opcImage : modbusImage}
-            alt={''}
-          />
-        </div>
-      </div>
-      <div className={'card-item-right'}>
-        <div className={'card-item-body'}>
-          <div className={'card-item-right-header'}>
-            <div className={'card-item-right-title'}>
-              <Ellipsis title={item.name} />
-            </div>
-            <div className={'card-item-right-action'}>
-              <Popconfirm
-                title={'确认删除'}
-                onConfirm={async () => {
-                  if (item.id) {
-                    const resp = await service.removePoint(item.id);
-                    if (resp.status === 200) {
-                      onlyMessage('操作成功!');
-                      props.reload();
-                    }
-                  }
-                }}
-              >
-                <DeleteOutlined style={{ marginRight: 10 }} />
-              </Popconfirm>
-              <FormOutlined
-                onClick={() => {
-                  setEditVisible(true);
-                }}
+    <Spin spinning={spinning}>
+      <div className={'card-item'}>
+        <div className={'card-item-left'}>
+          <div className={'card-item-status'}>
+            <div className={'card-item-status-content'}>
+              <Badge
+                status={item.state?.value === 'enabled' ? 'success' : 'error'}
+                text={item.state?.text}
               />
             </div>
           </div>
-          <div className={'card-item-content'}>
-            <div className={'card-item-content-item'}>
-              <div className={'card-item-content-item-header'}>
-                <div className={'card-item-content-item-header-title'}>
-                  <Ellipsis title={'123455123455123455(int8)'} />
-                </div>
-                <div className={'card-item-content-item-header-action'}>
-                  <EditOutlined style={{ marginRight: 5 }} onClick={() => {}} />
-                  <RedoOutlined />
-                </div>
-              </div>
-              <div className={'card-item-content-item-text'}>
-                <Ellipsis title={'12345670200101010101010101'} />
+          <div className={'card-item-avatar'}>
+            <img
+              width={88}
+              height={88}
+              src={item.provider === 'OPC_UA' ? opcImage : modbusImage}
+              alt={''}
+            />
+          </div>
+        </div>
+        <div className={'card-item-right'}>
+          <div className={'card-item-body'}>
+            <div className={'card-item-right-header'}>
+              <div className={'card-item-right-title'}>
+                <Ellipsis title={item.name} />
               </div>
-              <div className={'card-item-content-item-text'}>
-                <Ellipsis title={'2011-10-10 09:00:00'} />
+              <div className={'card-item-right-action'}>
+                <Popconfirm
+                  title={'确认删除'}
+                  onConfirm={async () => {
+                    if (item.id) {
+                      const resp = await service.removePoint(item.id);
+                      if (resp.status === 200) {
+                        onlyMessage('操作成功!');
+                        props.reload();
+                      }
+                    }
+                  }}
+                >
+                  <DeleteOutlined style={{ marginRight: 10 }} />
+                </Popconfirm>
+                <FormOutlined
+                  onClick={() => {
+                    setEditVisible(true);
+                  }}
+                />
               </div>
             </div>
-            <div className={'content-item-border-right'}></div>
-            <div className={'card-item-content-item'}>
-              <div className={'card-item-content-item-header'}>
-                <div className={'card-item-content-item-header-item'}>
-                  <div>
-                    <Ellipsis title={item.configuration?.parameter?.quantity} />
+            <div className={'card-item-content'}>
+              {wsValue ? (
+                <div className={'card-item-content-item'}>
+                  <div className={'card-item-content-item-header'}>
+                    <div className={'card-item-content-item-header-title'}>
+                      <Ellipsis title={`${wsValue?.parseData}(${wsValue?.dataType})`} />
+                    </div>
+                    <div className={'card-item-content-item-header-action'}>
+                      <EditOutlined
+                        style={{ marginRight: 5 }}
+                        onClick={() => {
+                          setWriteVisible(true);
+                        }}
+                      />
+                      <RedoOutlined
+                        onClick={() => {
+                          read();
+                        }}
+                      />
+                    </div>
                   </div>
-                  <div style={{ width: 85, opacity: 0.75 }}>(读取寄存器)</div>
-                </div>
-                <div className={'card-item-content-item-header-item'}>
-                  <div>
-                    <Ellipsis title={item.configuration?.parameter?.address} />
+                  <div className={'card-item-content-item-text'}>
+                    <Ellipsis title={wsValue?.hex || ''} />
+                  </div>
+                  <div className={'card-item-content-item-text'}>
+                    <Ellipsis
+                      title={
+                        wsValue?.timestamp
+                          ? moment(wsValue.timestamp).format('YYYY-MM-DD HH:mm:ss')
+                          : ''
+                      }
+                    />
                   </div>
-                  <div style={{ width: 40, opacity: 0.75 }}>(地址)</div>
                 </div>
-                <div className={'card-item-content-item-header-item'}>
-                  <div>
-                    <Ellipsis title={item.configuration?.codec?.configuration?.scaleFactor} />
+              ) : (
+                <div className={'card-item-content-item'}>
+                  <div className={'card-item-content-item-empty'}>
+                    <MinusOutlined className={'action'} />
+                    <EditOutlined
+                      className={'action'}
+                      style={{ margin: '0 15px' }}
+                      onClick={() => {
+                        setWriteVisible(true);
+                      }}
+                    />
+                    <RedoOutlined
+                      className={'action'}
+                      onClick={() => {
+                        read();
+                      }}
+                    />
                   </div>
-                  <div style={{ width: 72, opacity: 0.75 }}>(缩放因子)</div>
                 </div>
-              </div>
-              <div className={'card-item-content-item-tags'}>
-                <div className={'card-item-content-item-tag'}>
-                  {(item?.accessModes || []).map((i) => i?.text).join(',')}
+              )}
+              <div className={'content-item-border-right'}></div>
+              <div className={'card-item-content-item'}>
+                <div className={'card-item-content-item-header'}>
+                  <div className={'card-item-content-item-header-item'}>
+                    <div>
+                      <Ellipsis title={item.configuration?.parameter?.quantity} />
+                    </div>
+                    <div style={{ width: 85, opacity: 0.75 }}>(读取寄存器)</div>
+                  </div>
+                  <div className={'card-item-content-item-header-item'}>
+                    <div>
+                      <Ellipsis title={item.configuration?.parameter?.address} />
+                    </div>
+                    <div style={{ width: 40, opacity: 0.75 }}>(地址)</div>
+                  </div>
+                  <div className={'card-item-content-item-header-item'}>
+                    <div>
+                      <Ellipsis title={item.configuration?.codec?.configuration?.scaleFactor} />
+                    </div>
+                    <div style={{ width: 72, opacity: 0.75 }}>(缩放因子)</div>
+                  </div>
                 </div>
-                <div className={'card-item-content-item-tag'}>
-                  采集频率{item?.configuration?.interval}s
+                <div className={'card-item-content-item-tags'}>
+                  <div className={'card-item-content-item-tag'}>
+                    {(item?.accessModes || []).map((i) => i?.text).join(',')}
+                  </div>
+                  <div className={'card-item-content-item-tag'}>
+                    采集频率{item?.configuration?.interval}s
+                  </div>
                 </div>
               </div>
             </div>
           </div>
         </div>
+        {editVisible && saveComponent()}
+        {writeVisible && (
+          <WritePoint
+            data={item}
+            onCancel={() => {
+              setWriteVisible(false);
+            }}
+          />
+        )}
       </div>
-      {editVisible && saveComponent()}
-    </div>
+    </Spin>
   );
 };

+ 1 - 1
src/pages/link/DataCollect/components/Point/Save/opc-ua.tsx

@@ -113,7 +113,7 @@ export default (props: Props) => {
             'x-component-props': {
               placeholder: '请选择数据类型',
             },
-            'x-reactions': '{{useAsyncDataSource(getSecurityPolicyList)}}',
+            'x-reactions': '{{useAsyncDataSource(getCodecProvider)}}',
             'x-validator': [
               {
                 required: true,

+ 84 - 30
src/pages/link/DataCollect/components/Point/Save/scan.tsx

@@ -1,12 +1,14 @@
-import { Button, Empty, Modal, Transfer, Tree } from 'antd';
+import { Button, Empty, Modal, Spin, Transfer, Tree } from 'antd';
 import type { TransferDirection } from 'antd/es/transfer';
 import { useEffect, useState } from 'react';
 import service from '@/pages/link/DataCollect/service';
 import './scan.less';
 import { CloseOutlined } from '@ant-design/icons';
+import { Ellipsis } from '@/components';
+import { onlyMessage } from '@/utils/util';
 
 interface Props {
-  channelId?: string;
+  collector?: any;
   close: () => void;
   reload: () => void;
 }
@@ -16,18 +18,29 @@ interface TreeTransferProps {
   targetKeys: string[];
   onChange: (targetKeys: string[], direction: TransferDirection, moveKeys: string[]) => void;
   channelId?: string;
+  arrChange: (arr: any[]) => void;
 }
 
-const TreeTransfer = ({ dataSource, targetKeys, channelId, ...restProps }: TreeTransferProps) => {
+const TreeTransfer = ({
+  dataSource,
+  targetKeys,
+  channelId,
+  arrChange,
+  ...restProps
+}: TreeTransferProps) => {
   const [transferDataSource, setTransferDataSource] = useState<any[]>(dataSource);
 
+  useEffect(() => {
+    setTransferDataSource([...dataSource]);
+  }, [dataSource]);
+
   const isChecked = (selectedKeys: (string | number)[], eventKey: string | number) =>
     selectedKeys.includes(eventKey);
 
   const generateTree = (treeNodes: any[] = [], checkedKeys: string[] = []): any[] =>
     treeNodes.map(({ children, ...props }) => ({
       ...props,
-      disabled: checkedKeys.includes(props.key as string),
+      disabled: checkedKeys.includes(props.key as string) || props?.folder,
       children: generateTree(children, checkedKeys),
     }));
 
@@ -43,18 +56,19 @@ const TreeTransfer = ({ dataSource, targetKeys, channelId, ...restProps }: TreeT
   };
 
   const generateTargetTree = (treeNodes: any[] = []): any[] => {
-    return treeNodes.map((item) => queryDataByID(transferDataSource, item));
+    const arr = treeNodes.map((item) => queryDataByID(transferDataSource, item));
+    return arr;
   };
 
-  const updateTreeData = (list: any[], key: React.Key, children: any[]): any[] =>
-    list.map((node) => {
+  const updateTreeData = (list: any[], key: string, children: any[]): any[] => {
+    const arr = list.map((node) => {
       if (node.key === key) {
         return {
           ...node,
           children,
         };
       }
-      if (node.children) {
+      if (node?.children && node.children.length) {
         return {
           ...node,
           children: updateTreeData(node.children, key, children),
@@ -62,19 +76,35 @@ const TreeTransfer = ({ dataSource, targetKeys, channelId, ...restProps }: TreeT
       }
       return node;
     });
+    return arr;
+  };
+
+  useEffect(() => {
+    arrChange(generateTargetTree(targetKeys));
+  }, [targetKeys]);
 
-  const onLoadData = ({ key, children }: any) =>
+  const onLoadData = (node: any) =>
     new Promise<void>(async (resolve) => {
-      if (children) {
+      if (node.children.length || !node?.folder) {
         resolve();
         return;
       }
       const resp = await service.scanOpcUAList({
         id: channelId,
-        nodeId: key,
+        nodeId: node.key,
       });
       if (resp.status === 200) {
-        setTransferDataSource((origin) => updateTreeData(origin, key, resp.result));
+        const list = resp.result.map((item: any) => {
+          return {
+            ...item,
+            key: item.id,
+            title: item.name,
+            disabled: item?.folder,
+            isLeaf: !item?.folder,
+          };
+        });
+        const arr = updateTreeData(transferDataSource, node.key, [...list]);
+        setTransferDataSource([...arr]);
       }
       resolve();
     });
@@ -84,7 +114,7 @@ const TreeTransfer = ({ dataSource, targetKeys, channelId, ...restProps }: TreeT
       {...restProps}
       targetKeys={targetKeys}
       dataSource={transferDataSource}
-      render={(item) => item.title!}
+      render={(item) => item?.title}
       showSelectAll={false}
       titles={['源数据', '目标数据']}
       oneWay
@@ -93,14 +123,14 @@ const TreeTransfer = ({ dataSource, targetKeys, channelId, ...restProps }: TreeT
         if (direction === 'left') {
           const checkedKeys = [...selectedKeys, ...targetKeys];
           return (
-            <div style={{ margin: '10px 0' }}>
+            <div style={{ margin: '10px' }}>
               <Tree
                 blockNode
                 checkable
                 checkStrictly
                 checkedKeys={checkedKeys}
                 height={250}
-                treeData={generateTree(dataSource, targetKeys)}
+                treeData={generateTree(transferDataSource, targetKeys)}
                 onCheck={(_, { node: { key } }) => {
                   onItemSelect(key as string, !isChecked(checkedKeys, key));
                 }}
@@ -118,8 +148,10 @@ const TreeTransfer = ({ dataSource, targetKeys, channelId, ...restProps }: TreeT
                 {generateTargetTree(targetKeys).map((item) => {
                   return (
                     <div className={'right-item'} key={item.key}>
-                      <div>{item.title || item.key}</div>
-                      <div>
+                      <div style={{ width: 'calc(100% - 30px)' }}>
+                        <Ellipsis title={item?.title || item.key} />
+                      </div>
+                      <div style={{ width: 20, marginLeft: 10 }}>
                         <CloseOutlined
                           onClick={() => {
                             if (onItemRemove) {
@@ -145,16 +177,18 @@ const TreeTransfer = ({ dataSource, targetKeys, channelId, ...restProps }: TreeT
 export default (props: Props) => {
   const [targetKeys, setTargetKeys] = useState<string[]>([]);
   const [treeData, setTreeData] = useState<any[]>([]);
-  // const [treeData, setTreeData] = useState<any[]>([]);
-  const onChange = (keys: string[]) => {
+  const [loading, setLoading] = useState<boolean>(false);
+  const [arr, setArr] = useState<any[]>([]);
+  const onChange = (keys: any[]) => {
     setTargetKeys(keys);
   };
 
   useEffect(() => {
-    if (props.channelId) {
+    if (props.collector?.channelId) {
+      setLoading(true);
       service
         .scanOpcUAList({
-          id: props.channelId,
+          id: props.collector?.channelId,
         })
         .then((resp) => {
           if (resp.status === 200) {
@@ -163,13 +197,15 @@ export default (props: Props) => {
                 ...item,
                 key: item.id,
                 title: item.name,
+                disabled: item?.folder,
               };
             });
             setTreeData(list);
           }
+          setLoading(false);
         });
     }
-  }, [props.channelId]);
+  }, [props.collector?.channelId]);
 
   return (
     <Modal
@@ -185,20 +221,38 @@ export default (props: Props) => {
         <Button
           type="primary"
           key={2}
-          onClick={() => {
-            // save();
+          onClick={async () => {
+            const list = arr.map((item) => {
+              return {
+                id: item.key,
+                name: item.title,
+                type: item.type,
+              };
+            });
+            const resp = await service.savePointBatch(props.collector?.id, props.collector?.name, [
+              ...list,
+            ]);
+            if (resp.status === 200) {
+              onlyMessage('操作成功');
+              props.reload();
+            }
           }}
         >
           确定
         </Button>,
       ]}
     >
-      <TreeTransfer
-        channelId={props.channelId}
-        dataSource={treeData}
-        targetKeys={targetKeys}
-        onChange={onChange}
-      />
+      <Spin spinning={loading}>
+        <TreeTransfer
+          channelId={props.collector?.channelId}
+          dataSource={treeData}
+          targetKeys={targetKeys}
+          onChange={onChange}
+          arrChange={(li) => {
+            setArr(li);
+          }}
+        />
+      </Spin>
     </Modal>
   );
 };

+ 30 - 2
src/pages/link/DataCollect/components/Point/index.tsx

@@ -1,7 +1,7 @@
 import { observer } from '@formily/react';
 import SearchComponent from '@/components/SearchComponent';
 import type { ProColumns } from '@jetlinks/pro-table';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 import { useDomFullHeight } from '@/hooks';
 import service from '@/pages/link/DataCollect/service';
 import CollectorCard from './CollectorCard/index';
@@ -10,6 +10,8 @@ import { Card, Col, Pagination, Row } from 'antd';
 import { model } from '@formily/reactive';
 import ModbusSave from '@/pages/link/DataCollect/components/Point/Save/modbus';
 import Scan from '@/pages/link/DataCollect/components/Point/Save/scan';
+import { map } from 'rxjs/operators';
+import useSendWebsocketMessage from '@/hooks/websocket/useSendWebsocketMessage';
 
 interface Props {
   type: boolean; // true: 综合查询  false: 数据采集
@@ -28,12 +30,14 @@ const PointModel = model<{
 });
 
 export default observer((props: Props) => {
+  const [subscribeTopic] = useSendWebsocketMessage();
   const { minHeight } = useDomFullHeight(`.data-collect-point`, 24);
   const [param, setParam] = useState({
     terms: [],
   });
   const [loading, setLoading] = useState<boolean>(true);
   const { permission } = PermissionButton.usePermission('device/Instance');
+  const [propertyValue, setPropertyValue] = useState<any>({});
   const [dataSource, setDataSource] = useState<any>({
     data: [],
     pageSize: 10,
@@ -81,6 +85,21 @@ export default observer((props: Props) => {
       },
     },
   ];
+
+  const subRef = useRef<any>(null);
+
+  const subscribeProperty = (list: any) => {
+    const id = `collector-${props.data?.channelId}-${props.data?.id}-data-${list.join('-')}`;
+    const topic = `/collector/${props.data?.channelId}/${props.data?.id}/data`;
+    subRef.current = subscribeTopic!(id, topic, {
+      pointId: list.join(','),
+    })
+      ?.pipe(map((res) => res.payload))
+      .subscribe((payload: any) => {
+        propertyValue[payload?.pointId] = { ...payload };
+        setPropertyValue([...propertyValue]);
+      });
+  };
   const handleSearch = (params: any) => {
     setLoading(true);
     setParam(params);
@@ -98,6 +117,7 @@ export default observer((props: Props) => {
       .then((resp) => {
         if (resp.status === 200) {
           setDataSource(resp.result);
+          subscribeProperty((resp.result?.data || []).map((item: any) => item.id));
         }
         setLoading(false);
       });
@@ -107,6 +127,13 @@ export default observer((props: Props) => {
     handleSearch(param);
   }, [props.data?.id]);
 
+  useEffect(() => {
+    return () => {
+      // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+      subRef.current && subRef.current?.unsubscribe();
+    };
+  }, []);
+
   return (
     <div>
       <SearchComponent<PointItem>
@@ -149,6 +176,7 @@ export default observer((props: Props) => {
                     <Col key={record.id} span={12}>
                       <CollectorCard
                         item={record}
+                        wsValue={propertyValue[record.id]}
                         reload={() => {
                           handleSearch(param);
                         }}
@@ -214,7 +242,7 @@ export default observer((props: Props) => {
           close={() => {
             PointModel.p_add_visible = false;
           }}
-          channelId={props.data?.channelId}
+          collector={props.data}
           reload={() => {
             PointModel.p_add_visible = false;
             handleSearch(param);

+ 31 - 1
src/pages/link/DataCollect/service.ts

@@ -13,6 +13,11 @@ class Service {
       method: 'POST',
       data: params,
     });
+  public queryPointCount = (params: any) =>
+    request(`/${SystemConst.API_BASE}/data-collect/point/_count`, {
+      method: 'POST',
+      data: params,
+    });
   public queryPointByID = (id: string) =>
     request(`/${SystemConst.API_BASE}/data-collect/point/${id}`, {
       method: 'GET',
@@ -27,7 +32,7 @@ class Service {
       method: 'POST',
       data,
     });
-  public writePoint = (collectorId: string, data: string[]) =>
+  public writePoint = (collectorId: string, data: any[]) =>
     request(`/${SystemConst.API_BASE}data-collect/collector/${collectorId}/points/_write`, {
       method: 'POST',
       data,
@@ -47,6 +52,11 @@ class Service {
       method: 'POST',
       data: params,
     });
+  public queryCollectorCount = (params: any) =>
+    request(`/${SystemConst.API_BASE}/data-collect/collector/_count`, {
+      method: 'POST',
+      data: params,
+    });
   public queryCollectorByID = (id: string) =>
     request(`/${SystemConst.API_BASE}/data-collect/collector/${id}`, {
       method: 'GET',
@@ -71,6 +81,11 @@ class Service {
       method: 'POST',
       data: params,
     });
+  public queryChannelCount = (params: any) =>
+    request(`/${SystemConst.API_BASE}/data-collect/channel/_count`, {
+      method: 'POST',
+      data: params,
+    });
   public queryChannelByID = (id: string) =>
     request(`/${SystemConst.API_BASE}/data-collect/channel/${id}`, {
       method: 'GET',
@@ -107,6 +122,21 @@ class Service {
     request(`/${SystemConst.API_BASE}/things/collector/codecs`, {
       method: 'GET',
     });
+
+  public savePointBatch = (collectorId: string, collectorName: string, params: any[]) =>
+    request(
+      `/${SystemConst.API_BASE}/data-collect/opc/point/_batch?collectorId=${collectorId}&collectorName=${collectorName}`,
+      {
+        method: 'POST',
+        data: params,
+      },
+    );
+
+  public dashboard = (data?: any) =>
+    request(`/${SystemConst.API_BASE}/dashboard/_multi`, {
+      method: 'POST',
+      data,
+    });
 }
 
 const service = new Service();