猛犸系统-导航菜单功能
导航菜单功能
后台权限
参考:https://www.django-rest-framework.org/api-guide/permissions/
REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'utils.exceptions.global_exception_handler',
'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework_simplejwt.authentication.JWTAuthentication'],
'DEFAULT_PERMISSION_CLASSES': ['rest_framework.permissions.IsAuthenticated',] # 全局,要求被认证,也就是登录成功
}
主路由
from django.urls import path
from rest_framework_simplejwt.views import (
TokenObtainPairView, # 没有提供CRUD,提供了queryset、serializer_class
TokenRefreshView,
)
from django.urls import include
tb = TokenObtainPairView.as_view()
urlpatterns = [
# path('admin/', admin.site.urls),
path('token/', tb, name='token_obtain_pair'),
path('token/refresh/', tb, name='token_refresh'),
# path('login/',include("user.urls"))
path('login/', tb, name='login'),
path('users/',include('user.urls')) # 二级路由到users/
]
需求:
只要是认证得用户都可以看到菜单项,管理员无视权限能看到所有项,普通用户能看到部分项(以后可以按照用户、组分配)。
后台构建一个视图类,菜单项功能得权限要求就是被认证的、管理员
user二级路由
from django.urls import path
from .views import menulist_view
urlpatterns = [
path('menulist/',menulist_view)
]
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework.permissions import IsAuthenticated, IsAdminUser
@api_view(['GET'])
@permission_classes([IsAuthenticated, IsAdminUser]) # 覆盖全局配置
def menulist_view(request:Request):
user = request.user
auth = request.auth
print(user, auth)
print(dir(user)) # .is_superuser
return Response({'test': 300})
没有token访问会抛出 rest_framework.exceptions.NotAuthenticated
,为异常模块定义一个新的映射
utils/exceptions.py
class NotAuthenticated(MagBaseException):
code = 3
message = '未登录,请重新登录'
# 内部异常暴露细节
exc_map = {
'AuthenticationFailed': InvalidUsernameOrPassword,
'InvalidToken': InvalidToken, # token过期
'NotAuthenticated': NotAuthenticated, # 无token登录
}
前台菜单项
数据设计
从上图可以看出要为菜单项构造一个层级结构,简化设计,2层就可以了
菜单项有层级,每一项可以有id(对应index)、name项名、path跳转路径(可以为null)、children子项列表。例如:
[
{
"id": 1,
"name": "用户管理",
"path": null,
"children": [
{
"id": 101,
"name": "用户列表",
"path": "/users",
"children": []
},
{
"id": 102,
"name": "角色列表",
"path": "/users/roles",
"children": []
},
{
"id": 103,
"name": "权限列表",
"path": "/users/perms",
"children": []
}
]
}
]
后端菜单生成
由于每一个菜单项都是一个字典,所以菜单项类直接就是dict得子类
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet,ReadOnlyModelViewSet
from rest_framework.decorators import api_view,permission_classes
from rest_framework.permissions import IsAuthenticated , IsAdminUser
@api_view(['GET'])
@permission_classes([IsAuthenticated, IsAdminUser]) # 覆盖全局配置
def menulist_view(request:Request):
user = request.user
auth = request.auth
print(user,auth)
print(dir(user)) # .is_superuser
menulist = []
if request.user.is_superuser: #用户管理必须管理员操作
item = MenuItem(1,'用户管理')
item.append(MenuItem(101, '用户列表', '/users'))
item.append(MenuItem(102, '角色列表', '/users/roles'))
item.append(MenuItem(103, '权限列表', '/users/perms'))
zichan = MenuItem(2,'资产管理')
zichan.append(MenuItem(201,'资产列表','/users/asset'))
zichan.append(MenuItem(202,'管理用户','/admin-user'))
menulist.append(item)
menulist.append(zichan)
print(menulist)
return Response(menulist)
class MenuItem(dict):
def __init__(self,id,name,path=None):
super().__init__()
self['id'] = id
self['name'] = name
self['path'] = path
self['children'] = []
def append(self,item):
self['children'].append(item)
# def __getattr__(self, item):
# return self[item]
# @property
# def children(self):
# return self['children']
前台解析渲染
返回的数据是列表,从中直接提取数据循环即可
v-for
<el-menu background-color="#123" text-color="#fff" active-textcolor="#ffd04b">
<el-submenu v-for="item in menulist" :index="item.id + ''" :key="item.id">
<template slot="title"><i class="el-icon-menu"></i><span slot="title">{{ item.itemName }}</span></template>
<el-menu-item v-for="subItem in item.children" :index="subItem.id + ''":key="subItem.id">
<template slot="title"><i class="el-icon-menu"></i><span slot="title">{{ subItem.itemName }}</span></template>
</el-menu-item>
</el-submenu>
</el-menu>
v-for 迭代是需要v-bind:key ,key唯一就行,不需要是字符串,但index是el-submenu属性,必须是字符串
HomeViev.vue
<template>
<el-container>
<el-header>
<div class="logo">
<img src="../assets/logo.png" alt="logo">
<div class="title"><b>中图科信运维管理平台</b></div>
<i :class="isCollapse?'el-icon-s-unfold':'el-icon-s-fold'" @click="isCollapse = !isCollapse"></i>
</div>
<div class="info">
<el-button class="quit" type="primary" @click="logout">退出</el-button>
</div>
</el-header>
<el-container>
<el-aside :width="isCollapse?'64px':'240px'">
<el-menu
default-active="2"
background-color="#123"
text-color="#fff"
active-text-color="#ffd04b"
:collapse="isCollapse"
:collapse-transition="true"
:router="true"
>
<el-submenu :index="item.id + ''" v-for="item in menuList" :key="item.id">
<template slot="title">
<i class="el-icon-location"></i>
<span>{{ item.name }}</span>
</template>
<el-menu-item :index="sub.path" v-for="sub in item.children" :key="sub.id">{{ sub.name }}</el-menu-item>
</el-submenu>
<el-menu-item index="4">
<i class="el-icon-setting"></i>
<span slot="title">导航四</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view></router-view>
</el-main>
</el-container>
</el-container>
</template>
<script>
export default {
created () {
this.getMenuList()
},
data () {
return {
menuList: [],
isCollapse: false
}
},
methods: {
logout () {
window.localStorage.removeItem('token')
this.$router.push('/login')
},
async getMenuList () {
const response = await this.$http.get('users/menulist/')
console.log(response)
if (response.code) {
return this.$message.error(response.message)
}
this.menuList = response.data
console.log('——————', response.data)
}
}
}
</script>
<style lang="less" scoped>
.el-container {
height: 100%;
}
.el-header {
display: flex;
justify-content: space-between;
.logo {
display: flex;
img {
width: 30px;
height: 30px;
margin-top: 15px;
}
.title {
font-size: 24px;
margin-left: 5px;
margin-top: 15px;
}
i {
font-size: 30px;
margin-top: 15px;
}
}
}
.el-aside {
background-color: #112233;
}
.el-button {
margin-top: 10px;
}
.el-main {
background-color: #f0f2f4;
}
.el-menu {
border-right: none;
}
</style>
折叠菜单
参考 https://element.eleme.cn/#/zh-CN/component/menu#zhe-die
为Menu组件增加一个绑定属性 :collapse="isCollapse"
才能折叠,是否开启动画 :collapse-transition="true"
HomeView.vue
<template>
<el-container>
<el-header>
<div class="logo">
<img src="../assets/logo.png" alt="logo">
<div class="title"><b>中图科信运维管理平台</b></div>
<i :class="isCollapse?'el-icon-s-unfold':'el-icon-s-fold'" @click="isCollapse = !isCollapse"></i>
</div>
<div class="info">
<el-button class="quit" type="primary" @click="logout">退出</el-button>
</div>
</el-header>
<el-container>
<el-aside :width="isCollapse?'64px':'240px'">
<el-menu
default-active="2"
background-color="#123"
text-color="#fff"
active-text-color="#ffd04b"
:collapse="isCollapse"
:collapse-transition="true"
:router="true"
>
<el-submenu :index="item.id + ''" v-for="item in menuList" :key="item.id">
<template slot="title">
<i class="el-icon-location"></i>
<span>{{ item.name }}</span>
</template>
<el-menu-item :index="sub.path" v-for="sub in item.children" :key="sub.id">{{ sub.name }}</el-menu-item>
</el-submenu>
<el-menu-item index="4">
<i class="el-icon-setting"></i>
<span slot="title">导航四</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view></router-view>
</el-main>
</el-container>
</el-container>
</template>
<script>
export default {
created () {
this.getMenuList()
},
data () {
return {
menuList: [],
isCollapse: false
}
},
methods: {
logout () {
window.localStorage.removeItem('token')
this.$router.push('/login')
},
async getMenuList () {
const response = await this.$http.get('users/menulist/')
console.log(response)
if (response.code) {
return this.$message.error(response.message)
}
this.menuList = response.data
console.log('——————', response.data)
}
}
}
</script>
<style lang="less" scoped>
.el-container {
height: 100%;
}
.el-header {
display: flex;
justify-content: space-between;
.logo {
display: flex;
img {
width: 30px;
height: 30px;
margin-top: 15px;
}
.title {
font-size: 24px;
margin-left: 5px;
margin-top: 15px;
}
i {
font-size: 30px;
margin-top: 15px;
}
}
}
.el-aside {
background-color: #112233;
}
.el-button {
margin-top: 10px;
}
.el-main {
background-color: #f0f2f4;
}
.el-menu {
border-right: none;
}
</style>
嵌套路由
欢迎页
新建src/views/WelcomeVue.vue
<template>
<div>
<h3>欢迎访问马哥教育猛犸运维平台系统 by wayne</h3>
</div>
</template>
<script>
export default {
}
</script>
<style>
</style>
嵌套路由
子路由参考 https://router.vuejs.org/zh/guide/essentials/nested-routes.html
/user/foo/profile /user/foo/posts
+------------------+ +-----------------+
| User | | User |
| +--------------+ | | +-------------+ |
| | Profile | | +------------> | | Posts | |
| | | | | | | |
| +--------------+ | | +-------------+ |
+------------------+ +-----------------+
也就是说,前端路径发生了变化,User组件不变,其中得子组件发生了切换
src/router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
// import HomeView from '../views/HomeView.vue'
import Login from '@/views/LoginView.vue'
import WelcomeView from '@/views/WelcomeView.vue'
import HomeVIew from '@/views/HomeVIew.vue'
import RoleView from '@/views/RoleView.vue'
import AssetView from '@/views/AssetView.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/login',
component: Login
},
{
path: '/',
redirect: '/login'
},
{
path: '/home',
component: HomeVIew,
redirect: '/users', // 访问/home 重定向到/welcome,进入子路由
children: [
{
path: '/users',
component: WelcomeView
},
{
path: '/users/roles',
component: RoleView
},
{
path: '/users/asset',
component: AssetView
}
]
}
]
const router = new VueRouter({
routes
})
router.beforeEach((to, from, next) => {
console.log(to)
console.log(from)
if (to.path === '/login') {
next()
} else {
const token = window.localStorage.getItem('token')
console.log(token)
if (!token) {
next('/login')
} else {
next()
}
}
})
export default router
HomeView.vue中
- 在el-main中插入
,作为子路由显示的地方 - 在el-menu中 :router=”true” ,开启路由
- 一旦开启路由后,点击el-menu-item菜单项就会按照index跳转。所以,把el-menu-item的index 改为
<el-menu-item :index="c.path" v-for="c in item.children" :key="c.id">
<template>
<el-container>
<el-header>
<div class="logo">
<img src="../assets/logo.png" alt="logo">
<div class="title"><b>中图科信运维管理平台</b></div>
<i :class="isCollapse?'el-icon-s-unfold':'el-icon-s-fold'" @click="isCollapse = !isCollapse"></i>
</div>
<div class="info">
<el-button class="quit" type="primary" @click="logout">退出</el-button>
</div>
</el-header>
<el-container>
<el-aside :width="isCollapse?'64px':'240px'">
<el-menu
default-active="2"
background-color="#123"
text-color="#fff"
active-text-color="#ffd04b"
:collapse="isCollapse"
:collapse-transition="true"
:router="true"
>
<el-submenu :index="item.id + ''" v-for="item in menuList" :key="item.id">
<template slot="title">
<i class="el-icon-location"></i>
<span>{{ item.name }}</span>
</template>
<el-menu-item :index="sub.path" v-for="sub in item.children" :key="sub.id">{{ sub.name }}</el-menu-item>
</el-submenu>
<el-menu-item index="4">
<i class="el-icon-setting"></i>
<span slot="title">导航四</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view></router-view>
</el-main>
</el-container>
</el-container>
</template>
<script>
export default {
created () {
this.getMenuList()
},
data () {
return {
menuList: [],
isCollapse: false
}
},
methods: {
logout () {
window.localStorage.removeItem('token')
this.$router.push('/login')
},
async getMenuList () {
const response = await this.$http.get('users/menulist/')
console.log(response)
if (response.code) {
return this.$message.error(response.message)
}
this.menuList = response.data
console.log('——————', response.data)
}
}
}
</script>
<style lang="less" scoped>
.el-container {
height: 100%;
}
.el-header {
display: flex;
justify-content: space-between;
.logo {
display: flex;
img {
width: 30px;
height: 30px;
margin-top: 15px;
}
.title {
font-size: 24px;
margin-left: 5px;
margin-top: 15px;
}
i {
font-size: 30px;
margin-top: 15px;
}
}
}
.el-aside {
background-color: #112233;
}
.el-button {
margin-top: 10px;
}
.el-main {
background-color: #f0f2f4;
}
.el-menu {
border-right: none;
}
</style>
失败重登录
访问后台资源的时候,验证了token,返回关于token的异常json,其中包含code、message。
使用axios全局响应拦截器处理。后台对异常的code做一下规定,小于100是与登录有关的异常,前端就会重登录。
后台utils/exceptions.py
from rest_framework.response import Response
from rest_framework.views import exception_handler
from rest_framework import exceptions
class MagBaseException(exceptions.APIException):
"""基类定义基本的异常"""
code = 10000 # code为0表示正常,非0表示错误
message = "非法请求" # 错误描述
@classmethod
def get_message(cls):
return {'code': cls.code, 'message': cls.message}
class InvalidUsernameOrPassword(MagBaseException):
code = 1
message = "用户名或密码错误,请重新登录"
class InvalidToken(MagBaseException):
code = 10001
message = "token过期,请重新登录"
class NotAuthenticated(MagBaseException):
code = 10002
message = "未登录,请重新登录"
# code < 100,前端清除token,跳转到登录页
exc_map = {
'AuthenticationFailed': InvalidUsernameOrPassword,
'InvalidToken' : InvalidToken, # token过期
'NotAuthenticated' : NotAuthenticated # 无token登录
}
def global_exception_handler(exc, context):
response = exception_handler(exc, context)
if response is not None:
if isinstance(exc, MagBaseException):
# 如果是MagBaseException,就是自定义类,就直接返回消息
errmsg = exc.get_message()
else:
print('——————————————————————————————————————————————————————————————————————————————',exc.__class__.__name__)
# 如果是APIException,就利用exc的类型名称映射到自定义类型
errmg = exc_map.get(exc.__class__.__name__,MagBaseException).get_message()
return Response(errmg, status=200)
return response
前端src/main.js
import Vue from 'vue'
import App from './App.vue' // 组件
import router from './router' // 静态路由
import './plugins/element.js' // 按需导入 Element UI文件
import './assets/css/main.css' // 导入全局css文件
import axios from 'axios'
import { Message } from 'element-ui'
axios.interceptors.request.use(config => {
config.headers.Authorization = 'Bearer ' + window.localStorage.getItem('token')
return config
})
// 响应拦截器,只要有错误码小于100都和登录有关,要求重新登录
axios.interceptors.response.use(function (response) {
// 对响应数据做点什么
if (response.data.code && response.data.code < 100) {
router.push('/login')
} else {
return response
}
})
axios.defaults.baseURL = '/api/v1'
Vue.prototype.$message = Message
Vue.prototype.$http = axios
Vue.config.productionTip = false
// 这是vue脚手架产生文件的入口,dujiedujiedujie:q:q
new Vue({
router, // 静态路由,前端路由
render: h => h(App) // APP就是组件
}).$mount('#app') // 挂到id 为app的标签上 #app指的是网页html中插入点,对应public/index.html的选择器