场景:
之前接触的都是若依框架的后台模板,导航菜单配置的图标选择器原理是svg图片放置在前端,选择器读取文件中的图片和图片名,选择保存后名字存入后端,每次前端拿到匹配。
我在想,图片资源占空间不小,如果不放置在服务器的话,那不是平白增加我打包大小?我项目中原本就有用iconfont,那我直接从iconfont的文件中取用不好吗?
基于后端逻辑不变,我尝试用白名单的思路,将iconfont中一些图标存成配置文件,图标选择器从配置文件中读取选用图标,向后端发送icon后导航栏读取当前用户菜单数据并完全渲染。
效果:

实现:
console-menu-icons.ts 图标配置表
typescript
export type ConsoleMenuIconName = (typeof CONSOLE_MENU_ICON_NAMES)[number]
export const CONSOLE_MENU_ICON_NAMES = [
'icon-quanqiushuju',
'icon-gongzuotai',
'icon-shujukanban',
'icon-shebeizhongxin',
'icon-chengyuanyuquanxian',
'icon-chengyuan',
'icon-caidan',
'icon-zuzhi',
'icon-rizhi',
'icon-renwuzhongxin',
'icon-hangxian',
'icon-hangxianliebiao',
'icon-kongyuguanli',
'icon-shishijiankong',
'icon-rizhiyugaojing',
'icon-gaojing',
] as const
export const CONSOLE_MENU_ICONS = Object.fromEntries(
CONSOLE_MENU_ICON_NAMES.map((name) => [name, name]),
) as Record<ConsoleMenuIconName, ConsoleMenuIconName>
export const consoleMenuIconOptions = CONSOLE_MENU_ICON_NAMES.map((name) => ({
label: name,
value: name,
}))
IconfontSelect.vue 选择器UI
javascript
<script setup lang="ts">
type IconfontOption = {
label: string
value: string
}
withDefaults(
defineProps<{
modelValue?: string
options: IconfontOption[]
placeholder?: string
}>(),
{
modelValue: '',
placeholder: '请选择图标',
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<el-select
:model-value="modelValue"
:placeholder="placeholder"
clearable
filterable
style="width: 100%"
popper-class="bh-select-dropdown-dark iconfont-select-dropdown"
@update:model-value="emit('update:modelValue', String($event ?? ''))"
>
<template #prefix>
<i v-if="modelValue" :class="['iconfont', modelValue, 'iconfont-select__prefix']" aria-hidden="true" />
</template>
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value">
<span class="iconfont-select__option flex flex-align-center">
<i :class="['iconfont', item.value, 'iconfont-select__icon']" aria-hidden="true" />
<span class="iconfont-select__name">{{ item.label }}</span>
</span>
</el-option>
</el-select>
</template>
<style scoped lang="scss">
.iconfont-select__prefix {
font-size: 16px;
}
.iconfont-select__option {
gap: 10px;
min-width: 0;
}
.iconfont-select__icon {
width: 18px;
flex-shrink: 0;
text-align: center;
font-size: 16px;
}
.iconfont-select__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
MenuFormDialog.vue 新增/编辑弹窗,自行查看图标选择代码
javascript
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { type FormInstance, type FormRules } from 'element-plus'
import type { MenuMutationPayload } from '@/api/http/system/menu'
import IconfontSelect from '@/components/popup/IconfontSelect.vue'
import { consoleMenuIconOptions } from '@/modules/console-menu-icons'
type ParentOption = {
label: string
value: number
}
const props = withDefaults(
defineProps<{
modelValue: boolean
mode: 'add' | 'edit'
submitting?: boolean
parentOptions: ParentOption[]
initialData?: MenuMutationPayload
}>(),
{
submitting: false,
initialData: () => ({}),
},
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
submit: [payload: MenuMutationPayload]
}>()
const formRef = ref<FormInstance>()
const formModel = reactive<MenuMutationPayload>({
id: undefined,
parentId: 0,
menuName: '',
menuType: 1,
icon: '',
routePath: '',
component: '',
permKey: '',
sort: 1,
visible: true,
status: true,
fixed: false,
})
const menuTypeOptions = [
{ label: '目录', value: 1 },
{ label: '菜单', value: 2 },
{ label: '按钮', value: 3 },
]
const needsMenuIcon = computed(() => [1, 2].includes(Number(formModel.menuType)))
function validateRequiredWhenMenuTypeIsMenu(fieldLabel: string) {
return (_rule: unknown, value: unknown, callback: (error?: Error) => void) => {
if (Number(formModel.menuType) !== 2) {
callback()
return
}
const text = typeof value === 'string' ? value.trim() : String(value ?? '').trim()
if (!text) {
callback(new Error(`请输入${fieldLabel}`))
return
}
callback()
}
}
const formRules: FormRules = {
parentId: [{ required: true, message: '请选择上级菜单', trigger: 'change' }],
menuName: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
menuType: [{ required: true, message: '请选择菜单类型', trigger: 'change' }],
routePath: [{ validator: validateRequiredWhenMenuTypeIsMenu('路由路径'), trigger: ['blur', 'change'] }],
component: [{ validator: validateRequiredWhenMenuTypeIsMenu('组件路径'), trigger: ['blur', 'change'] }],
}
const dialogTitle = computed(() => (props.mode === 'add' ? '新增菜单' : '编辑菜单'))
function syncFormByInitialData() {
const source = props.initialData ?? {}
formModel.id = source.id
formModel.parentId = source.parentId ?? 0
formModel.menuName = source.menuName ?? ''
formModel.menuType = source.menuType ?? 1
formModel.icon = source.icon ?? ''
formModel.routePath = source.routePath ?? ''
formModel.component = source.component ?? ''
formModel.permKey = source.permKey ?? ''
formModel.sort = source.sort ?? 1
formModel.visible = source.visible ?? true
formModel.status = source.status ?? true
formModel.fixed = source.fixed ?? false
}
watch(
() => props.modelValue,
(visible) => {
if (visible) {
syncFormByInitialData()
formRef.value?.clearValidate()
}
},
)
watch(
() => props.initialData,
() => {
if (props.modelValue) {
syncFormByInitialData()
}
},
{ deep: true },
)
watch(
() => formModel.menuType,
(menuType) => {
if (Number(menuType) !== 2) {
formRef.value?.clearValidate(['routePath', 'component'])
}
if (![1, 2].includes(Number(menuType))) {
formModel.icon = ''
formRef.value?.clearValidate(['icon'])
}
},
)
function closeDialog() {
emit('update:modelValue', false)
}
async function submitForm() {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
emit('submit', {
id: formModel.id,
parentId: Number(formModel.parentId ?? 0),
menuName: formModel.menuName?.trim(),
menuType: Number(formModel.menuType ?? 1),
icon: needsMenuIcon.value ? (formModel.icon?.trim() ?? '') : '',
routePath: formModel.routePath?.trim() ?? '',
component: formModel.component?.trim() ?? '',
permKey: formModel.permKey?.trim() ?? '',
sort: Number(formModel.sort ?? 0),
visible: Boolean(formModel.visible),
status: Boolean(formModel.status),
fixed: Boolean(formModel.fixed),
})
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="640px"
destroy-on-close
class="menu-dialog bh-dialog-dark bh-el-dark"
@close="closeDialog"
>
<el-form ref="formRef" :model="formModel" :rules="formRules" label-width="92px" class="menu-form">
<el-form-item label="上级菜单" prop="parentId">
<el-select v-model="formModel.parentId" placeholder="请选择上级菜单" style="width: 100%" popper-class="bh-select-dropdown-dark">
<el-option :value="0" label="顶级菜单" />
<el-option v-for="item in parentOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="菜单名称" prop="menuName">
<el-input v-model="formModel.menuName" maxlength="50" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单类型" prop="menuType">
<el-select v-model="formModel.menuType" placeholder="请选择菜单类型" style="width: 100%" popper-class="bh-select-dropdown-dark">
<el-option v-for="item in menuTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item v-if="needsMenuIcon" label="菜单图标" prop="icon">
<IconfontSelect v-model="formModel.icon" :options="consoleMenuIconOptions" placeholder="请选择菜单图标" />
</el-form-item>
<el-form-item label="路由路径" prop="routePath" :required="Number(formModel.menuType) === 2">
<el-input v-model="formModel.routePath" maxlength="120" placeholder="请输入路由路径" />
</el-form-item>
<el-form-item label="组件路径" prop="component" :required="Number(formModel.menuType) === 2">
<el-input v-model="formModel.component" maxlength="120" placeholder="请输入组件路径" />
</el-form-item>
<el-form-item label="菜单编码" prop="permKey">
<el-input v-model="formModel.permKey" maxlength="120" placeholder="请输入菜单编码/权限标识" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formModel.sort" :min="0" :max="9999" controls-position="right" style="width: 100%" />
</el-form-item>
<div class="menu-form__switch-row">
<el-form-item label="显示" prop="visible">
<el-switch v-model="formModel.visible" class="bh-switch-dark" inline-prompt active-text="开" inactive-text="关" />
</el-form-item>
<el-form-item label="启用" prop="status">
<el-switch v-model="formModel.status" class="bh-switch-dark" inline-prompt active-text="开" inactive-text="关" />
</el-form-item>
<el-form-item label="固定" prop="fixed">
<el-switch v-model="formModel.fixed" class="bh-switch-dark" inline-prompt active-text="是" inactive-text="否" />
</el-form-item>
</div>
</el-form>
<template #footer>
<span class="menu-dialog__footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitForm">保存</el-button>
</span>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.menu-form {
.menu-form__switch-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
}
:global(.menu-dialog.el-dialog),
:global(.menu-dialog .el-dialog) {
background: var(--bh-theme-panel-bg);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.52);
}
:global(.menu-dialog .el-dialog__header) {
background: var(--bh-theme-panel-bg);
}
:global(.menu-dialog .el-dialog__title) {
color: var(--bh-theme-text-strong);
font-size: 20px;
font-weight: 700;
}
:global(.menu-dialog .menu-form__switch-row .el-form-item) {
margin-bottom: 0;
}
@media (max-width: 1280px) {
.menu-form {
.menu-form__switch-row {
grid-template-columns: 1fr;
}
}
}
</style>
console-business-nav.ts 图标引用,业务导航参考清单
typescript
import { CONSOLE_MENU_ICONS } from './console-menu-icons'
/** 业务信息管理区 --- 控制台内二级导航(与 projectframework 中后台模块对齐) */
export type ConsoleBusinessNavEntry =
| { type: 'link'; id: string; name: string; path: string; icon: string }
| {
type: 'group'
id: string
name: string
icon: string
children: { id: string; name: string; path: string; icon: string }[]
}
export const consoleBusinessNav: ConsoleBusinessNavEntry[] = [
{ type: 'link', id: 'overview', name: '数据看板', path: '/console/overview', icon: CONSOLE_MENU_ICONS['icon-shujukanban'] },
{ type: 'link', id: 'devices', name: '设备中心', path: '/console/devices', icon: CONSOLE_MENU_ICONS['icon-shebeizhongxin'] },
{
type: 'group',
id: 'members',
name: '成员与权限',
icon: CONSOLE_MENU_ICONS['icon-chengyuanyuquanxian'],
children: [
{ id: 'member-manage', name: '成员管理', path: '/console/members', icon: CONSOLE_MENU_ICONS['icon-chengyuanyuquanxian'] },
{ id: 'menu-manage', name: '菜单管理', path: '/console/members/menu', icon: CONSOLE_MENU_ICONS['icon-rizhi'] },
],
},
{ type: 'link', id: 'tasks', name: '任务中心', path: '/console/tasks', icon: CONSOLE_MENU_ICONS['icon-renwuzhongxin'] },
{
type: 'group',
id: 'routes',
name: '航线中心',
icon: CONSOLE_MENU_ICONS['icon-hangxian'],
children: [
{ id: 'route-list', name: '航线列表', path: '/console/routes', icon: CONSOLE_MENU_ICONS['icon-hangxianliebiao'] },
{ id: 'route-zones', name: '区域管理', path: '/console/routes/zones', icon: CONSOLE_MENU_ICONS['icon-kongyuguanli'] },
],
},
{ type: 'link', id: 'monitor', name: '实时监控', path: '/console/monitor', icon: CONSOLE_MENU_ICONS['icon-shishijiankong'] },
{
type: 'group',
id: 'logs',
name: '日志与告警',
icon: CONSOLE_MENU_ICONS['icon-rizhiyugaojing'],
children: [
{ id: 'alerts', name: '告警中心', path: '/console/alerts', icon: CONSOLE_MENU_ICONS['icon-gaojing'] },
{ id: 'task-logs', name: '任务日志', path: '/console/logs/tasks', icon: CONSOLE_MENU_ICONS['icon-rizhi'] },
],
},
]