Flask Vue.js全栈开发|第6章:博客文章CURD与Markdown

  • 原创
  • Madman
  • /
  • 2018-11-09 23:29
  • /
  • 0
  • 78 次阅读

flask vuejs 全栈开发-min.png

Synopsis: 介绍了 SQLAlchemy 一对多关系以及如何实现级联删除,Post API 设计跟 User 基本类似。前端要支持 Markdown 的话,首先需要给用户提供一个编辑器,这里使用 bootstrap-markdown 插件;渲染也由前端完成,使用 vue-markdown,代码语法高亮使用 highlight.js 插件。博客 CURD 的实现,修改时使用 vue-sweetalert2 弹出确认框,分页栏的生成请查看代码

代码已上传到 https://github.com/wangy8961/flask-vuejs-madblog/tree/v0.6 ,欢迎star

1. 数据库

1.1 声明模型

修改 back-end/app/models.py,增加 Post 数据模型:

class Post(PaginatedAPIMixin, db.Model):
    __tablename__ = 'posts'
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(255))
    summary = db.Column(db.Text)
    body = db.Column(db.Text)
    timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
    views = db.Column(db.Integer, default=0)

    def __repr__(self):
        return '<Post {}>'.format(self.title)

1.2 一对多(one-to-many)关系

每个用户可以发布多篇博客,User 与 Post 是一对多关系,示意图如下:

1 User与Post一对多

修改 back-end/app/models.py,在 User 类中增加:

class User(PaginatedAPIMixin, db.Model):
    ...
    # 反向引用,直接查询出当前用户的所有博客文章; 同时,Post实例中会有 author 属性
    # cascade 用于级联删除,当删除user时,该user下面的所有posts都会被级联删除
    posts = db.relationship('Post', backref='author', lazy='dynamic',
                            cascade='all, delete-orphan')

在 Post 类中增加:

class Post(PaginatedAPIMixin, db.Model):
    ...
    # 外键, 直接操纵数据库当user下面有posts时不允许删除user,下面仅仅是 ORM-level “delete” cascade
    # db.ForeignKey('users.id', ondelete='CASCADE') 会同时在数据库中指定 FOREIGN KEY level “ON DELETE” cascade
    author_id = db.Column(db.Integer, db.ForeignKey('users.id'))

迁移数据库:

(venv) D:\python-code\flask-vuejs-madblog\back-end>flask db migrate -m "add posts table"
(venv) D:\python-code\flask-vuejs-madblog\back-end>flask db upgrade

参数说明:

db.relationship()posts 属性指向 Post 类并加载多篇博客,实现一对多关系;更多关系 API 请参考: https://docs.sqlalchemy.org/en/latest/orm/relationship_api.html

backref='author' 会在 Post 类上声明一个新的属性 author,即可以使用 post.author 来获取该博客的作者对象

lazy 决定了 SQLAlchemy 什么时候从数据库中加载数据:

  • select: (默认值)SQLAlchemy 会使用一个标准的 select 语句必要时一次性加载数据,即 user.posts 会直接返回包含该用户的所有博客对象的列表
(venv) D:\python-code\flask-vuejs-madblog\back-end>flask shell
>>> user = User.query.filter_by(username='wangy8961').first()
>>> user
<User wangy8961>
>>> user.posts
[<Post 1>, <Post 2>, <Post 3>]
  • joined: 告诉 SQLAlchemy 使用 JOIN 语句作为父级在同一查询中来加载关系
  • subquery: 类似 joined ,但是 SQLAlchemy 会使用子查询
  • dynamic: 在有多条数据的时候是特别有用,它不是直接加载这些数据,SQLAlchemy 会返回一个查询对象 BaseQuery,在加载数据前您可以过滤(提取)它们
(venv) D:\python-code\flask-vuejs-madblog\back-end>flask shell
>>> user = User.query.filter_by(username='wangy8961').first()
>>> user.posts
<sqlalchemy.orm.dynamic.AppenderBaseQuery object at 0x04567E70>

cascade='all, delete-orphan' 用于级联删除,当删除user时,该user下面的所有posts都会被级联删除,详情参考: https://docs.sqlalchemy.org/en/latest/orm/cascades.html

2. RESTful API设计

我们的 博客文章资源 将提供以下几个 API:

HTTP方法 资源URL 说明
GET /api/posts 返回所有文章的集合
POST /api/posts 添加一篇新文章
GET /api/posts/<id> 返回一篇文章
PUT /api/posts/<id> 修改一篇文章
DELETE /api/posts/<id> 删除一篇文章

创建 app/api/posts.py

