管理系统的权限设计(vue)

684 阅读5分钟

代码和部署地址

github代码仓库

部署地址

从前端的角度来做,需要考虑两个方面

  1. 路由怎么设计
  2. 按钮/组件级别的权限,怎么设计

路由设计

先来理解这张图,其实我们后续所有的设计都是基于这个思路来实现的

router.png 理解完这张图,我们再回到我们的路由设计思路

我们的路由设计很简单,就是两层路由,一层主路由就是main,另外一层就是它的children路由,这时候可能有人会说那嵌套路由呢?其实我们从上图可以发现,嵌套子路由的概念对应的页面就是侧边栏导航这块,所谓嵌套,其实只是用户看到的是多层级导航,但实际我们开发并不需要完全按照这种来设计路由。我们仅仅在拿到数据后,给他构造出这种tree树结构就行。这样就可以规避掉,后续新增业务模块,模块设计还要考虑嵌套路由的问题。(一劳永逸)

按钮/组件级别的权限,怎么设计

这个本质上就是当前的按钮和组件是否可见,通过当前按钮和组件对应的权限码,来和当前用户的权限码列表,进行比较,有则可见,否则不渲染

下面我们用一个项目来演示我们的整个的设计思路(后台管理系统来示例说明)

项目登录逻辑图

微信截图_20240521172704.png

登录页

<template>
    <div class="login">
        <n-form ref="formRef" :model="formData" :rules="rules" style="width:300px">
            <n-form-item label="账号" path="account" label-width="200">
                <n-input v-model:value="formData.account" placeholder="输入账号" />
            </n-form-item>
            <n-form-item label="密码" path="password">
                <n-input type="password" v-model:value="formData.password" placeholder="请输入密码" />
            </n-form-item>
            <n-form-item>
                <n-button @click="handleLogin" style="width:100%" type="primary" :loading="loading">
                    登录
                </n-button>
            </n-form-item>
        </n-form>
    </div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { FormInst } from 'naive-ui'
import { useRouter } from 'vue-router'
const router = useRouter()
const formRef = ref<FormInst | null>(null)
const formData = reactive({
    account: 'admin',
    password: '1234567896'
})
const loading = ref(false)
const rules = {
    account: {
        trigger: 'blur',
        message: '请输入账号',
        required: true
    },
    password: {
        trigger: 'blur',
        message: '请输入密码',
        required: true
    }
}
const handleLogin = async () => {
    try {
        await formRef?.value?.validate()
        loading.value = true
        setTimeout(() => {
            router.push({
                path: '/home'
            })
            loading.value = false
        }, 1500)
    } catch (error) {
        console.log('校验字段',error)
    }
}
</script>
<style lang="scss" scoped>
.login {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100vh;
    width: 100vw;
    background: white;
}
</style>

路由全局拦截(vue-router的beforeEach)

import { createWebHistory, createRouter } from 'vue-router'
import useAuthorityStore from '@/store/useAuthorityStore'
const mainPath = '/home'
const router = createRouter({
    history: createWebHistory(),
    routes: [
        {
            path: '/',
            redirect: '/login'
        },
        {
            path: '/login',
            name: 'login',
            component: () => import('@/views/login/index.vue')
        },
        {
            path: mainPath,
            name: 'home',
            component: () => import('@/views/home/index.vue'),
            children: [

            ]
        },
    ],
})
let setRoutes: undefined | (() => Promise<boolean>) = async () => {
    const authorityStore = useAuthorityStore()
    try {
        await authorityStore.setRoutes()
        authorityStore.addAsyncRoute(authorityStore.routes)
    } catch (error) {
        console.log('error', error)
        return false
    } finally {
        authorityStore.addFoundRoute()
        return true
    }
}
router.beforeEach(async (guard) => {
    if (guard.path.includes(mainPath)) {
        if (setRoutes) {
            try {
                await setRoutes()
                setRoutes = undefined
            } catch (error) {
                console.log('error', error)
                return false
            } finally {
                return guard.path
            }
        } else {
            return true
        }
    }
    return true
})
export default router

image.png

大家仔细看,可能会发现,这块的代码,我没有写,这里展开讲下这块,还是上逻辑图 image.png

