Просмотр исходного кода

merge(device): device、table card

Next xyh
Lind 3 лет назад
Родитель
Сommit
b4de5d42b2

+ 1 - 1
package.json

@@ -76,7 +76,7 @@
     "ahooks": "^2.10.9",
     "antd": "^4.18.8",
     "braft-editor": "^2.3.9",
-    "classnames": "^2.2.6",
+    "classnames": "^2.3.1",
     "dexie": "^3.0.3",
     "event-source-polyfill": "^1.0.25",
     "isomorphic-form-data": "^2.0.0",

BIN
public/images/avatar-1.png


BIN
public/images/device-type-1.png


BIN
public/images/device-type-2.png


BIN
public/images/device-type-3.png


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

@@ -0,0 +1,24 @@
+import { Card } from 'antd';
+import { EditOutlined, EllipsisOutlined, SettingOutlined } from '@ant-design/icons';
+
+export interface DeviceCardProps {
+  id: string;
+  name: string;
+  photoUrl?: string;
+}
+
+export default (props: DeviceCardProps) => {
+  return (
+    <Card
+      style={{ width: 280 }}
+      cover={null}
+      actions={[
+        <SettingOutlined key="setting" />,
+        <EditOutlined key="edit" />,
+        <EllipsisOutlined key="ellipsis" />,
+      ]}
+    >
+      <div>{props.name}</div>
+    </Card>
+  );
+};

+ 40 - 0
src/components/ProTableCard/index.less

@@ -0,0 +1,40 @@
+@import '../../../node_modules/antd/lib/style/themes/variable';
+
+.pro-table-card {
+  position: relative;
+
+  .pro-table-card-setting-item {
+    color: rgba(0, 0, 0, 0.75);
+    font-size: 16px;
+    cursor: pointer;
+
+    &:hover {
+      color: @primary-color-hover;
+    }
+
+    &.active {
+      color: @primary-color-active;
+    }
+  }
+
+  .pro-table-card-items {
+    display: flex;
+    flex-wrap: wrap;
+    padding-bottom: 32px;
+
+    > div {
+      margin-right: 14px;
+      margin-bottom: 14px;
+    }
+  }
+
+  .pro-table-card-pagination {
+    position: absolute;
+    right: 24px;
+    bottom: 24px;
+
+    > .ant-pagination-item {
+      display: none;
+    }
+  }
+}

+ 154 - 0
src/components/ProTableCard/index.tsx

@@ -0,0 +1,154 @@
+import ProTable from '@jetlinks/pro-table';
+import type { ProTableProps } from '@jetlinks/pro-table';
+import type { ParamsType } from '@ant-design/pro-provider';
+import React, { useState } from 'react';
+import { isFunction } from 'lodash';
+import { Space, Pagination } from 'antd';
+import { AppstoreOutlined, BarsOutlined } from '@ant-design/icons';
+import classNames from 'classnames';
+import './index.less';
+
+enum ModelEnum {
+  TABLE = 'TABLE',
+  CARD = 'CARD',
+}
+
+type ModelType = keyof typeof ModelEnum;
+
+interface ProTableCardProps<T> {
+  cardRender?: (data: T) => JSX.Element | React.ReactNode;
+}
+
+const ProTableCard = <
+  T extends Record<string, any>,
+  U extends ParamsType = ParamsType,
+  ValueType = 'text',
+>(
+  props: ProTableCardProps<T> & ProTableProps<T, U, ValueType>,
+) => {
+  const { cardRender, toolBarRender, request, ...extraProps } = props;
+  const [model, setModel] = useState<ModelType>(ModelEnum.CARD);
+  const [total, setTotal] = useState<number | undefined>(0);
+  const [current, setCurrent] = useState(1); // 当前页
+  const [pageIndex, setPageIndex] = useState(0);
+  const [pageSize, setPageSize] = useState(10); // 每页条数
+
+  /**
+   * 处理 Card
+   * @param dataSource
+   */
+  const handleCard = (dataSource: readonly T[] | undefined): JSX.Element => {
+    return (
+      <div className={'pro-table-card-items'}>
+        {dataSource
+          ? dataSource.map((item) =>
+              cardRender && isFunction(cardRender) ? cardRender(item) : null,
+            )
+          : null}
+      </div>
+    );
+  };
+
+  return (
+    <div className={'pro-table-card'}>
+      <ProTable<T, U, ValueType>
+        {...extraProps}
+        params={
+          {
+            ...props.params,
+            current: current,
+            pageIndex: pageIndex,
+            pageSize,
+          } as any
+        }
+        request={async (param, sort, filter) => {
+          if (request) {
+            const resp = await request(param, sort, filter);
+            setTotal(resp.result ? resp.result.total : 0);
+            return {
+              code: resp.message,
+              result: {
+                data: resp.result ? resp.result.data : [],
+                pageIndex: resp.result ? resp.result.pageIndex : 0,
+                pageSize: resp.result ? resp.result.pageSize : 0,
+                total: resp.result ? resp.result.total : 0,
+              },
+              status: resp.status,
+            };
+          }
+          return {};
+        }}
+        pagination={{
+          onChange: (page, size) => {
+            setCurrent(page);
+            setPageIndex(page - 1);
+            setPageSize(size);
+          },
+          pageSize: pageSize,
+          current: current,
+        }}
+        toolBarRender={(action, row) => {
+          const oldBar = toolBarRender ? toolBarRender(action, row) : [];
+          return [
+            ...oldBar,
+            <Space
+              align="center"
+              key={ModelEnum.TABLE}
+              size={12}
+              className={classNames(`pro-table-card-setting-item`, {
+                active: model === ModelEnum.TABLE,
+              })}
+              onClick={() => {
+                setModel(ModelEnum.TABLE);
+              }}
+            >
+              <BarsOutlined />
+            </Space>,
+            <Space
+              align="center"
+              size={12}
+              key={ModelEnum.CARD}
+              className={classNames(`pro-table-card-setting-item`, {
+                active: model === ModelEnum.CARD,
+              })}
+              onClick={() => {
+                setModel(ModelEnum.CARD);
+              }}
+            >
+              <AppstoreOutlined />
+            </Space>,
+          ];
+        }}
+        tableViewRender={
+          model === ModelEnum.CARD
+            ? (tableProps) => {
+                return handleCard(tableProps.dataSource);
+              }
+            : undefined
+        }
+      />
+      {model === ModelEnum.CARD && (
+        <Pagination
+          showSizeChanger
+          size="small"
+          className={'pro-table-card-pagination'}
+          total={total}
+          current={current}
+          onChange={(page, size) => {
+            setCurrent(page);
+            setPageIndex(page - 1);
+            setPageSize(size);
+          }}
+          pageSize={pageSize}
+          showTotal={(num) => {
+            const minSize = pageIndex * pageSize + 1;
+            const MaxSize = (pageIndex + 1) * pageSize;
+            return `第 ${minSize} - ${MaxSize > num ? num : MaxSize} 条/总共 ${num} 条`;
+          }}
+        />
+      )}
+    </div>
+  );
+};
+
+export default ProTableCard;

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

