Vue Antd Admin 自定义菜单栏图标,支持图片和SVG两种形式!

本教程适用于:Vue Antd Admin,默认脚手架不支持非官方图标库以外的图标(至少我没发现怎么实现),本来想着在网上复制粘贴找一波解决方案,结果还没找到比较合适的(可能我搜索姿势不对吧),没辙,只能自己改造!于是有了这篇博文!

目标

让菜单栏支持非官方图标库以外的图标!最少要支持 iconfont 的 svg 矢量图(懂的都懂)

效果图预览

Snipaste_2023-06-15_09-59-03.png

实现思路

改变官方菜单生成函数即可

如何实现

找到 menus.js 文件,参考路径:src/components/menu/menu.js

Snipaste_2023-06-15_10-00-37.png

修改 renderIcon 函数代码,修改后代码如下:

Snipaste_2023-06-15_10-01-38.png

function (h, icon, key) {
    if (this.$scopedSlots.icon && icon && icon !== 'none') {
        const vnodes = this.$scopedSlots.icon({icon, key})
        vnodes.forEach(vnode => {
            vnode.data.class = vnode.data.class ? vnode.data.class : []
            vnode.data.class.push('anticon')
        })
        return vnodes
    }

    if (!icon || icon === 'none') return null;

    // 增加 svg 图标支持
    if (icon.indexOf("<svg") === 0) {
        // 仅取出svg代码中path部分
        let path = icon.match(/<path.+?>/)[0];
        // 移除默认填色(否则会导致激活菜单状态下图标颜色不改变问题)
        path = path.replace(/\sfill="#[a-z0-9]{3,6}"/, "");
        // 创建DOM元素
        return h("i", {
            class: "anticon",
            domProps: {
                innerHTML: `<svg viewBox="64 64 896 896" data-icon="" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" class="">${path}</svg>`
            }
        })
    } else if (icon.indexOf("/") !== -1) {
        // 如果通过 require 方式引入的话(不推荐)
        return h('img', {
            class: "action",
            style: {
                width: '1em',
                height: '1em',
                marginRight: '10px'
            },
            domProps: {
                src: icon
            }
        })
    } else {
        return h(Icon, {props: {type: icon}})
    }
}

创建一个图标配置文件(当然你不创建也行,随便你!)

Snipaste_2023-06-15_10-03-15.png

扩展你的图标代码,可以从iconfont直接复制svg代码过来(推荐),或者下载图片文件后使用require载入(不推荐)

Snipaste_2023-06-15_10-04-37.png

// 扩展你的图标代码,可以从iconfont直接复制svg代码过来(推荐),或者下载图片文件后使用require载入(不推荐)
export default {
    mini: '<svg t="1686793174972" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2594" width="81" height="81"><path d="M402.2 734.6c-71.9 0-130.4-54.1-130.4-121 0-20.8 6-41.2 16.9-59.5 16.4-26.8 42.7-46.6 74.4-56 8.4-2.5 14.9-3.5 20.8-3.5 13.9 0 24.8 10.9 24.8 24.8s-10.9 24.8-24.8 24.8c-1 0-3 0-5.5 1-21.3 6-38.2 18.4-47.6 34.7-6.5 10.4-9.4 21.8-9.4 33.7 0 39.2 36.2 71.4 80.4 71.4 15.4 0 30.3-4 43.7-11.4 23.3-13.4 37.2-35.7 37.2-60V405.7c0-42.2 23.3-80.8 62-102.7 20.8-11.9 44.1-17.9 68-17.9 71.9 0 130.4 54.1 130.4 121 0 20.8-6 41.2-16.9 59.5-16.4 26.8-42.7 46.6-74.4 56-8.9 2.5-14.9 3.5-20.8 3.5-13.9 0-24.8-10.9-24.8-24.8s10.9-24.8 24.8-24.8c1 0 3 0 5.5-1 21.3-6.4 38.2-18.9 47.6-34.7 6.4-10.4 9.4-21.8 9.4-33.7 0-39.2-36.2-71.4-80.8-71.4-15.4 0-30.3 4-43.7 11.4-23.3 13.4-37.2 35.7-37.2 60v207.3c0 42.2-23.3 80.9-62 102.7-20.5 12.5-43.8 18.5-67.6 18.5z m504.4-223.2c0-219.2-177.6-396.8-396.8-396.8S113 292.1 113 511.4s177.6 396.8 396.8 396.8 396.8-177.6 396.8-396.8z m49.6 0c0 246.5-199.9 446.4-446.4 446.4-246.5 0-446.4-199.9-446.4-446.4C63.4 264.9 263.3 65 509.8 65c246.5 0 446.4 199.9 446.4 446.4z m0 0" fill="#333333" p-id="2595"></path></svg>'
}

