feat(home): 重构首页组件并添加新功能模块
将首页功能拆分为独立组件,包括精选课程轮播、推荐课程、热门课程和最新活动模块 新增 useHomeVideoPlayer 组合式函数管理视频播放状态 优化代码结构和可维护性,减少主组件复杂度
Showing
7 changed files
with
546 additions
and
476 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 | +} |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2025-03-20 19:55:21 | 2 | * @Date: 2025-03-20 19:55:21 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-12-18 23:53:12 | 4 | + * @LastEditTime: 2025-12-27 23:41:35 |
| 5 | * @FilePath: /mlaj/src/views/HomePage.vue | 5 | * @FilePath: /mlaj/src/views/HomePage.vue |
| 6 | * @Description: 美乐爱觉教育首页组件 | 6 | * @Description: 美乐爱觉教育首页组件 |
| 7 | * | 7 | * |
| ... | @@ -58,7 +58,7 @@ | ... | @@ -58,7 +58,7 @@ |
| 58 | 58 | ||
| 59 | <!-- User Stats --> | 59 | <!-- User Stats --> |
| 60 | <div class="flex justify-between text-center py-2"> | 60 | <div class="flex justify-between text-center py-2"> |
| 61 | - <div class="border-r border-gray-200 flex-1"> | 61 | + <div class=" border-gray-200 flex-1"> |
| 62 | <div class="text-lg font-bold flex items-baseline justify-center"> | 62 | <div class="text-lg font-bold flex items-baseline justify-center"> |
| 63 | <span>{{ currentUser?.total_days || 0 }}</span> | 63 | <span>{{ currentUser?.total_days || 0 }}</span> |
| 64 | <span class="text-xs ml-1 font-normal">天</span> | 64 | <span class="text-xs ml-1 font-normal">天</span> |
| ... | @@ -135,84 +135,7 @@ | ... | @@ -135,84 +135,7 @@ |
| 135 | ]" /> | 135 | ]" /> |
| 136 | </div> --> | 136 | </div> --> |
| 137 | 137 | ||
| 138 | - <!-- Featured Courses Carousel --> | 138 | + <FeaturedCoursesSection /> |
| 139 | - <div v-if="goodCourses.length" class="mb-6"> | ||
| 140 | - <div class="px-4 mb-2"> | ||
| 141 | - <h3 class="font-medium">精选课程</h3> | ||
| 142 | - </div> | ||
| 143 | - <div class="relative"> | ||
| 144 | - <div | ||
| 145 | - ref="carouselRef" | ||
| 146 | - class="flex overflow-x-scroll snap-x snap-mandatory" | ||
| 147 | - style="scrollbar-width: none; -ms-overflow-style: none;" | ||
| 148 | - > | ||
| 149 | - <div | ||
| 150 | - v-for="(course, index) in goodCourses" | ||
| 151 | - :key="course.id" | ||
| 152 | - class="flex-shrink-0 w-full snap-center px-4" | ||
| 153 | - > | ||
| 154 | - <div class="relative rounded-xl overflow-hidden shadow-lg h-48"> | ||
| 155 | - <img | ||
| 156 | - :src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" | ||
| 157 | - :alt="course.title" | ||
| 158 | - class="w-full h-full object-cover" | ||
| 159 | - /> | ||
| 160 | - <!-- 已购标识 --> | ||
| 161 | - <div | ||
| 162 | - v-if="course.is_buy" | ||
| 163 | - class="absolute top-0 left-0 bg-orange-500 text-white text-xs px-2 py-1 rounded-br-lg font-medium" | ||
| 164 | - style="background-color: rgba(249, 115, 22, 0.85)" | ||
| 165 | - > | ||
| 166 | - 已购 | ||
| 167 | - </div> | ||
| 168 | - <div class="absolute inset-0 bg-gradient-to-b from-transparent via-black/20 to-black/60 flex flex-col justify-end p-4"> | ||
| 169 | - <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"> | ||
| 170 | - {{ course.category }} | ||
| 171 | - </div> | ||
| 172 | - <h2 class="text-2xl font-bold text-white drop-shadow-md">{{ course.title }}</h2> | ||
| 173 | - <p class="text-white/90 text-sm drop-shadow-sm mb-1">{{ course.subtitle }}</p> | ||
| 174 | - <div class="flex justify-between items-center"> | ||
| 175 | - <div class="flex items-center"> | ||
| 176 | - <div class="flex"> | ||
| 177 | - <svg | ||
| 178 | - v-for="i in 5" | ||
| 179 | - :key="i" | ||
| 180 | - xmlns="http://www.w3.org/2000/svg" | ||
| 181 | - :class="[`h-4 w-4`, i <= course.comment_score ? 'text-amber-400' : 'text-gray-300']" | ||
| 182 | - viewBox="0 0 20 20" | ||
| 183 | - fill="currentColor" | ||
| 184 | - > | ||
| 185 | - <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" /> | ||
| 186 | - </svg> | ||
| 187 | - </div> | ||
| 188 | - <span class="text-white text-xs ml-1">{{ course.comment_count }}人评</span> | ||
| 189 | - </div> | ||
| 190 | - <router-link | ||
| 191 | - :to="`/courses/${course.id}`" | ||
| 192 | - class="bg-white/90 text-green-600 px-3 py-1 rounded-full text-xs font-medium" | ||
| 193 | - > | ||
| 194 | - {{ course.price === '0.00' ? '免费学习' : '立即学习' }} | ||
| 195 | - </router-link> | ||
| 196 | - </div> | ||
| 197 | - </div> | ||
| 198 | - </div> | ||
| 199 | - </div> | ||
| 200 | - </div> | ||
| 201 | - | ||
| 202 | - <!-- Carousel Indicators --> | ||
| 203 | - <div class="flex justify-center mt-4"> | ||
| 204 | - <button | ||
| 205 | - v-for="(_, index) in goodCourses.slice(0, 4)" | ||
| 206 | - :key="index" | ||
| 207 | - @click="scrollToSlide(index)" | ||
| 208 | - :class="[ | ||
| 209 | - 'w-2 h-2 mx-1 rounded-full', | ||
| 210 | - currentSlide === index ? 'bg-green-600' : 'bg-gray-300' | ||
| 211 | - ]" | ||
| 212 | - /> | ||
| 213 | - </div> | ||
| 214 | - </div> | ||
| 215 | - </div> | ||
| 216 | 139 | ||
| 217 | <!-- Custom Tab Navigation --> | 140 | <!-- Custom Tab Navigation --> |
| 218 | <!-- <div class="sticky top-0 bg-white/70 backdrop-blur-lg" style="z-index: 9;"> | 141 | <!-- <div class="sticky top-0 bg-white/70 backdrop-blur-lg" style="z-index: 9;"> |
| ... | @@ -245,91 +168,9 @@ | ... | @@ -245,91 +168,9 @@ |
| 245 | <div class="px-4 mt-5" ref="contentRef"> | 168 | <div class="px-4 mt-5" ref="contentRef"> |
| 246 | <!-- Recommended Content --> | 169 | <!-- Recommended Content --> |
| 247 | <div v-if="activeTab === '推荐'"> | 170 | <div v-if="activeTab === '推荐'"> |
| 248 | - <!-- Personalized Recommendations --> | 171 | + <RecommendationsSection /> |
| 249 | - <section class="mb-7"> | 172 | + <LatestActivitiesSection /> |
| 250 | - <div class="flex justify-between items-center mb-3"> | 173 | + <HotCoursesSection /> |
| 251 | - <h3 class="font-medium">为您推荐</h3> | ||
| 252 | - <button | ||
| 253 | - class="text-xs text-gray-500 flex items-center" | ||
| 254 | - @click="displayedRecommendations = getRecommendations(true)" | ||
| 255 | - > | ||
| 256 | - 换一批 | ||
| 257 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 258 | - <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" /> | ||
| 259 | - </svg> | ||
| 260 | - </button> | ||
| 261 | - </div> | ||
| 262 | - <div class="grid grid-cols-2 gap-4"> | ||
| 263 | - <FrostedGlass | ||
| 264 | - v-for="(item, index) in displayedRecommendations" | ||
| 265 | - :key="index" | ||
| 266 | - class="p-3 rounded-xl" | ||
| 267 | - > | ||
| 268 | - <div class="flex flex-col h-full" @click="goToCourseDetail(item)"> | ||
| 269 | - <div class="h-28 mb-2 rounded-lg overflow-hidden relative"> | ||
| 270 | - <img | ||
| 271 | - :src="item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" | ||
| 272 | - :alt="item.title" | ||
| 273 | - class="w-full h-full object-cover" | ||
| 274 | - /> | ||
| 275 | - <!-- 已购标识 --> | ||
| 276 | - <div | ||
| 277 | - v-if="item?.is_buy" | ||
| 278 | - class="absolute top-0 left-0 bg-orange-500 text-white text-xs px-2 py-1 rounded-br-lg font-medium" | ||
| 279 | - style="background-color: rgba(249, 115, 22, 0.85)" | ||
| 280 | - > | ||
| 281 | - 已购 | ||
| 282 | - </div> | ||
| 283 | - </div> | ||
| 284 | - <h4 class="font-medium text-sm mb-1 line-clamp-1">{{ item.title }}</h4> | ||
| 285 | - <!--<p class="text-xs text-gray-500 flex items-center mt-auto"> | ||
| 286 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 287 | - <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" /> | ||
| 288 | - </svg> | ||
| 289 | - <!~~ {{ item.duration }} ~~> | ||
| 290 | - </p>--> | ||
| 291 | - </div> | ||
| 292 | - </FrostedGlass> | ||
| 293 | - </div> | ||
| 294 | - </section> | ||
| 295 | - | ||
| 296 | - <!-- Recent Activities --> | ||
| 297 | - <section class="mb-7"> | ||
| 298 | - <div class="flex justify-between items-center mb-3"> | ||
| 299 | - <h3 class="font-medium">最新活动</h3> | ||
| 300 | - <a href="https://wxm.behalo.cc/pages/activity/activity" target="_blank" class="text-xs text-gray-500 flex items-center"> | ||
| 301 | - 更多 | ||
| 302 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 303 | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> | ||
| 304 | - </svg> | ||
| 305 | - </a> | ||
| 306 | - </div> | ||
| 307 | - <div class="space-y-4"> | ||
| 308 | - <div v-for="activity in activities.slice(0, 4)" :key="activity.id"> | ||
| 309 | - <ActivityCard :activity="activity" /> | ||
| 310 | - </div> | ||
| 311 | - </div> | ||
| 312 | - </section> | ||
| 313 | - | ||
| 314 | - <!-- Popular Courses --> | ||
| 315 | - <section> | ||
| 316 | - <div class="flex justify-between items-center mb-3"> | ||
| 317 | - <h3 class="font-medium">热门课程</h3> | ||
| 318 | - <router-link to="/courses" class="text-xs text-gray-500 flex items-center"> | ||
| 319 | - 更多 | ||
| 320 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 321 | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> | ||
| 322 | - </svg> | ||
| 323 | - </router-link> | ||
| 324 | - </div> | ||
| 325 | - <div class="space-y-4"> | ||
| 326 | - <CourseCard | ||
| 327 | - v-for="course in hotCourses" | ||
| 328 | - :key="course.id" | ||
| 329 | - :course="course" | ||
| 330 | - /> | ||
| 331 | - </div> | ||
| 332 | - </section> | ||
| 333 | </div> | 174 | </div> |
| 334 | 175 | ||
| 335 | <!-- Live Content --> | 176 | <!-- Live Content --> |
| ... | @@ -469,7 +310,7 @@ | ... | @@ -469,7 +310,7 @@ |
| 469 | <VideoPlayer | 310 | <VideoPlayer |
| 470 | v-else | 311 | v-else |
| 471 | :video-url="item.video_url" | 312 | :video-url="item.video_url" |
| 472 | - ref="videoPlayerRefs" | 313 | + :video-id="`home_recommend_video_${index}`" |
| 473 | /> | 314 | /> |
| 474 | </div> | 315 | </div> |
| 475 | </div> | 316 | </div> |
| ... | @@ -483,42 +324,36 @@ | ... | @@ -483,42 +324,36 @@ |
| 483 | 324 | ||
| 484 | <script setup lang="jsx"> | 325 | <script setup lang="jsx"> |
| 485 | // 导入所需的Vue核心功能和组件 | 326 | // 导入所需的Vue核心功能和组件 |
| 486 | -import { ref, onMounted, onUnmounted, defineComponent, h, watch } from 'vue' | 327 | +import { ref, onMounted, defineComponent, h, watch, nextTick } from 'vue' |
| 487 | -import { useRoute, useRouter } from 'vue-router' | 328 | +import { useRoute } from 'vue-router' |
| 488 | 329 | ||
| 489 | // 导入布局和UI组件 | 330 | // 导入布局和UI组件 |
| 490 | import AppLayout from '@/components/layout/AppLayout.vue' | 331 | import AppLayout from '@/components/layout/AppLayout.vue' |
| 491 | import FrostedGlass from '@/components/ui/FrostedGlass.vue' | 332 | import FrostedGlass from '@/components/ui/FrostedGlass.vue' |
| 492 | -import CourseCard from '@/components/ui/CourseCard.vue' | ||
| 493 | import LiveStreamCard from '@/components/ui/LiveStreamCard.vue' | 333 | import LiveStreamCard from '@/components/ui/LiveStreamCard.vue' |
| 494 | -import ActivityCard from '@/components/ui/ActivityCard.vue' | ||
| 495 | -import SummerCampCard from '@/components/ui/SummerCampCard.vue' | ||
| 496 | import VideoPlayer from '@/components/ui/VideoPlayer.vue' | 334 | import VideoPlayer from '@/components/ui/VideoPlayer.vue' |
| 497 | import CheckInList from '@/components/ui/CheckInList.vue' | 335 | import CheckInList from '@/components/ui/CheckInList.vue' |
| 498 | 336 | ||
| 337 | +import FeaturedCoursesSection from '@/components/homePage/FeaturedCoursesSection.vue' | ||
| 338 | +import RecommendationsSection from '@/components/homePage/RecommendationsSection.vue' | ||
| 339 | +import LatestActivitiesSection from '@/components/homePage/LatestActivitiesSection.vue' | ||
| 340 | +import HotCoursesSection from '@/components/homePage/HotCoursesSection.vue' | ||
| 341 | + | ||
| 499 | // 导入模拟数据和工具函数 | 342 | // 导入模拟数据和工具函数 |
| 500 | import { liveStreams } from '@/utils/mockData' | 343 | import { liveStreams } from '@/utils/mockData' |
| 501 | import { useTitle } from '@vueuse/core' | 344 | import { useTitle } from '@vueuse/core' |
| 502 | import { useAuth } from '@/contexts/auth' | 345 | import { useAuth } from '@/contexts/auth' |
| 503 | import { showToast } from 'vant' | 346 | import { showToast } from 'vant' |
| 347 | +import { useHomeVideoPlayer } from '@/composables/useHomeVideoPlayer' | ||
| 504 | 348 | ||
| 505 | // 导入接口 | 349 | // 导入接口 |
| 506 | -import { getCourseListAPI } from "@/api/course"; | ||
| 507 | import { getTaskListAPI } from "@/api/checkin"; | 350 | import { getTaskListAPI } from "@/api/checkin"; |
| 508 | 351 | ||
| 509 | // 视频播放状态管理 | 352 | // 视频播放状态管理 |
| 510 | -const activeVideoIndex = ref(null); // 当前播放的视频索引 | 353 | +const { activeVideoIndex, playVideo } = useHomeVideoPlayer() |
| 511 | -const videoPlayerRefs = ref([]); // 视频播放器组件引用数组 | ||
| 512 | - | ||
| 513 | -// 播放视频处理函数 | ||
| 514 | -const playVideo = (index, videoUrl) => { | ||
| 515 | - // 更新当前播放的视频索引 | ||
| 516 | - activeVideoIndex.value = index; | ||
| 517 | -}; | ||
| 518 | 354 | ||
| 519 | // 路由相关 | 355 | // 路由相关 |
| 520 | const $route = useRoute() | 356 | const $route = useRoute() |
| 521 | -const $router = useRouter() | ||
| 522 | useTitle($route.meta.title) // 设置页面标题 | 357 | useTitle($route.meta.title) // 设置页面标题 |
| 523 | 358 | ||
| 524 | // 获取用户认证状态 | 359 | // 获取用户认证状态 |
| ... | @@ -526,132 +361,27 @@ const { currentUser } = useAuth() | ... | @@ -526,132 +361,27 @@ const { currentUser } = useAuth() |
| 526 | 361 | ||
| 527 | // 响应式状态管理 | 362 | // 响应式状态管理 |
| 528 | const activeTab = ref('推荐') // 当前激活的内容标签页 | 363 | const activeTab = ref('推荐') // 当前激活的内容标签页 |
| 529 | -// 已移除:选中项与提交逻辑由通用组件内部处理 | ||
| 530 | -const currentSlide = ref(0) // 当前轮播图索引 | ||
| 531 | -// const isCheckingIn = ref(false) | ||
| 532 | -// const checkInSuccess = ref(false) | ||
| 533 | -const displayedRecommendations = ref([]) // 当前显示的推荐内容 | ||
| 534 | - | ||
| 535 | -// | ||
| 536 | -const userRecommendations = ref([]) | ||
| 537 | -const hotCourses = ref([]) | ||
| 538 | -const goodCourses = ref([]) | ||
| 539 | -// 活动列表(从外部接口获取) | ||
| 540 | -const activities = ref([]) | ||
| 541 | -// 获取推荐内容 | ||
| 542 | -const getRecommendations = (random = false) => { | ||
| 543 | - if (random) { | ||
| 544 | - const shuffled = [...userRecommendations.value].sort(() => 0.5 - Math.random()) | ||
| 545 | - return shuffled.slice(0, 4) | ||
| 546 | - } | ||
| 547 | - return userRecommendations.value.slice(0, 4) | ||
| 548 | -} | ||
| 549 | 364 | ||
| 550 | // 签到列表 | 365 | // 签到列表 |
| 551 | const checkInTypes = ref([]); | 366 | const checkInTypes = ref([]); |
| 552 | 367 | ||
| 553 | -// 自动轮播 | 368 | +onMounted(() => { |
| 554 | -let carouselInterval | ||
| 555 | -onMounted(async () => { | ||
| 556 | - // 获取课程列表 | ||
| 557 | - const res = await getCourseListAPI({ sn: 'RMKC' }) | ||
| 558 | - if (res.code) { | ||
| 559 | - userRecommendations.value = res.data | ||
| 560 | - // 初始化显示推荐内容 | ||
| 561 | - displayedRecommendations.value = getRecommendations() | ||
| 562 | - } | ||
| 563 | - // 获取热门课程 | ||
| 564 | - const res2 = await getCourseListAPI({ | ||
| 565 | - sn: 'RMKC', | ||
| 566 | - limit: 8 | ||
| 567 | - }) | ||
| 568 | - if (res2.code) { | ||
| 569 | - hotCourses.value = res2.data | ||
| 570 | - } | ||
| 571 | - // 获取精选课程 | ||
| 572 | - const res3 = await getCourseListAPI({ | ||
| 573 | - sn: 'JXKC', | ||
| 574 | - limit: 4 | ||
| 575 | - }) | ||
| 576 | - if (res3.code) { | ||
| 577 | - goodCourses.value = res3.data | ||
| 578 | - carouselInterval = setInterval(() => { | ||
| 579 | - if (carouselRef.value) { | ||
| 580 | - const nextSlide = (currentSlide.value + 1) % goodCourses.value.slice(0, 4).length | ||
| 581 | - scrollToSlide(nextSlide) | ||
| 582 | - } | ||
| 583 | - }, 5000) | ||
| 584 | - } | ||
| 585 | - | ||
| 586 | - // 获取签到列表 | ||
| 587 | watch(() => currentUser.value, async (newVal) => { | 369 | watch(() => currentUser.value, async (newVal) => { |
| 588 | - if (newVal) { | 370 | + if (!newVal) return |
| 589 | - const task = await getTaskListAPI() | 371 | + const task = await getTaskListAPI() |
| 590 | - if (task.code) { | 372 | + if (task && task.code) { |
| 591 | - checkInTypes.value = [] | 373 | + checkInTypes.value = (task.data || []).map(item => ({ |
| 592 | - task.data.forEach(item => { | 374 | + id: item.id, |
| 593 | - checkInTypes.value.push({ | 375 | + name: item.title, |
| 594 | - id: item.id, | 376 | + task_type: item.task_type, |
| 595 | - name: item.title, | 377 | + is_gray: item.is_gray, |
| 596 | - task_type: item.task_type, | 378 | + is_finish: item.is_finish, |
| 597 | - is_gray: item.is_gray, | 379 | + checkin_subtask_id: item.checkin_subtask_id |
| 598 | - is_finish: item.is_finish, | 380 | + })) |
| 599 | - checkin_subtask_id: item.checkin_subtask_id | ||
| 600 | - }) | ||
| 601 | - }); | ||
| 602 | - } | ||
| 603 | } | 381 | } |
| 604 | }, { immediate: true }) | 382 | }, { immediate: true }) |
| 605 | - | ||
| 606 | - // 获取最新活动(外部接口) | ||
| 607 | - await fetchExternalActivities() | ||
| 608 | - | ||
| 609 | - // 监听轮播容器的滚动,手动滑动时同步底部指示器 | ||
| 610 | - if (carouselRef.value) { | ||
| 611 | - // 添加滚动监听(被动监听),在用户手动滑动时同步指示器 | ||
| 612 | - carouselRef.value.addEventListener('scroll', handle_carousel_scroll, { passive: true }) | ||
| 613 | - } | ||
| 614 | -}) | ||
| 615 | - | ||
| 616 | -onUnmounted(() => { | ||
| 617 | - if (carouselInterval) { | ||
| 618 | - clearInterval(carouselInterval) | ||
| 619 | - } | ||
| 620 | - // 卸载时移除轮播滚动监听,避免内存泄漏 | ||
| 621 | - if (carouselRef.value) { | ||
| 622 | - carouselRef.value.removeEventListener('scroll', handle_carousel_scroll) | ||
| 623 | - } | ||
| 624 | }) | 383 | }) |
| 625 | 384 | ||
| 626 | -const carouselRef = ref(null) // 轮播图容器引用 | ||
| 627 | - | ||
| 628 | -/** | ||
| 629 | - * @function sync_current_slide | ||
| 630 | - * @description 根据滚动位置同步当前轮播索引 | ||
| 631 | - * 注释:通过容器 scrollLeft 与容器宽度计算当前页,向最近页取整。 | ||
| 632 | - * @returns {void} | ||
| 633 | - */ | ||
| 634 | -const sync_current_slide = () => { | ||
| 635 | - const container = carouselRef.value | ||
| 636 | - const total = goodCourses.value.slice(0, 4).length | ||
| 637 | - if (!container || total === 0) return | ||
| 638 | - const slide_width = container.offsetWidth | ||
| 639 | - if (slide_width === 0) return | ||
| 640 | - const raw_index = container.scrollLeft / slide_width | ||
| 641 | - const idx = Math.round(raw_index) | ||
| 642 | - const bounded = Math.min(Math.max(idx, 0), total - 1) | ||
| 643 | - currentSlide.value = bounded | ||
| 644 | -} | ||
| 645 | - | ||
| 646 | -/** | ||
| 647 | - * @function handle_carousel_scroll | ||
| 648 | - * @description 轮播滚动事件处理(被动监听),调用同步函数更新指示器位置。 | ||
| 649 | - * @returns {void} | ||
| 650 | - */ | ||
| 651 | -const handle_carousel_scroll = () => { | ||
| 652 | - sync_current_slide() | ||
| 653 | -} | ||
| 654 | - | ||
| 655 | // 右侧导航组件:搜索和消息通知 | 385 | // 右侧导航组件:搜索和消息通知 |
| 656 | 386 | ||
| 657 | // 右侧内容组件 | 387 | // 右侧内容组件 |
| ... | @@ -689,18 +419,6 @@ const formatToday = () => { | ... | @@ -689,18 +419,6 @@ const formatToday = () => { |
| 689 | return today.toLocaleDateString('zh-CN', options) // 返回中文格式的日期 | 419 | return today.toLocaleDateString('zh-CN', options) // 返回中文格式的日期 |
| 690 | } | 420 | } |
| 691 | 421 | ||
| 692 | -// 轮播图控制:滚动到指定位置 | ||
| 693 | -const scrollToSlide = (index) => { | ||
| 694 | - if (carouselRef.value) { | ||
| 695 | - const slideWidth = carouselRef.value.offsetWidth // 获取轮播图容器宽度 | ||
| 696 | - carouselRef.value.scrollTo({ | ||
| 697 | - left: index * slideWidth, // 计算目标滚动位置 | ||
| 698 | - behavior: 'smooth' // 使用平滑滚动效果 | ||
| 699 | - }) | ||
| 700 | - currentSlide.value = index // 更新当前轮播图索引 | ||
| 701 | - } | ||
| 702 | -} | ||
| 703 | - | ||
| 704 | /** | 422 | /** |
| 705 | * @function handleHomeCheckInSuccess | 423 | * @function handleHomeCheckInSuccess |
| 706 | * @description 首页打卡成功后刷新签到任务列表,更新置灰状态,并给出轻提示。 | 424 | * @description 首页打卡成功后刷新签到任务列表,更新置灰状态,并给出轻提示。 |
| ... | @@ -729,176 +447,14 @@ const contentRef = ref(null) // 内容区域的ref引用 | ... | @@ -729,176 +447,14 @@ const contentRef = ref(null) // 内容区域的ref引用 |
| 729 | watch(activeTab, () => { | 447 | watch(activeTab, () => { |
| 730 | nextTick(() => { | 448 | nextTick(() => { |
| 731 | if (contentRef.value) { | 449 | if (contentRef.value) { |
| 732 | - const navHeight = document.querySelector('.sticky').offsetHeight; | 450 | + const sticky_el = document.querySelector('.sticky') |
| 733 | - const marginTop = parseInt(window.getComputedStyle(contentRef.value).marginTop); | 451 | + const navHeight = sticky_el ? sticky_el.offsetHeight : 0 |
| 452 | + const marginTop = parseInt(window.getComputedStyle(contentRef.value).marginTop) | ||
| 734 | window.scrollTo({ | 453 | window.scrollTo({ |
| 735 | top: contentRef.value.offsetTop - navHeight - marginTop, | 454 | top: contentRef.value.offsetTop - navHeight - marginTop, |
| 736 | behavior:'smooth' | 455 | behavior:'smooth' |
| 737 | - }); | 456 | + }) |
| 738 | } | 457 | } |
| 739 | }) | 458 | }) |
| 740 | }) | 459 | }) |
| 741 | - | ||
| 742 | -// 跳转到购买课程详情页 | ||
| 743 | -const goToCourseDetail = ({id}) => { | ||
| 744 | - $router.push(`/courses/${id}`) | ||
| 745 | -} | ||
| 746 | - | ||
| 747 | -/** | ||
| 748 | - * 获取并处理外部活动数据 | ||
| 749 | - * @returns {Promise<void>} 无返回值,更新 activities 响应式数据 | ||
| 750 | - * 注释:使用原生 fetch 请求外部接口,解析与映射为 ActivityCard 需要的结构,并筛选“报名中”状态。 | ||
| 751 | - */ | ||
| 752 | -const fetchExternalActivities = async () => { | ||
| 753 | - // 外部接口地址(不复用项目 axios 配置) | ||
| 754 | - const url = 'https://bhapi.behalo.cc/api/get_act/?city_name=&pub_status=1&page_idx=1&page_size=300&search_option=4' | ||
| 755 | - try { | ||
| 756 | - // 发起请求 | ||
| 757 | - const resp = await fetch(url, { method: 'GET' }) | ||
| 758 | - // 解析结果 | ||
| 759 | - const json = await resp.json() | ||
| 760 | - | ||
| 761 | - // 兼容不同返回结构,优先 data.list 数组 | ||
| 762 | - const list = Array.isArray(json) | ||
| 763 | - ? json | ||
| 764 | - : Array.isArray(json?.data?.list) | ||
| 765 | - ? json.data.list | ||
| 766 | - : Array.isArray(json?.list) | ||
| 767 | - ? json.list | ||
| 768 | - : Array.isArray(json?.rows) | ||
| 769 | - ? json.rows | ||
| 770 | - : [] | ||
| 771 | - | ||
| 772 | - // 当前时间 | ||
| 773 | - const now = new Date() | ||
| 774 | - | ||
| 775 | - // 映射到 ActivityCard 结构 | ||
| 776 | - const mapped = list.map((item) => { | ||
| 777 | - // 解析数值 | ||
| 778 | - const xs_price = numberOrNull(item?.xs_price) | ||
| 779 | - const sc_price = numberOrNull(item?.sc_price) | ||
| 780 | - | ||
| 781 | - // 图片优先级:sl_img > fx_img > banner_img | ||
| 782 | - const imageUrl = item?.sl_img || item?.fx_img || item?.banner_img || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png' | ||
| 783 | - | ||
| 784 | - // 时间区间字符串 | ||
| 785 | - const period = formatPeriod(item?.act_start_at, item?.act_end_at) | ||
| 786 | - | ||
| 787 | - // 参与人数与上限(根据 stu_num_upper 与 bookgap_info 计算) | ||
| 788 | - const upper = numberOrNull(item?.stu_num_upper) | ||
| 789 | - const gap = numberOrNull(item?.bookgap_info?.stu_gap) | ||
| 790 | - const participantsCount = upper != null && gap != null ? Math.max(upper - gap, 0) : null | ||
| 791 | - | ||
| 792 | - // 报名状态判断:优先使用报名起止;缺失则按活动起止回退 | ||
| 793 | - const enrollStatus = computeEnrollStatus( | ||
| 794 | - item?.stu_start_at, | ||
| 795 | - item?.stu_end_at, | ||
| 796 | - now, | ||
| 797 | - item?.act_start_at, | ||
| 798 | - item?.act_end_at | ||
| 799 | - ) | ||
| 800 | - | ||
| 801 | - return { | ||
| 802 | - id: item?.act_id, | ||
| 803 | - title: item?.act_title || '', | ||
| 804 | - imageUrl, | ||
| 805 | - isHot: false, | ||
| 806 | - // isFree: xs_price === 0 || sc_price === 0, | ||
| 807 | - isFree: false, | ||
| 808 | - location: item?.act_address || item?.city_name || '', | ||
| 809 | - period, | ||
| 810 | - price: xs_price != null ? xs_price : '', | ||
| 811 | - originalPrice: sc_price != null && sc_price > 0 ? sc_price : '', | ||
| 812 | - participantsCount: participantsCount != null ? participantsCount : '', | ||
| 813 | - maxParticipants: upper != null ? upper : '', | ||
| 814 | - mock_link: 'https://wxm.behalo.cc/pages/activity/info?type=2&id=' + item?.act_id, | ||
| 815 | - status: enrollStatus | ||
| 816 | - } | ||
| 817 | - }) | ||
| 818 | - | ||
| 819 | - // 仅保留“报名中”的数据 | ||
| 820 | - activities.value = mapped.filter((a) => a.status === '报名中') | ||
| 821 | - } catch (err) { | ||
| 822 | - console.error('获取外部活动数据失败:', err) | ||
| 823 | - // 失败时回退为空数组,避免页面报错 | ||
| 824 | - activities.value = [] | ||
| 825 | - } | ||
| 826 | -} | ||
| 827 | - | ||
| 828 | -/** | ||
| 829 | - * 计算报名状态 | ||
| 830 | - * @param {string} stu_start_at 报名开始时间字符串 | ||
| 831 | - * @param {string} stu_end_at 报名结束时间字符串 | ||
| 832 | - * @param {Date} now 当前时间 | ||
| 833 | - * @param {string} act_start_at 活动开始时间 | ||
| 834 | - * @param {string} act_end_at 活动结束时间 | ||
| 835 | - * @returns {string} 状态字符串:报名中 / 即将开始 / 进行中 / 已结束 | ||
| 836 | - * 注释:优先以报名起止判断“报名中”;若缺失则按活动起止时间回退:未开始视为“报名中”,进行中为“进行中”。 | ||
| 837 | - */ | ||
| 838 | -const computeEnrollStatus = (stu_start_at, stu_end_at, now, act_start_at, act_end_at) => { | ||
| 839 | - // 优先使用报名时间窗口 | ||
| 840 | - if (stu_start_at && stu_end_at) { | ||
| 841 | - const start = new Date(stu_start_at) | ||
| 842 | - const end = new Date(stu_end_at) | ||
| 843 | - if (!isNaN(start.getTime()) && !isNaN(end.getTime())) { | ||
| 844 | - if (now >= start && now <= end) return '报名中' | ||
| 845 | - if (now < start) return '即将开始' | ||
| 846 | - return '已结束' | ||
| 847 | - } | ||
| 848 | - } | ||
| 849 | - | ||
| 850 | - // 回退:使用活动时间窗口 | ||
| 851 | - if (act_start_at && act_end_at) { | ||
| 852 | - const aStart = new Date(act_start_at) | ||
| 853 | - const aEnd = new Date(act_end_at) | ||
| 854 | - if (!isNaN(aStart.getTime()) && !isNaN(aEnd.getTime())) { | ||
| 855 | - if (now < aStart) return '报名中' // 活动未开始,视为报名中 | ||
| 856 | - if (now >= aStart && now <= aEnd) return '进行中' | ||
| 857 | - if (now > aEnd) return '已结束' | ||
| 858 | - } | ||
| 859 | - } | ||
| 860 | - | ||
| 861 | - // 默认回退 | ||
| 862 | - return '即将开始' | ||
| 863 | -} | ||
| 864 | - | ||
| 865 | -/** | ||
| 866 | - * 格式化活动时间区间 | ||
| 867 | - * @param {string} startStr 活动开始时间 | ||
| 868 | - * @param {string} endStr 活动结束时间 | ||
| 869 | - * @returns {string} 例如:2025-11-09 至 2025-12-20 | ||
| 870 | - * 注释:展示活动期信息;若解析失败则返回空串。 | ||
| 871 | - */ | ||
| 872 | -const formatPeriod = (startStr, endStr) => { | ||
| 873 | - if (!startStr || !endStr) return '' | ||
| 874 | - const start = new Date(startStr) | ||
| 875 | - const end = new Date(endStr) | ||
| 876 | - if (isNaN(start.getTime()) || isNaN(end.getTime())) return '' | ||
| 877 | - const s = formatDateOnly(start) | ||
| 878 | - const e = formatDateOnly(end) | ||
| 879 | - return `${s} 至 ${e}` | ||
| 880 | -} | ||
| 881 | - | ||
| 882 | -/** | ||
| 883 | - * 仅格式化为日期字符串(YYYY-MM-DD) | ||
| 884 | - * @param {Date} d 日期对象 | ||
| 885 | - * @returns {string} 日期字符串 | ||
| 886 | - */ | ||
| 887 | -const formatDateOnly = (d) => { | ||
| 888 | - const y = d.getFullYear() | ||
| 889 | - const m = String(d.getMonth() + 1).padStart(2, '0') | ||
| 890 | - const day = String(d.getDate()).padStart(2, '0') | ||
| 891 | - return `${y}-${m}-${day}` | ||
| 892 | -} | ||
| 893 | - | ||
| 894 | -/** | ||
| 895 | - * 将任意值安全转换为数字 | ||
| 896 | - * @param {any} v 原始值 | ||
| 897 | - * @returns {number|null} 数字或空 | ||
| 898 | - */ | ||
| 899 | -const numberOrNull = (v) => { | ||
| 900 | - if (v === null || v === undefined || v === '') return null | ||
| 901 | - const n = Number(v) | ||
| 902 | - return isNaN(n) ? null : n | ||
| 903 | -} | ||
| 904 | </script> | 460 | </script> | ... | ... |
-
Please register or login to post a comment