Vue + NodeJS + MySQL 搭建文章后台管理系统
WoodenStone

功能、目录结构、设计说明和一些小细节。

概览

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

本项目是HITSZ 21春数据库系统课程实验四的作品。

在线demo怕被攻击就不放出了🤐

repository: [github]WoodenStone/article_admin

SETUP

克隆项目

1
git clone https://github.com/WoodenStone/article_admin.git

数据库

articleAdmin.sql导入MySQL即可生成数据库。

⭕由于存在同一张表上存在多个触发器的情况,MySQL版本不能低于5.7。

后端

1
2
3
4
5
6
7
8
# 进入项目目录
cd article_admin/node_back_end

# 安装依赖
npm install

# 启动项目
node app.js

注意:需要修改数据库用户名、密码。

前端

1
2
3
4
5
6
7
8
# 进入项目目录
cd article_admin/vue_front_end

# 安装依赖
npm install

# 启动项目
npm run dev

访问 http://localhost:9529 查看前端界面。

部署

数据库:

安装不低于5.7版本的MySQL,并导入articleAdmin.sql执行建表。

前端:

根据需要配置生产环境的路径,然后运行:

1
2
# 生成静态文件
npm run build

/dist目录下的文件放在服务器可访问路径下(如/www)。

后端:

修改/src/sql/db.js中的数据库配置,修改/app.js中的图片上传路径。

使用pm2或forever等工具启动项目,保持后端为开启状态。

最后修改nginx配置,通过ip或域名访问查看效果。

预览

主要功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
- 注册
- 登录 / 注销

- 用户信息更改

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

- 评论回复
- 评论文章
- 回复用户
- 查看个人收到的评论、回复

- 收藏夹
- 添加 / 删除收藏夹
- 更改收藏夹名及描述
- 内部文章查看、删除、移动

- 用户关注
- 关注和取关

- 站内信
- 收 / 发站内信
- 阅读状态标记

- 输入错误地址时重定向至404

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
.
|-- node_back_end # 后端工程文件
| |-- app.js # 主要API
| |-- package-lock.json
| |-- package.json # 配置文件
| |-- public # 静态目录
| |-- src # 数据库相关操作
| |-- upload # 上传图片存放
|-- vue_front_end # 前端工程文件
|-- babel.config.js
|-- package-lock.json
|-- package.json # 项目配置
|-- public # html模板和网站图标
| |-- favicon.ico
| |-- index.html
|-- src # 主要代码
| |-- App.vue
| |-- assets # 静态资源,如图标字体等
| |-- components # 公用组件
| | |-- ArticleList # 文章列表组件
| | |-- BreadCrumb # 导航栏面包屑
| | |-- Collection # 收藏夹组件
| | |-- Comment # 评论、回复组件
| | |-- Message # 站内信(私信)组件
| | |-- Tags # 文章标签组件
| |-- layout # 页面基础布局
| |-- main.js # 入口文件 加载组件等
| |-- plugins # 引入的插件
| |-- router # 路由
| |-- settings.js
| |-- store # 全局store管理
| |-- styles # 全局样式
| |-- utils # 常用方法
| |-- views # 各页面
| |-- error-page # 404页面
| |-- form # 表单 实现文章修改和创建
| |-- home # 站点主页面
| |-- login # 登录、注册
| |-- table
| | |-- detail.vue # 文章详情
| | |-- index.vue # 文章列表
| |-- user # 用户相关页面
| |-- change.vue # 修改用户信息
| |-- collection.vue # 收藏夹
| |-- components
| | |-- UserInfo.vue # 用户信息主页面
| |-- favorite.vue # 收藏的文章
| |-- index.vue # 路由入口
| |-- message.vue # 站内信页面
| |-- visitor.vue # 访客界面
|-- vue.config.js # vue-cli配置

设计详细说明

数据库

概览

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

努力地想让数据库满足第三范式……

ER图:

补充说明

  1. comments - 评论回复表

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