在路由配置文件中(参考路径:src/router/config.js),载入配置,然后再对应的位置引用即可!

Snipaste_2023-06-15_10-05-50.png

Snipaste_2023-06-15_10-06-11.png

Snipaste_2023-06-15_10-06-11.png

完整 menus.js 代码分享

/**
 * 该插件可根据菜单配置自动生成 ANTD menu组件
 * menuOptions示例:
 * [
 *  {
 *    name: '菜单名称',
 *    path: '菜单路由',
 *    meta: {
 *      icon: '菜单图标',
 *      invisible: 'boolean, 是否不可见, 默认 false',
 *    },
 *    children: [子菜单配置]
 *  },
 *  {
 *    name: '菜单名称',
 *    path: '菜单路由',
 *    meta: {
 *      icon: '菜单图标',
 *      invisible: 'boolean, 是否不可见, 默认 false',
 *    },
 *    children: [子菜单配置]
 *  }
 * ]
 *
 * i18n: 国际化配置。系统默认会根据 options route配置的 path 和 name 生成英文以及中文的国际化配置,如需自定义或增加其他语言,配置
 * 此项即可。如:
 * i18n: {
 *   messages: {
 *     CN: {dashboard: {name: '监控中心'}}
 *     HK: {dashboard: {name: '監控中心'}}
 *   }
 * }
 **/
import Menu from 'ant-design-vue/es/menu'
import Icon from 'ant-design-vue/es/icon'
import fastEqual from 'fast-deep-equal'
import {getI18nKey} from '@/utils/routerUtil'

const {Item, SubMenu} = Menu

const resolvePath = (path, params = {}) => {
    let _path = path
    Object.entries(params).forEach(([key, value]) => {
        _path = _path.replace(new RegExp(`:${key}`, 'g'), value)
    })
    return _path
}

const toRoutesMap = (routes) => {
    const map = {}
    routes.forEach(route => {
        map[route.fullPath] = route
        if (route.children && route.children.length > 0) {
            const childrenMap = toRoutesMap(route.children)
            Object.assign(map, childrenMap)
        }
    })
    return map
}

