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,9 +1,24 @@
<template>
<Index/>
<div id="app">
<MainLayout />
</div>
</template>
<script setup>
import Index from './index.vue';
</script>
<style scoped>
<script setup lang="ts">
import MainLayout from './layouts/MainLayout.vue'
</script>
<style>
* {
margin: 0;
padding: 0;
}
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
</style>

View File

@@ -83,4 +83,6 @@ export const messageAPI = {
deleteMessage: (id) => service.delete(`/messages/${id}`)
}
export default service

View File

@@ -0,0 +1,240 @@
<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="/article-list">
<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.circleUrl" />
</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 scoped>
/* 整体布局外层每个子div底部间距 */
#alld div {
margin-bottom: 15px;
}
/* 顶部公告栏样式 */
#top {
height: 100px;
border-radius: 10px;
background-color: rgba(102, 161, 216, 0.9); /* 蓝色半透明背景 */
}
.top1 {
padding-top: 10px;
text-align: center;
color: white;
}
.top2 {
padding-top: 10px;
text-align: center;
color: white;
}
/* 内容区域样式 */
#cont {
border-radius: 10px;
background-color: rgba(255, 255, 255, 0.9); /* 白色半透明背景 */
}
.cont1 {
text-align: center;
padding: 25px;
}
.cont1 h2 {
color: #333;
margin-bottom: 10px;
}
.cont1 p {
color: #666;
font-size: 14px;
}
/* 菜单样式 */
.cont2 {
margin-top: 20px;
}
.cont2 .el-menu-vertical-demo{
display: block;
background-color: rgba(0, 0, 0,0 ); /* 白色半透明背景 */
}
.cont2 .el-menu-vertical-demo ul li:hover{
background-color: rgba(255, 255, 255, 0.9); /* 白色半透明背景 */
}
/* 分类列表样式 */
.cont3 {
margin-top: 20px;
padding: 15px;
border-radius: 10px;
background-color: rgba(255, 255, 255, 0.9);
}
.cont3 h3 {
text-align: center;
color: #333;
margin-bottom: 15px;
font-size: 16px;
}
.category-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.category-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
background-color: rgba(240, 240, 240, 0.5);
}
.category-item:hover {
background-color: rgba(52, 152, 219, 0.2);
transform: translateX(5px);
}
.category-name {
color: #333;
font-size: 14px;
}
.category-count {
color: #7f8c8d;
font-size: 12px;
background-color: rgba(127, 140, 141, 0.1);
padding: 2px 8px;
border-radius: 12px;
}
/* 底部标签页样式 */
#bot {
border-radius: 10px;
background-color: rgba(255, 255, 255, 0.9); /* 白色半透明背景 */
padding: 15px;
}
.demo-tabs .el-tabs__header .el-tabs__nav-wrap .el-tabs__nav-scroll{
text-align: center;
width: 100%;
}
/* 头像样式 */
.mylogo {
text-align: center;
margin-bottom: 10px;
}
.el-avatar {
width: 80px;
height: 80px;
}
/* 吸顶效果 */
.botrelative {
position: sticky;
top: 20px;
z-index: 100;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -54,7 +54,7 @@
<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import leftmodlue from '@/views/leftmodlue.vue';
// import leftmodlue from '@/views/leftmodlue.vue';
// hero 区域样式控制
const classhero = ref(false);

206
src/layouts/MainLayout.vue Normal file
View File

@@ -0,0 +1,206 @@
<template>
<div class="elrow-top" :class="elrowtop">
<el-row justify="center">
<el-col :span="4" v-if="windowwidth">
<div class="grid-content ep-bg-purple-dark">
<div class="logo-text">清疯不颠</div>
</div>
</el-col>
<el-col :span="14" justify="center">
<div class="grid-content ep-bg-purple-dark">
<el-menu :default-active="activeIndex" class="el-menu-demo" :collapse="false" @select="handleSelect">
<el-menu-item index="/:type">
首页
</el-menu-item>
<el-menu-item index="/article-list">
目录
</el-menu-item>
<el-menu-item index="/nonsense">
疯言疯语
</el-menu-item>
<el-menu-item index="/about">
关于
</el-menu-item>
<el-menu-item index="/message">
留言板
</el-menu-item>
</el-menu>
</div>
</el-col>
<el-col :span="2" class="search-container" v-if="windowwidth">
<!-- 搜索框可以在这里添加 -->
</el-col>
</el-row>
</div>
<!-- Hero 区域 -->
<div class="hero" :class="{ 'newhero': classhero }" v-if="windowwidth">
<h1 class="typewriter">{{ heroText }}</h1>
</div>
<!-- 主内容区域 -->
<div id="content-section" :class="{ 'visible': isconts }">
<div class="nonsensetitle" v-if="classnonsenset">
<div class="nonsensetitleconst">
<h1>发癫中QAQ</h1>
</div>
</div>
<!-- 左侧模块 -->
<LeftModule class="leftmodluepage" :class="{ 'nonsensetmargintop': classnonsenset }" v-if="windowwidth" />
<!-- 内容模块 -->
<RouterView class="RouterViewpage" :class="{ 'nonsensetmargintop': classnonsenset }" />
</div>
<!-- 分页区域 -->
<div class="Pagination">
<!-- 分页组件可以在这里添加 -->
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import LeftModule from '@/components/LeftModule.vue';
// 路由相关
const router = useRouter();
const route = useRoute();
// 响应式状态
const classhero = ref(false);
const isconts = ref(false);
const isScrollingleftmodlue = ref(false);
const elrowtop = ref('transparent');
const classnonsenset = ref(false);
const windowwidth = ref(true);
const activeIndex = ref('/:type');
// 打字机效果相关
const fullHeroText = '如果感到累了撸一管就好了';
const heroText = ref('');
let heroIndex = 0;
let heroTimer: number | undefined;
/**
* 打字机效果函数
*/
const startTypewriter = () => {
heroText.value = '';
heroIndex = 0;
if (heroTimer) clearInterval(heroTimer);
heroTimer = window.setInterval(() => {
if (heroIndex < fullHeroText.length) {
heroText.value += fullHeroText[heroIndex];
heroIndex++;
} else {
clearInterval(heroTimer);
}
}, 100);
};
/**
* 菜单选择跳转
*/
const handleSelect = (key: string) => {
router.push({ path: key });
};
/**
* 根据路由路径设置页面状态
*/
const updatePageState = (url: string) => {
classhero.value = url !== '/:type';
classnonsenset.value = url === '/nonsense';
};
/**
* 设置当前激活的菜单项
*/
const setActiveIndex = (path: string) => {
activeIndex.value = path;
};
/**
* 处理窗口大小变化
*/
const handleResize = () => {
windowwidth.value = window.innerWidth > 768;
// 根据屏幕大小调整内容区可见性
if (route.path === '/:type') {
isconts.value = window.innerWidth <= 768 ? true : false;
}
};
/**
* 处理滚动事件
*/
const handleScroll = () => {
// 屏幕小于768时只切换导航栏样式不做内容动画
if (window.innerWidth < 768) {
elrowtop.value = window.scrollY > 100 ? 'solid' : 'transparent';
return;
}
// 导航栏样式切换
if (window.scrollY > 1200) {
elrowtop.value = 'hide';
} else {
elrowtop.value = window.scrollY > 100 ? 'solid' : 'transparent';
}
// 首页内容区滚动动画
if (route.path === '/:type') {
isconts.value = window.scrollY > 200;
isScrollingleftmodlue.value = window.scrollY > 600;
}
};
/**
* 监听路由变化
*/
watch(() => route.path, (newPath) => {
updatePageState(newPath);
setActiveIndex(newPath);
// 跳转后回到顶部
window.scrollTo({ top: 0, behavior: 'smooth' });
// 首页内容区滚动动画仅大屏下生效
if (newPath === '/:type') {
isconts.value = window.innerWidth <= 768 ? true : false;
// 首页时启动打字机
startTypewriter();
} else {
isconts.value = true;
heroText.value = '';
if (heroTimer) clearInterval(heroTimer);
}
}, { immediate: true });
/**
* 生命周期钩子
*/
onMounted(() => {
// 初始化窗口大小
handleResize();
// 添加事件监听器
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleScroll);
});
onUnmounted(() => {
// 清理事件监听器
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleScroll);
// 清理定时器
if (heroTimer) clearInterval(heroTimer);
});
</script>
<style scoped>
</style>

View File

@@ -1,7 +1,11 @@
import { createApp } from 'vue'
import App from './App.vue'
import Router from './router/Router'
import './assets/index.css'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './styles/MainLayout.css'
const app = createApp(App)
app.use(Router)
app.use(ElementPlus)
app.mount('#app')

View File

@@ -1,54 +1,93 @@
import { createWebHistory, createRouter } from 'vue-router'
import Aericle from '../views/aericle.vue'
import home from '../views/home.vue'
import nonsense from '../views/nonsense.vue'
import messageboard from '../views/messageboard.vue'
import about from '../views/aboutme.vue'
import articlecontents from '../views/articlecontents.vue'
// 导入视图组件
import ArticleList from '../views/aericle.vue'
import HomePage from '../views/home.vue'
import NonsensePage from '../views/nonsense.vue'
import MessageBoardPage from '../views/messageboard.vue'
import AboutMePage from '../views/aboutme.vue'
import ArticleContentPage from '../views/articlecontents.vue'
/**
* 路由配置数组
* 定义了应用的所有路由路径和对应的组件
*/
const routes = [
{
path: '/',
redirect: '/:type' // 默认跳转到首页
redirect: '/all' // 默认跳转到首页,显示所有文章
},
{
path: '/:type',
// 如果type为空则是所有不为空查询相对应属性的文章
name: '/home',
component: home
name: 'home',
component: HomePage,
meta: {
title: '首页'
}
},
{
path: '/aericle',
name: 'Aericle',
component: Aericle
path: '/article-list',
name: 'articleList',
component: ArticleList,
meta: {
title: '文章目录'
}
},
{
path: '/nonsense',
name: 'nonsense',
component: nonsense
component: NonsensePage,
meta: {
title: '随笔'
}
},
{
path: '/message',
name: 'messageboard',
component: messageboard
name: 'messageBoard',
component: MessageBoardPage,
meta: {
title: '留言板'
}
},
{
path: '/about',
name: 'about',
component: about
name: 'aboutMe',
component: AboutMePage,
meta: {
title: '关于我'
}
},
{
path: '/articlecontents/:url',
name: 'articlecontents',
component: articlecontents
path: '/article/:url',
name: 'articleContent',
component: ArticleContentPage,
meta: {
title: '文章详情'
}
}
]
/**
* 创建路由实例
*/
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() {
// 路由切换时滚动到页面顶部
return { top: 0 }
}
})
/**
* 全局路由守卫 - 处理页面标题
*/
router.beforeEach((to, from, next) => {
if (to.meta.title) {
document.title = to.meta.title + ' - 个人博客'
} else {
document.title = '个人博客'
}
next()
})
export default router;

View File

@@ -0,0 +1,46 @@
// 基础 API 服务配置
import axios from 'axios'
// 创建 axios 实例
const apiService = axios.create({
baseURL: 'http://localhost:8080/api', // api的base_url
timeout: 10000, // 请求超时时间
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器 - 添加认证token
apiService.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器 - 统一处理响应
apiService.interceptors.response.use(
response => {
// 检查响应是否成功
if (response.data && response.data.success) {
return response.data
} else {
// 处理业务错误
return Promise.reject(new Error(response.data?.message || '请求失败'))
}
},
error => {
// 处理HTTP错误
console.error('API请求错误:', error)
// 可以在这里添加全局错误处理,如显示错误提示
return Promise.reject(error)
}
)
export default apiService

View File

@@ -0,0 +1,75 @@
// 文章相关API服务
import apiService from './apiService'
/**
* 文章服务类
*/
class ArticleService {
/**
* 获取所有文章
* @param {Object} params - 查询参数
* @returns {Promise}
*/
getAllArticles(params = {}) {
return apiService.get('/articles', { params })
}
/**
* 获取单篇文章
* @param {number} id - 文章ID
* @returns {Promise}
*/
getArticleById(id) {
return apiService.get(`/articles/${id}`)
}
/**
* 获取热门文章
* @returns {Promise}
*/
getPopularArticles() {
return apiService.get('/articles/popular')
}
/**
* 创建文章
* @param {Object} articleData - 文章数据
* @returns {Promise}
*/
createArticle(articleData) {
return apiService.post('/articles', articleData)
}
/**
* 更新文章
* @param {number} id - 文章ID
* @param {Object} articleData - 文章数据
* @returns {Promise}
*/
updateArticle(id, articleData) {
return apiService.put(`/articles/${id}`, articleData)
}
/**
* 删除文章
* @param {number} id - 文章ID
* @returns {Promise}
*/
deleteArticle(id) {
return apiService.delete(`/articles/${id}`)
}
/**
* 增加文章浏览量
* @param {number} id - 文章ID
* @returns {Promise}
*/
incrementArticleViews(id) {
return apiService.post(`/articles/${id}/views`)
}
}
// 导出文章服务实例
export default new ArticleService()

8
src/services/index.js Normal file
View File

@@ -0,0 +1,8 @@
// 导出所有服务
import articleService from './articleService'
import messageService from './messageService'
export {
articleService,
messageService
}

View File

@@ -0,0 +1,89 @@
// 留言相关API服务
import apiService from './apiService'
/**
* 留言服务类
*/
class MessageService {
/**
* 获取所有留言
* @returns {Promise}
*/
getAllMessages() {
return apiService.get('/messages')
}
/**
* 获取单条留言
* @param {number} id - 留言ID
* @returns {Promise}
*/
getMessageById(id) {
return apiService.get(`/messages/${id}`)
}
/**
* 根据文章ID获取留言
* @param {number} articleId - 文章ID
* @returns {Promise}
*/
getMessagesByArticleId(articleId) {
return apiService.get(`/messages/article/${articleId}`)
}
/**
* 获取根留言
* @returns {Promise}
*/
getRootMessages() {
return apiService.get('/messages/root')
}
/**
* 根据父留言ID获取回复
* @param {number} parentId - 父留言ID
* @returns {Promise}
*/
getRepliesByParentId(parentId) {
return apiService.get(`/messages/parent/${parentId}`)
}
/**
* 根据昵称搜索留言
* @param {string} nickname - 昵称
* @returns {Promise}
*/
searchMessagesByNickname(nickname) {
return apiService.get(`/messages/search?nickname=${nickname}`)
}
/**
* 获取文章评论数量
* @param {number} articleId - 文章ID
* @returns {Promise}
*/
getMessageCountByArticleId(articleId) {
return apiService.get(`/messages/count/${articleId}`)
}
/**
* 创建留言
* @param {Object} messageData - 留言数据
* @returns {Promise}
*/
saveMessage(messageData) {
return apiService.post('/messages', messageData)
}
/**
* 删除留言
* @param {number} id - 留言ID
* @returns {Promise}
*/
deleteMessage(id) {
return apiService.delete(`/messages/${id}`)
}
}
// 导出留言服务实例
export default new MessageService()

View File

@@ -122,6 +122,23 @@ p {
align-items: center;
}
/* Logo文本样式 - 清疯不颠 */
.logo-text {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
font-family: 'Microsoft YaHei', 'Ma Shan Zheng', cursive;
font-size: 1rem;
font-weight: bold;
background: linear-gradient(94.75deg, rgb(60, 172, 247) 0%, rgb(131, 101, 253) 43.66%, rgb(255, 141, 112) 64.23%, rgb(247, 201, 102) 83.76%, rgb(172, 143, 100) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
/* 导航栏透明状态 */
.elrow-top.transparent {
background-color: var(--nav-bg-transparent);
@@ -143,8 +160,10 @@ p {
}
.grid-content.ep-bg-purple-dark {
display: flex;
align-items: center;
height: 60px;
width: 100%;
}
/* 菜单样式 */
@@ -343,6 +362,7 @@ p {
/* 水平居中 */
align-items: center;
/* 垂直居中 */
}
.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-menu-item,

79
src/types/index.ts Normal file
View File

@@ -0,0 +1,79 @@
// 项目中使用的类型定义
/**
* 文章类型接口
*/
export interface Article {
id: number
title: string
content: string
author: string
createTime: string
updateTime: string
categoryId: number
categoryName?: string
tags?: string
views?: number
commentCount?: number
articleid?: string
publishedAt?: string
mg?: string
}
/**
* 留言类型接口
*/
export interface Message {
id: number
content: string
nickname: string
email: string
articleId?: number
parentId?: number
createdAt: string
replies?: Message[]
time?: string
}
/**
* 分类类型接口
*/
export interface Category {
id: number
name: string
description?: string
articleCount?: number
}
/**
* API响应接口
*/
export interface ApiResponse<T = any> {
success: boolean
code: number
message?: string
data?: T
total?: number
}
/**
* 分页参数接口
*/
export interface PaginationParams {
page?: number
size?: number
keyword?: string
[key: string]: any
}
/**
* 用户信息接口
*/
export interface User {
id?: number
username?: string
email?: string
avatar?: string
role?: string
token?: string
}

90
src/utils/dateUtils.js Normal file
View File

@@ -0,0 +1,90 @@
// 日期格式化工具函数
/**
* 格式化日期为指定格式
* @param {string|Date} date - 日期对象或日期字符串
* @param {string} format - 格式化模板,如 'YYYY-MM-DD HH:mm:ss'
* @returns {string} 格式化后的日期字符串
*/
export const formatDate = (date, format = 'YYYY-MM-DD HH:mm:ss') => {
if (!date) return ''
// 如果是字符串,转换为日期对象
const dateObj = typeof date === 'string' ? new Date(date) : date
// 检查日期对象是否有效
if (isNaN(dateObj.getTime())) return ''
const year = dateObj.getFullYear()
const month = String(dateObj.getMonth() + 1).padStart(2, '0')
const day = String(dateObj.getDate()).padStart(2, '0')
const hours = String(dateObj.getHours()).padStart(2, '0')
const minutes = String(dateObj.getMinutes()).padStart(2, '0')
const seconds = String(dateObj.getSeconds()).padStart(2, '0')
// 替换模板中的日期部分
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
/**
* 计算时间差,返回相对时间
* @param {string|Date} date - 日期对象或日期字符串
* @returns {string} 相对时间字符串
*/
export const formatRelativeTime = (date) => {
if (!date) return ''
const dateObj = typeof date === 'string' ? new Date(date) : date
const now = new Date()
const diff = now - dateObj
// 转换为秒
const seconds = Math.floor(diff / 1000)
if (seconds < 60) {
return `${seconds}秒前`
}
// 转换为分钟
const minutes = Math.floor(seconds / 60)
if (minutes < 60) {
return `${minutes}分钟前`
}
// 转换为小时
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}小时前`
}
// 转换为天
const days = Math.floor(hours / 24)
if (days < 30) {
return `${days}天前`
}
// 转换为月
const months = Math.floor(days / 30)
if (months < 12) {
return `${months}个月前`
}
// 转换为年
const years = Math.floor(months / 12)
return `${years}年前`
}
/**
* 获取当前日期
* @param {string} format - 格式化模板
* @returns {string} 当前日期字符串
*/
export const getCurrentDate = (format = 'YYYY-MM-DD HH:mm:ss') => {
return formatDate(new Date(), format)
}

75
src/utils/stringUtils.js Normal file
View File

@@ -0,0 +1,75 @@
// 字符串处理工具函数
/**
* 截断字符串并添加省略号
* @param {string} str - 原始字符串
* @param {number} length - 保留的长度
* @returns {string} 截断后的字符串
*/
export const truncateString = (str, length) => {
if (!str || typeof str !== 'string' || str.length <= length) {
return str
}
return str.substring(0, length) + '...'
}
/**
* 移除HTML标签
* @param {string} html - 包含HTML标签的字符串
* @returns {string} 纯文本字符串
*/
export const stripHtmlTags = (html) => {
if (!html || typeof html !== 'string') {
return html
}
return html.replace(/<[^>]*>/g, '')
}
/**
* 格式化内容预览
* @param {string} content - 原始内容
* @param {number} maxLength - 最大长度
* @returns {string} 格式化后的预览内容
*/
export const formatContentPreview = (content, maxLength = 200) => {
if (!content) return ''
// 先移除HTML标签
const plainText = stripHtmlTags(content)
// 再截断字符串
return truncateString(plainText, maxLength)
}
/**
* 转义HTML特殊字符
* @param {string} str - 原始字符串
* @returns {string} 转义后的字符串
*/
export const escapeHtml = (str) => {
if (!str || typeof str !== 'string') {
return str
}
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}
return str.replace(/[&<>"']/g, (m) => map[m])
}
/**
* 解析URL查询参数
* @param {string} url - URL字符串
* @returns {Object} 查询参数对象
*/
export const parseQueryParams = (url) => {
const queryString = url.split('?')[1]
if (!queryString) return {}
return queryString.split('&').reduce((params, param) => {
const [key, value] = param.split('=')
params[decodeURIComponent(key)] = decodeURIComponent(value || '')
return params
}, {})
}

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;