from flask import request, jsonify, url_for, g
from app.api import bp
from app.api.auth import token_auth
from app.api.errors import error_response, bad_request
from app.extensions import db
from app.models import Post


@bp.route('/posts', methods=['POST'])
@token_auth.login_required
def create_post():
    '''添加一篇新文章'''
    pass

@bp.route('/posts', methods=['GET'])
def get_posts():
    '''返回文章集合,分页'''
    pass

@bp.route('/posts/<int:id>', methods=['GET'])
def get_post(id):
    '''返回一篇文章'''
    pass

@bp.route('/posts/<int:id>', methods=['PUT'])
@token_auth.login_required
def update_post(id):
    '''修改一篇文章'''
    pass

@bp.route('/posts/<int:id>', methods=['DELETE'])
@token_auth.login_required
def delete_post(id):
    '''删除一篇文章'''
    pass

3.1 添加文章

只有通过 Token 认证的用户才能发表文章:

@bp.route('/posts', methods=['POST'])
@token_auth.login_required
def create_post():
    '''添加一篇新文章'''
    data = request.get_json()
    if not data:
        return bad_request('You must post JSON data.')
    message = {}
    if 'title' not in data or not data.get('title'):
        message['title'] = 'Title is required.'
    elif len(data.get('title')) > 255:
        message['title'] = 'Title must less than 255 characters.'
    if 'body' not in data or not data.get('body'):
        message['body'] = 'Body is required.'
    if message:
        return bad_request(message)

    post = Post()
    post.from_dict(data)
    post.author = g.current_user  # 通过 auth.py 中 verify_token() 传递过来的(同一个request中,需要先进行 Token 认证)
    db.session.add(post)
    db.session.commit()
    response = jsonify(post.to_dict())
    response.status_code = 201
    # HTTP协议要求201响应包含一个值为新资源URL的Location头部
    response.headers['Location'] = url_for('api.get_post', id=post.id)
    return response

3.2 修改文章

用户首先必须通过 Token 认证,而且他还必须是该博客文章的作者才允许修改:

@bp.route('/posts/<int:id>', methods=['PUT'])
@token_auth.login_required
def update_post(id):
    '''修改一篇文章'''
    post = Post.query.get_or_404(id)
    if g.current_user != post.author:
        return error_response(403)

    data = request.get_json()
    if not data:
        return bad_request('You must post JSON data.')
    message = {}
    if 'title' not in data or not data.get('title'):
        message['title'] = 'Title is required.'
    elif len(data.get('title')) > 255:
        message['title'] = 'Title must less than 255 characters.'
    if 'body' not in data or not data.get('body'):
        message['body'] = 'Body is required.'
    if message:
        return bad_request(message)

    post.from_dict(data)
    db.session.commit()
    return jsonify(post.to_dict())

3.3 返回一篇文章

不需要认证,游客也能查看文章详情:

@bp.route('/posts/<int:id>', methods=['GET'])
def get_post(id):
    '''返回一篇文章'''
    post = Post.query.get_or_404(id)
    post.views += 1
    db.session.add(post)
    db.session.commit()
    return jsonify(post.to_dict())

3.4 返回文章列表

不需要认证,游客也能访问前端首页,显示博客列表:

@bp.route('/posts', methods=['GET'])
def get_posts():
    '''返回文章集合,分页'''
    page = request.args.get('page', 1, type=int)
    per_page = min(request.args.get('per_page', 10, type=int), 100)
    data = Post.to_collection_dict(Post.query.order_by(Post.timestamp.desc()), page, per_page, 'api.get_posts')
    return jsonify(data)

如果请求的查询参数 pageper_page 没有对应的结果集,Post.to_collection_dict() 方法会自动抛出 404 错误

3.5 删除文章

用户首先必须通过 Token 认证,而且他还必须是该博客文章的作者才允许删除:

@bp.route('/posts/<int:id>', methods=['DELETE'])
@token_auth.login_required
def delete_post(id):
    '''删除一篇文章'''
    post = Post.query.get_or_404(id)
    if g.current_user != post.author:
        return error_response(403)
    db.session.delete(post)
    db.session.commit()
    return '', 204

3. 前端博客 CURD 操作

3.1 发布文章支持 Markdown

集成 bootstrap-markdown 编辑器,我已经下载好并放在 front-end/src/assets/bootstrap-markdown/ 目录下

(1) Vue.js 集成 JQuery

bootstrap-markdown 插件要使用到 JQuery 库,需要先引入到 Vue.js 中,由于 Bootstrap4 部分功能不只依赖 JQuery,还依赖 popper.js (比如移动端访问时,要缩放菜单按钮),所以一起安装了:

D:\python-code\flask-vuejs-madblog\front-end>cnpm install jquery popper.js --save

