Flask Vue.js全栈开发|第16章:管理后台
Synopsis: 我们需要一个后台来管理所有已注册的用户列表、他们所发布的博客文章和评论等,为了节约时间,我只是在前端菜单栏上增加了 Admin 入口,简单的演示如何管理角色、用户、博客、评论等,并没有重新开发另一个完整的后台管理系统
本系列的最新代码将持续更新到: http://www.madmalls.com/blog/post/latest-code/
1. Admin
1.1 导航栏入口
首先需要在导航栏中增加 Admin
管理后台的入口,并且结合上一章 权限管理
,只有管理员才能看到这个入口
修改 front-end/src/components/Base/Navbar.vue
:
<ul class="navbar-nav mr-auto mt-2 mt-lg-0"> <li class="nav-item active"> <router-link to="/" class="nav-link">Home <span class="sr-only">(current)</span></router-link> </li> <li class="nav-item"> <router-link to="/ping" class="nav-link">Ping</router-link> </li> <li class="nav-item" v-if="sharedState.is_authenticated && sharedState.user_perms.includes('admin')"> <router-link to="/admin" class="nav-link">Admin</router-link> </li> </ul>
1.2 相关组件
(1)Admin.vue
创建 front-end/src/components/Admin/Admin.vue
组件:
<template> <div class="container g-pt-20"> <div class="row"> <!-- 左边菜单栏 --> <div class="col-lg-3 g-mb-50"> <aside class="g-brd-around g-brd-gray-light-v4 rounded g-px-20 g-py-30"> <h2 class="text-center g-color-primary g-font-weight-600 g-line-height-1 g-font-size-60 g-pos-rel g-mb-30">Admin</h2> <hr class="g-brd-gray-light-v4 g-my-30"> <!-- 用户头像 --> <div v-if="user" class="text-center g-pos-rel g-mb-30"> <div class="g-width-100 g-height-100 mx-auto mb-3"> <img class="img-fluid rounded-circle g-brd-around g-brd-gray-light-v4 g-pa-2" v-bind:src="user._links.avatar" v-bind:alt="user.name || user.username"> </div> <span class="d-block g-font-weight-500">{{ user.name || user.username }}</span> <router-link v-bind:to="{ path: `/user/${sharedState.user_id}` }"> <span class="u-icon-v3 u-icon-size--xs g-color-white--hover g-bg-primary--hover rounded-circle g-pos-abs g-top-0 g-right-15 g-cursor-pointer" title="Go To Your Profile" data-toggle="tooltip" data-placement="top"> <i class="icon-finance-067 u-line-icon-pro"></i> </span> </router-link> </div> <!-- End 用户头像 --> <hr class="g-brd-gray-light-v4 g-my-30"> <!-- 菜单列表 --> <ul class="list-unstyled mb-0"> <li class="g-pb-3"> <router-link v-bind:to="{ name: 'AdminRoles' }" v-bind:active-class="'active g-color-primary--active g-bg-gray-light-v5--active'" v-bind:class="isAdminRoles" class="d-block align-middle u-link-v5 g-color-text g-color-primary--hover g-bg-gray-light-v5--hover rounded g-pa-3"> <span class="u-icon-v1 g-color-gray-dark-v5 mr-2"><i class="icon-media-127 u-line-icon-pro"></i></span> Roles </router-link> </li> <li class="g-pb-3"> <router-link v-bind:to="{ name: 'AdminUsers' }" v-bind:active-class="'active g-color-primary--active g-bg-gray-light-v5--active'" v-bind:class="isAdminUsers" class="d-block align-middle u-link-v5 g-color-text g-color-primary--hover g-bg-gray-light-v5--hover rounded g-pa-3"> <span class="u-icon-v1 g-color-gray-dark-v5 mr-2"><i class="icon-finance-067 u-line-icon-pro"></i></span> Users </router-link> </li> <li class="g-pb-3"> <router-link v-bind:to="{ name: 'AdminPosts' }" v-bind:active-class="'active g-color-primary--active g-bg-gray-light-v5--active'" class="d-block align-middle u-link-v5 g-color-text g-color-primary--hover g-bg-gray-light-v5--hover rounded g-pa-3"> <span class="u-icon-v1 g-color-gray-dark-v5 mr-2"><i class="icon-education-008 u-line-icon-pro"></i></span> Posts </router-link> </li> <li class="g-py-3"> <router-link v-bind:to="{ name: 'AdminComments' }" v-bind:active-class="'active g-color-primary--active g-bg-gray-light-v5--active'" class="d-block align-middle u-link-v5 g-color-text g-color-primary--hover g-bg-gray-light-v5--hover rounded g-pa-3"> <span class="u-icon-v1 g-color-gray-dark-v5 mr-2"><i class="icon-finance-206 u-line-icon-pro"></i></span> Comments </router-link> </li> </ul> <!-- End 菜单列表 --> </aside> </div> <!-- End 左边菜单栏 --> <!-- 右边子路由匹配后,显示对应的组件 --> <div class="col-lg-9 g-mb-50"> <div class="rounded g-brd-around g-brd-gray-light-v4 g-overflow-x-scroll g-overflow-x-visible--lg g-pa-30"> <router-view></router-view> </div> </div> <!-- End 嵌套路由 --> </div> </div> </template> <script> import store from '../../store' export default { name: 'Admin', // this is the name of the component data () { return { sharedState: store.state, user: '' } }, computed: { isAdminRoles: function () { const tabs = ['AdminRoles', 'AdminUsers', 'AdminEditUser', 'AdminPosts', 'AdminComments'] if (tabs.indexOf(this.$route.name) == -1) { return 'active g-color-primary--active g-bg-gray-light-v5--active' } else { return '' } }, isAdminUsers: function () { if (this.$route.name == 'AdminEditUser') { return 'active g-color-primary--active g-bg-gray-light-v5--active' } else { return '' } } }, methods: { getUser (id) { const path = `/api/users/${id}` this.$axios.get(path) .then((response) => { // handle success this.user = response.data }) .catch((error) => { // handle error console.error(error) }) } }, created () { const user_id = this.sharedState.user_id this.getUser(user_id) // tooltip $(document).ready(function(){ $('[data-toggle="tooltip"]').tooltip(); }) } } </script>
(2)Roles.vue
创建 front-end/src/components/Admin/Roles.vue
组件:
<template> <div> 角色列表 </div> </template> <script> export default { name: 'Roles', // this is the name of the component } </script>
(3)Users.vue
创建 front-end/src/components/Admin/Users.vue
组件:
<template> <div> 用户列表 </div> </template> <script> export default { name: 'Users', // this is the name of the component } </script>
(4)Posts.vue
创建 front-end/src/components/Admin/Posts.vue
组件:
<template> <div> 博客列表 </div> </template> <script> export default { name: 'Posts', // this is the name of the component } </script>
(5)Comments.vue
创建 front-end/src/components/Admin/Comments.vue
组件:
<template> <div> 评论列表 </div> </template> <script> export default { name: 'Comments', // this is the name of the component } </script>
1.3 前端路由
最后,增加前端路由,修改 front-end/src/router/index.js
:
... // 管理后台 import Admin from '@/components/Admin/Admin.vue' import AdminRoles from '@/components/Admin/Roles.vue' import AdminUsers from '@/components/Admin/Users.vue' import AdminPosts from '@/components/Admin/Posts.vue' import AdminComments from '@/components/Admin/Comments.vue' ... const router = new Router({ // mode: 'history', scrollBehavior, // 不用这个,在需要跳转的改用 vue-scrollto routes: [ { path: '/', name: 'Home', component: Home }, ... { // 管理后台 path: '/admin', component: Admin, children: [ { path: '', component: AdminRoles }, { path: 'roles', name: 'AdminRoles', component: AdminRoles }, { path: 'users', name: 'AdminUsers', component: AdminUsers }, { path: 'posts', name: 'AdminPosts', component: AdminPosts }, { path: 'comments', name: 'AdminComments', component: AdminComments } ], meta: { requiresAuth: true, requiresAdmin: true } }, { path: '/ping', name: 'Ping', component: Ping } ] }) router.beforeEach((to, from, next) => { const token = window.localStorage.getItem('madblog-token') if (token) { var payload = JSON.parse(atob(token.split('.')[1])) var user_perms = payload.permissions.split(",") } if (to.matched.some(record => record.meta.requiresAuth) && (!token || token === null)) { // 1. 用户未登录,但想访问需要认证的相关路由时,跳转到 登录 页 Vue.toasted.show('Please log in to access this page.', { icon: 'fingerprint' }) next({ path: '/login', query: { redirect: to.fullPath } }) } else if (token && !payload.confirmed && to.name != 'Unconfirmed') { // 2. 用户刚注册,但是还没确认邮箱地址时,全部跳转到 认证提示 页面 Vue.toasted.show('Please confirm your accout to access this page.', { icon: 'fingerprint' }) next({ path: '/unconfirmed', query: { redirect: to.fullPath } }) } else if (token && payload.confirmed && to.name == 'Unconfirmed') { // 3. 用户账户已确认,但又去访问 认证提示 页面时不让他过去 next({ path: '/' }) } else if (token && (to.name == 'Login' || to.name == 'Register' || to.name == 'ResetPasswordRequest' || to.name == 'ResetPassword')) { // 4. 用户已登录,但又去访问 登录/注册/请求重置密码/重置密码 页面时不让他过去 next({ path: from.fullPath }) } else if (to.matched.some(record => record.meta.requiresAdmin) && token && !user_perms.includes('admin')) { // 5. 普通用户想在浏览器地址中直接访问 /admin ,提示他没有权限,并跳转到首页 Vue.toasted.error('403: Forbidden', { icon: 'fingerprint' }) next({ path: '/' }) } else if (to.matched.length === 0) { // 6. 要前往的路由不存在时 Vue.toasted.error('404: Not Found', { icon: 'fingerprint' }) if (from.name) { next({ name: from.name }) } else { next({ path: '/' }) } } else { // 7. 正常路由出口 next() } }) export default router
效果图如下:
注意: 如果用户没有
admin
权限,他直接访问http://localhost:8080/#/admin
会报403: Forbidden
错误,并被重定向到首页!
2. 角色管理
2.1 角色对象转换成 JSON
修改 back-end/app/models.py
:
class Role(PaginatedAPIMixin, db.Model): ... def to_dict(self): data = { 'id': self.id, 'slug': self.slug, 'name': self.name, 'default': self.default, 'permissions': self.permissions, '_links': { 'self': url_for('api.get_role', id=self.id) } } return data def from_dict(self, data): for field in ['slug', 'name', 'permissions']: if field in data: setattr(self, field, data[field])
2.2 角色列表
创建 back-end/app/api/roles.py
文件:
from flask import request, url_for, jsonify from app.api import bp from app.api.auth import token_auth from app.api.errors import bad_request from app.extensions import db from app.models import Role from app.utils.decorator import admin_required @bp.route('/roles', methods=['GET']) @token_auth.login_required @admin_required def get_roles(): '''返回所有角色的集合''' page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 10, type=int), 100) data = Role.to_collection_dict(Role.query, page, per_page, 'api.get_roles') return jsonify(data)
修改 front-end/src/components/Admin/Roles.vue
:
<template> <div> <!-- Striped Rows --> <div class="card g-brd-teal rounded-0 g-mb-30"> <h3 class="card-header g-bg-teal g-brd-transparent g-color-white g-font-size-16 rounded-0 mb-0"> <i class="fa fa-edit g-mr-5"></i> 角色列表 </h3> <div class="table-responsive"> <table class="table table-striped u-table--v1 mb-0"> <thead> <tr> <th>#</th> <th>Slug</th> <th class="hidden-sm">Name</th> <th>Permissions</th> <th>Action</th> </tr> </thead> <tbody> <tr v-for="(role, index) in roles.items" v-bind:key="index"> <th scope="row">{{ index+1 }}</th> <td>{{ role.slug }}</td> <td class="hidden-sm">{{ role.name }}</td> <td>{{ role.permissions }}</td> <td> 操作 </td> </tr> </tbody> </table> </div> </div> <!-- End Striped Rows --> <!-- Pagination #04 --> <div v-if="roles"> <pagination v-bind:cur-page="roles._meta.page" v-bind:per-page="roles._meta.per_page" v-bind:total-pages="roles._meta.total_pages"> </pagination> </div> <!-- End Pagination #04 --> </div> </template> <script> import Pagination from '../Base/Pagination' export default { name: 'Roles', // this is the name of the component components: { Pagination }, data () { return { roles: '' } }, methods: { getRoles () { let page = 1 let per_page = 10 if (typeof this.$route.query.page != 'undefined') { page = this.$route.query.page } if (typeof this.$route.query.per_page != 'undefined') { per_page = this.$route.query.per_page } const path = `/api/roles/?page=${page}&per_page=${per_page}` this.$axios.get(path) .then((response) => { // handle success this.roles = response.data }) .catch((error) => { // handle error console.error(error) }) } }, created () { this.getRoles() }, // 当路由变化后(比如变更查询参数 page 和 per_page)重新加载数据 beforeRouteUpdate (to, from, next) { next() this.getRoles() } } </script>
2.3 添加角色
(1)后端 API
修改 back-end/app/api/roles.py
文件:
@bp.route('/roles/perms', methods=['GET']) def get_perms(): '''获取所有Permissions''' data = [ {'name': 'FOLLOW', 'dec': 1}, {'name': 'COMMENT', 'dec': 2}, {'name': 'WRITE', 'dec': 4}, {'name': 'ADMIN', 'dec': 128} ] return jsonify(data) @bp.route('/roles', methods=['POST']) @token_auth.login_required @admin_required def create_role(): '''注册一个新角色''' data = request.get_json() if not data: return bad_request('You must post JSON data.') message = {} if 'slug' not in data or not data.get('slug', None).strip(): message['slug'] = 'Please provide a valid slug.' if 'name' not in data or not data.get('name', None).strip(): message['name'] = 'Please provide a valid name.' if Role.query.filter_by(slug=data.get('slug', None)).first(): message['slug'] = 'Please use a different slug.' if message: return bad_request(message) permissions = 0 for perm in data.get('permissions', 0): permissions += perm data['permissions'] = permissions role = Role() role.from_dict(data) db.session.add(role) db.session.commit() response = jsonify(role.to_dict()) response.status_code = 201 # HTTP协议要求201响应包含一个值为新资源URL的Location头部 response.headers['Location'] = url_for('api.get_role', id=role.id) return response
(2)添加角色的表单
我们要添加新的角色,需要包含创建新角色的表单,所以创建 front-end/src/components/Admin/AddRole.vue
:
<template> <div> <h1>Add Role</h1> <form @submit.prevent="onSubmit"> <div class="form-group" v-bind:class="{'u-has-error-v1': roleForm.slugError}"> <label for="slug">Slug</label> <input type="text" v-model="roleForm.slug" class="form-control" id="slug" placeholder=""> <small class="form-control-feedback" v-show="roleForm.slugError">{{ roleForm.slugError }}</small> </div> <div class="form-group" v-bind:class="{'u-has-error-v1': roleForm.nameError}"> <label for="name">Name</label> <input type="text" v-model="roleForm.name" class="form-control" id="name" placeholder=""> <small class="form-control-feedback" v-show="roleForm.nameError">{{ roleForm.nameError }}</small> </div> <div class="form-group"> <label for="permissions">Permissions</label> <div> <!-- Inline Checkboxes --> <div v-for="(perm, index) in perms" v-bind:key="index" class="form-check form-check-inline mb-0"> <label class="form-check-label mr-2"> <input class="form-check-input mr-1" type="checkbox" v-bind:id="perm.dec" v-bind:value="perm.dec" v-model="checkPerms">{{ perm.name }} </label> </div> <!-- End Inline Checkboxes --> </div> </div> <button type="submit" class="btn btn-primary">Submit</button> </form> </div> </template> <script> import store from '../../store' export default { name: 'AddRole', //this is the name of the component data () { return { sharedState: store.state, roleForm: { slug: '', name: '', permissions: [], errors: 0, // 表单是否在前端验证通过,0 表示没有错误,验证通过 slugError: null, nameError: null }, perms: null, checkPerms: [] } }, methods: { getPerms () { const path = `/api/roles/perms` this.$axios
0 条评论
评论者的用户名
评论时间暂时还没有评论.