hookehuyr

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

修复首次进入且存在“打卡互动”时底部绿色指示条定位错误的问题
新增标签容器ref与ResizeObserver,按栏目数量对容器进行等分
指示条宽度与位移按分段和索引计算,异步加载第三个栏目时不再错位
同时优化打卡互动标签的显示条件,仅在存在任务时显示
......@@ -2,7 +2,7 @@
测试环境网站
https://oa-dev.onwall.cn/f/mlaj
功能更新记录
- 教师端新增作业管理页面:路径 `/teacher/tasks`,标题“作业管理”。
- 列表展示:作业名称、开始时间、截止时间。
......@@ -16,3 +16,6 @@ https://oa-dev.onwall.cn/f/mlaj
- 在作业主页的学生列表点击卡片可跳转至该页面(当前版本为固定示例页面)。
- 列表展示:作业帖子、图片/视频/音频、点赞与点评弹窗(与 `studentPage.vue` 的作业记录样式一致)。
- 接口参数固定:`user_id=817017``group_id=816653`(后续可替换为动态参数)。
- 学习详情页标签指示条修复:`/src/views/profile/StudyCoursePage.vue`
- 现象:首次进入且存在“打卡互动”时,底部绿色指示条定位错误。
- 修复:新增标签容器 `ref``ResizeObserver`,按栏目数量对容器进行等分,指示条宽度与位移按分段和索引计算,异步加载第三个栏目时不再错位。
......
......@@ -104,7 +104,7 @@
</div>
</div>
<div v-if="activeTab === '打卡互动'">
<div v-if="activeTab === '打卡互动' && task_list.length > 0">
<!-- 打卡区域 -->
<div class="py-4">
<div class="bg-white rounded-lg p-4 mb-4 cursor-pointer">
......@@ -428,7 +428,7 @@ const curriculumItems = computed(() => {
{ title: '课程大纲', active: activeTab.value === '课程大纲', show: !!(course.value.schedule && course.value.schedule.length > 0) },
// { title: '课程亮点', active: activeTab.value === '课程亮点', show: !!course.value.highlights },
// { title: '学习目标', active: activeTab.value === '学习目标', show: !!course.value.learning_goal },
{ title: '打卡互动', active: activeTab.value === '打卡互动', show: !!course.value.is_buy },
{ title: '打卡互动', active: activeTab.value === '打卡互动', show: !!course.value.is_buy && task_list.value.length > 0 },
].filter(item => item.show);
});
......
......@@ -28,23 +28,20 @@
<!-- 标签页区域 -->
<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">
<div class="flex justify-around items-center relative" ref="tabs_container_ref">
<!-- 动态标签:详情、目录,任务存在时显示打卡互动 -->
<div
v-for="(tab, index) in [{title: '详情', name: 'detail'}, {title: '目录', name: 'catalog'}, {title: '课程互动', name: 'interaction'}]"
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="tabRefs"
: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="{
width: tabRefs && tabRefs[activeTab === 'detail' ? 0 : activeTab === 'catalog' ? 1 : 2]?.offsetWidth + 'px',
transform: `translateX(${tabRefs && tabRefs[activeTab === 'detail' ? 0 : activeTab === 'catalog' ? 1 : 2]?.offsetLeft}px)`,
height: '2px',
}"
:style="indicatorStyle"
></div>
</div>
</div>
......@@ -102,7 +99,7 @@
<div class="h-2 bg-gray-100"></div>
<!-- 互动区域 -->
<div id="interaction" class="py-4 px-4">
<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">
......@@ -190,7 +187,7 @@
</template>
<script setup>
import { ref, onMounted, nextTick, onUnmounted } from 'vue';
import { ref, onMounted, nextTick, onUnmounted, computed } from 'vue';
import { useTitle } from '@vueuse/core';
import { useRouter } from "vue-router";
import dayjs from 'dayjs';
......@@ -210,7 +207,101 @@ useTitle('课程详情');
const activeTab = ref('detail');
const topWrapperHeight = ref(0);
const resizeObserver = ref(null);
// tabs DOM映射
const tabRefs = ref([]);
const tabElMap = ref({});
/**
* 设置标签引用映射
* @param {string} name - 标签名称
* @param {HTMLElement} el - 对应的元素
* 注释:用于根据激活标签定位指示条位置
*/
const setTabRef = (name, el) => {
if (el) {
tabElMap.value[name] = el;
}
};
/**
* 计算标签配置
* @returns {Array<{title:string,name:string}>} 标签数组(含条件展示的打卡互动)
* 注释:当存在打卡任务时,追加“打卡互动”标签
*/
const tabs = computed(() => {
const base = [
{ title: '课程详情', name: 'detail' },
{ title: '课程目录', name: 'catalog' },
];
if (task_list.value && task_list.value.length > 0) {
base.push({ title: '打卡互动', name: 'interaction' });
}
return base;
});
/**
* 当前激活标签的索引
* @returns {number} 当前标签在 tabs 中的索引
* 注释:用于计算底部指示条的宽度和位置
*/
const currentTabIndex = computed(() => {
return tabs.value.findIndex((t) => t.name === activeTab.value);
});
// 标签容器引用与尺寸
const tabs_container_ref = ref(null);
const tabs_container_width = ref(0);
const tabs_resize_observer = ref(null);
/**
* 初始化并监听标签容器宽度
* @returns {void}
* 注释:确保在首次渲染和尺寸变化时能正确计算分段宽度
*/
const initTabsContainerWidth = () => {
nextTick(() => {
const el = tabs_container_ref.value;
if (!el) return;
tabs_container_width.value = el.clientWidth;
if (tabs_resize_observer.value) {
tabs_resize_observer.value.disconnect();
}
tabs_resize_observer.value = new ResizeObserver(() => {
tabs_container_width.value = el.clientWidth;
});
tabs_resize_observer.value.observe(el);
});
};
/**
* 指示条样式计算
* @returns {{width:string, transform:string, height:string}} 指示条宽度与位置样式
* 注释:优先按栏目数量进行分段计算,缺省时回退到DOM测量
*/
const indicatorStyle = computed(() => {
const count = tabs.value.length || 1;
const index = currentTabIndex.value >= 0 ? currentTabIndex.value : 0;
// 优先使用容器分段宽度进行计算
if (tabs_container_width.value > 0 && count > 0) {
const segment = tabs_container_width.value / count;
return {
width: segment + 'px',
transform: `translateX(${segment * index}px)`,
height: '2px',
};
}
// 回退:使用激活标签的实际尺寸
const el = tabElMap.value[activeTab.value];
const width = el ? el.offsetWidth : 0;
const left = el ? el.offsetLeft : 0;
return {
width: width + 'px',
transform: `translateX(${left}px)`,
height: '2px',
};
});
// 计算topWrapperHeight的函数
const updateTopWrapperHeight = () => {
......@@ -259,7 +350,7 @@ onMounted(async () => {
course.value = data;
task_list.value = [];
timeout_task_list.value = [];
// 处理task_list数据格式
if (data.task_list) {
data.task_list.forEach(item => {
......@@ -271,7 +362,7 @@ onMounted(async () => {
});
});
}
// 处理timeout_task_list数据格式
if (data.timeout_task_list) {
data.timeout_task_list.forEach(item => {
......@@ -283,7 +374,7 @@ onMounted(async () => {
});
});
}
course_lessons.value = data.schedule || [];
default_list.value = task_list.value;
showTaskList.value = true;
......@@ -297,6 +388,8 @@ onMounted(async () => {
window.addEventListener('resize', updateTopWrapperHeight);
// 确保在组件挂载后计算高度
updateTopWrapperHeight();
// 初始化标签容器宽度监听
initTabsContainerWidth();
});
// 在组件卸载时移除监听器
......@@ -308,6 +401,11 @@ onUnmounted(() => {
resizeObserver.value.disconnect();
resizeObserver.value = null;
}
// 取消标签容器监听
if (tabs_resize_observer.value) {
tabs_resize_observer.value.disconnect();
tabs_resize_observer.value = null;
}
});
// 处理滚动事件
......@@ -339,12 +437,16 @@ const debounce = (fn, delay) => {
};
// 修改handleScroll函数
/**
* 处理滚动联动,更新标签激活态
* 注释:当互动区域不存在时,仅在详情与目录之间联动
*/
const handleScroll = debounce(() => {
const detailElement = document.getElementById('detail');
const catalogElement = document.getElementById('catalog');
const interactionElement = document.getElementById('interaction');
const tabElement = document.querySelector('.py-3.bg-white');
if (!detailElement || !catalogElement || !interactionElement || !tabElement) return;
if (!detailElement || !catalogElement || !tabElement) return;
const currentScrollY = window.scrollY;
isTabFixed.value = currentScrollY >= tabOriginalTop.value;
......@@ -353,20 +455,20 @@ const handleScroll = debounce(() => {
const tabHeight = tabElement.offsetHeight;
const buffer = 50; // 缓冲区域
// 计算每个区域的位置,考虑固定标签页的高度
// 计算各区域顶部位置(互动不存在则设为无穷大)
const detailTop = detailElement.offsetTop - tabHeight - buffer;
const catalogTop = catalogElement.offsetTop - tabHeight - buffer;
const interactionTop = interactionElement.offsetTop - tabHeight - buffer;
const interactionTop = interactionElement ? (interactionElement.offsetTop - tabHeight - buffer) : Infinity;
// 获取页面总高度和视口高度
// 页面高度与底部判断
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - buffer;
// 判断当前滚动位置所在区域
// 联动判断
if (scrollTop <= detailTop) {
activeTab.value = 'detail';
} else if (isAtBottom || scrollTop >= interactionTop) {
} else if (interactionElement && (isAtBottom || scrollTop >= interactionTop)) {
activeTab.value = 'interaction';
} else if (scrollTop >= catalogTop && scrollTop < interactionTop) {
activeTab.value = 'catalog';
......
......@@ -81,7 +81,7 @@
<template #title>评论({{ commentCount }})</template>
</van-tab>
</van-tabs>
<div @click="goToCheckin" style="position: absolute; right: 1rem; top: 1.5rem; font-size: 0.875rem; color: #666;">课程互动</div>
<div v-if="task_list.length > 0" @click="goToCheckin" style="position: absolute; right: 1rem; top: 1.5rem; font-size: 0.875rem; color: #666;">打卡互动</div>
</div>
</div>
......