feat(首页): 实现从外部接口获取活动数据并展示
移除本地模拟数据,改为从外部API获取活动数据 新增活动数据处理逻辑,包括状态计算和格式转换 调整首页展示活动数量从3个增加到4个
Showing
2 changed files
with
169 additions
and
6 deletions
| 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> | ... | ... |
-
Please register or login to post a comment