Skip to content

Commit 5bd7386

Browse files
authored
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
1 parent 22e6f28 commit 5bd7386

File tree

3 files changed

+158
-22
lines changed

3 files changed

+158
-22
lines changed

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

Lines changed: 85 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
<script setup lang="ts">
22
import type { VNode } from 'vue';
33
4-
import { computed, h, ref, watch, watchEffect } from 'vue';
4+
import { computed, ref, watch, watchEffect } from 'vue';
55
66
import { usePagination } from '@vben/hooks';
77
import { EmptyIcon, Grip, listIcons } from '@vben/icons';
88
import { $t } from '@vben/locales';
99
1010
import {
1111
Button,
12+
Input,
1213
Pagination,
1314
PaginationEllipsis,
1415
PaginationFirst,
@@ -22,11 +23,16 @@ import {
2223
VbenPopover,
2324
} from '@vben-core/shadcn-ui';
2425
25-
import { refDebounced } from '@vueuse/core';
26+
import { refDebounced, watchDebounced } from '@vueuse/core';
27+
28+
import { fetchIconsData } from './icons';
2629
2730
interface Props {
2831
pageSize?: number;
32+
/** 图标集的名字 */
2933
prefix?: string;
34+
/** 是否自动请求API以获得图标集的数据.提供prefix时有效 */
35+
autoFetchApi?: boolean;
3036
/**
3137
* 图标列表
3238
*/
@@ -39,16 +45,19 @@ interface Props {
3945
modelValueProp?: string;
4046
/** 图标样式 */
4147
iconClass?: string;
48+
type?: 'icon' | 'input';
4249
}
4350
4451
const props = withDefaults(defineProps<Props>(), {
4552
prefix: 'ant-design',
4653
pageSize: 36,
4754
icons: () => [],
48-
inputComponent: () => h('div'),
4955
iconSlot: 'default',
5056
iconClass: 'size-4',
51-
modelValueProp: 'value',
57+
autoFetchApi: true,
58+
modelValueProp: 'modelValue',
59+
inputComponent: undefined,
60+
type: 'input',
5261
});
5362
5463
const emit = defineEmits<{
@@ -62,9 +71,28 @@ const currentSelect = ref('');
6271
const currentPage = ref(1);
6372
const keyword = ref('');
6473
const keywordDebounce = refDebounced(keyword, 300);
74+
const innerIcons = ref<string[]>([]);
75+
76+
watchDebounced(
77+
() => props.prefix,
78+
async (prefix) => {
79+
if (prefix && prefix !== 'svg' && props.autoFetchApi) {
80+
innerIcons.value = await fetchIconsData(prefix);
81+
}
82+
},
83+
{ immediate: true, debounce: 500, maxWait: 1000 },
84+
);
85+
6586
const currentList = computed(() => {
6687
try {
6788
if (props.prefix) {
89+
if (
90+
props.prefix !== 'svg' &&
91+
props.autoFetchApi &&
92+
props.icons.length === 0
93+
) {
94+
return innerIcons.value;
95+
}
6896
const icons = listIcons('', props.prefix);
6997
if (icons.length === 0) {
7098
console.warn(`No icons found for prefix: ${props.prefix}`);
@@ -146,18 +174,61 @@ defineExpose({ toggleOpenState, open, close });
146174
content-class="p-0 pt-3"
147175
>
148176
<template #trigger>
149-
<component
150-
:is="inputComponent"
151-
:[modelValueProp]="currentSelect"
152-
:placeholder="$t('ui.iconPicker.placeholder')"
153-
>
154-
<template #[iconSlot]>
155-
<VbenIcon :icon="currentSelect || Grip" class="size-4" />
156-
</template>
157-
</component>
177+
<template v-if="props.type === 'input'">
178+
<component
179+
v-if="props.inputComponent"
180+
:is="inputComponent"
181+
:[modelValueProp]="currentSelect"
182+
:placeholder="$t('ui.iconPicker.placeholder')"
183+
role="combobox"
184+
:aria-label="$t('ui.iconPicker.placeholder')"
185+
aria-expanded="visible"
186+
v-bind="$attrs"
187+
>
188+
<template #[iconSlot]>
189+
<VbenIcon
190+
:icon="currentSelect || Grip"
191+
class="size-4"
192+
aria-hidden="true"
193+
/>
194+
</template>
195+
</component>
196+
<div class="relative w-full" v-else>
197+
<Input
198+
v-bind="$attrs"
199+
v-model="currentSelect"
200+
:placeholder="$t('ui.iconPicker.placeholder')"
201+
class="h-8 w-full pr-8"
202+
role="combobox"
203+
:aria-label="$t('ui.iconPicker.placeholder')"
204+
aria-expanded="visible"
205+
/>
206+
<VbenIcon
207+
:icon="currentSelect || Grip"
208+
class="absolute right-1 top-1 size-6"
209+
aria-hidden="true"
210+
/>
211+
</div>
212+
</template>
213+
<VbenIcon
214+
:icon="currentSelect || Grip"
215+
v-else
216+
class="size-4"
217+
v-bind="$attrs"
218+
/>
158219
</template>
159220
<div class="mb-2 flex w-full">
160-
<component :is="inputComponent" v-bind="searchInputProps" />
221+
<component
222+
v-if="inputComponent"
223+
:is="inputComponent"
224+
v-bind="searchInputProps"
225+
/>
226+
<Input
227+
v-else
228+
class="mx-2 h-8 w-full"
229+
:placeholder="$t('ui.iconPicker.search')"
230+
v-model="keyword"
231+
/>
161232
</div>
162233

163234
<template v-if="paginationList.length > 0">
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { Recordable } from '@vben/types';
2+
3+
/**
4+
* 一个缓存对象,在不刷新页面时,无需重复请求远程接口
5+
*/
6+
export const ICONS_MAP: Recordable<string[]> = {};
7+
8+
interface IconifyResponse {
9+
prefix: string;
10+
total: number;
11+
title: string;
12+
uncategorized?: string[];
13+
categories?: Recordable<string[]>;
14+
aliases?: Recordable<string>;
15+
}
16+
17+
const PENDING_REQUESTS: Recordable<Promise<string[]>> = {};
18+
19+
/**
20+
* 通过Iconify接口获取图标集数据。
21+
* 同一时间多个图标选择器同时请求同一个图标集时,实际上只会发起一次请求(所有请求共享同一份结果)。
22+
* 请求结果会被缓存,刷新页面前同一个图标集不会再次请求
23+
* @param prefix 图标集名称
24+
* @returns 图标集中包含的所有图标名称
25+
*/
26+
export async function fetchIconsData(prefix: string): Promise<string[]> {
27+
if (Reflect.has(ICONS_MAP, prefix) && ICONS_MAP[prefix]) {
28+
return ICONS_MAP[prefix];
29+
}
30+
if (Reflect.has(PENDING_REQUESTS, prefix) && PENDING_REQUESTS[prefix]) {
31+
return PENDING_REQUESTS[prefix];
32+
}
33+
PENDING_REQUESTS[prefix] = (async () => {
34+
try {
35+
const controller = new AbortController();
36+
const timeoutId = setTimeout(() => controller.abort(), 1000 * 10);
37+
const response: IconifyResponse = await fetch(
38+
`https://api.iconify.design/collection?prefix=${prefix}`,
39+
{ signal: controller.signal },
40+
).then((res) => res.json());
41+
clearTimeout(timeoutId);
42+
const list = response.uncategorized || [];
43+
if (response.categories) {
44+
for (const category in response.categories) {
45+
list.push(...(response.categories[category] || []));
46+
}
47+
}
48+
ICONS_MAP[prefix] = list.map((v) => `${prefix}:${v}`);
49+
} catch (error) {
50+
console.error(`Failed to fetch icons for prefix ${prefix}:`, error);
51+
return [] as string[];
52+
}
53+
return ICONS_MAP[prefix];
54+
})();
55+
return PENDING_REQUESTS[prefix];
56+
}

playground/src/views/demos/features/icons/index.vue

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
2121
import { Card, Input } from 'ant-design-vue';
2222
23-
const iconValue = ref('ant-design:trademark-outlined');
23+
const iconValue1 = ref('ant-design:trademark-outlined');
24+
const iconValue2 = ref('svg:avatar-1');
25+
const iconValue3 = ref('mdi:alien-outline');
26+
const iconValue4 = ref('mdi-light:book-multiple');
2427
2528
const inputComponent = h(Input);
2629
</script>
@@ -78,26 +81,32 @@ const inputComponent = h(Input);
7881
<Card class="mb-5" title="图标选择器">
7982
<div class="mb-5 flex items-center gap-5">
8083
<span>原始样式(Iconify):</span>
81-
<IconPicker class="w-[200px]" />
84+
<IconPicker v-model="iconValue1" class="w-[200px]" />
8285
</div>
8386
<div class="mb-5 flex items-center gap-5">
8487
<span>原始样式(svg):</span>
85-
<IconPicker class="w-[200px]" prefix="svg" />
88+
<IconPicker v-model="iconValue2" class="w-[200px]" prefix="svg" />
8689
</div>
8790
<div class="mb-5 flex items-center gap-5">
88-
<span>使用Input:</span>
89-
<IconPicker :input-component="inputComponent" icon-slot="addonAfter" />
91+
<span>自定义Input:</span>
92+
<IconPicker
93+
:input-component="inputComponent"
94+
v-model="iconValue3"
95+
icon-slot="addonAfter"
96+
model-value-prop="value"
97+
prefix="mdi"
98+
/>
9099
</div>
91100
<div class="flex items-center gap-5">
92-
<span>可手动输入,只能点击图标打开弹窗:</span>
101+
<span>显示为一个Icon:</span>
93102
<Input
94-
v-model:value="iconValue"
103+
v-model:value="iconValue4"
95104
allow-clear
96105
placeholder="点击这里选择图标"
97106
style="width: 300px"
98107
>
99108
<template #addonAfter>
100-
<IconPicker v-model="iconValue" class="w-[200px]" />
109+
<IconPicker v-model="iconValue4" prefix="mdi-light" type="icon" />
101110
</template>
102111
</Input>
103112
</div>

0 commit comments

Comments
 (0)