feat(分类系统): 实现分类属性功能并重构文章列表

重构文章分类系统,新增分类属性服务及相关API接口
修改文章列表页面以支持按属性筛选文章
调整路由和样式以适配新功能
This commit is contained in:
qingfeng1121
2025-10-18 10:28:49 +08:00
parent 266310dea3
commit 02d17d7260
13 changed files with 648 additions and 433 deletions

View File

@@ -1,309 +0,0 @@
<template>
<div id="allstyle">
<div class="header">
<h1>文章目录</h1>
</div>
<!-- 加载状态 -->
<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="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 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: '/home/aericletype',
query: {
type: typeid
}
})
}
/**
* 计算分类组中的文章总数
* @param {Array} categoryItems - 分类项数组
* @returns {number} 文章总数
*/
const getCategorySum = (categoryItems: any[]): number => {
if (!categoryItems || !Array.isArray(categoryItems)) {
return 0
}
return categoryItems.reduce((total, item) => {
return total + (item.sum || 0)
}, 0)
}
/**
* 组件挂载时获取分类列表
*/
onMounted(() => {
fetchCategories()
})
</script>
<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;
}
.pcont_li {
display: flex;
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: #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;
}
/* 透明方块效果 */
.btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 8px;
z-index: -1;
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.15);
background: rgba(145, 196, 238, 0.85);
}
.category-count {
color: #7f8c8d;
font-size: 0.9rem;
}
/* 标签样式 */
.badge {
text-transform: uppercase;
}
.badge-primary {
color: #2643e9;
background-color: rgba(203, 210, 246, .5);
}
.badge {
font-size: 66%;
font-weight: 600;
line-height: 1;
display: inline-block;
padding: .35rem .375rem;
transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out;
text-align: center;
vertical-align: baseline;
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>

422
src/views/aericlelist.vue Normal file
View File

@@ -0,0 +1,422 @@
<template>
<div id="allstyle">
<div class="header">
<h3>文章目录</h3>
</div>
<!-- 加载状态 -->
<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" id="category-list">
<p><strong></strong></p>
<div class="alert alert-primary"><strong><span class="alert-inner--text">文章分类如下点击跳转</span></strong></div>
<div v-for="categoryGroup in categories" :key="categoryGroup.typeid"
class="category-group">
<div v-if="categoryGroup.attributes.length > 0">
<h2 id="header-id-1">{{ categoryGroup.typename }}</h2>
<span class="badge badge-primary"> {{ categoryGroup.attributes.length }} </span>
<ul class="wp-block-list">
<li v-for="category in categoryGroup.attributes" :key="category.typeid">
<a class="btn" @click="handleCategoryClick(category.categoryid)"><kbd>{{ category.attributename
}}</kbd></a>
&nbsp; ({{ category.articles.length }})
</li>
</ul>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-container">
<el-empty description="暂无分类" />
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
// 由于模块 '@/services' 没有导出的成员 'CategoryService',请确认正确的导出名称后修改此处
// 以下代码仅作示例,实际需根据 '@/services' 模块的真实导出调整
import { ElMessage } from 'element-plus'
import { categoryService, categoryAttributeService, articleService } from '@/services'
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 categoryService.getAllCategories();
if (res.data && res.data.length > 0) {
// 创建处理后的分类数组
const processedCategories = res.data.map(item => ({
...item,
attributes: [] // 使用更清晰的命名
}));
// 并行处理所有分类
await Promise.all(
processedCategories.map(async (category) => {
try {
if (await categoryAttributeService.checkAttributeExists(category.typeid, category.typename || '')) {
// 获取分类的所有属性
const attributesRes = await categoryAttributeService.getAttributeById(category.typeid);
// 存储属性数据
if (attributesRes.data) {
category.attributes = Array.isArray(attributesRes.data) ? attributesRes.data : [attributesRes.data];
}
}
} catch (err) {
// console.error(`处理分类失败 (分类ID: ${category.typeid}):`, err);
}
})
);
await getCategoryAttributes(processedCategories);
console.log('获取分类列表成功:', categories.value);
} else {
categories.value = [];
}
} catch (err) {
error.value = '获取分类列表失败,请稍后重试';
console.error('获取分类列表失败:', err);
ElMessage.error(error.value);
} finally {
loading.value = false;
console.log('分类列表加载完成');
}
};
// 处理所有分类及其属性的文章数据
const getCategoryAttributes = async (processedCategories: any[]) => {
// 遍历所有分类
for (const category of processedCategories) {
if (category && category.attributes && category.attributes.length > 0) {
console.log(`处理分类: ${category.typename || '未命名分类'}`);
// 并行获取每个属性下的文章
await Promise.all(
category.attributes.map(async (attribute) => {
try {
// 使用正确的方法名获取属性下的文章优先使用typeid
const idToUse = attribute.typeid || attribute.categoryid;
const articlesRes = await articleService.getArticlesByAttribute(idToUse);
// 处理文章数据
attribute.articles = articlesRes.data && Array.isArray(articlesRes.data) ?
articlesRes.data : [];
} catch (err) {
console.error(`获取属性文章失败 (属性ID: ${attribute.typeid || attribute.categoryid}):`, err);
attribute.articles = [];
}
})
);
}
}
// 更新分类列表
categories.value = processedCategories;
console.log('所有分类属性文章数据处理完成');
}
/**
* 处理分类点击事件
* 注意现在实际上使用的是属性ID而不是分类ID
* @param {string | number} attributeId - 属性ID
*/
const handleCategoryClick = (attributeId: string | number) => {
router.push({
path: '/home/aericletype',
query: {
attributeId: String(attributeId)
}
})
}
/**
* 计算分类组中的文章总数
* @param {Array} categoryItems - 分类项数组
* @returns {number} 文章总数
*/
const getCategorySum = (categoryItems: any[]): number => {
if (!categoryItems || !Array.isArray(categoryItems)) {
return 0
}
return categoryItems.reduce((total, item) => {
return total + (item.sum || 0)
}, 0)
}
/**
* 组件挂载时获取分类列表
*/
onMounted(() => {
fetchCategories()
})
</script>
<style scoped>
.header {
text-align: center;
padding: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #2c3e50;
font-size: 1.8rem;
}
.post_content {
padding: 15px;
}
.category-group ul li {
display: flex;
align-items: center;
padding: 5px 0;
}
.category-group ul li .btn::before {
background-color: rgba(0, 0, 0, 0);
}
.category-group ul li .btn:hover {
background-color: rgb(245, 247, 250, 0.7);
border-bottom: #4b64f0 2px solid;
}
.alert:not(.alert-secondary) {
color: #fff;
}
.alert {
font-size: .875rem;
padding: 1rem 1.5rem;
border: 0;
border-radius: .25rem;
}
.alert-primary {
color: #fff;
border-color: #7889e8;
background-color: #7889e8;
}
.btn kbd {
color: #fff;
font-size: 14px;
padding: 4px 8px;
border-radius: 4px;
background-color: #2c3e50;
}
.alert {
position: relative;
margin-bottom: 1rem;
padding: 1rem 1.5rem;
border: .0625rem solid transparent;
border-radius: .25rem;
}
.category-group {
text-align: left;
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
}
.badge {
padding: .25em .4em;
font-size: 75%;
font-weight: 700;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: .25rem;
margin: 10px 6px;
}
.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;
}
.pcont_li {
display: flex;
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: #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;
}
/* 透明方块效果 */
.btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 8px;
z-index: -1;
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.15);
background: rgba(145, 196, 238, 0.85);
}
.category-count {
color: #7f8c8d;
font-size: 0.9rem;
}
/* 标签样式 */
.badge {
text-transform: uppercase;
}
.badge-primary {
color: #2643e9;
background-color: rgba(203, 210, 246, .5);
}
.badge {
font-size: 66%;
font-weight: 600;
line-height: 1;
display: inline-block;
padding: .35rem .375rem;
transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out;
text-align: center;
vertical-align: baseline;
white-space: nowrap;
border-radius: .25rem;
}
/* 加载状态 */
.loading-container {
padding: 40px 20px;
text-align: center;
}
/* 错误状态 */
.error-container {
text-align: center;
padding: 40px 20px;
}
.error-container .el-button {
margin-top: 16px;
}
/* 空状态 */
.empty-container {
text-align: center;
padding: 60px 20px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.header {
padding: 15px;
}
.header h1 {
font-size: 1.5rem;
}
.post_content {
padding: 15px;
}
.category-group {
padding: 15px;
margin-bottom: 15px;
}
.category-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.pcont_ul {
grid-template-columns: 1fr;
gap: 10px;
}
.category-header h2 {
font-size: 1.2rem;
}
}
</style>

