使用Vue+Django+Ant Design做一個留言評論模塊
1.總覽
留言的展示參考網絡上參見的格式,如掘金社區:
一共分為兩層,子孫留言都在第二層中
最終效果如下:
接下是數據庫的表結構,如下所示:
有一張user表和留言表,關系為一對多,留言表有父留言字段的id,和自身有一個一對多的關系,建表語句如下:
CREATE TABLE `message` (
`id` int NOT NULL AUTO_INCREMENT,
`date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`content` text NOT NULL,
`parent_msg_id` int DEFAULT NULL,
`user_id` int NOT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `message_ibfk_1` (`parent_msg_id`),
CONSTRAINT `message_ibfk_1` FOREIGN KEY (`parent_msg_id`) REFERENCES `message` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `message_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`identity` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8
2.后台接口
2.1獲取留言接口
在Django的views.py中定義兩個接口,一個負責提供留言內容,一個負責插入留言,如下:
# 獲取留言信息
@require_http_methods(['GET'])
def findAllMsg(request):
response = {}
try:
sql = '''
SELECT
msg1.*,
user.username,
msg2.username AS parent_msg_username
FROM message msg1
LEFT JOIN
(SELECT
m.id,
user.username
FROM message m
LEFT JOIN USER
ON m.user_id = user.id
)AS msg2
ON msg1.parent_msg_id = msg2.id
LEFT JOIN USER
ON msg1.user_id = user.id
ORDER BY msg1.date DESC;
'''
with connection.cursor() as cursor:
cursor.execute(sql)
response['messages'] = sortMsg(cursor)
response['status_code'] = 200
except Exception as e:
response['status_code'] = 500
response['error'] = e
return JsonResponse(response)
先來看看這個sql能查出些什么東西:
上面接口中的sorMsg()函數用於整理留言信息,使子留言和父留言能對應起來,算法實現如下:
# 整理留言信息返回格式
def sortMsg(cursor):
list = []
allMsg = dictfetchall(cursor)
for i in range(len(allMsg)):
tmpParent = allMsg[i]
tmpChild = []
# 如果沒有屬於根評論,則搜索該評論下的所有子評論
if tmpParent.get('parent_msg_id') == None:
tmpChild = bfs(tmpParent, allMsg)
# 如果是子評論則跳過,子評論最終會出現在根評論的子節點中
else:
continue
tmpParent['children'] = tmpChild
# 格式化時間
tmpParent['date'] = datetime.datetime.strftime(tmpParent['date'], '%Y-%m-%d %H:%M:%S')
list.append(tmpParent)
return list
# 搜索一條留言的所有子留言,廣度優先
import queue
def bfs(parent, allMsg):
childrenList = []
q = queue.Queue()
q.put(parent)
while(not q.empty()):
tmpChild = q.get()
for i in range(len(allMsg)):
if allMsg[i]['parent_msg_id'] is not None and allMsg[i]['parent_msg_id'] == tmpChild['id']:
childrenList.append(allMsg[i])
q.put(allMsg[i])
# 子留言列表按時間降序排序
childrenList = sorted(childrenList, key = lambda d: d['date'], reverse = True)
# 格式化日期格式
for item in childrenList:
item['date'] = datetime.datetime.strftime(item['date'], '%Y-%m-%d %H:%M:%S')
return childrenList
用postman測試接口,得到的json格式如下:
{
"messages": [
{
"id": 12,
"date": "2020-05-31 12:19:43",
"content": "你好啊,太棒了",
"parent_msg_id": null,
"user_id": 5,
"username": "wangwu",
"parent_msg_username": null,
"children": []
},
{
"id": 11,
"date": "2020-05-31 12:18:55",
"content": "的時刻層6666666632\n2面的思考名稱看到什么材料是isdafjoisdjiojildsc",
"parent_msg_id": null,
"user_id": 3,
"username": "zhangsan",
"parent_msg_username": null,
"children": []
},
{
"id": 5,
"date": "2020-05-29 19:09:33",
"content": "發的發射點發吖方吖是發是呵等方5愛的非4阿瑟東方 發",
"parent_msg_id": null,
"user_id": 4,
"username": "lisi",
"parent_msg_username": null,
"children": [
{
"id": 13,
"date": "2020-05-31 12:20:12",
"content": "號好好好矮好矮好矮好好",
"parent_msg_id": 5,
"user_id": 6,
"username": "zhaoliu",
"parent_msg_username": "lisi"
}
]
},
{
"id": 1,
"date": "2020-05-29 19:06:21",
"content": "fasfdsafas法阿薩德方吖65阿瑟東方5是的發",
"parent_msg_id": null,
"user_id": 1,
"username": "student",
"parent_msg_username": null,
"children": [
{
"id": 7,
"date": "2020-05-29 19:29:29",
"content": "hfhf2h22h222223232",
"parent_msg_id": 6,
"user_id": 1,
"username": "student",
"parent_msg_username": "zhaoliu"
},
{
"id": 6,
"date": "2020-05-29 19:09:56",
"content": "而離開離開鄰居哦i據哦i報價哦v保健品45465",
"parent_msg_id": 4,
"user_id": 6,
"username": "zhaoliu",
"parent_msg_username": "mike"
},
{
"id": 4,
"date": "2020-05-29 19:09:14",
"content": "發送端非場地薩擦手d5asd32 1dads\r\ndsac十多次ds出錯",
"parent_msg_id": 2,
"user_id": 8,
"username": "mike",
"parent_msg_username": "lisi"
},
{
"id": 3,
"date": "2020-05-29 19:08:56",
"content": "奮發惡法撒打發士大夫士大夫是大 大師傅撒",
"parent_msg_id": 2,
"user_id": 2,
"username": "teacher",
"parent_msg_username": "lisi"
},
{
"id": 2,
"date": "2020-05-29 19:08:41",
"content": "fasdfasdf發生的法撒旦飛灑多發點房地產",
"parent_msg_id": 1,
"user_id": 4,
"username": "lisi",
"parent_msg_username": "student"
}
]
}
],
"status_code": 200
}
這個就是前台所要的內容了。
其實一開始我是很直觀地認為是用深度優先來取出層層嵌套的留言的,如下:
# 遞歸搜索一條留言的所有子留言,深度優先
def dfs(parent, allMsg):
childrenList = []
for i in range(len(allMsg)):
if allMsg[i]['parent_msg_id'] is not None and allMsg[i]['parent_msg_id'] == parent['id']:
allMsg[i]['children'] = dfs(allMsg[i], allMsg)
childrenList.append(allMsg[i])
return childrenList
這樣取出的json格式是這樣的:
{
"messages": [
{
"id": 5,
"date": "2020-05-29 19:09:33",
"content": "發的發射點發吖方吖是發是呵等方5愛的非4阿瑟東方 發",
"parent_msg_id": null,
"user_id": 4,
"username": "lisi",
"children": [
{
"id": 8,
"date": "2020-05-29T17:23:37",
"content": "哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈呵呵呵呵呵呵",
"parent_msg_id": 5,
"user_id": 3,
"username": "zhangsan",
"children": []
}
]
},
{
"id": 1,
"date": "2020-05-29 19:06:21",
"content": "fasfdsafas法阿薩德方吖65阿瑟東方5是的發",
"parent_msg_id": null,
"user_id": 1,
"username": "student",
"children": [
{
"id": 2,
"date": "2020-05-29T19:08:41",
"content": "fasdfasdf發生的法撒旦飛灑多發點房地產",
"parent_msg_id": 1,
"user_id": 4,
"username": "lisi",
"children": [
{
"id": 4,
"date": "2020-05-29T19:09:14",
"content": "發送端非場地薩擦手d5asd32 1dads\r\ndsac十多次ds出錯",
"parent_msg_id": 2,
"user_id": 8,
"username": "mike",
"children": [
{
"id": 6,
"date": "2020-05-29T19:09:56",
"content": "而離開離開鄰居哦i據哦i報價哦v保健品45465",
"parent_msg_id": 4,
"user_id": 6,
"username": "zhaoliu",
"children": [
{
"id": 7,
"date": "2020-05-29T19:29:29",
"content": "hfhf2h22h222223232",
"parent_msg_id": 6,
"user_id": 1,
"username": "student",
"children": []
}
]
}
]
},
{
"id": 3,
"date": "2020-05-29T19:08:56",
"content": "奮發惡法撒打發士大夫士大夫是大 大師傅撒",
"parent_msg_id": 2,
"user_id": 2,
"username": "teacher",
"children": []
},
{
"id": 9,
"date": "2020-05-29T17:27:13",
"content": "alalla啦啦啦啦啦啦來的隊列李大水泛濫的薩拉發 的 第三方哈l",
"parent_msg_id": 2,
"user_id": 7,
"username": "joke",
"children": []
}
]
}
]
}
],
"status_code": 200
}
但仔細一想,實際頁面展示的時候肯定不能這樣一層層無限地嵌套下去,否則留言多了頁面就裝不下了,於是還是改成了兩層留言的格式,第二層使用廣度優先搜索將樹轉為列表存儲。
2.2 新增留言接口
前台提供留言內容、留言者id以及父留言的id(如果不是回復信息的話就是空)
import datetime
@require_http_methods(['POST'])
def insertMsg(request):
response = {}
try:
request.POST = request.POST.copy()
request.POST['date'] = datetime.datetime.now()
msg = Message()
msg.date = request.POST.get('date')
msg.content = request.POST.get('content')
msg.parent_msg_id = request.POST.get('parent_msg_id')
msg.user_id = request.POST.get('user_id')
msg.save()
response['msg'] = 'success'
response['status_code'] = 200
except Exception as e:
response['error'] = str(e)
response['status_code'] = 500
return JsonResponse(response)
3.前台設計
有了后台提供的數據,前台展示就比較簡單了。
留言板塊的設計我使用了Ant Design的留言組件。
留言界面主要由兩個組件所構成——留言區組件以及評論表單的組件
3.1主視圖Messeage.vue
<template>
<div>
<comment-message @handleReply="handleReply" :commentList="comments"></comment-message>
<comment-area @reload="reload" :parentMsgId="replyMsgId" :replyMsgUsername="replyMsgUsername"></comment-area>
</div>
</template>
<script>
import CommentMessage from "components/common/comment/CommentMessage";
import CommentArea from "components/common/comment/CommentArea";
import { findAllMsg } from "network/ajax";
export default {
name: "Message",
components: {
CommentMessage,
CommentArea
},
data() {
return {
comments: [],
replyMsgId: "",
replyMsgUsername: ""
};
},
mounted() {
findAllMsg()
.then(res => {
this.comments = res.data.messages;
})
.catch(err => {
console.log(err);
this.$router.push("/500");
});
},
methods: {
handleReply(data) {
this.replyMsgId = data.msgId;
this.replyMsgUsername = data.msgUsername;
},
reload() {
this.$emit("reload")
}
}
};
</script>
<style>
</style>
3.2 留言區域組件CommentMessage.vue:
<template>
<div id="commentMsg">
<div v-if="isEmpty(commentList)" class="head-message">暫無留言內容</div>
<div v-else class="head-message">留言內容</div>
<comment
@handleReply="handleReply"
v-for="(item1, index) in commentList"
:key="'parent-' + index"
:comment="item1"
>
<!-- 二層留言 -->
<template #childComment v-if="!isEmpty(item1.children)">
<comment
v-for="(item2, index) in item1.children"
:key="'children-' + index"
:comment="item2"
@handleReply="handleReply"
></comment>
</template>
</comment>
</div>
</template>
<script>
import Comment from "./Comment";
import Vue from "vue";
export default {
name: "CommentMessage",
components: {
Comment
},
props: {
commentList: {
type: Array,
default: []
}
},
methods: {
isEmpty(ls) {
return ls.length === 0;
},
handleReply(data) {
this.$emit("handleReply", {
msgId: data.msgId,
msgUsername: data.msgUsername
});
}
}
};
</script>
<style scoped>
.head-message {
font-size: 20px;
text-align: center;
}
</style>
3.3 留言區域由多個Comment留言組件所構成,留言組件定義如下
<template>
<a-comment>
<span
slot="actions"
key="comment-basic-reply-to"
@click="handlReply(comment.id, comment.username)"
>
<a href="#my-textarea">回復</a>
</span>
<a slot="author" style="font-size: 15px">{{comment.username}}</a>
<a
v-if="comment.parent_msg_username"
slot="author"
class="reply-to"
>@{{comment.parent_msg_username}}</a>
<a-avatar slot="avatar" :src="require('assets/images/login_logo.png')" alt />
<p slot="content">{{comment.content}}</p>
<a-tooltip slot="datetime">
<span>{{comment.date}}</span>
</a-tooltip>
<slot name="childComment"></slot>
</a-comment>
</template>
<script>
export default {
name: "Comment",
props: {
comment: ""
},
methods: {
handlReply(msgId, msgUsername) {
this.$emit("handleReply", { msgId, msgUsername });
}
}
};
</script>
<style scoped>
.reply-to {
padding-left: 5px;
color: #409eff;
font-weight: 500;
font-size: 15px;
}
</style>
3.4 添加留言或回復的表單組件CommentArea.vue
<template>
<div>
<a-comment id="comment-area">
<a-avatar slot="avatar" :src="require('assets/images/login_logo.png')" alt="Han Solo" />
<div slot="content">
<a-form-item>
<a-textarea id="my-textarea" :rows="4" v-model="content" />
</a-form-item>
<a-form-item>
<a-button
html-type="submit"
:loading="submitting"
type="primary"
@click="handleSubmit"
>添加留言</a-button>
</a-form-item>
</div>
</a-comment>
</div>
</template>
<script>
import {insertMsg} from 'network/ajax.js'
export default {
data() {
return {
content: "",
submitting: false
};
},
props: {
parentMsgId: "",
replyMsgUsername: ""
},
watch: {
replyMsgUsername() {
document
.querySelector("#my-textarea")
.setAttribute("placeholder", "回復: " + "@" + this.replyMsgUsername);
}
},
methods: {
handleSubmit() {
if (!this.content) {
return;
}
this.submitting = true;
insertMsg(this.content, this.parentMsgId, this.$store.state.userId).then(res => {
this.submitting = false;
this.content = "";
document
.querySelector("#my-textarea")
.setAttribute("placeholder", '');
this.$emit('reload')
}).catch(err => {
console.log(err);
this.$router.push('/500')
})
},
handleChange(e) {
this.value = e.target.value;
}
}
};
</script>
組裝完成后實現的功能有:
- 留言界面的展示
- 點擊回復按鈕跳到留言表單(這里我直接用了a標簽來錨定位,試過用scrollToView來平滑滾動過去,但不知道為什么只有第一次點擊回復按鈕時才能平滑滾動到,之后再點擊他就不滾動了。。。),並把被回復者的用戶名顯示在placeholder中
-
點擊添加留言按鈕,清空placeholder,並自動實現router-view的局部刷新(不是整頁刷新)顯示出新增的留言
局部刷新的實現就是通過代碼中的自定義事件
reload
,具體就是從表單組件開始發送reload
事件,其父組件Message.vue
收到后,再繼續發送reload
事件給外層的視圖Home.vue,Home的再外層就是App.vue了,Home.vue的定義如下:<template> <el-container class="main-el-container"> <!-- 側邊欄 --> <el-aside width="15%" class="main-el-aside"> <side-bar></side-bar> </el-aside> <!-- 主體部分 --> <el-main> <el-main> <router-view @reload="reload" v-if="isRouterAlive"></router-view> </el-main> </el-main> </el-container> </template> <script> import SideBar from "components/common/sidebar/SideBar"; export default { name: "Home", components: { SideBar }, data() { return { isRouterAlive: true }; }, props: { isReload: "" }, watch: { isReload() { this.reload(); } }, methods: { reload() { this.isRouterAlive = false; this.$nextTick(() => { this.isRouterAlive = true; }); } } }; </script> <style scoped> .main-el-container { height: 750px; border: 1px solid #eee; } .main-el-aside { background-color: rgb(238, 241, 246); } </style>
里面有一個reload方法,通過改變isRouterAlive來讓router-view先隱藏,再顯示,實現重新掛載。