评论组件

仓库链接
[[前端了解/仿知乎, 掘金vue3项目/_resources/评论组件/3cc93b50abce1b169da3d99c0ffa751a_MD5.jpeg|Open: Pasted image 20250922101620.png]]
![[前端了解/仿知乎, 掘金vue3项目/_resources/评论组件/3cc93b50abce1b169da3d99c0ffa751a_MD5.jpeg]]

  • 评论CommentList

    • commentPost, 发表评论的文本框加按钮

      • 发布图片CommentImage

        • 图片链接CommentImgUrl
      • 发布帖子, 添加与父组件的响应事件 – postCommentFinish

    • 评论列表CommentListItem

      • 每发表一个新评论, 就把后端返回的数据(回复评论下的所有评论) 中最后一个数添加到父级评论数组中 postCommentFinish

        • 一级评论直接把评论列表更新为后端返回的数据

        • 二级评论添加到评论列表的子级中

    • 处理评论数量时需要多次回调

    • 头部进行分类 orderChange

[[前端了解/仿知乎, 掘金vue3项目/_resources/评论组件/976955c63c85f199b6a01b7f63f94d6a_MD5.jpeg|Open: Pasted image 20250922101753.png]]
![[前端了解/仿知乎, 掘金vue3项目/_resources/评论组件/976955c63c85f199b6a01b7f63f94d6a_MD5.jpeg]]

组件功能简介

CommentList : 包含分类按钮, 控制评论列表的刷新, 评论的基本布局
- CommentPost : 发表评论的组件
- CommentListItem: 评论的展示
CommentListItem : 一级评论和二级评论的展示, 通过遍历数组, 来展示每一条一级评论(一级附带的二级评论)
CommentPost: 发布一级评论或二级评论
CommentImage : 评论里面图片的展示

详解

CommentList

样式

页面展示:
评论排序 -> 一级评论发布的文本框 -> 评论列表

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
<template>
<div class="comment-body">
<div class="comment-title">
<div class="title">
评论
<span class="count">{{ commentListInfo.totalCount }}</span>
</div>
<div class="tab">
<span
@click="orderChange(0)"
:class="{'a-link':orderType === 0}"
>热榜</span>
<el-divider direction="vertical"></el-divider>
<span
@click="orderChange(1)"
:class="{'a-link':orderType === 1}"
>最新</span>
</div>
</div>
<!-- 发送评论 -->
<div class="comment-form-pannel">
<CommentPost
:articlrUserId="articleUserId"
:articleId="articleId"
:avatarWidth="50"
:userId="currentUserInfo.userId"
:showInsertImg="currentUserInfo.userId !== null"
:pCommentId="0"
@postCommentFinish="postCommentFinish"
></CommentPost>
</div>
<div class="comment-list">
<DataList
:dataSource="commentListInfo"
:loading="loading"
@loadData="loadComment"
noDataMsg="暂无评论, 赶紧占沙发吧!"
>
<template #default="{data}">
<CommentListItem
:articleId="articleId"
:commentData="data"
:articleUserId="articleUserId"
:currentUserId="currentUserInfo.userId"
@hiddenAllReply="hiddenAllReplyHandler"
@reloadData="loadComment"
>
</CommentListItem>
</template>
</DataList>
</div>
</div>
</template>

<style scoped>
.comment-body {
.comment-title {
align-items: center;
display: flex;
.title {
font-size: 22px;
.count {
font-size: 19px;
padding: 0px 10px;
}
}
}

}
</style>

函数

1
2
3
4
5
6
import CommentListItem from './CommentListItem.vue';
import { useUserStore } from '@/store/index.js';
import {ref, watch} from 'vue'
import { getCurrentInstance } from 'vue';
import CommentPost from './CommentPost.vue';
import { handleCurrentChange } from 'element-plus/es/components/tree/src/model/util.mjs';

基础数据

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

const userStore = useUserStore()
const {proxy} = getCurrentInstance()

const props = defineProps({
articleId: {
type: String,
},
articleUserId: {
type: String,
}
})
const api = {
loadComment: "/comment/loadComment",
postComment: "/comment/postComment",
doLike: "/comment/doLike",
}


const currentUserInfo = ref({})
currentUserInfo.value = userStore.loginUserInfo || {}

//form信息, post内容
const formData = ref({})
const formDataRef = ref({})
const rules = {
title: [
{required: true, message: "请输入评论内容"},
]
}

排序

点击不同的分类, 修改数据后改变params传递的值, 重新渲染评论板块

1
2
3
4
5
6
//排序
const orderType = ref(0)
const orderChange = (type) => {
  orderType.value = type
  loadComment()
}

渲染评论列表

传递对应的params信息, 后端返回相应的评论
loading 表示加载动画的显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//评论列表
const loading = ref(null)
const commentListInfo = ref({})

const loadComment = async() => {
let params = {
pageNo: commentListInfo.value.pageNo, //页码脚标
articleId: props.articleId, //文章id
orderType: orderType.value, //排序
}
loading.value = true
let result = await proxy.Request({
url: api.loadComment,
params,
})

loading.value = false
if (!result) {
return
}
commentListInfo.value = result.data
}

loadComment()

发布评论

刚发布时, 会把评论放在列表的最上方
刷新网页后, 评论按照排序顺序重新排列
发布评论的同时更新评论数量

1
2
3
4
5
6
7
8
//评论发布完成
const emit = defineEmits(['updateCommentCount'])
const postCommentFinish = (resultData) => {
commentListInfo.value.list.unshift(resultData)
const totalCount = commentListInfo.value.totalCount + 1
commentListInfo.value.totalCount = totalCount
emit('updateCommentCount', totalCount)
}

CommentListItem

样式

先显示一级评论的信息
如果有二级评论, 再显示二级评论
最后通过 一/二级评论的 “回复” 图标控制发布评论的文本框

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257

<template>
<div class="comment-item">
<Avatar
:userId="commentData.userId"
:width="50"
></Avatar>
<div class="comment-info">
<div class="nick-name"
@click="gotoUcenter(commentData.userId)"
>
<span class="name">{{ commentData.nickName }}</span>
<span
class="tag-author"
v-if="commentData.userId === articleUserId"
>作者</span>
</div>
<div class="comment-content">
<div>
<span
class="tag tag-toping"
v-if="commentData.topType === 1"
>置顶</span>
<span
class="tag no-audit"
v-if="commentData.status === 0"
>待审核</span>
<span v-html="commentData.content"></span>
</div>
{{ commentData.imageUrl }}
<CommentImage
:style="{'margin-top':'10px'}"
v-if="commentData.imgPath"
:src="commentImgUrl"
:imgList="[proxy.globalInfo.imageUrl + commentData.imgPath]"
></CommentImage>
</div>
<div class="op-panel">
<div class="time">
<span>{{ commentData.postTime }}</span>
<span class="address">
&nbsp;.&nbsp;{{ commentData.userIpAddress }}
</span>
</div>
<div
:class="{active: commentData.likeType === 1}"
class="iconfont icon-good"
@click="doLike(commentData)"
>
{{ commentData.goodCount>0 ? commentData.goodCount : '点赞' }}
</div>
<div
class="iconfont icon-comment"
@click="showReplayPanel(commentData, 0)"
>
回复
</div>
<el-dropdown
v-if="articleUserId === currentUserId"
>
<div class="iconfont icon-more"></div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
@click="onTop(commentData)"
>
{{
commentData.topType === 0 ? '设为置顶' : '取消置顶'
}}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div v-if="commentData.children">
<div v-if="showMore">
<el-icon @click="changeShowState"><ArrowUp /></el-icon>
</div>
<div v-else>
<el-icon @click="changeShowState"><ArrowDown /></el-icon>
</div>
</div>
</div>
<div class="comment-sub-list"
v-if="commentData.children && showMore"
>
<div class="comment-sub-item"
v-for="sub in commentData.children"
>
<Avatar
:userId="sub.userId"
:width="35"
></Avatar>
<div class="comment-sub-info">
<div class="nick-name"
>
<span
class="name"
@click="gotoUcenter(sub.userId)"
>{{sub.nickName}}</span>
<span
class="tag-author"
v-if="sub.userId === articleUserId"
>作者</span>
<span class="reply-name">回复</span>
<span
@click="gotoUcenter(sub.replyUserId)"
class="a-link"
>@{{ sub.replyNickName }}</span>
<span> : </span>
<span class="sub-content" v-html="sub.content"></span>
</div>
<div class="op-panel">
<div class="time">
<span>{{ sub.postTime }}</span>
<span class="address">
&nbsp;.&nbsp;{{ sub.userIpAddress }}
</span>
</div>
<div
:class="{active: sub.likeType === 1}"
class="iconfont icon-good"
@click="doLike(sub)"
>
{{ sub.goodCount>0 ? sub.goodCount : '点赞' }}
</div>
<div
class="iconfont icon-comment"
@click="showReplayPanel(sub, 1)"
>
回复
</div>
</div>
</div>
</div>
</div>
<div
class="replay-info"
v-if="commentData.showReplay">
<CommentPost
:articleId="articleId"
:avatarWidth="35"
:placeholderInfo="placeholderInfo"
:replyUserId="replyUserId"
:userId="currentUserId"
:showInsertImg="false"
:pCommentId="pCommentId"
@postCommentFinish="postCommentFinish"
></CommentPost>
</div>
</div>
</div>
</template>

