init project
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
import type { VNode } from 'vue';
|
||||
|
||||
import { computed, ref, useAttrs, watch, watchEffect } from 'vue';
|
||||
|
||||
import { usePagination } from '@vben/hooks';
|
||||
import { EmptyIcon, Grip, listIcons } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Pagination,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
VbenIcon,
|
||||
VbenIconButton,
|
||||
VbenPopover,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
import { objectOmit, refDebounced, watchDebounced } from '@vueuse/core';
|
||||
|
||||
import { fetchIconsData } from './icons';
|
||||
|
||||
interface Props {
|
||||
pageSize?: number;
|
||||
/** 图标集的名字 */
|
||||
prefix?: string;
|
||||
/** 是否自动请求API以获得图标集的数据.提供prefix时有效 */
|
||||
autoFetchApi?: boolean;
|
||||
/**
|
||||
* 图标列表
|
||||
*/
|
||||
icons?: string[];
|
||||
/** Input组件 */
|
||||
inputComponent?: VNode;
|
||||
/** 图标插槽名,预览图标将被渲染到此插槽中 */
|
||||
iconSlot?: string;
|
||||
/** input组件的值属性名称 */
|
||||
modelValueProp?: string;
|
||||
/** 图标样式 */
|
||||
iconClass?: string;
|
||||
type?: 'icon' | 'input';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
prefix: 'ant-design',
|
||||
pageSize: 36,
|
||||
icons: () => [],
|
||||
iconSlot: 'default',
|
||||
iconClass: 'size-4',
|
||||
autoFetchApi: true,
|
||||
modelValueProp: 'modelValue',
|
||||
inputComponent: undefined,
|
||||
type: 'input',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [string];
|
||||
}>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const modelValue = defineModel({ default: '', type: String });
|
||||
|
||||
const visible = ref(false);
|
||||
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}`);
|
||||
}
|
||||
return icons;
|
||||
} else {
|
||||
return props.icons;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load icons:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const showList = computed(() => {
|
||||
return currentList.value.filter((item) =>
|
||||
item.includes(keywordDebounce.value),
|
||||
);
|
||||
});
|
||||
|
||||
const { paginationList, total, setCurrentPage } = usePagination(
|
||||
showList,
|
||||
props.pageSize,
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
currentSelect.value = modelValue.value;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => currentSelect.value,
|
||||
(v) => {
|
||||
emit('change', v);
|
||||
},
|
||||
);
|
||||
|
||||
const handleClick = (icon: string) => {
|
||||
currentSelect.value = icon;
|
||||
modelValue.value = icon;
|
||||
close();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page;
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
function toggleOpenState() {
|
||||
visible.value = !visible.value;
|
||||
}
|
||||
|
||||
function open() {
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function onKeywordChange(v: string) {
|
||||
keyword.value = v;
|
||||
}
|
||||
|
||||
const searchInputProps = computed(() => {
|
||||
return {
|
||||
placeholder: $t('ui.iconPicker.search'),
|
||||
[props.modelValueProp]: keyword.value,
|
||||
[`onUpdate:${props.modelValueProp}`]: onKeywordChange,
|
||||
class: 'mx-2',
|
||||
};
|
||||
});
|
||||
|
||||
function updateCurrentSelect(v: string) {
|
||||
currentSelect.value = v;
|
||||
const eventKey = `onUpdate:${props.modelValueProp}`;
|
||||
if (attrs[eventKey] && isFunction(attrs[eventKey])) {
|
||||
attrs[eventKey](v);
|
||||
}
|
||||
}
|
||||
const getBindAttrs = computed(() => {
|
||||
return objectOmit(attrs, [`onUpdate:${props.modelValueProp}`]);
|
||||
});
|
||||
|
||||
defineExpose({ toggleOpenState, open, close });
|
||||
</script>
|
||||
<template>
|
||||
<VbenPopover
|
||||
v-model:open="visible"
|
||||
:content-props="{ align: 'end', alignOffset: -11, sideOffset: 8 }"
|
||||
content-class="p-0 pt-3 w-full"
|
||||
trigger-class="w-full"
|
||||
>
|
||||
<template #trigger>
|
||||
<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"
|
||||
:[`onUpdate:${modelValueProp}`]="updateCurrentSelect"
|
||||
v-bind="getBindAttrs"
|
||||
>
|
||||
<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
|
||||
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">
|
||||
<div class="grid max-h-[360px] w-full grid-cols-6 justify-items-center">
|
||||
<VbenIconButton
|
||||
v-for="(item, index) in paginationList"
|
||||
:key="index"
|
||||
:tooltip="item"
|
||||
tooltip-side="top"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<VbenIcon
|
||||
:class="{
|
||||
'text-primary transition-all': currentSelect === item,
|
||||
}"
|
||||
:icon="item"
|
||||
/>
|
||||
</VbenIconButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="total >= pageSize"
|
||||
class="flex-center flex justify-end overflow-hidden border-t py-2 pr-3"
|
||||
>
|
||||
<Pagination
|
||||
:items-per-page="36"
|
||||
:sibling-count="1"
|
||||
:total="total"
|
||||
show-edges
|
||||
size="small"
|
||||
@update:page="handlePageChange"
|
||||
>
|
||||
<PaginationList
|
||||
v-slot="{ items }"
|
||||
class="flex w-full items-center gap-1"
|
||||
>
|
||||
<PaginationFirst class="size-5" />
|
||||
<PaginationPrev class="size-5" />
|
||||
<template v-for="(item, index) in items">
|
||||
<PaginationListItem
|
||||
v-if="item.type === 'page'"
|
||||
:key="index"
|
||||
:value="item.value"
|
||||
as-child
|
||||
>
|
||||
<Button
|
||||
:variant="item.value === currentPage ? 'default' : 'outline'"
|
||||
class="size-5 p-0 text-sm"
|
||||
>
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis
|
||||
v-else
|
||||
:key="item.type"
|
||||
:index="index"
|
||||
class="size-5"
|
||||
/>
|
||||
</template>
|
||||
<PaginationNext class="size-5" />
|
||||
<PaginationLast class="size-5" />
|
||||
</PaginationList>
|
||||
</Pagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex-col-center text-muted-foreground min-h-[150px] w-full">
|
||||
<EmptyIcon class="size-10" />
|
||||
<div class="mt-1 text-sm">{{ $t('common.noData') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</VbenPopover>
|
||||
</template>
|
||||
@@ -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];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as IconPicker } from './icon-picker.vue';
|
||||
Reference in New Issue
Block a user