lind 3 роки тому
батько
коміт
5ab56e05e3
63 змінених файлів з 3620 додано та 926 видалено
  1. BIN
      public/images/access-config-diaabled.png
  2. BIN
      public/images/access-config-enabled.png
  3. BIN
      public/images/device-gateway.png
  4. BIN
      public/images/device-media.png
  5. BIN
      public/images/device-product.png
  6. 23 20
      src/components/Player/ScreenPlayer.tsx
  7. 97 0
      src/components/ProTableCard/CardItems/AccessConfig/index.less
  8. 63 0
      src/components/ProTableCard/CardItems/AccessConfig/index.tsx
  9. 1 1
      src/components/ProTableCard/CardItems/cascade.tsx
  10. 57 0
      src/components/ProTableCard/CardItems/mediaDevice.tsx
  11. 1 1
      src/components/ProTableCard/CardItems/product.tsx
  12. 4 2
      src/components/ProTableCard/TableCard.tsx
  13. 9 6
      src/components/ProTableCard/index.less
  14. 5 1
      src/components/ProTableCard/index.tsx
  15. 23 0
      src/components/RadioCard/index.less
  16. 12 6
      src/components/RadioCard/index.tsx
  17. 1 1
      src/pages/Log/System/index.tsx
  18. 117 0
      src/pages/device/Instance/Detail/Config/Edit.tsx
  19. 136 150
      src/pages/device/Instance/Detail/Config/index.tsx
  20. 25 15
      src/pages/device/Instance/Detail/Info/index.tsx
  21. 1 1
      src/pages/device/Instance/Detail/Running/Property/index.tsx
  22. 69 52
      src/pages/device/Instance/Detail/Config/Tags/index.tsx
  23. 63 0
      src/pages/device/Instance/Detail/Tags/index.tsx
  24. 54 67
      src/pages/device/Instance/Detail/index.tsx
  25. 0 106
      src/pages/device/Product/Detail/Access/AccessConfig/index.less
  26. 11 49
      src/pages/device/Product/Detail/Access/AccessConfig/index.tsx
  27. 42 25
      src/pages/device/Product/Detail/Access/index.tsx
  28. 4 0
      src/pages/device/Product/index.tsx
  29. 40 72
      src/pages/link/AccessConfig/Detail/Access/index.tsx
  30. 1 1
      src/pages/link/AccessConfig/Detail/Media/index.tsx
  31. 1 0
      src/pages/link/AccessConfig/Detail/index.tsx
  32. 10 39
      src/pages/link/AccessConfig/index.less
  33. 5 46
      src/pages/link/AccessConfig/index.tsx
  34. 0 2
      src/pages/link/Protocol/index.tsx
  35. 158 0
      src/pages/media/Cascade/Channel/BindChannel/index.tsx
  36. 169 0
      src/pages/media/Cascade/Channel/index.tsx
  37. 84 0
      src/pages/media/Cascade/Publish/index.tsx
  38. 305 0
      src/pages/media/Cascade/Save/index.tsx
  39. 297 56
      src/pages/media/Cascade/index.tsx
  40. 66 0
      src/pages/media/Cascade/service.ts
  41. 1 0
      src/pages/media/Cascade/typings.d.ts
  42. 50 0
      src/pages/media/Device/Channel/Live.tsx
  43. 225 0
      src/pages/media/Device/Channel/Save.tsx
  44. 15 0
      src/pages/media/Device/Channel/Tree/index.less
  45. 34 0
      src/pages/media/Device/Channel/Tree/index.tsx
  46. 11 0
      src/pages/media/Device/Channel/index.less
  47. 258 0
      src/pages/media/Device/Channel/index.tsx
  48. 22 0
      src/pages/media/Device/Channel/service.ts
  49. 114 0
      src/pages/media/Device/Channel/typings.d.ts
  50. 6 0
      src/pages/media/Device/Playback/index.tsx
  51. 78 0
      src/pages/media/Device/Save/ProviderSelect.tsx
  52. 135 0
      src/pages/media/Device/Save/SaveProduct.tsx
  53. 325 0
      src/pages/media/Device/Save/index.tsx
  54. 12 0
      src/pages/media/Device/Save/providerSelect.less
  55. 280 115
      src/pages/media/Device/index.tsx
  56. 33 0
      src/pages/media/Device/service.ts
  57. 2 1
      src/pages/media/Device/typings.d.ts
  58. 42 87
      src/pages/media/Stream/Detail/index.tsx
  59. 1 0
      src/pages/notice/Config/typings.d.ts
  60. 13 4
      src/pages/rule-engine/Instance/index.tsx
  61. 1 0
      src/pages/system/Role/Detail/Permission/index.tsx
  62. 6 0
      src/utils/menu/index.ts
  63. 2 0
      src/utils/menu/router.ts

BIN
public/images/access-config-diaabled.png


BIN
public/images/access-config-enabled.png


BIN
public/images/device-gateway.png


BIN
public/images/device-media.png


BIN
public/images/device-product.png


+ 23 - 20
src/components/Player/ScreenPlayer.tsx