<style scoped>
.comment-item {
display: flex;
padding-top: 6px;
.comment-info {
flex: 1;
margin-left: 10px;
border-bottom: 1px solid #ddd;
padding-bottom: 8px;
.nick-name {
align-items: center;
.name {
margin-right: 10px;
font-size: 14px;
color: var(--text2);
}
.tag-author {
padding: 2px 3px;
background: var(--pink);
color: #fff;
font-size: 12px;
border-radius: 2px;
}
}
.comment-content {
margin-top: 5px;
font-size: 15px;
line-height: 22px;
.tag {
margin-right: 5px;
font-size: 13px;
border-radius: 3px;
padding: 1px 4px;
}
.tag-toping {
color: var(--pink);
border: 1px solid var(--pink);
}
.no-audit {
color: var(--text2);
border: 1px solid var(--text2);
}
}
.op-panel {
align-items: center;
display: flex;
margin-top: 5px;
font-size: 13px;
color: var(--text2);
.address {
margin: 0px 15px
}
.iconfont {
cursor: pointer;
color: var(--icon);
margin-right: 12px;
}
.active {
color: var(--link);
}
.iconfont::before {
margin-right: 5px;
}
.icon-more {
outline: none;
}
}
.comment-sub-list {
margin-top: 10px;
.comment-sub-item {
display: flex;
margin: 10px 0px;
.comment-sub-info {
.nick-name {
.reply-name{
margin: 0 5px;
}
.tag-author {
padding: 1.5px;
background: var(--pink);
color: #fff;
font-size: 12px;
border-radius: 2px;
}
.reply-name {
margin: 0px 5px;
font-size: 14px;
}
}
.op-panel {
margin-top: 5px;
.active {
color: var(--link);
}
}
}
}
}
.replay-info {
margin-top: 15px;
}
}
}
</style>

函数

基础数据

1
2
3
4
5
6
7
import CommentImage from './CommentImage.vue';
import { defineProps, ref, computed } from 'vue';
import CommentPost from './CommentPost.vue';
import Avatar from '@/components/Avatar.vue';
import { useRouter } from 'vue-router';
import { getCurrentInstance } from 'vue';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const { proxy } = getCurrentInstance()
const router = useRouter()

const props = defineProps({
articleId: {
type: String,
},
commentData: {
type: Object,
},
articleUserId: {
type: String,
},
currentUserId: {
type: String,
}
})

const api = {
doLike: "/comment/doLike",
changeTopType: "/comment/changeTopType",
}
const placeholderInfo = ref('')

CommentPost文本框的控制

点击回复时, 若type为0 则为一级评论; 为 1则为二级评论

更新文本框里面的提示信息, 回复的人的id信息, 文本框的出现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//回复的评论的id
const pCommentId = ref(0)
const replyUserId = ref(null)

const emit = defineEmits(["reloadData"])

//发布评论
const showReplayPanel = (curData, type) => {
if (type === 0) {
if(curData.showReplay === true && pCommentId.value === curData.commentId)
curData.showReplay = false
else curData.showReplay = true
} else {
if (props.commentData.showReplay !== true)
props.commentData.showReplay = true
else if (pCommentId.value === curData.commentId) {
props.commentData.showReplay = false
}
}

replyUserId.value = curData.userId
placeholderInfo.value = "回复 @" + curData.nickName
pCommentId.value = curData.commentId
}

二级评论是否展示

通过showMore切换图标, 同时控制二级评论信息的展示

1
2
3
4
5
//是否展示二级评论
const showMore = ref(false)
const changeShowState = () => {
  showMore.value = !showMore.value
}

发布评论

此组件里面发布的评论均为二级评论
更新一级评论children里面的内容

1
2
3
4
5
6
7
8
const postCommentFinish = (resultData) => {
//当发布第一个二级评论时, 需要将children初始化, 防止children为空
if (!props.commentData.children) {
props.commentData.children = [];
}
props.commentData.children.push(resultData[resultData.length - 1])
props.commentData.showReplay = false
}

点赞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//点赞
const doLike = async (data) => {
let result = await proxy.Request({
url: api.doLike,
showloading: false,
params: {
commentId: data.commentId,
}
})
if (!result) {
return
}
data.goodCount = result.data.goodCount
data.likeType = result.data.likeType
}

评论的图片信息

