feat: 新增图标选择器组件及相关功能;更新路由配置和类型定义;修复依赖地址
This commit is contained in:
226
src/components/custom/icon-picker.vue
Normal file
226
src/components/custom/icon-picker.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { NButton, NEmpty, NInput, NPopover, NScrollbar, NSpin, NTabPane, NTabs } from 'naive-ui';
|
||||
import { getIconPath, loadIconCollection, searchIcons } from '@/utils/icon-loader';
|
||||
import type { IconCollection } from '@/typings/icon';
|
||||
|
||||
defineOptions({
|
||||
name: 'IconPicker'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
/** 当前选中的图标 */
|
||||
modelValue?: string;
|
||||
/** 图标集前缀 */
|
||||
prefix?: IconCollection;
|
||||
/** 每页显示数量 */
|
||||
pageSize?: number;
|
||||
/** 弹窗宽度 */
|
||||
width?: number;
|
||||
/** 图标尺寸 */
|
||||
iconSize?: string;
|
||||
/** 可选的图标集列表 */
|
||||
collections?: IconCollection[];
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
prefix: 'material-symbols',
|
||||
pageSize: 60,
|
||||
width: 600,
|
||||
iconSize: '24px',
|
||||
collections: () => ['material-symbols', 'cryptocurrency-color']
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const showPopover = ref(false);
|
||||
const searchValue = ref('');
|
||||
const loading = ref(false);
|
||||
const allIcons = ref<string[]>([]);
|
||||
const currentPage = ref(1);
|
||||
const activeCollection = ref<IconCollection>(props.prefix);
|
||||
|
||||
// 图标集显示名称映射
|
||||
const collectionLabels: Record<IconCollection, string> = {
|
||||
'material-symbols': 'Material Symbols',
|
||||
'cryptocurrency-color': 'Cryptocurrency Color'
|
||||
};
|
||||
|
||||
// 过滤图标
|
||||
const filteredIcons = computed(() => {
|
||||
return searchIcons(allIcons.value, searchValue.value);
|
||||
});
|
||||
|
||||
// 分页计算
|
||||
const pageCount = computed(() => {
|
||||
return Math.ceil(filteredIcons.value.length / props.pageSize);
|
||||
});
|
||||
|
||||
const currentPageIcons = computed(() => {
|
||||
const start = (currentPage.value - 1) * props.pageSize;
|
||||
const end = start + props.pageSize;
|
||||
return filteredIcons.value.slice(start, end);
|
||||
});
|
||||
|
||||
// 监听搜索值变化,重置到第一页
|
||||
watch(searchValue, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
|
||||
// 监听图标集切换
|
||||
watch(activeCollection, () => {
|
||||
searchValue.value = '';
|
||||
currentPage.value = 1;
|
||||
loadIcons();
|
||||
});
|
||||
|
||||
// 加载图标集
|
||||
function loadIcons() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const icons = loadIconCollection(activeCollection.value);
|
||||
allIcons.value = icons;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load icons:', error);
|
||||
window.$message?.error('图标加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 选择图标
|
||||
function selectIcon(icon: string) {
|
||||
const fullIcon = getIconPath(activeCollection.value, icon);
|
||||
emit('update:modelValue', fullIcon);
|
||||
showPopover.value = false;
|
||||
}
|
||||
|
||||
// 清除选择
|
||||
function clearSelection() {
|
||||
emit('update:modelValue', '');
|
||||
}
|
||||
|
||||
// 弹窗打开时加载图标
|
||||
function handlePopoverShow(show: boolean) {
|
||||
if (show && allIcons.value.length === 0) {
|
||||
loadIcons();
|
||||
}
|
||||
}
|
||||
|
||||
// 上一页
|
||||
function prevPage() {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 下一页
|
||||
function nextPage() {
|
||||
if (currentPage.value < pageCount.value) {
|
||||
currentPage.value += 1;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 如果需要预加载,可以在这里调用
|
||||
loadIcons();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NPopover
|
||||
v-model:show="showPopover"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
:width="width"
|
||||
@update:show="handlePopoverShow"
|
||||
>
|
||||
<template #trigger>
|
||||
<div
|
||||
class="inline-flex cursor-pointer items-center gap-2 border border-gray-300 rounded px-3 py-2 transition-colors dark:border-gray-700 hover:border-primary"
|
||||
>
|
||||
<template v-if="modelValue">
|
||||
<SvgIcon :icon="modelValue" :style="{ fontSize: iconSize }" />
|
||||
<span class="text-sm">{{ modelValue.split(':')[1] || modelValue }}</span>
|
||||
<span class="ml-2 text-gray-400 transition-colors hover:text-red-500" @click.stop="clearSelection">✕</span>
|
||||
</template>
|
||||
<span v-else class="text-gray-400">选择图标</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-2">
|
||||
<!-- Tabs 切换图标集 -->
|
||||
<div v-if="collections.length > 1" class="px-3 pb-3">
|
||||
<NTabs v-model:value="activeCollection" type="line" size="small" animated>
|
||||
<NTabPane
|
||||
v-for="collection in collections"
|
||||
:key="collection"
|
||||
:name="collection"
|
||||
:tab="collectionLabels[collection]"
|
||||
/>
|
||||
</NTabs>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="px-3 pb-3">
|
||||
<NInput v-model:value="searchValue" placeholder="搜索图标名称..." clearable>
|
||||
<template #prefix>
|
||||
<SvgIcon icon="material-symbols:search" class="text-lg" />
|
||||
</template>
|
||||
</NInput>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="px-3 pb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
共 {{ filteredIcons.length }} 个图标
|
||||
<template v-if="searchValue">,第 {{ currentPage }}/{{ pageCount }} 页</template>
|
||||
</div>
|
||||
|
||||
<!-- 图标列表 -->
|
||||
<NSpin :show="loading">
|
||||
<NScrollbar class="max-h-360px">
|
||||
<div v-if="currentPageIcons.length" class="grid grid-cols-8 gap-2 px-3">
|
||||
<div
|
||||
v-for="icon in currentPageIcons"
|
||||
:key="icon"
|
||||
class="group flex flex-col cursor-pointer items-center justify-center rounded p-2 transition-all hover:bg-primary/10"
|
||||
:class="{
|
||||
'bg-primary/20 ring-2 ring-primary': modelValue === getIconPath(activeCollection, icon)
|
||||
}"
|
||||
:title="icon"
|
||||
@click="selectIcon(icon)"
|
||||
>
|
||||
<SvgIcon :icon="getIconPath(activeCollection, icon)" :style="{ fontSize: iconSize }" class="mb-1" />
|
||||
<span
|
||||
class="w-full truncate text-center text-xs text-gray-600 dark:text-gray-400 group-hover:text-primary"
|
||||
>
|
||||
{{ icon.length > 8 ? icon.slice(0, 8) + '...' : icon }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<NEmpty v-else description="未找到匹配的图标" class="py-8" />
|
||||
</NScrollbar>
|
||||
</NSpin>
|
||||
|
||||
<!-- 分页控制 -->
|
||||
<div
|
||||
v-if="pageCount > 1"
|
||||
class="flex items-center justify-between border-t border-gray-200 px-3 pt-3 dark:border-gray-700"
|
||||
>
|
||||
<NButton size="small" :disabled="currentPage === 1" @click="prevPage">上一页</NButton>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ currentPage }} / {{ pageCount }}</span>
|
||||
<NButton size="small" :disabled="currentPage === pageCount" @click="nextPage">下一页</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NPopover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加自定义样式 */
|
||||
</style>
|
||||
Reference in New Issue
Block a user