hookehuyr

fix(study-detail): 修复学习详情页标签指示条定位错误问题

修复首次进入且存在“打卡互动”时底部绿色指示条定位错误的问题
新增标签容器ref与ResizeObserver,按栏目数量对容器进行等分
指示条宽度与位移按分段和索引计算,异步加载第三个栏目时不再错位
同时优化打卡互动标签的显示条件,仅在存在任务时显示
...@@ -16,3 +16,6 @@ https://oa-dev.onwall.cn/f/mlaj ...@@ -16,3 +16,6 @@ https://oa-dev.onwall.cn/f/mlaj
16 - 在作业主页的学生列表点击卡片可跳转至该页面(当前版本为固定示例页面)。 16 - 在作业主页的学生列表点击卡片可跳转至该页面(当前版本为固定示例页面)。
17 - 列表展示:作业帖子、图片/视频/音频、点赞与点评弹窗(与 `studentPage.vue` 的作业记录样式一致)。 17 - 列表展示:作业帖子、图片/视频/音频、点赞与点评弹窗(与 `studentPage.vue` 的作业记录样式一致)。
18 - 接口参数固定:`user_id=817017``group_id=816653`(后续可替换为动态参数)。 18 - 接口参数固定:`user_id=817017``group_id=816653`(后续可替换为动态参数)。
19 + - 学习详情页标签指示条修复:`/src/views/profile/StudyCoursePage.vue`
20 + - 现象:首次进入且存在“打卡互动”时,底部绿色指示条定位错误。
21 + - 修复:新增标签容器 `ref``ResizeObserver`,按栏目数量对容器进行等分,指示条宽度与位移按分段和索引计算,异步加载第三个栏目时不再错位。
......
...@@ -104,7 +104,7 @@ ...@@ -104,7 +104,7 @@
104 </div> 104 </div>
105 </div> 105 </div>
106 106
107 - <div v-if="activeTab === '打卡互动'"> 107 + <div v-if="activeTab === '打卡互动' && task_list.length > 0">
108 <!-- 打卡区域 --> 108 <!-- 打卡区域 -->
109 <div class="py-4"> 109 <div class="py-4">
110 <div class="bg-white rounded-lg p-4 mb-4 cursor-pointer"> 110 <div class="bg-white rounded-lg p-4 mb-4 cursor-pointer">
...@@ -428,7 +428,7 @@ const curriculumItems = computed(() => { ...@@ -428,7 +428,7 @@ const curriculumItems = computed(() => {
428 { title: '课程大纲', active: activeTab.value === '课程大纲', show: !!(course.value.schedule && course.value.schedule.length > 0) }, 428 { title: '课程大纲', active: activeTab.value === '课程大纲', show: !!(course.value.schedule && course.value.schedule.length > 0) },
429 // { title: '课程亮点', active: activeTab.value === '课程亮点', show: !!course.value.highlights }, 429 // { title: '课程亮点', active: activeTab.value === '课程亮点', show: !!course.value.highlights },
430 // { title: '学习目标', active: activeTab.value === '学习目标', show: !!course.value.learning_goal }, 430 // { title: '学习目标', active: activeTab.value === '学习目标', show: !!course.value.learning_goal },
431 - { title: '打卡互动', active: activeTab.value === '打卡互动', show: !!course.value.is_buy }, 431 + { title: '打卡互动', active: activeTab.value === '打卡互动', show: !!course.value.is_buy && task_list.value.length > 0 },
432 ].filter(item => item.show); 432 ].filter(item => item.show);
433 }); 433 });
434 434
......
...@@ -28,23 +28,20 @@ ...@@ -28,23 +28,20 @@
28 28
29 <!-- 标签页区域 --> 29 <!-- 标签页区域 -->
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)` } : {}"> 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"> 31 + <div class="flex justify-around items-center relative" ref="tabs_container_ref">
32 + <!-- 动态标签:详情、目录,任务存在时显示打卡互动 -->
32 <div 33 <div
33 - v-for="(tab, index) in [{title: '详情', name: 'detail'}, {title: '目录', name: 'catalog'}, {title: '课程互动', name: 'interaction'}]" 34 + v-for="(tab, index) in tabs"
34 :key="tab.name" 35 :key="tab.name"
35 :class="['px-4 py-2 cursor-pointer transition-colors relative', activeTab === tab.name ? 'text-green-600 font-medium' : 'text-gray-600']" 36 :class="['px-4 py-2 cursor-pointer transition-colors relative', activeTab === tab.name ? 'text-green-600 font-medium' : 'text-gray-600']"
36 @click="handleTabChange(tab.name)" 37 @click="handleTabChange(tab.name)"
37 - ref="tabRefs" 38 + :ref="el => setTabRef(tab.name, el)"
38 > 39 >
39 {{ tab.title }} 40 {{ tab.title }}
40 </div> 41 </div>
41 <div 42 <div
42 class="absolute bottom-0 left-0 bg-green-600 transition-all duration-300 z-20" 43 class="absolute bottom-0 left-0 bg-green-600 transition-all duration-300 z-20"
43 - :style="{ 44 + :style="indicatorStyle"
44 - width: tabRefs && tabRefs[activeTab === 'detail' ? 0 : activeTab === 'catalog' ? 1 : 2]?.offsetWidth + 'px',
45 - transform: `translateX(${tabRefs && tabRefs[activeTab === 'detail' ? 0 : activeTab === 'catalog' ? 1 : 2]?.offsetLeft}px)`,
46 - height: '2px',
47 - }"
48 ></div> 45 ></div>
49 </div> 46 </div>
50 </div> 47 </div>
...@@ -102,7 +99,7 @@ ...@@ -102,7 +99,7 @@
102 <div class="h-2 bg-gray-100"></div> 99 <div class="h-2 bg-gray-100"></div>
103 100
104 <!-- 互动区域 --> 101 <!-- 互动区域 -->
105 - <div id="interaction" class="py-4 px-4"> 102 + <div id="interaction" class="py-4 px-4" v-if="task_list.length > 0">
106 <div class="bg-white rounded-lg p-4 mb-4 cursor-pointer"> 103 <div class="bg-white rounded-lg p-4 mb-4 cursor-pointer">
107 <div class="flex items-center justify-between" @click="goToCheckin()"> 104 <div class="flex items-center justify-between" @click="goToCheckin()">
108 <div class="flex items-center gap-3"> 105 <div class="flex items-center gap-3">
...@@ -190,7 +187,7 @@ ...@@ -190,7 +187,7 @@
190 </template> 187 </template>
191 188
192 <script setup> 189 <script setup>
193 -import { ref, onMounted, nextTick, onUnmounted } from 'vue'; 190 +import { ref, onMounted, nextTick, onUnmounted, computed } from 'vue';
194 import { useTitle } from '@vueuse/core'; 191 import { useTitle } from '@vueuse/core';
195 import { useRouter } from "vue-router"; 192 import { useRouter } from "vue-router";
196 import dayjs from 'dayjs'; 193 import dayjs from 'dayjs';
...@@ -210,7 +207,101 @@ useTitle('课程详情'); ...@@ -210,7 +207,101 @@ useTitle('课程详情');
210 const activeTab = ref('detail'); 207 const activeTab = ref('detail');
211 const topWrapperHeight = ref(0); 208 const topWrapperHeight = ref(0);
212 const resizeObserver = ref(null); 209 const resizeObserver = ref(null);
210 +// tabs DOM映射
213 const tabRefs = ref([]); 211 const tabRefs = ref([]);
212 +const tabElMap = ref({});
213 +
214 +/**
215 + * 设置标签引用映射
216 + * @param {string} name - 标签名称
217 + * @param {HTMLElement} el - 对应的元素
218 + * 注释:用于根据激活标签定位指示条位置
219 + */
220 +const setTabRef = (name, el) => {
221 + if (el) {
222 + tabElMap.value[name] = el;
223 + }
224 +};
225 +
226 +/**
227 + * 计算标签配置
228 + * @returns {Array<{title:string,name:string}>} 标签数组(含条件展示的打卡互动)
229 + * 注释:当存在打卡任务时,追加“打卡互动”标签
230 + */
231 +const tabs = computed(() => {
232 + const base = [
233 + { title: '课程详情', name: 'detail' },
234 + { title: '课程目录', name: 'catalog' },
235 + ];
236 + if (task_list.value && task_list.value.length > 0) {
237 + base.push({ title: '打卡互动', name: 'interaction' });
238 + }
239 + return base;
240 +});
241 +
242 +/**
243 + * 当前激活标签的索引
244 + * @returns {number} 当前标签在 tabs 中的索引
245 + * 注释:用于计算底部指示条的宽度和位置
246 + */
247 +const currentTabIndex = computed(() => {
248 + return tabs.value.findIndex((t) => t.name === activeTab.value);
249 +});
250 +
251 +// 标签容器引用与尺寸
252 +const tabs_container_ref = ref(null);
253 +const tabs_container_width = ref(0);
254 +const tabs_resize_observer = ref(null);
255 +
256 +/**
257 + * 初始化并监听标签容器宽度
258 + * @returns {void}
259 + * 注释:确保在首次渲染和尺寸变化时能正确计算分段宽度
260 + */
261 +const initTabsContainerWidth = () => {
262 + nextTick(() => {
263 + const el = tabs_container_ref.value;
264 + if (!el) return;
265 + tabs_container_width.value = el.clientWidth;
266 + if (tabs_resize_observer.value) {
267 + tabs_resize_observer.value.disconnect();
268 + }
269 + tabs_resize_observer.value = new ResizeObserver(() => {
270 + tabs_container_width.value = el.clientWidth;
271 + });
272 + tabs_resize_observer.value.observe(el);
273 + });
274 +};
275 +
276 +/**
277 + * 指示条样式计算
278 + * @returns {{width:string, transform:string, height:string}} 指示条宽度与位置样式
279 + * 注释:优先按栏目数量进行分段计算,缺省时回退到DOM测量
280 + */
281 +const indicatorStyle = computed(() => {
282 + const count = tabs.value.length || 1;
283 + const index = currentTabIndex.value >= 0 ? currentTabIndex.value : 0;
284 +
285 + // 优先使用容器分段宽度进行计算
286 + if (tabs_container_width.value > 0 && count > 0) {
287 + const segment = tabs_container_width.value / count;
288 + return {
289 + width: segment + 'px',
290 + transform: `translateX(${segment * index}px)`,
291 + height: '2px',
292 + };
293 + }
294 +
295 + // 回退:使用激活标签的实际尺寸
296 + const el = tabElMap.value[activeTab.value];
297 + const width = el ? el.offsetWidth : 0;
298 + const left = el ? el.offsetLeft : 0;
299 + return {
300 + width: width + 'px',
301 + transform: `translateX(${left}px)`,
302 + height: '2px',
303 + };
304 +});
214 305
215 // 计算topWrapperHeight的函数 306 // 计算topWrapperHeight的函数
216 const updateTopWrapperHeight = () => { 307 const updateTopWrapperHeight = () => {
...@@ -297,6 +388,8 @@ onMounted(async () => { ...@@ -297,6 +388,8 @@ onMounted(async () => {
297 window.addEventListener('resize', updateTopWrapperHeight); 388 window.addEventListener('resize', updateTopWrapperHeight);
298 // 确保在组件挂载后计算高度 389 // 确保在组件挂载后计算高度
299 updateTopWrapperHeight(); 390 updateTopWrapperHeight();
391 + // 初始化标签容器宽度监听
392 + initTabsContainerWidth();
300 }); 393 });
301 394
302 // 在组件卸载时移除监听器 395 // 在组件卸载时移除监听器
...@@ -308,6 +401,11 @@ onUnmounted(() => { ...@@ -308,6 +401,11 @@ onUnmounted(() => {
308 resizeObserver.value.disconnect(); 401 resizeObserver.value.disconnect();
309 resizeObserver.value = null; 402 resizeObserver.value = null;
310 } 403 }
404 + // 取消标签容器监听
405 + if (tabs_resize_observer.value) {
406 + tabs_resize_observer.value.disconnect();
407 + tabs_resize_observer.value = null;
408 + }
311 }); 409 });
312 410
313 // 处理滚动事件 411 // 处理滚动事件
...@@ -339,12 +437,16 @@ const debounce = (fn, delay) => { ...@@ -339,12 +437,16 @@ const debounce = (fn, delay) => {
339 }; 437 };
340 438
341 // 修改handleScroll函数 439 // 修改handleScroll函数
440 +/**
441 + * 处理滚动联动,更新标签激活态
442 + * 注释:当互动区域不存在时,仅在详情与目录之间联动
443 + */
342 const handleScroll = debounce(() => { 444 const handleScroll = debounce(() => {
343 const detailElement = document.getElementById('detail'); 445 const detailElement = document.getElementById('detail');
344 const catalogElement = document.getElementById('catalog'); 446 const catalogElement = document.getElementById('catalog');
345 const interactionElement = document.getElementById('interaction'); 447 const interactionElement = document.getElementById('interaction');
346 const tabElement = document.querySelector('.py-3.bg-white'); 448 const tabElement = document.querySelector('.py-3.bg-white');
347 - if (!detailElement || !catalogElement || !interactionElement || !tabElement) return; 449 + if (!detailElement || !catalogElement || !tabElement) return;
348 450
349 const currentScrollY = window.scrollY; 451 const currentScrollY = window.scrollY;
350 isTabFixed.value = currentScrollY >= tabOriginalTop.value; 452 isTabFixed.value = currentScrollY >= tabOriginalTop.value;
...@@ -353,20 +455,20 @@ const handleScroll = debounce(() => { ...@@ -353,20 +455,20 @@ const handleScroll = debounce(() => {
353 const tabHeight = tabElement.offsetHeight; 455 const tabHeight = tabElement.offsetHeight;
354 const buffer = 50; // 缓冲区域 456 const buffer = 50; // 缓冲区域
355 457
356 - // 计算每个区域的位置,考虑固定标签页的高度 458 + // 计算各区域顶部位置(互动不存在则设为无穷大)
357 const detailTop = detailElement.offsetTop - tabHeight - buffer; 459 const detailTop = detailElement.offsetTop - tabHeight - buffer;
358 const catalogTop = catalogElement.offsetTop - tabHeight - buffer; 460 const catalogTop = catalogElement.offsetTop - tabHeight - buffer;
359 - const interactionTop = interactionElement.offsetTop - tabHeight - buffer; 461 + const interactionTop = interactionElement ? (interactionElement.offsetTop - tabHeight - buffer) : Infinity;
360 462
361 - // 获取页面总高度和视口高度 463 + // 页面高度与底部判断
362 const scrollHeight = document.documentElement.scrollHeight; 464 const scrollHeight = document.documentElement.scrollHeight;
363 const clientHeight = document.documentElement.clientHeight; 465 const clientHeight = document.documentElement.clientHeight;
364 const isAtBottom = scrollTop + clientHeight >= scrollHeight - buffer; 466 const isAtBottom = scrollTop + clientHeight >= scrollHeight - buffer;
365 467
366 - // 判断当前滚动位置所在区域 468 + // 联动判断
367 if (scrollTop <= detailTop) { 469 if (scrollTop <= detailTop) {
368 activeTab.value = 'detail'; 470 activeTab.value = 'detail';
369 - } else if (isAtBottom || scrollTop >= interactionTop) { 471 + } else if (interactionElement && (isAtBottom || scrollTop >= interactionTop)) {
370 activeTab.value = 'interaction'; 472 activeTab.value = 'interaction';
371 } else if (scrollTop >= catalogTop && scrollTop < interactionTop) { 473 } else if (scrollTop >= catalogTop && scrollTop < interactionTop) {
372 activeTab.value = 'catalog'; 474 activeTab.value = 'catalog';
......
...@@ -81,7 +81,7 @@ ...@@ -81,7 +81,7 @@
81 <template #title>评论({{ commentCount }})</template> 81 <template #title>评论({{ commentCount }})</template>
82 </van-tab> 82 </van-tab>
83 </van-tabs> 83 </van-tabs>
84 - <div @click="goToCheckin" style="position: absolute; right: 1rem; top: 1.5rem; font-size: 0.875rem; color: #666;">课程互动</div> 84 + <div v-if="task_list.length > 0" @click="goToCheckin" style="position: absolute; right: 1rem; top: 1.5rem; font-size: 0.875rem; color: #666;">打卡互动</div>
85 </div> 85 </div>
86 </div> 86 </div>
87 87
......