导航菜单功能

后台权限

参考: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登录
}

前台菜单项

数据设计

image

从上图可以看出要为菜单项构造一个层级结构,简化设计,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']

image

前台解析渲染

返回的数据是列表,从中直接提取数据循环即可

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>

image

嵌套路由

欢迎页

新建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的选择器