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. 评论发布和回复
  2. 评论列表和排序
  3. 评论点赞和举报
  4. 评论审核后台

实现方案

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 评论系统的实现方法,包括:

  1. 评论系统架构:前端组件和后端服务的设计
  2. 评论数据结构:合理的数据结构设计
  3. 评论功能实现:发布、回复、审核等功能
  4. 性能优化策略:分页加载、懒加载、缓存等
  5. 安全性考虑:XSS 防护、CSRF 防护等

通过本教程的学习,您应该能够:

  • 理解评论系统的核心概念和实现原理
  • 掌握评论系统的前端和后端实现方法
  • 开发功能完整的评论系统
  • 优化评论系统的性能和安全性

评论系统是增强用户互动的重要工具,一个好的评论系统可以显著提升应用的用户体验和社区氛围。在实现评论系统时,需要平衡功能丰富性、性能和安全性,为用户提供一个良好的交流平台。

« 上一篇 uni-app 通知系统 下一篇 » uni-app 点赞系统