asyncRoutes

import { shallowRef } from 'vue'
import { defineStore } from 'pinia'
import { useRouter } from 'vue-router'
interface Route {
    label: string,
    key: string,
    componentPath?: string,
    children?: Array<Route>
}
const useAAuthorityStore = defineStore('asyncRoutes', () => {
    const router = useRouter()
    const routes = shallowRef<Array<Route>>([])
    const codes = shallowRef(['create', 'delete', 'query', 'put', 'reset'])
    const getModules: { [key: string]: null | Record<string, () => Promise<unknown>> } = {
        cacheValue: null,
        get value() {
            if (getModules.cacheValue) {
                return getModules.cacheValue
            } else {
                getModules.cacheValue = import.meta.glob(`@/views/**/index.vue`)
                return getModules.cacheValue
            }
        }
    }
    const getRoutes = () => {
        return new Promise((resolve) => {
            setTimeout(() => {
                resolve([
                    {
                        label: '首页',
                        key: '',
                        componentPath: 'index'
                    },
                    {
                        label: '权限管理',
                        key: 'authorityManagement',
                        children: [
                            {
                                label: '菜单管理',
                                key: 'menuManagement',
                                componentPath: 'authorityManagement/menuManagement'
                            },
                            {
                                label: '接口管理',
                                key: 'interfaceManagement',
                                componentPath: 'authorityManagement/interfaceManagement'
                            },
                            {
                                label: '角色管理',
                                key: 'roleManagement',
                                componentPath: 'authorityManagement/roleManagement'
                            },
                            {
                                label: '用户管理',
                                key: 'userManagement',
                                componentPath: 'authorityManagement/userManagement'
                            }
                        ]
                    },
                    {
                        label: '顶级路由',
                        key: 'topRoute',
                        children: [
                            {
                                label: '二级路由',
                                key: 'secondRoute',
                                children: [
                                    {
                                        label: '三级路由',
                                        key: 'thirdRoute',
                                        children: [
                                            {
                                                label: '四级路由',
                                                key: 'fourthRoute'
                                            }
                                        ]
                                    }
                                ]
                            }
                        ]
                    },
                    {
                        label: '基础组件',
                        key: 'baseComponent',
                        children: [
                            {
                                label: '头像',
                                key: 'avator',
                                componentPath: 'baseComponent/avator'
                            },
                            {
                                label: '按钮',
                                key: 'button',
                                componentPath: 'baseComponent/button'
                            },
                            {
                                label: '卡片',
                                key: 'card',
                                componentPath: 'baseComponent/card'
                            },
                            {
                                label: '图标',
                                key: 'icon',
                                componentPath: 'baseComponent/icon'
                            },
                            {
                                label: '标签',
                                key: 'tag',
                                componentPath: 'baseComponent/tag'
                            },
                            {
                                label: '水印',
                                key: 'waterMark',
                                componentPath: 'baseComponent/waterMark'
                            },
                            {
                                label: '轮播',
                                key: 'carousel',
                                componentPath: 'baseComponent/carousel'
                            },
                        ]
                    },
                    {
                        label: '数据组件',
                        key: 'dataComponent',
                        children: [
                            {
                                label: '输入框',
                                key: 'input',
                                componentPath: 'dataComponent/input'
                            },
                            {
                                label: '复选框',
                                key: 'checkbox',
                                componentPath: 'dataComponent/checkbox'
                            },
                            {
                                label: '单选框',
                                key: 'radio',
                                componentPath: 'dataComponent/radio'
                            },
                            {
                                label: '日期选择器',
                                key: 'datePicker',
                                componentPath: 'dataComponent/datePicker'
                            },
                            {
                                label: '表单',
                                key: 'form',
                                componentPath: 'dataComponent/form'
                            },
                            {
                                label: '表格',
                                key: 'table',
                                componentPath: 'dataComponent/table'
                            }, {
                                label: '上传',
                                key: 'upload',
                                componentPath: 'dataComponent/upload'
                            }
                        ]
                    }
                ])
            }, 300)
        })
    }
    const setRoutes = async () => {
        try {
            const res = await getRoutes()
            routes.value = res as Array<Route>
        } catch (error) {
            console.log('获取路由数据错误', error)
        }
    }
    const addFoundRoute = () => {
        router.addRoute('home', {
            path: `/home:afterUser(.*)`,
            component: import('@/views/found/index.vue')
        })
    }
    const addAsyncRoute = (value: Array<Route>) => {
        const modules = getModules.value as Record<string, () => Promise<unknown>>
        value.forEach(item => {
            const { label, key, componentPath, children } = item
                if (children) {
                    addAsyncRoute(children)
                } else {
                    const routeOption = {
                        path: key,
                        name: key,
                        component: modules[`/src/views/${componentPath}/index.vue`] || modules['/src/views/waitDevelopComponent/index.vue'],
                        meta: {
                            title: label
                        }
                    }
                    router.addRoute('home', routeOption)
                }
        })
    }
    return {
        codes,
        routes,
        setRoutes,
        addAsyncRoute,
        addFoundRoute
    }
})
export default useAAuthorityStore

