Selaa lähdekoodia

feat: 设备输出

Wzyyy98 3 vuotta sitten
vanhempi
commit
07a9de67a5

BIN
public/images/scene/device-custom.png


BIN
public/images/scene/device-variable.png


BIN
public/images/scene/invoke-function.png


BIN
public/images/scene/read-property.png


BIN
public/images/scene/write-property.png


+ 55 - 0
src/components/ProTableCard/CardItems/device.tsx

@@ -145,3 +145,58 @@ export default (props: DeviceCardProps) => {
     </TableCard>
   );
 };
+
+export const SceneDeviceCard = (props: DeviceCardProps) => {
+  const [imgUrl, setImgUrl] = useState<string>(props.photoUrl || defaultImage);
+
+  return (
+    <TableCard
+      showTool={props.showTool}
+      showMask={false}
+      status={props.state?.value}
+      actions={props.actions}
+      statusText={props.state?.text}
+      statusNames={{
+        online: StatusColorEnum.processing,
+        offline: StatusColorEnum.error,
+        notActive: StatusColorEnum.warning,
+      }}
+      onClick={props.onClick}
+      className={props.className}
+    >
+      <div className={'pro-table-card-item'}>
+        <div className={'card-item-avatar'}>
+          <img
+            width={88}
+            height={88}
+            src={imgUrl}
+            alt={''}
+            onError={() => {
+              setImgUrl(defaultImage);
+            }}
+          />
+        </div>
+        <div className={'card-item-body'}>
+          <div className={'card-item-header'}>
+            <Ellipsis title={props.name} titleClassName={'card-item-header-name'} />
+          </div>
+          <div className={'card-item-content-flex'}>
+            <div className={'flex-auto'}>
+              <label>设备类型</label>
+              <Ellipsis title={props.deviceType ? props.deviceType.text : ''} />
+            </div>
+            <div className={'flex-auto'}>
+              <label>产品名称</label>
+              <Ellipsis title={props.productName} />
+            </div>
+          </div>
+        </div>
+      </div>
+      <div className={'checked-icon'}>
+        <div>
+          <CheckOutlined />
+        </div>
+      </div>
+    </TableCard>
+  );
+};

+ 61 - 0
src/components/ProTableCard/CardItems/product.tsx

@@ -106,6 +106,67 @@ export const ExtraProductCard = (props: ProductCardProps) => {
   );
 };
 
