Files
wordpress-theme-aksrt/assets/admin/src/components/form/FaIconPicker.vue
T
kate522 39bafb65c4 fix(icons): render FA glyphs in the SPA picker grid and harden item clicks
The server catalog ships bare glyph names, but the SPA grid preview
composed "fas home" instead of "fas fa-home" — every FA icon rendered
blank (the old curated lists carried the fa- prefix, so the regression
appeared with the catalog switch).

Item clicks move from per-element @click to a delegated panel handler
reading data-icon (immunity to DOM-replacing extensions/userscripts,
matching the PHP picker's pattern), and selecting now closes the
popover so the choice is visible in the field immediately.
2026-09-06 15:48:52 +08:00

264 lines
7.4 KiB
Vue

<template>
<div class="aksrt-fa-icon-picker">
<n-popover v-model:show="popoverShow" trigger="click" placement="bottom-start" :width="460" :style="{ padding: 0 }">
<template #trigger>
<n-input
:value="modelValue"
:placeholder="placeholder"
readonly
clearable
@clear="$emit('update:modelValue', '')"
>
<template #prefix>
<IconValue v-if="modelValue" :value="modelValue" />
<template v-else></template>
</template>
<template #suffix>
<FaIcon icon="fa fas fa-chevron-down" :size="12" />
</template>
</n-input>
</template>
<div class="aksrt-icon-picker__panel" @click="onPanelClick">
<div class="aksrt-icon-picker__search">
<n-input v-model:value="search" placeholder="搜索图标..." size="small" clearable>
<template #prefix>
<FaIcon icon="fa fas fa-search" :size="12" />
</template>
</n-input>
</div>
<div class="aksrt-icon-picker__tabs">
<n-radio-group v-model:value="activeTab" size="small">
<n-radio-button v-for="tab in tabs" :key="tab.id" :value="tab.id">
{{ tab.title }}
</n-radio-button>
</n-radio-group>
</div>
<!-- Custom SVG tab -->
<div v-if="activeTab === 'custom'" class="aksrt-icon-picker__custom">
<n-input v-model:value="customCode" type="textarea" placeholder='&lt;svg viewBox="0 0 1024 1024"&gt;&lt;path d="…"&gt;&lt;/path&gt;&lt;/svg&gt;' :rows="4" />
<div class="aksrt-icon-picker__custom-actions">
<span class="aksrt-icon-picker__custom-preview" v-html="customPreview"></span>
<n-button size="small" type="primary" :disabled="!customPreview" @click="insertCustom">
确认插入
</n-button>
</div>
<div
v-if="sessionCustom.length"
class="aksrt-icon-picker__grid aksrt-icon-picker__grid--session"
data-aksrt-icon-grid="custom"
>
<div
v-for="(svg, i) in sessionCustom"
:key="i"
class="aksrt-icon-picker__item"
:class="{ 'is-active': modelValue === `customsvg-${svg}` }"
title="customsvg"
:data-icon="`customsvg-${svg}`"
v-html="cleanSvg(svg)"
></div>
</div>
</div>
<!-- Catalog tab -->
<div v-else class="aksrt-icon-picker__grid" data-aksrt-icon-grid="catalog">
<div
v-for="icon in filteredIcons"
:key="icon"
class="aksrt-icon-picker__item"
:class="{ 'is-active': modelValue === composeIconValue(activeGroup, icon) }"
:title="icon"
:data-icon="composeIconValue(activeGroup, icon)"
>
<i v-if="activeGroup.type === 'fa'" :class="[activeTab, 'fa-' + icon]"></i>
<span
v-else
class="aksrt-icon-picker__preview-svg"
v-html="zibsvgMarkup(`zibsvg-${icon}`)"
></span>
</div>
<div v-if="filteredIcons.length === 0" class="aksrt-icon-picker__empty">
未找到匹配图标
</div>
</div>
</div>
</n-popover>
</div>
</template>
<script setup>
/**
* FaIconPicker — universal icon picker backed by the server catalog
* (GET /aksrt/v1/icons/catalog): full FA5 free set, the theme's built-in
* SVG sprite keys and inline custom SVG values.
*/
import { ref, computed, onMounted } from 'vue'
import FaIcon from './FaIcon.vue'
import IconValue from './IconValue.vue'
import api from '@/api/index.js'
import {
cleanSvg,
zibsvgMarkup,
composeIconValue,
} from '@/utils/icons.js'
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '选择图标' },
})
const emit = defineEmits(['update:modelValue'])
const FALLBACK_TABS = [
{ id: 'fas', title: 'Solid', type: 'fa', icons: [] },
{ id: 'far', title: 'Regular', type: 'fa', icons: [] },
{ id: 'fab', title: 'Brands', type: 'fa', icons: [] },
{ id: 'zibsvg', title: '内置 SVG', type: 'svg', icons: [] },
{ id: 'custom', title: '自定义', type: 'custom', icons: [] },
]
const tabs = ref(FALLBACK_TABS)
const search = ref('')
const activeTab = ref('fas')
const customCode = ref('')
const sessionCustom = ref([])
const popoverShow = ref(false)
const activeGroup = computed(() => tabs.value.find(t => t.id === activeTab.value) || tabs.value[0])
const filteredIcons = computed(() => {
const group = activeGroup.value
if (!group || group.type === 'custom') return []
const q = search.value.toLowerCase().replace(/\s+/g, '')
if (!q) return group.icons
return group.icons.filter(i => i.includes(q))
})
const customPreview = computed(() => cleanSvg(customCode.value))
function selectIcon(value) {
emit('update:modelValue', value)
popoverShow.value = false
}
// Delegated on the panel: survives any DOM replacement inside the grids and
// works for both the catalog and session grids.
function onPanelClick(event) {
const item = event.target.closest('[data-icon]')
if (item) selectIcon(item.dataset.icon)
}
function insertCustom() {
const svg = cleanSvg(customCode.value)
if (!svg) return
if (!sessionCustom.value.includes(svg)) sessionCustom.value.push(svg)
emit('update:modelValue', `customsvg-${svg}`)
customCode.value = ''
}
onMounted(async () => {
try {
const res = await api.getIconCatalog()
if (res?.success && Array.isArray(res.groups) && res.groups.length) {
tabs.value = res.groups
}
} catch (e) {
// Keep fallback tabs; the panel stays usable once the endpoint answers.
}
})
</script>
<style scoped>
.aksrt-icon-picker__panel {
padding: 12px;
}
.aksrt-icon-picker__search {
margin-bottom: 10px;
}
.aksrt-icon-picker__tabs {
margin-bottom: 10px;
}
.aksrt-icon-picker__tabs :deep(.n-radio-group) {
flex-wrap: wrap;
}
.aksrt-icon-picker__grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
max-height: 260px;
overflow-y: auto;
}
.aksrt-icon-picker__item {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
color: var(--aksrt-color-text-secondary);
transition: all 0.15s;
}
.aksrt-icon-picker__item:hover {
background: var(--aksrt-color-primary-soft);
color: var(--aksrt-color-primary);
}
.aksrt-icon-picker__item.is-active {
background: var(--aksrt-color-primary);
color: #fff;
}
.aksrt-icon-picker__item :deep(svg),
.aksrt-icon-picker__preview-svg :deep(svg),
.aksrt-icon-picker__preview-svg svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.aksrt-icon-picker__preview-svg {
display: inline-flex;
}
.aksrt-icon-picker__empty {
grid-column: 1 / -1;
text-align: center;
padding: 20px;
color: var(--aksrt-color-muted);
font-size: 13px;
}
.aksrt-icon-picker__custom-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.aksrt-icon-picker__custom-preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px dashed var(--aksrt-color-muted);
border-radius: 4px;
}
.aksrt-icon-picker__grid--session {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--aksrt-color-muted);
}
</style>