1
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 接口返回的一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[
{
"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/前缀,并在注释简要说明了所提供的功能,主要包括:

名称 方法 简要功能
Login POST 用户登录
Register POST 用户注册
userInfoChange POST 用户信息更改
uploadAvatar POST 头像上传
Article GET 获取带有标签的文章列表
personalArticle GET 获取某用户的文章
articleDetail GET 获取某篇文章信息及标签
deleteArticle DELETE 删除某一篇文章
addArticle POST 添加文章
updateArticle POST 更新某一篇文章
imgUpload POST 文章内图片上传
getAvatar GET 由用户ID获取用户头像
getAvatarByName GET 由name获取头像
getIdByName GET 由用户名获取id
thumbupStatus GET 获取用户对某篇文章的点赞状态
favoriteStatus GET 获取用户对某篇文章的收藏状态
thumbupStatus POST 变更点赞状态
favoriteStatus POST 变更收藏状态
getRelationStatusOne GET 获取一对一关注状态
followStatusChange POST 变更关注状态
getFollowerNumber GET 获取被关注数
getFollowingNumber GET 获取关注数
commentsOfOneArticle GET 获取某篇文章的评论和回复
Comment POST 添加文章评论或回复
FollowersList GET 获取被关注列表
FollowingList GET 获取关注人列表
CommentsReceived GET 某用户收到的评论和回复
MessageNum GET 某用户收到的站内信总数
MessageNumOut GET 某用户发出的站内信总数
currentDirectMessage GET 分页获取某用户收到的站内信
currentMessageOut GET 分页获取某用户发出的站内信
messageReadStatus POST 更改某条站内信的阅读状态
newMessage POST 发送站内信
message DELETE 删除某条站内信
collection GET 获取某用户的收藏夹
favoriteArticle GET 获取某用户某收藏夹的文章
newCollection POST 创建新收藏夹
collectionInfo POST 修改收藏夹信息
collection DELETE 删除收藏夹
collectionStatus GET 查找某用户将某篇文章收藏于哪些收藏夹
articleFavorite POST 添加某篇文章到某些收藏夹
ArticleThumbupDesc GET 获取赞数降序的文章列表
ArticleFavoriteDesc GET 获取收藏降序的文章列表
ArticleCommentsDesc GET 获取回复评论数降序的文章列表
search POST 模糊搜索,包含标题、内容、作者
searchName POST 用户名输入提示
statistic GET 首页创作统计数据

补充说明

文章标签

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

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

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

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

图片上传

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

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

具体的操作有细微不同,且需要进行静态资源路径的设置。

评论和回复

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

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

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

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

1
2
3
4
5
6
7
- 评论1
- 回复1
- 回复2
- 评论2
- 回复1
- 回复2
- 评论3

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

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

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

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

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

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

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

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

sql:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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()一同处理,将得到的结果拼接,返回给前端。

后端现在基本改用参数化查询了,会对双引号等做转义,并且能预防SQL注入。

各种原理还是不太懂,不过到处在用promise、async / await,对这些更了解了。

前端

概览

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

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

补充说明

登录注册

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

21/12/28 update

注册登录现在采用了jwt做验证。具体流程是:

  • 用户第一次登录时,后端签发token,设置有效期7天
  • 前端拿到token存入cookie中(or localStorage),利用axios拦截器在每次发送请求时都在请求头上塞进这个token(这里叫aa-token
  • 后端对除白名单内的路由进行token校验(白名单就是登录、注册这两个),如果校验失败,根据消息提示告诉前端是token过期了还是没有登录;否则正常处理
  • 如过期,前端用相应拦截器根据返回的错误状态码展示提示,并跳转到登录页;否则正常处理

同时,前端还使用route.beforeEach在进入每个路由前都进行判断是否有token存在。如果token过期,不发请求应该是感受不到的。如果进行操作,就会进入到登录页。

如果要无感保持登录,最好加上refresh token。

由于原本的很多代码都是基于localStorage中的信息写的,为了尽可能地少改点代码(否则真的要重写了),在登录后还是会将信息存到localStorage中。不过它的过期时间已经没意义了,都由token进行外层的保护。

还有一点修改的是头像上传由于用的是el-upload组件,因此要手动挂上请求头,返回的时候一样处理。如果恰好在头像上传的过程中token过期了,也需要在失败处理中重定向到登录页。

文章列表展示

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

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

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

现采用前端分页。

文章排序

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @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或者splice来更新视图
// 现在采用了分页,不需要set了,见后面的分页
temp = array[j]
this.$set(array, j, array[i])
this.$set(array, i, temp)
}
}
}
}
},

