Vue+ElementUI+Express+MySQL搭建博客后台管理系统实践

2,398 阅读7分钟

刚上大三想学点Vue,刚好还在学数据库,就写了这个小项目作为练习。

code repository here: [github]WoodenStone/article_admin

在博客上查看:WoodenStone's Blog

概览

Article Admin 是一个前后端分离的文章/博客管理系统。前端采用Vue2.x并结合ElementUI,后端使用Node.js的Express框架,数据库为MySQL8.0。

预览

1.gif 2.gif 3.gif 4.gif 5.gif

2.gif

主要功能

- 注册
- 登录 / 注销

- 用户信息更改

- 文章管理
	- 发布文章
	- 删除文章
	- 编辑文章
	- 模糊搜索
	- 点赞
	- 收藏到特定收藏夹
	- Markdown编辑器及图片插入
	- 文章标签
	- 按特定方式排序(时间倒序、赞数降序、评论数降序、收藏数降序)

- 评论回复
	- 评论文章
	- 回复用户
	- 查看个人收到的评论、回复
	
- 收藏夹
	- 添加 / 删除收藏夹
	- 更改收藏夹名及描述
	- 内部文章查看、删除、移动

- 用户关注
	- 关注和取关

- 站内信
	- 收 / 发站内信
	- 阅读状态标记
	
- 输入错误地址时重定向至404

设计详细说明

数据库

概览

数据库设计共包含8个实体,14个联系。

ER图:

image-20211122201625888.png

补充说明

  1. comments - 评论回复表

对于评论表,数据库字段如下:

comment_id | publisher_id | recipient_id | article_commented_id | content | create_time | is_reply | comment_index

comment_id 为主键,标识某条评论或回复的唯一 ID。

publisher_idrecipient_idarticle_commented_id 均为外键,分别对应 user_info 用户信息表中的 user_iduser_id 和 article 文章信息表中的 id, 表示发布者 id,接收方 id 和被评论文章的 id。

其 中 comment_idpublisher_idarticle_commented_idcontentcreate_time 均不能为空,表示需要唯一确定某篇文章下由某个用户所发表的某条评论。而 recipient_id 可以为空,因为如果用户直接对某篇文章发表回复,就不需要特意指定接收者 ID(即文章作者 ID);相应地,如果不为空,则需要在 is_reply 中指定为 1,并且指定接收者 ID 和该评论在该文章中的索引。comment_index 字段的设置是由于一个用户可能在某篇 文章下发表多条评论,直接查找 comment_id 过于繁琐,因此显式指定其文章内索引。下面是 api 接口返回的一个实例:

[
  {
    "comment_id": 35,
    "publisher_id": 1,
    "recipient_id": null,
    "article_commented_id": 20,
    "content": "月が綺麗ですね",
    "create_time": "2021-11-22T12:54:36.000Z",
    "is_reply": null,
    "comment_index": 0,
    "children": [
      {
        "comment_id": 36,
        "publisher_id": 1,
        "recipient_id": 1,
        "article_commented_id": 20,
        "content": "月が綺麗ですね",
        "create_time": "2021-11-22T12:54:40.000Z",
        "is_reply": 1,
        "comment_index": 0,
        "publisher_name": "admin",
        "recipient_name": "admin"
      }
    ],
    "publisher_name": "admin"
  }
]

后端

后端没有完整的架构,仅提供RESTful API用于操作数据库,以便增删查改。

API均以/api/前缀,并在注释简要说明了所提供的功能,总共有四十多个,列出来过于冗长,可以到博客查看。

这个项目一开始写的还是比较随意的(也没有经验),一般是一边构思、一边写页面,用到的时候就去后端写一个API,也没想到要有架构之类,现在看起来就比较混乱。而且刚开始写的时候对RESTful风格也是一知半解,基本都用动+名的形式命名,方法一开始只会用GET,后来学会了POST又全变成POST,直到最后才重新修改了下。

补充说明

文章标签

由于文章和标签是多对多关系,故数据库设计将文章标签id和文章id单独抽取出来组成一个描述映射的关系,而文章表(article)和标签表(tag)独立存在。这就给修改标签带来了麻烦。

在更新文章时,每个标签都可能被更改、删除,因此采用的方式是在先删除该文章原有的标签映射(article_tag表),再进行标签表(tag)的更新,最后重新建立该文章和标签的映射(article_tag表)。这个流程是:

delete tag mappings -> add tags -> add tag mappings 

效率比较低,或许后续能找到更好的方式。

图片上传

图片上传采用了multer中间件,用到的地方有用户头像上传和文章内图片上传。主要思路都是:

  • 将图片上传至服务器
  • 将服务器路径存入数据库
  • 将服务器路径返回,前端回显
评论和回复

评论和回复的sql逻辑不太明显,因为数据库表字段的设计造成了一些麻烦。

两个主要的功能:①查询某篇文章下的评论回复和 ②查询某用户收到的评论回复。

  1. 查询某篇文章下的评论回复

由于需要返回的是一个最多2层高的树,示例如:

- 评论1
	- 回复1
	- 回复2
- 评论2
	- 回复1
	- 回复2
- 评论3

故采取的方式是先找到该文章下的所有评论,得到一个包含全部评论id的数组,再依次查找每条评论下是否有存在回复,如果存在回复,就拼接到children数组中。

在查找回复的sql中,不能指定接收者的id(即comments表中的recipient_id),因为回复有可能是楼中楼的沟通,如:

- A 评论[content]
	- B 回复 A 	# 回复1
	- C 回复 B	# 回复2
	- C 回复 C	# 回复3

如果指定接收用户id,可能导致回复2和回复3都无法收取到。

