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

@@ -50,7 +50,7 @@
"@better-scroll/core": "2.5.1",
"@elysiajs/eden": "^1.4.5",
"@iconify/vue": "5.0.0",
"@riwa/api-types": "http://192.168.1.27:9527/api/riwa-api-types-0.0.67.tgz",
"@riwa/api-types": "http://192.168.1.3:9527/api/riwa-api-types-0.0.67.tgz",
"@sa/axios": "workspace:*",
"@sa/color": "workspace:*",
"@sa/hooks": "workspace:*",
@@ -74,7 +74,10 @@
},
"devDependencies": {
"@elegant-router/vue": "0.3.8",
"@iconify-json/cryptocurrency-color": "^1.2.4",
"@iconify-json/material-symbols": "^1.2.50",
"@iconify/json": "2.2.414",
"@iconify/types": "^2.0.0",
"@sa/scripts": "workspace:*",
"@sa/uno-preset": "workspace:*",
"@soybeanjs/eslint-config": "1.7.4",

33
pnpm-lock.yaml generated
View File

@@ -18,8 +18,8 @@ importers:
specifier: 5.0.0
version: 5.0.0(vue@3.5.25(typescript@5.9.3))
'@riwa/api-types':
specifier: http://192.168.1.27:9527/api/riwa-api-types-0.0.67.tgz
version: http://192.168.1.27:9527/api/riwa-api-types-0.0.67.tgz(@elysiajs/eden@1.4.5(elysia@1.4.19(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))
specifier: http://192.168.1.3:9527/api/riwa-api-types-0.0.67.tgz
version: http://192.168.1.3:9527/api/riwa-api-types-0.0.67.tgz(@elysiajs/eden@1.4.5(elysia@1.4.19(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))
'@sa/axios':
specifier: workspace:*
version: link:packages/axios
@@ -84,9 +84,18 @@ importers:
'@elegant-router/vue':
specifier: 0.3.8
version: 0.3.8
'@iconify-json/cryptocurrency-color':
specifier: ^1.2.4
version: 1.2.4
'@iconify-json/material-symbols':
specifier: ^1.2.50
version: 1.2.50
'@iconify/json':
specifier: 2.2.414
version: 2.2.414
'@iconify/types':
specifier: ^2.0.0
version: 2.0.0
'@sa/scripts':
specifier: workspace:*
version: link:packages/scripts
@@ -892,6 +901,12 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iconify-json/cryptocurrency-color@1.2.4':
resolution: {integrity: sha512-8vjIfTAAMg0zo3/CdVWV7YjViY1L/q4TFfjROmqRPCRPhM6iVecW4TzMFS8hxm48S2Ge69SNM1yC8FmHT+jfHw==}
'@iconify-json/material-symbols@1.2.50':
resolution: {integrity: sha512-71tjHR70h46LHtBFab3fAd2V/wPTO7JMV5lKnRn3IcF303LaFgAlO0BZeTJDcmCv9d0snRZmnoLZAJVD7/eisw==}
'@iconify/json@2.2.414':
resolution: {integrity: sha512-7aYnEansnTQCuwK0HQjcxWWH/mzSDGbnRgtHnF3PNO0QXhylOpIPHuC+l2vBBIxC7CMrQd87U3UpI+1jfB5nag==}
@@ -1068,8 +1083,8 @@ packages:
'@quansync/fs@0.1.6':
resolution: {integrity: sha512-zoA8SqQO11qH9H8FCBR7NIbowYARIPmBz3nKjgAaOUDi/xPAAu1uAgebtV7KXHTc6CDZJVRZ1u4wIGvY5CWYaw==}
'@riwa/api-types@http://192.168.1.27:9527/api/riwa-api-types-0.0.67.tgz':
resolution: {tarball: http://192.168.1.27:9527/api/riwa-api-types-0.0.67.tgz}
'@riwa/api-types@http://192.168.1.3:9527/api/riwa-api-types-0.0.67.tgz':
resolution: {tarball: http://192.168.1.3:9527/api/riwa-api-types-0.0.67.tgz}
version: 0.0.67
peerDependencies:
'@elysiajs/eden': ^1.4.5
@@ -4896,6 +4911,14 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@iconify-json/cryptocurrency-color@1.2.4':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/material-symbols@1.2.50':
dependencies:
'@iconify/types': 2.0.0
'@iconify/json@2.2.414':
dependencies:
'@iconify/types': 2.0.0
@@ -5057,7 +5080,7 @@ snapshots:
dependencies:
quansync: 0.3.0
'@riwa/api-types@http://192.168.1.27:9527/api/riwa-api-types-0.0.67.tgz(@elysiajs/eden@1.4.5(elysia@1.4.19(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))':
'@riwa/api-types@http://192.168.1.3:9527/api/riwa-api-types-0.0.67.tgz(@elysiajs/eden@1.4.5(elysia@1.4.19(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))':
dependencies:
'@elysiajs/eden': 1.4.5(elysia@1.4.19(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3))

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>

View File

@@ -39,6 +39,16 @@ export const generatedRoutes: GeneratedRoute[] = [
hideInMenu: true
}
},
{
name: 'asset',
path: '/asset',
component: 'layout.base$view.asset',
meta: {
title: 'asset',
i18nKey: 'route.asset',
order: 5
}
},
{
name: 'deposit',
path: '/deposit',
@@ -234,15 +244,5 @@ export const generatedRoutes: GeneratedRoute[] = [
}
}
]
},
{
name: 'asset',
path: '/asset',
component: 'layout.base$view.asset',
meta: {
title: 'asset',
i18nKey: 'route.asset',
order: 5
}
},
}
];

