IconPicker 图标选择组件(下)
本节优化内容
- InputNumber 替换为 Slider 滑块组件
- Dialog 样式优化(去除多余内边距、添加标题)
- IconList 支持
v-model双向绑定(默认选中) - 组件目录按功能重新组织
Dialog 样式优化
去除内边距
Element Plus 的 Dialog 默认 body 区域有较大的 padding,需要通过 :deep() 深度选择器覆盖:
<!-- src/components/Icons/IconPicker.vue -->
<template>
<div class="icon-picker">
<ElDialog v-model="show" :title="title" :width="width">
<!-- ... -->
</ElDialog>
</div>
</template>
<style scoped>
.icon-picker :deep(.el-dialog__body) {
padding-top: 0;
padding-bottom: 0;
max-height: 65vh;
overflow-y: auto;
}
</style>
vue
添加标题
通过 Props 传入 Dialog 标题,避免关闭按钮与内容重叠:
interface IconPickerProps {
title?: string
width?: string
}
// 使用
const props = withDefaults(defineProps<IconPickerProps>(), {
title: '选择图标',
width: '50%',
})
typescript
设置区改造:Slider 替换 InputNumber
将 InputNumber 替换为 ElSlider,操作更直观:
<template>
<div class="flex items-center gap-4 p-2">
<!-- 颜色选择 -->
<div class="flex items-center gap-2 mr-4">
<span class="text-sm text-gray-600 whitespace-nowrap">Color</span>
<ElColorPicker v-model="color" size="small" />
</div>
<!-- 字号滑块 -->
<div class="flex items-center gap-2 flex-1">
<span class="text-sm text-gray-600 whitespace-nowrap">FontSize</span>
<ElSlider
v-model="fontSize"
:min="12"
:max="64"
:step="1"
:show-input="true"
class="flex-1"
/>
</div>
</div>
</template>
vue
InputNumber vs Slider 对比
| 特性 | ElInputNumber | ElSlider |
|---|---|---|
| 操作方式 | 点击加减 / 手动输入 | 拖拽滑块 |
| 直观性 | 需要精确输入 | 可视化拖拽 |
| 占用空间 | 较小 | 需要横向空间 |
| 适用场景 | 需要精确数值 | 快速调整范围值 |
| show-input | 无 | 支持,滑块旁显示输入框 |
defineModel 双向绑定
Vue 3.3+ 引入 defineModel,简化 v-model 的实现。
IconList 支持 v-model
<!-- src/components/Icons/IconList.vue -->
<script setup lang="ts">
import { ref, onBeforeMount } from 'vue'
import { Icon, loadIcons } from '@iconify/vue'
// v-model 双向绑定
const modelValue = defineModel<string>({ default: '' })
// 其他 props...
const chosen = ref(-1)
function handleClick(name: string, index: number) {
chosen.value = index
const fullName = `${props.collection}:${name}`
modelValue.value = fullName
emit('click', fullName, index)
}
</script>
<template>
<ul class="grid border-l border-t border-gray-200"
:style="{ gridTemplateColumns: 'repeat(auto-fill, minmax(1.825rem, 1fr))' }"
>
<li
v-for="(name, index) in iconData"
:key="index"
class="flex items-center justify-center border-r border-b border-gray-200
cursor-pointer hover:bg-sky-50 transition-colors py-2"
:class="[
itemClass,
// 优先使用 modelValue 匹配,否则用 chosen index
modelValue && modelValue === `${collection}:${name}`
? activeClass
: (!modelValue && chosen === index ? activeClass : ''),
]"
@click="handleClick(name, index)"
>
<Icon :icon="`${collection}:${name}`" :class="iconClass" />
</li>
</ul>
</template>
vue
选中条件逻辑
// 两种选中模式的判断
const isActive = computed(() => {
if (modelValue.value) {
// 有 v-model 时,按图标名匹配
return modelValue.value === `${props.collection}:${name}`
}
// 无 v-model 时,按点击索引匹配
return chosen.value === index
})
typescript
父组件使用 v-model
<!-- src/pages/components/icons/ep-iconpicker.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import IconPicker from '@/components/Icons/IconPicker.vue'
const test = ref('ep:apple') // 默认选中 apple 图标
</script>
<template>
<div class="p-6">
<h2 class="text-2xl font-bold mb-6">IconPicker</h2>
<!-- v-model 绑定,默认选中 ep:apple -->
<IconPicker v-model="test" @submit="handleSubmit" />
<!-- 显示当前选中值 -->
<div class="mt-4 flex items-center gap-2">
<span class="text-gray-500">当前选中:</span>
<code class="bg-gray-100 px-2 py-1 rounded">{{ test }}</code>
</div>
</div>
</template>
vue
defineModel 详解
Vue 3.3 前后对比
// === Vue 3.3 之前的写法 ===
// 子组件
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
function update(val: string) {
emit('update:modelValue', val)
}
// === Vue 3.3+ defineModel 写法 ===
const modelValue = defineModel<string>({ default: '' })
function update(val: string) {
modelValue.value = val // 直接赋值,自动触发 emit
}
typescript
defineModel 返回的是一个 ref,对其赋值会自动触发 update:modelValue 事件,无需手动 emit。
多个 v-model
// 支持命名 model
const icon = defineModel<string>('icon', { default: '' })
const color = defineModel<string>('color', { default: '#409EFF' })
const size = defineModel<number>('size', { default: 16 })
typescript
<!-- 父组件 -->
<IconPicker
v-model:icon="selectedIcon"
v-model:color="selectedColor"
v-model:size="selectedSize"
/>
vue
组件目录重组
问题
随着组件增多,src/components/ 下文件越来越多,不便管理。
方案
按功能划分子目录:
src/components/
├── Icons/
│ ├── IconList.vue
│ ├── IconPicker.vue
│ ├── NetIcon.vue
│ ├── SvgIcon.vue
│ └── types.ts
├── Others/
│ └── RemotePrompt.vue
└── ...
text
Vite 配置调整
// vite.config.ts
export default defineConfig({
components: {
resolvers: [
// ...
],
dirs: ['src/components'],
// 目录名作为命名空间前缀
// true: 组件名为 IconsIconList
// false: 组件名为 IconList(忽略目录)
directoryAsNamespace: false,
},
})
typescript
directoryAsNamespace 值 | 组件引用名 | 说明 |
|---|---|---|
true | <IconsIconList /> | 目录名作为前缀 |
false | <IconList /> | 忽略目录层级,按文件名 |
'only' | 按需前缀 | 仅嵌套目录加前缀 |
设置为 false 后,组件按文件名直接引用,不受目录层级影响,方便后续重组目录结构。
完整 IconPicker 最终版
<!-- src/components/Icons/IconPicker.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { Icon } from '@iconify/vue'
import { useToggle } from '@vueuse/core'
import {
ElButton,
ElDialog,
ElColorPicker,
ElSlider,
} from 'element-plus'
import IconList from './IconList.vue'
import type { IconPickerSubmitData } from './types'
interface IconPickerProps {
/** Dialog 标题 */
title?: string
/** Dialog 宽度 */
width?: string
/** 按钮文字 */
buttonText?: string
}
const props = withDefaults(defineProps<IconPickerProps>(), {
title: '选择图标',
width: '50%',
buttonText: '选择图标',
})
const emit = defineEmits<{
submit: [data: IconPickerSubmitData]
cancel: []
}>()
const [show, toggle] = useToggle(false)
const iconRef = ref('')
const color = ref('#409EFF')
const fontSize = ref(16)
function handleClick(icon: string) {
iconRef.value = icon
}
function handleConfirm() {
toggle(false)
emit('submit', {
icon: iconRef.value,
color: color.value,
fontSize: fontSize.value,
})
}
function handleCancel() {
toggle(false)
emit('cancel')
}
</script>
<template>
<div class="icon-picker">
<ElButton @click="toggle(true)">
<slot>{{ buttonText }}</slot>
</ElButton>
<ElDialog v-model="show" :title="title" :width="width">
<!-- 设置区 -->
<div class="flex items-center gap-4 p-2">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">Color</span>
<ElColorPicker v-model="color" size="small" />
</div>
<div class="flex items-center gap-2 flex-1">
<span class="text-sm text-gray-600">FontSize</span>
<ElSlider
v-model="fontSize"
:min="12"
:max="64"
:step="1"
:show-input="true"
/>
</div>
</div>
<!-- 预览 + 列表 -->
<div class="flex gap-4">
<div class="flex-shrink-0 w-20 flex items-center justify-center border rounded">
<Icon
v-if="iconRef"
:icon="iconRef"
:style="{ color, fontSize: fontSize + 'px' }"
/>
<span v-else class="text-xs text-gray-400">请选择</span>
</div>
<div class="flex-1 max-h-[400px] overflow-y-auto">
<IconList
:show-text="false"
icon-class="text-xl"
active-class="text-sky-500"
@click="handleClick"
/>
</div>
</div>
<template #footer>
<ElButton @click="handleCancel">取消</ElButton>
<ElButton type="primary" @click="handleConfirm">确定</ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.icon-picker :deep(.el-dialog__body) {
padding-top: 0;
padding-bottom: 0;
max-height: 65vh;
overflow-y: auto;
}
</style>
vue
↑