xieyonghong 3 лет назад
Родитель
Сommit
6371334966
50 измененных файлов с 3550 добавлено и 854 удалено
  1. BIN
      public/images/diagnose/status/error.png
  2. BIN
      public/images/diagnose/status/loading.png
  3. BIN
      public/images/diagnose/status/success.png
  4. BIN
      public/images/diagnose/status/warning.png
  5. 37 29
      src/components/BaseCrud/index.tsx
  6. 5 0
      src/components/Player/index.tsx
  7. 18 20
      src/components/ProTableCard/CardItems/ruleInstance.tsx
  8. 4 1
      src/components/Upload/index.tsx
  9. 34 26
      src/pages/device/Category/index.tsx
  10. 8 1
      src/pages/device/Instance/Detail/Config/Edit.tsx
  11. 78 0
      src/pages/device/Instance/Detail/Diagnose/Message/Dialog/index.less
  12. 61 0
      src/pages/device/Instance/Detail/Diagnose/Message/Dialog/index.tsx
  13. 56 0
      src/pages/device/Instance/Detail/Diagnose/Message/Log/index.less
  14. 56 0
      src/pages/device/Instance/Detail/Diagnose/Message/Log/index.tsx
  15. 22 0
      src/pages/device/Instance/Detail/Diagnose/Message/index.less
  16. 391 0
      src/pages/device/Instance/Detail/Diagnose/Message/index.tsx
  17. 73 0
      src/pages/device/Instance/Detail/Diagnose/Status/index.less
  18. 451 0
      src/pages/device/Instance/Detail/Diagnose/Status/index.tsx
  19. 27 0
      src/pages/device/Instance/Detail/Diagnose/index.less
  20. 184 0
      src/pages/device/Instance/Detail/Diagnose/index.tsx
  21. 285 0
      src/pages/device/Instance/Detail/MetadataMap/EditableTable/index.tsx
  22. 101 0
      src/pages/device/Instance/Detail/MetadataMap/index.tsx
  23. 27 1
      src/pages/device/Instance/Detail/index.tsx
  24. 199 160
      src/pages/device/Instance/index.tsx
  25. 79 0
      src/pages/device/Instance/service.ts
  26. 8 1
      src/pages/device/Product/Detail/Access/index.tsx
  27. 19 0
      src/pages/device/Product/Detail/index.tsx
  28. 146 111
      src/pages/device/Product/index.tsx
  29. 12 5
      src/pages/device/components/Metadata/Base/Edit/index.tsx
  30. 14 4
      src/pages/link/AccessConfig/index.tsx
  31. 38 18
      src/pages/link/Protocol/index.tsx
  32. 38 24
      src/pages/link/Type/index.tsx
  33. 145 122
      src/pages/media/Cascade/index.tsx
  34. 1 1
      src/pages/media/Device/Channel/index.tsx
  35. 73 1
      src/pages/media/Device/Playback/index.less
  36. 208 62
      src/pages/media/Device/Playback/index.tsx
  37. 13 3
      src/pages/media/Device/Playback/service.ts
  38. 205 0
      src/pages/media/Device/Playback/timeLine.tsx
  39. 5 0
      src/pages/media/Device/Playback/typings.d.ts
  40. 15 5
      src/pages/media/Device/Save/index.tsx
  41. 28 18
      src/pages/media/Stream/index.tsx
  42. 186 135
      src/pages/rule-engine/Instance/index.tsx
  43. 47 24
      src/pages/system/Department/index.tsx
  44. 22 14
      src/pages/system/Menu/index.tsx
  45. 55 44
      src/pages/system/Permission/index.tsx
  46. 20 7
      src/pages/system/Role/index.tsx
  47. 4 1
      src/pages/system/User/Save/index.tsx
  48. 31 15
      src/pages/system/User/index.tsx
  49. 9 1
      src/utils/menu/router.ts
  50. 12 0
      src/utils/util.ts

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


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


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


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


+ 37 - 29
src/components/BaseCrud/index.tsx

@@ -1,9 +1,9 @@
 import { useIntl } from '@@/plugin-locale/localeExports';
-import { Button, Dropdown } from 'antd';
+import { Button } from 'antd';
 import type { ActionType, ProColumns, RequestData } from '@jetlinks/pro-table';
 import ProTable from '@jetlinks/pro-table';
 
-import { EllipsisOutlined, PlusOutlined } from '@ant-design/icons';
+import { PlusOutlined } from '@ant-design/icons';
 import type BaseService from '@/utils/BaseService';
 import * as React from 'react';
 import { useRef, useState } from 'react';
