| <template> |
| <Navbar /> |
| <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> |