Files
MyfronyProject/src/views/messageboard.vue
qingfeng1121 6d90b5842f feat: 添加登录功能与文章编辑功能
refactor: 重构API服务与全局状态管理

style: 优化UI样式与布局

fix: 修复文章列表与详情页的显示问题

docs: 更新类型定义与注释

chore: 更新依赖包与配置文件
2025-10-30 19:00:59 +08:00

826 lines
24 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-container">
<!-- 留言内容区 -->
<div class="message-list-wrapper">
<h3 class="message-board-title">留言板</h3>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state-container">
<el-skeleton :count="5" />
</div>
<!-- 留言列表 -->
<div class="comment-list-container">
<div v-for="comment in messageBoardData" :key="comment.messageid" class="comment-item-wrapper">
<div class="comment-header-info">
<!-- 头像 -->
<img :src="getAvatar()" class="user-avatar">
<div class="user-meta-info">
<div class="user-nickname">{{ comment.displayName || comment.nickname }}</div>
<div class="comment-time">{{ formatDate(comment.createdAt) || '刚刚' }}</div>
</div>
</div>
<div class="comment-content-text" v-html="comment.content"></div>
<div class="comment-actions-bar">
<span class="like-button" @click="handleLike(comment)">
<span v-if="comment.likes && comment.likes > 0" class="like-count">{{ comment.likes }}</span>
👍
</span>
<span class="reply-button" @click="handleReply(null, comment)">回复</span>
</div>
<!-- 回复列表 -->
<div v-if="comment.replies && comment.replies && comment.replies.length > 0" class="reply-list-container">
<div v-for="reply in comment.replies" :key="reply.messageid" class="reply-item-wrapper">
<div class="reply-header-info">
<img :src="getAvatar()" class="user-avatar">
<div class="user-meta-info">
<div class="user-nickname">{{ reply.displayName || reply.nickname }}</div>
<div class="comment-time">{{ formatDate(reply.createdAt) || '刚刚' }}</div>
</div>
</div>
<div class="reply-content-text">{{ reply.content }}</div>
<div class="reply-actions-bar">
<span class="like-button" @click="handleLike(reply)">
<span v-if="reply.likes && reply.likes > 0" class="like-count">{{ reply.likes }}</span>
👍
</span>
<span class="reply-button" @click="handleReply(comment, reply)">回复</span>
<!-- 删除按钮 -->
<span class="delete-button" @click="handleDelete(reply.messageid)" v-if=" globalStore.Login">删除</span>
</div>
</div>
</div>
</div>
</div>
<!-- 无留言提示 -->
<div v-if="!loading && messageBoardData.length === 0" class="empty-message-state">
还没有留言快来抢沙发吧
</div>
</div>
<!-- 留言输入区 -->
<div class="comment-form-section">
<h2 class="comment-form-title">发送评论请正确填写邮箱地址否则将会当成垃圾评论处理</h2>
<div v-if="replyingTo.id" class="reply-preview-container">
<span>
正在回复 <b>{{ replyingTo.nickname }}</b> 的评论:
</span>
<div class="reply-preview-text">
{{ replyingTo.content }}
</div>
<button class="cancel-reply-button" @click="cancelReply">取消回复</button>
</div>
<el-form :model="form" :rules="rules" ref="formRef" label-width="0">
<el-form-item prop="content">
<el-input v-model="form.content" placeholder="评论内容" type="textarea" rows="4" clearable
:disabled="submitting" />
</el-form-item>
<div class="form-input-row form-input-row--inline">
<el-form-item prop="nickname">
<el-input v-model="form.nickname" placeholder="昵称" clearable :disabled="submitting" />
</el-form-item>
<el-form-item prop="email">
<el-input v-model="form.email" placeholder="邮箱/QQ号" clearable :disabled="submitting" />
</el-form-item>
<el-form-item prop="captcha">
<div class="captcha-input-wrapper">
<el-input
v-model="form.captcha"
placeholder="验证码"
clearable
:disabled="submitting"
@focus="showCaptchaHint = true"
@blur="showCaptchaHint = false"
/>
<div class="captcha-hint-popup" @click="generateCaptcha" v-show="showCaptchaHint">
{{ captchaHint }}
<span class="refresh-icon"></span>
</div>
</div>
</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, watch } from 'vue'
import { messageService } from '@/services'
import { ElMessage, ElForm } from 'element-plus'
import { useGlobalStore } from '@/store/globalStore'
import { formatDate } from '@/utils/dateUtils'
// 定义组件属性
const props = defineProps({
comments: {
type: Number,
default: null
}
})
const globalStore = useGlobalStore()
const messageBoardData = ref([]) // 留言板留言articleid为空的主留言及其回复
const loading = ref(false)
const submitting = ref(false)
const replyingTo = ref({ id: null, nickname: '', content: '' })
const formRef = ref()
// 验证码相关状态
const captchaHint = ref('')
const captchaAnswer = ref('')
const showCaptchaHint = ref(false)
const form = reactive({
parentid: null,
replyid: null,
articleid: null,
content: '',
nickname: '',
email: '',
captcha: ''
})
// 生成简单验证码
const generateCaptcha = () => {
// 随机选择数学题或字符验证码
const isMathCaptcha = Math.random() > 0.5
if (isMathCaptcha) {
// 简单数学题:加法或减法
const num1 = Math.floor(Math.random() * 10) + 1
const num2 = Math.floor(Math.random() * 10) + 1
const operator = Math.random() > 0.5 ? '+' : '-'
let answer
if (operator === '+') {
answer = num1 + num2
} else {
// 确保减法结果为正
const larger = Math.max(num1, num2)
const smaller = Math.min(num1, num2)
captchaHint.value = `${larger} - ${smaller} = ?`
captchaAnswer.value = (larger - smaller).toString()
return
}
captchaHint.value = `${num1} ${operator} ${num2} = ?`
captchaAnswer.value = answer.toString()
} else {
// 简单字符验证码
const chars = 'ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz123456789'
let captcha = ''
for (let i = 0; i < 4; i++) {
captcha += chars.charAt(Math.floor(Math.random() * chars.length))
}
captchaHint.value = captcha
captchaAnswer.value = captcha.toLowerCase()
}
}
// 表单验证规则
const rules = {
content: [
{ required: true, message: '请输入评论内容', trigger: 'blur' },
{ min: 1, max: 500, message: '评论内容长度应在1-500个字符之间', trigger: 'blur' }
],
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 2, max: 20, message: '昵称长度应在2-20个字符之间', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱/QQ号', trigger: 'blur' },
{
validator: (rule, value, callback) => {
// 验证邮箱格式或QQ号格式5-11位数字
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
const qqRegex = /^[1-9]\d{4,10}$/;
if (emailRegex.test(value) || qqRegex.test(value)) {
callback();
} else {
callback(new Error('请输入有效的邮箱地址或QQ号'));
}
},
trigger: 'blur'
}
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value.toLowerCase() !== captchaAnswer.value) {
callback(new Error('验证码错误,请重新输入'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
// 生成头像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
let res = null
// 优先使用props传递的articleid其次使用globalStore中的数据
let articleid = props.comments || null
if (!articleid) {
// 安全获取文章ID如果globalStore中没有articleInfo则返回null
const articleData = globalStore.getValue('articleInfo')
articleid = (articleData && typeof articleData === 'object' && 'articleid' in articleData) ? articleData.articleid : null
}
form.articleid = articleid
// 根据是否有文章ID选择不同的API调用
if (articleid) {
res = await messageService.getMessagesByArticleId(articleid)
} else {
res = await messageService.getAllMessages()
// 过滤掉articleid不为空的留言只保留articleid为空或不存在的留言
if (res && res.data) {
res.data = res.data.filter(msg => !msg.articleid || msg.articleid === '')
}
}
// 验证响应结果
if (!res || !res.data) {
console.warn('未获取到留言数据')
messageBoardData.value = []
return
}
const allMessages = res.data
// 处理所有留言为主留言添加replies数组
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)
if (parentMsg) {
// 处理@回复的显示名称
if (reply.replyid) {
const repliedMsg = replies.find(msg => msg.messageid === reply.replyid)
if (repliedMsg) {
reply.displayName = `${reply.nickname}@${repliedMsg.nickname}`
} else {
reply.displayName = reply.nickname
}
} else {
reply.displayName = reply.nickname
}
parentMsg.replies.push(reply)
}
})
// 更新留言板数据
messageBoardData.value = mainMessages
} catch (error) {
console.error('获取留言列表失败:', error)
ElMessage.error('获取留言失败,请稍后重试')
messageBoardData.value = [] // 出错时清空数据,避免显示错误内容
} finally {
loading.value = false
}
}
// 处理回复
const handleReply = (msg, reply) => {
// 检查是否是回复模式
if (msg !== null) {
// 回复模式
form.replyid = reply.messageid
form.parentid = msg.messageid
} else {
// 普通回复模式
form.replyid = null
form.parentid = reply.messageid
}
replyingTo.value = {
id: reply.messageid,
nickname: reply.nickname || '匿名用户',
content: reply.content
}
// 滚动到输入框
setTimeout(() => {
document.querySelector('.message-form-section')?.scrollIntoView({ behavior: 'smooth' })
}, 100)
}
// 取消回复
const cancelReply = () => {
replyingTo.value = { id: null, nickname: '', content: '' }
form.replyid = null
form.content = ''
}
// 监听articleid变化重新加载留言
watch(() => props.comments, (newVal) => {
if (newVal) {
fetchMessages()
}
}, { immediate: false })
// 组件挂载时获取留言列表
onMounted(() => {
fetchMessages()
generateCaptcha() // 页面加载时生成验证码
})
const onSubmit = async () => {
if (!formRef.value) return;
// 表单验证
await formRef.value.validate((valid) => {
if (!valid) {
ElMessage.warning('请检查表单填写是否正确');
throw new Error('表单验证失败');
}
});
console.log('提交留言表单:', form)
try {
submitting.value = true
if (form.parentid) {
// 回复模式
const res = await messageService.saveMessage({
content: form.content,
nickname: form.nickname,
email: form.email,
parentid: form.parentid,
replyid: form.replyid,
articleid: form.articleid
})
if (res.success) {
ElMessage.success('回复成功')
fetchMessages() // 重新获取列表
resetForm()
cancelReply()
} else {
ElMessage.error('回复失败:' + (res.message || '未知错误'))
}
} else {
// 普通留言
const res = await messageService.saveMessage({
content: form.content,
nickname: form.nickname,
email: form.email,
articleid: form.articleid
})
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 = ''
form.parentid = null
form.articleid = null
replyingTo.value = { id: null, nickname: '', content: '' }
}
const post_comment_reply_cancel = () => {
form.content = ''
form.replyid = null
replyingTo.value = { id: null, nickname: '', content: '' }
}
// 处理点赞
const handleLike = async (msg) => {
try {
// 显示加载状态或禁用按钮
msg.isLiking = true
const res = await messageService.likeMessage(msg.messageid)
if (res.success && res.data) {
// 更新点赞数
msg.likes = res.data.likes || 0
ElMessage.success('点赞成功')
} else {
ElMessage.error('点赞失败:' + (res.message || '未知错误'))
}
} catch (error) {
console.error('点赞失败:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
msg.isLiking = false
}
}
// 处理删除
const handleDelete = async (msg) => {
try {
// 显示加载状态或禁用按钮
// msg.isDeleting = true
if (!confirm('确定删除吗?')) {
return
}
const res = await messageService.deleteMessage(msg.messageid)
if (res.success) {
// 从列表中移除
messageBoardData.value = messageBoardData.value.filter(item => item.messageid !== msg.messageid)
ElMessage.success('删除成功')
} else {
ElMessage.error('删除失败:' + (res.message || '未知错误'))
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
// msg.isDeleting = false
}
}
</script>
<style scoped lang="scss">
/* 主容器样式 */
.message-board-container {
max-width: 1000px;
margin: 0 auto;
}
/* 留言列表容器 */
.message-list-wrapper {
margin-bottom: 24px;
background: #f8fafd;
border-radius: 12px;
padding: 16px;
min-height: 120px;
}
/* 留言板标题 */
.message-board-title {
color: #2c3e50;
margin-bottom: 16px;
font-size: 1.2rem;
font-weight: 600;
}
/* 加载状态样式 */
.loading-state-container {
padding: 40px 20px;
display: flex;
justify-content: center;
}
/* 评论列表容器 */
.comment-list-container {
background-color: #f5f5f5;
}
/* 评论项容器 */
.comment-item-wrapper {
background-color: #fff;
padding: 16px;
margin-bottom: 16px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.comment-item-wrapper:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
/* 评论头部信息 */
.comment-header-info {
display: flex;
align-items: center;
margin-bottom: 12px;
}
/* 用户头像 */
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 12px;
object-fit: cover;
}
/* 用户元信息 */
.user-meta-info {
flex: 1;
text-align: left;
}
/* 用户名 */
.user-nickname {
font-weight: bold;
font-size: 14px;
color: #409eff;
margin-bottom: 4px;
}
/* 评论时间 */
.comment-time {
font-size: 12px;
color: #999;
}
/* 评论内容文本 */
.comment-content-text {
font-size: 14px;
line-height: 1.6;
margin-bottom: 12px;
word-break: break-word;
color: #333;
}
/* 评论操作栏 */
.comment-actions-bar {
text-align: right;
font-size: 14px;
color: #666;
}
/* 点赞按钮 */
.like-button {
margin-right: 15px;
cursor: pointer;
transition: all 0.3s ease;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
}
.like-button:hover {
color: #e74c3c;
background-color: rgba(231, 76, 60, 0.1);
}
.like-button:active {
transform: scale(0.95);
}
/* 点赞数量 */
.like-count {
margin-right: 4px;
font-weight: 600;
}
/* 回复按钮 */
.reply-button {
cursor: pointer;
transition: color 0.3s ease;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
}
.reply-button:hover {
color: #409eff;
background-color: rgba(64, 158, 255, 0.1);
}
// 删除按钮
.delete-button {
cursor: pointer;
transition: color 0.3s ease;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
}
.delete-button:hover {
color: #e74c3c;
background-color: rgba(231, 76, 60, 0.1);
}
/* 回复列表容器 */
.reply-list-container {
margin-top: 16px;
padding-left: 52px;
border-top: 1px solid #f0f0f0;
padding-top: 16px;
}
/**
* 内联表单输入行样式
* 用于将表单输入项与标签或其他元素对齐
*/
.form-input-row--inline {
display: flex;
align-items: center;
}
.form-input-row--inline div:nth-child(2) {
margin-left: 9%;
margin-right: 9%;
}
/* 回复项容器 */
.reply-item-wrapper {
background-color: #fafafa;
padding: 12px;
margin-bottom: 8px;
border-radius: 6px;
transition: all 0.3s ease;
}
.reply-item-wrapper:hover {
background-color: #f0f0f0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
/* 回复头部信息 */
.reply-header-info {
display: flex;
align-items: center;
margin-bottom: 8px;
}
/* 回复内容文本 */
.reply-content-text {
font-size: 14px;
line-height: 1.5;
margin-bottom: 8px;
color: #333;
}
/* 回复操作栏 */
.reply-actions-bar {
font-size: 12px;
color: #666;
text-align: right;
}
/* 空状态提示 */
.empty-message-state {
text-align: center;
color: #bbb;
padding: 32px 0;
background-color: #f8f9fa;
border-radius: 8px;
}
/* 评论表单区域 */
.comment-form-section {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
/* 评论表单标题 */
.comment-form-title {
color: #2c3e50;
margin-bottom: 18px;
font-size: 1.1rem;
}
/* 回复预览容器 */
.reply-preview-container {
background: #f0f9ff;
border-left: 4px solid #409eff;
padding: 15px;
border-radius: 6px;
margin-bottom: 16px;
color: #333;
}
/* 回复预览文本 */
.reply-preview-text {
margin-top: 6px;
padding: 10px;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 4px;
font-style: italic;
color: #666;
font-size: 0.98rem;
}
/* 取消回复按钮 */
.cancel-reply-button {
background: #fff;
color: #409eff;
border: 1px solid #409eff;
border-radius: 6px;
padding: 4px 12px;
cursor: pointer;
margin-top: 10px;
font-size: 0.95rem;
transition: background 0.2s, color 0.2s;
}
.cancel-reply-button:hover {
background: #409eff;
color: #fff;
}
/* 验证码输入包装器 */
.captcha-input-wrapper {
position: relative;
width: 100%;
}
/* 验证码提示弹窗 */
.captcha-hint-popup {
position: absolute;
top: -30px;
right: 0;
background-color: #f0f9ff;
border: 1px solid #d9ecff;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
color: #1890ff;
white-space: nowrap;
z-index: 10;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.3s;
}
.captcha-hint-popup:hover {
background-color: #e6f7ff;
border-color: #91d5ff;
}
/* 响应式设计 */
@media (max-width: 768px) {
.message-board-container {
padding: 8px 0;
}
.message-list-wrapper {
padding: 12px;
}
.comment-form-section {
padding: 16px;
}
.form-input-row {
flex-direction: column;
gap: 8px;
}
.comment-header-info,
.reply-header-info {
flex-direction: column;
align-items: flex-start;
}
.user-avatar {
margin-right: 0;
margin-bottom: 8px;
}
.reply-list-container {
padding-left: 20px;
}
}
</style>