在线教育网站首页多个相同板块
效果图
实现方式
vue3插槽-默认插槽
实现思路
1.数据分析拆解
每个板块是根据一级分类来分的,目前一级分类分别有:IT互联网、创作设计、兴趣生活、考试考证。
一级分类下又有二级分类,例如IT互联网下面有:互联网产品、前端开发、后端开发、移动端开发、软件测试、大数据与AI。
然后根据二级分类会展示对应的课程信息卡片,
卡片里的信息有:课程封面图片、课程名称、总课程数量、课程状态(更新中/已完结)、费用(免费/¥xxx)、课程来源(腾讯课堂)、浏览人数(xxx人)
因此一级分类作为板块名称,二级分类作为tag标签,课程卡片列表根据tag动态切换。
2.接收数据要求
把不同板块数据作为不同对象分别传入。数据格式如下:
//总体数据
courses: {
//一级分类数据
categories: [{
id: 1,
cate_name: "IT互联网"
},
{
id: 2,
cate_name: "创作设计"
},
{
id: 3,
cate_name: "兴趣生活"
},
{
id: 4,
cate_name: "考试考证"
}],
// 二级分类数据
courses_cate: [{
id: 1,
cate_name: "前端开发",
pid: 1
},
{
id: 2,
cate_name: "后端开发",
pid: 1
},
{
id: 3,
cate_name: "移动端开发",
pid: 1
},
{
id: 4,
cate_name: "软件测试",
pid: 1
},
{
id: 5,
cate_name: "大数据与AI",
pid: 1
},
{
id: 6,
cate_name: "UI设计",
pid: 2
},
{
id: 7,
cate_name: "平面设计",
pid: 2
},
{
id: 8,
cate_name: "绘画创作",
pid: 2
},
{
id: 9,
cate_name: "服装设计",
pid: 2
},
{
id: 10,
cate_name: "游戏美术设计",
pid: 2
},
{
id: 11,
cate_name: "影视设计",
pid: 2
},
{
id: 12,
cate_name: "茶艺",
pid: 3
},
{
id: 13,
cate_name: "插花",
pid: 3
},
{
id: 14,
cate_name: "公务员考试",
pid: 4
},
],
// 课程信息
courses_info: [
{
id: 1,
pid: 1,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "前端开发速成1",
course_count: 7,
course_status: 1,
course_price: 0,
course_from: "tx",
course_views: 100,
},
{
id: 2,
pid: 1,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "前端开发速成2",
course_count: 7,
course_status: 0,
course_price: '',
course_from: "tx",
course_views: 100,
},
{
id: 3,
pid: 1,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "前端开发速成3",
course_count: 7,
course_status: 0,
course_price: 0,
course_from: "tx",
course_views: 100,
},
{
id: 4,
pid: 1,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "前端开发速成4",
course_count: 7,
course_status: 1,
course_price: 0,
course_from: "tx",
course_views: 100,
},
{
id: 5,
pid: 1,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "前端开发速成5",
course_count: 7,
course_status: 1,
course_price: 0,
course_from: "tx",
course_views: 100,
},
{
id: 6,
pid: 5,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "前端开发速成5",
course_count: 7,
course_status: 1,
course_price: 0,
course_from: "tx",
course_views: 100,
},
{
id: 7,
pid: 5,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "前端开发速成5",
course_count: 7,
course_status: 1,
course_price: 0,
course_from: "tx",
course_views: 100,
},
{
id: 8,
pid: 5,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "前端开发速成5",
course_count: 7,
course_status: 1,
course_price: 0,
course_from: "tx",
course_views: 100,
},
{
id: 9,
pid: 5,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "前端开发速成5",
course_count: 7,
course_status: 1,
course_price: 0,
course_from: "tx",
course_views: 100,
},
{
id: 10,
pid: 6,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "UI设计1",
course_count: 7,
course_status: 1,
course_price: 0,
course_from: "tx",
course_views: 100,
},
{
id: 11,
pid: 6,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "UI设计2",
course_count: 7,
course_status: 1,
course_price: 0,
course_from: "tx",
course_views: 100,
},
{
id: 12,
pid: 6,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "UI设计3",
course_count: 7,
course_status: 1,
course_price: 0,
course_from: "tx",
course_views: 100,
},
{
id: 13,
pid: 6,
course_img: "/src/assets/images/course/pm_course.png",
course_name: "UI设计4",
course_count: 7,
course_status: 1,
course_price: 0,
course_from: "tx",
course_views: 100,
},
]
}
3.插槽应用分析
将课程卡片封装为一个组件CourseItem.vue,是因为这个卡片要在多处重复使用。不仅要在首页的板块里重复展示,而且课程列表页面也要重复展示。
将板块封装为一个组件CourseList.vue,因为这个板块要在首页多次展示不同板块的分类数据。这个组件要作为课程卡片的父组件,循环插入4个课程卡片子组件,卡片组件置于板块的底部。把接收的一级分类数据的名称作为title置于板块最顶部;把接收的二级分类数据进行过滤处理作为tag放在title下方,这个tag通过绑定的点击事件传入二级分类的id可以切换它下方展示的课程卡片。
在首页组件Home.vue里应用CourseList.vue插槽,首页组件通过遍历一级分类的数据,来插入CourseList子组件。即可实现有多少个一级分类就插多少个板块。并且需要传递所有数据给子组件,有一级分类,二级分类,课程数据。因为如果让子组件们获取一部分数据再去调用后端接口请求完整数据,会由于子组件是多次循环遍历渲染出来的,而导致多次调用接口。所以采取了在首页组件Home.vue里全部请求再传递给子组件,子组件接收到数据再通过计算属性computed进行过滤处理。
4.相关代码实现
Home.vue 首页组件
<script setup>
import { ref, getCurrentInstance, onMounted, computed, watch } from 'vue';
import CourseItem from '@/components/CourseItem.vue';
import CourseList from '@/components/CourseList.vue';
const { proxy } = getCurrentInstance()
const categoriesRef = ref([])
const coursesCateRef = ref([])
const coursesInfoRef = ref([])
// 过滤出名称存在的一级分类,保证板块可以有title
const filterCategories = computed(() => {
return categoriesRef.value.filter((item) => item.cate_name);
})
const getCourses = async () => {
const data = await proxy.$api.getCoursesData()
const { categories, courses_cate, courses_info } = data.courses
categoriesRef.value = categories
coursesCateRef.value = courses_cate
coursesInfoRef.value = courses_info
}
// 用于让几个板块按照奇偶数展示不同背景颜色
const getColorClass = (index) => {
// 根据索引或其他逻辑返回颜色类名
const colors = ['button-color-1', 'button-color-2', 'button-color-3', 'button-color-4'];
return colors[index % colors.length];
}
const carouselItems = ref([
{
imgUrl: '/src/assets/images/carousel/1.jpg',
},
{
imgUrl: '/src/assets/images/carousel/2.jpg',
},
{
imgUrl: '/src/assets/images/carousel/3.jpg'
}
// 更多轮播项...
]);
onMounted(() => {
getCourses()
})
</script>
<template>
<div class="home">
<div class="top">
<div class="carousel">
<el-carousel :interval="5000" arrow="always" height="300px">
<el-carousel-item v-for="item in carouselItems" :key="item">
<img :src="item.imgUrl" alt="">
</el-carousel-item>
</el-carousel>
</div>
<div class="top-button">
<el-button v-for="(item, index) in filterCategories" :key="index"
:class="[getColorClass(index), 'button-shared-style']" @click="handleClick()">
{{ item.cate_name }}
</el-button>
</div>
</div>
<div class="content-list">
<CourseList v-for="(category, index) in categoriesRef" :key="category.id" :category="category"
:coursesCate="coursesCateRef" :coursesInfo="coursesInfoRef">
</CourseList>
</div>
</div>
</template>
<style scoped lang="less">
.home {
width: 100%;
// height: 100%;
}
.top {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.carousel {
align-self: center;
width: 90%;
margin-top: 20px;
}
.top-button {
margin-top: 20px;
width: 90%;
display: flex;
justify-content: space-between;
.button-shared-style {
margin: 30px 50px;
width: 400px;
height: 100px;
font-size: 30px;
padding: 10px 20px;
color: #fff;
}
span {
font-size: 16px;
}
}
.button-color-1 {
background-color: #59ACF2;
}
.button-color-2 {
background-color: #F1C25A;
}
.button-color-3 {
background-color: #B459F2;
}
.button-color-4 {
background-color: #4DDB47;
}
.content-list {
background: #F8F8F8;
width: 100%;
}
.contente-list> :nth-child(odd) {
background: #F8F8F8;
}
.content-list> :nth-child(even) {
background: #ffffff;
}
</style>
CourseList.vue 首页课程板块组件
<script setup>
import { ref, toRefs, watch, getCurrentInstance, onMounted, computed, defineProps } from 'vue';
import CourseItem from '@/components/CourseItem.vue';
const { proxy } = getCurrentInstance()
const props = defineProps({
category: {
type: Object,
required: true
},
coursesCate: {
type: Array,
required: true
},
coursesInfo: {
type: Array,
required: true
}
})
const { category, coursesCate, coursesInfo } = toRefs(props)
// 控制是否展开显示所有标签
const isExpanded = ref(false);
// 切换标签显示状态的方法
const toggleTags = () => {
isExpanded.value = !isExpanded.value;
};
// 计算属性,用于获取前4个标签
const visibleTags = computed(() => {
return filterCoursesCate.value.slice(0, 4);
});
// 计算属性,用于截取掉前4个,隐藏剩余的所有标签
const hiddenTags = computed(() => {
return filterCoursesCate.value.slice(4);
});
// 过滤出一级分类下的二级分类
const filterCoursesCate = computed(() => {
return coursesCate.value.filter((item) => item.pid === category.value.id);
})
// 过滤出当前一级分类下的所有二级分类下的课程数据
const filterCourses = computed(() => {
return coursesInfo.value.filter(item => coursesCate.value.some(cate => cate.id === item.pid && cate.pid === category.value.id));
});
// 过滤出被选中的二级分类下的课程数据(由于可能频繁更新,采用计算属性效率更高)
const filterCoursesInfo = computed(() => {
return filterCourses.value.filter(item => item.pid === selected.value);
})
// 获取第一个二级分类的id
const firstTagid = filterCoursesCate.value[0].id
// 用于存放选中的二级分类的id,默认选中第一个标签。
const selected = ref(firstTagid)
// 传入二级分类的id控制自身标签的选中状态
const onChange = (id) => {
// 单选
selected.value = id
}
</script>
<template>
<div class="course-list">
<div class="list-box">
<div class="list-top">
<h3>{{ category.cate_name }}</h3>
</div>
<div class="list-middle">
<div class="list-tag">
<!-- 选中标签时展示该二级分类下的课程数据 -->
<el-check-tag class="custom-tag" v-for="(cate, index) in visibleTags" :key="cate.id"
@change="onChange(cate.id)" :class="{ active: selected === cate.id }">
<p>{{ cate.cate_name }}</p>
</el-check-tag>
<el-check-tag class="more-tag" v-show="!isExpanded && hiddenTags.length" @click="toggleTags">
更多
</el-check-tag>
<!-- 显示剩余的标签 -->
<el-check-tag class="custom-tag" v-show="isExpanded" v-for="(cate, index) in hiddenTags"
:key="cate.id" closable @change="onChange(cate.id)" :class="{ active: selected === cate.id }">
<p>{{ cate.cate_name }}</p>
</el-check-tag>
<!-- “收起”标签,仅在展开时显示 -->
<el-check-tag class="more-tag" v-show="isExpanded" @click="toggleTags">
收起
</el-check-tag>
</div>
</div>
<div class="list-bottom">
<CourseItem :coursesInfo="info" v-for="info in filterCoursesInfo.slice(0, 4)" :key="info.id">
</CourseItem>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.course-list {
height: 430px;
display: flex;
justify-content: center;
align-items: center;
}
.list-box {
margin: 10px 50px;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.list-top {
height: 50px;
display: flex;
align-items: center;
h3 {
margin-top: 30px;
margin-left: 15px;
font-size: 24px;
font-weight: bold;
}
}
.list-middle {
display: flex;
margin-top: 30px;
height: 50px;
.list-tag {
width: 100%;
.custom-tag {
color: #58595B;
p {
font-weight: bold;
}
}
.more-tag,
.active,
.custom-tag:hover {
color: #409EFF
}
:deep(.el-check-tag) {
/* 移除边框 */
border: none !important;
/* 移除背景色 */
background-color: transparent !important;
/* 移除内边距 */
padding: 0 !important;
/* 平滑过渡效果 */
transition: color 0.3s;
/* 取消加粗 */
font-weight: normal !important;
margin-left: 40px;
}
}
.custom-tag:hover {
color: #409EFF
}
}
.list-bottom {
height: 200px;
display: flex;
justify-content: space-between;
margin-left: 20px;
align-items: center;
margin-top: 20px;
}
</style>
CourseItem.vue 课程卡片组件
<script setup>
import { toRefs, defineProps } from "vue";
const props = defineProps({
coursesInfo: {
type: Object,
required: true
}
})
const { coursesInfo } = toRefs(props)
</script>
<template>
<div class="course-item">
<div class="card-top">
<div class="card-img">
<img :src="coursesInfo.course_img">
</div>
<div class="card-name">
<p>{{ coursesInfo.course_name }}</p>
</div>
</div>
<div class="card-bottom">
<div class="first-row">
<div class="card-bottom-left">
<p>共{{ coursesInfo.course_count }}节|</p>
<p v-if="coursesInfo.course_status === 1">已完结</p>
<p v-else>更新中</p>
</div>
<div class="card-bottom-right">
<p style="color: green;" v-if="coursesInfo.course_price === 0 || coursesInfo.course_price === ''">免费
</p>
</div>
</div>
<div class="seconde-row">
<div class="card-bottom-left">
<p>{{ coursesInfo.course_from }}</p>
</div>
<div class="card-bottom-right">
<el-icon :size="16" style="margin-right: 4px;">
<View />
</el-icon>
<p>{{ coursesInfo.course_views }}人</p>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="less">
.course-item {
width: 250px;
height: 250px;
display: flex;
flex-wrap: wrap;
justify-content: center;
background: #fff;
border-radius: 2%;
}
.card-top,
.card-bottom,
.first-row,
.seconde-row {
width: 100%;
display: flex;
p {
font-size: 14px;
color: #b7b7b7;
}
}
.card-top {
height: 150px;
flex-wrap: wrap;
justify-content: center;
.card-img {
img {
margin-top: 10px;
width: 220px;
height: 120px;
}
}
.card-name {
margin-top: 8px;
height: 10px;
width: 100%;
p {
color: #000;
font-weight: bold;
margin-left: 15px;
}
}
}
.card-bottom {
height: fit-content;
// display: flex;
flex-wrap: wrap;
font-size: 12px;
color: #666;
font-weight: bold;
}
.first-row {
margin-top: 30px;
justify-content: space-between;
}
.seconde-row {
margin-top: 10px;
justify-content: space-between;
}
.card-bottom-left {
display: flex;
margin-left: 15px;
}
.card-bottom-right {
display: flex;
margin-right: 15px;
}
</style>