blob: c79bfa8a290634c015ead28054eeb5fd404d15b8 [file] [log] [blame]
208159515458d95702025-06-09 14:46:58 +08001<template>
2 <div class="topic-page">
3 <Navbar />
4 <div class="page-container">
5 <!-- 话题头部 -->
6 <div class="topic-header">
7 <div class="header-content">
8 <div class="topic-title-section">
9 <h1>{{ topic.title }}</h1>
10 <div class="topic-meta">
11 <span class="author">
12 <el-avatar :size="24">{{ topic.user?.username ? topic.user.username.charAt(0) : 'A' }}</el-avatar>
13 <span class="author-name">{{ topic.user?.username || '匿名' }}</span>
14 </span>
15 <span class="time">{{ formatTime(topic.createTime) }}</span>
16 <span class="views">
17 <el-icon><View /></el-icon>
18 {{ topic.views }}
19 </span>
20 <el-button
21 :icon="isSubscribed ? StarFilled : Star"
22 :type="isSubscribed ? 'warning' : 'default'"
23 @click="handleSubscribe"
24 >
25 {{ isSubscribed ? '已订阅' : '订阅' }}
26 </el-button>
27 </div>
28 </div>
29 <div class="topic-actions" v-if="isAuthor">
30 <el-button type="primary" @click="showEditDialog = true">编辑</el-button>
31 <el-button type="danger" @click="handleDelete">删除</el-button>
32 </div>
33 </div>
34 </div>
35
36 <!-- 话题内容 -->
37 <div class="topic-content">
38 <div class="content-card">
39 <div class="content-body" v-html="topic.content"></div>
40 <div class="content-tags">
41 <el-tag
42 v-for="tag in topic.tags"
43 :key="tag.id"
44 :color="tag.color"
45 effect="light"
46 >
47 {{ tag.name }}
48 </el-tag>
49 </div>
50 </div>
51 </div>
52
53
54
55 <!-- 回复列表 -->
56 <div class="replies-section">
57 <h2>回复 ({{ getTotalReplyCount() }})</h2>
58 <div class="replies-list">
59 <!-- 使用递归组件显示所有回复 -->
60 <ReplyTree
61 :replies="replies"
62 :level="0"
63 @reply="replyToPost"
64 @edit="editReply"
65 @delete="deleteReply"
66 />
67 </div>
68 </div>
69
70 <!-- 回复框 -->
71 <div class="reply-box">
72 <h3>{{ replyingTo ? `回复 @${replyingTo.author}` : '发表回复' }}</h3>
73 <div v-if="replyingTo" class="replying-to">
74 <div class="original-content">{{ replyingTo.content }}</div>
75 <el-button size="small" @click="cancelReply">取消回复</el-button>
76 </div>
77 <el-input
78 v-model="newReply"
79 type="textarea"
80 :rows="4"
81 placeholder="请输入回复内容..."
82 />
83 <div class="reply-actions">
84 <el-button type="primary" @click="submitReply" :loading="submitting">
85 发表回复
86 </el-button>
87 </div>
88 </div>
89 </div>
90
91 <!-- 编辑话题对话框 -->
92 <el-dialog
93 v-model="showEditDialog"
94 title="编辑话题"
95 width="600px"
96 >
97 <el-form
98 ref="editFormRef"
99 :model="editForm"
100 :rules="editRules"
101 label-width="80px"
102 >
103 <el-form-item label="标题" prop="title">
104 <el-input v-model="editForm.title" />
105 </el-form-item>
106 <el-form-item label="内容" prop="content">
107 <el-input
108 v-model="editForm.content"
109 type="textarea"
110 :rows="8"
111 />
112 </el-form-item>
113 <el-form-item label="标签">
114 <div class="tags-input">
115 <el-tag
116 v-for="tag in editForm.tags"
117 :key="tag"
118 closable
119 @close="removeTag(tag)"
120 >
121 {{ tag }}
122 </el-tag>
123 <el-input
124 v-if="tagInputVisible"
125 ref="tagInputRef"
126 v-model="tagInputValue"
127 size="small"
128 @keyup.enter="addTag"
129 @blur="addTag"
130 style="width: 100px;"
131 />
132 <el-button
133 v-else
134 size="small"
135 @click="showTagInput"
136 >
137 + 添加标签
138 </el-button>
139 </div>
140 </el-form-item>
141 </el-form>
142 <template #footer>
143 <el-button @click="showEditDialog = false">取消</el-button>
144 <el-button type="primary" @click="submitEdit" :loading="submitting">
145 保存
146 </el-button>
147 </template>
148 </el-dialog>
149 </div>
150</template>
151
152<script>
153import { ref, reactive, onMounted, computed } from 'vue'
154import { useRoute, useRouter } from 'vue-router'
155import { ElMessage, ElMessageBox } from 'element-plus'
156import { View, Star, StarFilled } from '@element-plus/icons-vue'
157import {
158 getTopicById,
159 updateTopic,
160 deleteTopic
161} from '@/api/topic'
162import {
163 getPostsByTopic,
164 getPostTreeByTopic,
165 createPost,
166 updatePost,
167 deletePost
168} from '@/api/post'
169
170import {
171 getTopicTags,
172 assignTagsToTopic
173} from '@/api/forumTag'
174import {
175 recordTopicView,
176 getUserViewHistory
177} from '@/api/topicView'
178import {
179 subscribeTopic,
180 unsubscribeTopic,
181 checkSubscription,
182 getSubscriptionList
183} from '@/api/subscription'
184import Navbar from "@/components/Navbar.vue";
185import ReplyTree from "@/components/ReplyTree.vue";
186
187export default {
188 name: 'TopicView',
189 components: {Navbar, ReplyTree},
190 setup() {
191 const route = useRoute()
192 const router = useRouter()
193 const topicId = computed(() => route.params.id)
194
195 const topic = ref({})
196 const replies = ref([])
197 const newReply = ref('')
198 const submitting = ref(false)
199 const showEditDialog = ref(false)
200 const tagInputVisible = ref(false)
201 const tagInputValue = ref('')
202 const replyingTo = ref(null)
203
204 const editForm = reactive({
205 title: '',
206 content: '',
207 tags: []
208 })
209
210 const editRules = {
211 title: [
212 { required: true, message: '请输入标题', trigger: 'blur' },
213 { min: 5, max: 100, message: '标题长度在 5 到 100 个字符', trigger: 'blur' }
214 ],
215 content: [
216 { required: true, message: '请输入内容', trigger: 'blur' },
217 { min: 10, max: 5000, message: '内容长度在 10 到 5000 个字符', trigger: 'blur' }
218 ]
219 }
220
221 const isAuthor = computed(() => {
222 // 这里需要根据实际用户系统来判断
223 return true
224 })
225
226 const isSubscribed = ref(false)
227
228 const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}')
229 const token = localStorage.getItem('token')
230
231 const loadTopicData = async () => {
232 try {
233 // 获取话题详情
234 const response = await getTopicById(topicId.value)
235 topic.value = response
236 editForm.title = response.title
237 editForm.content = response.content
238
239 // 获取话题标签
240 const tags = await getTopicTags(topicId.value)
241 editForm.tags = tags.map(tag => tag.name)
242
243 // 获取树形回复结构
244 const posts = await getPostTreeByTopic(topicId.value)
245 replies.value = posts
246
247
248
249 // 检查订阅状态
250 if (token && userInfo.id) {
251 const subscriptionStatus = await checkSubscription({
252 topicId: topicId.value,
253 userId: userInfo.id
254 })
255 isSubscribed.value = subscriptionStatus
256 }
257
258 // 记录浏览
259 if (token && userInfo.id) {
260 await recordTopicView({
261 topicId: topicId.value,
262 userId: userInfo.id
263 })
264 }
265 } catch (error) {
266 console.error('加载话题数据失败:', error)
267 ElMessage.error('加载话题数据失败')
268 }
269 }
270
271
272
273 const handleSubscribe = async () => {
274 try {
275 if (isSubscribed.value) {
276 await unsubscribeTopic({ topicId: topicId.value })
277 isSubscribed.value = false
278 ElMessage.success('已取消订阅')
279 } else {
280 await subscribeTopic({ topicId: topicId.value })
281 isSubscribed.value = true
282 ElMessage.success('已订阅话题')
283 }
284 } catch (error) {
285 console.error('订阅操作失败:', error)
286 ElMessage.error('订阅操作失败')
287 }
288 }
289
290 const formatTime = (timeString) => {
291 const date = new Date(timeString)
292 const now = new Date()
293 const diff = now - date
294 const hours = Math.floor(diff / (1000 * 60 * 60))
295
296 if (hours < 1) return '刚刚'
297 if (hours < 24) return `${hours}小时前`
298 const days = Math.floor(hours / 24)
299 return `${days}天前`
300 }
301
302 const handleDelete = async () => {
303 try {
304 await ElMessageBox.confirm(
305 '确定要删除这个话题吗?此操作不可恢复。',
306 '警告',
307 {
308 confirmButtonText: '确定',
309 cancelButtonText: '取消',
310 type: 'warning'
311 }
312 )
313
314 await deleteTopic(topicId.value)
315 ElMessage.success('话题已删除')
316 router.push('/forum')
317 } catch (error) {
318 if (error !== 'cancel') {
319 console.error('删除话题失败:', error)
320 ElMessage.error('删除话题失败')
321 }
322 }
323 }
324
325 const submitEdit = async () => {
326 try {
327 submitting.value = true
328 const updatedTopic = {
329 ...topic.value,
330 title: editForm.title,
331 content: editForm.content
332 }
333
334 await updateTopic(topicId.value, updatedTopic)
335
336 // 更新标签
337 await assignTagsToTopic(topicId.value, editForm.tags)
338
339 ElMessage.success('话题已更新')
340 showEditDialog.value = false
341 await loadTopicData()
342 } catch (error) {
343 console.error('更新话题失败:', error)
344 ElMessage.error('更新话题失败')
345 } finally {
346 submitting.value = false
347 }
348 }
349
350 const submitReply = async () => {
351 if (!newReply.value.trim()) {
352 ElMessage.warning('请输入回复内容')
353 return
354 }
355
356 try {
357 submitting.value = true
358 const reply = {
359 topicId: topicId.value,
360 content: newReply.value,
361 parentId: replyingTo.value ? replyingTo.value.id : null
362 }
363
364 await createPost(reply)
365 ElMessage.success('回复已发布')
366 newReply.value = ''
367 replyingTo.value = null
368 await loadTopicData()
369 } catch (error) {
370 console.error('发布回复失败:', error)
371 ElMessage.error('发布回复失败')
372 } finally {
373 submitting.value = false
374 }
375 }
376
377 const replyToPost = (post) => {
378 replyingTo.value = {
379 id: post.id,
380 author: post.user?.username || '匿名',
381 content: post.content.substring(0, 100) + (post.content.length > 100 ? '...' : '')
382 }
383 // 滚动到回复框
384 document.querySelector('.reply-box').scrollIntoView({ behavior: 'smooth' })
385 }
386
387 const cancelReply = () => {
388 replyingTo.value = null
389 }
390
391 // 递归计算所有回复的总数(包括嵌套回复)
392 const countReplies = (posts) => {
393 let count = 0
394 for (const post of posts) {
395 count += 1
396 if (post.replies && post.replies.length > 0) {
397 count += countReplies(post.replies)
398 }
399 }
400 return count
401 }
402
403 const getTotalReplyCount = () => {
404 return countReplies(replies.value)
405 }
406
407
408
409 const editReply = (reply) => {
410 // 实现编辑回复的逻辑
411 }
412
413 const deleteReply = async (replyId) => {
414 try {
415 await ElMessageBox.confirm(
416 '确定要删除这条回复吗?',
417 '警告',
418 {
419 confirmButtonText: '确定',
420 cancelButtonText: '取消',
421 type: 'warning'
422 }
423 )
424
425 await softDeletePost(replyId)
426 ElMessage.success('回复已删除')
427 await loadTopicData()
428 } catch (error) {
429 if (error !== 'cancel') {
430 console.error('删除回复失败:', error)
431 ElMessage.error('删除回复失败')
432 }
433 }
434 }
435
436 const showTagInput = () => {
437 tagInputVisible.value = true
438 nextTick(() => {
439 tagInputRef.value?.focus()
440 })
441 }
442
443 const addTag = () => {
444 const tag = tagInputValue.value.trim()
445 if (tag && !editForm.tags.includes(tag)) {
446 editForm.tags.push(tag)
447 }
448 tagInputVisible.value = false
449 tagInputValue.value = ''
450 }
451
452 const removeTag = (tag) => {
453 const index = editForm.tags.indexOf(tag)
454 if (index > -1) {
455 editForm.tags.splice(index, 1)
456 }
457 }
458
459 onMounted(() => {
460 loadTopicData()
461 })
462
463 return {
464 topic,
465 replies,
466 newReply,
467 submitting,
468 showEditDialog,
469 tagInputVisible,
470 tagInputValue,
471 replyingTo,
472 editForm,
473 editRules,
474 isAuthor,
475 formatTime,
476 handleDelete,
477 submitEdit,
478 submitReply,
479 replyToPost,
480 cancelReply,
481 getTotalReplyCount,
482 editReply,
483 deleteReply,
484 showTagInput,
485 addTag,
486 removeTag,
487 View,
488 Star,
489 StarFilled,
490 isSubscribed,
491 handleSubscribe
492 }
493 }
494}
495</script>
496
497<style lang="scss" scoped>
498.topic-page {
499 max-width: 1200px;
500 margin: 0 auto;
501 padding: 24px;
502 background: #f5f5f5;
503 min-height: 100vh;
504}
505
506.topic-header {
507 background: #fff;
508 border-radius: 12px;
509 padding: 24px;
510 margin-bottom: 24px;
511 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
512
513 .header-content {
514 display: flex;
515 justify-content: space-between;
516 align-items: flex-start;
517
518 .topic-title-section {
519 h1 {
520 font-size: 24px;
521 font-weight: 600;
522 color: #2c3e50;
523 margin: 0 0 16px 0;
524 }
525
526 .topic-meta {
527 display: flex;
528 align-items: center;
529 gap: 16px;
530 color: #7f8c8d;
531 font-size: 14px;
532
533 .author {
534 display: flex;
535 align-items: center;
536 gap: 8px;
537 }
538
539 .views {
540 display: flex;
541 align-items: center;
542 gap: 4px;
543 }
544 }
545 }
546
547 .topic-actions {
548 display: flex;
549 gap: 12px;
550 }
551 }
552}
553
554.topic-content {
555 margin-bottom: 24px;
556
557 .content-card {
558 background: #fff;
559 border-radius: 12px;
560 padding: 24px;
561 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
562
563 .content-body {
564 font-size: 16px;
565 line-height: 1.6;
566 color: #2c3e50;
567 margin-bottom: 24px;
568 }
569
570 .content-tags {
571 display: flex;
572 gap: 8px;
573 flex-wrap: wrap;
574 }
575 }
576}
577
578
579
580.replies-section {
581 background: #fff;
582 border-radius: 12px;
583 padding: 24px;
584 margin-bottom: 24px;
585 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
586
587 h2 {
588 font-size: 20px;
589 font-weight: 600;
590 color: #2c3e50;
591 margin: 0 0 20px 0;
592 }
593}
594
595.reply-box {
596 background: #fff;
597 border-radius: 12px;
598 padding: 24px;
599 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
600
601 h3 {
602 font-size: 18px;
603 font-weight: 600;
604 color: #2c3e50;
605 margin: 0 0 16px 0;
606 }
607
608 .replying-to {
609 background: #f8f9fa;
610 border-left: 4px solid #3498db;
611 padding: 12px 16px;
612 margin-bottom: 16px;
613 border-radius: 4px;
614
615 .original-content {
616 color: #666;
617 font-size: 14px;
618 margin-bottom: 8px;
619 font-style: italic;
620 }
621 }
622
623 .reply-actions {
624 margin-top: 16px;
625 display: flex;
626 justify-content: flex-end;
627 }
628}
629
630.tags-input {
631 display: flex;
632 flex-wrap: wrap;
633 gap: 8px;
634 align-items: center;
635
636 .el-tag {
637 margin: 0;
638 }
639}
640
641
642
643.reply-actions {
644 display: flex;
645 align-items: center;
646 gap: 8px;
647}
648
649@media (max-width: 768px) {
650 .topic-page {
651 padding: 16px;
652 }
653
654 .topic-header {
655 .header-content {
656 flex-direction: column;
657
658 .topic-actions {
659 margin-top: 16px;
660 width: 100%;
661 justify-content: flex-end;
662 }
663 }
664 }
665}
666</style>