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 +}
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>
......