fix(study-detail): 修复学习详情页标签指示条定位错误问题
修复首次进入且存在“打卡互动”时底部绿色指示条定位错误的问题 新增标签容器ref与ResizeObserver,按栏目数量对容器进行等分 指示条宽度与位移按分段和索引计算,异步加载第三个栏目时不再错位 同时优化打卡互动标签的显示条件,仅在存在任务时显示
Showing
4 changed files
with
128 additions
and
23 deletions
| ... | @@ -2,7 +2,7 @@ | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | ||
| 3 | 测试环境网站 | 3 | 测试环境网站 |
| 4 | https://oa-dev.onwall.cn/f/mlaj | 4 | https://oa-dev.onwall.cn/f/mlaj |
| 5 | - | 5 | + |
| 6 | 功能更新记录 | 6 | 功能更新记录 |
| 7 | - 教师端新增作业管理页面:路径 `/teacher/tasks`,标题“作业管理”。 | 7 | - 教师端新增作业管理页面:路径 `/teacher/tasks`,标题“作业管理”。 |
| 8 | - 列表展示:作业名称、开始时间、截止时间。 | 8 | - 列表展示:作业名称、开始时间、截止时间。 |
| ... | @@ -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 = () => { |
| ... | @@ -259,7 +350,7 @@ onMounted(async () => { | ... | @@ -259,7 +350,7 @@ onMounted(async () => { |
| 259 | course.value = data; | 350 | course.value = data; |
| 260 | task_list.value = []; | 351 | task_list.value = []; |
| 261 | timeout_task_list.value = []; | 352 | timeout_task_list.value = []; |
| 262 | - | 353 | + |
| 263 | // 处理task_list数据格式 | 354 | // 处理task_list数据格式 |
| 264 | if (data.task_list) { | 355 | if (data.task_list) { |
| 265 | data.task_list.forEach(item => { | 356 | data.task_list.forEach(item => { |
| ... | @@ -271,7 +362,7 @@ onMounted(async () => { | ... | @@ -271,7 +362,7 @@ onMounted(async () => { |
| 271 | }); | 362 | }); |
| 272 | }); | 363 | }); |
| 273 | } | 364 | } |
| 274 | - | 365 | + |
| 275 | // 处理timeout_task_list数据格式 | 366 | // 处理timeout_task_list数据格式 |
| 276 | if (data.timeout_task_list) { | 367 | if (data.timeout_task_list) { |
| 277 | data.timeout_task_list.forEach(item => { | 368 | data.timeout_task_list.forEach(item => { |
| ... | @@ -283,7 +374,7 @@ onMounted(async () => { | ... | @@ -283,7 +374,7 @@ onMounted(async () => { |
| 283 | }); | 374 | }); |
| 284 | }); | 375 | }); |
| 285 | } | 376 | } |
| 286 | - | 377 | + |
| 287 | course_lessons.value = data.schedule || []; | 378 | course_lessons.value = data.schedule || []; |
| 288 | default_list.value = task_list.value; | 379 | default_list.value = task_list.value; |
| 289 | showTaskList.value = true; | 380 | showTaskList.value = true; |
| ... | @@ -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 | ... | ... |
-
Please register or login to post a comment