Compare commits

..

2 Commits

Author SHA1 Message Date
qingfeng1121
266310dea3 feat: 实现文章搜索功能并优化留言系统
- 添加文章标题搜索功能,支持通过路由参数搜索
- 重构留言板组件,优化留言嵌套结构和交互
- 新增评论演示页面展示嵌套留言功能
- 调整主布局样式和导航菜单路由
- 修复留言板样式问题和数据字段不一致问题
2025-10-16 16:11:58 +08:00
qingfeng1121
ed09611d02 refactor: 重构布局和组件结构
- 删除冗余的index.vue文件,将其功能整合到MainLayout.vue
- 修改LeftModule.vue中的菜单项文字从"文章"改为"目录"
- 更新打字机效果的默认文本为测试内容
- 优化jsconfig.json格式
2025-10-13 15:14:14 +08:00
11 changed files with 504 additions and 245 deletions

View File

@@ -1,4 +1,5 @@
{
"include": ["src/**/*"],
"compilerOptions": {
"paths": {

View File

@@ -23,7 +23,7 @@
<el-menu-item index="/article-list">
<el-icon>
</el-icon>
<span>文章</span>
<span>目录</span>
</el-menu-item>
<el-menu-item index="/nonsense">
<el-icon>
@@ -145,7 +145,8 @@ onUnmounted(() => {
background-color: rgba(0, 0, 0,0 ); /* 白色半透明背景 */
}
.cont2 .el-menu-vertical-demo ul li:hover{
background-color: rgba(255, 255, 255, 0.9); /* 色半透明背景 */
background-color: rgba(220, 53, 69, 0.9); /* 色半透明背景 */
color: white;
}
/* 分类列表样式 */

View File

@@ -1,210 +0,0 @@
<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>清疯不颠</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="/aericle">
文章
</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>
<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>
<!-- 状态模块 -->
<leftmodlue 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 } from 'vue';
import { useRouter } from 'vue-router';
// import leftmodlue from '@/views/leftmodlue.vue';
// hero 区域样式控制
const classhero = ref(false);
// 内容区可见性
const isconts = ref(false);
// 左侧模块滚动状态
const isScrollingleftmodlue = ref(false);
// 导航栏样式类名
const elrowtop = ref('transparent');
// 疯言疯语标题区显示
const classnonsenset = ref(false);
// 屏幕宽度标记true为大屏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 router = useRouter();
/**
* 菜单选择跳转
*/
const handleSelect = (key: string) => {
router.push({ path: key });
};
/**
* 根据路由路径设置页面状态
*/
const updatePageState = (url: string) => {
classhero.value = url !== '/:type';
classnonsenset.value = url == '/nonsense';
};
/**
* 路由切换时处理页面状态和滚动
*/
router.beforeEach((to) => {
updatePageState(to.path);
setActiveIndex(to.path);
// 跳转后回到顶部
window.scrollTo({ top: 0, behavior: 'smooth' });
// 首页内容区滚动动画仅大屏下生效
if (to.path === '/:type') {
isconts.value = window.innerWidth <= 768 ? true : false;
// 首页时启动打字机
startTypewriter();
} else {
isconts.value = true;
heroText.value = '';
if (heroTimer) clearInterval(heroTimer);
}
});
/**
* 滚动事件处理
*/
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 (location.pathname === '/:type') {
isconts.value = window.scrollY > 200;
isScrollingleftmodlue.value = window.scrollY > 600;
} else {
isconts.value = true;
isScrollingleftmodlue.value = true;
}
};
/**
* 添加滚动监听
*/
const addScrollListener = () => {
window.addEventListener('scroll', handleScroll);
};
/**
* 移除滚动监听
*/
const removeScrollListener = () => {
window.removeEventListener('scroll', handleScroll);
};
/**
* 屏幕尺寸变化处理
* 小屏时移除滚动监听并设置相关状态
* 大屏时添加滚动监听
*/
const handleResize = () => {
if (window.innerWidth < 768) {
windowwidth.value = false;
isScrollingleftmodlue.value = true;
classnonsenset.value = false;
isconts.value = true;
} else {
windowwidth.value = true;
}
};
/**
* 设置当前激活菜单
*/
const setActiveIndex = (locationpathname) => {
activeIndex.value = locationpathname;
};
// 生命周期钩子
onMounted(() => {
handleResize(); addScrollListener();
window.addEventListener('resize', handleResize);
// 初始进入时如果是首页,启动打字机
if (window.location.pathname === '/:type') {
startTypewriter();
}
});
onUnmounted(() => {
// removeScrollListener();
window.removeEventListener('resize', handleResize);
if (heroTimer) clearInterval(heroTimer);
});
</script>
<style></style>

View File

@@ -9,7 +9,7 @@
<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 index="/home">
首页
</el-menu-item>
<el-menu-item index="/article-list">
@@ -28,7 +28,21 @@
</div>
</el-col>
<el-col :span="2" class="search-container" v-if="windowwidth">
<!-- 搜索框可以在这里添加 -->
<!-- 搜索功能 -->
<div class="search-wrapper">
<button class="search-icon-btn" @click="toggleSearchBox" :class="{ 'active': isSearchBoxOpen }">
<i class="el-icon-search">1</i>
</button>
<div class="search-box-container" :class="{ 'open': isSearchBoxOpen }">
<el-input
v-model="searchKeyword"
placeholder="搜索文章..."
class="search-input"
@keyup.enter="performSearch"
@blur="closeSearchBoxWithDelay"
/>
</div>
</div>
</el-col>
</el-row>
</div>
@@ -75,10 +89,17 @@ const isScrollingleftmodlue = ref(false);
const elrowtop = ref('transparent');
const classnonsenset = ref(false);
const windowwidth = ref(true);
const activeIndex = ref('/:type');
const activeIndex = ref('/home');
const localhome= '/home';
// 搜索相关状态
const isSearchBoxOpen = ref(false);
const searchKeyword = ref('');
let searchCloseTimer: number | undefined;
// 打字机效果相关
const fullHeroText = '如果感到累了撸一管就好了';
const fullHeroText = '测试打字机效果';
const heroText = ref('');
let heroIndex = 0;
let heroTimer: number | undefined;
@@ -107,11 +128,59 @@ const handleSelect = (key: string) => {
router.push({ path: key });
};
/**
* 切换搜索框显示/隐藏
*/
const toggleSearchBox = () => {
isSearchBoxOpen.value = !isSearchBoxOpen.value;
// 如果打开搜索框,清除之前的延时关闭定时器
if (isSearchBoxOpen.value && searchCloseTimer) {
clearTimeout(searchCloseTimer);
}
};
/**
* 关闭搜索框
*/
const closeSearchBox = () => {
isSearchBoxOpen.value = false;
};
/**
* 带延迟关闭搜索框(处理点击搜索按钮后的情况)
*/
const closeSearchBoxWithDelay = () => {
if (searchCloseTimer) {
clearTimeout(searchCloseTimer);
}
searchCloseTimer = window.setTimeout(() => {
isSearchBoxOpen.value = false;
}, 300);
};
/**
* 执行搜索操作
*/
const performSearch = () => {
if (searchKeyword.value.trim()) {
// 这里可以根据实际需求实现搜索逻辑
console.log('搜索关键词:', searchKeyword.value);
router.push({ path: `/home/aericletitle/${searchKeyword.value}`});
}
// 搜索后保持搜索框打开状态
if (searchCloseTimer) {
clearTimeout(searchCloseTimer);
}
};
/**
* 根据路由路径设置页面状态
*/
const updatePageState = (url: string) => {
classhero.value = url !== '/:type';
classhero.value = url !== localhome;
classnonsenset.value = url === '/nonsense';
};
@@ -129,7 +198,7 @@ const handleResize = () => {
windowwidth.value = window.innerWidth > 768;
// 根据屏幕大小调整内容区可见性
if (route.path === '/:type') {
if (route.path === localhome) {
isconts.value = window.innerWidth <= 768 ? true : false;
}
};
@@ -152,7 +221,7 @@ const handleScroll = () => {
}
// 首页内容区滚动动画
if (route.path === '/:type') {
if (route.path === localhome) {
isconts.value = window.scrollY > 200;
isScrollingleftmodlue.value = window.scrollY > 600;
}
@@ -169,13 +238,13 @@ watch(() => route.path, (newPath) => {
window.scrollTo({ top: 0, behavior: 'smooth' });
// 首页内容区滚动动画仅大屏下生效
if (newPath === '/:type') {
if (newPath === localhome) {
isconts.value = window.innerWidth <= 768 ? true : false;
// 首页时启动打字机
startTypewriter();
} else {
isconts.value = true;
heroText.value = '';
heroText.value =fullHeroText;
if (heroTimer) clearInterval(heroTimer);
}
}, { immediate: true });
@@ -185,6 +254,8 @@ watch(() => route.path, (newPath) => {
*/
onMounted(() => {
// 初始化窗口大小
handleResize();
// 添加事件监听器
@@ -203,4 +274,80 @@ onUnmounted(() => {
</script>
<style scoped>
/* 搜索框样式 */
.search-wrapper {
position: relative;
display: flex;
align-items: center;
height: 100%;
}
.search-icon-btn {
background: none;
border: none;
color: #fff;
font-size: 18px;
cursor: pointer;
/* padding: 8px; */
border-radius: 4px;
transition: all 0.3s ease;
}
.search-icon-btn:hover,
.search-icon-btn.active {
background-color: rgba(255, 255, 255, 0.1);
}
.search-box-container {
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
background-color: #fff;
border-radius: 4px;
/* box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); */
overflow: hidden;
width: 0;
opacity: 0;
transition: all 0.3s ease;
z-index: 1000;
}
.search-box-container.open {
width: 100%;
opacity: 1;
}
.search-input {
border: none;
outline: none;
width: 100%;
height: 36px;
/* padding: 0 8px; */
border: none;
}
.search-btn,
.close-btn {
background: none;
border: none;
padding: 8px;
cursor: pointer;
color: #606266;
transition: color 0.3s ease;
}
.search-btn:hover,
.close-btn:hover {
color: #409eff;
}
/* 防止搜索框在小屏幕上重叠 */
@media screen and (max-width: 1200px) {
.search-box-container.open {
width: 250px;
}
}
</style>

View File

@@ -6,6 +6,7 @@ import NonsensePage from '../views/nonsense.vue'
import MessageBoardPage from '../views/messageboard.vue'
import AboutMePage from '../views/aboutme.vue'
import ArticleContentPage from '../views/articlecontents.vue'
import CommentDemoPage from '../views/commentDemo.vue'
/**
* 路由配置数组
@@ -14,16 +15,56 @@ import ArticleContentPage from '../views/articlecontents.vue'
const routes = [
{
path: '/',
redirect: '/all' // 默认跳转到首页,显示所有文章
redirect: '/home' // 默认跳转到首页,显示所有文章
},
{
path: '/:type',
name: 'home',
component: HomePage,
path: '/comment-demo',
name: 'commentDemo',
component: CommentDemoPage,
meta: {
title: '首页'
title: '嵌套留言Demo'
}
},
{
path: '/home',
name: 'home',
component: HomePage,
meta: { title: '首页' },
children: [
{
path: 'aericletype/:type',
name: 'homeByType'
},
{
path: 'aericletitle/:title',
name: 'homeByTitle'
}
]
},
// {
// path: '/home',
// name: 'home',
// component: HomePage,
// meta: {
// title: '首页'
// }
// },
// {
// path: '/home/aericletype/:type',
// name: 'homeByType',
// component: HomePage,
// meta: {
// title: '首页'
// }
// },
// {
// path: '/home/aericletitle/:title',
// name: 'homeByTitle',
// component: HomePage,
// meta: {
// title: '首页'
// }
// },
{
path: '/article-list',
name: 'articleList',

View File

@@ -23,6 +23,14 @@ class ArticleService {
return apiService.get(`/articles/${id}`)
}
/**
* 根据标题查询文章列表
* @param {string} title - 文章标题
* @returns {Promise}
*/
getArticlesByTitle(title) {
return apiService.get(`/articles/title/${title}`)
}
/**

View File

@@ -1,6 +1,6 @@
:root {
/* 页面通用间距和圆角 */
--main-padding: 100px 10%;
--main-padding: 8px 10%;
/* 内容区内边距 */
--main-radius: 10px;
/* 内容区圆角 */
@@ -258,6 +258,7 @@ p {
/* hero 收缩状态 */
.hero.newhero {
height: var(--hero-height-small);
margin-top: 10%;
}
/* 打字机效果 */

View File

@@ -90,7 +90,7 @@ const fetchCategories = async () => {
*/
const handleCategoryClick = (typeid: string) => {
router.push({
path: '/:type',
path: '/home/aericletype',
query: {
type: typeid
}

262
src/views/commentDemo.vue Normal file
View File

@@ -0,0 +1,262 @@
<template>
<div class="comment-demo-container">
<h1>嵌套留言Demo</h1>
<!-- 留言列表 -->
<div class="comments-wrapper">
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="commentTree.length === 0" class="empty">暂无留言</div>
<div v-else class="comment-list">
<!-- 递归渲染留言树 -->
<CommentItem
v-for="comment in commentTree"
:key="comment.id"
:comment="comment"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, defineComponent, h } from 'vue'
// 模拟留言数据
const mockComments = [
{
id: 'a',
content: '这是主留言A',
parentId: null,
author: '用户A',
createdAt: new Date().toISOString()
},
{
id: 'b',
content: '这是回复A的留言B',
parentId: 'a',
author: '用户B',
createdAt: new Date(Date.now() + 1000 * 60 * 5).toISOString() // 5分钟后
},
{
id: 'c',
content: '这是回复B的留言C',
parentId: 'b',
author: '用户C',
createdAt: new Date(Date.now() + 1000 * 60 * 10).toISOString() // 10分钟后
},
{
id: 'd',
content: '这是另一个主留言D',
parentId: null,
author: '用户D',
createdAt: new Date(Date.now() + 1000 * 60 * 15).toISOString() // 15分钟后
},
{
id: 'e',
content: '这是回复D的留言E',
parentId: 'd',
author: '用户E',
createdAt: new Date(Date.now() + 1000 * 60 * 20).toISOString() // 20分钟后
},
{
id: 'f',
content: '这是回复A的另一条留言F',
parentId: 'a',
author: '用户F',
createdAt: new Date(Date.now() + 1000 * 60 * 25).toISOString() // 25分钟后
},
{
id: 'g',
content: '这是回复C的留言G三级嵌套',
parentId: 'c',
author: '用户G',
createdAt: new Date(Date.now() + 1000 * 60 * 30).toISOString() // 30分钟后
}
]
// 响应式状态
const loading = ref(false)
const commentTree = ref([])
// 递归构建评论树结构的函数
const buildCommentTree = (comments) => {
// 创建评论ID到评论对象的映射方便快速查找
const commentMap = {}
comments.forEach(comment => {
commentMap[comment.id] = { ...comment, children: [] }
})
// 构建树结构
const roots = []
comments.forEach(comment => {
if (!comment.parentId) {
// 没有parentId的是根节点
roots.push(commentMap[comment.id])
} else {
// 有parentId的是子节点添加到父节点的children数组中
if (commentMap[comment.parentId]) {
commentMap[comment.parentId].children.push(commentMap[comment.id])
}
}
})
return roots
}
// 获取留言数据
const fetchComments = async () => {
try {
loading.value = true
// 模拟异步请求
await new Promise(resolve => setTimeout(resolve, 500))
// 处理数据,构建树结构
commentTree.value = buildCommentTree(mockComments)
console.log('构建的留言树:', commentTree.value)
} catch (error) {
console.error('获取留言失败:', error)
} finally {
loading.value = false
}
}
// 留言项组件(递归组件)
const CommentItem = defineComponent({
name: 'CommentItem',
props: {
comment: {
type: Object,
required: true
}
},
setup(props) {
// 格式化日期
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
return () => h('div', { class: 'comment-item' }, [
h('div', { class: 'comment-header' }, [
h('span', { class: 'comment-author' }, props.comment.author),
h('span', { class: 'comment-time' }, formatDate(props.comment.createdAt))
]),
h('div', { class: 'comment-content' }, props.comment.content),
// 递归渲染子留言
props.comment.children && props.comment.children.length > 0 ?
h('div', { class: 'comment-children' },
props.comment.children.map(comment =>
h(CommentItem, { key: comment.id, comment })
)
) : null
])
}
})
// 组件挂载时获取数据
onMounted(() => {
fetchComments()
})
</script>
<style scoped>
.comment-demo-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.comments-wrapper {
background-color: #f9f9f9;
border-radius: 8px;
padding: 20px;
}
.loading, .empty {
text-align: center;
padding: 40px;
color: #666;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.comment-item {
background-color: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
}
.comment-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.comment-author {
font-weight: 600;
color: #333;
}
.comment-time {
font-size: 14px;
color: #999;
}
.comment-content {
color: #555;
line-height: 1.6;
word-break: break-word;
}
.comment-children {
margin-top: 16px;
margin-left: 30px;
padding-top: 16px;
border-top: 1px solid #eee;
display: flex;
flex-direction: column;
gap: 12px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.comment-demo-container {
padding: 10px;
}
.comments-wrapper {
padding: 15px;
}
.comment-children {
margin-left: 15px;
}
}
</style>

View File

@@ -47,17 +47,31 @@ import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
// 响应式状态
const datas = ref([])
const loading = ref(false)
/**
* 获取文章列表
*/
const fetchArticles = async () => {
try {
loading.value = true
const res = await articleService.getAllArticles()
let res = {} // 使用let而不是const
// 使用route对象获取参数而不是检查pathname
if (route.params.type ) {
console.log('根据type获取文章列表成功')
res = await articleService.getAllArticlesByType(route.params.type)
} else if (route.params.title) {
res = await articleService.getAllArticlesByTitle(route.params.title)
console.log('根据title获取文章列表成功')
} else {
res = await articleService.getAllArticles()
console.log('获取所有文章列表成功')
}
datas.value = res.data || []
console.log('获取文章列表成功:', datas.value)
} catch (error) {
@@ -65,10 +79,10 @@ const fetchArticles = async () => {
ElMessage.error('获取文章列表失败,请稍后重试')
} finally {
loading.value = false
console.log('文章列表加载完成')
}
}
/**
* 处理文章点击事件
* @param {Object} article - 文章对象

View File

@@ -67,9 +67,9 @@
<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 v-for="mainMsg in articleGroup.messages" :key="mainMsg.messageid" class="message-tree">
<!-- 主留言 -->
<div class="message-item" @mouseenter="hoverId = mainMsg.id"
<div class="message-item" @mouseenter="hoverId = mainMsg.messageid"
@mouseleave="hoverId = null">
<div class="message-avatar-container">
<img :src="getAvatar(mainMsg.email)" alt="头像" class="message-avatar" />
@@ -84,13 +84,13 @@
<!-- 回复按钮 -->
<div class="message-item-bottom">
<button class="reply-btn" @click="handleReply(mainMsg)"
:class="{ visible: hoverId === mainMsg.id }">
:class="{ visible: hoverId === mainMsg.messageid }">
回复
</button>
</div>
<!-- 回复列表 -->
<div v-if="mainMsg.replies && mainMsg.replies.length" class="replies">
<div v-for="reply in mainMsg.replies" :key="reply.id"
<div v-for="reply in mainMsg.replies" :key="reply.messageid"
class="reply-item">
<div class="message-avatar-container">
<img :src="getAvatar(reply.email)" alt="头像"
@@ -257,11 +257,11 @@ const fetchMessages = async () => {
// 处理回复
const handleReply = (msg) => {
replyingTo.value = {
id: msg.id,
id: msg.messageid,
nickname: msg.nickname || '匿名用户',
content: msg.content
}
form.replyid = msg.id
form.parentid = msg.messageid
form.content = `@${replyingTo.value.nickname} `
// 滚动到输入框
setTimeout(() => {
@@ -282,11 +282,10 @@ onMounted(() => {
})
const form = reactive({
replyid: null,
parentid: null,
content: '',
nickname: '',
email: '',
captcha: ''
})
const onSubmit = async () => {
@@ -358,7 +357,6 @@ const post_comment_reply_cancel = () => {
.message-board {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.message-list {
@@ -580,10 +578,6 @@ const post_comment_reply_cancel = () => {
}
}
.message-board {
padding: 24px 0;
}
.message-list {
margin-bottom: 24px;
background: #f8fafd;