评论系统与情感分析

0 阅读3分钟

第4天-3:评论系统与情感分析

🎯 掘金标题:💬 如何设计一个高互动的博客评论系统(含情感分析)

📝 CSDN标题:Vue 3 + LocalStorage 实现博客评论系统:支持回复、点赞、情感分析


前言

评论区是博主与读者互动的重要场所。一个设计良好的评论系统可以:

  • 增加读者参与感和粘性
  • 帮助博主了解读者需求
  • 形成良好的社区氛围

今天分享如何实现一个功能完备的评论系统,并加入情感分析功能!

功能设计

功能列表

评论系统功能
├── 评论列表展示
│   ├── 按时间排序
│   ├── 回复嵌套显示
│   └── 分页加载
├── 评论交互
│   ├── 发布评论
│   ├── 回复评论
│   ├── 点赞评论
│   └── 删除评论
└── 智能功能
    ├── 评论字数统计
    ├── 敏感词过滤
    └── 情感分析

核心实现

1. 评论数据结构

// src/types/comment.ts
export interface Comment {
  id: string
  articleId: string
  userId: string
  userName: string
  userAvatar: string
  content: string
  createTime: number
  likes: number
  replies: Comment[]
  sentiment?: 'positive' | 'neutral' | 'negative' // 情感分析结果
  isLiked?: boolean
}

export interface CommentForm {
  content: string
  replyTo?: string
}

2. 评论服务

// src/services/comment.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Comment, CommentForm } from '@/types/comment'

// 情感分析函数(简化版)
function analyzeSentiment(text: string): 'positive' | 'neutral' | 'negative' {
  const positiveWords = ['赞', '好', '棒', '厉害', '感谢', '有用', '学习了', '支持下', '期待', '喜欢']
  const negativeWords = ['差', '烂', '垃圾', '没用', '失望', '无语', '问题', '错误', 'bug']
  
  let score = 0
  positiveWords.forEach(word => {
    if (text.includes(word)) score++
  })
  negativeWords.forEach(word => {
    if (text.includes(word)) score--
  })
  
  if (score > 0) return 'positive'
  if (score < 0) return 'negative'
  return 'neutral'
}

export const useCommentStore = defineStore('comment', () => {
  const comments = ref<Comment[]>([])
  
  // 获取文章评论
  const getCommentsByArticle = computed(() => {
    return (articleId: string) => comments.value
      .filter(c => c.articleId === articleId)
      .sort((a, b) => b.createTime - a.createTime)
  })
  
  // 加载评论
  function loadComments(articleId: string) {
    const key = `comments_${articleId}`
    const data = localStorage.getItem(key)
    if (data) {
      comments.value = JSON.parse(data)
    }
  }
  
  // 保存评论
  function saveComments(articleId: string) {
    const key = `comments_${articleId}`
    const articleComments = comments.value.filter(c => c.articleId === articleId)
    localStorage.setItem(key, JSON.stringify(articleComments))
  }
  
  // 添加评论
  function addComment(articleId: string, form: CommentForm, user: { id: string; name: string; avatar: string }) {
    const comment: Comment = {
      id: `comment_${Date.now()}_${Math.random().toString(36).slice(2)}`,
      articleId,
      userId: user.id,
      userName: user.name,
      userAvatar: user.avatar,
      content: form.content,
      createTime: Date.now(),
      likes: 0,
      replies: [],
      sentiment: analyzeSentiment(form.content)
    }
    
    if (form.replyTo) {
      // 添加回复
      const parentComment = findComment(comments.value, form.replyTo)
      if (parentComment) {
        parentComment.replies.push(comment)
      }
    } else {
      // 添加顶层评论
      comments.value.unshift(comment)
    }
    
    saveComments(articleId)
    return comment
  }
  
  // 查找评论
  function findComment(list: Comment[], id: string): Comment | null {
    for (const item of list) {
      if (item.id === id) return item
      if (item.replies.length > 0) {
        const found = findComment(item.replies, id)
        if (found) return found
      }
    }
    return null
  }
  
  // 点赞评论
  function toggleLike(commentId: string) {
    const comment = findComment(comments.value, commentId)
    if (comment) {
      comment.isLiked = !comment.isLiked
      comment.likes += comment.isLiked ? 1 : -1
      
      const articleId = comment.articleId
      saveComments(articleId)
    }
  }
  
  // 获取评论统计
  function getStats(articleId: string) {
    const articleComments = comments.value.filter(c => c.articleId === articleId)
    const allComments = [...articleComments]
    
    articleComments.forEach(c => {
      allComments.push(...c.replies)
    })
    
    const sentiments = {
      positive: allComments.filter(c => c.sentiment === 'positive').length,
      neutral: allComments.filter(c => c.sentiment === 'neutral').length,
      negative: allComments.filter(c => c.sentiment === 'negative').length
    }
    
    return {
      total: allComments.length,
      ...sentiments
    }
  }
  
  return {
    comments,
    getCommentsByArticle,
    loadComments,
    addComment,
    toggleLike,
    getStats
  }
})

