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,11 +1,213 @@
<template>
<div id="allstyle">
<div class="header">
<h1>关于</h1>
<div>
<div class="about-wrapper">
<!-- 页面头部 -->
<div class="about-header">
<h1 class="about-title">关于我</h1>
<div class="about-subtitle">一个热爱技术的全栈开发者</div>
</div>
<!-- 关于内容 -->
<div class="about-content">
<div class="about-intro">
<p>你好欢迎来到我的个人博客我是一名热爱技术的全栈开发者热衷于探索新技术和解决复杂问题</p>
<p>这个博客是我分享技术见解学习心得和生活感悟的地方希望通过这个平台能够与更多志同道合的朋友交流和学习</p>
</div>
<div class="about-skills">
<h3>前端技术栈</h3>
<div class="skills-list">
<el-tag type="primary">HTML5</el-tag>
<el-tag type="primary">CSS3</el-tag>
<el-tag type="primary">JavaScript</el-tag>
<el-tag type="primary">TypeScript</el-tag>
<el-tag type="primary">Vue.js</el-tag>
<el-tag type="primary">React</el-tag>
<el-tag type="primary">Node.js</el-tag>
<el-tag type="primary">Webpack</el-tag>
<el-tag type="primary">Git</el-tag>
</div>
</div>
<div class="about-skills">
<h3>后端技术栈</h3>
<div class="skills-list">
<el-tag type="success">Spring Boot 2.6.13</el-tag>
<el-tag type="success">Spring Security</el-tag>
<el-tag type="success">Spring Data JPA</el-tag>
<el-tag type="success">MyBatis</el-tag>
<el-tag type="success">MySQL</el-tag>
<el-tag type="success">Lombok</el-tag>
<el-tag type="success">EHCache</el-tag>
<el-tag type="success">Maven</el-tag>
<el-tag type="success">Java 8</el-tag>
</div>
</div>
<div class="about-hobbies">
<h3>兴趣爱好</h3>
<ul>
<li>阅读技术书籍和博客</li>
<li>参与开源项目</li>
<li>学习新技术和框架</li>
</ul>
</div>
<div class="about-contact">
<h3>联系方式</h3>
<p>如果你有任何问题或建议欢迎随时联系我</p>
<div class="contact-list">
<el-button type="primary" plain>留言板</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
// 跳转到留言板
const goToMessageBoard = () => {
router.push('/message')
}
</script>
<style></style>
<style scoped>
.about-wrapper {
max-width: 900px;
margin: 0 auto;
background-color: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 页面头部 */
.about-header {
text-align: center;
margin-bottom: 40px;
padding-bottom: 24px;
border-bottom: 2px solid #ecf0f1;
}
.about-title {
font-size: 2.2rem;
color: #2c3e50;
margin-bottom: 12px;
font-weight: 600;
}
.about-subtitle {
font-size: 1.1rem;
color: #7f8c8d;
}
/* 关于内容 */
.about-content {
font-size: 1.05rem;
line-height: 1.8;
color: #34495e;
}
/* 个人介绍 */
.about-intro {
margin-bottom: 32px;
}
.about-intro p {
margin-bottom: 16px;
}
/* 技术栈 */
.about-skills {
margin-bottom: 32px;
}
.about-skills h3 {
color: #2c3e50;
font-size: 1.3rem;
margin-bottom: 16px;
}
.skills-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
/* 兴趣爱好 */
.about-hobbies {
margin-bottom: 32px;
}
.about-hobbies h3 {
color: #2c3e50;
font-size: 1.3rem;
margin-bottom: 16px;
}
.about-hobbies ul {
list-style: none;
padding: 0;
}
.about-hobbies li {
position: relative;
padding-left: 24px;
margin-bottom: 12px;
}
.about-hobbies li::before {
content: '✦';
position: absolute;
left: 0;
color: #3498db;
font-size: 1.2rem;
}
/* 联系方式 */
.about-contact {
margin-bottom: 20px;
}
.about-contact h3 {
color: #2c3e50;
font-size: 1.3rem;
margin-bottom: 16px;
}
.about-contact p {
margin-bottom: 16px;
}
/* 响应式设计 */
@media (max-width: 768px) {
#about-container {
padding: 20px 0;
}
.about-wrapper {
padding: 20px;
margin: 0 15px;
}
.about-title {
font-size: 1.8rem;
}
.about-subtitle {
font-size: 1rem;
}
.about-content {
font-size: 1rem;
}
.about-header {
margin-bottom: 32px;
}
}
</style>

