Explorar o código

feat: add api-select component (#5024)

Vben hai 5 meses
pai
achega
9896a67c21

+ 16 - 2
apps/web-antd/src/adapter/component/index.ts

@@ -8,7 +8,7 @@ import type { BaseFormComponentType } from '@vben/common-ui';
 import type { Component, SetupContext } from 'vue';
 import { h } from 'vue';
 
-import { globalShareState, IconPicker } from '@vben/common-ui';
+import { ApiSelect, globalShareState, IconPicker } from '@vben/common-ui';
 import { $t } from '@vben/locales';
 
 import {
@@ -48,6 +48,7 @@ const withDefaultPlaceholder = <T extends Component>(
 
 // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
 export type ComponentType =
+  | 'ApiSelect'
   | 'AutoComplete'
   | 'Checkbox'
   | 'CheckboxGroup'
@@ -78,7 +79,20 @@ async function initComponentAdapter() {
     // 如果你的组件体积比较大,可以使用异步加载
     // Button: () =>
     // import('xxx').then((res) => res.Button),
-
+    ApiSelect: (props, { attrs, slots }) => {
+      return h(
+        ApiSelect,
+        {
+          ...props,
+          ...attrs,
+          component: Select,
+          loadingSlot: 'suffixIcon',
+          visibleEvent: 'onDropdownVisibleChange',
+          modelField: 'value',
+        },
+        slots,
+      );
+    },
     AutoComplete,
     Checkbox,
     CheckboxGroup,

+ 16 - 2
apps/web-ele/src/adapter/component/index.ts

@@ -8,7 +8,7 @@ import type { BaseFormComponentType } from '@vben/common-ui';
 import type { Component, SetupContext } from 'vue';
 import { h } from 'vue';
 
-import { globalShareState, IconPicker } from '@vben/common-ui';
+import { ApiSelect, globalShareState, IconPicker } from '@vben/common-ui';
 import { $t } from '@vben/locales';
 
 import {
@@ -22,6 +22,7 @@ import {
   ElNotification,
   ElRadioGroup,
   ElSelect,
+  ElSelectV2,
   ElSpace,
   ElSwitch,
   ElTimePicker,
@@ -41,6 +42,7 @@ const withDefaultPlaceholder = <T extends Component>(
 
 // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
 export type ComponentType =
+  | 'ApiSelect'
   | 'Checkbox'
   | 'CheckboxGroup'
   | 'DatePicker'
@@ -62,7 +64,19 @@ async function initComponentAdapter() {
     // 如果你的组件体积比较大,可以使用异步加载
     // Button: () =>
     // import('xxx').then((res) => res.Button),
-
+    ApiSelect: (props, { attrs, slots }) => {
+      return h(
+        ApiSelect,
+        {
+          ...props,
+          ...attrs,
+          component: ElSelectV2,
+          loadingSlot: 'loading',
+          visibleEvent: 'onDropdownVisibleChange',
+        },
+        slots,
+      );
+    },
     Checkbox: ElCheckbox,
     CheckboxGroup: ElCheckboxGroup,
     // 自定义默认按钮

+ 14 - 1
apps/web-naive/src/adapter/component/index.ts

@@ -8,7 +8,7 @@ import type { BaseFormComponentType } from '@vben/common-ui';
 import type { Component, SetupContext } from 'vue';
 import { h } from 'vue';
 
-import { globalShareState, IconPicker } from '@vben/common-ui';
+import { ApiSelect, globalShareState, IconPicker } from '@vben/common-ui';
 import { $t } from '@vben/locales';
 
 import {
@@ -42,6 +42,7 @@ const withDefaultPlaceholder = <T extends Component>(
 
 // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
 export type ComponentType =
+  | 'ApiSelect'
   | 'Checkbox'
   | 'CheckboxGroup'
   | 'DatePicker'
@@ -64,6 +65,18 @@ async function initComponentAdapter() {
     // Button: () =>
     // import('xxx').then((res) => res.Button),
 
+    ApiSelect: (props, { attrs, slots }) => {
+      return h(
+        ApiSelect,
+        {
+          ...props,
+          ...attrs,
+          component: NSelect,
+          modelField: 'value',
+        },
+        slots,
+      );
+    },
     Checkbox: NCheckbox,
     CheckboxGroup: NCheckboxGroup,
     DatePicker: NDatePicker,

+ 4 - 0
packages/@core/base/shared/package.json

@@ -86,12 +86,16 @@
     "dayjs": "catalog:",
     "defu": "catalog:",
     "lodash.clonedeep": "catalog:",
+    "lodash.get": "catalog:",
+    "lodash.isequal": "catalog:",
     "nprogress": "catalog:",
     "tailwind-merge": "catalog:",
     "theme-colors": "catalog:"
   },
   "devDependencies": {
     "@types/lodash.clonedeep": "catalog:",
+    "@types/lodash.get": "catalog:",
+    "@types/lodash.isequal": "catalog:",
     "@types/nprogress": "catalog:"
   }
 }

+ 2 - 0
packages/@core/base/shared/src/utils/index.ts

@@ -15,3 +15,5 @@ export * from './update-css-variables';
 export * from './util';
 export * from './window';
 export { default as cloneDeep } from 'lodash.clonedeep';
+export { default as get } from 'lodash.get';
+export { default as isEqual } from 'lodash.isequal';

+ 182 - 0
packages/effects/common-ui/src/components/api-select/api-select.vue

@@ -0,0 +1,182 @@
+<script lang="ts" setup>
+import type { AnyPromiseFunction } from '@vben/types';
+
+import { computed, ref, unref, useAttrs, type VNode, watch } from 'vue';
+
+import { LoaderCircle } from '@vben/icons';
+import { get, isEqual, isFunction } from '@vben-core/shared/utils';
+
+import { objectOmit } from '@vueuse/core';
+
+type OptionsItem = {
+  [name: string]: any;
+  disabled?: boolean;
+  label?: string;
+  value?: string;
+};
+
+interface Props {
+  // 组件
+  component: VNode;
+  numberToString?: boolean;
+  api?: (arg?: any) => Promise<OptionsItem[] | Record<string, any>>;
+  params?: Record<string, any>;
+  resultField?: string;
+  labelField?: string;
+  valueField?: string;
+  immediate?: boolean;
+  alwaysLoad?: boolean;
+  beforeFetch?: AnyPromiseFunction<any, any>;
+  afterFetch?: AnyPromiseFunction<any, any>;
+  options?: OptionsItem[];
+  // 尾部插槽
+  loadingSlot?: string;
+  // 可见时触发的事件名
+  visibleEvent?: string;
+  modelField?: string;
+}
+
+defineOptions({ name: 'ApiSelect', inheritAttrs: false });
+
+const props = withDefaults(defineProps<Props>(), {
+  labelField: 'label',
+  valueField: 'value',
+  resultField: '',
+  visibleEvent: '',
+  numberToString: false,
+  params: () => ({}),
+  immediate: true,
+  alwaysLoad: false,
+  loadingSlot: '',
+  beforeFetch: undefined,
+  afterFetch: undefined,
+  modelField: 'modelValue',
+  api: undefined,
+  options: () => [],
+});
+
+const emit = defineEmits<{
+  optionsChange: [OptionsItem[]];
+}>();
+
+const modelValue = defineModel({ default: '' });
+
+const attrs = useAttrs();
+
+const refOptions = ref<OptionsItem[]>([]);
+const loading = ref(false);
+// 首次是否加载过了
+const isFirstLoaded = ref(false);
+
+const getOptions = computed(() => {
+  const { labelField, valueField, numberToString } = props;
+
+  const data: OptionsItem[] = [];
+  const refOptionsData = unref(refOptions);
+
+  for (const next of refOptionsData) {
+    if (next) {
+      const value = get(next, valueField);
+      data.push({
+        ...objectOmit(next, [labelField, valueField]),
+        label: get(next, labelField),
+        value: numberToString ? `${value}` : value,
+      });
+    }
+  }
+
+  return data.length > 0 ? data : props.options;
+});
+
+const bindProps = computed(() => {
+  return {
+    [props.modelField]: unref(modelValue),
+    [`onUpdate:${props.modelField}`]: (val: string) => {
+      modelValue.value = val;
+    },
+    ...objectOmit(attrs, ['onUpdate:value']),
+    ...(props.visibleEvent
+      ? {
+          [props.visibleEvent]: handleFetchForVisible,
+        }
+      : {}),
+  };
+});
+
+async function fetchApi() {
+  let { api, beforeFetch, afterFetch, params, resultField } = props;
+
+  if (!api || !isFunction(api) || loading.value) {
+    return;
+  }
+  refOptions.value = [];
+  try {
+    loading.value = true;
+    if (beforeFetch && isFunction(beforeFetch)) {
+      params = (await beforeFetch(params)) || params;
+    }
+    let res = await api(params);
+    if (afterFetch && isFunction(afterFetch)) {
+      res = (await afterFetch(res)) || res;
+    }
+    isFirstLoaded.value = true;
+    if (Array.isArray(res)) {
+      refOptions.value = res;
+      emitChange();
+      return;
+    }
+    if (resultField) {
+      refOptions.value = get(res, resultField) || [];
+    }
+    emitChange();
+  } catch (error) {
+    console.warn(error);
+    // reset status
+    isFirstLoaded.value = false;
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function handleFetchForVisible(visible: boolean) {
+  if (visible) {
+    if (props.alwaysLoad) {
+      await fetchApi();
+    } else if (!props.immediate && !unref(isFirstLoaded)) {
+      await fetchApi();
+    }
+  }
+}
+
+watch(
+  () => props.params,
+  (value, oldValue) => {
+    if (isEqual(value, oldValue)) {
+      return;
+    }
+    fetchApi();
+  },
+  { deep: true, immediate: props.immediate },
+);
+
+function emitChange() {
+  emit('optionsChange', unref(getOptions));
+}
+</script>
+<template>
+  <div v-bind="{ ...$attrs }">
+    <component
+      :is="component"
+      v-bind="bindProps"
+      :options="getOptions"
+      :placeholder="$attrs.placeholder"
+    >
+      <template v-for="item in Object.keys($slots)" #[item]="data">
+        <slot :name="item" v-bind="data || {}"></slot>
+      </template>
+      <template v-if="loadingSlot && loading" #[loadingSlot]>
+        <LoaderCircle class="animate-spin" />
+      </template>
+    </component>
+  </div>
+</template>

+ 1 - 0
packages/effects/common-ui/src/components/api-select/index.ts

@@ -0,0 +1 @@
+export { default as ApiSelect } from './api-select.vue';

+ 1 - 0
packages/effects/common-ui/src/components/index.ts

@@ -1,3 +1,4 @@
+export * from './api-select';
 export * from './captcha';
 export * from './ellipsis-text';
 export * from './icon-picker';

+ 16 - 1
playground/src/adapter/component/index.ts

@@ -8,7 +8,7 @@ import type { BaseFormComponentType } from '@vben/common-ui';
 import type { Component, SetupContext } from 'vue';
 import { h } from 'vue';
 
-import { globalShareState, IconPicker } from '@vben/common-ui';
+import { ApiSelect, globalShareState, IconPicker } from '@vben/common-ui';
 import { $t } from '@vben/locales';
 
 import {
@@ -48,6 +48,7 @@ const withDefaultPlaceholder = <T extends Component>(
 
 // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
 export type ComponentType =
+  | 'ApiSelect'
   | 'AutoComplete'
   | 'Checkbox'
   | 'CheckboxGroup'
@@ -79,6 +80,20 @@ async function initComponentAdapter() {
     // Button: () =>
     // import('xxx').then((res) => res.Button),
 
+    ApiSelect: (props, { attrs, slots }) => {
+      return h(
+        ApiSelect,
+        {
+          ...props,
+          ...attrs,
+          component: Select,
+          loadingSlot: 'suffixIcon',
+          modelField: 'value',
+          visibleEvent: 'onVisibleChange',
+        },
+        slots,
+      );
+    },
     AutoComplete,
     Checkbox,
     CheckboxGroup,

+ 22 - 0
playground/src/views/examples/form/basic.vue

@@ -7,6 +7,7 @@ import { Button, Card, message, TabPane, Tabs } from 'ant-design-vue';
 import dayjs from 'dayjs';
 
 import { useVbenForm } from '#/adapter/form';
+import { getAllMenusApi } from '#/api';
 
 import DocButton from '../doc-button.vue';
 
@@ -40,6 +41,27 @@ const [BaseForm, baseFormApi] = useVbenForm({
       // 界面显示的label
       label: '字符串',
     },
+    {
+      // 组件需要在 #/adapter.ts内注册,并加上类型
+      component: 'ApiSelect',
+      // 对应组件的参数
+      componentProps: {
+        // 菜单接口转options格式
+        afterFetch: (data: { name: string; path: string }[]) => {
+          return data.map((item: any) => ({
+            label: item.name,
+            value: item.path,
+          }));
+        },
+        // 菜单接口
+        api: getAllMenusApi,
+        placeholder: '请选择',
+      },
+      // 字段名
+      fieldName: 'api',
+      // 界面显示的label
+      label: 'ApiSelect',
+    },
     {
       component: 'InputPassword',
       componentProps: {

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 158 - 230
pnpm-lock.yaml


+ 25 - 21
pnpm-workspace.yaml

@@ -13,7 +13,7 @@ packages:
   - docs
   - playground
 catalog:
-  '@ast-grep/napi': ^0.30.1
+  '@ast-grep/napi': ^0.31.0
   '@changesets/changelog-github': ^0.5.0
   '@changesets/cli': ^2.27.10
   '@changesets/git': ^3.0.2
@@ -22,8 +22,8 @@ catalog:
   '@commitlint/config-conventional': ^19.6.0
   '@ctrl/tinycolor': ^4.1.0
   '@eslint/js': ^9.16.0
-  '@faker-js/faker': ^9.2.0
-  '@iconify/json': ^2.2.278
+  '@faker-js/faker': ^9.3.0
+  '@iconify/json': ^2.2.279
   '@iconify/tailwind': ^1.1.3
   '@iconify/vue': ^4.1.2
   '@intlify/core-base': ^10.0.5
@@ -36,20 +36,22 @@ catalog:
   '@stylistic/stylelint-plugin': ^3.1.1
   '@tailwindcss/nesting': 0.0.0-insiders.565cd3e
   '@tailwindcss/typography': ^0.5.15
-  '@tanstack/vue-query': ^5.62.0
+  '@tanstack/vue-query': ^5.62.2
   '@tanstack/vue-store': ^0.6.0
   '@types/archiver': ^6.0.3
   '@types/eslint': ^9.6.1
   '@types/html-minifier-terser': ^7.0.2
   '@types/jsonwebtoken': ^9.0.7
   '@types/lodash.clonedeep': ^4.5.9
-  '@types/node': ^22.10.0
+  '@types/lodash.get': ^4.4.9
+  '@types/lodash.isequal': ^4.5.8
+  '@types/node': ^22.10.1
   '@types/nprogress': ^0.2.3
   '@types/postcss-import': ^14.0.3
   '@types/qrcode': ^1.5.5
   '@types/sortablejs': ^1.15.8
-  '@typescript-eslint/eslint-plugin': ^8.16.0
-  '@typescript-eslint/parser': ^8.16.0
+  '@typescript-eslint/eslint-plugin': ^8.17.0
+  '@typescript-eslint/parser': ^8.17.0
   '@vee-validate/zod': ^4.14.7
   '@vite-pwa/vitepress': ^0.5.3
   '@vitejs/plugin-vue': ^5.2.1
@@ -62,7 +64,7 @@ catalog:
   ant-design-vue: ^4.2.6
   archiver: ^7.0.1
   autoprefixer: ^10.4.20
-  axios: ^1.7.8
+  axios: ^1.7.9
   axios-mock-adapter: ^2.1.0
   cac: ^6.7.14
   chalk: ^5.3.0
@@ -80,14 +82,14 @@ catalog:
   dayjs: ^1.11.13
   defu: ^6.1.4
   depcheck: ^1.4.7
-  dotenv: ^16.4.5
+  dotenv: ^16.4.7
   echarts: ^5.5.1
   element-plus: ^2.9.0
   eslint: ^9.16.0
   eslint-config-turbo: ^2.3.3
   eslint-plugin-command: ^0.2.6
   eslint-plugin-eslint-comments: ^3.2.0
-  eslint-plugin-import-x: ^4.4.3
+  eslint-plugin-import-x: ^4.5.0
   eslint-plugin-jsdoc: ^50.6.0
   eslint-plugin-jsonc: ^2.18.2
   eslint-plugin-n: ^17.14.0
@@ -102,7 +104,7 @@ catalog:
   execa: ^9.5.1
   find-up: ^7.0.0
   get-port: ^7.1.0
-  globals: ^15.12.0
+  globals: ^15.13.0
   h3: ^1.13.0
   happy-dom: ^15.11.7
   html-minifier-terser: ^7.2.0
@@ -112,9 +114,11 @@ catalog:
   jsonwebtoken: ^9.0.2
   lint-staged: ^15.2.10
   lodash.clonedeep: ^4.5.0
-  lucide-vue-next: ^0.461.0
+  lodash.get: ^4.4.2
+  lodash.isequal: ^4.5.0
+  lucide-vue-next: ^0.465.0
   medium-zoom: ^1.1.0
-  naive-ui: ^2.40.2
+  naive-ui: ^2.40.3
   nitropack: ^2.10.4
   nprogress: ^0.2.0
   ora: ^8.1.1
@@ -128,12 +132,12 @@ catalog:
   postcss-import: ^16.1.0
   postcss-preset-env: ^10.1.1
   postcss-scss: ^4.0.9
-  prettier: ^3.4.1
+  prettier: ^3.4.2
   prettier-plugin-tailwindcss: ^0.6.9
   publint: ^0.2.12
   qrcode: ^1.5.4
   radix-vue: ^1.9.10
-  resolve.exports: ^2.0.2
+  resolve.exports: ^2.0.3
   rimraf: ^6.0.1
   rollup: ^4.28.0
   rollup-plugin-visualizer: ^5.12.0
@@ -149,7 +153,7 @@ catalog:
   stylelint-prettier: ^5.0.2
   stylelint-scss: ^6.10.0
   tailwind-merge: ^2.5.5
-  tailwindcss: ^3.4.15
+  tailwindcss: ^3.4.16
   tailwindcss-animate: ^1.0.7
   theme-colors: ^0.1.0
   turbo: ^2.3.3
@@ -157,7 +161,7 @@ catalog:
   unbuild: ^3.0.0-rc.11
   unplugin-element-plus: ^0.8.0
   vee-validate: ^4.14.7
-  vite: ^6.0.1
+  vite: ^6.0.2
   vite-plugin-compression: ^0.5.1
   vite-plugin-dts: 4.2.1
   vite-plugin-html: ^3.2.2
@@ -165,15 +169,15 @@ catalog:
   vite-plugin-pwa: ^0.21.1
   vite-plugin-vue-devtools: ^7.6.7
   vitepress: ^1.5.0
-  vitepress-plugin-group-icons: ^1.3.0
-  vitest: ^2.1.6
+  vitepress-plugin-group-icons: ^1.3.1
+  vitest: ^2.1.8
   vue: ^3.5.13
   vue-eslint-parser: ^9.4.3
   vue-i18n: ^10.0.5
   vue-router: ^4.5.0
   vue-tsc: ^2.1.10
-  vxe-pc-ui: ^4.3.10
-  vxe-table: ^4.9.10
+  vxe-pc-ui: ^4.3.14
+  vxe-table: ^4.9.14
   watermark-js-plus: ^1.5.7
   zod: ^3.23.8
   zod-defaults: ^0.1.3

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio