hookehuyr

feat(home): 重构首页组件并添加新功能模块

将首页功能拆分为独立组件,包括精选课程轮播、推荐课程、热门课程和最新活动模块
新增 useHomeVideoPlayer 组合式函数管理视频播放状态
优化代码结构和可维护性,减少主组件复杂度
......@@ -23,9 +23,12 @@ declare module 'vue' {
CourseGroupCascader: typeof import('./components/ui/CourseGroupCascader.vue')['default']
CourseImageCard: typeof import('./components/ui/CourseImageCard.vue')['default']
CourseList: typeof import('./components/courses/CourseList.vue')['default']
FeaturedCoursesSection: typeof import('./components/homePage/FeaturedCoursesSection.vue')['default']
FormPage: typeof import('./components/infoEntry/formPage.vue')['default']
FrostedGlass: typeof import('./components/ui/FrostedGlass.vue')['default']
GradientHeader: typeof import('./components/ui/GradientHeader.vue')['default']
HotCoursesSection: typeof import('./components/homePage/HotCoursesSection.vue')['default']
LatestActivitiesSection: typeof import('./components/homePage/LatestActivitiesSection.vue')['default']
LiveStreamCard: typeof import('./components/ui/LiveStreamCard.vue')['default']
MenuItem: typeof import('./components/ui/MenuItem.vue')['default']
OfficeViewer: typeof import('./components/ui/OfficeViewer.vue')['default']
......@@ -33,6 +36,7 @@ declare module 'vue' {
PdfViewer: typeof import('./components/ui/PdfViewer.vue')['default']
PostCountModel: typeof import('./components/count/postCountModel.vue')['default']
RecallPoster: typeof import('./components/ui/RecallPoster.vue')['default']
RecommendationsSection: typeof import('./components/homePage/RecommendationsSection.vue')['default']
ReviewPopup: typeof import('./components/courses/ReviewPopup.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
......
<template>
<div v-if="courses.length" class="mb-6">
<div class="px-4 mb-2">
<h3 class="font-medium">精选课程</h3>
</div>
<div class="relative">
<div
ref="carouselRef"
class="flex overflow-x-scroll snap-x snap-mandatory"
style="scrollbar-width: none; -ms-overflow-style: none;"
>
<div
v-for="(course, index) in courses"
:key="course.id"
class="flex-shrink-0 w-full snap-center px-4"
>
<div class="relative rounded-xl overflow-hidden shadow-lg h-48">
<img
:src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
:alt="course.title"
class="w-full h-full object-cover"
/>
<div
v-if="course.is_buy"
class="absolute top-0 left-0 bg-orange-500 text-white text-xs px-2 py-1 rounded-br-lg font-medium"
style="background-color: rgba(249, 115, 22, 0.85)"
>
已购
</div>
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-black/20 to-black/60 flex flex-col justify-end p-4">
<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">
{{ course.category }}
</div>
<h2 class="text-2xl font-bold text-white drop-shadow-md">{{ course.title }}</h2>
<p class="text-white/90 text-sm drop-shadow-sm mb-1">{{ course.subtitle }}</p>
<div class="flex justify-between items-center">
<div class="flex items-center">
<div class="flex">
<svg
v-for="i in 5"
:key="i"
xmlns="http://www.w3.org/2000/svg"
:class="[`h-4 w-4`, i <= course.comment_score ? 'text-amber-400' : 'text-gray-300']"
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" />
</svg>
</div>
<span class="text-white text-xs ml-1">{{ course.comment_count }}人评</span>
</div>
<router-link
:to="`/courses/${course.id}`"
class="bg-white/90 text-green-600 px-3 py-1 rounded-full text-xs font-medium"
>
{{ course.price === '0.00' ? '免费学习' : '立即学习' }}
</router-link>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-center mt-4">
<button
v-for="(_, index) in courses.slice(0, 4)"
:key="index"
@click="scroll_to_slide(index)"
:class="[
'w-2 h-2 mx-1 rounded-full',
current_slide === index ? 'bg-green-600' : 'bg-gray-300'
]"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { getCourseListAPI } from '@/api/course'
const courses = ref([])
const carouselRef = ref(null)
const current_slide = ref(0)
let carousel_interval
/**
* @description 获取精选课程数据
* @returns {Promise<void>}
*/
const fetch_courses = async () => {
const res = await getCourseListAPI({
sn: 'JXKC',
limit: 4
})
if (res && res.code) {
courses.value = Array.isArray(res.data) ? res.data : []
} else {
courses.value = []
}
}
/**
* @description 根据滚动位置同步当前轮播索引
* @returns {void}
*/
const sync_current_slide = () => {
const container = carouselRef.value
const total = courses.value.slice(0, 4).length
if (!container || total === 0) return
const slide_width = container.offsetWidth
if (!slide_width) return
const raw_index = container.scrollLeft / slide_width
const idx = Math.round(raw_index)
const bounded = Math.min(Math.max(idx, 0), total - 1)
current_slide.value = bounded
}
/**
* @description 轮播滚动事件处理(被动监听)
* @returns {void}
*/
const handle_carousel_scroll = () => {
sync_current_slide()
}
/**
* @description 轮播图控制:滚动到指定位置
* @param {number} index 轮播索引
* @returns {void}
*/
const scroll_to_slide = (index) => {
const container = carouselRef.value
if (!container) return
const slide_width = container.offsetWidth
container.scrollTo({
left: index * slide_width,
behavior: 'smooth'
})
current_slide.value = index
}
/**
* @description 启动自动轮播
* @returns {void}
*/
const start_auto_carousel = () => {
if (carousel_interval) clearInterval(carousel_interval)
if (!courses.value.length) return
carousel_interval = setInterval(() => {
if (!carouselRef.value) return
const total = courses.value.slice(0, 4).length
if (!total) return
const next = (current_slide.value + 1) % total
scroll_to_slide(next)
}, 5000)
}
onMounted(async () => {
await fetch_courses()
start_auto_carousel()
if (carouselRef.value) {
carouselRef.value.addEventListener('scroll', handle_carousel_scroll, { passive: true })
}
})
onUnmounted(() => {
if (carousel_interval) clearInterval(carousel_interval)
if (carouselRef.value) {
carouselRef.value.removeEventListener('scroll', handle_carousel_scroll)
}
})
</script>
<template>
<section v-if="courses.length">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">热门课程</h3>
<router-link to="/courses" class="text-xs text-gray-500 flex items-center">
更多
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</router-link>
</div>
<div class="space-y-4">
<CourseCard
v-for="course in courses"
:key="course.id"
:course="course"
/>
</div>
</section>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import CourseCard from '@/components/ui/CourseCard.vue'
import { getCourseListAPI } from '@/api/course'
const courses = ref([])
/**
* @description 获取热门课程数据
* @returns {Promise<void>}
*/
const fetch_courses = async () => {
const res = await getCourseListAPI({
sn: 'RMKC',
limit: 8
})
if (res && res.code) {
courses.value = Array.isArray(res.data) ? res.data : []
} else {
courses.value = []
}
}
onMounted(async () => {
await fetch_courses()
})
</script>
<template>
<section v-if="activities.length" class="mb-7">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">最新活动</h3>
<a href="https://wxm.behalo.cc/pages/activity/activity" target="_blank" class="text-xs text-gray-500 flex items-center">
更多
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
<div class="space-y-4">
<div v-for="activity in activities.slice(0, 4)" :key="activity.id">
<ActivityCard :activity="activity" />
</div>
</div>
</section>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import ActivityCard from '@/components/ui/ActivityCard.vue'
const activities = ref([])
/**
* @description 将任意值安全转换为数字
* @param {any} v 原始值
* @returns {number|null}
*/
const number_or_null = (v) => {
if (v === null || v === undefined || v === '') return null
const n = Number(v)
return isNaN(n) ? null : n
}
/**
* @description 仅格式化为日期字符串(YYYY-MM-DD)
* @param {Date} d 日期对象
* @returns {string}
*/
const format_date_only = (d) => {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/**
* @description 格式化活动时间区间
* @param {string} start_str 活动开始时间
* @param {string} end_str 活动结束时间
* @returns {string}
*/
const format_period = (start_str, end_str) => {
if (!start_str || !end_str) return ''
const start = new Date(start_str)
const end = new Date(end_str)
if (isNaN(start.getTime()) || isNaN(end.getTime())) return ''
return `${format_date_only(start)} 至 ${format_date_only(end)}`
}
/**
* @description 计算报名状态
* @param {string} stu_start_at 报名开始时间字符串
* @param {string} stu_end_at 报名结束时间字符串
* @param {Date} now 当前时间
* @param {string} act_start_at 活动开始时间
* @param {string} act_end_at 活动结束时间
* @returns {string}
*/
const compute_enroll_status = (stu_start_at, stu_end_at, now, act_start_at, act_end_at) => {
if (stu_start_at && stu_end_at) {
const start = new Date(stu_start_at)
const end = new Date(stu_end_at)
if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
if (now >= start && now <= end) return '报名中'
if (now < start) return '即将开始'
return '已结束'
}
}
if (act_start_at && act_end_at) {
const a_start = new Date(act_start_at)
const a_end = new Date(act_end_at)
if (!isNaN(a_start.getTime()) && !isNaN(a_end.getTime())) {
if (now < a_start) return '报名中'
if (now >= a_start && now <= a_end) return '进行中'
if (now > a_end) return '已结束'
}
}
return '即将开始'
}
/**
* @description 获取并处理外部活动数据
* @returns {Promise<void>}
*/
const fetch_external_activities = async () => {
const url = 'https://bhapi.behalo.cc/api/get_act/?city_name=&pub_status=1&page_idx=1&page_size=300&search_option=4'
try {
const resp = await fetch(url, { method: 'GET' })
const json = await resp.json()
const list = Array.isArray(json)
? json
: Array.isArray(json?.data?.list)
? json.data.list
: Array.isArray(json?.list)
? json.list
: Array.isArray(json?.rows)
? json.rows
: []
const now = new Date()
const mapped = list.map((item) => {
const xs_price = number_or_null(item?.xs_price)
const sc_price = number_or_null(item?.sc_price)
const imageUrl = item?.sl_img || item?.fx_img || item?.banner_img || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'
const period = format_period(item?.act_start_at, item?.act_end_at)
const upper = number_or_null(item?.stu_num_upper)
const gap = number_or_null(item?.bookgap_info?.stu_gap)
const participantsCount = upper != null && gap != null ? Math.max(upper - gap, 0) : null
const status = compute_enroll_status(
item?.stu_start_at,
item?.stu_end_at,
now,
item?.act_start_at,
item?.act_end_at
)
return {
id: item?.act_id,
title: item?.act_title || '',
imageUrl,
isHot: false,
isFree: false,
location: item?.act_address || item?.city_name || '',
period,
price: xs_price != null ? xs_price : '',
originalPrice: sc_price != null && sc_price > 0 ? sc_price : '',
participantsCount: participantsCount != null ? participantsCount : '',
maxParticipants: upper != null ? upper : '',
mock_link: 'https://wxm.behalo.cc/pages/activity/info?type=2&id=' + item?.act_id,
status
}
})
activities.value = mapped.filter((a) => a.status === '报名中')
} catch (err) {
activities.value = []
}
}
onMounted(async () => {
await fetch_external_activities()
})
</script>
<template>
<section class="mb-7">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">为您推荐</h3>
<button
class="text-xs text-gray-500 flex items-center"
@click="refresh_recommendations"
>
换一批
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
</button>
</div>
<div class="grid grid-cols-2 gap-4">
<FrostedGlass
v-for="(item, index) in displayed_recommendations"
:key="index"
class="p-3 rounded-xl"
>
<div class="flex flex-col h-full" @click="go_to_course_detail(item)">
<div class="h-28 mb-2 rounded-lg overflow-hidden relative">
<img
:src="item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
:alt="item.title"
class="w-full h-full object-cover"
/>
<div
v-if="item?.is_buy"
class="absolute top-0 left-0 bg-orange-500 text-white text-xs px-2 py-1 rounded-br-lg font-medium"
style="background-color: rgba(249, 115, 22, 0.85)"
>
已购
</div>
</div>
<h4 class="font-medium text-sm mb-1 line-clamp-1">{{ item.title }}</h4>
</div>
</FrostedGlass>
</div>
</section>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import FrostedGlass from '@/components/ui/FrostedGlass.vue'
import { getCourseListAPI } from '@/api/course'
const router = useRouter()
const all_recommendations = ref([])
const displayed_recommendations = ref([])
/**
* @description 获取推荐列表(用于“为您推荐”模块)
* @returns {Promise<void>}
*/
const fetch_recommendations = async () => {
const res = await getCourseListAPI({ sn: 'RMKC' })
if (res && res.code) {
all_recommendations.value = Array.isArray(res.data) ? res.data : []
displayed_recommendations.value = get_recommendations()
} else {
all_recommendations.value = []
displayed_recommendations.value = []
}
}
/**
* @description 获取推荐内容(可随机)
* @param {boolean} random 是否随机
* @returns {Array} 推荐列表
*/
const get_recommendations = (random = false) => {
if (!Array.isArray(all_recommendations.value)) return []
if (random) {
const shuffled = [...all_recommendations.value].sort(() => 0.5 - Math.random())
return shuffled.slice(0, 4)
}
return all_recommendations.value.slice(0, 4)
}
/**
* @description 换一批推荐
* @returns {void}
*/
const refresh_recommendations = () => {
displayed_recommendations.value = get_recommendations(true)
}
/**
* @description 跳转到课程详情
* @param {Object} course 课程对象
* @returns {void}
*/
const go_to_course_detail = (course) => {
if (!course || !course.id) return
router.push(`/courses/${course.id}`)
}
onMounted(async () => {
await fetch_recommendations()
})
</script>
import { ref } from 'vue'
export const useHomeVideoPlayer = () => {
const activeVideoIndex = ref(null)
const playVideo = (index) => {
activeVideoIndex.value = index
}
const closeVideo = () => {
activeVideoIndex.value = null
}
return {
activeVideoIndex,
playVideo,
closeVideo
}
}
<!--
* @Date: 2025-03-20 19:55:21
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-12-18 23:53:12
* @LastEditTime: 2025-12-27 23:41:35
* @FilePath: /mlaj/src/views/HomePage.vue
* @Description: 美乐爱觉教育首页组件
*
......@@ -58,7 +58,7 @@
<!-- User Stats -->
<div class="flex justify-between text-center py-2">
<div class="border-r border-gray-200 flex-1">
<div class=" border-gray-200 flex-1">
<div class="text-lg font-bold flex items-baseline justify-center">
<span>{{ currentUser?.total_days || 0 }}</span>
<span class="text-xs ml-1 font-normal">天</span>
......@@ -135,84 +135,7 @@
]" />
</div> -->
<!-- Featured Courses Carousel -->
<div v-if="goodCourses.length" class="mb-6">
<div class="px-4 mb-2">
<h3 class="font-medium">精选课程</h3>
</div>
<div class="relative">
<div
ref="carouselRef"
class="flex overflow-x-scroll snap-x snap-mandatory"
style="scrollbar-width: none; -ms-overflow-style: none;"
>
<div
v-for="(course, index) in goodCourses"
:key="course.id"
class="flex-shrink-0 w-full snap-center px-4"
>
<div class="relative rounded-xl overflow-hidden shadow-lg h-48">
<img
:src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
:alt="course.title"
class="w-full h-full object-cover"
/>
<!-- 已购标识 -->
<div
v-if="course.is_buy"
class="absolute top-0 left-0 bg-orange-500 text-white text-xs px-2 py-1 rounded-br-lg font-medium"
style="background-color: rgba(249, 115, 22, 0.85)"
>
已购
</div>
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-black/20 to-black/60 flex flex-col justify-end p-4">
<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">
{{ course.category }}
</div>
<h2 class="text-2xl font-bold text-white drop-shadow-md">{{ course.title }}</h2>
<p class="text-white/90 text-sm drop-shadow-sm mb-1">{{ course.subtitle }}</p>
<div class="flex justify-between items-center">
<div class="flex items-center">
<div class="flex">
<svg
v-for="i in 5"
:key="i"
xmlns="http://www.w3.org/2000/svg"
:class="[`h-4 w-4`, i <= course.comment_score ? 'text-amber-400' : 'text-gray-300']"
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" />
</svg>
</div>
<span class="text-white text-xs ml-1">{{ course.comment_count }}人评</span>
</div>
<router-link
:to="`/courses/${course.id}`"
class="bg-white/90 text-green-600 px-3 py-1 rounded-full text-xs font-medium"
>
{{ course.price === '0.00' ? '免费学习' : '立即学习' }}
</router-link>
</div>
</div>
</div>
</div>
</div>
<!-- Carousel Indicators -->
<div class="flex justify-center mt-4">
<button
v-for="(_, index) in goodCourses.slice(0, 4)"
:key="index"
@click="scrollToSlide(index)"
:class="[
'w-2 h-2 mx-1 rounded-full',
currentSlide === index ? 'bg-green-600' : 'bg-gray-300'
]"
/>
</div>
</div>
</div>
<FeaturedCoursesSection />
<!-- Custom Tab Navigation -->
<!-- <div class="sticky top-0 bg-white/70 backdrop-blur-lg" style="z-index: 9;">
......@@ -245,91 +168,9 @@
<div class="px-4 mt-5" ref="contentRef">
<!-- Recommended Content -->
<div v-if="activeTab === '推荐'">
<!-- Personalized Recommendations -->
<section class="mb-7">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">为您推荐</h3>
<button
class="text-xs text-gray-500 flex items-center"
@click="displayedRecommendations = getRecommendations(true)"
>
换一批
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
</button>
</div>
<div class="grid grid-cols-2 gap-4">
<FrostedGlass
v-for="(item, index) in displayedRecommendations"
:key="index"
class="p-3 rounded-xl"
>
<div class="flex flex-col h-full" @click="goToCourseDetail(item)">
<div class="h-28 mb-2 rounded-lg overflow-hidden relative">
<img
:src="item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
:alt="item.title"
class="w-full h-full object-cover"
/>
<!-- 已购标识 -->
<div
v-if="item?.is_buy"
class="absolute top-0 left-0 bg-orange-500 text-white text-xs px-2 py-1 rounded-br-lg font-medium"
style="background-color: rgba(249, 115, 22, 0.85)"
>
已购
</div>
</div>
<h4 class="font-medium text-sm mb-1 line-clamp-1">{{ item.title }}</h4>
<!--<p class="text-xs text-gray-500 flex items-center mt-auto">
<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 }} ~~>
</p>-->
</div>
</FrostedGlass>
</div>
</section>
<!-- Recent Activities -->
<section class="mb-7">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">最新活动</h3>
<a href="https://wxm.behalo.cc/pages/activity/activity" target="_blank" class="text-xs text-gray-500 flex items-center">
更多
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
<div class="space-y-4">
<div v-for="activity in activities.slice(0, 4)" :key="activity.id">
<ActivityCard :activity="activity" />
</div>
</div>
</section>
<!-- Popular Courses -->
<section>
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">热门课程</h3>
<router-link to="/courses" class="text-xs text-gray-500 flex items-center">
更多
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</router-link>
</div>
<div class="space-y-4">
<CourseCard
v-for="course in hotCourses"
:key="course.id"
:course="course"
/>
</div>
</section>
<RecommendationsSection />
<LatestActivitiesSection />
<HotCoursesSection />
</div>
<!-- Live Content -->
......@@ -469,7 +310,7 @@
<VideoPlayer
v-else
:video-url="item.video_url"
ref="videoPlayerRefs"
:video-id="`home_recommend_video_${index}`"
/>
</div>
</div>
......@@ -483,42 +324,36 @@
<script setup lang="jsx">
// 导入所需的Vue核心功能和组件
import { ref, onMounted, onUnmounted, defineComponent, h, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, onMounted, defineComponent, h, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
// 导入布局和UI组件
import AppLayout from '@/components/layout/AppLayout.vue'
import FrostedGlass from '@/components/ui/FrostedGlass.vue'
import CourseCard from '@/components/ui/CourseCard.vue'
import LiveStreamCard from '@/components/ui/LiveStreamCard.vue'
import ActivityCard from '@/components/ui/ActivityCard.vue'
import SummerCampCard from '@/components/ui/SummerCampCard.vue'
import VideoPlayer from '@/components/ui/VideoPlayer.vue'
import CheckInList from '@/components/ui/CheckInList.vue'
import FeaturedCoursesSection from '@/components/homePage/FeaturedCoursesSection.vue'
import RecommendationsSection from '@/components/homePage/RecommendationsSection.vue'
import LatestActivitiesSection from '@/components/homePage/LatestActivitiesSection.vue'
import HotCoursesSection from '@/components/homePage/HotCoursesSection.vue'
// 导入模拟数据和工具函数
import { liveStreams } from '@/utils/mockData'
import { useTitle } from '@vueuse/core'
import { useAuth } from '@/contexts/auth'
import { showToast } from 'vant'
import { useHomeVideoPlayer } from '@/composables/useHomeVideoPlayer'
// 导入接口
import { getCourseListAPI } from "@/api/course";
import { getTaskListAPI } from "@/api/checkin";
// 视频播放状态管理
const activeVideoIndex = ref(null); // 当前播放的视频索引
const videoPlayerRefs = ref([]); // 视频播放器组件引用数组
// 播放视频处理函数
const playVideo = (index, videoUrl) => {
// 更新当前播放的视频索引
activeVideoIndex.value = index;
};
const { activeVideoIndex, playVideo } = useHomeVideoPlayer()
// 路由相关
const $route = useRoute()
const $router = useRouter()
useTitle($route.meta.title) // 设置页面标题
// 获取用户认证状态
......@@ -526,132 +361,27 @@ const { currentUser } = useAuth()
// 响应式状态管理
const activeTab = ref('推荐') // 当前激活的内容标签页
// 已移除:选中项与提交逻辑由通用组件内部处理
const currentSlide = ref(0) // 当前轮播图索引
// const isCheckingIn = ref(false)
// const checkInSuccess = ref(false)
const displayedRecommendations = ref([]) // 当前显示的推荐内容
//
const userRecommendations = ref([])
const hotCourses = ref([])
const goodCourses = ref([])
// 活动列表(从外部接口获取)
const activities = ref([])
// 获取推荐内容
const getRecommendations = (random = false) => {
if (random) {
const shuffled = [...userRecommendations.value].sort(() => 0.5 - Math.random())
return shuffled.slice(0, 4)
}
return userRecommendations.value.slice(0, 4)
}
// 签到列表
const checkInTypes = ref([]);
// 自动轮播
let carouselInterval
onMounted(async () => {
// 获取课程列表
const res = await getCourseListAPI({ sn: 'RMKC' })
if (res.code) {
userRecommendations.value = res.data
// 初始化显示推荐内容
displayedRecommendations.value = getRecommendations()
}
// 获取热门课程
const res2 = await getCourseListAPI({
sn: 'RMKC',
limit: 8
})
if (res2.code) {
hotCourses.value = res2.data
}
// 获取精选课程
const res3 = await getCourseListAPI({
sn: 'JXKC',
limit: 4
})
if (res3.code) {
goodCourses.value = res3.data
carouselInterval = setInterval(() => {
if (carouselRef.value) {
const nextSlide = (currentSlide.value + 1) % goodCourses.value.slice(0, 4).length
scrollToSlide(nextSlide)
}
}, 5000)
}
// 获取签到列表
onMounted(() => {
watch(() => currentUser.value, async (newVal) => {
if (newVal) {
const task = await getTaskListAPI()
if (task.code) {
checkInTypes.value = []
task.data.forEach(item => {
checkInTypes.value.push({
id: item.id,
name: item.title,
task_type: item.task_type,
is_gray: item.is_gray,
is_finish: item.is_finish,
checkin_subtask_id: item.checkin_subtask_id
})
});
}
if (!newVal) return
const task = await getTaskListAPI()
if (task && task.code) {
checkInTypes.value = (task.data || []).map(item => ({
id: item.id,
name: item.title,
task_type: item.task_type,
is_gray: item.is_gray,
is_finish: item.is_finish,
checkin_subtask_id: item.checkin_subtask_id
}))
}
}, { immediate: true })
// 获取最新活动(外部接口)
await fetchExternalActivities()
// 监听轮播容器的滚动,手动滑动时同步底部指示器
if (carouselRef.value) {
// 添加滚动监听(被动监听),在用户手动滑动时同步指示器
carouselRef.value.addEventListener('scroll', handle_carousel_scroll, { passive: true })
}
})
onUnmounted(() => {
if (carouselInterval) {
clearInterval(carouselInterval)
}
// 卸载时移除轮播滚动监听,避免内存泄漏
if (carouselRef.value) {
carouselRef.value.removeEventListener('scroll', handle_carousel_scroll)
}
})
const carouselRef = ref(null) // 轮播图容器引用
/**
* @function sync_current_slide
* @description 根据滚动位置同步当前轮播索引
* 注释:通过容器 scrollLeft 与容器宽度计算当前页,向最近页取整。
* @returns {void}
*/
const sync_current_slide = () => {
const container = carouselRef.value
const total = goodCourses.value.slice(0, 4).length
if (!container || total === 0) return
const slide_width = container.offsetWidth
if (slide_width === 0) return
const raw_index = container.scrollLeft / slide_width
const idx = Math.round(raw_index)
const bounded = Math.min(Math.max(idx, 0), total - 1)
currentSlide.value = bounded
}
/**
* @function handle_carousel_scroll
* @description 轮播滚动事件处理(被动监听),调用同步函数更新指示器位置。
* @returns {void}
*/
const handle_carousel_scroll = () => {
sync_current_slide()
}
// 右侧导航组件:搜索和消息通知
// 右侧内容组件
......@@ -689,18 +419,6 @@ const formatToday = () => {
return today.toLocaleDateString('zh-CN', options) // 返回中文格式的日期
}
// 轮播图控制:滚动到指定位置
const scrollToSlide = (index) => {
if (carouselRef.value) {
const slideWidth = carouselRef.value.offsetWidth // 获取轮播图容器宽度
carouselRef.value.scrollTo({
left: index * slideWidth, // 计算目标滚动位置
behavior: 'smooth' // 使用平滑滚动效果
})
currentSlide.value = index // 更新当前轮播图索引
}
}
/**
* @function handleHomeCheckInSuccess
* @description 首页打卡成功后刷新签到任务列表,更新置灰状态,并给出轻提示。
......@@ -729,176 +447,14 @@ const contentRef = ref(null) // 内容区域的ref引用
watch(activeTab, () => {
nextTick(() => {
if (contentRef.value) {
const navHeight = document.querySelector('.sticky').offsetHeight;
const marginTop = parseInt(window.getComputedStyle(contentRef.value).marginTop);
const sticky_el = document.querySelector('.sticky')
const navHeight = sticky_el ? sticky_el.offsetHeight : 0
const marginTop = parseInt(window.getComputedStyle(contentRef.value).marginTop)
window.scrollTo({
top: contentRef.value.offsetTop - navHeight - marginTop,
behavior:'smooth'
});
})
}
})
})
// 跳转到购买课程详情页
const goToCourseDetail = ({id}) => {
$router.push(`/courses/${id}`)
}
/**
* 获取并处理外部活动数据
* @returns {Promise<void>} 无返回值,更新 activities 响应式数据
* 注释:使用原生 fetch 请求外部接口,解析与映射为 ActivityCard 需要的结构,并筛选“报名中”状态。
*/
const fetchExternalActivities = async () => {
// 外部接口地址(不复用项目 axios 配置)
const url = 'https://bhapi.behalo.cc/api/get_act/?city_name=&pub_status=1&page_idx=1&page_size=300&search_option=4'
try {
// 发起请求
const resp = await fetch(url, { method: 'GET' })
// 解析结果
const json = await resp.json()
// 兼容不同返回结构,优先 data.list 数组
const list = Array.isArray(json)
? json
: Array.isArray(json?.data?.list)
? json.data.list
: Array.isArray(json?.list)
? json.list
: Array.isArray(json?.rows)
? json.rows
: []
// 当前时间
const now = new Date()
// 映射到 ActivityCard 结构
const mapped = list.map((item) => {
// 解析数值
const xs_price = numberOrNull(item?.xs_price)
const sc_price = numberOrNull(item?.sc_price)
// 图片优先级:sl_img > fx_img > banner_img
const imageUrl = item?.sl_img || item?.fx_img || item?.banner_img || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'
// 时间区间字符串
const period = formatPeriod(item?.act_start_at, item?.act_end_at)
// 参与人数与上限(根据 stu_num_upper 与 bookgap_info 计算)
const upper = numberOrNull(item?.stu_num_upper)
const gap = numberOrNull(item?.bookgap_info?.stu_gap)
const participantsCount = upper != null && gap != null ? Math.max(upper - gap, 0) : null
// 报名状态判断:优先使用报名起止;缺失则按活动起止回退
const enrollStatus = computeEnrollStatus(
item?.stu_start_at,
item?.stu_end_at,
now,
item?.act_start_at,
item?.act_end_at
)
return {
id: item?.act_id,
title: item?.act_title || '',
imageUrl,
isHot: false,
// isFree: xs_price === 0 || sc_price === 0,
isFree: false,
location: item?.act_address || item?.city_name || '',
period,
price: xs_price != null ? xs_price : '',
originalPrice: sc_price != null && sc_price > 0 ? sc_price : '',
participantsCount: participantsCount != null ? participantsCount : '',
maxParticipants: upper != null ? upper : '',
mock_link: 'https://wxm.behalo.cc/pages/activity/info?type=2&id=' + item?.act_id,
status: enrollStatus
}
})
// 仅保留“报名中”的数据
activities.value = mapped.filter((a) => a.status === '报名中')
} catch (err) {
console.error('获取外部活动数据失败:', err)
// 失败时回退为空数组,避免页面报错
activities.value = []
}
}
/**
* 计算报名状态
* @param {string} stu_start_at 报名开始时间字符串
* @param {string} stu_end_at 报名结束时间字符串
* @param {Date} now 当前时间
* @param {string} act_start_at 活动开始时间
* @param {string} act_end_at 活动结束时间
* @returns {string} 状态字符串:报名中 / 即将开始 / 进行中 / 已结束
* 注释:优先以报名起止判断“报名中”;若缺失则按活动起止时间回退:未开始视为“报名中”,进行中为“进行中”。
*/
const computeEnrollStatus = (stu_start_at, stu_end_at, now, act_start_at, act_end_at) => {
// 优先使用报名时间窗口
if (stu_start_at && stu_end_at) {
const start = new Date(stu_start_at)
const end = new Date(stu_end_at)
if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
if (now >= start && now <= end) return '报名中'
if (now < start) return '即将开始'
return '已结束'
}
}
// 回退:使用活动时间窗口
if (act_start_at && act_end_at) {
const aStart = new Date(act_start_at)
const aEnd = new Date(act_end_at)
if (!isNaN(aStart.getTime()) && !isNaN(aEnd.getTime())) {
if (now < aStart) return '报名中' // 活动未开始,视为报名中
if (now >= aStart && now <= aEnd) return '进行中'
if (now > aEnd) return '已结束'
}
}
// 默认回退
return '即将开始'
}
/**
* 格式化活动时间区间
* @param {string} startStr 活动开始时间
* @param {string} endStr 活动结束时间
* @returns {string} 例如:2025-11-09 至 2025-12-20
* 注释:展示活动期信息;若解析失败则返回空串。
*/
const formatPeriod = (startStr, endStr) => {
if (!startStr || !endStr) return ''
const start = new Date(startStr)
const end = new Date(endStr)
if (isNaN(start.getTime()) || isNaN(end.getTime())) return ''
const s = formatDateOnly(start)
const e = formatDateOnly(end)
return `${s} 至 ${e}`
}
/**
* 仅格式化为日期字符串(YYYY-MM-DD)
* @param {Date} d 日期对象
* @returns {string} 日期字符串
*/
const formatDateOnly = (d) => {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/**
* 将任意值安全转换为数字
* @param {any} v 原始值
* @returns {number|null} 数字或空
*/
const numberOrNull = (v) => {
if (v === null || v === undefined || v === '') return null
const n = Number(v)
return isNaN(n) ? null : n
}
</script>
......