hookehuyr

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

将首页功能拆分为独立组件,包括精选课程轮播、推荐课程、热门课程和最新活动模块
新增 useHomeVideoPlayer 组合式函数管理视频播放状态
优化代码结构和可维护性,减少主组件复杂度
...@@ -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>
1 +import { ref } from 'vue'
2 +
3 +export const useHomeVideoPlayer = () => {
4 + const activeVideoIndex = ref(null)
5 +
6 + const playVideo = (index) => {
7 + activeVideoIndex.value = index
8 + }
9 +
10 + const closeVideo = () => {
11 + activeVideoIndex.value = null
12 + }
13 +
14 + return {
15 + activeVideoIndex,
16 + playVideo,
17 + closeVideo
18 + }
19 +}
This diff is collapsed. Click to expand it.