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