feat: 新增图标选择器组件及相关功能;更新路由配置和类型定义;修复依赖地址

This commit is contained in:
2025-12-30 17:41:51 +07:00
parent 17f8082240
commit 2eab7c5365
9 changed files with 696 additions and 18 deletions

View 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>