uni-app 评论系统
核心知识点讲解
1. 评论系统概述
评论系统是用户生成内容(UGC)应用的重要组成部分,它可以:
- 增强用户互动和参与感
- 提供有价值的用户反馈
- 增加内容的丰富度和可信度
- 促进社区氛围的形成
2. 评论系统架构
一个完整的评论系统通常包含以下组件:
2.1 前端组件
- 评论输入框:用于用户输入评论内容
- 评论列表:展示评论内容和回复
- 评论操作:点赞、回复、举报等功能
- 评论排序:按时间、热度等排序
2.2 后端服务
- 评论 API:处理评论的创建、获取、更新、删除
- 评论存储:数据库设计和存储策略
- 评论审核:人工或自动审核机制
- 防 spam:防止垃圾评论的措施
3. 评论数据结构
合理的评论数据结构设计对于系统的可扩展性非常重要:
3.1 基本评论结构
{
id: String, // 评论ID
content: String, // 评论内容
userId: String, // 用户ID
userName: String, // 用户名
userAvatar: String, // 用户头像
targetId: String, // 目标ID(如文章ID、商品ID)
targetType: String, // 目标类型(如 article、product)
createdAt: Timestamp, // 创建时间
updatedAt: Timestamp, // 更新时间
status: String, // 状态(如 pending、approved、rejected)
likes: Number, // 点赞数
replies: Number // 回复数
}3.2 回复结构
{
id: String, // 回复ID
commentId: String, // 父评论ID
content: String, // 回复内容
userId: String, // 用户ID
userName: String, // 用户名
userAvatar: String, // 用户头像
replyToUserId: String,// 被回复用户ID
replyToUserName: String, // 被回复用户名
createdAt: Timestamp, // 创建时间
status: String, // 状态
likes: Number // 点赞数
}4. 评论功能实现
4.1 评论发布
- 输入验证:内容长度、敏感词过滤
- 用户认证:确保用户已登录
- 发布流程:前端提交 → 后端验证 → 存储 → 返回结果
4.2 评论回复
- 回复层级:支持多级回复或仅支持一级回复
- 回复通知:通知被回复的用户
- 回复展示:嵌套展示或平级展示
4.3 评论审核
- 自动审核:关键词过滤、AI 内容识别
- 人工审核:后台审核界面
- 审核状态:待审核、已通过、已拒绝
4.4 评论排序
- 时间排序:最新优先或最早优先
- 热度排序:按点赞数、回复数排序
- 混合排序:结合时间和热度
4.5 评论互动
- 点赞功能:支持对评论和回复点赞
- 举报功能:用户可以举报不良评论
- 分享功能:分享有价值的评论
5. 性能优化策略
评论系统的性能优化对于用户体验至关重要:
- 分页加载:避免一次性加载过多评论
- 懒加载:滚动到底部时加载更多评论
- 缓存策略:缓存热门评论和评论列表
- 预加载:预加载可能需要的评论数据
- 异步处理:非关键操作异步执行
6. 安全性考虑
评论系统需要注意以下安全问题:
- XSS 防护:防止跨站脚本攻击
- CSRF 防护:防止跨站请求伪造
- SQL 注入防护:防止数据库注入攻击
- 速率限制:防止恶意刷评论
- 敏感词过滤:过滤不良内容
实用案例分析
案例:实现完整的评论系统
功能需求
我们需要实现一个包含以下功能的评论系统:
- 评论发布和回复
- 评论列表和排序
- 评论点赞和举报
- 评论审核后台
实现方案
1. 评论 API 服务
首先,我们需要实现评论相关的 API 服务:
// api/comment.js
// 导入请求封装
import request from './request';
// 评论 API
export const commentApi = {
// 获取评论列表
getComments: (params) => {
return request({
url: '/api/comments',
method: 'GET',
params
});
},
// 发布评论
createComment: (data) => {
return request({
url: '/api/comments',
method: 'POST',
data
});
},
// 回复评论
replyComment: (commentId, data) => {
return request({
url: `/api/comments/${commentId}/replies`,
method: 'POST',
data
});
},
// 点赞评论
likeComment: (commentId) => {
return request({
url: `/api/comments/${commentId}/like`,
method: 'POST'
});
},
// 取消点赞
unlikeComment: (commentId) => {
return request({
url: `/api/comments/${commentId}/unlike`,
method: 'POST'
});
},
// 举报评论
reportComment: (commentId, data) => {
return request({
url: `/api/comments/${commentId}/report`,
method: 'POST',
data
});
},
// 删除评论
deleteComment: (commentId) => {
return request({
url: `/api/comments/${commentId}`,
method: 'DELETE'
});
}
};2. 评论组件
实现评论列表和发布功能的前端组件:
<template>
<view class="comment-system">
<!-- 评论发布 -->
<view class="comment-input-section">
<view class="user-info">
<image :src="userInfo.avatar" class="user-avatar"></image>
<text class="user-name">{{ userInfo.name }}</text>
</view>
<textarea
v-model="commentContent"
class="comment-input"
placeholder="写下你的评论..."
maxlength="500"
></textarea>
<view class="comment-actions">
<text class="comment-length">{{ commentContent.length }}/500</text>
<button
type="primary"
class="submit-btn"
:disabled="!commentContent.trim()"
@click="submitComment"
>
发布
</button>
</view>
</view>
<!-- 评论排序 -->
<view class="comment-sort">
<text
class="sort-item"
:class="{ active: sortBy === 'time' }"
@click="setSortBy('time')"
>
最新
</text>
<text
class="sort-item"
:class="{ active: sortBy === 'hot' }"
@click="setSortBy('hot')"
>
最热
</text>
</view>
<!-- 评论列表 -->
<view class="comment-list">
<view
v-for="comment in comments"
:key="comment.id"
class="comment-item"
>
<!-- 评论头部 -->
<view class="comment-header">
<image :src="comment.userAvatar" class="comment-avatar"></image>
<view class="comment-user-info">
<text class="comment-user-name">{{ comment.userName }}</text>
<text class="comment-time">{{ formatTime(comment.createdAt) }}</text>
</view>
<view class="comment-actions">
<text
class="comment-action"
@click="showCommentMenu(comment)"
>
···
</text>
</view>
</view>
<!-- 评论内容 -->
<view class="comment-content">
<text>{{ comment.content }}</text>
</view>
<!-- 评论操作 -->
<view class="comment-footer">
<view
class="comment-operation"
@click="toggleLike(comment)"
>
<text
class="like-icon"
:class="{ active: comment.isLiked }"
>
{{ comment.isLiked ? '❤️' : '🤍' }}
</text>
<text class="like-count">{{ comment.likes }}</text>
</view>
<view
class="comment-operation"
@click="showReplyInput(comment)"
>
<text class="reply-icon">💬</text>
<text class="reply-count">{{ comment.replies }}</text>
</view>
</view>
<!-- 回复列表 -->
<view v-if="comment.replies > 0" class="reply-list">
<view
v-for="reply in comment.replyList"
:key="reply.id"
class="reply-item"
>
<image :src="reply.userAvatar" class="reply-avatar"></image>
<view class="reply-content">
<text class="reply-user-name">{{ reply.userName }}</text>
<text v-if="reply.replyToUserName" class="reply-to"> 回复 {{ reply.replyToUserName }}:</text>
<text class="reply-text">{{ reply.content }}</text>
<text class="reply-time">{{ formatTime(reply.createdAt) }}</text>
</view>
<view class="reply-actions">
<text
class="reply-like"
:class="{ active: reply.isLiked }"
@click="toggleReplyLike(reply, comment)"
>
{{ reply.isLiked ? '❤️' : '🤍' }} {{ reply.likes }}
</text>
<text
class="reply-reply"
@click="showReplyInput(comment, reply)"
>
回复
</text>
</view>
</view>
<view
v-if="comment.replies > comment.replyList.length"
class="load-more-replies"
@click="loadMoreReplies(comment)"
>
查看更多回复
</view>
</view>
<!-- 回复输入框 -->
<view
v-if="replyingComment && replyingComment.id === comment.id"
class="reply-input-section"
>
<textarea
v-model="replyContent"
class="reply-input"
placeholder="写下你的回复..."
maxlength="200"
@keyup.enter.exact="submitReply(comment)"
></textarea>
<view class="reply-input-actions">
<text class="reply-length">{{ replyContent.length }}/200</text>
<button
type="primary"
size="mini"
:disabled="!replyContent.trim()"
@click="submitReply(comment)"
>
回复
</button>
<button
type="default"
size="mini"
@click="cancelReply"
>
取消
</button>
</view>
</view>
</view>
<!-- 加载更多 -->
<view
v-if="hasMore"
class="load-more"
@click="loadMoreComments"
>
加载更多评论
</view>
<!-- 无评论 -->
<view v-if="comments.length === 0" class="no-comments">
<text>暂无评论,快来抢沙发吧!</text>
</view>
</view>
<!-- 评论菜单弹窗 -->
<uni-popup
ref="commentMenuPopup"
type="bottom"
>
<view class="comment-menu">
<view class="menu-item" @click="reportComment(menuComment)">
<text class="menu-icon">🚫</text>
<text class="menu-text">举报</text>
</view>
<view
v-if="isOwner(menuComment)"
class="menu-item"
@click="deleteComment(menuComment)"
>
<text class="menu-icon">🗑️</text>
<text class="menu-text">删除</text>
</view>
<view class="menu-item cancel" @click="closeCommentMenu">
取消
</view>
</view>
</uni-popup>
</view>
</template>
<script>
import { commentApi } from '@/api/comment';
import uniPopup from '@dcloudio/uni-ui/lib/uni-popup/uni-popup.vue';
export default {
components: {
uniPopup
},
props: {
targetId: {
type: String,
required: true
},
targetType: {
type: String,
default: 'article'
}
},
data() {
return {
comments: [],
page: 1,
pageSize: 10,
hasMore: true,
sortBy: 'time',
commentContent: '',
replyContent: '',
replyingComment: null,
replyingTo: null,
menuComment: null,
userInfo: {
id: 'user123',
name: '当前用户',
avatar: 'https://via.placeholder.com/50'
}
};
},
mounted() {
this.loadComments();
},
methods: {
// 加载评论列表
loadComments() {
commentApi.getComments({
targetId: this.targetId,
targetType: this.targetType,
page: 1,
pageSize: this.pageSize,
sortBy: this.sortBy
}).then(res => {
this.comments = res.data.items;
this.hasMore = res.data.total > this.comments.length;
this.page = 1;
}).catch(err => {
console.error('加载评论失败:', err);
});
},
// 加载更多评论
loadMoreComments() {
if (!this.hasMore) return;
commentApi.getComments({
targetId: this.targetId,
targetType: this.targetType,
page: this.page + 1,
pageSize: this.pageSize,
sortBy: this.sortBy
}).then(res => {
this.comments = [...this.comments, ...res.data.items];
this.hasMore = res.data.total > this.comments.length;
this.page++;
}).catch(err => {
console.error('加载更多评论失败:', err);
});
},
// 提交评论
submitComment() {
if (!this.commentContent.trim()) return;
commentApi.createComment({
content: this.commentContent.trim(),
targetId: this.targetId,
targetType: this.targetType
}).then(res => {
// 清空输入框
this.commentContent = '';
// 添加到评论列表
this.comments.unshift(res.data);
// 显示成功提示
uni.showToast({
title: '评论发布成功',
duration: 2000
});
}).catch(err => {
console.error('发布评论失败:', err);
uni.showToast({
title: '评论发布失败',
duration: 2000,
icon: 'none'
});
});
},
// 提交回复
submitReply(comment) {
if (!this.replyContent.trim()) return;
commentApi.replyComment(comment.id, {
content: this.replyContent.trim(),
replyToUserId: this.replyingTo?.userId,
replyToUserName: this.replyingTo?.userName
}).then(res => {
// 清空输入框
this.replyContent = '';
this.replyingComment = null;
this.replyingTo = null;
// 更新评论的回复数
comment.replies++;
// 添加到回复列表
if (!comment.replyList) {
comment.replyList = [];
}
comment.replyList.push(res.data);
// 显示成功提示
uni.showToast({
title: '回复成功',
duration: 2000
});
}).catch(err => {
console.error('回复失败:', err);
uni.showToast({
title: '回复失败',
duration: 2000,
icon: 'none'
});
});
},
// 切换点赞状态
toggleLike(comment) {
if (comment.isLiked) {
// 取消点赞
commentApi.unlikeComment(comment.id).then(() => {
comment.isLiked = false;
comment.likes--;
}).catch(err => {
console.error('取消点赞失败:', err);
});
} else {
// 点赞
commentApi.likeComment(comment.id).then(() => {
comment.isLiked = true;
comment.likes++;
}).catch(err => {
console.error('点赞失败:', err);
});
}
},
// 显示回复输入框
showReplyInput(comment, replyTo = null) {
this.replyingComment = comment;
this.replyingTo = replyTo;
this.replyContent = '';
},
// 取消回复
cancelReply() {
this.replyingComment = null;
this.replyingTo = null;
this.replyContent = '';
},
// 加载更多回复
loadMoreReplies(comment) {
// 实现加载更多回复的逻辑
console.log('加载更多回复:', comment.id);
},
// 显示评论菜单
showCommentMenu(comment) {
this.menuComment = comment;
this.$refs.commentMenuPopup.open();
},
// 关闭评论菜单
closeCommentMenu() {
this.$refs.commentMenuPopup.close();
},
// 举报评论
reportComment(comment) {
// 实现举报评论的逻辑
console.log('举报评论:', comment.id);
this.closeCommentMenu();
uni.showToast({
title: '举报成功,我们会尽快处理',
duration: 2000
});
},
// 删除评论
deleteComment(comment) {
// 实现删除评论的逻辑
console.log('删除评论:', comment.id);
this.closeCommentMenu();
uni.showToast({
title: '评论已删除',
duration: 2000
});
},
// 设置排序方式
setSortBy(sortBy) {
if (this.sortBy === sortBy) return;
this.sortBy = sortBy;
this.loadComments();
},
// 格式化时间
formatTime(time) {
const date = new Date(time);
return date.toLocaleString();
},
// 判断是否是评论作者
isOwner(comment) {
return comment.userId === this.userInfo.id;
}
}
};
</script>
<style scoped>
.comment-system {
padding: 20rpx;
}
/* 评论输入区域 */
.comment-input-section {
background-color: #F5F5F5;
border-radius: 10rpx;
padding: 20rpx;
margin-bottom: 30rpx;
}
.user-info {
display: flex;
align-items: center;
margin-bottom: 15rpx;
}
.user-avatar {
width: 50rpx;
height: 50rpx;
border-radius: 50%;
margin-right: 15rpx;
}
.user-name {
font-size: 24rpx;
font-weight: 500;
}
.comment-input {
width: 100%;
min-height: 120rpx;
background-color: white;
border-radius: 8rpx;
padding: 15rpx;
font-size: 24rpx;
margin-bottom: 15rpx;
resize: none;
}
.comment-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.comment-length {
font-size: 20rpx;
color: #999;
}
.submit-btn {
padding: 0 30rpx;
}
/* 评论排序 */
.comment-sort {
display: flex;
margin-bottom: 20rpx;
border-bottom: 1rpx solid #F0F0F0;
padding-bottom: 15rpx;
}
.sort-item {
margin-right: 40rpx;
font-size: 24rpx;
color: #666;
padding-bottom: 10rpx;
}
.sort-item.active {
color: #007AFF;
border-bottom: 2rpx solid #007AFF;
}
/* 评论列表 */
.comment-list {
margin-bottom: 20rpx;
}
.comment-item {
margin-bottom: 30rpx;
padding-bottom: 30rpx;
border-bottom: 1rpx solid #F0F0F0;
}
.comment-header {
display: flex;
align-items: center;
margin-bottom: 15rpx;
}
.comment-avatar {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
margin-right: 15rpx;
}
.comment-user-info {
flex: 1;
}
.comment-user-name {
font-size: 24rpx;
font-weight: 500;
margin-bottom: 5rpx;
display: block;
}
.comment-time {
font-size: 20rpx;
color: #999;
}
.comment-content {
margin-bottom: 15rpx;
line-height: 1.5;
font-size: 24rpx;
}
.comment-footer {
display: flex;
align-items: center;
}
.comment-operation {
display: flex;
align-items: center;
margin-right: 40rpx;
font-size: 22rpx;
color: #666;
}
.like-icon,
.reply-icon {
margin-right: 5rpx;
}
.like-icon.active {
color: #FF3B30;
}
/* 回复列表 */
.reply-list {
margin-left: 55rpx;
margin-top: 20rpx;
}
.reply-item {
display: flex;
margin-bottom: 15rpx;
font-size: 22rpx;
}
.reply-avatar {
width: 30rpx;
height: 30rpx;
border-radius: 50%;
margin-right: 10rpx;
}
.reply-content {
flex: 1;
line-height: 1.4;
}
.reply-user-name {
font-weight: 500;
color: #333;
}
.reply-to {
color: #666;
}
.reply-text {
color: #333;
}
.reply-time {
display: block;
margin-top: 5rpx;
font-size: 18rpx;
color: #999;
}
.reply-actions {
display: flex;
align-items: center;
}
.reply-like {
margin-right: 15rpx;
color: #666;
}
.reply-like.active {
color: #FF3B30;
}
.reply-reply {
color: #007AFF;
}
.load-more-replies {
color: #007AFF;
font-size: 20rpx;
margin-top: 10rpx;
cursor: pointer;
}
/* 回复输入框 */
.reply-input-section {
margin-top: 20rpx;
margin-left: 55rpx;
background-color: #F5F5F5;
border-radius: 8rpx;
padding: 15rpx;
}
.reply-input {
width: 100%;
min-height: 80rpx;
background-color: white;
border-radius: 8rpx;
padding: 10rpx;
font-size: 22rpx;
margin-bottom: 10rpx;
resize: none;
}
.reply-input-actions {
display: flex;
justify-content: flex-end;
align-items: center;
}
.reply-length {
font-size: 18rpx;
color: #999;
margin-right: 10rpx;
}
/* 加载更多 */
.load-more {
text-align: center;
padding: 20rpx;
color: #007AFF;
font-size: 22rpx;
cursor: pointer;
}
/* 无评论 */
.no-comments {
text-align: center;
padding: 60rpx 0;
color: #999;
font-size: 24rpx;
}
/* 评论菜单 */
.comment-menu {
background-color: white;
border-radius: 20rpx 20rpx 0 0;
padding: 30rpx;
}
.menu-item {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 30rpx;
}
.menu-icon {
font-size: 40rpx;
margin-bottom: 10rpx;
}
.menu-text {
font-size: 22rpx;
}
.menu-item.cancel {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #F0F0F0;
}
/* 响应式设计 */
@media (max-width: 750rpx) {
.comment-system {
padding: 15rpx;
}
.comment-input {
min-height: 100rpx;
}
}
</style>2. 评论审核后台
为了管理评论内容,我们需要实现一个评论审核后台:
<template>
<view class="comment-admin">
<!-- 审核统计 -->
<view class="admin-stats">
<view class="stat-item">
<text class="stat-number">{{ stats.pending }}</text>
<text class="stat-label">待审核</text>
</view>
<view class="stat-item">
<text class="stat-number">{{ stats.approved }}</text>
<text class="stat-label">已通过</text>
</view>
<view class="stat-item">
<text class="stat-number">{{ stats.rejected }}</text>
<text class="stat-label">已拒绝</text>
</view>
</view>
<!-- 审核筛选 -->
<view class="admin-filter">
<text
class="filter-item"
:class="{ active: statusFilter === 'all' }"
@click="setStatusFilter('all')"
>
全部
</text>
<text
class="filter-item"
:class="{ active: statusFilter === 'pending' }"
@click="setStatusFilter('pending')"
>
待审核
</text>
<text
class="filter-item"
:class="{ active: statusFilter === 'approved' }"
@click="setStatusFilter('approved')"
>
已通过
</text>
<text
class="filter-item"
:class="{ active: statusFilter === 'rejected' }"
@click="setStatusFilter('rejected')"
>
已拒绝
</text>
</view>
<!-- 评论列表 -->
<view class="admin-comment-list">
<view
v-for="comment in comments"
:key="comment.id"
class="admin-comment-item"
:class="`status-${comment.status}`"
>
<!-- 评论头部 -->
<view class="admin-comment-header">
<view class="admin-comment-info">
<text class="admin-comment-id">ID: {{ comment.id }}</text>
<text class="admin-comment-status">{{ getStatusText(comment.status) }}</text>
<text class="admin-comment-time">{{ formatTime(comment.createdAt) }}</text>
</view>
<view class="admin-comment-target">
<text>{{ comment.targetType }}: {{ comment.targetId }}</text>
</view>
</view>
<!-- 评论用户信息 -->
<view class="admin-comment-user">
<image :src="comment.userAvatar" class="admin-user-avatar"></image>
<text class="admin-user-name">{{ comment.userName }}</text>
<text class="admin-user-id">ID: {{ comment.userId }}</text>
</view>
<!-- 评论内容 -->
<view class="admin-comment-content">
<text>{{ comment.content }}</text>
</view>
<!-- 审核操作 -->
<view class="admin-comment-actions" v-if="comment.status === 'pending'">
<button
type="primary"
size="mini"
@click="approveComment(comment)"
>
通过
</button>
<button
type="warn"
size="mini"
@click="rejectComment(comment)"
>
拒绝
</button>
</view>
<!-- 已审核操作 -->
<view class="admin-comment-actions" v-else>
<button
type="default"
size="mini"
@click="deleteComment(comment)"
>
删除
</button>
</view>
</view>
<!-- 加载更多 -->
<view
v-if="hasMore"
class="load-more"
@click="loadMoreComments"
>
加载更多
</view>
<!-- 无评论 -->
<view v-if="comments.length === 0" class="no-comments">
<text>暂无评论</text>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
comments: [],
stats: {
pending: 0,
approved: 0,
rejected: 0
},
statusFilter: 'pending',
hasMore: true
};
},
mounted() {
this.loadComments();
this.loadStats();
},
methods: {
// 加载评论
loadComments() {
// 实现加载评论的逻辑
console.log('加载评论:', this.statusFilter);
// 模拟数据
this.comments = [
{
id: '1',
content: '这是一条待审核的评论',
userId: 'user1',
userName: '用户1',
userAvatar: 'https://via.placeholder.com/50',
targetId: 'article1',
targetType: 'article',
createdAt: new Date().toISOString(),
status: 'pending'
},
{
id: '2',
content: '这是一条已通过的评论',
userId: 'user2',
userName: '用户2',
userAvatar: 'https://via.placeholder.com/50',
targetId: 'article1',
targetType: 'article',
createdAt: new Date().toISOString(),
status: 'approved'
}
];
},
// 加载统计数据
loadStats() {
// 实现加载统计数据的逻辑
console.log('加载统计数据');
// 模拟数据
this.stats = {
pending: 5,
approved: 120,
rejected: 10
};
},
// 设置状态筛选
setStatusFilter(status) {
this.statusFilter = status;
this.loadComments();
},
// 通过评论
approveComment(comment) {
// 实现通过评论的逻辑
console.log('通过评论:', comment.id);
comment.status = 'approved';
this.loadStats();
uni.showToast({
title: '评论已通过',
duration: 2000
});
},
// 拒绝评论
rejectComment(comment) {
// 实现拒绝评论的逻辑
console.log('拒绝评论:', comment.id);
comment.status = 'rejected';
this.loadStats();
uni.showToast({
title: '评论已拒绝',
duration: 2000
});
},
// 删除评论
deleteComment(comment) {
// 实现删除评论的逻辑
console.log('删除评论:', comment.id);
uni.showToast({
title: '评论已删除',
duration: 2000
});
},
// 加载更多评论
loadMoreComments() {
// 实现加载更多评论的逻辑
console.log('加载更多评论');
},
// 获取状态文本
getStatusText(status) {
const statusMap = {
pending: '待审核',
approved: '已通过',
rejected: '已拒绝'
};
return statusMap[status] || status;
},
// 格式化时间
formatTime(time) {
const date = new Date(time);
return date.toLocaleString();
}
}
};
</script>
<style scoped>
.comment-admin {
padding: 20rpx;
}
/* 统计 */
.admin-stats {
display: flex;
margin-bottom: 30rpx;
background-color: white;
border-radius: 10rpx;
overflow: hidden;
}
.stat-item {
flex: 1;
padding: 20rpx;
text-align: center;
border-right: 1rpx solid #F0F0F0;
}
.stat-item:last-child {
border-right: none;
}
.stat-number {
font-size: 32rpx;
font-weight: bold;
display: block;
margin-bottom: 5rpx;
}
.stat-label {
font-size: 20rpx;
color: #666;
}
/* 筛选 */
.admin-filter {
display: flex;
margin-bottom: 20rpx;
background-color: white;
border-radius: 10rpx;
padding: 10rpx;
}
.filter-item {
flex: 1;
text-align: center;
padding: 10rpx;
border-radius: 6rpx;
font-size: 22rpx;
}
.filter-item.active {
background-color: #007AFF;
color: white;
}
/* 评论列表 */
.admin-comment-list {
background-color: white;
border-radius: 10rpx;
overflow: hidden;
}
.admin-comment-item {
padding: 20rpx;
border-bottom: 1rpx solid #F0F0F0;
}
.admin-comment-item.status-pending {
background-color: #FFF9E6;
}
.admin-comment-item.status-approved {
background-color: #E8F5E9;
}
.admin-comment-item.status-rejected {
background-color: #FFEBEE;
}
.admin-comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15rpx;
font-size: 20rpx;
color: #666;
}
.admin-comment-status {
margin: 0 10rpx;
padding: 2rpx 8rpx;
border-radius: 4rpx;
font-size: 18rpx;
}
.admin-comment-item.status-pending .admin-comment-status {
background-color: #FFCC00;
color: white;
}
.admin-comment-item.status-approved .admin-comment-status {
background-color: #4CD964;
color: white;
}
.admin-comment-item.status-rejected .admin-comment-status {
background-color: #FF3B30;
color: white;
}
.admin-comment-user {
display: flex;
align-items: center;
margin-bottom: 15rpx;
}
.admin-user-avatar {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
margin-right: 10rpx;
}
.admin-user-name {
font-size: 22rpx;
font-weight: 500;
margin-right: 10rpx;
}
.admin-user-id {
font-size: 20rpx;
color: #999;
}
.admin-comment-content {
margin-bottom: 15rpx;
line-height: 1.5;
font-size: 22rpx;
}
.admin-comment-actions {
display: flex;
justify-content: flex-end;
}
.admin-comment-actions button {
margin-left: 10rpx;
}
/* 加载更多 */
.load-more {
text-align: center;
padding: 20rpx;
color: #007AFF;
font-size: 22rpx;
cursor: pointer;
}
/* 无评论 */
.no-comments {
text-align: center;
padding: 60rpx 0;
color: #999;
font-size: 24rpx;
}
</style>3. 防垃圾评论措施
为了防止垃圾评论,我们可以实现以下措施:
// utils/anti-spam.js
// 敏感词列表
const sensitiveWords = [
'垃圾', '广告', '推广', '营销',
// 更多敏感词...
];
// 检查敏感词
export function checkSensitiveWords(content) {
for (const word of sensitiveWords) {
if (content.includes(word)) {
return true;
}
}
return false;
}
// 检查评论是否为垃圾评论
export function isSpamComment(content, userId) {
// 1. 检查敏感词
if (checkSensitiveWords(content)) {
return true;
}
// 2. 检查评论长度
if (content.length < 5) {
return true;
}
// 3. 检查重复内容
// 实现检查重复内容的逻辑
// 4. 检查用户行为
// 实现检查用户行为的逻辑
return false;
}
// 生成评论评分
export function scoreComment(content) {
let score = 0;
// 内容长度评分
if (content.length > 50) {
score += 10;
} else if (content.length > 20) {
score += 5;
} else if (content.length > 5) {
score += 1;
}
// 内容质量评分
if (content.includes('谢谢') || content.includes('很棒')) {
score += 5;
}
// 敏感词扣分
if (checkSensitiveWords(content)) {
score -= 20;
}
return score;
}代码优化建议
1. 评论分页优化
使用虚拟列表来处理大量评论,提高渲染性能:
// 安装虚拟列表组件
// npm install @vueuse/core
// 使用虚拟列表
import { useVirtualList } from '@vueuse/core';
export default {
setup() {
const comments = ref([]);
const {
list, // 虚拟列表数据
containerProps, // 容器属性
itemProps // 项目属性
} = useVirtualList(
comments,
{
itemHeight: 200, // 估计项高
overscan: 5 // 预渲染数量
}
);
return {
list,
containerProps,
itemProps
};
}
};2. 评论缓存策略
实现评论缓存,减少重复请求:
// utils/comment-cache.js
// 评论缓存
export const commentCache = {
// 缓存评论列表
getComments: (targetId, targetType) => {
const key = `comments_${targetType}_${targetId}`;
return uni.getStorageSync(key);
},
// 缓存评论列表
setComments: (targetId, targetType, comments) => {
const key = `comments_${targetType}_${targetId}`;
uni.setStorageSync(key, comments);
},
// 清除评论缓存
clearComments: (targetId, targetType) => {
const key = `comments_${targetType}_${targetId}`;
uni.removeStorageSync(key);
},
// 清除所有评论缓存
clearAllComments: () => {
// 实现清除所有评论缓存的逻辑
}
};
// 使用缓存
async function loadComments(targetId, targetType) {
// 先从缓存获取
const cachedComments = commentCache.getComments(targetId, targetType);
if (cachedComments) {
return cachedComments;
}
// 从服务器获取
const comments = await commentApi.getComments({ targetId, targetType });
// 缓存评论
commentCache.setComments(targetId, targetType, comments);
return comments;
}3. 评论实时更新
使用 WebSocket 实现评论的实时更新:
// utils/comment-websocket.js
// WebSocket 连接
export class CommentWebSocket {
constructor() {
this.socket = null;
this.listeners = {};
}
// 连接 WebSocket
connect() {
this.socket = new WebSocket('wss://api.example.com/ws/comments');
this.socket.onopen = () => {
console.log('WebSocket 连接成功');
};
this.socket.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleMessage(data);
};
this.socket.onclose = () => {
console.log('WebSocket 连接关闭');
};
this.socket.onerror = (error) => {
console.error('WebSocket 错误:', error);
};
}
// 处理消息
handleMessage(data) {
const { type, payload } = data;
if (this.listeners[type]) {
this.listeners[type].forEach(callback => callback(payload));
}
}
// 订阅事件
on(type, callback) {
if (!this.listeners[type]) {
this.listeners[type] = [];
}
this.listeners[type].push(callback);
}
// 取消订阅
off(type, callback) {
if (this.listeners[type]) {
this.listeners[type] = this.listeners[type].filter(cb => cb !== callback);
}
}
// 发送消息
send(type, payload) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type, payload }));
}
}
// 关闭连接
close() {
if (this.socket) {
this.socket.close();
}
}
}
// 使用 WebSocket
const ws = new CommentWebSocket();
ws.connect();
// 订阅评论更新
ws.on('comment:created', (comment) => {
console.log('新评论:', comment);
// 更新评论列表
});
// 订阅评论点赞
ws.on('comment:liked', (data) => {
console.log('评论点赞:', data);
// 更新评论点赞数
});总结
本教程详细介绍了 uni-app 评论系统的实现方法,包括:
- 评论系统架构:前端组件和后端服务的设计
- 评论数据结构:合理的数据结构设计
- 评论功能实现:发布、回复、审核等功能
- 性能优化策略:分页加载、懒加载、缓存等
- 安全性考虑:XSS 防护、CSRF 防护等
通过本教程的学习,您应该能够:
- 理解评论系统的核心概念和实现原理
- 掌握评论系统的前端和后端实现方法
- 开发功能完整的评论系统
- 优化评论系统的性能和安全性
评论系统是增强用户互动的重要工具,一个好的评论系统可以显著提升应用的用户体验和社区氛围。在实现评论系统时,需要平衡功能丰富性、性能和安全性,为用户提供一个良好的交流平台。