修改 front-end/build/webpack.dev.conf.js,在 plugins 下增加:

plugins: [
  new webpack.ProvidePlugin({
    $: 'jquery',
    jquery: 'jquery',
    'window.jQuery': 'jquery',
    jQuery: 'jquery'
  }),
  ...
]

为了后续使用 webpack 打包部署到生产环境,还要修改 front-end/build/webpack.prod.conf.js,同时增加上述内容

然后,在 front-end/src/main.js 中引入 Bootstrap 4 的 CSS 和 JS 文件:

// Import Bootstrap css and js files
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap'

(2) 引入 bootstrap-markdown 样式文件

修改 front-end/src/main.js

// bootstrap-markdown 编辑器需要的样式
import './assets/bootstrap-markdown/css/bootstrap-markdown.min.css'
import './assets/bootstrap-markdown/css/custom.css'
import './assets/icon-awesome/css/font-awesome.min.css'  // 编辑器上的按钮图标是使用 font-awesome 字体图标

它默认使用 Font Awesome 图标,我也下载到 front-end/src/assets/icon-awesome/ 目录下了

(3) 初始化 bootstrap-markdown 编辑器

修改 front-end/src/components/Home.vue 组件:

<form v-if="sharedState.is_authenticated" @submit.prevent="onSubmitAdd" class="g-mb-40">
  <div class="form-group" v-bind:class="{'u-has-error-v1': postForm.titleError}">
    <input type="text" v-model="postForm.title" class="form-control" id="post_title" placeholder="标题">
    <small class="form-control-feedback" v-show="postForm.titleError">{{ postForm.titleError }}</small>
  </div>
  <div class="form-group">
    <input type="text" v-model="postForm.summary" class="form-control" id="post_summary" placeholder="摘要">
  </div>
  <div class="form-group">
    <textarea v-model="postForm.body" class="form-control" id="post_body" rows="5" placeholder=" 内容"></textarea>
    <small class="form-control-feedback" v-show="postForm.bodyError">{{ postForm.bodyError }}</small>
  </div>
  <button type="submit" class="btn btn-primary">Submit</button>
</form>

注意: 我准备在发布新博客的表单中的 id="post_body" 上使用 bootstrap-markdown 编辑器

<script>
// bootstrap-markdown 编辑器依赖的 JS 文件,初始化编辑器在组件的 created() 方法中,同时它需要 JQuery 支持哦
import '../assets/bootstrap-markdown/js/bootstrap-markdown.js'
import '../assets/bootstrap-markdown/js/bootstrap-markdown.zh.js'
import '../assets/bootstrap-markdown/js/marked.js'

export default {
  ...
  created () {
    this.getPosts()
    // 初始化 bootstrap-markdown 插件
    $(document).ready(function() {
      $("#post_body, #editform_body").markdown({
        autofocus:false,
        savable:false,
        iconlibrary: 'fa',  // 使用Font Awesome图标
        language: 'zh'
      })
    })
  }
}
</script>

现在,前端只有已登录的用户才能看到发表博客的表单:

2 add post

3.2 显示博客列表

在组件被加载时执行一次 getPosts()

created () {
  this.getPosts()
  ...
}

分页导航条的实现细节参考代码库

3 posts list

3.3 显示博客详情

如果没有前后端分离,即 Flask 使用 Jinja2 模版引擎来渲染页面的话,我会考虑用户输入 Markdown 原文,后台 Python 自动转换成 HTML 文档(使用 Python-Markdownbleach)后存储到数据库中,然后展示时 Jinja2 使用过滤器 content_html | safe 直接解析 HTML 文档

(1) vue-markdown

但是,按照这个思路,由于转换后的 HTML 文档中包含很多 \n 字符,Vue.jsv-html 指令并不能解析!!! 所以,换种方式,数据库中只保存 Markdown 原文,前端渲染使用 vue-markdown 插件:

D:\python-code\flask-vuejs-madblog\front-end>cnpm install vue-markdown --save

创建 front-end/src/components/Post.vue,引入子组件 vue-markdown

<template>
  <div class="container">
    ...
           <!-- vue-markdown 开始解析markdown它是子组件通过 props 给它传值即可 -->
            <vue-markdown
              :source="post.body"
              :toc="showToc"
              :toc-first-level="1"
              :toc-last-level="3"
              v-on:toc-rendered="tocAllRight"
              toc-id="toc"
              class="markdown-body">
            </vue-markdown>
    ...
  </div>
</template>

<script>
// 导入 vue-markdown 组件解析 markdown 原文为 HTML
import VueMarkdown from 'vue-markdown'

