hookehuyr

feat(courses): 实现课程模块接口集成与功能优化

- 集成课程列表、详情、热门课程等接口
- 优化课程卡片、课程详情页的数据展示
- 添加课程跳转功能
- 移除模拟数据,使用真实接口数据
/*
* @Date: 2025-04-15 09:32:07
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-04-15 12:58:17
* @FilePath: /mlaj/src/api/course.js
* @Description: 课程模块相关接口
*/
import { fn, fetch } from './fn'
const Api = {
GET_COURSE_LIST: '/srv/?a=schedule&t=list',
GET_COURSE_DETAIL: '/srv/?a=schedule&t=detail',
GET_SCHEDULE_COURSE_LIST: '/srv/?a=schedule&t=course',
}
/**
* @description: 获取课程列表
* @param: page 页码
* @param: limit 每页数量 默认10
* @param: sn 类型 RMKC=热门课程 JXKC=精选课程
* @return: data: [{ id, title, price, original_price, feature, highlights, count, cover}]
*/
export const getCourseListAPI = (params) => fn(fetch.get(Api.GET_COURSE_LIST, params))
/**
* @description: 获取课程详情
* @param: i 课程 ID
* @return: data: [{ id, title, price, original_price, feature, highlights, learning_goal, schedule}]
* @return: schedule: [{ id, schedule_time, seq, title, duration}] 课程章节
*/
export const getCourseDetailAPI = (params) => fn(fetch.get(Api.GET_COURSE_DETAIL, params))
/**
* @description: 获取特定学习课程的目录
* @param: i 课程 ID
* @return: data: [{ id, schedule_time, seq, title, duration, course_id, file}]
*/
export const getScheduleCourseListAPI = (params) => fn(fetch.get(Api.GET_SCHEDULE_COURSE_LIST, params))
<!--
* @Date: 2025-03-20 20:36:36
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-04-15 12:52:27
* @FilePath: /mlaj/src/components/ui/CourseCard.vue
* @Description: 文件描述
-->
<template>
<router-link :to="`/courses/${course.id}`" class="flex bg-white rounded-lg overflow-hidden shadow-sm">
<div class="w-1/3 h-28">
<img
:src="course.imageUrl"
:src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
:alt="course.title"
class="w-full h-full object-cover"
/>
......@@ -10,16 +17,16 @@
<div class="flex-1 p-3 flex flex-col justify-between">
<div>
<h3 class="font-medium text-sm mb-1 line-clamp-2">{{ course.title }}</h3>
<div class="text-gray-500 text-xs">{{ course.subtitle }}</div>
<div class="text-gray-500 text-xs">{{ course.subtitle || '空数据' }}</div>
</div>
<div class="flex justify-between items-end mt-1">
<div class="text-orange-500 font-semibold">¥{{ course.price }}</div>
<div class="text-orange-500 font-semibold">¥{{ course.price || '空数据' }}</div>
<div class="text-gray-400 text-xs">
{{ course.subscribers }}人订阅
{{ course.subscribers || '没字段' }}人订阅
</div>
</div>
<div class="text-gray-400 text-xs">
已更新{{ course.updatedLessons }}期 | {{ course.subscribers }}人订阅
已更新{{ course.count }}期 | {{ course.subscribers || '没字段' }}人订阅
</div>
</div>
</router-link>
......
<!--
* @Date: 2025-03-20 19:55:21
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-04-07 18:24:11
* @LastEditTime: 2025-04-15 13:52:20
* @FilePath: /mlaj/src/views/HomePage.vue
* @Description: 亲子学院首页组件
*
......@@ -305,13 +305,12 @@
:key="index"
class="p-3 rounded-xl"
>
<div class="flex flex-col h-full">
<div class="flex flex-col h-full" @click="goToCourseDetail(item)">
<div class="h-28 mb-2 rounded-lg overflow-hidden">
<img
:src="item.image"
:src="item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
:alt="item.title"
class="w-full h-full object-cover"
@error="handleImageError"
/>
</div>
<h4 class="font-medium text-sm mb-1 line-clamp-1">{{ item.title }}</h4>
......@@ -319,7 +318,9 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ item.duration }}
<!-- TODO: 后台没字段 -->
<!-- {{ item.duration }} -->
后台没字段
</p>
</div>
</FrostedGlass>
......@@ -357,7 +358,7 @@
</div>
<div class="space-y-4">
<CourseCard
v-for="course in courses.slice(0, 3)"
v-for="course in hotCourses"
:key="course.id"
:course="course"
/>
......@@ -529,11 +530,14 @@ import SummerCampCard from '@/components/ui/SummerCampCard.vue'
import VideoPlayer from '@/components/ui/VideoPlayer.vue'
// 导入模拟数据和工具函数
import { courses, liveStreams, activities, checkInTypes, userRecommendations } from '@/utils/mockData'
import { courses, liveStreams, activities, checkInTypes } from '@/utils/mockData'
import { useTitle } from '@vueuse/core'
import { useAuth } from '@/contexts/auth'
import { showToast } from 'vant'
// 导入接口
import { getCourseListAPI } from "@/api/course";
// 视频播放状态管理
const activeVideoIndex = ref(null); // 当前播放的视频索引
const videoPlayerRefs = ref([]); // 视频播放器组件引用数组
......@@ -546,6 +550,7 @@ const playVideo = (index, videoUrl) => {
// 路由相关
const $route = useRoute()
const $router = useRouter()
useTitle($route.meta.title) // 设置页面标题
// 获取用户认证状态
......@@ -560,19 +565,52 @@ const isCheckingIn = ref(false) // 打卡提交状态
const checkInSuccess = ref(false) // 打卡成功状态
const displayedRecommendations = ref([]) // 当前显示的推荐内容
//
const userRecommendations = ref([])
const hotCourses = ref([])
// 获取推荐内容
const getRecommendations = (random = false) => {
if (random) {
const shuffled = [...userRecommendations].sort(() => 0.5 - Math.random())
const shuffled = [...userRecommendations.value].sort(() => 0.5 - Math.random())
return shuffled.slice(0, 4)
}
return userRecommendations.slice(0, 4)
return userRecommendations.value.slice(0, 4)
}
// 初始化显示推荐内容
onMounted(() => {
displayedRecommendations.value = getRecommendations()
// 自动轮播
let carouselInterval
onMounted(async () => {
carouselInterval = setInterval(() => {
if (carouselRef.value) {
const nextSlide = (currentSlide.value + 1) % courses.slice(0, 4).length
scrollToSlide(nextSlide)
}
}, 5000)
// TODO: 模拟获取用户推荐内容
// 获取课程列表
const res = await getCourseListAPI({ sn: 'RMKC' })
if (res.code) {
userRecommendations.value = res.data
// 初始化显示推荐内容
displayedRecommendations.value = getRecommendations()
}
// 获取热门课程
const res2 = await getCourseListAPI({
sn: 'RMKC',
limit: 4
})
if (res2.code) {
hotCourses.value = res2.data
}
})
onUnmounted(() => {
if (carouselInterval) {
clearInterval(carouselInterval)
}
})
const carouselRef = ref(null) // 轮播图容器引用
// 右侧导航组件:搜索和消息通知
......@@ -658,22 +696,7 @@ const handleCheckInSubmit = () => {
}, 1500)
}
// 自动轮播
let carouselInterval
onMounted(() => {
carouselInterval = setInterval(() => {
if (carouselRef.value) {
const nextSlide = (currentSlide.value + 1) % courses.slice(0, 4).length
scrollToSlide(nextSlide)
}
}, 5000)
})
onUnmounted(() => {
if (carouselInterval) {
clearInterval(carouselInterval)
}
})
const contentRef = ref(null) // 内容区域的ref引用
......@@ -690,4 +713,9 @@ watch(activeTab, () => {
}
})
})
// 跳转到购买课程详情页
const goToCourseDetail = ({id}) => {
$router.push(`/courses/${id}`)
}
</script>
......
......@@ -5,22 +5,22 @@
<div class="px-4">
<div class="bg-gradient-to-b from-red-500 to-red-600 p-4 mb-4 rounded-b-3xl shadow-lg">
<div class="bg-white/10 backdrop-blur-sm rounded-lg p-3 mb-3 inline-block">
<div class="text-white font-semibold">{{ course?.subtitle?.split(' ')[0] }}</div>
<div class="text-white font-semibold">{{ course?.subtitle?.split(' ')[0] || '没有字段' }}</div>
</div>
<h1 class="text-2xl text-white font-bold mb-1">{{ course?.title }}</h1>
<h2 class="text-lg text-white/90">{{ course?.subtitle }}</h2>
<h2 class="text-lg text-white/90">{{ course?.subtitle || '没有字段' }}</h2>
<div class="mt-4 flex justify-between items-center">
<div class="text-orange-300 font-bold text-2xl">¥{{ course?.price }}</div>
<div class="text-orange-300 font-bold text-2xl">¥{{ course?.price || '空数据' }}</div>
<div class="bg-orange-500/30 text-orange-100 text-xs px-3 py-1 rounded-full">
限时优惠
</div>
</div>
<div class="flex justify-between text-xs text-white/80 mt-3">
<div>已更新{{ course?.updatedLessons }}期</div>
<div>{{ course?.subscribers }}人订阅</div>
<div>已更新{{ course?.count || '空数据' }}期</div>
<div>{{ course?.subscribers || '没有字段' }}人订阅</div>
</div>
<div v-if="course?.expireDate" class="text-xs text-white/80 mt-1">
有效期: {{ course?.expireDate }}
有效期: {{ course?.expireDate || '没有字段' }}
</div>
</div>
</div>
......@@ -31,14 +31,14 @@
<FrostedGlass class="mb-4 p-4 rounded-xl">
<h3 class="text-lg font-bold text-gray-800 mb-3">本课程介绍</h3>
<p class="text-gray-700 whitespace-pre-line">
{{ course?.description }}
{{ course?.description || '没有字段' }}
</p>
</FrostedGlass>
<!-- Course Image -->
<div class="mb-6">
<img
:src="course?.imageUrl"
:src="course?.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
:alt="course?.title"
class="w-full h-auto rounded-xl shadow-md"
/>
......@@ -67,46 +67,33 @@
<!-- Tab Content -->
<div class="p-4">
<div v-if="activeTab === '课程特色'">
<ul class="list-disc pl-5 space-y-2 text-gray-700">
<!-- <ul class="list-disc pl-5 space-y-2 text-gray-700">
<li>小班授课,更多关注</li>
<li>名师授课,经验丰富</li>
<li>随堂练习,巩固知识</li>
<li>及时反馈,调整教学</li>
</ul>
</ul> -->
<div v-html="course?.feature || '空数据'"></div>
</div>
<div v-if="activeTab === '课程大纲'">
<div class="space-y-4">
<div class="border-l-2 border-green-500 pl-3">
<h4 class="font-medium text-gray-800">第一章:心态准备</h4>
<p class="text-sm text-gray-600 mt-1">45分钟 · 3个小节</p>
</div>
<div class="border-l-2 border-gray-300 pl-3">
<h4 class="font-medium text-gray-800">第二章:考前减压</h4>
<p class="text-sm text-gray-600 mt-1">60分钟 · 4个小节</p>
</div>
<div class="border-l-2 border-gray-300 pl-3">
<h4 class="font-medium text-gray-800">第三章:家庭支持</h4>
<p class="text-sm text-gray-600 mt-1">50分钟 · 3个小节</p>
<div v-for="(item, index) in course?.schedule" :key="index" class="border-l-2 border-green-500 pl-3">
<h4 class="font-medium text-gray-800">{{ item.title }}</h4>
<p class="text-sm text-gray-600 mt-1">{{ item.duration }}分钟 · {{ item.schedule_time || '空数据' }}个小节</p>
</div>
</div>
</div>
<div v-if="activeTab === '课程亮点'">
<div class="space-y-3 text-gray-700">
<p>✓ 专业的心理辅导</p>
<p>✓ 系统化的学习方法</p>
<p>✓ 实用的家庭沟通技巧</p>
<p>✓ 定制的减压活动推荐</p>
<div v-html="course?.highlights || '空数据'"></div>
</div>
</div>
<div v-if="activeTab === '学习目标'">
<div class="space-y-3 text-gray-700">
<p>1. 帮助家长理解考前压力来源</p>
<p>2. 掌握有效的情绪管理技巧</p>
<p>3. 建立积极的家庭支持系统</p>
<p>4. 培养考前良好的心态和信心</p>
<div v-html="course?.learning_goal || '空数据'"></div>
</div>
</div>
</div>
......@@ -118,16 +105,16 @@
<div class="flex items-center">
<div class="w-16 h-16 rounded-full overflow-hidden mr-4">
<img
src="https://cdn.ipadbiz.cn/mlaj/images/teacher-avatar.jpg"
:src="teacher?.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
alt="Teacher"
class="w-full h-full object-cover"
@error="handleImageError"
/>
</div>
<div>
<h4 class="font-bold text-gray-900">张明睿</h4>
<p class="text-sm text-gray-600">教育心理学博士</p>
<p class="text-xs text-gray-500 mt-1">10年家庭教育培训经验</p>
<h4 class="font-bold text-gray-900">{{ teacher?.name || '没字段' }}</h4>
<p class="text-sm text-gray-600">{{ teacher?.position || '没字段' }}</p>
<p class="text-xs text-gray-500 mt-1">{{ teacher?.description || '没字段' }}</p>
</div>
</div>
</FrostedGlass>
......@@ -241,9 +228,9 @@
</div>
<div class="flex items-center">
<div class="mr-2">
<div class="text-red-500 font-bold">¥{{ course?.price }}</div>
<div class="text-red-500 font-bold">¥{{ course?.price || 0 }}</div>
<div class="text-xs text-gray-400 line-through">
¥{{ Math.round(course?.price * 1.2) }}
¥{{ Math.round((course?.price || 0) * 1.2) }}
</div>
</div>
<van-button
......@@ -297,17 +284,29 @@ import ReviewPopup from '@/components/ui/ReviewPopup.vue'
import { courses } from '@/utils/mockData'
import { useCart } from '@/contexts/cart'
import { useTitle } from '@vueuse/core';
import { showToast } from 'vant';
// 导入接口
import { getCourseDetailAPI } from "@/api/course";
const $route = useRoute();
const $router = useRouter();
useTitle($route.meta.title);
const route = useRoute()
const router = useRouter()
const course = ref(null)
const teacher = ref(null)
const activeTab = ref('课程特色')
// 是否收藏状态
const isFavorite = ref(false)
// 是否已购买状态
const isPurchased = ref(false)
// 是否已评论状态
const isReviewed = ref(false)
const showReviewPopup = ref(false)
const { addToCart, proceedToCheckout } = useCart()
// Handle favorite toggle
......@@ -377,16 +376,27 @@ const handleReviewSubmit = (review) => {
}
// Fetch course data
onMounted(() => {
onMounted(async () => {
const id = route.params.id
const foundCourse = courses.find(c => c.id === id)
if (foundCourse) {
course.value = foundCourse
isPurchased.value = foundCourse.isPurchased
isReviewed.value = foundCourse.isReviewed
} else {
// Course not found, redirect to courses page
router.push('/courses')
// 调用接口获取课程详情
const res = await getCourseDetailAPI({i: id});
if (res.code) {
const foundCourse = res.data;
if (foundCourse) {
course.value = foundCourse;
teacher.value = {
name: '',
avatar: '',
position: '',
description: '',
}
isPurchased.value = foundCourse.isPurchased;
isReviewed.value = foundCourse.isReviewed;
} else {
// Course not found, redirect to courses page
showToast('课程不存在')
router.push('/courses')
}
}
})
</script>
......
<!--
* @Date: 2025-03-21 14:31:21
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-21 14:54:47
* @LastEditTime: 2025-04-15 14:24:16
* @FilePath: /mlaj/src/views/courses/CourseListPage.vue
* @Description: 文件描述
-->
......@@ -10,29 +10,20 @@
<div class="pb-16">
<!-- Search Bar -->
<div class="pb-2">
<SearchBar placeholder="搜索" v-model="keyword" @search="handleSearch" @blur="handleBlur" />
<SearchBar placeholder="搜索" v-model="keyword" @blur="handleBlur" />
</div>
<!-- Course List -->
<div class="px-4">
<div class="space-y-4">
<CourseCard v-for="course in courses" :key="course.id" :course="course" />
</div>
<!-- Load More -->
<div
v-if="hasMore"
class="py-4 text-center text-gray-500 text-sm"
@click="loadMore"
>
加载更多
</div>
<div
v-else
class="py-4 text-center text-gray-400 text-sm"
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多课程了"
@load="onLoad"
class="space-y-4"
>
没有更多课程了
</div>
<CourseCard v-for="course in courses" :key="course.id" :course="course" />
</van-list>
</div>
</div>
</AppLayout>
......@@ -45,41 +36,42 @@ import AppLayout from '@/components/layout/AppLayout.vue';
import SearchBar from '@/components/ui/SearchBar.vue';
import CourseCard from '@/components/ui/CourseCard.vue';
import { courses as mockCourses } from '@/utils/mockData';
// 导入接口
import { getCourseListAPI } from "@/api/course";
import { List } from 'vant';
const $route = useRoute();
const courses = ref([]);
const hasMore = ref(true);
const page = ref(1);
const loading = ref(false);
const finished = ref(false);
const limit = ref(5);
const page = ref(0);
const keyword = ref('');
// 搜索课程列表
const searchCourses = () => {
// 实际项目中这里会调用搜索API
const filteredCourses = keyword.value
? mockCourses.filter(course =>
(course.title?.toLowerCase().includes(keyword.value.toLowerCase()) ||
course.description?.toLowerCase().includes(keyword.value.toLowerCase())) ?? false
)
: [...mockCourses];
courses.value = filteredCourses;
hasMore.value = filteredCourses.length >= 10;
page.value = 1;
const searchCourses = async () => {
const res = await getCourseListAPI({ limit: limit.value, page: 0, keyword: keyword.value });
if (res.code) {
courses.value = res.data;
finished.value = res.data.length < limit.value;
page.value = 1;
}
};
// 监听路由参数变化
watchEffect(() => {
const queryKeyword = $route.query.keyword;
if (keyword.value !== queryKeyword) {
keyword.value = queryKeyword || '';
searchCourses();
}
});
// watchEffect(() => {
// const queryKeyword = $route.query.keyword;
// if (keyword.value !== queryKeyword) {
// keyword.value = queryKeyword || '';
// searchCourses();
// }
// });
// Search handler
const handleSearch = (query) => {
keyword.value = query;
searchCourses();
};
// const handleSearch = (query) => {
// keyword.value = query;
// searchCourses();
// };
// Blur handler
const handleBlur = (query) => {
......@@ -88,22 +80,14 @@ const handleBlur = (query) => {
};
// Load more courses
const loadMore = () => {
// 实际项目中这里会调用分页API
if (page.value < 3) {
const filteredCourses = keyword.value
? mockCourses.filter(course =>
course.title.toLowerCase().includes(keyword.value.toLowerCase()) ||
course.description.toLowerCase().includes(keyword.value.toLowerCase())
)
: [...mockCourses];
courses.value = [...courses.value, ...filteredCourses];
console.warn(courses.value);
page.value += 1;
hasMore.value = page.value < 3;
} else {
hasMore.value = false;
const onLoad = async () => {
const nextPage = page.value;
const res = await getCourseListAPI({ limit: limit.value, page: nextPage, keyword: keyword.value });
if (res.code) {
courses.value = [...courses.value, ...res.data];
finished.value = res.data.length < limit.value;
page.value = nextPage + 1;
}
loading.value = false;
};
</script>
......
......@@ -88,11 +88,25 @@ import SearchBar from "@/components/ui/SearchBar.vue";
import FrostedGlass from "@/components/ui/FrostedGlass.vue";
import CourseCard from "@/components/ui/CourseCard.vue";
import LiveStreamCard from "@/components/ui/LiveStreamCard.vue";
import { featuredCourse, liveStreams, courses } from "@/utils/mockData";
import { featuredCourse, liveStreams } from "@/utils/mockData";
import { useTitle } from '@vueuse/core';
// 导入接口
import { getCourseListAPI } from "@/api/course";
const courses = ref([])
onMounted(async () => {
const res = await getCourseListAPI({ limit: 4 });
if (res.code) {
courses.value = res.data;
}
})
const $route = useRoute();
const $router = useRouter();
useTitle($route.meta.title);
// Search handler
const handleSearch = (query) => {
console.log("Searching for:", query);
......
......@@ -50,7 +50,7 @@
<!-- 目录区域 -->
<div id="catalog" class="py-4">
<div class="space-y-4">
<div v-for="(lesson, index) in course?.lessons" :key="index" class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative">
<div v-for="(lesson, index) in course?.lessons" :key="index" @click="router.push(`/studyDetail/${lesson.id}`)" class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative">
<div v-if="lesson.progress > 0 && lesson.progress < 100" class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded">上次看到</div>
<div class="text-black text-base font-medium mb-2">{{ lesson.title }}</div>
<div class="flex items-center text-sm text-gray-500">
......@@ -92,6 +92,9 @@
<script setup>
import { ref, onMounted, nextTick, onUnmounted } from 'vue';
import { useTitle } from '@vueuse/core';
import { useRouter } from "vue-router";
const router = useRouter();
// 页面标题
useTitle('课程详情');
......