Files
MyfronyProject/src/views/messageboard.vue
qingfeng1121 02d17d7260 feat(分类系统): 实现分类属性功能并重构文章列表
重构文章分类系统,新增分类属性服务及相关API接口
修改文章列表页面以支持按属性筛选文章
调整路由和样式以适配新功能
2025-10-18 10:28:49 +08:00

759 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<div class="message-board">
<!-- 留言内容区 -->
<div class="message-list">
<h3 class="title">留言板</h3>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :count="5" />
</div>
<!-- 留言列表 -->
<transition-group name="message-item" tag="div" v-else>
<!-- 留言板留言 (articleid为空的留言) -->
<div v-if="messageBoardData.length > 0" class="message-section">
<h4>留言板留言</h4>
<!-- 主留言和回复树结构 -->
<div v-for="mainMsg in messageBoardData" :key="mainMsg.id" class="message-tree">
<!-- 主留言 -->
<div class="message-item" @mouseenter="hoverId = mainMsg.id" @mouseleave="hoverId = null">
<div class="message-avatar-container">
<img :src="getAvatar(mainMsg.email)" alt="头像" class="message-avatar" />
</div>
<div class="message-content-container">
<div class="message-item-top">
<div class="message-nickname">{{ mainMsg.nickname || '匿名用户' }}</div>
<div class="message-time">{{ formatRelativeTime(mainMsg.createdAt) }}</div>
</div>
<div class="message-content">{{ mainMsg.content }}</div>
<!-- 回复按钮 -->
<div class="message-item-bottom">
<button class="reply-btn" @click="handleReply(mainMsg)"
:class="{ visible: hoverId === mainMsg.id }">
回复
</button>
</div>
<!-- 回复列表 -->
<div v-if="mainMsg.replies.length> 0" class="replies">
<div v-for="reply in mainMsg.replies" :key="reply.id" class="reply-item">
<div class="message-avatar-container">
<img :src="getAvatar(reply.email)" alt="头像" class="message-avatar" />
</div>
<div class="message-content-container">
<div class="message-item-top">
<div class="message-nickname">{{ reply.nickname || '匿名用户' }}</div>
<div class="message-time">{{ formatRelativeTime(reply.createdAt) }}
</div>
</div>
<div class="message-content">
<span class="reply-to">@{{ mainMsg.nickname || '匿名用户' }}</span>
{{ reply.content }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 文章相关留言 (articleid不为空的留言) -->
<div v-if="articleRelatedData.length > 0" class="message-section">
<h4>文章留言</h4>
<div v-for="articleGroup in articleRelatedData" :key="articleGroup.articleId"
class="article-message-group">
<div class="article-message-header">
<span class="article-title">{{ articleGroup.articleTitle }}</span>
</div>
<div class="article-message-content">
<div v-for="mainMsg in articleGroup.messages" :key="mainMsg.messageid" class="message-tree">
<!-- 主留言 -->
<div class="message-item" @mouseenter="hoverId = mainMsg.messageid"
@mouseleave="hoverId = null">
<div class="message-avatar-container">
<img :src="getAvatar(mainMsg.email)" alt="头像" class="message-avatar" />
</div>
<div class="message-content-container">
<div class="message-item-top">
<div class="message-nickname">{{ mainMsg.nickname || '匿名用户' }}</div>
<div class="message-time">{{ formatRelativeTime(mainMsg.createdAt) }}
</div>
</div>
<div class="message-content">{{ mainMsg.content }}</div>
<!-- 回复按钮 -->
<div class="message-item-bottom">
<button class="reply-btn" @click="handleReply(mainMsg)"
:class="{ visible: hoverId === mainMsg.messageid }">
回复
</button>
</div>
<!-- 回复列表 -->
<div v-if="mainMsg.replies && mainMsg.replies.length" class="replies">
<div v-for="reply in mainMsg.replies" :key="reply.messageid"
class="reply-item">
<div class="message-avatar-container">
<img :src="getAvatar(reply.email)" alt="头像"
class="message-avatar" />
</div>
<div class="message-content-container">
<div class="message-item-top">
<div class="message-nickname">{{ reply.nickname || '匿名用户' }}
</div>
<div class="message-time">{{
formatRelativeTime(reply.createdAt) }}</div>
</div>
<div class="message-content">
<span class="reply-to">@{{ mainMsg.nickname || '匿名用户'
}}</span>
{{ reply.content }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</transition-group>
<div v-if="!loading && messageBoardData.length === 0 && articleRelatedData.length === 0"
class="message-empty">
还没有留言快来抢沙发吧
</div>
</div>
<!-- 留言输入区 -->
<div class="message-form-section">
<h2>发送评论请正确填写邮箱地址否则将会当成垃圾评论处理</h2>
<div v-if="replyingTo.id" class="reply-preview">
<span>
正在回复 <b>{{ replyingTo.nickname }}</b> 的评论:
</span>
<div class="reply-preview-content">
{{ replyingTo.content }}
</div>
<button class="reply-cancel-btn" @click="cancelReply">取消回复</button>
</div>
<el-form :model="form" label-width="0">
<el-form-item>
<el-input v-model="form.content" placeholder="评论内容" type="textarea" rows="4" clearable
:disabled="submitting" />
</el-form-item>
<div class="form-input-row">
<el-form-item>
<el-input v-model="form.nickname" placeholder="昵称" clearable :disabled="submitting" />
</el-form-item>
<el-form-item>
<el-input v-model="form.email" placeholder="邮箱/QQ号" clearable :disabled="submitting" />
</el-form-item>
<el-form-item>
<el-input v-model="form.captcha" placeholder="验证码" clearable :disabled="submitting" />
</el-form-item>
</div>
<div class="form-input-row">
<el-form-item>
<el-button type="primary" @click="onSubmit" :loading="submitting"
:disabled="!form.content || !form.nickname">
发送
</el-button>
</el-form-item>
</div>
</el-form>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { messageService } from '@/services'
import { formatRelativeTime } from '@/utils/dateUtils'
import { ElMessage } from 'element-plus'
import { useRoute } from 'vue-router'
const route = useRoute()
const hoverId = ref(null)
const messageBoardData = ref([]) // 留言板留言articleid为空的主留言及其回复
const articleRelatedData = ref([]) // 文章相关留言articleid不为空的主留言及其回复按文章分组
const loading = ref(false)
const submitting = ref(false)
const replyingTo = ref({ id: null, nickname: '', content: '' })
// 生成头像URL
const getAvatar = (email) => {
if (!email) return 'https://www.gravatar.com/avatar?d=mp&s=40'
return `https://www.gravatar.com/avatar/${email}?d=mp&s=40`
}
// 从后端获取留言列表
const fetchMessages = async () => {
try {
loading.value = true
const res = await messageService.getAllMessages()
const allMessages = res.data || []
// 按articleId和parentId分类留言
const boardMsgs = []
const articleMsgsMap = new Map()
// 首先处理所有留言
const allMessagesWithReplies = allMessages.map(msg => ({
...msg,
replies: []
}))
// 分离主留言和回复
const mainMessages = []
const replies = []
allMessagesWithReplies.forEach(msg => {
if (msg.parentid && msg.parentid > 0) {
replies.push(msg)
} else {
mainMessages.push(msg)
}
})
// 将回复添加到对应的主留言中
replies.forEach(reply => {
const parentMsg = mainMessages.find(msg => msg.messageid === reply.parentid)
console.log('找到的父留言:', mainMessages)
if (parentMsg) {
parentMsg.replies.push(reply)
}
})
// 按articleId分类主留言
mainMessages.forEach(msg => {
if (msg.articleid) {
// 文章相关留言
if (!articleMsgsMap.has(msg.articleid)) {
articleMsgsMap.set(msg.articleid, [])
}
articleMsgsMap.get(msg.articleid).push(msg)
} else {
// 留言板留言
boardMsgs.push(msg)
}
})
// 转换文章留言Map为数组
articleRelatedData.value = Array.from(articleMsgsMap.entries()).map(([articleId, msgs]) => ({
articleId,
articleTitle: `文章 ${articleId}`, // 这里可以根据需要从其他地方获取文章标题
messages: msgs
}))
console.log('主留言和回复分离:', { mainMessages, replies })
messageBoardData.value = boardMsgs
console.log('获取留言列表成功:', { boardMessages: messageBoardData.value, articleMessages: articleRelatedData.value })
} catch (error) {
console.error('获取留言列表失败:', error)
ElMessage.error('获取留言失败,请稍后重试')
} finally {
loading.value = false
console.log('留言列表加载完成,共有' + messageBoardData.value.length + '条留言板留言')
}
}
// 处理回复
const handleReply = (msg) => {
replyingTo.value = {
id: msg.messageid,
nickname: msg.nickname || '匿名用户',
content: msg.content
}
form.parentid = msg.messageid
form.content = `@${replyingTo.value.nickname} `
// 滚动到输入框
setTimeout(() => {
document.querySelector('.message-form-section')?.scrollIntoView({ behavior: 'smooth' })
}, 100)
}
// 取消回复
const cancelReply = () => {
replyingTo.value = { id: null, nickname: '', content: '' }
form.replyid = null
form.content = ''
}
// 组件挂载时获取留言列表
onMounted(() => {
fetchMessages()
})
const form = reactive({
parentid: null,
content: '',
nickname: '',
email: '',
})
const onSubmit = async () => {
if (!form.content || !form.nickname) return
try {
submitting.value = true
if (form.replyid) {
// 回复模式
const res = await messageService.saveMessage({
content: form.content,
nickname: form.nickname,
email: form.email,
parentid: form.replyid
})
if (res.success) {
ElMessage.success('回复成功')
fetchMessages() // 重新获取列表
cancelReply()
} else {
ElMessage.error('回复失败:' + (res.message || '未知错误'))
}
} else {
// 普通留言
const res = await messageService.saveMessage({
content: form.content,
nickname: form.nickname,
email: form.email,
captcha: form.captcha
})
if (res.success) {
ElMessage.success('留言成功')
fetchMessages() // 重新获取列表
resetForm()
} else {
ElMessage.error('留言失败:' + (res.message || '未知错误'))
}
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
submitting.value = false
}
}
// 重置表单
const resetForm = () => {
form.replyid = null
form.content = ''
form.nickname = ''
form.email = ''
form.captcha = ''
replyingTo.value = { id: null, nickname: '', content: '' }
}
const post_comment_reply_cancel = () => {
form.content = ''
form.replyid = null
replyingTo.value = { id: null, nickname: '', content: '' }
}
</script>
<style scoped>
.message-board {
max-width: 1000px;
margin: 0 auto;
}
.message-list {
margin-bottom: 30px;
}
.message-section {
margin-bottom: 30px;
}
.message-section h4 {
color: #3498db;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #3498db;
}
.loading-container {
padding: 20px;
}
.message-item {
display: flex;
margin-bottom: 20px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 8px;
transition: all 0.3s ease;
}
.message-item:hover {
background-color: #e9ecef;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.message-avatar-container {
margin-right: 15px;
flex-shrink: 0;
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.message-content-container {
flex: 1;
}
.message-item-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.message-nickname {
font-weight: 600;
color: #2c3e50;
}
.message-time {
font-size: 0.8rem;
color: #7f8c8d;
}
.message-content {
color: #34495e;
line-height: 1.6;
margin-bottom: 10px;
word-break: break-word;
}
.reply-to {
color: #e74c3c;
font-weight: 500;
}
.message-item-bottom {
display: flex;
justify-content: flex-end;
}
.reply-btn {
background: none;
border: none;
color: #3498db;
cursor: pointer;
font-size: 0.85rem;
opacity: 0;
transition: opacity 0.3s ease;
}
.reply-btn.visible {
opacity: 1;
}
.reply-btn:hover {
color: #2980b9;
text-decoration: underline;
}
.replies {
margin-top: 15px;
padding-left: 20px;
border-left: 3px solid #3498db;
}
.reply-item {
display: flex;
margin-bottom: 15px;
padding: 10px;
background-color: rgba(255, 255, 255, 0.7);
border-radius: 6px;
}
.reply-item:last-child {
margin-bottom: 0;
}
.message-empty {
text-align: center;
color: #7f8c8d;
padding: 40px;
background-color: #f8f9fa;
border-radius: 8px;
}
.message-form-section {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.message-form-section h2 {
color: #2c3e50;
margin-bottom: 20px;
font-size: 1.3rem;
}
.reply-preview {
background-color: #e8f4fd;
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
}
.reply-preview-content {
margin-top: 10px;
padding: 10px;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 4px;
font-style: italic;
color: #555;
}
.reply-cancel-btn {
background: none;
border: 1px solid #e74c3c;
color: #e74c3c;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
font-size: 0.85rem;
}
.reply-cancel-btn:hover {
background-color: #e74c3c;
color: white;
}
.form-input-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.form-input-row .el-form-item {
flex: 1;
}
.article-message-group {
margin-bottom: 25px;
padding: 15px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.article-message-header {
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #ecf0f1;
}
.article-title {
color: #3498db;
font-weight: 600;
}
/* 响应式设计 */
@media (max-width: 768px) {
.form-input-row {
flex-direction: column;
}
.message-item {
flex-direction: column;
}
.message-avatar-container {
margin-right: 0;
margin-bottom: 10px;
}
}
.message-list {
margin-bottom: 24px;
background: #f8fafd;
border-radius: 12px;
padding: 16px;
min-height: 120px;
}
.replies {
margin-left: 40px;
border-top: 2px solid #eee;
padding: 12px;
}
.message-item {
background: #fff;
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
flex-direction: column;
gap: 4px;
position: relative;
margin-left: 1%;
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 12px;
float: left;
}
.message-item-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.message-nickname {
font-weight: bold;
color: #409eff;
}
.message-content {
color: #333;
margin-top: 2px;
}
.message-time {
font-size: 12px;
color: #aaa;
text-align: right;
}
.message-item-bottom {
height: 45px;
/* 固定高度以防跳动 */
text-align: center;
}
.reply-btn {
background: #409eff;
color: #fff;
border: none;
border-radius: 6px;
padding: 4px 12px;
margin-top: 4px;
transition: background 0.2s;
float: right;
visibility: hidden;
/* 默认隐藏但占位 */
}
.message-item:hover .reply-btn,
.reply-btn.visible {
visibility: visible;
}
.message-empty {
color: #bbb;
text-align: center;
padding: 32px 0;
}
.message-form-section {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.message-form-section h2 {
font-size: 1.1rem;
margin-bottom: 18px;
color: #333;
}
.reply-preview {
background: #f0f9ff;
border-left: 4px solid #409eff;
padding: 10px 16px;
border-radius: 8px;
margin-bottom: 16px;
color: #333;
}
.reply-preview-content {
margin-top: 6px;
color: #666;
font-size: 0.98rem;
}
.reply-cancel-btn {
background: #fff;
color: #409eff;
border: 1px solid #409eff;
border-radius: 6px;
padding: 2px 10px;
cursor: pointer;
margin-top: 8px;
font-size: 0.95rem;
transition: background 0.2s, color 0.2s;
}
.reply-cancel-btn:hover {
background: #409eff;
color: #fff;
}
.form-input-row {
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.form-input-row .el-form-item {
flex: 1;
}
.el-form-item .el-input__inner,
.el-form-item .el-textarea__inner {
min-height: 45px;
font-size: 1rem;
}
/* 加载状态 */
.loading-container {
padding: 40px 20px;
}
/* 过渡动画 */
.message-item-enter-active,
.message-item-leave-active {
transition: all 0.5s ease;
}
.message-item-enter-from,
.message-item-leave-to {
opacity: 0;
transform: translateY(20px);
}
@media (max-width: 768px) {
.message-board {
padding: 8px 0;
}
.message-form-section {
padding: 12px;
}
.form-input-row {
flex-direction: column;
gap: 8px;
}
}
</style>