@@ -0,0 +1,71 @@
+@import '~antd/lib/style/themes/variable';
+@border: 1px solid @border-color-base;
+
+.radio-card-items {
+  display: flex;
+
+  .radio-card-item {
+    display: flex;
+    align-items: center;
+    min-width: 180px;
+    padding: 16px 20px;
+    overflow: hidden;
+    font-size: 14px;
+    border: @border;
+    border-radius: @border-radius-base;
+
+    > img {
+      width: 32px;
+      height: 32px;
+      margin-right: 24px;
+    }
+
+    > span {
+      cursor: default;
+    }
+
+    &:not(:last-child) {
+      margin-right: 10px;
+    }
+
+    &:hover,
+    &:focus {
+      color: @primary-color-hover;
+      border-color: @primary-color-hover;
+    }
+
+    .checked-icon {
+      position: absolute;
+      right: -22px;
+      bottom: -22px;
+      z-index: 2;
+      display: none;
+      width: 44px;
+      height: 44px;
+      color: #fff;
+      background-color: @primary-color-active;
+      transform: rotate(-45deg);
+      > div {
+        position: relative;
+        height: 100%;
+        transform: rotate(45deg);
+        > span {
+          position: absolute;
+          top: 6px;
+          left: 6px;
+          font-size: 12px;
+        }
+      }
+    }
+
+    &.checked {
+      position: relative;
+      color: @primary-color-active;
+      border-color: @primary-color-active;
+
+      > .checked-icon {
+        display: block;
+      }
+    }
+  }
+}

+ 89 - 0
src/components/RadioCard/index.tsx

@@ -0,0 +1,89 @@
+import { useEffect, useState, useRef } from 'react';
+import classNames from 'classnames';
+import { isArray } from 'lodash';
+import './index.less';
+import { CheckOutlined } from '@ant-design/icons';
+
+type RadioCardModelType = 'multiple' | 'singular';
+
+interface RadioCardItem {
+  label: string;
+  value: string;
+  imgUrl: string;
+}
+
+export interface RadioCardProps {
+  value?: string | string[];
+  model?: RadioCardModelType;
+  options: RadioCardItem[];
+  onChange?: (keys: string | string[]) => void;
+  onSelect?: (key: string, selected: boolean, node: RadioCardItem[]) => void;
+}
+
+export default (props: RadioCardProps) => {
+  const { value, model, options, onChange, onSelect } = props;
+  const [keys, setKeys] = useState<string[]>([]);
+  const isMultiple = useRef<boolean>(true);
+
+  isMultiple.current = !(model && model === 'singular');
+
+  useEffect(() => {
+    // 初始化
+    setKeys(value ? (isArray(value) ? value : [value]) : []);
+  }, [props.value]);
+
+  const getNode = (_keys: string[]) =>
+    options.filter((item) => _keys.some((key) => key === item.value));
+
+  const toggleOption = (key: string) => {
+    const optionIndex = keys.indexOf(key);
+    const newKeys = [...keys];
+    if (optionIndex === -1) {
+      if (isMultiple.current) {
+        newKeys.push(key);
+      } else {
+        newKeys[0] = key;
+      }
+    } else {
+      newKeys.splice(optionIndex, 1);
+    }
+
+    if (!('value' in props)) {
+      setKeys(newKeys);
+    }
+
+    if (onChange) {
+      onChange(isMultiple.current ? newKeys : newKeys[0]);
+    }
+
+    if (onSelect) {
+      onSelect(key, optionIndex === -1, getNode(newKeys));
+    }
+  };
+
+  return (
+    <div className={'radio-card-items'}>
+      {options.map((item) => {
+        return (
+          <div
+            className={classNames('radio-card-item', {
+              checked: keys?.includes(item.value),
+            })}
+            key={item.value}
+            onClick={() => {
+              toggleOption(item.value);
+            }}
+          >
+            <img width={32} height={32} src={item.imgUrl} alt={''} />
+            <span>{item.label}</span>
+            <div className={'checked-icon'}>
+              <div>
+                <CheckOutlined />
+              </div>
+            </div>
+          </div>
+        );
+      })}
+    </div>
+  );
+};

+ 82 - 0
src/components/Upload/Image/index.less