@@ -34,6 +34,7 @@ interface ScreenProps {
    * @param type 当前操作动作
    */
   onMouseUp?: (id: string, channelId: string, type: string) => void;
+  showScreen?: boolean;
 }
 
 export default (props: ScreenProps) => {
@@ -80,25 +81,27 @@ export default (props: ScreenProps) => {
     <div className={'live-player-warp'}>
       <div className={'live-player-content'}>
         <div className={'player-screen-tool'}>
-          <Radio.Group
-            options={[
-              { label: '单屏', value: 1 },
-              { label: '四分屏', value: 4 },
-              { label: '九分屏', value: 9 },
-              { label: '全屏', value: 0 },
-            ]}
-            value={screen}
-            onChange={(e) => {
-              if (e.target.value) {
-                setScreen(e.target.value);
-              } else {
-                // 全屏操作
-                setFull();
-              }
-            }}
-            optionType={'button'}
-            buttonStyle={'solid'}
-          />
+          {props.showScreen !== false && (
+            <Radio.Group
+              options={[
+                { label: '单屏', value: 1 },
+                { label: '四分屏', value: 4 },
+                { label: '九分屏', value: 9 },
+                { label: '全屏', value: 0 },
+              ]}
+              value={screen}
+              onChange={(e) => {
+                if (e.target.value) {
+                  setScreen(e.target.value);
+                } else {
+                  // 全屏操作
+                  setFull();
+                }
+              }}
+              optionType={'button'}
+              buttonStyle={'solid'}
+            />
+          )}
         </div>
         <div className={'player-body'}>
           <div className={classNames('player-screen', screenClass)} ref={fullscreenRef}>
@@ -107,7 +110,7 @@ export default (props: ScreenProps) => {
                 <div
                   key={`player_body_${index}`}
                   className={classNames({
-                    active: playerActive === index && !isFullscreen,
+                    active: props.showScreen !== false && playerActive === index && !isFullscreen,
                     'full-screen': isFullscreen,
                   })}
                   onClick={() => {

+ 97 - 0
src/components/ProTableCard/CardItems/AccessConfig/index.less

@@ -0,0 +1,97 @@
+@import '~antd/es/style/themes/default.less';
+
+.tableCardDisabled {
+  width: 100%;
+  background: url('/images/access-config-diaabled.png') no-repeat;
+  background-size: 100% 100%;
+}
+
+.tableCardEnabled {
+  width: 100%;
+  background: url('/images/access-config-enabled.png') no-repeat;
+  background-size: 100% 100%;
+}
+
+.active {
+  border: 1px solid @primary-color-active;
+}
+
+.context-access {
+  display: flex;
+  width: 100%;
+  .card {
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    margin-left: 20px;
+    .header {
+      .title {
+        width: 70%;
+        overflow: hidden;
+        font-weight: 700;
+        font-size: 18px;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+      .title::before {
+        display: none;
+      }
+      .desc {
+        width: 70%;
+        margin-top: 10px;
+        overflow: hidden;
+        color: #666;
+        font-weight: 400;
+        font-size: 12px;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+    }
+
+    .container {
+      display: flex;
+      width: 100%;
+      min-height: 50px;
+      margin-top: 10px;
+
+      .server,
+      .procotol {
+        width: calc(50% - 20px);
+        margin-right: 10px;
+        .subTitle {
+          width: 100%;
+          margin-bottom: 5px;
+          overflow: hidden;
+          color: rgba(0, 0, 0, 0.75);
+          font-size: 12px;
+          white-space: nowrap;
+          text-overflow: ellipsis;
+        }
+        .subItem {
+          width: 100%;
+          height: 20px;
+          overflow: hidden;
+          white-space: nowrap;
+          text-overflow: ellipsis;
+        }
+      }
+      .procotol {
+        .desc {
+          width: 100%;
+          overflow: hidden;
+          color: #666;
+          font-weight: 400;
+          font-size: 12px;
+          white-space: nowrap;
+          text-overflow: ellipsis;
+        }
+      }
+    }
+  }
+}
+
+:global {
+  .ant-pagination-item {
+    display: none;
+  }
+}

+ 63 - 0
src/components/ProTableCard/CardItems/AccessConfig/index.tsx

@@ -0,0 +1,63 @@
+import React from 'react';
+import { StatusColorEnum } from '@/components/BadgeStatus';
+import { TableCard } from '@/components';
+import '@/style/common.less';
+import { Badge, Tooltip } from 'antd';
+import type { AccessItem } from '@/pages/link/AccessConfig/typings';
+import './index.less';
+
+export interface AccessConfigCardProps extends AccessItem {
+  detail?: React.ReactNode;
+  actions?: React.ReactNode[];
+  avatarSize?: number;
+  showTool?: boolean;
+  activeStyle?: string;
+}
+
+const defaultImage = require('/public/images/device-access.png');
+
+export default (props: AccessConfigCardProps) => {
+  return (
+    <TableCard
+      showMask={false}
+      actions={props.actions}
+      status={props.state.value}
+      statusText={props.state.text}
+      statusNames={{
+        enabled: StatusColorEnum.processing,
+        disabled: StatusColorEnum.error,
+      }}
+      showTool={props.showTool}
+      contentClassName={props.state.value === 'disabled' ? 'tableCardDisabled' : 'tableCardEnabled'}
+      className={props.activeStyle}
+    >
+      <div className="context-access">
+        <div>
+          <img width={88} height={88} src={defaultImage} alt={''} />
+        </div>
+        <div className="card">
+          <div className="header">
+            <div className="title">
+              <Tooltip title={props.name}>{props.name || '--'}</Tooltip>
+            </div>
+            <div className="desc">{props.description || '--'}</div>
+          </div>
+          <div className="container">
+            <div className="server">
+              <div className="subTitle">{props?.channelInfo?.name || '--'}</div>
+              {props.channelInfo?.addresses.slice(0, 2).map((i: any, index: number) => (
+                <div className="subItem" key={i.address + `_address${index}`}>
+                  <Badge color={i.health === -1 ? 'red' : 'green'} text={i.address} />
+                </div>
+              ))}
+            </div>
+            <div className="procotol">
+              <div className="subTitle">{props?.protocolDetail?.name || '--'}</div>
+              <div className="desc">{props.protocolDetail?.description || '--'}</div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </TableCard>
+  );
+};

+ 1 - 1
src/components/ProTableCard/CardItems/cascade.tsx

@@ -34,7 +34,7 @@ export default (props: CascadeCardProps) => {
           <div className={'card-item-header'}>
             <span className={'card-item-header-name ellipsis'}>{props.name}</span>
           </div>
-          <div>通道数量: 5</div>
+          <div>通道数量: {props?.count || 0}</div>
           <div>
             <Badge
               status={props.onlineStatus?.value === 'offline' ? 'error' : 'success'}

+ 57 - 0
src/components/ProTableCard/CardItems/mediaDevice.tsx

@@ -0,0 +1,57 @@
+import React from 'react';
+import type { DeviceItem } from '@/pages/media/Device/typings';
+import { StatusColorEnum } from '@/components/BadgeStatus';
+import { TableCard } from '@/components';
+import '@/style/common.less';
+import '../index.less';
+
+export interface ProductCardProps extends DeviceItem {
+  detail?: React.ReactNode;
+  actions?: React.ReactNode[];
+}
+const defaultImage = require('/public/images/device-media.png');
+
+export default (props: ProductCardProps) => {
+  return (
+    <TableCard
+      showMask={false}
+      detail={props.detail}
+      actions={props.actions}
+      status={props.state.value}
+      statusText={props.state.text}
+      statusNames={{
+        offline: StatusColorEnum.error,
+        online: StatusColorEnum.processing,
+      }}
+    >
+      <div className={'pro-table-card-item'}>
+        <div className={'card-item-avatar'}>
+          <img width={88} height={88} src={props.photoUrl || defaultImage} alt={''} />
+        </div>
+        <div className={'card-item-body'}>
+          <div className={'card-item-header'}>
+            <span className={'card-item-header-name ellipsis'}>{props.name}</span>
+          </div>
+          <div className={'card-item-content'}>
+            <div>
+              <label>厂商</label>
+              <div className={'ellipsis'}>{props.manufacturer || '--'}</div>
+            </div>
+            <div>
+              <label>通道数量</label>
+              <div className={'ellipsis'}>{props.channelNumber || '--'}</div>
+            </div>
+            <div>
+              <label>型号</label>
+              <div className={'ellipsis'}>{props.model || '--'}</div>
+            </div>
+            <div>
+              <label>接入方式</label>
+              <div className={'ellipsis'}>{props.transport || '--'}</div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </TableCard>
+  );
+};

+ 1 - 1
src/components/ProTableCard/CardItems/product.tsx

@@ -11,7 +11,7 @@ export interface ProductCardProps extends ProductItem {
   actions?: React.ReactNode[];
   avatarSize?: number;
 }
-const defaultImage = require('/public/images/device-type-3-big.png');
+const defaultImage = require('/public/images/device-product.png');
 
 export default (props: ProductCardProps) => {
   const intl = useIntl();

+ 4 - 2
src/components/ProTableCard/TableCard.tsx

@@ -1,7 +1,8 @@
 import React, { useState } from 'react';
 import classNames from 'classnames';
 import { BadgeStatus } from '@/components';
-import { StatusColorEnum, StatusColorType } from '@/components/BadgeStatus';
+import { StatusColorEnum } from '@/components/BadgeStatus';
+import type { StatusColorType } from '@/components/BadgeStatus';
 import './index.less';
 
 export interface TableCardProps {
@@ -15,6 +16,7 @@ export interface TableCardProps {
   statusNames?: Record<string | number, StatusColorType>;
   children?: React.ReactNode;
   actions?: React.ReactNode[];
+  contentClassName?: string;
 }
 
 function getAction(actions: React.ReactNode[]) {
@@ -72,7 +74,7 @@ export default (props: TableCardProps) => {
     <div className={classNames('iot-card', { hover: maskShow }, props.className)}>
       <div className={'card-warp'}>
         <div
-          className={'card-content'}
+          className={classNames('card-content', props.contentClassName)}
           onMouseEnter={() => {
             setMaskShow(true);
           }}

+ 9 - 6
src/components/ProTableCard/index.less

@@ -24,7 +24,6 @@
   .pro-table-card-items {
     display: grid;
     grid-gap: 26px;
-    grid-template-columns: repeat(4, 1fr);
     padding-bottom: 38px;
 
     .pro-table-card-item {
@@ -42,7 +41,7 @@
 
         .card-item-header {
           display: flex;
-          width: 100%;
+          width: calc(100% - 86px);
           margin-bottom: 12px;
 
           .card-item-header-name {
@@ -54,11 +53,15 @@
 
         .card-item-content {
           display: flex;
+          flex-wrap: wrap;
 
-          > div:last-child {
-            flex-grow: 1;
-            width: 0;
-            margin-left: 12px;
+          > div {
+            width: 50%;
+
+            &:nth-child(even) {
+              width: calc(50% - 12px);
+              margin-left: 12px;
+            }
           }
 
           label {

+ 5 - 1
src/components/ProTableCard/index.tsx

@@ -19,6 +19,7 @@ type ModelType = keyof typeof ModelEnum;
 
 interface ProTableCardProps<T> {
   cardRender?: (data: T) => JSX.Element | React.ReactNode;
+  gridColumn?: number;
 }
 
 const ProTableCard = <
@@ -43,7 +44,10 @@ const ProTableCard = <
     return (
       <>
         {dataSource && dataSource.length ? (
-          <div className={'pro-table-card-items'}>
+          <div
+            className={'pro-table-card-items'}
+            style={{ gridTemplateColumns: `repeat(${props.gridColumn || 4}, 1fr)` }}
+          >
             {dataSource.map((item) =>
               cardRender && isFunction(cardRender) ? cardRender(item) : null,
             )}

+ 23 - 0
src/components/RadioCard/index.less

@@ -71,4 +71,27 @@
       }
     }
   }
+
+  &.disabled {
+    .radio-card-item {
+      color: @disabled-color;
+      border-color: @disabled-bg;
+      cursor: not-allowed;
+
+      .checked-icon {
+        background-color: @disabled-active-bg;
+      }
+
+      &:hover,
+      &:focus {
+        color: @disabled-color;
+        border-color: @disabled-active-bg;
+      }
+
+      &.checked {
+        color: @disabled-color;
+        border-color: @disabled-active-bg;
+      }
+    }
+  }
 }

+ 12 - 6
src/components/RadioCard/index.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
 import classNames from 'classnames';
 import { isArray } from 'lodash';
 import './index.less';
@@ -9,14 +9,17 @@ type RadioCardModelType = 'multiple' | 'singular';
 interface RadioCardItem {
   label: string;
   value: string;
-  imgUrl: string;
+  imgUrl?: string;
 }
 
 export interface RadioCardProps {
+  options: RadioCardItem[];
   value?: string | string[];
   model?: RadioCardModelType;
-  options: RadioCardItem[];
+  itemStyle?: React.CSSProperties;
+  className?: string;
   onChange?: (keys: string | string[]) => void;
+  disabled?: boolean;
   onSelect?: (key: string, selected: boolean, node: RadioCardItem[]) => void;
 }
 
@@ -62,19 +65,22 @@ export default (props: RadioCardProps) => {
   };
 
   return (
-    <div className={'radio-card-items'}>
+    <div className={classNames('radio-card-items', props.className, { disabled: props.disabled })}>
       {options.map((item) => {
         return (
           <div
             className={classNames('radio-card-item', {
               checked: keys?.includes(item.value),
             })}
+            style={props.itemStyle}
             key={item.value}
             onClick={() => {
-              toggleOption(item.value);
+              if (!props.disabled) {
+                toggleOption(item.value);
+              }
             }}
           >
-            <img width={32} height={32} src={item.imgUrl} alt={''} />
+            {item.imgUrl && <img width={32} height={32} src={item.imgUrl} alt={''} />}
             <span>{item.label}</span>
             <div className={'checked-icon'}>
               <div>

+ 1 - 1
src/pages/Log/System/index.tsx

@@ -57,7 +57,7 @@ const System = () => {
         id: 'pages.log.system.logContent',
         defaultMessage: '日志内容',
       }),
-      dataIndex: 'exceptionStack',
+      dataIndex: 'message',
       ellipsis: true,
     },
     {

+ 117 - 0
src/pages/device/Instance/Detail/Config/Edit.tsx

@@ -0,0 +1,117 @@
+import { createForm } from '@formily/core';
+import { createSchemaField } from '@formily/react';
+import { InstanceModel, service } from '@/pages/device/Instance';
+import type { ISchema } from '@formily/json-schema';
+import { Form, FormGrid, FormItem, Input, Password, PreviewText } from '@formily/antd';
+import { Button, Drawer, message, Space } from 'antd';
+import { useParams } from 'umi';
+
+const componentMap = {
+  string: 'Input',
+  password: 'Password',
+};
+
+interface Props {
+  close: () => void;
+  metadata: any[];
+}
+
+const Edit = (props: Props) => {
+  const { metadata } = props;
+  const params = useParams<{ id: string }>();
+  const id = InstanceModel.detail?.id || params?.id;
+
+  const form = createForm({
+    validateFirst: true,
+    initialValues: InstanceModel.detail?.configuration,
+  });
+
+  const SchemaField = createSchemaField({
+    components: {
+      FormItem,
+      Input,
+      Password,
+      FormGrid,
+      PreviewText,
+    },
+  });
+
+  const configToSchema = (data: any[]) => {
+    const config = {};
+    data.forEach((item) => {
+      config[item.property] = {
+        type: 'string',
+        title: item.name,
+        'x-decorator': 'FormItem',
+        'x-component': componentMap[item.type.type],
+        'x-decorator-props': {
+          tooltip: item.description,
+        },
+      };
+    });
+    return config;
+  };
+
+  const renderConfigCard = () => {
+    return metadata?.map((item: any) => {
+      const itemSchema: ISchema = {
+        type: 'object',
+        properties: {
+          grid: {
+            type: 'void',
+            'x-component': 'FormGrid',
+            'x-component-props': {
+              minColumns: [1],
+              maxColumns: [1],
+            },
+            properties: configToSchema(item.properties),
+          },
+        },
+      };
+
+      return (
+        <>
+          <PreviewText.Placeholder value="-">
+            <Form form={form} layout="vertical">
+              <SchemaField schema={itemSchema} />
+            </Form>
+          </PreviewText.Placeholder>
+        </>
+      );
+    });
+  };
+  return (
+    <Drawer
+      title="编辑配置"
+      placement="right"
+      onClose={() => {
+        props.close();
+      }}
+      visible
+      extra={
+        <Space>
+          <Button
+            type="primary"
+            onClick={async () => {
+              const values = (await form.submit()) as any;
+              const resp = await service.modify(id || '', {
+                id,
+                configuration: { ...values },
+              });
+              if (resp.status === 200) {
+                message.success('操作成功!');
+                props.close();
+              }
+            }}
+          >
+            保存
+          </Button>
+        </Space>
+      }
+    >
+      {renderConfigCard()}
+    </Drawer>
+  );
+};
+
+export default Edit;

+ 136 - 150
src/pages/device/Instance/Detail/Config/index.tsx

@@ -1,19 +1,15 @@
-import { Card, Divider, Empty, message, Popconfirm, Space, Tooltip } from 'antd';
+import { Button, Descriptions, message, Popconfirm, Space, Tooltip } from 'antd';
 import { InstanceModel, service } from '@/pages/device/Instance';
 import { useEffect, useState } from 'react';
-import { createSchemaField } from '@formily/react';
-import type { ConfigMetadata, ConfigProperty } from '@/pages/device/Product/typings';
-import type { ISchema } from '@formily/json-schema';
-import { Form, FormGrid, FormItem, FormLayout, Input, Password, PreviewText } from '@formily/antd';
-import { createForm } from '@formily/core';
+import type { ConfigMetadata } from '@/pages/device/Product/typings';
 import { history, useParams } from 'umi';
-import Tags from '@/pages/device/Instance/Detail/Config/Tags';
-import Icon from '@ant-design/icons';
-
-const componentMap = {
-  string: 'Input',
-  password: 'Password',
-};
+import {
+  CheckOutlined,
+  EditOutlined,
+  QuestionCircleOutlined,
+  UndoOutlined,
+} from '@ant-design/icons';
+import Edit from './Edit';
 
 const Config = () => {
   const params = useParams<{ id: string }>();
@@ -29,13 +25,7 @@ const Config = () => {
   }, []);
 
   const [metadata, setMetadata] = useState<ConfigMetadata[]>([]);
-  const [state, setState] = useState<boolean>(false);
-
-  const form = createForm({
-    validateFirst: true,
-    readPretty: state,
-    initialValues: InstanceModel.detail?.configuration,
-  });
+  const [visible, setVisible] = useState<boolean>(false);
 
   const id = InstanceModel.detail?.id || params?.id;
 
@@ -52,146 +42,142 @@ const Config = () => {
       service.getConfigMetadata(id).then((config) => {
         setMetadata(config?.result);
       });
-      setState(
-        !!(
-          InstanceModel.detail?.configuration &&
-          Object.keys(InstanceModel.detail?.configuration).length > 0
-        ),
-      );
     }
-
-    return () => {};
   }, [id]);
 
-  const SchemaField = createSchemaField({
-    components: {
-      FormItem,
-      Input,
-      Password,
-      FormGrid,
-      PreviewText,
-    },
-  });
-
-  const configToSchema = (data: ConfigProperty[]) => {
-    const config = {};
-    data.forEach((item) => {
-      config[item.property] = {
-        type: 'string',
-        title: item.name,
-        'x-decorator': 'FormItem',
-        'x-component': componentMap[item.type.type],
-        'x-decorator-props': {
-          tooltip: item.description,
-        },
-      };
-    });
-    return config;
+  const isExit = (property: string) => {
+    return (
+      InstanceModel.detail?.cachedConfiguration &&
+      InstanceModel.detail?.cachedConfiguration[property] !== undefined &&
+      InstanceModel.detail?.configuration &&
+      InstanceModel.detail?.configuration[property] !==
+        InstanceModel.detail?.cachedConfiguration[property]
+    );
   };
 
-  const renderConfigCard = () => {
-    return metadata ? (
-      metadata?.map((item) => {
-        const itemSchema: ISchema = {
-          type: 'object',
-          properties: {
-            grid: {
-              type: 'void',
-              'x-component': 'FormGrid',
-              'x-component-props': {
-                minColumns: [2],
-                maxColumns: [2],
-              },
-              properties: configToSchema(item.properties),
-            },
-          },
-        };
-
+  const renderComponent = (item: any) => {
+    if (InstanceModel.detail?.configuration) {
+      const config = InstanceModel.detail?.configuration;
+      if (item.type.type === 'password' && config[item.property]?.length > 0) {
+        return '••••••';
+      }
+      if (isExit(item.property)) {
         return (
-          <>
-            <Divider />
-            <Card
-              title={item.name}
-              extra={
-                <Space>
-                  <a
-                    onClick={async () => {
-                      if (!state) {
-                        const values = (await form.submit()) as any;
-                        const resp = await service.modify(id || '', {
-                          id,
-                          configuration: { ...values },
-                        });
-                        if (resp.status === 200) {
-                          getDetail();
-                        }
-                      }
-                      setState(!state);
-                    }}
-                  >
-                    {state ? '编辑' : '保存'}
-                  </a>
-                  {InstanceModel.detail.state?.value !== 'notActive' && (
-                    <Popconfirm
-                      title="确认重新应用该配置?"
-                      onConfirm={async () => {
-                        const resp = await service.deployDevice(id || '');
-                        if (resp.status === 200) {
-                          message.success('操作成功');
-                          getDetail();
-                        }
-                      }}
-                    >
-                      <a>应用配置</a>
-                      <Tooltip title="修改配置后需重新应用后才能生效。">
-                        <Icon type="question-circle-o" />
-                      </Tooltip>
-                    </Popconfirm>
-                  )}
-                  {InstanceModel.detail?.aloneConfiguration && (
-                    <Popconfirm
-                      title="确认恢复默认配置?"
-                      onConfirm={async () => {
-                        const resp = await service.configurationReset(id || '');
-                        if (resp.status === 200) {
-                          message.success('恢复默认配置成功');
-                          getDetail();
-                        }
-                      }}
-                    >
-                      <a>恢复默认</a>
-                      <Tooltip
-                        title={`该设备单独编辑过配置信息,点击此将恢复成默认的配置信息,请谨慎操作。`}
-                      >
-                        <Icon type="question-circle-o" />
-                      </Tooltip>
-                    </Popconfirm>
-                  )}
-                </Space>
-              }
-            >
-              <PreviewText.Placeholder value="-">
-                <Form form={form}>
-                  <FormLayout labelCol={6} wrapperCol={16}>
-                    <SchemaField schema={itemSchema} />
-                  </FormLayout>
-                </Form>
-              </PreviewText.Placeholder>
-            </Card>
-          </>
+          <div>
+            <span style={{ marginRight: '10px' }}>{config[item.property]}</span>
+            <Tooltip title={`有效值:${config[item.property]}`}>
+              <QuestionCircleOutlined />
+            </Tooltip>
+          </div>
         );
-      })
-    ) : (
-      <Empty />
-    );
+      } else {
+        return <span>{config[item.property]}</span>;
+      }
+    } else {
+      return '--';
+    }
   };
 
   return (
-    <>
-      {renderConfigCard()}
-      <Divider />
-      <Tags />
-    </>
+    <div style={{ width: '100%', marginTop: '20px' }} className="config">
+      <Descriptions
+        layout="vertical"
+        title={[
+          <span key={1}>配置</span>,
+          <Space key={2}>
+            <Button
+              type="link"
+              onClick={async () => {
+                setVisible(true);
+              }}
+            >
+              <EditOutlined />
+              编辑
+            </Button>
+            {InstanceModel.detail.state?.value !== 'notActive' && (
+              <Popconfirm
+                title="确认重新应用该配置?"
+                onConfirm={async () => {
+                  const resp = await service.deployDevice(id || '');
+                  if (resp.status === 200) {
+                    message.success('操作成功');
+                    getDetail();
+                  }
+                }}
+              >
+                <Button type="link">
+                  <CheckOutlined />
+                  应用配置
+                </Button>
+                <Tooltip title="修改配置后需重新应用后才能生效。">
+                  <QuestionCircleOutlined />
+                </Tooltip>
+              </Popconfirm>
+            )}
+            {InstanceModel.detail?.aloneConfiguration && (
+              <Popconfirm
+                title="确认恢复默认配置?"
+                onConfirm={async () => {
+                  const resp = await service.configurationReset(id || '');
+                  if (resp.status === 200) {
+                    message.success('恢复默认配置成功');
+                    getDetail();
+                  }
+                }}
+              >
+                <Button type="link">
+                  <UndoOutlined />
+                  恢复默认
+                </Button>
+                <Tooltip
+                  title={`该设备单独编辑过配置信息,点击此将恢复成默认的配置信息,请谨慎操作。`}
+                >
+                  <QuestionCircleOutlined />
+                </Tooltip>
+              </Popconfirm>
+            )}
+          </Space>,
+        ]}
+      >
+        {(metadata || []).map((i) => (
+          <Descriptions.Item key={i.name} label={<h4>{i.name}</h4>} span={3}>
+            <div style={{ width: '100%' }}>
+              <Descriptions column={2} bordered size="small">
+                {(i?.properties || []).map((item: any) => (
+                  <Descriptions.Item
+                    span={1}
+                    label={
+                      item.description ? (
+                        <div>
+                          <span style={{ marginRight: '10px' }}>{item.name}</span>
+                          <Tooltip title={item.description}>
+                            <QuestionCircleOutlined />
+                          </Tooltip>
+                        </div>
+                      ) : (
+                        item.name
+                      )
+                    }
+                    key={item.property}
+                  >
+                    {renderComponent(item)}
+                  </Descriptions.Item>
+                ))}
+              </Descriptions>
+            </div>
+          </Descriptions.Item>
+        ))}
+      </Descriptions>
+      {visible && (
+        <Edit
+          metadata={metadata || []}
+          close={() => {
+            setVisible(false);
+            getDetail();
+          }}
+        />
+      )}
+    </div>
   );
 };
 

+ 25 - 15
src/pages/device/Instance/Detail/Info/index.tsx

@@ -1,4 +1,4 @@
-import { Card, Descriptions } from 'antd';
+import { Button, Card, Descriptions } from 'antd';
 import { InstanceModel } from '@/pages/device/Instance';
 import moment from 'moment';
 import { observer } from '@formily/react';
@@ -7,6 +7,8 @@ import Config from '@/pages/device/Instance/Detail/Config';
 import Save from '../../Save';
 import { useState } from 'react';
 import type { DeviceInstance } from '../../typings';
+import { EditOutlined } from '@ant-design/icons';
+import Tags from '@/pages/device/Instance/Detail/Tags';
 
 const Info = observer(() => {
   const intl = useIntl();
@@ -14,19 +16,25 @@ const Info = observer(() => {
 
   return (
     <>
-      <Card
-        title={'设备信息'}
-        extra={
-          <a
-            onClick={() => {
-              setVisible(true);
-            }}
-          >
-            编辑
-          </a>
-        }
-      >
-        <Descriptions size="small" column={3} bordered>
+      <Card>
+        <Descriptions
+          size="small"
+          column={3}
+          bordered
+          title={[
+            <span key={1}>设备信息</span>,
+            <Button
+              key={2}
+              type={'link'}
+              onClick={() => {
+                setVisible(true);
+              }}
+            >
+              <EditOutlined />
+              编辑
+            </Button>,
+          ]}
+        >
           <Descriptions.Item
             label={intl.formatMessage({
               id: 'pages.table.deviceId',
@@ -102,8 +110,10 @@ const Info = observer(() => {
             {InstanceModel.detail?.description}
           </Descriptions.Item>
         </Descriptions>
+        {InstanceModel.detail?.configuration &&
+          Object.keys(InstanceModel.detail?.configuration).length > 0 && <Config />}
+        {InstanceModel.detail?.tags && InstanceModel.detail?.tags.length > 0 && <Tags />}
       </Card>
-      <Config />
       <Save
         model={'edit'}
         data={{ ...InstanceModel?.detail, describe: InstanceModel?.detail?.description || '' }}

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

@@ -125,7 +125,7 @@ const Property = (props: Props) => {
       ?.pipe(map((res) => res.payload))
       .subscribe((payload: any) => {
         const { value } = payload;
-        propertyValue[value.property] = value;
+        propertyValue[value.property] = { ...payload, ...value };
         setPropertyValue({ ...propertyValue });
       });
   };

+ 69 - 52
src/pages/device/Instance/Detail/Config/Tags/index.tsx

@@ -1,31 +1,18 @@
-import { createSchemaField, FormProvider } from '@formily/react';
-import { Editable, FormItem, Input, ArrayTable } from '@formily/antd';
 import { createForm } from '@formily/core';
-import { Card, message } from 'antd';
-import { useIntl } from '@@/plugin-locale/localeExports';
+import { createSchemaField, FormProvider } from '@formily/react';
 import { InstanceModel, service } from '@/pages/device/Instance';
-import { useEffect, useState } from 'react';
+import { ArrayTable, FormItem, Input } from '@formily/antd';
+import { message, Modal } from 'antd';
+import { useIntl } from 'umi';
 
-const SchemaField = createSchemaField({
-  components: {
-    FormItem,
-    Editable,
-    Input,
-    ArrayTable,
-  },
-});
+interface Props {
+  close: () => void;
+  tags: any[];
+}
 
-const Tags = () => {
+const Edit = (props: Props) => {
+  const { tags } = props;
   const intl = useIntl();
-  const [tags, setTags] = useState<any[]>([]);
-
-  const tag = InstanceModel.detail?.tags;
-
-  useEffect(() => {
-    if (tag) {
-      setTags([...tag] || []);
-    }
-  }, [tag]);
 
   const form = createForm({
     initialValues: {
@@ -33,6 +20,14 @@ const Tags = () => {
     },
   });
 
+  const SchemaField = createSchemaField({
+    components: {
+      FormItem,
+      Input,
+      ArrayTable,
+    },
+  });
+
   const schema = {
     type: 'object',
     properties: {
@@ -56,7 +51,7 @@ const Tags = () => {
                   type: 'string',
                   'x-decorator': 'FormItem',
                   'x-component': 'Input',
-                  'x-disabled': true,
+                  // 'x-disabled': true
                 },
               },
             },
@@ -73,7 +68,7 @@ const Tags = () => {
               properties: {
                 name: {
                   type: 'string',
-                  'x-decorator': 'Editable',
+                  'x-decorator': 'FormItem',
                   'x-component': 'Input',
                 },
               },
@@ -91,47 +86,69 @@ const Tags = () => {
               properties: {
                 value: {
                   type: 'string',
-                  'x-decorator': 'Editable',
+                  'x-decorator': 'FormItem',
                   'x-component': 'Input',
                 },
               },
             },
+            column4: {
+              type: 'void',
+              'x-component': 'ArrayTable.Column',
+              'x-component-props': {
+                width: 100,
+                title: '操作',
+                dataIndex: 'operations',
+              },
+              properties: {
+                item: {
+                  type: 'void',
+                  'x-component': 'FormItem',
+                  properties: {
+                    remove: {
+                      type: 'void',
+                      'x-component': 'ArrayTable.Remove',
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+        properties: {
+          add: {
+            type: 'void',
+            'x-component': 'ArrayTable.Addition',
+            title: '添加',
           },
         },
       },
     },
   };
+
   return (
-    <Card
-      title={intl.formatMessage({
-        id: 'pages.device.instanceDetail.tags',
-        defaultMessage: '标签',
-      })}
-      extra={
-        <a
-          onClick={async () => {
-            const values = (await form.submit()) as any;
-            if (values?.tags) {
-              const resp = await service.saveTags(InstanceModel.detail?.id || '', values.tags);
-              if (resp.status === 200) {
-                InstanceModel.detail = { ...InstanceModel.detail, tags: values.tags };
-                message.success('操作成功!');
-              }
-            }
-          }}
-        >
-          {intl.formatMessage({
-            id: 'pages.device.instanceDetail.save',
-            defaultMessage: '保存',
-          })}
-        </a>
-      }
+    <Modal
+      title="编辑标签"
+      onCancel={() => {
+        props.close();
+      }}
+      visible
+      width={1000}
+      onOk={async () => {
+        const values: any = (await form.submit()) as any;
+        const list = (values?.tags || []).filter((item: any) => item?.id);
+        const resp = await service.saveTags(InstanceModel.detail?.id || '', list);
+        if (resp.status === 200) {
+          InstanceModel.detail = { ...InstanceModel.detail, tags: values.tags };
+          message.success('操作成功!');
+          props.close();
+        }
+      }}
     >
       <FormProvider form={form}>
         <SchemaField schema={schema} />
       </FormProvider>
-    </Card>
+    </Modal>
   );
 };
 
-export default Tags;
+export default Edit;

+ 63 - 0
src/pages/device/Instance/Detail/Tags/index.tsx

@@ -0,0 +1,63 @@
+import { Button, Descriptions } from 'antd';
+import { useIntl } from '@@/plugin-locale/localeExports';
+import { InstanceModel } from '@/pages/device/Instance';
+import { useEffect, useState } from 'react';
+import { EditOutlined } from '@ant-design/icons';
+import Edit from './Edit';
+
+const Tags = () => {
+  const intl = useIntl();
+  const [tags, setTags] = useState<any[]>([]);
+  const [visible, setVisible] = useState<boolean>(false);
+
+  const tag = InstanceModel.detail?.tags;
+
+  useEffect(() => {
+    if (tag) {
+      setTags([...tag] || []);
+    }
+  }, [tag]);
+
+  return (
+    <div style={{ width: '100%', marginTop: '20px' }}>
+      <Descriptions
+        style={{ marginBottom: 20 }}
+        bordered
+        size="small"
+        title={
+          <span>
+            {intl.formatMessage({
+              id: 'pages.device.instanceDetail.tags',
+              defaultMessage: '标签',
+            })}
+            <Button
+              type="link"
+              onClick={() => {
+                setVisible(true);
+              }}
+            >
+              <EditOutlined />
+              编辑
+            </Button>
+          </span>
+        }
+      >
+        {(tags || [])?.map((item: any) => (
+          <Descriptions.Item label={`${item.name}(${item.key})`} key={item.key}>
+            {item.value || '--'}
+          </Descriptions.Item>
+        ))}
+      </Descriptions>
+      {visible && (
+        <Edit
+          close={() => {
+            setVisible(false);
+          }}
+          tags={tags}
+        />
+      )}
+    </div>
+  );
+};
+
+export default Tags;

+ 54 - 67
src/pages/device/Instance/Detail/index.tsx

@@ -2,7 +2,7 @@ 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 { useEffect, useState } from 'react';
+import { ReactNode, useEffect, useState } from 'react';
 import { observer } from '@formily/react';
 import Log from '@/pages/device/Instance/Detail/Log';
 // import Alarm from '@/pages/device/components/Alarm';
@@ -27,47 +27,8 @@ deviceStatus.set('notActive', <Badge status="processing" text={'未启用'} />);
 const InstanceDetail = observer(() => {
   const intl = useIntl();
   const [tab, setTab] = useState<string>('detail');
-  const getDetail = (id: string) => {
-    service.detail(id).then((response) => {
-      InstanceModel.detail = response?.result;
-      // 写入物模型数据
-      const metadata: DeviceMetadata = JSON.parse(response.result?.metadata || '{}');
-      MetadataAction.insert(metadata);
-    });
-  };
   const params = useParams<{ id: string }>();
 
-  const [subscribeTopic] = useSendWebsocketMessage();
-
-  useEffect(() => {
-    if (subscribeTopic) {
-      subscribeTopic(
-        `instance-editor-info-status-${params.id}`,
-        `/dashboard/device/status/change/realTime`,
-        {
-          deviceId: params.id,
-        },
-        // @ts-ignore
-      ).subscribe((data: any) => {
-        const payload = data.payload;
-        const state = payload.value.type;
-        InstanceModel.detail.state = {
-          value: state,
-          text: '',
-        };
-      });
-    }
-  }, []);
-
-  useEffect(() => {
-    Store.subscribe(SystemConst.REFRESH_DEVICE, () => {
-      MetadataAction.clean();
-      setTimeout(() => {
-        getDetail(params.id);
-      }, 200);
-    });
-    // return subscription.unsubscribe();
-  }, []);
   const resetMetadata = async () => {
     const resp = await service.deleteMetadata(params.id);
     if (resp.status === 200) {
@@ -78,7 +39,7 @@ const InstanceDetail = observer(() => {
       }, 400);
     }
   };
-  const list = [
+  const baseList = [
     {
       key: 'detail',
       tab: intl.formatMessage({
@@ -131,33 +92,59 @@ const InstanceDetail = observer(() => {
       }),
       component: <Log />,
     },
-    // 产品类型为网关的情况下才显示此模块
-    {
-      key: 'child-device',
-      tab: '子设备',
-      component: <ChildDevice />,
-    },
-    // {
-    //   key: 'alarm',
-    //   tab: intl.formatMessage({
-    //     id: 'pages.device.instanceDetail.alarm',
-    //     defaultMessage: '告警设置',
-    //   }),
-    //   component: (
-    //     <Card>
-    //       <Alarm type="device" />
-    //     </Card>
-    //   ),
-    // },
-    // {
-    //   key: 'visualization',
-    //   tab: intl.formatMessage({
-    //     id: 'pages.device.instanceDetail.visualization',
-    //     defaultMessage: '可视化',
-    //   }),
-    //   component: <div>开发中...</div>,
-    // },
   ];
+  const [list, setList] = useState<{ key: string; tab: string; component: ReactNode }[]>(baseList);
+
+  const getDetail = (id: string) => {
+    service.detail(id).then((response) => {
+      InstanceModel.detail = response?.result;
+      const datalist = [...baseList];
+      if (response.result.deviceType?.value === 'gateway') {
+        // 产品类型为网关的情况下才显示此模块
+        datalist.push({
+          key: 'child-device',
+          tab: '子设备',
+          component: <ChildDevice />,
+        });
+      }
+      setList(datalist);
+      // 写入物模型数据
+      const metadata: DeviceMetadata = JSON.parse(response.result?.metadata || '{}');
+      MetadataAction.insert(metadata);
+    });
+  };
+
+  const [subscribeTopic] = useSendWebsocketMessage();
+
+  useEffect(() => {
+    if (subscribeTopic) {
+      subscribeTopic(
+        `instance-editor-info-status-${params.id}`,
+        `/dashboard/device/status/change/realTime`,
+        {
+          deviceId: params.id,
+        },
+        // @ts-ignore
+      ).subscribe((data: any) => {
+        const payload = data.payload;
+        const state = payload.value.type;
+        InstanceModel.detail.state = {
+          value: state,
+          text: '',
+        };
+      });
+    }
+  }, []);
+
+  useEffect(() => {
+    Store.subscribe(SystemConst.REFRESH_DEVICE, () => {
+      MetadataAction.clean();
+      setTimeout(() => {
+        getDetail(params.id);
+      }, 200);
+    });
+    // return subscription.unsubscribe();
+  }, []);
 
   useEffect(() => {
     if (!InstanceModel.current && !params.id) {

+ 0 - 106
src/pages/device/Product/Detail/Access/AccessConfig/index.less

@@ -1,109 +1,3 @@
-// .box {
-//   display: flex;
-//   justify-content: space-between;
-// }
-
-// .images {
-//   width: 64px;
-//   height: 64px;
-//   color: white;
-//   font-size: 18px;
-//   line-height: 64px;
-//   text-align: center;
-//   background: linear-gradient(
-//     128.453709216706deg,
-//     rgba(255, 255, 255, 1) 4%,
-//     rgba(113, 187, 255, 1) 43%,
-//     rgba(24, 144, 255, 1) 100%
-//   );
-//   border: 1px solid rgba(242, 242, 242, 1);
-//   border-radius: 50%;
-// }
-
-// .content {
-//   display: flex;
-//   flex-direction: column;
-//   width: calc(100% - 80px);
-// }
-
-// .top {
-//   display: flex;
-// }
-
-// .desc {
-//   width: 100%;
-//   margin-top: 10px;
-//   overflow: hidden;
-//   color: rgba(0, 0, 0, 0.55);
-//   font-weight: 400;
-//   font-size: 13px;
-//   white-space: nowrap;
-//   text-overflow: ellipsis;
-// }
-
-.context {
-  display: flex;
-  width: 100%;
-
-  .card {
-    display: flex;
-    flex-direction: column;
-    width: 100%;
-    margin-left: 10px;
-
-    .header {
-      .title {
-        width: 90%;
-        overflow: hidden;
-        font-weight: 700;
-        font-size: 18px;
-        white-space: nowrap;
-        text-overflow: ellipsis;
-      }
-
-      .desc {
-        width: 100%;
-        margin-top: 10px;
-        overflow: hidden;
-        color: #666;
-        font-weight: 400;
-        font-size: 12px;
-        white-space: nowrap;
-        text-overflow: ellipsis;
-      }
-    }
-
-    .container {
-      display: flex;
-      width: 100%;
-      height: 80px;
-      margin-top: 10px;
-
-      .server,
-      .procotol {
-        width: calc(50% - 20px);
-        margin-right: 10px;
-
-        .subTitle {
-          width: 100%;
-          overflow: hidden;
-          color: rgba(0, 0, 0, 0.75);
-          font-size: 12px;
-          white-space: nowrap;
-          text-overflow: ellipsis;
-        }
-
-        p {
-          width: 100%;
-          overflow: hidden;
-          white-space: nowrap;
-          text-overflow: ellipsis;
-        }
-      }
-    }
-  }
-}
-
 :global {
   .ant-pagination-item {
     display: none;

+ 11 - 49
src/pages/device/Product/Detail/Access/AccessConfig/index.tsx

@@ -1,15 +1,13 @@
 import { useEffect, useState } from 'react';
-import { Badge, Button, Col, message, Modal, Pagination, Row } from 'antd';
+import { Button, Col, message, Modal, Pagination, Row } from 'antd';
 import { service } from '@/pages/link/AccessConfig';
 import { productModel } from '@/pages/device/Product';
 import SearchComponent from '@/components/SearchComponent';
 import type { ProColumns } from '@jetlinks/pro-table';
 import styles from './index.less';
 import Service from '@/pages/device/Product/service';
-import { TableCard } from '@/components';
-import { StatusColorEnum } from '@/components/BadgeStatus';
 
-const defaultImage = require('/public/images/device-access.png');
+import AccessConfigCard from '@/components/ProTableCard/CardItems/AccessConfig';
 
 interface Props {
   close: () => void;
@@ -28,7 +26,7 @@ const AccessConfig = (props: Props) => {
   });
   const [param, setParam] = useState<any>({ pageSize: 4 });
 
-  const [currrent] = useState<any>({
+  const [currrent, setCurrrent] = useState<any>({
     id: productModel.current?.accessId,
     name: productModel.current?.accessName,
     protocol: productModel.current?.messageProtocol,
@@ -145,51 +143,15 @@ const AccessConfig = (props: Props) => {
           <Col
             key={item.name}
             span={12}
-            // style={{
-            //   width: '100%',
-            //   borderColor: currrent?.id === item.id ? 'var(--ant-primary-color-active)' : ''
-            // }}
-            // onClick={() => {
-            //   setCurrrent(item);
-            // }}
+            onClick={() => {
+              setCurrrent(item);
+            }}
           >
-            <TableCard
-              showMask={false}
-              status={item.state.value}
-              statusText={item.state.text}
-              statusNames={{
-                enabled: StatusColorEnum.processing,
-                disabled: StatusColorEnum.error,
-              }}
-            >
-              <div className={styles.context}>
-                <div>
-                  <img width={88} height={88} src={defaultImage} alt={''} />
-                </div>
-                <div className={styles.card}>
-                  <div className={styles.header}>
-                    <div className={styles.title}>{item.name || '--'}</div>
-                    <div className={styles.desc}>{item.description || '--'}</div>
-                  </div>
-                  <div className={styles.container}>
-                    <div className={styles.server}>
-                      <div className={styles.subTitle}>{item?.channelInfo?.name || '--'}</div>
-                      <p>
-                        {item.channelInfo?.addresses.map((i: any) => (
-                          <div key={i.address}>
-                            <Badge color={i.health === -1 ? 'red' : 'green'} text={i.address} />
-                          </div>
-                        ))}
-                      </p>
-                    </div>
-                    <div className={styles.procotol}>
-                      <div className={styles.subTitle}>{item?.protocolDetail?.name || '--'}</div>
-                      <p>{item.protocolDetail?.description || '--'}</p>
-                    </div>
-                  </div>
-                </div>
-              </div>
-            </TableCard>
+            <AccessConfigCard
+              {...item}
+              showTool={false}
+              activeStyle={currrent?.id === item.id ? 'active' : ''}
+            />
           </Col>
         ))}
       </Row>

+ 42 - 25
src/pages/device/Product/Detail/Access/index.tsx

@@ -12,6 +12,7 @@ import type { ConfigProperty } from '@/pages/device/Product/typings';
 import { createSchemaField } from '@formily/react';
 import { createForm } from '@formily/core';
 import { QuestionCircleOutlined } from '@ant-design/icons';
+import TitleComponent from '@/components/TitleComponent';
 
 const componentMap = {
   string: 'Input',
@@ -33,6 +34,8 @@ const Access = () => {
   MetworkTypeMapping.set('mqtt-client-gateway', 'MQTT_CLIENT');
   MetworkTypeMapping.set('mqtt-server-gateway', 'MQTT_SERVER');
   MetworkTypeMapping.set('tcp-server-gateway', 'TCP_SERVER');
+  MetworkTypeMapping.set('fixed-media', 'TCP_CLIENT');
+  MetworkTypeMapping.set('gb28181-2016', 'UDP');
 
   const [configVisible, setConfigVisible] = useState<boolean>(false);
 
@@ -284,6 +287,24 @@ const Access = () => {
                 minColumns: 1,
                 columnGap: 48,
               },
+              title: (
+                <TitleComponent
+                  data={
+                    <span>
+                      {item.name}
+                      <Tooltip title="此配置来自于该产品接入方式所选择的协议">
+                        <QuestionCircleOutlined />
+                      </Tooltip>
+                    </span>
+                  }
+                />
+              ),
+              'x-decorator': 'FormItem',
+              'x-decorator-props': {
+                gridSpan: 1,
+                labelAlign: 'left',
+                layout: 'vertical',
+              },
               properties: configToSchema(item.properties),
             },
           },
@@ -343,20 +364,24 @@ const Access = () => {
           <Col span={12}>
             <div className={styles.config}>
               <div className={styles.item}>
-                <div className={styles.title}>
-                  接入方式
-                  <Button
-                    size="small"
-                    type="primary"
-                    ghost
-                    style={{ marginLeft: 20 }}
-                    onClick={() => {
-                      setConfigVisible(true);
-                    }}
-                  >
-                    更换
-                  </Button>
-                </div>
+                <TitleComponent
+                  data={
+                    <span>
+                      接入方式
+                      <Button
+                        size="small"
+                        type="primary"
+                        ghost
+                        style={{ marginLeft: 20 }}
+                        onClick={() => {
+                          setConfigVisible(true);
+                        }}
+                      >
+                        更换
+                      </Button>
+                    </span>
+                  }
+                />
                 <div className={styles.context}>
                   {providers.find((i) => i.id === access?.provider)?.name || '--'}
                 </div>
@@ -370,7 +395,7 @@ const Access = () => {
               </div>
 
               <div className={styles.item}>
-                <div className={styles.title}>消息协议</div>
+                <TitleComponent data={'消息协议'} />
                 <div className={styles.context}>{access?.protocolDetail?.name || '--'}</div>
                 {config?.document && (
                   <div className={styles.context}>
@@ -380,7 +405,7 @@ const Access = () => {
               </div>
 
               <div className={styles.item}>
-                <div className={styles.title}>连接信息</div>
+                <TitleComponent data={'连接信息'} />
                 {(networkList.find((i) => i.id === access?.channelId)?.addresses || []).length > 0
                   ? (networkList.find((i) => i.id === access?.channelId)?.addresses || []).map(
                       (item: any) => (
@@ -396,15 +421,7 @@ const Access = () => {
                   : '暂无连接信息'}
               </div>
 
-              <div className={styles.item}>
-                <div className={styles.title}>
-                  认证配置
-                  <Tooltip title="此配置来自于该产品接入方式所选择的协议">
-                    <QuestionCircleOutlined />
-                  </Tooltip>
-                </div>
-                {renderConfigCard()}
-              </div>
+              <div className={styles.item}>{renderConfigCard()}</div>
             </div>
           </Col>
           <Col span={12}>

+ 4 - 0
src/pages/device/Product/index.tsx

@@ -223,6 +223,10 @@ const Product = observer(() => {
       dataIndex: 'name',
     },
     {
+      title: '接入方式',
+      dataIndex: 'transportProtocol',
+    },
+    {
       title: '设备类型',
       dataIndex: 'deviceType',
       valueType: 'select',

+ 40 - 72
src/pages/link/AccessConfig/Detail/Access/index.tsx

@@ -16,7 +16,7 @@ import { useEffect, useState } from 'react';
 import styles from './index.less';
 import { service } from '@/pages/link/AccessConfig';
 import encodeQuery from '@/utils/encodeQuery';
-import { useHistory, useLocation } from 'umi';
+import { useHistory } from 'umi';
 import ReactMarkdown from 'react-markdown';
 import { getMenuPathByCode, MENUS_CODE } from '@/utils/menu';
 import { ExclamationCircleFilled } from '@ant-design/icons';
@@ -24,12 +24,9 @@ import { ExclamationCircleFilled } from '@ant-design/icons';
 interface Props {
   change: () => void;
   data: any;
+  provider: any;
 }
 
-type LocationType = {
-  id?: string;
-};
-
 const Access = (props: Props) => {
   const [form] = Form.useForm();
 
@@ -38,11 +35,9 @@ const Access = (props: Props) => {
   const [current, setCurrent] = useState<number>(0);
   const [networkList, setNetworkList] = useState<any[]>([]);
   const [procotolList, setProcotolList] = useState<any[]>([]);
-  const [access, setAccess] = useState<any>({});
   const [procotolCurrent, setProcotolCurrent] = useState<string>('');
   const [networkCurrent, setNetworkCurrent] = useState<string>('');
   const [config, setConfig] = useState<any>();
-  const [providers, setProviders] = useState<any[]>([]);
 
   const MetworkTypeMapping = new Map();
   MetworkTypeMapping.set('websocket-server', 'WEB_SOCKET_SERVER');
@@ -78,48 +73,32 @@ const Access = (props: Props) => {
     });
   };
 
-  const queryProviders = () => {
-    service.getProviders().then((resp) => {
-      if (resp.status === 200) {
-        setProviders(resp.result);
-      }
-    });
-  };
-
   useEffect(() => {
-    if (props.data?.id) {
-      queryNetworkList(props.data?.id);
+    if (props.provider?.id) {
+      queryNetworkList(props.provider?.id);
       setCurrent(0);
     }
-  }, [props.data]);
-
-  const location = useLocation<LocationType>();
-
-  const params = new URLSearchParams(location.search);
+  }, [props.provider]);
 
   useEffect(() => {
-    if (params.get('id')) {
-      service.detail(params.get('id') || '').then((resp) => {
-        setAccess(resp.result);
-        setProcotolCurrent(resp.result?.protocol);
-        setNetworkCurrent(resp.result?.channelId);
-        form.setFieldsValue({
-          name: resp.result?.name,
-          description: resp.result?.description,
-        });
-        queryProviders();
-        setCurrent(0);
-        queryNetworkList(resp.result?.provider);
+    if (props.data?.id) {
+      setProcotolCurrent(props.data?.protocol);
+      setNetworkCurrent(props.data?.channelId);
+      form.setFieldsValue({
+        name: props.data?.name,
+        description: props.data?.description,
       });
+      setCurrent(0);
+      queryNetworkList(props.data?.provider);
     }
-  }, []);
+  }, [props.data]);
 
   const next = () => {
     if (current === 0) {
       if (!networkCurrent) {
         message.error('请选择网络组件!');
       } else {
-        queryProcotolList(props.data?.id || access?.provider);
+        queryProcotolList(props.provider?.id);
         setCurrent(current + 1);
       }
     }
@@ -128,7 +107,7 @@ const Access = (props: Props) => {
         message.error('请选择消息协议!');
       } else {
         service
-          .getConfigView(procotolCurrent, ProcotoleMapping.get(props.data?.id || access?.provider))
+          .getConfigView(procotolCurrent, ProcotoleMapping.get(props.provider?.id))
           .then((resp) => {
             if (resp.status === 200) {
               setConfig(resp.result);
@@ -301,7 +280,7 @@ const Access = (props: Props) => {
                 placeholder="请输入名称"
                 onSearch={(value: string) => {
                   queryNetworkList(
-                    props.data?.id || access?.provider,
+                    props.provider?.id,
                     encodeQuery({
                       terms: {
                         name$LIKE: `%${value}%`,
@@ -318,7 +297,7 @@ const Access = (props: Props) => {
                   const tab: any = window.open(`${origin}/#${url}`);
                   tab!.onTabSaveSuccess = (value: any) => {
                     if (value.status === 200) {
-                      queryNetworkList(props.data?.id || access?.provider);
+                      queryNetworkList(props.provider?.id);
                     }
                   };
                 }}
@@ -377,7 +356,7 @@ const Access = (props: Props) => {
                         const tab: any = window.open(`${origin}/#${url}`);
                         tab!.onTabSaveSuccess = (value: any) => {
                           if (value.status === 200) {
-                            queryNetworkList(props.data?.id || access?.provider);
+                            queryNetworkList(props.provider?.id);
                           }
                         };
                       }}
@@ -403,7 +382,7 @@ const Access = (props: Props) => {
                 placeholder="请输入名称"
                 onSearch={(value: string) => {
                   queryProcotolList(
-                    props.data?.id || access?.provider,
+                    props.provider?.id,
                     encodeQuery({
                       terms: {
                         name$LIKE: `%${value}%`,
@@ -416,10 +395,11 @@ const Access = (props: Props) => {
               <Button
                 type="primary"
                 onClick={() => {
-                  const tab: any = window.open(`${origin}/#/link/Protocol?save=true`);
+                  const url = getMenuPathByCode(MENUS_CODE['link/Protocol?save=true']);
+                  const tab: any = window.open(`${origin}/#${url}`);
                   tab!.onTabSaveSuccess = (value: any) => {
                     if (value) {
-                      queryProcotolList(props.data?.id || access?.provider);
+                      queryProcotolList(props.provider?.id);
                     }
                   };
                 }}
@@ -436,7 +416,7 @@ const Access = (props: Props) => {
                       style={{
                         width: '100%',
                         borderColor:
-                          networkCurrent === item.id ? 'var(--ant-primary-color-active)' : '',
+                          procotolCurrent === item.id ? 'var(--ant-primary-color-active)' : '',
                       }}
                       hoverable
                       onClick={() => {
@@ -458,10 +438,11 @@ const Access = (props: Props) => {
                     暂无数据
                     <a
                       onClick={() => {
-                        const tab: any = window.open(`${origin}/#/link/Protocol?save=true`);
+                        const url = getMenuPathByCode(MENUS_CODE['link/Protocol?save=true']);
+                        const tab: any = window.open(`${origin}/#${url}`);
                         tab!.onTabSaveSuccess = (value: any) => {
                           if (value) {
-                            queryProcotolList(props.data?.id || access?.provider);
+                            queryProcotolList(props.provider?.id);
                           }
                         };
                       }}
@@ -502,14 +483,14 @@ const Access = (props: Props) => {
                       try {
                         const values = await form.validateFields();
                         // 编辑还是保存
-                        if (!params.get('id')) {
+                        if (!props.data?.id) {
                           service
                             .save({
                               name: values.name,
                               description: values.description,
-                              provider: props.data.id,
+                              provider: props.provider.id,
                               protocol: procotolCurrent,
-                              transport: ProcotoleMapping.get(props.data.id),
+                              transport: ProcotoleMapping.get(props.provider.id),
                               channel: 'network', // 网络组件
                               channelId: networkCurrent,
                             })
@@ -526,12 +507,10 @@ const Access = (props: Props) => {
                         } else {
                           service
                             .update({
-                              id: access?.id,
+                              ...props.data,
                               name: values.name,
                               description: values.description,
-                              provider: access?.provider,
                               protocol: procotolCurrent,
-                              transport: access?.transport,
                               channel: 'network', // 网络组件
                               channelId: networkCurrent,
                             })
@@ -560,30 +539,19 @@ const Access = (props: Props) => {
               <div className={styles.config}>
                 <div className={styles.item}>
                   <div className={styles.title}>接入方式</div>
-                  <div className={styles.context}>
-                    {props.data?.name ||
-                      providers.find((i) => i.id === access?.provider)?.name ||
-                      '--'}
-                  </div>
-                  <div className={styles.context}>
-                    {((props.data?.description ||
-                      providers.find((i) => i.id === access?.provider)?.description) && (
-                      <span>
-                        {props.data?.description ||
-                          providers.find((i) => i.id === access?.provider)?.description}
-                      </span>
-                    )) ||
-                      '--'}
-                  </div>
+                  <div className={styles.context}>{props.provider?.name || '--'}</div>
+                  <div className={styles.context}>{props.provider?.description || '--'}</div>
                 </div>
                 <div className={styles.item}>
                   <div className={styles.title}>消息协议</div>
                   <div className={styles.context}>
                     {procotolList.find((i) => i.id === procotolCurrent)?.name || '--'}
                   </div>
-                  <div className={styles.context}>
-                    {config?.document ? <ReactMarkdown>{config?.document}</ReactMarkdown> : '--'}
-                  </div>
+                  {config?.document && (
+                    <div className={styles.context}>
+                      {<ReactMarkdown>{config?.document}</ReactMarkdown> || '--'}
+                    </div>
+                  )}
                 </div>
                 <div className={styles.item}>
                   <div className={styles.title}>网络组件</div>
@@ -604,8 +572,8 @@ const Access = (props: Props) => {
                 {config?.routes && config?.routes?.length > 0 && (
                   <div className={styles.item}>
                     <div style={{ fontWeight: '600', marginBottom: 10 }}>
-                      {access?.provider === 'mqtt-server-gateway' ||
-                      access?.provider === 'mqtt-client-gateway'
+                      {props.data?.provider === 'mqtt-server-gateway' ||
+                      props.data?.provider === 'mqtt-client-gateway'
                         ? 'topic'
                         : 'URL信息'}
                     </div>

+ 1 - 1
src/pages/link/AccessConfig/Detail/Media/index.tsx

@@ -530,7 +530,7 @@ const Media = (props: Props) => {
         </Button>
       )}
       {props?.provider?.id === 'fixed-media' ? (
-        FinishRender()
+        <div style={{ margin: '20px 30px' }}>{FinishRender()}</div>
       ) : (
         <div className={styles.box}>
           <div className={styles.steps}>

+ 1 - 0
src/pages/link/AccessConfig/Detail/index.tsx

@@ -56,6 +56,7 @@ const Detail = () => {
         return (
           <Access
             data={data}
+            provider={provider}
             change={() => {
               setVisible(true);
             }}

+ 10 - 39
src/pages/link/AccessConfig/index.less

@@ -1,43 +1,14 @@
-// .box {
-//   display: flex;
-//   justify-content: space-between;
-// }
-
-// .images {
-//   width: 64px;
-//   height: 64px;
-//   color: white;
-//   font-size: 18px;
-//   line-height: 64px;
-//   text-align: center;
-//   background: linear-gradient(
-//     128.453709216706deg,
-//     rgba(255, 255, 255, 1) 4%,
-//     rgba(113, 187, 255, 1) 43%,
-//     rgba(24, 144, 255, 1) 100%
-//   );
-//   border: 1px solid rgba(242, 242, 242, 1);
-//   border-radius: 50%;
-// }
-
-// .content {
-//   display: flex;
-//   flex-direction: column;
-//   width: calc(100% - 80px);
-// }
-
-// .top {
-//   display: flex;
-//   justify-content: space-between;
-
-//   .left {
-//     display: flex;
-//   }
+.tableCardDisabled {
+  width: 100%;
+  background: url('/images/access-config-diaabled.png') no-repeat;
+  background-size: 100% 100%;
+}
 
-//   .action a {
-//     margin: 0 5px;
-//   }
-// }
+.tableCardEnabled {
+  width: 100%;
+  background: url('/images/access-config-enabled.png') no-repeat;
+  background-size: 100% 100%;
+}
 
 .context {
   display: flex;

+ 5 - 46
src/pages/link/AccessConfig/index.tsx

@@ -1,17 +1,13 @@
-import { TableCard } from '@/components';
 import SearchComponent from '@/components/SearchComponent';
 import { getMenuPathByCode, MENUS_CODE } from '@/utils/menu';
-import { StatusColorEnum } from '@/components/BadgeStatus';
 import { PageContainer } from '@ant-design/pro-layout';
 import type { ProColumns } from '@jetlinks/pro-table';
-import { Badge, Button, Card, Col, Empty, message, Pagination, Popconfirm, Row } from 'antd';
+import { Button, Card, Col, Empty, message, Pagination, Popconfirm, Row } from 'antd';
 import { useEffect, useState } from 'react';
 import { useHistory } from 'umi';
-import styles from './index.less';
 import Service from './service';
 import { CheckCircleOutlined, DeleteOutlined, EditOutlined, StopOutlined } from '@ant-design/icons';
-
-const defaultImage = require('/public/images/device-access.png');
+import AccessConfigCard from '@/components/ProTableCard/CardItems/AccessConfig';
 
 export const service = new Service('gateway/device');
 
@@ -72,7 +68,6 @@ const AccessConfig = () => {
       <Card>
         <SearchComponent
           field={columns}
-          // pattern={'simple'}
           enableSave={false}
           onSearch={(data: any) => {
             const dt = {
@@ -96,8 +91,8 @@ const AccessConfig = () => {
           <Row gutter={[16, 16]} style={{ marginTop: 10 }}>
             {(dataSource?.data || []).map((item: any) => (
               <Col key={item.id} span={12}>
-                <TableCard
-                  showMask={false}
+                <AccessConfigCard
+                  {...item}
                   actions={[
                     <Button
                       key="edit"
@@ -166,43 +161,7 @@ const AccessConfig = () => {
                       </Popconfirm>
                     </Button>,
                   ]}
-                  status={item.state.value}
-                  statusText={item.state.text}
-                  statusNames={{
-                    enabled: StatusColorEnum.processing,
-                    disabled: StatusColorEnum.error,
-                  }}
-                >
-                  <div className={styles.context}>
-                    <div>
-                      <img width={88} height={88} src={defaultImage} alt={''} />
-                    </div>
-                    <div className={styles.card}>
-                      <div className={styles.header}>
-                        <div className={styles.title}>{item.name || '--'}</div>
-                        <div className={styles.desc}>{item.description || '--'}</div>
-                      </div>
-                      <div className={styles.container}>
-                        <div className={styles.server}>
-                          <div className={styles.subTitle}>{item?.channelInfo?.name || '--'}</div>
-                          <div style={{ width: '100%' }}>
-                            {item.channelInfo?.addresses.map((i: any, index: number) => (
-                              <p key={i.address + `_address${index}`}>
-                                <Badge color={i.health === -1 ? 'red' : 'green'} text={i.address} />
-                              </p>
-                            ))}
-                          </div>
-                        </div>
-                        <div className={styles.procotol}>
-                          <div className={styles.subTitle}>
-                            {item?.protocolDetail?.name || '--'}
-                          </div>
-                          <p>{item.protocolDetail?.description || '--'}</p>
-                        </div>
-                      </div>
-                    </div>
-                  </div>
-                </TableCard>
+                />
               </Col>
             ))}
           </Row>

+ 0 - 2
src/pages/link/Protocol/index.tsx

@@ -267,7 +267,6 @@ const Protocol = () => {
                   dependencies: ['..type'],
                   fulfill: {
                     state: {
-                      value: '',
                       visible: '{{["jar","local"].includes($deps[0])}}',
                       componentType: '{{$deps[0]==="jar"?"FileUpload":"Input"}}',
                       componentProps: '{{$deps[0]==="jar"?{type:"file", accept: ".jar, .zip"}:{}}}',
@@ -354,7 +353,6 @@ const Protocol = () => {
           </>
         }
       />
-      {/* {visible && <Debug data={current} close={() => setVisible(!visible)} />} */}
     </PageContainer>
   );
 };

+ 158 - 0
src/pages/media/Cascade/Channel/BindChannel/index.tsx

@@ -0,0 +1,158 @@
+import SearchComponent from '@/components/SearchComponent';
+import type { ActionType, ProColumns } from '@jetlinks/pro-table';
+import ProTable from '@jetlinks/pro-table';
+import { message, Modal, Space } from 'antd';
+import { useRef, useState } from 'react';
+import { service } from '@/pages/media/Cascade';
+import { useIntl } from 'umi';
+import BadgeStatus, { StatusColorEnum } from '@/components/BadgeStatus';
+
+interface Props {
+  data: string;
+  close: () => void;
+}
+
+const BindChannel = (props: Props) => {
+  const [param, setParam] = useState<any>({
+    pageIndex: 0,
+    pageSize: 10,
+    terms: [
+      {
+        column: 'id',
+        termType: 'cascade_channel$not',
+        value: props.data,
+        type: 'and',
+      },
+      {
+        column: 'catalogType',
+        termType: 'eq',
+        value: 'device',
+        type: 'and',
+      },
+    ],
+    sorts: [
+      {
+        name: 'name',
+        order: 'asc',
+      },
+    ],
+  });
+  const intl = useIntl();
+  const actionRef = useRef<ActionType>();
+  const [selectedRowKey, setSelectedRowKey] = useState<string[]>([]);
+
+  const columns: ProColumns<any>[] = [
+    {
+      dataIndex: 'deviceName',
+      title: '设备名称',
+    },
+    {
+      dataIndex: 'name',
+      title: '通道名称',
+    },
+    {
+      dataIndex: 'address',
+      title: '安装地址',
+    },
+    {
+      dataIndex: 'manufacturer',
+      title: '厂商',
+    },
+    {
+      dataIndex: 'status',
+      title: '在线状态',
+      render: (text: any, record: any) => (
+        <BadgeStatus
+          status={record.status?.value}
+          text={record.status?.text}
+          statusNames={{
+            online: StatusColorEnum.success,
+            offline: StatusColorEnum.error,
+          }}
+        />
+      ),
+    },
+  ];
+
+  return (
+    <Modal
+      title={'绑定通道'}
+      visible
+      onCancel={props.close}
+      onOk={async () => {
+        const resp = await service.bindChannel(props.data, selectedRowKey);
+        if (resp.status === 200) {
+          message.success('操作成功!');
+          props.close();
+        }
+      }}
+      width={1200}
+    >
+      <SearchComponent<any>
+        field={columns}
+        target="bind-channel"
+        enableSave={false}
+        onSearch={(data) => {
+          actionRef.current?.reload();
+          const terms = [
+            {
+              column: 'id',
+              termType: 'cascade_channel$not',
+              value: props.data,
+              type: 'and',
+            },
+            {
+              column: 'catalogType',
+              termType: 'eq',
+              value: 'device',
+              type: 'and',
+            },
+          ];
+          setParam({
+            ...param,
+            terms: data?.terms ? [...data?.terms, ...terms] : [...terms],
+          });
+        }}
+      />
+      <ProTable<any>
+        actionRef={actionRef}
+        params={param}
+        columns={columns}
+        search={false}
+        headerTitle={'通道列表'}
+        request={async (params) =>
+          service.queryChannel({ ...params, sorts: [{ name: 'name', order: 'desc' }] })
+        }
+        rowKey="id"
+        rowSelection={{
+          selectedRowKeys: selectedRowKey,
+          onChange: (keys) => {
+            setSelectedRowKey(keys as string[]);
+          },
+        }}
+        tableAlertRender={({ selectedRowKeys, onCleanSelected }) => (
+          <Space size={24}>
+            <span>
+              {intl.formatMessage({
+                id: 'pages.bindUser.bindTheNewUser.selected',
+                defaultMessage: '已选',
+              })}{' '}
+              {selectedRowKeys?.length}{' '}
+              {intl.formatMessage({
+                id: 'pages.bindUser.bindTheNewUser.item',
+                defaultMessage: '项',
+              })}
+              <a style={{ marginLeft: 8 }} onClick={onCleanSelected}>
+                {intl.formatMessage({
+                  id: 'pages.bindUser.bindTheNewUser.deselect',
+                  defaultMessage: '取消选择',
+                })}
+              </a>
+            </span>
+          </Space>
+        )}
+      />
+    </Modal>
+  );
+};
+export default BindChannel;

+ 169 - 0
src/pages/media/Cascade/Channel/index.tsx

@@ -0,0 +1,169 @@
+import { service } from '@/pages/media/Cascade';
+import SearchComponent from '@/components/SearchComponent';
+import { DisconnectOutlined } from '@ant-design/icons';
+import { PageContainer } from '@ant-design/pro-layout';
+import type { ActionType, ProColumns } from '@jetlinks/pro-table';
+import { Button, message, Popconfirm, Space, Tooltip } from 'antd';
+import { useRef, useState } from 'react';
+import ProTable from '@jetlinks/pro-table';
+import { useIntl, useLocation } from 'umi';
+import BindChannel from './BindChannel';
+import BadgeStatus, { StatusColorEnum } from '@/components/BadgeStatus';
+
+const Channel = () => {
+  const location: any = useLocation();
+  const actionRef = useRef<ActionType>();
+  const [param, setParam] = useState({});
+  const intl = useIntl();
+  const [visible, setVisible] = useState<boolean>(false);
+  const [selectedRowKey, setSelectedRowKey] = useState<string[]>([]);
+  const id = location?.query?.id || '';
+
+  const unbind = async (data: string[]) => {
+    const resp = await service.unbindChannel(id, data);
+    if (resp.status === 200) {
+      actionRef.current?.reload();
+      message.success('操作成功!');
+    }
+  };
+
+  const columns: ProColumns<any>[] = [
+    {
+      dataIndex: 'deviceName',
+      title: '设备名称',
+    },
+    {
+      dataIndex: 'name',
+      title: '通道名称',
+    },
+    {
+      dataIndex: 'channelId',
+      title: '国标ID',
+    },
+    {
+      dataIndex: 'address',
+      title: '安装地址',
+    },
+    {
+      dataIndex: 'manufacturer',
+      title: '厂商',
+    },
+    {
+      dataIndex: 'status',
+      title: '在线状态',
+      render: (text: any, record: any) => (
+        <BadgeStatus
+          status={record.status?.value}
+          text={record.status?.text}
+          statusNames={{
+            online: StatusColorEnum.success,
+            offline: StatusColorEnum.error,
+          }}
+        />
+      ),
+    },
+    {
+      title: '操作',
+      valueType: 'option',
+      align: 'center',
+      width: 200,
+      render: (text: any, record: any) => [
+        <Popconfirm
+          key={'unbinds'}
+          title="确认解绑"
+          onConfirm={() => {
+            unbind([record.id]);
+          }}
+        >
+          <a>
+            <Tooltip title={'解绑'}>
+              <DisconnectOutlined />
+            </Tooltip>
+          </a>
+        </Popconfirm>,
+      ],
+    },
+  ];
+
+  return (
+    <PageContainer>
+      <SearchComponent<any>
+        field={columns}
+        target="unbind-channel"
+        onSearch={(data) => {
+          actionRef.current?.reload();
+          setParam({
+            ...param,
+            terms: data?.terms ? [...data?.terms] : [],
+          });
+        }}
+      />
+      <ProTable<any>
+        actionRef={actionRef}
+        params={param}
+        columns={columns}
+        search={false}
+        headerTitle={'通道列表'}
+        request={async (params) =>
+          service.queryBindChannel(id, {
+            ...params,
+            sorts: [{ name: 'createTime', order: 'desc' }],
+          })
+        }
+        rowKey="id"
+        rowSelection={{
+          selectedRowKeys: selectedRowKey,
+          onChange: (selectedRowKeys) => {
+            setSelectedRowKey(selectedRowKeys as string[]);
+          },
+        }}
+        tableAlertRender={({ selectedRowKeys, onCleanSelected }) => (
+          <Space size={24}>
+            <span>
+              {intl.formatMessage({
+                id: 'pages.bindUser.bindTheNewUser.selected',
+                defaultMessage: '已选',
+              })}{' '}
+              {selectedRowKeys.length}{' '}
+              {intl.formatMessage({
+                id: 'pages.bindUser.bindTheNewUser.item',
+                defaultMessage: '项',
+              })}
+              <a style={{ marginLeft: 8 }} onClick={onCleanSelected}>
+                {intl.formatMessage({
+                  id: 'pages.bindUser.bindTheNewUser.deselect',
+                  defaultMessage: '取消选择',
+                })}
+              </a>
+            </span>
+          </Space>
+        )}
+        toolBarRender={() => [
+          <Button
+            onClick={() => {
+              setVisible(true);
+            }}
+            key="bind"
+            type="primary"
+          >
+            绑定通道
+          </Button>,
+          <Button onClick={() => {}} key="unbind">
+            批量解绑
+          </Button>,
+        ]}
+      />
+      {visible && (
+        <BindChannel
+          data={id}
+          close={() => {
+            setVisible(false);
+            actionRef.current?.reload();
+          }}
+        />
+      )}
+    </PageContainer>
+  );
+};
+
+export default Channel;

+ 84 - 0
src/pages/media/Cascade/Publish/index.tsx

@@ -0,0 +1,84 @@
+import SystemConst from '@/utils/const';
+import Token from '@/utils/token';
+import { downloadObject } from '@/utils/util';
+import { Col, Input, Modal, Row } from 'antd';
+import { EventSourcePolyfill } from 'event-source-polyfill';
+import { useEffect, useState } from 'react';
+
+interface Props {
+  data: any;
+  close: () => void;
+}
+
+const Publish = (props: Props) => {
+  const activeAPI = `/${SystemConst.API_BASE}/media/gb28181-cascade/${
+    props.data.id
+  }/bindings/publish?:X_Access_Token=${Token.get()}`;
+  const [count, setCount] = useState<number>(0);
+  const [countErr, setCountErr] = useState<number>(0);
+  const [flag, setFlag] = useState<boolean>(true);
+  const [errMessage, setErrMessage] = useState<string>('');
+
+  const getData = () => {
+    let dt = 0;
+    const source = new EventSourcePolyfill(activeAPI);
+    source.onmessage = (e: any) => {
+      const res = JSON.parse(e.data);
+      if (res.success) {
+        const temp = res.result.total;
+        dt += temp;
+        setCount(dt);
+        // setCountErr(0);
+      } else {
+        setCountErr(0);
+        setErrMessage(res.message);
+      }
+    };
+    source.onerror = () => {
+      setFlag(false);
+      source.close();
+    };
+    source.onopen = () => {};
+  };
+
+  useEffect(() => {
+    getData();
+  }, []);
+
+  return (
+    <Modal
+      title={'推送'}
+      maskClosable={false}
+      visible
+      onCancel={props.close}
+      onOk={props.close}
+      width={900}
+    >
+      <Row gutter={24} style={{ marginBottom: 20 }}>
+        <Col span={8}>
+          <div>成功: {count}</div>
+          <div>
+            失败: {countErr}
+            <a
+              style={{ marginLeft: 20 }}
+              onClick={() => {
+                downloadObject(JSON.parse(errMessage || '{}'), props.data.name + '-推送失败');
+              }}
+            >
+              下载
+            </a>
+          </div>
+        </Col>
+        <Col span={8}>推送通道数量: {props.data?.count || 0}</Col>
+        <Col span={8}>已推送通道数量: {countErr + count}</Col>
+      </Row>
+      {flag && (
+        <div>
+          <Input.TextArea rows={10} value={errMessage} />
+        </div>
+      )}
+    </Modal>
+  );
+};
+
+export default Publish;

+ 305 - 0
src/pages/media/Cascade/Save/index.tsx

@@ -0,0 +1,305 @@
+import TitleComponent from '@/components/TitleComponent';
+import { QuestionCircleOutlined } from '@ant-design/icons';
+import { PageContainer } from '@ant-design/pro-layout';
+import {
+  Button,
+  Card,
+  Col,
+  Form,
+  Input,
+  InputNumber,
+  message,
+  Radio,
+  Row,
+  Select,
+  Tooltip,
+} from 'antd';
+import SipComponent from '@/components/SipComponent';
+import { testIP } from '@/utils/util';
+import { useEffect, useState } from 'react';
+import { service } from '../index';
+import { useLocation } from 'umi';
+
+const Save = () => {
+  const location: any = useLocation();
+  const [form] = Form.useForm();
+  const [clusters, setClusters] = useState<any[]>([]);
+  const id = location?.query?.id || '';
+
+  const checkSIP = (_: any, value: { host: string; port: number }) => {
+    if (!value || !value.host) {
+      return Promise.reject(new Error('请输入API HOST'));
+    } else if (value?.host && !testIP(value.host)) {
+      return Promise.reject(new Error('请输入正确的IP地址'));
+    } else if (!value?.port) {
+      return Promise.reject(new Error('请输入端口'));
+    } else if ((value?.port && Number(value.port) < 1) || Number(value.port) > 65535) {
+      return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
+    }
+    return Promise.resolve();
+  };
+
+  useEffect(() => {
+    service.queryClusters().then((resp) => {
+      if (resp.status === 200) {
+        setClusters(resp.result);
+      }
+    });
+    if (!!id) {
+      service.detail(id).then((resp) => {
+        if (resp.status === 200) {
+          const sipConfigs = resp.result?.sipConfigs[0];
+          const data = {
+            ...resp.result,
+            sipConfigs: {
+              ...sipConfigs,
+              public: {
+                host: sipConfigs.remoteAddress,
+                port: sipConfigs.remotePort,
+              },
+              local: {
+                host: sipConfigs.host,
+                port: sipConfigs.port,
+              },
+            },
+          };
+          form.setFieldsValue(data);
+        }
+      });
+    }
+  }, []);
+
+  return (
+    <PageContainer>
+      <Card>
+        <Form
+          name="cascade"
+          layout="vertical"
+          form={form}
+          initialValues={{
+            proxyStream: false,
+            sipConfigs: {
+              transport: 'UDP',
+            },
+          }}
+          onFinish={async (values: any) => {
+            const sipConfigs = {
+              ...values.sipConfigs,
+              remoteAddress: values.sipConfigs.public.host,
+              remotePort: values.sipConfigs.public.port,
+              host: values.sipConfigs.local.host,
+              port: values.sipConfigs.local.port,
+            };
+            delete values.sipConfigs;
+            delete sipConfigs.public;
+            delete sipConfigs.local;
+            const param = { ...values, sipConfigs: [sipConfigs] };
+            let resp = undefined;
+            if (id) {
+              resp = await service.update({ ...param, id });
+            } else {
+              resp = await service.save(param);
+            }
+            if (resp && resp.status === 200) {
+              message.success('操作成功!');
+              history.back();
+            }
+          }}
+        >
+          <Row gutter={24}>
+            <TitleComponent data={'基本信息'} />
+            <Col span={12}>
+              <Form.Item
+                label="名称"
+                name="name"
+                rules={[{ required: true, message: '请输入名称' }]}
+              >
+                <Input placeholder="请输入名称" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label={<span>代理视频流</span>}
+                name="proxyStream"
+                rules={[{ required: true, message: '请选择代理视频流' }]}
+              >
+                <Radio.Group optionType="button" buttonStyle="solid">
+                  <Radio.Button value={true}>启用</Radio.Button>
+                  <Radio.Button value={false}>禁用</Radio.Button>
+                </Radio.Group>
+              </Form.Item>
+            </Col>
+            <TitleComponent data={'信令服务配置'} />
+            <Col span={12}>
+              <Form.Item
+                label={
+                  <span>
+                    集群节点
+                    <Tooltip title="使用此集群节点级联到上级平台">
+                      <QuestionCircleOutlined />
+                    </Tooltip>
+                  </span>
+                }
+                name={['sipConfigs', 'clusterNodeId']}
+                rules={[{ required: true, message: '请选择信令服务配置' }]}
+              >
+                <Select placeholder="请选择信令服务配置">
+                  {clusters.map((item) => (
+                    <Select.Option key={item.id} value={item.id}>
+                      {item.name}
+                    </Select.Option>
+                  ))}
+                </Select>
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="信令名称"
+                name={['sipConfigs', 'name']}
+                rules={[{ required: true, message: '请输入信令名称' }]}
+              >
+                <Input placeholder="请输入信令名称" />
+              </Form.Item>
+            </Col>
+            <Col span={24}>
+              <Form.Item
+                label="上级SIP ID"
+                name={['sipConfigs', 'sipId']}
+                rules={[{ required: true, message: '请输入上级SIP ID' }]}
+              >
+                <Input placeholder="请输入上级SIP ID" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="上级SIP域"
+                name={['sipConfigs', 'domain']}
+                rules={[{ required: true, message: '请输入上级SIP域' }]}
+              >
+                <Input placeholder="请输入上级SIP域" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="上级SIP 地址"
+                name={['sipConfigs', 'public']}
+                rules={[{ required: true, message: '请输入上级SIP 地址' }, { validator: checkSIP }]}
+              >
+                <SipComponent />
+              </Form.Item>
+            </Col>
+            <Col span={24}>
+              <Form.Item
+                label="本地SIP ID"
+                name={['sipConfigs', 'localSipId']}
+                rules={[{ required: true, message: '请输入本地SIP ID' }]}
+              >
+                <Input placeholder="请输入本地SIP ID" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="传输协议"
+                name={['sipConfigs', 'transport']}
+                rules={[{ required: true, message: '请选择传输协议' }]}
+              >
+                <Radio.Group optionType="button" buttonStyle="solid">
+                  <Radio.Button value="UDP">UDP</Radio.Button>
+                  <Radio.Button value="TCP">TCP</Radio.Button>
+                </Radio.Group>
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label={
+                  <span>
+                    SIP本地地址
+                    <Tooltip title="使用指定的网卡和端口进行请求">
+                      <QuestionCircleOutlined />
+                    </Tooltip>
+                  </span>
+                }
+                name={['sipConfigs', 'local']}
+                rules={[{ required: true, message: '请输入SIP本地地址' }, { validator: checkSIP }]}
+              >
+                <SipComponent />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="用户"
+                name={['sipConfigs', 'user']}
+                rules={[{ required: true, message: '请输入用户' }]}
+              >
+                <Input placeholder="请输入用户" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="接入密码"
+                name={['sipConfigs', 'password']}
+                rules={[{ required: true, message: '请输入接入密码' }]}
+              >
+                <Input.Password placeholder="请输入接入密码" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="厂商"
+                name={['sipConfigs', 'manufacturer']}
+                rules={[{ required: true, message: '请输入厂商' }]}
+              >
+                <Input placeholder="请输入厂商" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="型号"
+                name={['sipConfigs', 'model']}
+                rules={[{ required: true, message: '请输入型号' }]}
+              >
+                <Input placeholder="请输入型号" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="版本号"
+                name={['sipConfigs', 'firmware']}
+                rules={[{ required: true, message: '请输入版本号' }]}
+              >
+                <Input placeholder="请输入版本号" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="心跳周期(秒)"
+                name={['sipConfigs', 'keepaliveInterval']}
+                rules={[{ required: true, message: '请输入心跳周期' }]}
+              >
+                <InputNumber placeholder="请输入心跳周期" style={{ width: '100%' }} />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="注册间隔(秒)"
+                name={['sipConfigs', 'registerInterval']}
+                rules={[{ required: true, message: '请输入注册间隔' }]}
+              >
+                <InputNumber placeholder="请输入注册间隔" style={{ width: '100%' }} />
+              </Form.Item>
+            </Col>
+            <Col span={24}>
+              <Form.Item>
+                <Button type="primary" htmlType="submit">
+                  保存
+                </Button>
+              </Form.Item>
+            </Col>
+          </Row>
+        </Form>
+      </Card>
+    </PageContainer>
+  );
+};
+
+export default Save;

+ 297 - 56
src/pages/media/Cascade/index.tsx

@@ -1,25 +1,130 @@
 import { PageContainer } from '@ant-design/pro-layout';
-import BaseService from '@/utils/BaseService';
-import { useRef } from 'react';
+import { useRef, useState } from 'react';
 import type { ActionType, ProColumns } from '@jetlinks/pro-table';
-import { Tooltip } from 'antd';
-import { ArrowDownOutlined, BugOutlined, EditOutlined, MinusOutlined } from '@ant-design/icons';
-import BaseCrud from '@/components/BaseCrud';
+import { Badge, Button, message, Popconfirm, Tooltip } from 'antd';
+import {
+  CheckCircleOutlined,
+  DeleteOutlined,
+  EditOutlined,
+  LinkOutlined,
+  PlusOutlined,
+  ShareAltOutlined,
+  StopOutlined,
+} from '@ant-design/icons';
 import type { CascadeItem } from '@/pages/media/Cascade/typings';
 import { useIntl } from '@@/plugin-locale/localeExports';
+import SearchComponent from '@/components/SearchComponent';
+import { ProTableCard } from '@/components';
+import CascadeCard from '@/components/ProTableCard/CardItems/cascade';
+import { getMenuPathByCode, MENUS_CODE } from '@/utils/menu';
+import { useHistory } from 'umi';
+import Service from './service';
+import Publish from './Publish';
+import { lastValueFrom } from 'rxjs';
+
+export const service = new Service('media/gb28181-cascade');
 
-export const service = new BaseService<CascadeItem>('media/gb28181-cascade');
 const Cascade = () => {
   const intl = useIntl();
   const actionRef = useRef<ActionType>();
+  const [searchParams, setSearchParams] = useState<any>({});
+  const history = useHistory<Record<string, string>>();
+  const [visible, setVisible] = useState<boolean>(false);
+  const [current, setCurrent] = useState<Partial<CascadeItem>>();
+
+  const tools = (record: CascadeItem) => [
+    <Tooltip
+      title={intl.formatMessage({
+        id: 'pages.data.option.edit',
+        defaultMessage: '编辑',
+      })}
+      key={'edit'}
+    >
+      <Button
+        type={'link'}
+        style={{ padding: 0 }}
+        onClick={() => {
+          const url = getMenuPathByCode(MENUS_CODE[`media/Cascade/Save`]);
+          history.push(url + `?id=${record.id}`);
+        }}
+      >
+        <EditOutlined />
+      </Button>
+    </Tooltip>,
+    <Tooltip title={'选择通道'} key={'channel'}>
+      <Button
+        type={'link'}
+        style={{ padding: 0 }}
+        onClick={() => {
+          const url = getMenuPathByCode(MENUS_CODE[`media/Cascade/Channel`]);
+          history.push(url + `?id=${record.id}`);
+        }}
+      >
+        <LinkOutlined />
+      </Button>
+    </Tooltip>,
+    <Popconfirm
+      key={'share'}
+      title="确认共享!"
+      onConfirm={() => {
+        setCurrent(record);
+        setVisible(true);
+      }}
+    >
+      <Tooltip title={'共享'}>
+        <Button type={'link'}>
+          <ShareAltOutlined />
+        </Button>
+      </Tooltip>
+    </Popconfirm>,
+    <Popconfirm
+      key={'able'}
+      title={record.status.value === 'disabled' ? '确认启用' : '确认禁用'}
+      onConfirm={async () => {
+        let resp: any = undefined;
+        if (record.status.value === 'disabled') {
+          resp = await service.enabled(record.id);
+        } else {
+          resp = await service.disabled(record.id);
+        }
+        if (resp?.status === 200) {
+          message.success('操作成功!');
+          actionRef.current?.reset?.();
+        }
+      }}
+    >
+      <Button type={'link'} style={{ padding: 0 }}>
+        <Tooltip title={record.status.value === 'disabled' ? '启用' : '禁用'}>
+          {record.status.value === 'disabled' ? <CheckCircleOutlined /> : <StopOutlined />}
+        </Tooltip>
+      </Button>
+    </Popconfirm>,
+    <Popconfirm
+      title={'确认删除'}
+      key={'delete'}
+      onConfirm={async () => {
+        const resp: any = await service.remove(record.id);
+        if (resp.status === 200) {
+          message.success('操作成功!');
+          actionRef.current?.reset?.();
+        }
+      }}
+    >
+      <Tooltip
+        title={intl.formatMessage({
+          id: 'pages.data.option.remove',
+          defaultMessage: '删除',
+        })}
+      >
+        <Button type={'link'} style={{ padding: 0 }}>
+          <DeleteOutlined />
+        </Button>
+      </Tooltip>
+    </Popconfirm>,
+  ];
 
   const columns: ProColumns<CascadeItem>[] = [
     {
-      dataIndex: 'index',
-      valueType: 'indexBorder',
-      width: 48,
-    },
-    {
       dataIndex: 'name',
       title: intl.formatMessage({
         id: 'pages.table.name',
@@ -27,19 +132,64 @@ const Cascade = () => {
       }),
     },
     {
-      dataIndex: 'networkType',
-      title: intl.formatMessage({
-        id: 'pages.table.type',
-        defaultMessage: '类型',
-      }),
+      dataIndex: 'sipConfigs[0].sipId',
+      title: '上级SIP ID',
+      render: (text: any, record: any) => record.sipConfigs[0].sipId,
     },
     {
-      dataIndex: 'state',
+      dataIndex: 'sipConfigs[0].publicHost',
+      title: '上级SIP 地址',
+      render: (text: any, record: any) => record.sipConfigs[0].publicHost,
+    },
+    {
+      dataIndex: 'count',
+      title: '通道数量',
+      hideInSearch: true,
+    },
+    {
+      dataIndex: 'status',
       title: intl.formatMessage({
         id: 'pages.searchTable.titleStatus',
         defaultMessage: '状态',
       }),
-      render: (text, record) => record.status.value,
+      render: (text: any, record: any) => (
+        <Badge
+          status={record.status?.value === 'disabled' ? 'error' : 'success'}
+          text={record.status?.text}
+        />
+      ),
+      valueType: 'select',
+      valueEnum: {
+        disabled: {
+          text: '已停止',
+          status: 'disabled',
+        },
+        enabled: {
+          text: '已启动',
+          status: 'enabled',
+        },
+      },
+    },
+    {
+      dataIndex: 'onlineStatus',
+      title: '级联状态',
+      render: (text: any, record: any) => (
+        <Badge
+          status={record.onlineStatus?.value === 'offline' ? 'error' : 'success'}
+          text={record.onlineStatus?.text}
+        />
+      ),
+      valueType: 'select',
+      valueEnum: {
+        online: {
+          text: '在线',
+          status: 'online',
+        },
+        offline: {
+          text: '离线',
+          status: 'offline',
+        },
+      },
     },
     {
       title: intl.formatMessage({
@@ -50,7 +200,13 @@ const Cascade = () => {
       align: 'center',
       width: 200,
       render: (text, record) => [
-        <a onClick={() => console.log(record)}>
+        <a
+          key={'edit'}
+          onClick={() => {
+            const url = getMenuPathByCode(MENUS_CODE[`media/Cascade/Save`]);
+            history.push(url + `?id=${record.id}`);
+          }}
+        >
           <Tooltip
             title={intl.formatMessage({
               id: 'pages.data.option.edit',
@@ -60,54 +216,139 @@ const Cascade = () => {
             <EditOutlined />
           </Tooltip>
         </a>,
-        <a>
-          <Tooltip
-            title={intl.formatMessage({
-              id: 'pages.data.option.remove',
-              defaultMessage: '删除',
-            })}
-          >
-            <MinusOutlined />
-          </Tooltip>
-        </a>,
-        <a>
-          <Tooltip
-            title={intl.formatMessage({
-              id: 'pages.data.option.download',
-              defaultMessage: '下载配置',
-            })}
-          >
-            <ArrowDownOutlined />
-          </Tooltip>
-        </a>,
-        <a>
-          <Tooltip
-            title={intl.formatMessage({
-              id: 'pages.notice.option.debug',
-              defaultMessage: '调试',
-            })}
-          >
-            <BugOutlined />
+        <a
+          key={'channel'}
+          onClick={() => {
+            const url = getMenuPathByCode(MENUS_CODE[`media/Cascade/Channel`]);
+            history.push(url + `?id=${record.id}`);
+          }}
+        >
+          <Tooltip title={'选择通道'}>
+            <LinkOutlined />
           </Tooltip>
         </a>,
+        <Popconfirm
+          key={'share'}
+          onConfirm={() => {
+            setVisible(true);
+            setCurrent(record);
+          }}
+          title={'确认共享'}
+        >
+          <a>
+            <Tooltip title={'共享'}>
+              <ShareAltOutlined />
+            </Tooltip>
+          </a>
+        </Popconfirm>,
+        <Popconfirm
+          key={'able'}
+          title={record.status.value === 'disabled' ? '确认启用' : '确认禁用'}
+          onConfirm={async () => {
+            let resp: any = undefined;
+            if (record.status.value === 'disabled') {
+              resp = await service.enabled(record.id);
+            } else {
+              resp = await service.disabled(record.id);
+            }
+            if (resp?.status === 200) {
+              message.success('操作成功!');
+              actionRef.current?.reset?.();
+            }
+          }}
+        >
+          <a>
+            <Tooltip title={record.status.value === 'disabled' ? '启用' : '禁用'}>
+              {record.status.value === 'disabled' ? <CheckCircleOutlined /> : <StopOutlined />}
+            </Tooltip>
+          </a>
+        </Popconfirm>,
+        <Popconfirm
+          title={'确认删除'}
+          key={'delete'}
+          onConfirm={async () => {
+            const resp: any = await service.remove(record.id);
+            if (resp.status === 200) {
+              message.success('操作成功!');
+              actionRef.current?.reset?.();
+            }
+          }}
+        >
+          <a>
+            <Tooltip
+              title={intl.formatMessage({
+                id: 'pages.data.option.remove',
+                defaultMessage: '删除',
+              })}
+            >
+              <DeleteOutlined />
+            </Tooltip>
+          </a>
+        </Popconfirm>,
       ],
     },
   ];
 
-  const schema = {};
-
   return (
     <PageContainer>
-      <BaseCrud
+      <SearchComponent<CascadeItem>
+        field={columns}
+        target="media-cascade"
+        onSearch={(data) => {
+          // 重置分页数据
+          actionRef.current?.reset?.();
+          setSearchParams(data);
+        }}
+      />
+      <ProTableCard<CascadeItem>
         columns={columns}
-        service={service}
-        title={intl.formatMessage({
-          id: 'pages.media.cascade',
-          defaultMessage: '模拟测试',
-        })}
-        schema={schema}
         actionRef={actionRef}
+        params={searchParams}
+        options={{ fullScreen: true }}
+        request={async (params = {}) => {
+          return await lastValueFrom(
+            service.queryZipCount({
+              ...params,
+              sorts: [
+                {
+                  name: 'createTime',
+                  order: 'desc',
+                },
+              ],
+            }),
+          );
+        }}
+        rowKey="id"
+        search={false}
+        pagination={{ pageSize: 10 }}
+        headerTitle={[
+          <Button
+            onClick={() => {
+              const url = getMenuPathByCode(MENUS_CODE[`media/Cascade/Save`]);
+              history.push(url);
+            }}
+            style={{ marginRight: 12 }}
+            key="button"
+            icon={<PlusOutlined />}
+            type="primary"
+          >
+            {intl.formatMessage({
+              id: 'pages.data.option.add',
+              defaultMessage: '新增',
+            })}
+          </Button>,
+        ]}
+        gridColumn={2}
+        cardRender={(record) => <CascadeCard {...record} actions={tools(record)} />}
       />
+      {visible && (
+        <Publish
+          data={current}
+          close={() => {
+            setVisible(false);
+          }}
+        />
+      )}
     </PageContainer>
   );
 };

+ 66 - 0
src/pages/media/Cascade/service.ts

@@ -0,0 +1,66 @@
+import BaseService from '@/utils/BaseService';
+import { request } from 'umi';
+import SystemConst from '@/utils/const';
+import type { CascadeItem } from './typings';
+import { concatMap, from, toArray } from 'rxjs';
+import { map } from 'rxjs/operators';
+import type { PageResult, Response } from '@/utils/typings';
+import _ from 'lodash';
+
+class Service extends BaseService<CascadeItem> {
+  queryBindChannel = (id: string, data: any) =>
+    request(`/${SystemConst.API_BASE}/media/gb28181-cascade/${id}/bindings/_query`, {
+      method: 'POST',
+      data,
+    });
+
+  queryZipCount = (params: any) =>
+    from(this.query(params)).pipe(
+      concatMap((i: Response<CascadeItem>) =>
+        from((i.result as PageResult)?.data).pipe(
+          concatMap((t: CascadeItem) =>
+            from(this.queryBindChannel(t.id, {})).pipe(
+              map((count: any) => ({ ...t, count: count.result?.total || 0 })),
+            ),
+          ),
+          toArray(),
+          map((data) => _.set(i, 'result.data', data) as any),
+        ),
+      ),
+    );
+
+  queryClusters = () =>
+    request(`/${SystemConst.API_BASE}/network/resources/clusters`, {
+      method: 'GET',
+    });
+
+  enabled = (id: string) =>
+    request(`/${SystemConst.API_BASE}/media/gb28181-cascade/${id}/_enabled`, {
+      method: 'POST',
+    });
+
+  disabled = (id: string) =>
+    request(`/${SystemConst.API_BASE}/media/gb28181-cascade/${id}/_disabled`, {
+      method: 'POST',
+    });
+
+  queryChannel = (data: any) =>
+    request(`/${SystemConst.API_BASE}/media/channel/_query`, {
+      method: 'POST',
+      data,
+    });
+
+  bindChannel = (id: string, data: string[]) =>
+    request(`/${SystemConst.API_BASE}/media/gb28181-cascade/${id}/_bind`, {
+      method: 'POST',
+      data,
+    });
+
+  unbindChannel = (id: string, data: string[]) =>
+    request(`/${SystemConst.API_BASE}/media/gb28181-cascade/${id}/_unbind`, {
+      method: 'POST',
+      data,
+    });
+}
+
+export default Service;

+ 1 - 0
src/pages/media/Cascade/typings.d.ts

@@ -29,4 +29,5 @@ type CascadeItem = {
   proxyStream: boolean;
   sipConfigs: Partial<SipConfig>[];
   status: State;
+  count?: number;
 } & BaseItem;

+ 50 - 0
src/pages/media/Device/Channel/Live.tsx

@@ -0,0 +1,50 @@
+// 通道直播
+import { useState } from 'react';
+import { Radio, Modal } from 'antd';
+import { ScreenPlayer } from '@/components';
+
+interface LiveProps {
+  visible: boolean;
+  url: string;
+  onCancel: () => void;
+}
+
+const LiveFC = (props: LiveProps) => {
+  const [] = useState();
+
+  return (
+    <Modal
+      maskClosable={false}
+      visible={props.visible}
+      title={'播放'}
+      width={800}
+      onCancel={() => {
+        if (props.onCancel) {
+          props.onCancel();
+        }
+      }}
+      onOk={() => {
+        if (props.onCancel) {
+          props.onCancel();
+        }
+      }}
+    >
+      <div>
+        <ScreenPlayer showScreen={false} />
+      </div>
+      <div>
+        <Radio.Group
+          optionType={'button'}
+          buttonStyle={'solid'}
+          options={[
+            { label: 'MP4', value: 'mp4' },
+            { label: 'FLV', value: 'flv' },
+            { label: 'HLS', value: 'hls' },
+          ]}
+        />
+      </div>
+    </Modal>
+  );
+};
+
+export default LiveFC;

+ 225 - 0
src/pages/media/Device/Channel/Save.tsx

@@ -0,0 +1,225 @@
+// Modal 弹窗,用于新增、修改数据
+import { createForm } from '@formily/core';
+import { createSchemaField } from '@formily/react';
+import { Form, FormItem, FormTab, Input, Select, FormGrid } from '@formily/antd';
+import { message, Modal } from 'antd';
+import { useIntl } from '@@/plugin-locale/localeExports';
+import type { ISchema } from '@formily/json-schema';
+import { useEffect, useState } from 'react';
+import { ProviderValue } from '../index';
+
+interface SaveModalProps {
+  visible: boolean;
+  type?: string;
+  model: 'edit' | 'add';
+  deviceId: string;
+  data?: any;
+  onCancel?: () => void;
+  onReload?: () => void;
+  service: any;
+}
+
+const Save = (props: SaveModalProps) => {
+  const { data, onCancel, service } = props;
+  const [loading, setLoading] = useState(false);
+  const intl = useIntl();
+
+  const SchemaField = createSchemaField({
+    components: {
+      FormItem,
+      FormTab,
+      Input,
+      Select,
+      FormGrid,
+    },
+  });
+
+  const form = createForm({
+    validateFirst: true,
+  });
+
+  useEffect(() => {
+    if (form && props.visible) {
+      if (props.model === 'edit') {
+        form.setValues({
+          channelId: data.channelId,
+          name: data.name,
+          id: data.id,
+          manufacturer: data.manufacturer,
+          address: data.address,
+          ptzType: data.ptzType ? data.ptzType.value : 0,
+          description: data.description,
+          media_url: data.other ? data.other['media_url'] : '',
+        });
+      } else {
+        form.setValues({
+          deviceId: props.deviceId,
+        });
+      }
+    }
+  }, [props.visible]);
+
+  const schema: ISchema = {
+    type: 'object',
+    properties: {
+      channelId: {
+        type: 'string',
+        title: '通道ID',
+        'x-decorator': 'FormItem',
+        'x-component': 'Input',
+        'x-component-props': {
+          disabled: props.model === 'edit',
+        },
+      },
+      name: {
+        type: 'string',
+        title: '通道名称',
+        required: true,
+        'x-decorator': 'FormItem',
+        'x-component': 'Input',
+        'x-validator': [
+          {
+            max: 64,
+            message: '最多可输入64个字符',
+          },
+          {
+            required: true,
+            message: '请输入通道名称',
+          },
+        ],
+      },
+      manufacturer: {
+        type: 'string',
+        title: '厂商',
+        'x-visible': props.type === ProviderValue.GB281,
+        'x-decorator': 'FormItem',
+        'x-component': 'Input',
+        'x-validator': [
+          {
+            max: 64,
+            message: '最多可输入64个字符',
+          },
+        ],
+        'x-decorator-props': {
+          gridSpan: 24,
+        },
+      },
+      media_url: {
+        type: 'string',
+        title: '视频地址',
+        'x-visible': props.type === ProviderValue.FIXED,
+        'x-decorator': 'FormItem',
+        'x-component': 'Input',
+        'x-validator': [
+          {
+            max: 64,
+            message: '最多可输入64个字符',
+          },
+        ],
+      },
+      address: {
+        type: 'string',
+        title: '安装地址',
+        'x-decorator': 'FormItem',
+        'x-component': 'Input',
+        'x-validator': [
+          {
+            max: 64,
+            message: '最多可输入64个字符',
+          },
+        ],
+      },
+      ptzType: {
+        type: 'string',
+        title: '云台类型',
+        'x-decorator': 'FormItem',
+        'x-component': 'Select',
+        'x-visible': props.type === ProviderValue.GB281,
+        'x-validator': [
+          {
+            max: 64,
+            message: '最多可输入64个字符',
+          },
+        ],
+        enum: [
+          { label: '未知', value: 0 },
+          { label: '球体', value: 1 },
+          { label: '半球体', value: 2 },
+          { label: '固定枪机', value: 3 },
+          { label: '遥控枪机', value: 4 },
+        ],
+      },
+      description: {
+        type: 'string',
+        title: '说明',
+        'x-decorator': 'FormItem',
+        'x-component': 'Input.TextArea',
+        'x-component-props': {
+          rows: 4,
+          maxLength: 200,
+          showCount: true,
+        },
+      },
+    },
+  };
+
+  const modalClose = () => {
+    if (onCancel) {
+      form.reset();
+      onCancel();
+    }
+  };
+
+  const saveData = async () => {
+    const formData: any = await form.submit();
+    if (formData) {
+      const { media_url, ...extraFormData } = formData;
+      if (media_url) {
+        extraFormData.other = {
+          media_url,
+        };
+      }
+      setLoading(true);
+      const resp =
+        props.model === 'edit'
+          ? await service.updateChannel(formData.id, formData)
+          : await service.saveChannel(formData);
+      setLoading(false);
+
+      if (resp.status === 200) {
+        message.success('操作成功');
+        modalClose();
+        if (props.onReload) {
+          props.onReload();
+        }
+      } else {
+        message.error('操作失败');
+      }
+    }
+  };
+
+  return (
+    <Modal
+      title={intl.formatMessage({
+        id: `pages.data.option.${props.model}`,
+        defaultMessage: '新增',
+      })}
+      maskClosable={false}
+      visible={props.visible}
+      width={550}
+      onOk={saveData}
+      onCancel={() => {
+        modalClose();
+      }}
+      confirmLoading={loading}
+    >
+      <div>
+        <Form form={form} layout={'vertical'}>
+          <SchemaField schema={schema} />
+        </Form>
+      </div>
+    </Modal>
+  );
+};
+
+export default Save;

+ 15 - 0
src/pages/media/Device/Channel/Tree/index.less

@@ -0,0 +1,15 @@
+.channel-tree {
+  height: 100%;
+  margin-right: 16px;
+  padding: 20px;
+  background-color: #fff;
+  border-radius: 2px;
+
+  .channel-tree-query {
+    margin-bottom: 16px;
+  }
+
+  .channel-tree-content {
+    min-height: 100%;
+  }
+}

+ 34 - 0
src/pages/media/Device/Channel/Tree/index.tsx

@@ -0,0 +1,34 @@
+import { Tree, Input } from 'antd';
+import { useRequest } from 'umi';
+import { service } from '../index';
+import { useEffect } from 'react';
+import './index.less';
+import { SearchOutlined } from '@ant-design/icons';
+
+interface TreeProps {
+  deviceId: string;
+}
+
+export default (props: TreeProps) => {
+  const { data: TreeData, run: getTreeData } = useRequest(service.queryTree, {
+    manual: true,
+    formatResult: (res) => res.result,
+  });
+
+  useEffect(() => {
+    if (props.deviceId) {
+      getTreeData(props.deviceId, {});
+    }
+  }, [props.deviceId]);
+
+  return (
+    <div className={'channel-tree'}>
+      <div className={'channel-tree-query'}>
+        <Input placeholder={'请输入目录名称'} suffix={<SearchOutlined />} />
+      </div>
+      <div className={'channel-tree-content'}>
+        <Tree height={500} treeData={TreeData} />
+      </div>
+    </div>
+  );
+};

+ 11 - 0
src/pages/media/Device/Channel/index.less

@@ -0,0 +1,11 @@
+.device-channel-warp {
+  display: flex;
+
+  .left {
+    width: 300px;
+  }
+
+  .right {
+    flex: 1;
+  }
+}

+ 258 - 0
src/pages/media/Device/Channel/index.tsx

@@ -0,0 +1,258 @@
+// 视频设备通道列表
+import { PageContainer } from '@ant-design/pro-layout';
+import ProTable, { ActionType, ProColumns } from '@jetlinks/pro-table';
+import SearchComponent from '@/components/SearchComponent';
+import './index.less';
+import { useEffect, useRef, useState } from 'react';
+import { ChannelItem } from '@/pages/media/Device/Channel/typings';
+import { useIntl, useLocation, useHistory } from 'umi';
+import { BadgeStatus } from '@/components';
+import { StatusColorEnum } from '@/components/BadgeStatus';
+import { Button, message, Popconfirm, Tooltip } from 'antd';
+import {
+  DeleteOutlined,
+  EditOutlined,
+  PlusOutlined,
+  VideoCameraOutlined,
+  VideoCameraAddOutlined,
+} from '@ant-design/icons';
+import Save from './Save';
+import Service from './service';
+import { ProviderValue } from '../index';
+import Live from './Live';
+import { getMenuPathByCode, MENUS_CODE } from '@/utils/menu';
+import Tree from './Tree';
+
+export const service = new Service('media');
+
+export default () => {
+  const intl = useIntl();
+  const actionRef = useRef<ActionType>();
+  const [queryParam, setQueryParam] = useState({});
+  const [visible, setVisible] = useState<boolean>(false);
+  const [liveVisible, setLiveVisible] = useState(false);
+  const [current, setCurrent] = useState<ChannelItem>();
+  const [deviceId, setDeviceId] = useState('');
+  const [type, setType] = useState('');
+
+  const location = useLocation();
+  const history = useHistory();
+
+  useEffect(() => {
+    const param = new URLSearchParams(location.search);
+    const _deviceId = param.get('id');
+    const _type = param.get('type');
+    if (_deviceId) {
+      setDeviceId(_deviceId);
+    }
+    if (_type) {
+      setType(_type);
+    }
+  }, [location]);
+
+  /**
+   * table 查询参数
+   * @param data
+   */
+  const searchFn = (data: any) => {
+    setQueryParam(data);
+  };
+
+  const deleteItem = async (id: string) => {
+    const resp: any = await service.removeChannel(id);
+    if (resp.status === 200) {
+      actionRef.current?.reload();
+    } else {
+      message.error('删除失败');
+    }
+  };
+
+  const columns: ProColumns<ChannelItem>[] = [
+    {
+      dataIndex: 'channelId',
+      title: '通道ID',
+      width: 220,
+    },
+    {
+      dataIndex: 'name',
+      title: intl.formatMessage({
+        id: 'pages.table.name',
+        defaultMessage: '名称',
+      }),
+      width: 220,
+    },
+    {
+      dataIndex: 'manufacturer',
+      title: intl.formatMessage({
+        id: 'pages.media.device.manufacturer',
+        defaultMessage: '设备厂家',
+      }),
+      hideInTable: type !== ProviderValue.GB281,
+    },
+    {
+      dataIndex: 'address',
+      title: '安装地址',
+    },
+    {
+      dataIndex: 'state',
+      title: intl.formatMessage({
+        id: 'pages.searchTable.titleStatus',
+        defaultMessage: '状态',
+      }),
+      render: (_, record) => (
+        <BadgeStatus
+          status={record.status.value}
+          statusNames={{
+            online: StatusColorEnum.success,
+            offline: StatusColorEnum.error,
+            notActive: StatusColorEnum.processing,
+          }}
+          text={record.status.text}
+        />
+      ),
+    },
+    {
+      title: intl.formatMessage({
+        id: 'pages.data.option',
+        defaultMessage: '操作',
+      }),
+      valueType: 'option',
+      align: 'center',
+      width: 200,
+      render: (_, record) => [
+        <Tooltip
+          key="edit"
+          title={intl.formatMessage({
+            id: 'pages.data.option.edit',
+            defaultMessage: '编辑',
+          })}
+        >
+          <a
+            onClick={() => {
+              setCurrent(record);
+              setVisible(true);
+            }}
+          >
+            <EditOutlined />
+          </a>
+        </Tooltip>,
+        <Tooltip key={'live'} title={'播发'}>
+          <a
+            onClick={() => {
+              setLiveVisible(true);
+            }}
+          >
+            <VideoCameraOutlined />
+          </a>
+        </Tooltip>,
+        <Tooltip key={'playback'} title={'回放'}>
+          <a
+            onClick={() => {
+              history.push(
+                `${getMenuPathByCode(MENUS_CODE['media/Device/Playback'])}?id=${record.channelId}`,
+              );
+            }}
+          >
+            <VideoCameraAddOutlined />
+          </a>
+        </Tooltip>,
+        type === ProviderValue.FIXED ? (
+          <Tooltip key={'updateChannel'} title="删除">
+            <Popconfirm
+              key="delete"
+              title={intl.formatMessage({
+                id: 'page.table.isDelete',
+                defaultMessage: '是否删除?',
+              })}
+              onConfirm={async () => {
+                deleteItem(record.id);
+              }}
+            >
+              <Button type={'link'} style={{ padding: '4px' }}>
+                <DeleteOutlined />
+              </Button>
+            </Popconfirm>
+          </Tooltip>
+        ) : null,
+      ],
+    },
+  ];
+
+  return (
+    <PageContainer>
+      <div className={'device-channel-warp'}>
+        <div className={'left'}>
+          <Tree deviceId={deviceId} />
+        </div>
+        <div className={'right'}>
+          <SearchComponent field={columns} onSearch={searchFn} />
+          <ProTable<ChannelItem>
+            columns={columns}
+            actionRef={actionRef}
+            options={{ fullScreen: true }}
+            params={queryParam}
+            defaultParams={[
+              {
+                column: 'id',
+              },
+            ]}
+            request={(params = {}) =>
+              service.queryChannel(deviceId, {
+                ...params,
+                sorts: [
+                  {
+                    name: 'createTime',
+                    order: 'desc',
+                  },
+                ],
+              })
+            }
+            rowKey="id"
+            search={false}
+            headerTitle={
+              type === ProviderValue.FIXED
+                ? [
+                    <Button
+                      onClick={() => {
+                        setCurrent(undefined);
+                        setVisible(true);
+                      }}
+                      key="button"
+                      icon={<PlusOutlined />}
+                      type="primary"
+                    >
+                      {intl.formatMessage({
+                        id: 'pages.data.option.add',
+                        defaultMessage: '新增',
+                      })}
+                    </Button>,
+                  ]
+                : null
+            }
+          />
+        </div>
+      </div>
+      <Save
+        visible={visible}
+        service={service}
+        model={current ? 'edit' : 'add'}
+        type={type}
+        data={current}
+        deviceId={deviceId}
+        onCancel={() => {
+          setVisible(false);
+        }}
+        onReload={() => {
+          actionRef.current?.reload();
+        }}
+      />
+      <Live
+        visible={liveVisible}
+        url={''}
+        onCancel={() => {
+          setLiveVisible(false);
+        }}
+      />
+    </PageContainer>
+  );
+};

+ 22 - 0
src/pages/media/Device/Channel/service.ts

@@ -0,0 +1,22 @@
+import BaseService from '@/utils/BaseService';
+import { request } from 'umi';
+import type { ChannelItem } from './typings';
+
+class Service extends BaseService<ChannelItem> {
+  //
+  queryTree = (id: string, data?: any) =>
+    request(`${this.uri}/device/${id}/catalog/_query/tree`, { method: 'POST', data });
+
+  // 查询设备通道列表
+  queryChannel = (id: string, data: any) =>
+    request(`${this.uri}/device/${id}/channel/_query`, { method: 'POST', data });
+
+  updateChannel = (id: string, data: any) =>
+    request(`${this.uri}/channel/${id}`, { method: 'PUT', data });
+
+  saveChannel = (data: any) => request(`${this.uri}/channel`, { method: 'POST', data });
+
+  removeChannel = (id: string) => request(`${this.uri}/channel/${id}`, { method: 'DELETE' });
+}
+
+export default Service;

+ 114 - 0
src/pages/media/Device/Channel/typings.d.ts

@@ -0,0 +1,114 @@
+export type CatalogItemType = {
+  district?: string;
+  device?: string;
+  platform?: string;
+  user?: string;
+  platform_outer?: string;
+  ext?: string;
+};
+
+export interface CatalogItem {
+  id: string;
+  channelId: string;
+  deviceId: string;
+  name: string;
+  type: CatalogItemType;
+  createTime: number;
+  modifyTime: number;
+  children?: CatalogItem[];
+}
+
+export type ChannelStatusType =
+  | 'online'
+  | 'lost'
+  | 'defect'
+  | 'add'
+  | 'delete'
+  | 'update'
+  | 'offline';
+
+export type PtzType = 'unknown' | 'ball' | 'hemisphere' | 'fixed' | 'remoteControl';
+
+export type CatalogType = keyof CatalogItemType;
+
+export type ChannelType =
+  | 'dv_no_storage'
+  | 'dv_has_storage'
+  | 'dv_decoder'
+  | 'networking_monitor_server'
+  | 'media_proxy'
+  | 'web_access_server'
+  | 'video_management_server'
+  | 'network_matrix'
+  | 'network_controller'
+  | 'network_alarm_machine'
+  | 'dvr'
+  | 'video_server'
+  | 'encoder'
+  | 'decoder'
+  | 'video_switching_matrix'
+  | 'audio_switching_matrix'
+  | 'alarm_controller'
+  | 'nvr'
+  | 'hvr'
+  | 'camera'
+  | 'ipc'
+  | 'display'
+  | 'alarm_input'
+  | 'alarm_output'
+  | 'audio_input'
+  | 'audio_output'
+  | 'mobile_trans'
+  | 'other_outer'
+  | 'center_server'
+  | 'web_server'
+  | 'media_dispatcher'
+  | 'proxy_server'
+  | 'secure_server'
+  | 'alarm_server'
+  | 'database_server'
+  | 'gis_server'
+  | 'management_server'
+  | 'gateway_server'
+  | 'media_storage_server'
+  | 'signaling_secure_gateway'
+  | 'business_group'
+  | 'virtual_group'
+  | 'center_user'
+  | 'end_user'
+  | 'media_iap'
+  | 'media_ops'
+  | 'district'
+  | 'other';
+
+export interface ChannelItem {
+  id: string;
+  deviceId: string;
+  deviceName: string;
+  channelId: string;
+  name: string;
+  manufacturer: string;
+  model: string;
+  address: string;
+  provider: string;
+  status: {
+    value: string;
+    text: string;
+  };
+  others: object;
+  description: string;
+  parentChannelId: string;
+  subCount: integer;
+  civilCode: string;
+  ptzType: PtzType;
+  catalogType: CatalogType;
+  channelType: ChannelType;
+  catalogCode: string;
+  longitude: number;
+  latitude: number;
+  createTime: number;
+  modifyTime: number;
+  parentId: string;
+  gb28181ProxyStream: boolean;
+  gb28181ChannelId: string;
+}

+ 6 - 0
src/pages/media/Device/Playback/index.tsx

@@ -0,0 +1,6 @@
+// 回放
+import { PageContainer } from '@ant-design/pro-layout';
+
+export default () => {
+  return <PageContainer>回放</PageContainer>;
+};

+ 78 - 0
src/pages/media/Device/Save/ProviderSelect.tsx

@@ -0,0 +1,78 @@
+import classNames from 'classnames';
+import { Badge } from 'antd';
+import { StatusColorEnum } from '@/components/BadgeStatus';
+import styles from '@/pages/link/AccessConfig/index.less';
+import { TableCard } from '@/components';
+import './providerSelect.less';
+
+interface ProviderProps {
+  value?: string;
+  options?: any[];
+  onChange?: (id: string) => void;
+  onSelect?: (id: string, rowData: any) => void;
+}
+
+const defaultImage = require('/public/images/device-access.png');
+
+export default (props: ProviderProps) => {
+  return (
+    <div className={'provider-list'}>
+      {props.options && props.options.length
+        ? props.options.map((item) => (
+            <div
+              onClick={() => {
+                if (props.onChange) {
+                  props.onChange(item.id);
+                }
+
+                if (props.onSelect) {
+                  props.onSelect(item.id, item);
+                }
+              }}
+              style={{ padding: 16 }}
+            >
+              <TableCard
+                className={classNames({ active: item.id === props.value })}
+                showMask={false}
+                showTool={false}
+                status={item.state.value}
+                statusText={item.state.text}
+                statusNames={{
+                  enabled: StatusColorEnum.processing,
+                  disabled: StatusColorEnum.error,
+                }}
+              >
+                <div className={styles.context}>
+                  <div>
+                    <img width={88} height={88} src={defaultImage} alt={''} />
+                  </div>
+                  <div className={styles.card}>
+                    <div className={styles.header}>
+                      <div className={styles.title}>{item.name || '--'}</div>
+                      <div className={styles.desc}>{item.description || '--'}</div>
+                    </div>
+                    <div className={styles.container}>
+                      <div className={styles.server}>
+                        <div className={styles.subTitle}>{item?.channelInfo?.name || '--'}</div>
+                        <div style={{ width: '100%' }}>
+                          {item.channelInfo?.addresses.map((i: any, index: number) => (
+                            <p key={i.address + `_address${index}`}>
+                              <Badge color={i.health === -1 ? 'red' : 'green'} text={i.address} />
+                            </p>
+                          ))}
+                        </div>
+                      </div>
+                      <div className={styles.procotol}>
+                        <div className={styles.subTitle}>{item?.protocolDetail?.name || '--'}</div>
+                        <p>{item.protocolDetail?.description || '--'}</p>
+                      </div>
+                    </div>
+                  </div>
+                </div>
+              </TableCard>
+            </div>
+          ))
+        : null}
+    </div>
+  );
+};

+ 135 - 0
src/pages/media/Device/Save/SaveProduct.tsx

@@ -0,0 +1,135 @@
+import { useEffect, useState } from 'react';
+import { service } from '../index';
+import { useRequest } from 'umi';
+import { Form, Input, message, Modal } from 'antd';
+import ProviderItem from './ProviderSelect';
+
+interface SaveProps {
+  visible: boolean;
+  reload: (id: string, data: any) => void;
+  type: string;
+  close?: () => void;
+}
+
+export default (props: SaveProps) => {
+  const { visible, close, reload } = props;
+  const [loading, setLoading] = useState(false);
+  const [form] = Form.useForm();
+
+  const { data: providerList, run: getProviderList } = useRequest(service.queryProvider, {
+    manual: true,
+    formatResult: (response) => response.result.data,
+  });
+
+  useEffect(() => {
+    if (visible) {
+      getProviderList({
+        terms: [{ column: 'provider', value: props.type }],
+      });
+    }
+  }, [visible]);
+
+  useEffect(() => {
+    if (form) {
+      form.setFieldsValue({ accessProvider: props.type });
+    }
+  }, [props.type]);
+
+  const onClose = () => {
+    form.resetFields();
+    if (close) {
+      close();
+    }
+  };
+
+  const onSubmit = async () => {
+    const formData = await form.validateFields();
+    if (formData) {
+      setLoading(true);
+      const resp = await service.saveProduct(formData);
+      if (resp.status === 200) {
+        //  新增成功之后 发布产品
+        const deployResp = await service.deployProductById(resp.result.id);
+        setLoading(false);
+        if (deployResp.status === 200) {
+          if (reload) {
+            reload(resp.result.id, resp.result.name);
+          }
+          onClose();
+        } else {
+          message.error('新增失败');
+        }
+      } else {
+        setLoading(false);
+        message.error('新增失败');
+      }
+    }
+  };
+
+  return (
+    <Modal
+      maskClosable={false}
+      mask={false}
+      visible={visible}
+      width={660}
+      confirmLoading={loading}
+      title={'快速添加'}
+      onOk={onSubmit}
+      onCancel={onClose}
+    >
+      <Form
+        form={form}
+        layout={'vertical'}
+        labelCol={{
+          style: { width: 100 },
+        }}
+      >
+        <Form.Item
+          name={'name'}
+          label={'产品名称'}
+          required
+          rules={[
+            { required: true, message: '请输入产品名称' },
+            { max: 64, message: '最多可输入64个字符' },
+          ]}
+        >
+          <Input placeholder={'请输入产品名称'} />
+        </Form.Item>
+        <Form.Item
+          name={'accessId'}
+          label={'接入网关'}
+          required
+          rules={[{ required: true, message: '请选择接入网关' }]}
+        >
+          <ProviderItem
+            options={providerList}
+            onSelect={(_, rowData) => {
+              form.setFieldsValue({
+                accessName: rowData.name,
+                protocolName: rowData.protocolDetail.name,
+                messageProtocol: rowData.protocolDetail.id,
+                transportProtocol: rowData.transportDetail.id,
+                accessProvider: rowData.provider,
+              });
+            }}
+          />
+        </Form.Item>
+        <Form.Item name={'accessName'} hidden>
+          <Input />
+        </Form.Item>
+        <Form.Item name={'messageProtocol'} hidden>
+          <Input />
+        </Form.Item>
+        <Form.Item name={'protocolName'} hidden>
+          <Input />
+        </Form.Item>
+        <Form.Item name={'transportProtocol'} hidden>
+          <Input />
+        </Form.Item>
+        <Form.Item name={'accessProvider'} hidden>
+          <Input />
+        </Form.Item>
+      </Form>
+    </Modal>
+  );
+};

+ 325 - 0
src/pages/media/Device/Save/index.tsx

@@ -0,0 +1,325 @@
+import { useEffect, useState } from 'react';
+import { Button, Col, Form, Input, message, Modal, Radio, Row, Select } from 'antd';
+import { useIntl } from 'umi';
+import { RadioCard, UploadImage } from '@/components';
+import { PlusOutlined } from '@ant-design/icons';
+import { service } from '../index';
+import SaveProductModal from './SaveProduct';
+import type { DeviceItem } from '../typings';
+
+interface SaveProps {
+  visible: boolean;
+  close: () => void;
+  reload: () => void;
+  data?: DeviceItem;
+  model: 'add' | 'edit';
+}
+
+const DefaultAccessType = 'gb28181-2016';
+
+export default (props: SaveProps) => {
+  const { visible, close, data } = props;
+  const intl = useIntl();
+  const [form] = Form.useForm();
+  const [loading, setLoading] = useState(false);
+  const [productVisible, setProductVisible] = useState(false);
+  const [accessType, setAccessType] = useState(DefaultAccessType);
+  const [productList, setProductList] = useState<any[]>([]);
+
+  const getProductList = async (productParams: any) => {
+    const resp = await service.queryProductList(productParams);
+    if (resp.status === 200) {
+      setProductList(resp.result);
+    }
+  };
+
+  const queryProduct = async (value: string) => {
+    getProductList({
+      terms: [
+        { column: 'accessProvider', value: value },
+        { column: 'state', value: 1 },
+      ],
+    });
+  };
+
+  useEffect(() => {
+    if (visible) {
+      if (props.model === 'edit') {
+        form.setFieldsValue(data);
+        const _accessType = data?.provider || DefaultAccessType;
+        setAccessType(_accessType);
+
+        queryProduct(_accessType);
+      } else {
+        form.setFieldsValue({
+          provider: DefaultAccessType,
+        });
+        queryProduct(DefaultAccessType);
+        setAccessType(DefaultAccessType);
+      }
+    }
+  }, [visible]);
+
+  const handleSave = async () => {
+    const formData = await form.validateFields();
+    if (formData) {
+      const { provider, ...extraFormData } = formData;
+      setLoading(true);
+      const resp =
+        provider === DefaultAccessType
+          ? await service.saveGB(extraFormData)
+          : await service.saveFixed(extraFormData);
+      setLoading(false);
+      if (resp.status === 200) {
+        if (props.reload) {
+          props.reload();
+        }
+        form.resetFields();
+        close();
+        message.success('操作成功');
+      } else {
+        message.error('操作失败');
+      }
+    }
+  };
+
+  const intlFormat = (
+    id: string,
+    defaultMessage: string,
+    paramsID?: string,
+    paramsMessage?: string,
+  ) => {
+    const paramsObj: Record<string, string> = {};
+    if (paramsID) {
+      const paramsMsg = intl.formatMessage({
+        id: paramsID,
+        defaultMessage: paramsMessage,
+      });
+      paramsObj.name = paramsMsg;
+    }
+
+    return intl.formatMessage(
+      {
+        id,
+        defaultMessage,
+      },
+      paramsObj,
+    );
+  };
+
+  console.log(productList);
+
+  return (
+    <>
+      <Modal
+        maskClosable={false}
+        visible={visible}
+        onCancel={() => {
+          form.resetFields();
+          close();
+        }}
+        width={610}
+        title={intl.formatMessage({
+          id: `pages.data.option.${props.model || 'add'}`,
+          defaultMessage: '新增',
+        })}
+        confirmLoading={loading}
+        onOk={handleSave}
+      >
+        <Form
+          form={form}
+          layout={'vertical'}
+          labelCol={{
+            style: { width: 100 },
+          }}
+        >
+          <Row>
+            <Col span={24}>
+              <Form.Item
+                name={'provider'}
+                label={'接入方式'}
+                required
+                rules={[{ required: true, message: '请选择接入方式' }]}
+              >
+                <RadioCard
+                  model={'singular'}
+                  itemStyle={{ width: '50%' }}
+                  onSelect={(key) => {
+                    setAccessType(key);
+                    queryProduct(key);
+                  }}
+                  disabled={props.model === 'edit'}
+                  options={[
+                    {
+                      label: 'GB/T28181',
+                      value: DefaultAccessType,
+                    },
+                    {
+                      label: '固定地址',
+                      value: 'fixed-media',
+                    },
+                  ]}
+                />
+              </Form.Item>
+            </Col>
+          </Row>
+          <Row>
+            <Col span={8}>
+              <Form.Item name={'photoUrl'}>
+                <UploadImage />
+              </Form.Item>
+            </Col>
+            <Col span={16}>
+              <Form.Item
+                label={'ID'}
+                name={'id'}
+                required
+                rules={[{ required: true, message: '请输入ID' }, {}]}
+              >
+                <Input placeholder={'请输入ID'} disabled={props.model === 'edit'} />
+              </Form.Item>
+              <Form.Item
+                label={'设备名称'}
+                name={'name'}
+                required
+                rules={[
+                  { required: true, message: '请输入名称' },
+                  { max: 64, message: '最多可输入64个字符' },
+                ]}
+              >
+                <Input placeholder={'请输入设备名称'} />
+              </Form.Item>
+            </Col>
+          </Row>
+          <Row>
+            <Col span={24}>
+              <Form.Item
+                label={'所属产品'}
+                required
+                rules={[{ required: true, message: '请选择所属产品' }]}
+              >
+                <Form.Item name={'productId'} noStyle>
+                  <Select
+                    fieldNames={{
+                      label: 'name',
+                      value: 'id',
+                    }}
+                    disabled={props.model === 'edit'}
+                    options={productList}
+                    placeholder={'请选择所属产品'}
+                    style={{ width: props.model === 'edit' ? '100%' : 'calc(100% - 36px)' }}
+                  />
+                </Form.Item>
+                {props.model !== 'edit' && (
+                  <Form.Item noStyle>
+                    <Button
+                      type={'link'}
+                      style={{ padding: '4px 10px' }}
+                      onClick={() => {
+                        setProductVisible(true);
+                      }}
+                    >
+                      <PlusOutlined />
+                    </Button>
+                  </Form.Item>
+                )}
+              </Form.Item>
+            </Col>
+            {accessType === DefaultAccessType && (
+              <Col span={24}>
+                <Form.Item
+                  label={'接入密码'}
+                  name={'password'}
+                  required
+                  rules={[
+                    { required: true, message: '请输入接入密码' },
+                    { max: 64, message: '最大可输入64位' },
+                  ]}
+                >
+                  <Input.Password placeholder={'请输入接入密码'} />
+                </Form.Item>
+              </Col>
+            )}
+            {props.model === 'edit' && (
+              <>
+                <Col span={24}>
+                  <Form.Item
+                    label={'流传输模式'}
+                    name={'streamMode'}
+                    required
+                    rules={[{ required: true, message: '请选择流传输模式' }]}
+                  >
+                    <Radio.Group
+                      optionType="button"
+                      buttonStyle="solid"
+                      options={[
+                        { label: 'UDP', value: 'UDP' },
+                        { label: 'TCP', value: 'TCP' },
+                      ]}
+                    />
+                  </Form.Item>
+                </Col>
+                <Col span={24}>
+                  <Form.Item
+                    label={'设备厂商'}
+                    name={'manufacturer'}
+                    rules={[{ max: 64, message: '最多可输入64个字符' }]}
+                  >
+                    <Input placeholder={'请输入设备厂商'} />
+                  </Form.Item>
+                </Col>
+                <Col span={24}>
+                  <Form.Item
+                    label={'设备型号'}
+                    name={'model'}
+                    rules={[{ max: 64, message: '最多可输入64个字符' }]}
+                  >
+                    <Input placeholder={'请输入设备型号'} />
+                  </Form.Item>
+                </Col>
+                <Col span={24}>
+                  <Form.Item
+                    label={'固件版本'}
+                    name={'firmware'}
+                    rules={[{ max: 64, message: '最多可输入64个字符' }]}
+                  >
+                    <Input placeholder={'请输入固件版本'} />
+                  </Form.Item>
+                </Col>
+              </>
+            )}
+            <Col span={24}>
+              <Form.Item label={'说明'} name={'description'}>
+                <Input.TextArea
+                  placeholder={intlFormat('pages.form.tip.input', '请输入')}
+                  rows={4}
+                  style={{ width: '100%' }}
+                  maxLength={200}
+                  showCount={true}
+                />
+              </Form.Item>
+            </Col>
+          </Row>
+          <Form.Item name={'id'} hidden>
+            <Input />
+          </Form.Item>
+        </Form>
+      </Modal>
+      <SaveProductModal
+        visible={productVisible}
+        type={accessType}
+        close={() => {
+          setProductVisible(false);
+        }}
+        reload={(productId: string, name: string) => {
+          form.setFieldsValue({ productId });
+          productList.push({
+            id: productId,
+            name,
+          });
+          setProductList([...productList]);
+        }}
+      />
+    </>
+  );
+};

+ 12 - 0
src/pages/media/Device/Save/providerSelect.less

@@ -0,0 +1,12 @@
+@import '~antd/es/style/themes/default.less';
+
+.provider-list {
+  max-height: 450px;
+  overflow-y: auto;
+
+  .active {
+    .card-warp {
+      border-color: @primary-color-active;
+    }
+  }
+}

+ 280 - 115
src/pages/media/Device/index.tsx

@@ -1,26 +1,83 @@
 // 视频设备列表
 import { PageContainer } from '@ant-design/pro-layout';
-import { useRef } from 'react';
+import { useRef, useState } from 'react';
 import type { ActionType, ProColumns } from '@jetlinks/pro-table';
-import { Tooltip } from 'antd';
-import { ArrowDownOutlined, BugOutlined, EditOutlined, MinusOutlined } from '@ant-design/icons';
-import BaseCrud from '@/components/BaseCrud';
-import BaseService from '@/utils/BaseService';
+import { Button, message, Popconfirm, Tooltip } from 'antd';
+import {
+  DeleteOutlined,
+  EditOutlined,
+  PlusOutlined,
+  SyncOutlined,
+  PartitionOutlined,
+} from '@ant-design/icons';
 import type { DeviceItem } from '@/pages/media/Device/typings';
-import { useIntl } from '@@/plugin-locale/localeExports';
-import { BadgeStatus } from '@/components';
+import { useIntl, useHistory } from 'umi';
+import { BadgeStatus, ProTableCard } from '@/components';
 import { StatusColorEnum } from '@/components/BadgeStatus';
+import SearchComponent from '@/components/SearchComponent';
+import MediaDevice from '@/components/ProTableCard/CardItems/mediaDevice';
+import { getMenuPathByCode, MENUS_CODE } from '@/utils/menu';
+import Service from './service';
+import Save from './Save';
+
+export const service = new Service('media/device');
+
+const providerType = {
+  'gb28181-2016': 'GB/T28181',
+  'fixed-media': '固定地址',
+};
+
+export const ProviderValue = {
+  GB281: 'gb28181-2016',
+  FIXED: 'fixed-media',
+};
 
-export const service = new BaseService<DeviceItem>('media/device');
 const Device = () => {
   const intl = useIntl();
   const actionRef = useRef<ActionType>();
+  const [visible, setVisible] = useState<boolean>(false);
+  const [current, setCurrent] = useState<DeviceItem>();
+  const [queryParam, setQueryParam] = useState({});
+  const history = useHistory<Record<string, string>>();
+
+  /**
+   * table 查询参数
+   * @param data
+   */
+  const searchFn = (data: any) => {
+    setQueryParam(data);
+  };
+
+  const deleteItem = async (id: string) => {
+    const response: any = await service.remove(id);
+    if (response.status === 200) {
+      message.success(
+        intl.formatMessage({
+          id: 'pages.data.option.success',
+          defaultMessage: '操作成功!',
+        }),
+      );
+    }
+    actionRef.current?.reload();
+  };
+
+  /**
+   * 更新通道
+   * @param id 视频设备ID
+   */
+  const updateChannel = async (id: string) => {
+    const resp = await service.updateChannels(id);
+    if (resp.status === 200) {
+      message.success('通道更新成功');
+    } else {
+      message.error('通道更新失败');
+    }
+  };
 
   const columns: ProColumns<DeviceItem>[] = [
     {
-      dataIndex: 'index',
-      valueType: 'indexBorder',
-      width: 48,
+      dataIndex: 'id',
+      title: 'ID',
     },
     {
       dataIndex: 'name',
@@ -29,73 +86,48 @@ const Device = () => {
         defaultMessage: '名称',
       }),
     },
-    // {
-    //   dataIndex: 'transport',
-    //   title: intl.formatMessage({
-    //     id: 'pages.media.device.transport',
-    //     defaultMessage: '信令传输',
-    //   }),
-    // },
-    // {
-    //   dataIndex: 'streamMode',
-    //   title: intl.formatMessage({
-    //     id: 'pages.media.device.streamMode',
-    //     defaultMessage: '流传输模式',
-    //   }),
-    // },
-    // {
-    //   dataIndex: 'channelNumber',
-    //   title: intl.formatMessage({
-    //     id: 'pages.media.device.channelNumber',
-    //     defaultMessage: '通道数',
-    //   }),
-    // },
-    // {
-    //   dataIndex: 'host',
-    //   title: 'IP',
-    // },
-    // {
-    //   dataIndex: '端口',
-    //   title: intl.formatMessage({
-    //     id: 'pages.media.device.port',
-    //     defaultMessage: '端口',
-    //   }),
-    // },
-    // {
-    //   dataIndex: 'manufacturer',
-    //   title: intl.formatMessage({
-    //     id: 'pages.media.device.manufacturer',
-    //     defaultMessage: '设备厂家',
-    //   }),
-    // },
-    // {
-    //   dataIndex: 'model',
-    //   title: intl.formatMessage({
-    //     id: 'pages.media.device.model',
-    //     defaultMessage: '型号',
-    //   }),
-    // },
-    // {
-    //   dataIndex: 'firmware',
-    //   title: intl.formatMessage({
-    //     id: 'pages.media.device.firmware',
-    //     defaultMessage: '固件版本',
-    //   }),
-    // },
-    // {
-    //   dataIndex: 'networkType',
-    //   title: intl.formatMessage({
-    //     id: 'pages.table.type',
-    //     defaultMessage: '类型',
-    //   }),
-    // },
+    {
+      dataIndex: 'provider',
+      title: '接入方式',
+      render: (_, row) => {
+        return providerType[row.provider];
+      },
+    },
+    {
+      dataIndex: 'channelNumber',
+      title: intl.formatMessage({
+        id: 'pages.media.device.channelNumber',
+        defaultMessage: '通道数',
+      }),
+    },
+    {
+      dataIndex: 'manufacturer',
+      title: intl.formatMessage({
+        id: 'pages.media.device.manufacturer',
+        defaultMessage: '设备厂家',
+      }),
+    },
+    {
+      dataIndex: 'model',
+      title: intl.formatMessage({
+        id: 'pages.media.device.model',
+        defaultMessage: '型号',
+      }),
+    },
+    {
+      dataIndex: 'firmware',
+      title: intl.formatMessage({
+        id: 'pages.media.device.firmware',
+        defaultMessage: '固件版本',
+      }),
+    },
     {
       dataIndex: 'state',
       title: intl.formatMessage({
         id: 'pages.searchTable.titleStatus',
         defaultMessage: '状态',
       }),
-      render: (text, record) => (
+      render: (_, record) => (
         <BadgeStatus
           status={record.state.value}
           statusNames={{
@@ -103,7 +135,7 @@ const Device = () => {
             offline: StatusColorEnum.error,
             notActive: StatusColorEnum.processing,
           }}
-          text={text}
+          text={record.state.text}
         />
       ),
     },
@@ -116,63 +148,196 @@ const Device = () => {
       align: 'center',
       width: 200,
       render: (text, record) => [
-        <a onClick={() => console.log(record)}>
-          <Tooltip
-            title={intl.formatMessage({
-              id: 'pages.data.option.edit',
-              defaultMessage: '编辑',
-            })}
+        <Tooltip
+          key="edit"
+          title={intl.formatMessage({
+            id: 'pages.data.option.edit',
+            defaultMessage: '编辑',
+          })}
+        >
+          <a
+            onClick={() => {
+              setCurrent(record);
+              setVisible(true);
+            }}
           >
             <EditOutlined />
-          </Tooltip>
-        </a>,
-        <a>
-          <Tooltip
-            title={intl.formatMessage({
-              id: 'pages.data.option.remove',
-              defaultMessage: '删除',
-            })}
+          </a>
+        </Tooltip>,
+        <Tooltip key={'viewChannel'} title="查看通道">
+          <a
+            onClick={() => {
+              history.push(
+                `${getMenuPathByCode(MENUS_CODE['media/Device/Channel'])}?id=${record.id}&type=${
+                  record.provider
+                }`,
+              );
+            }}
           >
-            <MinusOutlined />
-          </Tooltip>
-        </a>,
-        <a>
-          <Tooltip
-            title={intl.formatMessage({
-              id: 'pages.data.option.download',
-              defaultMessage: '下载配置',
-            })}
+            <PartitionOutlined />
+          </a>
+        </Tooltip>,
+        <Tooltip key={'updateChannel'} title="更新通道">
+          <Button
+            style={{ padding: '4px' }}
+            type={'link'}
+            disabled={record.state.value === 'offline'}
+            onClick={() => {
+              updateChannel(record.id);
+            }}
           >
-            <ArrowDownOutlined />
-          </Tooltip>
-        </a>,
-        <a>
-          <Tooltip
+            <SyncOutlined />
+          </Button>
+        </Tooltip>,
+        <Tooltip key={'updateChannel'} title="删除">
+          <Popconfirm
+            key="delete"
             title={intl.formatMessage({
-              id: 'pages.notice.option.debug',
-              defaultMessage: '调试',
+              id:
+                record.state.value === 'offline'
+                  ? 'pages.device.productDetail.deleteTip'
+                  : 'page.table.isDelete',
+              defaultMessage: '是否删除?',
             })}
+            onConfirm={async () => {
+              if (record.state.value !== 'offline') {
+                await deleteItem(record.id);
+              } else {
+                message.error('在线设备不能进行删除操作');
+              }
+            }}
           >
-            <BugOutlined />
-          </Tooltip>
-        </a>,
+            <Button
+              type={'link'}
+              style={{ padding: '4px' }}
+              disabled={record.state.value !== 'offline'}
+            >
+              <DeleteOutlined />
+            </Button>
+          </Popconfirm>
+        </Tooltip>,
       ],
     },
   ];
 
-  const schema = {};
   return (
     <PageContainer>
-      <BaseCrud
+      <SearchComponent field={columns} onSearch={searchFn} />
+      <ProTableCard<DeviceItem>
         columns={columns}
-        service={service}
-        search={false}
-        title={intl.formatMessage({
-          id: 'pages.media.device',
-          defaultMessage: '模拟测试',
-        })}
-        schema={schema}
         actionRef={actionRef}
+        options={{ fullScreen: true }}
+        params={queryParam}
+        request={(params = {}) =>
+          service.query({
+            ...params,
+            sorts: [
+              {
+                name: 'createTime',
+                order: 'desc',
+              },
+            ],
+          })
+        }
+        rowKey="id"
+        search={false}
+        headerTitle={[
+          <Button
+            onClick={() => {
+              setCurrent(undefined);
+              setVisible(true);
+            }}
+            key="button"
+            icon={<PlusOutlined />}
+            type="primary"
+          >
+            {intl.formatMessage({
+              id: 'pages.data.option.add',
+              defaultMessage: '新增',
+            })}
+          </Button>,
+        ]}
+        cardRender={(record) => (
+          <MediaDevice
+            {...record}
+            actions={[
+              <Button
+                key="edit"
+                onClick={() => {
+                  setCurrent(record);
+                  setVisible(true);
+                }}
+                type={'link'}
+                style={{ padding: 0 }}
+              >
+                <EditOutlined />
+                {intl.formatMessage({
+                  id: 'pages.data.option.edit',
+                  defaultMessage: '编辑',
+                })}
+              </Button>,
+              <Button
+                key={'viewChannel'}
+                onClick={() => {
+                  history.push(
+                    `${getMenuPathByCode(MENUS_CODE['media/Device/Channel'])}?id=${
+                      record.id
+                    }&type=${record.provider}`,
+                  );
+                }}
+              >
+                <PartitionOutlined />
+                查看通道
+              </Button>,
+              <Button
+                key={'updateChannel'}
+                disabled={record.state.value === 'offline'}
+                onClick={() => {
+                  updateChannel(record.id);
+                }}
+              >
+                <SyncOutlined />
+                更新通道
+              </Button>,
+              <Popconfirm
+                key="delete"
+                title={intl.formatMessage({
+                  id:
+                    record.state.value === 'offline'
+                      ? 'pages.device.productDetail.deleteTip'
+                      : 'page.table.isDelete',
+                  defaultMessage: '是否删除?',
+                })}
+                onConfirm={async () => {
+                  if (record.state.value !== 'offline') {
+                    await deleteItem(record.id);
+                  } else {
+                    message.error('在线设备不能进行删除操作');
+                  }
+                }}
+              >
+                <Button
+                  type={'link'}
+                  style={{ padding: 0 }}
+                  disabled={record.state.value !== 'offline'}
+                >
+                  <DeleteOutlined />
+                </Button>
+              </Popconfirm>,
+            ]}
+          />
+        )}
+      />
+      <Save
+        model={!current ? 'add' : 'edit'}
+        data={current}
+        close={() => {
+          setVisible(false);
+        }}
+        reload={() => {
+          actionRef.current?.reload();
+        }}
+        visible={visible}
       />
     </PageContainer>
   );

+ 33 - 0
src/pages/media/Device/service.ts

@@ -0,0 +1,33 @@
+import BaseService from '@/utils/BaseService';
+import { request } from 'umi';
+import SystemConst from '@/utils/const';
+import type { DeviceItem } from './typings';
+
+class Service extends BaseService<DeviceItem> {
+  // 新增GB28181接入的设备
+  saveGB = (data?: any) => request(`${this.uri}/gb28181`, { method: 'PATCH', data });
+
+  // 新增固定地址接入的设备
+  saveFixed = (data?: any) => request(`${this.uri}/fixed-url`, { method: 'PATCH', data });
+
+  // 更新通道
+  updateChannels = (id: string) => request(`${this.uri}/${id}/channels/_sync`, { method: 'POST' });
+
+  // 快速添加产品
+  saveProduct = (data?: any) =>
+    request(`/${SystemConst.API_BASE}/device/product`, { method: 'POST', data });
+
+  // 产品发布
+  deployProductById = (id: string) =>
+    request(`/${SystemConst.API_BASE}/device/product/${id}/deploy`, { method: 'POST' });
+
+  // 查询产品列表
+  queryProductList = (data?: any) =>
+    request(`/${SystemConst.API_BASE}/device/product/_query/no-paging`, { method: 'POST', data });
+
+  // 查询设备接入配置
+  queryProvider = (data?: any) =>
+    request(`/${SystemConst.API_BASE}/gateway/device/detail/_query`, { method: 'POST', data });
+}
+
+export default Service;

+ 2 - 1
src/pages/media/Device/typings.d.ts

@@ -1,6 +1,7 @@
 import type { BaseItem, State } from '@/utils/typings';
 
-type DeviceItem = {
+export type DeviceItem = {
+  photoUrl?: string;
   channelNumber: number;
   createTime: number;
   firmware: string;

+ 42 - 87
src/pages/media/Stream/Detail/index.tsx

@@ -16,64 +16,8 @@ import { useEffect, useState } from 'react';
 import { service, StreamModel } from '@/pages/media/Stream';
 import { useParams } from 'umi';
 import { QuestionCircleOutlined } from '@ant-design/icons';
-
-const re =
-  /^([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])$/;
-
-// API host
-interface APIComponentProps {
-  onChange?: (data: any) => void;
-  value?: {
-    apiHost?: string;
-    apiPort?: number;
-  };
-}
-
-const APIComponent = (props: APIComponentProps) => {
-  const { value, onChange } = props;
-  const [data, setData] = useState<{ apiHost?: string; apiPort?: number } | undefined>(value);
-
-  useEffect(() => {
-    setData(value);
-  }, [value]);
-
-  return (
-    <div style={{ display: 'flex', alignItems: 'center' }}>
-      <Input
-        onChange={(e) => {
-          if (onChange) {
-            const item = {
-              ...data,
-              apiHost: e.target.value,
-            };
-            setData(item);
-            onChange(item);
-          }
-        }}
-        value={data?.apiHost}
-        style={{ marginRight: 10 }}
-        placeholder="请输入API Host"
-      />
-      <InputNumber
-        style={{ minWidth: 150 }}
-        value={data?.apiPort}
-        min={1}
-        max={65535}
-        onChange={(e: number) => {
-          if (onChange) {
-            const item = {
-              ...data,
-              apiPort: e,
-            };
-            setData(item);
-            onChange(item);
-          }
-        }}
-      />
-    </div>
-  );
-};
-
+import SipComponent from '@/components/SipComponent';
+import { testIP } from '@/utils/util';
 interface RTPComponentProps {
   onChange?: (data: any) => void;
   value?: {
@@ -224,15 +168,23 @@ const Detail = () => {
     }
   }, [params.id]);
 
-  const checkAPI = (_: any, value: { apiHost: string; apiPort: number }) => {
-    if (Number(value.apiPort) < 1 || Number(value.apiPort) > 65535) {
-      return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
-    }
-    if (!re.test(value.apiHost)) {
+  const checkSIP = (_: any, value: { host: string; port: number }) => {
+    if (!value || !value.host) {
+      return Promise.reject(new Error('请输入API HOST'));
+    } else if (value?.host && !testIP(value.host)) {
       return Promise.reject(new Error('请输入正确的IP地址'));
+    } else if (!value?.port) {
+      return Promise.reject(new Error('请输入端口'));
+    } else if ((value?.port && Number(value.port) < 1) || Number(value.port) > 65535) {
+      return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
     }
     return Promise.resolve();
   };
+
+  const testPort = (value: any) => {
+    return (value && Number(value) < 1) || Number(value) > 65535;
+  };
+
   const checkRIP = (
     _: any,
     value: {
@@ -242,26 +194,31 @@ const Detail = () => {
       dynamicRtpPortRange: number[];
     },
   ) => {
-    if (!re.test(value.rtpIp)) {
+    if (!value || !value.rtpIp) {
+      return Promise.reject(new Error('请输入RTP IP'));
+    } else if (value?.rtpIp && !testIP(value.rtpIp)) {
       return Promise.reject(new Error('请输入正确的IP地址'));
-    }
-    if (value.dynamicRtpPort) {
+    } else if (!value.dynamicRtpPort) {
+      if (value.rtpIp && !testIP(value.rtpIp)) {
+        return Promise.reject(new Error('请输入正确的IP地址'));
+      }
+      if (!value?.rtpPort) {
+        return Promise.reject(new Error('请输入端口'));
+      }
+      if (testPort(value?.rtpPort)) {
+        return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
+      }
+    } else if (value.dynamicRtpPort) {
       if (value.dynamicRtpPortRange) {
-        if (value.dynamicRtpPortRange?.[0]) {
-          if (
-            Number(value.dynamicRtpPortRange?.[0]) < 1 ||
-            Number(value.dynamicRtpPortRange?.[0]) > 65535
-          ) {
-            return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
-          }
+        if (!value.dynamicRtpPortRange?.[0]) {
+          return Promise.reject(new Error('请输入起始端口'));
+        } else if (testPort(value.dynamicRtpPortRange?.[0])) {
+          return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
         }
-        if (value.dynamicRtpPortRange?.[1]) {
-          if (
-            Number(value.dynamicRtpPortRange?.[1]) < 1 ||
-            Number(value.dynamicRtpPortRange?.[1]) > 65535
-          ) {
-            return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
-          }
+        if (!value.dynamicRtpPortRange?.[1]) {
+          return Promise.reject(new Error('请输入终止端口'));
+        } else if (testPort(value.dynamicRtpPortRange?.[1])) {
+          return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
         }
         if (
           value.dynamicRtpPortRange?.[0] &&
@@ -270,10 +227,8 @@ const Detail = () => {
         ) {
           return Promise.reject(new Error('终止端口需大于等于起始端'));
         }
-      }
-    } else {
-      if (Number(value.rtpPort) < 1 || Number(value.rtpPort) > 65535) {
-        return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
+      } else if (!value.dynamicRtpPortRange) {
+        return Promise.reject(new Error('请输入端口'));
       }
     }
     return Promise.resolve();
@@ -370,9 +325,9 @@ const Detail = () => {
                   </span>
                 }
                 name="api"
-                rules={[{ required: true, message: '请输入API Host' }, { validator: checkAPI }]}
+                rules={[{ required: true }, { validator: checkSIP }]}
               >
-                <APIComponent />
+                <SipComponent />
               </Form.Item>
             </Col>
             <Col span={24}>
@@ -389,7 +344,7 @@ const Detail = () => {
                   </span>
                 }
                 name="rtp"
-                rules={[{ required: true, message: '请输入RTP IP' }, { validator: checkRIP }]}
+                rules={[{ required: true }, { validator: checkRIP }]}
               >
                 <RTPComponent />
               </Form.Item>

+ 1 - 0
src/pages/notice/Config/typings.d.ts

@@ -31,5 +31,6 @@ type ConfigMetadata = {
     type: string;
     expands?: Record<string, any>;
   };
+  properties: ConfigProperty[];
   scopes: any[];
 };

+ 13 - 4
src/pages/rule-engine/Instance/index.tsx

@@ -11,13 +11,14 @@ import {
   PlusOutlined,
   StopOutlined,
 } from '@ant-design/icons';
-import { Badge, Button, message, Popconfirm, Tooltip } from 'antd';
+import { Button, message, Popconfirm, Tooltip } from 'antd';
 import { useIntl } from '@@/plugin-locale/localeExports';
 import SearchComponent from '@/components/SearchComponent';
-import { ProTableCard } from '@/components';
+import { BadgeStatus, ProTableCard } from '@/components';
 import RuleInstanceCard from '@/components/ProTableCard/CardItems/ruleInstance';
 import Save from '@/pages/rule-engine/Instance/Save';
 import SystemConst from '@/utils/const';
+import { StatusColorEnum } from '@/components/BadgeStatus';
 
 export const service = new Service('rule-engine/instance');
 
@@ -139,8 +140,16 @@ const Instance = () => {
     {
       dataIndex: 'state',
       title: '状态',
-      render: (text: any) => (
-        <Badge color={text?.value === 'stopped' ? 'red' : 'green'} text={text?.text} />
+      render: (text: any, record: any) => (
+        <BadgeStatus
+          status={record.state?.value}
+          text={record.state?.text}
+          statusNames={{
+            started: StatusColorEnum.success,
+            stopped: StatusColorEnum.error,
+            disable: StatusColorEnum.processing,
+          }}
+        />
       ),
       valueType: 'select',
       valueEnum: {

+ 1 - 0
src/pages/system/Role/Detail/Permission/index.tsx

@@ -76,6 +76,7 @@ const Permission = () => {
             .subscribe((resp) => {
               if (resp.status === 200) {
                 message.success('操作成功');
+                history.goBack();
               }
             });
         }}

+ 6 - 0
src/utils/menu/index.ts

@@ -22,6 +22,12 @@ const extraRouteObj = {
       { code: 'Channel', name: '选择通道' },
     ],
   },
+  'media/Device': {
+    children: [
+      { code: 'Channel', name: '通道列表' },
+      { code: 'Playback', name: '回放' },
+    ],
+  },
 };
 
 /**

+ 2 - 0
src/utils/menu/router.ts

@@ -45,6 +45,8 @@ export const MENUS_CODE = {
   'media/Cascade/Channel': 'media/Cascade/Channel',
   'media/Config': 'media/Config',
   'media/Device': 'media/Device',
+  'media/Device/Channel': 'media/Device/Channel',
+  'media/Device/Playback': 'media/Device/Playback',
   'media/Reveal': 'media/Reveal',
   'media/Stream': 'media/Stream',
   'media/Stream/Detail': 'media/Stream/Detail',