export default {
    name: 'IMenu',
    props: {
        options: {
            type: Array,
            required: true
        },
        theme: {
            type: String,
            required: false,
            default: 'dark'
        },
        mode: {
            type: String,
            required: false,
            default: 'inline'
        },
        collapsed: {
            type: Boolean,
            required: false,
            default: false
        },
        i18n: Object,
        openKeys: Array
    },
    data() {
        return {
            selectedKeys: [],
            sOpenKeys: [],
            cachedOpenKeys: []
        }
    },
    computed: {
        menuTheme() {
            return this.theme == 'light' ? this.theme : 'dark'
        },
        routesMap() {
            return toRoutesMap(this.options)
        }
    },
    created() {
        this.updateMenu()
        if (this.options.length > 0 && !this.options[0].fullPath) {
            this.formatOptions(this.options, '')
        }
        // 自定义国际化配置
        if (this.i18n && this.i18n.messages) {
            const messages = this.i18n.messages
            Object.keys(messages).forEach(key => {
                this.$i18n.mergeLocaleMessage(key, messages[key])
            })
        }
    },
    watch: {
        options(val) {
            if (val.length > 0 && !val[0].fullPath) {
                this.formatOptions(this.options, '')
            }
        },
        i18n(val) {
            if (val && val.messages) {
                const messages = this.i18n.messages
                Object.keys(messages).forEach(key => {
                    this.$i18n.mergeLocaleMessage(key, messages[key])
                })
            }
        },
        collapsed(val) {
            if (val) {
                this.cachedOpenKeys = this.sOpenKeys
                this.sOpenKeys = []
            } else {
                this.sOpenKeys = this.cachedOpenKeys
            }
        },
        '$route': function () {
            this.updateMenu()
        },
        sOpenKeys(val) {
            this.$emit('openChange', val)
            this.$emit('update:openKeys', val)
        }
    },
    methods: {
        renderIcon: function (h, icon, key) {
            if (this.$scopedSlots.icon && icon && icon !== 'none') {
                const vnodes = this.$scopedSlots.icon({icon, key})
                vnodes.forEach(vnode => {
                    vnode.data.class = vnode.data.class ? vnode.data.class : []
                    vnode.data.class.push('anticon')
                })
                return vnodes
            }

            if (!icon || icon === 'none') return null;

            // 增加 svg 图标支持
            if (icon.indexOf("<svg") === 0) {
                // 仅取出svg代码中path部分
                let path = icon.match(/<path.+?>/)[0];
                // 移除默认填色(否则会导致激活菜单状态下图标颜色不改变问题)
                path = path.replace(/\sfill="#[a-z0-9]{3,6}"/, "");
                // 创建DOM元素
                return h("i", {
                    class: "anticon",
                    domProps: {
                        innerHTML: `<svg viewBox="64 64 896 896" data-icon="" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" class="">${path}</svg>`
                    }
                })
            } else if (icon.indexOf("/") !== -1) {
                // 如果通过 require 方式引入的话(不推荐)
                return h('img', {
                    class: "action",
                    style: {
                        width: '1em',
                        height: '1em',
                        marginRight: '10px'
                    },
                    domProps: {
                        src: icon
                    }
                })
            } else {
                return h(Icon, {props: {type: icon}})
            }
        },
        renderMenuItem: function (h, menu) {
            let tag = 'router-link'
            const path = resolvePath(menu.fullPath, menu.meta.params)
            let config = {props: {to: {path, query: menu.meta.query},}, attrs: {style: 'overflow:hidden;white-space:normal;text-overflow:clip;'}}
            if (menu.meta && menu.meta.link) {
                tag = 'a'
                config = {attrs: {style: 'overflow:hidden;white-space:normal;text-overflow:clip;', href: menu.meta.link, target: '_blank'}}
            }
            return h(
                Item, {key: menu.fullPath},
                [
                    h(tag, config,
                        [
                            this.renderIcon(h, menu.meta ? menu.meta.icon : 'none', menu.fullPath),
                            this.$t(getI18nKey(menu.fullPath))
                        ]
                    )
                ]
            )
        },
        renderSubMenu: function (h, menu) {
            let this_ = this
            let subItem = [h('span', {slot: 'title', attrs: {style: 'overflow:hidden;white-space:normal;text-overflow:clip;'}},
                [
                    this.renderIcon(h, menu.meta ? menu.meta.icon : 'none', menu.fullPath),
                    this.$t(getI18nKey(menu.fullPath))
                ]
            )]
            let itemArr = []
            menu.children.forEach(function (item) {
                itemArr.push(this_.renderItem(h, item))
            })
            return h(SubMenu, {key: menu.fullPath},
                subItem.concat(itemArr)
            )
        },
        renderItem: function (h, menu) {
            const meta = menu.meta
            if (!meta || !meta.invisible) {
                let renderChildren = false
                const children = menu.children
                if (children != undefined) {
                    for (let i = 0; i < children.length; i++) {
                        const childMeta = children[i].meta
                        if (!childMeta || !childMeta.invisible) {
                            renderChildren = true
                            break
                        }
                    }
                }
                return (menu.children && renderChildren) ? this.renderSubMenu(h, menu) : this.renderMenuItem(h, menu)
            }
        },
        renderMenu: function (h, menuTree) {
            let this_ = this
            let menuArr = []
            menuTree.forEach(function (menu, i) {
                menuArr.push(this_.renderItem(h, menu, '0', i))
            })
            return menuArr
        },
        formatOptions(options, parentPath) {
            options.forEach(route => {
                let isFullPath = route.path.substring(0, 1) == '/'
                route.fullPath = isFullPath ? route.path : parentPath + '/' + route.path
                if (route.children) {
                    this.formatOptions(route.children, route.fullPath)
                }
            })
        },
        updateMenu() {
            this.selectedKeys = this.getSelectedKeys()
            let openKeys = this.selectedKeys.filter(item => item !== '')
            openKeys = openKeys.slice(0, openKeys.length - 1)
            if (!fastEqual(openKeys, this.sOpenKeys)) {
                this.collapsed || this.mode === 'horizontal' ? this.cachedOpenKeys = openKeys : this.sOpenKeys = openKeys
            }
        },
        getSelectedKeys() {
            let matches = this.$route.matched
            const route = matches[matches.length - 1]
            let chose = this.routesMap[route.path]
            if (chose && chose.meta && chose.meta.highlight) {
                chose = this.routesMap[chose.meta.highlight]
                const resolve = this.$router.resolve({path: chose.fullPath})
                matches = (resolve.resolved && resolve.resolved.matched) || matches
            }
            return matches.map(item => item.path)
        }
    },
    render(h) {
        return h(
            Menu,
            {
                props: {
                    theme: this.menuTheme,
                    mode: this.$props.mode,
                    selectedKeys: this.selectedKeys,
                    openKeys: this.openKeys ? this.openKeys : this.sOpenKeys
                },
                on: {
                    'update:openKeys': (val) => {
                        this.sOpenKeys = val
                    },
                    click: (obj) => {
                        obj.selectedKeys = [obj.key]
                        this.$emit('select', obj)
                    }
                }
            }, this.renderMenu(h, this.options)
        )
    }
}