3. 评论组件

<!-- src/components/comment/CommentSection.vue -->
<template>
  <div class="comment-section">
    <h3 class="section-title">
      💬 评论 ({{ totalComments }})
    </h3>
    
    <!-- 评论统计 -->
    <div v-if="totalComments > 0" class="sentiment-stats">
      <span class="stat positive">😊 赞 {{ stats.positive }}</span>
      <span class="stat neutral">😐 中立 {{ stats.neutral }}</span>
      <span class="stat negative">😔 待改进 {{ stats.negative }}</span>
    </div>
    
    <!-- 评论输入 -->
    <div class="comment-input">
      <el-avatar :size="40" src="/avatar.png" />
      <div class="input-wrapper">
        <el-input
          v-model="commentContent"
          type="textarea"
          :rows="3"
          :placeholder="replyTo ? `回复 @${replyToName}:` : '写下你的评论...'"
        />
        <div class="input-footer">
          <span class="char-count">{{ commentContent.length }}/500</span>
          <el-button type="primary" :disabled="!commentContent.trim()" @click="submitComment">
            发布评论
          </el-button>
        </div>
      </div>
    </div>
    
    <!-- 评论列表 -->
    <div class="comment-list">
      <CommentItem
        v-for="comment in comments"
        :key="comment.id"
        :comment="comment"
        @reply="handleReply"
        @like="handleLike"
      />
    </div>
    
    <!-- 加载更多 -->
    <div v-if="hasMore" class="load-more">
      <el-button @click="loadMore">加载更多</el-button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useCommentStore } from '@/services/comment'
import { ElMessage } from 'element-plus'
import CommentItem from './CommentItem.vue'

const props = defineProps<{
  articleId: string
}>()

const commentStore = useCommentStore()
const commentContent = ref('')
const replyTo = ref<string>('')
const replyToName = ref('')
const page = ref(1)
const pageSize = 10

const comments = computed(() => {
  return commentStore.getCommentsByArticle.value(props.articleId)
    .slice(0, page.value * pageSize)
})

const totalComments = computed(() => {
  return comments.value.length
})

const hasMore = computed(() => {
  return comments.value.length < commentStore.getCommentsByArticle.value(props.articleId).length
})

const stats = computed(() => {
  return commentStore.getStats(props.articleId)
})

function handleReply({ id, userName }: { id: string; userName: string }) {
  replyTo.value = id
  replyToName.value = userName
}

function handleLike(id: string) {
  commentStore.toggleLike(id)
}

function submitComment() {
  if (commentContent.value.length > 500) {
    ElMessage.warning('评论不能超过500字')
    return
  }
  
  // 模拟当前用户
  const user = {
    id: 'current_user',
    name: '访客',
    avatar: '/avatar.png'
  }
  
  commentStore.addComment(props.articleId, {
    content: commentContent.value,
    replyTo: replyTo.value || undefined
  }, user)
  
  commentContent.value = ''
  replyTo.value = ''
  replyToName.value = ''
  ElMessage.success('评论发布成功')
}

function loadMore() {
  page.value++
}

onMounted(() => {
  commentStore.loadComments(props.articleId)
})
</script>

<style scoped>
.comment-section {
  margin-top: 40px;
  padding: 24px;
  background: #fff;
  border-radius: 12px;
}

.section-title {
  font-size: 20px;
  margin-bottom: 16px;
}

