hookehuyr

refactor(StudyCoursePage): 重构课程页面布局和滚动逻辑

简化页面结构,移除不必要的嵌套div
使用窗口滚动替代容器内滚动
优化标签页固定和滚动联动逻辑
......@@ -5,117 +5,98 @@
<template>
<AppLayout :has-title="false">
<div class="study-course-page bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen">
<div v-if="course" class="flex flex-col h-screen">
<!-- 固定区域:课程封面和标签页 -->
<div class=" bg-white">
<!-- 课程封面区域 -->
<van-image class="w-full aspect-video object-cover" :src="course?.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" :alt="course?.title" />
<div class="p-4">
<!-- 标题和价格区域 -->
<div class="flex items-start justify-between gap-3 mb-2">
<h1 class="text-black text-xl font-bold flex-1 min-w-0">{{ course?.title }}</h1>
<div v-if="course?.price !== '0.00'" class="text-orange-500 font-bold text-xl flex-shrink-0">¥{{ course?.price || '0' }}</div>
<div v-else class="text-orange-500 text-base font-semibold"> 免费 </div>
</div>
<div class="flex items-center text-gray-500 text-sm">
<span>已更新 {{ course?.count }} 期</span>
<span class="mx-2">|</span>
<span>{{ course?.buy_count }} 人订阅</span>
</div>
<div v-if="course" class="bg-white">
<van-image class="w-full aspect-video object-cover" :src="course?.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" :alt="course?.title" />
<div class="p-4">
<div class="flex items-start justify-between gap-3 mb-2">
<h1 class="text-black text-xl font-bold flex-1 min-w-0">{{ course?.title }}</h1>
<div v-if="course?.price !== '0.00'" class="text-orange-500 font-bold text-xl flex-shrink-0">¥{{ course?.price || '0' }}</div>
<div v-else class="text-orange-500 text-base font-semibold"> 免费 </div>
</div>
<div class="flex items-center text-gray-500 text-sm">
<span>已更新 {{ course?.count }} 期</span>
<span class="mx-2">|</span>
<span>{{ course?.buy_count }} 人订阅</span>
</div>
</div>
<div class="h-2 bg-gray-100"></div>
<!-- 标签页区域 -->
<div class="py-3 bg-white transition-all duration-300" :class="{'fixed top-0 left-0 right-0 z-10': isTabFixed}" :style="isTabFixed ? { transform: `translateY(0)` } : {}">
<div class="flex justify-around items-center relative" ref="tabs_container_ref">
<!-- 动态标签:详情、目录,任务存在时显示打卡互动 -->
<div
v-for="(tab, index) in tabs"
:key="tab.name"
:class="['px-4 py-2 cursor-pointer transition-colors relative', activeTab === tab.name ? 'text-green-600 font-medium' : 'text-gray-600']"
@click="handleTabChange(tab.name)"
:ref="el => setTabRef(tab.name, el)"
>
{{ tab.title }}
</div>
<div
class="absolute bottom-0 left-0 bg-green-600 transition-all duration-300 z-20"
:style="indicatorStyle"
></div>
<div class="h-2 bg-gray-100"></div>
<div ref="tabs_wrapper_ref" class="py-3 bg-white sticky top-0 z-10">
<div class="flex justify-around items-center relative" ref="tabs_container_ref">
<div
v-for="(tab, index) in tabs"
:key="tab.name"
:class="['px-4 py-2 cursor-pointer transition-colors relative', activeTab === tab.name ? 'text-green-600 font-medium' : 'text-gray-600']"
@click="handleTabChange(tab.name)"
:ref="el => setTabRef(tab.name, el)"
>
{{ tab.title }}
</div>
<div
class="absolute bottom-0 left-0 bg-green-600 transition-all duration-300 z-20"
:style="indicatorStyle"
></div>
</div>
</div>
<!-- 滚动区域:详情、目录和互动内容 -->
<div ref="scroll_container_ref" class="overflow-y-auto flex-1"
:style="{ paddingTop: topWrapperHeight, height: 'calc(100vh - ' + topWrapperHeight + ')' }">
<!-- 详情区域 -->
<div id="detail" class="py-4 px-4">
<div v-if="course?.feature">
<div class="text-black text-xl font-bold mb-2">课程特色</div>
<div class="text-gray-700 text-sm leading-relaxed" v-html="course?.feature"></div>
<br />
</div>
<div v-if="course?.highlights">
<div class="text-black text-xl font-bold mb-2">课程亮点</div>
<div class="text-gray-700 text-sm leading-relaxed" v-html="course?.highlights"></div>
<br />
</div>
<div v-if="course?.learning_goal">
<div class="text-black text-xl font-bold mb-2">学习目标</div>
<div class="text-gray-700 text-sm leading-relaxed" v-html="course?.learning_goal"></div>
<br />
</div>
<van-empty v-if="!course?.feature && !course?.highlights && course?.learning_goal" description="暂无详情" />
<div id="detail" class="py-4 px-4">
<div v-if="course?.feature">
<div class="text-black text-xl font-bold mb-2">课程特色</div>
<div class="text-gray-700 text-sm leading-relaxed" v-html="course?.feature"></div>
<br />
</div>
<div v-if="course?.highlights">
<div class="text-black text-xl font-bold mb-2">课程亮点</div>
<div class="text-gray-700 text-sm leading-relaxed" v-html="course?.highlights"></div>
<br />
</div>
<div v-if="course?.learning_goal">
<div class="text-black text-xl font-bold mb-2">学习目标</div>
<div class="text-gray-700 text-sm leading-relaxed" v-html="course?.learning_goal"></div>
<br />
</div>
<van-empty v-if="!course?.feature && !course?.highlights && course?.learning_goal" description="暂无详情" />
</div>
<div class="h-2 bg-gray-100"></div>
<!-- 目录区域 -->
<div id="catalog" class="py-4">
<div v-if="course_lessons.length" class="space-y-4">
<div v-for="(lesson, index) in course_lessons" :key="index"
@click="goToStudyDetail(lesson.id)"
class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative">
<!-- <div v-if="lesson.progress > 0 && lesson.progress < 100"
class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded">
没有字段=>上次看到</div> -->
<div class="text-black text-base font-medium mb-2">{{ lesson.title }}</div>
<div class="flex items-center text-sm text-gray-500">
<span>{{ course_type_maps[lesson.course_type] }}</span>
<span v-if="course_type_maps[lesson.course_type]" class="mx-2">|</span>
<span>开课时间: {{ lesson.schedule_time ? dayjs(lesson.schedule_time).format('YYYY-MM-DD') : '暂无' }}</span>
<span class="mx-2">|</span>
<span v-if="lesson.duration">建议时长: {{ lesson.duration }} 分钟</span>
<!-- <span class="mx-2">|</span> -->
<!-- <span>没有字段=> 已学习{{ lesson?.progress }}%</span> -->
</div>
<div class="h-2 bg-gray-100"></div>
<div id="catalog" class="py-4">
<div v-if="course_lessons.length" class="space-y-4">
<div v-for="(lesson, index) in course_lessons" :key="index"
@click="goToStudyDetail(lesson.id)"
class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative">
<div class="text-black text-base font-medium mb-2">{{ lesson.title }}</div>
<div class="flex items-center text-sm text-gray-500">
<span>{{ course_type_maps[lesson.course_type] }}</span>
<span v-if="course_type_maps[lesson.course_type]" class="mx-2">|</span>
<span>开课时间: {{ lesson.schedule_time ? dayjs(lesson.schedule_time).format('YYYY-MM-DD') : '暂无' }}</span>
<span class="mx-2">|</span>
<span v-if="lesson.duration">建议时长: {{ lesson.duration }} 分钟</span>
</div>
</div>
<van-empty v-else description="暂无目录" />
</div>
<van-empty v-else description="暂无目录" />
</div>
<div class="h-2 bg-gray-100"></div>
<div class="h-2 bg-gray-100"></div>
<!-- 互动区域 -->
<div id="interaction" class="py-4 px-4" v-if="task_list.length > 0">
<div class="bg-white rounded-lg p-4 mb-4 cursor-pointer">
<div class="flex items-center justify-between" @click="goToCheckin()">
<div class="flex items-center gap-3">
<van-icon size="3rem" name="calendar-o" class="text-xl text-gray-600" />
<div>
<div class="text-base font-medium">打卡</div>
<div class="text-sm text-gray-500">关联{{ task_list.length }}个打卡</div>
</div>
<div id="interaction" class="py-4 px-4" v-if="task_list.length > 0">
<div class="bg-white rounded-lg p-4 mb-4 cursor-pointer">
<div class="flex items-center justify-between" @click="goToCheckin()">
<div class="flex items-center gap-3">
<van-icon size="3rem" name="calendar-o" class="text-xl text-gray-600" />
<div>
<div class="text-base font-medium">打卡</div>
<div class="text-sm text-gray-500">关联{{ task_list.length }}个打卡</div>
</div>
<van-icon name="arrow" class="text-gray-400" />
</div>
<van-icon name="arrow" class="text-gray-400" />
</div>
</div>
<!-- 添加底部填充区域 -->
<div style="height: 30vh;"></div>
</div>
<div style="height: 30vh;"></div>
</div>
<!-- 打卡弹窗:统一使用 CheckInDialog 组件 -->
......@@ -148,8 +129,6 @@ useTitle('课程详情');
// 当前激活的标签页
const activeTab = ref('detail');
const topWrapperHeight = ref(0);
const resizeObserver = ref(null);
// tabs DOM映射
const tabRefs = ref([]);
const tabElMap = ref({});
......@@ -195,8 +174,9 @@ const currentTabIndex = computed(() => {
const tabs_container_ref = ref(null);
const tabs_container_width = ref(0);
const tabs_resize_observer = ref(null);
const scroll_container_ref = ref(null);
const scroll_container_el = ref(null);
const tabs_wrapper_ref = ref(null);
const tabs_wrapper_height = ref(0);
const tabs_wrapper_resize_observer = ref(null);
const is_tab_scrolling = ref(false);
/**
......@@ -249,24 +229,19 @@ const indicatorStyle = computed(() => {
};
});
// 计算topWrapperHeight的函数
const updateTopWrapperHeight = () => {
setTimeout(() => {
nextTick(() => {
const topWrapper = document.querySelector('.top-wrapper');
if (topWrapper) {
// 断开之前的observer连接
if (resizeObserver.value) {
resizeObserver.value.disconnect();
}
// 使用 ResizeObserver 监听元素尺寸变化
resizeObserver.value = new ResizeObserver(() => {
topWrapperHeight.value = `${topWrapper.offsetHeight}px`;
});
resizeObserver.value.observe(topWrapper);
}
const updateTabsWrapperHeight = () => {
nextTick(() => {
const el = tabs_wrapper_ref.value;
if (!el) return;
tabs_wrapper_height.value = el.offsetHeight || 0;
if (tabs_wrapper_resize_observer.value) {
tabs_wrapper_resize_observer.value.disconnect();
}
tabs_wrapper_resize_observer.value = new ResizeObserver(() => {
tabs_wrapper_height.value = el.offsetHeight || 0;
});
}, 500)
tabs_wrapper_resize_observer.value.observe(el);
});
};
const course = ref([]);
......@@ -326,53 +301,31 @@ onMounted(async () => {
router.go(-1);
}
/**
* 初始化时计算topWrapperHeight
* 初始化滚动监听与标签高度
*/
nextTick(() => {
if (scroll_container_ref.value) {
scroll_container_el.value = scroll_container_ref.value;
scroll_container_el.value.addEventListener('scroll', handleScroll, { passive: true });
}
});
// 添加窗口大小变化监听
window.addEventListener('resize', updateTopWrapperHeight);
// 确保在组件挂载后计算高度
updateTopWrapperHeight();
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', updateTabsWrapperHeight);
updateTabsWrapperHeight();
// 初始化标签容器宽度监听
initTabsContainerWidth();
handleScroll();
});
// 在组件卸载时移除监听器
onUnmounted(() => {
if (scroll_container_el.value) {
scroll_container_el.value.removeEventListener('scroll', handleScroll);
scroll_container_el.value = null;
}
window.removeEventListener('resize', updateTopWrapperHeight);
// 组件卸载时取消监听
if (resizeObserver.value) {
resizeObserver.value.disconnect();
resizeObserver.value = null;
}
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', updateTabsWrapperHeight);
// 取消标签容器监听
if (tabs_resize_observer.value) {
tabs_resize_observer.value.disconnect();
tabs_resize_observer.value = null;
}
if (tabs_wrapper_resize_observer.value) {
tabs_wrapper_resize_observer.value.disconnect();
tabs_wrapper_resize_observer.value = null;
}
});
// 处理滚动事件
// 在script setup中添加
const isTabFixed = ref(false);
const get_container_relative_top = (el) => {
const container = scroll_container_ref.value;
if (!container || !el) return 0;
const container_rect = container.getBoundingClientRect();
const el_rect = el.getBoundingClientRect();
return el_rect.top - container_rect.top + container.scrollTop;
};
// 防抖函数
const debounce = (fn, delay) => {
let timer = null;
......@@ -392,37 +345,41 @@ const debounce = (fn, delay) => {
const handleScroll = debounce(() => {
if (is_tab_scrolling.value) return;
const detailElement = document.getElementById('detail');
const catalogElement = document.getElementById('catalog');
const interactionElement = document.getElementById('interaction');
const container = scroll_container_ref.value;
if (!detailElement || !catalogElement || !container) return;
isTabFixed.value = false;
const scrollTop = container.scrollTop;
const buffer = 20; // 缓冲区域
// 计算各区域顶部位置(互动不存在则设为无穷大)
const detailTop = get_container_relative_top(detailElement) - buffer;
const catalogTop = get_container_relative_top(catalogElement) - buffer;
const interactionTop = interactionElement ? (get_container_relative_top(interactionElement) - buffer) : Infinity;
// 页面高度与底部判断
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - buffer;
// 联动判断
if (scrollTop <= detailTop) {
activeTab.value = 'detail';
} else if (interactionElement && (isAtBottom || scrollTop >= interactionTop)) {
activeTab.value = 'interaction';
} else if (scrollTop >= catalogTop && scrollTop < interactionTop) {
activeTab.value = 'catalog';
} else if (scrollTop >= detailTop && scrollTop < catalogTop) {
activeTab.value = 'detail';
const buffer = 20;
const offset_top = (tabs_wrapper_height.value || 0) + buffer;
const current_scroll = window.scrollY + offset_top;
const sections = tabs.value
.map((t) => {
const el = document.getElementById(t.name);
if (!el) return null;
const rect = el.getBoundingClientRect();
const top = rect.top + window.scrollY;
return { name: t.name, top };
})
.filter(Boolean)
.sort((a, b) => a.top - b.top);
if (!sections.length) return;
const doc_height = document.documentElement.scrollHeight;
const win_height = window.innerHeight;
const is_at_bottom = window.scrollY + win_height >= doc_height - buffer;
if (is_at_bottom) {
activeTab.value = sections[sections.length - 1].name;
return;
}
let matched = sections[0].name;
for (let i = 0; i < sections.length; i++) {
if (current_scroll >= sections[i].top) {
matched = sections[i].name;
} else {
break;
}
}
activeTab.value = matched;
}, 100);
......@@ -430,11 +387,6 @@ const handleScroll = debounce(() => {
// 在script setup中添加
const previousTab = ref('detail');
const getTransitionTiming = (current, previous) => {
const tabOrder = { detail: 0, catalog: 1, interaction: 2 };
return tabOrder[current] > tabOrder[previous] ? 'cubic-bezier(0.4, 0, 0.2, 1)' : 'cubic-bezier(0.4, 0, 0.2, 1)';
};
// 修改handleTabChange函数
const handleTabChange = (name) => {
previousTab.value = activeTab.value;
......@@ -442,9 +394,12 @@ const handleTabChange = (name) => {
is_tab_scrolling.value = true;
nextTick(() => {
const element = document.getElementById(name);
if (element && scroll_container_ref.value) {
const topOffset = Math.max(0, get_container_relative_top(element) - 10);
scroll_container_ref.value.scrollTo({ top: topOffset, behavior: 'smooth' });
if (element) {
const rect = element.getBoundingClientRect();
const buffer = 10;
const offset_top = (tabs_wrapper_height.value || 0) + buffer;
const topOffset = Math.max(0, rect.top + window.scrollY - offset_top);
window.scrollTo({ top: topOffset, behavior: 'smooth' });
setTimeout(() => {
is_tab_scrolling.value = false;
}, 500);
......