Files
wordpress-theme-aksrt/assets/admin/src/components/form/FaIconPicker.vue
T

299 lines
8.3 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">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="aksrt-icon-picker__tab"
:class="{ 'is-active': activeTab === tab.id }"
@click="activeTab = tab.id"
>
{{ tab.title }}
</button>
</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 {
/* The popover teleports to <body>, outside #aksrt-admin-app where the
palette is declared — re-declare every token the panel consumes. */
--aksrt-color-primary: #18A058;
--aksrt-color-primary-hover: #36AD6A;
--aksrt-color-primary-soft: rgba(24, 160, 88, 0.12);
--aksrt-color-text-secondary: #465149;
--aksrt-color-muted: #6F7A72;
--aksrt-color-border: #E2E8E3;
padding: 12px;
}
.aksrt-icon-picker__search {
margin-bottom: 10px;
}
.aksrt-icon-picker__tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.aksrt-icon-picker__tab {
padding: 3px 10px;
font-size: 13px;
line-height: 20px;
border: 1px solid var(--aksrt-color-border);
border-radius: 4px;
background: transparent;
color: var(--aksrt-color-text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.aksrt-icon-picker__tab:hover {
border-color: var(--aksrt-color-primary);
color: var(--aksrt-color-primary);
}
.aksrt-icon-picker__tab.is-active {
border-color: var(--aksrt-color-primary);
background: var(--aksrt-color-primary);
color: #fff;
}
.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>