feat(home): 重构首页组件并添加新功能模块
将首页功能拆分为独立组件,包括精选课程轮播、推荐课程、热门课程和最新活动模块 新增 useHomeVideoPlayer 组合式函数管理视频播放状态 优化代码结构和可维护性,减少主组件复杂度
Showing
7 changed files
with
514 additions
and
0 deletions
| ... | @@ -23,9 +23,12 @@ declare module 'vue' { | ... | @@ -23,9 +23,12 @@ declare module 'vue' { |
| 23 | CourseGroupCascader: typeof import('./components/ui/CourseGroupCascader.vue')['default'] | 23 | CourseGroupCascader: typeof import('./components/ui/CourseGroupCascader.vue')['default'] |
| 24 | CourseImageCard: typeof import('./components/ui/CourseImageCard.vue')['default'] | 24 | CourseImageCard: typeof import('./components/ui/CourseImageCard.vue')['default'] |
| 25 | CourseList: typeof import('./components/courses/CourseList.vue')['default'] | 25 | CourseList: typeof import('./components/courses/CourseList.vue')['default'] |
| 26 | + FeaturedCoursesSection: typeof import('./components/homePage/FeaturedCoursesSection.vue')['default'] | ||
| 26 | FormPage: typeof import('./components/infoEntry/formPage.vue')['default'] | 27 | FormPage: typeof import('./components/infoEntry/formPage.vue')['default'] |
| 27 | FrostedGlass: typeof import('./components/ui/FrostedGlass.vue')['default'] | 28 | FrostedGlass: typeof import('./components/ui/FrostedGlass.vue')['default'] |
| 28 | GradientHeader: typeof import('./components/ui/GradientHeader.vue')['default'] | 29 | GradientHeader: typeof import('./components/ui/GradientHeader.vue')['default'] |
| 30 | + HotCoursesSection: typeof import('./components/homePage/HotCoursesSection.vue')['default'] | ||
| 31 | + LatestActivitiesSection: typeof import('./components/homePage/LatestActivitiesSection.vue')['default'] | ||
| 29 | LiveStreamCard: typeof import('./components/ui/LiveStreamCard.vue')['default'] | 32 | LiveStreamCard: typeof import('./components/ui/LiveStreamCard.vue')['default'] |
| 30 | MenuItem: typeof import('./components/ui/MenuItem.vue')['default'] | 33 | MenuItem: typeof import('./components/ui/MenuItem.vue')['default'] |
| 31 | OfficeViewer: typeof import('./components/ui/OfficeViewer.vue')['default'] | 34 | OfficeViewer: typeof import('./components/ui/OfficeViewer.vue')['default'] |
| ... | @@ -33,6 +36,7 @@ declare module 'vue' { | ... | @@ -33,6 +36,7 @@ declare module 'vue' { |
| 33 | PdfViewer: typeof import('./components/ui/PdfViewer.vue')['default'] | 36 | PdfViewer: typeof import('./components/ui/PdfViewer.vue')['default'] |
| 34 | PostCountModel: typeof import('./components/count/postCountModel.vue')['default'] | 37 | PostCountModel: typeof import('./components/count/postCountModel.vue')['default'] |
| 35 | RecallPoster: typeof import('./components/ui/RecallPoster.vue')['default'] | 38 | RecallPoster: typeof import('./components/ui/RecallPoster.vue')['default'] |
| 39 | + RecommendationsSection: typeof import('./components/homePage/RecommendationsSection.vue')['default'] | ||
| 36 | ReviewPopup: typeof import('./components/courses/ReviewPopup.vue')['default'] | 40 | ReviewPopup: typeof import('./components/courses/ReviewPopup.vue')['default'] |
| 37 | RouterLink: typeof import('vue-router')['RouterLink'] | 41 | RouterLink: typeof import('vue-router')['RouterLink'] |
| 38 | RouterView: typeof import('vue-router')['RouterView'] | 42 | RouterView: typeof import('vue-router')['RouterView'] | ... | ... |
| 1 | +<template> | ||
| 2 | + <div v-if="courses.length" class="mb-6"> | ||
| 3 | + <div class="px-4 mb-2"> | ||
| 4 | + <h3 class="font-medium">精选课程</h3> | ||
| 5 | + </div> | ||
| 6 | + <div class="relative"> | ||
| 7 | + <div | ||
| 8 | + ref="carouselRef" | ||
| 9 | + class="flex overflow-x-scroll snap-x snap-mandatory" | ||
| 10 | + style="scrollbar-width: none; -ms-overflow-style: none;" | ||
| 11 | + > | ||
| 12 | + <div | ||
| 13 | + v-for="(course, index) in courses" | ||
| 14 | + :key="course.id" | ||
| 15 | + class="flex-shrink-0 w-full snap-center px-4" | ||
| 16 | + > | ||
| 17 | + <div class="relative rounded-xl overflow-hidden shadow-lg h-48"> | ||
| 18 | + <img | ||
| 19 | + :src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" | ||
| 20 | + :alt="course.title" | ||
| 21 | + class="w-full h-full object-cover" | ||
| 22 | + /> | ||
| 23 | + <div | ||
| 24 | + v-if="course.is_buy" | ||
| 25 | + class="absolute top-0 left-0 bg-orange-500 text-white text-xs px-2 py-1 rounded-br-lg font-medium" | ||
| 26 | + style="background-color: rgba(249, 115, 22, 0.85)" | ||
| 27 | + > | ||
| 28 | + 已购 | ||
| 29 | + </div> | ||
| 30 | + <div class="absolute inset-0 bg-gradient-to-b from-transparent via-black/20 to-black/60 flex flex-col justify-end p-4"> | ||
| 31 | + <div v-if="course.category" class="bg-amber-500/90 text-white px-2 py-1 rounded-full text-xs font-medium inline-block w-fit mb-1"> | ||
| 32 | + {{ course.category }} | ||
| 33 | + </div> | ||
| 34 | + <h2 class="text-2xl font-bold text-white drop-shadow-md">{{ course.title }}</h2> | ||
| 35 | + <p class="text-white/90 text-sm drop-shadow-sm mb-1">{{ course.subtitle }}</p> | ||
| 36 | + <div class="flex justify-between items-center"> | ||
| 37 | + <div class="flex items-center"> | ||
| 38 | + <div class="flex"> | ||
| 39 | + <svg | ||
| 40 | + v-for="i in 5" | ||
| 41 | + :key="i" | ||
| 42 | + xmlns="http://www.w3.org/2000/svg" | ||
| 43 | + :class="[`h-4 w-4`, i <= course.comment_score ? 'text-amber-400' : 'text-gray-300']" | ||
| 44 | + viewBox="0 0 20 20" | ||
| 45 | + fill="currentColor" | ||
| 46 | + > | ||
| 47 | + <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" /> | ||
| 48 | + </svg> | ||
| 49 | + </div> | ||
| 50 | + <span class="text-white text-xs ml-1">{{ course.comment_count }}人评</span> | ||
| 51 | + </div> | ||
| 52 | + <router-link | ||
| 53 | + :to="`/courses/${course.id}`" | ||
| 54 | + class="bg-white/90 text-green-600 px-3 py-1 rounded-full text-xs font-medium" | ||
| 55 | + > | ||
| 56 | + {{ course.price === '0.00' ? '免费学习' : '立即学习' }} | ||
| 57 | + </router-link> | ||
| 58 | + </div> | ||
| 59 | + </div> | ||
| 60 | + </div> | ||
| 61 | + </div> | ||
| 62 | + </div> | ||
| 63 | + | ||
| 64 | + <div class="flex justify-center mt-4"> | ||
| 65 | + <button | ||
| 66 | + v-for="(_, index) in courses.slice(0, 4)" | ||
| 67 | + :key="index" | ||
| 68 | + @click="scroll_to_slide(index)" | ||
| 69 | + :class="[ | ||
| 70 | + 'w-2 h-2 mx-1 rounded-full', | ||
| 71 | + current_slide === index ? 'bg-green-600' : 'bg-gray-300' | ||
| 72 | + ]" | ||
| 73 | + /> | ||
| 74 | + </div> | ||
| 75 | + </div> | ||
| 76 | + </div> | ||
| 77 | +</template> | ||
| 78 | + | ||
| 79 | +<script setup> | ||
| 80 | +import { ref, onMounted, onUnmounted } from 'vue' | ||
| 81 | +import { getCourseListAPI } from '@/api/course' | ||
| 82 | + | ||
| 83 | +const courses = ref([]) | ||
| 84 | +const carouselRef = ref(null) | ||
| 85 | +const current_slide = ref(0) | ||
| 86 | + | ||
| 87 | +let carousel_interval | ||
| 88 | + | ||
| 89 | +/** | ||
| 90 | + * @description 获取精选课程数据 | ||
| 91 | + * @returns {Promise<void>} | ||
| 92 | + */ | ||
| 93 | +const fetch_courses = async () => { | ||
| 94 | + const res = await getCourseListAPI({ | ||
| 95 | + sn: 'JXKC', | ||
| 96 | + limit: 4 | ||
| 97 | + }) | ||
| 98 | + if (res && res.code) { | ||
| 99 | + courses.value = Array.isArray(res.data) ? res.data : [] | ||
| 100 | + } else { | ||
| 101 | + courses.value = [] | ||
| 102 | + } | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +/** | ||
| 106 | + * @description 根据滚动位置同步当前轮播索引 | ||
| 107 | + * @returns {void} | ||
| 108 | + */ | ||
| 109 | +const sync_current_slide = () => { | ||
| 110 | + const container = carouselRef.value | ||
| 111 | + const total = courses.value.slice(0, 4).length | ||
| 112 | + if (!container || total === 0) return | ||
| 113 | + | ||
| 114 | + const slide_width = container.offsetWidth | ||
| 115 | + if (!slide_width) return | ||
| 116 | + | ||
| 117 | + const raw_index = container.scrollLeft / slide_width | ||
| 118 | + const idx = Math.round(raw_index) | ||
| 119 | + const bounded = Math.min(Math.max(idx, 0), total - 1) | ||
| 120 | + current_slide.value = bounded | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +/** | ||
| 124 | + * @description 轮播滚动事件处理(被动监听) | ||
| 125 | + * @returns {void} | ||
| 126 | + */ | ||
| 127 | +const handle_carousel_scroll = () => { | ||
| 128 | + sync_current_slide() | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +/** | ||
| 132 | + * @description 轮播图控制:滚动到指定位置 | ||
| 133 | + * @param {number} index 轮播索引 | ||
| 134 | + * @returns {void} | ||
| 135 | + */ | ||
| 136 | +const scroll_to_slide = (index) => { | ||
| 137 | + const container = carouselRef.value | ||
| 138 | + if (!container) return | ||
| 139 | + | ||
| 140 | + const slide_width = container.offsetWidth | ||
| 141 | + container.scrollTo({ | ||
| 142 | + left: index * slide_width, | ||
| 143 | + behavior: 'smooth' | ||
| 144 | + }) | ||
| 145 | + current_slide.value = index | ||
| 146 | +} | ||
| 147 | + | ||
| 148 | +/** | ||
| 149 | + * @description 启动自动轮播 | ||
| 150 | + * @returns {void} | ||
| 151 | + */ | ||
| 152 | +const start_auto_carousel = () => { | ||
| 153 | + if (carousel_interval) clearInterval(carousel_interval) | ||
| 154 | + if (!courses.value.length) return | ||
| 155 | + carousel_interval = setInterval(() => { | ||
| 156 | + if (!carouselRef.value) return | ||
| 157 | + const total = courses.value.slice(0, 4).length | ||
| 158 | + if (!total) return | ||
| 159 | + const next = (current_slide.value + 1) % total | ||
| 160 | + scroll_to_slide(next) | ||
| 161 | + }, 5000) | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +onMounted(async () => { | ||
| 165 | + await fetch_courses() | ||
| 166 | + start_auto_carousel() | ||
| 167 | + if (carouselRef.value) { | ||
| 168 | + carouselRef.value.addEventListener('scroll', handle_carousel_scroll, { passive: true }) | ||
| 169 | + } | ||
| 170 | +}) | ||
| 171 | + | ||
| 172 | +onUnmounted(() => { | ||
| 173 | + if (carousel_interval) clearInterval(carousel_interval) | ||
| 174 | + if (carouselRef.value) { | ||
| 175 | + carouselRef.value.removeEventListener('scroll', handle_carousel_scroll) | ||
| 176 | + } | ||
| 177 | +}) | ||
| 178 | +</script> |
| 1 | +<template> | ||
| 2 | + <section v-if="courses.length"> | ||
| 3 | + <div class="flex justify-between items-center mb-3"> | ||
| 4 | + <h3 class="font-medium">热门课程</h3> | ||
| 5 | + <router-link to="/courses" class="text-xs text-gray-500 flex items-center"> | ||
| 6 | + 更多 | ||
| 7 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 8 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> | ||
| 9 | + </svg> | ||
| 10 | + </router-link> | ||
| 11 | + </div> | ||
| 12 | + <div class="space-y-4"> | ||
| 13 | + <CourseCard | ||
| 14 | + v-for="course in courses" | ||
| 15 | + :key="course.id" | ||
| 16 | + :course="course" | ||
| 17 | + /> | ||
| 18 | + </div> | ||
| 19 | + </section> | ||
| 20 | +</template> | ||
| 21 | + | ||
| 22 | +<script setup> | ||
| 23 | +import { ref, onMounted } from 'vue' | ||
| 24 | +import CourseCard from '@/components/ui/CourseCard.vue' | ||
| 25 | +import { getCourseListAPI } from '@/api/course' | ||
| 26 | + | ||
| 27 | +const courses = ref([]) | ||
| 28 | + | ||
| 29 | +/** | ||
| 30 | + * @description 获取热门课程数据 | ||
| 31 | + * @returns {Promise<void>} | ||
| 32 | + */ | ||
| 33 | +const fetch_courses = async () => { | ||
| 34 | + const res = await getCourseListAPI({ | ||
| 35 | + sn: 'RMKC', | ||
| 36 | + limit: 8 | ||
| 37 | + }) | ||
| 38 | + if (res && res.code) { | ||
| 39 | + courses.value = Array.isArray(res.data) ? res.data : [] | ||
| 40 | + } else { | ||
| 41 | + courses.value = [] | ||
| 42 | + } | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +onMounted(async () => { | ||
| 46 | + await fetch_courses() | ||
| 47 | +}) | ||
| 48 | +</script> |
| 1 | +<template> | ||
| 2 | + <section v-if="activities.length" class="mb-7"> | ||
| 3 | + <div class="flex justify-between items-center mb-3"> | ||
| 4 | + <h3 class="font-medium">最新活动</h3> | ||
| 5 | + <a href="https://wxm.behalo.cc/pages/activity/activity" target="_blank" class="text-xs text-gray-500 flex items-center"> | ||
| 6 | + 更多 | ||
| 7 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 8 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> | ||
| 9 | + </svg> | ||
| 10 | + </a> | ||
| 11 | + </div> | ||
| 12 | + <div class="space-y-4"> | ||
| 13 | + <div v-for="activity in activities.slice(0, 4)" :key="activity.id"> | ||
| 14 | + <ActivityCard :activity="activity" /> | ||
| 15 | + </div> | ||
| 16 | + </div> | ||
| 17 | + </section> | ||
| 18 | +</template> | ||
| 19 | + | ||
| 20 | +<script setup> | ||
| 21 | +import { ref, onMounted } from 'vue' | ||
| 22 | +import ActivityCard from '@/components/ui/ActivityCard.vue' | ||
| 23 | + | ||
| 24 | +const activities = ref([]) | ||
| 25 | + | ||
| 26 | +/** | ||
| 27 | + * @description 将任意值安全转换为数字 | ||
| 28 | + * @param {any} v 原始值 | ||
| 29 | + * @returns {number|null} | ||
| 30 | + */ | ||
| 31 | +const number_or_null = (v) => { | ||
| 32 | + if (v === null || v === undefined || v === '') return null | ||
| 33 | + const n = Number(v) | ||
| 34 | + return isNaN(n) ? null : n | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +/** | ||
| 38 | + * @description 仅格式化为日期字符串(YYYY-MM-DD) | ||
| 39 | + * @param {Date} d 日期对象 | ||
| 40 | + * @returns {string} | ||
| 41 | + */ | ||
| 42 | +const format_date_only = (d) => { | ||
| 43 | + const y = d.getFullYear() | ||
| 44 | + const m = String(d.getMonth() + 1).padStart(2, '0') | ||
| 45 | + const day = String(d.getDate()).padStart(2, '0') | ||
| 46 | + return `${y}-${m}-${day}` | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +/** | ||
| 50 | + * @description 格式化活动时间区间 | ||
| 51 | + * @param {string} start_str 活动开始时间 | ||
| 52 | + * @param {string} end_str 活动结束时间 | ||
| 53 | + * @returns {string} | ||
| 54 | + */ | ||
| 55 | +const format_period = (start_str, end_str) => { | ||
| 56 | + if (!start_str || !end_str) return '' | ||
| 57 | + const start = new Date(start_str) | ||
| 58 | + const end = new Date(end_str) | ||
| 59 | + if (isNaN(start.getTime()) || isNaN(end.getTime())) return '' | ||
| 60 | + return `${format_date_only(start)} 至 ${format_date_only(end)}` | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +/** | ||
| 64 | + * @description 计算报名状态 | ||
| 65 | + * @param {string} stu_start_at 报名开始时间字符串 | ||
| 66 | + * @param {string} stu_end_at 报名结束时间字符串 | ||
| 67 | + * @param {Date} now 当前时间 | ||
| 68 | + * @param {string} act_start_at 活动开始时间 | ||
| 69 | + * @param {string} act_end_at 活动结束时间 | ||
| 70 | + * @returns {string} | ||
| 71 | + */ | ||
| 72 | +const compute_enroll_status = (stu_start_at, stu_end_at, now, act_start_at, act_end_at) => { | ||
| 73 | + if (stu_start_at && stu_end_at) { | ||
| 74 | + const start = new Date(stu_start_at) | ||
| 75 | + const end = new Date(stu_end_at) | ||
| 76 | + if (!isNaN(start.getTime()) && !isNaN(end.getTime())) { | ||
| 77 | + if (now >= start && now <= end) return '报名中' | ||
| 78 | + if (now < start) return '即将开始' | ||
| 79 | + return '已结束' | ||
| 80 | + } | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + if (act_start_at && act_end_at) { | ||
| 84 | + const a_start = new Date(act_start_at) | ||
| 85 | + const a_end = new Date(act_end_at) | ||
| 86 | + if (!isNaN(a_start.getTime()) && !isNaN(a_end.getTime())) { | ||
| 87 | + if (now < a_start) return '报名中' | ||
| 88 | + if (now >= a_start && now <= a_end) return '进行中' | ||
| 89 | + if (now > a_end) return '已结束' | ||
| 90 | + } | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + return '即将开始' | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +/** | ||
| 97 | + * @description 获取并处理外部活动数据 | ||
| 98 | + * @returns {Promise<void>} | ||
| 99 | + */ | ||
| 100 | +const fetch_external_activities = async () => { | ||
| 101 | + const url = 'https://bhapi.behalo.cc/api/get_act/?city_name=&pub_status=1&page_idx=1&page_size=300&search_option=4' | ||
| 102 | + try { | ||
| 103 | + const resp = await fetch(url, { method: 'GET' }) | ||
| 104 | + const json = await resp.json() | ||
| 105 | + | ||
| 106 | + const list = Array.isArray(json) | ||
| 107 | + ? json | ||
| 108 | + : Array.isArray(json?.data?.list) | ||
| 109 | + ? json.data.list | ||
| 110 | + : Array.isArray(json?.list) | ||
| 111 | + ? json.list | ||
| 112 | + : Array.isArray(json?.rows) | ||
| 113 | + ? json.rows | ||
| 114 | + : [] | ||
| 115 | + | ||
| 116 | + const now = new Date() | ||
| 117 | + const mapped = list.map((item) => { | ||
| 118 | + const xs_price = number_or_null(item?.xs_price) | ||
| 119 | + const sc_price = number_or_null(item?.sc_price) | ||
| 120 | + const imageUrl = item?.sl_img || item?.fx_img || item?.banner_img || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png' | ||
| 121 | + const period = format_period(item?.act_start_at, item?.act_end_at) | ||
| 122 | + | ||
| 123 | + const upper = number_or_null(item?.stu_num_upper) | ||
| 124 | + const gap = number_or_null(item?.bookgap_info?.stu_gap) | ||
| 125 | + const participantsCount = upper != null && gap != null ? Math.max(upper - gap, 0) : null | ||
| 126 | + | ||
| 127 | + const status = compute_enroll_status( | ||
| 128 | + item?.stu_start_at, | ||
| 129 | + item?.stu_end_at, | ||
| 130 | + now, | ||
| 131 | + item?.act_start_at, | ||
| 132 | + item?.act_end_at | ||
| 133 | + ) | ||
| 134 | + | ||
| 135 | + return { | ||
| 136 | + id: item?.act_id, | ||
| 137 | + title: item?.act_title || '', | ||
| 138 | + imageUrl, | ||
| 139 | + isHot: false, | ||
| 140 | + isFree: false, | ||
| 141 | + location: item?.act_address || item?.city_name || '', | ||
| 142 | + period, | ||
| 143 | + price: xs_price != null ? xs_price : '', | ||
| 144 | + originalPrice: sc_price != null && sc_price > 0 ? sc_price : '', | ||
| 145 | + participantsCount: participantsCount != null ? participantsCount : '', | ||
| 146 | + maxParticipants: upper != null ? upper : '', | ||
| 147 | + mock_link: 'https://wxm.behalo.cc/pages/activity/info?type=2&id=' + item?.act_id, | ||
| 148 | + status | ||
| 149 | + } | ||
| 150 | + }) | ||
| 151 | + | ||
| 152 | + activities.value = mapped.filter((a) => a.status === '报名中') | ||
| 153 | + } catch (err) { | ||
| 154 | + activities.value = [] | ||
| 155 | + } | ||
| 156 | +} | ||
| 157 | + | ||
| 158 | +onMounted(async () => { | ||
| 159 | + await fetch_external_activities() | ||
| 160 | +}) | ||
| 161 | +</script> |
| 1 | +<template> | ||
| 2 | + <section class="mb-7"> | ||
| 3 | + <div class="flex justify-between items-center mb-3"> | ||
| 4 | + <h3 class="font-medium">为您推荐</h3> | ||
| 5 | + <button | ||
| 6 | + class="text-xs text-gray-500 flex items-center" | ||
| 7 | + @click="refresh_recommendations" | ||
| 8 | + > | ||
| 9 | + 换一批 | ||
| 10 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 11 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> | ||
| 12 | + </svg> | ||
| 13 | + </button> | ||
| 14 | + </div> | ||
| 15 | + <div class="grid grid-cols-2 gap-4"> | ||
| 16 | + <FrostedGlass | ||
| 17 | + v-for="(item, index) in displayed_recommendations" | ||
| 18 | + :key="index" | ||
| 19 | + class="p-3 rounded-xl" | ||
| 20 | + > | ||
| 21 | + <div class="flex flex-col h-full" @click="go_to_course_detail(item)"> | ||
| 22 | + <div class="h-28 mb-2 rounded-lg overflow-hidden relative"> | ||
| 23 | + <img | ||
| 24 | + :src="item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" | ||
| 25 | + :alt="item.title" | ||
| 26 | + class="w-full h-full object-cover" | ||
| 27 | + /> | ||
| 28 | + <div | ||
| 29 | + v-if="item?.is_buy" | ||
| 30 | + class="absolute top-0 left-0 bg-orange-500 text-white text-xs px-2 py-1 rounded-br-lg font-medium" | ||
| 31 | + style="background-color: rgba(249, 115, 22, 0.85)" | ||
| 32 | + > | ||
| 33 | + 已购 | ||
| 34 | + </div> | ||
| 35 | + </div> | ||
| 36 | + <h4 class="font-medium text-sm mb-1 line-clamp-1">{{ item.title }}</h4> | ||
| 37 | + </div> | ||
| 38 | + </FrostedGlass> | ||
| 39 | + </div> | ||
| 40 | + </section> | ||
| 41 | +</template> | ||
| 42 | + | ||
| 43 | +<script setup> | ||
| 44 | +import { ref, onMounted } from 'vue' | ||
| 45 | +import { useRouter } from 'vue-router' | ||
| 46 | +import FrostedGlass from '@/components/ui/FrostedGlass.vue' | ||
| 47 | +import { getCourseListAPI } from '@/api/course' | ||
| 48 | + | ||
| 49 | +const router = useRouter() | ||
| 50 | + | ||
| 51 | +const all_recommendations = ref([]) | ||
| 52 | +const displayed_recommendations = ref([]) | ||
| 53 | + | ||
| 54 | +/** | ||
| 55 | + * @description 获取推荐列表(用于“为您推荐”模块) | ||
| 56 | + * @returns {Promise<void>} | ||
| 57 | + */ | ||
| 58 | +const fetch_recommendations = async () => { | ||
| 59 | + const res = await getCourseListAPI({ sn: 'RMKC' }) | ||
| 60 | + if (res && res.code) { | ||
| 61 | + all_recommendations.value = Array.isArray(res.data) ? res.data : [] | ||
| 62 | + displayed_recommendations.value = get_recommendations() | ||
| 63 | + } else { | ||
| 64 | + all_recommendations.value = [] | ||
| 65 | + displayed_recommendations.value = [] | ||
| 66 | + } | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +/** | ||
| 70 | + * @description 获取推荐内容(可随机) | ||
| 71 | + * @param {boolean} random 是否随机 | ||
| 72 | + * @returns {Array} 推荐列表 | ||
| 73 | + */ | ||
| 74 | +const get_recommendations = (random = false) => { | ||
| 75 | + if (!Array.isArray(all_recommendations.value)) return [] | ||
| 76 | + if (random) { | ||
| 77 | + const shuffled = [...all_recommendations.value].sort(() => 0.5 - Math.random()) | ||
| 78 | + return shuffled.slice(0, 4) | ||
| 79 | + } | ||
| 80 | + return all_recommendations.value.slice(0, 4) | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +/** | ||
| 84 | + * @description 换一批推荐 | ||
| 85 | + * @returns {void} | ||
| 86 | + */ | ||
| 87 | +const refresh_recommendations = () => { | ||
| 88 | + displayed_recommendations.value = get_recommendations(true) | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +/** | ||
| 92 | + * @description 跳转到课程详情 | ||
| 93 | + * @param {Object} course 课程对象 | ||
| 94 | + * @returns {void} | ||
| 95 | + */ | ||
| 96 | +const go_to_course_detail = (course) => { | ||
| 97 | + if (!course || !course.id) return | ||
| 98 | + router.push(`/courses/${course.id}`) | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +onMounted(async () => { | ||
| 102 | + await fetch_recommendations() | ||
| 103 | +}) | ||
| 104 | +</script> |
src/composables/useHomeVideoPlayer.js
0 → 100644
| 1 | +import { ref } from 'vue' | ||
| 2 | + | ||
| 3 | +export const useHomeVideoPlayer = () => { | ||
| 4 | + const activeVideoIndex = ref(null) | ||
| 5 | + | ||
| 6 | + const playVideo = (index) => { | ||
| 7 | + activeVideoIndex.value = index | ||
| 8 | + } | ||
| 9 | + | ||
| 10 | + const closeVideo = () => { | ||
| 11 | + activeVideoIndex.value = null | ||
| 12 | + } | ||
| 13 | + | ||
| 14 | + return { | ||
| 15 | + activeVideoIndex, | ||
| 16 | + playVideo, | ||
| 17 | + closeVideo | ||
| 18 | + } | ||
| 19 | +} |
This diff is collapsed. Click to expand it.
-
Please register or login to post a comment