hookehuyr

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

移除本地模拟数据,改为从外部API获取活动数据
新增活动数据处理逻辑,包括状态计算和格式转换
调整首页展示活动数量从3个增加到4个
<!--
* @Date: 2025-03-20 20:36:36
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-05-21 16:42:40
* @LastEditTime: 2025-11-11 16:28:41
* @FilePath: /mlaj/src/components/ui/ActivityCard.vue
* @Description: 文件描述
-->
......@@ -62,12 +62,12 @@
</div>
<div v-else></div>
<div class="flex items-center text-xs text-gray-500">
<!-- <div class="flex items-center text-xs text-gray-500">
<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">
<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" />
</svg>
<span>{{ activity.participantsCount || '15' }}/{{ activity.maxParticipants || '30' }}</span>
</div>
</div> -->
</div>
</div>
</FrostedGlass>
......
<!--
* @Date: 2025-03-20 19:55:21
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-10-14 11:07:30
* @LastEditTime: 2025-11-11 16:29:22
* @FilePath: /mlaj/src/views/HomePage.vue
* @Description: 美乐爱觉教育首页组件
*
......@@ -347,7 +347,7 @@
</a>
</div>
<div class="space-y-4">
<div v-for="activity in activities.slice(0, 3)" :key="activity.id">
<div v-for="activity in activities.slice(0, 4)" :key="activity.id">
<ActivityCard :activity="activity" />
</div>
</div>
......@@ -538,7 +538,7 @@ import SummerCampCard from '@/components/ui/SummerCampCard.vue'
import VideoPlayer from '@/components/ui/VideoPlayer.vue'
// TODO: 导入模拟数据和工具函数
import { liveStreams, activities } from '@/utils/mockData'
import { liveStreams } from '@/utils/mockData'
import { useTitle } from '@vueuse/core'
import { useAuth } from '@/contexts/auth'
import { showToast } from 'vant'
......@@ -578,6 +578,8 @@ const displayedRecommendations = ref([]) // 当前显示的推荐内容
const userRecommendations = ref([])
const hotCourses = ref([])
const goodCourses = ref([])
// 活动列表(从外部接口获取)
const activities = ref([])
// 获取推荐内容
const getRecommendations = (random = false) => {
if (random) {
......@@ -637,6 +639,9 @@ onMounted(async () => {
});
}
}
// 获取最新活动(外部接口)
await fetchExternalActivities()
})
onUnmounted(() => {
......@@ -761,4 +766,162 @@ watch(activeTab, () => {
const goToCourseDetail = ({id}) => {
$router.push(`/courses/${id}`)
}
/**
* 获取并处理外部活动数据
* @returns {Promise<void>} 无返回值,更新 activities 响应式数据
* 注释:使用原生 fetch 请求外部接口,解析与映射为 ActivityCard 需要的结构,并筛选“报名中”状态。
*/
const fetchExternalActivities = async () => {
// 外部接口地址(不复用项目 axios 配置)
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()
// 兼容不同返回结构,优先 data.list 数组
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()
// 映射到 ActivityCard 结构
const mapped = list.map((item) => {
// 解析数值
const xs_price = numberOrNull(item?.xs_price)
const sc_price = numberOrNull(item?.sc_price)
// 图片优先级:sl_img > fx_img > banner_img
const imageUrl = item?.sl_img || item?.fx_img || item?.banner_img || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'
// 时间区间字符串
const period = formatPeriod(item?.act_start_at, item?.act_end_at)
// 参与人数与上限(根据 stu_num_upper 与 bookgap_info 计算)
const upper = numberOrNull(item?.stu_num_upper)
const gap = numberOrNull(item?.bookgap_info?.stu_gap)
const participantsCount = upper != null && gap != null ? Math.max(upper - gap, 0) : null
// 报名状态判断:优先使用报名起止;缺失则按活动起止回退
const enrollStatus = computeEnrollStatus(
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: xs_price === 0 || sc_price === 0,
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: enrollStatus
}
})
// 仅保留“报名中”的数据
activities.value = mapped.filter((a) => a.status === '报名中')
} catch (err) {
console.error('获取外部活动数据失败:', err)
// 失败时回退为空数组,避免页面报错
activities.value = []
}
}
/**
* 计算报名状态
* @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 computeEnrollStatus = (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 aStart = new Date(act_start_at)
const aEnd = new Date(act_end_at)
if (!isNaN(aStart.getTime()) && !isNaN(aEnd.getTime())) {
if (now < aStart) return '报名中' // 活动未开始,视为报名中
if (now >= aStart && now <= aEnd) return '进行中'
if (now > aEnd) return '已结束'
}
}
// 默认回退
return '即将开始'
}
/**
* 格式化活动时间区间
* @param {string} startStr 活动开始时间
* @param {string} endStr 活动结束时间
* @returns {string} 例如:2025-11-09 至 2025-12-20
* 注释:展示活动期信息;若解析失败则返回空串。
*/
const formatPeriod = (startStr, endStr) => {
if (!startStr || !endStr) return ''
const start = new Date(startStr)
const end = new Date(endStr)
if (isNaN(start.getTime()) || isNaN(end.getTime())) return ''
const s = formatDateOnly(start)
const e = formatDateOnly(end)
return `${s} 至 ${e}`
}
/**
* 仅格式化为日期字符串(YYYY-MM-DD)
* @param {Date} d 日期对象
* @returns {string} 日期字符串
*/
const formatDateOnly = (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}`
}
/**
* 将任意值安全转换为数字
* @param {any} v 原始值
* @returns {number|null} 数字或空
*/
const numberOrNull = (v) => {
if (v === null || v === undefined || v === '') return null
const n = Number(v)
return isNaN(n) ? null : n
}
</script>
......