这里也比较降低效率的是需要反复地获取用户名(或者进行表的连接),因为数据库设计都是以user_id作为外键关联。

  1. 查询某用户收到的评论回复

以查询某用户收到的评论为例,说明一下sql的逻辑。

首先,要在article表中找到该用户所发表的文章id,然后根据文章id在comments表中查询收到的评论(排除自己发表的评论),最后需要拼接上发表者用户名和文章标题。

sql:

SELECT ar.title,
         co.publisher_name,
         co.publisher_id,
         co.article_commented_id,
         co.content,
         co.create_time,
         co.is_reply
FROM 
    (SELECT us.user_name AS publisher_name,
         uc.publisher_id,
         uc.article_commented_id,
         uc.content,
         uc.create_time,
         uc.is_reply
    FROM 
        (SELECT c.publisher_id,
         c.article_commented_id,
         c.content,
         c.create_time,
        is_reply
        FROM comments c, article a, user_info u
        WHERE c.article_commented_id = a.id
                AND a.author_id = u.user_id
                AND u.user_id = ${uid}
                AND is_reply is null
                AND publisher_id <> ${uid}) AS uc, user_info us
        WHERE uc.publisher_id = us.user_id ) AS co, article ar
    WHERE co.article_commented_id = ar.id;

${uid}是传入的参数。

这两个功能使用db()分别返回一个Promise,最后使用Promise.all()一同处理,将得到的结果拼接,返回给前端。

前端

概览

前端根据数据库设计,主要有登录注册、个人主页、文章、站内信、收藏几个主要路由,分为登录注册、文章增删查改、站内信、评论、站内信、收藏几个主要模块来实现。

采用vue-cli脚手架搭建项目,主要使用ES6语法编写代码。使用vue-router进行路由管理,Less作为CSS预处理器,Axios进行前后端数据交互。

补充说明

登录注册

登录采取的是很简陋(不科学)的方式:用户输入用户名、密码后向服务端验证正确性,若正确则将信息存入 localStorage,权限也是写死在用户信息中的(作为数据库表中的一个字段存在)。这是考虑到作为一个博客后台管理系统,或者说带有部分社交属性(私信、评论)的系统,管理员的权限并不需要和普通用户做出非常大的区分。登录信息过期通过代码设置 localStorage 的有效期为 7 天。

后来了解到通过token和cookie来处理是一种更优雅的方式。

文章列表展示

文章列表的主要组件位于components/ArticleList下,实现功能为文章列表的展示,可选项包括:

  • 是否展示包含新增、搜索和排序的工具栏 - showHeader
  • 是否显示作者 - showAuthor
  • 是否展示内容预览 - showContent
  • 使用场景:个人文章 - personal
  • 使用场景:收藏夹内 - collection

该组件在文章列表(路由/table)、个人收藏夹内页面(/user/favorite)、用户个人文章(包含自己的和访客所见的文章: /user/index和/user/visitor)页面均有使用。

这个组件也是一个高耦合的组件,当时写的时候没感觉,现在看来……🤣

文章排序

排序方式有:默认时间倒序、按赞数降序、按收藏数降序和按评论数降序,后三种后端返回的都是一个文章id数组,按指定方式降序排列。

如按赞数降序返回的是一个形如[6, 9, 10, 1]的数组,表明赞数为6>9>10>1>其它,未出现的文章赞数为0。前端根据这个数组进行交换排序:

    /**
     * @description:: 根据传入的index数据对array进行交换排序
     * @param {Array} index
     * @param {Array} array
     * @return {*}
     * @author: WoodenStone
     */
    interchange (index, array) {
      for (let i = 0; i < index.length; i++) {
        if (array[i].id !== index[i].id) {
          let temp = {}
          for (let j = i + 1; j < array.length; j++) {
            if (array[j].id === index[i].id) {
              // 这里赋值要用$set 否则视图不会更新
              temp = array[j]
              this.$set(array, j, array[i])
              this.$set(array, i, temp)
            }
          }
        }
      }
    },

这个方式很原始,不过暂时没想到有什么通用简便的方法。

文章标签

核心组件位于/src/components/Tags,主要实现功能为输入标签,按回车键添加标签,按DELETE键删除标签。单个标签的字数和一篇文章最多可有的标签数均作出限制。

评论回复

找了一些开源轮子,没找到满意的,最后还是自己实现一个。核心组件位于/src/components/Comment下,分为单个回复(ReplyItem)、单个评论(包含回复,CommentItem)和所有评论(CommentGroup),给index传入正确的数据即可展示,评论是最多两层的树,效果如下图:

comment&reply

评论的回复采取的是ElementUI中的dialog组件实现,并使用开源轮子封装的v-dialogDrag指令使得dialog框可以拖动。在回复和评论中的“回复”按钮是在CommentGroup中组装的,换言之CommentItemReplyItem其实是兄弟关系。这样做是因为想把“回复”这个需要调用接口,传递数据的功能尽可能集中起来,就不用再使用$emit()等方法传参了。不过从设计上来看,可能设计为父子关系会更为直观一些。

参考

vue-element-admin的基础模板

写在最后
第一次写vue也是第一次用nodeJS,后端完全速成,前端倒是比较上心,学了挺多之前没见过的,对数据库的操作也有了一定的了解。现在又过了一个月,回头看这些代码又觉得哪里都写得不好,而且也没把axios的拦截器和vuex用对。不过也算是一个相对有一点点功能的项目啦,而且最后还部署到了服务器上,体验了一把上线,感觉还是学到很多。
以后有时间说不定会再做修改,不过再修改也许就是重写了🤣