@@ -47,6 +47,7 @@ export type Props<T> = {
   /** @name 用于存储搜索历史记录的标记*/
   moduleName?: string; //
   footer?: React.ReactNode;
+  disableAdd?: boolean;
 };
 
 const BaseCrud = <T extends Record<string, any>>(props: Props<T>) => {
@@ -55,15 +56,15 @@ const BaseCrud = <T extends Record<string, any>>(props: Props<T>) => {
   const {
     columns,
     service,
-    title,
-    menu,
+    // title,
+    // menu,
     schema,
     defaultParams,
     actionRef,
     schemaConfig,
     modelConfig,
     request,
-    toolBar,
+    // toolBar,
     pagination,
     search,
     formEffect,
@@ -78,16 +79,10 @@ const BaseCrud = <T extends Record<string, any>>(props: Props<T>) => {
       <SearchComponent<T>
         field={columns}
         onSearch={async (data) => {
-          // actionRef.current?.reset?.();
           actionRef.current?.setPageInfo?.({ pageSize: 10 });
           setParam(data);
         }}
         target={moduleName}
-        // onReset={() => {
-        //   // 重置分页及搜索参数
-        //   actionRef.current?.reset?.();
-        //   setParam({});
-        // }}
       />
       <ProTable<T>
         params={param}
@@ -125,25 +120,38 @@ const BaseCrud = <T extends Record<string, any>>(props: Props<T>) => {
               }
         }
         dateFormatter="string"
-        headerTitle={title}
-        defaultParams={defaultParams}
-        toolBarRender={() =>
-          toolBar || [
-            <Button onClick={CurdModel.add} key="button" icon={<PlusOutlined />} type="primary">
-              {intl.formatMessage({
-                id: 'pages.data.option.add',
-                defaultMessage: '新增',
-              })}
-            </Button>,
-            menu && (
-              <Dropdown key="menu" overlay={menu}>
-                <Button>
-                  <EllipsisOutlined />
-                </Button>
-              </Dropdown>
-            ),
-          ]
+        headerTitle={
+          <Button
+            disabled={props.disableAdd}
+            onClick={CurdModel.add}
+            key="button"
+            icon={<PlusOutlined />}
+            type="primary"
+          >
+            {intl.formatMessage({
+              id: 'pages.data.option.add',
+              defaultMessage: '新增',
+            })}
+          </Button>
         }
+        defaultParams={defaultParams}
+        // toolBarRender={() =>
+        //   toolBar || [
+        //     <Button onClick={CurdModel.add} key="button" icon={<PlusOutlined />} type="primary">
+        //       {intl.formatMessage({
+        //         id: 'pages.data.option.add',
+        //         defaultMessage: '新增',
+        //       })}
+        //     </Button>,
+        //     menu && (
+        //       <Dropdown key="menu" overlay={menu}>
+        //         <Button>
+        //           <EllipsisOutlined />
+        //         </Button>
+        //       </Dropdown>
+        //     ),
+        //   ]
+        // }
       />
       <Save
         reload={() => actionRef.current?.reload()}

+ 5 - 0
src/components/Player/index.tsx

@@ -9,12 +9,14 @@ export type PlayerProps = {
   poster?: string;
   timeout?: number;
   className?: string;
+  loading?: boolean;
   onDestroy?: () => void;
   onMessage?: (msg: any) => void;
   onError?: (err: any) => void;
   onTimeUpdate?: (time: any) => void;
   onPause?: () => void;
   onPlay?: () => void;
+  protocol?: 'mp4' | 'flv' | 'hls';
   onFullscreen?: () => void;
   onSnapOutside?: (base64: any) => void;
   onSnapInside?: (base64: any) => void;
@@ -101,7 +103,10 @@ export default (props: PlayerProps) => {
         player.current = r;
         EventInit();
       }}
+      fluent
+      protocol={props.protocol || 'mp4'}
       class={props.className}
+      loading={props.loading}
       live={'live' in props ? props.live !== false : true}
       autoplay={'autoplay' in props ? props.autoplay !== false : true}
       muted={'muted' in props ? props.muted !== false : true}

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

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

+ 4 - 1
src/components/Upload/index.tsx

@@ -72,8 +72,11 @@ const FUpload = connect((props: Props) => {
         <Input
           placeholder={props.placeholder}
           // 如果display 有值的话,显示display 的值
-          value={(url as FileProperty)[props?.display || 'url']}
+          value={url && (url as FileProperty)[props?.display || 'url']}
           onChange={(value) => {
+            setUrl({
+              [props?.display || 'url']: value.target.value,
+            } as any);
             props.onChange({ [props?.display || '_a']: value.target.value, url: null, size: null });
           }}
           onClick={(e) => {

+ 34 - 26
src/pages/device/Category/index.tsx

@@ -11,6 +11,7 @@ import { model } from '@formily/reactive';
 import { observer } from '@formily/react';
 import type { Response } from '@/utils/typings';
 import SearchComponent from '@/components/SearchComponent';
+import { getButtonPermission } from '@/utils/menu';
 
 export const service = new Service('device/category');
 
@@ -63,12 +64,15 @@ const Category = observer(() => {
       valueType: 'option',
       width: 200,
       render: (text, record) => [
-        <a
+        <Button
           key={'edit'}
           onClick={() => {
             state.visible = true;
             state.current = record;
           }}
+          type="link"
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('device/Category', ['update', 'add'])}
         >
           <Tooltip
             title={intl.formatMessage({
@@ -78,13 +82,16 @@ const Category = observer(() => {
           >
             <EditOutlined />
           </Tooltip>
-        </a>,
-        <a
+        </Button>,
+        <Button
+          type="link"
+          style={{ padding: 0 }}
           key={'add-next'}
           onClick={() => {
             state.visible = true;
             state.parentId = record.id;
           }}
+          disabled={getButtonPermission('device/Category', ['update', 'add'])}
         >
           <Tooltip
             title={intl.formatMessage({
@@ -94,21 +101,25 @@ const Category = observer(() => {
           >
             <PlusOutlined />
           </Tooltip>
-        </a>,
-        <Popconfirm
-          key={'delete'}
-          onConfirm={async () => {
-            const resp = (await service.remove(record.id)) as Response<any>;
-            if (resp.status === 200) {
-              message.success('操作成功');
-            } else {
-              message.error('操作失败');
-            }
-            actionRef.current?.reload();
-          }}
-          title={'确认删除吗?'}
+        </Button>,
+        <Button
+          disabled={getButtonPermission('device/Category', ['delete'])}
+          type="link"
+          style={{ padding: 0 }}
         >
-          <a>
+          <Popconfirm
+            key={'delete'}
+            onConfirm={async () => {
+              const resp = (await service.remove(record.id)) as Response<any>;
+              if (resp.status === 200) {
+                message.success('操作成功');
+              } else {
+                message.error('操作失败');
+              }
+              actionRef.current?.reload();
+            }}
+            title={'确认删除吗?'}
+          >
             <Tooltip
               title={intl.formatMessage({
                 id: 'pages.data.option.remove',
@@ -117,8 +128,8 @@ const Category = observer(() => {
             >
               <DeleteOutlined />
             </Tooltip>
-          </a>
-        </Popconfirm>,
+          </Popconfirm>
+        </Button>,
       ],
     },
   ];
@@ -150,12 +161,9 @@ const Category = observer(() => {
         }}
         rowKey="id"
         columns={columns}
-        headerTitle={intl.formatMessage({
-          id: 'pages.device.category',
-          defaultMessage: '产品分类',
-        })}
-        toolBarRender={() => [
+        headerTitle={
           <Button
+            disabled={getButtonPermission('device/Category', ['add'])}
             onClick={() => (state.visible = true)}
             key="button"
             icon={<PlusOutlined />}
@@ -165,8 +173,8 @@ const Category = observer(() => {
               id: 'pages.data.option.add',
               defaultMessage: '新增',
             })}
-          </Button>,
-        ]}
+          </Button>
+        }
         pagination={false}
         actionRef={actionRef}
       />

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 199 - 160
src/pages/device/Instance/index.tsx

@@ -29,7 +29,7 @@ import { ProTableCard } from '@/components';
 import SystemConst from '@/utils/const';
 import Token from '@/utils/token';
 import DeviceCard from '@/components/ProTableCard/CardItems/device';
-import { getMenuPathByParams, MENUS_CODE, getButtonPermission } from '@/utils/menu';
+import { getButtonPermission, getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
 
 export const statusMap = new Map();
 statusMap.set('在线', 'success');
@@ -69,68 +69,45 @@ const Instance = () => {
   const intl = useIntl();
 
   const tools = (record: DeviceInstance) => [
-    <Tooltip
-      title={intl.formatMessage({
-        id: 'pages.data.option.detail',
-        defaultMessage: '查看',
-      })}
-      key={'detail'}
-    >
-      <Button
-        type={'link'}
-        style={{ padding: 0 }}
-        onClick={() => {
-          InstanceModel.current = record;
-          const url = getMenuPathByParams(MENUS_CODE['device/Instance/Detail'], record.id);
-          history.push(url);
-        }}
-      >
-        <EyeOutlined />
-      </Button>
-    </Tooltip>,
-    <Popconfirm
-      key={'state'}
-      title={intl.formatMessage({
-        id: `pages.data.option.${record.state.value !== 'notActive' ? 'disabled' : 'enabled'}.tips`,
-        defaultMessage: '确认禁用?',
-      })}
-      onConfirm={async () => {
-        if (record.state.value !== 'notActive') {
-          await service.undeployDevice(record.id);
-        } else {
-          await service.deployDevice(record.id);
-        }
-        message.success(
-          intl.formatMessage({
-            id: 'pages.data.option.success',
-            defaultMessage: '操作成功!',
-          }),
-        );
-        actionRef.current?.reload();
+    <Button
+      type={'link'}
+      style={{ padding: 0 }}
+      onClick={() => {
+        InstanceModel.current = record;
+        const url = getMenuPathByParams(MENUS_CODE['device/Instance/Detail'], record.id);
+        history.push(url);
       }}
+      disabled={getButtonPermission('device/Instance', ['view'])}
     >
       <Tooltip
         title={intl.formatMessage({
-          id: `pages.data.option.${record.state.value !== 'notActive' ? 'disabled' : 'enabled'}`,
-          defaultMessage: record.state.value !== 'notActive' ? '禁用' : '启用',
+          id: 'pages.data.option.detail',
+          defaultMessage: '查看',
         })}
+        key={'detail'}
       >
-        <Button type={'link'} style={{ padding: 0 }}>
-          {record.state.value !== 'notActive' ? <StopOutlined /> : <CheckCircleOutlined />}
-        </Button>
+        <EyeOutlined />
       </Tooltip>
-    </Popconfirm>,
-    <Popconfirm
-      title={intl.formatMessage({
-        id:
-          record.state.value === 'notActive'
-            ? 'pages.data.option.remove.tips'
-            : 'pages.device.instance.deleteTip',
-      })}
-      key={'delete'}
-      onConfirm={async () => {
-        if (record.state.value === 'notActive') {
-          await service.remove(record.id);
+    </Button>,
+    <Button
+      type={'link'}
+      style={{ padding: 0 }}
+      disabled={getButtonPermission('device/Product', ['action'])}
+    >
+      <Popconfirm
+        key={'state'}
+        title={intl.formatMessage({
+          id: `pages.data.option.${
+            record.state.value !== 'notActive' ? 'disabled' : 'enabled'
+          }.tips`,
+          defaultMessage: '确认禁用?',
+        })}
+        onConfirm={async () => {
+          if (record.state.value !== 'notActive') {
+            await service.undeployDevice(record.id);
+          } else {
+            await service.deployDevice(record.id);
+          }
           message.success(
             intl.formatMessage({
               id: 'pages.data.option.success',
@@ -138,22 +115,57 @@ const Instance = () => {
             }),
           );
           actionRef.current?.reload();
-        } else {
-          message.error(intl.formatMessage({ id: 'pages.device.instance.deleteTip' }));
-        }
-      }}
+        }}
+      >
+        <Tooltip
+          title={intl.formatMessage({
+            id: `pages.data.option.${record.state.value !== 'notActive' ? 'disabled' : 'enabled'}`,
+            defaultMessage: record.state.value !== 'notActive' ? '禁用' : '启用',
+          })}
+        >
+          {record.state.value !== 'notActive' ? <StopOutlined /> : <CheckCircleOutlined />}
+        </Tooltip>
+      </Popconfirm>{' '}
+    </Button>,
+
+    <Button
+      type={'link'}
+      style={{ padding: 0 }}
+      disabled={getButtonPermission('device/Instance', ['delete'])}
     >
-      <Tooltip
+      <Popconfirm
         title={intl.formatMessage({
-          id: 'pages.data.option.remove',
-          defaultMessage: '删除',
+          id:
+            record.state.value === 'notActive'
+              ? 'pages.data.option.remove.tips'
+              : 'pages.device.instance.deleteTip',
         })}
+        key={'delete'}
+        onConfirm={async () => {
+          if (record.state.value === 'notActive') {
+            await service.remove(record.id);
+            message.success(
+              intl.formatMessage({
+                id: 'pages.data.option.success',
+                defaultMessage: '操作成功!',
+              }),
+            );
+            actionRef.current?.reload();
+          } else {
+            message.error(intl.formatMessage({ id: 'pages.device.instance.deleteTip' }));
+          }
+        }}
       >
-        <Button type={'link'} style={{ padding: 0 }}>
+        <Tooltip
+          title={intl.formatMessage({
+            id: 'pages.data.option.remove',
+            defaultMessage: '删除',
+          })}
+        >
           <DeleteOutlined />
-        </Button>
-      </Tooltip>
-    </Popconfirm>,
+        </Tooltip>
+      </Popconfirm>
+    </Button>,
   ];
 
   const columns: ProColumns<DeviceInstance>[] = [
@@ -247,6 +259,7 @@ const Instance = () => {
     <Menu>
       <Menu.Item key="1">
         <Button
+          disabled={getButtonPermission('device/Instance', ['export'])}
           icon={<ExportOutlined />}
           type="default"
           onClick={() => {
@@ -258,6 +271,7 @@ const Instance = () => {
       </Menu.Item>
       <Menu.Item key="2">
         <Button
+          disabled={getButtonPermission('device/Instance', ['import'])}
           icon={<ImportOutlined />}
           onClick={() => {
             setImportVisible(true);
@@ -267,24 +281,30 @@ const Instance = () => {
         </Button>
       </Menu.Item>
       <Menu.Item key="4">
-        <Popconfirm
-          title={'确认激活全部设备?'}
-          onConfirm={() => {
-            setType('active');
-            const activeAPI = `/${
-              SystemConst.API_BASE
-            }/device-instance/deploy?:X_Access_Token=${Token.get()}`;
-            setApi(activeAPI);
-            setOperationVisible(true);
-          }}
+        <Button
+          disabled={getButtonPermission('device/Instance', ['active'])}
+          icon={<CheckCircleOutlined />}
+          type="primary"
+          ghost
         >
-          <Button icon={<CheckCircleOutlined />} type="primary" ghost>
+          <Popconfirm
+            title={'确认激活全部设备?'}
+            onConfirm={() => {
+              setType('active');
+              const activeAPI = `/${
+                SystemConst.API_BASE
+              }/device-instance/deploy?:X_Access_Token=${Token.get()}`;
+              setApi(activeAPI);
+              setOperationVisible(true);
+            }}
+          >
             激活全部设备
-          </Button>
-        </Popconfirm>
+          </Popconfirm>
+        </Button>
       </Menu.Item>
       <Menu.Item key="5">
         <Button
+          disabled={getButtonPermission('device/Instance', ['sync'])}
           icon={<SyncOutlined />}
           type="primary"
           onClick={() => {
@@ -301,44 +321,54 @@ const Instance = () => {
       </Menu.Item>
       {bindKeys.length > 0 && (
         <Menu.Item key="3">
-          <Popconfirm
-            title="确认删除选中设备?"
-            onConfirm={() => {
-              service.batchDeleteDevice(bindKeys).then((resp) => {
-                if (resp.status === 200) {
-                  message.success('操作成功');
-                  actionRef.current?.reset?.();
-                }
-              });
-            }}
-            okText="确认"
-            cancelText="取消"
+          <Button
+            disabled={getButtonPermission('device/Instance', ['delete'])}
+            icon={<DeleteOutlined />}
+            type="primary"
+            danger
           >
-            <Button icon={<DeleteOutlined />} type="primary" danger>
+            <Popconfirm
+              title="确认删除选中设备?"
+              onConfirm={() => {
+                service.batchDeleteDevice(bindKeys).then((resp) => {
+                  if (resp.status === 200) {
+                    message.success('操作成功');
+                    actionRef.current?.reset?.();
+                  }
+                });
+              }}
+              okText="确认"
+              cancelText="取消"
+            >
               删除选中设备
-            </Button>
-          </Popconfirm>
+            </Popconfirm>
+          </Button>
         </Menu.Item>
       )}
       {bindKeys.length > 0 && (
         <Menu.Item key="6">
-          <Popconfirm
-            title="确认禁用选中设备?"
-            onConfirm={() => {
-              service.batchUndeployDevice(bindKeys).then((resp) => {
-                if (resp.status === 200) {
-                  message.success('操作成功');
-                  actionRef.current?.reset?.();
-                }
-              });
-            }}
-            okText="确认"
-            cancelText="取消"
+          <Button
+            disabled={getButtonPermission('device/Instance', ['action'])}
+            icon={<StopOutlined />}
+            type="primary"
+            danger
           >
-            <Button icon={<StopOutlined />} type="primary" danger>
+            <Popconfirm
+              title="确认禁用选中设备?"
+              onConfirm={() => {
+                service.batchUndeployDevice(bindKeys).then((resp) => {
+                  if (resp.status === 200) {
+                    message.success('操作成功');
+                    actionRef.current?.reset?.();
+                  }
+                });
+              }}
+              okText="确认"
+              cancelText="取消"
+            >
               禁用选中设备
-            </Button>
-          </Popconfirm>
+            </Popconfirm>
+          </Button>
         </Menu.Item>
       )}
     </Menu>
@@ -350,8 +380,6 @@ const Instance = () => {
         field={columns}
         target="device-instance"
         onSearch={(data) => {
-          console.log(data);
-          // 重置分页数据
           actionRef.current?.reset?.();
           setSearchParams(data);
         }}
@@ -393,7 +421,7 @@ const Instance = () => {
               setCurrent({});
             }}
             style={{ marginRight: 12 }}
-            disabled={getButtonPermission('device/Instance', 'delete')}
+            disabled={getButtonPermission('device/Instance', 'add')}
             key="button"
             icon={<PlusOutlined />}
             type="primary"
@@ -430,6 +458,7 @@ const Instance = () => {
                   setVisible(true);
                 }}
                 key={'edit'}
+                disabled={getButtonPermission('device/Instance', ['update', 'add'])}
               >
                 <EditOutlined />
                 {intl.formatMessage({
@@ -437,50 +466,25 @@ const Instance = () => {
                   defaultMessage: '编辑',
                 })}
               </Button>,
-              <Popconfirm
-                key={'state'}
-                title={intl.formatMessage({
-                  id: `pages.data.option.${
-                    record.state.value !== 'notActive' ? 'disabled' : 'enabled'
-                  }.tips`,
-                  defaultMessage: '确认禁用?',
-                })}
-                onConfirm={async () => {
-                  if (record.state.value !== 'notActive') {
-                    await service.undeployDevice(record.id);
-                  } else {
-                    await service.deployDevice(record.id);
-                  }
-                  message.success(
-                    intl.formatMessage({
-                      id: 'pages.data.option.success',
-                      defaultMessage: '操作成功!',
-                    }),
-                  );
-                  actionRef.current?.reload();
-                }}
+              <Button
+                disabled={getButtonPermission('device/Instance', ['action'])}
+                type={'link'}
+                style={{ padding: 0 }}
               >
-                <Button type={'link'} style={{ padding: 0 }}>
-                  {record.state.value !== 'notActive' ? <StopOutlined /> : <CheckCircleOutlined />}
-                  {intl.formatMessage({
+                <Popconfirm
+                  key={'state'}
+                  title={intl.formatMessage({
                     id: `pages.data.option.${
                       record.state.value !== 'notActive' ? 'disabled' : 'enabled'
-                    }`,
-                    defaultMessage: record.state.value !== 'notActive' ? '禁用' : '启用',
+                    }.tips`,
+                    defaultMessage: '确认禁用?',
                   })}
-                </Button>
-              </Popconfirm>,
-              <Popconfirm
-                title={intl.formatMessage({
-                  id:
-                    record.state.value === 'notActive'
-                      ? 'pages.data.option.remove.tips'
-                      : 'pages.device.instance.deleteTip',
-                })}
-                key={'delete'}
-                onConfirm={async () => {
-                  if (record.state.value === 'notActive') {
-                    await service.remove(record.id);
+                  onConfirm={async () => {
+                    if (record.state.value !== 'notActive') {
+                      await service.undeployDevice(record.id);
+                    } else {
+                      await service.deployDevice(record.id);
+                    }
                     message.success(
                       intl.formatMessage({
                         id: 'pages.data.option.success',
@@ -488,15 +492,50 @@ const Instance = () => {
                       }),
                     );
                     actionRef.current?.reload();
-                  } else {
-                    message.error(intl.formatMessage({ id: 'pages.device.instance.deleteTip' }));
-                  }
-                }}
+                  }}
+                >
+                  {record.state.value !== 'notActive' ? <StopOutlined /> : <CheckCircleOutlined />}
+                  {intl.formatMessage({
+                    id: `pages.data.option.${
+                      record.state.value !== 'notActive' ? 'disabled' : 'enabled'
+                    }`,
+                    defaultMessage: record.state.value !== 'notActive' ? '禁用' : '启用',
+                  })}
+                </Popconfirm>
+              </Button>,
+
+              <Button
+                key="delete"
+                disabled={getButtonPermission('device/Instance', ['delete'])}
+                type={'link'}
+                style={{ padding: 0 }}
               >
-                <Button type={'link'} style={{ padding: 0 }}>
+                <Popconfirm
+                  title={intl.formatMessage({
+                    id:
+                      record.state.value === 'notActive'
+                        ? 'pages.data.option.remove.tips'
+                        : 'pages.device.instance.deleteTip',
+                  })}
+                  key={'delete'}
+                  onConfirm={async () => {
+                    if (record.state.value === 'notActive') {
+                      await service.remove(record.id);
+                      message.success(
+                        intl.formatMessage({
+                          id: 'pages.data.option.success',
+                          defaultMessage: '操作成功!',
+                        }),
+                      );
+                      actionRef.current?.reload();
+                    } else {
+                      message.error(intl.formatMessage({ id: 'pages.device.instance.deleteTip' }));
+                    }
+                  }}
+                >
                   <DeleteOutlined />
-                </Button>
-              </Popconfirm>,
+                </Popconfirm>
+              </Button>,
             ]}
           />
         )}

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

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

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

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

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

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

+ 146 - 111
src/pages/device/Product/index.tsx

@@ -19,7 +19,7 @@ import type { ActionType, ProColumns } from '@jetlinks/pro-table';
 import { useEffect, useRef, useState } from 'react';
 import Save from '@/pages/device/Product/Save';
 import SearchComponent from '@/components/SearchComponent';
-import { getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
+import { getButtonPermission, getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
 import { ProTableCard } from '@/components';
 import ProductCard from '@/components/ProTableCard/CardItems/product';
 import { downloadObject } from '@/utils/util';
@@ -105,51 +105,57 @@ const Product = observer(() => {
   };
 
   const tools = (record: ProductItem) => [
-    <Tooltip
-      title={intl.formatMessage({
-        id: 'pages.data.option.detail',
-        defaultMessage: '查看',
-      })}
-      key={'detail'}
+    <Button
+      disabled={getButtonPermission('device/Product', ['view'])}
+      type={'link'}
+      onClick={() => {
+        productModel.current = record;
+        history.push(`${getMenuPathByParams(MENUS_CODE['device/Product/Detail'], record.id)}`);
+      }}
+      style={{ padding: 0 }}
     >
-      <Button
-        type={'link'}
-        onClick={() => {
-          productModel.current = record;
-          history.push(`${getMenuPathByParams(MENUS_CODE['device/Product/Detail'], record.id)}`);
-        }}
-        style={{ padding: 0 }}
+      <Tooltip
+        title={intl.formatMessage({
+          id: 'pages.data.option.detail',
+          defaultMessage: '查看',
+        })}
+        key={'detail'}
       >
         <EyeOutlined />
-      </Button>
-    </Tooltip>,
-    <Tooltip
-      title={intl.formatMessage({
-        id: 'pages.data.option.edit',
-        defaultMessage: '编辑',
-      })}
-      key={'edit'}
+      </Tooltip>
+    </Button>,
+    <Button
+      disabled={getButtonPermission('device/Product', ['update'])}
+      key="warning"
+      onClick={() => {
+        setCurrent(record);
+        setVisible(true);
+      }}
+      type={'link'}
+      style={{ padding: 0 }}
     >
-      <Button
-        key="warning"
-        onClick={() => {
-          setCurrent(record);
-          setVisible(true);
-        }}
-        type={'link'}
-        style={{ padding: 0 }}
+      <Tooltip
+        title={intl.formatMessage({
+          id: 'pages.data.option.edit',
+          defaultMessage: '编辑',
+        })}
+        key={'edit'}
       >
         <EditOutlined />
-      </Button>
-    </Tooltip>,
-    <Tooltip
-      title={intl.formatMessage({
-        id: 'pages.data.option.download',
-        defaultMessage: '下载',
-      })}
-      key={'download'}
+      </Tooltip>
+    </Button>,
+    <Button
+      disabled={getButtonPermission('device/Product', ['export'])}
+      type={'link'}
+      style={{ padding: 0 }}
     >
-      <Button type={'link'} style={{ padding: 0 }}>
+      <Tooltip
+        title={intl.formatMessage({
+          id: 'pages.data.option.download',
+          defaultMessage: '下载',
+        })}
+        key={'download'}
+      >
         <DownloadOutlined
           onClick={async () => {
             downloadObject(
@@ -162,55 +168,63 @@ const Product = observer(() => {
             message.success('操作成功');
           }}
         />
-      </Button>
-    </Tooltip>,
-    <Popconfirm
-      key={'state'}
-      title={intl.formatMessage({
-        id: `pages.data.option.${record.state ? 'disabled' : 'enabled'}.tips`,
-        defaultMessage: '是否启用?',
-      })}
-      onConfirm={() => {
-        changeDeploy(record.id, record.state ? 'undeploy' : 'deploy');
-      }}
+      </Tooltip>
+    </Button>,
+    <Button
+      disabled={getButtonPermission('device/Product', ['action'])}
+      style={{ padding: 0 }}
+      type={'link'}
     >
-      <Tooltip
+      <Popconfirm
+        key={'state'}
         title={intl.formatMessage({
-          id: `pages.data.option.${record.state ? 'disabled' : 'enabled'}`,
-          defaultMessage: record.state ? '禁用' : '启用',
+          id: `pages.data.option.${record.state ? 'disabled' : 'enabled'}.tips`,
+          defaultMessage: '是否启用?',
         })}
+        onConfirm={() => {
+          changeDeploy(record.id, record.state ? 'undeploy' : 'deploy');
+        }}
       >
-        <Button style={{ padding: 0 }} type={'link'}>
+        <Tooltip
+          title={intl.formatMessage({
+            id: `pages.data.option.${record.state ? 'disabled' : 'enabled'}`,
+            defaultMessage: record.state ? '禁用' : '启用',
+          })}
+        >
           {record.state ? <StopOutlined /> : <PlayCircleOutlined />}
-        </Button>
-      </Tooltip>
-    </Popconfirm>,
-    <Popconfirm
-      key="unBindUser"
-      title={intl.formatMessage({
-        id: record.state === 1 ? 'pages.device.productDetail.deleteTip' : 'page.table.isDelete',
-        defaultMessage: '是否删除?',
-      })}
-      onConfirm={async () => {
-        if (record.state === 0) {
-          await deleteItem(record.id);
-        } else {
-          message.error('已发布的产品不能进行删除操作');
-        }
-      }}
+        </Tooltip>
+      </Popconfirm>
+    </Button>,
+    <Button
+      disabled={getButtonPermission('device/Product', ['delete'])}
+      type={'link'}
+      style={{ padding: 0 }}
     >
-      <Tooltip
+      <Popconfirm
+        key="unBindUser"
         title={intl.formatMessage({
-          id: 'pages.data.option.remove',
-          defaultMessage: '删除',
+          id: record.state === 1 ? 'pages.device.productDetail.deleteTip' : 'page.table.isDelete',
+          defaultMessage: '是否删除?',
         })}
-        key={'remove'}
+        onConfirm={async () => {
+          if (record.state === 0) {
+            await deleteItem(record.id);
+          } else {
+            message.error('已发布的产品不能进行删除操作');
+          }
+        }}
       >
-        <Button type={'link'} style={{ padding: 0 }}>
+        <Tooltip
+          title={intl.formatMessage({
+            id: 'pages.data.option.remove',
+            defaultMessage: '删除',
+          })}
+          key={'remove'}
+        >
           <DeleteOutlined />
-        </Button>
-      </Tooltip>
-    </Popconfirm>,
+        </Tooltip>
+      </Popconfirm>
+    </Button>,
   ];
 
   const columns: ProColumns<ProductItem>[] = [
@@ -302,6 +316,7 @@ const Product = observer(() => {
               setCurrent(undefined);
               setVisible(true);
             }}
+            disabled={getButtonPermission('device/Product', ['add'])}
             key="button"
             icon={<PlusOutlined />}
             type="primary"
@@ -312,6 +327,7 @@ const Product = observer(() => {
             })}
           </Button>,
           <Upload
+            disabled={getButtonPermission('device/Product', ['import'])}
             key={'import'}
             showUploadList={false}
             beforeUpload={(file) => {
@@ -344,7 +360,12 @@ const Product = observer(() => {
               return false;
             }}
           >
-            <Button style={{ marginLeft: 12 }}>导入</Button>
+            <Button
+              disabled={getButtonPermission('device/Product', ['import'])}
+              style={{ marginLeft: 12 }}
+            >
+              导入
+            </Button>
           </Upload>,
         ]}
         cardRender={(record) => (
@@ -370,6 +391,7 @@ const Product = observer(() => {
                   setCurrent(record);
                   setVisible(true);
                 }}
+                disabled={getButtonPermission('device/Product', ['update', 'add'])}
                 type={'link'}
                 style={{ padding: 0 }}
               >
@@ -379,7 +401,12 @@ const Product = observer(() => {
                   defaultMessage: '编辑',
                 })}
               </Button>,
-              <Button type={'link'} key={'download'} style={{ padding: 0 }}>
+              <Button
+                disabled={getButtonPermission('device/Product', ['export'])}
+                type={'link'}
+                key={'download'}
+                style={{ padding: 0 }}
+              >
                 <DownloadOutlined
                   onClick={async () => {
                     downloadObject(
@@ -397,45 +424,53 @@ const Product = observer(() => {
                   defaultMessage: '下载',
                 })}
               </Button>,
-              <Popconfirm
-                key={'state'}
-                title={intl.formatMessage({
-                  id: `pages.data.option.${record.state ? 'disabled' : 'enabled'}.tips`,
-                  defaultMessage: '是否启用?',
-                })}
-                onConfirm={() => {
-                  changeDeploy(record.id, record.state ? 'undeploy' : 'deploy');
-                }}
+              <Button
+                style={{ padding: 0 }}
+                type={'link'}
+                disabled={getButtonPermission('device/Product', ['action'])}
               >
-                <Button style={{ padding: 0 }} type={'link'}>
+                <Popconfirm
+                  key={'state'}
+                  title={intl.formatMessage({
+                    id: `pages.data.option.${record.state ? 'disabled' : 'enabled'}.tips`,
+                    defaultMessage: '是否启用?',
+                  })}
+                  onConfirm={() => {
+                    changeDeploy(record.id, record.state ? 'undeploy' : 'deploy');
+                  }}
+                >
                   {record.state ? <StopOutlined /> : <PlayCircleOutlined />}
                   {intl.formatMessage({
                     id: `pages.data.option.${record.state ? 'disabled' : 'enabled'}`,
                     defaultMessage: record.state ? '禁用' : '启用',
                   })}
-                </Button>
-              </Popconfirm>,
-              <Popconfirm
-                key="delete"
-                title={intl.formatMessage({
-                  id:
-                    record.state === 1
-                      ? 'pages.device.productDetail.deleteTip'
-                      : 'page.table.isDelete',
-                  defaultMessage: '是否删除?',
-                })}
-                onConfirm={async () => {
-                  if (record.state === 0) {
-                    await deleteItem(record.id);
-                  } else {
-                    message.error('已发布的产品不能进行删除操作');
-                  }
-                }}
+                </Popconfirm>
+              </Button>,
+              <Button
+                type={'link'}
+                style={{ padding: 0 }}
+                disabled={getButtonPermission('device/Product', ['delete'])}
               >
-                <Button type={'link'} style={{ padding: 0 }}>
+                <Popconfirm
+                  key="delete"
+                  title={intl.formatMessage({
+                    id:
+                      record.state === 1
+                        ? 'pages.device.productDetail.deleteTip'
+                        : 'page.table.isDelete',
+                    defaultMessage: '是否删除?',
+                  })}
+                  onConfirm={async () => {
+                    if (record.state === 0) {
+                      await deleteItem(record.id);
+                    } else {
+                      message.error('已发布的产品不能进行删除操作');
+                    }
+                  }}
+                >
                   <DeleteOutlined />
-                </Button>
-              </Popconfirm>,
+                </Popconfirm>
+              </Button>,
             ]}
           />
         )}

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

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

+ 14 - 4
src/pages/link/AccessConfig/index.tsx

@@ -1,5 +1,5 @@
 import SearchComponent from '@/components/SearchComponent';
-import { getMenuPathByCode, MENUS_CODE } from '@/utils/menu';
+import { getButtonPermission, getMenuPathByCode, MENUS_CODE } from '@/utils/menu';
 import { PageContainer } from '@ant-design/pro-layout';
 import type { ProColumns } from '@jetlinks/pro-table';
 import { Button, Card, Col, Empty, message, Pagination, Popconfirm, Row } from 'antd';
@@ -77,9 +77,10 @@ const AccessConfig = () => {
             handleSearch(dt);
           }}
         />
-        <div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end' }}>
+        <div style={{ width: '100%', display: 'flex', justifyContent: 'flex-start' }}>
           <Button
             type="primary"
+            disabled={getButtonPermission('link/AccessConfig', ['add'])}
             onClick={() => {
               history.push(`${getMenuPathByCode(MENUS_CODE['link/AccessConfig/Detail'])}`);
             }}
@@ -95,6 +96,7 @@ const AccessConfig = () => {
                   {...item}
                   actions={[
                     <Button
+                      disabled={getButtonPermission('link/AccessConfig', ['update'])}
                       key="edit"
                       type="link"
                       onClick={() => {
@@ -108,7 +110,11 @@ const AccessConfig = () => {
                       <EditOutlined />
                       编辑
                     </Button>,
-                    <Button key="warning" type="link">
+                    <Button
+                      key="warning"
+                      type="link"
+                      disabled={getButtonPermission('link/AccessConfig', ['action'])}
+                    >
                       <Popconfirm
                         title={`确认${item.state.value !== 'disabled' ? '禁用' : '启用'}`}
                         onConfirm={() => {
@@ -142,7 +148,11 @@ const AccessConfig = () => {
                         )}
                       </Popconfirm>
                     </Button>,
-                    <Button key="delete" type="link">
+                    <Button
+                      key="delete"
+                      type="link"
+                      disabled={getButtonPermission('link/AccessConfig', ['delete'])}
+                    >
                       <Popconfirm
                         title={'确认删除?'}
                         onConfirm={() => {

+ 38 - 18
src/pages/link/Protocol/index.tsx

@@ -13,6 +13,7 @@ import { onFormValuesChange, registerValidateRules } from '@formily/core';
 import { Store } from 'jetlinks-store';
 import { useLocation } from 'umi';
 import SystemConst from '@/utils/const';
+import { getButtonPermission } from '@/utils/menu';
 
 export const service = new Service('protocol');
 const Protocol = () => {
@@ -70,7 +71,10 @@ const Protocol = () => {
       valueType: 'option',
       width: 200,
       render: (text, record) => [
-        <a
+        <Button
+          type="link"
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('link/Protocol', ['update'])}
           key="edit"
           onClick={() => {
             CurdModel.update(record);
@@ -85,37 +89,52 @@ const Protocol = () => {
           >
             <EditOutlined />
           </Tooltip>
-        </a>,
+        </Button>,
         record.state !== 1 && (
-          <a key="publish">
+          <Button
+            type="link"
+            style={{ padding: 0 }}
+            disabled={getButtonPermission('link/Protocol', ['action'])}
+            key="publish"
+          >
             <Popconfirm title="确认发布?" onConfirm={() => modifyState(record.id, 'deploy')}>
               <Tooltip title="发布">
                 <CheckCircleOutlined />
               </Tooltip>
             </Popconfirm>
-          </a>
+          </Button>
         ),
         record.state === 1 && (
-          <a key="reload">
+          <Button
+            type="link"
+            style={{ padding: 0 }}
+            disabled={getButtonPermission('link/Protocol', ['action'])}
+            key="reload"
+          >
             <Popconfirm title="确认撤销?" onConfirm={() => modifyState(record.id, 'un-deploy')}>
               <Tooltip title="撤销">
                 <StopOutlined />
               </Tooltip>
             </Popconfirm>
-          </a>
+          </Button>
         ),
-        <Tooltip
+        <Button
+          style={{ padding: 0 }}
           key="delete"
-          title={
-            record.state !== 1
-              ? intl.formatMessage({
-                  id: 'pages.data.option.remove',
-                  defaultMessage: '删除',
-                })
-              : '请先禁用该组件,再删除。'
-          }
+          type="link"
+          disabled={record.state === 1 || getButtonPermission('link/Protocol', ['delete'])}
         >
-          <Button style={{ padding: 0 }} key="delete" type="link" disabled={record.state === 1}>
+          <Tooltip
+            key="delete"
+            title={
+              record.state !== 1
+                ? intl.formatMessage({
+                    id: 'pages.data.option.remove',
+                    defaultMessage: '删除',
+                  })
+                : '请先禁用该组件,再删除。'
+            }
+          >
             <Popconfirm
               title={intl.formatMessage({
                 id: 'pages.data.option.remove.tips',
@@ -141,8 +160,8 @@ const Protocol = () => {
                 <DeleteOutlined />
               </Tooltip>
             </Popconfirm>
-          </Button>
-        </Tooltip>,
+          </Tooltip>
+        </Button>,
       ],
     },
   ];
@@ -326,6 +345,7 @@ const Protocol = () => {
         modelConfig={{ width: '550px' }}
         schema={schema}
         actionRef={actionRef}
+        disableAdd={getButtonPermission('link/Protocol', ['add'])}
         formEffect={() => {
           onFormValuesChange((form) => {
             form.setFieldState('id', (state) => {

+ 38 - 24
src/pages/link/Type/index.tsx

@@ -13,7 +13,7 @@ import { PageContainer } from '@ant-design/pro-layout';
 import type { NetworkItem } from '@/pages/link/Type/typings';
 import { useIntl } from '@@/plugin-locale/localeExports';
 import SearchComponent from '@/components/SearchComponent';
-import { getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
+import { getButtonPermission, getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
 import { history } from 'umi';
 import Service from '@/pages/link/service';
 import { Store } from 'jetlinks-store';
@@ -104,7 +104,10 @@ const Network = () => {
       valueType: 'option',
       width: 200,
       render: (text, record) => [
-        <a
+        <Button
+          type="link"
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('link/Type', ['view'])}
           key="edit"
           onClick={() => {
             Store.set('current-network-data', record);
@@ -114,9 +117,14 @@ const Network = () => {
           <Tooltip title="查看">
             <EyeOutlined />
           </Tooltip>
-        </a>,
+        </Button>,
 
-        <a key="changeState">
+        <Button
+          type="link"
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('link/Type', ['action'])}
+          key="changeState"
+        >
           <Popconfirm
             title={`确认${record.state.value === 'enabled' ? '禁用' : '启用'}?`}
             onConfirm={async () => {
@@ -142,19 +150,25 @@ const Network = () => {
               {record.state.value === 'enabled' ? <CloseCircleOutlined /> : <PlayCircleOutlined />}
             </Tooltip>
           </Popconfirm>
-        </a>,
-        <Tooltip
-          key="delete"
-          title={
-            record.state.value === 'disabled'
-              ? intl.formatMessage({
-                  id: 'pages.data.option.remove',
-                  defaultMessage: '删除',
-                })
-              : '请先禁用该组件,再删除。'
+        </Button>,
+        <Button
+          type="link"
+          style={{ padding: 0 }}
+          disabled={
+            record.state.value === 'enabled' || getButtonPermission('link/Type', ['delete'])
           }
         >
-          <Button type="link" style={{ padding: 0 }} disabled={record.state.value === 'enabled'}>
+          <Tooltip
+            key="delete"
+            title={
+              record.state.value === 'disabled'
+                ? intl.formatMessage({
+                    id: 'pages.data.option.remove',
+                    defaultMessage: '删除',
+                  })
+                : '请先禁用该组件,再删除。'
+            }
+          >
             <Popconfirm
               title="确认删除?"
               onConfirm={async () => {
@@ -167,8 +181,8 @@ const Network = () => {
             >
               <DeleteOutlined />
             </Popconfirm>
-          </Button>
-        </Tooltip>,
+          </Tooltip>
+        </Button>,
       ],
     },
   ];
@@ -189,12 +203,9 @@ const Network = () => {
         params={param}
         columns={columns}
         search={false}
-        headerTitle={'网络组件'}
-        request={async (params) =>
-          service.query({ ...params, sorts: [{ name: 'createTime', order: 'desc' }] })
-        }
-        toolBarRender={() => [
+        headerTitle={
           <Button
+            disabled={getButtonPermission('link/Type', ['add'])}
             onClick={() => {
               pageJump();
             }}
@@ -206,8 +217,11 @@ const Network = () => {
               id: 'pages.data.option.add',
               defaultMessage: '新增',
             })}
-          </Button>,
-        ]}
+          </Button>
+        }
+        request={async (params) =>
+          service.query({ ...params, sorts: [{ name: 'createTime', order: 'desc' }] })
+        }
       />
     </PageContainer>
   );

+ 145 - 122
src/pages/media/Cascade/index.tsx

@@ -16,7 +16,7 @@ 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 { getButtonPermission, getMenuPathByCode, MENUS_CODE } from '@/utils/menu';
 import { useHistory } from 'umi';
 import Service from './service';
 import Publish from './Publish';
@@ -33,94 +33,103 @@ const Cascade = () => {
   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 }}
+      disabled={getButtonPermission('media/Cascade', ['update', 'view'])}
+      onClick={() => {
+        const url = getMenuPathByCode(MENUS_CODE[`media/Cascade/Save`]);
+        history.push(url + `?id=${record.id}`);
+      }}
     >
-      <Button
-        type={'link'}
-        style={{ padding: 0 }}
-        onClick={() => {
-          const url = getMenuPathByCode(MENUS_CODE[`media/Cascade/Save`]);
-          history.push(url + `?id=${record.id}`);
-        }}
+      <Tooltip
+        title={intl.formatMessage({
+          id: 'pages.data.option.edit',
+          defaultMessage: '编辑',
+        })}
+        key={'edit'}
       >
         <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>
+    </Button>,
+    <Button
+      type={'link'}
+      style={{ padding: 0 }}
+      onClick={() => {
+        const url = getMenuPathByCode(MENUS_CODE[`media/Cascade/Channel`]);
+        history.push(url + `?id=${record.id}`);
       }}
     >
-      <Tooltip title={'共享'}>
-        <Button type={'link'}>
-          <ShareAltOutlined />
-        </Button>
+      <Tooltip title={'选择通道'} key={'channel'}>
+        <LinkOutlined />
       </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>,
+    <Button type={'link'}>
+      <Popconfirm
+        key={'share'}
+        title="确认共享!"
+        onConfirm={() => {
+          setCurrent(record);
+          setVisible(true);
+        }}
+      >
+        <Tooltip title={'共享'}>
+          <ShareAltOutlined />
+        </Tooltip>
+      </Popconfirm>
+    </Button>,
+    <Button
+      type={'link'}
+      style={{ padding: 0 }}
+      disabled={getButtonPermission('media/Cascade', ['action'])}
     >
-      <Button type={'link'} style={{ padding: 0 }}>
+      <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?.();
+          }
+        }}
+      >
         <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?.();
-        }
-      }}
+      </Popconfirm>
+    </Button>,
+    <Button
+      type={'link'}
+      style={{ padding: 0 }}
+      disabled={getButtonPermission('media/Cascade', ['delete'])}
     >
-      <Tooltip
-        title={intl.formatMessage({
-          id: 'pages.data.option.remove',
-          defaultMessage: '删除',
-        })}
+      <Popconfirm
+        title={'确认删除'}
+        key={'delete'}
+        onConfirm={async () => {
+          const resp: any = await service.remove(record.id);
+          if (resp.status === 200) {
+            message.success('操作成功!');
+            actionRef.current?.reset?.();
+          }
+        }}
       >
-        <Button type={'link'} style={{ padding: 0 }}>
+        <Tooltip
+          title={intl.formatMessage({
+            id: 'pages.data.option.remove',
+            defaultMessage: '删除',
+          })}
+        >
           <DeleteOutlined />
-        </Button>
-      </Tooltip>
-    </Popconfirm>,
+        </Tooltip>
+      </Popconfirm>
+    </Button>,
   ];
 
   const columns: ProColumns<CascadeItem>[] = [
@@ -200,7 +209,10 @@ const Cascade = () => {
       align: 'center',
       width: 200,
       render: (text, record) => [
-        <a
+        <Button
+          type="link"
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('media/Cascade', ['view', 'update'])}
           key={'edit'}
           onClick={() => {
             const url = getMenuPathByCode(MENUS_CODE[`media/Cascade/Save`]);
@@ -215,8 +227,10 @@ const Cascade = () => {
           >
             <EditOutlined />
           </Tooltip>
-        </a>,
-        <a
+        </Button>,
+        <Button
+          type="link"
+          style={{ padding: 0 }}
           key={'channel'}
           onClick={() => {
             const url = getMenuPathByCode(MENUS_CODE[`media/Cascade/Channel`]);
@@ -226,55 +240,63 @@ const Cascade = () => {
           <Tooltip title={'选择通道'}>
             <LinkOutlined />
           </Tooltip>
-        </a>,
-        <Popconfirm
-          key={'share'}
-          onConfirm={() => {
-            setVisible(true);
-            setCurrent(record);
-          }}
-          title={'确认共享'}
-        >
-          <a>
+        </Button>,
+        <Button type="link" style={{ padding: 0 }}>
+          <Popconfirm
+            key={'share'}
+            onConfirm={() => {
+              setVisible(true);
+              setCurrent(record);
+            }}
+            title={'确认共享'}
+          >
             <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?.();
-            }
-          }}
+          </Popconfirm>
+        </Button>,
+        <Button
+          type="link"
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('media/Cascade', ['action'])}
         >
-          <a>
+          <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?.();
+              }
+            }}
+          >
             <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?.();
-            }
-          }}
+          </Popconfirm>
+        </Button>,
+        <Button
+          type="link"
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('media/Cascade', ['delete'])}
         >
-          <a>
+          <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',
@@ -283,8 +305,8 @@ const Cascade = () => {
             >
               <DeleteOutlined />
             </Tooltip>
-          </a>
-        </Popconfirm>,
+          </Popconfirm>
+        </Button>,
       ],
     },
   ];
@@ -327,6 +349,7 @@ const Cascade = () => {
               const url = getMenuPathByCode(MENUS_CODE[`media/Cascade/Save`]);
               history.push(url);
             }}
+            disabled={getButtonPermission('media/Cascade', ['add'])}
             style={{ marginRight: 12 }}
             key="button"
             icon={<PlusOutlined />}

+ 1 - 1
src/pages/media/Device/Channel/index.tsx

@@ -152,7 +152,7 @@ export default () => {
             onClick={() => {
               history.push(
                 `${getMenuPathByCode(MENUS_CODE['media/Device/Playback'])}?id=${
-                  record.channelId
+                  record.deviceId
                 }&channelId=${record.channelId}`,
               );
             }}

+ 73 - 1
src/pages/media/Device/Playback/index.less

@@ -1,3 +1,4 @@
+@import '~antd/es/style/themes/default.less';
 @borderColor: #d9d9d9;
 
 .playback-warp {
@@ -7,6 +8,7 @@
 
   .playback-left {
     display: flex;
+    flex-direction: column;
     flex-grow: 1;
     width: 0;
 
@@ -16,7 +18,7 @@
   }
 
   .playback-right {
-    width: 280px;
+    width: 300px;
     margin-left: 24px;
 
     .playback-calendar {
@@ -44,6 +46,76 @@
         align-items: center;
         justify-content: center;
       }
+
+      .playback-list-items {
+        width: 100%;
+
+        .ant-list-item {
+          padding-left: 12px;
+        }
+      }
+    }
+  }
+
+  .time-line-warp {
+    padding: 10px 0;
+
+    .time-line-clock {
+      display: flex;
+      align-items: stretch;
+      justify-content: space-between;
+      width: 100%;
+
+      > div {
+        color: #666;
+        font-size: 12px;
+      }
+    }
+
+    .time-line-content {
+      position: relative;
+      padding-bottom: 20px;
+
+      .time-line-progress {
+        position: relative;
+        height: 16px;
+        overflow: hidden;
+        background-color: #d9d9d9;
+        border-radius: 2px;
+
+        > div {
+          position: absolute;
+          top: 0;
+          left: 0;
+          height: 100%;
+          background-color: #52c41a;
+          cursor: pointer;
+        }
+      }
+      .time-line-btn {
+        position: absolute;
+        top: -2px;
+        left: 0;
+        width: 3px;
+        height: 19px;
+        background-color: @primary-color;
+        border-radius: 2px;
+        visibility: hidden;
+      }
+
+      .time-line {
+        position: absolute;
+        bottom: -8px;
+        left: -30px;
+        width: 60px;
+        padding: 2px 0;
+        font-size: 12px;
+        text-align: center;
+        background-color: #d9d9d9;
+        border-radius: 2px;
+        box-shadow: 0 0 12px rgba(#000, 0.15);
+        visibility: hidden;
+      }
     }
   }
 }

+ 208 - 62
src/pages/media/Device/Playback/index.tsx

@@ -1,15 +1,23 @@
 // 回放
 import { PageContainer } from '@ant-design/pro-layout';
 import LivePlayer from '@/components/Player';
-import { useEffect, useState } from 'react';
-import { Select, Calendar, Empty, List } from 'antd';
+import { useCallback, useEffect, useState } from 'react';
+import { Select, Calendar, Empty, List, Tooltip } from 'antd';
 import { useLocation } from 'umi';
 import Service from './service';
 import './index.less';
 import { recordsItemType } from '@/pages/media/Device/Playback/typings';
 import * as moment from 'moment';
+import type { Moment } from 'moment';
 import classNames from 'classnames';
-import { CloudDownloadOutlined, PauseCircleOutlined, PlayCircleOutlined } from '@ant-design/icons';
+import {
+  CloudDownloadOutlined,
+  DownloadOutlined,
+  EyeOutlined,
+  PauseCircleOutlined,
+  PlayCircleOutlined,
+} from '@ant-design/icons';
+import TimeLine from './timeLine';
 
 const service = new Service('media');
 
@@ -17,41 +25,53 @@ export default () => {
   const [url, setUrl] = useState('');
   const [type, setType] = useState('local');
   const [historyList, setHistoryList] = useState<recordsItemType[]>([]);
-  const [time, setTime] = useState<any>('');
+  const [time, setTime] = useState<Moment | undefined>(undefined);
   const [playTime, setPlayTime] = useState(0);
+  // const [loading, setLoading] = useState(false)
+  const [cloudTime, setCloudTime] = useState<any>();
+  const [playing, setPlaying] = useState(false);
   const location = useLocation();
 
   const param = new URLSearchParams(location.search);
   const deviceId = param.get('id');
   const channelId = param.get('channelId');
 
-  const queryLocalRecords = async (date: any) => {
+  const queryLocalRecords = async (date: Moment) => {
+    setPlaying(false);
+    setUrl('');
     if (deviceId && channelId && date) {
       const params = {
         startTime: date.format('YYYY-MM-DD 00:00:00'),
         endTime: date.format('YYYY-MM-DD 23:59:59'),
       };
-      let list: recordsItemType[] = [];
+      const list: recordsItemType[] = [];
       const localResp = await service.queryRecordLocal(deviceId, channelId, params);
 
-      if (localResp.status === 200) {
-        list = localResp.result;
+      if (localResp.status === 200 && localResp.result.length) {
+        const serviceResp = await service.recordsInServer(deviceId, channelId, {
+          ...params,
+          includeFiles: false,
+        });
+        if (serviceResp.status === 200 && serviceResp.result) {
+          const newList = list.map((item) => {
+            return {
+              ...item,
+              isServer: serviceResp.result.some(
+                (serverFile: any) => serverFile.streamStartTime === item.startTime,
+              ),
+            };
+          });
+          setHistoryList(newList);
+        } else {
+          setHistoryList(list);
+        }
       }
-
-      const serviceResp = await service.recordsInServer(deviceId, channelId, {
-        ...params,
-        includeFiles: false,
-      });
-
-      if (serviceResp.status === 200) {
-        list = [...list, ...serviceResp.result];
-      }
-
-      setHistoryList(list);
     }
   };
 
-  const queryServiceRecords = async (date: any) => {
+  const queryServiceRecords = async (date: Moment) => {
+    setPlaying(false);
+    setUrl('');
     if (deviceId && channelId && date) {
       const params = {
         startTime: date.format('YYYY-MM-DD 00:00:00'),
@@ -67,6 +87,90 @@ export default () => {
     }
   };
 
+  const downLoadCloud = useCallback(
+    (item: recordsItemType) => {
+      setHistoryList(
+        historyList.map((historyItem) => {
+          if (historyItem.startTime === item.startTime) {
+            return {
+              ...item,
+              isServer: true,
+            };
+          }
+          return item;
+        }),
+      );
+    },
+    [historyList],
+  );
+
+  const cloudView = useCallback((startTime: number, endTime: number) => {
+    setType('cloud');
+    setCloudTime({
+      startTime,
+      endTime,
+    });
+    queryServiceRecords(time!);
+  }, []);
+
+  const downloadClick = async (item: recordsItemType) => {
+    const downloadUrl = service.downLoadFile(item.id);
+    const downNode = document.createElement('a');
+    downNode.href = downloadUrl;
+    downNode.download = `${channelId}-${moment(item.startTime).format('YYYY-MM-DD-HH-mm-ss')}.mp4`;
+    downNode.style.display = 'none';
+    document.body.appendChild(downNode);
+    downNode.click();
+    document.body.removeChild(downNode);
+  };
+
+  const DownloadIcon = useCallback(
+    (item: recordsItemType) => {
+      let title = '下载到云端';
+      let IconNode = (
+        <a
+          onClick={() => {
+            downLoadCloud(item);
+          }}
+        >
+          <CloudDownloadOutlined />
+        </a>
+      );
+      if (type === 'local') {
+        if (item.isServer) {
+          title = '查看';
+          IconNode = (
+            <a
+              onClick={() => {
+                cloudView(item.startTime, item.endTime);
+              }}
+            >
+              <EyeOutlined />
+            </a>
+          );
+        }
+      } else {
+        title = '下载录像文件';
+        IconNode = (
+          <a
+            onClick={() => {
+              downloadClick(item);
+            }}
+            download
+          >
+            <DownloadOutlined />
+          </a>
+        );
+      }
+
+      return {
+        title,
+        IconNode,
+      };
+    },
+    [type],
+  );
+
   useEffect(() => {
     setTime(moment(new Date()));
     queryLocalRecords(moment(new Date()));
@@ -76,7 +180,48 @@ export default () => {
     <PageContainer>
       <div className={'playback-warp'}>
         <div className={'playback-left'}>
-          <LivePlayer url={url} className={'playback-media'} />
+          <LivePlayer
+            url={url}
+            className={'playback-media'}
+            live={type === 'local'}
+            onPlay={() => {
+              setPlaying(true);
+            }}
+            onPause={() => {
+              setPlaying(false);
+            }}
+            onDestroy={() => {
+              setPlaying(false);
+            }}
+            onError={() => {
+              setPlaying(false);
+            }}
+          />
+          <TimeLine
+            type={type}
+            data={historyList}
+            dateTime={time}
+            onChange={(times) => {
+              if (times) {
+                setPlayTime(Number(times.endTime.valueOf()));
+                setUrl(
+                  type === 'local'
+                    ? service.playbackLocal(
+                        times.deviceId,
+                        times.channelId,
+                        'mp4',
+                        moment(times.startTime).format('YYYY-MM-DD HH:mm:ss'),
+                        moment(times.endTime).format('YYYY-MM-DD HH:mm:ss'),
+                      )
+                    : service.playbackStart(times.deviceId),
+                );
+              } else {
+                setUrl('');
+              }
+            }}
+            playing={playing}
+            localToServer={cloudTime}
+          />
         </div>
         <div className={'playback-right'}>
           <Select
@@ -89,9 +234,9 @@ export default () => {
             onSelect={(key: string) => {
               setType(key);
               if (key === 'cloud') {
-                queryServiceRecords(time);
+                queryServiceRecords(time!);
               } else {
-                queryLocalRecords(time);
+                queryLocalRecords(time!);
               }
             }}
           />
@@ -113,56 +258,57 @@ export default () => {
           <div className={classNames('playback-list', { 'no-list': !historyList.length })}>
             {historyList && historyList.length ? (
               <List
+                className={'playback-list-items'}
                 itemLayout="horizontal"
                 dataSource={historyList}
                 renderItem={(item) => {
-                  const startTime = moment(item.startTime);
-                  const startH = startTime.hours();
-                  const startM = startTime.minutes();
-                  const startS = startTime.seconds();
-
-                  const endTime = moment(item.endTime);
-                  const endH = endTime.hours();
-                  const endM = endTime.minutes();
-                  const endS = endTime.seconds();
+                  const startTime = moment(item.startTime || item.mediaStartTime).format(
+                    'HH:mm:ss',
+                  );
+                  const endTime = moment(item.endTime || item.mediaEndTime).format('HH:mm:ss');
+                  const downloadObj = DownloadIcon(item);
+                  const timeId = item.endTime || item.mediaEndTime;
 
+                  console.log(timeId, playTime);
                   return (
                     <List.Item
                       actions={[
-                        <a
-                          key="list-loadmore-edit"
-                          onClick={() => {
-                            if (!playTime) {
-                              setPlayTime(item.startTime);
-                              if (deviceId && channelId) {
-                                setUrl(
-                                  service.playbackLocal(
-                                    deviceId,
-                                    channelId,
-                                    'mp4',
-                                    item.startTime,
-                                    item.endTime,
-                                  ),
-                                );
-                              }
-                            } else {
-                              setPlayTime(0);
-                            }
-                          }}
+                        <Tooltip
+                          key="play-btn"
+                          title={item.startTime === playTime ? '暂停' : '播放'}
                         >
-                          {item.startTime === playTime ? (
-                            <PauseCircleOutlined />
-                          ) : (
-                            <PlayCircleOutlined />
-                          )}
-                        </a>,
-                        <a key="list-loadmore-more">
-                          <CloudDownloadOutlined />
-                        </a>,
+                          <a
+                            onClick={() => {
+                              if (!playTime) {
+                                setPlayTime(item.startTime);
+                                if (item.filePath) {
+                                  service.playbackStart(item.id);
+                                } else if (deviceId && channelId) {
+                                  setUrl(
+                                    service.playbackLocal(
+                                      deviceId,
+                                      channelId,
+                                      'mp4',
+                                      moment(item.startTime).format('YYYY-MM-DD HH:mm:ss'),
+                                      moment(item.endTime).format('YYYY-MM-DD HH:mm:ss'),
+                                    ),
+                                  );
+                                }
+                              } else {
+                                setPlayTime(0);
+                              }
+                            }}
+                          >
+                            {timeId === playTime ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
+                          </a>
+                        </Tooltip>,
+                        <Tooltip key={'download'} title={downloadObj.title}>
+                          {downloadObj.IconNode}
+                        </Tooltip>,
                       ]}
                     >
                       <div style={{ textAlign: 'center', paddingLeft: 10 }}>
-                        {`${startH}:${startM}:${startS}`} ~ {`${endH}:${endM}:${endS}`}
+                        {`${startTime}`} ~ {`${endTime}`}
                       </div>
                     </List.Item>
                   );

+ 13 - 3
src/pages/media/Device/Playback/service.ts

@@ -8,6 +8,13 @@ class Service extends BaseService<recordsItemType> {
   ptzStart = (deviceId: string, channelId: string, type: string) =>
     `${this.uri}/device/${deviceId}/${channelId}/live.${type}?:X_Access_Token=${Token.get()}`;
 
+  // 查询设备通道详情
+  queryDetail = (deviceId: string, data: any) =>
+    request(`${this.uri}/device/${deviceId}/channel/_query`, {
+      method: 'POST',
+      data,
+    });
+
   // 查询本地回放记录
   queryRecordLocal = (deviceId: string, channelId: string, data?: any) =>
     request(`${this.uri}/device/${deviceId}/${channelId}/records/in-local`, {
@@ -20,8 +27,8 @@ class Service extends BaseService<recordsItemType> {
     deviceId: string,
     channelId: string,
     suffix: string,
-    startTime: number,
-    endTime: number,
+    startTime: string,
+    endTime: string,
     speed: number = 1,
   ) =>
     `${
@@ -48,7 +55,10 @@ class Service extends BaseService<recordsItemType> {
 
   // 播放云端回放
   playbackStart = (recordId: string) =>
-    request(`${this.uri}/record/${recordId}.mp4`, { method: 'GET' });
+    `${this.uri}/record/${recordId}.mp4?:X_Access_Token=${Token.get()}`;
+
+  downLoadFile = (recordId: string) =>
+    `${this.uri}/record/${recordId}.mp4?download=true&:X_Access_Token=${Token.get()}`;
 }
 
 export default Service;

+ 205 - 0
src/pages/media/Device/Playback/timeLine.tsx

@@ -0,0 +1,205 @@
+import { message } from 'antd';
+import moment from 'moment';
+import type { Moment } from 'moment';
+import { useEffect, useState, useRef } from 'react';
+import './index.less';
+import { recordsItemType } from '@/pages/media/Device/Playback/typings';
+import { useSize } from 'ahooks';
+import classNames from 'classnames';
+
+export type TimeChangeType = {
+  endTime: Moment;
+  startTime: Moment;
+  deviceId: string;
+  channelId: string;
+};
+
+interface Props {
+  onChange: (times: TimeChangeType | undefined) => void;
+  data: recordsItemType[];
+  dateTime?: Moment;
+  type: string;
+  playing: boolean;
+  server?: any;
+  localToServer?: {
+    endTime: number;
+    startTime: number;
+  };
+  getPlayList?: (data: any) => void;
+}
+
+const Progress = (props: Props) => {
+  const [startT, setStartT] = useState<number>(
+    new Date(moment(props.dateTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')).getTime(),
+  ); // 获取选中当天开始时间戳
+  const endT = new Date(
+    moment(props.dateTime).endOf('day').format('YYYY-MM-DD HH:mm:ss'),
+  ).getTime(); // 获取选中当天结束时间戳
+
+  const [list, setList] = useState<any[]>([]);
+  const [time, setTime] = useState<number>(startT);
+
+  const LineContent = useRef<HTMLDivElement>(null);
+  const LineContentSize = useSize(LineContent);
+
+  const setTimeAndPosition = (ob: number) => {
+    const oBtn = document.getElementById('btn');
+    const oTime = document.getElementById('time');
+
+    if (oBtn && oTime && LineContentSize.width) {
+      oBtn.style.visibility = 'visible';
+      oBtn.style.left = `${ob * LineContentSize.width}px`;
+      oTime.style.visibility = 'visible';
+      oTime.style.left = `${ob * LineContentSize.width - 15}px`;
+    }
+  };
+
+  useEffect(() => {
+    setStartT(
+      new Date(moment(props.dateTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')).getTime(),
+    );
+  }, [props.dateTime]);
+
+  const onChange = (startTime: number, endTime: number, deviceId: string, channelId: string) => {
+    props.onChange({
+      startTime: moment(startTime),
+      endTime: moment(endTime),
+      deviceId,
+      channelId,
+    });
+  };
+
+  useEffect(() => {
+    const { data, localToServer, type } = props;
+    if (data && Array.isArray(data) && data.length > 0) {
+      setList([...data]);
+      if (type === 'local') {
+        // 播放第一个
+        onChange(data[0].startTime, data[0].endTime, data[0].deviceId, data[0].channelId);
+        setTime(startT);
+      } else if (type === 'cloud') {
+        // 是否从本地跳转到云端播放
+        if (localToServer && Object.keys(localToServer).length > 0) {
+          // 获取跳转播放段
+          const playItem = data.find((item) => {
+            return (
+              item.mediaEndTime <= localToServer.endTime &&
+              item.mediaStartTime >= localToServer.startTime
+            );
+          });
+          if (playItem) {
+            //播放片段
+            onChange(
+              playItem.mediaStartTime,
+              playItem.mediaEndTime,
+              playItem.id,
+              playItem.channelId,
+            );
+            setTime(playItem.mediaStartTime);
+          } else {
+            props.onChange(undefined);
+            setTime(localToServer.startTime);
+            message.error('没有可播放的视频资源');
+          }
+        } else {
+          setTime(data[0].mediaStartTime);
+          onChange(data[0].mediaStartTime, data[0].mediaEndTime, data[0].id, data[0].channelId);
+        }
+      }
+    } else if (localToServer && localToServer.startTime) {
+      // 本地跳转云端但是无资源
+      props.onChange(undefined);
+      message.error('没有可播放的视频资源');
+      setTime(startT);
+      setList([]);
+    } else {
+      // 啥都没有
+      setTime(startT);
+      setList([]);
+      props.onChange(undefined);
+    }
+  }, [props.data]);
+
+  useEffect(() => {
+    // if(props.server && Object.keys(props.server).length > 0){
+    //   if(props.type === 'local'){
+    //     setTime(props.server.startTime)
+    //     props.play({ start: props.server.startTime, end: props.server.endTime })
+    //   } else {
+    //     setTime(props.server.mediaStartTime)
+    //     props.play(props.server)
+    //   }
+    // }
+  }, [props.server]);
+
+  const getLineItemStyle = (
+    startTime: number,
+    endTime: number,
+  ): { left: string; width: string } => {
+    const start = startTime - startT > 0 ? startTime - startT : 0;
+    const _width = LineContentSize.width!;
+    const itemWidth = ((endTime - startTime) / (24 * 3600000)) * _width;
+    return {
+      left: `${(start / (24 * 3600000)) * _width}px`,
+      width: `${itemWidth < 1 ? 1 : itemWidth}px`,
+    };
+  };
+
+  useEffect(() => {
+    let timerId: any = null;
+    if (props.playing) {
+      timerId = setInterval(() => {
+        // eslint-disable-next-line @typescript-eslint/no-shadow
+        setTime((time) => time + 1000);
+      }, 1000);
+    }
+    return () => timerId && clearInterval(timerId);
+  }, [props.playing]);
+
+  useEffect(() => {
+    if (time >= startT && time <= endT && props.data && props.data.length) {
+      setTimeAndPosition((time - startT) / 3600000 / 24);
+    }
+  }, [time]);
+
+  return (
+    <div className={'time-line-warp'}>
+      <div className={'time-line-clock'}>
+        {Array.from(Array(25), (v, k) => k).map((item) => {
+          return <div key={item}>{item}</div>;
+        })}
+      </div>
+      <div className={'time-line-content'} ref={LineContent}>
+        <div className={'time-line-progress'}>
+          {list.map((item, index) => {
+            const { left, width } = getLineItemStyle(
+              item.startTime || item.mediaStartTime,
+              item.endTime || item.mediaEndTime,
+            );
+
+            return (
+              <div
+                key={`time_${index}`}
+                onClick={(event) => {
+                  const pos = LineContent.current?.getBoundingClientRect();
+                  if (pos && item.endTime) {
+                    const dt = event.clientX - pos.x;
+                    const start = (dt / pos.width) * 24 * 3600000 + startT;
+                    const _start = start < item.startTime ? item.startTime : start;
+                    onChange(_start, item.endTime, item.deviceId, item.channelId);
+                  }
+                }}
+                style={{ left, width }}
+              ></div>
+            );
+          })}
+        </div>
+        <div id="btn" className={classNames('time-line-btn')}></div>
+        <div id="time" className={classNames('time-line')}>
+          {moment(time).format('HH:mm:ss')}
+        </div>
+      </div>
+    </div>
+  );
+};
+export default Progress;

+ 5 - 0
src/pages/media/Device/Playback/typings.d.ts

@@ -6,5 +6,10 @@ export type recordsItemType = {
   name: string;
   secrecy: string;
   startTime: number;
+  mediaEndTime: number;
+  mediaStartTime: number;
+  filePath: string;
   type: string;
+  id: string;
+  isServer?: boolean;
 };

+ 15 - 5
src/pages/media/Device/Save/index.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useCallback, 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';
@@ -25,6 +25,7 @@ export default (props: SaveProps) => {
   const [productVisible, setProductVisible] = useState(false);
   const [accessType, setAccessType] = useState(DefaultAccessType);
   const [productList, setProductList] = useState<any[]>([]);
+  const [oldPassword, setOldPassword] = useState('');
 
   const getProductList = async (productParams: any) => {
     const resp = await service.queryProductList(productParams);
@@ -44,6 +45,7 @@ export default (props: SaveProps) => {
 
   useEffect(() => {
     if (visible) {
+      setOldPassword('');
       if (props.model === 'edit') {
         form.setFieldsValue(data);
         const _accessType = data?.provider || DefaultAccessType;
@@ -60,10 +62,13 @@ export default (props: SaveProps) => {
     }
   }, [visible]);
 
-  const handleSave = async () => {
+  const handleSave = useCallback(async () => {
     const formData = await form.validateFields();
     if (formData) {
       const { provider, ...extraFormData } = formData;
+      if (formData.password === oldPassword) {
+        delete extraFormData.password;
+      }
       setLoading(true);
       const resp =
         provider === DefaultAccessType
@@ -81,7 +86,7 @@ export default (props: SaveProps) => {
         message.error('操作失败');
       }
     }
-  };
+  }, [props.model, oldPassword]);
 
   const intlFormat = (
     id: string,
@@ -107,8 +112,6 @@ export default (props: SaveProps) => {
     );
   };
 
-  console.log(productList);
-
   return (
     <>
       <Modal
@@ -208,6 +211,13 @@ export default (props: SaveProps) => {
                     options={productList}
                     placeholder={'请选择所属产品'}
                     style={{ width: props.model === 'edit' ? '100%' : 'calc(100% - 36px)' }}
+                    onSelect={(_: any, node: any) => {
+                      const pasd = node.configuration ? node.configuration.access_pwd : '';
+                      form.setFieldsValue({
+                        password: pasd,
+                      });
+                      setOldPassword(pasd);
+                    }}
                   />
                 </Form.Item>
                 {props.model !== 'edit' && (

+ 28 - 18
src/pages/media/Stream/index.tsx

@@ -5,7 +5,7 @@ import type { ProColumns } from '@jetlinks/pro-table';
 import { Button, Card, Col, Empty, message, Pagination, Popconfirm, Row, Space } from 'antd';
 import { useEffect, useState } from 'react';
 import Service from '@/pages/media/Stream/service';
-import { getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
+import { getButtonPermission, getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
 import { useHistory } from 'umi';
 import styles from './index.less';
 import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
@@ -76,6 +76,7 @@ const Stream = () => {
         {dataSource.data.length > 0 ? (
           <>
             <Button
+              disabled={getButtonPermission('media/Stream', ['add'])}
               type="primary"
               onClick={() => {
                 history.push(`${getMenuPathByParams(MENUS_CODE['media/Stream/Detail'])}`);
@@ -94,7 +95,10 @@ const Stream = () => {
                         <div className={styles.title}>{item?.name}</div>
                         <div className={styles.actions}>
                           <Space>
-                            <span
+                            <Button
+                              type="link"
+                              style={{ padding: 0 }}
+                              disabled={getButtonPermission('media/Stream', ['update'])}
                               className={styles.action}
                               onClick={() => {
                                 history.push(
@@ -108,23 +112,29 @@ const Stream = () => {
                             >
                               <EditOutlined style={{ color: '#000000' }} />
                               <span>编辑</span>
-                            </span>
-                            <Popconfirm
-                              title={'确认删除?'}
-                              onConfirm={() => {
-                                service.remove(item.id).then((resp: any) => {
-                                  if (resp.status === 200) {
-                                    message.success('操作成功!');
-                                    handleSearch({ pageSize: 10, terms: [] });
-                                  }
-                                });
-                              }}
+                            </Button>
+                            <Button
+                              type="link"
+                              style={{ padding: 0 }}
+                              disabled={getButtonPermission('media/Stream', ['delete'])}
                             >
-                              <span className={styles.action}>
-                                <DeleteOutlined style={{ color: '#E50012' }} />
-                                <span>删除</span>
-                              </span>
-                            </Popconfirm>
+                              <Popconfirm
+                                title={'确认删除?'}
+                                onConfirm={() => {
+                                  service.remove(item.id).then((resp: any) => {
+                                    if (resp.status === 200) {
+                                      message.success('操作成功!');
+                                      handleSearch({ pageSize: 10, terms: [] });
+                                    }
+                                  });
+                                }}
+                              >
+                                <span className={styles.action}>
+                                  <DeleteOutlined style={{ color: '#E50012' }} />
+                                  <span>删除</span>
+                                </span>
+                              </Popconfirm>
+                            </Button>
                           </Space>
                         </div>
                       </div>

+ 186 - 135
src/pages/rule-engine/Instance/index.tsx

@@ -19,6 +19,7 @@ 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';
+import { getButtonPermission } from '@/utils/menu';
 
 export const service = new Service('rule-engine/instance');
 
@@ -30,79 +31,62 @@ const Instance = () => {
   const [searchParams, setSearchParams] = useState<any>({});
 
   const tools = (record: InstanceItem) => [
-    <Tooltip
-      title={intl.formatMessage({
-        id: 'pages.data.option.edit',
-        defaultMessage: '编辑',
-      })}
+    <Button
       key={'edit'}
-    >
-      <Button
-        type={'link'}
-        style={{ padding: 0 }}
-        onClick={() => {
-          setCurrent(record);
-          setVisible(true);
-        }}
-      >
-        <EditOutlined />
-      </Button>
-    </Tooltip>,
-    <Tooltip
-      title={intl.formatMessage({
-        id: 'pages.data.option.detail',
-        defaultMessage: '查看',
-      })}
-      key={'detail'}
-    >
-      <Button
-        type={'link'}
-        style={{ padding: 0 }}
-        onClick={() => {
-          window.open(`/${SystemConst.API_BASE}/rule-editor/index.html#flow/${record.id}`);
-        }}
-      >
-        <EyeOutlined />
-      </Button>
-    </Tooltip>,
-    <Popconfirm
-      key={'state'}
-      title={intl.formatMessage({
-        id: `pages.data.option.${record.state.value !== 'stopped' ? 'disabled' : 'enabled'}.tips`,
-        defaultMessage: '确认禁用?',
-      })}
-      onConfirm={async () => {
-        if (record.state.value !== 'stopped') {
-          await service.stopRule(record.id);
-        } else {
-          await service.startRule(record.id);
-        }
-        message.success(
-          intl.formatMessage({
-            id: 'pages.data.option.success',
-            defaultMessage: '操作成功!',
-          }),
-        );
-        actionRef.current?.reload();
+      type={'link'}
+      style={{ padding: 0 }}
+      onClick={() => {
+        setCurrent(record);
+        setVisible(true);
       }}
+      disabled={getButtonPermission('rule-engine/Instance', ['update'])}
     >
       <Tooltip
         title={intl.formatMessage({
-          id: `pages.data.option.${record.state.value !== 'stopped' ? 'disabled' : 'enabled'}`,
-          defaultMessage: record.state.value !== 'stopped' ? '禁用' : '启用',
+          id: 'pages.data.option.edit',
+          defaultMessage: '编辑',
         })}
+        key={'edit'}
       >
-        <Button type={'link'} style={{ padding: 0 }}>
-          {record.state.value !== 'stopped' ? <StopOutlined /> : <CheckCircleOutlined />}
-        </Button>
+        <EditOutlined />
       </Tooltip>
-    </Popconfirm>,
-    <Popconfirm
-      title={record.state.value === 'stopped' ? '确认删除' : '未停止不能删除'}
-      key={'delete'}
-      onConfirm={async () => {
-        if (record.state.value === 'stopped') {
-          await service.remove(record.id);
+    </Button>,
+    // <Button key={'view'}
+    //   disabled={getButtonPermission('rule-engine/Instance', ['view'])}
+    //   type={'link'}
+    //   style={{ padding: 0 }}
+    //   onClick={() => {
+    //     window.open(`/${SystemConst.API_BASE}/rule-editor/index.html#flow/${record.id}`);
+    //   }}
+    // >
+    //   <Tooltip
+    //     title={intl.formatMessage({
+    //       id: 'pages.data.option.detail',
+    //       defaultMessage: '查看',
+    //     })}
+    //     key={'detail'}
+    //   >
+    //     <EyeOutlined />
+    //   </Tooltip>
+    // </Button>,
+    <Button
+      key={'operate'}
+      disabled={getButtonPermission('rule-engine/Instance', ['action'])}
+      type={'link'}
+      style={{ padding: 0 }}
+    >
+      <Popconfirm
+        key={'state'}
+        title={intl.formatMessage({
+          id: `pages.data.option.${record.state.value !== 'stopped' ? 'disabled' : 'enabled'}.tips`,
+          defaultMessage: '确认禁用?',
+        })}
+        onConfirm={async () => {
+          if (record.state.value !== 'stopped') {
+            await service.stopRule(record.id);
+          } else {
+            await service.startRule(record.id);
+          }
           message.success(
             intl.formatMessage({
               id: 'pages.data.option.success',
@@ -110,22 +94,52 @@ const Instance = () => {
             }),
           );
           actionRef.current?.reload();
-        } else {
-          message.error('未停止不能删除');
-        }
-      }}
+        }}
+      >
+        <Tooltip
+          title={intl.formatMessage({
+            id: `pages.data.option.${record.state.value !== 'stopped' ? 'disabled' : 'enabled'}`,
+            defaultMessage: record.state.value !== 'stopped' ? '禁用' : '启用',
+          })}
+        >
+          {record.state.value !== 'stopped' ? <StopOutlined /> : <CheckCircleOutlined />}
+        </Tooltip>
+      </Popconfirm>
+    </Button>,
+    <Button
+      type={'link'}
+      key={'delete'}
+      style={{ padding: 0 }}
+      disabled={getButtonPermission('rule-engine/Instance', ['delete'])}
     >
-      <Tooltip
-        title={intl.formatMessage({
-          id: 'pages.data.option.remove',
-          defaultMessage: '删除',
-        })}
+      <Popconfirm
+        title={record.state.value === 'stopped' ? '确认删除' : '未停止不能删除'}
+        key={'delete'}
+        onConfirm={async () => {
+          if (record.state.value === 'stopped') {
+            await service.remove(record.id);
+            message.success(
+              intl.formatMessage({
+                id: 'pages.data.option.success',
+                defaultMessage: '操作成功!',
+              }),
+            );
+            actionRef.current?.reload();
+          } else {
+            message.error('未停止不能删除');
+          }
+        }}
       >
-        <Button type={'link'} style={{ padding: 0 }}>
+        <Tooltip
+          title={intl.formatMessage({
+            id: 'pages.data.option.remove',
+            defaultMessage: '删除',
+          })}
+        >
           <DeleteOutlined />
-        </Button>
-      </Tooltip>
-    </Popconfirm>,
+        </Tooltip>
+      </Popconfirm>
+    </Button>,
   ];
 
   const columns: ProColumns<InstanceItem>[] = [
@@ -181,7 +195,10 @@ const Instance = () => {
       align: 'center',
       width: 200,
       render: (text, record) => [
-        <a
+        <Button
+          type="link"
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('rule-engine/Instance', ['update'])}
           key={'edit'}
           onClick={() => {
             setCurrent(record);
@@ -196,9 +213,12 @@ const Instance = () => {
           >
             <EditOutlined />
           </Tooltip>
-        </a>,
-        <a
-          key={'see'}
+        </Button>,
+        <Button
+          type="link"
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('rule-engine/Instance', ['view'])}
+          key={'view'}
           onClick={() => {
             window.open(`/${SystemConst.API_BASE}/rule-editor/index.html#flow/${record.id}`);
           }}
@@ -211,47 +231,27 @@ const Instance = () => {
           >
             <EyeOutlined />
           </Tooltip>
-        </a>,
-        <Popconfirm
-          key={'state'}
-          title={intl.formatMessage({
-            id: `pages.data.option.${
-              record.state.value !== 'stopped' ? 'disabled' : 'enabled'
-            }.tips`,
-            defaultMessage: '确认禁用?',
-          })}
-          onConfirm={async () => {
-            if (record.state.value !== 'stopped') {
-              await service.stopRule(record.id);
-            } else {
-              await service.startRule(record.id);
-            }
-            message.success(
-              intl.formatMessage({
-                id: 'pages.data.option.success',
-                defaultMessage: '操作成功!',
-              }),
-            );
-            actionRef.current?.reload();
-          }}
+        </Button>,
+        <Button
+          type={'link'}
+          style={{ padding: 0 }}
+          key={'operate'}
+          disabled={getButtonPermission('rule-engine/Instance', ['action'])}
         >
-          <Tooltip
+          <Popconfirm
+            key={'state'}
             title={intl.formatMessage({
-              id: `pages.data.option.${record.state.value !== 'stopped' ? 'disabled' : 'enabled'}`,
-              defaultMessage: record.state.value !== 'stopped' ? '禁用' : '启用',
+              id: `pages.data.option.${
+                record.state.value !== 'stopped' ? 'disabled' : 'enabled'
+              }.tips`,
+              defaultMessage: '确认禁用?',
             })}
-          >
-            <Button type={'link'} style={{ padding: 0 }}>
-              {record.state.value !== 'stopped' ? <StopOutlined /> : <CheckCircleOutlined />}
-            </Button>
-          </Tooltip>
-        </Popconfirm>,
-        <Popconfirm
-          title={record.state.value === 'stopped' ? '确认删除' : '未停止不能删除'}
-          key={'delete'}
-          onConfirm={async () => {
-            if (record.state.value === 'stopped') {
-              await service.remove(record.id);
+            onConfirm={async () => {
+              if (record.state.value !== 'stopped') {
+                await service.stopRule(record.id);
+              } else {
+                await service.startRule(record.id);
+              }
               message.success(
                 intl.formatMessage({
                   id: 'pages.data.option.success',
@@ -259,22 +259,54 @@ const Instance = () => {
                 }),
               );
               actionRef.current?.reload();
-            } else {
-              message.error('未停止不能删除');
-            }
-          }}
+            }}
+          >
+            <Tooltip
+              title={intl.formatMessage({
+                id: `pages.data.option.${
+                  record.state.value !== 'stopped' ? 'disabled' : 'enabled'
+                }`,
+                defaultMessage: record.state.value !== 'stopped' ? '禁用' : '启用',
+              })}
+            >
+              {record.state.value !== 'stopped' ? <StopOutlined /> : <CheckCircleOutlined />}
+            </Tooltip>
+          </Popconfirm>
+        </Button>,
+        <Button
+          disabled={getButtonPermission('rule-engine/Instance', ['delete'])}
+          type={'link'}
+          key={'delete'}
+          style={{ padding: 0 }}
         >
-          <Tooltip
-            title={intl.formatMessage({
-              id: 'pages.data.option.remove',
-              defaultMessage: '删除',
-            })}
+          <Popconfirm
+            title={record.state.value === 'stopped' ? '确认删除' : '未停止不能删除'}
+            key={'delete'}
+            onConfirm={async () => {
+              if (record.state.value === 'stopped') {
+                await service.remove(record.id);
+                message.success(
+                  intl.formatMessage({
+                    id: 'pages.data.option.success',
+                    defaultMessage: '操作成功!',
+                  }),
+                );
+                actionRef.current?.reload();
+              } else {
+                message.error('未停止不能删除');
+              }
+            }}
           >
-            <Button type={'link'} style={{ padding: 0 }}>
+            <Tooltip
+              title={intl.formatMessage({
+                id: 'pages.data.option.remove',
+                defaultMessage: '删除',
+              })}
+            >
               <DeleteOutlined />
-            </Button>
-          </Tooltip>
-        </Popconfirm>,
+            </Tooltip>
+          </Popconfirm>
+        </Button>,
       ],
     },
   ];
@@ -285,7 +317,6 @@ const Instance = () => {
         field={columns}
         target="device-instance"
         onSearch={(data) => {
-          console.log(data);
           // 重置分页数据
           actionRef.current?.reset?.();
           setSearchParams(data);
@@ -316,6 +347,7 @@ const Instance = () => {
               setVisible(true);
               setCurrent({});
             }}
+            disabled={getButtonPermission('rule-engine/Instance', ['add'])}
             style={{ marginRight: 12 }}
             key="button"
             icon={<PlusOutlined />}
@@ -327,7 +359,26 @@ const Instance = () => {
             })}
           </Button>,
         ]}
-        cardRender={(record) => <RuleInstanceCard {...record} actions={tools(record)} />}
+        cardRender={(record) => (
+          <RuleInstanceCard
+            {...record}
+            actions={tools(record)}
+            detail={
+              <div
+                style={{ padding: 8, fontSize: 24 }}
+                onClick={() => {
+                  if (!getButtonPermission('rule-engine/Instance', ['view'])) {
+                    window.open(
+                      `/${SystemConst.API_BASE}/rule-editor/index.html#flow/${record.id}`,
+                    );
+                  }
+                }}
+              >
+                <EyeOutlined />
+              </div>
+            }
+          />
+        )}
       />
       {visible && (
         <Save

+ 47 - 24
src/pages/system/Department/index.tsx

@@ -4,7 +4,7 @@ import type { ActionType, ProColumns } from '@jetlinks/pro-table';
 import ProTable from '@jetlinks/pro-table';
 import * as React from 'react';
 import { useEffect, useRef, useState } from 'react';
-import { Link, useIntl, useLocation } from 'umi';
+import { history, useIntl, useLocation } from 'umi';
 import { Button, message, Popconfirm, Tooltip } from 'antd';
 import {
   DeleteOutlined,
@@ -21,7 +21,7 @@ import { observer } from '@formily/react';
 import { model } from '@formily/reactive';
 import Save from './save';
 import SearchComponent from '@/components/SearchComponent';
-import { getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
+import { getButtonPermission, getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
 
 export const service = new Service('organization');
 
@@ -85,7 +85,10 @@ export default observer(() => {
       valueType: 'option',
       width: 240,
       render: (text, record) => [
-        <a
+        <Button
+          style={{ padding: 0 }}
+          type="link"
+          disabled={getButtonPermission('system/Department', ['add', 'update'])}
           key="editable"
           onClick={() => {
             State.current = record;
@@ -100,8 +103,11 @@ export default observer(() => {
           >
             <EditOutlined />
           </Tooltip>
-        </a>,
-        <a
+        </Button>,
+        <Button
+          style={{ padding: 0 }}
+          type="link"
+          disabled={getButtonPermission('system/Department', ['add'])}
           key="editable"
           onClick={() => {
             State.current = {
@@ -118,13 +124,20 @@ export default observer(() => {
           >
             <PlusCircleOutlined />
           </Tooltip>
-        </a>,
-        <Link
+        </Button>,
+        <Button
+          type="link"
+          style={{ padding: 0 }}
           key="assets"
-          to={`${getMenuPathByParams(
-            MENUS_CODE['system/Department/Detail'],
-            record.id,
-          )}?type=assets`}
+          disabled={getButtonPermission('system/Department', ['add', 'view', 'update'])}
+          onClick={() => {
+            history.push(
+              `${getMenuPathByParams(
+                MENUS_CODE['system/Department/Detail'],
+                record.id,
+              )}?type=assets`,
+            );
+          }}
         >
           <Tooltip
             title={intl.formatMessage({
@@ -134,10 +147,17 @@ export default observer(() => {
           >
             <MedicineBoxOutlined />
           </Tooltip>
-        </Link>,
-        <Link
+        </Button>,
+        <Button
+          type="link"
           key="user"
-          to={`${getMenuPathByParams(MENUS_CODE['system/Department/Detail'], record.id)}?type=user`}
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('system/Department', ['add', 'view', 'update'])}
+          onClick={() =>
+            history.push(
+              `${getMenuPathByParams(MENUS_CODE['system/Department/Detail'], record.id)}?type=user`,
+            )
+          }
         >
           <Tooltip
             title={intl.formatMessage({
@@ -147,7 +167,7 @@ export default observer(() => {
           >
             <TeamOutlined />
           </Tooltip>
-        </Link>,
+        </Button>,
         <Popconfirm
           key="unBindUser"
           title={intl.formatMessage({
@@ -157,6 +177,7 @@ export default observer(() => {
           onConfirm={() => {
             deleteItem(record.id);
           }}
+          disabled={getButtonPermission('system/Department', ['delete'])}
         >
           <Tooltip
             title={intl.formatMessage({
@@ -164,9 +185,14 @@ export default observer(() => {
               defaultMessage: '删除',
             })}
           >
-            <a key="delete">
+            <Button
+              style={{ padding: 0 }}
+              type="link"
+              disabled={getButtonPermission('system/Department', ['delete'])}
+              key="delete"
+            >
               <DeleteOutlined />
-            </a>
+            </Button>
           </Tooltip>
         </Popconfirm>,
       ],
@@ -293,8 +319,9 @@ export default observer(() => {
         pagination={false}
         search={false}
         params={param}
-        toolBarRender={() => [
+        headerTitle={
           <Button
+            disabled={getButtonPermission('system/Department', ['add'])}
             onClick={() => (State.visible = true)}
             key="button"
             icon={<PlusOutlined />}
@@ -304,12 +331,8 @@ export default observer(() => {
               id: 'pages.data.option.add',
               defaultMessage: '新增',
             })}
-          </Button>,
-        ]}
-        headerTitle={intl.formatMessage({
-          id: 'pages.system.department',
-          defaultMessage: '部门列表',
-        })}
+          </Button>
+        }
       />
       <Save<DepartmentItem>
         title={

+ 22 - 14
src/pages/system/Menu/index.tsx

@@ -18,7 +18,7 @@ import SearchComponent from '@/components/SearchComponent';
 import Service from './service';
 import type { MenuItem } from './typing';
 import moment from 'moment';
-import { getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
+import { getButtonPermission, getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
 
 export const service = new Service('menu');
 
@@ -130,11 +130,14 @@ export default observer(() => {
       valueType: 'option',
       width: 240,
       render: (_, record) => [
-        <a
+        <Button
+          type="link"
           key="view"
+          style={{ padding: 0 }}
           onClick={() => {
             pageJump(record.id, record.parentId || '');
           }}
+          disabled={getButtonPermission('system/Menu', ['view'])}
         >
           <Tooltip
             title={intl.formatMessage({
@@ -144,9 +147,12 @@ export default observer(() => {
           >
             <SearchOutlined />
           </Tooltip>
-        </a>,
-        <a
+        </Button>,
+        <Button
+          type="link"
+          style={{ padding: 0 }}
           key="editable"
+          disabled={getButtonPermission('system/Menu', ['add'])}
           onClick={() => {
             State.current = {
               parentId: record.id,
@@ -163,7 +169,7 @@ export default observer(() => {
           >
             <PlusCircleOutlined />
           </Tooltip>
-        </a>,
+        </Button>,
         <Popconfirm
           key="unBindUser"
           title={intl.formatMessage({
@@ -180,9 +186,14 @@ export default observer(() => {
               defaultMessage: '删除',
             })}
           >
-            <a key="delete">
+            <Button
+              type="link"
+              style={{ padding: 0 }}
+              disabled={getButtonPermission('system/Menu', ['delete'])}
+              key="delete"
+            >
               <DeleteOutlined />
-            </a>
+            </Button>
           </Tooltip>
         </Popconfirm>,
       ],
@@ -253,8 +264,9 @@ export default observer(() => {
             status: response.status,
           };
         }}
-        toolBarRender={() => [
+        headerTitle={
           <Button
+            disabled={getButtonPermission('system/Menu', ['add'])}
             onClick={() => {
               pageJump();
             }}
@@ -266,12 +278,8 @@ export default observer(() => {
               id: 'pages.data.option.add',
               defaultMessage: '新增',
             })}
-          </Button>,
-        ]}
-        headerTitle={intl.formatMessage({
-          id: 'pages.system.menu',
-          defaultMessage: '菜单列表',
-        })}
+          </Button>
+        }
       />
       {/*<Modal*/}
       {/*  title={intl.formatMessage({*/}

+ 55 - 44
src/pages/system/Permission/index.tsx

@@ -7,7 +7,7 @@ import {
   PlayCircleOutlined,
   PlusOutlined,
 } from '@ant-design/icons';
-import { Badge, Button, Dropdown, Menu, message, Popconfirm, Tooltip, Upload } from 'antd';
+import { Badge, Button, Dropdown, Menu, message, Popconfirm, Space, Tooltip, Upload } from 'antd';
 import type { ActionType, ProColumns } from '@jetlinks/pro-table';
 import ProTable from '@jetlinks/pro-table';
 import { useIntl } from '@@/plugin-locale/localeExports';
@@ -19,6 +19,7 @@ import Save from './Save';
 import SystemConst from '@/utils/const';
 import { downloadObject } from '@/utils/util';
 import Token from '@/utils/token';
+import { getButtonPermission } from '@/utils/menu';
 
 export const service = new Service('permission');
 const Permission: React.FC = observer(() => {
@@ -62,11 +63,12 @@ const Permission: React.FC = observer(() => {
             };
           }}
         >
-          <Button>导入</Button>
+          <Button disabled={getButtonPermission('system/Permission', ['import'])}>导入</Button>
         </Upload>
       </Menu.Item>
       <Menu.Item key="export">
         <Popconfirm
+          disabled={getButtonPermission('system/Permission', ['export'])}
           title={'确认导出?'}
           onConfirm={() => {
             service.getPermission({ ...param, paging: false }).subscribe((resp) => {
@@ -79,7 +81,7 @@ const Permission: React.FC = observer(() => {
             });
           }}
         >
-          <Button>导出</Button>
+          <Button disabled={getButtonPermission('system/Permission', ['export'])}>导出</Button>
         </Popconfirm>
       </Menu.Item>
     </Menu>
@@ -142,7 +144,10 @@ const Permission: React.FC = observer(() => {
       valueType: 'option',
       width: 200,
       render: (text, record) => [
-        <a
+        <Button
+          type="link"
+          disabled={getButtonPermission('system/Permission', ['add'])}
+          style={{ padding: 0 }}
           key="editable"
           onClick={() => {
             edit(record);
@@ -156,8 +161,13 @@ const Permission: React.FC = observer(() => {
           >
             <EditOutlined />
           </Tooltip>
-        </a>,
-        <a key="view">
+        </Button>,
+        <Button
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('system/Permission', ['action'])}
+          type="link"
+          key="view"
+        >
           <Popconfirm
             title={intl.formatMessage({
               id: 'pages.data.option.disabled.tips',
@@ -187,19 +197,23 @@ const Permission: React.FC = observer(() => {
               {record.status ? <CloseCircleOutlined /> : <PlayCircleOutlined />}
             </Tooltip>
           </Popconfirm>
-        </a>,
-        <Tooltip
-          key="delete"
-          title={
-            record.status === 0
-              ? intl.formatMessage({
-                  id: 'pages.data.option.remove',
-                  defaultMessage: '删除',
-                })
-              : '请先禁用该权限,再删除。'
-          }
+        </Button>,
+        <Button
+          type="link"
+          style={{ padding: 0 }}
+          disabled={record.status === 1 || getButtonPermission('system/Permission', ['delete'])}
         >
-          <Button type="link" style={{ padding: 0 }} disabled={record.status === 1}>
+          <Tooltip
+            key="delete"
+            title={
+              record.status === 0
+                ? intl.formatMessage({
+                    id: 'pages.data.option.remove',
+                    defaultMessage: '删除',
+                  })
+                : '请先禁用该权限,再删除。'
+            }
+          >
             <Popconfirm
               title={intl.formatMessage({
                 id: 'pages.data.option.remove.tips',
@@ -218,8 +232,8 @@ const Permission: React.FC = observer(() => {
             >
               <DeleteOutlined />
             </Popconfirm>
-          </Button>
-        </Tooltip>,
+          </Tooltip>
+        </Button>,
       ],
     },
   ];
@@ -234,39 +248,36 @@ const Permission: React.FC = observer(() => {
           actionRef.current?.reset?.();
           setParam(data);
         }}
-        // onReset={() => {
-        //   // 重置分页及搜索参数
-        //   actionRef.current?.reset?.();
-        //   setParam({});
-        // }}
       />
       <ProTable<PermissionItem>
         actionRef={actionRef}
         params={param}
         columns={columns}
         search={false}
-        headerTitle={'权限列表'}
+        headerTitle={
+          <Space>
+            <Button
+              onClick={() => {
+                setMode('add');
+              }}
+              disabled={getButtonPermission('system/Permission', ['add'])}
+              key="button"
+              icon={<PlusOutlined />}
+              type="primary"
+            >
+              {intl.formatMessage({
+                id: 'pages.data.option.add',
+                defaultMessage: '新增',
+              })}
+            </Button>
+            <Dropdown key={'more'} overlay={menu} placement="bottom">
+              <Button>批量操作</Button>
+            </Dropdown>
+          </Space>
+        }
         request={async (params) =>
           service.query({ ...params, sorts: [{ name: 'id', order: 'asc' }] })
         }
-        toolBarRender={() => [
-          <Button
-            onClick={() => {
-              setMode('add');
-            }}
-            key="button"
-            icon={<PlusOutlined />}
-            type="primary"
-          >
-            {intl.formatMessage({
-              id: 'pages.data.option.add',
-              defaultMessage: '新增',
-            })}
-          </Button>,
-          <Dropdown key={'more'} overlay={menu} placement="bottom">
-            <Button>批量操作</Button>
-          </Dropdown>,
-        ]}
       />
       <Save
         model={model}

+ 20 - 7
src/pages/system/Role/index.tsx

@@ -1,17 +1,17 @@
 import { PageContainer } from '@ant-design/pro-layout';
 import React, { useEffect, useRef } from 'react';
 import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
-import { message, Popconfirm, Tooltip } from 'antd';
+import { Button, message, Popconfirm, Tooltip } from 'antd';
 import type { ActionType, ProColumns } from '@jetlinks/pro-table';
 import BaseCrud from '@/components/BaseCrud';
 import Service from './service';
 import { useIntl } from '@@/plugin-locale/localeExports';
 import { observer } from '@formily/react';
-import { Link, useLocation } from 'umi';
+import { history, useLocation } from 'umi';
 import { Store } from 'jetlinks-store';
 import SystemConst from '@/utils/const';
 import { CurdModel } from '@/components/BaseCrud/model';
-import { getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
+import { getButtonPermission, getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
 
 export const service = new Service('role');
 
@@ -83,7 +83,14 @@ const Role: React.FC = observer(() => {
       valueType: 'option',
       width: 200,
       render: (text, record) => [
-        <Link to={`${getMenuPathByParams(MENUS_CODE['system/Role/Detail'], record.id)}`} key="link">
+        <Button
+          type="link"
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('system/Role', ['update'])}
+          onClick={() =>
+            history.push(`${getMenuPathByParams(MENUS_CODE['system/Role/Detail'], record.id)}`)
+          }
+        >
           <Tooltip
             title={intl.formatMessage({
               id: 'pages.data.option.edit',
@@ -93,8 +100,13 @@ const Role: React.FC = observer(() => {
           >
             <EditOutlined />
           </Tooltip>
-        </Link>,
-        <a key="delete">
+        </Button>,
+        <Button
+          type="link"
+          disabled={getButtonPermission('system/Role', ['delete'])}
+          style={{ padding: 0 }}
+          key="delete"
+        >
           <Popconfirm
             title={intl.formatMessage({
               id: 'pages.data.option.remove.tips',
@@ -120,7 +132,7 @@ const Role: React.FC = observer(() => {
               <DeleteOutlined />
             </Tooltip>
           </Popconfirm>
-        </a>,
+        </Button>,
       ],
     },
   ];
@@ -195,6 +207,7 @@ const Role: React.FC = observer(() => {
   return (
     <PageContainer>
       <BaseCrud<RoleItem>
+        disableAdd={getButtonPermission('system/Role', ['add'])}
         actionRef={actionRef}
         moduleName="role"
         columns={columns}

+ 4 - 1
src/pages/system/User/Save/index.tsx

@@ -1,4 +1,4 @@
-import { message, Modal, TreeSelect as ATreeSelect } from 'antd';
+import { message, TreeSelect as ATreeSelect } from 'antd';
 import { useIntl } from 'umi';
 import type { Field } from '@formily/core';
 import { createForm } from '@formily/core';
@@ -11,6 +11,7 @@ import type { ISchema } from '@formily/json-schema';
 import { action } from '@formily/reactive';
 import type { Response } from '@/utils/typings';
 import { service } from '@/pages/system/User';
+import { Modal } from '@/components';
 
 interface Props {
   model: 'add' | 'edit' | 'query';
@@ -329,6 +330,8 @@ const Save = (props: Props) => {
       onCancel={props.close}
       onOk={save}
       width="35vw"
+      permissionCode={'system/User'}
+      permission={['add', 'edit']}
     >
       <Form form={form} layout="vertical">
         <SchemaField schema={schema} scope={{ useAsyncDataSource, getRole, getOrg }} />

+ 31 - 15
src/pages/system/User/index.tsx

@@ -15,6 +15,7 @@ import { useIntl } from '@@/plugin-locale/localeExports';
 import { useRef, useState } from 'react';
 import Save from './Save';
 import { observer } from '@formily/react';
+import { getButtonPermission } from '@/utils/menu';
 
 export const service = new Service('user');
 
@@ -112,7 +113,13 @@ const User = observer(() => {
       valueType: 'option',
       width: 200,
       render: (text, record) => [
-        <a key="editable" onClick={() => edit(record)}>
+        <Button
+          style={{ padding: 0 }}
+          type="link"
+          disabled={getButtonPermission('system/User', ['update', 'view'])}
+          key="editable"
+          onClick={() => edit(record)}
+        >
           <Tooltip
             title={intl.formatMessage({
               id: 'pages.data.option.edit',
@@ -121,8 +128,13 @@ const User = observer(() => {
           >
             <EditOutlined />
           </Tooltip>
-        </a>,
-        <a key="changeState">
+        </Button>,
+        <Button
+          style={{ padding: 0 }}
+          disabled={getButtonPermission('system/User', ['action'])}
+          type="link"
+          key="changeState"
+        >
           <Popconfirm
             title={intl.formatMessage({
               id: `pages.data.option.${record.status ? 'disabled' : 'enabled'}.tips`,
@@ -151,9 +163,13 @@ const User = observer(() => {
               {record.status ? <CloseCircleOutlined /> : <PlayCircleOutlined />}
             </Tooltip>
           </Popconfirm>
-        </a>,
-        <Tooltip title={record.status === 0 ? '删除' : '请先禁用该用户,再删除。'} key="delete">
-          <Button type="link" style={{ padding: 0 }} disabled={record.status === 1}>
+        </Button>,
+        <Button
+          type="link"
+          style={{ padding: 0 }}
+          disabled={record.status === 1 || getButtonPermission('system/User', 'delete')}
+        >
+          <Tooltip title={record.status === 0 ? '删除' : '请先禁用该用户,再删除。'} key="delete">
             <Popconfirm
               onConfirm={async () => {
                 await service.remove(record.id);
@@ -163,8 +179,8 @@ const User = observer(() => {
             >
               <DeleteOutlined />
             </Popconfirm>
-          </Button>
-        </Tooltip>,
+          </Tooltip>
+        </Button>,
       ],
     },
   ];
@@ -187,15 +203,12 @@ const User = observer(() => {
         params={param}
         columns={columns}
         search={false}
-        headerTitle={'用户列表'}
-        request={async (params) =>
-          service.query({ ...params, sorts: [{ name: 'createTime', order: 'desc' }] })
-        }
-        toolBarRender={() => [
+        headerTitle={
           <Button
             onClick={() => {
               setMode('add');
             }}
+            disabled={getButtonPermission('system/User', ['add'])}
             key="button"
             icon={<PlusOutlined />}
             type="primary"
@@ -204,8 +217,11 @@ const User = observer(() => {
               id: 'pages.data.option.add',
               defaultMessage: '新增',
             })}
-          </Button>,
-        ]}
+          </Button>
+        }
+        request={async (params) =>
+          service.query({ ...params, sorts: [{ name: 'createTime', order: 'desc' }] })
+        }
       />
       <Save
         model={model}

+ 9 - 1
src/utils/menu/router.ts

@@ -106,7 +106,15 @@ export enum MENUS_CODE {
 
 export type MENUS_CODE_TYPE = keyof typeof MENUS_CODE;
 
-export type BUTTON_PERMISSION = 'add' | 'delete' | 'import' | 'view' | 'export' | 'update' | string;
+export type BUTTON_PERMISSION =
+  | 'add'
+  | 'delete'
+  | 'import'
+  | 'view'
+  | 'export'
+  | 'update'
+  | 'action'
+  | string;
 
 export const getDetailNameByCode = {
   'system/Menu/Detail': '菜单详情',

+ 12 - 0
src/utils/util.ts

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