这里主要说明下,为啥要有getModuldesaddFoundRoute

getModuldes

正常我们动态加载组件可以直接使用import('componenyPath')即可,但是这里如果传入动态的,它只能解析一层文件夹,但是我们可是需要支持多层的路由(对应的文件夹也是一层嵌套一层的),所以要使用import.meta.glob全量加载views下面的组件,然后再通过componentPath去匹配拿到对应的组件,并且我们也针对这个做了按需加载,只有调用的时候,才会去加载,所以不用担心,会一直调用这个方法.

addFoundRoute

先说下,为啥要在这里定义,有人说,直接再router.ts定义不就好了吗?这个我们之所以这样做,是因为,路由的它的匹配机制,因为我们的是动态路由,如果我们一开始就定义404,后续所有的组件都有优先匹配404,所以我们要把它放在这里,动态注册路由结束后,再去追加,这样因为前面已经匹配到,就不会走到这里。

主路由页面

<template>
    <div class="home">
        <div class="sidebar">
            <sidebarCom @refreshView="handleRefreshView"></sidebarCom>
        </div>
        <div class="header">
            <n-dialog-provider>
                <headerCom></headerCom>
            </n-dialog-provider>
        </div>
        <div class="content">
            <router-view v-if="isRefresh"></router-view>
        </div>
    </div>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import sidebarCom from './components/sidebarCom/index.vue'
import headerCom from './components/headerCom/index.vue'
const isRefresh = ref(true)
const handleRefreshView = () => {
    isRefresh.value = false
    nextTick(() => {
        isRefresh.value = true
    })
}
</script>
<style lang="scss" scoped>
.home {
    display: grid;
    grid-template-columns: 200px 1fr;
    grid-template-rows: 80px 1fr;
    width: 100vw;
    height: 100vh;
}
.sidebar {
    grid-row: 1/3;
}
.sidebar,
.header {
    background: #fff;
}
.content {
    background: #efeff5;
    padding: 10px 15px;
    box-sizing: border-box;
}
</style>

侧边栏组件

<template>
    <div>
        <n-scrollbar style="max-height: 100vh">
            <n-menu v-model:value="activeKey" mode="vertical" :indent="20" :options="transformMenus" />
        </n-scrollbar>
    </div>
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import useAuthorityStore from '@/store/useAuthorityStore'
interface MenuOption {
    label: Function | string,
    key: string,
    children?: Array<MenuOption>
}
const emit = defineEmits(['refreshView'])
const baseUrl = '/home'
const route = useRoute()
const router = useRouter()
const authorityStore = useAuthorityStore()
const activeKey = computed(() => route.name)
const transformMenus = computed(() => setLabelBymenus(authorityStore.routes))
const setLabelBymenus = (list: Array<MenuOption>) => {
    return list.map(item => {
        const { label, key, children } = item
        const menu: MenuOption = {
            key,
            label: () => h('div', { onClick: !children ? () => handleClick(key) : null }, label as string)
        }
        children && (menu.children = setLabelBymenus(children as Array<MenuOption>))
        return menu
    })
}
const handleClick = (path: string) => {
    emit('refreshView')
    router.push({
        path: `${baseUrl}/${path}`
    })
}
</script>

