刚上大三想学点Vue,刚好还在学数据库,就写了这个小项目作为练习。
code repository here: [github]WoodenStone/article_admin
在博客上查看:WoodenStone's Blog
概览
Article Admin 是一个前后端分离的文章/博客管理系统。前端采用Vue2.x并结合ElementUI,后端使用Node.js的Express框架,数据库为MySQL8.0。
预览
主要功能
- 注册
- 登录 / 注销
- 用户信息更改
- 文章管理
- 发布文章
- 删除文章
- 编辑文章
- 模糊搜索
- 点赞
- 收藏到特定收藏夹
- Markdown编辑器及图片插入
- 文章标签
- 按特定方式排序(时间倒序、赞数降序、评论数降序、收藏数降序)
- 评论回复
- 评论文章
- 回复用户
- 查看个人收到的评论、回复
- 收藏夹
- 添加 / 删除收藏夹
- 更改收藏夹名及描述
- 内部文章查看、删除、移动
- 用户关注
- 关注和取关
- 站内信
- 收 / 发站内信
- 阅读状态标记
- 输入错误地址时重定向至404
设计详细说明
数据库
概览
数据库设计共包含8个实体,14个联系。
ER图:
补充说明
- comments - 评论回复表
对于评论表,数据库字段如下:
comment_id | publisher_id | recipient_id | article_commented_id | content | create_time | is_reply | comment_index
comment_id 为主键,标识某条评论或回复的唯一 ID。
publisher_id,recipient_id 和 article_commented_id 均为外键,分别对应 user_info 用户信息表中的 user_id、user_id 和 article 文章信息表中的 id, 表示发布者 id,接收方 id 和被评论文章的 id。
其 中 comment_id 、 publisher_id 、 article_commented_id 、 content 和 create_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逻辑不太明显,因为数据库表字段的设计造成了一些麻烦。
两个主要的功能:①查询某篇文章下的评论回复和 ②查询某用户收到的评论回复。
- 查询某篇文章下的评论回复
由于需要返回的是一个最多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作为外键关联。
- 查询某用户收到的评论回复
以查询某用户收到的评论为例,说明一下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传入正确的数据即可展示,评论是最多两层的树,效果如下图:
评论的回复采取的是ElementUI中的dialog组件实现,并使用开源轮子封装的v-dialogDrag指令使得dialog框可以拖动。在回复和评论中的“回复”按钮是在CommentGroup中组装的,换言之CommentItem和ReplyItem其实是兄弟关系。这样做是因为想把“回复”这个需要调用接口,传递数据的功能尽可能集中起来,就不用再使用$emit()等方法传参了。不过从设计上来看,可能设计为父子关系会更为直观一些。
参考
写在最后
第一次写vue也是第一次用nodeJS,后端完全速成,前端倒是比较上心,学了挺多之前没见过的,对数据库的操作也有了一定的了解。现在又过了一个月,回头看这些代码又觉得哪里都写得不好,而且也没把axios的拦截器和vuex用对。不过也算是一个相对有一点点功能的项目啦,而且最后还部署到了服务器上,体验了一把上线,感觉还是学到很多。
以后有时间说不定会再做修改,不过再修改也许就是重写了🤣