hookehuyr

feat(收藏): 实现课程收藏功能并优化相关页面

- 新增 `favorite.js` API 文件,提供收藏列表、新增和取消收藏的接口
- 在 `CourseDetailPage.vue` 中实现收藏和取消收藏的逻辑,并添加交互反馈
- 在 `MyFavoritesPage.vue` 中替换模拟数据为真实接口调用,优化收藏课程加载逻辑
- 调整 `jsconfig.json` 配置文件,包含新增的 API 文件路径
......@@ -24,6 +24,6 @@
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*", "build/**/*", "vite.config.js"],
"include": ["src/**/*", "build/**/*", "vite.config.js", "src/api/.js"],
"exclude": ["node_modules", "dist"]
}
......
/*
* @Date: 2025-04-16 16:21:37
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-04-17 10:04:30
* @FilePath: /mlaj/src/api/favorite.js
* @Description: 收藏相关接口
*/
import { fn, fetch } from './fn'
const Api = {
GROUP_FAVORITE_LIST: '/srv/?a=group_favorite_list',
FAVORITE_ADD: '/srv/?a=group_favorite',
FAVORITE_CANCEL: '/srv/?a=group_unfavorite',
}
/**
* @description: 获取课程收藏列表
* @param: page 页码
* @param: limit 每页数量
* @return: data: { id: 收藏ID, title: 课程名称, price: 优惠价格, original_price: 原价, feature: 课程特色, highlights: 课程亮点, learning_goal: 学习目标, count: 课程章节数, cover: 封面图 }
*/
export const getGroupFavoriteListAPI = (params) => fn(fetch.get(Api.GROUP_FAVORITE_LIST, params))
/**
* @description: 新增收藏
* @param: group_id 课程ID
* @return: data: { }
*/
export const addFavoriteAPI = (params) => fn(fetch.post(Api.FAVORITE_ADD, params))
/**
* @description: 取消收藏
* @param: group_id 课程ID
* @return: data: { }
*/
export const cancelFavoriteAPI = (params) => fn(fetch.post(Api.FAVORITE_CANCEL, params))
......@@ -8,7 +8,7 @@
<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 || 'N/A' }}</div>
<div class="bg-orange-500/30 text-orange-100 text-xs px-3 py-1 rounded-full">
......@@ -37,28 +37,20 @@
<!-- Course Image -->
<div class="mb-6">
<img
:src="course?.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
:alt="course?.title"
class="w-full h-auto rounded-xl shadow-md"
/>
<img :src="course?.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" :alt="course?.title"
class="w-full h-auto rounded-xl shadow-md" />
</div>
<!-- Tab Navigation -->
<FrostedGlass class="mb-6 rounded-xl overflow-hidden">
<div class="border-b border-gray-200">
<div class="flex">
<button
v-for="(item, index) in curriculumItems"
:key="index"
@click="activeTab = item.title"
:class="[
'flex-1 py-3 font-medium text-center',
activeTab === item.title
? 'text-green-600 border-b-2 border-green-600 bg-green-50/50'
: 'text-gray-500'
]"
>
<button v-for="(item, index) in curriculumItems" :key="index" @click="activeTab = item.title" :class="[
'flex-1 py-3 font-medium text-center',
activeTab === item.title
? 'text-green-600 border-b-2 border-green-600 bg-green-50/50'
: 'text-gray-500'
]">
{{ item.title }}
</button>
</div>
......@@ -83,8 +75,10 @@
<p class="text-sm text-gray-600 mt-1">{{ item.duration }}分钟 · {{ item.schedule_time || 'N/A' }}个小节</p>
</div>
<div v-if="course?.schedule?.length > 4" class="flex justify-center mt-4">
<button @click="toggleSchedule" class="p-2 rounded-full hover:bg-green-50 text-green-600 hover:text-green-700 transition-all duration-300">
<van-icon :name="isScheduleExpanded ? 'arrow-up' : 'arrow-down'" class="text-xl transform transition-transform duration-300" />
<button @click="toggleSchedule"
class="p-2 rounded-full hover:bg-green-50 text-green-600 hover:text-green-700 transition-all duration-300">
<van-icon :name="isScheduleExpanded ? 'arrow-up' : 'arrow-down'"
class="text-xl transform transition-transform duration-300" />
</button>
</div>
</div>
......@@ -109,12 +103,8 @@
<h3 class="text-lg font-bold text-gray-800 mb-3">主讲老师</h3>
<div class="flex items-center">
<div class="w-16 h-16 rounded-full overflow-hidden mr-4">
<img
:src="teacher?.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
alt="Teacher"
class="w-full h-full object-cover"
@error="handleImageError"
/>
<img :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">{{ teacher?.name || '没字段' }}</h4>
......@@ -129,17 +119,10 @@
<h3 class="text-lg font-bold text-gray-800 mb-3">学员评价</h3>
<div class="flex items-center mb-3">
<div class="flex text-yellow-400 mr-2">
<svg
v-for="star in 5"
:key="star"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<svg v-for="star in 5" :key="star" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
fill="currentColor">
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
<div class="text-gray-700">4.9 (126条评论)</div>
......@@ -167,10 +150,8 @@
</div>
</div>
<button
@click="router.push(`/courses/${course?.id}/reviews`)"
class="w-full text-center text-green-600 mt-3 text-sm"
>
<button @click="router.push(`/courses/${course?.id}/reviews`)"
class="w-full text-center text-green-600 mt-3 text-sm">
查看全部评价
</button>
</FrostedGlass>
......@@ -213,20 +194,12 @@
</svg>
分享
</button> -->
<button class="flex flex-col items-center text-gray-500 text-xs transition-transform duration-300" @click="toggleFavorite" :class="{ 'animate-favorite': isFavorite }">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 transition-transform duration-300"
:fill="isFavorite ? 'red' : 'none'"
viewBox="0 0 24 24"
:stroke="isFavorite ? 'red' : 'currentColor'"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
<button class="flex flex-col items-center text-gray-500 text-xs transition-transform duration-300"
@click="toggleFavorite" :class="{ 'animate-favorite': isFavorite }">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transition-transform duration-300"
:fill="isFavorite ? 'red' : 'none'" viewBox="0 0 24 24" :stroke="isFavorite ? 'red' : 'currentColor'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
收藏
</button>
......@@ -238,34 +211,16 @@
¥{{ Math.round((course?.price || 0) * 1.2) }}
</div>
</div>
<van-button
v-if="!isPurchased"
@click="handlePurchase"
round
block
color="linear-gradient(to right, #22c55e, #16a34a)"
class="shadow-md"
>
<van-button v-if="!isPurchased" @click="handlePurchase" round block
color="linear-gradient(to right, #22c55e, #16a34a)" class="shadow-md">
立即购买
</van-button>
<van-button
v-else-if="!isReviewed"
@click="showReviewPopup = true"
round
block
color="linear-gradient(to right, #3b82f6, #2563eb)"
class="shadow-md"
>
<van-button v-else-if="!isReviewed" @click="showReviewPopup = true" round block
color="linear-gradient(to right, #3b82f6, #2563eb)" class="shadow-md">
立即评论
</van-button>
<van-button
v-else
@click="router.push(`/courses/${course?.id}/reviews`)"
round
block
color="linear-gradient(to right, #6b7280, #4b5563)"
class="shadow-md"
>
<van-button v-else @click="router.push(`/courses/${course?.id}/reviews`)" round block
color="linear-gradient(to right, #6b7280, #4b5563)" class="shadow-md">
查看评论
</van-button>
</div>
......@@ -273,10 +228,7 @@
</div>
<!-- Review Popup -->
<ReviewPopup
v-model:show="showReviewPopup"
@submit="handleReviewSubmit"
/>
<ReviewPopup v-model:show="showReviewPopup" @submit="handleReviewSubmit" />
</AppLayout>
</template>
......@@ -286,13 +238,14 @@ import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import FrostedGlass from '@/components/ui/FrostedGlass.vue'
import ReviewPopup from '@/components/ui/ReviewPopup.vue'
import { courses } from '@/utils/mockData'
// import { courses } from '@/utils/mockData'
import { useCart } from '@/contexts/cart'
import { useTitle } from '@vueuse/core';
import { showToast } from 'vant';
// 导入接口
import { getCourseDetailAPI } from "@/api/course";
import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite";
const $route = useRoute();
const $router = useRouter();
......@@ -315,9 +268,25 @@ const showReviewPopup = ref(false)
const { addToCart, proceedToCheckout } = useCart()
// Handle favorite toggle
const toggleFavorite = () => {
isFavorite.value = !isFavorite.value
// TODO: 后续对接收藏接口
// 收藏/取消收藏操作
const toggleFavorite = async () => {
if (isFavorite.value) {
const { code, msg } = await cancelFavoriteAPI({
group_id: course.value.id
})
if (code) {
isFavorite.value = !isFavorite.value
showToast('取消收藏')
}
} else {
const { code, msg } = await addFavoriteAPI({
group_id: course.value.id
})
if (code) {
isFavorite.value = !isFavorite.value
showToast('收藏成功')
}
}
}
// Curriculum items
......@@ -384,7 +353,7 @@ const handleReviewSubmit = (review) => {
onMounted(async () => {
const id = route.params.id
// 调用接口获取课程详情
const res = await getCourseDetailAPI({i: id});
const res = await getCourseDetailAPI({ i: id });
if (res.code) {
const foundCourse = res.data;
if (foundCourse) {
......@@ -431,9 +400,11 @@ const toggleSchedule = () => {
0% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
......
......@@ -2,7 +2,7 @@
<div class="bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen pb-20">
<!-- 分类切换 -->
<div class="px-4 py-3">
<van-tabs v-model:active="activeTab" sticky swipeable title-active-color="#4caf50" color="#4caf50">
<van-tabs v-model:active="activeTab" @click-tab="onClickTab" sticky swipeable title-active-color="#4caf50" color="#4caf50">
<van-tab title="课程" name="courses">
<van-list
v-model:loading="coursesLoading"
......@@ -24,7 +24,7 @@
</van-tab>
<van-tab title="活动" name="activities">
<van-list
<!--<van-list
v-model:loading="activitiesLoading"
:finished="activitiesFinished"
finished-text="没有更多了"
......@@ -34,13 +34,13 @@
<ActivityCard v-for="activity in favoriteActivities" :key="activity.id" :activity="activity" />
</van-list>
<!-- 无数据提示 -->
<!~~ 无数据提示 ~~>
<div v-if="!activitiesLoading && favoriteActivities.length === 0" class="flex flex-col items-center justify-center py-12">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<p class="mt-4 text-gray-500">暂无收藏活动</p>
</div>
</div>-->
</van-tab>
</van-tabs>
</div>
......@@ -55,6 +55,9 @@ import ActivityCard from '@/components/ui/ActivityCard.vue';
import { courses as mockCourses, activities as mockActivities } from '@/utils/mockData';
import { useTitle } from '@vueuse/core';
// 导入接口
import { getGroupFavoriteListAPI } from '@/api/favorite';
const $route = useRoute();
const $router = useRouter();
useTitle($route.meta.title);
......@@ -67,8 +70,8 @@ const favoriteActivities = ref([]);
// 课程列表加载状态
const coursesLoading = ref(false);
const coursesFinished = ref(false);
const coursePage = ref(1);
const coursePageSize = 10;
const coursePage = ref(0);
const courseLimit = ref(5);
// 活动列表加载状态
const activitiesLoading = ref(false);
......@@ -77,42 +80,41 @@ const activitiesPage = ref(1);
const activitiesPageSize = 10;
// 加载收藏课程
const onCoursesLoad = () => {
coursesLoading.value = true;
// 模拟异步加载
setTimeout(() => {
const start = (coursePage.value - 1) * coursePageSize;
const end = start + coursePageSize;
const newCourses = mockCourses.slice(start, end);
favoriteCourses.value.push(...newCourses);
coursesLoading.value = false;
if (newCourses.length < coursePageSize) {
coursesFinished.value = true;
} else {
coursePage.value += 1;
}
}, 1000);
const onCoursesLoad = async () => {
const nextPage = coursePage.value;
const res = await getGroupFavoriteListAPI({ limit: courseLimit.value, page: nextPage });
if (res.code) {
favoriteCourses.value = [...favoriteCourses.value, ...res.data];
coursesFinished.value = res.data.length < courseLimit.value;
coursePage.value = nextPage + 1;
}
coursesLoading.value = false;
};
// 加载收藏活动
const onActivitiesLoad = () => {
activitiesLoading.value = true;
// 模拟异步加载
setTimeout(() => {
const start = (activitiesPage.value - 1) * activitiesPageSize;
const end = start + activitiesPageSize;
const newActivities = mockActivities.slice(start, end);
// const onActivitiesLoad = () => {
// activitiesLoading.value = true;
// // 模拟异步加载
// setTimeout(() => {
// const start = (activitiesPage.value - 1) * activitiesPageSize;
// const end = start + activitiesPageSize;
// const newActivities = mockActivities.slice(start, end);
// favoriteActivities.value.push(...newActivities);
// activitiesLoading.value = false;
favoriteActivities.value.push(...newActivities);
activitiesLoading.value = false;
// if (newActivities.length < activitiesPageSize) {
// activitiesFinished.value = true;
// } else {
// activitiesPage.value += 1;
// }
// }, 1000);
// };
if (newActivities.length < activitiesPageSize) {
activitiesFinished.value = true;
} else {
activitiesPage.value += 1;
}
}, 1000);
// 切换标签页
const onClickTab = ({ name, title }) => {
if (name === 'activities') {
location.href = 'http://www.baidu.com'
}
};
</script>
......