hookehuyr

feat(Home): 实现流程步骤分页显示和响应式箭头定位

添加流程步骤分页功能,每屏最多显示7个步骤,支持横向滑动浏览
优化底部箭头在不同屏幕尺寸下的定位,添加窗口resize监听
使用占位项保持分页间距一致,改进移动端滚动体验
...@@ -36,14 +36,20 @@ ...@@ -36,14 +36,20 @@
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对齐 -->
42 + <div
43 + class="process-steps"
44 + v-for="(page, pindex) in stepPages"
45 + :key="pindex"
46 + >
41 <div 47 <div
42 - v-for="(step, index) in processSteps" 48 + v-for="(step, sindex) in page"
43 - :key="index" 49 + :key="pindex + '-' + sindex"
44 class="process-step" 50 class="process-step"
45 - :class="{ active: step.active }" 51 + :class="{ active: step.active, placeholder: step.is_placeholder }"
46 - @click="selectStep(index)" 52 + @click="selectStep(pindex * steps_per_page + sindex)"
47 > 53 >
48 <!-- 顶部白色圆形占位(选中状态) --> 54 <!-- 顶部白色圆形占位(选中状态) -->
49 <div class="top-circle"></div> 55 <div class="top-circle"></div>
...@@ -53,6 +59,7 @@ ...@@ -53,6 +59,7 @@
53 <span class="step-text">{{ step.name }}</span> 59 <span class="step-text">{{ step.name }}</span>
54 </div> 60 </div>
55 </div> 61 </div>
62 + </div>
56 <!-- 全局底部箭头:跟随选中项移动 --> 63 <!-- 全局底部箭头:跟随选中项移动 -->
57 <div v-show="processSteps.length" class="bottom-arrow moving-arrow" :style="arrowStyle"> 64 <div v-show="processSteps.length" class="bottom-arrow moving-arrow" :style="arrowStyle">
58 <img src="https://cdn.ipadbiz.cn/stdj/images/home/%E7%82%B9@2x.png" alt="选中" /> 65 <img src="https://cdn.ipadbiz.cn/stdj/images/home/%E7%82%B9@2x.png" alt="选中" />
...@@ -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 {
......