feat(admin): back the SPA icon picker with the server catalog

FaIconPicker drops its duplicated hard-coded FA subset and loads
GET /aksrt/v1/icons/catalog: full solid/regular/brands sets, the
built-in zibsvg sprite tab and an inline custom SVG tab. Props/emit
surface is unchanged; the system-notice title icon schema drift
(input vs icon) is fixed on the JS side.
This commit is contained in:
2026-09-06 12:59:30 +08:00
parent 009f9874c0
commit 5b5db0f8f5
5 changed files with 211 additions and 96 deletions
+8
View File
@@ -0,0 +1,8 @@
/**
* Icon catalog API — serves the universal icon picker (FA5 + sprite + custom).
*/
export const iconsApiMethods = {
getIconCatalog() {
return this.request('/icons/catalog')
},
}
+2
View File
@@ -6,6 +6,7 @@ import { paymentApiMethods } from './payment.js'
import { extensionsApiMethods } from './extensions.js'
import { toolsApiMethods } from './tools.js'
import { schemaApiMethods } from './schema.js'
import { iconsApiMethods } from './icons.js'
export const apiMethods = {
...globalApiMethods,
@@ -16,4 +17,5 @@ export const apiMethods = {
...extensionsApiMethods,
...toolsApiMethods,
...schemaApiMethods,
...iconsApiMethods,
}
+138 -95
View File
@@ -1,6 +1,6 @@
<template>
<div class="aksrt-fa-icon-picker">
<n-popover trigger="click" placement="bottom-start" :width="360" :style="{ padding: 0 }">
<n-popover trigger="click" placement="bottom-start" :width="400" :style="{ padding: 0 }">
<template #trigger>
<n-input
:value="modelValue"
@@ -10,7 +10,20 @@
@clear="$emit('update:modelValue', '')"
>
<template #prefix>
<FaIcon v-if="modelValue" :icon="modelValue" />
<!-- FA value -->
<FaIcon v-if="isFaValue(modelValue)" :icon="modelValue" />
<!-- Built-in sprite value -->
<span
v-else-if="isZibsvgValue(modelValue)"
class="aksrt-icon-picker__preview-svg"
v-html="zibsvgMarkup(modelValue)"
></span>
<!-- Inline custom SVG value -->
<span
v-else-if="isCustomSvgValue(modelValue)"
class="aksrt-icon-picker__preview-svg"
v-html="cleanSvg(modelValue.slice(10))"
></span>
</template>
<template #suffix>
<FaIcon icon="fa fas fa-chevron-down" :size="12" />
@@ -20,12 +33,7 @@
<div class="aksrt-icon-picker__panel">
<div class="aksrt-icon-picker__search">
<n-input
v-model:value="search"
placeholder="搜索图标..."
size="small"
clearable
>
<n-input v-model:value="search" placeholder="搜索图标..." size="small" clearable>
<template #prefix>
<FaIcon icon="fa fas fa-search" :size="12" />
</template>
@@ -33,23 +41,51 @@
</div>
<div class="aksrt-icon-picker__tabs">
<n-radio-group v-model:value="activePrefix" size="small">
<n-radio-button value="fas">Solid</n-radio-button>
<n-radio-button value="far">Regular</n-radio-button>
<n-radio-button value="fab">Brands</n-radio-button>
<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>
<div class="aksrt-icon-picker__grid" ref="gridRef">
<!-- 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">
<div
v-for="(svg, i) in sessionCustom"
:key="i"
class="aksrt-icon-picker__item"
:class="{ 'is-active': modelValue === `customsvg-${svg}` }"
title="customsvg"
@click="$emit('update:modelValue', `customsvg-${svg}`)"
v-html="cleanSvg(svg)"
></div>
</div>
</div>
<!-- Catalog tab -->
<div v-else class="aksrt-icon-picker__grid" ref="gridRef">
<div
v-for="icon in filteredIcons"
:key="icon"
class="aksrt-icon-picker__item"
:class="{ 'is-active': modelValue === `fa ${activePrefix} ${icon}` }"
:class="{ 'is-active': modelValue === composeIconValue(activeGroup, icon) }"
:title="icon"
@click="selectIcon(icon)"
>
<i :class="['fa', activePrefix, icon]"></i>
<i v-if="activeGroup.type === 'fa'" :class="['fa', activeTab, 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">
未找到匹配图标
@@ -62,11 +98,21 @@
<script setup>
/**
* FaIconPicker — searchable FA5 icon picker.
* Renders an input that opens a popover grid of FA5 icons.
* 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 } from 'vue'
import { ref, computed, onMounted } from 'vue'
import FaIcon from './FaIcon.vue'
import api from '@/api/index.js'
import {
cleanSvg,
isFaValue,
isZibsvgValue,
isCustomSvgValue,
zibsvgMarkup,
composeIconValue,
} from '@/utils/icons.js'
const props = defineProps({
modelValue: { type: String, default: '' },
@@ -75,92 +121,54 @@ const props = defineProps({
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 activePrefix = ref('fas')
const gridRef = ref(null)
const activeTab = ref('fas')
const customCode = ref('')
const sessionCustom = ref([])
// Common FA5 icons subset — a curated list covering most admin UI needs.
// Curated FA5 subsets for the three bundled font families.
const SOLID_ICONS = [
'fa-home', 'fa-cog', 'fa-cogs', 'fa-wrench', 'fa-sliders-h', 'fa-wrench',
'fa-search', 'fa-filter', 'fa-sort', 'fa-sync-alt', 'fa-redo-alt', 'fa-undo',
'fa-plus', 'fa-minus', 'fa-times', 'fa-check', 'fa-exclamation', 'fa-info',
'fa-question', 'fa-bell', 'fa-envelope', 'fa-comment', 'fa-comments',
'fa-user', 'fa-users', 'fa-user-plus', 'fa-user', 'fa-user-circle',
'fa-lock', 'fa-unlock', 'fa-key', 'fa-shield-alt', 'fa-id-badge',
'fa-globe', 'fa-globe', 'fa-link', 'fa-external-link-alt', 'fa-anchor',
'fa-image', 'fa-image', 'fa-camera', 'fa-video', 'fa-music', 'fa-file',
'fa-folder', 'fa-folder-open', 'fa-download', 'fa-upload', 'fa-cloud',
'fa-database', 'fa-server', 'fa-hdd', 'fa-microchip', 'fa-microchip',
'fa-desktop', 'fa-laptop', 'fa-mobile-alt', 'fa-tablet-alt',
'fa-chart-bar', 'fa-chart-line', 'fa-chart-pie', 'fa-chart-area',
'fa-tachometer-alt', 'fa-tasks', 'fa-clipboard', 'fa-clipboard',
'fa-list', 'fa-list-alt', 'fa-list-ol', 'fa-list-ul', 'fa-th', 'fa-th-large',
'fa-tags', 'fa-tag', 'fa-bookmark', 'fa-star', 'fa-heart', 'fa-thumbs-up',
'fa-share', 'fa-share-alt', 'fa-external-link-alt', 'fa-clipboard',
'fa-eye', 'fa-eye-slash', 'fa-lightbulb', 'fa-magic', 'fa-paint-brush',
'fa-paint-brush', 'fa-font', 'fa-text-height', 'fa-bold', 'fa-italic',
'fa-code', 'fa-terminal', 'fa-bug', 'fa-exclamation-triangle',
'fa-exclamation-circle', 'fa-check-circle', 'fa-info-circle',
'fa-spinner', 'fa-circle-notch', 'fa-hourglass-half', 'fa-clock',
'fa-calendar', 'fa-calendar', 'fa-history', 'fa-hourglass',
'fa-map-marker-alt', 'fa-map', 'fa-road', 'fa-compass',
'fa-shopping-cart', 'fa-shopping-bag', 'fa-credit-card', 'fa-money-bill',
'fa-database', 'fa-dollar-sign', 'fa-credit-card', 'fa-file-alt',
'fa-newspaper', 'fa-rss', 'fa-podcast', 'fa-bullhorn',
'fa-flag', 'fa-trophy', 'fa-trophy', 'fa-trophy', 'fa-gift',
'fa-fire', 'fa-bolt', 'fa-sun', 'fa-moon', 'fa-cloud',
'fa-male', 'fa-female', 'fa-child', 'fa-id-card',
'fa-paper-plane', 'fa-paperclip', 'fa-at', 'fa-hashtag',
'fa-ellipsis-h', 'fa-ellipsis-v', 'fa-bars', 'fa-columns',
'fa-window-maximize', 'fa-window-minimize', 'fa-window-close',
'fa-expand', 'fa-compress', 'fa-arrows-alt', 'fa-crosshairs',
'fa-sign-in-alt', 'fa-sign-out-alt', 'fa-sign-in-alt',
'fa-plug', 'fa-power-off', 'fa-redo-alt', 'fa-sync-alt',
]
const REGULAR_ICONS = [
'fa-heart', 'fa-star', 'fa-bookmark', 'fa-user', 'fa-envelope',
'fa-comment', 'fa-comments', 'fa-bell', 'fa-clock', 'fa-calendar',
'fa-clipboard', 'fa-file', 'fa-folder', 'fa-image', 'fa-eye',
'fa-thumbs-up', 'fa-flag', 'fa-share-square', 'fa-hand-point-right',
'fa-life-ring', 'fa-lightbulb', 'fa-question-circle', 'fa-check-circle',
'fa-times-circle', 'fa-dot-circle', 'fa-circle', 'fa-square',
'fa-moon', 'fa-sun', 'fa-snowflake', 'fa-lemon', 'fa-gem',
'fa-copy', 'fa-edit', 'fa-trash-alt', 'fa-save', 'fa-window-maximize',
'fa-window-minimize', 'fa-window-close', 'fa-caret-square-right',
'fa-address-book', 'fa-address-card', 'fa-id-badge',
'fa-building', 'fa-chart-bar', 'fa-newspaper', 'fa-map',
]
const BRAND_ICONS = [
'fa-github', 'fa-gitlab', 'fa-bitbucket', 'fa-google', 'fa-google-plus',
'fa-facebook', 'fa-facebook-f', 'fa-twitter', 'fa-instagram',
'fa-linkedin', 'fa-linkedin-in', 'fa-youtube', 'fa-weixin', 'fa-qq',
'fa-weibo', 'fa-telegram', 'fa-telegram-plane', 'fa-discord', 'fa-slack',
'fa-whatsapp', 'fa-skype', 'fa-apple', 'fa-windows', 'fa-linux',
'fa-android', 'fa-chrome', 'fa-firefox', 'fa-edge', 'fa-safari',
'fa-opera', 'fa-html5', 'fa-css3-alt', 'fa-js', 'fa-node', 'fa-react',
'fa-vuejs', 'fa-angular', 'fa-php', 'fa-python', 'fa-java', 'fa-rust',
'fa-docker', 'fa-aws', 'fa-digital-ocean', 'fa-cloudflare', 'fa-wordpress',
'fa-joomla', 'fa-drupal', 'fa-shopify', 'fa-paypal', 'fa-stripe',
'fa-alipay', 'fa-tiktok', 'fa-snapchat', 'fa-pinterest', 'fa-reddit',
'fa-stack-overflow', 'fa-codepen', 'fa-npm', 'fa-yarn', 'fa-twitch',
'fa-spotify', 'fa-soundcloud',
]
const iconSets = { fas: SOLID_ICONS, far: REGULAR_ICONS, fab: BRAND_ICONS }
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, '')
const set = iconSets[activePrefix.value] || SOLID_ICONS
if (!q) return set
return set.filter(i => i.includes(q))
if (!q) return group.icons
return group.icons.filter(i => i.includes(q))
})
const customPreview = computed(() => cleanSvg(customCode.value))
function selectIcon(icon) {
emit('update:modelValue', `fa ${activePrefix.value} ${icon}`)
emit('update:modelValue', composeIconValue(activeGroup.value, 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>
@@ -207,6 +215,18 @@ function selectIcon(icon) {
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;
@@ -214,4 +234,27 @@ function selectIcon(icon) {
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>
@@ -124,7 +124,7 @@ export const systemNoticeSchema = {
},
{ key: 'system_notice_radius', label: '按钮圆角', type: 'switch', default: false, dependsOn: { key: 'system_notice_enabled', value: true }, help: '开启后弹窗按钮使用圆角样式' },
{ key: 'system_notice_class', label: '公告样式类', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
{ key: 'system_notice_title_icon', label: '标题图标', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true }, help: '如 fa fas fa-bullhorn' },
{ key: 'system_notice_title_icon', label: '标题图标', type: 'icon', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
{ key: 'system_notice_title_class', label: '标题背景配色', type: 'select', default: '', dependsOn: { key: 'system_notice_enabled', value: true }, options: [
{ label: '无', value: '' },
{ label: '炫彩黄', value: 'jb-yellow' },
+62
View File
@@ -0,0 +1,62 @@
/**
* Icon value helpers shared by admin UI.
*
* Supported stored formats:
* "fa fas fa-star" — FA5 class string
* "zibsvg-hot" — theme built-in SVG sprite key (window.aksrt_svgs)
* "customsvg-<svg>" — inline user SVG markup
*/
/** Clean pasted SVG markup: strip authoring leftovers, reject unsafe content. */
export function cleanSvg(raw) {
const text = String(raw || '').trim()
if (!text) return ''
let doc
try {
doc = new DOMParser().parseFromString(text, 'image/svg+xml')
} catch (e) {
return ''
}
if (doc.querySelector('parsererror')) return ''
const svg = doc.querySelector('svg')
if (!svg || svg.querySelector('script, foreignObject, animate')) return ''
;['class', 'id', 'style', 't', 'version', 'p-id'].forEach((attr) => svg.removeAttribute(attr))
Array.from(svg.attributes).forEach((attr) => {
if (attr.name.indexOf('data-') === 0) svg.removeAttribute(attr.name)
})
const keep = new XMLSerializer().serializeToString(svg)
return /<path|<use|<g\b|<circle|<rect|<polygon|<ellipse|<line/i.test(keep) ? keep : ''
}
export function isFaValue(value) {
return /^fa[\s-]/.test(String(value || '').trim())
}
export function isZibsvgValue(value) {
return String(value || '').trim().startsWith('zibsvg-')
}
export function isCustomSvgValue(value) {
return String(value || '').trim().startsWith('customsvg-')
}
/** Sprite key of a zibsvg value, e.g. "zibsvg-hot" → "hot". */
export function zibsvgKey(value) {
return String(value || '')
.trim()
.slice(7)
.replace(/[^a-zA-Z0-9_-]/g, '')
}
/** Inline markup for a zibsvg sprite preview (sprite injected by svg-icon.js). */
export function zibsvgMarkup(value) {
const key = zibsvgKey(value)
return key ? `<svg class="icon" aria-hidden="true"><use xlink:href="#icon-${key}"></use></svg>` : ''
}
/** Value of a picker selection composed from a catalog group + icon name. */
export function composeIconValue(group, name) {
if (group.type === 'fa') return `fa ${group.id} fa-${name}`
if (group.type === 'svg') return `zibsvg-${name}`
return String(name || '')
}