@@ -0,0 +1,82 @@
+@import '../../../../node_modules/antd/lib/style/themes/variable';
+
+@border: 1px dashed @border-color-base;
+@mask-color: rgba(#000, 0.35);
+@with: 160px;
+@height: 150px;
+
+.flex-center() {
+  align-items: center;
+  justify-content: center;
+}
+
+.upload-image-warp {
+  display: flex;
+  justify-content: flex-start;
+
+  .upload-image-border {
+    position: relative;
+    width: @with;
+    height: @height;
+    overflow: hidden;
+    //border-radius: 50%;
+    border: @border;
+    transition: all 0.3s;
+
+    &:hover {
+      border-color: @primary-color-hover;
+    }
+
+    .upload-image-content {
+      .flex-center();
+
+      position: relative;
+      display: flex;
+      flex-direction: column;
+      width: @with;
+      height: @height;
+      background-color: rgba(#000, 0.06);
+      cursor: pointer;
+
+      .upload-image-mask {
+        .flex-center();
+
+        position: absolute;
+        top: 0;
+        left: 0;
+        display: none;
+        width: 100%;
+        height: 100%;
+        color: #fff;
+        font-size: 16px;
+        background-color: @mask-color;
+      }
+
+      .upload-image {
+        width: 144px;
+        height: 138px;
+        //border-radius: 50%;
+        background-repeat: no-repeat;
+        background-position: center;
+        background-size: cover;
+      }
+
+      &:hover .upload-image-mask {
+        display: flex;
+      }
+    }
+  }
+
+  .upload-loading-mask {
+    .flex-center();
+
+    position: absolute;
+    top: 0;
+    left: 0;
+    display: flex;
+    width: 100%;
+    height: 100%;
+    color: #fff;
+    background-color: @mask-color;
+  }
+}

+ 100 - 0
src/components/Upload/Image/index.tsx

@@ -0,0 +1,100 @@
+import { message, Upload } from 'antd';
+import { useEffect, useState } from 'react';
+import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
+import SystemConst from '@/utils/const';
+import Token from '@/utils/token';
+import type { UploadChangeParam } from 'antd/lib/upload/interface';
+import type { RcFile } from 'antd/es/upload';
+import './index.less';
+
+interface UploadImageProps {
+  onChange?: (url: string) => void;
+  value?: string;
+  disabled?: boolean;
+  /**
+   * 图片格式类型,默认 'image/jpeg', 'image/png'
+   */
+  types?: string[];
+  /**
+   * 图片大小限制, 单位 M,默认 4 M
+   */
+  size?: number;
+}
+
+export default ({ onChange, value, ...extraProps }: UploadImageProps) => {
+  const [values, setValues] = useState(value || '');
+  const [loading, setLoading] = useState<boolean>(false);
+  const imageTypes = extraProps.types ? extraProps.types : ['image/jpeg', 'image/png'];
+
+  useEffect(() => {
+    setValues(value || '');
+  }, [value]);
+
+  const handleChange = (info: UploadChangeParam) => {
+    if (info.file.status === 'uploading') {
+      setLoading(true);
+    }
+    if (info.file.status === 'done') {
+      info.file.url = info.file.response?.result;
+      setLoading(false);
+      setValues(info.file.response?.result);
+      if (onChange) {
+        onChange(info.file.response?.result);
+      }
+    }
+  };
+
+  const beforeUpload = (file: RcFile) => {
+    const isType = imageTypes.includes(file.type);
+    if (!isType) {
+      message.error(`图片格式错误,必须是${imageTypes.toString()}格式`);
+      return false;
+    }
+    const isSize = file.size / 1024 / 1024 < (extraProps.size || 4);
+    if (!isSize) {
+      message.error(`图片大小必须小于${extraProps.size || 4}M`);
+    }
+    return isType && isSize;
+  };
+
+  return (
+    <div className={'upload-image-warp'}>
+      <div className={'upload-image-border'}>
+        <Upload
+          action={`/${SystemConst.API_BASE}/file/static`}
+          headers={{
+            'X-Access-Token': Token.get(),
+          }}
+          showUploadList={false}
+          onChange={handleChange}
+          beforeUpload={beforeUpload}
+          {...extraProps}
+        >
+          <div className={'upload-image-content'}>
+            {values ? (
+              <>
+                {/*<img width={120} height={120} src={values} />*/}
+                <div className={'upload-image'} style={{ backgroundImage: `url(${values})` }} />
+                <div className={'upload-image-mask'}>点击修改</div>
+              </>
+            ) : (
+              <>
+                {loading ? (
+                  <LoadingOutlined style={{ fontSize: 28 }} />
+                ) : (
+                  <PlusOutlined style={{ fontSize: 28 }} />
+                )}
+                <div>点击上传图片</div>
+              </>
+            )}
+          </div>
+        </Upload>
+        {values && loading ? (
+          <div className={'upload-loading-mask'}>
+            {loading ? <LoadingOutlined style={{ fontSize: 28 }} /> : null}
+          </div>
+        ) : null}
+      </div>
+    </div>
+  );
+};

+ 3 - 0
src/components/index.ts

@@ -0,0 +1,3 @@
+export { default as RadioCard } from './RadioCard';
+export { default as UploadImage } from './Upload/Image';
+export { default as ProTableCard } from './ProTableCard';

+ 18 - 0
src/locales/zh-CN/pages.ts

@@ -46,6 +46,14 @@ export default {
   'pages.table.type': '类型',
   'pages.table.deviceId': '设备ID',
   'pages.table.createTime': '创建时间',
+  'pages.form.tip.select': '请选择',
+  'pages.form.tip.input': '请输入',
+  'pages.form.tip.max64': '最多输入64个字符',
+  'pages.form.tip.id': '请输入英文或者数字或者-或者_',
+  'pages.form.tooltip.id': '若不填写,系统将自动生成唯一ID',
+  'pages.form.tip.existsID': 'ID重复',
+  'pages.form.tip.input.props': '请输入{name}',
+  'pages.form.tip.select.props': '请选择{name}',
 
   // 统计分析
   'pages.analysis.volume': '今日设备消息量',
@@ -222,6 +230,12 @@ export default {
   'pages.device.productDetail.setting': '应用配置',
   'pages.device.productDetail.disable': '停用',
   'pages.device.productDetail.enabled': '启用',
+
+  // 设备管理-设备分类
+  'pages.device.type.device': '直连设备',
+  'pages.device.type.childrenDevice': '网关子设备',
+  'pages.device.type.gateway': '网关设备',
+
   // 设备管理-产品分类
   'pages.device.category': '产品分类',
   'pages.device.category.id': '分类ID',
@@ -268,6 +282,10 @@ export default {
   'pages.device.instanceDetail.alarm': '告警设置',
   'pages.device.instanceDetail.visualization': '可视化',
   'pages.device.instanceDetail.shadow': '设备影子',
+  'pages.device.instanceDetail.IPAddress': 'IP地址',
+  'pages.device.instanceDetail.info': '设备信息',
+  // 设备管理-设备功能
+  'pages.device.instance.function.result': '执行结果',
   // 设备管理-指令下发
   'pages.device.command': '指令下发',
   'pages.device.command.type': '指令类型',

+ 7 - 1
src/pages/device/Instance/Detail/Functions/form.tsx

@@ -130,7 +130,13 @@ export default (props: FunctionProps) => {
         </div>
       </div>
       <div className="right">
-        <p>执行结果:</p>
+        <p>
+          {intl.formatMessage({
+            id: 'pages.device.instance.function.result',
+            defaultMessage: '执行结果',
+          })}
+          :
+        </p>
         <Input.TextArea value={result} rows={6} />
       </div>
     </div>

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

@@ -105,6 +105,7 @@ const Info = observer(() => {
       </Card>
       <Config />
       <Save
+        model={'edit'}
         data={{ ...InstanceModel?.detail, describe: InstanceModel?.detail?.description || '' }}
         close={(data: DeviceInstance | undefined) => {
           setVisible(false);

+ 193 - 97
src/pages/device/Instance/Save/index.tsx

@@ -1,32 +1,37 @@
-import { message, Modal } from 'antd';
-import { createForm } from '@formily/core';
-import { Form, FormItem, FormLayout, Input, Radio, Select } from '@formily/antd';
-import { createSchemaField } from '@formily/react';
-import type { ISchema } from '@formily/json-schema';
-import FUpload from '@/components/Upload';
+import { Col, message, Modal } from 'antd';
+import { Form, Input, Row, Select } from 'antd';
 import { service } from '@/pages/device/Instance';
-import 'antd/lib/tree-select/style/index.less';
 import type { DeviceInstance } from '../typings';
 import { useEffect, useState } from 'react';
+import { useIntl } from '@@/plugin-locale/localeExports';
+import { UploadImage } from '@/components';
+import { debounce } from 'lodash';
 
 interface Props {
   visible: boolean;
-  close: (data?: DeviceInstance) => void;
-  data: Partial<DeviceInstance>;
+  close: (data: DeviceInstance | undefined) => void;
+  reload?: () => void;
+  model?: 'add' | 'edit';
+  data?: Partial<DeviceInstance>;
 }
 
 const Save = (props: Props) => {
   const { visible, close, data } = props;
   const [productList, setProductList] = useState<any[]>([]);
-  const form = createForm({
-    initialValues: {
-      id: data?.id,
-      name: data?.name,
-      productName: data?.productName,
-      productId: data?.productId,
-      describe: data?.describe,
-    },
-  });
+  const [form] = Form.useForm();
+
+  useEffect(() => {
+    if (visible && data) {
+      form.setFieldsValue({
+        id: data.id,
+        name: data.name,
+        productId: data.productId,
+        describe: data.describe,
+      });
+    }
+  }, [visible]);
+
+  const intl = useIntl();
 
   useEffect(() => {
     service.getProductList({ paging: false }).then((resp) => {
@@ -39,96 +44,187 @@ const Save = (props: Props) => {
       }
     });
   }, []);
-  const SchemaField = createSchemaField({
-    components: {
-      FormItem,
-      Input,
-      Select,
-      Radio,
-      FUpload,
-      FormLayout,
-    },
-  });
 
-  const handleSave = async () => {
-    const values = (await form.submit()) as any;
-    const productId = values.productId;
-    if (productId) {
-      const product = productList.find((i) => i.value === productId);
-      values.productName = product.label;
+  const intlFormat = (
+    id: string,
+    defaultMessage: string,
+    paramsID?: string,
+    paramsMessage?: string,
+  ) => {
+    const paramsObj: Record<string, string> = {};
+    if (paramsID) {
+      const paramsMsg = intl.formatMessage({
+        id: paramsID,
+        defaultMessage: paramsMessage,
+      });
+      paramsObj.name = paramsMsg;
     }
-    const resp = (await service.update(values)) as any;
-    if (resp.status === 200) {
-      message.success('保存成功');
-      props.close(values);
+    const msg = intl.formatMessage(
+      {
+        id,
+        defaultMessage,
+      },
+      paramsObj,
+    );
+    return msg;
+  };
+
+  const handleSave = async () => {
+    const values = await form.validateFields();
+    if (values) {
+      const resp = (await service.update(values)) as any;
+      if (resp.status === 200) {
+        message.success('保存成功');
+        if (props.reload) {
+          props.reload();
+        }
+        props.close(resp.result);
+      }
     }
   };
-  const schema: ISchema = {
-    type: 'object',
-    properties: {
-      layout: {
-        type: 'void',
-        'x-component': 'FormLayout',
-        'x-component-props': {
-          labelCol: 4,
-          wrapperCol: 18,
-        },
-        properties: {
-          id: {
-            title: 'ID',
-            'x-disabled': !!data?.id,
-            'x-component': 'Input',
-            'x-decorator': 'FormItem',
-            'x-decorator-props': {
-              tooltip: <div>若不填写,系统将自动生成唯一ID</div>,
-            },
-          },
-          name: {
-            title: '名称',
-            'x-component': 'Input',
-            'x-decorator': 'FormItem',
-            'x-validator': [
-              {
-                required: true,
-                message: '请输入名称',
-              },
-              {
-                max: 64,
-                message: '最多可输入64个字符',
-              },
-            ],
-          },
-          productId: {
-            title: '所属产品',
-            'x-disabled': !!data?.id,
-            required: true,
-            'x-component': 'Select',
-            'x-decorator': 'FormItem',
-            enum: [...productList],
-          },
-          describe: {
-            title: '描述',
-            'x-component': 'Input.TextArea',
-            'x-decorator': 'FormItem',
-            'x-component-props': {
-              showCount: true,
-              maxLength: 200,
-            },
-          },
-        },
-      },
-    },
+
+  const vailId = (_: any, value: any, callback: Function) => {
+    if (props.model === 'add') {
+      service.isExists(value).then((resp) => {
+        if (resp.status === 200 && resp.result) {
+          callback(
+            intl.formatMessage({
+              id: 'pages.form.tip.existsID',
+              defaultMessage: 'ID重复',
+            }),
+          );
+        } else {
+          callback();
+        }
+      });
+    }
   };
+
   return (
     <Modal
       visible={visible}
-      onCancel={() => close()}
+      onCancel={() => {
+        form.resetFields();
+        close(undefined);
+      }}
       width="30vw"
-      title={data?.id ? '编辑' : '新增'}
+      title={intl.formatMessage({
+        id: `pages.data.option.${props.model || 'add'}`,
+        defaultMessage: '新增',
+      })}
       onOk={handleSave}
     >
-      <Form form={form}>
-        <SchemaField schema={schema} />
+      <Form
+        form={form}
+        layout={'vertical'}
+        labelAlign={'right'}
+        labelCol={{
+          style: { width: 100 },
+        }}
+      >
+        <Row>
+          <Col span={8}>
+            <Form.Item name={'photoUrl'}>
+              <UploadImage />
+            </Form.Item>
+          </Col>
+          <Col span={16}>
+            <Form.Item
+              label={'ID'}
+              name={'id'}
+              tooltip={intlFormat('pages.form.tooltip.id', '若不填写,系统将自动生成唯一ID')}
+              rules={[
+                {
+                  pattern: /^[a-zA-Z0-9_\-]+$/,
+                  message: intlFormat('pages.form.tip.id', '请输入英文或者数字或者-或者_'),
+                },
+                {
+                  max: 64,
+                  message: intlFormat('pages.form.tip.max64', '最多输入64个字符'),
+                },
+                {
+                  validator: debounce(vailId, 300),
+                },
+              ]}
+            >
+              <Input
+                disabled={props.model === 'edit'}
+                placeholder={intlFormat('pages.form.tip.input', '请输入')}
+              />
+            </Form.Item>
+            <Form.Item
+              label={intlFormat('pages.table.name', '名称')}
+              name={'name'}
+              rules={[
+                {
+                  required: true,
+                  message: intlFormat(
+                    'pages.form.tip.input.props',
+                    '请输入名称',
+                    'pages.table.name',
+                    '名称',
+                  ),
+                },
+                {
+                  max: 64,
+                  message: intl.formatMessage({
+                    id: 'pages.form.tip.max64',
+                    defaultMessage: '最多输入64个字符',
+                  }),
+                },
+              ]}
+              required
+            >
+              <Input placeholder={intlFormat('pages.form.tip.input', '请输入')} />
+            </Form.Item>
+          </Col>
+        </Row>
+        <Row>
+          <Col span={24}>
+            <Form.Item
+              label={'所属产品'}
+              name={'productId'}
+              rules={[
+                {
+                  required: true,
+                  message: intlFormat(
+                    'pages.form.tip.select.props',
+                    '请选择所属产品',
+                    'pages.device.instanceDetail.deviceType',
+                    '设备类型',
+                  ),
+                },
+              ]}
+              required
+            >
+              <Select
+                showSearch
+                options={productList}
+                onSelect={(_: any, node: any) => {
+                  form.setFieldsValue({
+                    productName: node.name,
+                  });
+                }}
+              />
+            </Form.Item>
+            <Form.Item hidden={true} name={'productName'}>
+              <Input />
+            </Form.Item>
+          </Col>
+        </Row>
+        <Row>
+          <Col span={24}>
+            <Form.Item label={intlFormat('pages.table.description', '说明')} name={'describe'}>
+              <Input.TextArea
+                placeholder={intlFormat('pages.form.tip.input', '请输入')}
+                rows={4}
+                style={{ width: '100%' }}
+                maxLength={200}
+                showCount={true}
+              />
+            </Form.Item>
+          </Col>
+        </Row>
       </Form>
     </Modal>
   );

+ 8 - 3
src/pages/device/Instance/index.tsx

@@ -1,6 +1,5 @@
 import { PageContainer } from '@ant-design/pro-layout';
 import type { ActionType, ProColumns } from '@jetlinks/pro-table';
-import ProTable from '@jetlinks/pro-table';
 import type { DeviceInstance } from '@/pages/device/Instance/typings';
 import moment from 'moment';
 import { Badge, Button, Dropdown, Menu, message, Popconfirm, Tooltip } from 'antd';
@@ -25,8 +24,10 @@ import Export from './Export';
 import Import from './Import';
 import Process from './Process';
 import SearchComponent from '@/components/SearchComponent';
+import { ProTableCard } from '@/components';
 import SystemConst from '@/utils/const';
 import Token from '@/utils/token';
+import DeviceCard from '@/components/ProTableCard/CardItems/device';
 
 export const statusMap = new Map();
 statusMap.set('在线', 'success');
@@ -139,7 +140,7 @@ const Instance = () => {
         id: 'pages.table.description',
         defaultMessage: '说明',
       }),
-      dataIndex: 'description',
+      dataIndex: 'describe',
       width: '15%',
       ellipsis: true,
     },
@@ -343,7 +344,7 @@ const Instance = () => {
           setSearchParams({});
         }}
       />
-      <ProTable<DeviceInstance>
+      <ProTableCard<DeviceInstance>
         columns={columns}
         actionRef={actionRef}
         params={searchParams}
@@ -387,11 +388,15 @@ const Instance = () => {
             <Button>批量操作</Button>
           </Dropdown>,
         ]}
+        cardRender={(item) => <DeviceCard {...item} />}
       />
       <Save
         data={current}
+        model={!current ? 'add' : 'edit'}
         close={() => {
           setVisible(false);
+        }}
+        reload={() => {
           actionRef.current?.reload();
         }}
         visible={visible}

+ 223 - 205
src/pages/device/Product/Save/index.tsx

@@ -1,15 +1,11 @@
-import { message, Modal } from 'antd';
-import type { Field } from '@formily/core';
-import { createForm, onFieldValueChange } from '@formily/core';
-import { Form, FormItem, FormLayout, Input, Radio, Select, TreeSelect } from '@formily/antd';
-import { createSchemaField } from '@formily/react';
-import type { ISchema } from '@formily/json-schema';
-import FUpload from '@/components/Upload';
+import { useEffect } from 'react';
 import { service } from '@/pages/device/Product';
-import { action } from '@formily/reactive';
-import 'antd/lib/tree-select/style/index.less';
 import type { ProductItem } from '@/pages/device/Product/typings';
 import { useIntl } from '@@/plugin-locale/localeExports';
+import { RadioCard, UploadImage } from '@/components';
+import { Form, Input, Modal, TreeSelect, Row, Col, message } from 'antd';
+import { useRequest } from 'umi';
+import { debounce } from 'lodash';
 
 interface Props {
   visible: boolean;
@@ -19,27 +15,16 @@ interface Props {
   model: 'add' | 'edit';
 }
 
-/**
- * 处理品类数据
- * @param tree
- */
-const treeToArray = (tree: any) => {
-  const arr: any[] = [];
-  const expanded = (datas: any[]) => {
-    if (datas && datas.length > 0) {
-      datas.forEach((e) => {
-        arr.push(e);
-        expanded(e.children);
-      });
-    }
-  };
-  expanded(tree);
-  return arr;
-};
-
 const Save = (props: Props) => {
   const { visible, close, data } = props;
   const intl = useIntl();
+  const [form] = Form.useForm();
+  const { data: classOptions, run: classRequest } = useRequest(service.category, {
+    manual: true,
+    formatResult: (response) => {
+      return response.result;
+    },
+  });
 
   const handleData = () => {
     // 特殊处理deviceType字段
@@ -50,200 +35,233 @@ const Save = (props: Props) => {
     }
     return data;
   };
-  const form = createForm({
-    initialValues: handleData(),
-    effects() {
-      onFieldValueChange('messageProtocol', (field, f) => {
-        const protocol = (field as Field).value;
-        f.setFieldState('transportProtocol', async (state) => {
-          state.loading = true;
-          const resp = await service.getTransport(protocol);
-          state.dataSource = resp.result.map((item: { name: string; id: string }) => ({
-            label: item.name,
-            value: item.id,
-          }));
-          state.loading = false;
-        });
-      });
-    },
-  });
 
-  const SchemaField = createSchemaField({
-    components: {
-      FormItem,
-      Input,
-      Select,
-      Radio,
-      FUpload,
-      FormLayout,
-      TreeSelect,
-    },
-  });
-  const serviceMap = new Map<string, Promise<any>>();
-  serviceMap.set('classifiedId', service.category());
-  serviceMap.set('protocol', service.getProtocol());
-  serviceMap.set('storePolicy', service.getStorage());
-  serviceMap.set('org', service.getOrg());
-
-  let classifiedList: any[] = [];
-  const useAsyncDataSource = (type: string) => (field: Field) => {
-    field.loading = true;
-    serviceMap.get(type)!.then(
-      action.bound!((resp) => {
-        if (type === 'classifiedId') {
-          // 处理key错误
-          field.dataSource = resp.result.map((item: Record<string, unknown>) => ({
-            ...item,
-            key: item.id,
-          }));
-          // 考虑冗余分类字段
-          classifiedList = resp.result;
-        } else {
-          field.dataSource = resp.result.map((item: { name: any; id: any }) => ({
-            label: item.name,
-            value: item.id,
-          }));
-        }
-        field.loading = false;
-      }),
+  const intlFormat = (
+    id: string,
+    defaultMessage: string,
+    paramsID?: string,
+    paramsMessage?: string,
+  ) => {
+    const paramsObj: Record<string, string> = {};
+    if (paramsID) {
+      const paramsMsg = intl.formatMessage({
+        id: paramsID,
+        defaultMessage: paramsMessage,
+      });
+      paramsObj.name = paramsMsg;
+    }
+    const msg = intl.formatMessage(
+      {
+        id,
+        defaultMessage,
+      },
+      paramsObj,
     );
+    return msg;
   };
 
-  const handleSave = async () => {
-    const values = (await form.submit()) as any;
-    // 冗余classifiedName 字段;
-    // 如果只存储string。 可考虑字段解构方式处理
-    // 可能存在数据反显问题,此处考虑与后台协商处理
-    const classifiedId = values.classifiedId;
-    if (classifiedId) {
-      const tempClassifiedList = treeToArray(classifiedList);
-      const classified = tempClassifiedList.find((i) => i.id === classifiedId);
-      // values.classifiedId = classifiedId[classifiedId.length - 1];
-      values.classfiedName = classified.name;
+  useEffect(() => {
+    if (visible) {
+      // 获取产品分类
+      classRequest();
+      form.setFieldsValue(handleData());
     }
-    const resp = (await service.update(values)) as any;
-    if (resp.status === 200) {
-      message.success('保存成功');
-      props.reload();
-      props.close();
+  }, [visible]);
+
+  const handleSave = async () => {
+    const formData = await form.validateFields();
+    if (formData) {
+      const res = await service.update(formData);
+      if (res.status === 200) {
+        message.success('保存成功');
+        if (props.reload) {
+          props.reload();
+        }
+        props.close();
+      }
     }
   };
-  const schema: ISchema = {
-    type: 'object',
-    properties: {
-      layout: {
-        type: 'void',
-        'x-component': 'FormLayout',
-        'x-component-props': {
-          labelCol: 4,
-          wrapperCol: 18,
-        },
-        properties: {
-          photoUrl: {
-            title: '图标',
-            'x-component': 'FUpload',
-            'x-decorator': 'FormItem',
-          },
-          id: {
-            title: 'ID',
-            'x-component': 'Input',
-            'x-decorator': 'FormItem',
-            'x-decorator-props': {
-              tooltip: <div>若不填写,系统将自动生成唯一ID</div>,
-            },
-            'x-component-props': {
-              disabled: props.model === 'edit',
-            },
-          },
-          name: {
-            title: '名称',
-            'x-component': 'Input',
-            'x-decorator': 'FormItem',
-            'x-validator': [
-              {
-                required: true,
-                message: '请输入名称',
-              },
-              {
-                max: 64,
-                message: '最多可输入64个字符',
-              },
-            ],
-          },
-          classifiedId: {
-            title: '所属品类',
-            'x-component': 'TreeSelect',
-            'x-decorator': 'FormItem',
-            'x-component-props': {
-              fieldNames: { label: 'name', value: 'id', children: 'children', key: 'id' },
-            },
-            'x-reactions': ['{{useAsyncDataSource("classifiedId")}}'],
-          },
-          // orgId: {
-          //   title: '所属机构',
-          //   'x-component': 'Select',
-          //   'x-decorator': 'FormItem',
-          //   'x-reactions': ['{{useAsyncDataSource("org")}}'],
-          // },
-          // messageProtocol: {
-          //   title: '消息协议',
-          //   'x-component': 'Select',
-          //   'x-decorator': 'FormItem',
-          //   'x-reactions': ['{{useAsyncDataSource("protocol")}}'],
-          // },
-          // transportProtocol: {
-          //   title: '传输协议',
-          //   'x-component': 'Select',
-          //   'x-decorator': 'FormItem',
-          // },
-          // storePolicy: {
-          //   title: '存储策略',
-          //   'x-component': 'Select',
-          //   'x-decorator': 'FormItem',
-          //   'x-reactions': ['{{useAsyncDataSource("storePolicy")}}'],
-          // },
-          deviceType: {
-            title: '设备类型',
-            'x-component': 'Radio.Group',
-            'x-decorator': 'FormItem',
-            enum: [
-              { label: '直连设备', value: 'device' },
-              { label: '网关子设备', value: 'childrenDevice' },
-              { label: '网关设备', value: 'gateway' },
-            ],
-            'x-validator': [
-              {
-                required: true,
-                message: '请选择设备类型',
-              },
-            ],
-          },
-          describe: {
-            title: '描述',
-            'x-component': 'Input.TextArea',
-            'x-decorator': 'FormItem',
-            'x-component-props': {
-              showCount: true,
-              maxLength: 200,
-            },
-          },
-        },
-      },
-    },
+
+  const vailId = (_: any, value: any, callback: Function) => {
+    if (props.model === 'add') {
+      service.existsID(value).then((res) => {
+        if (res.status === 200 && res.result) {
+          callback(
+            intl.formatMessage({
+              id: 'pages.form.tip.existsID',
+              defaultMessage: 'ID重复',
+            }),
+          );
+        }
+        callback();
+      });
+    } else {
+      callback();
+    }
   };
+
   return (
     <Modal
       visible={visible}
-      onCancel={() => close()}
-      width="30vw"
+      onCancel={() => {
+        form.resetFields();
+        close();
+      }}
+      width={610}
       title={intl.formatMessage({
-        id: `pages.data.option.${props.model}`,
+        id: `pages.data.option.${props.model || 'add'}`,
         defaultMessage: '新增',
       })}
       onOk={handleSave}
     >
-      <Form form={form}>
-        <SchemaField schema={schema} scope={{ useAsyncDataSource }} />
+      <Form
+        form={form}
+        layout={'vertical'}
+        labelAlign={'right'}
+        labelCol={{
+          style: { width: 100 },
+        }}
+      >
+        <Row>
+          <Col span={8}>
+            <Form.Item name={'photoUrl'}>
+              <UploadImage />
+            </Form.Item>
+          </Col>
+          <Col span={16}>
+            <Form.Item
+              label={'ID'}
+              name={'id'}
+              tooltip={intl.formatMessage({
+                id: 'pages.form.tooltip.id',
+                defaultMessage: '若不填写,系统将自动生成唯一ID',
+              })}
+              rules={[
+                {
+                  pattern: /^[a-zA-Z0-9_\-]+$/,
+                  message: intl.formatMessage({
+                    id: 'pages.form.tip.id',
+                    defaultMessage: '请输入英文或者数字或者-或者_',
+                  }),
+                },
+                {
+                  max: 64,
+                  message: intl.formatMessage({
+                    id: 'pages.form.tip.max64',
+                    defaultMessage: '最多输入64个字符',
+                  }),
+                },
+                {
+                  validator: debounce(vailId, 300),
+                },
+              ]}
+            >
+              <Input
+                disabled={props.model === 'edit'}
+                placeholder={intlFormat('pages.form.tip.input', '请输入')}
+              />
+            </Form.Item>
+            <Form.Item
+              label={intlFormat('pages.table.name', '名称')}
+              name={'name'}
+              rules={[
+                {
+                  required: true,
+                  message: intlFormat(
+                    'pages.form.tip.input.props',
+                    '请输入名称',
+                    'pages.table.name',
+                    '名称',
+                  ),
+                },
+                {
+                  max: 64,
+                  message: intl.formatMessage({
+                    id: 'pages.form.tip.max64',
+                    defaultMessage: '最多输入64个字符',
+                  }),
+                },
+              ]}
+              required
+            >
+              <Input placeholder={intlFormat('pages.form.tip.input', '请输入')} />
+            </Form.Item>
+          </Col>
+        </Row>
+        <Row>
+          <Col span={24}>
+            <Form.Item label={'分类'} name={'classifiedId'}>
+              <TreeSelect
+                showSearch
+                onSelect={(_: any, node: any) => {
+                  form.setFieldsValue({
+                    classifiedName: node.name,
+                  });
+                }}
+                filterTreeNode={(input, treeNode) => treeNode.name.includes(input)}
+                placeholder={intlFormat('pages.form.tip.select', '请选择')}
+                fieldNames={{
+                  label: 'name',
+                  value: 'id',
+                }}
+                treeData={classOptions}
+              />
+            </Form.Item>
+            <Form.Item hidden={true} name={'classifiedName'}>
+              <Input />
+            </Form.Item>
+          </Col>
+          <Col span={24}>
+            <Form.Item
+              label={intlFormat('pages.device.instanceDetail.deviceType', '设备类型')}
+              name={'deviceType'}
+              rules={[
+                {
+                  required: true,
+                  message: intlFormat(
+                    'pages.form.tip.select.props',
+                    '请选择设备类型',
+                    'pages.device.instanceDetail.deviceType',
+                    '设备类型',
+                  ),
+                },
+              ]}
+              required
+            >
+              <RadioCard
+                model={'singular'}
+                options={[
+                  {
+                    label: intlFormat('pages.device.type.device', '直连设备'),
+                    value: 'device',
+                    imgUrl: require('/public/images/device-type-1.png'),
+                  },
+                  {
+                    label: intlFormat('pages.device.type.childrenDevice', '网关子设备'),
+                    value: 'childrenDevice',
+                    imgUrl: require('/public/images/device-type-2.png'),
+                  },
+                  {
+                    label: intlFormat('pages.device.type.gateway', '网关设备'),
+                    value: 'gateway',
+                    imgUrl: require('/public/images/device-type-3.png'),
+                  },
+                ]}
+              />
+            </Form.Item>
+          </Col>
+          <Col span={24}>
+            <Form.Item label={intlFormat('pages.table.description', '说明')} name={'describe'}>
+              <Input.TextArea
+                placeholder={intlFormat('pages.form.tip.input', '请输入')}
+                rows={4}
+                style={{ width: '100%' }}
+                maxLength={200}
+                showCount={true}
+              />
+            </Form.Item>
+          </Col>
+        </Row>
       </Form>
     </Modal>
   );

+ 19 - 6
src/pages/device/Product/index.tsx

@@ -2,13 +2,13 @@ import { PageContainer } from '@ant-design/pro-layout';
 import { Badge, Button, message, Popconfirm, Space, Tooltip } from 'antd';
 import type { ProductItem } from '@/pages/device/Product/typings';
 import {
-  DeleteOutlined,
+  StopOutlined,
   DownloadOutlined,
   EditOutlined,
   EyeOutlined,
+  DeleteOutlined,
   PlayCircleOutlined,
   PlusOutlined,
-  StopOutlined,
 } from '@ant-design/icons';
 import Service from '@/pages/device/Product/service';
 import { observer } from '@formily/react';
@@ -16,12 +16,13 @@ import { model } from '@formily/reactive';
 import { Link } from 'umi';
 import { useIntl } from '@@/plugin-locale/localeExports';
 import type { ActionType, ProColumns } from '@jetlinks/pro-table';
+import { useEffect, useRef, useState } from 'react';
 import ProTable from '@jetlinks/pro-table';
-import { useRef, useState } from 'react';
 import encodeQuery from '@/utils/encodeQuery';
 import Save from '@/pages/device/Product/Save';
 import SearchComponent from '@/components/SearchComponent';
 import { getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
+import { useHistory } from 'umi';
 
 export const service = new Service('device-product');
 export const statusMap = {
@@ -39,7 +40,19 @@ const Product = observer(() => {
   const [current, setCurrent] = useState<ProductItem>();
   const actionRef = useRef<ActionType>();
   const intl = useIntl();
-  const [param, setParam] = useState({});
+  const [queryParam, setQueryParam] = useState({});
+  const history = useHistory<Record<string, string>>();
+
+  useEffect(() => {
+    if (history) {
+      const state = history.location.state;
+      if (state) {
+        setQueryParam({
+          terms: history.location.state,
+        });
+      }
+    }
+  }, [history]);
 
   const status = {
     1: (
@@ -80,7 +93,7 @@ const Product = observer(() => {
    * @param data
    */
   const searchFn = (data: any) => {
-    setParam({
+    setQueryParam({
       terms: data,
     });
   };
@@ -230,7 +243,7 @@ const Product = observer(() => {
         //     service.queryZipCount(encodeQuery({ ...params, sorts: { id: 'ascend' } })),
         //   );
         // }}
-        params={param}
+        params={queryParam}
         request={(params = {}) =>
           service.query(encodeQuery({ ...params, sorts: { createTime: 'ascend' } }))
         }

+ 2 - 0
src/pages/device/Product/service.ts

@@ -142,6 +142,8 @@ class Service extends BaseService<ProductItem> {
         method: 'POST',
       },
     );
+
+  public existsID = (id: string) => request(`${this.uri}/${id}/exists`, { method: 'GET' });
 }
 
 export default Service;