hookehuyr

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

简化页面结构,移除不必要的嵌套div
使用窗口滚动替代容器内滚动
优化标签页固定和滚动联动逻辑
...@@ -5,117 +5,98 @@ ...@@ -5,117 +5,98 @@
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 - <!-- 固定区域:课程封面和标签页 --> 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" />
10 - <div class=" bg-white"> 10 + <div class="p-4">
11 - <!-- 课程封面区域 --> 11 + <div class="flex items-start justify-between gap-3 mb-2">
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" /> 12 + <h1 class="text-black text-xl font-bold flex-1 min-w-0">{{ course?.title }}</h1>
13 - <div class="p-4"> 13 + <div v-if="course?.price !== '0.00'" class="text-orange-500 font-bold text-xl flex-shrink-0">¥{{ course?.price || '0' }}</div>
14 - <!-- 标题和价格区域 --> 14 + <div v-else class="text-orange-500 text-base font-semibold"> 免费 </div>
15 - <div class="flex items-start justify-between gap-3 mb-2"> 15 + </div>
16 - <h1 class="text-black text-xl font-bold flex-1 min-w-0">{{ course?.title }}</h1> 16 + <div class="flex items-center text-gray-500 text-sm">
17 - <div v-if="course?.price !== '0.00'" class="text-orange-500 font-bold text-xl flex-shrink-0">¥{{ course?.price || '0' }}</div> 17 + <span>已更新 {{ course?.count }} 期</span>
18 - <div v-else class="text-orange-500 text-base font-semibold"> 免费 </div> 18 + <span class="mx-2">|</span>
19 - </div> 19 + <span>{{ course?.buy_count }} 人订阅</span>
20 - <div class="flex items-center text-gray-500 text-sm">
21 - <span>已更新 {{ course?.count }} 期</span>
22 - <span class="mx-2">|</span>
23 - <span>{{ course?.buy_count }} 人订阅</span>
24 - </div>
25 </div> 20 </div>
21 + </div>
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)` } : {}"> 26 + <div class="flex justify-around items-center relative" ref="tabs_container_ref">
31 - <div class="flex justify-around items-center relative" ref="tabs_container_ref"> 27 + <div
32 - <!-- 动态标签:详情、目录,任务存在时显示打卡互动 --> 28 + v-for="(tab, index) in tabs"
33 - <div 29 + :key="tab.name"
34 - v-for="(tab, index) in tabs" 30 + :class="['px-4 py-2 cursor-pointer transition-colors relative', activeTab === tab.name ? 'text-green-600 font-medium' : 'text-gray-600']"
35 - :key="tab.name" 31 + @click="handleTabChange(tab.name)"
36 - :class="['px-4 py-2 cursor-pointer transition-colors relative', activeTab === tab.name ? 'text-green-600 font-medium' : 'text-gray-600']" 32 + :ref="el => setTabRef(tab.name, el)"
37 - @click="handleTabChange(tab.name)" 33 + >
38 - :ref="el => setTabRef(tab.name, el)" 34 + {{ tab.title }}
39 - >
40 - {{ tab.title }}
41 - </div>
42 - <div
43 - class="absolute bottom-0 left-0 bg-green-600 transition-all duration-300 z-20"
44 - :style="indicatorStyle"
45 - ></div>
46 </div> 35 </div>
36 + <div
37 + class="absolute bottom-0 left-0 bg-green-600 transition-all duration-300 z-20"
38 + :style="indicatorStyle"
39 + ></div>
47 </div> 40 </div>
48 </div> 41 </div>
49 42
50 - <!-- 滚动区域:详情、目录和互动内容 --> 43 + <div id="detail" class="py-4 px-4">
51 - <div ref="scroll_container_ref" class="overflow-y-auto flex-1" 44 + <div v-if="course?.feature">
52 - :style="{ paddingTop: topWrapperHeight, height: 'calc(100vh - ' + topWrapperHeight + ')' }"> 45 + <div class="text-black text-xl font-bold mb-2">课程特色</div>
53 - <!-- 详情区域 --> 46 + <div class="text-gray-700 text-sm leading-relaxed" v-html="course?.feature"></div>
54 - <div id="detail" class="py-4 px-4"> 47 + <br />
55 - <div v-if="course?.feature"> 48 + </div>
56 - <div class="text-black text-xl font-bold mb-2">课程特色</div> 49 + <div v-if="course?.highlights">
57 - <div class="text-gray-700 text-sm leading-relaxed" v-html="course?.feature"></div> 50 + <div class="text-black text-xl font-bold mb-2">课程亮点</div>
58 - <br /> 51 + <div class="text-gray-700 text-sm leading-relaxed" v-html="course?.highlights"></div>
59 - </div> 52 + <br />
60 - <div v-if="course?.highlights">
61 - <div class="text-black text-xl font-bold mb-2">课程亮点</div>
62 - <div class="text-gray-700 text-sm leading-relaxed" v-html="course?.highlights"></div>
63 - <br />
64 - </div>
65 - <div v-if="course?.learning_goal">
66 - <div class="text-black text-xl font-bold mb-2">学习目标</div>
67 - <div class="text-gray-700 text-sm leading-relaxed" v-html="course?.learning_goal"></div>
68 - <br />
69 - </div>
70 - <van-empty v-if="!course?.feature && !course?.highlights && course?.learning_goal" description="暂无详情" />
71 </div> 53 </div>
54 + <div v-if="course?.learning_goal">
55 + <div class="text-black text-xl font-bold mb-2">学习目标</div>
56 + <div class="text-gray-700 text-sm leading-relaxed" v-html="course?.learning_goal"></div>
57 + <br />
58 + </div>
59 + <van-empty v-if="!course?.feature && !course?.highlights && course?.learning_goal" description="暂无详情" />
60 + </div>
72 61
73 - <div class="h-2 bg-gray-100"></div> 62 + <div class="h-2 bg-gray-100"></div>
74 - 63 +
75 - <!-- 目录区域 --> 64 + <div id="catalog" class="py-4">
76 - <div id="catalog" class="py-4"> 65 + <div v-if="course_lessons.length" class="space-y-4">
77 - <div v-if="course_lessons.length" class="space-y-4"> 66 + <div v-for="(lesson, index) in course_lessons" :key="index"
78 - <div v-for="(lesson, index) in course_lessons" :key="index" 67 + @click="goToStudyDetail(lesson.id)"
79 - @click="goToStudyDetail(lesson.id)" 68 + class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative">
80 - class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative"> 69 + <div class="text-black text-base font-medium mb-2">{{ lesson.title }}</div>
81 - <!-- <div v-if="lesson.progress > 0 && lesson.progress < 100" 70 + <div class="flex items-center text-sm text-gray-500">
82 - class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded"> 71 + <span>{{ course_type_maps[lesson.course_type] }}</span>
83 - 没有字段=>上次看到</div> --> 72 + <span v-if="course_type_maps[lesson.course_type]" class="mx-2">|</span>
84 - <div class="text-black text-base font-medium mb-2">{{ lesson.title }}</div> 73 + <span>开课时间: {{ lesson.schedule_time ? dayjs(lesson.schedule_time).format('YYYY-MM-DD') : '暂无' }}</span>
85 - <div class="flex items-center text-sm text-gray-500"> 74 + <span class="mx-2">|</span>
86 - <span>{{ course_type_maps[lesson.course_type] }}</span> 75 + <span v-if="lesson.duration">建议时长: {{ lesson.duration }} 分钟</span>
87 - <span v-if="course_type_maps[lesson.course_type]" class="mx-2">|</span>
88 - <span>开课时间: {{ lesson.schedule_time ? dayjs(lesson.schedule_time).format('YYYY-MM-DD') : '暂无' }}</span>
89 - <span class="mx-2">|</span>
90 - <span v-if="lesson.duration">建议时长: {{ lesson.duration }} 分钟</span>
91 - <!-- <span class="mx-2">|</span> -->
92 - <!-- <span>没有字段=> 已学习{{ lesson?.progress }}%</span> -->
93 - </div>
94 </div> 76 </div>
95 </div> 77 </div>
96 - <van-empty v-else description="暂无目录" />
97 </div> 78 </div>
79 + <van-empty v-else description="暂无目录" />
80 + </div>
81 +
82 + <div class="h-2 bg-gray-100"></div>
98 83
99 - <div class="h-2 bg-gray-100"></div> 84 + <div id="interaction" class="py-4 px-4" v-if="task_list.length > 0">
100 - 85 + <div class="bg-white rounded-lg p-4 mb-4 cursor-pointer">
101 - <!-- 互动区域 --> 86 + <div class="flex items-center justify-between" @click="goToCheckin()">
102 - <div id="interaction" class="py-4 px-4" v-if="task_list.length > 0"> 87 + <div class="flex items-center gap-3">
103 - <div class="bg-white rounded-lg p-4 mb-4 cursor-pointer"> 88 + <van-icon size="3rem" name="calendar-o" class="text-xl text-gray-600" />
104 - <div class="flex items-center justify-between" @click="goToCheckin()"> 89 + <div>
105 - <div class="flex items-center gap-3"> 90 + <div class="text-base font-medium">打卡</div>
106 - <van-icon size="3rem" name="calendar-o" class="text-xl text-gray-600" /> 91 + <div class="text-sm text-gray-500">关联{{ task_list.length }}个打卡</div>
107 - <div>
108 - <div class="text-base font-medium">打卡</div>
109 - <div class="text-sm text-gray-500">关联{{ task_list.length }}个打卡</div>
110 - </div>
111 </div> 92 </div>
112 - <van-icon name="arrow" class="text-gray-400" />
113 </div> 93 </div>
94 + <van-icon name="arrow" class="text-gray-400" />
114 </div> 95 </div>
115 </div> 96 </div>
116 - <!-- 添加底部填充区域 -->
117 - <div style="height: 30vh;"></div>
118 </div> 97 </div>
98 +
99 + <div style="height: 30vh;"></div>
119 </div> 100 </div>
120 101
121 <!-- 打卡弹窗:统一使用 CheckInDialog 组件 --> 102 <!-- 打卡弹窗:统一使用 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 = () => { 233 + nextTick(() => {
254 - setTimeout(() => { 234 + const el = tabs_wrapper_ref.value;
255 - nextTick(() => { 235 + if (!el) return;
256 - const topWrapper = document.querySelector('.top-wrapper'); 236 + tabs_wrapper_height.value = el.offsetHeight || 0;
257 - if (topWrapper) { 237 + if (tabs_wrapper_resize_observer.value) {
258 - // 断开之前的observer连接 238 + tabs_wrapper_resize_observer.value.disconnect();
259 - if (resizeObserver.value) { 239 + }
260 - resizeObserver.value.disconnect(); 240 + tabs_wrapper_resize_observer.value = new ResizeObserver(() => {
261 - } 241 + tabs_wrapper_height.value = el.offsetHeight || 0;
262 - // 使用 ResizeObserver 监听元素尺寸变化
263 - resizeObserver.value = new ResizeObserver(() => {
264 - topWrapperHeight.value = `${topWrapper.offsetHeight}px`;
265 - });
266 - resizeObserver.value.observe(topWrapper);
267 - }
268 }); 242 });
269 - }, 500) 243 + tabs_wrapper_resize_observer.value.observe(el);
244 + });
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);
......