blob: 0374dacc6a0d907cf4c208a9217f781ecc5cf260 [file] [log] [blame]
<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>