hookehuyr

feat(首页): 实现从外部接口获取活动数据并展示

移除本地模拟数据,改为从外部API获取活动数据
新增活动数据处理逻辑,包括状态计算和格式转换
调整首页展示活动数量从3个增加到4个
1 <!-- 1 <!--
2 * @Date: 2025-03-20 20:36:36 2 * @Date: 2025-03-20 20:36:36
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-05-21 16:42:40 4 + * @LastEditTime: 2025-11-11 16:28:41
5 * @FilePath: /mlaj/src/components/ui/ActivityCard.vue 5 * @FilePath: /mlaj/src/components/ui/ActivityCard.vue
6 * @Description: 文件描述 6 * @Description: 文件描述
7 --> 7 -->
...@@ -62,12 +62,12 @@ ...@@ -62,12 +62,12 @@
62 </div> 62 </div>
63 <div v-else></div> 63 <div v-else></div>
64 64
65 - <div class="flex items-center text-xs text-gray-500"> 65 + <!-- <div class="flex items-center text-xs text-gray-500">
66 <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 66 <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
67 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /> 67 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
68 </svg> 68 </svg>
69 <span>{{ activity.participantsCount || '15' }}/{{ activity.maxParticipants || '30' }}</span> 69 <span>{{ activity.participantsCount || '15' }}/{{ activity.maxParticipants || '30' }}</span>
70 - </div> 70 + </div> -->
71 </div> 71 </div>
72 </div> 72 </div>
73 </FrostedGlass> 73 </FrostedGlass>
......
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-10-14 11:07:30 4 + * @LastEditTime: 2025-11-11 16:29:22
5 * @FilePath: /mlaj/src/views/HomePage.vue 5 * @FilePath: /mlaj/src/views/HomePage.vue
6 * @Description: 美乐爱觉教育首页组件 6 * @Description: 美乐爱觉教育首页组件
7 * 7 *
...@@ -347,7 +347,7 @@ ...@@ -347,7 +347,7 @@
347 </a> 347 </a>
348 </div> 348 </div>
349 <div class="space-y-4"> 349 <div class="space-y-4">
350 - <div v-for="activity in activities.slice(0, 3)" :key="activity.id"> 350 + <div v-for="activity in activities.slice(0, 4)" :key="activity.id">
351 <ActivityCard :activity="activity" /> 351 <ActivityCard :activity="activity" />
352 </div> 352 </div>
353 </div> 353 </div>
...@@ -538,7 +538,7 @@ import SummerCampCard from '@/components/ui/SummerCampCard.vue' ...@@ -538,7 +538,7 @@ import SummerCampCard from '@/components/ui/SummerCampCard.vue'
538 import VideoPlayer from '@/components/ui/VideoPlayer.vue' 538 import VideoPlayer from '@/components/ui/VideoPlayer.vue'
539 539
540 // TODO: 导入模拟数据和工具函数 540 // TODO: 导入模拟数据和工具函数
541 -import { liveStreams, activities } from '@/utils/mockData' 541 +import { liveStreams } from '@/utils/mockData'
542 import { useTitle } from '@vueuse/core' 542 import { useTitle } from '@vueuse/core'
543 import { useAuth } from '@/contexts/auth' 543 import { useAuth } from '@/contexts/auth'
544 import { showToast } from 'vant' 544 import { showToast } from 'vant'
...@@ -578,6 +578,8 @@ const displayedRecommendations = ref([]) // 当前显示的推荐内容 ...@@ -578,6 +578,8 @@ const displayedRecommendations = ref([]) // 当前显示的推荐内容
578 const userRecommendations = ref([]) 578 const userRecommendations = ref([])
579 const hotCourses = ref([]) 579 const hotCourses = ref([])
580 const goodCourses = ref([]) 580 const goodCourses = ref([])
581 +// 活动列表(从外部接口获取)
582 +const activities = ref([])
581 // 获取推荐内容 583 // 获取推荐内容
582 const getRecommendations = (random = false) => { 584 const getRecommendations = (random = false) => {
583 if (random) { 585 if (random) {
...@@ -637,6 +639,9 @@ onMounted(async () => { ...@@ -637,6 +639,9 @@ onMounted(async () => {
637 }); 639 });
638 } 640 }
639 } 641 }
642 +
643 + // 获取最新活动(外部接口)
644 + await fetchExternalActivities()
640 }) 645 })
641 646
642 onUnmounted(() => { 647 onUnmounted(() => {
...@@ -761,4 +766,162 @@ watch(activeTab, () => { ...@@ -761,4 +766,162 @@ watch(activeTab, () => {
761 const goToCourseDetail = ({id}) => { 766 const goToCourseDetail = ({id}) => {
762 $router.push(`/courses/${id}`) 767 $router.push(`/courses/${id}`)
763 } 768 }
769 +
770 +/**
771 + * 获取并处理外部活动数据
772 + * @returns {Promise<void>} 无返回值,更新 activities 响应式数据
773 + * 注释:使用原生 fetch 请求外部接口,解析与映射为 ActivityCard 需要的结构,并筛选“报名中”状态。
774 + */
775 +const fetchExternalActivities = async () => {
776 + // 外部接口地址(不复用项目 axios 配置)
777 + const url = 'https://bhapi.behalo.cc/api/get_act/?city_name=&pub_status=1&page_idx=1&page_size=300&search_option=4'
778 + try {
779 + // 发起请求
780 + const resp = await fetch(url, { method: 'GET' })
781 + // 解析结果
782 + const json = await resp.json()
783 +
784 + // 兼容不同返回结构,优先 data.list 数组
785 + const list = Array.isArray(json)
786 + ? json
787 + : Array.isArray(json?.data?.list)
788 + ? json.data.list
789 + : Array.isArray(json?.list)
790 + ? json.list
791 + : Array.isArray(json?.rows)
792 + ? json.rows
793 + : []
794 +
795 + // 当前时间
796 + const now = new Date()
797 +
798 + // 映射到 ActivityCard 结构
799 + const mapped = list.map((item) => {
800 + // 解析数值
801 + const xs_price = numberOrNull(item?.xs_price)
802 + const sc_price = numberOrNull(item?.sc_price)
803 +
804 + // 图片优先级:sl_img > fx_img > banner_img
805 + const imageUrl = item?.sl_img || item?.fx_img || item?.banner_img || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'
806 +
807 + // 时间区间字符串
808 + const period = formatPeriod(item?.act_start_at, item?.act_end_at)
809 +
810 + // 参与人数与上限(根据 stu_num_upper 与 bookgap_info 计算)
811 + const upper = numberOrNull(item?.stu_num_upper)
812 + const gap = numberOrNull(item?.bookgap_info?.stu_gap)
813 + const participantsCount = upper != null && gap != null ? Math.max(upper - gap, 0) : null
814 +
815 + // 报名状态判断:优先使用报名起止;缺失则按活动起止回退
816 + const enrollStatus = computeEnrollStatus(
817 + item?.stu_start_at,
818 + item?.stu_end_at,
819 + now,
820 + item?.act_start_at,
821 + item?.act_end_at
822 + )
823 +
824 + return {
825 + id: item?.act_id,
826 + title: item?.act_title || '',
827 + imageUrl,
828 + isHot: false,
829 + // isFree: xs_price === 0 || sc_price === 0,
830 + isFree: false,
831 + location: item?.act_address || item?.city_name || '',
832 + period,
833 + price: xs_price != null ? xs_price : '',
834 + originalPrice: sc_price != null && sc_price > 0 ? sc_price : '',
835 + participantsCount: participantsCount != null ? participantsCount : '',
836 + maxParticipants: upper != null ? upper : '',
837 + mock_link: 'https://wxm.behalo.cc/pages/activity/info?type=2&id=' + item?.act_id,
838 + status: enrollStatus
839 + }
840 + })
841 +
842 + // 仅保留“报名中”的数据
843 + activities.value = mapped.filter((a) => a.status === '报名中')
844 + } catch (err) {
845 + console.error('获取外部活动数据失败:', err)
846 + // 失败时回退为空数组,避免页面报错
847 + activities.value = []
848 + }
849 +}
850 +
851 +/**
852 + * 计算报名状态
853 + * @param {string} stu_start_at 报名开始时间字符串
854 + * @param {string} stu_end_at 报名结束时间字符串
855 + * @param {Date} now 当前时间
856 + * @param {string} act_start_at 活动开始时间
857 + * @param {string} act_end_at 活动结束时间
858 + * @returns {string} 状态字符串:报名中 / 即将开始 / 进行中 / 已结束
859 + * 注释:优先以报名起止判断“报名中”;若缺失则按活动起止时间回退:未开始视为“报名中”,进行中为“进行中”。
860 + */
861 +const computeEnrollStatus = (stu_start_at, stu_end_at, now, act_start_at, act_end_at) => {
862 + // 优先使用报名时间窗口
863 + if (stu_start_at && stu_end_at) {
864 + const start = new Date(stu_start_at)
865 + const end = new Date(stu_end_at)
866 + if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
867 + if (now >= start && now <= end) return '报名中'
868 + if (now < start) return '即将开始'
869 + return '已结束'
870 + }
871 + }
872 +
873 + // 回退:使用活动时间窗口
874 + if (act_start_at && act_end_at) {
875 + const aStart = new Date(act_start_at)
876 + const aEnd = new Date(act_end_at)
877 + if (!isNaN(aStart.getTime()) && !isNaN(aEnd.getTime())) {
878 + if (now < aStart) return '报名中' // 活动未开始,视为报名中
879 + if (now >= aStart && now <= aEnd) return '进行中'
880 + if (now > aEnd) return '已结束'
881 + }
882 + }
883 +
884 + // 默认回退
885 + return '即将开始'
886 +}
887 +
888 +/**
889 + * 格式化活动时间区间
890 + * @param {string} startStr 活动开始时间
891 + * @param {string} endStr 活动结束时间
892 + * @returns {string} 例如:2025-11-09 至 2025-12-20
893 + * 注释:展示活动期信息;若解析失败则返回空串。
894 + */
895 +const formatPeriod = (startStr, endStr) => {
896 + if (!startStr || !endStr) return ''
897 + const start = new Date(startStr)
898 + const end = new Date(endStr)
899 + if (isNaN(start.getTime()) || isNaN(end.getTime())) return ''
900 + const s = formatDateOnly(start)
901 + const e = formatDateOnly(end)
902 + return `${s} 至 ${e}`
903 +}
904 +
905 +/**
906 + * 仅格式化为日期字符串(YYYY-MM-DD)
907 + * @param {Date} d 日期对象
908 + * @returns {string} 日期字符串
909 + */
910 +const formatDateOnly = (d) => {
911 + const y = d.getFullYear()
912 + const m = String(d.getMonth() + 1).padStart(2, '0')
913 + const day = String(d.getDate()).padStart(2, '0')
914 + return `${y}-${m}-${day}`
915 +}
916 +
917 +/**
918 + * 将任意值安全转换为数字
919 + * @param {any} v 原始值
920 + * @returns {number|null} 数字或空
921 + */
922 +const numberOrNull = (v) => {
923 + if (v === null || v === undefined || v === '') return null
924 + const n = Number(v)
925 + return isNaN(n) ? null : n
926 +}
764 </script> 927 </script>
......