仓库链接
[[前端了解/仿知乎, 掘金vue3项目/_resources/评论组件/3cc93b50abce1b169da3d99c0ffa751a_MD5.jpeg|Open: Pasted image 20250922101620.png]]
![[前端了解/仿知乎, 掘金vue3项目/_resources/评论组件/3cc93b50abce1b169da3d99c0ffa751a_MD5.jpeg]]
评论CommentList
commentPost, 发表评论的文本框加按钮
评论列表CommentListItem
处理评论数量时需要多次回调
头部进行分类 orderChange
[[前端了解/仿知乎, 掘金vue3项目/_resources/评论组件/976955c63c85f199b6a01b7f63f94d6a_MD5.jpeg|Open: Pasted image 20250922101753.png]]
![[前端了解/仿知乎, 掘金vue3项目/_resources/评论组件/976955c63c85f199b6a01b7f63f94d6a_MD5.jpeg]]
组件功能简介
CommentList : 包含分类按钮, 控制评论列表的刷新, 评论的基本布局
- CommentPost : 发表评论的组件
- CommentListItem: 评论的展示
CommentListItem : 一级评论和二级评论的展示, 通过遍历数组, 来展示每一条一级评论(一级附带的二级评论)
CommentPost: 发布一级评论或二级评论
CommentImage : 评论里面图片的展示
详解
样式
页面展示:
评论排序 -> 一级评论发布的文本框 -> 评论列表
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) }
|
样式
先显示一级评论的信息
如果有二级评论, 再显示二级评论
最后通过 一/二级评论的 “回复” 图标控制发布评论的文本框
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"> . {{ 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"> . {{ 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('')
|
点击回复时, 若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') }
|
样式
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个字"} ] }
|
发布帖子
与父组件CommentList和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
| 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 }
|