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.
264 lines
7.4 KiB
Vue
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='<svg viewBox="0 0 1024 1024"><path d="…"></path></svg>' :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>
|