Sfoglia il codice sorgente

feat(ApiCascader): add asynchronous cascader component (#1321)

Jobin 3 anni fa
parent
commit
97fe8e2058

+ 325 - 0
mock/demo/api-cascader.ts

@@ -0,0 +1,325 @@
+import { MockMethod } from 'vite-plugin-mock';
+import { resultSuccess } from '../_util';
+
+const areaList: any[] = [
+  {
+    id: '530825900854620160',
+    code: '430000',
+    parentCode: '100000',
+    levelType: 1,
+    name: '湖南省',
+    province: '湖南省',
+    city: null,
+    district: null,
+    town: null,
+    village: null,
+    parentPath: '430000',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 16:33:42',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530825900883980288',
+    code: '430100',
+    parentCode: '430000',
+    levelType: 2,
+    name: '长沙市',
+    province: '湖南省',
+    city: '长沙市',
+    district: null,
+    town: null,
+    village: null,
+    parentPath: '430000,430100',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 16:33:42',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530825900951089152',
+    code: '430102',
+    parentCode: '430100',
+    levelType: 3,
+    name: '芙蓉区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '芙蓉区',
+    town: null,
+    village: null,
+    parentPath: '430000,430100,430102',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 16:33:42',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530825901014003712',
+    code: '430104',
+    parentCode: '430100',
+    levelType: 3,
+    name: '岳麓区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '岳麓区',
+    town: null,
+    village: null,
+    parentPath: '430000,430100,430104',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 16:33:42',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530825900988837888',
+    code: '430103',
+    parentCode: '430100',
+    levelType: 3,
+    name: '天心区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: null,
+    village: null,
+    parentPath: '430000,430100,430103',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 16:33:42',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530826672489115648',
+    code: '430103002',
+    parentCode: '430103',
+    levelType: 4,
+    name: '坡子街街道',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: null,
+    parentPath: '430000,430100,430103,430103002',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-12-14 15:26:43',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530840241171607552',
+    code: '430103002001',
+    parentCode: '430103002',
+    levelType: 5,
+    name: '八角亭社区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: '八角亭社区',
+    parentPath: '430000,430100,430103,430103002,430103002001',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2021-01-20 14:07:23',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530840241200967680',
+    code: '430103002002',
+    parentCode: '430103002',
+    levelType: 5,
+    name: '西牌楼社区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: '西牌楼社区',
+    parentPath: '430000,430100,430103,430103002,430103002002',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 17:30:41',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530840241230327808',
+    code: '430103002003',
+    parentCode: '430103002',
+    levelType: 5,
+    name: '太平街社区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: '太平街社区',
+    parentPath: '430000,430100,430103,430103002,430103002003',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 17:30:41',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530840241259687936',
+    code: '430103002005',
+    parentCode: '430103002',
+    levelType: 5,
+    name: '坡子街社区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: '坡子街社区',
+    parentPath: '430000,430100,430103,430103002,430103002005',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 17:30:41',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530840241284853760',
+    code: '430103002006',
+    parentCode: '430103002',
+    levelType: 5,
+    name: '青山祠社区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: '青山祠社区',
+    parentPath: '430000,430100,430103,430103002,430103002006',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 17:30:41',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530840241310019584',
+    code: '430103002007',
+    parentCode: '430103002',
+    levelType: 5,
+    name: '沙河社区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: '沙河社区',
+    parentPath: '430000,430100,430103,430103002,430103002007',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 17:30:41',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530840241381322752',
+    code: '430103002008',
+    parentCode: '430103002',
+    levelType: 5,
+    name: '碧湘社区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: '碧湘社区',
+    parentPath: '430000,430100,430103,430103002,430103002008',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 17:30:41',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530840241410682880',
+    code: '430103002009',
+    parentCode: '430103002',
+    levelType: 5,
+    name: '创远社区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: '创远社区',
+    parentPath: '430000,430100,430103,430103002,430103002009',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 17:30:41',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530840241431654400',
+    code: '430103002010',
+    parentCode: '430103002',
+    levelType: 5,
+    name: '楚湘社区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: '楚湘社区',
+    parentPath: '430000,430100,430103,430103002,430103002010',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 17:30:41',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530840241465208832',
+    code: '430103002011',
+    parentCode: '430103002',
+    levelType: 5,
+    name: '西湖社区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: '西湖社区',
+    parentPath: '430000,430100,430103,430103002,430103002011',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 17:30:41',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530840241502957568',
+    code: '430103002012',
+    parentCode: '430103002',
+    levelType: 5,
+    name: '登仁桥社区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: '登仁桥社区',
+    parentPath: '430000,430100,430103,430103002,430103002012',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 17:30:41',
+    customized: false,
+    usable: true,
+  },
+  {
+    id: '530840241553289216',
+    code: '430103002013',
+    parentCode: '430103002',
+    levelType: 5,
+    name: '文庙坪社区',
+    province: '湖南省',
+    city: '长沙市',
+    district: '天心区',
+    town: '坡子街街道',
+    village: '文庙坪社区',
+    parentPath: '430000,430100,430103,430103002,430103002013',
+    createTime: '2020-11-30 15:47:31',
+    updateTime: '2020-11-30 17:30:41',
+    customized: false,
+    usable: true,
+  },
+];
+export default [
+  {
+    url: '/basic-api/cascader/getAreaRecord',
+    timeout: 1000,
+    method: 'post',
+    response: ({ body }) => {
+      const { parentCode } = body || {};
+      if (!parentCode) {
+        return resultSuccess(areaList.filter((it) => it.code === '430000'));
+      }
+      return resultSuccess(areaList.filter((it) => it.parentCode === parentCode));
+    },
+  },
+] as MockMethod[];

+ 9 - 0
src/api/demo/cascader.ts

@@ -0,0 +1,9 @@
+import { defHttp } from '/@/utils/http/axios';
+import { AreaModel, AreaParams } from '/@/api/demo/model/areaModel';
+
+enum Api {
+  AREA_RECORD = '/cascader/getAreaRecord',
+}
+
+export const areaRecord = (data: AreaParams) =>
+  defHttp.post<AreaModel>({ url: Api.AREA_RECORD, data });

+ 12 - 0
src/api/demo/model/areaModel.ts

@@ -0,0 +1,12 @@
+export interface AreaModel {
+  id: string;
+  code: string;
+  parentCode: string;
+  name: string;
+  levelType: number;
+  [key: string]: string | number;
+}
+
+export interface AreaParams {
+  parentCode: string;
+}

+ 1 - 0
src/components/Form/index.ts

@@ -10,5 +10,6 @@ export { default as ApiSelect } from './src/components/ApiSelect.vue';
 export { default as RadioButtonGroup } from './src/components/RadioButtonGroup.vue';
 export { default as ApiTreeSelect } from './src/components/ApiTreeSelect.vue';
 export { default as ApiRadioGroup } from './src/components/ApiRadioGroup.vue';
+export { default as ApiCascader } from './src/components/ApiCascader.vue';
 
 export { BasicForm };

+ 2 - 0
src/components/Form/src/componentMap.ts

@@ -25,6 +25,7 @@ import ApiRadioGroup from './components/ApiRadioGroup.vue';
 import RadioButtonGroup from './components/RadioButtonGroup.vue';
 import ApiSelect from './components/ApiSelect.vue';
 import ApiTreeSelect from './components/ApiTreeSelect.vue';
+import ApiCascader from './components/ApiCascader.vue';
 import { BasicUpload } from '/@/components/Upload';
 import { StrengthMeter } from '/@/components/StrengthMeter';
 import { IconPicker } from '/@/components/Icon';
@@ -50,6 +51,7 @@ componentMap.set('RadioButtonGroup', RadioButtonGroup);
 componentMap.set('RadioGroup', Radio.Group);
 componentMap.set('Checkbox', Checkbox);
 componentMap.set('CheckboxGroup', Checkbox.Group);
+componentMap.set('ApiCascader', ApiCascader);
 componentMap.set('Cascader', Cascader);
 componentMap.set('Slider', Slider);
 componentMap.set('Rate', Rate);

+ 197 - 0
src/components/Form/src/components/ApiCascader.vue

@@ -0,0 +1,197 @@
+<template>
+  <a-cascader
+    v-model:value="state"
+    :options="options"
+    :load-data="loadData"
+    change-on-select
+    @change="handleChange"
+    :displayRender="handleRenderDisplay"
+  >
+    <template #suffixIcon v-if="loading">
+      <LoadingOutlined spin />
+    </template>
+    <template #notFoundContent v-if="loading">
+      <span>
+        <LoadingOutlined spin class="mr-1" />
+        {{ t('component.form.apiSelectNotFound') }}
+      </span>
+    </template>
+  </a-cascader>
+</template>
+<script lang="ts">
+  import { defineComponent, PropType, ref, unref, watch, watchEffect } from 'vue';
+  import { Cascader } from 'ant-design-vue';
+  import { propTypes } from '/@/utils/propTypes';
+  import { isFunction } from '/@/utils/is';
+  import { get, omit } from 'lodash-es';
+  import { useRuleFormItem } from '/@/hooks/component/useFormItem';
+  import { LoadingOutlined } from '@ant-design/icons-vue';
+
+  interface Option {
+    value: string;
+    label: string;
+    loading?: boolean;
+    isLeaf?: boolean;
+    children?: Option[];
+  }
+  export default defineComponent({
+    name: 'ApiCascader',
+    components: {
+      LoadingOutlined,
+      [Cascader.name]: Cascader,
+    },
+    props: {
+      value: {
+        type: Array,
+      },
+      api: {
+        type: Function as PropType<(arg?: Recordable) => Promise<Option[]>>,
+        default: null,
+      },
+      numberToString: propTypes.bool,
+      resultField: propTypes.string.def(''),
+      labelField: propTypes.string.def('label'),
+      valueField: propTypes.string.def('value'),
+      childrenField: propTypes.string.def('children'),
+      asyncFetchParamKey: propTypes.string.def('parentCode'),
+      immediate: propTypes.bool.def(true),
+      // init fetch params
+      initFetchParams: {
+        type: Object as PropType<Recordable>,
+        default: () => ({}),
+      },
+      // 是否有下级,默认是
+      isLeaf: {
+        type: Function as PropType<(arg: Recordable) => boolean>,
+        default: null,
+      },
+      displayRenderArray: {
+        type: Array,
+      },
+    },
+    emits: ['change', 'defaultChange'],
+    setup(props, { emit }) {
+      const apiData = ref<any[]>([]);
+      const options = ref<Option[]>([]);
+      const loading = ref<boolean>(false);
+      const emitData = ref<any[]>([]);
+      const isFirstLoad = ref(true);
+
+      // Embedded in the form, just use the hook binding to perform form verification
+      const [state] = useRuleFormItem(props, 'value', 'change', emitData);
+
+      watch(
+        apiData,
+        (data) => {
+          const opts = generatorOptions(data);
+          options.value = opts;
+        },
+        { deep: true },
+      );
+
+      function generatorOptions(options: any[]): Option[] {
+        const { labelField, valueField, numberToString, childrenField, isLeaf } = props;
+        return options.reduce((prev, next: Recordable) => {
+          if (next) {
+            const value = next[valueField];
+            const item = {
+              ...omit(next, [labelField, valueField]),
+              label: next[labelField],
+              value: numberToString ? `${value}` : value,
+              isLeaf: isLeaf && typeof isLeaf === 'function' ? isLeaf(next) : false,
+            };
+            const children = Reflect.get(next, childrenField);
+            if (children) {
+              Reflect.set(item, childrenField, generatorOptions(children));
+            }
+            prev.push(item);
+          }
+          return prev;
+        }, [] as Option[]);
+      }
+
+      async function initialFetch() {
+        const api = props.api;
+        if (!api || !isFunction(api)) return;
+        apiData.value = [];
+        loading.value = true;
+        try {
+          const res = await api(props.initFetchParams);
+          if (Array.isArray(res)) {
+            apiData.value = res;
+            return;
+          }
+          if (props.resultField) {
+            apiData.value = get(res, props.resultField) || [];
+          }
+        } catch (error) {
+          console.warn(error);
+        } finally {
+          loading.value = false;
+        }
+      }
+
+      async function loadData(selectedOptions: Option[]) {
+        const targetOption = selectedOptions[selectedOptions.length - 1];
+        targetOption.loading = true;
+
+        const api = props.api;
+        if (!api || !isFunction(api)) return;
+        try {
+          const res = await api({
+            [props.asyncFetchParamKey]: Reflect.get(targetOption, 'value'),
+          });
+          if (Array.isArray(res)) {
+            const children = generatorOptions(res);
+            targetOption.children = children;
+            return;
+          }
+          if (props.resultField) {
+            const children = generatorOptions(get(res, props.resultField) || []);
+            targetOption.children = children;
+          }
+        } catch (e) {
+          console.error(e);
+        } finally {
+          targetOption.loading = false;
+        }
+      }
+
+      watchEffect(() => {
+        props.immediate && initialFetch();
+      });
+
+      watch(
+        () => props.initFetchParams,
+        () => {
+          !unref(isFirstLoad) && initialFetch();
+        },
+        { deep: true },
+      );
+
+      function handleChange(keys, args) {
+        emitData.value = keys;
+        emit('defaultChange', keys, args);
+      }
+
+      function handleRenderDisplay({ labels, selectedOptions }) {
+        if (unref(emitData).length === selectedOptions.length) {
+          return labels.join(' / ');
+        }
+        if (props.displayRenderArray) {
+          return props.displayRenderArray.join(' / ');
+        }
+        return '';
+      }
+
+      return {
+        state,
+        options,
+        loading,
+        handleChange,
+        loadData,
+        handleRenderDisplay,
+      };
+    },
+  });
+</script>

+ 1 - 0
src/components/Form/src/types/index.ts

@@ -98,6 +98,7 @@ export type ComponentType =
   | 'Checkbox'
   | 'CheckboxGroup'
   | 'AutoComplete'
+  | 'ApiCascader'
   | 'Cascader'
   | 'DatePicker'
   | 'MonthPicker'

+ 75 - 1
src/views/demo/form/UseForm.vue

@@ -52,6 +52,7 @@
       >
         修改查询按钮
       </a-button>
+      <a-button @click="handleLoad" class="mr-2"> 联动回显 </a-button>
     </div>
     <CollapseContainer title="useForm示例">
       <BasicForm @register="register" @submit="handleSubmit" />
@@ -64,6 +65,7 @@
   import { CollapseContainer } from '/@/components/Container/index';
   import { useMessage } from '/@/hooks/web/useMessage';
   import { PageWrapper } from '/@/components/Page';
+  import { areaRecord } from '/@/api/demo/cascader';
 
   const schemas: FormSchema[] = [
     {
@@ -166,6 +168,48 @@
         ],
       },
     },
