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
}
}
This diff is collapsed. Click to expand it.