图片的路径加上项目的路径

1
2
3
4
5
6
7
//展示图片 
const commentImgUrl = computed(() => {
if (props.commentData.imgPath) {
return proxy.globalInfo.imageUrl + props.commentData.imgPath.replace('.','_.')
}
return ''
})

置顶

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//置顶
const emit = defineEmits(["reloadData"])
const onTop = async(data) => {
let result = await proxy.Request({
url: api.changeTopType,
params: {
commentId: data.commentId,
topType: data.topType === 1 ? 0 : 1
}
})
if (!result) {
return
}
emit('reloadData')
}

CommentPost

样式

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97

<template>
<div class="post-comment-panel">
<Avatar
:width="avatarWidth"
:userId="userId"
></Avatar>
<div class="comment-form">
<el-form
:model="formData"
:rules="rules"
ref="formDataRef"
>
<!-- textarea输入 -->
<el-form-item prop="content">
<el-input
clearable
:placeholder=placeholderInfo
type="textarea"
:maxlength="200"
resize="none"
show-word-limit
v-model="formData.content"
>
</el-input>
<div
class="insert-img"
v-if="showInsertImg">
<div class="pre-img" v-if="commentImg">
<CommentImage
:src="commentImg"
></CommentImage>
<span
class="iconfont icon-remove"
@click="removeCommentImg"
></span>
</div>
<el-upload
v-else
name="file"
:show-file-list="false"
accept=".png,.PNG,.jpg,.JPG,.jepg,.JEPG,.gif,.GIF,.bmp,.BMP"
:multiple="false"
:http-request="selectImg"
>
<span class="iconfont icon-image"></span>
</el-upload>
</div>
</el-form-item>
</el-form>
</div>
<div class="send-btn" @click="postCommentDo">发表</div>
</div>
</template>

<style scoped>
.post-comment-panel {
margin-top: 10px;
display: flex;
align-items: top;
.comment-form{
margin: 0 10px;
flex: 1;
.insert-img {
line-height: normal;
.iconfont {
cursor: pointer;
margin-top: 3px;
font-size: 21px;
color: #4b4848;
}
.pre-img {
margin-top: 10px;
position: relative;
.iconfont {
position: absolute;
top: -10px;
right: -10px;
color: rgb(104, 102, 102);
}
}
}
}
.send-btn {
width: 55px;
height: 55px;
background: var(--link);
color: #fff;
text-align: center;
line-height: 55px;
font-size: 18px;
border-radius: 10px;
cursor: pointer;
}
}

</style>

函数

基础数据

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
import CommentImage from './CommentImage.vue';
import { defineProps, ref } from 'vue';
import { getCurrentInstance } from 'vue';

const {proxy} = getCurrentInstance()

const props = defineProps({
articleId: {
type: String
},
avatarWidth: {
type: Number,
},
userId: {
type: String
},
showInsertImg: {
type: Boolean,
},
placeholderInfo: {
type: String,
default: "请文明发言,做一个棒棒的程序员!"
},
pCommentId: {
type: Number,
} ,
replyUserId: {
type: String
},
})

const api = {
postComment: "/comment/postComment",
}

文本框信息验证

不满足条件时打印对应的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const checkPostComment = (rule, value, callback) => {
if (value === null && formData.value.image === null) {
callback(new Error(rule.message))
} else {
callback()
}
}
const formData = ref({})
const formDataRef = ref({})
const rules = {
content: [
{required: true, message: "请输入评论内容", validator: checkPostComment},
{min: 5, message: "评论至少5个字"}
]
}

发布帖子

与父组件CommentListCommentListitem均有信息传递
当点击发表时, 新发布的内容传递到父组件, 父组件在添加到评论列表中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const emit = defineEmits(["postCommentFinish"])

const postCommentDo = () => {
formDataRef.value.validate(async (valid) => {

if (!valid) {
return;
}

let params = Object.assign({}, formData.value)
params.articleId = props.articleId
params.pCommentId = props.pCommentId
params.replyUserId = props.replyUserId

let result = await proxy.Request({
url: api.postComment,
params,
})
proxy.Message.success("评论发表成功")
formDataRef.value.resetFields()
removeCommentImg()
emit("postCommentFinish", result.data)
})
}

图片处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  // 选择图片
const commentImg = ref(null)
const selectImg = (file) => {
file = file.file
let img = new FileReader()
img.readAsDataURL(file)
img.onload = ({target}) => {
let imgData = target.result
commentImg.value = imgData
formData.value.image = file
}
}

//删除图片
const removeCommentImg = () => {
commentImg.value = null
formData.value.image = null
}