+export const SceneProductCard = (props: ProductCardProps) => {
+  const intl = useIntl();
+  const [imgUrl, setImgUrl] = useState<string>(props.photoUrl || defaultImage);
+
+  return (
+    <TableCard
+      showMask={false}
+      status={props.state}
+      showTool={props.showTool}
+      actions={props.actions}
+      statusText={intl.formatMessage({
+        id: `pages.device.product.status.${props.state ? 'enabled' : 'disabled'}`,
+        defaultMessage: '正常',
+      })}
+      statusNames={{
+        0: StatusColorEnum.error,
+        1: StatusColorEnum.success,
+      }}
+      className={props.className}
+      onClick={props.onClick}
+    >
+      <div className={'pro-table-card-item'}>
+        <div className={'card-item-avatar'}>
+          <img
+            width={88}
+            height={88}
+            src={imgUrl}
+            alt={''}
+            onError={() => {
+              setImgUrl(defaultImage);
+            }}
+          />
+        </div>
+        <div className={'card-item-body'}>
+          <div className={'card-item-header'}>
+            <Ellipsis title={props.name} titleClassName={'card-item-header-name'} />
+          </div>
+          <div className={'card-item-content-items'} style={{ display: 'flex', gap: 12 }}>
+            {props.content}
+          </div>
+          <div className={'card-item-content-flex'}>
+            <div className={'flex-auto'}>
+              <label>设备类型</label>
+              <Ellipsis title={props?.deviceType?.text} />
+            </div>
+            <div className={'flex-auto'}>
+              <label>接入方式</label>
+              <Ellipsis title={props.accessName || '未接入'} />
+            </div>
+          </div>
+        </div>
+      </div>
+      <div className={'checked-icon'}>
+        <div>
+          <CheckOutlined />
+        </div>
+      </div>
+    </TableCard>
+  );
+};
+
 export default (props: ProductCardProps) => {
   const intl = useIntl();
   return (

+ 8 - 4
src/components/ProTableCard/index.tsx

@@ -30,6 +30,7 @@ interface ProTableCardProps<T> {
    */
   gridColumns?: [number, number, number];
   height?: 'none';
+  onlyCard?: boolean; //只展示card
 }
 
 const ProTableCard = <
@@ -39,7 +40,7 @@ const ProTableCard = <
 >(
   props: ProTableCardProps<T> & ProTableProps<T, U, ValueType>,
 ) => {
-  const { cardRender, toolBarRender, request, ...extraProps } = props;
+  const { cardRender, toolBarRender, request, onlyCard, ...extraProps } = props;
   const [model, setModel] = useState<ModelType>(ModelEnum.CARD);
   const [total, setTotal] = useState<number | undefined>(0);
   const [current, setCurrent] = useState(1); // 当前页
@@ -64,7 +65,7 @@ const ProTableCard = <
         if (!rowSelection || (rowSelection && !rowSelection.selectedRowKeys)) {
           return dom;
         }
-        const { selectedRowKeys, onChange } = rowSelection;
+        const { selectedRowKeys, onChange, type } = rowSelection;
 
         // @ts-ignore
         const id = dom.props.id;
@@ -82,13 +83,15 @@ const ProTableCard = <
               const isSelect = selectedRowKeys.includes(id);
 
               if (isSelect) {
-                const nowRowKeys = selectedRowKeys.filter((key: string) => key !== id);
+                const nowRowKeys =
+                  type === 'radio' ? [id] : selectedRowKeys.filter((key: string) => key !== id);
                 onChange(
                   nowRowKeys,
                   dataSource!.filter((item) => nowRowKeys.includes(item.id)),
                 );
               } else {
-                const nowRowKeys = [...selectedRowKeys, id];
+                // const nowRowKeys = [...selectedRowKeys, id];
+                const nowRowKeys = rowSelection.type === 'radio' ? [id] : [...selectedRowKeys, id];
                 onChange(
                   nowRowKeys,
                   dataSource!.filter((item) => nowRowKeys.includes(item.id)),
@@ -207,6 +210,7 @@ const ProTableCard = <
           pageSizeOptions: pageSizeOptions,
         }}
         toolBarRender={(action, row) => {
+          if (onlyCard) return [];
           const oldBar = toolBarRender ? toolBarRender(action, row) : [];
           return [
             ...oldBar,

+ 88 - 0
src/pages/rule-engine/Scene/Save/action/DeviceOutput/actions/WriteProperty.tsx

@@ -0,0 +1,88 @@
+import { Col, Form, Row, Select } from 'antd';
+// import { useEffect, useState } from "react";
+
+interface Props {
+  properties: any[];
+  value?: any;
+  id?: string;
+  onChange?: (value?: any) => void;
+  propertiesChange?: (value?: string) => void;
+}
+
+export default (props: Props) => {
+  // const [propertiesKey, setPropertiesKey] = useState<string | undefined>(undefined);
+  // const [propertiesValue, setPropertiesValue] = useState(undefined);
+  // const [propertiesType, setPropertiesType] = useState('');
+
+  // useEffect(() => {
+  //     console.log(props.value);
+  //     if (props.value) {
+  //         if (props.properties && props.properties.length) {
+  //             if (0 in props.value) {
+  //                 setPropertiesValue(props.value[0]);
+  //             } else if ('undefined' in props.value) {
+  //                 setPropertiesKey(undefined);
+  //                 setPropertiesValue(undefined);
+  //             } else {
+  //                 Object.keys(props.value).forEach((key: string) => {
+  //                     setPropertiesKey(key);
+  //                     setPropertiesValue(props.value[key].value);
+  //                     const propertiesItem = props.properties.find((item: any) => item.id === key);
+  //                     if (propertiesItem) {
+  //                         setPropertiesType(propertiesItem.valueType.type);
+  //                     }
+  //                 });
+  //             }
+  //         }
+  //     } else {
+  //         setPropertiesKey(undefined);
+  //         setPropertiesValue(undefined);
+  //     }
+  // }, [props.value, props.properties]);
+  return (
+    <Row gutter={24}>
+      <Col span={24}>
+        <Form.Item
+          name={['device', 'message', 'properties']}
+          label="设置属性"
+          rules={[{ required: true, message: '请选择属性' }]}
+        >
+          <Select
+            id={props.id}
+            value={props.value ? props.value[0] : undefined}
+            options={props.properties.filter((item) => {
+              if (item.expands && item.expands.type) {
+                return item.expands.type.includes('write');
+              }
+              return false;
+            })}
+            fieldNames={{ label: 'name', value: 'id' }}
+            style={{ width: '100%' }}
+            placeholder={'请选择属性'}
+          ></Select>
+        </Form.Item>
+      </Col>
+      <Col span={12}>
+        <Form.Item
+          name={['device', 'message', 'properties']}
+          label="设置属性"
+          rules={[{ required: true, message: '请选择属性' }]}
+        >
+          <Select
+            id={props.id}
+            value={props.value ? props.value[0] : undefined}
+            options={props.properties.filter((item) => {
+              if (item.expands && item.expands.type) {
+                return item.expands.type.includes('write');
+              }
+              return false;
+            })}
+            fieldNames={{ label: 'name', value: 'id' }}
+            style={{ width: '100%' }}
+            placeholder={'请选择属性'}
+          ></Select>
+        </Form.Item>
+      </Col>
+    </Row>
+  );
+};

+ 81 - 0
src/pages/rule-engine/Scene/Save/action/DeviceOutput/actions/index.tsx

@@ -0,0 +1,81 @@
+import { observer } from '@formily/reactive-react';
+import { Form, Select } from 'antd';
+import { useEffect, useState } from 'react';
+import ReadProperty from '../../device/readProperty';
+import TopCard from '../device/TopCard';
+import DeviceModel from '../model';
+import WriteProperty from './WriteProperty';
+
+interface Props {
+  get: (data: any) => void;
+}
+
+export default observer((props: Props) => {
+  const [form] = Form.useForm();
+  const [deviceMessageType, setDeviceMessageType] = useState('WRITE_PROPERTY');
+  const [properties, setProperties] = useState([]); // 物模型-属性
+  const [propertiesId, setPropertiesId] = useState<string | undefined>(''); // 物模型-属性ID,用于串行
+
+  const TypeList = [
+    {
+      label: '功能调用',
+      value: 'INVOKE_FUNCTION',
+      image: require('/public/images/scene/invoke-function.png'),
+      tip: '-',
+    },
+    {
+      label: '读取属性',
+      value: 'READ_PROPERTY',
+      image: require('/public/images/scene/read-property.png'),
+      tip: '-',
+    },
+    {
+      label: '设置属性',
+      value: 'WRITE_PROPERTY',
+      image: require('/public/images/scene/write-property.png'),
+      tip: '-',
+    },
+  ];
+
+  useEffect(() => {
+    if (DeviceModel.productDetail) {
+      const metadata = JSON.parse(DeviceModel.productDetail?.metadata || '{}');
+      setProperties(metadata.properties);
+    }
+  }, [DeviceModel.productDetail]);
+
+  useEffect(() => {
+    props.get(form);
+    console.log(propertiesId);
+  }, [form]);
+
+  return (
+    <div>
+      <Form form={form} layout={'vertical'}>
+        <Form.Item name="messageType" label="动作类型" required>
+          <TopCard
+            typeList={TypeList}
+            onChange={(value: string) => {
+              setDeviceMessageType(value);
+            }}
+          />
+        </Form.Item>
+        {deviceMessageType === 'INVOKE_FUNCTION' && (
+          <Form.Item name={['device', 'message', 'inputs']} label="功能调用" required>
+            <Select></Select>
+          </Form.Item>
+        )}
+        {deviceMessageType === 'READ_PROPERTY' && (
+          <Form.Item
+            name={['device', 'message', 'properties']}
+            label="读取属性"
+            rules={[{ required: true, message: '请选择读取属性' }]}
+          >
+            <ReadProperty properties={properties} propertiesChange={setPropertiesId} />
+          </Form.Item>
+        )}
+        {deviceMessageType === 'WRITE_PROPERTY' && <WriteProperty properties={properties} />}
+      </Form>
+    </div>
+  );
+});

+ 55 - 0
src/pages/rule-engine/Scene/Save/action/DeviceOutput/device/TopCard.tsx

@@ -0,0 +1,55 @@
+import classNames from 'classnames';
+import { useEffect, useState } from 'react';
+import '../index.less';
+
+interface Props {
+  typeList: any[];
+  value?: string;
+  className?: string;
+  onChange?: (type: string) => void;
+  onSelect?: (type: string) => void;
+  disabled?: boolean;
+}
+
+const TopCard = (props: Props) => {
+  const [type, setType] = useState(props.value || '');
+
+  useEffect(() => {
+    setType(props.value || '');
+  }, [props.value]);
+
+  const onSelect = (_type: string) => {
+    if (!props.disabled) {
+      setType(_type);
+
+      if (props.onChange) {
+        props.onChange(_type);
+      }
+    }
+  };
+
+  return (
+    <div className={classNames('trigger-way-warp', props.className, { disabled: props.disabled })}>
+      {props.typeList.map((item) => (
+        <div
+          key={item.value}
+          className={classNames('trigger-way-item', {
+            active: type === item.value,
+          })}
+          onClick={() => {
+            onSelect(item.value);
+          }}
+        >
+          <div className={'way-item-title'}>
+            <p>{item.label}</p>
+            <span>{item.tip}</span>
+          </div>
+          <div className={'way-item-image'}>
+            <img width={48} src={item.image} />
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+};
+export default TopCard;

+ 298 - 0
src/pages/rule-engine/Scene/Save/action/DeviceOutput/device/index.tsx

@@ -0,0 +1,298 @@
+import { ProTableCard } from '@/components';
+import SearchComponent from '@/components/SearchComponent';
+import type { DeviceInstance } from '@/pages/device/Instance/typings';
+import { useRef, useState } from 'react';
+import type { ActionType, ProColumns } from '@jetlinks/pro-table';
+import { service } from '@/pages/device/Instance/index';
+import { SceneDeviceCard } from '@/components/ProTableCard/CardItems/device';
+import { isNoCommunity } from '@/utils/util';
+import { useIntl } from 'umi';
+import { service as categoryService } from '@/pages/device/Category';
+import { service as deptService } from '@/pages/system/Department';
+import DeviceModel from '../model';
+import { observer } from '@formily/reactive-react';
+import { Form } from 'antd';
+import '../index.less';
+import TopCard from './TopCard';
+import { ExclamationCircleOutlined } from '@ant-design/icons';
+
+export default observer(() => {
+  const actionRef = useRef<ActionType>();
+  const intl = useIntl();
+  const [searchParam, setSearchParam] = useState({});
+  const [form] = Form.useForm();
+
+  const TypeList = [
+    {
+      label: '自定义',
+      value: 'custom',
+      image: require('/public/images/scene/device-custom.png'),
+      tip: '自定义选择当前产品下的任意设备',
+    },
+    {
+      label: '按变量',
+      value: 'variable',
+      image: require('/public/images/scene/device-variable.png'),
+      tip: '选择设备ID为上游变量值的设备',
+    },
+  ];
+
+  const columns: ProColumns<DeviceInstance>[] = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      width: 300,
+      ellipsis: true,
+      fixed: 'left',
+    },
+    {
+      title: intl.formatMessage({
+        id: 'pages.table.deviceName',
+        defaultMessage: '设备名称',
+      }),
+      dataIndex: 'name',
+      ellipsis: true,
+      width: 200,
+    },
+    {
+      title: intl.formatMessage({
+        id: 'pages.table.productName',
+        defaultMessage: '产品名称',
+      }),
+      dataIndex: 'productId',
+      width: 200,
+      ellipsis: true,
+      valueType: 'select',
+      request: async () => {
+        const res = await service.getProductList();
+        if (res.status === 200) {
+          return res.result.map((pItem: any) => ({ label: pItem.name, value: pItem.id }));
+        }
+        return [];
+      },
+      render: (_, row) => row.productName,
+      filterMultiple: true,
+    },
+    {
+      title: intl.formatMessage({
+        id: 'pages.device.instance.registrationTime',
+        defaultMessage: '注册时间',
+      }),
+      dataIndex: 'registryTime',
+    },
+    {
+      title: intl.formatMessage({
+        id: 'pages.searchTable.titleStatus',
+        defaultMessage: '状态',
+      }),
+      dataIndex: 'state',
+      width: '90px',
+      valueType: 'select',
+      valueEnum: {
+        notActive: {
+          text: intl.formatMessage({
+            id: 'pages.device.instance.status.notActive',
+            defaultMessage: '禁用',
+          }),
+          status: 'notActive',
+        },
+        offline: {
+          text: intl.formatMessage({
+            id: 'pages.device.instance.status.offLine',
+            defaultMessage: '离线',
+          }),
+          status: 'offline',
+        },
+        online: {
+          text: intl.formatMessage({
+            id: 'pages.device.instance.status.onLine',
+            defaultMessage: '在线',
+          }),
+          status: 'online',
+        },
+      },
+      filterMultiple: false,
+    },
+    {
+      dataIndex: 'classifiedId',
+      title: '产品分类',
+      valueType: 'treeSelect',
+      hideInTable: true,
+      fieldProps: {
+        fieldNames: {
+          label: 'name',
+          value: 'id',
+        },
+      },
+      request: () =>
+        categoryService
+          .queryTree({
+            paging: false,
+          })
+          .then((resp: any) => resp.result),
+    },
+    {
+      title: '网关类型',
+      dataIndex: 'accessProvider',
+      width: 150,
+      ellipsis: true,
+      valueType: 'select',
+      hideInTable: true,
+      request: () =>
+        service.getProviders().then((resp: any) => {
+          return (resp?.result || [])
+            .filter((i: any) =>
+              !isNoCommunity
+                ? [
+                    'mqtt-server-gateway',
+                    'http-server-gateway',
+                    'mqtt-client-gateway',
+                    'tcp-server-gateway',
+                  ].includes(i.id)
+                : i,
+            )
+            .map((item: any) => ({
+              label: item.name,
+              value: `accessProvider is ${item.id}`,
+            }));
+        }),
+    },
+    {
+      dataIndex: 'productId$product-info',
+      title: '接入方式',
+      valueType: 'select',
+      hideInTable: true,
+      request: () =>
+        service.queryGatewayList().then((resp: any) =>
+          resp.result.map((item: any) => ({
+            label: item.name,
+            value: `accessId is ${item.id}`,
+          })),
+        ),
+    },
+    {
+      dataIndex: 'deviceType',
+      title: '设备类型',
+      valueType: 'select',
+      hideInTable: true,
+      valueEnum: {
+        device: {
+          text: '直连设备',
+          status: 'device',
+        },
+        childrenDevice: {
+          text: '网关子设备',
+          status: 'childrenDevice',
+        },
+        gateway: {
+          text: '网关设备',
+          status: 'gateway',
+        },
+      },
+    },
+    {
+      dataIndex: 'id$dim-assets',
+      title: '所属组织',
+      valueType: 'treeSelect',
+      hideInTable: true,
+      fieldProps: {
+        fieldNames: {
+          label: 'name',
+          value: 'value',
+        },
+      },
+      request: () =>
+        deptService
+          .queryOrgThree({
+            paging: false,
+          })
+          .then((resp) => {
+            const formatValue = (list: any[]) => {
+              const _list: any[] = [];
+              list.forEach((item) => {
+                if (item.children) {
+                  item.children = formatValue(item.children);
+                }
+                _list.push({
+                  ...item,
+                  value: JSON.stringify({
+                    assetType: 'device',
+                    targets: [
+                      {
+                        type: 'org',
+                        id: item.id,
+                      },
+                    ],
+                  }),
+                });
+              });
+              return _list;
+            };
+            return formatValue(resp.result);
+          }),
+    },
+  ];
+  return (
+    <div>
+      <div className="device-title">
+        <ExclamationCircleOutlined className="device-title-icon" />
+        <span>自定义选择当前产品下的任意设备</span>
+      </div>
+      <Form form={form} layout={'vertical'}>
+        <Form.Item name="type" label="选择方式" required>
+          <TopCard typeList={TypeList} />
+        </Form.Item>
+      </Form>
+      <SearchComponent
+        field={columns}
+        model={'simple'}
+        enableSave={false}
+        onSearch={async (data) => {
+          actionRef.current?.reset?.();
+          setSearchParam(data);
+        }}
+        target="device"
+        defaultParam={[
+          {
+            terms: [
+              {
+                column: 'productId',
+                value: DeviceModel.productId[0],
+              },
+            ],
+          },
+        ]}
+      />
+      <div>
+        <ProTableCard<DeviceInstance>
+          actionRef={actionRef}
+          columns={columns}
+          rowKey="id"
+          search={false}
+          gridColumn={2}
+          columnEmptyText={''}
+          onlyCard={true}
+          tableAlertRender={false}
+          rowSelection={{
+            type: 'radio',
+            selectedRowKeys: DeviceModel.productId,
+            onChange: (selectedRowKeys, selectedRows) => {
+              DeviceModel.deviceId = selectedRows.map((item) => item.id);
+            },
+          }}
+          request={(params) =>
+            service.query({
+              ...params,
+              sorts: [{ name: 'createTime', order: 'desc' }],
+            })
+          }
+          params={searchParam}
+          cardRender={(record) => (
+            <SceneDeviceCard showBindBtn={false} showTool={false} {...record} />
+          )}
+          height={'none'}
+        />
+      </div>
+    </div>
+  );
+});

+ 90 - 0
src/pages/rule-engine/Scene/Save/action/DeviceOutput/index.less

@@ -0,0 +1,90 @@
+@import '../../../../../../../node_modules/antd/es/style/themes/default.less';
+
+.steps-steps {
+  width: 100%;
+  margin-bottom: 17px;
+  padding-bottom: 17px;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+.steps-content {
+  width: 100%;
+}
+
+.trigger-way-warp {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  width: 100%;
+
+  .trigger-way-item {
+    display: flex;
+    justify-content: space-between;
+    width: 237px;
+    //width: 100%;
+    padding: 16px;
+    border: 1px solid #e0e4e8;
+    border-radius: 2px;
+    cursor: pointer;
+    opacity: 0.6;
+    transition: all 0.3s;
+
+    .way-item-title {
+      p {
+        margin-bottom: 8px;
+        font-weight: bold;
+        font-size: 16px;
+      }
+
+      span {
+        color: rgba(#000, 0.35);
+        font-size: 12px;
+      }
+    }
+
+    .way-item-image {
+      margin: 0 !important;
+    }
+
+    &:hover {
+      color: @primary-color-hover;
+      opacity: 0.8;
+    }
+
+    &.active {
+      border-color: @primary-color-active;
+      opacity: 1;
+    }
+  }
+
+  &.disabled {
+    .trigger-way-item {
+      cursor: not-allowed;
+
+      &:hover {
+        color: initial;
+        opacity: 0.6;
+      }
+
+      &.active {
+        opacity: 1;
+      }
+    }
+  }
+}
+
+.device-title {
+  top: 5px;
+  left: 0;
+  width: 760px;
+  height: 38px;
+  padding: 8px 16px 8px 16px;
+  background: rgba(250, 178, 71, 0.1);
+  border: 1px solid rgba(250, 178, 71, 0.4);
+  border-radius: 4px;
+
+  .device-title-icon {
+    margin-right: 5px;
+    color: #fab247;
+  }
+}

+ 107 - 0
src/pages/rule-engine/Scene/Save/action/DeviceOutput/index.tsx

@@ -0,0 +1,107 @@
+import { Modal, Button, Steps } from 'antd';
+import { useRef, useState } from 'react';
+import { observer } from '@formily/react';
+import Device from './device';
+import Product from './product';
+import Action from './actions';
+import Service from './service';
+// import { model } from '@formily/reactive';
+import './index.less';
+import DeviceModel from './model';
+import { onlyMessage } from '@/utils/util';
+
+export const service = new Service<any>('');
+
+export default observer(() => {
+  const [open, setOpen] = useState<boolean>(true);
+  // const [data, setData] = useState<any>({})
+  const formRef = useRef<any>();
+
+  DeviceModel.steps = [
+    {
+      key: 'product',
+      title: '选择产品',
+      content: <Product />,
+    },
+    {
+      key: 'device',
+      title: '选择设备',
+      content: <Device />,
+    },
+    {
+      key: 'action',
+      title: '执行动作',
+      content: (
+        <Action
+          get={(item: any) => {
+            formRef.current = item;
+          }}
+        />
+      ),
+    },
+  ];
+
+  const next = () => {
+    if (
+      (DeviceModel.current === 0 && DeviceModel.productId.length !== 0) ||
+      (DeviceModel.current === 1 && DeviceModel.deviceId.length !== 0)
+    ) {
+      return (DeviceModel.current += 1);
+    } else {
+      return DeviceModel.current === 0
+        ? onlyMessage('请选择产品', 'error')
+        : onlyMessage('请选择设备', 'error');
+    }
+  };
+
+  const prev = () => {
+    DeviceModel.current -= 1;
+  };
+
+  const save = async () => {
+    const value = await formRef.current?.validateFields();
+    console.log(value);
+  };
+
+  return (
+    <Modal
+      title={'执行动作'}
+      open={open}
+      width={800}
+      onCancel={() => {
+        setOpen(false);
+      }}
+      maskClosable={false}
+      footer={
+        <div className="steps-action">
+          {DeviceModel.current === 0 && <Button onClick={() => {}}>取消</Button>}
+          {DeviceModel.current > 0 && (
+            <Button style={{ margin: '0 8px' }} onClick={() => prev()}>
+              上一步
+            </Button>
+          )}
+          {DeviceModel.current < DeviceModel.steps.length - 1 && (
+            <Button type="primary" onClick={() => next()}>
+              下一步
+            </Button>
+          )}
+          {DeviceModel.current === DeviceModel.steps.length - 1 && (
+            <Button
+              type="primary"
+              onClick={async () => {
+                save();
+              }}
+            >
+              确定
+            </Button>
+          )}
+        </div>
+      }
+    >
+      <div className="steps-steps">
+        <Steps current={DeviceModel.current} items={DeviceModel.steps} />
+      </div>
+      <div className="steps-content">{DeviceModel.steps[DeviceModel.current]?.content}</div>
+    </Modal>
+  );
+});

+ 25 - 0
src/pages/rule-engine/Scene/Save/action/DeviceOutput/model.ts

@@ -0,0 +1,25 @@
+// 模型
+import { ProductItem } from '@/pages/device/Product/typings';
+import { model } from '@formily/reactive';
+
+type ModelType = {
+  steps: {
+    key: string;
+    title: string;
+    content: React.ReactNode;
+  }[];
+  current: number;
+  productId: string[];
+  deviceId: string[];
+  productDetail: ProductItem | any;
+};
+
+const DeviceModel = model<ModelType>({
+  steps: [],
+  current: 0,
+  productId: [],
+  deviceId: [],
+  productDetail: {},
+});
+
+export default DeviceModel;

+ 243 - 0
src/pages/rule-engine/Scene/Save/action/DeviceOutput/product/index.tsx

@@ -0,0 +1,243 @@
+import { ProTableCard } from '@/components';
+import SearchComponent from '@/components/SearchComponent';
+import { ProductItem } from '@/pages/device/Product/typings';
+import { useRef, useState } from 'react';
+import type { ActionType, ProColumns } from '@jetlinks/pro-table';
+import { service } from '@/pages/device/Product/index';
+import { SceneProductCard } from '@/components/ProTableCard/CardItems/product';
+import { isNoCommunity } from '@/utils/util';
+import { useIntl } from 'umi';
+import { service as categoryService } from '@/pages/device/Category';
+import { service as deptService } from '@/pages/system/Department';
+import DeviceModel from '../model';
+import { observer } from '@formily/reactive-react';
+
+export default observer(() => {
+  const actionRef = useRef<ActionType>();
+  const intl = useIntl();
+  const [searchParam, setSearchParam] = useState({});
+
+  const columns: ProColumns<ProductItem>[] = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      width: 300,
+      ellipsis: true,
+      fixed: 'left',
+    },
+    {
+      title: '名称',
+      dataIndex: 'name',
+      width: 200,
+      ellipsis: true,
+    },
+    {
+      title: '网关类型',
+      dataIndex: 'accessProvider',
+      width: 150,
+      ellipsis: true,
+      valueType: 'select',
+      hideInTable: true,
+      request: () =>
+        service.getProviders().then((resp: any) => {
+          if (isNoCommunity) {
+            return (resp?.result || []).map((item: any) => ({
+              label: item.name,
+              value: item.id,
+            }));
+          } else {
+            return (resp?.result || [])
+              .filter((i: any) =>
+                [
+                  'mqtt-server-gateway',
+                  'http-server-gateway',
+                  'mqtt-client-gateway',
+                  'tcp-server-gateway',
+                ].includes(i.id),
+              )
+              .map((item: any) => ({
+                label: item.name,
+                value: item.id,
+              }));
+          }
+        }),
+    },
+    {
+      title: '接入方式',
+      dataIndex: 'accessName',
+      width: 150,
+      ellipsis: true,
+      valueType: 'select',
+      request: () =>
+        service.queryGatewayList().then((resp: any) =>
+          resp.result.map((item: any) => ({
+            label: item.name,
+            value: item.name,
+          })),
+        ),
+    },
+    {
+      title: '设备类型',
+      dataIndex: 'deviceType',
+      valueType: 'select',
+      valueEnum: {
+        device: {
+          text: '直连设备',
+          status: 'device',
+        },
+        childrenDevice: {
+          text: '网关子设备',
+          status: 'childrenDevice',
+        },
+        gateway: {
+          text: '网关设备',
+          status: 'gateway',
+        },
+      },
+      width: 150,
+      render: (_, row) => <>{row.deviceType ? row.deviceType.text : undefined}</>,
+    },
+    {
+      title: '状态',
+      dataIndex: 'state',
+      valueType: 'select',
+      width: '90px',
+      valueEnum: {
+        0: {
+          text: intl.formatMessage({
+            id: 'pages.device.product.status.disabled',
+            defaultMessage: '禁用',
+          }),
+          status: 0,
+        },
+        1: {
+          text: intl.formatMessage({
+            id: 'pages.device.product.status.enabled',
+            defaultMessage: '正常',
+          }),
+          status: 1,
+        },
+      },
+    },
+    {
+      dataIndex: 'describe',
+      title: intl.formatMessage({
+        id: 'pages.system.description',
+        defaultMessage: '说明',
+      }),
+      ellipsis: true,
+      width: 300,
+      // hideInSearch: true,
+    },
+    {
+      dataIndex: 'classifiedId',
+      title: '分类',
+      valueType: 'treeSelect',
+      hideInTable: true,
+      fieldProps: {
+        fieldNames: {
+          label: 'name',
+          value: 'id',
+        },
+      },
+      request: () =>
+        categoryService
+          .queryTree({
+            paging: false,
+          })
+          .then((resp: any) => resp.result),
+    },
+    {
+      dataIndex: 'id$dim-assets',
+      title: '所属组织',
+      valueType: 'treeSelect',
+      hideInTable: true,
+      fieldProps: {
+        fieldNames: {
+          label: 'name',
+          value: 'value',
+        },
+      },
+      request: () =>
+        deptService
+          .queryOrgThree({
+            paging: false,
+          })
+          .then((resp) => {
+            const formatValue = (list: any[]) => {
+              const _list: any[] = [];
+              list.forEach((item) => {
+                if (item.children) {
+                  item.children = formatValue(item.children);
+                }
+                _list.push({
+                  ...item,
+                  value: JSON.stringify({
+                    assetType: 'product',
+                    targets: [
+                      {
+                        type: 'org',
+                        id: item.id,
+                      },
+                    ],
+                  }),
+                });
+              });
+              return _list;
+            };
+            return formatValue(resp.result);
+          }),
+    },
+  ];
+  return (
+    <div>
+      <SearchComponent
+        field={columns}
+        model={'simple'}
+        enableSave={false}
+        onSearch={async (data) => {
+          actionRef.current?.reset?.();
+          setSearchParam(data);
+        }}
+        target="department-assets-product"
+      />
+      <div
+        style={{
+          height: 'calc(100vh - 440px)',
+          overflowY: 'auto',
+        }}
+      >
+        <ProTableCard<ProductItem>
+          actionRef={actionRef}
+          columns={columns}
+          rowKey="id"
+          search={false}
+          gridColumn={2}
+          columnEmptyText={''}
+          onlyCard={true}
+          tableAlertRender={false}
+          rowSelection={{
+            type: 'radio',
+            selectedRowKeys: DeviceModel.productId,
+            onChange: (selectedRowKeys, selectedRows) => {
+              // console.log(selectedRowKeys,selectedRows)
+              DeviceModel.productId = selectedRows.map((item) => item.id);
+              DeviceModel.productDetail = selectedRows?.[0];
+            },
+          }}
+          request={(params) =>
+            service.query({
+              ...params,
+              sorts: [{ name: 'createTime', order: 'desc' }],
+            })
+          }
+          params={searchParam}
+          cardRender={(record) => (
+            <SceneProductCard showBindBtn={false} showTool={false} {...record} />
+          )}
+          height={'none'}
+        />
+      </div>
+    </div>
+  );
+});

+ 30 - 0
src/pages/rule-engine/Scene/Save/action/DeviceOutput/service.ts

@@ -0,0 +1,30 @@
+import BaseService from '@/utils/BaseService';
+import { request } from '@@/plugin-request/request';
+import SystemConst from '@/utils/const';
+
+class Service<T> extends BaseService<T> {
+  // 设备
+  queryDeviceList = (params: any) => {
+    return request<T>(`${SystemConst.API_BASE}/device/instance/_query`, {
+      method: 'POST',
+      data: params,
+    });
+  };
+
+  // 查询产品列表
+  getProductList = (params?: any) =>
+    request(`/${SystemConst.API_BASE}/device/product/_query/no-paging?paging=false`, {
+      method: 'GET',
+      params,
+    });
+
+  // 产品
+  queryProductList = (params: any) => {
+    return request<T>(`${SystemConst.API_BASE}/device-product/_query`, {
+      method: 'POST',
+      data: params,
+    });
+  };
+}
+
+export default Service;

+ 3 - 0
src/pages/rule-engine/Scene/Save/action/Modal/add.tsx

@@ -2,12 +2,15 @@ import { Modal, Form } from 'antd';
 import ActionsType from '@/pages/rule-engine/Scene/Save/components/TriggerWay/actionsType';
 import { useState } from 'react';
 import Notify from '../notify';
+import Device from '../DeviceOutput';
 export default () => {
   const [form] = Form.useForm();
   const [actionType, setActionType] = useState<string>('');
 
   const actionTypeComponent = (type: string) => {
     switch (type) {
+      case 'device':
+        return <Device />;
       case 'notify':
         return <Notify />;
       default: