feat: 重构前端项目结构并添加新功能
重构项目目录结构,将组件和服务模块化 添加Element Plus UI库并集成到项目中 实现文章、留言和分类的类型定义 新增工具函数模块包括日期格式化和字符串处理 重写路由配置并添加全局路由守卫 优化页面布局和响应式设计 新增服务层封装API请求 完善文章详情页和相关文章推荐功能
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"include": ["src/**/*"],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
|
||||
25
src/App.vue
25
src/App.vue
@@ -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>
|
||||
|
||||
@@ -83,4 +83,6 @@ export const messageAPI = {
|
||||
deleteMessage: (id) => service.delete(`/messages/${id}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default service
|
||||
240
src/components/LeftModule.vue
Normal file
240
src/components/LeftModule.vue
Normal 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>
|
||||
@@ -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
206
src/layouts/MainLayout.vue
Normal 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>
|
||||
@@ -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')
|
||||
@@ -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;
|
||||
46
src/services/apiService.js
Normal file
46
src/services/apiService.js
Normal 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
|
||||
75
src/services/articleService.js
Normal file
75
src/services/articleService.js
Normal 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
8
src/services/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// 导出所有服务
|
||||
import articleService from './articleService'
|
||||
import messageService from './messageService'
|
||||
|
||||
export {
|
||||
articleService,
|
||||
messageService
|
||||
}
|
||||
89
src/services/messageService.js
Normal file
89
src/services/messageService.js
Normal 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()
|
||||
@@ -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
79
src/types/index.ts
Normal 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
90
src/utils/dateUtils.js
Normal 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
75
src/utils/stringUtils.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
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
|
||||
}, {})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user