论坛,聊天室前后端对接

Change-Id: I90740329ab40dc050e8a791a382ab187900d673a
diff --git a/src/views/forum/TopicView.vue b/src/views/forum/TopicView.vue
new file mode 100644
index 0000000..c79bfa8
--- /dev/null
+++ b/src/views/forum/TopicView.vue
@@ -0,0 +1,666 @@
+<template>
+  <div class="topic-page">
+    <Navbar />
+    <div class="page-container">
+      <!-- 话题头部 -->
+      <div class="topic-header">
+        <div class="header-content">
+          <div class="topic-title-section">
+            <h1>{{ topic.title }}</h1>
+            <div class="topic-meta">
+              <span class="author">
+                <el-avatar :size="24">{{ topic.user?.username ? topic.user.username.charAt(0) : 'A' }}</el-avatar>
+                <span class="author-name">{{ topic.user?.username || '匿名' }}</span>
+              </span>
+              <span class="time">{{ formatTime(topic.createTime) }}</span>
+              <span class="views">
+                <el-icon><View /></el-icon>
+                {{ topic.views }}
+              </span>
+              <el-button
+                :icon="isSubscribed ? StarFilled : Star"
+                :type="isSubscribed ? 'warning' : 'default'"
+                @click="handleSubscribe"
+              >
+                {{ isSubscribed ? '已订阅' : '订阅' }}
+              </el-button>
+            </div>
+          </div>
+          <div class="topic-actions" v-if="isAuthor">
+            <el-button type="primary" @click="showEditDialog = true">编辑</el-button>
+            <el-button type="danger" @click="handleDelete">删除</el-button>
+          </div>
+        </div>
+      </div>
+
+      <!-- 话题内容 -->
+      <div class="topic-content">
+        <div class="content-card">
+          <div class="content-body" v-html="topic.content"></div>
+          <div class="content-tags">
+            <el-tag
+              v-for="tag in topic.tags"
+              :key="tag.id"
+              :color="tag.color"
+              effect="light"
+            >
+              {{ tag.name }}
+            </el-tag>
+          </div>
+        </div>
+      </div>
+
+
+
+      <!-- 回复列表 -->
+      <div class="replies-section">
+        <h2>回复 ({{ getTotalReplyCount() }})</h2>
+        <div class="replies-list">
+          <!-- 使用递归组件显示所有回复 -->
+          <ReplyTree
+            :replies="replies"
+            :level="0"
+            @reply="replyToPost"
+            @edit="editReply"
+            @delete="deleteReply"
+          />
+        </div>
+      </div>
+
+      <!-- 回复框 -->
+      <div class="reply-box">
+        <h3>{{ replyingTo ? `回复 @${replyingTo.author}` : '发表回复' }}</h3>
+        <div v-if="replyingTo" class="replying-to">
+          <div class="original-content">{{ replyingTo.content }}</div>
+          <el-button size="small" @click="cancelReply">取消回复</el-button>
+        </div>
+        <el-input
+          v-model="newReply"
+          type="textarea"
+          :rows="4"
+          placeholder="请输入回复内容..."
+        />
+        <div class="reply-actions">
+          <el-button type="primary" @click="submitReply" :loading="submitting">
+            发表回复
+          </el-button>
+        </div>
+      </div>
+    </div>
+
+    <!-- 编辑话题对话框 -->
+    <el-dialog
+      v-model="showEditDialog"
+      title="编辑话题"
+      width="600px"
+    >
+      <el-form
+        ref="editFormRef"
+        :model="editForm"
+        :rules="editRules"
+        label-width="80px"
+      >
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="editForm.title" />
+        </el-form-item>
+        <el-form-item label="内容" prop="content">
+          <el-input
+            v-model="editForm.content"
+            type="textarea"
+            :rows="8"
+          />
+        </el-form-item>
+        <el-form-item label="标签">
+          <div class="tags-input">
+            <el-tag
+              v-for="tag in editForm.tags"
+              :key="tag"
+              closable
+              @close="removeTag(tag)"
+            >
+              {{ tag }}
+            </el-tag>
+            <el-input
+              v-if="tagInputVisible"
+              ref="tagInputRef"
+              v-model="tagInputValue"
+              size="small"
+              @keyup.enter="addTag"
+              @blur="addTag"
+              style="width: 100px;"
+            />
+            <el-button
+              v-else
+              size="small"
+              @click="showTagInput"
+            >
+              + 添加标签
+            </el-button>
+          </div>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showEditDialog = false">取消</el-button>
+        <el-button type="primary" @click="submitEdit" :loading="submitting">
+          保存
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { ref, reactive, onMounted, computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { View, Star, StarFilled } from '@element-plus/icons-vue'
+import {
+  getTopicById,
+  updateTopic,
+  deleteTopic
+} from '@/api/topic'
+import {
+  getPostsByTopic,
+  getPostTreeByTopic,
+  createPost,
+  updatePost,
+  deletePost
+} from '@/api/post'
+
+import {
+  getTopicTags,
+  assignTagsToTopic
+} from '@/api/forumTag'
+import {
+  recordTopicView,
+  getUserViewHistory
+} from '@/api/topicView'
+import {
+  subscribeTopic,
+  unsubscribeTopic,
+  checkSubscription,
+  getSubscriptionList
+} from '@/api/subscription'
+import Navbar from "@/components/Navbar.vue";
+import ReplyTree from "@/components/ReplyTree.vue";
+
+export default {
+  name: 'TopicView',
+  components: {Navbar, ReplyTree},
+  setup() {
+    const route = useRoute()
+    const router = useRouter()
+    const topicId = computed(() => route.params.id)
+    
+    const topic = ref({})
+    const replies = ref([])
+    const newReply = ref('')
+    const submitting = ref(false)
+    const showEditDialog = ref(false)
+    const tagInputVisible = ref(false)
+    const tagInputValue = ref('')
+    const replyingTo = ref(null)
+    
+    const editForm = reactive({
+      title: '',
+      content: '',
+      tags: []
+    })
+    
+    const editRules = {
+      title: [
+        { required: true, message: '请输入标题', trigger: 'blur' },
+        { min: 5, max: 100, message: '标题长度在 5 到 100 个字符', trigger: 'blur' }
+      ],
+      content: [
+        { required: true, message: '请输入内容', trigger: 'blur' },
+        { min: 10, max: 5000, message: '内容长度在 10 到 5000 个字符', trigger: 'blur' }
+      ]
+    }
+    
+    const isAuthor = computed(() => {
+      // 这里需要根据实际用户系统来判断
+      return true
+    })
+    
+    const isSubscribed = ref(false)
+    
+    const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}')
+    const token = localStorage.getItem('token')
+    
+    const loadTopicData = async () => {
+      try {
+        // 获取话题详情
+        const response = await getTopicById(topicId.value)
+        topic.value = response
+        editForm.title = response.title
+        editForm.content = response.content
+        
+        // 获取话题标签
+        const tags = await getTopicTags(topicId.value)
+        editForm.tags = tags.map(tag => tag.name)
+        
+        // 获取树形回复结构
+        const posts = await getPostTreeByTopic(topicId.value)
+        replies.value = posts
+        
+
+        
+        // 检查订阅状态
+        if (token && userInfo.id) {
+          const subscriptionStatus = await checkSubscription({
+            topicId: topicId.value,
+            userId: userInfo.id
+          })
+          isSubscribed.value = subscriptionStatus
+        }
+        
+        // 记录浏览
+        if (token && userInfo.id) {
+          await recordTopicView({
+            topicId: topicId.value,
+            userId: userInfo.id
+          })
+        }
+      } catch (error) {
+        console.error('加载话题数据失败:', error)
+        ElMessage.error('加载话题数据失败')
+      }
+    }
+    
+
+    
+    const handleSubscribe = async () => {
+      try {
+        if (isSubscribed.value) {
+          await unsubscribeTopic({ topicId: topicId.value })
+          isSubscribed.value = false
+          ElMessage.success('已取消订阅')
+        } else {
+          await subscribeTopic({ topicId: topicId.value })
+          isSubscribed.value = true
+          ElMessage.success('已订阅话题')
+        }
+      } catch (error) {
+        console.error('订阅操作失败:', error)
+        ElMessage.error('订阅操作失败')
+      }
+    }
+    
+    const formatTime = (timeString) => {
+      const date = new Date(timeString)
+      const now = new Date()
+      const diff = now - date
+      const hours = Math.floor(diff / (1000 * 60 * 60))
+      
+      if (hours < 1) return '刚刚'
+      if (hours < 24) return `${hours}小时前`
+      const days = Math.floor(hours / 24)
+      return `${days}天前`
+    }
+    
+    const handleDelete = async () => {
+      try {
+        await ElMessageBox.confirm(
+          '确定要删除这个话题吗?此操作不可恢复。',
+          '警告',
+          {
+            confirmButtonText: '确定',
+            cancelButtonText: '取消',
+            type: 'warning'
+          }
+        )
+        
+        await deleteTopic(topicId.value)
+        ElMessage.success('话题已删除')
+        router.push('/forum')
+      } catch (error) {
+        if (error !== 'cancel') {
+          console.error('删除话题失败:', error)
+          ElMessage.error('删除话题失败')
+        }
+      }
+    }
+    
+    const submitEdit = async () => {
+      try {
+        submitting.value = true
+        const updatedTopic = {
+          ...topic.value,
+          title: editForm.title,
+          content: editForm.content
+        }
+        
+        await updateTopic(topicId.value, updatedTopic)
+        
+        // 更新标签
+        await assignTagsToTopic(topicId.value, editForm.tags)
+        
+        ElMessage.success('话题已更新')
+        showEditDialog.value = false
+        await loadTopicData()
+      } catch (error) {
+        console.error('更新话题失败:', error)
+        ElMessage.error('更新话题失败')
+      } finally {
+        submitting.value = false
+      }
+    }
+    
+    const submitReply = async () => {
+      if (!newReply.value.trim()) {
+        ElMessage.warning('请输入回复内容')
+        return
+      }
+      
+      try {
+        submitting.value = true
+        const reply = {
+          topicId: topicId.value,
+          content: newReply.value,
+          parentId: replyingTo.value ? replyingTo.value.id : null
+        }
+        
+        await createPost(reply)
+        ElMessage.success('回复已发布')
+        newReply.value = ''
+        replyingTo.value = null
+        await loadTopicData()
+      } catch (error) {
+        console.error('发布回复失败:', error)
+        ElMessage.error('发布回复失败')
+      } finally {
+        submitting.value = false
+      }
+    }
+
+    const replyToPost = (post) => {
+      replyingTo.value = {
+        id: post.id,
+        author: post.user?.username || '匿名',
+        content: post.content.substring(0, 100) + (post.content.length > 100 ? '...' : '')
+      }
+      // 滚动到回复框
+      document.querySelector('.reply-box').scrollIntoView({ behavior: 'smooth' })
+    }
+
+    const cancelReply = () => {
+      replyingTo.value = null
+    }
+
+    // 递归计算所有回复的总数(包括嵌套回复)
+    const countReplies = (posts) => {
+      let count = 0
+      for (const post of posts) {
+        count += 1
+        if (post.replies && post.replies.length > 0) {
+          count += countReplies(post.replies)
+        }
+      }
+      return count
+    }
+
+    const getTotalReplyCount = () => {
+      return countReplies(replies.value)
+    }
+
+
+    
+    const editReply = (reply) => {
+      // 实现编辑回复的逻辑
+    }
+    
+    const deleteReply = async (replyId) => {
+      try {
+        await ElMessageBox.confirm(
+          '确定要删除这条回复吗?',
+          '警告',
+          {
+            confirmButtonText: '确定',
+            cancelButtonText: '取消',
+            type: 'warning'
+          }
+        )
+        
+        await softDeletePost(replyId)
+        ElMessage.success('回复已删除')
+        await loadTopicData()
+      } catch (error) {
+        if (error !== 'cancel') {
+          console.error('删除回复失败:', error)
+          ElMessage.error('删除回复失败')
+        }
+      }
+    }
+    
+    const showTagInput = () => {
+      tagInputVisible.value = true
+      nextTick(() => {
+        tagInputRef.value?.focus()
+      })
+    }
+    
+    const addTag = () => {
+      const tag = tagInputValue.value.trim()
+      if (tag && !editForm.tags.includes(tag)) {
+        editForm.tags.push(tag)
+      }
+      tagInputVisible.value = false
+      tagInputValue.value = ''
+    }
+    
+    const removeTag = (tag) => {
+      const index = editForm.tags.indexOf(tag)
+      if (index > -1) {
+        editForm.tags.splice(index, 1)
+      }
+    }
+    
+    onMounted(() => {
+      loadTopicData()
+    })
+    
+          return {
+      topic,
+      replies,
+      newReply,
+      submitting,
+      showEditDialog,
+      tagInputVisible,
+      tagInputValue,
+      replyingTo,
+      editForm,
+      editRules,
+      isAuthor,
+      formatTime,
+      handleDelete,
+      submitEdit,
+      submitReply,
+      replyToPost,
+      cancelReply,
+      getTotalReplyCount,
+      editReply,
+      deleteReply,
+      showTagInput,
+      addTag,
+      removeTag,
+      View,
+      Star,
+      StarFilled,
+      isSubscribed,
+      handleSubscribe
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.topic-page {
+  max-width: 1200px;
+  margin: 0 auto;
+  padding: 24px;
+  background: #f5f5f5;
+  min-height: 100vh;
+}
+
+.topic-header {
+  background: #fff;
+  border-radius: 12px;
+  padding: 24px;
+  margin-bottom: 24px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+  
+  .header-content {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-start;
+    
+    .topic-title-section {
+      h1 {
+        font-size: 24px;
+        font-weight: 600;
+        color: #2c3e50;
+        margin: 0 0 16px 0;
+      }
+      
+      .topic-meta {
+        display: flex;
+        align-items: center;
+        gap: 16px;
+        color: #7f8c8d;
+        font-size: 14px;
+        
+        .author {
+          display: flex;
+          align-items: center;
+          gap: 8px;
+        }
+        
+        .views {
+          display: flex;
+          align-items: center;
+          gap: 4px;
+        }
+      }
+    }
+    
+    .topic-actions {
+      display: flex;
+      gap: 12px;
+    }
+  }
+}
+
+.topic-content {
+  margin-bottom: 24px;
+  
+  .content-card {
+    background: #fff;
+    border-radius: 12px;
+    padding: 24px;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+    
+    .content-body {
+      font-size: 16px;
+      line-height: 1.6;
+      color: #2c3e50;
+      margin-bottom: 24px;
+    }
+    
+    .content-tags {
+      display: flex;
+      gap: 8px;
+      flex-wrap: wrap;
+    }
+  }
+}
+
+
+
+.replies-section {
+  background: #fff;
+  border-radius: 12px;
+  padding: 24px;
+  margin-bottom: 24px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+  
+  h2 {
+    font-size: 20px;
+    font-weight: 600;
+    color: #2c3e50;
+    margin: 0 0 20px 0;
+  }
+}
+
+.reply-box {
+  background: #fff;
+  border-radius: 12px;
+  padding: 24px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+  
+  h3 {
+    font-size: 18px;
+    font-weight: 600;
+    color: #2c3e50;
+    margin: 0 0 16px 0;
+  }
+  
+  .replying-to {
+    background: #f8f9fa;
+    border-left: 4px solid #3498db;
+    padding: 12px 16px;
+    margin-bottom: 16px;
+    border-radius: 4px;
+    
+    .original-content {
+      color: #666;
+      font-size: 14px;
+      margin-bottom: 8px;
+      font-style: italic;
+    }
+  }
+  
+  .reply-actions {
+    margin-top: 16px;
+    display: flex;
+    justify-content: flex-end;
+  }
+}
+
+.tags-input {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  align-items: center;
+  
+  .el-tag {
+    margin: 0;
+  }
+}
+
+
+
+.reply-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+@media (max-width: 768px) {
+  .topic-page {
+    padding: 16px;
+  }
+  
+  .topic-header {
+    .header-content {
+      flex-direction: column;
+      
+      .topic-actions {
+        margin-top: 16px;
+        width: 100%;
+        justify-content: flex-end;
+      }
+    }
+  }
+}
+</style> 
\ No newline at end of file