这么写是在后端排好序返回一个大数据包和前端排序进行交换哪个更快进行了选择,不过或许哪个都不是好的选择……

分页

新增功能

需要分页的地方有两个,一个是文章的列表,一个是收到 / 发出的站内信。现在采用的实现方法是:文章列表用前端分页,站内信用查询分页。

文章列表分页

列表的前端分页和分页查询怎么选择?如果选择分页查询的话,需要考虑:

  • 按各种不同排序的查询分页
  • 按不同场景查询的分页(总的文章列表、个人收藏、个人的文章、别人的文章)

需要修改的接口很多。

如果是前端分页,虽然第一次要请求所有的数据,但是由于不用请求文章内容,只需要每次将标题、作者、时间这样的一条条数据拿过来,负担不会很大。对于不同的排序方式,只需要在排序完更新当前需要展示的页面即可,相对来说改动的工作量不大。

综合上面的考虑选择了前端分页。

在不同方式进行排序更新的页面的时候,也不需要用$set更新articles,因为视图展示依赖的数据不是articles,只要在排完序之后更新一下当前页面展示的数据即可:

1
2
3
4
5
6
currentPageData () {
this.currentPageArticles = this.articles.slice(
(this.currentPage - 1) * this.pageSize,
this.currentPage * this.pageSize
)
}
站内信分页

一般来说看站内信就看个最新收到,没有必要一次性把所有的都select出来,两种方式修改的工作量差不多,考虑到可能有的大量垃圾信还是用了查询分页,SQL也很简单,加上合适的limitoffset参数就行了。不过一次查一点需要先查一下总的条目数,单独返回一个total,这里新增了两个接口。

典型的SQL,用逆序主键保证查出来的是最新的:

1
2
3
4
select d.message_id, d.addresser_id, d.read_status, d.delivery_time, d.content, d.title, u.user_name as addresser_name 
from direct_message d, user_info u
where d.consignee_id = ${req.query.userID}
and d.addresser_id = u.user_id order by message_id desc limit ${req.query.pageSize} offset ${req.query.offset};
文章标签

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

评论回复

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

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

前端是越看需要改进的地方越多😑 重构❎ 重写✅

虽然这个项目叫做“xxx后台管理系统”,但是只是为了符合“xxx后台管理系统”的叫法……从实现上来看,文章详情页、收藏、站内信都不能说很符合这个场景,更像是一个综合在一起的应用。

最后,自己给自己提需求实在是太困难了!每当开始思考用户画像的时候,都会陷入“这种东西真的会有人想用吗”的怀疑中。从功能上来说,其实脑海里想的是lofter,但是lofter的目标群体还是挺明确的,而且它网页版和手机版差得不是一点半点,用起来感觉也怪怪的。最终还是在“既然想了就得做出来吧”这种念头的鼓励下做了。嗯,用来放点自己的零散想法还是可以的。第一个项目,之后还是想尝试点别的,RN?electron?以后再说咯。

参考

vue-element-admin的基础模板

  • 本文标题:Vue + NodeJS + MySQL 搭建文章后台管理系统
  • 本文作者:WoodenStone
  • 创建时间:2021-11-24 19:17:25
  • 本文链接:https://woodenstone.github.io/Projects/article-admin/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!