hookehuyr

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

简化页面结构,移除不必要的嵌套div
使用窗口滚动替代容器内滚动
优化标签页固定和滚动联动逻辑
...@@ -5,13 +5,9 @@ ...@@ -5,13 +5,9 @@
5 <template> 5 <template>
6 <AppLayout :has-title="false"> 6 <AppLayout :has-title="false">
7 <div class="study-course-page bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen"> 7 <div class="study-course-page bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen">
8 - <div v-if="course" class="flex flex-col h-screen"> 8 + <div v-if="course" class="bg-white">
9 - <!-- 固定区域:课程封面和标签页 -->
10 - <div class=" bg-white">
11 - <!-- 课程封面区域 -->
12 <van-image class="w-full aspect-video object-cover" :src="course?.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" :alt="course?.title" /> 9 <van-image class="w-full aspect-video object-cover" :src="course?.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" :alt="course?.title" />
13 <div class="p-4"> 10 <div class="p-4">
14 - <!-- 标题和价格区域 -->
15 <div class="flex items-start justify-between gap-3 mb-2"> 11 <div class="flex items-start justify-between gap-3 mb-2">
16 <h1 class="text-black text-xl font-bold flex-1 min-w-0">{{ course?.title }}</h1> 12 <h1 class="text-black text-xl font-bold flex-1 min-w-0">{{ course?.title }}</h1>
17 <div v-if="course?.price !== '0.00'" class="text-orange-500 font-bold text-xl flex-shrink-0">¥{{ course?.price || '0' }}</div> 13 <div v-if="course?.price !== '0.00'" class="text-orange-500 font-bold text-xl flex-shrink-0">¥{{ course?.price || '0' }}</div>
...@@ -26,10 +22,8 @@ ...@@ -26,10 +22,8 @@
26 22
27 <div class="h-2 bg-gray-100"></div> 23 <div class="h-2 bg-gray-100"></div>
28 24
29 - <!-- 标签页区域 --> 25 + <div ref="tabs_wrapper_ref" class="py-3 bg-white sticky top-0 z-10">
30 - <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)` } : {}">
31 <div class="flex justify-around items-center relative" ref="tabs_container_ref"> 26 <div class="flex justify-around items-center relative" ref="tabs_container_ref">
32 - <!-- 动态标签:详情、目录,任务存在时显示打卡互动 -->
33 <div 27 <div
34 v-for="(tab, index) in tabs" 28 v-for="(tab, index) in tabs"
35 :key="tab.name" 29 :key="tab.name"
...@@ -45,12 +39,7 @@ ...@@ -45,12 +39,7 @@
45 ></div> 39 ></div>
46 </div> 40 </div>
47 </div> 41 </div>
48 - </div>
49 42
50 - <!-- 滚动区域:详情、目录和互动内容 -->
51 - <div ref="scroll_container_ref" class="overflow-y-auto flex-1"
52 - :style="{ paddingTop: topWrapperHeight, height: 'calc(100vh - ' + topWrapperHeight + ')' }">
53 - <!-- 详情区域 -->
54 <div id="detail" class="py-4 px-4"> 43 <div id="detail" class="py-4 px-4">
55 <div v-if="course?.feature"> 44 <div v-if="course?.feature">
56 <div class="text-black text-xl font-bold mb-2">课程特色</div> 45 <div class="text-black text-xl font-bold mb-2">课程特色</div>
...@@ -72,15 +61,11 @@ ...@@ -72,15 +61,11 @@
72 61
73 <div class="h-2 bg-gray-100"></div> 62 <div class="h-2 bg-gray-100"></div>
74 63
75 - <!-- 目录区域 -->
76 <div id="catalog" class="py-4"> 64 <div id="catalog" class="py-4">
77 <div v-if="course_lessons.length" class="space-y-4"> 65 <div v-if="course_lessons.length" class="space-y-4">
78 <div v-for="(lesson, index) in course_lessons" :key="index" 66 <div v-for="(lesson, index) in course_lessons" :key="index"
79 @click="goToStudyDetail(lesson.id)" 67 @click="goToStudyDetail(lesson.id)"
80 class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative"> 68 class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative">
81 - <!-- <div v-if="lesson.progress > 0 && lesson.progress < 100"
82 - class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded">
83 - 没有字段=>上次看到</div> -->
84 <div class="text-black text-base font-medium mb-2">{{ lesson.title }}</div> 69 <div class="text-black text-base font-medium mb-2">{{ lesson.title }}</div>
85 <div class="flex items-center text-sm text-gray-500"> 70 <div class="flex items-center text-sm text-gray-500">
86 <span>{{ course_type_maps[lesson.course_type] }}</span> 71 <span>{{ course_type_maps[lesson.course_type] }}</span>
...@@ -88,8 +73,6 @@ ...@@ -88,8 +73,6 @@
88 <span>开课时间: {{ lesson.schedule_time ? dayjs(lesson.schedule_time).format('YYYY-MM-DD') : '暂无' }}</span> 73 <span>开课时间: {{ lesson.schedule_time ? dayjs(lesson.schedule_time).format('YYYY-MM-DD') : '暂无' }}</span>
89 <span class="mx-2">|</span> 74 <span class="mx-2">|</span>
90 <span v-if="lesson.duration">建议时长: {{ lesson.duration }} 分钟</span> 75 <span v-if="lesson.duration">建议时长: {{ lesson.duration }} 分钟</span>
91 - <!-- <span class="mx-2">|</span> -->
92 - <!-- <span>没有字段=> 已学习{{ lesson?.progress }}%</span> -->
93 </div> 76 </div>
94 </div> 77 </div>
95 </div> 78 </div>
...@@ -98,7 +81,6 @@ ...@@ -98,7 +81,6 @@
98 81
99 <div class="h-2 bg-gray-100"></div> 82 <div class="h-2 bg-gray-100"></div>
100 83
101 - <!-- 互动区域 -->
102 <div id="interaction" class="py-4 px-4" v-if="task_list.length > 0"> 84 <div id="interaction" class="py-4 px-4" v-if="task_list.length > 0">
103 <div class="bg-white rounded-lg p-4 mb-4 cursor-pointer"> 85 <div class="bg-white rounded-lg p-4 mb-4 cursor-pointer">
104 <div class="flex items-center justify-between" @click="goToCheckin()"> 86 <div class="flex items-center justify-between" @click="goToCheckin()">
...@@ -113,10 +95,9 @@ ...@@ -113,10 +95,9 @@
113 </div> 95 </div>
114 </div> 96 </div>
115 </div> 97 </div>
116 - <!-- 添加底部填充区域 --> 98 +
117 <div style="height: 30vh;"></div> 99 <div style="height: 30vh;"></div>
118 </div> 100 </div>
119 - </div>
120 101
121 <!-- 打卡弹窗:统一使用 CheckInDialog 组件 --> 102 <!-- 打卡弹窗:统一使用 CheckInDialog 组件 -->
122 <CheckInDialog 103 <CheckInDialog
...@@ -148,8 +129,6 @@ useTitle('课程详情'); ...@@ -148,8 +129,6 @@ useTitle('课程详情');
148 129
149 // 当前激活的标签页 130 // 当前激活的标签页
150 const activeTab = ref('detail'); 131 const activeTab = ref('detail');
151 -const topWrapperHeight = ref(0);
152 -const resizeObserver = ref(null);
153 // tabs DOM映射 132 // tabs DOM映射
154 const tabRefs = ref([]); 133 const tabRefs = ref([]);
155 const tabElMap = ref({}); 134 const tabElMap = ref({});
...@@ -195,8 +174,9 @@ const currentTabIndex = computed(() => { ...@@ -195,8 +174,9 @@ const currentTabIndex = computed(() => {
195 const tabs_container_ref = ref(null); 174 const tabs_container_ref = ref(null);
196 const tabs_container_width = ref(0); 175 const tabs_container_width = ref(0);
197 const tabs_resize_observer = ref(null); 176 const tabs_resize_observer = ref(null);
198 -const scroll_container_ref = ref(null); 177 +const tabs_wrapper_ref = ref(null);
199 -const scroll_container_el = ref(null); 178 +const tabs_wrapper_height = ref(0);
179 +const tabs_wrapper_resize_observer = ref(null);
200 const is_tab_scrolling = ref(false); 180 const is_tab_scrolling = ref(false);
201 181
202 /** 182 /**
...@@ -249,24 +229,19 @@ const indicatorStyle = computed(() => { ...@@ -249,24 +229,19 @@ const indicatorStyle = computed(() => {
249 }; 229 };
250 }); 230 });
251 231
252 -// 计算topWrapperHeight的函数 232 +const updateTabsWrapperHeight = () => {
253 -const updateTopWrapperHeight = () => {
254 - setTimeout(() => {
255 nextTick(() => { 233 nextTick(() => {
256 - const topWrapper = document.querySelector('.top-wrapper'); 234 + const el = tabs_wrapper_ref.value;
257 - if (topWrapper) { 235 + if (!el) return;
258 - // 断开之前的observer连接 236 + tabs_wrapper_height.value = el.offsetHeight || 0;
259 - if (resizeObserver.value) { 237 + if (tabs_wrapper_resize_observer.value) {
260 - resizeObserver.value.disconnect(); 238 + tabs_wrapper_resize_observer.value.disconnect();
261 } 239 }
262 - // 使用 ResizeObserver 监听元素尺寸变化 240 + tabs_wrapper_resize_observer.value = new ResizeObserver(() => {
263 - resizeObserver.value = new ResizeObserver(() => { 241 + tabs_wrapper_height.value = el.offsetHeight || 0;
264 - topWrapperHeight.value = `${topWrapper.offsetHeight}px`;
265 }); 242 });
266 - resizeObserver.value.observe(topWrapper); 243 + tabs_wrapper_resize_observer.value.observe(el);
267 - }
268 }); 244 });
269 - }, 500)
270 }; 245 };
271 246
272 const course = ref([]); 247 const course = ref([]);
...@@ -326,53 +301,31 @@ onMounted(async () => { ...@@ -326,53 +301,31 @@ onMounted(async () => {
326 router.go(-1); 301 router.go(-1);
327 } 302 }
328 /** 303 /**
329 - * 初始化时计算topWrapperHeight 304 + * 初始化滚动监听与标签高度
330 */ 305 */
331 - nextTick(() => { 306 + window.addEventListener('scroll', handleScroll, { passive: true });
332 - if (scroll_container_ref.value) { 307 + window.addEventListener('resize', updateTabsWrapperHeight);
333 - scroll_container_el.value = scroll_container_ref.value; 308 + updateTabsWrapperHeight();
334 - scroll_container_el.value.addEventListener('scroll', handleScroll, { passive: true });
335 - }
336 - });
337 - // 添加窗口大小变化监听
338 - window.addEventListener('resize', updateTopWrapperHeight);
339 - // 确保在组件挂载后计算高度
340 - updateTopWrapperHeight();
341 // 初始化标签容器宽度监听 309 // 初始化标签容器宽度监听
342 initTabsContainerWidth(); 310 initTabsContainerWidth();
311 + handleScroll();
343 }); 312 });
344 313
345 // 在组件卸载时移除监听器 314 // 在组件卸载时移除监听器
346 onUnmounted(() => { 315 onUnmounted(() => {
347 - if (scroll_container_el.value) { 316 + window.removeEventListener('scroll', handleScroll);
348 - scroll_container_el.value.removeEventListener('scroll', handleScroll); 317 + window.removeEventListener('resize', updateTabsWrapperHeight);
349 - scroll_container_el.value = null;
350 - }
351 - window.removeEventListener('resize', updateTopWrapperHeight);
352 - // 组件卸载时取消监听
353 - if (resizeObserver.value) {
354 - resizeObserver.value.disconnect();
355 - resizeObserver.value = null;
356 - }
357 // 取消标签容器监听 318 // 取消标签容器监听
358 if (tabs_resize_observer.value) { 319 if (tabs_resize_observer.value) {
359 tabs_resize_observer.value.disconnect(); 320 tabs_resize_observer.value.disconnect();
360 tabs_resize_observer.value = null; 321 tabs_resize_observer.value = null;
361 } 322 }
323 + if (tabs_wrapper_resize_observer.value) {
324 + tabs_wrapper_resize_observer.value.disconnect();
325 + tabs_wrapper_resize_observer.value = null;
326 + }
362 }); 327 });
363 328
364 -// 处理滚动事件
365 -// 在script setup中添加
366 -const isTabFixed = ref(false);
367 -
368 -const get_container_relative_top = (el) => {
369 - const container = scroll_container_ref.value;
370 - if (!container || !el) return 0;
371 - const container_rect = container.getBoundingClientRect();
372 - const el_rect = el.getBoundingClientRect();
373 - return el_rect.top - container_rect.top + container.scrollTop;
374 -};
375 -
376 // 防抖函数 329 // 防抖函数
377 const debounce = (fn, delay) => { 330 const debounce = (fn, delay) => {
378 let timer = null; 331 let timer = null;
...@@ -392,37 +345,41 @@ const debounce = (fn, delay) => { ...@@ -392,37 +345,41 @@ const debounce = (fn, delay) => {
392 const handleScroll = debounce(() => { 345 const handleScroll = debounce(() => {
393 if (is_tab_scrolling.value) return; 346 if (is_tab_scrolling.value) return;
394 347
395 - const detailElement = document.getElementById('detail'); 348 + const buffer = 20;
396 - const catalogElement = document.getElementById('catalog'); 349 + const offset_top = (tabs_wrapper_height.value || 0) + buffer;
397 - const interactionElement = document.getElementById('interaction'); 350 + const current_scroll = window.scrollY + offset_top;
398 - const container = scroll_container_ref.value; 351 +
399 - if (!detailElement || !catalogElement || !container) return; 352 + const sections = tabs.value
400 - 353 + .map((t) => {
401 - isTabFixed.value = false; 354 + const el = document.getElementById(t.name);
402 - 355 + if (!el) return null;
403 - const scrollTop = container.scrollTop; 356 + const rect = el.getBoundingClientRect();
404 - const buffer = 20; // 缓冲区域 357 + const top = rect.top + window.scrollY;
405 - 358 + return { name: t.name, top };
406 - // 计算各区域顶部位置(互动不存在则设为无穷大) 359 + })
407 - const detailTop = get_container_relative_top(detailElement) - buffer; 360 + .filter(Boolean)
408 - const catalogTop = get_container_relative_top(catalogElement) - buffer; 361 + .sort((a, b) => a.top - b.top);
409 - const interactionTop = interactionElement ? (get_container_relative_top(interactionElement) - buffer) : Infinity; 362 +
410 - 363 + if (!sections.length) return;
411 - // 页面高度与底部判断 364 +
412 - const scrollHeight = container.scrollHeight; 365 + const doc_height = document.documentElement.scrollHeight;
413 - const clientHeight = container.clientHeight; 366 + const win_height = window.innerHeight;
414 - const isAtBottom = scrollTop + clientHeight >= scrollHeight - buffer; 367 + const is_at_bottom = window.scrollY + win_height >= doc_height - buffer;
415 - 368 +
416 - // 联动判断 369 + if (is_at_bottom) {
417 - if (scrollTop <= detailTop) { 370 + activeTab.value = sections[sections.length - 1].name;
418 - activeTab.value = 'detail'; 371 + return;
419 - } else if (interactionElement && (isAtBottom || scrollTop >= interactionTop)) {
420 - activeTab.value = 'interaction';
421 - } else if (scrollTop >= catalogTop && scrollTop < interactionTop) {
422 - activeTab.value = 'catalog';
423 - } else if (scrollTop >= detailTop && scrollTop < catalogTop) {
424 - activeTab.value = 'detail';
425 } 372 }
373 +
374 + let matched = sections[0].name;
375 + for (let i = 0; i < sections.length; i++) {
376 + if (current_scroll >= sections[i].top) {
377 + matched = sections[i].name;
378 + } else {
379 + break;
380 + }
381 + }
382 + activeTab.value = matched;
426 }, 100); 383 }, 100);
427 384
428 385
...@@ -430,11 +387,6 @@ const handleScroll = debounce(() => { ...@@ -430,11 +387,6 @@ const handleScroll = debounce(() => {
430 // 在script setup中添加 387 // 在script setup中添加
431 const previousTab = ref('detail'); 388 const previousTab = ref('detail');
432 389
433 -const getTransitionTiming = (current, previous) => {
434 - const tabOrder = { detail: 0, catalog: 1, interaction: 2 };
435 - return tabOrder[current] > tabOrder[previous] ? 'cubic-bezier(0.4, 0, 0.2, 1)' : 'cubic-bezier(0.4, 0, 0.2, 1)';
436 -};
437 -
438 // 修改handleTabChange函数 390 // 修改handleTabChange函数
439 const handleTabChange = (name) => { 391 const handleTabChange = (name) => {
440 previousTab.value = activeTab.value; 392 previousTab.value = activeTab.value;
...@@ -442,9 +394,12 @@ const handleTabChange = (name) => { ...@@ -442,9 +394,12 @@ const handleTabChange = (name) => {
442 is_tab_scrolling.value = true; 394 is_tab_scrolling.value = true;
443 nextTick(() => { 395 nextTick(() => {
444 const element = document.getElementById(name); 396 const element = document.getElementById(name);
445 - if (element && scroll_container_ref.value) { 397 + if (element) {
446 - const topOffset = Math.max(0, get_container_relative_top(element) - 10); 398 + const rect = element.getBoundingClientRect();
447 - scroll_container_ref.value.scrollTo({ top: topOffset, behavior: 'smooth' }); 399 + const buffer = 10;
400 + const offset_top = (tabs_wrapper_height.value || 0) + buffer;
401 + const topOffset = Math.max(0, rect.top + window.scrollY - offset_top);
402 + window.scrollTo({ top: topOffset, behavior: 'smooth' });
448 setTimeout(() => { 403 setTimeout(() => {
449 is_tab_scrolling.value = false; 404 is_tab_scrolling.value = false;
450 }, 500); 405 }, 500);
......