refactor(日历组件): 优化日历高度计算和滚动隐藏逻辑
使用 ResizeObserver 替代手动计算日历高度 添加滚动时隐藏日历头部的动画效果
Showing
2 changed files
with
87 additions
and
20 deletions
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2025-01-25 15:34:17 | 2 | * @Date: 2025-01-25 15:34:17 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-12-18 22:13:17 | 4 | + * @LastEditTime: 2025-12-18 22:33:09 |
| 5 | * @FilePath: /mlaj/src/components/ui/CollapsibleCalendar.vue | 5 | * @FilePath: /mlaj/src/components/ui/CollapsibleCalendar.vue |
| 6 | * @Description: 可折叠日历组件 | 6 | * @Description: 可折叠日历组件 |
| 7 | --> | 7 | --> |
| 8 | <template> | 8 | <template> |
| 9 | <div class="collapsible-calendar "> | 9 | <div class="collapsible-calendar "> |
| 10 | <!-- 折叠状态显示 --> | 10 | <!-- 折叠状态显示 --> |
| 11 | - <div class="calendar-collapsed"> | 11 | + <div class="calendar-collapsed" :class="{ 'is-compact': isHeaderHidden }"> |
| 12 | <div class="calendar-header"> | 12 | <div class="calendar-header"> |
| 13 | <div class="calendar-icon"> | 13 | <div class="calendar-icon"> |
| 14 | <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | 14 | <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| ... | @@ -29,7 +29,7 @@ | ... | @@ -29,7 +29,7 @@ |
| 29 | <div class="calendar-date-display" @click="expandCalendar"> | 29 | <div class="calendar-date-display" @click="expandCalendar"> |
| 30 | <div class="calendar-date-main">{{ formattedCurrentDate }}</div> | 30 | <div class="calendar-date-main">{{ formattedCurrentDate }}</div> |
| 31 | <div class="calendar-weekday">{{ formattedWeekday }}</div> | 31 | <div class="calendar-weekday">{{ formattedWeekday }}</div> |
| 32 | - <div class="text-xs text-gray-500 mt-1">点击切换日期</div> | 32 | + <div class="text-xs text-gray-500 mt-1 collapsible-text">点击切换日期</div> |
| 33 | </div> | 33 | </div> |
| 34 | <!-- <div class="calendar-action"> | 34 | <!-- <div class="calendar-action"> |
| 35 | <div class="calendar-action-text">指定日期</div> | 35 | <div class="calendar-action-text">指定日期</div> |
| ... | @@ -49,7 +49,7 @@ | ... | @@ -49,7 +49,7 @@ |
| 49 | </svg> | 49 | </svg> |
| 50 | </div> | 50 | </div> |
| 51 | </div> | 51 | </div> |
| 52 | - <div v-if="selectedSubtask" class="text-xs text-gray-500 mt-1 cursor-pointer hover:text-green-600 transition-colors" @click.stop="openRulesPopup">点击查看打卡规则</div> | 52 | + <div v-if="selectedSubtask" class="text-xs text-gray-500 mt-1 cursor-pointer hover:text-green-600 transition-colors collapsible-text" @click.stop="openRulesPopup">点击查看打卡规则</div> |
| 53 | </div> | 53 | </div> |
| 54 | </div> | 54 | </div> |
| 55 | </div> | 55 | </div> |
| ... | @@ -119,6 +119,7 @@ | ... | @@ -119,6 +119,7 @@ |
| 119 | 119 | ||
| 120 | <script setup> | 120 | <script setup> |
| 121 | import { ref, computed, watch, onMounted } from 'vue' | 121 | import { ref, computed, watch, onMounted } from 'vue' |
| 122 | +import { useScroll } from '@vueuse/core' | ||
| 122 | import dayjs from 'dayjs' | 123 | import dayjs from 'dayjs' |
| 123 | 124 | ||
| 124 | // Props定义 | 125 | // Props定义 |
| ... | @@ -155,6 +156,44 @@ const selectedCourseText = ref('全部作业') | ... | @@ -155,6 +156,44 @@ const selectedCourseText = ref('全部作业') |
| 155 | const selectedCourseId = ref('') | 156 | const selectedCourseId = ref('') |
| 156 | const showRulesPopup = ref(false) | 157 | const showRulesPopup = ref(false) |
| 157 | 158 | ||
| 159 | +const { y } = useScroll(window) | ||
| 160 | +const isHeaderHidden = ref(false) | ||
| 161 | +const lastY = ref(0) | ||
| 162 | + | ||
| 163 | +watch(y, (newY) => { | ||
| 164 | + // 忽略弹性滚动造成的负值 | ||
| 165 | + if (newY < 0) return | ||
| 166 | + | ||
| 167 | + // 获取文档高度和窗口高度,用于判断是否触底 | ||
| 168 | + const windowHeight = window.innerHeight | ||
| 169 | + const documentHeight = document.documentElement.scrollHeight | ||
| 170 | + const bottomThreshold = 20 // 触底阈值 | ||
| 171 | + | ||
| 172 | + // 如果接近底部,不执行隐藏逻辑(避免回弹导致的显示) | ||
| 173 | + if (newY + windowHeight >= documentHeight - bottomThreshold) { | ||
| 174 | + // 如果已经在底部,保持之前的状态,或者强制隐藏(根据需求,通常保持当前状态更稳妥,或者强制隐藏因为是在向下浏览) | ||
| 175 | + // 这里选择保持状态,但更新lastY防止下次误判 | ||
| 176 | + lastY.value = newY | ||
| 177 | + return | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + // 滚动方向判定 | ||
| 181 | + const direction = newY > lastY.value ? 'down' : 'up' | ||
| 182 | + const scrollThreshold = 50 // 滚动阈值 | ||
| 183 | + | ||
| 184 | + if (newY > scrollThreshold && direction === 'down') { | ||
| 185 | + isHeaderHidden.value = true | ||
| 186 | + } else if (direction === 'up') { | ||
| 187 | + // 只有当向上滚动超过一定距离或者不在底部反弹区域时才显示 | ||
| 188 | + // 这里的判断其实已经被上面的触底检测覆盖了一部分,但为了保险,可以加一个最小变动阈值 | ||
| 189 | + if (Math.abs(newY - lastY.value) > 5) { | ||
| 190 | + isHeaderHidden.value = false | ||
| 191 | + } | ||
| 192 | + } | ||
| 193 | + | ||
| 194 | + lastY.value = newY | ||
| 195 | +}) | ||
| 196 | + | ||
| 158 | const courseColumns = computed(() => { | 197 | const courseColumns = computed(() => { |
| 159 | return [ | 198 | return [ |
| 160 | { text: '全部作业', value: '' }, | 199 | { text: '全部作业', value: '' }, |
| ... | @@ -335,11 +374,39 @@ defineExpose({ | ... | @@ -335,11 +374,39 @@ defineExpose({ |
| 335 | transition: all 0.1s ease; | 374 | transition: all 0.1s ease; |
| 336 | } | 375 | } |
| 337 | 376 | ||
| 377 | + // 紧凑模式下的样式控制 | ||
| 378 | + &.is-compact { | ||
| 379 | + .calendar-header { | ||
| 380 | + max-height: 0; | ||
| 381 | + margin-bottom: 0; | ||
| 382 | + opacity: 0; | ||
| 383 | + pointer-events: none; | ||
| 384 | + } | ||
| 385 | + | ||
| 386 | + .collapsible-text { | ||
| 387 | + max-height: 0; | ||
| 388 | + margin-top: 0; | ||
| 389 | + opacity: 0; | ||
| 390 | + overflow: hidden; | ||
| 391 | + } | ||
| 392 | + } | ||
| 393 | + | ||
| 338 | .calendar-header { | 394 | .calendar-header { |
| 339 | display: flex; | 395 | display: flex; |
| 340 | align-items: center; | 396 | align-items: center; |
| 341 | margin-bottom: 16px; | 397 | margin-bottom: 16px; |
| 342 | gap: 12px; | 398 | gap: 12px; |
| 399 | + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | ||
| 400 | + max-height: 100px; | ||
| 401 | + opacity: 1; | ||
| 402 | + overflow: hidden; | ||
| 403 | + | ||
| 404 | + &.is-hidden { | ||
| 405 | + max-height: 0; | ||
| 406 | + margin-bottom: 0; | ||
| 407 | + opacity: 0; | ||
| 408 | + pointer-events: none; | ||
| 409 | + } | ||
| 343 | 410 | ||
| 344 | .calendar-icon { | 411 | .calendar-icon { |
| 345 | display: flex; | 412 | display: flex; |
| ... | @@ -458,6 +525,12 @@ defineExpose({ | ... | @@ -458,6 +525,12 @@ defineExpose({ |
| 458 | } | 525 | } |
| 459 | } | 526 | } |
| 460 | 527 | ||
| 528 | +.collapsible-text { | ||
| 529 | + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | ||
| 530 | + max-height: 20px; // 假设文字高度不超过20px | ||
| 531 | + opacity: 1; | ||
| 532 | +} | ||
| 533 | + | ||
| 461 | .calendar-popup-content { | 534 | .calendar-popup-content { |
| 462 | height: 100%; | 535 | height: 100%; |
| 463 | overflow: hidden; | 536 | overflow: hidden; | ... | ... |
| ... | @@ -9,7 +9,7 @@ | ... | @@ -9,7 +9,7 @@ |
| 9 | <AppLayout :hasTitle="false"> | 9 | <AppLayout :hasTitle="false"> |
| 10 | <van-config-provider :theme-vars="themeVars"> | 10 | <van-config-provider :theme-vars="themeVars"> |
| 11 | <!-- 固定的日历组件 --> | 11 | <!-- 固定的日历组件 --> |
| 12 | - <div class="fixed-calendar"> | 12 | + <div class="fixed-calendar" ref="fixedCalendarWrapper"> |
| 13 | <CollapsibleCalendar | 13 | <CollapsibleCalendar |
| 14 | ref="calendarRef" | 14 | ref="calendarRef" |
| 15 | :title="taskDetail.title" | 15 | :title="taskDetail.title" |
| ... | @@ -146,7 +146,7 @@ import FrostedGlass from "@/components/ui/FrostedGlass.vue"; | ... | @@ -146,7 +146,7 @@ import FrostedGlass from "@/components/ui/FrostedGlass.vue"; |
| 146 | import CollapsibleCalendar from "@/components/ui/CollapsibleCalendar.vue"; | 146 | import CollapsibleCalendar from "@/components/ui/CollapsibleCalendar.vue"; |
| 147 | import PostCountModel from "@/components/count/postCountModel.vue"; | 147 | import PostCountModel from "@/components/count/postCountModel.vue"; |
| 148 | import CheckinCard from "@/components/checkin/CheckinCard.vue"; | 148 | import CheckinCard from "@/components/checkin/CheckinCard.vue"; |
| 149 | -import { useTitle } from '@vueuse/core'; | 149 | +import { useTitle, useResizeObserver } from '@vueuse/core'; |
| 150 | import dayjs from 'dayjs'; | 150 | import dayjs from 'dayjs'; |
| 151 | 151 | ||
| 152 | import { getTaskDetailAPI, getUploadTaskListAPI, delUploadTaskInfoAPI, likeUploadTaskInfoAPI, dislikeUploadTaskInfoAPI } from "@/api/checkin"; | 152 | import { getTaskDetailAPI, getUploadTaskListAPI, delUploadTaskInfoAPI, likeUploadTaskInfoAPI, dislikeUploadTaskInfoAPI } from "@/api/checkin"; |
| ... | @@ -167,18 +167,17 @@ const windowWidth = ref(window.innerWidth); | ... | @@ -167,18 +167,17 @@ const windowWidth = ref(window.innerWidth); |
| 167 | 167 | ||
| 168 | // 日历高度相关的响应式数据 | 168 | // 日历高度相关的响应式数据 |
| 169 | const calendarRef = ref(null); | 169 | const calendarRef = ref(null); |
| 170 | +const fixedCalendarWrapper = ref(null); | ||
| 170 | const calendarHeight = ref(200); // 默认高度 | 171 | const calendarHeight = ref(200); // 默认高度 |
| 171 | 172 | ||
| 172 | -/** | 173 | +// 使用 ResizeObserver 监听日历容器高度变化 |
| 173 | - * 动态获取日历组件的实际高度 | 174 | +useResizeObserver(fixedCalendarWrapper, (entries) => { |
| 174 | - */ | 175 | + const entry = entries[0]; |
| 175 | -const updateCalendarHeight = async () => { | 176 | + if (entry && entry.target) { |
| 176 | - await nextTick(); | 177 | + // 使用 getBoundingClientRect 获取包含 padding 和 border 的完整高度 |
| 177 | - if (calendarRef.value) { | 178 | + calendarHeight.value = entry.target.getBoundingClientRect().height; |
| 178 | - const rect = calendarRef.value.$el.getBoundingClientRect(); | ||
| 179 | - calendarHeight.value = rect.height; | ||
| 180 | } | 179 | } |
| 181 | -}; | 180 | +}); |
| 182 | 181 | ||
| 183 | /** | 182 | /** |
| 184 | * 监听窗口尺寸变化 | 183 | * 监听窗口尺寸变化 |
| ... | @@ -186,8 +185,6 @@ const updateCalendarHeight = async () => { | ... | @@ -186,8 +185,6 @@ const updateCalendarHeight = async () => { |
| 186 | const handleResize = () => { | 185 | const handleResize = () => { |
| 187 | windowHeight.value = window.innerHeight; | 186 | windowHeight.value = window.innerHeight; |
| 188 | windowWidth.value = window.innerWidth; | 187 | windowWidth.value = window.innerWidth; |
| 189 | - // 重新计算日历高度 | ||
| 190 | - updateCalendarHeight(); | ||
| 191 | }; | 188 | }; |
| 192 | 189 | ||
| 193 | // 组件挂载时添加事件监听 | 190 | // 组件挂载时添加事件监听 |
| ... | @@ -198,9 +195,6 @@ onMounted(() => { | ... | @@ -198,9 +195,6 @@ onMounted(() => { |
| 198 | // 延迟更新,等待方向变化完成 | 195 | // 延迟更新,等待方向变化完成 |
| 199 | setTimeout(handleResize, 100); | 196 | setTimeout(handleResize, 100); |
| 200 | }); | 197 | }); |
| 201 | - | ||
| 202 | - // 初始化时计算日历高度 | ||
| 203 | - updateCalendarHeight(); | ||
| 204 | }); | 198 | }); |
| 205 | 199 | ||
| 206 | // CheckinCard refs | 200 | // CheckinCard refs | ... | ... |
-
Please register or login to post a comment