You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

922 lines
21 KiB

<template>
<div class="post-detail-container">
<!-- 顶部导航栏 -->
<nav class="forum-navbar glass-light">
<div class="nav-container">
<div class="nav-brand">
<router-link to="/" class="brand-link">
<div class="logo-circle">
<i class="el-icon-star-filled"></i>
</div>
<span class="brand-name gradient-text">UniLife</span>
</router-link>
</div>
<div class="nav-menu">
<router-link to="/forum" class="nav-item">论坛</router-link>
<router-link to="/resources" class="nav-item">资源</router-link>
<router-link to="/schedule" class="nav-item">课程表</router-link>
<router-link to="/tasks" class="nav-item">日程管理</router-link>
</div>
<div class="nav-actions">
<div class="user-info">
<el-avatar :size="36" :src="userStore.user?.avatar">
{{ userStore.user?.nickname?.charAt(0) }}
</el-avatar>
<span class="username">{{ userStore.user?.nickname }}</span>
</div>
</div>
</div>
</nav>
<!-- 主要内容区域 -->
<div class="post-detail-main">
<div class="post-detail-content">
<!-- 返回按钮 -->
<div class="back-section">
<el-button @click="goBack" circle>
<el-icon><ArrowLeft /></el-icon>
</el-button>
<span class="breadcrumb">论坛 / 帖子详情</span>
</div>
<!-- 帖子内容 -->
<div class="post-content-section card-light" v-loading="loading">
<div class="post-header">
<div class="post-meta">
<el-tag type="primary">{{ post.categoryName }}</el-tag>
<span class="post-time">{{ post.createdAt }}</span>
</div>
<div class="post-actions">
<el-button text @click="toggleLike">
<el-icon><Star /></el-icon>
{{ post.isLiked ? '已点赞' : '点赞' }} ({{ post.likeCount }})
</el-button>
<el-button text>
<el-icon><Share /></el-icon>
分享
</el-button>
<el-dropdown>
<el-button text>
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>举报</el-dropdown-item>
<el-dropdown-item v-if="isAuthor">编辑</el-dropdown-item>
<el-dropdown-item v-if="isAuthor" divided>删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<h1 class="post-title">{{ post.title }}</h1>
<div class="author-section">
<div class="author-info">
<el-avatar :size="50" :src="post.avatar">
{{ post.nickname?.charAt(0) }}
</el-avatar>
<div class="author-details">
<div class="author-name">{{ post.nickname }}</div>
<div class="author-stats">发帖 {{ authorStats.postCount }} · 获赞 {{ authorStats.likeCount }}</div>
</div>
</div>
<el-button type="primary" plain size="small" class="follow-btn">关注</el-button>
</div>
<div class="post-content">
<div class="markdown-content">
<MdPreview
:model-value="post.content"
preview-theme="default"
code-theme="atom"
:show-code-row-number="true"
class="post-markdown"
/>
</div>
</div>
<div class="post-footer">
<div class="post-stats">
<span class="stat-item">
<el-icon><View /></el-icon>
{{ post.viewCount }} 浏览
</span>
<span class="stat-item">
<el-icon><ChatDotRound /></el-icon>
{{ post.commentCount }} 评论
</span>
<span class="stat-item">
<el-icon><Star /></el-icon>
{{ post.likeCount }} 点赞
</span>
</div>
</div>
</div>
<!-- 评论区域 -->
<div class="comments-section card-light">
<div class="comments-header">
<h3>评论 ({{ post.commentCount || 0 }})</h3>
<el-select v-model="commentSort" size="small" style="width: 120px">
<el-option label="最新" value="latest" />
<el-option label="最热" value="hot" />
</el-select>
</div>
<!-- 发表评论 -->
<div class="comment-form">
<el-input
v-model="newComment"
type="textarea"
:rows="3"
placeholder="写下你的评论..."
class="comment-input"
/>
<div class="comment-actions">
<el-button type="primary" @click="submitComment" :loading="commenting">
{{ commenting ? '...' : '' }}
</el-button>
</div>
</div>
<!-- 评论列表 -->
<div class="comments-list">
<!-- 空状态 -->
<div v-if="comments.length === 0" class="empty-comments">
<el-empty description="暂无评论" :image-size="80">
<template #description>
<p>暂无评论,快来发表第一个评论吧!</p>
</template>
</el-empty>
</div>
<div
v-for="comment in comments"
:key="comment.id"
class="comment-item"
>
<div class="comment-avatar">
<el-avatar :size="40" :src="comment.avatar">
{{ comment.nickname?.charAt(0) }}
</el-avatar>
</div>
<div class="comment-content">
<div class="comment-header">
<span class="comment-author">{{ comment.nickname }}</span>
<span class="comment-time">{{ comment.createdAt }}</span>
</div>
<div class="comment-text">{{ comment.content }}</div>
<div class="comment-footer">
<el-button text size="small" @click="replyToComment(comment)">
<el-icon><ChatDotRound /></el-icon>
回复
</el-button>
<el-button text size="small" @click="likeComment(comment)">
<el-icon><Star /></el-icon>
{{ comment.likeCount }}
</el-button>
</div>
<!-- 回复列表 -->
<div v-if="comment.replies && comment.replies.length > 0" class="replies-list">
<div
v-for="reply in comment.replies"
:key="reply.id"
class="reply-item"
>
<el-avatar :size="32" :src="reply.avatar">
{{ reply.nickname?.charAt(0) }}
</el-avatar>
<div class="reply-content">
<div class="reply-header">
<span class="reply-author">{{ reply.nickname }}</span>
<span class="reply-time">{{ reply.createdAt }}</span>
</div>
<div class="reply-text">{{ reply.content }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowLeft,
Star,
Share,
MoreFilled,
View,
ChatDotRound
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { getPostDetail, likePost as likePostAPI, getComments, createComment as createCommentAPI, likeComment as likeCommentAPI } from '@/api/forum'
import type { Post, ApiResponse } from '@/types'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
//
const newComment = ref('')
const commentSort = ref('latest')
const loading = ref(false)
const commenting = ref(false)
//
const post = ref<Post>({
id: 0,
title: '',
content: '',
summary: '',
userId: 0,
nickname: '',
avatar: '',
categoryId: 0,
categoryName: '',
viewCount: 0,
likeCount: 0,
commentCount: 0,
isLiked: false,
createdAt: '',
updatedAt: ''
})
const comments = ref<any[]>([])
// 作者统计信息
const authorStats = ref({
postCount: 12,
likeCount: 234
})
// 计算属性
const isAuthor = computed(() => {
return userStore.user?.id === post.value.userId
})
// 方法
const goBack = () => {
router.go(-1)
}
const toggleLike = async () => {
if (!post.value) return
try {
const response = await likePostAPI(post.value.id) as any as ApiResponse<null>
if (response.code === 200) {
post.value.isLiked = !post.value.isLiked
post.value.likeCount += post.value.isLiked ? 1 : -1
ElMessage.success(post.value.isLiked ? '点赞成功' : '取消点赞')
}
} catch (error: any) {
ElMessage.error(error.message || '操作失败')
}
}
const submitComment = async () => {
if (!newComment.value.trim()) {
ElMessage.warning('请输入评论内容')
return
}
try {
commenting.value = true
const response = await createCommentAPI({
postId: post.value.id,
content: newComment.value,
parentId: null
}) as any as ApiResponse<{ commentId: number }>
if (response.code === 200) {
newComment.value = ''
ElMessage.success('评论发表成功')
// 重新加载评论
loadComments()
}
} catch (error: any) {
ElMessage.error(error.message || '评论发表失败')
} finally {
commenting.value = false
}
}
const replyToComment = (comment: any) => {
ElMessage.info('回复功能开发中...')
}
const likeComment = async (comment: any) => {
try {
const response = await likeCommentAPI(comment.id) as any as ApiResponse<null>
if (response.code === 200) {
comment.isLiked = !comment.isLiked
comment.likeCount += comment.isLiked ? 1 : -1
ElMessage.success(comment.isLiked ? '点赞成功' : '取消点赞')
}
} catch (error: any) {
ElMessage.error(error.message || '操作失败')
}
}
// 加载帖子详情
const loadPost = async () => {
const postId = Number(route.params.id)
if (!postId) {
ElMessage.error('帖子ID无效')
router.push('/forum')
return
}
try {
loading.value = true
const response = await getPostDetail(postId) as any as ApiResponse<Post>
if (response.code === 200) {
post.value = response.data
// 调试信息:打印帖子详情的点赞状态
console.log('加载的帖子详情:', {
id: post.value.id,
title: post.value.title,
isLiked: post.value.isLiked,
likeCount: post.value.likeCount
})
} else {
ElMessage.error(response.message || '加载帖子失败')
router.push('/forum')
}
} catch (error: any) {
console.error('加载帖子失败:', error)
ElMessage.error(error.response?.data?.message || '加载帖子失败')
router.push('/forum')
} finally {
loading.value = false
}
}
// 加载评论列表
const loadComments = async () => {
if (!post.value.id) return
try {
const response = await getComments(post.value.id) as any as ApiResponse<{
total: number
list: any[]
}>
if (response.code === 200) {
comments.value = response.data.list || []
} else {
console.error('加载评论失败:', response.message)
}
} catch (error: any) {
console.error('加载评论失败:', error)
ElMessage.error('加载评论失败')
}
}
onMounted(async () => {
console.log('帖子详情页面加载完成', route.params.id)
await loadPost()
if (post.value.id) {
await loadComments()
}
})
</script>
<style scoped>
.post-detail-container {
min-height: 100vh;
background: var(--gradient-bg);
}
/* 导航栏样式 - 复用论坛页面的样式 */
.forum-navbar {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 0;
border-bottom: 1px solid var(--gray-200);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.logo-circle {
width: 40px;
height: 40px;
background: var(--gradient-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: var(--shadow-light);
}
.brand-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
.nav-menu {
display: flex;
gap: 32px;
}
.nav-item {
text-decoration: none;
color: var(--gray-600);
font-weight: 600;
padding: 8px 16px;
border-radius: 12px;
transition: var(--transition-base);
}
.nav-item:hover {
color: var(--primary-600);
background: var(--primary-50);
}
.nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.username {
font-weight: 600;
color: var(--gray-700);
}
/* 主要内容区域 */
.post-detail-main {
padding: 32px 24px;
}
.post-detail-content {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
/* 返回按钮区域 */
.back-section {
display: flex;
align-items: center;
gap: 12px;
}
.breadcrumb {
color: var(--gray-600);
font-size: 14px;
}
/* 帖子内容区域 */
.post-content-section {
padding: 32px;
}
.post-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.post-meta {
display: flex;
align-items: center;
gap: 12px;
}
.post-time {
color: var(--gray-500);
font-size: 14px;
}
.post-actions {
display: flex;
gap: 8px;
}
.post-title {
font-size: 2rem;
font-weight: 700;
color: var(--gray-800);
margin-bottom: 24px;
line-height: 1.3;
}
.author-section {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: var(--primary-50);
border-radius: 16px;
margin-bottom: 32px;
}
.author-info {
display: flex;
align-items: center;
gap: 16px;
}
.author-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.author-name {
font-weight: 700;
color: var(--gray-800);
font-size: 16px;
}
.author-stats {
color: var(--gray-600);
font-size: 14px;
}
/* 关注按钮优化样式 */
.follow-btn {
background: transparent !important;
border: 2px solid var(--primary-300) !important;
color: var(--primary-600) !important;
padding: 8px 16px !important;
border-radius: 20px !important;
font-weight: 500 !important;
transition: var(--transition-base) !important;
min-width: 80px !important;
}
.follow-btn:hover {
background: var(--primary-50) !important;
border-color: var(--primary-400) !important;
color: var(--primary-700) !important;
transform: translateY(-1px) !important;
box-shadow: 0 2px 8px rgba(168, 85, 247, 0.2) !important;
}
.follow-btn:active {
transform: translateY(0) !important;
}
/* 已关注状态样式 */
.follow-btn.following {
background: var(--primary-100) !important;
border-color: var(--primary-400) !important;
color: var(--primary-700) !important;
}
.follow-btn.following:hover {
background: var(--gray-100) !important;
border-color: var(--gray-400) !important;
color: var(--gray-600) !important;
}
.post-content {
margin-bottom: 32px;
}
.markdown-content {
background: white;
border-radius: 12px;
padding: 24px;
border: 1px solid var(--gray-200);
box-shadow: var(--shadow-sm);
}
.post-markdown {
font-size: 16px !important;
line-height: 1.8 !important;
color: var(--gray-700) !important;
}
/* Markdown内容样式优化 */
.post-markdown h1,
.post-markdown h2,
.post-markdown h3,
.post-markdown h4,
.post-markdown h5,
.post-markdown h6 {
color: var(--gray-800) !important;
font-weight: 600 !important;
margin: 24px 0 16px 0 !important;
}
.post-markdown h1 {
font-size: 28px !important;
border-bottom: 2px solid var(--primary-200) !important;
padding-bottom: 8px !important;
}
.post-markdown h2 {
font-size: 24px !important;
border-bottom: 1px solid var(--gray-200) !important;
padding-bottom: 6px !important;
}
.post-markdown h3 {
font-size: 20px !important;
}
.post-markdown p {
margin-bottom: 16px !important;
color: var(--gray-700) !important;
}
.post-markdown blockquote {
border-left: 4px solid var(--primary-400) !important;
background: var(--primary-50) !important;
padding: 16px 20px !important;
margin: 16px 0 !important;
border-radius: 6px !important;
}
.post-markdown code {
background: var(--gray-100) !important;
padding: 2px 6px !important;
border-radius: 4px !important;
font-size: 14px !important;
color: var(--primary-700) !important;
}
.post-markdown pre {
background: var(--gray-900) !important;
border-radius: 8px !important;
padding: 16px !important;
margin: 16px 0 !important;
overflow-x: auto !important;
}
.post-markdown ul,
.post-markdown ol {
margin: 16px 0 !important;
padding-left: 24px !important;
}
.post-markdown li {
margin-bottom: 8px !important;
color: var(--gray-700) !important;
}
.post-markdown table {
width: 100% !important;
border-collapse: collapse !important;
margin: 16px 0 !important;
border-radius: 8px !important;
overflow: hidden !important;
box-shadow: var(--shadow-sm) !important;
}
.post-markdown th,
.post-markdown td {
padding: 12px 16px !important;
text-align: left !important;
border-bottom: 1px solid var(--gray-200) !important;
}
.post-markdown th {
background: var(--primary-50) !important;
font-weight: 600 !important;
color: var(--gray-800) !important;
}
.post-markdown a {
color: var(--primary-600) !important;
text-decoration: none !important;
border-bottom: 1px solid transparent !important;
transition: var(--transition-base) !important;
}
.post-markdown a:hover {
color: var(--primary-700) !important;
border-bottom-color: var(--primary-400) !important;
}
.post-markdown img {
max-width: 100% !important;
height: auto !important;
border-radius: 8px !important;
margin: 16px 0 !important;
box-shadow: var(--shadow-md) !important;
}
.post-footer {
padding-top: 24px;
border-top: 1px solid var(--gray-200);
}
.post-stats {
display: flex;
gap: 24px;
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
color: var(--gray-500);
font-size: 14px;
}
/* 评论区域 */
.comments-section {
padding: 32px;
}
.comments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.comments-header h3 {
color: var(--gray-800);
font-size: 18px;
font-weight: 700;
}
.comment-form {
margin-bottom: 32px;
}
.comment-input {
margin-bottom: 12px;
}
.comment-actions {
display: flex;
justify-content: flex-end;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 24px;
}
.empty-comments {
text-align: center;
padding: 48px 24px;
color: var(--gray-500);
}
.comment-item {
display: flex;
gap: 16px;
}
.comment-avatar {
flex-shrink: 0;
}
.comment-content {
flex: 1;
}
.comment-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.comment-author {
font-weight: 600;
color: var(--gray-800);
}
.comment-time {
color: var(--gray-500);
font-size: 12px;
}
.comment-text {
color: var(--gray-700);
line-height: 1.6;
margin-bottom: 12px;
}
.comment-footer {
display: flex;
gap: 16px;
}
.replies-list {
margin-top: 16px;
padding-left: 16px;
border-left: 2px solid var(--gray-200);
display: flex;
flex-direction: column;
gap: 16px;
}
.reply-item {
display: flex;
gap: 12px;
}
.reply-content {
flex: 1;
}
.reply-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.reply-author {
font-weight: 600;
color: var(--gray-800);
font-size: 14px;
}
.reply-time {
color: var(--gray-500);
font-size: 12px;
}
.reply-text {
color: var(--gray-700);
line-height: 1.6;
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.nav-menu {
display: none;
}
.post-detail-main {
padding: 16px;
}
.post-content-section,
.comments-section {
padding: 24px 16px;
}
.post-title {
font-size: 1.5rem;
}
.author-section {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.comment-item {
gap: 12px;
}
}
</style>