export default {
  name: 'Post',
  components: {
    VueMarkdown
  },
  data() {
    return {
      sharedState: store.state,
      post: {},
      editForm: {
        title: '',
        summary: '',
        body: '',
        errors: 0,  // 表单是否在前端验证通过,0 表示没有错误,验证通过
        titleError: null,
        bodyError: null
      },
      showToc: true
    }
  },
  ...
</script>

此时,你看到的页面是这样子的:

post detail 01

(2) highlight.js

使用 highlight.js 语法高亮:

D:\python-code\flask-vuejs-madblog\front-end>cnpm install highlight.js --save

Post.vue 组件中:

<script>
// vue-router 从 Home 页路由到 Post 页后,会重新渲染并且会移除事件,自定义的指令 v-highlight 也不生效了
// 所以,这个页面,在 mounted() 和 updated() 方法中调用 highlightCode() 可以解决代码不高亮问题
import hljs from 'highlight.js'
const highlightCode = () => {
  let blocks = document.querySelectorAll('pre code');
  blocks.forEach((block)=>{
    hljs.highlightBlock(block)
  })
}

export default {
  ...
  mounted () {
    highlightCode()
  },
  updated () {
    highlightCode()
  }
}
</script>

先选中一款模版,在 front-end/src/main.js 中导入 css 文件:

// 样式文件,浅色:default, atelier-dune-light  深色:atom-one-dark, atom-one-dark-reasonable, monokai
import 'highlight.js/styles/atom-one-dark-reasonable.css'

现在你看到的页面如下:

post detail 02

代码是语法高亮了,但 Markdown 中的标题、标记等样式太简陋了

在 Chrome 开发者工具中查看该模版的 <pre></pre> 颜色是什么,比如是 #212529

再在 front-end/src/main.js 中导入 github-markdown-css 样式文件:

// markdown 样式
import './assets/markdown-styles/github-markdown.css'

修改 front-end/src/assets/markdown-styles/github-markdown.css 中的 <pre></pre> 颜色为 #212529

.markdown-body .highlight pre,
.markdown-body pre {
  padding: 16px;
  overflow: auto;
  font-size: 85%;
  line-height: 1.45;
  background-color: #212529;  // 修改这个值
  border-radius: 3px;
}

最终的样式如下:

post detail 03

3.4 修改博客

用户登录后,自己发布的文章会显示 编辑 按钮,点击后使用 Bootstrap4 modal 显示编辑表单:

5 update post

3.5 删除博客

用户登录后,自己发布的文章会显示 删除 按钮,同时使用 vue-sweetalert2 插件弹出警告信息:

D:\python-code\flask-vuejs-madblog\front-end>cnpm install --save vue-sweetalert2

修改 front-end/src/main.js

// register the vue-sweetalert2 plugin on vue
import VueSweetalert2 from 'vue-sweetalert2'
Vue.use(VueSweetalert2)

Home.vue 组件中使用:

onDeletePost (post) {
  this.$swal({
    title: "Are you sure?",
    text: "该操作将彻底删除 [ " + post.title + " ], 请慎重",
    type: "warning",
    showCancelButton: true,
    confirmButtonColor: '#3085d6',
    cancelButtonColor: '#d33',
    confirmButtonText: 'Yes, delete it!',
    cancelButtonText: 'No, cancel!'
  }).then((result) => {
    if(result.value) {
      const path = `/api/posts/${post.id}`
      this.$axios.delete(path)
        .then((response) => {
          // handle success
          this.$swal('Deleted', 'You successfully deleted this post', 'success')
          // this.$toasted.success('Successed delete the post.', { icon: 'fingerprint' })
          this.getPosts()
        })
        .catch((error) => {
          // handle error
          console.log(error.response.data)
        })
    } else {
      this.$swal('Cancelled', 'The post is safe :)', 'error')
    }
  })
}

6 delete post

4. 提交代码

$ git add .
$ git commit -m "6. 博客文章CURD与Markdown"
$ git checkout master
$ git merge dev
$ git branch -d dev

将本地 master 分支代码上传到 Github 代码仓库中的 master 分支:

$ git push -u origin master

打上标签 tag并上传:

$ git tag v0.6
$ git push origin v0.6

代码已上传到 https://github.com/wangy8961/flask-vuejs-madblog/tree/v0.6 ,欢迎star

未经允许不得转载: LIFE & SHARE - 王颜公子 » Flask Vue.js全栈开发|第6章:博客文章CURD与Markdown

分享

作者

作者头像

Madman

如果博文内容有误或其它任何问题,欢迎留言评论,我会尽快回复; 或者通过QQ、微信等联系我

0 条评论

暂时还没有评论.

发表评论前请先登录