library-picturebook-activity/lesingle-creation-frontend/src/components/AntdIconPicker.vue
En 98e9ad1d28 feat(前端): 测试环境登录框支持自动填充测试账号
通过 VITE_AUTO_FILL_TEST 环境变量控制,在 .env.test 中启用,
使测试环境构建后登录框也能自动填充测试账号,方便测试人员使用。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 17:03:22 +08:00

353 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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