论坛,聊天室前后端对接
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