Vue3+iconfont图标选择器封装

场景

之前接触的都是若依框架的后台模板,导航菜单配置的图标选择器原理是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'] },
    ],
  },
]
相关推荐
ID_180079054731 小时前
淘宝店铺所有商品 API 接口:核心能力与数据返回参考
java·服务器·前端
Hello--_--World1 小时前
vite:什么是热更新?vite 和 webpack 有什么区别?vite常见配置和优化手段?
前端·webpack·node.js
渡我白衣1 小时前
定时器与时间轮思想
linux·开发语言·前端·c++·人工智能·深度学习·神经网络
鹏程十八少1 小时前
13. Android 面了50位Kotlin候选人,这36个语法坑90%的人答不全
前端·后端·面试
Hello--_--World1 小时前
Vite:什么是bundleless?哪些要打包,哪些不要打包?依赖预构建是什么?依赖预构建如何减少网络请求的?esbuild 又是什么?
前端·javascript·webpack·vite
Rooting++1 小时前
vue2+webpack打包优化的相关问题
前端·webpack·node.js
alxraves1 小时前
超声图像前端信号处理的关键技术
前端·fpga开发·信号处理
问心无愧05131 小时前
ctf show web入门47
前端·笔记
web守墓人1 小时前
【神经网络】js版本的Pytorch,estorch重磅发布
前端·javascript·人工智能·pytorch·深度学习·神经网络