View File

@@ -1,39 +1,94 @@
<template>
<div id="allstyle">
<div class="header">
<h1>目录</h1>
<h1>文章目录</h1>
</div>
<div class="post_content">
<div v-for="(items, index) in datas" style=" padding: 20px;">
<h2>{{ items[index] }}</h2>
<span class="badge badge-primary">{{ contentsum(items) }}</span>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :count="5" />
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<el-alert :title="error" type="error" show-icon />
<el-button type="primary" @click="fetchCategories">重新加载</el-button>
</div>
<!-- 分类列表 -->
<div v-else-if="categories.length > 0" class="post_content">
<div v-for="categoryGroup in categories" :key="categoryGroup.name" class="category-group">
<div class="category-header">
<h2>{{ categoryGroup.name }}</h2>
<span class="badge badge-primary">{{ getCategorySum(categoryGroup.categories) }}</span>
</div>
<ul class="pcont_ul">
<li class="pcont_li" v-for="item in items">
<a class="btn" @click="btnonclick(item.typeid)"><kbd>{{ item.content }}</kbd></a> ({{ item.sum }})
<li class="pcont_li" v-for="category in categoryGroup.categories" :key="category.typeid">
<button class="btn" @click="handleCategoryClick(category.typeid)">
<kbd>{{ category.content }}</kbd>
</button>
<span class="category-count">({{ category.sum }})</span>
</li>
</ul>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-container">
<el-empty description="暂无分类" />
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { articleAPI } from '@/axios/api'
import { ref, onMounted } from 'vue'
const router = useRouter()
const datas = ref([])
console.log("获取文章列表")
onMounted(() => {
articleAPI.getAllArticles().then(res => {
datas.value = res.data
}).catch(err => {
console.log(err)
}).finally(() => {
console.log("finally")
})
})
const btnonclick = (typeid) => {
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
import { articleService } from '@/services'
import { ElMessage } from 'element-plus'
import type { Category } from '@/types'
const router = useRouter()
// 响应式状态
const categories = ref<any[]>([])
const loading = ref(false)
const error = ref('')
/**
* 获取文章分类列表
*/
const fetchCategories = async () => {
try {
loading.value = true
error.value = ''
// 获取所有文章数据,然后从中提取分类信息
const res = await articleService.getAllArticles()
if (res.data && res.data.length > 0) {
// 假设数据结构是嵌套的分类组
categories.value = res.data
} else {
categories.value = []
}
console.log('获取分类列表成功:', categories.value)
} catch (err) {
error.value = '获取分类列表失败,请稍后重试'
console.error('获取分类列表失败:', err)
ElMessage.error(error.value)
} finally {
loading.value = false
console.log('分类列表加载完成')
}
}
/**
* 处理分类点击事件
* @param {string} typeid - 分类ID
*/
const handleCategoryClick = (typeid: string) => {
router.push({
path: '/:type',
query: {
@@ -41,25 +96,73 @@ const btnonclick = (typeid) => {
}
})
}
const contentsum = (items) => {
let nums = 0
for (let i = 0; i < items.length; i++) {
nums += items[i].sum
/**
* 计算分类组中的文章总数
* @param {Array} categoryItems - 分类项数组
* @returns {number} 文章总数
*/
const getCategorySum = (categoryItems: any[]): number => {
if (!categoryItems || !Array.isArray(categoryItems)) {
return 0
}
return nums
return categoryItems.reduce((total, item) => {
return total + (item.sum || 0)
}, 0)
}
/**
* 组件挂载时获取分类列表
*/
onMounted(() => {
fetchCategories()
})
</script>
<style>
<style scoped>
.header {
text-align: center;
padding: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #2c3e50;
font-size: 1.8rem;
}
.post_content {
padding: 20px;
}
.category-group {
background-color: rgba(255, 255, 255, 0.9);
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.category-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid #ecf0f1;
}
.category-header h2 {
color: #34495e;
font-size: 1.4rem;
margin: 0;
}
.pcont_ul {
list-style: none;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 15px;
margin-top: 20px;
@@ -70,20 +173,30 @@ const contentsum = (items) => {
align-items: center;
padding: 10px 15px;
border-radius: 12px;
background-color: rgba(245, 247, 250, 0.7);
transition: transform 0.3s ease;
gap: 10px;
}
.pcont_li:hover {
transform: translateY(-2px);
background-color: rgba(236, 240, 241, 0.9);
}
.btn {
position: relative;
text-decoration: none;
color: black;
color: #34495e;
padding: 10px 15px;
border-radius: 8px;
transition: all 0.3s ease;
display: inline-block;
z-index: 1;
overflow: hidden;
border: none;
background: transparent;
cursor: pointer;
font-size: 1rem;
}
/* 透明方块效果 */
@@ -99,17 +212,22 @@ const contentsum = (items) => {
transition: all 0.3s ease;
transform: scale(0.95);
opacity: 0.8;
background: rgba(255, 255, 255, 0.5);
}
/* 悬浮效果 */
.btn:hover::before {
transform: scale(1.1);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
background: rgba(145, 196, 238, 0.85);
transform: translateY(-3px);
}
/* spa */
.category-count {
color: #7f8c8d;
font-size: 0.9rem;
}
/* 标签样式 */
.badge {
text-transform: uppercase;
}
@@ -131,4 +249,61 @@ const contentsum = (items) => {
white-space: nowrap;
border-radius: .25rem;
}
/* 加载状态 */
.loading-container {
padding: 40px 20px;
text-align: center;
}
/* 错误状态 */
.error-container {
text-align: center;
padding: 40px 20px;
}
.error-container .el-button {
margin-top: 16px;
}
/* 空状态 */
.empty-container {
text-align: center;
padding: 60px 20px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.header {
padding: 15px;
}
.header h1 {
font-size: 1.5rem;
}
.post_content {
padding: 15px;
}
.category-group {
padding: 15px;
margin-bottom: 15px;
}
.category-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.pcont_ul {
grid-template-columns: 1fr;
gap: 10px;
}
.category-header h2 {
font-size: 1.2rem;
}
}
</style>

View File

@@ -1,31 +1,484 @@
<template>
<div id="allstyle">
<div class="header">
<h1>{{ article.title }}</h1>
</div>
<div class="article-content">
<p>{{ article.content }}</p>
</div>
<div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :count="1" />
<el-skeleton :count="3" />
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<el-alert :title="error" type="error" show-icon />
<el-button type="primary" @click="fetchArticleDetail">重新加载</el-button>
</div>
<!-- 文章详情 -->
<div v-else-if="article && Object.keys(article).length > 0" class="article-wrapper">
<!-- 文章头部 -->
<div class="article-header">
<h1 class="article-title">{{ article.title }}</h1>
<div class="article-meta">
<span class="meta-item">
<i class="el-icon-date"></i>
{{ formatDate(article.createTime) }}
</span>
<span class="meta-item">
<i class="el-icon-folder"></i>
{{ article.categoryName || '未分类' }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
{{ article.views || 0 }} 阅读
</span>
</div>
</div>
<!-- 文章内容 -->
<div class="article-content">
<div v-html="article.content"></div>
</div>
<!-- 文章底部 -->
<div class="article-footer">
<div class="tag-list">
<span
v-for="tag in article.tags || []"
:key="tag"
class="el-tag el-tag--primary"
>
{{ tag }}
</span>
</div>
<!-- 文章操作 -->
<div class="article-actions">
<el-button
type="primary"
icon="el-icon-arrow-left"
@click="goBack"
plain
>
返回
</el-button>
</div>
</div>
<!-- 相关文章 -->
<div class="related-articles" v-if="relatedArticles.length > 0">
<h3>相关文章</h3>
<div class="related-articles-list">
<div
v-for="item in relatedArticles"
:key="item.id"
class="related-article-item"
@click="handleRelatedArticleClick(item.id)"
>
<i class="el-icon-document"></i>
<span>{{ item.title }}</span>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-container">
<el-empty description="文章不存在" />
</div>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router';
import { articleAPI } from '@/axios/api'
import { ref, onMounted } from 'vue'
const article = ref({})
const urls = useRoute().query
// 从后端获取文章详情
onMounted(() => {
articleAPI.getArticleById(urls.articleid).then(res => {
article.value = res.data
}).catch(err => {
console.log(err)
}).finally(() => {
console.log("finally")
})
})
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
import { articleService } from '@/services'
import { ElMessage } from 'element-plus'
import type { Article } from '@/types'
import { formatDate } from '@/utils/dateUtils'
const route = useRoute()
const router = useRouter()
// 响应式状态
const article = ref<Article | null>(null) // 使用 null 作为初始值,避免类型不匹配问题
const loading = ref(false)
const error = ref('')
const relatedArticles = ref<Article[]>([])
/**
* 获取文章详情
*/
const fetchArticleDetail = async () => {
try {
loading.value = true
error.value = ''
// 获取路由参数
const articleId = route.query.url as string
console.log('获取文章ID:', articleId)
if (!articleId) {
throw new Error('文章ID不存在')
}
// 获取文章详情
const res = await articleService.getArticleById(Number(articleId))
if (res.data) {
article.value = res.data
// 增加文章浏览量
try {
await articleService.incrementArticleViews(Number(articleId))
console.log('文章浏览量增加成功')
// 更新前端显示的浏览量
if (article.value.views) {
article.value.views++
} else {
article.value.views = 1
}
} catch (err) {
console.error('增加文章浏览量失败:', err)
// 不阻止主流程
}
// 获取相关文章(同分类下的其他文章)
if (article.value.categoryId) {
try {
const relatedRes = await articleService.createArticle(article.value.categoryId)
// 过滤掉当前文章并取前5篇作为相关文章
relatedArticles.value = relatedRes.data
? relatedRes.data.filter((item: Article) => item.id !== article.value?.id).slice(0, 5)
: []
} catch (err) {
console.error('获取相关文章失败:', err)
// 不阻止主流程
}
}
} else {
throw new Error('文章不存在或已被删除')
}
console.log('获取文章详情成功:', article.value)
} catch (err) {
error.value = err instanceof Error ? err.message : '获取文章详情失败,请稍后重试'
console.error('获取文章详情失败:', err)
ElMessage.error(error.value)
} finally {
loading.value = false
console.log('文章详情加载完成')
}
}
/**
* 返回上一页
*/
const goBack = () => {
router.back()
}
/**
* 处理相关文章点击
*/
const handleRelatedArticleClick = (id: number) => {
router.push({
path: '/article/:url',
query: { url: id }
})
}
/**
* 组件挂载时获取文章详情
*/
onMounted(() => {
fetchArticleDetail()
})
</script>
<style>
</style>
<style scoped>
#article-container {
min-height: 100vh;
background-color: #f5f7fa;
padding: 40px 0;
}
.article-wrapper {
max-width: 900px;
margin: 0 auto;
background-color: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 文章头部 */
.article-header {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 2px solid #ecf0f1;
}
.article-title {
font-size: 2rem;
color: #2c3e50;
line-height: 1.4;
margin-bottom: 20px;
font-weight: 600;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 20px;
color: #7f8c8d;
font-size: 0.95rem;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
}
/* 文章内容 */
.article-content {
font-size: 1.1rem;
line-height: 1.8;
color: #333;
margin-bottom: 32px;
}
.article-content p {
margin-bottom: 16px;
}
.article-content h2 {
font-size: 1.6rem;
margin: 32px 0 16px 0;
color: #2c3e50;
font-weight: 600;
}
.article-content h3 {
font-size: 1.4rem;
margin: 24px 0 16px 0;
color: #2c3e50;
font-weight: 600;
}
.article-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 16px 0;
}
.article-content blockquote {
border-left: 4px solid #3498db;
padding-left: 16px;
color: #7f8c8d;
margin: 16px 0;
}
/* 文章底部 */
.article-footer {
padding-top: 24px;
border-top: 1px solid #ecf0f1;
margin-bottom: 32px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.article-actions {
display: flex;
justify-content: flex-start;
}
/* 相关文章 */
.related-articles {
padding-top: 32px;
border-top: 2px solid #ecf0f1;
}
.related-articles h3 {
font-size: 1.3rem;
color: #2c3e50;
margin-bottom: 16px;
}
.related-articles-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.related-article-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background-color: #f8f9fa;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
}
.related-article-item:hover {
background-color: #e9ecef;
transform: translateX(5px);
}
.related-article-item i {
color: #3498db;
}
.related-article-item span {
font-size: 1rem;
color: #495057;
}
/* 错误和空状态 */
.error-container {
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.empty-container {
max-width: 600px;
margin: 0 auto;
}
/* 响应式设计 */
@media (max-width: 768px) {
.article-wrapper {
padding: 20px;
}
.article-title {
font-size: 1.6rem;
}
.article-meta {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
/* 文章内容 */
.article-content {
font-size: 1.05rem;
line-height: 1.8;
color: #34495e;
margin-bottom: 32px;
}
.article-content p {
margin-bottom: 16px;
}
.article-content h2 {
color: #2c3e50;
font-size: 1.5rem;
margin: 32px 0 16px 0;
padding-bottom: 8px;
border-bottom: 1px solid #ecf0f1;
}
.article-content h3 {
color: #34495e;
font-size: 1.3rem;
margin: 24px 0 12px 0;
}
.article-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 20px 0;
display: block;
}
/* 文章底部 */
.article-footer {
padding-top: 24px;
border-top: 2px solid #ecf0f1;
}
.tag-list {
margin-bottom: 24px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.article-actions {
display: flex;
justify-content: flex-start;
}
/* 加载状态 */
.loading-container {
max-width: 900px;
margin: 0 auto;
padding: 40px;
background-color: white;
border-radius: 12px;
}
/* 错误状态 */
.error-container {
max-width: 900px;
margin: 0 auto;
padding: 60px 40px;
background-color: white;
border-radius: 12px;
text-align: center;
}
.error-container .el-button {
margin-top: 16px;
}
/* 空状态 */
.empty-container {
max-width: 900px;
margin: 0 auto;
padding: 80px 40px;
background-color: white;
border-radius: 12px;
text-align: center;
}
/* 响应式设计 */
@media (max-width: 768px) {
#article-container {
padding: 20px 0;
}
.article-wrapper,
.loading-container,
.error-container,
.empty-container {
padding: 20px;
margin: 0 15px;
}
.article-title {
font-size: 1.5rem;
}
.article-meta {
gap: 15px;
font-size: 0.9rem;
}
.article-content {
font-size: 1rem;
}
}
</style>

View File

@@ -1,61 +1,145 @@
<!-- 文章模板 -->
<!-- 文章列表组件 -->
<template>
<div>
<div
class="article-card"
v-for="item in datas"
:key="item.title + item.publishedAt"
@click="aericleClick(item.articleid)"
>
<h2>{{ item.title }}</h2>
<el-text class="mx-1">{{ item.author }}</el-text>
<div v-if="item.mg">mg</div>
<p>{{ item.publishedAt }}</p>
<div class="article-list-container">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :count="5" />
</div>
<!-- 文章列表 -->
<transition-group name="article-item" tag="div" v-else>
<div
class="article-card"
v-for="item in datas"
:key="item.id || (item.title + item.publishedAt)"
@click="handleArticleClick(item.articleid)"
>
<h2 class="article-title">{{ item.title }}</h2>
<div class="article-meta">
<span class="article-author">{{ item.author || '清疯不颠' }}</span>
<span class="article-date">{{ formatDateDisplay(item.publishedAt || item.createTime) }}</span>
<span v-if="item.categoryName" class="article-category">{{ item.categoryName }}</span>
<span v-if="item.views" class="article-views">{{ item.views }} 阅读</span>
<span v-if="item.commentCount" class="article-comments">{{ item.commentCount }} 评论</span>
</div>
<div v-if="item.mg" class="article-tag">mg</div>
<p class="article-preview">{{ formatContentPreview(item.content, 150) }}</p>
</div>
</transition-group>
<!-- 空状态 -->
<div v-if="!loading && datas.length === 0" class="empty-container">
<el-empty description="暂无文章" />
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { articleAPI } from '@/axios/api'
import { useRouter, useRoute } from 'vue-router'
import { ref, onMounted } from 'vue'
import { articleService } from '@/services'
import { formatDate, formatRelativeTime } from '@/utils/dateUtils'
import { formatContentPreview } from '@/utils/stringUtils'
import { ElMessage } from 'element-plus'
// 路由实例
const router = useRouter()
const route = useRoute()
// 响应式状态
const datas = ref([])
console.log("获取文章列表")
onMounted(() => {
articleAPI.getAllArticles().then(res => {
datas.value = res.data
console.log(res.data)
}).catch(err => {
console.log(err)
}).finally(() => {
console.log("finally")
})
})
// 跳转到文章详情
const aericleClick = (aur) => {
router.push({
path: '/articlecontents/:url',
query: { url: aur }
})
const loading = ref(false)
/**
* 获取文章列表
*/
const fetchArticles = async () => {
try {
loading.value = true
const res = await articleService.getAllArticles()
datas.value = res.data || []
console.log('获取文章列表成功:', datas.value)
} catch (error) {
console.error('获取文章列表失败:', error)
ElMessage.error('获取文章列表失败,请稍后重试')
} finally {
loading.value = false
console.log('文章列表加载完成')
}
}
/**
* 处理文章点击事件
* @param {Object} article - 文章对象
*/
const handleArticleClick = (article) => {
console.log('文章点击:', article)
router.push({
path: '/article/:url',
query: { url: article }
})
}
/**
* 格式化日期显示
* @param {string} dateString - 日期字符串
* @returns {string} 格式化后的日期
*/
const formatDateDisplay = (dateString) => {
if (!dateString) return ''
try {
// 如果是今天或昨天的文章,显示相对时间
const date = new Date(dateString)
const now = new Date()
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24))
if (diffDays < 2) {
return formatRelativeTime(dateString)
}
// 否则显示具体日期
return formatDate(dateString, 'YYYY-MM-DD')
} catch (error) {
console.error('日期格式化错误:', error)
return dateString
}
}
// 组件挂载时获取数据
onMounted(() => {
fetchArticles()
})
</script>
<style scoped>
.article-list-container {
max-width: 100%;
}
/* 分类筛选区域 */
/* 加载状态 */
.loading-container {
padding: 20px;
}
/* 文章卡片 */
.article-card {
border-radius: 10px;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 1rem;
background-color: rgba(255, 255, 255, 0.85);
padding: 15px;
margin-bottom: 30px;
gap: 0.8rem;
background-color: rgba(255, 255, 255, 0.9);
padding: 20px;
margin-bottom: 20px;
transition: all 0.4s ease;
position: relative;
overflow: hidden;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.article-card::before {
content: '';
position: absolute;
@@ -71,12 +155,99 @@ const aericleClick = (aur) => {
opacity: 0;
transition: opacity 0.4s ease;
}
.article-card:hover {
transform: translateY(-5px) perspective(2000px) rotateX(0);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25),
0 0 50px rgba(255, 255, 255, 0.3);
}
.article-card:hover::before {
opacity: 1;
}
/* 文章标题 */
.article-title {
font-size: 1.5rem;
font-weight: 600;
color: #2c3e50;
margin: 0;
transition: color 0.3s ease;
}
.article-card:hover .article-title {
color: #3498db;
}
/* 文章元信息 */
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 0.875rem;
color: #7f8c8d;
}
/* 文章分类 */
.article-category {
padding: 2px 8px;
background-color: rgba(52, 152, 219, 0.1);
color: #3498db;
border-radius: 4px;
font-size: 0.8rem;
}
/* 文章标签 */
.article-tag {
display: inline-block;
padding: 2px 8px;
background-color: rgba(52, 152, 219, 0.1);
color: #3498db;
border-radius: 4px;
font-size: 0.8rem;
align-self: flex-start;
}
/* 文章预览 */
.article-preview {
color: #555;
line-height: 1.6;
margin: 0;
font-size: 0.95rem;
}
/* 空状态 */
.empty-container {
text-align: center;
padding: 60px 20px;
color: #7f8c8d;
}
/* 过渡动画 */
.article-item-enter-active,
.article-item-leave-active {
transition: all 0.5s ease;
}
.article-item-enter-from,
.article-item-leave-to {
opacity: 0;
transform: translateY(20px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.article-card {
padding: 15px;
margin-bottom: 15px;
}
.article-title {
font-size: 1.25rem;
}
.article-meta {
font-size: 0.8rem;
}
}
</style>

View File

@@ -1,212 +0,0 @@
<template>
<div id="alld">
<div id="top">
<div class="top1">
<h3>小颠公告栏</h3>
</div>
<div class="top2">
<p>站主发癫中请勿靠近</p>
</div>
</div>
<div id="cont">
<div class="cont1">
<h2>小颠片刻</h2>
<p>左眼右右眼左四十五度成就美</p>
</div>
<div class="cont2">
<el-menu :default-active="activeIndex" class="el-menu-vertical-demo" @select="handleSelect">
<el-menu-item index="/:type">
<el-icon>
</el-icon>
<span>首页</span>
</el-menu-item>
<el-menu-item index="/aericle">
<el-icon>
</el-icon>
<span>文章</span>
</el-menu-item>
<el-menu-item index="/nonsense">
<el-icon>
</el-icon>
<span>疯言疯语</span>
</el-menu-item>
</el-menu>
</div>
</div>
<div id="bot" :class="{ 'botrelative': scrollY }">
<el-tabs v-model="activeName" class="demo-tabs">
<el-tab-pane label="个人简介" name="first">
<div class="mylogo">
<el-avatar :src="state" />
</div>
<p>清疯不颠</p>
<p>···重度精神失常患者···</p>
</el-tab-pane>
<el-tab-pane label="功能" name="second">
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
// 当前激活菜单
const activeIndex = ref('/:type')
const router = useRouter()
const activeName = ref('first')
const state = reactive({
circleUrl: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
squareUrl: 'https://cube.elemecdn.com/9/c2/f0ee8a3c7c9638a54940382568c9dpng.png',
sizeList: ['small', '', 'large'] as const,
})
// 处理菜单选择跳转
const handleSelect = (key: string) => {
router.push({ path: key })
}
// 路由切换时同步菜单高亮
router.beforeEach((to) => {
activeIndex.value = to.path
})
// 控制底部模块吸顶效果
const scrollY = ref(false)
const handleScroll = () => {
scrollY.value = window.scrollY > 1100
}
// 生命周期管理事件监听,防止内存泄漏
onMounted(() => {
window.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<style>
/* 整体布局外层每个子div底部间距 */
#alld div {
margin-bottom: 15px;
}
/* 顶部公告栏样式 */
#top {
height: 100px;
border-radius: 10px;
background-color: rgba(102, 161, 216, 0.9); /* 蓝色半透明背景 */
text-align: left;
padding: 15px;
}
/* 公告栏副标题字体大小 */
#top .top2 p {
font-size: 15px;
}
/* 中部内容区整体样式 */
#cont {
padding: 15px;
height: 350px;
border-radius: 10px;
padding: 0;
}
/* 内容区上半部分(标题) */
#cont .cont1 {
border-radius: 10px 10px 0 0;
padding: 15px;
text-align: center;
margin-bottom: 0;
background-color: rgba(102, 161, 216, 0.9); /* 蓝色半透明背景 */
}
/* 内容区下半部分(菜单) */
#cont .cont2 {
padding: 10px 0;
border-radius: 0 0 10px 10px;
background-color: rgba(215, 224, 218, 0.9); /* 浅绿色半透明背景 */
}
/* 菜单整体样式 */
#cont .cont2 .el-menu-vertical-demo {
display: inline;
}
/* 菜单项悬停效果 */
#cont .cont2 .el-menu-vertical-demo .el-menu-item:hover {
background-color: rgba(255, 255, 255, 0.7); /* 白色半透明 */
}
/* 菜单背景透明,去除右边框 */
.el-menu-vertical-demo {
background-color: transparent;
border-right: 0;
}
/* 去除内容区和底部区子div的底部间距 */
#cont div,
#bot div {
margin-bottom: 0;
}
/* 底部模块样式 */
#bot {
padding: 15px;
border-radius: 10px;
background-color: rgba(0, 233, 70, 0.7); /* 绿色半透明背景 */
transition: all 0.4s ease;
}
/* 底部模块吸顶效果 */
#bot.botrelative {
transition: all 0.4s ease;
position: sticky;
top: 20px;
}
/* tabs整体居中字体颜色 */
.demo-tabs {
text-align: center;
color: #6b778c;
}
/* tabs导航栏高度和下边距 */
.el-tabs__nav-scroll {
height: 45px;
margin-bottom: 5px;
}
/* tabs导航栏宽度 */
.el-tabs__nav {
width: 100%;
}
/* tabs每个item宽度一半 */
.el-tabs__item {
width: 50%;
}
/* 去除tabs导航栏底部线 */
.el-tabs__nav-wrap:after {
height: 0;
}
/* 头像容器,垂直水平居中 */
.mylogo {
height: 80px;
display: flex;
justify-content: center;
align-items: center;
}
/* 头像悬停放大效果 */
.el-avatar:hover {
transform: scale(2);
z-index: 2;
transition: all 0.4s ease;
}
</style>

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;