+    {
+      field: 'field8',
+      component: 'ApiCascader',
+      label: '联动',
+      colProps: {
+        span: 8,
+      },
+      componentProps: {
+        api: areaRecord,
+        apiParamKey: 'parentCode',
+        dataField: 'data',
+        labelField: 'name',
+        valueField: 'code',
+        initFetchParams: {
+          parentCode: '',
+        },
+        isLeaf: (record) => {
+          return !(record.levelType < 3);
+        },
+      },
+    },
+    {
+      field: 'field9',
+      component: 'ApiCascader',
+      label: '联动回显',
+      colProps: {
+        span: 8,
+      },
+      componentProps: {
+        api: areaRecord,
+        apiParamKey: 'parentCode',
+        dataField: 'data',
+        labelField: 'name',
+        valueField: 'code',
+        initFetchParams: {
+          parentCode: '',
+        },
+        isLeaf: (record) => {
+          return !(record.levelType < 3);
+        },
+      },
+    },
   ];
 
   export default defineComponent({
@@ -173,7 +217,7 @@
     setup() {
       const { createMessage } = useMessage();
 
-      const [register, { setProps }] = useForm({
+      const [register, { setProps, setFieldsValue, updateSchema }] = useForm({
         labelWidth: 120,
         schemas,
         actionColOptions: {
@@ -181,6 +225,35 @@
         },
         fieldMapToTime: [['fieldTime', ['startTime', 'endTime'], 'YYYY-MM']],
       });
+
+      async function handleLoad() {
+        const promiseFn = function () {
+          return new Promise((resolve) => {
+            setTimeout(() => {
+              resolve({
+                field9: ['430000', '430100', '430102'],
+                province: '湖南省',
+                city: '长沙市',
+                district: '岳麓区',
+              });
+            }, 1000);
+          });
+        };
+
+        const item = await promiseFn();
+
+        const { field9, province, city, district } = item as any;
+        await updateSchema({
+          field: 'field9',
+          componentProps: {
+            displayRenderArray: [province, city, district],
+          },
+        });
+        await setFieldsValue({
+          field9,
+        });
+      }
+
       return {
         register,
         schemas,
@@ -188,6 +261,7 @@
           createMessage.success('click search,values:' + JSON.stringify(values));
         },
         setProps,
+        handleLoad,
       };
     },
   });