ソースを参照

Merge branch 'next' into next-dev

Lind 4 年 前
コミット
0eee449a49

+ 7 - 0
config/routes.ts

@@ -70,6 +70,13 @@
         component: './system/Tenant',
       },
       {
+        hideInMenu: true,
+        path: '/system/tenant/detail/:id',
+        name: 'tenant-detail',
+        icon: 'smile',
+        component: './system/Tenant/Detail',
+      },
+      {
         path: '/system/datasource',
         name: 'datasource',
         icon: 'smile',

+ 7 - 7
package.json

@@ -61,13 +61,13 @@
     "@ant-design/pro-form": "^1.18.3",
     "@ant-design/pro-layout": "^6.15.3",
     "@dabeng/react-orgchart": "^1.0.0",
-    "@formily/antd": "2.0.0-beta.84",
-    "@formily/core": "2.0.0-beta.84",
-    "@formily/json-schema": "2.0.0-beta.84",
-    "@formily/react": "2.0.0-beta.84",
-    "@formily/reactive": "2.0.0-beta.84",
-    "@formily/reactive-react": "2.0.0-beta.84",
-    "@formily/shared": "2.0.0-beta.84",
+    "@formily/antd": "2.0.0-rc.14",
+    "@formily/core": "2.0.0-rc.14",
+    "@formily/json-schema": "2.0.0-rc.14",
+    "@formily/react": "2.0.0-rc.14",
+    "@formily/reactive": "2.0.0-rc.14",
+    "@formily/reactive-react": "2.0.0-rc.14",
+    "@formily/shared": "2.0.0-rc.14",
     "@jetlinks/pro-list": "^1.10.8",
     "@jetlinks/pro-table": "^2.43.7",
     "@umijs/route-utils": "^1.0.36",

+ 3 - 4
src/components/Authorization/index.less

@@ -1,11 +1,10 @@
 .action {
-  position: absolute;
+  position: fixed;
   right: 0;
   bottom: 0;
-  width: 100%;
-  padding: 10px 16px;
+  width: 50%;
+  padding: 16px;
   text-align: right;
-  background: 10px 16px;
   border-top: 1px solid #e9e9e9;
 }
 

+ 23 - 16
src/components/Authorization/index.tsx

@@ -68,6 +68,19 @@ const Authorization = observer((props: AuthorizationProps) => {
     [AuthorizationModel.data],
   );
 
+  const searchPermission = async (name: string, type: string) => {
+    AuthorizationModel.filterParam.name = name;
+    AuthorizationModel.filterParam.type = type;
+    AuthorizationModel.data = await db
+      .table(tableName)
+      .where('name')
+      .startsWith(name)
+      .filter((item) => (type === 'all' ? item : (item.type || []).includes(type)))
+      .distinct()
+      .reverse()
+      .sortBy('name');
+  };
+
   const initAutzInfo = useCallback(async () => {
     if (!target.id) {
       message.error('被授权对象数据缺失!');
@@ -106,6 +119,10 @@ const Authorization = observer((props: AuthorizationProps) => {
         error: () => {},
         complete: () => {
           AuthorizationModel.spinning = false;
+
+          if (props.type) {
+            searchPermission('', props.type);
+          }
         },
       });
   }, [target.id]);
@@ -129,25 +146,13 @@ const Authorization = observer((props: AuthorizationProps) => {
 
   useEffect(() => {
     initPermission();
+
     return () => {
       db.table(tableName).clear();
       AuthorizationModel.spinning = true;
     };
   }, [target.id]);
 
-  const searchPermission = async (name: string, type: string) => {
-    AuthorizationModel.filterParam.name = name;
-    AuthorizationModel.filterParam.type = type;
-    AuthorizationModel.data = await db
-      .table(tableName)
-      .where('name')
-      .startsWith(name)
-      .filter((item) => (type === 'all' ? item : (item.type || []).includes(type)))
-      .distinct()
-      .reverse()
-      .sortBy('name');
-  };
-
   const setAutz = (data: unknown[]) => {
     const permissions = Object.keys(data)
       .filter((i) => !i.startsWith('_'))
@@ -277,10 +282,12 @@ const Authorization = observer((props: AuthorizationProps) => {
               <Col span={4}>
                 <Select
                   onSelect={(type: string) =>
-                    searchPermission(AuthorizationModel.filterParam.name, type)
+                    searchPermission(AuthorizationModel.filterParam.name, props.type || type)
                   }
                   style={{ width: '100%' }}
-                  defaultValue={'all'}
+                  defaultValue={props.type || 'all'}
+                  // 如果传了类型,那么授权不能更改类型
+                  disabled={!!props.type}
                   options={permissionType}
                 />
               </Col>
@@ -291,7 +298,7 @@ const Authorization = observer((props: AuthorizationProps) => {
                     defaultMessage: '请输入权限名称',
                   })}
                   onSearch={(name: string) =>
-                    searchPermission(name, AuthorizationModel.filterParam?.type)
+                    searchPermission(name, props.type || AuthorizationModel.filterParam?.type)
                   }
                 />
               </Col>

+ 1 - 0
src/components/Authorization/typings.d.ts

@@ -37,4 +37,5 @@ interface AuthorizationProps {
     type: string;
   };
   close: () => void;
+  type?: string;
 }

+ 2 - 0
src/components/BaseCrud/save/index.tsx

@@ -26,6 +26,7 @@ import SystemConst from '@/utils/const';
 import { CurdModel } from '@/components/BaseCrud/model';
 import type { ISchemaFieldProps } from '@formily/react/lib/types';
 import type { ModalProps } from 'antd/lib/modal/Modal';
+import FUpload from '@/components/Upload';
 
 interface Props<T> {
   schema: ISchema;
@@ -72,6 +73,7 @@ const Save = <T extends Record<string, any>>(props: Props<T>) => {
       FormGrid,
       Editable,
       NumberPicker,
+      FUpload,
     },
     scope: {
       icon(name: any) {

+ 202 - 0
src/components/Upload/Upload.tsx

@@ -0,0 +1,202 @@
+import React, { useEffect } from 'react';
+import type { Field } from '@formily/core';
+import { connect, mapProps, useField } from '@formily/react';
+import { Upload as AntdUpload, Button } from 'antd';
+import type {
+  UploadChangeParam,
+  UploadProps as AntdUploadProps,
+  DraggerProps as AntdDraggerProps,
+} from 'antd/lib/upload';
+import { InboxOutlined, UploadOutlined } from '@ant-design/icons';
+import { reaction } from '@formily/reactive';
+import type { UploadFile } from 'antd/lib/upload/interface';
+import { isArr, toArr } from '@formily/shared';
+import { UPLOAD_PLACEHOLDER } from './placeholder';
+import { usePrefixCls } from '@formily/antd/lib/__builtins__';
+
+type UploadProps = Omit<AntdUploadProps, 'onChange'> & {
+  textContent?: React.ReactNode;
+  onChange?: (fileList: UploadFile[]) => void;
+  serviceErrorMessage?: string;
+};
+
+type DraggerProps = Omit<AntdDraggerProps, 'onChange'> & {
+  textContent?: React.ReactNode;
+  onChange?: (fileList: UploadFile[]) => void;
+  serviceErrorMessage?: string;
+};
+
+type ComposedUpload = React.FC<UploadProps> & {
+  Dragger?: React.FC<DraggerProps>;
+};
+
+type IUploadProps = {
+  serviceErrorMessage?: string;
+  onChange?: (...args: any) => void;
+};
+
+const testOpts = (ext: RegExp, options: { exclude?: string[]; include?: string[] }) => {
+  if (options && isArr(options.include)) {
+    return options.include.some((url) => ext.test(url));
+  }
+
+  if (options && isArr(options.exclude)) {
+    return !options.exclude.some((url) => ext.test(url));
+  }
+
+  return true;
+};
+
+const getImageByUrl = (url: string, options: any) => {
+  for (let i = 0; i < UPLOAD_PLACEHOLDER.length; i += 1) {
+    if (UPLOAD_PLACEHOLDER[i].ext.test(url) && testOpts(UPLOAD_PLACEHOLDER[i].ext, options)) {
+      return UPLOAD_PLACEHOLDER[i].icon || url;
+    }
+  }
+
+  return url;
+};
+
+const getURL = (target: any) => {
+  return target?.['result'] || target?.['url'] || target?.['downloadURL'] || target?.['imgURL'];
+};
+const getThumbURL = (target: any) => {
+  return (
+    target?.['result'] ||
+    target?.['thumbUrl'] ||
+    target?.['url'] ||
+    target?.['downloadURL'] ||
+    target?.['imgURL']
+  );
+};
+
+const getErrorMessage = (target: any) => {
+  return target?.errorMessage ||
+    target?.errMsg ||
+    target?.errorMsg ||
+    target?.message ||
+    typeof target?.error === 'string'
+    ? target.error
+    : '';
+};
+
+const getState = (target: any) => {
+  if (target?.success === false) return 'error';
+  if (target?.failed === true) return 'error';
+  if (target?.error) return 'error';
+  return target?.state || target?.status;
+};
+
+const normalizeFileList = (fileList: UploadFile[]) => {
+  if (fileList && fileList.length) {
+    return fileList.map((file, index) => {
+      return {
+        ...file,
+        uid: file.uid || `${index}`,
+        status: getState(file.response) || getState(file),
+        url: getURL(file) || getURL(file?.response),
+        thumbUrl: getImageByUrl(getThumbURL(file) || getThumbURL(file?.response), {
+          exclude: ['.png', '.jpg', '.jpeg', '.gif'],
+        }),
+      };
+    });
+  }
+  return [];
+};
+
+const useValidator = (validator: (value: any) => string) => {
+  const field = useField<Field>();
+  useEffect(() => {
+    const dispose = reaction(
+      () => field.value,
+      (value) => {
+        const message = validator(value);
+        field.setFeedback({
+          type: 'error',
+          code: 'UploadError',
+          messages: message ? [message] : [],
+        });
+      },
+    );
+    return () => {
+      dispose();
+    };
+  }, []);
+};
+
+const useUploadValidator = (serviceErrorMessage = 'Upload Service Error') => {
+  // eslint-disable-next-line consistent-return
+  useValidator((value) => {
+    const list = toArr(value);
+    for (let i = 0; i < list.length; i += 1) {
+      if (list[i]?.status === 'error') {
+        return (
+          getErrorMessage(list[i]?.response) || getErrorMessage(list[i]) || serviceErrorMessage
+        );
+      }
+    }
+  });
+};
+
+function useUploadProps<T extends IUploadProps = UploadProps>({
+  serviceErrorMessage,
+  ...props
+}: T) {
+  useUploadValidator(serviceErrorMessage);
+  const onChange = (param: UploadChangeParam<UploadFile>) => {
+    props.onChange?.(normalizeFileList([...param.fileList]));
+  };
+  return {
+    ...props,
+    onChange,
+  };
+}
+
+const getPlaceholder = (props: UploadProps) => {
+  if (props.listType !== 'picture-card') {
+    return (
+      <Button>
+        <UploadOutlined />
+        {props.textContent}
+      </Button>
+    );
+  }
+  return <UploadOutlined style={{ fontSize: 20 }} />;
+};
+
+export const Upload: ComposedUpload = connect(
+  (props: React.PropsWithChildren<UploadProps>) => {
+    return (
+      <AntdUpload {...useUploadProps(props)}>{props.children || getPlaceholder(props)}</AntdUpload>
+    );
+  },
+  mapProps({
+    value: 'fileList',
+  }),
+);
+
+const Dragger = connect(
+  (props: React.PropsWithChildren<DraggerProps>) => {
+    return (
+      <div className={usePrefixCls('upload-dragger')}>
+        <AntdUpload.Dragger {...useUploadProps(props)}>
+          {props.children || (
+            <React.Fragment>
+              <p className="ant-upload-drag-icon">
+                <InboxOutlined />
+              </p>
+              {props.textContent && <p className="ant-upload-text">{props.textContent}</p>}
+            </React.Fragment>
+          )}
+        </AntdUpload.Dragger>
+      </div>
+    );
+  },
+  mapProps({
+    value: 'fileList',
+  }),
+);
+
+Upload.Dragger = Dragger;
+
+export default Upload;

+ 20 - 0
src/components/Upload/index.tsx

@@ -0,0 +1,20 @@
+import { Button } from 'antd';
+import { UploadOutlined } from '@ant-design/icons';
+import SystemConst from '@/utils/const';
+import Token from '@/utils/token';
+import Upload from '@/components/Upload/Upload';
+
+const FUpload = (props: any) => {
+  return (
+    <Upload
+      {...props}
+      action={`/${SystemConst.API_BASE}/file/static`}
+      headers={{
+        'X-Access-Token': Token.get(),
+      }}
+    >
+      <Button icon={<UploadOutlined />}>{props.title}</Button>
+    </Upload>
+  );
+};
+export default FUpload;

+ 62 - 0
src/components/Upload/placeholder.ts

@@ -0,0 +1,62 @@
+export const UPLOAD_PLACEHOLDER = [
+  {
+    ext: /\.docx?$/i,
+    icon: '//img.alicdn.com/tfs/TB1n8jfr1uSBuNjy1XcXXcYjFXa-200-200.png',
+  },
+  {
+    ext: /\.pptx?$/i,
+    icon: '//img.alicdn.com/tfs/TB1ItgWr_tYBeNjy1XdXXXXyVXa-200-200.png',
+  },
+  {
+    ext: /\.jpe?g$/i,
+    icon: '//img.alicdn.com/tfs/TB1wrT5r9BYBeNjy0FeXXbnmFXa-200-200.png',
+  },
+  {
+    ext: /\.pdf$/i,
+    icon: '//img.alicdn.com/tfs/TB1GwD8r9BYBeNjy0FeXXbnmFXa-200-200.png',
+  },
+  {
+    ext: /\.png$/i,
+    icon: '//img.alicdn.com/tfs/TB1BHT5r9BYBeNjy0FeXXbnmFXa-200-200.png',
+  },
+  {
+    ext: /\.eps$/i,
+    icon: '//img.alicdn.com/tfs/TB1G_iGrVOWBuNjy0FiXXXFxVXa-200-200.png',
+  },
+  {
+    ext: /\.ai$/i,
+    icon: '//img.alicdn.com/tfs/TB1B2cVr_tYBeNjy1XdXXXXyVXa-200-200.png',
+  },
+  {
+    ext: /\.gif$/i,
+    icon: '//img.alicdn.com/tfs/TB1DTiGrVOWBuNjy0FiXXXFxVXa-200-200.png',
+  },
+  {
+    ext: /\.svg$/i,
+    icon: '//img.alicdn.com/tfs/TB1uUm9rY9YBuNjy0FgXXcxcXXa-200-200.png',
+  },
+  {
+    ext: /\.xlsx?$/i,
+    icon: '//img.alicdn.com/tfs/TB1any1r1OSBuNjy0FdXXbDnVXa-200-200.png',
+  },
+  {
+    ext: /\.psd?$/i,
+    icon: '//img.alicdn.com/tfs/TB1_nu1r1OSBuNjy0FdXXbDnVXa-200-200.png',
+  },
+  {
+    ext: /\.(wav|aif|aiff|au|mp1|mp2|mp3|ra|rm|ram|mid|rmi)$/i,
+    icon: '//img.alicdn.com/tfs/TB1jPvwr49YBuNjy0FfXXXIsVXa-200-200.png',
+  },
+  {
+    ext: /\.(avi|wmv|mpg|mpeg|vob|dat|3gp|mp4|mkv|rm|rmvb|mov|flv)$/i,
+    icon: '//img.alicdn.com/tfs/TB1FrT5r9BYBeNjy0FeXXbnmFXa-200-200.png',
+  },
+  {
+    ext: /\.(zip|rar|arj|z|gz|iso|jar|ace|tar|uue|dmg|pkg|lzh|cab)$/i,
+    icon: '//img.alicdn.com/tfs/TB10jmfr29TBuNjy0FcXXbeiFXa-200-200.png',
+  },
+  {
+    ext: /\.[^.]+$/i,
+    icon: '//img.alicdn.com/tfs/TB10.R4r3mTBuNjy1XbXXaMrVXa-200-200.png',
+  },
+];

+ 1 - 0
src/components/Upload/style.ts

@@ -0,0 +1 @@
+import 'antd/lib/upload/style/index';

+ 9 - 9
src/pages/system/Org/Save/index.tsx

@@ -1,17 +1,17 @@
 import { message, Modal } from 'antd';
 import { createForm } from '@formily/core';
-import { createSchemaField, observer } from '@formily/react';
+import { createSchemaField } from '@formily/react';
 import { NumberPicker, Form, Input, FormItem } from '@formily/antd';
-import React from 'react';
 import { useIntl } from '@@/plugin-locale/localeExports';
 import OrgModel from '@/pages/system/Org/model';
 import { service } from '@/pages/system/Org';
 
 interface Props {
-  refresh: () => void;
+  refresh?: () => void;
+  visible: boolean;
 }
 
-const Save: React.FC<Props> = observer((props: Props) => {
+const Save = (props: Props) => {
   const intl = useIntl();
   const form = createForm({
     initialValues: OrgModel.current,
@@ -79,17 +79,17 @@ const Save: React.FC<Props> = observer((props: Props) => {
     await service.update({ ...data, parentId: OrgModel.parentId });
     message.success('保存成功');
     OrgModel.closeEdit();
-    props.refresh();
+    props.refresh?.();
   };
 
   return (
     <Modal
-      onOk={save}
+      onOk={() => save()}
       title={`${OrgModel.parentId ? '添加下级' : '编辑'}`}
-      visible={OrgModel.edit}
+      visible={props.visible}
       onCancel={() => {
         OrgModel.closeEdit();
-        props.refresh();
+        props.refresh?.();
       }}
     >
       <Form form={form} labelCol={5} wrapperCol={16}>
@@ -97,5 +97,5 @@ const Save: React.FC<Props> = observer((props: Props) => {
       </Form>
     </Modal>
   );
-});
+};
 export default Save;

+ 3 - 3
src/pages/system/Org/index.tsx

@@ -4,7 +4,7 @@ import styles from './index.less';
 import { Drawer, Menu, message, Modal } from 'antd';
 import NodeTemplate from '@/pages/system/Org/NodeTemplate';
 import { observer } from '@formily/react';
-import React, { useEffect } from 'react';
+import { useEffect } from 'react';
 import Service from '@/pages/system/Org/service';
 import encodeQuery from '@/utils/encodeQuery';
 import Save from '@/pages/system/Org/Save';
@@ -16,7 +16,7 @@ import BindUser from '@/components/BindUser';
 import OrgModel from '@/pages/system/Org/model';
 
 export const service = new Service('organization');
-const Org: React.FC = observer(() => {
+const Org = observer(() => {
   const intl = useIntl();
   const hitCenter = () => {
     const orgChart = document.getElementsByClassName('orgchart-container')[0];
@@ -142,7 +142,7 @@ const Org: React.FC = observer(() => {
           )}
         />
       </div>
-      <Save refresh={query} />
+      <Save refresh={query} visible={OrgModel.edit} />
       <Modal
         visible={BindModel.visible}
         closable={false}

+ 115 - 0
src/pages/system/Tenant/Detail/Assets/index.tsx

@@ -0,0 +1,115 @@
+import ProCard from '@ant-design/pro-card';
+import { EditOutlined, EyeOutlined } from '@ant-design/icons';
+import { Card, Form, Row, Select, Statistic } from 'antd';
+import { Col } from 'antd';
+import { useEffect } from 'react';
+import { observer } from '@formily/react';
+import { service } from '@/pages/system/Tenant';
+import { useParams } from 'umi';
+import TenantModel from '@/pages/system/Tenant/model';
+import type { TenantMember } from '@/pages/system/Tenant/typings';
+import encodeQuery from '@/utils/encodeQuery';
+
+const Assets = observer(() => {
+  const param = useParams<{ id: string }>();
+
+  const getDeviceCount = (type: 'online' | 'offline') => {
+    // 查询资产数量
+    service.assets
+      .deviceCount(
+        encodeQuery({
+          terms: {
+            state: type,
+            id$assets: {
+              tenantId: param.id,
+              assetType: 'device',
+              memberId: TenantModel.assetsMemberId,
+            },
+          },
+        }),
+      )
+      .subscribe((data) => {
+        TenantModel.assets.device[type] = data;
+      });
+  };
+
+  // 1\0 已发布\未发布
+  const getProductCount = (type: 1 | 0) => {
+    service.assets
+      .productCount(
+        encodeQuery({
+          terms: {
+            state: type,
+            id$assets: {
+              tenantId: param.id,
+              assetType: 'product',
+              memberId: TenantModel.assetsMemberId,
+            },
+          },
+        }),
+      )
+      .subscribe((data) => {
+        TenantModel.assets.product[type] = data;
+      });
+  };
+
+  useEffect(() => {
+    // 查询成员
+    service.queryMemberNoPaging(param.id).subscribe((data: TenantMember[]) => {
+      TenantModel.members = data;
+    });
+  }, []);
+
+  useEffect(() => {
+    getProductCount(1);
+    getProductCount(0);
+    getDeviceCount('online');
+    getDeviceCount('offline');
+  }, [TenantModel.assetsMemberId]);
+  return (
+    <Card>
+      <Form.Item label="成员" style={{ width: 200 }}>
+        <Select
+          onChange={(id: string) => {
+            TenantModel.assetsMemberId = id;
+          }}
+          options={TenantModel.members.map((item) => ({ label: item.name, value: item.userId }))}
+        />
+      </Form.Item>
+      <ProCard gutter={[16, 16]} style={{ marginTop: 16 }}>
+        <ProCard
+          title="产品"
+          colSpan="25%"
+          bordered
+          actions={[<EyeOutlined key="setting" />, <EditOutlined key="edit" />]}
+        >
+          <Row>
+            <Col span={12}>
+              <Statistic title="已发布" value={TenantModel.assets.product['1']} />
+            </Col>
+            <Col span={12}>
+              <Statistic title="未发布" value={TenantModel.assets.product['0']} />
+            </Col>
+          </Row>
+        </ProCard>
+
+        <ProCard
+          title="设备"
+          colSpan="25%"
+          bordered
+          actions={[<EyeOutlined key="setting" />, <EditOutlined key="edit" />]}
+        >
+          <Row>
+            <Col span={12}>
+              <Statistic title="在线" value={TenantModel.assets.device.online} />
+            </Col>
+            <Col span={12}>
+              <Statistic title="离线" value={TenantModel.assets.device.offline} />
+            </Col>
+          </Row>
+        </ProCard>
+      </ProCard>
+    </Card>
+  );
+});
+export default Assets;

+ 15 - 0
src/pages/system/Tenant/Detail/Info/index.tsx

@@ -0,0 +1,15 @@
+import { Descriptions } from 'antd';
+import TenantModel from '@/pages/system/Tenant/model';
+
+const Info = () => {
+  return (
+    <div>
+      <Descriptions size="small" column={3}>
+        <Descriptions.Item label="ID">{TenantModel.detail?.id}</Descriptions.Item>
+        <Descriptions.Item label="名称">{TenantModel.detail?.name}</Descriptions.Item>
+        <Descriptions.Item label="状态">{TenantModel.detail?.state?.text}</Descriptions.Item>
+      </Descriptions>
+    </div>
+  );
+};
+export default Info;

+ 85 - 0
src/pages/system/Tenant/Detail/Member/Bind.tsx

@@ -0,0 +1,85 @@
+import type { ProColumns, ActionType } from '@jetlinks/pro-table';
+import ProTable from '@jetlinks/pro-table';
+import { service } from '@/pages/system/Tenant';
+import { message, Space } from 'antd';
+import { useParams } from 'umi';
+import TenantModel from '@/pages/system/Tenant/model';
+import { observer } from '@formily/react';
+import { useRef } from 'react';
+
+interface Props {
+  reload: () => void;
+}
+
+const Bind = observer((props: Props) => {
+  const param = useParams<{ id: string }>();
+  const actionRef = useRef<ActionType>();
+  const columns: ProColumns<UserItem>[] = [
+    {
+      dataIndex: 'name',
+      title: '姓名',
+      search: {
+        transform: (value) => ({ name$LIKE: value }),
+      },
+    },
+    {
+      dataIndex: 'username',
+      title: '用户名',
+      search: {
+        transform: (value) => ({ username$LIKE: value }),
+      },
+    },
+  ];
+
+  const handleBind = () => {
+    service.handleUser(param.id, TenantModel.bindUsers, 'bind').subscribe({
+      next: () => message.success('操作成功'),
+      error: () => message.error('操作失败'),
+      complete: () => {
+        TenantModel.bindUsers = [];
+        actionRef.current?.reload();
+        props.reload();
+      },
+    });
+  };
+
+  return (
+    <ProTable
+      actionRef={actionRef}
+      columns={columns}
+      rowKey="id"
+      pagination={{
+        pageSize: 5,
+      }}
+      tableAlertRender={({ selectedRowKeys, onCleanSelected }) => (
+        <Space size={24}>
+          <span>
+            已选 {selectedRowKeys.length} 项
+            <a style={{ marginLeft: 8 }} onClick={onCleanSelected}>
+              取消选择
+            </a>
+          </span>
+        </Space>
+      )}
+      tableAlertOptionRender={() => (
+        <Space size={16}>
+          <a onClick={handleBind}>批量绑定</a>
+        </Space>
+      )}
+      rowSelection={{
+        selectedRowKeys: TenantModel.bindUsers.map((item) => item.userId),
+        onChange: (selectedRowKeys, selectedRows) => {
+          TenantModel.bindUsers = selectedRows.map((item) => ({
+            name: item.name,
+            userId: item.id,
+          }));
+        },
+      }}
+      request={(params) => service.queryUser(params)}
+      defaultParams={{
+        'id$tenant-user$not': param.id,
+      }}
+    />
+  );
+});
+export default Bind;

+ 122 - 0
src/pages/system/Tenant/Detail/Member/index.tsx

@@ -0,0 +1,122 @@
+import type { ProColumns, ActionType } from '@jetlinks/pro-table';
+import ProTable from '@jetlinks/pro-table';
+import { Button, Card, Col, message, Row, Space } from 'antd';
+import { CloseOutlined, PlusOutlined } from '@ant-design/icons';
+import type { TenantMember } from '@/pages/system/Tenant/typings';
+import { service } from '@/pages/system/Tenant';
+import { useParams } from 'umi';
+import Bind from '@/pages/system/Tenant/Detail/Member/Bind';
+import { observer } from '@formily/react';
+import TenantModel from '@/pages/system/Tenant/model';
+import { useRef } from 'react';
+
+const Member = observer(() => {
+  const actionRef = useRef<ActionType>();
+
+  const param = useParams<{ id: string }>();
+  const columns: ProColumns<TenantMember>[] = [
+    {
+      dataIndex: 'index',
+      valueType: 'indexBorder',
+      width: 48,
+    },
+    {
+      dataIndex: 'name',
+      title: '姓名',
+      search: {
+        transform: (value) => ({ name$LIKE: value }),
+      },
+    },
+    {
+      title: '管理员',
+      dataIndex: 'adminMember',
+      renderText: (text) => (text ? '是' : '否'),
+      search: false,
+    },
+    {
+      title: '状态',
+      dataIndex: 'state',
+      renderText: (text) => text.text,
+      search: false,
+    },
+  ];
+  const handleUnBind = () => {
+    service.handleUser(param.id, TenantModel.unBindUsers, 'unbind').subscribe({
+      next: () => message.success('操作成功'),
+      error: () => message.error('操作失败'),
+      complete: () => {
+        TenantModel.unBindUsers = [];
+        actionRef.current?.reload();
+      },
+    });
+  };
+  return (
+    <Row gutter={[16, 16]}>
+      <Col span={TenantModel.bind ? 12 : 24}>
+        <Card title="租户成员">
+          <ProTable
+            actionRef={actionRef}
+            columns={columns}
+            rowKey="id"
+            pagination={{
+              pageSize: 5,
+            }}
+            tableAlertRender={({ selectedRowKeys, onCleanSelected }) => (
+              <Space size={24}>
+                <span>
+                  已选 {selectedRowKeys.length} 项
+                  <a style={{ marginLeft: 8 }} onClick={onCleanSelected}>
+                    取消选择
+                  </a>
+                </span>
+              </Space>
+            )}
+            tableAlertOptionRender={() => (
+              <Space size={16}>
+                <a onClick={handleUnBind}>批量解绑</a>
+              </Space>
+            )}
+            rowSelection={{
+              selectedRowKeys: TenantModel.unBindUsers,
+              onChange: (selectedRowKeys, selectedRows) => {
+                TenantModel.unBindUsers = selectedRows.map((item) => item.id);
+              },
+            }}
+            request={(params) => service.queryMembers(param.id, params)}
+            toolBarRender={() => [
+              <Button
+                size="small"
+                onClick={() => {
+                  TenantModel.bind = true;
+                }}
+                icon={<PlusOutlined />}
+                type="primary"
+                key="bind"
+              >
+                绑定用户
+              </Button>,
+            ]}
+          />
+        </Card>
+      </Col>
+      {TenantModel.bind && (
+        <Col span={12}>
+          <Card
+            title="添加用户"
+            extra={
+              <CloseOutlined
+                onClick={() => {
+                  TenantModel.bind = false;
+                  TenantModel.bindUsers = [];
+                }}
+              />
+            }
+          >
+            <Bind reload={() => actionRef.current?.reload()} />
+          </Card>
+        </Col>
+      )}
+    </Row>
+  );
+});
+export default Member;

+ 4 - 0
src/pages/system/Tenant/Detail/Permission/index.tsx

@@ -0,0 +1,4 @@
+const Permission = () => {
+  return <div>权限管理</div>;
+};
+export default Permission;

+ 48 - 0
src/pages/system/Tenant/Detail/index.tsx

@@ -0,0 +1,48 @@
+import { observer } from '@formily/react';
+import { PageContainer } from '@ant-design/pro-layout';
+import { useEffect, useState } from 'react';
+import { history, useParams } from 'umi';
+import TenantModel from '@/pages/system/Tenant/model';
+import { service } from '@/pages/system/Tenant';
+import Assets from '@/pages/system/Tenant/Detail/Assets';
+import Member from '@/pages/system/Tenant/Detail/Member';
+import Info from '@/pages/system/Tenant/Detail/Info';
+
+const TenantDetail = observer(() => {
+  const [tab, setTab] = useState<string>('assets');
+  const params = useParams<{ id: string }>();
+  const getDetail = (id: string) => {
+    service.queryDetail(id).subscribe((data) => {
+      TenantModel.detail = data;
+    });
+  };
+
+  useEffect(() => {
+    const { id } = params;
+    if (id) {
+      getDetail(id);
+    } else {
+      history.goBack();
+    }
+  }, [params.id]);
+
+  const list = [
+    {
+      key: 'assets',
+      tab: '资产信息',
+      component: <Assets />,
+    },
+    {
+      key: 'member',
+      tab: '成员管理',
+      component: <Member />,
+    },
+  ];
+
+  return (
+    <PageContainer onBack={history.goBack} tabList={list} onTabChange={setTab} content={<Info />}>
+      {list.find((k) => k.key === tab)?.component}
+    </PageContainer>
+  );
+});
+export default TenantDetail;

+ 139 - 59
src/pages/system/Tenant/index.tsx

@@ -4,26 +4,21 @@ import type { TenantDetail } from '@/pages/system/Tenant/typings';
 import type { TenantItem } from '@/pages/system/Tenant/typings';
 import BaseCrud from '@/components/BaseCrud';
 import { useRef } from 'react';
-import { Avatar, Menu, message, Popconfirm, Tooltip } from 'antd';
+import { Avatar, Drawer, Tooltip } from 'antd';
 import Service from '@/pages/system/Tenant/service';
-import { CurdModel } from '@/components/BaseCrud/model';
-import {
-  CloseCircleOutlined,
-  EditOutlined,
-  KeyOutlined,
-  PlayCircleOutlined,
-} from '@ant-design/icons';
+import { EyeOutlined, KeyOutlined } from '@ant-design/icons';
 import { useIntl } from '@@/plugin-locale/localeExports';
+import moment from 'moment';
+import { Link } from 'umi';
+import TenantModel from '@/pages/system/Tenant/model';
+import type { ISchema } from '@formily/json-schema';
+import autzModel from '@/components/Authorization/autz';
+import Authorization from '@/components/Authorization';
+import { observer } from '@formily/react';
 
-const menu = (
-  <Menu>
-    <Menu.Item key="1">1st item</Menu.Item>
-    <Menu.Item key="2">2nd item</Menu.Item>
-    <Menu.Item key="3">3rd item</Menu.Item>
-  </Menu>
-);
-const service = new Service('tenant');
-const Tenant = () => {
+export const service = new Service('tenant');
+
+const Tenant = observer(() => {
   const intl = useIntl();
   const actionRef = useRef<ActionType>();
   const columns: ProColumns<TenantItem>[] = [
@@ -50,6 +45,9 @@ const Tenant = () => {
       }),
       align: 'center',
       renderText: (text: TenantDetail) => text.name,
+      search: {
+        transform: (value) => ({ name$LIKE: value }),
+      },
     },
     {
       dataIndex: 'members',
@@ -58,6 +56,7 @@ const Tenant = () => {
         defaultMessage: '成员数',
       }),
       align: 'center',
+      search: false,
     },
     {
       dataIndex: 'tenant',
@@ -70,6 +69,7 @@ const Tenant = () => {
       valueType: 'select',
       hideInForm: true,
       onFilter: true,
+      search: false,
       valueEnum: [
         {
           text: intl.formatMessage({
@@ -95,6 +95,16 @@ const Tenant = () => {
       ],
     },
     {
+      title: '创建时间',
+      dataIndex: 'tenant',
+      width: '200px',
+      align: 'center',
+      renderText: (record: TenantDetail) => moment(record.createTime).format('YYYY-MM-DD HH:mm:ss'),
+      sorter: true,
+      search: false,
+      defaultSortOrder: 'descend',
+    },
+    {
       title: intl.formatMessage({
         id: 'pages.data.option',
         defaultMessage: '操作',
@@ -103,17 +113,32 @@ const Tenant = () => {
       align: 'center',
       width: 200,
       render: (text, record) => [
-        <a key="editable" onClick={() => CurdModel.update(record)}>
+        <Link
+          onClick={() => {
+            TenantModel.current = record;
+          }}
+          to={`/system/tenant/detail/${record.tenant.id}`}
+          key="link"
+        >
           <Tooltip
             title={intl.formatMessage({
-              id: 'pages.data.option.edit',
-              defaultMessage: '编辑',
+              id: 'pages.data.option.detail',
+              defaultMessage: '查看',
             })}
+            key={'detail'}
           >
-            <EditOutlined />
+            <EyeOutlined />
           </Tooltip>
-        </a>,
-        <a onClick={() => console.log('授权')}>
+        </Link>,
+
+        <a
+          key="auth"
+          onClick={() => {
+            autzModel.autzTarget.id = record.tenant.id!;
+            autzModel.autzTarget.name = record.tenant.name!;
+            autzModel.visible = true;
+          }}
+        >
           <Tooltip
             title={intl.formatMessage({
               id: 'pages.data.option.authorize',
@@ -123,57 +148,112 @@ const Tenant = () => {
             <KeyOutlined />
           </Tooltip>
         </a>,
-        <a href={record.tenant.id} target="_blank" rel="noopener noreferrer" key="view">
-          <Popconfirm
-            title={intl.formatMessage({
-              id: 'pages.data.option.disabled.tips',
-              defaultMessage: '确认禁用?',
-            })}
-            onConfirm={async () => {
-              await service.update({
-                tenant: {
-                  id: record.tenant.id,
-                  state: record.tenant?.state.value ? 0 : 1,
-                },
-              });
-              message.success(
-                intl.formatMessage({
-                  id: 'pages.data.option.success',
-                  defaultMessage: '操作成功!',
-                }),
-              );
-              actionRef.current?.reload();
-            }}
-          >
-            <Tooltip
-              title={intl.formatMessage({
-                id: `pages.data.option.${record.tenant?.state.value ? 'disabled' : 'enabled'}`,
-                defaultMessage: record.tenant?.state?.value ? '禁用' : '启用',
-              })}
-            >
-              {record.tenant?.state.value ? <CloseCircleOutlined /> : <PlayCircleOutlined />}
-            </Tooltip>
-          </Popconfirm>
-        </a>,
       ],
     },
   ];
 
+  const schema: ISchema = {
+    type: 'object',
+    properties: {
+      name: {
+        type: 'string',
+        title: '名称',
+        required: true,
+        'x-decorator': 'FormItem',
+        'x-component': 'Input',
+      },
+      username: {
+        type: 'string',
+        title: '用户名',
+        required: true,
+        'x-decorator': 'FormItem',
+        'x-component': 'Input',
+      },
+      password: {
+        type: 'string',
+        title: '密码',
+        required: true,
+        'x-decorator': 'FormItem',
+        'x-component': 'Password',
+        'x-component-props': {
+          checkStrength: true,
+        },
+        'x-reactions': [
+          {
+            dependencies: ['.confirmPassword'],
+            fulfill: {
+              state: {
+                selfErrors:
+                  '{{$deps[0] && $self.value && $self.value !== $deps[0] ? "确认密码不匹配" : ""}}',
+              },
+            },
+          },
+        ],
+      },
+      confirmPassword: {
+        type: 'string',
+        title: '确认密码',
+        required: true,
+        'x-decorator': 'FormItem',
+        'x-component': 'Password',
+        'x-component-props': {
+          checkStrength: true,
+        },
+        'x-reactions': [
+          {
+            dependencies: ['.password'],
+            fulfill: {
+              state: {
+                selfErrors:
+                  '{{$deps[0] && $self.value && $self.value !== $deps[0] ? "确认密码不匹配" : ""}}',
+              },
+            },
+          },
+        ],
+      },
+      description: {
+        type: 'string',
+        title: '备注',
+        required: true,
+        'x-decorator': 'FormItem',
+        'x-component': 'Input.TextArea',
+      },
+    },
+  };
+
   return (
     <PageContainer>
       <BaseCrud<TenantItem>
-        request={(params = {}) => service.queryDetail(params)}
+        request={(params = {}) => service.queryList(params)}
         columns={columns}
         service={service}
         title={intl.formatMessage({
           id: 'pages.system.tenant.list',
           defaultMessage: '租户列表',
         })}
-        schema={{}}
-        menu={menu}
+        schema={schema}
         actionRef={actionRef}
       />
+      <Drawer
+        title={intl.formatMessage({
+          id: 'pages.data.option.authorize',
+          defaultMessage: '授权',
+        })}
+        width="50vw"
+        visible={autzModel.visible}
+        onClose={() => {
+          autzModel.visible = false;
+        }}
+      >
+        <Authorization
+          close={() => {
+            autzModel.visible = false;
+          }}
+          target={autzModel.autzTarget}
+          type={'tenant'}
+        />
+      </Drawer>
     </PageContainer>
   );
-};
+});
 export default Tenant;

+ 44 - 0
src/pages/system/Tenant/model.ts

@@ -0,0 +1,44 @@
+import { model } from '@formily/reactive';
+import type { TenantDetail } from '@/pages/system/Tenant/typings';
+import type { TenantMember } from '@/pages/system/Tenant/typings';
+
+type TenantModelType = {
+  current: Partial<TenantDetail>;
+  detail: Partial<TenantDetail>;
+  bind: boolean;
+  bindUsers: { name: string; userId: string }[];
+  unBindUsers: string[];
+  members: TenantMember[];
+  assets: {
+    device: {
+      online: number;
+      offline: number;
+    };
+    product: {
+      0: number;
+      1: number;
+    };
+  };
+  assetsMemberId: string | undefined;
+};
+const TenantModel = model<TenantModelType>({
+  current: {},
+  detail: {},
+  bind: false,
+  bindUsers: [],
+  unBindUsers: [],
+  members: [],
+  assets: {
+    device: {
+      online: 0,
+      offline: 0,
+    },
+    product: {
+      0: 0,
+      1: 0,
+    },
+  },
+  assetsMemberId: undefined,
+});
+
+export default TenantModel;

+ 75 - 2
src/pages/system/Tenant/service.ts

@@ -1,13 +1,86 @@
 import type { TenantItem } from '@/pages/system/Tenant/typings';
 import BaseService from '@/utils/BaseService';
 import { request } from 'umi';
+import { defer, from } from 'rxjs';
+import { filter, map } from 'rxjs/operators';
+import SystemConst from '@/utils/const';
 
 class Service extends BaseService<TenantItem> {
-  queryDetail = (params: any) => {
-    return request(`/jetlinks/tenant/detail/_query`, {
+  queryList = (params: any) =>
+    request(`${this.uri}/detail/_query`, {
       method: 'GET',
       params,
     });
+
+  update = (data: any) => request(`${this.uri}/_create`, { data, method: 'POST' });
+
+  queryDetail = (id: string) =>
+    defer(() =>
+      from(
+        request(`${this.uri}/${id}`, {
+          method: 'GET',
+        }),
+      ),
+    ).pipe(
+      filter((item) => item.status === 200),
+      map((item) => item.result),
+    );
+
+  queryMembers = (id: string, params: Record<string, unknown>) =>
+    request(`${this.uri}/${id}/members/_query`, { method: 'GET', params });
+
+  queryMemberNoPaging = (id: string) =>
+    defer(() =>
+      from(request(`${this.uri}/${id}/members/_query/no-paging?paging=false`, { method: 'GET' })),
+    ).pipe(
+      filter((item) => item.status === 200),
+      map((item) => item.result),
+    );
+
+  queryUser = (params: Record<string, unknown>) =>
+    request(`/${SystemConst.API_BASE}/user/_query`, {
+      method: 'GET',
+      params,
+    });
+
+  handleUser = (id: string, data: Record<string, unknown>[] | string[], type: 'bind' | 'unbind') =>
+    defer(() =>
+      from(
+        request(`/${SystemConst.API_BASE}/tenant/${id}/members/_${type}`, {
+          method: 'POST',
+          data,
+        }),
+      ),
+    ).pipe(
+      filter((item) => item.status === 200),
+      map((item) => item.result),
+    );
+
+  assets = {
+    deviceCount: (params: any) =>
+      defer(() =>
+        from(
+          request(`/${SystemConst.API_BASE}/device/instance/_count`, {
+            method: 'GET',
+            params,
+          }),
+        ).pipe(
+          filter((resp) => resp.status === 200),
+          map((resp) => resp.result),
+        ),
+      ),
+    productCount: (params: any) =>
+      defer(() =>
+        from(
+          request(`/${SystemConst.API_BASE}/device-product/_count`, {
+            method: 'GET',
+            params,
+          }),
+        ).pipe(
+          filter((resp) => resp.status === 200),
+          map((resp) => resp.result),
+        ),
+      ),
   };
 }
 

+ 15 - 1
src/pages/system/Tenant/typings.d.ts

@@ -1,8 +1,10 @@
+import type { State } from '@/utils/typings';
+
 export type TenantDetail = {
   id: string;
   name: string;
   type: string;
-  state: any;
+  state: State;
   members: number;
   photo: string;
   createTime: number;
@@ -13,3 +15,15 @@ export type TenantItem = {
   members: number;
   tenant: Partial<TenantDetail>;
 };
+
+export type TenantMember = {
+  id: string;
+  adminMember: boolean;
+  createTime: number;
+  mainTenant: true;
+  name: string;
+  state: State;
+  tenantId: string;
+  type: string;
+  userId: string;
+};

+ 1 - 1
src/utils/token.ts

@@ -1,5 +1,5 @@
 const Token = {
   set: (token: string) => localStorage.setItem('X-Access-Token', token),
-  get: () => localStorage.getItem('X-Access-Token'),
+  get: () => localStorage.getItem('X-Access-Token') || Date.now().toString(),
 };
 export default Token;