通过 VITE_AUTO_FILL_TEST 环境变量控制,在 .env.test 中启用, 使测试环境构建后登录框也能自动填充测试账号,方便测试人员使用。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
|