1
0
Эх сурвалжийг харах

feat: auto fetch icon list in iconPicker (#5446)

* feat: auto fetch icon list in iconPicker

* fix: add timeout controller for fetching

* feat: add pending controller

* fix: icon demo prefix
Netfan 2 сар өмнө
parent
commit
5bd73867b6

+ 85 - 14
packages/effects/common-ui/src/components/icon-picker/icon-picker.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import type { VNode } from 'vue';
 
-import { computed, h, ref, watch, watchEffect } from 'vue';
+import { computed, ref, watch, watchEffect } from 'vue';
 
 import { usePagination } from '@vben/hooks';
 import { EmptyIcon, Grip, listIcons } from '@vben/icons';
@@ -9,6 +9,7 @@ import { $t } from '@vben/locales';
 
 import {
   Button,
+  Input,
   Pagination,
   PaginationEllipsis,
   PaginationFirst,
@@ -22,11 +23,16 @@ import {
   VbenPopover,
 } from '@vben-core/shadcn-ui';
 
-import { refDebounced } from '@vueuse/core';
+import { refDebounced, watchDebounced } from '@vueuse/core';
+
+import { fetchIconsData } from './icons';
 
 interface Props {
   pageSize?: number;
+  /** 图标集的名字 */
   prefix?: string;
+  /** 是否自动请求API以获得图标集的数据.提供prefix时有效 */
+  autoFetchApi?: boolean;
   /**
    * 图标列表
    */
@@ -39,16 +45,19 @@ interface Props {
   modelValueProp?: string;
   /** 图标样式 */
   iconClass?: string;
+  type?: 'icon' | 'input';
 }
 
 const props = withDefaults(defineProps<Props>(), {
   prefix: 'ant-design',
   pageSize: 36,
   icons: () => [],
-  inputComponent: () => h('div'),
   iconSlot: 'default',
   iconClass: 'size-4',
-  modelValueProp: 'value',
+  autoFetchApi: true,
+  modelValueProp: 'modelValue',
+  inputComponent: undefined,
+  type: 'input',
 });
 
 const emit = defineEmits<{
@@ -62,9 +71,28 @@ const currentSelect = ref('');
 const currentPage = ref(1);
 const keyword = ref('');
 const keywordDebounce = refDebounced(keyword, 300);
+const innerIcons = ref<string[]>([]);
+
+watchDebounced(
+  () => props.prefix,
+  async (prefix) => {
+    if (prefix && prefix !== 'svg' && props.autoFetchApi) {
+      innerIcons.value = await fetchIconsData(prefix);
+    }
+  },
+  { immediate: true, debounce: 500, maxWait: 1000 },
+);
+
 const currentList = computed(() => {
   try {
     if (props.prefix) {
+      if (
+        props.prefix !== 'svg' &&
+        props.autoFetchApi &&
+        props.icons.length === 0
+      ) {
+        return innerIcons.value;
+      }
       const icons = listIcons('', props.prefix);
       if (icons.length === 0) {
         console.warn(`No icons found for prefix: ${props.prefix}`);
@@ -146,18 +174,61 @@ defineExpose({ toggleOpenState, open, close });
     content-class="p-0 pt-3"
   >
     <template #trigger>
-      <component
-        :is="inputComponent"
-        :[modelValueProp]="currentSelect"
-        :placeholder="$t('ui.iconPicker.placeholder')"
-      >
-        <template #[iconSlot]>
-          <VbenIcon :icon="currentSelect || Grip" class="size-4" />
-        </template>
-      </component>
+      <template v-if="props.type === 'input'">
+        <component
+          v-if="props.inputComponent"
+          :is="inputComponent"
+          :[modelValueProp]="currentSelect"
+          :placeholder="$t('ui.iconPicker.placeholder')"
+          role="combobox"
+          :aria-label="$t('ui.iconPicker.placeholder')"
+          aria-expanded="visible"
+          v-bind="$attrs"
+        >
+          <template #[iconSlot]>
+            <VbenIcon
+              :icon="currentSelect || Grip"
+              class="size-4"
+              aria-hidden="true"
+            />
+          </template>
+        </component>
+        <div class="relative w-full" v-else>
+          <Input
+            v-bind="$attrs"
+            v-model="currentSelect"
+            :placeholder="$t('ui.iconPicker.placeholder')"
+            class="h-8 w-full pr-8"
+            role="combobox"
+            :aria-label="$t('ui.iconPicker.placeholder')"
+            aria-expanded="visible"
+          />
+          <VbenIcon
+            :icon="currentSelect || Grip"
+            class="absolute right-1 top-1 size-6"
+            aria-hidden="true"
+          />
+        </div>
+      </template>
+      <VbenIcon
+        :icon="currentSelect || Grip"
+        v-else
+        class="size-4"
+        v-bind="$attrs"
+      />
     </template>
     <div class="mb-2 flex w-full">
-      <component :is="inputComponent" v-bind="searchInputProps" />
+      <component
+        v-if="inputComponent"
+        :is="inputComponent"
+        v-bind="searchInputProps"
+      />
+      <Input
+        v-else
+        class="mx-2 h-8 w-full"
+        :placeholder="$t('ui.iconPicker.search')"
+        v-model="keyword"
+      />
     </div>
 
     <template v-if="paginationList.length > 0">

+ 56 - 0
packages/effects/common-ui/src/components/icon-picker/icons.ts

@@ -0,0 +1,56 @@
+import type { Recordable } from '@vben/types';
+
+/**
+ * 一个缓存对象,在不刷新页面时,无需重复请求远程接口
+ */
+export const ICONS_MAP: Recordable<string[]> = {};
+
+interface IconifyResponse {
+  prefix: string;
+  total: number;
+  title: string;
+  uncategorized?: string[];
+  categories?: Recordable<string[]>;
+  aliases?: Recordable<string>;
+}
+
+const PENDING_REQUESTS: Recordable<Promise<string[]>> = {};
+
+/**
+ * 通过Iconify接口获取图标集数据。
+ * 同一时间多个图标选择器同时请求同一个图标集时,实际上只会发起一次请求(所有请求共享同一份结果)。
+ * 请求结果会被缓存,刷新页面前同一个图标集不会再次请求
+ * @param prefix 图标集名称
+ * @returns 图标集中包含的所有图标名称
+ */
+export async function fetchIconsData(prefix: string): Promise<string[]> {
+  if (Reflect.has(ICONS_MAP, prefix) && ICONS_MAP[prefix]) {
+    return ICONS_MAP[prefix];
+  }
+  if (Reflect.has(PENDING_REQUESTS, prefix) && PENDING_REQUESTS[prefix]) {
+    return PENDING_REQUESTS[prefix];
+  }
+  PENDING_REQUESTS[prefix] = (async () => {
+    try {
+      const controller = new AbortController();
+      const timeoutId = setTimeout(() => controller.abort(), 1000 * 10);
+      const response: IconifyResponse = await fetch(
+        `https://api.iconify.design/collection?prefix=${prefix}`,
+        { signal: controller.signal },
+      ).then((res) => res.json());
+      clearTimeout(timeoutId);
+      const list = response.uncategorized || [];
+      if (response.categories) {
+        for (const category in response.categories) {
+          list.push(...(response.categories[category] || []));
+        }
+      }
+      ICONS_MAP[prefix] = list.map((v) => `${prefix}:${v}`);
+    } catch (error) {
+      console.error(`Failed to fetch icons for prefix ${prefix}:`, error);
+      return [] as string[];
+    }
+    return ICONS_MAP[prefix];
+  })();
+  return PENDING_REQUESTS[prefix];
+}

+ 17 - 8
playground/src/views/demos/features/icons/index.vue

@@ -20,7 +20,10 @@ import {
 
 import { Card, Input } from 'ant-design-vue';
 
-const iconValue = ref('ant-design:trademark-outlined');
+const iconValue1 = ref('ant-design:trademark-outlined');
+const iconValue2 = ref('svg:avatar-1');
+const iconValue3 = ref('mdi:alien-outline');
+const iconValue4 = ref('mdi-light:book-multiple');
 
 const inputComponent = h(Input);
 </script>
@@ -78,26 +81,32 @@ const inputComponent = h(Input);
     <Card class="mb-5" title="图标选择器">
       <div class="mb-5 flex items-center gap-5">
         <span>原始样式(Iconify):</span>
-        <IconPicker class="w-[200px]" />
+        <IconPicker v-model="iconValue1" class="w-[200px]" />
       </div>
       <div class="mb-5 flex items-center gap-5">
         <span>原始样式(svg):</span>
-        <IconPicker class="w-[200px]" prefix="svg" />
+        <IconPicker v-model="iconValue2" class="w-[200px]" prefix="svg" />
       </div>
       <div class="mb-5 flex items-center gap-5">
-        <span>使用Input:</span>
-        <IconPicker :input-component="inputComponent" icon-slot="addonAfter" />
+        <span>自定义Input:</span>
+        <IconPicker
+          :input-component="inputComponent"
+          v-model="iconValue3"
+          icon-slot="addonAfter"
+          model-value-prop="value"
+          prefix="mdi"
+        />
       </div>
       <div class="flex items-center gap-5">
-        <span>可手动输入,只能点击图标打开弹窗:</span>
+        <span>显示为一个Icon:</span>
         <Input
-          v-model:value="iconValue"
+          v-model:value="iconValue4"
           allow-clear
           placeholder="点击这里选择图标"
           style="width: 300px"
         >
           <template #addonAfter>
-            <IconPicker v-model="iconValue" class="w-[200px]" />
+            <IconPicker v-model="iconValue4" prefix="mdi-light" type="icon" />
           </template>
         </Input>
       </div>