353 lines
9.1 KiB
Vue
353 lines
9.1 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="antd-icon-picker">
|
|||
|
|
<a-input-group compact class="antd-icon-picker-row">
|
|||
|
|
<a-input
|
|||
|
|
v-model:value="iconName"
|
|||
|
|
:placeholder="placeholder"
|
|||
|
|
:maxlength="maxlength"
|
|||
|
|
allow-clear
|
|||
|
|
class="antd-icon-picker-input"
|
|||
|
|
>
|
|||
|
|
<template v-if="iconName" #prefix>
|
|||
|
|
<span class="antd-icon-picker-prefix">
|
|||
|
|
<component v-if="previewRender" :is="previewRender" />
|
|||
|
|
</span>
|
|||
|
|
</template>
|
|||
|
|
</a-input>
|
|||
|
|
<a-popover
|
|||
|
|
v-model:open="pickerOpen"
|
|||
|
|
trigger="click"
|
|||
|
|
placement="bottomLeft"
|
|||
|
|
:overlay-style="{ width: '400px' }"
|
|||
|
|
>
|
|||
|
|
<template #content>
|
|||
|
|
<a-form-item-rest>
|
|||
|
|
<a-input-search
|
|||
|
|
v-model:value="keyword"
|
|||
|
|
placeholder="搜索图标名称"
|
|||
|
|
allow-clear
|
|||
|
|
class="mb-2"
|
|||
|
|
/>
|
|||
|
|
<a-tabs v-model:active-key="iconStyleTab" size="small" class="antd-icon-picker-tabs mb-2">
|
|||
|
|
<a-tab-pane key="outlined" tab="线条" />
|
|||
|
|
<a-tab-pane key="filled" tab="实底" />
|
|||
|
|
<a-tab-pane key="twoTone" tab="双色" />
|
|||
|
|
</a-tabs>
|
|||
|
|
<!-- 虚拟列表:只挂载可视区域内的行,避免一次性渲染上千个图标组件 -->
|
|||
|
|
<div
|
|||
|
|
v-if="filteredNames.length > 0"
|
|||
|
|
ref="scrollRef"
|
|||
|
|
class="antd-icon-picker-grid"
|
|||
|
|
@scroll.passive="onGridScroll"
|
|||
|
|
>
|
|||
|
|
<div class="antd-icon-picker-grid-spacer" :style="{ height: `${totalScrollHeight}px` }">
|
|||
|
|
<div
|
|||
|
|
class="antd-icon-picker-grid-inner"
|
|||
|
|
:style="{ transform: `translateY(${virtualOffsetY}px)` }"
|
|||
|
|
>
|
|||
|
|
<div
|
|||
|
|
v-for="(row, rowIdx) in visibleRows"
|
|||
|
|
:key="virtualStartRow + rowIdx"
|
|||
|
|
class="antd-icon-picker-grid-row"
|
|||
|
|
>
|
|||
|
|
<button
|
|||
|
|
v-for="name in row"
|
|||
|
|
:key="name"
|
|||
|
|
type="button"
|
|||
|
|
:title="name"
|
|||
|
|
class="antd-icon-picker-cell"
|
|||
|
|
:class="{ 'is-active': iconName === name }"
|
|||
|
|
@click="selectIcon(name)"
|
|||
|
|
>
|
|||
|
|
<component :is="getIconRenderCached(name)" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="text-center text-gray-400 py-4 text-sm">无匹配图标</div>
|
|||
|
|
</a-form-item-rest>
|
|||
|
|
</template>
|
|||
|
|
<a-button type="default" class="antd-icon-picker-trigger">
|
|||
|
|
<template #icon><AppstoreOutlined /></template>
|
|||
|
|
选择
|
|||
|
|
</a-button>
|
|||
|
|
</a-popover>
|
|||
|
|
</a-input-group>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { computed, h, nextTick, ref, watch } from 'vue'
|
|||
|
|
import type { VNode } from 'vue'
|
|||
|
|
import * as Icons from '@ant-design/icons-vue'
|
|||
|
|
import { AppstoreOutlined } from '@ant-design/icons-vue'
|
|||
|
|
|
|||
|
|
withDefaults(
|
|||
|
|
defineProps<{
|
|||
|
|
placeholder?: string
|
|||
|
|
maxlength?: number
|
|||
|
|
}>(),
|
|||
|
|
{
|
|||
|
|
placeholder: '请输入或点击选择图标(Ant Design Icons)',
|
|||
|
|
maxlength: 50,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const iconName = defineModel<string>('value', { default: '' })
|
|||
|
|
|
|||
|
|
const keyword = ref('')
|
|||
|
|
const pickerOpen = ref(false)
|
|||
|
|
const scrollRef = ref<HTMLElement | null>(null)
|
|||
|
|
const scrollTop = ref(0)
|
|||
|
|
|
|||
|
|
/** 每行高度(与样式 .antd-icon-picker-grid-row 一致) */
|
|||
|
|
const ROW_HEIGHT = 44
|
|||
|
|
const COLS = 8
|
|||
|
|
const BUFFER_ROWS = 3
|
|||
|
|
const GRID_MAX_HEIGHT = 280
|
|||
|
|
|
|||
|
|
const EXCLUDED_ICON_MODULE_KEYS = new Set([
|
|||
|
|
'default',
|
|||
|
|
'createFromIconfontCN',
|
|||
|
|
'Icon',
|
|||
|
|
'getTwoToneColor',
|
|||
|
|
'setTwoToneColor',
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
function isVueIconExport(val: unknown): boolean {
|
|||
|
|
if (val === null || val === undefined) return false
|
|||
|
|
const t = typeof val
|
|||
|
|
return t === 'function' || t === 'object'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildNamesBySuffix(suffix: 'Outlined' | 'Filled' | 'TwoTone'): string[] {
|
|||
|
|
return Object.keys(Icons)
|
|||
|
|
.filter((k) => {
|
|||
|
|
if (EXCLUDED_ICON_MODULE_KEYS.has(k)) return false
|
|||
|
|
if (!k.endsWith(suffix)) return false
|
|||
|
|
return isVueIconExport((Icons as Record<string, unknown>)[k])
|
|||
|
|
})
|
|||
|
|
.sort((a, b) => a.localeCompare(b))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 线条 / 实底 / 双色 三套列表,分页切换减少单次渲染量 */
|
|||
|
|
const OUTLINED_ICON_NAMES = buildNamesBySuffix('Outlined')
|
|||
|
|
const FILLED_ICON_NAMES = buildNamesBySuffix('Filled')
|
|||
|
|
const TWO_TONE_ICON_NAMES = buildNamesBySuffix('TwoTone')
|
|||
|
|
|
|||
|
|
type IconStyleTab = 'outlined' | 'filled' | 'twoTone'
|
|||
|
|
|
|||
|
|
const iconStyleTab = ref<IconStyleTab>('outlined')
|
|||
|
|
|
|||
|
|
const namesByTab = computed(() => {
|
|||
|
|
switch (iconStyleTab.value) {
|
|||
|
|
case 'filled':
|
|||
|
|
return FILLED_ICON_NAMES
|
|||
|
|
case 'twoTone':
|
|||
|
|
return TWO_TONE_ICON_NAMES
|
|||
|
|
default:
|
|||
|
|
return OUTLINED_ICON_NAMES
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const filteredNames = computed(() => {
|
|||
|
|
const q = keyword.value.trim().toLowerCase()
|
|||
|
|
const list = namesByTab.value
|
|||
|
|
if (!q) return list
|
|||
|
|
return list.filter((n) => n.toLowerCase().includes(q))
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const totalRows = computed(() => Math.ceil(filteredNames.value.length / COLS))
|
|||
|
|
|
|||
|
|
const totalScrollHeight = computed(() => Math.max(0, totalRows.value * ROW_HEIGHT))
|
|||
|
|
|
|||
|
|
const virtualStartRow = computed(() => {
|
|||
|
|
const tr = totalRows.value
|
|||
|
|
if (tr === 0) return 0
|
|||
|
|
const top = scrollTop.value
|
|||
|
|
const start = Math.max(0, Math.floor(top / ROW_HEIGHT) - BUFFER_ROWS)
|
|||
|
|
const maxStart = Math.max(0, tr - 1)
|
|||
|
|
return Math.min(start, maxStart)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const virtualEndRow = computed(() => {
|
|||
|
|
const tr = totalRows.value
|
|||
|
|
if (tr === 0) return 0
|
|||
|
|
const h = GRID_MAX_HEIGHT
|
|||
|
|
const top = scrollTop.value
|
|||
|
|
const end = Math.ceil((top + h) / ROW_HEIGHT) + BUFFER_ROWS
|
|||
|
|
return Math.min(tr, Math.max(0, end))
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/** 当前窗口内要渲染的行(每行最多 COLS 个名称) */
|
|||
|
|
const visibleRows = computed(() => {
|
|||
|
|
const list = filteredNames.value
|
|||
|
|
const start = virtualStartRow.value
|
|||
|
|
const end = virtualEndRow.value
|
|||
|
|
const rows: string[][] = []
|
|||
|
|
for (let r = start; r < end; r++) {
|
|||
|
|
const row: string[] = []
|
|||
|
|
for (let c = 0; c < COLS; c++) {
|
|||
|
|
const i = r * COLS + c
|
|||
|
|
if (i < list.length) row.push(list[i]!)
|
|||
|
|
}
|
|||
|
|
if (row.length) rows.push(row)
|
|||
|
|
}
|
|||
|
|
return rows
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const virtualOffsetY = computed(() => virtualStartRow.value * ROW_HEIGHT)
|
|||
|
|
|
|||
|
|
function onGridScroll(e: Event) {
|
|||
|
|
const el = e.target as HTMLElement
|
|||
|
|
scrollTop.value = el.scrollTop
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resetGridScroll() {
|
|||
|
|
scrollTop.value = 0
|
|||
|
|
nextTick(() => {
|
|||
|
|
if (scrollRef.value) scrollRef.value.scrollTop = 0
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function inferTabFromIconName(name: string): IconStyleTab {
|
|||
|
|
if (name.endsWith('TwoTone')) return 'twoTone'
|
|||
|
|
if (name.endsWith('Filled')) return 'filled'
|
|||
|
|
return 'outlined'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const iconRenderCache = new Map<string, () => VNode>()
|
|||
|
|
|
|||
|
|
function getIconRenderCached(name: string) {
|
|||
|
|
let fn = iconRenderCache.get(name)
|
|||
|
|
if (!fn) {
|
|||
|
|
const C = (Icons as Record<string, unknown>)[name]
|
|||
|
|
if (!C) {
|
|||
|
|
fn = () => h('span')
|
|||
|
|
} else {
|
|||
|
|
const Comp = C as Parameters<typeof h>[0]
|
|||
|
|
fn = () => h(Comp)
|
|||
|
|
}
|
|||
|
|
iconRenderCache.set(name, fn)
|
|||
|
|
}
|
|||
|
|
return fn
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const previewRender = computed(() => {
|
|||
|
|
const n = iconName.value
|
|||
|
|
if (!n) return null
|
|||
|
|
return getIconRenderCached(n)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function selectIcon(name: string) {
|
|||
|
|
iconName.value = name
|
|||
|
|
pickerOpen.value = false
|
|||
|
|
keyword.value = ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
watch(keyword, () => {
|
|||
|
|
resetGridScroll()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
watch(iconStyleTab, () => {
|
|||
|
|
resetGridScroll()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
watch(pickerOpen, (open) => {
|
|||
|
|
if (!open) {
|
|||
|
|
keyword.value = ''
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
iconStyleTab.value = inferTabFromIconName(iconName.value || '')
|
|||
|
|
resetGridScroll()
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.antd-icon-picker-row {
|
|||
|
|
display: flex;
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.antd-icon-picker-input {
|
|||
|
|
flex: 1;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.antd-icon-picker-prefix {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
font-size: 16px;
|
|||
|
|
line-height: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.antd-icon-picker-trigger {
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.antd-icon-picker-tabs :deep(.ant-tabs-nav) {
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.antd-icon-picker-tabs :deep(.ant-tabs-tab) {
|
|||
|
|
padding: 6px 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.antd-icon-picker-grid {
|
|||
|
|
max-height: 280px;
|
|||
|
|
overflow-x: hidden;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.antd-icon-picker-grid-spacer {
|
|||
|
|
position: relative;
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.antd-icon-picker-grid-inner {
|
|||
|
|
position: absolute;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
top: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.antd-icon-picker-grid-row {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(8, 1fr);
|
|||
|
|
gap: 4px;
|
|||
|
|
height: 44px;
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.antd-icon-picker-cell {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 100%;
|
|||
|
|
min-height: 0;
|
|||
|
|
height: 40px;
|
|||
|
|
padding: 0;
|
|||
|
|
margin: 0;
|
|||
|
|
border: 1px solid var(--ant-color-border-secondary, #f0f0f0);
|
|||
|
|
border-radius: 6px;
|
|||
|
|
background: var(--ant-color-bg-container, #fff);
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 18px;
|
|||
|
|
line-height: 1;
|
|||
|
|
transition: border-color 0.2s, background 0.2s, color 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.antd-icon-picker-cell:hover {
|
|||
|
|
border-color: var(--ant-color-primary, #0958d9);
|
|||
|
|
color: var(--ant-color-primary, #0958d9);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.antd-icon-picker-cell.is-active {
|
|||
|
|
border-color: var(--ant-color-primary, #0958d9);
|
|||
|
|
background: var(--ant-color-primary-bg, #e6f4ff);
|
|||
|
|
color: var(--ant-color-primary, #0958d9);
|
|||
|
|
}
|
|||
|
|
</style>
|