feat: 重构前端项目结构并添加新功能

重构项目目录结构,将组件和服务模块化
添加Element Plus UI库并集成到项目中
实现文章、留言和分类的类型定义
新增工具函数模块包括日期格式化和字符串处理
重写路由配置并添加全局路由守卫
优化页面布局和响应式设计
新增服务层封装API请求
完善文章详情页和相关文章推荐功能
This commit is contained in:
qingfeng1121
2025-10-12 14:24:20 +08:00
parent 07d3159b08
commit b8362e7835
22 changed files with 2673 additions and 453 deletions

View File

@@ -1,105 +1,284 @@
<template>
<div class="message-board">
<!-- 留言内容区 -->
<div class="message-list">
<h3>留言板</h3>
<div class="message-item" v-for="msg in messages" :key="msg.id" @mouseenter="hoverId = msg.id"
@mouseleave="hoverId = null">
<div>
<!-- 头像 -->
<img src="https://www.gravatar.com/avatar?d=mp&s=40" alt="头像" class="message-avatar" />
<div>
<div class="message-board">
<!-- 留言内容区 -->
<div class="message-list">
<h3>留言板</h3>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :count="5" />
</div>
<div>
<!-- 评论内容 -->
<div class="message-item-top">
<div class="message-nickname">{{ msg.nickname }}</div>
<div class="message-time">{{ msg.createdAt }}</div>
</div>
<div class="message-content">{{ msg.content }}</div>
<!-- 回复按钮 -->
<div class="message-item-bottom">
<button class="reply-btn" @click="message_click(msg)"
:class="{ visible: hoverId === msg.id }">回复</button>
</div>
<!-- 回复列表 -->
<div v-if="msg.replies && msg.replies.length" class="replies">
<div class="reply-item" v-for="reply in msg.replies" :key="reply.id">
<div>
<!-- 头像 -->
<img src="https://www.gravatar.com/avatar?d=mp&s=40" alt="头像" class="message-avatar" />
</div>
<div>
<div class="message-item-top ">
<div class="message-nickname">{{ reply.nickname }}</div>
<div class="message-time">{{ reply.time }}</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">{{ reply.content }}</div>
<!-- 回复按钮 -->
<div class="message-item-bottom">
<button class="reply-btn" @click="message_click(msg)"
:class="{ visible: hoverId === msg.id }">回复</button>
<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.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 && mainMsg.replies.length" 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>
</div>
</div>
</transition-group>
<div v-if="!loading && messageBoardData.length === 0 && articleRelatedData.length === 0"
class="message-empty">
还没有留言快来抢沙发吧
</div>
</div>
<div v-if="messages.length === 0" class="message-empty">还没有留言快来抢沙发吧</div>
</div>
<!-- 留言输入区 -->
<div class="message-form-section">
<h2>发送评论请正确填写邮箱地址否则将会当成垃圾评论处理</h2>
<div v-if="message_all.id" class="reply-preview">
<span>
正在回复 <b>{{ message_all.nickname }}</b> 的评论 :
</span>
<div class="reply-preview-content">
{{ message_all.content }}
<!-- 留言输入区 -->
<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>
<button class="reply-cancel-btn" @click="post_comment_reply_cancel">取消回复</button>
<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>
<el-form :model="form" label-width="0">
<el-form-item>
<el-input v-model="form.content" placeholder="评论内容" type="textarea" rows="4" clearable />
</el-form-item>
<div class="form-input-row">
<el-form-item>
<el-input v-model="form.nickname" placeholder="昵称" clearable />
</el-form-item>
<el-form-item>
<el-input v-model="form.email" placeholder="邮箱/QQ号" clearable />
</el-form-item>
<el-form-item>
<el-input v-model="form.captcha" placeholder="验证码" clearable />
</el-form-item>
</div>
<div class="form-input-row">
<el-form-item>
<el-button type="primary" @click="onSubmit">发送</el-button>
</el-form-item>
</div>
</el-form>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { messageAPI } from '@/axios/api'
const message_all = ref({})
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 messages = ref([])
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.id,
nickname: msg.nickname || '匿名用户',
content: msg.content
}
form.replyid = msg.id
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(() => {
messageAPI.getAllMessages().then(res => {
messages.value = res.data
}).catch(err => {
console.log(err)
}).finally(() => {
console.log("finally")
})
fetchMessages()
})
const form = reactive({
@@ -110,51 +289,297 @@ const form = reactive({
captcha: ''
})
const onSubmit = () => {
const onSubmit = async () => {
if (!form.content || !form.nickname) return
if (form.replyid) {
// 回复模式
const parent = messages.value.find(msg => msg.id === form.replyid)
if (parent) {
parent.replies = parent.replies || []
parent.replies.push({
id: Date.now(),
nickname: form.nickname,
try {
submitting.value = true
if (form.replyid) {
// 回复模式
const res = await messageService.saveMessage({
content: form.content,
time: new Date().toLocaleString()
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 || '未知错误'))
}
}
} else {
// 普通留言
messages.value.push({
id: Date.now(),
nickname: form.nickname,
content: form.content,
time: new Date().toLocaleString(),
replies: []
})
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
submitting.value = false
}
}
// 重置表单
const resetForm = () => {
form.replyid = null
form.content = ''
form.nickname = ''
form.email = ''
form.captcha = ''
message_all.value = {}
}
const message_click = (msg) => {
message_all.value = msg
form.replyid = msg.id
// 可选:填充回复内容到输入框
form.content = `@${msg.nickname} `
replyingTo.value = { id: null, nickname: '', content: '' }
}
const post_comment_reply_cancel = () => {
message_all.value = {}
form.content = ''
form.replyid = null
replyingTo.value = { id: null, nickname: '', content: '' }
}
</script>
<style scoped>
.message-board {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.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-board {
padding: 24px 0;
}
@@ -306,6 +731,23 @@ const post_comment_reply_cancel = () => {
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;