feat(Home): 实现流程步骤分页显示和响应式箭头定位
添加流程步骤分页功能,每屏最多显示7个步骤,支持横向滑动浏览 优化底部箭头在不同屏幕尺寸下的定位,添加窗口resize监听 使用占位项保持分页间距一致,改进移动端滚动体验
Showing
1 changed file
with
154 additions
and
21 deletions
| ... | @@ -36,21 +36,28 @@ | ... | @@ -36,21 +36,28 @@ |
| 36 | <!-- 麻绳背景 --> | 36 | <!-- 麻绳背景 --> |
| 37 | <div class="rope-background"></div> | 37 | <div class="rope-background"></div> |
| 38 | 38 | ||
| 39 | - <!-- 流程步骤 --> | 39 | + <!-- 流程步骤:每屏最多显示7个,超出可横向滑动 --> |
| 40 | - <div class="process-steps" ref="processStepsRef"> | 40 | + <div class="process-steps-wrapper" ref="processStepsRef"> |
| 41 | + <!-- 单页步骤容器:宽度占满一屏,Scroll Snap对齐 --> | ||
| 41 | <div | 42 | <div |
| 42 | - v-for="(step, index) in processSteps" | 43 | + class="process-steps" |
| 43 | - :key="index" | 44 | + v-for="(page, pindex) in stepPages" |
| 44 | - class="process-step" | 45 | + :key="pindex" |
| 45 | - :class="{ active: step.active }" | 46 | + > |
| 46 | - @click="selectStep(index)" | 47 | + <div |
| 47 | - > | 48 | + v-for="(step, sindex) in page" |
| 48 | - <!-- 顶部白色圆形占位(选中状态) --> | 49 | + :key="pindex + '-' + sindex" |
| 49 | - <div class="top-circle"></div> | 50 | + class="process-step" |
| 50 | - | 51 | + :class="{ active: step.active, placeholder: step.is_placeholder }" |
| 51 | - <!-- 步骤内容 --> | 52 | + @click="selectStep(pindex * steps_per_page + sindex)" |
| 52 | - <div class="step-content"> | 53 | + > |
| 53 | - <span class="step-text">{{ step.name }}</span> | 54 | + <!-- 顶部白色圆形占位(选中状态) --> |
| 55 | + <div class="top-circle"></div> | ||
| 56 | + | ||
| 57 | + <!-- 步骤内容 --> | ||
| 58 | + <div class="step-content"> | ||
| 59 | + <span class="step-text">{{ step.name }}</span> | ||
| 60 | + </div> | ||
| 54 | </div> | 61 | </div> |
| 55 | </div> | 62 | </div> |
| 56 | <!-- 全局底部箭头:跟随选中项移动 --> | 63 | <!-- 全局底部箭头:跟随选中项移动 --> |
| ... | @@ -156,7 +163,7 @@ | ... | @@ -156,7 +163,7 @@ |
| 156 | </template> | 163 | </template> |
| 157 | 164 | ||
| 158 | <script setup> | 165 | <script setup> |
| 159 | -import { ref, computed, nextTick, onMounted, onActivated, watch } from 'vue' | 166 | +import { ref, computed, nextTick, onMounted, onActivated, onUnmounted, watch } from 'vue' |
| 160 | import VideoPlayer from '@/components/VideoPlayer.vue' | 167 | import VideoPlayer from '@/components/VideoPlayer.vue' |
| 161 | import { useTitle } from '@vueuse/core'; | 168 | import { useTitle } from '@vueuse/core'; |
| 162 | import { useRouter } from 'vue-router' | 169 | import { useRouter } from 'vue-router' |
| ... | @@ -187,8 +194,44 @@ const videoOptions = ref({ | ... | @@ -187,8 +194,44 @@ const videoOptions = ref({ |
| 187 | const processSteps = ref([]) | 194 | const processSteps = ref([]) |
| 188 | // 流程步骤容器引用 | 195 | // 流程步骤容器引用 |
| 189 | const processStepsRef = ref(null) | 196 | const processStepsRef = ref(null) |
| 190 | -// 底部箭头的动态样式 | 197 | +// 底部箭头的动态样式(保持在容器内,避免被裁剪) |
| 191 | -const arrowStyle = ref({ left: '50%', bottom: '-1.25rem', transform: 'translateX(-50%)' }) | 198 | +const arrowStyle = ref({ left: '50%', bottom: '0.25rem', transform: 'translateX(-50%)' }) |
| 199 | +// 底部箭头在不同屏幕下的rem偏移值 | ||
| 200 | +const arrowBottomRem = ref(0.25) | ||
| 201 | + | ||
| 202 | +/** | ||
| 203 | + * 处理窗口尺寸变化:更新箭头bottom并重新定位箭头 | ||
| 204 | + * @returns {void} | ||
| 205 | + */ | ||
| 206 | +const handleResize = () => { | ||
| 207 | + updateArrowBottomByScreen() | ||
| 208 | + updateArrowPosition() | ||
| 209 | +} | ||
| 210 | + | ||
| 211 | +// 每页显示的步骤数量(移动端要求最多7个) | ||
| 212 | +const steps_per_page = 7 | ||
| 213 | + | ||
| 214 | +/** | ||
| 215 | + * 计算分页后的步骤数组 | ||
| 216 | + * 说明:将流程步骤按每页7个分组,配合横向滚动实现分页显示 | ||
| 217 | + * @returns {Array<Array>} 分页后的步骤列表 | ||
| 218 | + */ | ||
| 219 | +const stepPages = computed(() => { | ||
| 220 | + const pages = [] | ||
| 221 | + const list = processSteps.value || [] | ||
| 222 | + for (let i = 0; i < list.length; i += steps_per_page) { | ||
| 223 | + const page = list.slice(i, i + steps_per_page) | ||
| 224 | + const pad = steps_per_page - page.length | ||
| 225 | + if (pad > 0) { | ||
| 226 | + for (let k = 0; k < pad; k++) { | ||
| 227 | + // 占位项:用于固定每页7个槽位,保持间距一致 | ||
| 228 | + page.push({ id: `placeholder-${i}-${k}`, name: '', cover: '', active: false, is_placeholder: true }) | ||
| 229 | + } | ||
| 230 | + } | ||
| 231 | + pages.push(page) | ||
| 232 | + } | ||
| 233 | + return pages | ||
| 234 | +}) | ||
| 192 | 235 | ||
| 193 | // 获取当前选中的步骤 | 236 | // 获取当前选中的步骤 |
| 194 | const currentStep = computed(() => { | 237 | const currentStep = computed(() => { |
| ... | @@ -263,12 +306,28 @@ const selectStep = (index) => { | ... | @@ -263,12 +306,28 @@ const selectStep = (index) => { |
| 263 | processSteps.value.forEach((step, i) => { | 306 | processSteps.value.forEach((step, i) => { |
| 264 | step.active = i === index | 307 | step.active = i === index |
| 265 | }) | 308 | }) |
| 309 | + // 根据选中索引滚动到对应页 | ||
| 310 | + const pageIndex = Math.floor(index / steps_per_page) | ||
| 311 | + scrollToPage(pageIndex) | ||
| 266 | // 切换步骤后重新计算装饰线位置 | 312 | // 切换步骤后重新计算装饰线位置 |
| 267 | calculateLinePosition() | 313 | calculateLinePosition() |
| 268 | // 切换步骤后更新底部箭头位置 | 314 | // 切换步骤后更新底部箭头位置 |
| 269 | updateArrowPosition() | 315 | updateArrowPosition() |
| 270 | } | 316 | } |
| 271 | 317 | ||
| 318 | +/** | ||
| 319 | + * 滚动到指定分页 | ||
| 320 | + * 说明:容器按一屏宽度分页,横向滚动到目标页 | ||
| 321 | + * @param {number} pageIndex 目标页索引 | ||
| 322 | + */ | ||
| 323 | +const scrollToPage = (pageIndex) => { | ||
| 324 | + const container = processStepsRef.value | ||
| 325 | + if (!container) return | ||
| 326 | + const rect = container.getBoundingClientRect() | ||
| 327 | + const pageWidth = rect.width | ||
| 328 | + container.scrollTo({ left: pageIndex * pageWidth, behavior: 'smooth' }) | ||
| 329 | +} | ||
| 330 | + | ||
| 272 | // 三师七证数据列表 | 331 | // 三师七证数据列表 |
| 273 | const mastersList = ref([]) | 332 | const mastersList = ref([]) |
| 274 | 333 | ||
| ... | @@ -276,6 +335,11 @@ const mastersList = ref([]) | ... | @@ -276,6 +335,11 @@ const mastersList = ref([]) |
| 276 | onMounted(async () => { | 335 | onMounted(async () => { |
| 277 | // 进入页面时立即回到顶部 | 336 | // 进入页面时立即回到顶部 |
| 278 | ensurePageScrollTop() | 337 | ensurePageScrollTop() |
| 338 | + // 初始化不同屏幕下箭头bottom适配并监听resize | ||
| 339 | + updateArrowBottomByScreen() | ||
| 340 | + if (typeof window !== 'undefined') { | ||
| 341 | + window.addEventListener('resize', handleResize) | ||
| 342 | + } | ||
| 279 | await calculateLinePosition(); | 343 | await calculateLinePosition(); |
| 280 | // 调用接口获取首页数据 | 344 | // 调用接口获取首页数据 |
| 281 | const { code, list } = await homePageAPI(); | 345 | const { code, list } = await homePageAPI(); |
| ... | @@ -324,6 +388,13 @@ onActivated(() => { | ... | @@ -324,6 +388,13 @@ onActivated(() => { |
| 324 | ensurePageScrollTop() | 388 | ensurePageScrollTop() |
| 325 | }) | 389 | }) |
| 326 | 390 | ||
| 391 | +// 组件卸载时清理resize监听 | ||
| 392 | +onUnmounted(() => { | ||
| 393 | + if (typeof window !== 'undefined') { | ||
| 394 | + window.removeEventListener('resize', handleResize) | ||
| 395 | + } | ||
| 396 | +}) | ||
| 397 | + | ||
| 327 | // 监听当前步骤变化,重新计算装饰线位置 | 398 | // 监听当前步骤变化,重新计算装饰线位置 |
| 328 | watch(currentStep, () => { | 399 | watch(currentStep, () => { |
| 329 | calculateLinePosition() | 400 | calculateLinePosition() |
| ... | @@ -524,6 +595,10 @@ const handleNewsClick = (item) => { | ... | @@ -524,6 +595,10 @@ const handleNewsClick = (item) => { |
| 524 | /** | 595 | /** |
| 525 | * 更新底部箭头的位置,使其移动到当前选中步骤下方 | 596 | * 更新底部箭头的位置,使其移动到当前选中步骤下方 |
| 526 | */ | 597 | */ |
| 598 | +/** | ||
| 599 | + * 更新底部箭头的位置,使其移动到当前选中步骤下方 | ||
| 600 | + * @returns {void} | ||
| 601 | + */ | ||
| 527 | const updateArrowPosition = () => { | 602 | const updateArrowPosition = () => { |
| 528 | const containerEl = processStepsRef.value | 603 | const containerEl = processStepsRef.value |
| 529 | if (!containerEl) return | 604 | if (!containerEl) return |
| ... | @@ -542,10 +617,33 @@ const updateArrowPosition = () => { | ... | @@ -542,10 +617,33 @@ const updateArrowPosition = () => { |
| 542 | // 使用px进行精确定位(移动端rem布局下,定位需要像素计算) | 617 | // 使用px进行精确定位(移动端rem布局下,定位需要像素计算) |
| 543 | arrowStyle.value = { | 618 | arrowStyle.value = { |
| 544 | left: `${centerX}px`, | 619 | left: `${centerX}px`, |
| 545 | - bottom: '-1.25rem', | 620 | + // 不同屏幕下适配底部偏移(rem单位) |
| 621 | + bottom: `${arrowBottomRem.value}rem`, | ||
| 546 | transform: 'translateX(-50%)' | 622 | transform: 'translateX(-50%)' |
| 547 | } | 623 | } |
| 548 | } | 624 | } |
| 625 | + | ||
| 626 | +/** | ||
| 627 | + * 依据屏幕宽度适配箭头bottom值 | ||
| 628 | + * 说明:遵循项目rem基准16px,将窗口宽度转换为rem做断点判断 | ||
| 629 | + * - ≤30rem(约≤480px):0.25rem | ||
| 630 | + * - ≤48rem(约≤768px):0.3rem | ||
| 631 | + * - >48rem:0.5rem | ||
| 632 | + * @returns {void} | ||
| 633 | + */ | ||
| 634 | +const updateArrowBottomByScreen = () => { | ||
| 635 | + let wRem = 48 | ||
| 636 | + if (typeof window !== 'undefined') { | ||
| 637 | + wRem = (window.innerWidth || 768) / 16 | ||
| 638 | + } | ||
| 639 | + if (wRem <= 30) { | ||
| 640 | + arrowBottomRem.value = 0 | ||
| 641 | + } else if (wRem <= 48) { | ||
| 642 | + arrowBottomRem.value = -0.25 | ||
| 643 | + } else { | ||
| 644 | + arrowBottomRem.value = -0.5 | ||
| 645 | + } | ||
| 646 | +} | ||
| 549 | </script> | 647 | </script> |
| 550 | 648 | ||
| 551 | <style scoped> | 649 | <style scoped> |
| ... | @@ -598,7 +696,7 @@ const updateArrowPosition = () => { | ... | @@ -598,7 +696,7 @@ const updateArrowPosition = () => { |
| 598 | /* 响应式设计 */ | 696 | /* 响应式设计 */ |
| 599 | @media (max-width: 768px) { | 697 | @media (max-width: 768px) { |
| 600 | .ceremony-process { | 698 | .ceremony-process { |
| 601 | - padding: 2rem 1rem; | 699 | + padding: 2rem 1rem 0.25rem; |
| 602 | } | 700 | } |
| 603 | 701 | ||
| 604 | .ceremony-intro { | 702 | .ceremony-intro { |
| ... | @@ -957,13 +1055,38 @@ const updateArrowPosition = () => { | ... | @@ -957,13 +1055,38 @@ const updateArrowPosition = () => { |
| 957 | gap: 0.5rem; | 1055 | gap: 0.5rem; |
| 958 | } | 1056 | } |
| 959 | 1057 | ||
| 1058 | +/* 流程步骤分页容器:横向滚动,每屏一页 */ | ||
| 1059 | +.process-steps-wrapper { | ||
| 1060 | + position: relative; | ||
| 1061 | + width: 100%; | ||
| 1062 | + display: flex; | ||
| 1063 | + overflow-x: auto; | ||
| 1064 | + scroll-snap-type: x mandatory; | ||
| 1065 | + -webkit-overflow-scrolling: touch; | ||
| 1066 | + /* 底部留白,确保容器高度包含箭头展示空间 */ | ||
| 1067 | + padding-top: 0.5rem; | ||
| 1068 | + padding-bottom: 1rem; | ||
| 1069 | +} | ||
| 1070 | + | ||
| 1071 | +/* 隐藏移动端滚动条以优化视觉 */ | ||
| 1072 | +.process-steps-wrapper::-webkit-scrollbar { | ||
| 1073 | + display: none; | ||
| 1074 | +} | ||
| 1075 | + | ||
| 1076 | +/* 单页占满一屏,滚动对齐 */ | ||
| 1077 | +.process-steps-wrapper .process-steps { | ||
| 1078 | + flex: 0 0 100%; | ||
| 1079 | + scroll-snap-align: start; | ||
| 1080 | +} | ||
| 1081 | + | ||
| 960 | /* 单个流程步骤 */ | 1082 | /* 单个流程步骤 */ |
| 961 | .process-step { | 1083 | .process-step { |
| 962 | position: relative; | 1084 | position: relative; |
| 963 | display: flex; | 1085 | display: flex; |
| 964 | flex-direction: column; | 1086 | flex-direction: column; |
| 965 | align-items: center; | 1087 | align-items: center; |
| 966 | - flex: 1; | 1088 | + /* 固定占位,避免因数量不足导致单页拉伸 */ |
| 1089 | + flex: 0 0 auto; | ||
| 967 | cursor: pointer; | 1090 | cursor: pointer; |
| 968 | transition: transform 0.2s ease; | 1091 | transition: transform 0.2s ease; |
| 969 | } | 1092 | } |
| ... | @@ -1043,6 +1166,16 @@ const updateArrowPosition = () => { | ... | @@ -1043,6 +1166,16 @@ const updateArrowPosition = () => { |
| 1043 | pointer-events: none; | 1166 | pointer-events: none; |
| 1044 | } | 1167 | } |
| 1045 | 1168 | ||
| 1169 | +/* 占位项:隐藏内容但占据空间,保持间距统一 */ | ||
| 1170 | +.process-step.placeholder .step-content { | ||
| 1171 | + visibility: hidden; | ||
| 1172 | + pointer-events: none; | ||
| 1173 | +} | ||
| 1174 | + | ||
| 1175 | +.process-step.placeholder .top-circle { | ||
| 1176 | + visibility: hidden; | ||
| 1177 | +} | ||
| 1178 | + | ||
| 1046 | /* 响应式调整 */ | 1179 | /* 响应式调整 */ |
| 1047 | @media (max-width: 48rem) { | 1180 | @media (max-width: 48rem) { |
| 1048 | .ceremony-process { | 1181 | .ceremony-process { |
| ... | @@ -1077,7 +1210,7 @@ const updateArrowPosition = () => { | ... | @@ -1077,7 +1210,7 @@ const updateArrowPosition = () => { |
| 1077 | 1210 | ||
| 1078 | @media (max-width: 30rem) { | 1211 | @media (max-width: 30rem) { |
| 1079 | .ceremony-process { | 1212 | .ceremony-process { |
| 1080 | - padding: 2rem 0.25rem; | 1213 | + padding: 2rem 1rem 0.5rem; |
| 1081 | } | 1214 | } |
| 1082 | 1215 | ||
| 1083 | .ceremony-title img { | 1216 | .ceremony-title img { | ... | ... |
-
Please register or login to post a comment