.sentiment-stats {
  display: flex;
  gap: 16px;
  margin-bottom: 20px;
  padding: 12px;
  background: #f5f7fa;
  border-radius: 8px;
}

.stat {
  font-size: 14px;
}

.positive { color: #67c23a; }
.neutral { color: #909399; }
.negative { color: #f56c6c; }

.comment-input {
  display: flex;
  gap: 12px;
  margin-bottom: 24px;
}

.input-wrapper {
  flex: 1;
}

.input-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 8px;
}

.char-count {
  font-size: 12px;
  color: #999;
}

.comment-list {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.load-more {
  text-align: center;
  margin-top: 20px;
}
</style>
<!-- src/components/comment/CommentItem.vue -->
<template>
  <div class="comment-item" :class="{ 'is-reply': isReply }">
    <el-avatar :size="isReply ? 32 : 40" :src="comment.userAvatar" />
    
    <div class="comment-content">
      <div class="comment-header">
        <span class="user-name">{{ comment.userName }}</span>
        <span class="sentiment-badge" :class="comment.sentiment">
          {{ sentimentText }}
        </span>
        <span class="create-time">{{ formatTime(comment.createTime) }}</span>
      </div>
      
      <div class="comment-body">{{ comment.content }}</div>
      
      <div class="comment-actions">
        <span class="action-btn" @click="$emit('like', comment.id)">
          {{ comment.isLiked ? '❤️' : '🤍' }} {{ comment.likes }}
        </span>
        <span class="action-btn" @click="handleReply">
          💬 回复
        </span>
      </div>
      
      <!-- 回复列表 -->
      <div v-if="comment.replies?.length > 0" class="replies">
        <CommentItem
          v-for="reply in comment.replies"
          :key="reply.id"
          :comment="reply"
          :is-reply="true"
          @reply="$emit('reply', $event)"
          @like="$emit('like', reply.id)"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import type { Comment } from '@/types/comment'

const props = defineProps<{
  comment: Comment
  isReply?: boolean
}>()

defineEmits(['reply', 'like'])

const sentimentText = computed(() => {
  const map = {
    positive: '好评',
    neutral: '中立',
    negative: '待改进'
  }
  return map[props.comment.sentiment || 'neutral']
})

function formatTime(timestamp: number) {
  const date = new Date(timestamp)
  const now = new Date()
  const diff = now.getTime() - date.getTime()
  
  if (diff < 60000) return '刚刚'
  if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
  if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`
  
  return date.toLocaleDateString()
}

function handleReply() {
  // 滚动到评论输入框
  document.querySelector('.comment-input')?.scrollIntoView({ behavior: 'smooth' })
}
</script>

<style scoped>
.comment-item {
  display: flex;
  gap: 12px;
}

.comment-item.is-reply {
  margin-top: 12px;
}

.comment-content {
  flex: 1;
}

.comment-header {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}

.user-name {
  font-weight: 600;
  color: #333;
}

.sentiment-badge {
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 10px;
}

.sentiment-badge.positive { background: #e1f3d8; color: #67c23a; }
.sentiment-badge.neutral { background: #f4f4f5; color: #909399; }
.sentiment-badge.negative { background: #fef0f0; color: #f56c6c; }

.create-time {
  font-size: 12px;
  color: #999;
}

.comment-body {
  line-height: 1.6;
  color: #333;
}

.comment-actions {
  display: flex;
  gap: 16px;
  margin-top: 8px;
}

.action-btn {
  font-size: 13px;
  color: #666;
  cursor: pointer;
  transition: color 0.2s;
}

.action-btn:hover {
  color: #409eff;
}

.replies {
  margin-top: 16px;
  padding-left: 16px;
  border-left: 2px solid #ebeef5;
}
</style>

效果展示

评论系统上线后,可以获得:

  • 📊 读者反馈数据:通过情感分析了解文章质量
  • 💬 社区活跃度:提升读者互动意愿
  • 🔍 改进方向:根据负面反馈优化内容

🎯 进阶功能建议

  • 接入后端实现用户登录和评论管理
  • 添加评论审核功能,防止垃圾评论
  • 实现@提及功能,通知被回复的用户

🔗 相关资源

  • 在线演示:[fineday.vip]