View File

@@ -141,10 +141,10 @@ const fetchArticleDetail = async () => {
// 不阻止主流程
}
// 获取相关文章(同分类下的其他文章)
// 获取相关文章(同属性下的其他文章)
if (article.value.categoryId) {
try {
const relatedRes = await articleService.createArticle(article.value.categoryId)
const relatedRes = await articleService.getArticlesByCategory(article.value.categoryId)
// 过滤掉当前文章并取前5篇作为相关文章
relatedArticles.value = relatedRes.data
? relatedRes.data.filter((item: Article) => item.id !== article.value?.id).slice(0, 5)
@@ -154,6 +154,17 @@ const fetchArticleDetail = async () => {
// 不阻止主流程
}
}
// 兼容旧的categoryId
else if (article.value.categoryId) {
try {
const relatedRes = await articleService.getArticlesByCategory(article.value.categoryId)
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('文章不存在或已被删除')
}

View File

@@ -1,21 +1,18 @@
<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"
/>
<CommentItem v-for="comment in commentTree" :key="comment.id" :comment="comment" />
</div>
</div>
</div>
</template>
<script setup>
@@ -85,7 +82,7 @@ const buildCommentTree = (comments) => {
comments.forEach(comment => {
commentMap[comment.id] = { ...comment, children: [] }
})
// 构建树结构
const roots = []
comments.forEach(comment => {
@@ -99,7 +96,7 @@ const buildCommentTree = (comments) => {
}
}
})
return roots
}
@@ -109,7 +106,7 @@ const fetchComments = async () => {
loading.value = true
// 模拟异步请求
await new Promise(resolve => setTimeout(resolve, 500))
// 处理数据,构建树结构
commentTree.value = buildCommentTree(mockComments)
console.log('构建的留言树:', commentTree.value)
@@ -141,20 +138,20 @@ const CommentItem = defineComponent({
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 =>
props.comment.children.map(comment =>
h(CommentItem, { key: comment.id, comment })
)
) : null
@@ -188,7 +185,8 @@ h1 {
padding: 20px;
}
.loading, .empty {
.loading,
.empty {
text-align: center;
padding: 40px;
color: #666;
@@ -250,11 +248,11 @@ h1 {
.comment-demo-container {
padding: 10px;
}
.comments-wrapper {
padding: 15px;
}
.comment-children {
margin-left: 15px;
}

View File

@@ -61,17 +61,33 @@ const fetchArticles = async () => {
try {
loading.value = true
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('获取所有文章列表成功')
// 检查URL参数
const query = route.query
const params = route.params
// 优先使用attributeId参数新接口
if (query.attributeId) {
console.log('根据属性ID获取文章列表')
res = await articleService.getArticlesByAttribute(query.attributeId)
}
// 兼容旧的type参数
else if (params.type) {
console.log('根据类型ID获取文章列表兼容模式')
res = await articleService.getArticlesByCategory(params.type)
}
// 搜索标题
else if (params.title || query.title) {
const title = params.title || query.title
console.log('根据标题获取文章列表')
res = await articleService.getArticlesByTitle(title)
}
// 获取所有文章
else {
res = await articleService.getAllArticles()
console.log('获取所有文章列表')
}
datas.value = res.data || []
console.log('获取文章列表成功:', datas.value)
} catch (error) {

View File

@@ -3,7 +3,7 @@
<div class="message-board">
<!-- 留言内容区 -->
<div class="message-list">
<h3>留言板</h3>
<h3 class="title">留言板</h3>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :count="5" />