View File

@@ -34,6 +34,7 @@ declare module 'vue' {
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
IconPicker: typeof import('./../components/custom/icon-picker.vue')['default']
IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
IconUilSearch: typeof import('~icons/uil/search')['default']
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
@@ -124,6 +125,7 @@ declare global {
const IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
const IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
const IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
const IconPicker: typeof import('./../components/custom/icon-picker.vue')['default']
const IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
const IconUilSearch: typeof import('~icons/uil/search')['default']
const LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']

21
src/typings/icon.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
/**
* 图标相关类型定义
*/
/** 图标集名称 */
export type IconCollection = 'material-symbols' | 'cryptocurrency-color';
/** 图标完整路径 */
export type IconPath = `${IconCollection}:${string}`;
/** 图标选择器配置 */
export interface IconPickerOptions {
/** 图标集前缀 */
prefix?: IconCollection;
/** 每页显示数量 */
pageSize?: number;
/** 弹窗宽度 */
width?: number;
/** 图标尺寸 */
iconSize?: string;
}

149
src/utils/icon-loader.ts Normal file
View File

@@ -0,0 +1,149 @@
import type { IconifyJSON } from '@iconify/types';
import materialSymbolsIcons from '@iconify-json/material-symbols/icons.json';
import cryptoCurrencyIcons from '@iconify-json/cryptocurrency-color/icons.json';
/**
* 图标集缓存
*/
interface IconCache {
/** 图标名称列表 */
icons: string[];
/** 图标别名映射 */
aliases: Record<string, string>;
/** 加载时间戳 */
timestamp: number;
}
const iconCacheMap = new Map<string, IconCache>();
/**
* 加载 Material Symbols 图标集
* @returns 图标名称数组
*/
export function loadMaterialSymbolsIcons(): string[] {
const collectionName = 'material-symbols';
// 返回缓存的图标
if (iconCacheMap.has(collectionName)) {
const cache = iconCacheMap.get(collectionName)!;
return cache.icons;
}
try {
const iconSet = materialSymbolsIcons as unknown as IconifyJSON;
// 获取所有图标名称
const icons = Object.keys(iconSet.icons || {});
// 处理别名
const aliases: Record<string, string> = {};
if (iconSet.aliases) {
Object.entries(iconSet.aliases).forEach(([alias, config]) => {
if (typeof config === 'object' && 'parent' in config) {
aliases[alias] = config.parent;
}
});
}
// 缓存结果
iconCacheMap.set(collectionName, {
icons,
aliases,
timestamp: Date.now()
});
return icons;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load material symbols icons:', error);
return [];
}
}
/**
* 加载 cryptocurrency-color 图标集
* @returns 图标名称数组
*/
export function loadCryptoCurrencyIcons(): string[] {
const collectionName = 'cryptocurrency-color';
// 返回缓存的图标
if (iconCacheMap.has(collectionName)) {
const cache = iconCacheMap.get(collectionName)!;
return cache.icons;
}
try {
const iconSet = cryptoCurrencyIcons as IconifyJSON;
// 获取所有图标名称
const icons = Object.keys(iconSet.icons || {});
// 缓存结果
iconCacheMap.set(collectionName, {
icons,
aliases: {},
timestamp: Date.now()
});
return icons;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load material icon theme icons:', error);
return [];
}
}
/**
* 通用图标集加载器
* @param collectionName 图标集名称,如 'material-symbols'
* @returns 图标名称数组
*/
export function loadIconCollection(collectionName: string): string[] {
// 根据图标集名称调用对应的加载函数
switch (collectionName) {
case 'material-symbols':
return loadMaterialSymbolsIcons();
case 'cryptocurrency-color':
return loadCryptoCurrencyIcons();
default:
// eslint-disable-next-line no-console
console.error(`Unknown icon collection: ${collectionName}`);
return [];
}
}
/**
* 搜索图标
* @param icons 图标列表
* @param keyword 搜索关键词
* @returns 匹配的图标列表
*/
export function searchIcons(icons: string[], keyword: string): string[] {
if (!keyword.trim()) return icons;
const lowerKeyword = keyword.toLowerCase();
return icons.filter(icon => icon.toLowerCase().includes(lowerKeyword));
}
/**
* 清除图标缓存
* @param collectionName 可选,指定清除的图标集,不传则清除所有
*/
export function clearIconCache(collectionName?: string): void {
if (collectionName) {
iconCacheMap.delete(collectionName);
} else {
iconCacheMap.clear();
}
}
/**
* 获取图标完整路径
* @param collection 图标集名称
* @param iconName 图标名称
* @returns 完整图标路径,如 'material-symbols:home'
*/
export function getIconPath(collection: string, iconName: string): string {
return `${collection}:${iconName}`;
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useAppStore } from '@/store/modules/app';
import IconPicker from '@/components/custom/icon-picker.vue';
import HeaderBanner from './modules/header-banner.vue';
import CardData from './modules/card-data.vue';
import LineChart from './modules/line-chart.vue';
@@ -11,10 +12,26 @@ import CreativityBanner from './modules/creativity-banner.vue';
const appStore = useAppStore();
const gap = computed(() => (appStore.isMobile ? 0 : 16));
const selectedIcon = ref('');
</script>
<template>
<NSpace vertical :size="16">
<!-- Icon Picker 测试区域 -->
<NCard title="Icon Picker 测试" :bordered="false" class="card-wrapper">
<NSpace vertical :size="12">
<div class="flex items-center gap-3">
<span class="text-sm">选择图标</span>
<IconPicker v-model="selectedIcon" />
</div>
<div v-if="selectedIcon" class="flex items-center gap-3 rounded bg-gray-100 p-3 dark:bg-gray-800">
<SvgIcon :icon="selectedIcon" class="text-2xl" />
<code class="text-sm text-primary">{{ selectedIcon }}</code>
</div>
<div v-else class="text-sm text-gray-400">未选择图标</div>
</NSpace>
</NCard>
<NAlert :title="$t('common.tip')" type="warning">
{{ $t('page.home.branchDesc') }}
</NAlert>

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { ref } from 'vue';
import { NButton, NCard, NDescriptions, NDescriptionsItem, NInput, NSpace } from 'naive-ui';
import IconPicker from '@/components/custom/icon-picker.vue';
defineOptions({
name: 'IconPickerExample'
});
// 基础使用
const selectedIcon1 = ref('');
// 默认值
const selectedIcon2 = ref('material-symbols:home');
// 自定义配置
const selectedIcon3 = ref('');
// 单个图标集
const selectedIcon4 = ref('material-icon-theme:folder-aws');
// 表单集成示例
interface FormData {
name: string;
icon: string;
description: string;
}
const formData = ref<FormData>({
name: '',
icon: '',
description: ''
});
function handleSubmit() {
// eslint-disable-next-line no-console
console.log('表单数据:', formData.value);
window.$message?.success(`提交成功!图标:${formData.value.icon}`);
}
function handleReset() {
formData.value = {
name: '',
icon: '',
description: ''
};
}
</script>
<template>
<div class="p-4">
<NSpace vertical :size="24">
<!-- 标题 -->
<div>
<h1 class="mb-2 text-2xl font-bold">Icon Picker 图标选择器</h1>
<p class="text-gray-600 dark:text-gray-400">支持多个图标集切换实时搜索和分页</p>
</div>
<!-- 基础使用 - 多图标集切换 -->
<NCard title="多图标集切换" :bordered="false" class="rounded-lg shadow">
<NSpace vertical :size="16">
<div>
<p class="mb-3 text-sm text-gray-600">通过 Tabs 在不同的图标集之间切换</p>
<IconPicker v-model="selectedIcon1" />
</div>
<NDescriptions :column="1" size="small" bordered>
<NDescriptionsItem label="选中的图标">
<code class="text-primary">{{ selectedIcon1 || '未选择' }}</code>
</NDescriptionsItem>
</NDescriptions>
</NSpace>
</NCard>
<!-- 带默认值 -->
<NCard title="带默认值" :bordered="false" class="rounded-lg shadow">
<NSpace vertical :size="16">
<div>
<p class="mb-3 text-sm text-gray-600">设置默认图标值</p>
<IconPicker v-model="selectedIcon2" />
</div>
<NDescriptions :column="1" size="small" bordered>
<NDescriptionsItem label="选中的图标">
<code class="text-primary">{{ selectedIcon2 }}</code>
</NDescriptionsItem>
</NDescriptions>
</NSpace>
</NCard>
<!-- 自定义配置 -->
<NCard title="自定义配置" :bordered="false" class="rounded-lg shadow">
<NSpace vertical :size="16">
<div>
<p class="mb-3 text-sm text-gray-600">自定义弹窗宽度每页数量和图标尺寸</p>
<IconPicker v-model="selectedIcon3" :width="700" :page-size="80" icon-size="28px" />
</div>
<NDescriptions :column="1" size="small" bordered>
<NDescriptionsItem label="选中的图标">
<code class="text-primary">{{ selectedIcon3 || '未选择' }}</code>
</NDescriptionsItem>
</NDescriptions>
</NSpace>
</NCard>
<!-- 单个图标集 -->
<NCard title="单个图标集" :bordered="false" class="rounded-lg shadow">
<NSpace vertical :size="16">
<div>
<p class="mb-3 text-sm text-gray-600">只显示 Cryptocurrency Color 图标集</p>
<IconPicker v-model="selectedIcon4" prefix="cryptocurrency-color" :collections="['cryptocurrency-color']" />
</div>
<NDescriptions :column="1" size="small" bordered>
<NDescriptionsItem label="选中的图标">
<code class="text-primary">{{ selectedIcon4 }}</code>
</NDescriptionsItem>
</NDescriptions>
</NSpace>
</NCard>
<!-- 表单集成 -->
<NCard title="表单集成示例" :bordered="false" class="rounded-lg shadow">
<NSpace vertical :size="16">
<div class="grid grid-cols-1 gap-4">
<div>
<label class="mb-2 block text-sm font-medium">名称</label>
<NInput v-model:value="formData.name" placeholder="请输入名称" />
</div>
<div>
<label class="mb-2 block text-sm font-medium">图标</label>
<IconPicker v-model="formData.icon" />
</div>
<div>
<label class="mb-2 block text-sm font-medium">描述</label>
<NInput v-model:value="formData.description" type="textarea" placeholder="请输入描述" :rows="3" />
</div>
</div>
<NSpace>
<NButton type="primary" @click="handleSubmit">提交</NButton>
<NButton @click="handleReset">重置</NButton>
</NSpace>
<NDescriptions :column="1" size="small" bordered>
<NDescriptionsItem label="表单数据">
<code class="text-primary">{{ JSON.stringify(formData, null, 2) }}</code>
</NDescriptionsItem>
</NDescriptions>
</NSpace>
</NCard>
<!-- API 文档 -->
<NCard title="Props API" :bordered="false" class="rounded-lg shadow">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-100 dark:bg-gray-800">
<tr>
<th class="px-4 py-2 text-left">属性</th>
<th class="px-4 py-2 text-left">类型</th>
<th class="px-4 py-2 text-left">默认值</th>
<th class="px-4 py-2 text-left">说明</th>
</tr>
</thead>
<tbody>
<tr class="border-t dark:border-gray-700">
<td class="px-4 py-2"><code>modelValue</code></td>
<td class="px-4 py-2"><code>string</code></td>
<td class="px-4 py-2"><code>''</code></td>
<td class="px-4 py-2">选中的图标值v-model</td>
</tr>
<tr class="border-t dark:border-gray-700">
<td class="px-4 py-2"><code>prefix</code></td>
<td class="px-4 py-2"><code>IconCollection</code></td>
<td class="px-4 py-2"><code>'material-symbols'</code></td>
<td class="px-4 py-2">默认图标集前缀</td>
</tr>
<tr class="border-t dark:border-gray-700">
<td class="px-4 py-2"><code>collections</code></td>
<td class="px-4 py-2"><code>IconCollection[]</code></td>
<td class="px-4 py-2"><code>['material-symbols', 'material-icon-theme']</code></td>
<td class="px-4 py-2">可选的图标集列表</td>
</tr>
<tr class="border-t dark:border-gray-700">
<td class="px-4 py-2"><code>pageSize</code></td>
<td class="px-4 py-2"><code>number</code></td>
<td class="px-4 py-2"><code>60</code></td>
<td class="px-4 py-2">每页显示的图标数量</td>
</tr>
<tr class="border-t dark:border-gray-700">
<td class="px-4 py-2"><code>width</code></td>
<td class="px-4 py-2"><code>number</code></td>
<td class="px-4 py-2"><code>600</code></td>
<td class="px-4 py-2">弹窗宽度px</td>
</tr>
<tr class="border-t dark:border-gray-700">
<td class="px-4 py-2"><code>iconSize</code></td>
<td class="px-4 py-2"><code>string</code></td>
<td class="px-4 py-2"><code>'24px'</code></td>
<td class="px-4 py-2">图标显示尺寸</td>
</tr>
</tbody>
</table>
</div>
</NCard>
<!-- 功能特性 -->
<NCard title="功能特性" :bordered="false" class="rounded-lg shadow">
<ul class="list-disc list-inside text-sm space-y-2">
<li> 支持多个图标集切换Tabs</li>
<li> Material Symbols3000+ 图标</li>
<li> Material Icon ThemeVSCode 文件图标</li>
<li> 支持实时搜索过滤</li>
<li> 分页展示避免一次性渲染大量图标</li>
<li> 缓存机制避免重复加载</li>
<li> 响应式设计支持深色模式</li>
<li> TypeScript 类型支持</li>
<li> 支持清除选择</li>
<li> 图标预览和名称提示</li>
</ul>
</NCard>
</NSpace>
</div>
</template>
<style scoped>
code {
@apply rounded bg-gray-100 px-1.5 py-0.5 text-xs font-mono dark:bg-gray-800;
}
table {
@apply border-collapse;
}
th {
@apply font-semibold;
}
</style>