大家可能会发现,有个isRefresh变量在home.vue页面,这个主要是为了刷新页面,假如我在A页面,我想刷新,再次点击即可,通过对router-view的销毁和重构建来做(目前没有找到更好的方法去替代)。

顶部组件

<template>
    <div class="header-com">
        <n-breadcrumb>
            <n-breadcrumb-item>{{ route.meta.title }}</n-breadcrumb-item>
        </n-breadcrumb>
        <n-button type="primary" size="medium" @click="handleLogout">退出</n-button>
    </div>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { useDialog } from 'naive-ui'
const router = useRouter()
const route = useRoute()
const dialog = useDialog()
const handleLogout = () => {
    dialog.success({
        title: '操作框',
        content: '确定退出吗',
        positiveText: '确定',
        negativeText: '取消',
        maskClosable: false,
        onPositiveClick() {
            router.push({ path: '/' }).finally(() => {
                setTimeout(() => {
                    location.reload()
                }, 0)
            })
        }
    })
}
</script>
<style lang="scss" scoped>
.header-com {
    height: 100%;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 10px 15px;
    box-sizing: border-box;
}
</style>

至此路由设计就结束了

来讲讲按钮/组件级别的权限,怎么设计

  1. 鉴权组件,通过传入的权限码和用户实际的权限码列表,作比较,如果有,则渲染,否则不渲染,该组件通过默认插槽的方式,暴露给需要鉴权的组件使用。
  2. 采用自定义指令来判断(动态修改dom的style的display)。

1.鉴权组件

<template>
    <div v-if="isRender">
        <slot></slot>
    </div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import useAuthorityStore from '@/store/useAuthorityStore'
const props = defineProps({
    code: {
        type: String,
        required: true
    }
})
const store = useAuthorityStore()
const isRender = computed(() => store.codes.includes(props.code))
</script>
<style scoped></style>

用法示例

<template>
    <div class="index">
        <!-- 组件方式推荐 -->
        <authority :code="code">
            <n-button type="primary">测试权限按钮(基于组件)</n-button>
        </authority>
        <div>
            <n-button size="medium" @click="handleChangeCode">修改权限</n-button>
        </div>
    </div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import authority from '@/components/authority/index.vue'
const code = ref('reset')
const handleChangeCode = () => {
    code.value = code.value === 'reset' ? '' : 'reset'
}
</script>
<style lang="scss" scoped>
.index {
    display: flex;
    .n-button {
        margin-left: 20px;
    }
}
</style>

2.自定义指令的方式

import useAuthorityStore from '@/store/useAuthorityStore'
let codes: Array<string> | null
const updateEl = (el: HTMLElement, code: string) => {
    if (!codes) {
        const store = useAuthorityStore()
        codes = store.codes
    }
    if (codes) {
        el.style.display = codes.includes(code) ? '' : 'none'
    }
}
export default {
    created(el: HTMLElement, { value }: { value: string }) {
        return updateEl(el, value)
    },
    updated(el: HTMLElement, { value }: { value: string }) {
        return updateEl(el, value)
    },
}

实际项目updated这个方法,其实是不需要的,这里仅仅是做演示,才加的,因为正常用户的权限,在一开始渲染就知道,不可能会在当前页面随时变更的

用法示例 v-permission="code"

<template>
    <div class="index">
        <!-- 指令方式 -->
        <n-button type="primary" v-permission="code">测试权限按钮(基于自定义指令)</n-button>
        <div>
            <n-button size="medium" @click="handleChangeCode">修改权限</n-button>
        </div>
    </div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const code = ref('reset')
const handleChangeCode = () => {
    code.value = code.value === 'reset' ? '' : 'reset'
}
</script>
<style lang="scss" scoped>
.index {
    display: flex;
    .n-button {
        margin-left: 20px;
    }
}
</style>

至此前端的权限设计就结束了

总结

终于写完了,为了弄这个,我还专门去写了这个示例项目,之前在vue2版本的时候,做过,现在用vue3来重新写还是有很多不一样的,比如import()加载这块,还有beforeEach拦截这块改的挺大的,不过总算写完了 哈哈。