blob: b3fe08b41810a59e8b5a9fa45236d052a5cd8ef3 [file] [log] [blame]
<template>
<Navbar />
<div class="topic-detail-page">
<Navbar />
<div class="page-container">
<!-- 面包屑导航 -->
<div class="breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/forum' }">论坛首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: `/forum/section/${topic.sectionId}` }">
{{ topic.sectionName }}
</el-breadcrumb-item>
<el-breadcrumb-item>{{ topic.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<!-- 主题信息 -->
<div class="topic-header">
<div class="topic-info">
<div class="topic-title-row">
<h1 class="topic-title">{{ topic.title }}</h1>
<div class="topic-status">
<el-tag v-if="topic.pinned" type="warning" size="small">置顶</el-tag>
<el-tag v-if="topic.hot" type="danger" size="small">热门</el-tag>
<el-tag v-if="topic.closed" type="info" size="small">已关闭</el-tag>
</div>
</div>
<div class="topic-tags">
<el-tag
v-for="tag in topic.tags"
:key="tag"
size="small"
type="info"
effect="plain"
>
{{ tag }}
</el-tag>
</div>
<div class="topic-meta">
<div class="author-info">
<el-avatar :size="32">{{ topic.author.charAt(0) }}</el-avatar>
<div class="author-details">
<span class="author-name">{{ topic.author }}</span>
</div>
</div>
<div class="topic-stats">
<div class="stat-item">
<el-icon><View /></el-icon>
<span>{{ topic.views }} 浏览</span>
</div>
<div class="stat-item">
<el-icon><Comment /></el-icon>
<span>{{ topic.replies }} 回复</span>
</div>
</div>
</div>
</div>
<div class="topic-actions">
<el-button
v-if="!topic.closed"
type="primary"
:icon="Edit"
@click="showReplyDialog = true"
>
回复主题
</el-button>
<el-dropdown @command="handleTopicAction">
<el-button :icon="More">
更多 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="share">分享主题</el-dropdown-item>
<el-dropdown-item command="report" divided>举报主题</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 主题内容和回复列表 -->
<div class="posts-container">
<!-- 主楼 -->
<div class="post-item main-post">
<div class="post-header">
<div class="floor-number">#1</div>
<div class="post-author">
<el-avatar :size="48">{{ topic.author.charAt(0) }}</el-avatar>
<div class="author-info">
<span class="author-name">{{ topic.author }}</span>
<span class="author-title">{{ topic.authorTitle || '会员' }}</span>
<div class="author-stats">
<span>帖子: {{ topic.authorPosts || 0 }}</span>
<span>声望: {{ topic.authorReputation || 0 }}</span>
</div>
</div>
</div>
</div>
<div class="post-content">
<div class="content-text" v-html="formatContent(topic.content)"></div>
</div>
<div class="post-actions">
<el-button type="text" size="small" @click="quotePost(topic)">
<el-icon><ChatDotRound /></el-icon>
引用
</el-button>
<el-button type="text" size="small" @click="reportPost(topic.id)">
<el-icon><Flag /></el-icon>
举报
</el-button>
</div>
</div>
<!-- 回复列表 -->
<div
v-for="(reply, index) in replies"
:key="reply.id"
class="post-item reply-post"
>
<div class="post-header">
<div class="floor-number">#{{ index + 2 }}</div>
<div class="post-author">
<el-avatar :size="48">{{ reply.author.charAt(0) }}</el-avatar>
<div class="author-info">
<span class="author-name">{{ reply.author }}</span>
<span class="author-title">{{ reply.authorTitle || '会员' }}</span>
<div class="author-stats">
<span>帖子: {{ reply.authorPosts || 0 }}</span>
<span>声望: {{ reply.authorReputation || 0 }}</span>
</div>
</div>
</div>
</div>
<div class="post-content">
<div v-if="reply.quotedPost" class="quoted-content">
<div class="quote-header">
<el-icon><ChatDotRound /></el-icon>
<span>{{ reply.quotedPost.author }}</span>
</div>
<div class="quote-text">{{ reply.quotedPost.content }}</div>
</div>
<div class="content-text" v-html="formatContent(reply.content)"></div>
</div>
<div class="post-actions">
<el-button type="text" size="small" @click="quotePost(reply)">
<el-icon><ChatDotRound /></el-icon>
引用
</el-button>
<el-button type="text" size="small" @click="reportPost(reply.id)">
<el-icon><Flag /></el-icon>
举报
</el-button>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="totalReplies"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 快速回复 -->
<div v-if="!topic.closed" class="quick-reply">
<h3>快速回复</h3>
<el-input
v-model="quickReplyContent"
type="textarea"
:rows="4"
placeholder="输入你的回复..."
maxlength="2000"
show-word-limit
/>
<div class="quick-reply-actions">
<el-button @click="clearQuickReply">清空</el-button>
<el-button type="primary" @click="submitQuickReply" :loading="submittingReply">
发表回复
</el-button>
</div>
</div>
</div>
<!-- 回复对话框 -->
<el-dialog
v-model="showReplyDialog"
title="回复主题"
width="700px"
:before-close="handleCloseReplyDialog"
>
<el-form
ref="replyFormRef"
:model="replyForm"
:rules="replyRules"
label-width="80px"
>
<el-form-item v-if="quotedContent" label="引用内容">
<div class="quoted-preview">
<div class="quote-header">
<span>{{ quotedContent.author }}</span>
</div>
<div class="quote-content">{{ quotedContent.content }}</div>
<el-button type="text" size="small" @click="clearQuote">
清除引用
</el-button>
</div>
</el-form-item>
<el-form-item label="回复内容" prop="content">
<el-input
v-model="replyForm.content"
type="textarea"
:rows="8"
placeholder="请输入回复内容..."
maxlength="5000"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCloseReplyDialog">取消</el-button>
<el-button type="primary" @click="submitReply" :loading="submittingReply">
发表回复
</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'
import {
Edit,
More,
View,
Comment,
ChatDotRound,
Flag,
ArrowDown
} from '@element-plus/icons-vue'
import Navbar from "@/components/Navbar.vue";
export default {
name: 'ForumTopicView',
components: {Navbar},
setup() {
const route = useRoute()
const router = useRouter()
const replyFormRef = ref(null)
const showReplyDialog = ref(false)
const submittingReply = ref(false)
const loading = ref(false)
const currentPage = ref(1)
const pageSize = ref(20)
const totalReplies = ref(0)
const quickReplyContent = ref('')
const quotedContent = ref(null)
const topic = ref({
id: null,
title: '',
sectionId: null,
sectionName: '',
author: '',
authorTitle: '',
authorPosts: 0,
authorReputation: 0,
createTime: '',
content: '',
views: 0,
replies: 0,
likes: 0,
tags: [],
pinned: false,
hot: false,
closed: false
})
const replies = ref([])
const replyForm = reactive({
content: ''
})
const replyRules = {
content: [
{ required: true, message: '请输入回复内容', trigger: 'blur' },
{ min: 5, max: 5000, message: '内容长度在 5 到 5000 个字符', trigger: 'blur' }
]
}
// 初始化数据
onMounted(() => {
const topicId = route.params.id
fetchTopicDetail(topicId)
fetchReplies(topicId)
})
// 获取主题详情
const fetchTopicDetail = async (topicId) => {
loading.value = true
console.log(`[fetchTopicDetail] 请求主题详情: /api/posts/${topicId}`)
try {
const response = await axios.get(`/api/posts/${topicId}`)
console.log('[fetchTopicDetail] 响应数据:', response.data)
topic.value = {
...response.data,
tags: response.data.tags ? response.data.tags.split(',') : [],
authorTitle: response.data.authorTitle || '会员'
}
} catch (error) {
console.error('[fetchTopicDetail] 请求失败:', error)
ElMessage.error('获取主题详情失败')
router.back()
} finally {
loading.value = false
}
}
// 获取主题的所有回复(平铺结构)
const fetchReplies = async (topicId) => {
loading.value = true
const url = `/api/posts/topic/${topicId}`
const params = { page: currentPage.value, size: pageSize.value }
console.log(`[fetchReplies] 请求回复列表: ${url}`, params)
try {
const response = await axios.get(url, { params })
console.log('[fetchReplies] 响应数据:', response.data)
replies.value = response.data.posts.map(post => ({
id: post.id,
author: post.author,
authorTitle: post.authorTitle || '会员',
authorPosts: post.authorPosts || 0,
authorReputation: post.authorReputation || 0,
createTime: post.createTime,
content: post.content,
likes: post.likes || 0,
quotedPost: post.quotedPost ? {
author: post.quotedPost.author,
time: post.quotedPost.time,
content: post.quotedPost.content
} : null
}))
totalReplies.value = response.data.total
} catch (error) {
console.error('[fetchReplies] 请求失败:', error)
ElMessage.error('获取回复列表失败')
} finally {
loading.value = false
}
}
const formatDateTime = (dateString) => {
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const formatContent = (content) => {
return content.replace(/\n/g, '<br>')
}
const handleTopicAction = (command) => {
switch (command) {
case 'share':
navigator.clipboard.writeText(window.location.href)
ElMessage.success('链接已复制到剪贴板')
break
case 'report':
reportPost(topic.value.id)
break
}
}
const quotePost = (post) => {
quotedContent.value = {
author: post.author,
content: post.content.replace(/<[^>]*>/g, '').substring(0, 100) + '...',
time: post.createTime
}
showReplyDialog.value = true
}
const reportPost = async (postId) => {
try {
const { value } = await ElMessageBox.prompt('请说明举报原因', '举报内容', {
confirmButtonText: '提交举报',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '请详细说明举报原因...'
})
// 假设有举报API,实际需根据后端实现
ElMessage.success('举报已提交,我们会尽快处理')
} catch {
// 用户取消
}
}
const clearQuote = () => {
quotedContent.value = null
}
const handleCloseReplyDialog = () => {
if (replyForm.content) {
ElMessageBox.confirm(
'确定要关闭吗?未保存的内容将会丢失。',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
resetReplyForm()
showReplyDialog.value = false
}).catch(() => {
// 用户取消
})
} else {
resetReplyForm()
showReplyDialog.value = false
}
}
const submitReply = async () => {
try {
await replyFormRef.value?.validate()
submittingReply.value = true
const postData = {
topicId: topic.value.id,
content: replyForm.content,
quotedPost: quotedContent.value ? {
author: quotedContent.value.author,
time: quotedContent.value.time,
content: quotedContent.value.content
} : null,
author: localStorage.getItem('username') || '用户'
}
const response = await axios.post('/api/posts', postData)
replies.value.push({
id: response.data.id,
author: response.data.author,
authorTitle: response.data.authorTitle || '会员',
authorPosts: response.data.authorPosts || 0,
authorReputation: response.data.authorReputation || 0,
createTime: response.data.createTime,
content: response.data.content,
likes: response.data.likes || 0,
quotedPost: response.data.quotedPost
})
topic.value.replies += 1
ElMessage.success('回复发表成功!')
resetReplyForm()
showReplyDialog.value = false
} catch (error) {
console.error('表单验证或提交失败:', error)
ElMessage.error('发表回复失败')
} finally {
submittingReply.value = false
}
}
const submitQuickReply = async () => {
if (!quickReplyContent.value.trim()) {
ElMessage.warning('请输入回复内容')
return
}
submittingReply.value = true
try {
const postData = {
topicId: topic.value.id,
content: quickReplyContent.value,
author: localStorage.getItem('username') || '用户'
}
const response = await axios.post('/api/posts', postData)
replies.value.push({
id: response.data.id,
author: response.data.author,
authorTitle: response.data.authorTitle || '会员',
authorPosts: response.data.authorPosts || 0,
authorReputation: response.data.authorReputation || 0,
createTime: response.data.createTime,
content: response.data.content,
likes: response.data.likes || 0
})
topic.value.replies += 1
quickReplyContent.value = ''
ElMessage.success('回复发表成功!')
} catch (error) {
ElMessage.error('发表回复失败')
} finally {
submittingReply.value = false
}
}
const clearQuickReply = () => {
quickReplyContent.value = ''
}
const resetReplyForm = () => {
replyFormRef.value?.resetFields()
replyForm.content = ''
quotedContent.value = null
}
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
fetchReplies(topic.value.id)
}
const handleCurrentChange = (page) => {
currentPage.value = page
fetchReplies(topic.value.id)
}
return {
showReplyDialog,
submittingReply,
loading,
currentPage,
pageSize,
totalReplies,
quickReplyContent,
quotedContent,
topic,
replies,
replyForm,
replyRules,
replyFormRef,
formatDateTime,
formatContent,
handleTopicAction,
quotePost,
reportPost,
clearQuote,
handleCloseReplyDialog,
submitReply,
submitQuickReply,
clearQuickReply,
handleSizeChange,
handleCurrentChange,
Edit,
More,
View,
Comment,
ChatDotRound,
Flag,
ArrowDown
}
}
}
</script>
<style lang="scss" scoped>
.topic-detail-page {
max-width: 1000px;
margin: 0 auto;
padding: 24px;
background: #f5f5f5;
min-height: 100vh;
}
.breadcrumb {
margin-bottom: 16px;
}
.topic-header {
background: #fff;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
.topic-info {
flex: 1;
.topic-title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
.topic-title {
font-size: 24px;
font-weight: 600;
color: #2c3e50;
margin: 0;
flex: 1;
}
.topic-status {
.el-tag {
margin-left: 8px;
}
}
}
.topic-tags {
margin-bottom: 16px;
.el-tag {
margin-right: 8px;
}
}
.topic-meta {
display: flex;
justify-content: space-between;
align-items: center;
.author-info {
display: flex;
align-items: center;
gap: 12px;
.author-details {
.author-name {
display: block;
font-weight: 600;
color: #2c3e50;
font-size: 14px;
}
.post-time {
display: block;
font-size: 12px;
color: #909399;
}
}
}
.topic-stats {
display: flex;
gap: 16px;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #7f8c8d;
}
}
}
}
.topic-actions {
display: flex;
gap: 12px;
flex-shrink: 0;
}
}
.posts-container {
.post-item {
background: #fff;
border-radius: 12px;
margin-bottom: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
overflow: hidden;
&.main-post {
border-left: 4px solid #409eff;
}
.post-header {
background: #f8f9fa;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
border-bottom: 1px solid #f0f0f0;
.floor-number {
background: #409eff;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
min-width: 32px;
text-align: center;
}
.post-author {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
.author-info {
.author-name {
display: block;
font-weight: 600;
color: #2c3e50;
font-size: 14px;
}
.author-title {
display: block;
font-size: 12px;
color: #67c23a;
margin-bottom: 4px;
}
.author-stats {
font-size: 11px;
color: #909399;
span {
margin-right: 12px;
}
}
}
}
.post-time {
font-size: 12px;
color: #909399;
}
}
.post-content {
padding: 24px;
.quoted-content {
background: #f5f7fa;
border-left: 4px solid #e4e7ed;
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 0 4px 4px 0;
.quote-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.quote-text {
font-size: 14px;
color: #606266;
line-height: 1.5;
}
}
.content-text {
line-height: 1.6;
color: #2c3e50;
:deep(h3) {
color: #2c3e50;
font-size: 18px;
font-weight: 600;
margin: 20px 0 12px 0;
}
:deep(p) {
margin-bottom: 12px;
}
:deep(ul), :deep(ol) {
margin: 12px 0;
padding-left: 20px;
li {
margin-bottom: 8px;
}
}
}
}
.post-actions {
padding: 12px 24px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
.el-button {
margin-right: 16px;
.el-icon {
margin-right: 4px;
}
}
}
}
}
.pagination-wrapper {
text-align: center;
margin: 24px 0;
}
.quick-reply {
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;
}
.quick-reply-actions {
margin-top: 12px;
text-align: right;
.el-button {
margin-left: 12px;
}
}
}
.quoted-preview {
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 12px;
.quote-header {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.quote-content {
font-size: 14px;
color: #606266;
margin-bottom: 8px;
line-height: 1.5;
}
}
@media (max-width: 768px) {
.topic-detail-page {
padding: 16px;
}
.topic-header {
flex-direction: column;
align-items: flex-start;
.topic-actions {
width: 100%;
justify-content: flex-end;
}
}
.post-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
.floor-number {
align-self: flex-start;
}
}
.post-content {
padding: 16px;
}
.post-actions {
padding: 12px 16px;
.el-button {
margin-right: 8px;
margin-bottom: 8px;
}
}
}
</style>