代码和部署地址
从前端的角度来做,需要考虑两个方面
- 路由怎么设计
- 按钮/组件级别的权限,怎么设计
路由设计
先来理解这张图,其实我们后续所有的设计都是基于这个思路来实现的
理解完这张图,我们再回到我们的路由设计思路
我们的路由设计很简单,就是两层路由,一层主路由就是main,另外一层就是它的children路由,这时候可能有人会说那嵌套路由呢?其实我们从上图可以发现,嵌套子路由的概念对应的页面就是侧边栏导航这块,所谓嵌套,其实只是用户看到的是多层级导航,但实际我们开发并不需要完全按照这种来设计路由。我们仅仅在拿到数据后,给他构造出这种tree树结构就行。这样就可以规避掉,后续新增业务模块,模块设计还要考虑嵌套路由的问题。(一劳永逸)
按钮/组件级别的权限,怎么设计
这个本质上就是当前的按钮和组件是否可见,通过当前按钮和组件对应的权限码,来和当前用户的权限码列表,进行比较,有则可见,否则不渲染
下面我们用一个项目来演示我们的整个的设计思路(后台管理系统来示例说明)
项目登录逻辑图
登录页
<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
大家仔细看,可能会发现,这块的代码,我没有写,这里展开讲下这块,还是上逻辑图
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
这里主要说明下,为啥要有getModuldes和addFoundRoute
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>
至此路由设计就结束了
来讲讲按钮/组件级别的权限,怎么设计
- 鉴权组件,通过传入的权限码和用户实际的权限码列表,作比较,如果有,则渲染,否则不渲染,该组件通过默认插槽的方式,暴露给需要鉴权的组件使用。
- 采用自定义指令来判断(动态修改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拦截这块改的挺大的,不过总算写完了 哈哈。